diff --git a/src/tracksdata/graph/_base_graph.py b/src/tracksdata/graph/_base_graph.py index c9be6b47..a52cb8ab 100644 --- a/src/tracksdata/graph/_base_graph.py +++ b/src/tracksdata/graph/_base_graph.py @@ -26,6 +26,7 @@ ) from tracksdata.utils._logging import LOG from tracksdata.utils._multiprocessing import multiprocessing_apply +from tracksdata.utils._numpy_native import is_int_like, to_native if TYPE_CHECKING: import motile @@ -2403,9 +2404,9 @@ def __getitem__(self, node_id: int) -> "NodeInterface": NodeInterface Interface for accessing the node's attributes. """ - if not isinstance(node_id, int): + if not is_int_like(node_id): raise ValueError(f"node_id must be an integer, found '{node_id}' of type {type(node_id)}") - return NodeInterface(self._graph, node_id) + return NodeInterface(self._graph, to_native(node_id)) class EdgesAccessor: @@ -2435,9 +2436,9 @@ def __getitem__(self, edge_id: int) -> "EdgeInterface": EdgeInterface Interface for accessing the edge's attributes. """ - if not isinstance(edge_id, int): + if not is_int_like(edge_id): raise ValueError(f"edge_id must be an integer, found '{edge_id}' of type {type(edge_id)}") - return EdgeInterface(self._graph, edge_id) + return EdgeInterface(self._graph, to_native(edge_id)) class NodeInterface: diff --git a/src/tracksdata/graph/_graph_view.py b/src/tracksdata/graph/_graph_view.py index dfe7d8c8..cd34f431 100644 --- a/src/tracksdata/graph/_graph_view.py +++ b/src/tracksdata/graph/_graph_view.py @@ -14,6 +14,7 @@ from tracksdata.graph._rustworkx_graph import IndexedRXGraph, RustWorkXGraph, RXFilter from tracksdata.graph.filters._indexed_filter import IndexRXFilter from tracksdata.utils._dtypes import AttrSchema +from tracksdata.utils._numpy_native import is_int_like, to_native, to_native_list from tracksdata.utils._signal import ( emit_node_added_events, emit_node_removed_events, @@ -598,10 +599,7 @@ def bulk_remove_nodes(self, node_ids: Sequence[int]) -> None: ValueError If any node_id does not exist in the graph. """ - if hasattr(node_ids, "tolist"): - node_ids = node_ids.tolist() - else: - node_ids = list(node_ids) + node_ids = to_native_list(node_ids) if len(node_ids) == 0: return @@ -822,10 +820,7 @@ def bulk_remove_edges(self, edge_ids: Sequence[int]) -> None: ValueError If any edge_id does not exist in the root graph. """ - if hasattr(edge_ids, "tolist"): - edge_ids = edge_ids.tolist() - else: - edge_ids = list(edge_ids) + edge_ids = to_native_list(edge_ids) if len(edge_ids) == 0: return @@ -986,8 +981,8 @@ def _get_neighbors( single_node = False if node_ids is None: node_ids = self.node_ids() - elif isinstance(node_ids, int): - node_ids = [node_ids] + elif is_int_like(node_ids): + node_ids = [to_native(node_ids)] single_node = True local_node_ids = self._map_to_local(node_ids) @@ -1334,8 +1329,8 @@ def in_degree(self, node_ids: list[int] | int | None = None) -> list[int] | int: if node_ids is None: node_ids = self.node_ids() rx_graph = self.rx_graph - if isinstance(node_ids, int): - return rx_graph.in_degree(self._map_to_local(node_ids)) + if is_int_like(node_ids): + return rx_graph.in_degree(self._map_to_local(to_native(node_ids))) return [rx_graph.in_degree(self._map_to_local(node_id)) for node_id in node_ids] def out_degree(self, node_ids: list[int] | int | None = None) -> list[int] | int: @@ -1345,8 +1340,8 @@ def out_degree(self, node_ids: list[int] | int | None = None) -> list[int] | int if node_ids is None: node_ids = self.node_ids() rx_graph = self.rx_graph - if isinstance(node_ids, int): - return rx_graph.out_degree(self._map_to_local(node_ids)) + if is_int_like(node_ids): + return rx_graph.out_degree(self._map_to_local(to_native(node_ids))) return [rx_graph.out_degree(self._map_to_local(node_id)) for node_id in node_ids] def dividing_nodes(self) -> list[int]: diff --git a/src/tracksdata/graph/_rustworkx_graph.py b/src/tracksdata/graph/_rustworkx_graph.py index 98247e7c..790136bb 100644 --- a/src/tracksdata/graph/_rustworkx_graph.py +++ b/src/tracksdata/graph/_rustworkx_graph.py @@ -23,6 +23,7 @@ from tracksdata.utils._dataframe import unpack_array_attrs from tracksdata.utils._dtypes import AttrSchema, process_attr_key_args from tracksdata.utils._logging import LOG +from tracksdata.utils._numpy_native import is_int_like, to_native, to_native_list from tracksdata.utils._signal import ( emit_node_added_events, emit_node_removed_events, @@ -180,8 +181,8 @@ def __init__( self._graph = graph self._attr_comps = attr_comps - if node_ids is not None and hasattr(node_ids, "tolist"): - node_ids = node_ids.tolist() + if node_ids is not None: + node_ids = to_native_list(node_ids) self._node_ids = node_ids self._include_targets = include_targets @@ -695,10 +696,7 @@ def bulk_remove_nodes(self, node_ids: Sequence[int]) -> None: ValueError If any node_id does not exist in the graph. """ - if hasattr(node_ids, "tolist"): - node_ids = node_ids.tolist() - else: - node_ids = list(node_ids) + node_ids = to_native_list(node_ids) if len(node_ids) == 0: return @@ -754,10 +752,7 @@ def bulk_remove_edges(self, edge_ids: Sequence[int]) -> None: ValueError If any edge_id does not exist in the graph. """ - if hasattr(edge_ids, "tolist"): - edge_ids = edge_ids.tolist() - else: - edge_ids = list(edge_ids) + edge_ids = to_native_list(edge_ids) if len(edge_ids) == 0: return self._bulk_remove_edges_local(edge_ids) @@ -845,8 +840,8 @@ def _get_neighbors( rx_graph = self.rx_graph if node_ids is None: node_ids = list(rx_graph.node_indices()) - elif isinstance(node_ids, int): - node_ids = [node_ids] + elif is_int_like(node_ids): + node_ids = [to_native(node_ids)] single_node = True if not return_attrs and attr_keys is not None: @@ -1467,7 +1462,7 @@ def assign_tracklet_ids( "Often used from `graph.subgraph(edge_attr_filter={'solution': True})`" ) from e - # Converting to list of int for SQLGraph compatibility (See below) + # A list, not the numpy array, so the id remapping below can index into it tracklet_ids = tracklet_ids.tolist() # For the IndexedRXGraph, we need to map the track_node_ids to the external node ids @@ -1496,9 +1491,7 @@ def assign_tracklet_ids( tracklet_id_map = dict( zip(tracklet_id_map[output_key + "_new"], tracklet_id_map[output_key], strict=True) ) - # Ensure that the result is a list of integers (using numpy integer causes issues with SQLGraph) - # Later on, we will make it safe to use numpy integers everywhere for updating attributes. - tracklet_ids = [int(tracklet_id_map.get(tid, tid)) for tid in tracklet_ids] # type: ignore + tracklet_ids = [tracklet_id_map.get(tid, tid) for tid in tracklet_ids] # type: ignore # Update the value with the reused IDs id_update_df = id_update_df.with_columns(pl.Series(output_key + "_new", tracklet_ids)) @@ -1523,8 +1516,8 @@ def in_degree(self, node_ids: list[int] | int | None = None) -> list[int] | int: if node_ids is None: node_ids = self.node_ids() rx_graph = self.rx_graph - if isinstance(node_ids, int): - return rx_graph.in_degree(node_ids) + if is_int_like(node_ids): + return rx_graph.in_degree(to_native(node_ids)) return [rx_graph.in_degree(node_id) for node_id in node_ids] def out_degree(self, node_ids: list[int] | int | None = None) -> list[int] | int: @@ -1534,8 +1527,8 @@ def out_degree(self, node_ids: list[int] | int | None = None) -> list[int] | int if node_ids is None: node_ids = self.node_ids() rx_graph = self.rx_graph - if isinstance(node_ids, int): - return rx_graph.out_degree(node_ids) + if is_int_like(node_ids): + return rx_graph.out_degree(to_native(node_ids)) return [rx_graph.out_degree(node_id) for node_id in node_ids] def dividing_nodes(self) -> list[int]: @@ -2094,10 +2087,7 @@ def bulk_remove_nodes(self, node_ids: Sequence[int]) -> None: ValueError If any node_id does not exist in the graph. """ - if hasattr(node_ids, "tolist"): - node_ids = node_ids.tolist() - else: - node_ids = list(node_ids) + node_ids = to_native_list(node_ids) if len(node_ids) == 0: return diff --git a/src/tracksdata/graph/_sql_graph.py b/src/tracksdata/graph/_sql_graph.py index d4ab8663..a8a3b28e 100644 --- a/src/tracksdata/graph/_sql_graph.py +++ b/src/tracksdata/graph/_sql_graph.py @@ -43,6 +43,7 @@ sqlalchemy_type_to_polars_dtype, ) from tracksdata.utils._logging import LOG +from tracksdata.utils._numpy_native import is_int_like, to_native, to_native_list from tracksdata.utils._signal import ( emit_node_added_events, emit_node_removed_events, @@ -66,11 +67,7 @@ def _data_numpy_to_native(data: dict[str, Any]) -> None: """ Convert numpy scalars to native Python scalars in place. - Database drivers do not know about numpy scalar types. ``sqlite3``, for example, - falls back to the buffer protocol and stores ``np.int64(7)`` as its raw - little-endian byte buffer (a BLOB), silently corrupting a column declared as - ``BIGINT``. Numpy floats and strings happen to survive because they subclass - their Python counterparts, which makes the corruption look selective. + See :func:`tracksdata.utils._numpy_native.to_native` for why drivers need this. Parameters ---------- @@ -78,10 +75,7 @@ def _data_numpy_to_native(data: dict[str, Any]) -> None: The data to convert. Modified in place. """ for k, v in data.items(): - # `np.generic` is the base class of every numpy scalar, and excludes - # (0-dim) arrays, which must be passed through untouched. - if isinstance(v, np.generic): - data[k] = v.item() + data[k] = to_native(v) def _normalize_updated_value(value: Any, schema: AttrSchema | None) -> Any: @@ -146,7 +140,11 @@ def _to_sql_clause(f: Filter, table: type[DeclarativeBase]) -> Any: struct-field comparisons resolve to the flat physical column. """ if isinstance(f, AttrComparison): - return f.op(_resolve_attr_filter_column(table, f), f.other) + # The compared value is bound as a SQL parameter, so numpy scalars (including + # the sequence of them that `is_in` takes) must be converted first. + other = f.other + other = to_native_list(other) if isinstance(other, list | tuple | np.ndarray) else to_native(other) + return f.op(_resolve_attr_filter_column(table, f), other) assert isinstance(f, AttrFilter) if f.op == "not": @@ -200,9 +198,7 @@ def __init__( *, occurrences: int = 1, ) -> None: - if hasattr(ids, "tolist"): - ids = ids.tolist() - self._ids: list[int] = list(ids) + self._ids: list[int] = to_native_list(ids) # Hold the engine, not the graph, so this set does not participate in # the graph -> SQLFilter -> _SQLIDSet -> graph reference cycle. # Otherwise the scratch table would only be dropped after Python's @@ -1122,10 +1118,7 @@ def bulk_remove_nodes(self, node_ids: Sequence[int]) -> None: ValueError If any node_id does not exist in the graph. """ - if hasattr(node_ids, "tolist"): - node_ids = node_ids.tolist() - else: - node_ids = list(node_ids) + node_ids = to_native_list(node_ids) if len(node_ids) == 0: return @@ -1277,10 +1270,10 @@ def bulk_add_overlaps( [add_overlap][tracksdata.graph.SQLGraph.add_overlap]: Add a single overlap to the graph. """ - if hasattr(overlaps, "tolist"): - overlaps = overlaps.tolist() - - overlaps = [{"source_id": int(source_id), "target_id": int(target_id)} for source_id, target_id in overlaps] + # `overlaps` is a nested sequence, so each id is converted individually + overlaps = [ + {"source_id": to_native(source_id), "target_id": to_native(target_id)} for source_id, target_id in overlaps + ] self._chunked_sa_write(Session.bulk_insert_mappings, overlaps, self.Overlap) def overlaps( @@ -1297,8 +1290,8 @@ def overlaps( filtered in Polars afterwards to avoid a quadratic blow-up of bound parameters. """ - if hasattr(node_ids, "tolist"): - node_ids = node_ids.tolist() + if node_ids is not None: + node_ids = to_native_list(node_ids) with Session(self._engine) as session: base_query = session.query(self.Overlap.source_id, self.Overlap.target_id) @@ -1367,14 +1360,15 @@ def _get_neighbors( """ single_node = False filter_node_ids: list[int] | None - if isinstance(node_ids, int): - node_ids = [node_ids] + if is_int_like(node_ids): + node_ids = [to_native(node_ids)] filter_node_ids = node_ids single_node = True elif node_ids is None: node_ids = self.node_ids() filter_node_ids = None else: + node_ids = to_native_list(node_ids) filter_node_ids = node_ids if isinstance(attr_keys, str): @@ -2081,8 +2075,7 @@ def _update_table( LOG.info("No ids to update, skipping") return - if hasattr(ids, "tolist"): - ids = ids.tolist() + ids = to_native_list(ids) # Handle array values with bulk_update_mappings schemas = self._attr_schemas_for_table(table_class) @@ -2327,8 +2320,8 @@ def _get_degree( ) -> list[int] | int: edge_key_col = getattr(self.Edge, node_key) - if isinstance(node_ids, int): - stmt = sa.select(sa.func.count()).where(edge_key_col == node_ids) + if is_int_like(node_ids): + stmt = sa.select(sa.func.count()).where(edge_key_col == to_native(node_ids)) with Session(self._engine) as session: return int(session.execute(stmt).scalar()) @@ -2339,6 +2332,7 @@ def _get_degree( if node_ids is None: degree.update(session.execute(base_stmt).all()) else: + node_ids = to_native_list(node_ids) # Chunk the IN(...) so the bound-parameter count stays below # the backend's limit (notably SQLite's # ``SQLITE_MAX_VARIABLE_NUMBER``). Each chunk's group-by result @@ -2662,7 +2656,7 @@ def has_node(self, node_id: int) -> bool: Check if the graph has a node with the given id. """ with Session(self._engine) as session: - return session.scalar(sa.sql.expression.exists().where(self.Node.node_id == node_id).select()) + return session.scalar(sa.sql.expression.exists().where(self.Node.node_id == to_native(node_id)).select()) def has_edge(self, source_id: int, target_id: int) -> bool: """ @@ -2671,7 +2665,7 @@ def has_edge(self, source_id: int, target_id: int) -> bool: with Session(self._engine) as session: return ( session.query(self.Edge) - .filter(self.Edge.source_id == source_id, self.Edge.target_id == target_id) + .filter(self.Edge.source_id == to_native(source_id), self.Edge.target_id == to_native(target_id)) .count() > 0 ) @@ -2683,7 +2677,7 @@ def edge_id(self, source_id: int, target_id: int) -> int: with Session(self._engine) as session: edge_id = ( session.query(self.Edge.edge_id) - .filter(self.Edge.source_id == source_id, self.Edge.target_id == target_id) + .filter(self.Edge.source_id == to_native(source_id), self.Edge.target_id == to_native(target_id)) .scalar() ) if edge_id is None: @@ -2704,10 +2698,7 @@ def bulk_remove_edges(self, edge_ids: Sequence[int]) -> None: ValueError If any edge_id does not exist in the graph. """ - if hasattr(edge_ids, "tolist"): - edge_ids = edge_ids.tolist() - else: - edge_ids = list(edge_ids) + edge_ids = to_native_list(edge_ids) if len(edge_ids) == 0: return diff --git a/src/tracksdata/graph/_test/test_graph_backends.py b/src/tracksdata/graph/_test/test_graph_backends.py index 9c77d471..4b4b31c1 100644 --- a/src/tracksdata/graph/_test/test_graph_backends.py +++ b/src/tracksdata/graph/_test/test_graph_backends.py @@ -172,6 +172,113 @@ def test_valid_attr_keys_are_not_rejected(graph_backend: BaseGraph) -> None: assert graph_backend.filter(EdgeAttr("w") == 1.0).edge_ids() == [ids["e"]] +def _numpy_id_graph(graph: BaseGraph) -> dict[str, int]: + """A 3-node / 2-edge chain with one int node attr and one int edge attr.""" + graph.add_node_attr_key("area", dtype=pl.Float64, default_value=0.0) + graph.add_node_attr_key("label", dtype=pl.Int64, default_value=0) + graph.add_edge_attr_key("rank", dtype=pl.Int64, default_value=0) + a = graph.add_node({"t": 0, "area": 1.0, "label": 10}) + b = graph.add_node({"t": 1, "area": 2.0, "label": 20}) + c = graph.add_node({"t": 2, "area": 3.0, "label": 30}) + e_ab = graph.add_edge(a, b, {"rank": 0}) + e_bc = graph.add_edge(b, c, {"rank": 1}) + graph.add_overlap(b, c) + return {"a": a, "b": b, "c": c, "e_ab": e_ab, "e_bc": e_bc} + + +# `np.int64` is not a subclass of `int`, and numpy integers leak out of nearly every +# numpy / polars / scikit-image operation, so they reach the graph API constantly. +# Database drivers do not know them: `sqlite3` falls back to the buffer protocol and +# binds `np.int64(7)` as an 8-byte BLOB, so `node_id == np.int64(7)` matched nothing and +# lookups silently reported "not found" instead of raising. Every read path below must +# therefore give the same answer for a Python int and for the equal numpy integer. +_NUMPY_ID_ACCESSORS: dict[str, Callable[[BaseGraph, dict[str, int], Callable[[int], Any]], Any]] = { + "has_node": lambda g, i, c: g.has_node(c(i["a"])), + "has_edge": lambda g, i, c: g.has_edge(c(i["a"]), c(i["b"])), + "edge_id": lambda g, i, c: g.edge_id(c(i["a"]), c(i["b"])), + "successors(scalar)": lambda g, i, c: g.successors(c(i["a"])), + "successors(list)": lambda g, i, c: g.successors([c(i["a"])]), + "predecessors(scalar)": lambda g, i, c: g.predecessors(c(i["b"])), + "predecessors(list)": lambda g, i, c: g.predecessors([c(i["b"])]), + "in_degree(scalar)": lambda g, i, c: g.in_degree(c(i["b"])), + "in_degree(list)": lambda g, i, c: g.in_degree([c(i["b"])]), + "out_degree(list)": lambda g, i, c: g.out_degree([c(i["a"])]), + "filter(node_ids).node_ids": lambda g, i, c: sorted(g.filter(node_ids=[c(i["a"])]).node_ids()), + "filter(node_ids).edge_ids": lambda g, i, c: sorted(g.filter(node_ids=[c(i["a"]), c(i["b"])]).edge_ids()), + "filter(node_ids).node_attrs": lambda g, i, c: ( + g.filter(node_ids=[c(i["a"])]).node_attrs(attr_keys=["t"]).to_dicts() + ), + "filter(node_ids).subgraph": lambda g, i, c: sorted( + g.filter(node_ids=[c(i["a"]), c(i["b"])]).subgraph().node_ids() + ), + "filter(NodeAttr == value)": lambda g, i, c: sorted(g.filter(NodeAttr("label") == c(20)).node_ids()), + "filter(EdgeAttr == value)": lambda g, i, c: sorted(g.filter(EdgeAttr("rank") == c(1)).edge_ids()), + "filter(NodeAttr is_in values)": lambda g, i, c: sorted( + g.filter(NodeAttr("label").is_in([c(10), c(20)])).node_ids() + ), + "nodes[id]": lambda g, i, c: g.nodes[c(i["a"])]["area"], + "edges[id]": lambda g, i, c: g.edges[c(i["e_ab"])]["rank"], + # `overlaps` keeps only pairs with *both* endpoints in `node_ids` + "overlaps": lambda g, i, c: g.overlaps([c(i["b"]), c(i["c"])]), +} + + +@pytest.mark.parametrize( + "accessor", + list(_NUMPY_ID_ACCESSORS.values()), + ids=list(_NUMPY_ID_ACCESSORS.keys()), +) +def test_numpy_integer_ids_read_paths( + graph_backend: BaseGraph, + accessor: Callable[[BaseGraph, dict[str, int], Callable[[int], Any]], Any], +) -> None: + """A numpy integer id or filter value behaves exactly like the equal Python int.""" + ids = _numpy_id_graph(graph_backend) + + expected = accessor(graph_backend, ids, int) + assert accessor(graph_backend, ids, np.int64) == expected + + +@pytest.mark.parametrize("cast", [int, np.int64], ids=["int", "np.int64"]) +def test_numpy_integer_ids_write_paths(graph_backend: BaseGraph, cast: Callable[[int], Any]) -> None: + """Mutating calls take effect whether the id is a Python int or a numpy integer.""" + ids = _numpy_id_graph(graph_backend) + + graph_backend.update_node_attrs(node_ids=[cast(ids["a"])], attrs={"area": 9.0}) + assert graph_backend.nodes[ids["a"]]["area"] == 9.0 + + graph_backend.update_edge_attrs(edge_ids=[cast(ids["e_ab"])], attrs={"rank": 7}) + assert graph_backend.edges[ids["e_ab"]]["rank"] == 7 + + new_edge = graph_backend.add_edge(cast(ids["a"]), cast(ids["c"]), {"rank": 2}) + assert graph_backend.has_edge(ids["a"], ids["c"]) + + graph_backend.remove_edge(edge_id=cast(new_edge)) + assert not graph_backend.has_edge(ids["a"], ids["c"]) + + graph_backend.remove_edge(cast(ids["a"]), cast(ids["b"])) + assert not graph_backend.has_edge(ids["a"], ids["b"]) + + graph_backend.remove_node(cast(ids["a"])) + assert not graph_backend.has_node(ids["a"]) + + graph_backend.bulk_remove_nodes([cast(ids["b"])]) + assert not graph_backend.has_node(ids["b"]) + + +def test_numpy_integer_ids_bulk_add(graph_backend: BaseGraph) -> None: + """Bulk adds accept numpy integer ids and store them as native integers.""" + graph_backend.add_edge_attr_key("rank", dtype=pl.Int64, default_value=0) + node_ids = graph_backend.bulk_add_nodes([{"t": 0}, {"t": 1}]) + + graph_backend.bulk_add_edges([{"source_id": np.int64(node_ids[0]), "target_id": np.int64(node_ids[1]), "rank": 0}]) + + assert graph_backend.has_edge(node_ids[0], node_ids[1]) + edge_df = graph_backend.edge_attrs(attr_keys=[DEFAULT_ATTR_KEYS.EDGE_SOURCE, DEFAULT_ATTR_KEYS.EDGE_TARGET]) + assert edge_df[DEFAULT_ATTR_KEYS.EDGE_SOURCE].to_list() == [node_ids[0]] + assert edge_df[DEFAULT_ATTR_KEYS.EDGE_TARGET].to_list() == [node_ids[1]] + + def test_add_node(graph_backend: BaseGraph) -> None: """Test adding nodes with various attributes.""" diff --git a/src/tracksdata/utils/_numpy_native.py b/src/tracksdata/utils/_numpy_native.py new file mode 100644 index 00000000..e19c7ec4 --- /dev/null +++ b/src/tracksdata/utils/_numpy_native.py @@ -0,0 +1,64 @@ +"""Coercion of numpy scalars into the native Python scalars that other libraries understand.""" + +from collections.abc import Sequence +from typing import Any + +import numpy as np + + +def is_int_like(value: Any) -> bool: + """ + Whether ``value`` is a single integer, either a Python ``int`` or a numpy integer. + + ``np.int64`` is not a subclass of ``int``, so a plain ``isinstance(value, int)`` + check rejects the numpy integers that come out of nearly every numpy or polars + operation. Use this wherever a scalar id must be told apart from a sequence of ids. + """ + return isinstance(value, int | np.integer) + + +def to_native(value: Any) -> Any: + """ + Return the native Python equivalent of a numpy scalar, or ``value`` unchanged. + + Database drivers do not know about numpy's scalar types. ``sqlite3``, for example, + falls back to the buffer protocol and binds ``np.int64(7)`` as its raw little-endian + byte buffer (a BLOB), which never compares equal to an ``INTEGER`` column, so queries + silently match no rows instead of raising. Numpy floats and strings happen to survive + because they subclass their Python counterparts, which makes the corruption look + selective. + + Parameters + ---------- + value : Any + The value to convert. Non-numpy values are returned as-is. + + Returns + ------- + Any + ``value.item()`` for numpy scalars, otherwise ``value``. + """ + # `np.generic` is the base class of every numpy scalar, and excludes (0-dim) + # arrays, which must be passed through untouched. + if isinstance(value, np.generic): + return value.item() + return value + + +def to_native_list(values: Sequence[Any] | np.ndarray) -> list[Any]: + """ + Return ``values`` as a list with every numpy scalar converted to native Python. + + Parameters + ---------- + values : Sequence[Any] | np.ndarray + The values to convert. + + Returns + ------- + list[Any] + A new list of native Python scalars. + """ + if isinstance(values, np.ndarray): + return values.tolist() + return [to_native(v) for v in values]