diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b9adc01..adb4894 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -20,19 +20,19 @@ repos: # Replace tabs by whitespaces before committing - id: remove-tabs - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.15.7 + rev: v0.15.8 hooks: - id: ruff-check args: [ --fix, --exit-non-zero-on-fix ] # No official hook yet - repo: https://github.com/NSPBot911/ty-pre-commit - rev: v0.0.25 + rev: v0.0.26 hooks: - id: ty-check language: system pass_filenames: false - repo: https://github.com/astral-sh/uv-pre-commit - rev: 0.11.1 + rev: 0.11.2 hooks: - id: uv-lock - repo: https://github.com/psf/black diff --git a/.run/Run Demo.run.xml b/.run/Run Demo.run.xml new file mode 100644 index 0000000..df35017 --- /dev/null +++ b/.run/Run Demo.run.xml @@ -0,0 +1,26 @@ + + + + + diff --git a/src/graphworks/graph.py b/src/graphworks/graph.py index 3c4f8a3..6045893 100644 --- a/src/graphworks/graph.py +++ b/src/graphworks/graph.py @@ -17,6 +17,13 @@ * a stdlib adjacency matrix (``input_matrix``), or * programmatically via :meth:`add_vertex` / :meth:`add_edge`. +Fluent chaining is supported — :meth:`add_vertex`, :meth:`add_edge`, and :meth:`add_edges` all +return ``self`` so calls can be chained:: + + >>> g = Graph("demo") + >>> g.add_edge("A", "B").add_edge("B", "C").add_edge("C", "A") + Graph('demo', order=3, size=3) + For numpy ``ndarray`` input, convert first with :func:`graphworks.numpy_compat.ndarray_to_matrix`. Example:: @@ -44,7 +51,8 @@ from .vertex import Vertex if TYPE_CHECKING: - from collections.abc import Iterator + from collections.abc import Iterable, Iterator + from typing import Any, Self from .types import AdjacencyMatrix @@ -138,9 +146,167 @@ def __init__( # noqa: PLR0913 ) raise ValueError(msg) - # ------------------------------------------------------------------ - # Properties — metadata - # ------------------------------------------------------------------ + def __repr__(self) -> str: + """Return a developer-friendly representation. + + :return: String like ``Graph('my graph', order=5, size=7)``. + :rtype: str + """ + return f"Graph({self._label!r}, order={self.order}, size={self.size})" + + def __str__(self) -> str: + """Return a human-readable adjacency-list view of the graph. + + Each line shows ``vertex -> neighbor_names`` (or ``-> 0`` for isolated vertices). Lines + are sorted alphabetically by vertex name and preceded by the graph label. + + :return: Multi-line adjacency list string. + :rtype: str + """ + lines: list[str] = [] + for name in sorted(self._vertices): + nbrs = self.neighbors(name) + rhs = "".join(nbrs) if nbrs else "0" + lines.append(f"{name} -> {rhs}") + return f"{self._label}\n" + "\n".join(lines) + + def __iter__(self) -> Iterator[str]: + """Iterate over vertex names in insertion order. + + :return: An iterator yielding vertex name strings. + :rtype: Iterator[str] + """ + return iter(self._vertices) + + def __getitem__(self, node: str) -> list[str]: + """Return neighbor names for *node*, or ``[]`` if absent. + + This enables the common ``graph[v]`` idiom used throughout the algorithm modules. + + :param node: Vertex name. + :type node: str + :return: List of neighbor vertex names. + :rtype: list[str] + """ + if node not in self._vertices: + return [] + return self.neighbors(node) + + def __contains__(self, item: str) -> bool: + """Return ``True`` if *item* is a vertex name in this graph. + + :param item: Vertex name to check. + :type item: str + :return: ``True`` if the vertex exists. + :rtype: bool + """ + return item in self._vertices + + def __len__(self) -> int: + """Return the number of vertices (same as :attr:`order`). + + :return: Vertex count. + :rtype: int + """ + return len(self._vertices) + + def __or__(self, other: object) -> Graph: + """Return the union of this graph and *other*. + + Creates a **new** :class:`Graph` containing all vertices and edges from both operands. + When a vertex name appears in both graphs, the left operand's :class:`Vertex` object is + kept. When the same edge ``(source, target)`` exists in both, the right operand's + :class:`Edge` object wins (last-write-wins, mirroring ``dict.__or__``). + + The operands must agree on :attr:`directed`; mixing directed and undirected graphs raises + :class:`TypeError`. The result is :attr:`weighted` if either operand is. + + Example:: + + >>> g1 = Graph("left") + >>> g1.add_edge("A", "B") + Graph('left', order=2, size=1) + >>> g2 = Graph("right") + >>> g2.add_edge("B", "C") + Graph('right', order=2, size=1) + >>> merged = g1 | g2 + >>> merged.order + 3 + >>> merged.size + 2 + + :param other: Another :class:`Graph` to merge. + :type other: object + :return: A new graph containing the union of both operands. + :rtype: Graph + :raises TypeError: If *other* is not a :class:`Graph`, or if the graphs disagree on + :attr:`directed`. + """ + try: + other = self._check_union_compat(other) + except TypeError: + return NotImplemented + + label = f"{self._label} | {other._label}" if self._label or other._label else "" + result = Graph( + label, + directed=self._directed, + weighted=self._weighted or other._weighted, + ) + + # Seed with left's vertices (left takes precedence on name collision), + # then merge right via |=. + for v_obj in self._vertices.values(): + result.add_vertex(v_obj) + for targets in self._adj.values(): + for edge in targets.values(): + result.add_edge(edge) + + result |= other + return result + + def __ior__(self, other: object) -> Self: + """Merge *other* into this graph in place (augmented union). + + Adds all vertices and edges from *other* to ``self``. Existing vertices in ``self`` + are kept; new vertices from *other* are added. Edges from *other* overwrite any + existing edges with the same ``(source, target)`` pair. + + Returns ``self`` to support chaining and augmented assignment:: + + >>> g1 = Graph("base") + >>> g1.add_edge("A", "B") + Graph('base', order=2, size=1) + >>> g2 = Graph() + >>> g2.add_edge("B", "C") + Graph('', order=2, size=1) + >>> g1 |= g2 + >>> g1.order + 3 + + :param other: Another :class:`Graph` to merge into ``self``. + :type other: object + :return: This graph instance (for chaining). + :rtype: Self + :raises TypeError: If *other* is not a :class:`Graph`, or if the graphs disagree on + :attr:`directed`. + """ + try: + other = self._check_union_compat(other) + except TypeError: + return NotImplemented + + if other._weighted: + self._weighted = True + + for v_obj in other._vertices.values(): + self.add_vertex(v_obj) + + for targets in other._adj.values(): + for edge in targets.values(): + self.add_edge(edge) + + return self @property def label(self) -> str: @@ -211,21 +377,43 @@ def vertex(self, name: str) -> Vertex | None: """ return self._vertices.get(name) - def add_vertex(self, vertex: str | Vertex) -> None: + def add_vertex( + self, + vertex: str | Vertex, + *, + label: str | None = None, + attrs: dict[str, Any] | None = None, + ) -> Self: """Add a vertex to the graph if it does not already exist. - Accepts either a plain name string (which is wrapped in a - :class:`~graphworks.vertex.Vertex` automatically) or an existing - :class:`~graphworks.vertex.Vertex` instance. + Accepts a plain name string, a :class:`~graphworks.vertex.Vertex` instance, or a name + string with keyword arguments that are forwarded to :meth:`Vertex.create`. + + When *vertex* is a :class:`Vertex` object, *label* and *attrs* are ignored — the object + is used as-is. + + Returns ``self`` to support fluent call chaining:: + + >>> g = Graph() + >>> g.add_vertex("A").add_vertex("B").add_vertex("C") + Graph('', order=3, size=0) :param vertex: Vertex name or :class:`Vertex` object. :type vertex: str | Vertex - :return: Nothing. - :rtype: None + :param label: Human-readable display label. Only used when *vertex* is a ``str``. + :type label: str | None + :param attrs: Arbitrary metadata dict. Only used when *vertex* is a ``str``. The dict is + defensively copied and frozen into a :class:`~types.MappingProxyType`. + :type attrs: dict[str, Any] | None + :return: This graph instance (for chaining). + :rtype: Self """ if isinstance(vertex, Vertex): name = vertex.name obj = vertex + elif label is not None or attrs is not None: + name = vertex + obj = Vertex.create(name, label=label, attrs=attrs) else: name = vertex obj = Vertex(name) @@ -234,6 +422,8 @@ def add_vertex(self, vertex: str | Vertex) -> None: self._vertices[name] = obj self._adj[name] = {} + return self + # ------------------------------------------------------------------ # Edge access # ------------------------------------------------------------------ @@ -271,37 +461,115 @@ def edge(self, source: str, target: str) -> Edge | None: def add_edge( self, - source: str, - target: str, + source_or_edge: str | Edge, + target: str | None = None, *, weight: float | None = None, label: str | None = None, - ) -> None: - """Add an edge from *source* to *target*. + ) -> Self: + """Add an edge to the graph. - Both vertices are created automatically if they do not yet exist. + Accepts either two vertex-name strings **or** a pre-built :class:`~graphworks.edge.Edge` + object. Both endpoint vertices are created automatically if they do not yet exist. - :param source: Source vertex name. - :type source: str - :param target: Destination vertex name. - :type target: str - :param weight: Optional numeric weight for the edge. + When an :class:`Edge` object is passed, *target*, *weight*, and *label* are ignored — the + edge is used as-is. + + Returns ``self`` to support fluent call chaining:: + + >>> g = Graph("triangle") + >>> g.add_edge("A", "B").add_edge("B", "C").add_edge("C", "A") + Graph('triangle', order=3, size=3) + + :param source_or_edge: Source vertex name **or** an :class:`Edge` instance. + :type source_or_edge: str | Edge + :param target: Destination vertex name. Required when *source_or_edge* is a ``str``; + ignored when it is an :class:`Edge`. + :type target: str | None + :param weight: Optional numeric weight for the edge. Ignored when *source_or_edge* is an + :class:`Edge`. :type weight: float | None - :param label: Optional human-readable label for the edge. + :param label: Optional human-readable label for the edge. Ignored when *source_or_edge* + is an :class:`Edge`. :type label: str | None - :return: Nothing. - :rtype: None + :return: This graph instance (for chaining). + :rtype: Self + :raises TypeError: If *source_or_edge* is a ``str`` and *target* is ``None``. """ - self.add_vertex(source) - self.add_vertex(target) - edge = Edge( - source=source, - target=target, - directed=self._directed, - weight=weight, - label=label, - ) - self._adj[source][target] = edge + if isinstance(source_or_edge, Edge): + edge = source_or_edge + self.add_vertex(edge.source) + self.add_vertex(edge.target) + self._adj[edge.source][edge.target] = edge + else: + if target is None: + msg = "target is required when source_or_edge is a vertex name string." + raise TypeError(msg) + self.add_vertex(source_or_edge) + self.add_vertex(target) + edge = Edge( + source=source_or_edge, + target=target, + directed=self._directed, + weight=weight, + label=label, + ) + self._adj[source_or_edge][target] = edge + + return self + + def add_edges( + self, + edges: Iterable[tuple[str, str] | tuple[str, str, dict[str, Any]] | Edge], + ) -> Self: + """Add multiple edges to the graph in one call. + + Each element of *edges* can be: + + * a ``(source, target)`` tuple, + * a ``(source, target, attrs_dict)`` tuple where *attrs_dict* may contain ``"weight"`` + and/or ``"label"`` keys, or + * a pre-built :class:`~graphworks.edge.Edge` object. + + Returns ``self`` to support fluent call chaining:: + + >>> g = Graph("square") + >>> g.add_edges([("A", "B"), ("B", "C"), ("C", "D"), ("D", "A")]) + Graph('square', order=4, size=4) + + :param edges: Iterable of edge specifications. + :type edges: Iterable[tuple[str, str] | tuple[str, str, dict[str, Any]] | Edge] + :return: This graph instance (for chaining). + :rtype: Self + :raises TypeError: If an element is not a recognized edge specification. + """ + for item in edges: + match item: + case Edge() as edge: + self.add_edge(edge) + case (str(src), str(tgt)): + self.add_edge(src, tgt) + case (str(src), str(tgt), dict(kwargs)): + self.add_edge( + src, + tgt, + weight=kwargs.get("weight"), + label=kwargs.get("label"), + ) + case (str(), str(), invalid): + msg = ( + f"Third element of edge tuple must be a dict, " + f"got {type(invalid).__name__}." + ) + raise TypeError(msg) + case tuple(): + msg = f"Edge tuple must have 2 or 3 str elements, got {len(item)}." + raise TypeError(msg) + case _: + msg = f"Expected a tuple or Edge, got {type(item).__name__}." + raise TypeError(msg) + + return self # ------------------------------------------------------------------ # Neighbour access @@ -370,74 +638,6 @@ def index_to_vertex(self, index: int) -> str: """ return self.vertices()[index] - # ------------------------------------------------------------------ - # Dunder methods - # ------------------------------------------------------------------ - - def __repr__(self) -> str: - """Return a developer-friendly representation. - - :return: String like ``Graph('my graph', order=5, size=7)``. - :rtype: str - """ - return f"Graph({self._label!r}, order={self.order}, size={self.size})" - - def __str__(self) -> str: - """Return a human-readable adjacency-list view of the graph. - - Each line shows ``vertex -> neighbor_names`` (or ``-> 0`` for isolated vertices). Lines - are sorted alphabetically by vertex name and preceded by the graph label. - - :return: Multi-line adjacency list string. - :rtype: str - """ - lines: list[str] = [] - for name in sorted(self._vertices): - nbrs = self.neighbors(name) - rhs = "".join(nbrs) if nbrs else "0" - lines.append(f"{name} -> {rhs}") - return f"{self._label}\n" + "\n".join(lines) - - def __iter__(self) -> Iterator[str]: - """Iterate over vertex names in insertion order. - - :return: An iterator yielding vertex name strings. - :rtype: Iterator[str] - """ - return iter(self._vertices) - - def __getitem__(self, node: str) -> list[str]: - """Return neighbor names for *node*, or ``[]`` if absent. - - This enables the common ``graph[v]`` idiom used throughout the algorithm modules. - - :param node: Vertex name. - :type node: str - :return: List of neighbor vertex names. - :rtype: list[str] - """ - if node not in self._vertices: - return [] - return self.neighbors(node) - - def __contains__(self, item: str) -> bool: - """Return ``True`` if *item* is a vertex name in this graph. - - :param item: Vertex name to check. - :type item: str - :return: ``True`` if the vertex exists. - :rtype: bool - """ - return item in self._vertices - - def __len__(self) -> int: - """Return the number of vertices (same as :attr:`order`). - - :return: Vertex count. - :rtype: int - """ - return len(self._vertices) - # ------------------------------------------------------------------ # Protected helpers # ------------------------------------------------------------------ @@ -549,3 +749,23 @@ def _matrix_to_graph(self, matrix: AdjacencyMatrix) -> None: directed=self._directed, ) self._adj[names[r_idx]][names[c_idx]] = edge + + def _check_union_compat(self, other: object) -> Graph: + """Validate that *other* is a compatible :class:`Graph` for union. + + :param other: Candidate right-hand operand. + :type other: object + :return: *other*, narrowed to :class:`Graph`. + :rtype: Graph + :raises TypeError: If *other* is not a :class:`Graph` or has a different + :attr:`directed` flag. + """ + if not isinstance(other, Graph): + raise TypeError # sentinel; callers return NotImplemented instead + if self._directed != other._directed: + msg = ( + f"Cannot union a {'directed' if self._directed else 'undirected'} graph " + f"with a {'directed' if other._directed else 'undirected'} graph." + ) + raise TypeError(msg) + return other diff --git a/tests/test_graph_fluent_chaining.py b/tests/test_graph_fluent_chaining.py new file mode 100644 index 0000000..7f81049 --- /dev/null +++ b/tests/test_graph_fluent_chaining.py @@ -0,0 +1,417 @@ +"""Unit tests for the Graph fluent API enhancements. + +Tests cover: +1. Fluent chaining — ``add_vertex`` and ``add_edge`` return ``self`` +2. Batch ``add_edges`` — accepts iterables of edge specifications +3. ``add_vertex`` with kwargs — forwards ``label`` and ``attrs`` to ``Vertex.create`` +4. ``add_edge`` accepts ``Edge`` objects — pre-built edges as first positional arg +""" + +from __future__ import annotations + +import pytest + +from graphworks.edge import Edge +from graphworks.graph import Graph +from graphworks.vertex import Vertex + +# --------------------------------------------------------------------------- +# 1. Fluent chaining +# --------------------------------------------------------------------------- + + +class TestFluentChaining: + """Verify that ``add_vertex`` and ``add_edge`` return ``self``.""" + + def test_add_vertex_returns_self(self) -> None: + g = Graph() + result = g.add_vertex("A") + assert result is g + + def test_add_edge_returns_self(self) -> None: + g = Graph() + result = g.add_edge("A", "B") + assert result is g + + def test_add_edges_returns_self(self) -> None: + g = Graph() + result = g.add_edges([("A", "B")]) + assert result is g + + def test_chain_add_vertex(self) -> None: + g = Graph() + g.add_vertex("A").add_vertex("B").add_vertex("C") + assert sorted(g.vertices()) == ["A", "B", "C"] + + def test_chain_add_edge(self) -> None: + g = Graph("triangle") + g.add_edge("A", "B").add_edge("B", "C").add_edge("C", "A") + assert g.order == 3 + assert g.size == 3 + + def test_chain_mixed(self) -> None: + g = Graph() + g.add_vertex("X", label="Entry").add_edge("X", "Y").add_vertex("Z") + assert g.order == 3 + assert g.size == 1 + v = g.vertex("X") + assert v is not None + assert v.label == "Entry" + + def test_chain_add_edges_then_vertex(self) -> None: + g = Graph() + g.add_edges([("A", "B"), ("B", "C")]).add_vertex("D") + assert g.order == 4 + assert g.size == 2 + + +# --------------------------------------------------------------------------- +# 2. Batch add_edges +# --------------------------------------------------------------------------- + + +class TestAddEdges: + """Verify batch edge insertion via ``add_edges``.""" + + def test_tuple_pairs(self) -> None: + g = Graph() + g.add_edges([("A", "B"), ("B", "C"), ("C", "D")]) + assert g.order == 4 + assert g.size == 3 + + def test_tuple_with_attrs(self) -> None: + g = Graph() + g.add_edges( + [ + ("A", "B", {"weight": 3.5, "label": "highway"}), + ("B", "C", {"weight": 2.0}), + ] + ) + e_ab = g.edge("A", "B") + assert e_ab is not None + assert e_ab.weight == pytest.approx(3.5) + assert e_ab.label == "highway" + + e_bc = g.edge("B", "C") + assert e_bc is not None + assert e_bc.weight == pytest.approx(2.0) + assert e_bc.label is None + + def test_edge_objects(self) -> None: + g = Graph() + edges = [ + Edge.create("X", "Y", weight=1.0), + Edge.create("Y", "Z", label="bridge"), + ] + g.add_edges(edges) + assert g.order == 3 + assert g.size == 2 + assert g.edge("X", "Y") is not None + assert g.edge("Y", "Z") is not None + + def test_mixed_specs(self) -> None: + g = Graph() + g.add_edges( + [ + ("A", "B"), + ("B", "C", {"weight": 5.0}), + Edge.create("C", "D", directed=True, label="arc"), + ] + ) + assert g.order == 4 + assert g.size == 3 + + def test_empty_iterable(self) -> None: + g = Graph() + g.add_edges([]) + assert g.order == 0 + assert g.size == 0 + + def test_generator_input(self) -> None: + def pairs(): + yield ("A", "B") + yield ("B", "C") + + g = Graph() + g.add_edges(pairs()) + assert g.size == 2 + + def test_auto_creates_vertices(self) -> None: + g = Graph() + g.add_edges([("X", "Y"), ("Y", "Z")]) + assert set(g.vertices()) == {"X", "Y", "Z"} + + def test_invalid_tuple_length_raises(self) -> None: + g = Graph() + with pytest.raises(TypeError, match="2 or 3 str elements"): + g.add_edges([("A",)]) # type: ignore[list-item] + + def test_invalid_tuple_four_elements_raises(self) -> None: + g = Graph() + with pytest.raises(TypeError, match="2 or 3 str elements"): + g.add_edges([("A", "B", {}, "extra")]) # type: ignore[list-item] + + def test_non_dict_third_element_raises(self) -> None: + g = Graph() + with pytest.raises(TypeError, match="must be a dict"): + g.add_edges([("A", "B", 42)]) # type: ignore[list-item] + + def test_non_tuple_non_edge_raises(self) -> None: + g = Graph() + with pytest.raises(TypeError, match="Expected a tuple or Edge"): + g.add_edges(["not_valid"]) # type: ignore[list-item] + + def test_attrs_dict_weight_only(self) -> None: + g = Graph() + g.add_edges([("A", "B", {"weight": 7.0})]) + e = g.edge("A", "B") + assert e is not None + assert e.weight == pytest.approx(7.0) + assert e.label is None + + def test_attrs_dict_label_only(self) -> None: + g = Graph() + g.add_edges([("A", "B", {"label": "road"})]) + e = g.edge("A", "B") + assert e is not None + assert e.weight is None + assert e.label == "road" + + def test_attrs_dict_empty(self) -> None: + g = Graph() + g.add_edges([("A", "B", {})]) + e = g.edge("A", "B") + assert e is not None + assert e.weight is None + assert e.label is None + + +# --------------------------------------------------------------------------- +# 3. add_vertex with kwargs +# --------------------------------------------------------------------------- + + +class TestAddVertexKwargs: + """Verify ``add_vertex`` forwards ``label`` and ``attrs`` to ``Vertex.create``.""" + + def test_label_kwarg(self) -> None: + g = Graph() + g.add_vertex("hub", label="Central") + v = g.vertex("hub") + assert v is not None + assert v.label == "Central" + assert v.display_name == "Central" + + def test_attrs_kwarg(self) -> None: + g = Graph() + g.add_vertex("hub", attrs={"rank": 1, "color": "red"}) + v = g.vertex("hub") + assert v is not None + assert v.attrs["rank"] == 1 + assert v.attrs["color"] == "red" + + def test_label_and_attrs(self) -> None: + g = Graph() + g.add_vertex("n1", label="Start", attrs={"priority": "high"}) + v = g.vertex("n1") + assert v is not None + assert v.label == "Start" + assert v.attrs["priority"] == "high" + + def test_attrs_frozen(self) -> None: + raw = {"key": "original"} + g = Graph() + g.add_vertex("n1", attrs=raw) + raw["key"] = "mutated" + v = g.vertex("n1") + assert v is not None + assert v.attrs["key"] == "original" + + def test_vertex_object_ignores_kwargs(self) -> None: + v = Vertex.create("obj", label="Original") + g = Graph() + g.add_vertex(v, label="Ignored", attrs={"ignored": True}) + result = g.vertex("obj") + assert result is not None + assert result.label == "Original" + assert result.attrs == {} + + def test_no_kwargs_plain_string(self) -> None: + g = Graph() + g.add_vertex("plain") + v = g.vertex("plain") + assert v is not None + assert v.label is None + assert v.attrs == {} + + def test_duplicate_vertex_idempotent_with_kwargs(self) -> None: + g = Graph() + g.add_vertex("A", label="First") + g.add_vertex("A", label="Second") + v = g.vertex("A") + assert v is not None + assert v.label == "First" + assert g.order == 1 + + def test_chained_with_kwargs(self) -> None: + g = Graph() + g.add_vertex("A", label="Start").add_vertex("B", label="End") + assert g.vertex("A").label == "Start" # type: ignore[union-attr] + assert g.vertex("B").label == "End" # type: ignore[union-attr] + + +# --------------------------------------------------------------------------- +# 4. add_edge accepts Edge objects +# --------------------------------------------------------------------------- + + +class TestAddEdgeObject: + """Verify ``add_edge`` accepts pre-built :class:`Edge` objects.""" + + def test_basic_edge_object(self) -> None: + g = Graph() + e = Edge("A", "B") + g.add_edge(e) + assert g.order == 2 + assert g.size == 1 + + def test_edge_create_factory(self) -> None: + g = Graph() + e = Edge.create("X", "Y", weight=3.14, label="bridge") + g.add_edge(e) + found = g.edge("X", "Y") + assert found is not None + assert found.weight == pytest.approx(3.14) + assert found.label == "bridge" + + def test_edge_object_preserves_directed(self) -> None: + g = Graph() + e = Edge("A", "B", directed=True) + g.add_edge(e) + found = g.edge("A", "B") + assert found is not None + assert found.directed + + def test_edge_object_preserves_attrs(self) -> None: + g = Graph() + e = Edge.create("A", "B", attrs={"color": "red"}) + g.add_edge(e) + found = g.edge("A", "B") + assert found is not None + assert found.attrs["color"] == "red" + + def test_edge_object_auto_creates_vertices(self) -> None: + g = Graph() + g.add_edge(Edge("P", "Q")) + assert set(g.vertices()) == {"P", "Q"} + + def test_edge_object_ignores_kwargs(self) -> None: + g = Graph() + e = Edge.create("A", "B", weight=10.0) + g.add_edge(e, weight=99.0, label="ignored") + found = g.edge("A", "B") + assert found is not None + assert found.weight == pytest.approx(10.0) + assert found.label is None + + def test_edge_object_chained(self) -> None: + g = Graph() + e1 = Edge("A", "B") + e2 = Edge("B", "C") + g.add_edge(e1).add_edge(e2) + assert g.size == 2 + + def test_str_source_missing_target_raises(self) -> None: + g = Graph() + with pytest.raises(TypeError, match="target is required"): + g.add_edge("A") # type: ignore[call-overload] + + def test_edge_object_target_param_ignored(self) -> None: + g = Graph() + e = Edge("A", "B") + g.add_edge(e, "IGNORED") + assert g.edge("A", "B") is not None + assert "IGNORED" not in g.vertices() + + +# --------------------------------------------------------------------------- +# Integration: combining all enhancements +# --------------------------------------------------------------------------- + + +class TestFluentAPIIntegration: + """Integration tests exercising multiple enhancements together.""" + + def test_full_construction_pipeline(self) -> None: + g = Graph("network") + g.add_vertex("hub", label="Central", attrs={"rank": 1}).add_edges( + [ + ("hub", "A"), + ("hub", "B", {"weight": 2.5}), + Edge.create("hub", "C", label="express"), + ] + ).add_vertex("D").add_edge("C", "D") + + assert g.order == 5 + assert g.size == 4 + + v = g.vertex("hub") + assert v is not None + assert v.label == "Central" + assert v.attrs["rank"] == 1 + + e = g.edge("hub", "B") + assert e is not None + assert e.weight == pytest.approx(2.5) + + e_express = g.edge("hub", "C") + assert e_express is not None + assert e_express.label == "express" + + def test_directed_graph_chaining(self) -> None: + g = Graph("dag", directed=True) + g.add_edges( + [ + ("compile", "lint"), + ("compile", "typecheck"), + ("test", "compile"), + ("package", "test"), + ] + ) + assert g.order == 5 + assert g.size == 4 + assert g.edge("compile", "lint") is not None + assert g.edge("lint", "compile") is None + + def test_weighted_graph_batch(self) -> None: + g = Graph("routes", weighted=True) + g.add_edges( + [ + ("Denver", "SLC", {"weight": 525.0, "label": "I-70/I-15"}), + ("SLC", "Boise", {"weight": 340.0, "label": "I-84"}), + ("Boise", "Portland", {"weight": 430.0, "label": "I-84"}), + ] + ) + assert g.order == 4 + assert g.size == 3 + + e = g.edge("Denver", "SLC") + assert e is not None + assert e.weight == pytest.approx(525.0) + assert e.label == "I-70/I-15" + + def test_existing_tests_still_pass_pattern(self, simple_edge_json) -> None: + """Ensure the old string-based ``add_edge`` API still works identically.""" + g = Graph() + g.add_edge("A", "B") + assert g.size == 1 + assert g.order == 2 + assert g.edge("A", "B") is not None + + def test_existing_vertex_object_pattern(self) -> None: + """Ensure the old Vertex-object ``add_vertex`` API still works identically.""" + g = Graph() + g.add_vertex(Vertex.create("hub", label="Central", attrs={"rank": 1})) + v = g.vertex("hub") + assert v is not None + assert v.label == "Central" diff --git a/tests/test_graph_union.py b/tests/test_graph_union.py new file mode 100644 index 0000000..e49efc9 --- /dev/null +++ b/tests/test_graph_union.py @@ -0,0 +1,417 @@ +"""Unit tests for Graph union operators (``|`` and ``|=``). + +Tests cover: +- ``__or__``: creates a new graph from the union of two graphs +- ``__ior__``: merges another graph into self in place +- Edge cases: empty graphs, overlapping vertices/edges, metadata precedence +- Error cases: directed/undirected mismatch, non-Graph operand +""" + +from __future__ import annotations + +import pytest + +from graphworks.edge import Edge +from graphworks.graph import Graph + +# --------------------------------------------------------------------------- +# __or__ — new graph from union +# --------------------------------------------------------------------------- + + +class TestGraphOr: + """Tests for ``g1 | g2`` producing a new graph.""" + + def test_disjoint_graphs(self) -> None: + g1 = Graph("left") + g1.add_edge("A", "B") + g2 = Graph("right") + g2.add_edge("C", "D") + + result = g1 | g2 + assert result.order == 4 + assert result.size == 2 + assert set(result.vertices()) == {"A", "B", "C", "D"} + + def test_overlapping_vertices(self) -> None: + g1 = Graph("left") + g1.add_edge("A", "B") + g2 = Graph("right") + g2.add_edge("B", "C") + + result = g1 | g2 + assert result.order == 3 + assert result.size == 2 + assert result.edge("A", "B") is not None + assert result.edge("B", "C") is not None + + def test_overlapping_edges(self) -> None: + g1 = Graph() + g1.add_edge("A", "B", weight=1.0) + g2 = Graph() + g2.add_edge("A", "B", weight=99.0) + + result = g1 | g2 + assert result.size == 1 + e = result.edge("A", "B") + assert e is not None + assert e.weight == pytest.approx(99.0), "Right operand edge should win" + + def test_returns_new_graph(self) -> None: + g1 = Graph("left") + g1.add_edge("A", "B") + g2 = Graph("right") + g2.add_edge("C", "D") + + result = g1 | g2 + assert result is not g1 + assert result is not g2 + assert g1.order == 2, "Original should be unmodified" + assert g2.order == 2, "Original should be unmodified" + + def test_label_combined(self) -> None: + g1 = Graph("alpha") + g2 = Graph("beta") + assert (g1 | g2).label == "alpha | beta" + + def test_label_empty_both(self) -> None: + assert (Graph() | Graph()).label == "" + + def test_label_one_empty(self) -> None: + g1 = Graph("named") + g2 = Graph() + assert (g1 | g2).label == "named | " + + def test_directed_preserved(self) -> None: + g1 = Graph(directed=True) + g1.add_edge("A", "B") + g2 = Graph(directed=True) + g2.add_edge("B", "C") + + result = g1 | g2 + assert result.directed + + def test_weighted_either(self) -> None: + g1 = Graph(weighted=True) + g2 = Graph(weighted=False) + assert (g1 | g2).weighted + assert (g2 | g1).weighted + + def test_weighted_neither(self) -> None: + assert not (Graph() | Graph()).weighted + + def test_directed_mismatch_raises(self) -> None: + g1 = Graph(directed=True) + g2 = Graph(directed=False) + with pytest.raises( + TypeError, match="unsupported operand type(s) for |: 'Graph' and 'Graph'" + ): + _ = g1 | g2 + + def test_non_graph_returns_not_implemented(self) -> None: + g = Graph() + assert g.__or__("not a graph") is NotImplemented + + def test_empty_union_empty(self) -> None: + result = Graph() | Graph() + assert result.order == 0 + assert result.size == 0 + + def test_empty_union_nonempty(self) -> None: + g1 = Graph() + g2 = Graph() + g2.add_edge("A", "B") + + result = g1 | g2 + assert result.order == 2 + assert result.size == 1 + + def test_nonempty_union_empty(self) -> None: + g1 = Graph() + g1.add_edge("A", "B") + g2 = Graph() + + result = g1 | g2 + assert result.order == 2 + assert result.size == 1 + + def test_vertex_metadata_left_precedence(self) -> None: + g1 = Graph() + g1.add_vertex("hub", label="Left Label", attrs={"side": "left"}) + g2 = Graph() + g2.add_vertex("hub", label="Right Label", attrs={"side": "right"}) + + result = g1 | g2 + v = result.vertex("hub") + assert v is not None + assert v.label == "Left Label" + assert v.attrs["side"] == "left" + + def test_edge_metadata_right_precedence(self) -> None: + g1 = Graph() + g1.add_edge("A", "B", label="old road") + g2 = Graph() + g2.add_edge("A", "B", label="new highway") + + result = g1 | g2 + e = result.edge("A", "B") + assert e is not None + assert e.label == "new highway" + + def test_preserves_edge_objects(self) -> None: + g1 = Graph() + custom_edge = Edge.create("X", "Y", weight=42.0, attrs={"toll": True}) + g1.add_edge(custom_edge) + g2 = Graph() + g2.add_vertex("Z") + + result = g1 | g2 + e = result.edge("X", "Y") + assert e is not None + assert e.weight == pytest.approx(42.0) + assert e.attrs["toll"] is True + + def test_self_loops_preserved(self) -> None: + g1 = Graph() + g1.add_edge("A", "A") + g2 = Graph() + + result = g1 | g2 + e = result.edge("A", "A") + assert e is not None + + def test_multiple_unions_chained(self) -> None: + g1 = Graph() + g1.add_edge("A", "B") + g2 = Graph() + g2.add_edge("C", "D") + g3 = Graph() + g3.add_edge("E", "F") + + result = g1 | g2 | g3 + assert result.order == 6 + assert result.size == 3 + + def test_directed_edges_not_reversed(self) -> None: + g1 = Graph(directed=True) + g1.add_edge("A", "B") + g2 = Graph(directed=True) + g2.add_edge("C", "D") + + result = g1 | g2 + assert result.edge("A", "B") is not None + assert result.edge("B", "A") is None + assert result.edge("C", "D") is not None + assert result.edge("D", "C") is None + + def test_large_union(self) -> None: + g1 = Graph() + g1.add_edges([(f"L{i}", f"L{i + 1}") for i in range(50)]) + g2 = Graph() + g2.add_edges([(f"R{i}", f"R{i + 1}") for i in range(50)]) + + result = g1 | g2 + assert result.order == 102 + assert result.size == 100 + + +# --------------------------------------------------------------------------- +# __ior__ — in-place merge +# --------------------------------------------------------------------------- + + +class TestGraphIor: + """Tests for ``g1 |= g2`` merging into g1 in place.""" + + def test_basic_merge(self) -> None: + g1 = Graph("base") + g1.add_edge("A", "B") + g2 = Graph() + g2.add_edge("C", "D") + + g1 |= g2 + assert g1.order == 4 + assert g1.size == 2 + + def test_returns_self(self) -> None: + g1 = Graph() + g2 = Graph() + g2.add_vertex("X") + + original_id = id(g1) + g1 |= g2 + assert id(g1) == original_id + + def test_label_unchanged(self) -> None: + g1 = Graph("original") + g2 = Graph("other") + + g1 |= g2 + assert g1.label == "original" + + def test_overlapping_vertices_keeps_left(self) -> None: + g1 = Graph() + g1.add_vertex("hub", label="Original") + g2 = Graph() + g2.add_vertex("hub", label="Incoming") + + g1 |= g2 + v = g1.vertex("hub") + assert v is not None + assert v.label == "Original" + + def test_overlapping_edges_overwrites(self) -> None: + g1 = Graph() + g1.add_edge("A", "B", weight=1.0) + g2 = Graph() + g2.add_edge("A", "B", weight=99.0) + + g1 |= g2 + e = g1.edge("A", "B") + assert e is not None + assert e.weight == pytest.approx(99.0) + + def test_weighted_promotion(self) -> None: + g1 = Graph() + g2 = Graph(weighted=True) + + g1 |= g2 + assert g1.weighted + + def test_weighted_not_demoted(self) -> None: + g1 = Graph(weighted=True) + g2 = Graph() + + g1 |= g2 + assert g1.weighted + + def test_directed_mismatch_raises(self) -> None: + g1 = Graph(directed=True) + g2 = Graph(directed=False) + with pytest.raises( + TypeError, match="unsupported operand type(s) for |=: 'Graph' and 'Graph'" + ): + g1 |= g2 + + def test_non_graph_returns_not_implemented(self) -> None: + g = Graph() + assert g.__ior__("not a graph") is NotImplemented + + def test_other_unchanged(self) -> None: + g1 = Graph() + g1.add_edge("A", "B") + g2 = Graph() + g2.add_edge("C", "D") + + g1 |= g2 + assert g2.order == 2, "Right operand should be unmodified" + assert "A" not in g2.vertices() + + def test_merge_empty_into_nonempty(self) -> None: + g1 = Graph() + g1.add_edge("A", "B") + g2 = Graph() + + g1 |= g2 + assert g1.order == 2 + assert g1.size == 1 + + def test_merge_nonempty_into_empty(self) -> None: + g1 = Graph() + g2 = Graph() + g2.add_edge("A", "B") + + g1 |= g2 + assert g1.order == 2 + assert g1.size == 1 + + def test_chained_ior(self) -> None: + g1 = Graph() + g1.add_edge("A", "B") + g2 = Graph() + g2.add_edge("C", "D") + g3 = Graph() + g3.add_edge("E", "F") + + g1 |= g2 + g1 |= g3 + assert g1.order == 6 + assert g1.size == 3 + + +# --------------------------------------------------------------------------- +# Integration: union with other API features +# --------------------------------------------------------------------------- + + +class TestUnionIntegration: + """Integration tests combining union with other Graph features.""" + + def test_union_then_algorithm(self) -> None: + """Union result works with algorithm functions.""" + from graphworks.algorithms.properties import is_connected + + g1 = Graph() + g1.add_edge("A", "B") + g2 = Graph() + g2.add_edge("B", "C") + + result = g1 | g2 + assert is_connected(result) + + def test_union_then_fluent_chain(self) -> None: + g1 = Graph() + g1.add_edge("A", "B") + g2 = Graph() + g2.add_edge("C", "D") + + result = g1 | g2 + result.add_edge("B", "C").add_vertex("E") + assert result.order == 5 + assert result.size == 3 + + def test_ior_then_fluent_chain(self) -> None: + g1 = Graph() + g1.add_edge("A", "B") + g2 = Graph() + g2.add_edge("C", "D") + + g1 |= g2 + g1.add_edge("B", "C") + assert g1.order == 4 + assert g1.size == 3 + + def test_union_preserves_vertex_objects_from_add_vertex_kwargs(self) -> None: + g1 = Graph() + g1.add_vertex("hub", label="Central", attrs={"rank": 1}) + g1.add_edge("hub", "A") + + g2 = Graph() + g2.add_edge("hub", "B") + + result = g1 | g2 + v = result.vertex("hub") + assert v is not None + assert v.label == "Central" + assert v.attrs["rank"] == 1 + + def test_union_with_batch_edges(self) -> None: + g1 = Graph() + g1.add_edges([("A", "B"), ("B", "C")]) + g2 = Graph() + g2.add_edges([("C", "D"), ("D", "E")]) + + result = g1 | g2 + assert result.order == 5 + assert result.size == 4 + + def test_union_adjacency_matrix(self) -> None: + g1 = Graph() + g1.add_edge("A", "B") + g2 = Graph() + g2.add_edge("B", "C") + + result = g1 | g2 + matrix = result.adjacency_matrix() + assert len(matrix) == 3 + assert all(len(row) == 3 for row in matrix) diff --git a/uv.lock b/uv.lock index d2fae4d..9fb6731 100644 --- a/uv.lock +++ b/uv.lock @@ -773,27 +773,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.15.7" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a1/22/9e4f66ee588588dc6c9af6a994e12d26e19efbe874d1a909d09a6dac7a59/ruff-0.15.7.tar.gz", hash = "sha256:04f1ae61fc20fe0b148617c324d9d009b5f63412c0b16474f3d5f1a1a665f7ac", size = 4601277, upload-time = "2026-03-19T16:26:22.605Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/41/2f/0b08ced94412af091807b6119ca03755d651d3d93a242682bf020189db94/ruff-0.15.7-py3-none-linux_armv6l.whl", hash = "sha256:a81cc5b6910fb7dfc7c32d20652e50fa05963f6e13ead3c5915c41ac5d16668e", size = 10489037, upload-time = "2026-03-19T16:26:32.47Z" }, - { url = "https://files.pythonhosted.org/packages/91/4a/82e0fa632e5c8b1eba5ee86ecd929e8ff327bbdbfb3c6ac5d81631bef605/ruff-0.15.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:722d165bd52403f3bdabc0ce9e41fc47070ac56d7a91b4e0d097b516a53a3477", size = 10955433, upload-time = "2026-03-19T16:27:00.205Z" }, - { url = "https://files.pythonhosted.org/packages/ab/10/12586735d0ff42526ad78c049bf51d7428618c8b5c467e72508c694119df/ruff-0.15.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7fbc2448094262552146cbe1b9643a92f66559d3761f1ad0656d4991491af49e", size = 10269302, upload-time = "2026-03-19T16:26:26.183Z" }, - { url = "https://files.pythonhosted.org/packages/eb/5d/32b5c44ccf149a26623671df49cbfbd0a0ae511ff3df9d9d2426966a8d57/ruff-0.15.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b39329b60eba44156d138275323cc726bbfbddcec3063da57caa8a8b1d50adf", size = 10607625, upload-time = "2026-03-19T16:27:03.263Z" }, - { url = "https://files.pythonhosted.org/packages/5d/f1/f0001cabe86173aaacb6eb9bb734aa0605f9a6aa6fa7d43cb49cbc4af9c9/ruff-0.15.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:87768c151808505f2bfc93ae44e5f9e7c8518943e5074f76ac21558ef5627c85", size = 10324743, upload-time = "2026-03-19T16:27:09.791Z" }, - { url = "https://files.pythonhosted.org/packages/7a/87/b8a8f3d56b8d848008559e7c9d8bf367934d5367f6d932ba779456e2f73b/ruff-0.15.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fb0511670002c6c529ec66c0e30641c976c8963de26a113f3a30456b702468b0", size = 11138536, upload-time = "2026-03-19T16:27:06.101Z" }, - { url = "https://files.pythonhosted.org/packages/e4/f2/4fd0d05aab0c5934b2e1464784f85ba2eab9d54bffc53fb5430d1ed8b829/ruff-0.15.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e0d19644f801849229db8345180a71bee5407b429dd217f853ec515e968a6912", size = 11994292, upload-time = "2026-03-19T16:26:48.718Z" }, - { url = "https://files.pythonhosted.org/packages/64/22/fc4483871e767e5e95d1622ad83dad5ebb830f762ed0420fde7dfa9d9b08/ruff-0.15.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4806d8e09ef5e84eb19ba833d0442f7e300b23fe3f0981cae159a248a10f0036", size = 11398981, upload-time = "2026-03-19T16:26:54.513Z" }, - { url = "https://files.pythonhosted.org/packages/b0/99/66f0343176d5eab02c3f7fcd2de7a8e0dd7a41f0d982bee56cd1c24db62b/ruff-0.15.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dce0896488562f09a27b9c91b1f58a097457143931f3c4d519690dea54e624c5", size = 11242422, upload-time = "2026-03-19T16:26:29.277Z" }, - { url = "https://files.pythonhosted.org/packages/5d/3a/a7060f145bfdcce4c987ea27788b30c60e2c81d6e9a65157ca8afe646328/ruff-0.15.7-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:1852ce241d2bc89e5dc823e03cff4ce73d816b5c6cdadd27dbfe7b03217d2a12", size = 11232158, upload-time = "2026-03-19T16:26:42.321Z" }, - { url = "https://files.pythonhosted.org/packages/a7/53/90fbb9e08b29c048c403558d3cdd0adf2668b02ce9d50602452e187cd4af/ruff-0.15.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:5f3e4b221fb4bd293f79912fc5e93a9063ebd6d0dcbd528f91b89172a9b8436c", size = 10577861, upload-time = "2026-03-19T16:26:57.459Z" }, - { url = "https://files.pythonhosted.org/packages/2f/aa/5f486226538fe4d0f0439e2da1716e1acf895e2a232b26f2459c55f8ddad/ruff-0.15.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:b15e48602c9c1d9bdc504b472e90b90c97dc7d46c7028011ae67f3861ceba7b4", size = 10327310, upload-time = "2026-03-19T16:26:35.909Z" }, - { url = "https://files.pythonhosted.org/packages/99/9e/271afdffb81fe7bfc8c43ba079e9d96238f674380099457a74ccb3863857/ruff-0.15.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1b4705e0e85cedc74b0a23cf6a179dbb3df184cb227761979cc76c0440b5ab0d", size = 10840752, upload-time = "2026-03-19T16:26:45.723Z" }, - { url = "https://files.pythonhosted.org/packages/bf/29/a4ae78394f76c7759953c47884eb44de271b03a66634148d9f7d11e721bd/ruff-0.15.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:112c1fa316a558bb34319282c1200a8bf0495f1b735aeb78bfcb2991e6087580", size = 11336961, upload-time = "2026-03-19T16:26:39.076Z" }, - { url = "https://files.pythonhosted.org/packages/26/6b/8786ba5736562220d588a2f6653e6c17e90c59ced34a2d7b512ef8956103/ruff-0.15.7-py3-none-win32.whl", hash = "sha256:6d39e2d3505b082323352f733599f28169d12e891f7dd407f2d4f54b4c2886de", size = 10582538, upload-time = "2026-03-19T16:26:15.992Z" }, - { url = "https://files.pythonhosted.org/packages/2b/e9/346d4d3fffc6871125e877dae8d9a1966b254fbd92a50f8561078b88b099/ruff-0.15.7-py3-none-win_amd64.whl", hash = "sha256:4d53d712ddebcd7dace1bc395367aec12c057aacfe9adbb6d832302575f4d3a1", size = 11755839, upload-time = "2026-03-19T16:26:19.897Z" }, - { url = "https://files.pythonhosted.org/packages/8f/e8/726643a3ea68c727da31570bde48c7a10f1aa60eddd628d94078fec586ff/ruff-0.15.7-py3-none-win_arm64.whl", hash = "sha256:18e8d73f1c3fdf27931497972250340f92e8c861722161a9caeb89a58ead6ed2", size = 11023304, upload-time = "2026-03-19T16:26:51.669Z" }, +version = "0.15.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/14/b0/73cf7550861e2b4824950b8b52eebdcc5adc792a00c514406556c5b80817/ruff-0.15.8.tar.gz", hash = "sha256:995f11f63597ee362130d1d5a327a87cb6f3f5eae3094c620bcc632329a4d26e", size = 4610921, upload-time = "2026-03-26T18:39:38.675Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/92/c445b0cd6da6e7ae51e954939cb69f97e008dbe750cfca89b8cedc081be7/ruff-0.15.8-py3-none-linux_armv6l.whl", hash = "sha256:cbe05adeba76d58162762d6b239c9056f1a15a55bd4b346cfd21e26cd6ad7bc7", size = 10527394, upload-time = "2026-03-26T18:39:41.566Z" }, + { url = "https://files.pythonhosted.org/packages/eb/92/f1c662784d149ad1414cae450b082cf736430c12ca78367f20f5ed569d65/ruff-0.15.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:d3e3d0b6ba8dca1b7ef9ab80a28e840a20070c4b62e56d675c24f366ef330570", size = 10905693, upload-time = "2026-03-26T18:39:30.364Z" }, + { url = "https://files.pythonhosted.org/packages/ca/f2/7a631a8af6d88bcef997eb1bf87cc3da158294c57044aafd3e17030613de/ruff-0.15.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:6ee3ae5c65a42f273f126686353f2e08ff29927b7b7e203b711514370d500de3", size = 10323044, upload-time = "2026-03-26T18:39:33.37Z" }, + { url = "https://files.pythonhosted.org/packages/67/18/1bf38e20914a05e72ef3b9569b1d5c70a7ef26cd188d69e9ca8ef588d5bf/ruff-0.15.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fdce027ada77baa448077ccc6ebb2fa9c3c62fd110d8659d601cf2f475858d94", size = 10629135, upload-time = "2026-03-26T18:39:44.142Z" }, + { url = "https://files.pythonhosted.org/packages/d2/e9/138c150ff9af60556121623d41aba18b7b57d95ac032e177b6a53789d279/ruff-0.15.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:12e617fc01a95e5821648a6df341d80456bd627bfab8a829f7cfc26a14a4b4a3", size = 10348041, upload-time = "2026-03-26T18:39:52.178Z" }, + { url = "https://files.pythonhosted.org/packages/02/f1/5bfb9298d9c323f842c5ddeb85f1f10ef51516ac7a34ba446c9347d898df/ruff-0.15.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:432701303b26416d22ba696c39f2c6f12499b89093b61360abc34bcc9bf07762", size = 11121987, upload-time = "2026-03-26T18:39:55.195Z" }, + { url = "https://files.pythonhosted.org/packages/10/11/6da2e538704e753c04e8d86b1fc55712fdbdcc266af1a1ece7a51fff0d10/ruff-0.15.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d910ae974b7a06a33a057cb87d2a10792a3b2b3b35e33d2699fdf63ec8f6b17a", size = 11951057, upload-time = "2026-03-26T18:39:19.18Z" }, + { url = "https://files.pythonhosted.org/packages/83/f0/c9208c5fd5101bf87002fed774ff25a96eea313d305f1e5d5744698dc314/ruff-0.15.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2033f963c43949d51e6fdccd3946633c6b37c484f5f98c3035f49c27395a8ab8", size = 11464613, upload-time = "2026-03-26T18:40:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/f8/22/d7f2fabdba4fae9f3b570e5605d5eb4500dcb7b770d3217dca4428484b17/ruff-0.15.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f29b989a55572fb885b77464cf24af05500806ab4edf9a0fd8977f9759d85b1", size = 11257557, upload-time = "2026-03-26T18:39:57.972Z" }, + { url = "https://files.pythonhosted.org/packages/71/8c/382a9620038cf6906446b23ce8632ab8c0811b8f9d3e764f58bedd0c9a6f/ruff-0.15.8-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:ac51d486bf457cdc985a412fb1801b2dfd1bd8838372fc55de64b1510eff4bec", size = 11169440, upload-time = "2026-03-26T18:39:22.205Z" }, + { url = "https://files.pythonhosted.org/packages/4d/0d/0994c802a7eaaf99380085e4e40c845f8e32a562e20a38ec06174b52ef24/ruff-0.15.8-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c9861eb959edab053c10ad62c278835ee69ca527b6dcd72b47d5c1e5648964f6", size = 10605963, upload-time = "2026-03-26T18:39:46.682Z" }, + { url = "https://files.pythonhosted.org/packages/19/aa/d624b86f5b0aad7cef6bbf9cd47a6a02dfdc4f72c92a337d724e39c9d14b/ruff-0.15.8-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8d9a5b8ea13f26ae90838afc33f91b547e61b794865374f114f349e9036835fb", size = 10357484, upload-time = "2026-03-26T18:39:49.176Z" }, + { url = "https://files.pythonhosted.org/packages/35/c3/e0b7835d23001f7d999f3895c6b569927c4d39912286897f625736e1fd04/ruff-0.15.8-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c2a33a529fb3cbc23a7124b5c6ff121e4d6228029cba374777bd7649cc8598b8", size = 10830426, upload-time = "2026-03-26T18:40:03.702Z" }, + { url = "https://files.pythonhosted.org/packages/f0/51/ab20b322f637b369383adc341d761eaaa0f0203d6b9a7421cd6e783d81b9/ruff-0.15.8-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:75e5cd06b1cf3f47a3996cfc999226b19aa92e7cce682dcd62f80d7035f98f49", size = 11345125, upload-time = "2026-03-26T18:39:27.799Z" }, + { url = "https://files.pythonhosted.org/packages/37/e6/90b2b33419f59d0f2c4c8a48a4b74b460709a557e8e0064cf33ad894f983/ruff-0.15.8-py3-none-win32.whl", hash = "sha256:bc1f0a51254ba21767bfa9a8b5013ca8149dcf38092e6a9eb704d876de94dc34", size = 10571959, upload-time = "2026-03-26T18:39:36.117Z" }, + { url = "https://files.pythonhosted.org/packages/1f/a2/ef467cb77099062317154c63f234b8a7baf7cb690b99af760c5b68b9ee7f/ruff-0.15.8-py3-none-win_amd64.whl", hash = "sha256:04f79eff02a72db209d47d665ba7ebcad609d8918a134f86cb13dd132159fc89", size = 11743893, upload-time = "2026-03-26T18:39:25.01Z" }, + { url = "https://files.pythonhosted.org/packages/15/e2/77be4fff062fa78d9b2a4dea85d14785dac5f1d0c1fb58ed52331f0ebe28/ruff-0.15.8-py3-none-win_arm64.whl", hash = "sha256:cf891fa8e3bb430c0e7fac93851a5978fc99c8fa2c053b57b118972866f8e5f2", size = 11048175, upload-time = "2026-03-26T18:40:01.06Z" }, ] [[package]] @@ -936,26 +936,26 @@ wheels = [ [[package]] name = "ty" -version = "0.0.25" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/12/bf/3c3147c7237277b0e8a911ff89de7183408be96b31fb42b38edb666d287f/ty-0.0.25.tar.gz", hash = "sha256:8ae3891be17dfb6acab51a2df3a8f8f6c551eb60ea674c10946dc92aae8d4401", size = 5375500, upload-time = "2026-03-24T22:32:34.608Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/97/a4/6c289cbd1474285223124a4ffb55c078dbe9ae1d925d0b6a948643c7f115/ty-0.0.25-py3-none-linux_armv6l.whl", hash = "sha256:26d6d5aede5d54fb055779460f896d9c1473c6fb996716bd11cb90f027d8fee7", size = 10452747, upload-time = "2026-03-24T22:32:32.662Z" }, - { url = "https://files.pythonhosted.org/packages/00/13/74cb9de356b9ceb3f281ab048f8c4ac2207122161b0ac0066886ce129abe/ty-0.0.25-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:aedcfbc7b6b96dbc55b0da78fa02bd049373ff3d8a827f613dadd8bd17d10758", size = 10271349, upload-time = "2026-03-24T22:32:13.041Z" }, - { url = "https://files.pythonhosted.org/packages/0e/93/ffc5a20cc9e14fa9b32b0c54884864bede30d144ce2ae013805bce0c86d0/ty-0.0.25-py3-none-macosx_11_0_arm64.whl", hash = "sha256:0a8fb3c1e28f73618941811e2568dca195178a1a6314651d4ee97086a4497253", size = 9730308, upload-time = "2026-03-24T22:32:19.24Z" }, - { url = "https://files.pythonhosted.org/packages/6d/78/52e05ef32a5f172fce70633a4e19d8e04364271a4322ae12382c7344b0de/ty-0.0.25-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:814870b7f347b5d0276304cddb98a0958f08de183bf159abc920ebe321247ad4", size = 10247664, upload-time = "2026-03-24T22:32:08.669Z" }, - { url = "https://files.pythonhosted.org/packages/c2/64/0d0a47ed0aa1d634c666c2cc15d3b0af4b95d0fd3dbb796032bd493f3433/ty-0.0.25-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:781150e23825dc110cd5e1f50ca3d61664f7a5db5b4a55d5dbf7d3b1e246b917", size = 10261961, upload-time = "2026-03-24T22:32:43.935Z" }, - { url = "https://files.pythonhosted.org/packages/3e/ba/4666b96f0499465efb97c244554107c541d74a1add393e62276b3de9b54f/ty-0.0.25-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffc81ff2a0143911321251dc81d1c259fa5cdc56d043019a733c845d55409e2a", size = 10746076, upload-time = "2026-03-24T22:32:26.37Z" }, - { url = "https://files.pythonhosted.org/packages/e7/ed/aa958ccbcd85cc206600e48fbf0a1c27aef54b4b90112d9a73f69ed0c739/ty-0.0.25-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f03c5c5b5c10355ea030cbe3cd93b2e759b9492c66688288ea03a68086069f2e", size = 11287331, upload-time = "2026-03-24T22:32:21.607Z" }, - { url = "https://files.pythonhosted.org/packages/26/e4/f4a004e1952e6042f5bfeeb7d09cffb379270ef009d9f8568471863e86e6/ty-0.0.25-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7fc1ef49cd6262eb9223ccf6e258ac899aaa53e7dc2151ba65a2c9fa248dfa75", size = 11028804, upload-time = "2026-03-24T22:32:39.088Z" }, - { url = "https://files.pythonhosted.org/packages/56/32/5c15bb8ea20ed54d43c734f253a2a5da95d41474caecf4ef3682df9f68f5/ty-0.0.25-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7ad98da1393161096235a387cc36abecd31861060c68416761eccdb7c1bc326b", size = 10845246, upload-time = "2026-03-24T22:32:41.33Z" }, - { url = "https://files.pythonhosted.org/packages/6f/fe/4ddd83e810c8682fcfada0d1c9d38936a34a024d32d7736075c1e53a038e/ty-0.0.25-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:2d4336aa5381eb4eab107c3dec75fe22943a648ef6646f5a8431ef1c8cdabb66", size = 10233515, upload-time = "2026-03-24T22:32:17.012Z" }, - { url = "https://files.pythonhosted.org/packages/ad/db/9fe54f6fb952e5b218f2e661e64ed656512edf2046cfbb9c159558e255db/ty-0.0.25-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e10ed39564227de2b7bd89398250b65daaedbef15a25cef8eee70078f5d9e0b2", size = 10275289, upload-time = "2026-03-24T22:32:28.21Z" }, - { url = "https://files.pythonhosted.org/packages/b1/e0/090d7b33791b42bc7ec29463ac6a634738e16b289e027608ebe542682773/ty-0.0.25-py3-none-musllinux_1_2_i686.whl", hash = "sha256:aca04e9ed9b61c706064a1c0b71a247c3f92f373d0222103f3bc54b649421796", size = 10461195, upload-time = "2026-03-24T22:32:24.252Z" }, - { url = "https://files.pythonhosted.org/packages/42/31/5bf12bce01b80b72a7a4e627380779b41510e730f6000862a1d078e423f7/ty-0.0.25-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:18a5443e4ef339c1bd8c57fc13112c22080617ea582bfc22b497d82d65361325", size = 10931471, upload-time = "2026-03-24T22:32:14.985Z" }, - { url = "https://files.pythonhosted.org/packages/6a/5e/ab60c11f8a6dd2a0ae96daac83458ef2e9be1ae70481d1ad9c59d3eaf20f/ty-0.0.25-py3-none-win32.whl", hash = "sha256:a685b9a611b69195b5a557e05dbb7ebcd12815f6c32fb27fdf15edeb1fa33d8f", size = 9835974, upload-time = "2026-03-24T22:32:36.86Z" }, - { url = "https://files.pythonhosted.org/packages/41/55/625acc2ef34646268bc2baa8fdd6e22fb47cd5965e2acd3be92c687fb6b0/ty-0.0.25-py3-none-win_amd64.whl", hash = "sha256:0d4d37a1f1ab7f2669c941c38c65144ff223eb51ececd7ccfc0d623afbc0f729", size = 10815449, upload-time = "2026-03-24T22:32:11.031Z" }, - { url = "https://files.pythonhosted.org/packages/82/c7/0147bfb543df97740b45b222c54ff79ef20fa57f14b9d2c1dab3cd7d3faa/ty-0.0.25-py3-none-win_arm64.whl", hash = "sha256:d80b8cd965cbacbfd887ac2d985f5b6da09b7aa3569371e2894e0b30b26b89cd", size = 10225494, upload-time = "2026-03-24T22:32:30.611Z" }, +version = "0.0.26" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/18/94/4879b81f8681117ccaf31544579304f6dc2ddcc0c67f872afb35869643a2/ty-0.0.26.tar.gz", hash = "sha256:0496b62405d62de7b954d6d677dc1cc5d3046197215d7a0a7fef37745d7b6d29", size = 5393643, upload-time = "2026-03-26T16:27:11.067Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/24/99fe33ecd7e16d23c53b0d4244778c6d1b6eb1663b091236dcba22882d67/ty-0.0.26-py3-none-linux_armv6l.whl", hash = "sha256:35beaa56cf59725fd59ab35d8445bbd40b97fe76db39b052b1fcb31f9bf8adf7", size = 10521856, upload-time = "2026-03-26T16:27:06.335Z" }, + { url = "https://files.pythonhosted.org/packages/55/97/1b5e939e2ff69b9bb279ab680bfa8f677d886309a1ac8d9588fd6ce58146/ty-0.0.26-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:487a0be58ab0eb02e31ba71eb6953812a0f88e50633469b0c0ce3fb795fe0fa1", size = 10320958, upload-time = "2026-03-26T16:27:13.849Z" }, + { url = "https://files.pythonhosted.org/packages/71/25/37081461e13d38a190e5646948d7bc42084f7bd1c6b44f12550be3923e7e/ty-0.0.26-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a01b7de5693379646d423b68f119719a1338a20017ba48a93eefaff1ee56f97b", size = 9799905, upload-time = "2026-03-26T16:26:55.805Z" }, + { url = "https://files.pythonhosted.org/packages/a1/1c/295d8f55a7b0e037dfc3a5ec4bdda3ab3cbca6f492f725bf269f96a4d841/ty-0.0.26-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:628c3ee869d113dd2bd249925662fd39d9d0305a6cb38f640ddaa7436b74a1ef", size = 10317507, upload-time = "2026-03-26T16:27:31.887Z" }, + { url = "https://files.pythonhosted.org/packages/1d/62/48b3875c5d2f48fe017468d4bbdde1164c76a8184374f1d5e6162cf7d9b8/ty-0.0.26-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:63d04f35f5370cbc91c0b9675dc83e0c53678125a7b629c9c95769e86f123e65", size = 10319821, upload-time = "2026-03-26T16:27:29.647Z" }, + { url = "https://files.pythonhosted.org/packages/ff/28/cfb2d495046d5bf42d532325cea7412fa1189912d549dbfae417a24fd794/ty-0.0.26-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4a53c4e6f6a91927f8b90e584a4b12bcde05b0c1870ddff8d17462168ad7947a", size = 10831757, upload-time = "2026-03-26T16:27:37.441Z" }, + { url = "https://files.pythonhosted.org/packages/26/bf/dbc3e42f448a2d862651de070b4108028c543ca18cab096b38d7de449915/ty-0.0.26-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:caf2ced0e58d898d5e3ba5cb843e0ebd377c8a461464748586049afbd9321f51", size = 11369556, upload-time = "2026-03-26T16:26:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/92/4c/6d2f8f34bc6d502ab778c9345a4a936a72ae113de11329c1764bb1f204f6/ty-0.0.26-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:384807bbcb7d7ce9b97ee5aaa6417a8ae03ccfb426c52b08018ca62cf60f5430", size = 11085679, upload-time = "2026-03-26T16:27:21.746Z" }, + { url = "https://files.pythonhosted.org/packages/cc/f4/f3f61c203bc980dd9bba0ba7ed3c6e81ddfd36b286330f9487c2c7d041aa/ty-0.0.26-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8a2c766a94d79b4f82995d41229702caf2d76e5c440ec7e543d05c70e98bf8ab", size = 10900581, upload-time = "2026-03-26T16:27:24.39Z" }, + { url = "https://files.pythonhosted.org/packages/3d/fd/3ca1b4e4bdd129829e9ce78677e0f8e0f1038a7702dccecfa52f037c6046/ty-0.0.26-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:f41ac45a0f8e3e8e181508d863a0a62156341db0f624ffd004b97ee550a9de80", size = 10294401, upload-time = "2026-03-26T16:27:03.999Z" }, + { url = "https://files.pythonhosted.org/packages/de/20/4ee3d8c3f90e008843795c765cb8bb245f188c23e5e5cc612c7697406fba/ty-0.0.26-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:73eb8327a34d529438dfe4db46796946c4e825167cbee434dc148569892e435f", size = 10351469, upload-time = "2026-03-26T16:27:19.003Z" }, + { url = "https://files.pythonhosted.org/packages/3d/b1/9fb154ade65906d4148f0b999c4a8257c2a34253cb72e15d84c1f04a064e/ty-0.0.26-py3-none-musllinux_1_2_i686.whl", hash = "sha256:4bb53a79259516535a1b55f613ba1619e9c666854946474ca8418c35a5c4fd60", size = 10529488, upload-time = "2026-03-26T16:27:01.378Z" }, + { url = "https://files.pythonhosted.org/packages/a5/70/9b02b03b1862e27b64143db65946d68b138160a5b6bfea193bee0b8bbc34/ty-0.0.26-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:2f0e75edc1aeb1b4b84af516c7891f631254a4ca3dcd15e848fa1e061e1fe9da", size = 10999015, upload-time = "2026-03-26T16:27:34.636Z" }, + { url = "https://files.pythonhosted.org/packages/21/16/0a56b8667296e2989b9d48095472d98ebf57a0006c71f2a101bbc62a142d/ty-0.0.26-py3-none-win32.whl", hash = "sha256:943c998c5523ed6b519c899c0c39b26b4c751a9759e460fb964765a44cde226f", size = 9912378, upload-time = "2026-03-26T16:27:08.999Z" }, + { url = "https://files.pythonhosted.org/packages/60/c2/fef0d4bba9cd89a82d725b3b1a66efb1b36629ecf0fb1d8e916cb75b8829/ty-0.0.26-py3-none-win_amd64.whl", hash = "sha256:19c856d343efeb1ecad8ee220848f5d2c424daf7b2feda357763ad3036e2172f", size = 10863737, upload-time = "2026-03-26T16:27:27.06Z" }, + { url = "https://files.pythonhosted.org/packages/4d/05/888ebcb3c4d3b6b72d5d3241fddd299142caa3c516e6d26a9cd887dfed3b/ty-0.0.26-py3-none-win_arm64.whl", hash = "sha256:2cde58ccffa046db1223dc28f3e7d4f2c7da8267e97cc5cd186af6fe85f1758a", size = 10285408, upload-time = "2026-03-26T16:27:16.432Z" }, ] [[package]]