From ec809690e6a6bc938ddfaafeadfc527dfc43cc5a Mon Sep 17 00:00:00 2001 From: Marcin Zieba Date: Thu, 27 Aug 2026 15:38:30 +0200 Subject: [PATCH 1/4] fix(export): accept vendor tuples in the GraphQL manufacturer filter --vendors is parsed into a tuple, but _build_manufacturer_filter accepted only a list. Every "--export-diff --vendors X" run raised ValueError before the first fetch. The filter now accepts any non-string sequence of non-empty strings and normalizes it to a list for the GraphQL variables. It still rejects a bare string, non-string items, and blank items. Exporter.__init__ normalizes an empty vendor selection to None, the "all vendors" sentinel. This removes four repeated "if x else None" guards at the call sites. get_component_templates now rejects a non-string manufacturer_slug instead of building a broken filter. The bug survived because exporter tests stubbed the GraphQL client or passed hand-written lists. New end-to-end tests run the CLI entry point with nothing stubbed below argument parsing and assert the GraphQL request variables, including one test against a local HTTP server that checks the filter as serialized on the wire. All new tests were verified to fail against the unfixed code. Existing fake configs now use tuples, matching the real config type. Some type annotations in these files support the mypy gate added in the next commit. --- core/export.py | 27 ++++--- core/graphql_client.py | 60 ++++++++++------ nb-dt-import.py | 8 +-- tests/test_exporter.py | 24 ++++++- tests/test_graphql_client.py | 110 ++++++++++++++++++++++------ tests/test_nb_dt_import.py | 135 +++++++++++++++++++++++++++++++++-- 6 files changed, 296 insertions(+), 68 deletions(-) diff --git a/core/export.py b/core/export.py index c3e43aa1..0a79ae1d 100644 --- a/core/export.py +++ b/core/export.py @@ -9,7 +9,7 @@ import threading from dataclasses import dataclass from pathlib import Path -from typing import Any, List, Optional +from typing import Any, List, Optional, Sequence import requests import yaml @@ -30,7 +30,12 @@ from core.netbox_api import IMAGE_EXTENSIONS, _build_auth_header from core.repo import LIBRARY_TYPE_DIRS, library_dirs_present -_SKIP = object() # sentinel: image already exists, no download needed + +class _SkipSentinel: + """Type of the ``_SKIP`` sentinel: the image already exists, so no download is needed.""" + + +_SKIP = _SkipSentinel() # Maps Content-Type to a canonical extension for extension-less attachments. _CONTENT_TYPE_EXT = { @@ -191,13 +196,14 @@ def _is_subset(sub: Any, sup: Any) -> bool: class Exporter: """Exports NetBox device/module/rack types to a local directory in DTL format.""" - def __init__(self, config, handle, export_dir: str, force_overwrite: bool, vendor_slugs: Optional[List[str]]): + def __init__(self, config, handle, export_dir: str, force_overwrite: bool, vendor_slugs: Optional[Sequence[str]]): """Initialize the Exporter from the resolved run configuration.""" self.config = config self.handle = handle self.export_dir = Path(export_dir) self.force_overwrite = force_overwrite - self.vendor_slugs = vendor_slugs # None means all vendors + # None means all vendors. The GraphQL layer rejects an empty sequence, so normalize one here. + self.vendor_slugs = tuple(vendor_slugs) if vendor_slugs else None self.repo_path = Path(config.repo_path) self.base_url = config.netbox_url.rstrip("/") self.token = config.netbox_token @@ -233,11 +239,9 @@ def run(self, progress=None) -> None: self.handle.log(f"Export-diff: fetching NetBox device/module/rack types{scope}") # ── Fetch all types from NetBox ────────────────────────────────────── - by_model, by_slug = self.graphql.get_device_types( - manufacturer_slugs=self.vendor_slugs if self.vendor_slugs else None - ) - all_mt = self.graphql.get_module_types(manufacturer_slugs=self.vendor_slugs if self.vendor_slugs else None) - all_rt = self.graphql.get_rack_types(manufacturer_slugs=self.vendor_slugs if self.vendor_slugs else None) + by_model, by_slug = self.graphql.get_device_types(manufacturer_slugs=self.vendor_slugs) + all_mt = self.graphql.get_module_types(manufacturer_slugs=self.vendor_slugs) + all_rt = self.graphql.get_rack_types(manufacturer_slugs=self.vendor_slugs) total_dt = len(by_model) total_mt = sum(len(v) for v in all_mt.values()) @@ -671,6 +675,7 @@ def _determine_export_set_for_device_types( manifest_key = f"{mfr_name}/{rec.slug}" repo_yaml = repo_dt_by_slug.get((mfr_slug, rec.slug)) + reason: Optional[str] if repo_yaml is None: reason = "absent" elif _repo_supersedes(repo_yaml, serialized): @@ -911,7 +916,9 @@ def _download_module_type_images(self, item: ExportItem) -> bool: ok = False return ok - def _download_image(self, url_path: str, dest: Path, content_type_out: "Optional[list]" = None) -> "Optional[str]": + def _download_image( + self, url_path: str, dest: Path, content_type_out: "Optional[list]" = None + ) -> "str | _SkipSentinel | None": """Download an image from NetBox and write to *dest*. Returns SHA-256 hex digest on success, None on failure, or the module-level diff --git a/core/graphql_client.py b/core/graphql_client.py index 953d4d59..6c4821ab 100644 --- a/core/graphql_client.py +++ b/core/graphql_client.py @@ -7,6 +7,7 @@ import threading import time +from collections.abc import Sequence import requests @@ -78,7 +79,7 @@ def _to_dotdict(obj): IDs as strings but the rest of the codebase expects integer IDs. """ if isinstance(obj, dict): - converted = {} + converted: dict = {} for k, v in obj.items(): if k == "id" and isinstance(v, str): try: @@ -363,15 +364,21 @@ def _build_manufacturer_filter(self, slugs): :meth:`query_all`. Args: - slugs: ``None`` or a non-empty list of manufacturer slug strings. + slugs: ``None`` or a non-empty sequence (list or tuple) of manufacturer + slug strings. Returns: tuple[str, str, dict] """ if not slugs: return "", "", {} - if not isinstance(slugs, list) or any(not isinstance(s, str) or not s.strip() for s in slugs): - raise ValueError("manufacturer_slugs must be None or a non-empty list of non-empty strings") + # A str is itself a Sequence of characters, so reject it before the item check. + if ( + isinstance(slugs, (str, bytes)) + or not isinstance(slugs, Sequence) + or any(not isinstance(s, str) or not s.strip() for s in slugs) + ): + raise ValueError("manufacturer_slugs must be None or a non-empty sequence of non-empty strings") slugs = [s.strip() for s in slugs] if len(slugs) == 1: return ( @@ -389,8 +396,9 @@ def get_device_types(self, manufacturer_slugs=None): """Fetch all device types and return two lookup indexes. Args: - manufacturer_slugs: Optional list of manufacturer slugs to filter by. - When provided, only device types from the specified manufacturers are returned. + manufacturer_slugs: Optional sequence (list or tuple) of manufacturer slugs + to filter by. When provided, only device types from the specified + manufacturers are returned. Returns: tuple[dict, dict]: @@ -398,10 +406,10 @@ def get_device_types(self, manufacturer_slugs=None): - ``by_slug``: ``{(manufacturer_slug, slug): record}`` Raises: - ValueError: If *manufacturer_slugs* is an empty list. + ValueError: If *manufacturer_slugs* is an empty sequence. """ if manufacturer_slugs is not None and len(manufacturer_slugs) == 0: - raise ValueError("manufacturer_slugs must be None or a non-empty list") + raise ValueError("manufacturer_slugs must be None or a non-empty sequence") var_decl, filter_fragment, extra_vars = self._build_manufacturer_filter(manufacturer_slugs) @@ -452,17 +460,18 @@ def get_module_types(self, manufacturer_slugs=None): """Fetch all module types and return them indexed by manufacturer slug and model. Args: - manufacturer_slugs: Optional list of manufacturer slugs to filter by. - When provided, only module types from the specified manufacturers are returned. + manufacturer_slugs: Optional sequence (list or tuple) of manufacturer slugs + to filter by. When provided, only module types from the specified + manufacturers are returned. Returns: dict: ``{manufacturer_slug: {model: record}}`` Raises: - ValueError: If *manufacturer_slugs* is an empty list. + ValueError: If *manufacturer_slugs* is an empty sequence. """ if manufacturer_slugs is not None and len(manufacturer_slugs) == 0: - raise ValueError("manufacturer_slugs must be None or a non-empty list") + raise ValueError("manufacturer_slugs must be None or a non-empty sequence") var_decl, filter_fragment, extra_vars = self._build_manufacturer_filter(manufacturer_slugs) @@ -488,7 +497,7 @@ def get_module_types(self, manufacturer_slugs=None): """ items = self.query_all(query, list_key="module_type_list", variables=extra_vars or None) - result = {} + result: dict = {} for item in items: record = _to_dotdict(item) mfr_slug = record.manufacturer.slug @@ -500,17 +509,18 @@ def get_rack_types(self, manufacturer_slugs=None): """Fetch all rack types and return them indexed by manufacturer slug and model. Args: - manufacturer_slugs: Optional list of manufacturer slugs to filter by. - When provided, only rack types from the specified manufacturers are returned. + manufacturer_slugs: Optional sequence (list or tuple) of manufacturer slugs + to filter by. When provided, only rack types from the specified + manufacturers are returned. Returns: dict: ``{manufacturer_slug: {model: record}}`` Raises: - ValueError: If *manufacturer_slugs* is an empty list. + ValueError: If *manufacturer_slugs* is an empty sequence. """ if manufacturer_slugs is not None and len(manufacturer_slugs) == 0: - raise ValueError("manufacturer_slugs must be None or a non-empty list") + raise ValueError("manufacturer_slugs must be None or a non-empty sequence") var_decl, filter_fragment, extra_vars = self._build_manufacturer_filter(manufacturer_slugs) @@ -545,7 +555,7 @@ def get_rack_types(self, manufacturer_slugs=None): }} """ items = self.query_all(query, list_key="rack_type_list", variables=extra_vars or None) - result = {} + result: dict = {} for item in items: record = _to_dotdict(item) mfr_slug = record.manufacturer.slug @@ -600,7 +610,7 @@ def get_module_type_images(self): and (i.get("object_type") or {}).get("model") == "moduletype" ] - result = {} + result: dict = {} for item in items: name = item.get("name") if not name: @@ -660,7 +670,7 @@ def get_module_type_image_details(self): and (i.get("object_type") or {}).get("model") == "moduletype" ] - result = {} + result: dict = {} for item in items: name = item.get("name") if not name: @@ -743,7 +753,8 @@ def _build_query(field_list): # Only a schema rejection means the tier is unsupported. Transport failures # propagate: falling back on those queries an older tier against a server that # supports the newer one, silently dropping the mappings data. - original_exc = last_exc = None + original_exc: GraphQLSchemaError | None = None + last_exc: GraphQLSchemaError | None = None for variant in field_variants: try: return self.query_all( @@ -754,6 +765,8 @@ def _build_query(field_list): if original_exc is None: original_exc = exc + if last_exc is None: # pragma: no cover - every variant list carries at least one entry + raise GraphQLError(f"No field variant was attempted for {list_key}") if original_exc is last_exc: raise last_exc raise last_exc from original_exc @@ -776,13 +789,14 @@ def get_component_templates(self, endpoint_name, manufacturer_slug=None, on_page Raises: ValueError: If *endpoint_name* is not a recognized component template endpoint. - ValueError: If *manufacturer_slug* is an empty string. + ValueError: If *manufacturer_slug* is anything but None or a non-blank string. """ component = BY_ENDPOINT.get(endpoint_name) if component is None: raise ValueError(f"Unknown component endpoint: {endpoint_name}") - if manufacturer_slug is not None and len(manufacturer_slug) == 0: + # A sequence would pass a bare length check and build a filter that matches nothing. + if manufacturer_slug is not None and (not isinstance(manufacturer_slug, str) or not manufacturer_slug.strip()): raise ValueError("manufacturer_slug must be None or a non-empty string") fields = component.graphql_fields diff --git a/nb-dt-import.py b/nb-dt-import.py index acb339ab..6d53f2da 100644 --- a/nb-dt-import.py +++ b/nb-dt-import.py @@ -22,7 +22,7 @@ ) from rich.text import Text -from core.config import ConfigError, resolve_run_config +from core.config import ConfigError, RunConfig, resolve_run_config from core.errors import FatalError from core.graphql_client import GraphQLError from core.import_run import ImportRun @@ -112,7 +112,7 @@ def get_progress_panel(show_remaining_time=False): yield progress -def _run_export_diff(config, handle): +def _run_export_diff(config: RunConfig, handle): """Run the export-diff pipeline.""" from core.export import Exporter @@ -121,7 +121,7 @@ def _run_export_diff(config, handle): handle=handle, export_dir=config.export_diff_dir, force_overwrite=config.force_export_overwrite, - vendor_slugs=config.vendors if config.vendors else None, + vendor_slugs=config.vendors, ) with get_progress_panel(config.show_remaining_time) as progress: if progress is not None: @@ -133,7 +133,7 @@ def _run_export_diff(config, handle): handle.set_console(None) -def _run(config): +def _run(config: RunConfig): """Build and execute the selected run pipeline.""" started_at = datetime.now() handle = LogHandler(config.verbose) diff --git a/tests/test_exporter.py b/tests/test_exporter.py index a356e528..e14d75e1 100644 --- a/tests/test_exporter.py +++ b/tests/test_exporter.py @@ -715,6 +715,24 @@ def test_absent_module_type_triggers_export(self, tmp_path): assert items[0].kind == "module-type" +class TestVendorSlugNormalization: + """vendor_slugs reaches every downstream call, so the Exporter normalizes it once.""" + + def _make_exporter(self, tmp_path, vendor_slugs): + settings = _make_settings(tmp_path) + return Exporter(settings, _make_handle(), str(tmp_path / "extra"), False, vendor_slugs) + + @pytest.mark.parametrize("empty", [(), [], None], ids=["tuple", "list", "none"]) + def test_empty_selection_becomes_all_vendors(self, tmp_path, empty): + """The GraphQL layer rejects an empty sequence, so an empty selection must not reach it.""" + assert self._make_exporter(tmp_path, empty).vendor_slugs is None + + def test_selection_is_kept_as_a_tuple(self, tmp_path): + """--vendors arrives from the CLI as a tuple and stays one.""" + assert self._make_exporter(tmp_path, ("nokia",)).vendor_slugs == ("nokia",) + assert self._make_exporter(tmp_path, ["nokia"]).vendor_slugs == ("nokia",) + + class TestVendorDirSlugNormalization: """Tests for Exporter._vendor_dirs slug-based directory matching.""" @@ -725,7 +743,7 @@ def _make_exporter(self, tmp_path, vendor_slugs): def test_single_word_dir_matches_slug(self, tmp_path): root = tmp_path / "device-types" (root / "Nokia").mkdir(parents=True) - exporter = self._make_exporter(tmp_path, ["nokia"]) + exporter = self._make_exporter(tmp_path, ("nokia",)) dirs = list(exporter._vendor_dirs(root)) assert len(dirs) == 1 assert dirs[0].name == "Nokia" @@ -734,7 +752,7 @@ def test_multi_word_dir_matches_hyphenated_slug(self, tmp_path): """'Extreme Networks' dir must match slug 'extreme-networks'.""" root = tmp_path / "device-types" (root / "Extreme Networks").mkdir(parents=True) - exporter = self._make_exporter(tmp_path, ["extreme-networks"]) + exporter = self._make_exporter(tmp_path, ("extreme-networks",)) dirs = list(exporter._vendor_dirs(root)) assert len(dirs) == 1 assert dirs[0].name == "Extreme Networks" @@ -743,7 +761,7 @@ def test_non_matching_vendor_excluded(self, tmp_path): root = tmp_path / "device-types" (root / "Nokia").mkdir(parents=True) (root / "Juniper").mkdir(parents=True) - exporter = self._make_exporter(tmp_path, ["nokia"]) + exporter = self._make_exporter(tmp_path, ("nokia",)) dirs = list(exporter._vendor_dirs(root)) assert len(dirs) == 1 assert dirs[0].name == "Nokia" diff --git a/tests/test_graphql_client.py b/tests/test_graphql_client.py index 39d9376c..d11ee36a 100644 --- a/tests/test_graphql_client.py +++ b/tests/test_graphql_client.py @@ -1682,6 +1682,53 @@ def test_request_exception_retries_before_exhausting(self, mock_post): # ── Vendor-scoped filtering tests ───────────────────────────────────────── +class TestBuildManufacturerFilter: + """Tests for _build_manufacturer_filter() input handling.""" + + def _make_client(self): + from core.graphql_client import NetBoxGraphQLClient + + return NetBoxGraphQLClient("http://netbox.local", "tok") + + def test_single_slug_tuple_matches_list(self): + """config.vendors is a tuple, so a tuple must build the same filter as a list.""" + client = self._make_client() + assert client._build_manufacturer_filter(("cisco",)) == client._build_manufacturer_filter(["cisco"]) + + def test_multiple_slug_tuple_matches_list(self): + """A multi-slug tuple must build the same filter as the equivalent list.""" + client = self._make_client() + assert client._build_manufacturer_filter(("cisco", "juniper")) == client._build_manufacturer_filter( + ["cisco", "juniper"] + ) + + def test_tuple_slugs_are_stripped_and_sent_as_list(self): + """The GraphQL variable must be a JSON-serializable list, not the incoming tuple.""" + client = self._make_client() + var_decl, filter_fragment, variables = client._build_manufacturer_filter((" cisco ", "juniper ")) + assert var_decl == ", $manufacturer_slugs: [String!]!" + assert filter_fragment == "filters: {manufacturer: {slug: {in_list: $manufacturer_slugs}}}, " + assert variables == {"manufacturer_slugs": ["cisco", "juniper"]} + + def test_bare_string_raises_value_error(self): + """A bare string is a sequence of characters, not a sequence of slugs.""" + client = self._make_client() + with pytest.raises(ValueError, match="manufacturer_slugs must be None or a non-empty"): + client._build_manufacturer_filter("cisco") + + def test_non_string_item_raises_value_error(self): + + client = self._make_client() + with pytest.raises(ValueError, match="manufacturer_slugs must be None or a non-empty"): + client._build_manufacturer_filter(("cisco", 5)) + + def test_blank_item_raises_value_error(self): + + client = self._make_client() + with pytest.raises(ValueError, match="manufacturer_slugs must be None or a non-empty"): + client._build_manufacturer_filter(("cisco", " ")) + + class TestVendorScopedDeviceTypes: """Tests for vendor-scoped filtering in get_device_types().""" @@ -1690,7 +1737,8 @@ def _make_client(self): return NetBoxGraphQLClient("http://netbox.local", "tok") - def test_single_vendor_filter(self, mock_post): + @pytest.mark.parametrize("slugs", [["cisco"], ("cisco",)], ids=["list", "tuple"]) + def test_single_vendor_filter(self, mock_post, slugs): """Test filtering by a single manufacturer slug.""" data = { "device_type_list": [ @@ -1716,7 +1764,7 @@ def test_single_vendor_filter(self, mock_post): mock_post.side_effect = _make_paged_responses(data, "device_type_list") client = self._make_client() - by_model, by_slug = client.get_device_types(manufacturer_slugs=["cisco"]) + by_model, by_slug = client.get_device_types(manufacturer_slugs=slugs) assert ("cisco", "Catalyst 3850") in by_model assert by_model[("cisco", "Catalyst 3850")].model == "Catalyst 3850" @@ -1728,7 +1776,8 @@ def test_single_vendor_filter(self, mock_post): assert "$manufacturer_slug: String!" in query assert variables["manufacturer_slug"] == "cisco" - def test_multiple_vendor_filter(self, mock_post): + @pytest.mark.parametrize("slugs", [["cisco", "juniper"], ("cisco", "juniper")], ids=["list", "tuple"]) + def test_multiple_vendor_filter(self, mock_post, slugs): """Test filtering by multiple manufacturer slugs.""" data = { "device_type_list": [ @@ -1771,7 +1820,7 @@ def test_multiple_vendor_filter(self, mock_post): mock_post.side_effect = _make_paged_responses(data, "device_type_list") client = self._make_client() - by_model, by_slug = client.get_device_types(manufacturer_slugs=["cisco", "juniper"]) + by_model, by_slug = client.get_device_types(manufacturer_slugs=slugs) assert ("cisco", "Catalyst 3850") in by_model assert ("juniper", "EX4300") in by_model @@ -1818,11 +1867,12 @@ def test_none_manufacturer_slugs_unfiltered(self, mock_post): assert "filters:" not in query assert "manufacturer_slug" not in variables - def test_empty_list_raises_value_error(self): - """Passing [] for manufacturer_slugs should raise ValueError immediately.""" + @pytest.mark.parametrize("slugs", [[], ()], ids=["list", "tuple"]) + def test_empty_sequence_raises_value_error(self, slugs): + """An empty manufacturer_slugs sequence should raise ValueError immediately.""" client = self._make_client() - with pytest.raises(ValueError, match="manufacturer_slugs must be None or a non-empty list"): - client.get_device_types(manufacturer_slugs=[]) + with pytest.raises(ValueError, match="manufacturer_slugs must be None or a non-empty"): + client.get_device_types(manufacturer_slugs=slugs) class TestVendorScopedModuleTypes: @@ -1833,7 +1883,8 @@ def _make_client(self): return NetBoxGraphQLClient("http://netbox.local", "tok") - def test_single_vendor_filter(self, mock_post): + @pytest.mark.parametrize("slugs", [["cisco"], ("cisco",)], ids=["list", "tuple"]) + def test_single_vendor_filter(self, mock_post, slugs): """Test filtering by a single manufacturer slug.""" data = { "module_type_list": [ @@ -1853,7 +1904,7 @@ def test_single_vendor_filter(self, mock_post): mock_post.side_effect = _make_paged_responses(data, "module_type_list") client = self._make_client() - result = client.get_module_types(manufacturer_slugs=["cisco"]) + result = client.get_module_types(manufacturer_slugs=slugs) assert "cisco" in result assert "C9300-NM-8X" in result["cisco"] @@ -1865,7 +1916,8 @@ def test_single_vendor_filter(self, mock_post): assert "$manufacturer_slug: String!" in query assert variables["manufacturer_slug"] == "cisco" - def test_multiple_vendor_filter(self, mock_post): + @pytest.mark.parametrize("slugs", [["cisco", "juniper"], ("cisco", "juniper")], ids=["list", "tuple"]) + def test_multiple_vendor_filter(self, mock_post, slugs): """Test filtering by multiple manufacturer slugs.""" data = { "module_type_list": [ @@ -1896,7 +1948,7 @@ def test_multiple_vendor_filter(self, mock_post): mock_post.side_effect = _make_paged_responses(data, "module_type_list") client = self._make_client() - result = client.get_module_types(manufacturer_slugs=["cisco", "juniper"]) + result = client.get_module_types(manufacturer_slugs=slugs) assert "cisco" in result assert "juniper" in result @@ -1908,11 +1960,12 @@ def test_multiple_vendor_filter(self, mock_post): assert "$manufacturer_slugs: [String!]!" in query assert variables["manufacturer_slugs"] == ["cisco", "juniper"] - def test_empty_list_raises_value_error(self): - """Passing [] for manufacturer_slugs should raise ValueError immediately.""" + @pytest.mark.parametrize("slugs", [[], ()], ids=["list", "tuple"]) + def test_empty_sequence_raises_value_error(self, slugs): + """An empty manufacturer_slugs sequence should raise ValueError immediately.""" client = self._make_client() - with pytest.raises(ValueError, match="manufacturer_slugs must be None or a non-empty list"): - client.get_module_types(manufacturer_slugs=[]) + with pytest.raises(ValueError, match="manufacturer_slugs must be None or a non-empty"): + client.get_module_types(manufacturer_slugs=slugs) class TestVendorScopedRackTypes: @@ -1923,7 +1976,8 @@ def _make_client(self): return NetBoxGraphQLClient("http://netbox.local", "tok") - def test_single_vendor_filter(self, mock_post): + @pytest.mark.parametrize("slugs", [["apc"], ("apc",)], ids=["list", "tuple"]) + def test_single_vendor_filter(self, mock_post, slugs): """Test filtering by a single manufacturer slug.""" data = { "rack_type_list": [ @@ -1953,7 +2007,7 @@ def test_single_vendor_filter(self, mock_post): mock_post.side_effect = _make_paged_responses(data, "rack_type_list") client = self._make_client() - result = client.get_rack_types(manufacturer_slugs=["apc"]) + result = client.get_rack_types(manufacturer_slugs=slugs) assert "apc" in result assert "AR1300" in result["apc"] @@ -1965,11 +2019,12 @@ def test_single_vendor_filter(self, mock_post): assert "$manufacturer_slug: String!" in query assert variables["manufacturer_slug"] == "apc" - def test_empty_list_raises_value_error(self): - """Passing [] for manufacturer_slugs should raise ValueError immediately.""" + @pytest.mark.parametrize("slugs", [[], ()], ids=["list", "tuple"]) + def test_empty_sequence_raises_value_error(self, slugs): + """An empty manufacturer_slugs sequence should raise ValueError immediately.""" client = self._make_client() - with pytest.raises(ValueError, match="manufacturer_slugs must be None or a non-empty list"): - client.get_rack_types(manufacturer_slugs=[]) + with pytest.raises(ValueError, match="manufacturer_slugs must be None or a non-empty"): + client.get_rack_types(manufacturer_slugs=slugs) class TestVendorScopedComponentTemplates: @@ -2107,6 +2162,17 @@ def test_unfiltered_query(self, mock_post): query = call_args[1]["json"]["query"] assert "filters:" not in query + @pytest.mark.parametrize( + "slug", + ["", " ", ("cisco",), ["cisco"], 5], + ids=["empty", "blank", "tuple", "list", "int"], + ) + def test_non_string_or_blank_slug_raises_value_error(self, slug): + """The endpoint takes one slug: a sequence would build a filter that matches nothing.""" + client = self._make_client() + with pytest.raises(ValueError, match="manufacturer_slug must be None or a non-empty string"): + client.get_component_templates("interface_templates", manufacturer_slug=slug) + def test_vendor_scoped_two_queries(self, mock_post): """Test that vendor-scoped query makes two separate queries (device + module).""" device_data = { diff --git a/tests/test_nb_dt_import.py b/tests/test_nb_dt_import.py index 929231ca..31245d0b 100644 --- a/tests/test_nb_dt_import.py +++ b/tests/test_nb_dt_import.py @@ -1747,7 +1747,7 @@ def test_run_export_diff_wires_up_exporter(self, nb_dt_import): args = SimpleNamespace( export_diff_dir="extra", force_export_overwrite=True, - vendors=["nokia"], + vendors=("nokia",), show_remaining_time=True, ) @@ -1769,7 +1769,7 @@ def __exit__(self, exc_type, exc, tb): "handle": handle, "export_dir": "extra", "force_overwrite": True, - "vendor_slugs": ["nokia"], + "vendor_slugs": ("nokia",), } assert handle.set_console.call_args_list == [ ((progress.console,),), @@ -1777,12 +1777,12 @@ def __exit__(self, exc_type, exc, tb): ] MockExporter.return_value.run.assert_called_once_with(progress=progress) - def test_run_export_diff_sends_no_vendor_filter_when_vendors_is_empty(self, nb_dt_import): - """An empty --vendors must reach the exporter as None, not as an empty list.""" + def test_run_export_diff_passes_an_empty_vendor_selection_straight_through(self, nb_dt_import): + """The Exporter normalizes an empty selection to "all vendors"; the CLI does not pre-filter it.""" args = SimpleNamespace( export_diff_dir="extra", force_export_overwrite=False, - vendors=[], + vendors=(), show_remaining_time=False, ) @@ -1792,7 +1792,7 @@ def test_run_export_diff_sends_no_vendor_filter_when_vendors_is_empty(self, nb_d ): nb_dt_import._run_export_diff(args, MagicMock()) - assert MockExporter.call_args.kwargs["vendor_slugs"] is None + assert MockExporter.call_args.kwargs["vendor_slugs"] == () class TestMainAdditionalCoverage: @@ -2106,3 +2106,126 @@ def test_every_parser_flag_is_documented(self, nb_dt_import): def test_table_documents_no_removed_flags(self, nb_dt_import): stale = self._documented_flags() - self._parser_flags(nb_dt_import) assert not stale, f"README arguments table documents flags the parser no longer defines: {sorted(stale)}" + + +# --------------------------------------------------------------------------- +# Export-diff end-to-end: argv → resolve_run_config → main() → Exporter → GraphQL +# --------------------------------------------------------------------------- + +_LIST_FIELDS = ("device_type_list", "module_type_list", "rack_type_list") + + +def _run_export_diff_cli(nb_dt_import, monkeypatch, tmp_path, library_root, vendors=None): + """Run main() for one --export-diff invocation, stubbing nothing below the CLI.""" + monkeypatch.setenv("REPO_PATH", str(library_root)) + monkeypatch.delenv("VENDORS", raising=False) + argv = ["nb-dt-import.py", "--export-diff", "--export-diff-dir", str(tmp_path / "export")] + if vendors is not None: + argv += ["--vendors", vendors] + with patch.object(sys, "argv", argv): + nb_dt_import.main() + + +def _filters_by_list_field(payloads): + """Map each queried type-list field to the variables of its first GraphQL request.""" + seen = {} + for payload in payloads: + for field in _LIST_FIELDS: + if f"{field}(" in payload["query"]: + seen.setdefault(field, payload.get("variables", {})) + return seen + + +class TestExportDiffVendorFilterEndToEnd: + """The vendor filter must survive argv → RunConfig → Exporter → GraphQL untouched. + + Nothing below the CLI is stubbed: the real Exporter drives the real client, so a + config value the GraphQL layer rejects (config.vendors is a tuple) fails right here. + """ + + def test_single_vendor_reaches_the_graphql_request( + self, nb_dt_import, monkeypatch, tmp_path, capsys, mock_post, _real_library_root + ): + _run_export_diff_cli(nb_dt_import, monkeypatch, tmp_path, _real_library_root, "Cisco") + + payloads = [call.kwargs["json"] for call in mock_post.call_args_list] + filters = _filters_by_list_field(payloads) + assert set(filters) == set(_LIST_FIELDS) + for field, variables in filters.items(): + assert variables["manufacturer_slug"] == "cisco", field + assert isinstance(variables["manufacturer_slug"], str), field + assert "Nothing to export" in capsys.readouterr().out + + def test_multiple_vendors_are_sent_as_a_json_list( + self, nb_dt_import, monkeypatch, tmp_path, capsys, mock_post, _real_library_root + ): + """A tuple would not compare equal here, and NetBox would not accept it as a list variable.""" + _run_export_diff_cli(nb_dt_import, monkeypatch, tmp_path, _real_library_root, "Cisco,Juniper") + + payloads = [call.kwargs["json"] for call in mock_post.call_args_list] + filters = _filters_by_list_field(payloads) + assert set(filters) == set(_LIST_FIELDS) + for field, variables in filters.items(): + assert variables["manufacturer_slugs"] == ["cisco", "juniper"], field + assert isinstance(variables["manufacturer_slugs"], list), field + assert "Nothing to export" in capsys.readouterr().out + + def test_no_vendors_queries_every_manufacturer( + self, nb_dt_import, monkeypatch, tmp_path, capsys, mock_post, _real_library_root + ): + """Without --vendors, config.vendors is (), which the GraphQL layer rejects if it reaches it.""" + _run_export_diff_cli(nb_dt_import, monkeypatch, tmp_path, _real_library_root) + + payloads = [call.kwargs["json"] for call in mock_post.call_args_list] + filters = _filters_by_list_field(payloads) + assert set(filters) == set(_LIST_FIELDS) + for field, variables in filters.items(): + assert set(variables) == {"pagination"}, field + assert not any("filters:" in payload["query"] for payload in payloads) + assert "Nothing to export" in capsys.readouterr().out + + +@pytest.mark.real_http +class TestExportDiffVendorFilterOverRealHTTP: + """Same run against a local HTTP server, so the filter is asserted as it is serialized on the wire.""" + + @staticmethod + def _serve(): + """Serve empty GraphQL pages and record every decoded request body.""" + import json + import threading + from http.server import BaseHTTPRequestHandler, HTTPServer + + bodies = [] + + class Handler(BaseHTTPRequestHandler): + def do_POST(self): + length = int(self.headers["Content-Length"]) + bodies.append(json.loads(self.rfile.read(length))) + payload = b'{"data": {}}' + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + + def log_message(self, *args): + """Silence the default stderr access log.""" + + server = HTTPServer(("127.0.0.1", 0), Handler) + threading.Thread(target=server.serve_forever, daemon=True).start() + return f"http://127.0.0.1:{server.server_port}", server, bodies + + def test_vendor_filter_is_serialized_as_a_json_list(self, nb_dt_import, monkeypatch, tmp_path, _real_library_root): + url, server, bodies = self._serve() + monkeypatch.setenv("NETBOX_URL", url) + try: + _run_export_diff_cli(nb_dt_import, monkeypatch, tmp_path, _real_library_root, "Cisco,Juniper") + finally: + server.shutdown() + server.server_close() + + filters = _filters_by_list_field(bodies) + assert set(filters) == set(_LIST_FIELDS) + for field, variables in filters.items(): + assert variables["manufacturer_slugs"] == ["cisco", "juniper"], field From 9bae1c788d9dfdc141c7fcd8261fd1837daa1bda Mon Sep 17 00:00:00 2001 From: Marcin Zieba Date: Thu, 27 Aug 2026 15:38:30 +0200 Subject: [PATCH 2/4] ci: add a mypy gate over core and the CLI entry point The fixed bug is one instance of a class: config produces a tuple, a signature claims List[str], a validator enforces list. mypy catches this class at CI time, so add it to the test workflow and pre-commit. Scope is core/ and nb-dt-import.py with check_untyped_defs enabled, because most bodies there are unannotated and mypy skips them by default. resolve_run_config is annotated to return RunConfig so config attributes stop being Any. Reverting the Exporter annotation to the buggy List[str] makes mypy fail on the exact call site that carried the bug. Getting the gate green fixed 51 findings with no type-ignore comments, among them one more instance of the same drift: _serialize_component was annotated list but fed a tuple from the component registry. types-PyYAML provides real stubs for yaml; pynetbox has none and gets the only ignore_missing_imports override. --- .github/workflows/tests.yml | 3 + .pre-commit-config.yaml | 8 ++ core/change_detector.py | 4 +- core/component_cache.py | 9 +- core/component_registry.py | 6 +- core/config.py | 8 +- core/export_manifest.py | 2 +- core/import_run.py | 3 +- core/log_handler.py | 2 +- core/nb_serializer.py | 4 +- core/netbox_api.py | 14 +- core/repo.py | 14 +- pyproject.toml | 13 ++ uv.lock | 263 ++++++++++++++++++++++++++++++++++++ 14 files changed, 322 insertions(+), 31 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index a822f5bd..3ae602c8 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -55,5 +55,8 @@ jobs: - name: Check formatting run: uv run ruff format --check . + - name: Check types + run: uv run mypy + - name: Check docstring coverage run: uv run interrogate diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 96e5b004..270c5bea 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -19,6 +19,14 @@ repos: language: system types: [python] require_serial: true + # No filenames: mypy reads its own scope (core/ and nb-dt-import.py) from pyproject.toml. + - id: mypy + name: mypy + entry: uv run --native-tls mypy + language: system + types: [python] + pass_filenames: false + require_serial: true - id: interrogate name: interrogate entry: uv run --native-tls interrogate diff --git a/core/change_detector.py b/core/change_detector.py index aee6014e..389927d2 100644 --- a/core/change_detector.py +++ b/core/change_detector.py @@ -376,7 +376,7 @@ def _compare_component_properties( # so that any change to rear port name, front_port_position, or # rear_port_position is detected. yaml_mappings = yaml_comp.get("_mappings") or [] - yaml_set = frozenset( + yaml_set: frozenset = frozenset( ( m.get("rear_port", ""), m.get("front_port_position", 1), @@ -392,7 +392,7 @@ def _compare_component_properties( has_names = any(m.get("rear_port_name") is not None for m in canonical) if has_names: # NetBox >= 4.5: compare with rear port names - netbox_set = frozenset( + netbox_set: frozenset = frozenset( ( m.get("rear_port_name", ""), m.get("front_port_position", 1), diff --git a/core/component_cache.py b/core/component_cache.py index 32318c8f..d8dc4a22 100644 --- a/core/component_cache.py +++ b/core/component_cache.py @@ -12,6 +12,7 @@ import concurrent.futures import queue import threading +from typing import Any from core.compat import ( device_type_filter_key, @@ -136,9 +137,9 @@ def __init__(self, netbox, graphql, handle, new_filters, max_threads, wrap_recor self.max_threads = max_threads self._wrap_record = wrap_record or (lambda record: record) - self._entries = {} + self._entries: dict = {} self._ready = False - self._job = None + self._job: Any = None # ── State ──────────────────────────────────────────────────────────────── @@ -169,7 +170,7 @@ def begin_prefetch(self, manufacturer_slug=None, display=None): for component in COMPONENT_TYPES: display.add(component.endpoint, component.plural_label) - updates = queue.Queue() + updates: queue.Queue = queue.Queue() max_workers = max(1, min(len(COMPONENT_TYPES), self.max_threads)) executor = concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) worker_state = threading.local() @@ -369,7 +370,7 @@ def _pending(self): def _drain_updates(self): """Apply every queued page count to the display, coalescing per endpoint.""" - totals = {} + totals: dict = {} while True: try: endpoint_name, advance = self._job["updates"].get_nowait() diff --git a/core/component_registry.py b/core/component_registry.py index 21bac067..ed991052 100644 --- a/core/component_registry.py +++ b/core/component_registry.py @@ -25,10 +25,10 @@ class ComponentType: yaml_key: str endpoint: str label: str - fields: tuple + fields: tuple[str, ...] module_types: bool = True - graphql_extra: tuple = field(default_factory=tuple) - compare_extra: tuple = field(default_factory=tuple) + graphql_extra: tuple[str, ...] = field(default_factory=tuple) + compare_extra: tuple[str, ...] = field(default_factory=tuple) link: Optional[str] = None @property diff --git a/core/config.py b/core/config.py index 7a67f694..d37778e6 100644 --- a/core/config.py +++ b/core/config.py @@ -56,8 +56,8 @@ class RunConfig: repo_branch: str repo_path: str - vendors: tuple = () - slugs: tuple = () + vendors: tuple[str, ...] = () + slugs: tuple[str, ...] = () export_diff: bool = False export_diff_dir: str = "extra/" @@ -74,7 +74,7 @@ class RunConfig: show_remaining_time: bool = False # Resolution decisions worth telling the user about, logged once the handler exists. - notices: tuple = field(default_factory=tuple) + notices: tuple[str, ...] = field(default_factory=tuple) def _text(env, name, default=None): @@ -246,7 +246,7 @@ def _split_slugs(values): return tuple(s.strip() for slug in values for s in slug.split(",") if s.strip()) -def resolve_run_config(argv=None, env=None): +def resolve_run_config(argv=None, env=None) -> RunConfig: """Resolve *argv* and *env* into one RunConfig, or raise ConfigError. Reads argv before the environment, so ``--help`` answers without an environment diff --git a/core/export_manifest.py b/core/export_manifest.py index 9d632420..b959ddeb 100644 --- a/core/export_manifest.py +++ b/core/export_manifest.py @@ -9,7 +9,7 @@ import os from pathlib import Path -_EMPTY = {"device-types": {}, "module-types": {}, "rack-types": {}} +_EMPTY: dict = {"device-types": {}, "module-types": {}, "rack-types": {}} def load_manifest(path: Path) -> dict: diff --git a/core/import_run.py b/core/import_run.py index 00da6870..92adbafc 100644 --- a/core/import_run.py +++ b/core/import_run.py @@ -5,6 +5,7 @@ from dataclasses import dataclass from datetime import datetime, timedelta import os +from typing import Any from core.change_detector import ChangeDetector, ChangeType, IMAGE_PROPERTIES from core.component_cache import NullTaskDisplay, RichTaskDisplay @@ -575,7 +576,7 @@ def __init__(self, config, repo, netbox, reporter, progress_factory, *, started_ self.reporter = reporter self.progress_factory = progress_factory self.started_at = started_at or datetime.now() - self.progress = None + self.progress: Any = None self.task_registry = None self.vendor_task_id = None diff --git a/core/log_handler.py b/core/log_handler.py index 972fa7d3..2a53eab9 100644 --- a/core/log_handler.py +++ b/core/log_handler.py @@ -15,7 +15,7 @@ def __init__(self, verbose: bool): self.verbose = verbose self.console = None self._defer_depth = 0 - self._deferred_messages = [] + self._deferred_messages: list = [] def _timestamp(self): """Return the current time formatted as HH:MM:SS.""" diff --git a/core/nb_serializer.py b/core/nb_serializer.py index 9d382d10..c6535506 100644 --- a/core/nb_serializer.py +++ b/core/nb_serializer.py @@ -5,7 +5,7 @@ """ import warnings -from typing import Any +from typing import Any, Sequence from core.component_registry import BY_ENDPOINT, COMPONENT_TYPES @@ -114,7 +114,7 @@ def _should_include(field: str, val: Any) -> bool: return True -def _serialize_component(record: Any, fields: list) -> dict: +def _serialize_component(record: Any, fields: Sequence[str]) -> dict: """Serialize a single component template record to a YAML-ready dict.""" result = {} for field in fields: diff --git a/core/netbox_api.py b/core/netbox_api.py index 7425479f..6338f07b 100644 --- a/core/netbox_api.py +++ b/core/netbox_api.py @@ -12,6 +12,7 @@ import os import glob from pathlib import Path +from typing import Any, Optional from core.change_detector import ChangeDetector, ChangeType from core.component_cache import ComponentCache @@ -213,7 +214,7 @@ def _is_image_hash_changed(local_path: str, hash_cache: dict, log_fn=None) -> bo return current != cached -def _load_image_hash_cache(path: str, log_fn=None) -> dict: +def _load_image_hash_cache(path: Optional[str], log_fn=None) -> dict: """Load the image-hash cache from *path* (JSON), returning an empty dict when it cannot be read. An absent file is the normal first run and stays quiet. Anything else means the @@ -448,7 +449,7 @@ def __init__(self, config, handle): self.repo_path = config.repo_path self.verbose = config.verbose self.handle = handle - self.netbox = None + self.netbox: Any = None self.ignore_ssl = config.ignore_ssl_errors self.modules = False self.new_filters = False @@ -465,7 +466,7 @@ def __init__(self, config, handle): _cache_dir = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")) / "nb-dt-import" try: _cache_dir.mkdir(parents=True, exist_ok=True) - self._image_hash_cache_path = str(_cache_dir / "image-hashes.json") + self._image_hash_cache_path: Optional[str] = str(_cache_dir / "image-hashes.json") except OSError as exc: self.handle.log( "[yellow]Warning: could not create image hash cache directory " @@ -2019,6 +2020,7 @@ def __init__(self, record): """ object.__setattr__(self, "_record", record) mappings_raw = getattr(record, "mappings", None) + canonical: Optional[list] if mappings_raw is not None: # NetBox >= 4.5: mappings is a list of PortTemplateMapping objects canonical = [] @@ -2407,8 +2409,8 @@ def update_components(self, yaml_data, device_type_id, component_changes, parent parent_type: "device" or "module" """ # Group changes by component type and change type - changes_to_update = {} - changes_to_add = {} + changes_to_update: dict = {} + changes_to_add: dict = {} for change in component_changes: if change.change_type == ChangeType.COMPONENT_CHANGED: if change.component_type not in changes_to_update: @@ -2437,7 +2439,7 @@ def remove_components(self, device_type_id, component_changes, parent_type="devi removals = [c for c in component_changes if c.change_type == ChangeType.COMPONENT_REMOVED] # Group removals by component type - removals_by_type = {} + removals_by_type: dict = {} for removal in removals: if removal.component_type not in removals_by_type: removals_by_type[removal.component_type] = [] diff --git a/core/repo.py b/core/repo.py index 57656278..2022cb57 100644 --- a/core/repo.py +++ b/core/repo.py @@ -6,7 +6,7 @@ import pickle from glob import glob from re import sub as re_sub -from typing import Optional +from typing import Optional, Sequence from urllib.parse import urlparse from git import Repo, exc import yaml @@ -320,7 +320,7 @@ def normalize_port_mappings(data): # --- Old inline format --- # Collect rear_port references declared directly on front-port entries. - inline_mappings = {} # {front_port_name: [mapping_dict, ...]} + inline_mappings: dict = {} # {front_port_name: [mapping_dict, ...]} for fp in front_ports: rp_name = fp.get("rear_port") if rp_name is None: @@ -339,7 +339,7 @@ def normalize_port_mappings(data): ) # --- New port-mappings stanza --- - stanza_mappings = {} # {front_port_name: [mapping_dict, ...]} + stanza_mappings: dict = {} # {front_port_name: [mapping_dict, ...]} if "port-mappings" in data: for entry in port_mappings_stanza or []: fp_name = entry.get("front_port") @@ -589,7 +589,7 @@ def clone_repo(self): except Exception as git_error: raise UnknownError("Git Repository Error", cause=git_error) from git_error - def get_devices(self, base_path, vendors: list = None): + def get_devices(self, base_path, vendors: Optional[Sequence[str]] = None): """Discover device YAML files and vendor directories under a base path. Args: @@ -663,7 +663,7 @@ def resolve_slug_files(self, slugs): slugs_lower = [s.casefold() for s in slugs] # --- device types -------------------------------------------------- - device_files = {} # vendor_slug -> [abs_path] + device_files: dict = {} # vendor_slug -> [abs_path] try: known_slugs = _safe_index_load(device_index) except Exception: @@ -733,7 +733,7 @@ def discover_vendors(self, devices_path, modules_path, racks_path): # Return sorted list by slug return sorted(vendors_dict.values(), key=lambda v: v["slug"]) - def parse_files(self, files: list, slugs: list = None, progress=None): + def parse_files(self, files: list, slugs: Optional[Sequence[str]] = None, progress=None): """Parse YAML device files into device type dicts, optionally filtering and tracking progress. Args: @@ -787,7 +787,7 @@ def parse_files(self, files: list, slugs: list = None, progress=None): # for the user to fix upstream. deduped = [] seen = {} # key -> kept item - groups = {} # key -> list of all srcs in sorted order + groups: dict = {} # key -> list of all srcs in sorted order for item in sorted(deviceTypes, key=lambda d: d.get("src", "")): try: key = (item["manufacturer"]["slug"], item.get("model")) diff --git a/pyproject.toml b/pyproject.toml index 6fd4477f..0101090d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,12 +16,14 @@ dependencies = [ [dependency-groups] dev = [ "interrogate>=1.7.0", + "mypy>=2.3.1", "pre-commit>=4.6.2", "pytest>=9.1.1", "pytest-cov>=6.0", "pytest-mock>=3.15.1", "pytest-timeout>=2.4.0", "ruff>=0.16.2", + "types-pyyaml>=6.0.12.20260815", "zizmor==1.29.0", ] @@ -52,6 +54,17 @@ max-complexity = 15 [tool.ruff.lint.per-file-ignores] "tests/**" = ["D102", "D103"] # test functions don't need docstrings +[tool.mypy] +python_version = "3.12" +files = ["core", "nb-dt-import.py"] +# Most of core/ carries no annotations, and mypy skips unannotated bodies by default. +# Those bodies are where this bug class hides: a tuple reaching a list-typed parameter. +check_untyped_defs = true + +[[tool.mypy.overrides]] +module = ["pynetbox.*"] +ignore_missing_imports = true + [tool.interrogate] # Measure docstring coverage on first-party source only. The vendored # Device-Type-Library lives under repo/, and test functions are exempt from diff --git a/uv.lock b/uv.lock index de81e5dd..9c05b0f9 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,74 @@ version = 1 revision = 3 requires-python = ">=3.12" +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version < '3.15'", +] + +[[package]] +name = "ast-serialize" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e1/a9/11851c3e02a3fea2ddc9932d1fdc7d2edaeecc0d2e11bc5f2a7fde2b0934/ast_serialize-0.8.0.tar.gz", hash = "sha256:6c37c43e4004dfb42d321ddedc569dc17ff4259296f3af577c9ea46a809bc010", size = 845638, upload-time = "2026-08-07T11:29:02.152Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/34/16/6e520b57cd8c75914b38c670ad4593d13c22911e4306cc7165dab8b0789b/ast_serialize-0.8.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:3d822605fa7bb326ef868d25fafced7fc660fa46d9b90c02ea86d5e2f5d325f7", size = 863924, upload-time = "2026-08-07T11:27:34.579Z" }, + { url = "https://files.pythonhosted.org/packages/03/e1/48802de9b22a2bcad42ec80601a17e3f69172fe4f590e6311bcc2b323aeb/ast_serialize-0.8.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:2efa40b068197d5efb62655b43baadb842ed71c4958cccd3e8b86a35726f0119", size = 1177662, upload-time = "2026-08-07T11:27:36.196Z" }, + { url = "https://files.pythonhosted.org/packages/38/d4/323438db76bded3a1f3523a3167b8325916b2ddceb2107a330c6ec9fcf4d/ast_serialize-0.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:db1b957291bca08c7e72f43a12357b2948e20775d970e3fc3dac0aa3160ab725", size = 1167072, upload-time = "2026-08-07T11:27:37.646Z" }, + { url = "https://files.pythonhosted.org/packages/77/82/53c5400b54144b56de8ed7f957fd1ccd97e42482009292ab46121d15f8dd/ast_serialize-0.8.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fdc0d5b18ff8fb364e87923e47c0a91d0d69dbcaeaa274591f7fd26892cc3a3a", size = 1225497, upload-time = "2026-08-07T11:27:39.225Z" }, + { url = "https://files.pythonhosted.org/packages/44/5f/36c07327a8b91303fbf1382c7c3e8a2902072dbe1b9546138a5288e75ff0/ast_serialize-0.8.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9da7330f3e235bf7da89b8d39205c6350fc0c08a85379743f2df9fff87d6d980", size = 1227101, upload-time = "2026-08-07T11:27:40.799Z" }, + { url = "https://files.pythonhosted.org/packages/9d/48/5adf5c67addc7ddb328122208c6d375a84cf154984f412b4087330a157bd/ast_serialize-0.8.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f3186969ee66a9863b00acc6523ace44c56974eecb348a7ea4b228d9f0b80e19", size = 1424001, upload-time = "2026-08-07T11:27:42.708Z" }, + { url = "https://files.pythonhosted.org/packages/38/a1/70074dd3869d2b0e934f91891d8d6b734361cd3b80f85ca7ece2e668ecdd/ast_serialize-0.8.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:40a57b73731be45da4fa41430c4d5dc94a24b3a4faba7b9e069978c0402064ea", size = 1245545, upload-time = "2026-08-07T11:27:44.4Z" }, + { url = "https://files.pythonhosted.org/packages/e3/be/53b9c0a8a6399950c2e3546bdfab96d2b299d5b114b47eb94fd3c49c4054/ast_serialize-0.8.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5075b9da3ef807eda752502446dfecea3b381c4900b7e27a5d5f4f899eb39951", size = 1248961, upload-time = "2026-08-07T11:27:45.781Z" }, + { url = "https://files.pythonhosted.org/packages/eb/13/3651d3812548a2bda15e26e5dd51aadb48cf682d0865370255fcf0e367dd/ast_serialize-0.8.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:293cc1c5bfa741f8e3fbe8175b9c07beee487c9a6fdbb25a5acad9f1df2d30a9", size = 1243877, upload-time = "2026-08-07T11:27:47.325Z" }, + { url = "https://files.pythonhosted.org/packages/21/a0/521f0bf000f675e9312a4aae2c8ba7a992405d072a85c485e08fd59433b9/ast_serialize-0.8.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e0910c3442a75216dde0f102d854ba2aaa71d2482e0ee213630b9bf29584fba3", size = 1293903, upload-time = "2026-08-07T11:27:49.264Z" }, + { url = "https://files.pythonhosted.org/packages/b1/7e/402fc902568aa2ee65865a3e151f000db0153da8ce6b1be4c9c349025f8d/ast_serialize-0.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:43dd6d596879bb1cb8a12cc9dae7bb10090a39a35883026c24f82488a195619a", size = 1401070, upload-time = "2026-08-07T11:27:50.947Z" }, + { url = "https://files.pythonhosted.org/packages/ff/7c/97d4b66c057f1706fc8be6dd532cc77c988794357c8f4ffdb6adabb39562/ast_serialize-0.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:8c9d537f59e936392cfd3597789d1390304dd659efc3c486ce7f40fb6b8a9f53", size = 1502602, upload-time = "2026-08-07T11:27:52.364Z" }, + { url = "https://files.pythonhosted.org/packages/89/6f/72cc3b71562001bba46e898ccfbf1844f7939b3e28912736206102f2e5a8/ast_serialize-0.8.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:f0190a33d7f97c65e9069f7a7f40499eea6b5cbe260c558378109caf20ce934b", size = 1495848, upload-time = "2026-08-07T11:27:53.803Z" }, + { url = "https://files.pythonhosted.org/packages/a0/53/d6f629d1e49308b2f363dae028baa213ec222c9106fa1f7f0d1f7b41499a/ast_serialize-0.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:77308ae6c5cf5264cc0f01a7c556ec77a9e68eb1f61b093534d698139fdc3b14", size = 1556556, upload-time = "2026-08-07T11:27:55.342Z" }, + { url = "https://files.pythonhosted.org/packages/ee/22/340f35dd8dfc6d412d53dc20699ca014b8d228db923e8ed4759c512b162c/ast_serialize-0.8.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8d53a23f27e1ed3a36b2d26fd2a1a6228c8e85a1ed62ff7cdb44bd610769f20a", size = 1417822, upload-time = "2026-08-07T11:27:56.712Z" }, + { url = "https://files.pythonhosted.org/packages/11/29/6dde5c13fbebc051d3a6df4ec0a6fd1d5359333cc1193f7f609f3410b4d8/ast_serialize-0.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ffa5e7cb08f96fed9121f77b224151e41caf88feab9d652bb46c78202b6fbeda", size = 1445153, upload-time = "2026-08-07T11:27:58.275Z" }, + { url = "https://files.pythonhosted.org/packages/62/c5/f473a8ed030f7a0ca24b9849cca184677a50c053867a7b808c2e1289bbd3/ast_serialize-0.8.0-cp314-cp314t-win32.whl", hash = "sha256:fa70ed4dea0bb18b30a1789c77baa701d0ef30c474f2ccabdea61e25623a8827", size = 1063711, upload-time = "2026-08-07T11:27:59.793Z" }, + { url = "https://files.pythonhosted.org/packages/23/63/39e171fcd38ca057c2e1979d5ee81ac7a3502784abe3d83df7454f7a0978/ast_serialize-0.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d8b3c8eee4c1baef9d4e84d2a59a805501617127be42615cb48970b15b0892b6", size = 1103740, upload-time = "2026-08-07T11:28:01.405Z" }, + { url = "https://files.pythonhosted.org/packages/21/1c/d00762b399e7726d68d0a088cc946e3a4c60f1c6176f557608f672f627f3/ast_serialize-0.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:ac4f0a83c55a9b782f79ad55a5247b7db123c1db405959791c2ef886e9710c9f", size = 1076021, upload-time = "2026-08-07T11:28:02.947Z" }, + { url = "https://files.pythonhosted.org/packages/4c/11/911210c3c78923273a9211a2b6cfc4c8aa723b30dab3e1c8d19afb983b40/ast_serialize-0.8.0-cp315-abi3.abi3t-macosx_10_12_x86_64.whl", hash = "sha256:86b8a1e6d90467345356098b040150e82fbc26d24a7a202224b13dc1f6264ca0", size = 1177715, upload-time = "2026-08-07T11:28:04.654Z" }, + { url = "https://files.pythonhosted.org/packages/77/89/6282881c8587606638db153cbe21e1e0c4d1f3970dee1aa0610a1c62a026/ast_serialize-0.8.0-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:39e92ff8e8cb45947fe9007174b2950e1fb098e6abd00266a13cd3bcf6675068", size = 1169347, upload-time = "2026-08-07T11:28:06.1Z" }, + { url = "https://files.pythonhosted.org/packages/97/78/a9f846a03a340ff3728c915f23338ca742742f3292700559cdb3ad999b1e/ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c85d8d18db5b2dfcb3b7e38a4d600ca35504c0ed8a6f75cd1c811e4ffe248a15", size = 1225916, upload-time = "2026-08-07T11:28:07.654Z" }, + { url = "https://files.pythonhosted.org/packages/c0/15/aba6ef8a988a6eceb6f0359589aac509e29ae2dba67fd9bfd5af0c3f13e7/ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9830ff7e764f74d9eefb01170c61a9f0fd2c027dac5fcb72e064decd57d56371", size = 1227135, upload-time = "2026-08-07T11:28:09.504Z" }, + { url = "https://files.pythonhosted.org/packages/94/29/3f63d696ea7c5b8abadcecc3505be51bd900daaccc522ed8322fa5b05a93/ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6479d9722a4cd21b578f5478074c41e6169f04811996ec881655560f703a5bba", size = 1425040, upload-time = "2026-08-07T11:28:11.044Z" }, + { url = "https://files.pythonhosted.org/packages/e2/5d/0aac338604ff59df5774d4304307898982252f325ff7cafe31d52fedcb65/ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a63bed264e818cd83eec11feed0f50aa162542b91132ef58afebc857182763a5", size = 1246278, upload-time = "2026-08-07T11:28:12.519Z" }, + { url = "https://files.pythonhosted.org/packages/23/ca/9f1ef795bb724719532bd86dbec11e5b66857d3fbe9b6772baec0191a6ed/ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9d187197d234aa45d6cfa2b096be5f666e8cc2e7eb3722d0ab8926293cf5720c", size = 1250029, upload-time = "2026-08-07T11:28:13.896Z" }, + { url = "https://files.pythonhosted.org/packages/dc/25/5e061372d2ed953b9ba3b9c4f73de3b8e9234cda3f6c088db4686801d0e1/ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_31_riscv64.whl", hash = "sha256:2d39a56282cfcc0d8eeea37267c754be59c98d48505c23b1dae5c6011f3813dd", size = 1243575, upload-time = "2026-08-07T11:28:15.37Z" }, + { url = "https://files.pythonhosted.org/packages/a8/c1/ae7da218053120635a4ca802366c69f707203641af95372eeb83f70dfd52/ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f7cc5f10386994c0f4844f1e6d6a97127e9b478660eb6dec2b257644f0acab64", size = 1294396, upload-time = "2026-08-07T11:28:16.813Z" }, + { url = "https://files.pythonhosted.org/packages/2e/89/271d1f49c5269fcddcc789ea3f25be401f6723fc1138aeda539f4d05516d/ast_serialize-0.8.0-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:6102f2f985c2e542be85cd857678ec9356fefa792b93cadfadd31139f5696f27", size = 1401987, upload-time = "2026-08-07T11:28:18.333Z" }, + { url = "https://files.pythonhosted.org/packages/55/be/4e7d77fcf571ac7cb5cf7115a20c36642bd7d29473b45dfaaefeb9618f90/ast_serialize-0.8.0-cp315-abi3.abi3t-musllinux_1_2_armv7l.whl", hash = "sha256:3a8660fe66667b76a6e9dccd1d33e66b229fde3b308db991c041609226c005b6", size = 1502904, upload-time = "2026-08-07T11:28:20.039Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ae/ed1de2db7e019d4236fbc164ffa5ef9a6022a300a342bbf142d21b7c141e/ast_serialize-0.8.0-cp315-abi3.abi3t-musllinux_1_2_i686.whl", hash = "sha256:e7266307e5fba39836edb79def8608887af48820508bff3c5f2941e1e04d1534", size = 1496967, upload-time = "2026-08-07T11:28:21.734Z" }, + { url = "https://files.pythonhosted.org/packages/92/89/5fea507fae5c5f18b7dc7f95e5c00956574b8c717b8fd2049c504fab0b18/ast_serialize-0.8.0-cp315-abi3.abi3t-musllinux_1_2_ppc64le.whl", hash = "sha256:4ca7e6fd1ad845d1cc649dc2ecd499db2f8f46af5bf8da7b70dd858774cc038b", size = 1559041, upload-time = "2026-08-07T11:28:23.194Z" }, + { url = "https://files.pythonhosted.org/packages/42/71/478d69df21b64e064554a68134c94be304270316ca676a94e63c389a636a/ast_serialize-0.8.0-cp315-abi3.abi3t-musllinux_1_2_riscv64.whl", hash = "sha256:2880350b13d3eae69a0d70bc1fb6c9bfaca4dbd0e20ba8cd1aa483080b56ff06", size = 1417367, upload-time = "2026-08-07T11:28:24.601Z" }, + { url = "https://files.pythonhosted.org/packages/5e/2d/8962dc8d5b3a9dc27b36f9db199afa25264c741505469d9ec10ffbfd2ba7/ast_serialize-0.8.0-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:ab0f9a59f7d63d0d441b56b9a818b273705264352d5115cfee12e940e816d958", size = 1446178, upload-time = "2026-08-07T11:28:26.152Z" }, + { url = "https://files.pythonhosted.org/packages/4f/22/14d2ad4fd1d1bcd0dc687ca268e0630069f45162496260c0efb70ee0ea72/ast_serialize-0.8.0-cp315-abi3.abi3t-win32.whl", hash = "sha256:0485a25ef519c62e749ee3c1ad8070e591b380d67226349eb5a70b228dc1ac4a", size = 1063811, upload-time = "2026-08-07T11:28:27.864Z" }, + { url = "https://files.pythonhosted.org/packages/18/1d/84a327c0202a41aa5fdba3ade33904d6d8f3b9e6806fa83568d835395850/ast_serialize-0.8.0-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:bd84d60bca7079e741be4ac5dbe237751a59d7f6f9f0126b11880d63822cbe16", size = 1105518, upload-time = "2026-08-07T11:28:29.691Z" }, + { url = "https://files.pythonhosted.org/packages/8c/92/74556dec52fde85a2ad84ed159991b916241043788609c15d8b77e14570b/ast_serialize-0.8.0-cp315-abi3.abi3t-win_arm64.whl", hash = "sha256:057769b5921336eb2d9124f2a731b42ed05ffdac559b840dbdf6f3937cf153dc", size = 1076319, upload-time = "2026-08-07T11:28:31.282Z" }, + { url = "https://files.pythonhosted.org/packages/d1/5d/c650b1f2cc1e75193358da95a080261422e8cd10b66d7370b1688c9915c5/ast_serialize-0.8.0-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:a02cbed7d8bfdcdee88edaac12bd50d53d9953aaa2e1852ef078625be5f1c0b5", size = 852914, upload-time = "2026-08-07T11:28:32.929Z" }, + { url = "https://files.pythonhosted.org/packages/d9/e3/6142e920fec6ef7bccabd8c24ed8ed99f8bdc6cb8b065e1df7c6a3b2d667/ast_serialize-0.8.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:e1bd223df0f6c96b396975fa604cb33bce53d9b4a0185490be4c4a289f7c9c87", size = 1184007, upload-time = "2026-08-07T11:28:34.654Z" }, + { url = "https://files.pythonhosted.org/packages/a6/e9/6e8be8df02b35d85e2b8809f7f1cfa290bdf5882b55127a539d049482db0/ast_serialize-0.8.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ddd3b61f45c132da66c5476b281891e08c1fd87fbdabe8a6973e1622efc85f06", size = 1177588, upload-time = "2026-08-07T11:28:36.318Z" }, + { url = "https://files.pythonhosted.org/packages/8c/80/7e0fd2e2e2aba257820db4a8657c4c356844d36b914b20a4af294bcfb902/ast_serialize-0.8.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f9caa63fad8241257ae401b5ff0a64026c6adb36b8e86cbe8782d9ea505daf6", size = 1234575, upload-time = "2026-08-07T11:28:37.772Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6a/3bae0af06f9b1bae3001c44d64215f5b567877e7aae9ffd45db11c3a7647/ast_serialize-0.8.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3926fa117b5e65019853a2969966d11c7175af377a3425991f3fe73784412405", size = 1236015, upload-time = "2026-08-07T11:28:39.14Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c4/ce2d41a1bc22508e82618901f7e10f2a5e2f9556553fea90624daf9875e2/ast_serialize-0.8.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:485f1113af805e9e170b95ef993ca3fbd4f89c04bab25c58b4fc632d854801ab", size = 1432808, upload-time = "2026-08-07T11:28:40.664Z" }, + { url = "https://files.pythonhosted.org/packages/1a/90/f5058f209756dd70e958b7538aaa82d25d24944baf9ec8ae6f27b06fcacc/ast_serialize-0.8.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3ccebbed24f1281062d5852353c72c47502955926cfcb8345ffb3a44d87ff3d3", size = 1256251, upload-time = "2026-08-07T11:28:42.223Z" }, + { url = "https://files.pythonhosted.org/packages/bf/32/7f77ea87fa0836daab706ed5cb7f903bb25fa26a77439011aee626af11d8/ast_serialize-0.8.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:252f883290d1cdb728eb7fe1d9a7221b88af5a329aae0bc91ddee4dafb820331", size = 1258574, upload-time = "2026-08-07T11:28:43.751Z" }, + { url = "https://files.pythonhosted.org/packages/eb/5a/75b82ad2725b5e8e8c742732f9e76c6738a292d0709e1f60d10a973730b4/ast_serialize-0.8.0-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:96abc072ad29db8d02194afd47d68987322622787daceae82398d7b69f3ba2e6", size = 1254075, upload-time = "2026-08-07T11:28:45.28Z" }, + { url = "https://files.pythonhosted.org/packages/4e/54/8c20ed4eea805516a3fd23dd4a721ce28c64f50f0e4b359969f60a8c97a6/ast_serialize-0.8.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9118ad3e369727060b2696fc4078f250ecffca4248ba87f537f55cea9f9dce06", size = 1301018, upload-time = "2026-08-07T11:28:46.851Z" }, + { url = "https://files.pythonhosted.org/packages/cb/5b/9f14430f12fe830b656fb38f8e2e05ee13b02a88967660bef46af0ab22a8/ast_serialize-0.8.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f359df4bd921918af8bebd142a376c77511d7151cc8ba852760b587b5a4a54f3", size = 1409951, upload-time = "2026-08-07T11:28:48.312Z" }, + { url = "https://files.pythonhosted.org/packages/2d/3d/084882eca93c842bd4262591a071ec7f825340644035e51501208cc5a8d4/ast_serialize-0.8.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:e94f9121d13fa36cbf21314783c77d05ae3a0868decd18cf5233fdcc6de49ac8", size = 1509544, upload-time = "2026-08-07T11:28:49.847Z" }, + { url = "https://files.pythonhosted.org/packages/ce/73/ea84852096c2036c61cc0b2f97b90242207419f534dc671060ee1c8e05cb/ast_serialize-0.8.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:54f95b486018d262bcb387a9afd96f0da74508b442762b80c769454a6fbb3ee3", size = 1505671, upload-time = "2026-08-07T11:28:51.239Z" }, + { url = "https://files.pythonhosted.org/packages/cb/88/287b9a5300c1f2f651d259f670931b63110adc265b7613c885b44c5bc53d/ast_serialize-0.8.0-cp39-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:4c38b915511e32bc718c49dbce98ff9af36bac0ad6a604f58000cd5e3aecdba7", size = 1563685, upload-time = "2026-08-07T11:28:53.112Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f3/1bc3a79afcf0c2a8d2c37182d0d659d1545a9d7f7f6dc9cf3e63d6c17135/ast_serialize-0.8.0-cp39-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:9a2ef9cf12f2de4f1028c42c1dd7d775255e0fb3e5bb48896c97e35ef52366fe", size = 1427977, upload-time = "2026-08-07T11:28:54.418Z" }, + { url = "https://files.pythonhosted.org/packages/5c/cd/440c798957e14e31776bfeb024d8fafe0bb1d5b89c51c2f067e69938f7b0/ast_serialize-0.8.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6f18048fe9f6dd266bd577cdec48bdcecb74faaa01fe941324435483b013ed2a", size = 1454335, upload-time = "2026-08-07T11:28:55.968Z" }, + { url = "https://files.pythonhosted.org/packages/4f/4a/587eb36dcc240a54c8660f599464516b469ecad96f0dbdb6bccbedb50745/ast_serialize-0.8.0-cp39-abi3-win32.whl", hash = "sha256:31883542dd6c94d178f5db3d32fbd69c5eb88b3a7c018e7ac8cc0c45195ddbed", size = 1068858, upload-time = "2026-08-07T11:28:57.541Z" }, + { url = "https://files.pythonhosted.org/packages/5f/a4/3e887bbd92164e183cb6e412c6a3e9198ddd446d7fe405958293ef5ef49c/ast_serialize-0.8.0-cp39-abi3-win_amd64.whl", hash = "sha256:861794565b06337005c1447ef23103a3d5a627d08bdc827870d00d0b28ef5f51", size = 1111839, upload-time = "2026-08-07T11:28:59Z" }, + { url = "https://files.pythonhosted.org/packages/25/6c/b400476d3ceba681ab929787edc9554f6d88fcc69435eb681b00fc0457a5/ast_serialize-0.8.0-cp39-abi3-win_arm64.whl", hash = "sha256:b2a5978662fd4db463dfb4b974d2b10ac6430b98f5333aabc7051909df3561d0", size = 1083655, upload-time = "2026-08-07T11:29:00.349Z" }, +] [[package]] name = "attrs" @@ -207,12 +275,14 @@ dependencies = [ [package.dev-dependencies] dev = [ { name = "interrogate" }, + { name = "mypy" }, { name = "pre-commit" }, { name = "pytest" }, { name = "pytest-cov" }, { name = "pytest-mock" }, { name = "pytest-timeout" }, { name = "ruff" }, + { name = "types-pyyaml" }, { name = "zizmor" }, ] @@ -229,12 +299,14 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ { name = "interrogate", specifier = ">=1.7.0" }, + { name = "mypy", specifier = ">=2.3.1" }, { name = "pre-commit", specifier = ">=4.6.2" }, { name = "pytest", specifier = ">=9.1.1" }, { name = "pytest-cov", specifier = ">=6.0" }, { name = "pytest-mock", specifier = ">=3.15.1" }, { name = "pytest-timeout", specifier = ">=2.4.0" }, { name = "ruff", specifier = ">=0.16.2" }, + { name = "types-pyyaml", specifier = ">=6.0.12.20260815" }, { name = "zizmor", specifier = "==1.29.0" }, ] @@ -323,6 +395,107 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/12/c9/6869a1dcf4aaf309b9543ec070be3ec3adebee7c9bec9af8c230494134b9/interrogate-1.7.0-py3-none-any.whl", hash = "sha256:b13ff4dd8403369670e2efe684066de9fcb868ad9d7f2b4095d8112142dc9d12", size = 46982, upload-time = "2024-04-07T22:30:44.277Z" }, ] +[[package]] +name = "librt" +version = "0.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/36/9b/356320fbae2ac8467e21c5e73e1389c80468e4998c62cc7d3536cc51b614/librt-0.15.0.tar.gz", hash = "sha256:4e66cbe84437497d951b799d3e1551291b6fb3d643820a7014b3655d57a59162", size = 214338, upload-time = "2026-08-07T10:49:42.663Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/39/99c25030e782bdfb7a21be8c05254806a2e4bbb05c8d50c2a2130acbfa05/librt-0.15.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e87bc679f86a99aa3b26e3c78eeb821a247c9a28eae48eaafcc32c3bf4c3bb9e", size = 151021, upload-time = "2026-08-07T10:47:00.057Z" }, + { url = "https://files.pythonhosted.org/packages/14/43/f4b1bd1b2888798a1409808889a25ea1ba49eaabce7d681ed27734c2df9d/librt-0.15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:71599e011ac880e8e45d46047d714871894c7d4ab6f25626f8d4f89da21f368d", size = 155267, upload-time = "2026-08-07T10:47:01.311Z" }, + { url = "https://files.pythonhosted.org/packages/0c/db/3ad9c965c72f1e1d6beeec44ec10a54e17be8ae042fbb4baade16cbadced/librt-0.15.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c802434092b769b1d613ed2e13fac15fbfce1934a74bd10283b03c0fae231cd1", size = 503136, upload-time = "2026-08-07T10:47:02.45Z" }, + { url = "https://files.pythonhosted.org/packages/4b/07/5888a6d76acd62ebce66c61b74d94e9370b9c32929f111e487bb6546f8ed/librt-0.15.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5500eeae393a184d14e1f35645962c27129d20c81afa4069e6ef826ebc2b3aaa", size = 496670, upload-time = "2026-08-07T10:47:03.675Z" }, + { url = "https://files.pythonhosted.org/packages/29/39/ab57cc2f5b276156da02bb7f5a8921bada1cb1993ffec99acf811c602c23/librt-0.15.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6ecfc32dfb46fb7b565bcd6abf9412acf978775a998273d22888a6d7953730dd", size = 513688, upload-time = "2026-08-07T10:47:04.981Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b9/bdbb0b648b5c2befb031f4c6f3b1dd857415e8fb492a25a3c764a6681e6c/librt-0.15.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89cc46cfd15022e35084355478c9ac809d90b1152222706ac9a7655ec21df6fa", size = 531904, upload-time = "2026-08-07T10:47:06.211Z" }, + { url = "https://files.pythonhosted.org/packages/93/26/473c2e4b6c104e9e58e27ce95fc8005c8bd4fc36cae4f254371125a92db8/librt-0.15.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d5f51401d102c885b9ca509e62c79b1dbff286e1b9b047fde6f763780789356d", size = 524427, upload-time = "2026-08-07T10:47:07.592Z" }, + { url = "https://files.pythonhosted.org/packages/26/60/03b3abb82b41714671b907bf6989b228e31e6a8af52dec82b5b0728dc250/librt-0.15.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:cc30523e3f1a23fb7511cc659834a0d01a1042bb9de359bc1c131cc4ec6c9656", size = 543155, upload-time = "2026-08-07T10:47:08.866Z" }, + { url = "https://files.pythonhosted.org/packages/f2/0e/9bb1f0a4affbd0a1888f4f79dc03ed2a299d9a2c26c59ab2a97dcbf11903/librt-0.15.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:59fe030d8ae4a57e3fb7756bf35a858de74e04066fc8555c53d0af979132af81", size = 546890, upload-time = "2026-08-07T10:47:10.327Z" }, + { url = "https://files.pythonhosted.org/packages/dc/84/6937a280d461f7de6e031ffb02edc2b7c3c90d49d630565ce8ff27cbc5f2/librt-0.15.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5a6526a2a956bbb1e4ae3568c82e650fc99119c66bb011ea60715744955a2b4d", size = 555163, upload-time = "2026-08-07T10:47:11.798Z" }, + { url = "https://files.pythonhosted.org/packages/bc/95/2a2853c1ee014bf102116e7f897a04beeaeb2461b45b79af98bdfb95f1ef/librt-0.15.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:85ea21ec6730194d67156b0e0b5430ccb1d61f8b8b907e39b37f9812b74a13f0", size = 535812, upload-time = "2026-08-07T10:47:13.279Z" }, + { url = "https://files.pythonhosted.org/packages/c9/4c/cf9601c1b4c5f09280acd5d83abdb2e68527a2be8257136eb42304218622/librt-0.15.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1e47b8ba865d7ede071a91a7163073bbaeb72541f1ef8a07d512c45c7b5007f2", size = 573688, upload-time = "2026-08-07T10:47:14.727Z" }, + { url = "https://files.pythonhosted.org/packages/47/6d/9ac7cbec46189a7625af4b5acbd25f10d827f4141b2002181848c8418923/librt-0.15.0-cp312-cp312-win32.whl", hash = "sha256:a5207ec414d1c4a2a7231b2086970dc036f94293cdf338190984958a013a42f1", size = 106138, upload-time = "2026-08-07T10:47:15.973Z" }, + { url = "https://files.pythonhosted.org/packages/38/d0/2ae99c83be86ce23f925ac1aeeedc777e97f427c4a8d190c70d0a16e9a87/librt-0.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:73b30cfa976659b3917c8f6153bdb0591c6a9ec6583599fd24a689b690622022", size = 126974, upload-time = "2026-08-07T10:47:17.049Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ef/dd24f9635c730b86b87587967dda7516b1845e8b17684603d31607fed598/librt-0.15.0-cp312-cp312-win_arm64.whl", hash = "sha256:a54cf9e0ef47b96af580849db5471142200568ce1e02cbf416addab551369570", size = 112292, upload-time = "2026-08-07T10:47:18.222Z" }, + { url = "https://files.pythonhosted.org/packages/e7/42/467b53a601b406ccd7b97c1fd54b59cb34f9185ad5ce7e9d5c3c4e8961c8/librt-0.15.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:db13ca398005abcbe538deda87b686d9bd08b7001cf40c4c06b444960ae10a26", size = 151029, upload-time = "2026-08-07T10:47:19.312Z" }, + { url = "https://files.pythonhosted.org/packages/3e/e6/36c2299b7a94b84fdd01220d8a777a71be5be0925bb0dbdf71c0a06a34d9/librt-0.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:aa1f1995789dca3698bc550aaceb09a51bd5df0a057ff84ff15296cd1975b801", size = 155194, upload-time = "2026-08-07T10:47:20.398Z" }, + { url = "https://files.pythonhosted.org/packages/c9/b6/ed5071f9325845e670bd36012757419767fbf56af77ed483077b9e4db541/librt-0.15.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55456ea87d8df21808446d03817be2f65e20391c1c615d9187440dff28cd08dc", size = 502568, upload-time = "2026-08-07T10:47:21.652Z" }, + { url = "https://files.pythonhosted.org/packages/7f/81/6450c67c3615d87704bcbc21323fafc69c799b06a044c447529f725d4b01/librt-0.15.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5a86a5a08c2235316bdb359d5dbb6ce0abfca7fac06363103e2c5af571d92f95", size = 496153, upload-time = "2026-08-07T10:47:22.925Z" }, + { url = "https://files.pythonhosted.org/packages/e1/d6/5f52b722bc75076954b3bfd49be15ea362df4d580c6fb315d0f617100d30/librt-0.15.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e56b6a368529bed262da40ce13f8fef590db0479819cca84f16a1f01ac356d0b", size = 513336, upload-time = "2026-08-07T10:47:24.213Z" }, + { url = "https://files.pythonhosted.org/packages/8d/e2/c08fd1d36ce63ea5a12b85c5d37f4550b5f86a692167e41e5a74222607ae/librt-0.15.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:234d8d394721fa0d786af15ebf1f3fb7f3ed82fd1cd0cde45c2f247b5d4281d2", size = 531661, upload-time = "2026-08-07T10:47:25.507Z" }, + { url = "https://files.pythonhosted.org/packages/3f/d8/d9482fcbeb177b9eb87bb3899eeb3b42be690313c652f9e146b1d0681fb2/librt-0.15.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d8363d7accb0286ac3a0e633f396e93800dafb8150494505daf9515bbda591f3", size = 524487, upload-time = "2026-08-07T10:47:26.79Z" }, + { url = "https://files.pythonhosted.org/packages/10/cc/075171517b41f861753034fbb151b42cfc83bcc853849f24f5e66fd60ccf/librt-0.15.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0f0ee3644d951f31055ad07d77d92520e84505dd7a432cc4cd501dd70ee06785", size = 543201, upload-time = "2026-08-07T10:47:27.999Z" }, + { url = "https://files.pythonhosted.org/packages/b0/03/42c2330f37eeb475b6affeedd06518f60035f323af3a839335e3fc9fef2d/librt-0.15.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2cfd1a81a648806e6a7717be4cc4d1bb392fa229752bf8444ba365e381e984d6", size = 546467, upload-time = "2026-08-07T10:47:29.396Z" }, + { url = "https://files.pythonhosted.org/packages/57/1e/1ad4c5638f7e64d8560328bd25c54b409a661bdb6ff254b38ff90744288d/librt-0.15.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:a6cd22c9da0d866558e46a041f1cc0c2bbb26b61b137b2347fa834c332e1d101", size = 555139, upload-time = "2026-08-07T10:47:30.815Z" }, + { url = "https://files.pythonhosted.org/packages/49/41/39fa7d15db1204cd1cbe6514680fbdc243adf754a0885061308f43afc013/librt-0.15.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:6d5225ef8801e4ea5e482fa9b5dfb891dd9ef6f6d870f1f25d449ca2c70ac218", size = 536050, upload-time = "2026-08-07T10:47:32.222Z" }, + { url = "https://files.pythonhosted.org/packages/1e/88/c6dcf0dd8e26dc0c9a499a2abab8646c86dcaf9ecea9524cb46d3686331a/librt-0.15.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d28a05796b99f749bf8794f17ba9ba1612d0076b802e9cfc62c554634e9ce3b", size = 573700, upload-time = "2026-08-07T10:47:33.527Z" }, + { url = "https://files.pythonhosted.org/packages/1b/9b/ab54c71a7918a7c34fa5327fb61390a77446a07a146fbfb1165250a61035/librt-0.15.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:2067ff438048cead9d223ca5675bae2a25e520a7c3e6c1498bf9c6892d22caab", size = 82194, upload-time = "2026-08-07T10:47:34.835Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b2/4f9a243bb892395f3becb80789ade13771701091f9f07ab8230247953ba8/librt-0.15.0-cp313-cp313-win32.whl", hash = "sha256:1cd3b721f24c206398b9e26da3c3a9c011e6e89d06f318ba8ebefc30f1003890", size = 106231, upload-time = "2026-08-07T10:47:36.251Z" }, + { url = "https://files.pythonhosted.org/packages/bf/af/64aff4885a40b93132382f2c314647d722574605416504379184ef3045ea/librt-0.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:f395a4a9a03ac062dbe9a9f82e0c720502e590a38feee6a757bc82e9c63afbd8", size = 126996, upload-time = "2026-08-07T10:47:37.453Z" }, + { url = "https://files.pythonhosted.org/packages/27/83/335bccf6c7cb9028cb0b54aead27d9ece3f01f83bc6baa2abace5da655c1/librt-0.15.0-cp313-cp313-win_arm64.whl", hash = "sha256:0a15cb554761247d84a3ec0cbdf4078d70725384f0e4662c0fa3b26266eb60ad", size = 112188, upload-time = "2026-08-07T10:47:38.729Z" }, + { url = "https://files.pythonhosted.org/packages/a8/93/949053fb462eecc4a9a5ee770a81f4b40be7b79538b245545d4aebc6b58b/librt-0.15.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f5de7feedc56337a088eb15cd9fafa9938367362221d8cc62c642b7f94821993", size = 149833, upload-time = "2026-08-07T10:47:39.86Z" }, + { url = "https://files.pythonhosted.org/packages/61/ca/8281aa6cd560a3420e4497729f6b704b53be3eeaaef82d5aeadddaf7441f/librt-0.15.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6c0eb900c0e91f4aebe680845242e614f1864edfd44106380d0752ac29522bf8", size = 154088, upload-time = "2026-08-07T10:47:41.065Z" }, + { url = "https://files.pythonhosted.org/packages/dd/02/1a1662dceaba6a086360891448d5ce9a7d3555976cae59a31a39d744b9c7/librt-0.15.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e8c9a650a188e38bac005048cbe6342e81407782944d01934540ab75e417df21", size = 494215, upload-time = "2026-08-07T10:47:42.388Z" }, + { url = "https://files.pythonhosted.org/packages/69/84/99211619dc656370a3740c33d2b0b6d5a3fb1e73689314f6ed477a397dc4/librt-0.15.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:92bfed8deec93df30286b9fe9e3b1dd17329cc076a192b4ee5ec223841d54953", size = 491173, upload-time = "2026-08-07T10:47:43.683Z" }, + { url = "https://files.pythonhosted.org/packages/d4/aa/5448d0b05f4579b635d3899176817ebf561af0e57bacd425b5b1887264c1/librt-0.15.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ec4b19788f835711a2072f9dbe6b03b3bf32ed1f0fb30cf399bdd59d9f0c33fa", size = 505512, upload-time = "2026-08-07T10:47:45.314Z" }, + { url = "https://files.pythonhosted.org/packages/95/82/01940e40b83c43a546c4a3c896cf34ca272a9690899d55914e4827b3dcce/librt-0.15.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d4c7bacb70930f3d0a56f4ecf1be474a1f0d941b01dd73b756f3c256d42cb879", size = 523073, upload-time = "2026-08-07T10:47:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/88/fa/759c0030f3ee371439eb26de34fc745807caf0abb878af7af4b8b7c3dd3d/librt-0.15.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3e79f05e4a08b4d880342673312bbc895b56df7765605796f15902eb5367d3ae", size = 515080, upload-time = "2026-08-07T10:47:48.319Z" }, + { url = "https://files.pythonhosted.org/packages/0b/27/894e072228fcb159703c655da69f8cd10dbed489c36e3df7dd032a2483be/librt-0.15.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a417149c0cba4d50b61e992e5a15e69eaf96746609b461cc4ed168aeef6b79dd", size = 534164, upload-time = "2026-08-07T10:47:49.875Z" }, + { url = "https://files.pythonhosted.org/packages/98/a3/0078e91c1f36f8815db17827de15650b9a3fe56c55fbf998c854b34e40d3/librt-0.15.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:da7a94d6a3411f579d72aa3e3bc5fbca7ed4549f3dbd7e5de3aa567333374285", size = 540616, upload-time = "2026-08-07T10:47:51.408Z" }, + { url = "https://files.pythonhosted.org/packages/86/33/81a29b796dd52a45e9ef7974c7732926e8f10f15b8d2be505665979f896d/librt-0.15.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:856f743ae607f2c1380eccb566c0038a9fb3eabf0fc2be2704d76d9f73557239", size = 545890, upload-time = "2026-08-07T10:47:52.818Z" }, + { url = "https://files.pythonhosted.org/packages/05/82/8be1baa1350e5d30cfd70ae79d0a6f4dc5862ef47f7bb2808aabc9bb86e5/librt-0.15.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:779a6e7c894737e5983e7790a9c78c4000c30e23c9aada08081bdbea53b0fa60", size = 523287, upload-time = "2026-08-07T10:47:54.165Z" }, + { url = "https://files.pythonhosted.org/packages/c6/4f/d1be6a01a35c20ef734e0e44113f87d4af756a9354a89dcfbe3b4f8af5e1/librt-0.15.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:96bb17dbe8bab3c0954fbebfc69ed395599de75b6bbc35e3270a878e15d4dd65", size = 565868, upload-time = "2026-08-07T10:47:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/67/88/649cfa33f5825927b160610f670bdab012a64d627eddb94fa795ea4292fd/librt-0.15.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:7220697efaa6e5348fc3d18ee7f8563d4bfecd9872b37ffb915bfc1d08840622", size = 81619, upload-time = "2026-08-07T10:47:56.886Z" }, + { url = "https://files.pythonhosted.org/packages/22/31/8e88a8d5e48fc8d1a817787fb6811dfff6499acd6c8683dd83934aa6ede0/librt-0.15.0-cp314-cp314-win32.whl", hash = "sha256:f54598964d357b1c5ab77cf5d92f21e598fe0e23cdbe9618480807f81b4eba15", size = 100138, upload-time = "2026-08-07T10:47:58.093Z" }, + { url = "https://files.pythonhosted.org/packages/80/92/20fd6c4b6a1b1a564b076d55cd3d427d8428217d7638dc25a654cc4791d4/librt-0.15.0-cp314-cp314-win_amd64.whl", hash = "sha256:3ff5893a2c23d886aa9ce786de5ac6ddc74aeeaf90743682b74d920e117d2e28", size = 121258, upload-time = "2026-08-07T10:47:59.564Z" }, + { url = "https://files.pythonhosted.org/packages/fc/28/6af430b44d9ebb897b865a3c363b6dcace51357be2347cc0f8f869656a86/librt-0.15.0-cp314-cp314-win_arm64.whl", hash = "sha256:3722a099730704c9a3d70c879fc0f51daec25fe5f1555672d97bc595abeafb95", size = 106467, upload-time = "2026-08-07T10:48:01.097Z" }, + { url = "https://files.pythonhosted.org/packages/7e/aa/b42bb798942ced219f6d63b27e07f91237887a8d0bd0921666db79a13790/librt-0.15.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:38c0c7d4b6fc06c3324b3f9162c8391bfc4fd9dde53afe1033ce7edb48d5a714", size = 159523, upload-time = "2026-08-07T10:48:02.442Z" }, + { url = "https://files.pythonhosted.org/packages/75/03/1b53cd4ef904e73b1d828a5f90143bf94a2967d7cfff0b9ccf93e12aa9b4/librt-0.15.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8b2fdd7ead3c995c37940a790690660d0ca006c302db26cc51933f6766866fc3", size = 161638, upload-time = "2026-08-07T10:48:03.725Z" }, + { url = "https://files.pythonhosted.org/packages/ac/c4/9f9c9fba097d49e9e694c2b4dc331df31884645ecbc58a93b4b5fc69d2c5/librt-0.15.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2fde98cf1fc4bac144ce23c2c4c017b924ba714509ea9334977b0b27050c837d", size = 701795, upload-time = "2026-08-07T10:48:05.135Z" }, + { url = "https://files.pythonhosted.org/packages/4c/05/0966840bda0380c8ae167b9043c6230202941cc90ea29c48e096964c765e/librt-0.15.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:e3b461183c5fa7681b48560f91515f53a953122fb30c71e07abc67d7ddf58c38", size = 682147, upload-time = "2026-08-07T10:48:06.555Z" }, + { url = "https://files.pythonhosted.org/packages/18/af/1c47ca573c30ea47d195aec26133af522fea1104afaace028d7b32247ea8/librt-0.15.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4bbcc257e3babea20a91715c361b24554ec4e8f51aa578568afc230799fe1a19", size = 696397, upload-time = "2026-08-07T10:48:08.03Z" }, + { url = "https://files.pythonhosted.org/packages/2e/0f/1aed6223d4f9f9d1171a8596ff100ea4c3f7699fea7a4ba657c3e60daa6c/librt-0.15.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b845b8d48088fad0cadc84be4b8fda63203be7e9237b71015b3925443c1f35ab", size = 722542, upload-time = "2026-08-07T10:48:09.569Z" }, + { url = "https://files.pythonhosted.org/packages/c6/22/9e3a929aea456c97d69e6ef3884efea56d4807f97399471cc946baebd8af/librt-0.15.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b30e600e8f337b9bd7f39b86d9fdfedc73cc46e3d0f745931a23a234220bb7e2", size = 729709, upload-time = "2026-08-07T10:48:11.129Z" }, + { url = "https://files.pythonhosted.org/packages/e9/1b/c327ef6018e3a9ca0b8e7c5eddeeb331ba8f9b76c24e126d37d0f6d62faf/librt-0.15.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:64b0c8c35aa4c4ed79896359f3e0b285cbe4e610042106500da4811c322cc108", size = 752891, upload-time = "2026-08-07T10:48:12.558Z" }, + { url = "https://files.pythonhosted.org/packages/d7/d1/d5f1ea02c56930087009e39db9b70660a663e76c730b27b925d786718457/librt-0.15.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0da0d94cb802f32a0524653e7201f2cef72d5f700a5407678f5290483d4fcd08", size = 745301, upload-time = "2026-08-07T10:48:14.55Z" }, + { url = "https://files.pythonhosted.org/packages/d9/3c/5f7c585d15ebb2250c73e7c0ee4e9e47be72c65d520c07ddbcdc62037674/librt-0.15.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:4a6369168d371207339b1e50d4532b06a7121586141f82599505a3f315751d47", size = 747921, upload-time = "2026-08-07T10:48:16.453Z" }, + { url = "https://files.pythonhosted.org/packages/7f/52/1443a446486eba966bcbca1696b472e4f210320ec42f490a47f48fbf0fdc/librt-0.15.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c434e072557ade9cbc642d052c89d031efe47d5c9614523619d0d74a02378e81", size = 727561, upload-time = "2026-08-07T10:48:18.089Z" }, + { url = "https://files.pythonhosted.org/packages/79/91/2270a9380f11725cf83ce1925a5e32dd1dde2be9bba597f25c10a38644e7/librt-0.15.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c7eec6a42018bc1d45763b1c162d3d2bf7c3b9a1b0ed30d3e91dcba390efefcc", size = 774417, upload-time = "2026-08-07T10:48:19.611Z" }, + { url = "https://files.pythonhosted.org/packages/9e/3b/f4b1548d4f5b99186737fe27aec238e9823e8d5d23bf4df007c030689dc5/librt-0.15.0-cp314-cp314t-win32.whl", hash = "sha256:6912fa5e635d74529ac7cdb1bdf6ca3af4453da8d1edbe0110ee1cb4ad407ebf", size = 104381, upload-time = "2026-08-07T10:48:21.048Z" }, + { url = "https://files.pythonhosted.org/packages/80/b6/134afad262def1de04c0843c376d02135f1168af43f22e09a52bd8394727/librt-0.15.0-cp314-cp314t-win_amd64.whl", hash = "sha256:8e11699ed745931c395acd3621b07062e0f840efa6935aad87a64ed0995f0915", size = 127034, upload-time = "2026-08-07T10:48:22.561Z" }, + { url = "https://files.pythonhosted.org/packages/99/5f/1b6846b20572bd699c9e9ec321a5f781845bee477df2aa2a43b28bc40119/librt-0.15.0-cp314-cp314t-win_arm64.whl", hash = "sha256:5d2a91724463bfed4f573cd7a9fdc856d2e230d0c0e5a61416a93481dccd8605", size = 110827, upload-time = "2026-08-07T10:48:23.804Z" }, + { url = "https://files.pythonhosted.org/packages/c6/44/4de9f4ddadb009a55c7758eb5736d62534a7daaf27bd71bc50e64b606b06/librt-0.15.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:8443e38dcfcfdbcf5add5118c623efd788d65ac2e25756d6251a54a06a4d0aca", size = 149843, upload-time = "2026-08-07T10:48:25.148Z" }, + { url = "https://files.pythonhosted.org/packages/1f/eb/5d9ab71e30119c44094e0275f38b47dd327aea0f843a080396677029d508/librt-0.15.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:6d15a29033c57490cfe2069097c6fc4049e4e65ffbb749be7dc453b7c4c68965", size = 154510, upload-time = "2026-08-07T10:48:26.485Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9c/8505d1b8f5e8c19587bd03f7429993b3e9ce5c06819d856bfb11d919374c/librt-0.15.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d2c05c729b589e734c09578bf5964be48a911765484840d017bbc84f49d4c4ad", size = 497543, upload-time = "2026-08-07T10:48:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/1d/9a/3a8390775cb095765aded027ac9c63e7c8ea74e731498607544c6505de0e/librt-0.15.0-cp315-cp315-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:fa60887537e1d0cd2d9982269d33a709bf54b195cd2b9364fc0a758022af5bd9", size = 480452, upload-time = "2026-08-07T10:48:29.531Z" }, + { url = "https://files.pythonhosted.org/packages/e7/40/258a4a7117ee915d66de5cd9b8ade65a440993161107ce3a686f1859955c/librt-0.15.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d8bc24219b24c0af375718942ab75e3544b2763085f40f965be4326734ae8328", size = 507768, upload-time = "2026-08-07T10:48:31.007Z" }, + { url = "https://files.pythonhosted.org/packages/6b/c6/2f4dd296c97a0b85b98894519b279408ec9dd602d4f692b1ea0e25dee670/librt-0.15.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:86a21a7bd3fe3a419512ef424cc1c020f6771d0b29cfddff36d1635a855e63f0", size = 525122, upload-time = "2026-08-07T10:48:32.7Z" }, + { url = "https://files.pythonhosted.org/packages/49/dd/29eab42be13b2bf0ea8cb227135a45d44693e30a7e8b92871981ff56b82b/librt-0.15.0-cp315-cp315-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dbab647e88d90b3167b91efe7091e248653688ed4337e4f90907a722c7361bb9", size = 520371, upload-time = "2026-08-07T10:48:34.294Z" }, + { url = "https://files.pythonhosted.org/packages/91/ed/4bad71adeca8fe208b775c2a35417fa5a2584c8f4791daaf89a89450fea1/librt-0.15.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:d8edcf6f550e918dca779c069b9e156385c60b406f99fc7641f32c52f7193659", size = 537258, upload-time = "2026-08-07T10:48:35.88Z" }, + { url = "https://files.pythonhosted.org/packages/4c/63/59dba6143fdcc7240c54458b629f3250000a61b8945890fc9efd451b19c5/librt-0.15.0-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:8b62076030baa2d8b1501a46bf0e19c27a489aa90671c55665bff7887f7660b0", size = 527432, upload-time = "2026-08-07T10:48:37.466Z" }, + { url = "https://files.pythonhosted.org/packages/ec/21/21a24c6a2327d8362580efebe77286bf47b0f4062ec5ea41766e609d3c7d/librt-0.15.0-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:d00d20d1818e82a07a0ee0aa89a98b17ed7916b92441090b683719cb20a59b6d", size = 548108, upload-time = "2026-08-07T10:48:39.384Z" }, + { url = "https://files.pythonhosted.org/packages/5a/6d/fc68c89a7971418b41f9a873623ff935cb864097544c6a2f8ce491c8ef5d/librt-0.15.0-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:4e6ee93fc3cf848dcbf0cce2eca73d8e7dcd0cc2b6df3a529d57750b30a4c55c", size = 529681, upload-time = "2026-08-07T10:48:41.392Z" }, + { url = "https://files.pythonhosted.org/packages/65/7e/c2d98766124400d722063a630b0fde38a9fc768705d37eecca15c47dc192/librt-0.15.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:32896a0af72508ea979e0acb4e4c04cbeeae04938167950d535c83c45597167d", size = 567736, upload-time = "2026-08-07T10:48:43.124Z" }, + { url = "https://files.pythonhosted.org/packages/55/6c/f8c34a95e3a515c6e1c192b89511e7253c89a7760c6b500d57ffdb8d2dc8/librt-0.15.0-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:ec3ba415afaf951f6951b1dd16d3c8e4f540065fc382d7e70b823a79567ca374", size = 81673, upload-time = "2026-08-07T10:48:44.645Z" }, + { url = "https://files.pythonhosted.org/packages/c9/9e/e23fa8e78679ec45728188650b39e8ff476c83b691c96f749217df3b1b7c/librt-0.15.0-cp315-cp315-win32.whl", hash = "sha256:d2813ba2503764f0450680c533d13df7cff9b49df1411062eded5f67db4195b9", size = 100081, upload-time = "2026-08-07T10:48:46.171Z" }, + { url = "https://files.pythonhosted.org/packages/e1/dc/3eb4c5e297343f0620a55532cd7c8d764d3001fa2159212dadf480464827/librt-0.15.0-cp315-cp315-win_amd64.whl", hash = "sha256:b87d67e33afaf265262f2a66db578284b88ee2e6fcd224579cb5c15518677ad8", size = 121228, upload-time = "2026-08-07T10:48:47.631Z" }, + { url = "https://files.pythonhosted.org/packages/97/70/43abce19f04e49762f8ec834c8fafee13cc40fd6b94a72a24e534febfcd0/librt-0.15.0-cp315-cp315-win_arm64.whl", hash = "sha256:713bd7df21170b982e729e46870f31d6b437bd1a9b4648cffb529bd3c2ec5c4b", size = 106487, upload-time = "2026-08-07T10:48:49.095Z" }, + { url = "https://files.pythonhosted.org/packages/de/15/83f2deddb9368b8951ec8c9477269b5b9b8bd9bbf15e57402d0f38817dca/librt-0.15.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:3de789c82752730f94782a5ee518baf9c05edf85733aeaf73bb6e518755cdf54", size = 159448, upload-time = "2026-08-07T10:48:50.649Z" }, + { url = "https://files.pythonhosted.org/packages/06/bf/043097353f9b3c73b583d07f6b8e552795463f4bfc8caf85e42eee50c26a/librt-0.15.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:e0b5deec9a8664eb722c797241970fd4aa1894d25fda36a1ddac0f7407606bd6", size = 161686, upload-time = "2026-08-07T10:48:52.174Z" }, + { url = "https://files.pythonhosted.org/packages/f4/2a/8ae77f9719d42ce71cd708560a3557b38ac3c17a0383e57f87084de45bbe/librt-0.15.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5563302a8359bc2295bb7084d1a8ed1519df96afb30eb2aa4e0bff7b54228988", size = 710668, upload-time = "2026-08-07T10:48:53.782Z" }, + { url = "https://files.pythonhosted.org/packages/61/34/c0436ea134deb9a0d6da80a396a2739a81cb31e0418f7227239e23140898/librt-0.15.0-cp315-cp315t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:22d6263b9d39d7bbb286fa791945646e3218f1be2d693e36fb630f1d0e59cd13", size = 679396, upload-time = "2026-08-07T10:48:55.645Z" }, + { url = "https://files.pythonhosted.org/packages/4a/9f/001e0d99aa9250d5cd5715a9081291a20656083459f9019cda15255329e1/librt-0.15.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:39ffd14646190c454f0d86e0d256b33f00a87a26ab410e619773b841d0e41416", size = 704313, upload-time = "2026-08-07T10:48:57.46Z" }, + { url = "https://files.pythonhosted.org/packages/2d/53/b34fa9d0ff00f136f4d58ebb4c411ff634baed1eb412bb602a2bc8dcafcb/librt-0.15.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c47318cd3a61401452de11282242937e3e057c4fd3dbaf601e269d0928a06c0a", size = 729847, upload-time = "2026-08-07T10:48:59.231Z" }, + { url = "https://files.pythonhosted.org/packages/86/ac/fa4d7a424665040e95baf480a6d523446057684b6758624c85338e8a23b2/librt-0.15.0-cp315-cp315t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a56a1d4f859a82ca5b99fc4b82c9b027b15e3c455c5cd99e7d0719f27bb20b6c", size = 742736, upload-time = "2026-08-07T10:49:01.151Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f1/e17a9bb5de6fb8c3186ed1a7d68d21618b027ac2d3633e03d3b6109c67ae/librt-0.15.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:077471b3182db4e17c36ae91555f36a4d2c00080b267f749bcad34a478a9a302", size = 763454, upload-time = "2026-08-07T10:49:03.039Z" }, + { url = "https://files.pythonhosted.org/packages/1d/ec/ecd02cd30935b931b9cdbfed6ab5a099c51b280b4e7baa274da80978ed27/librt-0.15.0-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:411ca4d1b905b860ceba7570dd6717a71dedaddcc4b0f77ece710aa41ee11f8d", size = 743296, upload-time = "2026-08-07T10:49:04.941Z" }, + { url = "https://files.pythonhosted.org/packages/e6/b5/b3c2b8353ce820a4854f78d19321344242f89fa71c975b71132ba9bf242a/librt-0.15.0-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:1256589e0b0adb31751d685a68bce29d73407ddf4ef05d4188f49d5dcf9566d9", size = 756217, upload-time = "2026-08-07T10:49:06.825Z" }, + { url = "https://files.pythonhosted.org/packages/3c/52/6cc22542ba59146b05cca2a656f9ff8bb67e38e63d12c3b0cc183d837bf1/librt-0.15.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:f42b74a53e5f26a0ba0007411a7455b66c67ce4022a39cc1f56fc4efd65bcbab", size = 741934, upload-time = "2026-08-07T10:49:08.839Z" }, + { url = "https://files.pythonhosted.org/packages/40/32/a04b72b1aa86e3be23b2ecff8c1aad2dcc955bd3956d6d26e7e34267e57a/librt-0.15.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:291bf73caf78b9e88d6fae9bfd693207ff7d832e2fdbe2cf8e746bc13f5f892b", size = 783763, upload-time = "2026-08-07T10:49:10.661Z" }, + { url = "https://files.pythonhosted.org/packages/6c/f0/89eb11dffbe9279ff37144dec786927314502ae0b114f1449dc78c458aab/librt-0.15.0-cp315-cp315t-win32.whl", hash = "sha256:c16d15ee371643ab48dc8248a3e680ebbeca573a13af2c3dd0c985b142d77162", size = 104313, upload-time = "2026-08-07T10:49:12.305Z" }, + { url = "https://files.pythonhosted.org/packages/6d/4a/1f1978c200f563beda63c36adff2d65bbecb81e365e8e69e572f5f70fbc6/librt-0.15.0-cp315-cp315t-win_amd64.whl", hash = "sha256:dbd605739f228912dc49027cb764456b9757750bdc2b6b7773164db7096c6fd1", size = 126889, upload-time = "2026-08-07T10:49:13.881Z" }, + { url = "https://files.pythonhosted.org/packages/38/a6/800800bfed7b1fb10fc3f3d557785c3854e80d3f7a9800d784b176a1fc2d/librt-0.15.0-cp315-cp315t-win_arm64.whl", hash = "sha256:84d244b00604d17df3fc7736c327892d6bba66181254aa4087be807b6c342bdc", size = 110700, upload-time = "2026-08-07T10:49:15.499Z" }, +] + [[package]] name = "markdown-it-py" version = "4.0.0" @@ -344,6 +517,69 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] +[[package]] +name = "mypy" +version = "2.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ast-serialize" }, + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/82/6a/878cc1097d4035f82bd516658d0c528d2a9955bc7b363afcbd0b07fea11b/mypy-2.3.1.tar.gz", hash = "sha256:47c1b1207258513a9d93495f69c8be9de73916186f0e52703e8c461b7a623419", size = 3992554, upload-time = "2026-08-15T03:03:38.549Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/85/da/d6effc4f808a842d91edc22535dc9e799d2ff6e91449168b7f47a0771f54/mypy-2.3.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a32bbbb940af990d3be0b8af321c7b6815bb1b3b48142fe7459b9cc5f58959ff", size = 14047547, upload-time = "2026-08-15T03:02:57.707Z" }, + { url = "https://files.pythonhosted.org/packages/e4/e6/478229701dab76f26485fc8ff5d6f241f393da22447400bbc56f6946aebe/mypy-2.3.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff715e45b2231a8e85de1d163d1b42791e4d7aab8f5145f85fee1b710b735aff", size = 14216515, upload-time = "2026-08-15T03:01:26.496Z" }, + { url = "https://files.pythonhosted.org/packages/8d/fe/7c42327a3b21e84681f691982cbfe43f334a3685f3b683b72c376476c4fa/mypy-2.3.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:858fc57d3d91fa728e33e7ad71def60fc6272694607b306cd3292db53ae39080", size = 15307789, upload-time = "2026-08-15T03:03:31.62Z" }, + { url = "https://files.pythonhosted.org/packages/59/f4/7e597edbe01b5a56fa958ce541302dcaabfed979966f1dffedbea0ea0fc2/mypy-2.3.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:851833db876e7b650f93719c74b7879a08e338979c96054fdfc3bfd90a486355", size = 15548831, upload-time = "2026-08-15T03:03:15.55Z" }, + { url = "https://files.pythonhosted.org/packages/a3/52/cb31e084bc0314a1e384bdd677a4b80e55af04ccac077545e2238b9d320a/mypy-2.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:4c5095a327483591c94e0c8d3ef9e50d4ab1369b541eae007c1f23bc2a41f6bb", size = 11226359, upload-time = "2026-08-15T03:03:29.002Z" }, + { url = "https://files.pythonhosted.org/packages/7a/47/88fcf6217b43fa2da81a8c2611370af18141536a4f0294bbf98b457d456d/mypy-2.3.1-cp312-cp312-win_arm64.whl", hash = "sha256:bbfe022634a2a195406bd469e888d2eaf193b02ba7e607391cd7640374aaae3b", size = 10214707, upload-time = "2026-08-15T03:02:48.807Z" }, + { url = "https://files.pythonhosted.org/packages/de/cf/862010ee800ca9c2bd0c4c0dacf0f092e5411824a09b8f97ad4be8fe250e/mypy-2.3.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:114dff494000f18bd10d5d95d84b8567b26da60279ecbe838131841df20e635d", size = 13964542, upload-time = "2026-08-15T03:02:21.43Z" }, + { url = "https://files.pythonhosted.org/packages/75/5a/3f3a2107b41e3e92e617e25daaee121413b91e9784bea733131ed4fecc5d/mypy-2.3.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8637731bb5eee3671eb2c3200827aa3564ed8a9309ecee4d1afe77e6d031bdb", size = 14168922, upload-time = "2026-08-15T03:03:00.351Z" }, + { url = "https://files.pythonhosted.org/packages/8b/41/04dc4fe7e63d7820fa4eff272e95157d30cbea921388f3ab3fe77794cd0b/mypy-2.3.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c80fbc405ed8020f5ff3802dc18cf060197bcdd3fbdd6a26ef2fd34dfdd5226", size = 15244791, upload-time = "2026-08-15T03:02:31.089Z" }, + { url = "https://files.pythonhosted.org/packages/96/fc/c3053b26b9054949285aa868cb6af8c10e7591541cacd79c5dcc06a1fcf9/mypy-2.3.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:84081f538ce27375045c02e3d7f81bd11d853400621ae245d87ce7b6c420ec74", size = 15501627, upload-time = "2026-08-15T03:03:34.128Z" }, + { url = "https://files.pythonhosted.org/packages/70/4e/d77daab008bbc4e5001374d7928f4a260d28f0e6747af444fc4763f7a310/mypy-2.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:e9144ac16fde007096f9563eb2041b4433c2d705c4218edeb79e7e9d01035ee6", size = 11243961, upload-time = "2026-08-15T03:02:11.952Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f8/7eb68c136e4abd30569fe31ef2bfcb7eceae9952cab80017c04cd09f5d0c/mypy-2.3.1-cp313-cp313-win_arm64.whl", hash = "sha256:77ad9529e67dca28e511f5cd5671436584ce91f6d3bac159a353158187b986ac", size = 10213219, upload-time = "2026-08-15T03:02:26.361Z" }, + { url = "https://files.pythonhosted.org/packages/be/c4/42a49d44aeff804edf1b19acce0b49e8bd1a9c57dee9605dd8d980aa43d7/mypy-2.3.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:192abaedf75da1bc0b1cef104927e70ec49c1ef0031cc4825c7ee10a438ed24d", size = 13986778, upload-time = "2026-08-15T03:01:33.69Z" }, + { url = "https://files.pythonhosted.org/packages/45/13/9331fd2dfed7194d66c5304072894a8be3e51e9deda6863c1eceaa35a43d/mypy-2.3.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf678dffd16efcda2c15cbd30e9ecc0081388e29ea23687a88e686ed92638dc3", size = 14188467, upload-time = "2026-08-15T03:02:40.554Z" }, + { url = "https://files.pythonhosted.org/packages/78/f7/f4a34edab45667c5465855dc585a20e87978ffa8aee711445b7239d120c6/mypy-2.3.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e036f06b41630f4c8a1d48f9ac6aa26acc65f8be089973f5519da643318f03f", size = 15225538, upload-time = "2026-08-15T03:03:09.761Z" }, + { url = "https://files.pythonhosted.org/packages/40/05/534b3590757bd05794f73e07f6666c2a77b8597ffed795c94ce570096aa0/mypy-2.3.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:71af9c8a894e862b58e92abb08e53b05a384a1e5e5d6dc7cda59126211a53d82", size = 15480805, upload-time = "2026-08-15T03:01:41.134Z" }, + { url = "https://files.pythonhosted.org/packages/55/da/bdfba852e2562f599624af5bb7d29e36b0b4f526f2b8bac85efe0dd1803d/mypy-2.3.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:3c80cd23d85368bdd9f37d5231dfd97d35bcbf5bf41af96ef3a9b078ad1957f9", size = 7761712, upload-time = "2026-08-15T03:02:36.008Z" }, + { url = "https://files.pythonhosted.org/packages/98/31/60fc64a74cdba4f2a5d642d32317993e479163e1ac7d91b695e5d15e2264/mypy-2.3.1-cp314-cp314-win_amd64.whl", hash = "sha256:4956f34d145e145562a0a0bf367f642bbc85c04ec2baf47ae015947c3169a85d", size = 11423968, upload-time = "2026-08-15T03:02:06.931Z" }, + { url = "https://files.pythonhosted.org/packages/a9/23/eb5950b24cd26ba3b78f87707a275568d633c77dae8e61c9661be6055ca6/mypy-2.3.1-cp314-cp314-win_arm64.whl", hash = "sha256:cfb12e360242d23d91f5e978d94f58ea66acf5804c4fb6f2f794a20d4cb1b595", size = 10399323, upload-time = "2026-08-15T03:02:33.671Z" }, + { url = "https://files.pythonhosted.org/packages/82/c7/f80f4e46c0b9a00eb5f78a79d49dda8bdf56a5230f7257fb33e76be04da7/mypy-2.3.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e5f1c50bb05b64e2026b52867e8d21106f01313c744a2c4ecc34c90d12e8d6e2", size = 15121308, upload-time = "2026-08-15T03:01:46.053Z" }, + { url = "https://files.pythonhosted.org/packages/5d/74/9b04f17c7074cc5188f02fb63a2ca1d43fedf479e84fe3091c39061a1d7f/mypy-2.3.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:667196b352f4cf304ded4c10f90cfc179263a1acfb3cdcfa984bdfd340d498bc", size = 15536590, upload-time = "2026-08-15T03:01:35.941Z" }, + { url = "https://files.pythonhosted.org/packages/26/04/c837ef6208e567774e2ed1f863f8ba6ec4817b1b6dd426315e5d559b6ec9/mypy-2.3.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b9c53e395c12cad2c6d4b67d5da7c6057638a132d85c08b73646b18f802a0045", size = 16791074, upload-time = "2026-08-15T03:01:31.073Z" }, + { url = "https://files.pythonhosted.org/packages/37/68/48730230afa45192d5bd429a6a2ff24a6f8dedda90fdf2b221792b54518f/mypy-2.3.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:18162b128c3f9c703cd35f5537446900b0d21a2549aa7a95d21380d2ef643fb0", size = 17069183, upload-time = "2026-08-15T03:02:28.566Z" }, + { url = "https://files.pythonhosted.org/packages/1c/ea/ca23fc9c20eeda09a15c9cbcf50015d0e73f409f6ead059e42aa69a608ff/mypy-2.3.1-cp314-cp314t-win_amd64.whl", hash = "sha256:30c0477d4aab7b7f39c8397dc877f2c96b9fe5588ec379f372c56eb63d599f63", size = 12154679, upload-time = "2026-08-15T03:02:04.809Z" }, + { url = "https://files.pythonhosted.org/packages/3b/67/8d982126034990869466f73b8db80dcb2234a7ac39b4dad093e047a79835/mypy-2.3.1-cp314-cp314t-win_arm64.whl", hash = "sha256:6941ab3619377bc3f32ca02876b07d27f216f5201604b664d3937ea0fdd23bb4", size = 10969159, upload-time = "2026-08-15T03:02:38.152Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f7/41e7f2d8117fbc7a7587286162ffe2f688984b69c46ed63cf5f2e4fc3bae/mypy-2.3.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:6f041a6de52c9217ca125e78ba0a335cb7fd98a1c0580978e49ab2b126f70b57", size = 13990694, upload-time = "2026-08-15T03:03:21.919Z" }, + { url = "https://files.pythonhosted.org/packages/06/85/8f665811a0c8f3bf6fa1d9acd665ec2d97a2bcc453ae68dcd92340941cd6/mypy-2.3.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5159ae60f5dbc3a498af5ba8365505808ac8031bc63f9e00304ad545d40bdd9b", size = 14203518, upload-time = "2026-08-15T03:01:48.455Z" }, + { url = "https://files.pythonhosted.org/packages/2d/82/91b866c8546b120bff83b73a439d90d2d63ef3aff113599e6b8e4d566848/mypy-2.3.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47a8a7a0a7f6f6e63995c0ac36fa0c07b127413fdc81f0439b7f3dccafd33561", size = 15220224, upload-time = "2026-08-15T03:01:23.577Z" }, + { url = "https://files.pythonhosted.org/packages/c8/78/c226c99208ee40de7c768369fa533f933afa003dfdc606ff021450724e91/mypy-2.3.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:2329c0501293d4e1f33bc15d04d6304d65a1cdda967ee93a05c1e681a3923133", size = 15501512, upload-time = "2026-08-15T03:02:09.453Z" }, + { url = "https://files.pythonhosted.org/packages/a9/e7/7cfb3f106c393979f4cc37ad6c0586044d50401e3c35b0c003e4f3ba6bc9/mypy-2.3.1-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:bb26deed807bdb0457cf3e3f1cd7c4a1cf9d66864eaf1b4a61e06805d4c6b1f9", size = 7761913, upload-time = "2026-08-15T03:01:55.65Z" }, + { url = "https://files.pythonhosted.org/packages/99/3c/52affefa273b97939a1f474ae4a349c8718635c15b941112dfab4291b0c1/mypy-2.3.1-cp315-cp315-win_amd64.whl", hash = "sha256:375d7013876a8233b2d05be185bfa09f689696cd999ce8b1cfe6acac5c80e8a3", size = 11422533, upload-time = "2026-08-15T03:03:24.101Z" }, + { url = "https://files.pythonhosted.org/packages/2a/b7/75643e70c72a5b346d8a9b1543c967ea8824df2ee3fb7ccba652c272b7bb/mypy-2.3.1-cp315-cp315-win_arm64.whl", hash = "sha256:586b3612214cceabb3c0f588c97e7d1e535393f06a60e912e994f6b3ace97523", size = 10397931, upload-time = "2026-08-15T03:02:55.265Z" }, + { url = "https://files.pythonhosted.org/packages/10/ce/53be21f2d4adfcd26f63f1184a13ed797015ab463853f117e2e11e4d726f/mypy-2.3.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:ef0c6335cda9d807f8193d8ff6204a72bc909fa9882aacbca14f43cdb7188306", size = 15118669, upload-time = "2026-08-15T03:02:51.479Z" }, + { url = "https://files.pythonhosted.org/packages/62/43/20de757cd42989d291a17fad607742c4c74e875ce5cea00e5a5225020ac1/mypy-2.3.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e598c8c66401d26b150872154a286e6d484cf2789c3bb28a7556806298423021", size = 15545627, upload-time = "2026-08-15T03:03:05.132Z" }, + { url = "https://files.pythonhosted.org/packages/7e/fc/092bdf77ad280eaf501422f0f3b966012b528076cc13e41a774861c907d1/mypy-2.3.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eda22fd4efa9dcd39331d1dede9b5b8b8a7fd69af07592e778433da98610d29e", size = 16764157, upload-time = "2026-08-15T03:02:23.958Z" }, + { url = "https://files.pythonhosted.org/packages/94/5c/c94c4d62d909b07f552d0d9356d7acc943825558e602a64822ffa2231536/mypy-2.3.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:2a0ba2e57847849fb0d1fcdabb32786d223095ed8bc121dfe322bcdb3d9c46bc", size = 17073258, upload-time = "2026-08-15T03:02:14.573Z" }, + { url = "https://files.pythonhosted.org/packages/c0/f7/511a88b89e478053c02d22039bb8f3ce4183efe8fd7a4f0a5910a8bb0a32/mypy-2.3.1-cp315-cp315t-win_amd64.whl", hash = "sha256:3f7e865dd51f235f60a2dbcd8728a1c095f5ca28f095d48a725b84cd935735c4", size = 12135505, upload-time = "2026-08-15T03:02:16.714Z" }, + { url = "https://files.pythonhosted.org/packages/71/bf/02573b56964ecb0f7c644f915f53c325ae15c3faec521c5adf11599a32df/mypy-2.3.1-cp315-cp315t-win_arm64.whl", hash = "sha256:8ad80807dc3ab8ea978b1b2b6e4a657194ace1d4ef03e0e731aff1abd517da29", size = 10962647, upload-time = "2026-08-15T03:01:43.712Z" }, + { url = "https://files.pythonhosted.org/packages/8e/41/9675c7a1e78edecfba0b79e587a52594c56e189368261dc7b3a7fffb9527/mypy-2.3.1-py3-none-any.whl", hash = "sha256:6ed5c7e3419083268e5c9258bd1c1ef91af44a9e89374dbcaf37b775716e72eb", size = 2754338, upload-time = "2026-08-15T03:02:53.4Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + [[package]] name = "nodeenv" version = "1.9.1" @@ -362,6 +598,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, ] +[[package]] +name = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, +] + [[package]] name = "platformdirs" version = "4.5.1" @@ -607,6 +852,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/99/55/db07de81b5c630da5cbf5c7df646580ca26dfaefa593667fc6f2fe016d2e/tabulate-0.10.0-py3-none-any.whl", hash = "sha256:f0b0622e567335c8fabaaa659f1b33bcb6ddfe2e496071b743aa113f8774f2d3", size = 39814, upload-time = "2026-03-04T18:55:31.284Z" }, ] +[[package]] +name = "types-pyyaml" +version = "6.0.12.20260815" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9f/72/b56089aeee6c496d969bac42376bedb6e3eeab4682e1018fa3137122f94b/types_pyyaml-6.0.12.20260815.tar.gz", hash = "sha256:28764110c9cf35846e733da32d8d734df7473c5dde9ef67c3b7332ec0e819858", size = 18545, upload-time = "2026-08-15T02:41:51.532Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/52/eefeba09be4ef2a1eb989eb92934561e8e502a6ee3c32654996e4be7e399/types_pyyaml-6.0.12.20260815-py3-none-any.whl", hash = "sha256:6f332212b7e191f3afd5016a713c510b6340593b7ebec573c7d5d20aa5386d3b", size = 21148, upload-time = "2026-08-15T02:41:50.555Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + [[package]] name = "urllib3" version = "2.7.0" From 5e5a46f4c2f06efb74a1285515c56ec9921aa400 Mon Sep 17 00:00:00 2001 From: Marcin Zieba Date: Thu, 27 Aug 2026 15:48:43 +0200 Subject: [PATCH 3/4] fix(graphql): validate manufacturer_slugs in the shared filter helper The three public fetchers each ran their own `len(manufacturer_slugs) == 0` pre-check before `_build_manufacturer_filter` validated the input, so a non-sequence such as `get_device_types(manufacturer_slugs=5)` raised a TypeError from `len(5)` instead of the documented ValueError. `_build_manufacturer_filter` is now the one validation point: None means no filter, and everything else that is not a non-empty sequence of non-blank strings raises ValueError. The three duplicated pre-checks are gone. Behavior for None and for an empty sequence is unchanged. --- core/graphql_client.py | 27 +++++++++++++------------- tests/test_graphql_client.py | 37 ++++++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 13 deletions(-) diff --git a/core/graphql_client.py b/core/graphql_client.py index 6c4821ab..70baabfd 100644 --- a/core/graphql_client.py +++ b/core/graphql_client.py @@ -363,19 +363,26 @@ def _build_manufacturer_filter(self, slugs): ``extra_variables`` is the dict to pass as ``variables`` to :meth:`query_all`. + This is the one place manufacturer_slugs is validated, for every caller. + Args: slugs: ``None`` or a non-empty sequence (list or tuple) of manufacturer slug strings. Returns: tuple[str, str, dict] + + Raises: + ValueError: If *slugs* is anything but None or a non-empty sequence of + non-blank strings. """ - if not slugs: + if slugs is None: return "", "", {} # A str is itself a Sequence of characters, so reject it before the item check. if ( isinstance(slugs, (str, bytes)) or not isinstance(slugs, Sequence) + or not slugs or any(not isinstance(s, str) or not s.strip() for s in slugs) ): raise ValueError("manufacturer_slugs must be None or a non-empty sequence of non-empty strings") @@ -406,11 +413,9 @@ def get_device_types(self, manufacturer_slugs=None): - ``by_slug``: ``{(manufacturer_slug, slug): record}`` Raises: - ValueError: If *manufacturer_slugs* is an empty sequence. + ValueError: If *manufacturer_slugs* is anything but None or a non-empty + sequence of non-blank strings. """ - if manufacturer_slugs is not None and len(manufacturer_slugs) == 0: - raise ValueError("manufacturer_slugs must be None or a non-empty sequence") - var_decl, filter_fragment, extra_vars = self._build_manufacturer_filter(manufacturer_slugs) query = f""" @@ -468,11 +473,9 @@ def get_module_types(self, manufacturer_slugs=None): dict: ``{manufacturer_slug: {model: record}}`` Raises: - ValueError: If *manufacturer_slugs* is an empty sequence. + ValueError: If *manufacturer_slugs* is anything but None or a non-empty + sequence of non-blank strings. """ - if manufacturer_slugs is not None and len(manufacturer_slugs) == 0: - raise ValueError("manufacturer_slugs must be None or a non-empty sequence") - var_decl, filter_fragment, extra_vars = self._build_manufacturer_filter(manufacturer_slugs) query = f""" @@ -517,11 +520,9 @@ def get_rack_types(self, manufacturer_slugs=None): dict: ``{manufacturer_slug: {model: record}}`` Raises: - ValueError: If *manufacturer_slugs* is an empty sequence. + ValueError: If *manufacturer_slugs* is anything but None or a non-empty + sequence of non-blank strings. """ - if manufacturer_slugs is not None and len(manufacturer_slugs) == 0: - raise ValueError("manufacturer_slugs must be None or a non-empty sequence") - var_decl, filter_fragment, extra_vars = self._build_manufacturer_filter(manufacturer_slugs) query = f""" diff --git a/tests/test_graphql_client.py b/tests/test_graphql_client.py index d11ee36a..6c5b22b5 100644 --- a/tests/test_graphql_client.py +++ b/tests/test_graphql_client.py @@ -1728,6 +1728,25 @@ def test_blank_item_raises_value_error(self): with pytest.raises(ValueError, match="manufacturer_slugs must be None or a non-empty"): client._build_manufacturer_filter(("cisco", " ")) + @pytest.mark.parametrize("slugs", [5, 5.0, object()], ids=["int", "float", "object"]) + def test_non_sequence_raises_value_error(self, slugs): + """Garbage input is a caller error, not a TypeError from a length check.""" + client = self._make_client() + with pytest.raises(ValueError, match="manufacturer_slugs must be None or a non-empty"): + client._build_manufacturer_filter(slugs) + + @pytest.mark.parametrize("slugs", [(), []], ids=["tuple", "list"]) + def test_empty_sequence_raises_value_error(self, slugs): + """An empty selection would silently widen the query to every manufacturer.""" + client = self._make_client() + with pytest.raises(ValueError, match="manufacturer_slugs must be None or a non-empty"): + client._build_manufacturer_filter(slugs) + + def test_none_builds_no_filter(self): + """Only None means "every manufacturer".""" + client = self._make_client() + assert client._build_manufacturer_filter(None) == ("", "", {}) + class TestVendorScopedDeviceTypes: """Tests for vendor-scoped filtering in get_device_types().""" @@ -1874,6 +1893,12 @@ def test_empty_sequence_raises_value_error(self, slugs): with pytest.raises(ValueError, match="manufacturer_slugs must be None or a non-empty"): client.get_device_types(manufacturer_slugs=slugs) + def test_non_sequence_raises_value_error(self): + """Garbage input must fail as a caller error, not as a TypeError from a length check.""" + client = self._make_client() + with pytest.raises(ValueError, match="manufacturer_slugs must be None or a non-empty"): + client.get_device_types(manufacturer_slugs=5) + class TestVendorScopedModuleTypes: """Tests for vendor-scoped filtering in get_module_types().""" @@ -1967,6 +1992,12 @@ def test_empty_sequence_raises_value_error(self, slugs): with pytest.raises(ValueError, match="manufacturer_slugs must be None or a non-empty"): client.get_module_types(manufacturer_slugs=slugs) + def test_non_sequence_raises_value_error(self): + """Garbage input must fail as a caller error, not as a TypeError from a length check.""" + client = self._make_client() + with pytest.raises(ValueError, match="manufacturer_slugs must be None or a non-empty"): + client.get_module_types(manufacturer_slugs=5) + class TestVendorScopedRackTypes: """Tests for vendor-scoped filtering in get_rack_types().""" @@ -2026,6 +2057,12 @@ def test_empty_sequence_raises_value_error(self, slugs): with pytest.raises(ValueError, match="manufacturer_slugs must be None or a non-empty"): client.get_rack_types(manufacturer_slugs=slugs) + def test_non_sequence_raises_value_error(self): + """Garbage input must fail as a caller error, not as a TypeError from a length check.""" + client = self._make_client() + with pytest.raises(ValueError, match="manufacturer_slugs must be None or a non-empty"): + client.get_rack_types(manufacturer_slugs=5) + class TestVendorScopedComponentTemplates: """Tests for vendor-scoped filtering in get_component_templates().""" From d155947f9d50c1ccee9d871a5430feb8ed15aa84 Mon Sep 17 00:00:00 2001 From: Marcin Zieba Date: Thu, 27 Aug 2026 17:55:51 +0200 Subject: [PATCH 4/4] fix(export): reject a bare string for vendor_slugs A bare string is truthy and iterable, so `tuple("cisco")` produced the five single-character slugs `('c','i','s','c','o')`. Each one is a non-blank string, so the GraphQL validator accepted them and built a five-manufacturer filter. The Exporter converts before the GraphQL layer sees the value, so its str/bytes rejection never fired. Fail fast at the boundary that does the conversion. --- core/export.py | 3 +++ tests/test_exporter.py | 6 ++++++ 2 files changed, 9 insertions(+) diff --git a/core/export.py b/core/export.py index 0a79ae1d..c0dd57ac 100644 --- a/core/export.py +++ b/core/export.py @@ -202,6 +202,9 @@ def __init__(self, config, handle, export_dir: str, force_overwrite: bool, vendo self.handle = handle self.export_dir = Path(export_dir) self.force_overwrite = force_overwrite + # tuple("cisco") is five single-character slugs, each one valid to the GraphQL layer. + if isinstance(vendor_slugs, (str, bytes)): + raise ValueError("vendor_slugs must be None or a sequence of vendor slugs, not a bare string") # None means all vendors. The GraphQL layer rejects an empty sequence, so normalize one here. self.vendor_slugs = tuple(vendor_slugs) if vendor_slugs else None self.repo_path = Path(config.repo_path) diff --git a/tests/test_exporter.py b/tests/test_exporter.py index e14d75e1..18c06bf2 100644 --- a/tests/test_exporter.py +++ b/tests/test_exporter.py @@ -732,6 +732,12 @@ def test_selection_is_kept_as_a_tuple(self, tmp_path): assert self._make_exporter(tmp_path, ("nokia",)).vendor_slugs == ("nokia",) assert self._make_exporter(tmp_path, ["nokia"]).vendor_slugs == ("nokia",) + @pytest.mark.parametrize("bare", ["cisco", b"cisco"], ids=["str", "bytes"]) + def test_bare_string_raises_value_error(self, tmp_path, bare): + """tuple("cisco") would filter on five single-character slugs, and every one of them is valid.""" + with pytest.raises(ValueError, match="vendor_slugs must be None or a sequence"): + self._make_exporter(tmp_path, bare) + class TestVendorDirSlugNormalization: """Tests for Exporter._vendor_dirs slug-based directory matching."""