diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..7141f8a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,67 @@ +name: CI + +'on': + push: + branches: + - master + pull_request: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + quality: + runs-on: ubuntu-24.04 + timeout-minutes: 20 + steps: + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + persist-credentials: false + - name: Set up uv + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 + with: + version: "0.11.32" + enable-cache: true + python-version: "3.10" + - name: Install dependencies + run: uv sync --locked --all-groups + - name: Check formatting + run: uv run ruff format --check . + - name: Lint + run: uv run ruff check . + - name: Run Pyrefly + run: uv run pyrefly check + - name: Run mypy + run: uv run mypy . + - name: Run Pyright + run: uv run pyright + + tests: + runs-on: ubuntu-24.04 + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + python-version: + - "3.10" + - "3.14" + steps: + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + persist-credentials: false + - name: Set up uv + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 + with: + version: "0.11.32" + enable-cache: true + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: uv sync --locked --all-groups + - name: Run tests + run: uv run pytest diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d2493ce --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +.worktrees/ +.DS_Store +.coverage +.mypy_cache/ +.pytest_cache/ +.pyrefly_cache/ +.ruff_cache/ +.tox/ +.venv/ +__pycache__/ +build/ +dist/ +*.egg-info/ +*.py[cod] diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..c8cfe39 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.10 diff --git a/CH_02_interactive_python/README.rst b/CH_02_interactive_python/README.rst index 6569ba3..170d4c4 100644 --- a/CH_02_interactive_python/README.rst +++ b/CH_02_interactive_python/README.rst @@ -1,8 +1,16 @@ Chapter 2 - interactive python ======================================================================================================================= -1. The `rlcompleter` enhancement we created currently only handles dictionaries. Try and extend the code so it supports lists, strings, and tuples as well. -2. Add colors to the completer (hint: use `colorama` for the coloring). -3. Instead of manually completing using our own object introspection, try and use the `jedi` library for autocompletion, which does static code analysis. -4. Try to create a `Hello` `` so the name of the person can be edited through a notebook without code changes. -5. Try and create a script that will look for a given pattern through all of your previous ipython sessions. +1. `Exercise 1 `_: The ``rlcompleter`` enhancement we + created currently only handles dictionaries. Try and extend the code so it + supports lists, strings, and tuples as well. +2. `Exercise 2 `_: Add colors to the completer (hint: + use ``colorama`` for the coloring). +3. `Exercise 3 `_: Instead of manually completing using + our own object introspection, try and use the ``jedi`` library for + autocompletion, which does static code analysis. +4. `Exercise 4 `_: Try to create a ``Hello`` + ```` so the name of the person can be edited through a notebook + without code changes. +5. `Exercise 5 `_: Try and create a script that will + look for a given pattern through all of your previous ipython sessions. diff --git a/CH_02_interactive_python/exercise_01/README.rst b/CH_02_interactive_python/exercise_01/README.rst new file mode 100644 index 0000000..765e39e --- /dev/null +++ b/CH_02_interactive_python/exercise_01/README.rst @@ -0,0 +1,48 @@ +Exercise 1 - safe finite-sequence completion +============================================ + +1. The `rlcompleter` enhancement we created currently only handles dictionaries. Try and extend the code so it supports lists, strings, and tuples as well. + +Solution +-------- + +``Completer`` extends the standard-library ``rlcompleter.Completer`` with safe +name and item completion. Exact built-in dictionaries produce recursively safe +literal keys using ``repr``; non-finite numeric keys, keys nested beyond 64 +tuple levels, and keys that exceed interpreter rendering limits are rejected. +Exact lists, tuples, and strings produce finite integer indices. Prefixes +filter candidates, and each request inspects at most 25 items. An explicitly +supplied namespace is shallow-copied once, and both completion paths use that +snapshot. Name completion includes Python hard keywords and built-ins without +inspecting namespace values. + +Expressions are parsed with ``ast``. Only names, exact ``SimpleNamespace`` +attributes containing safe containers, and exact built-in container subscripts +whose keys are safe literals are resolved. Calls and all other expression +forms are rejected, so completion never evaluates arbitrary Python code. +Importing the module does not register a ``readline`` completer or mutate +terminal state. + +Dependencies +------------ + +The solution uses only the Python standard library. The repository's ``dev`` +dependency group supplies pytest and the static-analysis tools. + +Run the demonstration: + +.. code-block:: console + + uv run python CH_02_interactive_python/exercise_01/solution_00.py + +Run the focused tests: + +.. code-block:: console + + uv run pytest CH_02_interactive_python/exercise_01/test_solution_00.py + +Reference +--------- + +The chapter's original dictionary-only implementation is +`T_02_modifying_autocompletion.py `_. diff --git a/CH_02_interactive_python/exercise_01/__init__.py b/CH_02_interactive_python/exercise_01/__init__.py new file mode 100644 index 0000000..9c95a1b --- /dev/null +++ b/CH_02_interactive_python/exercise_01/__init__.py @@ -0,0 +1 @@ +"""Exercise 1: safe item completion.""" diff --git a/CH_02_interactive_python/exercise_01/solution_00.py b/CH_02_interactive_python/exercise_01/solution_00.py new file mode 100644 index 0000000..42faffd --- /dev/null +++ b/CH_02_interactive_python/exercise_01/solution_00.py @@ -0,0 +1,243 @@ +"""Safely complete mapping keys and finite-sequence indices.""" + +from __future__ import annotations + +import ast +import builtins +import keyword +import math +import rlcompleter +import sys +from collections.abc import Iterator, Mapping, Sequence +from types import SimpleNamespace +from typing import Final, cast + +MAX_MATCHES: Final[int] = 25 +MAX_LITERAL_NESTING: Final[int] = 64 +_UNRESOLVED: Final[object] = object() +_KEYWORDS: Final[tuple[str, ...]] = tuple(keyword.kwlist) +_BUILTINS: Final[tuple[tuple[str, object], ...]] = tuple( + (name, value) for name, value in vars(builtins).items() if type(name) is str +) + + +def _is_safe_literal(value: object, remaining_depth: int = MAX_LITERAL_NESTING) -> bool: + value_type: type[object] = type(value) + if value_type in (str, bytes, int, bool, type(None)): + return True + if value_type is float: + float_number: float = cast(float, value) + return math.isfinite(float_number) + if value_type is complex: + complex_number: complex = cast(complex, value) + return math.isfinite(complex_number.real) and math.isfinite(complex_number.imag) + if value_type is tuple: + if remaining_depth <= 0: + return False + items: tuple[object, ...] = cast(tuple[object, ...], value) + return all(_is_safe_literal(item, remaining_depth - 1) for item in items) + return False + + +def _render_safe_literal(value: object) -> str | None: + try: + if not _is_safe_literal(value): + return None + return repr(value) + except (RecursionError, ValueError): + return None + + +def _is_safe_container(value: object) -> bool: + return type(value) in (dict, list, tuple, str) + + +class Completer(rlcompleter.Completer): + """Extend standard completion with safe item completion.""" + + def __init__(self, namespace: Mapping[str, object] | None = None) -> None: + parent_namespace: dict[str, object] | None = ( + dict(namespace) if namespace is not None else None + ) + super().__init__(parent_namespace) + self._namespace: Mapping[str, object] | None = parent_namespace + self._item_completion_active: bool = False + self._item_matches_cache: list[str] = [] + self._name_matches_cache: list[str] = [] + + def _active_namespace(self) -> Mapping[str, object]: + if self._namespace is None: + return cast(Mapping[str, object], vars(sys.modules["__main__"])) + return self._namespace + + def _resolve_name(self, name: str) -> object: + candidate: object + value: object + for candidate, value in self._active_namespace().items(): + if type(candidate) is str and candidate == name: + return value + return _UNRESOLVED + + def _resolve_node(self, node: ast.expr) -> object: + if isinstance(node, ast.Name): + return self._resolve_name(node.id) + + if isinstance(node, ast.Attribute): + owner: object = self._resolve_node(node.value) + if owner is _UNRESOLVED: + return _UNRESOLVED + if type(owner) is not SimpleNamespace: + return _UNRESOLVED + attributes: object = object.__getattribute__(owner, "__dict__") + if type(attributes) is not dict: + return _UNRESOLVED + attribute_mapping: dict[object, object] = cast( + dict[object, object], attributes + ) + for candidate, attribute in attribute_mapping.items(): + if ( + type(candidate) is str + and candidate == node.attr + and _is_safe_container(attribute) + ): + return attribute + return _UNRESOLVED + + if isinstance(node, ast.Subscript): + container: object = self._resolve_node(node.value) + if container is _UNRESOLVED: + return _UNRESOLVED + try: + key: object = ast.literal_eval(node.slice) + except (SyntaxError, ValueError): + return _UNRESOLVED + if not _is_safe_literal(key): + return _UNRESOLVED + try: + if type(container) is dict: + mapping: Mapping[object, object] = cast( + Mapping[object, object], container + ) + for candidate, value in mapping.items(): + if _is_safe_literal(candidate) and candidate == key: + return value + return _UNRESOLVED + if type(container) in (list, tuple, str) and type(key) is int: + sequence: Sequence[object] = cast(Sequence[object], container) + return sequence[key] + except (IndexError, KeyError, TypeError): + return _UNRESOLVED + + return _UNRESOLVED + + def _resolve_expression(self, expression: str) -> object: + try: + node: ast.expr = ast.parse(expression, mode="eval").body + except SyntaxError: + return _UNRESOLVED + return self._resolve_node(node) + + @staticmethod + def _matches_prefix(key: object, rendered: str, prefix: str) -> bool: + if not prefix: + return True + if prefix[0] in {"'", '"'}: + return type(key) is str and key.startswith(prefix[1:]) + return rendered.startswith(prefix) + + def item_matches(self, text: str) -> Iterator[str]: + """Yield safe item completions for the final opening bracket in *text*.""" + expression: str + separator: str + prefix: str + expression, separator, prefix = text.rpartition("[") + if not separator or not expression: + return + + value: object = self._resolve_expression(expression) + if type(value) is dict: + mapping: Mapping[object, object] = cast(Mapping[object, object], value) + keys: Iterator[object] = iter(mapping) + elif type(value) in (list, tuple, str): + sequence: Sequence[object] = cast(Sequence[object], value) + keys = iter(range(min(len(sequence), MAX_MATCHES))) + else: + return + + inspected: int = 0 + key: object + for key in keys: + if inspected >= MAX_MATCHES: + return + inspected += 1 + rendered: str | None = _render_safe_literal(key) + if rendered is None: + continue + if self._matches_prefix(key, rendered, prefix.lstrip()): + yield f"{expression}[{rendered}]" + + def _name_matches(self, text: str) -> Iterator[str]: + if not text.isidentifier(): + return + seen: set[str] = set() + keyword_name: str + for keyword_name in _KEYWORDS: + if keyword_name.startswith(text): + seen.add(keyword_name) + if keyword_name in {"finally", "try"}: + yield f"{keyword_name}:" + elif keyword_name in {"False", "None", "True"}: + yield keyword_name + else: + yield f"{keyword_name} " + name: object + for name in self._active_namespace(): + if type(name) is str and name not in seen and name.startswith(text): + seen.add(name) + yield name + value: object + for name, value in _BUILTINS: + if name in seen or not name.startswith(text): + continue + if type(value) in (type(len), type): + yield f"{name}(" + else: + yield name + + def complete(self, text: str, state: int) -> str | None: + """Return the requested cached item match or standard completion.""" + if "[" in text: + if state == 0: + self._item_matches_cache = list(self.item_matches(text)) + self._item_completion_active = bool(self._item_matches_cache) + if self._item_completion_active: + try: + return self._item_matches_cache[state] + except IndexError: + return None + return None + if "." in text: + return None + if state == 0: + self._name_matches_cache = list(self._name_matches(text)) + try: + return self._name_matches_cache[state] + except IndexError: + return None + + +def main() -> None: + """Print a small, non-registering completion demonstration.""" + namespace: dict[str, object] = { + "config": {"host": "localhost", "port": 8000}, + "letters": ["a", "b", "c"], + } + completer: Completer = Completer(namespace) + text: str + for text in ("config[", "letters["): + matches: list[str] = list(completer.item_matches(text)) + print(f"{text} -> {matches}") + + +if __name__ == "__main__": + main() diff --git a/CH_02_interactive_python/exercise_01/test_solution_00.py b/CH_02_interactive_python/exercise_01/test_solution_00.py new file mode 100644 index 0000000..c61891c --- /dev/null +++ b/CH_02_interactive_python/exercise_01/test_solution_00.py @@ -0,0 +1,564 @@ +"""Tests for the safe item completer.""" + +import importlib +import readline +import rlcompleter +import sys +from collections.abc import Iterator, Mapping +from types import ModuleType, SimpleNamespace +from typing import ClassVar, cast + +import pytest + +from . import solution_00 +from .solution_00 import MAX_MATCHES, Completer + + +class RecordingMapping(Mapping[object, object]): + def __init__(self, calls: list[str]) -> None: + self._calls: list[str] = calls + + def __getitem__(self, key: object) -> object: + self._calls.append("getitem") + raise KeyError(key) + + def __iter__(self) -> Iterator[object]: + self._calls.append("iter") + return iter(()) + + def __len__(self) -> int: + self._calls.append("len") + return 0 + + +class RecordingKey: + def __init__(self, calls: list[str]) -> None: + self._calls: list[str] = calls + + def __hash__(self) -> int: + return 0 + + def __repr__(self) -> str: + self._calls.append("repr") + return "unsafe_key" + + +class RecordingNamespaceKey: + def __init__(self, calls: list[str]) -> None: + self._calls: list[str] = calls + + def __hash__(self) -> int: + return hash("values") + + def __eq__(self, other: object) -> bool: + del other + self._calls.append("eq") + return False + + +class RecordingList(list[object]): + def __init__(self, calls: list[str]) -> None: + super().__init__(["unsafe"]) + self._calls: list[str] = calls + + def __len__(self) -> int: + self._calls.append("len") + return super().__len__() + + +def test_completes_mapping_keys_using_repr() -> None: + values: Mapping[object, object] = {"alpha": 1, "beta": 2, 10: "ten"} + completer: Completer = Completer({"values": values}) + + matches: list[str] = list(completer.item_matches("values[")) + + assert matches == ["values['alpha']", "values['beta']", "values[10]"] + + +def test_filters_mapping_keys_by_quoted_and_unquoted_prefixes() -> None: + values: dict[object, object] = {"alpha": 1, "beta": 2, 10: "ten", 20: "twenty"} + completer: Completer = Completer({"values": values}) + + quoted_matches: list[str] = list(completer.item_matches("values['a")) + numeric_matches: list[str] = list(completer.item_matches("values[1")) + + assert quoted_matches == ["values['alpha']"] + assert numeric_matches == ["values[10]"] + + +def test_rejects_non_finite_float_and_complex_keys() -> None: + values: dict[object, object] = { + 1.5: "finite float", + float("inf"): "positive infinity", + float("-inf"): "negative infinity", + float("nan"): "not a number", + complex(1, 2): "finite complex", + complex(float("inf"), 1): "infinite real component", + complex(1, float("nan")): "nan imaginary component", + } + completer: Completer = Completer({"values": values}) + + matches: list[str] = list(completer.item_matches("values[")) + + assert matches == ["values[1.5]", "values[(1+2j)]"] + + +def test_skips_huge_integer_keys_that_cannot_be_rendered() -> None: + huge_key: int = 10**5000 + completer: Completer = Completer({"values": {huge_key: "too large"}}) + + matches: list[str] = list(completer.item_matches("values[")) + + assert matches == [] + + +def test_skips_nested_huge_integer_keys_that_cannot_be_rendered() -> None: + huge_key: tuple[object, ...] = (10**5000,) + completer: Completer = Completer({"values": {huge_key: "too large"}}) + + matches: list[str] = list(completer.item_matches("values[")) + + assert matches == [] + + +def test_skips_deep_tuple_keys_and_continues_to_safe_keys() -> None: + original_limit: int = sys.getrecursionlimit() + deep_key: object = 0 + _depth: int + for _depth in range(500): + deep_key = cast(object, (deep_key,)) + values: dict[object, object] = {deep_key: "too deep", "safe": 1} + completer: Completer = Completer({"values": values}) + + try: + sys.setrecursionlimit(3000) + matches: list[str] = list(completer.item_matches("values[")) + finally: + sys.setrecursionlimit(original_limit) + + assert matches == ["values['safe']"] + + +def test_completes_list_tuple_and_string_indices() -> None: + namespace: dict[str, object] = { + "items": ["first", "second"], + "pair": ("left", "right"), + "word": "abc", + } + completer: Completer = Completer(namespace) + + list_matches: list[str] = list(completer.item_matches("items[")) + tuple_matches: list[str] = list(completer.item_matches("pair[")) + string_matches: list[str] = list(completer.item_matches("word[")) + + assert list_matches == ["items[0]", "items[1]"] + assert tuple_matches == ["pair[0]", "pair[1]"] + assert string_matches == ["word[0]", "word[1]", "word[2]"] + + +def test_filters_sequence_indices_by_prefix() -> None: + items: list[int] = list(range(20)) + completer: Completer = Completer({"items": items}) + + matches: list[str] = list(completer.item_matches("items[1")) + + assert matches == [ + "items[1]", + "items[10]", + "items[11]", + "items[12]", + "items[13]", + "items[14]", + "items[15]", + "items[16]", + "items[17]", + "items[18]", + "items[19]", + ] + + +def test_resolves_attributes_and_literal_subscripts() -> None: + holder: SimpleNamespace = SimpleNamespace(values={"nested": ["a", "b"]}) + completer: Completer = Completer({"holder": holder}) + + attribute_matches: list[str] = list(completer.item_matches("holder.values[")) + nested_matches: list[str] = list(completer.item_matches("holder.values['nested'][")) + + assert attribute_matches == ["holder.values['nested']"] + assert nested_matches == [ + "holder.values['nested'][0]", + "holder.values['nested'][1]", + ] + + +def test_missing_and_unsupported_values_have_no_item_matches() -> None: + namespace: dict[str, object] = {"number": 42, "items": {1, 2, 3}} + completer: Completer = Completer(namespace) + + missing_matches: list[str] = list(completer.item_matches("missing[")) + scalar_matches: list[str] = list(completer.item_matches("number[")) + set_matches: list[str] = list(completer.item_matches("items[")) + unsupported_expression_matches: list[str] = list( + completer.item_matches("(number + 1)[") + ) + + assert missing_matches == [] + assert scalar_matches == [] + assert set_matches == [] + assert unsupported_expression_matches == [] + + +def test_caps_item_matches_at_max_matches() -> None: + values: dict[str, int] = {f"key-{index}": index for index in range(30)} + completer: Completer = Completer({"values": values}) + + matches: list[str] = list(completer.item_matches("values[")) + + assert MAX_MATCHES == 25 + assert len(matches) == MAX_MATCHES + assert matches[-1] == "values['key-24']" + + +def test_does_not_invoke_call_expressions() -> None: + calls: list[str] = [] + + def dangerous() -> dict[str, int]: + calls.append("called") + return {"secret": 1} + + completer: Completer = Completer({"dangerous": dangerous}) + + matches: list[str] = list(completer.item_matches("dangerous()[")) + + assert matches == [] + assert calls == [] + + +def test_complete_does_not_invoke_properties() -> None: + calls: list[str] = [] + + class Dangerous: + @property + def secret(self) -> dict[str, int]: + calls.append("property") + return {"secret": 1} + + completer: Completer = Completer({"dangerous": Dangerous()}) + + match: str | None = completer.complete("dangerous.secret.", 0) + + assert calls == [] + assert match is None + + +def test_complete_does_not_inspect_class_roots_with_hostile_metaclasses() -> None: + calls: list[str] = [] + + class HostileMeta(type): + def __getattribute__(cls, name: str) -> object: + calls.append(name) + return super().__getattribute__(name) + + class Evil(metaclass=HostileMeta): + values: ClassVar[dict[str, int]] = {"secret": 1} + + completer: Completer = Completer({"Evil": Evil}) + + match: str | None = completer.complete("Evil.values[", 0) + + assert calls == [] + assert match is None + + +def test_complete_does_not_inspect_instances_with_hostile_metaclasses() -> None: + calls: list[str] = [] + + class HostileMeta(type): + def __getattribute__(cls, name: str) -> object: + calls.append(name) + return super().__getattribute__(name) + + class Evil(metaclass=HostileMeta): + values: dict[str, int] + + def __init__(self) -> None: + self.values = {"secret": 1} + + evil: Evil = Evil() + calls.clear() + completer: Completer = Completer({"obj": evil}) + + match: str | None = completer.complete("obj.values[", 0) + + assert calls == [] + assert match is None + + +def test_complete_does_not_inspect_hostile_instances_from_safe_containers() -> None: + calls: list[str] = [] + + class HostileMeta(type): + def __getattribute__(cls, name: str) -> object: + calls.append(name) + return super().__getattribute__(name) + + class Evil(metaclass=HostileMeta): + values: dict[str, int] + + def __init__(self) -> None: + self.values = {"secret": 1} + + evil: Evil = Evil() + calls.clear() + namespace: dict[str, object] = { + "objects": {"evil": evil}, + "items": [evil], + "pair": (evil,), + } + completer: Completer = Completer(namespace) + + expression: str + for expression in ( + "objects['evil'].values[", + "items[0].values[", + "pair[0].values[", + ): + match: str | None = completer.complete(expression, 0) + + assert calls == [] + assert match is None + + +def test_complete_does_not_invoke_instance_getattribute() -> None: + calls: list[str] = [] + + class Evil: + def __init__(self) -> None: + self.values: dict[str, int] = {"secret": 1} + + def __getattribute__(self, name: str) -> object: + calls.append(name) + return object.__getattribute__(self, name) + + evil: Evil = Evil() + completer: Completer = Completer({"obj": evil}) + + match: str | None = completer.complete("obj.values[", 0) + + assert calls == [] + assert match is None + + +def test_complete_does_not_invoke_container_value_getattribute() -> None: + calls: list[str] = [] + + class Evil: + def __init__(self) -> None: + self.values: dict[str, int] = {"secret": 1} + + def __getattribute__(self, name: str) -> object: + calls.append(name) + return object.__getattribute__(self, name) + + evil: Evil = Evil() + namespace: dict[str, object] = { + "objects": {"evil": evil}, + "items": [evil], + "pair": (evil,), + } + completer: Completer = Completer(namespace) + + expression: str + for expression in ( + "objects['evil'].values[", + "items[0].values[", + "pair[0].values[", + ): + match: str | None = completer.complete(expression, 0) + + assert calls == [] + assert match is None + + +def test_name_completion_does_not_inspect_callable_objects() -> None: + calls: list[str] = [] + + class DangerousCallable: + @property + def __signature__(self) -> object: + calls.append("signature") + return None + + def __call__(self) -> None: + calls.append("call") + + completer: Completer = Completer({"dangerous_callable": DangerousCallable()}) + + match: str | None = completer.complete("dangerous_c", 0) + + assert calls == [] + assert match == "dangerous_callable" + + +def test_item_completion_does_not_iterate_custom_mappings() -> None: + calls: list[str] = [] + completer: Completer = Completer({"values": RecordingMapping(calls)}) + + match: str | None = completer.complete("values[", 0) + + assert match is None + assert calls == [] + + +def test_nested_item_completion_does_not_index_custom_mappings() -> None: + calls: list[str] = [] + completer: Completer = Completer({"values": RecordingMapping(calls)}) + + match: str | None = completer.complete("values['nested'][", 0) + + assert match is None + assert calls == [] + + +def test_item_completion_does_not_render_unsafe_keys() -> None: + calls: list[str] = [] + key: RecordingKey = RecordingKey(calls) + completer: Completer = Completer({"values": {key: "secret"}}) + + match: str | None = completer.complete("values[", 0) + + assert match is None + assert calls == [] + + +def test_item_completion_rejects_container_subclasses_without_invoking_them() -> None: + calls: list[str] = [] + completer: Completer = Completer({"values": RecordingList(calls)}) + + match: str | None = completer.complete("values[", 0) + + assert match is None + assert calls == [] + + +def test_namespace_snapshot_is_shared_by_all_completion_paths() -> None: + namespace: dict[str, object] = {"values": {"before": 1}} + completer: Completer = Completer(namespace) + namespace["values"] = {"after": 2} + + matches: list[str] = list(completer.item_matches("values[")) + + assert matches == ["values['before']"] + + +def test_item_completion_does_not_compare_unsafe_namespace_keys() -> None: + calls: list[str] = [] + key: RecordingNamespaceKey = RecordingNamespaceKey(calls) + unsafe_namespace: dict[object, object] = {key: {"secret": 1}} + namespace: Mapping[str, object] = cast(Mapping[str, object], unsafe_namespace) + completer: Completer = Completer(namespace) + + match: str | None = completer.complete("values[", 0) + + assert calls == [] + assert match is None + + +def test_complete_caches_item_matches_across_states() -> None: + completer: Completer = Completer({"values": {"alpha": 1, "beta": 2}}) + + first: str | None = completer.complete("values[", 0) + second: str | None = completer.complete("values[", 1) + exhausted: str | None = completer.complete("values[", 2) + + assert first == "values['alpha']" + assert second == "values['beta']" + assert exhausted is None + + +def test_complete_resets_cached_matches_at_state_zero() -> None: + completer: Completer = Completer({"values": {"alpha": 1, "beta": 2}}) + + initial: str | None = completer.complete("values[", 0) + reset: str | None = completer.complete("values['b", 0) + exhausted: str | None = completer.complete("values['b", 1) + + assert initial == "values['alpha']" + assert reset == "values['beta']" + assert exhausted is None + + +def test_complete_preserves_explicit_namespace_completion() -> None: + completer: Completer = Completer({"example_name": 1}) + + match: str | None = completer.complete("example_n", 0) + + assert match == "example_name" + + +def test_complete_preserves_builtin_name_completion() -> None: + completer: Completer = Completer({}) + + match: str | None = completer.complete("pri", 0) + + assert match == "print(" + + +def test_complete_preserves_keyword_completion() -> None: + completer: Completer = Completer({}) + + match: str | None = completer.complete("for", 0) + + assert match == "for " + + +@pytest.mark.parametrize("name", ["_", "case", "match"]) +def test_complete_treats_soft_keywords_as_namespace_names(name: str) -> None: + completer: Completer = Completer({name: 1}) + + match: str | None = completer.complete(name, 0) + + assert match == name + + +def test_complete_does_not_delegate_item_text_without_matches( + monkeypatch: pytest.MonkeyPatch, +) -> None: + calls: list[tuple[str, int]] = [] + + def standard_complete( + completer: rlcompleter.Completer, text: str, state: int + ) -> str | None: + del completer + calls.append((text, state)) + return f"standard-{state}" + + monkeypatch.setattr(rlcompleter.Completer, "complete", standard_complete) + completer: Completer = Completer({"number": 42}) + + first: str | None = completer.complete("number[", 0) + second: str | None = completer.complete("number[", 1) + + assert first is None + assert second is None + assert calls == [] + + +def test_import_does_not_register_readline_hooks( + monkeypatch: pytest.MonkeyPatch, +) -> None: + calls: list[tuple[str, object]] = [] + + def record_completer(completer: object) -> None: + calls.append(("set_completer", completer)) + + def record_parse_and_bind(command: str) -> None: + calls.append(("parse_and_bind", command)) + + monkeypatch.setattr(readline, "set_completer", record_completer) + monkeypatch.setattr(readline, "parse_and_bind", record_parse_and_bind) + + reloaded: ModuleType = importlib.reload(solution_00) + + assert reloaded is solution_00 + assert calls == [] diff --git a/CH_02_interactive_python/exercise_02/README.rst b/CH_02_interactive_python/exercise_02/README.rst new file mode 100644 index 0000000..5ff29ba --- /dev/null +++ b/CH_02_interactive_python/exercise_02/README.rst @@ -0,0 +1,71 @@ +Exercise 2 - colored completion candidates +========================================== + +The exercise asks: + +:: + + 2. Add colors to the completer (hint: use `colorama` for the coloring). + +Solution +-------- + +``ColorCompleter`` reuses the safe parser and completion behavior from +Exercise 1. Its ``complete`` method returns each inherited candidate unchanged, +including the ``None`` sentinel, so readline inserts plain valid Python rather +than ANSI escape bytes. On GNU readline, a registered completion-display hook +applies the configured Colorama foreground color only while listing choices, +then explicitly writes and flushes the fixed ``>>>`` prompt together with +readline's current input buffer. Calling ``redisplay()`` alone is insufficient +because a custom display hook does not receive the prompt. The default color is +``Fore.CYAN``; callers can pass another Colorama color with the keyword-only +``color`` argument. Every colored candidate ends with ``Style.RESET_ALL`` so +its color does not leak into later terminal output. + +The guarded demonstration removes expression punctuation from readline's +completion delimiters so the callback receives complete attribute, bracket, +quoted-string, signed-number, complex-number, and tuple expressions. It +always registers the plain replacement callback. GNU readline additionally +receives the prompt-aware colored display hook and uses ``tab: complete``. +Python 3.10's macOS libedit backend ignores the display-hook API, so the +configuration safely degrades to libedit's plain candidate display and uses +``bind ^I rl_complete``. It never adds ANSI bytes to replacement text to force +color on libedit. + +Exercise 2 adds no terminal, Colorama, or direct readline configuration during +import. Its terminal compatibility and direct readline setup happen only in the +guarded demonstration. On Python 3.10, a fresh import still reaches the +standard-library ``rlcompleter`` dependency through Exercise 1; that module +performs its own one-time readline registration. This inherited Exercise 1 +integration behavior is outside the colored wrapper. + +Dependencies +------------ + +The solution requires Python 3.10+ and Colorama. The repository's +``interactive`` dependency group supplies Colorama, while its default ``dev`` +group supplies pytest and the static-analysis tools. + +Run the module: + +.. code-block:: console + + $ uv run --group interactive python -m CH_02_interactive_python.exercise_02.solution_00 + +Run the focused tests: + +.. code-block:: console + + $ uv run --group interactive pytest CH_02_interactive_python/exercise_02/test_solution_00.py + +Run the optional real GNU readline PTY probe: + +.. code-block:: console + + $ uv run --python 3.10 --group interactive --with gnureadline --with pexpect pytest CH_02_interactive_python/exercise_02/test_solution_00.py::test_gnu_readline_pty_restores_prompt_and_plain_buffer + +Reference +--------- + +The chapter's original implementation is +`T_02_modifying_autocompletion.py `_. diff --git a/CH_02_interactive_python/exercise_02/__init__.py b/CH_02_interactive_python/exercise_02/__init__.py new file mode 100644 index 0000000..1fdb227 --- /dev/null +++ b/CH_02_interactive_python/exercise_02/__init__.py @@ -0,0 +1 @@ +"""Exercise 2: colored completion candidates.""" diff --git a/CH_02_interactive_python/exercise_02/solution_00.py b/CH_02_interactive_python/exercise_02/solution_00.py new file mode 100644 index 0000000..c6a120d --- /dev/null +++ b/CH_02_interactive_python/exercise_02/solution_00.py @@ -0,0 +1,161 @@ +"""Color safe completion candidates.""" + +from __future__ import annotations + +from collections.abc import Callable, Mapping, Sequence +from typing import Final, Protocol + +from colorama import ( # type: ignore[import-untyped] + Fore, + Style, + just_fix_windows_console, +) + +from CH_02_interactive_python.exercise_01.solution_00 import Completer + +_CompletionFunction = Callable[[str, int], str | None] +_DisplayHook = Callable[[str, Sequence[str], int], None] +_EXPRESSION_CHARACTERS: Final[frozenset[str]] = frozenset(".[]'\"(),+-\\") +PROMPT: Final[str] = ">>> " + + +class ReadlineLike(Protocol): + """Readline operations needed by the interactive demonstration.""" + + def get_completer_delims(self) -> str: ... + + def set_completer_delims(self, delimiters: str, /) -> None: ... + + def set_completer( + self, + completer: _CompletionFunction | None = None, + /, + ) -> None: ... + + def set_completion_display_matches_hook( + self, + hook: _DisplayHook | None = None, + /, + ) -> None: ... + + def parse_and_bind(self, binding: str, /) -> None: ... + + def get_line_buffer(self) -> str: ... + + +def colorize(match: str, color: str) -> str: + """Wrap a completion candidate in a color and reset the terminal style.""" + return f"{color}{match}{Style.RESET_ALL}" + + +class ColorCompleter(Completer): + """Color candidates produced by the safe completer.""" + + def __init__( + self, + namespace: Mapping[str, object] | None = None, + *, + color: str = Fore.CYAN, + ) -> None: + super().__init__(namespace) + self._color: str = color + + def complete(self, text: str, state: int) -> str | None: + """Return the requested plain candidate or the completion sentinel.""" + return super().complete(text, state) + + def display_matches( + self, + substitution: str, + matches: Sequence[str], + longest_match_length: int, + ) -> None: + """Display colored candidates without changing replacement text.""" + del substitution, longest_match_length + print() + match: str + for match in matches: + print(colorize(match, self._color)) + + +def _completion_delimiters(delimiters: str) -> str: + kept: list[str] = [] + character: str + for character in delimiters: + if character not in _EXPRESSION_CHARACTERS: + kept.append(character) + return "".join(kept) + + +def _uses_libedit(readline_module: ReadlineLike) -> bool: + backend: object = getattr(readline_module, "backend", None) + if backend == "editline": + return True + if backend == "readline": + return False + + library_version: object = getattr( + readline_module, + "_READLINE_LIBRARY_VERSION", + "", + ) + if isinstance(library_version, str) and "editline" in library_version.casefold(): + return True + + documentation: object = getattr(readline_module, "__doc__", "") + return isinstance(documentation, str) and "libedit" in documentation.casefold() + + +def _display_hook( + readline_module: ReadlineLike, + completer: ColorCompleter, + prompt: str, +) -> _DisplayHook: + def display_matches( + substitution: str, + matches: Sequence[str], + longest_match_length: int, + ) -> None: + completer.display_matches(substitution, matches, longest_match_length) + restored_line: str = f"{prompt}{readline_module.get_line_buffer()}" + print(restored_line, end="", flush=True) + + return display_matches + + +def configure_readline( + readline_module: ReadlineLike, + completer: ColorCompleter, + *, + prompt: str, +) -> None: + """Configure safe expression completion for GNU readline or libedit.""" + delimiters: str = _completion_delimiters(readline_module.get_completer_delims()) + readline_module.set_completer_delims(delimiters) + readline_module.set_completer(completer.complete) + uses_libedit: bool = _uses_libedit(readline_module) + if not uses_libedit: + readline_module.set_completion_display_matches_hook( + _display_hook(readline_module, completer, prompt) + ) + binding: str = "bind ^I rl_complete" if uses_libedit else "tab: complete" + readline_module.parse_and_bind(binding) + + +def main() -> None: + """Run an interactive completion demonstration.""" + import readline + + just_fix_windows_console() + completer: ColorCompleter = ColorCompleter( + {"config": {"host": "localhost", "port": 8000}} + ) + configure_readline(readline, completer, prompt=PROMPT) + try: + input(PROMPT) + except EOFError: + pass + + +if __name__ == "__main__": + main() diff --git a/CH_02_interactive_python/exercise_02/test_solution_00.py b/CH_02_interactive_python/exercise_02/test_solution_00.py new file mode 100644 index 0000000..d16aa39 --- /dev/null +++ b/CH_02_interactive_python/exercise_02/test_solution_00.py @@ -0,0 +1,256 @@ +"""Tests for colored safe completions.""" + +from __future__ import annotations + +import importlib.util +import re +import subprocess +import sys +from collections.abc import Callable, Sequence + +import pytest +from colorama import Fore, Style # type: ignore[import-untyped] + +from CH_02_interactive_python.exercise_01.solution_00 import Completer +from CH_02_interactive_python.exercise_02 import solution_00 +from CH_02_interactive_python.exercise_02.solution_00 import ( + ColorCompleter, + colorize, +) + +_ANSI_ESCAPE = re.compile(r"\x1b\[[0-9;]*m") +_CompletionFunction = Callable[[str, int], str | None] +_DisplayHook = Callable[[str, Sequence[str], int], None] + + +class FakeReadline: + def __init__( + self, + *, + delimiters: str, + library_version: str, + backend: str | None = None, + line_buffer: str = "", + ) -> None: + self.backend: str | None = backend + self._READLINE_LIBRARY_VERSION: str = library_version + self._delimiters: str = delimiters + self.completer: _CompletionFunction | None = None + self.display_hook: _DisplayHook | None = None + self.bindings: list[str] = [] + self.line_buffer: str = line_buffer + + def get_completer_delims(self) -> str: + return self._delimiters + + def set_completer_delims(self, delimiters: str) -> None: + self._delimiters = delimiters + + def set_completer(self, completer: _CompletionFunction | None = None) -> None: + self.completer = completer + + def set_completion_display_matches_hook( + self, + hook: _DisplayHook | None = None, + ) -> None: + self.display_hook = hook + + def parse_and_bind(self, binding: str) -> None: + self.bindings.append(binding) + + def get_line_buffer(self) -> str: + return self.line_buffer + + +def test_colorize_wraps_match_and_resets_style() -> None: + assert colorize("candidate", Fore.RED) == (f"{Fore.RED}candidate{Style.RESET_ALL}") + + +def test_complete_returns_plain_first_sequence_candidate() -> None: + completer: ColorCompleter = ColorCompleter({"values": ["alpha", "alpine"]}) + + match: str | None = completer.complete("values[", 0) + + assert match == "values[0]" + assert _ANSI_ESCAPE.search(match) is None + + +def test_complete_preserves_plain_candidates_and_sentinel() -> None: + completer: ColorCompleter = ColorCompleter( + {"values": {"alpha": 1, "alpine": 2}}, + color=Fore.MAGENTA, + ) + + matches: list[str | None] = [ + completer.complete("values[", state) for state in range(3) + ] + + assert matches == [ + "values['alpha']", + "values['alpine']", + None, + ] + + +def test_display_hook_colors_and_resets_each_candidate( + capsys: pytest.CaptureFixture[str], +) -> None: + completer: ColorCompleter = ColorCompleter(color=Fore.MAGENTA) + + completer.display_matches( + "values[", + ["values[0]", "values[1]"], + len("values[0]"), + ) + + assert capsys.readouterr().out.splitlines() == [ + "", + f"{Fore.MAGENTA}values[0]{Style.RESET_ALL}", + f"{Fore.MAGENTA}values[1]{Style.RESET_ALL}", + ] + + +def test_configure_gnu_readline_restores_prompt_and_current_buffer( + capsys: pytest.CaptureFixture[str], +) -> None: + readline: FakeReadline = FakeReadline( + delimiters=" \t.[]'\"(),+-\\:", + library_version="8.2", + line_buffer="config['", + ) + completer: ColorCompleter = ColorCompleter({"values": [1]}) + + solution_00.configure_readline(readline, completer, prompt=">>> ") + + assert readline.get_completer_delims() == " \t:" + assert readline.completer == completer.complete + assert readline.display_hook is not None + assert readline.bindings == ["tab: complete"] + readline.display_hook("values[", ["values[0]"], len("values[0]")) + output: str = capsys.readouterr().out + assert output.startswith(f"\n{Fore.CYAN}values[0]{Style.RESET_ALL}\n") + assert output.endswith(">>> config['") + + +def test_configure_libedit_uses_plain_safe_fallback() -> None: + readline: FakeReadline = FakeReadline( + delimiters=" \t[]'\"", + library_version="EditLine wrapper", + ) + completer: ColorCompleter = ColorCompleter({"values": [1]}) + + solution_00.configure_readline(readline, completer, prompt=">>> ") + + assert readline.bindings == ["bind ^I rl_complete"] + assert readline.display_hook is None + assert readline.completer is not None + assert readline.completer("values[", 0) == "values[0]" + + +@pytest.mark.integration +def test_gnu_readline_pty_restores_prompt_and_plain_buffer() -> None: + if ( + importlib.util.find_spec("gnureadline") is None + or importlib.util.find_spec("pexpect") is None + ): + pytest.skip("requires the optional gnureadline and pexpect packages") + + script: str = r''' +import io +import sys + +import pexpect + +child_code = """ +import sys +import gnureadline as readline +sys.modules["readline"] = readline +from CH_02_interactive_python.exercise_02.solution_00 import ( + ColorCompleter, + PROMPT, + configure_readline, +) + +completer = ColorCompleter({"config": {"host": "localhost", "port": 8000}}) +configure_readline(readline, completer, prompt=PROMPT) +value = input(PROMPT) +print("FINAL=" + value) +""" +transcript_stream = io.StringIO() +child = pexpect.spawn( + sys.executable, + ["-c", child_code], + encoding="utf-8", + timeout=10, +) +child.logfile_read = transcript_stream +child.expect_exact(">>> ") +child.send("config[\x27") +child.send("\t\t") +child.expect_exact("\x1b[36mconfig[\x27host\x27]\x1b[0m") +child.expect_exact(">>> config[\x27") +child.send("h\r") +child.expect_exact("FINAL=config[\x27h") +child.expect(pexpect.EOF) +transcript = transcript_stream.getvalue() +assert ">>> config[\x27h" in transcript +final_text = transcript.rsplit("FINAL=", 1)[1] +assert "\x1b[" not in final_text +print(repr(transcript)) +''' + result: subprocess.CompletedProcess[str] = subprocess.run( + [sys.executable, "-c", script], + check=False, + capture_output=True, + text=True, + ) + + assert result.returncode == 0, result.stderr + assert "FINAL=config['h" in result.stdout + + +def test_color_completer_inherits_safe_expression_rejection() -> None: + calls: list[str] = [] + + def dangerous() -> dict[str, int]: + calls.append("called") + return {"secret": 1} + + completer: ColorCompleter = ColorCompleter({"dangerous": dangerous}) + + assert isinstance(completer, Completer) + assert completer.complete("dangerous()[", 0) is None + assert calls == [] + + +def test_import_adds_no_terminal_or_readline_configuration() -> None: + script = """ +import importlib +import sys + +import colorama +import readline +import CH_02_interactive_python.exercise_01.solution_00 + +def unexpected_call(*args: object, **kwargs: object) -> None: + del args, kwargs + raise AssertionError("import performed terminal setup") + +colorama.just_fix_windows_console = unexpected_call +readline.set_completer = unexpected_call +readline.set_completer_delims = unexpected_call +readline.set_completion_display_matches_hook = unexpected_call +readline.parse_and_bind = unexpected_call + +sys.modules.pop("CH_02_interactive_python.exercise_02.solution_00", None) +importlib.import_module("CH_02_interactive_python.exercise_02.solution_00") +""" + + result: subprocess.CompletedProcess[str] = subprocess.run( + [sys.executable, "-c", script], + check=False, + capture_output=True, + text=True, + ) + + assert result.returncode == 0, result.stderr diff --git a/CH_02_interactive_python/exercise_03/README.rst b/CH_02_interactive_python/exercise_03/README.rst new file mode 100644 index 0000000..6f41b7d --- /dev/null +++ b/CH_02_interactive_python/exercise_03/README.rst @@ -0,0 +1,56 @@ +Exercise 3 - Jedi static source completion +========================================== + +The exercise asks: + +:: + + 3. Instead of manually completing using our own object introspection, try and use the `jedi` library for autocompletion, which does static code analysis. + +Solution +-------- + +``complete_source`` passes the source text to ``jedi.Script`` and returns the +sorted, deduplicated ``name_with_symbols`` completion spellings. It does not +directly execute runtime calls embedded in the supplied source text. This is +not a sandbox or security boundary: Jedi may start an interpreter or import +compiled modules for environment inference, so analyzing untrusted code or +environments can have import-initialization side effects. Importing this +solution module does not invoke completion; only the guarded module +demonstration prints output. + +``line`` uses Jedi's one-based line numbering and ``column`` is a zero-based +offset within that line. When omitted, both coordinates target the end of the +final logical source line. Empty source contains one empty line. A terminal +``\n``, ``\r``, or ``\r\n`` creates a final empty line, including after +multiple trailing newlines. Explicit lines must be between 1 and the number of +logical lines, and columns must be between 0 and the selected line's length. +Invalid coordinates raise ``ValueError``. + +Incomplete or invalid Python is passed unchanged to Jedi for best-effort +completion. Jedi and dependency failures propagate to the caller. + +Dependencies +------------ + +The solution requires Python 3.10+ and Jedi. The repository's ``interactive`` +dependency group supplies Jedi, while the default ``dev`` group supplies +pytest and the static-analysis tools. + +Run the guarded module demonstration: + +.. code-block:: console + + $ uv run --group interactive python -m CH_02_interactive_python.exercise_03.solution_00 + +Run the focused tests: + +.. code-block:: console + + $ uv run --group interactive pytest CH_02_interactive_python/exercise_03/test_solution_00.py + +Reference +--------- + +The question comes from the immutable `upstream Chapter 2 exercise list +`_. diff --git a/CH_02_interactive_python/exercise_03/__init__.py b/CH_02_interactive_python/exercise_03/__init__.py new file mode 100644 index 0000000..9e4e8e6 --- /dev/null +++ b/CH_02_interactive_python/exercise_03/__init__.py @@ -0,0 +1 @@ +"""Exercise 3: static source completion with Jedi.""" diff --git a/CH_02_interactive_python/exercise_03/solution_00.py b/CH_02_interactive_python/exercise_03/solution_00.py new file mode 100644 index 0000000..cffe8de --- /dev/null +++ b/CH_02_interactive_python/exercise_03/solution_00.py @@ -0,0 +1,59 @@ +"""Complete Python source statically with Jedi.""" + +from __future__ import annotations + +import re +from collections.abc import Iterable +from typing import Protocol, cast + +import jedi # type: ignore[import-untyped] + + +class _Completion(Protocol): + @property + def name_with_symbols(self) -> str: ... + + +class _Script(Protocol): + def complete(self, line: int, column: int) -> Iterable[_Completion]: ... + + +def complete_source( + source: str, + *, + line: int | None = None, + column: int | None = None, +) -> list[str]: + """Return Jedi completion spellings at a validated source position.""" + lines: list[str] = re.split(r"\r\n|\r|\n", source) + selected_line: int = len(lines) if line is None else line + if not 1 <= selected_line <= len(lines): + raise ValueError( + f"line must be between 1 and {len(lines)}; got {selected_line}", + ) + + selected_source_line: str = lines[selected_line - 1] + selected_column: int = len(selected_source_line) if column is None else column + if not 0 <= selected_column <= len(selected_source_line): + raise ValueError( + "column must be between 0 and " + f"{len(selected_source_line)} for line {selected_line}; " + f"got {selected_column}", + ) + + script: _Script = cast(_Script, jedi.Script(code=source)) + completions: Iterable[_Completion] = script.complete( + selected_line, + selected_column, + ) + names: set[str] = {completion.name_with_symbols for completion in completions} + return sorted(names) + + +def _demo() -> None: + source: str = "from pathlib import Pa" + print("\n".join(complete_source(source))) + + +if __name__ == "__main__": + _demo() diff --git a/CH_02_interactive_python/exercise_03/test_solution_00.py b/CH_02_interactive_python/exercise_03/test_solution_00.py new file mode 100644 index 0000000..d40d9b5 --- /dev/null +++ b/CH_02_interactive_python/exercise_03/test_solution_00.py @@ -0,0 +1,193 @@ +"""Tests for the Jedi-based source completer.""" + +from __future__ import annotations + +import subprocess +import sys +from collections.abc import Iterable +from pathlib import Path +from typing import Protocol, cast + +import jedi # type: ignore[import-untyped] +import pytest + +from CH_02_interactive_python.exercise_03.solution_00 import complete_source + + +class _JediCompletion(Protocol): + @property + def name_with_symbols(self) -> str: ... + + +class _JediScript(Protocol): + def complete(self, line: int, column: int) -> Iterable[_JediCompletion]: ... + + +def _direct_jedi_names(source: str, *, line: int, column: int) -> list[str]: + script: _JediScript = cast(_JediScript, jedi.Script(code=source)) + completions: Iterable[_JediCompletion] = script.complete(line, column) + return sorted( + {completion.name_with_symbols for completion in completions}, + ) + + +def test_pathlib_incomplete_import_includes_path() -> None: + source: str = "from pathlib import Pa" + + assert "Path" in complete_source(source) + + +def test_explicit_coordinates_complete_middle_of_source() -> None: + target_line: str = "from pathlib import Pa" + source: str = f"{target_line}\nunrelated_name = 1" + + completions: list[str] = complete_source( + source, + line=1, + column=len(target_line), + ) + + assert "Path" in completions + + +class _FakeCompletion: + def __init__(self, name_with_symbols: str) -> None: + self.name_with_symbols: str = name_with_symbols + + +class _FakeScript: + def __init__(self, *, code: str) -> None: + assert code == "candidate" + + def complete(self, line: int, column: int) -> list[_FakeCompletion]: + assert (line, column) == (1, len("candidate")) + return [ + _FakeCompletion("zeta"), + _FakeCompletion("alpha"), + _FakeCompletion("zeta"), + ] + + +def test_returns_sorted_unique_candidate_names( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + "CH_02_interactive_python.exercise_03.solution_00.jedi.Script", + _FakeScript, + ) + + assert complete_source("candidate") == ["alpha", "zeta"] + + +def test_empty_source_uses_first_line_at_column_zero() -> None: + completions: list[str] = complete_source("") + + assert "abs" in completions + assert completions == sorted(set(completions)) + + +def test_default_targets_terminal_empty_line() -> None: + source: str = "x\n" + expected: list[str] = _direct_jedi_names(source, line=2, column=0) + + assert complete_source(source) == expected + + +def test_explicit_terminal_empty_line_coordinate_is_valid() -> None: + source: str = "x\n" + + assert complete_source(source, line=2, column=0) == _direct_jedi_names( + source, + line=2, + column=0, + ) + + +@pytest.mark.parametrize( + ("source", "final_line"), + [ + ("x\n\n", 3), + ("x\r\n\r\n", 3), + ], +) +def test_default_targets_final_line_after_multiple_newlines( + source: str, + final_line: int, + monkeypatch: pytest.MonkeyPatch, +) -> None: + calls: list[tuple[str, int, int]] = [] + + class RecordingScript: + def __init__(self, *, code: str) -> None: + self.source: str = code + + def complete(self, line: int, column: int) -> list[_FakeCompletion]: + calls.append((self.source, line, column)) + return [] + + monkeypatch.setattr( + "CH_02_interactive_python.exercise_03.solution_00.jedi.Script", + RecordingScript, + ) + + assert complete_source(source) == [] + assert calls == [(source, final_line, 0)] + + +def test_form_feed_stays_within_one_source_line() -> None: + source: str = "x =\f1" + expected: list[str] = _direct_jedi_names( + source, + line=1, + column=len(source), + ) + + assert complete_source(source) == expected + + +@pytest.mark.parametrize( + ("source", "line", "column", "message"), + [ + ("value", 0, 0, "line"), + ("value", 2, 0, "line"), + ("value", 1, -1, "column"), + ("value", 1, len("value") + 1, "column"), + ], +) +def test_rejects_invalid_coordinates( + source: str, + line: int, + column: int, + message: str, +) -> None: + with pytest.raises(ValueError, match=message): + complete_source(source, line=line, column=column) + + +def test_source_runtime_calls_are_not_executed(tmp_path: Path) -> None: + marker: Path = tmp_path / "marker.txt" + marker.write_text("unchanged") + source: str = ( + f"from pathlib import Path\nPath({str(marker)!r}).write_text('changed')\nPath." + ) + + # This checks direct source evaluation, not whether Jedi is a security sandbox. + complete_source(source) + + assert marker.read_text() == "unchanged" + + +def test_import_has_no_output_or_demo_side_effects() -> None: + result: subprocess.CompletedProcess[str] = subprocess.run( + [ + sys.executable, + "-c", + "import CH_02_interactive_python.exercise_03.solution_00", + ], + check=True, + capture_output=True, + text=True, + ) + + assert result.stdout == "" + assert result.stderr == "" diff --git a/CH_02_interactive_python/exercise_04/README.rst b/CH_02_interactive_python/exercise_04/README.rst new file mode 100644 index 0000000..8040f19 --- /dev/null +++ b/CH_02_interactive_python/exercise_04/README.rst @@ -0,0 +1,65 @@ +Exercise 4 - editable Hello widget +================================== + +The exercise asks: + +:: + + 4. Try to create a `Hello` `` so the name of the person can be edited through a notebook without code changes. + +Solution +-------- + +``create_hello_widget`` returns an immutable bundle containing a text input, a +greeting label, and a vertical container. Editing the text input updates the +existing label immediately. Leading and trailing whitespace is removed from the +greeting; an empty or whitespace-only name displays ``Hello, World!``. + +Each call creates independent widget state. Importing the module and creating +the bundle are headless: neither operation displays a widget, opens a browser, +nor requires a running notebook. ``main`` imports IPython's display helper only +when called and displays the container. + +Call ``hello.close()`` when the bundle is no longer needed. Cleanup is +idempotent, unregisters only the bundle's own observer, and closes the input, +label, and container. Observers registered by callers remain untouched. If a +cleanup operation raises, calling ``close`` again retries unfinished cleanup. + +Notebook usage: + +.. code-block:: python + + from IPython.display import display + + from CH_02_interactive_python.exercise_04.solution_00 import ( + create_hello_widget, + ) + + hello = create_hello_widget("Ada") + display(hello.container) + +Dependencies +------------ + +The solution requires Python 3.10+, ipywidgets, and IPython. The repository's +``interactive`` dependency group supplies both packages; the default ``dev`` +group supplies pytest and the static-analysis tools. + +Run the guarded module demonstration: + +.. code-block:: console + + $ uv run --group interactive python -m CH_02_interactive_python.exercise_04.solution_00 + +Run the focused tests without opening a notebook or browser: + +.. code-block:: console + + $ uv run --group interactive pytest CH_02_interactive_python/exercise_04/test_solution_00.py + +Reference +--------- + +The question is preserved from the immutable +`upstream Chapter 2 exercise list +`_. diff --git a/CH_02_interactive_python/exercise_04/__init__.py b/CH_02_interactive_python/exercise_04/__init__.py new file mode 100644 index 0000000..2160092 --- /dev/null +++ b/CH_02_interactive_python/exercise_04/__init__.py @@ -0,0 +1 @@ +"""Exercise 4: editable Hello widget for notebooks.""" diff --git a/CH_02_interactive_python/exercise_04/solution_00.py b/CH_02_interactive_python/exercise_04/solution_00.py new file mode 100644 index 0000000..ff006c4 --- /dev/null +++ b/CH_02_interactive_python/exercise_04/solution_00.py @@ -0,0 +1,100 @@ +"""Create an editable greeting widget for notebook use.""" + +from __future__ import annotations + +from collections.abc import Callable, Mapping +from dataclasses import dataclass, field +from typing import cast + +import ipywidgets as widgets # type: ignore[import-untyped] + + +@dataclass(frozen=True) +class HelloWidget: + """Widgets that make up an editable greeting.""" + + name_input: widgets.Text + greeting: widgets.Label + container: widgets.VBox + _observer: Callable[[Mapping[str, object]], None] | None = field( + default=None, + init=False, + repr=False, + compare=False, + ) + _closed: bool = field( + default=False, + init=False, + repr=False, + compare=False, + ) + + def close(self) -> None: + """Unregister internal state and close every owned widget once.""" + if self._closed: + return + + first_error: Exception | None = None + if self._observer is not None: + try: + self.name_input.unobserve(self._observer, names="value") + except Exception as error: + first_error = error + else: + object.__setattr__(self, "_observer", None) + + for widget in (self.name_input, self.greeting, self.container): + try: + widget.close() + except Exception as error: + if first_error is None: + first_error = error + + if first_error is not None: + raise first_error + object.__setattr__(self, "_closed", True) + + +def _greeting_text(name: str) -> str: + displayed_name: str = name.strip() or "World" + return f"Hello, {displayed_name}!" + + +def create_hello_widget(initial_name: str = "World") -> HelloWidget: + """Create independent editable greeting state.""" + name_input: widgets.Text = widgets.Text( + value=initial_name, + description="Name", + ) + greeting: widgets.Label = widgets.Label(value=_greeting_text(initial_name)) + + def update_greeting(change: Mapping[str, object]) -> None: + raw_name: object = change["new"] + name: str = raw_name.strip() if isinstance(raw_name, str) else "" + greeting.value = _greeting_text(name) + + name_input.observe(update_greeting, names="value") + container: widgets.VBox = widgets.VBox(children=(name_input, greeting)) + hello: HelloWidget = HelloWidget( + name_input=name_input, + greeting=greeting, + container=container, + ) + object.__setattr__(hello, "_observer", update_greeting) + return hello + + +def main() -> None: + """Display the editable greeting in an IPython frontend.""" + from IPython.display import display # pyright: ignore[reportUnknownVariableType] + + hello: HelloWidget = create_hello_widget() + display_widget: Callable[[object], object] = cast( + Callable[[object], object], + display, + ) + display_widget(hello.container) + + +if __name__ == "__main__": + main() diff --git a/CH_02_interactive_python/exercise_04/test_solution_00.py b/CH_02_interactive_python/exercise_04/test_solution_00.py new file mode 100644 index 0000000..890f7ed --- /dev/null +++ b/CH_02_interactive_python/exercise_04/test_solution_00.py @@ -0,0 +1,347 @@ +"""Tests for the editable Hello widget.""" + +from __future__ import annotations + +import gc +import subprocess +import sys +import weakref +from collections.abc import Callable, Mapping +from dataclasses import FrozenInstanceError +from typing import Protocol, cast + +import IPython.display +import ipywidgets as widgets # type: ignore[import-untyped] +import pytest +from traitlets import Bunch + +import CH_02_interactive_python.exercise_04.solution_00 as solution_00 +from CH_02_interactive_python.exercise_04.solution_00 import ( + HelloWidget, + create_hello_widget, +) + + +class _ChangeNotifier(Protocol): + def notify_change(self, change: Bunch, /) -> None: ... + + +def test_initial_name_sets_text_and_greeting() -> None: + hello: HelloWidget = create_hello_widget("Ada") + + assert hello.name_input.value == "Ada" + assert hello.name_input.description == "Name" + assert hello.greeting.value == "Hello, Ada!" + + +def test_editing_name_updates_existing_greeting_synchronously() -> None: + hello: HelloWidget = create_hello_widget("Ada") + + hello.name_input.value = "Grace" + + assert hello.greeting.value == "Hello, Grace!" + + +@pytest.mark.parametrize("name", ["", " ", "\t\n"]) +def test_empty_or_whitespace_name_falls_back_to_world(name: str) -> None: + hello: HelloWidget = create_hello_widget("Ada") + + hello.name_input.value = name + + assert hello.greeting.value == "Hello, World!" + + +def test_nonempty_name_is_trimmed_in_greeting() -> None: + hello: HelloWidget = create_hello_widget(" Ada Lovelace ") + + assert hello.name_input.value == " Ada Lovelace " + assert hello.greeting.value == "Hello, Ada Lovelace!" + + hello.name_input.value = " Grace Hopper\t" + + assert hello.greeting.value == "Hello, Grace Hopper!" + + +def test_non_string_observer_value_falls_back_to_world() -> None: + hello: HelloWidget = create_hello_widget("Ada") + change: Bunch = Bunch( + name="value", + old="Ada", + new=42, + owner=hello.name_input, + type="change", + ) + notifier: _ChangeNotifier = cast(_ChangeNotifier, hello.name_input) + + notifier.notify_change(change) + + assert hello.greeting.value == "Hello, World!" + + +def test_instances_have_independent_widget_state() -> None: + ada: HelloWidget = create_hello_widget("Ada") + grace: HelloWidget = create_hello_widget("Grace") + + ada.name_input.value = "Charles" + + assert ada.greeting.value == "Hello, Charles!" + assert grace.name_input.value == "Grace" + assert grace.greeting.value == "Hello, Grace!" + assert ada.name_input is not grace.name_input + assert ada.greeting is not grace.greeting + assert ada.container is not grace.container + + +def test_container_has_exact_widget_types_and_order() -> None: + hello: HelloWidget = create_hello_widget() + children_attribute: str = "children" + children: tuple[object, ...] = cast( + tuple[object, ...], + getattr(hello.container, children_attribute), + ) + + assert type(hello.name_input) is widgets.Text + assert type(hello.greeting) is widgets.Label + assert type(hello.container) is widgets.VBox + assert children == (hello.name_input, hello.greeting) + + +def test_bundle_is_frozen() -> None: + hello: HelloWidget = create_hello_widget() + field_name: str = "greeting" + + with pytest.raises(FrozenInstanceError): + setattr(hello, field_name, widgets.Label(value="replacement")) + + +def test_close_preserves_external_observer_and_stops_greeting_updates() -> None: + hello: HelloWidget = create_hello_widget("Ada") + external_values: list[object] = [] + + def observe_external(change: Mapping[str, object]) -> None: + external_values.append(change["new"]) + + hello.name_input.observe(observe_external, names="value") + + hello.close() + hello.close() + hello.name_input.value = "Grace" + + assert hello.greeting.value == "Hello, Ada!" + assert external_values == ["Grace"] + + +def test_close_closes_each_owned_widget_exactly_once( + monkeypatch: pytest.MonkeyPatch, +) -> None: + hello: HelloWidget = create_hello_widget() + owned_widgets: tuple[object, ...] = ( + hello.name_input, + hello.greeting, + hello.container, + ) + close_calls: dict[int, int] = {id(widget): 0 for widget in owned_widgets} + + def counting_close( + widget_id: int, + original_close: Callable[..., None], + ) -> Callable[..., None]: + def record_close(*args: object, **kwargs: object) -> None: + close_calls[widget_id] += 1 + original_close(*args, **kwargs) + + return record_close + + comm_attribute: str = "comm" + close_attribute: str = "close" + for widget in owned_widgets: + comm: object = getattr(widget, comm_attribute) + original_close: Callable[..., None] = cast( + Callable[..., None], + getattr(comm, close_attribute), + ) + monkeypatch.setattr( + comm, + close_attribute, + counting_close(id(widget), original_close), + ) + + hello.close() + hello.close() + + assert close_calls == {id(widget): 1 for widget in owned_widgets} + assert all(getattr(widget, comm_attribute) is None for widget in owned_widgets) + + +def test_close_releases_internal_greeting_reference() -> None: + hello: HelloWidget = create_hello_widget() + retained_name_input: widgets.Text = hello.name_input + greeting_reference: weakref.ReferenceType[widgets.Label] = weakref.ref( + hello.greeting, + ) + + hello.close() + del hello + gc.collect() + + assert greeting_reference() is None + assert retained_name_input.value == "World" + + +def test_close_retries_failed_widget_after_attempting_remaining_cleanup( + monkeypatch: pytest.MonkeyPatch, +) -> None: + hello: HelloWidget = create_hello_widget("Ada") + external_values: list[object] = [] + + def observe_external(change: Mapping[str, object]) -> None: + external_values.append(change["new"]) + + hello.name_input.observe(observe_external, names="value") + unobserve_attribute: str = "unobserve" + original_unobserve: Callable[..., None] = cast( + Callable[..., None], + getattr(hello.name_input, unobserve_attribute), + ) + unobserve_calls: int = 0 + + def record_unobserve(*args: object, **kwargs: object) -> None: + nonlocal unobserve_calls + unobserve_calls += 1 + original_unobserve(*args, **kwargs) + + monkeypatch.setattr( + hello.name_input, + unobserve_attribute, + record_unobserve, + ) + close_attribute: str = "close" + original_name_close: Callable[[], None] = cast( + Callable[[], None], + getattr(hello.name_input, close_attribute), + ) + name_close_calls: int = 0 + close_error: RuntimeError = RuntimeError("name close failed") + + def fail_name_close_once() -> None: + nonlocal name_close_calls + name_close_calls += 1 + if name_close_calls == 1: + raise close_error + original_name_close() + + monkeypatch.setattr( + hello.name_input, + close_attribute, + fail_name_close_once, + ) + + with pytest.raises(RuntimeError, match="name close failed") as error_info: + hello.close() + + assert error_info.value is close_error + comm_attribute: str = "comm" + assert getattr(hello.name_input, comm_attribute) is not None + assert getattr(hello.greeting, comm_attribute) is None + assert getattr(hello.container, comm_attribute) is None + + hello.close() + hello.name_input.value = "Grace" + + assert name_close_calls == 2 + assert unobserve_calls == 1 + assert getattr(hello.name_input, comm_attribute) is None + assert hello.greeting.value == "Hello, Ada!" + assert external_values == ["Grace"] + + +def test_close_retries_transient_unobserve_failure( + monkeypatch: pytest.MonkeyPatch, +) -> None: + hello: HelloWidget = create_hello_widget("Ada") + external_values: list[object] = [] + + def observe_external(change: Mapping[str, object]) -> None: + external_values.append(change["new"]) + + hello.name_input.observe(observe_external, names="value") + unobserve_attribute: str = "unobserve" + original_unobserve: Callable[..., None] = cast( + Callable[..., None], + getattr(hello.name_input, unobserve_attribute), + ) + unobserve_calls: int = 0 + unobserve_error: RuntimeError = RuntimeError("unobserve failed") + + def fail_unobserve_once(*args: object, **kwargs: object) -> None: + nonlocal unobserve_calls + unobserve_calls += 1 + if unobserve_calls == 1: + raise unobserve_error + original_unobserve(*args, **kwargs) + + monkeypatch.setattr( + hello.name_input, + unobserve_attribute, + fail_unobserve_once, + ) + + with pytest.raises(RuntimeError, match="unobserve failed") as error_info: + hello.close() + + assert error_info.value is unobserve_error + comm_attribute: str = "comm" + assert getattr(hello.name_input, comm_attribute) is None + assert getattr(hello.greeting, comm_attribute) is None + assert getattr(hello.container, comm_attribute) is None + hello.name_input.value = "Grace" + assert hello.greeting.value == "Hello, Grace!" + + hello.close() + hello.close() + hello.name_input.value = "Katherine" + + assert unobserve_calls == 2 + assert hello.greeting.value == "Hello, Grace!" + assert external_values == ["Grace", "Katherine"] + + +def test_main_displays_only_the_container( + monkeypatch: pytest.MonkeyPatch, +) -> None: + displayed: list[object] = [] + + def record_display(value: object) -> None: + displayed.append(value) + + monkeypatch.setattr(IPython.display, "display", record_display) + + solution_00.main() + + assert len(displayed) == 1 + container: object = displayed[0] + assert type(container) is widgets.VBox + + +def test_import_is_headless_and_has_no_output() -> None: + script: str = """ +import IPython.display +import webbrowser + +def fail(*args: object, **kwargs: object) -> None: + raise AssertionError("import attempted an interactive side effect") + +IPython.display.display = fail +webbrowser.open = fail +import CH_02_interactive_python.exercise_04.solution_00 +""" + result: subprocess.CompletedProcess[str] = subprocess.run( + [sys.executable, "-c", script], + check=False, + capture_output=True, + text=True, + ) + + assert result.returncode == 0, result.stderr + assert result.stdout == "" + assert result.stderr == "" diff --git a/CH_02_interactive_python/exercise_05/README.rst b/CH_02_interactive_python/exercise_05/README.rst new file mode 100644 index 0000000..68e5a27 --- /dev/null +++ b/CH_02_interactive_python/exercise_05/README.rst @@ -0,0 +1,63 @@ +Exercise 5 - search IPython history +=================================== + +The exercise asks: + +:: + + 5. Try and create a script that will look for a given pattern through all of your previous ipython sessions. + +Solution +-------- + +``search_history`` compiles one Python regular expression, applies ``re.search`` +to each complete raw input, and returns immutable ``HistoryMatch`` values sorted +by session and line. Match equality includes the source text; ordering compares +only the session and line. The input may span multiple lines. Use inline flags +such as ``(?m)`` when ``^`` and ``$`` should match individual lines, or ``(?s)`` +when ``.`` should match newlines. An invalid expression raises ``re.error``. + +``iter_ipython_history`` uses IPython's ``HistoryAccessor`` to request raw, +non-unique input from every stored session. An injected accessor remains owned +by its caller. An accessor created by the iterator is closed after exhaustion, +an error, or early generator closure. + +The command prints the first source line as ``session:line: source``. It frames +every continuation line as ``session:line: | continuation``, including empty +and trailing lines, so adjacent records remain distinct. It exits with status +zero when at least one match exists or status one when none exists. An invalid +regular expression or missing default IPython profile produces a concise +command-line error and exits with status two. + +Privacy warning +--------------- + +By default, the command reads the local IPython history database for the current +profile. That history can contain code, paths, URLs, tokens, and other sensitive +input. Review the regular expression and output destination before running it. +Importing the module does not construct a history accessor or read a profile. +The tests use a temporary SQLite history database and never read user history. +The missing-profile test uses an isolated IPython directory and verifies that +no profile or history data is created. + +Requirements +------------ + +Python 3.10+ and IPython are required. Run the command with the interactive +dependency group: + +.. code-block:: console + + $ uv run --group interactive python -m CH_02_interactive_python.exercise_05.solution_00 'pattern' + +Run the focused tests with: + +.. code-block:: console + + $ uv run --group interactive pytest CH_02_interactive_python/exercise_05/test_solution_00.py -W error + +Reference +--------- + +The original approach is preserved in the immutable +`upstream session_filename.ipy `_. diff --git a/CH_02_interactive_python/exercise_05/__init__.py b/CH_02_interactive_python/exercise_05/__init__.py new file mode 100644 index 0000000..284deda --- /dev/null +++ b/CH_02_interactive_python/exercise_05/__init__.py @@ -0,0 +1 @@ +"""Exercise 5: search saved IPython history.""" diff --git a/CH_02_interactive_python/exercise_05/solution_00.py b/CH_02_interactive_python/exercise_05/solution_00.py new file mode 100644 index 0000000..331e53b --- /dev/null +++ b/CH_02_interactive_python/exercise_05/solution_00.py @@ -0,0 +1,124 @@ +"""Search raw input stored in IPython history.""" + +from __future__ import annotations + +import argparse +import re +import sqlite3 +from collections.abc import Callable, Iterable, Iterator, Sequence +from dataclasses import dataclass +from typing import cast + +from IPython.core.history import HistoryAccessor + + +class HistoryAccessError(RuntimeError): + """IPython's default history accessor could not be created.""" + + +@dataclass(frozen=True) +class HistoryMatch: + """One matching input from an IPython session.""" + + session: int + line: int + source: str + + def _location(self) -> tuple[int, int]: + return (self.session, self.line) + + def __lt__(self, other: object) -> bool: + if not isinstance(other, HistoryMatch): + return NotImplemented + return self._location() < other._location() + + def __le__(self, other: object) -> bool: + if not isinstance(other, HistoryMatch): + return NotImplemented + return self._location() <= other._location() + + def __gt__(self, other: object) -> bool: + if not isinstance(other, HistoryMatch): + return NotImplemented + return self._location() > other._location() + + def __ge__(self, other: object) -> bool: + if not isinstance(other, HistoryMatch): + return NotImplemented + return self._location() >= other._location() + + +def search_history( + pattern: str, + records: Iterable[tuple[int, int, str]], +) -> list[HistoryMatch]: + """Return saved inputs matching *pattern*, ordered by session and line.""" + expression: re.Pattern[str] = re.compile(pattern) + return sorted( + HistoryMatch(session, line, source) + for session, line, source in records + if expression.search(source) + ) + + +def format_history_match(match: HistoryMatch) -> str: + """Frame every source line with its saved location.""" + prefix: str = f"{match.session}:{match.line}: " + source_lines: list[str] = match.source.split("\n") + framed_lines: list[str] = [f"{prefix}{source_lines[0]}"] + framed_lines.extend(f"{prefix}| {line}" for line in source_lines[1:]) + return "\n".join(framed_lines) + + +def iter_ipython_history( + accessor: HistoryAccessor | None = None, +) -> Iterator[tuple[int, int, str]]: + """Yield raw stored input from every IPython session.""" + owns_accessor: bool = accessor is None + accessor_factory: Callable[[], HistoryAccessor] = cast( + Callable[[], HistoryAccessor], HistoryAccessor + ) + if accessor is not None: + history: HistoryAccessor = accessor + else: + try: + history = accessor_factory() + except OSError as error: + raise HistoryAccessError(str(error)) from error + try: + records: Iterator[tuple[int, int, str]] = cast( + Iterator[tuple[int, int, str]], + history.search("*", raw=True, search_raw=True, unique=False), + ) + yield from records + finally: + if owns_accessor: + connection: sqlite3.Connection = cast( + sqlite3.Connection, + history.db, # pyright: ignore[reportUnknownMemberType] + ) + connection.close() + + +def main(argv: Sequence[str] | None = None) -> int: + """Search IPython history for one regular expression.""" + parser: argparse.ArgumentParser = argparse.ArgumentParser( + description="Search raw input from all saved IPython sessions." + ) + parser.add_argument("pattern", help="Python regular expression to search for") + arguments: argparse.Namespace = parser.parse_args(argv) + pattern: str = cast(str, arguments.pattern) + + try: + matches: list[HistoryMatch] = search_history(pattern, iter_ipython_history()) + except re.error as error: + parser.error(f"invalid regular expression: {error}") + except HistoryAccessError as error: + parser.error(f"unable to open IPython history: {error}") + for match in matches: + print(format_history_match(match)) + return 0 if matches else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/CH_02_interactive_python/exercise_05/test_solution_00.py b/CH_02_interactive_python/exercise_05/test_solution_00.py new file mode 100644 index 0000000..ed74d2e --- /dev/null +++ b/CH_02_interactive_python/exercise_05/test_solution_00.py @@ -0,0 +1,356 @@ +"""Tests for searching saved IPython history.""" + +from __future__ import annotations + +import os +import re +import sqlite3 +import subprocess +import sys +from collections.abc import Callable, Generator, Iterator +from dataclasses import FrozenInstanceError +from pathlib import Path +from typing import cast + +import pytest +from IPython.core.history import HistoryAccessor + +import CH_02_interactive_python.exercise_05.solution_00 as solution_00 + +from .solution_00 import HistoryMatch, iter_ipython_history, main, search_history + + +class _OneShotHistory: + def __init__(self, records: list[tuple[int, int, str]]) -> None: + self._records: list[tuple[int, int, str]] = records + self._iterated: bool = False + + def __iter__(self) -> Iterator[tuple[int, int, str]]: + if self._iterated: + raise AssertionError("history was consumed more than once") + self._iterated = True + return iter(self._records) + + +class _FakeConnection: + def __init__(self) -> None: + self.close_calls: int = 0 + + def close(self) -> None: + self.close_calls += 1 + + +class _FakeAccessor: + def __init__( + self, + records: list[tuple[int, int, str]], + *, + error: Exception | None = None, + ) -> None: + self.records: list[tuple[int, int, str]] = records + self.error: Exception | None = error + self.db: _FakeConnection = _FakeConnection() + self.search_calls: list[tuple[str, bool, bool, bool]] = [] + + def search( + self, + pattern: str, + *, + raw: bool, + search_raw: bool, + unique: bool, + ) -> Iterator[tuple[int, int, str]]: + self.search_calls.append((pattern, raw, search_raw, unique)) + yield from self.records + if self.error is not None: + raise self.error + + +def test_history_match_equality_includes_source_and_order_uses_location() -> None: + first: HistoryMatch = HistoryMatch(1, 2, "z") + same_location: HistoryMatch = HistoryMatch(1, 2, "a") + later: HistoryMatch = HistoryMatch(2, 1, "a") + + assert first != same_location + assert not first < same_location + assert not same_location < first + assert first <= same_location + assert same_location <= first + assert not first > same_location + assert not same_location > first + assert first >= same_location + assert same_location >= first + assert sorted([later, same_location, first]) == [same_location, first, later] + with pytest.raises(FrozenInstanceError): + first.source = "changed" # type: ignore[misc] + + +def test_search_history_matches_across_sessions_in_location_order() -> None: + records: list[tuple[int, int, str]] = [ + (3, 4, "print('needle')"), + (1, 8, "needle = True"), + (2, 1, "unrelated"), + (1, 2, "# needle"), + ] + + assert search_history("needle", records) == [ + HistoryMatch(1, 2, "# needle"), + HistoryMatch(1, 8, "needle = True"), + HistoryMatch(3, 4, "print('needle')"), + ] + + +def test_search_history_returns_empty_list_when_nothing_matches() -> None: + assert search_history("missing", [(1, 1, "value = 1")]) == [] + + +def test_search_history_supports_multiline_source_and_regex_flags() -> None: + source: str = "value = 1\nprint(value)\nvalue += 1" + + assert search_history(r"(?m)^print\(value\)$", [(2, 7, source)]) == [ + HistoryMatch(2, 7, source) + ] + + +def test_search_history_propagates_invalid_regex() -> None: + with pytest.raises(re.error): + search_history("[", [(1, 1, "source")]) + + +def test_search_history_consumes_one_shot_iterable_once() -> None: + records: _OneShotHistory = _OneShotHistory([(2, 1, "target"), (1, 1, "target")]) + + assert search_history("target", records) == [ + HistoryMatch(1, 1, "target"), + HistoryMatch(2, 1, "target"), + ] + + +def test_format_history_match_frames_multiline_and_trailing_empty_lines() -> None: + match: HistoryMatch = HistoryMatch(3, 7, "first\nsecond\n") + + assert solution_00.format_history_match(match) == ( + "3:7: first\n3:7: | second\n3:7: | " + ) + + +def test_iter_ipython_history_uses_raw_unfiltered_nonunique_search() -> None: + fake: _FakeAccessor = _FakeAccessor([(2, 3, "%time value")]) + + assert list(iter_ipython_history(cast(HistoryAccessor, fake))) == [ + (2, 3, "%time value") + ] + assert fake.search_calls == [("*", True, True, False)] + assert fake.db.close_calls == 0 + + +def test_iter_ipython_history_does_not_close_injected_accessor_on_error() -> None: + error: RuntimeError = RuntimeError("database read failed") + fake: _FakeAccessor = _FakeAccessor([], error=error) + + with pytest.raises(RuntimeError, match="database read failed"): + list(iter_ipython_history(cast(HistoryAccessor, fake))) + + assert fake.db.close_calls == 0 + + +def test_iter_ipython_history_closes_internal_accessor_after_exhaustion( + monkeypatch: pytest.MonkeyPatch, +) -> None: + fake: _FakeAccessor = _FakeAccessor([(1, 1, "first")]) + + monkeypatch.setattr(solution_00, "HistoryAccessor", lambda: fake) + + assert list(iter_ipython_history()) == [(1, 1, "first")] + assert fake.db.close_calls == 1 + + +def test_iter_ipython_history_closes_internal_accessor_when_generator_closed( + monkeypatch: pytest.MonkeyPatch, +) -> None: + fake: _FakeAccessor = _FakeAccessor([(1, 1, "first"), (1, 2, "second")]) + generator: Generator[tuple[int, int, str], None, None] + + monkeypatch.setattr(solution_00, "HistoryAccessor", lambda: fake) + generator = cast( + Generator[tuple[int, int, str], None, None], iter_ipython_history() + ) + + assert next(generator) == (1, 1, "first") + generator.close() + assert fake.db.close_calls == 1 + + +def test_iter_ipython_history_closes_internal_accessor_on_search_error( + monkeypatch: pytest.MonkeyPatch, +) -> None: + error: RuntimeError = RuntimeError("database read failed") + fake: _FakeAccessor = _FakeAccessor([], error=error) + + monkeypatch.setattr(solution_00, "HistoryAccessor", lambda: fake) + + with pytest.raises(RuntimeError, match="database read failed"): + list(iter_ipython_history()) + + assert fake.db.close_calls == 1 + + +def test_actual_history_accessor_reads_raw_inputs_from_explicit_temp_database( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + history_path: Path = tmp_path / "history.sqlite" + + def reject_profile_lookup(self: HistoryAccessor, profile: str) -> str: + del self, profile + raise AssertionError("user profile must not be consulted") + + monkeypatch.setattr(HistoryAccessor, "_get_hist_file_name", reject_profile_lookup) + accessor_factory: Callable[..., HistoryAccessor] = cast( + Callable[..., HistoryAccessor], HistoryAccessor + ) + accessor: HistoryAccessor = accessor_factory(hist_file=str(history_path)) + connection: sqlite3.Connection = cast( + sqlite3.Connection, + accessor.db, # pyright: ignore[reportUnknownMemberType] + ) + try: + connection.executemany( + "INSERT INTO history(session, line, source, source_raw) VALUES (?, ?, ?, ?)", + [ + (2, 4, "get_ipython().run_line_magic('time', 'value')", "%time value"), + (1, 3, "print('translated')", "print('raw input')"), + ], + ) + connection.commit() + + assert set(iter_ipython_history(accessor)) == { + (2, 4, "%time value"), + (1, 3, "print('raw input')"), + } + assert history_path.is_file() + finally: + connection.close() + + +def test_main_prints_matches_and_returns_zero( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + def fake_history( + accessor: HistoryAccessor | None = None, + ) -> Iterator[tuple[int, int, str]]: + assert accessor is None + yield (2, 1, "nothing") + yield (1, 4, "print('target')") + + monkeypatch.setattr(solution_00, "iter_ipython_history", fake_history) + + assert main(["target"]) == 0 + assert capsys.readouterr().out == "1:4: print('target')\n" + + +def test_main_frames_multiline_matches_so_adjacent_records_are_distinct( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + def fake_history( + accessor: HistoryAccessor | None = None, + ) -> Iterator[tuple[int, int, str]]: + assert accessor is None + yield (1, 1, "first\nsecond\n") + yield (1, 2, "next") + + monkeypatch.setattr(solution_00, "iter_ipython_history", fake_history) + + assert main(["first|next"]) == 0 + assert capsys.readouterr().out == ( + "1:1: first\n1:1: | second\n1:1: | \n1:2: next\n" + ) + + +def test_main_prints_nothing_and_returns_one_when_no_match( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + def fake_history( + accessor: HistoryAccessor | None = None, + ) -> Iterator[tuple[int, int, str]]: + assert accessor is None + yield (1, 1, "nothing") + + monkeypatch.setattr(solution_00, "iter_ipython_history", fake_history) + + assert main(["target"]) == 1 + assert capsys.readouterr().out == "" + + +def test_main_reports_invalid_regex_before_reading_history( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + def reject_accessor_construction() -> HistoryAccessor: + raise AssertionError("history accessor must not be created") + + monkeypatch.setattr(solution_00, "HistoryAccessor", reject_accessor_construction) + + with pytest.raises(SystemExit) as error_info: + main(["["]) + + assert error_info.value.code == 2 + captured_stdout: str + captured_stderr: str + captured_stdout, captured_stderr = capsys.readouterr() + assert captured_stdout == "" + assert "error: invalid regular expression:" in captured_stderr + assert "Traceback" not in captured_stderr + + +def test_import_does_not_construct_accessor_or_emit_output() -> None: + script: str = """ +import IPython.core.history + +def reject_construction(self, *args, **kwargs): + raise AssertionError("HistoryAccessor constructed during import") + +IPython.core.history.HistoryAccessor.__init__ = reject_construction +import CH_02_interactive_python.exercise_05.solution_00 +""" + result: subprocess.CompletedProcess[str] = subprocess.run( + [sys.executable, "-c", script], + check=False, + capture_output=True, + text=True, + ) + + assert result.returncode == 0, result.stderr + assert result.stdout == "" + assert result.stderr == "" + + +def test_cli_reports_missing_default_profile_without_creating_it( + tmp_path: Path, +) -> None: + ipython_dir: Path = tmp_path / "isolated-ipython" + environment: dict[str, str] = os.environ.copy() + environment["IPYTHONDIR"] = str(ipython_dir) + + result: subprocess.CompletedProcess[str] = subprocess.run( + [ + sys.executable, + "-m", + "CH_02_interactive_python.exercise_05.solution_00", + "target", + ], + check=False, + capture_output=True, + text=True, + env=environment, + ) + + assert result.returncode == 2 + assert result.stdout == "" + assert "error: unable to open IPython history:" in result.stderr + assert "Traceback" not in result.stderr + assert ipython_dir.is_dir() + assert list(ipython_dir.iterdir()) == [] diff --git a/CH_04_design_patterns/README.rst b/CH_04_design_patterns/README.rst index 37b87e9..b102f83 100644 --- a/CH_04_design_patterns/README.rst +++ b/CH_04_design_patterns/README.rst @@ -1,6 +1,6 @@ Chapter 4 - design patterns ======================================================================================================================= - - Create a SortedDict collection that takes a keyfunc to decide the sort order. - - Create a SortedList collection that has O(log(n)) inserts and always returns a sorted list during each iteration. - - Create a Borg pattern that has a state per subclass. +1. `Create a SortedDict collection that takes a keyfunc to decide the sort order. `_ +2. `Create a SortedList collection that has O(log(n)) inserts and always returns a sorted list during each iteration. `_ +3. `Create a Borg pattern that has a state per subclass. `_ diff --git a/CH_04_design_patterns/exercise_01/README.rst b/CH_04_design_patterns/exercise_01/README.rst new file mode 100644 index 0000000..feeca5b --- /dev/null +++ b/CH_04_design_patterns/exercise_01/README.rst @@ -0,0 +1,39 @@ +Sorted dictionary +================= + +Question +-------- + +Create SortedDict collection takes keyfunc decide sort order. + +Answer +------ + +``SortedDict`` implements ``collections.abc.MutableMapping`` using an ordinary +dictionary for storage. Its optional ``keyfunc`` receives each key, not its +value. Iteration sorts the current keys, so insertions, replacements, and +deletions are reflected immediately without maintaining a second index. +Consequently, each iteration costs O(n log n). + +Without a ``keyfunc``, all keys must be mutually orderable. Incompatible key +types raise ``TypeError`` during iteration; Python's static type system cannot +fully guarantee that arbitrary keys support a common ordering. + +No upstream substrate was needed for this answer. + +Dependencies +------------ + +The answer requires Python 3.10 or newer and uses only the standard library. +The focused test requires pytest from the repository's ``dev`` dependency +group. + +Run +--- + +From the repository root: + +.. code-block:: console + + $ uv run python CH_04_design_patterns/exercise_01/solution_00.py + $ uv run pytest CH_04_design_patterns/exercise_01/test_solution_00.py diff --git a/CH_04_design_patterns/exercise_01/solution_00.py b/CH_04_design_patterns/exercise_01/solution_00.py new file mode 100644 index 0000000..8ee93c8 --- /dev/null +++ b/CH_04_design_patterns/exercise_01/solution_00.py @@ -0,0 +1,48 @@ +from collections.abc import Callable, Iterable, Iterator, Mapping, MutableMapping +from reprlib import recursive_repr +from typing import Any, Protocol, TypeVar, cast + +K = TypeVar("K") +V = TypeVar("V") +S = TypeVar("S", bound="_Comparable") + + +class _Comparable(Protocol): + def __lt__(self, other: Any, /) -> bool: ... + + +def _as_comparable(value: object) -> _Comparable: + return cast(_Comparable, value) + + +class SortedDict(MutableMapping[K, V]): + def __init__( + self, + source: Mapping[K, V] | Iterable[tuple[K, V]] = (), + keyfunc: Callable[[K], S] | None = None, + ) -> None: + self._data: dict[K, V] = dict(source) + self._keyfunc: Callable[[K], _Comparable] = cast( + Callable[[K], _Comparable], + _as_comparable if keyfunc is None else keyfunc, + ) + + def __getitem__(self, key: K) -> V: + return self._data[key] + + def __setitem__(self, key: K, value: V) -> None: + self._data[key] = value + + def __delitem__(self, key: K) -> None: + del self._data[key] + + def __iter__(self) -> Iterator[K]: + return iter(sorted(self._data, key=self._keyfunc)) + + def __len__(self) -> int: + return len(self._data) + + @recursive_repr(fillvalue="...") + def __repr__(self) -> str: + ordered: dict[K, V] = {key: self._data[key] for key in self} + return f"{type(self).__name__}({ordered!r})" diff --git a/CH_04_design_patterns/exercise_01/test_solution_00.py b/CH_04_design_patterns/exercise_01/test_solution_00.py new file mode 100644 index 0000000..c1f7a9a --- /dev/null +++ b/CH_04_design_patterns/exercise_01/test_solution_00.py @@ -0,0 +1,116 @@ +from collections.abc import ItemsView, KeysView, MutableMapping, ValuesView + +import pytest + +from .solution_00 import SortedDict + + +class _FalseyReverseKey: + def __call__(self, key: int) -> int: + return -key + + def __bool__(self) -> bool: + return False + + +def test_constructs_from_mapping_in_default_key_order() -> None: + mapping: SortedDict[int, str] = SortedDict[int, str]( + {3: "three", 1: "one", 2: "two"} + ) + + assert list(mapping) == [1, 2, 3] + assert list(mapping.items()) == [(1, "one"), (2, "two"), (3, "three")] + + +def test_constructs_from_iterable_pairs() -> None: + mapping: SortedDict[str, int] = SortedDict[str, int]( + [("charlie", 3), ("alpha", 1), ("bravo", 2)] + ) + + assert list(mapping.items()) == [("alpha", 1), ("bravo", 2), ("charlie", 3)] + + +def test_constructs_empty_mapping() -> None: + mapping: SortedDict[str, int] = SortedDict[str, int]() + + assert len(mapping) == 0 + assert list(mapping) == [] + + +def test_mutations_are_sorted_live_by_key_length() -> None: + mapping: SortedDict[str, int] = SortedDict[str, int]( + {"three": 3, "one": 1}, keyfunc=len + ) + keys_view: KeysView[str] = mapping.keys() + items_view: ItemsView[str, int] = mapping.items() + values_view: ValuesView[int] = mapping.values() + + mapping["to"] = 2 + mapping["f"] = 1 + + assert list(keys_view) == ["f", "to", "one", "three"] + assert list(items_view) == [ + ("f", 1), + ("to", 2), + ("one", 1), + ("three", 3), + ] + assert list(values_view) == [1, 2, 1, 3] + + +def test_deletion_and_repr_follow_sorted_order() -> None: + mapping: SortedDict[str, int] = SortedDict[str, int]( + [("three", 3), ("to", 2), ("f", 1)], len + ) + + del mapping["to"] + + assert repr(mapping) == "SortedDict({'f': 1, 'three': 3})" + + +def test_repr_handles_self_reference() -> None: + mapping: SortedDict[str, object] = SortedDict[str, object]() + mapping["self"] = mapping + + assert repr(mapping) == "SortedDict({'self': ...})" + + +def test_replacing_value_does_not_duplicate_key() -> None: + mapping: SortedDict[str, int] = SortedDict[str, int]([("b", 1), ("a", 2)]) + + mapping["a"] = 10 + + assert len(mapping) == 2 + assert list(mapping.items()) == [("a", 10), ("b", 1)] + + +def test_falsey_custom_key_can_reverse_numeric_order() -> None: + mapping: SortedDict[int, str] = SortedDict[int, str]( + [(1, "one"), (3, "three"), (2, "two")], + keyfunc=_FalseyReverseKey(), + ) + + assert list(mapping) == [3, 2, 1] + + +def test_missing_key_access_raises_key_error() -> None: + mapping: SortedDict[str, int] = SortedDict[str, int]() + + with pytest.raises(KeyError, match="missing"): + _ = mapping["missing"] + + +def test_missing_key_deletion_raises_key_error() -> None: + mapping: SortedDict[str, int] = SortedDict[str, int]() + + with pytest.raises(KeyError, match="missing"): + del mapping["missing"] + + +def test_mutable_mapping_equality_ignores_iteration_order() -> None: + mapping: MutableMapping[str, int] = SortedDict[str, int]( + [("long", 2), ("x", 1)], keyfunc=len + ) + + assert mapping == {"x": 1, "long": 2} + assert {"long": 2, "x": 1} == mapping diff --git a/CH_04_design_patterns/exercise_02/README.rst b/CH_04_design_patterns/exercise_02/README.rst new file mode 100644 index 0000000..795f981 --- /dev/null +++ b/CH_04_design_patterns/exercise_02/README.rst @@ -0,0 +1,59 @@ +Sorted list +=========== + +Question +-------- + +Create a SortedList collection that has O(log(n)) inserts and always returns a sorted list during each iteration. + +Answer +------ + +``SortedList`` implements ``collections.abc.Collection`` with a deterministic +AVL tree. Each node stores one representative value and its sort key. Values +with the same sort key remain in an insertion-ordered duplicate bucket, so +equal keys do not disappear or reorder. + +AVL rotations keep the tree height logarithmic, including already sorted input. +``add`` and its ``insert`` alias therefore take O(log n) sort-key comparisons. +``len`` and the read-only ``tree_height`` diagnostic take O(1) time. Iteration +visits each stored value once in sort-key order and takes O(n) time. Membership +uses O(log n) key comparisons to reach one matching-key bucket, then compares +only values in that bucket; its worst case includes the bucket size. + +The constructor accepts any iterable and an optional keyword-only ``key`` +function. Without ``key``, values themselves provide the ordering. Public +operations are ``add``, ``insert``, ``len``, iteration, membership, ``repr``, +and ``tree_height``. + +Values returned by ``key`` must form a strict weak ordering under ``<``. In +particular, the ordering must be irreflexive and transitive, and +incomparability must be transitive. Incomparable values such as floating-point +NaN do not satisfy this contract, so the collection does not promise correct +ordering for them. + +Membership applies the configured ``key`` function to its candidate. The +candidate must therefore be accepted by that function, and its result must be +comparable with the stored sort keys. A foreign candidate may raise the +original key-function or comparison error; ``SortedList`` intentionally does +not hide it. + +The book's bisect discussion is available in the immutable upstream reference: +https://github.com/mastering-python/code_2/blob/a012f6ce3f5bf11f5bf380d1c4e7f743355adb89/CH_04_design_patterns/T_10_bisect.rst + +Dependencies +------------ + +The answer requires Python 3.10 or newer and uses only the standard library. +The focused test requires pytest from the repository's ``dev`` dependency +group. + +Run +--- + +From the repository root: + +.. code-block:: console + + $ uv run python CH_04_design_patterns/exercise_02/solution_00.py + $ uv run pytest -W error CH_04_design_patterns/exercise_02/test_solution_00.py diff --git a/CH_04_design_patterns/exercise_02/__init__.py b/CH_04_design_patterns/exercise_02/__init__.py new file mode 100644 index 0000000..be84bb3 --- /dev/null +++ b/CH_04_design_patterns/exercise_02/__init__.py @@ -0,0 +1 @@ +"""Solution for Chapter 4, exercise 2.""" diff --git a/CH_04_design_patterns/exercise_02/solution_00.py b/CH_04_design_patterns/exercise_02/solution_00.py new file mode 100644 index 0000000..b3ec168 --- /dev/null +++ b/CH_04_design_patterns/exercise_02/solution_00.py @@ -0,0 +1,223 @@ +"""An AVL-tree-backed sorted collection.""" + +from collections.abc import Callable, Collection, Iterable, Iterator +from dataclasses import dataclass, field +from reprlib import recursive_repr +from typing import Any, Generic, Protocol, TypeVar, cast, overload + +T = TypeVar("T") + + +class _Comparable(Protocol): + def __lt__(self, other: Any, /) -> bool: + """Return whether this value sorts before ``other``.""" + raise NotImplementedError + + +S = TypeVar("S", bound=_Comparable) +_C = TypeVar("_C", bound=_Comparable) +_V = TypeVar("_V") +_K = TypeVar("_K", bound=_Comparable) + + +def _identity(value: T) -> T: + return value + + +@dataclass(slots=True) +class _Node(Generic[T, S]): + value: T + sort_key: S + duplicates: list[T] = field(default_factory=list[T]) + height: int = 1 + left: "_Node[T, S] | None" = None + right: "_Node[T, S] | None" = None + + +class SortedList(Collection[T], Generic[T, S]): + """Store values in sort-key order in an AVL multiset.""" + + @overload + def __new__( + cls, + values: Iterable[_C] = (), + *, + key: None = None, + ) -> "SortedList[_C, _C]": ... + + @overload + def __new__( + cls, + values: Iterable[_V] = (), + *, + key: Callable[[_V], _K], + ) -> "SortedList[_V, _K]": ... + + def __new__( + cls, + values: Iterable[Any] = (), + *, + key: Callable[[Any], _Comparable] | None = None, + ) -> "SortedList[Any, Any]": + del values, key + return super().__new__(cls) + + @overload + def __init__( + self, + values: tuple[()] = (), + *, + key: None = None, + ) -> None: ... + + @overload + def __init__( + # Mypy uses generic self here; Pyright uses the __new__ result above. + self: "SortedList[_C, _C]", # pyright: ignore[reportInvalidTypeVarUse] + values: Iterable[_C], + *, + key: None = None, + ) -> None: ... + + @overload + def __init__( + self, + values: Iterable[T] = (), + *, + key: Callable[[T], S], + ) -> None: ... + + def __init__( + self, + values: Iterable[Any] = (), + *, + key: Callable[[Any], _Comparable] | None = None, + ) -> None: + self._root: _Node[T, S] | None = None + self._key: Callable[[T], S] = cast( + Callable[[T], S], _identity if key is None else key + ) + self._size: int = 0 + for value in values: + self.add(cast(T, value)) + + @staticmethod + def _height(node: _Node[T, S] | None) -> int: + return 0 if node is None else node.height + + @classmethod + def _update_height(cls, node: _Node[T, S]) -> None: + node.height = 1 + max(cls._height(node.left), cls._height(node.right)) + + @classmethod + def _rotate_left(cls, root: _Node[T, S]) -> _Node[T, S]: + pivot: _Node[T, S] | None = root.right + if pivot is None: + raise RuntimeError("left rotation requires a right child") + root.right = pivot.left + pivot.left = root + cls._update_height(root) + cls._update_height(pivot) + return pivot + + @classmethod + def _rotate_right(cls, root: _Node[T, S]) -> _Node[T, S]: + pivot: _Node[T, S] | None = root.left + if pivot is None: + raise RuntimeError("right rotation requires a left child") + root.left = pivot.right + pivot.right = root + cls._update_height(root) + cls._update_height(pivot) + return pivot + + @classmethod + def _rebalance(cls, node: _Node[T, S]) -> _Node[T, S]: + cls._update_height(node) + balance: int = cls._height(node.left) - cls._height(node.right) + + if balance > 1: + left: _Node[T, S] | None = node.left + if left is None: + raise RuntimeError("left-heavy node requires a left child") + if cls._height(left.left) < cls._height(left.right): + node.left = cls._rotate_left(left) + return cls._rotate_right(node) + + if balance < -1: + right: _Node[T, S] | None = node.right + if right is None: + raise RuntimeError("right-heavy node requires a right child") + if cls._height(right.right) < cls._height(right.left): + node.right = cls._rotate_right(right) + return cls._rotate_left(node) + + return node + + @classmethod + def _add_node(cls, node: _Node[T, S] | None, value: T, sort_key: S) -> _Node[T, S]: + if node is None: + return _Node(value=value, sort_key=sort_key) + + if sort_key < node.sort_key: + node.left = cls._add_node(node.left, value, sort_key) + elif node.sort_key < sort_key: + node.right = cls._add_node(node.right, value, sort_key) + else: + node.duplicates.append(value) + return node + return cls._rebalance(node) + + def add(self, value: T) -> None: + """Insert ``value`` while preserving sorted iteration.""" + sort_key: S = self._key(value) + self._root = self._add_node(self._root, value, sort_key) + self._size += 1 + + insert = add + + @property + def tree_height(self) -> int: + """Return the current AVL height without allowing mutation.""" + return self._height(self._root) + + def __len__(self) -> int: + return self._size + + def __iter__(self) -> Iterator[T]: + stack: list[_Node[T, S]] = [] + node: _Node[T, S] | None = self._root + while stack or node is not None: + while node is not None: + stack.append(node) + node = node.left + node = stack.pop() + yield node.value + yield from node.duplicates + node = node.right + + def __contains__(self, value: object) -> bool: + node: _Node[T, S] | None = self._root + if node is None: + return False + candidate: T = cast(T, value) + sort_key: S = self._key(candidate) + while node is not None: + if sort_key < node.sort_key: + node = node.left + elif node.sort_key < sort_key: + node = node.right + else: + return node.value == value or any( + duplicate == value for duplicate in node.duplicates + ) + return False + + @recursive_repr(fillvalue="...") + def __repr__(self) -> str: + return f"{type(self).__name__}({list(self)!r})" + + +if __name__ == "__main__": + example: SortedList[int, int] = SortedList([5, 3, 1, 4, 2, 2]) + print(example) diff --git a/CH_04_design_patterns/exercise_02/test_solution_00.py b/CH_04_design_patterns/exercise_02/test_solution_00.py new file mode 100644 index 0000000..38d7ae0 --- /dev/null +++ b/CH_04_design_patterns/exercise_02/test_solution_00.py @@ -0,0 +1,146 @@ +from collections.abc import Collection, Iterator +from dataclasses import dataclass +from typing import TYPE_CHECKING + +import pytest + +from .solution_00 import SortedList + +if TYPE_CHECKING: + _naturally_typed_values = SortedList([3, 1, 2]) + _keyed_values = SortedList(["bbb", "a"], key=len) + + def _require_natural_type(value: SortedList[int, int]) -> None: + pass + + def _require_keyed_type(value: SortedList[str, int]) -> None: + pass + + _require_natural_type(_naturally_typed_values) + _require_keyed_type(_keyed_values) + + +def test_empty_collection() -> None: + values: SortedList[int, int] = SortedList() + + assert isinstance(values, Collection) + assert len(values) == 0 + assert list(values) == [] + assert values.tree_height == 0 + + +def test_add_insert_and_duplicate_values() -> None: + values: SortedList[int, int] = SortedList() + + values.add(3) + values.insert(1) + values.add(2) + values.insert(2) + + assert list(values) == [1, 2, 2, 3] + assert len(values) == 4 + + +@pytest.mark.parametrize( + "insertions", + [ + [3, 2, 1], + [3, 1, 2], + [1, 2, 3], + [1, 3, 2], + ], + ids=["LL", "LR", "RR", "RL"], +) +def test_three_node_insertions_apply_each_avl_rotation( + insertions: list[int], +) -> None: + values: SortedList[int, int] = SortedList(insertions) + + assert list(values) == [1, 2, 3] + assert values.tree_height == 2 + + +@pytest.mark.parametrize( + "source", [range(1_024), range(1_023, -1, -1)], ids=["ascending", "descending"] +) +def test_monotonic_input_stays_balanced(source: range) -> None: + values: SortedList[int, int] = SortedList(source) + + assert list(values) == list(range(1_024)) + assert len(values) == 1_024 + assert values.tree_height <= 20 + + +def test_reverse_key_controls_order() -> None: + values: SortedList[int, int] = SortedList([1, 3, 2], key=lambda value: -value) + + assert list(values) == [3, 2, 1] + + +@dataclass(frozen=True) +class _Record: + group: int + label: str + + +def test_equal_sort_keys_preserve_insertion_order_and_membership() -> None: + alpha: _Record = _Record(1, "alpha") + beta: _Record = _Record(1, "beta") + gamma: _Record = _Record(2, "gamma") + values: SortedList[_Record, int] = SortedList( + [beta, gamma, alpha, beta], key=lambda record: record.group + ) + + assert list(values) == [beta, alpha, beta, gamma] + assert beta in values + assert _Record(1, "missing") not in values + assert _Record(3, "missing") not in values + + +def test_repr_uses_sorted_iteration_order() -> None: + values: SortedList[int, int] = SortedList([3, 1, 2, 2]) + + assert repr(values) == "SortedList([1, 2, 2, 3])" + + +def test_constructor_consumes_one_shot_iterable_once() -> None: + consumed: list[int] = [] + + def source() -> Iterator[int]: + for value in (3, 1, 2): + consumed.append(value) + yield value + + iterator: Iterator[int] = source() + values: SortedList[int, int] = SortedList(iterator) + + assert consumed == [3, 1, 2] + assert list(values) == [1, 2, 3] + assert list(iterator) == [] + + +def test_key_is_called_once_per_inserted_value() -> None: + calls: list[str] = [] + + def measured_key(value: str) -> int: + calls.append(value) + return len(value) + + values: SortedList[str, int] = SortedList(["bbb", "a"], key=measured_key) + values.add("cc") + values.insert("dddd") + + assert calls == ["bbb", "a", "cc", "dddd"] + assert list(values) == ["a", "cc", "bbb", "dddd"] + + +def test_duplicate_mutations_change_count_without_changing_height() -> None: + values: SortedList[int, int] = SortedList([2, 1, 3]) + original_height: int = values.tree_height + + values.add(2) + values.insert(2) + + assert len(values) == 5 + assert values.tree_height == original_height + assert list(values) == [1, 2, 2, 2, 3] diff --git a/CH_04_design_patterns/exercise_03/README.rst b/CH_04_design_patterns/exercise_03/README.rst new file mode 100644 index 0000000..311c5d4 --- /dev/null +++ b/CH_04_design_patterns/exercise_03/README.rst @@ -0,0 +1,50 @@ +Borg state per subclass +======================= + +Question +-------- + +Create a Borg pattern that has a state per subclass. + +Answer +------ + +``Borg`` keeps ordinary object identity while making instances of the same +exact class share one instance ``__dict__``. It therefore differs from a +singleton: constructing a ``Borg`` repeatedly returns distinct objects, but +assignments and deletions stored in one instance's ``__dict__`` are visible +through every other instance of that exact class. Attributes backed by +nonempty ``__slots__`` or other data descriptors can use per-instance storage +outside that dictionary and are not part of the shared-state contract. + +The base class owns its own shared state. Each subclass receives a fresh +state dictionary when the subclass is created, including subclasses of +subclasses. State is consequently shared by instances of one exact class but +not inherited at runtime by sibling, parent, or grandchild instances. +Subclass ``__init__`` methods still run for every construction, so their +assignments update that exact class's shared state; merely constructing another +instance does not otherwise reset it. Allocation is cooperative in multiple +inheritance: later ``__new__`` hooks in the method resolution order are called +with the constructor arguments and can consume or forward them. A terminal +``object.__new__`` receives only the class, leaving ordinary constructor +arguments for the subclass initializer. + +The Borg pattern is described in the immutable upstream reference: +https://github.com/mastering-python/code_2/blob/a012f6ce3f5bf11f5bf380d1c4e7f743355adb89/CH_04_design_patterns/T_11_borg_and_singleton.rst + +Dependencies +------------ + +The answer requires Python 3.10 or newer and uses only the standard library. +The focused test requires pytest from the repository's ``dev`` dependency +group. + +Run +--- + +From the repository root: + +.. code-block:: console + + $ uv run python CH_04_design_patterns/exercise_03/solution_00.py + $ uv run pytest CH_04_design_patterns/exercise_03/test_solution_00.py diff --git a/CH_04_design_patterns/exercise_03/solution_00.py b/CH_04_design_patterns/exercise_03/solution_00.py new file mode 100644 index 0000000..d34b81c --- /dev/null +++ b/CH_04_design_patterns/exercise_03/solution_00.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +from collections.abc import Callable +from typing import Any, ClassVar, TypeVar, cast + +_BorgT = TypeVar("_BorgT", bound="Borg") + + +class Borg: + """Share attribute state between instances of each exact class.""" + + _shared_state: ClassVar[dict[str, object]] = {} + + def __init_subclass__(cls, **kwargs: object) -> None: + super().__init_subclass__(**kwargs) + cls._shared_state = {} + + def __new__(cls: type[_BorgT], *args: Any, **kwargs: Any) -> _BorgT: + allocator: Callable[..., _BorgT] = cast(Callable[..., _BorgT], super().__new__) + if allocator is object.__new__: + instance: _BorgT = allocator(cls) + else: + instance = allocator(cls, *args, **kwargs) + object.__setattr__(instance, "__dict__", cls._shared_state) + return instance + + +def main() -> None: + class Settings(Borg): + language: str + + first: Settings = Settings() + second: Settings = Settings() + first.language = "Python" + + print(first is second) + print(second.language) + + +if __name__ == "__main__": + main() diff --git a/CH_04_design_patterns/exercise_03/test_solution_00.py b/CH_04_design_patterns/exercise_03/test_solution_00.py new file mode 100644 index 0000000..3bf5967 --- /dev/null +++ b/CH_04_design_patterns/exercise_03/test_solution_00.py @@ -0,0 +1,249 @@ +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path +from typing import ClassVar + +from .solution_00 import Borg + +REPOSITORY_ROOT: Path = Path(__file__).resolve().parents[2] +SOLUTION_PATH: Path = Path(__file__).with_name("solution_00.py") +MODULE_NAME: str = "CH_04_design_patterns.exercise_03.solution_00" + + +def test_direct_borg_instances_share_mutations_and_deletions() -> None: + first: Borg = Borg() + second: Borg = Borg() + attribute_name: str = "answer" + + setattr(first, attribute_name, 42) + + assert getattr(second, attribute_name) == 42 + + delattr(second, attribute_name) + + assert not hasattr(first, attribute_name) + + +def test_instances_of_one_direct_subclass_share_state() -> None: + class Feature(Borg): + enabled: bool + + first: Feature = Feature() + second: Feature = Feature() + + first.enabled = True + + assert second.enabled is True + + del second.enabled + + assert not hasattr(first, "enabled") + + +def test_sibling_subclasses_have_isolated_state() -> None: + class Left(Borg): + label: str + + class Right(Borg): + label: str + + left: Left = Left() + right: Right = Right() + left.label = "left" + + assert not hasattr(right, "label") + + right.label = "right" + + assert left.label == "left" + assert right.label == "right" + + +def test_parent_subclass_and_grandchild_have_isolated_state() -> None: + class Parent(Borg): + value: int + + class Grandchild(Parent): + pass + + parent: Parent = Parent() + grandchild: Grandchild = Grandchild() + parent.value = 10 + + assert not hasattr(grandchild, "value") + + grandchild.value = 20 + + assert parent.value == 10 + assert grandchild.value == 20 + + +def test_base_class_and_subclass_have_isolated_state() -> None: + class Child(Borg): + pass + + base: Borg = Borg() + child: Child = Child() + attribute_name: str = "base_only" + setattr(base, attribute_name, "base") + + assert not hasattr(child, attribute_name) + + delattr(base, attribute_name) + + +def test_new_instances_see_existing_exact_class_state() -> None: + class Settings(Borg): + theme: str + + first: Settings = Settings() + first.theme = "dark" + + later: Settings = Settings() + + assert later.theme == "dark" + + +def test_instances_keep_ordinary_identity_and_hash_semantics() -> None: + class Service(Borg): + pass + + first: Service = Service() + second: Service = Service() + + assert first is not second + assert first != second + assert len({first, second}) == 2 + assert hash(first) == object.__hash__(first) + assert hash(second) == object.__hash__(second) + + +def test_subclass_init_assignments_update_shared_state() -> None: + class Configuration(Borg): + value: int + + def __init__(self, value: int) -> None: + self.value = value + + first: Configuration = Configuration(1) + second: Configuration = Configuration(2) + + assert first.value == 2 + assert second.value == 2 + + +def test_keyword_only_subclass_init_arguments_share_assigned_state() -> None: + class RegionalConfiguration(Borg): + region: str + + def __init__(self, *, region: str) -> None: + self.region = region + + first: RegionalConfiguration = RegionalConfiguration(region="west") + second: RegionalConfiguration = RegionalConfiguration(region="east") + + assert first.region == "east" + assert second.region == "east" + + +def test_instantiation_does_not_reset_existing_state() -> None: + class Persistent(Borg): + marker: str + + first: Persistent = Persistent() + first.marker = "kept" + + second: Persistent = Persistent() + + assert first.marker == "kept" + assert second.marker == "kept" + + +def test_subclass_keywords_are_forwarded_to_cooperative_bases() -> None: + class KeywordBase: + tag: ClassVar[str] + + def __init_subclass__(cls, *, tag: str, **kwargs: object) -> None: + super().__init_subclass__(**kwargs) + cls.tag = tag + + class TaggedBorg(Borg, KeywordBase, tag="configured"): + value: int + + first: TaggedBorg = TaggedBorg() + second: TaggedBorg = TaggedBorg() + first.value = 7 + + assert TaggedBorg.tag == "configured" + assert second.value == 7 + + +def test_later_mro_cooperative_new_receives_allocation_arguments() -> None: + class NewHook: + calls: ClassVar[list[tuple[type[object], str]]] = [] + + def __new__( + cls, + *args: object, + allocation_marker: str, + **kwargs: object, + ) -> NewHook: + NewHook.calls.append((cls, allocation_marker)) + return super().__new__(cls, *args, **kwargs) + + class Combined(Borg, NewHook): + label: str + + def __init__(self, *, allocation_marker: str) -> None: + self.label = allocation_marker + + first: Combined = Combined(allocation_marker="first") + second: Combined = Combined(allocation_marker="second") + + assert NewHook.calls == [(Combined, "first"), (Combined, "second")] + assert first.label == "second" + assert second.label == "second" + + +def test_shared_dict_binding_bypasses_restrictive_setattr() -> None: + class AssignmentBlocking(Borg): + value: int + + def __setattr__(self, name: str, value: object) -> None: + raise AttributeError(f"cannot set {name} to {value!r}") + + first: AssignmentBlocking = AssignmentBlocking() + second: AssignmentBlocking = AssignmentBlocking() + object.__setattr__(first, "value", 23) + + assert second.value == 23 + + +def test_import_is_silent() -> None: + result: subprocess.CompletedProcess[str] = subprocess.run( + [sys.executable, "-c", f"import {MODULE_NAME}"], + cwd=REPOSITORY_ROOT, + check=False, + capture_output=True, + text=True, + ) + + assert result.returncode == 0 + assert result.stdout == "" + assert result.stderr == "" + + +def test_guarded_demo_runs_only_as_a_script() -> None: + result: subprocess.CompletedProcess[str] = subprocess.run( + [sys.executable, str(SOLUTION_PATH)], + cwd=REPOSITORY_ROOT, + check=False, + capture_output=True, + text=True, + ) + + assert result.returncode == 0 + assert result.stdout == "False\nPython\n" + assert result.stderr == "" diff --git a/CH_05_functional_programming/README.rst b/CH_05_functional_programming/README.rst index cfefb24..fa1c392 100644 --- a/CH_05_functional_programming/README.rst +++ b/CH_05_functional_programming/README.rst @@ -1,6 +1,6 @@ Chapter 5 - functional programming ======================================================================================================================= -1. Implement the quicksort algorithm. -2. Write a groupby function that isn’t affected by sorting. -3. Write a groupby function that returns lists of results instead of generators. +1. `Implement the quicksort algorithm. `_ +2. `Write a groupby function that isn’t affected by sorting. `_ +3. `Write a groupby function that returns lists of results instead of generators. `_ diff --git a/CH_05_functional_programming/exercise_01/README.rst b/CH_05_functional_programming/exercise_01/README.rst new file mode 100644 index 0000000..4466cb6 --- /dev/null +++ b/CH_05_functional_programming/exercise_01/README.rst @@ -0,0 +1,61 @@ +Exercise 1: quicksort +===================== + +Question +-------- + +Implement the quicksort algorithm. + +Solution +-------- + +``quicksort`` accepts any iterable and returns a fresh sorted list. It consumes +the iterable exactly once while materializing it. Mutable input containers are +not structurally modified, and empty and singleton inputs also produce fresh +lists. The ``qs`` name remains available as a typed compatibility wrapper. + +The implementation deterministically chooses the first item as the pivot. +Each remaining item is compared with the pivot once using only ``<``. Items +that compare less than the pivot enter the lower partition; all other items +enter the upper partition. This retains duplicates and distinct objects that +are equivalent under the ordering. + +Values must provide a strict weak ordering through ``<``: comparisons should +be irreflexive and transitive, and equivalence induced by neither value being +less than the other should also be transitive. Comparison exceptions propagate +to the caller. The algorithm does not directly mutate contained objects, but a +user-defined ``__lt__`` method can have its own side effects. + +Average time complexity is :math:`O(n \log n)` with :math:`O(n)` partition +storage across one recursion level. Choosing the first pivot intentionally +makes already sorted, reverse-sorted, and all-equivalent inputs worst-case +:math:`O(n^2)` time with :math:`O(n)` recursion depth. Large adversarial inputs +can therefore reach Python's recursion limit; the deterministic recursive form +is retained to demonstrate the functional algorithm rather than avoid that +tradeoff. + +Dependencies +------------ + +The solution requires Python 3.10 or newer and uses only the standard library. +The repository's ``dev`` dependency group supplies pytest and the static +analysis tools. + +Run the guarded module demonstration: + +.. code-block:: console + + $ uv run python -m CH_05_functional_programming.exercise_01.solution_00 + +Run the focused tests: + +.. code-block:: console + + $ uv run pytest -W error CH_05_functional_programming/exercise_01/test_solution_00.py + +Historical alternative +---------------------- + +``solution_01.py`` is preserved byte-for-byte as the historical in-place +partitioning alternative. Unlike the primary functional solution, it mutates +the caller's list and requires a sequence with index access. diff --git a/CH_05_functional_programming/exercise_01/solution_00.py b/CH_05_functional_programming/exercise_01/solution_00.py index 344f790..31cfc4e 100644 --- a/CH_05_functional_programming/exercise_01/solution_00.py +++ b/CH_05_functional_programming/exercise_01/solution_00.py @@ -1,29 +1,68 @@ -# Implement the quicksort algorithm. -import random +"""Implement the quicksort algorithm.""" -# one-liner approach -qs = lambda xs: xs if len(xs) <= 1 else qs( - [x for x in xs[1:] if x < xs[0]]) + [xs[0]] + qs( - [x for x in xs[1:] if x >= xs[0]]) +from collections.abc import Iterable +from typing import Any, Protocol, TypeVar, cast +T_contra = TypeVar("T_contra", contravariant=True) -# more verbose approach -def quicksort(xs): - if len(xs) <= 1: - return xs - else: - left = quicksort([x for x in xs[1:] if x < xs[0]]) - right = quicksort([x for x in xs[1:] if x >= xs[0]]) - middle = [xs[0]] - return left + middle + right +class SupportsLessThan(Protocol[T_contra]): + def __lt__(self, other: T_contra, /) -> bool: ... -def main(): - # test - xs = random.sample(range(1000), 100) - assert quicksort(xs) == sorted(xs) - assert qs(xs) == sorted(xs) +class SupportsGreaterThan(Protocol[T_contra]): + def __gt__(self, other: T_contra, /) -> bool: ... -if __name__ == '__main__': + +T = TypeVar( + "T", + bound=SupportsLessThan[Any] | SupportsGreaterThan[Any], +) + + +def _quicksort(values: list[T]) -> list[T]: + if len(values) <= 1: + return values.copy() + + iterator = iter(values) + pivot: T = next(iterator) + lower: list[T] = [] + upper: list[T] = [] + for value in iterator: + comparable: SupportsLessThan[Any] = cast(SupportsLessThan[Any], value) + if comparable < pivot: + lower.append(value) + else: + upper.append(value) + + return [*_quicksort(lower), pivot, *_quicksort(upper)] + + +def quicksort(values: Iterable[T]) -> list[T]: + """Return a fresh sorted list after consuming the iterable once. + + Mutable input containers are not structurally modified, and the algorithm + directly mutates no elements. + """ + materialized: list[T] = list(values) + return _quicksort(materialized) + + +def qs(values: Iterable[T]) -> list[T]: + """Return a fresh sorted list after consuming the iterable once. + + Mutable input containers are not structurally modified, and this + compatibility wrapper directly mutates no elements. + """ + return quicksort(values) + + +def main() -> None: + values: list[int] = [8, 3, 5, 3, 1] + expected: list[int] = [1, 3, 3, 5, 8] + assert quicksort(values) == expected + assert qs(values) == expected + + +if __name__ == "__main__": main() diff --git a/CH_05_functional_programming/exercise_01/test_solution_00.py b/CH_05_functional_programming/exercise_01/test_solution_00.py new file mode 100644 index 0000000..347affd --- /dev/null +++ b/CH_05_functional_programming/exercise_01/test_solution_00.py @@ -0,0 +1,291 @@ +from __future__ import annotations + +import ast +import subprocess +import sys +from collections.abc import Iterator +from pathlib import Path +from textwrap import dedent +from typing import ClassVar + +import pytest + +from . import solution_00 +from .solution_00 import qs, quicksort + + +class OneShotIterable: + def __init__(self, values: list[int]) -> None: + self._values: list[int] = values + self._iterated: bool = False + + def __iter__(self) -> Iterator[int]: + if self._iterated: + raise AssertionError("iterable consumed more than once") + self._iterated = True + yield from self._values + + +class Record: + def __init__(self, name: str, rank: int) -> None: + self.name: str = name + self.rank: int = rank + + def __lt__(self, other: Record) -> bool: + return self.rank < other.rank + + +class GreaterOnly: + def __init__(self, rank: int) -> None: + self.rank: int = rank + + def __gt__(self, other: GreaterOnly) -> bool: + return self.rank > other.rank + + +class CountedValue: + comparisons: ClassVar[int] = 0 + + def __init__(self, value: int) -> None: + self.value: int = value + + def __lt__(self, other: CountedValue) -> bool: + type(self).comparisons += 1 + return self.value < other.value + + +class BrokenComparison: + def __lt__(self, other: BrokenComparison) -> bool: + del other + raise RuntimeError("comparison failed") + + +def run_type_checker( + checker: str, probe_path: Path +) -> subprocess.CompletedProcess[str]: + repository_root: Path = Path(__file__).parents[2] + if checker == "mypy": + arguments: list[str] = [ + sys.executable, + "-m", + "mypy", + "--strict", + "--python-version", + "3.10", + str(probe_path), + ] + else: + arguments = [ + sys.executable, + "-m", + "pyright", + "--pythonversion", + "3.10", + str(probe_path), + ] + return subprocess.run( + arguments, + cwd=repository_root, + check=False, + capture_output=True, + text=True, + ) + + +def test_quicksort_accepts_generator() -> None: + values: Iterator[int] = (value for value in [4, 1, 3, 2]) + + assert quicksort(values) == [1, 2, 3, 4] + + +def test_quicksort_consumes_one_shot_iterable_once() -> None: + values: OneShotIterable = OneShotIterable([3, 1, 2]) + + assert quicksort(values) == [1, 2, 3] + + +def test_quicksort_preserves_duplicate_values() -> None: + assert quicksort([3, 1, 3, 2, 1, 3]) == [1, 1, 2, 3, 3, 3] + + +@pytest.mark.parametrize("values", [[], [42]]) +def test_quicksort_returns_fresh_list_for_short_inputs(values: list[int]) -> None: + result: list[int] = quicksort(values) + + assert result == values + assert result is not values + + +def test_quicksort_does_not_mutate_input_list_or_records() -> None: + high: Record = Record("high", 3) + low: Record = Record("low", 1) + middle: Record = Record("middle", 2) + values: list[Record] = [high, low, middle] + + result: list[Record] = quicksort(values) + + assert values == [high, low, middle] + assert [(record.name, record.rank) for record in values] == [ + ("high", 3), + ("low", 1), + ("middle", 2), + ] + assert result == [low, middle, high] + + +def test_quicksort_uses_only_less_than_and_preserves_equivalent_records() -> None: + first: Record = Record("first", 2) + lower: Record = Record("lower", 1) + second: Record = Record("second", 2) + third: Record = Record("third", 2) + + result: list[Record] = quicksort([first, lower, second, third]) + + assert result == [lower, first, second, third] + + +def test_quicksort_uses_reflected_greater_than_comparison() -> None: + values: list[GreaterOnly] = [GreaterOnly(3), GreaterOnly(1), GreaterOnly(2)] + + result: list[GreaterOnly] = quicksort(values) + + assert result == sorted(values) + assert values[0].rank == 3 + + +@pytest.mark.parametrize( + "raw_values", + [ + list(range(12)), + list(range(11, -1, -1)), + [7] * 12, + ], +) +def test_first_pivot_partition_compares_each_remaining_item_once( + raw_values: list[int], +) -> None: + values: list[CountedValue] = [CountedValue(value) for value in raw_values] + CountedValue.comparisons = 0 + + result: list[CountedValue] = quicksort(values) + + assert [value.value for value in result] == sorted(raw_values) + assert CountedValue.comparisons == len(values) * (len(values) - 1) // 2 + + +def test_qs_matches_quicksort() -> None: + values: list[int] = [8, 3, 5, 3, 1] + + assert qs(iter(values)) == quicksort(values) + + +def test_comparison_errors_propagate() -> None: + values: list[BrokenComparison] = [BrokenComparison(), BrokenComparison()] + + with pytest.raises(RuntimeError, match="comparison failed"): + quicksort(values) + + +@pytest.mark.parametrize("checker", ["mypy", "pyright"]) +def test_static_typing_accepts_concrete_comparable_types( + checker: str, + tmp_path: Path, +) -> None: + probe_path: Path = tmp_path / "valid_quicksort_typing.py" + probe_path.write_text( + dedent( + """ + # pyright: strict + from __future__ import annotations + + from CH_05_functional_programming.exercise_01.solution_00 import qs, quicksort + + + class Record: + def __init__(self, rank: int) -> None: + self.rank: int = rank + + def __lt__(self, other: Record) -> bool: + return self.rank < other.rank + + + class GreaterOnly: + def __init__(self, rank: int) -> None: + self.rank: int = rank + + def __gt__(self, other: GreaterOnly) -> bool: + return self.rank > other.rank + + + integers = quicksort([3, 1, 2]) + strings = qs(["c", "a", "b"]) + records = quicksort([Record(2), Record(1)]) + greater_only = quicksort([GreaterOnly(2), GreaterOnly(1)]) + + reveal_type(integers) + reveal_type(strings) + reveal_type(records) + reveal_type(greater_only) + """ + ), + encoding="utf-8", + ) + + result: subprocess.CompletedProcess[str] = run_type_checker(checker, probe_path) + + output: str = result.stdout + result.stderr + assert result.returncode == 0, output + if checker == "mypy": + assert 'Revealed type is "list[int]"' in output + assert 'Revealed type is "list[str]"' in output + assert 'Revealed type is "list[valid_quicksort_typing.Record]"' in output + assert 'Revealed type is "list[valid_quicksort_typing.GreaterOnly]"' in output + else: + assert 'Type of "integers" is "list[int]"' in output + assert 'Type of "strings" is "list[str]"' in output + assert 'Type of "records" is "list[Record]"' in output + assert 'Type of "greater_only" is "list[GreaterOnly]"' in output + + +@pytest.mark.parametrize("checker", ["mypy", "pyright"]) +def test_static_typing_rejects_unorderable_objects( + checker: str, + tmp_path: Path, +) -> None: + probe_path: Path = tmp_path / "invalid_quicksort_typing.py" + probe_path.write_text( + dedent( + """ + # pyright: strict + from CH_05_functional_programming.exercise_01.solution_00 import quicksort + + result: list[object] = quicksort([object(), object()]) + """ + ), + encoding="utf-8", + ) + + result: subprocess.CompletedProcess[str] = run_type_checker(checker, probe_path) + output: str = result.stdout + result.stderr + + assert result.returncode != 0, output + assert "object" in output + + +def test_module_demo_is_protected_by_main_guard() -> None: + source_path: Path = Path(solution_00.__file__) + module: ast.Module = ast.parse(source_path.read_text(encoding="utf-8")) + guarded_calls: list[ast.Call] = [ + node + for statement in module.body + if isinstance(statement, ast.If) + and isinstance(statement.test, ast.Compare) + and isinstance(statement.test.left, ast.Name) + and statement.test.left.id == "__name__" + for node in ast.walk(statement) + if isinstance(node, ast.Call) + and isinstance(node.func, ast.Name) + and node.func.id == "main" + ] + + assert guarded_calls diff --git a/CH_05_functional_programming/exercise_02/README.rst b/CH_05_functional_programming/exercise_02/README.rst new file mode 100644 index 0000000..1b1189e --- /dev/null +++ b/CH_05_functional_programming/exercise_02/README.rst @@ -0,0 +1,57 @@ +Exercise 2: sorting-independent groupby +======================================== + +Question +-------- + +Write a groupby function that isn’t affected by sorting. + +Solution +-------- + +``groupby`` accepts a key function and any iterable. It returns a dictionary +whose keys are the key function's results and whose values are lists of the +matching input items. + +Unlike ``itertools.groupby``, which starts a new group whenever the key +changes, this implementation combines equal keys even when their items are not +consecutive. Pre-sorting can make equal keys adjacent for +``itertools.groupby``, but it changes encounter order and generally +requires materializing the iterable. This implementation does neither. + +Dictionary keys retain first-key encounter order. Each group's list retains +the encounter order of its items. The iterable is consumed once, the key +function is called exactly once per item, and the input container is not +structurally modified. + +Assuming average constant-time dictionary operations, the algorithm takes +:math:`O(n)` time and :math:`O(n)` output space. It does not sort or +materialize the complete input before grouping. Passing an iterator consumes +it. Key-function side effects occur in item encounter order, and callback +exceptions propagate immediately. Keys must be hashable; an unhashable runtime +key raises ``TypeError``. + +Dependencies +------------ + +The solution requires Python 3.10 or newer and uses only the standard library. +The repository's ``dev`` dependency group supplies pytest and the static +analysis tools. + +Run the guarded module demonstration: + +.. code-block:: console + + $ uv run python -m CH_05_functional_programming.exercise_02.solution_00 + +Run the focused tests: + +.. code-block:: console + + $ uv run pytest -W error CH_05_functional_programming/exercise_02/test_solution_00.py + +Reference +--------- + +The chapter's original discussion is preserved at +https://github.com/mastering-python/code_2/blob/a012f6ce3f5bf11f5bf380d1c4e7f743355adb89/CH_05_functional_programming/T_14_groupby.rst. diff --git a/CH_05_functional_programming/exercise_02/solution_00.py b/CH_05_functional_programming/exercise_02/solution_00.py index 441baf1..dcf48a6 100644 --- a/CH_05_functional_programming/exercise_02/solution_00.py +++ b/CH_05_functional_programming/exercise_02/solution_00.py @@ -1,33 +1,33 @@ -# Write a groupby function that isn’t affected by sorting. -import collections +"""Group values without requiring adjacent equal keys.""" +from collections.abc import Callable, Hashable, Iterable +from typing import TypeVar -def groupby(func, seq): - groups = collections.defaultdict(list) +T = TypeVar("T") +K = TypeVar("K", bound=Hashable) + + +def groupby(func: Callable[[T], K], seq: Iterable[T]) -> dict[K, list[T]]: + """Group items by keys returned by ``func``, preserving encounter order.""" + if not callable(func): + raise TypeError("func must be callable") + + groups: dict[K, list[T]] = {} for item in seq: - groups[func(item)].append(item) + key: K = func(item) + groups.setdefault(key, []).append(item) return groups -def main(): - # Explicitly defined test data for clarity. - xs = [0, 1, 2, 3, 4, 5, 6, 7] +def main() -> None: + """Demonstrate grouping non-consecutive values.""" + values: list[int] = list(range(8)) - assert groupby(lambda x: x % 2, xs) == { + assert groupby(lambda value: value % 2, values) == { 0: [0, 2, 4, 6], 1: [1, 3, 5, 7], } - assert groupby( - lambda x: 'even' if x % 2 == 0 else 'odd', - xs, - ) == {'even': [0, 2, 4, 6], 'odd': [1, 3, 5, 7]} - - assert groupby(lambda x: x > 5, xs) == { - False: [0, 1, 2, 3, 4, 5], - True: [6, 7], - } - -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/CH_05_functional_programming/exercise_02/test_solution_00.py b/CH_05_functional_programming/exercise_02/test_solution_00.py new file mode 100644 index 0000000..a810387 --- /dev/null +++ b/CH_05_functional_programming/exercise_02/test_solution_00.py @@ -0,0 +1,343 @@ +from __future__ import annotations + +import ast +import subprocess +import sys +from collections.abc import Callable, Hashable, Iterator +from pathlib import Path +from textwrap import dedent +from typing import cast + +import pytest + +from . import solution_00 +from .solution_00 import groupby + +REPOSITORY_ROOT: Path = Path(__file__).resolve().parents[2] +MODULE_NAME: str = "CH_05_functional_programming.exercise_02.solution_00" + + +class OneShotIterable: + def __init__(self, values: list[int]) -> None: + self._values: list[int] = values + self._iterated: bool = False + + def __iter__(self) -> Iterator[int]: + if self._iterated: + raise AssertionError("iterable consumed more than once") + self._iterated = True + yield from self._values + + +class IterationForbidden: + def __iter__(self) -> Iterator[int]: + raise AssertionError("seq was consumed") + + +class TrackedIterable: + def __init__(self, values: list[int]) -> None: + self._values: list[int] = values + self.yielded: list[int] = [] + + def __iter__(self) -> Iterator[int]: + for value in self._values: + self.yielded.append(value) + yield value + + +class Parity: + def __call__(self, value: int) -> str: + return "even" if value % 2 == 0 else "odd" + + +class CollidingEqualKey: + def __init__(self, label: str, group: int) -> None: + self.label: str = label + self.group: int = group + + def __hash__(self) -> int: + return 1 + + def __eq__(self, other: object) -> bool: + if not isinstance(other, CollidingEqualKey): + return NotImplemented + return self.group == other.group + + +class ExplodingEqualityKey: + def __hash__(self) -> int: + return 1 + + def __eq__(self, other: object) -> bool: + raise RuntimeError("key equality failed") + + +def run_type_checker( + checker: str, probe_path: Path +) -> subprocess.CompletedProcess[str]: + arguments: list[str] + if checker == "mypy": + arguments = [ + sys.executable, + "-m", + "mypy", + "--strict", + "--python-version", + "3.10", + str(probe_path), + ] + else: + arguments = [ + sys.executable, + "-m", + "pyright", + "--pythonversion", + "3.10", + str(probe_path), + ] + + return subprocess.run( + arguments, + cwd=REPOSITORY_ROOT, + check=False, + capture_output=True, + text=True, + ) + + +def test_groupby_combines_nonconsecutive_equal_parity_keys() -> None: + result: dict[int, list[int]] = groupby(lambda value: value % 2, range(8)) + + assert result == {0: [0, 2, 4, 6], 1: [1, 3, 5, 7]} + assert type(result) is dict + + +def test_groupby_returns_empty_dict_for_empty_iterable() -> None: + result: dict[int, list[int]] = groupby(lambda value: value, []) + + assert result == {} + assert type(result) is dict + + +def test_groupby_consumes_one_shot_iterable_once() -> None: + values: OneShotIterable = OneShotIterable([3, 1, 2, 4]) + + assert groupby(lambda value: value % 2, values) == { + 1: [3, 1], + 0: [2, 4], + } + + +def test_groupby_streams_and_exhausts_generator_expression_once() -> None: + events: list[str] = [] + source: list[int] = [1, 2, 3, 4] + + def transform(value: int) -> int: + events.append(f"transform:{value}") + return value * 10 + + def parity(value: int) -> int: + events.append(f"key:{value}") + return (value // 10) % 2 + + values: Iterator[int] = (transform(value) for value in source) + + assert groupby(parity, values) == {1: [10, 30], 0: [20, 40]} + assert events == [ + "transform:1", + "key:10", + "transform:2", + "key:20", + "transform:3", + "key:30", + "transform:4", + "key:40", + ] + assert list(values) == [] + + +def test_groupby_preserves_first_key_and_item_encounter_order() -> None: + values: Iterator[str] = iter(["beta", "a", "bravo", "cat", "amber"]) + + result: dict[str, list[str]] = groupby(lambda value: value[0], values) + + assert list(result) == ["b", "a", "c"] + assert result == { + "b": ["beta", "bravo"], + "a": ["a", "amber"], + "c": ["cat"], + } + + +def test_groupby_calls_key_function_exactly_once_per_item() -> None: + calls: list[int] = [] + + def recording_key(value: int) -> int: + calls.append(value) + return value % 2 + + values: list[int] = [4, 1, 4, 3] + + assert groupby(recording_key, values) == {0: [4, 4], 1: [1, 3]} + assert calls == values + + +def test_groupby_accepts_callable_object() -> None: + assert groupby(Parity(), [1, 2, 3]) == { + "odd": [1, 3], + "even": [2], + } + + +def test_groupby_rejects_non_callable_before_consuming_seq() -> None: + invalid_func: Callable[[int], int] = cast(Callable[[int], int], None) + + with pytest.raises(TypeError, match=r"^func must be callable$"): + groupby(invalid_func, IterationForbidden()) + + +def test_groupby_propagates_key_function_exception() -> None: + expected_error: RuntimeError = RuntimeError("key failed") + + def broken_key(value: int) -> int: + if value == 2: + raise expected_error + return value + + with pytest.raises(RuntimeError, match=r"^key failed$") as raised: + groupby(broken_key, [1, 2, 3]) + + assert raised.value is expected_error + + +def test_groupby_propagates_unhashable_key_error() -> None: + def unhashable_key(value: int) -> Hashable: + return cast(Hashable, [value]) + + with pytest.raises(TypeError, match=r"unhashable type: 'list'"): + groupby(unhashable_key, [1]) + + +def test_groupby_merges_equal_colliding_keys_and_retains_first_key_object() -> None: + first: CollidingEqualKey = CollidingEqualKey("first", 1) + collision: CollidingEqualKey = CollidingEqualKey("collision", 2) + equal_later: CollidingEqualKey = CollidingEqualKey("equal later", 1) + + result: dict[CollidingEqualKey, list[CollidingEqualKey]] = groupby( + lambda key: key, + [first, collision, equal_later], + ) + + result_keys: list[CollidingEqualKey] = list(result) + assert result_keys == [first, collision] + assert result_keys[0] is first + assert result[first] == [first, equal_later] + assert result[collision] == [collision] + + +def test_groupby_propagates_key_equality_error_without_further_consumption() -> None: + values: TrackedIterable = TrackedIterable([1, 2, 3]) + calls: list[int] = [] + keys: dict[int, ExplodingEqualityKey] = { + 1: ExplodingEqualityKey(), + 2: ExplodingEqualityKey(), + 3: ExplodingEqualityKey(), + } + + def broken_key(value: int) -> ExplodingEqualityKey: + calls.append(value) + return keys[value] + + with pytest.raises(RuntimeError, match=r"^key equality failed$"): + groupby(broken_key, values) + + assert values.yielded == [1, 2] + assert calls == [1, 2] + + +def test_groupby_does_not_structurally_mutate_input_container() -> None: + values: list[str] = ["pear", "fig", "plum", "kiwi"] + original: list[str] = values.copy() + + result: dict[int, list[str]] = groupby(len, values) + + assert values == original + assert result == {4: ["pear", "plum", "kiwi"], 3: ["fig"]} + + +@pytest.mark.parametrize("checker", ["mypy", "pyright"]) +def test_static_typing_rejects_unhashable_key_function( + checker: str, + tmp_path: Path, +) -> None: + probe_path: Path = tmp_path / "invalid_groupby_typing.py" + probe_path.write_text( + dedent( + """ + # pyright: strict + from CH_05_functional_programming.exercise_02.solution_00 import groupby + + invalid = groupby(lambda value: [value], [1, 2]) + """ + ), + encoding="utf-8", + ) + + result: subprocess.CompletedProcess[str] = run_type_checker(checker, probe_path) + output: str = result.stdout + result.stderr + + assert result.returncode != 0, output + assert "list[int]" in output + + +def test_import_is_quiet() -> None: + import_result: subprocess.CompletedProcess[str] = subprocess.run( + [sys.executable, "-c", f"import {MODULE_NAME}"], + cwd=REPOSITORY_ROOT, + check=False, + capture_output=True, + text=True, + ) + assert import_result.returncode == 0, import_result.stderr + assert import_result.stdout == "" + assert import_result.stderr == "" + + +def test_module_demo_runs_under_exact_main_guard() -> None: + execution_result: subprocess.CompletedProcess[str] = subprocess.run( + [sys.executable, "-m", MODULE_NAME], + cwd=REPOSITORY_ROOT, + check=False, + capture_output=True, + text=True, + ) + assert execution_result.returncode == 0, execution_result.stderr + assert execution_result.stdout == "" + assert execution_result.stderr == "" + + source_path: Path = Path(solution_00.__file__) + module: ast.Module = ast.parse(source_path.read_text(encoding="utf-8")) + exact_main_guards: list[ast.If] = [ + statement + for statement in module.body + if isinstance(statement, ast.If) + and isinstance(statement.test, ast.Compare) + and isinstance(statement.test.left, ast.Name) + and statement.test.left.id == "__name__" + and len(statement.test.ops) == 1 + and isinstance(statement.test.ops[0], ast.Eq) + and len(statement.test.comparators) == 1 + and isinstance(statement.test.comparators[0], ast.Constant) + and statement.test.comparators[0].value == "__main__" + ] + guarded_main_calls: list[ast.Call] = [ + node + for statement in exact_main_guards + for node in ast.walk(statement) + if isinstance(node, ast.Call) + and isinstance(node.func, ast.Name) + and node.func.id == "main" + ] + + assert len(exact_main_guards) == 1 + assert guarded_main_calls diff --git a/CH_05_functional_programming/exercise_03/README.rst b/CH_05_functional_programming/exercise_03/README.rst new file mode 100644 index 0000000..6d293f3 --- /dev/null +++ b/CH_05_functional_programming/exercise_03/README.rst @@ -0,0 +1,63 @@ +Exercise 3: reusable groupby lists +================================== + +Question +-------- + +Write a groupby function that returns lists of results instead of generators. + +Solution +-------- + +``groupby`` accepts an iterable first and an optional ``key`` function second. +When ``key`` is omitted or ``None``, each hashable item is its own group key. +When a key function is supplied, its hashable result is the group key, so input +items themselves may be unhashable. + +This differs from Exercise 2, whose API is function-first: +``groupby(func, seq)``. It also differs from ``itertools.groupby``. +``itertools.groupby`` returns lazy group iterators and starts a new group when +the key changes, so equal non-adjacent keys form separate groups unless the +input is suitably ordered. This solution makes one dictionary entry per equal +key regardless of adjacency and stores each group in a concrete, reusable +``list``. + +The result preserves first key encounter order, and every group preserves item +encounter order. The input is traversed once without sorting. An input +iterator is therefore consumed, and the key callback is called exactly once per +item in encounter order. Callback exceptions and errors from unhashable group +keys propagate immediately. A non-callable, non-``None`` key raises +``TypeError`` before the iterable is consumed. + +Concrete lists can be traversed repeatedly and mutated independently after the +call. The tradeoff is eager storage: grouping uses :math:`O(n)` output memory, +whereas ``itertools.groupby`` can expose each adjacent group lazily. + +Dependencies +------------ + +The solution requires Python 3.10 or newer and uses only the standard library. +The repository's ``dev`` dependency group supplies pytest and the static +analysis tools. + +Run +--- + +Run the guarded module demonstration from the repository root: + +.. code-block:: console + + $ uv run python -m CH_05_functional_programming.exercise_03.solution_00 + +Run the focused tests: + +.. code-block:: console + + $ uv run pytest -W error CH_05_functional_programming/exercise_03/test_solution_00.py + +Reference +--------- + +The chapter's original discussion is preserved in the pinned +`T_14_groupby.rst source +`_. diff --git a/CH_05_functional_programming/exercise_03/solution_00.py b/CH_05_functional_programming/exercise_03/solution_00.py index abd0296..c67d6b4 100644 --- a/CH_05_functional_programming/exercise_03/solution_00.py +++ b/CH_05_functional_programming/exercise_03/solution_00.py @@ -1,29 +1,46 @@ -# Write a groupby function that returns lists of results instead of -# generators. +"""Group values into reusable lists.""" -import pprint +from collections.abc import Callable, Hashable, Iterable +from typing import TypeVar, overload +H = TypeVar("H", bound=Hashable) +K = TypeVar("K", bound=Hashable) +T = TypeVar("T") -def groupby(iterable, key=None): - ''' - Return a dictionary of lists of items grouped by the key function. - Note that as opposed to the itertools.groupby function, this function - does not require the iterable to be sorted. - ''' +@overload +def groupby(iterable: Iterable[H], key: None = None) -> dict[H, list[H]]: ... + + +@overload +def groupby(iterable: Iterable[T], key: Callable[[T], K]) -> dict[K, list[T]]: ... + + +def groupby( + iterable: Iterable[T], key: Callable[[T], K] | None = None +) -> dict[K, list[T]] | dict[T, list[T]]: + """Return input items collected into lists by their group key.""" + if key is not None and not callable(key): + raise TypeError("key must be callable or None") + + item: T if key is None: - key = lambda x: x - groups = {} + identity_groups: dict[T, list[T]] = {} + for item in iterable: + identity_groups.setdefault(item, []).append(item) + return identity_groups + + keyed_groups: dict[K, list[T]] = {} for item in iterable: - groups.setdefault(key(item), []).append(item) - return groups + group_key: K = key(item) + keyed_groups.setdefault(group_key, []).append(item) + return keyed_groups -def main(): - # Demo data from the itertools docs - pprint.pprint(groupby('AAAABBBCCDAABBB')) - pprint.pprint(groupby('AAAABBBCCD')) +def main() -> None: + """Print a small identity-grouping demonstration.""" + print(groupby("ABACA")) -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/CH_05_functional_programming/exercise_03/test_solution_00.py b/CH_05_functional_programming/exercise_03/test_solution_00.py new file mode 100644 index 0000000..20c389b --- /dev/null +++ b/CH_05_functional_programming/exercise_03/test_solution_00.py @@ -0,0 +1,266 @@ +from __future__ import annotations + +import ast +import subprocess +import sys +from collections.abc import Callable, Hashable, Iterable, Iterator +from pathlib import Path +from typing import cast + +import pytest + +from . import solution_00 +from .solution_00 import groupby + +REPOSITORY_ROOT: Path = Path(__file__).resolve().parents[2] +MODULE_NAME: str = "CH_05_functional_programming.exercise_03.solution_00" + + +class OneShotIterable: + def __init__(self, values: list[int]) -> None: + self._values: Iterator[int] = iter(values) + self._started: bool = False + + def __iter__(self) -> Iterator[int]: + if self._started: + raise AssertionError("iterable was restarted") + self._started = True + return self._values + + +class IterationForbidden: + def __iter__(self) -> Iterator[int]: + raise AssertionError("iterable was consumed") + + +class EqualKey: + def __init__(self, label: str) -> None: + self.label: str = label + + def __hash__(self) -> int: + return 1 + + def __eq__(self, other: object) -> bool: + return isinstance(other, EqualKey) + + +class CollidingKey: + def __init__(self, label: str) -> None: + self.label: str = label + + def __hash__(self) -> int: + return 1 + + def __eq__(self, other: object) -> bool: + return isinstance(other, CollidingKey) and self.label == other.label + + +def test_groupby_identity_example_returns_lists() -> None: + groups: dict[str, list[str]] = groupby("ABACA") + + assert groups == {"A": ["A", "A", "A"], "B": ["B"], "C": ["C"]} + assert all(isinstance(group, list) for group in groups.values()) + + +def test_group_lists_are_reusable_and_independent() -> None: + groups: dict[str, list[str]] = groupby("ABACA") + first_read: list[str] = list(groups["A"]) + second_read: list[str] = list(groups["A"]) + + groups["A"].append("changed") + + assert first_read == second_read == ["A", "A", "A"] + assert groups["B"] == ["B"] + assert groups["A"] is not groups["B"] + + +def test_groupby_accepts_empty_generator() -> None: + empty: Iterable[int] = () + values: Iterator[int] = (value for value in empty) + + assert groupby(values) == {} + + +def test_groupby_consumes_one_shot_iterable_once() -> None: + values: OneShotIterable = OneShotIterable([3, 1, 2, 4]) + + assert groupby(values, key=lambda value: value % 2) == { + 1: [3, 1], + 0: [2, 4], + } + + +def test_groupby_identity_signature_preserves_types() -> None: + groups: dict[str, list[str]] = groupby(["alpha", "beta", "alpha"]) + + assert groups == {"alpha": ["alpha", "alpha"], "beta": ["beta"]} + + +def test_groupby_keyed_signature_preserves_item_and_key_types() -> None: + groups: dict[int, list[str]] = groupby(["one", "three", "two"], key=len) + + assert groups == {3: ["one", "two"], 5: ["three"]} + + +def test_keyed_groupby_accepts_unhashable_items() -> None: + values: list[list[int]] = [[1], [2, 3], [4]] + + assert groupby(values, key=len) == {1: [[1], [4]], 2: [[2, 3]]} + + +def test_noncallable_key_is_rejected_before_iterable_consumption() -> None: + invalid_key: Callable[[int], int] = cast(Callable[[int], int], 0) + + with pytest.raises(TypeError, match=r"^key must be callable or None$"): + groupby(IterationForbidden(), key=invalid_key) + + +def test_first_key_and_item_encounter_order_are_preserved() -> None: + groups: dict[int, list[int]] = groupby( + [5, 2, 3, 4, 1, 6], key=lambda value: value % 3 + ) + + assert list(groups) == [2, 0, 1] + assert groups == {2: [5, 2], 0: [3, 6], 1: [4, 1]} + + +def test_key_callback_is_called_once_per_item_in_encounter_order() -> None: + calls: list[int] = [] + + def recording_key(value: int) -> int: + calls.append(value) + return value % 2 + + values: list[int] = [4, 1, 4, 3] + + assert groupby(values, key=recording_key) == { + 0: [4, 4], + 1: [1, 3], + } + assert calls == values + + +def test_key_callback_error_propagates_without_consuming_later_items() -> None: + yielded: list[int] = [] + + def values() -> Iterator[int]: + for value in [1, 2, 3]: + yielded.append(value) + yield value + + def failing_key(value: int) -> int: + if value == 2: + raise RuntimeError("key failed") + return value + + with pytest.raises(RuntimeError, match=r"^key failed$"): + groupby(values(), key=failing_key) + + assert yielded == [1, 2] + + +def test_unhashable_identity_key_error_propagates() -> None: + values: Iterable[Hashable] = cast(Iterable[Hashable], [[1]]) + + with pytest.raises(TypeError, match="unhashable type"): + groupby(values) + + +def test_unhashable_derived_key_error_propagates() -> None: + def unhashable_key(value: int) -> Hashable: + return cast(Hashable, [value]) + + with pytest.raises(TypeError, match="unhashable type"): + groupby([1], key=unhashable_key) + + +def test_groupby_does_not_modify_input_list() -> None: + values: list[int] = [3, 1, 2, 3] + original: list[int] = values.copy() + + groupby(values) + + assert values == original + + +def test_equal_keys_keep_first_encountered_key_object() -> None: + first: EqualKey = EqualKey("first") + second: EqualKey = EqualKey("second") + + groups: dict[EqualKey, list[str]] = groupby( + ["alpha", "beta"], key=lambda value: first if value == "alpha" else second + ) + retained_key: EqualKey = next(iter(groups)) + + assert list(groups) == [first] + assert retained_key is first + assert groups[first] == ["alpha", "beta"] + + +def test_hash_collisions_keep_distinct_groups() -> None: + first: CollidingKey = CollidingKey("first") + second: CollidingKey = CollidingKey("second") + + groups: dict[CollidingKey, list[str]] = groupby( + ["alpha", "beta"], key=lambda value: first if value == "alpha" else second + ) + + assert list(groups) == [first, second] + assert groups[first] == ["alpha"] + assert groups[second] == ["beta"] + + +def test_import_is_silent() -> None: + result: subprocess.CompletedProcess[str] = subprocess.run( + [sys.executable, "-c", f"import {MODULE_NAME}"], + cwd=REPOSITORY_ROOT, + check=False, + capture_output=True, + text=True, + ) + + assert result.returncode == 0, result.stderr + assert result.stdout == "" + assert result.stderr == "" + + +def test_module_demo_has_exact_output() -> None: + result: subprocess.CompletedProcess[str] = subprocess.run( + [sys.executable, "-m", MODULE_NAME], + cwd=REPOSITORY_ROOT, + check=False, + capture_output=True, + text=True, + ) + + assert result.returncode == 0, result.stderr + assert result.stdout == ("{'A': ['A', 'A', 'A'], 'B': ['B'], 'C': ['C']}\n") + assert result.stderr == "" + + +def test_module_demo_is_protected_by_exact_main_guard() -> None: + source_path: Path = Path(solution_00.__file__) + tree: ast.Module = ast.parse(source_path.read_text(encoding="utf-8")) + final_statement: ast.stmt = tree.body[-1] + + assert isinstance(final_statement, ast.If) + assert isinstance(final_statement.test, ast.Compare) + assert isinstance(final_statement.test.left, ast.Name) + assert final_statement.test.left.id == "__name__" + assert isinstance(final_statement.test.left.ctx, ast.Load) + assert len(final_statement.test.ops) == 1 + assert isinstance(final_statement.test.ops[0], ast.Eq) + assert len(final_statement.test.comparators) == 1 + main_guard_value: ast.expr = final_statement.test.comparators[0] + assert isinstance(main_guard_value, ast.Constant) + assert main_guard_value.value == "__main__" + assert len(final_statement.body) == 1 + guarded_statement: ast.stmt = final_statement.body[0] + assert isinstance(guarded_statement, ast.Expr) + main_call: ast.expr = guarded_statement.value + assert isinstance(main_call, ast.Call) + assert isinstance(main_call.func, ast.Name) + assert main_call.func.id == "main" + assert isinstance(main_call.func.ctx, ast.Load) + assert main_call.args == [] + assert main_call.keywords == [] diff --git a/CH_06_decorators/README.rst b/CH_06_decorators/README.rst index ba70c6b..f8206c7 100644 --- a/CH_06_decorators/README.rst +++ b/CH_06_decorators/README.rst @@ -1,10 +1,17 @@ Chapter 6 - decorators -======================================================================================================================= +====================== -1. Extend the `track` function to monitor execution time. -2. Extend the `track` function with min/max/average execution time and call count. -3. Modify the memoization function to function with unhashable types. -4. Modify the memoization function to have a cache per function instead of a global one. -5. Create a version of `functools.cached_property` that can be recalculated as needed. -6. Create a single-dispatch decorator that considers all or a configurable number of arguments instead of only the first one. -7. Enhance the `type_check` decorator to include additional checks such as requiring a number to be greater than or less than a given value. +1. `Extend ``track`` to monitor execution time. + `_ +2. `Extend ``track`` with minimum, maximum, and average execution time and a + call count. `_ +3. `Modify memoization to support functions with unhashable arguments. + `_ +4. `Modify memoization to keep a cache per function instead of one global + cache. `_ +5. `Create a version of ``functools.cached_property`` that can be recalculated + as needed. `_ +6. `Create a single-dispatch decorator that considers a configurable number + of arguments instead of only the first one. `_ +7. `Enhance ``type_check`` with checks requiring a number to be greater or + less than a given value. `_ diff --git a/CH_06_decorators/exercise_01/README.rst b/CH_06_decorators/exercise_01/README.rst new file mode 100644 index 0000000..2b8238f --- /dev/null +++ b/CH_06_decorators/exercise_01/README.rst @@ -0,0 +1,57 @@ +Exercise 1: execution timing +============================ + +Question +-------- + +1. Extend the `track` function to monitor execution time. + +Solution +-------- + +``track`` supports ``@track``, a configured +``@track(label=..., clock=..., reporter=...)`` form, and a configured direct +call such as ``track(function, label=..., clock=..., reporter=...)``. Each call +produces a frozen ``Timing`` value containing its label, elapsed seconds, and +failure state. + +An explicit ``label`` always wins. Otherwise, the default label is a string +``function.__name__`` when available, or ``type(function).__name__`` for +callables such as ``functools.partial`` instances. + +The clock and reporter are injectable. A deterministic clock makes elapsed +time straightforward to test without sleeping, while a custom reporter can +store or publish timings without changing the decorator. The default clock is +the monotonic ``time.perf_counter``, so wall-clock adjustments do not distort +elapsed time. The default reporter prints the result. + +The starting clock is read before the target runs. If that read fails, the +target does not run and the clock exception propagates. After a successful +target call, ending-clock and reporter failures propagate. If the target +raises, timing is still attempted with ``failed=True``; an ending-clock or +reporter failure is suppressed so the original target exception remains the +one propagated to the caller. + +Dependencies +------------ + +The solution has no third-party runtime dependencies and requires Python 3.10 +or newer. Tests use the repository's ``pytest`` development dependency. + +Run the guarded demonstration: + +.. code-block:: console + + uv run python -m CH_06_decorators.exercise_01.solution_00 + +Run the focused tests: + +.. code-block:: console + + uv run pytest CH_06_decorators/exercise_01 -v + +Reference +--------- + +The decorator follows the chapter's `chaining decorators example +`_. diff --git a/CH_06_decorators/exercise_01/solution_00.py b/CH_06_decorators/exercise_01/solution_00.py index 0396998..7f4d52e 100644 --- a/CH_06_decorators/exercise_01/solution_00.py +++ b/CH_06_decorators/exercise_01/solution_00.py @@ -1,43 +1,219 @@ -# Extend the `track` function to monitor execution time. -import functools -import time -from datetime import datetime +"""Track function execution time with an injectable clock and reporter.""" +from collections.abc import Callable, Mapping +from dataclasses import dataclass +from functools import WRAPPER_ASSIGNMENTS, WRAPPER_UPDATES, wraps +from time import perf_counter +from typing import ParamSpec, TypeVar, cast, overload -def track(function=None, label=None): - # Trick to add an optional argument to our decorator - if label and not function: - return functools.partial(track, label=label) +P = ParamSpec("P") +R = TypeVar("R") +_MISSING: object = object() - print(f'initializing {label}') - @functools.wraps(function) - def _track(*args, **kwargs): - print(f'calling {label}') +@dataclass(frozen=True) +class Timing: + """The outcome and duration of one tracked function call.""" - start = datetime.now() - result = function(*args, **kwargs) - end = datetime.now() + label: str + elapsed: float + failed: bool - print(f'called {label} in {end - start}') +def print_timing(timing: Timing) -> None: + """Print a timing report.""" + status: str = "failed" if timing.failed else "completed" + print(f"{timing.label} {status} in {timing.elapsed:.6f}s") + + +def snapshot_wrapper_metadata( + function: Callable[..., object], + label: str | None, +) -> tuple[str, dict[str, object], dict[str, object]]: + """Snapshot a safe label, assigned metadata, and custom attributes.""" + + def valid_assignment(attribute: str, value: object) -> bool: + if attribute in {"__module__", "__name__", "__qualname__"}: + return isinstance(value, str) + if attribute == "__doc__": + return value is None or isinstance(value, str) + if attribute == "__annotations__": + return isinstance(value, dict) + if attribute == "__annotate__": + return value is None or callable(value) + if attribute == "__type_params__": + return isinstance(value, tuple) + return False + + def read_attribute(attribute: str) -> object: + try: + return getattr(function, attribute, _MISSING) + except Exception: + return _MISSING + + def callable_type_name() -> str: + try: + type_name: object = type.__getattribute__( + type(function), + "__name__", + ) + except Exception: + return "callable" + if isinstance(type_name, str): + return type_name + return "callable" + + runtime_assignment_attributes: list[str] = [ + str(attribute) for attribute in WRAPPER_ASSIGNMENTS + ] + managed_attributes: tuple[str, ...] = ( + "__annotations__", + "__annotate__", + "__type_params__", + ) + assignment_attributes_list: list[str] = [ + attribute + for attribute in runtime_assignment_attributes + if attribute not in managed_attributes + ] + assignment_attributes_list.extend(managed_attributes) + assignment_attributes: tuple[str, ...] = tuple(assignment_attributes_list) + reserved_attributes: set[str] = { + *assignment_attributes, + "__wrapped__", + } + + function_name: object = read_attribute("__name__") + timing_label: str + if label is not None: + timing_label = label + elif isinstance(function_name, str): + timing_label = function_name + else: + timing_label = callable_type_name() + + assigned: dict[str, object] = {} + for attribute in assignment_attributes: + value: object + if attribute == "__name__": + value = function_name + else: + value = read_attribute(attribute) + if value is not _MISSING and valid_assignment(attribute, value): + assigned[attribute] = value + + custom_attributes: dict[str, object] = {} + for attribute in WRAPPER_UPDATES: + source_updates: object = read_attribute(str(attribute)) + if not isinstance(source_updates, Mapping): + continue + source_mapping: Mapping[object, object] = cast( + Mapping[object, object], + source_updates, + ) + for key, value in source_mapping.items(): + if isinstance(key, str) and key not in reserved_attributes: + custom_attributes[key] = value + + return timing_label, assigned, custom_attributes + + +def _decorate( + function: Callable[P, R], + *, + label: str | None, + clock: Callable[[], float], + reporter: Callable[[Timing], None], +) -> Callable[P, R]: + wrapper_metadata: tuple[ + str, + dict[str, object], + dict[str, object], + ] = snapshot_wrapper_metadata(function, label) + timing_label: str = wrapper_metadata[0] + assigned_metadata: dict[str, object] = wrapper_metadata[1] + custom_attributes: dict[str, object] = wrapper_metadata[2] + + def report(started_at: float, *, failed: bool) -> None: + elapsed: float = clock() - started_at + reporter(Timing(label=timing_label, elapsed=elapsed, failed=failed)) + + @wraps(function, assigned=(), updated=()) + def tracked(*args: P.args, **kwargs: P.kwargs) -> R: + started_at: float = clock() + try: + result: R = function(*args, **kwargs) + except BaseException: + try: + report(started_at, failed=True) + except BaseException: + pass + raise + + report(started_at, failed=False) return result - return _track + tracked.__dict__.update(custom_attributes) + for attribute, value in assigned_metadata.items(): + setattr(tracked, attribute, value) + return tracked + + +@overload +def track( + function: Callable[P, R], + /, + *, + label: str | None = None, + clock: Callable[[], float] = perf_counter, + reporter: Callable[[Timing], None] = print_timing, +) -> Callable[P, R]: ... + + +@overload +def track( + *, + label: str | None = None, + clock: Callable[[], float] = perf_counter, + reporter: Callable[[Timing], None] = print_timing, +) -> Callable[[Callable[P, R]], Callable[P, R]]: ... + + +def track( + function: Callable[P, R] | None = None, + /, + *, + label: str | None = None, + clock: Callable[[], float] = perf_counter, + reporter: Callable[[Timing], None] = print_timing, +) -> Callable[P, R] | Callable[[Callable[P, R]], Callable[P, R]]: + """Decorate a function and report each call's duration and outcome.""" + + if function is not None: + return _decorate( + function, + label=label, + clock=clock, + reporter=reporter, + ) + + def decorate(target: Callable[P, R]) -> Callable[P, R]: + return _decorate( + target, + label=label, + clock=clock, + reporter=reporter, + ) + return decorate -@track(label='outer') -@track(label='inner') -def func(): - print('func') +if __name__ == "__main__": -@track(label='Slow function') -def slower_func(): - print('slower_func') - time.sleep(0.5) + @track(label="slow function") + def slow_function() -> None: + from time import sleep + sleep(0.5) -if __name__ == '__main__': - func() - slower_func() + slow_function() diff --git a/CH_06_decorators/exercise_01/test_solution_00.py b/CH_06_decorators/exercise_01/test_solution_00.py new file mode 100644 index 0000000..45fa011 --- /dev/null +++ b/CH_06_decorators/exercise_01/test_solution_00.py @@ -0,0 +1,632 @@ +from collections.abc import Callable, Iterator +from functools import partial +from inspect import signature + +import pytest + +from .solution_00 import Timing, print_timing, track + + +def test_configured_track_reports_exact_timing_on_success() -> None: + readings: Iterator[float] = iter((10.0, 10.25)) + reports: list[Timing] = [] + + @track(label="addition", clock=readings.__next__, reporter=reports.append) + def add(left: int, right: int) -> int: + return left + right + + result: int = add(2, 3) + + assert result == 5 + assert reports == [Timing(label="addition", elapsed=0.25, failed=False)] + + +def test_track_reports_failure_and_propagates_original_exception() -> None: + readings: Iterator[float] = iter((3.0, 4.5)) + reports: list[Timing] = [] + error: RuntimeError = RuntimeError("boom") + + @track(label="explosion", clock=readings.__next__, reporter=reports.append) + def explode() -> None: + raise error + + with pytest.raises(RuntimeError) as caught: + explode() + + assert caught.value is error + assert reports == [Timing(label="explosion", elapsed=1.5, failed=True)] + + +def test_reporter_failure_cannot_mask_target_exception() -> None: + readings: Iterator[float] = iter((3.0, 4.5)) + reports: list[Timing] = [] + target_error: RuntimeError = RuntimeError("target failed") + reporter_error: ValueError = ValueError("reporter failed") + + def failing_reporter(timing: Timing) -> None: + reports.append(timing) + raise reporter_error + + @track( + label="explosion", + clock=readings.__next__, + reporter=failing_reporter, + ) + def explode() -> None: + raise target_error + + with pytest.raises(RuntimeError) as caught: + explode() + + assert caught.value is target_error + assert reports == [Timing(label="explosion", elapsed=1.5, failed=True)] + + +def test_ending_clock_failure_cannot_mask_target_exception() -> None: + clock_calls: list[int] = [] + reports: list[Timing] = [] + target_error: RuntimeError = RuntimeError("target failed") + clock_error: ValueError = ValueError("clock failed") + + def failing_ending_clock() -> float: + clock_calls.append(len(clock_calls) + 1) + if len(clock_calls) == 1: + return 3.0 + raise clock_error + + @track(clock=failing_ending_clock, reporter=reports.append) + def explode() -> None: + raise target_error + + with pytest.raises(RuntimeError) as caught: + explode() + + assert caught.value is target_error + assert clock_calls == [1, 2] + assert reports == [] + + +def test_starting_clock_failure_prevents_target_execution() -> None: + target_calls: list[None] = [] + clock_error: ValueError = ValueError("clock failed") + + def failing_starting_clock() -> float: + raise clock_error + + @track(clock=failing_starting_clock) + def target() -> None: + target_calls.append(None) + + with pytest.raises(ValueError) as caught: + target() + + assert caught.value is clock_error + assert target_calls == [] + + +def test_reporter_failure_after_success_propagates() -> None: + readings: Iterator[float] = iter((3.0, 4.5)) + reporter_error: ValueError = ValueError("reporter failed") + + def failing_reporter(timing: Timing) -> None: + raise reporter_error + + @track(clock=readings.__next__, reporter=failing_reporter) + def target() -> str: + return "result" + + with pytest.raises(ValueError) as caught: + target() + + assert caught.value is reporter_error + + +def test_ending_clock_failure_after_success_propagates() -> None: + clock_calls: list[int] = [] + clock_error: ValueError = ValueError("clock failed") + + def failing_ending_clock() -> float: + clock_calls.append(len(clock_calls) + 1) + if len(clock_calls) == 1: + return 3.0 + raise clock_error + + @track(clock=failing_ending_clock) + def target() -> str: + return "result" + + with pytest.raises(ValueError) as caught: + target() + + assert caught.value is clock_error + assert clock_calls == [1, 2] + + +def test_track_can_be_used_directly( + capsys: pytest.CaptureFixture[str], +) -> None: + @track + def answer() -> int: + return 42 + + result: int = answer() + output: str = capsys.readouterr().out + + assert result == 42 + assert output.startswith("answer completed in ") + assert output.endswith("s\n") + + +def test_track_accepts_direct_call_with_configuration() -> None: + readings: Iterator[float] = iter((10.0, 10.25)) + reports: list[Timing] = [] + + def add(left: int, right: int) -> int: + return left + right + + tracked_add: Callable[[int, int], int] = track( + add, + label="addition", + clock=readings.__next__, + reporter=reports.append, + ) + + assert tracked_add(2, 3) == 5 + assert reports == [Timing(label="addition", elapsed=0.25, failed=False)] + + +def test_track_uses_function_name_as_default_label() -> None: + readings: Iterator[float] = iter((7.0, 9.0)) + reports: list[Timing] = [] + + @track(clock=readings.__next__, reporter=reports.append) + def calculate() -> str: + return "done" + + calculate() + + assert reports == [Timing(label="calculate", elapsed=2.0, failed=False)] + + +def test_track_uses_callable_type_as_default_label_without_name() -> None: + readings: Iterator[float] = iter((7.0, 9.0)) + reports: list[Timing] = [] + + def add(left: int, right: int) -> int: + return left + right + + add_ten: Callable[[int], int] = partial(add, 10) + tracked_add_ten: Callable[[int], int] = track( + add_ten, + clock=readings.__next__, + reporter=reports.append, + ) + + result: int = tracked_add_ten(5) + + assert result == 15 + assert reports == [Timing(label="partial", elapsed=2.0, failed=False)] + assert getattr(tracked_add_ten, "__wrapped__", None) is add_ten + assert signature(tracked_add_ten) == signature(add_ten) + + +def test_track_uses_callable_type_when_name_is_not_a_string() -> None: + readings: Iterator[float] = iter((7.0, 9.0)) + reports: list[Timing] = [] + + class Increment: + __name__: int = 42 + + def __call__(self, value: int) -> int: + return value + 1 + + increment: Increment = Increment() + tracked_increment: Callable[[int], int] = track( + increment, + clock=readings.__next__, + reporter=reports.append, + ) + + result: int = tracked_increment(5) + + assert result == 6 + assert reports == [Timing(label="Increment", elapsed=2.0, failed=False)] + assert getattr(tracked_increment, "__wrapped__", None) is increment + assert signature(tracked_increment) == signature(increment) + + +def test_explicit_label_catches_raising_name_getter_once() -> None: + readings: Iterator[float] = iter((7.0, 9.0)) + reports: list[Timing] = [] + + class Increment: + def __init__(self) -> None: + self.name_reads: int = 0 + + @property + def __name__(self) -> str: + self.name_reads += 1 + raise RuntimeError("name unavailable") + + def __call__(self, value: int) -> int: + return value + 1 + + increment: Increment = Increment() + tracked_increment: Callable[[int], int] = track( + increment, + label="increment", + clock=readings.__next__, + reporter=reports.append, + ) + result: int = tracked_increment(5) + + assert result == 6 + assert reports == [Timing(label="increment", elapsed=2.0, failed=False)] + assert increment.name_reads == 1 + assert getattr(tracked_increment, "__wrapped__", None) is increment + + +def test_explicit_label_preserves_dynamic_name_from_one_read() -> None: + readings: Iterator[float] = iter((7.0, 9.0)) + reports: list[Timing] = [] + + class Increment: + def __init__(self) -> None: + self.name_reads: int = 0 + + @property + def __name__(self) -> str: + self.name_reads += 1 + return "increment" + + def __call__(self, value: int) -> int: + return value + 1 + + increment: Increment = Increment() + tracked_increment: Callable[[int], int] = track( + increment, + label="explicit", + clock=readings.__next__, + reporter=reports.append, + ) + result: int = tracked_increment(5) + + assert result == 6 + assert reports == [Timing(label="explicit", elapsed=2.0, failed=False)] + assert getattr(tracked_increment, "__name__", None) == "increment" + assert increment.name_reads == 1 + assert getattr(tracked_increment, "__wrapped__", None) is increment + assert signature(tracked_increment) == signature(increment) + + +def test_explicit_label_preserves_bound_method_name() -> None: + readings: Iterator[float] = iter((7.0, 9.0)) + reports: list[Timing] = [] + + class Incrementer: + def increment(self, value: int) -> int: + return value + 1 + + incrementer: Incrementer = Incrementer() + increment: Callable[[int], int] = incrementer.increment + tracked_increment: Callable[[int], int] = track( + increment, + label="explicit", + clock=readings.__next__, + reporter=reports.append, + ) + result: int = tracked_increment(5) + + assert result == 6 + assert reports == [Timing(label="explicit", elapsed=2.0, failed=False)] + assert getattr(tracked_increment, "__name__", None) == "increment" + assert getattr(tracked_increment, "__wrapped__", None) is increment + assert signature(tracked_increment) == signature(increment) + + +def test_default_label_falls_back_when_name_getter_raises() -> None: + readings: Iterator[float] = iter((7.0, 9.0)) + reports: list[Timing] = [] + + class Increment: + def __init__(self) -> None: + self.name_reads: int = 0 + + @property + def __name__(self) -> str: + self.name_reads += 1 + raise RuntimeError("name unavailable") + + def __call__(self, value: int) -> int: + return value + 1 + + increment: Increment = Increment() + tracked_increment: Callable[[int], int] = track( + increment, + clock=readings.__next__, + reporter=reports.append, + ) + result: int = tracked_increment(5) + + assert result == 6 + assert reports == [Timing(label="Increment", elapsed=2.0, failed=False)] + assert increment.name_reads == 1 + assert getattr(tracked_increment, "__wrapped__", None) is increment + + +def test_changing_name_getter_is_snapshotted_once() -> None: + readings: Iterator[float] = iter((7.0, 9.0)) + reports: list[Timing] = [] + + class Increment: + def __init__(self) -> None: + self.name_reads: int = 0 + + @property + def __name__(self) -> object: + self.name_reads += 1 + if self.name_reads <= 2: + return "increment" + return 42 + + def __call__(self, value: int) -> int: + return value + 1 + + increment: Increment = Increment() + tracked_increment: Callable[[int], int] = track( + increment, + clock=readings.__next__, + reporter=reports.append, + ) + result: int = tracked_increment(5) + + assert result == 6 + assert reports == [Timing(label="increment", elapsed=2.0, failed=False)] + assert getattr(tracked_increment, "__name__", None) == "increment" + assert increment.name_reads == 1 + assert getattr(tracked_increment, "__wrapped__", None) is increment + assert signature(tracked_increment) == signature(increment) + + +def test_default_label_bypasses_hostile_metaclass_name_getter() -> None: + readings: Iterator[float] = iter((7.0, 9.0)) + reports: list[Timing] = [] + + class HostileType(type): + def __getattribute__(self, attribute: str) -> object: + if attribute == "__name__": + raise RuntimeError("type name unavailable") + return super().__getattribute__(attribute) + + class Increment(metaclass=HostileType): + def __call__(self, value: int) -> int: + return value + 1 + + increment: Increment = Increment() + tracked_increment: Callable[[int], int] = track( + increment, + clock=readings.__next__, + reporter=reports.append, + ) + result: int = tracked_increment(5) + + assert result == 6 + assert reports == [Timing(label="Increment", elapsed=2.0, failed=False)] + assert getattr(tracked_increment, "__wrapped__", None) is increment + + +def test_track_ignores_non_string_qualname_metadata() -> None: + readings: Iterator[float] = iter((7.0, 9.0)) + reports: list[Timing] = [] + + class Increment: + def __call__(self, value: int) -> int: + return value + 1 + + increment: Increment = Increment() + increment.__dict__["__qualname__"] = 42 + + tracked_increment: Callable[[int], int] = track( + increment, + clock=readings.__next__, + reporter=reports.append, + ) + result: int = tracked_increment(5) + + assert result == 6 + assert reports == [Timing(label="Increment", elapsed=2.0, failed=False)] + assert getattr(tracked_increment, "__wrapped__", None) is increment + assert signature(tracked_increment) == signature(increment) + + +def test_track_ignores_non_dict_annotations_with_explicit_label() -> None: + readings: Iterator[float] = iter((7.0, 9.0)) + reports: list[Timing] = [] + + class Increment: + def __call__(self, value: int) -> int: + return value + 1 + + increment: Increment = Increment() + annotations_attribute: str = "__annotations__" + setattr(increment, annotations_attribute, 42) + increment.__dict__["category"] = "kept" + + tracked_increment: Callable[[int], int] = track( + increment, + label="increment", + clock=readings.__next__, + reporter=reports.append, + ) + result: int = tracked_increment(5) + + assert result == 6 + assert reports == [Timing(label="increment", elapsed=2.0, failed=False)] + assert getattr(tracked_increment, "category", None) == "kept" + assert getattr(tracked_increment, "__wrapped__", None) is increment + assert signature(tracked_increment) == signature(increment) + + +def test_track_ignores_non_mapping_wrapper_updates() -> None: + readings: Iterator[float] = iter((7.0, 9.0)) + reports: list[Timing] = [] + + class Increment: + def __getattribute__(self, attribute: str) -> object: + if attribute == "__dict__": + return 42 + return super().__getattribute__(attribute) + + def __call__(self, value: int) -> int: + return value + 1 + + increment: Increment = Increment() + tracked_increment: Callable[[int], int] = track( + increment, + clock=readings.__next__, + reporter=reports.append, + ) + result: int = tracked_increment(5) + + assert result == 6 + assert reports == [Timing(label="Increment", elapsed=2.0, failed=False)] + assert getattr(tracked_increment, "__wrapped__", None) is increment + assert signature(tracked_increment) == signature(increment) + + +def test_track_preserves_valid_type_params_metadata() -> None: + readings: Iterator[float] = iter((7.0, 9.0)) + reports: list[Timing] = [] + + type_parameters: tuple[object, ...] = (int,) + + class Increment: + __slots__: tuple[str, ...] = ("__type_params__",) + + def __init__(self) -> None: + self.__type_params__: tuple[object, ...] = type_parameters + + def __call__(self, value: int) -> int: + return value + 1 + + increment: Increment = Increment() + + tracked_increment: Callable[[int], int] = track( + increment, + clock=readings.__next__, + reporter=reports.append, + ) + result: int = tracked_increment(5) + + assert result == 6 + assert reports == [Timing(label="Increment", elapsed=2.0, failed=False)] + assert getattr(tracked_increment, "__type_params__", None) == type_parameters + assert getattr(tracked_increment, "__wrapped__", None) is increment + assert signature(tracked_increment) == signature(increment) + + +def test_track_preserves_annotate_attribute_presence() -> None: + def increment(value: int) -> int: + return value + 1 + + tracked_increment: Callable[[int], int] = track(increment) + + assert hasattr(tracked_increment, "__annotate__") is hasattr( + increment, + "__annotate__", + ) + + +def test_track_preserves_valid_annotate_metadata() -> None: + def annotate(format_value: int) -> dict[str, object]: + return {"format": format_value} + + annotate_values: tuple[Callable[[int], dict[str, object]] | None, ...] = ( + annotate, + None, + ) + + class Increment: + __slots__: tuple[str, ...] = ("__annotate__",) + + def __init__( + self, + annotate_value: Callable[[int], dict[str, object]] | None, + ) -> None: + self.__annotate__: Callable[[int], dict[str, object]] | None = ( + annotate_value + ) + + def __call__(self, value: int) -> int: + return value + 1 + + for annotate_value in annotate_values: + increment: Increment = Increment(annotate_value) + tracked_increment: Callable[[int], int] = track(increment) + + assert getattr(tracked_increment, "__annotate__", 42) is annotate_value + assert getattr(tracked_increment, "__wrapped__", None) is increment + assert signature(tracked_increment) == signature(increment) + + +def test_track_forwards_positional_and_keyword_arguments() -> None: + readings: Iterator[float] = iter((1.0, 2.0)) + reports: list[Timing] = [] + + @track(clock=readings.__next__, reporter=reports.append) + def describe(number: int, *, prefix: str) -> str: + return f"{prefix}: {number}" + + result: str = describe(7, prefix="value") + + assert result == "value: 7" + + +def test_track_preserves_function_metadata_and_signature() -> None: + readings: Iterator[float] = iter((1.0, 2.0)) + reports: list[Timing] = [] + + def greet(name: str, punctuation: str = "!") -> str: + """Return a greeting.""" + return f"Hello, {name}{punctuation}" + + greet.__dict__["category"] = "salutation" + greet_annotate: object = getattr(greet, "__annotate__", None) + tracked_greet: Callable[[str, str], str] = track( + clock=readings.__next__, + reporter=reports.append, + )(greet) + + assert tracked_greet.__name__ == "greet" + assert tracked_greet.__module__ == greet.__module__ + assert tracked_greet.__qualname__ == greet.__qualname__ + assert tracked_greet.__doc__ == "Return a greeting." + assert tracked_greet.__annotations__ == greet.__annotations__ + if callable(greet_annotate): + assert getattr(tracked_greet, "__annotate__", None) is greet_annotate + assert getattr(tracked_greet, "__wrapped__", None) is greet + assert signature(tracked_greet) == signature(greet) + assert getattr(tracked_greet, "category", None) == "salutation" + + +@pytest.mark.parametrize( + ("timing", "expected"), + ( + ( + Timing(label="lookup", elapsed=0.125, failed=False), + "lookup completed in 0.125000s\n", + ), + ( + Timing(label="lookup", elapsed=0.125, failed=True), + "lookup failed in 0.125000s\n", + ), + ), +) +def test_print_timing_uses_default_format( + timing: Timing, + expected: str, + capsys: pytest.CaptureFixture[str], +) -> None: + print_timing(timing) + + assert capsys.readouterr().out == expected diff --git a/CH_06_decorators/exercise_02/README.rst b/CH_06_decorators/exercise_02/README.rst new file mode 100644 index 0000000..669aa84 --- /dev/null +++ b/CH_06_decorators/exercise_02/README.rst @@ -0,0 +1,68 @@ +Exercise 2 +========== + +Question +-------- + +.. code-block:: text + + 2. Extend the `track` function with min/max/average execution time and call count. + +Solution +-------- + +``track`` supports a bare decorator (``@track``), a configured decorator +(``@track(label=..., clock=..., reporter=...)``), and a configured direct call +(``track(function, label=..., clock=..., reporter=...)``). Each decorated +function has its own mutable ``TimingStats`` instance at ``function.stats`` and +a zero-argument ``function.print_stats()`` method. The aggregates use seconds: + +An explicit ``label`` always wins. Otherwise, the default label is a string +``function.__name__`` when available, or ``type(function).__name__`` for +callables such as ``functools.partial`` instances. + +* ``count`` is the number of calls with a completed duration. +* ``total`` is the sum of those durations. +* ``minimum`` and ``maximum`` are the shortest and longest durations. +* ``average`` is ``total / count``. + +A failed target call is included when the ending clock succeeds. Empty +statistics have ``minimum``, ``maximum``, and ``average`` set to ``None``. +``print_stats()`` renders them as ``n/a`` and renders every duration in seconds +to six decimal places. Its exact empty output is: + +.. code-block:: text + + lookup stats: count=0, total=0.000000s, minimum=n/a, maximum=n/a, average=n/a + +The clock and reporter are injectable. Tests use deterministic clock values; +they do not sleep. A starting-clock failure prevents the target call. An +ending-clock failure produces no duration or statistics. After a successful +target call, ending-clock and reporter failures propagate. After a target +failure, instrumentation is attempted, but an ending-clock or reporter failure +cannot replace the original target exception. A completed duration updates +statistics before exactly one ``Timing`` is sent to the reporter. + +Dependencies +------------ + +The solution uses only the Python standard library and requires Python 3.10 or +newer. Tests use the repository's ``pytest`` development dependency. + +Run the deterministic, guarded demonstration: + +.. code-block:: console + + uv run python -m CH_06_decorators.exercise_02.solution_00 + +Run the focused tests: + +.. code-block:: console + + uv run pytest -W error CH_06_decorators/exercise_02 -v + +Reference +--------- + +The decorator extends the chapter's `chaining decorators example +`_. diff --git a/CH_06_decorators/exercise_02/solution_00.py b/CH_06_decorators/exercise_02/solution_00.py index ebd6f66..d1ecefe 100644 --- a/CH_06_decorators/exercise_02/solution_00.py +++ b/CH_06_decorators/exercise_02/solution_00.py @@ -1,61 +1,187 @@ -# Extend the `track` function with min/max/average execution time and call -# count. -import functools -import random -import time -from datetime import datetime, timedelta - - -def track(function=None, label=None): - # Trick to add an optional argument to our decorator - if label and not function: - return functools.partial(track, label=label) - - execution_times = dict( - min=timedelta.max, - max=timedelta.min, - total=timedelta(), - count=0, - ) - - print(f'initializing {label}') - - @functools.wraps(function) - def _track(*args, **kwargs): - print(f'calling {label}') - - start = datetime.now() - result = function(*args, **kwargs) - end = datetime.now() - - duration = end - start - execution_times['min'] = min(execution_times['min'], duration) - execution_times['max'] = max(execution_times['max'], duration) - execution_times['total'] += duration - execution_times['count'] += 1 - - print(f'called {label} in {duration}') +"""Track call timing and aggregate per-decorator statistics.""" + +from collections.abc import Callable, Iterator +from dataclasses import dataclass +from functools import wraps +from time import perf_counter +from typing import ParamSpec, Protocol, TypeVar, cast, overload + +from CH_06_decorators.exercise_01.solution_00 import ( + Timing, + print_timing, + snapshot_wrapper_metadata, +) + +P = ParamSpec("P") +R = TypeVar("R") +R_co = TypeVar("R_co", covariant=True) + + +@dataclass +class TimingStats: + """Mutable aggregate execution-time statistics in seconds.""" + + count: int = 0 + total: float = 0.0 + minimum: float | None = None + maximum: float | None = None + + @property + def average(self) -> float | None: + """Return mean elapsed seconds, or ``None`` before the first record.""" + if self.count == 0: + return None + return self.total / self.count + + def record(self, elapsed: float) -> None: + """Add one completed duration to the aggregates.""" + self.count += 1 + self.total += elapsed + if self.minimum is None or elapsed < self.minimum: + self.minimum = elapsed + if self.maximum is None or elapsed > self.maximum: + self.maximum = elapsed + + +class TrackedCallable(Protocol[P, R_co]): + """Callable carrying mutable timing statistics and a printer.""" + + stats: TimingStats + + def __call__(self, *args: P.args, **kwargs: P.kwargs) -> R_co: + """Call the wrapped target.""" + ... + + print_stats: Callable[[], None] + + +def _format_duration(duration: float | None) -> str: + if duration is None: + return "n/a" + return f"{duration:.6f}s" + + +def _decorate( + function: Callable[P, R], + *, + label: str | None, + clock: Callable[[], float], + reporter: Callable[[Timing], None], +) -> TrackedCallable[P, R]: + stats: TimingStats = TimingStats() + wrapper_metadata: tuple[ + str, + dict[str, object], + dict[str, object], + ] = snapshot_wrapper_metadata(function, label) + timing_label: str = wrapper_metadata[0] + assigned_metadata: dict[str, object] = wrapper_metadata[1] + custom_attributes: dict[str, object] = wrapper_metadata[2] + + def finish(started_at: float, *, failed: bool) -> None: + elapsed: float = clock() - started_at + timing: Timing = Timing( + label=timing_label, + elapsed=elapsed, + failed=failed, + ) + stats.record(elapsed) + reporter(timing) + + @wraps(function, assigned=(), updated=()) + def tracked(*args: P.args, **kwargs: P.kwargs) -> R: + started_at: float = clock() + try: + result: R = function(*args, **kwargs) + except BaseException: + try: + finish(started_at, failed=True) + except BaseException: + pass + raise + + finish(started_at, failed=False) return result - def print_stats(): - print(f'{label} stats:') - print(f' min: {execution_times["min"]}') - print(f' max: {execution_times["max"]}') - print(f' total: {execution_times["total"]}') - print(f' avg: {execution_times["total"] / execution_times["count"]}') - - _track.print_stats = print_stats - - return _track - - -@track(label='random sleep') -def random_sleep(): - time.sleep(random.random()) - - -if __name__ == '__main__': - for i in range(10): - random_sleep() - - random_sleep.print_stats() + tracked.__dict__.update(custom_attributes) + for attribute, value in assigned_metadata.items(): + setattr(tracked, attribute, value) + + def print_stats() -> None: + print( + f"{timing_label} stats: count={stats.count}, " + f"total={stats.total:.6f}s, " + f"minimum={_format_duration(stats.minimum)}, " + f"maximum={_format_duration(stats.maximum)}, " + f"average={_format_duration(stats.average)}" + ) + + tracked_callable: TrackedCallable[P, R] = cast( + TrackedCallable[P, R], + tracked, + ) + tracked_callable.stats = stats + tracked_callable.print_stats = print_stats + return tracked_callable + + +@overload +def track( + function: Callable[P, R], + /, + *, + label: str | None = None, + clock: Callable[[], float] = perf_counter, + reporter: Callable[[Timing], None] = print_timing, +) -> TrackedCallable[P, R]: ... + + +@overload +def track( + function: None = None, + /, + *, + label: str | None = None, + clock: Callable[[], float] = perf_counter, + reporter: Callable[[Timing], None] = print_timing, +) -> Callable[[Callable[P, R]], TrackedCallable[P, R]]: ... + + +def track( + function: Callable[P, R] | None = None, + /, + *, + label: str | None = None, + clock: Callable[[], float] = perf_counter, + reporter: Callable[[Timing], None] = print_timing, +) -> TrackedCallable[P, R] | Callable[[Callable[P, R]], TrackedCallable[P, R]]: + """Decorate a function and aggregate every completed call duration.""" + if function is not None: + return _decorate( + function, + label=label, + clock=clock, + reporter=reporter, + ) + + def decorate(target: Callable[P, R]) -> TrackedCallable[P, R]: + return _decorate( + target, + label=label, + clock=clock, + reporter=reporter, + ) + + return decorate + + +if __name__ == "__main__": + readings: Iterator[float] = iter((10.0, 10.25, 20.0, 21.5)) + + @track(label="example", clock=readings.__next__) + def example(value: int) -> int: + return value * 2 + + example(3) + example(4) + example.print_stats() diff --git a/CH_06_decorators/exercise_02/test_solution_00.py b/CH_06_decorators/exercise_02/test_solution_00.py new file mode 100644 index 0000000..f3cf53a --- /dev/null +++ b/CH_06_decorators/exercise_02/test_solution_00.py @@ -0,0 +1,586 @@ +from collections.abc import Callable, Iterator +from functools import partial +from inspect import signature + +import pytest + +from CH_06_decorators.exercise_01.solution_00 import Timing + +from .solution_00 import TimingStats, TrackedCallable, track + + +def test_bare_track_decorator_uses_defaults_and_attaches_stats( + capsys: pytest.CaptureFixture[str], +) -> None: + @track + def increment(value: int) -> int: + return value + 1 + + assert increment(4) == 5 + assert increment.stats.count == 1 + assert increment.stats.total >= 0.0 + assert increment.stats.minimum == increment.stats.total + assert increment.stats.maximum == increment.stats.total + assert increment.stats.average == increment.stats.total + + output: str = capsys.readouterr().out + prefix: str = "increment completed in " + assert output.startswith(prefix) + assert output.endswith("s\n") + rendered_duration: str = output.removeprefix(prefix).removesuffix("s\n") + assert len(rendered_duration.partition(".")[2]) == 6 + assert rendered_duration == f"{increment.stats.total:.6f}" + + +def test_stats_are_initially_empty_and_print_as_documented( + capsys: pytest.CaptureFixture[str], +) -> None: + @track(label="lookup") + def lookup() -> None: + raise AssertionError("the decorated function must not run") + + assert lookup.stats == TimingStats() + assert lookup.stats.average is None + + lookup.print_stats() + + assert capsys.readouterr().out == ( + "lookup stats: count=0, total=0.000000s, minimum=n/a, " + "maximum=n/a, average=n/a\n" + ) + + +def test_two_calls_record_exact_aggregates_and_reports( + capsys: pytest.CaptureFixture[str], +) -> None: + readings: Iterator[float] = iter((10.0, 10.25, 20.0, 21.5)) + reports: list[Timing] = [] + + @track(label="lookup", clock=readings.__next__, reporter=reports.append) + def lookup(value: int) -> int: + return value * 2 + + assert lookup(3) == 6 + assert lookup(4) == 8 + assert lookup.stats == TimingStats( + count=2, + total=1.75, + minimum=0.25, + maximum=1.5, + ) + assert lookup.stats.average == 0.875 + assert reports == [ + Timing(label="lookup", elapsed=0.25, failed=False), + Timing(label="lookup", elapsed=1.5, failed=False), + ] + + lookup.print_stats() + + assert capsys.readouterr().out == ( + "lookup stats: count=2, total=1.750000s, minimum=0.250000s, " + "maximum=1.500000s, average=0.875000s\n" + ) + + +def test_failed_target_is_counted_reported_and_propagated() -> None: + readings: Iterator[float] = iter((3.0, 4.5)) + reports: list[Timing] = [] + error: RuntimeError = RuntimeError("boom") + + @track(label="explosion", clock=readings.__next__, reporter=reports.append) + def explode() -> None: + raise error + + with pytest.raises(RuntimeError) as caught: + explode() + + assert caught.value is error + assert explode.stats == TimingStats( + count=1, + total=1.5, + minimum=1.5, + maximum=1.5, + ) + assert reports == [Timing(label="explosion", elapsed=1.5, failed=True)] + + +def test_decorator_instances_have_independent_stats() -> None: + first_readings: Iterator[float] = iter((0.0, 1.0)) + second_readings: Iterator[float] = iter((5.0, 7.0)) + + @track(clock=first_readings.__next__, reporter=lambda timing: None) + def first() -> None: + pass + + @track(clock=second_readings.__next__, reporter=lambda timing: None) + def second() -> None: + pass + + first() + + assert first.stats == TimingStats(1, 1.0, 1.0, 1.0) + assert second.stats == TimingStats() + assert first.stats is not second.stats + + +def test_direct_form_accepts_configuration_and_exposes_typed_protocol() -> None: + readings: Iterator[float] = iter((10.0, 10.25)) + reports: list[Timing] = [] + + def add(left: int, right: int) -> int: + return left + right + + tracked_add: TrackedCallable[[int, int], int] = track( + add, + label="addition", + clock=readings.__next__, + reporter=reports.append, + ) + + assert tracked_add(2, 3) == 5 + assert tracked_add.stats.count == 1 + assert reports == [Timing(label="addition", elapsed=0.25, failed=False)] + + +def test_configured_form_preserves_arguments_result_metadata_and_signature() -> None: + readings: Iterator[float] = iter((1.0, 2.0)) + reports: list[Timing] = [] + + def greet(name: str, *, punctuation: str = "!") -> str: + """Return a greeting.""" + return f"Hello, {name}{punctuation}" + + type_parameters: tuple[object, ...] = (str,) + greet.__dict__["__type_params__"] = type_parameters + greet.__dict__["category"] = "salutation" + greet_annotate: object = getattr(greet, "__annotate__", None) + greet_type_parameters: object = getattr(greet, "__type_params__", None) + tracked_greet: TrackedCallable[..., str] = track( + label="greeting", + clock=readings.__next__, + reporter=reports.append, + )(greet) + + assert tracked_greet("Ada", punctuation="?") == "Hello, Ada?" + assert getattr(tracked_greet, "__name__", None) == "greet" + assert getattr(tracked_greet, "__module__", None) == greet.__module__ + assert getattr(tracked_greet, "__qualname__", None) == greet.__qualname__ + assert getattr(tracked_greet, "__doc__", None) == "Return a greeting." + assert getattr(tracked_greet, "__annotations__", None) == greet.__annotations__ + assert hasattr(tracked_greet, "__annotate__") is hasattr(greet, "__annotate__") + if callable(greet_annotate): + assert getattr(tracked_greet, "__annotate__", None) is greet_annotate + assert getattr(tracked_greet, "__type_params__", None) == greet_type_parameters + assert getattr(tracked_greet, "category", None) == "salutation" + assert getattr(tracked_greet, "__wrapped__", None) is greet + assert signature(tracked_greet) == signature(greet) + + +def test_default_label_is_the_function_name() -> None: + readings: Iterator[float] = iter((7.0, 9.0)) + reports: list[Timing] = [] + + @track(clock=readings.__next__, reporter=reports.append) + def calculate() -> str: + return "done" + + calculate() + + assert reports == [Timing(label="calculate", elapsed=2.0, failed=False)] + + +def test_default_label_falls_back_to_callable_type_name( + capsys: pytest.CaptureFixture[str], +) -> None: + readings: Iterator[float] = iter((7.0, 9.0)) + reports: list[Timing] = [] + + def multiply(left: int, right: int) -> int: + return left * right + + double: Callable[[int], int] = partial(multiply, 2) + tracked_double: TrackedCallable[[int], int] = track( + double, + clock=readings.__next__, + reporter=reports.append, + ) + + result: int = tracked_double(5) + tracked_double.print_stats() + + assert result == 10 + assert tracked_double.stats == TimingStats(1, 2.0, 2.0, 2.0) + assert reports == [Timing(label="partial", elapsed=2.0, failed=False)] + assert getattr(tracked_double, "__wrapped__", None) is double + assert signature(tracked_double) == signature(double) + assert capsys.readouterr().out == ( + "partial stats: count=1, total=2.000000s, minimum=2.000000s, " + "maximum=2.000000s, average=2.000000s\n" + ) + + +def test_explicit_label_wins_for_callable_with_non_string_name() -> None: + readings: Iterator[float] = iter((7.0, 9.0)) + reports: list[Timing] = [] + + class Increment: + __name__: int = 42 + + def __call__(self, value: int) -> int: + return value + 1 + + increment: Increment = Increment() + tracked_increment: TrackedCallable[[int], int] = track( + increment, + label="increment", + clock=readings.__next__, + reporter=reports.append, + ) + + result: int = tracked_increment(5) + + assert result == 6 + assert tracked_increment.stats == TimingStats(1, 2.0, 2.0, 2.0) + assert reports == [Timing(label="increment", elapsed=2.0, failed=False)] + assert getattr(tracked_increment, "__wrapped__", None) is increment + assert signature(tracked_increment) == signature(increment) + + +def test_explicit_label_preserves_builtin_name_and_stats() -> None: + readings: Iterator[float] = iter((7.0, 9.0)) + reports: list[Timing] = [] + + length: Callable[[str], int] = len + tracked_length: TrackedCallable[[str], int] = track( + length, + label="length", + clock=readings.__next__, + reporter=reports.append, + ) + result: int = tracked_length("hello") + + assert result == 5 + assert tracked_length.stats == TimingStats(1, 2.0, 2.0, 2.0) + assert reports == [Timing(label="length", elapsed=2.0, failed=False)] + assert getattr(tracked_length, "__name__", None) == "len" + assert getattr(tracked_length, "__wrapped__", None) is length + assert signature(tracked_length) == signature(length) + + +def test_changing_name_getter_inherits_snapshot_and_stats() -> None: + readings: Iterator[float] = iter((7.0, 9.0)) + reports: list[Timing] = [] + + class Increment: + def __init__(self) -> None: + self.name_reads: int = 0 + + @property + def __name__(self) -> object: + self.name_reads += 1 + if self.name_reads <= 2: + return "increment" + return 42 + + def __call__(self, value: int) -> int: + return value + 1 + + increment: Increment = Increment() + tracked_increment: TrackedCallable[[int], int] = track( + increment, + clock=readings.__next__, + reporter=reports.append, + ) + result: int = tracked_increment(5) + + assert result == 6 + assert tracked_increment.stats == TimingStats(1, 2.0, 2.0, 2.0) + assert reports == [Timing(label="increment", elapsed=2.0, failed=False)] + assert getattr(tracked_increment, "__name__", None) == "increment" + assert increment.name_reads == 1 + assert getattr(tracked_increment, "__wrapped__", None) is increment + assert signature(tracked_increment) == signature(increment) + + +def test_hostile_metaclass_type_name_inherits_safe_fallback_and_stats() -> None: + readings: Iterator[float] = iter((7.0, 9.0)) + reports: list[Timing] = [] + + class HostileType(type): + def __getattribute__(self, attribute: str) -> object: + if attribute == "__name__": + raise RuntimeError("type name unavailable") + return super().__getattribute__(attribute) + + class Increment(metaclass=HostileType): + def __call__(self, value: int) -> int: + return value + 1 + + increment: Increment = Increment() + tracked_increment: TrackedCallable[[int], int] = track( + increment, + clock=readings.__next__, + reporter=reports.append, + ) + result: int = tracked_increment(5) + + assert result == 6 + assert tracked_increment.stats == TimingStats(1, 2.0, 2.0, 2.0) + assert reports == [Timing(label="Increment", elapsed=2.0, failed=False)] + assert getattr(tracked_increment, "__wrapped__", None) is increment + + +def test_malformed_metadata_callable_inherits_safe_wrapping() -> None: + readings: Iterator[float] = iter((7.0, 9.0)) + reports: list[Timing] = [] + + class Increment: + def __call__(self, value: int) -> int: + return value + 1 + + increment: Increment = Increment() + increment.__dict__["__qualname__"] = 42 + annotations_attribute: str = "__annotations__" + setattr(increment, annotations_attribute, 42) + increment.__dict__["category"] = "kept" + + tracked_increment: TrackedCallable[[int], int] = track( + increment, + clock=readings.__next__, + reporter=reports.append, + ) + result: int = tracked_increment(5) + + assert result == 6 + assert tracked_increment.stats == TimingStats(1, 2.0, 2.0, 2.0) + assert reports == [Timing(label="Increment", elapsed=2.0, failed=False)] + assert getattr(tracked_increment, "__qualname__", None) != 42 + assert getattr(tracked_increment, "__annotations__", None) != 42 + assert getattr(tracked_increment, "category", None) == "kept" + assert getattr(tracked_increment, "__wrapped__", None) is increment + assert signature(tracked_increment) == signature(increment) + + +@pytest.mark.parametrize( + ("attribute", "invalid_value"), + ( + ("__module__", 42), + ("__doc__", 42), + ("__annotate__", 42), + ("__type_params__", 42), + ), +) +def test_malformed_assigned_metadata_is_not_copied( + attribute: str, + invalid_value: object, +) -> None: + readings: Iterator[float] = iter((7.0, 9.0)) + reports: list[Timing] = [] + + class Increment: + def __call__(self, value: int) -> int: + return value + 1 + + increment: Increment = Increment() + increment.__dict__[attribute] = invalid_value + increment.__dict__["category"] = "kept" + + tracked_increment: TrackedCallable[[int], int] = track( + increment, + clock=readings.__next__, + reporter=reports.append, + ) + result: int = tracked_increment(5) + + assert result == 6 + assert tracked_increment.stats == TimingStats(1, 2.0, 2.0, 2.0) + assert reports == [Timing(label="Increment", elapsed=2.0, failed=False)] + assert getattr(tracked_increment, attribute, None) != invalid_value + assert getattr(tracked_increment, "category", None) == "kept" + assert getattr(tracked_increment, "__wrapped__", None) is increment + assert signature(tracked_increment) == signature(increment) + + +def test_non_mapping_wrapper_updates_are_ignored() -> None: + readings: Iterator[float] = iter((7.0, 9.0)) + reports: list[Timing] = [] + + class Increment: + def __getattribute__(self, attribute: str) -> object: + if attribute == "__dict__": + return 42 + return super().__getattribute__(attribute) + + def __call__(self, value: int) -> int: + return value + 1 + + increment: Increment = Increment() + tracked_increment: TrackedCallable[[int], int] = track( + increment, + clock=readings.__next__, + reporter=reports.append, + ) + result: int = tracked_increment(5) + + assert result == 6 + assert tracked_increment.stats == TimingStats(1, 2.0, 2.0, 2.0) + assert reports == [Timing(label="Increment", elapsed=2.0, failed=False)] + assert getattr(tracked_increment, "__wrapped__", None) is increment + assert signature(tracked_increment) == signature(increment) + + +def test_valid_type_params_inherit_shared_wrapping() -> None: + readings: Iterator[float] = iter((7.0, 9.0)) + reports: list[Timing] = [] + + type_parameters: tuple[object, ...] = (int,) + + class Increment: + __slots__: tuple[str, ...] = ("__type_params__",) + + def __init__(self) -> None: + self.__type_params__: tuple[object, ...] = type_parameters + + def __call__(self, value: int) -> int: + return value + 1 + + increment: Increment = Increment() + + tracked_increment: TrackedCallable[[int], int] = track( + increment, + clock=readings.__next__, + reporter=reports.append, + ) + result: int = tracked_increment(5) + + assert result == 6 + assert tracked_increment.stats == TimingStats(1, 2.0, 2.0, 2.0) + assert reports == [Timing(label="Increment", elapsed=2.0, failed=False)] + assert getattr(tracked_increment, "__type_params__", None) == type_parameters + assert getattr(tracked_increment, "__wrapped__", None) is increment + assert signature(tracked_increment) == signature(increment) + + +def test_reporter_failure_after_success_propagates_after_recording() -> None: + readings: Iterator[float] = iter((3.0, 4.5)) + reporter_error: ValueError = ValueError("reporter failed") + + def failing_reporter(timing: Timing) -> None: + raise reporter_error + + @track(clock=readings.__next__, reporter=failing_reporter) + def target() -> str: + return "result" + + with pytest.raises(ValueError) as caught: + target() + + assert caught.value is reporter_error + assert target.stats == TimingStats(1, 1.5, 1.5, 1.5) + + +def test_reporter_failure_cannot_mask_target_exception_and_stats_record() -> None: + readings: Iterator[float] = iter((3.0, 4.5)) + reports: list[Timing] = [] + target_error: RuntimeError = RuntimeError("target failed") + reporter_error: ValueError = ValueError("reporter failed") + + def failing_reporter(timing: Timing) -> None: + reports.append(timing) + raise reporter_error + + @track(clock=readings.__next__, reporter=failing_reporter) + def target() -> None: + raise target_error + + with pytest.raises(RuntimeError) as caught: + target() + + assert caught.value is target_error + assert target.stats == TimingStats(1, 1.5, 1.5, 1.5) + assert reports == [Timing(label="target", elapsed=1.5, failed=True)] + + +def test_starting_clock_failure_prevents_target_and_stats() -> None: + target_calls: list[None] = [] + clock_error: ValueError = ValueError("clock failed") + + def failing_clock() -> float: + raise clock_error + + @track(clock=failing_clock) + def target() -> None: + target_calls.append(None) + + with pytest.raises(ValueError) as caught: + target() + + assert caught.value is clock_error + assert target_calls == [] + assert target.stats == TimingStats() + + +@pytest.mark.parametrize("target_fails", (False, True)) +def test_ending_clock_failure_means_no_duration_or_stats( + target_fails: bool, +) -> None: + clock_calls: list[int] = [] + reports: list[Timing] = [] + target_error: RuntimeError = RuntimeError("target failed") + clock_error: ValueError = ValueError("clock failed") + + def failing_ending_clock() -> float: + clock_calls.append(len(clock_calls) + 1) + if len(clock_calls) == 1: + return 3.0 + raise clock_error + + @track(clock=failing_ending_clock, reporter=reports.append) + def target() -> str: + if target_fails: + raise target_error + return "result" + + expected_error: BaseException = target_error if target_fails else clock_error + with pytest.raises(type(expected_error)) as caught: + target() + + assert caught.value is expected_error + assert clock_calls == [1, 2] + assert target.stats == TimingStats() + assert reports == [] + + +def test_timing_stats_are_mutable_and_record_exact_aggregates() -> None: + stats: TimingStats = TimingStats() + + stats.record(2.0) + stats.record(0.5) + + assert stats == TimingStats( + count=2, + total=2.5, + minimum=0.5, + maximum=2.0, + ) + assert stats.average == 1.25 + + stats.count = 5 + stats.total = 10.0 + + assert stats.average == 2.0 + + +def test_track_return_is_assignable_to_matching_callable() -> None: + readings: Iterator[float] = iter((1.0, 2.0)) + + def identity(value: int) -> int: + return value + + tracked_identity: Callable[[int], int] = track( + identity, + clock=readings.__next__, + reporter=lambda timing: None, + ) + + assert tracked_identity(42) == 42 diff --git a/CH_06_decorators/exercise_03/README.rst b/CH_06_decorators/exercise_03/README.rst new file mode 100644 index 0000000..fc1843e --- /dev/null +++ b/CH_06_decorators/exercise_03/README.rst @@ -0,0 +1,54 @@ +Exercise 3: memoizing container arguments +========================================= + +Question +-------- + +The relevant chapter exercise-list excerpt is: + +1. Extend the `track` function to monitor execution time. +2. Extend the `track` function with min/max/average execution time and call count. +3. Modify the memoization function to function with unhashable types. + +Solution +-------- + +``memoize`` freezes arguments into immutable, type-tagged cache keys. It +supports arbitrarily nested tuples, lists, dictionaries, sets, and frozen +sets. Lists remain distinct from tuples, sets remain distinct from frozen +sets, and scalar runtime types remain distinct, including ``bool`` and +``int``. + +Dictionary and set order does not affect a key. Both dictionary keys and +values are frozen recursively. Shared references are accepted when they do +not form a cycle. Active recursion cycles raise ``ValueError("cyclic +argument")``. Other unhashable objects raise ``TypeError`` naming the +unsupported type. + +The module-level ``cache`` lives for the lifetime of the Python process. +Every decoration receives a unique opaque namespace in its cache keys, so +distinct functions and callable objects cannot reuse one another's results. +Keyword argument order does not affect a key. ``None`` is a normal cached +result. A call that raises an exception is not cached and will run again on +the next call. + +Running +------- + +Run the solution module: + +.. code-block:: console + + uv run python -m CH_06_decorators.exercise_03.solution_00 + +Run the exercise tests: + +.. code-block:: console + + uv run pytest -W error CH_06_decorators/exercise_03 -v + +Reference +--------- + +The solution extends the chapter's `memoization example +`_. diff --git a/CH_06_decorators/exercise_03/solution_00.py b/CH_06_decorators/exercise_03/solution_00.py index c4779f1..f8497b7 100644 --- a/CH_06_decorators/exercise_03/solution_00.py +++ b/CH_06_decorators/exercise_03/solution_00.py @@ -1,46 +1,87 @@ -# Modify the memoization function to function with unhashable types. +"""Memoize calls whose arguments can contain mutable built-in containers.""" -import functools +from collections.abc import Callable, Hashable +from functools import wraps +from typing import ParamSpec, TypeAlias, TypeVar, cast +__all__: list[str] = ["cache", "memoize"] -cache = dict() +_P = ParamSpec("_P") +_R = TypeVar("_R") -def memoize(function): - def safe_hash(args): - ''' - In the case of unhashable types use the `repr()` to be hashable. - ''' - try: - return hash(args) - except TypeError: - return repr(args) - - @functools.wraps(function) - def _memoize(*args): - # If the cache is not available, call the function - # Note that all args need to be hashable - # key = function, safe_hash(args) - key = function, args - if key not in cache: - cache[key] = function(*args) - return cache[key] +_CacheKey: TypeAlias = tuple[object, Hashable, Hashable] - return _memoize +cache: dict[_CacheKey, object] = {} +_MISSING: object = object() -@memoize -def printer(*args): - print(args) +def _freeze(value: object, active: set[int] | None = None) -> Hashable: + """Return a type-tagged, hashable representation of an argument.""" + if active is None: + active = set() + value_type: type[object] = type(value) + if not isinstance(value, (tuple, frozenset)) and isinstance(value, Hashable): + return (value_type, value) + + if isinstance(value, (tuple, list, dict, set, frozenset)): + container: object = cast(object, value) + identity: int = id(container) + if identity in active: + raise ValueError("cyclic argument") + active.add(identity) + try: + if isinstance(value, tuple): + tuple_value: tuple[object, ...] = cast(tuple[object, ...], value) + return ( + value_type, + tuple(_freeze(item, active) for item in tuple_value), + ) + if isinstance(value, list): + list_value: list[object] = cast(list[object], value) + return ( + value_type, + tuple(_freeze(item, active) for item in list_value), + ) + if isinstance(value, dict): + dict_value: dict[object, object] = cast(dict[object, object], value) + return ( + value_type, + frozenset( + (_freeze(key, active), _freeze(item, active)) + for key, item in dict_value.items() + ), + ) + if isinstance(value, set): + set_value: set[object] = cast(set[object], value) + return ( + value_type, + frozenset(_freeze(item, active) for item in set_value), + ) + frozenset_value: frozenset[object] = cast(frozenset[object], value) + return ( + value_type, + frozenset(_freeze(item, active) for item in frozenset_value), + ) + finally: + active.remove(identity) -def main(): - # Should work as expected - printer('a', 'b', 'c') + raise TypeError(f"unsupported unhashable argument: {value_type.__name__}") - # Would have issues with the original memoize function because the - # parameters are unhashable - printer(dict(a=1, b=2, c=3)) +def memoize(function: Callable[_P, _R]) -> Callable[_P, _R]: + """Cache successful calls by function and frozen arguments.""" + namespace: object = object() -if __name__ == '__main__': - main() + @wraps(function) + def _memoize(*args: _P.args, **kwargs: _P.kwargs) -> _R: + key: _CacheKey = (namespace, _freeze(args), _freeze(kwargs)) + cached: object = cache.get(key, _MISSING) + if cached is not _MISSING: + return cast(_R, cached) + + result: _R = function(*args, **kwargs) + cache[key] = result + return result + + return _memoize diff --git a/CH_06_decorators/exercise_03/test_solution_00.py b/CH_06_decorators/exercise_03/test_solution_00.py new file mode 100644 index 0000000..0e48e24 --- /dev/null +++ b/CH_06_decorators/exercise_03/test_solution_00.py @@ -0,0 +1,318 @@ +from dataclasses import dataclass +from inspect import signature +from typing import NoReturn + +import pytest + +from .solution_00 import cache, memoize + + +def setup_function() -> None: + cache.clear() + + +def test_equivalent_list_and_dict_keyword_arguments_share_cache_entry() -> None: + calls: list[int] = [] + + @memoize + def render(values: object, *, options: object) -> object: + calls.append(1) + return object() + + first: object = render([1, 2], options={"enabled": True}) + second: object = render([1, 2], options={"enabled": True}) + + assert second is first + assert calls == [1] + assert len(cache) == 1 + + +def test_list_and_tuple_arguments_have_separate_entries() -> None: + calls: list[int] = [] + + @memoize + def identify(value: object) -> str: + calls.append(1) + return type(value).__name__ + + assert identify([1, 2]) == "list" + assert identify((1, 2)) == "tuple" + assert calls == [1, 1] + assert len(cache) == 2 + + +def test_bool_and_int_arguments_have_separate_entries() -> None: + calls: list[int] = [] + + @memoize + def identify(value: object) -> type[object]: + calls.append(1) + return type(value) + + assert identify(True) is bool + assert identify(1) is int + assert calls == [1, 1] + assert len(cache) == 2 + + +def test_dictionary_keys_are_frozen_with_runtime_type_tags() -> None: + calls: list[int] = [] + + @memoize + def identify_key(mapping: dict[object, str]) -> str: + calls.append(1) + key: object = next(iter(mapping)) + return type(key).__name__ + + assert identify_key({True: "value"}) == "bool" + assert identify_key({1: "value"}) == "int" + assert calls == [1, 1] + assert len(cache) == 2 + + +def test_unordered_nested_dicts_and_sets_have_equivalent_keys() -> None: + calls: list[int] = [] + + @memoize + def render(value: object) -> object: + calls.append(1) + return object() + + first_value: dict[str, object] = { + "flags": {"red", "blue"}, + "mapping": {"one": [1, 2], "two": (3, 4)}, + } + second_value: dict[str, object] = { + "mapping": {"two": (3, 4), "one": [1, 2]}, + "flags": {"blue", "red"}, + } + + assert render(second_value) is render(first_value) + assert calls == [1] + + +def test_set_and_frozenset_arguments_have_separate_entries() -> None: + calls: list[int] = [] + + @memoize + def identify(value: object) -> str: + calls.append(1) + return type(value).__name__ + + assert identify({1, 2}) == "set" + assert identify(frozenset({1, 2})) == "frozenset" + assert calls == [1, 1] + + +def test_different_decorated_functions_do_not_share_entries() -> None: + first_calls: list[int] = [] + second_calls: list[int] = [] + + @memoize + def first(value: object) -> str: + first_calls.append(1) + return f"first:{value}" + + @memoize + def second(value: object) -> str: + second_calls.append(1) + return f"second:{value}" + + assert first("value") == "first:value" + assert second("value") == "second:value" + assert first_calls == [1] + assert second_calls == [1] + assert len(cache) == 2 + + +def test_equal_hashable_callable_instances_have_independent_entries() -> None: + class EqualCallable: + def __init__(self, label: str) -> None: + self.label: str = label + self.calls: int = 0 + + def __call__(self, value: int) -> str: + self.calls += 1 + return f"{self.label}:{value}" + + def __hash__(self) -> int: + return 1 + + def __eq__(self, other: object) -> bool: + return isinstance(other, EqualCallable) + + first_callable: EqualCallable = EqualCallable("first") + second_callable: EqualCallable = EqualCallable("second") + first = memoize(first_callable) + second = memoize(second_callable) + + assert first(1) == "first:1" + assert second(1) == "second:1" + assert first(1) == "first:1" + assert second(1) == "second:1" + assert first_callable.calls == 1 + assert second_callable.calls == 1 + assert len(cache) == 2 + + +def test_unhashable_callable_instance_can_be_memoized() -> None: + @dataclass + class UnhashableCallable: + calls: int = 0 + + def __call__(self, value: int) -> int: + self.calls += 1 + return value * 2 + + callable_instance: UnhashableCallable = UnhashableCallable() + decorated = memoize(callable_instance) + + assert decorated(3) == 6 + assert decorated(3) == 6 + assert callable_instance.calls == 1 + assert len(cache) == 1 + + +def test_keyword_argument_order_does_not_affect_cache_key() -> None: + calls: list[int] = [] + + @memoize + def combine(**values: int) -> object: + calls.append(1) + return object() + + first: object = combine(left=1, right=2) + second: object = combine(right=2, left=1) + + assert second is first + assert calls == [1] + + +def test_none_result_is_cached() -> None: + calls: list[int] = [] + + @memoize + def returns_none(value: object) -> None: + calls.append(1) + + assert returns_none("value") is None + assert returns_none("value") is None + assert calls == [1] + assert len(cache) == 1 + + +def test_exceptions_are_not_cached() -> None: + calls: list[int] = [] + error: RuntimeError = RuntimeError("boom") + + @memoize + def explode(value: object) -> NoReturn: + calls.append(1) + raise error + + for _ in range(2): + with pytest.raises(RuntimeError) as caught: + explode("value") + assert caught.value is error + + assert calls == [1, 1] + assert cache == {} + + +def test_cyclic_list_raises_exact_error() -> None: + cyclic: list[object] = [] + cyclic.append(cyclic) + + @memoize + def consume(value: object) -> None: + raise AssertionError("cyclic input must fail before invocation") + + with pytest.raises(ValueError, match=r"^cyclic argument$"): + consume(cyclic) + + +def test_cyclic_dict_raises_exact_error() -> None: + cyclic: dict[str, object] = {} + cyclic["self"] = cyclic + + @memoize + def consume(value: object) -> None: + raise AssertionError("cyclic input must fail before invocation") + + with pytest.raises(ValueError, match=r"^cyclic argument$"): + consume(cyclic) + + +def test_shared_noncyclic_reference_is_allowed() -> None: + shared: list[int] = [1, 2] + value: list[list[int]] = [shared, shared] + calls: list[int] = [] + + @memoize + def consume(argument: object) -> str: + calls.append(1) + return "ok" + + assert consume(value) == "ok" + assert consume([[1, 2], [1, 2]]) == "ok" + assert calls == [1] + + +def test_unsupported_unhashable_object_names_its_type() -> None: + @dataclass + class Unsupported: + value: int = 0 + + @memoize + def consume(value: object) -> None: + raise AssertionError("unsupported input must fail before invocation") + + with pytest.raises( + TypeError, + match=r"^unsupported unhashable argument: Unsupported$", + ): + consume(Unsupported()) + + +def test_hashable_user_objects_are_supported_and_tagged_by_runtime_type() -> None: + class EqualHash: + def __hash__(self) -> int: + return 1 + + def __eq__(self, other: object) -> bool: + return isinstance(other, EqualHash) + + class First(EqualHash): + pass + + class Second(EqualHash): + pass + + calls: list[int] = [] + + @memoize + def identify(value: object) -> str: + calls.append(1) + return type(value).__name__ + + assert identify(First()) == "First" + assert identify(Second()) == "Second" + assert calls == [1, 1] + + +def test_wrapper_preserves_metadata_and_signature() -> None: + def describe(value: int, *, prefix: str = "value") -> str: + """Describe a value.""" + return f"{prefix}:{value}" + + describe.category = "example" # type: ignore[attr-defined] + decorated = memoize(describe) + + assert decorated.__name__ == "describe" + assert decorated.__qualname__ == describe.__qualname__ + assert decorated.__doc__ == "Describe a value." + assert decorated.__module__ == describe.__module__ + assert decorated.__annotations__ == describe.__annotations__ + assert decorated.__dict__["category"] == "example" + assert decorated.__dict__["__wrapped__"] is describe + assert signature(decorated) == signature(describe) diff --git a/CH_06_decorators/exercise_04/README.rst b/CH_06_decorators/exercise_04/README.rst new file mode 100644 index 0000000..475752c --- /dev/null +++ b/CH_06_decorators/exercise_04/README.rst @@ -0,0 +1,58 @@ +Exercise 4: per-function memoization +==================================== + +Question +-------- + +The relevant chapter exercise-list excerpt is: + +1. Extend the `track` function to monitor execution time. +2. Extend the `track` function with min/max/average execution time and call + count. +3. Modify the memoization function to function with unhashable types. +4. Modify the memoization function to have a cache per function instead of a global one. + +Solution +-------- + +``memoize`` gives every decoration its own closure-local cache. Two decorated +functions therefore never share entries, even when called with the same +arguments. Each wrapper exposes its actual live ``cache`` dictionary for +inspection and a ``cache_clear()`` method that clears only that wrapper. + +The cache key freezes positional and keyword arguments with Exercise 3's +freezer. Lists, tuples, dictionaries, sets, and frozen sets may be nested. +Dictionary and set order does not affect a key, while container types and +scalar runtime types remain distinct. For example, a list differs from a tuple, +a set differs from a frozen set, and ``bool`` differs from ``int``. + +Active recursion cycles raise ``ValueError("cyclic argument")``. Other +unsupported unhashable objects raise ``TypeError``. Shared references that do +not form a cycle remain valid. + +A private missing-value sentinel allows ``None`` to be cached. The wrapped +callable runs only on a cache miss, and a result is stored only after a +successful return, so exceptions are never cached. ``functools.wraps`` +preserves ordinary metadata, custom attributes, ``__wrapped__``, and the +inspectable signature. + +Running +------- + +Run the solution module: + +.. code-block:: console + + uv run python -m CH_06_decorators.exercise_04.solution_00 + +Run the exercise tests: + +.. code-block:: console + + uv run pytest -W error CH_06_decorators/exercise_04 -v + +Reference +--------- + +This solution extends the chapter's +`memoization example `_. diff --git a/CH_06_decorators/exercise_04/solution_00.py b/CH_06_decorators/exercise_04/solution_00.py index 7f6f485..31f05fe 100644 --- a/CH_06_decorators/exercise_04/solution_00.py +++ b/CH_06_decorators/exercise_04/solution_00.py @@ -1,49 +1,83 @@ -# Modify the memoization function to have a cache per function instead of a -# global one. +"""Memoize each decorated callable with its own inspectable cache.""" -import functools +from collections.abc import Callable, Hashable +from functools import wraps +from typing import ParamSpec, Protocol, TypeVar, cast +from CH_06_decorators.exercise_03.solution_00 import ( + _freeze, # pyright: ignore[reportPrivateUsage] +) -def memoize(function): - # Store the cache as attribute of the function so we can - # apply the decorator to multiple functions without - # sharing the cache. - function.cache = dict() +__all__: list[str] = ["MemoizedCallable", "memoize"] - def safe_hash(args): - ''' - In the case of unhashable types use the `repr()` to be hashable. - ''' - try: - return hash(args) - except TypeError: - return repr(args) +_P = ParamSpec("_P") +_R = TypeVar("_R") - @functools.wraps(function) - def _memoize(*args): - # If the cache is not available, call the function - # Note that all args need to be hashable - key = safe_hash(args) - if key not in function.cache: - function.cache[key] = function(*args) - return function.cache[key] - return _memoize +class MemoizedCallable(Protocol[_P, _R]): + """Callable interface exposed by :func:`memoize`.""" + + __name__: str + __doc__: str | None + __wrapped__: Callable[_P, _R] + + @property + def cache(self) -> dict[Hashable, _R]: + """Return the callable's live cache.""" + ... + + def cache_clear(self) -> None: + """Clear this callable's cache.""" + ... + + def __call__(self, *args: _P.args, **kwargs: _P.kwargs) -> _R: + """Call the wrapped callable.""" + ... + + +class _CacheAttributes(Protocol[_R]): + """Writable attributes attached to the wrapper.""" + + cache: dict[Hashable, _R] + cache_clear: Callable[[], None] + + +def memoize(function: Callable[_P, _R]) -> MemoizedCallable[_P, _R]: + """Memoize *function* using a cache private to this decoration.""" + local_cache: dict[Hashable, _R] = {} + missing: object = object() + + @wraps(function) + def _memoized(*args: _P.args, **kwargs: _P.kwargs) -> _R: + key: Hashable = (_freeze(args), _freeze(kwargs)) + cached: object = local_cache.get(key, missing) + if cached is not missing: + return cast(_R, cached) + + result: _R = function(*args, **kwargs) + local_cache[key] = result + return result + + attributes: _CacheAttributes[_R] = cast( + _CacheAttributes[_R], + _memoized, + ) + attributes.cache = local_cache + attributes.cache_clear = local_cache.clear + return cast(MemoizedCallable[_P, _R], _memoized) @memoize -def printer(*args): +def printer(*args: object) -> None: + """Print arguments once per distinct call.""" print(args) -def main(): - # Should work as expected - printer('a', 'b', 'c') - - # Would have issues with the original memoize function because the - # parameters are unhashable - printer(dict(a=1, b=2, c=3)) +def main() -> None: + """Run a small memoization demonstration.""" + printer("a", "b", "c") + printer({"a": 1, "b": 2, "c": 3}) -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/CH_06_decorators/exercise_04/test_solution_00.py b/CH_06_decorators/exercise_04/test_solution_00.py new file mode 100644 index 0000000..431dc3e --- /dev/null +++ b/CH_06_decorators/exercise_04/test_solution_00.py @@ -0,0 +1,265 @@ +"""Tests for per-function memoization caches.""" + +import inspect +from dataclasses import dataclass +from typing import NoReturn + +import pytest + +from . import solution_00 +from .solution_00 import memoize + + +def test_public_api_exposes_memoized_callable_protocol() -> None: + assert hasattr(solution_00, "MemoizedCallable") + + +def test_decorated_functions_own_distinct_caches() -> None: + first_calls: list[int] = [] + second_calls: list[int] = [] + + @memoize + def first(value: int) -> str: + first_calls.append(value) + return f"first:{value}" + + @memoize + def second(value: int) -> str: + second_calls.append(value) + return f"second:{value}" + + assert first.cache is not second.cache + assert first(1) == "first:1" + assert second(1) == "second:1" + assert first(1) == "first:1" + assert second(1) == "second:1" + assert first_calls == [1] + assert second_calls == [1] + assert len(first.cache) == 1 + assert len(second.cache) == 1 + + +def test_equivalent_mutable_arguments_and_reordered_keywords_hit_cache() -> None: + calls: list[int] = [] + + @memoize + def render(values: object, **options: object) -> object: + calls.append(1) + return object() + + first: object = render( + [{"labels": {"beta", "alpha"}}], + enabled=True, + settings={"scale": [1, 2]}, + ) + second: object = render( + [{"labels": {"alpha", "beta"}}], + settings={"scale": [1, 2]}, + enabled=True, + ) + + assert second is first + assert calls == [1] + assert len(render.cache) == 1 + + +def test_cache_clear_is_local_and_forces_recomputation() -> None: + first_calls: list[int] = [] + second_calls: list[int] = [] + + @memoize + def first(value: int) -> object: + first_calls.append(value) + return object() + + @memoize + def second(value: int) -> object: + second_calls.append(value) + return object() + + first_result: object = first(1) + second_result: object = second(1) + first.cache_clear() + + assert first.cache == {} + assert len(second.cache) == 1 + assert first(1) is not first_result + assert second(1) is second_result + assert first_calls == [1, 1] + assert second_calls == [1] + + +def test_none_result_is_cached() -> None: + calls: list[int] = [] + + @memoize + def returns_none(value: object) -> None: + calls.append(1) + + assert returns_none("value") is None + assert returns_none("value") is None + assert calls == [1] + assert len(returns_none.cache) == 1 + + +def test_exceptions_are_not_cached() -> None: + calls: list[int] = [] + + @memoize + def fail(value: object) -> NoReturn: + calls.append(1) + raise RuntimeError("boom") + + for _ in range(2): + with pytest.raises(RuntimeError, match=r"^boom$"): + fail("value") + + assert calls == [1, 1] + assert fail.cache == {} + + +def test_container_and_scalar_runtime_types_have_distinct_entries() -> None: + calls: list[str] = [] + + @memoize + def identify(value: object) -> str: + name: str = type(value).__name__ + calls.append(name) + return name + + arguments: list[object] = [ + [1, 2], + (1, 2), + True, + 1, + {1, 2}, + frozenset({1, 2}), + ] + expected: list[str] = ["list", "tuple", "bool", "int", "set", "frozenset"] + + assert [identify(value) for value in arguments] == expected + assert [identify(value) for value in arguments] == expected + assert calls == expected + assert len(identify.cache) == 6 + + +def test_nested_unordered_containers_are_order_independent() -> None: + calls: list[int] = [] + + @memoize + def consume(value: object) -> object: + calls.append(1) + return object() + + first: object = consume( + { + "groups": [ + {"members": {"Ada", "Grace"}}, + {"members": frozenset({"Linus", "Guido"})}, + ] + } + ) + second: object = consume( + { + "groups": [ + {"members": {"Grace", "Ada"}}, + {"members": frozenset({"Guido", "Linus"})}, + ] + } + ) + + assert second is first + assert calls == [1] + + +def test_cyclic_list_raises_exact_error() -> None: + cyclic: list[object] = [] + cyclic.append(cyclic) + + @memoize + def consume(value: object) -> object: + return value + + with pytest.raises(ValueError, match=r"^cyclic argument$"): + consume(cyclic) + + +def test_cyclic_dict_raises_exact_error() -> None: + cyclic: dict[str, object] = {} + cyclic["self"] = cyclic + + @memoize + def consume(value: object) -> object: + return value + + with pytest.raises(ValueError, match=r"^cyclic argument$"): + consume(cyclic) + + +def test_shared_noncyclic_reference_is_supported() -> None: + calls: list[int] = [] + shared: list[int] = [1, 2] + + @memoize + def consume(value: object) -> object: + calls.append(1) + return object() + + first: object = consume([shared, shared]) + second: object = consume([[1, 2], [1, 2]]) + + assert second is first + assert calls == [1] + + +def test_unsupported_unhashable_argument_raises_exact_error() -> None: + @dataclass + class Unsupported: + value: int + + @memoize + def consume(value: object) -> object: + return value + + with pytest.raises( + TypeError, + match=r"^unsupported unhashable argument: Unsupported$", + ): + consume(Unsupported(1)) + + +def test_metadata_custom_attributes_wrapping_and_signature_are_preserved() -> None: + def original(prefix: str, /, value: int = 1, *, suffix: str) -> str: + """Build a labelled value.""" + return f"{prefix}{value}{suffix}" + + vars(original)["category"] = "formatter" + original_signature: inspect.Signature = inspect.signature(original) + decorated = memoize(original) + + assert decorated("value=", value=2, suffix="!") == "value=2!" + assert decorated.__name__ == "original" + assert decorated.__doc__ == "Build a labelled value." + assert vars(decorated)["category"] == "formatter" + assert decorated.__wrapped__ is original + assert inspect.signature(decorated) == original_signature + + +def test_cache_is_live_inspectable_dictionary() -> None: + calls: list[int] = [] + + @memoize + def calculate(value: int) -> int: + calls.append(value) + return value * 2 + + exposed_cache = calculate.cache + + assert isinstance(exposed_cache, dict) + assert exposed_cache == {} + assert calculate(3) == 6 + assert calculate.cache is exposed_cache + assert len(exposed_cache) == 1 + exposed_cache.clear() + assert calculate(3) == 6 + assert calls == [3, 3] diff --git a/CH_06_decorators/exercise_05/README.rst b/CH_06_decorators/exercise_05/README.rst new file mode 100644 index 0000000..2f594cb --- /dev/null +++ b/CH_06_decorators/exercise_05/README.rst @@ -0,0 +1,61 @@ +Exercise 5: recalculable cached property +======================================== + +Question +-------- + +The relevant chapter exercise-list excerpt is: + +1. Extend the `track` function to monitor execution time. +2. Extend the `track` function with min/max/average execution time and call count. +3. Modify the memoization function to function with unhashable types. +4. Modify the memoization function to have a cache per function instead of a global one. +5. Create a version of `functools.cached_property` that can be recalculated as needed. + +Solution +-------- + +``CachedProperty[T, R]`` is a generic descriptor that stores each computed +result under the descriptor's bound attribute name in that instance's +``__dict__``. Instances therefore cache independently. Class access returns +the descriptor itself. + +The first instance access calls the decorated function and stores its result, +including ``None``, only after the function returns successfully. Later +accesses reuse that value. Deleting the property removes only that instance's +cached value, so the next access recalculates it. Deletion before calculation +raises ``AttributeError`` naming the property. + +The descriptor rejects access before ``__set_name__`` and rejects binding one +descriptor to two different names. Instances without a ``__dict__`` are not +supported and raise a clear ``TypeError``. Standard callable metadata is +retained when present and well formed, including ``__type_params__`` on newer +Python versions. Every declared metadata attribute has a neutral, type-valid +fallback. Non-private custom attributes are copied only from an ordinary +``dict``; private, reserved, malformed, and hostile mapping metadata cannot +replace descriptor state or helpers. Partials and slotted callable objects +remain valid inputs. + +This teaching implementation provides no locking or thread-safety guarantee. +Concurrent access may calculate the value more than once. + +Running +------- + +Run the solution module: + +.. code-block:: console + + uv run python -m CH_06_decorators.exercise_05.solution_00 + +Run the exercise tests: + +.. code-block:: console + + uv run pytest -W error CH_06_decorators/exercise_05 -v + +Reference +--------- + +The solution extends the chapter's +`properties and descriptors discussion `_. diff --git a/CH_06_decorators/exercise_05/solution_00.py b/CH_06_decorators/exercise_05/solution_00.py index 6ab1276..23a7c9f 100644 --- a/CH_06_decorators/exercise_05/solution_00.py +++ b/CH_06_decorators/exercise_05/solution_00.py @@ -1,62 +1,193 @@ -# Create a version of `functools.cached_property` that can be recalculated -# as needed. -from datetime import datetime - - -class _NotFound: - pass - - -class CachedProperty: - # Note that this is a very basic version of `functools.cached_property`. If - # you wish to use this in production I suggest looking at the original - # `cached_property` decorator and implement the locking and conflict - # handling as well. - - def __init__(self, func): - self.func = func - - def clear(self): - self.cache.pop(self.attrname, None) - - def __set_name__(self, owner, name): - if not hasattr(owner, '_cache'): - owner._cache = dict() - # Add a clear method to the owner class - setattr(owner, f'clear_{name}', self.clear) - - self.cache = owner._cache - self.attrname = name - - def __get__(self, instance, owner=None): +"""A cached-property descriptor whose value can be deleted and recalculated.""" + +from __future__ import annotations + +from collections.abc import Callable +from types import BuiltinFunctionType, FunctionType, MethodType +from typing import Generic, TypeVar, cast, overload + +__all__: list[str] = ["CachedProperty"] + +T = TypeVar("T") +R = TypeVar("R") +_MISSING: object = object() +_RESERVED_CUSTOM_ATTRIBUTES: frozenset[str] = frozenset({"function", "name"}) + + +class CachedProperty(Generic[T, R]): + """Cache a computed value in each instance until that value is deleted.""" + + function: Callable[[T], R] + name: str | None + __name__: str + __qualname__: str + __module__: str + __doc__: str | None + __annotations__: dict[str, object] + __type_params__: tuple[object, ...] + __wrapped__: Callable[[T], R] + + def __init__(self, function: Callable[[T], R]) -> None: + self.function = function + self.name = None + self._initialize_metadata(function) + self._copy_metadata(function) + self._copy_custom_metadata(function) + + def __set_name__(self, owner: type[T], name: str) -> None: + if self.name is None: + self.name = name + return + if name != self.name: + raise TypeError( + "Cannot assign the same CachedProperty to two different names " + f"({self.name!r} and {name!r})." + ) + + def _bound_name(self) -> str: + name: str | None = self.name + if name is None: + raise TypeError("CachedProperty has not been assigned to a class attribute") + return name + + @overload + def __get__( + self, + instance: None, + owner: type[T] | None = None, + ) -> CachedProperty[T, R]: ... + + @overload + def __get__(self, instance: T, owner: type[T] | None = None) -> R: ... + + def __get__( + self, + instance: T | None, + owner: type[T] | None = None, + ) -> CachedProperty[T, R] | R: + name: str = self._bound_name() if instance is None: return self - key = self.attrname - if key not in self.cache: - self.cache[key] = self.func(instance) - - return self.cache[key] - - -class SomeClass: - - @CachedProperty - def current_time(self): - return datetime.now() - - -def main(): - some_class = SomeClass() - a = some_class.current_time - b = some_class.current_time - assert a == b - # Clear the cache. Even though your editor might complain, this method - # exists. Can you think of a better API to make the cache clearable? - some_class.clear_current_time() - c = some_class.current_time - assert a != c - - -if __name__ == '__main__': - main() + storage: dict[str, object] = self._instance_storage(instance, name) + if name in storage: + return cast(R, storage[name]) + + value: R = self.function(instance) + storage[name] = value + return value + + def __delete__(self, instance: T) -> None: + name: str = self._bound_name() + storage: dict[str, object] = self._instance_storage(instance, name) + try: + del storage[name] + except KeyError: + raise AttributeError(name) from None + + @staticmethod + def _instance_storage(instance: T, name: str) -> dict[str, object]: + try: + return vars(instance) + except TypeError: + raise TypeError( + f"CachedProperty {name!r} requires instances with a __dict__" + ) from None + + def _initialize_metadata(self, function: Callable[[T], R]) -> None: + function_type: type[object] = type(function) + + module: object = self._metadata_value(function_type, "__module__") + self.__module__ = module if isinstance(module, str) else "" + + name: object = self._metadata_value(function_type, "__name__") + self.__name__ = name if isinstance(name, str) else "callable" + + qualname: object = self._metadata_value(function_type, "__qualname__") + self.__qualname__ = qualname if isinstance(qualname, str) else self.__name__ + + self.__doc__ = None + self.__annotations__ = {} + self.__type_params__ = () + self.__wrapped__ = function + + def _copy_metadata(self, function: Callable[[T], R]) -> None: + module: object = self._callable_metadata_value(function, "__module__") + if isinstance(module, str): + self.__module__ = module + + name: object = self._callable_metadata_value(function, "__name__") + if isinstance(name, str): + self.__name__ = name + + qualname: object = self._callable_metadata_value(function, "__qualname__") + if isinstance(qualname, str): + self.__qualname__ = qualname + + doc: object = self._callable_metadata_value(function, "__doc__") + if doc is None or isinstance(doc, str): + self.__doc__ = doc + + annotations: object = self._callable_metadata_value( + function, + "__annotations__", + ) + if type(annotations) is dict: + annotation_items: dict[object, object] = cast( + dict[object, object], + annotations, + ) + if all(isinstance(key, str) for key in annotation_items): + self.__annotations__ = cast(dict[str, object], annotation_items) + + type_parameters: object = self._callable_metadata_value( + function, + "__type_params__", + ) + if isinstance(type_parameters, tuple): + self.__type_params__ = type_parameters + + def _copy_custom_metadata(self, function: Callable[[T], R]) -> None: + attributes: dict[object, object] | None = self._callable_attributes(function) + if attributes is None: + return + + for name, value in attributes.items(): + if ( + isinstance(name, str) + and not name.startswith("_") + and name not in _RESERVED_CUSTOM_ATTRIBUTES + ): + setattr(self, name, value) + + def _callable_metadata_value( + self, + function: Callable[[T], R], + attribute: str, + ) -> object: + if isinstance( + function, + (BuiltinFunctionType, FunctionType, MethodType, type), + ): + return self._metadata_value(function, attribute) + + attributes: dict[object, object] | None = self._callable_attributes(function) + if attributes is None: + return _MISSING + return attributes.get(attribute, _MISSING) + + def _callable_attributes( + self, + function: Callable[[T], R], + ) -> dict[object, object] | None: + attributes: object = self._metadata_value(function, "__dict__") + if type(attributes) is not dict: + return None + return cast(dict[object, object], attributes) + + @staticmethod + def _metadata_value(source: object, attribute: str) -> object: + try: + return getattr(source, attribute) + except Exception: + return _MISSING diff --git a/CH_06_decorators/exercise_05/test_solution_00.py b/CH_06_decorators/exercise_05/test_solution_00.py new file mode 100644 index 0000000..dc3af64 --- /dev/null +++ b/CH_06_decorators/exercise_05/test_solution_00.py @@ -0,0 +1,388 @@ +"""Tests for the recalculable cached-property descriptor.""" + +import sys +from collections.abc import Callable +from functools import partial +from typing import NoReturn, cast + +import pytest + +from .solution_00 import CachedProperty + + +def test_instances_cache_independently_and_deletion_recalculates() -> None: + calls: list[str] = [] + + class Example: + def __init__(self, value: str) -> None: + self.value: str = value + + @CachedProperty + def rendered(self) -> object: + calls.append(self.value) + return object() + + first: Example = Example("first") + second: Example = Example("second") + + first_result: object = first.rendered + second_result: object = second.rendered + + assert first.rendered is first_result + assert second.rendered is second_result + assert calls == ["first", "second"] + assert vars(first)["rendered"] is first_result + assert vars(second)["rendered"] is second_result + + del first.rendered + + replacement: object = first.rendered + assert replacement is not first_result + assert second.rendered is second_result + assert calls == ["first", "second", "first"] + + +def test_deletion_before_first_access_names_the_property() -> None: + class Example: + @CachedProperty + def value(self) -> int: + return 1 + + example: Example = Example() + + with pytest.raises(AttributeError, match=r"^value$"): + del example.value + + +def test_class_access_returns_the_descriptor() -> None: + class Example: + @CachedProperty + def value(self) -> int: + return 1 + + descriptor: CachedProperty[Example, int] = Example.value + + assert isinstance(descriptor, CachedProperty) + assert descriptor is vars(Example)["value"] + + +def test_one_descriptor_cannot_bind_to_two_names() -> None: + class Example: + def value(self) -> int: + return 1 + + descriptor: CachedProperty[Example, int] = CachedProperty(Example.value) + descriptor.__set_name__(Example, "first") + + with pytest.raises( + TypeError, + match=r"^Cannot assign the same CachedProperty to two different names " + r"\('first' and 'second'\)\.$", + ): + descriptor.__set_name__(Example, "second") + + +def test_unbound_descriptor_access_raises_type_error() -> None: + class Example: + def value(self) -> int: + return 1 + + descriptor: CachedProperty[Example, int] = CachedProperty(Example.value) + example: Example = Example() + + with pytest.raises( + TypeError, + match=r"^CachedProperty has not been assigned to a class attribute$", + ): + descriptor.__get__(example, Example) + + +def test_unbound_descriptor_class_access_raises_type_error() -> None: + class Example: + def value(self) -> int: + return 1 + + descriptor: CachedProperty[Example, int] = CachedProperty(Example.value) + + with pytest.raises( + TypeError, + match=r"^CachedProperty has not been assigned to a class attribute$", + ): + descriptor.__get__(None, Example) + + +def test_slots_only_instance_requires_instance_dict() -> None: + class Slotted: + __slots__: tuple[()] = () + + @CachedProperty + def value(self) -> int: + return 1 + + with pytest.raises( + TypeError, + match=r"^CachedProperty 'value' requires instances with a __dict__$", + ): + assert Slotted().value == 1 + + +def test_none_is_cached_and_function_is_called_once() -> None: + calls: list[int] = [] + + class Example: + @CachedProperty + def value(self) -> None: + calls.append(1) + return None + + example: Example = Example() + + assert example.value is None + assert example.value is None + assert calls == [1] + assert "value" in vars(example) + + +def test_function_metadata_is_preserved() -> None: + def original(self: object) -> int: + """Return the answer.""" + return 42 + + descriptor: CachedProperty[object, int] = CachedProperty(original) + + assert descriptor.__name__ == "original" + assert descriptor.__qualname__ == original.__qualname__ + assert descriptor.__doc__ == "Return the answer." + assert descriptor.__module__ == original.__module__ + assert descriptor.__annotations__ == original.__annotations__ + assert descriptor.__wrapped__ is original + + +def test_valid_type_parameters_are_preserved_when_present() -> None: + def original(self: object) -> int: + return 42 + + type_parameters: tuple[object, ...] = (object(),) + original.__type_params__ = type_parameters # type: ignore[attr-defined] + + descriptor: CachedProperty[object, int] = CachedProperty(original) + + assert descriptor.__type_params__ == type_parameters + + +def test_native_type_parameters_are_preserved_on_supported_python() -> None: + if sys.version_info < (3, 12): + pytest.skip("generic function syntax requires Python 3.12 or newer") + + namespace: dict[str, object] = {} + source: str = "def generic[T](self: object) -> object:\n return self\n" + exec(source, namespace) + function: Callable[[object], object] = cast( + Callable[[object], object], + namespace["generic"], + ) + + descriptor: CachedProperty[object, object] = CachedProperty(function) + type_parameters: object = object.__getattribute__(function, "__type_params__") + + assert isinstance(type_parameters, tuple) + assert descriptor.__type_params__ == type_parameters + + +def test_malformed_type_parameters_are_ignored() -> None: + class MalformedTypeParameters: + def __call__(self, instance: object) -> int: + return 42 + + def __getattribute__(self, attribute: str) -> object: + if attribute == "__type_params__": + return ["not", "a", "tuple"] + return object.__getattribute__(self, attribute) + + function: Callable[[object], int] = MalformedTypeParameters() + descriptor: CachedProperty[object, int] = CachedProperty(function) + + assert descriptor.__type_params__ == () + + +def test_malformed_standard_metadata_is_ignored() -> None: + class MalformedMetadata: + def __call__(self, instance: object) -> int: + return 42 + + def __getattribute__(self, attribute: str) -> object: + malformed: dict[str, object] = { + "__module__": 1, + "__name__": 2, + "__qualname__": 3, + "__doc__": 4, + "__annotations__": (), + "__type_params__": [], + } + if attribute in malformed: + return malformed[attribute] + return object.__getattribute__(self, attribute) + + function: Callable[[object], int] = MalformedMetadata() + descriptor: CachedProperty[object, int] = CachedProperty(function) + copied: dict[str, object] = vars(descriptor) + + assert isinstance(copied["__module__"], str) + assert isinstance(copied["__name__"], str) + assert isinstance(copied["__qualname__"], str) + assert copied["__doc__"] is None + assert copied["__annotations__"] == {} + assert copied["__type_params__"] == () + assert descriptor.__annotations__ == {} + assert "function" not in descriptor.__annotations__ + assert descriptor.__wrapped__ is function + + +def test_hostile_annotations_dict_is_not_iterated() -> None: + class ExplodingDict(dict[str, object]): + def __iter__(self) -> NoReturn: + raise RuntimeError("do not iterate") + + def original(self: object) -> int: + return 42 + + original.__annotations__ = ExplodingDict({"return": int}) + + descriptor: CachedProperty[object, int] = CachedProperty(original) + + assert descriptor.__annotations__ == {} + + +def test_partial_is_a_supported_callable() -> None: + class Example: + pass + + def render(prefix: str, instance: Example) -> str: + return f"{prefix}:{type(instance).__name__}" + + function: Callable[[Example], str] = partial(render, "partial") + descriptor: CachedProperty[Example, str] = CachedProperty(function) + descriptor.__set_name__(Example, "rendered") + example: Example = Example() + + assert descriptor.__get__(example, Example) == "partial:Example" + assert isinstance(descriptor.__module__, str) + assert isinstance(descriptor.__name__, str) + assert isinstance(descriptor.__qualname__, str) + assert descriptor.__doc__ is None or isinstance(descriptor.__doc__, str) + assert descriptor.__annotations__ == {} + assert descriptor.__type_params__ == () + + +def test_slotted_callable_is_supported() -> None: + class Example: + pass + + class Render: + __slots__: tuple[()] = () + + def __call__(self, instance: Example) -> str: + return type(instance).__name__ + + function: Callable[[Example], str] = Render() + descriptor: CachedProperty[Example, str] = CachedProperty(function) + descriptor.__set_name__(Example, "rendered") + example: Example = Example() + + assert descriptor.__get__(example, Example) == "Example" + assert isinstance(descriptor.__module__, str) + assert isinstance(descriptor.__name__, str) + assert isinstance(descriptor.__qualname__, str) + assert descriptor.__doc__ is None or isinstance(descriptor.__doc__, str) + assert descriptor.__annotations__ == {} + assert descriptor.__type_params__ == () + + +def test_hostile_callable_dict_is_not_iterated() -> None: + class HostileDict(dict[str, object]): + def __iter__(self) -> NoReturn: + raise RuntimeError("do not iterate") + + def items(self) -> NoReturn: + raise RuntimeError("do not inspect items") + + class Example: + pass + + class Render: + def __call__(self, instance: Example) -> str: + return type(instance).__name__ + + def __getattribute__(self, attribute: str) -> object: + if attribute == "__dict__": + return HostileDict({"category": "unsafe"}) + return object.__getattribute__(self, attribute) + + function: Callable[[Example], str] = Render() + descriptor: CachedProperty[Example, str] = CachedProperty(function) + descriptor.__set_name__(Example, "rendered") + + assert descriptor.__get__(Example(), Example) == "Example" + assert "category" not in vars(descriptor) + + +def test_callable_attributes_cannot_shadow_descriptor_internals() -> None: + class Example: + pass + + def original(instance: Example) -> str: + return type(instance).__name__ + + attributes: dict[str, object] = vars(original) + attributes["_bound_name"] = "shadowed" + attributes["_instance_storage"] = "shadowed" + attributes["function"] = "shadowed" + attributes["name"] = "shadowed" + attributes["category"] = "preserved" + + descriptor: CachedProperty[Example, str] = CachedProperty(original) + + assert descriptor.function is original + assert descriptor.name is None + assert "_bound_name" not in vars(descriptor) + assert "_instance_storage" not in vars(descriptor) + assert descriptor.category == "preserved" # type: ignore[attr-defined] + + descriptor.__set_name__(Example, "rendered") + + assert descriptor.name == "rendered" + assert descriptor.__get__(Example(), Example) == "Example" + + +def test_exception_is_not_cached() -> None: + calls: list[int] = [] + + class Example: + @CachedProperty + def value(self) -> NoReturn: + calls.append(1) + raise RuntimeError("boom") + + example: Example = Example() + + def read_value() -> object: + return example.value + + for _ in range(2): + with pytest.raises(RuntimeError, match=r"^boom$"): + read_value() + + assert calls == [1, 1] + assert "value" not in vars(example) + + +def test_public_constructor_accepts_a_typed_callable() -> None: + class Example: + def value(self) -> str: + return "value" + + function: Callable[[Example], str] = Example.value + descriptor: CachedProperty[Example, str] = CachedProperty(function) + + assert descriptor.function is function + assert descriptor.name is None diff --git a/CH_06_decorators/exercise_06/README.rst b/CH_06_decorators/exercise_06/README.rst new file mode 100644 index 0000000..ee24dba --- /dev/null +++ b/CH_06_decorators/exercise_06/README.rst @@ -0,0 +1,74 @@ +Exercise 6: configurable multi-argument single dispatch +======================================================== + +Question +-------- + +The relevant chapter exercise-list excerpt is: + +.. code-block:: text + + 1. Extend the `track` function to monitor execution time. + 2. Extend the `track` function with min/max/average execution time and call count. + 3. Modify the memoization function to function with unhashable types. + 4. Modify the memoization function to have a cache per function instead of a global one. + 5. Create a version of `functools.cached_property` that can be recalculated as needed. + 6. Create a single-dispatch decorator that considers all or a configurable number of arguments instead of only the first one. + +Solution +-------- + +``fancysingledispatch`` creates a dispatcher for a base function. By default, +every base parameter contributes to the dispatch key. The legacy positional +form, such as ``@fancysingledispatch("context")``, and true-keyword form, such +as ``@fancysingledispatch(context=True)``, exclude named parameters. The +explicit ``dispatch_on=("left", "right")`` form selects parameters in the +given order and cannot be combined with either legacy form. + +A call is bound against the base signature and defaults are applied before the +key is built. The key is the tuple of exact runtime types for selected +arguments. Registry lookup deliberately performs no subclass or method +resolution order fallback. Identity-only key components avoid invoking custom +metaclass hashing or equality. An unregistered tuple calls the base function. +The selected implementation receives the normalized bound arguments, including +defaults from the base signature. Its own missing or different defaults cannot +change the values used by the dispatcher. + +``dispatcher.register(function)`` is a decorator and returns ``function``. +Registered functions must have exactly the base parameter names and kinds. +Every selected parameter needs an annotation that ``typing.get_type_hints`` +resolves to a concrete class. Missing annotations, ``typing.Any``, unions, +parameterized generics, and other non-class annotations are rejected. +Only selected parameter annotations are resolved; annotations on ignored +parameters and the return value are not evaluated. Registering an existing +type tuple replaces its implementation. + +Configuration rejects false compatibility keywords, conflicting selection +styles, empty or duplicate selections, duplicate exclusions, and unknown +parameter names. Legacy exclusions must leave at least one selected parameter. +Selected variadic positional and keyword parameters are rejected because their +runtime tuple or dictionary does not describe one dispatched argument; they +remain usable when excluded through a legacy form. Normal signature-binding +errors propagate unchanged. ``functools.wraps`` preserves base metadata, +``__wrapped__``, and the inspectable signature. + +Running +------- + +Run the silent, guarded module demonstration: + +.. code-block:: console + + uv run python -m CH_06_decorators.exercise_06.solution_00 + +Run focused tests with warnings treated as errors: + +.. code-block:: console + + uv run pytest -W error CH_06_decorators/exercise_06 -v + +Reference +--------- + +The solution extends the chapter's +`single-dispatch discussion `_. diff --git a/CH_06_decorators/exercise_06/solution_00.py b/CH_06_decorators/exercise_06/solution_00.py index e29cc23..aac9406 100644 --- a/CH_06_decorators/exercise_06/solution_00.py +++ b/CH_06_decorators/exercise_06/solution_00.py @@ -1,107 +1,281 @@ -# Create a single-dispatch decorator that considers all or a configurable -# number of arguments instead of only the first one. -import functools +"""Configurable exact-type dispatch across one or more arguments.""" + +from __future__ import annotations + import inspect -import typing - - -def fancysingledispatch(*disabled_args, **disabled_kwargs): - ''' - A single-dispatch decorator that considers all or a configurable - number of arguments instead of only the first one. - - Args: - disabled_args: A list of argument names to ignore. - disabled_kwargs: A list of keyword argument names to ignore. - - ''' - registry = dict() - disabled_args = set(disabled_args) - for key, value in disabled_kwargs.items(): - if value: - disabled_args.add(key) - - def register(function): - key_parts = [] - for key, type_ in typing.get_type_hints(function).items(): - if key == 'return': - # Ignore the return type - continue - - if key in disabled_args: - key_parts.append(None) - else: - key_parts.append(type_) - - registry[tuple(key_parts)] = function - return function - - def dispatch(function): - signature = inspect.signature(function) - - @functools.wraps(function) - def _dispatch(*args, **kwargs): - bound = signature.bind(*args, **kwargs) - bound.apply_defaults() +from collections.abc import Callable +from functools import wraps +from typing import ( + Any, + ParamSpec, + Protocol, + TypeVar, + cast, + get_origin, + get_type_hints, +) + +__all__: list[str] = ["Dispatcher", "fancysingledispatch"] + +P = ParamSpec("P") +Q = ParamSpec("Q") +R = TypeVar("R") + + +class Dispatcher(Protocol[P, R]): + """Callable returned by :func:`fancysingledispatch`.""" + + __name__: str + __doc__: str | None + __wrapped__: Callable[P, R] + + def __call__(self, *args: P.args, **kwargs: P.kwargs) -> R: ... + + def register( + self, + function: Callable[Q, R], + /, + ) -> Callable[Q, R]: ... + + +class _TypeIdentity: + """Hash and compare a class by identity without invoking its metaclass.""" + + __slots__: tuple[str, ...] = ("target",) + + target: type[object] + + def __init__(self, target: type[object]) -> None: + self.target: type[object] = target + + def __hash__(self) -> int: + return id(self.target) + + def __eq__(self, other: object) -> bool: + return isinstance(other, _TypeIdentity) and self.target is other.target + - key_parts = [] - for key, value in bound.arguments.items(): - if key in disabled_args: - key_parts.append(None) - else: - key_parts.append(type(value)) +def _validated_names(value: object, label: str) -> tuple[str, ...]: + if not isinstance(value, tuple): + raise TypeError(f"{label} must be a tuple of parameter names") + names: tuple[object, ...] = cast(tuple[object, ...], value) + for name in names: + if not isinstance(name, str): + raise TypeError(f"{label} parameter names must be strings") + return cast(tuple[str, ...], names) - key = tuple(key_parts) - if key in registry: - return registry[key](*args, **kwargs) - else: - raise TypeError(f'No matching function for {key}') - _dispatch.register = register - register(function) - return _dispatch +def _duplicate_name(names: tuple[str, ...]) -> str | None: + seen: set[str] = set() + for name in names: + if name in seen: + return name + seen.add(name) + return None - return dispatch +def _parameter_shape( + signature: inspect.Signature, +) -> tuple[tuple[str, str], ...]: + return tuple( + (name, str(parameter.kind)) for name, parameter in signature.parameters.items() + ) -@fancysingledispatch(last_name=True) -def hello(first_name: str, last_name: str, age: None = None) -> str: - return f'Hello {first_name} {last_name}' +def _resolve_selected_annotations( + function: Callable[..., object], + signature: inspect.Signature, + selected_names: tuple[str, ...], +) -> dict[str, object]: + raw_annotations: dict[str, object] = {} + for name in selected_names: + annotation: object = signature.parameters[name].annotation + if annotation is inspect.Parameter.empty: + raise TypeError( + f"dispatch parameter {name!r} must have a resolvable " + "concrete class annotation; got missing", + ) + raw_annotations[name] = annotation -# Since this function only differs in the last_name argument, it will -# override the previous one. The original `hello` function will never get -# called again. -@hello.register -def first_name_only( - first_name: str, - last_name: None = None, - age: None = None, -) -> str: - return f'Hello {first_name}' + def annotation_target() -> None: + pass + + annotation_target.__annotations__ = raw_annotations + candidate_globals: object = getattr(function, "__globals__", None) + global_namespace: dict[str, Any] | None = ( + cast(dict[str, Any], candidate_globals) + if isinstance(candidate_globals, dict) + else None + ) + try: + return cast( + dict[str, object], + get_type_hints( + annotation_target, + globalns=global_namespace, + ), + ) + except Exception as error: + raise TypeError( + f"dispatch parameter annotations could not be resolved: {error}", + ) from error + + +def fancysingledispatch( + *ignored: str, + dispatch_on: tuple[str, ...] | None = None, + **ignored_by_name: bool, +) -> Callable[[Callable[P, R]], Dispatcher[P, R]]: + """Build an exact-type dispatcher over selected function parameters. + + Without ``dispatch_on``, every base parameter participates except names + excluded by the two legacy ignored-name forms. Registering the same type + tuple again replaces its previous implementation. + """ + + validated_ignored: tuple[str, ...] = _validated_names(ignored, "ignored") + ignored_keyword_names: tuple[str, ...] = tuple(ignored_by_name) + for name, enabled in ignored_by_name.items(): + if enabled is not True: + raise TypeError(f"ignored keyword {name!r} must be True") + + all_ignored: tuple[str, ...] = validated_ignored + ignored_keyword_names + duplicate_ignored: str | None = _duplicate_name(all_ignored) + if duplicate_ignored is not None: + raise TypeError( + f"duplicate ignored parameter name: {duplicate_ignored!r}", + ) + + if dispatch_on is not None: + if all_ignored: + raise TypeError( + "dispatch_on cannot be combined with ignored parameter names", + ) + selected_configuration: tuple[str, ...] = _validated_names( + dispatch_on, + "dispatch_on", + ) + if not selected_configuration: + raise TypeError( + "dispatch_on must contain at least one parameter name", + ) + duplicate_selected: str | None = _duplicate_name( + selected_configuration, + ) + if duplicate_selected is not None: + raise TypeError( + f"duplicate dispatch parameter name: {duplicate_selected!r}", + ) + + def decorate(base: Callable[P, R]) -> Dispatcher[P, R]: + base_signature: inspect.Signature = inspect.signature(base) + parameter_names: tuple[str, ...] = tuple(base_signature.parameters) + + for name in all_ignored: + if name not in base_signature.parameters: + raise TypeError(f"unknown ignored parameter name: {name!r}") + + if dispatch_on is None: + ignored_names: frozenset[str] = frozenset(all_ignored) + selected_names: tuple[str, ...] = tuple( + name for name in parameter_names if name not in ignored_names + ) + else: + for name in dispatch_on: + if name not in base_signature.parameters: + raise TypeError( + f"unknown dispatch parameter name: {name!r}", + ) + selected_names = dispatch_on + + if not selected_names: + raise TypeError("at least one dispatch parameter must remain") + for name in selected_names: + if base_signature.parameters[name].kind in ( + inspect.Parameter.VAR_POSITIONAL, + inspect.Parameter.VAR_KEYWORD, + ): + raise TypeError( + f"dispatch parameter {name!r} cannot be variadic", + ) + + base_shape: tuple[tuple[str, str], ...] = _parameter_shape( + base_signature, + ) + registry: dict[ + tuple[_TypeIdentity, ...], + Callable[..., R], + ] = {} + + def register( + function: Callable[Q, R], + ) -> Callable[Q, R]: + implementation_signature: inspect.Signature = inspect.signature( + function, + ) + if _parameter_shape(implementation_signature) != base_shape: + raise TypeError( + "registered function parameters must match base names and kinds", + ) + + annotations: dict[str, object] = _resolve_selected_annotations( + function, + implementation_signature, + selected_names, + ) + + key_parts: list[_TypeIdentity] = [] + for name in selected_names: + annotation: object | None = annotations.get(name) + if ( + annotation is None + or annotation is Any + or get_origin(annotation) is not None + or not isinstance(annotation, type) + ): + description: str = ( + "missing" if annotation is None else str(annotation) + ) + raise TypeError( + f"dispatch parameter {name!r} must have a resolvable " + "concrete class annotation; " + f"got {description}", + ) + key_parts.append(_TypeIdentity(annotation)) + + registry[tuple(key_parts)] = function + return function + + @wraps(base) + def dispatcher(*args: P.args, **kwargs: P.kwargs) -> R: + bound: inspect.BoundArguments = base_signature.bind(*args, **kwargs) + bound.apply_defaults() + key: tuple[_TypeIdentity, ...] = tuple( + _TypeIdentity(type(cast(object, bound.arguments[name]))) + for name in selected_names + ) + implementation: Callable[..., R] = registry.get(key, base) + return implementation(*bound.args, **bound.kwargs) + vars(dispatcher)["register"] = register + return cast(Dispatcher[P, R], dispatcher) -@hello.register -def name_age(first_name: str, last_name: str, age: int) -> str: - # Reuse the function above - return hello(first_name, last_name) + f', you are {age} years old' + return decorate -@hello.register -def name_age_days(first_name: str, last_name: str, age: float) -> str: - days = int((age % 1) * 365) - age = int(age) - return hello( - first_name, - last_name - ) + f', you are {age} years and {days} days old' +def _demonstrate() -> None: + @fancysingledispatch(dispatch_on=("value", "unit")) + def format_measurement(value: object, unit: object) -> str: + return f"{value!r} {unit!r}" + @format_measurement.register + def _format_number(value: float, unit: str) -> str: + return f"{value:g} {unit}" -def main(): - print(hello('Rick', 'van Hattem')) - print(hello('Rick', 'van Hattem', age=30)) - print(hello('Rick', 'van Hattem', age=30.5)) + assert callable(_format_number) + assert format_measurement(1.5, "m") == "1.5 m" + assert format_measurement(1, "m") == "1 'm'" -if __name__ == '__main__': - main() +if __name__ == "__main__": + _demonstrate() diff --git a/CH_06_decorators/exercise_06/test_solution_00.py b/CH_06_decorators/exercise_06/test_solution_00.py new file mode 100644 index 0000000..275bc03 --- /dev/null +++ b/CH_06_decorators/exercise_06/test_solution_00.py @@ -0,0 +1,584 @@ +"""Tests for configurable multi-argument single dispatch.""" + +from __future__ import annotations + +import inspect +import re +import subprocess +import sys +from collections.abc import Callable +from pathlib import Path +from textwrap import dedent +from typing import Any, cast + +import pytest + +from .solution_00 import fancysingledispatch + +_REPOSITORY_ROOT: Path = Path(__file__).parents[2] + + +class _ForwardValue: + pass + + +class _UnhashableMeta(type): + def __eq__(cls, other: object) -> bool: + return cls is other + + +class _UnhashableValue(metaclass=_UnhashableMeta): + pass + + +class _EqualMeta(type): + def __hash__(cls) -> int: + return 1 + + def __eq__(cls, other: object) -> bool: + return isinstance(other, _EqualMeta) + + +class _FirstEqualValue(metaclass=_EqualMeta): + pass + + +class _SecondEqualValue(metaclass=_EqualMeta): + pass + + +def _run_type_checker( + checker: str, + probe_path: Path, +) -> subprocess.CompletedProcess[str]: + if checker == "mypy": + arguments: list[str] = [ + sys.executable, + "-m", + "mypy", + "--strict", + "--python-version", + "3.10", + str(probe_path), + ] + else: + arguments = [ + sys.executable, + "-m", + "pyright", + "--pythonversion", + "3.10", + str(probe_path), + ] + return subprocess.run( + arguments, + cwd=_REPOSITORY_ROOT, + check=False, + capture_output=True, + text=True, + ) + + +def test_explicit_multi_argument_dispatch_supports_positional_and_keywords() -> None: + @fancysingledispatch(dispatch_on=("left", "right")) + def combine(left: object, right: object, separator: str = ":") -> str: + return f"base{separator}{left}{separator}{right}" + + @combine.register + def _combine_strings(left: str, right: str, separator: str = ":") -> str: + return separator.join((left, right)) + + assert callable(_combine_strings) + assert combine("alpha", "beta") == "alpha:beta" + assert combine(left="alpha", right="beta", separator="/") == "alpha/beta" + assert combine("alpha", 2) == "base:alpha:2" + + +def test_default_configuration_dispatches_on_every_parameter() -> None: + @fancysingledispatch() + def describe(value: object, enabled: object) -> str: + return "base" + + @describe.register + def _describe_integer(value: int, enabled: bool) -> str: + return f"{value}:{enabled}" + + assert callable(_describe_integer) + assert describe(3, True) == "3:True" + assert describe(3, 1) == "base" + + +def test_legacy_positional_exclusions_are_supported() -> None: + @fancysingledispatch("context") + def render(value: object, context: object) -> str: + return "base" + + @render.register + def _render_integer(value: int, context: str) -> str: + return f"{context}:{value}" + + assert callable(_render_integer) + assert render(4, "decimal") == "decimal:4" + assert render(4, object()).endswith(":4") + + +def test_legacy_true_keyword_exclusions_are_supported() -> None: + @fancysingledispatch(context=True) + def render(value: object, context: object) -> str: + return "base" + + @render.register + def _render_integer(value: int, context: object) -> str: + return f"{type(context).__name__}:{value}" + + assert callable(_render_integer) + assert render(4, "text") == "str:4" + assert render(4, 5) == "int:4" + + +def test_false_legacy_keyword_is_rejected() -> None: + with pytest.raises( + TypeError, + match=r"^ignored keyword 'context' must be True$", + ): + fancysingledispatch(context=False) + + +@pytest.mark.parametrize( + ("factory", "message"), + [ + ( + lambda: fancysingledispatch("context", dispatch_on=("value",)), + "dispatch_on cannot be combined with ignored parameter names", + ), + ( + lambda: fancysingledispatch( + dispatch_on=("value",), + context=True, + ), + "dispatch_on cannot be combined with ignored parameter names", + ), + ( + lambda: fancysingledispatch(dispatch_on=()), + "dispatch_on must contain at least one parameter name", + ), + ( + lambda: fancysingledispatch("context", "context"), + "duplicate ignored parameter name: 'context'", + ), + ( + lambda: fancysingledispatch("context", context=True), + "duplicate ignored parameter name: 'context'", + ), + ( + lambda: fancysingledispatch(dispatch_on=("value", "value")), + "duplicate dispatch parameter name: 'value'", + ), + ], +) +def test_invalid_factory_configurations_are_rejected( + factory: Callable[[], object], + message: str, +) -> None: + with pytest.raises(TypeError, match=f"^{message}$"): + factory() + + +def test_unknown_ignored_name_is_rejected_when_base_is_decorated() -> None: + decorator = fancysingledispatch("missing") + + def base(value: object) -> str: + return str(value) + + with pytest.raises( + TypeError, + match=r"^unknown ignored parameter name: 'missing'$", + ): + decorator(base) + + +def test_unknown_selected_name_is_rejected_when_base_is_decorated() -> None: + decorator = fancysingledispatch(dispatch_on=("missing",)) + + def base(value: object) -> str: + return str(value) + + with pytest.raises( + TypeError, + match=r"^unknown dispatch parameter name: 'missing'$", + ): + decorator(base) + + +@pytest.mark.parametrize( + "decorator", + [ + fancysingledispatch("value"), + fancysingledispatch(value=True), + ], +) +def test_legacy_exclusions_cannot_remove_every_dispatch_parameter( + decorator: Callable[[Callable[[object], str]], object], +) -> None: + def base(value: object) -> str: + return str(value) + + with pytest.raises( + TypeError, + match=r"^at least one dispatch parameter must remain$", + ): + decorator(base) + + +def test_default_dispatch_rejects_variadic_positional_parameter() -> None: + def base(value: object, *items: object) -> str: + return f"{value}:{len(items)}" + + with pytest.raises( + TypeError, + match=r"^dispatch parameter 'items' cannot be variadic$", + ): + fancysingledispatch()(base) + + +def test_default_dispatch_rejects_variadic_keyword_parameter() -> None: + def base(value: object, **options: object) -> str: + return f"{value}:{len(options)}" + + with pytest.raises( + TypeError, + match=r"^dispatch parameter 'options' cannot be variadic$", + ): + fancysingledispatch()(base) + + +def test_variadic_parameters_are_allowed_when_ignored() -> None: + @fancysingledispatch("items", "options") + def collect(value: object, *items: object, **options: object) -> str: + return "base" + + @collect.register + def _collect_integer( + value: int, + *items: object, + **options: object, + ) -> str: + return f"{value}:{len(items)}:{len(options)}" + + assert callable(_collect_integer) + assert collect(3, "extra", enabled=True) == "3:1:1" + + +def test_defaults_are_applied_before_building_dispatch_key() -> None: + @fancysingledispatch(dispatch_on=("count",)) + def repeat(text: str, count: object = 2) -> str: + return "base" + + @repeat.register + def _repeat_integer(text: str, count: int = 2) -> str: + return text * count + + assert callable(_repeat_integer) + assert repeat("go") == "gogo" + assert repeat("go", count=3) == "gogogo" + + +def test_base_default_is_forwarded_to_registered_required_parameter() -> None: + @fancysingledispatch(dispatch_on=("value",)) + def render(value: object, label: str = "base") -> str: + return "fallback" + + @render.register + def _render_integer(value: int, label: str) -> str: + return f"{label}:{value}" + + assert callable(_render_integer) + assert render(3) == "base:3" + + +def test_base_default_overrides_registered_implementation_default() -> None: + @fancysingledispatch(dispatch_on=("value",)) + def render(value: object, label: str = "base") -> str: + return "fallback" + + @render.register + def _render_integer(value: int, label: str = "implementation") -> str: + return f"{label}:{value}" + + assert callable(_render_integer) + assert render(3) == "base:3" + + +def test_unregistered_types_call_base_and_subclasses_do_not_match() -> None: + class Number(int): + pass + + @fancysingledispatch(dispatch_on=("value",)) + def identify(value: object) -> str: + return "base" + + @identify.register + def _identify_integer(value: int) -> str: + return "int" + + assert callable(_identify_integer) + assert identify(1) == "int" + assert identify(Number(1)) == "base" + + +def test_register_returns_function_and_duplicate_key_replaces_implementation() -> None: + @fancysingledispatch(dispatch_on=("value",)) + def identify(value: object) -> str: + return "base" + + def first(value: int) -> str: + return "first" + + def second(value: int) -> str: + return "second" + + assert identify.register(first) is first + assert identify(1) == "first" + assert identify.register(second) is second + assert identify(1) == "second" + + +@pytest.mark.parametrize( + ("annotation", "description"), + [ + (None, "missing"), + (Any, "typing.Any"), + (int | str, "int | str"), + (list[int], "list[int]"), + (42, "42"), + ], +) +def test_selected_parameter_requires_concrete_class_annotation( + annotation: object | None, + description: str, +) -> None: + @fancysingledispatch(dispatch_on=("value",)) + def identify(value: object) -> str: + return "base" + + def implementation(value: object) -> str: + return str(value) + + if annotation is None: + del implementation.__annotations__["value"] + else: + implementation.__annotations__["value"] = annotation + + with pytest.raises( + TypeError, + match=( + r"^dispatch parameter 'value' must have a resolvable concrete " + rf"class annotation; got {re.escape(description)}$" + ), + ): + identify.register(implementation) + + +def test_unresolvable_forward_reference_is_rejected() -> None: + @fancysingledispatch(dispatch_on=("value",)) + def identify(value: object) -> str: + return "base" + + def implementation(value: object) -> str: + return str(value) + + implementation.__annotations__["value"] = "MissingClass" + + with pytest.raises( + TypeError, + match=( + r"^dispatch parameter annotations could not be resolved: " + r"name 'MissingClass' is not defined$" + ), + ): + identify.register(implementation) + + +def test_resolvable_selected_forward_reference_is_registered() -> None: + @fancysingledispatch(dispatch_on=("value",)) + def identify(value: object) -> str: + return "base" + + @identify.register + def _identify_forward(value: _ForwardValue) -> str: + return "forward" + + assert callable(_identify_forward) + assert identify(_ForwardValue()) == "forward" + + +def test_unresolved_unselected_parameter_annotation_is_not_evaluated() -> None: + @fancysingledispatch(dispatch_on=("value",)) + def identify(value: object, context: object) -> str: + return "base" + + def identify_integer(value: int, context: object) -> str: + return f"{value}:{context}" + + identify_integer.__annotations__["context"] = "MissingContext" + + assert identify.register(identify_integer) is identify_integer + assert identify(3, "context") == "3:context" + + +def test_unresolved_return_annotation_is_not_evaluated() -> None: + @fancysingledispatch(dispatch_on=("value",)) + def identify(value: object) -> str: + return "base" + + def identify_integer(value: int) -> str: + return str(value) + + identify_integer.__annotations__["return"] = "1 / 0" + + assert identify.register(identify_integer) is identify_integer + assert identify(3) == "3" + + +def test_unhashable_metaclass_can_be_registered_and_dispatched() -> None: + @fancysingledispatch(dispatch_on=("value",)) + def identify(value: object) -> str: + return "base" + + @identify.register + def _identify_unhashable(value: _UnhashableValue) -> str: + return "unhashable" + + assert callable(_identify_unhashable) + assert identify(_UnhashableValue()) == "unhashable" + + +def test_equal_classes_keep_distinct_registry_entries() -> None: + @fancysingledispatch(dispatch_on=("value",)) + def identify(value: object) -> str: + return "base" + + @identify.register + def _identify_first(value: _FirstEqualValue) -> str: + return "first" + + @identify.register + def _identify_second(value: _SecondEqualValue) -> str: + return "second" + + assert callable(_identify_first) + assert callable(_identify_second) + assert identify(_FirstEqualValue()) == "first" + assert identify(_SecondEqualValue()) == "second" + + +def test_registration_requires_same_parameter_names_and_kinds() -> None: + @fancysingledispatch(dispatch_on=("value",)) + def identify(value: object, /, label: str = "") -> str: + return "base" + + def wrong_name(item: int, /, label: str = "") -> str: + return f"{label}{item}" + + def wrong_kind(value: int, label: str = "") -> str: + return f"{label}{value}" + + with pytest.raises( + TypeError, + match=r"^registered function parameters must match base names and kinds$", + ): + identify.register(cast(Callable[[object, str], str], wrong_name)) + + with pytest.raises( + TypeError, + match=r"^registered function parameters must match base names and kinds$", + ): + identify.register(cast(Callable[[object, str], str], wrong_kind)) + + +def test_metadata_wrapping_and_signature_are_preserved() -> None: + def original(value: object, label: str = "value") -> str: + """Describe a value.""" + return f"{label}:{value}" + + vars(original)["category"] = "formatter" + original_signature: inspect.Signature = inspect.signature(original) + decorated = fancysingledispatch(dispatch_on=("value",))(original) + + assert decorated.__name__ == "original" + assert decorated.__doc__ == "Describe a value." + assert vars(decorated)["category"] == "formatter" + assert decorated.__wrapped__ is original + assert inspect.signature(decorated) == original_signature + + +def test_argument_binding_errors_propagate() -> None: + @fancysingledispatch(dispatch_on=("value",)) + def identify(value: object) -> str: + return str(value) + + unchecked_identify = cast(Callable[..., str], identify) + + with pytest.raises(TypeError, match="missing a required argument: 'value'"): + unchecked_identify() + + with pytest.raises(TypeError, match="too many positional arguments"): + unchecked_identify(1, 2) + + +@pytest.mark.parametrize("checker", ["mypy", "pyright"]) +def test_register_typing_preserves_signature_and_rejects_wrong_return( + checker: str, + tmp_path: Path, +) -> None: + valid_probe: Path = tmp_path / "valid_dispatch_typing.py" + valid_probe.write_text( + dedent( + """ + # pyright: strict + from CH_06_decorators.exercise_06.solution_00 import fancysingledispatch + + @fancysingledispatch(dispatch_on=("value",)) + def render(value: object) -> str: + return "base" + + @render.register + def render_integer(value: int) -> str: + return str(value) + + rendered: str = render_integer(3) + """ + ), + encoding="utf-8", + ) + valid_result: subprocess.CompletedProcess[str] = _run_type_checker( + checker, + valid_probe, + ) + valid_output: str = valid_result.stdout + valid_result.stderr + assert valid_result.returncode == 0, valid_output + + invalid_probe: Path = tmp_path / "invalid_dispatch_typing.py" + invalid_probe.write_text( + dedent( + """ + # pyright: strict + from CH_06_decorators.exercise_06.solution_00 import fancysingledispatch + + @fancysingledispatch(dispatch_on=("value",)) + def render(value: object) -> str: + return "base" + + @render.register + def render_integer(value: int) -> int: + return value + """ + ), + encoding="utf-8", + ) + invalid_result: subprocess.CompletedProcess[str] = _run_type_checker( + checker, + invalid_probe, + ) + invalid_output: str = invalid_result.stdout + invalid_result.stderr + assert invalid_result.returncode != 0, invalid_output diff --git a/CH_06_decorators/exercise_07/README.rst b/CH_06_decorators/exercise_07/README.rst new file mode 100644 index 0000000..9a60ff5 --- /dev/null +++ b/CH_06_decorators/exercise_07/README.rst @@ -0,0 +1,83 @@ +Exercise 7: constrained runtime type checking +============================================= + +Question +-------- + +The relevant chapter exercise-list excerpt is: + +.. code-block:: text + + 1. Extend `track` function monitor execution time. + 2. Extend `track` function min/max/average execution time call count. + 3. Modify memoization function function unhashable types. + 4. Modify memoization function cache per function instead global one. + 5. Create version `functools.cached_property` can be recalculated as needed. + 6. Create single-dispatch decorator considers all configurable number arguments instead only first one. + 7. Enhance the `type_check` decorator to include additional checks such as requiring a number to be greater than or less than a given value. + +Solution +-------- + +``Constraint`` defines the ``validate(name, value) -> None`` protocol. +The frozen ``GreaterThan``, ``LessThan``, and ``Between`` implementations +accept finite real-number bounds and never coerce values. Boolean values are +not treated as numbers. Non-finite float bounds are rejected at construction, +and non-finite float call values are rejected before comparison. Huge finite +integers and finite fractions remain supported. ``Between`` requires its lower +bound to be less than its upper bound. Its interval is exclusive by default; +``inclusive=True`` includes both boundary values. + +``type_check(**constraints)`` binds each call against the decorated function's +signature and applies defaults. It first checks every annotated parameter, +including parameters without constraints, using runtime instance checks. It +then validates configured parameters. ``Literal`` matching requires both exact +runtime type and equality, so equal values such as ``1`` and ``True`` remain +distinct. Type hints resolve lazily on the first call, with defining locals and +function globals available for forward references. Arbitrary argument and +receiver types never enter the annotation namespace. Module-level class +self-references, later globals, and function-local types defined before the +decorated function are supported. An unresolved nested-local class +self-reference is deliberately unsupported and raises ``NameError`` on first +call rather than guessing an owner. Only a successful complete resolution is +cached; unresolved names therefore raise when called rather than when +decorated. + +Unknown parameter names, constraints targeting ``*args`` or ``**kwargs``, and +objects without a callable ``validate`` method are rejected when the function +is decorated. Unconstrained variadic annotations are supported and checked +element by element. + +Parameter type mismatches, booleans, and other nonnumeric constrained values +raise ``TypeError``. Non-finite numeric values and numeric constraint +violations raise ``ValueError``. Normal signature-binding, annotation +resolution, and wrapped-function exceptions propagate unchanged. Return +annotations are intentionally not checked. ``functools.wraps`` keeps the +original metadata, wrapped function, and inspectable signature. + +For naming compatibility, ``Gt`` is an alias of ``GreaterThan`` and +``enforce_type_hints`` is an alias of ``type_check``. + +Dependencies +------------ + +The solution has no third-party runtime dependencies and requires Python 3.10 +or newer. Tests use the repository's ``pytest`` development dependency. + +Run the silent, guarded module demonstration: + +.. code-block:: console + + uv run python -m CH_06_decorators.exercise_07.solution_00 + +Run focused tests with warnings treated as errors: + +.. code-block:: console + + uv run pytest -W error CH_06_decorators/exercise_07 -v + +Reference +--------- + +The solution extends the chapter's `validation discussion +`_. diff --git a/CH_06_decorators/exercise_07/solution_00.py b/CH_06_decorators/exercise_07/solution_00.py index cfabfcf..2340956 100644 --- a/CH_06_decorators/exercise_07/solution_00.py +++ b/CH_06_decorators/exercise_07/solution_00.py @@ -1,120 +1,322 @@ -# Enhance the `type_check` decorator to include additional checks such as -# requiring a number to be greater than or less than a given value. -import abc -import functools +"""Runtime parameter type checks with reusable numeric constraints.""" + +from __future__ import annotations + import inspect +import types +from _thread import LockType +from collections.abc import Callable +from dataclasses import dataclass +from functools import wraps +from math import isfinite +from numbers import Real +from threading import Lock +from typing import ( + Annotated, + Any, + Literal, + ParamSpec, + Protocol, + TypeVar, + Union, + cast, + get_args, + get_origin, + get_type_hints, +) + +__all__: list[str] = [ + "Between", + "Constraint", + "GreaterThan", + "Gt", + "LessThan", + "enforce_type_hints", + "type_check", +] + +P = ParamSpec("P") +R = TypeVar("R") + + +class Constraint(Protocol): + """A named-argument validator used by :func:`type_check`.""" + + def validate(self, name: str, value: object) -> None: + """Raise when ``value`` does not satisfy the constraint.""" + + +def _real_bound(value: object) -> Real: + if isinstance(value, bool) or not isinstance(value, Real): + raise TypeError("constraint bounds must be real numbers") + if isinstance(value, float) and not isfinite(value): + raise ValueError("constraint bounds must be finite real numbers") + return value + -import pytest +def _real_value(name: str, value: object) -> Real: + if isinstance(value, bool) or not isinstance(value, Real): + raise TypeError(f"{name}={value!r} must be a real number") + if isinstance(value, float) and not isfinite(value): + raise ValueError(f"{name}={value!r} must be a finite real number") + return value -# Note: the exercise erroneously mentions `type_check` instead of -# `enforce_type_hints`. For clarity the function was renamed in the text of -# the book, but it seems I forgot about the exercise. +@dataclass(frozen=True) +class GreaterThan: + """Require a real value strictly greater than ``value``.""" + value: object -class Constraint(abc.ABC): - def __call__(self, name, value): - return False + def __post_init__(self) -> None: + _real_bound(self.value) - def to_string(self, name, value, constraint): - return f'{name}={value!r} must be {constraint}' + def validate(self, name: str, value: object) -> None: + bound: Real = _real_bound(self.value) + numeric_value: Real = _real_value(name, value) + if numeric_value <= bound: + raise ValueError( + f"{name}={value!r} must be greater than {self.value!r}", + ) - def __str__(self): - return self.to_string() +@dataclass(frozen=True) +class LessThan: + """Require a real value strictly less than ``value``.""" -class Gt(Constraint): - def __init__(self, value): - self.value = value + value: object - def __call__(self, name, value): - if not value > self.value: - raise ValueError(self.to_string(name, value)) + def __post_init__(self) -> None: + _real_bound(self.value) - def to_string(self, name='x', value='x', constraint='x > y'): - return super().to_string(name, value, f'greater than {self.value}') + def validate(self, name: str, value: object) -> None: + bound: Real = _real_bound(self.value) + numeric_value: Real = _real_value(name, value) + if numeric_value >= bound: + raise ValueError( + f"{name}={value!r} must be less than {self.value!r}", + ) -class Between(Constraint): - def __init__(self, min_value, max_value): - self.min_value = min_value - self.max_value = max_value +@dataclass(frozen=True) +class Between: + """Require a real value between ordered lower and upper bounds.""" - def __call__(self, name, value): - if not self.min_value < value < self.max_value: - raise ValueError(self.to_string(name, value)) + lower: object + upper: object + inclusive: object = False - def to_string(self, name='x', value='x', constraint='x < y < z'): - return super().to_string( - name, - value, - f'between {self.min_value} and {self.max_value}', + def __post_init__(self) -> None: + lower: Real = _real_bound(self.lower) + upper: Real = _real_bound(self.upper) + _inclusive_value(self.inclusive) + if not lower < upper: + raise ValueError("lower bound must be less than upper bound") + + def validate(self, name: str, value: object) -> None: + lower: Real = _real_bound(self.lower) + upper: Real = _real_bound(self.upper) + numeric_value: Real = _real_value(name, value) + if _inclusive_value(self.inclusive): + valid: bool = lower <= numeric_value <= upper + interval: str = "inclusive" + else: + valid = lower < numeric_value < upper + interval = "exclusive" + if not valid: + raise ValueError( + f"{name}={value!r} must be between " + f"{self.lower!r} and {self.upper!r} ({interval})", + ) + + +def _annotation_name(annotation: object) -> str: + if isinstance(annotation, type): + return annotation.__name__ + return str(annotation).removeprefix("typing.") + + +def _inclusive_value(value: object) -> bool: + if not isinstance(value, bool): + raise TypeError("inclusive must be a bool") + return value + + +def _matches_annotation(value: object, annotation: object) -> bool: + if annotation is Any: + return True + if annotation is None: + return value is None + + origin: Any = get_origin(annotation) + arguments: tuple[object, ...] = get_args(annotation) + if origin is Annotated: + return _matches_annotation(value, arguments[0]) + if origin in (Union, types.UnionType): + return any(_matches_annotation(value, member) for member in arguments) + if origin is Literal: + return any( + type(value) is type(literal) and value == literal for literal in arguments ) + if isinstance(origin, type): + return isinstance(value, origin) + if isinstance(annotation, type): + return isinstance(value, annotation) + raise TypeError( + f"unsupported runtime annotation: {_annotation_name(annotation)}", + ) -def enforce_type_hints(**constraint_kwargs): - def _enforce_type_hints(function): - # Construct the signature from the function which contains - # the type annotations - signature = inspect.signature(function) - @functools.wraps(function) - def __enforce_type_hints(*args, **kwargs): - # Bind the arguments and apply the default values - bound = signature.bind(*args, **kwargs) +def _check_value(name: str, value: object, annotation: object) -> None: + if not _matches_annotation(value, annotation): + raise TypeError( + f"{name} expected {_annotation_name(annotation)}, " + f"got {type(value).__name__}", + ) + + +def _check_parameter( + name: str, + parameter: inspect.Parameter, + value: object, + annotation: object, +) -> None: + if parameter.kind is inspect.Parameter.VAR_POSITIONAL: + for item in cast(tuple[object, ...], value): + _check_value(name, item, annotation) + return + if parameter.kind is inspect.Parameter.VAR_KEYWORD: + for item in cast(dict[str, object], value).values(): + _check_value(name, item, annotation) + return + _check_value(name, value, annotation) + + +def _function_globals( + function: Callable[..., object], +) -> dict[str, Any] | None: + candidate_globals: object = getattr(function, "__globals__", None) + if not isinstance(candidate_globals, dict): + return None + return cast(dict[str, Any], candidate_globals) + + +def _resolve_parameter_annotations( + function: Callable[..., object], + signature: inspect.Signature, + local_namespace: dict[str, Any], +) -> dict[str, Any]: + raw_annotations: dict[str, object] = cast( + dict[str, object], + getattr(function, "__annotations__", {}), + ) + parameter_annotations: dict[str, object] = { + name: raw_annotations[name] + for name in signature.parameters + if name in raw_annotations + } + + def annotation_target() -> None: + pass + + annotation_target.__annotations__ = parameter_annotations + global_namespace: dict[str, Any] | None = _function_globals(function) + return get_type_hints( + annotation_target, + globalns=global_namespace, + localns=local_namespace, + ) + + +def type_check( + **constraints: Constraint, +) -> Callable[[Callable[P, R]], Callable[P, R]]: + """Check annotated arguments and then apply named constraints.""" + + configured_constraints: dict[str, Constraint] = dict(constraints) + + def decorate(function: Callable[P, R]) -> Callable[P, R]: + signature: inspect.Signature = inspect.signature(function) + current_frame: types.FrameType | None = inspect.currentframe() + try: + caller_frame: types.FrameType | None = ( + current_frame.f_back if current_frame is not None else None + ) + defining_locals: dict[str, Any] = ( + dict(caller_frame.f_locals) if caller_frame is not None else {} + ) + finally: + del current_frame + + for name, constraint in configured_constraints.items(): + if name not in signature.parameters: + raise TypeError(f"unknown constrained parameter: {name!r}") + parameter: inspect.Parameter = signature.parameters[name] + if parameter.kind in ( + inspect.Parameter.VAR_POSITIONAL, + inspect.Parameter.VAR_KEYWORD, + ): + raise TypeError( + f"constraint for variadic parameter {name!r} is not supported", + ) + if not callable(getattr(constraint, "validate", None)): + raise TypeError( + f"constraint for {name!r} must provide validate(name, value)", + ) + + annotations_cache: dict[str, Any] | None = None + resolution_lock: LockType = Lock() + + def resolve_annotations() -> dict[str, Any]: + nonlocal annotations_cache + with resolution_lock: + if annotations_cache is None: + resolved: dict[str, Any] = _resolve_parameter_annotations( + function, + signature, + defining_locals, + ) + annotations_cache = resolved + return annotations_cache + + @wraps(function) + def checked(*args: P.args, **kwargs: P.kwargs) -> R: + bound: inspect.BoundArguments = signature.bind(*args, **kwargs) bound.apply_defaults() + annotations: dict[str, Any] = resolve_annotations() - for key, value in bound.arguments.items(): - param = signature.parameters[key] - # The annotation should be a callable - # type/function so we can cast as validation - if param.annotation: - try: - bound.arguments[key] = param.annotation(value) - except ValueError: - raise ValueError( - f'{key} must be {param.annotation.__name__}' - ) - - if key in constraint_kwargs: - constraint_kwargs[key](key, value) + for name, value in bound.arguments.items(): + annotation: object | None = annotations.get(name) + if annotation is not None: + _check_parameter( + name, + signature.parameters[name], + value, + annotation, + ) - return function(*bound.args, **bound.kwargs) + for name, constraint in configured_constraints.items(): + constraint.validate(name, bound.arguments[name]) - return __enforce_type_hints + return function(*bound.args, **bound.kwargs) - return _enforce_type_hints + return checked + return decorate -@enforce_type_hints(bacon=Gt(0), eggs=Between(1, 4)) -def sandwich(bacon: float, eggs: int): - print(f'bacon: {bacon!r}, eggs: {eggs!r}') +Gt = GreaterThan +enforce_type_hints = type_check -def test_sandwich(): - sandwich(1, 2) - sandwich(5, 3) - try: - sandwich(1, 0) - except ValueError as e: - assert str(e) == 'eggs=0 must be between 1 and 4' - else: - assert False +def _demonstrate() -> None: + @type_check(bacon=Gt(0), eggs=Between(1, 4)) + def sandwich(bacon: float, eggs: int) -> str: + return f"bacon: {bacon!r}, eggs: {eggs!r}" - try: - sandwich(1, 5) - except ValueError as e: - assert str(e) == 'eggs=5 must be between 1 and 4' - else: - assert False + assert sandwich(1.5, 2) == "bacon: 1.5, eggs: 2" - try: - sandwich(0, 5) - except ValueError as e: - assert str(e) == 'bacon=0 must be greater than 0' - else: - assert False -if __name__ == '__main__': - pytest.main(['-vv']) +if __name__ == "__main__": + _demonstrate() diff --git a/CH_06_decorators/exercise_07/test_solution_00.py b/CH_06_decorators/exercise_07/test_solution_00.py new file mode 100644 index 0000000..5d12627 --- /dev/null +++ b/CH_06_decorators/exercise_07/test_solution_00.py @@ -0,0 +1,671 @@ +"""Tests for numeric constraints and runtime parameter type checking.""" + +from __future__ import annotations + +import inspect +from collections.abc import Callable +from dataclasses import FrozenInstanceError +from fractions import Fraction +from typing import Literal, cast + +import pytest + +from .solution_00 import ( + Between, + Constraint, + GreaterThan, + Gt, + LessThan, + enforce_type_hints, + type_check, +) + + +def test_greater_than_accepts_value_above_bound() -> None: + GreaterThan(3).validate("count", 4) + + +def test_greater_than_rejects_value_at_bound() -> None: + with pytest.raises( + ValueError, + match=r"^count=3 must be greater than 3$", + ): + GreaterThan(3).validate("count", 3) + + +def test_less_than_accepts_value_below_bound() -> None: + LessThan(3).validate("count", 2) + + +def test_less_than_rejects_value_at_bound() -> None: + with pytest.raises( + ValueError, + match=r"^count=3 must be less than 3$", + ): + LessThan(3).validate("count", 3) + + +@pytest.mark.parametrize("value", [1, 4]) +def test_between_is_exclusive_by_default(value: int) -> None: + with pytest.raises( + ValueError, + match=rf"^count={value} must be between 1 and 4 \(exclusive\)$", + ): + Between(1, 4).validate("count", value) + + +def test_between_accepts_value_strictly_inside_bounds() -> None: + Between(1, 4).validate("count", 2) + + +@pytest.mark.parametrize("value", [1, 4]) +def test_between_inclusive_accepts_boundary_values(value: int) -> None: + Between(1, 4, inclusive=True).validate("count", value) + + +@pytest.mark.parametrize("value", [0, 5]) +def test_between_inclusive_rejects_values_outside_bounds(value: int) -> None: + with pytest.raises( + ValueError, + match=rf"^count={value} must be between 1 and 4 \(inclusive\)$", + ): + Between(1, 4, inclusive=True).validate("count", value) + + +@pytest.mark.parametrize( + "factory", + [ + lambda: GreaterThan("zero"), + lambda: LessThan(object()), + lambda: Between("zero", 1), + lambda: Between(0, object()), + ], +) +def test_constraint_bounds_must_be_real( + factory: Callable[[], object], +) -> None: + with pytest.raises(TypeError, match=r"^constraint bounds must be real numbers$"): + factory() + + +@pytest.mark.parametrize( + ("lower", "upper"), + [(1, 1), (2, 1)], +) +def test_between_lower_bound_must_be_less_than_upper_bound( + lower: float, + upper: float, +) -> None: + with pytest.raises( + ValueError, + match=r"^lower bound must be less than upper bound$", + ): + Between(lower, upper) + + +def test_between_inclusive_option_must_be_boolean() -> None: + with pytest.raises(TypeError, match=r"^inclusive must be a bool$"): + Between(1, 4, inclusive=cast(bool, 1)) + + +@pytest.mark.parametrize( + "constraint", + [GreaterThan(0), LessThan(10), Between(0, 10)], +) +def test_constraints_reject_nonnumeric_values( + constraint: Constraint, +) -> None: + with pytest.raises( + TypeError, + match=r"^count='5' must be a real number$", + ): + constraint.validate("count", "5") + + +@pytest.mark.parametrize( + "constraint", + [GreaterThan(0), LessThan(10), Between(0, 10)], + ids=["greater-than", "less-than", "between"], +) +def test_constraints_reject_boolean_values( + constraint: Constraint, +) -> None: + with pytest.raises( + TypeError, + match=r"^count=True must be a real number$", + ): + constraint.validate("count", True) + + +@pytest.mark.parametrize( + "factory", + [ + lambda: GreaterThan(True), + lambda: LessThan(False), + lambda: Between(False, 1), + lambda: Between(0, True), + ], +) +def test_constraint_bounds_reject_booleans( + factory: Callable[[], object], +) -> None: + with pytest.raises(TypeError, match=r"^constraint bounds must be real numbers$"): + factory() + + +@pytest.mark.parametrize( + "factory", + [ + lambda: GreaterThan(float("nan")), + lambda: GreaterThan(float("inf")), + lambda: GreaterThan(float("-inf")), + lambda: LessThan(float("nan")), + lambda: LessThan(float("inf")), + lambda: LessThan(float("-inf")), + lambda: Between(float("nan"), 1), + lambda: Between(float("inf"), 1), + lambda: Between(float("-inf"), 1), + lambda: Between(0, float("nan")), + lambda: Between(0, float("inf")), + lambda: Between(0, float("-inf")), + ], +) +def test_constraint_bounds_must_be_finite( + factory: Callable[[], object], +) -> None: + with pytest.raises( + ValueError, + match=r"^constraint bounds must be finite real numbers$", + ): + factory() + + +@pytest.mark.parametrize( + ("constraint", "value"), + [ + (GreaterThan(0), float("nan")), + (GreaterThan(0), float("inf")), + (GreaterThan(0), float("-inf")), + (LessThan(0), float("nan")), + (LessThan(0), float("inf")), + (LessThan(0), float("-inf")), + (Between(-1, 1), float("nan")), + (Between(-1, 1), float("inf")), + (Between(-1, 1), float("-inf")), + ], + ids=[ + "greater-than-nan", + "greater-than-infinity", + "greater-than-negative-infinity", + "less-than-nan", + "less-than-infinity", + "less-than-negative-infinity", + "between-nan", + "between-infinity", + "between-negative-infinity", + ], +) +def test_constraints_reject_nonfinite_values_before_comparison( + constraint: Constraint, + value: float, +) -> None: + with pytest.raises( + ValueError, + match=r"^count=.* must be a finite real number$", + ): + constraint.validate("count", value) + + +def test_constraints_preserve_huge_finite_integers() -> None: + huge: int = 10**10_000 + + GreaterThan(huge - 1).validate("count", huge) + LessThan(huge + 1).validate("count", huge) + Between(huge - 1, huge + 1).validate("count", huge) + + +def test_constraints_support_finite_fractions() -> None: + value: Fraction = Fraction(1, 2) + + GreaterThan(Fraction(1, 3)).validate("ratio", value) + LessThan(Fraction(2, 3)).validate("ratio", value) + Between(Fraction(1, 3), Fraction(2, 3)).validate("ratio", value) + + +def test_constraint_implementations_are_frozen() -> None: + constraint: GreaterThan = GreaterThan(0) + attribute: str = "value" + + with pytest.raises(FrozenInstanceError): + setattr(constraint, attribute, 1) + + +def test_decorator_accepts_valid_positional_and_keyword_arguments() -> None: + @type_check( + bacon=GreaterThan(0), + eggs=Between(1, 4), + temperature=LessThan(100), + ) + def sandwich(bacon: float, eggs: int, *, temperature: float) -> str: + return f"{bacon}:{eggs}:{temperature}" + + assert sandwich(1.5, 2, temperature=90.0) == "1.5:2:90.0" + assert sandwich(bacon=3.0, eggs=3, temperature=50.0) == "3.0:3:50.0" + + +def test_parameter_types_are_checked_before_constraints() -> None: + calls: list[tuple[str, object]] = [] + + class RecordingConstraint: + def validate(self, name: str, value: object) -> None: + calls.append((name, value)) + + @type_check(value=RecordingConstraint()) + def identify(value: int) -> int: + return value + + with pytest.raises( + TypeError, + match=r"^value expected int, got str$", + ): + identify("3") # type: ignore[arg-type] + + assert calls == [] + + +def test_annotated_parameters_without_constraints_are_checked() -> None: + @type_check() + def repeat(text: str, count: int) -> str: + return text * count + + with pytest.raises( + TypeError, + match=r"^text expected str, got int$", + ): + repeat(3, 2) # type: ignore[arg-type] + + +def test_unannotated_constrained_parameter_rejects_nonnumeric_value() -> None: + @type_check(value=GreaterThan(0)) + def identity(value) -> object: # type: ignore[no-untyped-def] + return cast(object, value) + + with pytest.raises( + TypeError, + match=r"^value='3' must be a real number$", + ): + identity("3") + + +def test_default_arguments_are_bound_checked_and_constrained() -> None: + @type_check(count=GreaterThan(0)) + def repeat(text: str, count: int = 2) -> str: + return text * count + + assert repeat("go") == "gogo" + + +def test_invalid_default_is_checked_when_omitted() -> None: + @type_check(count=GreaterThan(0)) + def repeat(text: str, count: int = 0) -> str: + return text * count + + with pytest.raises( + ValueError, + match=r"^count=0 must be greater than 0$", + ): + repeat("go") + + +def test_unknown_constraint_name_is_rejected_at_decoration() -> None: + decorator = type_check(missing=GreaterThan(0)) + + with pytest.raises( + TypeError, + match=r"^unknown constrained parameter: 'missing'$", + ): + + @decorator + def identity(value: int) -> int: + return value + + assert callable(identity) + + +def test_constraint_must_provide_validate_method() -> None: + decorator = type_check(value=cast(Constraint, object())) + + with pytest.raises( + TypeError, + match=r"^constraint for 'value' must provide validate\(name, value\)$", + ): + + @decorator + def identity(value: int) -> int: + return value + + assert callable(identity) + + +def test_type_check_does_not_coerce_values() -> None: + calls: list[int] = [] + + @type_check(value=GreaterThan(0)) + def record(value: int) -> int: + calls.append(value) + return value + + with pytest.raises(TypeError, match=r"^value expected int, got str$"): + record("3") # type: ignore[arg-type] + + assert calls == [] + + +def test_return_annotation_is_not_validated() -> None: + @type_check() + def unchecked_return() -> int: + return cast(int, "not checked") + + result: object = unchecked_return() + + assert result == "not checked" + + +def test_unresolved_return_annotation_is_not_resolved() -> None: + @type_check() + def identity(value: int) -> object: + return value + + original = inspect.unwrap(identity) + original.__annotations__["return"] = "MissingReturnValue" + + assert identity(1) == 1 + + +def test_literal_integer_rejects_equal_boolean() -> None: + @type_check() + def identify(value: Literal[1]) -> Literal[1]: + return value + + with pytest.raises( + TypeError, + match=r"^value expected Literal\[1\], got bool$", + ): + identify(True) # type: ignore[arg-type] + + +def test_literal_boolean_rejects_equal_integer() -> None: + @type_check() + def identify(value: Literal[True]) -> Literal[True]: + return value + + with pytest.raises( + TypeError, + match=r"^value expected Literal\[True\], got int$", + ): + identify(1) # type: ignore[arg-type] + + +def test_function_local_forward_reference_resolves_on_first_call() -> None: + class LocalValue: + pass + + @type_check() + def identify(value: LocalValue) -> object: + return value + + value: LocalValue = LocalValue() + + assert identify(value) is value + + +def test_free_function_forward_reference_cannot_be_spoofed_by_argument() -> None: + class ExpectedValue: + pass + + @type_check() + def identify(value: ExpectedValue) -> object: + return value + + SpoofValue: type[object] = type("ExpectedValue", (), {}) + + with pytest.raises( + TypeError, + match=r"^value expected ExpectedValue, got ExpectedValue$", + ): + identify(SpoofValue()) # type: ignore[arg-type] + + +def test_nested_local_class_self_reference_is_unsupported() -> None: + class Node: + @type_check() + def connect(self, other: Node) -> Node: + return other + + with pytest.raises(NameError, match=r"name 'Node' is not defined"): + Node().connect(Node()) + + +def test_copied_instance_descriptor_cannot_forge_nested_owner() -> None: + class Node: + @type_check() + def connect(self, other: Node) -> object: + return other + + descriptor: object = inspect.getattr_static(Node, "connect") + ForgedNode: type[object] = type( + "Node", + (), + { + "__module__": Node.__module__, + "connect": descriptor, + }, + ) + ForgedNode.__qualname__ = Node.__qualname__ + forged: object = ForgedNode() + + with pytest.raises(NameError, match=r"name 'Node' is not defined"): + Node.connect(forged, forged) # type: ignore[arg-type] + + +def test_copied_classmethod_descriptor_cannot_forge_nested_owner() -> None: + class Node: + @classmethod + @type_check() + def connect(cls, other: Node) -> object: + return other + + descriptor: object = inspect.getattr_static(Node, "connect") + ForgedNode: type[object] = type( + "Node", + (), + { + "__module__": Node.__module__, + "connect": descriptor, + }, + ) + ForgedNode.__qualname__ = Node.__qualname__ + forged: object = ForgedNode() + + with pytest.raises(NameError, match=r"name 'Node' is not defined"): + ForgedNode.connect(forged) # type: ignore[attr-defined] + + +def test_module_class_self_reference_resolves_after_class_creation() -> None: + namespace: dict[str, object] = {"type_check": type_check} + exec( + "from __future__ import annotations\n" + "class Node:\n" + " @type_check()\n" + " def connect(self, other: Node) -> object:\n" + " return other\n", + namespace, + ) + node_type: type[object] = cast(type[object], namespace["Node"]) + first: object = node_type() + second: object = node_type() + attribute: str = "connect" + connect = cast(Callable[[object], object], getattr(first, attribute)) + + assert connect(second) is second + + +def test_module_forward_reference_resolves_after_definition() -> None: + namespace: dict[str, object] = {"type_check": type_check} + exec( + "from __future__ import annotations\n" + "@type_check()\n" + "def identify(value: LaterValue) -> object:\n" + " return value\n" + "class LaterValue:\n" + " pass\n", + namespace, + ) + identify = cast(Callable[[object], object], namespace["identify"]) + later_value = cast(type[object], namespace["LaterValue"])() + + assert identify(later_value) is later_value + + +def test_unresolved_forward_reference_raises_only_when_called() -> None: + @type_check() + def identify(value: object) -> object: + return value + + original = inspect.unwrap(identify) + original.__annotations__["value"] = "MissingValue" + + assert callable(identify) + + with pytest.raises(NameError, match=r"name 'MissingValue' is not defined"): + identify(object()) + + +def test_resolved_type_hints_are_cached_after_success() -> None: + @type_check() + def identify(value: int) -> object: + return value + + assert identify(1) == 1 + + original = inspect.unwrap(identify) + original.__annotations__["value"] = "MissingAfterResolution" + + assert identify(2) == 2 + + +def test_function_metadata_and_signature_are_preserved() -> None: + def original(value: int, *, scale: float = 1.0) -> float: + """Scale a value.""" + return value * scale + + decorated = type_check(value=GreaterThan(0))(original) + + assert decorated.__name__ == original.__name__ + assert decorated.__qualname__ == original.__qualname__ + assert decorated.__module__ == original.__module__ + assert decorated.__doc__ == original.__doc__ + assert decorated.__annotations__ == original.__annotations__ + assert inspect.unwrap(decorated) is original + assert inspect.signature(decorated) == inspect.signature(original) + + +def test_signature_binding_errors_propagate() -> None: + @type_check() + def add(left: int, right: int) -> int: + return left + right + + with pytest.raises(TypeError, match=r"missing a required argument: 'right'"): + add(1) # type: ignore[call-arg] + + with pytest.raises(TypeError, match=r"multiple values for argument 'left'"): + add(1, left=2, right=3) # type: ignore[misc] + + with pytest.raises(TypeError, match=r"unexpected keyword argument 'extra'"): + add(left=1, right=2, extra=3) # type: ignore[call-arg] + + +def test_constraint_cannot_target_variadic_positional_parameter() -> None: + decorator = type_check(values=GreaterThan(0)) + + with pytest.raises( + TypeError, + match=r"^constraint for variadic parameter 'values' is not supported$", + ): + + @decorator + def collect(*values: object) -> tuple[object, ...]: + return values + + assert callable(collect) + + +def test_constraint_cannot_target_variadic_keyword_parameter() -> None: + decorator = type_check(options=GreaterThan(0)) + + with pytest.raises( + TypeError, + match=r"^constraint for variadic parameter 'options' is not supported$", + ): + + @decorator + def collect(**options: object) -> dict[str, object]: + return options + + assert callable(collect) + + +def test_unconstrained_variadic_annotations_are_checked_elementwise() -> None: + @type_check() + def collect(*values: int, **options: int) -> tuple[tuple[int, ...], dict[str, int]]: + return values, options + + assert collect(1, 2, enabled=3) == ((1, 2), {"enabled": 3}) + + with pytest.raises(TypeError, match=r"^values expected int, got str$"): + collect(1, "2") # type: ignore[arg-type] + + with pytest.raises(TypeError, match=r"^options expected int, got str$"): + collect(enabled="3") # type: ignore[arg-type] + + +def test_wrapped_function_errors_propagate() -> None: + @type_check() + def explode(value: int) -> int: + raise RuntimeError(f"boom: {value}") + + with pytest.raises(RuntimeError, match=r"^boom: 1$"): + explode(1) + + +def test_decorators_keep_independent_constraint_configuration() -> None: + @type_check(value=GreaterThan(0)) + def positive(value: int) -> int: + return value + + @type_check(value=LessThan(0)) + def negative(value: int) -> int: + return value + + assert positive(1) == 1 + assert negative(-1) == -1 + + with pytest.raises(ValueError, match=r"must be greater than 0$"): + positive(-1) + + with pytest.raises(ValueError, match=r"must be less than 0$"): + negative(1) + + +def test_aliases_preserve_identity_and_behavior() -> None: + assert Gt is GreaterThan + assert enforce_type_hints is type_check + + @enforce_type_hints(value=Gt(0)) + def identity(value: int) -> int: + return value + + assert identity(1) == 1 + + with pytest.raises(ValueError, match=r"^value=0 must be greater than 0$"): + identity(0) diff --git a/CH_07_generators_and_coroutines/README.rst b/CH_07_generators_and_coroutines/README.rst index ba0ff7c..5c5bf8f 100644 --- a/CH_07_generators_and_coroutines/README.rst +++ b/CH_07_generators_and_coroutines/README.rst @@ -1,7 +1,10 @@ Chapter 7 - generators and coroutines -======================================================================================================================= +===================================== -1. Create a generator similar to `itertools.islice()` that allows for a negative step so you can execute `some_list[20:10:-1]`. -2. Create a class that wraps a generator so it becomes sliceable by using `itertools.islice()` internally. -3. Write a generator for the Fibonacci numbers. -4. Write a generator that uses the sieve of Eratosthenes to generate prime numbers. +Exercises +--------- + +1. `Create a generator similar to \`itertools.islice()\` that allows for a negative step so you can execute \`some_list[20:10:-1]\`. `_ +2. `Create a class that wraps a generator so it becomes sliceable by using \`itertools.islice()\` internally. `_ +3. `Write a generator for the Fibonacci numbers. `_ +4. `Write a generator that uses the sieve of Eratosthenes to generate prime numbers. `_ diff --git a/CH_07_generators_and_coroutines/exercise_01/README.rst b/CH_07_generators_and_coroutines/exercise_01/README.rst new file mode 100644 index 0000000..9661f3f --- /dev/null +++ b/CH_07_generators_and_coroutines/exercise_01/README.rst @@ -0,0 +1,59 @@ +Exercise 1: iterable slicing with negative steps +================================================ + +Question +-------- + +1. Create a generator similar to `itertools.islice()` that allows for a negative step so you can execute `some_list[20:10:-1]`. + +Solution +-------- + +``islice(iterable, start, stop, step=1)`` returns an iterator. It first +normalizes all three slice arguments through ``operator.index`` without +touching the source. Integer-compatible objects are accepted, while floats and +other invalid indices raise ``TypeError`` immediately. + +For a positive ``step``, the function delegates directly to +``itertools.islice``. The source remains streaming and lazy: values are +consumed only as the result requests them, and the standard library's +restrictions on negative bounds still apply. + +A zero ``step`` raises ``ValueError`` immediately. For a negative ``step``, the +function materializes the source exactly once, applies an ordinary list slice, +and returns an iterator over that result. This provides list-slice behavior for +negative bounds, out-of-range bounds, empty slices, and step magnitudes greater +than one. + +Materialization means negative-step slicing requires a finite iterable and uses +:math:`O(n)` temporary memory. Calling it with an infinite iterable never +terminates. Positive-step slicing does not have this limitation. + +Dependencies +------------ + +The solution requires Python 3.10 or newer and uses only the standard library. +The repository's ``dev`` dependency group supplies pytest and the static +analysis tools. + +Run +--- + +Run the guarded module demonstration from the repository root: + +.. code-block:: console + + $ uv run python -m CH_07_generators_and_coroutines.exercise_01.solution_00 + +Run the focused tests with warnings treated as errors: + +.. code-block:: console + + $ uv run pytest -W error CH_07_generators_and_coroutines/exercise_01/test_solution_00.py + +Reference +--------- + +The chapter's original iterable-slicing discussion is preserved in the pinned +`T_07_islice.rst source +`_. diff --git a/CH_07_generators_and_coroutines/exercise_01/solution_00.py b/CH_07_generators_and_coroutines/exercise_01/solution_00.py index 69cdc78..7c674d3 100644 --- a/CH_07_generators_and_coroutines/exercise_01/solution_00.py +++ b/CH_07_generators_and_coroutines/exercise_01/solution_00.py @@ -1,31 +1,47 @@ -# Create a generator similar to `itertools.islice()` that allows for a -# negative step so you can execute `some_list[20:10:-1]`. -import itertools +"""Slice iterables with support for negative steps.""" +from collections.abc import Iterable, Iterator +from itertools import islice as stdlib_islice +from operator import index +from typing import TypeVar -def islice(iterable, start, stop, step): - if step > 0: - assert start <= stop, 'start must be less than stop' - yield from itertools.islice(iterable, start, stop, step) - else: - assert start >= stop, 'start must be greater than stop for negative step' +T = TypeVar("T") - output = [] - for i, item in enumerate(iterable): - if i >= stop: - output.append(item) - if i >= start: - break - yield from reversed(output) +def islice( + iterable: Iterable[T], + start: int, + stop: int, + step: int = 1, +) -> Iterator[T]: + """Return an iterator over the requested slice of *iterable*. + Positive steps retain :func:`itertools.islice` streaming behavior. + Negative steps first materialize the iterable, which must therefore be + finite, and then apply ordinary list-slice semantics. + """ + normalized_start: int = index(start) + normalized_stop: int = index(stop) + normalized_step: int = index(step) -def main(): - some_iterable = iter(range(100)) - print(list(islice(some_iterable, 20, 10, -1))) + if normalized_step == 0: + raise ValueError("step must not be zero") + if normalized_step > 0: + return stdlib_islice( + iterable, + normalized_start, + normalized_stop, + normalized_step, + ) - print(list(islice(some_iterable, 10, 20, 1))) + values: list[T] = list(iterable) + return iter(values[normalized_start:normalized_stop:normalized_step]) -if __name__ == '__main__': +def main() -> None: + """Demonstrate a negative-step iterable slice.""" + print(list(islice(range(30), 20, 10, -1))) + + +if __name__ == "__main__": main() diff --git a/CH_07_generators_and_coroutines/exercise_01/test_solution_00.py b/CH_07_generators_and_coroutines/exercise_01/test_solution_00.py new file mode 100644 index 0000000..461c831 --- /dev/null +++ b/CH_07_generators_and_coroutines/exercise_01/test_solution_00.py @@ -0,0 +1,317 @@ +from __future__ import annotations + +import subprocess +import sys +from collections.abc import Iterator +from itertools import islice as stdlib_islice +from pathlib import Path +from typing import cast + +import pytest + +from .solution_00 import islice + + +class OneShotIterable: + def __init__(self, values: list[int]) -> None: + self.values: list[int] = values + self.iterations: int = 0 + + def __iter__(self) -> Iterator[int]: + self.iterations += 1 + if self.iterations > 1: + raise AssertionError("iterable was iterated more than once") + return iter(self.values) + + +class TrackingIterable: + def __init__(self, values: list[int]) -> None: + self.values: list[int] = values + self.consumed: list[int] = [] + + def __iter__(self) -> Iterator[int]: + for value in self.values: + self.consumed.append(value) + yield value + + +class RaisingIterable: + def __init__(self, error: RuntimeError) -> None: + self.error: RuntimeError = error + self.iterations: int = 0 + + def __iter__(self) -> Iterator[int]: + self.iterations += 1 + yield 1 + raise self.error + + +class FailOnIteration: + def __init__(self) -> None: + self.iterations: int = 0 + + def __iter__(self) -> Iterator[int]: + self.iterations += 1 + raise AssertionError("source must not be iterated") + + +class IndexCompatible: + def __init__(self, value: int) -> None: + self.value: int = value + self.calls: int = 0 + + def __index__(self) -> int: + self.calls += 1 + return self.value + + +@pytest.mark.parametrize( + ("start", "stop", "step"), + [ + (0, 10, 1), + (1, 10, 2), + (3, 3, 1), + (4, 50, 3), + (10, 2, -1), + (10, 1, -3), + (3, 8, -1), + ], +) +def test_islice_matches_list_slice_for_nonnegative_bounds( + start: int, + stop: int, + step: int, +) -> None: + values: list[int] = list(range(12)) + + assert list(islice(values, start, stop, step)) == values[start:stop:step] + + +@pytest.mark.parametrize( + ("values", "start", "stop", "step"), + [ + ([], 0, 10, 1), + ([], 10, 0, -1), + ([1, 2, 3], 20, 30, 1), + ([1, 2, 3], 20, 10, -1), + ([1, 2, 3], 0, 20, -1), + ], +) +def test_empty_and_out_of_range_slices( + values: list[int], + start: int, + stop: int, + step: int, +) -> None: + assert list(islice(values, start, stop, step)) == values[start:stop:step] + + +@pytest.mark.parametrize( + ("start", "stop", "step"), + [ + (-1, -8, -1), + (-2, -11, -2), + (8, -20, -3), + (-20, -1, -1), + ], +) +def test_negative_step_supports_negative_bounds( + start: int, + stop: int, + step: int, +) -> None: + values: list[int] = list(range(10)) + + assert list(islice(values, start, stop, step)) == values[start:stop:step] + + +def test_zero_step_raises_immediately_without_iterating_source() -> None: + source: OneShotIterable = OneShotIterable([1, 2, 3]) + + with pytest.raises(ValueError): + islice(source, 0, 3, 0) + + assert source.iterations == 0 + + +def test_step_defaults_to_one() -> None: + assert list(islice(range(8), 2, 6)) == [2, 3, 4, 5] + + +@pytest.mark.parametrize( + ("start", "stop", "step"), + [ + (cast(int, 1.5), 4, 1), + (4, cast(int, -0.5), -1), + (4, 0, cast(int, -1.5)), + ], +) +def test_invalid_indices_fail_before_source_iteration( + start: int, + stop: int, + step: int, +) -> None: + source: FailOnIteration = FailOnIteration() + + with pytest.raises(TypeError): + islice(source, start, stop, step) + + assert source.iterations == 0 + + +@pytest.mark.parametrize( + ("start_value", "stop_value", "step_value", "expected"), + [ + (1, 7, 2, [1, 3, 5]), + (7, 1, -2, [7, 5, 3]), + ], +) +def test_index_compatible_arguments_are_normalized( + start_value: int, + stop_value: int, + step_value: int, + expected: list[int], +) -> None: + start: IndexCompatible = IndexCompatible(start_value) + stop: IndexCompatible = IndexCompatible(stop_value) + step: IndexCompatible = IndexCompatible(step_value) + + result: list[int] = list( + islice( + range(9), + cast(int, start), + cast(int, stop), + cast(int, step), + ) + ) + + assert result == expected + assert (start.calls, stop.calls, step.calls) == (1, 1, 1) + + +def test_index_compatible_zero_step_fails_before_source_iteration() -> None: + source: FailOnIteration = FailOnIteration() + step: IndexCompatible = IndexCompatible(0) + + with pytest.raises(ValueError): + islice(source, 0, 3, cast(int, step)) + + assert step.calls == 1 + assert source.iterations == 0 + + +@pytest.mark.parametrize( + ("start", "stop"), + [ + (-1, 3), + (0, -1), + (-4, -1), + ], +) +def test_positive_step_negative_indices_match_stdlib_errors( + start: int, + stop: int, +) -> None: + with pytest.raises(ValueError) as expected_error: + stdlib_islice([1, 2, 3], start, stop, 1) + + with pytest.raises(ValueError) as actual_error: + islice([1, 2, 3], start, stop, 1) + + assert str(actual_error.value) == str(expected_error.value) + + +@pytest.mark.parametrize( + ("start", "stop", "step"), + [ + (1, 6, 2), + (6, 1, -2), + ], +) +def test_one_shot_iterable_is_iterated_once( + start: int, + stop: int, + step: int, +) -> None: + source: OneShotIterable = OneShotIterable(list(range(8))) + + assert list(islice(source, start, stop, step)) == source.values[start:stop:step] + assert source.iterations == 1 + + +def test_positive_step_consumes_lazily_and_only_to_stop() -> None: + source: TrackingIterable = TrackingIterable(list(range(20))) + result: Iterator[int] = islice(source, 2, 7, 2) + + assert source.consumed == [] + assert next(result) == 2 + assert source.consumed == [0, 1, 2] + assert list(result) == [4, 6] + assert source.consumed == list(range(7)) + + +def test_negative_step_materializes_source_once_at_call_time() -> None: + source: TrackingIterable = TrackingIterable(list(range(8))) + + result: Iterator[int] = islice(source, 6, 1, -2) + + assert source.consumed == list(range(8)) + assert list(result) == [6, 4, 2] + + +@pytest.mark.parametrize( + ("start", "stop", "step"), + [ + (0, 3, 1), + (2, 0, -1), + ], +) +def test_source_exception_propagates_and_source_is_iterated_once( + start: int, + stop: int, + step: int, +) -> None: + error: RuntimeError = RuntimeError("source failed") + source: RaisingIterable = RaisingIterable(error) + + with pytest.raises(RuntimeError) as caught: + list(islice(source, start, stop, step)) + + assert caught.value is error + assert source.iterations == 1 + + +def test_import_is_silent() -> None: + repository_root: Path = Path(__file__).parents[2] + completed: subprocess.CompletedProcess[str] = subprocess.run( + [ + sys.executable, + "-c", + "import CH_07_generators_and_coroutines.exercise_01.solution_00", + ], + cwd=repository_root, + check=True, + capture_output=True, + text=True, + ) + + assert completed.stdout == "" + assert completed.stderr == "" + + +def test_module_demonstration() -> None: + repository_root: Path = Path(__file__).parents[2] + completed: subprocess.CompletedProcess[str] = subprocess.run( + [ + sys.executable, + "-m", + "CH_07_generators_and_coroutines.exercise_01.solution_00", + ], + cwd=repository_root, + check=True, + capture_output=True, + text=True, + ) + + assert completed.stdout == "[20, 19, 18, 17, 16, 15, 14, 13, 12, 11]\n" + assert completed.stderr == "" diff --git a/CH_07_generators_and_coroutines/exercise_02/README.rst b/CH_07_generators_and_coroutines/exercise_02/README.rst new file mode 100644 index 0000000..5a0a4d1 --- /dev/null +++ b/CH_07_generators_and_coroutines/exercise_02/README.rst @@ -0,0 +1,53 @@ +Exercise 2: sliceable generator +=============================== + +Question +-------- + +.. code-block:: text + + 2. Create a class that wraps a generator so it becomes sliceable by using `itertools.islice()` internally. + +Solution +-------- + +``SliceableGenerator`` converts its input iterable to one iterator and stores +that iterator for the wrapper's lifetime. Every slice is interpreted relative +to the iterator's current position, so an access consumes skipped values as +well as returned values. Later accesses continue after everything consumed by +earlier accesses. + +Omitted start, stop, and step values are supported, and a positive step is +passed through to ``itertools.islice``. Integer indexing is rejected because +the wrapper accepts slices only. Negative bounds and nonpositive steps are +also rejected. The wrapper cannot rewind, and an omitted stop will not +terminate when the underlying iterator is infinite. + +Dependencies +------------ + +The solution requires Python 3.10 or newer and uses only the standard library. +The repository's ``dev`` dependency group supplies pytest for the tests. + +Run +--- + +Run the bounded, guarded demonstration: + +.. code-block:: console + + $ uv run --python 3.10 python -m CH_07_generators_and_coroutines.exercise_02.solution_00 + +Run the focused tests: + +.. code-block:: console + + $ uv run --python 3.10 pytest CH_07_generators_and_coroutines/exercise_02/test_solution_00.py + +Historical source +----------------- + +The standard slicing discussion is preserved in the immutable upstream +`T_07_islice.rst source +`_. +The canonical solution and tests are self-contained. diff --git a/CH_07_generators_and_coroutines/exercise_02/solution_00.py b/CH_07_generators_and_coroutines/exercise_02/solution_00.py index 24d69b4..a1ba5b0 100644 --- a/CH_07_generators_and_coroutines/exercise_02/solution_00.py +++ b/CH_07_generators_and_coroutines/exercise_02/solution_00.py @@ -1,21 +1,57 @@ -# Create a class that wraps a generator so it becomes sliceable by using -# `itertools.islice()` internally. +"""Expose non-negative slices over one consumable iterator.""" + +from __future__ import annotations + import itertools +from collections.abc import Iterable, Iterator +from typing import Generic, Protocol, TypeVar, cast +T = TypeVar("T") -class SliceableGenerator: - def __init__(self, gen): - self.gen = gen - def __getitem__(self, index): - return list(itertools.islice(self.gen, index.start, index.stop)) +class _SliceIndex(Protocol): + @property + def start(self) -> int | None: ... + @property + def stop(self) -> int | None: ... -def main(): - generator = SliceableGenerator(itertools.count()) - print(generator[10:20]) + @property + def step(self) -> int | None: ... -if __name__ == '__main__': - main() +def _require_slice(index: object) -> _SliceIndex: + """Return a slice or reject another runtime index type.""" + if not isinstance(index, slice): + raise TypeError("SliceableGenerator accepts slices only") + return cast(_SliceIndex, index) + + +class SliceableGenerator(Generic[T]): + """Apply each slice relative to one iterator's current position.""" + + def __init__(self, iterable: Iterable[T]) -> None: + self._iterator: Iterator[T] = iter(iterable) + def __getitem__(self, index: slice) -> list[T]: + """Consume and return the requested non-negative forward slice.""" + normalized: _SliceIndex = _require_slice(index) + start: int = 0 if normalized.start is None else normalized.start + stop: int | None = normalized.stop + step: int = 1 if normalized.step is None else normalized.step + if start < 0 or (stop is not None and stop < 0): + raise ValueError("slice bounds must be non-negative") + if step <= 0: + raise ValueError("slice step must be positive") + return list(itertools.islice(self._iterator, start, stop, step)) + + +def main() -> None: + """Demonstrate a bounded slice from an infinite iterator.""" + generator: SliceableGenerator[int] = SliceableGenerator(itertools.count()) + values: list[int] = generator[10:20] + print(values) + + +if __name__ == "__main__": + main() diff --git a/CH_07_generators_and_coroutines/exercise_02/test_solution_00.py b/CH_07_generators_and_coroutines/exercise_02/test_solution_00.py new file mode 100644 index 0000000..c85619d --- /dev/null +++ b/CH_07_generators_and_coroutines/exercise_02/test_solution_00.py @@ -0,0 +1,121 @@ +"""Tests for one-shot generator slicing.""" + +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path +from typing import cast + +import pytest + +from CH_07_generators_and_coroutines.exercise_02.solution_00 import ( + SliceableGenerator, +) + + +def test_slices_consume_one_underlying_iterator_sequentially() -> None: + values: SliceableGenerator[int] = SliceableGenerator(iter(range(30))) + + assert values[2:6] == [2, 3, 4, 5] + assert values[0:3] == [6, 7, 8] + + +def test_positive_step_is_relative_to_current_position() -> None: + values: SliceableGenerator[int] = SliceableGenerator(range(12)) + + assert values[1:8:3] == [1, 4, 7] + assert values[:2] == [8, 9] + + +def test_omitted_bounds_and_step_are_supported() -> None: + values: SliceableGenerator[int] = SliceableGenerator(range(10)) + + assert values[:3] == [0, 1, 2] + assert values[1::2] == [4, 6, 8] + + +def test_empty_and_exhausted_slices_return_empty_lists() -> None: + values: SliceableGenerator[int] = SliceableGenerator(range(3)) + + assert values[0:0] == [] + assert values[:] == [0, 1, 2] + assert values[:4] == [] + + +@pytest.mark.parametrize( + "invalid_slice", + [ + slice(-1, 3), + slice(0, -1), + slice(-2, -1), + ], +) +def test_negative_slice_bound_is_rejected_without_consuming( + invalid_slice: slice, +) -> None: + values: SliceableGenerator[int] = SliceableGenerator(range(5)) + + with pytest.raises(ValueError, match="non-negative"): + values[invalid_slice] + + assert values[:2] == [0, 1] + + +@pytest.mark.parametrize("step", [0, -1, -5]) +def test_nonpositive_step_is_rejected_without_consuming(step: int) -> None: + values: SliceableGenerator[int] = SliceableGenerator(range(5)) + + with pytest.raises(ValueError, match="positive"): + values[::step] + + assert values[:2] == [0, 1] + + +def test_integer_indexing_is_rejected_without_consuming() -> None: + values: SliceableGenerator[int] = SliceableGenerator(range(5)) + + with pytest.raises(TypeError, match="slices only"): + values[cast(slice, 1)] + + assert values[:2] == [0, 1] + + +def test_import_is_silent() -> None: + repository_root: Path = Path(__file__).parents[2] + completed: subprocess.CompletedProcess[str] = subprocess.run( + [ + sys.executable, + "-c", + "import CH_07_generators_and_coroutines.exercise_02.solution_00", + ], + cwd=repository_root, + check=False, + capture_output=True, + text=True, + timeout=5, + ) + + assert completed.returncode == 0 + assert completed.stdout == "" + assert completed.stderr == "" + + +def test_guarded_demo_is_bounded() -> None: + repository_root: Path = Path(__file__).parents[2] + completed: subprocess.CompletedProcess[str] = subprocess.run( + [ + sys.executable, + "-m", + "CH_07_generators_and_coroutines.exercise_02.solution_00", + ], + cwd=repository_root, + check=False, + capture_output=True, + text=True, + timeout=5, + ) + + assert completed.returncode == 0 + assert completed.stdout == "[10, 11, 12, 13, 14, 15, 16, 17, 18, 19]\n" + assert completed.stderr == "" diff --git a/CH_07_generators_and_coroutines/exercise_03/README.rst b/CH_07_generators_and_coroutines/exercise_03/README.rst new file mode 100644 index 0000000..4e69b3d --- /dev/null +++ b/CH_07_generators_and_coroutines/exercise_03/README.rst @@ -0,0 +1,46 @@ +Exercise 3: Fibonacci generator +=============================== + +Question +-------- + +.. code-block:: text + + 3. Write a generator for the Fibonacci numbers. + +Solution +-------- + +``fibonacci`` returns a new infinite iterator yielding +``0, 1, 1, 2, 3, ...``. Each iterator stores only its current pair of +arbitrary-precision Python integers. Separate calls have independent state, +and consumers must bound iteration when they need a finite result. + +Dependencies +------------ + +The solution requires Python 3.10 or newer and uses only the standard library. +The repository's ``dev`` dependency group supplies pytest for the tests. + +Run +--- + +Run the bounded, guarded demonstration: + +.. code-block:: console + + $ uv run --python 3.10 python -m CH_07_generators_and_coroutines.exercise_03.solution_00 + +Run the focused tests: + +.. code-block:: console + + $ uv run --python 3.10 pytest CH_07_generators_and_coroutines/exercise_03/test_solution_00.py + +Historical source +----------------- + +The repository's initial answer is preserved in the immutable upstream +`original Fibonacci solution +`_. +The canonical solution and tests are self-contained. diff --git a/CH_07_generators_and_coroutines/exercise_03/solution_00.py b/CH_07_generators_and_coroutines/exercise_03/solution_00.py index 532365b..d37d87a 100644 --- a/CH_07_generators_and_coroutines/exercise_03/solution_00.py +++ b/CH_07_generators_and_coroutines/exercise_03/solution_00.py @@ -1,18 +1,23 @@ -# Write a generator for the Fibonacci numbers. +"""Generate the infinite Fibonacci sequence.""" -def fibonacci(): - a, b = 0, 1 +from collections.abc import Iterator +from itertools import islice + + +def fibonacci() -> Iterator[int]: + """Yield ``0, 1, 1, 2, ...`` forever.""" + current: int = 0 + following: int = 1 while True: - yield a - a, b = b, a + b + yield current + current, following = following, current + following -def main(): - fib = fibonacci() - for _ in range(10): - print(next(fib)) +def main() -> None: + """Print a bounded prefix of the infinite sequence.""" + numbers: list[int] = list(islice(fibonacci(), 10)) + print(numbers) -if __name__ == '__main__': +if __name__ == "__main__": main() - diff --git a/CH_07_generators_and_coroutines/exercise_03/test_solution_00.py b/CH_07_generators_and_coroutines/exercise_03/test_solution_00.py new file mode 100644 index 0000000..3be7f24 --- /dev/null +++ b/CH_07_generators_and_coroutines/exercise_03/test_solution_00.py @@ -0,0 +1,84 @@ +"""Tests for the infinite Fibonacci generator.""" + +from __future__ import annotations + +import subprocess +import sys +from collections.abc import Iterator +from itertools import islice +from pathlib import Path +from typing import get_type_hints + +from CH_07_generators_and_coroutines.exercise_03.solution_00 import fibonacci + + +def test_first_ten_fibonacci_numbers() -> None: + assert list(islice(fibonacci(), 10)) == [ + 0, + 1, + 1, + 2, + 3, + 5, + 8, + 13, + 21, + 34, + ] + + +def test_generator_instances_advance_independently() -> None: + first: Iterator[int] = fibonacci() + second: Iterator[int] = fibonacci() + + assert [next(first), next(first), next(second), next(first)] == [0, 1, 0, 1] + + +def test_hundredth_value_is_generated_iteratively() -> None: + hundredth: int = next(islice(fibonacci(), 100, 101)) + + assert hundredth == 354224848179261915075 + + +def test_return_type_is_explicit() -> None: + assert get_type_hints(fibonacci)["return"] == Iterator[int] + + +def test_import_is_silent() -> None: + repository_root: Path = Path(__file__).parents[2] + completed: subprocess.CompletedProcess[str] = subprocess.run( + [ + sys.executable, + "-c", + "import CH_07_generators_and_coroutines.exercise_03.solution_00", + ], + cwd=repository_root, + check=False, + capture_output=True, + text=True, + timeout=5, + ) + + assert completed.returncode == 0 + assert completed.stdout == "" + assert completed.stderr == "" + + +def test_guarded_demo_is_bounded() -> None: + repository_root: Path = Path(__file__).parents[2] + completed: subprocess.CompletedProcess[str] = subprocess.run( + [ + sys.executable, + "-m", + "CH_07_generators_and_coroutines.exercise_03.solution_00", + ], + cwd=repository_root, + check=False, + capture_output=True, + text=True, + timeout=5, + ) + + assert completed.returncode == 0 + assert completed.stdout == "[0, 1, 1, 2, 3, 5, 8, 13, 21, 34]\n" + assert completed.stderr == "" diff --git a/CH_07_generators_and_coroutines/exercise_04/README.rst b/CH_07_generators_and_coroutines/exercise_04/README.rst new file mode 100644 index 0000000..0135fee --- /dev/null +++ b/CH_07_generators_and_coroutines/exercise_04/README.rst @@ -0,0 +1,53 @@ +Exercise 4: prime generator +=========================== + +Question +-------- + +.. code-block:: text + + 4. Write a generator that uses the sieve of Eratosthenes to generate prime numbers. + +Solution +-------- + +``generate_primes`` implements an incremental dictionary-based sieve of +Eratosthenes. A composite candidate maps to the prime factors whose next +multiple it represents. A missing candidate is prime; it is yielded and its +square enters the dictionary. Composite entries move forward by their prime +steps. + +The iterator yields primes forever in ascending order, so consumers must bound +iteration for finite results. Each call owns an independent sieve dictionary. +The dictionary grows as iteration advances; no machine-speed or memory-size +assumption is part of the public behavior. + +Dependencies +------------ + +The solution requires Python 3.10 or newer and uses only the standard library. +The repository's ``dev`` dependency group supplies pytest for the tests. + +Run +--- + +Run the bounded, guarded demonstration: + +.. code-block:: console + + $ uv run --python 3.10 python -m CH_07_generators_and_coroutines.exercise_04.solution_00 + +Run the focused tests: + +.. code-block:: console + + $ uv run --python 3.10 pytest CH_07_generators_and_coroutines/exercise_04/test_solution_00.py + +Historical source +----------------- + +The repository's initial answer is preserved in the immutable upstream +`original prime solution +`_. +The local ``solution_01.py`` historical alternative remains byte-for-byte +unchanged. diff --git a/CH_07_generators_and_coroutines/exercise_04/solution_00.py b/CH_07_generators_and_coroutines/exercise_04/solution_00.py index d5394cd..b8cb974 100644 --- a/CH_07_generators_and_coroutines/exercise_04/solution_00.py +++ b/CH_07_generators_and_coroutines/exercise_04/solution_00.py @@ -1,23 +1,30 @@ -# Write a generator that uses the sieve of Eratosthenes to generate prime -# numbers. +"""Generate prime numbers with an incremental Eratosthenes sieve.""" -import itertools +from collections.abc import Iterator +from itertools import islice -def generate_primes(): - '''Generate prime numbers using the sieve of Eratosthenes.''' - primes = [] - for i in itertools.count(2): - if all(i % prime != 0 for prime in primes): - primes.append(i) - yield i +def generate_primes() -> Iterator[int]: + """Yield every prime in ascending order forever.""" + composites: dict[int, list[int]] = {} + candidate: int = 2 + while True: + factors: list[int] | None = composites.pop(candidate, None) + if factors is None: + yield candidate + composites[candidate * candidate] = [candidate] + else: + prime: int + for prime in factors: + composites.setdefault(candidate + prime, []).append(prime) + candidate += 1 -def main(): - primes = generate_primes() - for _ in range(20): - print(next(primes)) +def main() -> None: + """Print a bounded prefix of the infinite prime sequence.""" + primes: list[int] = list(islice(generate_primes(), 20)) + print(primes) -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/CH_07_generators_and_coroutines/exercise_04/test_solution_00.py b/CH_07_generators_and_coroutines/exercise_04/test_solution_00.py new file mode 100644 index 0000000..47fe41e --- /dev/null +++ b/CH_07_generators_and_coroutines/exercise_04/test_solution_00.py @@ -0,0 +1,117 @@ +"""Tests for the incremental prime sieve.""" + +from __future__ import annotations + +import subprocess +import sys +from collections.abc import Iterator +from itertools import islice +from pathlib import Path +from typing import get_type_hints + +from CH_07_generators_and_coroutines.exercise_04.solution_00 import ( + generate_primes, +) + + +def _is_prime(candidate: int) -> bool: + if candidate < 2: + return False + divisor: int = 2 + while divisor * divisor <= candidate: + if candidate % divisor == 0: + return False + divisor += 1 + return True + + +def _first_primes(count: int) -> list[int]: + primes: list[int] = [] + candidate: int = 2 + while len(primes) < count: + if _is_prime(candidate): + primes.append(candidate) + candidate += 1 + return primes + + +def test_first_twenty_primes() -> None: + assert list(islice(generate_primes(), 20)) == [ + 2, + 3, + 5, + 7, + 11, + 13, + 17, + 19, + 23, + 29, + 31, + 37, + 41, + 43, + 47, + 53, + 59, + 61, + 67, + 71, + ] + + +def test_first_two_hundred_match_trial_division_oracle() -> None: + assert list(islice(generate_primes(), 200)) == _first_primes(200) + + +def test_generator_instances_do_not_share_sieve_state() -> None: + first: Iterator[int] = generate_primes() + second: Iterator[int] = generate_primes() + + assert [next(first), next(first), next(second), next(first)] == [2, 3, 2, 5] + + +def test_return_type_is_explicit() -> None: + assert get_type_hints(generate_primes)["return"] == Iterator[int] + + +def test_import_is_silent() -> None: + repository_root: Path = Path(__file__).parents[2] + completed: subprocess.CompletedProcess[str] = subprocess.run( + [ + sys.executable, + "-c", + "import CH_07_generators_and_coroutines.exercise_04.solution_00", + ], + cwd=repository_root, + check=False, + capture_output=True, + text=True, + timeout=5, + ) + + assert completed.returncode == 0 + assert completed.stdout == "" + assert completed.stderr == "" + + +def test_guarded_demo_is_bounded() -> None: + repository_root: Path = Path(__file__).parents[2] + completed: subprocess.CompletedProcess[str] = subprocess.run( + [ + sys.executable, + "-m", + "CH_07_generators_and_coroutines.exercise_04.solution_00", + ], + cwd=repository_root, + check=False, + capture_output=True, + text=True, + timeout=5, + ) + + assert completed.returncode == 0 + assert completed.stdout == ( + "[2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43, 47, 53, 59, 61, 67, 71]\n" + ) + assert completed.stderr == "" diff --git a/CH_08_metaclasses/README.rst b/CH_08_metaclasses/README.rst index fd04f7c..e564fbd 100644 --- a/CH_08_metaclasses/README.rst +++ b/CH_08_metaclasses/README.rst @@ -1,8 +1,9 @@ Chapter 8 - metaclasses ======================================================================================================================= -1. Create a metaclass to test if attributes/methods are available. -2. Create a metaclass to test if specific classes are inherited. -3. Build a metaclass that wraps every method with a decorator (could be useful for logging/de- bugging purposes), something with a signature like this: +Exercises +--------- - class SomeClass(metaclass=WrappingMeta, wrapper=some_wrapper): +1. `Exercise 1: required attributes `_ +2. `Exercise 2: required base classes `_ +3. `Exercise 3: wrapping methods `_ diff --git a/CH_08_metaclasses/exercise_01/README.rst b/CH_08_metaclasses/exercise_01/README.rst new file mode 100644 index 0000000..a3467c2 --- /dev/null +++ b/CH_08_metaclasses/exercise_01/README.rst @@ -0,0 +1,54 @@ +Exercise 1: required class attributes +===================================== + +Question +-------- + +1. Create a metaclass to test if attributes/methods are available. + +Solution +-------- + +``ExpectedAttrsMeta`` forwards unrelated class keywords to normal class +construction, then validates the requested names on the completed class. +``inspect.getattr_static`` follows the method resolution order without invoking +descriptors. Attributes defined by direct, indirect, or multiple inheritance +therefore satisfy the contract, and every missing name is reported together in +the order requested. + +Post-creation validation has an intentional tradeoff: ``__init_subclass__`` and +descriptor ``__set_name__`` hooks have already run if the contract later rejects +the class. Validation still occurs after construction so the completed class +and its full inheritance behavior determine attribute availability. + +``Trade`` is a valid example requiring ``buy`` and ``sell``. Its methods return +``"buy"`` and ``"sell"`` respectively. + +Dependencies +------------ + +The solution requires Python 3.10 or newer and has no third-party runtime +dependencies. Tests use the repository's pytest development dependency. + +Run +--- + +Run the guarded demonstration from the repository root: + +.. code-block:: console + + $ uv run python -m CH_08_metaclasses.exercise_01.solution_00 + +Run the focused tests: + +.. code-block:: console + + $ uv run pytest CH_08_metaclasses/exercise_01/test_solution_00.py -v + +Reference +--------- + +The class-construction pattern is informed by +`T_01_basic_metaclass.rst `_. +The solution is self-contained and does not import the upstream material at +runtime. diff --git a/CH_08_metaclasses/exercise_01/solution_00.py b/CH_08_metaclasses/exercise_01/solution_00.py index 4a46159..ef56f9b 100644 --- a/CH_08_metaclasses/exercise_01/solution_00.py +++ b/CH_08_metaclasses/exercise_01/solution_00.py @@ -1,16 +1,66 @@ -# Create a metaclass to test if attributes/methods are available. +"""Validate required class attributes with a metaclass.""" + +from collections.abc import Mapping +from inspect import getattr_static + +_MISSING_ATTRIBUTE: object = object() + class ExpectedAttrsMeta(type): - _expected_attrs = ['buy', 'sell'] + """Require attributes to be available on each completed class.""" + + def __new__( + cls: type["ExpectedAttrsMeta"], + name: str, + bases: tuple[type, ...], + namespace: Mapping[str, object], + *, + required_attributes: tuple[str, ...] = (), + **kwargs: object, + ) -> "ExpectedAttrsMeta": + """Create a class, then validate its direct and inherited attributes.""" + class_namespace: dict[str, object] = dict(namespace) + created_class: ExpectedAttrsMeta = super().__new__( + cls, + name, + bases, + class_namespace, + **kwargs, + ) + missing: tuple[str, ...] = tuple( + attribute + for attribute in required_attributes + if getattr_static(created_class, attribute, _MISSING_ATTRIBUTE) + is _MISSING_ATTRIBUTE + ) + if missing: + missing_text: str = ", ".join(missing) + raise AttributeError( + f"{name} missing required attribute(s): {missing_text}" + ) + return created_class + + +class Trade( + metaclass=ExpectedAttrsMeta, + required_attributes=("buy", "sell"), +): + """Demonstrate a class satisfying a required-attribute contract.""" + + def buy(self) -> str: + """Return the demonstrated buy operation.""" + return "buy" + + def sell(self) -> str: + """Return the demonstrated sell operation.""" + return "sell" + - def __new__(cls, name, bases, attrs): - for attr in cls._expected_attrs: - if attr not in attrs: - raise AttributeError( - f'{attr} attribute is missing from {name} class' - ) - return super().__new__(cls, name, bases, attrs) +def main() -> None: + """Run the valid metaclass demonstration.""" + trade: Trade = Trade() + print(trade.buy(), trade.sell()) -class Trade(metaclass=ExpectedAttrsMeta): - pass +if __name__ == "__main__": + main() diff --git a/CH_08_metaclasses/exercise_01/test_solution_00.py b/CH_08_metaclasses/exercise_01/test_solution_00.py new file mode 100644 index 0000000..c6dc9e2 --- /dev/null +++ b/CH_08_metaclasses/exercise_01/test_solution_00.py @@ -0,0 +1,197 @@ +import subprocess +import sys +from typing import ClassVar + +import pytest + +from .solution_00 import ExpectedAttrsMeta, Trade + + +def test_trade_exposes_required_methods() -> None: + trade: Trade = Trade() + + assert trade.buy() == "buy" + assert trade.sell() == "sell" + + +def test_direct_methods_and_values_satisfy_requirements() -> None: + class Complete( + metaclass=ExpectedAttrsMeta, + required_attributes=("quantity", "execute"), + ): + quantity: int = 3 + + def execute(self) -> str: + return "executed" + + complete: Complete = Complete() + + assert complete.quantity == 3 + assert complete.execute() == "executed" + + +def test_inherited_attributes_satisfy_requirements() -> None: + class Parent: + inherited_value: int = 5 + + def inherited_method(self) -> str: + return "inherited" + + class Child( + Parent, + metaclass=ExpectedAttrsMeta, + required_attributes=("inherited_value", "inherited_method"), + ): + pass + + child: Child = Child() + + assert child.inherited_value == 5 + assert child.inherited_method() == "inherited" + + +def test_indirectly_inherited_attributes_satisfy_requirements() -> None: + class Grandparent: + inherited_value: int = 8 + + class Parent(Grandparent): + pass + + class Child( + Parent, + metaclass=ExpectedAttrsMeta, + required_attributes=("inherited_value",), + ): + pass + + assert Child.inherited_value == 8 + + +def test_inherited_descriptor_presence_is_checked_without_execution() -> None: + class RaisingDescriptor: + def __init__(self) -> None: + self.access_count: int = 0 + + def __get__( + self, + instance: object | None, + owner: type[object] | None, + ) -> object: + self.access_count += 1 + raise AttributeError("descriptor access is unsafe") + + raising_descriptor: RaisingDescriptor = RaisingDescriptor() + + class Parent: + descriptor: ClassVar[RaisingDescriptor] = raising_descriptor + + class Complete( + Parent, + metaclass=ExpectedAttrsMeta, + required_attributes=("descriptor",), + ): + pass + + assert issubclass(Complete, Parent) + assert Parent.__dict__["descriptor"] is raising_descriptor + assert raising_descriptor.access_count == 0 + + +def test_missing_members_are_aggregated_in_requested_order() -> None: + with pytest.raises(AttributeError) as error: + + class Broken( + metaclass=ExpectedAttrsMeta, + required_attributes=("sell", "buy", "quantity"), + ): + pass + + _ = Broken + + assert str(error.value) == ( + "Broken missing required attribute(s): sell, buy, quantity" + ) + + +def test_empty_requirements_allow_plain_class() -> None: + class Plain(metaclass=ExpectedAttrsMeta): + value: int = 7 + + assert Plain.value == 7 + + +def test_required_attributes_keyword_is_consumed() -> None: + class KeywordAware: + received_keywords: ClassVar[dict[str, object] | None] = None + + def __init_subclass__(cls, **kwargs: object) -> None: + super().__init_subclass__() + cls.received_keywords = dict(kwargs) + + class Complete( + KeywordAware, + metaclass=ExpectedAttrsMeta, + required_attributes=("value",), + ): + value: int = 11 + + assert Complete.received_keywords == {} + + +def test_unrelated_class_keyword_is_forwarded_to_base_hook() -> None: + class KeywordAware: + received_marker: ClassVar[str | None] = None + received_keywords: ClassVar[dict[str, object] | None] = None + + def __init_subclass__( + cls, + *, + marker: str, + **kwargs: object, + ) -> None: + super().__init_subclass__() + cls.received_marker = marker + cls.received_keywords = dict(kwargs) + + class Complete( + KeywordAware, + metaclass=ExpectedAttrsMeta, + required_attributes=("value",), + marker="forwarded", + ): + value: int = 11 + + assert Complete.received_marker == "forwarded" + assert Complete.received_keywords == {} + + +def test_module_import_is_silent() -> None: + completed: subprocess.CompletedProcess[str] = subprocess.run( + [ + sys.executable, + "-c", + "import CH_08_metaclasses.exercise_01.solution_00", + ], + check=True, + capture_output=True, + text=True, + ) + + assert completed.stdout == "" + assert completed.stderr == "" + + +def test_module_demo() -> None: + completed: subprocess.CompletedProcess[str] = subprocess.run( + [ + sys.executable, + "-m", + "CH_08_metaclasses.exercise_01.solution_00", + ], + check=True, + capture_output=True, + text=True, + ) + + assert completed.stdout == "buy sell\n" + assert completed.stderr == "" diff --git a/CH_08_metaclasses/exercise_02/README.rst b/CH_08_metaclasses/exercise_02/README.rst new file mode 100644 index 0000000..fe087f1 --- /dev/null +++ b/CH_08_metaclasses/exercise_02/README.rst @@ -0,0 +1,72 @@ +Exercise 2: required base classes +================================= + +Question +-------- + +.. code-block:: text + + 2. Create a metaclass to test if specific classes are inherited. + +Solution +-------- + +``ExpectedBasesMeta`` consumes the ``expected_bases`` class keyword in both +metaclass ``__new__`` and ``__init__``. Unrelated class keywords continue +cooperatively through both stages. The original namespace returned by +``__prepare__`` is forwarded by identity rather than copied, preserving custom +mapping behavior in a combined metaclass. + +Before class construction, ``expected_bases`` must be a tuple containing only +types. Invalid values raise +``TypeError(" expected_bases must be a tuple containing only types")`` +before descriptor ``__set_name__`` or base ``__init_subclass__`` hooks can run. +Accepted tuple subclasses are iterated once into a built-in tuple; item +validation and completed-class checks reuse that snapshot. An empty requirement +tuple allows an ordinary class. + +After that boundary check, the metaclass creates the class and uses +``issubclass`` against the completed class. Direct, indirect, multilevel, and +mixin inheritance therefore satisfy a requirement. + +Every missing base is reported together in requested order. For example, a +class named ``Broken`` missing ``FirstBase`` and ``SecondBase`` raises +``TypeError("Broken must inherit FirstBase, SecondBase")``. + +For a valid-shaped requirement tuple, missing-base validation after class +construction has an intentional tradeoff: descriptor ``__set_name__`` runs +first, then base ``__init_subclass__``, before the missing-base error is raised. +Their side effects are not rolled back. This timing is necessary to validate +the completed class and its full method resolution order. + +``Trade`` is a valid example requiring ``SomeBaseClass``. The guarded module +demonstration prints whether that inheritance requirement is satisfied. + +Dependencies +------------ + +The solution requires Python 3.10 or newer and has no third-party runtime +dependencies. Tests use the repository's pytest development dependency. + +Run +--- + +Run the guarded demonstration from the repository root: + +.. code-block:: console + + $ uv run python -m CH_08_metaclasses.exercise_02.solution_00 + +Run the focused tests: + +.. code-block:: console + + $ uv run pytest -W error CH_08_metaclasses/exercise_02/test_solution_00.py -v + +Reference +--------- + +The completed-class check follows the ``issubclass`` behavior demonstrated in +`T_05_custom_type_checks.rst `_. +The solution is self-contained and does not import upstream material at +runtime. diff --git a/CH_08_metaclasses/exercise_02/solution_00.py b/CH_08_metaclasses/exercise_02/solution_00.py index 60c9b01..5f5527a 100644 --- a/CH_08_metaclasses/exercise_02/solution_00.py +++ b/CH_08_metaclasses/exercise_02/solution_00.py @@ -1,25 +1,94 @@ -# Create a metaclass to test if specific classes are inherited. +"""Validate required base classes with a metaclass.""" + +from collections.abc import Mapping +from typing import cast + + +def _validate_expected_bases( + name: str, + expected_bases: object, +) -> tuple[type, ...]: + """Validate the required-base contract before class construction.""" + if not isinstance(expected_bases, tuple): + raise TypeError(f"{name} expected_bases must be a tuple containing only types") + requirement_source: tuple[object, ...] = cast( + tuple[object, ...], + expected_bases, + ) + requirements: tuple[object, ...] = tuple(iter(requirement_source)) + if not all(isinstance(base, type) for base in requirements): + raise TypeError(f"{name} expected_bases must be a tuple containing only types") + return cast(tuple[type, ...], requirements) class SomeBaseClass: - pass + """Provide the base required by the example class.""" class ExpectedBasesMeta(type): - _expected_bases = [SomeBaseClass] + """Require completed classes to inherit selected base classes.""" + + def __new__( + cls: type["ExpectedBasesMeta"], + name: str, + bases: tuple[type, ...], + namespace: Mapping[str, object], + *, + expected_bases: tuple[type, ...] = (), + **kwargs: object, + ) -> "ExpectedBasesMeta": + """Create a class and validate its full inheritance hierarchy.""" + requirements: tuple[type, ...] = _validate_expected_bases( + name, + expected_bases, + ) + class_namespace: dict[str, object] = cast(dict[str, object], namespace) + created_class: ExpectedBasesMeta = super().__new__( + cls, + name, + bases, + class_namespace, + **kwargs, + ) + missing_bases: tuple[type, ...] = tuple( + base for base in requirements if not issubclass(created_class, base) + ) + if missing_bases: + missing_names: str = ", ".join(base.__name__ for base in missing_bases) + raise TypeError(f"{name} must inherit {missing_names}") + return created_class + + def __init__( + self, + name: str, + bases: tuple[type, ...], + namespace: Mapping[str, object], + *, + expected_bases: tuple[type, ...] = (), + **kwargs: object, + ) -> None: + """Consume base requirements and cooperatively initialize the class.""" + class_namespace: dict[str, object] = cast(dict[str, object], namespace) + super().__init__(name, bases, class_namespace, **kwargs) + + +class Trade( + SomeBaseClass, + metaclass=ExpectedBasesMeta, + expected_bases=(SomeBaseClass,), +): + """Demonstrate a class satisfying its required-base contract.""" + - def __new__(cls, name, bases, attrs): - for base in cls._expected_bases: - if base not in bases: - raise TypeError( - f'{name} is not inheriting {base}' - ) - return super().__new__(cls, name, bases, attrs) +def _inherits_base(candidate: type, expected_base: type) -> bool: + """Return whether a class satisfies one base requirement.""" + return issubclass(candidate, expected_base) -class Trade(SomeBaseClass, metaclass=ExpectedBasesMeta): - pass +def main() -> None: + """Run the valid metaclass demonstration.""" + print(_inherits_base(Trade, SomeBaseClass)) -class BrokenTrade(metaclass=ExpectedBasesMeta): - pass +if __name__ == "__main__": + main() diff --git a/CH_08_metaclasses/exercise_02/test_solution_00.py b/CH_08_metaclasses/exercise_02/test_solution_00.py new file mode 100644 index 0000000..8fcd14d --- /dev/null +++ b/CH_08_metaclasses/exercise_02/test_solution_00.py @@ -0,0 +1,397 @@ +import subprocess +import sys +from collections.abc import Iterator, Mapping +from typing import ClassVar, cast + +import pytest + +from .solution_00 import ExpectedBasesMeta, SomeBaseClass, Trade + + +def test_trade_directly_inherits_expected_base() -> None: + trade: Trade = Trade() + + assert issubclass(Trade, SomeBaseClass) + assert isinstance(trade, SomeBaseClass) + + +def test_direct_inheritance_satisfies_requirement() -> None: + class RequiredBase: + pass + + class Complete( + RequiredBase, + metaclass=ExpectedBasesMeta, + expected_bases=(RequiredBase,), + ): + pass + + assert issubclass(Complete, RequiredBase) + + +def test_multiple_levels_of_indirect_inheritance_satisfy_requirement() -> None: + class RequiredBase: + pass + + class Grandparent(RequiredBase): + pass + + class Parent(Grandparent): + pass + + class Complete( + Parent, + metaclass=ExpectedBasesMeta, + expected_bases=(RequiredBase,), + ): + pass + + assert issubclass(Complete, RequiredBase) + + +def test_requirement_inherited_through_mixin_satisfies_requirement() -> None: + class RequiredBase: + pass + + class RequiredMixin(RequiredBase): + pass + + class UnrelatedBase: + pass + + class Complete( + UnrelatedBase, + RequiredMixin, + metaclass=ExpectedBasesMeta, + expected_bases=(RequiredBase,), + ): + pass + + assert issubclass(Complete, RequiredBase) + + +def test_empty_requirements_allow_plain_class() -> None: + class Plain(metaclass=ExpectedBasesMeta): + value: int = 7 + + assert Plain.value == 7 + + +def test_one_missing_base_reports_class_and_base_name() -> None: + class RequiredBase: + pass + + with pytest.raises(TypeError) as error: + + class Broken( + metaclass=ExpectedBasesMeta, + expected_bases=(RequiredBase,), + ): + pass + + _ = Broken + + assert str(error.value) == "Broken must inherit RequiredBase" + + +def test_multiple_missing_bases_are_aggregated_in_requested_order() -> None: + class PresentBase: + pass + + class FirstMissingBase: + pass + + class SecondMissingBase: + pass + + with pytest.raises(TypeError) as error: + + class Broken( + PresentBase, + metaclass=ExpectedBasesMeta, + expected_bases=( + FirstMissingBase, + PresentBase, + SecondMissingBase, + ), + ): + pass + + _ = Broken + + assert str(error.value) == "Broken must inherit FirstMissingBase, SecondMissingBase" + + +@pytest.mark.parametrize( + "invalid_expected_bases", + [ + None, + [SomeBaseClass], + (SomeBaseClass, "not-a-type"), + ], + ids=["none", "list", "non-type-item"], +) +def test_invalid_requirements_are_rejected_before_class_hook_side_effects( + invalid_expected_bases: object, +) -> None: + events: list[str] = [] + + class RecordingDescriptor: + def __set_name__(self, owner: type[object], name: str) -> None: + events.append(f"set_name:{name}") + + class HookBase: + def __init_subclass__(cls) -> None: + events.append("init_subclass") + super().__init_subclass__() + + with pytest.raises(TypeError) as error: + + class Broken( + HookBase, + metaclass=ExpectedBasesMeta, + expected_bases=cast( + tuple[type, ...], + invalid_expected_bases, + ), + ): + descriptor: ClassVar[RecordingDescriptor] = RecordingDescriptor() + + _ = Broken + + assert ( + str(error.value) + == "Broken expected_bases must be a tuple containing only types" + ) + assert events == [] + + +def test_missing_base_validation_runs_after_class_construction_hooks() -> None: + events: list[str] = [] + + class RecordingDescriptor: + def __set_name__(self, owner: type[object], name: str) -> None: + events.append(f"set_name:{name}") + + class HookBase: + def __init_subclass__(cls) -> None: + events.append("init_subclass") + super().__init_subclass__() + + class MissingBase: + pass + + with pytest.raises(TypeError) as error: + + class Broken( + HookBase, + metaclass=ExpectedBasesMeta, + expected_bases=(MissingBase,), + ): + descriptor: ClassVar[RecordingDescriptor] = RecordingDescriptor() + + _ = Broken + + assert str(error.value) == "Broken must inherit MissingBase" + assert events == ["set_name:descriptor", "init_subclass"] + + +def test_tuple_subclass_requirements_are_snapshotted_once() -> None: + events: list[str] = [] + + class MissingBase: + pass + + class StatefulRequirements(tuple[object, ...]): + iteration_count: int = 0 + + def __iter__(self) -> Iterator[object]: + self.iteration_count += 1 + if self.iteration_count == 1: + return iter((MissingBase,)) + return iter(("not-a-type",)) + + class RecordingDescriptor: + def __set_name__(self, owner: type[object], name: str) -> None: + events.append(f"set_name:{name}") + + class HookBase: + def __init_subclass__(cls) -> None: + events.append("init_subclass") + super().__init_subclass__() + + requirements: StatefulRequirements = StatefulRequirements((MissingBase,)) + + with pytest.raises(TypeError) as error: + + class Broken( + HookBase, + metaclass=ExpectedBasesMeta, + expected_bases=cast(tuple[type, ...], requirements), + ): + descriptor: ClassVar[RecordingDescriptor] = RecordingDescriptor() + + _ = Broken + + assert str(error.value) == "Broken must inherit MissingBase" + assert requirements.iteration_count == 1 + assert events == ["set_name:descriptor", "init_subclass"] + + +def test_expected_bases_keyword_is_consumed() -> None: + class KeywordAware: + received_keywords: ClassVar[dict[str, object] | None] = None + + def __init_subclass__(cls, **kwargs: object) -> None: + super().__init_subclass__() + cls.received_keywords = dict(kwargs) + + class Complete( + KeywordAware, + metaclass=ExpectedBasesMeta, + expected_bases=(KeywordAware,), + ): + pass + + assert Complete.received_keywords == {} + + +def test_unrelated_class_keyword_is_forwarded_to_keyword_only_hook() -> None: + class KeywordAware: + received_marker: ClassVar[str | None] = None + + def __init_subclass__(cls, *, marker: str) -> None: + super().__init_subclass__() + cls.received_marker = marker + + class Complete( + KeywordAware, + metaclass=ExpectedBasesMeta, + expected_bases=(KeywordAware,), + marker="forwarded", + ): + pass + + assert Complete.received_marker == "forwarded" + + +def test_prepared_namespace_identity_is_preserved_through_metaclass_mro() -> None: + class PreparedNamespace(dict[str, object]): + pass + + class IdentityCheckingMeta(type): + prepared_namespace: ClassVar[dict[str, object] | None] = None + received_namespace: ClassVar[Mapping[str, object] | None] = None + + @classmethod + def __prepare__( + mcls, + name: str, + bases: tuple[type, ...], + /, + **kwargs: object, + ) -> dict[str, object]: + namespace: PreparedNamespace = PreparedNamespace() + mcls.prepared_namespace = namespace + return namespace + + def __new__( + cls: type["IdentityCheckingMeta"], + name: str, + bases: tuple[type, ...], + namespace: Mapping[str, object], + **kwargs: object, + ) -> "IdentityCheckingMeta": + cls.received_namespace = namespace + return super().__new__( + cls, + name, + bases, + dict(namespace), + **kwargs, + ) + + class CombinedMeta(ExpectedBasesMeta, IdentityCheckingMeta): + pass + + class Complete(metaclass=CombinedMeta): + pass + + assert Complete.__name__ == "Complete" + assert CombinedMeta.received_namespace is CombinedMeta.prepared_namespace + + +def test_expected_bases_is_consumed_before_cooperative_metaclass_init() -> None: + events: list[str] = [] + + class StrictLifecycleMeta(type): + def __new__( + cls: type["StrictLifecycleMeta"], + name: str, + bases: tuple[type, ...], + namespace: dict[str, object], + *, + marker: str, + ) -> "StrictLifecycleMeta": + events.append(f"new:{marker}") + return super().__new__(cls, name, bases, namespace) + + def __init__( + self, + name: str, + bases: tuple[type, ...], + namespace: dict[str, object], + *, + marker: str, + ) -> None: + events.append(f"init:{marker}") + super().__init__(name, bases, namespace) + + class CombinedMeta(ExpectedBasesMeta, StrictLifecycleMeta): + pass + + class RequiredBase: + pass + + class Complete( + RequiredBase, + metaclass=CombinedMeta, + expected_bases=(RequiredBase,), + marker="cooperative", + ): + pass + + assert Complete.__name__ == "Complete" + assert events == ["new:cooperative", "init:cooperative"] + + +def test_module_import_is_silent() -> None: + completed: subprocess.CompletedProcess[str] = subprocess.run( + [ + sys.executable, + "-c", + "import CH_08_metaclasses.exercise_02.solution_00", + ], + capture_output=True, + text=True, + ) + + assert completed.returncode == 0 + assert completed.stdout == "" + assert completed.stderr == "" + + +def test_module_demo() -> None: + completed: subprocess.CompletedProcess[str] = subprocess.run( + [ + sys.executable, + "-m", + "CH_08_metaclasses.exercise_02.solution_00", + ], + check=True, + capture_output=True, + text=True, + ) + + assert completed.stdout == "True\n" + assert completed.stderr == "" diff --git a/CH_08_metaclasses/exercise_03/README.rst b/CH_08_metaclasses/exercise_03/README.rst new file mode 100644 index 0000000..81cc30f --- /dev/null +++ b/CH_08_metaclasses/exercise_03/README.rst @@ -0,0 +1,68 @@ +Exercise 3: wrapping methods +============================ + +Question +-------- + +.. code-block:: text + + 3. Build a metaclass that wraps every method with a decorator (could be + useful for logging/de- bugging purposes), something with a signature like + this: + + class SomeClass(metaclass=WrappingMeta, wrapper=some_wrapper): + +Solution +-------- + +``WrappingMeta`` accepts the optional ``wrapper`` class keyword. It wraps only +ordinary functions, static methods, and class methods defined directly in the +new class namespace. Static and class methods are rebuilt with their original +descriptor types, so their binding behavior remains intact. + +Inherited methods, data attributes, properties and other descriptors, and +dunder methods remain untouched. Omitting ``wrapper`` or passing ``None`` +leaves the namespace methods unchanged. ``WrappingMeta`` consumes ``wrapper`` +cooperatively in ``__prepare__``, ``__new__``, and ``__init__`` while +forwarding unrelated class keywords at every stage. It wraps methods in place +and forwards the same mutable prepared namespace object and type through the +cooperative metaclass lifecycle. + +``print_call`` is a generic example decorator. It preserves the wrapped +callable's metadata and signature, prints ``calling ``, and returns the +callable's result. ``SomeClass`` provides the guarded module demonstration. + +Dependencies +------------ + +The solution requires Python 3.10 or newer and has no third-party runtime +dependencies. Tests use the repository's pytest development dependency. + +Run +--- + +Run the example: + +.. code-block:: console + + $ uv run python -m CH_08_metaclasses.exercise_03.solution_00 + +Run the tests: + +.. code-block:: console + + $ uv run pytest CH_08_metaclasses/exercise_03/test_solution_00.py -v + +Further reading +--------------- + +The metaclass class-keyword flow follows +`T_02_arguments_to_metaclasses.rst +`_. +The distinction between class attributes and metaclass attributes is +illustrated in +`T_03_accessing_metaclass_attributes.rst +`_. + +The solution and tests are self-contained; no source files are copied from +those references. diff --git a/CH_08_metaclasses/exercise_03/solution_00.py b/CH_08_metaclasses/exercise_03/solution_00.py index 1d10456..62e91a9 100644 --- a/CH_08_metaclasses/exercise_03/solution_00.py +++ b/CH_08_metaclasses/exercise_03/solution_00.py @@ -1,29 +1,118 @@ -# Build a metaclass that wraps every method with a decorator (could be -# useful for logging/de- bugging purposes), something with a signature like -# this: -# -# class SomeClass(metaclass=WrappingMeta, wrapper=some_wrapper): +from collections.abc import Callable, MutableMapping +from functools import wraps +from types import FunctionType +from typing import ParamSpec, Protocol, TypeAlias, TypeVar, cast + +Method: TypeAlias = Callable[..., object] + +Parameters = ParamSpec("Parameters") +ReturnValue = TypeVar("ReturnValue") + + +class _ClassMethodDescriptor(Protocol): + @property + def __func__(self) -> Method: ... + + +def _wrap_namespace( + namespace: MutableMapping[str, object], + wrapper: Callable[[Method], Method] | None, +) -> None: + """Wrap direct public methods in a prepared class namespace.""" + if wrapper is None: + return + + namespace_items: list[tuple[str, object]] = list(namespace.items()) + for attribute_name, attribute in namespace_items: + if attribute_name.startswith("__") and attribute_name.endswith("__"): + continue + if isinstance(attribute, FunctionType): + namespace[attribute_name] = wrapper(attribute) + elif isinstance(attribute, staticmethod): + static_method: Method = cast(Method, attribute.__func__) + namespace[attribute_name] = staticmethod(wrapper(static_method)) + elif isinstance(attribute, classmethod): + descriptor: _ClassMethodDescriptor = cast( + _ClassMethodDescriptor, + attribute, + ) + namespace[attribute_name] = classmethod( + wrapper(descriptor.__func__), + ) + class WrappingMeta(type): - def __new__(cls, name, bases, attrs, wrapper): - for attr_name, attr_value in attrs.items(): - if callable(attr_value): - attrs[attr_name] = wrapper(attr_value) - return super().__new__(cls, name, bases, attrs) + """Wrap methods defined directly in a class namespace.""" + @classmethod + def __prepare__( + mcls: type["WrappingMeta"], + name: str, + bases: tuple[type, ...], + /, + *, + wrapper: Callable[[Method], Method] | None = None, + **kwargs: object, + ) -> MutableMapping[str, object]: + """Consume wrapper before preparing a class namespace cooperatively.""" + return super().__prepare__(name, bases, **kwargs) -def print_call(func): - def wrapped(*args, **kwargs): - print(f'Calling {func.__name__}({args}, {kwargs})') - return func(*args, **kwargs) + def __new__( + cls: type["WrappingMeta"], + name: str, + bases: tuple[type, ...], + namespace: MutableMapping[str, object], + *, + wrapper: Callable[[Method], Method] | None = None, + **kwargs: object, + ) -> "WrappingMeta": + """Create a class after wrapping its direct public methods.""" + _wrap_namespace(namespace, wrapper) + class_namespace: dict[str, object] = cast(dict[str, object], namespace) + return super().__new__(cls, name, bases, class_namespace, **kwargs) + + def __init__( + self, + name: str, + bases: tuple[type, ...], + namespace: MutableMapping[str, object], + *, + wrapper: Callable[[Method], Method] | None = None, + **kwargs: object, + ) -> None: + """Consume the wrapper keyword and initialize cooperatively.""" + class_namespace: dict[str, object] = cast(dict[str, object], namespace) + super().__init__(name, bases, class_namespace, **kwargs) + + +def print_call( + method: Callable[Parameters, ReturnValue], +) -> Callable[Parameters, ReturnValue]: + """Print a method name immediately before calling it.""" + + @wraps(method) + def wrapped( + *args: Parameters.args, + **kwargs: Parameters.kwargs, + ) -> ReturnValue: + print(f"calling {method.__name__}") + return method(*args, **kwargs) return wrapped class SomeClass(metaclass=WrappingMeta, wrapper=print_call): - def some_method(self): - print('some_method() called') + """Demonstrate automatic method decoration.""" + def some_method(self) -> None: + """Print the demonstration method result.""" + print("some_method() called") -if __name__ == '__main__': + +def main() -> None: + """Run the method-wrapping demonstration.""" SomeClass().some_method() + + +if __name__ == "__main__": + main() diff --git a/CH_08_metaclasses/exercise_03/test_solution_00.py b/CH_08_metaclasses/exercise_03/test_solution_00.py new file mode 100644 index 0000000..bdc7c8f --- /dev/null +++ b/CH_08_metaclasses/exercise_03/test_solution_00.py @@ -0,0 +1,334 @@ +import inspect +import subprocess +import sys +from collections.abc import Callable, MutableMapping +from typing import ClassVar, Protocol, cast + +import pytest + +from .solution_00 import Method, SomeClass, WrappingMeta, print_call + + +class _ClassMethodDescriptor(Protocol): + @property + def __func__(self) -> Method: ... + + +def recording_wrapper( + calls: list[str], +) -> Callable[[Method], Method]: + def wrap(method: Method) -> Method: + calls.append(method.__name__) + + def wrapped(*args: object, **kwargs: object) -> object: + return method(*args, **kwargs) + + return wrapped + + return wrap + + +def test_instance_static_and_class_methods_are_wrapped_once() -> None: + calls: list[str] = [] + + class Example( + metaclass=WrappingMeta, + wrapper=recording_wrapper(calls), + ): + def instance_method(self, value: int) -> tuple[str, int]: + return ("instance", value) + + @staticmethod + def static_method(value: int) -> tuple[str, int]: + return ("static", value) + + @classmethod + def class_method(cls, value: int) -> tuple[str, type["Example"], int]: + return ("class", cls, value) + + assert calls == ["instance_method", "static_method", "class_method"] + assert Example().instance_method(1) == ("instance", 1) + assert Example.static_method(2) == ("static", 2) + assert Example.class_method(3) == ("class", Example, 3) + assert calls == ["instance_method", "static_method", "class_method"] + + +def test_static_and_class_method_descriptors_are_reconstructed() -> None: + class Example(metaclass=WrappingMeta, wrapper=print_call): + @staticmethod + def static_method() -> str: + return "static" + + @classmethod + def class_method(cls) -> type["Example"]: + return cls + + static_descriptor: object = Example.__dict__["static_method"] + class_descriptor: object = Example.__dict__["class_method"] + + assert isinstance(static_descriptor, staticmethod) + assert isinstance(class_descriptor, classmethod) + static_function: Method = cast(Method, static_descriptor.__func__) + typed_class_descriptor: _ClassMethodDescriptor = cast( + _ClassMethodDescriptor, + class_descriptor, + ) + class_function: Method = typed_class_descriptor.__func__ + assert static_function.__name__ == "static_method" + assert class_function.__name__ == "class_method" + assert Example.static_method() == "static" + assert Example.class_method() is Example + + +def test_data_properties_other_descriptors_and_dunders_are_untouched() -> None: + calls: list[str] = [] + + class CallableDescriptor: + def __call__(self) -> str: + return "descriptor" + + def __get__(self, instance: object, owner: type[object]) -> str: + return owner.__name__ + + descriptor: CallableDescriptor = CallableDescriptor() + + class Example( + metaclass=WrappingMeta, + wrapper=recording_wrapper(calls), + ): + value: int = 7 + custom: ClassVar[CallableDescriptor] = descriptor + + def __init__(self) -> None: + self.created: bool = True + + @property + def label(self) -> str: + return "property" + + def ordinary(self) -> str: + return "ordinary" + + example: Example = Example() + + assert calls == ["ordinary"] + assert Example.value == 7 + assert Example.__dict__["custom"] is descriptor + assert Example.custom == "Example" + assert Example.__dict__["label"].fget is not None + assert Example.__dict__["label"].fget.__name__ == "label" + assert example.label == "property" + assert example.created is True + assert example.ordinary() == "ordinary" + + +def test_inherited_methods_are_not_wrapped() -> None: + calls: list[str] = [] + + class Parent: + def inherited(self) -> str: + return "parent" + + class Child( + Parent, + metaclass=WrappingMeta, + wrapper=recording_wrapper(calls), + ): + def direct(self) -> str: + return "child" + + child: Child = Child() + + assert calls == ["direct"] + assert child.inherited() == "parent" + assert child.direct() == "child" + + +def test_missing_wrapper_leaves_methods_unchanged() -> None: + class Example(metaclass=WrappingMeta): + def method(self) -> str: + return "unchanged" + + original_method: object = Example.__dict__["method"] + + assert Example().method() == "unchanged" + assert original_method is Example.__dict__["method"] + + +def test_none_wrapper_leaves_methods_unchanged() -> None: + class Example(metaclass=WrappingMeta, wrapper=None): + def method(self) -> str: + return "unchanged" + + assert Example().method() == "unchanged" + + +@pytest.mark.parametrize("wrapper_enabled", [True, False]) +def test_cooperative_lifecycle_preserves_prepared_namespace( + wrapper_enabled: bool, +) -> None: + events: list[tuple[str, str]] = [] + prepared_namespaces: list[MutableMapping[str, object]] = [] + received_namespaces: list[MutableMapping[str, object]] = [] + received_methods: list[object] = [] + received_keywords: list[dict[str, object]] = [] + received_prepare_wrappers: list[Callable[[Method], Method] | None] = [] + wrapper_calls: list[str] = [] + wrapper: Callable[[Method], Method] | None = ( + recording_wrapper(wrapper_calls) if wrapper_enabled else None + ) + + def missing_wrapper(method: Method) -> Method: + raise AssertionError(f"unexpected wrapper call for {method.__name__}") + + class PreparedNamespace(dict[str, object]): + pass + + class LifecycleMeta(type): + @classmethod + def __prepare__( + mcls: type["LifecycleMeta"], + name: str, + bases: tuple[type, ...], + /, + *, + wrapper: Callable[[Method], Method] | None = missing_wrapper, + **kwargs: object, + ) -> MutableMapping[str, object]: + received_prepare_wrappers.append(wrapper) + marker_value: object = kwargs.pop("marker", "") + assert isinstance(marker_value, str) + marker: str = marker_value + events.append(("prepare", marker)) + received_keywords.append(dict(kwargs)) + namespace: PreparedNamespace = PreparedNamespace() + prepared_namespaces.append(namespace) + return namespace + + def __new__( + cls: type["LifecycleMeta"], + name: str, + bases: tuple[type, ...], + namespace: MutableMapping[str, object], + *, + marker: str, + **kwargs: object, + ) -> "LifecycleMeta": + events.append(("new", marker)) + received_keywords.append(dict(kwargs)) + received_namespaces.append(namespace) + received_methods.append(namespace["method"]) + class_namespace: dict[str, object] = cast( + dict[str, object], + namespace, + ) + return super().__new__(cls, name, bases, class_namespace) + + def __init__( + self, + name: str, + bases: tuple[type, ...], + namespace: MutableMapping[str, object], + *, + marker: str, + **kwargs: object, + ) -> None: + events.append(("init", marker)) + received_keywords.append(dict(kwargs)) + received_namespaces.append(namespace) + received_methods.append(namespace["method"]) + class_namespace: dict[str, object] = cast( + dict[str, object], + namespace, + ) + super().__init__(name, bases, class_namespace) + + class CombinedMeta(WrappingMeta, LifecycleMeta): + pass + + class Example( + metaclass=CombinedMeta, + wrapper=wrapper, + marker="cooperative", + ): + def method(self) -> str: + return "result" + + assert events == [ + ("prepare", "cooperative"), + ("new", "cooperative"), + ("init", "cooperative"), + ] + assert received_prepare_wrappers == [missing_wrapper] + assert received_keywords == [{}, {}, {}] + assert len(prepared_namespaces) == 1 + prepared_namespace: MutableMapping[str, object] = prepared_namespaces[0] + assert type(prepared_namespace) is PreparedNamespace + assert received_namespaces == [prepared_namespace, prepared_namespace] + assert received_namespaces[0] is prepared_namespace + assert received_namespaces[1] is prepared_namespace + assert received_methods == [ + prepared_namespace["method"], + prepared_namespace["method"], + ] + prepared_method: Method = cast(Method, prepared_namespace["method"]) + assert prepared_method.__name__ == ("wrapped" if wrapper_enabled else "method") + assert Example.__dict__["method"] is prepared_namespace["method"] + assert Example().method() == "result" + assert wrapper_calls == (["method"] if wrapper_enabled else []) + + +def test_print_call_preserves_metadata_signature_result_and_output( + capsys: pytest.CaptureFixture[str], +) -> None: + def add(left: int, right: int = 1) -> int: + """Add two integers.""" + return left + right + + wrapped: Callable[..., int] = print_call(add) + + assert wrapped.__name__ == "add" + assert wrapped.__doc__ == "Add two integers." + assert inspect.signature(wrapped) == inspect.signature(add) + assert wrapped(2, right=3) == 5 + assert capsys.readouterr().out == "calling add\n" + + +def test_public_example_class_uses_print_call( + capsys: pytest.CaptureFixture[str], +) -> None: + SomeClass().some_method() + + assert capsys.readouterr().out == "calling some_method\nsome_method() called\n" + + +def test_module_import_is_silent() -> None: + completed: subprocess.CompletedProcess[str] = subprocess.run( + [ + sys.executable, + "-c", + "import CH_08_metaclasses.exercise_03.solution_00", + ], + check=True, + capture_output=True, + text=True, + ) + + assert completed.stdout == "" + assert completed.stderr == "" + + +def test_module_demo() -> None: + completed: subprocess.CompletedProcess[str] = subprocess.run( + [ + sys.executable, + "-m", + "CH_08_metaclasses.exercise_03.solution_00", + ], + check=True, + capture_output=True, + text=True, + ) + + assert completed.stdout == "calling some_method\nsome_method() called\n" + assert completed.stderr == "" diff --git a/CH_09_documentation/README.rst b/CH_09_documentation/README.rst index 68bd5fe..33ea6b7 100644 --- a/CH_09_documentation/README.rst +++ b/CH_09_documentation/README.rst @@ -1,6 +1,9 @@ Chapter 9 - documentation -======================================================================================================================= +========================= -1. Type hint a complex `dict` -2. Type hint nested types -3. Type hint recursive types +Exercises +--------- + +1. `Exercise 1: type hint complex dict `_ +2. `Exercise 2: type hint nested measurements `_ +3. `Exercise 3: type hint recursive trees `_ diff --git a/CH_09_documentation/exercise_01/README.rst b/CH_09_documentation/exercise_01/README.rst new file mode 100644 index 0000000..7d17544 --- /dev/null +++ b/CH_09_documentation/exercise_01/README.rst @@ -0,0 +1,44 @@ +Exercise 1: type hint a complex dict +==================================== + +Question +-------- + +1. Type hint a complex `dict` + +Solution +-------- + +``TypedDict`` fits a dictionary with fixed keys whose values have different +types. ``UserRecord`` records the type associated with each required key, +while the nested ``Contact`` type gives the contact dictionary its own reusable +shape. The phone field accepts either a string or ``None``. + +These declarations support static type checkers. They do not validate values, +convert input, or create a new runtime record type: values remain ordinary +``dict`` objects at runtime. + +Dependencies +------------ + +The solution requires Python 3.10 or newer and uses only the standard library. +The repository's ``dev`` dependency group supplies pytest for the tests. + +Run the guarded module demonstration: + +.. code-block:: console + + $ uv run python -m CH_09_documentation.exercise_01.solution_00 + Ada (36) active: ada@example.test + +Run the focused tests: + +.. code-block:: console + + $ uv run pytest -W error CH_09_documentation/exercise_01/test_solution_00.py + +Historical source +----------------- + +The chapter's type-hinting discussion is preserved in +`T_00_type_hinting.rst `_. diff --git a/CH_09_documentation/exercise_01/__init__.py b/CH_09_documentation/exercise_01/__init__.py new file mode 100644 index 0000000..6acead4 --- /dev/null +++ b/CH_09_documentation/exercise_01/__init__.py @@ -0,0 +1 @@ +"""Chapter 9, exercise 1: type hint a complex dictionary.""" diff --git a/CH_09_documentation/exercise_01/solution_00.py b/CH_09_documentation/exercise_01/solution_00.py new file mode 100644 index 0000000..7f38b2d --- /dev/null +++ b/CH_09_documentation/exercise_01/solution_00.py @@ -0,0 +1,43 @@ +"""Describe a user record represented by nested typed dictionaries.""" + +from typing import TypedDict + + +class Contact(TypedDict): + """Contact details stored in a user record.""" + + email: str + phone: str | None + + +class UserRecord(TypedDict): + """A fixed, heterogeneous user record.""" + + name: str + age: int + active: bool + contact: Contact + + +def describe_user(user: UserRecord) -> str: + """Return a compact description of *user*.""" + status: str = "active" if user["active"] else "inactive" + return f"{user['name']} ({user['age']}) {status}: {user['contact']['email']}" + + +def main() -> None: + """Print a demonstration user description.""" + user: UserRecord = { + "name": "Ada", + "age": 36, + "active": True, + "contact": { + "email": "ada@example.test", + "phone": None, + }, + } + print(describe_user(user)) + + +if __name__ == "__main__": + main() diff --git a/CH_09_documentation/exercise_01/test_solution_00.py b/CH_09_documentation/exercise_01/test_solution_00.py new file mode 100644 index 0000000..8dea740 --- /dev/null +++ b/CH_09_documentation/exercise_01/test_solution_00.py @@ -0,0 +1,120 @@ +"""Tests for a fixed, heterogeneous user-record type.""" + +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path +from types import NoneType +from typing import get_type_hints + +from .solution_00 import Contact, UserRecord, describe_user, main + + +def test_describe_active_user() -> None: + user: UserRecord = { + "name": "Ada", + "age": 36, + "active": True, + "contact": { + "email": "ada@example.test", + "phone": "+31 20 123 4567", + }, + } + + assert describe_user(user) == "Ada (36) active: ada@example.test" + + +def test_describe_inactive_user() -> None: + user: UserRecord = { + "name": "Grace", + "age": 40, + "active": False, + "contact": { + "email": "grace@example.test", + "phone": "+1 555 0100", + }, + } + + assert describe_user(user) == "Grace (40) inactive: grace@example.test" + + +def test_contact_allows_a_nullable_phone() -> None: + contact: Contact = { + "email": "ada@example.test", + "phone": None, + } + user: UserRecord = { + "name": "Ada", + "age": 36, + "active": True, + "contact": contact, + } + + assert user["contact"]["phone"] is None + assert describe_user(user) == "Ada (36) active: ada@example.test" + + +def test_typed_dict_field_hints_are_exact() -> None: + assert get_type_hints(Contact) == { + "email": str, + "phone": str | None, + } + assert Contact.__required_keys__ == frozenset({"email", "phone"}) + assert Contact.__optional_keys__ == frozenset() + assert get_type_hints(UserRecord) == { + "name": str, + "age": int, + "active": bool, + "contact": Contact, + } + assert UserRecord.__required_keys__ == frozenset( + {"name", "age", "active", "contact"} + ) + assert UserRecord.__optional_keys__ == frozenset() + + +def test_function_annotations_are_exact() -> None: + assert get_type_hints(describe_user) == { + "user": UserRecord, + "return": str, + } + assert get_type_hints(main) == {"return": NoneType} + + +def test_import_is_silent() -> None: + repository_root: Path = Path(__file__).parents[2] + completed: subprocess.CompletedProcess[str] = subprocess.run( + [ + sys.executable, + "-c", + "import CH_09_documentation.exercise_01.solution_00", + ], + cwd=repository_root, + check=False, + capture_output=True, + text=True, + ) + + assert completed.returncode == 0 + assert completed.stdout == "" + assert completed.stderr == "" + + +def test_module_prints_demonstration() -> None: + repository_root: Path = Path(__file__).parents[2] + completed: subprocess.CompletedProcess[str] = subprocess.run( + [ + sys.executable, + "-m", + "CH_09_documentation.exercise_01.solution_00", + ], + cwd=repository_root, + check=False, + capture_output=True, + text=True, + ) + + assert completed.returncode == 0 + assert completed.stdout == "Ada (36) active: ada@example.test\n" + assert completed.stderr == "" diff --git a/CH_09_documentation/exercise_02/README.rst b/CH_09_documentation/exercise_02/README.rst new file mode 100644 index 0000000..45370e4 --- /dev/null +++ b/CH_09_documentation/exercise_02/README.rst @@ -0,0 +1,48 @@ +Exercise 2: type hint nested measurements +========================================= + +Question +-------- + +1. Type hint a complex `dict` +2. Type hint nested types + +Solution +-------- + +``NestedMeasurements`` gives the nested +``dict[str, list[tuple[int, float]]]`` shape a readable name. The alias helps +static type checkers and documents the relationship between level names, +integer identifiers, and floating-point measurements. It does not add runtime +validation or create a new container type. + +``flatten_measurements`` visits mapping values in dictionary insertion order and +rows in list order, returning each tuple's floating-point measurement component. +It creates a new list and does not mutate the input containers. + +Dependencies +------------ + +The solution requires Python 3.10 or newer and uses only the standard library. +The repository's ``dev`` dependency group supplies pytest for the tests. + +Run +--- + +Run the guarded demonstration from the repository root: + +.. code-block:: console + + $ uv run python -m CH_09_documentation.exercise_02.solution_00 + +Run the focused tests: + +.. code-block:: console + + $ uv run pytest -W error CH_09_documentation/exercise_02/test_solution_00.py + +Historical source +----------------- + +The chapter's type-hinting discussion is preserved in +`T_00_type_hinting.rst `_. diff --git a/CH_09_documentation/exercise_02/__init__.py b/CH_09_documentation/exercise_02/__init__.py new file mode 100644 index 0000000..31ba19f --- /dev/null +++ b/CH_09_documentation/exercise_02/__init__.py @@ -0,0 +1 @@ +"""Chapter 9, exercise 2: type hint nested measurements.""" diff --git a/CH_09_documentation/exercise_02/solution_00.py b/CH_09_documentation/exercise_02/solution_00.py new file mode 100644 index 0000000..cc7e6d1 --- /dev/null +++ b/CH_09_documentation/exercise_02/solution_00.py @@ -0,0 +1,28 @@ +"""Flatten measurements stored in explicitly typed nested containers.""" + +from typing import TypeAlias + +NestedMeasurements: TypeAlias = dict[str, list[tuple[int, float]]] + + +def flatten_measurements(values: NestedMeasurements) -> list[float]: + """Return measurement values in mapping and row insertion order.""" + flattened: list[float] = [] + rows: list[tuple[int, float]] + row: tuple[int, float] + for rows in values.values(): + for row in rows: + flattened.append(row[1]) + return flattened + + +def main() -> None: + """Print a canonical nested-measurement demonstration.""" + values: NestedMeasurements = { + "north": [(1, 1.5), (2, 2.5)], + } + print(flatten_measurements(values)) + + +if __name__ == "__main__": + main() diff --git a/CH_09_documentation/exercise_02/test_solution_00.py b/CH_09_documentation/exercise_02/test_solution_00.py new file mode 100644 index 0000000..221074f --- /dev/null +++ b/CH_09_documentation/exercise_02/test_solution_00.py @@ -0,0 +1,113 @@ +"""Tests for flattening explicitly typed nested measurements.""" + +import subprocess +import sys +from copy import deepcopy +from pathlib import Path +from types import NoneType +from typing import get_type_hints + +from CH_09_documentation.exercise_02.solution_00 import ( + NestedMeasurements, + flatten_measurements, + main, +) + + +def test_flatten_measurements_preserves_mapping_and_list_order() -> None: + values: NestedMeasurements = { + "north": [(2, 2.5), (1, 1.5)], + "south": [(4, 4.5), (3, 3.5)], + } + + assert flatten_measurements(values) == [2.5, 1.5, 4.5, 3.5] + + +def test_flatten_measurements_accepts_an_empty_mapping() -> None: + values: NestedMeasurements = {} + + assert flatten_measurements(values) == [] + + +def test_flatten_measurements_skips_empty_levels() -> None: + values: NestedMeasurements = { + "north": [], + "south": [(3, 3.5)], + "west": [], + } + + assert flatten_measurements(values) == [3.5] + + +def test_flatten_measurements_preserves_duplicate_ids_and_values() -> None: + values: NestedMeasurements = { + "north": [(1, 1.5), (1, 1.5)], + "south": [(1, 1.5)], + } + + assert flatten_measurements(values) == [1.5, 1.5, 1.5] + + +def test_flatten_measurements_does_not_mutate_input() -> None: + values: NestedMeasurements = { + "north": [(1, 1.5), (2, 2.5)], + "south": [(3, 3.5)], + } + before: NestedMeasurements = deepcopy(values) + north_rows: list[tuple[int, float]] = values["north"] + + flattened: list[float] = flatten_measurements(values) + + assert flattened == [1.5, 2.5, 3.5] + assert values == before + assert values["north"] is north_rows + + +def test_nested_measurements_alias_has_exact_shape() -> None: + assert NestedMeasurements == dict[str, list[tuple[int, float]]] + + +def test_function_annotations_are_exact() -> None: + assert get_type_hints(flatten_measurements) == { + "values": NestedMeasurements, + "return": list[float], + } + assert get_type_hints(main) == {"return": NoneType} + + +def test_import_is_silent() -> None: + repository_root: Path = Path(__file__).parents[2] + completed: subprocess.CompletedProcess[str] = subprocess.run( + [ + sys.executable, + "-c", + "import CH_09_documentation.exercise_02.solution_00", + ], + cwd=repository_root, + check=False, + capture_output=True, + text=True, + ) + + assert completed.returncode == 0 + assert completed.stdout == "" + assert completed.stderr == "" + + +def test_module_prints_canonical_list() -> None: + repository_root: Path = Path(__file__).parents[2] + completed: subprocess.CompletedProcess[str] = subprocess.run( + [ + sys.executable, + "-m", + "CH_09_documentation.exercise_02.solution_00", + ], + cwd=repository_root, + check=False, + capture_output=True, + text=True, + ) + + assert completed.returncode == 0 + assert completed.stdout == "[1.5, 2.5]\n" + assert completed.stderr == "" diff --git a/CH_09_documentation/exercise_03/README.rst b/CH_09_documentation/exercise_03/README.rst new file mode 100644 index 0000000..8e896d4 --- /dev/null +++ b/CH_09_documentation/exercise_03/README.rst @@ -0,0 +1,62 @@ +Exercise 3: type hint recursive trees +======================================= + +Question +-------- + +The relevant chapter exercise-list excerpt is: + +.. code-block:: text + + 1. Type hint complex `dict` + 2. Type hint nested types + 3. Type hint recursive types + +Solution +-------- + +``Tree`` names the recursive ``dict[str, "Tree"]`` shape. Each mapping key +names one node and its value contains that node's children. The root mapping is +the container for those named nodes, so it is not itself counted. +``count_nodes`` therefore adds one for every mapping entry and includes the +entry's child-tree count. Dictionary insertion order and node names do not +affect the result, and the input is never modified. + +The traversal uses an explicit post-order stack, so a valid tree may be deeper +than Python's recursion limit. Cycle detection tracks dictionary identities +only along the active path. Revisiting an active dictionary raises exactly +``ValueError("tree contains cycle")``. + +After a subtree is completed, its count is memoized by dictionary identity. +Each unique dictionary is traversed once, while every parent reference still +adds one plus the cached child count. Shared acyclic directed graphs therefore +remain valid and are counted once per occurrence, including compact graphs +whose expanded counts are very large. This teaching function relies on the +type annotation and performs no additional runtime shape validation. + +Dependencies +------------ + +The solution requires Python 3.10 or newer and uses only the standard library. +The repository's ``dev`` dependency group supplies pytest for tests. + +Run +--- + +Run the guarded canonical demonstration from the repository root: + +.. code-block:: console + + $ uv run python -m CH_09_documentation.exercise_03.solution_00 + +Run focused tests with warnings treated as errors: + +.. code-block:: console + + $ uv run pytest -W error CH_09_documentation/exercise_03/test_solution_00.py + +Historical source +----------------- + +The chapter's type-hinting discussion is preserved in +`T_00_type_hinting.rst `_. diff --git a/CH_09_documentation/exercise_03/__init__.py b/CH_09_documentation/exercise_03/__init__.py new file mode 100644 index 0000000..a60befb --- /dev/null +++ b/CH_09_documentation/exercise_03/__init__.py @@ -0,0 +1 @@ +"""Chapter 9, exercise 3: type hint recursive trees.""" diff --git a/CH_09_documentation/exercise_03/solution_00.py b/CH_09_documentation/exercise_03/solution_00.py new file mode 100644 index 0000000..0abb95e --- /dev/null +++ b/CH_09_documentation/exercise_03/solution_00.py @@ -0,0 +1,61 @@ +"""Count nodes in recursively typed trees.""" + +from __future__ import annotations + +from typing import TypeAlias + +Tree: TypeAlias = dict[str, "Tree"] + + +def count_nodes(tree: Tree) -> int: + """Count named nodes with iterative cycle detection and memoization.""" + active: set[int] = set() + completed: dict[int, int] = {} + stack: list[tuple[Tree, tuple[Tree, ...] | None]] = [(tree, None)] + + while stack: + current, children = stack.pop() + identity: int = id(current) + + if children is not None: + total: int = 0 + child: Tree + for child in children: + total += 1 + completed[id(child)] + completed[identity] = total + active.remove(identity) + continue + + if identity in completed: + continue + if identity in active: + raise ValueError("tree contains cycle") + + active.add(identity) + current_children: tuple[Tree, ...] = tuple(current.values()) + stack.append((current, current_children)) + pending_child: Tree + for pending_child in reversed(current_children): + stack.append((pending_child, None)) + + return completed[id(tree)] + + +def main() -> None: + """Print the node count for the canonical demonstration tree.""" + tree: Tree = { + "docs": { + "api": {}, + "guide": {}, + }, + "src": { + "package": { + "module": {}, + }, + }, + } + print(count_nodes(tree)) + + +if __name__ == "__main__": + main() diff --git a/CH_09_documentation/exercise_03/test_solution_00.py b/CH_09_documentation/exercise_03/test_solution_00.py new file mode 100644 index 0000000..a8552d4 --- /dev/null +++ b/CH_09_documentation/exercise_03/test_solution_00.py @@ -0,0 +1,197 @@ +"""Tests for recursively typed tree node counting.""" + +from __future__ import annotations + +import subprocess +import sys +from collections.abc import ValuesView +from copy import deepcopy +from pathlib import Path +from types import NoneType +from typing import ClassVar, cast, get_args, get_origin, get_type_hints + +import pytest + +from CH_09_documentation.exercise_03.solution_00 import ( + Tree, + count_nodes, + main, +) + + +class _ObservedTree: + value_reads: ClassVar[int] = 0 + maximum_reads: ClassVar[int] = 100 + + children: dict[str, Tree] + + def __init__(self, children: dict[str, Tree] | None = None) -> None: + self.children: dict[str, Tree] = children or {} + + def values(self) -> ValuesView[Tree]: + type(self).value_reads += 1 + if type(self).value_reads > type(self).maximum_reads: + raise AssertionError("shared subtree was recomputed") + return self.children.values() + + +def test_count_nodes_counts_every_nested_named_node() -> None: + tree: Tree = { + "docs": { + "api": {}, + "guide": {"intro": {}}, + }, + "src": { + "package": { + "module": {}, + }, + }, + } + + assert count_nodes(tree) == 7 + + +def test_count_nodes_returns_zero_for_empty_tree() -> None: + tree: Tree = {} + + assert count_nodes(tree) == 0 + + +def test_count_nodes_handles_chain_deeper_than_recursion_limit() -> None: + tree: Tree = {} + cursor: Tree = tree + depth: int = sys.getrecursionlimit() + 100 + index: int + for index in range(depth): + child: Tree = {} + cursor[f"node-{index}"] = child + cursor = child + + assert count_nodes(tree) == depth + + +def test_count_nodes_rejects_direct_cycle() -> None: + tree: Tree = {} + tree["self"] = tree + + with pytest.raises(ValueError, match=r"^tree contains cycle$"): + count_nodes(tree) + + +def test_count_nodes_rejects_deep_cycle() -> None: + tree: Tree = {"first": {"second": {"third": {}}}} + third: Tree = tree["first"]["second"]["third"] + third["back"] = tree["first"] + + with pytest.raises(ValueError, match=r"^tree contains cycle$"): + count_nodes(tree) + + +def test_shared_acyclic_subtree_is_counted_per_occurrence() -> None: + shared: Tree = {"leaf": {}} + tree: Tree = { + "left": shared, + "right": shared, + } + + assert count_nodes(tree) == 4 + + +def test_compact_shared_dag_is_memoized_and_counts_every_reference() -> None: + _ObservedTree.value_reads = 0 + tree: Tree = cast(Tree, _ObservedTree()) + depth: int = 80 + for _ in range(depth): + tree = cast( + Tree, + _ObservedTree({"left": tree, "right": tree}), + ) + + assert count_nodes(tree) == (2 ** (depth + 1)) - 2 + assert _ObservedTree.value_reads == depth + 1 + + +def test_count_nodes_does_not_mutate_input() -> None: + tree: Tree = { + "alpha": {"one": {}, "two": {}}, + "beta": {"three": {}}, + } + before: Tree = deepcopy(tree) + alpha: Tree = tree["alpha"] + + assert count_nodes(tree) == 5 + assert tree == before + assert tree["alpha"] is alpha + + +def test_count_depends_on_structure_not_names_or_insertion_order() -> None: + first: Tree = { + "alpha": {"child": {}}, + "beta": {}, + } + second: Tree = { + "renamed-beta": {}, + "renamed-alpha": {"renamed-child": {}}, + } + + assert count_nodes(first) == 3 + assert count_nodes(second) == 3 + + +def test_tree_alias_has_exact_recursive_shape() -> None: + assert Tree == dict[str, "Tree"] + + +def test_function_annotations_are_exact() -> None: + hints: dict[str, object] = get_type_hints(count_nodes) + tree_hint: object = hints["tree"] + key_hint: object + recursive_hint: object + key_hint, recursive_hint = get_args(tree_hint) + + assert count_nodes.__annotations__["tree"] == "Tree" + assert get_origin(tree_hint) is dict + assert key_hint is str + assert recursive_hint == "Tree" or ( + getattr(recursive_hint, "__forward_arg__", None) == "Tree" + ) + assert hints["return"] is int + assert get_type_hints(main) == {"return": NoneType} + + +def test_import_is_silent() -> None: + repository_root: Path = Path(__file__).parents[2] + completed: subprocess.CompletedProcess[str] = subprocess.run( + [ + sys.executable, + "-c", + "import CH_09_documentation.exercise_03.solution_00", + ], + cwd=repository_root, + check=False, + capture_output=True, + text=True, + ) + + assert completed.returncode == 0 + assert completed.stdout == "" + assert completed.stderr == "" + + +def test_module_prints_canonical_count() -> None: + repository_root: Path = Path(__file__).parents[2] + completed: subprocess.CompletedProcess[str] = subprocess.run( + [ + sys.executable, + "-m", + "CH_09_documentation.exercise_03.solution_00", + ], + cwd=repository_root, + check=False, + capture_output=True, + text=True, + ) + + assert completed.returncode == 0 + assert completed.stdout == "6\n" + assert completed.stderr == "" diff --git a/CH_10_testing_and_logging/exercise_01/README.rst b/CH_10_testing_and_logging/exercise_01/README.rst new file mode 100644 index 0000000..febd85f --- /dev/null +++ b/CH_10_testing_and_logging/exercise_01/README.rst @@ -0,0 +1,134 @@ +Exercise 1: testing and logging utilities +========================================= + +This exercise answers all five Chapter 10 questions. The public entry point is +``CH_10_testing_and_logging.exercise_01.solution_00``; focused implementation +modules keep doctest discovery, pytest collection, and logging independent. + +Question 1: run doctests for one object +--------------------------------------- + +``run_doctests(target, *, optionflags=doctest.ELLIPSIS)`` recursively discovers +examples attached to a function or class and returns one aggregate +``doctest.TestResults``. A class includes examples on discoverable methods. +Targets without examples return zero failures and zero attempts. + +The function uses a fresh finder and runner for every call. A reentrant lock +serializes calls because ``doctest.DocTestRunner`` temporarily changes +process-wide state. Code that invokes ``doctest`` directly does not share that +lock. Exceptions such as ``KeyboardInterrupt`` propagate. A locally defined +function or class with a valid ``__name__`` can refer to itself in its examples. + +Question 2: run doctests for a module +------------------------------------- + +``run_module_doctests(target, *, optionflags=doctest.ELLIPSIS)`` is the explicit +module-recursive API. ``target`` must be a ``types.ModuleType``; any other +value raises ``TypeError``. It aggregates the module docstring and examples on +module-owned, discoverable functions, classes, and methods. + +Discovery follows ``doctest.DocTestFinder(recurse=True)``. Objects hidden in a +closure, generated dynamically without a module attribute, or imported from +another module are not treated as recursively owned members. ``optionflags=0`` +enables exact comparison instead of the default ellipsis matching. + +Question 3: require file-level test documentation +------------------------------------------------- + +``file_documentation_plugin.py`` provides the pytest 9 +``pytest_collect_file(file_path: pathlib.Path, parent)`` hook. It checks ``.py`` +files selected explicitly or matched by the active ``python_files`` +configuration. Patterns without path separators match basenames; patterns +with separators match paths using pytest's platform-aware behavior. A +non-empty, syntactically real module docstring is accepted. Missing docstrings, +malformed source, unreadable files, and invalid encodings become clear +collection errors. Non-test Python files are left to other collectors. + +Load the plugin explicitly: + +.. code-block:: console + + $ uv run --no-sync python -m pytest -p CH_10_testing_and_logging.exercise_01.file_documentation_plugin --collect-only -q CH_10_testing_and_logging/exercise_01/tests_plugin/test_solution_00.py + +The plugin validates source during collection; it does not alter test execution +or require documentation on non-Python collectors. ``--no-sync`` preserves the +repository environment prepared by the required all-groups dependency sync. + +Question 4: run static analysis with tox +---------------------------------------- + +``tox.ini`` defines the named tox 4 environment ``static``. It selects Python +3.10, skips package construction, installs ``mypy`` and ``pytest`` (which the +plugin imports), changes to the repository root, and checks only the maintained +Chapter 10 implementation files. It does not run pytest or recursively analyze +the rest of the repository. + +Run it from the repository root: + +.. code-block:: console + + $ uv run --group packaging tox -c CH_10_testing_and_logging/exercise_01/tox.ini -e static + +Question 5: batch logging records by task ID +-------------------------------------------- + +``TaskBatchLoggerAdapter`` is a thread-safe ``logging.LoggerAdapter``. +``record(task_id, level, message, *args)`` appends one message to a task-local +batch. ``flush(task_id)`` emits one record and removes the flushed messages. +Messages keep insertion order and use a newline separator. Percent-style +logging arguments are formatted at flush time. The record level is the highest +numeric level in the batch. + +Adapter ``extra`` values are copied to the combined record, and the flushed +``task_id`` overrides any adapter-level ``task_id``. Empty IDs raise +``ValueError``; non-string IDs raise ``TypeError``; flushing an unknown or empty +batch raises ``KeyError``. ``pending_count(task_id)`` reports buffered work. +``discard(task_id)`` is the only operation that intentionally drops a batch and +returns the number of messages removed. + +If the combined level is disabled, a logger filter rejects the record, or every +eligible handler rejects it by level or filter, ``flush`` returns ``False`` and +retains the batch. Formatting failures and handler failures reported through +the standard ``handleError`` path propagate and also retain it for retry or +explicit discard. With multiple handlers, retrying after a later handler fails +can duplicate the record in an earlier handler that already accepted it. A +successful flush returns ``True``. Standard adapter logging methods remain +immediate; batching uses ``record`` and ``flush``. Batches are in-memory only +and are not durable across process termination. + +Dependencies and commands +------------------------- + +The doctest and logging solutions require Python 3.10 or newer and use the +standard library. The collection plugin requires ``pytest>=9.0``. The tox +environment requires ``tox>=4.30`` and installs its own ``mypy>=1.18`` and +``pytest>=9.0`` dependencies. Repository development groups provide these +tools. + +Run the demonstration: + +.. code-block:: console + + $ uv run python -m CH_10_testing_and_logging.exercise_01.solution_00 + Attempted 1 doctest; 0 failed. + +Run every focused exercise test: + +.. code-block:: console + + $ uv run pytest -q CH_10_testing_and_logging/exercise_01 + +Run the three added suites independently: + +.. code-block:: console + + $ uv run pytest -q CH_10_testing_and_logging/exercise_01/tests_doctests/test_solution_00.py + $ uv run pytest -q CH_10_testing_and_logging/exercise_01/tests_plugin/test_solution_00.py + $ uv run pytest -q CH_10_testing_and_logging/exercise_01/tests_task_logging/test_solution_00.py + +Historical source +----------------- + +The book repository's original exercise source is available at +`T_00_simple_doctest.py +`_. diff --git a/CH_10_testing_and_logging/exercise_01/__init__.py b/CH_10_testing_and_logging/exercise_01/__init__.py new file mode 100644 index 0000000..2646e50 --- /dev/null +++ b/CH_10_testing_and_logging/exercise_01/__init__.py @@ -0,0 +1 @@ +"""Chapter 10, exercise 1: recursively run doctests.""" diff --git a/CH_10_testing_and_logging/exercise_01/doctest_runner.py b/CH_10_testing_and_logging/exercise_01/doctest_runner.py new file mode 100644 index 0000000..60e4f2a --- /dev/null +++ b/CH_10_testing_and_logging/exercise_01/doctest_runner.py @@ -0,0 +1,61 @@ +"""Discover and run doctests attached to Python objects and modules.""" + +from __future__ import annotations + +import doctest +import keyword +import threading +from types import ModuleType + +_RUN_LOCK: threading.RLock = threading.RLock() + + +def run_doctests( + target: object, + *, + optionflags: int = doctest.ELLIPSIS, +) -> doctest.TestResults: + """Run doctests found recursively in *target*.""" + finder: doctest.DocTestFinder = doctest.DocTestFinder(recurse=True) + runner: doctest.DocTestRunner = doctest.DocTestRunner( + verbose=False, + optionflags=optionflags, + ) + + target_name: object = getattr(target, "__name__", None) + finder_name: str = type(target).__name__ + extraglobs: dict[str, object] = {} + if ( + isinstance(target_name, str) + and target_name.isidentifier() + and not keyword.iskeyword(target_name) + ): + finder_name = target_name + extraglobs[target_name] = target + + with _RUN_LOCK: + tests: list[doctest.DocTest] = finder.find( + target, + name=finder_name, + extraglobs=extraglobs, + ) + for test in tests: + runner.run(test) + + return runner.summarize(verbose=False) + + +def run_module_doctests( + target: ModuleType, + *, + optionflags: int = doctest.ELLIPSIS, +) -> doctest.TestResults: + """Run and aggregate examples in a module and its discoverable members.""" + module: ModuleType = _require_module(target) + return run_doctests(module, optionflags=optionflags) + + +def _require_module(target: object) -> ModuleType: + if not isinstance(target, ModuleType): + raise TypeError("target must be a module") + return target diff --git a/CH_10_testing_and_logging/exercise_01/file_documentation_plugin.py b/CH_10_testing_and_logging/exercise_01/file_documentation_plugin.py new file mode 100644 index 0000000..c185de7 --- /dev/null +++ b/CH_10_testing_and_logging/exercise_01/file_documentation_plugin.py @@ -0,0 +1,82 @@ +"""Pytest plugin enforcing documentation on collected Python test modules.""" + +from __future__ import annotations + +import ast +import fnmatch +import os +import tokenize +from pathlib import Path +from typing import TYPE_CHECKING, cast + +if TYPE_CHECKING: + import pytest + + +def _is_python_test( + file_path: Path, + parent: pytest.Collector, +) -> bool: + """Return whether pytest will collect *file_path* as a Python test.""" + if file_path.suffix != ".py": + return False + + if parent.session.isinitpath(file_path): + return True + + patterns: list[str] = cast( + list[str], + parent.config.getini("python_files"), + ) + return any(_matches_pattern(file_path, pattern) for pattern in patterns) + + +def _matches_pattern(file_path: Path, pattern: str) -> bool: + candidate: str = file_path.name + if os.sep in pattern or (os.altsep is not None and os.altsep in pattern): + candidate = str(file_path) + if file_path.is_absolute() and not os.path.isabs(pattern): + pattern = f"*{os.sep}{pattern}" + return fnmatch.fnmatch(candidate, pattern) + + +def _read_module(file_path: Path) -> ast.Module: + """Parse *file_path* or raise a collection error with source context.""" + try: + with tokenize.open(file_path) as source_file: + source: str = source_file.read() + return ast.parse(source, filename=str(file_path)) + except SyntaxError as error: + line_number: int = error.lineno or 1 + message: str = ( + f"Malformed Python test module {file_path}:{line_number}: {error.msg}" + ) + raise _collection_error(message) from error + except (OSError, UnicodeError) as error: + raise _collection_error( + f"Cannot read Python test module {file_path}: {error}" + ) from error + + +def _collection_error(message: str) -> Exception: + import pytest + + return pytest.Collector.CollectError(message) + + +def pytest_collect_file( + file_path: Path, + parent: pytest.Collector, +) -> pytest.Collector | None: + """Reject collected Python test modules without file-level documentation.""" + if not _is_python_test(file_path, parent): + return None + + module: ast.Module = _read_module(file_path) + docstring: str | None = ast.get_docstring(module, clean=False) + if docstring is None or not docstring.strip(): + raise _collection_error( + f"Python test module requires a file-level docstring: {file_path}" + ) + + return None diff --git a/CH_10_testing_and_logging/exercise_01/solution_00.py b/CH_10_testing_and_logging/exercise_01/solution_00.py new file mode 100644 index 0000000..32a5f03 --- /dev/null +++ b/CH_10_testing_and_logging/exercise_01/solution_00.py @@ -0,0 +1,43 @@ +"""Public solutions for the Chapter 10 testing and logging exercises.""" + +from __future__ import annotations + +import doctest + +from CH_10_testing_and_logging.exercise_01.doctest_runner import ( + run_doctests, + run_module_doctests, +) +from CH_10_testing_and_logging.exercise_01.file_documentation_plugin import ( + pytest_collect_file, +) +from CH_10_testing_and_logging.exercise_01.task_logging import ( + TaskBatchLoggerAdapter, +) + +__all__: list[str] = [ + "TaskBatchLoggerAdapter", + "pytest_collect_file", + "run_doctests", + "run_module_doctests", +] + + +def _double(value: int) -> int: + """ + Return twice *value*. + + >>> _double(6) + 12 + """ + return value * 2 + + +def main() -> None: + """Demonstrate the runner and report its aggregate totals.""" + result: doctest.TestResults = run_doctests(_double) + print(f"Attempted {result.attempted} doctest; {result.failed} failed.") + + +if __name__ == "__main__": + main() diff --git a/CH_10_testing_and_logging/exercise_01/task_logging.py b/CH_10_testing_and_logging/exercise_01/task_logging.py new file mode 100644 index 0000000..85424ef --- /dev/null +++ b/CH_10_testing_and_logging/exercise_01/task_logging.py @@ -0,0 +1,200 @@ +"""Thread-safe logging batches keyed by task ID.""" + +from __future__ import annotations + +import logging +import threading +from collections.abc import Mapping, MutableMapping +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, NoReturn, cast + +if TYPE_CHECKING: + + class _LoggerAdapterBase(logging.LoggerAdapter[logging.Logger]): + pass + +else: + _LoggerAdapterBase = logging.LoggerAdapter + + +@dataclass(frozen=True, slots=True) +class _PendingMessage: + level: int + message: object + args: tuple[object, ...] + + +class TaskBatchLoggerAdapter(_LoggerAdapterBase): + """Buffer messages per task and emit one record for each successful flush.""" + + separator: str = "\n" + + def __init__( + self, + logger: logging.Logger, + extra: Mapping[str, object] | None = None, + ) -> None: + super().__init__(logger, dict(extra or {})) + self._batches: dict[str, list[_PendingMessage]] = {} + self._lock: threading.RLock = threading.RLock() + + def process( + self, + msg: object, + kwargs: MutableMapping[str, Any], + ) -> tuple[object, MutableMapping[str, Any]]: + """Merge adapter and call-specific context for the combined record.""" + call_extra: object = kwargs.get("extra") + if call_extra is not None and not isinstance(call_extra, Mapping): + raise TypeError("extra must be a mapping") + + merged_extra: dict[str, object] = dict(self.extra or {}) + if call_extra is not None: + merged_extra.update(cast(Mapping[str, object], call_extra)) + kwargs["extra"] = merged_extra + return msg, kwargs + + def record( + self, + task_id: str, + level: int, + message: object, + *args: object, + ) -> None: + """Append one lazily formatted message to *task_id*.""" + self._validate_task_id(task_id) + pending: _PendingMessage = _PendingMessage(level, message, args) + with self._lock: + self._batches.setdefault(task_id, []).append(pending) + + def flush(self, task_id: str) -> bool: + """Emit and remove one task batch, retaining it on any failure.""" + self._validate_task_id(task_id) + with self._lock: + batch: list[_PendingMessage] | None = self._batches.get(task_id) + if not batch: + raise KeyError(f"unknown task_id: {task_id}") + + pending: tuple[_PendingMessage, ...] = tuple(batch) + level: int = max(message.level for message in pending) + if not self.isEnabledFor(level): + return False + + formatted: list[str] = [ + self._format_message(message) for message in pending + ] + record: logging.LogRecord = self._make_record( + task_id, + level, + self.separator.join(formatted), + ) + if not self._dispatch_record(record): + return False + + del batch[: len(pending)] + if not batch: + del self._batches[task_id] + return True + + def pending_count(self, task_id: str) -> int: + """Return the number of buffered messages for *task_id*.""" + self._validate_task_id(task_id) + with self._lock: + return len(self._batches.get(task_id, ())) + + def discard(self, task_id: str) -> int: + """Explicitly discard and return the size of one task batch.""" + self._validate_task_id(task_id) + with self._lock: + batch: list[_PendingMessage] | None = self._batches.pop(task_id, None) + return len(batch) if batch is not None else 0 + + @staticmethod + def _format_message(message: _PendingMessage) -> str: + rendered: str = str(message.message) + return rendered % message.args if message.args else rendered + + def _make_record( + self, + task_id: str, + level: int, + message: str, + ) -> logging.LogRecord: + extra: dict[str, object] = dict(self.extra or {}) + extra["task_id"] = task_id + file_name: str + line_number: int + function_name: str + stack_info: str | None + file_name, line_number, function_name, stack_info = self.logger.findCaller( + stack_info=False, + stacklevel=3, + ) + return self.logger.makeRecord( + self.logger.name, + level, + file_name, + line_number, + message, + (), + None, + function_name, + extra, + stack_info, + ) + + def _dispatch_record(self, record: logging.LogRecord) -> bool: + if not self.logger.filter(record): + return False + + handlers: list[logging.Handler] = self._handlers_for() + emitted: bool = False + for handler in handlers: + if record.levelno >= handler.level and self._emit(handler, record): + emitted = True + return emitted + + def _handlers_for(self) -> list[logging.Handler]: + handlers: list[logging.Handler] = [] + logger: logging.Logger | None = self.logger + while logger is not None: + handlers.extend(logger.handlers) + if not logger.propagate: + break + logger = logger.parent + + if not handlers and logging.lastResort is not None: + handlers.append(logging.lastResort) + return handlers + + @staticmethod + def _emit(handler: logging.Handler, record: logging.LogRecord) -> bool: + handler.acquire() + sentinel: object = object() + previous_handle_error: object = handler.__dict__.get( + "handleError", + sentinel, + ) + + def raise_handler_error(failed_record: logging.LogRecord) -> NoReturn: + raise RuntimeError(f"logging handler failed for {failed_record.name}") + + try: + if not handler.filter(record): + return False + handler.__dict__["handleError"] = raise_handler_error + handler.emit(record) + return True + finally: + if previous_handle_error is sentinel: + handler.__dict__.pop("handleError", None) + else: + handler.__dict__["handleError"] = previous_handle_error + handler.release() + + @staticmethod + def _validate_task_id(task_id: object) -> None: + if not isinstance(task_id, str): + raise TypeError("task_id must be a string") + if not task_id: + raise ValueError("task_id must not be empty") diff --git a/CH_10_testing_and_logging/exercise_01/test_solution_00.py b/CH_10_testing_and_logging/exercise_01/test_solution_00.py new file mode 100644 index 0000000..d46bcbd --- /dev/null +++ b/CH_10_testing_and_logging/exercise_01/test_solution_00.py @@ -0,0 +1,338 @@ +"""Tests for the recursive doctest runner.""" + +from __future__ import annotations + +import doctest +import inspect +import linecache +import pdb +import subprocess +import sys +import threading +from collections.abc import Callable +from concurrent.futures import Future, ThreadPoolExecutor +from pathlib import Path +from typing import TextIO, get_type_hints + +import pytest + +from CH_10_testing_and_logging.exercise_01.solution_00 import ( + main, + run_doctests, +) + +_OFFSET: int = 7 + + +def _passing_example() -> None: + """ + >>> sorted([3, 1, 2]) + [1, 2, 3] + """ + + +def _failing_example() -> None: + """ + >>> 2 + 2 + 5 + """ + + +def _no_examples() -> None: + """Contain no doctest examples.""" + + +def _ellipsis_example() -> None: + """ + >>> print("prefix: variable suffix") + prefix: ... suffix + """ + + +def _uses_module_global(value: int) -> int: + """ + >>> _uses_module_global(5) + 12 + """ + return value + _OFFSET + + +def _keyboard_interrupt_example() -> None: + """ + >>> raise KeyboardInterrupt + """ + + +class _Calculator: + """ + Create a calculator. + + >>> calculator = _Calculator(4) + >>> calculator.double() + 8 + """ + + def __init__(self, value: int) -> None: + self.value: int = value + + def double(self) -> int: + """ + Return twice the configured value. + + >>> _Calculator(3).double() + 6 + """ + return self.value * 2 + + +def test_passing_doctest_returns_exact_test_results_type() -> None: + result: doctest.TestResults = run_doctests(_passing_example) + + assert type(result) is doctest.TestResults + assert result == doctest.TestResults(failed=0, attempted=1) + + +def test_failure_is_reported_and_counted( + capsys: pytest.CaptureFixture[str], +) -> None: + result: doctest.TestResults = run_doctests(_failing_example) + report: str = capsys.readouterr().out + + assert result == doctest.TestResults(failed=1, attempted=1) + assert "Failed example:" in report + assert "2 + 2" in report + assert "Expected:" in report + + +def test_target_without_examples_returns_zero_totals() -> None: + assert run_doctests(_no_examples) == doctest.TestResults( + failed=0, + attempted=0, + ) + + +def test_class_and_method_examples_are_found_recursively() -> None: + result: doctest.TestResults = run_doctests(_Calculator) + + assert result == doctest.TestResults(failed=0, attempted=3) + + +def test_default_and_custom_optionflags_control_matching( + capsys: pytest.CaptureFixture[str], +) -> None: + default_result: doctest.TestResults = run_doctests(_ellipsis_example) + exact_result: doctest.TestResults = run_doctests( + _ellipsis_example, + optionflags=0, + ) + capsys.readouterr() + + assert default_result == doctest.TestResults(failed=0, attempted=1) + assert exact_result == doctest.TestResults(failed=1, attempted=1) + + +def test_passing_and_empty_targets_are_silent_with_verbose_argv( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + monkeypatch.setattr(sys, "argv", ["pytest", "-v"]) + + passing_result: doctest.TestResults = run_doctests(_passing_example) + empty_result: doctest.TestResults = run_doctests(_no_examples) + + assert passing_result == doctest.TestResults(failed=0, attempted=1) + assert empty_result == doctest.TestResults(failed=0, attempted=0) + assert capsys.readouterr().out == "" + + +def test_target_module_globals_are_available_to_examples() -> None: + assert run_doctests(_uses_module_global) == doctest.TestResults( + failed=0, + attempted=1, + ) + + +def test_locally_defined_function_can_reference_itself() -> None: + def local_double(value: int) -> int: + """ + >>> local_double(5) + 10 + """ + return value * 2 + + assert run_doctests(local_double) == doctest.TestResults( + failed=0, + attempted=1, + ) + + +def test_locally_defined_class_can_reference_itself() -> None: + class LocalMultiplier: + """ + >>> LocalMultiplier(6).double() + 12 + """ + + def __init__(self, value: int) -> None: + self.value: int = value + + def double(self) -> int: + """Return twice the configured value.""" + return self.value * 2 + + assert run_doctests(LocalMultiplier) == doctest.TestResults( + failed=0, + attempted=1, + ) + + +def test_each_call_uses_fresh_runner_state( + capsys: pytest.CaptureFixture[str], +) -> None: + failed_result: doctest.TestResults = run_doctests(_failing_example) + capsys.readouterr() + empty_result: doctest.TestResults = run_doctests(_no_examples) + + assert failed_result == doctest.TestResults(failed=1, attempted=1) + assert empty_result == doctest.TestResults(failed=0, attempted=0) + + +def test_keyboard_interrupt_propagates_without_leaving_runner_state() -> None: + with pytest.raises(KeyboardInterrupt): + run_doctests(_keyboard_interrupt_example) + + assert run_doctests(_passing_example) == doctest.TestResults( + failed=0, + attempted=1, + ) + + +def test_concurrent_calls_do_not_overlap_and_restore_process_globals( + monkeypatch: pytest.MonkeyPatch, +) -> None: + original_run: Callable[..., doctest.TestResults] = doctest.DocTestRunner.run + original_stdout: TextIO = sys.stdout + original_displayhook: Callable[[object], None] = sys.displayhook + original_set_trace: Callable[..., object] = pdb.set_trace + original_getlines: Callable[..., list[str]] = linecache.getlines + first_entered: threading.Event = threading.Event() + second_entered: threading.Event = threading.Event() + second_started: threading.Event = threading.Event() + release_first: threading.Event = threading.Event() + state_lock: threading.Lock = threading.Lock() + active_runners: int = 0 + maximum_active_runners: int = 0 + entry_count: int = 0 + + def instrumented_run( + self: doctest.DocTestRunner, + *args: object, + **kwargs: object, + ) -> doctest.TestResults: + nonlocal active_runners, entry_count, maximum_active_runners + + with state_lock: + active_runners += 1 + entry_count += 1 + current_entry: int = entry_count + maximum_active_runners = max( + maximum_active_runners, + active_runners, + ) + + if current_entry == 1: + first_entered.set() + if not release_first.wait(timeout=2): + raise TimeoutError("first doctest runner was not released") + else: + second_entered.set() + + try: + return original_run(self, *args, **kwargs) + finally: + with state_lock: + active_runners -= 1 + + def run_second_call() -> doctest.TestResults: + second_started.set() + return run_doctests(_passing_example) + + monkeypatch.setattr(doctest.DocTestRunner, "run", instrumented_run) + executor: ThreadPoolExecutor = ThreadPoolExecutor(max_workers=2) + try: + first_future: Future[doctest.TestResults] = executor.submit( + run_doctests, + _passing_example, + ) + assert first_entered.wait(timeout=1) + + second_future: Future[doctest.TestResults] = executor.submit( + run_second_call, + ) + assert second_started.wait(timeout=1) + assert not second_entered.wait(timeout=0.1) + release_first.set() + + assert first_future.result(timeout=2) == doctest.TestResults(0, 1) + assert second_future.result(timeout=2) == doctest.TestResults(0, 1) + finally: + release_first.set() + executor.shutdown(wait=True) + + assert second_entered.is_set() + assert maximum_active_runners == 1 + assert sys.stdout is original_stdout + assert sys.displayhook is original_displayhook + assert pdb.set_trace is original_set_trace + assert linecache.getlines is original_getlines + + +def test_public_annotations_and_defaults_are_exact() -> None: + assert get_type_hints(run_doctests) == { + "target": object, + "optionflags": int, + "return": doctest.TestResults, + } + assert ( + inspect.signature(run_doctests).parameters["optionflags"].default + == doctest.ELLIPSIS + ) + assert get_type_hints(main) == {"return": type(None)} + + +def test_import_is_silent() -> None: + repository_root: Path = Path(__file__).parents[2] + completed: subprocess.CompletedProcess[str] = subprocess.run( + [ + sys.executable, + "-c", + "import CH_10_testing_and_logging.exercise_01.solution_00", + ], + cwd=repository_root, + check=False, + capture_output=True, + text=True, + ) + + assert completed.returncode == 0 + assert completed.stdout == "" + assert completed.stderr == "" + + +def test_guarded_module_demonstrates_the_runner() -> None: + repository_root: Path = Path(__file__).parents[2] + completed: subprocess.CompletedProcess[str] = subprocess.run( + [ + sys.executable, + "-m", + "CH_10_testing_and_logging.exercise_01.solution_00", + ], + cwd=repository_root, + check=False, + capture_output=True, + text=True, + ) + + assert completed.returncode == 0 + assert completed.stdout == "Attempted 1 doctest; 0 failed.\n" + assert completed.stderr == "" diff --git a/CH_10_testing_and_logging/exercise_01/tests_doctests/__init__.py b/CH_10_testing_and_logging/exercise_01/tests_doctests/__init__.py new file mode 100644 index 0000000..3175cd6 --- /dev/null +++ b/CH_10_testing_and_logging/exercise_01/tests_doctests/__init__.py @@ -0,0 +1 @@ +"""Tests for the Chapter 10 doctest runner.""" diff --git a/CH_10_testing_and_logging/exercise_01/tests_doctests/fixture_module.py b/CH_10_testing_and_logging/exercise_01/tests_doctests/fixture_module.py new file mode 100644 index 0000000..6b19e45 --- /dev/null +++ b/CH_10_testing_and_logging/exercise_01/tests_doctests/fixture_module.py @@ -0,0 +1,35 @@ +"""Module-level doctest example. + +>>> MODULE_VALUE + 1 +4 +""" + +MODULE_VALUE: int = 3 + + +def add_module_value(value: int) -> int: + """Add the module value. + + >>> add_module_value(4) + 7 + """ + return value + MODULE_VALUE + + +class Multiplier: + """Multiply configured values. + + >>> Multiplier(3).apply(4) + 12 + """ + + def __init__(self, factor: int) -> None: + self.factor: int = factor + + def apply(self, value: int) -> int: + """Apply the configured factor. + + >>> Multiplier(5).apply(2) + 10 + """ + return self.factor * value diff --git a/CH_10_testing_and_logging/exercise_01/tests_doctests/test_solution_00.py b/CH_10_testing_and_logging/exercise_01/tests_doctests/test_solution_00.py new file mode 100644 index 0000000..61255da --- /dev/null +++ b/CH_10_testing_and_logging/exercise_01/tests_doctests/test_solution_00.py @@ -0,0 +1,47 @@ +"""Tests for recursive module doctest discovery.""" + +from __future__ import annotations + +import doctest +import subprocess +import sys +from pathlib import Path +from types import ModuleType +from typing import cast + +import pytest + +from CH_10_testing_and_logging.exercise_01 import solution_00 +from CH_10_testing_and_logging.exercise_01.tests_doctests import fixture_module + + +def test_module_runner_aggregates_module_function_class_and_method_examples() -> None: + result: doctest.TestResults = solution_00.run_module_doctests(fixture_module) + + assert result == doctest.TestResults(failed=0, attempted=4) + + +def test_module_runner_rejects_non_module_targets() -> None: + with pytest.raises(TypeError, match="target must be a module"): + solution_00.run_module_doctests(cast(ModuleType, object())) + + +def test_public_doctest_entry_point_imports_without_third_party_packages() -> None: + repository_root: Path = Path(__file__).parents[3] + completed: subprocess.CompletedProcess[str] = subprocess.run( + [ + sys.executable, + "-S", + "-c", + ( + "from CH_10_testing_and_logging.exercise_01.solution_00 " + "import run_doctests" + ), + ], + cwd=repository_root, + check=False, + capture_output=True, + text=True, + ) + + assert completed.returncode == 0, completed.stderr diff --git a/CH_10_testing_and_logging/exercise_01/tests_plugin/__init__.py b/CH_10_testing_and_logging/exercise_01/tests_plugin/__init__.py new file mode 100644 index 0000000..8623e1b --- /dev/null +++ b/CH_10_testing_and_logging/exercise_01/tests_plugin/__init__.py @@ -0,0 +1 @@ +"""Integration tests for the pytest file-documentation plugin.""" diff --git a/CH_10_testing_and_logging/exercise_01/tests_plugin/test_solution_00.py b/CH_10_testing_and_logging/exercise_01/tests_plugin/test_solution_00.py new file mode 100644 index 0000000..6f8fdc7 --- /dev/null +++ b/CH_10_testing_and_logging/exercise_01/tests_plugin/test_solution_00.py @@ -0,0 +1,206 @@ +"""Exercise the documentation plugin through real pytest collection.""" + +from __future__ import annotations + +import os +import shlex +import subprocess +import sys +from importlib.util import find_spec +from pathlib import Path + +PLUGIN: str = "CH_10_testing_and_logging.exercise_01.file_documentation_plugin" +REPOSITORY_ROOT: Path = Path(__file__).parents[3] + + +def _run_collection( + directory: Path, + *initial_paths: str, +) -> subprocess.CompletedProcess[str]: + environment: dict[str, str] = os.environ.copy() + existing_path: str = environment.get("PYTHONPATH", "") + environment["PYTHONPATH"] = os.pathsep.join( + part for part in (str(REPOSITORY_ROOT), existing_path) if part + ) + return subprocess.run( + [ + sys.executable, + "-m", + "pytest", + "-p", + PLUGIN, + "--collect-only", + "-q", + *initial_paths, + ], + cwd=directory, + env=environment, + check=False, + capture_output=True, + text=True, + ) + + +def _configure(directory: Path) -> None: + (directory / "pytest.ini").write_text( + "[pytest]\npython_files = check_*.py\n", + encoding="utf-8", + ) + + +def test_collects_documented_python_test_matching_configured_pattern( + tmp_path: Path, +) -> None: + _configure(tmp_path) + (tmp_path / "check_documented.py").write_text( + '"""File-level documentation."""\n\n' + "def test_example() -> None:\n" + " assert True\n", + encoding="utf-8", + ) + + completed: subprocess.CompletedProcess[str] = _run_collection(tmp_path) + + assert completed.returncode == 0, completed.stdout + completed.stderr + assert "1 test collected" in completed.stdout + + +def test_rejects_matching_python_test_without_module_docstring( + tmp_path: Path, +) -> None: + _configure(tmp_path) + (tmp_path / "check_undocumented.py").write_text( + "def test_example() -> None:\n assert True\n", + encoding="utf-8", + ) + + completed: subprocess.CompletedProcess[str] = _run_collection(tmp_path) + output: str = completed.stdout + completed.stderr + + assert completed.returncode == 2, output + assert "Python test module requires a file-level docstring" in output + assert "check_undocumented.py" in output + + +def test_rejects_malformed_matching_python_test_with_clear_error( + tmp_path: Path, +) -> None: + _configure(tmp_path) + (tmp_path / "check_malformed.py").write_text( + "def test_broken(:\n", + encoding="utf-8", + ) + + completed: subprocess.CompletedProcess[str] = _run_collection(tmp_path) + output: str = completed.stdout + completed.stderr + + assert completed.returncode == 2 + assert "Malformed Python test module" in output + assert "check_malformed.py:1" in output + + +def test_does_not_claim_non_test_python_files(tmp_path: Path) -> None: + _configure(tmp_path) + (tmp_path / "check_documented.py").write_text( + '"""File-level documentation."""\n\n' + "def test_example() -> None:\n" + " assert True\n", + encoding="utf-8", + ) + (tmp_path / "application.py").write_text( + "def malformed(:\n", + encoding="utf-8", + ) + + completed: subprocess.CompletedProcess[str] = _run_collection(tmp_path) + output: str = completed.stdout + completed.stderr + + assert completed.returncode == 0, output + assert "1 test collected" in completed.stdout + assert "application.py" not in output + + +def test_path_patterns_apply_to_nested_python_test_files(tmp_path: Path) -> None: + (tmp_path / "pytest.ini").write_text( + "[pytest]\npython_files = nested/check_*.py\n", + encoding="utf-8", + ) + nested: Path = tmp_path / "nested" + nested.mkdir() + (nested / "check_undocumented.py").write_text( + "def test_example() -> None:\n assert True\n", + encoding="utf-8", + ) + + completed: subprocess.CompletedProcess[str] = _run_collection(tmp_path) + output: str = completed.stdout + completed.stderr + + assert completed.returncode == 2 + assert "Python test module requires a file-level docstring" in output + assert "check_undocumented.py" in output + + +def test_documented_plugin_command_works_without_synthetic_pythonpath() -> None: + readme: Path = REPOSITORY_ROOT / ( + "CH_10_testing_and_logging/exercise_01/README.rst" + ) + command_line: str = next( + line.strip().removeprefix("$ ") + for line in readme.read_text(encoding="utf-8").splitlines() + if line.strip().startswith("$ uv run") and PLUGIN in line + ) + environment: dict[str, str] = os.environ.copy() + environment.pop("PYTHONPATH", None) + command: list[str] = shlex.split(command_line) + setuptools_available: bool = find_spec("setuptools") is not None + + assert command[:3] == ["uv", "run", "--no-sync"] + + completed: subprocess.CompletedProcess[str] = subprocess.run( + command, + cwd=REPOSITORY_ROOT, + env=environment, + check=False, + capture_output=True, + text=True, + timeout=30, + ) + + assert completed.returncode == 0, completed.stdout + completed.stderr + if setuptools_available: + availability_probe: subprocess.CompletedProcess[str] = subprocess.run( + [ + sys.executable, + "-c", + ( + "import importlib.util; " + "raise SystemExit(" + "importlib.util.find_spec('setuptools') is None)" + ), + ], + check=False, + capture_output=True, + text=True, + ) + assert availability_probe.returncode == 0, availability_probe.stderr + + +def test_explicit_non_pattern_python_test_requires_module_docstring( + tmp_path: Path, +) -> None: + _configure(tmp_path) + selected: Path = tmp_path / "selected_case.py" + selected.write_text( + "def test_example() -> None:\n assert True\n", + encoding="utf-8", + ) + + completed: subprocess.CompletedProcess[str] = _run_collection( + tmp_path, + selected.name, + ) + output: str = completed.stdout + completed.stderr + + assert completed.returncode != 0, output + assert "Python test module requires a file-level docstring" in output + assert selected.name in output diff --git a/CH_10_testing_and_logging/exercise_01/tests_task_logging/__init__.py b/CH_10_testing_and_logging/exercise_01/tests_task_logging/__init__.py new file mode 100644 index 0000000..9004471 --- /dev/null +++ b/CH_10_testing_and_logging/exercise_01/tests_task_logging/__init__.py @@ -0,0 +1 @@ +"""Tests for task-based logging batches.""" diff --git a/CH_10_testing_and_logging/exercise_01/tests_task_logging/test_solution_00.py b/CH_10_testing_and_logging/exercise_01/tests_task_logging/test_solution_00.py new file mode 100644 index 0000000..7701ec8 --- /dev/null +++ b/CH_10_testing_and_logging/exercise_01/tests_task_logging/test_solution_00.py @@ -0,0 +1,204 @@ +"""Test the task-batching LoggerAdapter with real logging records.""" + +from __future__ import annotations + +import logging +from concurrent.futures import ThreadPoolExecutor +from typing import TextIO, cast + +import pytest + +from CH_10_testing_and_logging.exercise_01.solution_00 import ( + TaskBatchLoggerAdapter, +) + + +class RecordHandler(logging.Handler): + """Capture emitted records for assertions.""" + + def __init__(self) -> None: + super().__init__() + self.records: list[logging.LogRecord] = [] + + def emit(self, record: logging.LogRecord) -> None: + self.records.append(record) + + +class FailOnceHandler(RecordHandler): + """Raise on the first emission and capture later emissions.""" + + def __init__(self) -> None: + super().__init__() + self.fail: bool = True + + def emit(self, record: logging.LogRecord) -> None: + if self.fail: + self.fail = False + raise RuntimeError("handler failed") + super().emit(record) + + +class BrokenStream: + """Fail every stream write.""" + + def write(self, text: str) -> int: + raise OSError("stream unavailable") + + def flush(self) -> None: + pass + + +def _adapter( + handler: logging.Handler, + *, + level: int = logging.DEBUG, +) -> TaskBatchLoggerAdapter: + logger: logging.Logger = logging.getLogger(f"chapter-10-task-batches-{id(handler)}") + logger.handlers.clear() + logger.setLevel(level) + logger.propagate = False + logger.addHandler(handler) + return TaskBatchLoggerAdapter( + logger, + {"component": "chapter-10", "task_id": "adapter-default"}, + ) + + +def test_flush_combines_messages_in_insertion_order_and_isolates_tasks() -> None: + handler: RecordHandler = RecordHandler() + adapter: TaskBatchLoggerAdapter = _adapter(handler) + + adapter.record("task-a", logging.INFO, "first %s", "message") + adapter.record("task-b", logging.WARNING, "other") + adapter.record("task-a", logging.ERROR, "last") + + assert adapter.flush("task-a") is True + assert adapter.flush("task-b") is True + + assert [record.getMessage() for record in handler.records] == [ + "first message\nlast", + "other", + ] + assert [record.levelno for record in handler.records] == [ + logging.ERROR, + logging.WARNING, + ] + assert [record.__dict__["task_id"] for record in handler.records] == [ + "task-a", + "task-b", + ] + assert [record.__dict__["component"] for record in handler.records] == [ + "chapter-10", + "chapter-10", + ] + + +def test_formatting_failure_preserves_batch_until_explicit_discard() -> None: + handler: RecordHandler = RecordHandler() + adapter: TaskBatchLoggerAdapter = _adapter(handler) + adapter.record("broken", logging.INFO, "number %d", "not-a-number") + + with pytest.raises(TypeError): + adapter.flush("broken") + + assert adapter.pending_count("broken") == 1 + assert adapter.discard("broken") == 1 + assert adapter.pending_count("broken") == 0 + assert handler.records == [] + + +def test_disabled_level_preserves_batch_for_later_flush() -> None: + handler: RecordHandler = RecordHandler() + adapter: TaskBatchLoggerAdapter = _adapter(handler, level=logging.WARNING) + adapter.record("quiet", logging.INFO, "retained") + + assert adapter.flush("quiet") is False + assert adapter.pending_count("quiet") == 1 + + adapter.logger.setLevel(logging.INFO) + + assert adapter.flush("quiet") is True + assert [record.getMessage() for record in handler.records] == ["retained"] + + +def test_handler_level_rejection_preserves_batch_for_later_flush() -> None: + handler: RecordHandler = RecordHandler() + handler.setLevel(logging.WARNING) + adapter: TaskBatchLoggerAdapter = _adapter(handler) + adapter.record("quiet-handler", logging.INFO, "retained") + + assert adapter.flush("quiet-handler") is False + assert adapter.pending_count("quiet-handler") == 1 + + handler.setLevel(logging.INFO) + + assert adapter.flush("quiet-handler") is True + assert [record.getMessage() for record in handler.records] == ["retained"] + + +def test_stream_handler_error_propagates_and_preserves_batch() -> None: + handler: logging.StreamHandler[TextIO] = logging.StreamHandler( + cast(TextIO, BrokenStream()) + ) + adapter: TaskBatchLoggerAdapter = _adapter(handler) + adapter.record("broken-stream", logging.ERROR, "retained") + + with pytest.raises(RuntimeError, match="logging handler failed"): + adapter.flush("broken-stream") + + assert adapter.pending_count("broken-stream") == 1 + + +def test_logging_failure_preserves_batch_for_retry() -> None: + handler: FailOnceHandler = FailOnceHandler() + adapter: TaskBatchLoggerAdapter = _adapter(handler) + adapter.record("retry", logging.WARNING, "try again") + + with pytest.raises(RuntimeError, match="handler failed"): + adapter.flush("retry") + + assert adapter.pending_count("retry") == 1 + assert adapter.flush("retry") is True + assert [record.getMessage() for record in handler.records] == ["try again"] + + +def test_empty_and_unknown_task_ids_have_explicit_behavior() -> None: + adapter: TaskBatchLoggerAdapter = _adapter(RecordHandler()) + + with pytest.raises(ValueError, match="task_id must not be empty"): + adapter.record("", logging.INFO, "message") + with pytest.raises(TypeError, match="task_id must be a string"): + adapter.record(42, logging.INFO, "message") # type: ignore[arg-type] + with pytest.raises(KeyError, match="unknown task_id"): + adapter.flush("unknown") + + assert adapter.pending_count("unknown") == 0 + assert adapter.discard("unknown") == 0 + + +def test_concurrent_tasks_remain_isolated_and_ordered() -> None: + handler: RecordHandler = RecordHandler() + adapter: TaskBatchLoggerAdapter = _adapter(handler) + + def record_task(task_number: int) -> None: + task_id: str = f"task-{task_number}" + for message_number in range(40): + adapter.record( + task_id, + logging.INFO, + "message %d", + message_number, + ) + + with ThreadPoolExecutor(max_workers=8) as executor: + list(executor.map(record_task, range(8))) + + for task_number in range(8): + assert adapter.flush(f"task-{task_number}") is True + + assert len(handler.records) == 8 + for task_number, record in enumerate(handler.records): + assert record.__dict__["task_id"] == f"task-{task_number}" + assert record.getMessage().splitlines() == [ + f"message {message_number}" for message_number in range(40) + ] diff --git a/CH_10_testing_and_logging/exercise_01/tox.ini b/CH_10_testing_and_logging/exercise_01/tox.ini new file mode 100644 index 0000000..451d214 --- /dev/null +++ b/CH_10_testing_and_logging/exercise_01/tox.ini @@ -0,0 +1,20 @@ +[tox] +env_list = static +skipsdist = true +work_dir = {tox_root}/../../.tox + +[testenv:static] +description = type-check the maintained Chapter 10 implementation +base_python = python3.10 +package = skip +deps = + mypy>=1.18 + pytest>=9.0 +change_dir = {tox_root}/../.. +commands = + mypy \ + CH_10_testing_and_logging/exercise_01/__init__.py \ + CH_10_testing_and_logging/exercise_01/doctest_runner.py \ + CH_10_testing_and_logging/exercise_01/file_documentation_plugin.py \ + CH_10_testing_and_logging/exercise_01/solution_00.py \ + CH_10_testing_and_logging/exercise_01/task_logging.py diff --git a/CH_11_debugging/README.rst b/CH_11_debugging/README.rst index 7cdb291..b864040 100644 --- a/CH_11_debugging/README.rst +++ b/CH_11_debugging/README.rst @@ -1,6 +1,9 @@ Chapter 11 - debugging -======================================================================================================================= +====================== -1. Execute code with a timeout so you can see where your application is stalling -2. Measure the duration of the execution -3. Show how often that specific bit of code has been executed +Exercises +--------- + +1. `Stalled-call diagnostics `_ +2. `Execution duration `_ +3. `Line execution counts `_ diff --git a/CH_11_debugging/exercise_01/README.rst b/CH_11_debugging/exercise_01/README.rst new file mode 100644 index 0000000..60e4063 --- /dev/null +++ b/CH_11_debugging/exercise_01/README.rst @@ -0,0 +1,58 @@ +Exercise 1: stalled-call diagnostics +==================================== + +Question +-------- + +.. code-block:: text + + 1. Execute code with a timeout so you can see where your application is stalling + +Solution +-------- + +``run_with_timeout_diagnostics`` schedules a one-time stack dump with +``faulthandler.dump_traceback_later`` before calling the supplied synchronous +callable. The diagnostic is cancelled in a ``finally`` block after either a +return or an exception. Positional and keyword arguments, return values, and +callable exceptions pass through unchanged. + +The timeout must be greater than zero. Reaching it writes all thread stacks to +standard error, which can reveal the line where the application is stalled. +This is diagnostic only: it neither interrupts nor kills the callable. The +call therefore remains blocked until the callable itself returns or raises. +``faulthandler`` owns one process-wide delayed dump, so independently +scheduled diagnostics can replace one another. + +Dependencies +------------ + +The solution requires Python 3.10 or newer and uses only the standard library. +The repository's ``dev`` dependency group supplies pytest for the tests. + +Run +--- + +Run the guarded demonstration: + +.. code-block:: console + + $ uv run python -m CH_11_debugging.exercise_01.solution_00 + +Run the focused tests: + +.. code-block:: console + + $ uv run pytest CH_11_debugging/exercise_01/test_solution_00.py -v + +Historical source +----------------- + +The diagnostic approach is illustrated by the immutable upstream +`T_06_stack.py +`_ +and +`T_07_faulthandler.py +`_. +The solution and tests are self-contained; no source files are copied from +those references. diff --git a/CH_11_debugging/exercise_01/__init__.py b/CH_11_debugging/exercise_01/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/CH_11_debugging/exercise_01/__init__.py @@ -0,0 +1 @@ + diff --git a/CH_11_debugging/exercise_01/solution_00.py b/CH_11_debugging/exercise_01/solution_00.py new file mode 100644 index 0000000..81ef7bf --- /dev/null +++ b/CH_11_debugging/exercise_01/solution_00.py @@ -0,0 +1,35 @@ +"""Schedule a stack dump when synchronous code appears stalled.""" + +import faulthandler +from collections.abc import Callable +from typing import ParamSpec, TypeVar + +Parameters = ParamSpec("Parameters") +Result = TypeVar("Result") + + +def run_with_timeout_diagnostics( + function: Callable[Parameters, Result], + timeout: float, + *args: Parameters.args, + **kwargs: Parameters.kwargs, +) -> Result: + """Run a callable and dump all thread stacks if it exceeds ``timeout``.""" + if not timeout > 0: + raise ValueError("timeout must be positive") + + faulthandler.dump_traceback_later(timeout, repeat=False) + try: + return function(*args, **kwargs) + finally: + faulthandler.cancel_dump_traceback_later() + + +def main() -> None: + """Demonstrate a fast call that cancels its scheduled diagnostic.""" + value: str = run_with_timeout_diagnostics(lambda: "finished", 1.0) + print(value) + + +if __name__ == "__main__": + main() diff --git a/CH_11_debugging/exercise_01/test_solution_00.py b/CH_11_debugging/exercise_01/test_solution_00.py new file mode 100644 index 0000000..82af48b --- /dev/null +++ b/CH_11_debugging/exercise_01/test_solution_00.py @@ -0,0 +1,126 @@ +"""Tests for delayed stalled-call diagnostics.""" + +import faulthandler +import subprocess +import sys + +import pytest + +from CH_11_debugging.exercise_01.solution_00 import ( + run_with_timeout_diagnostics, +) + + +def test_returns_value_and_forwards_arguments( + monkeypatch: pytest.MonkeyPatch, +) -> None: + events: list[tuple[str, object]] = [] + + def schedule( + timeout: float, + repeat: bool = False, + file: object | None = None, + exit: bool = False, + ) -> None: + events.append(("scheduled", (timeout, repeat, file, exit))) + + def cancel() -> None: + events.append(("cancelled", None)) + + monkeypatch.setattr(faulthandler, "dump_traceback_later", schedule) + monkeypatch.setattr(faulthandler, "cancel_dump_traceback_later", cancel) + + def combine(prefix: str, *, number: int) -> str: + return f"{prefix}-{number}" + + result: str = run_with_timeout_diagnostics( + combine, + 0.5, + "item", + number=3, + ) + + assert result == "item-3" + assert events == [ + ("scheduled", (0.5, False, None, False)), + ("cancelled", None), + ] + + +def test_diagnostic_is_cancelled_when_function_raises( + monkeypatch: pytest.MonkeyPatch, +) -> None: + events: list[str] = [] + + def schedule( + timeout: float, + repeat: bool = False, + file: object | None = None, + exit: bool = False, + ) -> None: + del timeout, repeat, file, exit + events.append("scheduled") + + def cancel() -> None: + events.append("cancelled") + + monkeypatch.setattr(faulthandler, "dump_traceback_later", schedule) + monkeypatch.setattr(faulthandler, "cancel_dump_traceback_later", cancel) + + def fail() -> None: + raise LookupError("boom") + + with pytest.raises(LookupError, match="boom"): + run_with_timeout_diagnostics(fail, 0.1) + + assert events == ["scheduled", "cancelled"] + + +@pytest.mark.parametrize("timeout", [0.0, -0.1, float("nan")]) +def test_timeout_must_be_positive(timeout: float) -> None: + with pytest.raises(ValueError, match="timeout must be positive"): + run_with_timeout_diagnostics(lambda: None, timeout) + + +@pytest.mark.integration +def test_real_timeout_prints_stalled_function() -> None: + source: str = """ +import time +from CH_11_debugging.exercise_01.solution_00 import ( + run_with_timeout_diagnostics, +) + +def stalled() -> None: + time.sleep(0.10) + +run_with_timeout_diagnostics(stalled, 0.01) +""" + completed: subprocess.CompletedProcess[str] = subprocess.run( + [sys.executable, "-c", source], + text=True, + capture_output=True, + check=False, + timeout=2, + ) + + assert completed.returncode == 0 + assert "Timeout" in completed.stderr + assert "stalled" in completed.stderr + + +def test_module_import_is_silent() -> None: + completed: subprocess.CompletedProcess[str] = subprocess.run( + [ + sys.executable, + "-c", + "import CH_11_debugging.exercise_01.solution_00", + ], + check=False, + capture_output=True, + text=True, + timeout=2, + ) + + assert completed.returncode == 0 + assert completed.stdout == "" + assert completed.stderr == "" diff --git a/CH_11_debugging/exercise_02/README.rst b/CH_11_debugging/exercise_02/README.rst new file mode 100644 index 0000000..2e89760 --- /dev/null +++ b/CH_11_debugging/exercise_02/README.rst @@ -0,0 +1,53 @@ +Exercise 2: execution duration +============================== + +Question +-------- + +.. code-block:: text + + 2. Measure the duration of the execution + +Solution +-------- + +``CallTimer.measure`` runs one generic callable and returns a frozen +``TimedResult`` containing its value and elapsed seconds. Its default +``time.perf_counter`` clock is monotonic. Callers can inject another clock, +which makes tests deterministic without assumptions about machine speed. +``measure_call`` is the convenience API for the default clock. + +The start sample is taken before the callable. The end sample is taken only +after a successful return, so callable exceptions propagate without requiring +another clock value. A successful callable followed by an end sample earlier +than its start raises ``ValueError`` rather than reporting a negative duration. + +Dependencies +------------ + +The solution requires Python 3.10 or newer and uses only the standard library. +The repository's ``dev`` dependency group supplies pytest for the tests. + +Run +--- + +Run the guarded demonstration: + +.. code-block:: console + + $ uv run python -m CH_11_debugging.exercise_02.solution_00 + +Run the focused tests: + +.. code-block:: console + + $ uv run pytest CH_11_debugging/exercise_02/test_solution_00.py -v + +Historical source +----------------- + +Selective instrumentation is illustrated by the immutable upstream +`T_02_selective_trace.py +`_. +The solution and tests are self-contained; no source files are copied from +that reference. diff --git a/CH_11_debugging/exercise_02/__init__.py b/CH_11_debugging/exercise_02/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/CH_11_debugging/exercise_02/__init__.py @@ -0,0 +1 @@ + diff --git a/CH_11_debugging/exercise_02/solution_00.py b/CH_11_debugging/exercise_02/solution_00.py new file mode 100644 index 0000000..f07c2f5 --- /dev/null +++ b/CH_11_debugging/exercise_02/solution_00.py @@ -0,0 +1,60 @@ +"""Measure callable duration without coupling tests to wall-clock speed.""" + +import time +from collections.abc import Callable +from dataclasses import dataclass +from typing import Generic, ParamSpec, TypeVar + +Parameters = ParamSpec("Parameters") +Result = TypeVar("Result") + + +@dataclass(frozen=True) +class TimedResult(Generic[Result]): + """A callable's return value and elapsed monotonic seconds.""" + + value: Result + seconds: float + + +class CallTimer: + """Measure calls with an injectable monotonic clock.""" + + def __init__( + self, + clock: Callable[[], float] = time.perf_counter, + ) -> None: + self._clock: Callable[[], float] = clock + + def measure( + self, + function: Callable[Parameters, Result], + *args: Parameters.args, + **kwargs: Parameters.kwargs, + ) -> TimedResult[Result]: + """Run a callable once and return its value and duration.""" + started: float = self._clock() + value: Result = function(*args, **kwargs) + finished: float = self._clock() + seconds: float = finished - started + if seconds < 0: + raise ValueError("clock moved backwards") + return TimedResult(value=value, seconds=seconds) + + +def measure_call( + function: Callable[Parameters, Result], + *args: Parameters.args, + **kwargs: Parameters.kwargs, +) -> TimedResult[Result]: + """Measure a callable with ``time.perf_counter``.""" + return CallTimer().measure(function, *args, **kwargs) + + +def main() -> None: + """Demonstrate the default monotonic timer.""" + print(measure_call(sum, [1, 2, 3])) + + +if __name__ == "__main__": + main() diff --git a/CH_11_debugging/exercise_02/test_solution_00.py b/CH_11_debugging/exercise_02/test_solution_00.py new file mode 100644 index 0000000..183735a --- /dev/null +++ b/CH_11_debugging/exercise_02/test_solution_00.py @@ -0,0 +1,74 @@ +"""Tests for deterministic callable timing.""" + +from collections.abc import Callable, Iterator +from dataclasses import FrozenInstanceError + +import pytest + +from CH_11_debugging.exercise_02.solution_00 import ( + CallTimer, + TimedResult, + measure_call, +) + + +def clock_from(values: list[float]) -> Callable[[], float]: + iterator: Iterator[float] = iter(values) + return iterator.__next__ + + +def test_measure_returns_value_and_elapsed_seconds() -> None: + timer: CallTimer = CallTimer(clock_from([10.0, 10.25])) + + def increment(value: int) -> int: + return value + 1 + + result: TimedResult[int] = timer.measure(increment, 4) + + assert result == TimedResult(value=5, seconds=0.25) + + +def test_measure_forwards_keyword_arguments() -> None: + timer: CallTimer = CallTimer(clock_from([2.0, 2.0])) + + def concatenate(value: str, *, suffix: str) -> str: + return value + suffix + + result: TimedResult[str] = timer.measure( + concatenate, + "a", + suffix="b", + ) + + assert result == TimedResult(value="ab", seconds=0.0) + + +def test_measure_propagates_failure_without_sampling_end_time() -> None: + timer: CallTimer = CallTimer(clock_from([1.0])) + + def fail() -> None: + raise RuntimeError("failed") + + with pytest.raises(RuntimeError, match="failed"): + timer.measure(fail) + + +def test_measure_rejects_clock_moving_backwards() -> None: + timer: CallTimer = CallTimer(clock_from([5.0, 4.0])) + + with pytest.raises(ValueError, match="clock moved backwards"): + timer.measure(lambda: None) + + +def test_timed_result_is_frozen() -> None: + result: TimedResult[int] = TimedResult(value=1, seconds=0.5) + + with pytest.raises(FrozenInstanceError): + result.__setattr__("seconds", 1.0) + + +def test_measure_call_returns_a_timed_result() -> None: + result: TimedResult[int] = measure_call(sum, [1, 2, 3]) + + assert result.value == 6 + assert result.seconds >= 0.0 diff --git a/CH_11_debugging/exercise_03/README.rst b/CH_11_debugging/exercise_03/README.rst new file mode 100644 index 0000000..8b59de3 --- /dev/null +++ b/CH_11_debugging/exercise_03/README.rst @@ -0,0 +1,56 @@ +Exercise 3: line execution counts +================================= + +Question +-------- + +.. code-block:: text + + 3. Show how often that specific bit of code has been executed + +Solution +-------- + +``count_executions`` uses the standard-library ``trace`` module to count line +events while running a generic callable. ``ExecutionCounts`` returns both the +callable's result and a line-number-to-frequency mapping. The public +``SourceLocation`` alias describes the filename and line-number keys produced +by ``trace`` before filtering. + +Only events whose filename matches the callable's inspected source file appear +in the returned mapping. Lines executed in dependencies are excluded. A +callable without an inspectable source file is rejected before execution. +Callable exceptions propagate and no partial profile is returned. + +Dependencies +------------ + +The solution requires Python 3.10 or newer and uses only the standard library. +The repository's ``dev`` dependency group supplies pytest for the tests. + +Run +--- + +Run the guarded demonstration: + +.. code-block:: console + + $ uv run python -m CH_11_debugging.exercise_03.solution_00 + +Run the focused tests: + +.. code-block:: console + + $ uv run pytest CH_11_debugging/exercise_03/test_solution_00.py -v + +Historical source +----------------- + +The immutable upstream references are +`T_02_selective_trace.py +`_ +and +`T_03_filename_trace.py +`_. +The solution and tests are self-contained; no source files are copied from +those references. diff --git a/CH_11_debugging/exercise_03/__init__.py b/CH_11_debugging/exercise_03/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/CH_11_debugging/exercise_03/__init__.py @@ -0,0 +1 @@ + diff --git a/CH_11_debugging/exercise_03/solution_00.py b/CH_11_debugging/exercise_03/solution_00.py new file mode 100644 index 0000000..c77cfbb --- /dev/null +++ b/CH_11_debugging/exercise_03/solution_00.py @@ -0,0 +1,59 @@ +"""Count how often each source line in a callable's file executes.""" + +import inspect +import trace +from collections.abc import Callable +from dataclasses import dataclass +from typing import Generic, ParamSpec, TypeAlias, TypeVar + +Parameters = ParamSpec("Parameters") +Result = TypeVar("Result") +SourceLocation: TypeAlias = tuple[str, int] + + +@dataclass(frozen=True) +class ExecutionCounts(Generic[Result]): + """A return value and line-number-to-count mapping.""" + + result: Result + counts: dict[int, int] + + +def count_executions( + function: Callable[Parameters, Result], + *args: Parameters.args, + **kwargs: Parameters.kwargs, +) -> ExecutionCounts[Result]: + """Run a callable and count lines from its source file.""" + try: + filename: str | None = inspect.getsourcefile(function) + except TypeError: + filename = None + if filename is None: + raise ValueError("function has no inspectable source file") + + tracer: trace.Trace = trace.Trace(count=True, trace=False) + result: Result = tracer.runfunc(function, *args, **kwargs) + raw_counts: dict[SourceLocation, int] = tracer.results().counts + counts: dict[int, int] = { + line_number: count + for (source_file, line_number), count in raw_counts.items() + if source_file == filename + } + return ExecutionCounts(result=result, counts=counts) + + +def main() -> None: + """Demonstrate line counts for a small loop.""" + + def total(limit: int) -> int: + result: int = 0 + for number in range(limit): + result += number + return result + + print(count_executions(total, 4)) + + +if __name__ == "__main__": + main() diff --git a/CH_11_debugging/exercise_03/test_solution_00.py b/CH_11_debugging/exercise_03/test_solution_00.py new file mode 100644 index 0000000..7e1768b --- /dev/null +++ b/CH_11_debugging/exercise_03/test_solution_00.py @@ -0,0 +1,46 @@ +"""Tests for source-line execution counts.""" + +import inspect + +import pytest + +from CH_11_debugging.exercise_03.solution_00 import ( + ExecutionCounts, + count_executions, +) + + +def repeated(total: int) -> int: + value: int = 0 + for number in range(total): + value += number + return value + + +def test_count_executions_reports_loop_line_frequency() -> None: + source_lines: list[str] + first_line: int + source_lines, first_line = inspect.getsourcelines(repeated) + offset: int = next( + index for index, line in enumerate(source_lines) if "value += number" in line + ) + loop_line: int = first_line + offset + + profile: ExecutionCounts[int] = count_executions(repeated, 4) + + assert profile.result == 6 + assert profile.counts[loop_line] == 4 + assert all(count > 0 for count in profile.counts.values()) + + +def test_count_executions_propagates_failure() -> None: + def fail() -> None: + raise LookupError("boom") + + with pytest.raises(LookupError, match="boom"): + count_executions(fail) + + +def test_count_executions_rejects_callable_without_source_file() -> None: + with pytest.raises(ValueError, match="no inspectable source file"): + count_executions(len, []) diff --git a/CH_12_performance/README.rst b/CH_12_performance/README.rst index c65f7b3..9b27c5b 100644 --- a/CH_12_performance/README.rst +++ b/CH_12_performance/README.rst @@ -1,6 +1,9 @@ Chapter 12 - performance -======================================================================================================================= +======================== -1. Try to create a decorator that monitors each run of a function and warns you if the memory usage grows each run. -2. Try to create a decorator that monitors the runtime of a function and warns you if it deviates too much from the previous run. Optionally, you could make the function generate a (running) average runtime as well. -3. Try to create a memory manager for your classes that warns you when more than a configured number of instances remain in memory. If you never expect more than 5 instances of a certain class, you can warn the user when that number is exceeded. +Exercises +--------- + +1. `Memory-growth monitor `_ +2. `Runtime-deviation monitor `_ +3. `Live-instance manager `_ diff --git a/CH_12_performance/exercise_01/README.rst b/CH_12_performance/exercise_01/README.rst new file mode 100644 index 0000000..5450e4e --- /dev/null +++ b/CH_12_performance/exercise_01/README.rst @@ -0,0 +1,66 @@ +Exercise 1: memory-growth monitor +================================= + +Question +-------- + +.. code-block:: text + + 1. Try to create a decorator that monitors each run of a function and warns you if the memory usage grows each run. + +Solution +-------- + +``MemoryGrowthMonitor`` is a callable wrapper, and +``monitor_memory_growth`` constructs it as a decorator. The wrapped callable +runs before the injected sampler. Only a successful call consumes a sample, +and only growth above the nonnegative byte tolerance emits +``ResourceWarning``. Decreases, unchanged samples, and growth equal to the +tolerance do not warn. If sampling fails, the prior successful sample remains +the comparison baseline. + +The wrapper is descriptor-aware, so it also decorates instance methods while +preserving their metadata, ``__wrapped__`` reference, and inspectable +signature. The class attribute owns one monitor: calls through every instance +share its previous-sample baseline. Access through the class returns that +monitor object. + +The default sampler starts ``tracemalloc`` when needed and reads its current +traced allocation count. That sample describes traced Python allocations, not +whole-process resident memory. Tests inject integer samples and make no +assumptions about real memory usage. + +Dependencies +------------ + +The solution requires Python 3.10 or newer and uses only the standard library. +The repository's ``dev`` dependency group supplies pytest for the tests. + +Run +--- + +Run the guarded demonstration: + +.. code-block:: console + + $ uv run python -m CH_12_performance.exercise_01.solution_00 + +Run the focused tests: + +.. code-block:: console + + $ uv run pytest CH_12_performance/exercise_01/test_solution_00.py -v + +Historical source +----------------- + +The immutable upstream references are +`T_11_tracemalloc.py +`_, +`T_12_memory_profiler.py +`_, +and +`T_13_memory_leaks.py +`_. +The solution and tests are self-contained; no source files are copied from +those references. diff --git a/CH_12_performance/exercise_01/__init__.py b/CH_12_performance/exercise_01/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/CH_12_performance/exercise_01/__init__.py @@ -0,0 +1 @@ + diff --git a/CH_12_performance/exercise_01/solution_00.py b/CH_12_performance/exercise_01/solution_00.py new file mode 100644 index 0000000..c589710 --- /dev/null +++ b/CH_12_performance/exercise_01/solution_00.py @@ -0,0 +1,121 @@ +"""Warn when a successful call's sampled memory grows.""" + +import tracemalloc +import warnings +from collections.abc import Callable +from functools import update_wrapper +from types import MethodType +from typing import Generic, ParamSpec, TypeVar, overload + +Parameters = ParamSpec("Parameters") +Result = TypeVar("Result") + + +def _traced_bytes() -> int: + """Return currently traced bytes, starting tracemalloc when necessary.""" + if not tracemalloc.is_tracing(): + tracemalloc.start() + current: int + current, _ = tracemalloc.get_traced_memory() + return current + + +class MemoryGrowthMonitor(Generic[Parameters, Result]): + """Callable wrapper retaining the previous successful sample.""" + + __name__: str + __wrapped__: Callable[Parameters, Result] + + def __init__( + self, + function: Callable[Parameters, Result], + sampler: Callable[[], int] = _traced_bytes, + tolerance_bytes: int = 0, + ) -> None: + if tolerance_bytes < 0: + raise ValueError("tolerance_bytes must not be negative") + self._function: Callable[Parameters, Result] = function + self._sampler: Callable[[], int] = sampler + self._tolerance_bytes: int = tolerance_bytes + self._previous: int | None = None + update_wrapper(self, function, updated=()) + + @overload + def __get__( + self, + instance: None, + owner: type[object] | None = None, + ) -> "MemoryGrowthMonitor[Parameters, Result]": ... + + @overload + def __get__( + self, + instance: object, + owner: type[object] | None = None, + ) -> Callable[..., Result]: ... + + def __get__( + self, + instance: object | None, + owner: type[object] | None = None, + ) -> "MemoryGrowthMonitor[Parameters, Result] | Callable[..., Result]": + """Bind the shared monitor when accessed through an instance.""" + if instance is None: + return self + return MethodType(self, instance) + + def __call__( + self, + *args: Parameters.args, + **kwargs: Parameters.kwargs, + ) -> Result: + """Run the callable, sample memory, and warn about excess growth.""" + value: Result = self._function(*args, **kwargs) + current: int = self._sampler() + previous: int | None = self._previous + self._previous = current + if previous is not None: + growth: int = current - previous + if growth > self._tolerance_bytes: + warnings.warn( + f"sampled memory grew by {growth} bytes", + ResourceWarning, + stacklevel=2, + ) + return value + + +def monitor_memory_growth( + *, + sampler: Callable[[], int] = _traced_bytes, + tolerance_bytes: int = 0, +) -> Callable[ + [Callable[Parameters, Result]], + MemoryGrowthMonitor[Parameters, Result], +]: + """Create a monitor decorator with an injectable sampler.""" + if tolerance_bytes < 0: + raise ValueError("tolerance_bytes must not be negative") + + def decorate( + function: Callable[Parameters, Result], + ) -> MemoryGrowthMonitor[Parameters, Result]: + return MemoryGrowthMonitor(function, sampler, tolerance_bytes) + + return decorate + + +def main() -> None: + """Demonstrate deterministic sampled growth.""" + samples: list[int] = [100, 120] + + @monitor_memory_growth(sampler=lambda: samples.pop(0)) + def operation() -> str: + return "finished" + + print(operation()) + print(operation()) + + +if __name__ == "__main__": + main() diff --git a/CH_12_performance/exercise_01/test_solution_00.py b/CH_12_performance/exercise_01/test_solution_00.py new file mode 100644 index 0000000..9110dc4 --- /dev/null +++ b/CH_12_performance/exercise_01/test_solution_00.py @@ -0,0 +1,192 @@ +"""Tests for sampled memory-growth monitoring.""" + +import inspect +import warnings +from collections.abc import Callable, Iterator + +import pytest + +from CH_12_performance.exercise_01.solution_00 import ( + MemoryGrowthMonitor, + monitor_memory_growth, +) + + +def sampler(values: list[int]) -> Callable[[], int]: + iterator: Iterator[int] = iter(values) + return iterator.__next__ + + +def test_preserves_free_function_metadata_and_signature() -> None: + def original(value: int, *, scale: int = 1) -> int: + """Return a scaled value.""" + return value * scale + + decorated: MemoryGrowthMonitor[..., int] = monitor_memory_growth( + sampler=lambda: 100, + )(original) + + assert decorated.__name__ == original.__name__ + assert decorated.__doc__ == original.__doc__ + assert decorated.__wrapped__ is original + assert inspect.signature(decorated) == inspect.signature(original) + + +def test_wrapped_function_attributes_cannot_replace_monitor_state() -> None: + def original() -> int: + return 7 + + collisions: dict[str, object] = { + "_function": lambda: -1, + "_sampler": lambda: 999, + "_previous": 100, + "_tolerance_bytes": 1_000, + } + for name, value in collisions.items(): + setattr(original, name, value) + + intended_sampler: Callable[[], int] = sampler([100, 120]) + decorated: MemoryGrowthMonitor[[], int] = monitor_memory_growth( + sampler=intended_sampler, + tolerance_bytes=5, + )(original) + + monitor_state: dict[str, object] = vars(decorated) + assert ( + monitor_state["_function"], + monitor_state["_sampler"], + monitor_state["_previous"], + monitor_state["_tolerance_bytes"], + ) == (original, intended_sampler, None, 5) + with warnings.catch_warnings(): + warnings.simplefilter("error", ResourceWarning) + assert decorated() == 7 + with pytest.warns(ResourceWarning, match="grew by 20 bytes"): + assert decorated() == 7 + + +def test_decorated_method_binds_receiver_and_shares_samples() -> None: + class Accumulator: + def __init__(self, offset: int) -> None: + self.offset: int = offset + + @monitor_memory_growth( + sampler=sampler([100, 120]), + tolerance_bytes=5, + ) + def add(self, value: int, *, scale: int = 1) -> int: + return self.offset + value * scale + + first: Accumulator = Accumulator(1) + second: Accumulator = Accumulator(10) + + with warnings.catch_warnings(): + warnings.simplefilter("error", ResourceWarning) + assert first.add(2, scale=3) == 7 + with pytest.warns(ResourceWarning, match="grew by 20 bytes"): + assert second.add(2, scale=3) == 16 + + +def test_first_below_boundary_and_decrease_emit_no_warning() -> None: + monitor: MemoryGrowthMonitor[[], None] = MemoryGrowthMonitor( + lambda: None, + sampler=sampler([100, 104, 109, 90]), + tolerance_bytes=5, + ) + + with warnings.catch_warnings(): + warnings.simplefilter("error", ResourceWarning) + monitor() + monitor() + monitor() + monitor() + + +def test_warns_only_after_growth_above_tolerance() -> None: + @monitor_memory_growth( + sampler=sampler([100, 104, 120]), + tolerance_bytes=5, + ) + def value() -> int: + return 7 + + assert value() == 7 + assert value() == 7 + with pytest.warns(ResourceWarning, match="grew by 16 bytes"): + assert value() == 7 + + +def test_decrease_does_not_warn() -> None: + monitor: MemoryGrowthMonitor[[], int] = MemoryGrowthMonitor( + lambda: 1, + sampler=sampler([100, 50]), + tolerance_bytes=0, + ) + + assert monitor() == 1 + assert monitor() == 1 + + +def test_failed_call_does_not_consume_a_sample() -> None: + sample_count: int = 0 + + def sample() -> int: + nonlocal sample_count + sample_count += 1 + return 10 + + def fail() -> None: + raise RuntimeError("boom") + + monitor: MemoryGrowthMonitor[[], None] = MemoryGrowthMonitor( + fail, + sampler=sample, + ) + + with pytest.raises(RuntimeError, match="boom"): + monitor() + + assert sample_count == 0 + + +def test_sampler_failure_preserves_previous_successful_sample() -> None: + calls: int = 0 + + def sample() -> int: + nonlocal calls + calls += 1 + if calls == 1: + return 100 + if calls == 2: + raise RuntimeError("sampler failed") + return 120 + + monitor: MemoryGrowthMonitor[[], None] = MemoryGrowthMonitor( + lambda: None, + sampler=sample, + ) + + monitor() + with pytest.raises(RuntimeError, match="sampler failed"): + monitor() + with pytest.warns(ResourceWarning, match="grew by 20 bytes"): + monitor() + + +@pytest.mark.parametrize("tolerance", [-1, -10]) +def test_negative_tolerance_is_rejected(tolerance: int) -> None: + with pytest.raises( + ValueError, + match="tolerance_bytes must not be negative", + ): + monitor_memory_growth(tolerance_bytes=tolerance) + + with pytest.raises( + ValueError, + match="tolerance_bytes must not be negative", + ): + MemoryGrowthMonitor( + lambda: None, + sampler=lambda: 0, + tolerance_bytes=tolerance, + ) diff --git a/CH_12_performance/exercise_02/README.rst b/CH_12_performance/exercise_02/README.rst new file mode 100644 index 0000000..b34564a --- /dev/null +++ b/CH_12_performance/exercise_02/README.rst @@ -0,0 +1,63 @@ +Exercise 2: runtime-deviation monitor +===================================== + +Question +-------- + +.. code-block:: text + + 2. Try to create a decorator that monitors the runtime of a function and warns you if it deviates too much from the previous run. Optionally, you could make the function generate a (running) average runtime as well. + +Solution +-------- + +``RuntimeMonitor`` is a callable wrapper, and ``monitor_runtime`` constructs +it as a decorator. Each successful duration is compared with the running +average from earlier successful calls. A relative difference above the +nonnegative ``max_deviation`` emits ``RuntimeWarning``. No comparison is made +until a positive prior average exists. + +The wrapper is descriptor-aware, so it also decorates instance methods while +preserving their metadata, ``__wrapped__`` reference, and inspectable +signature. The class attribute owns one monitor, and calls through every +instance contribute to that monitor's shared statistics. Both class access and +a bound instance method expose the same current ``stats`` value. + +The frozen ``RuntimeStats`` value exposes successful call count, total +seconds, and the derived average. Failed callables propagate without taking an +end-clock sample or changing statistics. A clock that moves backwards raises +``ValueError`` and also leaves statistics unchanged. Tests inject clock values +and make no assumptions about machine speed. + +Dependencies +------------ + +The solution requires Python 3.10 or newer and uses only the standard library. +The repository's ``dev`` dependency group supplies pytest for the tests. + +Run +--- + +Run the guarded demonstration: + +.. code-block:: console + + $ uv run python -m CH_12_performance.exercise_02.solution_00 + +Run the focused tests: + +.. code-block:: console + + $ uv run pytest CH_12_performance/exercise_02/test_solution_00.py -v + +Historical source +----------------- + +The immutable upstream references are +`T_00_timeit.py +`_ +and +`T_06_selective_profiling.py +`_. +The solution and tests are self-contained; no source files are copied from +those references. diff --git a/CH_12_performance/exercise_02/__init__.py b/CH_12_performance/exercise_02/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/CH_12_performance/exercise_02/__init__.py @@ -0,0 +1 @@ + diff --git a/CH_12_performance/exercise_02/solution_00.py b/CH_12_performance/exercise_02/solution_00.py new file mode 100644 index 0000000..b84edf0 --- /dev/null +++ b/CH_12_performance/exercise_02/solution_00.py @@ -0,0 +1,153 @@ +"""Warn when call duration differs substantially from prior runs.""" + +import time +import warnings +from collections.abc import Callable +from dataclasses import dataclass +from functools import update_wrapper +from types import MethodType +from typing import Generic, ParamSpec, Protocol, TypeVar, cast, overload + +Parameters = ParamSpec("Parameters") +Result = TypeVar("Result") +BoundResult = TypeVar("BoundResult", covariant=True) + + +@dataclass(frozen=True) +class RuntimeStats: + """Successful-call runtime statistics.""" + + calls: int + total_seconds: float + + @property + def average_seconds(self) -> float: + """Return mean duration, or zero before the first successful call.""" + return self.total_seconds / self.calls if self.calls else 0.0 + + +class _BoundRuntimeMonitor(Protocol[BoundResult]): + """Callable bound monitor exposing its shared statistics.""" + + @property + def stats(self) -> RuntimeStats: + """Return statistics owned by the class-level monitor.""" + ... + + def __call__(self, *args: object, **kwargs: object) -> BoundResult: + """Invoke the monitor with its bound instance.""" + ... + + +class RuntimeMonitor(Generic[Parameters, Result]): + """Callable wrapper comparing each duration with the prior average.""" + + __name__: str + __wrapped__: Callable[Parameters, Result] + + def __init__( + self, + function: Callable[Parameters, Result], + clock: Callable[[], float] = time.perf_counter, + max_deviation: float = 0.25, + ) -> None: + if not max_deviation >= 0: + raise ValueError("max_deviation must not be negative") + self._function: Callable[Parameters, Result] = function + self._clock: Callable[[], float] = clock + self._max_deviation: float = max_deviation + self.stats: RuntimeStats = RuntimeStats(0, 0.0) + update_wrapper(self, function, updated=()) + + @overload + def __get__( + self, + instance: None, + owner: type[object] | None = None, + ) -> "RuntimeMonitor[Parameters, Result]": ... + + @overload + def __get__( + self, + instance: object, + owner: type[object] | None = None, + ) -> _BoundRuntimeMonitor[Result]: ... + + def __get__( + self, + instance: object | None, + owner: type[object] | None = None, + ) -> "RuntimeMonitor[Parameters, Result] | _BoundRuntimeMonitor[Result]": + """Bind the shared monitor when accessed through an instance.""" + if instance is None: + return self + return cast(_BoundRuntimeMonitor[Result], MethodType(self, instance)) + + def __call__( + self, + *args: Parameters.args, + **kwargs: Parameters.kwargs, + ) -> Result: + """Run the callable, update statistics, and warn on deviation.""" + started: float = self._clock() + value: Result = self._function(*args, **kwargs) + finished: float = self._clock() + duration: float = finished - started + if duration < 0: + raise ValueError("clock moved backwards") + + previous_stats: RuntimeStats = self.stats + previous_average: float = previous_stats.average_seconds + self.stats = RuntimeStats( + previous_stats.calls + 1, + previous_stats.total_seconds + duration, + ) + if ( + previous_stats.calls > 0 + and previous_average > 0 + and abs(duration - previous_average) + > previous_average * self._max_deviation + ): + warnings.warn( + "runtime deviated from the previous average", + RuntimeWarning, + stacklevel=2, + ) + return value + + +def monitor_runtime( + *, + clock: Callable[[], float] = time.perf_counter, + max_deviation: float = 0.25, +) -> Callable[ + [Callable[Parameters, Result]], + RuntimeMonitor[Parameters, Result], +]: + """Create a runtime monitor decorator.""" + if not max_deviation >= 0: + raise ValueError("max_deviation must not be negative") + + def decorate( + function: Callable[Parameters, Result], + ) -> RuntimeMonitor[Parameters, Result]: + return RuntimeMonitor(function, clock, max_deviation) + + return decorate + + +def main() -> None: + """Demonstrate deterministic runtime statistics.""" + samples: list[float] = [0.0, 1.0, 2.0, 3.0] + + @monitor_runtime(clock=lambda: samples.pop(0)) + def operation() -> str: + return "finished" + + print(operation()) + print(operation()) + print(operation.stats) + + +if __name__ == "__main__": + main() diff --git a/CH_12_performance/exercise_02/test_solution_00.py b/CH_12_performance/exercise_02/test_solution_00.py new file mode 100644 index 0000000..d7ffc26 --- /dev/null +++ b/CH_12_performance/exercise_02/test_solution_00.py @@ -0,0 +1,206 @@ +"""Tests for runtime-deviation monitoring.""" + +import inspect +import warnings +from collections.abc import Callable, Iterator +from dataclasses import FrozenInstanceError + +import pytest + +from CH_12_performance.exercise_02.solution_00 import ( + RuntimeMonitor, + RuntimeStats, + monitor_runtime, +) + + +def clock(values: list[float]) -> Callable[[], float]: + iterator: Iterator[float] = iter(values) + return iterator.__next__ + + +def test_preserves_free_function_metadata_and_signature() -> None: + def original(value: int, *, scale: int = 1) -> int: + """Return a scaled value.""" + return value * scale + + decorated: RuntimeMonitor[..., int] = monitor_runtime( + clock=clock([0.0, 1.0]), + )(original) + + assert decorated.__name__ == original.__name__ + assert decorated.__doc__ == original.__doc__ + assert decorated.__wrapped__ is original + assert inspect.signature(decorated) == inspect.signature(original) + + +def test_wrapped_function_attributes_cannot_replace_monitor_state() -> None: + def original() -> int: + return 7 + + collisions: dict[str, object] = { + "_function": lambda: -1, + "_clock": lambda: 999.0, + "_max_deviation": 1_000.0, + "stats": RuntimeStats(calls=40, total_seconds=40.0), + } + for name, value in collisions.items(): + setattr(original, name, value) + + intended_clock: Callable[[], float] = clock([0.0, 1.0, 2.0, 5.0]) + decorated: RuntimeMonitor[[], int] = monitor_runtime( + clock=intended_clock, + max_deviation=0.25, + )(original) + + monitor_state: dict[str, object] = vars(decorated) + assert ( + monitor_state["_function"], + monitor_state["_clock"], + monitor_state["_max_deviation"], + monitor_state["stats"], + ) == (original, intended_clock, 0.25, RuntimeStats(0, 0.0)) + with warnings.catch_warnings(): + warnings.simplefilter("error", RuntimeWarning) + assert decorated() == 7 + with pytest.warns(RuntimeWarning, match="deviated"): + assert decorated() == 7 + + assert decorated.stats == RuntimeStats(calls=2, total_seconds=4.0) + + +def test_decorated_method_binds_receiver_and_shares_stats() -> None: + class Accumulator: + def __init__(self, offset: int) -> None: + self.offset: int = offset + + @monitor_runtime(clock=clock([0.0, 1.0, 2.0, 3.0])) + def add(self, value: int, *, scale: int = 1) -> int: + return self.offset + value * scale + + first: Accumulator = Accumulator(1) + second: Accumulator = Accumulator(10) + + with warnings.catch_warnings(): + warnings.simplefilter("error", RuntimeWarning) + assert first.add(2, scale=3) == 7 + assert second.add(2, scale=3) == 16 + + expected: RuntimeStats = RuntimeStats(calls=2, total_seconds=2.0) + assert Accumulator.add.stats == expected + assert first.add.stats == expected + assert second.add.stats == expected + + +def test_first_below_and_boundary_durations_emit_no_warning() -> None: + monitor: RuntimeMonitor[[], None] = RuntimeMonitor( + lambda: None, + clock=clock([0.0, 2.0, 3.0, 5.5, 6.0, 9.375]), + max_deviation=0.5, + ) + + with warnings.catch_warnings(): + warnings.simplefilter("error", RuntimeWarning) + monitor() + monitor() + monitor() + + +def test_third_duration_is_compared_with_prior_running_average() -> None: + monitor: RuntimeMonitor[[], None] = RuntimeMonitor( + lambda: None, + clock=clock([0.0, 1.0, 2.0, 5.0, 6.0, 9.0]), + max_deviation=0.25, + ) + + with warnings.catch_warnings(): + warnings.simplefilter("error", RuntimeWarning) + monitor() + with pytest.warns(RuntimeWarning, match="deviated"): + monitor() + with pytest.warns(RuntimeWarning, match="deviated"): + monitor() + + assert monitor.stats == RuntimeStats(calls=3, total_seconds=7.0) + + +def test_warns_against_previous_running_average() -> None: + @monitor_runtime(clock=clock([0.0, 1.0, 2.0, 4.0]), max_deviation=0.25) + def operation() -> str: + return "ok" + + assert operation() == "ok" + with pytest.warns(RuntimeWarning, match="deviated"): + assert operation() == "ok" + + assert operation.stats == RuntimeStats(calls=2, total_seconds=3.0) + assert operation.stats.average_seconds == pytest.approx(1.5) + + +def test_duration_at_deviation_boundary_does_not_warn() -> None: + monitor: RuntimeMonitor[[], None] = RuntimeMonitor( + lambda: None, + clock=clock([0.0, 2.0, 3.0, 6.0]), + max_deviation=0.5, + ) + + monitor() + monitor() + + assert monitor.stats == RuntimeStats(calls=2, total_seconds=5.0) + + +def test_failed_call_does_not_update_stats_or_sample_end_time() -> None: + def fail() -> None: + raise RuntimeError("boom") + + monitor: RuntimeMonitor[[], None] = RuntimeMonitor( + fail, + clock=clock([1.0]), + max_deviation=0.25, + ) + + with pytest.raises(RuntimeError, match="boom"): + monitor() + + assert monitor.stats == RuntimeStats(calls=0, total_seconds=0.0) + + +def test_clock_moving_backwards_does_not_update_stats() -> None: + monitor: RuntimeMonitor[[], None] = RuntimeMonitor( + lambda: None, + clock=clock([2.0, 1.0]), + max_deviation=0.25, + ) + + with pytest.raises(ValueError, match="clock moved backwards"): + monitor() + + assert monitor.stats.calls == 0 + + +@pytest.mark.parametrize("deviation", [-0.1, float("nan")]) +def test_invalid_deviation_is_rejected(deviation: float) -> None: + with pytest.raises( + ValueError, + match="max_deviation must not be negative", + ): + monitor_runtime(max_deviation=deviation) + + with pytest.raises( + ValueError, + match="max_deviation must not be negative", + ): + RuntimeMonitor( + lambda: None, + clock=lambda: 0.0, + max_deviation=deviation, + ) + + +def test_runtime_stats_is_frozen_and_empty_average_is_zero() -> None: + stats: RuntimeStats = RuntimeStats(calls=0, total_seconds=0.0) + + assert stats.average_seconds == 0.0 + with pytest.raises(FrozenInstanceError): + stats.__setattr__("calls", 1) diff --git a/CH_12_performance/exercise_03/README.rst b/CH_12_performance/exercise_03/README.rst new file mode 100644 index 0000000..d9a4727 --- /dev/null +++ b/CH_12_performance/exercise_03/README.rst @@ -0,0 +1,60 @@ +Exercise 3: live-instance manager +================================= + +Question +-------- + +.. code-block:: text + + 3. Try to create a memory manager for your classes that warns you when more than a configured number of instances remain in memory. If you never expect more than 5 instances of a certain class, you can warn the user when that number is exceeded. + +Solution +-------- + +``InstanceManager`` creates instances of one configured class and tracks those +instances with weak references. ``live_count`` reports how many remain alive, +and ``instances`` returns the live objects in deterministic creation order. +Collection removes an object from both views without the manager extending its +lifetime. Separate managers, including managers for a base class and a +subclass, keep isolated state. + +Only objects made through ``create`` are tracked. The limit must be positive. +Creating an object above it emits ``ResourceWarning``. An object type that +does not support weak references is rejected after construction without +leaving a tracked entry. The callback holds only a weak reference to its +manager, so keeping a created object alive does not keep the manager alive. + +Dependencies +------------ + +The solution requires Python 3.10 or newer and uses only the standard library. +The repository's ``dev`` dependency group supplies pytest for the tests. + +Run +--- + +Run the guarded demonstration: + +.. code-block:: console + + $ uv run python -m CH_12_performance.exercise_03.solution_00 + +Run the focused tests: + +.. code-block:: console + + $ uv run pytest CH_12_performance/exercise_03/test_solution_00.py -v + +Historical source +----------------- + +The immutable upstream references are +`T_13_memory_leaks.py +`_, +`T_15_garbage_collector_viewing.py +`_, +and +`T_16_weak_references.py +`_. +The solution and tests are self-contained; no source files are copied from +those references. diff --git a/CH_12_performance/exercise_03/__init__.py b/CH_12_performance/exercise_03/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/CH_12_performance/exercise_03/__init__.py @@ -0,0 +1 @@ + diff --git a/CH_12_performance/exercise_03/solution_00.py b/CH_12_performance/exercise_03/solution_00.py new file mode 100644 index 0000000..f233907 --- /dev/null +++ b/CH_12_performance/exercise_03/solution_00.py @@ -0,0 +1,105 @@ +"""Track live instances created for one class without retaining them.""" + +import warnings +import weakref +from typing import Generic, TypeVar + +Instance = TypeVar("Instance") + + +class InstanceManager(Generic[Instance]): + """Construct and weakly track instances of one class.""" + + def __init__( + self, + instance_type: type[Instance], + *, + max_instances: int, + ) -> None: + if max_instances <= 0: + raise ValueError("max_instances must be positive") + self._instance_type: type[Instance] = instance_type + self._max_instances: int = max_instances + self._next_sequence: int = 0 + self._references: dict[ + int, + weakref.ReferenceType[Instance], + ] = {} + + @property + def instances(self) -> tuple[Instance, ...]: + """Return live instances in deterministic creation order.""" + live: list[Instance] = [] + dead_sequences: list[int] = [] + reference_items: list[tuple[int, weakref.ReferenceType[Instance]]] = list( + self._references.items() + ) + for sequence, reference in reference_items: + instance: Instance | None = reference() + if instance is None: + dead_sequences.append(sequence) + else: + live.append(instance) + for sequence in dead_sequences: + self._references.pop(sequence, None) + return tuple(live) + + @property + def live_count(self) -> int: + """Return the number of tracked instances still alive.""" + return len(self.instances) + + def create(self, *args: object, **kwargs: object) -> Instance: + """Construct, track, and return an instance.""" + instance: Instance = self._instance_type(*args, **kwargs) + sequence: int = self._next_sequence + manager_reference: weakref.ReferenceType[InstanceManager[Instance]] + manager_reference = weakref.ref(self) + + def discard( + reference: weakref.ReferenceType[Instance], + ) -> None: + del reference + manager: InstanceManager[Instance] | None = manager_reference() + if manager is not None: + manager._references.pop(sequence, None) + + try: + reference: weakref.ReferenceType[Instance] = weakref.ref( + instance, + discard, + ) + except TypeError: + raise TypeError( + "instances must support weak references", + ) from None + + self._references[sequence] = reference + self._next_sequence += 1 + count: int = self.live_count + if count > self._max_instances: + warnings.warn( + f"{count} live {self._instance_type.__name__} instances", + ResourceWarning, + stacklevel=2, + ) + return instance + + +def main() -> None: + """Demonstrate weak live-instance tracking.""" + + class Resource: + """A weak-referenceable demonstration resource.""" + + manager: InstanceManager[Resource] = InstanceManager( + Resource, + max_instances=2, + ) + first: Resource = manager.create() + second: Resource = manager.create() + print(manager.instances == (first, second)) + + +if __name__ == "__main__": + main() diff --git a/CH_12_performance/exercise_03/test_solution_00.py b/CH_12_performance/exercise_03/test_solution_00.py new file mode 100644 index 0000000..e3c7c89 --- /dev/null +++ b/CH_12_performance/exercise_03/test_solution_00.py @@ -0,0 +1,153 @@ +"""Tests for weak live-instance tracking.""" + +import gc +import warnings +import weakref + +import pytest + +from CH_12_performance.exercise_03.solution_00 import InstanceManager + + +class Resource: + def __init__(self, name: str) -> None: + self.name: str = name + + +def test_first_instance_at_limit_emits_no_warning() -> None: + manager: InstanceManager[Resource] = InstanceManager( + Resource, + max_instances=1, + ) + + with warnings.catch_warnings(): + warnings.simplefilter("error", ResourceWarning) + instance: Resource = manager.create("at-limit") + + assert manager.instances == (instance,) + assert manager.live_count == 1 + + +def test_warns_above_limit_and_releases_collected_instances() -> None: + manager: InstanceManager[Resource] = InstanceManager( + Resource, + max_instances=1, + ) + first: Resource = manager.create("first") + with pytest.warns(ResourceWarning, match="2 live Resource instances"): + second: Resource = manager.create("second") + + assert manager.live_count == 2 + assert isinstance(manager.instances, tuple) + assert list(manager.instances) == [first, second] + first_reference: weakref.ReferenceType[Resource] = weakref.ref(first) + del first + gc.collect() + + assert first_reference() is None + assert manager.live_count == 1 + assert list(manager.instances) == [second] + + +def test_instances_remain_in_creation_order_after_collection() -> None: + manager: InstanceManager[Resource] = InstanceManager( + Resource, + max_instances=3, + ) + first: Resource = manager.create("first") + second: Resource = manager.create("second") + third: Resource = manager.create("third") + + del second + gc.collect() + + assert manager.instances == (first, third) + assert manager.live_count == 2 + + +def test_equal_unhashable_instances_can_be_tracked() -> None: + class EqualResource: + def __eq__(self, other: object) -> bool: + return isinstance(other, EqualResource) + + manager: InstanceManager[EqualResource] = InstanceManager( + EqualResource, + max_instances=2, + ) + first: EqualResource = manager.create() + second: EqualResource = manager.create() + + assert manager.instances == (first, second) + + +def test_managers_and_inherited_types_have_isolated_counts() -> None: + class Child(Resource): + """Resource subtype with a separate manager.""" + + resources: InstanceManager[Resource] = InstanceManager( + Resource, + max_instances=2, + ) + children: InstanceManager[Child] = InstanceManager( + Child, + max_instances=2, + ) + resource: Resource = resources.create("base") + child: Child = children.create("child") + + assert resources.instances == (resource,) + assert children.instances == (child,) + assert resources.live_count == 1 + assert children.live_count == 1 + + +def test_manager_does_not_track_instances_created_elsewhere() -> None: + manager: InstanceManager[Resource] = InstanceManager( + Resource, + max_instances=1, + ) + outside: Resource = Resource("outside") + + assert manager.live_count == 0 + assert manager.instances == () + assert outside.name == "outside" + + +def test_invalid_limit_is_rejected() -> None: + with pytest.raises(ValueError, match="max_instances must be positive"): + InstanceManager(Resource, max_instances=0) + + +def test_non_weak_referenceable_instance_is_rejected_without_tracking() -> None: + class Slotted: + __slots__: tuple[str, ...] = () + + manager: InstanceManager[Slotted] = InstanceManager( + Slotted, + max_instances=1, + ) + + with pytest.raises( + TypeError, + match="instances must support weak references", + ): + manager.create() + + assert manager.live_count == 0 + assert manager.instances == () + + +def test_manager_can_be_collected_while_an_instance_remains_alive() -> None: + manager: InstanceManager[Resource] = InstanceManager( + Resource, + max_instances=1, + ) + instance: Resource = manager.create("kept") + manager_reference: weakref.ReferenceType[InstanceManager[Resource]] + manager_reference = weakref.ref(manager) + + del manager + gc.collect() + + assert manager_reference() is None + assert instance.name == "kept" diff --git a/CH_13_async_io/README.rst b/CH_13_async_io/README.rst index 54f6d0b..3f4cf6a 100644 --- a/CH_13_async_io/README.rst +++ b/CH_13_async_io/README.rst @@ -1,6 +1,5 @@ -Chapter 13 - async io -======================================================================================================================= - -1. Try to create a `asyncio` base class that automatically registers all instances for easy closing/destructuring when you are done -2. Create an `asyncio` wrapper class for a synchronous process such as file or network operations using executors +Chapter 13 - async I/O +====================== +1. `Registered resources `_ +2. `Executor-backed synchronous operations `_ diff --git a/CH_13_async_io/exercise_01/README.rst b/CH_13_async_io/exercise_01/README.rst new file mode 100644 index 0000000..03cc775 --- /dev/null +++ b/CH_13_async_io/exercise_01/README.rst @@ -0,0 +1,70 @@ +Exercise 1: registered async resources +====================================== + +Question +-------- + +The exact exercise-list question is: + +.. code-block:: text + + Try to create a `asyncio` base class that automatically registers all instances for easy closing/destructuring when you are done + +Answer +------ + +``AsyncBase`` registers each concrete instance when its constructor calls +``super().__init__()``. Subclasses implement only the abstract asynchronous +``_close`` hook. ``close`` is idempotent after successful cleanup. A failed +cleanup leaves the instance registered and eligible for another attempt. +Neither ``AsyncBase`` nor ``AsyncManager`` starts an event loop or performs +asynchronous work from a destructor. + +``AsyncBase.pending_count()`` reports the shared registry size. +``AsyncBase.close_all()`` takes a stable snapshot and attempts its resources +in last-in, first-out order. Successful resources are unregistered. Ordinary +``Exception`` instances are collected in attempt order and raised together as +the ``RuntimeError`` subclass ``CloseFailures``. Its ``failures`` attribute is +the immutable exception tuple. ``BaseException`` subclasses, including +``asyncio.CancelledError``, propagate immediately instead of being converted +to cleanup failures. + +``AsyncManager`` is a no-argument asynchronous context manager over that +registry. It always attempts cleanup when the body exits. With a successful +body, cleanup failures propagate normally. When both the body and cleanup +fail, ``close_all`` propagation is unchanged: ``CloseFailures`` is raised and +the body exception remains available through normal exception context. A +cleanup ``BaseException`` remains control flow and propagates immediately. + +Dependencies +------------ + +The solution requires Python 3.10 or newer and uses only the standard +library. The repository's ``dev`` dependency group supplies pytest and the +static-analysis tools used by the tests. + +Run +--- + +Run the guarded demonstration from the repository root: + +.. code-block:: console + + $ uv run --python 3.10 python -m CH_13_async_io.exercise_01.solution_00 + +Run the focused tests: + +.. code-block:: console + + $ uv run --python 3.10 pytest CH_13_async_io/exercise_01/test_solution_00.py -v + +Upstream references +------------------- + +The explicit async constructor and destructor pattern is informed by +`T_12_constructors_and_destructors.rst +`_ +and task shutdown by +`T_18_wait_for_all_tasks.py +`_. +The upstream material is MIT licensed; this solution is self-contained. diff --git a/CH_13_async_io/exercise_01/solution_00.py b/CH_13_async_io/exercise_01/solution_00.py index 2728814..899c375 100644 --- a/CH_13_async_io/exercise_01/solution_00.py +++ b/CH_13_async_io/exercise_01/solution_00.py @@ -1,76 +1,119 @@ -# Try to create a `asyncio` base class that automatically -# registers all instances for easy closing/destructuring when you -# are done +"""Manage explicitly registered asynchronous resources.""" + +from __future__ import annotations + import abc import asyncio +from types import TracebackType +from typing import ClassVar -class AsyncBase(abc.ABC): - _instances = [] - - def __init__(self): - self._instances.append(self) +class CloseFailures(RuntimeError): + """Report every ordinary exception raised during one close pass.""" - async def close(self): - raise NotImplementedError + def __init__(self, failures: tuple[Exception, ...]) -> None: + self.failures: tuple[Exception, ...] = failures + super().__init__(f"{len(failures)} resource(s) failed to close") -class AsyncManager(AsyncBase): - # Use a separate class for the managing of the instances so - # we don't pollute the namespace of the base class +class AsyncBase(abc.ABC): + """Base class for resources with deterministic asynchronous cleanup.""" + + _instances: ClassVar[list[AsyncBase]] = [] + + def __init__(self) -> None: + self._closed: bool = False + self._close_task: asyncio.Task[None] | None = None + AsyncBase._instances.append(self) + + @property + def closed(self) -> bool: + """Return whether cleanup completed successfully.""" + return self._closed + + @abc.abstractmethod + async def _close(self) -> None: + """Release this resource's asynchronous state.""" + + async def close(self) -> None: + """Close once, retaining failed resources for a later retry.""" + if self._closed: + return + + close_task: asyncio.Task[None] | None = self._close_task + if close_task is None: + close_task = asyncio.create_task(self._close_once()) + self._close_task = close_task + try: + await close_task + finally: + if close_task.done() and self._close_task is close_task: + self._close_task = None + + async def _close_once(self) -> None: + await self._close() + self._closed = True + AsyncBase._unregister(self) @classmethod - async def close(cls): - # Make sure to clear the list of instances while closing - while cls._instances: - await cls._instances.pop().close() - - # Support `async with` syntax as well - async def __aenter__(self): - return self - - async def __aexit__(self, exc_type, exc, tb): - await self.close() + def pending_count(cls: type[AsyncBase]) -> int: + """Return the number of registered resources awaiting cleanup.""" + return len(AsyncBase._instances) + @classmethod + async def close_all(cls: type[AsyncBase]) -> None: + """Attempt a LIFO close pass and aggregate ordinary exceptions.""" + failures: list[Exception] = [] + resources: tuple[AsyncBase, ...] = tuple(AsyncBase._instances) -class A(AsyncBase): - def __init__(self): - super().__init__() - print('A.__init__') + for resource in reversed(resources): + try: + await resource.close() + except Exception as error: + failures.append(error) - async def close(self): - print('A.close') + if failures: + raise CloseFailures(tuple(failures)) + @staticmethod + def _unregister(resource: AsyncBase) -> None: + for index, registered in enumerate(AsyncBase._instances): + if registered is resource: + del AsyncBase._instances[index] + return -class B(AsyncBase): - def __init__(self): - super().__init__() - print('B.__init__') - async def close(self): - print('B.close') +class AsyncManager: + """Close all registered resources when leaving an async context.""" + async def __aenter__(self) -> AsyncManager: + return self -async def main(): - print('Using close method directly') + async def __aexit__( + self, + exception_type: type[BaseException] | None, + exception: BaseException | None, + traceback: TracebackType | None, + ) -> bool: + del exception_type, exception, traceback + await AsyncBase.close_all() + return False - A() - B() - await AsyncManager.close() - print() +class DemoResource(AsyncBase): + def __init__(self, name: str) -> None: + self._name: str = name + super().__init__() + async def _close(self) -> None: + print(f"closed {self._name}") -async def main_with(): - print('Using async with') +async def main() -> None: + """Demonstrate automatic LIFO cleanup.""" async with AsyncManager(): - A() - B() - - print() + DemoResource("demo") -if __name__ == '__main__': +if __name__ == "__main__": asyncio.run(main()) - asyncio.run(main_with()) diff --git a/CH_13_async_io/exercise_01/test_solution_00.py b/CH_13_async_io/exercise_01/test_solution_00.py new file mode 100644 index 0000000..c3f01e7 --- /dev/null +++ b/CH_13_async_io/exercise_01/test_solution_00.py @@ -0,0 +1,430 @@ +"""Tests explicit asynchronous resource lifecycle management.""" + +from __future__ import annotations + +import asyncio +import subprocess +import sys +from collections.abc import Coroutine +from pathlib import Path +from types import TracebackType +from typing import Any, TypeVar, cast + +import pytest + +from CH_13_async_io.exercise_01.solution_00 import ( + AsyncBase, + AsyncManager, + CloseFailures, +) + +_T = TypeVar("_T") + + +def _run(coroutine: Coroutine[Any, Any, _T]) -> _T: + return asyncio.run(coroutine) + + +class _Resource(AsyncBase): + def __init__( + self, + name: str, + events: list[str], + failures: list[BaseException] | None = None, + ) -> None: + self.name: str = name + self.events: list[str] = events + self.failures: list[BaseException] = failures or [] + self.attempts: int = 0 + super().__init__() + + async def _close(self) -> None: + self.attempts += 1 + self.events.append(self.name) + if self.failures: + raise self.failures.pop(0) + + +class _CoalescingResource(AsyncBase): + def __init__( + self, + *, + failure: BaseException | None = None, + ) -> None: + self.entered: asyncio.Event = asyncio.Event() + self.release: asyncio.Event = asyncio.Event() + self.failure: BaseException | None = failure + self.attempts: int = 0 + super().__init__() + + async def _close(self) -> None: + self.attempts += 1 + self.entered.set() + await self.release.wait() + if self.failure is not None: + raise self.failure + + +class _BodyFailure(RuntimeError): + pass + + +class _CleanupFailure(RuntimeError): + pass + + +class _ControlFlow(BaseException): + pass + + +def test_close_all_uses_lifo_order_and_clears_successful_resources() -> None: + async def scenario() -> None: + events: list[str] = [] + first: _Resource = _Resource("first", events) + second: _Resource = _Resource("second", events) + + assert AsyncBase.pending_count() == 2 + await AsyncBase.close_all() + + assert events == ["second", "first"] + assert first.closed + assert second.closed + assert AsyncBase.pending_count() == 0 + + _run(scenario()) + + +def test_resource_close_is_idempotent() -> None: + async def scenario() -> None: + events: list[str] = [] + resource: _Resource = _Resource("resource", events) + + await resource.close() + await resource.close() + await AsyncBase.close_all() + + assert resource.attempts == 1 + assert events == ["resource"] + assert AsyncBase.pending_count() == 0 + + _run(scenario()) + + +def test_concurrent_close_calls_share_one_successful_attempt() -> None: + async def scenario() -> None: + resource: _CoalescingResource = _CoalescingResource() + second_started: asyncio.Event = asyncio.Event() + + async def close_second() -> None: + second_started.set() + await resource.close() + + first: asyncio.Task[None] = asyncio.create_task(resource.close()) + await asyncio.wait_for(resource.entered.wait(), timeout=2) + second: asyncio.Task[None] = asyncio.create_task(close_second()) + await asyncio.wait_for(second_started.wait(), timeout=2) + + assert resource.attempts == 1 + assert not first.done() + assert not second.done() + resource.release.set() + await asyncio.wait_for(asyncio.gather(first, second), timeout=2) + assert resource.closed + assert AsyncBase.pending_count() == 0 + + _run(scenario()) + + +def test_concurrent_close_failure_is_shared_and_resource_is_retryable() -> None: + async def scenario() -> None: + failure: RuntimeError = RuntimeError("close failed") + resource: _CoalescingResource = _CoalescingResource(failure=failure) + second_started: asyncio.Event = asyncio.Event() + + async def close_second() -> None: + second_started.set() + await resource.close() + + first: asyncio.Task[None] = asyncio.create_task(resource.close()) + await asyncio.wait_for(resource.entered.wait(), timeout=2) + second: asyncio.Task[None] = asyncio.create_task(close_second()) + await asyncio.wait_for(second_started.wait(), timeout=2) + resource.release.set() + + results: tuple[BaseException | None, BaseException | None] = cast( + tuple[BaseException | None, BaseException | None], + tuple( + await asyncio.wait_for( + asyncio.gather( + first, + second, + return_exceptions=True, + ), + timeout=2, + ) + ), + ) + assert results == (failure, failure) + assert resource.attempts == 1 + assert not resource.closed + assert AsyncBase.pending_count() == 1 + + resource.failure = None + await resource.close() + assert resource.attempts == 2 + assert resource.closed + assert AsyncBase.pending_count() == 0 + + _run(scenario()) + + +def test_concurrent_close_cancellation_is_shared_and_resource_is_retryable() -> None: + async def scenario() -> None: + resource: _CoalescingResource = _CoalescingResource() + second_started: asyncio.Event = asyncio.Event() + + async def close_second() -> None: + second_started.set() + await resource.close() + + first: asyncio.Task[None] = asyncio.create_task(resource.close()) + await asyncio.wait_for(resource.entered.wait(), timeout=2) + second: asyncio.Task[None] = asyncio.create_task(close_second()) + await asyncio.wait_for(second_started.wait(), timeout=2) + + first.cancel() + with pytest.raises(asyncio.CancelledError): + await first + with pytest.raises(asyncio.CancelledError): + await asyncio.wait_for(second, timeout=2) + + assert resource.attempts == 1 + assert not resource.closed + assert AsyncBase.pending_count() == 1 + + resource.release.set() + await resource.close() + assert resource.attempts == 2 + assert resource.closed + assert AsyncBase.pending_count() == 0 + + _run(scenario()) + + +def test_close_all_aggregates_exceptions_and_retries_failed_resources() -> None: + async def scenario() -> None: + events: list[str] = [] + first_error: ValueError = ValueError("first failed") + last_error: LookupError = LookupError("last failed") + first: _Resource = _Resource("first", events, [first_error]) + middle: _Resource = _Resource("middle", events) + last: _Resource = _Resource("last", events, [last_error]) + + with pytest.raises(CloseFailures) as caught: + await AsyncBase.close_all() + + assert caught.value.failures == (last_error, first_error) + assert events == ["last", "middle", "first"] + assert AsyncBase.pending_count() == 2 + assert not first.closed + assert middle.closed + assert not last.closed + + await AsyncBase.close_all() + + assert events == ["last", "middle", "first", "last", "first"] + assert first.closed + assert last.closed + assert AsyncBase.pending_count() == 0 + + _run(scenario()) + + +def test_base_exception_propagates_without_being_aggregated() -> None: + async def scenario() -> None: + events: list[str] = [] + earlier: _Resource = _Resource("earlier", events) + control: _ControlFlow = _ControlFlow() + interrupted: _Resource = _Resource( + "interrupted", + events, + [control], + ) + later: _Resource = _Resource("later", events) + + with pytest.raises(_ControlFlow) as caught: + await AsyncBase.close_all() + + assert caught.value is control + assert events == ["later", "interrupted"] + assert later.closed + assert not interrupted.closed + assert not earlier.closed + assert AsyncBase.pending_count() == 2 + await AsyncBase.close_all() + + _run(scenario()) + + +def test_cancelled_error_propagates_and_resource_remains_retryable() -> None: + async def scenario() -> None: + events: list[str] = [] + resource: _Resource = _Resource( + "cancelled", + events, + [asyncio.CancelledError()], + ) + + with pytest.raises(asyncio.CancelledError): + await AsyncBase.close_all() + + assert AsyncBase.pending_count() == 1 + assert not resource.closed + await AsyncBase.close_all() + assert resource.closed + assert AsyncBase.pending_count() == 0 + + _run(scenario()) + + +def test_context_closes_resources_and_preserves_body_exception() -> None: + async def scenario() -> None: + manager: AsyncManager = AsyncManager() + events: list[str] = [] + resource: _Resource | None = None + + with pytest.raises(_BodyFailure, match="body failed"): + async with manager as entered: + assert entered is manager + resource = _Resource("resource", events) + raise _BodyFailure("body failed") + + assert events == ["resource"] + assert resource is not None + assert resource.closed + assert AsyncBase.pending_count() == 0 + + _run(scenario()) + + +def test_cleanup_failure_propagates_when_body_also_fails() -> None: + async def scenario() -> None: + manager: AsyncManager = AsyncManager() + cleanup_error: _CleanupFailure = _CleanupFailure("cleanup failed") + + with pytest.raises(CloseFailures) as caught: + async with manager: + _Resource("resource", [], [cleanup_error]) + raise _BodyFailure("body failed") + + assert caught.value.failures == (cleanup_error,) + assert isinstance(caught.value.__context__, _BodyFailure) + assert AsyncBase.pending_count() == 1 + await AsyncBase.close_all() + + _run(scenario()) + + +def test_cleanup_failure_propagates_when_context_body_succeeds() -> None: + async def scenario() -> None: + manager: AsyncManager = AsyncManager() + cleanup_error: _CleanupFailure = _CleanupFailure("cleanup failed") + + with pytest.raises(CloseFailures) as caught: + async with manager: + _Resource("resource", [], [cleanup_error]) + + assert caught.value.failures == (cleanup_error,) + assert AsyncBase.pending_count() == 1 + await AsyncBase.close_all() + + _run(scenario()) + + +def test_all_subclasses_use_one_base_registry_without_shadow_state() -> None: + class OtherResource(_Resource): + pass + + async def scenario() -> None: + events: list[str] = [] + first: _Resource = _Resource("first", events) + second: OtherResource = OtherResource("second", events) + + assert _Resource.pending_count() == 2 + assert OtherResource.pending_count() == 2 + await OtherResource.close_all() + + assert events == ["second", "first"] + assert first.closed + assert second.closed + assert AsyncBase.pending_count() == 0 + + _run(scenario()) + + +def test_close_failures_is_a_runtime_error() -> None: + assert issubclass(CloseFailures, RuntimeError) + + +def test_async_base_cannot_be_instantiated_without_close_hook() -> None: + class IncompleteResource(AsyncBase): + pass + + with pytest.raises(TypeError): + IncompleteResource() # type: ignore[abstract] + + +def test_context_exit_annotation_accepts_standard_protocol_shape() -> None: + async def call_exit( + manager: AsyncManager, + exception_type: type[BaseException] | None, + exception: BaseException | None, + traceback: TracebackType | None, + ) -> bool: + return await manager.__aexit__( + exception_type, + exception, + traceback, + ) + + assert _run(call_exit(AsyncManager(), None, None, None)) is False + + +def test_import_is_silent_and_does_not_start_async_work() -> None: + repository_root: Path = Path(__file__).parents[2] + completed: subprocess.CompletedProcess[str] = subprocess.run( + [ + sys.executable, + "-c", + "import CH_13_async_io.exercise_01.solution_00", + ], + cwd=repository_root, + check=False, + capture_output=True, + text=True, + timeout=5, + ) + + assert completed.returncode == 0 + assert completed.stdout == "" + assert completed.stderr == "" + + +def test_guarded_demo_runs() -> None: + repository_root: Path = Path(__file__).parents[2] + completed: subprocess.CompletedProcess[str] = subprocess.run( + [ + sys.executable, + "-m", + "CH_13_async_io.exercise_01.solution_00", + ], + cwd=repository_root, + check=False, + capture_output=True, + text=True, + timeout=5, + ) + + assert completed.returncode == 0 + assert completed.stdout == "closed demo\n" + assert completed.stderr == "" diff --git a/CH_13_async_io/exercise_02/README.rst b/CH_13_async_io/exercise_02/README.rst new file mode 100644 index 0000000..9d5e3c2 --- /dev/null +++ b/CH_13_async_io/exercise_02/README.rst @@ -0,0 +1,73 @@ +Exercise 2: executor-backed synchronous operations +================================================== + +Question +-------- + +The exact exercise-list question is: + +.. code-block:: text + + Create an `asyncio` wrapper class for a synchronous process such as file or network operations using executors + +Answer +------ + +``AsyncExecutor.run`` preserves each callable's parameter and result types and +submits a partial call through the current running loop's +``run_in_executor`` method. Results and worker exceptions propagate through +the await. New work is rejected after the adapter closes. + +Without an injected executor, the adapter creates and owns a +``ThreadPoolExecutor``. ``close`` marks the adapter closed immediately, then +waits for owned-pool shutdown in a separate thread so the event loop remains +responsive and cancels queued futures. Repeated closes are harmless, and a +closed adapter rejects both new work and context re-entry. An injected +``concurrent.futures.Executor`` remains caller-owned; closing the adapter does +not shut it down. The same contract supports caller-managed thread and process +pools. Process-pool callables must be top-level and pickleable. + +``AsyncioFile`` composes an ``AsyncExecutor`` and provides asynchronous +``pathlib.Path`` operations for ``exists``, text and byte reads and writes, +and ``rename``. It accepts an optional caller-owned ``AsyncExecutor`` runner +and closes only a runner it created itself. Text encoding and error handling +are keyword-only and default to ``"utf-8"`` and ``"strict"``. ``rename`` +returns the new ``Path`` while the wrapper remains bound to the original path, +matching the underlying ``pathlib`` call. + +Blocking socket operations use the same generic ``AsyncExecutor.run`` path as +files and process work. The tests use a local ``socketpair`` and require no +fixed port or external network service. + +Dependencies +------------ + +The solution requires Python 3.10 or newer and uses only the standard +library. The repository's ``dev`` dependency group supplies pytest and the +static-analysis tools used by the tests. + +Run +--- + +Run the guarded demonstration from the repository root: + +.. code-block:: console + + $ uv run --python 3.10 python -m CH_13_async_io.exercise_02.solution_00 + +Run the focused tests: + +.. code-block:: console + + $ uv run --python 3.10 pytest CH_13_async_io/exercise_02/test_solution_00.py -v + +Upstream references +------------------- + +Executor usage is informed by +`T_05_executors.rst +`_ +and the blocking-call warning by +`T_14_slow_blocking_code.py +`_. +The upstream material is MIT licensed; no runtime clone or import is used. diff --git a/CH_13_async_io/exercise_02/solution_00.py b/CH_13_async_io/exercise_02/solution_00.py index fe28252..738dd2f 100644 --- a/CH_13_async_io/exercise_02/solution_00.py +++ b/CH_13_async_io/exercise_02/solution_00.py @@ -1,92 +1,182 @@ -# Create an `asyncio` wrapper class for a synchronous process -# such as file or network operations using executors +"""Run synchronous process, file, and network operations in executors.""" -# This example shows an `AsyncioFile` class that makes your file -# operations asynchronous by running them in a separate thread. -# If your operation has a tendency to block the Python GIL you -# could also opt for using a ProcessPoolExecutor instead. -# -# Note that for real-life usage I would recommend the aiofiles -# module over this class. +from __future__ import annotations import asyncio import concurrent.futures import functools -import pathlib -from asyncio import AbstractEventLoop -from concurrent.futures import ThreadPoolExecutor - - -class AsyncExecutorBase: - _executor: ThreadPoolExecutor - _loop: AbstractEventLoop - - def __init__(self): - self._executor = concurrent.futures.ThreadPoolExecutor() - self._loop = asyncio.get_running_loop() - super().__init__() - - def _run_in_executor(self, func, *args, **kwargs): - # Note that this method is not async but can be awaited - # because it returns a coroutine. Alternatively, we could - # have made this method async and used `await` before - # returning - return self._loop.run_in_executor( - self._executor, - functools.partial(func, *args, **kwargs), +from collections.abc import Callable +from pathlib import Path +from types import TracebackType +from typing import ParamSpec, TypeVar + +_P = ParamSpec("_P") +_T = TypeVar("_T") + + +class AsyncExecutor: + """Adapt a synchronous executor to an awaitable interface.""" + + def __init__( + self, + executor: concurrent.futures.Executor | None = None, + ) -> None: + if executor is None: + self._executor: concurrent.futures.Executor = ( + concurrent.futures.ThreadPoolExecutor() + ) + self._owns_executor: bool = True + else: + self._executor = executor + self._owns_executor = False + self._closed: bool = False + self._close_task: asyncio.Task[None] | None = None + + @property + def closed(self) -> bool: + """Return whether this adapter rejects new work.""" + return self._closed + + async def run( + self, + function: Callable[_P, _T], + *args: _P.args, + **kwargs: _P.kwargs, + ) -> _T: + """Run *function* in the configured executor.""" + if self._closed: + raise RuntimeError("executor is closed") + + loop: asyncio.AbstractEventLoop = asyncio.get_running_loop() + call: functools.partial[_T] = functools.partial( + function, + *args, + **kwargs, ) - - -class AsyncioFile(AsyncExecutorBase): - _path: pathlib.Path - - def __init__(self, path: pathlib.Path): - super().__init__() - self._path = path + return await loop.run_in_executor(self._executor, call) + + async def close(self) -> None: + """Reject new work and asynchronously stop an owned thread pool.""" + close_task: asyncio.Task[None] | None = self._close_task + if close_task is None: + self._closed = True + close_task = asyncio.create_task(self._close_executor()) + self._close_task = close_task + + try: + await asyncio.shield(close_task) + except BaseException: + if ( + self._close_task is close_task + and close_task.done() + and (close_task.cancelled() or close_task.exception() is not None) + ): + self._close_task = None + raise + + async def _close_executor(self) -> None: + if self._owns_executor: + await asyncio.to_thread( + self._executor.shutdown, + wait=True, + cancel_futures=True, + ) + + async def __aenter__(self) -> AsyncExecutor: + if self._closed: + raise RuntimeError("executor is closed") + return self + + async def __aexit__( + self, + exception_type: type[BaseException] | None, + exception: BaseException | None, + traceback: TracebackType | None, + ) -> bool: + del exception_type, exception, traceback + await self.close() + return False + + +class AsyncioFile: + """Expose common :class:`pathlib.Path` operations asynchronously.""" + + def __init__( + self, + path: Path, + runner: AsyncExecutor | None = None, + ) -> None: + self.path: Path = path + self._runner: AsyncExecutor = runner if runner is not None else AsyncExecutor() + self._owns_runner: bool = runner is None + + @property + def closed(self) -> bool: + """Return whether the internal executor adapter is closed.""" + return self._runner.closed async def exists(self) -> bool: - return await self._run_in_executor(self._path.exists) - - async def rename(self, target): - return await self._run_in_executor( - self._path.rename, - target, - ) - - async def read_text(self, encoding=None, errors=None): - return await self._run_in_executor( - self._path.read_text, + return await self._runner.run(self.path.exists) + + async def read_text( + self, + *, + encoding: str = "utf-8", + errors: str = "strict", + ) -> str: + return await self._runner.run( + self.path.read_text, encoding=encoding, errors=errors, ) - async def read_bytes(self): - return await self._run_in_executor(self._path.read_bytes) - - async def write_text(self, data, encoding=None, errors=None, - newline=None): - return await self._run_in_executor( - self._path.write_text, + async def write_text( + self, + data: str, + *, + encoding: str = "utf-8", + errors: str = "strict", + ) -> int: + return await self._runner.run( + self.path.write_text, data, encoding=encoding, errors=errors, - newline=newline, ) - async def write_bytes(self, data): - return await self._run_in_executor( - self._path.write_bytes, - data, - ) + async def read_bytes(self) -> bytes: + return await self._runner.run(self.path.read_bytes) + + async def write_bytes(self, data: bytes) -> int: + return await self._runner.run(self.path.write_bytes, data) + + async def rename(self, target: Path) -> Path: + return await self._runner.run(self.path.rename, target) + + async def close(self) -> None: + if self._owns_runner: + await self._runner.close() + + async def __aenter__(self) -> AsyncioFile: + return self + + async def __aexit__( + self, + exception_type: type[BaseException] | None, + exception: BaseException | None, + traceback: TracebackType | None, + ) -> bool: + del exception_type, exception, traceback + await self.close() + return False + -async def main(): - afile = AsyncioFile(pathlib.Path(__file__)) +async def main() -> None: + """Read this source file through an executor.""" + async_file: AsyncioFile = AsyncioFile(Path(__file__)) + async with async_file: + print((await async_file.read_text()).splitlines()[0]) - print('#' * 79) - print('Exists:', await afile.exists()) - print('#' * 79) - print('Contents:') - print(await afile.read_text()) -if __name__ == '__main__': +if __name__ == "__main__": asyncio.run(main()) diff --git a/CH_13_async_io/exercise_02/test_solution_00.py b/CH_13_async_io/exercise_02/test_solution_00.py new file mode 100644 index 0000000..8eab530 --- /dev/null +++ b/CH_13_async_io/exercise_02/test_solution_00.py @@ -0,0 +1,652 @@ +"""Tests executor-backed asynchronous adapters.""" + +from __future__ import annotations + +import asyncio +import concurrent.futures +import inspect +import operator +import socket +import subprocess +import sys +import threading +from collections.abc import Callable, Coroutine +from pathlib import Path +from typing import Any, TypeVar + +import pytest + +from CH_13_async_io.exercise_02.solution_00 import AsyncExecutor, AsyncioFile + +_T = TypeVar("_T") + + +def _run(coroutine: Coroutine[Any, Any, _T]) -> _T: + return asyncio.run(coroutine) + + +def _multiply(left: int, right: int) -> int: + return left * right + + +def _raise_lookup_error(message: str) -> None: + raise LookupError(message) + + +def test_owned_executor_runs_work_off_the_event_loop_thread() -> None: + async def scenario() -> None: + adapter: AsyncExecutor = AsyncExecutor() + loop_thread: int = threading.get_ident() + started: threading.Event = threading.Event() + release: threading.Event = threading.Event() + + def blocking_thread_identity() -> int: + started.set() + if not release.wait(timeout=2): + raise TimeoutError("test did not release worker") + return threading.get_ident() + + task: asyncio.Task[int] = asyncio.create_task( + adapter.run(blocking_thread_identity) + ) + try: + started_on_time: bool = await asyncio.wait_for( + asyncio.to_thread(started.wait, 2), + timeout=3, + ) + assert started_on_time + assert not task.done() + release.set() + worker_thread: int = await asyncio.wait_for(task, timeout=3) + assert worker_thread != loop_thread + finally: + release.set() + await adapter.close() + + _run(scenario()) + + +def test_run_returns_results_and_propagates_worker_exceptions() -> None: + async def scenario() -> None: + adapter: AsyncExecutor = AsyncExecutor() + try: + assert await adapter.run(_multiply, 6, 7) == 42 + with pytest.raises(LookupError, match="worker failed"): + await adapter.run(_raise_lookup_error, "worker failed") + finally: + await adapter.close() + + _run(scenario()) + + +def test_injected_thread_pool_remains_caller_owned() -> None: + async def scenario(executor: concurrent.futures.ThreadPoolExecutor) -> None: + adapter: AsyncExecutor = AsyncExecutor(executor) + assert await adapter.run(_multiply, 3, 9) == 27 + await adapter.close() + await adapter.close() + + with pytest.raises(RuntimeError, match="closed"): + await adapter.run(_multiply, 1, 2) + + executor: concurrent.futures.ThreadPoolExecutor = ( + concurrent.futures.ThreadPoolExecutor(max_workers=1) + ) + try: + _run(scenario(executor)) + future: concurrent.futures.Future[int] = executor.submit(_multiply, 4, 5) + assert future.result(timeout=3) == 20 + finally: + executor.shutdown(wait=True, cancel_futures=True) + + +def test_injected_process_pool_supports_pickleable_callables() -> None: + async def scenario(executor: concurrent.futures.ProcessPoolExecutor) -> None: + adapter: AsyncExecutor = AsyncExecutor(executor) + assert await asyncio.wait_for(adapter.run(operator.mul, 8, 9), timeout=5) == 72 + await adapter.close() + + executor: concurrent.futures.ProcessPoolExecutor = ( + concurrent.futures.ProcessPoolExecutor(max_workers=1) + ) + try: + _run(scenario(executor)) + future: concurrent.futures.Future[int] = executor.submit(operator.mul, 2, 11) + assert future.result(timeout=5) == 22 + finally: + executor.shutdown(wait=True, cancel_futures=True) + + +def test_context_closes_owned_adapter_and_rejects_later_work() -> None: + async def scenario() -> None: + adapter: AsyncExecutor = AsyncExecutor() + + entered: AsyncExecutor + async with adapter as entered: + assert entered is adapter + assert not adapter.closed + assert await adapter.run(_multiply, 2, 3) == 6 + + assert adapter.closed + with pytest.raises(RuntimeError, match="closed"): + await adapter.run(_multiply, 2, 3) + await adapter.close() + + _run(scenario()) + + +def test_closed_adapter_rejects_context_entry() -> None: + async def scenario() -> None: + adapter: AsyncExecutor = AsyncExecutor() + await adapter.close() + + with pytest.raises(RuntimeError, match=r"^executor is closed$"): + async with adapter: + raise AssertionError("closed adapter entered its context") + + _run(scenario()) + + +def test_owned_executor_shutdown_cancels_pending_futures( + monkeypatch: pytest.MonkeyPatch, +) -> None: + async def scenario() -> None: + calls: list[tuple[bool, bool]] = [] + adapter: AsyncExecutor = AsyncExecutor() + owned_executor: object = vars(adapter)["_executor"] + original_shutdown: Callable[..., None] = ( + concurrent.futures.ThreadPoolExecutor.shutdown + ) + + def recording_shutdown( + executor: concurrent.futures.ThreadPoolExecutor, + wait: bool = True, + *, + cancel_futures: bool = False, + ) -> None: + if executor is owned_executor: + calls.append((wait, cancel_futures)) + original_shutdown( + executor, + wait=wait, + cancel_futures=cancel_futures, + ) + + monkeypatch.setattr( + concurrent.futures.ThreadPoolExecutor, + "shutdown", + recording_shutdown, + ) + await adapter.close() + + assert calls == [(True, True)] + + _run(scenario()) + + +def test_concurrent_owned_executor_closes_share_shutdown( + monkeypatch: pytest.MonkeyPatch, +) -> None: + async def scenario() -> None: + adapter: AsyncExecutor = AsyncExecutor() + owned_executor: object = vars(adapter)["_executor"] + shutdown_started: threading.Event = threading.Event() + release_shutdown: threading.Event = threading.Event() + calls: list[tuple[bool, bool]] = [] + original_shutdown: Callable[..., None] = ( + concurrent.futures.ThreadPoolExecutor.shutdown + ) + + def gated_shutdown( + executor: concurrent.futures.ThreadPoolExecutor, + wait: bool = True, + *, + cancel_futures: bool = False, + ) -> None: + if executor is owned_executor: + calls.append((wait, cancel_futures)) + shutdown_started.set() + if not release_shutdown.wait(timeout=2): + raise TimeoutError("test did not release executor shutdown") + original_shutdown( + executor, + wait=wait, + cancel_futures=cancel_futures, + ) + + monkeypatch.setattr( + concurrent.futures.ThreadPoolExecutor, + "shutdown", + gated_shutdown, + ) + first_waiter: asyncio.Task[None] = asyncio.create_task(adapter.close()) + second_entered: asyncio.Event = asyncio.Event() + + async def close_again() -> None: + second_entered.set() + await adapter.close() + + second_waiter: asyncio.Task[None] = asyncio.create_task(close_again()) + try: + assert await asyncio.wait_for( + asyncio.to_thread(shutdown_started.wait, 2), + timeout=3, + ) + await asyncio.wait_for(second_entered.wait(), timeout=3) + + assert adapter.closed + assert not first_waiter.done() + assert not second_waiter.done() + shutdown_task: object = vars(adapter).get("_close_task") + assert isinstance(shutdown_task, asyncio.Task) + + release_shutdown.set() + await asyncio.wait_for( + asyncio.gather(first_waiter, second_waiter), + timeout=3, + ) + assert calls == [(True, True)] + + await adapter.close() + assert vars(adapter)["_close_task"] is shutdown_task + finally: + release_shutdown.set() + await asyncio.wait_for( + asyncio.gather( + first_waiter, + second_waiter, + return_exceptions=True, + ), + timeout=3, + ) + + _run(scenario()) + + +def test_concurrent_shutdown_failure_is_shared_and_later_close_retries( + monkeypatch: pytest.MonkeyPatch, +) -> None: + async def scenario() -> None: + adapter: AsyncExecutor = AsyncExecutor() + owned_executor: concurrent.futures.ThreadPoolExecutor = vars(adapter)[ + "_executor" + ] + shutdown_started: threading.Event = threading.Event() + release_failure: threading.Event = threading.Event() + second_entered: asyncio.Event = asyncio.Event() + failure: OSError = OSError("cannot shut down executor") + calls: list[tuple[bool, bool]] = [] + original_shutdown: Callable[..., None] = ( + concurrent.futures.ThreadPoolExecutor.shutdown + ) + + def failing_once_shutdown( + executor: concurrent.futures.ThreadPoolExecutor, + wait: bool = True, + *, + cancel_futures: bool = False, + ) -> None: + if executor is owned_executor: + calls.append((wait, cancel_futures)) + if len(calls) == 1: + shutdown_started.set() + if not release_failure.wait(timeout=2): + raise TimeoutError("test did not release failed shutdown") + raise failure + original_shutdown( + executor, + wait=wait, + cancel_futures=cancel_futures, + ) + + async def close_second() -> None: + second_entered.set() + await adapter.close() + + monkeypatch.setattr( + concurrent.futures.ThreadPoolExecutor, + "shutdown", + failing_once_shutdown, + ) + first_waiter: asyncio.Task[None] = asyncio.create_task(adapter.close()) + second_waiter: asyncio.Task[None] = asyncio.create_task(close_second()) + + try: + assert await asyncio.wait_for( + asyncio.to_thread(shutdown_started.wait, 2), + timeout=3, + ) + await asyncio.wait_for(second_entered.wait(), timeout=3) + shared_task: object = vars(adapter).get("_close_task") + assert isinstance(shared_task, asyncio.Task) + assert not first_waiter.done() + assert not second_waiter.done() + + release_failure.set() + with pytest.raises(OSError) as first_caught: + await asyncio.wait_for(first_waiter, timeout=3) + with pytest.raises(OSError) as second_caught: + await asyncio.wait_for(second_waiter, timeout=3) + + assert first_caught.value is failure + assert second_caught.value is failure + assert calls == [(True, True)] + assert vars(adapter)["_close_task"] is None + + await asyncio.wait_for(adapter.close(), timeout=3) + retry_task: object = vars(adapter).get("_close_task") + assert isinstance(retry_task, asyncio.Task) + assert retry_task is not shared_task + assert calls == [(True, True), (True, True)] + + await adapter.close() + assert vars(adapter)["_close_task"] is retry_task + assert calls == [(True, True), (True, True)] + finally: + release_failure.set() + await asyncio.wait_for( + asyncio.gather( + first_waiter, + second_waiter, + return_exceptions=True, + ), + timeout=3, + ) + await asyncio.to_thread( + original_shutdown, + owned_executor, + wait=True, + cancel_futures=True, + ) + + _run(scenario()) + + +def test_cancelled_close_waiter_does_not_cancel_owned_shutdown( + monkeypatch: pytest.MonkeyPatch, +) -> None: + async def scenario() -> None: + adapter: AsyncExecutor = AsyncExecutor() + owned_executor: object = vars(adapter)["_executor"] + shutdown_started: threading.Event = threading.Event() + release_shutdown: threading.Event = threading.Event() + calls: list[tuple[bool, bool]] = [] + original_shutdown: Callable[..., None] = ( + concurrent.futures.ThreadPoolExecutor.shutdown + ) + + def gated_shutdown( + executor: concurrent.futures.ThreadPoolExecutor, + wait: bool = True, + *, + cancel_futures: bool = False, + ) -> None: + if executor is owned_executor: + calls.append((wait, cancel_futures)) + shutdown_started.set() + if not release_shutdown.wait(timeout=2): + raise TimeoutError("test did not release executor shutdown") + original_shutdown( + executor, + wait=wait, + cancel_futures=cancel_futures, + ) + + monkeypatch.setattr( + concurrent.futures.ThreadPoolExecutor, + "shutdown", + gated_shutdown, + ) + cancelled_waiter: asyncio.Task[None] = asyncio.create_task(adapter.close()) + current_entered: asyncio.Event = asyncio.Event() + + async def close_currently() -> None: + current_entered.set() + await adapter.close() + + current_waiter: asyncio.Task[None] = asyncio.create_task(close_currently()) + try: + assert await asyncio.wait_for( + asyncio.to_thread(shutdown_started.wait, 2), + timeout=3, + ) + await asyncio.wait_for(current_entered.wait(), timeout=3) + shutdown_task: object = vars(adapter).get("_close_task") + assert isinstance(shutdown_task, asyncio.Task) + assert not current_waiter.done() + + cancelled_waiter.cancel() + with pytest.raises(asyncio.CancelledError): + await cancelled_waiter + assert not shutdown_task.cancelled() + assert not shutdown_task.done() + assert not current_waiter.done() + + release_shutdown.set() + await asyncio.wait_for(current_waiter, timeout=3) + await adapter.close() + + assert vars(adapter)["_close_task"] is shutdown_task + assert calls == [(True, True)] + finally: + release_shutdown.set() + await asyncio.wait_for( + asyncio.gather( + current_waiter, + cancelled_waiter, + return_exceptions=True, + ), + timeout=3, + ) + + _run(scenario()) + + +def test_socketpair_round_trip_uses_no_external_network_service() -> None: + async def scenario() -> None: + adapter: AsyncExecutor = AsyncExecutor() + reader: socket.socket + writer: socket.socket + reader, writer = socket.socketpair() + try: + receive_task: asyncio.Task[bytes] = asyncio.create_task( + adapter.run(reader.recv, 4) + ) + writer.sendall(b"ping") + assert await asyncio.wait_for(receive_task, timeout=3) == b"ping" + finally: + reader.close() + writer.close() + await adapter.close() + + _run(scenario()) + + +def test_async_file_round_trip_and_rename(tmp_path: Path) -> None: + async def scenario() -> None: + source: Path = tmp_path / "source.txt" + target: Path = tmp_path / "renamed.bin" + async_file: AsyncioFile = AsyncioFile(source) + + entered: AsyncioFile + async with async_file as entered: + assert entered is async_file + assert entered.path == source + assert not await entered.exists() + assert await entered.write_text("héllo\n") == len("héllo\n") + assert await entered.read_text() == "héllo\n" + assert await entered.write_bytes(b"\x00\x01\x02") == 3 + assert await entered.read_bytes() == b"\x00\x01\x02" + assert await entered.rename(target) == target + + assert async_file.closed + assert target.read_bytes() == b"\x00\x01\x02" + with pytest.raises(RuntimeError, match="closed"): + await async_file.exists() + + _run(scenario()) + + +def test_text_methods_have_keyword_only_utf8_strict_contract() -> None: + read_parameters: dict[str, inspect.Parameter] = dict( + inspect.signature(AsyncioFile.read_text).parameters + ) + write_parameters: dict[str, inspect.Parameter] = dict( + inspect.signature(AsyncioFile.write_text).parameters + ) + + assert read_parameters["encoding"].kind is inspect.Parameter.KEYWORD_ONLY + assert read_parameters["encoding"].default == "utf-8" + assert read_parameters["errors"].kind is inspect.Parameter.KEYWORD_ONLY + assert read_parameters["errors"].default == "strict" + assert write_parameters["encoding"].kind is inspect.Parameter.KEYWORD_ONLY + assert write_parameters["encoding"].default == "utf-8" + assert write_parameters["errors"].kind is inspect.Parameter.KEYWORD_ONLY + assert write_parameters["errors"].default == "strict" + assert "newline" not in write_parameters + + +def test_async_file_with_injected_runner_does_not_close_runner( + tmp_path: Path, +) -> None: + async def scenario() -> None: + runner: AsyncExecutor = AsyncExecutor() + async_file: AsyncioFile = AsyncioFile( + tmp_path / "owned-by-caller.txt", + runner, + ) + try: + async with async_file: + await async_file.write_text("contents") + + assert not runner.closed + assert await runner.run(_multiply, 5, 5) == 25 + assert await async_file.read_text() == "contents" + finally: + await runner.close() + + _run(scenario()) + + +def test_owned_async_file_close_inherits_shared_shielded_shutdown( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + async def scenario() -> None: + async_file: AsyncioFile = AsyncioFile(tmp_path / "owned-runner.txt") + runner: AsyncExecutor = vars(async_file)["_runner"] + owned_executor: object = vars(runner)["_executor"] + shutdown_started: threading.Event = threading.Event() + release_shutdown: threading.Event = threading.Event() + calls: list[tuple[bool, bool]] = [] + original_shutdown: Callable[..., None] = ( + concurrent.futures.ThreadPoolExecutor.shutdown + ) + + def gated_shutdown( + executor: concurrent.futures.ThreadPoolExecutor, + wait: bool = True, + *, + cancel_futures: bool = False, + ) -> None: + if executor is owned_executor: + calls.append((wait, cancel_futures)) + shutdown_started.set() + if not release_shutdown.wait(timeout=2): + raise TimeoutError("test did not release file runner shutdown") + original_shutdown( + executor, + wait=wait, + cancel_futures=cancel_futures, + ) + + monkeypatch.setattr( + concurrent.futures.ThreadPoolExecutor, + "shutdown", + gated_shutdown, + ) + cancelled_waiter: asyncio.Task[None] = asyncio.create_task(async_file.close()) + current_entered: asyncio.Event = asyncio.Event() + + async def close_currently() -> None: + current_entered.set() + await async_file.close() + + current_waiter: asyncio.Task[None] = asyncio.create_task(close_currently()) + try: + assert await asyncio.wait_for( + asyncio.to_thread(shutdown_started.wait, 2), + timeout=3, + ) + await asyncio.wait_for(current_entered.wait(), timeout=3) + shutdown_task: object = vars(runner).get("_close_task") + assert isinstance(shutdown_task, asyncio.Task) + assert not current_waiter.done() + + cancelled_waiter.cancel() + with pytest.raises(asyncio.CancelledError): + await cancelled_waiter + assert not shutdown_task.cancelled() + assert not shutdown_task.done() + assert not current_waiter.done() + + release_shutdown.set() + await asyncio.wait_for(current_waiter, timeout=3) + await async_file.close() + + assert vars(runner)["_close_task"] is shutdown_task + assert calls == [(True, True)] + finally: + release_shutdown.set() + await asyncio.wait_for( + asyncio.gather( + current_waiter, + cancelled_waiter, + return_exceptions=True, + ), + timeout=3, + ) + + _run(scenario()) + + +def test_import_is_silent_and_does_not_create_threads() -> None: + repository_root: Path = Path(__file__).parents[2] + completed: subprocess.CompletedProcess[str] = subprocess.run( + [ + sys.executable, + "-c", + "import CH_13_async_io.exercise_02.solution_00", + ], + cwd=repository_root, + check=False, + capture_output=True, + text=True, + timeout=5, + ) + + assert completed.returncode == 0 + assert completed.stdout == "" + assert completed.stderr == "" + + +def test_guarded_demo_runs() -> None: + repository_root: Path = Path(__file__).parents[2] + completed: subprocess.CompletedProcess[str] = subprocess.run( + [ + sys.executable, + "-m", + "CH_13_async_io.exercise_02.solution_00", + ], + cwd=repository_root, + check=False, + capture_output=True, + text=True, + timeout=5, + ) + + assert completed.returncode == 0 + assert completed.stdout == ( + '"""Run synchronous process, file, and network operations in executors."""\n' + ) + assert completed.stderr == "" diff --git a/CH_14_multithreading_and_multiprocessing/README.rst b/CH_14_multithreading_and_multiprocessing/README.rst index 0edeccf..543e21b 100644 --- a/CH_14_multithreading_and_multiprocessing/README.rst +++ b/CH_14_multithreading_and_multiprocessing/README.rst @@ -1,10 +1,10 @@ Chapter 14 - multithreading and multiprocessing ======================================================================================================================= -1. See if you can make an echo server and client as separate processes. Even though we did not cover `multiprocessing.Pipe()`, I trust you can work with it regardless. It can be created through `a, b = multiprocessing.Pipe()` and you can use it with `a.send()` or `b.send()` and `a.recv()` or `b.recv()`. -2. Read all files in a directory and sum the size of the files by reading each file using `concurrent.futures`. If you want an extra challenge, walk through the directories recursively by letting the thread/process queue new items while running. -3. Read all files in a directory and sum the size of the files by reading each file using `threading` or `multiprocessing` -4. As above, but walk through the directories recursively by letting the thread/process queue new items while running. -5. Create a pool of workers that keeps waiting for items to be queued through `multiprocessing.Queue()`. -6. Convert the pool above to a safe RPC (remote procedure call) type operation. -7. Apply your functional programming skills and calculate something in a parallel way. Perhaps parallel sorting? +1. `See if you can make an echo server and client as separate processes. Even though we did not cover ``multiprocessing.Pipe()``, I trust you can work with it regardless. It can be created through ``a, b = multiprocessing.Pipe()`` and you can use it with ``a.send()`` or ``b.send()`` and ``a.recv()`` or ``b.recv()``. `_ +2. `Read all files in a directory and sum the size of the files by reading each file using ``concurrent.futures``. If you want an extra challenge, walk through the directories recursively by letting the thread/process queue new items while running. `_ +3. `Read all files in a directory and sum the size of the files by reading each file using ``threading`` and ``multiprocessing``. `_ +4. `As above, walk through the directories recursively by letting the thread/process queue new items while running. `_ +5. `Create a pool of workers that keeps waiting for items to be queued through ``multiprocessing.Queue()``. `_ +6. `Convert the pool above into a safe RPC (remote procedure call) type of operation. `_ +7. `Apply your functional skills to calculate something in a parallel way. Perhaps parallel sorting? `_ diff --git a/CH_14_multithreading_and_multiprocessing/exercise_01/README.rst b/CH_14_multithreading_and_multiprocessing/exercise_01/README.rst new file mode 100644 index 0000000..214e017 --- /dev/null +++ b/CH_14_multithreading_and_multiprocessing/exercise_01/README.rst @@ -0,0 +1,56 @@ +Exercise 1: spawn-safe process echo +=================================== + +Question +-------- + +The original exercise question is: + +.. code-block:: text + + 1. See if you can make an echo server and client as separate processes. Even though we did not cover `multiprocessing.Pipe()`, I trust you can work with it regardless. It can be created through `a, b = multiprocessing.Pipe()` and you can use it with `a.send()` or `b.send()` and `a.recv()` or `b.recv()`. + +Solution +-------- + +``EchoRequest``, ``EchoReply``, and ``StopRequest`` are frozen dataclasses that +define the duplex pipe protocol. ``echo_server(connection)`` is a top-level, +spawn-safe child target. It validates request identifiers and message types, +returns identified replies in order, preserves empty strings, and exits through +a cooperative ``StopRequest``. + +``run_echo_session(messages, timeout=5.0, context=None)`` validates a finite, +positive timeout and every string before spawning. It uses a spawn context by +default, applies one deadline to reply polling and child joining, validates +reply type, identifier, message, and child exit code, and always closes both +local endpoints. Normal completion uses the cooperative stop and bounded join. +Failure cleanup terminates and joins a still-running child. + +Dependencies +------------ + +The solution uses only the Python 3.10 standard library. Tests use the +repository's ``pytest`` development dependency. + +Run the guarded demonstration: + +.. code-block:: console + + uv run python -m CH_14_multithreading_and_multiprocessing.exercise_01.solution_00 + +Run the focused tests: + +.. code-block:: console + + uv run pytest CH_14_multithreading_and_multiprocessing/exercise_01/test_solution_00.py -vv + +Reference +--------- + +The implementation builds on the chapter's immutable +`multiprocessing process example +`_ +and +`multiprocessing class example +`_. +The upstream material is MIT licensed; this solution is self-contained. diff --git a/CH_14_multithreading_and_multiprocessing/exercise_01/solution_00.py b/CH_14_multithreading_and_multiprocessing/exercise_01/solution_00.py index 68a4448..619be2f 100644 --- a/CH_14_multithreading_and_multiprocessing/exercise_01/solution_00.py +++ b/CH_14_multithreading_and_multiprocessing/exercise_01/solution_00.py @@ -1,40 +1,216 @@ -# See if you can make an echo server and client as separate -# processes. Even though we did not cover -# `multiprocessing.Pipe()`, I trust you can work with it -# regardless. It can be created through -# `a, b = multiprocessing.Pipe()` and you can use it with -# `a.send()` or `b.send()` and `a.recv()` or `b.recv()`. +"""Spawn-safe echo request/reply protocol over a multiprocessing pipe.""" + +from __future__ import annotations + import multiprocessing +from collections.abc import Callable, Sequence +from dataclasses import dataclass +from math import isfinite +from multiprocessing.connection import Connection +from multiprocessing.context import BaseContext +from numbers import Real +from time import monotonic +from typing import Protocol, cast + +__all__: list[str] = [ + "EchoReply", + "EchoRequest", + "StopRequest", + "echo_server", + "run_echo_session", +] + + +@dataclass(frozen=True) +class EchoRequest: + """One identified string sent to the echo server.""" + + request_id: int + message: str + + +@dataclass(frozen=True) +class EchoReply: + """One identified string returned by the echo server.""" + + request_id: int + message: str + + +@dataclass(frozen=True) +class StopRequest: + """Cooperative request for the echo server to exit.""" + + +class _ProcessHandle(Protocol): + @property + def exitcode(self) -> int | None: ... + + def start(self) -> None: ... + + def join(self, timeout: float | None = None) -> None: ... + + def is_alive(self) -> bool: ... + + def terminate(self) -> None: ... + +class _EchoContext(Protocol): + def Pipe(self, duplex: bool = True) -> tuple[Connection, Connection]: ... -def echo_client(receive_pipe, send_pipe, message): - print('client sending', message) - send_pipe.send(message) - print('client received', receive_pipe.recv()) + def Process( + self, + *, + target: Callable[[Connection], None], + args: tuple[Connection], + name: str, + ) -> _ProcessHandle: ... -def echo_server(receive_pipe, send_pipe): - while True: - message = receive_pipe.recv() - print('server received', message) - send_pipe.send(message) +def _valid_request_id(request_id: object) -> bool: + return type(request_id) is int and request_id >= 0 -if __name__ == '__main__': - a, b = multiprocessing.Pipe() - server = multiprocessing.Process( - target=echo_server, - args=(a, b), +def echo_server(connection: Connection) -> None: + """Serve typed echo requests until a cooperative stop request arrives.""" + + try: + while True: + try: + request: object = connection.recv() + except EOFError: + return + if isinstance(request, StopRequest): + return + if not isinstance(request, EchoRequest): + raise TypeError( + f"invalid echo request type: {type(request).__name__}", + ) + if not _valid_request_id(request.request_id): + raise ValueError( + "echo request identifier must be a nonnegative integer" + ) + request_message: object = cast(object, request.message) + if not isinstance(request_message, str): + raise TypeError("echo request message must be a string") + connection.send(EchoReply(request.request_id, request_message)) + finally: + connection.close() + + +def _validated_messages(messages: Sequence[str]) -> tuple[str, ...]: + validated: tuple[str, ...] = tuple(messages) + for index, message in enumerate(validated): + candidate: object = cast(object, message) + if not isinstance(candidate, str): + raise TypeError(f"messages[{index}] must be a string") + return validated + + +def _validated_timeout(timeout: float) -> float: + if isinstance(timeout, bool) or not isinstance(timeout, Real): + raise TypeError("timeout must be a real number") + if (isinstance(timeout, float) and not isfinite(timeout)) or timeout <= 0: + raise ValueError("timeout must be finite and positive") + return float(timeout) + + +def _remaining(deadline: float) -> float: + remaining: float = deadline - monotonic() + if remaining <= 0.0: + raise TimeoutError("echo session timed out") + return remaining + + +def _validated_reply(reply: object, request: EchoRequest) -> EchoReply: + if not isinstance(reply, EchoReply): + raise RuntimeError(f"invalid echo reply type: {type(reply).__name__}") + if ( + not _valid_request_id(reply.request_id) + or reply.request_id != request.request_id + ): + raise RuntimeError("unexpected echo reply identifier") + reply_message: object = cast(object, reply.message) + if not isinstance(reply_message, str) or reply_message != request.message: + raise RuntimeError("echo reply message did not match") + return reply + + +def run_echo_session( + messages: Sequence[str], + *, + timeout: float = 5.0, + context: BaseContext | None = None, +) -> list[str]: + """Echo ``messages`` through one bounded child-process session.""" + + validated_messages: tuple[str, ...] = _validated_messages(messages) + validated_timeout: float = _validated_timeout(timeout) + runtime_context: BaseContext = ( + multiprocessing.get_context("spawn") if context is None else context ) - server.start() - for i in range(5): - client = multiprocessing.Process( - target=echo_client, - args=(a, b, f'message {i}'), + parent_connection: Connection + child_connection: Connection + echo_context: _EchoContext = cast(_EchoContext, runtime_context) + parent_connection, child_connection = echo_context.Pipe(duplex=True) + process: _ProcessHandle | None = None + started: bool = False + failed: bool = True + deadline: float = monotonic() + validated_timeout + replies: list[str] = [] + + try: + process = echo_context.Process( + target=echo_server, + args=(child_connection,), + name="chapter-14-echo-server", ) - client.start() - client.join() + process.start() + started = True + child_connection.close() + + for request_id, message in enumerate(validated_messages): + request: EchoRequest = EchoRequest(request_id, message) + parent_connection.send(request) + if not parent_connection.poll(_remaining(deadline)): + raise TimeoutError("echo server reply timed out") + try: + raw_reply: object = parent_connection.recv() + except EOFError as error: + raise RuntimeError("echo server closed before replying") from error + reply: EchoReply = _validated_reply(raw_reply, request) + replies.append(reply.message) + + parent_connection.send(StopRequest()) + process.join(_remaining(deadline)) + if process.is_alive(): + raise TimeoutError("echo server did not stop") + if process.exitcode != 0: + raise ChildProcessError( + f"echo server exited with code {process.exitcode}", + ) + failed = False + return replies + finally: + try: + try: + parent_connection.close() + finally: + child_connection.close() + finally: + if failed and process is not None and started: + try: + if process.is_alive(): + process.terminate() + finally: + process.join(validated_timeout) + + +def _demonstrate() -> None: + replies: list[str] = run_echo_session(["hello", "", "goodbye"]) + print(replies) + - server.terminate() - server.join() +if __name__ == "__main__": + _demonstrate() diff --git a/CH_14_multithreading_and_multiprocessing/exercise_01/test_solution_00.py b/CH_14_multithreading_and_multiprocessing/exercise_01/test_solution_00.py new file mode 100644 index 0000000..01f570c --- /dev/null +++ b/CH_14_multithreading_and_multiprocessing/exercise_01/test_solution_00.py @@ -0,0 +1,502 @@ +"""Tests for the spawn-safe pipe echo session.""" + +from __future__ import annotations + +import multiprocessing +import sys +import time +from collections import deque +from collections.abc import Callable +from dataclasses import FrozenInstanceError +from multiprocessing.connection import Connection +from multiprocessing.context import BaseContext +from pathlib import Path +from typing import Protocol, cast + +import pytest + +from . import solution_00 +from .solution_00 import ( + EchoReply, + EchoRequest, + StopRequest, + run_echo_session, +) + +_REPOSITORY_ROOT: Path = Path(__file__).parents[2] +if str(_REPOSITORY_ROOT) not in sys.path: + sys.path.insert(0, str(_REPOSITORY_ROOT)) + + +def _child_pids() -> set[int]: + return { + process.pid + for process in multiprocessing.active_children() + if process.pid is not None + } + + +class _ChildProcess(Protocol): + pid: int | None + name: str + + def is_alive(self) -> bool: ... + + def terminate(self) -> None: ... + + def join(self, timeout: float | None = None) -> None: ... + + +def _new_children(existing: set[int]) -> list[_ChildProcess]: + return [ + cast(_ChildProcess, process) + for process in multiprocessing.active_children() + if process.pid is not None and process.pid not in existing + ] + + +def _assert_no_new_children(existing: set[int]) -> None: + deadline: float = time.monotonic() + 2.0 + while time.monotonic() < deadline: + if not _new_children(existing): + return + time.sleep(0.01) + leaked: list[_ChildProcess] = _new_children(existing) + leaked_names: list[str] = [process.name for process in leaked] + for process in leaked: + if process.is_alive(): + process.terminate() + process.join(timeout=2.0) + pytest.fail(f"child processes leaked and were cleaned up: {leaked_names}") + + +def _never_reply_server(connection: Connection) -> None: + try: + connection.recv() + while True: + time.sleep(1.0) + finally: + connection.close() + + +def _never_stop_server(connection: Connection) -> None: + try: + while True: + request: object = connection.recv() + if isinstance(request, EchoRequest): + connection.send(EchoReply(request.request_id, request.message)) + elif isinstance(request, StopRequest): + while True: + time.sleep(1.0) + finally: + connection.close() + + +def test_protocol_messages_are_frozen() -> None: + request: EchoRequest = EchoRequest(1, "hello") + reply: EchoReply = EchoReply(1, "hello") + stop: StopRequest = StopRequest() + attribute: str = "message" + + with pytest.raises(FrozenInstanceError): + setattr(request, attribute, "changed") + with pytest.raises(FrozenInstanceError): + setattr(reply, attribute, "changed") + with pytest.raises(FrozenInstanceError): + setattr(stop, attribute, "changed") + + +def test_spawn_session_preserves_order_and_empty_strings() -> None: + context: BaseContext = multiprocessing.get_context("spawn") + existing: set[int] = _child_pids() + + result: list[str] = run_echo_session( + ["first", "", "third", "first"], + timeout=5.0, + context=context, + ) + + assert result == ["first", "", "third", "first"] + _assert_no_new_children(existing) + + +def test_spawn_session_handles_empty_input_without_leaking_child() -> None: + context: BaseContext = multiprocessing.get_context("spawn") + existing: set[int] = _child_pids() + + assert run_echo_session((), timeout=5.0, context=context) == [] + + _assert_no_new_children(existing) + + +@pytest.mark.timeout(10) +def test_reply_timeout_cleans_up_real_child( + monkeypatch: pytest.MonkeyPatch, +) -> None: + context: BaseContext = multiprocessing.get_context("spawn") + existing: set[int] = _child_pids() + monkeypatch.setattr(solution_00, "echo_server", _never_reply_server) + + try: + with pytest.raises(TimeoutError, match=r"^echo server reply timed out$"): + run_echo_session(["message"], timeout=1.0, context=context) + finally: + _assert_no_new_children(existing) + + +@pytest.mark.timeout(10) +def test_stop_timeout_cleans_up_real_child( + monkeypatch: pytest.MonkeyPatch, +) -> None: + context: BaseContext = multiprocessing.get_context("spawn") + existing: set[int] = _child_pids() + monkeypatch.setattr(solution_00, "echo_server", _never_stop_server) + + try: + with pytest.raises(TimeoutError, match=r"^echo server did not stop$"): + run_echo_session(["message"], timeout=1.0, context=context) + finally: + _assert_no_new_children(existing) + + +@pytest.mark.parametrize( + "timeout", + [0.0, -1.0, float("nan"), float("inf")], +) +def test_invalid_timeout_is_rejected_before_spawning(timeout: float) -> None: + existing: set[int] = _child_pids() + + with pytest.raises(ValueError, match=r"^timeout must be finite and positive$"): + run_echo_session(["message"], timeout=timeout) + + _assert_no_new_children(existing) + + +def test_boolean_timeout_is_rejected_before_spawning() -> None: + existing: set[int] = _child_pids() + + with pytest.raises(TypeError, match=r"^timeout must be a real number$"): + run_echo_session(["message"], timeout=cast(float, True)) + + _assert_no_new_children(existing) + + +@pytest.mark.parametrize( + "messages", + [ + cast(list[str], [1]), + cast(list[str], ["valid", object()]), + ], +) +def test_non_string_message_is_rejected_before_spawning( + messages: list[str], +) -> None: + existing: set[int] = _child_pids() + + with pytest.raises( + TypeError, + match=r"^messages\[\d+\] must be a string$", + ): + run_echo_session(messages) + + _assert_no_new_children(existing) + + +ReplyFactory = Callable[[EchoRequest], object] + + +class _FakeConnection: + replies: deque[object] + reply_factory: ReplyFactory | None + sent: list[object] + closed: bool + send_error: Exception | None + recv_error: Exception | None + close_error: Exception | None + + def __init__( + self, + reply_factory: ReplyFactory | None = None, + *, + send_error: Exception | None = None, + recv_error: Exception | None = None, + close_error: Exception | None = None, + ) -> None: + self.replies = deque() + self.reply_factory = reply_factory + self.sent = [] + self.closed = False + self.send_error = send_error + self.recv_error = recv_error + self.close_error = close_error + + def send(self, value: object) -> None: + self.sent.append(value) + if self.send_error is not None: + raise self.send_error + if isinstance(value, EchoRequest) and self.reply_factory is not None: + self.replies.append(self.reply_factory(value)) + + def poll(self, timeout: float) -> bool: + assert timeout >= 0.0 + return bool(self.replies) or self.recv_error is not None + + def recv(self) -> object: + if self.recv_error is not None: + raise self.recv_error + return self.replies.popleft() + + def close(self) -> None: + self.closed = True + if self.close_error is not None: + raise self.close_error + + +class _FakeProcess: + configured_exitcode: int + alive: bool + started: bool + joined: bool + terminated: bool + start_error: bool + join_error: bool + + def __init__( + self, + exitcode: int, + alive: bool, + start_error: bool, + join_error: bool, + ) -> None: + self.configured_exitcode = exitcode + self.alive = alive + self.started = False + self.joined = False + self.terminated = False + self.start_error = start_error + self.join_error = join_error + + @property + def exitcode(self) -> int | None: + if not self.started: + return None + return self.configured_exitcode + + def start(self) -> None: + if self.start_error: + raise RuntimeError("cannot start child") + self.started = True + + def join(self, timeout: float | None = None) -> None: + assert timeout is None or timeout >= 0.0 + self.joined = True + if self.join_error: + raise OSError("cannot join child") + + def is_alive(self) -> bool: + return self.alive + + def terminate(self) -> None: + self.terminated = True + self.alive = False + + +class _FakeContext: + parent: _FakeConnection + child: _FakeConnection + process: _FakeProcess + process_name: str | None + + def __init__( + self, + reply_factory: ReplyFactory, + *, + exitcode: int = 0, + alive: bool = False, + start_error: bool = False, + send_error: Exception | None = None, + recv_error: Exception | None = None, + parent_close_error: Exception | None = None, + child_close_error: Exception | None = None, + join_error: bool = False, + ) -> None: + self.parent = _FakeConnection( + reply_factory, + send_error=send_error, + recv_error=recv_error, + close_error=parent_close_error, + ) + self.child = _FakeConnection(close_error=child_close_error) + self.process = _FakeProcess(exitcode, alive, start_error, join_error) + self.process_name = None + + def Pipe( + self, + duplex: bool = True, + ) -> tuple[_FakeConnection, _FakeConnection]: + assert duplex is True + return self.parent, self.child + + def Process( + self, + *, + target: Callable[..., object], + args: tuple[object, ...], + name: str | None = None, + ) -> _FakeProcess: + assert callable(target) + assert args == (self.child,) + self.process_name = name + return self.process + + +def _run_with_fake_context(context: _FakeContext) -> list[str]: + return run_echo_session( + ["hello"], + timeout=1.0, + context=cast(BaseContext, context), + ) + + +def test_reply_identifier_must_match_request() -> None: + context = _FakeContext( + lambda request: EchoReply(request.request_id + 1, request.message), + alive=True, + ) + + with pytest.raises(RuntimeError, match=r"^unexpected echo reply identifier$"): + _run_with_fake_context(context) + + assert context.process.terminated is True + assert context.process.joined is True + assert context.parent.closed is True + assert context.child.closed is True + + +def test_reply_must_have_expected_type() -> None: + context = _FakeContext(lambda request: request.message, alive=True) + + with pytest.raises(RuntimeError, match=r"^invalid echo reply type: str$"): + _run_with_fake_context(context) + + assert context.process.terminated is True + + +def test_reply_message_must_match_request() -> None: + context = _FakeContext( + lambda request: EchoReply(request.request_id, "different"), + alive=True, + ) + + with pytest.raises(RuntimeError, match=r"^echo reply message did not match$"): + _run_with_fake_context(context) + + assert context.process.terminated is True + + +def test_child_exitcode_must_be_zero() -> None: + context = _FakeContext( + lambda request: EchoReply(request.request_id, request.message), + exitcode=3, + ) + + with pytest.raises(ChildProcessError, match=r"^echo server exited with code 3$"): + _run_with_fake_context(context) + + assert context.process.terminated is False + assert context.process.joined is True + + +def test_success_uses_cooperative_stop_without_termination() -> None: + context = _FakeContext( + lambda request: EchoReply(request.request_id, request.message), + ) + + assert _run_with_fake_context(context) == ["hello"] + + assert isinstance(context.parent.sent[-1], StopRequest) + assert context.process.terminated is False + assert context.process.joined is True + assert context.process_name == "chapter-14-echo-server" + assert context.parent.closed is True + assert context.child.closed is True + + +def test_start_failure_closes_endpoints_without_joining_unstarted_process() -> None: + context = _FakeContext( + lambda request: EchoReply(request.request_id, request.message), + start_error=True, + ) + + with pytest.raises(RuntimeError, match=r"^cannot start child$"): + _run_with_fake_context(context) + + assert context.process.started is False + assert context.process.terminated is False + assert context.process.joined is False + assert context.parent.closed is True + assert context.child.closed is True + + +def test_send_failure_terminates_and_joins_child() -> None: + context = _FakeContext( + lambda request: EchoReply(request.request_id, request.message), + alive=True, + send_error=BrokenPipeError("cannot send request"), + ) + + with pytest.raises(BrokenPipeError, match=r"^cannot send request$"): + _run_with_fake_context(context) + + assert context.process.terminated is True + assert context.process.joined is True + assert context.parent.closed is True + assert context.child.closed is True + + +def test_recv_failure_terminates_and_joins_child() -> None: + context = _FakeContext( + lambda request: EchoReply(request.request_id, request.message), + alive=True, + recv_error=OSError("cannot receive reply"), + ) + + with pytest.raises(OSError, match=r"^cannot receive reply$"): + _run_with_fake_context(context) + + assert context.process.terminated is True + assert context.process.joined is True + assert context.parent.closed is True + assert context.child.closed is True + + +def test_endpoint_close_failure_still_closes_peer_and_cleans_child() -> None: + context = _FakeContext( + lambda request: EchoReply(request.request_id + 1, request.message), + alive=True, + parent_close_error=OSError("cannot close parent endpoint"), + ) + + with pytest.raises(OSError, match=r"^cannot close parent endpoint$"): + _run_with_fake_context(context) + + assert context.parent.closed is True + assert context.child.closed is True + assert context.process.terminated is True + assert context.process.joined is True + + +def test_join_failure_still_closes_endpoints_and_attempts_termination() -> None: + context = _FakeContext( + lambda request: EchoReply(request.request_id, request.message), + alive=True, + join_error=True, + ) + + with pytest.raises(OSError, match=r"^cannot join child$"): + _run_with_fake_context(context) + + assert context.parent.closed is True + assert context.child.closed is True + assert context.process.terminated is True + assert context.process.joined is True diff --git a/CH_14_multithreading_and_multiprocessing/exercise_02/README.rst b/CH_14_multithreading_and_multiprocessing/exercise_02/README.rst new file mode 100644 index 0000000..f9f45c9 --- /dev/null +++ b/CH_14_multithreading_and_multiprocessing/exercise_02/README.rst @@ -0,0 +1,58 @@ +Exercise 2: threaded directory sizes +==================================== + +Question +-------- + +The original exercise question is: + +.. code-block:: text + + 2. Read all files in a directory and sum the size of the files by reading each file using `concurrent.futures`. If you want an extra challenge, walk through the directories recursively by letting the thread/process queue new items while running. + +Solution +-------- + +``get_size(path)`` returns one file's byte size. ``get_total_size(path)`` sums +only immediate non-symlink regular files. +``get_total_size_recursive(path)`` discovers nested entries iteratively and +sums non-symlink regular files without following file or directory symlinks. +Both aggregators reject non-directories, symlink roots, and non-positive worker +counts. Empty directories return zero. + +Both aggregators keep a pinned, no-follow root descriptor through worker +completion and bound submitted file work to the effective worker count. +Recursive discovery uses deterministic depth-first order. Each queued +directory is reopened securely from the root with short-lived descriptors. A +``ThreadPoolExecutor`` runs ``get_size`` for descriptor-backed paths. Results +are read through each ``Future.result()``, so worker exceptions propagate to +the caller. ``main(path)`` prints the flat and recursive totals. + +Dependencies +------------ + +The solution uses only the Python 3.10 standard library. Tests use the +repository's ``pytest`` development dependency. + +Run the guarded demonstration: + +.. code-block:: console + + uv run python -m CH_14_multithreading_and_multiprocessing.exercise_02.solution_00 + +Run the focused tests: + +.. code-block:: console + + uv run pytest CH_14_multithreading_and_multiprocessing/exercise_02/test_solution_00.py -vv + +Reference +--------- + +The implementation builds on the chapter's immutable +`concurrent futures example +`_ +and +`thread batch-processing example +`_. +The upstream material is MIT licensed; this solution is self-contained. diff --git a/CH_14_multithreading_and_multiprocessing/exercise_02/solution_00.py b/CH_14_multithreading_and_multiprocessing/exercise_02/solution_00.py index 2c0d954..2615558 100644 --- a/CH_14_multithreading_and_multiprocessing/exercise_02/solution_00.py +++ b/CH_14_multithreading_and_multiprocessing/exercise_02/solution_00.py @@ -1,74 +1,435 @@ -# Read all files in a directory and sum the size of the files by -# reading each file using `concurrent.futures`. If you want an -# extra challenge, walk through the directories recursively by -# letting the thread/process queue new items while running. +"""Deterministic threaded file-size aggregation without symlink traversal.""" -import concurrent.futures -import logging -import pathlib -import time +from __future__ import annotations -# Our current directory -PATH = pathlib.Path(__file__).parent +import errno +import os +import stat +from concurrent.futures import Future, ThreadPoolExecutor +from os import PathLike, stat_result +from pathlib import Path +from typing import cast +__all__: list[str] = [ + "get_size", + "get_total_size", + "get_total_size_recursive", + "main", +] -def get_size(path: pathlib.Path) -> int: - size = path.stat().st_size - logging.info('%s is %d bytes', path, size) - return size +PATH: Path = Path(__file__).parent +_DIRECTORY_OPEN_FLAGS: int = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW +_REPLACED_DIRECTORY_ERRORS: frozenset[int] = frozenset( + {errno.ENOENT, errno.ENOTDIR, errno.ELOOP} +) +_DEFAULT_PENDING_SIZES: int = min(32, (os.cpu_count() or 1) + 4) -def get_total_size(path) -> int: - with concurrent.futures.ThreadPoolExecutor() as executor: - return sum(executor.map(get_size, path.iterdir())) +class _DescriptorPath: + """Path display plus descriptor-relative metadata access.""" + def __init__(self, path: Path, directory_fd: int, entry_name: str) -> None: + self._path: Path = path + self._directory_fd: int = directory_fd + self._entry_name: str = entry_name -def get_size_or_queue( - executor: concurrent.futures.Executor, - futures: list[concurrent.futures.Future], - path: pathlib.Path, -) -> int: - # If the path is a directory, queue up the children - if path.is_dir(): - for child in path.iterdir(): - futures.append(executor.submit( - get_size_or_queue, executor, futures, child)) + @property + def name(self) -> str: + return self._path.name + + def as_posix(self) -> str: + return self._path.as_posix() + + def lstat(self) -> stat_result: + return os.stat( + self._entry_name, + dir_fd=self._directory_fd, + follow_symlinks=False, + ) + + def relative_to(self, other: str | PathLike[str]) -> Path: + return self._path.relative_to(other) + + def symlink_to( + self, + target: str | PathLike[str], + target_is_directory: bool = False, + ) -> None: + self._path.symlink_to(target, target_is_directory=target_is_directory) + + def unlink(self, missing_ok: bool = False) -> None: + self._path.unlink(missing_ok=missing_ok) + + def __fspath__(self) -> str: + return os.fspath(self._path) + + def __str__(self) -> str: + return str(self._path) + + +class _PendingSize: + """One worker future and the descriptor it owns.""" + + def __init__( + self, + future: Future[int], + directory_fd: int | None, + ) -> None: + self.future: Future[int] = future + self.directory_fd: int | None = directory_fd + + +class _SizeCollector: + """Bound submitted size work and close descriptors as results settle.""" + + def __init__(self, executor: ThreadPoolExecutor, pending_limit: int) -> None: + self._executor: ThreadPoolExecutor = executor + self._pending_limit: int = pending_limit + self._pending: dict[int, _PendingSize] = {} + self._next_identifier: int = 0 + self.total: int = 0 + + def submit( + self, + path: Path, + directory_fd: int | None, + ) -> None: + try: + if len(self._pending) >= self._pending_limit: + oldest_identifier: int = next(iter(self._pending)) + self._settle(oldest_identifier) + + identifier: int = self._next_identifier + self._next_identifier += 1 + future: Future[int] = self._executor.submit(get_size, path) + self._pending[identifier] = _PendingSize(future, directory_fd) + except BaseException: + if directory_fd is not None: + os.close(directory_fd) + raise + + def settle_all(self, *, suppress_errors: bool = False) -> None: + first_error: BaseException | None = None + while self._pending: + oldest_identifier: int = next(iter(self._pending)) + try: + self._settle(oldest_identifier) + except BaseException as error: + if first_error is None: + first_error = error + + if first_error is not None and not suppress_errors: + raise first_error + + def _settle(self, identifier: int) -> None: + pending: _PendingSize = self._pending.pop(identifier) + try: + self.total += pending.future.result() + finally: + if pending.directory_fd is not None: + os.close(pending.directory_fd) + + +class _TraversalWork: + """One root-relative file or directory queued for depth-first work.""" + + def __init__( + self, + components: tuple[str, ...], + *, + is_directory: bool, + ) -> None: + self.components: tuple[str, ...] = components + self.is_directory: bool = is_directory + + +def get_size(path: Path) -> int: + """Return the size of one regular file.""" + + try: + metadata: stat_result = path.lstat() + except FileNotFoundError: + raise FileNotFoundError(f"not a regular file: {path}") from None + if not stat.S_ISREG(metadata.st_mode): + raise FileNotFoundError(f"not a regular file: {path}") + return metadata.st_size - # A directory has size 0 but we recurse into it - return 0 - else: - return get_size(path) +def _validated_directory(path: Path) -> Path: + if path.is_symlink() or not path.is_dir(): + raise NotADirectoryError(f"{path} is not a directory") + return path -def get_total_size_recursive(path) -> int: - with concurrent.futures.ThreadPoolExecutor() as executor: - futures = [] - # Note that we are using a regular list as a queue. This is - # thread-safe because `list.append()` is atomic. - futures.append(executor.submit( - get_size_or_queue, executor, futures, path)) +def _validated_max_workers(max_workers: int | None) -> int | None: + if max_workers is None: + return None + if type(max_workers) is not int: + raise TypeError("max_workers must be an integer") + if max_workers <= 0: + raise ValueError("max_workers must be positive") + return max_workers - total_size = 0 - for future in futures: - total_size += future.result() - return total_size +def _open_root_directory(path: Path) -> int: + try: + return os.open(path, _DIRECTORY_OPEN_FLAGS) + except OSError as error: + if error.errno in _REPLACED_DIRECTORY_ERRORS: + raise NotADirectoryError(f"{path} is not a directory") from None + raise -def main(path: pathlib.Path): - total_size = get_total_size(path) - print(f'Total size for {path} is: {total_size}') +def _descriptor_path(path: Path, directory_fd: int, entry_name: str) -> Path: + descriptor_path: _DescriptorPath = _DescriptorPath( + path, + directory_fd, + entry_name, + ) + return cast(Path, descriptor_path) + + +def _open_child_directory(entry_name: str, parent_fd: int) -> int | None: + try: + return os.open( + entry_name, + _DIRECTORY_OPEN_FLAGS, + dir_fd=parent_fd, + ) + except OSError as error: + if error.errno in _REPLACED_DIRECTORY_ERRORS: + return None + raise + + +def _open_relative_directory( + root_fd: int, + components: tuple[str, ...], +) -> tuple[int, bool] | None: + current_fd: int = root_fd + current_is_owned: bool = False + component: str + for component in components: + try: + child_fd: int | None = _open_child_directory(component, current_fd) + except BaseException: + if current_is_owned: + os.close(current_fd) + raise + if child_fd is None: + if current_is_owned: + os.close(current_fd) + return None + if current_is_owned: + try: + os.close(current_fd) + except BaseException: + os.close(child_fd) + raise + current_fd = child_fd + current_is_owned = True + return current_fd, current_is_owned + + +def _discover_relative_directory( + root_fd: int, + components: tuple[str, ...], +) -> list[_TraversalWork]: + opened: tuple[int, bool] | None = _open_relative_directory(root_fd, components) + if opened is None: + return [] + directory_fd, directory_is_owned = opened + try: + work: list[_TraversalWork] = [] + entry_name: str + for entry_name in sorted(os.listdir(directory_fd)): + try: + metadata: stat_result = os.stat( + entry_name, + dir_fd=directory_fd, + follow_symlinks=False, + ) + except FileNotFoundError: + continue + child_components: tuple[str, ...] = (*components, entry_name) + if stat.S_ISDIR(metadata.st_mode): + work.append( + _TraversalWork( + child_components, + is_directory=True, + ) + ) + elif stat.S_ISREG(metadata.st_mode): + work.append( + _TraversalWork( + child_components, + is_directory=False, + ) + ) + return work + finally: + if directory_is_owned: + os.close(directory_fd) + + +def _submit_relative_file( + root_fd: int, + root_path: Path, + components: tuple[str, ...], + collector: _SizeCollector, +) -> None: + parent_components: tuple[str, ...] = components[:-1] + entry_name: str = components[-1] + opened: tuple[int, bool] | None = _open_relative_directory( + root_fd, + parent_components, + ) + if opened is None: + return + directory_fd, directory_is_owned = opened + descriptor_was_transferred: bool = False + try: + try: + metadata: stat_result = os.stat( + entry_name, + dir_fd=directory_fd, + follow_symlinks=False, + ) + except FileNotFoundError: + return + if not stat.S_ISREG(metadata.st_mode): + return + + display_path: Path = root_path.joinpath(*components) + descriptor_path: Path = _descriptor_path( + display_path, + directory_fd, + entry_name, + ) + owned_fd: int | None = directory_fd if directory_is_owned else None + descriptor_was_transferred = directory_is_owned + collector.submit(descriptor_path, owned_fd) + finally: + if directory_is_owned and not descriptor_was_transferred: + os.close(directory_fd) + + +def _collect_recursive_tree( + root_fd: int, + path: Path, + collector: _SizeCollector, +) -> None: + pending: list[_TraversalWork] = [_TraversalWork((), is_directory=True)] + try: + while pending: + work: _TraversalWork = pending.pop() + if work.is_directory: + children: list[_TraversalWork] = _discover_relative_directory( + root_fd, + work.components, + ) + pending.extend(reversed(children)) + else: + _submit_relative_file( + root_fd, + path, + work.components, + collector, + ) + collector.settle_all() + except BaseException: + collector.settle_all(suppress_errors=True) + raise + finally: + os.close(root_fd) + + +def _pending_size_limit(max_workers: int | None) -> int: + if max_workers is None: + return _DEFAULT_PENDING_SIZES + return max_workers + + +def _total_size_flat(path: Path, max_workers: int | None) -> int: + pending_limit: int = _pending_size_limit(max_workers) + with ThreadPoolExecutor(max_workers=max_workers) as executor: + collector: _SizeCollector = _SizeCollector(executor, pending_limit) + root_fd: int = _open_root_directory(path) + try: + entry_name: str + for entry_name in sorted(os.listdir(root_fd)): + try: + metadata: stat_result = os.stat( + entry_name, + dir_fd=root_fd, + follow_symlinks=False, + ) + except FileNotFoundError: + continue + if stat.S_ISREG(metadata.st_mode): + descriptor_path: Path = _descriptor_path( + path / entry_name, + root_fd, + entry_name, + ) + collector.submit(descriptor_path, None) + collector.settle_all() + except BaseException: + collector.settle_all(suppress_errors=True) + raise + finally: + os.close(root_fd) + return collector.total + + +def _total_size_recursive(path: Path, max_workers: int | None) -> int: + pending_limit: int = _pending_size_limit(max_workers) + with ThreadPoolExecutor(max_workers=max_workers) as executor: + collector: _SizeCollector = _SizeCollector(executor, pending_limit) + root_fd: int = _open_root_directory(path) + _collect_recursive_tree(root_fd, path, collector) + return collector.total + + +def get_total_size( + path: Path, + *, + max_workers: int | None = None, +) -> int: + """Sum immediate non-symlink regular files in ``path``.""" + + directory: Path = _validated_directory(path) + workers: int | None = _validated_max_workers(max_workers) + return _total_size_flat(directory, workers) + + +def get_total_size_recursive( + path: Path, + *, + max_workers: int | None = None, +) -> int: + """Sum nested non-symlink regular files without following symlinks.""" + + directory: Path = _validated_directory(path) + workers: int | None = _validated_max_workers(max_workers) + return _total_size_recursive(directory, workers) - # Sleep so editors such as Pycharm don't mix the output - time.sleep(0.5) - total_size = get_total_size_recursive(path) - print(f'Recursive total size for {path} is: {total_size}') +def main( + path: Path, + *, + max_workers: int | None = None, +) -> None: + """Print flat and recursive totals for ``path``.""" + total_size: int = get_total_size(path, max_workers=max_workers) + print(f"Total size for {path} is: {total_size}") + recursive_size: int = get_total_size_recursive( + path, + max_workers=max_workers, + ) + print(f"Recursive total size for {path} is: {recursive_size}") -if __name__ == '__main__': - logging.basicConfig(level=logging.INFO) - # Use the parent directory to get a reasonable list of files - main(PATH.parent) +if __name__ == "__main__": + main(PATH) diff --git a/CH_14_multithreading_and_multiprocessing/exercise_02/test_solution_00.py b/CH_14_multithreading_and_multiprocessing/exercise_02/test_solution_00.py new file mode 100644 index 0000000..ee63874 --- /dev/null +++ b/CH_14_multithreading_and_multiprocessing/exercise_02/test_solution_00.py @@ -0,0 +1,553 @@ +"""Tests for deterministic threaded file-size aggregation.""" + +from __future__ import annotations + +import errno +import inspect +import os +import shutil +import subprocess +import sys +import threading +from collections.abc import Callable +from concurrent.futures import Future, ThreadPoolExecutor +from os import stat_result +from pathlib import Path +from typing import cast, get_type_hints + +import pytest + +from . import solution_00 +from .solution_00 import ( + get_size, + get_total_size, + get_total_size_recursive, + main, +) + +SizeFunction = Callable[[Path], int] + + +def _write(path: Path, content: bytes) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(content) + + +def _record_directory_fds(monkeypatch: pytest.MonkeyPatch) -> list[int]: + original_open: Callable[..., int] = os.open + opened: list[int] = [] + + def recording_open( + path: object, + flags: int, + *args: object, + **kwargs: object, + ) -> int: + descriptor: int = original_open(path, flags, *args, **kwargs) + if flags & os.O_DIRECTORY: + opened.append(descriptor) + return descriptor + + monkeypatch.setattr(os, "open", recording_open) + return opened + + +def _assert_descriptors_closed(descriptors: list[int]) -> None: + descriptor: int + for descriptor in set(descriptors): + with pytest.raises(OSError) as error: + os.fstat(descriptor) + assert error.value.errno == errno.EBADF + + +def _assert_recursive_total_with_low_descriptor_limit( + root: Path, + expected: int, +) -> None: + script: str = """ +import resource +import sys +from pathlib import Path +from CH_14_multithreading_and_multiprocessing.exercise_02.solution_00 import ( + get_total_size_recursive, +) + +original_soft, hard = resource.getrlimit(resource.RLIMIT_NOFILE) +low_soft = 32 if hard == resource.RLIM_INFINITY else min(32, hard) +if low_soft < 16: + raise SystemExit(77) +resource.setrlimit(resource.RLIMIT_NOFILE, (low_soft, hard)) +try: + print(get_total_size_recursive(Path(sys.argv[1]), max_workers=2)) +finally: + resource.setrlimit(resource.RLIMIT_NOFILE, (original_soft, hard)) +""" + result: subprocess.CompletedProcess[str] = subprocess.run( + [sys.executable, "-c", script, str(root)], + check=False, + capture_output=True, + text=True, + ) + if result.returncode == 77: + pytest.skip("RLIMIT_NOFILE hard limit is below the safe test minimum") + assert result.returncode == 0, result.stderr + assert result.stdout.strip() == str(expected) + + +def test_get_size_returns_file_size(tmp_path: Path) -> None: + path: Path = tmp_path / "value.bin" + _write(path, b"12345") + + assert get_size(path) == 5 + + +def test_get_size_rejects_non_files(tmp_path: Path) -> None: + directory: Path = tmp_path / "directory" + directory.mkdir() + missing: Path = tmp_path / "missing.bin" + + for path in (directory, missing): + with pytest.raises( + FileNotFoundError, + match=rf"^not a regular file: {path}$", + ): + get_size(path) + + +def test_get_size_rejects_symlink_to_regular_file(tmp_path: Path) -> None: + target: Path = tmp_path / "target.bin" + link: Path = tmp_path / "link.bin" + _write(target, b"contents") + link.symlink_to(target) + + with pytest.raises( + FileNotFoundError, + match=rf"^not a regular file: {link}$", + ): + get_size(link) + + +@pytest.mark.parametrize( + "function", + [get_total_size, get_total_size_recursive], +) +def test_worker_rejects_file_replaced_by_symlink_after_discovery( + function: Callable[..., int], + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + root: Path = tmp_path / "root" + outside: Path = tmp_path / "outside.bin" + discovered: Path = root / "discovered.bin" + _write(discovered, b"inside") + _write(outside, b"outside") + original_get_size: SizeFunction = get_size + + def replace_then_get_size(path: Path) -> int: + path.unlink() + path.symlink_to(outside) + return original_get_size(path) + + monkeypatch.setattr(solution_00, "get_size", replace_then_get_size) + + with pytest.raises( + FileNotFoundError, + match=rf"^not a regular file: {discovered}$", + ): + function(root, max_workers=1) + + +def test_recursive_traversal_does_not_follow_directory_replaced_after_stat( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + root: Path = tmp_path / "root" + nested: Path = root / "nested" + outside: Path = tmp_path / "outside" + _write(root / "root.bin", b"x") + _write(nested / "removed.bin", b"inside") + _write(outside / "outside.bin", b"outside-bytes") + original_stat: Callable[..., stat_result] = os.stat + replaced: bool = False + + def replace_directory_after_stat( + path: object, + *args: object, + **kwargs: object, + ) -> stat_result: + nonlocal replaced + metadata: stat_result = original_stat(path, *args, **kwargs) + path_matches: bool = isinstance(path, (str, Path)) and Path(path) == nested + descriptor_name_matches: bool = ( + path == "nested" and kwargs.get("dir_fd") is not None + ) + if not replaced and (path_matches or descriptor_name_matches): + replaced = True + shutil.rmtree(nested) + nested.symlink_to(outside, target_is_directory=True) + return metadata + + monkeypatch.setattr(os, "stat", replace_directory_after_stat) + + assert get_total_size_recursive(root, max_workers=1) == 1 + assert replaced is True + + +def test_flat_traversal_rejects_root_replaced_after_validation( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + root: Path = tmp_path / "root" + outside: Path = tmp_path / "outside" + _write(root / "removed.bin", b"inside") + _write(outside / "outside.bin", b"outside-bytes") + original_is_dir: Callable[[Path], bool] = Path.is_dir + replaced: bool = False + + def replace_root_after_validation(path: Path) -> bool: + nonlocal replaced + is_directory: bool = original_is_dir(path) + if not replaced and path == root and is_directory: + replaced = True + shutil.rmtree(root) + root.symlink_to(outside, target_is_directory=True) + return is_directory + + monkeypatch.setattr(Path, "is_dir", replace_root_after_validation) + + with pytest.raises(NotADirectoryError, match=rf"^{root} is not a directory$"): + get_total_size(root, max_workers=1) + assert replaced is True + + +@pytest.mark.parametrize( + "function", + [get_total_size, get_total_size_recursive], +) +def test_aggregator_worker_default_is_none( + function: Callable[..., int], + tmp_path: Path, +) -> None: + _write(tmp_path / "value.bin", b"x") + parameter: inspect.Parameter = inspect.signature(function).parameters["max_workers"] + + assert parameter.default is None + assert get_type_hints(function)["max_workers"] == int | None + assert function(tmp_path) == 1 + assert function(tmp_path, max_workers=None) == 1 + + +def test_flat_total_includes_only_immediate_regular_files(tmp_path: Path) -> None: + _write(tmp_path / "first.bin", b"12") + _write(tmp_path / "second.bin", b"345") + _write(tmp_path / "nested" / "ignored.bin", b"6789") + + assert get_total_size(tmp_path, max_workers=2) == 5 + + +def test_flat_total_bounds_pending_futures_to_worker_count( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + file_count: int = 24 + index: int + for index in range(file_count): + _write(tmp_path / f"value-{index:02d}.bin", b"x") + + original_get_size: SizeFunction = get_size + original_submit: Callable[..., Future[int]] = cast( + Callable[..., Future[int]], + ThreadPoolExecutor.submit, + ) + original_result: Callable[..., int] = cast( + Callable[..., int], + Future[int].result, + ) + release_workers: threading.Event = threading.Event() + first_result_started: threading.Event = threading.Event() + counter_lock: threading.Lock = threading.Lock() + unresolved: int = 0 + peak_unresolved: int = 0 + + def blocking_size(path: Path) -> int: + release_workers.wait(timeout=5) + return original_get_size(path) + + def record_submit( + executor: ThreadPoolExecutor, + function: Callable[..., int], + *args: object, + **kwargs: object, + ) -> Future[int]: + nonlocal unresolved, peak_unresolved + future: Future[int] = original_submit( + executor, + function, + *args, + **kwargs, + ) + with counter_lock: + unresolved += 1 + peak_unresolved = max(peak_unresolved, unresolved) + + def record_completion(completed: Future[int]) -> None: + nonlocal unresolved + with counter_lock: + unresolved -= 1 + + future.add_done_callback(record_completion) + return future + + def record_result(future: Future[int], timeout: float | None = None) -> int: + first_result_started.set() + return original_result(future, timeout) + + monkeypatch.setattr(solution_00, "get_size", blocking_size) + monkeypatch.setattr(ThreadPoolExecutor, "submit", record_submit) + monkeypatch.setattr(Future, "result", record_result) + totals: list[int] = [] + errors: list[BaseException] = [] + + def calculate_total() -> None: + try: + totals.append(get_total_size(tmp_path, max_workers=2)) + except BaseException as error: + errors.append(error) + + calculation: threading.Thread = threading.Thread(target=calculate_total) + calculation.start() + try: + assert first_result_started.wait(timeout=2) + with counter_lock: + assert peak_unresolved <= 2 + finally: + release_workers.set() + calculation.join(timeout=5) + + assert calculation.is_alive() is False + assert errors == [] + assert totals == [file_count] + + +def test_flat_total_closes_descriptor_after_worker_error( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + _write(tmp_path / "file.bin", b"value") + descriptors: list[int] = _record_directory_fds(monkeypatch) + + def fail(path: Path) -> int: + raise OSError(f"cannot stat {path.name}") + + monkeypatch.setattr(solution_00, "get_size", fail) + + with pytest.raises(OSError, match=r"^cannot stat file\.bin$"): + get_total_size(tmp_path, max_workers=2) + assert len(descriptors) == 1 + _assert_descriptors_closed(descriptors) + + +def test_recursive_total_includes_nested_regular_files(tmp_path: Path) -> None: + _write(tmp_path / "first.bin", b"12") + _write(tmp_path / "nested" / "second.bin", b"345") + _write(tmp_path / "nested" / "deep" / "third.bin", b"6789") + + assert get_total_size_recursive(tmp_path, max_workers=3) == 9 + + +def test_recursive_total_handles_wide_tree_with_low_descriptor_limit( + tmp_path: Path, +) -> None: + root: Path = tmp_path / "root" + expected: int = 0 + index: int + for index in range(96): + content: bytes = b"x" * ((index % 7) + 1) + _write(root / f"directory-{index:03d}" / "value.bin", content) + expected += len(content) + + _assert_recursive_total_with_low_descriptor_limit(root, expected) + + +def test_recursive_total_handles_deep_tree_with_low_descriptor_limit( + tmp_path: Path, +) -> None: + root: Path = tmp_path / "root" + directory: Path = root + depth: int + for depth in range(96): + directory /= f"d{depth:02d}" + _write(directory / "value.bin", b"deep") + + _assert_recursive_total_with_low_descriptor_limit(root, 4) + + +def test_recursive_total_closes_descriptors_after_success( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + _write(tmp_path / "root.bin", b"x") + _write(tmp_path / "nested" / "value.bin", b"yz") + descriptors: list[int] = _record_directory_fds(monkeypatch) + + assert get_total_size_recursive(tmp_path, max_workers=2) == 3 + assert len(descriptors) >= 2 + _assert_descriptors_closed(descriptors) + + +def test_recursive_total_closes_descriptors_after_worker_error( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + _write(tmp_path / "root.bin", b"x") + _write(tmp_path / "nested" / "value.bin", b"yz") + descriptors: list[int] = _record_directory_fds(monkeypatch) + + def fail(path: Path) -> int: + raise OSError(f"cannot stat {path.name}") + + monkeypatch.setattr(solution_00, "get_size", fail) + + with pytest.raises(OSError, match=r"^cannot stat value\.bin$"): + get_total_size_recursive(tmp_path, max_workers=1) + assert len(descriptors) >= 2 + _assert_descriptors_closed(descriptors) + + +def test_empty_directory_has_zero_total(tmp_path: Path) -> None: + assert get_total_size(tmp_path, max_workers=1) == 0 + assert get_total_size_recursive(tmp_path, max_workers=1) == 0 + + +@pytest.mark.parametrize( + "function", + [get_total_size, get_total_size_recursive], +) +def test_root_must_be_directory( + function: SizeFunction, + tmp_path: Path, +) -> None: + file_path: Path = tmp_path / "file.bin" + _write(file_path, b"value") + + with pytest.raises( + NotADirectoryError, + match=rf"^{file_path} is not a directory$", + ): + function(file_path) + + +@pytest.mark.parametrize( + "function", + [get_total_size, get_total_size_recursive], +) +@pytest.mark.parametrize("max_workers", [0, -1]) +def test_max_workers_must_be_positive( + function: Callable[..., int], + max_workers: int, + tmp_path: Path, +) -> None: + with pytest.raises(ValueError, match=r"^max_workers must be positive$"): + function(tmp_path, max_workers=max_workers) + + +@pytest.mark.parametrize( + "function", + [get_total_size, get_total_size_recursive], +) +def test_boolean_max_workers_is_rejected( + function: Callable[..., int], + tmp_path: Path, +) -> None: + with pytest.raises(TypeError, match=r"^max_workers must be an integer$"): + function(tmp_path, max_workers=cast(int, True)) + + +def test_flat_and_recursive_totals_never_follow_symlinks(tmp_path: Path) -> None: + root: Path = tmp_path / "root" + outside: Path = tmp_path / "outside" + root.mkdir() + outside.mkdir() + _write(root / "real.bin", b"12") + outside_file: Path = outside / "outside.bin" + _write(outside_file, b"34567") + (root / "file-link.bin").symlink_to(outside_file) + (root / "directory-link").symlink_to(outside, target_is_directory=True) + + assert get_total_size(root, max_workers=2) == 2 + assert get_total_size_recursive(root, max_workers=2) == 2 + + +@pytest.mark.parametrize( + "function", + [get_total_size, get_total_size_recursive], +) +def test_symlink_root_is_rejected( + function: SizeFunction, + tmp_path: Path, +) -> None: + real: Path = tmp_path / "real" + real.mkdir() + link: Path = tmp_path / "link" + link.symlink_to(real, target_is_directory=True) + + with pytest.raises(NotADirectoryError, match=rf"^{link} is not a directory$"): + function(link) + + +def test_discovery_order_is_deterministic( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + _write(tmp_path / "z.bin", b"z") + _write(tmp_path / "a.bin", b"a") + _write(tmp_path / "nested" / "m.bin", b"m") + seen: list[str] = [] + + def record_size(path: Path) -> int: + seen.append(path.relative_to(tmp_path).as_posix()) + return 1 + + monkeypatch.setattr(solution_00, "get_size", record_size) + + assert get_total_size(tmp_path, max_workers=1) == 2 + assert seen == ["a.bin", "z.bin"] + + seen.clear() + assert get_total_size_recursive(tmp_path, max_workers=1) == 3 + assert seen == ["a.bin", "nested/m.bin", "z.bin"] + + +@pytest.mark.parametrize( + "function", + [get_total_size, get_total_size_recursive], +) +def test_worker_failure_propagates_from_future_result( + function: Callable[..., int], + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + _write(tmp_path / "file.bin", b"value") + + def fail(path: Path) -> int: + raise OSError(f"cannot stat {path.name}") + + monkeypatch.setattr(solution_00, "get_size", fail) + + with pytest.raises(OSError, match=r"^cannot stat file\.bin$"): + function(tmp_path, max_workers=1) + + +def test_main_reports_flat_and_recursive_totals( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + _write(tmp_path / "first.bin", b"12") + _write(tmp_path / "nested" / "second.bin", b"345") + + main(tmp_path, max_workers=1) + + assert capsys.readouterr().out.splitlines() == [ + f"Total size for {tmp_path} is: 2", + f"Recursive total size for {tmp_path} is: 5", + ] diff --git a/CH_14_multithreading_and_multiprocessing/exercise_03/README.rst b/CH_14_multithreading_and_multiprocessing/exercise_03/README.rst new file mode 100644 index 0000000..257cea1 --- /dev/null +++ b/CH_14_multithreading_and_multiprocessing/exercise_03/README.rst @@ -0,0 +1,52 @@ +Exercise 3: threaded and process file sizes +=========================================== + +Question +-------- + +The original exercise question is: + +.. code-block:: text + + 3. Read all files in a directory and sum the size of the files by reading each file using `threading` and `multiprocessing`. + +Solution +-------- + +``threaded_total_size(path)`` uses persistent raw threads and +``multiprocessing_total_size(path)`` uses a bounded spawn-process pool. Both +sum the immediate regular, non-symlink files in deterministic name order. +They reject invalid directories and worker counts, return zero for an empty +directory, and propagate worker errors. + +The historical ``threading_solution_00.py`` and +``multiprocessing_solution_00.py`` files remain available as specialized +alternatives. + +Dependencies +------------ + +The solution has no third-party runtime dependencies and uses only the Python +3.10 standard library. Tests use the repository's ``pytest`` development +dependency. + +Run the guarded demonstration: + +.. code-block:: console + + uv run python -m CH_14_multithreading_and_multiprocessing.exercise_03.solution_00 + +Run the focused tests: + +.. code-block:: console + + uv run pytest CH_14_multithreading_and_multiprocessing/exercise_03/test_solution_00.py -vv + +References +---------- + +The implementation builds on the chapter's immutable +`thread batch-processing example `_ +and +`process batch-processing example `_. +The upstream material is MIT licensed; this solution is self-contained. diff --git a/CH_14_multithreading_and_multiprocessing/exercise_03/solution_00.py b/CH_14_multithreading_and_multiprocessing/exercise_03/solution_00.py new file mode 100644 index 0000000..e7db8e1 --- /dev/null +++ b/CH_14_multithreading_and_multiprocessing/exercise_03/solution_00.py @@ -0,0 +1,174 @@ +"""Immediate file-size totals with persistent threads or spawn processes.""" + +from __future__ import annotations + +import multiprocessing +import queue +import stat +import threading +from collections.abc import Callable +from multiprocessing.context import BaseContext +from os import stat_result +from pathlib import Path +from types import TracebackType +from typing import Protocol, cast + +__all__: list[str] = [ + "multiprocessing_total_size", + "threaded_total_size", +] + +PATH: Path = Path(__file__).parent +_ThreadTask = tuple[int, Path] | None + + +class _ProcessPool(Protocol): + def __enter__(self) -> _ProcessPool: ... + + def __exit__( + self, + exception_type: type[BaseException] | None, + exception: BaseException | None, + traceback: TracebackType | None, + ) -> bool | None: ... + + def map( + self, + function: Callable[[Path], int], + values: list[Path], + ) -> list[int]: ... + + +def _validated_directory(directory: Path) -> Path: + if directory.is_symlink() or not directory.is_dir(): + raise NotADirectoryError(f"{directory} is not a directory") + return directory + + +def _validated_worker_count(worker_count: int) -> int: + if type(worker_count) is not int: + raise TypeError("worker_count must be an integer") + if worker_count < 1: + raise ValueError("worker_count must be positive") + return worker_count + + +def _immediate_files(directory: Path) -> list[Path]: + files: list[Path] = [] + child: Path + for child in sorted(directory.iterdir(), key=lambda path: path.name): + try: + metadata: stat_result = child.lstat() + except FileNotFoundError: + continue + if stat.S_ISREG(metadata.st_mode): + files.append(child) + return files + + +def _stat_size(path: Path) -> int: + metadata: stat_result = path.lstat() + if not stat.S_ISREG(metadata.st_mode): + raise FileNotFoundError(f"not a regular file: {path}") + return metadata.st_size + + +def threaded_total_size( + directory: Path, + *, + worker_count: int = 4, +) -> int: + """Return the total size of immediate files using raw persistent threads.""" + + path: Path = _validated_directory(directory) + workers: int = _validated_worker_count(worker_count) + files: list[Path] = _immediate_files(path) + if not files: + return 0 + + task_queue: queue.Queue[_ThreadTask] = queue.Queue() + results: dict[int, int] = {} + failures: dict[int, Exception] = {} + result_lock: threading.Lock = threading.Lock() + + def worker() -> None: + while True: + task: _ThreadTask = task_queue.get() + try: + if task is None: + return + task_id, file_path = task + try: + size: int = _stat_size(file_path) + except Exception as error: + with result_lock: + failures[task_id] = error + else: + with result_lock: + results[task_id] = size + finally: + task_queue.task_done() + + active_worker_count: int = min(workers, len(files)) + threads: list[threading.Thread] = [ + threading.Thread(target=worker, name=f"file-size-{index}") + for index in range(active_worker_count) + ] + thread: threading.Thread + for thread in threads: + thread.start() + + task_id: int + file_path: Path + for task_id, file_path in enumerate(files): + task_queue.put((task_id, file_path)) + task_queue.join() + for _ in threads: + task_queue.put(None) + task_queue.join() + for thread in threads: + thread.join() + + if failures: + raise failures[min(failures)] + return sum(results[index] for index in range(len(files))) + + +def multiprocessing_total_size( + directory: Path, + *, + worker_count: int = 2, + context: BaseContext | None = None, +) -> int: + """Return the total size of immediate files using a process pool.""" + + path: Path = _validated_directory(directory) + workers: int = _validated_worker_count(worker_count) + files: list[Path] = _immediate_files(path) + if not files: + return 0 + + process_context: BaseContext = ( + context if context is not None else multiprocessing.get_context("spawn") + ) + active_worker_count: int = min(workers, len(files)) + process_pool: _ProcessPool = cast( + _ProcessPool, + process_context.Pool(processes=active_worker_count), + ) + with process_pool as pool: + sizes: list[int] = pool.map(_stat_size, files) + return sum(sizes) + + +def main(path: Path = PATH) -> None: + """Print bounded thread and process totals for the exercise directory.""" + + thread_total: int = threaded_total_size(path, worker_count=2) + process_total: int = multiprocessing_total_size(path, worker_count=2) + print(f"Threaded total size for {path} is: {thread_total}") + print(f"Process total size for {path} is: {process_total}") + + +if __name__ == "__main__": + main() diff --git a/CH_14_multithreading_and_multiprocessing/exercise_03/test_solution_00.py b/CH_14_multithreading_and_multiprocessing/exercise_03/test_solution_00.py new file mode 100644 index 0000000..384f371 --- /dev/null +++ b/CH_14_multithreading_and_multiprocessing/exercise_03/test_solution_00.py @@ -0,0 +1,89 @@ +"""Tests for canonical flat thread and process file-size workers.""" + +from __future__ import annotations + +import multiprocessing +import sys +from collections.abc import Callable +from pathlib import Path + +import pytest + +from . import solution_00 +from .solution_00 import multiprocessing_total_size, threaded_total_size + +_REPOSITORY_ROOT: Path = Path(__file__).parents[2] +if str(_REPOSITORY_ROOT) not in sys.path: + sys.path.insert(0, str(_REPOSITORY_ROOT)) + + +def _write(path: Path, content: bytes) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(content) + + +@pytest.mark.timeout(20) +def test_thread_and_process_totals_match(tmp_path: Path) -> None: + _write(tmp_path / "a.bin", b"12") + _write(tmp_path / "b.bin", b"34567") + _write(tmp_path / "nested" / "ignored.bin", b"ignored-data") + outside: Path = tmp_path.parent / f"{tmp_path.name}-outside.bin" + _write(outside, b"outside") + (tmp_path / "ignored-link.bin").symlink_to(outside) + + assert threaded_total_size(tmp_path, worker_count=2) == 7 + assert ( + multiprocessing_total_size( + tmp_path, + worker_count=2, + context=multiprocessing.get_context("spawn"), + ) + == 7 + ) + + +@pytest.mark.parametrize("function", [threaded_total_size, multiprocessing_total_size]) +def test_apis_return_zero_for_empty_directory( + function: Callable[..., int], + tmp_path: Path, +) -> None: + assert function(tmp_path) == 0 + + +@pytest.mark.parametrize("function", [threaded_total_size, multiprocessing_total_size]) +def test_apis_reject_non_directories( + function: Callable[..., int], + tmp_path: Path, +) -> None: + path: Path = tmp_path / "file" + _write(path, b"x") + + with pytest.raises(NotADirectoryError, match=rf"^{path} is not a directory$"): + function(path) + + +@pytest.mark.parametrize("function", [threaded_total_size, multiprocessing_total_size]) +@pytest.mark.parametrize("worker_count", [0, -1]) +def test_worker_count_must_be_positive( + function: Callable[..., int], + worker_count: int, + tmp_path: Path, +) -> None: + with pytest.raises(ValueError, match=r"^worker_count must be positive$"): + function(tmp_path, worker_count=worker_count) + + +def test_thread_worker_failure_propagates( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + file_path: Path = tmp_path / "file.bin" + _write(file_path, b"value") + + def fail(path: Path) -> int: + raise OSError(f"cannot stat {path.name}") + + monkeypatch.setattr(solution_00, "_stat_size", fail) + + with pytest.raises(OSError, match=r"^cannot stat file\.bin$"): + threaded_total_size(tmp_path, worker_count=2) diff --git a/CH_14_multithreading_and_multiprocessing/exercise_04/README.rst b/CH_14_multithreading_and_multiprocessing/exercise_04/README.rst new file mode 100644 index 0000000..1e974cc --- /dev/null +++ b/CH_14_multithreading_and_multiprocessing/exercise_04/README.rst @@ -0,0 +1,56 @@ +Exercise 4: recursive worker queues +=================================== + +Question +-------- + +The original exercise question is: + +.. code-block:: text + + 4. As above, walk through the directories recursively by letting the thread/process queue new items while running. + +Solution +-------- + +``threaded_recursive_size(path)`` and +``multiprocessing_recursive_size(path)`` keep persistent workers alive while +the traversal grows. Workers discover sorted child directories and return +``ScanResult`` messages; the parent-owned scheduler queues those children and +tracks the exact outstanding count. Non-symlink regular files are counted, +directory symlinks are not followed, and ``ScanFailure`` becomes a +``WorkerScanError`` instead of disappearing across the worker boundary. +Concurrent failures are exposed in deterministic directory order through +``WorkerScanError.failures``. Timeouts bound scheduling and cleanup. + +The historical ``threading_solution_00.py`` and +``multiprocessing_solution_00.py`` files remain available as specialized +alternatives. + +Dependencies +------------ + +The solution has no third-party runtime dependencies and uses only the Python +3.10 standard library. Tests use the repository's ``pytest`` development +dependency. + +Run the guarded demonstration: + +.. code-block:: console + + uv run python -m CH_14_multithreading_and_multiprocessing.exercise_04.solution_00 + +Run the focused tests: + +.. code-block:: console + + uv run pytest CH_14_multithreading_and_multiprocessing/exercise_04/test_solution_00.py -vv + +References +---------- + +The implementation builds on the chapter's immutable +`thread batch-processing example `_ +and +`process batch-processing example `_. +The upstream material is MIT licensed; this solution is self-contained. diff --git a/CH_14_multithreading_and_multiprocessing/exercise_04/solution_00.py b/CH_14_multithreading_and_multiprocessing/exercise_04/solution_00.py new file mode 100644 index 0000000..19b2313 --- /dev/null +++ b/CH_14_multithreading_and_multiprocessing/exercise_04/solution_00.py @@ -0,0 +1,371 @@ +"""Recursive file-size scheduling with persistent thread or process workers.""" + +from __future__ import annotations + +import math +import multiprocessing +import queue +import stat +import threading +from collections.abc import Callable +from dataclasses import dataclass +from multiprocessing.context import BaseContext +from os import stat_result +from pathlib import Path +from time import monotonic +from typing import Protocol, cast + +__all__: list[str] = [ + "ScanFailure", + "ScanResult", + "WorkerScanError", + "multiprocessing_recursive_size", + "threaded_recursive_size", +] + +PATH: Path = Path(__file__).parent +_ScanMessage = "ScanResult | ScanFailure" + + +@dataclass(frozen=True) +class ScanResult: + """Successful scan of one queued directory.""" + + directory: Path + child_directories: tuple[Path, ...] + byte_count: int + + +@dataclass(frozen=True) +class ScanFailure: + """Serializable worker failure for one queued directory.""" + + directory: Path + error_type: str + message: str + + +class WorkerScanError(RuntimeError): + """Raised when recursive workers cannot scan queued directories.""" + + def __init__( + self, + failure: ScanFailure | tuple[ScanFailure, ...], + ) -> None: + failures: tuple[ScanFailure, ...] = ( + (failure,) if isinstance(failure, ScanFailure) else failure + ) + if not failures: + raise ValueError("at least one scan failure is required") + self.failures: tuple[ScanFailure, ...] = tuple( + sorted(failures, key=lambda item: str(item.directory)) + ) + self.failure: ScanFailure = self.failures[0] + details: str = "; ".join( + f"{item.directory}: {item.error_type}: {item.message}" + for item in self.failures + ) + super().__init__(details) + + +class _QueueLike(Protocol): + def put(self, value: object) -> None: ... + + def get(self, timeout: float | None = None) -> object: ... + + +class _ClosableQueue(_QueueLike, Protocol): + def cancel_join_thread(self) -> None: ... + + def close(self) -> None: ... + + def join_thread(self) -> None: ... + + +class _ProcessHandle(Protocol): + pid: int | None + + @property + def exitcode(self) -> int | None: ... + + def start(self) -> None: ... + + def join(self, timeout: float | None = None) -> None: ... + + def is_alive(self) -> bool: ... + + def terminate(self) -> None: ... + + +class _ProcessContext(Protocol): + def Queue(self) -> _ClosableQueue: ... + + def Process( + self, + *, + target: Callable[[_QueueLike, _QueueLike], None], + args: tuple[_QueueLike, _QueueLike], + name: str, + ) -> _ProcessHandle: ... + + +def _validated_directory(directory: Path) -> Path: + if directory.is_symlink() or not directory.is_dir(): + raise NotADirectoryError(f"{directory} is not a directory") + return directory + + +def _validated_worker_count(worker_count: int) -> int: + if type(worker_count) is not int: + raise TypeError("worker_count must be an integer") + if worker_count < 1: + raise ValueError("worker_count must be positive") + return worker_count + + +def _validated_timeout(timeout: float) -> float: + raw_timeout: object = cast(object, timeout) + if isinstance(raw_timeout, bool) or not isinstance(raw_timeout, (int, float)): + raise TypeError("timeout must be a number") + value: float = float(raw_timeout) + if not math.isfinite(value) or value <= 0.0: + raise ValueError("timeout must be positive") + return value + + +def _remaining(deadline: float) -> float: + remaining: float = deadline - monotonic() + if remaining <= 0.0: + raise TimeoutError("recursive scan timed out") + return remaining + + +def _scan_directory(directory: Path) -> ScanResult: + metadata: stat_result = directory.lstat() + if not stat.S_ISDIR(metadata.st_mode): + raise NotADirectoryError(f"{directory} is not a directory") + + children: list[Path] = [] + byte_count: int = 0 + child: Path + for child in sorted(directory.iterdir(), key=lambda path: path.name): + try: + child_metadata: stat_result = child.lstat() + except FileNotFoundError: + continue + if stat.S_ISDIR(child_metadata.st_mode): + children.append(child) + elif stat.S_ISREG(child_metadata.st_mode): + byte_count += child_metadata.st_size + return ScanResult(directory, tuple(children), byte_count) + + +def _failure(directory: Path, error: Exception) -> ScanFailure: + return ScanFailure(directory, type(error).__name__, str(error)) + + +def _thread_worker( + tasks: queue.Queue[Path | None], + results: queue.Queue[ScanResult | ScanFailure], + stop: threading.Event, +) -> None: + while True: + directory: Path | None = tasks.get() + if directory is None or stop.is_set(): + return + try: + result: ScanResult | ScanFailure = _scan_directory(directory) + except Exception as error: + result = _failure(directory, error) + results.put(result) + + +def _process_worker(tasks: _QueueLike, results: _QueueLike) -> None: + while True: + raw_directory: object = tasks.get() + if raw_directory is None: + return + if not isinstance(raw_directory, Path): + raise TypeError("invalid scan task") + try: + result: ScanResult | ScanFailure = _scan_directory(raw_directory) + except Exception as error: + result = _failure(raw_directory, error) + results.put(result) + + +def _schedule( + directory: Path, + tasks: _QueueLike, + results: _QueueLike, + timeout: float, +) -> int: + deadline: float = monotonic() + timeout + outstanding: int = 1 + total: int = 0 + failures: list[ScanFailure] = [] + tasks.put(directory) + while outstanding: + try: + raw_result: object = results.get(timeout=_remaining(deadline)) + except queue.Empty: + raise TimeoutError("recursive scan timed out") from None + outstanding -= 1 + if isinstance(raw_result, ScanFailure): + failures.append(raw_result) + continue + if not isinstance(raw_result, ScanResult): + raise RuntimeError("worker returned an invalid scan message") + total += raw_result.byte_count + child: Path + for child in raw_result.child_directories: + tasks.put(child) + outstanding += 1 + if failures: + raise WorkerScanError(tuple(failures)) + return total + + +def threaded_recursive_size( + directory: Path, + *, + worker_count: int = 4, + timeout: float = 5.0, +) -> int: + """Traverse recursively with persistent raw-thread workers.""" + + path: Path = _validated_directory(directory) + workers: int = _validated_worker_count(worker_count) + wait_timeout: float = _validated_timeout(timeout) + tasks: queue.Queue[Path | None] = queue.Queue() + results: queue.Queue[ScanResult | ScanFailure] = queue.Queue() + stop: threading.Event = threading.Event() + threads: list[threading.Thread] = [ + threading.Thread( + target=_thread_worker, + args=(tasks, results, stop), + name=f"recursive-scan-{index}", + daemon=True, + ) + for index in range(workers) + ] + thread: threading.Thread + for thread in threads: + thread.start() + + total: int = 0 + operation_error: BaseException | None = None + try: + total = _schedule( + path, cast(_QueueLike, tasks), cast(_QueueLike, results), wait_timeout + ) + except BaseException as error: + operation_error = error + + stop.set() + for _ in threads: + tasks.put(None) + cleanup_deadline: float = monotonic() + wait_timeout + for thread in threads: + remaining: float = max(0.0, cleanup_deadline - monotonic()) + thread.join(remaining) + cleanup_error: TimeoutError | None = None + if any(thread.is_alive() for thread in threads): + cleanup_error = TimeoutError("thread workers did not stop") + + if operation_error is not None: + raise operation_error + if cleanup_error is not None: + raise cleanup_error + return total + + +def multiprocessing_recursive_size( + directory: Path, + *, + worker_count: int = 2, + timeout: float = 5.0, + context: BaseContext | None = None, +) -> int: + """Traverse recursively with persistent spawn-process workers.""" + + path: Path = _validated_directory(directory) + workers: int = _validated_worker_count(worker_count) + wait_timeout: float = _validated_timeout(timeout) + process_context: BaseContext = ( + context if context is not None else multiprocessing.get_context("spawn") + ) + typed_context: _ProcessContext = cast(_ProcessContext, process_context) + tasks: _ClosableQueue = typed_context.Queue() + results: _ClosableQueue = typed_context.Queue() + processes: list[_ProcessHandle] = [ + typed_context.Process( + target=_process_worker, + args=(tasks, results), + name=f"recursive-scan-{index}", + ) + for index in range(workers) + ] + process: _ProcessHandle + for process in processes: + process.start() + + total: int = 0 + operation_error: BaseException | None = None + try: + total = _schedule(path, tasks, results, wait_timeout) + except BaseException as error: + operation_error = error + + for _ in processes: + tasks.put(None) + cleanup_deadline: float = monotonic() + wait_timeout + for process in processes: + remaining: float = max(0.0, cleanup_deadline - monotonic()) + process.join(remaining) + + cleanup_error: BaseException | None = None + alive: list[_ProcessHandle] = [ + process for process in processes if process.is_alive() + ] + if alive: + cleanup_error = TimeoutError("process workers did not stop") + for process in alive: + process.terminate() + for process in alive: + process.join(wait_timeout) + elif any(process.exitcode != 0 for process in processes): + exitcodes: tuple[int | None, ...] = tuple( + process.exitcode for process in processes + ) + cleanup_error = ChildProcessError( + f"recursive workers exited unsuccessfully: {exitcodes}" + ) + + if cleanup_error is not None: + tasks.cancel_join_thread() + results.cancel_join_thread() + tasks.close() + results.close() + if cleanup_error is None: + tasks.join_thread() + results.join_thread() + + if operation_error is not None: + raise operation_error + if cleanup_error is not None: + raise cleanup_error + return total + + +def main(path: Path = PATH) -> None: + """Print bounded thread and process recursive totals.""" + + thread_total: int = threaded_recursive_size(path, worker_count=2) + process_total: int = multiprocessing_recursive_size(path, worker_count=2) + print(f"Threaded recursive size for {path} is: {thread_total}") + print(f"Process recursive size for {path} is: {process_total}") + + +if __name__ == "__main__": + main() diff --git a/CH_14_multithreading_and_multiprocessing/exercise_04/test_solution_00.py b/CH_14_multithreading_and_multiprocessing/exercise_04/test_solution_00.py new file mode 100644 index 0000000..6ac44e2 --- /dev/null +++ b/CH_14_multithreading_and_multiprocessing/exercise_04/test_solution_00.py @@ -0,0 +1,175 @@ +"""Tests for recursive persistent thread and process schedulers.""" + +from __future__ import annotations + +import multiprocessing +import queue +import sys +from collections.abc import Callable +from multiprocessing.context import BaseContext +from pathlib import Path +from typing import cast +from unittest.mock import MagicMock + +import pytest + +from . import solution_00 +from .solution_00 import ( + WorkerScanError, + multiprocessing_recursive_size, + threaded_recursive_size, +) + +_REPOSITORY_ROOT: Path = Path(__file__).parents[2] +if str(_REPOSITORY_ROOT) not in sys.path: + sys.path.insert(0, str(_REPOSITORY_ROOT)) + + +def _write(path: Path, content: bytes) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(content) + + +def _child_pids() -> set[int]: + return { + process.pid + for process in multiprocessing.active_children() + if process.pid is not None + } + + +@pytest.mark.timeout(20) +def test_recursive_apis_find_dynamically_enqueued_directories( + tmp_path: Path, +) -> None: + _write(tmp_path / "root.bin", b"12") + _write(tmp_path / "one" / "child.bin", b"345") + _write(tmp_path / "one" / "two" / "deep.bin", b"67890") + outside: Path = tmp_path.parent / f"{tmp_path.name}-outside" + _write(outside / "ignored.bin", b"outside") + (tmp_path / "directory-link").symlink_to(outside, target_is_directory=True) + + assert threaded_recursive_size(tmp_path, worker_count=3, timeout=5.0) == 10 + assert ( + multiprocessing_recursive_size( + tmp_path, + worker_count=2, + timeout=5.0, + context=multiprocessing.get_context("spawn"), + ) + == 10 + ) + + +@pytest.mark.parametrize( + "function", + [threaded_recursive_size, multiprocessing_recursive_size], +) +def test_recursive_apis_return_zero_for_empty_directory( + function: Callable[..., int], + tmp_path: Path, +) -> None: + assert function(tmp_path) == 0 + + +@pytest.mark.parametrize( + "function", + [threaded_recursive_size, multiprocessing_recursive_size], +) +def test_recursive_apis_reject_non_directories( + function: Callable[..., int], + tmp_path: Path, +) -> None: + path: Path = tmp_path / "file" + _write(path, b"x") + + with pytest.raises(NotADirectoryError, match=rf"^{path} is not a directory$"): + function(path) + + +@pytest.mark.parametrize("timeout", [0.0, -1.0]) +def test_timeout_must_be_positive(tmp_path: Path, timeout: float) -> None: + with pytest.raises(ValueError, match=r"^timeout must be positive$"): + threaded_recursive_size(tmp_path, timeout=timeout) + + +def test_thread_worker_failure_propagates( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + _write(tmp_path / "file.bin", b"value") + + def fail(directory: Path) -> solution_00.ScanResult: + raise OSError(f"cannot scan {directory.name}") + + monkeypatch.setattr(solution_00, "_scan_directory", fail) + + with pytest.raises(WorkerScanError, match=r"OSError: cannot scan"): + threaded_recursive_size(tmp_path, worker_count=2, timeout=2.0) + + +def test_scheduler_reports_all_worker_failures_deterministically( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + first: Path = tmp_path / "first" + second: Path = tmp_path / "second" + first.mkdir() + second.mkdir() + + def fail_children(directory: Path) -> solution_00.ScanResult: + if directory == tmp_path: + return solution_00.ScanResult(directory, (second, first), 0) + raise OSError(f"cannot scan {directory.name}") + + monkeypatch.setattr(solution_00, "_scan_directory", fail_children) + + with pytest.raises(WorkerScanError) as captured: + threaded_recursive_size(tmp_path, worker_count=2, timeout=2.0) + + assert tuple(failure.directory for failure in captured.value.failures) == ( + first, + second, + ) + + +@pytest.mark.timeout(20) +def test_process_workers_are_reaped(tmp_path: Path) -> None: + _write(tmp_path / "nested" / "file.bin", b"value") + before: set[int] = _child_pids() + + assert ( + multiprocessing_recursive_size( + tmp_path, + worker_count=2, + timeout=5.0, + context=multiprocessing.get_context("spawn"), + ) + == 5 + ) + assert _child_pids() == before + + +def test_forced_process_cleanup_cancels_queue_feeders(tmp_path: Path) -> None: + tasks: MagicMock = MagicMock() + results: MagicMock = MagicMock() + results.get.side_effect = queue.Empty + process: MagicMock = MagicMock() + process.is_alive.return_value = True + process.exitcode = -15 + context: MagicMock = MagicMock() + context.Queue.side_effect = [tasks, results] + context.Process.return_value = process + + with pytest.raises(TimeoutError, match=r"^recursive scan timed out$"): + multiprocessing_recursive_size( + tmp_path, + worker_count=1, + timeout=0.01, + context=cast(BaseContext, context), + ) + + tasks.cancel_join_thread.assert_called_once_with() + results.cancel_join_thread.assert_called_once_with() + tasks.join_thread.assert_not_called() + results.join_thread.assert_not_called() diff --git a/CH_14_multithreading_and_multiprocessing/exercise_05/README.rst b/CH_14_multithreading_and_multiprocessing/exercise_05/README.rst new file mode 100644 index 0000000..9db070b --- /dev/null +++ b/CH_14_multithreading_and_multiprocessing/exercise_05/README.rst @@ -0,0 +1,53 @@ +Exercise 5: persistent process workers +====================================== + +Question +-------- + +The original exercise question is: + +.. code-block:: text + + 5. Create a pool of workers that keeps waiting for items to be queued through `multiprocessing.Queue()`. + +Solution +-------- + +``PersistentWorkerPool`` starts a fixed set of spawn-process workers for one +top-level callable. ``submit()``, ``result()``, and ``map()`` exchange frozen +``Task``, ``Success``, and ``Failure`` messages through multiprocessing +queues. Worker PIDs stay stable across calls, results preserve input order, +and out-of-order replies are cached by task identifier. Worker exceptions +cross the process boundary as ``WorkerError``. ``close()`` and the context +manager perform idempotent, bounded cooperative shutdown. + +``square()`` is the spawn-picklable demonstration function. + +Dependencies +------------ + +The solution has no third-party runtime dependencies and uses only the Python +3.10 standard library. Tests use the repository's ``pytest`` development +dependency. + +Run the guarded demonstration: + +.. code-block:: console + + uv run python -m CH_14_multithreading_and_multiprocessing.exercise_05.solution_00 + +Run the focused tests: + +.. code-block:: console + + uv run pytest CH_14_multithreading_and_multiprocessing/exercise_05/test_solution_00.py -vv + +References +---------- + +The implementation builds on the chapter's immutable +`thread batch-processing example `_, +`process batch-processing example `_, +and +`multiprocessing pool example `_. +The upstream material is MIT licensed; this solution is self-contained. diff --git a/CH_14_multithreading_and_multiprocessing/exercise_05/solution_00.py b/CH_14_multithreading_and_multiprocessing/exercise_05/solution_00.py index 7097521..ab4fb1f 100644 --- a/CH_14_multithreading_and_multiprocessing/exercise_05/solution_00.py +++ b/CH_14_multithreading_and_multiprocessing/exercise_05/solution_00.py @@ -1,5 +1,333 @@ -# Create a pool of workers that keeps waiting for items to be -# queued through `multiprocessing.Queue()`. +"""Generic persistent multiprocessing workers over typed queue messages.""" -# Please refer to exercise_04/multiprocessing_solution_00.py for -# the solution to the exercise as it already uses this technique. +from __future__ import annotations + +import math +import multiprocessing +import queue +from collections.abc import Callable, Iterable +from dataclasses import dataclass +from multiprocessing.context import BaseContext +from time import monotonic +from types import TracebackType +from typing import Generic, Protocol, TypeVar, cast + +__all__: list[str] = [ + "Failure", + "PersistentWorkerPool", + "Success", + "Task", + "WorkerError", + "square", +] + +InputT = TypeVar("InputT") +ResultT = TypeVar("ResultT") + + +@dataclass(frozen=True) +class Task(Generic[InputT]): + """One identified input queued for a persistent worker.""" + + task_id: int + value: InputT + + +@dataclass(frozen=True) +class Success(Generic[ResultT]): + """Successful identified worker reply.""" + + task_id: int + value: ResultT + + +@dataclass(frozen=True) +class Failure: + """Serializable identified worker failure.""" + + task_id: int + error_type: str + message: str + + +class WorkerError(RuntimeError): + """Raised when a task fails inside a worker process.""" + + def __init__(self, failure: Failure) -> None: + super().__init__( + f"task {failure.task_id}: {failure.error_type}: {failure.message}" + ) + self.failure: Failure = failure + + +class _QueueLike(Protocol): + def put(self, value: object) -> None: ... + + def get(self, timeout: float | None = None) -> object: ... + + +class _ClosableQueue(_QueueLike, Protocol): + def cancel_join_thread(self) -> None: ... + + def close(self) -> None: ... + + def join_thread(self) -> None: ... + + +class _ProcessHandle(Protocol): + pid: int | None + + @property + def exitcode(self) -> int | None: ... + + def start(self) -> None: ... + + def join(self, timeout: float | None = None) -> None: ... + + def is_alive(self) -> bool: ... + + def terminate(self) -> None: ... + + +class _ProcessContext(Protocol): + def Queue(self) -> _ClosableQueue: ... + + def Process( + self, + *, + target: Callable[..., None], + args: tuple[object, ...], + name: str, + ) -> _ProcessHandle: ... + + +def _validated_worker_count(worker_count: int) -> int: + if type(worker_count) is not int: + raise TypeError("worker_count must be an integer") + if worker_count < 1: + raise ValueError("worker_count must be positive") + return worker_count + + +def _validated_timeout(timeout: float) -> float: + raw_timeout: object = cast(object, timeout) + if isinstance(raw_timeout, bool) or not isinstance(raw_timeout, (int, float)): + raise TypeError("timeout must be a number") + value: float = float(raw_timeout) + if not math.isfinite(value) or value <= 0.0: + raise ValueError("timeout must be positive") + return value + + +def _worker_main( + function: Callable[[object], object], + tasks: _QueueLike, + replies: _QueueLike, +) -> None: + while True: + raw_task: object = tasks.get() + if raw_task is None: + return + if not isinstance(raw_task, Task): + raise TypeError("invalid worker task") + task: Task[object] = cast(Task[object], raw_task) + try: + value: object = function(task.value) + except Exception as error: + reply: Success[object] | Failure = Failure( + task.task_id, + type(error).__name__, + str(error), + ) + else: + reply = Success(task.task_id, value) + replies.put(reply) + + +class PersistentWorkerPool(Generic[InputT, ResultT]): + """Persistent spawn workers serving one picklable function.""" + + def __init__( + self, + function: Callable[[InputT], ResultT], + *, + worker_count: int = 2, + context: BaseContext | None = None, + shutdown_timeout: float = 5.0, + ) -> None: + workers: int = _validated_worker_count(worker_count) + self._shutdown_timeout: float = _validated_timeout(shutdown_timeout) + runtime_context: BaseContext = ( + context if context is not None else multiprocessing.get_context("spawn") + ) + typed_context: _ProcessContext = cast(_ProcessContext, runtime_context) + self._tasks: _ClosableQueue = typed_context.Queue() + self._replies: _ClosableQueue = typed_context.Queue() + worker_function: Callable[[object], object] = cast( + Callable[[object], object], + function, + ) + self._workers: tuple[_ProcessHandle, ...] = tuple( + typed_context.Process( + target=_worker_main, + args=(worker_function, self._tasks, self._replies), + name=f"persistent-worker-{index}", + ) + for index in range(workers) + ) + self._next_task_id: int = 0 + self._submitted: set[int] = set() + self._cache: dict[int, Success[object] | Failure] = {} + self._closed: bool = False + process: _ProcessHandle + for process in self._workers: + process.start() + + @property + def workers(self) -> tuple[_ProcessHandle, ...]: + """Return worker handles for lifecycle inspection.""" + + return self._workers + + @property + def worker_pids(self) -> tuple[int, ...]: + """Return stable started worker process identifiers.""" + + pids: list[int] = [] + process: _ProcessHandle + for process in self._workers: + if process.pid is None: + raise RuntimeError("worker has not started") + pids.append(process.pid) + return tuple(pids) + + def submit(self, value: InputT) -> int: + """Queue one value and return its monotonic task identifier.""" + + if self._closed: + raise RuntimeError("pool is closed") + task_id: int = self._next_task_id + self._next_task_id += 1 + self._submitted.add(task_id) + self._tasks.put(Task(task_id, value)) + return task_id + + def result(self, task_id: int, *, timeout: float = 5.0) -> ResultT: + """Return one result, caching replies that arrive out of order.""" + + wait_timeout: float = _validated_timeout(timeout) + if task_id not in self._submitted: + raise KeyError(f"unknown task identifier: {task_id}") + deadline: float = monotonic() + wait_timeout + while task_id not in self._cache: + remaining: float = deadline - monotonic() + if remaining <= 0.0: + raise TimeoutError(f"task {task_id} timed out") + try: + raw_reply: object = self._replies.get(timeout=remaining) + except queue.Empty: + raise TimeoutError(f"task {task_id} timed out") from None + if not isinstance(raw_reply, (Success, Failure)): + raise RuntimeError("worker returned an invalid reply") + self._cache[raw_reply.task_id] = raw_reply + + reply: Success[object] | Failure = self._cache.pop(task_id) + self._submitted.remove(task_id) + if isinstance(reply, Failure): + raise WorkerError(reply) + return cast(ResultT, reply.value) + + def map( + self, + values: Iterable[InputT], + *, + timeout: float = 5.0, + ) -> list[ResultT]: + """Return results in input order using the persistent workers.""" + + wait_timeout: float = _validated_timeout(timeout) + task_ids: list[int] = [self.submit(value) for value in values] + deadline: float = monotonic() + wait_timeout + results: list[ResultT] = [] + task_id: int + for task_id in task_ids: + remaining: float = deadline - monotonic() + if remaining <= 0.0: + raise TimeoutError("worker map timed out") + results.append(self.result(task_id, timeout=remaining)) + return results + + def close(self) -> None: + """Cooperatively stop workers; force cleanup only after timeout.""" + + if self._closed: + return + self._closed = True + for _ in self._workers: + self._tasks.put(None) + + deadline: float = monotonic() + self._shutdown_timeout + process: _ProcessHandle + for process in self._workers: + process.join(max(0.0, deadline - monotonic())) + alive: list[_ProcessHandle] = [ + process for process in self._workers if process.is_alive() + ] + cleanup_error: BaseException | None = None + if alive: + cleanup_error = TimeoutError("worker pool shutdown timed out") + for process in alive: + process.terminate() + for process in alive: + process.join(self._shutdown_timeout) + elif any(process.exitcode != 0 for process in self._workers): + exitcodes: tuple[int | None, ...] = tuple( + process.exitcode for process in self._workers + ) + cleanup_error = ChildProcessError( + f"workers exited unsuccessfully: {exitcodes}" + ) + + if cleanup_error is not None: + self._tasks.cancel_join_thread() + self._replies.cancel_join_thread() + self._tasks.close() + self._replies.close() + if cleanup_error is None: + self._tasks.join_thread() + self._replies.join_thread() + if cleanup_error is not None: + raise cleanup_error + + def __enter__(self) -> PersistentWorkerPool[InputT, ResultT]: + return self + + def __exit__( + self, + exception_type: type[BaseException] | None, + exception: BaseException | None, + traceback: TracebackType | None, + ) -> bool | None: + try: + self.close() + except BaseException: + if exception_type is None: + raise + return None + + +def square(value: int) -> int: + """Return ``value`` squared; top-level for spawn pickling.""" + + return value * value + + +def main() -> None: + """Run a bounded spawn-safe persistent-worker demonstration.""" + + with PersistentWorkerPool(square, worker_count=2) as pool: + print(pool.map([1, 2, 3, 4], timeout=5.0)) + + +if __name__ == "__main__": + main() diff --git a/CH_14_multithreading_and_multiprocessing/exercise_05/test_solution_00.py b/CH_14_multithreading_and_multiprocessing/exercise_05/test_solution_00.py new file mode 100644 index 0000000..079bd0c --- /dev/null +++ b/CH_14_multithreading_and_multiprocessing/exercise_05/test_solution_00.py @@ -0,0 +1,110 @@ +"""Tests for the generic persistent multiprocessing worker pool.""" + +from __future__ import annotations + +import multiprocessing +import sys +from multiprocessing.context import BaseContext +from pathlib import Path +from typing import cast +from unittest.mock import MagicMock + +import pytest + +from .solution_00 import PersistentWorkerPool, WorkerError, square + +_REPOSITORY_ROOT: Path = Path(__file__).parents[2] +if str(_REPOSITORY_ROOT) not in sys.path: + sys.path.insert(0, str(_REPOSITORY_ROOT)) + + +def _child_pids() -> set[int]: + return { + process.pid + for process in multiprocessing.active_children() + if process.pid is not None + } + + +@pytest.mark.timeout(20) +def test_pool_reuses_workers_and_preserves_map_order() -> None: + before: set[int] = _child_pids() + with PersistentWorkerPool( + square, + worker_count=2, + context=multiprocessing.get_context("spawn"), + ) as pool: + first_pids: tuple[int, ...] = pool.worker_pids + assert len(first_pids) == 2 + assert pool.map([3, 1, 2], timeout=5.0) == [9, 1, 4] + assert pool.map([4], timeout=5.0) == [16] + assert pool.worker_pids == first_pids + assert _child_pids() == before + + +def test_result_caches_out_of_order_replies() -> None: + with PersistentWorkerPool(square, worker_count=2) as pool: + first: int = pool.submit(2) + second: int = pool.submit(3) + assert pool.result(second, timeout=5.0) == 9 + assert pool.result(first, timeout=5.0) == 4 + + +def test_pool_propagates_failure_and_worker_continues() -> None: + with PersistentWorkerPool(square, worker_count=1) as pool: + bad_task: int = pool.submit(cast(int, "bad")) + with pytest.raises(WorkerError, match=r"TypeError"): + pool.result(bad_task, timeout=5.0) + assert pool.map([5], timeout=5.0) == [25] + + +def test_close_is_idempotent_and_rejects_submission() -> None: + pool: PersistentWorkerPool[int, int] = PersistentWorkerPool( + square, + worker_count=1, + ) + pool.close() + pool.close() + + with pytest.raises(RuntimeError, match=r"pool is closed"): + pool.submit(2) + assert all(not process.is_alive() for process in pool.workers) + + +@pytest.mark.parametrize("worker_count", [0, -1]) +def test_worker_count_must_be_positive(worker_count: int) -> None: + with pytest.raises(ValueError, match=r"^worker_count must be positive$"): + PersistentWorkerPool(square, worker_count=worker_count) + + +@pytest.mark.parametrize("timeout", [0.0, -1.0]) +def test_result_timeout_must_be_positive(timeout: float) -> None: + with PersistentWorkerPool(square, worker_count=1) as pool: + task_id: int = pool.submit(2) + with pytest.raises(ValueError, match=r"^timeout must be positive$"): + pool.result(task_id, timeout=timeout) + + +def test_forced_shutdown_cancels_queue_feeders() -> None: + tasks: MagicMock = MagicMock() + replies: MagicMock = MagicMock() + process: MagicMock = MagicMock() + process.is_alive.return_value = True + process.exitcode = -15 + context: MagicMock = MagicMock() + context.Queue.side_effect = [tasks, replies] + context.Process.return_value = process + pool: PersistentWorkerPool[int, int] = PersistentWorkerPool( + square, + worker_count=1, + context=cast(BaseContext, context), + shutdown_timeout=0.01, + ) + + with pytest.raises(TimeoutError, match=r"^worker pool shutdown timed out$"): + pool.close() + + tasks.cancel_join_thread.assert_called_once_with() + replies.cancel_join_thread.assert_called_once_with() + tasks.join_thread.assert_not_called() + replies.join_thread.assert_not_called() diff --git a/CH_14_multithreading_and_multiprocessing/exercise_06/README.rst b/CH_14_multithreading_and_multiprocessing/exercise_06/README.rst new file mode 100644 index 0000000..9bebed1 --- /dev/null +++ b/CH_14_multithreading_and_multiprocessing/exercise_06/README.rst @@ -0,0 +1,51 @@ +Exercise 6: allowlisted queue RPC +================================= + +Question +-------- + +The original exercise question is: + +.. code-block:: text + + 6. Convert the pool above into a safe RPC (remote procedure call) type of operation. + +Solution +-------- + +``RpcWorkerPool.call()`` exchanges frozen, identified request and reply +messages with persistent spawn-process workers. ``RPC_METHODS`` is an +immutable allowlist containing only ``add`` and ``repeat``. Workers never +evaluate code, import client-selected modules, dispatch arbitrary attributes, +or accept client-supplied callables. Unknown names raise +``UnknownMethodError``; exceptions from an allowlisted method raise +``RemoteCallError``. Either failure leaves the worker available for later +requests. Shutdown is cooperative, idempotent, and bounded. + +Dependencies +------------ + +The solution has no third-party runtime dependencies and uses only the Python +3.10 standard library. Tests use the repository's ``pytest`` development +dependency. + +Run the guarded demonstration: + +.. code-block:: console + + uv run python -m CH_14_multithreading_and_multiprocessing.exercise_06.solution_00 + +Run the focused tests: + +.. code-block:: console + + uv run pytest CH_14_multithreading_and_multiprocessing/exercise_06/test_solution_00.py -vv + +References +---------- + +The implementation draws on the chapter's immutable remote-multiprocessing +`server example `_ +and +`function registry `_. +The upstream material is MIT licensed; this solution is self-contained. diff --git a/CH_14_multithreading_and_multiprocessing/exercise_06/solution_00.py b/CH_14_multithreading_and_multiprocessing/exercise_06/solution_00.py index ebe5ee6..41095c1 100644 --- a/CH_14_multithreading_and_multiprocessing/exercise_06/solution_00.py +++ b/CH_14_multithreading_and_multiprocessing/exercise_06/solution_00.py @@ -1,50 +1,344 @@ -# Convert the pool above to a safe RPC (remote procedure call) -# type operation. +"""Allowlisted request/reply RPC over persistent multiprocessing queues.""" + +from __future__ import annotations + +import math import multiprocessing +import queue +from collections.abc import Callable, Mapping +from dataclasses import dataclass +from multiprocessing.context import BaseContext +from time import monotonic +from types import MappingProxyType, TracebackType +from typing import Protocol, cast + +__all__: list[str] = [ + "RPC_METHODS", + "RemoteCallError", + "RpcFailure", + "RpcRequest", + "RpcSuccess", + "RpcWorkerPool", + "StopRequest", + "UnknownMethodError", + "add", + "repeat", +] + + +def add(left: int, right: int) -> int: + """Return the sum of two integers.""" + + return left + right + + +def repeat(value: str, *, count: int) -> str: + """Repeat ``value`` ``count`` times.""" + + if count < 0: + raise ValueError("count must not be negative") + return value * count + + +RPC_METHODS: Mapping[str, Callable[..., object]] = MappingProxyType( + { + "add": add, + "repeat": repeat, + } +) + + +@dataclass(frozen=True) +class RpcRequest: + """One immutable allowlisted RPC request.""" + + request_id: int + method: str + args: tuple[object, ...] + kwargs: tuple[tuple[str, object], ...] + + +@dataclass(frozen=True) +class RpcSuccess: + """Successful identified RPC reply.""" + + request_id: int + value: object + + +@dataclass(frozen=True) +class RpcFailure: + """Serializable identified RPC failure.""" + + request_id: int + failure_kind: str + error_type: str + message: str + + +@dataclass(frozen=True) +class StopRequest: + """Cooperative request for one RPC worker to stop.""" + + +class UnknownMethodError(RuntimeError): + """Raised when a method name is outside ``RPC_METHODS``.""" + + +class RemoteCallError(RuntimeError): + """Raised when an allowlisted method fails in a worker.""" + + +class _QueueLike(Protocol): + def put(self, value: object) -> None: ... + + def get(self, timeout: float | None = None) -> object: ... + + +class _ClosableQueue(_QueueLike, Protocol): + def cancel_join_thread(self) -> None: ... + + def close(self) -> None: ... + + def join_thread(self) -> None: ... + + +class _ProcessHandle(Protocol): + pid: int | None + + @property + def exitcode(self) -> int | None: ... + + def start(self) -> None: ... + + def join(self, timeout: float | None = None) -> None: ... + + def is_alive(self) -> bool: ... + + def terminate(self) -> None: ... + + +class _ProcessContext(Protocol): + def Queue(self) -> _ClosableQueue: ... + + def Process( + self, + *, + target: Callable[..., None], + args: tuple[object, ...], + name: str, + ) -> _ProcessHandle: ... + + +def _validated_worker_count(worker_count: int) -> int: + if type(worker_count) is not int: + raise TypeError("worker_count must be an integer") + if worker_count < 1: + raise ValueError("worker_count must be positive") + return worker_count + + +def _validated_timeout(timeout: float) -> float: + raw_timeout: object = cast(object, timeout) + if isinstance(raw_timeout, bool) or not isinstance(raw_timeout, (int, float)): + raise TypeError("timeout must be a number") + value: float = float(raw_timeout) + if not math.isfinite(value) or value <= 0.0: + raise ValueError("timeout must be positive") + return value + + +def _rpc_worker(requests: _QueueLike, replies: _QueueLike) -> None: + while True: + raw_request: object = requests.get() + if isinstance(raw_request, StopRequest): + return + if not isinstance(raw_request, RpcRequest): + raise TypeError("invalid RPC request") + + method: Callable[..., object] | None = RPC_METHODS.get(raw_request.method) + if method is None: + reply: RpcSuccess | RpcFailure = RpcFailure( + raw_request.request_id, + "unknown_method", + "UnknownMethodError", + f"unknown RPC method: {raw_request.method}", + ) + else: + try: + value: object = method( + *raw_request.args, + **dict(raw_request.kwargs), + ) + except Exception as error: + reply = RpcFailure( + raw_request.request_id, + "remote_call", + type(error).__name__, + str(error), + ) + else: + reply = RpcSuccess(raw_request.request_id, value) + replies.put(reply) + + +class RpcWorkerPool: + """Persistent spawn workers serving only immutable ``RPC_METHODS``.""" + + def __init__( + self, + *, + worker_count: int = 2, + context: BaseContext | None = None, + shutdown_timeout: float = 5.0, + ) -> None: + workers: int = _validated_worker_count(worker_count) + self._shutdown_timeout: float = _validated_timeout(shutdown_timeout) + runtime_context: BaseContext = ( + context if context is not None else multiprocessing.get_context("spawn") + ) + typed_context: _ProcessContext = cast(_ProcessContext, runtime_context) + self._requests: _ClosableQueue = typed_context.Queue() + self._replies: _ClosableQueue = typed_context.Queue() + self._workers: tuple[_ProcessHandle, ...] = tuple( + typed_context.Process( + target=_rpc_worker, + args=(self._requests, self._replies), + name=f"rpc-worker-{index}", + ) + for index in range(workers) + ) + self._next_request_id: int = 0 + self._cache: dict[int, RpcSuccess | RpcFailure] = {} + self._closed: bool = False + process: _ProcessHandle + for process in self._workers: + process.start() + + @property + def worker_pids(self) -> tuple[int, ...]: + """Return stable worker identifiers.""" + + pids: list[int] = [] + process: _ProcessHandle + for process in self._workers: + if process.pid is None: + raise RuntimeError("worker has not started") + pids.append(process.pid) + return tuple(pids) -WORKERS = 4 + def call( + self, + method: str, + *args: object, + timeout: float = 5.0, + **kwargs: object, + ) -> object: + """Invoke one allowlisted method and return its remote value.""" + if self._closed: + raise RuntimeError("RPC pool is closed") + raw_method: object = cast(object, method) + if not isinstance(raw_method, str): + raise TypeError("method must be a string") + wait_timeout: float = _validated_timeout(timeout) + request_id: int = self._next_request_id + self._next_request_id += 1 + request: RpcRequest = RpcRequest( + request_id, + raw_method, + tuple(args), + tuple(sorted(kwargs.items())), + ) + self._requests.put(request) -def say(msg): - print(f'Saying: {msg}') + deadline: float = monotonic() + wait_timeout + while request_id not in self._cache: + remaining: float = deadline - monotonic() + if remaining <= 0.0: + raise TimeoutError(f"RPC request {request_id} timed out") + try: + raw_reply: object = self._replies.get(timeout=remaining) + except queue.Empty: + raise TimeoutError(f"RPC request {request_id} timed out") from None + if not isinstance(raw_reply, (RpcSuccess, RpcFailure)): + raise RuntimeError("worker returned an invalid RPC reply") + self._cache[raw_reply.request_id] = raw_reply + reply: RpcSuccess | RpcFailure = self._cache.pop(request_id) + if isinstance(reply, RpcSuccess): + return reply.value + detail: str = f"request {request_id}: {reply.error_type}: {reply.message}" + if reply.failure_kind == "unknown_method": + raise UnknownMethodError(detail) + raise RemoteCallError(detail) -# Explicitly define the RPC methods to make this safer -RPC_METHODS = dict(say=say) + def close(self) -> None: + """Cooperatively stop RPC workers with bounded failure cleanup.""" + if self._closed: + return + self._closed = True + for _ in self._workers: + self._requests.put(StopRequest()) -class RpcProcess(multiprocessing.Process): - def __init__(self, queue: multiprocessing.Queue): - super().__init__() - self.queue = queue + deadline: float = monotonic() + self._shutdown_timeout + process: _ProcessHandle + for process in self._workers: + process.join(max(0.0, deadline - monotonic())) + alive: list[_ProcessHandle] = [ + process for process in self._workers if process.is_alive() + ] + cleanup_error: BaseException | None = None + if alive: + cleanup_error = TimeoutError("RPC worker shutdown timed out") + for process in alive: + process.terminate() + for process in alive: + process.join(self._shutdown_timeout) + elif any(process.exitcode != 0 for process in self._workers): + exitcodes: tuple[int | None, ...] = tuple( + process.exitcode for process in self._workers + ) + cleanup_error = ChildProcessError( + f"RPC workers exited unsuccessfully: {exitcodes}" + ) - def run(self): - while True: - func_name, args, kwargs = self.queue.get() - func = RPC_METHODS[func_name] - func(*args, **kwargs) - self.queue.task_done() + if cleanup_error is not None: + self._requests.cancel_join_thread() + self._replies.cancel_join_thread() + self._requests.close() + self._replies.close() + if cleanup_error is None: + self._requests.join_thread() + self._replies.join_thread() + if cleanup_error is not None: + raise cleanup_error + def __enter__(self) -> RpcWorkerPool: + return self -def main(): - q = multiprocessing.JoinableQueue() - q.put(('say', ('hello',), {})) - q.put(('say', ('world',), {})) - # This should result in an error because this is not a valid - # RPC method - q.put(('non-existing-method', (), {})) + def __exit__( + self, + exception_type: type[BaseException] | None, + exception: BaseException | None, + traceback: TracebackType | None, + ) -> bool | None: + try: + self.close() + except BaseException: + if exception_type is None: + raise + return None - for _ in range(WORKERS): - p = RpcProcess(q) - p.start() - q.join() - q.close() +def main() -> None: + """Run a bounded allowlisted RPC demonstration.""" - for p in multiprocessing.active_children(): - p.terminate() - p.join() + with RpcWorkerPool(worker_count=2) as rpc: + print(rpc.call("add", 20, 22, timeout=5.0)) + print(rpc.call("repeat", "rpc", count=2, timeout=5.0)) -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/CH_14_multithreading_and_multiprocessing/exercise_06/test_solution_00.py b/CH_14_multithreading_and_multiprocessing/exercise_06/test_solution_00.py new file mode 100644 index 0000000..a518291 --- /dev/null +++ b/CH_14_multithreading_and_multiprocessing/exercise_06/test_solution_00.py @@ -0,0 +1,101 @@ +"""Tests for allowlisted queue RPC workers.""" + +from __future__ import annotations + +import multiprocessing +import sys +from multiprocessing.context import BaseContext +from pathlib import Path +from typing import cast +from unittest.mock import MagicMock + +import pytest + +from .solution_00 import ( + RPC_METHODS, + RemoteCallError, + RpcWorkerPool, + UnknownMethodError, +) + +_REPOSITORY_ROOT: Path = Path(__file__).parents[2] +if str(_REPOSITORY_ROOT) not in sys.path: + sys.path.insert(0, str(_REPOSITORY_ROOT)) + + +def _child_pids() -> set[int]: + return { + process.pid + for process in multiprocessing.active_children() + if process.pid is not None + } + + +def test_rpc_calls_only_registered_methods() -> None: + assert tuple(RPC_METHODS) == ("add", "repeat") + with RpcWorkerPool(worker_count=2) as rpc: + assert rpc.call("add", 2, 5, timeout=5.0) == 7 + assert rpc.call("repeat", "ab", count=3, timeout=5.0) == "ababab" + + +@pytest.mark.parametrize("method", ["missing", "__import__", "__class__"]) +def test_unknown_method_does_not_kill_worker(method: str) -> None: + with RpcWorkerPool(worker_count=1) as rpc: + with pytest.raises(UnknownMethodError, match=method): + rpc.call(method, timeout=5.0) + assert rpc.call("add", 1, 1, timeout=5.0) == 2 + + +def test_remote_argument_error_does_not_kill_worker() -> None: + with RpcWorkerPool(worker_count=1) as rpc: + with pytest.raises(RemoteCallError, match=r"TypeError"): + rpc.call("add", "one", 2, timeout=5.0) + with pytest.raises(RemoteCallError, match=r"ValueError"): + rpc.call("repeat", "x", count=-1, timeout=5.0) + assert rpc.call("repeat", "x", count=2, timeout=5.0) == "xx" + + +def test_rpc_method_mapping_is_immutable() -> None: + with pytest.raises(TypeError): + RPC_METHODS["missing"] = lambda: None # type: ignore[index] + + +@pytest.mark.timeout(20) +def test_rpc_context_reaps_children() -> None: + before: set[int] = _child_pids() + with RpcWorkerPool( + worker_count=2, + context=multiprocessing.get_context("spawn"), + ) as rpc: + assert rpc.call("add", 20, 22, timeout=5.0) == 42 + assert _child_pids() == before + + +@pytest.mark.parametrize("worker_count", [0, -1]) +def test_worker_count_must_be_positive(worker_count: int) -> None: + with pytest.raises(ValueError, match=r"^worker_count must be positive$"): + RpcWorkerPool(worker_count=worker_count) + + +def test_forced_shutdown_cancels_queue_feeders() -> None: + requests: MagicMock = MagicMock() + replies: MagicMock = MagicMock() + process: MagicMock = MagicMock() + process.is_alive.return_value = True + process.exitcode = -15 + context: MagicMock = MagicMock() + context.Queue.side_effect = [requests, replies] + context.Process.return_value = process + rpc: RpcWorkerPool = RpcWorkerPool( + worker_count=1, + context=cast(BaseContext, context), + shutdown_timeout=0.01, + ) + + with pytest.raises(TimeoutError, match=r"^RPC worker shutdown timed out$"): + rpc.close() + + requests.cancel_join_thread.assert_called_once_with() + replies.cancel_join_thread.assert_called_once_with() + requests.join_thread.assert_not_called() + replies.join_thread.assert_not_called() diff --git a/CH_14_multithreading_and_multiprocessing/exercise_07/README.rst b/CH_14_multithreading_and_multiprocessing/exercise_07/README.rst new file mode 100644 index 0000000..d10cca9 --- /dev/null +++ b/CH_14_multithreading_and_multiprocessing/exercise_07/README.rst @@ -0,0 +1,48 @@ +Exercise 7: bounded parallel merge sort +======================================= + +Question +-------- + +The original exercise question is: + +.. code-block:: text + + 7. Apply your functional skills to calculate something in a parallel way. Perhaps parallel sorting? + +Solution +-------- + +``merge()``, ``split()``, and ``merge_sort()`` provide deterministic, +non-mutating sequential building blocks. ``parallel_merge_sort()`` validates +integer-only input, creates at most one non-empty chunk per input value, and +uses no more processes than the requested bound or input length. Spawn is the +default process context. Sorted chunks are merged in deterministic balanced +rounds. Empty input returns without starting a worker. +``multiprocessing_merge_sort()`` is the compatibility wrapper. + +Dependencies +------------ + +The solution has no third-party runtime dependencies and uses only the Python +3.10 standard library. Tests use the repository's ``pytest`` development +dependency. + +Run the guarded demonstration: + +.. code-block:: console + + uv run python -m CH_14_multithreading_and_multiprocessing.exercise_07.solution_00 + +Run the focused tests: + +.. code-block:: console + + uv run pytest CH_14_multithreading_and_multiprocessing/exercise_07/test_solution_00.py -vv + +References +---------- + +The implementation builds on the chapter's immutable +`multiprocessing pool example `_. +The upstream material is MIT licensed; this solution is self-contained. diff --git a/CH_14_multithreading_and_multiprocessing/exercise_07/solution_00.py b/CH_14_multithreading_and_multiprocessing/exercise_07/solution_00.py index 3a25ba8..2065e62 100644 --- a/CH_14_multithreading_and_multiprocessing/exercise_07/solution_00.py +++ b/CH_14_multithreading_and_multiprocessing/exercise_07/solution_00.py @@ -1,105 +1,152 @@ -# Apply your functional programming skills and calculate -# something in a parallel way. Perhaps parallel sorting? +"""Deterministic sequential and bounded spawn-process merge sort.""" + +from __future__ import annotations + import multiprocessing -import random +from collections.abc import Sequence +from concurrent.futures import ProcessPoolExecutor +from multiprocessing.context import BaseContext +from typing import cast + +__all__: list[str] = [ + "merge", + "merge_sort", + "multiprocessing_merge_sort", + "parallel_merge_sort", + "split", +] + + +def _validated_values(values: Sequence[int]) -> list[int]: + raw_values: object = cast(object, values) + if not isinstance(raw_values, Sequence): + raise TypeError("values must be an integer sequence") + candidate_values: Sequence[object] = cast(Sequence[object], raw_values) + copied: list[int] = [] + value: object + for value in candidate_values: + if type(value) is not int: + raise TypeError("values must contain only integers") + copied.append(value) + return copied + + +def merge(left: Sequence[int], right: Sequence[int]) -> list[int]: + """Merge two sorted integer sequences without mutating either.""" + + left_values: list[int] = _validated_values(left) + right_values: list[int] = _validated_values(right) + merged: list[int] = [] + left_index: int = 0 + right_index: int = 0 + while left_index < len(left_values) and right_index < len(right_values): + if left_values[left_index] <= right_values[right_index]: + merged.append(left_values[left_index]) + left_index += 1 + else: + merged.append(right_values[right_index]) + right_index += 1 + merged.extend(left_values[left_index:]) + merged.extend(right_values[right_index:]) + return merged + + +def split(values: Sequence[int], size: int) -> list[list[int]]: + """Split an integer sequence into at most ``size`` balanced chunks.""" + + if type(size) is not int: + raise TypeError("size must be an integer") + if size < 1: + raise ValueError("size must be positive") + copied: list[int] = _validated_values(values) + if not copied: + return [] + chunk_count: int = min(size, len(copied)) + base_size, remainder = divmod(len(copied), chunk_count) + chunks: list[list[int]] = [] + start: int = 0 + index: int + for index in range(chunk_count): + chunk_size: int = base_size + (1 if index < remainder else 0) + stop: int = start + chunk_size + chunks.append(copied[start:stop]) + start = stop + return chunks + + +def _merge_sort_validated(values: list[int]) -> list[int]: + if len(values) < 2: + return values.copy() + middle: int = len(values) // 2 + left: list[int] = _merge_sort_validated(values[:middle]) + right: list[int] = _merge_sort_validated(values[middle:]) + return merge(left, right) + -# Since sorting is CPU limited we should not go above the number of -# CPU cores -WORKERS = multiprocessing.cpu_count() +def merge_sort(values: Sequence[int]) -> list[int]: + """Return a non-mutating sequential merge sort.""" + return _merge_sort_validated(_validated_values(values)) -def merge_sort(data): - if len(data) <= 1: - return data - middle = len(data) // 2 - left = merge_sort(data[:middle]) - right = merge_sort(data[middle:]) +def _merge_pair(pair: tuple[list[int], list[int]]) -> list[int]: + left, right = pair return merge(left, right) -def merge(left, right): - ''' - Merge two sorted lists into one sorted list - - >>> merge([1, 3, 5], [2, 4, 6]) - [1, 2, 3, 4, 5, 6] - >>> merge([1, 3, 5], [2, 4, 6, 7]) - [1, 2, 3, 4, 5, 6, 7] - >>> merge([1, 2, 3], [1, 2, 3]) - [1, 1, 2, 2, 3, 3] - ''' - result = [] - left_index = right_index = 0 - - # When using iterators, we can avoid the IndexError of - # accessing a non-existing element by using the `next` - # function. This will raise a `StopIteration` error if - # there are no more elements to iterate over. - left_next = left[left_index] - right_next = right[right_index] - - while True: - try: - if left_next <= right_next: - result.append(left_next) - left_index += 1 - left_next = left[left_index] - else: - result.append(right_next) - right_index += 1 - right_next = right[right_index] - except IndexError: - # If we get an IndexError, it means that we have - # reached the end of one of the lists. We can - # simply extend the result with the remaining - # elements and break out of the loop. - result.extend(left[left_index:] or right[right_index:]) - break - - return result - - -def split(data, size=WORKERS): - ''' - Split a list into `size` different chunks so that each chunk - can be processed in parallel. - ''' - chunk_size = len(data) // size - return [data[i:i + chunk_size] for i in - range(0, len(data), chunk_size)] - - -def multiprocessing_merge_sort(data): - # Split the data into chunks - with multiprocessing.Pool(processes=WORKERS) as pool: - chunks = split(data, WORKERS) - sorted_chunks = pool.map(merge_sort, chunks) - - # Merge the chunks - i = 0 +def parallel_merge_sort( + values: Sequence[int], + *, + max_workers: int | None = None, + context: BaseContext | None = None, +) -> list[int]: + """Sort integers using bounded spawn-process map and pairwise merges.""" + + if max_workers is not None: + if type(max_workers) is not int: + raise TypeError("max_workers must be an integer") + if max_workers < 1: + raise ValueError("max_workers must be positive") + copied: list[int] = _validated_values(values) + if not copied: + return [] + + requested_workers: int = ( + max_workers if max_workers is not None else multiprocessing.cpu_count() + ) + worker_count: int = min(requested_workers, len(copied)) + chunks: list[list[int]] = split(copied, worker_count) + process_context: BaseContext = ( + context if context is not None else multiprocessing.get_context("spawn") + ) + with ProcessPoolExecutor( + max_workers=worker_count, + mp_context=process_context, + ) as executor: + sorted_chunks: list[list[int]] = list(executor.map(merge_sort, chunks)) while len(sorted_chunks) > 1: - # zip the chunks into pairs - pairs = zip(sorted_chunks[::2], sorted_chunks[1::2]) - # merge the pairs in parallel - merged_chunks = pool.starmap(merge, pairs) + pairs: list[tuple[list[int], list[int]]] = list( + zip(sorted_chunks[::2], sorted_chunks[1::2], strict=False) + ) + merged: list[list[int]] = list(executor.map(_merge_pair, pairs)) + if len(sorted_chunks) % 2: + merged.append(sorted_chunks[-1]) + sorted_chunks = merged + return sorted_chunks[0] - # If we have an odd number of chunks, we need to - # add the last chunk to the merged chunks - if len(sorted_chunks) % 2 == 1: - merged_chunks.append(sorted_chunks[-1]) - sorted_chunks = merged_chunks +def multiprocessing_merge_sort(values: Sequence[int]) -> list[int]: + """Compatibility wrapper using bounded spawn-process merge sort.""" + + return parallel_merge_sort(values) - return sorted_chunks[0] +def main() -> None: + """Run a deterministic bounded parallel-sort demonstration.""" -def main(): - data = random.sample(range(1000), 100) - sorted_data = multiprocessing_merge_sort(data) - # Verify that the data is sorted correctly - assert sorted_data == sorted(data) + values: list[int] = [9, 3, -1, 7, 3, 0] + print(parallel_merge_sort(values, max_workers=2)) -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/CH_14_multithreading_and_multiprocessing/exercise_07/test_solution_00.py b/CH_14_multithreading_and_multiprocessing/exercise_07/test_solution_00.py new file mode 100644 index 0000000..23aaf2c --- /dev/null +++ b/CH_14_multithreading_and_multiprocessing/exercise_07/test_solution_00.py @@ -0,0 +1,101 @@ +"""Tests for bounded deterministic parallel merge sort.""" + +from __future__ import annotations + +import multiprocessing +import sys +from pathlib import Path + +import pytest + +from .solution_00 import ( + merge, + merge_sort, + multiprocessing_merge_sort, + parallel_merge_sort, + split, +) + +_REPOSITORY_ROOT: Path = Path(__file__).parents[2] +if str(_REPOSITORY_ROOT) not in sys.path: + sys.path.insert(0, str(_REPOSITORY_ROOT)) + + +@pytest.mark.parametrize( + ("left", "right", "expected"), + [ + ([], [], []), + ([1, 3], [2, 4], [1, 2, 3, 4]), + ([1, 1], [1], [1, 1, 1]), + ([-3, 8], [-4, 9], [-4, -3, 8, 9]), + ], +) +def test_merge( + left: list[int], + right: list[int], + expected: list[int], +) -> None: + original_left: list[int] = left.copy() + original_right: list[int] = right.copy() + + assert merge(left, right) == expected + assert left == original_left + assert right == original_right + + +def test_split_is_balanced_and_never_creates_empty_chunks() -> None: + assert split([], 4) == [] + assert split([3, 2], 8) == [[3], [2]] + assert split([1, 2, 3, 4, 5], 2) == [[1, 2, 3], [4, 5]] + + +@pytest.mark.parametrize("size", [0, -1]) +def test_split_size_must_be_positive(size: int) -> None: + with pytest.raises(ValueError, match=r"^size must be positive$"): + split([1], size) + + +@pytest.mark.timeout(20) +def test_parallel_merge_sort_matches_builtin_without_mutation() -> None: + values: list[int] = [5, -1, 5, 0, 9, 2, -7] + + assert parallel_merge_sort( + values, + max_workers=3, + context=multiprocessing.get_context("spawn"), + ) == sorted(values) + assert multiprocessing_merge_sort(values) == sorted(values) + assert values == [5, -1, 5, 0, 9, 2, -7] + + +def test_empty_parallel_sort_does_not_spawn_children() -> None: + before: set[int] = { + process.pid + for process in multiprocessing.active_children() + if process.pid is not None + } + + assert parallel_merge_sort([], max_workers=8) == [] + assert { + process.pid + for process in multiprocessing.active_children() + if process.pid is not None + } == before + + +def test_sequential_merge_sort_handles_boundaries() -> None: + assert merge_sort([]) == [] + assert merge_sort([1]) == [1] + assert merge_sort((3, 1, 2)) == [1, 2, 3] + + +@pytest.mark.parametrize("max_workers", [0, -1]) +def test_parallel_worker_count_must_be_positive(max_workers: int) -> None: + with pytest.raises(ValueError, match=r"^max_workers must be positive$"): + parallel_merge_sort([1], max_workers=max_workers) + + +@pytest.mark.parametrize("values", [[1, True], [1, "2"]]) +def test_sort_rejects_non_integer_values(values: list[object]) -> None: + with pytest.raises(TypeError, match=r"values must contain only integers"): + parallel_merge_sort(values) # type: ignore[arg-type] diff --git a/CH_15_scientific_python/README.rst b/CH_15_scientific_python/README.rst index 28fedfb..8099e4e 100644 --- a/CH_15_scientific_python/README.rst +++ b/CH_15_scientific_python/README.rst @@ -1,5 +1,5 @@ Chapter 15 - scientific python -======================================================================================================================= +============================== -1. Create a datashader plot. -2. Make the datashater plot interactive using a Jupyter notebook. +1. `Create a datashader plot. `_ +2. `Make the datashater plot interactive using a Jupyter notebook. `_ diff --git a/CH_15_scientific_python/exercise_01/README.rst b/CH_15_scientific_python/exercise_01/README.rst new file mode 100644 index 0000000..f573299 --- /dev/null +++ b/CH_15_scientific_python/exercise_01/README.rst @@ -0,0 +1,56 @@ +Exercise 1: deterministic Datashader plot +========================================= + +Question +-------- + +.. code-block:: text + + Create a datashader plot. + +Solution +-------- + +``points.csv`` is an exact six-row local fixture with ``x``, ``y``, and +``category`` columns. ``load_points`` rejects any other column layout, empty +input, and nonnumeric, null, or non-finite ``x`` and ``y`` coordinates before +Datashader can silently omit them. ``aggregate_points`` uses fixed horizontal +and vertical ranges, fixed output dimensions, and ``datashader.count()`` so +duplicate points remain visible in the aggregate. ``render_points`` applies +one fixed linear color map, making repeated rendering deterministic. + +``save_plot`` creates missing destination directories and writes a PNG through +Pillow. The guarded demonstration writes ``datashader_plot.png`` beside the +solution; generated images are not repository artifacts. + +Dependencies +------------ + +Python 3.10 or newer is required. The repository's ``scientific`` dependency +group supplies the pandas, Datashader, and Pillow runtime tools. The ``dev`` +dependency group supplies pytest. All input data is committed locally; +execution needs no network service. + +Run +--- + +Run the guarded demonstration from the repository root: + +.. code-block:: console + + $ uv run --group scientific python -m CH_15_scientific_python.exercise_01.solution_00 + +Run the focused tests: + +.. code-block:: console + + $ uv run --group scientific pytest CH_15_scientific_python/exercise_01/test_solution_00.py -vv + +Reference +--------- + +The implementation is informed by the chapter's immutable +`Datashader notebook +`_. +The upstream material is MIT licensed; this exercise solution is +self-contained. diff --git a/CH_15_scientific_python/exercise_01/__init__.py b/CH_15_scientific_python/exercise_01/__init__.py new file mode 100644 index 0000000..aedffd4 --- /dev/null +++ b/CH_15_scientific_python/exercise_01/__init__.py @@ -0,0 +1 @@ +"""Exercise 1: deterministic Datashader plotting.""" diff --git a/CH_15_scientific_python/exercise_01/points.csv b/CH_15_scientific_python/exercise_01/points.csv new file mode 100644 index 0000000..fee02eb --- /dev/null +++ b/CH_15_scientific_python/exercise_01/points.csv @@ -0,0 +1,7 @@ +x,y,category +-1.0,-1.0,a +-0.5,0.5,a +0.0,0.0,b +0.5,-0.5,b +1.0,1.0,c +1.0,1.0,c diff --git a/CH_15_scientific_python/exercise_01/solution_00.py b/CH_15_scientific_python/exercise_01/solution_00.py new file mode 100644 index 0000000..e4718b6 --- /dev/null +++ b/CH_15_scientific_python/exercise_01/solution_00.py @@ -0,0 +1,142 @@ +"""Create deterministic Datashader plots from a local point fixture.""" + +from __future__ import annotations + +from collections.abc import Callable +from math import isfinite +from numbers import Real +from pathlib import Path +from typing import cast + +import datashader as ds # type: ignore[import-untyped] +import datashader.transfer_functions as tf # type: ignore[import-untyped] +import pandas as pd # type: ignore[import-untyped] +import xarray as xr + +__all__: list[str] = [ + "aggregate_points", + "load_points", + "render_points", + "save_plot", +] + +X_RANGE: tuple[float, float] = (-1.1, 1.1) +Y_RANGE: tuple[float, float] = (-1.1, 1.1) +COLOR_MAP: tuple[str, ...] = ("#0b132b", "#3a86ff", "#ffbe0b") +READ_CSV: Callable[[Path], pd.DataFrame] = cast( + Callable[[Path], pd.DataFrame], + pd.read_csv, # pyright: ignore[reportUnknownMemberType] +) + + +def _coordinates_are_valid(frame: pd.DataFrame) -> bool: + for column_name in ("x", "y"): + values: list[object] = cast( + list[object], + frame[column_name].tolist(), # pyright: ignore[reportUnknownMemberType] + ) + for value in values: + if ( + isinstance(value, bool) + or not isinstance(value, Real) + or not isfinite(float(value)) + ): + return False + return True + + +def load_points(path: Path) -> pd.DataFrame: + """Load the exact point-table schema from ``path``.""" + + frame: pd.DataFrame = READ_CSV(path) + if list(frame.columns) != ["x", "y", "category"]: + raise ValueError("point data must contain exactly x, y, category columns") + if frame.empty: + raise ValueError("point data must not be empty") + if not _coordinates_are_valid(frame): + raise ValueError("point coordinates must be numeric and finite") + return frame + + +def aggregate_points( + frame: pd.DataFrame, + *, + width: int = 256, + height: int = 256, +) -> xr.DataArray: + """Count points on a fixed-range raster.""" + + if width < 1 or height < 1: + raise ValueError("plot dimensions must be positive") + canvas: ds.Canvas = ds.Canvas( + plot_width=width, + plot_height=height, + x_range=X_RANGE, + y_range=Y_RANGE, + ) + aggregate: xr.DataArray = cast( + xr.DataArray, + canvas.points( # pyright: ignore[reportUnknownMemberType] + frame, + "x", + "y", + agg=ds.count(), + ), + ) + return aggregate + + +def render_points( + frame: pd.DataFrame, + *, + width: int = 256, + height: int = 256, +) -> tf.Image: + """Render a point count with a fixed linear color map.""" + + aggregate: xr.DataArray = aggregate_points( + frame, + width=width, + height=height, + ) + image: tf.Image = cast( + tf.Image, + tf.shade( # pyright: ignore[reportUnknownMemberType] + aggregate, + cmap=list(COLOR_MAP), + how="linear", + ), + ) + return image + + +def save_plot( + source: Path, + destination: Path, + *, + width: int = 256, + height: int = 256, +) -> Path: + """Render ``source`` and save a PNG to ``destination``.""" + + image: tf.Image = render_points( + load_points(source), + width=width, + height=height, + ) + destination.parent.mkdir(parents=True, exist_ok=True) + image.to_pil().save(destination, format="PNG") + return destination + + +def main() -> None: + """Write a deterministic demonstration plot beside this module.""" + + source: Path = Path(__file__).with_name("points.csv") + destination: Path = Path(__file__).with_name("datashader_plot.png") + saved: Path = save_plot(source, destination) + print(saved) + + +if __name__ == "__main__": + main() diff --git a/CH_15_scientific_python/exercise_01/test_solution_00.py b/CH_15_scientific_python/exercise_01/test_solution_00.py new file mode 100644 index 0000000..c258edc --- /dev/null +++ b/CH_15_scientific_python/exercise_01/test_solution_00.py @@ -0,0 +1,128 @@ +"""Tests for deterministic Datashader plotting.""" + +from __future__ import annotations + +from pathlib import Path +from typing import cast + +import pandas as pd # type: ignore[import-untyped] +import pytest + +from .solution_00 import aggregate_points, load_points, render_points, save_plot + +FIXTURE: Path = Path(__file__).with_name("points.csv") +EXPECTED_FIXTURE: str = """x,y,category +-1.0,-1.0,a +-0.5,0.5,a +0.0,0.0,b +0.5,-0.5,b +1.0,1.0,c +1.0,1.0,c +""" + + +def test_fixture_and_loaded_points_are_exact() -> None: + assert FIXTURE.read_text(encoding="utf-8") == EXPECTED_FIXTURE + + frame: pd.DataFrame = load_points(FIXTURE) + + assert list(frame.columns) == ["x", "y", "category"] + assert frame.shape == (6, 3) + x_values: list[float] = cast( + list[float], + frame["x"].tolist(), # pyright: ignore[reportUnknownMemberType] + ) + y_values: list[float] = cast( + list[float], + frame["y"].tolist(), # pyright: ignore[reportUnknownMemberType] + ) + categories: list[str] = cast( + list[str], + frame["category"].tolist(), # pyright: ignore[reportUnknownMemberType] + ) + assert x_values == [-1.0, -0.5, 0.0, 0.5, 1.0, 1.0] + assert y_values == [-1.0, 0.5, 0.0, -0.5, 1.0, 1.0] + assert categories == ["a", "a", "b", "b", "c", "c"] + + +def test_aggregate_uses_fixed_shape_and_counts_every_point() -> None: + frame: pd.DataFrame = load_points(FIXTURE) + + aggregate = aggregate_points(frame, width=8, height=6) + + assert aggregate.shape == (6, 8) + assert int(aggregate.sum()) == 6 + assert int(aggregate.max()) == 2 + + +def test_render_is_deterministic() -> None: + frame: pd.DataFrame = load_points(FIXTURE) + + first = render_points(frame, width=16, height=12).to_pil() + second = render_points(frame, width=16, height=12).to_pil() + + assert first.size == (16, 12) + assert first.tobytes() == second.tobytes() + + +def test_save_plot_creates_parent_directories_and_writes_png(tmp_path: Path) -> None: + destination: Path = tmp_path / "nested" / "plot.png" + + result: Path = save_plot(FIXTURE, destination, width=16, height=12) + + assert result == destination + assert destination.read_bytes().startswith(b"\x89PNG\r\n\x1a\n") + + +@pytest.mark.parametrize( + ("width", "height"), + [(0, 8), (8, 0), (-1, 8)], +) +def test_dimensions_must_be_positive(width: int, height: int) -> None: + frame: pd.DataFrame = load_points(FIXTURE) + + with pytest.raises(ValueError, match=r"^plot dimensions must be positive$"): + aggregate_points(frame, width=width, height=height) + + +def test_load_points_rejects_wrong_columns(tmp_path: Path) -> None: + source: Path = tmp_path / "wrong.csv" + source.write_text("x,y,label\n1,2,a\n", encoding="utf-8") + + with pytest.raises( + ValueError, + match=r"^point data must contain exactly x, y, category columns$", + ): + load_points(source) + + +def test_load_points_rejects_empty_data(tmp_path: Path) -> None: + source: Path = tmp_path / "empty.csv" + source.write_text("x,y,category\n", encoding="utf-8") + + with pytest.raises(ValueError, match=r"^point data must not be empty$"): + load_points(source) + + +@pytest.mark.parametrize( + "row", + [ + "NaN,0,a", + "inf,0,a", + "-inf,0,a", + "not-a-number,0,a", + "0,,a", + ], +) +def test_load_points_rejects_invalid_coordinates( + tmp_path: Path, + row: str, +) -> None: + source: Path = tmp_path / "invalid-coordinate.csv" + source.write_text(f"x,y,category\n{row}\n", encoding="utf-8") + + with pytest.raises( + ValueError, + match=r"^point coordinates must be numeric and finite$", + ): + load_points(source) diff --git a/CH_15_scientific_python/exercise_02/README.rst b/CH_15_scientific_python/exercise_02/README.rst new file mode 100644 index 0000000..5f87bab --- /dev/null +++ b/CH_15_scientific_python/exercise_02/README.rst @@ -0,0 +1,67 @@ +Exercise 2: interactive Datashader notebook +============================================ + +Question +-------- + +.. code-block:: text + + Make the datashater plot interactive using a Jupyter notebook. + +Solution +-------- + +``build_interactive_plot`` reuses exercise 1's deterministic renderer behind +an ``ipywidgets.IntSlider``. Resolution is bounded from 32 through 256 pixels, +in steps of 32, with a default of 64. A value-change callback refreshes an +``ipywidgets.Output`` without duplicating output. Call +``dispose_interactive_plot`` when finished to unregister the callback, close +the slider, output, and container, and release the captured DataFrame. + +``datashader_interactive.ipynb`` loads only the committed six-row fixture, +builds the widget, and asserts the fixture shape and default resolution. It +discovers the repository root from the current directory and its parents, +adding that root to ``sys.path`` only when needed. It is a clean nbformat 4 +notebook with a Python 3 kernelspec, deterministic cell IDs, no execution +counts, no outputs, and no saved widget state. Tests execute in-memory copies +from both the repository root and notebook directory through nbclient with a +bounded timeout; no network access is required. + +Dependencies +------------ + +Python 3.10 or newer is required. The repository's ``scientific`` dependency +group supplies pandas, Datashader, Pillow, ipywidgets, nbformat, nbclient, +ipykernel, jupyter-client, and JupyterLab. The ``dev`` dependency group +supplies pytest and pytest-timeout. + +Run +--- + +Open the notebook from the repository root: + +.. code-block:: console + + $ uv run --group scientific jupyter lab CH_15_scientific_python/exercise_02/datashader_interactive.ipynb + +The notebook also executes when Jupyter starts in its own directory: + +.. code-block:: console + + $ cd CH_15_scientific_python/exercise_02 + $ uv run --group scientific jupyter lab datashader_interactive.ipynb + +Run the focused tests, including headless notebook execution: + +.. code-block:: console + + $ uv run --group scientific pytest CH_15_scientific_python/exercise_02/test_solution_00.py -vv + +Reference +--------- + +The implementation is informed by the chapter's immutable +`Datashader notebook +`_. +The upstream material is MIT licensed; this exercise solution is +self-contained. diff --git a/CH_15_scientific_python/exercise_02/__init__.py b/CH_15_scientific_python/exercise_02/__init__.py new file mode 100644 index 0000000..ea5a7ad --- /dev/null +++ b/CH_15_scientific_python/exercise_02/__init__.py @@ -0,0 +1 @@ +"""Exercise 2: interactive Datashader plotting.""" diff --git a/CH_15_scientific_python/exercise_02/datashader_interactive.ipynb b/CH_15_scientific_python/exercise_02/datashader_interactive.ipynb new file mode 100644 index 0000000..a45e3b0 --- /dev/null +++ b/CH_15_scientific_python/exercise_02/datashader_interactive.ipynb @@ -0,0 +1,75 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "id": "imports", + "metadata": {}, + "outputs": [], + "source": [ + "from pathlib import Path\n", + "import sys\n", + "\n", + "import ipywidgets as widgets\n", + "import pandas as pd\n", + "from IPython.display import display\n", + "\n", + "def find_repository_root(start: Path) -> Path:\n", + " candidates: tuple[Path, ...] = (start, *start.parents)\n", + " candidate: Path\n", + " for candidate in candidates:\n", + " if (\n", + " (candidate / \"pyproject.toml\").is_file()\n", + " and (candidate / \"CH_15_scientific_python\").is_dir()\n", + " ):\n", + " return candidate\n", + " raise RuntimeError(\"repository root could not be found\")\n", + "\n", + "current_directory: Path = Path.cwd().resolve()\n", + "repository_root: Path = find_repository_root(current_directory)\n", + "repository_root_text: str = str(repository_root)\n", + "if repository_root != current_directory and repository_root_text not in sys.path:\n", + " sys.path.insert(0, repository_root_text)\n", + "\n", + "from CH_15_scientific_python.exercise_02.solution_00 import build_interactive_plot" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "build-widget", + "metadata": {}, + "outputs": [], + "source": [ + "points: Path = repository_root / \"CH_15_scientific_python/exercise_01/points.csv\"\n", + "frame: pd.DataFrame = pd.read_csv(points)\n", + "widget: widgets.VBox = build_interactive_plot(frame)\n", + "display(widget)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "assertions", + "metadata": {}, + "outputs": [], + "source": [ + "assert tuple(frame.shape) == (6, 3)\n", + "assert widget.children[0].value == 64" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.10" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/CH_15_scientific_python/exercise_02/solution_00.py b/CH_15_scientific_python/exercise_02/solution_00.py new file mode 100644 index 0000000..ecd6b29 --- /dev/null +++ b/CH_15_scientific_python/exercise_02/solution_00.py @@ -0,0 +1,125 @@ +"""Build an interactive resolution control for the Datashader plot.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import cast +from weakref import WeakKeyDictionary + +import datashader.transfer_functions as tf # type: ignore[import-untyped] +import ipywidgets as widgets # type: ignore[import-untyped] +import pandas as pd # type: ignore[import-untyped] +from IPython.display import ( + display as untyped_display, # pyright: ignore[reportUnknownVariableType] +) + +from CH_15_scientific_python.exercise_01.solution_00 import render_points + +__all__: list[str] = [ + "build_interactive_plot", + "dispose_interactive_plot", + "render_resolution", +] + +display: Callable[[object], object] = cast( + Callable[[object], object], + untyped_display, +) +Observer = Callable[[dict[str, object]], None] + + +@dataclass(slots=True) +class _InteractivePlotState: + slider: widgets.IntSlider + output: widgets.Output + observer: Observer | None + + def dispose(self, container: widgets.VBox) -> None: + first_error: Exception | None = None + observer: Observer | None = self.observer + self.observer = None + if observer is not None: + try: + self.slider.unobserve(observer, names="value") + except Exception as error: + first_error = error + + closers: tuple[Callable[[], None], ...] = ( + self.slider.close, + self.output.close, + container.close, + ) + for close in closers: + try: + close() + except Exception as error: + if first_error is None: + first_error = error + + if first_error is not None: + raise first_error + + +_INTERACTIVE_PLOTS: WeakKeyDictionary[ + widgets.VBox, + _InteractivePlotState, +] = WeakKeyDictionary() + + +def render_resolution(frame: pd.DataFrame, resolution: int) -> tf.Image: + """Render ``frame`` as a square image at ``resolution`` pixels.""" + + if resolution < 1: + raise ValueError("resolution must be positive") + return render_points( + frame, + width=resolution, + height=resolution, + ) + + +def build_interactive_plot(frame: pd.DataFrame) -> widgets.VBox: + """Return a bounded resolution slider and its rendered output.""" + + slider: widgets.IntSlider = widgets.IntSlider( + value=64, + min=32, + max=256, + step=32, + description="Resolution", + ) + output: widgets.Output = widgets.Output() + clear_output: Callable[..., None] = cast( + Callable[..., None], + output.clear_output, # pyright: ignore[reportUnknownMemberType] + ) + + def update(change: dict[str, object]) -> None: + resolution_value: object = change["new"] + if not isinstance(resolution_value, int): + raise TypeError("resolution widget returned a non-integer") + with output: + clear_output(wait=True) + display(render_resolution(frame, resolution_value).to_pil()) + + slider.observe(update, names="value") + with output: + display(render_resolution(frame, 64).to_pil()) + container: widgets.VBox = widgets.VBox((slider, output)) + _INTERACTIVE_PLOTS[container] = _InteractivePlotState( + slider=slider, + output=output, + observer=update, + ) + return container + + +def dispose_interactive_plot(container: widgets.VBox) -> None: + """Unregister observers, release captured data, close owned widgets.""" + state: _InteractivePlotState | None = _INTERACTIVE_PLOTS.pop( + container, + None, + ) + if state is not None: + state.dispose(container) diff --git a/CH_15_scientific_python/exercise_02/test_solution_00.py b/CH_15_scientific_python/exercise_02/test_solution_00.py new file mode 100644 index 0000000..fcefcf6 --- /dev/null +++ b/CH_15_scientific_python/exercise_02/test_solution_00.py @@ -0,0 +1,204 @@ +"""Tests for the interactive Datashader notebook.""" + +from __future__ import annotations + +from collections.abc import Callable, Mapping +from gc import collect +from pathlib import Path +from typing import Protocol, cast +from weakref import ReferenceType, ref + +import ipywidgets as widgets # type: ignore[import-untyped] +import nbformat +import pandas as pd # type: ignore[import-untyped] +import pytest +from nbclient import NotebookClient +from nbformat import NotebookNode +from PIL.Image import Image + +from . import solution_00 +from .solution_00 import ( + build_interactive_plot, + dispose_interactive_plot, + render_resolution, +) + +POINTS: Path = Path(__file__).parents[1] / "exercise_01" / "points.csv" +NOTEBOOK: Path = Path(__file__).with_name("datashader_interactive.ipynb") +REPOSITORY: Path = Path(__file__).parents[2] +READ_CSV: Callable[[Path], pd.DataFrame] = cast( + Callable[[Path], pd.DataFrame], + pd.read_csv, # pyright: ignore[reportUnknownMemberType] +) +READ_NOTEBOOK: Callable[..., NotebookNode] = cast( + Callable[..., NotebookNode], + nbformat.read, # pyright: ignore[reportUnknownMemberType] +) + + +class _NotebookCell(Protocol): + cell_type: str + execution_count: int | None + outputs: list[dict[str, object]] + source: str + + +class _NotebookView(Protocol): + nbformat: int + metadata: Mapping[str, object] + cells: list[_NotebookCell] + + +class _WidgetBox(Protocol): + children: tuple[object, ...] + + +class _Closable(Protocol): + def close(self) -> None: ... + + +class _Slider(Protocol): + min: int + max: int + step: int + value: int + + +def test_render_resolution_changes_image_dimensions() -> None: + frame: pd.DataFrame = READ_CSV(POINTS) + + assert render_resolution(frame, 32).to_pil().size == (32, 32) + assert render_resolution(frame, 64).to_pil().size == (64, 64) + + +def test_render_resolution_rejects_nonpositive_values() -> None: + frame: pd.DataFrame = READ_CSV(POINTS) + + with pytest.raises(ValueError, match=r"^resolution must be positive$"): + render_resolution(frame, 0) + + +def test_widget_has_bounded_control_and_updates_output( + monkeypatch: pytest.MonkeyPatch, +) -> None: + frame: pd.DataFrame = READ_CSV(POINTS) + rendered_sizes: list[tuple[int, int]] = [] + + def record_display(value: Image) -> None: + rendered_sizes.append(value.size) + + monkeypatch.setattr(solution_00, "display", record_display) + widget: widgets.VBox = build_interactive_plot(frame) + children: tuple[object, ...] = cast(_WidgetBox, widget).children + + assert len(children) == 2 + assert isinstance(children[0], widgets.IntSlider) + assert isinstance(children[1], widgets.Output) + slider: _Slider = cast(_Slider, children[0]) + assert (slider.min, slider.max, slider.step, slider.value) == (32, 256, 32, 64) + assert rendered_sizes == [(64, 64)] + + slider.value = 96 + assert rendered_sizes == [(64, 64), (96, 96)] + + +def test_dispose_interactive_plot_releases_frame_and_closes_widgets( + monkeypatch: pytest.MonkeyPatch, +) -> None: + frame: pd.DataFrame = READ_CSV(POINTS) + frame_reference: ReferenceType[pd.DataFrame] = ref(frame) + rendered_sizes: list[tuple[int, int]] = [] + + def record_display(value: Image) -> None: + rendered_sizes.append(value.size) + + monkeypatch.setattr(solution_00, "display", record_display) + widget: widgets.VBox = build_interactive_plot(frame) + children: tuple[object, ...] = cast(_WidgetBox, widget).children + slider: _Slider = cast(_Slider, children[0]) + slider_widget: _Closable = cast(_Closable, children[0]) + output_widget: _Closable = cast(_Closable, children[1]) + closed: list[str] = [] + slider_close: Callable[[], None] = slider_widget.close + output_close: Callable[[], None] = output_widget.close + container_close: Callable[[], None] = widget.close + + def close_slider() -> None: + closed.append("slider") + slider_close() + + def close_output() -> None: + closed.append("output") + output_close() + + def close_container() -> None: + closed.append("container") + container_close() + + monkeypatch.setattr(children[0], "close", close_slider) + monkeypatch.setattr(children[1], "close", close_output) + monkeypatch.setattr(widget, "close", close_container) + del frame + collect() + assert frame_reference() is not None + + dispose_interactive_plot(widget) + slider.value = 96 + collect() + + assert rendered_sizes == [(64, 64)] + assert closed == ["slider", "output", "container"] + assert frame_reference() is None + + +def test_notebook_is_clean_and_reproducible() -> None: + notebook: NotebookNode = READ_NOTEBOOK(NOTEBOOK, as_version=4) + notebook_view: _NotebookView = cast(_NotebookView, notebook) + + assert notebook_view.nbformat == 4 + assert notebook_view.metadata["kernelspec"] == { + "display_name": "Python 3", + "language": "python", + "name": "python3", + } + assert "widgets" not in notebook_view.metadata + assert len(notebook_view.cells) == 3 + assert all(cell.cell_type == "code" for cell in notebook_view.cells) + assert all(cell.execution_count is None for cell in notebook_view.cells) + assert all(cell.outputs == [] for cell in notebook_view.cells) + assert "def find_repository_root(start: Path) -> Path:" in ( + notebook_view.cells[0].source + ) + assert "repository_root != current_directory" in notebook_view.cells[0].source + assert "repository_root_text not in sys.path" in notebook_view.cells[0].source + assert ( + "CH_15_scientific_python/exercise_01/points.csv" + in notebook_view.cells[1].source + ) + assert "points: Path = repository_root /" in notebook_view.cells[1].source + assert "assert tuple(frame.shape) == (6, 3)" in notebook_view.cells[2].source + assert "assert widget.children[0].value == 64" in notebook_view.cells[2].source + + +@pytest.mark.timeout(90) +@pytest.mark.parametrize( + "working_directory", + [REPOSITORY, NOTEBOOK.parent], + ids=["repository-root", "notebook-directory"], +) +def test_notebook_executes_headlessly(working_directory: Path) -> None: + notebook: NotebookNode = READ_NOTEBOOK(NOTEBOOK, as_version=4) + client: NotebookClient = NotebookClient( + notebook, + timeout=60, + kernel_name="python3", + ) + + executed: NotebookNode = client.execute(cwd=str(working_directory)) + executed_view: _NotebookView = cast(_NotebookView, executed) + + assert all( + output.get("output_type") != "error" + for cell in executed_view.cells + for output in cell.outputs + ) diff --git a/CH_16_machine_learning/README.rst b/CH_16_machine_learning/README.rst index 58445d4..e615d7a 100644 --- a/CH_16_machine_learning/README.rst +++ b/CH_16_machine_learning/README.rst @@ -1,4 +1,4 @@ Chapter 16 - machine learning -======================================================================================================================= +============================= -1. Extract data or information from this chapter’s summary by applying one of the NLP algorithms. +1. `Extract data or information from this chapter’s summary by applying one of the NLP algorithms. `_ diff --git a/CH_16_machine_learning/exercise_01/README.rst b/CH_16_machine_learning/exercise_01/README.rst new file mode 100644 index 0000000..167f469 --- /dev/null +++ b/CH_16_machine_learning/exercise_01/README.rst @@ -0,0 +1,53 @@ +Exercise 1: deterministic offline entity extraction +=================================================== + +Question +-------- + +.. code-block:: text + + 1. Extract data or information from this chapter’s summary by applying one of the NLP algorithms. + +Solution +-------- + +``chapter_summary.txt`` is a fixed local exercise fixture. ``load_summary`` +reads it as UTF-8 and rejects blank input. ``extract_entities`` returns frozen +``ExtractedEntity`` values in source order with zero-based sentence indexes; +empty input returns an empty list. + +The pipeline uses ``spacy.blank("en")`` with only a sentencizer and an +``EntityRuler`` containing fixed patterns. It never calls ``spacy.load``, +``spacy.cli.download``, a network API, or an external dataset. Consequently, +the ``en_core_web_sm`` model is neither required nor downloaded. + +Dependencies +------------ + +Python 3.10 or newer is required. The repository's ``nlp`` dependency group +supplies spaCy without a language-model wheel. Tests use only the committed +summary. + +Run +--- + +Run the guarded demonstration from the repository root: + +.. code-block:: console + + $ uv run --group nlp python -m CH_16_machine_learning.exercise_01.solution_00 + +Run the focused tests: + +.. code-block:: console + + $ uv run --group nlp pytest CH_16_machine_learning/exercise_01/test_solution_00.py -vv + +Reference +--------- + +The implementation is informed by the chapter's immutable +`spaCy extraction lesson +`_. +The upstream material is MIT licensed; this exercise solution is +self-contained. diff --git a/CH_16_machine_learning/exercise_01/__init__.py b/CH_16_machine_learning/exercise_01/__init__.py new file mode 100644 index 0000000..d89d305 --- /dev/null +++ b/CH_16_machine_learning/exercise_01/__init__.py @@ -0,0 +1 @@ +"""Exercise 1: deterministic offline entity extraction.""" diff --git a/CH_16_machine_learning/exercise_01/chapter_summary.txt b/CH_16_machine_learning/exercise_01/chapter_summary.txt new file mode 100644 index 0000000..a3f3032 --- /dev/null +++ b/CH_16_machine_learning/exercise_01/chapter_summary.txt @@ -0,0 +1 @@ +Machine learning turns examples into predictions instead of encoding every rule by hand. Chapter 16 surveys image processing, natural language processing, neural networks, and model selection in Python. The natural language processing example uses spaCy to identify named concepts in local text. Reproducible exercises keep data local and avoid downloading models during the test run. diff --git a/CH_16_machine_learning/exercise_01/solution_00.py b/CH_16_machine_learning/exercise_01/solution_00.py new file mode 100644 index 0000000..89d7d5c --- /dev/null +++ b/CH_16_machine_learning/exercise_01/solution_00.py @@ -0,0 +1,104 @@ +"""Extract deterministic entities with an offline rule-based spaCy pipeline.""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import cast + +import spacy +from spacy.language import Language +from spacy.pipeline import EntityRuler +from spacy.pipeline.entityruler import PatternType +from spacy.tokens import Doc + +__all__: list[str] = [ + "ExtractedEntity", + "build_pipeline", + "extract_entities", + "load_summary", +] + + +@dataclass(frozen=True) +class ExtractedEntity: + """One source-ordered entity extracted from the chapter summary.""" + + text: str + label: str + sentence_index: int + + +PATTERNS: tuple[PatternType, ...] = ( + { + "label": "METHOD", + "pattern": [{"LOWER": "machine"}, {"LOWER": "learning"}], + }, + { + "label": "METHOD", + "pattern": [ + {"LOWER": "natural"}, + {"LOWER": "language"}, + {"LOWER": "processing"}, + ], + }, + { + "label": "CHAPTER", + "pattern": [{"LOWER": "chapter"}, {"TEXT": "16"}], + }, + {"label": "LANGUAGE", "pattern": "Python"}, + {"label": "LIBRARY", "pattern": "spaCy"}, +) + + +def build_pipeline() -> Language: + """Build a local English tokenizer with deterministic entity rules.""" + + pipeline: Language = spacy.blank("en") + pipeline.add_pipe("sentencizer") + ruler: EntityRuler = cast(EntityRuler, pipeline.add_pipe("entity_ruler")) + ruler.add_patterns(list(PATTERNS)) + return pipeline + + +def load_summary(path: Path) -> str: + """Load a nonblank UTF-8 chapter summary.""" + + text: str = path.read_text(encoding="utf-8").strip() + if not text: + raise ValueError("chapter summary must not be blank") + return text + + +def extract_entities(text: str) -> list[ExtractedEntity]: + """Extract fixed entities in source order with zero-based sentence indexes.""" + + if not text.strip(): + return [] + document: Doc = build_pipeline()(text) + sentence_numbers: dict[int, int] = { + token.i: sentence_index + for sentence_index, sentence in enumerate(document.sents) + for token in sentence + } + return [ + ExtractedEntity( + text=entity.text, + label=entity.label_, + sentence_index=sentence_numbers[entity.start], + ) + for entity in document.ents + ] + + +def main() -> None: + """Print entities from the committed local summary.""" + + source: Path = Path(__file__).with_name("chapter_summary.txt") + entities: list[ExtractedEntity] = extract_entities(load_summary(source)) + for entity in entities: + print(f"{entity.label}\t{entity.text}\tsentence={entity.sentence_index}") + + +if __name__ == "__main__": + main() diff --git a/CH_16_machine_learning/exercise_01/test_solution_00.py b/CH_16_machine_learning/exercise_01/test_solution_00.py new file mode 100644 index 0000000..5ea234e --- /dev/null +++ b/CH_16_machine_learning/exercise_01/test_solution_00.py @@ -0,0 +1,59 @@ +"""Tests for deterministic offline entity extraction.""" + +from __future__ import annotations + +from dataclasses import FrozenInstanceError +from pathlib import Path + +import pytest + +from .solution_00 import ( + ExtractedEntity, + build_pipeline, + extract_entities, + load_summary, +) + +SUMMARY: Path = Path(__file__).with_name("chapter_summary.txt") + + +def test_extracts_expected_entities_in_source_order_with_sentence_indexes() -> None: + entities: list[ExtractedEntity] = extract_entities(load_summary(SUMMARY)) + + assert [ + (entity.text, entity.label, entity.sentence_index) for entity in entities + ] == [ + ("Machine learning", "METHOD", 0), + ("Chapter 16", "CHAPTER", 1), + ("natural language processing", "METHOD", 1), + ("Python", "LANGUAGE", 1), + ("natural language processing", "METHOD", 2), + ("spaCy", "LIBRARY", 2), + ] + + +def test_pipeline_is_local_rule_based_and_repeatable() -> None: + text: str = load_summary(SUMMARY) + + assert build_pipeline().pipe_names == ["sentencizer", "entity_ruler"] + assert extract_entities(text) == extract_entities(text) + + +def test_extracted_entities_are_frozen() -> None: + entity: ExtractedEntity = ExtractedEntity("Python", "LANGUAGE", 0) + + with pytest.raises(FrozenInstanceError): + setattr(entity, "label", "CHANGED") # noqa: B010 + + +def test_empty_text_returns_no_entities() -> None: + assert extract_entities("") == [] + assert extract_entities(" \n") == [] + + +def test_load_summary_rejects_blank_fixture(tmp_path: Path) -> None: + path: Path = tmp_path / "blank.txt" + path.write_text(" \n", encoding="utf-8") + + with pytest.raises(ValueError, match=r"^chapter summary must not be blank$"): + load_summary(path) diff --git a/CH_17_c_and_cpp_extensions/README.rst b/CH_17_c_and_cpp_extensions/README.rst index 17a1190..6b25a65 100644 --- a/CH_17_c_and_cpp_extensions/README.rst +++ b/CH_17_c_and_cpp_extensions/README.rst @@ -1,5 +1,8 @@ Chapter 17 - c and cpp extensions -======================================================================================================================= +================================= -1. Try to sort a list of numbers using `ctypes`, `CFFI`, and with a native extension. You can use the `qsort` function in `stdlib`. -2. Try to make the `custom_sum` function we created safer by adding proper errors for overflow/underflow issues. Additionally, catch the errors when summing multiple numbers that only overflow or underflow in summation. +Exercises +--------- + +1. `Try to sort a list of numbers using ctypes, CFFI, and with a native extension. You can use the qsort function in stdlib. `_ +2. `Try to make the custom_sum function we created safer by adding proper errors for overflow/underflow issues. Additionally, catch the errors when summing multiple numbers that only overflow or underflow in summation. `_ diff --git a/CH_17_c_and_cpp_extensions/exercise_01/README.rst b/CH_17_c_and_cpp_extensions/exercise_01/README.rst new file mode 100644 index 0000000..53f8379 --- /dev/null +++ b/CH_17_c_and_cpp_extensions/exercise_01/README.rst @@ -0,0 +1,61 @@ +Exercise 1: native qsort interfaces +=================================== + +Question +-------- + +.. code-block:: text + + 1. Try to sort a list of numbers using `ctypes`, `CFFI`, and with a native extension. You can use the `qsort` function in `stdlib`. + +Answer +------ + +``qsort_ctypes``, ``qsort_cffi``, and ``qsort_native`` expose the same +non-mutating contract. They accept a sequence containing only integers in the +platform C ``int`` range, reject booleans and other objects before entering C, +and return a new sorted list. Empty sequences are returned safely without +calling C ``qsort``. + +The ctypes and CFFI ABI paths load ``qsort`` from the current process, which is +portable across the supported macOS and Linux environments. Their callbacks +compare relationally instead of subtracting, so ``INT_MIN`` and ``INT_MAX`` +cannot overflow the comparator. The CPython extension performs its own checked +per-item conversion, frees temporary memory on every path, and builds a Python +list result. + +Native compilation uses the repository's ``native`` dependency group. The +shared builder places the extension, object files, and compiler directories +under an explicit temporary build root; tests and the guarded demo never write +compiled artifacts into the repository. + +Run +--- + +Run the guarded demonstration: + +.. code-block:: console + + $ uv run --group native python -m CH_17_c_and_cpp_extensions.exercise_01.solution_00 + +Run the focused tests: + +.. code-block:: console + + $ uv run --group native pytest CH_17_c_and_cpp_extensions/exercise_01/test_solution_00.py -vv + +Upstream references +------------------- + +The immutable upstream references are +`_libc.py +`_, +`T_04_cffi.rst +`_, +`T_05_cffi_open_library.rst +`_, +and +`T_09_native/setup.py +`_. +The upstream repository is MIT licensed. This solution is self-contained and +does not copy or import those source files at runtime. diff --git a/CH_17_c_and_cpp_extensions/exercise_01/__init__.py b/CH_17_c_and_cpp_extensions/exercise_01/__init__.py new file mode 100644 index 0000000..68d0398 --- /dev/null +++ b/CH_17_c_and_cpp_extensions/exercise_01/__init__.py @@ -0,0 +1 @@ +"""Exercise 1: sort C integers through three native interfaces.""" diff --git a/CH_17_c_and_cpp_extensions/exercise_01/native_qsort.c b/CH_17_c_and_cpp_extensions/exercise_01/native_qsort.c new file mode 100644 index 0000000..a5132b2 --- /dev/null +++ b/CH_17_c_and_cpp_extensions/exercise_01/native_qsort.c @@ -0,0 +1,112 @@ +#define PY_SSIZE_T_CLEAN +#include + +#include +#include + +static int +compare_ints(const void *left_pointer, const void *right_pointer) +{ + const int left = *(const int *)left_pointer; + const int right = *(const int *)right_pointer; + + return (left > right) - (left < right); +} + +static PyObject * +sort_ints(PyObject *self, PyObject *argument) +{ + PyObject *sequence = NULL; + PyObject *result = NULL; + int *values = NULL; + Py_ssize_t length = 0; + Py_ssize_t index = 0; + + (void)self; + sequence = PySequence_Fast( + argument, + "sort_ints requires a sequence of integers" + ); + if (sequence == NULL) { + return NULL; + } + + length = PySequence_Fast_GET_SIZE(sequence); + if (length > 0) { + values = PyMem_New(int, length); + if (values == NULL) { + Py_DECREF(sequence); + return PyErr_NoMemory(); + } + } + + for (index = 0; index < length; index++) { + PyObject *item = PySequence_Fast_GET_ITEM(sequence, index); + long value = 0; + + if (PyBool_Check(item)) { + PyErr_SetString(PyExc_TypeError, "sort_ints rejects booleans"); + goto error; + } + value = PyLong_AsLong(item); + if (value == -1 && PyErr_Occurred()) { + goto error; + } + if (value < INT_MIN || value > INT_MAX) { + PyErr_SetString(PyExc_OverflowError, "value is outside C int range"); + goto error; + } + values[index] = (int)value; + } + + if (length > 1) { + qsort(values, (size_t)length, sizeof(int), compare_ints); + } + + result = PyList_New(length); + if (result == NULL) { + goto error; + } + for (index = 0; index < length; index++) { + PyObject *item = PyLong_FromLong((long)values[index]); + + if (item == NULL) { + goto error; + } + PyList_SET_ITEM(result, index, item); + } + + PyMem_Free(values); + Py_DECREF(sequence); + return result; + +error: + Py_XDECREF(result); + PyMem_Free(values); + Py_DECREF(sequence); + return NULL; +} + +static PyMethodDef module_methods[] = { + { + "sort_ints", + sort_ints, + METH_O, + PyDoc_STR("Sort a sequence of C-int values.") + }, + {NULL, NULL, 0, NULL} +}; + +static struct PyModuleDef module_definition = { + PyModuleDef_HEAD_INIT, + "_native_qsort", + NULL, + -1, + module_methods +}; + +PyMODINIT_FUNC +PyInit__native_qsort(void) +{ + return PyModule_Create(&module_definition); +} diff --git a/CH_17_c_and_cpp_extensions/exercise_01/solution_00.py b/CH_17_c_and_cpp_extensions/exercise_01/solution_00.py new file mode 100644 index 0000000..d0f4382 --- /dev/null +++ b/CH_17_c_and_cpp_extensions/exercise_01/solution_00.py @@ -0,0 +1,127 @@ +"""Sort C integers through ctypes, CFFI, and a CPython extension.""" + +from __future__ import annotations + +import ctypes +from collections.abc import Sequence +from pathlib import Path +from tempfile import TemporaryDirectory +from types import ModuleType +from typing import Any, cast + +from cffi import FFI # type: ignore[import-untyped] + +from CH_17_c_and_cpp_extensions.native_build import build_extension + +_C_INT_BITS: int = ctypes.sizeof(ctypes.c_int) * 8 +_C_INT_MIN: int = -(1 << (_C_INT_BITS - 1)) +_C_INT_MAX: int = (1 << (_C_INT_BITS - 1)) - 1 + + +def _validated_ints(values: Sequence[object]) -> list[int]: + """Return values proven safe for conversion to C ``int``.""" + checked: list[int] = [] + for value in values: + if isinstance(value, bool) or not isinstance(value, int): + raise TypeError("qsort accepts integers, not booleans or other values") + if value < _C_INT_MIN or value > _C_INT_MAX: + raise OverflowError("value is outside C int range") + checked.append(value) + return checked + + +def qsort_ctypes(values: Sequence[int]) -> list[int]: + """Sort C-int values through process libc and return a new list.""" + checked: list[int] = _validated_ints(values) + if not checked: + return [] + + comparator_type = ctypes.CFUNCTYPE( + ctypes.c_int, + ctypes.c_void_p, + ctypes.c_void_p, + ) + + def compare(left_address: int, right_address: int) -> int: + left_pointer = ctypes.cast(left_address, ctypes.POINTER(ctypes.c_int)) + right_pointer = ctypes.cast(right_address, ctypes.POINTER(ctypes.c_int)) + left_value: int = left_pointer.contents.value + right_value: int = right_pointer.contents.value + return (left_value > right_value) - (left_value < right_value) + + compare_callback = comparator_type(compare) + array_type = ctypes.c_int * len(checked) + data = array_type(*checked) + libc: ctypes.CDLL = ctypes.CDLL(None) + qsort = libc.qsort + qsort.argtypes = [ + ctypes.c_void_p, + ctypes.c_size_t, + ctypes.c_size_t, + comparator_type, + ] + qsort.restype = None + qsort(data, len(checked), ctypes.sizeof(ctypes.c_int), compare_callback) + return list(data) + + +def qsort_cffi(values: Sequence[int]) -> list[int]: + """Sort C-int values through CFFI ABI mode and return a new list.""" + checked: list[int] = _validated_ints(values) + if not checked: + return [] + + ffi: FFI = FFI() + ffi.cdef("void qsort(void *, size_t, size_t, int (*)(const void *, const void *));") + libc: Any = ffi.dlopen(None) + data: Any = ffi.new("int[]", checked) + + def compare(left: object, right: object) -> int: + left_pointer: Any = ffi.cast("const int *", cast(Any, left)) + right_pointer: Any = ffi.cast("const int *", cast(Any, right)) + left_value: int = left_pointer[0] + right_value: int = right_pointer[0] + return (left_value > right_value) - (left_value < right_value) + + compare_callback: Any = ffi.callback("int(const void *, const void *)")(compare) + libc.qsort(data, len(checked), ffi.sizeof("int"), compare_callback) + return [int(data[index]) for index in range(len(checked))] + + +def qsort_native( + values: Sequence[int], + *, + native_module: ModuleType, +) -> list[int]: + """Sort C-int values with a compiled CPython extension.""" + checked: list[int] = _validated_ints(values) + result: object = native_module.sort_ints(checked) + if not isinstance(result, list): + raise TypeError("native qsort returned a non-integer list") + result_items: list[object] = cast(list[object], result) + typed_result: list[int] = [] + for value in result_items: + if isinstance(value, bool) or not isinstance(value, int): + raise TypeError("native qsort returned a non-integer list") + typed_result.append(value) + return typed_result + + +def main() -> None: + """Demonstrate all paths without leaving compiler output in the repository.""" + values: list[int] = [3, -1, 2] + print(qsort_ctypes(values)) + print(qsort_cffi(values)) + with TemporaryDirectory(prefix="mastering-python-qsort-") as directory: + build_root: Path = Path(directory) + source: Path = Path(__file__).with_name("native_qsort.c") + native_module: ModuleType = build_extension( + "_native_qsort", + source, + build_root, + ) + print(qsort_native(values, native_module=native_module)) + + +if __name__ == "__main__": + main() diff --git a/CH_17_c_and_cpp_extensions/exercise_01/test_solution_00.py b/CH_17_c_and_cpp_extensions/exercise_01/test_solution_00.py new file mode 100644 index 0000000..524916e --- /dev/null +++ b/CH_17_c_and_cpp_extensions/exercise_01/test_solution_00.py @@ -0,0 +1,147 @@ +"""Tests for ctypes, CFFI, and CPython-extension qsort paths.""" + +from __future__ import annotations + +import ctypes +import subprocess +import sys +from collections.abc import Callable, Iterator, Sequence +from pathlib import Path +from types import ModuleType +from typing import cast + +import pytest + +from CH_17_c_and_cpp_extensions.exercise_01.solution_00 import ( + qsort_cffi, + qsort_ctypes, + qsort_native, +) +from CH_17_c_and_cpp_extensions.native_build import build_extension + +Sorter = Callable[[Sequence[int]], list[int]] + +_C_INT_BITS: int = ctypes.sizeof(ctypes.c_int) * 8 +_C_INT_MIN: int = -(1 << (_C_INT_BITS - 1)) +_C_INT_MAX: int = (1 << (_C_INT_BITS - 1)) - 1 + + +@pytest.fixture(scope="module") +def native_module( + tmp_path_factory: pytest.TempPathFactory, +) -> Iterator[ModuleType]: + build_root: Path = tmp_path_factory.mktemp("native-qsort") + source: Path = Path(__file__).with_name("native_qsort.c") + yield build_extension("_native_qsort", source, build_root) + + +def _sorters(native_module: ModuleType) -> tuple[Sorter, Sorter, Sorter]: + def sort_native(values: Sequence[int]) -> list[int]: + return qsort_native(values, native_module=native_module) + + return qsort_ctypes, qsort_cffi, sort_native + + +@pytest.mark.parametrize( + "values", + [ + [], + [1], + [4, -1, 4, 0, 2], + [_C_INT_MIN, _C_INT_MAX, 0, _C_INT_MIN, _C_INT_MAX], + ], +) +def test_all_qsort_paths_match_builtin_without_mutating_input( + values: list[int], + native_module: ModuleType, +) -> None: + expected: list[int] = sorted(values) + original: list[int] = values.copy() + + for sorter in _sorters(native_module): + assert sorter(values) == expected + assert values == original + + +def test_all_qsort_paths_accept_other_integer_sequences( + native_module: ModuleType, +) -> None: + values: tuple[int, ...] = (3, 1, 2) + + for sorter in _sorters(native_module): + assert sorter(values) == [1, 2, 3] + + +@pytest.mark.parametrize("value", [_C_INT_MIN - 1, _C_INT_MAX + 1]) +def test_all_qsort_paths_reject_values_outside_c_int_range( + value: int, + native_module: ModuleType, +) -> None: + for sorter in _sorters(native_module): + with pytest.raises(OverflowError, match="outside C int range"): + sorter([value]) + + +@pytest.mark.parametrize("value", [True, False, "2", 2.5]) +def test_all_qsort_paths_reject_booleans_and_non_integers( + value: object, + native_module: ModuleType, +) -> None: + values: Sequence[int] = cast(Sequence[int], [value]) + + for sorter in _sorters(native_module): + with pytest.raises(TypeError, match="integers"): + sorter(values) + + +def test_native_extension_preserves_direct_conversion_errors( + native_module: ModuleType, +) -> None: + with pytest.raises(TypeError): + native_module.sort_ints(["bad"]) + with pytest.raises(TypeError, match="booleans"): + native_module.sort_ints([True]) + with pytest.raises(OverflowError): + native_module.sort_ints([_C_INT_MAX + 1]) + with pytest.raises(TypeError): + native_module.sort_ints(42) + + +def test_import_is_silent() -> None: + repository_root: Path = Path(__file__).parents[2] + completed: subprocess.CompletedProcess[str] = subprocess.run( + [ + sys.executable, + "-c", + "import CH_17_c_and_cpp_extensions.exercise_01.solution_00", + ], + cwd=repository_root, + check=False, + capture_output=True, + text=True, + timeout=5, + ) + + assert completed.returncode == 0 + assert completed.stdout == "" + assert completed.stderr == "" + + +def test_guarded_demo_builds_only_in_a_temporary_directory() -> None: + repository_root: Path = Path(__file__).parents[2] + completed: subprocess.CompletedProcess[str] = subprocess.run( + [ + sys.executable, + "-m", + "CH_17_c_and_cpp_extensions.exercise_01.solution_00", + ], + cwd=repository_root, + check=False, + capture_output=True, + text=True, + timeout=30, + ) + + assert completed.returncode == 0 + assert completed.stdout == "[-1, 2, 3]\n[-1, 2, 3]\n[-1, 2, 3]\n" + assert completed.stderr == "" diff --git a/CH_17_c_and_cpp_extensions/exercise_02/README.rst b/CH_17_c_and_cpp_extensions/exercise_02/README.rst new file mode 100644 index 0000000..46cd2aa --- /dev/null +++ b/CH_17_c_and_cpp_extensions/exercise_02/README.rst @@ -0,0 +1,49 @@ +Exercise 2: checked native custom sum +===================================== + +Question +-------- + +.. code-block:: text + + 2. Try to make the `custom_sum` function we created safer by adding proper errors for overflow/underflow issues. Additionally, catch the errors when summing multiple numbers that only overflow or underflow in summation. + +Answer +------ + +``custom_sum`` accepts an integer sequence, rejects booleans and non-integers +before native execution, and leaves the input unchanged. The extension checks +every ``PyLong_AsLongLong`` conversion immediately, preserving Python's +``TypeError`` and ``OverflowError`` instead of replacing them. + +Before each addition, the C loop checks the signed ``long long`` bounds. +Cumulative overflow and underflow raise distinct ``OverflowError`` messages. +Opposite-signed values may safely cancel, and an empty sequence returns zero. + +Native compilation uses the repository's ``native`` dependency group. Both +the tests and guarded demonstration compile into temporary directories through +the shared builder, so no extension or object files are left in the repository. + +Run +--- + +Run the guarded demonstration: + +.. code-block:: console + + $ uv run --group native python -m CH_17_c_and_cpp_extensions.exercise_02.solution_00 + +Run the focused tests: + +.. code-block:: console + + $ uv run --group native pytest CH_17_c_and_cpp_extensions/exercise_02/test_solution_00.py -vv + +Upstream reference +------------------ + +The immutable upstream reference is +`T_12_silent_or_lethal_errors.c +`_. +The upstream repository is MIT licensed. This solution is self-contained and +does not copy or import that source file at runtime. diff --git a/CH_17_c_and_cpp_extensions/exercise_02/__init__.py b/CH_17_c_and_cpp_extensions/exercise_02/__init__.py new file mode 100644 index 0000000..556370e --- /dev/null +++ b/CH_17_c_and_cpp_extensions/exercise_02/__init__.py @@ -0,0 +1 @@ +"""Exercise 2: checked native signed-64-bit summation.""" diff --git a/CH_17_c_and_cpp_extensions/exercise_02/safe_sum.c b/CH_17_c_and_cpp_extensions/exercise_02/safe_sum.c new file mode 100644 index 0000000..2c4f584 --- /dev/null +++ b/CH_17_c_and_cpp_extensions/exercise_02/safe_sum.c @@ -0,0 +1,83 @@ +#define PY_SSIZE_T_CLEAN +#include + +#include + +static PyObject * +custom_sum(PyObject *self, PyObject *argument) +{ + PyObject *sequence = NULL; + Py_ssize_t length = 0; + Py_ssize_t index = 0; + long long total = 0; + + (void)self; + sequence = PySequence_Fast( + argument, + "custom_sum requires a sequence of integers" + ); + if (sequence == NULL) { + return NULL; + } + + length = PySequence_Fast_GET_SIZE(sequence); + for (index = 0; index < length; index++) { + PyObject *item = PySequence_Fast_GET_ITEM(sequence, index); + long long value = 0; + + if (PyBool_Check(item)) { + PyErr_SetString(PyExc_TypeError, "custom_sum rejects booleans"); + Py_DECREF(sequence); + return NULL; + } + value = PyLong_AsLongLong(item); + if (value == -1 && PyErr_Occurred()) { + Py_DECREF(sequence); + return NULL; + } + if (value > 0 && total > LLONG_MAX - value) { + PyErr_SetString( + PyExc_OverflowError, + "custom_sum cumulative overflow" + ); + Py_DECREF(sequence); + return NULL; + } + if (value < 0 && total < LLONG_MIN - value) { + PyErr_SetString( + PyExc_OverflowError, + "custom_sum cumulative underflow" + ); + Py_DECREF(sequence); + return NULL; + } + total += value; + } + + Py_DECREF(sequence); + return PyLong_FromLongLong(total); +} + +static PyMethodDef module_methods[] = { + { + "custom_sum", + custom_sum, + METH_O, + PyDoc_STR("Return a checked signed-64-bit sum.") + }, + {NULL, NULL, 0, NULL} +}; + +static struct PyModuleDef module_definition = { + PyModuleDef_HEAD_INIT, + "_safe_sum", + NULL, + -1, + module_methods +}; + +PyMODINIT_FUNC +PyInit__safe_sum(void) +{ + return PyModule_Create(&module_definition); +} diff --git a/CH_17_c_and_cpp_extensions/exercise_02/solution_00.py b/CH_17_c_and_cpp_extensions/exercise_02/solution_00.py new file mode 100644 index 0000000..0f8f43d --- /dev/null +++ b/CH_17_c_and_cpp_extensions/exercise_02/solution_00.py @@ -0,0 +1,50 @@ +"""Sum integers in a checked CPython extension.""" + +from __future__ import annotations + +from collections.abc import Sequence +from pathlib import Path +from tempfile import TemporaryDirectory +from types import ModuleType + +from CH_17_c_and_cpp_extensions.native_build import build_extension + + +def _validated_ints(values: Sequence[object]) -> list[int]: + """Return integer values while rejecting booleans and other objects.""" + checked: list[int] = [] + for value in values: + if isinstance(value, bool) or not isinstance(value, int): + raise TypeError("custom_sum accepts integers, not booleans or other values") + checked.append(value) + return checked + + +def custom_sum( + values: Sequence[int], + *, + native_module: ModuleType, +) -> int: + """Return a checked native signed-64-bit sum.""" + checked: list[int] = _validated_ints(values) + result: object = native_module.custom_sum(checked) + if isinstance(result, bool) or not isinstance(result, int): + raise TypeError("native custom_sum returned a non-integer") + return result + + +def main() -> None: + """Compile and run the demonstration entirely in a temporary directory.""" + with TemporaryDirectory(prefix="mastering-python-safe-sum-") as directory: + build_root: Path = Path(directory) + source: Path = Path(__file__).with_name("safe_sum.c") + native_module: ModuleType = build_extension( + "_safe_sum", + source, + build_root, + ) + print(custom_sum([1, 2, 3], native_module=native_module)) + + +if __name__ == "__main__": + main() diff --git a/CH_17_c_and_cpp_extensions/exercise_02/test_solution_00.py b/CH_17_c_and_cpp_extensions/exercise_02/test_solution_00.py new file mode 100644 index 0000000..9e7843c --- /dev/null +++ b/CH_17_c_and_cpp_extensions/exercise_02/test_solution_00.py @@ -0,0 +1,173 @@ +"""Tests for checked native signed-64-bit summation.""" + +from __future__ import annotations + +import ctypes +import subprocess +import sys +from collections.abc import Sequence +from pathlib import Path +from types import ModuleType +from typing import cast + +import pytest + +from CH_17_c_and_cpp_extensions.exercise_02.solution_00 import custom_sum +from CH_17_c_and_cpp_extensions.native_build import build_extension + +_LONG_LONG_BITS: int = ctypes.sizeof(ctypes.c_longlong) * 8 +_LONG_LONG_MIN: int = -(1 << (_LONG_LONG_BITS - 1)) +_LONG_LONG_MAX: int = (1 << (_LONG_LONG_BITS - 1)) - 1 + + +@pytest.fixture(scope="module") +def native_module( + tmp_path_factory: pytest.TempPathFactory, +) -> ModuleType: + build_root: Path = tmp_path_factory.mktemp("safe-sum") + source: Path = Path(__file__).with_name("safe_sum.c") + return build_extension("_safe_sum", source, build_root) + + +@pytest.mark.parametrize( + ("values", "expected"), + [ + ([], 0), + ([1, -2, 3], 2), + ([_LONG_LONG_MAX], _LONG_LONG_MAX), + ([_LONG_LONG_MIN], _LONG_LONG_MIN), + ], +) +def test_custom_sum_handles_normal_empty_and_limit_values( + values: list[int], + expected: int, + native_module: ModuleType, +) -> None: + original: list[int] = values.copy() + + assert custom_sum(values, native_module=native_module) == expected + assert values == original + + +@pytest.mark.parametrize("value", [True, False, "bad", 1.5]) +def test_custom_sum_rejects_booleans_and_non_integers_before_native_call( + value: object, +) -> None: + called: list[bool] = [] + fake_module: ModuleType = ModuleType("_must_not_run") + + def forbidden(values: Sequence[int]) -> int: + del values + called.append(True) + return 0 + + fake_module.__dict__["custom_sum"] = forbidden + values: Sequence[int] = cast(Sequence[int], [value]) + + with pytest.raises(TypeError, match="integers"): + custom_sum(values, native_module=fake_module) + + assert called == [] + + +@pytest.mark.parametrize("value", [1 << 100, -(1 << 100)]) +def test_custom_sum_preserves_each_conversion_overflow( + value: int, + native_module: ModuleType, +) -> None: + with pytest.raises(OverflowError): + custom_sum([value], native_module=native_module) + + +def test_custom_sum_detects_cumulative_overflow( + native_module: ModuleType, +) -> None: + with pytest.raises(OverflowError, match="cumulative overflow"): + custom_sum([_LONG_LONG_MAX, 1, -1], native_module=native_module) + + +def test_custom_sum_detects_cumulative_underflow( + native_module: ModuleType, +) -> None: + with pytest.raises(OverflowError, match="cumulative underflow"): + custom_sum([_LONG_LONG_MIN, -1, 1], native_module=native_module) + + +@pytest.mark.parametrize( + "values", + [ + [_LONG_LONG_MAX, -_LONG_LONG_MAX], + [-_LONG_LONG_MAX, _LONG_LONG_MAX], + [_LONG_LONG_MIN, _LONG_LONG_MAX], + ], +) +def test_custom_sum_allows_in_range_cancellation( + values: list[int], + native_module: ModuleType, +) -> None: + assert custom_sum(values, native_module=native_module) == sum(values) + + +def test_native_extension_preserves_direct_conversion_errors( + native_module: ModuleType, +) -> None: + with pytest.raises(TypeError): + native_module.custom_sum([1, "bad", 2]) + with pytest.raises(TypeError, match="booleans"): + native_module.custom_sum([True]) + with pytest.raises(OverflowError): + native_module.custom_sum([1 << 100]) + with pytest.raises(TypeError): + native_module.custom_sum(42) + + +def test_custom_sum_rejects_invalid_native_result() -> None: + fake_module: ModuleType = ModuleType("_invalid_result") + + def invalid_sum(values: Sequence[int]) -> bool: + return bool(values) + + fake_module.__dict__["custom_sum"] = invalid_sum + + with pytest.raises(TypeError, match="non-integer"): + custom_sum([1], native_module=fake_module) + + +def test_import_is_silent() -> None: + repository_root: Path = Path(__file__).parents[2] + completed: subprocess.CompletedProcess[str] = subprocess.run( + [ + sys.executable, + "-c", + "import CH_17_c_and_cpp_extensions.exercise_02.solution_00", + ], + cwd=repository_root, + check=False, + capture_output=True, + text=True, + timeout=5, + ) + + assert completed.returncode == 0 + assert completed.stdout == "" + assert completed.stderr == "" + + +def test_guarded_demo_builds_only_in_a_temporary_directory() -> None: + repository_root: Path = Path(__file__).parents[2] + completed: subprocess.CompletedProcess[str] = subprocess.run( + [ + sys.executable, + "-m", + "CH_17_c_and_cpp_extensions.exercise_02.solution_00", + ], + cwd=repository_root, + check=False, + capture_output=True, + text=True, + timeout=30, + ) + + assert completed.returncode == 0 + assert completed.stdout == "6\n" + assert completed.stderr == "" diff --git a/CH_17_c_and_cpp_extensions/native_build.py b/CH_17_c_and_cpp_extensions/native_build.py new file mode 100644 index 0000000..27cf75d --- /dev/null +++ b/CH_17_c_and_cpp_extensions/native_build.py @@ -0,0 +1,61 @@ +"""Build and import CPython extensions inside an explicit build root.""" + +from __future__ import annotations + +import errno +import importlib.util +from pathlib import Path +from types import ModuleType + +from setuptools import Distribution, Extension # type: ignore[import-untyped] +from setuptools.command.build_ext import build_ext # type: ignore[import-untyped] + + +def build_extension( + module_name: str, + source: Path, + build_root: Path, +) -> ModuleType: + """Compile *source* under *build_root* and import *module_name*.""" + if not source.is_file(): + raise FileNotFoundError( + errno.ENOENT, + "native extension source is not a file", + str(source), + ) + + source_path: Path = source.resolve() + root: Path = build_root.resolve() + build_lib: Path = root / "lib" + build_temp: Path = root / "temp" + build_lib.mkdir(parents=True, exist_ok=True) + build_temp.mkdir(parents=True, exist_ok=True) + + extension: Extension = Extension(module_name, sources=[str(source_path)]) + distribution: Distribution = Distribution( + { + "name": module_name, + "ext_modules": [extension], + } + ) + command: build_ext = build_ext(distribution) + command.ensure_finalized() + command.inplace = False + command.build_lib = str(build_lib) + command.build_temp = str(build_temp) + command.run() + + extension_path: Path = Path(command.get_ext_fullpath(module_name)).resolve() + if not extension_path.is_relative_to(root): + raise RuntimeError(f"compiler output escaped build root: {extension_path}") + if not extension_path.is_file(): + raise RuntimeError( + f"compiler did not produce expected extension: {extension_path}" + ) + + spec = importlib.util.spec_from_file_location(module_name, extension_path) + if spec is None or spec.loader is None: + raise ImportError(f"cannot load extension at {extension_path}") + module: ModuleType = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module diff --git a/CH_17_c_and_cpp_extensions/test_native_build.py b/CH_17_c_and_cpp_extensions/test_native_build.py new file mode 100644 index 0000000..3463f48 --- /dev/null +++ b/CH_17_c_and_cpp_extensions/test_native_build.py @@ -0,0 +1,59 @@ +"""Tests for isolated native-extension compilation.""" + +from __future__ import annotations + +from pathlib import Path +from types import ModuleType + +import pytest + +from CH_17_c_and_cpp_extensions.native_build import build_extension + + +def test_build_extension_imports_from_temporary_directory(tmp_path: Path) -> None: + source: Path = tmp_path / "answer.c" + source.write_text( + """ +#define PY_SSIZE_T_CLEAN +#include + +static PyObject *answer(PyObject *self, PyObject *args) { + return PyLong_FromLong(42); +} + +static PyMethodDef methods[] = { + {"answer", answer, METH_NOARGS, "Return 42."}, + {NULL, NULL, 0, NULL} +}; + +static struct PyModuleDef module = { + PyModuleDef_HEAD_INIT, + "_answer", + NULL, + -1, + methods +}; + +PyMODINIT_FUNC PyInit__answer(void) { + return PyModule_Create(&module); +} +""".strip(), + encoding="utf-8", + ) + build_root: Path = tmp_path / "build" + + module: ModuleType = build_extension("_answer", source, build_root) + + assert module.answer() == 42 + assert module.__file__ is not None + module_path: Path = Path(module.__file__).resolve() + assert module_path.is_relative_to(build_root.resolve()) + + +def test_build_extension_rejects_missing_source(tmp_path: Path) -> None: + missing: Path = tmp_path / "missing.c" + + with pytest.raises(FileNotFoundError) as caught: + build_extension("_missing", missing, tmp_path / "build") + + assert caught.value.filename == str(missing) diff --git a/CH_18_packaging/README.rst b/CH_18_packaging/README.rst index 180dd04..4fff1f8 100644 --- a/CH_18_packaging/README.rst +++ b/CH_18_packaging/README.rst @@ -1,6 +1,6 @@ Chapter 18 - packaging -======================================================================================================================= +====================== -1. Create a `setuptools` command to bump the version in your package -2. Extend the version bumping command by interactively asking for a major, minor, or patch upgrade -3. Try and convert existing projects from `setup.py` to a `pyproject.toml` structure +1. `Create a setuptools command to bump the version in your package `_ +2. `Extend the version bumping command by interactively asking for a major, minor, or patch upgrade `_ +3. `Try and convert existing projects from setup.py to a pyproject.toml structure `_ diff --git a/CH_18_packaging/exercise_01/README.rst b/CH_18_packaging/exercise_01/README.rst new file mode 100644 index 0000000..b6f0ff6 --- /dev/null +++ b/CH_18_packaging/exercise_01/README.rst @@ -0,0 +1,48 @@ +Exercise 1: setuptools version command +====================================== + +Question +-------- + +.. code-block:: text + + 1. Create a `setuptools` command to bump the version in your package + +Answer +------ + +``bump_version`` accepts strict three-integer semantic versions and increments +the selected major, minor, or patch component. Major and minor bumps reset +lower-order components. ``read_version`` and ``write_version`` require exactly +one ``__version__`` assignment and preserve every other byte of the module. + +``BumpVersion`` exposes ``--version-file`` and ``--part`` setuptools command +options. The part defaults to ``patch``. Finalization rejects missing files and +unknown parts before execution; execution writes only the designated version +file. + +Security and dependencies +------------------------- + +Python 3.10 or newer is required. The repository's ``packaging`` dependency +group supplies setuptools and the test/build tools. Tests create disposable +fixture projects, invoke their commands with the active Python interpreter, and +use bounded subprocesses with captured output. No repository metadata file or +network service is modified. + +Run +--- + +Run the focused tests from the repository root: + +.. code-block:: console + + $ uv run --group packaging pytest CH_18_packaging/exercise_01/test_solution_00.py -vv + +Reference and provenance +------------------------ + +The implementation is informed by the chapter's immutable +`basic setup.py example +`_. +The upstream material is MIT licensed; this exercise solution is self-contained. diff --git a/CH_18_packaging/exercise_01/__init__.py b/CH_18_packaging/exercise_01/__init__.py new file mode 100644 index 0000000..01e65b8 --- /dev/null +++ b/CH_18_packaging/exercise_01/__init__.py @@ -0,0 +1 @@ +"""Setuptools version-bump command exercise.""" diff --git a/CH_18_packaging/exercise_01/solution_00.py b/CH_18_packaging/exercise_01/solution_00.py new file mode 100644 index 0000000..9800205 --- /dev/null +++ b/CH_18_packaging/exercise_01/solution_00.py @@ -0,0 +1,119 @@ +"""Strict semantic-version helpers and a setuptools bump command.""" + +from __future__ import annotations + +import re +from pathlib import Path +from typing import ClassVar, Literal, cast + +from setuptools import Command # type: ignore[import-untyped] +from setuptools.errors import OptionError # type: ignore[import-untyped] + +VersionPart = Literal["major", "minor", "patch"] + +VERSION_PATTERN: re.Pattern[str] = re.compile( + r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$" +) +ASSIGNMENT_PATTERN: re.Pattern[str] = re.compile( + r"^(?P[ \t]*__version__[ \t]*=[ \t]*)" + r"(?P[\"'])(?P[^\"'\r\n]+)(?P=quote)" + r"(?P[ \t]*(?:#[^\r\n]*)?\r?)$", + re.MULTILINE, +) + + +def _validated_version(version: str) -> tuple[int, int, int]: + match: re.Match[str] | None = VERSION_PATTERN.fullmatch(version) + if match is None: + raise ValueError("version must use strict major.minor.patch integers") + major: int + minor: int + patch: int + major, minor, patch = (int(group) for group in match.groups()) + return major, minor, patch + + +def bump_version(version: str, part: VersionPart) -> str: + """Increment one semantic-version part and reset lower-order parts.""" + + major, minor, patch = _validated_version(version) + if part == "major": + return f"{major + 1}.0.0" + if part == "minor": + return f"{major}.{minor + 1}.0" + if part == "patch": + return f"{major}.{minor}.{patch + 1}" + raise ValueError(f"unknown version part: {part}") + + +def _find_version_assignment(content: str, path: Path) -> re.Match[str]: + matches: list[re.Match[str]] = list(ASSIGNMENT_PATTERN.finditer(content)) + if len(matches) != 1: + raise ValueError(f"{path} must contain exactly one __version__ assignment") + return matches[0] + + +def read_version(path: Path) -> str: + """Read one strict semantic version from a Python module.""" + + content: str = path.read_bytes().decode("utf-8") + match: re.Match[str] = _find_version_assignment(content, path) + version: str = match.group("version") + _validated_version(version) + return version + + +def write_version(path: Path, version: str) -> None: + """Replace only the version value while preserving all other module content.""" + + _validated_version(version) + content: str = path.read_bytes().decode("utf-8") + match: re.Match[str] = _find_version_assignment(content, path) + replacement: str = ( + f"{match.group('prefix')}{match.group('quote')}{version}" + f"{match.group('quote')}{match.group('suffix')}" + ) + updated: str = f"{content[: match.start()]}{replacement}{content[match.end() :]}" + path.write_bytes(updated.encode("utf-8")) + + +class BumpVersion(Command): # type: ignore[misc] + """Setuptools command that bumps a designated Python version file.""" + + description: str = "bump a package semantic version" + user_options: ClassVar[ + list[tuple[str, str, str]] | list[tuple[str, str | None, str]] + ] = cast( + list[tuple[str, str | None, str]], + [ + ("version-file=", None, "Python file containing __version__"), + ("part=", None, "major, minor, or patch"), + ], + ) + + version_file: str | None + part: str | None + + def initialize_options(self) -> None: + self.version_file = None + self.part = "patch" + + def finalize_options(self) -> None: + if self.version_file is None: + raise OptionError("--version-file is required") + if self.part not in {"major", "minor", "patch"}: + raise OptionError("--part must be major, minor, or patch") + + def run(self) -> None: + if self.version_file is None or self.part not in { + "major", + "minor", + "patch", + }: + raise OptionError("command options were not finalized") + path: Path = Path(self.version_file) + current: str = read_version(path) + part: VersionPart = cast(VersionPart, self.part) + updated: str = bump_version(current, part) + write_version(path, updated) + self.announce(f"bumped {current} to {updated}", level=2) diff --git a/CH_18_packaging/exercise_01/test_solution_00.py b/CH_18_packaging/exercise_01/test_solution_00.py new file mode 100644 index 0000000..f47b4bd --- /dev/null +++ b/CH_18_packaging/exercise_01/test_solution_00.py @@ -0,0 +1,177 @@ +"""Tests for the non-interactive setuptools version-bump command.""" + +from __future__ import annotations + +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest + +from .solution_00 import VersionPart, bump_version, read_version, write_version + + +@pytest.mark.parametrize( + ("part", "expected"), + [ + ("major", "2.0.0"), + ("minor", "1.3.0"), + ("patch", "1.2.4"), + ], +) +def test_bump_version_parts(part: VersionPart, expected: str) -> None: + assert bump_version("1.2.3", part) == expected + + +@pytest.mark.parametrize( + "version", + ["1.2", "1.2.x", "v1.2.3", "1.-2.3", "01.2.3", "1.02.3", "1.2.03"], +) +def test_bump_version_rejects_invalid_versions(version: str) -> None: + with pytest.raises(ValueError, match=r"major\.minor\.patch"): + bump_version(version, "patch") + + +def test_bump_version_rejects_unknown_part() -> None: + with pytest.raises(ValueError, match="unknown version part"): + bump_version("1.2.3", "fourth") # type: ignore[arg-type] + + +def test_read_and_write_version_preserve_module_content(tmp_path: Path) -> None: + version_file: Path = tmp_path / "__init__.py" + version_file.write_text( + 'NAME = "demo"\n__version__ = "1.2.3"\nENABLED = True\n', + encoding="utf-8", + ) + + assert read_version(version_file) == "1.2.3" + write_version(version_file, "1.2.4") + + assert version_file.read_text(encoding="utf-8") == ( + 'NAME = "demo"\n__version__ = "1.2.4"\nENABLED = True\n' + ) + + +def test_read_and_write_version_preserve_crlf_and_mixed_file_bytes( + tmp_path: Path, +) -> None: + version_file: Path = tmp_path / "__init__.py" + original: bytes = ( + b'NAME = "demo"\r\n' + b'__version__ = "1.2.3"\r\n' + b"UNIX_LINE = True\n" + b'PAYLOAD = "\\r\\n"\r\n' + ) + version_file.write_bytes(original) + + assert read_version(version_file) == "1.2.3" + write_version(version_file, "1.2.4") + + assert version_file.read_bytes() == original.replace(b"1.2.3", b"1.2.4") + + +@pytest.mark.parametrize( + "content", + [ + 'NAME = "demo"\n', + '__version__ = "1.2.3"\n__version__ = "1.2.4"\n', + ], +) +def test_read_version_requires_exactly_one_assignment( + tmp_path: Path, + content: str, +) -> None: + version_file: Path = tmp_path / "__init__.py" + version_file.write_text(content, encoding="utf-8") + + with pytest.raises(ValueError, match="exactly one __version__ assignment"): + read_version(version_file) + + +def test_write_version_rejects_invalid_new_version(tmp_path: Path) -> None: + version_file: Path = tmp_path / "__init__.py" + version_file.write_text('__version__ = "1.2.3"\n', encoding="utf-8") + + with pytest.raises(ValueError, match=r"major\.minor\.patch"): + write_version(version_file, "1.2") + + assert version_file.read_text(encoding="utf-8") == '__version__ = "1.2.3"\n' + + +def _write_command_fixture(tmp_path: Path) -> tuple[Path, Path]: + package: Path = tmp_path / "demo_package" + package.mkdir() + version_file: Path = package / "__init__.py" + version_file.write_text('__version__ = "1.2.3"\n', encoding="utf-8") + untouched_file: Path = package / "metadata.py" + untouched_file.write_text('__version__ = "9.9.9"\n', encoding="utf-8") + shutil.copy( + Path(__file__).with_name("solution_00.py"), + tmp_path / "bump_command.py", + ) + (tmp_path / "setup.py").write_text( + "from setuptools import setup\n" + "from bump_command import BumpVersion\n" + "setup(name='demo-package', version='1.2.3', " + "cmdclass={'bump_version': BumpVersion})\n", + encoding="utf-8", + ) + return version_file, untouched_file + + +def _run_bump_command( + tmp_path: Path, + *options: str, +) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [sys.executable, "setup.py", "bump_version", *options], + cwd=tmp_path, + check=False, + capture_output=True, + text=True, + timeout=20, + ) + + +@pytest.mark.timeout(30) +def test_setuptools_command_defaults_to_patch_and_only_writes_designated_file( + tmp_path: Path, +) -> None: + version_file, untouched_file = _write_command_fixture(tmp_path) + + completed: subprocess.CompletedProcess[str] = _run_bump_command( + tmp_path, + "--version-file", + "demo_package/__init__.py", + ) + + assert completed.returncode == 0, completed.stderr + assert read_version(version_file) == "1.2.4" + assert read_version(untouched_file) == "9.9.9" + + +@pytest.mark.timeout(30) +def test_setuptools_command_accepts_explicit_part(tmp_path: Path) -> None: + version_file, _ = _write_command_fixture(tmp_path) + + completed: subprocess.CompletedProcess[str] = _run_bump_command( + tmp_path, + "--version-file", + "demo_package/__init__.py", + "--part", + "minor", + ) + + assert completed.returncode == 0, completed.stderr + assert read_version(version_file) == "1.3.0" + + +@pytest.mark.timeout(30) +def test_setuptools_command_validates_required_options(tmp_path: Path) -> None: + _write_command_fixture(tmp_path) + + completed: subprocess.CompletedProcess[str] = _run_bump_command(tmp_path) + + assert completed.returncode != 0 + assert "--version-file is required" in completed.stderr diff --git a/CH_18_packaging/exercise_02/README.rst b/CH_18_packaging/exercise_02/README.rst new file mode 100644 index 0000000..9de6b91 --- /dev/null +++ b/CH_18_packaging/exercise_02/README.rst @@ -0,0 +1,46 @@ +Exercise 2: interactive version command +======================================= + +Question +-------- + +.. code-block:: text + + 2. Extend the version bumping command by interactively asking for a major, minor, or patch upgrade + +Answer +------ + +``choose_version_part`` accepts ``major``, ``minor``, and ``patch`` without +case sensitivity, plus the numeric aliases ``1``, ``2``, and ``3``. Invalid +input raises ``ValueError`` instead of silently choosing a version component. +Its input function is injectable for deterministic direct tests. + +``InteractiveBumpVersion`` inherits the non-interactive command. It prompts by +default, including through subprocess standard input, while an explicit +``--part`` bypasses prompting for automation. + +Security and dependencies +------------------------- + +Python 3.10 or newer is required. The repository's ``packaging`` dependency +group supplies setuptools and the test tools. Subprocess tests use copied +temporary fixture projects, the active Python interpreter, captured output, and +timeouts. They neither modify the repository nor require network access. + +Run +--- + +Run the focused tests from the repository root: + +.. code-block:: console + + $ uv run --group packaging pytest CH_18_packaging/exercise_02/test_solution_00.py -vv + +Reference and provenance +------------------------ + +The implementation is informed by the chapter's immutable +`basic setup.py example +`_. +The upstream material is MIT licensed; this exercise solution is self-contained. diff --git a/CH_18_packaging/exercise_02/__init__.py b/CH_18_packaging/exercise_02/__init__.py new file mode 100644 index 0000000..1aceadc --- /dev/null +++ b/CH_18_packaging/exercise_02/__init__.py @@ -0,0 +1 @@ +"""Interactive setuptools version-bump command exercise.""" diff --git a/CH_18_packaging/exercise_02/solution_00.py b/CH_18_packaging/exercise_02/solution_00.py new file mode 100644 index 0000000..7a714dd --- /dev/null +++ b/CH_18_packaging/exercise_02/solution_00.py @@ -0,0 +1,47 @@ +"""Interactive extension of the setuptools version-bump command.""" + +from __future__ import annotations + +import builtins +from collections.abc import Callable + +from CH_18_packaging.exercise_01.solution_00 import BumpVersion, VersionPart + +CHOICES: dict[str, VersionPart] = { + "1": "major", + "major": "major", + "2": "minor", + "minor": "minor", + "3": "patch", + "patch": "patch", +} + + +def choose_version_part( + input_function: Callable[[str], str] | None = None, +) -> VersionPart: + """Read and validate a numeric or named semantic-version part.""" + + reader: Callable[[str], str] = ( + builtins.input if input_function is None else input_function + ) + answer: str = reader("Choose version bump: 1) major, 2) minor, 3) patch: ").strip() + try: + return CHOICES[answer.lower()] + except KeyError as error: + raise ValueError("choose major, minor, or patch") from error + + +class InteractiveBumpVersion(BumpVersion): + """Setuptools command that prompts unless ``--part`` is supplied.""" + + description: str = "interactively choose a package semantic-version bump" + + def initialize_options(self) -> None: + super().initialize_options() + self.part = "interactive" + + def finalize_options(self) -> None: + if self.part is None or self.part == "interactive": + self.part = choose_version_part() + super().finalize_options() diff --git a/CH_18_packaging/exercise_02/test_solution_00.py b/CH_18_packaging/exercise_02/test_solution_00.py new file mode 100644 index 0000000..5ce2f16 --- /dev/null +++ b/CH_18_packaging/exercise_02/test_solution_00.py @@ -0,0 +1,129 @@ +"""Tests for the interactive setuptools version-bump command.""" + +from __future__ import annotations + +import builtins +import os +import shutil +import subprocess +import sys +from collections.abc import Iterator +from pathlib import Path + +import pytest +from setuptools import Distribution # type: ignore[import-untyped] + +from CH_18_packaging.exercise_01.solution_00 import read_version + +from .solution_00 import InteractiveBumpVersion, choose_version_part + + +@pytest.mark.parametrize( + ("answer", "expected"), + [ + ("major", "major"), + ("2", "minor"), + ("PATCH", "patch"), + (" 1 ", "major"), + ("Minor", "minor"), + ("3", "patch"), + ], +) +def test_choose_version_part_accepts_names_and_numbers( + answer: str, + expected: str, +) -> None: + responses: Iterator[str] = iter([answer]) + + assert choose_version_part(lambda prompt: next(responses)) == expected + + +def test_choose_version_part_rejects_invalid_choice() -> None: + with pytest.raises(ValueError, match="major, minor, or patch"): + choose_version_part(lambda prompt: "fourth") + + +def _write_interactive_fixture(tmp_path: Path) -> Path: + package: Path = tmp_path / "demo_package" + package.mkdir() + version_file: Path = package / "__init__.py" + version_file.write_text('__version__ = "1.2.3"\n', encoding="utf-8") + shutil.copy( + Path(__file__).with_name("solution_00.py"), + tmp_path / "interactive_bump.py", + ) + (tmp_path / "setup.py").write_text( + "from setuptools import setup\n" + "from interactive_bump import InteractiveBumpVersion\n" + "setup(name='demo-package', version='1.2.3', " + "cmdclass={'bump_version': InteractiveBumpVersion})\n", + encoding="utf-8", + ) + return version_file + + +@pytest.mark.timeout(30) +def test_interactive_command_uses_stdin(tmp_path: Path) -> None: + version_file: Path = _write_interactive_fixture(tmp_path) + environment: dict[str, str] = os.environ.copy() + environment["PYTHONPATH"] = str(Path(__file__).parents[2]) + + completed: subprocess.CompletedProcess[str] = subprocess.run( + [ + sys.executable, + "setup.py", + "bump_version", + "--version-file", + "demo_package/__init__.py", + ], + cwd=tmp_path, + env=environment, + input="minor\n", + check=False, + capture_output=True, + text=True, + timeout=20, + ) + + assert completed.returncode == 0, completed.stderr + assert read_version(version_file) == "1.3.0" + + +def test_explicit_part_bypasses_input( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + version_file: Path = tmp_path / "__init__.py" + version_file.write_text('__version__ = "1.2.3"\n', encoding="utf-8") + command: InteractiveBumpVersion = InteractiveBumpVersion(Distribution()) + command.version_file = str(version_file) + command.part = "major" + + def fail_input(prompt: str) -> str: + raise AssertionError(f"unexpected prompt: {prompt}") + + monkeypatch.setattr(builtins, "input", fail_input) + command.finalize_options() + command.run() + + assert read_version(version_file) == "2.0.0" + + +def test_direct_command_prompts_when_part_is_not_explicit( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + version_file: Path = tmp_path / "__init__.py" + version_file.write_text('__version__ = "1.2.3"\n', encoding="utf-8") + command: InteractiveBumpVersion = InteractiveBumpVersion(Distribution()) + command.version_file = str(version_file) + + def choose_patch(prompt: str) -> str: + return "patch" + + monkeypatch.setattr(builtins, "input", choose_patch) + + command.finalize_options() + command.run() + + assert read_version(version_file) == "1.2.4" diff --git a/CH_18_packaging/exercise_03/README.rst b/CH_18_packaging/exercise_03/README.rst new file mode 100644 index 0000000..462e5e0 --- /dev/null +++ b/CH_18_packaging/exercise_03/README.rst @@ -0,0 +1,63 @@ +Exercise 3: static legacy-project migration +=========================================== + +Question +-------- + +.. code-block:: text + + 3. Try and convert existing projects from `setup.py` to a `pyproject.toml` structure + +Answer +------ + +``read_setup_metadata`` parses the legacy file as an abstract syntax tree; it +never imports or executes ``setup.py``. The accepted static subset contains one +setup call with literal ``name``, ``version``, ``description``, +``python_requires``, and ``entry_points`` fields. Keyword expansion, dynamic +supported fields, duplicate setup calls or console-script names, and malformed +``name=module:function`` entries are rejected. + +``migrate_project`` leaves the source unchanged and creates a deterministic +``src``-layout project in a new destination. It copies only the package and +README, renders safely quoted modern metadata, and never copies ``setup.py``. +Distribution names are validated and must resolve to exactly one package child +beneath the source. Source and destination paths cannot contain each other. +Migration stages the complete output in a sibling temporary directory, removes +that directory after any failure, and publishes only through a final rename. +``setup.py``, the README, the package root, and every package descendant must +not be symlinks. A no-follow recursive validation rejects file and directory +links before any destination or temporary migration directory is created. + +Security and dependencies +------------------------- + +Python 3.10 or newer is required. The repository's ``packaging`` dependency +group supplies setuptools, build, and wheel. Tests copy the committed legacy +fixture into pytest temporary storage, build with ``python -m build +--no-isolation``, require exactly one wheel and one source distribution, inspect +their metadata and contents, and smoke-test the wheel import and console target. +All subprocesses use captured output and timeouts. No setup code executes during +metadata extraction, no build artifact enters the repository, and no network +access is required. + +Run +--- + +Run the focused tests from the repository root: + +.. code-block:: console + + $ uv run --group packaging pytest CH_18_packaging/exercise_03/test_solution_00.py -vv + +References and provenance +------------------------- + +The implementation is informed by the chapter's immutable +`basic setup.py example +`_ +and +`basic pyproject.toml example +`_. +The upstream material is MIT licensed; this exercise solution and fixture are +self-contained. diff --git a/CH_18_packaging/exercise_03/__init__.py b/CH_18_packaging/exercise_03/__init__.py new file mode 100644 index 0000000..74e973a --- /dev/null +++ b/CH_18_packaging/exercise_03/__init__.py @@ -0,0 +1 @@ +"""Static legacy-project migration exercise.""" diff --git a/CH_18_packaging/exercise_03/fixtures/legacy_project/README.rst b/CH_18_packaging/exercise_03/fixtures/legacy_project/README.rst new file mode 100644 index 0000000..fc88c98 --- /dev/null +++ b/CH_18_packaging/exercise_03/fixtures/legacy_project/README.rst @@ -0,0 +1,4 @@ +Greeting demo legacy fixture +============================ + +This intentionally legacy project exists only for the packaging migration exercise. diff --git a/CH_18_packaging/exercise_03/fixtures/legacy_project/greeting_demo/__init__.py b/CH_18_packaging/exercise_03/fixtures/legacy_project/greeting_demo/__init__.py new file mode 100644 index 0000000..0ed26a2 --- /dev/null +++ b/CH_18_packaging/exercise_03/fixtures/legacy_project/greeting_demo/__init__.py @@ -0,0 +1,17 @@ +"""Tiny package used by the legacy-project migration fixture.""" + +from __future__ import annotations + +__version__: str = "1.2.3" + + +def greeting(name: str) -> str: + """Return a deterministic greeting.""" + + return f"Hello, {name}!" + + +def main() -> None: + """Print the console-script greeting.""" + + print(greeting("packaging")) diff --git a/CH_18_packaging/exercise_03/fixtures/legacy_project/setup.py b/CH_18_packaging/exercise_03/fixtures/legacy_project/setup.py new file mode 100644 index 0000000..490f7a8 --- /dev/null +++ b/CH_18_packaging/exercise_03/fixtures/legacy_project/setup.py @@ -0,0 +1,12 @@ +"""Intentionally legacy packaging fixture for the migration exercise.""" + +from setuptools import find_packages, setup + +setup( + name="greeting-demo", + version="1.2.3", + description="A tiny migration fixture", + packages=find_packages(), + python_requires=">=3.10", + entry_points={"console_scripts": ["greet=greeting_demo:main"]}, +) diff --git a/CH_18_packaging/exercise_03/solution_00.py b/CH_18_packaging/exercise_03/solution_00.py new file mode 100644 index 0000000..4301eba --- /dev/null +++ b/CH_18_packaging/exercise_03/solution_00.py @@ -0,0 +1,276 @@ +"""Safely migrate a constrained legacy setuptools project.""" + +from __future__ import annotations + +import ast +import json +import os +import re +import shutil +import tempfile +from dataclasses import dataclass +from pathlib import Path +from typing import cast + +SUPPORTED_FIELDS: frozenset[str] = frozenset( + {"name", "version", "description", "python_requires", "entry_points"} +) +REQUIRED_FIELDS: frozenset[str] = SUPPORTED_FIELDS +SCRIPT_NAME_PATTERN: re.Pattern[str] = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]*$") +DISTRIBUTION_NAME_PATTERN: re.Pattern[str] = re.compile( + r"^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$" +) + + +@dataclass(frozen=True) +class ProjectMetadata: + """Supported static metadata extracted from one legacy setup call.""" + + name: str + version: str + description: str + requires_python: str + scripts: dict[str, str] + + +def _is_setup_call(node: ast.Call) -> bool: + function: ast.expr = node.func + return (isinstance(function, ast.Name) and function.id == "setup") or ( + isinstance(function, ast.Attribute) and function.attr == "setup" + ) + + +def _find_setup_call(tree: ast.Module) -> ast.Call: + calls: list[ast.Call] = [ + node + for node in ast.walk(tree) + if isinstance(node, ast.Call) and _is_setup_call(node) + ] + if len(calls) != 1: + raise ValueError("setup.py must contain exactly one setup call") + call: ast.Call = calls[0] + if call.args: + raise ValueError("setup metadata must use named static fields") + return call + + +def _read_supported_fields(call: ast.Call) -> dict[str, object]: + values: dict[str, object] = {} + for keyword in call.keywords: + if keyword.arg is None: + raise ValueError("setup keyword expansion is not supported") + if keyword.arg not in SUPPORTED_FIELDS: + continue + if keyword.arg in values: + raise ValueError(f"duplicate setup field: {keyword.arg}") + try: + value: object = ast.literal_eval(keyword.value) + except (TypeError, ValueError) as error: + raise ValueError(f"{keyword.arg} must be a static literal") from error + values[keyword.arg] = value + missing: list[str] = sorted(REQUIRED_FIELDS.difference(values)) + if missing: + raise ValueError(f"required metadata fields missing: {', '.join(missing)}") + return values + + +def _parse_console_scripts(entry_points: object) -> dict[str, str]: + if not isinstance(entry_points, dict): + raise ValueError("entry_points must contain a console_scripts mapping") + groups: dict[object, object] = cast(dict[object, object], entry_points) + entries: object = groups.get("console_scripts") + if not isinstance(entries, (list, tuple)): + raise ValueError("entry_points must contain a console_scripts list") + script_entries: list[object] | tuple[object, ...] = cast( + list[object] | tuple[object, ...], + entries, + ) + scripts: dict[str, str] = {} + for entry in script_entries: + if not isinstance(entry, str) or entry.count("=") != 1: + raise ValueError("each console script must use name=module:function") + name, target = (part.strip() for part in entry.split("=", maxsplit=1)) + if ( + SCRIPT_NAME_PATTERN.fullmatch(name) is None + or target.count(":") != 1 + or any(not part.strip() for part in target.split(":", maxsplit=1)) + ): + raise ValueError("each console script must use name=module:function") + if name in scripts: + raise ValueError(f"duplicate console script: {name}") + scripts[name] = target + return scripts + + +def _required_string(values: dict[str, object], field: str) -> str: + value: object = values[field] + if not isinstance(value, str) or not value: + raise ValueError(f"{field} must be a non-empty static literal string") + return value + + +def read_setup_metadata(setup_py: Path) -> ProjectMetadata: + """Parse supported metadata without importing or executing ``setup.py``.""" + + source: str = setup_py.read_text(encoding="utf-8") + try: + tree: ast.Module = ast.parse(source, filename=str(setup_py)) + except SyntaxError as error: + raise ValueError("setup.py must contain valid Python") from error + values: dict[str, object] = _read_supported_fields(_find_setup_call(tree)) + return ProjectMetadata( + name=_required_string(values, "name"), + version=_required_string(values, "version"), + description=_required_string(values, "description"), + requires_python=_required_string(values, "python_requires"), + scripts=_parse_console_scripts(values["entry_points"]), + ) + + +def _toml_string(value: str) -> str: + return json.dumps(value, ensure_ascii=False) + + +def render_pyproject(metadata: ProjectMetadata) -> str: + """Render deterministic TOML using safely quoted basic strings.""" + + lines: list[str] = [ + "[build-system]", + 'requires = ["setuptools>=77", "wheel"]', + 'build-backend = "setuptools.build_meta"', + "", + "[project]", + f"name = {_toml_string(metadata.name)}", + f"version = {_toml_string(metadata.version)}", + f"description = {_toml_string(metadata.description)}", + 'readme = "README.rst"', + f"requires-python = {_toml_string(metadata.requires_python)}", + "", + "[project.scripts]", + ] + lines.extend( + f"{_toml_string(name)} = {_toml_string(target)}" + for name, target in sorted(metadata.scripts.items()) + ) + lines.extend( + [ + "", + "[tool.setuptools.packages.find]", + 'where = ["src"]', + "", + ] + ) + return "\n".join(lines) + + +def _resolve_package_source(source: Path, distribution_name: str) -> Path: + if DISTRIBUTION_NAME_PATTERN.fullmatch(distribution_name) is None: + raise ValueError(f"invalid distribution name: {distribution_name!r}") + package_name: str = re.sub(r"[-_.]+", "_", distribution_name) + source_resolved: Path = source.resolve() + package_candidate: Path = source_resolved / package_name + if package_candidate.is_symlink(): + raise ValueError("package root must not be a symlink") + package_source: Path = package_candidate.resolve() + try: + relative: Path = package_source.relative_to(source_resolved) + except ValueError as error: + raise ValueError("distribution name must resolve inside source") from error + if relative.parts != (package_name,) or package_source == source_resolved: + raise ValueError("distribution name must resolve to one package child") + if not package_source.is_dir(): + raise FileNotFoundError(package_source) + return package_source + + +def _validate_regular_file(path: Path, label: str) -> None: + if path.is_symlink(): + raise ValueError(f"{label} must not be a symlink") + if not path.is_file(): + raise FileNotFoundError(path) + + +def _validate_package_tree(package_source: Path) -> None: + if package_source.is_symlink(): + raise ValueError("package root must not be a symlink") + for root, directory_names, file_names in os.walk( + package_source, + topdown=True, + followlinks=False, + ): + directory_names.sort() + file_names.sort() + root_path: Path = Path(root) + for name in [*directory_names, *file_names]: + entry: Path = root_path / name + if entry.is_symlink(): + raise ValueError(f"package tree contains symlink: {entry}") + + +def _validate_migration_paths(source: Path, destination: Path) -> tuple[Path, Path]: + source_resolved: Path = source.resolve() + destination_resolved: Path = destination.resolve() + if not source_resolved.is_dir(): + raise FileNotFoundError(source_resolved) + if destination_resolved == source_resolved or ( + destination_resolved.is_relative_to(source_resolved) + or source_resolved.is_relative_to(destination_resolved) + ): + raise ValueError("source and destination must not contain each other") + if destination.exists() or destination.is_symlink(): + raise FileExistsError(destination) + destination_parent: Path = destination.parent + if not destination_parent.is_dir(): + raise FileNotFoundError(destination_parent) + return source_resolved, destination_parent + + +def _populate_migration( + temporary: Path, + package_source: Path, + readme_source: Path, + pyproject: str, +) -> None: + package_destination: Path = temporary / "src" / package_source.name + package_destination.parent.mkdir() + shutil.copytree( + package_source, + package_destination, + symlinks=True, + ignore=shutil.ignore_patterns("__pycache__", "*.pyc", "*.pyo"), + ) + _validate_package_tree(package_destination) + shutil.copy2(readme_source, temporary / "README.rst") + (temporary / "pyproject.toml").write_text(pyproject, encoding="utf-8") + + +def migrate_project(source: Path, destination: Path) -> Path: + """Create a modern src-layout copy without executing or copying setup.py.""" + + source_resolved, destination_parent = _validate_migration_paths( + source, + destination, + ) + setup_py: Path = source_resolved / "setup.py" + _validate_regular_file(setup_py, "setup.py") + metadata: ProjectMetadata = read_setup_metadata(setup_py) + package_source: Path = _resolve_package_source(source_resolved, metadata.name) + _validate_package_tree(package_source) + readme_source: Path = source_resolved / "README.rst" + _validate_regular_file(readme_source, "README.rst") + pyproject: str = render_pyproject(metadata) + temporary: Path = Path( + tempfile.mkdtemp( + prefix=f".{destination.name}.tmp-", + dir=destination_parent, + ) + ) + try: + _populate_migration(temporary, package_source, readme_source, pyproject) + if destination.exists() or destination.is_symlink(): + raise FileExistsError(destination) + temporary.rename(destination) + except BaseException: + shutil.rmtree(temporary) + raise + return destination diff --git a/CH_18_packaging/exercise_03/test_solution_00.py b/CH_18_packaging/exercise_03/test_solution_00.py new file mode 100644 index 0000000..7a2259f --- /dev/null +++ b/CH_18_packaging/exercise_03/test_solution_00.py @@ -0,0 +1,411 @@ +"""Tests for safe static migration from legacy packaging metadata.""" + +from __future__ import annotations + +import os +import shutil +import subprocess +import sys +import tarfile +import zipfile +from dataclasses import FrozenInstanceError +from pathlib import Path + +import pytest + +from .solution_00 import ( + ProjectMetadata, + migrate_project, + read_setup_metadata, + render_pyproject, +) + +FIXTURE: Path = Path(__file__).with_name("fixtures") / "legacy_project" + + +def test_project_metadata_is_frozen() -> None: + metadata: ProjectMetadata = ProjectMetadata( + name="demo", + version="1.2.3", + description="Demo", + requires_python=">=3.10", + scripts={}, + ) + + with pytest.raises(FrozenInstanceError): + metadata.name = "changed" # type: ignore[misc] + + +def test_read_setup_metadata_without_executing_setup(tmp_path: Path) -> None: + marker: Path = tmp_path / "executed.txt" + setup_py: Path = tmp_path / "setup.py" + setup_py.write_text( + "from pathlib import Path\n" + "from setuptools import setup\n" + f"Path({str(marker)!r}).write_text('executed')\n" + "setup(" + "name='greeting-demo', " + "version='1.2.3', " + "description='A tiny migration fixture', " + "python_requires='>=3.10', " + "entry_points={'console_scripts': ['greet=greeting_demo:main']}" + ")\n", + encoding="utf-8", + ) + + metadata: ProjectMetadata = read_setup_metadata(setup_py) + + assert metadata == ProjectMetadata( + name="greeting-demo", + version="1.2.3", + description="A tiny migration fixture", + requires_python=">=3.10", + scripts={"greet": "greeting_demo:main"}, + ) + assert not marker.exists() + + +def test_read_committed_fixture_metadata() -> None: + assert read_setup_metadata(FIXTURE / "setup.py") == ProjectMetadata( + name="greeting-demo", + version="1.2.3", + description="A tiny migration fixture", + requires_python=">=3.10", + scripts={"greet": "greeting_demo:main"}, + ) + + +@pytest.mark.parametrize( + ("content", "message"), + [ + ( + "from setuptools import setup\nsetup(name=get_name(), version='1.0.0')\n", + "static literal", + ), + ( + "from setuptools import setup\n" + "values = {'name': 'demo'}\n" + "setup(**values)\n", + "keyword expansion", + ), + ( + "from setuptools import setup\nsetup(name='one')\nsetup(name='two')\n", + "exactly one setup", + ), + ( + "from setuptools import setup\n", + "exactly one setup", + ), + ( + "from setuptools import setup\n" + "setup(name='demo', version='1.0.0', description='Demo', " + "python_requires='>=3.10', " + "entry_points={'console_scripts': ['broken']})\n", + "console script", + ), + ( + "from setuptools import setup\n" + "setup(name='demo', version='1.0.0', description='Demo', " + "python_requires='>=3.10', " + "entry_points={'console_scripts': [" + "'greet=demo:main', 'greet=demo:other']})\n", + "duplicate console script", + ), + ( + "from setuptools import setup\n" + "setup(name='demo', version='1.0.0', description='Demo', " + "python_requires='>=3.10')\n", + "required metadata", + ), + ( + "from setuptools import setup\nsetup(name='unterminated)\n", + "valid Python", + ), + ], +) +def test_invalid_or_dynamic_setup_metadata_is_rejected( + tmp_path: Path, + content: str, + message: str, +) -> None: + setup_py: Path = tmp_path / "setup.py" + setup_py.write_text(content, encoding="utf-8") + + with pytest.raises(ValueError, match=message): + read_setup_metadata(setup_py) + + +def test_render_pyproject_quotes_toml_strings_safely() -> None: + metadata: ProjectMetadata = ProjectMetadata( + name="quote-demo", + version="1.2.3", + description='A "quoted" description\non two lines', + requires_python=">=3.10", + scripts={"greet-tool": "greeting_demo:main"}, + ) + + rendered: str = render_pyproject(metadata) + + assert 'requires = ["setuptools>=77", "wheel"]' in rendered + assert "setuptools>=80" not in rendered + assert 'description = "A \\"quoted\\" description\\non two lines"' in rendered + assert '"greet-tool" = "greeting_demo:main"' in rendered + + +def _source_snapshot(source: Path) -> dict[str, bytes]: + return { + str(path.relative_to(source)): ( + b"SYMLINK\0" + os.fsencode(path.readlink()) + if path.is_symlink() + else path.read_bytes() + ) + for path in sorted(source.rglob("*")) + if path.is_symlink() or path.is_file() + } + + +def test_migrate_project_creates_deterministic_src_layout(tmp_path: Path) -> None: + legacy_copy: Path = tmp_path / "legacy" + shutil.copytree(FIXTURE, legacy_copy) + before: dict[str, bytes] = _source_snapshot(legacy_copy) + destination: Path = tmp_path / "migrated" + + migrated: Path = migrate_project(legacy_copy, destination) + + assert migrated == destination + assert not (migrated / "setup.py").exists() + assert (migrated / "README.rst").read_bytes() == ( + legacy_copy / "README.rst" + ).read_bytes() + assert (migrated / "src" / "greeting_demo" / "__init__.py").read_bytes() == ( + legacy_copy / "greeting_demo" / "__init__.py" + ).read_bytes() + assert _source_snapshot(legacy_copy) == before + + +def test_migrate_project_refuses_existing_destination(tmp_path: Path) -> None: + destination: Path = tmp_path / "migrated" + destination.mkdir() + + with pytest.raises(FileExistsError): + migrate_project(FIXTURE, destination) + + +@pytest.mark.parametrize("distribution_name", [".", "../x", "a/b", r"a\b"]) +def test_migrate_project_rejects_unsafe_distribution_names_before_copying( + tmp_path: Path, + distribution_name: str, +) -> None: + source: Path = tmp_path / "legacy" + source.mkdir() + setup_py: Path = source / "setup.py" + setup_py.write_text( + "from setuptools import setup\n" + f"setup(name={distribution_name!r}, version='1.2.3', " + "description='Unsafe name fixture', python_requires='>=3.10', " + "entry_points={'console_scripts': ['greet=greeting_demo:main']})\n", + encoding="utf-8", + ) + (source / "README.rst").write_text("Unsafe fixture\n==============\n") + destination: Path = tmp_path / "migrated" + original_setup: bytes = setup_py.read_bytes() + + with pytest.raises(ValueError, match="distribution name"): + migrate_project(source, destination) + + assert setup_py.read_bytes() == original_setup + assert not destination.exists() + + +def test_migrate_project_rejects_destination_inside_source_without_mutation( + tmp_path: Path, +) -> None: + source: Path = tmp_path / "legacy" + shutil.copytree(FIXTURE, source) + before: dict[str, bytes] = _source_snapshot(source) + destination: Path = source / "migrated" + + with pytest.raises(ValueError, match="source and destination"): + migrate_project(source, destination) + + assert _source_snapshot(source) == before + assert not destination.exists() + + +@pytest.mark.parametrize("failure_point", ["copy2", "write"]) +def test_migrate_project_failure_is_transactional( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + failure_point: str, +) -> None: + source: Path = tmp_path / "legacy" + shutil.copytree(FIXTURE, source) + before: dict[str, bytes] = _source_snapshot(source) + destination: Path = tmp_path / "migrated" + + if failure_point == "copy2": + + def fail_copy2(source_path: Path, destination_path: Path) -> None: + raise OSError("injected copy2 failure") + + monkeypatch.setattr(shutil, "copy2", fail_copy2) + else: + + def fail_write_text( + path: Path, + data: str, + encoding: str | None = None, + errors: str | None = None, + newline: str | None = None, + ) -> int: + raise OSError("injected write failure") + + monkeypatch.setattr(Path, "write_text", fail_write_text) + + with pytest.raises(OSError, match="injected"): + migrate_project(source, destination) + + assert _source_snapshot(source) == before + assert not destination.exists() + assert list(tmp_path.glob(".migrated.tmp-*")) == [] + + +def _assert_symlink_migration_rejected( + source: Path, + destination: Path, + before: dict[str, bytes], +) -> None: + with pytest.raises(ValueError, match="symlink"): + migrate_project(source, destination) + + assert _source_snapshot(source) == before + assert not destination.exists() + assert list(destination.parent.glob(f".{destination.name}.tmp-*")) == [] + + +def test_migrate_project_rejects_external_file_symlink(tmp_path: Path) -> None: + source: Path = tmp_path / "legacy" + shutil.copytree(FIXTURE, source) + external_secret: Path = tmp_path / "secret.txt" + external_secret.write_text("not package data\n", encoding="utf-8") + (source / "greeting_demo" / "secret.txt").symlink_to(external_secret) + before: dict[str, bytes] = _source_snapshot(source) + + _assert_symlink_migration_rejected(source, tmp_path / "migrated", before) + + +def test_migrate_project_rejects_directory_symlink(tmp_path: Path) -> None: + source: Path = tmp_path / "legacy" + shutil.copytree(FIXTURE, source) + external_directory: Path = tmp_path / "external" + external_directory.mkdir() + (external_directory / "secret.txt").write_text("external\n", encoding="utf-8") + (source / "greeting_demo" / "external").symlink_to( + external_directory, + target_is_directory=True, + ) + before: dict[str, bytes] = _source_snapshot(source) + + _assert_symlink_migration_rejected(source, tmp_path / "migrated", before) + + +def test_migrate_project_rejects_symlink_package_root(tmp_path: Path) -> None: + source: Path = tmp_path / "legacy" + shutil.copytree(FIXTURE, source) + package: Path = source / "greeting_demo" + external_package: Path = tmp_path / "external_package" + shutil.copytree(package, external_package) + shutil.rmtree(package) + package.symlink_to(external_package, target_is_directory=True) + before: dict[str, bytes] = _source_snapshot(source) + + _assert_symlink_migration_rejected(source, tmp_path / "migrated", before) + + +def test_migrate_project_rejects_symlink_setup_py(tmp_path: Path) -> None: + source: Path = tmp_path / "legacy" + shutil.copytree(FIXTURE, source) + setup_py: Path = source / "setup.py" + external_setup: Path = tmp_path / "external_setup.py" + shutil.copy2(setup_py, external_setup) + setup_py.unlink() + setup_py.symlink_to(external_setup) + before: dict[str, bytes] = _source_snapshot(source) + + _assert_symlink_migration_rejected(source, tmp_path / "migrated", before) + + +@pytest.mark.timeout(90) +def test_migrated_project_builds_and_artifacts_are_usable(tmp_path: Path) -> None: + legacy_copy: Path = tmp_path / "legacy" + shutil.copytree(FIXTURE, legacy_copy) + migrated: Path = migrate_project(legacy_copy, tmp_path / "migrated") + dist: Path = tmp_path / "dist" + + completed: subprocess.CompletedProcess[str] = subprocess.run( + [ + sys.executable, + "-m", + "build", + "--no-isolation", + "--outdir", + str(dist), + str(migrated), + ], + cwd=tmp_path, + check=False, + capture_output=True, + text=True, + timeout=60, + ) + + assert completed.returncode == 0, completed.stderr + wheels: list[Path] = list(dist.glob("*.whl")) + sdists: list[Path] = list(dist.glob("*.tar.gz")) + assert len(wheels) == 1 + assert len(sdists) == 1 + + with zipfile.ZipFile(wheels[0]) as wheel: + wheel_names: set[str] = set(wheel.namelist()) + assert "greeting_demo/__init__.py" in wheel_names + metadata_name: str = next( + name for name in wheel_names if name.endswith(".dist-info/METADATA") + ) + entry_points_name: str = next( + name for name in wheel_names if name.endswith(".dist-info/entry_points.txt") + ) + metadata_text: str = wheel.read(metadata_name).decode("utf-8") + entry_points_text: str = wheel.read(entry_points_name).decode("utf-8") + assert "Name: greeting-demo" in metadata_text + assert "Version: 1.2.3" in metadata_text + assert "greet = greeting_demo:main" in entry_points_text + + with tarfile.open(sdists[0], mode="r:gz") as sdist: + sdist_names: list[str] = sdist.getnames() + assert any(name.endswith("/pyproject.toml") for name in sdist_names) + assert any( + name.endswith("/src/greeting_demo/__init__.py") for name in sdist_names + ) + assert not any(name.endswith("/setup.py") for name in sdist_names) + + environment: dict[str, str] = os.environ.copy() + environment["PYTHONPATH"] = str(wheels[0]) + smoke: subprocess.CompletedProcess[str] = subprocess.run( + [ + sys.executable, + "-c", + ( + "from greeting_demo import greeting, main; " + "print(greeting('wheel')); main()" + ), + ], + cwd=tmp_path, + env=environment, + check=False, + capture_output=True, + text=True, + timeout=20, + ) + + assert smoke.returncode == 0, smoke.stderr + assert smoke.stdout.splitlines() == ["Hello, wheel!", "Hello, packaging!"] diff --git a/README.rst b/README.rst index 0ae34e5..916eae5 100644 --- a/README.rst +++ b/README.rst @@ -1,9 +1,92 @@ Mastering Python Second Edition Exercises -======================================================================================================================= +========================================= -This repository contains both example exercise solutions and user submitted solutions. +This repository contains solutions for the exercises in *Mastering Python, +Second Edition*. Each ``solution_00.py`` is the maintained, typed reference +solution. Higher-numbered ``solution_NN.py`` files and specialized solution +files are community alternatives. -You are always free to submit your solution, so feel free to jump in and see if you can come up with an improvement -or alternative option! +Requirements +------------ -Naturally code style is a big part of the exercise, so please make sure your code is well formatted and indented. +Development requires Python 3.10 or newer, uv, and Lefthook. On macOS, install +the tools, synchronize every dependency group, and install the Git hooks: + +.. code-block:: console + + $ brew install uv lefthook + $ uv sync --all-groups + $ lefthook install + +Running exercises +----------------- + +Each ``exercise_NN`` directory contains its exact question, a concise +explanation, the canonical answer in ``solution_00.py``, its dependencies, and +focused tests. Run an answer or its tests from the repository root. For +example: + +.. code-block:: console + + $ uv run python CH_04_design_patterns/exercise_01/solution_00.py + $ uv run pytest CH_04_design_patterns/exercise_01/test_solution_00.py + +Run all checks +-------------- + +Run the complete formatting, linting, type-checking, and test suite before +submitting changes: + +.. code-block:: console + + $ uv run ruff format --check . + $ uv run ruff check . + $ uv run pyrefly check + $ uv run mypy . + $ uv run pyright + $ uv run pytest + +Dependency groups +----------------- + +The lockfile contains separate dependency groups so an exercise can use only +the tools it needs: + +``dev`` + Documentation rendering, formatting, linting, type-checking, and tests. +``interactive`` + Interactive Python, widgets, terminal colors, and completion support. +``scientific`` + Numerical, tabular, notebook, and visualization tools. +``nlp`` + Natural-language processing and its command-line support. +``native`` + Native-extension build tools and C foreign-function interfaces. +``packaging`` + Distribution builds, wheels, and isolated environment testing. + +Install a single optional group when working only in that domain: + +.. code-block:: console + + $ uv sync --group interactive + +After the package cache has been populated, reproduce the complete locked +environment without network access: + +.. code-block:: console + + $ uv sync --all-groups --locked --offline + +Contributing +------------ + +Alternative solutions are welcome and use the next available +``solution_NN.py``. Keep canonical files, tests, and exercise documentation +unchanged unless correcting the reference answer. All submitted Python code +must be formatted, linted, typed, and tested. Higher-numbered and specialized +community alternatives are intentionally excluded from automated canonical +gates to preserve historical files. New alternative pull requests must include +focused tests and report equivalent per-file Ruff, Pyrefly, mypy, and Pyright +results. The repository-wide commands above validate the maintained canonical +scope. diff --git a/docs/superpowers/plans/2026-07-28-ch02-interactive.md b/docs/superpowers/plans/2026-07-28-ch02-interactive.md new file mode 100644 index 0000000..c6b95f2 --- /dev/null +++ b/docs/superpowers/plans/2026-07-28-ch02-interactive.md @@ -0,0 +1,460 @@ +# Chapter 2 Interactive Python Implementation + +**For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` (recommended) or `superpowers:executing-plans` to implement this plan task-by-task. + +**Goal:** Add offline, Python 3.10-compatible canonical solutions and headless tests for all five Chapter 2 interactive-Python exercises. + +**Architecture:** Each exercise is an independent import-safe module with a small reusable API and a guarded demonstration. Exercise 1 vendors the minimum behavior of the book's pinned completer example and safely extends it; later exercises use public IPython, Jedi, Colorama, and ipywidgets APIs behind typed functions that can be tested without a terminal or notebook UI. + +**Tech Stack:** Python 3.10, `rlcompleter`, IPython, Jedi, Colorama, ipywidgets, pytest. + +--- + +## Exact Canonical File Map + +Each Chapter 2 directory is new. Create these files in addition to the +exercise-specific support files named by later tasks: + +- `CH_02_interactive_python/exercise_01/__init__.py` +- `CH_02_interactive_python/exercise_01/README.rst` +- `CH_02_interactive_python/exercise_01/solution_00.py` +- `CH_02_interactive_python/exercise_02/__init__.py` +- `CH_02_interactive_python/exercise_02/README.rst` +- `CH_02_interactive_python/exercise_02/solution_00.py` +- `CH_02_interactive_python/exercise_03/__init__.py` +- `CH_02_interactive_python/exercise_03/README.rst` +- `CH_02_interactive_python/exercise_03/solution_00.py` +- `CH_02_interactive_python/exercise_04/__init__.py` +- `CH_02_interactive_python/exercise_04/README.rst` +- `CH_02_interactive_python/exercise_04/solution_00.py` +- `CH_02_interactive_python/exercise_05/__init__.py` +- `CH_02_interactive_python/exercise_05/README.rst` +- `CH_02_interactive_python/exercise_05/solution_00.py` + +## Scope and file map + +Do not edit shared `pyproject.toml`, `uv.lock`, CI, Lefthook, or root documentation; the coordinator owns them. Create: + +- `CH_02_interactive_python/exercise_01/{__init__.py,README.rst,solution_00.py,test_solution_00.py}` — mapping and sequence completion. +- `CH_02_interactive_python/exercise_02/{__init__.py,README.rst,solution_00.py,test_solution_00.py}` — ANSI-colored completion. +- `CH_02_interactive_python/exercise_03/{__init__.py,README.rst,solution_00.py,test_solution_00.py}` — Jedi static completion. +- `CH_02_interactive_python/exercise_04/{__init__.py,README.rst,solution_00.py,test_solution_00.py}` — editable greeting widget. +- `CH_02_interactive_python/exercise_05/{__init__.py,README.rst,solution_00.py,test_solution_00.py}` — all-session history search and CLI. +- Modify `CH_02_interactive_python/README.rst` only after all five exercise directories pass. + +Use this immutable book source where the original completer/history examples inform the answer: + +`https://github.com/mastering-python/code_2/blob/a012f6ce3f5bf11f5bf380d1c4e7f743355adb89/CH_02_interactive_python/T_02_modifying_autocompletion.py` + +Exercise 5 additionally cites: + +`https://github.com/mastering-python/code_2/blob/a012f6ce3f5bf11f5bf380d1c4e7f743355adb89/CH_02_interactive_python/session_filename.ipy` + +## Task 1: Complete mappings and finite sequences + +**Public API:** `Completer(namespace: Mapping[str, object] | None = None)`, `Completer.item_matches(text: str) -> Iterator[str]`, and inherited `complete(text: str, state: int) -> str | None`. + +**Behavioral decisions:** Mapping completions use `repr(key)`; list, tuple, and string completions use numeric indexes; matches preserve container order and stop at 25 entries; numeric prefixes filter sequence indexes. Resolve only names, attributes, and constant subscripts—never execute calls from completion text. + +- [ ] **Step 1: Write the failing contract tests** + +Create `test_solution_00.py` with representative cases: + +```python +from CH_02_interactive_python.exercise_01.solution_00 import Completer + + +def test_mapping_and_sequence_matches() -> None: + completer: Completer = Completer( + {"food": {"spam": 1, "eggs": 2}, "values": ["a", "b", "c"]} + ) + assert list(completer.item_matches("food['s")) == ["food['spam']"] + assert list(completer.item_matches("values[")) == [ + "values[0]", + "values[1]", + "values[2]", + ] + assert list(completer.item_matches("values[1")) == ["values[1]"] + + +def test_completion_does_not_execute_calls() -> None: + called: list[bool] = [] + + def dangerous() -> list[str]: + called.append(True) + return ["never"] + + completer: Completer = Completer({"dangerous": dangerous}) + assert list(completer.item_matches("dangerous()[")) == [] + assert called == [] +``` + +Also cover tuple/string indexing, missing names, unsupported objects, a 26-item cap, and repeated `complete(..., state)` calls. + +- [ ] **Step 2: Run the focused test and verify the red state** + +Run: `uv run pytest CH_02_interactive_python/exercise_01/test_solution_00.py -v` + +Expected: collection fails with `ModuleNotFoundError` because `exercise_01` does not exist. + +- [ ] **Step 3: Implement the minimal safe completer** + +Create empty `__init__.py`, then implement `Completer`. The resolver must recursively handle `ast.Name`, `ast.Attribute`, and `ast.Subscript` with `ast.literal_eval` keys and raise `ValueError` for every other node. The key branch is: + +```python +class Completer(rlcompleter.Completer): + MAX_MATCHES: int = 25 + + def item_matches(self, text: str) -> Iterator[str]: + match: re.Match[str] | None = ITEM_RE.fullmatch(text) + if match is None: + return + expression: str = match.group("expression") + prefix: str = match.group("key").strip() + try: + value: object = _resolve_expression(expression, self.namespace) + except (AttributeError, KeyError, TypeError, ValueError): + return + if isinstance(value, Mapping): + for key in itertools.islice(value, self.MAX_MATCHES): + candidate: str = repr(key) + if candidate.lstrip("'\"").startswith(prefix.lstrip("'\"")): + yield f"{expression}[{candidate}]" + elif isinstance(value, (list, tuple, str)): + for index in range(min(len(value), self.MAX_MATCHES)): + if str(index).startswith(prefix): + yield f"{expression}[{index}]" +``` + +`complete()` must cache `item_matches()` at state zero and otherwise delegate to `rlcompleter.Completer.complete`. Keep `readline` installation in `main()` so imports do not alter global terminal state. + +- [ ] **Step 4: Add exercise documentation** + +`README.rst` must repeat Chapter 2 question 1 verbatim, describe safe expression resolution and finite sequence indexing, list no third-party dependency, show: + +```console +uv run python -m CH_02_interactive_python.exercise_01.solution_00 +uv run pytest CH_02_interactive_python/exercise_01 -v +``` + +Include the pinned book permalink above and its MIT attribution. + +- [ ] **Step 5: Verify and commit** + +Run: `uv run pytest CH_02_interactive_python/exercise_01 -v` + +Expected: all tests pass. + +```bash +git add CH_02_interactive_python/exercise_01 +git commit -m "feat(ch02): complete container autocompletion" +``` + +## Task 2: Add colored completion + +**Public API:** `ColorCompleter(Completer)` with keyword-only `color: str = Fore.CYAN`; `colorize(match: str, color: str) -> str`. + +- [ ] **Step 1: Write the failing tests** + +```python +from colorama import Fore, Style + +from CH_02_interactive_python.exercise_02.solution_00 import ( + ColorCompleter, + colorize, +) + + +def test_colorize_wraps_and_resets_match() -> None: + assert colorize("values[0]", Fore.GREEN) == ( + f"{Fore.GREEN}values[0]{Style.RESET_ALL}" + ) + + +def test_color_completer_preserves_none_sentinel() -> None: + completer: ColorCompleter = ColorCompleter({"values": [1]}, color=Fore.BLUE) + assert completer.complete("values[", 0) == ( + f"{Fore.BLUE}values[0]{Style.RESET_ALL}" + ) + assert completer.complete("values[", 1) is None +``` + +Also assert state zero returns colored text whose ANSI-stripped value is `values[0]`. + +- [ ] **Step 2: Verify failure** + +Run: `uv run pytest CH_02_interactive_python/exercise_02/test_solution_00.py -v` + +Expected: `ModuleNotFoundError` for `exercise_02`. + +- [ ] **Step 3: Implement** + +Subclass Exercise 1's `Completer`; do not duplicate its parser: + +```python +def colorize(match: str, color: str) -> str: + return f"{color}{match}{Style.RESET_ALL}" + + +class ColorCompleter(Completer): + def __init__( + self, + namespace: Mapping[str, object] | None = None, + *, + color: str = Fore.CYAN, + ) -> None: + super().__init__(namespace) + self.color: str = color + + def complete(self, text: str, state: int) -> str | None: + match: str | None = super().complete(text, state) + return None if match is None else colorize(match, self.color) +``` + +Guard `colorama.just_fix_windows_console()` and readline setup in `main()`. + +- [ ] **Step 4: Document, verify, and commit** + +The exercise README repeats question 2, names Colorama as its dependency, documents reset behavior, includes the same pinned book reference, and shows module/test `uv run` commands. + +Run: `uv run pytest CH_02_interactive_python/exercise_02 -v` + +Expected: all tests pass. + +```bash +git add CH_02_interactive_python/exercise_02 +git commit -m "feat(ch02): color completion candidates" +``` + +## Task 3: Complete source with Jedi + +**Public API:** `complete_source(source: str, *, line: int | None = None, column: int | None = None) -> list[str]`. + +**Behavioral decisions:** Defaults target the end of the source; results are deduplicated and sorted; invalid coordinates raise `ValueError`; incomplete or invalid Python source uses Jedi's normal best-effort completion result. + +- [ ] **Step 1: Write failing tests** + +```python +import pytest + +from CH_02_interactive_python.exercise_03.solution_00 import complete_source + + +def test_completes_static_source_without_execution() -> None: + source: str = "import pathlib\npathlib.Pa" + assert "Path" in complete_source(source) + + +def test_rejects_invalid_coordinates() -> None: + with pytest.raises(ValueError, match="line"): + complete_source("value = 1", line=0, column=0) +``` + +Also test an explicit middle-of-source coordinate, deterministic ordering, empty source, and source containing a function call that must not execute. + +- [ ] **Step 2: Verify failure** + +Run: `uv run pytest CH_02_interactive_python/exercise_03/test_solution_00.py -v` + +Expected: `ModuleNotFoundError`. + +- [ ] **Step 3: Implement** + +```python +def complete_source( + source: str, + *, + line: int | None = None, + column: int | None = None, +) -> list[str]: + lines: list[str] = source.splitlines() or [""] + selected_line: int = len(lines) if line is None else line + if selected_line < 1 or selected_line > len(lines): + raise ValueError("line is outside the source") + selected_column: int = ( + len(lines[selected_line - 1]) if column is None else column + ) + if selected_column < 0 or selected_column > len(lines[selected_line - 1]): + raise ValueError("column is outside the selected line") + completions: list[jedi.api.classes.Completion] = jedi.Script( + code=source + ).complete(selected_line, selected_column) + return sorted({completion.name_with_symbols for completion in completions}) +``` + +Do not catch Jedi/internal exceptions: coordinate errors are validated locally, while dependency failures must propagate rather than masquerade as “no completions.” + +- [ ] **Step 4: Document, verify, and commit** + +README: exact question 3, Jedi dependency, static-analysis/no-execution guarantee, coordinate rules, and module/test commands. + +Run: `uv run pytest CH_02_interactive_python/exercise_03 -v` + +Expected: all tests pass. + +```bash +git add CH_02_interactive_python/exercise_03 +git commit -m "feat(ch02): add Jedi source completion" +``` + +## Task 4: Build an editable Hello widget + +**Public API:** immutable `HelloWidget(name_input: widgets.Text, greeting: widgets.Label, container: widgets.VBox)` and `create_hello_widget(initial_name: str = "World") -> HelloWidget`. + +- [ ] **Step 1: Write failing headless tests** + +```python +from CH_02_interactive_python.exercise_04.solution_00 import ( + HelloWidget, + create_hello_widget, +) + + +def test_name_edit_updates_greeting_without_running_code_again() -> None: + hello: HelloWidget = create_hello_widget("Ada") + assert hello.greeting.value == "Hello, Ada!" + hello.name_input.value = "Grace" + assert hello.greeting.value == "Hello, Grace!" + assert hello.container.children == (hello.name_input, hello.greeting) +``` + +Also test empty input produces `Hello, World!` and two widget instances do not share state. + +- [ ] **Step 2: Verify failure** + +Run: `uv run pytest CH_02_interactive_python/exercise_04/test_solution_00.py -v` + +Expected: `ModuleNotFoundError`. + +- [ ] **Step 3: Implement** + +```python +@dataclass(frozen=True) +class HelloWidget: + name_input: widgets.Text + greeting: widgets.Label + container: widgets.VBox + + +def create_hello_widget(initial_name: str = "World") -> HelloWidget: + name_input: widgets.Text = widgets.Text(value=initial_name, description="Name") + greeting: widgets.Label = widgets.Label() + + def update(change: dict[str, object]) -> None: + raw_name: object = change["new"] + name: str = raw_name.strip() if isinstance(raw_name, str) else "" + greeting.value = f"Hello, {name or 'World'}!" + + name_input.observe(update, names="value") + update({"new": initial_name}) + container: widgets.VBox = widgets.VBox((name_input, greeting)) + return HelloWidget(name_input, greeting, container) +``` + +`main()` imports `IPython.display.display` locally and displays only `container`. + +- [ ] **Step 4: Document, verify, and commit** + +README: exact question 4, ipywidgets/IPython dependencies, headless behavior, notebook usage, and module/test commands. + +Run: `uv run pytest CH_02_interactive_python/exercise_04 -v` + +Expected: all tests pass without opening a browser. + +```bash +git add CH_02_interactive_python/exercise_04 +git commit -m "feat(ch02): add editable greeting widget" +``` + +## Task 5: Search all prior IPython sessions + +**Public API:** immutable `HistoryMatch(session: int, line: int, source: str)`, `search_history(pattern: str, records: Iterable[tuple[int, int, str]]) -> list[HistoryMatch]`, `iter_ipython_history(accessor: HistoryAccessor | None = None) -> Iterator[tuple[int, int, str]]`, and `main(argv: Sequence[str] | None = None) -> int`. + +**Behavioral decisions:** Pattern syntax is a compiled regular expression; invalid patterns raise `re.error`; search covers raw input from every stored session; ordering is `(session, line)`; CLI returns 0 with matches, 1 with no matches, and lets invalid regex errors surface with a clear message. + +- [ ] **Step 1: Write failing pure and headless-adapter tests** + +```python +from CH_02_interactive_python.exercise_05.solution_00 import ( + HistoryMatch, + search_history, +) + + +def test_searches_across_sessions_in_order() -> None: + records: list[tuple[int, int, str]] = [ + (2, 4, "print('spam')"), + (1, 3, "value = 'spam'"), + (1, 2, "eggs = 1"), + ] + assert search_history(r"spam", records) == [ + HistoryMatch(1, 3, "value = 'spam'"), + HistoryMatch(2, 4, "print('spam')"), + ] +``` + +Add tests for no matches, multiline input, invalid regex, CLI output/exit codes, and an actual `IPython.core.history.HistoryAccessor` backed by `tmp_path / "history.sqlite"` so no user profile is read or written. + +- [ ] **Step 2: Verify failure** + +Run: `uv run pytest CH_02_interactive_python/exercise_05/test_solution_00.py -v` + +Expected: `ModuleNotFoundError`. + +- [ ] **Step 3: Implement** + +```python +@dataclass(frozen=True, order=True) +class HistoryMatch: + session: int + line: int + source: str = field(compare=False) + + +def search_history( + pattern: str, + records: Iterable[tuple[int, int, str]], +) -> list[HistoryMatch]: + regex: re.Pattern[str] = re.compile(pattern) + matches: list[HistoryMatch] = [ + HistoryMatch(session, line, source) + for session, line, source in records + if regex.search(source) is not None + ] + return sorted(matches) + + +def iter_ipython_history( + accessor: HistoryAccessor | None = None, +) -> Iterator[tuple[int, int, str]]: + history: HistoryAccessor = accessor or HistoryAccessor() + yield from history.search("*", raw=True, search_raw=True, unique=False) +``` + +The CLI formats each hit as `session:line: source`. Close only an accessor created by this module; never close an injected object. + +- [ ] **Step 4: Document and update the chapter index** + +README: exact question 5, regex choice, IPython dependency, privacy note that the default CLI reads local IPython history, headless temporary-database test strategy, module/test commands, and the full pinned `session_filename.ipy` permalink listed above. + +Replace the chapter README's bare list with five numbered RST links to the exercise READMEs while keeping each one-line question recognizable. + +- [ ] **Step 5: Run packet quality gates** + +```bash +uv run ruff format --check CH_02_interactive_python +uv run ruff check CH_02_interactive_python +uv run pyrefly check CH_02_interactive_python +uv run mypy CH_02_interactive_python +uv run pyright CH_02_interactive_python +uv run pytest CH_02_interactive_python -v +``` + +Expected: every command exits 0; tests use only temporary history storage and open no UI. + +- [ ] **Step 6: Commit** + +```bash +git add CH_02_interactive_python +git commit -m "feat(ch02): search IPython session history" +``` diff --git a/docs/superpowers/plans/2026-07-28-ch04-ch05-collections-functional.md b/docs/superpowers/plans/2026-07-28-ch04-ch05-collections-functional.md new file mode 100644 index 0000000..12abe16 --- /dev/null +++ b/docs/superpowers/plans/2026-07-28-ch04-ch05-collections-functional.md @@ -0,0 +1,438 @@ +# Chapters 4–5 Collections and Functional Programming Implementation + +**For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` (recommended) or `superpowers:executing-plans` to implement this plan task-by-task. + +**Goal:** Deliver typed, tested canonical solutions for the three design-pattern exercises and three functional-programming exercises. + +**Architecture:** Sorted mappings are mapping views over ordinary storage; the logarithmic-insertion sorted collection is a deterministic AVL multiset, not a bisected Python list. Functional exercises expose pure functions with deterministic output and no mutation of caller-owned inputs. Historical `solution_01.py` files remain byte-for-byte unchanged. + +**Tech Stack:** Python 3.10 standard library, generics, pytest. + +--- + +## Exact Canonical File Additions + +Later tasks use shortened filenames within their stated exercise directory. +Their exact repository paths are: + +- `CH_04_design_patterns/exercise_02/__init__.py` +- `CH_04_design_patterns/exercise_02/README.rst` +- `CH_04_design_patterns/exercise_02/solution_00.py` +- `CH_05_functional_programming/exercise_02/README.rst` +- `CH_05_functional_programming/exercise_02/solution_00.py` +- `CH_05_functional_programming/exercise_03/README.rst` +- `CH_05_functional_programming/exercise_03/solution_00.py` + +## Scope and public APIs + +- `CH_04_design_patterns/exercise_01`: `SortedDict`. +- `CH_04_design_patterns/exercise_02`: `SortedList`. +- `CH_04_design_patterns/exercise_03`: `Borg`. +- `CH_05_functional_programming/exercise_01`: `quicksort`, compatibility wrapper `qs`. +- `CH_05_functional_programming/exercise_02`: `groupby(func, seq)`. +- `CH_05_functional_programming/exercise_03`: `groupby(iterable, key=None)`. + +For every exercise, create or update `README.rst`, `solution_00.py`, `test_solution_00.py`, and `__init__.py`. Modify both chapter READMEs into linked indexes only after their packet passes. Do not modify either existing `solution_01.py`. + +Where the book examples inform an answer, cite the corresponding immutable source in that exercise README: + +- `https://github.com/mastering-python/code_2/blob/a012f6ce3f5bf11f5bf380d1c4e7f743355adb89/CH_04_design_patterns/T_10_bisect.rst` +- `https://github.com/mastering-python/code_2/blob/a012f6ce3f5bf11f5bf380d1c4e7f743355adb89/CH_04_design_patterns/T_11_borg_and_singleton.rst` +- `https://github.com/mastering-python/code_2/blob/a012f6ce3f5bf11f5bf380d1c4e7f743355adb89/CH_05_functional_programming/T_14_groupby.rst` + +## Task 1: SortedDict + +**Files:** create `exercise_01/{README.rst,test_solution_00.py}`; rewrite `exercise_01/solution_00.py`; retain its initializer and `solution_01.py`. + +- [ ] **Step 1: Write failing tests** + +```python +from CH_04_design_patterns.exercise_01.solution_00 import SortedDict + + +def test_iteration_tracks_mutations_in_key_order() -> None: + values: SortedDict[str, int, int] = SortedDict( + {"bbb": 3, "a": 1}, keyfunc=len + ) + values["cc"] = 2 + assert list(values) == ["a", "cc", "bbb"] + assert list(values.items()) == [("a", 1), ("cc", 2), ("bbb", 3)] + del values["cc"] + assert repr(values) == "{'a': 1, 'bbb': 3}" +``` + +Also test empty construction, iterable pairs, replacement without duplicate keys, reverse key functions, normal `KeyError`, and equality through the `MutableMapping` contract. + +- [ ] **Step 2: Verify red** + +Run: `uv run pytest CH_04_design_patterns/exercise_01/test_solution_00.py -v` + +Expected: current canonical implementation fails mutation/mapping-contract tests. + +- [ ] **Step 3: Implement** + +Use `MutableMapping[K, V]` and sort keys at iteration: + +```python +class SortedDict(MutableMapping[K, V], Generic[K, V, S]): + def __init__( + self, + values: Mapping[K, V] | Iterable[tuple[K, V]] = (), + keyfunc: Callable[[K], S] | None = None, + ) -> None: + self._values: dict[K, V] = dict(values) + self._keyfunc: Callable[[K], S] = _identity if keyfunc is None else keyfunc + + def __getitem__(self, key: K) -> V: + return self._values[key] + + def __setitem__(self, key: K, value: V) -> None: + self._values[key] = value + + def __delitem__(self, key: K) -> None: + del self._values[key] + + def __iter__(self) -> Iterator[K]: + return iter(sorted(self._values, key=self._keyfunc)) + + def __len__(self) -> int: + return len(self._values) + + def __repr__(self) -> str: + return repr(dict(self.items())) +``` + +Define a typed comparable protocol for `S` and a typed/cast identity helper; do not use an unbounded `object` sort key. Keeping `keyfunc` positional preserves the existing canonical constructor while also allowing keyword use. + +- [ ] **Step 4: Document, verify, commit** + +README repeats the exact question, states the key function receives keys, documents live mutation ordering and O(n log n) iteration, and lists module/test commands. + +Run: `uv run pytest CH_04_design_patterns/exercise_01 -v` + +Expected: pass. + +```bash +git add CH_04_design_patterns/exercise_01/README.rst CH_04_design_patterns/exercise_01/solution_00.py CH_04_design_patterns/exercise_01/test_solution_00.py +git commit -m "feat(ch04): implement sorted mapping" +``` + +## Task 2: SortedList with logarithmic insertion + +**Public API:** `SortedList(values: Iterable[T] = (), *, key: Callable[[T], S] | None = None)`, `add(value: T) -> None`, `insert(value: T) -> None`, iteration, length, membership, and repr. Duplicates are retained in insertion count. + +- [ ] **Step 1: Write failing behavior and complexity tests** + +```python +from CH_04_design_patterns.exercise_02.solution_00 import SortedList + + +def test_add_keeps_values_sorted_and_retains_duplicates() -> None: + values: SortedList[int, int] = SortedList([3, 1, 2, 2]) + values.add(0) + values.insert(4) + assert list(values) == [0, 1, 2, 2, 3, 4] + assert len(values) == 6 + + +def test_tree_height_stays_logarithmic_for_sorted_input() -> None: + values: SortedList[int, int] = SortedList() + for value in range(1_024): + values.add(value) + assert values.tree_height <= 2 * 10 +``` + +Also test an empty collection, reverse/custom keys, duplicate custom-key values, and membership. The public read-only `tree_height` diagnostic makes the asymptotic design testable without timing. + +- [ ] **Step 2: Verify red** + +Run: `uv run pytest CH_04_design_patterns/exercise_02/test_solution_00.py -v` + +Expected: `ModuleNotFoundError`. + +- [ ] **Step 3: Implement the deterministic AVL multiset** + +Use a private generic `_Node` with `value`, `sort_key`, `duplicates`, `height`, `left`, and `right`. `add()` performs ordinary BST insertion, appends equal-key values to `duplicates`, updates heights, and applies LL/LR/RR/RL rotations. Iteration is in-order: + +```python +def _iter_node(node: _Node[T, S] | None) -> Iterator[T]: + if node is None: + return + yield from _iter_node(node.left) + yield node.value + yield from node.duplicates + yield from _iter_node(node.right) + + +class SortedList(Collection[T], Generic[T, S]): + def add(self, value: T) -> None: + sort_key: S = self._key(value) + self._root = _insert(self._root, value, sort_key) + self._length += 1 + + def insert(self, value: T) -> None: + self.add(value) + + @property + def tree_height(self) -> int: + return _height(self._root) +``` + +The default key is a typed identity helper. Never use randomness or a Python-list insertion internally. + +- [ ] **Step 4: Document, verify, commit** + +README explains the AVL choice, O(log n) insertion, O(n) iteration, duplicate ordering, public APIs, and `uv run` commands. + +Run: `uv run pytest CH_04_design_patterns/exercise_02 -v` + +Expected: pass. + +```bash +git add CH_04_design_patterns/exercise_02 +git commit -m "feat(ch04): add logarithmic sorted list" +``` + +## Task 3: Borg state per subclass + +- [ ] **Step 1: Write failing tests** + +```python +from CH_04_design_patterns.exercise_03.solution_00 import Borg + + +def test_state_is_shared_within_but_not_between_subclasses() -> None: + class First(Borg): + pass + + class Second(Borg): + pass + + first_a: First = First() + first_b: First = First() + second: Second = Second() + setattr(first_a, "answer", 42) + assert getattr(first_b, "answer") == 42 + assert not hasattr(second, "answer") +``` + +Also test direct `Borg` instances and that a grandchild receives its own state. + +- [ ] **Step 2: Verify red** + +Run: `uv run pytest CH_04_design_patterns/exercise_03/test_solution_00.py -v` + +Expected: `ModuleNotFoundError` for canonical `solution_00.py`. + +- [ ] **Step 3: Implement** + +```python +BorgT = TypeVar("BorgT", bound="Borg") + + +class Borg: + _shared_state: ClassVar[dict[str, object]] = {} + + def __init_subclass__(cls, **kwargs: object) -> None: + super().__init_subclass__(**kwargs) + cls._shared_state = {} + + def __new__(cls: type[BorgT]) -> BorgT: + instance: BorgT = cast(BorgT, super().__new__(cls)) + instance.__dict__ = cls._shared_state + return instance +``` + +Keep the demonstration guarded. Do not edit `solution_01.py`. + +- [ ] **Step 4: Document, verify, commit** + +README states instance identity remains distinct while `__dict__` is shared only inside the exact subclass. + +Run: `uv run pytest CH_04_design_patterns/exercise_03 -v` + +Expected: pass. + +```bash +git add CH_04_design_patterns/exercise_03/README.rst CH_04_design_patterns/exercise_03/solution_00.py CH_04_design_patterns/exercise_03/test_solution_00.py +git commit -m "feat(ch04): isolate Borg state by subclass" +``` + +## Task 4: Functional quicksort + +**Public API:** `quicksort(values: Iterable[T]) -> list[T]`; `qs` is a typed compatibility wrapper. Neither mutates caller data. + +- [ ] **Step 1: Write failing tests** + +```python +from CH_05_functional_programming.exercise_01.solution_00 import qs, quicksort + + +def test_quicksort_handles_duplicates_without_mutating_input() -> None: + values: list[int] = [3, 1, 2, 1] + assert quicksort(values) == [1, 1, 2, 3] + assert qs(iter(values)) == [1, 1, 2, 3] + assert values == [3, 1, 2, 1] +``` + +Also test empty/singleton input and comparable records. + +- [ ] **Step 2: Verify red** + +Run: `uv run pytest CH_05_functional_programming/exercise_01/test_solution_00.py -v` + +Expected: current `qs` rejects a generator or lacks the typed compatibility contract. + +- [ ] **Step 3: Implement** + +```python +def quicksort(values: Iterable[T]) -> list[T]: + items: list[T] = list(values) + if len(items) < 2: + return items + pivot: T = items[0] + lower: list[T] = [item for item in items[1:] if item < pivot] + upper: list[T] = [item for item in items[1:] if not item < pivot] + return [*quicksort(lower), pivot, *quicksort(upper)] + + +def qs(values: Iterable[T]) -> list[T]: + return quicksort(values) +``` + +- [ ] **Step 4: Document, verify, commit** + +README documents fresh-list behavior, comparison requirement, deterministic first-item pivot, and worst-case recursion trade-off. + +Run: `uv run pytest CH_05_functional_programming/exercise_01 -v` + +Expected: pass. Preserve `solution_01.py`. + +```bash +git add CH_05_functional_programming/exercise_01/README.rst CH_05_functional_programming/exercise_01/solution_00.py CH_05_functional_programming/exercise_01/test_solution_00.py +git commit -m "feat(ch05): complete functional quicksort" +``` + +## Task 5: Group non-consecutive values + +**Public API:** `groupby(func: Callable[[T], K], seq: Iterable[T]) -> dict[K, list[T]]`. + +- [ ] **Step 1: Write failing tests** + +```python +import pytest + +from CH_05_functional_programming.exercise_02.solution_00 import groupby + + +def test_non_consecutive_values_share_one_group() -> None: + assert groupby(lambda value: value % 2, [1, 2, 3, 4]) == { + 1: [1, 3], + 0: [2, 4], + } + + +def test_rejects_non_callable_group_function() -> None: + with pytest.raises(TypeError, match="func must be callable"): + groupby(3, [1, 2]) # type: ignore[arg-type] +``` + +Also test empty input, generators, and first-key/in-group ordering. + +- [ ] **Step 2: Verify red, implement, and rerun** + +Run: `uv run pytest CH_05_functional_programming/exercise_02/test_solution_00.py -v` + +Expected before implementation: type/iterator contract failures. + +```python +def groupby(func: Callable[[T], K], seq: Iterable[T]) -> dict[K, list[T]]: + if not callable(func): + raise TypeError("func must be callable") + groups: dict[K, list[T]] = {} + for item in seq: + groups.setdefault(func(item), []).append(item) + return groups +``` + +Run the same command; expected: pass. + +- [ ] **Step 3: Document and commit** + +README contrasts this behavior with adjacency-based `itertools.groupby`, states ordering rules, and includes `uv run` commands. + +```bash +git add CH_05_functional_programming/exercise_02 +git commit -m "feat(ch05): group non-consecutive values" +``` + +## Task 6: Return list-valued groups + +**Public API:** `groupby(iterable: Iterable[T], key: Callable[[T], K] | None = None) -> dict[K | T, list[T]]`. + +- [ ] **Step 1: Write failing tests** + +```python +import pytest + +from CH_05_functional_programming.exercise_03.solution_00 import groupby + + +def test_groups_are_reusable_lists() -> None: + groups: dict[str, list[str]] = groupby("ABACA") + assert groups == {"A": ["A", "A", "A"], "B": ["B"], "C": ["C"]} + assert list(groups["A"]) == list(groups["A"]) + + +def test_rejects_non_callable_key() -> None: + with pytest.raises(TypeError, match="key must be callable or None"): + groupby([1, 2], key=3) # type: ignore[arg-type] +``` + +Also test a key function, empty generator, first-key order, and input-list preservation. + +- [ ] **Step 2: Verify red, implement, and rerun** + +Run: `uv run pytest CH_05_functional_programming/exercise_03/test_solution_00.py -v` + +Expected before implementation: current loose typing/`key=None` contract fails strict tests. + +Implement with two overloads so identity grouping and keyed grouping have precise return types. The keyed implementation is: + +```python +def groupby( + iterable: Iterable[T], + key: Callable[[T], K] | None = None, +) -> dict[K | T, list[T]]: + if key is not None and not callable(key): + raise TypeError("key must be callable or None") + groups: dict[K | T, list[T]] = {} + for item in iterable: + group_key: K | T = item if key is None else key(item) + groups.setdefault(group_key, []).append(item) + return groups +``` + +Run the same command; expected: pass. + +- [ ] **Step 3: Add README/indexes and run packet gates** + +Document the list/reusability choice and `uv run` commands. Convert both chapter READMEs to numbered links without changing prompt meaning. + +```bash +uv run ruff format --check CH_04_design_patterns CH_05_functional_programming +uv run ruff check CH_04_design_patterns CH_05_functional_programming +uv run pyrefly check CH_04_design_patterns CH_05_functional_programming +uv run mypy CH_04_design_patterns CH_05_functional_programming +uv run pyright CH_04_design_patterns CH_05_functional_programming +uv run pytest CH_04_design_patterns CH_05_functional_programming -v +``` + +Expected: every command exits 0; historical alternatives are not modified or included in static-check scope. + +- [ ] **Step 4: Commit** + +```bash +git add CH_04_design_patterns/README.rst CH_05_functional_programming +git commit -m "feat(ch05): return reusable grouped lists" +``` diff --git a/docs/superpowers/plans/2026-07-28-ch06-ch07-decorators-generators.md b/docs/superpowers/plans/2026-07-28-ch06-ch07-decorators-generators.md new file mode 100644 index 0000000..7fbdce9 --- /dev/null +++ b/docs/superpowers/plans/2026-07-28-ch06-ch07-decorators-generators.md @@ -0,0 +1,577 @@ +# Chapter 6–7 Descriptors, Dispatch, Validation, and Generators Implementation + +**For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` (recommended) or `superpowers:executing-plans` to implement this plan task-by-task. + +**Goal:** Complete Chapter 6 exercises 5–7 and all four Chapter 7 exercises with typed descriptors, multi-argument dispatch, constraints, slicing, and deterministic generators. + +**Architecture:** The cached property stores values per instance and uses deletion for explicit recalculation. Dispatch binds calls against the base signature and selects registered functions by a configured tuple of runtime types. Generator utilities document one-shot/finite-input limits and otherwise match ordinary slice and numeric-sequence behavior. + +**Tech Stack:** Python 3.10 standard library, pytest. + +--- + +## Exact Canonical File Additions + +The Chapter 7 exercise 3 and 4 tasks use shortened filenames within their +exercise directories. Their exact paths are: + +- `CH_07_generators_and_coroutines/exercise_03/README.rst` +- `CH_07_generators_and_coroutines/exercise_03/solution_00.py` +- `CH_07_generators_and_coroutines/exercise_04/README.rst` +- `CH_07_generators_and_coroutines/exercise_04/solution_00.py` + +## Scope and ownership + +Modify canonical `solution_00.py` files and add `README.rst` plus `test_solution_00.py` in: + +- `CH_06_decorators/exercise_05`, `exercise_06`, `exercise_07` +- `CH_07_generators_and_coroutines/exercise_01` through `exercise_04` + +Preserve `CH_07_generators_and_coroutines/exercise_04/solution_01.py` unchanged. This packet owns the shared `CH_06_decorators/README.rst` and `CH_07_generators_and_coroutines/README.rst` linked indexes; the Chapter 6 exercises 1–4 packet must not edit the Chapter 6 index. + +Exercise READMEs must cite the relevant immutable book source: + +- `https://github.com/mastering-python/code_2/blob/a012f6ce3f5bf11f5bf380d1c4e7f743355adb89/CH_06_decorators/T_10_properties.rst` +- `https://github.com/mastering-python/code_2/blob/a012f6ce3f5bf11f5bf380d1c4e7f743355adb89/CH_06_decorators/T_13_single_dispatch.rst` +- `https://github.com/mastering-python/code_2/blob/a012f6ce3f5bf11f5bf380d1c4e7f743355adb89/CH_06_decorators/T_15_validation.rst` +- `https://github.com/mastering-python/code_2/blob/a012f6ce3f5bf11f5bf380d1c4e7f743355adb89/CH_07_generators_and_coroutines/T_07_islice.rst` + +## Task 1: Recalculable cached property + +**Public API:** generic `CachedProperty[T, R]` descriptor with `__get__`, `__set_name__`, and `__delete__`. `del instance.property` invalidates only that instance; the next access recalculates. + +- [ ] **Step 1: Write failing descriptor tests** + +```python +import pytest + +from CH_06_decorators.exercise_05.solution_00 import CachedProperty + + +def test_property_caches_per_instance_and_recalculates_after_delete() -> None: + class Counter: + def __init__(self) -> None: + self.calls: int = 0 + + @CachedProperty + def value(self) -> int: + self.calls += 1 + return self.calls + + first: Counter = Counter() + second: Counter = Counter() + assert (first.value, first.value, second.value) == (1, 1, 1) + del first.value + assert first.value == 2 + assert second.value == 1 + + +def test_delete_before_first_access_raises_attribute_error() -> None: + class Value: + @CachedProperty + def answer(self) -> int: + return 42 + + with pytest.raises(AttributeError, match="answer"): + del Value().answer +``` + +Also test class-level descriptor access, a `__slots__` instance without `__dict__`, and wrapped function metadata. + +- [ ] **Step 2: Verify red** + +Run: `uv run pytest CH_06_decorators/exercise_05/test_solution_00.py -v` + +Expected: current implementation shares its cache across instances and exposes a class-bound clear method. + +- [ ] **Step 3: Implement** + +```python +class CachedProperty(Generic[T, R]): + def __init__(self, function: Callable[[T], R]) -> None: + self.function: Callable[[T], R] = function + self.__doc__: str | None = function.__doc__ + self.name: str | None = None + + def __set_name__(self, owner: type[T], name: str) -> None: + if self.name is not None and self.name != name: + raise TypeError("one CachedProperty cannot serve two names") + self.name = name + + @overload + def __get__( + self, instance: None, owner: type[T] | None = None + ) -> CachedProperty[T, R]: ... + + @overload + def __get__(self, instance: T, owner: type[T] | None = None) -> R: ... + + def __get__( + self, instance: T | None, owner: type[T] | None = None + ) -> R | CachedProperty[T, R]: + if instance is None: + return self + name: str = self._bound_name() + namespace: dict[str, object] = vars(instance) + if name not in namespace: + namespace[name] = self.function(instance) + return cast(R, namespace[name]) + + def __delete__(self, instance: T) -> None: + name: str = self._bound_name() + try: + del vars(instance)[name] + except KeyError as error: + raise AttributeError(name) from error +``` + +`_bound_name()` raises `TypeError` if `__set_name__` was never called. A slots-only instance naturally raises a specific `TypeError` explaining that `__dict__` is required. + +- [ ] **Step 4: Document, verify, commit** + +README repeats question 5, documents per-instance storage/deletion, thread-lock non-guarantee, and module/test commands. + +Run: `uv run pytest CH_06_decorators/exercise_05 -v` + +Expected: pass. + +```bash +git add CH_06_decorators/exercise_05/README.rst CH_06_decorators/exercise_05/solution_00.py CH_06_decorators/exercise_05/test_solution_00.py +git commit -m "feat(ch06): add recalculable cached property" +``` + +## Task 2: Configurable multi-argument dispatch + +**Public API:** `fancysingledispatch(*ignored: str, dispatch_on: tuple[str, ...] | None = None, **ignored_by_name: bool)`. The returned dispatcher is callable and has `.register(function)`. `dispatch_on=None` dispatches on every parameter except legacy ignored names such as `last_name=True`; unmatched type tuples invoke the base implementation. Supplying `dispatch_on` together with either ignored form raises `TypeError`. + +- [ ] **Step 1: Write failing tests** + +```python +import pytest + +from CH_06_decorators.exercise_06.solution_00 import fancysingledispatch + + +def test_dispatches_on_multiple_named_arguments_and_kwargs() -> None: + @fancysingledispatch(dispatch_on=("left", "right")) + def combine(left: object, right: object, separator: str = ":") -> str: + return f"default{separator}{left}{separator}{right}" + + @combine.register + def combine_int_str(left: int, right: str, separator: str = ":") -> str: + return f"{left}{separator}{right.upper()}" + + assert combine(3, "eggs") == "3:EGGS" + assert combine(left=3, right="eggs", separator="/") == "3/EGGS" + assert combine("3", "eggs").startswith("default:") + + +def test_legacy_true_keyword_excludes_that_parameter() -> None: + @fancysingledispatch(last_name=True) + def greet(first_name: str, last_name: str) -> str: + return f"{first_name} {last_name}" + + assert greet("Ada", "Lovelace") == "Ada Lovelace" +``` + +Also test dispatch on all parameters, omitted defaults after `apply_defaults`, unknown dispatch names, missing registration annotations, metadata preservation, and a registration with the wrong signature. + +- [ ] **Step 2: Verify red** + +Run: `uv run pytest CH_06_decorators/exercise_06/test_solution_00.py -v` + +Expected: current API cannot express `dispatch_on` and raises instead of using a base fallback. + +- [ ] **Step 3: Implement** + +The outer decorator first rejects false-valued compatibility keywords, conflicting configuration styles, and duplicate ignored names. Create a typed `_Dispatcher[P, R]`; at construction derive the selected names from the base signature, then validate them. Registration must have the exact same parameter names/kinds and concrete class annotations for selected names: + +```python +def register(self, function: Callable[P, R]) -> Callable[P, R]: + signature: inspect.Signature = inspect.signature(function) + if _shape(signature) != self._shape: + raise TypeError("registered function signature does not match base function") + hints: dict[str, object] = typing.get_type_hints(function) + key: tuple[type[object], ...] = tuple( + _concrete_type(name, hints) for name in self._dispatch_on + ) + self._registry[key] = function + return function + + +def __call__(self, *args: P.args, **kwargs: P.kwargs) -> R: + bound: inspect.BoundArguments = self._signature.bind(*args, **kwargs) + bound.apply_defaults() + key: tuple[type[object], ...] = tuple( + type(bound.arguments[name]) for name in self._dispatch_on + ) + implementation: Callable[P, R] = self._registry.get(key, self._base) + return implementation(*args, **kwargs) +``` + +Apply `functools.update_wrapper(self, base)`. Reject empty `dispatch_on`, duplicate names, `typing.Any`, unions, and missing annotations with precise `TypeError`s. + +- [ ] **Step 4: Document, verify, commit** + +README states exact-type rather than subclass/MRO dispatch, base fallback, registration validation, and commands. + +Run: `uv run pytest CH_06_decorators/exercise_06 -v` + +Expected: pass. + +```bash +git add CH_06_decorators/exercise_06/README.rst CH_06_decorators/exercise_06/solution_00.py CH_06_decorators/exercise_06/test_solution_00.py +git commit -m "feat(ch06): dispatch across selected arguments" +``` + +## Task 3: Type and numeric constraints + +**Public API:** `Constraint` protocol; `GreaterThan`, `LessThan`, `Between`; compatibility names `Gt`; `type_check(**constraints)`; compatibility alias `enforce_type_hints`. + +**Behavioral decisions:** The decorator validates but never coerces. Wrong runtime types raise `TypeError`; violated numeric constraints raise `ValueError`; `Between(lower, upper, inclusive=False)` is exclusive by default, matching the old solution. + +- [ ] **Step 1: Write failing tests** + +```python +import pytest + +from CH_06_decorators.exercise_07.solution_00 import ( + Between, + GreaterThan, + type_check, +) + + +def test_validates_types_and_constraints_without_coercion() -> None: + @type_check(count=GreaterThan(0), ratio=Between(0.0, 1.0)) + def scale(count: int, ratio: float = 0.5) -> float: + return count * ratio + + assert scale(4, 0.25) == 1.0 + with pytest.raises(TypeError, match="count"): + scale("4", 0.25) # type: ignore[arg-type] + with pytest.raises(ValueError, match="greater than 0"): + scale(0, 0.25) + with pytest.raises(ValueError, match="between 0.0 and 1.0"): + scale(4, 1.0) +``` + +Also test `LessThan`, inclusive `Between`, defaults, unknown constraint names, unconstrained annotated parameters, return-value non-validation, and `Gt`/`enforce_type_hints` compatibility. + +- [ ] **Step 2: Verify red** + +Run: `uv run pytest CH_06_decorators/exercise_07/test_solution_00.py -v` + +Expected: current decorator coerces values and invokes pytest from production code. + +- [ ] **Step 3: Implement** + +```python +@dataclass(frozen=True) +class GreaterThan: + threshold: Real + + def validate(self, name: str, value: object) -> None: + if not isinstance(value, Real) or value <= self.threshold: + raise ValueError(f"{name} must be greater than {self.threshold}") + + +def type_check( + **constraints: Constraint, +) -> Callable[[Callable[P, R]], Callable[P, R]]: + def decorate(function: Callable[P, R]) -> Callable[P, R]: + signature: inspect.Signature = inspect.signature(function) + hints: dict[str, object] = typing.get_type_hints(function) + unknown: set[str] = constraints.keys() - signature.parameters.keys() + if unknown: + raise TypeError(f"unknown constrained parameters: {sorted(unknown)!r}") + + @functools.wraps(function) + def checked(*args: P.args, **kwargs: P.kwargs) -> R: + bound: inspect.BoundArguments = signature.bind(*args, **kwargs) + bound.apply_defaults() + for name, value in bound.arguments.items(): + annotation: object = hints.get(name, inspect.Signature.empty) + if isinstance(annotation, type) and not isinstance(value, annotation): + raise TypeError(f"{name} must be {annotation.__name__}") + constraint: Constraint | None = constraints.get(name) + if constraint is not None: + constraint.validate(name, value) + return function(*args, **kwargs) + + return checked + + return decorate +``` + +Implement `LessThan` and `Between` as immutable dataclasses. Validate `lower < upper` during `Between` construction. Production code must not import pytest. + +- [ ] **Step 4: Document, verify, commit** + +README notes the prompt's `type_check` versus old `enforce_type_hints` naming mismatch and documents both aliases. + +Run: `uv run pytest CH_06_decorators/exercise_07 -v` + +Expected: pass. + +```bash +git add CH_06_decorators/exercise_07/README.rst CH_06_decorators/exercise_07/solution_00.py CH_06_decorators/exercise_07/test_solution_00.py +git commit -m "feat(ch06): enforce typed numeric constraints" +``` + +## Task 4: Negative-step islice + +**Public API:** `islice(iterable: Iterable[T], start: int, stop: int, step: int = 1) -> Iterator[T]`. + +For positive steps, stream with `itertools.islice`. For negative steps, materialize the finite iterable once and apply ordinary list slicing. Reject step zero. Document that a negative step never terminates for an infinite iterable. + +- [ ] **Step 1: Write failing tests** + +```python +import pytest + +from CH_07_generators_and_coroutines.exercise_01.solution_00 import islice + + +def test_negative_and_positive_steps_match_sequence_slices() -> None: + values: list[int] = list(range(30)) + assert list(islice(iter(values), 20, 10, -1)) == values[20:10:-1] + assert list(islice(iter(values), 2, 12, 3)) == values[2:12:3] + + +def test_zero_step_is_rejected() -> None: + with pytest.raises(ValueError, match="step"): + list(islice(range(5), 0, 3, 0)) +``` + +Also test empty ranges, negative start/stop under a negative step, and that positive slicing consumes only as far as needed. + +- [ ] **Step 2: Verify red** + +Run: `uv run pytest CH_07_generators_and_coroutines/exercise_01/test_solution_00.py -v` + +Expected: current implementation yields iterator objects rather than sliced values. + +- [ ] **Step 3: Implement** + +```python +def islice( + iterable: Iterable[T], + start: int, + stop: int, + step: int = 1, +) -> Iterator[T]: + if step == 0: + raise ValueError("step must not be zero") + if step > 0: + yield from itertools.islice(iterable, start, stop, step) + return + values: list[T] = list(iterable) + yield from values[slice(start, stop, step)] +``` + +- [ ] **Step 4: Document, verify, commit** + +README includes the finite-materialization decision required by the design. + +Run: `uv run pytest CH_07_generators_and_coroutines/exercise_01 -v` + +Expected: pass. + +```bash +git add CH_07_generators_and_coroutines/exercise_01/README.rst CH_07_generators_and_coroutines/exercise_01/solution_00.py CH_07_generators_and_coroutines/exercise_01/test_solution_00.py +git commit -m "feat(ch07): support negative generator slices" +``` + +## Task 5: Sliceable one-shot generator + +**Public API:** `SliceableGenerator[T](iterable: Iterable[T])`, with `__getitem__(index: slice) -> list[T]`. Only non-negative slices and positive steps are supported; each access consumes the underlying iterator and indexes are relative to its current position. + +- [ ] **Step 1: Write failing tests** + +```python +import pytest + +from CH_07_generators_and_coroutines.exercise_02.solution_00 import ( + SliceableGenerator, +) + + +def test_slices_consume_one_underlying_iterator() -> None: + values: SliceableGenerator[int] = SliceableGenerator(iter(range(30))) + assert values[2:6] == [2, 3, 4, 5] + assert values[0:3] == [6, 7, 8] + + +def test_negative_slice_is_rejected() -> None: + values: SliceableGenerator[int] = SliceableGenerator(range(5)) + with pytest.raises(ValueError, match="non-negative"): + values[-1:3] +``` + +Also test step two, omitted start/stop, integer indexing rejection, zero step, empty/exhausted iterators. + +- [ ] **Step 2: Verify red** + +Run: `uv run pytest CH_07_generators_and_coroutines/exercise_02/test_solution_00.py -v` + +Expected: current implementation mishandles omitted bounds/steps and lacks validation. + +- [ ] **Step 3: Implement** + +```python +class SliceableGenerator(Generic[T]): + def __init__(self, iterable: Iterable[T]) -> None: + self._iterator: Iterator[T] = iter(iterable) + + def __getitem__(self, index: slice) -> list[T]: + if not isinstance(index, slice): + raise TypeError("SliceableGenerator accepts slices only") + start: int = 0 if index.start is None else index.start + stop: int | None = index.stop + step: int = 1 if index.step is None else index.step + if start < 0 or (stop is not None and stop < 0): + raise ValueError("slice bounds must be non-negative") + if step <= 0: + raise ValueError("slice step must be positive") + return list(itertools.islice(self._iterator, start, stop, step)) +``` + +- [ ] **Step 4: Document, verify, commit** + +README makes one-shot/current-position semantics explicit. + +Run: `uv run pytest CH_07_generators_and_coroutines/exercise_02 -v` + +Expected: pass. + +```bash +git add CH_07_generators_and_coroutines/exercise_02/README.rst CH_07_generators_and_coroutines/exercise_02/solution_00.py CH_07_generators_and_coroutines/exercise_02/test_solution_00.py +git commit -m "feat(ch07): wrap generators with slicing" +``` + +## Task 6: Fibonacci generator + +**Public API:** `fibonacci() -> Iterator[int]`, yielding `0, 1, 1, 2, ...` forever. + +- [ ] **Step 1: Write failing tests** + +```python +from collections.abc import Iterator +from itertools import islice +from typing import get_type_hints + +from CH_07_generators_and_coroutines.exercise_03.solution_00 import fibonacci + + +def test_first_ten_fibonacci_numbers() -> None: + assert list(islice(fibonacci(), 10)) == [0, 1, 1, 2, 3, 5, 8, 13, 21, 34] + assert get_type_hints(fibonacci)["return"] == Iterator[int] +``` + +Also assert two instances advance independently and a 100th value is an `int`. + +- [ ] **Step 2: Verify red, implement, rerun** + +Run: `uv run pytest CH_07_generators_and_coroutines/exercise_03/test_solution_00.py -v` + +Expected before implementation: current file lacks complete typing/documentation tests. + +```python +def fibonacci() -> Iterator[int]: + current: int = 0 + following: int = 1 + while True: + yield current + current, following = following, current + following +``` + +Run the same command; expected: pass. + +- [ ] **Step 3: Document and commit** + +README specifies starting values, infinite behavior, no dependencies, and commands. + +```bash +git add CH_07_generators_and_coroutines/exercise_03 +git commit -m "feat(ch07): add Fibonacci generator" +``` + +## Task 7: Sieve of Eratosthenes prime generator + +**Public API:** `generate_primes() -> Iterator[int]`, yielding primes from 2 forever. + +- [ ] **Step 1: Write failing tests** + +```python +from collections.abc import Iterator +from itertools import islice +from typing import get_type_hints + +from CH_07_generators_and_coroutines.exercise_04.solution_00 import ( + generate_primes, +) + + +def test_first_twenty_primes() -> None: + assert list(islice(generate_primes(), 20)) == [ + 2, 3, 5, 7, 11, 13, 17, 19, 23, 29, + 31, 37, 41, 43, 47, 53, 59, 61, 67, 71, + ] + assert get_type_hints(generate_primes)["return"] == Iterator[int] + + +def test_generators_do_not_share_sieve_state() -> None: + first: Iterator[int] = generate_primes() + second: Iterator[int] = generate_primes() + assert [next(first), next(first), next(second)] == [2, 3, 2] +``` + +Also cross-check the first 200 values with a small trial-division oracle in the test. + +- [ ] **Step 2: Verify red** + +Run: `uv run pytest CH_07_generators_and_coroutines/exercise_04/test_solution_00.py -v` + +Expected: existing answer is untyped and uses an ever-growing list/trial division rather than a sieve. + +- [ ] **Step 3: Implement an incremental sieve** + +```python +def generate_primes() -> Iterator[int]: + composites: dict[int, list[int]] = {} + candidate: int = 2 + while True: + factors: list[int] | None = composites.pop(candidate, None) + if factors is None: + yield candidate + composites[candidate * candidate] = [candidate] + else: + for prime in factors: + composites.setdefault(candidate + prime, []).append(prime) + candidate += 1 +``` + +Keep the demonstration bounded and guarded. Do not modify `solution_01.py`. + +- [ ] **Step 4: Add READMEs/indexes and run packet gates** + +Exercise README explains incremental sieve state, infinite behavior, deterministic output, and commands. Convert both chapter READMEs to numbered links; Chapter 6 must link all exercises 1–7, including files created by the other packet. + +```bash +uv run ruff format --check CH_06_decorators CH_07_generators_and_coroutines +uv run ruff check CH_06_decorators CH_07_generators_and_coroutines +uv run pyrefly check CH_06_decorators CH_07_generators_and_coroutines +uv run mypy CH_06_decorators CH_07_generators_and_coroutines +uv run pyright CH_06_decorators CH_07_generators_and_coroutines +uv run pytest CH_06_decorators CH_07_generators_and_coroutines -v +``` + +Expected: all commands exit 0; `solution_01.py` remains unchanged. + +- [ ] **Step 5: Commit** + +```bash +git add CH_06_decorators/README.rst CH_07_generators_and_coroutines +git commit -m "feat(ch07): add deterministic prime sieve" +``` diff --git a/docs/superpowers/plans/2026-07-28-ch06-tracking-memoization.md b/docs/superpowers/plans/2026-07-28-ch06-tracking-memoization.md new file mode 100644 index 0000000..0627c1c --- /dev/null +++ b/docs/superpowers/plans/2026-07-28-ch06-tracking-memoization.md @@ -0,0 +1,431 @@ +# Chapter 6 Tracking and Memoization Implementation + +**For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` (recommended) or `superpowers:executing-plans` to implement this plan task-by-task. + +**Goal:** Rewrite Chapter 6 exercises 1–4 as deterministic, typed decorators with complete timing and cache contracts. + +**Architecture:** Timing decorators accept injectable monotonic clocks and structured reporters, so tests never sleep. Memoization converts supported mutable containers into type-tagged immutable keys; exercise 3 deliberately stores those keys in a module cache, while exercise 4 advances the design to an independently inspectable cache per decorated function. + +**Tech Stack:** Python 3.10 standard library, `ParamSpec`, pytest. + +--- + +## Exact Canonical File Addition + +The Chapter 6 exercise 4 task uses shortened filenames within its exercise +directory. Their exact paths are: + +- `CH_06_decorators/exercise_04/README.rst` +- `CH_06_decorators/exercise_04/solution_00.py` + +## Scope and ownership + +Only modify: + +- `CH_06_decorators/exercise_01/{README.rst,solution_00.py,test_solution_00.py}` +- `CH_06_decorators/exercise_02/{README.rst,solution_00.py,test_solution_00.py}` +- `CH_06_decorators/exercise_03/{README.rst,solution_00.py,test_solution_00.py}` +- `CH_06_decorators/exercise_04/{README.rst,solution_00.py,test_solution_00.py}` + +Keep existing initializers. Do not modify `CH_06_decorators/README.rst`; the Chapter 6 exercises 5–7 packet owns that shared index. Do not add third-party runtime dependencies. + +Exercise READMEs must cite the relevant immutable book source: + +- `https://github.com/mastering-python/code_2/blob/a012f6ce3f5bf11f5bf380d1c4e7f743355adb89/CH_06_decorators/T_03_chaining_decorators.rst` +- `https://github.com/mastering-python/code_2/blob/a012f6ce3f5bf11f5bf380d1c4e7f743355adb89/CH_06_decorators/T_05_memoization.rst` + +## Task 1: Track every function execution + +**Public API:** + +- `Timing(label: str, elapsed: float, failed: bool)`. +- `track(function=None, *, label=None, clock=time.perf_counter, reporter=print_timing)`. +- Decorated functions preserve signature metadata and return/raise exactly as the wrapped function. + +- [ ] **Step 1: Write failing deterministic tests** + +Create `test_solution_00.py`: + +```python +from collections.abc import Iterator + +import pytest + +from CH_06_decorators.exercise_01.solution_00 import Timing, track + + +def clock(values: list[float]) -> Iterator[float]: + yield from values + + +def test_track_reports_elapsed_time_and_preserves_result() -> None: + readings: Iterator[float] = clock([10.0, 10.25]) + reports: list[Timing] = [] + + @track( + label="addition", + clock=lambda: next(readings), + reporter=reports.append, + ) + def add(left: int, right: int) -> int: + return left + right + + assert add(2, 3) == 5 + assert add.__name__ == "add" + assert reports == [Timing("addition", 0.25, False)] + + +def test_track_reports_and_propagates_failure() -> None: + readings: Iterator[float] = clock([1.0, 1.5]) + reports: list[Timing] = [] + + @track(clock=lambda: next(readings), reporter=reports.append) + def fail() -> None: + raise LookupError("broken") + + with pytest.raises(LookupError, match="broken"): + fail() + assert reports == [Timing("fail", 0.5, True)] +``` + +Also test direct `@track` usage, default label selection, positional/keyword forwarding, and the default printable report format. + +- [ ] **Step 2: Run and verify red** + +Run: `uv run pytest CH_06_decorators/exercise_01/test_solution_00.py -v` + +Expected: current `track` has no injectable clock/reporter or structured `Timing`. + +- [ ] **Step 3: Implement the typed decorator** + +Use overloads for direct and configured decorator forms. The core implementation is: + +```python +@dataclass(frozen=True) +class Timing: + label: str + elapsed: float + failed: bool + + +def print_timing(timing: Timing) -> None: + status: str = "failed" if timing.failed else "completed" + print(f"{timing.label} {status} in {timing.elapsed:.6f}s") + + +def _decorate( + function: Callable[P, R], + *, + label: str | None, + clock: Callable[[], float], + reporter: Callable[[Timing], None], +) -> Callable[P, R]: + selected_label: str = function.__name__ if label is None else label + + @functools.wraps(function) + def wrapped(*args: P.args, **kwargs: P.kwargs) -> R: + started: float = clock() + failed: bool = True + try: + result: R = function(*args, **kwargs) + failed = False + return result + finally: + reporter(Timing(selected_label, clock() - started, failed)) + + return wrapped +``` + +`track` must call `_decorate` immediately when `function` is not `None`, otherwise return a decorator closure. Never catch the wrapped exception. + +- [ ] **Step 4: Add README, verify, and commit** + +README repeats question 1 exactly, explains monotonic timing, failure reporting, clock/reporter injection, no-sleep tests, no extra dependencies, and shows: + +```console +uv run python -m CH_06_decorators.exercise_01.solution_00 +uv run pytest CH_06_decorators/exercise_01 -v +``` + +Run: `uv run pytest CH_06_decorators/exercise_01 -v` + +Expected: pass. + +```bash +git add CH_06_decorators/exercise_01/README.rst CH_06_decorators/exercise_01/solution_00.py CH_06_decorators/exercise_01/test_solution_00.py +git commit -m "feat(ch06): track deterministic call timing" +``` + +## Task 2: Aggregate min/max/average/count + +**Public API:** + +- Mutable `TimingStats(count, total, minimum, maximum)` with `average`. +- `TrackedCallable[P, R]` protocol exposing `stats`, `print_stats()`, and `__call__`. +- Exercise-local `track` with the same configuration arguments as Exercise 1. + +Record failed calls as executions and propagate their exceptions. Before any call, min/max/average are `None`. + +- [ ] **Step 1: Write failing tests** + +```python +from collections.abc import Iterator + +from CH_06_decorators.exercise_02.solution_00 import TimingStats, track + + +def test_track_aggregates_exact_injected_durations() -> None: + readings: Iterator[float] = iter([0.0, 1.0, 10.0, 13.0]) + + @track(clock=lambda: next(readings), reporter=lambda timing: None) + def identity(value: int) -> int: + return value + + assert identity(1) == 1 + assert identity(2) == 2 + assert identity.stats == TimingStats( + count=2, + total=4.0, + minimum=1.0, + maximum=3.0, + ) + assert identity.stats.average == 2.0 +``` + +Also test initial empty stats, one failed call, independent decorators, and exact `print_stats()` output through `capsys`. + +- [ ] **Step 2: Verify red** + +Run: `uv run pytest CH_06_decorators/exercise_02/test_solution_00.py -v` + +Expected: current implementation uses datetimes/random sleeps and does not expose a typed stats contract. + +- [ ] **Step 3: Implement** + +Reuse Exercise 1's `Timing`; define: + +```python +@dataclass +class TimingStats: + count: int = 0 + total: float = 0.0 + minimum: float | None = None + maximum: float | None = None + + @property + def average(self) -> float | None: + return None if self.count == 0 else self.total / self.count + + def record(self, elapsed: float) -> None: + self.count += 1 + self.total += elapsed + self.minimum = elapsed if self.minimum is None else min(self.minimum, elapsed) + self.maximum = elapsed if self.maximum is None else max(self.maximum, elapsed) +``` + +The wrapper's `finally` block creates one `Timing`, records it, and forwards it to the configured reporter. Attach `stats` and a zero-argument `print_stats` function to the wrapped callable, then `cast(TrackedCallable[P, R], wrapped)` instead of using broad ignores. Print all fields with six decimal places and print `n/a` for empty min/max/average. + +- [ ] **Step 4: Document, verify, commit** + +README states failed calls count, empty-stat semantics, units in seconds, and deterministic injection. + +Run: `uv run pytest CH_06_decorators/exercise_02 -v` + +Expected: pass. + +```bash +git add CH_06_decorators/exercise_02/README.rst CH_06_decorators/exercise_02/solution_00.py CH_06_decorators/exercise_02/test_solution_00.py +git commit -m "feat(ch06): aggregate tracked call statistics" +``` + +## Task 3: Memoize mutable container arguments + +**Public API:** module-level `cache`, `memoize(function)`, and private `_freeze(value)` used by the decorator. + +**Behavioral decisions:** Support nested tuples, lists, dicts, sets, and frozensets; preserve type distinctions; dictionary/set order does not affect keys; cycles raise `ValueError("cyclic argument")`; unsupported unhashable objects raise `TypeError`; exceptions are never cached. + +- [ ] **Step 1: Write failing cache-contract tests** + +```python +import pytest + +from CH_06_decorators.exercise_03.solution_00 import cache, memoize + + +def test_equivalent_unhashable_arguments_hit_one_cache_entry() -> None: + cache.clear() + calls: list[int] = [] + + @memoize + def total(values: list[int], *, weights: dict[str, int]) -> int: + calls.append(1) + return sum(values) + sum(weights.values()) + + assert total([1, 2], weights={"a": 3, "b": 4}) == 10 + assert total([1, 2], weights={"b": 4, "a": 3}) == 10 + assert calls == [1] + + +def test_cyclic_arguments_fail_explicitly() -> None: + cyclic: list[object] = [] + cyclic.append(cyclic) + + @memoize + def identity(value: object) -> object: + return value + + with pytest.raises(ValueError, match="cyclic argument"): + identity(cyclic) +``` + +Also test list versus tuple separation, unordered nested sets, different functions with equal arguments, kwargs, and a function that raises twice. + +- [ ] **Step 2: Verify red** + +Run: `uv run pytest CH_06_decorators/exercise_03/test_solution_00.py -v` + +Expected: current implementation still uses raw unhashable arguments as the cache key. + +- [ ] **Step 3: Implement structural freezing and global caching** + +Use type tags so structurally similar container types cannot collide: + +```python +def _freeze(value: object, active: set[int] | None = None) -> Hashable: + seen: set[int] = set() if active is None else active + if isinstance(value, (str, bytes, int, float, bool, type(None))): + return ("scalar", type(value), value) + if isinstance(value, Hashable) and not isinstance( + value, (tuple, frozenset) + ): + return ("hashable", type(value), value) + identity: int = id(value) + if identity in seen: + raise ValueError("cyclic argument") + seen.add(identity) + try: + if isinstance(value, tuple): + return ("tuple", tuple(_freeze(item, seen) for item in value)) + if isinstance(value, list): + return ("list", tuple(_freeze(item, seen) for item in value)) + if isinstance(value, dict): + return ( + "dict", + frozenset( + (_freeze(key, seen), _freeze(item, seen)) + for key, item in value.items() + ), + ) + if isinstance(value, (set, frozenset)): + return ( + type(value).__name__, + frozenset(_freeze(item, seen) for item in value), + ) + raise TypeError(f"unsupported unhashable argument: {type(value).__name__}") + finally: + seen.remove(identity) +``` + +`memoize` computes `(function, _freeze(args), _freeze(kwargs))`, calls the function only when absent, and stores the result only after a successful return. Use a private sentinel so cached `None` is distinguishable from a miss. + +- [ ] **Step 4: Document, verify, commit** + +README lists supported containers, cycles/errors, global-cache lifetime, exception behavior, and `uv run` commands. + +Run: `uv run pytest CH_06_decorators/exercise_03 -v` + +Expected: pass. + +```bash +git add CH_06_decorators/exercise_03/README.rst CH_06_decorators/exercise_03/solution_00.py CH_06_decorators/exercise_03/test_solution_00.py +git commit -m "feat(ch06): memoize unhashable arguments" +``` + +## Task 4: Give each function its own cache + +**Public API:** `MemoizedCallable[P, R]` protocol exposing `cache`, `cache_clear()`, and call behavior; `memoize(function) -> MemoizedCallable[P, R]`. + +- [ ] **Step 1: Write failing tests** + +```python +from CH_06_decorators.exercise_04.solution_00 import memoize + + +def test_each_decorated_function_owns_and_clears_its_cache() -> None: + calls: list[str] = [] + + @memoize + def first(values: list[int]) -> int: + calls.append("first") + return sum(values) + + @memoize + def second(values: list[int]) -> int: + calls.append("second") + return sum(values) + + assert first([1, 2]) == second([1, 2]) == 3 + assert first.cache is not second.cache + assert calls == ["first", "second"] + first([1, 2]) + first.cache_clear() + first([1, 2]) + assert calls == ["first", "second", "first"] + assert first.__name__ == "first" +``` + +Also test kwargs/order, cached `None`, exception non-caching, and unsupported/cyclic arguments inherited from Exercise 3's freezer. + +- [ ] **Step 2: Verify red** + +Run: `uv run pytest CH_06_decorators/exercise_04/test_solution_00.py -v` + +Expected: current function-attached cache is lost through `functools.wraps` or has an incorrect key contract. + +- [ ] **Step 3: Implement** + +Import `_freeze` from Exercise 3 and keep the cache in the decorator closure: + +```python +def memoize(function: Callable[P, R]) -> MemoizedCallable[P, R]: + local_cache: dict[Hashable, R] = {} + missing: object = object() + + @functools.wraps(function) + def wrapped(*args: P.args, **kwargs: P.kwargs) -> R: + key: Hashable = (_freeze(args), _freeze(kwargs)) + cached: R | object = local_cache.get(key, missing) + if cached is not missing: + return cast(R, cached) + result: R = function(*args, **kwargs) + local_cache[key] = result + return result + + wrapped.cache = local_cache + wrapped.cache_clear = local_cache.clear + return cast(MemoizedCallable[P, R], wrapped) +``` + +Define a private `_CacheAttributes[R]` protocol with writable `cache: dict[Hashable, R]` and `cache_clear: Callable[[], None]`, cast `wrapped` to that protocol for the two assignments, then cast it to `MemoizedCallable[P, R]` for the return. Do not add file-wide suppressions. + +- [ ] **Step 4: Add README and run packet gates** + +README explains per-decoration ownership, explicit clearing, supported keys, and exception behavior. + +```bash +uv run ruff format --check CH_06_decorators/exercise_0{1,2,3,4} +uv run ruff check CH_06_decorators/exercise_0{1,2,3,4} +uv run pyrefly check CH_06_decorators/exercise_0{1,2,3,4} +uv run mypy CH_06_decorators/exercise_0{1,2,3,4} +uv run pyright CH_06_decorators/exercise_0{1,2,3,4} +uv run pytest CH_06_decorators/exercise_0{1,2,3,4} -v +``` + +Expected: all commands exit 0; tests contain no sleeps or nondeterministic timing. + +- [ ] **Step 5: Commit** + +```bash +git add CH_06_decorators/exercise_04 +git commit -m "feat(ch06): isolate memoization caches" +``` diff --git a/docs/superpowers/plans/2026-07-28-ch08-metaclasses.md b/docs/superpowers/plans/2026-07-28-ch08-metaclasses.md new file mode 100644 index 0000000..ac0ee73 --- /dev/null +++ b/docs/superpowers/plans/2026-07-28-ch08-metaclasses.md @@ -0,0 +1,793 @@ +# Chapter 8 Metaclass Solutions Implementation + +**For agentic workers:** REQUIRED SUB-SKILL: Use +`superpowers:subagent-driven-development` (recommended) or +`superpowers:executing-plans` to implement this plan task-by-task. + +**Goal:** Deliver typed, import-safe, tested canonical answers and exercise +documentation for all three Chapter 8 metaclass exercises. + +**Architecture:** Keep each exercise independent and expose the metaclass being +taught as its public API. Class-definition-time validation is deterministic: +required members allow inheritance, required bases allow indirect inheritance, +and method wrapping handles ordinary, static, and class methods without +touching dunder methods. Historical alternatives are not changed. + +**Tech Stack:** Python 3.10, pytest, Ruff, Pyrefly, mypy, Pyright + +--- + +## File map and public APIs + +- `CH_08_metaclasses/exercise_01/solution_00.py` + - `ExpectedAttrsMeta` + - `Trade.buy()` and `Trade.sell()` +- `CH_08_metaclasses/exercise_02/solution_00.py` + - `SomeBaseClass` + - `ExpectedBasesMeta` + - `Trade` +- `CH_08_metaclasses/exercise_03/solution_00.py` + - `WrappingMeta` + - `print_call` + - `SomeClass` +- Every exercise gains `README.rst` and `test_solution_00.py`. +- `CH_08_metaclasses/README.rst` becomes a concise linked exercise index. +- Do not modify any `solution_01.py` file or another packet's files. + +### Task 1: Validate required attributes and methods + +**Files:** + +- Modify: `CH_08_metaclasses/exercise_01/solution_00.py` +- Create: `CH_08_metaclasses/exercise_01/test_solution_00.py` +- Create: `CH_08_metaclasses/exercise_01/README.rst` + +- [ ] **Step 1: Write the failing metaclass contract tests** + +Create `CH_08_metaclasses/exercise_01/test_solution_00.py`: + +```python +import pytest + +from CH_08_metaclasses.exercise_01.solution_00 import ( + ExpectedAttrsMeta, + Trade, +) + + +def test_trade_exposes_required_methods() -> None: + trade: Trade = Trade() + + assert trade.buy() == "buy" + assert trade.sell() == "sell" + + +def test_inherited_members_satisfy_requirement() -> None: + class Parent: + inherited_value: int = 3 + + class Child( + Parent, + metaclass=ExpectedAttrsMeta, + required_attributes=("inherited_value",), + ): + pass + + assert Child.inherited_value == 3 + + +def test_missing_members_are_reported_together() -> None: + with pytest.raises( + AttributeError, + match=r"Broken missing required attribute\(s\): buy, sell", + ): + + class Broken( + metaclass=ExpectedAttrsMeta, + required_attributes=("buy", "sell"), + ): + pass + + +def test_empty_requirement_allows_plain_class() -> None: + class Plain(metaclass=ExpectedAttrsMeta): + value: int = 7 + + assert Plain.value == 7 +``` + +- [ ] **Step 2: Run the focused test and confirm the old implementation fails** + +Run: + +```bash +uv run pytest CH_08_metaclasses/exercise_01/test_solution_00.py -v +``` + +Expected: collection fails because the old module raises while defining its +empty `Trade`, or tests fail because `ExpectedAttrsMeta` does not accept +`required_attributes`. + +- [ ] **Step 3: Replace the canonical implementation with the reusable API** + +Replace `CH_08_metaclasses/exercise_01/solution_00.py` with: + +```python +"""Validate required class members while a class is being created.""" + +from collections.abc import Mapping + + +class ExpectedAttrsMeta(type): + """Require named attributes to be available on the resulting class.""" + + def __new__( + metaclass: type["ExpectedAttrsMeta"], + name: str, + bases: tuple[type, ...], + namespace: Mapping[str, object], + *, + required_attributes: tuple[str, ...] = (), + ) -> "ExpectedAttrsMeta": + class_namespace: dict[str, object] = dict(namespace) + created_class: ExpectedAttrsMeta = super().__new__( + metaclass, + name, + bases, + class_namespace, + ) + missing: tuple[str, ...] = tuple( + attribute + for attribute in required_attributes + if not hasattr(created_class, attribute) + ) + if missing: + missing_text: str = ", ".join(missing) + raise AttributeError( + f"{name} missing required attribute(s): {missing_text}" + ) + return created_class + + +class Trade( + metaclass=ExpectedAttrsMeta, + required_attributes=("buy", "sell"), +): + """Small valid example using the validation metaclass.""" + + def buy(self) -> str: + """Return the name of the demonstrated operation.""" + return "buy" + + def sell(self) -> str: + """Return the name of the demonstrated operation.""" + return "sell" + + +def main() -> None: + """Demonstrate that the canonical class satisfies its contract.""" + trade: Trade = Trade() + print(trade.buy(), trade.sell()) + + +if __name__ == "__main__": + main() +``` + +The post-creation `hasattr` check is intentional: inherited attributes and +methods count as available. Preserve the existing public names +`ExpectedAttrsMeta` and `Trade`. + +- [ ] **Step 4: Run the focused test and static checks** + +Run: + +```bash +uv run pytest CH_08_metaclasses/exercise_01/test_solution_00.py -v +uv run ruff format --check CH_08_metaclasses/exercise_01 +uv run ruff check CH_08_metaclasses/exercise_01 +uv run pyrefly check CH_08_metaclasses/exercise_01/solution_00.py +uv run mypy CH_08_metaclasses/exercise_01/solution_00.py +uv run pyright CH_08_metaclasses/exercise_01/solution_00.py +``` + +Expected: four pytest tests pass and every quality command exits zero. + +- [ ] **Step 5: Add the exercise README** + +Create `CH_08_metaclasses/exercise_01/README.rst` with: + +```rst +Exercise 1: required attributes +=============================== + +Question +-------- + +Create a metaclass that tests whether attributes and methods are available. + +Answer +------ + +``ExpectedAttrsMeta`` validates the completed class at definition time. Members +inherited from a base class satisfy the requirement; all missing names are +reported in one ``AttributeError``. ``Trade`` demonstrates a valid class. + +Dependencies +------------ + +Only the Python 3.10 standard library is required at runtime. Tests use pytest. + +Run +--- + +.. code-block:: console + + uv run python -m CH_08_metaclasses.exercise_01.solution_00 + uv run pytest CH_08_metaclasses/exercise_01/test_solution_00.py -v + +Upstream reference +------------------ + +The class-construction pattern is informed by +`T_01_basic_metaclass.rst `_. +The upstream material is MIT licensed; this answer is self-contained and does +not import it at runtime. +``` + +- [ ] **Step 6: Re-run the exercise test and commit** + +Run: + +```bash +uv run pytest CH_08_metaclasses/exercise_01/test_solution_00.py -v +git add CH_08_metaclasses/exercise_01/solution_00.py CH_08_metaclasses/exercise_01/test_solution_00.py CH_08_metaclasses/exercise_01/README.rst +git commit -m "feat: complete metaclass attribute exercise" +``` + +Expected: tests pass; the commit contains only Exercise 1 files. + +### Task 2: Validate required base classes + +**Files:** + +- Modify: `CH_08_metaclasses/exercise_02/solution_00.py` +- Create: `CH_08_metaclasses/exercise_02/test_solution_00.py` +- Create: `CH_08_metaclasses/exercise_02/README.rst` + +- [ ] **Step 1: Write the failing inheritance contract tests** + +Create `CH_08_metaclasses/exercise_02/test_solution_00.py`: + +```python +import pytest + +from CH_08_metaclasses.exercise_02.solution_00 import ( + ExpectedBasesMeta, + SomeBaseClass, + Trade, +) + + +def test_trade_inherits_required_base() -> None: + assert issubclass(Trade, SomeBaseClass) + + +def test_indirect_inheritance_is_accepted() -> None: + class Intermediate(SomeBaseClass): + pass + + class Indirect( + Intermediate, + metaclass=ExpectedBasesMeta, + expected_bases=(SomeBaseClass,), + ): + pass + + assert issubclass(Indirect, SomeBaseClass) + + +def test_missing_required_base_raises_type_error() -> None: + with pytest.raises( + TypeError, + match="Broken must inherit from SomeBaseClass", + ): + + class Broken( + metaclass=ExpectedBasesMeta, + expected_bases=(SomeBaseClass,), + ): + pass + + +def test_multiple_requirements_report_every_missing_base() -> None: + class OtherBase: + pass + + with pytest.raises( + TypeError, + match="Partial must inherit from OtherBase", + ): + + class Partial( + SomeBaseClass, + metaclass=ExpectedBasesMeta, + expected_bases=(SomeBaseClass, OtherBase), + ): + pass +``` + +- [ ] **Step 2: Run the test and confirm the current direct-base check fails** + +Run: + +```bash +uv run pytest CH_08_metaclasses/exercise_02/test_solution_00.py -v +``` + +Expected: collection or assertions fail because the existing module defines an +invalid class at import time and checks only direct bases. + +- [ ] **Step 3: Implement indirect required-base validation** + +Replace `CH_08_metaclasses/exercise_02/solution_00.py` with: + +```python +"""Validate required inheritance while a class is being created.""" + +from collections.abc import Mapping + + +class SomeBaseClass: + """Base class required by the canonical example.""" + + +class ExpectedBasesMeta(type): + """Require the resulting class to inherit from specified classes.""" + + def __new__( + metaclass: type["ExpectedBasesMeta"], + name: str, + bases: tuple[type, ...], + namespace: Mapping[str, object], + *, + expected_bases: tuple[type, ...] = (), + ) -> "ExpectedBasesMeta": + class_namespace: dict[str, object] = dict(namespace) + created_class: ExpectedBasesMeta = super().__new__( + metaclass, + name, + bases, + class_namespace, + ) + missing: tuple[type, ...] = tuple( + base + for base in expected_bases + if not issubclass(created_class, base) + ) + if missing: + missing_text: str = ", ".join(base.__name__ for base in missing) + raise TypeError(f"{name} must inherit from {missing_text}") + return created_class + + +class Trade( + SomeBaseClass, + metaclass=ExpectedBasesMeta, + expected_bases=(SomeBaseClass,), +): + """Valid example inheriting the required base.""" + + +def main() -> None: + """Demonstrate successful inheritance validation.""" + print(issubclass(Trade, SomeBaseClass)) + + +if __name__ == "__main__": + main() +``` + +Use `issubclass` on the completed class so indirect inheritance is valid. Do +not restore the old import-time `BrokenTrade` example. + +- [ ] **Step 4: Run tests and strict checks** + +Run: + +```bash +uv run pytest CH_08_metaclasses/exercise_02/test_solution_00.py -v +uv run ruff format --check CH_08_metaclasses/exercise_02 +uv run ruff check CH_08_metaclasses/exercise_02 +uv run pyrefly check CH_08_metaclasses/exercise_02/solution_00.py +uv run mypy CH_08_metaclasses/exercise_02/solution_00.py +uv run pyright CH_08_metaclasses/exercise_02/solution_00.py +``` + +Expected: four tests pass and all quality commands exit zero. + +- [ ] **Step 5: Document the behavioral choice and provenance** + +Create `CH_08_metaclasses/exercise_02/README.rst`: + +```rst +Exercise 2: required base classes +================================= + +Question +-------- + +Create a metaclass that tests whether specific classes are inherited. + +Answer +------ + +``ExpectedBasesMeta`` checks the completed class with ``issubclass``. Direct +and indirect inheritance satisfy the contract. A missing required base raises a +``TypeError`` during class definition and names every missing base. + +Dependencies +------------ + +Only the Python 3.10 standard library is required at runtime. Tests use pytest. + +Run +--- + +.. code-block:: console + + uv run python -m CH_08_metaclasses.exercise_02.solution_00 + uv run pytest CH_08_metaclasses/exercise_02/test_solution_00.py -v + +Upstream reference +------------------ + +The inheritance checks are informed by +`T_05_custom_type_checks.rst `_. +The upstream material is MIT licensed; this answer remains offline and +self-contained. +``` + +- [ ] **Step 6: Re-run and commit Exercise 2** + +Run: + +```bash +uv run pytest CH_08_metaclasses/exercise_02/test_solution_00.py -v +git add CH_08_metaclasses/exercise_02/solution_00.py CH_08_metaclasses/exercise_02/test_solution_00.py CH_08_metaclasses/exercise_02/README.rst +git commit -m "feat: complete metaclass inheritance exercise" +``` + +Expected: tests pass; only Exercise 2 files are staged. + +### Task 3: Wrap ordinary, static, and class methods + +**Files:** + +- Modify: `CH_08_metaclasses/exercise_03/solution_00.py` +- Create: `CH_08_metaclasses/exercise_03/test_solution_00.py` +- Create: `CH_08_metaclasses/exercise_03/README.rst` +- Modify: `CH_08_metaclasses/README.rst` + +- [ ] **Step 1: Write failing descriptor-aware wrapping tests** + +Create `CH_08_metaclasses/exercise_03/test_solution_00.py`: + +```python +from collections.abc import Callable + +from CH_08_metaclasses.exercise_03.solution_00 import WrappingMeta + + +def test_wraps_supported_method_descriptors_once() -> None: + calls: list[str] = [] + + def wrapper( + function: Callable[..., object], + ) -> Callable[..., object]: + def wrapped(*args: object, **kwargs: object) -> object: + calls.append(function.__name__) + return function(*args, **kwargs) + + return wrapped + + class Example(metaclass=WrappingMeta, wrapper=wrapper): + label: str = "kept" + + def instance_method(self, value: int) -> int: + return value + 1 + + @staticmethod + def static_method(value: int) -> int: + return value + 2 + + @classmethod + def class_method(cls, value: int) -> tuple[str, int]: + return cls.__name__, value + 3 + + example: Example = Example() + + assert example.label == "kept" + assert example.instance_method(1) == 2 + assert Example.static_method(1) == 3 + assert Example.class_method(1) == ("Example", 4) + assert calls == ["instance_method", "static_method", "class_method"] + + +def test_dunder_methods_are_not_wrapped() -> None: + calls: list[str] = [] + + def wrapper( + function: Callable[..., object], + ) -> Callable[..., object]: + def wrapped(*args: object, **kwargs: object) -> object: + calls.append(function.__name__) + return function(*args, **kwargs) + + return wrapped + + class Example(metaclass=WrappingMeta, wrapper=wrapper): + def __init__(self) -> None: + self.value: int = 4 + + def read(self) -> int: + return self.value + + example: Example = Example() + + assert calls == [] + assert example.read() == 4 + assert calls == ["read"] + + +def test_missing_wrapper_leaves_methods_unchanged() -> None: + class Example(metaclass=WrappingMeta): + def read(self) -> int: + return 5 + + assert Example().read() == 5 +``` + +- [ ] **Step 2: Run the focused test and observe descriptor failures** + +Run: + +```bash +uv run pytest CH_08_metaclasses/exercise_03/test_solution_00.py -v +``` + +Expected: tests fail because the old implementation is not import-safe and +does not preserve `staticmethod` and `classmethod` descriptors. + +- [ ] **Step 3: Implement descriptor-aware method wrapping** + +Replace `CH_08_metaclasses/exercise_03/solution_00.py` with: + +```python +"""Wrap methods supplied in a class namespace through a metaclass.""" + +import functools +import inspect +from collections.abc import Callable, Mapping +from typing import ParamSpec, TypeVar, cast + +Parameters = ParamSpec("Parameters") +Result = TypeVar("Result") +Method = Callable[..., object] +Wrapper = Callable[[Method], Method] + + +class WrappingMeta(type): + """Apply a wrapper to each non-dunder method defined by a class.""" + + def __new__( + metaclass: type["WrappingMeta"], + name: str, + bases: tuple[type, ...], + namespace: Mapping[str, object], + *, + wrapper: Wrapper | None = None, + ) -> "WrappingMeta": + wrapped_namespace: dict[str, object] = dict(namespace) + if wrapper is not None: + for attribute_name, attribute in namespace.items(): + if attribute_name.startswith("__"): + continue + if isinstance(attribute, staticmethod): + static_function: Method = cast(Method, attribute.__func__) + wrapped_namespace[attribute_name] = staticmethod( + wrapper(static_function) + ) + elif isinstance(attribute, classmethod): + class_function: Method = cast(Method, attribute.__func__) + wrapped_namespace[attribute_name] = classmethod( + wrapper(class_function) + ) + elif inspect.isfunction(attribute): + method: Method = cast(Method, attribute) + wrapped_namespace[attribute_name] = wrapper(method) + return super().__new__( + metaclass, + name, + bases, + wrapped_namespace, + ) + + +def print_call( + function: Callable[Parameters, Result], +) -> Callable[Parameters, Result]: + """Print a method name before delegating to it.""" + + @functools.wraps(function) + def wrapped( + *args: Parameters.args, + **kwargs: Parameters.kwargs, + ) -> Result: + print(f"calling {function.__name__}") + return function(*args, **kwargs) + + return wrapped + + +class SomeClass(metaclass=WrappingMeta, wrapper=cast(Wrapper, print_call)): + """Demonstrate wrapping an ordinary method.""" + + def some_method(self) -> str: + """Return a deterministic demonstration value.""" + return "wrapped" + + +def main() -> None: + """Run the wrapped demonstration method.""" + print(SomeClass().some_method()) + + +if __name__ == "__main__": + main() +``` + +Keep the single generic-wrapper cast exactly at the dynamic metaclass boundary +shown above; do not introduce a repository-wide `Any` or missing-import +suppression. + +- [ ] **Step 4: Run focused tests and quality checks** + +Run: + +```bash +uv run pytest CH_08_metaclasses/exercise_03/test_solution_00.py -v +uv run ruff format --check CH_08_metaclasses/exercise_03 +uv run ruff check CH_08_metaclasses/exercise_03 +uv run pyrefly check CH_08_metaclasses/exercise_03/solution_00.py +uv run mypy CH_08_metaclasses/exercise_03/solution_00.py +uv run pyright CH_08_metaclasses/exercise_03/solution_00.py +``` + +Expected: three tests pass and all quality commands exit zero. + +- [ ] **Step 5: Add the exercise README and linked chapter index** + +Create `CH_08_metaclasses/exercise_03/README.rst`: + +```rst +Exercise 3: wrapping class methods +================================== + +Question +-------- + +Build a metaclass that wraps every method with a decorator, for example: + +.. code-block:: python + + class SomeClass(metaclass=WrappingMeta, wrapper=some_wrapper): + pass + +Answer +------ + +``WrappingMeta`` wraps ordinary methods, static methods, and class methods +defined directly in the class body. It preserves descriptor behavior and +leaves data attributes, inherited methods, and dunder methods unchanged. + +Dependencies +------------ + +Only the Python 3.10 standard library is required at runtime. Tests use pytest. + +Run +--- + +.. code-block:: console + + uv run python -m CH_08_metaclasses.exercise_03.solution_00 + uv run pytest CH_08_metaclasses/exercise_03/test_solution_00.py -v + +Upstream references +------------------- + +Metaclass keyword arguments and class namespace handling are informed by +`T_02_arguments_to_metaclasses.rst `_ +and +`T_03_accessing_metaclass_attributes.rst `_. +The upstream material is MIT licensed; no runtime import or download is used. +``` + +Replace `CH_08_metaclasses/README.rst` with: + +```rst +Chapter 8 - metaclasses +======================= + +1. `Required attributes `_ +2. `Required base classes `_ +3. `Wrapping class methods `_ +``` + +- [ ] **Step 6: Run the complete Chapter 8 packet** + +Run: + +```bash +uv run pytest CH_08_metaclasses -v +uv run ruff format --check CH_08_metaclasses +uv run ruff check CH_08_metaclasses +uv run pyrefly check CH_08_metaclasses +uv run mypy CH_08_metaclasses +uv run pyright CH_08_metaclasses +``` + +Expected: all Chapter 8 tests pass with zero static-quality findings. + +- [ ] **Step 7: Commit the final exercise and chapter index** + +Run: + +```bash +git add CH_08_metaclasses/exercise_03/solution_00.py CH_08_metaclasses/exercise_03/test_solution_00.py CH_08_metaclasses/exercise_03/README.rst CH_08_metaclasses/README.rst +git commit -m "feat: complete metaclass wrapping exercise" +``` + +Expected: the commit contains only Exercise 3 and the Chapter 8 index. + +### Task 4: Packet self-review + +**Files:** + +- Review: `CH_08_metaclasses/README.rst` +- Review: `CH_08_metaclasses/exercise_01/` +- Review: `CH_08_metaclasses/exercise_02/` +- Review: `CH_08_metaclasses/exercise_03/` + +- [ ] **Step 1: Verify directory-contract completeness** + +Run: + +```bash +find CH_08_metaclasses -maxdepth 2 -type f | sort +``` + +Expected: each `exercise_NN` contains `__init__.py`, `README.rst`, +`solution_00.py`, and `test_solution_00.py`; no historical alternative changed. + +- [ ] **Step 2: Scan for plan-forbidden placeholders in implemented files** + +Run: + +```bash +``` + +Expected: no matches. + +- [ ] **Step 3: Confirm the packet diff is isolated** + +Run: + +```bash +git status --short +git diff --stat master -- CH_08_metaclasses +``` + +Expected: no uncommitted Chapter 8 changes; the diff names no file outside the +owned packet and its plan. diff --git a/docs/superpowers/plans/2026-07-28-ch09-ch10-typing-testing.md b/docs/superpowers/plans/2026-07-28-ch09-ch10-typing-testing.md new file mode 100644 index 0000000..ae5f9b8 --- /dev/null +++ b/docs/superpowers/plans/2026-07-28-ch09-ch10-typing-testing.md @@ -0,0 +1,1197 @@ +# Chapters 9-10 Typing, Testing, and Logging Implementation + +**For agentic workers:** REQUIRED SUB-SKILL: Use +`superpowers:subagent-driven-development` (recommended) or +`superpowers:executing-plans` to implement this plan task-by-task. + +**Goal:** Add canonical typed examples for all three Chapter 9 prompts and +offline-tested implementations for all five Chapter 10 prompts. + +**Architecture:** Chapter 9 uses one focused runtime example per typing concept: +a heterogeneous `TypedDict`, a deeply nested type alias, and a recursive tree +alias. Chapter 10 exposes reusable doctest runners, a modern pytest collection +hook, a generated tox configuration that runs offline from the locked +environment, and a thread-safe `LoggerAdapter` message buffer. + +**Tech Stack:** Python 3.10, `typing`, doctest, pytest/pytester, tox 4, flake8, +mypy, logging, Ruff, Pyrefly, Pyright + +--- + +## File map and fixed public APIs + +- CH09 Exercise 1: `Contact`, `UserRecord`, `describe_user` +- CH09 Exercise 2: `NestedMeasurements`, `flatten_measurements` +- CH09 Exercise 3: `Tree`, `count_nodes` +- CH10 Exercise 1: `run_doctests` +- CH10 Exercise 2: `run_module_doctests` +- CH10 Exercise 3: `has_module_docstring`, `pytest_collect_file` +- CH10 Exercise 4: `render_tox_config`, `write_tox_config` +- CH10 Exercise 5: `TaskLoggerAdapter.add`, `TaskLoggerAdapter.flush` +- Every exercise directory contains `__init__.py`, `README.rst`, + `solution_00.py`, and `test_solution_00.py`. +- The coordinator owns root dependency/static-check configuration. Do not + modify root configuration from this packet. + +### Task 1: Type a heterogeneous complex dictionary + +**Files:** + +- Create: `CH_09_documentation/exercise_01/__init__.py` +- Create: `CH_09_documentation/exercise_01/solution_00.py` +- Create: `CH_09_documentation/exercise_01/test_solution_00.py` +- Create: `CH_09_documentation/exercise_01/README.rst` + +- [ ] **Step 1: Write the failing runtime and annotation tests** + +```python +# CH_09_documentation/exercise_01/test_solution_00.py +from typing import get_type_hints + +from CH_09_documentation.exercise_01.solution_00 import ( + Contact, + UserRecord, + describe_user, +) + + +def test_describe_user_reads_heterogeneous_record() -> None: + user: UserRecord = { + "name": "Ada", + "age": 36, + "active": True, + "contact": {"email": "ada@example.test", "phone": None}, + } + + assert describe_user(user) == "Ada (36) active: ada@example.test" + + +def test_typed_dict_exposes_nested_field_types() -> None: + user_hints: dict[str, object] = get_type_hints(UserRecord) + contact_hints: dict[str, object] = get_type_hints(Contact) + + assert set(user_hints) == {"name", "age", "active", "contact"} + assert set(contact_hints) == {"email", "phone"} +``` + +- [ ] **Step 2: Run the missing-module test** + +Run: + +```bash +uv run pytest CH_09_documentation/exercise_01/test_solution_00.py -v +``` + +Expected: FAIL during collection with `ModuleNotFoundError` because the exercise +directory does not exist. + +- [ ] **Step 3: Add the typed dictionary implementation** + +```python +# CH_09_documentation/exercise_01/solution_00.py +"""Type a dictionary whose values have different, known shapes.""" + +from typing import TypedDict + + +class Contact(TypedDict): + """Contact fields nested in a user record.""" + + email: str + phone: str | None + + +class UserRecord(TypedDict): + """A heterogeneous dictionary with a nested typed dictionary.""" + + name: str + age: int + active: bool + contact: Contact + + +def describe_user(user: UserRecord) -> str: + """Render the fields while preserving their precise static types.""" + state: str = "active" if user["active"] else "inactive" + return ( + f'{user["name"]} ({user["age"]}) {state}: ' + f'{user["contact"]["email"]}' + ) + + +def main() -> None: + """Render one fully typed record.""" + user: UserRecord = { + "name": "Ada", + "age": 36, + "active": True, + "contact": {"email": "ada@example.test", "phone": None}, + } + print(describe_user(user)) + + +if __name__ == "__main__": + main() +``` + +Create an empty `CH_09_documentation/exercise_01/__init__.py`. + +- [ ] **Step 4: Verify runtime and strict typing** + +Run: + +```bash +uv run pytest CH_09_documentation/exercise_01/test_solution_00.py -v +uv run mypy CH_09_documentation/exercise_01/solution_00.py +uv run pyright CH_09_documentation/exercise_01/solution_00.py +uv run pyrefly check CH_09_documentation/exercise_01/solution_00.py +``` + +Expected: two tests pass and all three type checkers exit zero. + +- [ ] **Step 5: Add README and commit** + +The README must repeat “Type hint a complex `dict`,” explain why `TypedDict` +fits heterogeneous fixed keys, list standard-library/pytest dependencies, and +show: + +```console +uv run python -m CH_09_documentation.exercise_01.solution_00 +uv run pytest CH_09_documentation/exercise_01/test_solution_00.py -v +``` + +Include this immutable source: + +```rst +`Upstream type-hinting examples `_ +``` + +Run: + +```bash +git add CH_09_documentation/exercise_01 +git commit -m "feat: add complex dictionary typing exercise" +``` + +Expected: the commit contains only CH09 Exercise 1. + +### Task 2: Type deeply nested containers + +**Files:** + +- Create: `CH_09_documentation/exercise_02/__init__.py` +- Create: `CH_09_documentation/exercise_02/solution_00.py` +- Create: `CH_09_documentation/exercise_02/test_solution_00.py` +- Create: `CH_09_documentation/exercise_02/README.rst` + +- [ ] **Step 1: Write the failing nested-container tests** + +```python +# CH_09_documentation/exercise_02/test_solution_00.py +from CH_09_documentation.exercise_02.solution_00 import ( + NestedMeasurements, + flatten_measurements, +) + + +def test_flatten_measurements_preserves_mapping_and_list_order() -> None: + values: NestedMeasurements = { + "north": [(1, 1.5), (2, 2.5)], + "south": [(3, 3.5)], + } + + assert flatten_measurements(values) == [1.5, 2.5, 3.5] + + +def test_flatten_measurements_accepts_empty_levels() -> None: + values: NestedMeasurements = {"north": [], "south": []} + + assert flatten_measurements(values) == [] +``` + +- [ ] **Step 2: Confirm the test fails before implementation** + +Run: + +```bash +uv run pytest CH_09_documentation/exercise_02/test_solution_00.py -v +``` + +Expected: FAIL with `ModuleNotFoundError`. + +- [ ] **Step 3: Implement the nested alias and consumer** + +```python +# CH_09_documentation/exercise_02/solution_00.py +"""Type nested homogeneous containers with a readable alias.""" + +from typing import TypeAlias + +NestedMeasurements: TypeAlias = dict[str, list[tuple[int, float]]] + + +def flatten_measurements(values: NestedMeasurements) -> list[float]: + """Return each measurement value in deterministic insertion order.""" + flattened: list[float] = [] + rows: list[tuple[int, float]] + measurement: float + for rows in values.values(): + for _, measurement in rows: + flattened.append(measurement) + return flattened + + +def main() -> None: + """Demonstrate the nested alias.""" + values: NestedMeasurements = {"north": [(1, 1.5), (2, 2.5)]} + print(flatten_measurements(values)) + + +if __name__ == "__main__": + main() +``` + +Create an empty `__init__.py`. + +- [ ] **Step 4: Run tests, type checks, document, and commit** + +Run: + +```bash +uv run pytest CH_09_documentation/exercise_02/test_solution_00.py -v +uv run mypy CH_09_documentation/exercise_02/solution_00.py +uv run pyright CH_09_documentation/exercise_02/solution_00.py +uv run pyrefly check CH_09_documentation/exercise_02/solution_00.py +``` + +Expected: two tests pass and strict checks exit zero. + +Create `README.rst` with the exact question “Type hint nested types,” explain +the alias and deterministic order, cite `T_00_type_hinting.rst` at the pinned +SHA, and show: + +```console +uv run python -m CH_09_documentation.exercise_02.solution_00 +uv run pytest CH_09_documentation/exercise_02/test_solution_00.py -v +``` + +Then: + +```bash +git add CH_09_documentation/exercise_02 +git commit -m "feat: add nested type exercise" +``` + +### Task 3: Type and safely traverse a recursive tree + +**Files:** + +- Create: `CH_09_documentation/exercise_03/__init__.py` +- Create: `CH_09_documentation/exercise_03/solution_00.py` +- Create: `CH_09_documentation/exercise_03/test_solution_00.py` +- Create: `CH_09_documentation/exercise_03/README.rst` +- Modify: `CH_09_documentation/README.rst` + +- [ ] **Step 1: Write recursive and cycle tests** + +```python +# CH_09_documentation/exercise_03/test_solution_00.py +import pytest + +from CH_09_documentation.exercise_03.solution_00 import Tree, count_nodes + + +def test_count_nodes_handles_nested_and_empty_branches() -> None: + tree: Tree = { + "root": { + "left": {}, + "right": {"leaf": {}}, + } + } + + assert count_nodes(tree) == 4 + assert count_nodes({}) == 0 + + +def test_count_nodes_rejects_a_cycle() -> None: + tree: Tree = {} + tree["loop"] = tree + + with pytest.raises(ValueError, match="tree contains a cycle"): + count_nodes(tree) +``` + +- [ ] **Step 2: Run and observe the missing-module failure** + +Run: + +```bash +uv run pytest CH_09_documentation/exercise_03/test_solution_00.py -v +``` + +Expected: FAIL with `ModuleNotFoundError`. + +- [ ] **Step 3: Implement a forward-referenced recursive alias** + +```python +# CH_09_documentation/exercise_03/solution_00.py +"""Represent and traverse a recursively nested tree.""" + +from typing import TypeAlias + +Tree: TypeAlias = dict[str, "Tree"] + + +def count_nodes(tree: Tree) -> int: + """Count named nodes and reject reference cycles.""" + + def visit(branch: Tree, ancestors: set[int]) -> int: + branch_id: int = id(branch) + if branch_id in ancestors: + raise ValueError("tree contains a cycle") + next_ancestors: set[int] = ancestors | {branch_id} + total: int = 0 + name: str + children: Tree + for name, children in branch.items(): + del name + total += 1 + visit(children, next_ancestors) + return total + + return visit(tree, set()) + + +def main() -> None: + """Count one recursive example.""" + tree: Tree = {"root": {"leaf": {}}} + print(count_nodes(tree)) + + +if __name__ == "__main__": + main() +``` + +Create the empty package initializer. + +- [ ] **Step 4: Verify, document, index, and commit** + +Run: + +```bash +uv run pytest CH_09_documentation/exercise_03/test_solution_00.py -v +uv run ruff format --check CH_09_documentation +uv run ruff check CH_09_documentation +uv run pyrefly check CH_09_documentation +uv run mypy CH_09_documentation +uv run pyright CH_09_documentation +``` + +Expected: recursive tests pass and all checks exit zero. + +The exercise README repeats “Type hint recursive types,” documents the +dictionary-tree interpretation and cycle error, lists standard-library/pytest +dependencies, shows the module/test commands, and cites pinned +`T_00_type_hinting.rst`. Replace the chapter README with: + +```rst +Chapter 9 - documentation +========================= + +1. `Complex dictionaries `_ +2. `Nested container types `_ +3. `Recursive types `_ +``` + +Commit: + +```bash +git add CH_09_documentation/exercise_03 CH_09_documentation/README.rst +git commit -m "feat: add recursive type exercise" +``` + +### Task 4: Run doctests attached to one function or class + +**Files:** + +- Create: `CH_10_testing_and_logging/exercise_01/__init__.py` +- Create: `CH_10_testing_and_logging/exercise_01/solution_00.py` +- Create: `CH_10_testing_and_logging/exercise_01/test_solution_00.py` +- Create: `CH_10_testing_and_logging/exercise_01/README.rst` + +- [ ] **Step 1: Write passing, failing, and empty-doctest tests** + +```python +# CH_10_testing_and_logging/exercise_01/test_solution_00.py +import doctest + +from CH_10_testing_and_logging.exercise_01.solution_00 import run_doctests + + +def documented_add(left: int, right: int) -> int: + """Add two values. + + >>> documented_add(2, 3) + 5 + """ + return left + right + + +def broken_example() -> int: + """Expose a deliberately failing doctest. + + >>> broken_example() + 2 + """ + return 1 + + +def test_run_doctests_reports_success() -> None: + result: doctest.TestResults = run_doctests(documented_add) + + assert result == doctest.TestResults(failed=0, attempted=1) + + +def test_run_doctests_reports_failure_without_swallowing_it() -> None: + result: doctest.TestResults = run_doctests(broken_example) + + assert result.failed == 1 + assert result.attempted == 1 + + +def test_run_doctests_accepts_object_without_examples() -> None: + result: doctest.TestResults = run_doctests(object) + + assert result == doctest.TestResults(failed=0, attempted=0) +``` + +- [ ] **Step 2: Run the test and see the missing implementation** + +Run: + +```bash +uv run pytest CH_10_testing_and_logging/exercise_01/test_solution_00.py -v +``` + +Expected: FAIL with `ModuleNotFoundError`. + +- [ ] **Step 3: Implement the focused doctest runner** + +```python +# CH_10_testing_and_logging/exercise_01/solution_00.py +"""Run doctests attached to a function, class, or other object.""" + +import doctest + + +def run_doctests( + target: object, + *, + optionflags: int = doctest.ELLIPSIS, +) -> doctest.TestResults: + """Run every doctest found below one target and return totals.""" + finder: doctest.DocTestFinder = doctest.DocTestFinder(recurse=True) + runner: doctest.DocTestRunner = doctest.DocTestRunner( + optionflags=optionflags + ) + test: doctest.DocTest + for test in finder.find(target): + runner.run(test) + return runner.summarize(verbose=False) + + +if __name__ == "__main__": + raise SystemExit("Import run_doctests and pass a documented object.") +``` + +Create the empty package initializer. + +- [ ] **Step 4: Verify, document, and commit** + +Run: + +```bash +uv run pytest CH_10_testing_and_logging/exercise_01/test_solution_00.py -v +uv run ruff check CH_10_testing_and_logging/exercise_01 +uv run mypy CH_10_testing_and_logging/exercise_01/solution_00.py +uv run pyright CH_10_testing_and_logging/exercise_01/solution_00.py +``` + +Expected: three tests pass and checks exit zero. + +README requirements: exact prompt, explain returned attempted/failed totals, +list pytest, show the focused `uv run pytest` command, and cite: + +```rst +`Simple doctest example `_ +``` + +Commit: + +```bash +git add CH_10_testing_and_logging/exercise_01 +git commit -m "feat: add object doctest runner" +``` + +### Task 5: Recursively run doctests in a module + +**Files:** + +- Create: `CH_10_testing_and_logging/exercise_02/__init__.py` +- Create: `CH_10_testing_and_logging/exercise_02/solution_00.py` +- Create: `CH_10_testing_and_logging/exercise_02/test_solution_00.py` +- Create: `CH_10_testing_and_logging/exercise_02/README.rst` + +- [ ] **Step 1: Write a synthetic-module integration test** + +```python +# CH_10_testing_and_logging/exercise_02/test_solution_00.py +import doctest +import types + +import pytest + +from CH_10_testing_and_logging.exercise_02.solution_00 import ( + run_module_doctests, +) + + +def make_module() -> types.ModuleType: + module: types.ModuleType = types.ModuleType("doctest_fixture") + source: str = ''' +def square(value: int) -> int: + """Square a value. + + >>> square(4) + 16 + """ + return value * value + +class Calculator: + """A documented class. + + >>> Calculator().double(3) + 6 + """ + def double(self, value: int) -> int: + return value * 2 +''' + exec(compile(source, "", "exec"), module.__dict__) + return module + + +def test_run_module_doctests_recurses_into_functions_and_classes() -> None: + result: doctest.TestResults = run_module_doctests(make_module()) + + assert result == doctest.TestResults(failed=0, attempted=2) + + +def test_run_module_doctests_rejects_non_module() -> None: + with pytest.raises(TypeError, match="target must be a module"): + run_module_doctests(object()) +``` + +- [ ] **Step 2: Run and confirm failure** + +Run: + +```bash +uv run pytest CH_10_testing_and_logging/exercise_02/test_solution_00.py -v +``` + +Expected: FAIL with `ModuleNotFoundError`. + +- [ ] **Step 3: Implement recursive module discovery** + +```python +# CH_10_testing_and_logging/exercise_02/solution_00.py +"""Run all function and class doctests owned by a module.""" + +import doctest +import types + + +def run_module_doctests(target: object) -> doctest.TestResults: + """Recursively discover and run doctests in one module.""" + if not isinstance(target, types.ModuleType): + raise TypeError("target must be a module") + finder: doctest.DocTestFinder = doctest.DocTestFinder(recurse=True) + runner: doctest.DocTestRunner = doctest.DocTestRunner() + test: doctest.DocTest + for test in finder.find( + target, + name=target.__name__, + module=target, + ): + runner.run(test) + return runner.summarize(verbose=False) + + +if __name__ == "__main__": + raise SystemExit("Import run_module_doctests and pass a module.") +``` + +- [ ] **Step 4: Verify, document, and commit** + +Run: + +```bash +uv run pytest CH_10_testing_and_logging/exercise_02/test_solution_00.py -v +uv run ruff check CH_10_testing_and_logging/exercise_02 +uv run pyrefly check CH_10_testing_and_logging/exercise_02 +uv run mypy CH_10_testing_and_logging/exercise_02 +uv run pyright CH_10_testing_and_logging/exercise_02 +``` + +Expected: two tests pass and every check exits zero. + +README: repeat the recursive-doctest prompt, state that only objects owned by +the module are discovered, list pytest, show the exact focused command, and cite +the pinned `T_00_simple_doctest.py` and `T_02_testing_with_documentation/` +sources. Commit as: + +```bash +git add CH_10_testing_and_logging/exercise_02 +git commit -m "feat: add recursive module doctest runner" +``` + +### Task 6: Enforce module docstrings with a pytest plugin + +**Files:** + +- Create: `CH_10_testing_and_logging/exercise_03/__init__.py` +- Create: `CH_10_testing_and_logging/exercise_03/solution_00.py` +- Create: `CH_10_testing_and_logging/exercise_03/test_solution_00.py` +- Create: `CH_10_testing_and_logging/exercise_03/README.rst` + +- [ ] **Step 1: Write pytester integration tests** + +```python +# CH_10_testing_and_logging/exercise_03/test_solution_00.py +import pytest + +pytest_plugins: tuple[str, ...] = ("pytester",) + + +def install_plugin(pytester: pytest.Pytester) -> None: + pytester.makeconftest( + 'pytest_plugins = ' + '("CH_10_testing_and_logging.exercise_03.solution_00",)' + ) + + +def test_documented_test_module_collects(pytester: pytest.Pytester) -> None: + install_plugin(pytester) + pytester.makepyfile( + test_documented='''"""Documented test module.""" + +def test_ok() -> None: + assert True +''' + ) + + result: pytest.RunResult = pytester.runpytest("-q") + + result.assert_outcomes(passed=1) + + +def test_undocumented_test_module_is_rejected( + pytester: pytest.Pytester, +) -> None: + install_plugin(pytester) + pytester.makepyfile( + test_undocumented="""def test_ok() -> None: + assert True +""" + ) + + result: pytest.RunResult = pytester.runpytest("-q") + + assert result.ret == pytest.ExitCode.USAGE_ERROR + result.stderr.fnmatch_lines( + ["*test_undocumented.py is missing a module docstring*"] + ) +``` + +- [ ] **Step 2: Run and confirm the plugin does not exist** + +Run: + +```bash +uv run pytest CH_10_testing_and_logging/exercise_03/test_solution_00.py -v +``` + +Expected: both pytester subprocesses fail to import the plugin. + +- [ ] **Step 3: Implement the modern pathlib-based collection hook** + +```python +# CH_10_testing_and_logging/exercise_03/solution_00.py +"""Pytest plugin requiring a file-level docstring in Python test modules.""" + +import ast +from pathlib import Path + +import pytest + + +def has_module_docstring(path: Path) -> bool: + """Return whether a Python source file starts with a module docstring.""" + source: str = path.read_text(encoding="utf-8") + module: ast.Module = ast.parse(source, filename=str(path)) + return ast.get_docstring(module, clean=False) is not None + + +def _is_test_module(path: Path) -> bool: + return path.suffix == ".py" and ( + path.name.startswith("test_") or path.name.endswith("_test.py") + ) + + +def pytest_collect_file( + file_path: Path, + parent: pytest.Collector, +) -> None: + """Reject collected Python test modules without file documentation.""" + del parent + if _is_test_module(file_path) and not has_module_docstring(file_path): + raise pytest.UsageError( + f"{file_path.name} is missing a module docstring" + ) +``` + +This uses pytest's `pathlib.Path` hook argument, not the legacy `py.path` +argument. It checks test modules only, so loading `conftest.py` does not +recursively reject the plugin setup itself. + +- [ ] **Step 4: Verify integration, document, and commit** + +Run: + +```bash +uv run pytest CH_10_testing_and_logging/exercise_03/test_solution_00.py -v +uv run ruff check CH_10_testing_and_logging/exercise_03 +uv run pyrefly check CH_10_testing_and_logging/exercise_03 +uv run mypy CH_10_testing_and_logging/exercise_03 +uv run pyright CH_10_testing_and_logging/exercise_03 +``` + +Expected: two outer tests pass; the nested undocumented project exits with +pytest usage error; static checks pass. + +README: exact prompt, test-module scope, syntax-error propagation, pytest +dependency, focused command, and pinned references to upstream +`CH_10_testing_and_logging/conftest.py` and `T_19_pytest.ini`. Commit: + +```bash +git add CH_10_testing_and_logging/exercise_03 +git commit -m "feat: add pytest documentation plugin" +``` + +### Task 7: Generate and execute an offline tox quality environment + +**Files:** + +- Create: `CH_10_testing_and_logging/exercise_04/__init__.py` +- Create: `CH_10_testing_and_logging/exercise_04/solution_00.py` +- Create: `CH_10_testing_and_logging/exercise_04/test_solution_00.py` +- Create: `CH_10_testing_and_logging/exercise_04/README.rst` + +- [ ] **Step 1: Write config and tox integration tests** + +```python +# CH_10_testing_and_logging/exercise_04/test_solution_00.py +import subprocess +import sys +from pathlib import Path + +from CH_10_testing_and_logging.exercise_04.solution_00 import ( + render_tox_config, + write_tox_config, +) + + +def test_render_tox_config_has_flake8_and_mypy_environments() -> None: + config: str = render_tox_config("sample.py") + + assert "env_list = flake8, mypy" in config + assert "python -m flake8 sample.py" in config + assert "python -m mypy sample.py" in config + assert "system_site_packages = true" in config + + +def test_generated_tox_config_passes_offline(tmp_path: Path) -> None: + source: Path = tmp_path / "sample.py" + source.write_text( + '"""Typed sample."""\n\nvalue: int = 3\n', + encoding="utf-8", + ) + config: Path = write_tox_config(tmp_path, "sample.py") + + completed: subprocess.CompletedProcess[str] = subprocess.run( + [ + sys.executable, + "-m", + "tox", + "run", + "--no-provision", + "-c", + str(config), + ], + cwd=tmp_path, + text=True, + capture_output=True, + check=False, + timeout=60, + ) + + assert completed.returncode == 0, completed.stdout + completed.stderr +``` + +- [ ] **Step 2: Run and confirm the missing implementation** + +Run: + +```bash +uv run pytest CH_10_testing_and_logging/exercise_04/test_solution_00.py -v +``` + +Expected: FAIL with `ModuleNotFoundError`. + +- [ ] **Step 3: Implement deterministic config generation** + +```python +# CH_10_testing_and_logging/exercise_04/solution_00.py +"""Generate a tox configuration that runs flake8 and mypy offline.""" + +from pathlib import Path, PurePath + + +def render_tox_config(target: str) -> str: + """Render tox 4 configuration for one relative Python path.""" + target_path: PurePath = PurePath(target) + if target_path.is_absolute() or ".." in target_path.parts: + raise ValueError("target must be a relative path inside the project") + if any(character.isspace() for character in target): + raise ValueError("target must not contain whitespace") + return f"""[tox] +env_list = flake8, mypy +skipsdist = true + +[testenv] +package = skip +system_site_packages = true + +[testenv:flake8] +commands = python -m flake8 {target} + +[testenv:mypy] +commands = python -m mypy {target} +""" + + +def write_tox_config(directory: Path, target: str) -> Path: + """Write the generated configuration and return its path.""" + directory.mkdir(parents=True, exist_ok=True) + config_path: Path = directory / "tox.ini" + config_path.write_text(render_tox_config(target), encoding="utf-8") + return config_path + + +if __name__ == "__main__": + print(render_tox_config("sample.py")) +``` + +The tox environments inherit only the already locked uv environment and +declare no tox dependencies, so the integration test cannot trigger package +downloads. + +- [ ] **Step 4: Verify success and representative failure** + +Append this complete regression test: + +```python +def test_generated_tox_config_reports_mypy_failure(tmp_path: Path) -> None: + source: Path = tmp_path / "sample.py" + source.write_text( + '"""Invalid typed sample."""\n\nvalue: str = 3\n', + encoding="utf-8", + ) + config: Path = write_tox_config(tmp_path, "sample.py") + + completed: subprocess.CompletedProcess[str] = subprocess.run( + [ + sys.executable, + "-m", + "tox", + "run", + "--no-provision", + "-c", + str(config), + "-e", + "mypy", + ], + cwd=tmp_path, + text=True, + capture_output=True, + check=False, + timeout=60, + ) + + output: str = completed.stdout + completed.stderr + assert completed.returncode != 0 + assert "assignment" in output +``` + +Then run: + +```bash +uv run pytest CH_10_testing_and_logging/exercise_04/test_solution_00.py -v +``` + +Expected: config test and valid integration pass; invalid source produces the +asserted nonzero tox result while the outer pytest test passes. + +- [ ] **Step 5: Document and commit** + +README: exact tox prompt; explain `flake8` is intentionally used even though +the repository uses Ruff; state the inherited locked-environment/offline +choice; list tox, flake8, mypy, pytest; show the focused test command; cite +`T_22_tox/tox.ini` and `T_19_pytest.ini` at the pinned upstream SHA. + +Run Ruff and all three type checkers, then: + +```bash +git add CH_10_testing_and_logging/exercise_04 +git commit -m "feat: add tox quality environment exercise" +``` + +### Task 8: Buffer task messages in a LoggerAdapter + +**Files:** + +- Create: `CH_10_testing_and_logging/exercise_05/__init__.py` +- Create: `CH_10_testing_and_logging/exercise_05/solution_00.py` +- Create: `CH_10_testing_and_logging/exercise_05/test_solution_00.py` +- Create: `CH_10_testing_and_logging/exercise_05/README.rst` +- Modify: `CH_10_testing_and_logging/README.rst` + +- [ ] **Step 1: Write isolation, ordering, and failure tests** + +```python +# CH_10_testing_and_logging/exercise_05/test_solution_00.py +import logging + +import pytest + +from CH_10_testing_and_logging.exercise_05.solution_00 import ( + TaskLoggerAdapter, +) + + +def test_flush_combines_only_one_task_in_order( + caplog: pytest.LogCaptureFixture, +) -> None: + logger: logging.Logger = logging.getLogger("task-buffer-test") + adapter: TaskLoggerAdapter = TaskLoggerAdapter(logger) + adapter.add("a", "first %s", "message") + adapter.add("b", "other") + adapter.add("a", "second") + + with caplog.at_level(logging.INFO, logger=logger.name): + combined: str = adapter.flush("a") + + assert combined == "first message\nsecond" + assert [record.getMessage() for record in caplog.records] == [combined] + assert getattr(caplog.records[0], "task_id") == "a" + assert adapter.pending("b") == 1 + + +def test_flush_unknown_task_raises_key_error() -> None: + adapter: TaskLoggerAdapter = TaskLoggerAdapter( + logging.getLogger("empty-task-buffer") + ) + + with pytest.raises(KeyError, match="missing"): + adapter.flush("missing") + + +def test_empty_task_id_is_rejected() -> None: + adapter: TaskLoggerAdapter = TaskLoggerAdapter( + logging.getLogger("invalid-task-buffer") + ) + + with pytest.raises(ValueError, match="task_id must not be empty"): + adapter.add("", "message") +``` + +- [ ] **Step 2: Run and observe the missing-module failure** + +Run: + +```bash +uv run pytest CH_10_testing_and_logging/exercise_05/test_solution_00.py -v +``` + +Expected: FAIL with `ModuleNotFoundError`. + +- [ ] **Step 3: Implement the thread-safe adapter** + +```python +# CH_10_testing_and_logging/exercise_05/solution_00.py +"""Combine buffered messages into one log record per task identifier.""" + +import logging +import threading +from collections.abc import Mapping + + +class TaskLoggerAdapter(logging.LoggerAdapter): + """Buffer ordered messages separately for each task.""" + + def __init__( + self, + logger: logging.Logger, + extra: Mapping[str, object] | None = None, + ) -> None: + super().__init__(logger, dict(extra or {})) + self._messages: dict[str, list[str]] = {} + self._lock: threading.Lock = threading.Lock() + + def add( + self, + task_id: str, + message: str, + *args: object, + ) -> None: + """Format and append one message without emitting a record.""" + if not task_id: + raise ValueError("task_id must not be empty") + formatted: str = message % args if args else message + with self._lock: + self._messages.setdefault(task_id, []).append(formatted) + + def pending(self, task_id: str) -> int: + """Return the number of buffered messages for a task.""" + with self._lock: + return len(self._messages.get(task_id, ())) + + def flush( + self, + task_id: str, + *, + level: int = logging.INFO, + ) -> str: + """Emit and return one newline-joined record for a task.""" + with self._lock: + try: + messages: list[str] = self._messages.pop(task_id) + except KeyError: + raise KeyError(task_id) from None + combined: str = "\n".join(messages) + record_extra: dict[str, object] = dict(self.extra) + record_extra["task_id"] = task_id + self.logger.log(level, combined, extra=record_extra) + return combined + + +def main() -> None: + """Demonstrate buffering two messages into one record.""" + logging.basicConfig(level=logging.INFO) + adapter: TaskLoggerAdapter = TaskLoggerAdapter( + logging.getLogger(__name__) + ) + adapter.add("demo", "first") + adapter.add("demo", "second") + adapter.flush("demo") + + +if __name__ == "__main__": + main() +``` + +- [ ] **Step 4: Run tests and packet quality checks** + +Run: + +```bash +uv run pytest CH_10_testing_and_logging/exercise_05/test_solution_00.py -v +uv run ruff format --check CH_10_testing_and_logging +uv run ruff check CH_10_testing_and_logging +uv run pyrefly check CH_10_testing_and_logging +uv run mypy CH_10_testing_and_logging +uv run pyright CH_10_testing_and_logging +``` + +Expected: three tests pass and all quality commands exit zero. + +- [ ] **Step 5: Document, index, and commit** + +README: exact LoggerAdapter prompt; ordered per-task buffering, explicit flush, +unknown-task/empty-ID behavior, logging/pytest dependencies, module/test +commands, and pinned `T_33_logging_format.py`. + +Replace `CH_10_testing_and_logging/README.rst` with: + +```rst +Chapter 10 - testing and logging +================================ + +1. `Object doctests `_ +2. `Recursive module doctests `_ +3. `File-documentation pytest plugin `_ +4. `Tox flake8 and mypy environments `_ +5. `Task-aware LoggerAdapter `_ +``` + +Commit: + +```bash +git add CH_10_testing_and_logging/exercise_05 CH_10_testing_and_logging/README.rst +git commit -m "feat: add task-aware logging adapter exercise" +``` + +### Task 9: Packet acceptance check + +**Files:** + +- Review: `CH_09_documentation/` +- Review: `CH_10_testing_and_logging/` + +- [ ] **Step 1: Verify directory contracts and placeholders** + +Run: + +```bash +find CH_09_documentation CH_10_testing_and_logging -maxdepth 2 -type f | sort +``` + +Expected: every exercise has the four required files; placeholder search has no +matches. + +- [ ] **Step 2: Run the complete packet** + +Run: + +```bash +uv run pytest CH_09_documentation CH_10_testing_and_logging -v +uv run ruff format --check CH_09_documentation CH_10_testing_and_logging +uv run ruff check CH_09_documentation CH_10_testing_and_logging +uv run pyrefly check CH_09_documentation CH_10_testing_and_logging +uv run mypy CH_09_documentation CH_10_testing_and_logging +uv run pyright CH_09_documentation CH_10_testing_and_logging +``` + +Expected: all tests pass, including nested pytest and tox integration; all +static checks exit zero; no network access occurs. + +- [ ] **Step 3: Confirm ownership isolation** + +Run: + +```bash +git status --short +git diff --stat master -- CH_09_documentation CH_10_testing_and_logging +``` + +Expected: no uncommitted packet changes and no historical alternative or +outside-packet file was changed. diff --git a/docs/superpowers/plans/2026-07-28-ch11-ch12-debugging-performance.md b/docs/superpowers/plans/2026-07-28-ch11-ch12-debugging-performance.md new file mode 100644 index 0000000..0002fac --- /dev/null +++ b/docs/superpowers/plans/2026-07-28-ch11-ch12-debugging-performance.md @@ -0,0 +1,1022 @@ +# Chapters 11-12 Debugging and Performance Implementation + +**For agentic workers:** REQUIRED SUB-SKILL: Use +`superpowers:subagent-driven-development` (recommended) or +`superpowers:executing-plans` to implement this plan task-by-task. + +**Goal:** Deliver six deterministic, typed debugging/performance utilities with +focused tests that do not depend on machine speed or process RSS. + +**Architecture:** Chapter 11 wraps standard-library diagnostics in reusable +APIs: delayed faulthandler dumps, injected-clock timing, and line-level trace +counts. Chapter 12 uses stateful callable objects for memory/runtime monitoring +and a compositional weak-reference instance manager. Clocks and memory samplers +are injected in unit tests; one bounded subprocess verifies the real +faulthandler path. + +**Tech Stack:** Python 3.10 standard library, pytest, Ruff, Pyrefly, mypy, +Pyright + +--- + +## File map and public APIs + +- CH11 Exercise 1: `run_with_timeout_diagnostics` +- CH11 Exercise 2: `TimedResult`, `CallTimer`, `measure_call` +- CH11 Exercise 3: `SourceLocation`, `ExecutionCounts`, `count_executions` +- CH12 Exercise 1: `MemoryGrowthMonitor`, `monitor_memory_growth` +- CH12 Exercise 2: `RuntimeStats`, `RuntimeMonitor`, `monitor_runtime` +- CH12 Exercise 3: `InstanceManager` +- Every exercise gains the standard `__init__.py`, `README.rst`, + `solution_00.py`, and `test_solution_00.py` contract. + +### Task 1: Dump a stalled stack after a timeout + +**Files:** + +- Create: `CH_11_debugging/exercise_01/__init__.py` +- Create: `CH_11_debugging/exercise_01/solution_00.py` +- Create: `CH_11_debugging/exercise_01/test_solution_00.py` +- Create: `CH_11_debugging/exercise_01/README.rst` + +- [ ] **Step 1: Write cancellation, validation, and real subprocess tests** + +```python +# CH_11_debugging/exercise_01/test_solution_00.py +import faulthandler +import subprocess +import sys + +import pytest + +from CH_11_debugging.exercise_01.solution_00 import ( + run_with_timeout_diagnostics, +) + + +def test_diagnostic_is_cancelled_when_function_raises( + monkeypatch: pytest.MonkeyPatch, +) -> None: + events: list[str] = [] + + def schedule( + timeout: float, + repeat: bool = False, + file: object | None = None, + exit: bool = False, + ) -> None: + del timeout, repeat, file, exit + events.append("scheduled") + + def cancel() -> None: + events.append("cancelled") + + monkeypatch.setattr(faulthandler, "dump_traceback_later", schedule) + monkeypatch.setattr(faulthandler, "cancel_dump_traceback_later", cancel) + + def fail() -> None: + raise LookupError("boom") + + with pytest.raises(LookupError, match="boom"): + run_with_timeout_diagnostics(fail, 0.1) + + assert events == ["scheduled", "cancelled"] + + +def test_timeout_must_be_positive() -> None: + with pytest.raises(ValueError, match="timeout must be positive"): + run_with_timeout_diagnostics(lambda: None, 0.0) + + +def test_real_timeout_prints_stalled_function() -> None: + source: str = """ +import time +from CH_11_debugging.exercise_01.solution_00 import ( + run_with_timeout_diagnostics, +) + +def stalled() -> None: + time.sleep(0.10) + +run_with_timeout_diagnostics(stalled, 0.01) +""" + completed: subprocess.CompletedProcess[str] = subprocess.run( + [sys.executable, "-c", source], + text=True, + capture_output=True, + check=False, + timeout=2, + ) + + assert completed.returncode == 0 + assert "Timeout" in completed.stderr + assert "stalled" in completed.stderr +``` + +- [ ] **Step 2: Run the missing-module test** + +Run: + +```bash +uv run pytest CH_11_debugging/exercise_01/test_solution_00.py -v +``` + +Expected: FAIL during collection with `ModuleNotFoundError`. + +- [ ] **Step 3: Implement the generic diagnostic wrapper** + +```python +# CH_11_debugging/exercise_01/solution_00.py +"""Schedule a stack dump when synchronous code appears stalled.""" + +import faulthandler +from collections.abc import Callable +from typing import ParamSpec, TypeVar + +Parameters = ParamSpec("Parameters") +Result = TypeVar("Result") + + +def run_with_timeout_diagnostics( + function: Callable[Parameters, Result], + timeout: float, + *args: Parameters.args, + **kwargs: Parameters.kwargs, +) -> Result: + """Run a callable and dump all thread stacks if it exceeds timeout.""" + if timeout <= 0: + raise ValueError("timeout must be positive") + faulthandler.dump_traceback_later(timeout, repeat=False) + try: + return function(*args, **kwargs) + finally: + faulthandler.cancel_dump_traceback_later() + + +def main() -> None: + """Demonstrate a fast call that cancels its scheduled dump.""" + value: str = run_with_timeout_diagnostics(lambda: "finished", 1.0) + print(value) + + +if __name__ == "__main__": + main() +``` + +The function diagnoses but deliberately does not terminate the callable. +Aborting arbitrary threads would be unsafe; the README must make this boundary +explicit. + +- [ ] **Step 4: Verify, document, and commit** + +Run: + +```bash +uv run pytest CH_11_debugging/exercise_01/test_solution_00.py -v +uv run ruff check CH_11_debugging/exercise_01 +uv run pyrefly check CH_11_debugging/exercise_01 +uv run mypy CH_11_debugging/exercise_01 +uv run pyright CH_11_debugging/exercise_01 +``` + +Expected: three tests pass and all checks exit zero. + +README: repeat the timeout/stall question; explain diagnostic-only semantics, +positive timeout, exception propagation, stdlib/pytest dependencies, module +and test commands, and pinned upstream `T_06_stack.py` plus +`T_07_faulthandler.py`. Commit: + +```bash +git add CH_11_debugging/exercise_01 +git commit -m "feat: add stalled-call diagnostics exercise" +``` + +### Task 2: Measure one call with an injectable monotonic clock + +**Files:** + +- Create: `CH_11_debugging/exercise_02/__init__.py` +- Create: `CH_11_debugging/exercise_02/solution_00.py` +- Create: `CH_11_debugging/exercise_02/test_solution_00.py` +- Create: `CH_11_debugging/exercise_02/README.rst` + +- [ ] **Step 1: Write deterministic timing and propagation tests** + +```python +# CH_11_debugging/exercise_02/test_solution_00.py +from collections.abc import Callable, Iterator + +import pytest + +from CH_11_debugging.exercise_02.solution_00 import CallTimer, TimedResult + + +def clock_from(values: list[float]) -> Callable[[], float]: + iterator: Iterator[float] = iter(values) + return iterator.__next__ + + +def test_measure_returns_value_and_elapsed_seconds() -> None: + timer: CallTimer = CallTimer(clock_from([10.0, 10.25])) + + result: TimedResult[int] = timer.measure(lambda value: value + 1, 4) + + assert result == TimedResult(value=5, seconds=0.25) + + +def test_measure_propagates_function_failure() -> None: + timer: CallTimer = CallTimer(clock_from([1.0])) + + def fail() -> None: + raise RuntimeError("failed") + + with pytest.raises(RuntimeError, match="failed"): + timer.measure(fail) + + +def test_measure_rejects_clock_moving_backwards() -> None: + timer: CallTimer = CallTimer(clock_from([5.0, 4.0])) + + with pytest.raises(ValueError, match="clock moved backwards"): + timer.measure(lambda: None) +``` + +- [ ] **Step 2: Confirm the missing implementation fails** + +Run: + +```bash +uv run pytest CH_11_debugging/exercise_02/test_solution_00.py -v +``` + +Expected: FAIL with `ModuleNotFoundError`. + +- [ ] **Step 3: Implement the result and timer types** + +```python +# CH_11_debugging/exercise_02/solution_00.py +"""Measure callable duration without coupling tests to wall-clock speed.""" + +import time +from collections.abc import Callable +from dataclasses import dataclass +from typing import Generic, ParamSpec, TypeVar + +Parameters = ParamSpec("Parameters") +Result = TypeVar("Result") + + +@dataclass(frozen=True) +class TimedResult(Generic[Result]): + """A callable's return value and elapsed monotonic seconds.""" + + value: Result + seconds: float + + +class CallTimer: + """Measure calls with an injectable monotonic clock.""" + + def __init__( + self, + clock: Callable[[], float] = time.perf_counter, + ) -> None: + self._clock: Callable[[], float] = clock + + def measure( + self, + function: Callable[Parameters, Result], + *args: Parameters.args, + **kwargs: Parameters.kwargs, + ) -> TimedResult[Result]: + """Run a callable once and return its value and duration.""" + started: float = self._clock() + value: Result = function(*args, **kwargs) + finished: float = self._clock() + seconds: float = finished - started + if seconds < 0: + raise ValueError("clock moved backwards") + return TimedResult(value=value, seconds=seconds) + + +def measure_call( + function: Callable[Parameters, Result], + *args: Parameters.args, + **kwargs: Parameters.kwargs, +) -> TimedResult[Result]: + """Measure a callable with `time.perf_counter`.""" + return CallTimer().measure(function, *args, **kwargs) + + +if __name__ == "__main__": + print(measure_call(sum, [1, 2, 3])) +``` + +- [ ] **Step 4: Verify, document, and commit** + +Run: + +```bash +uv run pytest CH_11_debugging/exercise_02/test_solution_00.py -v +uv run ruff check CH_11_debugging/exercise_02 +uv run pyrefly check CH_11_debugging/exercise_02 +uv run mypy CH_11_debugging/exercise_02 +uv run pyright CH_11_debugging/exercise_02 +``` + +Expected: three tests pass with zero findings. + +README: exact duration prompt, monotonic/injected clock decision, failed-call +behavior, dependencies and run commands, pinned upstream +`CH_11_debugging/T_02_selective_trace.py`. Commit: + +```bash +git add CH_11_debugging/exercise_02 +git commit -m "feat: add execution duration exercise" +``` + +### Task 3: Count executed source lines + +**Files:** + +- Create: `CH_11_debugging/exercise_03/__init__.py` +- Create: `CH_11_debugging/exercise_03/solution_00.py` +- Create: `CH_11_debugging/exercise_03/test_solution_00.py` +- Create: `CH_11_debugging/exercise_03/README.rst` +- Modify: `CH_11_debugging/README.rst` + +- [ ] **Step 1: Write a line-frequency test that rejects call-only counters** + +```python +# CH_11_debugging/exercise_03/test_solution_00.py +import inspect + +import pytest + +from CH_11_debugging.exercise_03.solution_00 import ( + ExecutionCounts, + count_executions, +) + + +def repeated(total: int) -> int: + value: int = 0 + for number in range(total): + value += number + return value + + +def test_count_executions_reports_loop_line_frequency() -> None: + source_lines, first_line = inspect.getsourcelines(repeated) + offset: int = next( + index + for index, line in enumerate(source_lines) + if "value += number" in line + ) + loop_line: int = first_line + offset + + profile: ExecutionCounts[int] = count_executions(repeated, 4) + + assert profile.result == 6 + assert profile.counts[loop_line] == 4 + + +def test_count_executions_propagates_failure() -> None: + def fail() -> None: + raise LookupError("boom") + + with pytest.raises(LookupError, match="boom"): + count_executions(fail) +``` + +- [ ] **Step 2: Run and confirm the module is absent** + +Run: + +```bash +uv run pytest CH_11_debugging/exercise_03/test_solution_00.py -v +``` + +Expected: FAIL with `ModuleNotFoundError`. + +- [ ] **Step 3: Implement source-file-filtered trace counts** + +```python +# CH_11_debugging/exercise_03/solution_00.py +"""Count how often each source line in a callable's file executes.""" + +import inspect +import trace +from collections.abc import Callable +from dataclasses import dataclass +from typing import Generic, ParamSpec, TypeVar, cast + +Parameters = ParamSpec("Parameters") +Result = TypeVar("Result") +SourceLocation = tuple[str, int] + + +@dataclass(frozen=True) +class ExecutionCounts(Generic[Result]): + """A return value and line-number-to-count mapping.""" + + result: Result + counts: dict[int, int] + + +def count_executions( + function: Callable[Parameters, Result], + *args: Parameters.args, + **kwargs: Parameters.kwargs, +) -> ExecutionCounts[Result]: + """Run a callable and count lines from its source file.""" + filename: str | None = inspect.getsourcefile(function) + if filename is None: + raise ValueError("function has no inspectable source file") + tracer: trace.Trace = trace.Trace(count=True, trace=False) + result: Result = tracer.runfunc(function, *args, **kwargs) + raw: dict[SourceLocation, int] = cast( + dict[SourceLocation, int], + tracer.results().counts, + ) + counts: dict[int, int] = { + line_number: count + for (source_file, line_number), count in raw.items() + if source_file == filename + } + return ExecutionCounts(result=result, counts=counts) +``` + +- [ ] **Step 4: Verify, document, index, and commit** + +Run: + +```bash +uv run pytest CH_11_debugging/exercise_03/test_solution_00.py -v +uv run ruff check CH_11_debugging/exercise_03 +uv run pyrefly check CH_11_debugging/exercise_03 +uv run mypy CH_11_debugging/exercise_03 +uv run pyright CH_11_debugging/exercise_03 +``` + +Expected: the loop line count is four and every command exits zero. + +README: exact execution-count prompt, source-file filtering, exception +propagation, stdlib/pytest dependencies, commands, and pinned upstream +`T_02_selective_trace.py` plus `T_03_filename_trace.py`. + +Replace `CH_11_debugging/README.rst` with: + +```rst +Chapter 11 - debugging +====================== + +1. `Stalled-call diagnostics `_ +2. `Execution duration `_ +3. `Line execution counts `_ +``` + +Commit: + +```bash +git add CH_11_debugging/exercise_03 CH_11_debugging/README.rst +git commit -m "feat: add line execution count exercise" +``` + +### Task 4: Warn when sampled memory grows + +**Files:** + +- Create: `CH_12_performance/exercise_01/__init__.py` +- Create: `CH_12_performance/exercise_01/solution_00.py` +- Create: `CH_12_performance/exercise_01/test_solution_00.py` +- Create: `CH_12_performance/exercise_01/README.rst` + +- [ ] **Step 1: Write deterministic sampler tests** + +```python +# CH_12_performance/exercise_01/test_solution_00.py +from collections.abc import Callable, Iterator + +import pytest + +from CH_12_performance.exercise_01.solution_00 import monitor_memory_growth + + +def sampler(values: list[int]) -> Callable[[], int]: + iterator: Iterator[int] = iter(values) + return iterator.__next__ + + +def test_warns_only_after_growth_above_tolerance() -> None: + @monitor_memory_growth( + sampler=sampler([100, 104, 120]), + tolerance_bytes=5, + ) + def value() -> int: + return 7 + + assert value() == 7 + assert value() == 7 + with pytest.warns(ResourceWarning, match="grew by 16 bytes"): + assert value() == 7 + + +def test_failed_call_does_not_consume_a_sample() -> None: + samples: Callable[[], int] = sampler([10]) + + @monitor_memory_growth(sampler=samples) + def fail() -> None: + raise RuntimeError("boom") + + with pytest.raises(RuntimeError, match="boom"): + fail() +``` + +- [ ] **Step 2: Run and observe missing module** + +Run: + +```bash +uv run pytest CH_12_performance/exercise_01/test_solution_00.py -v +``` + +Expected: `ModuleNotFoundError`. + +- [ ] **Step 3: Implement the stateful decorator** + +```python +# CH_12_performance/exercise_01/solution_00.py +"""Warn when a successful call's sampled memory grows.""" + +import tracemalloc +import warnings +from collections.abc import Callable +from typing import Generic, ParamSpec, TypeVar + +Parameters = ParamSpec("Parameters") +Result = TypeVar("Result") + + +def _traced_bytes() -> int: + if not tracemalloc.is_tracing(): + tracemalloc.start() + current: int + current, _ = tracemalloc.get_traced_memory() + return current + + +class MemoryGrowthMonitor(Generic[Parameters, Result]): + """Callable wrapper retaining the previous successful sample.""" + + def __init__( + self, + function: Callable[Parameters, Result], + sampler: Callable[[], int], + tolerance_bytes: int, + ) -> None: + self._function: Callable[Parameters, Result] = function + self._sampler: Callable[[], int] = sampler + self._tolerance_bytes: int = tolerance_bytes + self._previous: int | None = None + + def __call__( + self, + *args: Parameters.args, + **kwargs: Parameters.kwargs, + ) -> Result: + value: Result = self._function(*args, **kwargs) + current: int = self._sampler() + if self._previous is not None: + growth: int = current - self._previous + if growth > self._tolerance_bytes: + warnings.warn( + f"sampled memory grew by {growth} bytes", + ResourceWarning, + stacklevel=2, + ) + self._previous = current + return value + + +def monitor_memory_growth( + *, + sampler: Callable[[], int] = _traced_bytes, + tolerance_bytes: int = 0, +) -> Callable[ + [Callable[Parameters, Result]], + MemoryGrowthMonitor[Parameters, Result], +]: + """Create a monitor decorator with an injectable sampler.""" + if tolerance_bytes < 0: + raise ValueError("tolerance_bytes must not be negative") + + def decorate( + function: Callable[Parameters, Result], + ) -> MemoryGrowthMonitor[Parameters, Result]: + return MemoryGrowthMonitor(function, sampler, tolerance_bytes) + + return decorate +``` + +- [ ] **Step 4: Verify, document, and commit** + +Run: + +```bash +uv run pytest CH_12_performance/exercise_01/test_solution_00.py -v +uv run ruff check CH_12_performance/exercise_01 +uv run pyrefly check CH_12_performance/exercise_01 +uv run mypy CH_12_performance/exercise_01 +uv run pyright CH_12_performance/exercise_01 +``` + +Expected: both tests pass without real-memory assumptions. + +README: exact memory decorator prompt, successful-call sampling, tolerance and +tracemalloc default, dependencies/commands, pinned upstream +`T_11_tracemalloc.py`, `T_12_memory_profiler.py`, and +`T_13_memory_leaks.py`. Commit: + +```bash +git add CH_12_performance/exercise_01 +git commit -m "feat: add memory growth monitor exercise" +``` + +### Task 5: Warn on runtime deviation and expose a running average + +**Files:** + +- Create: `CH_12_performance/exercise_02/__init__.py` +- Create: `CH_12_performance/exercise_02/solution_00.py` +- Create: `CH_12_performance/exercise_02/test_solution_00.py` +- Create: `CH_12_performance/exercise_02/README.rst` + +- [ ] **Step 1: Write injected-clock deviation tests** + +```python +# CH_12_performance/exercise_02/test_solution_00.py +from collections.abc import Callable, Iterator + +import pytest + +from CH_12_performance.exercise_02.solution_00 import monitor_runtime + + +def clock(values: list[float]) -> Callable[[], float]: + iterator: Iterator[float] = iter(values) + return iterator.__next__ + + +def test_warns_against_previous_running_average() -> None: + @monitor_runtime(clock=clock([0.0, 1.0, 2.0, 4.0]), max_deviation=0.25) + def operation() -> str: + return "ok" + + assert operation() == "ok" + with pytest.warns(RuntimeWarning, match="deviated"): + assert operation() == "ok" + + assert operation.stats.calls == 2 + assert operation.stats.average_seconds == pytest.approx(1.5) + + +def test_invalid_deviation_is_rejected() -> None: + with pytest.raises(ValueError, match="max_deviation must not be negative"): + monitor_runtime(max_deviation=-0.1) +``` + +- [ ] **Step 2: Run and confirm failure** + +Run: + +```bash +uv run pytest CH_12_performance/exercise_02/test_solution_00.py -v +``` + +Expected: `ModuleNotFoundError`. + +- [ ] **Step 3: Implement the running statistics monitor** + +```python +# CH_12_performance/exercise_02/solution_00.py +"""Warn when call duration differs substantially from prior runs.""" + +import time +import warnings +from collections.abc import Callable +from dataclasses import dataclass +from typing import Generic, ParamSpec, TypeVar + +Parameters = ParamSpec("Parameters") +Result = TypeVar("Result") + + +@dataclass(frozen=True) +class RuntimeStats: + """Successful-call runtime statistics.""" + + calls: int + total_seconds: float + + @property + def average_seconds(self) -> float: + return self.total_seconds / self.calls if self.calls else 0.0 + + +class RuntimeMonitor(Generic[Parameters, Result]): + """Callable wrapper comparing each duration to the prior average.""" + + def __init__( + self, + function: Callable[Parameters, Result], + clock: Callable[[], float], + max_deviation: float, + ) -> None: + self._function: Callable[Parameters, Result] = function + self._clock: Callable[[], float] = clock + self._max_deviation: float = max_deviation + self.stats: RuntimeStats = RuntimeStats(0, 0.0) + + def __call__( + self, + *args: Parameters.args, + **kwargs: Parameters.kwargs, + ) -> Result: + started: float = self._clock() + value: Result = self._function(*args, **kwargs) + duration: float = self._clock() - started + if duration < 0: + raise ValueError("clock moved backwards") + previous_average: float = self.stats.average_seconds + if ( + self.stats.calls > 0 + and previous_average > 0 + and abs(duration - previous_average) + > previous_average * self._max_deviation + ): + warnings.warn( + "runtime deviated from the previous average", + RuntimeWarning, + stacklevel=2, + ) + self.stats = RuntimeStats( + self.stats.calls + 1, + self.stats.total_seconds + duration, + ) + return value + + +def monitor_runtime( + *, + clock: Callable[[], float] = time.perf_counter, + max_deviation: float = 0.25, +) -> Callable[ + [Callable[Parameters, Result]], + RuntimeMonitor[Parameters, Result], +]: + """Create a runtime monitor decorator.""" + if max_deviation < 0: + raise ValueError("max_deviation must not be negative") + + def decorate( + function: Callable[Parameters, Result], + ) -> RuntimeMonitor[Parameters, Result]: + return RuntimeMonitor(function, clock, max_deviation) + + return decorate +``` + +- [ ] **Step 4: Verify, document, and commit** + +Run: + +```bash +uv run pytest CH_12_performance/exercise_02/test_solution_00.py -v +uv run ruff check CH_12_performance/exercise_02 +uv run pyrefly check CH_12_performance/exercise_02 +uv run mypy CH_12_performance/exercise_02 +uv run pyright CH_12_performance/exercise_02 +``` + +Expected: two tests pass and all checks exit zero. +README: exact prompt, prior-average and successful-call semantics, injected +clock, dependencies/commands, pinned upstream `T_00_timeit.py` and +`T_06_selective_profiling.py`. Commit: + +```bash +git add CH_12_performance/exercise_02 +git commit -m "feat: add runtime deviation monitor exercise" +``` + +### Task 6: Track live instances without keeping them alive + +**Files:** + +- Create: `CH_12_performance/exercise_03/__init__.py` +- Create: `CH_12_performance/exercise_03/solution_00.py` +- Create: `CH_12_performance/exercise_03/test_solution_00.py` +- Create: `CH_12_performance/exercise_03/README.rst` +- Modify: `CH_12_performance/README.rst` + +- [ ] **Step 1: Write live-count, warning, and collection tests** + +```python +# CH_12_performance/exercise_03/test_solution_00.py +import gc + +import pytest + +from CH_12_performance.exercise_03.solution_00 import InstanceManager + + +class Resource: + def __init__(self, name: str) -> None: + self.name: str = name + + +def test_warns_above_limit_and_releases_collected_instances() -> None: + manager: InstanceManager[Resource] = InstanceManager( + Resource, + max_instances=1, + ) + first: Resource = manager.create("first") + with pytest.warns(ResourceWarning, match="2 live Resource instances"): + second: Resource = manager.create("second") + + assert manager.live_count == 2 + del first + gc.collect() + assert manager.live_count == 1 + assert second.name == "second" + + +def test_invalid_limit_is_rejected() -> None: + with pytest.raises(ValueError, match="max_instances must be positive"): + InstanceManager(Resource, max_instances=0) + + +def test_non_weak_referenceable_instance_is_rejected() -> None: + class Slotted: + __slots__: tuple[str, ...] = () + + manager: InstanceManager[Slotted] = InstanceManager( + Slotted, + max_instances=1, + ) + + with pytest.raises(TypeError, match="instances must support weak references"): + manager.create() +``` + +- [ ] **Step 2: Run and confirm failure** + +Run: + +```bash +uv run pytest CH_12_performance/exercise_03/test_solution_00.py -v +``` + +Expected: `ModuleNotFoundError`. + +- [ ] **Step 3: Implement a compositional weak-reference manager** + +```python +# CH_12_performance/exercise_03/solution_00.py +"""Track live instances created for one class without retaining them.""" + +import warnings +import weakref +from typing import Generic, TypeVar + +Instance = TypeVar("Instance") + + +class InstanceManager(Generic[Instance]): + """Construct and weakly track instances of one class.""" + + def __init__( + self, + instance_type: type[Instance], + *, + max_instances: int, + ) -> None: + if max_instances <= 0: + raise ValueError("max_instances must be positive") + self._instance_type: type[Instance] = instance_type + self._max_instances: int = max_instances + self._references: list[weakref.ReferenceType[Instance]] = [] + + def _discard_dead(self) -> None: + self._references = [ + reference + for reference in self._references + if reference() is not None + ] + + @property + def live_count(self) -> int: + """Return the number of tracked instances still alive.""" + self._discard_dead() + return len(self._references) + + def create(self, *args: object, **kwargs: object) -> Instance: + """Construct, track, and return an instance.""" + instance: Instance = self._instance_type(*args, **kwargs) + try: + reference: weakref.ReferenceType[Instance] = weakref.ref(instance) + except TypeError: + raise TypeError( + "instances must support weak references" + ) from None + self._discard_dead() + self._references.append(reference) + count: int = len(self._references) + if count > self._max_instances: + warnings.warn( + f"{count} live {self._instance_type.__name__} instances", + ResourceWarning, + stacklevel=2, + ) + return instance +``` + +- [ ] **Step 4: Verify, document, index, and commit** + +Run: + +```bash +uv run pytest CH_11_debugging CH_12_performance -v +uv run ruff format --check CH_11_debugging CH_12_performance +uv run ruff check CH_11_debugging CH_12_performance +uv run pyrefly check CH_11_debugging CH_12_performance +uv run mypy CH_11_debugging CH_12_performance +uv run pyright CH_11_debugging CH_12_performance +``` + +Expected: all six exercise suites pass with no timing/RSS flakes and all +quality commands exit zero. + +README: exact instance-manager prompt; manager-created-instance scope, weak +reference collection, positive limit and non-weakref error; dependencies and +commands; pinned upstream `T_13_memory_leaks.py`, +`T_15_garbage_collector_viewing.py`, and `T_16_weak_references.py`. + +Replace `CH_12_performance/README.rst` with: + +```rst +Chapter 12 - performance +======================== + +1. `Memory-growth monitor `_ +2. `Runtime-deviation monitor `_ +3. `Live-instance manager `_ +``` + +Commit: + +```bash +git add CH_12_performance/exercise_03 CH_12_performance/README.rst +git commit -m "feat: add live instance manager exercise" +``` + +### Task 7: Packet self-review + +**Files:** + +- Review: `CH_11_debugging/` +- Review: `CH_12_performance/` + +- [ ] **Step 1: Verify directory contracts and placeholder absence** + +Run: + +```bash +find CH_11_debugging CH_12_performance -maxdepth 2 -type f | sort +``` + +Expected: each exercise contains all four contract files and placeholder scan +returns no matches. + +- [ ] **Step 2: Verify failure propagation explicitly** + +Run: + +```bash +uv run pytest CH_11_debugging CH_12_performance -v -k "failure or raises or rejects" +``` + +Expected: all selected failure-path tests pass; no worker, sampler, clock, or +callable exception is swallowed. + +- [ ] **Step 3: Confirm ownership isolation** + +Run: + +```bash +git status --short +git diff --stat master -- CH_11_debugging CH_12_performance +``` + +Expected: no uncommitted packet changes and no files outside the owned packet +or plan were modified. diff --git a/docs/superpowers/plans/2026-07-28-ch13-asyncio.md b/docs/superpowers/plans/2026-07-28-ch13-asyncio.md new file mode 100644 index 0000000..d69b170 --- /dev/null +++ b/docs/superpowers/plans/2026-07-28-ch13-asyncio.md @@ -0,0 +1,769 @@ +# Chapter 13 Asyncio Solutions Implementation + +**For agentic workers:** REQUIRED SUB-SKILL: Use +`superpowers:subagent-driven-development` (recommended) or +`superpowers:executing-plans` to implement this plan task-by-task. + +**Goal:** Upgrade both Chapter 13 canonical answers into deterministic, +failure-propagating async resource and executor APIs with complete offline +tests. + +**Architecture:** Exercise 1 uses an abstract template method, explicit +registration, idempotent close, LIFO bulk shutdown, and an async context +manager; it never starts an event loop from `__del__`. Exercise 2 provides a +generic injected `Executor` adapter plus an `AsyncioFile` convenience wrapper. +Thread, process, file, and loopback-socket paths are tested with bounded waits. + +**Tech Stack:** Python 3.10 asyncio, concurrent.futures, pathlib, sockets, +pytest, Ruff, Pyrefly, mypy, Pyright + +--- + +## File map and public APIs + +- `CH_13_async_io/exercise_01/solution_00.py` + - `CloseFailures` + - `AsyncBase.close`, `AsyncBase.close_all`, `AsyncBase.pending_count` + - subclass hook `AsyncBase._close` + - `AsyncManager` +- `CH_13_async_io/exercise_02/solution_00.py` + - `AsyncExecutor.run`, `AsyncExecutor.close` + - `AsyncioFile` +- Preserve `CH_13_async_io/exercise_01/solution_01.py` unchanged. +- The coordinator owns root dependencies and quality configuration. + +### Task 1: Register and close async resources safely + +**Files:** + +- Modify: `CH_13_async_io/exercise_01/solution_00.py` +- Create: `CH_13_async_io/exercise_01/test_solution_00.py` +- Create: `CH_13_async_io/exercise_01/README.rst` +- Modify: `CH_13_async_io/README.rst` + +- [ ] **Step 1: Write registration, ordering, context, and failure tests** + +Create `CH_13_async_io/exercise_01/test_solution_00.py`: + +```python +import asyncio + +import pytest + +from CH_13_async_io.exercise_01.solution_00 import ( + AsyncBase, + AsyncManager, + CloseFailures, +) + + +class RecordingResource(AsyncBase): + def __init__( + self, + name: str, + events: list[str], + *, + fail: bool = False, + ) -> None: + self.name: str = name + self.events: list[str] = events + self.fail: bool = fail + super().__init__() + + async def _close(self) -> None: + self.events.append(self.name) + if self.fail: + raise RuntimeError(f"cannot close {self.name}") + + +def test_close_all_uses_lifo_order_and_is_idempotent() -> None: + async def scenario() -> None: + events: list[str] = [] + first: RecordingResource = RecordingResource("first", events) + RecordingResource("second", events) + + assert AsyncBase.pending_count() == 2 + await AsyncBase.close_all() + await first.close() + + assert events == ["second", "first"] + assert AsyncBase.pending_count() == 0 + + asyncio.run(scenario()) + + +def test_async_manager_closes_resources_on_body_failure() -> None: + async def scenario() -> None: + events: list[str] = [] + + with pytest.raises(LookupError, match="body failed"): + async with AsyncManager(): + RecordingResource("managed", events) + raise LookupError("body failed") + + assert events == ["managed"] + assert AsyncBase.pending_count() == 0 + + asyncio.run(scenario()) + + +def test_close_all_attempts_every_resource_and_reports_failures() -> None: + async def scenario() -> None: + events: list[str] = [] + failing: RecordingResource = RecordingResource( + "failing", + events, + fail=True, + ) + RecordingResource("successful", events) + + with pytest.raises(CloseFailures) as captured: + await AsyncBase.close_all() + + assert events == ["successful", "failing"] + assert len(captured.value.failures) == 1 + assert AsyncBase.pending_count() == 1 + + failing.fail = False + await AsyncBase.close_all() + assert AsyncBase.pending_count() == 0 + + asyncio.run(scenario()) +``` + +- [ ] **Step 2: Run the tests against the old global-list implementation** + +Run: + +```bash +uv run pytest CH_13_async_io/exercise_01/test_solution_00.py -v +``` + +Expected: FAIL because `_close`, `close_all`, `pending_count`, `CloseFailures`, +idempotency, and failure aggregation do not exist. + +- [ ] **Step 3: Implement explicit resource lifecycle management** + +Replace `CH_13_async_io/exercise_01/solution_00.py` with: + +```python +"""Register async resources and close them deterministically.""" + +import abc +from types import TracebackType +from typing import ClassVar + + +class CloseFailures(RuntimeError): + """Report every ordinary exception raised during bulk close.""" + + def __init__(self, failures: tuple[Exception, ...]) -> None: + self.failures: tuple[Exception, ...] = failures + super().__init__(f"{len(failures)} resource(s) failed to close") + + +class AsyncBase(abc.ABC): + """Base class that registers each live async resource.""" + + _instances: ClassVar[list["AsyncBase"]] = [] + + def __init__(self) -> None: + self._closed: bool = False + self._instances.append(self) + + @abc.abstractmethod + async def _close(self) -> None: + """Release subclass-specific resources.""" + + async def close(self) -> None: + """Close once and unregister only after successful cleanup.""" + if self._closed: + return + await self._close() + self._closed = True + self._instances.remove(self) + + @classmethod + def pending_count(cls) -> int: + """Return the number of registered resources awaiting close.""" + return len(cls._instances) + + @classmethod + async def close_all(cls) -> None: + """Attempt LIFO close of every resource and aggregate failures.""" + failures: list[Exception] = [] + instance: AsyncBase + for instance in tuple(reversed(cls._instances)): + try: + await instance.close() + except Exception as error: + failures.append(error) + if failures: + raise CloseFailures(tuple(failures)) + + +class AsyncManager: + """Close all registered resources when leaving an async context.""" + + async def __aenter__(self) -> "AsyncManager": + return self + + async def __aexit__( + self, + exception_type: type[BaseException] | None, + exception: BaseException | None, + traceback: TracebackType | None, + ) -> bool: + del exception_type, exception, traceback + await AsyncBase.close_all() + return False + + +class DemoResource(AsyncBase): + """Minimal import-safe example resource.""" + + def __init__(self, name: str) -> None: + self.name: str = name + super().__init__() + + async def _close(self) -> None: + print(f"closed {self.name}") + + +async def main() -> None: + """Demonstrate managed cleanup.""" + async with AsyncManager(): + DemoResource("demo") + + +if __name__ == "__main__": + import asyncio + + asyncio.run(main()) +``` + +Catch `Exception`, not `BaseException`, so cancellation and interpreter-level +termination are never converted into `CloseFailures`. A failed resource stays +registered and can be retried. + +- [ ] **Step 4: Run focused tests and strict quality checks** + +Run: + +```bash +uv run pytest CH_13_async_io/exercise_01/test_solution_00.py -v +uv run ruff format --check CH_13_async_io/exercise_01/solution_00.py CH_13_async_io/exercise_01/test_solution_00.py +uv run ruff check CH_13_async_io/exercise_01/solution_00.py CH_13_async_io/exercise_01/test_solution_00.py +uv run pyrefly check CH_13_async_io/exercise_01/solution_00.py +uv run mypy CH_13_async_io/exercise_01/solution_00.py +uv run pyright CH_13_async_io/exercise_01/solution_00.py +``` + +Expected: three tests pass and all quality commands exit zero. + +- [ ] **Step 5: Add the exercise README and chapter index** + +Create `CH_13_async_io/exercise_01/README.rst`: + +```rst +Exercise 1: registered async resources +====================================== + +Question +-------- + +Create an ``asyncio`` base class that automatically registers all instances so +closing and destruction can be performed easily. + +Answer +------ + +``AsyncBase`` registers each instance, provides idempotent ``close()``, and +closes pending resources in last-created-first-closed order. ``AsyncManager`` +performs explicit asynchronous cleanup. Ordinary close errors are all attempted +and then reported as ``CloseFailures``; failed resources remain registered for +retry. No asynchronous work is attempted from ``__del__``. + +Dependencies +------------ + +Only the Python 3.10 standard library is required at runtime. Tests use pytest. + +Run +--- + +.. code-block:: console + + uv run python -m CH_13_async_io.exercise_01.solution_00 + uv run pytest CH_13_async_io/exercise_01/test_solution_00.py -v + +Upstream references +------------------- + +The explicit async constructor/destructor pattern is informed by +`T_12_constructors_and_destructors.rst `_ +and task shutdown by +`T_18_wait_for_all_tasks.py `_. +The upstream material is MIT licensed; this solution is self-contained. +``` + +Replace `CH_13_async_io/README.rst` with: + +```rst +Chapter 13 - async I/O +====================== + +1. `Registered async resources `_ +2. `Executor-backed synchronous operations `_ +``` + +- [ ] **Step 6: Re-run and commit Exercise 1** + +Run: + +```bash +uv run pytest CH_13_async_io/exercise_01/test_solution_00.py -v +git add CH_13_async_io/exercise_01/solution_00.py CH_13_async_io/exercise_01/test_solution_00.py CH_13_async_io/exercise_01/README.rst CH_13_async_io/README.rst +git commit -m "feat: complete async resource manager exercise" +``` + +Expected: tests pass; `solution_01.py` remains byte-for-byte unchanged. + +### Task 2: Wrap blocking thread, process, file, and network calls + +**Files:** + +- Modify: `CH_13_async_io/exercise_02/solution_00.py` +- Create: `CH_13_async_io/exercise_02/test_solution_00.py` +- Create: `CH_13_async_io/exercise_02/README.rst` + +- [ ] **Step 1: Write bounded executor and file integration tests** + +Create `CH_13_async_io/exercise_02/test_solution_00.py`: + +```python +import asyncio +import concurrent.futures +import socket +import threading +from pathlib import Path + +import pytest + +from CH_13_async_io.exercise_02.solution_00 import ( + AsyncExecutor, + AsyncioFile, +) + + +def square(value: int) -> int: + """Spawn-safe process-pool callable.""" + return value * value + + +def test_default_executor_runs_off_event_loop_thread() -> None: + async def scenario() -> None: + loop_thread: int = threading.get_ident() + async with AsyncExecutor() as runner: + worker_thread: int = await asyncio.wait_for( + runner.run(threading.get_ident), + timeout=2, + ) + assert worker_thread != loop_thread + + asyncio.run(scenario()) + + +def test_async_file_round_trip(tmp_path: Path) -> None: + async def scenario() -> None: + path: Path = tmp_path / "sample.txt" + async with AsyncioFile(path) as async_file: + written: int = await async_file.write_text("hello") + assert written == 5 + assert await async_file.exists() + assert await async_file.read_text() == "hello" + + asyncio.run(scenario()) + + +def test_injected_process_pool_executes_spawn_safe_callable() -> None: + async def scenario() -> None: + with concurrent.futures.ProcessPoolExecutor( + max_workers=1 + ) as process_pool: + runner: AsyncExecutor = AsyncExecutor(process_pool) + result: int = await asyncio.wait_for( + runner.run(square, 6), + timeout=10, + ) + await runner.close() + assert result == 36 + + asyncio.run(scenario()) + + +def test_loopback_socket_and_failure_propagation() -> None: + async def scenario() -> None: + left: socket.socket + right: socket.socket + left, right = socket.socketpair() + try: + async with AsyncExecutor() as runner: + left.sendall(b"ping") + received: bytes = await asyncio.wait_for( + runner.run(right.recv, 4), + timeout=2, + ) + assert received == b"ping" + + def fail() -> None: + raise OSError("blocking failure") + + with pytest.raises(OSError, match="blocking failure"): + await runner.run(fail) + finally: + left.close() + right.close() + + asyncio.run(scenario()) +``` + +- [ ] **Step 2: Run tests against the old file-only wrapper** + +Run: + +```bash +uv run pytest CH_13_async_io/exercise_02/test_solution_00.py -v +``` + +Expected: FAIL because `AsyncExecutor` does not provide ownership-aware async +context management and the old file wrapper leaks its executor. + +- [ ] **Step 3: Implement the generic executor adapter** + +The first half of `solution_00.py` must be: + +```python +"""Run synchronous process, file, and network operations in executors.""" + +import asyncio +import concurrent.futures +import functools +from collections.abc import Callable +from pathlib import Path +from types import TracebackType +from typing import ParamSpec, TypeVar + +Parameters = ParamSpec("Parameters") +Result = TypeVar("Result") + + +class AsyncExecutor: + """Await synchronous callables through an injected executor.""" + + def __init__( + self, + executor: concurrent.futures.Executor | None = None, + ) -> None: + self._executor: concurrent.futures.Executor = ( + executor + if executor is not None + else concurrent.futures.ThreadPoolExecutor() + ) + self._owns_executor: bool = executor is None + self._closed: bool = False + + async def run( + self, + function: Callable[Parameters, Result], + *args: Parameters.args, + **kwargs: Parameters.kwargs, + ) -> Result: + """Run a blocking callable and propagate its result or exception.""" + if self._closed: + raise RuntimeError("executor is closed") + loop: asyncio.AbstractEventLoop = asyncio.get_running_loop() + bound: Callable[[], Result] = functools.partial( + function, + *args, + **kwargs, + ) + return await loop.run_in_executor(self._executor, bound) + + async def close(self) -> None: + """Shut down an owned executor without blocking the event loop.""" + if self._closed: + return + self._closed = True + if self._owns_executor: + await asyncio.to_thread( + self._executor.shutdown, + wait=True, + cancel_futures=True, + ) + + async def __aenter__(self) -> "AsyncExecutor": + if self._closed: + raise RuntimeError("executor is closed") + return self + + async def __aexit__( + self, + exception_type: type[BaseException] | None, + exception: BaseException | None, + traceback: TracebackType | None, + ) -> bool: + del exception_type, exception, traceback + await self.close() + return False +``` + +Injected executors are caller-owned: `close()` marks the adapter closed but +does not shut the injected executor down. This permits the process-pool test to +use the pool's own context manager. + +- [ ] **Step 4: Add the file convenience wrapper** + +Append this complete public wrapper to the same file: + +```python +class AsyncioFile: + """Expose common `pathlib.Path` operations asynchronously.""" + + def __init__( + self, + path: Path, + runner: AsyncExecutor | None = None, + ) -> None: + self.path: Path = path + self._runner: AsyncExecutor = runner or AsyncExecutor() + self._owns_runner: bool = runner is None + + async def exists(self) -> bool: + return await self._runner.run(self.path.exists) + + async def read_text( + self, + *, + encoding: str = "utf-8", + errors: str = "strict", + ) -> str: + return await self._runner.run( + self.path.read_text, + encoding=encoding, + errors=errors, + ) + + async def write_text( + self, + data: str, + *, + encoding: str = "utf-8", + errors: str = "strict", + ) -> int: + return await self._runner.run( + self.path.write_text, + data, + encoding=encoding, + errors=errors, + ) + + async def read_bytes(self) -> bytes: + return await self._runner.run(self.path.read_bytes) + + async def write_bytes(self, data: bytes) -> int: + return await self._runner.run(self.path.write_bytes, data) + + async def rename(self, target: Path) -> Path: + return await self._runner.run(self.path.rename, target) + + async def close(self) -> None: + if self._owns_runner: + await self._runner.close() + + async def __aenter__(self) -> "AsyncioFile": + return self + + async def __aexit__( + self, + exception_type: type[BaseException] | None, + exception: BaseException | None, + traceback: TracebackType | None, + ) -> bool: + del exception_type, exception, traceback + await self.close() + return False + + +async def main() -> None: + """Read this source file through an executor.""" + async with AsyncioFile(Path(__file__)) as async_file: + print((await async_file.read_text()).splitlines()[0]) + + +if __name__ == "__main__": + asyncio.run(main()) +``` + +- [ ] **Step 5: Add close-state and ownership regression tests** + +Append these complete tests: + +```python +def test_closed_runner_rejects_new_work() -> None: + async def scenario() -> None: + runner: AsyncExecutor = AsyncExecutor() + await runner.close() + with pytest.raises(RuntimeError, match="executor is closed"): + await runner.run(square, 2) + + asyncio.run(scenario()) + + +def test_injected_executor_remains_caller_owned() -> None: + async def scenario() -> None: + executor: concurrent.futures.ThreadPoolExecutor = ( + concurrent.futures.ThreadPoolExecutor(max_workers=1) + ) + runner: AsyncExecutor = AsyncExecutor(executor) + await runner.close() + future: concurrent.futures.Future[int] = executor.submit(square, 3) + try: + assert future.result(timeout=2) == 9 + finally: + executor.shutdown(wait=True, cancel_futures=True) + + asyncio.run(scenario()) +``` + +- [ ] **Step 6: Run focused and strict checks** + +Run: + +```bash +uv run pytest CH_13_async_io/exercise_02/test_solution_00.py -v +uv run ruff format --check CH_13_async_io/exercise_02/solution_00.py CH_13_async_io/exercise_02/test_solution_00.py +uv run ruff check CH_13_async_io/exercise_02/solution_00.py CH_13_async_io/exercise_02/test_solution_00.py +uv run pyrefly check CH_13_async_io/exercise_02/solution_00.py +uv run mypy CH_13_async_io/exercise_02/solution_00.py +uv run pyright CH_13_async_io/exercise_02/solution_00.py +``` + +Expected: six tests pass within their bounds; process/thread executors shut +down deterministically; all static checks exit zero. + +- [ ] **Step 7: Add the exercise README** + +Create `CH_13_async_io/exercise_02/README.rst`: + +```rst +Exercise 2: executor-backed synchronous operations +================================================== + +Question +-------- + +Create an ``asyncio`` wrapper class for synchronous process, file, and network +operations using executors. + +Answer +------ + +``AsyncExecutor`` makes any synchronous callable awaitable through a thread or +process executor and propagates its result or exception. Executors created by +the adapter are shut down asynchronously; injected executors remain owned by +their caller. ``AsyncioFile`` provides typed convenience methods for common +``pathlib.Path`` operations. Blocking socket calls use the same generic API. +Process-pool callables must be top-level and pickleable. + +Dependencies +------------ + +Only the Python 3.10 standard library is required at runtime. Tests use pytest +and local threads, processes, temporary files, and ``socketpair``; they require +no network service. + +Run +--- + +.. code-block:: console + + uv run python -m CH_13_async_io.exercise_02.solution_00 + uv run pytest CH_13_async_io/exercise_02/test_solution_00.py -v + +Upstream references +------------------- + +Executor usage is informed by +`T_05_executors.rst `_ +and the blocking-call warning by +`T_14_slow_blocking_code.py `_. +The upstream material is MIT licensed; no runtime clone or import is used. +``` + +- [ ] **Step 8: Run the complete packet and commit** + +Run: + +```bash +uv run pytest CH_13_async_io -v +uv run ruff format --check CH_13_async_io +uv run ruff check CH_13_async_io +uv run pyrefly check CH_13_async_io +uv run mypy CH_13_async_io +uv run pyright CH_13_async_io +``` + +Expected: all Chapter 13 tests pass with zero findings and no live worker +threads/processes after pytest exits. + +Commit: + +```bash +git add CH_13_async_io/exercise_02/solution_00.py CH_13_async_io/exercise_02/test_solution_00.py CH_13_async_io/exercise_02/README.rst +git commit -m "feat: complete executor-backed asyncio exercise" +``` + +### Task 3: Packet self-review + +**Files:** + +- Review: `CH_13_async_io/README.rst` +- Review: `CH_13_async_io/exercise_01/` +- Review: `CH_13_async_io/exercise_02/` + +- [ ] **Step 1: Verify the directory contract and historical preservation** + +Run: + +```bash +find CH_13_async_io -maxdepth 2 -type f | sort +git diff --exit-code master -- CH_13_async_io/exercise_01/solution_01.py +``` + +Expected: both exercises have README/test/canonical solution/init files and +the historical alternative has no diff. + +- [ ] **Step 2: Scan for placeholders and unsafe async destruction** + +Run: + +```bash +rg -n "__del__|asyncio\\.run\\(" CH_13_async_io +``` + +Expected: only the two guarded demonstration `asyncio.run(main())` calls and +synchronous pytest wrappers match; no `__del__`, placeholder, or nested-loop +cleanup exists. + +- [ ] **Step 3: Confirm ownership isolation** + +Run: + +```bash +git status --short +git diff --stat master -- CH_13_async_io +``` + +Expected: no uncommitted packet changes; no file outside Chapter 13 or its plan +was modified. diff --git a/docs/superpowers/plans/2026-07-28-ch14-ipc-files.md b/docs/superpowers/plans/2026-07-28-ch14-ipc-files.md new file mode 100644 index 0000000..4d667ce --- /dev/null +++ b/docs/superpowers/plans/2026-07-28-ch14-ipc-files.md @@ -0,0 +1,470 @@ +# Chapter 14 IPC and Concurrent Files Implementation + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Deliver canonical, typed, spawn-safe solutions and co-located tests for Chapter 14 exercises 1 and 2. + +**Architecture:** Exercise 1 uses a duplex `multiprocessing.Connection` with typed request/reply messages and cooperative shutdown. Exercise 2 separates path discovery from concurrent stat calls, uses `ThreadPoolExecutor`, and propagates worker failures through `Future.result()`. + +**Tech Stack:** Python 3.10, `multiprocessing`, `concurrent.futures`, `pathlib`, pytest, pytest-timeout, uv, Ruff, Pyrefly, mypy, Pyright. + +--- + +## File map + +- Modify `CH_14_multithreading_and_multiprocessing/README.rst`: link exercises 1 and 2 while retaining the exact questions. +- Modify `CH_14_multithreading_and_multiprocessing/exercise_01/solution_00.py`: canonical pipe echo API. +- Create `CH_14_multithreading_and_multiprocessing/exercise_01/test_solution_00.py`: spawn, boundary, timeout, and cleanup tests. +- Create `CH_14_multithreading_and_multiprocessing/exercise_01/README.rst`: prompt, contract, commands, provenance. +- Modify `CH_14_multithreading_and_multiprocessing/exercise_02/solution_00.py`: canonical concurrent file-size API. +- Create `CH_14_multithreading_and_multiprocessing/exercise_02/test_solution_00.py`: flat/recursive/error tests. +- Create `CH_14_multithreading_and_multiprocessing/exercise_02/README.rst`: prompt, contract, commands, provenance. + +Do not modify historical alternatives. This packet contains none, but the rule still applies if alternatives appear before execution. + +### Task 1: Pipe-based echo session + +**Files:** +- Modify: `CH_14_multithreading_and_multiprocessing/exercise_01/solution_00.py` +- Create: `CH_14_multithreading_and_multiprocessing/exercise_01/test_solution_00.py` + +- [ ] **Step 1: Replace the script-shaped behavior with failing public-contract tests** + +Use spawn explicitly, record pre-existing child PIDs, and require cooperative shutdown: + +```python +from __future__ import annotations + +import multiprocessing +from collections.abc import Iterator + +import pytest + +from .solution_00 import run_echo_session + + +@pytest.fixture +def child_pids() -> Iterator[set[int]]: + before: set[int] = { + process.pid + for process in multiprocessing.active_children() + if process.pid is not None + } + yield before + leaked: list[multiprocessing.Process] = [ + process + for process in multiprocessing.active_children() + if process.pid is not None and process.pid not in before + ] + for process in leaked: + process.terminate() + process.join(timeout=2.0) + assert all(not process.is_alive() for process in leaked) + + +@pytest.mark.timeout(10) +def test_run_echo_session_preserves_order(child_pids: set[int]) -> None: + messages: list[str] = ["first", "", "last"] + assert run_echo_session(messages, context=multiprocessing.get_context("spawn")) == messages + + +@pytest.mark.timeout(10) +def test_run_echo_session_accepts_no_messages(child_pids: set[int]) -> None: + assert run_echo_session([], context=multiprocessing.get_context("spawn")) == [] + + +def test_run_echo_session_rejects_non_positive_timeout() -> None: + with pytest.raises(ValueError, match="timeout must be positive"): + run_echo_session(["hello"], timeout=0.0) + + +def test_run_echo_session_rejects_non_string_messages() -> None: + with pytest.raises(TypeError, match="messages must contain only strings"): + run_echo_session(["hello", 3]) # type: ignore[list-item] +``` + +- [ ] **Step 2: Run the focused tests and verify the old API fails** + +Run: + +```bash +uv run pytest CH_14_multithreading_and_multiprocessing/exercise_01/test_solution_00.py -vv +``` + +Expected: FAIL during import because `run_echo_session` does not exist. + +- [ ] **Step 3: Implement typed messages, a spawn-safe server, and deterministic cleanup** + +Keep these public names and signatures: + +```python +from __future__ import annotations + +import multiprocessing +from collections.abc import Sequence +from dataclasses import dataclass +from multiprocessing.connection import Connection + + +@dataclass(frozen=True) +class EchoRequest: + request_id: int + message: str + + +@dataclass(frozen=True) +class EchoReply: + request_id: int + message: str + + +@dataclass(frozen=True) +class StopRequest: + pass + + +def echo_server(connection: Connection) -> None: + try: + while True: + request: object = connection.recv() + if isinstance(request, StopRequest): + return + if not isinstance(request, EchoRequest): + raise TypeError("echo server received an invalid request") + connection.send(EchoReply(request.request_id, request.message)) + finally: + connection.close() + + +def run_echo_session( + messages: Sequence[str], + *, + timeout: float = 5.0, + context: multiprocessing.context.BaseContext | None = None, +) -> list[str]: + if timeout <= 0: + raise ValueError("timeout must be positive") + if any(not isinstance(message, str) for message in messages): + raise TypeError("messages must contain only strings") + + process_context: multiprocessing.context.BaseContext = ( + context if context is not None else multiprocessing.get_context("spawn") + ) + client_connection: Connection + server_connection: Connection + client_connection, server_connection = process_context.Pipe(duplex=True) + server: multiprocessing.Process = process_context.Process( + target=echo_server, + args=(server_connection,), + name="chapter-14-echo-server", + ) + server.start() + server_connection.close() + + replies: list[str] = [] + try: + for request_id, message in enumerate(messages): + client_connection.send(EchoRequest(request_id, message)) + if not client_connection.poll(timeout): + raise TimeoutError(f"echo request {request_id} timed out") + reply: object = client_connection.recv() + if not isinstance(reply, EchoReply) or reply.request_id != request_id: + raise RuntimeError("echo server returned an invalid reply") + replies.append(reply.message) + client_connection.send(StopRequest()) + server.join(timeout) + if server.is_alive(): + raise TimeoutError("echo server did not stop") + if server.exitcode != 0: + raise ChildProcessError(f"echo server exited with code {server.exitcode}") + return replies + finally: + client_connection.close() + if server.is_alive(): + server.terminate() + server.join(timeout=timeout) + + +def main() -> None: + messages: list[str] = [f"message {index}" for index in range(5)] + for reply in run_echo_session(messages): + print(reply) + + +if __name__ == "__main__": + main() +``` + +The `terminate()` call is cleanup after a failed timeout, never the successful shutdown path. + +- [ ] **Step 4: Run the focused tests and static checks** + +Run: + +```bash +uv run pytest CH_14_multithreading_and_multiprocessing/exercise_01/test_solution_00.py -vv +uv run ruff format --check CH_14_multithreading_and_multiprocessing/exercise_01 +uv run ruff check CH_14_multithreading_and_multiprocessing/exercise_01 +uv run pyrefly check CH_14_multithreading_and_multiprocessing/exercise_01 +uv run mypy CH_14_multithreading_and_multiprocessing/exercise_01 +uv run pyright CH_14_multithreading_and_multiprocessing/exercise_01 +``` + +Expected: every command exits 0; pytest reports 4 passed and the orphan-check fixture reports no child process. + +- [ ] **Step 5: Commit the exercise 1 implementation** + +```bash +git add CH_14_multithreading_and_multiprocessing/exercise_01/solution_00.py CH_14_multithreading_and_multiprocessing/exercise_01/test_solution_00.py +git commit -m "feat: add pipe-based echo exercise" +``` + +### Task 2: Concurrent flat and recursive file sizing + +**Files:** +- Modify: `CH_14_multithreading_and_multiprocessing/exercise_02/solution_00.py` +- Create: `CH_14_multithreading_and_multiprocessing/exercise_02/test_solution_00.py` + +- [ ] **Step 1: Write failing tests for flat, recursive, empty, invalid, and worker-error behavior** + +```python +from __future__ import annotations + +from pathlib import Path + +import pytest + +from . import solution_00 + + +def write_bytes(path: Path, size: int) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(b"x" * size) + + +def test_get_total_size_counts_only_immediate_regular_files(tmp_path: Path) -> None: + write_bytes(tmp_path / "one.bin", 3) + write_bytes(tmp_path / "nested" / "two.bin", 5) + assert solution_00.get_total_size(tmp_path, max_workers=2) == 3 + + +def test_get_total_size_recursive_counts_nested_files(tmp_path: Path) -> None: + write_bytes(tmp_path / "one.bin", 3) + write_bytes(tmp_path / "nested" / "two.bin", 5) + write_bytes(tmp_path / "nested" / "deep" / "three.bin", 7) + assert solution_00.get_total_size_recursive(tmp_path, max_workers=2) == 15 + + +def test_empty_directory_has_zero_size(tmp_path: Path) -> None: + assert solution_00.get_total_size(tmp_path) == 0 + assert solution_00.get_total_size_recursive(tmp_path) == 0 + + +def test_directory_argument_is_required(tmp_path: Path) -> None: + file_path: Path = tmp_path / "file.bin" + write_bytes(file_path, 1) + with pytest.raises(NotADirectoryError): + solution_00.get_total_size(file_path) + + +def test_worker_failure_propagates( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + blocked: Path = tmp_path / "blocked.bin" + write_bytes(blocked, 1) + + def fail_stat(path: Path) -> int: + raise PermissionError(path) + + monkeypatch.setattr(solution_00, "get_size", fail_stat) + with pytest.raises(PermissionError): + solution_00.get_total_size(tmp_path, max_workers=1) +``` + +- [ ] **Step 2: Run the focused tests and verify the current implementation fails** + +Run: + +```bash +uv run pytest CH_14_multithreading_and_multiprocessing/exercise_02/test_solution_00.py -vv +``` + +Expected: FAIL because the current functions do not consistently return totals, validate inputs, or expose `max_workers`. + +- [ ] **Step 3: Implement deterministic discovery and executor-backed stat calls** + +Preserve `get_size`, `get_total_size`, `get_total_size_recursive`, and `main`: + +```python +from __future__ import annotations + +import concurrent.futures +from collections.abc import Iterable +from pathlib import Path + + +def get_size(path: Path) -> int: + if not path.is_file(): + raise FileNotFoundError(f"not a regular file: {path}") + return path.stat().st_size + + +def _validate_directory(path: Path) -> None: + if not path.is_dir(): + raise NotADirectoryError(path) + + +def _flat_files(path: Path) -> list[Path]: + return sorted( + child + for child in path.iterdir() + if child.is_file() and not child.is_symlink() + ) + + +def _recursive_files(path: Path) -> list[Path]: + files: list[Path] = [] + pending: list[Path] = [path] + while pending: + directory: Path = pending.pop() + for child in sorted(directory.iterdir(), reverse=True): + if child.is_symlink(): + continue + if child.is_dir(): + pending.append(child) + elif child.is_file(): + files.append(child) + return sorted(files) + + +def _sum_sizes(paths: Iterable[Path], max_workers: int | None) -> int: + if max_workers is not None and max_workers < 1: + raise ValueError("max_workers must be positive") + with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor: + futures: list[concurrent.futures.Future[int]] = [ + executor.submit(get_size, path) for path in paths + ] + return sum(future.result() for future in futures) + + +def get_total_size(path: Path, *, max_workers: int | None = None) -> int: + _validate_directory(path) + return _sum_sizes(_flat_files(path), max_workers) + + +def get_total_size_recursive(path: Path, *, max_workers: int | None = None) -> int: + _validate_directory(path) + return _sum_sizes(_recursive_files(path), max_workers) + + +def main(path: Path) -> None: + print(f"Total size of immediate files in {path}: {get_total_size(path)}") + print(f"Recursive total size of {path}: {get_total_size_recursive(path)}") + + +if __name__ == "__main__": + main(Path.cwd()) +``` + +Symlinks are deliberately excluded so a recursive run cannot leave the selected tree or loop. + +- [ ] **Step 4: Run focused tests and static checks** + +Run: + +```bash +uv run pytest CH_14_multithreading_and_multiprocessing/exercise_02/test_solution_00.py -vv +uv run ruff format --check CH_14_multithreading_and_multiprocessing/exercise_02 +uv run ruff check CH_14_multithreading_and_multiprocessing/exercise_02 +uv run pyrefly check CH_14_multithreading_and_multiprocessing/exercise_02 +uv run mypy CH_14_multithreading_and_multiprocessing/exercise_02 +uv run pyright CH_14_multithreading_and_multiprocessing/exercise_02 +``` + +Expected: every command exits 0; pytest reports 5 passed. + +- [ ] **Step 5: Commit the exercise 2 implementation** + +```bash +git add CH_14_multithreading_and_multiprocessing/exercise_02/solution_00.py CH_14_multithreading_and_multiprocessing/exercise_02/test_solution_00.py +git commit -m "feat: add concurrent file sizing exercise" +``` + +### Task 3: Document both exercises and link the chapter index + +**Files:** +- Create: `CH_14_multithreading_and_multiprocessing/exercise_01/README.rst` +- Create: `CH_14_multithreading_and_multiprocessing/exercise_02/README.rst` +- Modify: `CH_14_multithreading_and_multiprocessing/README.rst` + +- [ ] **Step 1: Write the exercise 1 README** + +Include the exact Chapter 14 question, explain duplex request/reply order, string-only messages, cooperative `StopRequest`, timeout cleanup, and the absence of third-party runtime dependencies. Include: + +```rst +Run the answer and tests +------------------------ + +.. code-block:: console + + uv run python -m CH_14_multithreading_and_multiprocessing.exercise_01.solution_00 + uv run pytest CH_14_multithreading_and_multiprocessing/exercise_01/test_solution_00.py -vv + +Reference +--------- + +The process and concurrency structure was informed by the book's MIT-licensed +examples at +https://github.com/mastering-python/code_2/blob/a012f6ce3f5bf11f5bf380d1c4e7f743355adb89/CH_14_multithreading_and_multiprocessing/T_03_multiprocessing.py +and +https://github.com/mastering-python/code_2/blob/a012f6ce3f5bf11f5bf380d1c4e7f743355adb89/CH_14_multithreading_and_multiprocessing/T_04_multiprocessing_class.py +``` + +- [ ] **Step 2: Write the exercise 2 README** + +Include the exact question, flat versus recursive semantics, regular-file-only behavior, symlink exclusion, failure propagation, and no third-party runtime dependencies. Use these commands and immutable references: + +```rst +.. code-block:: console + + uv run python -m CH_14_multithreading_and_multiprocessing.exercise_02.solution_00 + uv run pytest CH_14_multithreading_and_multiprocessing/exercise_02/test_solution_00.py -vv + +The executor usage was informed by +https://github.com/mastering-python/code_2/blob/a012f6ce3f5bf11f5bf380d1c4e7f743355adb89/CH_14_multithreading_and_multiprocessing/T_00_concurrent_futures.py +and +https://github.com/mastering-python/code_2/blob/a012f6ce3f5bf11f5bf380d1c4e7f743355adb89/CH_14_multithreading_and_multiprocessing/T_07_thread_batch_processing.py +``` + +- [ ] **Step 3: Turn chapter items 1 and 2 into links without changing their wording** + +Use RST links whose labels reproduce the current questions exactly and whose targets are `exercise_01/` and `exercise_02/`. Leave items 3 through 7 for the other Chapter 14 packet. + +- [ ] **Step 4: Verify documentation commands and packet checks** + +Run: + +```bash +uv run python -m CH_14_multithreading_and_multiprocessing.exercise_01.solution_00 +uv run python -m CH_14_multithreading_and_multiprocessing.exercise_02.solution_00 +uv run pytest CH_14_multithreading_and_multiprocessing/exercise_01 CH_14_multithreading_and_multiprocessing/exercise_02 -vv +uv run ruff format --check CH_14_multithreading_and_multiprocessing/exercise_01 CH_14_multithreading_and_multiprocessing/exercise_02 +uv run ruff check CH_14_multithreading_and_multiprocessing/exercise_01 CH_14_multithreading_and_multiprocessing/exercise_02 +``` + +Expected: both demonstrations exit 0, pytest reports 9 passed, and Ruff exits 0. + +- [ ] **Step 5: Commit documentation** + +```bash +git add CH_14_multithreading_and_multiprocessing/README.rst CH_14_multithreading_and_multiprocessing/exercise_01/README.rst CH_14_multithreading_and_multiprocessing/exercise_02/README.rst +git commit -m "docs: document chapter 14 IPC exercises" +``` + +## Packet acceptance + +- [ ] Run both exercises on Python 3.10 and Python 3.14 through the coordinator's pytest matrix. +- [ ] Confirm every spawned process joins and no test uses a fixed port, network, or interactive input. +- [ ] Confirm all canonical functions, local variables, dataclass fields, and return values are explicitly typed. +- [ ] Confirm `git diff -- CH_14_multithreading_and_multiprocessing/exercise_01 CH_14_multithreading_and_multiprocessing/exercise_02 CH_14_multithreading_and_multiprocessing/README.rst` contains only this packet. diff --git a/docs/superpowers/plans/2026-07-28-ch14-workers-rpc-parallel.md b/docs/superpowers/plans/2026-07-28-ch14-workers-rpc-parallel.md new file mode 100644 index 0000000..28a5ff7 --- /dev/null +++ b/docs/superpowers/plans/2026-07-28-ch14-workers-rpc-parallel.md @@ -0,0 +1,656 @@ +# Chapter 14 Workers, RPC, and Parallel Sorting Implementation + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Deliver canonical, typed, tested solutions for Chapter 14 exercises 3 through 7 while retaining every historical specialized answer unchanged. + +**Architecture:** Exercises 3 and 4 expose paired thread/process APIs for flat and recursively scheduled file work. Exercise 5 supplies a reusable persistent queue pool; exercise 6 narrows that transport into allowlisted RPC; exercise 7 performs deterministic parallel merge sort with bounded spawn workers. + +**Tech Stack:** Python 3.10, `threading`, `multiprocessing`, `queue`, `concurrent.futures`, pytest, pytest-timeout, uv, Ruff, Pyrefly, mypy, Pyright. + +--- + +## File map and immutable files + +- Create canonical `solution_00.py`, `test_solution_00.py`, and `README.rst` in `exercise_03/` and `exercise_04/`. +- Rewrite `exercise_05/solution_00.py`; create its test and README. +- Rewrite `exercise_06/solution_00.py`; create its test and README. +- Rewrite `exercise_07/solution_00.py`; create its test and README. +- Modify `CH_14_multithreading_and_multiprocessing/README.rst` only to link exercises 3 through 7. +- Never modify: + - `exercise_03/threading_solution_00.py` + - `exercise_03/multiprocessing_solution_00.py` + - `exercise_04/threading_solution_00.py` + - `exercise_04/multiprocessing_solution_00.py` + +### Task 1: Canonical non-recursive thread and process APIs + +**Files:** +- Create: `CH_14_multithreading_and_multiprocessing/exercise_03/solution_00.py` +- Create: `CH_14_multithreading_and_multiprocessing/exercise_03/test_solution_00.py` + +- [ ] **Step 1: Write failing parity and failure-propagation tests** + +```python +from __future__ import annotations + +import multiprocessing +from pathlib import Path + +import pytest + +from .solution_00 import multiprocessing_total_size, threaded_total_size + + +def write_file(path: Path, size: int) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(b"x" * size) + + +@pytest.mark.timeout(15) +def test_thread_and_process_totals_match(tmp_path: Path) -> None: + write_file(tmp_path / "a.bin", 2) + write_file(tmp_path / "b.bin", 5) + write_file(tmp_path / "nested" / "ignored.bin", 11) + assert threaded_total_size(tmp_path, worker_count=2) == 7 + assert multiprocessing_total_size( + tmp_path, + worker_count=2, + context=multiprocessing.get_context("spawn"), + ) == 7 + + +def test_both_apis_reject_non_directories(tmp_path: Path) -> None: + path: Path = tmp_path / "file" + write_file(path, 1) + with pytest.raises(NotADirectoryError): + threaded_total_size(path) + with pytest.raises(NotADirectoryError): + multiprocessing_total_size(path) + + +def test_worker_count_must_be_positive(tmp_path: Path) -> None: + with pytest.raises(ValueError, match="worker_count"): + threaded_total_size(tmp_path, worker_count=0) +``` + +- [ ] **Step 2: Run the tests and verify the canonical module is missing** + +Run `uv run pytest CH_14_multithreading_and_multiprocessing/exercise_03/test_solution_00.py -vv`. + +Expected: FAIL during import because `exercise_03/solution_00.py` does not exist. + +- [ ] **Step 3: Implement the paired APIs** + +Use a `queue.Queue[Path | None]` plus `threading.Thread` for `threaded_total_size`. Use a spawn-context `multiprocessing.Pool` for `multiprocessing_total_size`; map the top-level `_stat_size(path: Path) -> int` function so child failures are re-raised by `pool.map`. Both APIs validate `worker_count`, count only sorted immediate regular non-symlink files, return `0` for an empty directory, and expose: + +```python +def threaded_total_size(directory: Path, *, worker_count: int = 4) -> int: + """Return the total size of immediate files using raw threads.""" + + +def multiprocessing_total_size( + directory: Path, + *, + worker_count: int = 2, + context: multiprocessing.context.BaseContext | None = None, +) -> int: + """Return the total size of immediate files using a process pool.""" +``` + +The thread worker must catch `Exception`, append the exception under a lock, call `task_done()` in `finally`, and the parent must re-raise the first exception after joining every worker. The process pool must use a context manager and `processes=min(worker_count, max(1, len(paths)))`. + +- [ ] **Step 4: Run focused verification** + +```bash +uv run pytest CH_14_multithreading_and_multiprocessing/exercise_03/test_solution_00.py -vv +uv run ruff format --check CH_14_multithreading_and_multiprocessing/exercise_03/solution_00.py CH_14_multithreading_and_multiprocessing/exercise_03/test_solution_00.py +uv run ruff check CH_14_multithreading_and_multiprocessing/exercise_03/solution_00.py CH_14_multithreading_and_multiprocessing/exercise_03/test_solution_00.py +uv run pyrefly check CH_14_multithreading_and_multiprocessing/exercise_03/solution_00.py CH_14_multithreading_and_multiprocessing/exercise_03/test_solution_00.py +uv run mypy CH_14_multithreading_and_multiprocessing/exercise_03/solution_00.py CH_14_multithreading_and_multiprocessing/exercise_03/test_solution_00.py +uv run pyright CH_14_multithreading_and_multiprocessing/exercise_03/solution_00.py CH_14_multithreading_and_multiprocessing/exercise_03/test_solution_00.py +``` + +Expected: every command exits 0 and pytest reports 3 passed. + +- [ ] **Step 5: Commit** + +```bash +git add CH_14_multithreading_and_multiprocessing/exercise_03/solution_00.py CH_14_multithreading_and_multiprocessing/exercise_03/test_solution_00.py +git commit -m "feat: add canonical threaded and process file totals" +``` + +### Task 2: Recursive queue scheduling with explicit timeouts + +**Files:** +- Create: `CH_14_multithreading_and_multiprocessing/exercise_04/solution_00.py` +- Create: `CH_14_multithreading_and_multiprocessing/exercise_04/test_solution_00.py` + +- [ ] **Step 1: Write failing recursive parity, symlink, empty, and cleanup tests** + +```python +from __future__ import annotations + +import multiprocessing +from pathlib import Path + +import pytest + +from .solution_00 import multiprocessing_recursive_size, threaded_recursive_size + + +def write_file(path: Path, size: int) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(b"x" * size) + + +@pytest.mark.timeout(20) +def test_recursive_apis_find_dynamically_enqueued_directories(tmp_path: Path) -> None: + write_file(tmp_path / "root.bin", 2) + write_file(tmp_path / "one" / "child.bin", 3) + write_file(tmp_path / "one" / "two" / "deep.bin", 5) + assert threaded_recursive_size(tmp_path, worker_count=3, timeout=5.0) == 10 + assert multiprocessing_recursive_size( + tmp_path, + worker_count=2, + timeout=5.0, + context=multiprocessing.get_context("spawn"), + ) == 10 + + +def test_recursive_apis_return_zero_for_empty_directory(tmp_path: Path) -> None: + assert threaded_recursive_size(tmp_path) == 0 + assert multiprocessing_recursive_size(tmp_path) == 0 + + +def test_timeout_must_be_positive(tmp_path: Path) -> None: + with pytest.raises(ValueError, match="timeout"): + threaded_recursive_size(tmp_path, timeout=0.0) +``` + +- [ ] **Step 2: Run tests and verify the canonical module is missing** + +Run `uv run pytest CH_14_multithreading_and_multiprocessing/exercise_04/test_solution_00.py -vv`. + +Expected: FAIL during import because the canonical module does not exist. + +- [ ] **Step 3: Implement a parent-owned dynamic scheduler** + +Define picklable messages so the parent, not workers, owns the outstanding-work count: + +```python +from dataclasses import dataclass +from pathlib import Path + + +@dataclass(frozen=True) +class ScanResult: + directory: Path + child_directories: tuple[Path, ...] + byte_count: int + + +@dataclass(frozen=True) +class ScanFailure: + directory: Path + error_type: str + message: str +``` + +Implement `_scan_directory(directory: Path) -> ScanResult`, skipping symlinks and sorting entries. Implement: + +```python +def threaded_recursive_size( + directory: Path, + *, + worker_count: int = 4, + timeout: float = 5.0, +) -> int: + """Traverse recursively with persistent raw-thread workers.""" + + +def multiprocessing_recursive_size( + directory: Path, + *, + worker_count: int = 2, + timeout: float = 5.0, + context: multiprocessing.context.BaseContext | None = None, +) -> int: + """Traverse recursively with persistent spawn-process workers.""" +``` + +For both variants: + +1. enqueue the root and set `outstanding = 1`; +2. wait for `ScanResult | ScanFailure` with the supplied timeout; +3. decrement for the completed directory and increment for each newly queued directory; +4. stop when `outstanding == 0`; +5. send one stop sentinel per worker and join every worker with the same timeout; +6. raise `TimeoutError` for a missing result and `WorkerScanError` for `ScanFailure`; +7. terminate only process workers that remain alive during exceptional cleanup. + +- [ ] **Step 4: Verify the recursive packet** + +```bash +uv run pytest CH_14_multithreading_and_multiprocessing/exercise_04/test_solution_00.py -vv +uv run ruff format --check CH_14_multithreading_and_multiprocessing/exercise_04/solution_00.py CH_14_multithreading_and_multiprocessing/exercise_04/test_solution_00.py +uv run ruff check CH_14_multithreading_and_multiprocessing/exercise_04/solution_00.py CH_14_multithreading_and_multiprocessing/exercise_04/test_solution_00.py +uv run pyrefly check CH_14_multithreading_and_multiprocessing/exercise_04/solution_00.py CH_14_multithreading_and_multiprocessing/exercise_04/test_solution_00.py +uv run mypy CH_14_multithreading_and_multiprocessing/exercise_04/solution_00.py CH_14_multithreading_and_multiprocessing/exercise_04/test_solution_00.py +uv run pyright CH_14_multithreading_and_multiprocessing/exercise_04/solution_00.py CH_14_multithreading_and_multiprocessing/exercise_04/test_solution_00.py +``` + +Expected: all commands exit 0; pytest reports 3 passed with no remaining child process. + +- [ ] **Step 5: Commit** + +```bash +git add CH_14_multithreading_and_multiprocessing/exercise_04/solution_00.py CH_14_multithreading_and_multiprocessing/exercise_04/test_solution_00.py +git commit -m "feat: add recursive queue traversal exercise" +``` + +### Task 3: Persistent multiprocessing queue pool + +**Files:** +- Modify: `CH_14_multithreading_and_multiprocessing/exercise_05/solution_00.py` +- Create: `CH_14_multithreading_and_multiprocessing/exercise_05/test_solution_00.py` + +- [ ] **Step 1: Write tests proving persistence, order, exceptions, lifecycle, and cleanup** + +```python +from __future__ import annotations + +import multiprocessing + +import pytest + +from .solution_00 import PersistentWorkerPool, WorkerError, square + + +@pytest.mark.timeout(20) +def test_pool_reuses_workers_and_preserves_map_order() -> None: + with PersistentWorkerPool(square, worker_count=2) as pool: + first_pids: tuple[int, ...] = pool.worker_pids + assert pool.map([3, 1, 2], timeout=5.0) == [9, 1, 4] + assert pool.map([4], timeout=5.0) == [16] + assert pool.worker_pids == first_pids + + +def test_pool_propagates_worker_failure() -> None: + with PersistentWorkerPool(square, worker_count=1) as pool: + task_id: int = pool.submit("bad") # type: ignore[arg-type] + with pytest.raises(WorkerError, match="TypeError"): + pool.result(task_id, timeout=5.0) + + +def test_closed_pool_rejects_submission() -> None: + pool: PersistentWorkerPool[int, int] = PersistentWorkerPool(square, worker_count=1) + pool.close() + with pytest.raises(RuntimeError, match="closed"): + pool.submit(2) + assert all(process.pid is None or not process.is_alive() for process in pool.workers) +``` + +- [ ] **Step 2: Run tests and observe failure of the cross-reference-only answer** + +Run `uv run pytest CH_14_multithreading_and_multiprocessing/exercise_05/test_solution_00.py -vv`. + +Expected: FAIL because no pool class or runnable behavior exists. + +- [ ] **Step 3: Implement the generic persistent pool** + +Use `TypeVar`, `Generic`, and these public interfaces: + +```python +class WorkerError(RuntimeError): + """A task raised in a worker process.""" + + +class PersistentWorkerPool(Generic[InputT, ResultT]): + def __init__( + self, + function: Callable[[InputT], ResultT], + *, + worker_count: int = 2, + context: multiprocessing.context.BaseContext | None = None, + shutdown_timeout: float = 5.0, + ) -> None: + """Start persistent queue workers for one spawn-picklable function.""" + + @property + def workers(self) -> tuple[multiprocessing.Process, ...]: + """Return workers for lifecycle inspection.""" + + @property + def worker_pids(self) -> tuple[int, ...]: + """Return started worker PIDs.""" + + def submit(self, value: InputT) -> int: + """Queue one value and return a monotonic task ID.""" + + def result(self, task_id: int, *, timeout: float = 5.0) -> ResultT: + """Return one result, retaining out-of-order replies in a local cache.""" + + def map(self, values: Iterable[InputT], *, timeout: float = 5.0) -> list[ResultT]: + """Return results in input order.""" + + def close(self) -> None: + """Idempotently send sentinels and join workers.""" +``` + +Back the API with frozen `Task`, `TaskSuccess`, and `TaskFailure` dataclasses. `_worker_main` catches `Exception`, sends the exception class name and message, and continues serving later tasks. `result` raises `TimeoutError` on `Queue.get(timeout=...)`. `close` joins each process and raises `TimeoutError` after terminating a worker that does not stop. Add top-level `square(value: int) -> int` for the demonstration and spawn-safe tests. + +- [ ] **Step 4: Verify** + +```bash +uv run pytest CH_14_multithreading_and_multiprocessing/exercise_05/test_solution_00.py -vv +uv run ruff format --check CH_14_multithreading_and_multiprocessing/exercise_05 +uv run ruff check CH_14_multithreading_and_multiprocessing/exercise_05 +uv run pyrefly check CH_14_multithreading_and_multiprocessing/exercise_05 +uv run mypy CH_14_multithreading_and_multiprocessing/exercise_05 +uv run pyright CH_14_multithreading_and_multiprocessing/exercise_05 +``` + +Expected: all commands exit 0; pytest reports 3 passed, worker PIDs remain stable inside the context, and all workers are stopped afterward. + +- [ ] **Step 5: Commit** + +```bash +git add CH_14_multithreading_and_multiprocessing/exercise_05/solution_00.py CH_14_multithreading_and_multiprocessing/exercise_05/test_solution_00.py +git commit -m "feat: add persistent queue worker pool" +``` + +### Task 4: Allowlisted RPC over persistent queues + +**Files:** +- Modify: `CH_14_multithreading_and_multiprocessing/exercise_06/solution_00.py` +- Create: `CH_14_multithreading_and_multiprocessing/exercise_06/test_solution_00.py` + +- [ ] **Step 1: Write failing safe-RPC tests** + +```python +from __future__ import annotations + +import pytest + +from .solution_00 import RemoteCallError, RpcWorkerPool, UnknownMethodError + + +def test_rpc_calls_registered_methods() -> None: + with RpcWorkerPool(worker_count=2) as rpc: + assert rpc.call("add", 2, 5, timeout=5.0) == 7 + assert rpc.call("repeat", "ab", count=3, timeout=5.0) == "ababab" + + +def test_rpc_rejects_unknown_method_without_killing_worker() -> None: + with RpcWorkerPool(worker_count=1) as rpc: + with pytest.raises(UnknownMethodError, match="missing"): + rpc.call("missing", timeout=5.0) + assert rpc.call("add", 1, 1, timeout=5.0) == 2 + + +def test_rpc_propagates_argument_errors() -> None: + with RpcWorkerPool(worker_count=1) as rpc: + with pytest.raises(RemoteCallError, match="TypeError"): + rpc.call("add", "one", 2, timeout=5.0) +``` + +- [ ] **Step 2: Run tests and verify the current unsafe worker fails** + +Expected: FAIL because the existing worker deadlocks on unknown methods, has no result channel, and terminates children rather than shutting down. + +- [ ] **Step 3: Implement request IDs, allowlisting, replies, and cooperative shutdown** + +Expose: + +```python +def add(left: int, right: int) -> int: + return left + right + + +def repeat(value: str, *, count: int) -> str: + if count < 0: + raise ValueError("count must not be negative") + return value * count + + +RPC_METHODS: Mapping[str, Callable[..., object]] = MappingProxyType( + {"add": add, "repeat": repeat} +) + + +class RpcWorkerPool: + def call( + self, + method: str, + *args: object, + timeout: float = 5.0, + **kwargs: object, + ) -> object: + """Invoke one allowlisted method and return its value.""" +``` + +Use frozen `RpcRequest`, `RpcSuccess`, `RpcFailure`, and `StopRequest` messages and a request-ID cache for out-of-order responses. Workers look up names only in `RPC_METHODS`; they never use `eval`, `getattr`, imports, or client-supplied callables. Unknown names return a distinct failure kind mapped to `UnknownMethodError`; method exceptions map to `RemoteCallError`. Context-manager cleanup sends sentinels, joins with a timeout, and checks nonzero exit codes. + +- [ ] **Step 4: Verify** + +```bash +uv run pytest CH_14_multithreading_and_multiprocessing/exercise_06/test_solution_00.py -vv +uv run ruff format --check CH_14_multithreading_and_multiprocessing/exercise_06 +uv run ruff check CH_14_multithreading_and_multiprocessing/exercise_06 +uv run pyrefly check CH_14_multithreading_and_multiprocessing/exercise_06 +uv run mypy CH_14_multithreading_and_multiprocessing/exercise_06 +uv run pyright CH_14_multithreading_and_multiprocessing/exercise_06 +``` + +Expected: every command exits 0; pytest reports 3 passed and the same worker handles a valid call after an unknown method. + +- [ ] **Step 5: Commit** + +```bash +git add CH_14_multithreading_and_multiprocessing/exercise_06/solution_00.py CH_14_multithreading_and_multiprocessing/exercise_06/test_solution_00.py +git commit -m "feat: add allowlisted queue RPC exercise" +``` + +### Task 5: Bounded parallel merge sort + +**Files:** +- Modify: `CH_14_multithreading_and_multiprocessing/exercise_07/solution_00.py` +- Create: `CH_14_multithreading_and_multiprocessing/exercise_07/test_solution_00.py` + +- [ ] **Step 1: Write failing merge, split, and parallel-sort tests** + +```python +from __future__ import annotations + +import multiprocessing + +import pytest + +from .solution_00 import merge, merge_sort, parallel_merge_sort, split + + +@pytest.mark.parametrize( + ("left", "right", "expected"), + [ + ([], [], []), + ([1, 3], [2, 4], [1, 2, 3, 4]), + ([1, 1], [1], [1, 1, 1]), + ], +) +def test_merge(left: list[int], right: list[int], expected: list[int]) -> None: + assert merge(left, right) == expected + + +def test_split_never_creates_empty_chunks() -> None: + assert split([], 4) == [] + assert split([3, 2], 8) == [[3], [2]] + + +@pytest.mark.timeout(20) +def test_parallel_merge_sort_matches_builtin() -> None: + values: list[int] = [5, -1, 5, 0, 9, 2, -7] + assert parallel_merge_sort( + values, + max_workers=3, + context=multiprocessing.get_context("spawn"), + ) == sorted(values) + assert values == [5, -1, 5, 0, 9, 2, -7] + + +def test_sequential_merge_sort_handles_boundaries() -> None: + assert merge_sort([]) == [] + assert merge_sort([1]) == [1] +``` + +- [ ] **Step 2: Run tests and expose the zero-chunk and empty-input bugs** + +Run `uv run pytest CH_14_multithreading_and_multiprocessing/exercise_07/test_solution_00.py -vv`. + +Expected: FAIL for empty input and when worker count exceeds input length. + +- [ ] **Step 3: Implement deterministic bounded parallelism** + +Preserve `merge`, `split`, `merge_sort`, and `multiprocessing_merge_sort`, and add: + +```python +def parallel_merge_sort( + values: Sequence[int], + *, + max_workers: int | None = None, + context: multiprocessing.context.BaseContext | None = None, +) -> list[int]: + if max_workers is not None and max_workers < 1: + raise ValueError("max_workers must be positive") + if not values: + return [] + requested_workers: int = max_workers or multiprocessing.cpu_count() + worker_count: int = min(requested_workers, len(values)) + chunks: list[list[int]] = split(values, worker_count) + process_context: multiprocessing.context.BaseContext = ( + context if context is not None else multiprocessing.get_context("spawn") + ) + with concurrent.futures.ProcessPoolExecutor( + max_workers=worker_count, + mp_context=process_context, + ) as executor: + sorted_chunks: list[list[int]] = list(executor.map(merge_sort, chunks)) + while len(sorted_chunks) > 1: + pairs: list[tuple[list[int], list[int]]] = list( + zip(sorted_chunks[::2], sorted_chunks[1::2]) + ) + merged: list[list[int]] = list(executor.map(_merge_pair, pairs)) + if len(sorted_chunks) % 2: + merged.append(sorted_chunks[-1]) + sorted_chunks = merged + return sorted_chunks[0] + + +def multiprocessing_merge_sort(data: Sequence[int]) -> list[int]: + return parallel_merge_sort(data) +``` + +`split` distributes the remainder across the first chunks and rejects non-positive sizes. `merge` must not mutate either input. `_merge_pair` is top-level and spawn-picklable. + +- [ ] **Step 4: Verify** + +```bash +uv run pytest CH_14_multithreading_and_multiprocessing/exercise_07/test_solution_00.py -vv +uv run ruff format --check CH_14_multithreading_and_multiprocessing/exercise_07 +uv run ruff check CH_14_multithreading_and_multiprocessing/exercise_07 +uv run pyrefly check CH_14_multithreading_and_multiprocessing/exercise_07 +uv run mypy CH_14_multithreading_and_multiprocessing/exercise_07 +uv run pyright CH_14_multithreading_and_multiprocessing/exercise_07 +``` + +Expected: every command exits 0 and pytest reports 6 parameterized cases/tests passing. + +- [ ] **Step 5: Commit** + +```bash +git add CH_14_multithreading_and_multiprocessing/exercise_07/solution_00.py CH_14_multithreading_and_multiprocessing/exercise_07/test_solution_00.py +git commit -m "feat: add bounded parallel merge sort" +``` + +### Task 6: Per-exercise documentation and chapter links + +**Files:** +- Create: `CH_14_multithreading_and_multiprocessing/exercise_03/README.rst` +- Create: `CH_14_multithreading_and_multiprocessing/exercise_04/README.rst` +- Create: `CH_14_multithreading_and_multiprocessing/exercise_05/README.rst` +- Create: `CH_14_multithreading_and_multiprocessing/exercise_06/README.rst` +- Create: `CH_14_multithreading_and_multiprocessing/exercise_07/README.rst` +- Modify: `CH_14_multithreading_and_multiprocessing/README.rst` + +- [ ] **Step 1: Write all five READMEs** + +Each README repeats its exact chapter question, documents public APIs and boundary choices, lists no third-party runtime dependency, and provides its matching command pair: + +```rst +.. code-block:: console + + uv run python -m CH_14_multithreading_and_multiprocessing.exercise_03.solution_00 + uv run pytest CH_14_multithreading_and_multiprocessing/exercise_03/test_solution_00.py -vv + uv run python -m CH_14_multithreading_and_multiprocessing.exercise_04.solution_00 + uv run pytest CH_14_multithreading_and_multiprocessing/exercise_04/test_solution_00.py -vv + uv run python -m CH_14_multithreading_and_multiprocessing.exercise_05.solution_00 + uv run pytest CH_14_multithreading_and_multiprocessing/exercise_05/test_solution_00.py -vv + uv run python -m CH_14_multithreading_and_multiprocessing.exercise_06.solution_00 + uv run pytest CH_14_multithreading_and_multiprocessing/exercise_06/test_solution_00.py -vv + uv run python -m CH_14_multithreading_and_multiprocessing.exercise_07.solution_00 + uv run pytest CH_14_multithreading_and_multiprocessing/exercise_07/test_solution_00.py -vv +``` + +Exercise 3 and 4 READMEs explicitly identify their historical specialized files as retained alternatives. Exercise 5 documents persistent PIDs and exception behavior. Exercise 6 documents the allowlist security boundary. Exercise 7 documents integer-only input, non-mutation, and bounded workers. + +Use relevant immutable references: + +- exercises 3-5: + `https://github.com/mastering-python/code_2/blob/a012f6ce3f5bf11f5bf380d1c4e7f743355adb89/CH_14_multithreading_and_multiprocessing/T_07_thread_batch_processing.py` + and `T_08_process_batch_processing.py`; +- exercise 5 and 7: + `T_09_multiprocessing_pool.py`; +- exercise 6: + `T_17_remote_multiprocessing/server.py` and `functions.py`. + +- [ ] **Step 2: Link chapter items 3 through 7** + +Change only their RST presentation: retain exact question text and link each item to `exercise_03/` through `exercise_07/`. Accommodate concurrent edits to items 1 and 2. + +- [ ] **Step 3: Run packet verification** + +```bash +uv run pytest CH_14_multithreading_and_multiprocessing/exercise_03 CH_14_multithreading_and_multiprocessing/exercise_04 CH_14_multithreading_and_multiprocessing/exercise_05 CH_14_multithreading_and_multiprocessing/exercise_06 CH_14_multithreading_and_multiprocessing/exercise_07 -vv +uv run ruff format --check CH_14_multithreading_and_multiprocessing +uv run ruff check CH_14_multithreading_and_multiprocessing +uv run pyrefly check CH_14_multithreading_and_multiprocessing/exercise_03/solution_00.py CH_14_multithreading_and_multiprocessing/exercise_04/solution_00.py CH_14_multithreading_and_multiprocessing/exercise_05/solution_00.py CH_14_multithreading_and_multiprocessing/exercise_06/solution_00.py CH_14_multithreading_and_multiprocessing/exercise_07/solution_00.py +uv run mypy CH_14_multithreading_and_multiprocessing/exercise_03/solution_00.py CH_14_multithreading_and_multiprocessing/exercise_04/solution_00.py CH_14_multithreading_and_multiprocessing/exercise_05/solution_00.py CH_14_multithreading_and_multiprocessing/exercise_06/solution_00.py CH_14_multithreading_and_multiprocessing/exercise_07/solution_00.py +uv run pyright CH_14_multithreading_and_multiprocessing/exercise_03/solution_00.py CH_14_multithreading_and_multiprocessing/exercise_04/solution_00.py CH_14_multithreading_and_multiprocessing/exercise_05/solution_00.py CH_14_multithreading_and_multiprocessing/exercise_06/solution_00.py CH_14_multithreading_and_multiprocessing/exercise_07/solution_00.py +``` + +Expected: every command exits 0; no worker remains in `multiprocessing.active_children()`. + +- [ ] **Step 4: Confirm historical answers are byte-for-byte unchanged** + +Run: + +```bash +git diff --exit-code HEAD -- CH_14_multithreading_and_multiprocessing/exercise_03/threading_solution_00.py CH_14_multithreading_and_multiprocessing/exercise_03/multiprocessing_solution_00.py CH_14_multithreading_and_multiprocessing/exercise_04/threading_solution_00.py CH_14_multithreading_and_multiprocessing/exercise_04/multiprocessing_solution_00.py +``` + +Expected: exit 0 with no output. + +- [ ] **Step 5: Commit documentation** + +```bash +git add CH_14_multithreading_and_multiprocessing/README.rst CH_14_multithreading_and_multiprocessing/exercise_03/README.rst CH_14_multithreading_and_multiprocessing/exercise_04/README.rst CH_14_multithreading_and_multiprocessing/exercise_05/README.rst CH_14_multithreading_and_multiprocessing/exercise_06/README.rst CH_14_multithreading_and_multiprocessing/exercise_07/README.rst +git commit -m "docs: document chapter 14 worker exercises" +``` + +## Packet acceptance + +- [ ] Run the packet on Python 3.10 and Python 3.14. +- [ ] Confirm every queue operation and process join has a bounded timeout. +- [ ] Confirm errors cross worker boundaries and no implementation silently drops them. +- [ ] Confirm no fixed ports, network access, or external files are required. +- [ ] Confirm the four historical alternatives remain unchanged. diff --git a/docs/superpowers/plans/2026-07-28-ch15-ch16-scientific-nlp.md b/docs/superpowers/plans/2026-07-28-ch15-ch16-scientific-nlp.md new file mode 100644 index 0000000..52ceb6b --- /dev/null +++ b/docs/superpowers/plans/2026-07-28-ch15-ch16-scientific-nlp.md @@ -0,0 +1,569 @@ +# Chapters 15 and 16 Scientific Plotting and NLP Implementation + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add deterministic Datashader plotting, a headlessly executable interactive notebook, and offline rule-based NLP extraction. + +**Architecture:** Chapter 15 exercise 1 owns a tiny local point fixture and pure load/aggregate/render functions. Exercise 2 reuses those functions behind ipywidgets and a committed notebook. Chapter 16 uses `spacy.blank("en")`, a sentencizer, and an `EntityRuler` over committed chapter-summary text, so no model or dataset download occurs. + +**Tech Stack:** Python 3.10, pandas, Datashader, Pillow, ipywidgets, nbformat, nbclient, ipykernel, spaCy, pytest, pytest-timeout, uv. + +--- + +## Coordinator dependency handoff + +Do not modify shared `pyproject.toml` or `uv.lock` in this packet. Tell the coordinator that: + +- `scientific` needs pandas, Datashader, Pillow, ipywidgets, nbformat, nbclient, ipykernel, and jupyter-client; +- `nlp` needs spaCy but no language-model wheel. + +## File map + +- Create `exercise_01/` and `exercise_02/` under `CH_15_scientific_python/`, each with the directory-contract files. +- Add `CH_15_scientific_python/exercise_01/points.csv`. +- Add `CH_15_scientific_python/exercise_02/datashader_interactive.ipynb`. +- Create `CH_16_machine_learning/exercise_01/` with the directory-contract files and `chapter_summary.txt`. +- Modify the Chapter 15 and 16 READMEs only to link exercises. + +### Task 1: Deterministic headless Datashader plot + +**Files:** +- Create: `CH_15_scientific_python/exercise_01/__init__.py` +- Create: `CH_15_scientific_python/exercise_01/points.csv` +- Create: `CH_15_scientific_python/exercise_01/solution_00.py` +- Create: `CH_15_scientific_python/exercise_01/test_solution_00.py` + +- [ ] **Step 1: Add the fixed local data fixture** + +Write exactly: + +```csv +x,y,category +-1.0,-1.0,a +-0.5,0.5,a +0.0,0.0,b +0.5,-0.5,b +1.0,1.0,c +1.0,1.0,c +``` + +- [ ] **Step 2: Write failing load, aggregate, render, and validation tests** + +```python +from __future__ import annotations + +from pathlib import Path + +import pytest + +from .solution_00 import aggregate_points, load_points, render_points, save_plot + + +FIXTURE: Path = Path(__file__).with_name("points.csv") + + +def test_load_and_aggregate_fixed_points() -> None: + frame = load_points(FIXTURE) + aggregate = aggregate_points(frame, width=8, height=6) + assert list(frame.columns) == ["x", "y", "category"] + assert aggregate.shape == (6, 8) + assert int(aggregate.sum()) == 6 + + +def test_render_is_deterministic() -> None: + frame = load_points(FIXTURE) + first = render_points(frame, width=16, height=12).to_pil() + second = render_points(frame, width=16, height=12).to_pil() + assert first.size == (16, 12) + assert first.tobytes() == second.tobytes() + + +def test_save_plot_writes_png(tmp_path: Path) -> None: + destination: Path = tmp_path / "plot.png" + save_plot(FIXTURE, destination, width=16, height=12) + assert destination.read_bytes().startswith(b"\x89PNG\r\n\x1a\n") + + +@pytest.mark.parametrize(("width", "height"), [(0, 8), (8, 0), (-1, 8)]) +def test_dimensions_must_be_positive(width: int, height: int) -> None: + frame = load_points(FIXTURE) + with pytest.raises(ValueError, match="positive"): + aggregate_points(frame, width=width, height=height) +``` + +- [ ] **Step 3: Run the tests and verify the implementation is absent** + +Run `uv run --group scientific pytest CH_15_scientific_python/exercise_01/test_solution_00.py -vv`. + +Expected: FAIL during import because `solution_00.py` does not exist. + +- [ ] **Step 4: Implement the plotting pipeline** + +Expose this API and fixed rendering choices: + +```python +from __future__ import annotations + +from pathlib import Path + +import datashader as ds +import datashader.transfer_functions as tf +import pandas as pd +import xarray as xr + + +X_RANGE: tuple[float, float] = (-1.1, 1.1) +Y_RANGE: tuple[float, float] = (-1.1, 1.1) +COLOR_MAP: tuple[str, ...] = ("#0b132b", "#3a86ff", "#ffbe0b") + + +def load_points(path: Path) -> pd.DataFrame: + frame: pd.DataFrame = pd.read_csv(path) + required: set[str] = {"x", "y", "category"} + if set(frame.columns) != required: + raise ValueError("point data must contain x, y, and category columns") + if frame.empty: + raise ValueError("point data must not be empty") + return frame.loc[:, ["x", "y", "category"]] + + +def aggregate_points( + frame: pd.DataFrame, + *, + width: int = 256, + height: int = 256, +) -> xr.DataArray: + if width < 1 or height < 1: + raise ValueError("plot dimensions must be positive") + canvas: ds.Canvas = ds.Canvas( + plot_width=width, + plot_height=height, + x_range=X_RANGE, + y_range=Y_RANGE, + ) + return canvas.points(frame, "x", "y", agg=ds.count()) + + +def render_points( + frame: pd.DataFrame, + *, + width: int = 256, + height: int = 256, +) -> tf.Image: + aggregate: xr.DataArray = aggregate_points(frame, width=width, height=height) + return tf.shade(aggregate, cmap=list(COLOR_MAP), how="linear") + + +def save_plot( + source: Path, + destination: Path, + *, + width: int = 256, + height: int = 256, +) -> Path: + image: tf.Image = render_points( + load_points(source), + width=width, + height=height, + ) + destination.parent.mkdir(parents=True, exist_ok=True) + image.to_pil().save(destination, format="PNG") + return destination + + +def main() -> None: + source: Path = Path(__file__).with_name("points.csv") + destination: Path = Path(__file__).with_name("datashader_plot.png") + print(save_plot(source, destination)) + + +if __name__ == "__main__": + main() +``` + +If a type checker lacks Datashader return types, add one narrow suppression on the exact `Canvas.points` or `tf.shade` line with the checker error code and a comment; do not suppress missing imports globally. + +- [ ] **Step 5: Run focused verification and commit** + +```bash +uv run --group scientific pytest CH_15_scientific_python/exercise_01/test_solution_00.py -vv +uv run ruff format --check CH_15_scientific_python/exercise_01 +uv run ruff check CH_15_scientific_python/exercise_01 +uv run pyrefly check CH_15_scientific_python/exercise_01 +uv run mypy CH_15_scientific_python/exercise_01 +uv run pyright CH_15_scientific_python/exercise_01 +git add CH_15_scientific_python/exercise_01 +git commit -m "feat: add deterministic datashader exercise" +``` + +Expected: all quality commands exit 0 and pytest reports 6 parameterized cases/tests passing. + +### Task 2: Interactive and headlessly executable notebook + +**Files:** +- Create: `CH_15_scientific_python/exercise_02/__init__.py` +- Create: `CH_15_scientific_python/exercise_02/solution_00.py` +- Create: `CH_15_scientific_python/exercise_02/datashader_interactive.ipynb` +- Create: `CH_15_scientific_python/exercise_02/test_solution_00.py` + +- [ ] **Step 1: Write failing widget and notebook execution tests** + +```python +from __future__ import annotations + +from pathlib import Path + +import ipywidgets as widgets +import nbformat +import pandas as pd +import pytest +from nbclient import NotebookClient +from nbformat import NotebookNode + +from .solution_00 import build_interactive_plot, render_resolution + + +POINTS: Path = Path(__file__).parents[1] / "exercise_01" / "points.csv" +NOTEBOOK: Path = Path(__file__).with_name("datashader_interactive.ipynb") +REPOSITORY: Path = Path(__file__).parents[2] + + +def test_render_resolution_changes_image_dimensions() -> None: + frame: pd.DataFrame = pd.read_csv(POINTS) + assert render_resolution(frame, 32).to_pil().size == (32, 32) + assert render_resolution(frame, 64).to_pil().size == (64, 64) + + +def test_widget_has_bounded_resolution_control() -> None: + frame: pd.DataFrame = pd.read_csv(POINTS) + widget: widgets.VBox = build_interactive_plot(frame) + assert isinstance(widget.children[0], widgets.IntSlider) + slider: widgets.IntSlider = widget.children[0] + assert (slider.min, slider.max, slider.step, slider.value) == (32, 256, 32, 64) + + +@pytest.mark.timeout(90) +def test_notebook_executes_headlessly(tmp_path: Path) -> None: + notebook: NotebookNode = nbformat.read(NOTEBOOK, as_version=4) + client: NotebookClient = NotebookClient( + notebook, + timeout=60, + kernel_name="python3", + ) + executed: NotebookNode = client.execute(cwd=str(REPOSITORY)) + assert all( + output.get("output_type") != "error" + for cell in executed.cells + for output in cell.get("outputs", []) + ) +``` + +- [ ] **Step 2: Run tests and verify both artifacts are missing** + +Run `uv run --group scientific pytest CH_15_scientific_python/exercise_02/test_solution_00.py -vv`. + +Expected: FAIL during import because the interactive API and notebook do not exist. + +- [ ] **Step 3: Implement the widget adapter** + +```python +from __future__ import annotations + +import datashader.transfer_functions as tf +import ipywidgets as widgets +import pandas as pd + +from CH_15_scientific_python.exercise_01.solution_00 import render_points + + +def render_resolution(frame: pd.DataFrame, resolution: int) -> tf.Image: + if resolution < 1: + raise ValueError("resolution must be positive") + return render_points(frame, width=resolution, height=resolution) + + +def build_interactive_plot(frame: pd.DataFrame) -> widgets.VBox: + slider: widgets.IntSlider = widgets.IntSlider( + value=64, + min=32, + max=256, + step=32, + description="Resolution", + ) + output: widgets.Output = widgets.Output() + + def update(change: dict[str, object]) -> None: + resolution_value: object = change["new"] + if not isinstance(resolution_value, int): + raise TypeError("resolution widget returned a non-integer") + with output: + output.clear_output(wait=True) + display(render_resolution(frame, resolution_value).to_pil()) + + slider.observe(update, names="value") + with output: + display(render_resolution(frame, slider.value).to_pil()) + return widgets.VBox((slider, output)) +``` + +Import `display` from `IPython.display`. The nested callback has an explicit `dict[str, object]` parameter and `None` return annotation in the actual file. + +- [ ] **Step 4: Create the notebook with exact reproducible cells** + +Create nbformat 4 JSON with a Python 3 kernelspec and these code cells: + +```python +from pathlib import Path +import pandas as pd +from IPython.display import display +from CH_15_scientific_python.exercise_02.solution_00 import build_interactive_plot +``` + +```python +points = Path("CH_15_scientific_python/exercise_01/points.csv") +frame = pd.read_csv(points) +widget = build_interactive_plot(frame) +display(widget) +``` + +```python +assert tuple(frame.shape) == (6, 3) +assert widget.children[0].value == 64 +``` + +Clear execution counts and outputs before committing. Do not embed a generated PNG or widget state. + +- [ ] **Step 5: Run verification and commit** + +```bash +uv run --group scientific pytest CH_15_scientific_python/exercise_02/test_solution_00.py -vv +uv run ruff format --check CH_15_scientific_python/exercise_02/solution_00.py CH_15_scientific_python/exercise_02/test_solution_00.py +uv run ruff check CH_15_scientific_python/exercise_02/solution_00.py CH_15_scientific_python/exercise_02/test_solution_00.py +uv run pyrefly check CH_15_scientific_python/exercise_02/solution_00.py CH_15_scientific_python/exercise_02/test_solution_00.py +uv run mypy CH_15_scientific_python/exercise_02/solution_00.py CH_15_scientific_python/exercise_02/test_solution_00.py +uv run pyright CH_15_scientific_python/exercise_02/solution_00.py CH_15_scientific_python/exercise_02/test_solution_00.py +git diff --exit-code -- CH_15_scientific_python/exercise_02/datashader_interactive.ipynb +``` + +The pytest notebook test executes an in-memory copy through nbclient, so the committed notebook remains clean. Expected: pytest reports 3 passed, the notebook has no error output, static checks exit 0, and `git diff` emits no notebook changes. + +```bash +git add CH_15_scientific_python/exercise_02 +git commit -m "feat: add interactive datashader notebook" +``` + +### Task 3: Deterministic offline entity extraction + +**Files:** +- Create: `CH_16_machine_learning/exercise_01/__init__.py` +- Create: `CH_16_machine_learning/exercise_01/chapter_summary.txt` +- Create: `CH_16_machine_learning/exercise_01/solution_00.py` +- Create: `CH_16_machine_learning/exercise_01/test_solution_00.py` + +- [ ] **Step 1: Add local chapter-summary text** + +Use this deterministic, attributed exercise fixture: + +```text +Machine learning turns examples into predictions instead of encoding every rule by hand. +Chapter 16 surveys image processing, natural language processing, neural networks, and model selection in Python. +The natural language processing example uses spaCy to identify named concepts in local text. +Reproducible exercises keep their data local and avoid downloading models during a test run. +``` + +- [ ] **Step 2: Write failing extraction tests** + +```python +from __future__ import annotations + +from pathlib import Path + +import pytest + +from .solution_00 import ExtractedEntity, extract_entities, load_summary + + +SUMMARY: Path = Path(__file__).with_name("chapter_summary.txt") + + +def test_extracts_expected_entities_in_source_order() -> None: + entities: list[ExtractedEntity] = extract_entities(load_summary(SUMMARY)) + assert [(entity.text, entity.label) for entity in entities] == [ + ("Machine learning", "METHOD"), + ("Chapter 16", "CHAPTER"), + ("natural language processing", "METHOD"), + ("Python", "LANGUAGE"), + ("natural language processing", "METHOD"), + ("spaCy", "LIBRARY"), + ] + + +def test_extraction_is_repeatable() -> None: + text: str = load_summary(SUMMARY) + assert extract_entities(text) == extract_entities(text) + + +def test_empty_text_returns_no_entities() -> None: + assert extract_entities("") == [] + + +def test_load_summary_rejects_blank_fixture(tmp_path: Path) -> None: + path: Path = tmp_path / "blank.txt" + path.write_text(" \n", encoding="utf-8") + with pytest.raises(ValueError, match="blank"): + load_summary(path) +``` + +- [ ] **Step 3: Run tests and verify the module is absent** + +Run `uv run --group nlp pytest CH_16_machine_learning/exercise_01/test_solution_00.py -vv`. + +Expected: FAIL during import because the NLP implementation does not exist. + +- [ ] **Step 4: Implement a local blank spaCy pipeline** + +```python +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path + +import spacy +from spacy.language import Language +from spacy.tokens import Doc + + +@dataclass(frozen=True) +class ExtractedEntity: + text: str + label: str + sentence_index: int + + +PATTERNS: tuple[dict[str, object], ...] = ( + {"label": "METHOD", "pattern": [{"LOWER": "machine"}, {"LOWER": "learning"}]}, + { + "label": "METHOD", + "pattern": [ + {"LOWER": "natural"}, + {"LOWER": "language"}, + {"LOWER": "processing"}, + ], + }, + {"label": "CHAPTER", "pattern": [{"LOWER": "chapter"}, {"TEXT": "16"}]}, + {"label": "LANGUAGE", "pattern": "Python"}, + {"label": "LIBRARY", "pattern": "spaCy"}, +) + + +def build_pipeline() -> Language: + pipeline: Language = spacy.blank("en") + pipeline.add_pipe("sentencizer") + ruler = pipeline.add_pipe("entity_ruler") + ruler.add_patterns(list(PATTERNS)) + return pipeline + + +def load_summary(path: Path) -> str: + text: str = path.read_text(encoding="utf-8").strip() + if not text: + raise ValueError("chapter summary must not be blank") + return text + + +def extract_entities(text: str) -> list[ExtractedEntity]: + if not text: + return [] + document: Doc = build_pipeline()(text) + sentence_numbers: dict[int, int] = { + token.i: sentence_index + for sentence_index, sentence in enumerate(document.sents) + for token in sentence + } + return [ + ExtractedEntity( + text=entity.text, + label=entity.label_, + sentence_index=sentence_numbers[entity.start], + ) + for entity in document.ents + ] +``` + +Add a guarded `main()` that prints `label`, `text`, and sentence index. Do not call `spacy.load`, `spacy.cli.download`, or a network API. + +- [ ] **Step 5: Verify and commit** + +```bash +uv run --group nlp pytest CH_16_machine_learning/exercise_01/test_solution_00.py -vv +uv run ruff format --check CH_16_machine_learning/exercise_01 +uv run ruff check CH_16_machine_learning/exercise_01 +uv run pyrefly check CH_16_machine_learning/exercise_01 +uv run mypy CH_16_machine_learning/exercise_01 +uv run pyright CH_16_machine_learning/exercise_01 +git add CH_16_machine_learning/exercise_01 +git commit -m "feat: add deterministic chapter NLP extraction" +``` + +Expected: all commands exit 0 and pytest reports 4 passed. + +### Task 4: Exercise READMEs and chapter indexes + +**Files:** +- Create: `CH_15_scientific_python/exercise_01/README.rst` +- Create: `CH_15_scientific_python/exercise_02/README.rst` +- Modify: `CH_15_scientific_python/README.rst` +- Create: `CH_16_machine_learning/exercise_01/README.rst` +- Modify: `CH_16_machine_learning/README.rst` + +- [ ] **Step 1: Document Chapter 15 exercise 1** + +Repeat “Create a datashader plot.” exactly. Explain fixed ranges, `ds.count`, fixed color map, local six-row data, PNG output, and the `scientific` dependency group. Include run/test commands and: + +`https://github.com/mastering-python/code_2/blob/a012f6ce3f5bf11f5bf380d1c4e7f743355adb89/CH_15_scientific_python/T_17_datashader.ipynb` + +- [ ] **Step 2: Document Chapter 15 exercise 2** + +Repeat the exact interactive-notebook question. Explain the bounded resolution slider, reuse of exercise 1, headless nbclient execution, and clean committed outputs. Include: + +```console +uv run --group scientific jupyter lab CH_15_scientific_python/exercise_02/datashader_interactive.ipynb +uv run --group scientific pytest CH_15_scientific_python/exercise_02/test_solution_00.py -vv +``` + +Cite the same immutable Datashader notebook. + +- [ ] **Step 3: Document Chapter 16 exercise 1** + +Repeat the exact question. Explain rule-based entity extraction, fixed local summary, deterministic order, empty-input behavior, spaCy dependency, and why no `en_core_web_sm` model is downloaded. Include run/test commands and: + +`https://github.com/mastering-python/code_2/blob/a012f6ce3f5bf11f5bf380d1c4e7f743355adb89/CH_16_machine_learning/T_03_spacy_extract.rst` + +- [ ] **Step 4: Link chapter indexes and run packet verification** + +Retain the exact question text and link Chapter 15 items to `exercise_01/` and `exercise_02/`, and Chapter 16 item 1 to `exercise_01/`. + +```bash +uv run --all-groups pytest CH_15_scientific_python CH_16_machine_learning -vv +uv run ruff format --check CH_15_scientific_python CH_16_machine_learning +uv run ruff check CH_15_scientific_python CH_16_machine_learning +uv run pyrefly check CH_15_scientific_python CH_16_machine_learning +uv run mypy CH_15_scientific_python CH_16_machine_learning +uv run pyright CH_15_scientific_python CH_16_machine_learning +``` + +Expected: every command exits 0 without network access. + +- [ ] **Step 5: Commit documentation** + +```bash +git add CH_15_scientific_python/README.rst CH_15_scientific_python/exercise_01/README.rst CH_15_scientific_python/exercise_02/README.rst CH_16_machine_learning/README.rst CH_16_machine_learning/exercise_01/README.rst +git commit -m "docs: document scientific and NLP exercises" +``` + +## Packet acceptance + +- [ ] Execute the notebook from a clean checkout with all outputs initially cleared. +- [ ] Confirm tests do not access the network, external datasets, or downloaded spaCy models. +- [ ] Confirm the PNG render is deterministic on Python 3.10 and 3.14. +- [ ] Confirm all third-party untyped boundaries use typed local values or narrow checker-specific suppressions. diff --git a/docs/superpowers/plans/2026-07-28-ch17-native.md b/docs/superpowers/plans/2026-07-28-ch17-native.md new file mode 100644 index 0000000..28f26b6 --- /dev/null +++ b/docs/superpowers/plans/2026-07-28-ch17-native.md @@ -0,0 +1,504 @@ +# Chapter 17 Native Interoperability Implementation + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Implement and cross-check ctypes, CFFI, and CPython-extension `qsort` paths, then implement a native integer sum that detects conversion and cumulative range failures. + +**Architecture:** A chapter helper compiles a named C source into a temporary directory and imports the resulting extension without writing build artifacts into the repository. Exercise 1 supplies three independent `qsort` paths with one common integer contract. Exercise 2 uses a dedicated C loop with checked `PyLong_AsLongLong` conversion and pre-addition overflow guards. + +**Tech Stack:** Python 3.10 C API, a system C compiler, ctypes, CFFI ABI mode, setuptools `build_ext`, pytest, pytest-timeout, uv. + +--- + +## Coordinator dependency handoff + +Do not edit shared project configuration. Confirm the coordinator's `native` group contains CFFI, setuptools, and wheel. CI runners must have a working C compiler and Python development headers. + +## File map + +- Create `CH_17_c_and_cpp_extensions/native_build.py`: typed temporary extension builder shared by both exercises. +- Create complete exercise directories `exercise_01/` and `exercise_02/`. +- Add `exercise_01/native_qsort.c` and `exercise_02/safe_sum.c`. +- Modify `CH_17_c_and_cpp_extensions/README.rst` only to link the two questions. + +### Task 1: Temporary native-extension builder + +**Files:** +- Create: `CH_17_c_and_cpp_extensions/native_build.py` +- Create: `CH_17_c_and_cpp_extensions/test_native_build.py` + +- [ ] **Step 1: Write a failing test using a minimal C extension** + +```python +from __future__ import annotations + +from pathlib import Path +from types import ModuleType + +from .native_build import build_extension + + +def test_build_extension_imports_from_temporary_directory(tmp_path: Path) -> None: + source: Path = tmp_path / "answer.c" + source.write_text( + """ +#define PY_SSIZE_T_CLEAN +#include + +static PyObject *answer(PyObject *self, PyObject *args) { + return PyLong_FromLong(42); +} + +static PyMethodDef methods[] = { + {"answer", answer, METH_NOARGS, "Return 42."}, + {NULL, NULL, 0, NULL} +}; + +static struct PyModuleDef module = { + PyModuleDef_HEAD_INIT, "_answer", NULL, -1, methods +}; + +PyMODINIT_FUNC PyInit__answer(void) { + return PyModule_Create(&module); +} +""".strip(), + encoding="utf-8", + ) + module: ModuleType = build_extension("_answer", source, tmp_path / "build") + assert module.answer() == 42 + assert Path(module.__file__).is_relative_to(tmp_path) +``` + +- [ ] **Step 2: Run and verify the helper is absent** + +Run `uv run --group native pytest CH_17_c_and_cpp_extensions/test_native_build.py -vv`. + +Expected: FAIL during import because `native_build.py` does not exist. + +- [ ] **Step 3: Implement isolated setuptools compilation** + +```python +from __future__ import annotations + +import importlib.util +from pathlib import Path +from types import ModuleType + +from setuptools import Distribution, Extension +from setuptools.command.build_ext import build_ext + + +def build_extension(module_name: str, source: Path, build_root: Path) -> ModuleType: + if not source.is_file(): + raise FileNotFoundError(source) + build_lib: Path = build_root / "lib" + build_temp: Path = build_root / "temp" + build_lib.mkdir(parents=True, exist_ok=True) + build_temp.mkdir(parents=True, exist_ok=True) + + extension: Extension = Extension(module_name, sources=[str(source)]) + distribution: Distribution = Distribution({"ext_modules": [extension]}) + command: build_ext = build_ext(distribution) + command.ensure_finalized() + command.build_lib = str(build_lib) + command.build_temp = str(build_temp) + command.run() + + extension_path: Path = Path(command.get_ext_fullpath(module_name)) + spec = importlib.util.spec_from_file_location(module_name, extension_path) + if spec is None or spec.loader is None: + raise ImportError(f"cannot load extension at {extension_path}") + module: ModuleType = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module +``` + +The final implementation must assert `extension_path.is_relative_to(build_root)` and raise `RuntimeError` if the compiler does not produce the expected file. + +- [ ] **Step 4: Run tests and static checks** + +```bash +uv run --group native pytest CH_17_c_and_cpp_extensions/test_native_build.py -vv +uv run ruff format --check CH_17_c_and_cpp_extensions/native_build.py CH_17_c_and_cpp_extensions/test_native_build.py +uv run ruff check CH_17_c_and_cpp_extensions/native_build.py CH_17_c_and_cpp_extensions/test_native_build.py +uv run pyrefly check CH_17_c_and_cpp_extensions/native_build.py CH_17_c_and_cpp_extensions/test_native_build.py +uv run mypy CH_17_c_and_cpp_extensions/native_build.py CH_17_c_and_cpp_extensions/test_native_build.py +uv run pyright CH_17_c_and_cpp_extensions/native_build.py CH_17_c_and_cpp_extensions/test_native_build.py +git status --short +``` + +Expected: all commands exit 0, the extension returns 42, and `git status --short` contains no `.so`, `.pyd`, `build/`, or `dist/` artifact. + +- [ ] **Step 5: Commit** + +```bash +git add CH_17_c_and_cpp_extensions/native_build.py CH_17_c_and_cpp_extensions/test_native_build.py +git commit -m "test: add temporary native extension builder" +``` + +### Task 2: Three `qsort` implementations + +**Files:** +- Create: `CH_17_c_and_cpp_extensions/exercise_01/__init__.py` +- Create: `CH_17_c_and_cpp_extensions/exercise_01/native_qsort.c` +- Create: `CH_17_c_and_cpp_extensions/exercise_01/solution_00.py` +- Create: `CH_17_c_and_cpp_extensions/exercise_01/test_solution_00.py` + +- [ ] **Step 1: Write cross-implementation contract tests** + +```python +from __future__ import annotations + +import ctypes +from collections.abc import Callable, Iterator +from pathlib import Path +from types import ModuleType + +import pytest + +from CH_17_c_and_cpp_extensions.native_build import build_extension +from .solution_00 import qsort_cffi, qsort_ctypes, qsort_native + + +Sorter = Callable[[list[int]], list[int]] + + +@pytest.fixture(scope="module") +def native_module(tmp_path_factory: pytest.TempPathFactory) -> Iterator[ModuleType]: + root: Path = tmp_path_factory.mktemp("native-qsort") + source: Path = Path(__file__).with_name("native_qsort.c") + yield build_extension("_native_qsort", source, root) + + +@pytest.mark.parametrize( + "values", + [ + [], + [1], + [4, -1, 4, 0, 2], + [-(2**31), 2**31 - 1, 0], + ], +) +def test_all_qsort_paths_match_builtin( + values: list[int], + native_module: ModuleType, +) -> None: + expected: list[int] = sorted(values) + original: list[int] = values.copy() + assert qsort_ctypes(values) == expected + assert qsort_cffi(values) == expected + assert qsort_native(values, native_module=native_module) == expected + assert values == original + + +def test_qsort_paths_reject_out_of_c_int_range(native_module: ModuleType) -> None: + too_large: list[int] = [ctypes.c_int(0).value + 2**63] + for sorter in (qsort_ctypes, qsort_cffi): + with pytest.raises(OverflowError): + sorter(too_large) + with pytest.raises(OverflowError): + qsort_native(too_large, native_module=native_module) + + +def test_qsort_paths_reject_non_integers(native_module: ModuleType) -> None: + bad: list[object] = [1, "2"] + with pytest.raises(TypeError): + qsort_ctypes(bad) # type: ignore[arg-type] + with pytest.raises(TypeError): + qsort_cffi(bad) # type: ignore[arg-type] + with pytest.raises(TypeError): + qsort_native(bad, native_module=native_module) # type: ignore[arg-type] +``` + +- [ ] **Step 2: Run tests and verify the exercise is absent** + +Run `uv run --group native pytest CH_17_c_and_cpp_extensions/exercise_01/test_solution_00.py -vv`. + +Expected: FAIL during import because neither Python nor C implementation exists. + +- [ ] **Step 3: Implement the ctypes and CFFI paths** + +Expose: + +```python +def qsort_ctypes(values: Sequence[int]) -> list[int]: + """Sort C-int values through libc qsort and return a new list.""" + + +def qsort_cffi(values: Sequence[int]) -> list[int]: + """Sort C-int values through CFFI ABI mode and return a new list.""" + + +def qsort_native( + values: Sequence[int], + *, + native_module: ModuleType, +) -> list[int]: + """Sort with the compiled CPython extension.""" +``` + +Add `_validated_ints(values) -> list[int]` using `ctypes.c_int` limits and rejecting `bool` and non-`int` values. For ctypes: + +```python +Comparator = ctypes.CFUNCTYPE( + ctypes.c_int, + ctypes.POINTER(ctypes.c_int), + ctypes.POINTER(ctypes.c_int), +) + + +@Comparator +def compare(left: ctypes.POINTER[ctypes.c_int], right: ctypes.POINTER[ctypes.c_int]) -> int: + left_value: int = left.contents.value + right_value: int = right.contents.value + return (left_value > right_value) - (left_value < right_value) +``` + +Load `ctypes.CDLL(None)`, set `qsort.argtypes` and `restype`, and retain `compare` until the call returns. For CFFI, use: + +```python +ffi.cdef("void qsort(void *, size_t, size_t, int (*)(const void *, const void *));") +libc = ffi.dlopen(None) +data = ffi.new("int[]", checked_values) + +@ffi.callback("int(const void *, const void *)") +def compare(left: object, right: object) -> int: + left_value: int = ffi.cast("const int *", left)[0] + right_value: int = ffi.cast("const int *", right)[0] + return (left_value > right_value) - (left_value < right_value) +``` + +Call `qsort` only for non-empty arrays. The subtraction-free comparator avoids overflow at `INT_MIN`/`INT_MAX`. + +- [ ] **Step 4: Implement `_native_qsort.sort_ints`** + +In `native_qsort.c`: + +1. use `PySequence_Fast` and reject non-sequences; +2. allocate with `PyMem_New(int, length)` and raise `PyErr_NoMemory`; +3. for each item, call `PyLong_AsLong`, immediately check `PyErr_Occurred`, and verify `INT_MIN <= value <= INT_MAX`; +4. call C `qsort` with a comparator based on relational results, not subtraction; +5. build a Python list with `PyLong_FromLong`; +6. free the C array on every success and failure path; +7. expose only `sort_ints`. + +Representative checked conversion: + +```c +long value = PyLong_AsLong(item); +if (value == -1 && PyErr_Occurred()) { + PyMem_Free(values); + Py_DECREF(sequence); + return NULL; +} +if (value < INT_MIN || value > INT_MAX) { + PyErr_SetString(PyExc_OverflowError, "value is outside the C int range"); + PyMem_Free(values); + Py_DECREF(sequence); + return NULL; +} +values[index] = (int)value; +``` + +- [ ] **Step 5: Verify and commit** + +```bash +uv run --group native pytest CH_17_c_and_cpp_extensions/exercise_01/test_solution_00.py -vv +uv run ruff format --check CH_17_c_and_cpp_extensions/exercise_01 +uv run ruff check CH_17_c_and_cpp_extensions/exercise_01 +uv run pyrefly check CH_17_c_and_cpp_extensions/exercise_01 +uv run mypy CH_17_c_and_cpp_extensions/exercise_01 +uv run pyright CH_17_c_and_cpp_extensions/exercise_01 +git add CH_17_c_and_cpp_extensions/exercise_01 +git commit -m "feat: add three native qsort implementations" +``` + +Expected: all commands exit 0; all three sort paths match for every case; no compiler artifact appears in the worktree. + +### Task 3: Checked native `custom_sum` + +**Files:** +- Create: `CH_17_c_and_cpp_extensions/exercise_02/__init__.py` +- Create: `CH_17_c_and_cpp_extensions/exercise_02/safe_sum.c` +- Create: `CH_17_c_and_cpp_extensions/exercise_02/solution_00.py` +- Create: `CH_17_c_and_cpp_extensions/exercise_02/test_solution_00.py` + +- [ ] **Step 1: Write failing normal, conversion, overflow, underflow, and cancellation tests** + +```python +from __future__ import annotations + +import ctypes +from pathlib import Path +from types import ModuleType + +import pytest + +from CH_17_c_and_cpp_extensions.native_build import build_extension +from .solution_00 import custom_sum + + +@pytest.fixture(scope="module") +def native_module(tmp_path_factory: pytest.TempPathFactory) -> ModuleType: + root: Path = tmp_path_factory.mktemp("safe-sum") + source: Path = Path(__file__).with_name("safe_sum.c") + return build_extension("_safe_sum", source, root) + + +def test_custom_sum_handles_normal_and_empty_values(native_module: ModuleType) -> None: + assert custom_sum([], native_module=native_module) == 0 + assert custom_sum([1, -2, 3], native_module=native_module) == 2 + + +def test_custom_sum_detects_each_conversion_failure(native_module: ModuleType) -> None: + with pytest.raises(TypeError): + custom_sum([1, "bad", 2], native_module=native_module) # type: ignore[list-item] + with pytest.raises(OverflowError): + custom_sum([2**100], native_module=native_module) + + +def test_custom_sum_detects_cumulative_overflow(native_module: ModuleType) -> None: + maximum: int = ctypes.c_longlong(-1).value + 2**63 + with pytest.raises(OverflowError, match="overflow"): + custom_sum([maximum, 1], native_module=native_module) + + +def test_custom_sum_detects_cumulative_underflow(native_module: ModuleType) -> None: + minimum: int = -(2**63) + with pytest.raises(OverflowError, match="underflow"): + custom_sum([minimum, -1], native_module=native_module) + + +def test_custom_sum_allows_cancellation(native_module: ModuleType) -> None: + maximum: int = 2**63 - 1 + assert custom_sum([maximum, -maximum], native_module=native_module) == 0 +``` + +- [ ] **Step 2: Run tests and verify both source files are absent** + +Run `uv run --group native pytest CH_17_c_and_cpp_extensions/exercise_02/test_solution_00.py -vv`. + +Expected: FAIL during import. + +- [ ] **Step 3: Implement the Python entry point** + +```python +from __future__ import annotations + +from collections.abc import Sequence +from types import ModuleType + + +def custom_sum(values: Sequence[int], *, native_module: ModuleType) -> int: + if any(isinstance(value, bool) or not isinstance(value, int) for value in values): + raise TypeError("custom_sum accepts integers, not booleans or other values") + result: object = native_module.custom_sum(values) + if not isinstance(result, int): + raise TypeError("native custom_sum returned a non-integer") + return result +``` + +Add a guarded `main()` that compiles in a `TemporaryDirectory`, sums `[1, 2, 3]`, and prints `6`. + +- [ ] **Step 4: Implement checked C conversion and accumulation** + +Use `PySequence_Fast`. For every item: + +```c +long long value = PyLong_AsLongLong(item); +if (value == -1 && PyErr_Occurred()) { + Py_DECREF(sequence); + return NULL; +} +if (value > 0 && total > LLONG_MAX - value) { + PyErr_SetString(PyExc_OverflowError, "custom_sum cumulative overflow"); + Py_DECREF(sequence); + return NULL; +} +if (value < 0 && total < LLONG_MIN - value) { + PyErr_SetString(PyExc_OverflowError, "custom_sum cumulative underflow"); + Py_DECREF(sequence); + return NULL; +} +total += value; +``` + +Return `PyLong_FromLongLong(total)`. Check the conversion error immediately for every element; never clear or replace a `TypeError`/`OverflowError` already set by `PyLong_AsLongLong`. + +- [ ] **Step 5: Verify and commit** + +```bash +uv run --group native pytest CH_17_c_and_cpp_extensions/exercise_02/test_solution_00.py -vv +uv run ruff format --check CH_17_c_and_cpp_extensions/exercise_02 +uv run ruff check CH_17_c_and_cpp_extensions/exercise_02 +uv run pyrefly check CH_17_c_and_cpp_extensions/exercise_02 +uv run mypy CH_17_c_and_cpp_extensions/exercise_02 +uv run pyright CH_17_c_and_cpp_extensions/exercise_02 +git add CH_17_c_and_cpp_extensions/exercise_02 +git commit -m "feat: add checked native custom sum" +``` + +Expected: every command exits 0 and pytest reports 5 passed. + +### Task 4: Documentation and chapter index + +**Files:** +- Create: `CH_17_c_and_cpp_extensions/exercise_01/README.rst` +- Create: `CH_17_c_and_cpp_extensions/exercise_02/README.rst` +- Modify: `CH_17_c_and_cpp_extensions/README.rst` + +- [ ] **Step 1: Document exercise 1** + +Repeat the exact `qsort` question. Explain C-int-only input, non-mutating results, empty input, portable process-libc loading, subtraction-free comparators, temporary native builds, and the `native` dependency group. Include: + +```console +uv run --group native python -m CH_17_c_and_cpp_extensions.exercise_01.solution_00 +uv run --group native pytest CH_17_c_and_cpp_extensions/exercise_01/test_solution_00.py -vv +``` + +Cite these immutable upstream references: + +- `CH_17_c_and_cpp_extensions/_libc.py` +- `CH_17_c_and_cpp_extensions/T_04_cffi.rst` +- `CH_17_c_and_cpp_extensions/T_05_cffi_open_library.rst` +- `CH_17_c_and_cpp_extensions/T_09_native/setup.py` + +Each link starts with `https://github.com/mastering-python/code_2/blob/a012f6ce3f5bf11f5bf380d1c4e7f743355adb89/`. + +- [ ] **Step 2: Document exercise 2** + +Repeat the exact `custom_sum` question. Explain integer-only input, per-element conversion checks, signed 64-bit cumulative overflow/underflow, cancellation, and temporary compilation. Include run/test commands and cite: + +`https://github.com/mastering-python/code_2/blob/a012f6ce3f5bf11f5bf380d1c4e7f743355adb89/CH_17_c_and_cpp_extensions/T_12_silent_or_lethal_errors.c` + +- [ ] **Step 3: Link both chapter questions** + +Retain their exact text and link them to `exercise_01/` and `exercise_02/`. + +- [ ] **Step 4: Run packet verification** + +```bash +uv run --group native pytest CH_17_c_and_cpp_extensions -vv +uv run ruff format --check CH_17_c_and_cpp_extensions +uv run ruff check CH_17_c_and_cpp_extensions +uv run pyrefly check CH_17_c_and_cpp_extensions/native_build.py CH_17_c_and_cpp_extensions/exercise_01 CH_17_c_and_cpp_extensions/exercise_02 +uv run mypy CH_17_c_and_cpp_extensions/native_build.py CH_17_c_and_cpp_extensions/exercise_01 CH_17_c_and_cpp_extensions/exercise_02 +uv run pyright CH_17_c_and_cpp_extensions/native_build.py CH_17_c_and_cpp_extensions/exercise_01 CH_17_c_and_cpp_extensions/exercise_02 +git status --short +``` + +Expected: all quality commands exit 0 and status shows only intended source/documentation changes, never compiled artifacts. + +- [ ] **Step 5: Commit documentation** + +```bash +git add CH_17_c_and_cpp_extensions/README.rst CH_17_c_and_cpp_extensions/exercise_01/README.rst CH_17_c_and_cpp_extensions/exercise_02/README.rst +git commit -m "docs: document native interoperability exercises" +``` + +## Packet acceptance + +- [ ] Cross-check all three `qsort` paths against `sorted()` on Python 3.10 and 3.14. +- [ ] Confirm conversion failure checks happen per value and both cumulative range directions are tested. +- [ ] Confirm build output stays entirely under pytest or `TemporaryDirectory` paths. +- [ ] Confirm native errors propagate as Python exceptions and no C failure returns a non-NULL result. diff --git a/docs/superpowers/plans/2026-07-28-ch18-packaging.md b/docs/superpowers/plans/2026-07-28-ch18-packaging.md new file mode 100644 index 0000000..42a11a5 --- /dev/null +++ b/docs/superpowers/plans/2026-07-28-ch18-packaging.md @@ -0,0 +1,715 @@ +# Chapter 18 Packaging Implementation + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Implement a setuptools version-bump command, an interactive major/minor/patch extension, and a tested migration from a static `setup.py` project to modern `pyproject.toml`. + +**Architecture:** Exercises 1 and 2 expose reusable version parsing/bumping functions plus setuptools `Command` subclasses that tests install into temporary fixture projects. Exercise 3 parses a deliberately static legacy fixture without executing it, migrates to a `src` layout, builds wheel and sdist in a temporary directory, and verifies artifact contents. + +**Tech Stack:** Python 3.10, setuptools, wheel, `build`, tox, pytest, subprocess, zipfile, tarfile, uv. + +--- + +## Coordinator dependency handoff + +Do not modify shared project files. Confirm the coordinator's `packaging` group includes setuptools, build, wheel, and tox. Every subprocess must use `sys.executable`, set `cwd` to a temporary fixture, disable network-dependent isolation where required, capture stdout/stderr, and use a timeout. + +## File map + +- Create complete exercise directories `exercise_01/`, `exercise_02/`, and `exercise_03/`. +- Exercise 3 adds only `fixtures/legacy_project/setup.py`, `README.rst`, and `greeting_demo/__init__.py`. +- Modify `CH_18_packaging/README.rst` only to link the three exact questions. + +### Task 1: Non-interactive setuptools bump command + +**Files:** +- Create: `CH_18_packaging/exercise_01/__init__.py` +- Create: `CH_18_packaging/exercise_01/solution_00.py` +- Create: `CH_18_packaging/exercise_01/test_solution_00.py` + +- [ ] **Step 1: Write failing version and command-integration tests** + +```python +from __future__ import annotations + +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest + +from .solution_00 import bump_version, read_version, write_version + + +def test_bump_version_parts() -> None: + assert bump_version("1.2.3", "major") == "2.0.0" + assert bump_version("1.2.3", "minor") == "1.3.0" + assert bump_version("1.2.3", "patch") == "1.2.4" + + +@pytest.mark.parametrize("version", ["1.2", "1.2.x", "v1.2.3", "1.-2.3"]) +def test_bump_version_rejects_invalid_versions(version: str) -> None: + with pytest.raises(ValueError, match="major.minor.patch"): + bump_version(version, "patch") + + +def test_read_and_write_version_preserve_module_content(tmp_path: Path) -> None: + version_file: Path = tmp_path / "__init__.py" + version_file.write_text('NAME = "demo"\n__version__ = "1.2.3"\n', encoding="utf-8") + assert read_version(version_file) == "1.2.3" + write_version(version_file, "1.2.4") + assert version_file.read_text(encoding="utf-8") == ( + 'NAME = "demo"\n__version__ = "1.2.4"\n' + ) + + +@pytest.mark.timeout(30) +def test_setuptools_command_bumps_fixture_patch(tmp_path: Path) -> None: + package: Path = tmp_path / "demo_package" + package.mkdir() + (package / "__init__.py").write_text('__version__ = "1.2.3"\n', encoding="utf-8") + shutil.copy(Path(__file__).with_name("solution_00.py"), tmp_path / "bump_command.py") + (tmp_path / "setup.py").write_text( + "from setuptools import setup\n" + "from bump_command import BumpVersion\n" + "setup(name='demo-package', version='1.2.3', " + "cmdclass={'bump_version': BumpVersion})\n", + encoding="utf-8", + ) + completed: subprocess.CompletedProcess[str] = subprocess.run( + [ + sys.executable, + "setup.py", + "bump_version", + "--version-file", + "demo_package/__init__.py", + "--part", + "patch", + ], + cwd=tmp_path, + check=False, + capture_output=True, + text=True, + timeout=20, + ) + assert completed.returncode == 0, completed.stderr + assert read_version(package / "__init__.py") == "1.2.4" +``` + +- [ ] **Step 2: Run tests and verify the exercise is absent** + +Run `uv run --group packaging pytest CH_18_packaging/exercise_01/test_solution_00.py -vv`. + +Expected: FAIL during import. + +- [ ] **Step 3: Implement strict semantic-version helpers** + +```python +from __future__ import annotations + +import re +from pathlib import Path +from typing import Literal, cast + + +VersionPart = Literal["major", "minor", "patch"] +VERSION_PATTERN: re.Pattern[str] = re.compile(r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$") +ASSIGNMENT_PATTERN: re.Pattern[str] = re.compile( + r'^(?P__version__\s*=\s*["\'])(?P[^"\']+)(?P["\']\s*)$', + re.MULTILINE, +) + + +def bump_version(version: str, part: VersionPart) -> str: + match: re.Match[str] | None = VERSION_PATTERN.fullmatch(version) + if match is None: + raise ValueError("version must be major.minor.patch") + major: int + minor: int + patch: int + major, minor, patch = (int(group) for group in match.groups()) + if part == "major": + return f"{major + 1}.0.0" + if part == "minor": + return f"{major}.{minor + 1}.0" + if part == "patch": + return f"{major}.{minor}.{patch + 1}" + raise ValueError(f"unknown version part: {part}") + + +def read_version(path: Path) -> str: + content: str = path.read_text(encoding="utf-8") + match: re.Match[str] | None = ASSIGNMENT_PATTERN.search(content) + if match is None: + raise ValueError(f"{path} must contain one __version__ assignment") + version: str = match.group("version") + if VERSION_PATTERN.fullmatch(version) is None: + raise ValueError("version must be major.minor.patch") + return version + + +def write_version(path: Path, version: str) -> None: + if VERSION_PATTERN.fullmatch(version) is None: + raise ValueError("version must be major.minor.patch") + content: str = path.read_text(encoding="utf-8") + updated: str + replacements: int + updated, replacements = ASSIGNMENT_PATTERN.subn( + lambda match: f"{match.group('prefix')}{version}{match.group('suffix')}", + content, + count=1, + ) + if replacements != 1: + raise ValueError(f"{path} must contain one __version__ assignment") + path.write_text(updated, encoding="utf-8") +``` + +- [ ] **Step 4: Implement the setuptools command** + +```python +from setuptools import Command, Distribution +from setuptools.errors import OptionError + + +class BumpVersion(Command): + description: str = "bump a package version" + user_options: list[tuple[str, str | None, str]] = [ + ("version-file=", None, "Python file containing __version__"), + ("part=", None, "major, minor, or patch"), + ] + + version_file: str | None + part: str | None + + def initialize_options(self) -> None: + self.version_file = None + self.part = "patch" + + def finalize_options(self) -> None: + if self.version_file is None: + raise OptionError("--version-file is required") + if self.part not in {"major", "minor", "patch"}: + raise OptionError("--part must be major, minor, or patch") + + def run(self) -> None: + if self.version_file is None or self.part not in {"major", "minor", "patch"}: + raise OptionError("command options were not finalized") + path: Path = Path(self.version_file) + current: str = read_version(path) + part: VersionPart = cast(VersionPart, self.part) + new: str = bump_version(current, part) + write_version(path, new) + self.announce(f"bumped {current} to {new}", level=2) +``` + +Remove unused `Distribution` if the installed setuptools annotations do not require it. Do not edit `setup.py` metadata; the exercise command changes only the designated package version file. + +- [ ] **Step 5: Verify and commit** + +```bash +uv run --group packaging pytest CH_18_packaging/exercise_01/test_solution_00.py -vv +uv run ruff format --check CH_18_packaging/exercise_01 +uv run ruff check CH_18_packaging/exercise_01 +uv run pyrefly check CH_18_packaging/exercise_01 +uv run mypy CH_18_packaging/exercise_01 +uv run pyright CH_18_packaging/exercise_01 +git add CH_18_packaging/exercise_01 +git commit -m "feat: add setuptools version bump command" +``` + +Expected: all commands exit 0; pytest reports 7 parameterized cases/tests passing. + +### Task 2: Interactive major/minor/patch command + +**Files:** +- Create: `CH_18_packaging/exercise_02/__init__.py` +- Create: `CH_18_packaging/exercise_02/solution_00.py` +- Create: `CH_18_packaging/exercise_02/test_solution_00.py` + +- [ ] **Step 1: Write failing prompt and subprocess tests** + +```python +from __future__ import annotations + +import shutil +import subprocess +import sys +import os +from collections.abc import Iterator +from pathlib import Path + +import pytest + +from CH_18_packaging.exercise_01.solution_00 import read_version +from .solution_00 import choose_version_part + + +@pytest.mark.parametrize( + ("answer", "expected"), + [ + ("major", "major"), + ("2", "minor"), + ("PATCH", "patch"), + ], +) +def test_choose_version_part_accepts_names_and_numbers( + answer: str, + expected: str, +) -> None: + responses: Iterator[str] = iter([answer]) + assert choose_version_part(lambda prompt: next(responses)) == expected + + +def test_choose_version_part_rejects_invalid_choice() -> None: + with pytest.raises(ValueError, match="major, minor, or patch"): + choose_version_part(lambda prompt: "fourth") + + +@pytest.mark.timeout(30) +def test_interactive_command_uses_stdin(tmp_path: Path) -> None: + package: Path = tmp_path / "demo_package" + package.mkdir() + (package / "__init__.py").write_text('__version__ = "1.2.3"\n', encoding="utf-8") + shutil.copy(Path(__file__).with_name("solution_00.py"), tmp_path / "interactive_bump.py") + (tmp_path / "setup.py").write_text( + "from setuptools import setup\n" + "from interactive_bump import InteractiveBumpVersion\n" + "setup(name='demo-package', version='1.2.3', " + "cmdclass={'bump_version': InteractiveBumpVersion})\n", + encoding="utf-8", + ) + environment: dict[str, str] = os.environ.copy() + environment["PYTHONPATH"] = str(Path(__file__).parents[2]) + completed: subprocess.CompletedProcess[str] = subprocess.run( + [ + sys.executable, + "setup.py", + "bump_version", + "--version-file", + "demo_package/__init__.py", + ], + cwd=tmp_path, + env=environment, + input="minor\n", + check=False, + capture_output=True, + text=True, + timeout=20, + ) + assert completed.returncode == 0, completed.stderr + assert read_version(package / "__init__.py") == "1.3.0" +``` + +- [ ] **Step 2: Run tests and verify the interactive command is absent** + +Run `uv run --group packaging pytest CH_18_packaging/exercise_02/test_solution_00.py -vv`. + +Expected: FAIL during import. + +- [ ] **Step 3: Implement injectable choice parsing** + +```python +from __future__ import annotations + +from collections.abc import Callable + +from CH_18_packaging.exercise_01.solution_00 import BumpVersion, VersionPart + + +CHOICES: dict[str, VersionPart] = { + "1": "major", + "major": "major", + "2": "minor", + "minor": "minor", + "3": "patch", + "patch": "patch", +} + + +def choose_version_part(input_function: Callable[[str], str] = input) -> VersionPart: + answer: str = input_function( + "Choose version bump: 1) major, 2) minor, 3) patch: " + ).strip().lower() + try: + return CHOICES[answer] + except KeyError as error: + raise ValueError("choose major, minor, or patch") from error + + +class InteractiveBumpVersion(BumpVersion): + description: str = "interactively choose and bump a package version" + + def finalize_options(self) -> None: + if self.part is None or self.part == "interactive": + self.part = choose_version_part() + super().finalize_options() +``` + +Override `initialize_options()` so `part` defaults to `"interactive"` while retaining the inherited `version_file` field. Supplying `--part` must bypass input, allowing non-interactive automation. + +- [ ] **Step 4: Add a test that an explicit part bypasses input** + +Instantiate a `setuptools.Distribution`, create `InteractiveBumpVersion`, set a temporary version file and `part = "major"`, monkeypatch module `input` to raise if called, finalize, run, and assert `2.0.0`. + +Expected initial failure: input is called despite explicit `--part`. Expected pass after the conditional implementation. + +- [ ] **Step 5: Verify and commit** + +```bash +uv run --group packaging pytest CH_18_packaging/exercise_02/test_solution_00.py -vv +uv run ruff format --check CH_18_packaging/exercise_02 +uv run ruff check CH_18_packaging/exercise_02 +uv run pyrefly check CH_18_packaging/exercise_02 +uv run mypy CH_18_packaging/exercise_02 +uv run pyright CH_18_packaging/exercise_02 +git add CH_18_packaging/exercise_02 +git commit -m "feat: add interactive version bump command" +``` + +Expected: every command exits 0 and pytest reports 6 parameterized cases/tests passing. + +### Task 3: Static legacy metadata reader + +**Files:** +- Create: `CH_18_packaging/exercise_03/__init__.py` +- Create: `CH_18_packaging/exercise_03/fixtures/legacy_project/setup.py` +- Create: `CH_18_packaging/exercise_03/fixtures/legacy_project/README.rst` +- Create: `CH_18_packaging/exercise_03/fixtures/legacy_project/greeting_demo/__init__.py` +- Create: `CH_18_packaging/exercise_03/solution_00.py` +- Create: `CH_18_packaging/exercise_03/test_solution_00.py` + +- [ ] **Step 1: Add the intentionally legacy fixture** + +`setup.py`: + +```python +from setuptools import find_packages, setup + + +setup( + name="greeting-demo", + version="1.2.3", + description="A tiny migration fixture", + packages=find_packages(), + python_requires=">=3.10", + entry_points={"console_scripts": ["greet=greeting_demo:main"]}, +) +``` + +`greeting_demo/__init__.py`: + +```python +from __future__ import annotations + +__version__: str = "1.2.3" + + +def greeting(name: str) -> str: + return f"Hello, {name}!" + + +def main() -> None: + print(greeting("packaging")) +``` + +`README.rst` contains a title and states that the fixture exists only for the migration exercise. + +- [ ] **Step 2: Write failing metadata and security-boundary tests** + +```python +from __future__ import annotations + +from pathlib import Path + +import pytest + +from .solution_00 import ProjectMetadata, read_setup_metadata + + +FIXTURE: Path = Path(__file__).with_name("fixtures") / "legacy_project" + + +def test_read_setup_metadata_without_executing_setup() -> None: + metadata: ProjectMetadata = read_setup_metadata(FIXTURE / "setup.py") + assert metadata == ProjectMetadata( + name="greeting-demo", + version="1.2.3", + description="A tiny migration fixture", + requires_python=">=3.10", + scripts={"greet": "greeting_demo:main"}, + ) + + +def test_dynamic_setup_metadata_is_rejected(tmp_path: Path) -> None: + setup_py: Path = tmp_path / "setup.py" + setup_py.write_text( + "from setuptools import setup\n" + "setup(name=get_name(), version='1.0.0')\n", + encoding="utf-8", + ) + with pytest.raises(ValueError, match="static literal"): + read_setup_metadata(setup_py) +``` + +- [ ] **Step 3: Run tests and verify the parser is absent** + +Run `uv run --group packaging pytest CH_18_packaging/exercise_03/test_solution_00.py -vv`. + +Expected: FAIL during import. + +- [ ] **Step 4: Implement a non-executing AST reader** + +Expose: + +```python +@dataclass(frozen=True) +class ProjectMetadata: + name: str + version: str + description: str + requires_python: str + scripts: dict[str, str] + + +def read_setup_metadata(setup_py: Path) -> ProjectMetadata: + """Read supported literal setup() keywords without executing setup.py.""" +``` + +Parse with `ast.parse`; find exactly one call whose function is `setup` or an attribute named `setup`; accept only `name`, `version`, `description`, `python_requires`, and `entry_points` via `ast.literal_eval`. Reject `**kwargs`, duplicate setup calls, non-literal supported fields, absent required fields, and malformed `console_scripts`. Convert each `name=module:function` string to a mapping and validate that it has exactly one `=` and one `:`. + +- [ ] **Step 5: Verify and commit the fixture/parser slice** + +Run focused pytest and static checks, then: + +```bash +git add CH_18_packaging/exercise_03 +git commit -m "feat: parse static legacy setup metadata" +``` + +Expected: all commands exit 0 and pytest reports 2 passed. + +### Task 4: Migrate, build, and inspect artifacts + +**Files:** +- Modify: `CH_18_packaging/exercise_03/solution_00.py` +- Modify: `CH_18_packaging/exercise_03/test_solution_00.py` + +- [ ] **Step 1: Add failing migration and build integration tests** + +```python +from __future__ import annotations + +import shutil +import subprocess +import sys +import zipfile +from pathlib import Path + +import pytest + +from .solution_00 import migrate_project + + +@pytest.mark.timeout(90) +def test_migrated_project_builds_wheel_and_sdist(tmp_path: Path) -> None: + source: Path = Path(__file__).with_name("fixtures") / "legacy_project" + legacy_copy: Path = tmp_path / "legacy" + shutil.copytree(source, legacy_copy) + migrated: Path = migrate_project(legacy_copy, tmp_path / "migrated") + assert not (migrated / "setup.py").exists() + assert (migrated / "src" / "greeting_demo" / "__init__.py").is_file() + + dist: Path = tmp_path / "dist" + completed: subprocess.CompletedProcess[str] = subprocess.run( + [ + sys.executable, + "-m", + "build", + "--no-isolation", + "--outdir", + str(dist), + str(migrated), + ], + check=False, + capture_output=True, + text=True, + timeout=60, + ) + assert completed.returncode == 0, completed.stderr + wheels: list[Path] = list(dist.glob("*.whl")) + sdists: list[Path] = list(dist.glob("*.tar.gz")) + assert len(wheels) == 1 + assert len(sdists) == 1 + + with zipfile.ZipFile(wheels[0]) as archive: + names: set[str] = set(archive.namelist()) + assert "greeting_demo/__init__.py" in names + metadata_name: str = next( + name for name in names if name.endswith(".dist-info/METADATA") + ) + entry_points_name: str = next( + name for name in names if name.endswith(".dist-info/entry_points.txt") + ) + metadata: str = archive.read(metadata_name).decode() + entry_points: str = archive.read(entry_points_name).decode() + assert "Name: greeting-demo" in metadata + assert "Version: 1.2.3" in metadata + assert "greet = greeting_demo:main" in entry_points +``` + +- [ ] **Step 2: Run the integration test and verify `migrate_project` is missing** + +Expected: FAIL during import or attribute lookup. + +- [ ] **Step 3: Implement deterministic migration** + +Expose: + +```python +def migrate_project(source: Path, destination: Path) -> Path: + if destination.exists(): + raise FileExistsError(destination) + metadata: ProjectMetadata = read_setup_metadata(source / "setup.py") + package_source: Path = source / metadata.name.replace("-", "_") + if not package_source.is_dir(): + raise FileNotFoundError(package_source) + + package_destination: Path = destination / "src" / package_source.name + package_destination.parent.mkdir(parents=True) + shutil.copytree(package_source, package_destination) + shutil.copy2(source / "README.rst", destination / "README.rst") + (destination / "pyproject.toml").write_text( + render_pyproject(metadata), + encoding="utf-8", + ) + return destination +``` + +`render_pyproject` returns deterministic TOML with JSON-quoted strings: + +```toml +[build-system] +requires = ["setuptools>=77", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "greeting-demo" +version = "1.2.3" +description = "A tiny migration fixture" +readme = "README.rst" +requires-python = ">=3.10" + +[project.scripts] +greet = "greeting_demo:main" + +[tool.setuptools.packages.find] +where = ["src"] +``` + +Do not copy `setup.py`; do not execute it; do not mutate the source fixture. + +- [ ] **Step 4: Add installed-wheel smoke coverage** + +After building, import directly from the pure-Python wheel archive and call the console target: + +```python +environment: dict[str, str] = os.environ.copy() +environment["PYTHONPATH"] = str(wheels[0]) +smoke: subprocess.CompletedProcess[str] = subprocess.run( + [ + sys.executable, + "-c", + "from greeting_demo import greeting, main; " + "print(greeting('wheel')); main()", + ], + env=environment, + check=False, + capture_output=True, + text=True, + timeout=20, +) +assert smoke.returncode == 0, smoke.stderr +assert smoke.stdout.splitlines() == ["Hello, wheel!", "Hello, packaging!"] +``` + +This validates import and the function targeted by the console entry point without requiring pip in the uv environment. + +- [ ] **Step 5: Verify and commit** + +```bash +uv run --group packaging pytest CH_18_packaging/exercise_03/test_solution_00.py -vv +uv run ruff format --check CH_18_packaging/exercise_03 +uv run ruff check CH_18_packaging/exercise_03 +uv run pyrefly check CH_18_packaging/exercise_03 +uv run mypy CH_18_packaging/exercise_03 +uv run pyright CH_18_packaging/exercise_03 +git status --short +git add CH_18_packaging/exercise_03/solution_00.py CH_18_packaging/exercise_03/test_solution_00.py +git commit -m "feat: migrate and build legacy fixture package" +``` + +Expected: all commands exit 0, status contains no `dist/`, `build/`, `*.egg-info`, wheel, or sdist, both artifacts exist only in pytest temporary storage, and the installed-wheel smoke test passes. + +### Task 5: Per-exercise documentation and chapter links + +**Files:** +- Create: `CH_18_packaging/exercise_01/README.rst` +- Create: `CH_18_packaging/exercise_02/README.rst` +- Create: `CH_18_packaging/exercise_03/README.rst` +- Modify: `CH_18_packaging/README.rst` + +- [ ] **Step 1: Document exercise 1** + +Repeat the exact setuptools-command question. Explain strict three-integer versions, all three bump parts, default patch behavior, designated-file-only writes, and the `packaging` dependency group. Include: + +```console +uv run --group packaging pytest CH_18_packaging/exercise_01/test_solution_00.py -vv +``` + +Cite: + +`https://github.com/mastering-python/code_2/blob/a012f6ce3f5bf11f5bf380d1c4e7f743355adb89/CH_18_packaging/T_02_basic_setup_py/setup.py` + +- [ ] **Step 2: Document exercise 2** + +Repeat the exact interactive question. Explain accepted names/numbers, invalid-choice errors, stdin integration testing, and explicit `--part` automation. Include its focused uv pytest command and the same immutable setuptools reference. + +- [ ] **Step 3: Document exercise 3** + +Repeat the exact migration question. Explain the supported static metadata subset, refusal to execute setup code, `src` layout, temporary build, wheel/sdist inspection, installed-wheel smoke test, and no network. Cite: + +- `CH_18_packaging/T_02_basic_setup_py/setup.py` +- `CH_18_packaging/T_00_basic_pyproject/pyproject.toml` + +using the pinned GitHub commit URL. + +- [ ] **Step 4: Link the chapter index and run packet checks** + +Retain all exact question text and link items 1 through 3 to their exercise directories. + +```bash +uv run --group packaging pytest CH_18_packaging -vv +uv run ruff format --check CH_18_packaging +uv run ruff check CH_18_packaging +uv run pyrefly check CH_18_packaging +uv run mypy CH_18_packaging +uv run pyright CH_18_packaging +``` + +Expected: every command exits 0. + +- [ ] **Step 5: Commit documentation** + +```bash +git add CH_18_packaging/README.rst CH_18_packaging/exercise_01/README.rst CH_18_packaging/exercise_02/README.rst CH_18_packaging/exercise_03/README.rst +git commit -m "docs: document packaging exercises" +``` + +## Packet acceptance + +- [ ] Confirm all packaging/build operations occur on copied temporary fixtures. +- [ ] Confirm no test executes untrusted `setup.py` during metadata extraction. +- [ ] Confirm `python -m build --no-isolation` creates exactly one wheel and one sdist. +- [ ] Confirm wheel metadata, entry point, import, and console behavior are verified. +- [ ] Run the packet on Python 3.10 and 3.14 with no network access. diff --git a/docs/superpowers/plans/2026-07-28-exercise-solutions-roadmap.md b/docs/superpowers/plans/2026-07-28-exercise-solutions-roadmap.md new file mode 100644 index 0000000..b80f210 --- /dev/null +++ b/docs/superpowers/plans/2026-07-28-exercise-solutions-roadmap.md @@ -0,0 +1,79 @@ +# Complete Exercise Solutions Implementation + +**For agentic workers:** REQUIRED SUB-SKILL: `superpowers:subagent-driven-development` (recommended) or `superpowers:executing-plans` to implement each packet task-by-task. Use checkbox (`- [ ]`) steps. + +**Goal:** Deliver canonical Python 3.10 answers, documentation, tests, and automated quality gates for all 56 repository exercises. + +**Architecture:** Shared repository tooling is established first. Thirteen file-disjoint chapter packets then execute in waves of at most three workers, with specification and quality review after every packet and full integration checks after every wave. + +**Tech Stack:** Python 3.10 and 3.14, uv, pytest, Ruff, Pyrefly, mypy, Pyright, Lefthook, GitHub Actions. + +--- + +## Plan Set + +Execute these plans in order: + +1. `2026-07-28-foundation-tooling.md` +2. `2026-07-28-ch02-interactive.md` +3. `2026-07-28-ch04-ch05-collections-functional.md` +4. `2026-07-28-ch06-tracking-memoization.md` +5. `2026-07-28-ch06-ch07-decorators-generators.md` +6. `2026-07-28-ch08-metaclasses.md` +7. `2026-07-28-ch09-ch10-typing-testing.md` +8. `2026-07-28-ch11-ch12-debugging-performance.md` +9. `2026-07-28-ch13-asyncio.md` +10. `2026-07-28-ch14-ipc-files.md` +11. `2026-07-28-ch14-workers-rpc-parallel.md` +12. `2026-07-28-ch15-ch16-scientific-nlp.md` +13. `2026-07-28-ch17-native.md` +14. `2026-07-28-ch18-packaging.md` + +## Execution Waves + +- [ ] **Execution preflight:** Invoke `superpowers:using-git-worktrees` and + create an isolated `codex/complete-exercise-solutions` worktree from the + commit containing this complete plan set. Run every subsequent task from that + worktree. +- [ ] **Wave 0:** Execute foundation tooling and verify configuration parsing, + dependency locking, hooks, and CI syntax. +- [ ] **Wave 1:** Execute Chapter 2, Chapters 4-5, and Chapter 6 exercises 1-4 + concurrently. +- [ ] **Wave 1 review:** Run specification review per packet, then run the full + canonical Ruff, Pyrefly, mypy, Pyright, and pytest commands. +- [ ] **Wave 2:** Execute Chapter 6 exercises 5-7 plus Chapter 7, Chapter 8, and + Chapters 9-10 concurrently. +- [ ] **Wave 2 review:** Repeat packet reviews and all repository quality gates. +- [ ] **Wave 3:** Execute Chapters 11-12, Chapter 13, and Chapter 14 exercises + 1-2 concurrently. +- [ ] **Wave 3 review:** Repeat packet reviews and all repository quality gates. +- [ ] **Wave 4:** Execute Chapter 14 exercises 3-7, Chapters 15-16, and Chapter + 17 concurrently. +- [ ] **Wave 4 review:** Repeat packet reviews and all repository quality gates, + including native and notebook integration markers. +- [ ] **Wave 5:** Execute Chapter 18. +- [ ] **Wave 5 review:** Run packaging integration checks and all repository + quality gates. + +## Final Integration + +- [ ] Run `uv lock --check` and expect exit code 0. +- [ ] Run `uv sync --locked --all-groups` with Python 3.10 and expect exit code + 0. +- [ ] Run `uv run ruff format --check .` and expect exit code 0. +- [ ] Run `uv run ruff check .` and expect exit code 0. +- [ ] Run `uv run pyrefly check` and expect zero errors. +- [ ] Run `uv run mypy .` and expect `Success: no issues found`. +- [ ] Run `uv run pyright` and expect zero errors. +- [ ] Run `uv run pytest` and expect all tests to pass with no unexplained + skips. +- [ ] Run the same pytest suite under Python 3.14 with + `uv run --python 3.14 pytest` and expect all tests to pass. +- [ ] Confirm `pgrep -af 'mastering-python-exercises'` finds no leaked worker, + server, or notebook processes. +- [ ] Run `git status --short` and confirm only intended tracked changes remain. +- [ ] Request independent code review with + `superpowers:requesting-code-review`; return verified findings to the owning + packet worker. +- [ ] Invoke `superpowers:verification-before-completion` and rerun every + acceptance command before making a completion claim. diff --git a/docs/superpowers/plans/2026-07-28-foundation-tooling.md b/docs/superpowers/plans/2026-07-28-foundation-tooling.md new file mode 100644 index 0000000..f18e143 --- /dev/null +++ b/docs/superpowers/plans/2026-07-28-foundation-tooling.md @@ -0,0 +1,488 @@ +# Repository Tooling Foundation Implementation + +**For agentic workers:** REQUIRED SUB-SKILL: `superpowers:subagent-driven-development` (recommended) or `superpowers:executing-plans` to implement this plan task-by-task. Use checkbox (`- [ ]`) steps. + +**Goal:** Add reproducible Python 3.10 project metadata, locked dependencies, canonical quality checks, Lefthook hooks, CI, and contributor documentation. + +**Architecture:** The repository remains a non-package uv project. Tool discovery includes all chapter Python files but excludes historical alternatives and fixture projects through centrally defined patterns; CI executes the same underlying commands as Lefthook. + +**Tech Stack:** uv, Ruff, pytest, pytest-timeout, Pyrefly, mypy, Pyright, Lefthook, GitHub Actions. + +--- + +### Task 1: Create the non-package uv project + +**Files:** +- Create: `pyproject.toml` +- Create: `.python-version` +- Create: `.gitignore` +- Create: `uv.lock` + +- [ ] **Step 1: Add the Python version marker** + +Create `.python-version`: + +```text +3.10 +``` + +- [ ] **Step 2: Add project metadata and dependency groups** + +Create `pyproject.toml`: + +```toml +[project] +name = "mastering-python-exercises" +version = "0.1.0" +description = "Tested solutions for Mastering Python Second Edition exercises" +readme = "README.rst" +requires-python = ">=3.10" +dependencies = [] + +[dependency-groups] +dev = [ + "docutils>=0.21", + "mypy>=1.18", + "pyrefly>=0.59", + "pyright>=1.1.411", + "pyyaml>=6.0", + "pytest>=9.0", + "pytest-timeout>=2.4", + "ruff>=0.15", +] +interactive = [ + "colorama>=0.4.6", + "ipython>=8.37", + "ipywidgets>=8.1", + "jedi>=0.19", +] +scientific = [ + "datashader>=0.18", + "nbclient>=0.10", + "nbformat>=5.10", + "numpy>=1.26", + "pandas>=2.2", +] +nlp = [ + "spacy>=3.8", +] +native = [ + "build>=1.3", + "cffi>=1.17", + "setuptools>=80", + "wheel>=0.45", +] +packaging = [ + "build>=1.3", + "setuptools>=80", + "tox>=4.30", + "wheel>=0.45", +] + +[tool.uv] +package = false +default-groups = ["dev"] +``` + +- [ ] **Step 3: Ignore generated local state** + +Create `.gitignore`: + +```gitignore +.DS_Store +.coverage +.mypy_cache/ +.pytest_cache/ +.pyrefly_cache/ +.ruff_cache/ +.tox/ +.venv/ +__pycache__/ +build/ +dist/ +*.egg-info/ +*.py[cod] +``` + +- [ ] **Step 4: Generate the cross-version lock** + +Run: + +```console +uv lock +``` + +Expected: exit code 0 and a new `uv.lock` that resolves all dependency groups +for the declared `>=3.10` range. + +- [ ] **Step 5: Verify the locked Python 3.10 environment** + +Run: + +```console +uv sync --python 3.10 --locked --all-groups +uv run python --version +``` + +Expected: both commands exit 0; the version line starts with `Python 3.10.`. + +- [ ] **Step 6: Commit project metadata** + +```console +git add pyproject.toml .python-version .gitignore uv.lock +git commit -m "build: add locked Python exercise environment" +``` + +### Task 2: Configure formatting, linting, tests, and three type checkers + +**Files:** +- Modify: `pyproject.toml` + +- [ ] **Step 1: Add the shared exclusion and Ruff configuration** + +Append to `pyproject.toml`: + +```toml +[tool.ruff] +target-version = "py310" +line-length = 88 +force-exclude = true +extend-exclude = [ + "**/fixtures/**", + "**/multiprocessing_solution_00.py", + "**/solution_[1-9][0-9].py", + "**/threading_solution_00.py", +] + +[tool.ruff.lint] +select = ["B", "E4", "E7", "E9", "F", "I", "RUF", "UP"] + +[tool.ruff.format] +docstring-code-format = true +``` + +- [ ] **Step 2: Add deterministic pytest discovery** + +Append: + +```toml +[tool.pytest.ini_options] +addopts = [ + "--import-mode=importlib", + "--strict-config", + "--strict-markers", + "--timeout=30", +] +python_files = ["test_solution_00.py"] +norecursedirs = ["fixtures"] +markers = [ + "integration: focused subprocess, notebook, plugin, or build integration", + "native: tests requiring a working native compiler", + "notebook: tests executing a notebook headlessly", +] +``` + +- [ ] **Step 3: Add strict mypy configuration** + +Append: + +```toml +[tool.mypy] +python_version = "3.10" +strict = true +show_error_codes = true +warn_unused_configs = true +exclude = [ + '(^|/)fixtures/', + '(^|/)multiprocessing_solution_00\.py$', + '(^|/)solution_[1-9][0-9]\.py$', + '(^|/)threading_solution_00\.py$', +] +``` + +- [ ] **Step 4: Add strict Pyright configuration** + +Append: + +```toml +[tool.pyright] +pythonVersion = "3.10" +typeCheckingMode = "strict" +include = ["CH_*"] +exclude = [ + "**/fixtures/**", + "**/multiprocessing_solution_00.py", + "**/solution_[1-9][0-9].py", + "**/threading_solution_00.py", +] +``` + +- [ ] **Step 5: Add Pyrefly configuration** + +Append: + +```toml +[tool.pyrefly] +python-version = "3.10" +project-includes = ["CH_*/**/*.py"] +project-excludes = [ + "**/fixtures/**", + "**/multiprocessing_solution_00.py", + "**/solution_[1-9][0-9].py", + "**/threading_solution_00.py", +] +check-unannotated-defs = true +infer-return-types = "never" +``` + +- [ ] **Step 6: Verify every tool reads its configuration** + +Run: + +```console +uv run ruff check --show-settings CH_04_design_patterns/__init__.py +uv run pytest --collect-only +uv run mypy --no-incremental CH_04_design_patterns/__init__.py +uv run pyright CH_04_design_patterns/__init__.py --outputjson +uv run pyrefly dump-config +``` + +Expected: no configuration parse errors. Quality commands may report canonical +source findings until the chapter packets are complete; record those findings +without suppressing them. + +- [ ] **Step 7: Commit quality configuration** + +```console +git add pyproject.toml +git commit -m "build: configure canonical quality checks" +``` + +### Task 3: Add Lefthook commands + +**Files:** +- Create: `lefthook.yml` + +- [ ] **Step 1: Create the hook configuration** + +Create `lefthook.yml`: + +```yaml +pre-commit: + parallel: false + commands: + format: + glob: "**/*.py" + run: uv run ruff format {staged_files} + stage_fixed: true + lint: + glob: "**/*.py" + run: uv run ruff check --fix {staged_files} + stage_fixed: true + +pre-push: + parallel: true + commands: + mypy: + run: uv run mypy . + pyrefly: + run: uv run pyrefly check + pyright: + run: uv run pyright + pytest: + run: uv run pytest +``` + +- [ ] **Step 2: Validate Lefthook parsing** + +Run: + +```console +lefthook validate +lefthook dump +``` + +Expected: both commands exit 0 and list `pre-commit` and `pre-push`. + +- [ ] **Step 3: Commit the hook configuration** + +```console +git add lefthook.yml +git commit -m "build: add Lefthook quality gates" +``` + +### Task 4: Add GitHub Actions CI + +**Files:** +- Create: `.github/workflows/ci.yml` + +- [ ] **Step 1: Create the workflow** + +Create `.github/workflows/ci.yml`: + +```yaml +name: CI + +on: + push: + branches: [master] + pull_request: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + quality: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 + with: + enable-cache: true + python-version: "3.10" + - run: uv sync --locked --all-groups + - run: uv run ruff format --check . + - run: uv run ruff check . + - run: uv run pyrefly check + - run: uv run mypy . + - run: uv run pyright + + tests: + runs-on: ubuntu-latest + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + python-version: ["3.10", "3.14"] + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 + with: + enable-cache: true + python-version: ${{ matrix.python-version }} + - run: uv sync --locked --all-groups + - run: uv run pytest +``` + +- [ ] **Step 2: Parse the workflow as YAML** + +Run: + +```console +uv run python -c "import pathlib; import yaml; yaml.safe_load(pathlib.Path('.github/workflows/ci.yml').read_text())" +``` + +Expected: exit code 0. + +- [ ] **Step 3: Commit CI** + +```console +git add .github/workflows/ci.yml pyproject.toml uv.lock +git commit -m "ci: test canonical solutions and quality gates" +``` + +### Task 5: Replace the root contributor documentation + +**Files:** +- Modify: `README.rst` + +- [ ] **Step 1: Replace the README** + +Use this complete structure and retain the existing invitation to submit +alternatives: + +```rst +Mastering Python Second Edition Exercises +========================================== + +This repository contains canonical and community solutions for all exercises +from *Mastering Python, Second Edition*. ``solution_00.py`` is the maintained, +typed reference answer. Higher-numbered and specialized solution files are +preserved community alternatives. + +Requirements +------------ + +Python 3.10 or newer, uv, and Lefthook are required. On macOS: + +.. code-block:: console + + brew install uv lefthook + uv sync --all-groups + lefthook install + +Running exercises +----------------- + +Each ``exercise_NN`` directory contains its exact question, explanation, +canonical answer, dependencies, and focused tests. Run one answer or test: + +.. code-block:: console + + uv run python CH_04_design_patterns/exercise_01/solution_00.py + uv run pytest CH_04_design_patterns/exercise_01/test_solution_00.py + +Run all repository checks: + +.. code-block:: console + + uv run ruff format --check . + uv run ruff check . + uv run pyrefly check + uv run mypy . + uv run pyright + uv run pytest + +Dependency groups +----------------- + +``dev`` contains repository quality tools. ``interactive``, ``scientific``, +``nlp``, ``native``, and ``packaging`` contain dependencies needed by their +respective chapter exercises. ``uv sync --all-groups`` installs the complete +locked environment without runtime downloads. + +Contributing +------------ + +Alternative solutions are welcome as the next available ``solution_NN.py``. +Keep canonical files, tests, and exercise documentation unchanged unless the +reference answer itself is being corrected. All submitted Python code must be +formatted, linted, typed, and tested. +``` + +- [ ] **Step 2: Check README rendering syntax** + +Run: + +```console +uv run rst2html5 README.rst /tmp/mastering-python-exercises-readme.html +``` + +Expected: exit code 0 and no reStructuredText errors. + +- [ ] **Step 3: Run foundation verification** + +Run: + +```console +uv lock --check +lefthook validate +uv run pytest --collect-only +git diff --check +``` + +Expected: the lock, Lefthook, and diff checks pass. Pytest collection lists +canonical tests added by completed packets, or exits 5 only when foundation is +executed before the first packet. + +- [ ] **Step 4: Commit documentation** + +```console +git add README.rst pyproject.toml uv.lock +git commit -m "docs: document exercise development workflow" +``` diff --git a/docs/superpowers/specs/2026-07-27-complete-exercise-solutions-design.md b/docs/superpowers/specs/2026-07-27-complete-exercise-solutions-design.md new file mode 100644 index 0000000..94ef474 --- /dev/null +++ b/docs/superpowers/specs/2026-07-27-complete-exercise-solutions-design.md @@ -0,0 +1,274 @@ +# Complete Python 3.10 Exercise Solutions + +## Goal + +Provide a high-quality, Python 3.10-compatible canonical answer for all 56 +substantive exercises in this repository. Every answer must be documented, +explicitly typed, automatically tested with pytest, and reproducible from a +clean checkout. + +The repository will also gain locked development dependencies, Ruff formatting +and linting, strict checks with Pyrefly, mypy, and Pyright, Lefthook Git hooks, +and GitHub Actions CI. + +## Existing Content + +The repository has 18 chapter directories and 56 substantive exercises. +Chapters 1 and 3 explicitly have no exercises. Existing coverage is incomplete: +28 exercise directories contain 33 answer files, including 24 reference +`solution_00.py` files and several contributed alternatives. + +Existing contributed alternatives are historical material. They must remain +unchanged: + +- `solution_01.py` and future numbered alternatives +- `threading_solution_00.py` +- `multiprocessing_solution_00.py` + +Existing `solution_00.py` files are the canonical reference answers and may be +rewritten to satisfy this design. + +## Exercise Directory Contract + +Create every missing `exercise_NN/` directory. Each exercise directory must +contain: + +- `__init__.py` +- `README.rst` +- `solution_00.py` +- `test_solution_00.py` +- only the minimal support files or fixtures required by that exercise + +Each `README.rst` must: + +1. Repeat the exact exercise question from the chapter README. +2. Explain briefly what the answer is expected to do. +3. State important behavioral choices and edge cases. +4. List exercise-specific dependencies. +5. Show commands for running the answer and its tests with `uv run`. +6. Cite immutable upstream references where they informed the answer. + +Chapter READMEs remain concise exercise indexes and link to their exercise +directories. + +## Canonical Solution Contract + +Each `solution_00.py` must: + +- parse and run on Python 3.10; +- annotate all variables, function signatures, attributes, and public return + values explicitly; +- remain import-safe, with demonstrations guarded by + `if __name__ == "__main__":`; +- expose reusable functions or classes rather than hiding behavior in a script; +- preserve existing public names when they remain compatible with the prompt; +- use deterministic behavior unless nondeterminism is intrinsic to the + exercise; +- raise specific built-in exceptions for invalid input or failed operations; +- avoid swallowing worker, build, plugin, subprocess, or I/O errors. + +Complex exercises may use helper modules, notebooks, C sources, or fixture +projects. `solution_00.py` remains the documented canonical entry point. + +## Upstream Source Material + +Missing book examples are resolved against: + +`mastering-python/code_2@a012f6ce3f5bf11f5bf380d1c4e7f743355adb89` + +Upstream code is example material, not a supported importable package. Solutions +must not clone or import it at runtime. Use SHA-pinned permalinks for provenance +and commit the smallest attributed local fixture or snippet needed for offline +execution. Preserve applicable MIT attribution. + +## Exercise Coverage + +Implementation is divided into 13 file-disjoint packets: + +1. Chapter 2: five interactive Python exercises. +2. Chapters 4-5: six design-pattern and functional-programming exercises. +3. Chapter 6 exercises 1-4: tracking and memoization. +4. Chapter 6 exercises 5-7 and Chapter 7: descriptors, dispatch, validation, + generators, and slicing. +5. Chapter 8: three metaclass exercises. +6. Chapters 9-10: three typing exercises and five testing/logging exercises. +7. Chapters 11-12: six debugging and performance exercises. +8. Chapter 13: two asyncio exercises. +9. Chapter 14 exercises 1-2: IPC and concurrent file processing. +10. Chapter 14 exercises 3-7: threaded/process traversal, worker pools, RPC, + and parallel functional programming. +11. Chapters 15-16: Datashader, an interactive notebook, and deterministic NLP. +12. Chapter 17: two native-extension exercises. +13. Chapter 18: three packaging exercises. + +Notable exercise decisions: + +- Chapter 4's sorted collection must provide logarithmic insertion rather than + merely logarithmic search followed by linear list insertion. +- Chapter 7 negative-step slicing may materialize a finite iterable and must + document that requirement. +- Chapter 10's tox answer remains faithful to its flake8/mypy prompt even + though repository-wide linting uses Ruff. +- Chapter 14 exercises 3 and 4 expose canonical threaded and multiprocessing + APIs while preserving the historical specialized answer files. +- Chapter 14 exercise 5 must contain an actual persistent queue worker pool, + replacing the current cross-reference-only answer. +- Chapter 15 uses tiny local data and a headlessly executable notebook. +- Chapter 16 uses deterministic local chapter-summary text and no downloaded + NLP model. +- Chapter 17 exercise 1 implements and cross-checks ctypes, CFFI, and native + extension `qsort` paths. +- Chapter 17 exercise 2 detects per-value conversion failures and cumulative + overflow or underflow. +- Chapter 18 operates on temporary fixture packages and verifies build output. + +## Tests + +Every exercise has co-located pytest coverage derived from the exact prompt. +Tests cover: + +- normal behavior; +- boundaries and empty inputs; +- invalid values and types; +- repeatability; +- failure propagation; +- representative plausible broken implementations. + +Environment-heavy tests use focused integration rather than broad mocks: + +- IPython, Jedi, widgets, and notebooks run headlessly. +- Pytest plugins use pytest's test-project facilities. +- Tox and packaging exercises use temporary fixture projects. +- Timing and memory utilities accept injectable clocks or samplers and use + bounded tolerances. +- Concurrency tests use spawn-safe top-level callables, bounded pools, explicit + timeouts, deterministic shutdown, loopback or local IPC only, and orphan + checks. +- Native-extension tests compile in temporary build directories and cross-check + results between implementations. + +Tests must not require external network access, fixed ports, external datasets, +language-model downloads, or interactive input. + +## Project and Dependency Configuration + +Add a non-package uv project: + +- `requires-python = ">=3.10"` +- `.python-version` set to `3.10` +- `[tool.uv] package = false` +- committed `uv.lock` + +Dependency groups: + +- `dev`: Ruff, pytest, pytest-timeout, mypy, Pyright, and Pyrefly +- `interactive`: IPython, Jedi, Colorama, and ipywidgets +- `scientific`: Datashader and headless notebook dependencies +- `nlp`: spaCy without an external model +- `native`: CFFI and native build tooling +- `packaging`: setuptools, build, wheel, and tox + +The root must not define a build backend because automatic discovery could +accidentally package every chapter directory. + +## Static Quality Gates + +Ruff formats and lints: + +- canonical `solution_00.py` files; +- co-located tests; +- canonical helper modules; +- package initializers. + +Pyrefly, mypy, and Pyright check the same canonical scope in strict Python 3.10 +mode. Exclude historical alternatives and intentionally invalid fixture +projects. Do not use repository-wide missing-import suppression. Untyped +third-party boundaries must use small typed adapters, protocols, or narrowly +documented error-code-specific suppressions. + +## Lefthook + +Add `lefthook.yml` with: + +- pre-commit formatting and linting for relevant staged canonical Python files; +- safe re-staging of formatter/linter fixes; +- pre-push Pyrefly, mypy, Pyright, and complete pytest execution. + +The root README documents: + +```console +uv sync --all-groups +brew install lefthook +lefthook install +``` + +CI invokes the underlying quality commands directly rather than depending on +Lefthook. + +## Continuous Integration + +Add a GitHub Actions workflow with: + +- read-only repository permissions; +- concurrency cancellation for superseded branch or pull-request runs; +- locked uv dependency installation and caching; +- explicit job timeouts. + +Jobs: + +1. Quality on Python 3.10: + - `uv sync --locked --all-groups` + - Ruff format check + - Ruff lint + - Pyrefly + - mypy + - Pyright +2. Pytest matrix on Python 3.10 and Python 3.14: + - `uv sync --locked --all-groups` + - complete pytest suite, including focused integration tests + +Pin actions to reviewed commit SHAs. At design time the reviewed releases are: + +- `actions/checkout` v7.0.1: + `3d3c42e5aac5ba805825da76410c181273ba90b1` +- `astral-sh/setup-uv` v9.0.0: + `c771a70e6277c0a99b617c7a806ffedaca235ff9` + +## Team Workflow + +The coordinator exclusively owns shared configuration, the lockfile, root +documentation, CI, and final integration. Up to three chapter agents work +concurrently with exclusive packet ownership. + +Each chapter agent: + +1. Reads the prompt and pinned upstream references. +2. Writes failing contract tests. +3. Implements or upgrades `solution_00.py`. +4. Adds the exercise README and attributed fixtures. +5. Runs packet-scoped Ruff, type checks, and pytest. +6. Reports verification evidence and unresolved ambiguity. + +A separate reviewer checks prompt fidelity, Python 3.10 compatibility, public +API clarity, edge cases, test effectiveness, and README reproducibility. +Findings return to the owning agent before integration. + +After every wave, the coordinator runs repository-wide canonical quality gates. +Agents must accommodate concurrent edits and never revert unrelated work. + +## Acceptance Criteria + +The work is complete only when: + +- all 56 exercises have canonical answers and per-exercise READMEs; +- all exercise tests pass on Python 3.10 and Python 3.14; +- canonical scope has zero Ruff, Pyrefly, mypy, or Pyright findings; +- native, notebook, concurrency, pytest-plugin, tox, and packaging integration + paths pass; +- setup and commands work from a clean locked environment; +- no runtime downloads, orphan processes, generated tracked artifacts, or + unexplained skips remain; +- an independent final review has no unresolved findings. + +Implementation remains local unless the user separately authorizes GitHub +writes. diff --git a/lefthook.yml b/lefthook.yml new file mode 100644 index 0000000..51f4054 --- /dev/null +++ b/lefthook.yml @@ -0,0 +1,25 @@ +pre-commit: + parallel: false + commands: + lint: + priority: 1 + glob: "**/*.py" + run: uv run ruff check --fix {staged_files} + stage_fixed: true + format: + priority: 2 + glob: "**/*.py" + run: uv run ruff format {staged_files} + stage_fixed: true + +pre-push: + parallel: true + commands: + mypy: + run: uv run mypy . + pyrefly: + run: uv run pyrefly check + pyright: + run: uv run pyright + pytest: + run: uv run pytest diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..b633975 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,145 @@ +[project] +name = "mastering-python-exercises" +version = "0.1.0" +description = "Tested solutions for Mastering Python Second Edition exercises" +readme = "README.rst" +requires-python = ">=3.10" +dependencies = [] + +[dependency-groups] +dev = [ + "docutils>=0.21", + "mypy>=1.18", + "pyrefly>=0.59", + "pyright>=1.1.411", + "pyyaml>=6.0", + "pytest>=9.0", + "pytest-timeout>=2.4", + "ruff>=0.15", +] +interactive = [ + "colorama>=0.4.6", + "ipython>=8.37", + "ipywidgets>=8.1", + "jedi>=0.19", +] +scientific = [ + "datashader>=0.18", + "ipykernel>=6.29", + "ipywidgets>=8.1", + "jupyter-client>=8.6", + "jupyterlab>=4.4", + "nbclient>=0.10", + "nbformat>=5.10", + "numpy>=1.26", + "pandas>=2.2", + "pillow>=11", + "xarray>=2025.6.1", +] +nlp = [ + "spacy>=3.8.13,<3.8.14", + "typer>=0.25,<0.26", +] +native = [ + "build>=1.3", + "cffi>=1.17", + "setuptools>=80", + "wheel>=0.45", +] +packaging = [ + "build>=1.3", + "setuptools>=80", + "tox>=4.30", + "wheel>=0.45", +] + +[tool.uv] +package = false +default-groups = ["dev"] + +[tool.ruff] +target-version = "py310" +line-length = 88 +force-exclude = true +include = ["CH_*/**/*.py"] +extend-exclude = [ + "**/fixtures/**", + "**/multiprocessing_solution_00.py", + "**/solution_0[1-9].py", + "**/solution_[1-9][0-9].py", + "**/threading_solution_00.py", +] + +[tool.ruff.lint] +select = ["B", "E4", "E7", "E9", "F", "I", "RUF", "UP"] + +[tool.ruff.format] +docstring-code-format = true + +[tool.pytest.ini_options] +addopts = [ + "--import-mode=importlib", + "--strict-config", + "--strict-markers", + "--timeout=30", +] +python_files = ["test_solution_00.py", "test_native_build.py"] +norecursedirs = ["fixtures"] +markers = [ + "integration: focused subprocess, notebook, plugin, or build integration", + "native: tests requiring a working native compiler", + "notebook: tests executing a notebook headlessly", +] + +[tool.mypy] +python_version = "3.10" +strict = true +show_error_codes = true +warn_unused_configs = true +exclude = [ + "(^|/)fixtures/", + "(^|/)multiprocessing_solution_00\\.py$", + "(^|/)solution_(0[1-9]|[1-9][0-9])\\.py$", + "(^|/)threading_solution_00\\.py$", +] + +[tool.pyright] +pythonVersion = "3.10" +typeCheckingMode = "strict" +include = ["CH_*"] +exclude = [ + "**/fixtures/**", + "**/multiprocessing_solution_00.py", + "**/solution_01.py", + "**/solution_02.py", + "**/solution_03.py", + "**/solution_04.py", + "**/solution_05.py", + "**/solution_06.py", + "**/solution_07.py", + "**/solution_08.py", + "**/solution_09.py", + "**/solution_1?.py", + "**/solution_2?.py", + "**/solution_3?.py", + "**/solution_4?.py", + "**/solution_5?.py", + "**/solution_6?.py", + "**/solution_7?.py", + "**/solution_8?.py", + "**/solution_9?.py", + "**/threading_solution_00.py", +] + +[tool.pyrefly] +python-version = "3.10" +project-includes = ["CH_*/**/*.py"] +project-excludes = [ + "**/fixtures/**", + "**/multiprocessing_solution_00.py", + "**/solution_0[1-9].py", + "**/solution_[1-9][0-9].py", + "**/threading_solution_00.py", +] +check-unannotated-defs = true +infer-return-types = "never" diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..46fa102 --- /dev/null +++ b/uv.lock @@ -0,0 +1,4218 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" +resolution-markers = [ + "python_full_version >= '3.15' and sys_platform == 'win32'", + "python_full_version >= '3.15' and sys_platform == 'emscripten'", + "python_full_version >= '3.15' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.14.*' and sys_platform == 'win32'", + "python_full_version == '3.14.*' and sys_platform == 'emscripten'", + "python_full_version == '3.14.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", + "python_full_version == '3.11.*' and sys_platform == 'emscripten'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version < '3.11'", +] + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, +] + +[[package]] +name = "anyio" +version = "4.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, +] + +[[package]] +name = "appnope" +version = "0.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/35/5d/752690df9ef5b76e169e68d6a129fa6d08a7100ca7f754c89495db3c6019/appnope-0.1.4.tar.gz", hash = "sha256:1de3860566df9caf38f01f86f65e0e13e379af54f9e4bee1e66b48f2efffd1ee", size = 4170, upload-time = "2024-02-06T09:43:11.258Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/29/5ecc3a15d5a33e31b26c11426c45c501e439cb865d0bff96315d86443b78/appnope-0.1.4-py2.py3-none-any.whl", hash = "sha256:502575ee11cd7a28c0205f379b525beefebab9d161b7c964670864014ed7213c", size = 4321, upload-time = "2024-02-06T09:43:09.663Z" }, +] + +[[package]] +name = "argon2-cffi" +version = "25.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "argon2-cffi-bindings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0e/89/ce5af8a7d472a67cc819d5d998aa8c82c5d860608c4db9f46f1162d7dab9/argon2_cffi-25.1.0.tar.gz", hash = "sha256:694ae5cc8a42f4c4e2bf2ca0e64e51e23a040c6a517a85074683d3959e1346c1", size = 45706, upload-time = "2025-06-03T06:55:32.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4f/d3/a8b22fa575b297cd6e3e3b0155c7e25db170edf1c74783d6a31a2490b8d9/argon2_cffi-25.1.0-py3-none-any.whl", hash = "sha256:fdc8b074db390fccb6eb4a3604ae7231f219aa669a2652e0f20e16ba513d5741", size = 14657, upload-time = "2025-06-03T06:55:30.804Z" }, +] + +[[package]] +name = "argon2-cffi-bindings" +version = "25.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5c/2d/db8af0df73c1cf454f71b2bbe5e356b8c1f8041c979f505b3d3186e520a9/argon2_cffi_bindings-25.1.0.tar.gz", hash = "sha256:b957f3e6ea4d55d820e40ff76f450952807013d361a65d7f28acc0acbf29229d", size = 1783441, upload-time = "2025-07-30T10:02:05.147Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/97/3c0a35f46e52108d4707c44b95cfe2afcafc50800b5450c197454569b776/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:3d3f05610594151994ca9ccb3c771115bdb4daef161976a266f0dd8aa9996b8f", size = 54393, upload-time = "2025-07-30T10:01:40.97Z" }, + { url = "https://files.pythonhosted.org/packages/9d/f4/98bbd6ee89febd4f212696f13c03ca302b8552e7dbf9c8efa11ea4a388c3/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8b8efee945193e667a396cbc7b4fb7d357297d6234d30a489905d96caabde56b", size = 29328, upload-time = "2025-07-30T10:01:41.916Z" }, + { url = "https://files.pythonhosted.org/packages/43/24/90a01c0ef12ac91a6be05969f29944643bc1e5e461155ae6559befa8f00b/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3c6702abc36bf3ccba3f802b799505def420a1b7039862014a65db3205967f5a", size = 31269, upload-time = "2025-07-30T10:01:42.716Z" }, + { url = "https://files.pythonhosted.org/packages/d4/d3/942aa10782b2697eee7af5e12eeff5ebb325ccfb86dd8abda54174e377e4/argon2_cffi_bindings-25.1.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a1c70058c6ab1e352304ac7e3b52554daadacd8d453c1752e547c76e9c99ac44", size = 86558, upload-time = "2025-07-30T10:01:43.943Z" }, + { url = "https://files.pythonhosted.org/packages/0d/82/b484f702fec5536e71836fc2dbc8c5267b3f6e78d2d539b4eaa6f0db8bf8/argon2_cffi_bindings-25.1.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e2fd3bfbff3c5d74fef31a722f729bf93500910db650c925c2d6ef879a7e51cb", size = 92364, upload-time = "2025-07-30T10:01:44.887Z" }, + { url = "https://files.pythonhosted.org/packages/c9/c1/a606ff83b3f1735f3759ad0f2cd9e038a0ad11a3de3b6c673aa41c24bb7b/argon2_cffi_bindings-25.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4f9665de60b1b0e99bcd6be4f17d90339698ce954cfd8d9cf4f91c995165a92", size = 85637, upload-time = "2025-07-30T10:01:46.225Z" }, + { url = "https://files.pythonhosted.org/packages/44/b4/678503f12aceb0262f84fa201f6027ed77d71c5019ae03b399b97caa2f19/argon2_cffi_bindings-25.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ba92837e4a9aa6a508c8d2d7883ed5a8f6c308c89a4790e1e447a220deb79a85", size = 91934, upload-time = "2025-07-30T10:01:47.203Z" }, + { url = "https://files.pythonhosted.org/packages/f0/c7/f36bd08ef9bd9f0a9cff9428406651f5937ce27b6c5b07b92d41f91ae541/argon2_cffi_bindings-25.1.0-cp314-cp314t-win32.whl", hash = "sha256:84a461d4d84ae1295871329b346a97f68eade8c53b6ed9a7ca2d7467f3c8ff6f", size = 28158, upload-time = "2025-07-30T10:01:48.341Z" }, + { url = "https://files.pythonhosted.org/packages/b3/80/0106a7448abb24a2c467bf7d527fe5413b7fdfa4ad6d6a96a43a62ef3988/argon2_cffi_bindings-25.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b55aec3565b65f56455eebc9b9f34130440404f27fe21c3b375bf1ea4d8fbae6", size = 32597, upload-time = "2025-07-30T10:01:49.112Z" }, + { url = "https://files.pythonhosted.org/packages/05/b8/d663c9caea07e9180b2cb662772865230715cbd573ba3b5e81793d580316/argon2_cffi_bindings-25.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:87c33a52407e4c41f3b70a9c2d3f6056d88b10dad7695be708c5021673f55623", size = 28231, upload-time = "2025-07-30T10:01:49.92Z" }, + { url = "https://files.pythonhosted.org/packages/1d/57/96b8b9f93166147826da5f90376e784a10582dd39a393c99bb62cfcf52f0/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:aecba1723ae35330a008418a91ea6cfcedf6d31e5fbaa056a166462ff066d500", size = 54121, upload-time = "2025-07-30T10:01:50.815Z" }, + { url = "https://files.pythonhosted.org/packages/0a/08/a9bebdb2e0e602dde230bdde8021b29f71f7841bd54801bcfd514acb5dcf/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:2630b6240b495dfab90aebe159ff784d08ea999aa4b0d17efa734055a07d2f44", size = 29177, upload-time = "2025-07-30T10:01:51.681Z" }, + { url = "https://files.pythonhosted.org/packages/b6/02/d297943bcacf05e4f2a94ab6f462831dc20158614e5d067c35d4e63b9acb/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:7aef0c91e2c0fbca6fc68e7555aa60ef7008a739cbe045541e438373bc54d2b0", size = 31090, upload-time = "2025-07-30T10:01:53.184Z" }, + { url = "https://files.pythonhosted.org/packages/c1/93/44365f3d75053e53893ec6d733e4a5e3147502663554b4d864587c7828a7/argon2_cffi_bindings-25.1.0-cp39-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e021e87faa76ae0d413b619fe2b65ab9a037f24c60a1e6cc43457ae20de6dc6", size = 81246, upload-time = "2025-07-30T10:01:54.145Z" }, + { url = "https://files.pythonhosted.org/packages/09/52/94108adfdd6e2ddf58be64f959a0b9c7d4ef2fa71086c38356d22dc501ea/argon2_cffi_bindings-25.1.0-cp39-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d3e924cfc503018a714f94a49a149fdc0b644eaead5d1f089330399134fa028a", size = 87126, upload-time = "2025-07-30T10:01:55.074Z" }, + { url = "https://files.pythonhosted.org/packages/72/70/7a2993a12b0ffa2a9271259b79cc616e2389ed1a4d93842fac5a1f923ffd/argon2_cffi_bindings-25.1.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87b72589133f0346a1cb8d5ecca4b933e3c9b64656c9d175270a000e73b288d", size = 80343, upload-time = "2025-07-30T10:01:56.007Z" }, + { url = "https://files.pythonhosted.org/packages/78/9a/4e5157d893ffc712b74dbd868c7f62365618266982b64accab26bab01edc/argon2_cffi_bindings-25.1.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1db89609c06afa1a214a69a462ea741cf735b29a57530478c06eb81dd403de99", size = 86777, upload-time = "2025-07-30T10:01:56.943Z" }, + { url = "https://files.pythonhosted.org/packages/74/cd/15777dfde1c29d96de7f18edf4cc94c385646852e7c7b0320aa91ccca583/argon2_cffi_bindings-25.1.0-cp39-abi3-win32.whl", hash = "sha256:473bcb5f82924b1becbb637b63303ec8d10e84c8d241119419897a26116515d2", size = 27180, upload-time = "2025-07-30T10:01:57.759Z" }, + { url = "https://files.pythonhosted.org/packages/e2/c6/a759ece8f1829d1f162261226fbfd2c6832b3ff7657384045286d2afa384/argon2_cffi_bindings-25.1.0-cp39-abi3-win_amd64.whl", hash = "sha256:a98cd7d17e9f7ce244c0803cad3c23a7d379c301ba618a5fa76a67d116618b98", size = 31715, upload-time = "2025-07-30T10:01:58.56Z" }, + { url = "https://files.pythonhosted.org/packages/42/b9/f8d6fa329ab25128b7e98fd83a3cb34d9db5b059a9847eddb840a0af45dd/argon2_cffi_bindings-25.1.0-cp39-abi3-win_arm64.whl", hash = "sha256:b0fdbcf513833809c882823f98dc2f931cf659d9a1429616ac3adebb49f5db94", size = 27149, upload-time = "2025-07-30T10:01:59.329Z" }, + { url = "https://files.pythonhosted.org/packages/11/2d/ba4e4ca8d149f8dcc0d952ac0967089e1d759c7e5fcf0865a317eb680fbb/argon2_cffi_bindings-25.1.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:6dca33a9859abf613e22733131fc9194091c1fa7cb3e131c143056b4856aa47e", size = 24549, upload-time = "2025-07-30T10:02:00.101Z" }, + { url = "https://files.pythonhosted.org/packages/5c/82/9b2386cc75ac0bd3210e12a44bfc7fd1632065ed8b80d573036eecb10442/argon2_cffi_bindings-25.1.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:21378b40e1b8d1655dd5310c84a40fc19a9aa5e6366e835ceb8576bf0fea716d", size = 25539, upload-time = "2025-07-30T10:02:00.929Z" }, + { url = "https://files.pythonhosted.org/packages/31/db/740de99a37aa727623730c90d92c22c9e12585b3c98c54b7960f7810289f/argon2_cffi_bindings-25.1.0-pp310-pypy310_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d588dec224e2a83edbdc785a5e6f3c6cd736f46bfd4b441bbb5aa1f5085e584", size = 28467, upload-time = "2025-07-30T10:02:02.08Z" }, + { url = "https://files.pythonhosted.org/packages/71/7a/47c4509ea18d755f44e2b92b7178914f0c113946d11e16e626df8eaa2b0b/argon2_cffi_bindings-25.1.0-pp310-pypy310_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5acb4e41090d53f17ca1110c3427f0a130f944b896fc8c83973219c97f57b690", size = 27355, upload-time = "2025-07-30T10:02:02.867Z" }, + { url = "https://files.pythonhosted.org/packages/ee/82/82745642d3c46e7cea25e1885b014b033f4693346ce46b7f47483cf5d448/argon2_cffi_bindings-25.1.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:da0c79c23a63723aa5d782250fbf51b768abca630285262fb5144ba5ae01e520", size = 29187, upload-time = "2025-07-30T10:02:03.674Z" }, +] + +[[package]] +name = "arrow" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, + { name = "tzdata" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/33/032cdc44182491aa708d06a68b62434140d8c50820a087fac7af37703357/arrow-1.4.0.tar.gz", hash = "sha256:ed0cc050e98001b8779e84d461b0098c4ac597e88704a655582b21d116e526d7", size = 152931, upload-time = "2025-10-18T17:46:46.761Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ed/c9/d7977eaacb9df673210491da99e6a247e93df98c715fc43fd136ce1d3d33/arrow-1.4.0-py3-none-any.whl", hash = "sha256:749f0769958ebdc79c173ff0b0670d59051a535fa26e8eba02953dc19eb43205", size = 68797, upload-time = "2025-10-18T17:46:45.663Z" }, +] + +[[package]] +name = "ast-serialize" +version = "0.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/ad/0d70a3a2d6e01968d985415259e8ec7ad3f777903f9b1c1f3c8c44642c60/ast_serialize-0.6.0.tar.gz", hash = "sha256:aadd3ffcf4858c9726bf3515f7b199c7eadbe504f96028e4a87172c0da65a8fe", size = 61489, upload-time = "2026-06-30T20:02:55.555Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/12/3e5f575f156555547c250a8b0d1347517a3a20fc7f4492e9703a69d4f45e/ast_serialize-0.6.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:a7520b672827885bafeae7501f684d14d47d17e5f45256f9df547686cca52264", size = 1177640, upload-time = "2026-06-30T20:02:06.708Z" }, + { url = "https://files.pythonhosted.org/packages/a2/a4/921a9e27951627983b0f368859ea00f8330a551dc0bf4c2fdcb11855a98b/ast_serialize-0.6.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a14191beec7e0c078d2fc1f6edc0aee88bcd4db9f18e1bc9f8052b559c22dddc", size = 1168111, upload-time = "2026-06-30T20:02:08.366Z" }, + { url = "https://files.pythonhosted.org/packages/00/69/950cf404de7b8782cf95e5c1237e25e2aa46177b287f39f9eeddf481fd6f/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:32ef62ec34cf6be20ad77d4799556638fbdf187f3ae10698dfb20ef9f2c89516", size = 1227656, upload-time = "2026-06-30T20:02:09.843Z" }, + { url = "https://files.pythonhosted.org/packages/4c/a8/46f8f6a6479d9d2273980957bb091a506c55f5b95d3c029ee58518a78407/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:13b7769970a39983b0adf2f38917b1cd3b8946f76df045756c3d741bc689f089", size = 1227706, upload-time = "2026-06-30T20:02:11.367Z" }, + { url = "https://files.pythonhosted.org/packages/b7/b9/9ac415bda0a40e49eab8fea3b2741c19c98bb84d57d62c4cfc6230eb67be/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6f7a408601bb3edaefb3bc67a4c01f5235e3253653b6a5729a2ee2382b35341c", size = 1431705, upload-time = "2026-06-30T20:02:12.737Z" }, + { url = "https://files.pythonhosted.org/packages/e5/06/8807115d441444879f7561b5eede5ac18fc80392f11826d61ccf31f503b1/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8670bfa51208a2c0c8d138928e40e998fab158f9200d53bb80c088b5b8eda7b8", size = 1249533, upload-time = "2026-06-30T20:02:14.571Z" }, + { url = "https://files.pythonhosted.org/packages/3e/c0/c2ba82ef9618650357d9421a1fdb27ffec862a7f57e8e2de82a3ccd11e12/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a4826809eb8597a8cd59fd924b6d7c285b8969a1e0007e2cb652cab62376270f", size = 1252619, upload-time = "2026-06-30T20:02:16.219Z" }, + { url = "https://files.pythonhosted.org/packages/0f/a7/fa31d52dd4102cede29fb9634e98d214129b2783b4f95528c6dc6a8f6587/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:577a6c189068686869f5f1ddc38363f3ae1808a4753b577266f9202071a7bb66", size = 1242983, upload-time = "2026-06-30T20:02:17.813Z" }, + { url = "https://files.pythonhosted.org/packages/b1/20/ddf742b5ad3c4bafd3466f2265037cfd99bc1b9a5ee46a5d58c90d523242/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:085de7f62dc9cc247eb01e965a362707d1d90b1d89a82c5bf78301a60a3c417b", size = 1296148, upload-time = "2026-06-30T20:02:19.146Z" }, + { url = "https://files.pythonhosted.org/packages/24/cb/9f6f217cce8b3b632c5568b478d195a35e79dce4dbe309438cb89ba6ea4f/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9f8a8b78b13173de6a9ec22111d9be674874cd5bdccda04f14ae5ebc2bef403a", size = 1403826, upload-time = "2026-06-30T20:02:20.696Z" }, + { url = "https://files.pythonhosted.org/packages/2d/f8/9d16d4f0107a183924425cc0e7618d8bf76f96b45afa9ff19f924ed1ad57/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:f2ff3baffc3a29c1f15bc9098aa0c09763410262d5e6cef42116f7356c184554", size = 1502943, upload-time = "2026-06-30T20:02:22.034Z" }, + { url = "https://files.pythonhosted.org/packages/80/dd/bbc1c38756350dddf7e24acae1c9482ef42051c267417e019aecc1ed4075/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0067b25fce104eaae5b88383de9ab803faeb671831e14ca698b771b356e2600f", size = 1497632, upload-time = "2026-06-30T20:02:23.517Z" }, + { url = "https://files.pythonhosted.org/packages/42/7e/9daffefcf5b97e6bb4c3e0b3c024c1aee9722f23d3cf7cd2ff80d6fb4a40/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c617417f9cbb0cb144f6283c3cbe0d2e0f01beaf9f608f662b21191058a626ec", size = 1448858, upload-time = "2026-06-30T20:02:24.889Z" }, + { url = "https://files.pythonhosted.org/packages/e5/1f/f9baaab81a677ea0af7d2458cac2f94ebcc85958f8a3c15ba9d9e5dab653/ast_serialize-0.6.0-cp314-cp314t-win32.whl", hash = "sha256:5337cb256dcea3df9288205213d1601581536526b8f4da44b6974f1180f3252a", size = 1052600, upload-time = "2026-06-30T20:02:26.263Z" }, + { url = "https://files.pythonhosted.org/packages/9e/1f/41b535866519512d8cf6669cb2cff7823b7672bb6279c0333b4ff89d7d9f/ast_serialize-0.6.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2d947e45cafc4b09bd7528917fa84c517654a43de173c79785574b7b3068ac24", size = 1095570, upload-time = "2026-06-30T20:02:27.639Z" }, + { url = "https://files.pythonhosted.org/packages/50/64/e472fe3e3a2d33d874b987e8518aedf24562919e3b6161a4fa1797e89c0f/ast_serialize-0.6.0-cp314-cp314t-win_arm64.whl", hash = "sha256:6e15ec740436e1a0d62de848641abe5f3a2f89a7f94907d534795ac91bbacf14", size = 1067267, upload-time = "2026-06-30T20:02:28.949Z" }, + { url = "https://files.pythonhosted.org/packages/52/19/ac8348ae8711c9b5ae834634f635780cab62a0f5e6f988882e048b89c2ae/ast_serialize-0.6.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:093cb8bb91b720d8523580498d031791bb1bbaa048599c3d21085d380e11a596", size = 1185367, upload-time = "2026-06-30T20:02:30.427Z" }, + { url = "https://files.pythonhosted.org/packages/c1/f6/ec7ec652c51db77c2f61d8573338e13e4704303265ccc658cb4031d9f354/ast_serialize-0.6.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:e61580a69faf47e3689795367ed211f2a10fd741478cc0f36a0f128793360aad", size = 1178657, upload-time = "2026-06-30T20:02:31.964Z" }, + { url = "https://files.pythonhosted.org/packages/6f/02/613a7534a41d0122f37d1e0c64aa8ac78bfb831f8c92f6db057a311abb3c/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:305802f2ce2a7c4e87835078ea85c58b586ddda8095b92fe2ead9364ae19c80a", size = 1238620, upload-time = "2026-06-30T20:02:33.664Z" }, + { url = "https://files.pythonhosted.org/packages/4d/21/087957bba486242afc52f49b2d9e21c9dad00289356cf9efe67084015a9d/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c7b8b8f0c42f752ea00b2b7d7c090b3f80d9c1c5c75cadf16423790a0cc74081", size = 1236075, upload-time = "2026-06-30T20:02:34.936Z" }, + { url = "https://files.pythonhosted.org/packages/82/04/78128bbb170071c2c72a210a181f1c00e11cc1cec60a8beef747b07f9201/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cd5b91b9e6f2356ace3a556963b0cd783b395fbbb0bb17b4defc283415466e77", size = 1441348, upload-time = "2026-06-30T20:02:36.245Z" }, + { url = "https://files.pythonhosted.org/packages/64/64/62fb99d6faf199b4c3e5b08a07136e9a0d7664bb249c6de3670e5b63e9b6/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4d6ef91590258ada18909b9caea344dac4de2013906b035473cd674a43f4b790", size = 1258580, upload-time = "2026-06-30T20:02:37.53Z" }, + { url = "https://files.pythonhosted.org/packages/ca/87/b4d6c38e0ccd5e85dc54cecdf933a152c60b28fe5d993a6d8a72fa6d5896/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dcbed41e9386059fc0261d602445ede0976c2ecec2939688bcbcb9ed0b6f28b7", size = 1261693, upload-time = "2026-06-30T20:02:39.123Z" }, + { url = "https://files.pythonhosted.org/packages/0e/4b/3676ca2191f39bafb75f93f99b2f429ec464586158fece2165f3572805dc/ast_serialize-0.6.0-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:cdc4e6f930b9090c2f92c9036ad12ffb8e6e44d4a5ba06f1458a05d60f203f7b", size = 1252517, upload-time = "2026-06-30T20:02:40.511Z" }, + { url = "https://files.pythonhosted.org/packages/f3/58/494ef8c4b4acb2f4a265ac934caf45f792a08fe27d6b853de35ad991941a/ast_serialize-0.6.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:897ac47b5637be41c0c07061c8a912fafa967ef1dc73fa115e4bfa70882a093b", size = 1304843, upload-time = "2026-06-30T20:02:41.961Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f2/13736d920ab3d49bbee80ef1a277dd7b7aaf3b3545efd9d2a8114fe05525/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c4af9a1386166e40ed01464991806f89038a2d89782576c7774876fa77034e32", size = 1413698, upload-time = "2026-06-30T20:02:44.179Z" }, + { url = "https://files.pythonhosted.org/packages/a8/5a/e046f3899e2acba4677d7427b76431443a1aa1a0e583dfb05b55b69d55cf/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:c901adbd750029b9ac4ad3d6aa56853e0ad4875119fbf52b7b8298afc223828b", size = 1512209, upload-time = "2026-06-30T20:02:45.584Z" }, + { url = "https://files.pythonhosted.org/packages/cc/c7/e42aaca7bb2d22a7c06d5a8c7930086c5a334e93d716e6fa5e6647a4515f/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:3ae22a366b752ab4496191525b78b097b5b72d531752e3c1dd7e383a8f2c8a1a", size = 1508464, upload-time = "2026-06-30T20:02:46.942Z" }, + { url = "https://files.pythonhosted.org/packages/95/93/5524a3dc6c3f593de3228ed9cbef73afa047625b7000ec21b7f58e6eb4d4/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:4ed29121da8b3fdc291002801a1de0f76248fa07dce89157a5f277842cf6126e", size = 1457164, upload-time = "2026-06-30T20:02:48.294Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c0/36a6ffb4d653cf621427b4c4928671f53ad800c453474de2b82564a44ad9/ast_serialize-0.6.0-cp39-abi3-pyemscripten_2026_0_wasm32.whl", hash = "sha256:b1dac4e09d341c1300ba69cdcbe62867b32a8c75d90db9bf4d083bec3b039f0b", size = 863014, upload-time = "2026-06-30T20:02:49.742Z" }, + { url = "https://files.pythonhosted.org/packages/09/c7/7d5ad8b49e1278e1c2a1e0274bd7850560b3f09313aa00c13bc8d5544792/ast_serialize-0.6.0-cp39-abi3-win32.whl", hash = "sha256:82c312a7844d2fdeb4d5c48bd3d215bf940dafd4704e1a9bcf252a99010a99b1", size = 1063165, upload-time = "2026-06-30T20:02:50.98Z" }, + { url = "https://files.pythonhosted.org/packages/47/ae/6710c14ecb276031cf10249f6adf5a59e2d3fdb3b5183bd59f70524067ee/ast_serialize-0.6.0-cp39-abi3-win_amd64.whl", hash = "sha256:113b58346f9ceb664352032770caca817d4a3c86f611c6088e6ef65ddaa70f0e", size = 1101444, upload-time = "2026-06-30T20:02:52.554Z" }, + { url = "https://files.pythonhosted.org/packages/66/40/c53deb2cd0c9b0fb636d24d9f40924cf2e65028e6b20b10cd5c1eeb2c730/ast_serialize-0.6.0-cp39-abi3-win_arm64.whl", hash = "sha256:ccd132fe8db56f61fe743b1f644d01b8d65b83248a8da506f3132bda86d6ed5e", size = 1072965, upload-time = "2026-06-30T20:02:54.097Z" }, +] + +[[package]] +name = "asttokens" +version = "3.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/25/1e/faf0f247f6f881b98fc4d6d07e14085cb89d13665084e6d6ac1dc2c03d0b/asttokens-3.0.2.tar.gz", hash = "sha256:3ecdbd8f2cc195f53ccada3a613538bb5f9ef6f6869129f13e03c30a677b8fe2", size = 63136, upload-time = "2026-07-12T03:31:49.084Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/2b/04b8a15f3a1c77bc79ddf5c73875327f34b4fa75982df2b76e45e402d364/asttokens-3.0.2-py3-none-any.whl", hash = "sha256:9da13157f5b28becde0bd374fc677dcd3c290614264eff096f167c469cd9f933", size = 28702, upload-time = "2026-07-12T03:31:47.542Z" }, +] + +[[package]] +name = "async-lru" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e8/1f/989ecfef8e64109a489fff357450cb73fa73a865a92bd8c272170a6922c2/async_lru-2.3.0.tar.gz", hash = "sha256:89bdb258a0140d7313cf8f4031d816a042202faa61d0ab310a0a538baa1c24b6", size = 16332, upload-time = "2026-03-19T01:04:32.413Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/e2/c2e3abf398f80732e58b03be77bde9022550d221dd8781bf586bd4d97cc1/async_lru-2.3.0-py3-none-any.whl", hash = "sha256:eea27b01841909316f2cc739807acea1c623df2be8c5cfad7583286397bb8315", size = 8403, upload-time = "2026-03-19T01:04:30.883Z" }, +] + +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + +[[package]] +name = "babel" +version = "2.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/b2/51899539b6ceeeb420d40ed3cd4b7a40519404f9baf3d4ac99dc413a834b/babel-2.18.0.tar.gz", hash = "sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d", size = 9959554, upload-time = "2026-02-01T12:30:56.078Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845, upload-time = "2026-02-01T12:30:53.445Z" }, +] + +[[package]] +name = "beautifulsoup4" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "soupsieve" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/65/318323f98dbee45d42dff61d8f047181bc6f2268a9068cfad035a46be5af/beautifulsoup4-4.15.0.tar.gz", hash = "sha256:288e3ca7d54b06f2ac191970bc275c1939cb46d450b255bf6718b04aa37ab4f7", size = 632571, upload-time = "2026-06-07T16:44:20.453Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/c6/92fcd42f1ba33e1184263f25bfabf3d27c383410470f169e4b8163bf9c17/beautifulsoup4-4.15.0-py3-none-any.whl", hash = "sha256:d6f88de62e1d4e38ecb1077eb9724cd0eff29d2a08ca16a401e9b9e93f117cf9", size = 109924, upload-time = "2026-06-07T16:44:21.566Z" }, +] + +[[package]] +name = "bleach" +version = "6.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "webencodings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/48/3c/e12ac860709702bd5ebeb9b56a4fe334f1001246ee1b8f2b7ee28912df7d/bleach-6.4.0.tar.gz", hash = "sha256:4202482733d85cedd04e59fcb2f89f4e4c7c385a78d3c3c23c30446843a37452", size = 204857, upload-time = "2026-06-05T13:01:13.734Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/9d/40b6267367182187139a4000b82a3b287d84d745bccd808e75d916920e9d/bleach-6.4.0-py3-none-any.whl", hash = "sha256:4b6b6a54fff2e69a3dde9d21cc6301220bee3c3cb792187d11403fd795031081", size = 165109, upload-time = "2026-06-05T13:01:12.504Z" }, +] + +[package.optional-dependencies] +css = [ + { name = "tinycss2" }, +] + +[[package]] +name = "blis" +version = "1.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d0/d0/d8cc8c9a4488a787e7fa430f6055e5bd1ddb22c340a751d9e901b82e2efe/blis-1.3.3.tar.gz", hash = "sha256:034d4560ff3cc43e8aa37e188451b0440e3261d989bb8a42ceee865607715ecd", size = 2644873, upload-time = "2025-11-17T12:28:30.511Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/db/d80daf6c060618c72acecf026410b806f620cdea62b2e72f3235d7389d05/blis-1.3.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:650f1d2b28e3c875927c63deebda463a6f9d237dff30e445bfe2127718c1a344", size = 6925724, upload-time = "2025-11-17T12:27:14.23Z" }, + { url = "https://files.pythonhosted.org/packages/06/cd/7ac854c92e33cfccc0eded48e979a9fc26a447952d07a9c7c7da7c1d6eec/blis-1.3.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9b0d42420ddd543eec51ccb99d38364a0c0833b6895eced37127822de6ecacff", size = 1233606, upload-time = "2025-11-17T12:27:16.107Z" }, + { url = "https://files.pythonhosted.org/packages/c7/ae/ad3165fdbc4ef6afef585686a778c72cd67fb5aa16ab2fd2f4494186705e/blis-1.3.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f0628a030d44aa71cac5973e40c9e95ec767abaaf2fd366a094b9398885f82f2", size = 2769094, upload-time = "2025-11-17T12:27:17.883Z" }, + { url = "https://files.pythonhosted.org/packages/25/d4/7b0820f139b4ea67606d01b59ba6afbee4552ce7b2fd179f2fb7908e294f/blis-1.3.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d0114cf2d8f19e0ed210f9ae92594cd0a12efa1bbbce444028b0fc365bbbb8af", size = 11300520, upload-time = "2025-11-17T12:27:20.058Z" }, + { url = "https://files.pythonhosted.org/packages/85/f3/865a4322bdbeb944744c1908e67fdabecd476613a17204956cff12d568c9/blis-1.3.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:7e88181e9dd8430029ebaf22d41bf79e756e8c95363e9471717102c66beb4a6d", size = 2962083, upload-time = "2025-11-17T12:27:22.098Z" }, + { url = "https://files.pythonhosted.org/packages/65/a2/c2842fa1e2e6bd56eb93e41b34859a9af8b5b63669ee0442bea585d8f607/blis-1.3.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:62fb8c731347b0f98f5f81d19d339049e61489798738467d156c66cc329b0754", size = 14177001, upload-time = "2025-11-17T12:27:24.345Z" }, + { url = "https://files.pythonhosted.org/packages/b5/9b/3b1532f23db8bdddf3a976e9acf51e8debd94c63be5dafb8ccbab3e62935/blis-1.3.3-cp310-cp310-win_amd64.whl", hash = "sha256:631836d4f335e62c30aa50a1aa0170773265c73654d296361f95180006e88c04", size = 6184429, upload-time = "2025-11-17T12:27:27.054Z" }, + { url = "https://files.pythonhosted.org/packages/a1/0a/a4c8736bc497d386b0ffc76d321f478c03f1a4725e52092f93b38beb3786/blis-1.3.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e10c8d3e892b1dbdff365b9d00e08291876fc336915bf1a5e9f188ed087e1a91", size = 6925522, upload-time = "2025-11-17T12:27:29.199Z" }, + { url = "https://files.pythonhosted.org/packages/83/5a/3437009282f23684ecd3963a8b034f9307cdd2bf4484972e5a6b096bf9ac/blis-1.3.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:66e6249564f1db22e8af1e0513ff64134041fa7e03c8dd73df74db3f4d8415a7", size = 1232787, upload-time = "2025-11-17T12:27:30.996Z" }, + { url = "https://files.pythonhosted.org/packages/d1/0e/82221910d16259ce3017c1442c468a3f206a4143a96fbba9f5b5b81d62e8/blis-1.3.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7260da065958b4e5475f62f44895ef9d673b0f47dcf61b672b22b7dae1a18505", size = 2844596, upload-time = "2025-11-17T12:27:32.601Z" }, + { url = "https://files.pythonhosted.org/packages/6c/93/ab547f1a5c23e20bca16fbcf04021c32aac3f969be737ea4980509a7ca90/blis-1.3.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e9327a6ca67de8ae76fe071e8584cc7f3b2e8bfadece4961d40f2826e1cda2df", size = 11377746, upload-time = "2025-11-17T12:27:35.342Z" }, + { url = "https://files.pythonhosted.org/packages/6e/a6/7733820aa62da32526287a63cd85c103b2b323b186c8ee43b7772ff7017c/blis-1.3.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c4ae70629cf302035d268858a10ca4eb6242a01b2dc8d64422f8e6dcb8a8ee74", size = 3041954, upload-time = "2025-11-17T12:27:37.479Z" }, + { url = "https://files.pythonhosted.org/packages/87/53/e39d67fd3296b649772780ca6aab081412838ecb54e0b0c6432d01626a50/blis-1.3.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:45866a9027d43b93e8b59980a23c5d7358b6536fc04606286e39fdcfce1101c2", size = 14251222, upload-time = "2025-11-17T12:27:39.705Z" }, + { url = "https://files.pythonhosted.org/packages/ea/44/b749f8777b020b420bceaaf60f66432fc30cc904ca5b69640ec9cbef11ed/blis-1.3.3-cp311-cp311-win_amd64.whl", hash = "sha256:27f82b8633030f8d095d2b412dffa7eb6dbc8ee43813139909a20012e54422ea", size = 6171233, upload-time = "2025-11-17T12:27:41.921Z" }, + { url = "https://files.pythonhosted.org/packages/16/d1/429cf0cf693d4c7dc2efed969bd474e315aab636e4a95f66c4ed7264912d/blis-1.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2a1c74e100665f8e918ebdbae2794576adf1f691680b5cdb8b29578432f623ef", size = 6929663, upload-time = "2025-11-17T12:27:44.482Z" }, + { url = "https://files.pythonhosted.org/packages/11/69/363c8df8d98b3cc97be19aad6aabb2c9c53f372490d79316bdee92d476e7/blis-1.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3f6c595185176ce021316263e1a1d636a3425b6c48366c1fd712d08d0b71849a", size = 1230939, upload-time = "2025-11-17T12:27:46.19Z" }, + { url = "https://files.pythonhosted.org/packages/96/2a/fbf65d906d823d839076c5150a6f8eb5ecbc5f9135e0b6510609bda1e6b7/blis-1.3.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d734b19fba0be7944f272dfa7b443b37c61f9476d9ab054a9ac53555ceadd2e0", size = 2818835, upload-time = "2025-11-17T12:27:48.167Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ad/58deaa3ad856dd3cc96493e40ffd2ed043d18d4d304f85a65cde1ccbf644/blis-1.3.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1ef6d6e2b599a3a2788eb6d9b443533961265aa4ec49d574ed4bb846e548dcdb", size = 11366550, upload-time = "2025-11-17T12:27:49.958Z" }, + { url = "https://files.pythonhosted.org/packages/78/82/816a7adfe1f7acc8151f01ec86ef64467a3c833932d8f19f8e06613b8a4e/blis-1.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8c888438ae99c500422d50698e3028b65caa8ebb44e24204d87fda2df64058f7", size = 3023686, upload-time = "2025-11-17T12:27:52.062Z" }, + { url = "https://files.pythonhosted.org/packages/1e/e2/0e93b865f648b5519360846669a35f28ee8f4e1d93d054f6850d8afbabde/blis-1.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8177879fd3590b5eecdd377f9deafb5dc8af6d684f065bd01553302fb3fcf9a7", size = 14250939, upload-time = "2025-11-17T12:27:53.847Z" }, + { url = "https://files.pythonhosted.org/packages/20/07/fb43edc2ff0a6a367e4a94fc39eb3b85aa1e55e24cc857af2db145ce9f0d/blis-1.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:f20f7ad69aaffd1ce14fe77de557b6df9b61e0c9e582f75a843715d836b5c8af", size = 6192759, upload-time = "2025-11-17T12:27:56.176Z" }, + { url = "https://files.pythonhosted.org/packages/e6/f7/d26e62d9be3d70473a63e0a5d30bae49c2fe138bebac224adddcdef8a7ce/blis-1.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1e647341f958421a86b028a2efe16ce19c67dba2a05f79e8f7e80b1ff45328aa", size = 6928322, upload-time = "2025-11-17T12:27:57.965Z" }, + { url = "https://files.pythonhosted.org/packages/4a/78/750d12da388f714958eb2f2fd177652323bbe7ec528365c37129edd6eb84/blis-1.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d563160f874abb78a57e346f07312c5323f7ad67b6370052b6b17087ef234a8e", size = 1229635, upload-time = "2025-11-17T12:28:00.118Z" }, + { url = "https://files.pythonhosted.org/packages/e8/36/eac4199c5b200a5f3e93cad197da8d26d909f218eb444c4f552647c95240/blis-1.3.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:30b8a5b90cb6cb81d1ada9ae05aa55fb8e70d9a0ae9db40d2401bb9c1c8f14c4", size = 2815650, upload-time = "2025-11-17T12:28:02.544Z" }, + { url = "https://files.pythonhosted.org/packages/bf/51/472e7b36a6bedb5242a9757e7486f702c3619eff76e256735d0c8b1679c6/blis-1.3.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e9f5c53b277f6ac5b3ca30bc12ebab7ea16c8f8c36b14428abb56924213dc127", size = 11359008, upload-time = "2025-11-17T12:28:04.589Z" }, + { url = "https://files.pythonhosted.org/packages/84/da/d0dfb6d6e6321ae44df0321384c32c322bd07b15740d7422727a1a49fc5d/blis-1.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6297e7616c158b305c9a8a4e47ca5fc9b0785194dd96c903b1a1591a7ca21ddf", size = 3011959, upload-time = "2025-11-17T12:28:06.862Z" }, + { url = "https://files.pythonhosted.org/packages/20/c5/2b0b5e556fa0364ed671051ea078a6d6d7b979b1cfef78d64ad3ca5f0c7f/blis-1.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3f966ca74f89f8a33e568b9a1d71992fc9a0d29a423e047f0a212643e21b5458", size = 14232456, upload-time = "2025-11-17T12:28:08.779Z" }, + { url = "https://files.pythonhosted.org/packages/31/07/4cdc81a47bf862c0b06d91f1bc6782064e8b69ac9b5d4ff51d97e4ff03da/blis-1.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:7a0fc4b237a3a453bdc3c7ab48d91439fcd2d013b665c46948d9eaf9c3e45a97", size = 6192624, upload-time = "2025-11-17T12:28:14.197Z" }, + { url = "https://files.pythonhosted.org/packages/5f/8a/80f7c68fbc24a76fc9c18522c46d6d69329c320abb18e26a707a5d874083/blis-1.3.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c3e33cfbf22a418373766816343fcfcd0556012aa3ffdf562c29cddec448a415", size = 6934081, upload-time = "2025-11-17T12:28:16.436Z" }, + { url = "https://files.pythonhosted.org/packages/e5/52/d1aa3a51a7fc299b0c89dcaa971922714f50b1202769eebbdaadd1b5cff7/blis-1.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6f165930e8d3a85c606d2003211497e28d528c7416fbfeafb6b15600963f7c9b", size = 1231486, upload-time = "2025-11-17T12:28:18.008Z" }, + { url = "https://files.pythonhosted.org/packages/99/4f/badc7bd7f74861b26c10123bba7b9d16f99cd9535ad0128780360713820f/blis-1.3.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:878d4d96d8f2c7a2459024f013f2e4e5f46d708b23437dae970d998e7bff14a0", size = 2814944, upload-time = "2025-11-17T12:28:19.654Z" }, + { url = "https://files.pythonhosted.org/packages/72/a6/f62a3bd814ca19ec7e29ac889fd354adea1217df3183e10217de51e2eb8b/blis-1.3.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f36c0ca84a05ee5d3dbaa38056c4423c1fc29948b17a7923dd2fed8967375d74", size = 11345825, upload-time = "2025-11-17T12:28:21.354Z" }, + { url = "https://files.pythonhosted.org/packages/d4/6c/671af79ee42bc4c968cae35c091ac89e8721c795bfa4639100670dc59139/blis-1.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e5a662c48cd4aad5dae1a950345df23957524f071315837a4c6feb7d3b288990", size = 3008771, upload-time = "2025-11-17T12:28:23.637Z" }, + { url = "https://files.pythonhosted.org/packages/be/92/7cd7f8490da7c98ee01557f2105885cc597217b0e7fd2eeb9e22cdd4ef23/blis-1.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9de26fbd72bac900c273b76d46f0b45b77a28eace2e01f6ac6c2239531a413bb", size = 14219213, upload-time = "2025-11-17T12:28:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/0a/de/acae8e9f9a1f4bb393d41c8265898b0f29772e38eac14e9f69d191e2c006/blis-1.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:9e5fdf4211b1972400f8ff6dafe87cb689c5d84f046b4a76b207c0bd2270faaf", size = 6324695, upload-time = "2025-11-17T12:28:28.401Z" }, +] + +[[package]] +name = "build" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "os_name == 'nt'" }, + { name = "importlib-metadata", marker = "python_full_version < '3.10.2'" }, + { name = "packaging" }, + { name = "pyproject-hooks" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/e0/df5e171f685f82f37b12e1f208064e24244911079d7b767447d1af7e0d70/build-1.5.0.tar.gz", hash = "sha256:302c22c3ba2a0fd5f3911918651341ebb3896176cbdec15bd421f80b1afc7647", size = 89796, upload-time = "2026-04-30T03:18:25.17Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/fe/6bea5c9162869c5beba5d9c8abbed835ec85bf1ec1fba05a3822325c45f3/build-1.5.0-py3-none-any.whl", hash = "sha256:13f3eecb844759ab66efec90ca17639bbf14dc06cb2fdf37a9010322d9c50a6f", size = 26018, upload-time = "2026-04-30T03:18:23.644Z" }, +] + +[[package]] +name = "cachetools" +version = "7.1.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/55/af/861ebc2e318a5c3300e3eb63bc4d30f3d70a46d13b360093728ac0705eed/cachetools-7.1.6.tar.gz", hash = "sha256:c7a79e7f30ba9943c1cefd08cc36f006aaae086e017af9166f1d59d6170c47e1", size = 40572, upload-time = "2026-07-23T22:47:53.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/f2/2086ba18a925a73586c4d4e61d25f4a6058e56fd00d77ce8f1d361ab4c9b/cachetools-7.1.6-py3-none-any.whl", hash = "sha256:2c12e255780330af28b91bb7fb96cce4c766f04e38396b9a24510190a5827096", size = 16954, upload-time = "2026-07-23T22:47:52.397Z" }, +] + +[[package]] +name = "catalogue" +version = "2.0.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/38/b4/244d58127e1cdf04cf2dc7d9566f0d24ef01d5ce21811bab088ecc62b5ea/catalogue-2.0.10.tar.gz", hash = "sha256:4f56daa940913d3f09d589c191c74e5a6d51762b3a9e37dd53b7437afd6cda15", size = 19561, upload-time = "2023-09-25T06:29:24.962Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/96/d32b941a501ab566a16358d68b6eb4e4acc373fab3c3c4d7d9e649f7b4bb/catalogue-2.0.10-py3-none-any.whl", hash = "sha256:58c2de0020aa90f4a2da7dfad161bf7b3b054c86a5f09fcedc0b2b740c109a9f", size = 17325, upload-time = "2023-09-25T06:29:23.337Z" }, +] + +[[package]] +name = "certifi" +version = "2026.7.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, +] + +[[package]] +name = "cffi" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/57/5f/ff100cae70ebe9d8df1c01a00e510e45d9adb5c1fdda84791b199141de97/cffi-2.1.0.tar.gz", hash = "sha256:efc1cdd798b1aaf39b4610bba7aad28c9bea9b910f25c784ccf9ec1fa719d1f9", size = 531036, upload-time = "2026-07-06T21:34:30.382Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/e9/6d7724983b3d5a0908dbf74f64038ade77c18646ff6636ec7894fd392ce1/cffi-2.1.0-cp310-cp310-macosx_10_15_x86_64.whl", hash = "sha256:b65f590ef2a44640f9a05dbb548a429b4ade77913ce683ac8b1480777658a6c0", size = 183837, upload-time = "2026-07-06T21:32:09.655Z" }, + { url = "https://files.pythonhosted.org/packages/69/aa/24580a278de21fd7322635556334d9b535f1cbc00b0a3919447cdf464c65/cffi-2.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:164bff1657b2a74f0b6d54e11c9b375bc97b931f2ca9c43fcf875838da1570dd", size = 184226, upload-time = "2026-07-06T21:32:11.196Z" }, + { url = "https://files.pythonhosted.org/packages/88/a9/02cae418ec4beb282ace11958d9d4737793439d561fadc7e6d56f2e2b354/cffi-2.1.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:c941bb58d5a6e1c3892d86e42927ed6c180302f07e6d395d08c416e594b98b46", size = 211107, upload-time = "2026-07-06T21:32:12.328Z" }, + { url = "https://files.pythonhosted.org/packages/3b/30/c806937ed5e4c2c7ac30d9d6b76b5dc57ff8b75d83800d9bb11a8253cf2a/cffi-2.1.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a016194dbe13d14ee9556e734b772d8d67b947092b268d757fd4290e3ba2dfc2", size = 218733, upload-time = "2026-07-06T21:32:13.67Z" }, + { url = "https://files.pythonhosted.org/packages/f9/cf/398272b8bbfd58aa314fda5a7f1cdbb26d1d78ae324a11211521315dd1f0/cffi-2.1.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:03e9810d18c646077e501f661b682fbf5dee4676048527ca3cffe66faa9960dd", size = 205543, upload-time = "2026-07-06T21:32:15.148Z" }, + { url = "https://files.pythonhosted.org/packages/45/ca/f91641185cdd90c36d317a9dc7f85e88ef8682d8b300977baff5e23c35d8/cffi-2.1.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:19c54ac121cad98450b4896fa9a43ee0180d57bc4bc911a33db6cab1efab6cd3", size = 205460, upload-time = "2026-07-06T21:32:16.479Z" }, + { url = "https://files.pythonhosted.org/packages/38/66/04781a77b411f0bb5b234d62c1814754ab75ebe455ccff1b08e8d7aae98f/cffi-2.1.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d433a51f1870e43a13b6732f92aaf540ff77c2015097c78556f75a2d6c030e0", size = 218760, upload-time = "2026-07-06T21:32:17.98Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9a/bb1d5ed9c3fcae158e9f6391bf309c95d98c2ac37ed56573228471d0af5e/cffi-2.1.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3d7f118b5adbfdfead90c25822690b02bc8074fba949bb7858bec4ebd55adb43", size = 221230, upload-time = "2026-07-06T21:32:19.407Z" }, + { url = "https://files.pythonhosted.org/packages/41/aa/3c1409cdd26094efacd1c36c66e0a6eb9d4296e4fd4f9901b8b2042f4323/cffi-2.1.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:c5f5df567f6eb216de69be06ce55c8b714090fae02b18a3b40da8163b8c5fa9c", size = 213524, upload-time = "2026-07-06T21:32:20.828Z" }, + { url = "https://files.pythonhosted.org/packages/fa/75/74dfb7c3fc6ebbd408038476bd4c1d7e925c62614e7b9c534ecc34218288/cffi-2.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:11b3fb55f4f8ad92274ed26705f65d8f91457de71f5380061eb6d125a768fecd", size = 220341, upload-time = "2026-07-06T21:32:21.9Z" }, + { url = "https://files.pythonhosted.org/packages/70/b6/9003c33a3e7d2c1306f5962e646457dcfe5a8cd8fce6bbe02d7af25db783/cffi-2.1.0-cp310-cp310-win32.whl", hash = "sha256:9d72af0cf10a76a600a9690078fe31c63b9588c8e86bf9fd353f713c84b5db0f", size = 174578, upload-time = "2026-07-06T21:32:23.073Z" }, + { url = "https://files.pythonhosted.org/packages/8a/26/710688310447531c7a22f857c7f79d9855ec18b03e04494ced723fb37e2f/cffi-2.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:fb62edb5bb52cca65fab91a63afa7561607120d26090a7e8fda6fb9f064726da", size = 185071, upload-time = "2026-07-06T21:32:24.671Z" }, + { url = "https://files.pythonhosted.org/packages/d3/67/85c89a59ba36a671e79638f44d466749f08179266a57e4f2ffdf92174072/cffi-2.1.0-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:02cb7ff33ded4f1532476731f89ede53e2e488a8e6205515a82144246ffa7dcc", size = 183845, upload-time = "2026-07-06T21:32:26.32Z" }, + { url = "https://files.pythonhosted.org/packages/ea/dd/e3b0baa2d3d6a857ac72b7efbf18e32e487c9cdafcc13049ad765495b15e/cffi-2.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f5bce581e6b8c235e566a14768a943b172ada3ed73537bb0c0be1edee312d4e7", size = 184186, upload-time = "2026-07-06T21:32:28.025Z" }, + { url = "https://files.pythonhosted.org/packages/65/68/9f3ef890cf3c6ab97bd531c5677f67613d302165d16f8142b2811782a614/cffi-2.1.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:30b65779d598c370374fefabf138d456fd6f3216bfa7bedfab1ba82025b0cd93", size = 211892, upload-time = "2026-07-06T21:32:29.565Z" }, + { url = "https://files.pythonhosted.org/packages/22/d7/1a74539db16d8bfd839ff1515948948efbb162e574650fd3d846896eea95/cffi-2.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88023dfe18799507b73f1dbb0d14326a17465de1bc9c9c7655c22845e9ddc3a2", size = 218793, upload-time = "2026-07-06T21:32:30.951Z" }, + { url = "https://files.pythonhosted.org/packages/ec/d1/9a5b7169499e8e8d8e636de70b97ac7c9447104d2ff1a2cd94790cea5162/cffi-2.1.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:0a96b74cda968eebbad56d973efe5098974f0a9fb323865bf99ea1fd24e3e64c", size = 205737, upload-time = "2026-07-06T21:32:32.216Z" }, + { url = "https://files.pythonhosted.org/packages/ba/b0/e131a9c41f10607926278453d9596163594fe1c4ebc46efe3b5e5b34eb84/cffi-2.1.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a5781494d4d400a3f47f8f1da94b324f6e6b440a53387774002890a2a2f4b50f", size = 204909, upload-time = "2026-07-06T21:32:33.655Z" }, + { url = "https://files.pythonhosted.org/packages/fb/d2/4398416cd699b35167947c6e22aca52c47e69ad5695073c9f1f2c52e04aa/cffi-2.1.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aa7a1b53a2a4452ada2d1b5dade9960b2522f1e61293a811a077439e39029565", size = 217883, upload-time = "2026-07-06T21:32:35.173Z" }, + { url = "https://files.pythonhosted.org/packages/a2/a5/d4fe77b589e5e82d43ebc809bf2e6474afe8e48e32ea050b9357645b6471/cffi-2.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9d8272c0e483b024e1b9ad029821470ed8ec65631dbd90217469da0e7cd89f1c", size = 221251, upload-time = "2026-07-06T21:32:36.527Z" }, + { url = "https://files.pythonhosted.org/packages/22/f0/a2fc43084c0433caf7f461bccc013e28f848d04ee1c5ed7fce71423cf4d9/cffi-2.1.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7762faa47e8ff7eb80bd261d9a7d8eea2d8baa69de5e95b70c1f338bbe712f02", size = 214250, upload-time = "2026-07-06T21:32:37.852Z" }, + { url = "https://files.pythonhosted.org/packages/04/8c/b925975448cf20634a9fbd5efceb807219db452653648d2897c0989cab2d/cffi-2.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:89095c1968b4ba8285840e131bf2891b09ae137fe2146905acae0354fbce1b5e", size = 219441, upload-time = "2026-07-06T21:32:39.146Z" }, + { url = "https://files.pythonhosted.org/packages/eb/da/5c4918a2d61d86fa927d716cb3d8e4626ef8dc8f605a599d32f33897f59a/cffi-2.1.0-cp311-cp311-win32.whl", hash = "sha256:64c753a0f87a256020004f37a1c8c02c480e725f910f0b2a0f3f07debd1b2479", size = 174496, upload-time = "2026-07-06T21:32:40.467Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c8/6c2de1d55cf35ef8b92885d5ef280790f0fb9634d87ea1cc315176aecd61/cffi-2.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:4f26194e3d95e06501b942642855aed4f953d55e95d7d01b7c4483db3ecff458", size = 185113, upload-time = "2026-07-06T21:32:41.761Z" }, + { url = "https://files.pythonhosted.org/packages/9e/4e/e8d7cb5783f1841a3c8fb3a7735838d7484d08ec08c9f984b14cac1ac0e9/cffi-2.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:35aaea0c7ee0e58a5cd8c2fd1a48fdf7ece0d2699b7ecdda08194e9ce5dd9b3d", size = 179927, upload-time = "2026-07-06T21:32:42.961Z" }, + { url = "https://files.pythonhosted.org/packages/1e/85/990925db5df586ec90beb97529c853497e7f85ba0234830447faf41c3057/cffi-2.1.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:df2b82571a1b30f58a87bf4e5a9e78d2b1eff6c6ce8fd3aa3757221f93f0863f", size = 184829, upload-time = "2026-07-06T21:32:44.324Z" }, + { url = "https://files.pythonhosted.org/packages/4b/92/e7bb136ad6b5352603732cf907ef862ca103f20f2031c1735a46300c20c9/cffi-2.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:78474632761faa0fb96f30b1c928c84ebcf68713cbb80d15bab09dfe61640fde", size = 184728, upload-time = "2026-07-06T21:32:45.683Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c0/d1ec30ffb370f748f2fb54425972bfef9871e0132e82fb589c46b6676049/cffi-2.1.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:5972433ad71a9e46516584ef60a0fda12d9dc459938d1539c3ddecf9bdc1368d", size = 214815, upload-time = "2026-07-06T21:32:48.557Z" }, + { url = "https://files.pythonhosted.org/packages/1b/dc/5620cf930688be01f2d673804291de757a934c90b946dbdc3d84130c2ea4/cffi-2.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b6422532152adf4e59b110cb2808cee7a033800952f5c036b4af047ee43199e7", size = 222429, upload-time = "2026-07-06T21:32:49.848Z" }, + { url = "https://files.pythonhosted.org/packages/4b/a4/77b53abbf7a1e0beb9637edbef2a94d15f9c822f591e85d439ffd91519a6/cffi-2.1.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:46b1c8db8f6122420f32d02fffb924c2fe9bc772d228c7c711748fff56aabb2b", size = 210315, upload-time = "2026-07-06T21:32:51.221Z" }, + { url = "https://files.pythonhosted.org/packages/58/0c/f528df19cc94b675087324d4760d9e6d5bfae97d6217aa4fac43de4f5fcc/cffi-2.1.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9fafc5aa2e2a39aaf7f8cc0c1f044a9b07fca12e558dca53a3cc5c654ad67a7", size = 208859, upload-time = "2026-07-06T21:32:52.512Z" }, + { url = "https://files.pythonhosted.org/packages/62/f2/c9522a81c32132799a1972c39f5c5f8b4c8b9f00488a23feaa6c06f07741/cffi-2.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1e9f50d192a3e525b15a75ab5114e442d83d657b7ec29182a991bc9a88fd3a66", size = 221844, upload-time = "2026-07-06T21:32:53.704Z" }, + { url = "https://files.pythonhosted.org/packages/6e/28/bd53988b9833e8f8ad539d26f4c07a6b3f6bcb1e9e02e7ca038250b3428d/cffi-2.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:98fff996e983a36d3aa2eca83af40c5821202e7e6f32d13ae94e3d2286f10cfe", size = 225287, upload-time = "2026-07-06T21:32:54.907Z" }, + { url = "https://files.pythonhosted.org/packages/79/99/0d0fd37f055224085f42bbb2c022d002e17dde4a97972822327b07d84101/cffi-2.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:379de10ce1ba048b1448599d1b37b24caee16309d1ac98d3982fc997f768700b", size = 223681, upload-time = "2026-07-06T21:32:56.329Z" }, + { url = "https://files.pythonhosted.org/packages/b0/80/c138990aa2a70b1a269f6e06348729836d733d6f970867943f61d367f8cc/cffi-2.1.0-cp312-cp312-win32.whl", hash = "sha256:9b8f0f26ca4e7513c534d351eca551947d053fac438f2a04ac96d882909b0d3a", size = 175269, upload-time = "2026-07-06T21:32:57.777Z" }, + { url = "https://files.pythonhosted.org/packages/a8/eb/f636456ff21a83fc13c032b58cc5dde061691546ac79efa284b2989b7982/cffi-2.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:c97f080ea627e2863524c5af3836e2270b5f5dfff1f104392b959f8df0c5d384", size = 185881, upload-time = "2026-07-06T21:32:59.253Z" }, + { url = "https://files.pythonhosted.org/packages/dd/2c/400ea43e721727dca8a65c4521390e9196757caba4a45643acb2b63271b8/cffi-2.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:6d194185eabd279f1c05ebe3504265ddfc5ad2b58d0714f7db9f01da592e9eb6", size = 180088, upload-time = "2026-07-06T21:33:02.278Z" }, + { url = "https://files.pythonhosted.org/packages/96/88/a996879e2eeccb815f6e3a5967b12a308257412acec882039d386bd2aa7b/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:10537b1df4967ca26d21e5072d7d54188354483b91dc75058968d3f0cf13fbda", size = 194331, upload-time = "2026-07-06T21:33:03.697Z" }, + { url = "https://files.pythonhosted.org/packages/58/85/7ae00d5c8dd6266f4e944c3db630f3c5c9a98b61d469c714d848b1d8138a/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a95b05f9baf29b91171b3a8bd2020b028835243e7b0ff6bb23e2a3c228518b1b", size = 196966, upload-time = "2026-07-06T21:33:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/8c/e9/45c3a76ad8d43ad9261f4c95436da61128d3ca545d72b9612c0ab5be0b1c/cffi-2.1.0-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:15faec4adfff450819f3aee0e2e02c812de6edb88203aa58807955db2003472a", size = 184795, upload-time = "2026-07-06T21:33:06.699Z" }, + { url = "https://files.pythonhosted.org/packages/84/4c/82f132cb4418ee6d953d982b19191e87e2a6372c8a4ce36e50b69d6ade4a/cffi-2.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:716ff8ec22f20b4d988b12884086bcef0fc99737043e503f7a3935a6be99b1ea", size = 184746, upload-time = "2026-07-06T21:33:08.071Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1c/4ed5a0e5bdca6cbc275556de3328dd1b76fd0c11cc13c88fe66d1d8715f2/cffi-2.1.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:63960549e4f8dc41e31accb97b975abaecfc44c03e396c093a6436763c2ea7db", size = 214747, upload-time = "2026-07-06T21:33:09.671Z" }, + { url = "https://files.pythonhosted.org/packages/3a/a6/e879bb68cc23a2bc9ba8f4b7d8019f0c2694bad2ab6c4a3701d429439f58/cffi-2.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ff067a8d8d880e7809e4ac88eb009bb848870115317b306666502ccad30b147f", size = 222392, upload-time = "2026-07-06T21:33:10.896Z" }, + { url = "https://files.pythonhosted.org/packages/88/f6/01890cfd63c08f8eb96a8319b0443690197d240a8bd6346048cf7bde9190/cffi-2.1.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3b926723c13eba9f81d2ef3820d63aeceec3b2d4639906047bf675cb8a7a500d", size = 210285, upload-time = "2026-07-06T21:33:12.251Z" }, + { url = "https://files.pythonhosted.org/packages/a6/cf/2b684132056f438567b61e19d690dd31cd0921ace051e0a458be6074369e/cffi-2.1.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:47ff3a8bfd8cb9da1af7524b965127095055654c177fcfc7578debcb015eecd0", size = 208801, upload-time = "2026-07-06T21:33:13.617Z" }, + { url = "https://files.pythonhosted.org/packages/6f/08/f2e7d62c460faae0926f2d6e423694aa409ced3bc1fe2927a0a6e5f05416/cffi-2.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:799416bae98336e400981ff6e532d67d5c709cfb30afb79865a1315f94b0e224", size = 221808, upload-time = "2026-07-06T21:33:15.466Z" }, + { url = "https://files.pythonhosted.org/packages/38/37/04f54b8e63a02f3d908332c9effbf8c366167c6f733ed8a3d4f79b7e2a1e/cffi-2.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:961be50688f7fba2fa65f63712d3b9b341a22311f5253460ce933f52f0de1c8c", size = 225241, upload-time = "2026-07-06T21:33:16.869Z" }, + { url = "https://files.pythonhosted.org/packages/a9/d6/c72eecca433cd3e681c65ed313ab4835d9d4a379704d0f628a6a05f51c2e/cffi-2.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bf5c6cf48238b0eb4c086978c492ad1cbc22373fc5b2d7353b3a598ce6db887a", size = 223588, upload-time = "2026-07-06T21:33:18.239Z" }, + { url = "https://files.pythonhosted.org/packages/c6/4b/e706f67279140f92939da3475ad610df18bfd52d50f14953a8e5fede71d5/cffi-2.1.0-cp313-cp313-win32.whl", hash = "sha256:db3eb7d46527159a878ec3460e9d40615bc25ba337d477db681aea6e4f05c5d2", size = 175248, upload-time = "2026-07-06T21:33:19.799Z" }, + { url = "https://files.pythonhosted.org/packages/5a/47/59eb7975cb0e4ef0afa764ea945b29a5bb4537a9f771cb7d6c8a5dd74c95/cffi-2.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:8e74a6135550c4748af665b1b1118b6aab33b1fc6a16f9aff630af107c3b4512", size = 185717, upload-time = "2026-07-06T21:33:21.47Z" }, + { url = "https://files.pythonhosted.org/packages/5a/af/34fee85c48f8d94efc8597bc09470c9dd274c145f1c12e0fbc6ab6d38d74/cffi-2.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:2282cd5e38aa8accd03e99d1256af8411c84cdbee6a89d841b563fdbd1f3e50f", size = 180114, upload-time = "2026-07-06T21:33:22.515Z" }, + { url = "https://files.pythonhosted.org/packages/d8/f0/81478e482afa03f6d18dc8f2afb5edc45b3080853b634b5ed91961be0998/cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d2117334c3af3bdcb9a88522b844a2bdb5efdc4f71c6c822df55486ae1c3347a", size = 194142, upload-time = "2026-07-06T21:33:23.657Z" }, + { url = "https://files.pythonhosted.org/packages/7d/95/8de304305cd9204974b0ca051b86d307cafca13aa575a0ef1b44d92c0d8c/cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:702c436735fbe99d59ada02a1f65cfc0d31c0ee8b7290912f8fbc5cd1e4b16c3", size = 196819, upload-time = "2026-07-06T21:33:25.007Z" }, + { url = "https://files.pythonhosted.org/packages/20/71/7c8372d30e42415602ed9f268f7cfd66f1b855fed881ecd168bcb45dbc0b/cffi-2.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1ff3456eab0d889592d1936d6125bbfbc7ae4d3354a700f8bd80450a66445d4d", size = 184965, upload-time = "2026-07-06T21:33:26.605Z" }, + { url = "https://files.pythonhosted.org/packages/d6/5c/584e626835f0375c928176c04137c96927165cb8733cdb3150ec04e5ee5e/cffi-2.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c4165821e131d6d4ca444347c2b694e2311bcfa3fe5a861cc72968f28867beac", size = 184952, upload-time = "2026-07-06T21:33:27.823Z" }, + { url = "https://files.pythonhosted.org/packages/2e/d2/065fcae1c73979fac8e054462478d0ff8a29c40cdc2ed7ea5676a061df53/cffi-2.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:276f20fffd7b396e12516ba8edf9509210ac248cbbc5acbc39cd512f9f59ebe6", size = 222353, upload-time = "2026-07-06T21:33:29.178Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a5/e8bbb1ce5b3ac2f53ad6a10bde44318a5a8d99d4f4a000d44a6e39aeb3e4/cffi-2.1.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7d5980a3433d4b71a5e120f9dd551403d7824e31e2e67124fe2769c404c06913", size = 210051, upload-time = "2026-07-06T21:33:30.534Z" }, + { url = "https://files.pythonhosted.org/packages/28/ed/c127d3ac36e899c965e3361357c3befacd6578c03f40125183e41c3b219e/cffi-2.1.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6ca4919c6e4f89aa99c42510b42cf54596892c00b3f9077f6bdd1505e24b9c8d", size = 208630, upload-time = "2026-07-06T21:33:31.753Z" }, + { url = "https://files.pythonhosted.org/packages/cc/d7/97d3136f81db489ec8d1d67748c110d6c994268fd7528014aa9f2b085e4e/cffi-2.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d53d10f7da99ae46f7373b9150393e9c5eab9b224909982b43832668de4779f5", size = 221593, upload-time = "2026-07-06T21:33:33.044Z" }, + { url = "https://files.pythonhosted.org/packages/d3/27/93195977168ee63aed233a1a0993a2178798654d1f4bddcdd321d6fd3b21/cffi-2.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c351efb95e832a853a29361675f33a7ce53de1a109cd73fd47af0712213aa4ce", size = 225146, upload-time = "2026-07-06T21:33:34.224Z" }, + { url = "https://files.pythonhosted.org/packages/b3/c1/6dbd291ee2ae5a50a034aa057207081f545923bbf15dad4511e985aafff5/cffi-2.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:dbf7c7a88e2bac086f06d14577332760bdeecc42bdec8ac4077f6260557d9326", size = 223240, upload-time = "2026-07-06T21:33:35.57Z" }, + { url = "https://files.pythonhosted.org/packages/0f/6f/ade5ce9863a57992a6ea3d0d10d7e29b8749fc127204b3d493d667b2815f/cffi-2.1.0-cp314-cp314-win32.whl", hash = "sha256:1854b724d00f6654c742097d5387569021be12d3a0f770eae1df8f8acfcc6acd", size = 177723, upload-time = "2026-07-06T21:33:51.626Z" }, + { url = "https://files.pythonhosted.org/packages/41/de/92b9eeed4ae4a21d6fd9b2a2c8505cbed573299902ea73981cc13f7ff62c/cffi-2.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:1b96bfe2c4bd825681b7d311ad6d9b7280a091f43e8f63da5729638083cd3bfb", size = 187937, upload-time = "2026-07-06T21:33:53.403Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1a/cc6ae6c2913a03aab8898eee57963cf1035b8df5872ed8b9115fcc7e2be8/cffi-2.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:7d28dff1db6764108bc30788d85d61c876beff416d9a49cb9dd7c5a9f34f5804", size = 183001, upload-time = "2026-07-06T21:33:54.74Z" }, + { url = "https://files.pythonhosted.org/packages/14/f0/134c00ce0779ec86dea2aa1aac69339c2741a8045072676763512363a2ea/cffi-2.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7ea6b3e2c4250ff1de21c630fe72d0f63eb95c2c32ffbf64a358cf4a8836d714", size = 188538, upload-time = "2026-07-06T21:33:36.792Z" }, + { url = "https://files.pythonhosted.org/packages/50/d8/3b86aba791cb610d24e8a3e1b2cd529e71fa15096b04e4d4e360049d4a4c/cffi-2.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6af371f3767faeffc6ac1ef57cdfd25844403e9d3f476c5537caee499de96376", size = 188230, upload-time = "2026-07-06T21:33:38.011Z" }, + { url = "https://files.pythonhosted.org/packages/14/d0/117dcd9209255ad8571fbc8c92ef32593a1d294dcec91ddc4e4db50606f2/cffi-2.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb4e8997a49aa2c08a3e43c9045d224448b8941d88e7ac163c7d383e560cbf98", size = 223899, upload-time = "2026-07-06T21:33:39.514Z" }, + { url = "https://files.pythonhosted.org/packages/b6/3d/f20f8b886b254e3ad10e15cd4186d3aed49f3e6a35ab37aab9f8f25f7c03/cffi-2.1.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:bf01d8c84cbea96b944c73b22182e6c7c432b3475632b8111dbfdc95ddad6e13", size = 211652, upload-time = "2026-07-06T21:33:40.851Z" }, + { url = "https://files.pythonhosted.org/packages/28/3b/fad54de07260b93ddeef4b96d0131d57ea900675df1d410ae1deee52d7a6/cffi-2.1.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:33eb1ad83ebe8f313e0df035c406227d55a79456704a863fad9842136af5ad7d", size = 210755, upload-time = "2026-07-06T21:33:42.183Z" }, + { url = "https://files.pythonhosted.org/packages/cc/82/3d5c705acb7abbba9bbd7d79b8e62e0f25b6120eb7ae6ac49f1b721722fe/cffi-2.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ac0f1a2d0cfa7eea3f2aaf006ab6e70e8feeb16b75d65b7e5939982ca2f11056", size = 223933, upload-time = "2026-07-06T21:33:43.603Z" }, + { url = "https://files.pythonhosted.org/packages/6c/d0/47e338384ab6b1004241002fa616301020cea4fc95f283506565d252f276/cffi-2.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c16914df9fb7f500e440e6875fa23ff5e0b31db01fa9c06af98d59a91f0dc2e4", size = 226749, upload-time = "2026-07-06T21:33:45.046Z" }, + { url = "https://files.pythonhosted.org/packages/70/25/65bd5b58ea4bfdfc15cde02cb5365f89ef8ab8b2adfb8fe5c4bd4233382f/cffi-2.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5ecbd0499275d57506d397eebe1981cee87b47fcd9ef5c22cab7ed7644a39a94", size = 225703, upload-time = "2026-07-06T21:33:46.374Z" }, + { url = "https://files.pythonhosted.org/packages/dc/78/aa01ac599a8a4322533d45a1f9bc93b338276d2d59dabbe7c6d92a775c81/cffi-2.1.0-cp314-cp314t-win32.whl", hash = "sha256:7d034dcffa09e9a46c93fa3a3be402096cb5354ac6e41ab8e5cc9cd8b642ad76", size = 182857, upload-time = "2026-07-06T21:33:47.696Z" }, + { url = "https://files.pythonhosted.org/packages/b9/26/d00496b22de4d4228f32dde94ad996f350c8aad676d63bcca0743c8dea4d/cffi-2.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0582a58f3051372229ca8e7f5f589f9e5632678208d8636fea3676711fdf7fe5", size = 194065, upload-time = "2026-07-06T21:33:48.953Z" }, + { url = "https://files.pythonhosted.org/packages/d5/dd/0c7dbf815a579ff005008a2d815a55d6bb047c349eef536d9dc53d3f0a8d/cffi-2.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:510aeeeac94811b138077451da1fb18b308a5feab47dd2b603af55804155e1c8", size = 186404, upload-time = "2026-07-06T21:33:50.309Z" }, + { url = "https://files.pythonhosted.org/packages/55/c7/8c8c50cb11c6750051daf12164098a9a6f027ac4356967fd4d800a07f242/cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:2e9dabb9abcb7ad15938c7196ad5c1718a4e6d33cc79b4c0209bdb64c4a54a5c", size = 194121, upload-time = "2026-07-06T21:33:56.109Z" }, + { url = "https://files.pythonhosted.org/packages/99/e2/67680bf19a6b60d2bb7ff83baefa2a4c3d2d7dc0f3277034b802e1fc504c/cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:37f525a7e7e50c017fdebe58b787be310ad59357ae43a053943a6e1a6c526001", size = 196820, upload-time = "2026-07-06T21:33:57.288Z" }, + { url = "https://files.pythonhosted.org/packages/ed/da/4bbe583a3b3a5c8c60892124fe17f3fa3656523faf0d3484eae90f091853/cffi-2.1.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:95f2954c2c9473d892eca6e0409f3568b37ab62a8eedb122461f73cc273476e3", size = 184936, upload-time = "2026-07-06T21:33:58.765Z" }, + { url = "https://files.pythonhosted.org/packages/e5/4b/1f4c36ab273980d7aa75bb126ea4f8971f24a96108acad3a0a084028c57b/cffi-2.1.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:cdf2448aab5f661c9315308ec8b93f4e8a1a67a3c733f8631067a2b67d5913dc", size = 185045, upload-time = "2026-07-06T21:34:00.085Z" }, + { url = "https://files.pythonhosted.org/packages/ef/c3/ad299dc38f3583f8d916b299f028af418a9ec98bc695fcbebeae7420691c/cffi-2.1.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:90bec57cf82089383bd06a605b3eb8daebf7e5a668520beaf6e327a83a947699", size = 222342, upload-time = "2026-07-06T21:34:01.814Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d8/df4543cc087245044ed02ef3ad8e0a26619d0075ac7a77a12dc81177851b/cffi-2.1.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6274dcb2d15cef48daa73ed1be5a40d501d74dccd0cd6db364776d12cb6ba022", size = 210073, upload-time = "2026-07-06T21:34:03.255Z" }, + { url = "https://files.pythonhosted.org/packages/2c/0e/fac738d73728c6cea2a88a2883dca54892496cbba88a1dc1f2909cb8a6f5/cffi-2.1.0-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:2b71d409cccee78310ab5dec549aed052aaea483346e282c7b02362596e01bb0", size = 208551, upload-time = "2026-07-06T21:34:04.433Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3f/0b04a700dd64f465c93020253a793a82c9b4dff9961f48facd0df945d9b8/cffi-2.1.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7d3538f9c0e50670f4deb93dbb696576e60590369cae2faf7de681e597a8a1f1", size = 221649, upload-time = "2026-07-06T21:34:06.157Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7c/b7379a5704c79eda57ce075869ba70a0368d1c850f803b3c0d078d39dcaf/cffi-2.1.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:8f9ec95b8a043d3dfbc74d9abc6f7baf524dd27a8dc160b0a32ff9cdab650c28", size = 225203, upload-time = "2026-07-06T21:34:07.489Z" }, + { url = "https://files.pythonhosted.org/packages/5a/02/d5e6c43ea85c41bda2a184a3418f195fe7cf602967a8d2b94e085b83deef/cffi-2.1.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:af5e2915d41fe6c961694d7bfdc8562942638200f3ce2765dfb8b745cf997629", size = 223263, upload-time = "2026-07-06T21:34:08.712Z" }, + { url = "https://files.pythonhosted.org/packages/2c/d8/772b8259bf75749adffb1c546828978381fb516f60cf701f6c83daf60c85/cffi-2.1.0-cp315-cp315-win32.whl", hash = "sha256:0a42c688d19fca6e095a53c6a6e2295a5b050a8b289f109adab02a9e61a25de6", size = 177696, upload-time = "2026-07-06T21:34:26.355Z" }, + { url = "https://files.pythonhosted.org/packages/2f/dd/afa2191fc6d57fedd26e5844a2fe2fcc0bbfa00961bbaa5a41e4921e7cca/cffi-2.1.0-cp315-cp315-win_amd64.whl", hash = "sha256:bccbbb5ee76a61f9d99b5bf3846a51d7fca4b6a732fe46f89295610edaf41853", size = 187914, upload-time = "2026-07-06T21:34:27.58Z" }, + { url = "https://files.pythonhosted.org/packages/05/ef/6cd4f8c671517162379dc79cfae5aea9106bc38abb89628d5c16adf6a838/cffi-2.1.0-cp315-cp315-win_arm64.whl", hash = "sha256:8d35c139744adb3e727cd51b1a18324bbe44b8bd41bf8322bca4d41289f48eda", size = 183004, upload-time = "2026-07-06T21:34:28.905Z" }, + { url = "https://files.pythonhosted.org/packages/11/b6/12fc55092817a5faa26fb8c40c7f9d662e11a46ee248c137aafc42517d92/cffi-2.1.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:f9912624a0c0b834b7520d7769b3644453aabc0a7e1c839da7359f050750e9bc", size = 188378, upload-time = "2026-07-06T21:34:09.926Z" }, + { url = "https://files.pythonhosted.org/packages/8d/2e/cdac88979f295fde5daa69622c7d2111e56e7ceb94f211357fbe452339e4/cffi-2.1.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:df92f2aba50eb4d96718b68ef76f2e57a57b54f2fa62333496d16c6d585a85ca", size = 188319, upload-time = "2026-07-06T21:34:11.101Z" }, + { url = "https://files.pythonhosted.org/packages/e0/27/1d0b408497e41a74795af122d7b603c418c5fed0171450f899afd04e594f/cffi-2.1.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0520e1f4c35f44e209cbbb421b67eec42e6a157f59444dfb6058874ff3610e5d", size = 223904, upload-time = "2026-07-06T21:34:12.606Z" }, + { url = "https://files.pythonhosted.org/packages/8b/31/e115c985105dd7ffb32444505f18ceb874bb42d992af05d5dced7ecf1980/cffi-2.1.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3681e031db29958a7502f5c0c9d6bbc4c36cb20f7b104086fa642d1799631ff8", size = 211554, upload-time = "2026-07-06T21:34:13.987Z" }, + { url = "https://files.pythonhosted.org/packages/5a/67/9e6e09409336d9e515c58367e7cfcf4f89df06ad25252675595a58eb59d5/cffi-2.1.0-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:762f99479dcb369f60ab9017ad4ab97a36a1dd7c1ee5a3b15db0f4b8659120cd", size = 210795, upload-time = "2026-07-06T21:34:15.972Z" }, + { url = "https://files.pythonhosted.org/packages/19/e5/d3cc82a4a0be7902af279c04181ad038449c096734464a5ae1de3e1401bd/cffi-2.1.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0611e7ebf90573a535ebdc33ae9da222d037853983e13359f580fab781ca017f", size = 223843, upload-time = "2026-07-06T21:34:17.509Z" }, + { url = "https://files.pythonhosted.org/packages/b9/65/b434abc97ce7cecc2c640fde160507c0ecc7e21544b483ba3325d2e2ea17/cffi-2.1.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:86cf8755a791f72c85dc287128cc62d4f24d392e3f1e15837245623f4a33cccc", size = 226773, upload-time = "2026-07-06T21:34:19.05Z" }, + { url = "https://files.pythonhosted.org/packages/b5/9f/d4dc66ca651eb1145a133314cda721abf13cfac3d28c4a0402263ae6ad75/cffi-2.1.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:ba00f661f8ba35d075c937174e27c2c421cec3942fd2e0ea3e66996757c0fdd9", size = 225719, upload-time = "2026-07-06T21:34:20.576Z" }, + { url = "https://files.pythonhosted.org/packages/68/5a/e536c528bc8057496c360c0978559a2dc45653f89dd6151078aa7d8fca1a/cffi-2.1.0-cp315-cp315t-win32.whl", hash = "sha256:cb96698e3c7413d906ce83f8ffd245ec1bd94707541f299d0ce4d6b0193e982b", size = 182760, upload-time = "2026-07-06T21:34:22.059Z" }, + { url = "https://files.pythonhosted.org/packages/d3/0b/0ffe8b82d3875bced5fa1e7986a7a46b748262a40ab7f60b475eb9fb1bb3/cffi-2.1.0-cp315-cp315t-win_amd64.whl", hash = "sha256:f146d154428a2523f9cc7936c02353c2459b8f6cf07d3cd1ee1c0a611109c5d5", size = 193769, upload-time = "2026-07-06T21:34:23.589Z" }, + { url = "https://files.pythonhosted.org/packages/a0/17/1073b53b68c9b5ca6914adf5f8bf55aacc2d3be102418c90700160ea8605/cffi-2.1.0-cp315-cp315t-win_arm64.whl", hash = "sha256:cbb7640ce37159548d2147b5b8c241f962143d4c71231431820783f4dc78f210", size = 186405, upload-time = "2026-07-06T21:34:24.857Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/2a/23f34ec9d04624958e137efdc394888716353190e75f25dd22c7a2c7a8aa/charset_normalizer-3.4.9.tar.gz", hash = "sha256:673611bbd43f0810bec0b0f028ddeaaa501190339cac411f347ac76917c3ae7b", size = 152439, upload-time = "2026-07-07T14:34:58.454Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ad/81/8e983840c6e5b93b33c2ba81aa3d52c2e42f0e9a690ce7607a2e61da4a5c/charset_normalizer-3.4.9-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cd6280cf040f233bd7d3407b743b4b4c74f70e8e1c4199cb112a62c941c0772a", size = 322240, upload-time = "2026-07-07T14:32:36.236Z" }, + { url = "https://files.pythonhosted.org/packages/de/d1/b4319dc3229d8272fba305e206fc0a148e2de8d4087917ce62ae6382f359/charset_normalizer-3.4.9-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa99adc8f081b475a12843953db36831eaf83ec33eb46a90629ca6a5de45a616", size = 216475, upload-time = "2026-07-07T14:32:38.142Z" }, + { url = "https://files.pythonhosted.org/packages/80/33/6c99c1b3e6b8bf730e1bc809b9a2608f224145069114c479a2e9e1494346/charset_normalizer-3.4.9-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c1225416b463483160e4af85d5fc3a9690ccb53fd4b1865a6437825f5ede3209", size = 238670, upload-time = "2026-07-07T14:32:39.658Z" }, + { url = "https://files.pythonhosted.org/packages/7f/f4/ffbb83546e1f198ecc70ecd372b65cf2b50f9068b380abd67640f17a8e18/charset_normalizer-3.4.9-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:16d10d789dd9bcca1173c95af82c58433122564b7bc39385124be735a35cbe99", size = 233476, upload-time = "2026-07-07T14:32:41.155Z" }, + { url = "https://files.pythonhosted.org/packages/e8/5f/b98b8da398637b551e427e7be922bdec19177dc54d6811dcdaa503f23aac/charset_normalizer-3.4.9-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9bb41182d93ea91f60b4bc8fbf4c820c69ef8a12ab2d917f3f1834f1acad07e8", size = 223817, upload-time = "2026-07-07T14:32:42.592Z" }, + { url = "https://files.pythonhosted.org/packages/36/31/a276bb2e66243072a3fd06fdcab9cbb61a305b02143d70d2bda21d888fa8/charset_normalizer-3.4.9-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:bcf74c1df76758a395bf0af608c04c82257523f55c9868b334f06270d0f2112b", size = 207974, upload-time = "2026-07-07T14:32:44.258Z" }, + { url = "https://files.pythonhosted.org/packages/5e/be/7ee4453d7e88dfbc4104ccd34900b9f2c7c17dac22881865fe0e82424a25/charset_normalizer-3.4.9-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b5314963fce9b0b12743891de876e724997864ee22aa496f903f426c7e2fa5b2", size = 221655, upload-time = "2026-07-07T14:32:45.64Z" }, + { url = "https://files.pythonhosted.org/packages/1d/85/181c652953eb5276d198f375b1dd641047392050098100a3a02d6534f657/charset_normalizer-3.4.9-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:e9701d0049d92c16703a42771b98d560b95248949f23f8cf7b4eddd201814fb9", size = 219229, upload-time = "2026-07-07T14:32:47.376Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e7/aaf6da33fc9f4691cda8f7efbc9f69179d3d39ec8a4799baf273ee1d8db0/charset_normalizer-3.4.9-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:65a7ff3f705e57d392f7261b6d0550fe137c3019477431f1c355e0db0a7d3e15", size = 209704, upload-time = "2026-07-07T14:32:48.855Z" }, + { url = "https://files.pythonhosted.org/packages/63/01/f2fb3bd3a73be48b173ee0c6aa8d2497af97d5663a8c4c4b491de4c62f7a/charset_normalizer-3.4.9-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:79580094b00d1789d1f93ea55bc43cb2f611910c72235b7657f3482ddcc1b22d", size = 226243, upload-time = "2026-07-07T14:32:50.239Z" }, + { url = "https://files.pythonhosted.org/packages/c4/02/c57a22739fe05246b0b5783b3bfb6afaac4eebb46f3ececdfb2f048f780e/charset_normalizer-3.4.9-cp310-cp310-win32.whl", hash = "sha256:432786d3561e69aeeae6c7e8648964ce0ad05736120135601f87ac26b9c83381", size = 150935, upload-time = "2026-07-07T14:32:51.676Z" }, + { url = "https://files.pythonhosted.org/packages/37/8d/ca39a7559a4797505530d084fd3a49a2c959efbbbff146302fb7be4e3b35/charset_normalizer-3.4.9-cp310-cp310-win_amd64.whl", hash = "sha256:8c041122946b7ba21bb32c45b1aa57b1be35527690aeb3c5c234521085632eee", size = 162314, upload-time = "2026-07-07T14:32:53.193Z" }, + { url = "https://files.pythonhosted.org/packages/01/da/a44bd7a13d426e69e4894557106cd58669097bfad4a8681123b618fbfc5d/charset_normalizer-3.4.9-cp310-cp310-win_arm64.whl", hash = "sha256:375b83ed0aecfce76c16d198fbc21f3b11b337d68662bea0a995046682a11419", size = 153075, upload-time = "2026-07-07T14:32:54.554Z" }, + { url = "https://files.pythonhosted.org/packages/0b/e3/85ec501f206fb049259288c1f3506e53876937fb00edb47009348e66756b/charset_normalizer-3.4.9-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0e94703ec9684807f20cfb5eed95c70f67f2a8f21ad620146d7b5a13677b93e5", size = 317075, upload-time = "2026-07-07T14:32:56.021Z" }, + { url = "https://files.pythonhosted.org/packages/c3/69/2a5385192e67175f7d8bd5ce4f57c24bc956439adeae5c13a99aa28a53d1/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a441ea71902098ffe78c5abe6c494f44160b4af614ed16c3d9a3b1d17fd8ee2", size = 213837, upload-time = "2026-07-07T14:32:57.78Z" }, + { url = "https://files.pythonhosted.org/packages/b3/46/03ddc7da576d814fe0a36dd1f0fd3258e95404b4b2e3c026b7923d7e133f/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:304b13570067b2547562e308af560b3963857b1fa90bd6afd978130130fe2d6a", size = 235503, upload-time = "2026-07-07T14:32:59.205Z" }, + { url = "https://files.pythonhosted.org/packages/4e/6e/de0229a7ef40f6f9d28a837eebf4ec47bdca5dab4e900c84f22919af636a/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4773092f8019072343a7447203308b176e10199920eb02d6195e81bbb3274c29", size = 229944, upload-time = "2026-07-07T14:33:00.803Z" }, + { url = "https://files.pythonhosted.org/packages/a5/34/49b9060e8418b14fb5cba9cf6bfb383111e2538a03a1fb18e66a95aeb3d5/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:04ce310cb89c15df659582aee80a0603788732a5e017d5bd5c81158106ce249c", size = 221276, upload-time = "2026-07-07T14:33:02.199Z" }, + { url = "https://files.pythonhosted.org/packages/44/95/80282cce0fae9c3061203d723ee87da996aed79679e65d8935050ee7ca1f/charset_normalizer-3.4.9-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:c0323c9daef75ef2e5083624b4585018a0c9d5e3b40f607eed81a311270b934b", size = 205260, upload-time = "2026-07-07T14:33:03.698Z" }, + { url = "https://files.pythonhosted.org/packages/0c/74/2f62c8821b969ea3bd67cc2e6976834f48ca5d12664d2559ebcd9bcfbed7/charset_normalizer-3.4.9-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:871ff67ea1aad4dfd91736464934d56b32dac49f9fbe16cddba36198a7b3a0db", size = 217786, upload-time = "2026-07-07T14:33:05.12Z" }, + { url = "https://files.pythonhosted.org/packages/d9/8d/feabb82cb49fcad14515b1d7d1ca4787b0da7fc723a212bf89bc9e0fac52/charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:67830fc78e67501f47bb950471b2dcb9b35b140084429318e862895a8e89c993", size = 216798, upload-time = "2026-07-07T14:33:06.629Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ff/c946d63bc3786d5b84d960b0f7ab7e25b828486a946b5aa997625bcaf6a6/charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:3d92613ec25e43b05f042302531ec0f00b8445190e43325880cbd6ab7c2581da", size = 206429, upload-time = "2026-07-07T14:33:08.006Z" }, + { url = "https://files.pythonhosted.org/packages/af/ba/5e5007c370702f85d2ef75791fac7943ed41e080364a673b20142e430e3e/charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:280081916dc341820640489a66e4696049401ef1cf6dd672f672e70ad915aca3", size = 223066, upload-time = "2026-07-07T14:33:09.783Z" }, + { url = "https://files.pythonhosted.org/packages/83/d5/9096aa3cf532dfad237861544eb47a0f20d5adbf1039760fed8eaae935d9/charset_normalizer-3.4.9-cp311-cp311-win32.whl", hash = "sha256:ac351b3b8014eead140e77e9717e2992c6bbe30b63bc3422422eb84865412e3d", size = 150456, upload-time = "2026-07-07T14:33:11.217Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a1/e29995109e455dc8eff8d0fac6ae509be39561318a7cfeac5d33ad029213/charset_normalizer-3.4.9-cp311-cp311-win_amd64.whl", hash = "sha256:6366a16e1a25018694d6a5d784d09b046edc9eac40ea2b54065c3052672516a1", size = 161410, upload-time = "2026-07-07T14:33:12.743Z" }, + { url = "https://files.pythonhosted.org/packages/4f/8d/1569f4d0032d6ba2a4fe4591c35bf87868c600c41a71eb5c2e1ffa8464c2/charset_normalizer-3.4.9-cp311-cp311-win_arm64.whl", hash = "sha256:1d22856ffbe153a602df38e4a5464f0b748a54002e0d69ac6d2ad0a197cc99ec", size = 152649, upload-time = "2026-07-07T14:33:14.173Z" }, + { url = "https://files.pythonhosted.org/packages/70/4a/ecbd131485c07fcdfad54e28946d513e3da22ef3b4bd854dcafae54ec739/charset_normalizer-3.4.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:45b0cc4e3556cd875e09102988d1ab8356c998b596c9fced84547c8138b487a0", size = 319300, upload-time = "2026-07-07T14:33:15.666Z" }, + { url = "https://files.pythonhosted.org/packages/ec/96/5d9364e3342d69f3a045e1777bc47c85c383e6e9466d561b33fdb419d1f9/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b2aff1c7b3884512b9512c3eaadd9bab39fb45042ffaaa1dd08ff2b9f8109d9", size = 215802, upload-time = "2026-07-07T14:33:17.031Z" }, + { url = "https://files.pythonhosted.org/packages/4b/4c/5361f9aa7f2cb58d94f2ab831b3d493f69efb1d239654b4744e3c09527cb/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9104ed0bd76a429d46f9ec0dbc9b08ad1d2dcdf2b00a5a0daa1c145329b35b44", size = 237171, upload-time = "2026-07-07T14:33:18.576Z" }, + { url = "https://files.pythonhosted.org/packages/50/78/ce342ca4ff30b2eb49fe6d9578df85974f90c67d294113e94efdd9664cbd/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7b86a2b16095d250c6f58b3d9b2eee6f4147754344f3dab0922f7c9bf7d226c9", size = 233075, upload-time = "2026-07-07T14:33:20.084Z" }, + { url = "https://files.pythonhosted.org/packages/01/c4/4fa4c8b3097a11f3c5f09a35b72ed6855fb1d332469504962ab7bafcc702/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e226f6218febc71f6c1fc2fafb91c226f75bdc1d8fb12d66823716e891608fd", size = 224256, upload-time = "2026-07-07T14:33:21.747Z" }, + { url = "https://files.pythonhosted.org/packages/87/3a/ad914516df7e358a81aae018caa5e0470ba827fa6d763b1d2e87d920a5f6/charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:90c44bc373b7687f6948b693cceaea1348ae0975d7474746559494468e3c1d84", size = 208784, upload-time = "2026-07-07T14:33:23.313Z" }, + { url = "https://files.pythonhosted.org/packages/d7/74/3c12f9755717dfe5c5c87da63f35d765fa0c00382ec26bf23f7fae34f2ba/charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cdef90ae47919cae358d8ab15797a800ed41da7aba5d72419fb510729e2ed4b", size = 219928, upload-time = "2026-07-07T14:33:24.814Z" }, + { url = "https://files.pythonhosted.org/packages/33/9a/895095b83e7907abd6d3d99aad3a38ad0d9686cc186cb0c94c24320fe63e/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:60f44ade2cf573dad7a277e6f8ca9a51a21dda572b13bd7d8539bb3cd5dbedde", size = 218489, upload-time = "2026-07-07T14:33:26.42Z" }, + { url = "https://files.pythonhosted.org/packages/a1/34/ef5c05f412f42520d7709b7d3784d19640839eb7366ded1755511585429f/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:a1786910334ed46ab1dd73222f2cd1e05c2c3bb39f6dddb4f8b36fc382058a39", size = 210267, upload-time = "2026-07-07T14:33:27.952Z" }, + { url = "https://files.pythonhosted.org/packages/83/dc/9b29fa4412b318bf3bfea985c35d67eb55e04b59a7c3f2237168b0e0be6f/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:03d07803992c6c7bbc976327f34b18b6160327fc81cb82c9d504720ac0be3b62", size = 226030, upload-time = "2026-07-07T14:33:29.397Z" }, + { url = "https://files.pythonhosted.org/packages/0e/42/6dbc00b8cd16011691203e33570fa42ed5746599a2e878112d16eab403a3/charset_normalizer-3.4.9-cp312-cp312-win32.whl", hash = "sha256:78841cccf1af7b40f6f716338d50c0902dbe88d9f800b3c973b7a9a0a693a642", size = 151185, upload-time = "2026-07-07T14:33:30.781Z" }, + { url = "https://files.pythonhosted.org/packages/80/cc/f920afd1a23c58ccd53c1d36085a71893a4737ff5e66e0371efab6809850/charset_normalizer-3.4.9-cp312-cp312-win_amd64.whl", hash = "sha256:4b3dac63058cc36820b0dd072f89898604e2d39686fe05321729d00d8ac185a0", size = 162557, upload-time = "2026-07-07T14:33:32.176Z" }, + { url = "https://files.pythonhosted.org/packages/f0/e6/0386d43a261ff4e4b30c5857af7df877254b46bec7b9d1b74b6bf969a90b/charset_normalizer-3.4.9-cp312-cp312-win_arm64.whl", hash = "sha256:78fa18e436a1a0e58dbd7e02fc4473f3f32cceb12df9dfca542d075961c307d2", size = 152665, upload-time = "2026-07-07T14:33:33.711Z" }, + { url = "https://files.pythonhosted.org/packages/b2/06/97ec2aeae780b31d742b6352218b43841a6871e2564578ca522dce4a45c3/charset_normalizer-3.4.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:440eede837960000d74978f0eba527be106b5b9aee0daf779d395276ed0b0614", size = 317688, upload-time = "2026-07-07T14:33:35.408Z" }, + { url = "https://files.pythonhosted.org/packages/d0/39/8ff066c672434225f8d25f8b739f992af250944392173dcc88362681c9bf/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21e764fd1e70b6a3e205a0e46f3051701f98a8cb3fad66eeb80e48bb502f8698", size = 214982, upload-time = "2026-07-07T14:33:36.996Z" }, + { url = "https://files.pythonhosted.org/packages/92/8f/3a47a3667c83c2df9483d91644c6c107de3bf8874aa1793da9d3012eb986/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e4fd89cc178bced6ad29cb3e6dd4aa63fa5017c3524dbd0b25998fb64a87cc8b", size = 236460, upload-time = "2026-07-07T14:33:38.536Z" }, + { url = "https://files.pythonhosted.org/packages/f1/60/b22cdbee7e4013dab8b0d7647fc6181120fbbbc8f7025c226d15bd5a47fc/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bd47ba7fc3ca94896759ea0109775132d3e7ab921fbf54038e1bab2e46c313c9", size = 232003, upload-time = "2026-07-07T14:33:40.059Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f8/72eb13dcabe7257035cea8aefd922caad2f110d252bf9f67c4c2ca763aee/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:84fd18bcc17526fc2b3c1af7d2b9217d32c9c04448c16ec693b9b4f1985c3d33", size = 223149, upload-time = "2026-07-07T14:33:41.631Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3e/faee8f9de92b14ee1198e9163252bb15efee7301b31256a3b6d9ebfdd0dd/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:5b10cd92fc5c498b35a8635df6d5a100207f88b63a4dc1de7ef9a548e1e2cd63", size = 207901, upload-time = "2026-07-07T14:33:43.209Z" }, + { url = "https://files.pythonhosted.org/packages/3a/25/45f30093ae27dd7b92a793b61882a38685f993700113ca36e0c9c14965e1/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a4fbdde9dd4a9ce5fd52c2b3a347bb50cc89483ef783f1cb00d408c13f7a96c0", size = 219176, upload-time = "2026-07-07T14:33:44.725Z" }, + { url = "https://files.pythonhosted.org/packages/48/18/c8f397329c35e32f6a837e488986f4ae03bd2abebc453b48714991630c2f/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:416c229f77e5ea25b3dfd4b582f8d73d7e43c22320302b9ab128a2d3a0b38efe", size = 217356, upload-time = "2026-07-07T14:33:46.192Z" }, + { url = "https://files.pythonhosted.org/packages/86/7e/5ce0bba863470fd1902d5e5843968951bddf38abe4742fc97116ef4598b3/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:75286256590a6320cf106a0d28970d3560aad9ee09aa7b34fb40524792436d35", size = 209614, upload-time = "2026-07-07T14:33:47.705Z" }, + { url = "https://files.pythonhosted.org/packages/6c/ef/2473d3c4d869155be4af1191111d59c4d5c4e0173026f7e85b176e23bf65/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:69b157c5d3292bcd443faca052f3096f637f1e074b98212a933c074ae23dc3b8", size = 224991, upload-time = "2026-07-07T14:33:49.238Z" }, + { url = "https://files.pythonhosted.org/packages/d0/a3/53ddae3db108a088156aa8ddfafd411ebbc1340f48c5573f697b27f69a39/charset_normalizer-3.4.9-cp313-cp313-win32.whl", hash = "sha256:51307f5c71007673a2bf8232ad973483d281e74cb99c8c5a990af1eefa6277d9", size = 150622, upload-time = "2026-07-07T14:33:50.711Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ef/6953a77c7cf2c2ff9998e6f575ab3e380119f100223381565a4f94c1f836/charset_normalizer-3.4.9-cp313-cp313-win_amd64.whl", hash = "sha256:fe2c7201c642b7c308f1675355ad7ff7b66acfe3541625efe5a3ad38f29d6115", size = 161947, upload-time = "2026-07-07T14:33:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/6e/fb/d560d1d1555debbfe7849d9cac6145c1b537709d79576bf22557ed803b82/charset_normalizer-3.4.9-cp313-cp313-win_arm64.whl", hash = "sha256:611057cc5d5c0afc743ba8be6bd828c17e0aaa8643f9d0a9b9bb7dea80eb8012", size = 152594, upload-time = "2026-07-07T14:33:53.486Z" }, + { url = "https://files.pythonhosted.org/packages/7e/8d/496817fa0944239ecae662dd57ea765cfeaec6a735f9f025d4b7b72e7143/charset_normalizer-3.4.9-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:0327fcd59a935777d83410750c50600ee9571af2846f71ce40f25b13da1ef380", size = 317253, upload-time = "2026-07-07T14:33:54.994Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f9/ef4a69ea338ad3c0deceea0f5f7d2380ae8b52132b06d652cb0d2cd86706/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a79d9f4d8001473a30c163556b3c3bfebec837495a412dde78b51672f6134f9", size = 215898, upload-time = "2026-07-07T14:33:56.334Z" }, + { url = "https://files.pythonhosted.org/packages/8c/e7/5ddfd76fc061eb52de219658a4aa431cbacadf0a0219c8854f00da50d289/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:33bdcc2a32c0a0e861f60841a512c8acc658c87c2ac59d89e3a46dacf7d866e4", size = 236718, upload-time = "2026-07-07T14:33:57.9Z" }, + { url = "https://files.pythonhosted.org/packages/49/ba/768fa3f36048d81c477a0ce61f813bc1454d80917ccfe550abd9f44f5e24/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f840ed6d8ecba8255df8c42b87fadeda98ddfc6eeec05e2dc66e26d46dd6f58a", size = 232519, upload-time = "2026-07-07T14:33:59.811Z" }, + { url = "https://files.pythonhosted.org/packages/f4/c4/b3e049d2aa3766180c78507110543d9d50894cc97f57de543f1be521dcdc/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c25fe15c70c59eb7c5ce8c06a1f3fa1da0ecc5ea1e7a5922c40fd2fa9b0d5046", size = 223143, upload-time = "2026-07-07T14:34:01.517Z" }, + { url = "https://files.pythonhosted.org/packages/19/79/55c32d06d76ae4feafe053f061f3e3ab70bcf19f4007797ce8c3efda7830/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:f7fb7d750cfa0a070d2c24e831fd3481019a60dd317ea2b39acbcebc08b6ed81", size = 206742, upload-time = "2026-07-07T14:34:03.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/e0/47c079dd82d217c807479cd59ffd30af56307ea31c108b75758970459ad3/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d1c96a7a18b9690a4d46df09e3e3382406ae3213727cd1019ebade1c4a81917", size = 219191, upload-time = "2026-07-07T14:34:04.657Z" }, + { url = "https://files.pythonhosted.org/packages/42/ab/b9bc2e77d6b44a7e46ef62ec5cac1c9a6ba7b9135a5d560f002696ec9995/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a4cfde78a9f2880208d16a93b795726a3017d5977e08d1e162a7a31322479c41", size = 218328, upload-time = "2026-07-07T14:34:06.115Z" }, + { url = "https://files.pythonhosted.org/packages/f1/78/c9c71d599f5aa2d42bcdd35cbbd46d7f535351a57e40ff7d8e5a7e219401/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d4d6fcde76f94f5cb9e43e9e9a61f16dacefd228cbbf6f1a09bd9b219a92f1a1", size = 207406, upload-time = "2026-07-07T14:34:07.554Z" }, + { url = "https://files.pythonhosted.org/packages/f6/39/c914445c321a845097ce4f6ac7de9a18228a77b766272125a1ce00d851eb/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:898f0e9068ca27d37f8e83a5b962821df851532e6c4a7d615c1c033f9da6eedf", size = 225157, upload-time = "2026-07-07T14:34:09.061Z" }, + { url = "https://files.pythonhosted.org/packages/9b/f2/c0d4b8508565a36bc5c624e88ed297f5b0b1095011034d7f5b83a69908b5/charset_normalizer-3.4.9-cp314-cp314-win32.whl", hash = "sha256:c1c948747b03be832dceed96ca815cef7360de9aa19d37c730f8e3f6101aca48", size = 151095, upload-time = "2026-07-07T14:34:10.901Z" }, + { url = "https://files.pythonhosted.org/packages/49/fd/a1d26144398c67486422a72bf5812cda22cb4ccfcd95a290fb41ceb4b8e2/charset_normalizer-3.4.9-cp314-cp314-win_amd64.whl", hash = "sha256:16b65ea0f2465b6fb52aa22de5eca612aa964ddfec00a912e26f4656cbef890b", size = 162796, upload-time = "2026-07-07T14:34:12.47Z" }, + { url = "https://files.pythonhosted.org/packages/20/95/d75e82f8ce9fd323ebf059c16c9aadefb22a1ecde13b7840b35835e4886c/charset_normalizer-3.4.9-cp314-cp314-win_arm64.whl", hash = "sha256:40a126142a56b2dfc0aacbad1de8310cbf60da7656db0e6b16eebd48e3e93519", size = 153334, upload-time = "2026-07-07T14:34:14.044Z" }, + { url = "https://files.pythonhosted.org/packages/00/5e/17398df3a139985ba9d11ed072531986f408c8fca952835ef1ab1820c02b/charset_normalizer-3.4.9-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:609b3ba8fcc0fb5ab7af00719d0fb6ad0cb518e48e7712d12fd68f1327951198", size = 338848, upload-time = "2026-07-07T14:34:15.688Z" }, + { url = "https://files.pythonhosted.org/packages/cd/91/7253a32e86b7e1d1239b1b36ba6dd0f021a21107ab33054b53119cc083b9/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51447e9aa2684679af07ca5021c3db526e0284347ebf4ffcec1154c3350cfe32", size = 223022, upload-time = "2026-07-07T14:34:17.248Z" }, + { url = "https://files.pythonhosted.org/packages/cb/32/2e64bd2be10e89c61e57ebe6a93fd98ae88eb7ebe414b5121f22c96c69eb/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cc1b0fff8ead343dae06305f954eb8468ba0ec1a97881f42489d198e4ce3c632", size = 241590, upload-time = "2026-07-07T14:34:18.813Z" }, + { url = "https://files.pythonhosted.org/packages/3d/ef/d96ec496cfea0c21db43b0ad03891308b02388d054cc902cf0e5a1ad6a88/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa36ec09ef71d158186bc79e359ff5fdd6e7996fe8ab638f00d6b93139ba4fcf", size = 239584, upload-time = "2026-07-07T14:34:20.52Z" }, + { url = "https://files.pythonhosted.org/packages/d4/ce/9af95f7876194bd7a14e3dfe4a4de2e0bff02666a3910d72beafd06cc297/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df115d4d83168fdf2cae48ef1ff6d1cb4c466364e30861b37121de0f3bf1b990", size = 230224, upload-time = "2026-07-07T14:34:22.189Z" }, + { url = "https://files.pythonhosted.org/packages/52/94/af74dde74a3996bd959c350709bfe50e297823d70a8c1cbd54b838880863/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f86c6358749bd4fda175388691e3ba8c46e24c5347d0afd20f9b7edfc9faf07d", size = 212667, upload-time = "2026-07-07T14:34:23.857Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f0/f1c4fe746c395922961b5916ed1d7d6e7d4c84851d19ed43cc89980ec953/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:32286a2c8d167e897177b673176c1e3e00d4057caf5d2b64eef9a3666b03018e", size = 227179, upload-time = "2026-07-07T14:34:25.586Z" }, + { url = "https://files.pythonhosted.org/packages/e4/56/6c745619ac397e8871e2bcd3cea1eec86b877488f33888b3aef5c3ed506e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:83aed2c10721ddd90f68140685391b50811a880af20654c59af6b6c66c40513c", size = 225372, upload-time = "2026-07-07T14:34:27.212Z" }, + { url = "https://files.pythonhosted.org/packages/78/ad/98aae8630ac71f16711968e38a5acfecce41b778bf2f0312851020f565a8/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cd6c3d4b783c556fa00bf540854e42f135e2f256abd29669fcd0da0f2dec79c2", size = 215222, upload-time = "2026-07-07T14:34:28.774Z" }, + { url = "https://files.pythonhosted.org/packages/f7/40/9593d54209765207a7f11073c06494c1721e4ca4a0a426c597679bf7f91e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ee2f2a527e3c1a6e6411eb4209642e138b544a2d72fe5d0d76daf77b24063534", size = 231958, upload-time = "2026-07-07T14:34:30.345Z" }, + { url = "https://files.pythonhosted.org/packages/b1/27/693ee5e8a18191eb38647360c51cd505013e2bd3b366aa43fd5344c21e3c/charset_normalizer-3.4.9-cp314-cp314t-win32.whl", hash = "sha256:0d861473f743244d349b50f850d10eb87aeb22bbdcc8e64f79273c94af5a8226", size = 155580, upload-time = "2026-07-07T14:34:31.884Z" }, + { url = "https://files.pythonhosted.org/packages/80/3f/bd97d3d9c613013d07cb7733d299385b41df37f0471310f5a73dc359f0b8/charset_normalizer-3.4.9-cp314-cp314t-win_amd64.whl", hash = "sha256:9b8e0f3107e2200b76f6054de99016eac3ee6762713587b36baaa7e4bd2ae177", size = 167620, upload-time = "2026-07-07T14:34:33.438Z" }, + { url = "https://files.pythonhosted.org/packages/3d/c6/eee9dca4439b1061f76373f06ea855678cc4a64c1c3c90b50e479edbb8eb/charset_normalizer-3.4.9-cp314-cp314t-win_arm64.whl", hash = "sha256:19ac87f93086ce37b86e098888555c4b4bc48102279bae3350098c0ed664b501", size = 158037, upload-time = "2026-07-07T14:34:35.018Z" }, + { url = "https://files.pythonhosted.org/packages/98/2b/f97f1c193fb855c345d678f5077d6926034db0722df74c8f057020e05a25/charset_normalizer-3.4.9-py3-none-any.whl", hash = "sha256:68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5", size = 64538, upload-time = "2026-07-07T14:34:56.993Z" }, +] + +[[package]] +name = "click" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" }, +] + +[[package]] +name = "cloudpathlib" +version = "0.24.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/19/58bc6b5d7d0f81c7209b05445af477e147c486552f96665a5912211839b9/cloudpathlib-0.24.0.tar.gz", hash = "sha256:c521a984e77b47e656fe78e20a7e3e260e0ab45fc69e33ac01094227c979e34a", size = 53600, upload-time = "2026-04-30T00:54:43.265Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/5b/ba933f896d9b0b07608d575a8501e2b4e32166b60d84c430a4a7285ebe64/cloudpathlib-0.24.0-py3-none-any.whl", hash = "sha256:b1c51e2d2ec7dc4fed6538991f4aea849d6cf11a7e6b9069f86e461aa1f9b5b4", size = 63214, upload-time = "2026-04-30T00:54:42.06Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "colorcet" +version = "3.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3f/af/b969f541242b84cbbacabdf20862e487689e352bf0f02f90df2795d29da5/colorcet-3.2.1.tar.gz", hash = "sha256:48d9a67e6e59dc5c0a965aa1b46fe5d59cdc95cc36a95949f29313f950ac59f7", size = 2202958, upload-time = "2026-04-28T16:25:37.43Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/24/e95471ae93c08d3606c9c7343cf65d490f154daa88b50581957a0aa780f4/colorcet-3.2.1-py3-none-any.whl", hash = "sha256:3f6fde13cef2169222dd5fe2a2bf847c02d644470fdf167ed566f6421df470f7", size = 262291, upload-time = "2026-04-28T16:25:35.365Z" }, +] + +[[package]] +name = "comm" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4c/13/7d740c5849255756bc17888787313b61fd38a0a8304fc4f073dfc46122aa/comm-0.2.3.tar.gz", hash = "sha256:2dc8048c10962d55d7ad693be1e7045d891b7ce8d999c97963a5e3e99c055971", size = 6319, upload-time = "2025-07-25T14:02:04.452Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/97/891a0971e1e4a8c5d2b20bbe0e524dc04548d2307fee33cdeba148fd4fc7/comm-0.2.3-py3-none-any.whl", hash = "sha256:c615d91d75f7f04f095b30d1c1711babd43bdc6419c1be9886a85f2f4e489417", size = 7294, upload-time = "2025-07-25T14:02:02.896Z" }, +] + +[[package]] +name = "confection" +version = "1.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ca/65/efd0fe8a936fc8ca2978cb7b82581fb20d901c6039e746a808f746b7647b/confection-1.3.3.tar.gz", hash = "sha256:f0f6810d567ff73993fe74d218ca5e1ffb6a44fb03f391257fc5d033546cbfaa", size = 54895, upload-time = "2026-03-24T18:45:24.331Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/e4/d66708bdf0d92fb4d49b22cdff4b10cec38aca5dcd7e81d909bb55c65cd7/confection-1.3.3-py3-none-any.whl", hash = "sha256:b9fef9ee84b237ef4611ec3eb5797b70e13063e6310ad9f15536373f5e313c82", size = 35902, upload-time = "2026-03-24T18:45:22.664Z" }, +] + +[[package]] +name = "cymem" +version = "2.0.13" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/2f0fbb32535c3731b7c2974c569fb9325e0a38ed5565a08e1139a3b71e82/cymem-2.0.13.tar.gz", hash = "sha256:1c91a92ae8c7104275ac26bd4d29b08ccd3e7faff5893d3858cb6fadf1bc1588", size = 12320, upload-time = "2025-11-14T14:58:36.902Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/14/462018dd384ee1848ac9c1951534a813a325abbfc161a74e2cbcb38d2469/cymem-2.0.13-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8efc4f308169237aade0e82877a65a563833dec32eb7ab2326120253e0e9e918", size = 43747, upload-time = "2025-11-14T14:57:11.287Z" }, + { url = "https://files.pythonhosted.org/packages/4b/9b/c123ba65dddcd8a2bc0b3c9046766c15abe0e257c315b3040eed22cce1e2/cymem-2.0.13-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e03bb575a96c59bc210d7d59862747f0012696b0dac3427ce8af33c7afb3d4a2", size = 43328, upload-time = "2025-11-14T14:57:12.578Z" }, + { url = "https://files.pythonhosted.org/packages/bd/be/7b7a4cf9cd2d37e674612a86fc90b3d59bff12177f83430e62b25afaf7fc/cymem-2.0.13-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1775d3fd34cf099929b79c3e48469283642463f977af6801231f3c0e5d9c9369", size = 231539, upload-time = "2025-11-14T14:57:14.441Z" }, + { url = "https://files.pythonhosted.org/packages/79/6d/d165c38cd4caaaf60942e2cec9998b667008f2384047ccfe0b4b5f7a1ffe/cymem-2.0.13-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:84e2976e38cd663f758e40b5497fa5cd183d7c5fb0d04ce81a4b42a1ba124ff0", size = 229674, upload-time = "2025-11-14T14:57:15.685Z" }, + { url = "https://files.pythonhosted.org/packages/95/c1/af83c03a93f890ca81149561b18a4a67a9aa36a1109f15e291dd2703ab12/cymem-2.0.13-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ed9de1b9b042f76fe5c312e4359eab58bf52ac7dfdf6887368a760410d809440", size = 229805, upload-time = "2025-11-14T14:57:17.289Z" }, + { url = "https://files.pythonhosted.org/packages/03/2d/12900758b80345d9aed5892a9d61e8a5f6abbbe5837e4def373a53cd0da2/cymem-2.0.13-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:1366c7437a209230f4b797fae10227a8206d4021d37c9f9c0d31fd97ea4feb35", size = 234018, upload-time = "2025-11-14T14:57:18.512Z" }, + { url = "https://files.pythonhosted.org/packages/a6/8b/5fcf5430fc81098aef58cc20340e51f37b49b9d8c15766e0d5d63e7288a3/cymem-2.0.13-cp310-cp310-win_amd64.whl", hash = "sha256:7700b116524b087e0169f10f267539223b48240ef2734c3a727a9e6b4db9a671", size = 40102, upload-time = "2025-11-14T14:57:19.972Z" }, + { url = "https://files.pythonhosted.org/packages/0d/d3/cb6c83758fe399443b858faafb7096b72535621a7af7dd9a54ff0989fa14/cymem-2.0.13-cp310-cp310-win_arm64.whl", hash = "sha256:c8dbfddfe5c604974e17c6f373cedd4d25cd67f84812ede7dea12128fa0c2015", size = 36282, upload-time = "2025-11-14T14:57:21.398Z" }, + { url = "https://files.pythonhosted.org/packages/10/64/1db41f7576a6b69f70367e3c15e968fd775ba7419e12059c9966ceb826f8/cymem-2.0.13-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:673183466b0ff2e060d97ec5116711d44200b8f7be524323e080d215ee2d44a5", size = 43587, upload-time = "2025-11-14T14:57:22.39Z" }, + { url = "https://files.pythonhosted.org/packages/81/13/57f936fc08551323aab3f92ff6b7f4d4b89d5b4e495c870a67cb8d279757/cymem-2.0.13-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:bee2791b3f6fc034ce41268851462bf662ff87e8947e35fb6dd0115b4644a61f", size = 43139, upload-time = "2025-11-14T14:57:23.363Z" }, + { url = "https://files.pythonhosted.org/packages/32/a6/9345754be51e0479aa387b7b6cffc289d0fd3201aaeb8dade4623abd1e02/cymem-2.0.13-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f3aee3adf16272bca81c5826eed55ba3c938add6d8c9e273f01c6b829ecfde22", size = 245063, upload-time = "2025-11-14T14:57:24.839Z" }, + { url = "https://files.pythonhosted.org/packages/d6/01/6bc654101526fa86e82bf6b05d99b2cd47c30a333cfe8622c26c0592beb2/cymem-2.0.13-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:30c4e75a3a1d809e89106b0b21803eb78e839881aa1f5b9bd27b454bc73afde3", size = 244496, upload-time = "2025-11-14T14:57:26.42Z" }, + { url = "https://files.pythonhosted.org/packages/c4/fb/853b7b021e701a1f41687f3704d5f469aeb2a4f898c3fbb8076806885955/cymem-2.0.13-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ec99efa03cf8ec11c8906aa4d4cc0c47df393bc9095c9dd64b89b9b43e220b04", size = 243287, upload-time = "2025-11-14T14:57:27.542Z" }, + { url = "https://files.pythonhosted.org/packages/d4/2b/0e4664cafc581de2896d75000651fd2ce7094d33263f466185c28ffc96e4/cymem-2.0.13-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c90a6ecba994a15b17a3f45d7ec74d34081df2f73bd1b090e2adc0317e4e01b6", size = 248287, upload-time = "2025-11-14T14:57:29.055Z" }, + { url = "https://files.pythonhosted.org/packages/21/0f/f94c6950edbfc2aafb81194fc40b6cacc8e994e9359d3cb4328c5705b9b5/cymem-2.0.13-cp311-cp311-win_amd64.whl", hash = "sha256:ce821e6ba59148ed17c4567113b8683a6a0be9c9ac86f14e969919121efb61a5", size = 40116, upload-time = "2025-11-14T14:57:30.592Z" }, + { url = "https://files.pythonhosted.org/packages/00/df/2455eff6ac0381ff165db6883b311f7016e222e3dd62185517f8e8187ed0/cymem-2.0.13-cp311-cp311-win_arm64.whl", hash = "sha256:0dca715e708e545fd1d97693542378a00394b20a37779c1ae2c8bdbb43acef79", size = 36349, upload-time = "2025-11-14T14:57:31.573Z" }, + { url = "https://files.pythonhosted.org/packages/c9/52/478a2911ab5028cb710b4900d64aceba6f4f882fcb13fd8d40a456a1b6dc/cymem-2.0.13-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e8afbc5162a0fe14b6463e1c4e45248a1b2fe2cbcecc8a5b9e511117080da0eb", size = 43745, upload-time = "2025-11-14T14:57:32.52Z" }, + { url = "https://files.pythonhosted.org/packages/f9/71/f0f8adee945524774b16af326bd314a14a478ed369a728a22834e6785a18/cymem-2.0.13-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c9251d889348fe79a75e9b3e4d1b5fa651fca8a64500820685d73a3acc21b6a8", size = 42927, upload-time = "2025-11-14T14:57:33.827Z" }, + { url = "https://files.pythonhosted.org/packages/62/6d/159780fe162ff715d62b809246e5fc20901cef87ca28b67d255a8d741861/cymem-2.0.13-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:742fc19764467a49ed22e56a4d2134c262d73a6c635409584ae3bf9afa092c33", size = 258346, upload-time = "2025-11-14T14:57:34.917Z" }, + { url = "https://files.pythonhosted.org/packages/eb/12/678d16f7aa1996f947bf17b8cfb917ea9c9674ef5e2bd3690c04123d5680/cymem-2.0.13-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f190a92fe46197ee64d32560eb121c2809bb843341733227f51538ce77b3410d", size = 260843, upload-time = "2025-11-14T14:57:36.503Z" }, + { url = "https://files.pythonhosted.org/packages/31/5d/0dd8c167c08cd85e70d274b7235cfe1e31b3cebc99221178eaf4bbb95c6f/cymem-2.0.13-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d670329ee8dbbbf241b7c08069fe3f1d3a1a3e2d69c7d05ea008a7010d826298", size = 254607, upload-time = "2025-11-14T14:57:38.036Z" }, + { url = "https://files.pythonhosted.org/packages/b7/c9/d6514a412a1160aa65db539836b3d47f9b59f6675f294ec34ae32f867c82/cymem-2.0.13-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a84ba3178d9128b9ffb52ce81ebab456e9fe959125b51109f5b73ebdfc6b60d6", size = 262421, upload-time = "2025-11-14T14:57:39.265Z" }, + { url = "https://files.pythonhosted.org/packages/dd/fe/3ee37d02ca4040f2fb22d34eb415198f955862b5dd47eee01df4c8f5454c/cymem-2.0.13-cp312-cp312-win_amd64.whl", hash = "sha256:2ff1c41fd59b789579fdace78aa587c5fc091991fa59458c382b116fc36e30dc", size = 40176, upload-time = "2025-11-14T14:57:40.706Z" }, + { url = "https://files.pythonhosted.org/packages/94/fb/1b681635bfd5f2274d0caa8f934b58435db6c091b97f5593738065ddb786/cymem-2.0.13-cp312-cp312-win_arm64.whl", hash = "sha256:6bbd701338df7bf408648191dff52472a9b334f71bcd31a21a41d83821050f67", size = 35959, upload-time = "2025-11-14T14:57:41.682Z" }, + { url = "https://files.pythonhosted.org/packages/ce/0f/95a4d1e3bebfdfa7829252369357cf9a764f67569328cd9221f21e2c952e/cymem-2.0.13-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:891fd9030293a8b652dc7fb9fdc79a910a6c76fc679cd775e6741b819ffea476", size = 43478, upload-time = "2025-11-14T14:57:42.682Z" }, + { url = "https://files.pythonhosted.org/packages/bf/a0/8fc929cc29ae466b7b4efc23ece99cbd3ea34992ccff319089c624d667fd/cymem-2.0.13-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:89c4889bd16513ce1644ccfe1e7c473ba7ca150f0621e66feac3a571bde09e7e", size = 42695, upload-time = "2025-11-14T14:57:43.741Z" }, + { url = "https://files.pythonhosted.org/packages/4a/b3/deeb01354ebaf384438083ffe0310209ef903db3e7ba5a8f584b06d28387/cymem-2.0.13-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:45dcaba0f48bef9cc3d8b0b92058640244a95a9f12542210b51318da97c2cf28", size = 250573, upload-time = "2025-11-14T14:57:44.81Z" }, + { url = "https://files.pythonhosted.org/packages/36/36/bc980b9a14409f3356309c45a8d88d58797d02002a9d794dd6c84e809d3a/cymem-2.0.13-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e96848faaafccc0abd631f1c5fb194eac0caee4f5a8777fdbb3e349d3a21741c", size = 254572, upload-time = "2025-11-14T14:57:46.023Z" }, + { url = "https://files.pythonhosted.org/packages/fd/dd/a12522952624685bd0f8968e26d2ed6d059c967413ce6eb52292f538f1b0/cymem-2.0.13-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e02d3e2c3bfeb21185d5a4a70790d9df40629a87d8d7617dc22b4e864f665fa3", size = 248060, upload-time = "2025-11-14T14:57:47.605Z" }, + { url = "https://files.pythonhosted.org/packages/08/11/5dc933ddfeb2dfea747a0b935cb965b9a7580b324d96fc5f5a1b5ff8df29/cymem-2.0.13-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fece5229fd5ecdcd7a0738affb8c59890e13073ae5626544e13825f26c019d3c", size = 254601, upload-time = "2025-11-14T14:57:48.861Z" }, + { url = "https://files.pythonhosted.org/packages/70/66/d23b06166864fa94e13a98e5922986ce774832936473578febce64448d75/cymem-2.0.13-cp313-cp313-win_amd64.whl", hash = "sha256:38aefeb269597c1a0c2ddf1567dd8605489b661fa0369c6406c1acd433b4c7ba", size = 40103, upload-time = "2025-11-14T14:57:50.396Z" }, + { url = "https://files.pythonhosted.org/packages/2f/9e/c7b21271ab88a21760f3afdec84d2bc09ffa9e6c8d774ad9d4f1afab0416/cymem-2.0.13-cp313-cp313-win_arm64.whl", hash = "sha256:717270dcfd8c8096b479c42708b151002ff98e434a7b6f1f916387a6c791e2ad", size = 36016, upload-time = "2025-11-14T14:57:51.611Z" }, + { url = "https://files.pythonhosted.org/packages/7f/28/d3b03427edc04ae04910edf1c24b993881c3ba93a9729a42bcbb816a1808/cymem-2.0.13-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:7e1a863a7f144ffb345397813701509cfc74fc9ed360a4d92799805b4b865dd1", size = 46429, upload-time = "2025-11-14T14:57:52.582Z" }, + { url = "https://files.pythonhosted.org/packages/35/a9/7ed53e481f47ebfb922b0b42e980cec83e98ccb2137dc597ea156642440c/cymem-2.0.13-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:c16cb80efc017b054f78998c6b4b013cef509c7b3d802707ce1f85a1d68361bf", size = 46205, upload-time = "2025-11-14T14:57:53.64Z" }, + { url = "https://files.pythonhosted.org/packages/61/39/a3d6ad073cf7f0fbbb8bbf09698c3c8fac11be3f791d710239a4e8dd3438/cymem-2.0.13-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0d78a27c88b26c89bd1ece247d1d5939dba05a1dae6305aad8fd8056b17ddb51", size = 296083, upload-time = "2025-11-14T14:57:55.922Z" }, + { url = "https://files.pythonhosted.org/packages/36/0c/20697c8bc19f624a595833e566f37d7bcb9167b0ce69de896eba7cfc9c2d/cymem-2.0.13-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6d36710760f817194dacb09d9fc45cb6a5062ed75e85f0ef7ad7aeeb13d80cc3", size = 286159, upload-time = "2025-11-14T14:57:57.106Z" }, + { url = "https://files.pythonhosted.org/packages/82/d4/9326e3422d1c2d2b4a8fb859bdcce80138f6ab721ddafa4cba328a505c71/cymem-2.0.13-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c8f30971cadd5dcf73bcfbbc5849b1f1e1f40db8cd846c4aa7d3b5e035c7b583", size = 288186, upload-time = "2025-11-14T14:57:58.334Z" }, + { url = "https://files.pythonhosted.org/packages/ed/bc/68da7dd749b72884dc22e898562f335002d70306069d496376e5ff3b6153/cymem-2.0.13-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:9d441d0e45798ec1fd330373bf7ffa6b795f229275f64016b6a193e6e2a51522", size = 290353, upload-time = "2025-11-14T14:58:00.562Z" }, + { url = "https://files.pythonhosted.org/packages/50/23/dbf2ad6ecd19b99b3aab6203b1a06608bbd04a09c522d836b854f2f30f73/cymem-2.0.13-cp313-cp313t-win_amd64.whl", hash = "sha256:d1c950eebb9f0f15e3ef3591313482a5a611d16fc12d545e2018cd607f40f472", size = 44764, upload-time = "2025-11-14T14:58:01.793Z" }, + { url = "https://files.pythonhosted.org/packages/54/3f/35701c13e1fc7b0895198c8b20068c569a841e0daf8e0b14d1dc0816b28f/cymem-2.0.13-cp313-cp313t-win_arm64.whl", hash = "sha256:042e8611ef862c34a97b13241f5d0da86d58aca3cecc45c533496678e75c5a1f", size = 38964, upload-time = "2025-11-14T14:58:02.87Z" }, + { url = "https://files.pythonhosted.org/packages/a7/2e/f0e1596010a9a57fa9ebd124a678c07c5b2092283781ae51e79edcf5cb98/cymem-2.0.13-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d2a4bf67db76c7b6afc33de44fb1c318207c3224a30da02c70901936b5aafdf1", size = 43812, upload-time = "2025-11-14T14:58:04.227Z" }, + { url = "https://files.pythonhosted.org/packages/bc/45/8ccc21df08fcbfa6aa3efeb7efc11a1c81c90e7476e255768bb9c29ba02a/cymem-2.0.13-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:92a2ce50afa5625fb5ce7c9302cee61e23a57ccac52cd0410b4858e572f8614b", size = 42951, upload-time = "2025-11-14T14:58:05.424Z" }, + { url = "https://files.pythonhosted.org/packages/01/8c/fe16531631f051d3d1226fa42e2d76fd2c8d5cfa893ec93baee90c7a9d90/cymem-2.0.13-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bc116a70cc3a5dc3d1684db5268eff9399a0be8603980005e5b889564f1ea42f", size = 249878, upload-time = "2025-11-14T14:58:06.95Z" }, + { url = "https://files.pythonhosted.org/packages/47/4b/39d67b80ffb260457c05fcc545de37d82e9e2dbafc93dd6b64f17e09b933/cymem-2.0.13-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:68489bf0035c4c280614067ab6a82815b01dc9fcd486742a5306fe9f68deb7ef", size = 252571, upload-time = "2025-11-14T14:58:08.232Z" }, + { url = "https://files.pythonhosted.org/packages/53/0e/76f6531f74dfdfe7107899cce93ab063bb7ee086ccd3910522b31f623c08/cymem-2.0.13-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:03cb7bdb55718d5eb6ef0340b1d2430ba1386db30d33e9134d01ba9d6d34d705", size = 248555, upload-time = "2025-11-14T14:58:09.429Z" }, + { url = "https://files.pythonhosted.org/packages/c7/7c/eee56757db81f0aefc2615267677ae145aff74228f529838425057003c0d/cymem-2.0.13-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1710390e7fb2510a8091a1991024d8ae838fd06b02cdfdcd35f006192e3c6b0e", size = 254177, upload-time = "2025-11-14T14:58:10.594Z" }, + { url = "https://files.pythonhosted.org/packages/77/e0/a4b58ec9e53c836dce07ef39837a64a599f4a21a134fc7ca57a3a8f9a4b5/cymem-2.0.13-cp314-cp314-win_amd64.whl", hash = "sha256:ac699c8ec72a3a9de8109bd78821ab22f60b14cf2abccd970b5ff310e14158ed", size = 40853, upload-time = "2025-11-14T14:58:12.116Z" }, + { url = "https://files.pythonhosted.org/packages/61/81/9931d1f83e5aeba175440af0b28f0c2e6f71274a5a7b688bc3e907669388/cymem-2.0.13-cp314-cp314-win_arm64.whl", hash = "sha256:90c2d0c04bcda12cd5cebe9be93ce3af6742ad8da96e1b1907e3f8e00291def1", size = 36970, upload-time = "2025-11-14T14:58:13.114Z" }, + { url = "https://files.pythonhosted.org/packages/b7/ef/af447c2184dec6dec973be14614df8ccb4d16d1c74e0784ab4f02538433c/cymem-2.0.13-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ff036bbc1464993552fd1251b0a83fe102af334b301e3896d7aa05a4999ad042", size = 46804, upload-time = "2025-11-14T14:58:14.113Z" }, + { url = "https://files.pythonhosted.org/packages/8c/95/e10f33a8d4fc17f9b933d451038218437f9326c2abb15a3e7f58ce2a06ec/cymem-2.0.13-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:fb8291691ba7ff4e6e000224cc97a744a8d9588418535c9454fd8436911df612", size = 46254, upload-time = "2025-11-14T14:58:15.156Z" }, + { url = "https://files.pythonhosted.org/packages/e7/7a/5efeb2d2ea6ebad2745301ad33a4fa9a8f9a33b66623ee4d9185683007a6/cymem-2.0.13-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d8d06ea59006b1251ad5794bcc00121e148434826090ead0073c7b7fedebe431", size = 296061, upload-time = "2025-11-14T14:58:16.254Z" }, + { url = "https://files.pythonhosted.org/packages/0b/28/2a3f65842cc8443c2c0650cf23d525be06c8761ab212e0a095a88627be1b/cymem-2.0.13-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c0046a619ecc845ccb4528b37b63426a0cbcb4f14d7940add3391f59f13701e6", size = 285784, upload-time = "2025-11-14T14:58:17.412Z" }, + { url = "https://files.pythonhosted.org/packages/98/73/dd5f9729398f0108c2e71d942253d0d484d299d08b02e474d7cfc43ed0b0/cymem-2.0.13-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:18ad5b116a82fa3674bc8838bd3792891b428971e2123ae8c0fd3ca472157c5e", size = 288062, upload-time = "2025-11-14T14:58:20.225Z" }, + { url = "https://files.pythonhosted.org/packages/5a/01/ffe51729a8f961a437920560659073e47f575d4627445216c1177ecd4a41/cymem-2.0.13-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:666ce6146bc61b9318aa70d91ce33f126b6344a25cf0b925621baed0c161e9cc", size = 290465, upload-time = "2025-11-14T14:58:21.815Z" }, + { url = "https://files.pythonhosted.org/packages/fd/ac/c9e7d68607f71ef978c81e334ab2898b426944c71950212b1467186f69f9/cymem-2.0.13-cp314-cp314t-win_amd64.whl", hash = "sha256:84c1168c563d9d1e04546cb65e3e54fde2bf814f7c7faf11fc06436598e386d1", size = 46665, upload-time = "2025-11-14T14:58:23.512Z" }, + { url = "https://files.pythonhosted.org/packages/66/66/150e406a2db5535533aa3c946de58f0371f2e412e23f050c704588023e6e/cymem-2.0.13-cp314-cp314t-win_arm64.whl", hash = "sha256:e9027764dc5f1999fb4b4cabee1d0322c59e330c0a6485b436a68275f614277f", size = 39715, upload-time = "2025-11-14T14:58:24.773Z" }, +] + +[[package]] +name = "datashader" +version = "0.19.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorcet" }, + { name = "multipledispatch" }, + { name = "numba" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "packaging" }, + { name = "pandas", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "pandas", version = "3.0.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "param" }, + { name = "pyct" }, + { name = "requests" }, + { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "scipy", version = "1.18.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "toolz" }, + { name = "xarray", version = "2025.6.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "xarray", version = "2026.7.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/53/4b/a9141f286f0c01685e9715ba3404769a6138e74575c4d5ccbaa95a22c3bd/datashader-0.19.1.tar.gz", hash = "sha256:f62d880a4a431813f9bb3959e565feda79c1634f889aaadcf948cb0d0c114cdd", size = 10581968, upload-time = "2026-05-19T07:53:06.574Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/41/247627c8b9fef5c605d00546b85771a8fe42975b9616a557cead5468789b/datashader-0.19.1-py3-none-any.whl", hash = "sha256:7ce7154ff3ed070607f429355f57002fee5a17964e47c0b6447eeafe8cef9c82", size = 10715897, upload-time = "2026-05-19T07:53:03.431Z" }, +] + +[[package]] +name = "debugpy" +version = "1.8.21" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f2/aa/12037145b7a56eaa5b29b41872f7a21b538e807e13f32c4d3c46e59be084/debugpy-1.8.21.tar.gz", hash = "sha256:a3c53278e84c94e11bd87c53970ec391d1a67396c8b22609fcac576520e611a6", size = 1697577, upload-time = "2026-06-01T19:30:35.156Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/f3/6b1d4c71f4cbb5360009f928934a03b42906f28fc7b3f7f35f04e58acead/debugpy-1.8.21-cp310-cp310-macosx_15_0_x86_64.whl", hash = "sha256:8eeab7b5462f683452c57c0126aaa5ec4e974ddb705f39ba87dff8818c8e08f9", size = 2113873, upload-time = "2026-06-01T19:30:37.148Z" }, + { url = "https://files.pythonhosted.org/packages/1c/f2/17c3bf91cebc173bfbf5734cd2669723d0a35c0cf9d2fd2124546efeae83/debugpy-1.8.21-cp310-cp310-manylinux_2_34_x86_64.whl", hash = "sha256:0fddfdc130ac6d8bfc0415b0409822fa901c8f310e5c945ac5653a0352532344", size = 3004715, upload-time = "2026-06-01T19:30:38.888Z" }, + { url = "https://files.pythonhosted.org/packages/5a/22/1f8efd80c7b5909e760f9cfd0c9e8681d2d35d532f7c0a40760cd4da4a19/debugpy-1.8.21-cp310-cp310-win32.whl", hash = "sha256:72b5d676c4cbfac3bac5bb01c138a4656e843f93f03ce2a5f4e394ad49fbee73", size = 5303455, upload-time = "2026-06-01T19:30:40.52Z" }, + { url = "https://files.pythonhosted.org/packages/da/ce/54c79abd6cccef92fa7b43d97e3acafedf4d645557267ece05e948b5e4b8/debugpy-1.8.21-cp310-cp310-win_amd64.whl", hash = "sha256:a7fe47fd23da57b9e0bec3f4a8ee65a2dc55782455ed7f2141d75ab5d2eaeef5", size = 5331751, upload-time = "2026-06-01T19:30:42.146Z" }, + { url = "https://files.pythonhosted.org/packages/89/fb/cbf306d6e07a313a91e7171a98669054502840931432c227cfd505ee367f/debugpy-1.8.21-cp311-cp311-macosx_15_0_universal2.whl", hash = "sha256:da456226c7b4c69e35dbe35dcee6623d912000a77816db7856a41af1c72a0264", size = 2203120, upload-time = "2026-06-01T19:30:43.964Z" }, + { url = "https://files.pythonhosted.org/packages/aa/57/aa739bd4ad2cbf96aeb1b20b56918ddd5ae4c28b68709bfcd327f02123ee/debugpy-1.8.21-cp311-cp311-manylinux_2_34_x86_64.whl", hash = "sha256:f68b891688e61bdc08b8d364d919ff0051e0b94657b39dcd027bc3173edb7cdc", size = 3059958, upload-time = "2026-06-01T19:30:45.622Z" }, + { url = "https://files.pythonhosted.org/packages/a8/31/453d2c9a23d133fe2c8ec7ca1d816ded52a913487fe3ffef7c01b4b706af/debugpy-1.8.21-cp311-cp311-win32.whl", hash = "sha256:f843a8b08c2edeaf9b1582eed4f25441af21a297c22ff16bf76a662557aa9c9e", size = 5236515, upload-time = "2026-06-01T19:30:47.461Z" }, + { url = "https://files.pythonhosted.org/packages/60/94/6660de2f2d7bf388f229335ba4637646eebabdbf38564cb439a95a9193c9/debugpy-1.8.21-cp311-cp311-win_amd64.whl", hash = "sha256:84c564d8cc701d41843b29a92814c1f1bef6798724ca9d675c284ad9f6a547d7", size = 5256138, upload-time = "2026-06-01T19:30:49.113Z" }, + { url = "https://files.pythonhosted.org/packages/a2/df/bf625547431a9cadc9f4cbfeda38866e2b17f6aed147b625377e87834449/debugpy-1.8.21-cp312-cp312-macosx_15_0_universal2.whl", hash = "sha256:9f96713896f39c3dff0ee841f47320c3f2983d33c341e009361bb0ebc79adc4e", size = 2483609, upload-time = "2026-06-01T19:30:50.794Z" }, + { url = "https://files.pythonhosted.org/packages/bf/09/59324b903599031ff9faaec1758292409f6561a0ec2492fe4b703327705a/debugpy-1.8.21-cp312-cp312-manylinux_2_34_x86_64.whl", hash = "sha256:c193d474f0a211191f2b4449d2d06157c689013035bd952f3b617e0ef422b176", size = 3968900, upload-time = "2026-06-01T19:30:52.341Z" }, + { url = "https://files.pythonhosted.org/packages/14/cd/27f65b805d7fe005c44e1a36b9183ecdfbcdbf9d3e721a5115d461ecc7ee/debugpy-1.8.21-cp312-cp312-win32.whl", hash = "sha256:4743373c1cac7f9e74a1b9915bf1dbe0e900eca657ffb170ae07ac8363205ae9", size = 5336340, upload-time = "2026-06-01T19:30:54.047Z" }, + { url = "https://files.pythonhosted.org/packages/77/1d/c84e30c0c674184948b66f076ab271c01d940618a2824c23cd035a27bc20/debugpy-1.8.21-cp312-cp312-win_amd64.whl", hash = "sha256:bd7ba9dd3daa7c2f942c6ca8d4695a16bf9ac16b63615261c7982bc74f7ed20c", size = 5374751, upload-time = "2026-06-01T19:30:55.891Z" }, + { url = "https://files.pythonhosted.org/packages/77/6b/d817e1f8cc77aa055d37fba092e0febfdff40fe652d8d53d4cd7a86ad98d/debugpy-1.8.21-cp313-cp313-macosx_15_0_universal2.whl", hash = "sha256:13678151fc401e2d68c9880b91e28714f797d40422994572b24560ef80910a88", size = 2477398, upload-time = "2026-06-01T19:30:57.644Z" }, + { url = "https://files.pythonhosted.org/packages/48/57/412421516afc3055fa577516f00beec3d663f9b0ab330639547ae6c57720/debugpy-1.8.21-cp313-cp313-manylinux_2_34_x86_64.whl", hash = "sha256:ecbd158386c31ffe71d46f72d44d56e66331ab9b16cad649156d514368f23ab2", size = 3962096, upload-time = "2026-06-01T19:30:59.235Z" }, + { url = "https://files.pythonhosted.org/packages/c1/62/2c616337cf6ba7b07ebbc97f02c6c945a8e2f76b365e33ee809c32ee36d1/debugpy-1.8.21-cp313-cp313-win32.whl", hash = "sha256:2c2ae706dec41d99a9ca1f7ebc987a83e65578363be6f6b3ac9067504917fae1", size = 5336288, upload-time = "2026-06-01T19:31:00.79Z" }, + { url = "https://files.pythonhosted.org/packages/f8/99/9175103392f84c4b1bf7622888cdc68da07f0ff7d9e581266428f6776033/debugpy-1.8.21-cp313-cp313-win_amd64.whl", hash = "sha256:aa648733047443eb1d07682c4ef287d36a54507b643ffdf38b09a3ef002c72a0", size = 5376567, upload-time = "2026-06-01T19:31:02.56Z" }, + { url = "https://files.pythonhosted.org/packages/ce/3d/f4bbb323a548bfab2af3d6b4ffd9bf22636e55956a1285d317a1de643aad/debugpy-1.8.21-cp314-cp314-macosx_15_0_universal2.whl", hash = "sha256:9bb2a685287a2ac9b181cde89edcec64845cb51de7faaa75badb9a698bc24782", size = 2477209, upload-time = "2026-06-01T19:31:04.157Z" }, + { url = "https://files.pythonhosted.org/packages/8c/2d/6e7ec524984a1702777868de49a4c53202bddac2a432a76a093469587750/debugpy-1.8.21-cp314-cp314-manylinux_2_34_x86_64.whl", hash = "sha256:3d6922439bf33fd38a3e2c447869ebc7b97da5cd3d329ff1ef9bc06c4903437e", size = 3927115, upload-time = "2026-06-01T19:31:05.863Z" }, + { url = "https://files.pythonhosted.org/packages/97/47/d1aa6d64005a98a9144647d99306b419396f9ad7bf1d73c119e17a81fb4d/debugpy-1.8.21-cp314-cp314-win32.whl", hash = "sha256:15d4963bd5ffa48f0da0947fd06757fa7621945048a14ad7705431566d3c0e7c", size = 5336724, upload-time = "2026-06-01T19:31:07.711Z" }, + { url = "https://files.pythonhosted.org/packages/5f/67/b905b90d163af11878c1af8abafa4a25206335e112e284e413454543a6da/debugpy-1.8.21-cp314-cp314-win_amd64.whl", hash = "sha256:fe0744a12353406de0ae8ccff0d0a4a666f00801a3db8fd04e7a5f761cd520e8", size = 5373803, upload-time = "2026-06-01T19:31:09.469Z" }, + { url = "https://files.pythonhosted.org/packages/95/51/67e7cf11a53e40694f720457d5b3a1cdaaa3d5a9a633e482f225456b93ff/debugpy-1.8.21-py2.py3-none-any.whl", hash = "sha256:b1e37d333663c8851516a47364ef473da127f9caebe4417e6df6f5825a7e9a92", size = 5352888, upload-time = "2026-06-01T19:31:25.186Z" }, +] + +[[package]] +name = "decorator" +version = "5.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/60/8b/32f9823da46cde7df2087faa08cd98d01b908f8dcab982cdba9c84e85355/decorator-5.3.1.tar.gz", hash = "sha256:4cbcdd55a6efadb9dbea26b858f4fb3264567b52d69ca0d25b721b553f60ea82", size = 58084, upload-time = "2026-05-18T06:03:28.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/05/7f/798705f5296a58ca505d600456748d1be48078eac8a7050d8a98bc9edb89/decorator-5.3.1-py3-none-any.whl", hash = "sha256:f47fe6fdbd2edd623ecfe36875d37aba411624e2670dd395dddae1358689bb3c", size = 10365, upload-time = "2026-05-18T06:03:26.517Z" }, +] + +[[package]] +name = "defusedxml" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/d5/c66da9b79e5bdb124974bfe172b4daf3c984ebd9c2a06e2b8a4dc7331c72/defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69", size = 75520, upload-time = "2021-03-08T10:59:26.269Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604, upload-time = "2021-03-08T10:59:24.45Z" }, +] + +[[package]] +name = "distlib" +version = "0.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/02/bd72be9134d25ed783ecbbc38a539ffaefbf90c78418c7fb7229600dbac7/distlib-0.4.3.tar.gz", hash = "sha256:f152097224a0ae24be5a0f6bae1b9359af82133bce63f98a95f86cae1aede9ed", size = 615141, upload-time = "2026-06-12T08:04:52.847Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/08/9c41fb51ab5b43eb21674aff13df270e8ba6c4b29c8624e328dc7a9482af/distlib-0.4.3-py2.py3-none-any.whl", hash = "sha256:4b0ce306c966eb73bc3a7b6abad017c556dadd92c44701562cd528ac7fde4d5b", size = 470628, upload-time = "2026-06-12T08:04:50.506Z" }, +] + +[[package]] +name = "docutils" +version = "0.23" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/39/a4/5180d9afc57e8fca05601dd652bdff19604c218814037fe90ffc7625a50a/docutils-0.23.tar.gz", hash = "sha256:746f5060322511280a1e50eb76846ed6bf2342984b2ac04dc42caa1a8d78799e", size = 2303823, upload-time = "2026-05-27T17:41:06.934Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/91/30151a39f7570f448ed84529390628a651d7f27c87d73c9b887f8189695e/docutils-0.23-py3-none-any.whl", hash = "sha256:25d013af9bf23bc1c7b2b093dff4208166c53a94786c9e447808335ef1185fea", size = 634701, upload-time = "2026-05-27T17:40:58.442Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "executing" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/28/c14e053b6762b1044f34a13aab6859bbf40456d37d23aa286ac24cfd9a5d/executing-2.2.1.tar.gz", hash = "sha256:3632cc370565f6648cc328b32435bd120a1e4ebb20c77e3fdde9a13cd1e533c4", size = 1129488, upload-time = "2025-09-01T09:48:10.866Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017", size = 28317, upload-time = "2025-09-01T09:48:08.5Z" }, +] + +[[package]] +name = "fastjsonschema" +version = "2.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/98/474719c58eddaf77fa443b063693e76d49db32bbe851bcbaf58d2700119f/fastjsonschema-2.22.1.tar.gz", hash = "sha256:0b83d1ce8d7845b959dcb20e1a5c3c8883b6541d9c52ab02cce5166b75ec805f", size = 382291, upload-time = "2026-07-27T13:31:08.515Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/17/e1/62cc96341f01bdff2ba967441939178fcd1900d11ce7e6554d9954a5d7ec/fastjsonschema-2.22.1-py3-none-any.whl", hash = "sha256:cf377ff5c9a6f4f3125fb35f75a2c5767bd824ffbcf62c209a93cd48d1453999", size = 26239, upload-time = "2026-07-27T13:31:03.251Z" }, +] + +[[package]] +name = "filelock" +version = "3.32.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c0/80/8232b582c4b318b817cf1274ba74976b07b34d35ef439b3eb948f98645a1/filelock-3.32.0.tar.gz", hash = "sha256:7be2ad23a14607ccc71808e68fe30848aeace7058ace17852f68e2a68e310402", size = 213757, upload-time = "2026-07-21T13:17:42.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/79/b4c714bef36bc4ec2beeae1e0c124f0223888cd8c6feb1cdc56038116920/filelock-3.32.0-py3-none-any.whl", hash = "sha256:d396bea984af47333ef05e50eae7eff88c84256de6112aea0ec48a233c064fe3", size = 97732, upload-time = "2026-07-21T13:17:41.55Z" }, +] + +[[package]] +name = "fqdn" +version = "1.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/30/3e/a80a8c077fd798951169626cde3e239adeba7dab75deb3555716415bd9b0/fqdn-1.5.1.tar.gz", hash = "sha256:105ed3677e767fb5ca086a0c1f4bb66ebc3c100be518f0e0d755d9eae164d89f", size = 6015, upload-time = "2021-03-11T07:16:29.08Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cf/58/8acf1b3e91c58313ce5cb67df61001fc9dcd21be4fadb76c1a2d540e09ed/fqdn-1.5.1-py3-none-any.whl", hash = "sha256:3a179af3761e4df6eb2e026ff9e1a3033d3587bf980a0b1b2e1e5d08d7358014", size = 9121, upload-time = "2021-03-11T07:16:28.351Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, +] + +[[package]] +name = "importlib-metadata" +version = "9.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "zipp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/01/15bb152d77b21318514a96f43af312635eb2500c96b55398d020c93d86ea/importlib_metadata-9.0.0.tar.gz", hash = "sha256:a4f57ab599e6a2e3016d7595cfd72eb4661a5106e787a95bcc90c7105b831efc", size = 56405, upload-time = "2026-03-20T06:42:56.999Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/3d/2d244233ac4f76e38533cfcb2991c9eb4c7bf688ae0a036d30725b8faafe/importlib_metadata-9.0.0-py3-none-any.whl", hash = "sha256:2d21d1cc5a017bd0559e36150c21c830ab1dc304dedd1b7ea85d20f45ef3edd7", size = 27789, upload-time = "2026-03-20T06:42:55.665Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "ipykernel" +version = "7.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "appnope", marker = "sys_platform == 'darwin'" }, + { name = "comm" }, + { name = "debugpy" }, + { name = "ipython", version = "8.39.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "ipython", version = "9.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "jupyter-client" }, + { name = "jupyter-core" }, + { name = "matplotlib-inline" }, + { name = "nest-asyncio2" }, + { name = "packaging" }, + { name = "psutil" }, + { name = "pyzmq" }, + { name = "tornado" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/c4/e4a38f579de4225a561305666f7541cdabb30075def2aa1ac17bd73c1fb5/ipykernel-7.3.0.tar.gz", hash = "sha256:9acaaaf97d16355166e4085afe9d225bfbdf2b7ef520f9df3be8f2b248275e09", size = 184899, upload-time = "2026-06-10T08:41:25.481Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/02/77b271f5dc58bfbc0b577c877b2365d1ffea2afe66a80c13f2312820348c/ipykernel-7.3.0-py3-none-any.whl", hash = "sha256:897eb64da762549ef610698fca5e9675195ec6ac8ec7f19d81ce1ca20c876057", size = 120583, upload-time = "2026-06-10T08:41:23.648Z" }, +] + +[[package]] +name = "ipython" +version = "8.39.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "decorator" }, + { name = "exceptiongroup" }, + { name = "jedi" }, + { name = "matplotlib-inline" }, + { name = "pexpect", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "prompt-toolkit" }, + { name = "pygments" }, + { name = "stack-data" }, + { name = "traitlets" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/40/18/f8598d287006885e7136451fdea0755af4ebcbfe342836f24deefaed1164/ipython-8.39.0.tar.gz", hash = "sha256:4110ae96012c379b8b6db898a07e186c40a2a1ef5d57a7fa83166047d9da7624", size = 5513971, upload-time = "2026-03-27T10:02:13.94Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/56/4cc7fc9e9e3f38fd324f24f8afe0ad8bb5fa41283f37f1aaf9de0612c968/ipython-8.39.0-py3-none-any.whl", hash = "sha256:bb3c51c4fa8148ab1dea07a79584d1c854e234ea44aa1283bcb37bc75054651f", size = 831849, upload-time = "2026-03-27T10:02:07.846Z" }, +] + +[[package]] +name = "ipython" +version = "9.15.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.15' and sys_platform == 'win32'", + "python_full_version >= '3.15' and sys_platform == 'emscripten'", + "python_full_version >= '3.15' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.14.*' and sys_platform == 'win32'", + "python_full_version == '3.14.*' and sys_platform == 'emscripten'", + "python_full_version == '3.14.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", + "python_full_version == '3.11.*' and sys_platform == 'emscripten'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "decorator" }, + { name = "ipython-pygments-lexers" }, + { name = "jedi" }, + { name = "matplotlib-inline" }, + { name = "pexpect", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "prompt-toolkit" }, + { name = "psutil", marker = "sys_platform != 'cygwin' and sys_platform != 'emscripten'" }, + { name = "pygments" }, + { name = "stack-data" }, + { name = "traitlets" }, + { name = "typing-extensions", marker = "python_full_version < '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/53/59/165d3b4d75cc34add3122c4417ecb229085140ac573103c223cd01dde96f/ipython-9.15.0.tar.gz", hash = "sha256:da2819ce2aa83135257df830660b1176d986c3d2876db24df01974fa955b2756", size = 4442580, upload-time = "2026-06-26T11:03:35.913Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/3a/948263ca3b9d65bb2b1b0c521b3a49fad5d59ada58724bd87d2bd5ff3f36/ipython-9.15.0-py3-none-any.whl", hash = "sha256:515ad9c3cdf0c932a5a9f6245419e8aba706b7bd03c3e1d3a1c83d9351d6aa6e", size = 630895, upload-time = "2026-06-26T11:03:33.809Z" }, +] + +[[package]] +name = "ipython-pygments-lexers" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ef/4c/5dd1d8af08107f88c7f741ead7a40854b8ac24ddf9ae850afbcf698aa552/ipython_pygments_lexers-1.1.1.tar.gz", hash = "sha256:09c0138009e56b6854f9535736f4171d855c8c08a563a0dcd8022f78355c7e81", size = 8393, upload-time = "2025-01-17T11:24:34.505Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/33/1f075bf72b0b747cb3288d011319aaf64083cf2efef8354174e3ed4540e2/ipython_pygments_lexers-1.1.1-py3-none-any.whl", hash = "sha256:a9462224a505ade19a605f71f8fa63c2048833ce50abc86768a0d81d876dc81c", size = 8074, upload-time = "2025-01-17T11:24:33.271Z" }, +] + +[[package]] +name = "ipywidgets" +version = "8.1.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "comm" }, + { name = "ipython", version = "8.39.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "ipython", version = "9.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "jupyterlab-widgets" }, + { name = "traitlets" }, + { name = "widgetsnbextension" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4c/ae/c5ce1edc1afe042eadb445e95b0671b03cee61895264357956e61c0d2ac0/ipywidgets-8.1.8.tar.gz", hash = "sha256:61f969306b95f85fba6b6986b7fe45d73124d1d9e3023a8068710d47a22ea668", size = 116739, upload-time = "2025-11-01T21:18:12.393Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/6d/0d9848617b9f753b87f214f1c682592f7ca42de085f564352f10f0843026/ipywidgets-8.1.8-py3-none-any.whl", hash = "sha256:ecaca67aed704a338f88f67b1181b58f821ab5dc89c1f0f5ef99db43c1c2921e", size = 139808, upload-time = "2025-11-01T21:18:10.956Z" }, +] + +[[package]] +name = "isoduration" +version = "20.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "arrow" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7c/1a/3c8edc664e06e6bd06cce40c6b22da5f1429aa4224d0c590f3be21c91ead/isoduration-20.11.0.tar.gz", hash = "sha256:ac2f9015137935279eac671f94f89eb00584f940f5dc49462a0c4ee692ba1bd9", size = 11649, upload-time = "2020-11-01T11:00:00.312Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/55/e5326141505c5d5e34c5e0935d2908a74e4561eca44108fbfb9c13d2911a/isoduration-20.11.0-py3-none-any.whl", hash = "sha256:b2904c2a4228c3d44f409c8ae8e2370eb21a26f7ac2ec5446df141dde3452042", size = 11321, upload-time = "2020-11-01T10:59:58.02Z" }, +] + +[[package]] +name = "jedi" +version = "0.20.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "parso" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/46/b7/a3635f6a2d7cf5b5dd98064fc1d5fbbafcb25477bcea204a3a92145d158b/jedi-0.20.0.tar.gz", hash = "sha256:c3f4ccbd276696f4b19c54618d4fb18f9fc24b0aef02acf704b23f487daa1011", size = 3119416, upload-time = "2026-05-01T23:38:47.814Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/93/242e2eab5fe682ffcb8b0084bde703a41d51e17ee0f3a31ff0d9d813620a/jedi-0.20.0-py2.py3-none-any.whl", hash = "sha256:7bdd9c2634f56713299976f4cbd59cb3fa92165cc5e05ea811fb253480728b67", size = 4884812, upload-time = "2026-05-01T23:38:43.919Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "json5" +version = "0.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/7d/05c46a96a78147ae3bf99c2f4169ce144a70220b8d6fcd56f6ec368b8ce9/json5-0.15.0.tar.gz", hash = "sha256:7424d1f1eb1d56da6e3d70643f53619862b4ce81440bdb8ecfd6f875e5ba4a71", size = 53278, upload-time = "2026-06-19T20:08:27.716Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/be/59527c99478aade6bb33a68d72e6e18dd4e6ff6eacfc7d01bdb15bc76912/json5-0.15.0-py3-none-any.whl", hash = "sha256:56636a30c0e8a4665fe2179c0212f32eae3796dea89ea6f649b9436ecdb39618", size = 36570, upload-time = "2026-06-19T20:08:26.748Z" }, +] + +[[package]] +name = "jsonpointer" +version = "3.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/18/c7/af399a2e7a67fd18d63c40c5e62d3af4e67b836a2107468b6a5ea24c4304/jsonpointer-3.1.1.tar.gz", hash = "sha256:0b801c7db33a904024f6004d526dcc53bbb8a4a0f4e32bfd10beadf60adf1900", size = 9068, upload-time = "2026-03-23T22:32:32.458Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/6a/a83720e953b1682d2d109d3c2dbb0bc9bf28cc1cbc205be4ef4be5da709d/jsonpointer-3.1.1-py3-none-any.whl", hash = "sha256:8ff8b95779d071ba472cf5bc913028df06031797532f08a7d5b602d8b2a488ca", size = 7659, upload-time = "2026-03-23T22:32:31.568Z" }, +] + +[[package]] +name = "jsonschema" +version = "4.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py", version = "0.30.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "rpds-py", version = "2026.6.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, +] + +[package.optional-dependencies] +format-nongpl = [ + { name = "fqdn" }, + { name = "idna" }, + { name = "isoduration" }, + { name = "jsonpointer" }, + { name = "rfc3339-validator" }, + { name = "rfc3986-validator" }, + { name = "rfc3987-syntax" }, + { name = "uri-template" }, + { name = "webcolors" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, +] + +[[package]] +name = "jupyter-builder" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jupyter-core" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c3/61/47f7ae054f5cd3983c10e1d65a6eb7fcd4b87ebb1056e190ef7d63ff4f19/jupyter_builder-1.1.1.tar.gz", hash = "sha256:1a13977912b08deda77fce2c803940131c27cf77a27ed64b9ffca25aa0ed7e6c", size = 971667, upload-time = "2026-07-17T13:14:47.761Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/cc/f6a12de1c890ea5dd2816c5c76d5ac6d3ed52db3c37f78691328207d13b9/jupyter_builder-1.1.1-py3-none-any.whl", hash = "sha256:f9c14bc55c0488a073f62af12d468936fcf9ecb7e9dd802f6f9c33de46ad70db", size = 913264, upload-time = "2026-07-17T13:14:45.857Z" }, +] + +[[package]] +name = "jupyter-client" +version = "8.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jupyter-core" }, + { name = "python-dateutil" }, + { name = "pyzmq" }, + { name = "tornado" }, + { name = "traitlets" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/dc/5512503b088997c2250b8bf18258fba9d9ce5ead641183700960d3c9d342/jupyter_client-8.9.1.tar.gz", hash = "sha256:a58f730dd9e728ba16ba1d62ebccf7ffe1ebbdbce4e95cfae941b7321ae1f4fa", size = 359256, upload-time = "2026-06-09T13:15:01.033Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/6f/56d39bf385c5c27988aebaf0c18a2a17e960575740100973511018bd904e/jupyter_client-8.9.1-py3-none-any.whl", hash = "sha256:0b7a295bc46e8751e9adae84781f726c851c1d911bd793edc4a3bde942e3da81", size = 109828, upload-time = "2026-06-09T13:14:58.835Z" }, +] + +[[package]] +name = "jupyter-core" +version = "5.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "platformdirs" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/02/49/9d1284d0dc65e2c757b74c6687b6d319b02f822ad039e5c512df9194d9dd/jupyter_core-5.9.1.tar.gz", hash = "sha256:4d09aaff303b9566c3ce657f580bd089ff5c91f5f89cf7d8846c3cdf465b5508", size = 89814, upload-time = "2025-10-16T19:19:18.444Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/e7/80988e32bf6f73919a113473a604f5a8f09094de312b9d52b79c2df7612b/jupyter_core-5.9.1-py3-none-any.whl", hash = "sha256:ebf87fdc6073d142e114c72c9e29a9d7ca03fad818c5d300ce2adc1fb0743407", size = 29032, upload-time = "2025-10-16T19:19:16.783Z" }, +] + +[[package]] +name = "jupyter-events" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jsonschema", extra = ["format-nongpl"] }, + { name = "packaging" }, + { name = "python-json-logger" }, + { name = "pyyaml" }, + { name = "referencing" }, + { name = "rfc3339-validator" }, + { name = "rfc3986-validator" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/f8/475c4241b2b75af0deaae453ed003c6c851766dbc44d332d8baf245dc931/jupyter_events-0.12.1.tar.gz", hash = "sha256:faff25f77218335752f35f23c5fe6e4a392a7bd99a5939ccb9b8fbf594636cf3", size = 62854, upload-time = "2026-04-20T23:17:50.66Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/6c/6fcde0c8f616ed360ffd3587f7db9e225a7e62b583a04494d2f069cf64ea/jupyter_events-0.12.1-py3-none-any.whl", hash = "sha256:c366585253f537a627da52fa7ca7410c5b5301fe893f511e7b077c2d93ec8bcf", size = 19512, upload-time = "2026-04-20T23:17:48.927Z" }, +] + +[[package]] +name = "jupyter-lsp" +version = "2.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jupyter-server" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/36/ff/1e4a61f5170a9a1d978f3ac3872449de6c01fc71eaf89657824c878b1549/jupyter_lsp-2.3.1.tar.gz", hash = "sha256:fdf8a4aa7d85813976d6e29e95e6a2c8f752701f926f2715305249a3829805a6", size = 55677, upload-time = "2026-04-02T08:10:06.749Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/23/e8/9d61dcbd1dce8ef418f06befd4ac084b4720429c26b0b1222bc218685eff/jupyter_lsp-2.3.1-py3-none-any.whl", hash = "sha256:71b954d834e85ff3096400554f2eefaf7fe37053036f9a782b0f7c5e42dadb81", size = 77513, upload-time = "2026-04-02T08:10:01.753Z" }, +] + +[[package]] +name = "jupyter-server" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "argon2-cffi" }, + { name = "jinja2" }, + { name = "jupyter-client" }, + { name = "jupyter-core" }, + { name = "jupyter-events" }, + { name = "jupyter-server-terminals" }, + { name = "nbconvert" }, + { name = "nbformat" }, + { name = "overrides", marker = "python_full_version < '3.12'" }, + { name = "packaging" }, + { name = "prometheus-client" }, + { name = "pywinpty", marker = "os_name == 'nt'" }, + { name = "pyzmq" }, + { name = "send2trash" }, + { name = "terminado" }, + { name = "tornado" }, + { name = "traitlets" }, + { name = "websocket-client" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6b/dc/db3a582633170186f8c8b31298d7eb26ad0eb031a1f53476c258b64eed05/jupyter_server-2.20.0.tar.gz", hash = "sha256:b5778ba337d8015a3dc2b80803ecdd5ac18d3797fddf61a50ea5fb472b4ebe14", size = 756523, upload-time = "2026-06-17T12:09:09.435Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/71/8c002223e873a870f5c41dc69b0a7c922301123e4a31d5d01ecb700aef77/jupyter_server-2.20.0-py3-none-any.whl", hash = "sha256:c3b67c93c471e947c18b5026f04f21614218adb706df8f48227d3ee8e0a7cdcc", size = 393143, upload-time = "2026-06-17T12:09:07.234Z" }, +] + +[[package]] +name = "jupyter-server-terminals" +version = "0.5.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pywinpty", marker = "os_name == 'nt'" }, + { name = "terminado" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f4/a7/bcd0a9b0cbba88986fe944aaaf91bfda603e5a50bda8ed15123f381a3b2f/jupyter_server_terminals-0.5.4.tar.gz", hash = "sha256:bbda128ed41d0be9020349f9f1f2a4ab9952a73ed5f5ac9f1419794761fb87f5", size = 31770, upload-time = "2026-01-14T16:53:20.213Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/2d/6674563f71c6320841fc300911a55143925112a72a883e2ca71fba4c618d/jupyter_server_terminals-0.5.4-py3-none-any.whl", hash = "sha256:55be353fc74a80bc7f3b20e6be50a55a61cd525626f578dcb66a5708e2007d14", size = 13704, upload-time = "2026-01-14T16:53:18.738Z" }, +] + +[[package]] +name = "jupyterlab" +version = "4.6.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "async-lru" }, + { name = "httpx" }, + { name = "ipykernel" }, + { name = "jinja2" }, + { name = "jupyter-builder" }, + { name = "jupyter-core" }, + { name = "jupyter-lsp" }, + { name = "jupyter-server" }, + { name = "jupyterlab-server" }, + { name = "notebook-shim" }, + { name = "packaging" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "tornado" }, + { name = "traitlets" }, + { name = "typing-extensions", marker = "python_full_version < '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7a/7f/51c0c856ab286bdaf5709cf61ed13584ed9d4bee906479707da45b11b353/jupyterlab-4.6.2.tar.gz", hash = "sha256:e18ce8b34f3de350e93cd5b2c4f3ae884cbe266eb76bf5d6825a4ed34c13bcff", size = 28183650, upload-time = "2026-07-21T12:05:24.051Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/1f/e39b248c76bb3736bc05a9491a6aa1315414c32dea12f548ecc1b24c758e/jupyterlab-4.6.2-py3-none-any.whl", hash = "sha256:5964447036629adfcd3fc0969effc1da6f47d2cbd0a60b2c2eea7c31be0ec6a8", size = 17166703, upload-time = "2026-07-21T12:05:19.818Z" }, +] + +[[package]] +name = "jupyterlab-pygments" +version = "0.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/90/51/9187be60d989df97f5f0aba133fa54e7300f17616e065d1ada7d7646b6d6/jupyterlab_pygments-0.3.0.tar.gz", hash = "sha256:721aca4d9029252b11cfa9d185e5b5af4d54772bb8072f9b7036f4170054d35d", size = 512900, upload-time = "2023-11-23T09:26:37.44Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b1/dd/ead9d8ea85bf202d90cc513b533f9c363121c7792674f78e0d8a854b63b4/jupyterlab_pygments-0.3.0-py3-none-any.whl", hash = "sha256:841a89020971da1d8693f1a99997aefc5dc424bb1b251fd6322462a1b8842780", size = 15884, upload-time = "2023-11-23T09:26:34.325Z" }, +] + +[[package]] +name = "jupyterlab-server" +version = "2.28.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "babel" }, + { name = "jinja2" }, + { name = "json5" }, + { name = "jsonschema" }, + { name = "jupyter-server" }, + { name = "packaging" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d6/2c/90153f189e421e93c4bb4f9e3f59802a1f01abd2ac5cf40b152d7f735232/jupyterlab_server-2.28.0.tar.gz", hash = "sha256:35baa81898b15f93573e2deca50d11ac0ae407ebb688299d3a5213265033712c", size = 76996, upload-time = "2025-10-22T13:59:18.37Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/07/a000fe835f76b7e1143242ab1122e6362ef1c03f23f83a045c38859c2ae0/jupyterlab_server-2.28.0-py3-none-any.whl", hash = "sha256:e4355b148fdcf34d312bbbc80f22467d6d20460e8b8736bf235577dd18506968", size = 59830, upload-time = "2025-10-22T13:59:16.767Z" }, +] + +[[package]] +name = "jupyterlab-widgets" +version = "3.0.16" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/26/2d/ef58fed122b268c69c0aa099da20bc67657cdfb2e222688d5731bd5b971d/jupyterlab_widgets-3.0.16.tar.gz", hash = "sha256:423da05071d55cf27a9e602216d35a3a65a3e41cdf9c5d3b643b814ce38c19e0", size = 897423, upload-time = "2025-11-01T21:11:29.724Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/b5/36c712098e6191d1b4e349304ef73a8d06aed77e56ceaac8c0a306c7bda1/jupyterlab_widgets-3.0.16-py3-none-any.whl", hash = "sha256:45fa36d9c6422cf2559198e4db481aa243c7a32d9926b500781c830c80f7ecf8", size = 914926, upload-time = "2025-11-01T21:11:28.008Z" }, +] + +[[package]] +name = "lark" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/da/34/28fff3ab31ccff1fd4f6c7c7b0ceb2b6968d8ea4950663eadcb5720591a0/lark-1.3.1.tar.gz", hash = "sha256:b426a7a6d6d53189d318f2b6236ab5d6429eaf09259f1ca33eb716eed10d2905", size = 382732, upload-time = "2025-10-27T18:25:56.653Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3d/14ce75ef66813643812f3093ab17e46d3a206942ce7376d31ec2d36229e7/lark-1.3.1-py3-none-any.whl", hash = "sha256:c629b661023a014c37da873b4ff58a817398d12635d3bbb2c5a03be7fe5d1e12", size = 113151, upload-time = "2025-10-27T18:25:54.882Z" }, +] + +[[package]] +name = "librt" +version = "0.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/2f/3908645ddddab7120b46295e541ead308109fa48dbec7d67d7a778870d60/librt-0.13.0.tar.gz", hash = "sha256:1d2a610c14ac0d0750ee0a3ab8548e83155258387891caaca04def4bf7289781", size = 211402, upload-time = "2026-07-08T12:26:29.834Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/89/2f/ec5241c38e7fa0fe6c26bfc450e78b9489a6c3c08b394b85d2c10e506975/librt-0.13.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:34e47058fcc69a313293d6dee94216a4f30c929ae6f2476e58c5ba635aa639d5", size = 148654, upload-time = "2026-07-08T12:24:30.622Z" }, + { url = "https://files.pythonhosted.org/packages/a5/1a/d651e18d3ee7aa2879322368c4f278bb7ecaa6b90caadfdec4ebfa8389f3/librt-0.13.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dbdd5b6509d0c2a8fe72cf494c299a61dbd58142a90a4190664ae159e4a7b547", size = 153537, upload-time = "2026-07-08T12:24:31.773Z" }, + { url = "https://files.pythonhosted.org/packages/45/18/10bff2122577246009d9619b6569596daf69b7648812f997ca9ca0426f60/librt-0.13.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e56ea4ee4df77585a6b5c138f6538680886024fa559f5b55bd14b12e98e67b2", size = 494336, upload-time = "2026-07-08T12:24:33.079Z" }, + { url = "https://files.pythonhosted.org/packages/67/69/87dfee871b852970f137fdeae8e2ca356c5ab38e6f21d2a3299535fc3159/librt-0.13.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:f1f9cc4d09a46d9cb3c2063ae100629d3f52a6517c3c08c2f4c9828261883929", size = 485393, upload-time = "2026-07-08T12:24:34.324Z" }, + { url = "https://files.pythonhosted.org/packages/e9/d5/625447a8c0441ff5f15f4ac5e1d323fb9d4d256ebfde7a3c8e003f646057/librt-0.13.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f125f5d46b20f89dc5587a55cc416b4ba2a5b2ffda36d048ee120e17598a653a", size = 515382, upload-time = "2026-07-08T12:24:35.575Z" }, + { url = "https://files.pythonhosted.org/packages/8d/d8/1c8c49ea04235960426444deece9092a6b3a9587a850a81bae2335317411/librt-0.13.0-cp310-cp310-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2608d3b39f9e0b4a66a130d9150c615cba40a5090d25eeeaa225e0e46de8c0ac", size = 509483, upload-time = "2026-07-08T12:24:36.923Z" }, + { url = "https://files.pythonhosted.org/packages/6f/65/f1760fc48050e215201a03506c32b7270159088d01f64557b53e39e74a45/librt-0.13.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:9fd35e95ab5e45c3901d37110263c7db85a961110f5460588fe37f8c131f88a7", size = 532503, upload-time = "2026-07-08T12:24:38.203Z" }, + { url = "https://files.pythonhosted.org/packages/18/1b/793e281dcf494879eff99f642b63ebc9c7c58694a1c2d1e93362a22c7041/librt-0.13.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:5f31b0aa13c9b04370d4da6be1ab7779776b3a075cceb6747a39a4be85fe1e40", size = 537027, upload-time = "2026-07-08T12:24:39.34Z" }, + { url = "https://files.pythonhosted.org/packages/69/45/0801bbb40c9eea795d3dd3ce91c4c5f3fe7d42d23ec4be3e8cb283bcc754/librt-0.13.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:0b795f5fc70fbbb787ceaf79bb3a0d627bcc33c53de51741755263ec406b775a", size = 517100, upload-time = "2026-07-08T12:24:40.907Z" }, + { url = "https://files.pythonhosted.org/packages/a1/6c/eb5f514f8e29d4924bc0ff4601dd7b4175557e182e7c0721e84cffa39b8a/librt-0.13.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:36b306a623aaad96fe4b378692b54f9c0789fccd833b9851753d5fbf6138cfde", size = 558653, upload-time = "2026-07-08T12:24:42.359Z" }, + { url = "https://files.pythonhosted.org/packages/b4/bf/f140100d1b59fe87ff40b5ecbb4e27924335b189a784e230ee465452f6c2/librt-0.13.0-cp310-cp310-win32.whl", hash = "sha256:a3762e75fcac8c9e4dacaaf438bffd9003e2ca2c531b756f3c0035deefa674c8", size = 104402, upload-time = "2026-07-08T12:24:43.668Z" }, + { url = "https://files.pythonhosted.org/packages/22/7c/57e40fef7cfb61869341cb28bdcefe8a950bebcbecca74a397bae14dce4a/librt-0.13.0-cp310-cp310-win_amd64.whl", hash = "sha256:d63bae12a8aeb51380be3438e4dc4bd27354d0f8e19166b2f44e3e94d6f552dc", size = 125002, upload-time = "2026-07-08T12:24:44.793Z" }, + { url = "https://files.pythonhosted.org/packages/89/25/a6498964cfeec270c468cffdc118f69c29b412593610d55fa1327ca51ff4/librt-0.13.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1b5a7bbff495baedbd9b916c367d66854008f8f3b575908ded477c499dc60082", size = 148029, upload-time = "2026-07-08T12:24:45.961Z" }, + { url = "https://files.pythonhosted.org/packages/78/59/dc86d1bffd8e0c2818bace29d9f7783cfbb8e0673bf3673b5bbd5bbe0420/librt-0.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:34bc7938b9fdf14fe32a406c19c71faf894c5cee7e7474bd0be2f17200b82d14", size = 153036, upload-time = "2026-07-08T12:24:47.257Z" }, + { url = "https://files.pythonhosted.org/packages/29/3f/b923826660f02f286186cd9303d52bb05ced0a13708edc104dc8480920e3/librt-0.13.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f40e56b61b41be5f7dec938cfeffd660668cf4b5e72c78e7bd671d66b7bc2c79", size = 493062, upload-time = "2026-07-08T12:24:48.483Z" }, + { url = "https://files.pythonhosted.org/packages/88/87/6c0980a9c9b1302cb68d108906697b89eceb55889bb1dcf77c109aa56ca5/librt-0.13.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:9c5d02b89de5acd0379a51ec44a89476fb03df6145442e1c8ecd6bee2f91b176", size = 485510, upload-time = "2026-07-08T12:24:49.727Z" }, + { url = "https://files.pythonhosted.org/packages/32/81/795ae3b9df5dd94079fb807e38191855e023e8c6249014ae6bc3f0d9a490/librt-0.13.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7db9a3ff32ef5f7d1703d93831a3316cdf0b537de6a1cc03cc8fdd09b9194e89", size = 515909, upload-time = "2026-07-08T12:24:51.135Z" }, + { url = "https://files.pythonhosted.org/packages/20/e5/182de15abce8907108a6fdb41487de65beb5099b74dc5841b19b099168db/librt-0.13.0-cp311-cp311-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3dbb2a31882456cadc7053378e81ad7ed7693db4ac9f98ab5f81ef034aa8ec9f", size = 508620, upload-time = "2026-07-08T12:24:52.358Z" }, + { url = "https://files.pythonhosted.org/packages/32/03/33978d32db76e1f66377e8f78e42a2ca3c162143331677d1f50bbad36cfb/librt-0.13.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c6014e3c80f9c1fe268ef8b0e0ef113bac672cc032f2f93866e7ddad4f3e663d", size = 530363, upload-time = "2026-07-08T12:24:53.503Z" }, + { url = "https://files.pythonhosted.org/packages/e6/f5/b291fbd2d00f7d8287bcbf67b5aa0c6afed4bc26cef23e079629c47a2c04/librt-0.13.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:091b60a4d2174fc1ec5c34cdc0b72efb6224753d76b7da61ebeab7a191aec8bd", size = 534209, upload-time = "2026-07-08T12:24:55.138Z" }, + { url = "https://files.pythonhosted.org/packages/3e/03/6f41f17939d191bc21609f220da8509316bc62797f078545fe83be522e78/librt-0.13.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:66cb1138f384a191a6d75f986064841fcfdc0cea98f7bd9c9ab9b38049917588", size = 514254, upload-time = "2026-07-08T12:24:56.276Z" }, + { url = "https://files.pythonhosted.org/packages/af/c2/2e4befa5410a7443019c14abccc94ff619797171f6b72013635fb87f31d7/librt-0.13.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:17221a7569f8f292aa0014226e48aa25b8c2b08da18088cd230953d0ea0f9cd1", size = 557611, upload-time = "2026-07-08T12:24:57.561Z" }, + { url = "https://files.pythonhosted.org/packages/ab/54/8b69f81448417adbc040a2185f4e2eece1e1994b7dcfaeed4662b30f98a5/librt-0.13.0-cp311-cp311-win32.whl", hash = "sha256:fc67741da44c6eaa90e01eafb586bbba9b51eb5b6ed381ee6f5ae72eb3316d21", size = 104906, upload-time = "2026-07-08T12:24:58.806Z" }, + { url = "https://files.pythonhosted.org/packages/76/5a/f4aaf37b50f2fde12c8c663b83fdd499cdc24f957f19543d7414bfcc9e25/librt-0.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:cc99dfb62b23c9207c33d0be8a2e2af7a42e21e6ea388b380a0c948c7b88953b", size = 125852, upload-time = "2026-07-08T12:25:00.065Z" }, + { url = "https://files.pythonhosted.org/packages/f2/99/bf1820e6feeabc2f218c24450ec0c995d6a91e8ba0fd3caf042c9e8adb2a/librt-0.13.0-cp311-cp311-win_arm64.whl", hash = "sha256:40ccd13c252d3fe473ffc8a57be7565abc8b64cf1b108344c859d5164f7f3e0c", size = 111832, upload-time = "2026-07-08T12:25:01.148Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f4/b2933ddae222dac338476abb872641169a5cfed2c2bb5444a5b07b32b0c3/librt-0.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:30536798f4504c0fad0885b1d371b0539abb081e4570c9d7c641cb51141b49f0", size = 150990, upload-time = "2026-07-08T12:25:02.42Z" }, + { url = "https://files.pythonhosted.org/packages/90/ef/db98f744ca50e6efc9c95c70ee49b77aefac31f6a3fc7c83754a42d6a74f/librt-0.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:93d24ebb82aa4420b1409c389e7857bc35bd0b668007ac8172427d5c73cc8cc5", size = 155238, upload-time = "2026-07-08T12:25:03.681Z" }, + { url = "https://files.pythonhosted.org/packages/03/e7/a197e7bc72baf2c61ce7fdc6906a5054dc05bd8da0819aa894e4857bf87e/librt-0.13.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cb8a1adce42d8b75485a5d56a9623a50bcab995b6079f1dac59fc44034dd93d9", size = 503073, upload-time = "2026-07-08T12:25:05.049Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e7/7887712e27da7c1ab80fcabb1de6eb24243964f6557cae530d4b70706dbd/librt-0.13.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:0763ca2ab66058174f9dee426dc64f5e0a89c24a7df8d3fe3f1836c04e25de4b", size = 496528, upload-time = "2026-07-08T12:25:06.26Z" }, + { url = "https://files.pythonhosted.org/packages/94/f0/f2283385bb6b950b26a1410f4ce51ec27231e0b3a4b925c46366d218b198/librt-0.13.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b222493da6e7b6199db9bd79502436cf5a27da3c1f7fa83c7e285444fc93fd03", size = 531786, upload-time = "2026-07-08T12:25:07.658Z" }, + { url = "https://files.pythonhosted.org/packages/36/11/69ac3b54766ffba5fd7e5acebfb048d66dbe1f9f2d14516c2b3edc59cf87/librt-0.13.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fadc63331f4388c3dc90090448f682a7e9feafc11481391c1e94f2f907a3976e", size = 524393, upload-time = "2026-07-08T12:25:09.121Z" }, + { url = "https://files.pythonhosted.org/packages/61/5f/d72f95fd444a926a3c14b4e24979474116988dd57a45be242077c45d3c22/librt-0.13.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:70d9c62a4cffd9f23396cd5ef93fc5d11b31596b9b7d6306074abe3d5fcf09bd", size = 543026, upload-time = "2026-07-08T12:25:10.459Z" }, + { url = "https://files.pythonhosted.org/packages/c4/08/dcd9993ad192737a004ba263d549f8ea605b326b952e7d6205c7d4170b76/librt-0.13.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:66c0e7e6b02a155576df2c77ec933a70b72da726e248c494abf690923e624348", size = 546829, upload-time = "2026-07-08T12:25:11.716Z" }, + { url = "https://files.pythonhosted.org/packages/96/d5/6d9bb2f54e4109a956b7128836529653eb9d740f784bc47ed10a02c1000e/librt-0.13.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:ac04bcd3328eb91d99dfedf6a60d9c1f15d3434e6f6daf922f0420f7d90b85c7", size = 535700, upload-time = "2026-07-08T12:25:13.144Z" }, + { url = "https://files.pythonhosted.org/packages/8c/f2/10946922503858a359492fa27f13e86228bde702116a740ac7b3cd185f24/librt-0.13.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:db327e7271e653c32040b85ae6188059c924b57d7e1e29f935523fa017cd4e82", size = 573566, upload-time = "2026-07-08T12:25:14.336Z" }, + { url = "https://files.pythonhosted.org/packages/48/a8/94f00e3c99479a18088af3685ea016c42f3c7d5d1964d8dbb40c08d7f1aa/librt-0.13.0-cp312-cp312-win32.whl", hash = "sha256:860bd1d8ba48456ce08feaf8d343a8aaeb2fa086f2bcaa2a923fa3f7a3ff9aa3", size = 106099, upload-time = "2026-07-08T12:25:16.159Z" }, + { url = "https://files.pythonhosted.org/packages/c9/7b/2da9c74c1ed25a89cc4e1c8e007ea2eb4a0f1fafa3e70d757fe3242c5c5c/librt-0.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:e54a315caf843c8d77e388cadc56ea9ded569935ee2d2347d7ea94992e5aa6fa", size = 126934, upload-time = "2026-07-08T12:25:17.275Z" }, + { url = "https://files.pythonhosted.org/packages/d0/65/aead61bbf3b5358593f9d4779d2a0e88eaf6ec191a6342dde36dd1df6371/librt-0.13.0-cp312-cp312-win_arm64.whl", hash = "sha256:c718e99a0992127af84385378460db624103b559ab260435abcfe77a4e4ed1c1", size = 112236, upload-time = "2026-07-08T12:25:18.425Z" }, + { url = "https://files.pythonhosted.org/packages/67/3b/18e7b63255297a2bdc9c25c8d6d4ca8eca9f63aceb1252c0f7427ac7099e/librt-0.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a468951af16155824e88bdd8326ebe5bdb371f3ec0ac04642994b98201d914f3", size = 151027, upload-time = "2026-07-08T12:25:19.638Z" }, + { url = "https://files.pythonhosted.org/packages/4d/68/e2248452c00d1a03b45fee1752cdc8f790a476efd2402b75181da88a9e61/librt-0.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ae01d8512cc17079e53425635327dbf3f7ff57a42c00dec348bf79791c56444c", size = 155152, upload-time = "2026-07-08T12:25:20.851Z" }, + { url = "https://files.pythonhosted.org/packages/0e/16/52b1c99bf19057a062aac39c900cbb81499f6f75d6c537c14463d247ba78/librt-0.13.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:32c26893cd085c1efe83219e78d866da23fb20a066101b8f68210004361d224c", size = 502499, upload-time = "2026-07-08T12:25:22.055Z" }, + { url = "https://files.pythonhosted.org/packages/9f/54/b811151805c795f55e0dedee6ec687b75f9982a8105d240ea3910737a77b/librt-0.13.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5929da1981a46bcf4b28b1b9499905f0ff58e2419da402a048234e9783acbc4b", size = 496108, upload-time = "2026-07-08T12:25:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/8f/f8/094d6b2bd93f3fdaa54db54cc788c4a365333bddad65ab02e04da0b1d004/librt-0.13.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:94b85d664d777bab6c0d709416cb42938251fda9e221b79e3a2215d85df5f4f9", size = 531576, upload-time = "2026-07-08T12:25:24.648Z" }, + { url = "https://files.pythonhosted.org/packages/2e/40/541733d5755824f968f7ec39d78ffbd75d145964157ae5e69a09ec6d7326/librt-0.13.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:531b2df3e9fe96b1fcf73a6d165921e4656be5f58d631d384ebce344298368db", size = 524390, upload-time = "2026-07-08T12:25:25.898Z" }, + { url = "https://files.pythonhosted.org/packages/c6/b5/255673cfdbf5ba663339d36cd863c897289ab4337577e19f9405ce059f36/librt-0.13.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:109b84a9edf69ad89dc1f66358659e14a031baca95e3e5b0060bd903ede8efd6", size = 543053, upload-time = "2026-07-08T12:25:27.436Z" }, + { url = "https://files.pythonhosted.org/packages/9e/11/ab5005e9c9850710f21e354201bf090646349d3fabf5f951eaf70235729e/librt-0.13.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1304368a3e7ffc3e9db986796cc5326fdb5943a3567ecc137cff318e4240c0e7", size = 546387, upload-time = "2026-07-08T12:25:28.65Z" }, + { url = "https://files.pythonhosted.org/packages/a2/04/a5d7ce1d1df1afd15ca283dcdf7530ac073e12d69ae8c40879dda96f7868/librt-0.13.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e4f9b472e7d308d94b62c801982065661158c6ed02790d6c7ddb4337cea0f9c1", size = 535970, upload-time = "2026-07-08T12:25:30.171Z" }, + { url = "https://files.pythonhosted.org/packages/5a/76/927e267a6daa290174ac281b23c9804c8829b042ade9c6f24a065f540958/librt-0.13.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9f836c37478f167a81200d8c8b2c920a22224564bed2c23d7aeec760965c367a", size = 573582, upload-time = "2026-07-08T12:25:31.507Z" }, + { url = "https://files.pythonhosted.org/packages/10/24/b6c5213efe39c19f9e13605644d0cf063b4ddaa33ac2e45b088e23a70e2e/librt-0.13.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:4000d961ff9598ac6ea603c6c836a5ed49bc205ade5fc378b998dfe1e2c36628", size = 82189, upload-time = "2026-07-08T12:25:32.675Z" }, + { url = "https://files.pythonhosted.org/packages/4c/00/d29736be177a906ac0b84a5b04b4fbfa22c776dc2f366de4172b0f968c08/librt-0.13.0-cp313-cp313-win32.whl", hash = "sha256:79e44cff71750d299d61a678e49995b0d5935a9cda238c2574daeca3ba536927", size = 106193, upload-time = "2026-07-08T12:25:33.692Z" }, + { url = "https://files.pythonhosted.org/packages/c8/ac/aff6fb45393cb8912f39dfb156ef6b2d1cadb207ff465fc8f66141054be8/librt-0.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:54dab44a847d5ad1acd05c8a83fe518ae685516ecf4d3f7cc6e3df2a66767650", size = 126962, upload-time = "2026-07-08T12:25:34.769Z" }, + { url = "https://files.pythonhosted.org/packages/d9/3a/d68cb2b334d53fd30fac81d3a489ce4ba0d9506f4df43fcf676b68352b19/librt-0.13.0-cp313-cp313-win_arm64.whl", hash = "sha256:d4cb6fbfdf874340ab5e51450753c0f817b6958a3621125ee695bbc3de866566", size = 112127, upload-time = "2026-07-08T12:25:35.981Z" }, + { url = "https://files.pythonhosted.org/packages/7b/66/f49ae0d592bd45b6941e9a8bafcb6a87cddcd501ee7874707e767f01b585/librt-0.13.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:25218d94b1d2cbc0ba1d8a3f9dc9af578d9646e5ed16443a70cde1dfdcce6d71", size = 149818, upload-time = "2026-07-08T12:25:37.203Z" }, + { url = "https://files.pythonhosted.org/packages/3d/50/51c76d74014d04fb95b6506d286808984b78a2f7a41039094e6b2194ac48/librt-0.13.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f26629539d4893c2957a16c41bb058e1e135c1f150f6a2e25ed047f64cf3f5c6", size = 154071, upload-time = "2026-07-08T12:25:39.399Z" }, + { url = "https://files.pythonhosted.org/packages/b8/fe/f19b0f5f82d5a1f2da736586bc840abd00ce07d6388136ae80b7333883fc/librt-0.13.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a4517d47b2b8af26975a406fba7d314de9696d864252e0257c6ea90238cfe27f", size = 494168, upload-time = "2026-07-08T12:25:40.641Z" }, + { url = "https://files.pythonhosted.org/packages/94/bc/b8550c75775127fd31a5f20e8775997f7b527ad661fc8ddccd7497c064f7/librt-0.13.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:f19e181de5b3a1148bb3420b8c4b0b0ea0fce6950099724ad151d6cea5acc180", size = 491054, upload-time = "2026-07-08T12:25:41.905Z" }, + { url = "https://files.pythonhosted.org/packages/30/14/4d0204867623df3f33f86efd3d3692ba5e01321443f4d6eab35a22697618/librt-0.13.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22034924f5b42d5a56371cf271771bfeaabf235a7a8b6264bef2d20013f786c6", size = 523006, upload-time = "2026-07-08T12:25:43.327Z" }, + { url = "https://files.pythonhosted.org/packages/19/0a/c45fc9a260934696bace1ac5df1e148ac92bd71767aee3bf7cd7a4534f4c/librt-0.13.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c7897db4e95e22468bdda33d8e012ceacd0182abf001e6389d763f0def6286b9", size = 515058, upload-time = "2026-07-08T12:25:44.541Z" }, + { url = "https://files.pythonhosted.org/packages/13/0a/50c5ce45b326854ef8fa6ae4c36cf5142e5c55315eaf9e51d0ae73ac4da3/librt-0.13.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1ce61b3746545029d4f5c17d6bd74b676254ad98433086c846ffb5e8fa73f007", size = 534025, upload-time = "2026-07-08T12:25:45.825Z" }, + { url = "https://files.pythonhosted.org/packages/89/2d/08c413c8f93fc13b8103624fce38e5caa86cd08cbbc8465870ab287af54b/librt-0.13.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:46c330e82565962c761dbce7941be2cff7db674ee807455a8d0cadc5f9b759b0", size = 540557, upload-time = "2026-07-08T12:25:47.059Z" }, + { url = "https://files.pythonhosted.org/packages/b3/c1/93af71fb4a364952210051811dd4e40174e79656b050c89cacac18af3330/librt-0.13.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:375f5af8f99cbaa99dd293af986e3d57caabc9ba81a5d3f021603764854197a1", size = 523201, upload-time = "2026-07-08T12:25:48.392Z" }, + { url = "https://files.pythonhosted.org/packages/c1/6e/9766f07b676a4889d9f8bc2864e9ba5fff165653143ef4dda7df6aa34d16/librt-0.13.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9320d34c3376ae204b2cd176e8d4883a013934e0aef822f1aed9c536490c275d", size = 565740, upload-time = "2026-07-08T12:25:49.678Z" }, + { url = "https://files.pythonhosted.org/packages/a2/1e/664e3472ce2b6e10e9b83f29d4a36eb982ff6b5a169ae7567bba3a4c4ff5/librt-0.13.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:9af313c66157a69dc69ea0059a66961692250e0dc95af9c385a48ffb770a0d16", size = 81611, upload-time = "2026-07-08T12:25:50.857Z" }, + { url = "https://files.pythonhosted.org/packages/2f/d4/8582a4d65e2234673685e07309d02c230b28a85724eb0acbf13f019b7f6e/librt-0.13.0-cp314-cp314-win32.whl", hash = "sha256:f2a7253458e34f33543551394ae4fe104b497ec2a65ac266074de64c1df82e37", size = 100106, upload-time = "2026-07-08T12:25:52.03Z" }, + { url = "https://files.pythonhosted.org/packages/63/ce/0cb99efe6086b46cd985dc26672166fae312a239690e75871f7fafbd3fc5/librt-0.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:a3dfe4edf10e8ed7e55b026a8bfc2c2a8704218b659cd4bffdf604fab966dc39", size = 121209, upload-time = "2026-07-08T12:25:53.166Z" }, + { url = "https://files.pythonhosted.org/packages/26/85/4f3ccb083a3c9b0d42e223acdb3c3f507953324a59cdcab4826e8e2e3b89/librt-0.13.0-cp314-cp314-win_arm64.whl", hash = "sha256:68a5faee4bba381cb93b5961f684a514cf0053cb92308ff9c792c2fea0b174c6", size = 106404, upload-time = "2026-07-08T12:25:54.253Z" }, + { url = "https://files.pythonhosted.org/packages/b2/77/333191499538c8e8189de7a4cba8e6f49ee949fd6d6e6324b21fd1522466/librt-0.13.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:a38fb81d8376dfa2f8963b265fec07637802b0d01e2a127c19c66cb070fb24f5", size = 159231, upload-time = "2026-07-08T12:25:55.432Z" }, + { url = "https://files.pythonhosted.org/packages/7a/9e/2aa83758f22c278b837a1d8025898434ce2b8bff36678d5330ecaef56dff/librt-0.13.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d4c8d9bd5abce34b2e75edb3bf37ab0f34e49b1f915a40ae8468eb7c85bc5b46", size = 161300, upload-time = "2026-07-08T12:25:56.585Z" }, + { url = "https://files.pythonhosted.org/packages/bb/c0/86791e936553ca763d6b3c2fb4d31d596cd00e14fa631c283a40ba01559a/librt-0.13.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:387e2f1d27e89bffe0d3f520f0da0662c973fd607ca16c1808f8a5085419485e", size = 582056, upload-time = "2026-07-08T12:25:58.144Z" }, + { url = "https://files.pythonhosted.org/packages/a8/d3/a9ec15984a185e000c4d2a16ba28bd623124ad4c38a10974c7ff78e3a893/librt-0.13.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:4f6db193d2e5e0ed60359b9a5a682cd67205d0d3b1e459a867dd4b5c4e7eaa7a", size = 562758, upload-time = "2026-07-08T12:25:59.544Z" }, + { url = "https://files.pythonhosted.org/packages/3c/af/dbe36b78b19c06a55097f99305e4ea9458e2273e6ae16a3cbecaad7ee978/librt-0.13.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0d38604854e8d22faadf683ec6c02bb0f886e2ba56ef981a1c36ee275f21ea22", size = 602095, upload-time = "2026-07-08T12:26:00.991Z" }, + { url = "https://files.pythonhosted.org/packages/2a/a8/2966891b4dd2830f5203fbee92ac2c4947653a2390ba73dfa44244fad025/librt-0.13.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:371f7ce73026815dafd51c50ce38416e91428b28c4b2ec97cd39271164b0045c", size = 593452, upload-time = "2026-07-08T12:26:02.352Z" }, + { url = "https://files.pythonhosted.org/packages/61/f5/4df8bfc8405ecf8c0d525b4d69636f694bdd8620b313ec8b76e54a5926cc/librt-0.13.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3aaedf52171bee90860704c560bc798fe83b76247df47568e0197e9b13c735a0", size = 623729, upload-time = "2026-07-08T12:26:04.294Z" }, + { url = "https://files.pythonhosted.org/packages/d6/13/9ac202dffc8db06f75d06c08c2f9f6ff054be67d21272dcc078fa1cc0c57/librt-0.13.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:96bad8725a4f196a798366c25ce075d1f7543a4ec045ffc13e6a7ec095cdab04", size = 617077, upload-time = "2026-07-08T12:26:05.845Z" }, + { url = "https://files.pythonhosted.org/packages/6e/f0/ebe38610716aee5cb28efd95089bb90192096179802779381e1c5dcf239c/librt-0.13.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:6bf6a559ffe4a93bbea6cf31ddf01a7fd9ba342ef51f27beb178e318b74acd61", size = 599561, upload-time = "2026-07-08T12:26:07.21Z" }, + { url = "https://files.pythonhosted.org/packages/4f/5c/c2e72e236fff7abc716d5b1753b8b8cd3ea85ac46fe17d2e7c51d4e1c723/librt-0.13.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:301067672387902c55f94b51d5022304b36c966ea9fe1f21caab99a9bef487c9", size = 645511, upload-time = "2026-07-08T12:26:08.562Z" }, + { url = "https://files.pythonhosted.org/packages/0c/99/6203ce619dee940d6bfbe099ec3fe4be00a68e9d60f70abf906cf124fe66/librt-0.13.0-cp314-cp314t-win32.whl", hash = "sha256:5fdcf34f86de8fb66d7dc7589f96ba91c4aa46671200d400e6fd6f109a483f18", size = 104357, upload-time = "2026-07-08T12:26:09.828Z" }, + { url = "https://files.pythonhosted.org/packages/52/dd/843b6314087c41657c7036d7914d8f294bdf9b580aa8513ea0588c8e9a3d/librt-0.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:260c33e92263fa629b4f6d3c51967a1c2158fe6c33237aaa3ebeac586b085259", size = 126998, upload-time = "2026-07-08T12:26:10.975Z" }, + { url = "https://files.pythonhosted.org/packages/5f/5d/3dcec2884ba1b0806d1408612555c38dd5d68e90156b59f75f6e36435c3a/librt-0.13.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2f281549a4c52ac7bb97997f14353f8bd0e53a34ca0dad1c905cfd0b4a58ae99", size = 110771, upload-time = "2026-07-08T12:26:12.303Z" }, +] + +[[package]] +name = "llvmlite" +version = "0.48.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/a0/acc8ffcd5bdc63df0097e22c719bfcd61b604358343089313a8aebbb24ab/llvmlite-0.48.0.tar.gz", hash = "sha256:543b19f9ef8f3c7c60d1468191e4ee1b1537bf9f8a3d56f64c0ddd98de92edd2", size = 184016, upload-time = "2026-07-02T20:20:05.308Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/4e/32543c42568fb321b3bdfcf9106e4116ab8f5a7bbcfd9ecf5569b0c07d83/llvmlite-0.48.0-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:614aad57df707e3172efd5165f2aa7da6a0c6897e40dce590bf756396815ba76", size = 40480650, upload-time = "2026-07-01T18:41:01.945Z" }, + { url = "https://files.pythonhosted.org/packages/a9/0d/6aa48abd423067139a129d1434b77bbcc56080db51d12a88510bb491ca3d/llvmlite-0.48.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:13532f248960ba888ad5ab8150494e2f3a3d20e5f59f264e63741ea5b0ba844c", size = 59890118, upload-time = "2026-07-01T18:41:10.608Z" }, + { url = "https://files.pythonhosted.org/packages/5a/c7/aa917444d871a79608af49149de1b28764e87d2ab41f933c5cd02431d03d/llvmlite-0.48.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ee0c77685a18f5fca994ae21d0763007fca5c5c64b41de37accc78b69079176", size = 58343459, upload-time = "2026-07-01T18:41:06.21Z" }, + { url = "https://files.pythonhosted.org/packages/c5/2b/ceee1cdc263617109d514ac4d1b31f10a282662740ff7d5777baae25b3b5/llvmlite-0.48.0-cp310-cp310-win_amd64.whl", hash = "sha256:02853fe4214acb3780fc920c3fee10564b61d58a35e1b78afcc8a546c2deaba3", size = 41864734, upload-time = "2026-07-01T18:41:14.746Z" }, + { url = "https://files.pythonhosted.org/packages/9a/55/595981f14fbae9ba966feb12af552b1fe69889e44e64ac883a731ed335e0/llvmlite-0.48.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:56a7e24607d3f02d7b1bae8d29c7e1e423d53143d68b072999777f19678fe77b", size = 40480651, upload-time = "2026-07-01T18:41:18.438Z" }, + { url = "https://files.pythonhosted.org/packages/26/08/0109d1b9cb3f4603f3890e30bc66c65332b79185f12a045343b2ae431f67/llvmlite-0.48.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6fa532d6bb3fd3f0803567c736401c54aecfe1a396d3ad25d2440d220e09f0e7", size = 59890118, upload-time = "2026-07-01T18:41:28.184Z" }, + { url = "https://files.pythonhosted.org/packages/02/eb/c5281be180c789cdffbf45b671884c57d7e61345ef3b0f643a4965e108e8/llvmlite-0.48.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:979a66a3f28a02565383ff463527dce78e9b856298872a361283132488e83591", size = 58343458, upload-time = "2026-07-01T18:41:23.397Z" }, + { url = "https://files.pythonhosted.org/packages/aa/f7/b3222b13f2d424dae3c9e63fde476af25ebccf1f3faf0b52d1b79fc15c70/llvmlite-0.48.0-cp311-cp311-win_amd64.whl", hash = "sha256:efaee0276e5e17c2b99b92e0c974bd484ef5977cf5dbc9168e82b71578edb47f", size = 41864734, upload-time = "2026-07-01T18:41:31.932Z" }, + { url = "https://files.pythonhosted.org/packages/92/a2/28696a9e61e245d1a79816d29d106692a90a2b6e7d78c98b326db70827af/llvmlite-0.48.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:d66c3beb4209087ddd4cf4ed2a0856b6887e6a913bdcf1aacfec9851cf2cba4e", size = 40480651, upload-time = "2026-07-01T18:41:35.694Z" }, + { url = "https://files.pythonhosted.org/packages/80/f2/72409351db66d0a317ec5087e076f31fb7b773a640db8a90ce6b5cac9edd/llvmlite-0.48.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:416fa4c2c66c2c6dc6d0a402648c19206e548efa0aa1eff01ad5cdad0af8217d", size = 59890118, upload-time = "2026-07-01T18:41:44.886Z" }, + { url = "https://files.pythonhosted.org/packages/3a/27/5ae2f3722606360480707adb47f001ad89df8251d06b14ee80336e660b66/llvmlite-0.48.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f5e5a5131045b72345c71062ea1a91910dde913792b6c9b28ebb2c1c0a712e98", size = 58343459, upload-time = "2026-07-01T18:41:40.306Z" }, + { url = "https://files.pythonhosted.org/packages/16/78/d824ffff7521cd140dc2006e44ce2bc82e64b48d1b32e90e956308c85a74/llvmlite-0.48.0-cp312-cp312-win_amd64.whl", hash = "sha256:d45c7541a80934ec6d8ab0defe67439494ecd2193cbf852a44ba827808976ac1", size = 41865022, upload-time = "2026-07-01T18:41:48.663Z" }, + { url = "https://files.pythonhosted.org/packages/9c/23/fe9316d14626b42c73ef0b502e724705a6ee9450afe53759c0a99c37c2d7/llvmlite-0.48.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:a83a99ef0c05b4ccddf9b6218ed9fe84b653a0caf7c1d9dbe148d6d16c67f518", size = 40480652, upload-time = "2026-07-01T18:41:52.216Z" }, + { url = "https://files.pythonhosted.org/packages/1b/4a/90715fa12006d681270b08d881195b6fab3ec39572e048764a1f7f59fed7/llvmlite-0.48.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8761b9e522f55207e24424fcd98370289eec2710bf8e915c82d1053f642450dc", size = 59890120, upload-time = "2026-07-01T18:42:00.748Z" }, + { url = "https://files.pythonhosted.org/packages/70/5e/7b3e20d64650ca3c80af0cdb664ec4b575ec83d9d4dd05bea8bd31f9bbb6/llvmlite-0.48.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2fe5cb59b2063bfa039dcb8ca6481c0181bf552f340d10dcf61d7996a665556e", size = 58343457, upload-time = "2026-07-01T18:41:56.41Z" }, + { url = "https://files.pythonhosted.org/packages/17/97/5a430055d1838cf1fb7a01cfa943300f5e4c026fc6333a522c5e4a03b0c1/llvmlite-0.48.0-cp313-cp313-win_amd64.whl", hash = "sha256:91c7e24e74cde3f02b88aa5acca678373f9e069f3b98531b3dbb3a142d9d10bb", size = 41865022, upload-time = "2026-07-01T18:42:04.57Z" }, + { url = "https://files.pythonhosted.org/packages/8d/8e/8170f2e0c217f88069c333d85bb976e536b332aecfcce606ddbdb249385f/llvmlite-0.48.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:321f1ac39b462603f0b589751aecf2d237d056f6d005749c1752b6f23ec3f074", size = 40480650, upload-time = "2026-07-01T18:42:07.935Z" }, + { url = "https://files.pythonhosted.org/packages/d6/e1/05b50692b647cac3c18200ac485b04f342f00ed173c9cc46767274469a15/llvmlite-0.48.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:05f0103c8f2f96a37441337e3643863c01b8e83e530aff38960dcb383c54a065", size = 59890115, upload-time = "2026-07-01T18:42:17.805Z" }, + { url = "https://files.pythonhosted.org/packages/f7/c3/470b8c4ff9ae2db2f9cf5c3e73de76ed908a32788ae9eb5602d43e6a476b/llvmlite-0.48.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:37d66fae72802175b0bfe1ea06e624b51e2d7aee6c3c34bbd09739b8f88e8e0b", size = 58343457, upload-time = "2026-07-01T18:42:13.217Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2d/6a5171fb7236ac0895e1a02ccba3735bf291e8597239aa6421894d3c0ba8/llvmlite-0.48.0-cp314-cp314-win_amd64.whl", hash = "sha256:966dcab0a598e2bd8fb5f2cc082cf7b07bae564fc485a3a8692393caf986facf", size = 42986372, upload-time = "2026-07-01T18:42:21.483Z" }, + { url = "https://files.pythonhosted.org/packages/94/e3/7a93e09c9f94e637ca90209ceef0334a9a1d45b0bdb7c92ff922d25d6187/llvmlite-0.48.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:7a5c413317050a1d67c34708bde97707f9b2257ef1017f7532d21fe7d9a9ff30", size = 40480654, upload-time = "2026-07-01T18:42:25.076Z" }, + { url = "https://files.pythonhosted.org/packages/27/98/a29133b4728671a175f7d616fab8b1c6e1d8c269d1523581d3160697bfb1/llvmlite-0.48.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1d9f952dff6c350c529997423d4fa43abae9722a884ac7ffafc37a3af676e7db", size = 59890119, upload-time = "2026-07-01T18:42:33.88Z" }, + { url = "https://files.pythonhosted.org/packages/1a/cf/7aac11a1f1c7ec54b60c7f6814e87561fb6b55b2f290455d7941eb113420/llvmlite-0.48.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:054aa7d46595935565f276cf0c1659b4f10929c996dd4a606875fae26fba2a23", size = 58343460, upload-time = "2026-07-01T18:42:29.545Z" }, + { url = "https://files.pythonhosted.org/packages/db/41/b96f440c7df5ebba07872cad4e30fbc3560387755b1ea0b629adb76d5ca8/llvmlite-0.48.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d0b3c61aac83b42fb48cc96bffbf57c81b82b2aa92276b7ed6420c814629a99a", size = 42986383, upload-time = "2026-07-01T18:42:37.544Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/4b/3541d44f3937ba468b75da9eebcae497dcf67adb65caa16760b0a6807ebb/markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559", size = 11631, upload-time = "2025-09-27T18:36:05.558Z" }, + { url = "https://files.pythonhosted.org/packages/98/1b/fbd8eed11021cabd9226c37342fa6ca4e8a98d8188a8d9b66740494960e4/markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419", size = 12057, upload-time = "2025-09-27T18:36:07.165Z" }, + { url = "https://files.pythonhosted.org/packages/40/01/e560d658dc0bb8ab762670ece35281dec7b6c1b33f5fbc09ebb57a185519/markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695", size = 22050, upload-time = "2025-09-27T18:36:08.005Z" }, + { url = "https://files.pythonhosted.org/packages/af/cd/ce6e848bbf2c32314c9b237839119c5a564a59725b53157c856e90937b7a/markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591", size = 20681, upload-time = "2025-09-27T18:36:08.881Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2a/b5c12c809f1c3045c4d580b035a743d12fcde53cf685dbc44660826308da/markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c", size = 20705, upload-time = "2025-09-27T18:36:10.131Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e3/9427a68c82728d0a88c50f890d0fc072a1484de2f3ac1ad0bfc1a7214fd5/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f", size = 21524, upload-time = "2025-09-27T18:36:11.324Z" }, + { url = "https://files.pythonhosted.org/packages/bc/36/23578f29e9e582a4d0278e009b38081dbe363c5e7165113fad546918a232/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6", size = 20282, upload-time = "2025-09-27T18:36:12.573Z" }, + { url = "https://files.pythonhosted.org/packages/56/21/dca11354e756ebd03e036bd8ad58d6d7168c80ce1fe5e75218e4945cbab7/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1", size = 20745, upload-time = "2025-09-27T18:36:13.504Z" }, + { url = "https://files.pythonhosted.org/packages/87/99/faba9369a7ad6e4d10b6a5fbf71fa2a188fe4a593b15f0963b73859a1bbd/markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa", size = 14571, upload-time = "2025-09-27T18:36:14.779Z" }, + { url = "https://files.pythonhosted.org/packages/d6/25/55dc3ab959917602c96985cb1253efaa4ff42f71194bddeb61eb7278b8be/markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8", size = 15056, upload-time = "2025-09-27T18:36:16.125Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9e/0a02226640c255d1da0b8d12e24ac2aa6734da68bff14c05dd53b94a0fc3/markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1", size = 13932, upload-time = "2025-09-27T18:36:17.311Z" }, + { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, + { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, + { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, + { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, + { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, + { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, + { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, + { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[package]] +name = "mastering-python-exercises" +version = "0.1.0" +source = { virtual = "." } + +[package.dev-dependencies] +dev = [ + { name = "docutils" }, + { name = "mypy" }, + { name = "pyrefly" }, + { name = "pyright" }, + { name = "pytest" }, + { name = "pytest-timeout" }, + { name = "pyyaml" }, + { name = "ruff" }, +] +interactive = [ + { name = "colorama" }, + { name = "ipython", version = "8.39.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "ipython", version = "9.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "ipywidgets" }, + { name = "jedi" }, +] +native = [ + { name = "build" }, + { name = "cffi" }, + { name = "setuptools" }, + { name = "wheel" }, +] +nlp = [ + { name = "spacy" }, + { name = "typer" }, +] +packaging = [ + { name = "build" }, + { name = "setuptools" }, + { name = "tox" }, + { name = "wheel" }, +] +scientific = [ + { name = "datashader" }, + { name = "ipykernel" }, + { name = "ipywidgets" }, + { name = "jupyter-client" }, + { name = "jupyterlab" }, + { name = "nbclient" }, + { name = "nbformat" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "pandas", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "pandas", version = "3.0.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "pillow" }, + { name = "xarray", version = "2025.6.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "xarray", version = "2026.7.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] + +[package.metadata] + +[package.metadata.requires-dev] +dev = [ + { name = "docutils", specifier = ">=0.21" }, + { name = "mypy", specifier = ">=1.18" }, + { name = "pyrefly", specifier = ">=0.59" }, + { name = "pyright", specifier = ">=1.1.411" }, + { name = "pytest", specifier = ">=9.0" }, + { name = "pytest-timeout", specifier = ">=2.4" }, + { name = "pyyaml", specifier = ">=6.0" }, + { name = "ruff", specifier = ">=0.15" }, +] +interactive = [ + { name = "colorama", specifier = ">=0.4.6" }, + { name = "ipython", specifier = ">=8.37" }, + { name = "ipywidgets", specifier = ">=8.1" }, + { name = "jedi", specifier = ">=0.19" }, +] +native = [ + { name = "build", specifier = ">=1.3" }, + { name = "cffi", specifier = ">=1.17" }, + { name = "setuptools", specifier = ">=80" }, + { name = "wheel", specifier = ">=0.45" }, +] +nlp = [ + { name = "spacy", specifier = ">=3.8.13,<3.8.14" }, + { name = "typer", specifier = ">=0.25,<0.26" }, +] +packaging = [ + { name = "build", specifier = ">=1.3" }, + { name = "setuptools", specifier = ">=80" }, + { name = "tox", specifier = ">=4.30" }, + { name = "wheel", specifier = ">=0.45" }, +] +scientific = [ + { name = "datashader", specifier = ">=0.18" }, + { name = "ipykernel", specifier = ">=6.29" }, + { name = "ipywidgets", specifier = ">=8.1" }, + { name = "jupyter-client", specifier = ">=8.6" }, + { name = "jupyterlab", specifier = ">=4.4" }, + { name = "nbclient", specifier = ">=0.10" }, + { name = "nbformat", specifier = ">=5.10" }, + { name = "numpy", specifier = ">=1.26" }, + { name = "pandas", specifier = ">=2.2" }, + { name = "pillow", specifier = ">=11" }, + { name = "xarray", specifier = ">=2025.6.1" }, +] + +[[package]] +name = "matplotlib-inline" +version = "0.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bd/c0/9f7c9a46090390368a4d7bcb76bb87a4a36c421e4c0792cdb53486ffac7a/matplotlib_inline-0.2.2.tar.gz", hash = "sha256:72f3fe8fce36b70d4a5b612f899090cd0401deddc4ea90e1572b9f4bfb058c79", size = 8150, upload-time = "2026-05-08T17:33:33.49Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/09/5b161152e2d90f7b87f781c2e1267494aef9c32498df793f73ad0a0a494a/matplotlib_inline-0.2.2-py3-none-any.whl", hash = "sha256:3c821cf1c209f59fb2d2d64abbf5b23b67bcb2210d663f9918dd851c6da1fcf6", size = 9534, upload-time = "2026-05-08T17:33:32.055Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +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 = "mistune" +version = "3.3.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7b/92/328a294a6de83bacb95bed01f04e0eaff4e3616ee359fc821a5dfc539b02/mistune-3.3.4.tar.gz", hash = "sha256:58b5c96d6fcb61190dfe5fae498d2b2065f99cf61e9649418fd54cf1ada86dfe", size = 121426, upload-time = "2026-07-22T05:22:30.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/e4/288365afae98953bc01de09f686f40d8ee84578135aa7767d5d4e60b5278/mistune-3.3.4-py3-none-any.whl", hash = "sha256:ee015381e955e370962968befe1d729ab60fafb6a715ac6751763fbce38c8d4a", size = 66862, upload-time = "2026-07-22T05:22:29.419Z" }, +] + +[[package]] +name = "multipledispatch" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/3e/a62c3b824c7dec33c4a1578bcc842e6c30300051033a4e5975ed86cc2536/multipledispatch-1.0.0.tar.gz", hash = "sha256:5c839915465c68206c3e9c473357908216c28383b425361e5d144594bf85a7e0", size = 12385, upload-time = "2023-06-27T16:45:11.074Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/c0/00c9809d8b9346eb238a6bbd5f83e846a4ce4503da94a4c08cb7284c325b/multipledispatch-1.0.0-py3-none-any.whl", hash = "sha256:0c53cd8b077546da4e48869f49b13164bebafd0c2a5afceb6bb6a316e7fb46e4", size = 12818, upload-time = "2023-06-27T16:45:09.418Z" }, +] + +[[package]] +name = "murmurhash" +version = "1.0.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/23/2e/88c147931ea9725d634840d538622e94122bceaf346233349b7b5c62964b/murmurhash-1.0.15.tar.gz", hash = "sha256:58e2b27b7847f9e2a6edf10b47a8c8dd70a4705f45dccb7bf76aeadacf56ba01", size = 13291, upload-time = "2025-11-14T09:51:15.272Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/09/3c/5e59e29fe971365d27f191a5cbf8a5fb492746e458604fe5d39810da4668/murmurhash-1.0.15-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f4989c16053a9a83b02c520dd00a31f0877d5fd2ab8a9b6b75ed9eba0e25c489", size = 27463, upload-time = "2025-11-14T09:49:53.158Z" }, + { url = "https://files.pythonhosted.org/packages/38/3d/ace00a9b82beaa99a8a7a52e98171cfbf13c0066d2f820e84a5d572e3bd0/murmurhash-1.0.15-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:899068ba3d7c371e7edd093852c634cce802fefd9aaddfcc0d2fda1d7433c7f9", size = 27714, upload-time = "2025-11-14T09:49:54.855Z" }, + { url = "https://files.pythonhosted.org/packages/10/0f/34f1c4f97424ea1bc72b1e3bdf61ac34f4c5555ec9163721f1e4cafe5b1d/murmurhash-1.0.15-cp310-cp310-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fe883982114de576c793fd1cf55945c8ee6453ad4c4785ac1a48f84e74fdc650", size = 122570, upload-time = "2025-11-14T09:49:55.977Z" }, + { url = "https://files.pythonhosted.org/packages/b9/75/0019717a16ce5a7b088fc50a3ecb513035e4196c5e569bf4a2e16bcc0414/murmurhash-1.0.15-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:342277d8d7f712d136507fb3ccdba26c076a34ca0f8d1b96f65f0daa556da2e9", size = 123194, upload-time = "2025-11-14T09:49:57.462Z" }, + { url = "https://files.pythonhosted.org/packages/7b/a4/c1c95ce60b816c2255098164e424752779269c93f5d6dceaa213346789a2/murmurhash-1.0.15-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:bc54facccb32fe1e97d6231edd4f3e2937467c35658b26aa35bbd6a87ebb7cb0", size = 122461, upload-time = "2025-11-14T09:49:58.686Z" }, + { url = "https://files.pythonhosted.org/packages/63/28/e1f79369a6e8d1a5901346ed2fd3a5c56e647d0b849044870c071cb64e1c/murmurhash-1.0.15-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e525bbd8e26e6b9ab1b56758a59b16c2fffd73bad2f7b8bf361c16f70ff1d980", size = 121676, upload-time = "2025-11-14T09:49:59.888Z" }, + { url = "https://files.pythonhosted.org/packages/1d/7c/e2be1f5387e5898f6551cf81c4220975858b9dbda4d471b133750945599a/murmurhash-1.0.15-cp310-cp310-win_amd64.whl", hash = "sha256:2224f30f7729717644745a6f513ea7662517dfe7b1867cf1588177f64c61df3c", size = 25156, upload-time = "2025-11-14T09:50:01.016Z" }, + { url = "https://files.pythonhosted.org/packages/74/07/0df6e1a753de68368662cbbb8f88558e2c877d3886ac12b30953fb8ed335/murmurhash-1.0.15-cp310-cp310-win_arm64.whl", hash = "sha256:8a181494b5f03ba831f9a13f2de3aab9ef591e508e57239043d65c5c592f5837", size = 23270, upload-time = "2025-11-14T09:50:01.99Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ca/77d3e69924a8eb4508bb4f0ad34e46adbeedeb93616a71080e61e53dad71/murmurhash-1.0.15-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f32307fb9347680bb4fe1cbef6362fb39bd994f1b59abd8c09ca174e44199081", size = 27397, upload-time = "2025-11-14T09:50:03.077Z" }, + { url = "https://files.pythonhosted.org/packages/e6/53/a936f577d35b245d47b310f29e5e9f09fcac776c8c992f1ab51a9fb0cee2/murmurhash-1.0.15-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:539d8405885d1d19c005f3a2313b47e8e54b0ee89915eb8dfbb430b194328e6c", size = 27692, upload-time = "2025-11-14T09:50:04.144Z" }, + { url = "https://files.pythonhosted.org/packages/4d/64/5f8cfd1fd9cbeb43fcff96672f5bd9e7e1598d1c970f808ecd915490dc20/murmurhash-1.0.15-cp311-cp311-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c4cd739a00f5a4602201b74568ddabae46ec304719d9be752fd8f534a9464b5e", size = 128396, upload-time = "2025-11-14T09:50:05.268Z" }, + { url = "https://files.pythonhosted.org/packages/ac/10/d9ce29d559a75db0d8a3f13ea12c7f541ec9de2afca38dc70418b890eedb/murmurhash-1.0.15-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:44d211bcc3ec203c47dac06f48ee871093fcbdffa6652a6cc5ea7180306680a8", size = 128687, upload-time = "2025-11-14T09:50:06.527Z" }, + { url = "https://files.pythonhosted.org/packages/48/cd/dc97ab7e68cdfa1537a56e36dbc846c5a66701cc39ecee2d4399fe61996c/murmurhash-1.0.15-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f9bf47101354fb1dc4b2e313192566f04ba295c28a37e2f71c692759acc1ba3c", size = 128198, upload-time = "2025-11-14T09:50:08.062Z" }, + { url = "https://files.pythonhosted.org/packages/53/73/32f2aaa22c1e4afae337106baf0c938abf36a6cc879cfee83a00461bbbf7/murmurhash-1.0.15-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3c69b4d3bcd6233782a78907fe10b9b7a796bdc5d28060cf097d067bec280a5d", size = 127214, upload-time = "2025-11-14T09:50:09.265Z" }, + { url = "https://files.pythonhosted.org/packages/82/ed/812103a7f353eba2d83655b08205e13a38c93b4db0692f94756e1eb44516/murmurhash-1.0.15-cp311-cp311-win_amd64.whl", hash = "sha256:e43a69496342ce530bdd670264cb7c8f45490b296e4764c837ce577e3c7ebd53", size = 25241, upload-time = "2025-11-14T09:50:10.373Z" }, + { url = "https://files.pythonhosted.org/packages/eb/5f/2c511bdd28f7c24da37a00116ffd0432b65669d098f0d0260c66ac0ffdc2/murmurhash-1.0.15-cp311-cp311-win_arm64.whl", hash = "sha256:f3e99a6ee36ef5372df5f138e3d9c801420776d3641a34a49e5c2555f44edba7", size = 23216, upload-time = "2025-11-14T09:50:11.651Z" }, + { url = "https://files.pythonhosted.org/packages/b6/46/be8522d3456fdccf1b8b049c6d82e7a3c1114c4fc2cfe14b04cba4b3e701/murmurhash-1.0.15-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d37e3ae44746bca80b1a917c2ea625cf216913564ed43f69d2888e5df97db0cb", size = 27884, upload-time = "2025-11-14T09:50:13.133Z" }, + { url = "https://files.pythonhosted.org/packages/ed/cc/630449bf4f6178d7daf948ce46ad00b25d279065fc30abd8d706be3d87e0/murmurhash-1.0.15-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0861cb11039409eaf46878456b7d985ef17b6b484103a6fc367b2ecec846891d", size = 27855, upload-time = "2025-11-14T09:50:14.859Z" }, + { url = "https://files.pythonhosted.org/packages/ff/30/ea8f601a9bf44db99468696efd59eb9cff1157cd55cb586d67116697583f/murmurhash-1.0.15-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5a301decfaccfec70fe55cb01dde2a012c3014a874542eaa7cc73477bb749616", size = 134088, upload-time = "2025-11-14T09:50:15.958Z" }, + { url = "https://files.pythonhosted.org/packages/c9/de/c40ce8c0877d406691e735b8d6e9c815f36a82b499d358313db5dbe219d7/murmurhash-1.0.15-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:32c6fde7bd7e9407003370a07b5f4addacabe1556ad3dc2cac246b7a2bba3400", size = 133978, upload-time = "2025-11-14T09:50:17.572Z" }, + { url = "https://files.pythonhosted.org/packages/47/84/bd49963ecd84ebab2fe66595e2d1ed41d5e8b5153af5dc930f0bd827007c/murmurhash-1.0.15-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5d8b43a7011540dc3c7ce66f2134df9732e2bc3bbb4a35f6458bc755e48bde26", size = 132956, upload-time = "2025-11-14T09:50:18.742Z" }, + { url = "https://files.pythonhosted.org/packages/4f/7c/2530769c545074417c862583f05f4245644599f1e9ff619b3dfe2969aafc/murmurhash-1.0.15-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:43bf4541892ecd95963fcd307bf1c575fc0fee1682f41c93007adee71ca2bb40", size = 134184, upload-time = "2025-11-14T09:50:19.941Z" }, + { url = "https://files.pythonhosted.org/packages/84/a4/b249b042f5afe34d14ada2dc4afc777e883c15863296756179652e081c44/murmurhash-1.0.15-cp312-cp312-win_amd64.whl", hash = "sha256:f4ac15a2089dc42e6eb0966622d42d2521590a12c92480aafecf34c085302cca", size = 25647, upload-time = "2025-11-14T09:50:21.049Z" }, + { url = "https://files.pythonhosted.org/packages/13/bf/028179259aebc18fd4ba5cae2601d1d47517427a537ab44336446431a215/murmurhash-1.0.15-cp312-cp312-win_arm64.whl", hash = "sha256:4a70ca4ae19e600d9be3da64d00710e79dde388a4d162f22078d64844d0ebdda", size = 23338, upload-time = "2025-11-14T09:50:22.359Z" }, + { url = "https://files.pythonhosted.org/packages/29/2f/ba300b5f04dae0409202d6285668b8a9d3ade43a846abee3ef611cb388d5/murmurhash-1.0.15-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fe50dc70e52786759358fd1471e309b94dddfffb9320d9dfea233c7684c894ba", size = 27861, upload-time = "2025-11-14T09:50:23.804Z" }, + { url = "https://files.pythonhosted.org/packages/34/02/29c19d268e6f4ea1ed2a462c901eed1ed35b454e2cbc57da592fad663ac6/murmurhash-1.0.15-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1349a7c23f6092e7998ddc5bd28546cc31a595afc61e9fdb3afc423feec3d7ad", size = 27840, upload-time = "2025-11-14T09:50:25.146Z" }, + { url = "https://files.pythonhosted.org/packages/e2/63/58e2de2b5232cd294c64092688c422196e74f9fa8b3958bdf02d33df24b9/murmurhash-1.0.15-cp313-cp313-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3ba6d05de2613535b5a9227d4ad8ef40a540465f64660d4a8800634ae10e04f", size = 133080, upload-time = "2025-11-14T09:50:26.566Z" }, + { url = "https://files.pythonhosted.org/packages/aa/9a/d13e2e9f8ba1ced06840921a50f7cece0a475453284158a3018b72679761/murmurhash-1.0.15-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fa1b70b3cc2801ab44179c65827bbd12009c68b34e9d9ce7125b6a0bd35af63c", size = 132648, upload-time = "2025-11-14T09:50:27.788Z" }, + { url = "https://files.pythonhosted.org/packages/b2/e1/47994f1813fa205c84977b0ff51ae6709f8539af052c7491a5f863d82bdc/murmurhash-1.0.15-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:213d710fb6f4ef3bc11abbfad0fa94a75ffb675b7dc158c123471e5de869f9af", size = 131502, upload-time = "2025-11-14T09:50:29.339Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ea/90c1fd00b4aeb704fb5e84cd666b33ffd7f245155048071ffbb51d2bb57d/murmurhash-1.0.15-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b65a5c4e7f5d71f7ccac2d2b60bdf7092d7976270878cfec59d5a66a533db823", size = 132736, upload-time = "2025-11-14T09:50:30.545Z" }, + { url = "https://files.pythonhosted.org/packages/00/db/da73462dbfa77f6433b128d2120ba7ba300f8c06dc4f4e022c38d240a5f5/murmurhash-1.0.15-cp313-cp313-win_amd64.whl", hash = "sha256:9aba94c5d841e1904cd110e94ceb7f49cfb60a874bbfb27e0373622998fb7c7c", size = 25682, upload-time = "2025-11-14T09:50:31.624Z" }, + { url = "https://files.pythonhosted.org/packages/bb/83/032729ef14971b938fbef41ee125fc8800020ee229bd35178b6ede8ee934/murmurhash-1.0.15-cp313-cp313-win_arm64.whl", hash = "sha256:263807eca40d08c7b702413e45cca75ecb5883aa337237dc5addb660f1483378", size = 23370, upload-time = "2025-11-14T09:50:33.264Z" }, + { url = "https://files.pythonhosted.org/packages/10/83/7547d9205e9bd2f8e5dfd0b682cc9277594f98909f228eb359489baec1df/murmurhash-1.0.15-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:694fd42a74b7ce257169d14c24aa616aa6cd4ccf8abe50eca0557e08da99d055", size = 29955, upload-time = "2025-11-14T09:50:34.488Z" }, + { url = "https://files.pythonhosted.org/packages/b7/c7/3afd5de7a5b3ae07fe2d3a3271b327ee1489c58ba2b2f2159bd31a25edb9/murmurhash-1.0.15-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a2ea4546ba426390beff3cd10db8f0152fdc9072c4f2583ec7d8aa9f3e4ac070", size = 30108, upload-time = "2025-11-14T09:50:35.53Z" }, + { url = "https://files.pythonhosted.org/packages/02/69/d6637ee67d78ebb2538c00411f28ea5c154886bbe1db16c49435a8a4ab16/murmurhash-1.0.15-cp313-cp313t-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:34e5a91139c40b10f98d0b297907f5d5267b4b1b2e5dd2eb74a021824f751b98", size = 164054, upload-time = "2025-11-14T09:50:36.591Z" }, + { url = "https://files.pythonhosted.org/packages/ab/4c/89e590165b4c7da6bf941441212a721a270195332d3aacfdfdf527d466ca/murmurhash-1.0.15-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:dc35606868a5961cf42e79314ca0bddf5a400ce377b14d83192057928d6252ec", size = 168153, upload-time = "2025-11-14T09:50:37.856Z" }, + { url = "https://files.pythonhosted.org/packages/07/7a/95c42df0c21d2e413b9fcd17317a7587351daeb264dc29c6aec1fdbd26f8/murmurhash-1.0.15-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:43cc6ac3b91ca0f7a5ae9c063ba4d6c26972c97fd7c25280ecc666413e4c5535", size = 164345, upload-time = "2025-11-14T09:50:39.346Z" }, + { url = "https://files.pythonhosted.org/packages/d0/22/9d02c880a88b83bb3ce7d6a38fb727373ab78d82e5f3d8d9fc5612219f90/murmurhash-1.0.15-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:847d712136cb462f0e4bd6229ee2d9eb996d8854eb8312dff3d20c8f5181fda5", size = 161990, upload-time = "2025-11-14T09:50:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e3/750232524e0dc262e8dcede6536dafc766faadd9a52f1d23746b02948ad8/murmurhash-1.0.15-cp313-cp313t-win_amd64.whl", hash = "sha256:2680851af6901dbe66cc4aa7ef8e263de47e6e1b425ae324caa571bdf18f8d58", size = 28812, upload-time = "2025-11-14T09:50:41.971Z" }, + { url = "https://files.pythonhosted.org/packages/ff/89/4ad9d215ef6ade89f27a72dc4e86b98ef1a43534cc3e6a6900a362a0bf0a/murmurhash-1.0.15-cp313-cp313t-win_arm64.whl", hash = "sha256:189a8de4d657b5da9efd66601b0636330b08262b3a55431f2379097c986995d0", size = 25398, upload-time = "2025-11-14T09:50:43.023Z" }, + { url = "https://files.pythonhosted.org/packages/1c/69/726df275edf07688146966e15eaaa23168100b933a2e1a29b37eb56c6db8/murmurhash-1.0.15-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:7c4280136b738e85ff76b4bdc4341d0b867ee753e73fd8b6994288080c040d0b", size = 28029, upload-time = "2025-11-14T09:50:44.124Z" }, + { url = "https://files.pythonhosted.org/packages/59/8f/24ecf9061bc2b20933df8aba47c73e904274ea8811c8300cab92f6f82372/murmurhash-1.0.15-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d4d681f474830489e2ec1d912095cfff027fbaf2baa5414c7e9d25b89f0fab68", size = 27912, upload-time = "2025-11-14T09:50:45.266Z" }, + { url = "https://files.pythonhosted.org/packages/ba/26/fff3caba25aa3c0622114e03c69fb66c839b22335b04d7cce91a3a126d44/murmurhash-1.0.15-cp314-cp314-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d7e47c5746785db6a43b65fac47b9e63dd71dfbd89a8c92693425b9715e68c6e", size = 131847, upload-time = "2025-11-14T09:50:46.819Z" }, + { url = "https://files.pythonhosted.org/packages/df/e4/0f2b9fc533467a27afb4e906c33f32d5f637477de87dd94690e0c44335a6/murmurhash-1.0.15-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e8e674f02a99828c8a671ba99cd03299381b2f0744e6f25c29cadfc6151dc724", size = 132267, upload-time = "2025-11-14T09:50:48.298Z" }, + { url = "https://files.pythonhosted.org/packages/da/bf/9d1c107989728ec46e25773d503aa54070b32822a18cfa7f9d5f41bc17a5/murmurhash-1.0.15-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:26fd7c7855ac4850ad8737991d7b0e3e501df93ebaf0cf45aa5954303085fdba", size = 131894, upload-time = "2025-11-14T09:50:49.485Z" }, + { url = "https://files.pythonhosted.org/packages/0d/81/dcf27c71445c0e993b10e33169a098ca60ee702c5c58fcbde205fa6332a6/murmurhash-1.0.15-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:cb8ebafae60d5f892acff533cc599a359954d8c016a829514cb3f6e9ee10f322", size = 132054, upload-time = "2025-11-14T09:50:50.747Z" }, + { url = "https://files.pythonhosted.org/packages/bc/32/e874a14b2d2246bd2d16f80f49fad393a3865d4ee7d66d2cae939a67a29a/murmurhash-1.0.15-cp314-cp314-win_amd64.whl", hash = "sha256:898a629bf111f1aeba4437e533b5b836c0a9d2dd12d6880a9c75f6ca13e30e22", size = 26579, upload-time = "2025-11-14T09:50:52.278Z" }, + { url = "https://files.pythonhosted.org/packages/af/8e/4fca051ed8ae4d23a15aaf0a82b18cb368e8cf84f1e3b474d5749ec46069/murmurhash-1.0.15-cp314-cp314-win_arm64.whl", hash = "sha256:88dc1dd53b7b37c0df1b8b6bce190c12763014492f0269ff7620dc6027f470f4", size = 24341, upload-time = "2025-11-14T09:50:53.295Z" }, + { url = "https://files.pythonhosted.org/packages/38/9c/c72c2a4edd86aac829337ab9f83cf04cdb15e5d503e4c9a3a243f30a261c/murmurhash-1.0.15-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:6cb4e962ec4f928b30c271b2d84e6707eff6d942552765b663743cfa618b294b", size = 30146, upload-time = "2025-11-14T09:50:54.705Z" }, + { url = "https://files.pythonhosted.org/packages/ac/d7/72b47ebc86436cd0aa1fd4c6e8779521ec389397ac11389990278d0f7a47/murmurhash-1.0.15-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5678a3ea4fbf0cbaaca2bed9b445f556f294d5f799c67185d05ffcb221a77faf", size = 30141, upload-time = "2025-11-14T09:50:55.829Z" }, + { url = "https://files.pythonhosted.org/packages/64/bb/6d2f09135079c34dc2d26e961c52742d558b320c61503f273eab6ba743d9/murmurhash-1.0.15-cp314-cp314t-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ef19f38c6b858eef83caf710773db98c8f7eb2193b4c324650c74f3d8ba299e0", size = 163898, upload-time = "2025-11-14T09:50:56.946Z" }, + { url = "https://files.pythonhosted.org/packages/b9/e2/9c1b462e33f9cb2d632056f07c90b502fc20bd7da50a15d0557343bd2fed/murmurhash-1.0.15-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22aa3ceaedd2e57078b491ed08852d512b84ff4ff9bb2ff3f9bf0eec7f214c9e", size = 168040, upload-time = "2025-11-14T09:50:58.234Z" }, + { url = "https://files.pythonhosted.org/packages/e8/73/8694db1408fcdfa73589f7df6c445437ea146986fa1e393ec60d26d6e30c/murmurhash-1.0.15-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bba0e0262c0d08682b028cb963ac477bd9839029486fa1333fc5c01fb6072749", size = 164239, upload-time = "2025-11-14T09:50:59.95Z" }, + { url = "https://files.pythonhosted.org/packages/2d/f9/8e360bdfc3c44e267e7e046f0e0b9922766da92da26959a6963f597e6bb5/murmurhash-1.0.15-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4fd8189ee293a09f30f4931408f40c28ccd42d9de4f66595f8814879339378bc", size = 161811, upload-time = "2025-11-14T09:51:01.289Z" }, + { url = "https://files.pythonhosted.org/packages/f9/31/97649680595b1096803d877ababb9a67c07f4378f177ec885eea28b9db6d/murmurhash-1.0.15-cp314-cp314t-win_amd64.whl", hash = "sha256:66395b1388f7daa5103db92debe06842ae3be4c0749ef6db68b444518666cdcc", size = 29817, upload-time = "2025-11-14T09:51:02.493Z" }, + { url = "https://files.pythonhosted.org/packages/76/66/4fce8755f25d77324401886c00017c556be7ca3039575b94037aff905385/murmurhash-1.0.15-cp314-cp314t-win_arm64.whl", hash = "sha256:c22e56c6a0b70598a66e456de5272f76088bc623688da84ef403148a6d41851d", size = 26219, upload-time = "2025-11-14T09:51:03.563Z" }, +] + +[[package]] +name = "mypy" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ast-serialize" }, + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/12/af/4e516a05d3ca2eb9283e9ec45b2c02225c1514dd6da49fd3c9eaa6639370/mypy-2.3.0.tar.gz", hash = "sha256:465965d41cd9a2726694e983e8ce7113259327bec798115d1e1dfa2a52fb666e", size = 3988104, upload-time = "2026-07-13T11:34:53.387Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/09/f2f5f45dae0c9a0891e4751a73312730e009395102e5d72a22a976cca41f/mypy-2.3.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:1fa8d916ac3b705af733c4c1e6c9ebe38fd0d52beb15b105c3e8355b55e6ecdc", size = 14927774, upload-time = "2026-07-13T11:28:38.224Z" }, + { url = "https://files.pythonhosted.org/packages/56/b9/345367effd3a6877275a94d481614bfca983f45e028c6290e2cc54603811/mypy-2.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:28e1e2af8cd8fff551fd30f2fe4b03fb76764ac8b1ba6c6a1bd00ad32b412db3", size = 14000127, upload-time = "2026-07-13T11:30:19.57Z" }, + { url = "https://files.pythonhosted.org/packages/99/6c/a10b7a7b9f0a755fb94e27ae834d4cea9ad6c5221f9325eef8f182641feb/mypy-2.3.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3e77244df3843048c3f927182916730e40c124cbaa43905c1fb86cb382aa0805", size = 14229437, upload-time = "2026-07-13T11:28:17.765Z" }, + { url = "https://files.pythonhosted.org/packages/d9/bd/a26a602acb1bbf849fa4bdac4bc657ee2f11c0c2a764a2cc87a5304e865c/mypy-2.3.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9559ab18a9c9957dfa3004ab57cd4bac5f26a724329a9584e583367f0c2e1117", size = 15171457, upload-time = "2026-07-13T11:29:01.834Z" }, + { url = "https://files.pythonhosted.org/packages/7f/14/124f462bef69bcbc90b9358088460b6091954a3e004852fcd9948db617a5/mypy-2.3.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:09abd66d8685e73f8f7d17b847c3e104d9a7b164a8706ea87d6c96a3d45816d5", size = 15478281, upload-time = "2026-07-13T11:32:23.413Z" }, + { url = "https://files.pythonhosted.org/packages/db/a4/8bdca6a8ac8d856d82ed049144af2721245a135c2e8001d3890c93975852/mypy-2.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:5e91adad1ca81742ac7ef9893959911df867752206b37135185e88dfb3c89494", size = 11148008, upload-time = "2026-07-13T11:34:17.332Z" }, + { url = "https://files.pythonhosted.org/packages/83/41/490eea348e60ba50decec20bc750605444149a5d7a8cc560042f90ba2c75/mypy-2.3.0-cp310-cp310-win_arm64.whl", hash = "sha256:6f99ec626e3c3a2f7c0b22c5b90ddb5dabb1c18729c971e9bdaca1f1766d2cee", size = 10142329, upload-time = "2026-07-13T11:32:52.116Z" }, + { url = "https://files.pythonhosted.org/packages/e6/b9/d75b3082b05f1b3028828aeb18e74ae5ab0a0936051bbf1f32f59f654747/mypy-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3419d00717afbc5265b50dd14b1278f29ea4884dd398ab67873489ac093fd329", size = 14838725, upload-time = "2026-07-13T11:32:44.655Z" }, + { url = "https://files.pythonhosted.org/packages/a9/50/79a65c6ea6e115bc73296038a4543b2d5c91f07912b918a2c616a2514bba/mypy-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cfca8ee88544090f86b6dcce05ec55d66eb48a762412ac2507810ba4bd793b6f", size = 13911128, upload-time = "2026-07-13T11:32:02.021Z" }, + { url = "https://files.pythonhosted.org/packages/90/48/e11ed7716c26953ca321f726e452e374dbf81a6f2b8b212ec02af29b6b8f/mypy-2.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:75cbb4b9ef04a0c84a957f07abc4504fbf64b8dcc145675101f2d3a78a4b1d6a", size = 14146742, upload-time = "2026-07-13T11:33:03.313Z" }, + { url = "https://files.pythonhosted.org/packages/06/72/6807565b1c4861ef66f7fdd98b51c61556356eab80235717b46c53bb8627/mypy-2.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:982e3d53dd23d0a4cef67dd66791fdbede0cf38f9eb617bf47663554c51e1e36", size = 15081418, upload-time = "2026-07-13T11:31:13.899Z" }, + { url = "https://files.pythonhosted.org/packages/00/80/1ea14c5d80e589e415973db3e47c78c2219a305b808b2b506395342c1d79/mypy-2.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:85c5385b93012ffa3b31479ab579aef5415f4f3a32c6cf1ae07a984d2a0ff461", size = 15328164, upload-time = "2026-07-13T11:31:35.723Z" }, + { url = "https://files.pythonhosted.org/packages/37/28/8223157404a3d51920078459c37f80fbdc590e1d8ea049dc5ce48643022a/mypy-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:13b1b16e2fa39f3b2e33fb1c468abc7a69369fa2e886b4b87b5afc81472325cd", size = 11136472, upload-time = "2026-07-13T11:27:37.018Z" }, + { url = "https://files.pythonhosted.org/packages/6f/cc/ea27e5959c5f258585a756b252031f3b313583d81b5064b2bebc41d3706b/mypy-2.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:b5cd2f027a972a4a5f2278a11fac9747f5f81a53a30b714d74950b6807e55568", size = 10135800, upload-time = "2026-07-13T11:30:08.92Z" }, + { url = "https://files.pythonhosted.org/packages/dc/94/0e7e592619e2133596a47cdd642534b0456545c218430bd3b9d8fefdd1b1/mypy-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d53fc67b9d28a43c6199077f49fea0f05839e36cf6158500331c9549225e5a5", size = 15026523, upload-time = "2026-07-13T11:34:49.206Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/1e1731df090a857df2807177a4626863e5ac0f0256513c35780efe53986f/mypy-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fbc00cee7bdbb9291979ddc9d08034a29dfcda4932628c9bbc28c1edd589df0c", size = 14032189, upload-time = "2026-07-13T11:33:57.168Z" }, + { url = "https://files.pythonhosted.org/packages/44/95/cab921f4a806e171f34113e6181dd23c55358ccf6a80741269ef594a410e/mypy-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:04e617030eca5221909c8b7d8d7fd1c637948199aa2100b2ad9813feb07e1491", size = 14198696, upload-time = "2026-07-13T11:32:12.767Z" }, + { url = "https://files.pythonhosted.org/packages/66/80/e6d008bb19fe446e3662d85e0e2717bf9f2d611a2164fb29d6e067dbf46c/mypy-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56c184d2c20ca6b6378d58d1960270a767f41f5e44acbbd27f05effef4f4e1d7", size = 15286904, upload-time = "2026-07-13T11:34:27.594Z" }, + { url = "https://files.pythonhosted.org/packages/db/83/94397c9293608a364aa03e8084fb34ede4ae976a260384b9b52929308135/mypy-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3961a4a34b05f7c74b0f05aa51fbfe99a2d1e126038df40318d15c8f558b7ef3", size = 15528342, upload-time = "2026-07-13T11:34:07.819Z" }, + { url = "https://files.pythonhosted.org/packages/cf/96/d8b37d819adec6cfccfb1fd3afc1735d94717ddeafb45536db9c6943e09b/mypy-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:b1942b9314d4c784b8ea1dbab4972603290e5dd5630f06675f13aec97526bc4c", size = 11218346, upload-time = "2026-07-13T11:28:27.745Z" }, + { url = "https://files.pythonhosted.org/packages/2b/cd/cd9f725b19b19e5b530a154cf9bcf9e94279c5d55b3c34fb42b3aa48ea1b/mypy-2.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:be51653d7669d7d7955d613b8d0bb57d5b652eaf71a873ddf65ac87254dd2595", size = 10204525, upload-time = "2026-07-13T11:31:02.552Z" }, + { url = "https://files.pythonhosted.org/packages/6e/ae/f7d056eb0294586a572d0d0d89580ec633c064db520f11d37d5a2fb833bd/mypy-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:91ad22a52ae2c7e621c2f67c94d5a17f66b3209a4cff5cf8a573579835c69e97", size = 14947298, upload-time = "2026-07-13T11:27:47.734Z" }, + { url = "https://files.pythonhosted.org/packages/32/d5/db3e7af01e7844d21662c6ddc1f7825ec7cb4053f0391ac02faf3638396f/mypy-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:99ac767cc5d3b64c8d0ae226ead10c96694f94e4e7da1668642225dcd4e75aac", size = 13950768, upload-time = "2026-07-13T11:27:57.726Z" }, + { url = "https://files.pythonhosted.org/packages/d9/fb/43c031f0190513d1ec248ed037eceb742ddd2a4d74bbf406658a28173837/mypy-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de6d2c484742a4d7b0ed6d07b143375624d3b899c5749c7b3c947f56261f48a6", size = 14151586, upload-time = "2026-07-13T11:29:18.615Z" }, + { url = "https://files.pythonhosted.org/packages/ec/c3/f8b2ffc60883084da91be51af58e88a7ffd4ff9795acb7d902ff88d31eb1/mypy-2.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7da939dd335cfd2ad788bdfd081c9f4e47634ab995e5a45eb15fd1e5bc052f8b", size = 15227411, upload-time = "2026-07-13T11:30:29.904Z" }, + { url = "https://files.pythonhosted.org/packages/83/2e/16b917fc7adcf03f1aadddfc93aab804ffb234b1ab09c0ffd6d92a5d34a2/mypy-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7247eb2824f996722a949530183394921ca71deb9680052a338cf53cff7925c2", size = 15478790, upload-time = "2026-07-13T11:33:14.686Z" }, + { url = "https://files.pythonhosted.org/packages/c0/88/aaa65a93c73d0cdae7e42f8adb302bf6885bb281302084f99d0290a35347/mypy-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:75b0984bb3cbd76bb5c9291a8671f7ae66ca3b51c7584c358fc2e923259f0757", size = 11234919, upload-time = "2026-07-13T11:33:39.28Z" }, + { url = "https://files.pythonhosted.org/packages/35/19/b40de63f1a80e63bc2d40f0679a6a8dbd34e95176c8122119bdf406aa552/mypy-2.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:d78fcf900b59cb7e82cb7e3a235e31b462d9333d92285bd1e4952d355b8ffba1", size = 10201510, upload-time = "2026-07-13T11:31:52.619Z" }, + { url = "https://files.pythonhosted.org/packages/a4/58/fa0ae047da911f540284009b4f44b96fe09d83c076d7c103e9d645f46303/mypy-2.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ea317b060ce83e26050f8f9e4d7d6bf44ed7597c8ff9990bccffbb9d1d8522db", size = 14941909, upload-time = "2026-07-13T11:32:34.332Z" }, + { url = "https://files.pythonhosted.org/packages/15/14/2ba1d61452d7c2a7fe12741e8d374e52b183476b07aa7f9e2a0d02b0720a/mypy-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:094af99f92638aa92852326188b85a89e50f4a472f44827c03362228482f0762", size = 13967581, upload-time = "2026-07-13T11:30:00.587Z" }, + { url = "https://files.pythonhosted.org/packages/ed/5a/483fb9e5ffbbb1a28dccc7b0a13d141b17ac769b6c9f488c0a0c63698962/mypy-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de121747278144fc9ae7caa2e978cf5df12aebc82933182f5b3b86081a30baef", size = 14168807, upload-time = "2026-07-13T11:28:48.6Z" }, + { url = "https://files.pythonhosted.org/packages/ae/77/70d7a10732063beb74ad713682cf871e88f5c5fa39bfc8beff8a524bf9cb/mypy-2.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:37fa4de896a84e2dc9200d91e614c22563b43d1a266789d4bbac7b22ebe6192b", size = 15200144, upload-time = "2026-07-13T11:31:25.283Z" }, + { url = "https://files.pythonhosted.org/packages/56/72/766218ac783be4fdfcd699b90037b63017348a3e86fb2c1fbfb18302637d/mypy-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f1b3a98dfd21058bc759bb3337d5d1f61d0fdf9f3cf9c00f4291790fb5427bff", size = 15460389, upload-time = "2026-07-13T11:29:29.077Z" }, + { url = "https://files.pythonhosted.org/packages/38/4e/8a9db7411ecb8ec0cb1fd05dba432f28bafffcd38b4e887714a4a0506689/mypy-2.3.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:944c665d984157cb96a679dfb7a4a81dd1d36b24b9c284b699514e6e626b82d4", size = 7753664, upload-time = "2026-07-13T11:29:08.147Z" }, + { url = "https://files.pythonhosted.org/packages/65/4c/c3f8bfd6ed0e5e38b5a244403b27f821d433443df5a15a278417c10a3a3c/mypy-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:4359424140d985192c778c1ce2c114a10c1ca58a381ed79cfa70d37df94b299f", size = 11417237, upload-time = "2026-07-13T11:33:47.467Z" }, + { url = "https://files.pythonhosted.org/packages/3c/00/89a32eaf5ccf174bc4f90db0eaea5d70636c01b8d49f384bdab2e8834390/mypy-2.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:3dd0bed92c4bdec57c42505b96416fb9e6a5aa7be84d2809bcd5f2ecec2860d7", size = 10389252, upload-time = "2026-07-13T11:31:43.81Z" }, + { url = "https://files.pythonhosted.org/packages/31/56/104f93d69aa9f339b6b9d3b0a7faa699b8b466c942cf3ae86cc2a2ec0915/mypy-2.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:691fdc37132b1ae628d834f672e74de83462d9fb4aff621835767fb43a8dd373", size = 16385495, upload-time = "2026-07-13T11:29:49.818Z" }, + { url = "https://files.pythonhosted.org/packages/d2/03/f1d2123313f55efafdd27706960f43a771c62f1b68426c76043f3ab9ebf3/mypy-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:aec15d465d477558fd842757b487849007311cf3897849cdda0e3162ac0ac556", size = 15098155, upload-time = "2026-07-13T11:30:40.301Z" }, + { url = "https://files.pythonhosted.org/packages/e5/5d/d5f9200399b445e81726c4f23becee33f233aee81c72680b1ef3a258b641/mypy-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b352b7e49f5e6576009e8df730e1ff4f915cb565b851b396d2ffe2f5a6f5da88", size = 15514155, upload-time = "2026-07-13T11:34:38.569Z" }, + { url = "https://files.pythonhosted.org/packages/cd/ce/69977c555f08faa3190cfde44189b89dbd56861b1ab97aa18fc5f3a2e4a3/mypy-2.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c6c6bf687b17f90dbfcad95b960d32eaa0154c00da45f03ab50bf8952e047fe", size = 16766351, upload-time = "2026-07-13T11:33:29.195Z" }, + { url = "https://files.pythonhosted.org/packages/bc/92/6648b6caa3ab9e00f9ac0c2a78307805f873dd48139b24a6f6f7c3667bbf/mypy-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f4ed18f111bfe2d599bca7468e7f9251042c1c2118f762c8de2766a56d773c60", size = 17043490, upload-time = "2026-07-13T11:30:53.927Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ab/0dc91d80f3f016634c68d451f294a97320fe903a9b6f90b9e57b3f7f1717/mypy-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0b025a93cffb9781d231f232be07a17912f35f10a313c24f301c81e842870654", size = 12146869, upload-time = "2026-07-13T11:29:38.874Z" }, + { url = "https://files.pythonhosted.org/packages/85/b5/4c964d02634ba81f4d1c84838e5c5b18ab06d13ed568960f5d6318495ccc/mypy-2.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:adebc76aab4f3495a88b41d48aa4aff0c03f2822501da76625afcca5975f19e5", size = 10965113, upload-time = "2026-07-13T11:28:07.056Z" }, + { url = "https://files.pythonhosted.org/packages/2c/fa/fdc54fe583ba3cafbcedfb70eeeaf03849f75b1827a07096c7bd996f582d/mypy-2.3.0-py3-none-any.whl", hash = "sha256:6b1cdb579446b60432432b2b2403a6201b4b475a004d7f488511c9ba177c9e88", size = 2753292, upload-time = "2026-07-13T11:33:18.48Z" }, +] + +[[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 = "nbclient" +version = "0.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jupyter-client" }, + { name = "jupyter-core" }, + { name = "nbformat" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/28/a5/b3bae4b590c0cbcada2c63a34f7580024e834a8ba213e949a2f906705787/nbclient-0.11.0.tar.gz", hash = "sha256:04a134a5b087f2c5887f228aca155db50169b8cd9334dee6942c8e927e56081a", size = 62535, upload-time = "2026-06-05T07:52:41.746Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/36/c9/94d73e5a01c5b926c3fa2496e97d7a8dc28ed5a77c0b2ed712f1a62e6694/nbclient-0.11.0-py3-none-any.whl", hash = "sha256:ef7fa0d59d6e1d41103933d8a445a18d5de860ca6b613b87b8574accdb3c2895", size = 25288, upload-time = "2026-06-05T07:52:40.115Z" }, +] + +[[package]] +name = "nbconvert" +version = "7.17.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "beautifulsoup4" }, + { name = "bleach", extra = ["css"] }, + { name = "defusedxml" }, + { name = "jinja2" }, + { name = "jupyter-core" }, + { name = "jupyterlab-pygments" }, + { name = "markupsafe" }, + { name = "mistune" }, + { name = "nbclient" }, + { name = "nbformat" }, + { name = "packaging" }, + { name = "pandocfilters" }, + { name = "pygments" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/01/b1/708e53fe2e429c103c6e6e159106bcf0357ac41aa4c28772bd8402339051/nbconvert-7.17.1.tar.gz", hash = "sha256:34d0d0a7e73ce3cbab6c5aae8f4f468797280b01fd8bd2ca746da8569eddd7d2", size = 865311, upload-time = "2026-04-08T00:44:14.914Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/f8/bb0a9d5f46819c821dc1f004aa2cc29b1d91453297dbf5ff20470f00f193/nbconvert-7.17.1-py3-none-any.whl", hash = "sha256:aa85c087b435e7bf1ffd03319f658e285f2b89eccab33bc1ba7025495ab3e7c8", size = 261927, upload-time = "2026-04-08T00:44:12.845Z" }, +] + +[[package]] +name = "nbformat" +version = "5.10.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "fastjsonschema" }, + { name = "jsonschema" }, + { name = "jupyter-core" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6d/fd/91545e604bc3dad7dca9ed03284086039b294c6b3d75c0d2fa45f9e9caf3/nbformat-5.10.4.tar.gz", hash = "sha256:322168b14f937a5d11362988ecac2a4952d3d8e3a2cbeb2319584631226d5b3a", size = 142749, upload-time = "2024-04-04T11:20:37.371Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/82/0340caa499416c78e5d8f5f05947ae4bc3cba53c9f038ab6e9ed964e22f1/nbformat-5.10.4-py3-none-any.whl", hash = "sha256:3b48d6c8fbca4b299bf3982ea7db1af21580e4fec269ad087b9e81588891200b", size = 78454, upload-time = "2024-04-04T11:20:34.895Z" }, +] + +[[package]] +name = "nest-asyncio2" +version = "1.7.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b4/73/731debf26e27e0a0323d7bda270dc2f634b398e38f040a09da1f4351d0aa/nest_asyncio2-1.7.2.tar.gz", hash = "sha256:1921d70b92cc4612c374928d081552efb59b83d91b2b789d935c665fa01729a8", size = 14743, upload-time = "2026-02-13T00:34:04.386Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/3c/3179b85b0e1c3659f0369940200cd6d0fa900e6cefcc7ea0bc6dd0e29ffb/nest_asyncio2-1.7.2-py3-none-any.whl", hash = "sha256:f5dfa702f3f81f6a03857e9a19e2ba578c0946a4ad417b4c50a24d7ba641fe01", size = 7843, upload-time = "2026-02-13T00:34:02.691Z" }, +] + +[[package]] +name = "nodeenv" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, +] + +[[package]] +name = "notebook-shim" +version = "0.2.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jupyter-server" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/54/d2/92fa3243712b9a3e8bafaf60aac366da1cada3639ca767ff4b5b3654ec28/notebook_shim-0.2.4.tar.gz", hash = "sha256:b4b2cfa1b65d98307ca24361f5b30fe785b53c3fd07b7a47e89acb5e6ac638cb", size = 13167, upload-time = "2024-02-14T23:35:18.353Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/33/bd5b9137445ea4b680023eb0469b2bb969d61303dedb2aac6560ff3d14a1/notebook_shim-0.2.4-py3-none-any.whl", hash = "sha256:411a5be4e9dc882a074ccbcae671eda64cceb068767e9a3419096986560e1cef", size = 13307, upload-time = "2024-02-14T23:35:16.286Z" }, +] + +[[package]] +name = "numba" +version = "0.66.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "llvmlite" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/a0/570e3dc53e5602b49108f62a13e529f1eec8bfc7ef37d49c825924dcf546/numba-0.66.0.tar.gz", hash = "sha256:b900e63a0e26c05ea9a6d5a3a5a0a177cb64c5011887bf43edb8c3ed2c38d363", size = 2806181, upload-time = "2026-07-01T23:12:46.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2b/48/d139bde40f2359351bfe26ee1b261937f458ac177ab810d4f045ae1c9d92/numba-0.66.0-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:27951c47e0def9bf8afe580eb961102902e2fd23cb77924b7d9d7cc0f8b444cb", size = 2727368, upload-time = "2026-07-01T23:12:04.282Z" }, + { url = "https://files.pythonhosted.org/packages/36/e4/b780bfa9191410da50ba249cb3248a75014e17f611e72709cbddcb21f42d/numba-0.66.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cc408c54b450f41582f4be1608f8981c1dcc44c7f40355cc150dd93015753407", size = 3803554, upload-time = "2026-07-01T23:12:06.379Z" }, + { url = "https://files.pythonhosted.org/packages/1c/b2/a051b96626bdf5c4d8fa6b8d450605c09638d85dc872ab63ef9a67096dca/numba-0.66.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7c14c044c06b453ec3fa7715dfe75425e2ba72c73377a7ffde6d9ec511dfd94c", size = 3510065, upload-time = "2026-07-01T23:12:08.051Z" }, + { url = "https://files.pythonhosted.org/packages/34/01/24dcdc3e919522e2efbd92969c281ff40deb1d5f8a994bcd0057081c158c/numba-0.66.0-cp310-cp310-win_amd64.whl", hash = "sha256:2338cc0d43609fe448930848fd35a5bc688761b986f81b597a6f45cc0f8c9577", size = 2780379, upload-time = "2026-07-01T23:12:09.772Z" }, + { url = "https://files.pythonhosted.org/packages/9e/02/970796b4daa709604cde22e87a7cda9bde473c278ea4a75f59fe38cee47f/numba-0.66.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:bbd531c327557a9004507fa6bff06c53ab51a7a5776b75261bb9cef1efe2b2ea", size = 2727049, upload-time = "2026-07-01T23:12:11.296Z" }, + { url = "https://files.pythonhosted.org/packages/8c/99/33a6ed9c1a0b5e42efa98eb0edf617d61dca576c82625947377b1d4540c9/numba-0.66.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc6629becb21a867d85401ec89f426dd24c484a4193ade8a38309debfd1529ca", size = 3808870, upload-time = "2026-07-01T23:12:12.944Z" }, + { url = "https://files.pythonhosted.org/packages/04/20/8c51126025211659235b8de2866dfa226984ae0c8273461a3cf374716741/numba-0.66.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aac69f3ccb8af100f5913c1241edc9692bad1cdd2508721713f426eb06c9a659", size = 3514498, upload-time = "2026-07-01T23:12:15.307Z" }, + { url = "https://files.pythonhosted.org/packages/5e/c9/9476940bc6d5caf5c0cf2e4c5feecbf01244bbe6f914614082dd7a3e520e/numba-0.66.0-cp311-cp311-win_amd64.whl", hash = "sha256:fb601841d9e02e6237bb6522e36d0741614be3cfe2b482a6f00a41b5ba209443", size = 2780225, upload-time = "2026-07-01T23:12:16.924Z" }, + { url = "https://files.pythonhosted.org/packages/62/a3/70deb7f88461c1cd5d16aa990c2380604102661a427667b8950dcdccc27f/numba-0.66.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:53ca5900b7cab15109796030113a6b28576bae5ad7bb507ad6dd1360ddd81ba4", size = 2727264, upload-time = "2026-07-01T23:12:18.669Z" }, + { url = "https://files.pythonhosted.org/packages/2d/55/25c319845e9a4e08f16611ddbda56a192eb7b6ed13e1a2bff2da272ffb97/numba-0.66.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0999e3ee1b18c48e1fb51d11af35ef59852c7f4f50569c9550c25faef0616ad1", size = 3866252, upload-time = "2026-07-01T23:12:20.429Z" }, + { url = "https://files.pythonhosted.org/packages/71/ef/a82d6fd6bf1b0fe461651e924d3647eeec9ac17f8eee4896264bf7480930/numba-0.66.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:efe0d2d5099790df945e0cb6e1b3104bd965d7bbfac50d62f1d5d1d6ade0825d", size = 3566974, upload-time = "2026-07-01T23:12:22.116Z" }, + { url = "https://files.pythonhosted.org/packages/fc/eb/9e6171e378822ab191c7abcfd3d8cfc8644516f6c7834c22e210e4acc070/numba-0.66.0-cp312-cp312-win_amd64.whl", hash = "sha256:b075a4e7ebc43dc6294f223e2821659656209fd5e0ce53245877c23d66d6e1a9", size = 2797403, upload-time = "2026-07-01T23:12:23.724Z" }, + { url = "https://files.pythonhosted.org/packages/03/52/176c02d005c5c5143cde10a85bbcdcb6236d9e34c3aac089380e0506cd1d/numba-0.66.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:380b2556a2019ccd1e956ae77dd257eaa39403f7520768b626d44b755112785e", size = 2727084, upload-time = "2026-07-01T23:12:25.434Z" }, + { url = "https://files.pythonhosted.org/packages/44/b5/e930010965568fe7f2c6c962fd2849d458cb9f62c3ab7584af8a19a2b40a/numba-0.66.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:939316d5d8619751207b8972a67852b5a7646665298cb4de693cd6bf135152f4", size = 3873663, upload-time = "2026-07-01T23:12:27.308Z" }, + { url = "https://files.pythonhosted.org/packages/d0/ec/5b51457cbe96e4831141d83e892e65191b23a1b78728456c62909d231ace/numba-0.66.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cdf506775d9f02eb92a87bf5c5b1e0d25506fd18cafd769f4ed914a8feac73e7", size = 3573529, upload-time = "2026-07-01T23:12:28.944Z" }, + { url = "https://files.pythonhosted.org/packages/83/7e/cea7710e96913d3c7f2999f16db1b28e6c5be5171cbf40f77f98333a7243/numba-0.66.0-cp313-cp313-win_amd64.whl", hash = "sha256:c5bfe5350284509ab0474390321454c3a8627a188af5b68c910e83df3e2db4a7", size = 2797247, upload-time = "2026-07-01T23:12:30.774Z" }, + { url = "https://files.pythonhosted.org/packages/96/7a/7e0e73550eb4e41ede6e72fb5371f4539537a4d770a3b73fa9b61aea0622/numba-0.66.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:46ae5f2b19e2af3c33c2df100306a90ea2f981c8158b0390f8bf6c20eee7357e", size = 2727296, upload-time = "2026-07-01T23:12:32.39Z" }, + { url = "https://files.pythonhosted.org/packages/0f/26/885774c006de6620ed3d10f45d8e20fe0b8e6aad6d573211a2cbc8b3e528/numba-0.66.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e2b101f23b8b63978d334574d2039f27f0dccfe1d891756f33a2e2f3e4c88cf4", size = 3842720, upload-time = "2026-07-01T23:12:33.938Z" }, + { url = "https://files.pythonhosted.org/packages/93/99/edebf7de890b73973d839dd971cf73734adfb81ffa1b4504f84b9059c3e5/numba-0.66.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:63b943eb2c9ba371908ce2cd6dfc643db51fc40f7966993376a1701bc922f537", size = 3543537, upload-time = "2026-07-01T23:12:35.566Z" }, + { url = "https://files.pythonhosted.org/packages/66/c5/b46ad28ac3681d035ea21365c5e052149062e1a0a9affd0563d2760ea6ff/numba-0.66.0-cp314-cp314-win_amd64.whl", hash = "sha256:bd57790acd20f6a468e0ad333ef6b82355e309a92310fb7dff80e919f01a21a9", size = 2799250, upload-time = "2026-07-01T23:12:37.154Z" }, + { url = "https://files.pythonhosted.org/packages/10/6f/5e77a7397a37dd16f57a7b72e7e470db5227b68e3639df0d13a8e674883d/numba-0.66.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:db7735d15ea17a283d6485b9fa3504769f78fd86e5146638ad5e8da57c031b9e", size = 2730342, upload-time = "2026-07-01T23:12:38.758Z" }, + { url = "https://files.pythonhosted.org/packages/39/fd/e9c9680a3813f3d781c20e5d53c1074801b787d4feecca0472fdd7c05ce1/numba-0.66.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:651a2b53298340956db26ecbe7ab043106b50a40c2807e66d617a4917245a4ab", size = 3878695, upload-time = "2026-07-01T23:12:40.302Z" }, + { url = "https://files.pythonhosted.org/packages/61/3a/9b363287b85fcd4537ea3878793822878b2ac1008a78159d2096fea628de/numba-0.66.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8c1144ba1720ea59ad79f4f488ed54d149b2613b357f7e445678b7d0739c70e9", size = 3596323, upload-time = "2026-07-01T23:12:42.805Z" }, + { url = "https://files.pythonhosted.org/packages/4c/f2/dca53d50b8f2289dd01954ace9da261e0487d5b74b188b4304e4ecc3492c/numba-0.66.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d426178fb991a85714c43112a8ea7b9d9579ea856ad8dcdb9c1c3941903ba5be", size = 2804772, upload-time = "2026-07-01T23:12:44.399Z" }, +] + +[[package]] +name = "numpy" +version = "2.2.6" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +sdist = { url = "https://files.pythonhosted.org/packages/76/21/7d2a95e4bba9dc13d043ee156a356c0a8f0c6309dff6b21b4d71a073b8a8/numpy-2.2.6.tar.gz", hash = "sha256:e29554e2bef54a90aa5cc07da6ce955accb83f21ab5de01a62c8478897b264fd", size = 20276440, upload-time = "2025-05-17T22:38:04.611Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3e/ed6db5be21ce87955c0cbd3009f2803f59fa08df21b5df06862e2d8e2bdd/numpy-2.2.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b412caa66f72040e6d268491a59f2c43bf03eb6c96dd8f0307829feb7fa2b6fb", size = 21165245, upload-time = "2025-05-17T21:27:58.555Z" }, + { url = "https://files.pythonhosted.org/packages/22/c2/4b9221495b2a132cc9d2eb862e21d42a009f5a60e45fc44b00118c174bff/numpy-2.2.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8e41fd67c52b86603a91c1a505ebaef50b3314de0213461c7a6e99c9a3beff90", size = 14360048, upload-time = "2025-05-17T21:28:21.406Z" }, + { url = "https://files.pythonhosted.org/packages/fd/77/dc2fcfc66943c6410e2bf598062f5959372735ffda175b39906d54f02349/numpy-2.2.6-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:37e990a01ae6ec7fe7fa1c26c55ecb672dd98b19c3d0e1d1f326fa13cb38d163", size = 5340542, upload-time = "2025-05-17T21:28:30.931Z" }, + { url = "https://files.pythonhosted.org/packages/7a/4f/1cb5fdc353a5f5cc7feb692db9b8ec2c3d6405453f982435efc52561df58/numpy-2.2.6-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:5a6429d4be8ca66d889b7cf70f536a397dc45ba6faeb5f8c5427935d9592e9cf", size = 6878301, upload-time = "2025-05-17T21:28:41.613Z" }, + { url = "https://files.pythonhosted.org/packages/eb/17/96a3acd228cec142fcb8723bd3cc39c2a474f7dcf0a5d16731980bcafa95/numpy-2.2.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:efd28d4e9cd7d7a8d39074a4d44c63eda73401580c5c76acda2ce969e0a38e83", size = 14297320, upload-time = "2025-05-17T21:29:02.78Z" }, + { url = "https://files.pythonhosted.org/packages/b4/63/3de6a34ad7ad6646ac7d2f55ebc6ad439dbbf9c4370017c50cf403fb19b5/numpy-2.2.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc7b73d02efb0e18c000e9ad8b83480dfcd5dfd11065997ed4c6747470ae8915", size = 16801050, upload-time = "2025-05-17T21:29:27.675Z" }, + { url = "https://files.pythonhosted.org/packages/07/b6/89d837eddef52b3d0cec5c6ba0456c1bf1b9ef6a6672fc2b7873c3ec4e2e/numpy-2.2.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:74d4531beb257d2c3f4b261bfb0fc09e0f9ebb8842d82a7b4209415896adc680", size = 15807034, upload-time = "2025-05-17T21:29:51.102Z" }, + { url = "https://files.pythonhosted.org/packages/01/c8/dc6ae86e3c61cfec1f178e5c9f7858584049b6093f843bca541f94120920/numpy-2.2.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8fc377d995680230e83241d8a96def29f204b5782f371c532579b4f20607a289", size = 18614185, upload-time = "2025-05-17T21:30:18.703Z" }, + { url = "https://files.pythonhosted.org/packages/5b/c5/0064b1b7e7c89137b471ccec1fd2282fceaae0ab3a9550f2568782d80357/numpy-2.2.6-cp310-cp310-win32.whl", hash = "sha256:b093dd74e50a8cba3e873868d9e93a85b78e0daf2e98c6797566ad8044e8363d", size = 6527149, upload-time = "2025-05-17T21:30:29.788Z" }, + { url = "https://files.pythonhosted.org/packages/a3/dd/4b822569d6b96c39d1215dbae0582fd99954dcbcf0c1a13c61783feaca3f/numpy-2.2.6-cp310-cp310-win_amd64.whl", hash = "sha256:f0fd6321b839904e15c46e0d257fdd101dd7f530fe03fd6359c1ea63738703f3", size = 12904620, upload-time = "2025-05-17T21:30:48.994Z" }, + { url = "https://files.pythonhosted.org/packages/da/a8/4f83e2aa666a9fbf56d6118faaaf5f1974d456b1823fda0a176eff722839/numpy-2.2.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f9f1adb22318e121c5c69a09142811a201ef17ab257a1e66ca3025065b7f53ae", size = 21176963, upload-time = "2025-05-17T21:31:19.36Z" }, + { url = "https://files.pythonhosted.org/packages/b3/2b/64e1affc7972decb74c9e29e5649fac940514910960ba25cd9af4488b66c/numpy-2.2.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c820a93b0255bc360f53eca31a0e676fd1101f673dda8da93454a12e23fc5f7a", size = 14406743, upload-time = "2025-05-17T21:31:41.087Z" }, + { url = "https://files.pythonhosted.org/packages/4a/9f/0121e375000b5e50ffdd8b25bf78d8e1a5aa4cca3f185d41265198c7b834/numpy-2.2.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:3d70692235e759f260c3d837193090014aebdf026dfd167834bcba43e30c2a42", size = 5352616, upload-time = "2025-05-17T21:31:50.072Z" }, + { url = "https://files.pythonhosted.org/packages/31/0d/b48c405c91693635fbe2dcd7bc84a33a602add5f63286e024d3b6741411c/numpy-2.2.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:481b49095335f8eed42e39e8041327c05b0f6f4780488f61286ed3c01368d491", size = 6889579, upload-time = "2025-05-17T21:32:01.712Z" }, + { url = "https://files.pythonhosted.org/packages/52/b8/7f0554d49b565d0171eab6e99001846882000883998e7b7d9f0d98b1f934/numpy-2.2.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b64d8d4d17135e00c8e346e0a738deb17e754230d7e0810ac5012750bbd85a5a", size = 14312005, upload-time = "2025-05-17T21:32:23.332Z" }, + { url = "https://files.pythonhosted.org/packages/b3/dd/2238b898e51bd6d389b7389ffb20d7f4c10066d80351187ec8e303a5a475/numpy-2.2.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba10f8411898fc418a521833e014a77d3ca01c15b0c6cdcce6a0d2897e6dbbdf", size = 16821570, upload-time = "2025-05-17T21:32:47.991Z" }, + { url = "https://files.pythonhosted.org/packages/83/6c/44d0325722cf644f191042bf47eedad61c1e6df2432ed65cbe28509d404e/numpy-2.2.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bd48227a919f1bafbdda0583705e547892342c26fb127219d60a5c36882609d1", size = 15818548, upload-time = "2025-05-17T21:33:11.728Z" }, + { url = "https://files.pythonhosted.org/packages/ae/9d/81e8216030ce66be25279098789b665d49ff19eef08bfa8cb96d4957f422/numpy-2.2.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9551a499bf125c1d4f9e250377c1ee2eddd02e01eac6644c080162c0c51778ab", size = 18620521, upload-time = "2025-05-17T21:33:39.139Z" }, + { url = "https://files.pythonhosted.org/packages/6a/fd/e19617b9530b031db51b0926eed5345ce8ddc669bb3bc0044b23e275ebe8/numpy-2.2.6-cp311-cp311-win32.whl", hash = "sha256:0678000bb9ac1475cd454c6b8c799206af8107e310843532b04d49649c717a47", size = 6525866, upload-time = "2025-05-17T21:33:50.273Z" }, + { url = "https://files.pythonhosted.org/packages/31/0a/f354fb7176b81747d870f7991dc763e157a934c717b67b58456bc63da3df/numpy-2.2.6-cp311-cp311-win_amd64.whl", hash = "sha256:e8213002e427c69c45a52bbd94163084025f533a55a59d6f9c5b820774ef3303", size = 12907455, upload-time = "2025-05-17T21:34:09.135Z" }, + { url = "https://files.pythonhosted.org/packages/82/5d/c00588b6cf18e1da539b45d3598d3557084990dcc4331960c15ee776ee41/numpy-2.2.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:41c5a21f4a04fa86436124d388f6ed60a9343a6f767fced1a8a71c3fbca038ff", size = 20875348, upload-time = "2025-05-17T21:34:39.648Z" }, + { url = "https://files.pythonhosted.org/packages/66/ee/560deadcdde6c2f90200450d5938f63a34b37e27ebff162810f716f6a230/numpy-2.2.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:de749064336d37e340f640b05f24e9e3dd678c57318c7289d222a8a2f543e90c", size = 14119362, upload-time = "2025-05-17T21:35:01.241Z" }, + { url = "https://files.pythonhosted.org/packages/3c/65/4baa99f1c53b30adf0acd9a5519078871ddde8d2339dc5a7fde80d9d87da/numpy-2.2.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:894b3a42502226a1cac872f840030665f33326fc3dac8e57c607905773cdcde3", size = 5084103, upload-time = "2025-05-17T21:35:10.622Z" }, + { url = "https://files.pythonhosted.org/packages/cc/89/e5a34c071a0570cc40c9a54eb472d113eea6d002e9ae12bb3a8407fb912e/numpy-2.2.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:71594f7c51a18e728451bb50cc60a3ce4e6538822731b2933209a1f3614e9282", size = 6625382, upload-time = "2025-05-17T21:35:21.414Z" }, + { url = "https://files.pythonhosted.org/packages/f8/35/8c80729f1ff76b3921d5c9487c7ac3de9b2a103b1cd05e905b3090513510/numpy-2.2.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2618db89be1b4e05f7a1a847a9c1c0abd63e63a1607d892dd54668dd92faf87", size = 14018462, upload-time = "2025-05-17T21:35:42.174Z" }, + { url = "https://files.pythonhosted.org/packages/8c/3d/1e1db36cfd41f895d266b103df00ca5b3cbe965184df824dec5c08c6b803/numpy-2.2.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd83c01228a688733f1ded5201c678f0c53ecc1006ffbc404db9f7a899ac6249", size = 16527618, upload-time = "2025-05-17T21:36:06.711Z" }, + { url = "https://files.pythonhosted.org/packages/61/c6/03ed30992602c85aa3cd95b9070a514f8b3c33e31124694438d88809ae36/numpy-2.2.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:37c0ca431f82cd5fa716eca9506aefcabc247fb27ba69c5062a6d3ade8cf8f49", size = 15505511, upload-time = "2025-05-17T21:36:29.965Z" }, + { url = "https://files.pythonhosted.org/packages/b7/25/5761d832a81df431e260719ec45de696414266613c9ee268394dd5ad8236/numpy-2.2.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fe27749d33bb772c80dcd84ae7e8df2adc920ae8297400dabec45f0dedb3f6de", size = 18313783, upload-time = "2025-05-17T21:36:56.883Z" }, + { url = "https://files.pythonhosted.org/packages/57/0a/72d5a3527c5ebffcd47bde9162c39fae1f90138c961e5296491ce778e682/numpy-2.2.6-cp312-cp312-win32.whl", hash = "sha256:4eeaae00d789f66c7a25ac5f34b71a7035bb474e679f410e5e1a94deb24cf2d4", size = 6246506, upload-time = "2025-05-17T21:37:07.368Z" }, + { url = "https://files.pythonhosted.org/packages/36/fa/8c9210162ca1b88529ab76b41ba02d433fd54fecaf6feb70ef9f124683f1/numpy-2.2.6-cp312-cp312-win_amd64.whl", hash = "sha256:c1f9540be57940698ed329904db803cf7a402f3fc200bfe599334c9bd84a40b2", size = 12614190, upload-time = "2025-05-17T21:37:26.213Z" }, + { url = "https://files.pythonhosted.org/packages/f9/5c/6657823f4f594f72b5471f1db1ab12e26e890bb2e41897522d134d2a3e81/numpy-2.2.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0811bb762109d9708cca4d0b13c4f67146e3c3b7cf8d34018c722adb2d957c84", size = 20867828, upload-time = "2025-05-17T21:37:56.699Z" }, + { url = "https://files.pythonhosted.org/packages/dc/9e/14520dc3dadf3c803473bd07e9b2bd1b69bc583cb2497b47000fed2fa92f/numpy-2.2.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:287cc3162b6f01463ccd86be154f284d0893d2b3ed7292439ea97eafa8170e0b", size = 14143006, upload-time = "2025-05-17T21:38:18.291Z" }, + { url = "https://files.pythonhosted.org/packages/4f/06/7e96c57d90bebdce9918412087fc22ca9851cceaf5567a45c1f404480e9e/numpy-2.2.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:f1372f041402e37e5e633e586f62aa53de2eac8d98cbfb822806ce4bbefcb74d", size = 5076765, upload-time = "2025-05-17T21:38:27.319Z" }, + { url = "https://files.pythonhosted.org/packages/73/ed/63d920c23b4289fdac96ddbdd6132e9427790977d5457cd132f18e76eae0/numpy-2.2.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:55a4d33fa519660d69614a9fad433be87e5252f4b03850642f88993f7b2ca566", size = 6617736, upload-time = "2025-05-17T21:38:38.141Z" }, + { url = "https://files.pythonhosted.org/packages/85/c5/e19c8f99d83fd377ec8c7e0cf627a8049746da54afc24ef0a0cb73d5dfb5/numpy-2.2.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f92729c95468a2f4f15e9bb94c432a9229d0d50de67304399627a943201baa2f", size = 14010719, upload-time = "2025-05-17T21:38:58.433Z" }, + { url = "https://files.pythonhosted.org/packages/19/49/4df9123aafa7b539317bf6d342cb6d227e49f7a35b99c287a6109b13dd93/numpy-2.2.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bc23a79bfabc5d056d106f9befb8d50c31ced2fbc70eedb8155aec74a45798f", size = 16526072, upload-time = "2025-05-17T21:39:22.638Z" }, + { url = "https://files.pythonhosted.org/packages/b2/6c/04b5f47f4f32f7c2b0e7260442a8cbcf8168b0e1a41ff1495da42f42a14f/numpy-2.2.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e3143e4451880bed956e706a3220b4e5cf6172ef05fcc397f6f36a550b1dd868", size = 15503213, upload-time = "2025-05-17T21:39:45.865Z" }, + { url = "https://files.pythonhosted.org/packages/17/0a/5cd92e352c1307640d5b6fec1b2ffb06cd0dabe7d7b8227f97933d378422/numpy-2.2.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b4f13750ce79751586ae2eb824ba7e1e8dba64784086c98cdbbcc6a42112ce0d", size = 18316632, upload-time = "2025-05-17T21:40:13.331Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3b/5cba2b1d88760ef86596ad0f3d484b1cbff7c115ae2429678465057c5155/numpy-2.2.6-cp313-cp313-win32.whl", hash = "sha256:5beb72339d9d4fa36522fc63802f469b13cdbe4fdab4a288f0c441b74272ebfd", size = 6244532, upload-time = "2025-05-17T21:43:46.099Z" }, + { url = "https://files.pythonhosted.org/packages/cb/3b/d58c12eafcb298d4e6d0d40216866ab15f59e55d148a5658bb3132311fcf/numpy-2.2.6-cp313-cp313-win_amd64.whl", hash = "sha256:b0544343a702fa80c95ad5d3d608ea3599dd54d4632df855e4c8d24eb6ecfa1c", size = 12610885, upload-time = "2025-05-17T21:44:05.145Z" }, + { url = "https://files.pythonhosted.org/packages/6b/9e/4bf918b818e516322db999ac25d00c75788ddfd2d2ade4fa66f1f38097e1/numpy-2.2.6-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0bca768cd85ae743b2affdc762d617eddf3bcf8724435498a1e80132d04879e6", size = 20963467, upload-time = "2025-05-17T21:40:44Z" }, + { url = "https://files.pythonhosted.org/packages/61/66/d2de6b291507517ff2e438e13ff7b1e2cdbdb7cb40b3ed475377aece69f9/numpy-2.2.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fc0c5673685c508a142ca65209b4e79ed6740a4ed6b2267dbba90f34b0b3cfda", size = 14225144, upload-time = "2025-05-17T21:41:05.695Z" }, + { url = "https://files.pythonhosted.org/packages/e4/25/480387655407ead912e28ba3a820bc69af9adf13bcbe40b299d454ec011f/numpy-2.2.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:5bd4fc3ac8926b3819797a7c0e2631eb889b4118a9898c84f585a54d475b7e40", size = 5200217, upload-time = "2025-05-17T21:41:15.903Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4a/6e313b5108f53dcbf3aca0c0f3e9c92f4c10ce57a0a721851f9785872895/numpy-2.2.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:fee4236c876c4e8369388054d02d0e9bb84821feb1a64dd59e137e6511a551f8", size = 6712014, upload-time = "2025-05-17T21:41:27.321Z" }, + { url = "https://files.pythonhosted.org/packages/b7/30/172c2d5c4be71fdf476e9de553443cf8e25feddbe185e0bd88b096915bcc/numpy-2.2.6-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e1dda9c7e08dc141e0247a5b8f49cf05984955246a327d4c48bda16821947b2f", size = 14077935, upload-time = "2025-05-17T21:41:49.738Z" }, + { url = "https://files.pythonhosted.org/packages/12/fb/9e743f8d4e4d3c710902cf87af3512082ae3d43b945d5d16563f26ec251d/numpy-2.2.6-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f447e6acb680fd307f40d3da4852208af94afdfab89cf850986c3ca00562f4fa", size = 16600122, upload-time = "2025-05-17T21:42:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/12/75/ee20da0e58d3a66f204f38916757e01e33a9737d0b22373b3eb5a27358f9/numpy-2.2.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:389d771b1623ec92636b0786bc4ae56abafad4a4c513d36a55dce14bd9ce8571", size = 15586143, upload-time = "2025-05-17T21:42:37.464Z" }, + { url = "https://files.pythonhosted.org/packages/76/95/bef5b37f29fc5e739947e9ce5179ad402875633308504a52d188302319c8/numpy-2.2.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8e9ace4a37db23421249ed236fdcdd457d671e25146786dfc96835cd951aa7c1", size = 18385260, upload-time = "2025-05-17T21:43:05.189Z" }, + { url = "https://files.pythonhosted.org/packages/09/04/f2f83279d287407cf36a7a8053a5abe7be3622a4363337338f2585e4afda/numpy-2.2.6-cp313-cp313t-win32.whl", hash = "sha256:038613e9fb8c72b0a41f025a7e4c3f0b7a1b5d768ece4796b674c8f3fe13efff", size = 6377225, upload-time = "2025-05-17T21:43:16.254Z" }, + { url = "https://files.pythonhosted.org/packages/67/0e/35082d13c09c02c011cf21570543d202ad929d961c02a147493cb0c2bdf5/numpy-2.2.6-cp313-cp313t-win_amd64.whl", hash = "sha256:6031dd6dfecc0cf9f668681a37648373bddd6421fff6c66ec1624eed0180ee06", size = 12771374, upload-time = "2025-05-17T21:43:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/9e/3b/d94a75f4dbf1ef5d321523ecac21ef23a3cd2ac8b78ae2aac40873590229/numpy-2.2.6-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0b605b275d7bd0c640cad4e5d30fa701a8d59302e127e5f79138ad62762c3e3d", size = 21040391, upload-time = "2025-05-17T21:44:35.948Z" }, + { url = "https://files.pythonhosted.org/packages/17/f4/09b2fa1b58f0fb4f7c7963a1649c64c4d315752240377ed74d9cd878f7b5/numpy-2.2.6-pp310-pypy310_pp73-macosx_14_0_x86_64.whl", hash = "sha256:7befc596a7dc9da8a337f79802ee8adb30a552a94f792b9c9d18c840055907db", size = 6786754, upload-time = "2025-05-17T21:44:47.446Z" }, + { url = "https://files.pythonhosted.org/packages/af/30/feba75f143bdc868a1cc3f44ccfa6c4b9ec522b36458e738cd00f67b573f/numpy-2.2.6-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce47521a4754c8f4593837384bd3424880629f718d87c5d44f8ed763edd63543", size = 16643476, upload-time = "2025-05-17T21:45:11.871Z" }, + { url = "https://files.pythonhosted.org/packages/37/48/ac2a9584402fb6c0cd5b5d1a91dcf176b15760130dd386bbafdbfe3640bf/numpy-2.2.6-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d042d24c90c41b54fd506da306759e06e568864df8ec17ccc17e9e884634fd00", size = 12812666, upload-time = "2025-05-17T21:45:31.426Z" }, +] + +[[package]] +name = "numpy" +version = "2.4.6" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.15' and sys_platform == 'win32'", + "python_full_version >= '3.15' and sys_platform == 'emscripten'", + "python_full_version >= '3.15' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.14.*' and sys_platform == 'win32'", + "python_full_version == '3.14.*' and sys_platform == 'emscripten'", + "python_full_version == '3.14.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", + "python_full_version == '3.11.*' and sys_platform == 'emscripten'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] +sdist = { url = "https://files.pythonhosted.org/packages/d0/ad/fed0499ce6a338d2a03ebae59cd15093910c8875328855781952abf6c2fe/numpy-2.4.6.tar.gz", hash = "sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda", size = 20735807, upload-time = "2026-05-18T23:37:14.07Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/49/ec46835a70be8fa6446c495126ac84fdb28cb2558e1620ffb87a10c8b64c/numpy-2.4.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0280e0356c0829a18d9de1cb7eee50ec22ca639878d7240307ca0943d73cd2c4", size = 16969194, upload-time = "2026-05-18T23:33:13.503Z" }, + { url = "https://files.pythonhosted.org/packages/0e/0d/f5957185c0ee2f3e12f78715aa9e3b353fd83633316c8532b38faa37e3f6/numpy-2.4.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:110f8b71aacb688ec69062bb7f6938a0f8acb01b7c1c4beb453c65b6d234584d", size = 14964111, upload-time = "2026-05-18T23:33:17.795Z" }, + { url = "https://files.pythonhosted.org/packages/ad/40/40a40ee0ddf7ceb782c49af278894b686e586d65d8c1889c8b5da01a3d7d/numpy-2.4.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:4cfe66903cc32a9921a6733d96b19bb6abf310397581bbad89c228f5abaf0ee8", size = 5469159, upload-time = "2026-05-18T23:33:20.654Z" }, + { url = "https://files.pythonhosted.org/packages/63/13/f9a8046535cb21deae82f8d03de9617e08882d274fad2539630761888228/numpy-2.4.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:8155154c7c691289fe18f510b5d4657c68c67989f293f0535a91360392ff6538", size = 6798936, upload-time = "2026-05-18T23:33:22.987Z" }, + { url = "https://files.pythonhosted.org/packages/33/a8/6fa8c1a345a8c85dbb21932c447bee07c30a2c2a3f31e369c0a84b300147/numpy-2.4.6-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ab0a9c4ffb1a6d95ef519fe4247dba8eb6b18ad93999f76b7f657039acabd47", size = 15966692, upload-time = "2026-05-18T23:33:26.62Z" }, + { url = "https://files.pythonhosted.org/packages/02/03/74fe2a4cb3817d94d86402f2506554130a2f01414e299b5a843e5a8a957f/numpy-2.4.6-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89cd468399cfd2504718f0ba50e410dca55a170b61a02ad92bb18c8a65186e93", size = 16918164, upload-time = "2026-05-18T23:33:29.955Z" }, + { url = "https://files.pythonhosted.org/packages/c5/80/3615be3313f7e7696609bc194b9f0101da809df79e859bdb84e0cd043f46/numpy-2.4.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c2d37ab77531417474168eb79d6d80b14f821a966818505d03013d0833edb7a8", size = 17322877, upload-time = "2026-05-18T23:33:34.724Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ac/a691e0fe2675e370d0e08ff905adc49a1c8830e8cae03efe4477e92cd55d/numpy-2.4.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f407cb6b8e9d6d8c626bc73c945db1706035af8fd632295547bf1c9e46d092d6", size = 18651487, upload-time = "2026-05-18T23:33:38.217Z" }, + { url = "https://files.pythonhosted.org/packages/15/a7/9bc1cd626d7bf6869bfedf27b91b6ab5dd607758bf8e959d6fa80c6a59cb/numpy-2.4.6-cp311-cp311-win32.whl", hash = "sha256:ddea102b48f9e339f3948bf22040944184627a30fdf7f858667673b9c5f033c8", size = 6233945, upload-time = "2026-05-18T23:33:41.331Z" }, + { url = "https://files.pythonhosted.org/packages/c5/31/7fc6239c12bce7e931463251cca4426c465e1876ba3cc785402ef4dd8f4e/numpy-2.4.6-cp311-cp311-win_amd64.whl", hash = "sha256:1e254a00cdf42b1e4d5b3d68d33af63268d41340d8885df2ab6470f2e1500147", size = 12608406, upload-time = "2026-05-18T23:33:44.131Z" }, + { url = "https://files.pythonhosted.org/packages/27/83/140f85a466595a16382996a1bf06b2b54bcd597488921b0c9daaeeda72af/numpy-2.4.6-cp311-cp311-win_arm64.whl", hash = "sha256:ed9749eef4cbd126da3dc1d6bcb3a57f5eb7ac6a6484146bdbf743f552dfc577", size = 10479528, upload-time = "2026-05-18T23:33:50.725Z" }, + { url = "https://files.pythonhosted.org/packages/95/2a/3d7b5ac8aac24feaf9ad7ed58f45b0bbc06d37e4338ae84c9f2298b570f9/numpy-2.4.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:001fbb8e08d942dd57599e781f2472269ee7f2755fae407b4f67b2f0b17da3f1", size = 16689119, upload-time = "2026-05-18T23:33:54.065Z" }, + { url = "https://files.pythonhosted.org/packages/ea/12/92c4c131527599e8288d6918e888d88726f84d805d784b771f32408aeaef/numpy-2.4.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ebfb099f8dcf083deef3ac1ca4c1503f387cf76296fcb3816b66f5ecb5f54fdb", size = 14699246, upload-time = "2026-05-18T23:33:57.621Z" }, + { url = "https://files.pythonhosted.org/packages/ad/fe/c0a6b7b2ca128a8fb228575147073b660656734b8ebe4d76c8fd748dcc79/numpy-2.4.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:3213d622a0283a39a93d188f3cf72b26862df52fbb4ca3697f51705016523d41", size = 5204410, upload-time = "2026-05-18T23:34:00.302Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d4/9770d14ba719432bb90a421bfd443872ed0f70f7264b64bec12ea363d5fd/numpy-2.4.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:357cc07a6d7b0b182ff02249616a03742827ebb1277546b5c7cd7f7620a45698", size = 6551240, upload-time = "2026-05-18T23:34:02.852Z" }, + { url = "https://files.pythonhosted.org/packages/c9/c6/50a46a6205feba2343f1d6d17438107c5dc491ed1c736e6ea68689fd906b/numpy-2.4.6-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f9fb9157b4ce2971008323afe46053787b526ef624fea915b261468a8421a0f", size = 15671012, upload-time = "2026-05-18T23:34:05.485Z" }, + { url = "https://files.pythonhosted.org/packages/99/60/14115e6364fa676c5397c2ad3004e527e9aa487abf5d0706ec81bbd08529/numpy-2.4.6-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90f9849678c75fe7afa2d348ac842c168b0a4d3d61919687216dfc547976d853", size = 16645538, upload-time = "2026-05-18T23:34:09.265Z" }, + { url = "https://files.pythonhosted.org/packages/ae/c5/693cbe59e57db94d2231fa519ca3978dc9e19da5a8f088588f5c6e947ff2/numpy-2.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c1a2af6c6ef86344a6b0db6b97834208bf598db514f2b155042439b62605601a", size = 17020706, upload-time = "2026-05-18T23:34:13.053Z" }, + { url = "https://files.pythonhosted.org/packages/ef/fc/85b7c4eff9b4966ade25c2273cf7e7012e92366c032058653934b37de044/numpy-2.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e5805d5a22fd19c8ccff10a9561f9df94436b0545619ea579db2d3c35294bce2", size = 18368541, upload-time = "2026-05-18T23:34:17.024Z" }, + { url = "https://files.pythonhosted.org/packages/f6/81/e1b27545deedce7f4a0b348618c6b62d74e36a4dc9ccd42f3eb2f85eee32/numpy-2.4.6-cp312-cp312-win32.whl", hash = "sha256:e3eeb0aabd6bd5ce64faae67e9935203a6991b4bc2a485a767fbafb2c5125f45", size = 5962825, upload-time = "2026-05-18T23:34:20.3Z" }, + { url = "https://files.pythonhosted.org/packages/ab/ca/feab00bd44aa5fe1ad2c18f08b4d3bb92e26484b0b1d1443897809ed528c/numpy-2.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:d8e8286dd7cea7895157318d1b91cdacac64c479f3cbc8dce548331728484751", size = 12321687, upload-time = "2026-05-18T23:34:23.095Z" }, + { url = "https://files.pythonhosted.org/packages/63/cf/5a6d34850a39d1093558564f77ee8e8e0bee5061151b8f05a55711001ec7/numpy-2.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:4081eb135ac24158bd51cdfbef16f1c64df7063b1143f24731387137c092bec8", size = 10221482, upload-time = "2026-05-18T23:34:25.876Z" }, + { url = "https://files.pythonhosted.org/packages/fb/82/bdab26d7438c6791ca31b7c024ca37c1eab8b726ba236129005cd4a06e45/numpy-2.4.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:511dbaf848decaaaf4b4ca48032619fb3138710c4bf7da7617765edad1ef96b0", size = 16684648, upload-time = "2026-05-18T23:34:29.41Z" }, + { url = "https://files.pythonhosted.org/packages/1b/30/a80189bcc7f5e4258b3fbc3968d909d1756f54d023299ecc39ad6fdb9ef8/numpy-2.4.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bf162abab1c1a736333192707cef898e735a5ca00f38f27eeedf44b39d9e85eb", size = 14693902, upload-time = "2026-05-18T23:34:33.013Z" }, + { url = "https://files.pythonhosted.org/packages/97/12/70b5d0d7c15e1ebb8a6a84a8caa1d19e181d84fb58bb6d70aca29099dec1/numpy-2.4.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:043191bfa8eab18c776647b62723ac9dddece59743b13f49b2016094129c2b3f", size = 5198992, upload-time = "2026-05-18T23:34:36.132Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8c/ebd2a8f8a83541f8d38cc5667e8c2b69cecfd30da6e45693e8158857d44b/numpy-2.4.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:6180d8b35af935aed8ece3a85e0a43f87393ae0ac87c8d2c8bd2c993f7270ef3", size = 6546944, upload-time = "2026-05-18T23:34:38.484Z" }, + { url = "https://files.pythonhosted.org/packages/bb/c5/7b863a97a91671a0338f4253bd3b5a3d3852f0692dae91711c9f4a10e787/numpy-2.4.6-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72fbe16c6fac95aedf5937fa873445cec2110be35d8a4e9433d7501fd98dae6b", size = 15669392, upload-time = "2026-05-18T23:34:41.257Z" }, + { url = "https://files.pythonhosted.org/packages/a5/9d/3584b9984ca4c047aea75214ce1a4c4c73d849bd71b604264b7f5653f8a8/numpy-2.4.6-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7830bab239b79cda9c08c2da014761cafb48da6150e1da17ac06283f43b6089", size = 16633220, upload-time = "2026-05-18T23:34:45.075Z" }, + { url = "https://files.pythonhosted.org/packages/05/ae/7c67fba23bd98caec7c99261f3a16072ade14813486b0282cb29846de832/numpy-2.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ef4aea96ce4d3b074422cb4f2f64e216bf9e213004bb58ecfdf50ea02ea8eb9a", size = 17020800, upload-time = "2026-05-18T23:34:49.065Z" }, + { url = "https://files.pythonhosted.org/packages/d9/5d/3b6725cb31d983c5e66916f5d36f6d7e5521129e4c4404d64f918292a5b6/numpy-2.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dfa20cc6ca228e6b155b11da03825975ce66aea520985dbbddf0f2a5a495c605", size = 18357600, upload-time = "2026-05-18T23:34:52.709Z" }, + { url = "https://files.pythonhosted.org/packages/f7/da/2ccc6c2fe8898dee01d90c75c5f5f914a23daf99e3e0f59516a08760c8b5/numpy-2.4.6-cp313-cp313-win32.whl", hash = "sha256:56b39e5e0622a09a25bf5baf62f4bcf0cb8a41ae6e2819cf49bbc5a74c083f91", size = 5961134, upload-time = "2026-05-18T23:34:55.618Z" }, + { url = "https://files.pythonhosted.org/packages/b5/cd/9cc4dc876fb065d5c220aae4d5e14826b2715331bb7618ce1fb07a679d99/numpy-2.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:c4fc99836233ea196540b17ab0983aff60ed07941751930f5f4d05bc3b3b7359", size = 12318598, upload-time = "2026-05-18T23:34:58.928Z" }, + { url = "https://files.pythonhosted.org/packages/39/1e/c0bcba1f8694116485fe28fd1be698c278fcda4141c5b0e53a2aed8b12a8/numpy-2.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a7c711e21628b52034bb5ab8d1bce291f752fcc5e92accc615778acee1ff4778", size = 10222272, upload-time = "2026-05-18T23:35:02.167Z" }, + { url = "https://files.pythonhosted.org/packages/63/6d/cc5619247c8f4204e507f5883528372e4ac4bb189e579fb859a12e480b1f/numpy-2.4.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:112b06a867b235ef466ed3508ddf0238050df9c727cafb5301ac385b899189a1", size = 14821197, upload-time = "2026-05-18T23:35:05.468Z" }, + { url = "https://files.pythonhosted.org/packages/00/58/f1c39161c87d9e9bed660f1ed4bafc0e403d5ec9650b6dd77aead07d489b/numpy-2.4.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:eaf7fa2de5c0be8ae6ff8e9bea2ccd725e980541244521d8d4b5f3354a27babe", size = 5326287, upload-time = "2026-05-18T23:35:08.693Z" }, + { url = "https://files.pythonhosted.org/packages/af/57/3917ab0fd97f271a8694513581b8a36c655f111c446852c302f04ccdb6fc/numpy-2.4.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:7265a2f3d436e54ef9f2b52b5c937e6be778781bd97a590319d7348f1c1ca997", size = 6646763, upload-time = "2026-05-18T23:35:11.459Z" }, + { url = "https://files.pythonhosted.org/packages/eb/0f/037e64c494b67581ae18193d770adef354c41f3f2c8ebf865602d949bf8f/numpy-2.4.6-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f74a575920ab21fe304421a3fc28793d82e299cae9eccb37084e9fc7f3617c20", size = 15728070, upload-time = "2026-05-18T23:35:14.79Z" }, + { url = "https://files.pythonhosted.org/packages/21/a6/5d2bae9c9542eb4df16dc9c46dc79c186e9bad53805dfa5399a6023c6db0/numpy-2.4.6-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ede83e07a75dd06bc501566c1eca2afc0d61677c1472ac9ad93fdee6e638a48d", size = 16681752, upload-time = "2026-05-18T23:35:18.836Z" }, + { url = "https://files.pythonhosted.org/packages/92/14/23d1dfb410ae362cd59ce53e936b1513d545eb40db3949ced632e19a459e/numpy-2.4.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:68bb27509ac1b9a3443094260f6326150663b06abe40b73a2f81160623da5b67", size = 17086024, upload-time = "2026-05-18T23:35:22.52Z" }, + { url = "https://files.pythonhosted.org/packages/4b/6e/23595a2c642cdf3bc567877064bdd7f91c8b0038a4453cf2daf7248eafe9/numpy-2.4.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a0df0043bdb289bde1f62da130d20df23d58b45429f752bc7a8fc5325a225ecd", size = 18403398, upload-time = "2026-05-18T23:35:26.398Z" }, + { url = "https://files.pythonhosted.org/packages/8a/90/0ac3bc947217e66dec77e7cbc6a1979d1af70b6461b82f620d3bccd5e4c8/numpy-2.4.6-cp313-cp313t-win32.whl", hash = "sha256:29a287e0cf63ff528da061de6b9f64a4618da591ca1046aafc54062e40ca7eab", size = 6084971, upload-time = "2026-05-18T23:35:29.387Z" }, + { url = "https://files.pythonhosted.org/packages/77/71/5673e351671a1d2bd6063b91b44f70c0affea7d1516fa7a6572941ba4aa1/numpy-2.4.6-cp313-cp313t-win_amd64.whl", hash = "sha256:25c692919ac5a01f170a3bfcd62d745b24fd095c353d50812637d6fcab442e75", size = 12458532, upload-time = "2026-05-18T23:35:32.175Z" }, + { url = "https://files.pythonhosted.org/packages/3f/88/19d3503c5046e688f049274b27a3ef3d771152fa80d3ba3d01a3dff61abe/numpy-2.4.6-cp313-cp313t-win_arm64.whl", hash = "sha256:1e978ec1e8bd0e0e4de6bb75de9d30cbb74db6b6a2bb727618613703ca0167dd", size = 10291881, upload-time = "2026-05-18T23:35:35.465Z" }, + { url = "https://files.pythonhosted.org/packages/f8/91/3ab2044d05fd16d343c5ac2e69b127f1b2854040dd20b193257c78028bd3/numpy-2.4.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06ca2f61ec4385a07a6977c55ba998a4466c123642b4a32694d3128fce18c079", size = 16683458, upload-time = "2026-05-18T23:35:38.353Z" }, + { url = "https://files.pythonhosted.org/packages/8e/62/764ce66fa4147ae6d73071a3abf804ffe606f174618697c571acdf26a7c9/numpy-2.4.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:38efbc8de75c7a0fc1ac190162d892787f3f47b57cc291231aafee36b80982b7", size = 14704559, upload-time = "2026-05-18T23:35:42.14Z" }, + { url = "https://files.pythonhosted.org/packages/60/61/23f27c172f022e04025b7dc2367f4d63c1a398120607ec896228649a6f48/numpy-2.4.6-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:d581b735e177fdcdce6fed8e7e8880a3fb6ee4e3653a3ac6af01c6f4c03effc5", size = 5209716, upload-time = "2026-05-18T23:35:45.377Z" }, + { url = "https://files.pythonhosted.org/packages/03/71/21cf70dc6ea3e3acb95fc53a265b2fc248b981f0194ceb5b475271b8809d/numpy-2.4.6-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:0a041d3d761dc3c35cc56ce0351506a02bcbc25f7b169f652435141a17db9096", size = 6543947, upload-time = "2026-05-18T23:35:47.926Z" }, + { url = "https://files.pythonhosted.org/packages/d5/91/64288395ee1799bd2e0b04a305dce9666da90c961e1f3fe982a05ee1c036/numpy-2.4.6-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40fdc1ae7125e518ea98e53e69a4ebc27e1fd50510c47b7ea130cf21e5e1d42b", size = 15685197, upload-time = "2026-05-18T23:35:50.863Z" }, + { url = "https://files.pythonhosted.org/packages/f3/eb/ebffaa97dc55502df69584a8f0dcf07f69a3e0b3e2323670a2722db9aa39/numpy-2.4.6-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2c306dea656c12c68f51f4cea133cbe78ca7435eb28c735eac1d3ebe73be6e8", size = 16638245, upload-time = "2026-05-18T23:35:54.752Z" }, + { url = "https://files.pythonhosted.org/packages/b8/0b/54f9da33128d7e350fab89c7455902eeae70349ee52bddb448dc4a576f45/numpy-2.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:33111801a01c12a8a1e3721f0a9232f8cfc8ae2c6b7098167e6f623c6073f402", size = 17036587, upload-time = "2026-05-18T23:35:58.355Z" }, + { url = "https://files.pythonhosted.org/packages/b6/f0/fdebc1052db1cc37c64beb22072d67cd6d1c71adca1299f53dec2b5e20d3/numpy-2.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ae506e6902902557576a26ff33eda8695e7ecb3cb36c3b573a0765dee114ebdb", size = 18363226, upload-time = "2026-05-18T23:36:02.845Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b4/298628d98c72b57e57f7165ae6a481a1deaf6f3c28262a6e4c739c275930/numpy-2.4.6-cp314-cp314-win32.whl", hash = "sha256:aaf159caa35993cb1f56fb9b8e4610d35758e7ca005412eb1daa856a78c9c4b1", size = 6010196, upload-time = "2026-05-18T23:36:05.92Z" }, + { url = "https://files.pythonhosted.org/packages/df/ac/46de6dda46478f7942f839e094970be2d4a861e005c4b3bf07c92e291a09/numpy-2.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:b507f5c4c1d508876d1819b6bf9a49d365b96320b5d4993426b33a23ca4b8261", size = 12450334, upload-time = "2026-05-18T23:36:09.107Z" }, + { url = "https://files.pythonhosted.org/packages/78/92/b8b798ac784102c0da830d2257d59358e3d3d90d1e2b3f2575dad976c5cf/numpy-2.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:6f41ae150c4e32db4f3310cdaf64b1593a03dbabe29eec77fc9b50fe64061df6", size = 10495678, upload-time = "2026-05-18T23:36:12.766Z" }, + { url = "https://files.pythonhosted.org/packages/30/34/ec28d1aa8115971537c01469ab2011ee96827930f0a124de1000cc2a7ed7/numpy-2.4.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ece3d2cfe132e7d51f44a832b303895e6f2d499c5e74dfbdb06ee246147a304a", size = 14823672, upload-time = "2026-05-18T23:36:16.473Z" }, + { url = "https://files.pythonhosted.org/packages/16/bd/f6d1fede4e54e8042a7ff97bb495510f3c220f94bcd9e8b228e87c92cc0d/numpy-2.4.6-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:e3e5193ef5a3dc73bceee50f7fdc2c90dbb76c42df8d8fae3d1067a583df579e", size = 5328731, upload-time = "2026-05-18T23:36:19.767Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f0/e105b9e2fd728a9910103884decd6951d9dd73896b914a98d9a231de02ee/numpy-2.4.6-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:17f9ade344e7d9b464a084d69bcf18fc691cb1db67c62ed80820bf4926d78f0e", size = 6649805, upload-time = "2026-05-18T23:36:22.266Z" }, + { url = "https://files.pythonhosted.org/packages/82/dd/1206a7ca6ab15e3f02069707ca96222e202af681bb73756da7527f3cb837/numpy-2.4.6-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cd5ffd25db4e7ba6a375693b3fc0fc1791ec636c17db3720da19bde7180ec43", size = 15730496, upload-time = "2026-05-18T23:36:25.713Z" }, + { url = "https://files.pythonhosted.org/packages/51/e7/38d3ea825dcab85a591734decb2f6c67caa7c8367d374df1a1c3842f9b07/numpy-2.4.6-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d92c3819208a60205a12a245c91ad70cb0a85336659b19b834205573ac8456e", size = 16679616, upload-time = "2026-05-18T23:36:29.652Z" }, + { url = "https://files.pythonhosted.org/packages/93/b7/caabfdf53edf663e0b4eb74d7d405d83baef09eb5e83bcd32d601d72b93e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e85b752a1e912b70eaad4fafbd4d1238007ab221de2009b9a2f5ae7461239895", size = 17085145, upload-time = "2026-05-18T23:36:33.449Z" }, + { url = "https://files.pythonhosted.org/packages/f9/45/68d7c33a6bcf3e5aa3bdbd57a367e6f615286dfd6482f97e8ffeb734306e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:29cb7f67d10b479ff07c17d33e39f78c07f71c40ef30d63c153d340e96cd3fb4", size = 18403813, upload-time = "2026-05-18T23:36:37.369Z" }, + { url = "https://files.pythonhosted.org/packages/9c/50/0753655aa844c99cd9e018aacf76f130f1bd81d881bb74bc0aef5d73a8ba/numpy-2.4.6-cp314-cp314t-win32.whl", hash = "sha256:260a5d70215b61ab4fadf5c7baacd64821842975eea312125ed3c39a6391b063", size = 6156982, upload-time = "2026-05-18T23:36:40.817Z" }, + { url = "https://files.pythonhosted.org/packages/b2/d4/7c67becf668f973cb490cec3e98dfd799d866f9c989a54d355672cfa0db6/numpy-2.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:81a1cca95ed5bb92aa8b10dd2cdc9a0d3853a50fad926c28b5d7e8ea54389627", size = 12638908, upload-time = "2026-05-18T23:36:43.996Z" }, + { url = "https://files.pythonhosted.org/packages/43/bb/e1c71a4295b1b1d1393d50dbb4f2a36283c6859d9d3892e84f00ec5a91d5/numpy-2.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:0c9136e14ed34a9e343a31c533d78a9813a69a3148332bce5e9821cb2f996e66", size = 10565867, upload-time = "2026-05-18T23:36:47.114Z" }, + { url = "https://files.pythonhosted.org/packages/de/12/b422cc84439adc0d00de605bf4a308890ae5c26f2c71fbd73e5d08fbb0dd/numpy-2.4.6-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:55cced7c52e981362f708ad635198e97a752dfba412cc03c23bbf3bd8d5cd662", size = 16847511, upload-time = "2026-05-18T23:36:50.673Z" }, + { url = "https://files.pythonhosted.org/packages/44/53/f481bef68011740f8849418d82db07230e825013f31f4eef5ba5b805316a/numpy-2.4.6-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d6da64deb6b8ed903e7560180a92f2d804ee1ba5eeb849ac2748b8c1aba1f6d7", size = 14889064, upload-time = "2026-05-18T23:36:53.879Z" }, + { url = "https://files.pythonhosted.org/packages/7f/57/42ed575c10ced8af951d426bc4e1f8aff16fd851db33f067036215a7f860/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:68a5124b13fa6cc2086764a20005d30bc0548146f7f5322f02fce212ca14317f", size = 5394157, upload-time = "2026-05-18T23:36:57.194Z" }, + { url = "https://files.pythonhosted.org/packages/6a/ef/f66cc724fcc36c1e364c67f51ae9146090b8b584f27d58b97fdae3edd737/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:948424b06129ce883307e8cff868c31396d8dc7630a59c61d70d98dbe70f222c", size = 6708728, upload-time = "2026-05-18T23:36:59.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/9c/c531f2293b91265d8b48e9b329f54fdd7ffae73cb4134ea10cca4237e9cc/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbbdb29840ca3d91ee0fece42fc29278886d908280bfec0a5846c6f901a3eb0", size = 15798374, upload-time = "2026-05-18T23:37:02.674Z" }, + { url = "https://files.pythonhosted.org/packages/1a/b0/413077f6b1153ed3cba361401c6783bbad6114804a000cc22eb71c13e190/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ad03c0965fb3c692200e74d458ca28c1dbb4ce96f9a479a8aa041ad5fabca02", size = 16747286, upload-time = "2026-05-18T23:37:06.327Z" }, + { url = "https://files.pythonhosted.org/packages/15/ce/e5ec180bc41812edcd8daeb8639d205622c0e8c02259d8ab25a0201b3c2a/numpy-2.4.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:2803abfebfc990042cd494d8ce2d5f82e9d847af6d35ec486923aa19dbad5e73", size = 12504263, upload-time = "2026-05-18T23:37:09.715Z" }, +] + +[[package]] +name = "overrides" +version = "7.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/36/86/b585f53236dec60aba864e050778b25045f857e17f6e5ea0ae95fe80edd2/overrides-7.7.0.tar.gz", hash = "sha256:55158fa3d93b98cc75299b1e67078ad9003ca27945c76162c1c0766d6f91820a", size = 22812, upload-time = "2024-01-27T21:01:33.423Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/ab/fc8290c6a4c722e5514d80f62b2dc4c4df1a68a41d1364e625c35990fcf3/overrides-7.7.0-py3-none-any.whl", hash = "sha256:c7ed9d062f78b8e4c1a7b70bd8796b35ead4d9f510227ef9c5dc7626c60d7e49", size = 17832, upload-time = "2024-01-27T21:01:31.393Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pandas" +version = "2.3.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +dependencies = [ + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } }, + { name = "python-dateutil" }, + { name = "pytz" }, + { name = "tzdata" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/33/01/d40b85317f86cf08d853a4f495195c73815fdf205eef3993821720274518/pandas-2.3.3.tar.gz", hash = "sha256:e05e1af93b977f7eafa636d043f9f94c7ee3ac81af99c13508215942e64c993b", size = 4495223, upload-time = "2025-09-29T23:34:51.853Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/f7/f425a00df4fcc22b292c6895c6831c0c8ae1d9fac1e024d16f98a9ce8749/pandas-2.3.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:376c6446ae31770764215a6c937f72d917f214b43560603cd60da6408f183b6c", size = 11555763, upload-time = "2025-09-29T23:16:53.287Z" }, + { url = "https://files.pythonhosted.org/packages/13/4f/66d99628ff8ce7857aca52fed8f0066ce209f96be2fede6cef9f84e8d04f/pandas-2.3.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e19d192383eab2f4ceb30b412b22ea30690c9e618f78870357ae1d682912015a", size = 10801217, upload-time = "2025-09-29T23:17:04.522Z" }, + { url = "https://files.pythonhosted.org/packages/1d/03/3fc4a529a7710f890a239cc496fc6d50ad4a0995657dccc1d64695adb9f4/pandas-2.3.3-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5caf26f64126b6c7aec964f74266f435afef1c1b13da3b0636c7518a1fa3e2b1", size = 12148791, upload-time = "2025-09-29T23:17:18.444Z" }, + { url = "https://files.pythonhosted.org/packages/40/a8/4dac1f8f8235e5d25b9955d02ff6f29396191d4e665d71122c3722ca83c5/pandas-2.3.3-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dd7478f1463441ae4ca7308a70e90b33470fa593429f9d4c578dd00d1fa78838", size = 12769373, upload-time = "2025-09-29T23:17:35.846Z" }, + { url = "https://files.pythonhosted.org/packages/df/91/82cc5169b6b25440a7fc0ef3a694582418d875c8e3ebf796a6d6470aa578/pandas-2.3.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:4793891684806ae50d1288c9bae9330293ab4e083ccd1c5e383c34549c6e4250", size = 13200444, upload-time = "2025-09-29T23:17:49.341Z" }, + { url = "https://files.pythonhosted.org/packages/10/ae/89b3283800ab58f7af2952704078555fa60c807fff764395bb57ea0b0dbd/pandas-2.3.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:28083c648d9a99a5dd035ec125d42439c6c1c525098c58af0fc38dd1a7a1b3d4", size = 13858459, upload-time = "2025-09-29T23:18:03.722Z" }, + { url = "https://files.pythonhosted.org/packages/85/72/530900610650f54a35a19476eca5104f38555afccda1aa11a92ee14cb21d/pandas-2.3.3-cp310-cp310-win_amd64.whl", hash = "sha256:503cf027cf9940d2ceaa1a93cfb5f8c8c7e6e90720a2850378f0b3f3b1e06826", size = 11346086, upload-time = "2025-09-29T23:18:18.505Z" }, + { url = "https://files.pythonhosted.org/packages/c1/fa/7ac648108144a095b4fb6aa3de1954689f7af60a14cf25583f4960ecb878/pandas-2.3.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:602b8615ebcc4a0c1751e71840428ddebeb142ec02c786e8ad6b1ce3c8dec523", size = 11578790, upload-time = "2025-09-29T23:18:30.065Z" }, + { url = "https://files.pythonhosted.org/packages/9b/35/74442388c6cf008882d4d4bdfc4109be87e9b8b7ccd097ad1e7f006e2e95/pandas-2.3.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8fe25fc7b623b0ef6b5009149627e34d2a4657e880948ec3c840e9402e5c1b45", size = 10833831, upload-time = "2025-09-29T23:38:56.071Z" }, + { url = "https://files.pythonhosted.org/packages/fe/e4/de154cbfeee13383ad58d23017da99390b91d73f8c11856f2095e813201b/pandas-2.3.3-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b468d3dad6ff947df92dcb32ede5b7bd41a9b3cceef0a30ed925f6d01fb8fa66", size = 12199267, upload-time = "2025-09-29T23:18:41.627Z" }, + { url = "https://files.pythonhosted.org/packages/bf/c9/63f8d545568d9ab91476b1818b4741f521646cbdd151c6efebf40d6de6f7/pandas-2.3.3-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b98560e98cb334799c0b07ca7967ac361a47326e9b4e5a7dfb5ab2b1c9d35a1b", size = 12789281, upload-time = "2025-09-29T23:18:56.834Z" }, + { url = "https://files.pythonhosted.org/packages/f2/00/a5ac8c7a0e67fd1a6059e40aa08fa1c52cc00709077d2300e210c3ce0322/pandas-2.3.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37b5848ba49824e5c30bedb9c830ab9b7751fd049bc7914533e01c65f79791", size = 13240453, upload-time = "2025-09-29T23:19:09.247Z" }, + { url = "https://files.pythonhosted.org/packages/27/4d/5c23a5bc7bd209231618dd9e606ce076272c9bc4f12023a70e03a86b4067/pandas-2.3.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:db4301b2d1f926ae677a751eb2bd0e8c5f5319c9cb3f88b0becbbb0b07b34151", size = 13890361, upload-time = "2025-09-29T23:19:25.342Z" }, + { url = "https://files.pythonhosted.org/packages/8e/59/712db1d7040520de7a4965df15b774348980e6df45c129b8c64d0dbe74ef/pandas-2.3.3-cp311-cp311-win_amd64.whl", hash = "sha256:f086f6fe114e19d92014a1966f43a3e62285109afe874f067f5abbdcbb10e59c", size = 11348702, upload-time = "2025-09-29T23:19:38.296Z" }, + { url = "https://files.pythonhosted.org/packages/9c/fb/231d89e8637c808b997d172b18e9d4a4bc7bf31296196c260526055d1ea0/pandas-2.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d21f6d74eb1725c2efaa71a2bfc661a0689579b58e9c0ca58a739ff0b002b53", size = 11597846, upload-time = "2025-09-29T23:19:48.856Z" }, + { url = "https://files.pythonhosted.org/packages/5c/bd/bf8064d9cfa214294356c2d6702b716d3cf3bb24be59287a6a21e24cae6b/pandas-2.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3fd2f887589c7aa868e02632612ba39acb0b8948faf5cc58f0850e165bd46f35", size = 10729618, upload-time = "2025-09-29T23:39:08.659Z" }, + { url = "https://files.pythonhosted.org/packages/57/56/cf2dbe1a3f5271370669475ead12ce77c61726ffd19a35546e31aa8edf4e/pandas-2.3.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecaf1e12bdc03c86ad4a7ea848d66c685cb6851d807a26aa245ca3d2017a1908", size = 11737212, upload-time = "2025-09-29T23:19:59.765Z" }, + { url = "https://files.pythonhosted.org/packages/e5/63/cd7d615331b328e287d8233ba9fdf191a9c2d11b6af0c7a59cfcec23de68/pandas-2.3.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b3d11d2fda7eb164ef27ffc14b4fcab16a80e1ce67e9f57e19ec0afaf715ba89", size = 12362693, upload-time = "2025-09-29T23:20:14.098Z" }, + { url = "https://files.pythonhosted.org/packages/a6/de/8b1895b107277d52f2b42d3a6806e69cfef0d5cf1d0ba343470b9d8e0a04/pandas-2.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a68e15f780eddf2b07d242e17a04aa187a7ee12b40b930bfdd78070556550e98", size = 12771002, upload-time = "2025-09-29T23:20:26.76Z" }, + { url = "https://files.pythonhosted.org/packages/87/21/84072af3187a677c5893b170ba2c8fbe450a6ff911234916da889b698220/pandas-2.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:371a4ab48e950033bcf52b6527eccb564f52dc826c02afd9a1bc0ab731bba084", size = 13450971, upload-time = "2025-09-29T23:20:41.344Z" }, + { url = "https://files.pythonhosted.org/packages/86/41/585a168330ff063014880a80d744219dbf1dd7a1c706e75ab3425a987384/pandas-2.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:a16dcec078a01eeef8ee61bf64074b4e524a2a3f4b3be9326420cabe59c4778b", size = 10992722, upload-time = "2025-09-29T23:20:54.139Z" }, + { url = "https://files.pythonhosted.org/packages/cd/4b/18b035ee18f97c1040d94debd8f2e737000ad70ccc8f5513f4eefad75f4b/pandas-2.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:56851a737e3470de7fa88e6131f41281ed440d29a9268dcbf0002da5ac366713", size = 11544671, upload-time = "2025-09-29T23:21:05.024Z" }, + { url = "https://files.pythonhosted.org/packages/31/94/72fac03573102779920099bcac1c3b05975c2cb5f01eac609faf34bed1ca/pandas-2.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdcd9d1167f4885211e401b3036c0c8d9e274eee67ea8d0758a256d60704cfe8", size = 10680807, upload-time = "2025-09-29T23:21:15.979Z" }, + { url = "https://files.pythonhosted.org/packages/16/87/9472cf4a487d848476865321de18cc8c920b8cab98453ab79dbbc98db63a/pandas-2.3.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e32e7cc9af0f1cc15548288a51a3b681cc2a219faa838e995f7dc53dbab1062d", size = 11709872, upload-time = "2025-09-29T23:21:27.165Z" }, + { url = "https://files.pythonhosted.org/packages/15/07/284f757f63f8a8d69ed4472bfd85122bd086e637bf4ed09de572d575a693/pandas-2.3.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:318d77e0e42a628c04dc56bcef4b40de67918f7041c2b061af1da41dcff670ac", size = 12306371, upload-time = "2025-09-29T23:21:40.532Z" }, + { url = "https://files.pythonhosted.org/packages/33/81/a3afc88fca4aa925804a27d2676d22dcd2031c2ebe08aabd0ae55b9ff282/pandas-2.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4e0a175408804d566144e170d0476b15d78458795bb18f1304fb94160cabf40c", size = 12765333, upload-time = "2025-09-29T23:21:55.77Z" }, + { url = "https://files.pythonhosted.org/packages/8d/0f/b4d4ae743a83742f1153464cf1a8ecfafc3ac59722a0b5c8602310cb7158/pandas-2.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:93c2d9ab0fc11822b5eece72ec9587e172f63cff87c00b062f6e37448ced4493", size = 13418120, upload-time = "2025-09-29T23:22:10.109Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c7/e54682c96a895d0c808453269e0b5928a07a127a15704fedb643e9b0a4c8/pandas-2.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:f8bfc0e12dc78f777f323f55c58649591b2cd0c43534e8355c51d3fede5f4dee", size = 10993991, upload-time = "2025-09-29T23:25:04.889Z" }, + { url = "https://files.pythonhosted.org/packages/f9/ca/3f8d4f49740799189e1395812f3bf23b5e8fc7c190827d55a610da72ce55/pandas-2.3.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:75ea25f9529fdec2d2e93a42c523962261e567d250b0013b16210e1d40d7c2e5", size = 12048227, upload-time = "2025-09-29T23:22:24.343Z" }, + { url = "https://files.pythonhosted.org/packages/0e/5a/f43efec3e8c0cc92c4663ccad372dbdff72b60bdb56b2749f04aa1d07d7e/pandas-2.3.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:74ecdf1d301e812db96a465a525952f4dde225fdb6d8e5a521d47e1f42041e21", size = 11411056, upload-time = "2025-09-29T23:22:37.762Z" }, + { url = "https://files.pythonhosted.org/packages/46/b1/85331edfc591208c9d1a63a06baa67b21d332e63b7a591a5ba42a10bb507/pandas-2.3.3-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6435cb949cb34ec11cc9860246ccb2fdc9ecd742c12d3304989017d53f039a78", size = 11645189, upload-time = "2025-09-29T23:22:51.688Z" }, + { url = "https://files.pythonhosted.org/packages/44/23/78d645adc35d94d1ac4f2a3c4112ab6f5b8999f4898b8cdf01252f8df4a9/pandas-2.3.3-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:900f47d8f20860de523a1ac881c4c36d65efcb2eb850e6948140fa781736e110", size = 12121912, upload-time = "2025-09-29T23:23:05.042Z" }, + { url = "https://files.pythonhosted.org/packages/53/da/d10013df5e6aaef6b425aa0c32e1fc1f3e431e4bcabd420517dceadce354/pandas-2.3.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a45c765238e2ed7d7c608fc5bc4a6f88b642f2f01e70c0c23d2224dd21829d86", size = 12712160, upload-time = "2025-09-29T23:23:28.57Z" }, + { url = "https://files.pythonhosted.org/packages/bd/17/e756653095a083d8a37cbd816cb87148debcfcd920129b25f99dd8d04271/pandas-2.3.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c4fc4c21971a1a9f4bdb4c73978c7f7256caa3e62b323f70d6cb80db583350bc", size = 13199233, upload-time = "2025-09-29T23:24:24.876Z" }, + { url = "https://files.pythonhosted.org/packages/04/fd/74903979833db8390b73b3a8a7d30d146d710bd32703724dd9083950386f/pandas-2.3.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:ee15f284898e7b246df8087fc82b87b01686f98ee67d85a17b7ab44143a3a9a0", size = 11540635, upload-time = "2025-09-29T23:25:52.486Z" }, + { url = "https://files.pythonhosted.org/packages/21/00/266d6b357ad5e6d3ad55093a7e8efc7dd245f5a842b584db9f30b0f0a287/pandas-2.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1611aedd912e1ff81ff41c745822980c49ce4a7907537be8692c8dbc31924593", size = 10759079, upload-time = "2025-09-29T23:26:33.204Z" }, + { url = "https://files.pythonhosted.org/packages/ca/05/d01ef80a7a3a12b2f8bbf16daba1e17c98a2f039cbc8e2f77a2c5a63d382/pandas-2.3.3-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d2cefc361461662ac48810cb14365a365ce864afe85ef1f447ff5a1e99ea81c", size = 11814049, upload-time = "2025-09-29T23:27:15.384Z" }, + { url = "https://files.pythonhosted.org/packages/15/b2/0e62f78c0c5ba7e3d2c5945a82456f4fac76c480940f805e0b97fcbc2f65/pandas-2.3.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ee67acbbf05014ea6c763beb097e03cd629961c8a632075eeb34247120abcb4b", size = 12332638, upload-time = "2025-09-29T23:27:51.625Z" }, + { url = "https://files.pythonhosted.org/packages/c5/33/dd70400631b62b9b29c3c93d2feee1d0964dc2bae2e5ad7a6c73a7f25325/pandas-2.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c46467899aaa4da076d5abc11084634e2d197e9460643dd455ac3db5856b24d6", size = 12886834, upload-time = "2025-09-29T23:28:21.289Z" }, + { url = "https://files.pythonhosted.org/packages/d3/18/b5d48f55821228d0d2692b34fd5034bb185e854bdb592e9c640f6290e012/pandas-2.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6253c72c6a1d990a410bc7de641d34053364ef8bcd3126f7e7450125887dffe3", size = 13409925, upload-time = "2025-09-29T23:28:58.261Z" }, + { url = "https://files.pythonhosted.org/packages/a6/3d/124ac75fcd0ecc09b8fdccb0246ef65e35b012030defb0e0eba2cbbbe948/pandas-2.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:1b07204a219b3b7350abaae088f451860223a52cfb8a6c53358e7948735158e5", size = 11109071, upload-time = "2025-09-29T23:32:27.484Z" }, + { url = "https://files.pythonhosted.org/packages/89/9c/0e21c895c38a157e0faa1fb64587a9226d6dd46452cac4532d80c3c4a244/pandas-2.3.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2462b1a365b6109d275250baaae7b760fd25c726aaca0054649286bcfbb3e8ec", size = 12048504, upload-time = "2025-09-29T23:29:31.47Z" }, + { url = "https://files.pythonhosted.org/packages/d7/82/b69a1c95df796858777b68fbe6a81d37443a33319761d7c652ce77797475/pandas-2.3.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0242fe9a49aa8b4d78a4fa03acb397a58833ef6199e9aa40a95f027bb3a1b6e7", size = 11410702, upload-time = "2025-09-29T23:29:54.591Z" }, + { url = "https://files.pythonhosted.org/packages/f9/88/702bde3ba0a94b8c73a0181e05144b10f13f29ebfc2150c3a79062a8195d/pandas-2.3.3-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a21d830e78df0a515db2b3d2f5570610f5e6bd2e27749770e8bb7b524b89b450", size = 11634535, upload-time = "2025-09-29T23:30:21.003Z" }, + { url = "https://files.pythonhosted.org/packages/a4/1e/1bac1a839d12e6a82ec6cb40cda2edde64a2013a66963293696bbf31fbbb/pandas-2.3.3-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2e3ebdb170b5ef78f19bfb71b0dc5dc58775032361fa188e814959b74d726dd5", size = 12121582, upload-time = "2025-09-29T23:30:43.391Z" }, + { url = "https://files.pythonhosted.org/packages/44/91/483de934193e12a3b1d6ae7c8645d083ff88dec75f46e827562f1e4b4da6/pandas-2.3.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d051c0e065b94b7a3cea50eb1ec32e912cd96dba41647eb24104b6c6c14c5788", size = 12699963, upload-time = "2025-09-29T23:31:10.009Z" }, + { url = "https://files.pythonhosted.org/packages/70/44/5191d2e4026f86a2a109053e194d3ba7a31a2d10a9c2348368c63ed4e85a/pandas-2.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3869faf4bd07b3b66a9f462417d0ca3a9df29a9f6abd5d0d0dbab15dac7abe87", size = 13202175, upload-time = "2025-09-29T23:31:59.173Z" }, +] + +[[package]] +name = "pandas" +version = "3.0.5" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.15' and sys_platform == 'win32'", + "python_full_version >= '3.15' and sys_platform == 'emscripten'", + "python_full_version >= '3.15' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.14.*' and sys_platform == 'win32'", + "python_full_version == '3.14.*' and sys_platform == 'emscripten'", + "python_full_version == '3.14.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", + "python_full_version == '3.11.*' and sys_platform == 'emscripten'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] +dependencies = [ + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" } }, + { name = "python-dateutil" }, + { name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/be/4f/5f3422a2afec5ffc46308b79e53291365a93748b498ac2e58bead0197916/pandas-3.0.5.tar.gz", hash = "sha256:dca3734d6ab7c906e6730f0788b0a1dbb9f2467731f9711f77995c8e9d62d712", size = 4658219, upload-time = "2026-07-22T22:19:28.819Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/ef/f1fd7431d635bf20015489bf0bd69c17fff1018de773540f651455a3916b/pandas-3.0.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2946e77e4a53cd248cbde631a12f0e51c8324ce354c3eba4d20147c1ad6f4282", size = 10397178, upload-time = "2026-07-22T22:17:48.274Z" }, + { url = "https://files.pythonhosted.org/packages/31/b4/0eafac990a431561187694126de01f9b12559549b4d86360c0c4bd870fde/pandas-3.0.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:71ecc8fb7ed1a7aa4392316b5309a6347e8e7f832f38fd897846b3a1457a9298", size = 9990736, upload-time = "2026-07-22T22:17:52.388Z" }, + { url = "https://files.pythonhosted.org/packages/de/21/359880af3ea9b7cb23bea5b51e8e70ef3866c03be09da9a2787e18e330a8/pandas-3.0.5-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b173f5951ff6b8b0ec7675e20dff3c97b7e7a57dfcce387c2d7c5afe87cb7899", size = 10814438, upload-time = "2026-07-22T22:17:54.708Z" }, + { url = "https://files.pythonhosted.org/packages/d1/50/d6cc4d7e508bbccf5d6027314a8312bc7ac73d0ec7f195f53838daafab40/pandas-3.0.5-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2c0cf1dd9b55a22d105fc46c1b489af3bd42264fcba7c66297bf47a9a1d9c78a", size = 11323634, upload-time = "2026-07-22T22:17:56.858Z" }, + { url = "https://files.pythonhosted.org/packages/70/2b/d5f0a8c90dd0ae04e64ba53b871afb796ec026b615086d382ddc2ade729b/pandas-3.0.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0fac0010c75e4efb6b99e249c183a8993ce0dc95c240f9b120a5e67c727b7928", size = 11850860, upload-time = "2026-07-22T22:17:59.1Z" }, + { url = "https://files.pythonhosted.org/packages/5c/30/183aec2e19adf778a98d29b5729a0a68f4cc4ebf9b9c3b70d0297355bcb1/pandas-3.0.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:08d24fe11a17dc33bd6e937dc9c665f9cba08fbdc9f657f405713515febe300d", size = 12411100, upload-time = "2026-07-22T22:18:01.485Z" }, + { url = "https://files.pythonhosted.org/packages/fa/9a/31f4983f191af51ab2a8f2d0c7b33dff3a84da26533f982fff02c2f9e28b/pandas-3.0.5-cp311-cp311-win_amd64.whl", hash = "sha256:b1261758dfb6cf12c3cff8300e21cefad30e7ec709abb4c24ac7318e6a52462a", size = 9968804, upload-time = "2026-07-22T22:18:03.903Z" }, + { url = "https://files.pythonhosted.org/packages/49/97/7886c89a39045c69ad82cbceaf3343810480c8ef49a216319ce8183860a6/pandas-3.0.5-cp311-cp311-win_arm64.whl", hash = "sha256:679f4e85b30ddb1515458ab1e788d3e260eae369b1f78da7a3aa4cac8ebf4a2a", size = 9205447, upload-time = "2026-07-22T22:18:06.134Z" }, + { url = "https://files.pythonhosted.org/packages/1c/54/1dc810ea558d1320b597aa140a514f2fdf1d2ea09c38cf556f13ea712ec9/pandas-3.0.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fa290c16964d4963fbfbc358928239cf3bd755b20e988ce944877def2f44471d", size = 10411717, upload-time = "2026-07-22T22:18:08.307Z" }, + { url = "https://files.pythonhosted.org/packages/68/56/fbe81c09195924d8b7b8d4461a20458fe80a6a5ed6b24f0314da684277e1/pandas-3.0.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c2e26bb46934b8a2ca0c3de1d3d606fc5f6746584791b2db264d58cf370e08dc", size = 9957095, upload-time = "2026-07-22T22:18:10.6Z" }, + { url = "https://files.pythonhosted.org/packages/e0/51/fac252f4a913ed5eabf3c11b880a9e8d5a6c10f0b2129d0462212d238b4d/pandas-3.0.5-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:73fa87b08a7ef706f8aafda39ddaccf2a99047bea62d8c88a0361bcafb2237bc", size = 10485458, upload-time = "2026-07-22T22:18:12.834Z" }, + { url = "https://files.pythonhosted.org/packages/12/98/e976540c1addf70442be7842a18cf70884a964abbf69442504f4d2939989/pandas-3.0.5-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d373ce03ffd84010ed9839fa73672a9c8256990532e158440c0085db7d914b34", size = 10998091, upload-time = "2026-07-22T22:18:15.209Z" }, + { url = "https://files.pythonhosted.org/packages/a4/8c/1f29b5be8d3fc47dd7567eb167fabba2085879b31e0287ce7cba6d3d2ff4/pandas-3.0.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2a29c53d85ea98c5e792c59ef82ee9fbe6ca902c0d0adb6b23f45ef894cd7bf6", size = 11499501, upload-time = "2026-07-22T22:18:17.689Z" }, + { url = "https://files.pythonhosted.org/packages/9d/e2/bd9c98ad2df7b38bde002adde4cdf353519da51881634323b126c55997f9/pandas-3.0.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a5ad3b02ed6bc7d7ae9b70804b2c6aa31827489d150f8e623ce82491b82085d7", size = 12060559, upload-time = "2026-07-22T22:18:20.147Z" }, + { url = "https://files.pythonhosted.org/packages/f3/9a/ffbd852d58bd74a617fe2f8ee6a58a96982271ce41cf981eab22190b4a4b/pandas-3.0.5-cp312-cp312-pyemscripten_2024_0_wasm32.whl", hash = "sha256:b2acb4650527eec6822c3dadb2b771277b65e7dae7a267d4bccf65fd1bb3fbce", size = 7197652, upload-time = "2026-07-22T22:18:22.502Z" }, + { url = "https://files.pythonhosted.org/packages/70/b5/d2d3e9ae73362ba4229651b0ee1455cf78073a1ce585f6ff693782ce263e/pandas-3.0.5-cp312-cp312-win_amd64.whl", hash = "sha256:80a611068e8a3ac23f7398c6c14eb46dc974e5cc9997f653e2dcfd1da74edd41", size = 9831691, upload-time = "2026-07-22T22:18:24.534Z" }, + { url = "https://files.pythonhosted.org/packages/52/51/dea1e89d6a6796b9c43f85a09b484ee03edb8a4c4842e73e200a8c11301c/pandas-3.0.5-cp312-cp312-win_arm64.whl", hash = "sha256:25ff585b972a18ef1fe9ffa3ac6544d9950508aa76832e5147640b6022821e49", size = 9105796, upload-time = "2026-07-22T22:18:27.064Z" }, + { url = "https://files.pythonhosted.org/packages/bf/09/7b95c4a0025227d6f118c4039b423412ac6a982db02864166185d812fbc7/pandas-3.0.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c1c05a767fe8e5b4fe9e1c29806829c582052eaedb9120a3da83ba3f69e24a5b", size = 10385742, upload-time = "2026-07-22T22:18:29.346Z" }, + { url = "https://files.pythonhosted.org/packages/8d/0c/dc78fd8c4da477b4b5e8ad37295af352190d21ef63a9ee1bc071753074cc/pandas-3.0.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b86765f268b56f7e665b93bce9d5df69dee7f99e595cf8fb839483ab315942a3", size = 9932067, upload-time = "2026-07-22T22:18:31.833Z" }, + { url = "https://files.pythonhosted.org/packages/3e/71/3592c055cf44df9808550f9368ceda80ff2b224d355ef73fe251dcda1802/pandas-3.0.5-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c597ecf5616b5c420372c1d4d4c00dbbfba7398bea857dcc984347e1ea48417b", size = 10466756, upload-time = "2026-07-22T22:18:34.195Z" }, + { url = "https://files.pythonhosted.org/packages/e3/70/4363150359f95b4cb4bcbb34ca23572bb5495749a621a8f3d5a1ddfd293c/pandas-3.0.5-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4b11c36e218331d0387cbe3a0a5f75162357a1d92d57b2b08a336ff94b19b2be", size = 10938525, upload-time = "2026-07-22T22:18:36.81Z" }, + { url = "https://files.pythonhosted.org/packages/f7/d0/317e7a0c67c0e69fa905a0161409397a7dc2d46ff611f6ca4803352c042b/pandas-3.0.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cf52e1f61d229496da17dc7ab54acdee627357e7008fd4fecba3d0ba2937fa58", size = 11489303, upload-time = "2026-07-22T22:18:39.287Z" }, + { url = "https://files.pythonhosted.org/packages/f1/8d/36dade89b49e4f9d5cbdbe863772581f98c0c6d78fc39ad4c557f6f2e17e/pandas-3.0.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:db172144bb56422bd157812f3b021eacc255451470b31e2c633c349490a1cfee", size = 11989004, upload-time = "2026-07-22T22:18:42.208Z" }, + { url = "https://files.pythonhosted.org/packages/9c/ba/18c4ec8a746e177da05a9e7a7963781d8ea195780724f854601b6ebd6b78/pandas-3.0.5-cp313-cp313-win_amd64.whl", hash = "sha256:0d298e951f23016ce4699951d044ae6418dbc91bf68cefca0f77666fcbb4e5c6", size = 9826896, upload-time = "2026-07-22T22:18:44.539Z" }, + { url = "https://files.pythonhosted.org/packages/de/ec/28a57266b753799a87b8bc79e7887ac6fd981b8c6d2978a0b7e7b6bd708c/pandas-3.0.5-cp313-cp313-win_arm64.whl", hash = "sha256:66266d3442a5e8b3c90274c2b8b230bee42dd1c286bc822cc2f9f2c7e12b883e", size = 9094790, upload-time = "2026-07-22T22:18:47.468Z" }, + { url = "https://files.pythonhosted.org/packages/51/2f/cf6aae281264f4463f0875bcbb15fd2bb6d291cc535187dad1732475e4a9/pandas-3.0.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2f264fc46911cc8131a7322a16199bbf8e353d27c10bb211f5bd0c814324dc36", size = 10390034, upload-time = "2026-07-22T22:18:49.818Z" }, + { url = "https://files.pythonhosted.org/packages/06/ec/5189518c7a7659c4bdcc6b1eb32c46c6f3c86b0661ffd84143d1112c7732/pandas-3.0.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:53730687fcd161883b24e10411c06d6a4c0f2275d2faf3bb2bc25deb4ba8007c", size = 9980065, upload-time = "2026-07-22T22:18:52.249Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f1/598503ce8d7e3c35601e0747ba288c7864baae66380725bc12f13f884dfe/pandas-3.0.5-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:960d3ebcf249f75206899fcd2c6de53f736b7265759ced0d3e559df0b8b709b0", size = 10545532, upload-time = "2026-07-22T22:18:54.813Z" }, + { url = "https://files.pythonhosted.org/packages/fa/de/ceae2adf7034e07e9910299fe412e1819c4f0dd520700a888bcb03625448/pandas-3.0.5-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9e94c2c5ca43bd3ca32bf64d32308887b65e5f9bfd8023ea52755107a999f93b", size = 10963120, upload-time = "2026-07-22T22:18:57.42Z" }, + { url = "https://files.pythonhosted.org/packages/66/25/86e0f4451874eb79e688deeebe3c451fec4557f8952005818d800ee8ac7e/pandas-3.0.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e819dd5f62966b481a8cb649d3299ebd886a1ea91ed5a99bf7ce77c98d18ab94", size = 11563178, upload-time = "2026-07-22T22:18:59.729Z" }, + { url = "https://files.pythonhosted.org/packages/f3/45/8643daa3b4147e433adfcccefdd0380d3aad79d86b15d8999730fe1944d5/pandas-3.0.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3c5ed2e7c06e91d340dfd091d7934f9bc82e4a36b95f647f090b9d1c9ac649da", size = 12028708, upload-time = "2026-07-22T22:19:02.164Z" }, + { url = "https://files.pythonhosted.org/packages/96/58/ad979ae617615576e8aafd569c9d4b62f1191d896e38f51d66ba06f3b89a/pandas-3.0.5-cp314-cp314-win_amd64.whl", hash = "sha256:cd8f7c6dc98527058ee6264219343f5392240a6f1bfa654fc5d79023020d0c92", size = 9951806, upload-time = "2026-07-22T22:19:04.596Z" }, + { url = "https://files.pythonhosted.org/packages/69/32/7ac03886b304049a9d2625ee88f59af760d8a93bd30ed9239bce7b9869a8/pandas-3.0.5-cp314-cp314-win_arm64.whl", hash = "sha256:5183427f5a8156d480f30333777bc978be93650a49a7c01db26adffe95b31e85", size = 9238297, upload-time = "2026-07-22T22:19:06.836Z" }, + { url = "https://files.pythonhosted.org/packages/be/ed/1d1f2ee5547d5167face2376d11c8b2a4c7bfff5a416ee7a9046891fab1e/pandas-3.0.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:303da736987d481074ca720ada325f8bd80c64ebc2d45ed79b29df3aaa4a26ca", size = 10849690, upload-time = "2026-07-22T22:19:09.391Z" }, + { url = "https://files.pythonhosted.org/packages/57/55/17e17152e98fbb0c4b1e562bc65387a2f20a80db0f4a86bf8d3a0e4248d4/pandas-3.0.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3b2801bbb049d0136f6c213eae02b5fca969384fc2064dd728d8620552aa49da", size = 10509945, upload-time = "2026-07-22T22:19:11.773Z" }, + { url = "https://files.pythonhosted.org/packages/88/90/817d44dbf83facf9556f33576d9af0a241981e7bb5c00606c0bcb5df8dda/pandas-3.0.5-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cce3a9d11d2b1f82c69a27ec1f4948a170e2c403c4bbfa8cca62e3fdebe2ef3a", size = 10392197, upload-time = "2026-07-22T22:19:14.024Z" }, + { url = "https://files.pythonhosted.org/packages/f1/da/889f00c0a6f5aa1545add70abbf01502dff87ab577adb855bd631c54d2f2/pandas-3.0.5-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef01af4d8dc6cd2c8d6c7736f149574ef93fe043811eeb5e445f2647154b5040", size = 10862726, upload-time = "2026-07-22T22:19:16.351Z" }, + { url = "https://files.pythonhosted.org/packages/bc/98/f1e934fb3c98fce859c6147c6785816c7b5b9ab7821115c5d8c4de9842b9/pandas-3.0.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e2759e890db96dfcffdbd9b86c3c2cb6afaf58def482820317e06163ec1066cd", size = 11414864, upload-time = "2026-07-22T22:19:18.981Z" }, + { url = "https://files.pythonhosted.org/packages/fe/be/d448af7d657d82e1888dd8551f79c6d6fb161080b5b9752d84d910ec2319/pandas-3.0.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b58b1b39d46a5862e3fb18f50d1a201398619d16a0f9f73f57eea5583cf0e63c", size = 11925105, upload-time = "2026-07-22T22:19:21.515Z" }, + { url = "https://files.pythonhosted.org/packages/29/c1/ccb4238212c8c4f496c584f3044d94e0c030ed8e1d68999db46c91c2242f/pandas-3.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:1c10461f6eeb35d8f05b6184c65c8b9991663b66c46b1d559b682cb34ae7c6ea", size = 10387612, upload-time = "2026-07-22T22:19:24.257Z" }, + { url = "https://files.pythonhosted.org/packages/d2/cf/6a51b2c38980e04c279fd2fa908a1b0982064e860444acfca4ec2e2c8359/pandas-3.0.5-cp314-cp314t-win_arm64.whl", hash = "sha256:3c5015fd1730fbf883647e88068176c839c102cea883ba1769a6f4593bfc1f8c", size = 9509776, upload-time = "2026-07-22T22:19:26.694Z" }, +] + +[[package]] +name = "pandocfilters" +version = "1.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/70/6f/3dd4940bbe001c06a65f88e36bad298bc7a0de5036115639926b0c5c0458/pandocfilters-1.5.1.tar.gz", hash = "sha256:002b4a555ee4ebc03f8b66307e287fa492e4a77b4ea14d3f934328297bb4939e", size = 8454, upload-time = "2024-01-18T20:08:13.726Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/af/4fbc8cab944db5d21b7e2a5b8e9211a03a79852b1157e2c102fcc61ac440/pandocfilters-1.5.1-py2.py3-none-any.whl", hash = "sha256:93be382804a9cdb0a7267585f157e5d1731bbe5545a85b268d6f5fe6232de2bc", size = 8663, upload-time = "2024-01-18T20:08:11.28Z" }, +] + +[[package]] +name = "param" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/79/9d/33f32981c9cda89fe22abff60f7c71ef4af3e566b6af530392f8b938470d/param-2.4.1.tar.gz", hash = "sha256:364a33bd8a968b053d8a49d319af78dc31b3ccb9661ecb95c29a3ea509d7e443", size = 217776, upload-time = "2026-06-09T13:17:33.665Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ac/37/1351bbfabbacbe92cd38774f8779ff680fab48c36b5fed9bcfe0c160009c/param-2.4.1-py3-none-any.whl", hash = "sha256:6cdc0869b3ade232fc6d01691223cf86c2bdb53a6d5ba67a33dba838d8b8cf4c", size = 151874, upload-time = "2026-06-09T13:17:31.996Z" }, +] + +[[package]] +name = "parso" +version = "0.8.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/30/4b/90c937815137d43ce71ba043cd3566221e9df6b9c805f24b5d138c9d40a7/parso-0.8.7.tar.gz", hash = "sha256:eaaac4c9fdd5e9e8852dc778d2d7405897ec510f2a298071453e5e3a07914bb1", size = 401824, upload-time = "2026-05-01T23:13:02.138Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/5d/8268b644392ee874ee82a635cd0df1773de230bde356c38de28e298392cc/parso-0.8.7-py2.py3-none-any.whl", hash = "sha256:a8926eb2a1b915486941fdbd31e86a4baf88fe8c210f25f2f35ecec5b574ca1c", size = 107025, upload-time = "2026-05-01T23:12:58.867Z" }, +] + +[[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 = "pexpect" +version = "4.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ptyprocess" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450, upload-time = "2023-11-25T09:07:26.339Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523", size = 63772, upload-time = "2023-11-25T06:56:14.81Z" }, +] + +[[package]] +name = "pillow" +version = "12.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/c2/669d88644cddb1485bd9534e63e8cf476c8e51cb3c3a1297677023505c0e/pillow-12.3.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:6c0016e7b354317c4e9e525b937ac8596c38d2d232b419529b9cd7a1cd46e39a", size = 5392418, upload-time = "2026-07-01T11:53:27.808Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ba/3762f376a2948e3036488d773a146e0ae6ecc2ca03ac20e2615bd0b2ba02/pillow-12.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:bcc33feacfaefce60c12fd500a277533bdc02b10a19f7f6d348763d8140bbba7", size = 4785287, upload-time = "2026-07-01T11:53:29.761Z" }, + { url = "https://files.pythonhosted.org/packages/07/50/b5d688cc9c52d4482f3d5bcab6ce20bc2a74a85d2343841c907444a3be2c/pillow-12.3.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5594fc43d548a7ed94949d139aa1341b270f1863f11cfd37f5a6c8b778a6b67f", size = 6253754, upload-time = "2026-07-01T11:53:32.298Z" }, + { url = "https://files.pythonhosted.org/packages/4e/89/36f4cd76cf4baf05c50ababb976249153f18c959171c7f6ba09a6f217260/pillow-12.3.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f0606c8bf2cdefea14a43530f7657cbbb7ecf1c4222512492ef4a4434a9501ec", size = 6925605, upload-time = "2026-07-01T11:53:34.487Z" }, + { url = "https://files.pythonhosted.org/packages/eb/c0/4de58cf6633b9e3a6061ef4be6fb91fc3c90b812ece886f531e3c523d777/pillow-12.3.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:85f998ea1848bc6757289e739cfbdda3a04adfd58b02fc018ce54d754a5ce468", size = 6327788, upload-time = "2026-07-01T11:53:36.433Z" }, + { url = "https://files.pythonhosted.org/packages/87/3c/14d53682a19550dbbaf3b598f807d5457646c510805a44c7d7891cd1cd1a/pillow-12.3.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:25b9b82bb22e6e2b3cd07b39c68b7b862001226cb3dff7130d1cb914121b39ed", size = 7036288, upload-time = "2026-07-01T11:53:38.712Z" }, + { url = "https://files.pythonhosted.org/packages/38/1d/36279e3c77efe034e4cc2b0393ee74ffdb5a62391dacbf9b916154f5f0b8/pillow-12.3.0-cp310-cp310-win32.whl", hash = "sha256:37dc8f7bbb66efe481bb60defacef820c950c24713fb44962ed6aa2a50966de1", size = 6472396, upload-time = "2026-07-01T11:53:40.781Z" }, + { url = "https://files.pythonhosted.org/packages/48/7c/8fa0039574c476d7c6fa57dd7c32a130436877c6ec1e5ce1cc8ec44878c1/pillow-12.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:300557495eb45ebb8aec96c2da9c4be642fbf7cd937278b4013ba894ea8eb0eb", size = 7226887, upload-time = "2026-07-01T11:53:42.764Z" }, + { url = "https://files.pythonhosted.org/packages/fa/17/e324be141d173c1c919428066c3259f21c1b8982e564e01a4a81e96dbdcf/pillow-12.3.0-cp310-cp310-win_arm64.whl", hash = "sha256:514435a37670e3e5e08f3945b68718b6ed329bb84367777e16f9f4dfe1e61a0f", size = 2568039, upload-time = "2026-07-01T11:53:45.372Z" }, + { url = "https://files.pythonhosted.org/packages/fb/c8/0a78b0e02d7ac54bc03e5321c9220da52f0c2ea83b21f7c40e7f3169c502/pillow-12.3.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:00808c5e14ef63ac5161091d242999076604ff74b883423a11e5d7bbb38bf756", size = 5392415, upload-time = "2026-07-01T11:53:47.162Z" }, + { url = "https://files.pythonhosted.org/packages/b2/5b/a02d30018abd97ced9f5a6c63d28597694a00d066516b9c1c6de45859fc9/pillow-12.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:37d6d0a00072fd2948eb22bce7e1475f34569d90c87c59f7a2ec59541b77f7a6", size = 4785266, upload-time = "2026-07-01T11:53:49.079Z" }, + { url = "https://files.pythonhosted.org/packages/c8/98/766667a4be768150a202836acd9fad19c06824ca86c4286d3cf6b274964e/pillow-12.3.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bcb46e2f9feff8d06323983bd83ed00c201fdcab3d74973e7072a889b3979fcd", size = 6263814, upload-time = "2026-07-01T11:53:51.32Z" }, + { url = "https://files.pythonhosted.org/packages/3b/2d/ede717bc1144f63886c21fd349bb95860b0d1a21149ff16f2bb362b612b6/pillow-12.3.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23d27a3e0307ec2244cc51e7287b919aa68d097504ebe19df4e76a98a3eea5bd", size = 6934408, upload-time = "2026-07-01T11:53:53.487Z" }, + { url = "https://files.pythonhosted.org/packages/a3/48/9c58b685e69d49c31af6c8eb9012055fab7e665785165c84796e2c73ce72/pillow-12.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4f883547d4b7f0495ebe7056b0cc2aea76094e7a4abc8e933540f3271df27d9c", size = 6337160, upload-time = "2026-07-01T11:53:55.457Z" }, + { url = "https://files.pythonhosted.org/packages/ff/fa/dc2a5c0ba6df93f67c31d34b808b7ce440b40cdbf96f0b81cde1d1e6fa93/pillow-12.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:236ff70b9312fb68943c703aa842ca6a758abfa45ac187a5e7c1452e96ef72b5", size = 7045172, upload-time = "2026-07-01T11:53:57.736Z" }, + { url = "https://files.pythonhosted.org/packages/86/a5/444817a4d4c4c2417df00513086ca196f388d8f9ef40c2e4ccd1ad1af54b/pillow-12.3.0-cp311-cp311-win32.whl", hash = "sha256:10e41f0fbf1eec8cfd234b8fe17a4caac7c9d0db4c204d3c173a8f9f6ef3232b", size = 6472232, upload-time = "2026-07-01T11:53:59.767Z" }, + { url = "https://files.pythonhosted.org/packages/63/c6/4bad1b18d132a50b27e1365e1ab163616f7a5bb56d330f66f9d1d9d4f9d4/pillow-12.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:8e95e1385e4998ae9694eeaa4730ba5457ff61185b3a55e2e7bea0880aef452a", size = 7233653, upload-time = "2026-07-01T11:54:02.066Z" }, + { url = "https://files.pythonhosted.org/packages/fd/16/00f91ab7760dc842f5aad55217e80fc4a7067a0604535249bc8a2d6d9870/pillow-12.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:ebaea975e03d3141d9d3a507df75c9b3ec90fa9d2ffd07567b3a978d9d790b26", size = 2568195, upload-time = "2026-07-01T11:54:04.622Z" }, + { url = "https://files.pythonhosted.org/packages/37/bf/fb3ebff8ddcb76aac5a01389251bbbb9519922a9b520d8247c1ca864a25d/pillow-12.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965", size = 5345969, upload-time = "2026-07-01T11:54:06.397Z" }, + { url = "https://files.pythonhosted.org/packages/d8/66/9a386a92561f402389a4fc70c18838bf6d35eb5eb5c6850b4b2dc64f5048/pillow-12.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7", size = 4780323, upload-time = "2026-07-01T11:54:09.351Z" }, + { url = "https://files.pythonhosted.org/packages/25/27/ac8f99618ffd3dde21db0f4d4b1d2ab00c0880595bfd17df103f7f39fd0c/pillow-12.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9", size = 6266838, upload-time = "2026-07-01T11:54:11.71Z" }, + { url = "https://files.pythonhosted.org/packages/84/21/a35af28dcc61f37ed850a2d64c65c701321dfbf25085e469d5559360cbbf/pillow-12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91", size = 6940830, upload-time = "2026-07-01T11:54:13.732Z" }, + { url = "https://files.pythonhosted.org/packages/eb/51/8b08617af3ad95e33ce6d7dd2c99ed6c8298f7fb131636303956be022e25/pillow-12.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c", size = 6344383, upload-time = "2026-07-01T11:54:15.756Z" }, + { url = "https://files.pythonhosted.org/packages/1d/72/cf78ac9780bb93c28328f408973845a309d4d145041665f734572ced1b52/pillow-12.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df", size = 7052934, upload-time = "2026-07-01T11:54:17.721Z" }, + { url = "https://files.pythonhosted.org/packages/20/20/25e0f4dc178a6bc0696793720055519a0de89e7661dae886992decbd2f81/pillow-12.3.0-cp312-cp312-win32.whl", hash = "sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f", size = 6472684, upload-time = "2026-07-01T11:54:19.839Z" }, + { url = "https://files.pythonhosted.org/packages/45/89/da2f7971a317f83d807fdd4065c0af40208e59e692cc43d315a71a0e96d1/pillow-12.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09", size = 7227137, upload-time = "2026-07-01T11:54:22.025Z" }, + { url = "https://files.pythonhosted.org/packages/de/47/4845a0a6c0dbf1db8456bd9fc791f13c5ced7ced20606d08a0aacfd25b49/pillow-12.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510", size = 2568267, upload-time = "2026-07-01T11:54:24.051Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ac/31fb64e1e7efb5a4b50cd3d92049ba89ac6e4d8d3bb6a74e15048ca3353e/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89", size = 4161684, upload-time = "2026-07-01T11:54:25.934Z" }, + { url = "https://files.pythonhosted.org/packages/87/b4/9805e23d2b4d77842b468513841fda254ee42f0289d25088340e4ff46e2d/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace", size = 4255487, upload-time = "2026-07-01T11:54:27.935Z" }, + { url = "https://files.pythonhosted.org/packages/df/39/ecf519435a200c693fe053a6ee4d835b41cf963a4dfc2551c4e637cb2a71/pillow-12.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec", size = 3696433, upload-time = "2026-07-01T11:54:29.813Z" }, + { url = "https://files.pythonhosted.org/packages/42/92/2fc3ffad878ae8dd5469ec1bc8eb83b71f48e13efdf68f02709003982a32/pillow-12.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66", size = 5345889, upload-time = "2026-07-01T11:54:31.97Z" }, + { url = "https://files.pythonhosted.org/packages/10/76/8803c13605b763d33d156c4678fc77f8443389c0c51c8aef707bb02015f4/pillow-12.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35", size = 4780109, upload-time = "2026-07-01T11:54:34.026Z" }, + { url = "https://files.pythonhosted.org/packages/1f/01/e18aff37cb0b4aac47ac90f016d347a49aca667ef97f190b06ac2aabc928/pillow-12.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65", size = 6263736, upload-time = "2026-07-01T11:54:36.131Z" }, + { url = "https://files.pythonhosted.org/packages/f7/62/de5bdd77d935331f4f802edc11e4d82950f642caad6cb2f949837b8560e2/pillow-12.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3", size = 6937129, upload-time = "2026-07-01T11:54:38.216Z" }, + { url = "https://files.pythonhosted.org/packages/70/4d/105627a13300c5e0df1d174230b32fd1273062c96f7745fd552b945d1e1d/pillow-12.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a", size = 6339562, upload-time = "2026-07-01T11:54:40.354Z" }, + { url = "https://files.pythonhosted.org/packages/6b/1d/f13de01a553988ab895ba1c722e06cf3144d4f57656fd5b81b6d881f1179/pillow-12.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e", size = 7049439, upload-time = "2026-07-01T11:54:42.489Z" }, + { url = "https://files.pythonhosted.org/packages/c9/f9/066794cca041b969964f779ee5fa66a9498bbf34248ac39c5d7954e4198f/pillow-12.3.0-cp313-cp313-win32.whl", hash = "sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f", size = 6473287, upload-time = "2026-07-01T11:54:44.9Z" }, + { url = "https://files.pythonhosted.org/packages/a6/9b/7a58e61d62be561da3a356fe2384d4059a6345fc130e23ef1c36a5b81d24/pillow-12.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8", size = 7239691, upload-time = "2026-07-01T11:54:47.141Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b0/c4ed4f0ef8f8fa5ee8351537db6650bb8189f7e118842978dd6589065692/pillow-12.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b", size = 2568185, upload-time = "2026-07-01T11:54:49.137Z" }, + { url = "https://files.pythonhosted.org/packages/dc/01/001f65b68192f0228cc1dbbc8d2530ab5d58b61037ba0587f946fea607cd/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330", size = 4161736, upload-time = "2026-07-01T11:54:51.156Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d2/0219746d0fd16fc8a84498e79452375be3797d3ce4044596ce565164b84f/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217", size = 4255435, upload-time = "2026-07-01T11:54:53.414Z" }, + { url = "https://files.pythonhosted.org/packages/c8/02/8d0bc62ef0302318c46ff2a512822d2610e81c7aa46c9b3abe6cbaca5ad0/pillow-12.3.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930", size = 3696262, upload-time = "2026-07-01T11:54:55.739Z" }, + { url = "https://files.pythonhosted.org/packages/85/e2/73c77d218410b14f5f2d565e8a998d5317b7b9c75368d29985139f7a46f0/pillow-12.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8", size = 5350344, upload-time = "2026-07-01T11:54:57.657Z" }, + { url = "https://files.pythonhosted.org/packages/c7/da/32c752228ae345f489e3a42499d817b6c3996da7e8a3bc7a04fc806b243b/pillow-12.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0", size = 4780131, upload-time = "2026-07-01T11:54:59.713Z" }, + { url = "https://files.pythonhosted.org/packages/b1/9d/8b2c807dbef61a5197c047afe99823787eb66f63daf9fb2432f91d6f0462/pillow-12.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321", size = 6263757, upload-time = "2026-07-01T11:55:01.778Z" }, + { url = "https://files.pythonhosted.org/packages/5c/44/c85361f65dbe00eea8576ee467c768d25129989efb76e94f205e9ca9bb46/pillow-12.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b", size = 6936962, upload-time = "2026-07-01T11:55:03.93Z" }, + { url = "https://files.pythonhosted.org/packages/18/7e/e483414b35800b86b6f08dbbc7803fb5cd52c4d6f897f47d53ea2c7e6f65/pillow-12.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198", size = 6339171, upload-time = "2026-07-01T11:55:05.989Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f4/68c491844841ede6bed70189546b3ee9731cf9f2cbad396faff5e1ccba45/pillow-12.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130", size = 7048116, upload-time = "2026-07-01T11:55:08.131Z" }, + { url = "https://files.pythonhosted.org/packages/a3/34/77f3f793fed8efc7d243f21b33c5a3f0d1c97ee70346d3db855587e155ff/pillow-12.3.0-cp314-cp314-win32.whl", hash = "sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a", size = 6467209, upload-time = "2026-07-01T11:55:10.408Z" }, + { url = "https://files.pythonhosted.org/packages/f1/e0/492879f69d94f91f60fc8cd05ba03650e9520afebb2fb7aa12777d7c7f38/pillow-12.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d", size = 7237707, upload-time = "2026-07-01T11:55:12.745Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ac/6b11f2875f1c2ac040d84e1bbf9cf22a88038f901ca1037898b280b38365/pillow-12.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838", size = 2565995, upload-time = "2026-07-01T11:55:14.736Z" }, + { url = "https://files.pythonhosted.org/packages/52/69/c2208e56af9bfc1913afb24020297a691eb1d4ef688474c8a04913f65e04/pillow-12.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e", size = 5352503, upload-time = "2026-07-01T11:55:17.076Z" }, + { url = "https://files.pythonhosted.org/packages/07/70/e5686d753e898a45d778ff1718dba8516ead6ab6b95d85fc8c4b70650cf2/pillow-12.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17", size = 4782956, upload-time = "2026-07-01T11:55:19.448Z" }, + { url = "https://files.pythonhosted.org/packages/d5/37/25c6692f06927ee973ff18c8d9ee98ad0b4d84ee67a09610c2dd1447958e/pillow-12.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385", size = 6322855, upload-time = "2026-07-01T11:55:21.613Z" }, + { url = "https://files.pythonhosted.org/packages/cc/91/420637fcb8f1bc11029e403b4538e6694744428d8246118e45719f944556/pillow-12.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c", size = 6989642, upload-time = "2026-07-01T11:55:24.006Z" }, + { url = "https://files.pythonhosted.org/packages/10/08/b94d7811281ccf0d143a1cf768d1c49e1e54af63e7b708ab2ee3eb87face/pillow-12.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d", size = 6391281, upload-time = "2026-07-01T11:55:26.252Z" }, + { url = "https://files.pythonhosted.org/packages/d2/87/24233f785f55474dc02ce3e739c5528a77e3a862e9333d1dd7a25cc31f70/pillow-12.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931", size = 7096716, upload-time = "2026-07-01T11:55:28.318Z" }, + { url = "https://files.pythonhosted.org/packages/23/26/fcb2f6e37175b04f53570b59937867e2b80ee1685e744023153028fc14f9/pillow-12.3.0-cp314-cp314t-win32.whl", hash = "sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7", size = 6474125, upload-time = "2026-07-01T11:55:30.956Z" }, + { url = "https://files.pythonhosted.org/packages/90/de/3634abee5f1c9e13c56787b7d5517b0ba8d6de51700b95578cf338349c9f/pillow-12.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c", size = 7242939, upload-time = "2026-07-01T11:55:34.044Z" }, + { url = "https://files.pythonhosted.org/packages/ce/2a/fd13f8eb24de5714a6eb444a3d67e2842c6c576e159a43793adf23051351/pillow-12.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45", size = 2567506, upload-time = "2026-07-01T11:55:35.988Z" }, + { url = "https://files.pythonhosted.org/packages/5d/dc/8fdce34ec725a33c81c6ba122b904d6b9024e50ea9ac7bede62fab54506c/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139", size = 4162063, upload-time = "2026-07-01T11:55:37.941Z" }, + { url = "https://files.pythonhosted.org/packages/76/66/2044b9a63d3b84ff048228dfcb7cd9bf0df983e8470971bf7d4c57b693de/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402", size = 4255549, upload-time = "2026-07-01T11:55:40.022Z" }, + { url = "https://files.pythonhosted.org/packages/52/7e/1f67e6f4ece6b582ee4b539decbcc9f848dc245a93ed8cd7338bafef72f1/pillow-12.3.0-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c", size = 3696331, upload-time = "2026-07-01T11:55:41.98Z" }, + { url = "https://files.pythonhosted.org/packages/12/40/d306fc2c8e4d45d7f175c77edca7063be7b86fe7fe6e68f4353bf71d808c/pillow-12.3.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f", size = 5350370, upload-time = "2026-07-01T11:55:44.028Z" }, + { url = "https://files.pythonhosted.org/packages/dd/44/668fb1437e8ce420f62d6106eb66e44a5971602a4d794615bdf79315d82d/pillow-12.3.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701", size = 4780147, upload-time = "2026-07-01T11:55:46.073Z" }, + { url = "https://files.pythonhosted.org/packages/0c/08/93fa2e70e30a2d81547e481b6ee2bb9522117221fb1e0ce4b5df70967677/pillow-12.3.0-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace", size = 6273659, upload-time = "2026-07-01T11:55:48.264Z" }, + { url = "https://files.pythonhosted.org/packages/f8/6d/043e96ff814fc31a33077e4cba86082167db520c93632afdf2042febbb0c/pillow-12.3.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4", size = 6947439, upload-time = "2026-07-01T11:55:50.503Z" }, + { url = "https://files.pythonhosted.org/packages/af/92/ba71d2ee2ac0edf3fa33bd9d5ee9ee080da70b1766f3ca3934f9938ddac9/pillow-12.3.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39", size = 6353577, upload-time = "2026-07-01T11:55:52.697Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ce/e63064e2122923ff687c8ad792d0d736a7b3920a56a46982e81a7fdd25d6/pillow-12.3.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71", size = 7060394, upload-time = "2026-07-01T11:55:55.149Z" }, + { url = "https://files.pythonhosted.org/packages/54/76/a09cc3ccc8d773a7283d34c38bec1708f9e3cc932093cbc4c5e71ac4060b/pillow-12.3.0-cp315-cp315-win32.whl", hash = "sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827", size = 6467375, upload-time = "2026-07-01T11:55:57.769Z" }, + { url = "https://files.pythonhosted.org/packages/3e/03/1846c49ba3b1d5550392a4bbd06d6fb4578e1cd91a803198b5c90f5f7d53/pillow-12.3.0-cp315-cp315-win_amd64.whl", hash = "sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5", size = 7237048, upload-time = "2026-07-01T11:55:59.975Z" }, + { url = "https://files.pythonhosted.org/packages/fb/bb/89f35dcc79610423f9f195504d7def7f0d1416a711541b42867e25fe3412/pillow-12.3.0-cp315-cp315-win_arm64.whl", hash = "sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658", size = 2566006, upload-time = "2026-07-01T11:56:02.143Z" }, + { url = "https://files.pythonhosted.org/packages/30/88/707027ba09942dfa2c28759b5c222d769290a41c6d20ea60ec250801941f/pillow-12.3.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf", size = 5352509, upload-time = "2026-07-01T11:56:04.2Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6d/00352fa25332c2569cd387851f568cc5a4b75a9adbfb37ac4fbce4c02eec/pillow-12.3.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64", size = 4783167, upload-time = "2026-07-01T11:56:06.631Z" }, + { url = "https://files.pythonhosted.org/packages/13/4f/9e049dfa21af7c22427275720e2490267ba8138120add5c4c574deb69782/pillow-12.3.0-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e", size = 6329237, upload-time = "2026-07-01T11:56:08.868Z" }, + { url = "https://files.pythonhosted.org/packages/36/16/cf6eeaae8d0fce8dd390a33437cf68c5d5bd73834a2bc6e2f14efda0ab45/pillow-12.3.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777", size = 6997047, upload-time = "2026-07-01T11:56:11.379Z" }, + { url = "https://files.pythonhosted.org/packages/1e/69/dbf769bdd55f48bf5733cac28edc6364ffaa072ec9ba336266e4fe66be55/pillow-12.3.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1", size = 6400440, upload-time = "2026-07-01T11:56:13.908Z" }, + { url = "https://files.pythonhosted.org/packages/a0/e1/ffc9cfc2eea0d178da8018e18e959301ad9d6bc9f3edb7181e748a474b97/pillow-12.3.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9", size = 7105895, upload-time = "2026-07-01T11:56:16.575Z" }, + { url = "https://files.pythonhosted.org/packages/18/f0/a5595c1e8c3ae44b9828cb2f0fa8155e5095ef04d6327b8f61cf44a3df85/pillow-12.3.0-cp315-cp315t-win32.whl", hash = "sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8", size = 6474384, upload-time = "2026-07-01T11:56:18.855Z" }, + { url = "https://files.pythonhosted.org/packages/e4/04/62bcd9f844984c5938d3b05264a61d797a29d3e0812341a8204af70bbdee/pillow-12.3.0-cp315-cp315t-win_amd64.whl", hash = "sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418", size = 7243537, upload-time = "2026-07-01T11:56:21.214Z" }, + { url = "https://files.pythonhosted.org/packages/3d/68/1f3066acedf37673694a7141381d8f811ae97f30d34413d236abe7d489f1/pillow-12.3.0-cp315-cp315t-win_arm64.whl", hash = "sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59", size = 2567491, upload-time = "2026-07-01T11:56:23.506Z" }, + { url = "https://files.pythonhosted.org/packages/75/18/2e8b40223153ccbc60df07f9e8928dc0c76202aa4e55ae9f53962b6510d6/pillow-12.3.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b3c777e849237620b022f7f297dd67705f9f5cf1685f09f02e46f93e92725468", size = 5302510, upload-time = "2026-07-01T11:56:25.736Z" }, + { url = "https://files.pythonhosted.org/packages/46/3e/51fabf59d5ab801ceab709453d3ab6b180083496579549de4c45ced6528a/pillow-12.3.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:b343699e8308bdc51978310e1c959c584e7869cc8c40780058c87da7781a1e94", size = 4736058, upload-time = "2026-07-01T11:56:28.041Z" }, + { url = "https://files.pythonhosted.org/packages/bf/20/22fe9384b7949e25fb1293bcfc84fb82590ff4ea6b37c95b24d26d793d86/pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbd139c8447d25dd750ab79ee274cc5e1fe80fc56340ab10b18a195e1b6eca3e", size = 5237776, upload-time = "2026-07-01T11:56:30.263Z" }, + { url = "https://files.pythonhosted.org/packages/08/14/f6ba68107680ffa74b39985f3f30884e41318fbc4250caa423c79b4788bb/pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e7e480451b9fa137494bccd3a7d69adbe8ac65a87d97be61e11f1b1050a5bac3", size = 5860358, upload-time = "2026-07-01T11:56:32.68Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0169bc772ec491108b62f644f8ecf1fe5d8ae5ebafde2ee2142210166903/pillow-12.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:04f01d28a6aaff387bf842a13be313df23ba0597a44f1a976c9feb3c6ff4711a", size = 7231786, upload-time = "2026-07-01T11:56:35.046Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/78/9b/560e4be8e26f6fd133a03630a8df0c663b9e8d61b4ade152b72005aec83b/platformdirs-4.11.0.tar.gz", hash = "sha256:0555d18370482847566ffabcaa53ad7c6c1c29f195989ae1ed634a05f76ea1e0", size = 31953, upload-time = "2026-07-21T13:09:36.565Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7d/68/d8d58938dfb1370b266a1a729e6d77a985be23689a0496498ee17b2cbf90/platformdirs-4.11.0-py3-none-any.whl", hash = "sha256:360ccded2b7fce0af0ff80cc8f5942a1c5d99b0e856033acb030bfc634709e74", size = 23247, upload-time = "2026-07-21T13:09:35.422Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "preshed" +version = "3.0.13" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cymem" }, + { name = "murmurhash" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/75/fe6b7bbd0dea530a001b0e24c331b21a0be2786e402abf3c57f5dce43d4b/preshed-3.0.13.tar.gz", hash = "sha256:d75f718bbfd97e992f7827e0fa7faf6a91bdd9c922d5baa4b50d62731396cb89", size = 18338, upload-time = "2026-03-23T08:57:31.378Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/45/7e/d55d8cdeefa78995eec15a11ae16cbd0581a0be2342527a64251fd948cef/preshed-3.0.13-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:42c58b07e8b431e33d0ad9922e896632453821cad8b09171b619b8c61101916f", size = 136920, upload-time = "2026-03-23T08:56:10.829Z" }, + { url = "https://files.pythonhosted.org/packages/10/bc/ee1f388a97c613e656d774b522b4ddc1cd32e984ca4eb1157c5d822e9011/preshed-3.0.13-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a06e27f4e5b9d7943840087828c6a0dae4a3475576d12c2e95b71abbb325a80b", size = 137576, upload-time = "2026-03-23T08:56:12.441Z" }, + { url = "https://files.pythonhosted.org/packages/a6/dd/24c5a576035df4043998e1069718dd7369e107ce9d169df2333d00461dbf/preshed-3.0.13-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8b82d7a7bb63d248a6cbbfcabb4a570c993d54d964e39dc5d85c14018ba2079e", size = 780270, upload-time = "2026-03-23T08:56:14.108Z" }, + { url = "https://files.pythonhosted.org/packages/e5/ab/fb0f6808fffad96c962ce254587cae2bb7df0fda3e6d6b481ce4f60f6c2d/preshed-3.0.13-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bef84b225d226af43adfee78ce5ddede72a6155ce5292c1a41dcd1f0b9c87c30", size = 779722, upload-time = "2026-03-23T08:56:15.721Z" }, + { url = "https://files.pythonhosted.org/packages/bf/7f/c9948dde95bf965c6af2c31f0dbbc6c7e5433b5de1c85f20644edf38c78c/preshed-3.0.13-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:70d502e081348df207d90f347f21770ed596822bb04eb3c3b32b7281579e90c6", size = 1775435, upload-time = "2026-03-23T08:56:17.655Z" }, + { url = "https://files.pythonhosted.org/packages/d2/57/8db29ac57b981ef19d1078001aa6c2055a3eed46998c1c93f3d1fdb86106/preshed-3.0.13-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:985cb9b097beda76cd13c01a0499707103e8915f888fa30f8aa8324ef2cc6b08", size = 1842612, upload-time = "2026-03-23T08:56:19.755Z" }, + { url = "https://files.pythonhosted.org/packages/38/e5/ead05efc423be237fba76a3bf0eeb492e5d3801504c096c3552c517f2f72/preshed-3.0.13-cp310-cp310-win_amd64.whl", hash = "sha256:867aa73abbf4ee3b4d7662148091c33a8c039271269e3a7f1e0ca995f91995c8", size = 121951, upload-time = "2026-03-23T08:56:21.138Z" }, + { url = "https://files.pythonhosted.org/packages/aa/80/9cf7f7c208046c97d4b2765f89545a6ea8cfefbd87f0141dde61e6f098ac/preshed-3.0.13-cp310-cp310-win_arm64.whl", hash = "sha256:2b704e46cb7b88f656ef16a3e5347b36525a1c53721d327a4ba1457404101f85", size = 109604, upload-time = "2026-03-23T08:56:22.537Z" }, + { url = "https://files.pythonhosted.org/packages/7c/d1/7bc39738388b38ff48cecbb326a9b2bb3f422bb32097be92e010f3162395/preshed-3.0.13-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5268c0e6fa96f50cdf87f516c2d4b32563c12706ee768e75c00e8d0098acd545", size = 136718, upload-time = "2026-03-23T08:56:23.889Z" }, + { url = "https://files.pythonhosted.org/packages/f6/65/de465b6801740140c2b5d2db6c312ca7937dcfd0442f1ae7d50dee529544/preshed-3.0.13-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:df642547a1a94079978a0ea8f4593ab4b8d3bd43f767bef0ef64d9a214f8c4c9", size = 137261, upload-time = "2026-03-23T08:56:25.303Z" }, + { url = "https://files.pythonhosted.org/packages/89/83/478ee078746a4a413c841542caebd2ea74b659475b8bf5f2e3724b6fe655/preshed-3.0.13-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:09397592d333a77f88454e72b7f1f941b2afaf040b392b9e74898dbc4648cdf5", size = 821010, upload-time = "2026-03-23T08:56:26.455Z" }, + { url = "https://files.pythonhosted.org/packages/ee/2e/1ac761e973966893cd3a0ad3256360365276e2d1e779e351448981a1156a/preshed-3.0.13-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f8e6fe0620ed0f96a246d46447055c447e071cd8222731a045c235e8a758c918", size = 823096, upload-time = "2026-03-23T08:56:28.126Z" }, + { url = "https://files.pythonhosted.org/packages/3c/51/7824cfd85dd7fe547888de20228ebd87d9acd3708206d30b82211e382d23/preshed-3.0.13-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:502f93f49a22788203f02d3067d4ea077a0cca3864de6a792eae12e7ce589e14", size = 1812148, upload-time = "2026-03-23T08:56:29.755Z" }, + { url = "https://files.pythonhosted.org/packages/34/48/32160a24705d56179de6af838c10a0c735c955dae5f9e4bb344750b79bc2/preshed-3.0.13-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:acd4d89abeca3678c5d8c89b3cd351314465bc67c7fa053d2644f8513e543386", size = 1881154, upload-time = "2026-03-23T08:56:31.49Z" }, + { url = "https://files.pythonhosted.org/packages/ed/22/0344b50f8b1ad9e3aac08099c47e1aba91c81602fd117d2673f6606ecae6/preshed-3.0.13-cp311-cp311-win_amd64.whl", hash = "sha256:de87fbabb0f37c3c92d4dd9b94fc82ab73cdab4247cdfbd57ab3926caa983919", size = 122219, upload-time = "2026-03-23T08:56:32.74Z" }, + { url = "https://files.pythonhosted.org/packages/33/c4/812eeaa568510f396e27edab01100ca71418f032fd7098b107f12e572361/preshed-3.0.13-cp311-cp311-win_arm64.whl", hash = "sha256:5e2753779832e411e93eb727f3d409c0a6b7408e5ce4dd868076d8ece48c7693", size = 109308, upload-time = "2026-03-23T08:56:33.839Z" }, + { url = "https://files.pythonhosted.org/packages/39/fb/ccff23c44c04088c248539005fcda78b9014512a34d170c5360f02ad908b/preshed-3.0.13-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5d14eea14bd01291388928991d7df7d60b9fd19ae970e55006eb4d29b0c1e8eb", size = 138497, upload-time = "2026-03-23T08:56:35.321Z" }, + { url = "https://files.pythonhosted.org/packages/8e/ce/cad5a8145881a771e6c0d002f2e585fc19b962f120860b54d32af5baa342/preshed-3.0.13-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f05b08ce92399c0655b5e0eb5a1cc1f9e295703ed3aabdfaf6538dfa8ae23d57", size = 138010, upload-time = "2026-03-23T08:56:36.399Z" }, + { url = "https://files.pythonhosted.org/packages/a7/a2/c5fed4fb3e946699259d11e4036a3cfdd8c89b3e542e3077d46781642425/preshed-3.0.13-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:62cf7f3113132891d6bba70ff547ad81c6fe50a31930bbbb8499f1d47cd122b7", size = 861498, upload-time = "2026-03-23T08:56:37.67Z" }, + { url = "https://files.pythonhosted.org/packages/51/94/8c9bc48a6ea4903f53a1a0031ce8e35687526949f25821762ef21493c007/preshed-3.0.13-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8b8de3f58043070a354477995acdd98626ce43e4193c708ebd0f694e467f5155", size = 868988, upload-time = "2026-03-23T08:56:39.324Z" }, + { url = "https://files.pythonhosted.org/packages/b6/df/ecd2f40055ff52527ca117ffbfafb888c1a3079b59fbabe03c5b8f9b7240/preshed-3.0.13-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:183b339956a9e1d7a4a00038a3b9587a734db9e8bd915939a49791bd1b372156", size = 1847382, upload-time = "2026-03-23T08:56:40.89Z" }, + { url = "https://files.pythonhosted.org/packages/e6/88/bdb244e40284ded3632a9f88c23bc80230bd7b2ae4a8b7f2cc91adead7a8/preshed-3.0.13-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2e77bed56aded7cbe5d28d6bd2178bc5b13eda0e0e464dab205fb578fa915000", size = 1919236, upload-time = "2026-03-23T08:56:42.616Z" }, + { url = "https://files.pythonhosted.org/packages/a0/c9/c91ea56342e6c364fc69b444a1ac5432327857199c44032c9cc9dc4c3a23/preshed-3.0.13-cp312-cp312-win_amd64.whl", hash = "sha256:04d8f13f2986e5d11af5ac51f55ce3106c70c41b483d20ea392e6180bdd0f870", size = 122938, upload-time = "2026-03-23T08:56:44.271Z" }, + { url = "https://files.pythonhosted.org/packages/b2/0b/6a99d99619fd83b14c696e2489caed7070647488d4d3ac0b723d35db2de0/preshed-3.0.13-cp312-cp312-win_arm64.whl", hash = "sha256:19318dc1cd8cac6663c6c830bf7e0002d2de853769fb03e056774e97c21bedfd", size = 109194, upload-time = "2026-03-23T08:56:45.346Z" }, + { url = "https://files.pythonhosted.org/packages/0e/2a/401158195d6dc7f6aef0b354d74d0e95c9da124499448c2b3dbb95b71204/preshed-3.0.13-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c0d0c14187dc0078d8a63bf190ec045a4d13e7748b6caeb557a7d575e411410b", size = 137289, upload-time = "2026-03-23T08:56:46.516Z" }, + { url = "https://files.pythonhosted.org/packages/88/8f/e20e64573988528785447a6893b2e7ab287ecfd85b3888e978b28812fd20/preshed-3.0.13-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7770987c2e57497cd26124a9be5f652b5b3ccd0def89859ab0da8bca6144a3de", size = 136847, upload-time = "2026-03-23T08:56:47.572Z" }, + { url = "https://files.pythonhosted.org/packages/b9/72/18168f881359c4482d312f8dc196371bdd61c1583a52b34390da4c88bbea/preshed-3.0.13-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4a7bc48220de579be6bdb0a8715482cf36e2a625a6fd5ad26c9f43485a4a23b5", size = 831478, upload-time = "2026-03-23T08:56:48.769Z" }, + { url = "https://files.pythonhosted.org/packages/fd/3a/3543476091087102775568cea9885dde3453569e9aeee365809108de572f/preshed-3.0.13-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e5c8462472f790c16708306aef3a102a762bd19dfe3d2f8ee08bd5e12f51b835", size = 839913, upload-time = "2026-03-23T08:56:49.937Z" }, + { url = "https://files.pythonhosted.org/packages/cf/65/b13f01329decc44ef53cfb6b4601ba85382dcb2a4ec78d9250f03a418066/preshed-3.0.13-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c046736239cc8d72670749b79b526e4111839a2fc461a58545d212797649129c", size = 1816452, upload-time = "2026-03-23T08:56:51.233Z" }, + { url = "https://files.pythonhosted.org/packages/d1/c7/f1a996c6832234efd4d543041b582418d41ac480ee55c557ec9e65344637/preshed-3.0.13-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7c333f18e9a81c8a6de0603fd8781e17115324b117c445ca91abdf7bfb1abe49", size = 1888978, upload-time = "2026-03-23T08:56:52.591Z" }, + { url = "https://files.pythonhosted.org/packages/e3/b9/96fb71499049885ce19545903fdd38877bbc2be0da47e37c04d01f3e9f66/preshed-3.0.13-cp313-cp313-win_amd64.whl", hash = "sha256:461327f8dd36520dcf1fd55a671e0c3c2c97a2d95e22fc85faa31173f4785dda", size = 122134, upload-time = "2026-03-23T08:56:54.392Z" }, + { url = "https://files.pythonhosted.org/packages/ef/a7/32a4903019d936a2316fdd330bedddac287ac26326107d24fb76a1fbc60a/preshed-3.0.13-cp313-cp313-win_arm64.whl", hash = "sha256:35d6c5acb3ee3b12b87a551913063f0cec784055c2af16e028c19fe875f079d0", size = 108497, upload-time = "2026-03-23T08:56:55.816Z" }, + { url = "https://files.pythonhosted.org/packages/bb/b5/993886c98f5caaa6f07a648cac97a7c62a3093091cad65e1e43a1bd41cc4/preshed-3.0.13-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d2f1efae396cadab5f3890a2fd43d2ee65373ef9096ccbb805e51e8d8bcc563b", size = 137882, upload-time = "2026-03-23T08:56:56.878Z" }, + { url = "https://files.pythonhosted.org/packages/c6/86/b7fd137cbf140afd6c45e895946068a15f5b55642916de0075e6eb18581c/preshed-3.0.13-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8d6acc1f5031a535a55a6f7148e2f274554a8343a16309c700cebea0fe7aee8c", size = 138233, upload-time = "2026-03-23T08:56:58.318Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ca/21a7e79625614134273dfed32bca5bb4c2ec1313e33fbd12d41657536f1f/preshed-3.0.13-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7da9d931e7660dcdd757e5870269f0c159126d682ed73ed313971d199eb0f334", size = 834835, upload-time = "2026-03-23T08:56:59.48Z" }, + { url = "https://files.pythonhosted.org/packages/8f/3a/2dbd299516461831ae90e0d5b0637137bf28520c4e6dd0b01d6f1886659a/preshed-3.0.13-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d4ae5cfe075bb7a07982e382bca44f41ddf041f4d24cbd358e8cccfc049259b8", size = 834928, upload-time = "2026-03-23T08:57:01.075Z" }, + { url = "https://files.pythonhosted.org/packages/7c/d3/af654eba4f6587c4ee02c5043e62c194b0a1c4431ffef0c67b9518f6b61c/preshed-3.0.13-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7557963d0125a3a7bcdb2eb6948f3e45da31b5a7f066b55320de3dea22d7557f", size = 1820368, upload-time = "2026-03-23T08:57:02.351Z" }, + { url = "https://files.pythonhosted.org/packages/bf/9b/ebcb2b9e8cb881e40b55b0bf450f8a6b187e2ef3ae0c685cce81d2d85026/preshed-3.0.13-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c4bc60dc994864095d784b7e4d77dba3e64188d169ac88722b699d175561fddb", size = 1888251, upload-time = "2026-03-23T08:57:04.158Z" }, + { url = "https://files.pythonhosted.org/packages/97/f7/c6c012779edcaa6e2cd092c554e98dc53e77f41205b07208655ba77e2327/preshed-3.0.13-cp314-cp314-win_amd64.whl", hash = "sha256:208dcebbe294bf1881ce33fb015d56ab2a7587aece85a09147727174207892e4", size = 125211, upload-time = "2026-03-23T08:57:05.83Z" }, + { url = "https://files.pythonhosted.org/packages/f8/82/390ef87d732ef64e673ef6bf9e5d898453986e979efa50fb3a400e2c0766/preshed-3.0.13-cp314-cp314-win_arm64.whl", hash = "sha256:cf8e1a7a1823b2a7765121446c630140ac6e8650c07a6efbf375e168d1fef4f7", size = 111942, upload-time = "2026-03-23T08:57:06.996Z" }, + { url = "https://files.pythonhosted.org/packages/80/3a/a9dde3167bcecb27ae82ce4567b5ab1aa3989113ae6814c092ce223cc4ef/preshed-3.0.13-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:9ca43ecbc3783eda4d6ab3416ae2ecd9ef23dca5f53995843f69f7457bcd0677", size = 144997, upload-time = "2026-03-23T08:57:08.064Z" }, + { url = "https://files.pythonhosted.org/packages/74/d4/22d9355b50b6a13b407dcad0a81df83fb1d5602092d1f05834674dde8fda/preshed-3.0.13-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c8596e41a258ff213553a441e0bb3eb388fd8158e84a7bf3aae6d8ede2c166d3", size = 147294, upload-time = "2026-03-23T08:57:09.411Z" }, + { url = "https://files.pythonhosted.org/packages/70/42/a225ee83fdb306d2a503f21a627953b820f4e079c90c8a84338957cb8ff5/preshed-3.0.13-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4f8856ca3d88e9b250630d70abb4f260d8933151ddfb413024784b25b009868e", size = 952110, upload-time = "2026-03-23T08:57:10.592Z" }, + { url = "https://files.pythonhosted.org/packages/40/ba/09a9dfe3d22d7e745483fd5d7f2a82cd4d39c161f7d2daa0faa4bd6402be/preshed-3.0.13-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e5b2865aecbd2e1e10e5d19bb8bfad765863c1307c6c3e51f2a08bd64122409", size = 932217, upload-time = "2026-03-23T08:57:12.124Z" }, + { url = "https://files.pythonhosted.org/packages/6c/5c/e10e2e05133e7fcbd7c40536af1148c82dd24357b8f5726e2c7bc51cfd53/preshed-3.0.13-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:09f96b477c987755b3c945df214ea1c1c80bfb350e9f34e78da89585535b77e8", size = 1896542, upload-time = "2026-03-23T08:57:13.525Z" }, + { url = "https://files.pythonhosted.org/packages/37/aa/51e5b4109a4cdfae28c3613eeeb10764a3794ebef8de93ffbb109465bea3/preshed-3.0.13-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:670db59a52e1823b5f088c764df474e65b686592d4093adbeef14581c95ee2cb", size = 1959473, upload-time = "2026-03-23T08:57:15.706Z" }, + { url = "https://files.pythonhosted.org/packages/0e/6a/1d966f367a14c703dde629d150d996c1b727d442f620300b21c9ec1a24d1/preshed-3.0.13-cp314-cp314t-win_amd64.whl", hash = "sha256:b03e21b0bf95eb56e23973f32cabb930e94f352228652f81c0955dbd6967d904", size = 146229, upload-time = "2026-03-23T08:57:17.457Z" }, + { url = "https://files.pythonhosted.org/packages/22/80/368139067603e590a000122355f9c8576c8ebed4fb0b8849feaa2698489d/preshed-3.0.13-cp314-cp314t-win_arm64.whl", hash = "sha256:b980f3ea9bb74b7f94464bc3d6eb3c9162b6b79b531febd14c6465c24344d2cc", size = 119339, upload-time = "2026-03-23T08:57:18.882Z" }, +] + +[[package]] +name = "prometheus-client" +version = "0.26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/52/73/f1334c29c2af4cd9dba6c7817e61b611bd0215e2eb5565c6064a4de18802/prometheus_client-0.26.0.tar.gz", hash = "sha256:04a91bcf94e2cf74a44a1a874d651a2e853ed354b6e822f3b7487751465d5c2b", size = 92910, upload-time = "2026-07-24T19:36:41.893Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/a3/b69efbf4143b5b9859b977770bbbabcc2796b702fa69dc40271e45cd5a56/prometheus_client-0.26.0-py3-none-any.whl", hash = "sha256:fa93d06737aa02bacd05794768508bb97d2fbee28cb3bca04eaae92f0ca953d6", size = 64494, upload-time = "2026-07-24T19:36:40.854Z" }, +] + +[[package]] +name = "prompt-toolkit" +version = "3.0.53" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wcwidth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/ea/39b988c938f75cb75d7045b5c69f8bfed47ee2152c8837fb403de29d6fb8/prompt_toolkit-3.0.53.tar.gz", hash = "sha256:9ec8a0ad96d5c56148b3f914aa79c1564c3fde5d2e6b876e7bc327e353cf8fa6", size = 435492, upload-time = "2026-07-26T20:56:14.758Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/6f/84908cad2d6aa5144abcf7b42709fe4fdb459bc640ec7ac5786e7693dabc/prompt_toolkit-3.0.53-py3-none-any.whl", hash = "sha256:01c0891d7f9237d5e339f7d3e42cdae80b7534abb1c7c0e3352efba6231492f2", size = 392288, upload-time = "2026-07-26T20:56:12.512Z" }, +] + +[[package]] +name = "psutil" +version = "7.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740, upload-time = "2026-01-28T18:14:54.428Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/08/510cbdb69c25a96f4ae523f733cdc963ae654904e8db864c07585ef99875/psutil-7.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b", size = 130595, upload-time = "2026-01-28T18:14:57.293Z" }, + { url = "https://files.pythonhosted.org/packages/d6/f5/97baea3fe7a5a9af7436301f85490905379b1c6f2dd51fe3ecf24b4c5fbf/psutil-7.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78c8603dcd9a04c7364f1a3e670cea95d51ee865e4efb3556a3a63adef958ea", size = 131082, upload-time = "2026-01-28T18:14:59.732Z" }, + { url = "https://files.pythonhosted.org/packages/37/d6/246513fbf9fa174af531f28412297dd05241d97a75911ac8febefa1a53c6/psutil-7.2.2-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a571f2330c966c62aeda00dd24620425d4b0cc86881c89861fbc04549e5dc63", size = 181476, upload-time = "2026-01-28T18:15:01.884Z" }, + { url = "https://files.pythonhosted.org/packages/b8/b5/9182c9af3836cca61696dabe4fd1304e17bc56cb62f17439e1154f225dd3/psutil-7.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:917e891983ca3c1887b4ef36447b1e0873e70c933afc831c6b6da078ba474312", size = 184062, upload-time = "2026-01-28T18:15:04.436Z" }, + { url = "https://files.pythonhosted.org/packages/16/ba/0756dca669f5a9300d0cbcbfae9a4c30e446dfc7440ffe43ded5724bfd93/psutil-7.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:ab486563df44c17f5173621c7b198955bd6b613fb87c71c161f827d3fb149a9b", size = 139893, upload-time = "2026-01-28T18:15:06.378Z" }, + { url = "https://files.pythonhosted.org/packages/1c/61/8fa0e26f33623b49949346de05ec1ddaad02ed8ba64af45f40a147dbfa97/psutil-7.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:ae0aefdd8796a7737eccea863f80f81e468a1e4cf14d926bd9b6f5f2d5f90ca9", size = 135589, upload-time = "2026-01-28T18:15:08.03Z" }, + { url = "https://files.pythonhosted.org/packages/81/69/ef179ab5ca24f32acc1dac0c247fd6a13b501fd5534dbae0e05a1c48b66d/psutil-7.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00", size = 130664, upload-time = "2026-01-28T18:15:09.469Z" }, + { url = "https://files.pythonhosted.org/packages/7b/64/665248b557a236d3fa9efc378d60d95ef56dd0a490c2cd37dafc7660d4a9/psutil-7.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9", size = 131087, upload-time = "2026-01-28T18:15:11.724Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2e/e6782744700d6759ebce3043dcfa661fb61e2fb752b91cdeae9af12c2178/psutil-7.2.2-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a", size = 182383, upload-time = "2026-01-28T18:15:13.445Z" }, + { url = "https://files.pythonhosted.org/packages/57/49/0a41cefd10cb7505cdc04dab3eacf24c0c2cb158a998b8c7b1d27ee2c1f5/psutil-7.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf", size = 185210, upload-time = "2026-01-28T18:15:16.002Z" }, + { url = "https://files.pythonhosted.org/packages/dd/2c/ff9bfb544f283ba5f83ba725a3c5fec6d6b10b8f27ac1dc641c473dc390d/psutil-7.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1", size = 141228, upload-time = "2026-01-28T18:15:18.385Z" }, + { url = "https://files.pythonhosted.org/packages/f2/fc/f8d9c31db14fcec13748d373e668bc3bed94d9077dbc17fb0eebc073233c/psutil-7.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841", size = 136284, upload-time = "2026-01-28T18:15:19.912Z" }, + { url = "https://files.pythonhosted.org/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090, upload-time = "2026-01-28T18:15:22.168Z" }, + { url = "https://files.pythonhosted.org/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859, upload-time = "2026-01-28T18:15:23.795Z" }, + { url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560, upload-time = "2026-01-28T18:15:25.976Z" }, + { url = "https://files.pythonhosted.org/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", size = 156997, upload-time = "2026-01-28T18:15:27.794Z" }, + { url = "https://files.pythonhosted.org/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8", size = 148972, upload-time = "2026-01-28T18:15:29.342Z" }, + { url = "https://files.pythonhosted.org/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", size = 148266, upload-time = "2026-01-28T18:15:31.597Z" }, + { url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737, upload-time = "2026-01-28T18:15:33.849Z" }, + { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" }, +] + +[[package]] +name = "ptyprocess" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/e5/16ff212c1e452235a90aeb09066144d0c5a6a8c0834397e03f5224495c4e/ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220", size = 70762, upload-time = "2020-12-28T15:15:30.155Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/a6/858897256d0deac81a172289110f31629fc4cee19b6f01283303e18c8db3/ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35", size = 13993, upload-time = "2020-12-28T15:15:28.35Z" }, +] + +[[package]] +name = "pure-eval" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/05/0a34433a064256a578f1783a10da6df098ceaa4a57bbeaa96a6c0352786b/pure_eval-0.2.3.tar.gz", hash = "sha256:5f4e983f40564c576c7c8635ae88db5956bb2229d7e9237d03b3c0b0190eaf42", size = 19752, upload-time = "2024-07-21T12:58:21.801Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8e/37/efad0257dc6e593a18957422533ff0f87ede7c9c6ea010a2177d738fb82f/pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0", size = 11842, upload-time = "2024-07-21T12:58:20.04Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pyct" +version = "0.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "param" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/c3/78eeacf7cbf478db6bd41de0ee2221cc6769d81e0c10f6c1616c82a25e06/pyct-0.6.0.tar.gz", hash = "sha256:d4e513b2cf35b6165605ae5fce5f2a49985bc67473c579de85134b8c71d374a8", size = 14586, upload-time = "2025-09-26T08:26:19.987Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8c/b2/23f4032cd1c9744aa8e9ecda43cd4d755fcb209f7f40fae035248f31a679/pyct-0.6.0-py3-none-any.whl", hash = "sha256:cfaded7289fca72ddf6579b81459e3ec8db323a508e61c49aa318ee3cd6ff160", size = 16630, upload-time = "2025-09-26T08:26:19.092Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/08/f1ba952f1c8ae5581c70fa9c6da89f247b83e3dd8c09c035d5d7931fc23d/pydantic_core-2.46.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:a396dcc17e5a0b164dbe026896245a4fa9ff402edca1dff0be3d53a517f74de4", size = 2113146, upload-time = "2026-05-06T13:37:36.537Z" }, + { url = "https://files.pythonhosted.org/packages/56/c6/65f646c7ff09bd257f660434adb45c4dfcbbcebcc030562fecf6f5bf887d/pydantic_core-2.46.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:da4b951fe36dc7c3a1ccb4e3cd1747c3542b8c9ceede8fc86cae054e764485f5", size = 1949769, upload-time = "2026-05-06T13:37:46.365Z" }, + { url = "https://files.pythonhosted.org/packages/64/ba/bfb1d928fd5b49e1258935ff104ae356e9fd89384a55bf9f847e9193ad40/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb63e0198ca18aad131c089b9204c23079c3afa95487e561f4c522d519e55aba", size = 1974958, upload-time = "2026-05-06T13:37:28.611Z" }, + { url = "https://files.pythonhosted.org/packages/4e/74/76223bfb117b64af743c9b6670d1364516f5c0604f96b48f3272f6af6cc6/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f47286a97f0bc9b8859519809077b91b2cefe4ae47fcbf5e466a009c1c5d742b", size = 2042118, upload-time = "2026-05-06T13:36:55.216Z" }, + { url = "https://files.pythonhosted.org/packages/cb/7b/848732968bc8f48f3187542f08358b9d842db564147b256669426ebb1652/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:905a0ed8ea6f2d61c1738835f99b699348d7857379083e5fc497fa0c967a407c", size = 2222876, upload-time = "2026-05-06T13:38:25.455Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2f/e90b63ee2e14bd8d3db8f705a6d75d64e6ee1b7c2c8833747ce706e1e0ce/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea793e075b70290d89d8142074262885d3f7da19634845135751bd6344f73b50", size = 2286703, upload-time = "2026-05-06T13:37:53.304Z" }, + { url = "https://files.pythonhosted.org/packages/ba/1e/acc4d70f88a0a277e4a1fa77ebb985ceabaf900430f875bf9338e11c9420/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:395aebd9183f9d112f569aeb5b2214d1a10a33bec8456447f7fbdfa51d38d4cd", size = 2092042, upload-time = "2026-05-06T13:38:46.981Z" }, + { url = "https://files.pythonhosted.org/packages/a9/da/0a422b57bf8504102bf3c4ccea9c41bab5a5cee6a54650acf8faf67f5a24/pydantic_core-2.46.4-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:b078afbc25f3a1436c7a1d2cd3e322497ee99615ba97c563566fdf46aff1ee01", size = 2117231, upload-time = "2026-05-06T13:39:23.146Z" }, + { url = "https://files.pythonhosted.org/packages/bd/2a/2ac13c3af305843e23c5078c53d135656b3f05a2fd78cb7bbbb12e97b473/pydantic_core-2.46.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f747929cf940cddb5b3668a390056ddd5ba2e5010615ea2dcf4f9c4f3ab8791d", size = 2168388, upload-time = "2026-05-06T13:40:08.06Z" }, + { url = "https://files.pythonhosted.org/packages/72/04/2beacf7e1607e93eefe4aed1b4709f079b905fb77530179d4f7c71745f22/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:daa27d92c36f24388fe3ad306b174781c747627f134452e4f128ea00ce1fe8c4", size = 2184769, upload-time = "2026-05-06T13:38:13.901Z" }, + { url = "https://files.pythonhosted.org/packages/9e/29/d2b9fd9f539133548eaf622c06a4ce176cb46ac59f32d0359c4abc0de047/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:19e51f073cd3df251856a8a4189fbdf1de4012c3ebacfb1884f94f1eb406079f", size = 2319312, upload-time = "2026-05-06T13:39:08.24Z" }, + { url = "https://files.pythonhosted.org/packages/7c/af/0f7a5b85fec6075bea96e3ef9187de38fccced0de92c1e7feda8d5cc7bb9/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c1747f85cee84c26985853c6f3d9bd3e75da5212912443fa111c113b9c246f39", size = 2361817, upload-time = "2026-05-06T13:38:43.2Z" }, + { url = "https://files.pythonhosted.org/packages/25/a4/73363fec545fd3ec025490bdda2743c56d0dd5b6266b1a53bbe9e4265375/pydantic_core-2.46.4-cp310-cp310-win32.whl", hash = "sha256:2f84c03c8607173d16b5a854ec68a2f9079ae03237a54fb506d13af47e1d018d", size = 1987085, upload-time = "2026-05-06T13:39:25.497Z" }, + { url = "https://files.pythonhosted.org/packages/01/aa/62f082da2c91fac1c234bc9ee0066257ce83f0604abd72e4c9d5991f2d84/pydantic_core-2.46.4-cp310-cp310-win_amd64.whl", hash = "sha256:8358a950c8909158e3df31538a7e4edc2d7265a7c54b47f0864d9e5bae9dcebf", size = 2074311, upload-time = "2026-05-06T13:39:59.922Z" }, + { url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" }, + { url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" }, + { url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051, upload-time = "2026-05-06T13:38:10.447Z" }, + { url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314, upload-time = "2026-05-06T13:40:13.089Z" }, + { url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146, upload-time = "2026-05-06T13:38:59.224Z" }, + { url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685, upload-time = "2026-05-06T13:38:17.762Z" }, + { url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420, upload-time = "2026-05-06T13:37:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122, upload-time = "2026-05-06T13:37:01.167Z" }, + { url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573, upload-time = "2026-05-06T13:38:45.04Z" }, + { url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139, upload-time = "2026-05-06T13:37:15.539Z" }, + { url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433, upload-time = "2026-05-06T13:37:30.099Z" }, + { url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513, upload-time = "2026-05-06T13:38:15.669Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114, upload-time = "2026-05-06T13:40:35.416Z" }, + { url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298, upload-time = "2026-05-06T13:38:29.754Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, + { url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589, upload-time = "2026-05-06T13:37:10.817Z" }, + { url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552, upload-time = "2026-05-06T13:36:56.717Z" }, + { url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984, upload-time = "2026-05-06T13:39:06.207Z" }, + { url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417, upload-time = "2026-05-06T13:39:45.476Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, + { url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782, upload-time = "2026-05-06T13:37:04.016Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146, upload-time = "2026-05-06T13:39:43.092Z" }, + { url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492, upload-time = "2026-05-06T13:36:58.124Z" }, + { url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604, upload-time = "2026-05-06T13:37:49.88Z" }, + { url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828, upload-time = "2026-05-06T13:37:43.053Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000, upload-time = "2026-05-06T13:37:56.694Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286, upload-time = "2026-05-06T13:40:05.667Z" }, + { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pyproject-api" +version = "1.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ab/bd/2f985c12bf33fdd8637e3a8f9418d6806f177601dee7c4924d0b2bb28650/pyproject_api-1.11.0.tar.gz", hash = "sha256:b8807d85a293e6c9f133e6575946fed45f1d42b22d58c780b33aa2421a799549", size = 23787, upload-time = "2026-07-21T13:09:33.744Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/6a/2822ee12bafe87af8f6cc620f7d1f126e77f82ce56ba888b94a9af5a979c/pyproject_api-1.11.0-py3-none-any.whl", hash = "sha256:860060c8832dce983b5eec6f41c4c43eb3ec06ff7332387a63acdf5ca27b68d8", size = 13275, upload-time = "2026-07-21T13:09:32.559Z" }, +] + +[[package]] +name = "pyproject-hooks" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/82/28175b2414effca1cdac8dc99f76d660e7a4fb0ceefa4b4ab8f5f6742925/pyproject_hooks-1.2.0.tar.gz", hash = "sha256:1e859bd5c40fae9448642dd871adf459e5e2084186e8d2c2a79a824c970da1f8", size = 19228, upload-time = "2024-09-29T09:24:13.293Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl", hash = "sha256:9e5c6bfa8dcc30091c74b0cf803c81fdd29d94f01992a7707bc97babb1141913", size = 10216, upload-time = "2024-09-29T09:24:11.978Z" }, +] + +[[package]] +name = "pyrefly" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/20/976165fa4b1517a1a92f393b3f4d4badabfff1165eff09d4cd4908428183/pyrefly-1.1.1.tar.gz", hash = "sha256:6deda959f8603a7dbdf112c48983e2275b2903cf33c8c739ed65d7e71a4fd520", size = 5880491, upload-time = "2026-06-18T23:45:43.785Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b5/d6/02ba666018c6a1cb4ddfa2db98ada721adddd374db5c29ba47a0bf2637fa/pyrefly-1.1.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:f4b8595f91885bc8b5e3c282ab68d1df21201668a84e6508b1e15f2feec0bb8d", size = 13631867, upload-time = "2026-06-18T23:45:13.923Z" }, + { url = "https://files.pythonhosted.org/packages/71/47/7a3457dbbddb513a83cf4fe527d5d5ebda5201a1010ad2a6034030e3e358/pyrefly-1.1.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d6b238e1362622d47a6eb5af704fd8b613c94e8c303386efd6350e3da59fecc8", size = 13075304, upload-time = "2026-06-18T23:45:16.865Z" }, + { url = "https://files.pythonhosted.org/packages/84/df/70f4b3f42d58ed686a80df31e04eca54d88036cea4f9b96195c64ad0b2b5/pyrefly-1.1.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b50d4510e4f8aaea79e2c4b343a4d7a060c9451c0b2aa9bfe10d7ca1ef33d68d", size = 13446966, upload-time = "2026-06-18T23:45:19.644Z" }, + { url = "https://files.pythonhosted.org/packages/3c/53/12a19bd6c7af985bcbc13c6910d0f9f6684069ead2282a5c08c2bfbb5d03/pyrefly-1.1.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f330cf039ef3da3b910c84f3a7e431f0cf8d0c1d2dad26491d6cadf3c7cd4759", size = 14449222, upload-time = "2026-06-18T23:45:22.252Z" }, + { url = "https://files.pythonhosted.org/packages/93/f0/e55c48a50076fc0f9ecf4bdedec50456db383e01162f5e2121f8468be071/pyrefly-1.1.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a6342d87c52b04f72156da04f554c4d57f3616f2b32d1763969efb22d05a1407", size = 14472947, upload-time = "2026-06-18T23:45:24.858Z" }, + { url = "https://files.pythonhosted.org/packages/b6/e7/30e085b31fed978ecb675bdbb54df566673ab550469e5af2d350f6af0be6/pyrefly-1.1.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c08b814ad03175e9cf47111390537161828b472044c39ab3320252b3ac6b2edd", size = 13975252, upload-time = "2026-06-18T23:45:27.247Z" }, + { url = "https://files.pythonhosted.org/packages/47/58/49c3e67641133d3fe5d8d9a660dc0826c6c37ca197d86cad05fa7dd8bfd6/pyrefly-1.1.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:d50cad97f19fc893b04deff7239626cffff5dd27ffb29b7d303a1b770247b208", size = 13471780, upload-time = "2026-06-18T23:45:29.775Z" }, + { url = "https://files.pythonhosted.org/packages/71/1e/65a7ba8355e2c39d8331832905fb74dcc85fc122a3f1dfd6dbf2a88907ad/pyrefly-1.1.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:2150b450ee6a6bcbe69b2d45d9a4ebc934a609e1abcf65e490433f38eb873d84", size = 13989306, upload-time = "2026-06-18T23:45:32.576Z" }, + { url = "https://files.pythonhosted.org/packages/37/de/b7ee1ab2392c36945738246fba7524439810befa3cfcc03cb6157567fc10/pyrefly-1.1.1-py3-none-win32.whl", hash = "sha256:5ffd8a8ed62fe4e6bf0afe1837d1bad149bb3b9f80e928ef248c96b836db3742", size = 12608469, upload-time = "2026-06-18T23:45:35.419Z" }, + { url = "https://files.pythonhosted.org/packages/a6/9c/a0f5b52934bf80e9c7eff08222e7caf318287b9aef76acb8d9ac5740581b/pyrefly-1.1.1-py3-none-win_amd64.whl", hash = "sha256:4e0430f3ef69c8ac73505fd6584db70ed504665a9f0816fef7f723de510f26cb", size = 13502172, upload-time = "2026-06-18T23:45:38.375Z" }, + { url = "https://files.pythonhosted.org/packages/42/3d/4c6bcb3d456835f51445d3662a428f56c3ea5643ec798c577030ae34298c/pyrefly-1.1.1-py3-none-win_arm64.whl", hash = "sha256:83baf0db71e172665db1fca0ced50b8f7773f5192ca57e8ac6773a772b6d2fc5", size = 12895979, upload-time = "2026-06-18T23:45:41.026Z" }, +] + +[[package]] +name = "pyright" +version = "1.1.411" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nodeenv" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7e/ab/265f7dc69d28113ebba19092e57b075f41543b2ed048429c5f56e2b88eac/pyright-1.1.411.tar.gz", hash = "sha256:d885a0551f2e763b089a02702174e7f4ba77548cddabc972ab86d1f7f1b0f998", size = 4112861, upload-time = "2026-06-25T02:14:06.37Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/49/385be530a6a5b78d1cbcd5c2e38debc8959a2fc6bdb716f4e581002979fc/pyright-1.1.411-py3-none-any.whl", hash = "sha256:dc7c72a8e2700c55baa127554040e067041ea53ccfd50bf96308cc4291c7d5d9", size = 6181526, upload-time = "2026-06-25T02:14:04.691Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pytest-timeout" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/82/4c9ecabab13363e72d880f2fb504c5f750433b2b6f16e99f4ec21ada284c/pytest_timeout-2.4.0.tar.gz", hash = "sha256:7e68e90b01f9eff71332b25001f85c75495fc4e3a836701876183c4bcfd0540a", size = 17973, upload-time = "2025-05-05T19:44:34.99Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/b6/3127540ecdf1464a00e5a01ee60a1b09175f6913f0644ac748494d9c4b21/pytest_timeout-2.4.0-py3-none-any.whl", hash = "sha256:c42667e5cdadb151aeb5b26d114aff6bdf5a907f176a007a30b940d3d865b5c2", size = 14382, upload-time = "2025-05-05T19:44:33.502Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "python-discovery" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "filelock" }, + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f1/51/276f964496a5714ab9f320896195639086881c2b39c03b5ad13de84acbb8/python_discovery-1.5.0.tar.gz", hash = "sha256:3e014c6327154d3dda27939a9a0dc9c5c000439f1906d3f303b48f984bd2ecef", size = 72483, upload-time = "2026-07-21T13:14:14.641Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/7b/14882602ddee241d7984a742fcb423cb4a30fb0d6efc546ac3129fba475a/python_discovery-1.5.0-py3-none-any.whl", hash = "sha256:70c4fc61b4e7404e44f01d6fc44a715c4d685ca6cea83d295922f05891877c98", size = 34205, upload-time = "2026-07-21T13:14:13.398Z" }, +] + +[[package]] +name = "python-json-logger" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f7/ff/3cc9165fd44106973cd7ac9facb674a65ed853494592541d339bdc9a30eb/python_json_logger-4.1.0.tar.gz", hash = "sha256:b396b9e3ed782b09ff9d6e4f1683d46c83ad0d35d2e407c09a9ebbf038f88195", size = 17573, upload-time = "2026-03-29T04:39:56.805Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/be/0631a861af4d1c875f096c07d34e9a63639560a717130e7a87cbc82b7e3f/python_json_logger-4.1.0-py3-none-any.whl", hash = "sha256:132994765cf75bf44554be9aa49b06ef2345d23661a96720262716438141b6b2", size = 15021, upload-time = "2026-03-29T04:39:55.266Z" }, +] + +[[package]] +name = "pytz" +version = "2026.3.post1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fb/48/fb042503b6ca6cd271261dc559fd6432f7d8c713153e9ec5c591af4dfc1c/pytz-2026.3.post1.tar.gz", hash = "sha256:2211d3fcf9a797d3405cac96ac7f61d80e6a644f72a3309607282fe8a2010c5d", size = 319745, upload-time = "2026-07-25T15:12:07.385Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/7b/39c34ca613b0b198cb866466651b26b045e2009864c5183c979a3b83f383/pytz-2026.3.post1-py2.py3-none-any.whl", hash = "sha256:dd95840dd199baea12d9cc096a1d452caa6596a1c1e4b5f3dbd1541855d5e815", size = 508283, upload-time = "2026-07-25T15:12:05.782Z" }, +] + +[[package]] +name = "pywinpty" +version = "3.0.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a9/ef/2d27f30c59a67be7025b2d7858c8c2d282b74d66544b2384730b82de74fd/pywinpty-3.0.5.tar.gz", hash = "sha256:61db0db063de9865adbea66db294628f8577f608d9764a4c7d3384eeacc4e81b", size = 16223484, upload-time = "2026-06-11T00:11:58.93Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/89/ff/8bd91d4502b48bee0459bc912806fa512111905ab97d317e7cb1201b3542/pywinpty-3.0.5-cp310-cp310-win_amd64.whl", hash = "sha256:b467dcad72365bc2205ed8b6e694e817d71de269d46e56cfe267dfa9d3d30e1b", size = 2092426, upload-time = "2026-06-10T23:40:23.292Z" }, + { url = "https://files.pythonhosted.org/packages/00/d2/dee13e67af2300a080dea30d4da8fdb05133a7eebb39171111f0530f7e88/pywinpty-3.0.5-cp310-cp310-win_arm64.whl", hash = "sha256:7dc4046ea8e4d7f0a16dae8dfcaeeda6df7ca3a9330444d2ba5bb96138fe0a91", size = 818083, upload-time = "2026-06-10T23:42:04.203Z" }, + { url = "https://files.pythonhosted.org/packages/b0/5c/31feb3dd82d1b33ae0bd09ca601edb993d9da1b7f0226b3336d4b4c39e1e/pywinpty-3.0.5-cp311-cp311-win_amd64.whl", hash = "sha256:af7a8720c78776ddd6259b71dd567944f766a6cd67f8d2887fbc4973967bacda", size = 2092466, upload-time = "2026-06-10T23:44:24.453Z" }, + { url = "https://files.pythonhosted.org/packages/ee/fe/fe23e2229ffec0c10190cef5964f5c9b2dba179d23b69ae537b7ea90bcab/pywinpty-3.0.5-cp311-cp311-win_arm64.whl", hash = "sha256:c2406f54f699eab75953fb75ce805f2ae55a33a957cd070890abd454fb4b7680", size = 818395, upload-time = "2026-06-10T23:41:56.93Z" }, + { url = "https://files.pythonhosted.org/packages/45/34/942cc95ca4e26489875aa8a95192766247a687379ec29543eebe73ec945f/pywinpty-3.0.5-cp312-cp312-win_amd64.whl", hash = "sha256:d62946adf14b15b54c0b8d785f93fe18b04da23f4ad59e2e8c4612646e9abd23", size = 2090915, upload-time = "2026-06-10T23:43:14.98Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/5b9053004844139ea8bd86209c57ade12b134b2782f383a095784c8531ec/pywinpty-3.0.5-cp312-cp312-win_arm64.whl", hash = "sha256:e9391c05fbfa7a992a97e831fc6849887b4014a614192e3d984a7ca59592b376", size = 815934, upload-time = "2026-06-10T23:41:42.384Z" }, + { url = "https://files.pythonhosted.org/packages/b9/f4/2a464b9893cceb3b3f416356e94fdc3e1bca9476993927e4e6d99fe95382/pywinpty-3.0.5-cp313-cp313-win_amd64.whl", hash = "sha256:48db1b0ad9d0a1b81dcaaa7163a99a7808deaceb0c1b2344716dc1fc090c3c4c", size = 2090471, upload-time = "2026-06-10T23:42:11.071Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2c/a138491a0afbdb50eb79395577bd326d4b0fbde7209417d1a8087ff2493a/pywinpty-3.0.5-cp313-cp313-win_arm64.whl", hash = "sha256:2c6008fb2d3774b48693b2fcb7f2cc317ade9dc581289a964ffeeaf81307c9b5", size = 815518, upload-time = "2026-06-10T23:42:02.363Z" }, + { url = "https://files.pythonhosted.org/packages/6f/15/54400049a380582acd1282665c70fcf11e0bd3713679aca78e24c3aae738/pywinpty-3.0.5-cp313-cp313t-win_amd64.whl", hash = "sha256:22ce1b780d89821cc52daf6eac0708af22d93d000ce9c7c07e37489db8594598", size = 2089920, upload-time = "2026-06-10T23:44:13.395Z" }, + { url = "https://files.pythonhosted.org/packages/94/0c/6f24f3c0799f502259b24bdf841a99ad2b0d59df5c2525b4e2a286d14be2/pywinpty-3.0.5-cp313-cp313t-win_arm64.whl", hash = "sha256:9c2919a81bc5cfb09b86fc5a002112b2de95ca4304a07413cbeeb746a1307a5c", size = 814520, upload-time = "2026-06-10T23:43:28.588Z" }, + { url = "https://files.pythonhosted.org/packages/e9/23/f3cd1b1e5fc56517f54452c49f92049e7dd9ffc8a63de22a495581f50d04/pywinpty-3.0.5-cp314-cp314-win_amd64.whl", hash = "sha256:03bb3c16d691d9242267201830bcd0e64a9b663170e9042bc84b210da9de15ac", size = 2090663, upload-time = "2026-06-10T23:43:59.845Z" }, + { url = "https://files.pythonhosted.org/packages/9d/dd/96d6cbfc6d9ddab5c1c2f92c26545ae8997446a2ba7ee2024cd43c81f49b/pywinpty-3.0.5-cp314-cp314-win_arm64.whl", hash = "sha256:89c5c6ef08997a3b4b277b214a35fe15cab4dd6d119f0140aa71df5b1168fdbc", size = 815700, upload-time = "2026-06-10T23:40:50.001Z" }, + { url = "https://files.pythonhosted.org/packages/30/36/d98087bce0acaa4cce7f196103cfa7be3f63ce65f52473bb3e38784ae5d9/pywinpty-3.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:7b566165e0c5fdd6abe167a5ac8b954be6a843eb55a85946576d6bc1dea03d6d", size = 2090093, upload-time = "2026-06-10T23:40:58.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/fd/fe2b0db922ba052ce3976a08f3fc05d0c05047c8b4ebb6102e832b8ef563/pywinpty-3.0.5-cp314-cp314t-win_arm64.whl", hash = "sha256:24366280a8aa677323da87bec729cb3ea3b35367386cece0978bdc6e4695c690", size = 814517, upload-time = "2026-06-10T23:42:34.946Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, + { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, + { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, + { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "pyzmq" +version = "27.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "implementation_name == 'pypy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/04/0b/3c9baedbdf613ecaa7aa07027780b8867f57b6293b6ee50de316c9f3222b/pyzmq-27.1.0.tar.gz", hash = "sha256:ac0765e3d44455adb6ddbf4417dcce460fc40a05978c08efdf2948072f6db540", size = 281750, upload-time = "2025-09-08T23:10:18.157Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/b9/52aa9ec2867528b54f1e60846728d8b4d84726630874fee3a91e66c7df81/pyzmq-27.1.0-cp310-cp310-macosx_10_15_universal2.whl", hash = "sha256:508e23ec9bc44c0005c4946ea013d9317ae00ac67778bd47519fdf5a0e930ff4", size = 1329850, upload-time = "2025-09-08T23:07:26.274Z" }, + { url = "https://files.pythonhosted.org/packages/99/64/5653e7b7425b169f994835a2b2abf9486264401fdef18df91ddae47ce2cc/pyzmq-27.1.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:507b6f430bdcf0ee48c0d30e734ea89ce5567fd7b8a0f0044a369c176aa44556", size = 906380, upload-time = "2025-09-08T23:07:29.78Z" }, + { url = "https://files.pythonhosted.org/packages/73/78/7d713284dbe022f6440e391bd1f3c48d9185673878034cfb3939cdf333b2/pyzmq-27.1.0-cp310-cp310-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf7b38f9fd7b81cb6d9391b2946382c8237fd814075c6aa9c3b746d53076023b", size = 666421, upload-time = "2025-09-08T23:07:31.263Z" }, + { url = "https://files.pythonhosted.org/packages/30/76/8f099f9d6482450428b17c4d6b241281af7ce6a9de8149ca8c1c649f6792/pyzmq-27.1.0-cp310-cp310-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:03ff0b279b40d687691a6217c12242ee71f0fba28bf8626ff50e3ef0f4410e1e", size = 854149, upload-time = "2025-09-08T23:07:33.17Z" }, + { url = "https://files.pythonhosted.org/packages/59/f0/37fbfff06c68016019043897e4c969ceab18bde46cd2aca89821fcf4fb2e/pyzmq-27.1.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:677e744fee605753eac48198b15a2124016c009a11056f93807000ab11ce6526", size = 1655070, upload-time = "2025-09-08T23:07:35.205Z" }, + { url = "https://files.pythonhosted.org/packages/47/14/7254be73f7a8edc3587609554fcaa7bfd30649bf89cd260e4487ca70fdaa/pyzmq-27.1.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:dd2fec2b13137416a1c5648b7009499bcc8fea78154cd888855fa32514f3dad1", size = 2033441, upload-time = "2025-09-08T23:07:37.432Z" }, + { url = "https://files.pythonhosted.org/packages/22/dc/49f2be26c6f86f347e796a4d99b19167fc94503f0af3fd010ad262158822/pyzmq-27.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:08e90bb4b57603b84eab1d0ca05b3bbb10f60c1839dc471fc1c9e1507bef3386", size = 1891529, upload-time = "2025-09-08T23:07:39.047Z" }, + { url = "https://files.pythonhosted.org/packages/a3/3e/154fb963ae25be70c0064ce97776c937ecc7d8b0259f22858154a9999769/pyzmq-27.1.0-cp310-cp310-win32.whl", hash = "sha256:a5b42d7a0658b515319148875fcb782bbf118dd41c671b62dae33666c2213bda", size = 567276, upload-time = "2025-09-08T23:07:40.695Z" }, + { url = "https://files.pythonhosted.org/packages/62/b2/f4ab56c8c595abcb26b2be5fd9fa9e6899c1e5ad54964e93ae8bb35482be/pyzmq-27.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:c0bb87227430ee3aefcc0ade2088100e528d5d3298a0a715a64f3d04c60ba02f", size = 632208, upload-time = "2025-09-08T23:07:42.298Z" }, + { url = "https://files.pythonhosted.org/packages/3b/e3/be2cc7ab8332bdac0522fdb64c17b1b6241a795bee02e0196636ec5beb79/pyzmq-27.1.0-cp310-cp310-win_arm64.whl", hash = "sha256:9a916f76c2ab8d045b19f2286851a38e9ac94ea91faf65bd64735924522a8b32", size = 559766, upload-time = "2025-09-08T23:07:43.869Z" }, + { url = "https://files.pythonhosted.org/packages/06/5d/305323ba86b284e6fcb0d842d6adaa2999035f70f8c38a9b6d21ad28c3d4/pyzmq-27.1.0-cp311-cp311-macosx_10_15_universal2.whl", hash = "sha256:226b091818d461a3bef763805e75685e478ac17e9008f49fce2d3e52b3d58b86", size = 1333328, upload-time = "2025-09-08T23:07:45.946Z" }, + { url = "https://files.pythonhosted.org/packages/bd/a0/fc7e78a23748ad5443ac3275943457e8452da67fda347e05260261108cbc/pyzmq-27.1.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:0790a0161c281ca9723f804871b4027f2e8b5a528d357c8952d08cd1a9c15581", size = 908803, upload-time = "2025-09-08T23:07:47.551Z" }, + { url = "https://files.pythonhosted.org/packages/7e/22/37d15eb05f3bdfa4abea6f6d96eb3bb58585fbd3e4e0ded4e743bc650c97/pyzmq-27.1.0-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c895a6f35476b0c3a54e3eb6ccf41bf3018de937016e6e18748317f25d4e925f", size = 668836, upload-time = "2025-09-08T23:07:49.436Z" }, + { url = "https://files.pythonhosted.org/packages/b1/c4/2a6fe5111a01005fc7af3878259ce17684fabb8852815eda6225620f3c59/pyzmq-27.1.0-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bbf8d3630bf96550b3be8e1fc0fea5cbdc8d5466c1192887bd94869da17a63e", size = 857038, upload-time = "2025-09-08T23:07:51.234Z" }, + { url = "https://files.pythonhosted.org/packages/cb/eb/bfdcb41d0db9cd233d6fb22dc131583774135505ada800ebf14dfb0a7c40/pyzmq-27.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:15c8bd0fe0dabf808e2d7a681398c4e5ded70a551ab47482067a572c054c8e2e", size = 1657531, upload-time = "2025-09-08T23:07:52.795Z" }, + { url = "https://files.pythonhosted.org/packages/ab/21/e3180ca269ed4a0de5c34417dfe71a8ae80421198be83ee619a8a485b0c7/pyzmq-27.1.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:bafcb3dd171b4ae9f19ee6380dfc71ce0390fefaf26b504c0e5f628d7c8c54f2", size = 2034786, upload-time = "2025-09-08T23:07:55.047Z" }, + { url = "https://files.pythonhosted.org/packages/3b/b1/5e21d0b517434b7f33588ff76c177c5a167858cc38ef740608898cd329f2/pyzmq-27.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e829529fcaa09937189178115c49c504e69289abd39967cd8a4c215761373394", size = 1894220, upload-time = "2025-09-08T23:07:57.172Z" }, + { url = "https://files.pythonhosted.org/packages/03/f2/44913a6ff6941905efc24a1acf3d3cb6146b636c546c7406c38c49c403d4/pyzmq-27.1.0-cp311-cp311-win32.whl", hash = "sha256:6df079c47d5902af6db298ec92151db82ecb557af663098b92f2508c398bb54f", size = 567155, upload-time = "2025-09-08T23:07:59.05Z" }, + { url = "https://files.pythonhosted.org/packages/23/6d/d8d92a0eb270a925c9b4dd039c0b4dc10abc2fcbc48331788824ef113935/pyzmq-27.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:190cbf120fbc0fc4957b56866830def56628934a9d112aec0e2507aa6a032b97", size = 633428, upload-time = "2025-09-08T23:08:00.663Z" }, + { url = "https://files.pythonhosted.org/packages/ae/14/01afebc96c5abbbd713ecfc7469cfb1bc801c819a74ed5c9fad9a48801cb/pyzmq-27.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:eca6b47df11a132d1745eb3b5b5e557a7dae2c303277aa0e69c6ba91b8736e07", size = 559497, upload-time = "2025-09-08T23:08:02.15Z" }, + { url = "https://files.pythonhosted.org/packages/92/e7/038aab64a946d535901103da16b953c8c9cc9c961dadcbf3609ed6428d23/pyzmq-27.1.0-cp312-abi3-macosx_10_15_universal2.whl", hash = "sha256:452631b640340c928fa343801b0d07eb0c3789a5ffa843f6e1a9cee0ba4eb4fc", size = 1306279, upload-time = "2025-09-08T23:08:03.807Z" }, + { url = "https://files.pythonhosted.org/packages/e8/5e/c3c49fdd0f535ef45eefcc16934648e9e59dace4a37ee88fc53f6cd8e641/pyzmq-27.1.0-cp312-abi3-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:1c179799b118e554b66da67d88ed66cd37a169f1f23b5d9f0a231b4e8d44a113", size = 895645, upload-time = "2025-09-08T23:08:05.301Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e5/b0b2504cb4e903a74dcf1ebae157f9e20ebb6ea76095f6cfffea28c42ecd/pyzmq-27.1.0-cp312-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3837439b7f99e60312f0c926a6ad437b067356dc2bc2ec96eb395fd0fe804233", size = 652574, upload-time = "2025-09-08T23:08:06.828Z" }, + { url = "https://files.pythonhosted.org/packages/f8/9b/c108cdb55560eaf253f0cbdb61b29971e9fb34d9c3499b0e96e4e60ed8a5/pyzmq-27.1.0-cp312-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43ad9a73e3da1fab5b0e7e13402f0b2fb934ae1c876c51d0afff0e7c052eca31", size = 840995, upload-time = "2025-09-08T23:08:08.396Z" }, + { url = "https://files.pythonhosted.org/packages/c2/bb/b79798ca177b9eb0825b4c9998c6af8cd2a7f15a6a1a4272c1d1a21d382f/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0de3028d69d4cdc475bfe47a6128eb38d8bc0e8f4d69646adfbcd840facbac28", size = 1642070, upload-time = "2025-09-08T23:08:09.989Z" }, + { url = "https://files.pythonhosted.org/packages/9c/80/2df2e7977c4ede24c79ae39dcef3899bfc5f34d1ca7a5b24f182c9b7a9ca/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_i686.whl", hash = "sha256:cf44a7763aea9298c0aa7dbf859f87ed7012de8bda0f3977b6fb1d96745df856", size = 2021121, upload-time = "2025-09-08T23:08:11.907Z" }, + { url = "https://files.pythonhosted.org/packages/46/bd/2d45ad24f5f5ae7e8d01525eb76786fa7557136555cac7d929880519e33a/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:f30f395a9e6fbca195400ce833c731e7b64c3919aa481af4d88c3759e0cb7496", size = 1878550, upload-time = "2025-09-08T23:08:13.513Z" }, + { url = "https://files.pythonhosted.org/packages/e6/2f/104c0a3c778d7c2ab8190e9db4f62f0b6957b53c9d87db77c284b69f33ea/pyzmq-27.1.0-cp312-abi3-win32.whl", hash = "sha256:250e5436a4ba13885494412b3da5d518cd0d3a278a1ae640e113c073a5f88edd", size = 559184, upload-time = "2025-09-08T23:08:15.163Z" }, + { url = "https://files.pythonhosted.org/packages/fc/7f/a21b20d577e4100c6a41795842028235998a643b1ad406a6d4163ea8f53e/pyzmq-27.1.0-cp312-abi3-win_amd64.whl", hash = "sha256:9ce490cf1d2ca2ad84733aa1d69ce6855372cb5ce9223802450c9b2a7cba0ccf", size = 619480, upload-time = "2025-09-08T23:08:17.192Z" }, + { url = "https://files.pythonhosted.org/packages/78/c2/c012beae5f76b72f007a9e91ee9401cb88c51d0f83c6257a03e785c81cc2/pyzmq-27.1.0-cp312-abi3-win_arm64.whl", hash = "sha256:75a2f36223f0d535a0c919e23615fc85a1e23b71f40c7eb43d7b1dedb4d8f15f", size = 552993, upload-time = "2025-09-08T23:08:18.926Z" }, + { url = "https://files.pythonhosted.org/packages/60/cb/84a13459c51da6cec1b7b1dc1a47e6db6da50b77ad7fd9c145842750a011/pyzmq-27.1.0-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:93ad4b0855a664229559e45c8d23797ceac03183c7b6f5b4428152a6b06684a5", size = 1122436, upload-time = "2025-09-08T23:08:20.801Z" }, + { url = "https://files.pythonhosted.org/packages/dc/b6/94414759a69a26c3dd674570a81813c46a078767d931a6c70ad29fc585cb/pyzmq-27.1.0-cp313-cp313-android_24_x86_64.whl", hash = "sha256:fbb4f2400bfda24f12f009cba62ad5734148569ff4949b1b6ec3b519444342e6", size = 1156301, upload-time = "2025-09-08T23:08:22.47Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ad/15906493fd40c316377fd8a8f6b1f93104f97a752667763c9b9c1b71d42d/pyzmq-27.1.0-cp313-cp313t-macosx_10_15_universal2.whl", hash = "sha256:e343d067f7b151cfe4eb3bb796a7752c9d369eed007b91231e817071d2c2fec7", size = 1341197, upload-time = "2025-09-08T23:08:24.286Z" }, + { url = "https://files.pythonhosted.org/packages/14/1d/d343f3ce13db53a54cb8946594e567410b2125394dafcc0268d8dda027e0/pyzmq-27.1.0-cp313-cp313t-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:08363b2011dec81c354d694bdecaef4770e0ae96b9afea70b3f47b973655cc05", size = 897275, upload-time = "2025-09-08T23:08:26.063Z" }, + { url = "https://files.pythonhosted.org/packages/69/2d/d83dd6d7ca929a2fc67d2c3005415cdf322af7751d773524809f9e585129/pyzmq-27.1.0-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d54530c8c8b5b8ddb3318f481297441af102517602b569146185fa10b63f4fa9", size = 660469, upload-time = "2025-09-08T23:08:27.623Z" }, + { url = "https://files.pythonhosted.org/packages/3e/cd/9822a7af117f4bc0f1952dbe9ef8358eb50a24928efd5edf54210b850259/pyzmq-27.1.0-cp313-cp313t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f3afa12c392f0a44a2414056d730eebc33ec0926aae92b5ad5cf26ebb6cc128", size = 847961, upload-time = "2025-09-08T23:08:29.672Z" }, + { url = "https://files.pythonhosted.org/packages/9a/12/f003e824a19ed73be15542f172fd0ec4ad0b60cf37436652c93b9df7c585/pyzmq-27.1.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c65047adafe573ff023b3187bb93faa583151627bc9c51fc4fb2c561ed689d39", size = 1650282, upload-time = "2025-09-08T23:08:31.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/4a/e82d788ed58e9a23995cee70dbc20c9aded3d13a92d30d57ec2291f1e8a3/pyzmq-27.1.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:90e6e9441c946a8b0a667356f7078d96411391a3b8f80980315455574177ec97", size = 2024468, upload-time = "2025-09-08T23:08:33.543Z" }, + { url = "https://files.pythonhosted.org/packages/d9/94/2da0a60841f757481e402b34bf4c8bf57fa54a5466b965de791b1e6f747d/pyzmq-27.1.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:add071b2d25f84e8189aaf0882d39a285b42fa3853016ebab234a5e78c7a43db", size = 1885394, upload-time = "2025-09-08T23:08:35.51Z" }, + { url = "https://files.pythonhosted.org/packages/4f/6f/55c10e2e49ad52d080dc24e37adb215e5b0d64990b57598abc2e3f01725b/pyzmq-27.1.0-cp313-cp313t-win32.whl", hash = "sha256:7ccc0700cfdf7bd487bea8d850ec38f204478681ea02a582a8da8171b7f90a1c", size = 574964, upload-time = "2025-09-08T23:08:37.178Z" }, + { url = "https://files.pythonhosted.org/packages/87/4d/2534970ba63dd7c522d8ca80fb92777f362c0f321900667c615e2067cb29/pyzmq-27.1.0-cp313-cp313t-win_amd64.whl", hash = "sha256:8085a9fba668216b9b4323be338ee5437a235fe275b9d1610e422ccc279733e2", size = 641029, upload-time = "2025-09-08T23:08:40.595Z" }, + { url = "https://files.pythonhosted.org/packages/f6/fa/f8aea7a28b0641f31d40dea42d7ef003fded31e184ef47db696bc74cd610/pyzmq-27.1.0-cp313-cp313t-win_arm64.whl", hash = "sha256:6bb54ca21bcfe361e445256c15eedf083f153811c37be87e0514934d6913061e", size = 561541, upload-time = "2025-09-08T23:08:42.668Z" }, + { url = "https://files.pythonhosted.org/packages/87/45/19efbb3000956e82d0331bafca5d9ac19ea2857722fa2caacefb6042f39d/pyzmq-27.1.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:ce980af330231615756acd5154f29813d553ea555485ae712c491cd483df6b7a", size = 1341197, upload-time = "2025-09-08T23:08:44.973Z" }, + { url = "https://files.pythonhosted.org/packages/48/43/d72ccdbf0d73d1343936296665826350cb1e825f92f2db9db3e61c2162a2/pyzmq-27.1.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:1779be8c549e54a1c38f805e56d2a2e5c009d26de10921d7d51cfd1c8d4632ea", size = 897175, upload-time = "2025-09-08T23:08:46.601Z" }, + { url = "https://files.pythonhosted.org/packages/2f/2e/a483f73a10b65a9ef0161e817321d39a770b2acf8bcf3004a28d90d14a94/pyzmq-27.1.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7200bb0f03345515df50d99d3db206a0a6bee1955fbb8c453c76f5bf0e08fb96", size = 660427, upload-time = "2025-09-08T23:08:48.187Z" }, + { url = "https://files.pythonhosted.org/packages/f5/d2/5f36552c2d3e5685abe60dfa56f91169f7a2d99bbaf67c5271022ab40863/pyzmq-27.1.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01c0e07d558b06a60773744ea6251f769cd79a41a97d11b8bf4ab8f034b0424d", size = 847929, upload-time = "2025-09-08T23:08:49.76Z" }, + { url = "https://files.pythonhosted.org/packages/c4/2a/404b331f2b7bf3198e9945f75c4c521f0c6a3a23b51f7a4a401b94a13833/pyzmq-27.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:80d834abee71f65253c91540445d37c4c561e293ba6e741b992f20a105d69146", size = 1650193, upload-time = "2025-09-08T23:08:51.7Z" }, + { url = "https://files.pythonhosted.org/packages/1c/0b/f4107e33f62a5acf60e3ded67ed33d79b4ce18de432625ce2fc5093d6388/pyzmq-27.1.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:544b4e3b7198dde4a62b8ff6685e9802a9a1ebf47e77478a5eb88eca2a82f2fd", size = 2024388, upload-time = "2025-09-08T23:08:53.393Z" }, + { url = "https://files.pythonhosted.org/packages/0d/01/add31fe76512642fd6e40e3a3bd21f4b47e242c8ba33efb6809e37076d9b/pyzmq-27.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cedc4c68178e59a4046f97eca31b148ddcf51e88677de1ef4e78cf06c5376c9a", size = 1885316, upload-time = "2025-09-08T23:08:55.702Z" }, + { url = "https://files.pythonhosted.org/packages/c4/59/a5f38970f9bf07cee96128de79590bb354917914a9be11272cfc7ff26af0/pyzmq-27.1.0-cp314-cp314t-win32.whl", hash = "sha256:1f0b2a577fd770aa6f053211a55d1c47901f4d537389a034c690291485e5fe92", size = 587472, upload-time = "2025-09-08T23:08:58.18Z" }, + { url = "https://files.pythonhosted.org/packages/70/d8/78b1bad170f93fcf5e3536e70e8fadac55030002275c9a29e8f5719185de/pyzmq-27.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:19c9468ae0437f8074af379e986c5d3d7d7bfe033506af442e8c879732bedbe0", size = 661401, upload-time = "2025-09-08T23:08:59.802Z" }, + { url = "https://files.pythonhosted.org/packages/81/d6/4bfbb40c9a0b42fc53c7cf442f6385db70b40f74a783130c5d0a5aa62228/pyzmq-27.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dc5dbf68a7857b59473f7df42650c621d7e8923fb03fa74a526890f4d33cc4d7", size = 575170, upload-time = "2025-09-08T23:09:01.418Z" }, + { url = "https://files.pythonhosted.org/packages/f3/81/a65e71c1552f74dec9dff91d95bafb6e0d33338a8dfefbc88aa562a20c92/pyzmq-27.1.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:c17e03cbc9312bee223864f1a2b13a99522e0dc9f7c5df0177cd45210ac286e6", size = 836266, upload-time = "2025-09-08T23:09:40.048Z" }, + { url = "https://files.pythonhosted.org/packages/58/ed/0202ca350f4f2b69faa95c6d931e3c05c3a397c184cacb84cb4f8f42f287/pyzmq-27.1.0-pp310-pypy310_pp73-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:f328d01128373cb6763823b2b4e7f73bdf767834268c565151eacb3b7a392f90", size = 800206, upload-time = "2025-09-08T23:09:41.902Z" }, + { url = "https://files.pythonhosted.org/packages/47/42/1ff831fa87fe8f0a840ddb399054ca0009605d820e2b44ea43114f5459f4/pyzmq-27.1.0-pp310-pypy310_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c1790386614232e1b3a40a958454bdd42c6d1811837b15ddbb052a032a43f62", size = 567747, upload-time = "2025-09-08T23:09:43.741Z" }, + { url = "https://files.pythonhosted.org/packages/d1/db/5c4d6807434751e3f21231bee98109aa57b9b9b55e058e450d0aef59b70f/pyzmq-27.1.0-pp310-pypy310_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:448f9cb54eb0cee4732b46584f2710c8bc178b0e5371d9e4fc8125201e413a74", size = 747371, upload-time = "2025-09-08T23:09:45.575Z" }, + { url = "https://files.pythonhosted.org/packages/26/af/78ce193dbf03567eb8c0dc30e3df2b9e56f12a670bf7eb20f9fb532c7e8a/pyzmq-27.1.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:05b12f2d32112bf8c95ef2e74ec4f1d4beb01f8b5e703b38537f8849f92cb9ba", size = 544862, upload-time = "2025-09-08T23:09:47.448Z" }, + { url = "https://files.pythonhosted.org/packages/4c/c6/c4dcdecdbaa70969ee1fdced6d7b8f60cfabe64d25361f27ac4665a70620/pyzmq-27.1.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:18770c8d3563715387139060d37859c02ce40718d1faf299abddcdcc6a649066", size = 836265, upload-time = "2025-09-08T23:09:49.376Z" }, + { url = "https://files.pythonhosted.org/packages/3e/79/f38c92eeaeb03a2ccc2ba9866f0439593bb08c5e3b714ac1d553e5c96e25/pyzmq-27.1.0-pp311-pypy311_pp73-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:ac25465d42f92e990f8d8b0546b01c391ad431c3bf447683fdc40565941d0604", size = 800208, upload-time = "2025-09-08T23:09:51.073Z" }, + { url = "https://files.pythonhosted.org/packages/49/0e/3f0d0d335c6b3abb9b7b723776d0b21fa7f3a6c819a0db6097059aada160/pyzmq-27.1.0-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53b40f8ae006f2734ee7608d59ed661419f087521edbfc2149c3932e9c14808c", size = 567747, upload-time = "2025-09-08T23:09:52.698Z" }, + { url = "https://files.pythonhosted.org/packages/a1/cf/f2b3784d536250ffd4be70e049f3b60981235d70c6e8ce7e3ef21e1adb25/pyzmq-27.1.0-pp311-pypy311_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f605d884e7c8be8fe1aa94e0a783bf3f591b84c24e4bc4f3e7564c82ac25e271", size = 747371, upload-time = "2025-09-08T23:09:54.563Z" }, + { url = "https://files.pythonhosted.org/packages/01/1b/5dbe84eefc86f48473947e2f41711aded97eecef1231f4558f1f02713c12/pyzmq-27.1.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c9f7f6e13dff2e44a6afeaf2cf54cee5929ad64afaf4d40b50f93c58fc687355", size = 544862, upload-time = "2025-09-08T23:09:56.509Z" }, +] + +[[package]] +name = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py", version = "0.30.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "rpds-py", version = "2026.6.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, +] + +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + +[[package]] +name = "rfc3339-validator" +version = "0.1.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/28/ea/a9387748e2d111c3c2b275ba970b735e04e15cdb1eb30693b6b5708c4dbd/rfc3339_validator-0.1.4.tar.gz", hash = "sha256:138a2abdf93304ad60530167e51d2dfb9549521a836871b88d7f4695d0022f6b", size = 5513, upload-time = "2021-05-12T16:37:54.178Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/44/4e421b96b67b2daff264473f7465db72fbdf36a07e05494f50300cc7b0c6/rfc3339_validator-0.1.4-py2.py3-none-any.whl", hash = "sha256:24f6ec1eda14ef823da9e36ec7113124b39c04d50a4d3d3a3c2859577e7791fa", size = 3490, upload-time = "2021-05-12T16:37:52.536Z" }, +] + +[[package]] +name = "rfc3986-validator" +version = "0.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/da/88/f270de456dd7d11dcc808abfa291ecdd3f45ff44e3b549ffa01b126464d0/rfc3986_validator-0.1.1.tar.gz", hash = "sha256:3d44bde7921b3b9ec3ae4e3adca370438eccebc676456449b145d533b240d055", size = 6760, upload-time = "2019-10-28T16:00:19.144Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/51/17023c0f8f1869d8806b979a2bffa3f861f26a3f1a66b094288323fba52f/rfc3986_validator-0.1.1-py2.py3-none-any.whl", hash = "sha256:2f235c432ef459970b4306369336b9d5dbdda31b510ca1e327636e01f528bfa9", size = 4242, upload-time = "2019-10-28T16:00:13.976Z" }, +] + +[[package]] +name = "rfc3987-syntax" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "lark" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2c/06/37c1a5557acf449e8e406a830a05bf885ac47d33270aec454ef78675008d/rfc3987_syntax-1.1.0.tar.gz", hash = "sha256:717a62cbf33cffdd16dfa3a497d81ce48a660ea691b1ddd7be710c22f00b4a0d", size = 14239, upload-time = "2025-07-18T01:05:05.015Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/71/44ce230e1b7fadd372515a97e32a83011f906ddded8d03e3c6aafbdedbb7/rfc3987_syntax-1.1.0-py3-none-any.whl", hash = "sha256:6c3d97604e4c5ce9f714898e05401a0445a641cfa276432b0a648c80856f6a3f", size = 8046, upload-time = "2025-07-18T01:05:03.843Z" }, +] + +[[package]] +name = "rich" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, +] + +[[package]] +name = "rpds-py" +version = "0.30.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/0c/0c411a0ec64ccb6d104dcabe0e713e05e153a9a2c3c2bd2b32ce412166fe/rpds_py-0.30.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:679ae98e00c0e8d68a7fda324e16b90fd5260945b45d3b824c892cec9eea3288", size = 370490, upload-time = "2025-11-30T20:21:33.256Z" }, + { url = "https://files.pythonhosted.org/packages/19/6a/4ba3d0fb7297ebae71171822554abe48d7cab29c28b8f9f2c04b79988c05/rpds_py-0.30.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4cc2206b76b4f576934f0ed374b10d7ca5f457858b157ca52064bdfc26b9fc00", size = 359751, upload-time = "2025-11-30T20:21:34.591Z" }, + { url = "https://files.pythonhosted.org/packages/cd/7c/e4933565ef7f7a0818985d87c15d9d273f1a649afa6a52ea35ad011195ea/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:389a2d49eded1896c3d48b0136ead37c48e221b391c052fba3f4055c367f60a6", size = 389696, upload-time = "2025-11-30T20:21:36.122Z" }, + { url = "https://files.pythonhosted.org/packages/5e/01/6271a2511ad0815f00f7ed4390cf2567bec1d4b1da39e2c27a41e6e3b4de/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:32c8528634e1bf7121f3de08fa85b138f4e0dc47657866630611b03967f041d7", size = 403136, upload-time = "2025-11-30T20:21:37.728Z" }, + { url = "https://files.pythonhosted.org/packages/55/64/c857eb7cd7541e9b4eee9d49c196e833128a55b89a9850a9c9ac33ccf897/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f207f69853edd6f6700b86efb84999651baf3789e78a466431df1331608e5324", size = 524699, upload-time = "2025-11-30T20:21:38.92Z" }, + { url = "https://files.pythonhosted.org/packages/9c/ed/94816543404078af9ab26159c44f9e98e20fe47e2126d5d32c9d9948d10a/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:67b02ec25ba7a9e8fa74c63b6ca44cf5707f2fbfadae3ee8e7494297d56aa9df", size = 412022, upload-time = "2025-11-30T20:21:40.407Z" }, + { url = "https://files.pythonhosted.org/packages/61/b5/707f6cf0066a6412aacc11d17920ea2e19e5b2f04081c64526eb35b5c6e7/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c0e95f6819a19965ff420f65578bacb0b00f251fefe2c8b23347c37174271f3", size = 390522, upload-time = "2025-11-30T20:21:42.17Z" }, + { url = "https://files.pythonhosted.org/packages/13/4e/57a85fda37a229ff4226f8cbcf09f2a455d1ed20e802ce5b2b4a7f5ed053/rpds_py-0.30.0-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:a452763cc5198f2f98898eb98f7569649fe5da666c2dc6b5ddb10fde5a574221", size = 404579, upload-time = "2025-11-30T20:21:43.769Z" }, + { url = "https://files.pythonhosted.org/packages/f9/da/c9339293513ec680a721e0e16bf2bac3db6e5d7e922488de471308349bba/rpds_py-0.30.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e0b65193a413ccc930671c55153a03ee57cecb49e6227204b04fae512eb657a7", size = 421305, upload-time = "2025-11-30T20:21:44.994Z" }, + { url = "https://files.pythonhosted.org/packages/f9/be/522cb84751114f4ad9d822ff5a1aa3c98006341895d5f084779b99596e5c/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:858738e9c32147f78b3ac24dc0edb6610000e56dc0f700fd5f651d0a0f0eb9ff", size = 572503, upload-time = "2025-11-30T20:21:46.91Z" }, + { url = "https://files.pythonhosted.org/packages/a2/9b/de879f7e7ceddc973ea6e4629e9b380213a6938a249e94b0cdbcc325bb66/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:da279aa314f00acbb803da1e76fa18666778e8a8f83484fba94526da5de2cba7", size = 598322, upload-time = "2025-11-30T20:21:48.709Z" }, + { url = "https://files.pythonhosted.org/packages/48/ac/f01fc22efec3f37d8a914fc1b2fb9bcafd56a299edbe96406f3053edea5a/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7c64d38fb49b6cdeda16ab49e35fe0da2e1e9b34bc38bd78386530f218b37139", size = 560792, upload-time = "2025-11-30T20:21:50.024Z" }, + { url = "https://files.pythonhosted.org/packages/e2/da/4e2b19d0f131f35b6146425f846563d0ce036763e38913d917187307a671/rpds_py-0.30.0-cp310-cp310-win32.whl", hash = "sha256:6de2a32a1665b93233cde140ff8b3467bdb9e2af2b91079f0333a0974d12d464", size = 221901, upload-time = "2025-11-30T20:21:51.32Z" }, + { url = "https://files.pythonhosted.org/packages/96/cb/156d7a5cf4f78a7cc571465d8aec7a3c447c94f6749c5123f08438bcf7bc/rpds_py-0.30.0-cp310-cp310-win_amd64.whl", hash = "sha256:1726859cd0de969f88dc8673bdd954185b9104e05806be64bcd87badbe313169", size = 235823, upload-time = "2025-11-30T20:21:52.505Z" }, + { url = "https://files.pythonhosted.org/packages/4d/6e/f964e88b3d2abee2a82c1ac8366da848fce1c6d834dc2132c3fda3970290/rpds_py-0.30.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a2bffea6a4ca9f01b3f8e548302470306689684e61602aa3d141e34da06cf425", size = 370157, upload-time = "2025-11-30T20:21:53.789Z" }, + { url = "https://files.pythonhosted.org/packages/94/ba/24e5ebb7c1c82e74c4e4f33b2112a5573ddc703915b13a073737b59b86e0/rpds_py-0.30.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dc4f992dfe1e2bc3ebc7444f6c7051b4bc13cd8e33e43511e8ffd13bf407010d", size = 359676, upload-time = "2025-11-30T20:21:55.475Z" }, + { url = "https://files.pythonhosted.org/packages/84/86/04dbba1b087227747d64d80c3b74df946b986c57af0a9f0c98726d4d7a3b/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:422c3cb9856d80b09d30d2eb255d0754b23e090034e1deb4083f8004bd0761e4", size = 389938, upload-time = "2025-11-30T20:21:57.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/bb/1463f0b1722b7f45431bdd468301991d1328b16cffe0b1c2918eba2c4eee/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:07ae8a593e1c3c6b82ca3292efbe73c30b61332fd612e05abee07c79359f292f", size = 402932, upload-time = "2025-11-30T20:21:58.47Z" }, + { url = "https://files.pythonhosted.org/packages/99/ee/2520700a5c1f2d76631f948b0736cdf9b0acb25abd0ca8e889b5c62ac2e3/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12f90dd7557b6bd57f40abe7747e81e0c0b119bef015ea7726e69fe550e394a4", size = 525830, upload-time = "2025-11-30T20:21:59.699Z" }, + { url = "https://files.pythonhosted.org/packages/e0/ad/bd0331f740f5705cc555a5e17fdf334671262160270962e69a2bdef3bf76/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:99b47d6ad9a6da00bec6aabe5a6279ecd3c06a329d4aa4771034a21e335c3a97", size = 412033, upload-time = "2025-11-30T20:22:00.991Z" }, + { url = "https://files.pythonhosted.org/packages/f8/1e/372195d326549bb51f0ba0f2ecb9874579906b97e08880e7a65c3bef1a99/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33f559f3104504506a44bb666b93a33f5d33133765b0c216a5bf2f1e1503af89", size = 390828, upload-time = "2025-11-30T20:22:02.723Z" }, + { url = "https://files.pythonhosted.org/packages/ab/2b/d88bb33294e3e0c76bc8f351a3721212713629ffca1700fa94979cb3eae8/rpds_py-0.30.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:946fe926af6e44f3697abbc305ea168c2c31d3e3ef1058cf68f379bf0335a78d", size = 404683, upload-time = "2025-11-30T20:22:04.367Z" }, + { url = "https://files.pythonhosted.org/packages/50/32/c759a8d42bcb5289c1fac697cd92f6fe01a018dd937e62ae77e0e7f15702/rpds_py-0.30.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:495aeca4b93d465efde585977365187149e75383ad2684f81519f504f5c13038", size = 421583, upload-time = "2025-11-30T20:22:05.814Z" }, + { url = "https://files.pythonhosted.org/packages/2b/81/e729761dbd55ddf5d84ec4ff1f47857f4374b0f19bdabfcf929164da3e24/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9a0ca5da0386dee0655b4ccdf46119df60e0f10da268d04fe7cc87886872ba7", size = 572496, upload-time = "2025-11-30T20:22:07.713Z" }, + { url = "https://files.pythonhosted.org/packages/14/f6/69066a924c3557c9c30baa6ec3a0aa07526305684c6f86c696b08860726c/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8d6d1cc13664ec13c1b84241204ff3b12f9bb82464b8ad6e7a5d3486975c2eed", size = 598669, upload-time = "2025-11-30T20:22:09.312Z" }, + { url = "https://files.pythonhosted.org/packages/5f/48/905896b1eb8a05630d20333d1d8ffd162394127b74ce0b0784ae04498d32/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3896fa1be39912cf0757753826bc8bdc8ca331a28a7c4ae46b7a21280b06bb85", size = 561011, upload-time = "2025-11-30T20:22:11.309Z" }, + { url = "https://files.pythonhosted.org/packages/22/16/cd3027c7e279d22e5eb431dd3c0fbc677bed58797fe7581e148f3f68818b/rpds_py-0.30.0-cp311-cp311-win32.whl", hash = "sha256:55f66022632205940f1827effeff17c4fa7ae1953d2b74a8581baaefb7d16f8c", size = 221406, upload-time = "2025-11-30T20:22:13.101Z" }, + { url = "https://files.pythonhosted.org/packages/fa/5b/e7b7aa136f28462b344e652ee010d4de26ee9fd16f1bfd5811f5153ccf89/rpds_py-0.30.0-cp311-cp311-win_amd64.whl", hash = "sha256:a51033ff701fca756439d641c0ad09a41d9242fa69121c7d8769604a0a629825", size = 236024, upload-time = "2025-11-30T20:22:14.853Z" }, + { url = "https://files.pythonhosted.org/packages/14/a6/364bba985e4c13658edb156640608f2c9e1d3ea3c81b27aa9d889fff0e31/rpds_py-0.30.0-cp311-cp311-win_arm64.whl", hash = "sha256:47b0ef6231c58f506ef0b74d44e330405caa8428e770fec25329ed2cb971a229", size = 229069, upload-time = "2025-11-30T20:22:16.577Z" }, + { url = "https://files.pythonhosted.org/packages/03/e7/98a2f4ac921d82f33e03f3835f5bf3a4a40aa1bfdc57975e74a97b2b4bdd/rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad", size = 375086, upload-time = "2025-11-30T20:22:17.93Z" }, + { url = "https://files.pythonhosted.org/packages/4d/a1/bca7fd3d452b272e13335db8d6b0b3ecde0f90ad6f16f3328c6fb150c889/rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05", size = 359053, upload-time = "2025-11-30T20:22:19.297Z" }, + { url = "https://files.pythonhosted.org/packages/65/1c/ae157e83a6357eceff62ba7e52113e3ec4834a84cfe07fa4b0757a7d105f/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28", size = 390763, upload-time = "2025-11-30T20:22:21.661Z" }, + { url = "https://files.pythonhosted.org/packages/d4/36/eb2eb8515e2ad24c0bd43c3ee9cd74c33f7ca6430755ccdb240fd3144c44/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd", size = 408951, upload-time = "2025-11-30T20:22:23.408Z" }, + { url = "https://files.pythonhosted.org/packages/d6/65/ad8dc1784a331fabbd740ef6f71ce2198c7ed0890dab595adb9ea2d775a1/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f", size = 514622, upload-time = "2025-11-30T20:22:25.16Z" }, + { url = "https://files.pythonhosted.org/packages/63/8e/0cfa7ae158e15e143fe03993b5bcd743a59f541f5952e1546b1ac1b5fd45/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1", size = 414492, upload-time = "2025-11-30T20:22:26.505Z" }, + { url = "https://files.pythonhosted.org/packages/60/1b/6f8f29f3f995c7ffdde46a626ddccd7c63aefc0efae881dc13b6e5d5bb16/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23", size = 394080, upload-time = "2025-11-30T20:22:27.934Z" }, + { url = "https://files.pythonhosted.org/packages/6d/d5/a266341051a7a3ca2f4b750a3aa4abc986378431fc2da508c5034d081b70/rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6", size = 408680, upload-time = "2025-11-30T20:22:29.341Z" }, + { url = "https://files.pythonhosted.org/packages/10/3b/71b725851df9ab7a7a4e33cf36d241933da66040d195a84781f49c50490c/rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51", size = 423589, upload-time = "2025-11-30T20:22:31.469Z" }, + { url = "https://files.pythonhosted.org/packages/00/2b/e59e58c544dc9bd8bd8384ecdb8ea91f6727f0e37a7131baeff8d6f51661/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5", size = 573289, upload-time = "2025-11-30T20:22:32.997Z" }, + { url = "https://files.pythonhosted.org/packages/da/3e/a18e6f5b460893172a7d6a680e86d3b6bc87a54c1f0b03446a3c8c7b588f/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e", size = 599737, upload-time = "2025-11-30T20:22:34.419Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e2/714694e4b87b85a18e2c243614974413c60aa107fd815b8cbc42b873d1d7/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394", size = 563120, upload-time = "2025-11-30T20:22:35.903Z" }, + { url = "https://files.pythonhosted.org/packages/6f/ab/d5d5e3bcedb0a77f4f613706b750e50a5a3ba1c15ccd3665ecc636c968fd/rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf", size = 223782, upload-time = "2025-11-30T20:22:37.271Z" }, + { url = "https://files.pythonhosted.org/packages/39/3b/f786af9957306fdc38a74cef405b7b93180f481fb48453a114bb6465744a/rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b", size = 240463, upload-time = "2025-11-30T20:22:39.021Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d2/b91dc748126c1559042cfe41990deb92c4ee3e2b415f6b5234969ffaf0cc/rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e", size = 230868, upload-time = "2025-11-30T20:22:40.493Z" }, + { url = "https://files.pythonhosted.org/packages/ed/dc/d61221eb88ff410de3c49143407f6f3147acf2538c86f2ab7ce65ae7d5f9/rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2", size = 374887, upload-time = "2025-11-30T20:22:41.812Z" }, + { url = "https://files.pythonhosted.org/packages/fd/32/55fb50ae104061dbc564ef15cc43c013dc4a9f4527a1f4d99baddf56fe5f/rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8", size = 358904, upload-time = "2025-11-30T20:22:43.479Z" }, + { url = "https://files.pythonhosted.org/packages/58/70/faed8186300e3b9bdd138d0273109784eea2396c68458ed580f885dfe7ad/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4", size = 389945, upload-time = "2025-11-30T20:22:44.819Z" }, + { url = "https://files.pythonhosted.org/packages/bd/a8/073cac3ed2c6387df38f71296d002ab43496a96b92c823e76f46b8af0543/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136", size = 407783, upload-time = "2025-11-30T20:22:46.103Z" }, + { url = "https://files.pythonhosted.org/packages/77/57/5999eb8c58671f1c11eba084115e77a8899d6e694d2a18f69f0ba471ec8b/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7", size = 515021, upload-time = "2025-11-30T20:22:47.458Z" }, + { url = "https://files.pythonhosted.org/packages/e0/af/5ab4833eadc36c0a8ed2bc5c0de0493c04f6c06de223170bd0798ff98ced/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2", size = 414589, upload-time = "2025-11-30T20:22:48.872Z" }, + { url = "https://files.pythonhosted.org/packages/b7/de/f7192e12b21b9e9a68a6d0f249b4af3fdcdff8418be0767a627564afa1f1/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6", size = 394025, upload-time = "2025-11-30T20:22:50.196Z" }, + { url = "https://files.pythonhosted.org/packages/91/c4/fc70cd0249496493500e7cc2de87504f5aa6509de1e88623431fec76d4b6/rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e", size = 408895, upload-time = "2025-11-30T20:22:51.87Z" }, + { url = "https://files.pythonhosted.org/packages/58/95/d9275b05ab96556fefff73a385813eb66032e4c99f411d0795372d9abcea/rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d", size = 422799, upload-time = "2025-11-30T20:22:53.341Z" }, + { url = "https://files.pythonhosted.org/packages/06/c1/3088fc04b6624eb12a57eb814f0d4997a44b0d208d6cace713033ff1a6ba/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7", size = 572731, upload-time = "2025-11-30T20:22:54.778Z" }, + { url = "https://files.pythonhosted.org/packages/d8/42/c612a833183b39774e8ac8fecae81263a68b9583ee343db33ab571a7ce55/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31", size = 599027, upload-time = "2025-11-30T20:22:56.212Z" }, + { url = "https://files.pythonhosted.org/packages/5f/60/525a50f45b01d70005403ae0e25f43c0384369ad24ffe46e8d9068b50086/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95", size = 563020, upload-time = "2025-11-30T20:22:58.2Z" }, + { url = "https://files.pythonhosted.org/packages/0b/5d/47c4655e9bcd5ca907148535c10e7d489044243cc9941c16ed7cd53be91d/rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d", size = 223139, upload-time = "2025-11-30T20:23:00.209Z" }, + { url = "https://files.pythonhosted.org/packages/f2/e1/485132437d20aa4d3e1d8b3fb5a5e65aa8139f1e097080c2a8443201742c/rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15", size = 240224, upload-time = "2025-11-30T20:23:02.008Z" }, + { url = "https://files.pythonhosted.org/packages/24/95/ffd128ed1146a153d928617b0ef673960130be0009c77d8fbf0abe306713/rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1", size = 230645, upload-time = "2025-11-30T20:23:03.43Z" }, + { url = "https://files.pythonhosted.org/packages/ff/1b/b10de890a0def2a319a2626334a7f0ae388215eb60914dbac8a3bae54435/rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a", size = 364443, upload-time = "2025-11-30T20:23:04.878Z" }, + { url = "https://files.pythonhosted.org/packages/0d/bf/27e39f5971dc4f305a4fb9c672ca06f290f7c4e261c568f3dea16a410d47/rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e", size = 353375, upload-time = "2025-11-30T20:23:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/40/58/442ada3bba6e8e6615fc00483135c14a7538d2ffac30e2d933ccf6852232/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000", size = 383850, upload-time = "2025-11-30T20:23:07.825Z" }, + { url = "https://files.pythonhosted.org/packages/14/14/f59b0127409a33c6ef6f5c1ebd5ad8e32d7861c9c7adfa9a624fc3889f6c/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db", size = 392812, upload-time = "2025-11-30T20:23:09.228Z" }, + { url = "https://files.pythonhosted.org/packages/b3/66/e0be3e162ac299b3a22527e8913767d869e6cc75c46bd844aa43fb81ab62/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2", size = 517841, upload-time = "2025-11-30T20:23:11.186Z" }, + { url = "https://files.pythonhosted.org/packages/3d/55/fa3b9cf31d0c963ecf1ba777f7cf4b2a2c976795ac430d24a1f43d25a6ba/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa", size = 408149, upload-time = "2025-11-30T20:23:12.864Z" }, + { url = "https://files.pythonhosted.org/packages/60/ca/780cf3b1a32b18c0f05c441958d3758f02544f1d613abf9488cd78876378/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083", size = 383843, upload-time = "2025-11-30T20:23:14.638Z" }, + { url = "https://files.pythonhosted.org/packages/82/86/d5f2e04f2aa6247c613da0c1dd87fcd08fa17107e858193566048a1e2f0a/rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9", size = 396507, upload-time = "2025-11-30T20:23:16.105Z" }, + { url = "https://files.pythonhosted.org/packages/4b/9a/453255d2f769fe44e07ea9785c8347edaf867f7026872e76c1ad9f7bed92/rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0", size = 414949, upload-time = "2025-11-30T20:23:17.539Z" }, + { url = "https://files.pythonhosted.org/packages/a3/31/622a86cdc0c45d6df0e9ccb6becdba5074735e7033c20e401a6d9d0e2ca0/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94", size = 565790, upload-time = "2025-11-30T20:23:19.029Z" }, + { url = "https://files.pythonhosted.org/packages/1c/5d/15bbf0fb4a3f58a3b1c67855ec1efcc4ceaef4e86644665fff03e1b66d8d/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08", size = 590217, upload-time = "2025-11-30T20:23:20.885Z" }, + { url = "https://files.pythonhosted.org/packages/6d/61/21b8c41f68e60c8cc3b2e25644f0e3681926020f11d06ab0b78e3c6bbff1/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27", size = 555806, upload-time = "2025-11-30T20:23:22.488Z" }, + { url = "https://files.pythonhosted.org/packages/f9/39/7e067bb06c31de48de3eb200f9fc7c58982a4d3db44b07e73963e10d3be9/rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6", size = 211341, upload-time = "2025-11-30T20:23:24.449Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4d/222ef0b46443cf4cf46764d9c630f3fe4abaa7245be9417e56e9f52b8f65/rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d", size = 225768, upload-time = "2025-11-30T20:23:25.908Z" }, + { url = "https://files.pythonhosted.org/packages/86/81/dad16382ebbd3d0e0328776d8fd7ca94220e4fa0798d1dc5e7da48cb3201/rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0", size = 362099, upload-time = "2025-11-30T20:23:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/2b/60/19f7884db5d5603edf3c6bce35408f45ad3e97e10007df0e17dd57af18f8/rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be", size = 353192, upload-time = "2025-11-30T20:23:29.151Z" }, + { url = "https://files.pythonhosted.org/packages/bf/c4/76eb0e1e72d1a9c4703c69607cec123c29028bff28ce41588792417098ac/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f", size = 384080, upload-time = "2025-11-30T20:23:30.785Z" }, + { url = "https://files.pythonhosted.org/packages/72/87/87ea665e92f3298d1b26d78814721dc39ed8d2c74b86e83348d6b48a6f31/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f", size = 394841, upload-time = "2025-11-30T20:23:32.209Z" }, + { url = "https://files.pythonhosted.org/packages/77/ad/7783a89ca0587c15dcbf139b4a8364a872a25f861bdb88ed99f9b0dec985/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87", size = 516670, upload-time = "2025-11-30T20:23:33.742Z" }, + { url = "https://files.pythonhosted.org/packages/5b/3c/2882bdac942bd2172f3da574eab16f309ae10a3925644e969536553cb4ee/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18", size = 408005, upload-time = "2025-11-30T20:23:35.253Z" }, + { url = "https://files.pythonhosted.org/packages/ce/81/9a91c0111ce1758c92516a3e44776920b579d9a7c09b2b06b642d4de3f0f/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad", size = 382112, upload-time = "2025-11-30T20:23:36.842Z" }, + { url = "https://files.pythonhosted.org/packages/cf/8e/1da49d4a107027e5fbc64daeab96a0706361a2918da10cb41769244b805d/rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07", size = 399049, upload-time = "2025-11-30T20:23:38.343Z" }, + { url = "https://files.pythonhosted.org/packages/df/5a/7ee239b1aa48a127570ec03becbb29c9d5a9eb092febbd1699d567cae859/rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f", size = 415661, upload-time = "2025-11-30T20:23:40.263Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/caa143cf6b772f823bc7929a45da1fa83569ee49b11d18d0ada7f5ee6fd6/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65", size = 565606, upload-time = "2025-11-30T20:23:42.186Z" }, + { url = "https://files.pythonhosted.org/packages/64/91/ac20ba2d69303f961ad8cf55bf7dbdb4763f627291ba3d0d7d67333cced9/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f", size = 591126, upload-time = "2025-11-30T20:23:44.086Z" }, + { url = "https://files.pythonhosted.org/packages/21/20/7ff5f3c8b00c8a95f75985128c26ba44503fb35b8e0259d812766ea966c7/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53", size = 553371, upload-time = "2025-11-30T20:23:46.004Z" }, + { url = "https://files.pythonhosted.org/packages/72/c7/81dadd7b27c8ee391c132a6b192111ca58d866577ce2d9b0ca157552cce0/rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed", size = 215298, upload-time = "2025-11-30T20:23:47.696Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d2/1aaac33287e8cfb07aab2e6b8ac1deca62f6f65411344f1433c55e6f3eb8/rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950", size = 228604, upload-time = "2025-11-30T20:23:49.501Z" }, + { url = "https://files.pythonhosted.org/packages/e8/95/ab005315818cc519ad074cb7784dae60d939163108bd2b394e60dc7b5461/rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6", size = 222391, upload-time = "2025-11-30T20:23:50.96Z" }, + { url = "https://files.pythonhosted.org/packages/9e/68/154fe0194d83b973cdedcdcc88947a2752411165930182ae41d983dcefa6/rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb", size = 364868, upload-time = "2025-11-30T20:23:52.494Z" }, + { url = "https://files.pythonhosted.org/packages/83/69/8bbc8b07ec854d92a8b75668c24d2abcb1719ebf890f5604c61c9369a16f/rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8", size = 353747, upload-time = "2025-11-30T20:23:54.036Z" }, + { url = "https://files.pythonhosted.org/packages/ab/00/ba2e50183dbd9abcce9497fa5149c62b4ff3e22d338a30d690f9af970561/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7", size = 383795, upload-time = "2025-11-30T20:23:55.556Z" }, + { url = "https://files.pythonhosted.org/packages/05/6f/86f0272b84926bcb0e4c972262f54223e8ecc556b3224d281e6598fc9268/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898", size = 393330, upload-time = "2025-11-30T20:23:57.033Z" }, + { url = "https://files.pythonhosted.org/packages/cb/e9/0e02bb2e6dc63d212641da45df2b0bf29699d01715913e0d0f017ee29438/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e", size = 518194, upload-time = "2025-11-30T20:23:58.637Z" }, + { url = "https://files.pythonhosted.org/packages/ee/ca/be7bca14cf21513bdf9c0606aba17d1f389ea2b6987035eb4f62bd923f25/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419", size = 408340, upload-time = "2025-11-30T20:24:00.2Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c7/736e00ebf39ed81d75544c0da6ef7b0998f8201b369acf842f9a90dc8fce/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551", size = 383765, upload-time = "2025-11-30T20:24:01.759Z" }, + { url = "https://files.pythonhosted.org/packages/4a/3f/da50dfde9956aaf365c4adc9533b100008ed31aea635f2b8d7b627e25b49/rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8", size = 396834, upload-time = "2025-11-30T20:24:03.687Z" }, + { url = "https://files.pythonhosted.org/packages/4e/00/34bcc2565b6020eab2623349efbdec810676ad571995911f1abdae62a3a0/rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5", size = 415470, upload-time = "2025-11-30T20:24:05.232Z" }, + { url = "https://files.pythonhosted.org/packages/8c/28/882e72b5b3e6f718d5453bd4d0d9cf8df36fddeb4ddbbab17869d5868616/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404", size = 565630, upload-time = "2025-11-30T20:24:06.878Z" }, + { url = "https://files.pythonhosted.org/packages/3b/97/04a65539c17692de5b85c6e293520fd01317fd878ea1995f0367d4532fb1/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856", size = 591148, upload-time = "2025-11-30T20:24:08.445Z" }, + { url = "https://files.pythonhosted.org/packages/85/70/92482ccffb96f5441aab93e26c4d66489eb599efdcf96fad90c14bbfb976/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40", size = 556030, upload-time = "2025-11-30T20:24:10.956Z" }, + { url = "https://files.pythonhosted.org/packages/20/53/7c7e784abfa500a2b6b583b147ee4bb5a2b3747a9166bab52fec4b5b5e7d/rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0", size = 211570, upload-time = "2025-11-30T20:24:12.735Z" }, + { url = "https://files.pythonhosted.org/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", size = 226532, upload-time = "2025-11-30T20:24:14.634Z" }, + { url = "https://files.pythonhosted.org/packages/69/71/3f34339ee70521864411f8b6992e7ab13ac30d8e4e3309e07c7361767d91/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c2262bdba0ad4fc6fb5545660673925c2d2a5d9e2e0fb603aad545427be0fc58", size = 372292, upload-time = "2025-11-30T20:24:16.537Z" }, + { url = "https://files.pythonhosted.org/packages/57/09/f183df9b8f2d66720d2ef71075c59f7e1b336bec7ee4c48f0a2b06857653/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ee6af14263f25eedc3bb918a3c04245106a42dfd4f5c2285ea6f997b1fc3f89a", size = 362128, upload-time = "2025-11-30T20:24:18.086Z" }, + { url = "https://files.pythonhosted.org/packages/7a/68/5c2594e937253457342e078f0cc1ded3dd7b2ad59afdbf2d354869110a02/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3adbb8179ce342d235c31ab8ec511e66c73faa27a47e076ccc92421add53e2bb", size = 391542, upload-time = "2025-11-30T20:24:20.092Z" }, + { url = "https://files.pythonhosted.org/packages/49/5c/31ef1afd70b4b4fbdb2800249f34c57c64beb687495b10aec0365f53dfc4/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:250fa00e9543ac9b97ac258bd37367ff5256666122c2d0f2bc97577c60a1818c", size = 404004, upload-time = "2025-11-30T20:24:22.231Z" }, + { url = "https://files.pythonhosted.org/packages/e3/63/0cfbea38d05756f3440ce6534d51a491d26176ac045e2707adc99bb6e60a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9854cf4f488b3d57b9aaeb105f06d78e5529d3145b1e4a41750167e8c213c6d3", size = 527063, upload-time = "2025-11-30T20:24:24.302Z" }, + { url = "https://files.pythonhosted.org/packages/42/e6/01e1f72a2456678b0f618fc9a1a13f882061690893c192fcad9f2926553a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:993914b8e560023bc0a8bf742c5f303551992dcb85e247b1e5c7f4a7d145bda5", size = 413099, upload-time = "2025-11-30T20:24:25.916Z" }, + { url = "https://files.pythonhosted.org/packages/b8/25/8df56677f209003dcbb180765520c544525e3ef21ea72279c98b9aa7c7fb/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58edca431fb9b29950807e301826586e5bbf24163677732429770a697ffe6738", size = 392177, upload-time = "2025-11-30T20:24:27.834Z" }, + { url = "https://files.pythonhosted.org/packages/4a/b4/0a771378c5f16f8115f796d1f437950158679bcd2a7c68cf251cfb00ed5b/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:dea5b552272a944763b34394d04577cf0f9bd013207bc32323b5a89a53cf9c2f", size = 406015, upload-time = "2025-11-30T20:24:29.457Z" }, + { url = "https://files.pythonhosted.org/packages/36/d8/456dbba0af75049dc6f63ff295a2f92766b9d521fa00de67a2bd6427d57a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ba3af48635eb83d03f6c9735dfb21785303e73d22ad03d489e88adae6eab8877", size = 423736, upload-time = "2025-11-30T20:24:31.22Z" }, + { url = "https://files.pythonhosted.org/packages/13/64/b4d76f227d5c45a7e0b796c674fd81b0a6c4fbd48dc29271857d8219571c/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:dff13836529b921e22f15cb099751209a60009731a68519630a24d61f0b1b30a", size = 573981, upload-time = "2025-11-30T20:24:32.934Z" }, + { url = "https://files.pythonhosted.org/packages/20/91/092bacadeda3edf92bf743cc96a7be133e13a39cdbfd7b5082e7ab638406/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:1b151685b23929ab7beec71080a8889d4d6d9fa9a983d213f07121205d48e2c4", size = 599782, upload-time = "2025-11-30T20:24:35.169Z" }, + { url = "https://files.pythonhosted.org/packages/d1/b7/b95708304cd49b7b6f82fdd039f1748b66ec2b21d6a45180910802f1abf1/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:ac37f9f516c51e5753f27dfdef11a88330f04de2d564be3991384b2f3535d02e", size = 562191, upload-time = "2025-11-30T20:24:36.853Z" }, +] + +[[package]] +name = "rpds-py" +version = "2026.6.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.15' and sys_platform == 'win32'", + "python_full_version >= '3.15' and sys_platform == 'emscripten'", + "python_full_version >= '3.15' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.14.*' and sys_platform == 'win32'", + "python_full_version == '3.14.*' and sys_platform == 'emscripten'", + "python_full_version == '3.14.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", + "python_full_version == '3.11.*' and sys_platform == 'emscripten'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] +sdist = { url = "https://files.pythonhosted.org/packages/aa/2a/9618a122aeb2a169a28b03889a2995fe297588964333d4a7d67bdf46e147/rpds_py-2026.6.3.tar.gz", hash = "sha256:1cebd1337c242e4ec2293e541f712b2da849b29f48f0c293684b71c0632625d4", size = 64051, upload-time = "2026-06-30T07:17:53.009Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/1f/a2dca5ffdbf1d475ffc4e80e4d5d720ff3a00f691795910116960ee12511/rpds_py-2026.6.3-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:7b689145a1485c335569bd056464f3243a29af7ed3871c7be31ad624ba239bc7", size = 342174, upload-time = "2026-06-30T07:14:54.821Z" }, + { url = "https://files.pythonhosted.org/packages/4d/dc/323d08583c0832911768663d1944f0107fcd4088704858d84b5e06d105a0/rpds_py-2026.6.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:db08f45aecde626498fb3df07bcf6d2ec040af42e859a4f5040d79c200342911", size = 345513, upload-time = "2026-06-30T07:14:56.515Z" }, + { url = "https://files.pythonhosted.org/packages/0b/2a/e31989834d18d2f26ec1d2774c5b1eb3331df4ea8ada525175294c94b48a/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:acc992ab27b15f852c76755eb2ab7dce86585ddadba6fa5946e58556088845b4", size = 373783, upload-time = "2026-06-30T07:14:57.736Z" }, + { url = "https://files.pythonhosted.org/packages/87/fe/e80107ee3639585c9941c17d6a42cd65325022f656c023191fce78c324c8/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7f88d653e7b3b779d71ae7454e20dcc9b6bae903f33c269db9f2be41bda3f261", size = 378316, upload-time = "2026-06-30T07:14:59.077Z" }, + { url = "https://files.pythonhosted.org/packages/22/6f/81e3adf81acfb6fa694de2a6e4e7d8863121e3e0799e0a7725e6cf5679c4/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e52655eaf81e32593abedaa4bfe33170c8cfedf3365ed9be6e11e07f148f0278", size = 499423, upload-time = "2026-06-30T07:15:00.488Z" }, + { url = "https://files.pythonhosted.org/packages/2d/9a/41263969df0ce3d9af2a96d5005a288200af1989aed3354bfceb5fc0b21f/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dfcc8b909769d19db55c7cc9541eb64b9b774b1057ffffb4f1048070475bb9f9", size = 386077, upload-time = "2026-06-30T07:15:01.911Z" }, + { url = "https://files.pythonhosted.org/packages/5e/19/7e98f468bd50346faff5b10e5297374b443bfdddacc8e9fbc65984539597/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c1255b302953c86a486b81d330d5ee1d5bd937691ce271b6be0ef0e299eaab7", size = 371315, upload-time = "2026-06-30T07:15:03.317Z" }, + { url = "https://files.pythonhosted.org/packages/99/3c/2b973b4d371906a134b03decfea7f5d9835a2c6d263454392e15b64b5b18/rpds_py-2026.6.3-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:8d2294a31386bfa251d8c8a39472beee17db67d4f1a6eabea665d35c9a4461c3", size = 383502, upload-time = "2026-06-30T07:15:04.627Z" }, + { url = "https://files.pythonhosted.org/packages/98/2a/12e2799500af0a307bca76b63361c51f9fe479223561489c29eea1f2ee41/rpds_py-2026.6.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f8f23ead891a3b762f35ab3b04623da7056545b48aa60d59957e6789914545da", size = 402673, upload-time = "2026-06-30T07:15:05.856Z" }, + { url = "https://files.pythonhosted.org/packages/2d/e3/21e5872d165fe08be4f229e3d5ee9d90019c0bf0e5538de60dbd54009450/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:421aba32367055614287a4292b6a17f1939c9452299f7a0209c117e990b646d4", size = 549964, upload-time = "2026-06-30T07:15:07.159Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d0/5ee0fe36844297de8123bee27bc12078c1a7416ad9f1b8a8ca18d6b0c0ac/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1e5822dfc2f0d4ab7e745eaa6d85945069329beeccef965af3f3bb26058fcab6", size = 615446, upload-time = "2026-06-30T07:15:08.531Z" }, + { url = "https://files.pythonhosted.org/packages/b1/80/1ea5873cb683f2fbe5f21b23ea1f6d179ead19f3c5b249b7eb5dca568ef2/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:83e35b57523816c8613fd0776b40cd8bb9f596b37ddd2692eb4a6bb5ab2f8c93", size = 576975, upload-time = "2026-06-30T07:15:09.97Z" }, + { url = "https://files.pythonhosted.org/packages/c9/e1/90ef639217a5ddb15b7f4f61b1c33911fd044ad03c311bafdd2bcab85582/rpds_py-2026.6.3-cp311-cp311-win32.whl", hash = "sha256:de3eceba0b683bcbb1ab93da016d0270df1f9ae7be716b40214c5dafac6ea45a", size = 204453, upload-time = "2026-06-30T07:15:11.324Z" }, + { url = "https://files.pythonhosted.org/packages/f2/b7/b7a1695d7af36f521fb11e80d6d3adbd744f73b921859bd3c2a2c0dc706f/rpds_py-2026.6.3-cp311-cp311-win_amd64.whl", hash = "sha256:2c54a076ca4d370980ab57bc0e31df57bbe8d41340436a90ef8b1219a3cbb127", size = 223219, upload-time = "2026-06-30T07:15:12.476Z" }, + { url = "https://files.pythonhosted.org/packages/d7/a2/145afacf796e4506062825941176ad9445c2dcf2b3b6a1f13d3030a15e19/rpds_py-2026.6.3-cp311-cp311-win_arm64.whl", hash = "sha256:168c733a7112e071bb7a66460e667edfcff06c017a3c523f7a8a8e08d0140804", size = 219137, upload-time = "2026-06-30T07:15:13.631Z" }, + { url = "https://files.pythonhosted.org/packages/5c/be/2e8974163072e7bab7df1a5acd54c4498e75e35d6d18b864d3a9d5dadc92/rpds_py-2026.6.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a0811d33247c3d6128a3001d763f2aa056bb3425204335400ac54f89eec3a0d0", size = 343691, upload-time = "2026-06-30T07:15:14.96Z" }, + { url = "https://files.pythonhosted.org/packages/a4/73/319dfa745dd668efe89309141ded489126461fcecd2b8f3a3cda185129b6/rpds_py-2026.6.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:538949e262e46caa31ac01bdb3c1e8f642622922cacbabbae6a8445d9dc33eaf", size = 338542, upload-time = "2026-06-30T07:15:16.267Z" }, + { url = "https://files.pythonhosted.org/packages/21/63/4239893be1c4d09b709b1a8f6be4188f0870084ff547f46606b8a75f1b03/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55927d532399c2c646100ff7feb48eaa940ad70f42cd68e1328f3ded9f81ca24", size = 368180, upload-time = "2026-06-30T07:15:17.62Z" }, + { url = "https://files.pythonhosted.org/packages/1c/ca/9c5de382225234ceb37b1844ebdb140db12b2a278bb9efe2fcd19f6c82ce/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f56f1695bc5c0871cbc33dc0130fcf503aab0c57dcc5a6700a4f49eba4f2652e", size = 375067, upload-time = "2026-06-30T07:15:18.952Z" }, + { url = "https://files.pythonhosted.org/packages/87/dc/863f69d1bf04ade34b7fe0d59b9fdf6f0135fe2d7cbca74f1d665589559d/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:270b293dae9058fc9fcedab50f13cebf46fb8ed1d1d54e0521a9da5d6b211975", size = 490509, upload-time = "2026-06-30T07:15:20.434Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ef/eac16a12048b45ec7c7fa94f2be3438a5f26bf9cc8580b18a1cfd609b7f6/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:127565fead0a10943b282957bd5447804ff3160ad79f2ad2635e6d249e380680", size = 382754, upload-time = "2026-06-30T07:15:21.831Z" }, + { url = "https://files.pythonhosted.org/packages/04/8f/d2f3f532616be4d06c316ef119683e832bd3d41e112bf3a88f4151c95b17/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecabd69db66de867690f9797f2f8fa27ba501bbc24540cbdbdc649cd15888ba6", size = 366189, upload-time = "2026-06-30T07:15:23.371Z" }, + { url = "https://files.pythonhosted.org/packages/e3/29/41a7b0e98a4b44cd676ab7598419623373eb43b20be68c084935c1a8cf88/rpds_py-2026.6.3-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:58eadac9cd119677b60e1cf8ac4052f35949d71b8a9e5556efccbe82533cf22a", size = 377750, upload-time = "2026-06-30T07:15:24.659Z" }, + { url = "https://files.pythonhosted.org/packages/2e/05/ecda0bec46f9a1565090bcdc941d023f6a25aff85fda28f89f8d19878152/rpds_py-2026.6.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7491ee23305ac3eb59e492b6945881f5cd77a6f731061a3f25b77fd40f9e99a4", size = 395576, upload-time = "2026-06-30T07:15:25.987Z" }, + { url = "https://files.pythonhosted.org/packages/68/a8/6ed52f03ee6cb854ce78785cc9a9a672eb880e83fd7224d471f667d151f1/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2c99f7e8ccb3dd6e3e4bfeac657a7b208c9bac8075f4b078c02d7404c34107fa", size = 543807, upload-time = "2026-06-30T07:15:27.356Z" }, + { url = "https://files.pythonhosted.org/packages/8f/d6/156c0d3eea27ba09b92562ba2364ba124c0a061b199e17eac637cd25a5e2/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:62698275682bf121181861295c9181e789030a2d516071f5b8f3c23c170cd0fc", size = 611187, upload-time = "2026-06-30T07:15:28.931Z" }, + { url = "https://files.pythonhosted.org/packages/f1/31/774212ed989c62f7f310220089f9b0a3fb8f40f5443d1727abd5d9f52bc9/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a214c993455f99a89aaeadc9b21241900037adc9d97203e374d75513c5911822", size = 573030, upload-time = "2026-06-30T07:15:30.553Z" }, + { url = "https://files.pythonhosted.org/packages/c9/50/22f73127a41f1ce4f87fe39aadfb9a126345801c274aa93ae88456249327/rpds_py-2026.6.3-cp312-cp312-win32.whl", hash = "sha256:501f9f04a588d6a09179368c57071301445191767c64e4b52a6aa9871f1ef5ed", size = 202185, upload-time = "2026-06-30T07:15:32.027Z" }, + { url = "https://files.pythonhosted.org/packages/04/3a/f0ee4d4dde9d3b69dedf1b5f74e7a40017046d55052d173e418c6a94f960/rpds_py-2026.6.3-cp312-cp312-win_amd64.whl", hash = "sha256:2c958bf94822e9290a40aaf2a822d4bc5c88099093e3948ad6c571eca9272e5f", size = 220394, upload-time = "2026-06-30T07:15:33.359Z" }, + { url = "https://files.pythonhosted.org/packages/f3/83/3382fe37f809b59f02aac04dbc4e765b480b46ee0227ed516e3bdc4d3dfc/rpds_py-2026.6.3-cp312-cp312-win_arm64.whl", hash = "sha256:22bffe6042b9bcb0822bcd1955ec00e245daf17b4344e4ed8e9551b976b63e96", size = 215753, upload-time = "2026-06-30T07:15:34.778Z" }, + { url = "https://files.pythonhosted.org/packages/a4/9e/b818ee580026ec578138e961027a68820c40afeb1ec8f6819b54fb99e196/rpds_py-2026.6.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:3cfe765c1da0072636ca06628261e0ea05688e160d5c8a03e0217c3854037223", size = 343012, upload-time = "2026-06-30T07:15:36.005Z" }, + { url = "https://files.pythonhosted.org/packages/f3/6b/686d9dc4359a8f163cfbbf89ee0b4e586431de22fe8248edb63a8cf50d49/rpds_py-2026.6.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f4d78253f6996be4901669ad25319f842f740eccf4d58e3c7f3dd39e6dde1d8f", size = 338203, upload-time = "2026-06-30T07:15:37.462Z" }, + { url = "https://files.pythonhosted.org/packages/9e/9b/069aa329940f8207615e091f5eedbbd40e1e15eac68a0790fd05ccdf796c/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54f45a148e28767bf343d33a684693c70e451c6f4c0e9904709a723fafbdfc1f", size = 367984, upload-time = "2026-06-30T07:15:39.008Z" }, + { url = "https://files.pythonhosted.org/packages/14/db/34c203e4becff3703e4d3bc121842c00b8689197f398161203a880052f4e/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:842e7b070435622248c7a2c44ae53fa1440e073cc3023bc919fed570884097a7", size = 374815, upload-time = "2026-06-30T07:15:40.253Z" }, + { url = "https://files.pythonhosted.org/packages/ee/7d/8071067d2cc453d916ad836e828c943f575e8a44612537759002a1e07381/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8020133a74bd81b4572dd8e4be028a6b1ebcd70e6726edc3918008c08bee6ee6", size = 490545, upload-time = "2026-06-30T07:15:41.729Z" }, + { url = "https://files.pythonhosted.org/packages/a3/42/da06c5aa8f0484ff07f270787434204d9f4535e2f8c3b51ed402267e63c3/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cdc7e35386f3847df728fbcb5e887e2d79c19e2fa1eba9e51b6621d23e3243af", size = 382828, upload-time = "2026-06-30T07:15:43.327Z" }, + { url = "https://files.pythonhosted.org/packages/57/d7/fe978efc2ae50abe48eb7464668ea99f53c010c60aeebb7b35ad27f23661/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:acac386b453c2516111b50985d60ce46e7fadb5ea71ae7b25f4c946935bf27cf", size = 365678, upload-time = "2026-06-30T07:15:44.992Z" }, + { url = "https://files.pythonhosted.org/packages/69/9d/1d8922e1990b2a6eb532b6ff53d3e73d2b3bbffc84116c75826bee73dfc6/rpds_py-2026.6.3-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:425560c6fa0415f27261727bb20bd097568485e5eb0c121f1949417d1c516885", size = 377811, upload-time = "2026-06-30T07:15:46.523Z" }, + { url = "https://files.pythonhosted.org/packages/b1/3d/198dceafb4fb034a6a47347e1b0735d34e0bd4a50be4e898d408ee66cb14/rpds_py-2026.6.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a550fb4950a06dde3beb4721f5ad4b25bf4513784665b0a8522c792e2bd822a4", size = 395382, upload-time = "2026-06-30T07:15:47.955Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f1/13968e49655d40b6b19d8b9140296bbc6f1d86b3f0f6c346cf9f1adddf4b/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4f4bca01b63096f606e095734dd56e74e175f94cfbf24ff3d63281cec61f7bb7", size = 543832, upload-time = "2026-06-30T07:15:49.33Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ab/289bcb1b90bd3e40a2900c561fa0e2087345ecbb094f0b870f2345142b7c/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ccffae9a092a00deb7efd545fe5e2c33c33b88e7c054337e9a74c179347d0b7d", size = 611011, upload-time = "2026-06-30T07:15:50.847Z" }, + { url = "https://files.pythonhosted.org/packages/1e/16/5043105e679436ccfbc8e5e0dd2d663ed18a8b8113515fd06a5e5d77c83e/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1cf01971c4f2c5553b772a542e4aaf191789cd331bc2cd4ff0e6e65ba49e1e97", size = 572431, upload-time = "2026-06-30T07:15:52.394Z" }, + { url = "https://files.pythonhosted.org/packages/85/ed/adab103321c0a6565d5ae1c2998349bc3ee175b82ccc5ae8fc04cc413075/rpds_py-2026.6.3-cp313-cp313-win32.whl", hash = "sha256:8c3d1e9c15b9d51ca0391e13da1a25a0a4df3c58a37c9dc368e0736cf7f69df0", size = 201710, upload-time = "2026-06-30T07:15:53.894Z" }, + { url = "https://files.pythonhosted.org/packages/7b/ed/a03b09668e74e5dabbf2e211f6468e1820c0552f7b0500082da31841bf7b/rpds_py-2026.6.3-cp313-cp313-win_amd64.whl", hash = "sha256:9250a9a0a6fd4648b3f868da8d91a4c52b5811a62df58e753d50ae4454a36f80", size = 219454, upload-time = "2026-06-30T07:15:55.25Z" }, + { url = "https://files.pythonhosted.org/packages/27/17/b8642c12930b71bc2b25831f6708ccf0f75abcd11883932ec9ce54ba3a78/rpds_py-2026.6.3-cp313-cp313-win_arm64.whl", hash = "sha256:900a67df3fd1660b035a4761c4ce73c382ea6b35f90f9863c36c6fd8bf8b09bb", size = 215063, upload-time = "2026-06-30T07:15:56.573Z" }, + { url = "https://files.pythonhosted.org/packages/b6/36/7fbe9dcdaf857fb3f63c2a2284b62492d95f5e8334e947e5fb6e7f68c9be/rpds_py-2026.6.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:931908d9fc855d8f74783377822be318edb6dcb19e47169dc038f9a1bf60b06e", size = 344510, upload-time = "2026-06-30T07:15:57.921Z" }, + { url = "https://files.pythonhosted.org/packages/ba/54/f785cc3d3f60839ca57a5af4927a9f347b07b2799c373fc20f7949f87c7e/rpds_py-2026.6.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d7469697dce35be237db177d42e2a2ee26e6dcc5fc052078a6fefabd288c6edd", size = 339495, upload-time = "2026-06-30T07:15:59.238Z" }, + { url = "https://files.pythonhosted.org/packages/63/ef/d4cdaf309e6b095b43597103cf8c0b951d6cca2acce68c474f75ec12e0c7/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bcfbcf66006befb9fd2aeaa9e01feaf881b4dc330a02ba07d2322b1c11be7b5d", size = 369454, upload-time = "2026-06-30T07:16:01.021Z" }, + { url = "https://files.pythonhosted.org/packages/96/4a/9559a68b7ee15db09d7981212e8c2e219d2a1d6d4faa0391d813c3496a36/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:847927daf4cffbd4e90e42bc890069897101edd015f956cb8721b3473372edda", size = 374583, upload-time = "2026-06-30T07:16:02.287Z" }, + { url = "https://files.pythonhosted.org/packages/ef/75/8964aa7d2c6e8ac43eba8eb6e6b0fdda1f46d39f2fc3e6aa9f2cb17f485d/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aca6c1ef08a82bfe327cc156da694660f599923e2e6665b6d81c9c2d0ac9ffc8", size = 492919, upload-time = "2026-06-30T07:16:03.723Z" }, + { url = "https://files.pythonhosted.org/packages/8f/97/6908094ac804115e65aedfd90f1b5fee4eebebd3f6c4cfc5419939267565/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ae50181a047c871561212bb97f7932a2d45fb53e947bd9b57ebad85b529cbc53", size = 383725, upload-time = "2026-06-30T07:16:05.305Z" }, + { url = "https://files.pythonhosted.org/packages/d1/9c/0d1fdc2e7aba23e290d603bc494e97bd205bae262ce33c6b32a69768ed5e/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc319e5a1de4b6913aac94bf6a2f9e847371e0a140a43dd4991db1a09bc2d504", size = 367255, upload-time = "2026-06-30T07:16:07.086Z" }, + { url = "https://files.pythonhosted.org/packages/c4/fe/f0209ca4a9ed074bc8acb44dfd0e81c3122e94c9689f5645b7973a866719/rpds_py-2026.6.3-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e4316bf32babbed84e691e352faf967ce2f0f024174a8643c37c94a1080374fc", size = 379060, upload-time = "2026-06-30T07:16:08.525Z" }, + { url = "https://files.pythonhosted.org/packages/c6/8d/f1cc54c616b9d8897de8738aac148d20afca93f68187475fe194d09a71b9/rpds_py-2026.6.3-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8c6e5a2f750cc71c3e3b11d71661f21d6f9bc6cebc6564b1466417a1ec03ec77", size = 395960, upload-time = "2026-06-30T07:16:09.989Z" }, + { url = "https://files.pythonhosted.org/packages/fb/04/aafff00f73aeca2945f734f1d483c64ab8f472d0864ab02377fd8e89c3b2/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4470ce197d4090875cf6affbf1f853338387428df97c4fb7b7106317b8214698", size = 545356, upload-time = "2026-06-30T07:16:11.816Z" }, + { url = "https://files.pythonhosted.org/packages/fd/cc/e229663b9e4ddac5a4acbe9085dd80a71af2a5d356b8b39d6bff233f24b0/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea964164cc9afa72d4d9b23cc28dafae93693c0a53e0b42acbff15b22c3f9ddd", size = 612319, upload-time = "2026-06-30T07:16:13.586Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7a/8a0e6d3e6cd066af108b71b43122c3fe158dd9eb86acac626593a2582eb1/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:639c8929aa0afe81be836b04de888460d6bed38b9c54cfc18da8f6bfabf5af5d", size = 573508, upload-time = "2026-06-30T07:16:15.23Z" }, + { url = "https://files.pythonhosted.org/packages/87/03/2a69ab618a789cf6cf85c86bb844c62d090e700ab1a2aa676b3741b6c516/rpds_py-2026.6.3-cp314-cp314-win32.whl", hash = "sha256:882076c00c0a608b131187055ddc5ae29f2e7eaf870d6168980420d58528a5c8", size = 202504, upload-time = "2026-06-30T07:16:16.893Z" }, + { url = "https://files.pythonhosted.org/packages/85/62/a3892ba945f4e24c78f352e5de3c7620d8479f73f211406a97263d13c7d2/rpds_py-2026.6.3-cp314-cp314-win_amd64.whl", hash = "sha256:0be972be84cfcaf46c8c6edf690ca0f154ac17babf1f6a955a51579b34ad2dc5", size = 220380, upload-time = "2026-06-30T07:16:18.108Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e7/c2bd44dc831931815ad11ebb5f430b5a0a4d3caa9de837107876c30c3432/rpds_py-2026.6.3-cp314-cp314-win_arm64.whl", hash = "sha256:2a9c6f195058cb45335e8cc3802745c603d716eb96bc9625950c1aac71c0c703", size = 215976, upload-time = "2026-06-30T07:16:19.654Z" }, + { url = "https://files.pythonhosted.org/packages/79/9c/fff7b74bce9a091ec9a012a03f9ff5f69364eaf9451060dfc4486da2ffdd/rpds_py-2026.6.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:f90938e92afda60266da758ee7d363447f7f0138c9559f9e1811629580582d90", size = 346840, upload-time = "2026-06-30T07:16:21.268Z" }, + { url = "https://files.pythonhosted.org/packages/e9/44/77bcb1168b33704908295533d27f10eb811e9e3e193e8993dc99572211d3/rpds_py-2026.6.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ec829541c45bca16e61c7ae50c20501f213605beb75d1aba91a6ee37fbbb56a4", size = 340282, upload-time = "2026-06-30T07:16:22.875Z" }, + { url = "https://files.pythonhosted.org/packages/87/3c/7a9081c7c9e645b39efe19e4ffbeccd80add246327cd9b888aecffd72317/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:afd70d95892096cdb26f15a00c45907b17817577aa8d1c76b2dcc2788391f9e9", size = 370403, upload-time = "2026-06-30T07:16:24.415Z" }, + { url = "https://files.pythonhosted.org/packages/f7/69/af47021eb7dad6ff3396cb001c08f0f3c4d06c20253f75be6421a59fe6b7/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:29dfa0533a5d4c94d4dfa1b694fcb56c9c63aad8330ffdd816fd225d0a7a162f", size = 376055, upload-time = "2026-06-30T07:16:26.111Z" }, + { url = "https://files.pythonhosted.org/packages/81/fc/a3bcf517084396a6dd258c592567a3c011ba4557f2fde23dceaf26e74f2e/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:af05d726809bff6b141be124d4c7ce998f9c9c7f30edb1f46c07aa103d540b41", size = 494419, upload-time = "2026-06-30T07:16:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/c9/eb/13d529d1788135425c7bf207f8463458ca5d92e43f3f701365b83e9dffc1/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9826217f048f620d9a712672818bf231442c1b35d96b227a07eabd11b4bb6945", size = 384848, upload-time = "2026-06-30T07:16:29.183Z" }, + { url = "https://files.pythonhosted.org/packages/8e/f4/b7ac49f30013aba8f7b9566b1dd07e81de95e708c1374b7bacc5b9bc5c9c/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:536bceea4fa4acf7e1c61da2b5786304367c816c8895be71b8f537c480b0ea1f", size = 371369, upload-time = "2026-06-30T07:16:30.912Z" }, + { url = "https://files.pythonhosted.org/packages/31/86/6260bafa622f788b07ddec0e52d810305c8b9b0b8c27f58a2ab04bf62b4f/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:bc0011654b91cc4fb2ae701bec0a0ba1e552c0714247fa7af6c59e0ccfa3a4e1", size = 379673, upload-time = "2026-06-30T07:16:32.486Z" }, + { url = "https://files.pythonhosted.org/packages/19/c3/03f1ee79a047b48daeca157c89a18509cde22b6b951d642b9b0af1be660a/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:539d75de9e0d536c84ff18dfeb805398e58227001ce09231a26a08b9aed1ee0e", size = 397500, upload-time = "2026-06-30T07:16:34.471Z" }, + { url = "https://files.pythonhosted.org/packages/f0/95/8ed0cd8c377dca12aea498f119fe639fc474d1461545c39d2b5872eb1c0f/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:166cf54d9f44fc6ceb53c7860258dde44a81406646de79f8ed3234fca3b6e538", size = 545978, upload-time = "2026-06-30T07:16:36.45Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f2/0eb57f0eaa83f8fc152a7e03de968ab77e1f00732bebc892b190c6eebde7/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:d34c20167764fbcf927194d532dd7e0c56772f0a5f943fa5ef9e9afbba8fb9db", size = 613350, upload-time = "2026-06-30T07:16:38.213Z" }, + { url = "https://files.pythonhosted.org/packages/5b/de/e0674bdbc3ef7634989b3f854c3f34bc1f587d36e5bfdc5c378d57034619/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ea7bb13b7c9a29791f87a0387ba7d3ad3a6d783d827e4d3f27b40a0ff44495e2", size = 576486, upload-time = "2026-06-30T07:16:39.797Z" }, + { url = "https://files.pythonhosted.org/packages/f2/f6/21101359743cd136ada781e8210a85769578422ba460672eea0e29739200/rpds_py-2026.6.3-cp314-cp314t-win32.whl", hash = "sha256:6de4744d05bd1aa1be4ed7ea1189e3979196808008113bbbf899a460966b925e", size = 201068, upload-time = "2026-06-30T07:16:41.316Z" }, + { url = "https://files.pythonhosted.org/packages/a6/b2/9574d4d44f7760c2aa32d92a0a4f41698e33f5b204a0bf5c9758f52c79d5/rpds_py-2026.6.3-cp314-cp314t-win_amd64.whl", hash = "sha256:c7b9a2f8f4d8e90af72571d3d495deebdd7e3c75451f5b41719aee166e940fc2", size = 220600, upload-time = "2026-06-30T07:16:43.091Z" }, + { url = "https://files.pythonhosted.org/packages/08/ae/f23a2697e6ee6340a578b0f136be6483657bef0c6f9497b752bb5c0964bb/rpds_py-2026.6.3-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:e059c5dde6452b44424bd1834557556c226b57781dee1227af23518459722b13", size = 344726, upload-time = "2026-06-30T07:16:44.5Z" }, + { url = "https://files.pythonhosted.org/packages/c3/63/e7b3a1a5358dd32c930a1062d8e15b67fd6e8922e81df9e91706d66ee5c8/rpds_py-2026.6.3-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2f7c26fbc5acd2522b95d4177fe4710ffd8e9b20529e703ffbf8db4d93903f05", size = 339587, upload-time = "2026-06-30T07:16:46.255Z" }, + { url = "https://files.pythonhosted.org/packages/ec/64/10a85681916ca55fffb91b0a211f84e34297c109243484dd6394660a8a7c/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a3086b538543802f84c843911242db20447de00d8752dd0efc936dbcf02218ba", size = 369585, upload-time = "2026-06-30T07:16:48.101Z" }, + { url = "https://files.pythonhosted.org/packages/76/c2/baf95c7c38823e12ba34407c5f5767a89e5cf2233895e56f608167ae9493/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8f2e5c5ee828d42cb11760761c0af6507927bec42d0ad5458f97c9203b054617", size = 375479, upload-time = "2026-06-30T07:16:49.93Z" }, + { url = "https://files.pythonhosted.org/packages/6a/94/0aad06c72d65101e11d33528d438cda99a39ce0da99466e156158f2541d3/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ed0c1e5d10cdc7135537988c74a0188da68e2f3c30813ba3744ab1e42e0480f9", size = 492418, upload-time = "2026-06-30T07:16:51.641Z" }, + { url = "https://files.pythonhosted.org/packages/b5/17/de3f5a479a1f056535d7489819639d8cd591ea6281d700390b43b1abd745/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c2642a7603ec0b16ed77da4555db3b4b472341904873788327c0b0d7b95f1bb", size = 384123, upload-time = "2026-06-30T07:16:53.622Z" }, + { url = "https://files.pythonhosted.org/packages/46/7d/bf09bd1b145bb2671c03e1e6d1ab8651858d90d8c7dfeadd85a37a934fd8/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e4320744c1ffdd95a603def63344bfab2d33edeab301c5007e7de9f9f5b3885", size = 367351, upload-time = "2026-06-30T07:16:55.241Z" }, + { url = "https://files.pythonhosted.org/packages/a3/ea/1bb734f314b8be319149ddee80b18bd41372bdcfbdf88d28131c0cd37719/rpds_py-2026.6.3-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:a9f4645593036b81bbdb36b9c8e0ea0d1c3fee968c4d59db0344c14087ef143a", size = 378827, upload-time = "2026-06-30T07:16:56.841Z" }, + { url = "https://files.pythonhosted.org/packages/4b/93/d9611e5b25e26df9a3649813ed66193ace9347a7c7fc4ab7cf70e94851c0/rpds_py-2026.6.3-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e55d236be29255554da47abe5c577637db7c24a02b8b46f0ca9524c855801868", size = 395966, upload-time = "2026-06-30T07:16:58.557Z" }, + { url = "https://files.pythonhosted.org/packages/c3/cb/99d77e16e5534ae1d90629bbe419ba6ee170833a6a85e3aa1cc41726fbbc/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:24e9c5386e16669b674a69c156c8eeefcb578f3b3397b713b08e6d60f3c7b187", size = 545680, upload-time = "2026-06-30T07:17:00.164Z" }, + { url = "https://files.pythonhosted.org/packages/59/15/11a29755f790cef7a2f755e8e14f4f0c33f39489e1893a632a2eee59672b/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:c60924535c75f1566b6eb75b5c31a48a43fef04fa2d0d201acbad8a9969c6107", size = 611853, upload-time = "2026-06-30T07:17:01.962Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/0c27547e21644da938fb530f7e1a8148dd24d02db07e7a5f2567a17ce710/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:38a2fea2787428f811719ceb9114cb78964a3138838320c29ac39526c79c16ba", size = 573715, upload-time = "2026-06-30T07:17:03.693Z" }, + { url = "https://files.pythonhosted.org/packages/29/71/4d8fcf700931815594bce892255bbd973b94efaf0fc1932b0590df18d886/rpds_py-2026.6.3-cp315-cp315-win32.whl", hash = "sha256:d483fe17f01ad64b7bf7cc38fcefff1ca9fb83f8c2b2542b68f97ffe0611b369", size = 202864, upload-time = "2026-06-30T07:17:05.746Z" }, + { url = "https://files.pythonhosted.org/packages/eb/62/b577562de0edbb55b2be85ce5fd09c33e386b9b13eee09833af4240fd5c4/rpds_py-2026.6.3-cp315-cp315-win_amd64.whl", hash = "sha256:67e3a721ffc5d8d2210d3671872298c4a84e4b8035cfe42ffd7cde35d772b146", size = 220430, upload-time = "2026-06-30T07:17:07.471Z" }, + { url = "https://files.pythonhosted.org/packages/c8/95/d6d0b2509825141eef60669a5739eec88dbc6a48053d6c92993a5704defe/rpds_py-2026.6.3-cp315-cp315-win_arm64.whl", hash = "sha256:6e84adbcf4bf841aed8116a8264b9f50b4cb3e7bd89b516122e616ac56ca269e", size = 215877, upload-time = "2026-06-30T07:17:09.008Z" }, + { url = "https://files.pythonhosted.org/packages/b7/bf/f3ea278f0afd615c1d0f19cb69043a41526e2bb600c2b536eb192218eb27/rpds_py-2026.6.3-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:ae6dd8f10bd17aad820876d24caec9efdafd80a318d16c0a48edb5e136902c6b", size = 346933, upload-time = "2026-06-30T07:17:10.762Z" }, + { url = "https://files.pythonhosted.org/packages/9d/29/9907bdf1c5346763cf10b7f6852aad86652168c259def904cbe0082c5864/rpds_py-2026.6.3-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:bdbd97738551fca3917c1bd7188bec1920bb520104f28e7e1007f9ceb17b7690", size = 340274, upload-time = "2026-06-30T07:17:12.266Z" }, + { url = "https://files.pythonhosted.org/packages/6f/2c/8e03767b5778ef25cebf74a7a91a2c3806f8eced4c92cb7406bbe060756d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b95977e7211527ab0ba576e286d023389fbeeb32a6b7b771665d333c60e5342", size = 370763, upload-time = "2026-06-30T07:17:14.107Z" }, + { url = "https://files.pythonhosted.org/packages/2e/e1/df2a7e1ba2efd796af26194250b8d42c821b46592311595162af9ef0528d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d15fde0e6fb0d88a60d221204873743e5d9f0b7d29165e62cd86d0413ad74ba6", size = 376467, upload-time = "2026-06-30T07:17:15.76Z" }, + { url = "https://files.pythonhosted.org/packages/6b/de/8a0814d1946af29cb068fb259aa8622f856df1d0bab58429448726b537f5/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a136d453475ac0fcbda502ef1e6504bd28d6d904700915d278deeab0d00fe140", size = 496689, upload-time = "2026-06-30T07:17:17.308Z" }, + { url = "https://files.pythonhosted.org/packages/df/f3/f19e0c852ba13694f5a79f3b719331051573cb5693feacf8a88ffffc3a71/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f826877d462181e5eb1c26a0026b8d0cab05d99844ecb6d8bf3627a2ca0c0442", size = 385340, upload-time = "2026-06-30T07:17:18.928Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ae/7ec3a9d2d4351f99e37bcb06b6b6f954512646bfdbf9742e1de727865daf/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:79486287de1730dbaff3dbd124d0ca4d2ef7f9d29bf2544f1f93c09b5bcbbd12", size = 372179, upload-time = "2026-06-30T07:17:20.539Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ac/9cee911dff2aaa9a5a8354f6610bf2e6a616de9197c5fff4f54f82585f1e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:808345f53cb952433ca2816f1604ff3515608a81784954f38d4452acfe8e61d5", size = 379993, upload-time = "2026-06-30T07:17:22.212Z" }, + { url = "https://files.pythonhosted.org/packages/83/6b/7c2a07ba88d1e9a936612f7a5d067467ed03d971d5a06f7d309dff044a7e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1967debc37f64f2c4dc90a7f563aec558b471966e12adcac4e1c4240496b6ebf", size = 398909, upload-time = "2026-06-30T07:17:23.66Z" }, + { url = "https://files.pythonhosted.org/packages/97/0b/776ffcb66783637b0031f6d58d6fb55913c8b5abf00aeecd46bf933fb477/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:f0840b5b17057f7fd918b76183a4b5a0635f43e14eb2ce60dce1d4ee4707ea00", size = 546584, upload-time = "2026-06-30T07:17:25.264Z" }, + { url = "https://files.pythonhosted.org/packages/55/33/ba3bc04d7092bd553c9b2b195624992d2cc4f3de1f380b7b93cbee67bd79/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:faa679d19a6696fd54259ad321251ad77a13e70e03dd834daa762a44fb6196ef", size = 614357, upload-time = "2026-06-30T07:17:26.888Z" }, + { url = "https://files.pythonhosted.org/packages/8b/71/14edf065f04630b1a8472f7653cad03f6c478bcf95ea0e6aed55451e33ea/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:23a439f31ccbeff1574e24889128821d1f7917470e830cf6544dced1c662262a", size = 576533, upload-time = "2026-06-30T07:17:28.546Z" }, + { url = "https://files.pythonhosted.org/packages/ba/76/65002b08596c389105720a8c0d22298b8dc25a4baf89b2ce431343c8b1de/rpds_py-2026.6.3-cp315-cp315t-win32.whl", hash = "sha256:913ca42ccad3f8cc6e292b587ae8ae49c8c823e5dce51a736252fc7c7cdfa577", size = 201204, upload-time = "2026-06-30T07:17:30.193Z" }, + { url = "https://files.pythonhosted.org/packages/8c/97/d855d6b3c322d1f27e26f5241c42016b56cf01377ea8ed348285f54652f0/rpds_py-2026.6.3-cp315-cp315t-win_amd64.whl", hash = "sha256:ae3d4fe8c0b9213624fdce7279d70e3b148b682ca20719ebd193a23ebfa47324", size = 220719, upload-time = "2026-06-30T07:17:31.788Z" }, + { url = "https://files.pythonhosted.org/packages/b4/9c/f0d19ac587fd0e4ab6b72cda355e9c5a6166b01ef7e064e437aef8eb9fef/rpds_py-2026.6.3-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:4cf2d36a2357e4d07bb5a4f98801265327b48256867816cfd2ceb001e9754a8f", size = 349791, upload-time = "2026-06-30T07:17:33.315Z" }, + { url = "https://files.pythonhosted.org/packages/38/c7/1d49d204c9fd2ee6c537601dc4c1ba921e03363ca576bfab94a00254ac9a/rpds_py-2026.6.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:30c6dc199b24a5e3e81d50da0f00858c5bbdb2617a750395687f4339c5818171", size = 352842, upload-time = "2026-06-30T07:17:34.897Z" }, + { url = "https://files.pythonhosted.org/packages/ac/e5/c0b5dc93cd0d4c06ce1f438907649514e2ea077bcd911e3154a51e96c38e/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9891e594296ab9dada6551c8e7b387b2721f27a67eecd528412e8906247a7b90", size = 382094, upload-time = "2026-06-30T07:17:36.514Z" }, + { url = "https://files.pythonhosted.org/packages/0d/54/ec0e907b4ca8d541112db352409bd15f871c9b243e0c92c9b5a46ae96f01/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b5c2dc92304aa48a4a60443b548bb12f12e119d4b72f314015e67b9e1be97fca", size = 388662, upload-time = "2026-06-30T07:17:38.235Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f4/921c22a4fd0f1c1ac13a3996ffbf0aa67951e2c8ad0d1d9574938a2932e8/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:127e08c0642d880cf32ca47ec2a4a77b901f7e2dd1ad9762adb13955d72ffcc9", size = 504896, upload-time = "2026-06-30T07:17:39.689Z" }, + { url = "https://files.pythonhosted.org/packages/0b/1b/a114b972cefa1ab1cdb3c7bb177cd3844a12826c507c722d3a73516dbbaf/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8bb68f03f395eb793220b45c097bd4d8c32944393da0fad8b999efac0868fc8c", size = 391545, upload-time = "2026-06-30T07:17:41.336Z" }, + { url = "https://files.pythonhosted.org/packages/4e/98/af9b3db77d47fcbe6c8c1f36e2c2147ec70292819e99c325f871584a1c11/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a3450b693fde92133e9f51060568a4c31fcca76d5e53bbd611e689ca446517e9", size = 380059, upload-time = "2026-06-30T07:17:42.857Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ba/0efd8668b97c1d26a61566386c636a7a7a09829e474fdf807caa15a2c844/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:5e8d07bddee435a2ff6f1920e18feff28d0bc4533e42f4bf6927fbd073312c41", size = 393235, upload-time = "2026-06-30T07:17:44.637Z" }, + { url = "https://files.pythonhosted.org/packages/62/90/8c139ee9690f73b0829f32647de6f40d826f8f443af6fa72644f96351aac/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3a83ae6c67b7676b9878378547ca8e93ed77a580037bcbcd1d32f739e1e6089c", size = 413008, upload-time = "2026-06-30T07:17:46.225Z" }, + { url = "https://files.pythonhosted.org/packages/9c/97/0043896fdd7828ce09a1d9a8b06433714d0960fc4ff3fc4aa72b666b764e/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:2bfd04c19ddbd6640de0b51894d764bd2758854d5b75bd102d2ef10cb9c293a9", size = 558118, upload-time = "2026-06-30T07:17:47.759Z" }, + { url = "https://files.pythonhosted.org/packages/f6/40/02355f0e134f783a8f9814c4680a1bd311d37671577a5964ea838573ff37/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:ca6546b66be9dc4738b1b043d5ebd5488c66c578c5ff0fd0e8065313fe3afb76", size = 623138, upload-time = "2026-06-30T07:17:49.355Z" }, + { url = "https://files.pythonhosted.org/packages/10/85/48f0abdcef5cce4e034c7a5b0ceeceba0b01bf0d942824f4bb720afe2dec/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:8e65860d238379ed982fd9ba690579b5e95af2f4840f99c772816dbe573cb826", size = 586486, upload-time = "2026-06-30T07:17:51.141Z" }, +] + +[[package]] +name = "ruff" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4d/94/1e5e4967626faf12fa56999cd6222dff6992ceb086ad7945756baf70c7a7/ruff-0.16.0.tar.gz", hash = "sha256:e460aafd5495ec89efaa6ced2e4a9a581116451e1c88b9d37ef497e0f8e93982", size = 4790557, upload-time = "2026-07-23T19:11:30.981Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/81/1c8818fee7ce1a04cd7d1b3172e0a8f8e4f1dc4feb7fc390e16daa8af323/ruff-0.16.0-py3-none-linux_armv6l.whl", hash = "sha256:e5115729eb08c585e5121978ba5d5b60caeae394ce21b9fb5e6cd33a1c6c9b1e", size = 10754633, upload-time = "2026-07-23T19:10:46.415Z" }, + { url = "https://files.pythonhosted.org/packages/23/df/beaf59c09d68db84304d555f188b276a77132a5d5b0b67a5c762aa143628/ruff-0.16.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:3c954b1d580bfa035b41654f7858cc7e71d5fc3ac5b723dd62bd9133830ed522", size = 10969164, upload-time = "2026-07-23T19:10:50.271Z" }, + { url = "https://files.pythonhosted.org/packages/42/ce/741cd197496a1abbf51352710fd15ed995d2a2be87189c1da26a450d6e83/ruff-0.16.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e01c21d10eb1b29f47b7454e1f4056db9a3f0260c646aa88457c610291db9f81", size = 10488846, upload-time = "2026-07-23T19:10:52.639Z" }, + { url = "https://files.pythonhosted.org/packages/52/2a/a2db8e88cade358f5cdcb05674a917751074109315d014eb6352d9a893f7/ruff-0.16.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e364e5ed22ed8dc05082fd78e35308618260907ac2d3c1d637b2e682415b6c9", size = 10889729, upload-time = "2026-07-23T19:10:54.89Z" }, + { url = "https://files.pythonhosted.org/packages/42/65/62a771694ebd63029dc953e27dbad40e1588bd4860ff9fe881018fddaa49/ruff-0.16.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d327b8fc113a1d4421a04f3839d3752057c8dd1ee320223a6f3f52d04ada462a", size = 10568275, upload-time = "2026-07-23T19:10:56.993Z" }, + { url = "https://files.pythonhosted.org/packages/3f/e2/ced249fe8af5f086c5c58cc21cc3356d50f32f7401c5df87050c999620a7/ruff-0.16.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a9b50c55e263103586b3dcf5f73d479eb8cb5fdb6098fec59a62891dab653717", size = 11385112, upload-time = "2026-07-23T19:10:59.615Z" }, + { url = "https://files.pythonhosted.org/packages/87/0b/05154977a8fd69eeb6c103271f55403bfd8711f5c0f8ed07489d95a504e7/ruff-0.16.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0ff4a79ce3ec0172f3241943835de1c4cb4e2dcd07f0f8c2d02603dbbbee4b17", size = 12207008, upload-time = "2026-07-23T19:11:02.154Z" }, + { url = "https://files.pythonhosted.org/packages/fb/29/98225831a3a1eab0e02f4acc6ca6559a98611dcc68b6965ff4b7234627c1/ruff-0.16.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e95c448fca1fb2a18372a9440926c5a6ee789639bb975c72e7ae6d0b04218ab4", size = 11650842, upload-time = "2026-07-23T19:11:04.557Z" }, + { url = "https://files.pythonhosted.org/packages/91/66/6bd3cf90500653d55dc0ffc8507aa8300bd49d0214b2e8cb4d3fef2943ba/ruff-0.16.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f11a8d11010301d0a398a2fdef67691feca7294da6aef55e2150e8fa2cd520b", size = 11400718, upload-time = "2026-07-23T19:11:09.233Z" }, + { url = "https://files.pythonhosted.org/packages/8e/a2/a54eb4eae05d66364050a5d3b8a9c5ef88196531b3cbe7109d873f87f819/ruff-0.16.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:48044c678e9cb8698246c99b14aaccfa6601dea7379eb48a6f8f73f7a6d86cd0", size = 11426177, upload-time = "2026-07-23T19:11:11.994Z" }, + { url = "https://files.pythonhosted.org/packages/1a/be/16e3eea4b2a478a496919f5e36f17c4559e54620bd3bbac5d6affa068006/ruff-0.16.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:7aa0959bad8eb8bef50340154fc9b58678dae31fa4293afa38b44b6e552c0213", size = 10856126, upload-time = "2026-07-23T19:11:14.221Z" }, + { url = "https://files.pythonhosted.org/packages/a2/84/252eb8b868a16eec7257c14f504f77537e734b2d69c762e639e588e304a3/ruff-0.16.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:28ea2b7df8ebf7f9da6b7d47b230ab48f387c0a29be3b474c4d0740e197bb9af", size = 10571208, upload-time = "2026-07-23T19:11:16.378Z" }, + { url = "https://files.pythonhosted.org/packages/21/09/817a482f542f7570cbb4554b26e896610c7114f539b1d9e2d2145bf6bef6/ruff-0.16.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:33a3dfac8c35f81498dea9181bccc2f4c4bc8f1521a1dd9406e77643e0f0fb09", size = 11063329, upload-time = "2026-07-23T19:11:19.173Z" }, + { url = "https://files.pythonhosted.org/packages/2e/23/9403c180ca1cb9b1f7335f5c3e5305c09d49ea5b345196682a36028bde4a/ruff-0.16.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a5237a0bda500d30d81b8e07a6973a5cbc772864cbf746ae2f4e8a2e01c9f4ed", size = 11489751, upload-time = "2026-07-23T19:11:21.74Z" }, + { url = "https://files.pythonhosted.org/packages/b2/1d/1b2ef7bcde851c78d7f17f1cca13fd6dc695fc4b3d6197941e72cae5b132/ruff-0.16.0-py3-none-win32.whl", hash = "sha256:7fab76fa065c873f41ff744347c6e77bcc3dfec4bcc754dc26b63d23c0f7f5fb", size = 10785885, upload-time = "2026-07-23T19:11:23.947Z" }, + { url = "https://files.pythonhosted.org/packages/b2/a3/d5e4ef7a56be3f928ffb90b94c25ba7d3cb9c7fe0736aeaaedf361770712/ruff-0.16.0-py3-none-win_amd64.whl", hash = "sha256:429c117f022bf481fabd9d551e7a3952b24c65e6ef44337ea09d90bebef14472", size = 11923141, upload-time = "2026-07-23T19:11:26.409Z" }, + { url = "https://files.pythonhosted.org/packages/cb/9a/8415f2657cbe200f41a4531ccededf135505a92d4a012229121f885b26f9/ruff-0.16.0-py3-none-win_arm64.whl", hash = "sha256:14296fedcd2705c77ab8235439278bbb38f285cf7da5528b00b3e330c3d4872d", size = 11273407, upload-time = "2026-07-23T19:11:28.705Z" }, +] + +[[package]] +name = "scipy" +version = "1.15.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +dependencies = [ + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0f/37/6964b830433e654ec7485e45a00fc9a27cf868d622838f6b6d9c5ec0d532/scipy-1.15.3.tar.gz", hash = "sha256:eae3cf522bc7df64b42cad3925c876e1b0b6c35c1337c93e12c0f366f55b0eaf", size = 59419214, upload-time = "2025-05-08T16:13:05.955Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/2f/4966032c5f8cc7e6a60f1b2e0ad686293b9474b65246b0c642e3ef3badd0/scipy-1.15.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:a345928c86d535060c9c2b25e71e87c39ab2f22fc96e9636bd74d1dbf9de448c", size = 38702770, upload-time = "2025-05-08T16:04:20.849Z" }, + { url = "https://files.pythonhosted.org/packages/a0/6e/0c3bf90fae0e910c274db43304ebe25a6b391327f3f10b5dcc638c090795/scipy-1.15.3-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:ad3432cb0f9ed87477a8d97f03b763fd1d57709f1bbde3c9369b1dff5503b253", size = 30094511, upload-time = "2025-05-08T16:04:27.103Z" }, + { url = "https://files.pythonhosted.org/packages/ea/b1/4deb37252311c1acff7f101f6453f0440794f51b6eacb1aad4459a134081/scipy-1.15.3-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:aef683a9ae6eb00728a542b796f52a5477b78252edede72b8327a886ab63293f", size = 22368151, upload-time = "2025-05-08T16:04:31.731Z" }, + { url = "https://files.pythonhosted.org/packages/38/7d/f457626e3cd3c29b3a49ca115a304cebb8cc6f31b04678f03b216899d3c6/scipy-1.15.3-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:1c832e1bd78dea67d5c16f786681b28dd695a8cb1fb90af2e27580d3d0967e92", size = 25121732, upload-time = "2025-05-08T16:04:36.596Z" }, + { url = "https://files.pythonhosted.org/packages/db/0a/92b1de4a7adc7a15dcf5bddc6e191f6f29ee663b30511ce20467ef9b82e4/scipy-1.15.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:263961f658ce2165bbd7b99fa5135195c3a12d9bef045345016b8b50c315cb82", size = 35547617, upload-time = "2025-05-08T16:04:43.546Z" }, + { url = "https://files.pythonhosted.org/packages/8e/6d/41991e503e51fc1134502694c5fa7a1671501a17ffa12716a4a9151af3df/scipy-1.15.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9e2abc762b0811e09a0d3258abee2d98e0c703eee49464ce0069590846f31d40", size = 37662964, upload-time = "2025-05-08T16:04:49.431Z" }, + { url = "https://files.pythonhosted.org/packages/25/e1/3df8f83cb15f3500478c889be8fb18700813b95e9e087328230b98d547ff/scipy-1.15.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ed7284b21a7a0c8f1b6e5977ac05396c0d008b89e05498c8b7e8f4a1423bba0e", size = 37238749, upload-time = "2025-05-08T16:04:55.215Z" }, + { url = "https://files.pythonhosted.org/packages/93/3e/b3257cf446f2a3533ed7809757039016b74cd6f38271de91682aa844cfc5/scipy-1.15.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5380741e53df2c566f4d234b100a484b420af85deb39ea35a1cc1be84ff53a5c", size = 40022383, upload-time = "2025-05-08T16:05:01.914Z" }, + { url = "https://files.pythonhosted.org/packages/d1/84/55bc4881973d3f79b479a5a2e2df61c8c9a04fcb986a213ac9c02cfb659b/scipy-1.15.3-cp310-cp310-win_amd64.whl", hash = "sha256:9d61e97b186a57350f6d6fd72640f9e99d5a4a2b8fbf4b9ee9a841eab327dc13", size = 41259201, upload-time = "2025-05-08T16:05:08.166Z" }, + { url = "https://files.pythonhosted.org/packages/96/ab/5cc9f80f28f6a7dff646c5756e559823614a42b1939d86dd0ed550470210/scipy-1.15.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:993439ce220d25e3696d1b23b233dd010169b62f6456488567e830654ee37a6b", size = 38714255, upload-time = "2025-05-08T16:05:14.596Z" }, + { url = "https://files.pythonhosted.org/packages/4a/4a/66ba30abe5ad1a3ad15bfb0b59d22174012e8056ff448cb1644deccbfed2/scipy-1.15.3-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:34716e281f181a02341ddeaad584205bd2fd3c242063bd3423d61ac259ca7eba", size = 30111035, upload-time = "2025-05-08T16:05:20.152Z" }, + { url = "https://files.pythonhosted.org/packages/4b/fa/a7e5b95afd80d24313307f03624acc65801846fa75599034f8ceb9e2cbf6/scipy-1.15.3-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:3b0334816afb8b91dab859281b1b9786934392aa3d527cd847e41bb6f45bee65", size = 22384499, upload-time = "2025-05-08T16:05:24.494Z" }, + { url = "https://files.pythonhosted.org/packages/17/99/f3aaddccf3588bb4aea70ba35328c204cadd89517a1612ecfda5b2dd9d7a/scipy-1.15.3-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:6db907c7368e3092e24919b5e31c76998b0ce1684d51a90943cb0ed1b4ffd6c1", size = 25152602, upload-time = "2025-05-08T16:05:29.313Z" }, + { url = "https://files.pythonhosted.org/packages/56/c5/1032cdb565f146109212153339f9cb8b993701e9fe56b1c97699eee12586/scipy-1.15.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:721d6b4ef5dc82ca8968c25b111e307083d7ca9091bc38163fb89243e85e3889", size = 35503415, upload-time = "2025-05-08T16:05:34.699Z" }, + { url = "https://files.pythonhosted.org/packages/bd/37/89f19c8c05505d0601ed5650156e50eb881ae3918786c8fd7262b4ee66d3/scipy-1.15.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39cb9c62e471b1bb3750066ecc3a3f3052b37751c7c3dfd0fd7e48900ed52982", size = 37652622, upload-time = "2025-05-08T16:05:40.762Z" }, + { url = "https://files.pythonhosted.org/packages/7e/31/be59513aa9695519b18e1851bb9e487de66f2d31f835201f1b42f5d4d475/scipy-1.15.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:795c46999bae845966368a3c013e0e00947932d68e235702b5c3f6ea799aa8c9", size = 37244796, upload-time = "2025-05-08T16:05:48.119Z" }, + { url = "https://files.pythonhosted.org/packages/10/c0/4f5f3eeccc235632aab79b27a74a9130c6c35df358129f7ac8b29f562ac7/scipy-1.15.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:18aaacb735ab38b38db42cb01f6b92a2d0d4b6aabefeb07f02849e47f8fb3594", size = 40047684, upload-time = "2025-05-08T16:05:54.22Z" }, + { url = "https://files.pythonhosted.org/packages/ab/a7/0ddaf514ce8a8714f6ed243a2b391b41dbb65251affe21ee3077ec45ea9a/scipy-1.15.3-cp311-cp311-win_amd64.whl", hash = "sha256:ae48a786a28412d744c62fd7816a4118ef97e5be0bee968ce8f0a2fba7acf3bb", size = 41246504, upload-time = "2025-05-08T16:06:00.437Z" }, + { url = "https://files.pythonhosted.org/packages/37/4b/683aa044c4162e10ed7a7ea30527f2cbd92e6999c10a8ed8edb253836e9c/scipy-1.15.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6ac6310fdbfb7aa6612408bd2f07295bcbd3fda00d2d702178434751fe48e019", size = 38766735, upload-time = "2025-05-08T16:06:06.471Z" }, + { url = "https://files.pythonhosted.org/packages/7b/7e/f30be3d03de07f25dc0ec926d1681fed5c732d759ac8f51079708c79e680/scipy-1.15.3-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:185cd3d6d05ca4b44a8f1595af87f9c372bb6acf9c808e99aa3e9aa03bd98cf6", size = 30173284, upload-time = "2025-05-08T16:06:11.686Z" }, + { url = "https://files.pythonhosted.org/packages/07/9c/0ddb0d0abdabe0d181c1793db51f02cd59e4901da6f9f7848e1f96759f0d/scipy-1.15.3-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:05dc6abcd105e1a29f95eada46d4a3f251743cfd7d3ae8ddb4088047f24ea477", size = 22446958, upload-time = "2025-05-08T16:06:15.97Z" }, + { url = "https://files.pythonhosted.org/packages/af/43/0bce905a965f36c58ff80d8bea33f1f9351b05fad4beaad4eae34699b7a1/scipy-1.15.3-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:06efcba926324df1696931a57a176c80848ccd67ce6ad020c810736bfd58eb1c", size = 25242454, upload-time = "2025-05-08T16:06:20.394Z" }, + { url = "https://files.pythonhosted.org/packages/56/30/a6f08f84ee5b7b28b4c597aca4cbe545535c39fe911845a96414700b64ba/scipy-1.15.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c05045d8b9bfd807ee1b9f38761993297b10b245f012b11b13b91ba8945f7e45", size = 35210199, upload-time = "2025-05-08T16:06:26.159Z" }, + { url = "https://files.pythonhosted.org/packages/0b/1f/03f52c282437a168ee2c7c14a1a0d0781a9a4a8962d84ac05c06b4c5b555/scipy-1.15.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:271e3713e645149ea5ea3e97b57fdab61ce61333f97cfae392c28ba786f9bb49", size = 37309455, upload-time = "2025-05-08T16:06:32.778Z" }, + { url = "https://files.pythonhosted.org/packages/89/b1/fbb53137f42c4bf630b1ffdfc2151a62d1d1b903b249f030d2b1c0280af8/scipy-1.15.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6cfd56fc1a8e53f6e89ba3a7a7251f7396412d655bca2aa5611c8ec9a6784a1e", size = 36885140, upload-time = "2025-05-08T16:06:39.249Z" }, + { url = "https://files.pythonhosted.org/packages/2e/2e/025e39e339f5090df1ff266d021892694dbb7e63568edcfe43f892fa381d/scipy-1.15.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0ff17c0bb1cb32952c09217d8d1eed9b53d1463e5f1dd6052c7857f83127d539", size = 39710549, upload-time = "2025-05-08T16:06:45.729Z" }, + { url = "https://files.pythonhosted.org/packages/e6/eb/3bf6ea8ab7f1503dca3a10df2e4b9c3f6b3316df07f6c0ded94b281c7101/scipy-1.15.3-cp312-cp312-win_amd64.whl", hash = "sha256:52092bc0472cfd17df49ff17e70624345efece4e1a12b23783a1ac59a1b728ed", size = 40966184, upload-time = "2025-05-08T16:06:52.623Z" }, + { url = "https://files.pythonhosted.org/packages/73/18/ec27848c9baae6e0d6573eda6e01a602e5649ee72c27c3a8aad673ebecfd/scipy-1.15.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2c620736bcc334782e24d173c0fdbb7590a0a436d2fdf39310a8902505008759", size = 38728256, upload-time = "2025-05-08T16:06:58.696Z" }, + { url = "https://files.pythonhosted.org/packages/74/cd/1aef2184948728b4b6e21267d53b3339762c285a46a274ebb7863c9e4742/scipy-1.15.3-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:7e11270a000969409d37ed399585ee530b9ef6aa99d50c019de4cb01e8e54e62", size = 30109540, upload-time = "2025-05-08T16:07:04.209Z" }, + { url = "https://files.pythonhosted.org/packages/5b/d8/59e452c0a255ec352bd0a833537a3bc1bfb679944c4938ab375b0a6b3a3e/scipy-1.15.3-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:8c9ed3ba2c8a2ce098163a9bdb26f891746d02136995df25227a20e71c396ebb", size = 22383115, upload-time = "2025-05-08T16:07:08.998Z" }, + { url = "https://files.pythonhosted.org/packages/08/f5/456f56bbbfccf696263b47095291040655e3cbaf05d063bdc7c7517f32ac/scipy-1.15.3-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:0bdd905264c0c9cfa74a4772cdb2070171790381a5c4d312c973382fc6eaf730", size = 25163884, upload-time = "2025-05-08T16:07:14.091Z" }, + { url = "https://files.pythonhosted.org/packages/a2/66/a9618b6a435a0f0c0b8a6d0a2efb32d4ec5a85f023c2b79d39512040355b/scipy-1.15.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:79167bba085c31f38603e11a267d862957cbb3ce018d8b38f79ac043bc92d825", size = 35174018, upload-time = "2025-05-08T16:07:19.427Z" }, + { url = "https://files.pythonhosted.org/packages/b5/09/c5b6734a50ad4882432b6bb7c02baf757f5b2f256041da5df242e2d7e6b6/scipy-1.15.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c9deabd6d547aee2c9a81dee6cc96c6d7e9a9b1953f74850c179f91fdc729cb7", size = 37269716, upload-time = "2025-05-08T16:07:25.712Z" }, + { url = "https://files.pythonhosted.org/packages/77/0a/eac00ff741f23bcabd352731ed9b8995a0a60ef57f5fd788d611d43d69a1/scipy-1.15.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:dde4fc32993071ac0c7dd2d82569e544f0bdaff66269cb475e0f369adad13f11", size = 36872342, upload-time = "2025-05-08T16:07:31.468Z" }, + { url = "https://files.pythonhosted.org/packages/fe/54/4379be86dd74b6ad81551689107360d9a3e18f24d20767a2d5b9253a3f0a/scipy-1.15.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f77f853d584e72e874d87357ad70f44b437331507d1c311457bed8ed2b956126", size = 39670869, upload-time = "2025-05-08T16:07:38.002Z" }, + { url = "https://files.pythonhosted.org/packages/87/2e/892ad2862ba54f084ffe8cc4a22667eaf9c2bcec6d2bff1d15713c6c0703/scipy-1.15.3-cp313-cp313-win_amd64.whl", hash = "sha256:b90ab29d0c37ec9bf55424c064312930ca5f4bde15ee8619ee44e69319aab163", size = 40988851, upload-time = "2025-05-08T16:08:33.671Z" }, + { url = "https://files.pythonhosted.org/packages/1b/e9/7a879c137f7e55b30d75d90ce3eb468197646bc7b443ac036ae3fe109055/scipy-1.15.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:3ac07623267feb3ae308487c260ac684b32ea35fd81e12845039952f558047b8", size = 38863011, upload-time = "2025-05-08T16:07:44.039Z" }, + { url = "https://files.pythonhosted.org/packages/51/d1/226a806bbd69f62ce5ef5f3ffadc35286e9fbc802f606a07eb83bf2359de/scipy-1.15.3-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:6487aa99c2a3d509a5227d9a5e889ff05830a06b2ce08ec30df6d79db5fcd5c5", size = 30266407, upload-time = "2025-05-08T16:07:49.891Z" }, + { url = "https://files.pythonhosted.org/packages/e5/9b/f32d1d6093ab9eeabbd839b0f7619c62e46cc4b7b6dbf05b6e615bbd4400/scipy-1.15.3-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:50f9e62461c95d933d5c5ef4a1f2ebf9a2b4e83b0db374cb3f1de104d935922e", size = 22540030, upload-time = "2025-05-08T16:07:54.121Z" }, + { url = "https://files.pythonhosted.org/packages/e7/29/c278f699b095c1a884f29fda126340fcc201461ee8bfea5c8bdb1c7c958b/scipy-1.15.3-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:14ed70039d182f411ffc74789a16df3835e05dc469b898233a245cdfd7f162cb", size = 25218709, upload-time = "2025-05-08T16:07:58.506Z" }, + { url = "https://files.pythonhosted.org/packages/24/18/9e5374b617aba742a990581373cd6b68a2945d65cc588482749ef2e64467/scipy-1.15.3-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a769105537aa07a69468a0eefcd121be52006db61cdd8cac8a0e68980bbb723", size = 34809045, upload-time = "2025-05-08T16:08:03.929Z" }, + { url = "https://files.pythonhosted.org/packages/e1/fe/9c4361e7ba2927074360856db6135ef4904d505e9b3afbbcb073c4008328/scipy-1.15.3-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9db984639887e3dffb3928d118145ffe40eff2fa40cb241a306ec57c219ebbbb", size = 36703062, upload-time = "2025-05-08T16:08:09.558Z" }, + { url = "https://files.pythonhosted.org/packages/b7/8e/038ccfe29d272b30086b25a4960f757f97122cb2ec42e62b460d02fe98e9/scipy-1.15.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:40e54d5c7e7ebf1aa596c374c49fa3135f04648a0caabcb66c52884b943f02b4", size = 36393132, upload-time = "2025-05-08T16:08:15.34Z" }, + { url = "https://files.pythonhosted.org/packages/10/7e/5c12285452970be5bdbe8352c619250b97ebf7917d7a9a9e96b8a8140f17/scipy-1.15.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:5e721fed53187e71d0ccf382b6bf977644c533e506c4d33c3fb24de89f5c3ed5", size = 38979503, upload-time = "2025-05-08T16:08:21.513Z" }, + { url = "https://files.pythonhosted.org/packages/81/06/0a5e5349474e1cbc5757975b21bd4fad0e72ebf138c5592f191646154e06/scipy-1.15.3-cp313-cp313t-win_amd64.whl", hash = "sha256:76ad1fb5f8752eabf0fa02e4cc0336b4e8f021e2d5f061ed37d6d264db35e3ca", size = 40308097, upload-time = "2025-05-08T16:08:27.627Z" }, +] + +[[package]] +name = "scipy" +version = "1.17.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.11.*' and sys_platform == 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'emscripten'", + "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] +dependencies = [ + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" } }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/75/b4ce781849931fef6fd529afa6b63711d5a733065722d0c3e2724af9e40a/scipy-1.17.1-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:1f95b894f13729334fb990162e911c9e5dc1ab390c58aa6cbecb389c5b5e28ec", size = 31613675, upload-time = "2026-02-23T00:16:00.13Z" }, + { url = "https://files.pythonhosted.org/packages/f7/58/bccc2861b305abdd1b8663d6130c0b3d7cc22e8d86663edbc8401bfd40d4/scipy-1.17.1-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:e18f12c6b0bc5a592ed23d3f7b891f68fd7f8241d69b7883769eb5d5dfb52696", size = 28162057, upload-time = "2026-02-23T00:16:09.456Z" }, + { url = "https://files.pythonhosted.org/packages/6d/ee/18146b7757ed4976276b9c9819108adbc73c5aad636e5353e20746b73069/scipy-1.17.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:a3472cfbca0a54177d0faa68f697d8ba4c80bbdc19908c3465556d9f7efce9ee", size = 20334032, upload-time = "2026-02-23T00:16:17.358Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e6/cef1cf3557f0c54954198554a10016b6a03b2ec9e22a4e1df734936bd99c/scipy-1.17.1-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:766e0dc5a616d026a3a1cffa379af959671729083882f50307e18175797b3dfd", size = 22709533, upload-time = "2026-02-23T00:16:25.791Z" }, + { url = "https://files.pythonhosted.org/packages/4d/60/8804678875fc59362b0fb759ab3ecce1f09c10a735680318ac30da8cd76b/scipy-1.17.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:744b2bf3640d907b79f3fd7874efe432d1cf171ee721243e350f55234b4cec4c", size = 33062057, upload-time = "2026-02-23T00:16:36.931Z" }, + { url = "https://files.pythonhosted.org/packages/09/7d/af933f0f6e0767995b4e2d705a0665e454d1c19402aa7e895de3951ebb04/scipy-1.17.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43af8d1f3bea642559019edfe64e9b11192a8978efbd1539d7bc2aaa23d92de4", size = 35349300, upload-time = "2026-02-23T00:16:49.108Z" }, + { url = "https://files.pythonhosted.org/packages/b4/3d/7ccbbdcbb54c8fdc20d3b6930137c782a163fa626f0aef920349873421ba/scipy-1.17.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cd96a1898c0a47be4520327e01f874acfd61fb48a9420f8aa9f6483412ffa444", size = 35127333, upload-time = "2026-02-23T00:17:01.293Z" }, + { url = "https://files.pythonhosted.org/packages/e8/19/f926cb11c42b15ba08e3a71e376d816ac08614f769b4f47e06c3580c836a/scipy-1.17.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4eb6c25dd62ee8d5edf68a8e1c171dd71c292fdae95d8aeb3dd7d7de4c364082", size = 37741314, upload-time = "2026-02-23T00:17:12.576Z" }, + { url = "https://files.pythonhosted.org/packages/95/da/0d1df507cf574b3f224ccc3d45244c9a1d732c81dcb26b1e8a766ae271a8/scipy-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:d30e57c72013c2a4fe441c2fcb8e77b14e152ad48b5464858e07e2ad9fbfceff", size = 36607512, upload-time = "2026-02-23T00:17:23.424Z" }, + { url = "https://files.pythonhosted.org/packages/68/7f/bdd79ceaad24b671543ffe0ef61ed8e659440eb683b66f033454dcee90eb/scipy-1.17.1-cp311-cp311-win_arm64.whl", hash = "sha256:9ecb4efb1cd6e8c4afea0daa91a87fbddbce1b99d2895d151596716c0b2e859d", size = 24599248, upload-time = "2026-02-23T00:17:34.561Z" }, + { url = "https://files.pythonhosted.org/packages/35/48/b992b488d6f299dbe3f11a20b24d3dda3d46f1a635ede1c46b5b17a7b163/scipy-1.17.1-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:35c3a56d2ef83efc372eaec584314bd0ef2e2f0d2adb21c55e6ad5b344c0dcb8", size = 31610954, upload-time = "2026-02-23T00:17:49.855Z" }, + { url = "https://files.pythonhosted.org/packages/b2/02/cf107b01494c19dc100f1d0b7ac3cc08666e96ba2d64db7626066cee895e/scipy-1.17.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:fcb310ddb270a06114bb64bbe53c94926b943f5b7f0842194d585c65eb4edd76", size = 28172662, upload-time = "2026-02-23T00:18:01.64Z" }, + { url = "https://files.pythonhosted.org/packages/cf/a9/599c28631bad314d219cf9ffd40e985b24d603fc8a2f4ccc5ae8419a535b/scipy-1.17.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:cc90d2e9c7e5c7f1a482c9875007c095c3194b1cfedca3c2f3291cdc2bc7c086", size = 20344366, upload-time = "2026-02-23T00:18:12.015Z" }, + { url = "https://files.pythonhosted.org/packages/35/f5/906eda513271c8deb5af284e5ef0206d17a96239af79f9fa0aebfe0e36b4/scipy-1.17.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:c80be5ede8f3f8eded4eff73cc99a25c388ce98e555b17d31da05287015ffa5b", size = 22704017, upload-time = "2026-02-23T00:18:21.502Z" }, + { url = "https://files.pythonhosted.org/packages/da/34/16f10e3042d2f1d6b66e0428308ab52224b6a23049cb2f5c1756f713815f/scipy-1.17.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e19ebea31758fac5893a2ac360fedd00116cbb7628e650842a6691ba7ca28a21", size = 32927842, upload-time = "2026-02-23T00:18:35.367Z" }, + { url = "https://files.pythonhosted.org/packages/01/8e/1e35281b8ab6d5d72ebe9911edcdffa3f36b04ed9d51dec6dd140396e220/scipy-1.17.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:02ae3b274fde71c5e92ac4d54bc06c42d80e399fec704383dcd99b301df37458", size = 35235890, upload-time = "2026-02-23T00:18:49.188Z" }, + { url = "https://files.pythonhosted.org/packages/c5/5c/9d7f4c88bea6e0d5a4f1bc0506a53a00e9fcb198de372bfe4d3652cef482/scipy-1.17.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8a604bae87c6195d8b1045eddece0514d041604b14f2727bbc2b3020172045eb", size = 35003557, upload-time = "2026-02-23T00:18:54.74Z" }, + { url = "https://files.pythonhosted.org/packages/65/94/7698add8f276dbab7a9de9fb6b0e02fc13ee61d51c7c3f85ac28b65e1239/scipy-1.17.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f590cd684941912d10becc07325a3eeb77886fe981415660d9265c4c418d0bea", size = 37625856, upload-time = "2026-02-23T00:19:00.307Z" }, + { url = "https://files.pythonhosted.org/packages/a2/84/dc08d77fbf3d87d3ee27f6a0c6dcce1de5829a64f2eae85a0ecc1f0daa73/scipy-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:41b71f4a3a4cab9d366cd9065b288efc4d4f3c0b37a91a8e0947fb5bd7f31d87", size = 36549682, upload-time = "2026-02-23T00:19:07.67Z" }, + { url = "https://files.pythonhosted.org/packages/bc/98/fe9ae9ffb3b54b62559f52dedaebe204b408db8109a8c66fdd04869e6424/scipy-1.17.1-cp312-cp312-win_arm64.whl", hash = "sha256:f4115102802df98b2b0db3cce5cb9b92572633a1197c77b7553e5203f284a5b3", size = 24547340, upload-time = "2026-02-23T00:19:12.024Z" }, + { url = "https://files.pythonhosted.org/packages/76/27/07ee1b57b65e92645f219b37148a7e7928b82e2b5dbeccecb4dff7c64f0b/scipy-1.17.1-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:5e3c5c011904115f88a39308379c17f91546f77c1667cea98739fe0fccea804c", size = 31590199, upload-time = "2026-02-23T00:19:17.192Z" }, + { url = "https://files.pythonhosted.org/packages/ec/ae/db19f8ab842e9b724bf5dbb7db29302a91f1e55bc4d04b1025d6d605a2c5/scipy-1.17.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:6fac755ca3d2c3edcb22f479fceaa241704111414831ddd3bc6056e18516892f", size = 28154001, upload-time = "2026-02-23T00:19:22.241Z" }, + { url = "https://files.pythonhosted.org/packages/5b/58/3ce96251560107b381cbd6e8413c483bbb1228a6b919fa8652b0d4090e7f/scipy-1.17.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:7ff200bf9d24f2e4d5dc6ee8c3ac64d739d3a89e2326ba68aaf6c4a2b838fd7d", size = 20325719, upload-time = "2026-02-23T00:19:26.329Z" }, + { url = "https://files.pythonhosted.org/packages/b2/83/15087d945e0e4d48ce2377498abf5ad171ae013232ae31d06f336e64c999/scipy-1.17.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:4b400bdc6f79fa02a4d86640310dde87a21fba0c979efff5248908c6f15fad1b", size = 22683595, upload-time = "2026-02-23T00:19:30.304Z" }, + { url = "https://files.pythonhosted.org/packages/b4/e0/e58fbde4a1a594c8be8114eb4aac1a55bcd6587047efc18a61eb1f5c0d30/scipy-1.17.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b64ca7d4aee0102a97f3ba22124052b4bd2152522355073580bf4845e2550b6", size = 32896429, upload-time = "2026-02-23T00:19:35.536Z" }, + { url = "https://files.pythonhosted.org/packages/f5/5f/f17563f28ff03c7b6799c50d01d5d856a1d55f2676f537ca8d28c7f627cd/scipy-1.17.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:581b2264fc0aa555f3f435a5944da7504ea3a065d7029ad60e7c3d1ae09c5464", size = 35203952, upload-time = "2026-02-23T00:19:42.259Z" }, + { url = "https://files.pythonhosted.org/packages/8d/a5/9afd17de24f657fdfe4df9a3f1ea049b39aef7c06000c13db1530d81ccca/scipy-1.17.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:beeda3d4ae615106d7094f7e7cef6218392e4465cc95d25f900bebabfded0950", size = 34979063, upload-time = "2026-02-23T00:19:47.547Z" }, + { url = "https://files.pythonhosted.org/packages/8b/13/88b1d2384b424bf7c924f2038c1c409f8d88bb2a8d49d097861dd64a57b2/scipy-1.17.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6609bc224e9568f65064cfa72edc0f24ee6655b47575954ec6339534b2798369", size = 37598449, upload-time = "2026-02-23T00:19:53.238Z" }, + { url = "https://files.pythonhosted.org/packages/35/e5/d6d0e51fc888f692a35134336866341c08655d92614f492c6860dc45bb2c/scipy-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:37425bc9175607b0268f493d79a292c39f9d001a357bebb6b88fdfaff13f6448", size = 36510943, upload-time = "2026-02-23T00:20:50.89Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fd/3be73c564e2a01e690e19cc618811540ba5354c67c8680dce3281123fb79/scipy-1.17.1-cp313-cp313-win_arm64.whl", hash = "sha256:5cf36e801231b6a2059bf354720274b7558746f3b1a4efb43fcf557ccd484a87", size = 24545621, upload-time = "2026-02-23T00:20:55.871Z" }, + { url = "https://files.pythonhosted.org/packages/6f/6b/17787db8b8114933a66f9dcc479a8272e4b4da75fe03b0c282f7b0ade8cd/scipy-1.17.1-cp313-cp313t-macosx_10_14_x86_64.whl", hash = "sha256:d59c30000a16d8edc7e64152e30220bfbd724c9bbb08368c054e24c651314f0a", size = 31936708, upload-time = "2026-02-23T00:19:58.694Z" }, + { url = "https://files.pythonhosted.org/packages/38/2e/524405c2b6392765ab1e2b722a41d5da33dc5c7b7278184a8ad29b6cb206/scipy-1.17.1-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:010f4333c96c9bb1a4516269e33cb5917b08ef2166d5556ca2fd9f082a9e6ea0", size = 28570135, upload-time = "2026-02-23T00:20:03.934Z" }, + { url = "https://files.pythonhosted.org/packages/fd/c3/5bd7199f4ea8556c0c8e39f04ccb014ac37d1468e6cfa6a95c6b3562b76e/scipy-1.17.1-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:2ceb2d3e01c5f1d83c4189737a42d9cb2fc38a6eeed225e7515eef71ad301dce", size = 20741977, upload-time = "2026-02-23T00:20:07.935Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b8/8ccd9b766ad14c78386599708eb745f6b44f08400a5fd0ade7cf89b6fc93/scipy-1.17.1-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:844e165636711ef41f80b4103ed234181646b98a53c8f05da12ca5ca289134f6", size = 23029601, upload-time = "2026-02-23T00:20:12.161Z" }, + { url = "https://files.pythonhosted.org/packages/6d/a0/3cb6f4d2fb3e17428ad2880333cac878909ad1a89f678527b5328b93c1d4/scipy-1.17.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:158dd96d2207e21c966063e1635b1063cd7787b627b6f07305315dd73d9c679e", size = 33019667, upload-time = "2026-02-23T00:20:17.208Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c3/2d834a5ac7bf3a0c806ad1508efc02dda3c8c61472a56132d7894c312dea/scipy-1.17.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74cbb80d93260fe2ffa334efa24cb8f2f0f622a9b9febf8b483c0b865bfb3475", size = 35264159, upload-time = "2026-02-23T00:20:23.087Z" }, + { url = "https://files.pythonhosted.org/packages/4d/77/d3ed4becfdbd217c52062fafe35a72388d1bd82c2d0ba5ca19d6fcc93e11/scipy-1.17.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:dbc12c9f3d185f5c737d801da555fb74b3dcfa1a50b66a1a93e09190f41fab50", size = 35102771, upload-time = "2026-02-23T00:20:28.636Z" }, + { url = "https://files.pythonhosted.org/packages/bd/12/d19da97efde68ca1ee5538bb261d5d2c062f0c055575128f11a2730e3ac1/scipy-1.17.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:94055a11dfebe37c656e70317e1996dc197e1a15bbcc351bcdd4610e128fe1ca", size = 37665910, upload-time = "2026-02-23T00:20:34.743Z" }, + { url = "https://files.pythonhosted.org/packages/06/1c/1172a88d507a4baaf72c5a09bb6c018fe2ae0ab622e5830b703a46cc9e44/scipy-1.17.1-cp313-cp313t-win_amd64.whl", hash = "sha256:e30bdeaa5deed6bc27b4cc490823cd0347d7dae09119b8803ae576ea0ce52e4c", size = 36562980, upload-time = "2026-02-23T00:20:40.575Z" }, + { url = "https://files.pythonhosted.org/packages/70/b0/eb757336e5a76dfa7911f63252e3b7d1de00935d7705cf772db5b45ec238/scipy-1.17.1-cp313-cp313t-win_arm64.whl", hash = "sha256:a720477885a9d2411f94a93d16f9d89bad0f28ca23c3f8daa521e2dcc3f44d49", size = 24856543, upload-time = "2026-02-23T00:20:45.313Z" }, + { url = "https://files.pythonhosted.org/packages/cf/83/333afb452af6f0fd70414dc04f898647ee1423979ce02efa75c3b0f2c28e/scipy-1.17.1-cp314-cp314-macosx_10_14_x86_64.whl", hash = "sha256:a48a72c77a310327f6a3a920092fa2b8fd03d7deaa60f093038f22d98e096717", size = 31584510, upload-time = "2026-02-23T00:21:01.015Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a6/d05a85fd51daeb2e4ea71d102f15b34fedca8e931af02594193ae4fd25f7/scipy-1.17.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:45abad819184f07240d8a696117a7aacd39787af9e0b719d00285549ed19a1e9", size = 28170131, upload-time = "2026-02-23T00:21:05.888Z" }, + { url = "https://files.pythonhosted.org/packages/db/7b/8624a203326675d7746a254083a187398090a179335b2e4a20e2ddc46e83/scipy-1.17.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:3fd1fcdab3ea951b610dc4cef356d416d5802991e7e32b5254828d342f7b7e0b", size = 20342032, upload-time = "2026-02-23T00:21:09.904Z" }, + { url = "https://files.pythonhosted.org/packages/c9/35/2c342897c00775d688d8ff3987aced3426858fd89d5a0e26e020b660b301/scipy-1.17.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:7bdf2da170b67fdf10bca777614b1c7d96ae3ca5794fd9587dce41eb2966e866", size = 22678766, upload-time = "2026-02-23T00:21:14.313Z" }, + { url = "https://files.pythonhosted.org/packages/ef/f2/7cdb8eb308a1a6ae1e19f945913c82c23c0c442a462a46480ce487fdc0ac/scipy-1.17.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:adb2642e060a6549c343603a3851ba76ef0b74cc8c079a9a58121c7ec9fe2350", size = 32957007, upload-time = "2026-02-23T00:21:19.663Z" }, + { url = "https://files.pythonhosted.org/packages/0b/2e/7eea398450457ecb54e18e9d10110993fa65561c4f3add5e8eccd2b9cd41/scipy-1.17.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eee2cfda04c00a857206a4330f0c5e3e56535494e30ca445eb19ec624ae75118", size = 35221333, upload-time = "2026-02-23T00:21:25.278Z" }, + { url = "https://files.pythonhosted.org/packages/d9/77/5b8509d03b77f093a0d52e606d3c4f79e8b06d1d38c441dacb1e26cacf46/scipy-1.17.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d2650c1fb97e184d12d8ba010493ee7b322864f7d3d00d3f9bb97d9c21de4068", size = 35042066, upload-time = "2026-02-23T00:21:31.358Z" }, + { url = "https://files.pythonhosted.org/packages/f9/df/18f80fb99df40b4070328d5ae5c596f2f00fffb50167e31439e932f29e7d/scipy-1.17.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:08b900519463543aa604a06bec02461558a6e1cef8fdbb8098f77a48a83c8118", size = 37612763, upload-time = "2026-02-23T00:21:37.247Z" }, + { url = "https://files.pythonhosted.org/packages/4b/39/f0e8ea762a764a9dc52aa7dabcfad51a354819de1f0d4652b6a1122424d6/scipy-1.17.1-cp314-cp314-win_amd64.whl", hash = "sha256:3877ac408e14da24a6196de0ddcace62092bfc12a83823e92e49e40747e52c19", size = 37290984, upload-time = "2026-02-23T00:22:35.023Z" }, + { url = "https://files.pythonhosted.org/packages/7c/56/fe201e3b0f93d1a8bcf75d3379affd228a63d7e2d80ab45467a74b494947/scipy-1.17.1-cp314-cp314-win_arm64.whl", hash = "sha256:f8885db0bc2bffa59d5c1b72fad7a6a92d3e80e7257f967dd81abb553a90d293", size = 25192877, upload-time = "2026-02-23T00:22:39.798Z" }, + { url = "https://files.pythonhosted.org/packages/96/ad/f8c414e121f82e02d76f310f16db9899c4fcde36710329502a6b2a3c0392/scipy-1.17.1-cp314-cp314t-macosx_10_14_x86_64.whl", hash = "sha256:1cc682cea2ae55524432f3cdff9e9a3be743d52a7443d0cba9017c23c87ae2f6", size = 31949750, upload-time = "2026-02-23T00:21:42.289Z" }, + { url = "https://files.pythonhosted.org/packages/7c/b0/c741e8865d61b67c81e255f4f0a832846c064e426636cd7de84e74d209be/scipy-1.17.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:2040ad4d1795a0ae89bfc7e8429677f365d45aa9fd5e4587cf1ea737f927b4a1", size = 28585858, upload-time = "2026-02-23T00:21:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/ed/1b/3985219c6177866628fa7c2595bfd23f193ceebbe472c98a08824b9466ff/scipy-1.17.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:131f5aaea57602008f9822e2115029b55d4b5f7c070287699fe45c661d051e39", size = 20757723, upload-time = "2026-02-23T00:21:52.039Z" }, + { url = "https://files.pythonhosted.org/packages/c0/19/2a04aa25050d656d6f7b9e7b685cc83d6957fb101665bfd9369ca6534563/scipy-1.17.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:9cdc1a2fcfd5c52cfb3045feb399f7b3ce822abdde3a193a6b9a60b3cb5854ca", size = 23043098, upload-time = "2026-02-23T00:21:56.185Z" }, + { url = "https://files.pythonhosted.org/packages/86/f1/3383beb9b5d0dbddd030335bf8a8b32d4317185efe495374f134d8be6cce/scipy-1.17.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e3dcd57ab780c741fde8dc68619de988b966db759a3c3152e8e9142c26295ad", size = 33030397, upload-time = "2026-02-23T00:22:01.404Z" }, + { url = "https://files.pythonhosted.org/packages/41/68/8f21e8a65a5a03f25a79165ec9d2b28c00e66dc80546cf5eb803aeeff35b/scipy-1.17.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a9956e4d4f4a301ebf6cde39850333a6b6110799d470dbbb1e25326ac447f52a", size = 35281163, upload-time = "2026-02-23T00:22:07.024Z" }, + { url = "https://files.pythonhosted.org/packages/84/8d/c8a5e19479554007a5632ed7529e665c315ae7492b4f946b0deb39870e39/scipy-1.17.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:a4328d245944d09fd639771de275701ccadf5f781ba0ff092ad141e017eccda4", size = 35116291, upload-time = "2026-02-23T00:22:12.585Z" }, + { url = "https://files.pythonhosted.org/packages/52/52/e57eceff0e342a1f50e274264ed47497b59e6a4e3118808ee58ddda7b74a/scipy-1.17.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a77cbd07b940d326d39a1d1b37817e2ee4d79cb30e7338f3d0cddffae70fcaa2", size = 37682317, upload-time = "2026-02-23T00:22:18.513Z" }, + { url = "https://files.pythonhosted.org/packages/11/2f/b29eafe4a3fbc3d6de9662b36e028d5f039e72d345e05c250e121a230dd4/scipy-1.17.1-cp314-cp314t-win_amd64.whl", hash = "sha256:eb092099205ef62cd1782b006658db09e2fed75bffcae7cc0d44052d8aa0f484", size = 37345327, upload-time = "2026-02-23T00:22:24.442Z" }, + { url = "https://files.pythonhosted.org/packages/07/39/338d9219c4e87f3e708f18857ecd24d22a0c3094752393319553096b98af/scipy-1.17.1-cp314-cp314t-win_arm64.whl", hash = "sha256:200e1050faffacc162be6a486a984a0497866ec54149a01270adc8a59b7c7d21", size = 25489165, upload-time = "2026-02-23T00:22:29.563Z" }, +] + +[[package]] +name = "scipy" +version = "1.18.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.15' and sys_platform == 'win32'", + "python_full_version >= '3.15' and sys_platform == 'emscripten'", + "python_full_version >= '3.15' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.14.*' and sys_platform == 'win32'", + "python_full_version == '3.14.*' and sys_platform == 'emscripten'", + "python_full_version == '3.14.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] +dependencies = [ + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" } }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a7/25/c2700dfaf6442b4effaa91af24ebce5dc9d31bb4a69706313aae70d72cd0/scipy-1.18.0.tar.gz", hash = "sha256:67b2ad2ad54c72ca6d04975a9b2df8c3638c34ddd5b28738e94fc2b57929d378", size = 30774447, upload-time = "2026-06-19T15:01:43.456Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/19/ca10ead60b0acc80b2b833c2c4a4f2ff753d0f58b811f70d911c7e94a25c/scipy-1.18.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:7bd21faaf5a1a3b2eff922d02db5f191b99a6518db9078a8fb23169f6d22259a", size = 31056519, upload-time = "2026-06-19T14:59:45.203Z" }, + { url = "https://files.pythonhosted.org/packages/96/72/1e6442a00cd2924d361aa1b642ab6373ec35c6fabf311a760be9f76e0f13/scipy-1.18.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:265915e79107de9f946b855e50d7470d5893ec3f54b342e1aa6201cbdcd8bb6b", size = 28681889, upload-time = "2026-06-19T14:59:48.103Z" }, + { url = "https://files.pythonhosted.org/packages/9b/2d/11dd93d21e147a73ba22bd75c0b9208d3a2e0ec76d53170ce7d9029b1015/scipy-1.18.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:9ab7b758be6940954a713ee466e2043e9f6e2ed965c1fce5c91039f4be3d90a9", size = 20423580, upload-time = "2026-06-19T14:59:50.665Z" }, + { url = "https://files.pythonhosted.org/packages/9c/01/93552f75e0d2a7dd115a45e59209c51e8d514daff02fc887d2623be06fe1/scipy-1.18.0-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:97b6cddaaee0a779ef6b5ca83c9604b27cc16b2b8fc22c142652df8793319fb8", size = 23054441, upload-time = "2026-06-19T14:59:53.564Z" }, + { url = "https://files.pythonhosted.org/packages/3c/23/21f5e703643d66f21faa6b4c73195bfcad70c55efcb4f1ab327cd7c4101a/scipy-1.18.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:52a96e21517c7292375c0e27dd796a811f03fcea5fd4d108fdfea8145dcf17ab", size = 33968720, upload-time = "2026-06-19T14:59:56.415Z" }, + { url = "https://files.pythonhosted.org/packages/dd/aa/1b939f6c67ed68635bb538e6752d3dacc02f66535182e939a89581a44e9c/scipy-1.18.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1f55797419e16e7f30cf88ffb3113ce0467f00cfe3f70d5c281730b21769bfc2", size = 35287115, upload-time = "2026-06-19T14:59:59.411Z" }, + { url = "https://files.pythonhosted.org/packages/b6/ff/eec46be7e9234208f801062b53e1983085eddebd693f6c9bfb03b459830d/scipy-1.18.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ad033410e2e0672ffdc1042110cef20e1c46f8fd0616cee1d44d8d58fad8fc11", size = 35577989, upload-time = "2026-06-19T15:00:02.235Z" }, + { url = "https://files.pythonhosted.org/packages/84/ca/210d4759c7210bb7d269437421959b39a33434e2776b60c5cb8a763bb30a/scipy-1.18.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4a55985d54c769c872e64b7f4c8a81cc30ef700cc04296abbbf3705439c126de", size = 37421717, upload-time = "2026-06-19T15:00:05.102Z" }, + { url = "https://files.pythonhosted.org/packages/2b/54/9a9edb45345bd6744da5ddfb6628e5d5185920494c6a67ec45b6381004cb/scipy-1.18.0-cp312-cp312-win_amd64.whl", hash = "sha256:71ccc8faa2dd16ac310233203474a8b5cb67f10dedd54a3116d34943f4b19132", size = 36597428, upload-time = "2026-06-19T15:00:08.112Z" }, + { url = "https://files.pythonhosted.org/packages/99/0e/33f32a2a58987e26aec0f7df252cbbad1e90ae77bdbc76f40dd4ed0cf0ea/scipy-1.18.0-cp312-cp312-win_arm64.whl", hash = "sha256:d88363fd9d8fbd3511bd273f1a49efb2a540773ddf92a91d57498ce7dd7f3e76", size = 24351481, upload-time = "2026-06-19T15:00:11.103Z" }, + { url = "https://files.pythonhosted.org/packages/05/52/9c0136c2de7ae0779b7b366447766cec6d9f0702c56bb8ffeb04c8fd3af4/scipy-1.18.0-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:09143f676d157d9f546d663504ef9c1becb819824f1afc018814176411942446", size = 31036107, upload-time = "2026-06-19T15:00:14.03Z" }, + { url = "https://files.pythonhosted.org/packages/02/73/0291a64843270f4efb86cdcf2ee0f2048631b65ec6b405398b2b4dbf11bf/scipy-1.18.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:5efe260f69417b97ddae455bfb5a95e8359f7f66ad7fa9522a60feb66f169520", size = 28663303, upload-time = "2026-06-19T15:00:16.819Z" }, + { url = "https://files.pythonhosted.org/packages/d3/0f/10ffa0b697a572f4e0d48b92a88895d366422f019f723e7e14a84c050dac/scipy-1.18.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:68363b7eaacd8b5dd426df56d782cc156468ac79a127a1b87ca597d6e2e82197", size = 20404960, upload-time = "2026-06-19T15:00:19.635Z" }, + { url = "https://files.pythonhosted.org/packages/7e/d2/e896cea21ba8edd6c81d4c55b1ffcc717e79698dcbebf9641b4cfb4c6622/scipy-1.18.0-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:c5557d8be5da8e41353fcd4d21491fdbab83b062fc579e94dc09a7c8ab4f669b", size = 23034074, upload-time = "2026-06-19T15:00:22.107Z" }, + { url = "https://files.pythonhosted.org/packages/ea/b2/e83ea34279a52c03374477c74006256ec78df65fc877baa4617d6de1d202/scipy-1.18.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0d13bca67c096d89fb95ced0d8921807300fce0275643aef9533cc63a0773468", size = 33942038, upload-time = "2026-06-19T15:00:24.964Z" }, + { url = "https://files.pythonhosted.org/packages/f6/af/e8fe5fb136f51e2b01678b92cb4106d10d8cd68ec147ead2e7cb0ac75398/scipy-1.18.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a46f9273dbd0eb1cefba61c9b8648b4dfe3cbc14a080176f9a73e44b8336dc7f", size = 35266390, upload-time = "2026-06-19T15:00:28.059Z" }, + { url = "https://files.pythonhosted.org/packages/3a/49/2c5cbb907b56695fc67517811d1db234dfd83381a84814ec220aded2794d/scipy-1.18.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5aba46108853ddfc77906b6557aac839d2b52e900c1d72a1180adaaab58d265f", size = 35551324, upload-time = "2026-06-19T15:00:31.014Z" }, + { url = "https://files.pythonhosted.org/packages/bb/73/eda39f7a2d306ff0ffc574afd13c0bbb6d10a603d9a413998ee269487a80/scipy-1.18.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b6f758e35f12757b5d95c00bc6de2438e229c2664b7a92e96f205959d9f2dfa4", size = 37404785, upload-time = "2026-06-19T15:00:34.072Z" }, + { url = "https://files.pythonhosted.org/packages/b7/d2/ae881ee28d014f38e0ccbfd974a06a919ba9af34f1f74bf42b5301891d63/scipy-1.18.0-cp313-cp313-win_amd64.whl", hash = "sha256:1afac4a847207c7ff8efd321734a50b06d0280b3b2a2c0fc2f413101747ad7c7", size = 36554943, upload-time = "2026-06-19T15:00:36.903Z" }, + { url = "https://files.pythonhosted.org/packages/70/3a/21154e2d54eb3639c6bf4dbae2e531c68356bfe95990daa30df33b30d556/scipy-1.18.0-cp313-cp313-win_arm64.whl", hash = "sha256:c5dbddf60e58c2312316d097271a8e73d40eaf2eabfa4d95ed7d3695bbf2ce7b", size = 24350911, upload-time = "2026-06-19T15:00:40.062Z" }, + { url = "https://files.pythonhosted.org/packages/78/b5/915a19b3de2f7430062b509653563db1633ddbb6f021b06731521115d4e2/scipy-1.18.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:4c256ee70c0d1a8a2ace807e199ccd4e3f57037433842abb3fb36bc17eaa9578", size = 31036253, upload-time = "2026-06-19T15:00:43.216Z" }, + { url = "https://files.pythonhosted.org/packages/d7/88/b72def7262e150d16be13fca37a96481138d624e700340bc3362a7588929/scipy-1.18.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:2ef3abc54a4ffc53765374b0d5728532dfdd2585ed23f6b11c206a1f0b1b9af8", size = 28673758, upload-time = "2026-06-19T15:00:46.663Z" }, + { url = "https://files.pythonhosted.org/packages/91/02/2e636a61a525632c373cf6a9c24442a3ffb79e364d38e98b32042964ac32/scipy-1.18.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:f2a6af57bd9e4a75d70e4117e78a1bbee84f79ae3fbb6d0111005d6ebcc4cb8d", size = 20415514, upload-time = "2026-06-19T15:00:49.399Z" }, + { url = "https://files.pythonhosted.org/packages/c9/b6/2135974442f6aba159d9d39d774a1c8cb19947016725d69fecc685df45bf/scipy-1.18.0-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:3f1ac564d3bf6c03d861d2cd87a1bea0da2887136f7fb1bf519c05a8971452d6", size = 23034398, upload-time = "2026-06-19T15:00:51.941Z" }, + { url = "https://files.pythonhosted.org/packages/f6/e6/ba89ec5abf6ee9257c0d1ec985573f3ae32742c24bc03e016388a40b1b15/scipy-1.18.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40395a5fcd1abee49a5c7aaa98c29db393eedc835138560a588c47ec16156690", size = 33998032, upload-time = "2026-06-19T15:00:54.838Z" }, + { url = "https://files.pythonhosted.org/packages/7f/c4/bc41eb19b0fd0db868f4132920879019318d80cc522ad8f2bca4611af808/scipy-1.18.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ca01e8ae69f1b18e9a58d91afead31be3cef0dd905a10249dac559ee15460a0", size = 35283333, upload-time = "2026-06-19T15:00:58.152Z" }, + { url = "https://files.pythonhosted.org/packages/53/a4/cbdeef6eb3830a8462a9d4ada814de5fc984345cc9ecf17cbec51a036f1e/scipy-1.18.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7a7f3b01647384dbc3a711e8c6778e0aabbe93959249fef5c7393396bcac0867", size = 35610216, upload-time = "2026-06-19T15:01:01.155Z" }, + { url = "https://files.pythonhosted.org/packages/80/4d/b2b82502b65f661d1b789c1665dcdf315d5f12194e06fc0b37946294ebae/scipy-1.18.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6aa94e78ec192a30063a5e72e561c28af769dc311190b24fe91774eff1969709", size = 37418960, upload-time = "2026-06-19T15:01:04.155Z" }, + { url = "https://files.pythonhosted.org/packages/93/3e/902d836831474b0ab5a37d16404f7bc5fafd9efba632890e271ba952635f/scipy-1.18.0-cp314-cp314-win_amd64.whl", hash = "sha256:2d8bbdc6c817f5b4006a54d799d4f5bab6f910193cbb9a1ff310833d4d270f61", size = 37288845, upload-time = "2026-06-19T15:01:07.822Z" }, + { url = "https://files.pythonhosted.org/packages/b6/43/8d73b337a3bdb14daa0314f0434210747c02d79d729ce1777574a817dcf6/scipy-1.18.0-cp314-cp314-win_arm64.whl", hash = "sha256:18e9575f1569b2c54174e6159d32942e03731177f63dce7975f0a0c88d102f5b", size = 24988971, upload-time = "2026-06-19T15:01:11.076Z" }, + { url = "https://files.pythonhosted.org/packages/b4/b4/f11918b0508a2787031a0499a03fbe3546f3bb5ca05d01038c45b278c09a/scipy-1.18.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f351e0dd702687d12a402b867a1b4146a256923e1c38317cbc472f6372b94707", size = 31399325, upload-time = "2026-06-19T15:01:13.723Z" }, + { url = "https://files.pythonhosted.org/packages/7b/d1/1f287b57c0ff0ee5185dff3946d92c8017d39b0e431f0ae79a3ff1859512/scipy-1.18.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:7c7a51b33ce387193c97f228320cf8e87361daa1bba750638677729598b3e677", size = 29092110, upload-time = "2026-06-19T15:01:16.908Z" }, + { url = "https://files.pythonhosted.org/packages/ff/1a/7b74eb6c392fdcb27d414c0e7558a6d0231eb3b6d73571f479bb81ea8794/scipy-1.18.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:84031d7b052a54fae2f8632e0ec802073d385476eb9a63079bce6e23ef9283d4", size = 20833811, upload-time = "2026-06-19T15:01:20.488Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ad/f3941716320a7b9cb4d68734a903b45fe16eff5fb7da7e16f2e619304979/scipy-1.18.0-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:56abf29a7c067dde59be8b9a22d606a4ea1b2f2a4b756d9d903c62818f5dacce", size = 23396644, upload-time = "2026-06-19T15:01:23.364Z" }, + { url = "https://files.pythonhosted.org/packages/22/22/1446b62ffe07f9719b7d9b1b6a4e05a772833ae8f441fe4c22c34c9b250f/scipy-1.18.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ad44305cfa24b1ba5803cbbebf033590ccbac1aa5d612d727b785325ab408b0", size = 34079318, upload-time = "2026-06-19T15:01:26.002Z" }, + { url = "https://files.pythonhosted.org/packages/56/3b/b87da667098bb470fa30c7011b0ba351ee976dd395c78798c66e941665a3/scipy-1.18.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:945c1761b93f38d7f99ae81ae80c63e621471608c7eeead563f6df025585cd58", size = 35324320, upload-time = "2026-06-19T15:01:28.881Z" }, + { url = "https://files.pythonhosted.org/packages/f8/a1/c7932f91909759b0267f75fdea34e91309f96b895757534b76a90b6b4344/scipy-1.18.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1a4441f15d620578772a49e5ab48c0ee1f7a0220e387110283062729136b2553", size = 35699541, upload-time = "2026-06-19T15:01:31.968Z" }, + { url = "https://files.pythonhosted.org/packages/f7/86/5185061a1fcc41d18c5dc2463969b3a3964b31d9ac67b2fb05d4c7ff7670/scipy-1.18.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9aac6192fac56bf2ca534389d24623f07b39ff83317d58287285e7fbd622ff76", size = 37472480, upload-time = "2026-06-19T15:01:35.136Z" }, + { url = "https://files.pythonhosted.org/packages/31/8e/f04c68e39919a010d34f2ee1367fd705b0a25a02f609d755f0bfbc0a15fc/scipy-1.18.0-cp314-cp314t-win_amd64.whl", hash = "sha256:e40baea28ae7f5475c779741e2d90b1247c78531207b49c7030e698ff81cee3f", size = 37365390, upload-time = "2026-06-19T15:01:38.091Z" }, + { url = "https://files.pythonhosted.org/packages/d5/19/969dc072906c84dd0a3b05dcf57ea750936087d7873549e408b35cfc3f97/scipy-1.18.0-cp314-cp314t-win_arm64.whl", hash = "sha256:368e0a705903c466aa5f08eefb39e6b1b6b2d659e7352a31fd9e2438365be0f8", size = 25279661, upload-time = "2026-06-19T15:01:40.817Z" }, +] + +[[package]] +name = "send2trash" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c5/f0/184b4b5f8d00f2a92cf96eec8967a3d550b52cf94362dad1100df9e48d57/send2trash-2.1.0.tar.gz", hash = "sha256:1c72b39f09457db3c05ce1d19158c2cbef4c32b8bedd02c155e49282b7ea7459", size = 17255, upload-time = "2026-01-14T06:27:36.056Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1c/78/504fdd027da3b84ff1aecd9f6957e65f35134534ccc6da8628eb71e76d3f/send2trash-2.1.0-py3-none-any.whl", hash = "sha256:0da2f112e6d6bb22de6aa6daa7e144831a4febf2a87261451c4ad849fe9a873c", size = 17610, upload-time = "2026-01-14T06:27:35.218Z" }, +] + +[[package]] +name = "setuptools" +version = "83.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/34/26/f5d29e25ffdb535afef2d35cdb55b325298f96debd670da4c325e08d70f4/setuptools-83.0.0.tar.gz", hash = "sha256:025bccbbf0fa05b6192bc64ae1e7b16e001fd6d6d4d5de03c97b1c1ade523bef", size = 1154254, upload-time = "2026-07-04T15:31:22.699Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/40/e1e72872c6354b306daef1703549e8e83b4d43cfea356311bf722a043752/setuptools-83.0.0-py3-none-any.whl", hash = "sha256:29b23c360f22f414dc7336bb39178cc7bcbf6021ed2733cde173f09dba19abb3", size = 1008090, upload-time = "2026-07-04T15:31:20.885Z" }, +] + +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "smart-open" +version = "8.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/53/9c513747547fd595d5c143259129ea8b9c3ea2f6b7bb9dcea2b1966ded3c/smart_open-8.0.1.tar.gz", hash = "sha256:18b1c4496003c6902be17c15f032b5c319f307c89c6ae9e6b028b508bed8b2cf", size = 61882, upload-time = "2026-07-15T13:56:10.492Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/96/325b8c507ccecc50421fecc0345a502ee6e4a44785af3c4e6ecbadad624a/smart_open-8.0.1-py3-none-any.whl", hash = "sha256:3e97f90e92a952cb57863dfe132082c400a52eeeb27c067692fb51dbcc5b0089", size = 73504, upload-time = "2026-07-15T13:56:09.033Z" }, +] + +[[package]] +name = "soupsieve" +version = "2.9.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d9/38/e12680bbe6b4f8f3d17adcaf38d26850aa756c85cf4a80e79fc12a018fe8/soupsieve-2.9.1.tar.gz", hash = "sha256:c33e6605bbc71dd628b00c632d58ae607c22bade247e52553928f83bbb75b4ba", size = 122261, upload-time = "2026-07-21T16:57:17.452Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/2c/437fe806897c2d6cfdc3ee43a18da8bf8e568530a4ae9bac781541ca9896/soupsieve-2.9.1-py3-none-any.whl", hash = "sha256:4f4477399246b7a0c720a88ca2454b11cd6bb9ae4c9d170140786e916776c14c", size = 37404, upload-time = "2026-07-21T16:57:16.421Z" }, +] + +[[package]] +name = "spacy" +version = "3.8.13" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "catalogue" }, + { name = "confection" }, + { name = "cymem" }, + { name = "jinja2" }, + { name = "murmurhash" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "packaging" }, + { name = "preshed" }, + { name = "pydantic" }, + { name = "requests" }, + { name = "setuptools" }, + { name = "spacy-legacy" }, + { name = "spacy-loggers" }, + { name = "srsly" }, + { name = "thinc" }, + { name = "tqdm" }, + { name = "typer" }, + { name = "wasabi" }, + { name = "weasel" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/63/d7/1924f32272f50d2f13275f16d6f869fcec47b2b676b64f91fa206bd30ef4/spacy-3.8.13.tar.gz", hash = "sha256:eb7c03c2bb16593c34d4c91974118f0931c6e6969dbfe895b1a026c6714176cf", size = 1328016, upload-time = "2026-03-23T17:44:32.042Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1c/9d/98690ead5f03a1ad6eedbe87316aac6dbd5677d8703c1972c57dd268c582/spacy-3.8.13-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:cb065e9fb78e60d205bc130cb6400a727de70bed2e0de6fdd7c9f4e662653710", size = 6625685, upload-time = "2026-03-23T17:41:50.106Z" }, + { url = "https://files.pythonhosted.org/packages/c4/16/eea0765ffee29fbe6a31bfd5ab746abdc4fcd71414c04110ea80acb733f0/spacy-3.8.13-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:7e6e842df203cc0115063143f94395220acf5fdf2de194c3e376fde11592f867", size = 6449911, upload-time = "2026-03-23T17:41:52.648Z" }, + { url = "https://files.pythonhosted.org/packages/8a/13/2320f8443145b9ec0b089dd14cdc7e74eded52b91c1be7a2a66604075c88/spacy-3.8.13-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:80cdf686e58e6ad1c450f978a63e2d805b1316d74b10152761de183cc609c13f", size = 30761599, upload-time = "2026-03-23T17:41:55.992Z" }, + { url = "https://files.pythonhosted.org/packages/9e/50/a775d0afaa65a27da04784d08a436537517652b982059ff4921672a1da67/spacy-3.8.13-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bdbc6f8ceda37dfb1faeb57685ab665b43db008c520ec3d7aa9b4f21538b9f9f", size = 30999822, upload-time = "2026-03-23T17:42:00.649Z" }, + { url = "https://files.pythonhosted.org/packages/f4/b5/95304bf02a4037e1e3a958f144d9f9ec3d411c063b454a07df06e84bf918/spacy-3.8.13-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:815ad97986b494f3e6957017d26e83a6f0d9e8c5b6656981d3cb7678b700d194", size = 31039431, upload-time = "2026-03-23T17:42:05.57Z" }, + { url = "https://files.pythonhosted.org/packages/9e/2c/36e9c43fac2126700462e49e2e75fb740583de47962c8959cf6c5d24d400/spacy-3.8.13-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:17b63375efc76c44d26ced29a14a9cf0ee0db31aca088d6d8f699bb70b5feb7a", size = 31882760, upload-time = "2026-03-23T17:42:10.397Z" }, + { url = "https://files.pythonhosted.org/packages/40/11/9ef0cebe578f8120d20d06dd598e537177bb847e731e90e51fea2f143f54/spacy-3.8.13-cp310-cp310-win_amd64.whl", hash = "sha256:f6f222f5a35f40ec9af2a6013bbc3b27d31b9d5e3833f7353ad38e48aebd06f6", size = 15360023, upload-time = "2026-03-23T17:42:14.591Z" }, + { url = "https://files.pythonhosted.org/packages/4c/a8/21f5b9c3998c7a9cdd28715248e489ebd9300b1d349d5220166b951c3df4/spacy-3.8.13-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3fb3ec2a78a72bb87ca1cc22ad9fea2d37a3d88ec68ee6b4a02c967f35c8b0cb", size = 6617511, upload-time = "2026-03-23T17:42:17.689Z" }, + { url = "https://files.pythonhosted.org/packages/0c/24/0da6c637b3d25a37db605f836e53226cae65f035e865d2909ddf66b89185/spacy-3.8.13-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1c7d719ff171aad00a9aac31e7c7f6e1871be08b0be50c362885c632826d41ce", size = 6441498, upload-time = "2026-03-23T17:42:20.516Z" }, + { url = "https://files.pythonhosted.org/packages/17/64/1eeb854dc194dde91582eedf523d38c8caa209fce45c79b175e3e7a00b9f/spacy-3.8.13-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:95c2bf7d0afbf699df89cca5431527dff4950a823fd093680618057aa2affff8", size = 32050555, upload-time = "2026-03-23T17:42:25.13Z" }, + { url = "https://files.pythonhosted.org/packages/05/5b/7de5aae98f0a4547b9e44fd41e3f5820c199d67c78148782a08df83d19a8/spacy-3.8.13-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b118c9ca4250cba8a39c7fc73641820086d476dafb54b5d1f306a3f60098d3cd", size = 32296475, upload-time = "2026-03-23T17:42:30.151Z" }, + { url = "https://files.pythonhosted.org/packages/96/33/624d0025097b6b26a2c6d1b3dd7e24dec5da495e8c2a0f379bcc3ec26d15/spacy-3.8.13-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:53a6ff574a4505339375e31ba3280ff7235e82e120377614210750fa66cb63df", size = 32288426, upload-time = "2026-03-23T17:42:35.418Z" }, + { url = "https://files.pythonhosted.org/packages/c2/be/93eec7f8266a6d96fb5229d4588d64bb6dfa037604b5121a703cad35debb/spacy-3.8.13-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:cf07faad25a301efbd90da1343c1f62e35185d064c5e087bd0d11a5a6b3358fe", size = 33113494, upload-time = "2026-03-23T17:42:41.053Z" }, + { url = "https://files.pythonhosted.org/packages/26/3d/0bd7d780642635e662b32fcd530ed5bed1844dbf977bc5eed43efefa034c/spacy-3.8.13-cp311-cp311-win_amd64.whl", hash = "sha256:740cafd3cb2331c48057f6689ed6be5652d35db92aa2980f32f3d78a9aba6300", size = 15359637, upload-time = "2026-03-23T17:42:45.168Z" }, + { url = "https://files.pythonhosted.org/packages/be/78/11896d5b5987cd37ae498ef1ce795e0dd551f984fffa9cf6a7887655617e/spacy-3.8.13-cp311-cp311-win_arm64.whl", hash = "sha256:189bdd05cfd66fd3d1f79449d38ba4e2d0270743f762db1304617ccad9dd321c", size = 14717010, upload-time = "2026-03-23T17:42:48.915Z" }, + { url = "https://files.pythonhosted.org/packages/d8/e1/e8f6dc7fc00582b8dcbf40fac5bce6c0ff366cf6db212decdacfaf13e584/spacy-3.8.13-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:dd8ab0deefe9d2c7dccce1be1e65660a32cfc3cc114d0aa222ff52ae11be58ba", size = 6218311, upload-time = "2026-03-23T17:42:51.938Z" }, + { url = "https://files.pythonhosted.org/packages/31/fe/517de9e25a6ec469738c9e886c15cd96649b338c6ae475b9b3e4c4f5e03d/spacy-3.8.13-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:eab059bc668336d0034f3f69aed6b661b626c174e97cc1b52296d0d923c24405", size = 6033843, upload-time = "2026-03-23T17:42:54.133Z" }, + { url = "https://files.pythonhosted.org/packages/2c/a8/9a33c69f772f5c32a57c9ef7e692293b558e7bad202264d5586ab087fd29/spacy-3.8.13-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:96a8e141fd7ff749fa6693c9c72187f802d334bcadf48b6b9d6ca9bff315ca73", size = 32725183, upload-time = "2026-03-23T17:42:58.847Z" }, + { url = "https://files.pythonhosted.org/packages/ba/cf/dcc0ea61270cb23a46d3efc437fe760e7a9c3cabcfaa770591329d1430f2/spacy-3.8.13-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:32c16f5be8c8006197554da3647d83311fb89bb31bb417d38f0d1da585877b0d", size = 33205816, upload-time = "2026-03-23T17:43:03.717Z" }, + { url = "https://files.pythonhosted.org/packages/90/6b/ce94ba2a166add3376aa2c976cb1a34d6387e9dc61103cfbe9192675d0b6/spacy-3.8.13-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7402154eae9d2c34d097f3f7f2bf3ed4ce6ef9d44e3035af15ccc7f357d122cb", size = 32090348, upload-time = "2026-03-23T17:43:08.809Z" }, + { url = "https://files.pythonhosted.org/packages/4d/20/df5076a162bda36b04cdfa9cd544ac2be4b47aed957b3922e4c8f75dc25a/spacy-3.8.13-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:879f467578569db73b5811aa0f0b9a3b3fb81a125c2f7a433dd5626bcaca53f6", size = 32991911, upload-time = "2026-03-23T17:43:14.543Z" }, + { url = "https://files.pythonhosted.org/packages/41/17/316dfdf5f8d1dd33a205e4f5e7190b67db73802180f7d08c8b306eb44375/spacy-3.8.13-cp312-cp312-win_amd64.whl", hash = "sha256:a0c849b5c9cee5a930a5e8a63d0b07e095d4e3d371af6a70c496efa1d0851257", size = 14226861, upload-time = "2026-03-23T17:43:18.122Z" }, + { url = "https://files.pythonhosted.org/packages/be/a8/a794d5bbf7bc2df6770df2b14cd6811420d0e10fe81830920bf0e891c19a/spacy-3.8.13-cp312-cp312-win_arm64.whl", hash = "sha256:5d7f660f64c7d09778600b27b881a367075fe792cb5cc6932737aeda71a9aa09", size = 13628855, upload-time = "2026-03-23T17:43:22.162Z" }, + { url = "https://files.pythonhosted.org/packages/a7/2d/dc15440fa58c12bab25cbd368bbf3b754eb11d1d3cc37f3aee47caa63695/spacy-3.8.13-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6db8903cf3fd8b40db9dab669c1e823a8884100f223dc8ec63c3744ff505d5fd", size = 6202080, upload-time = "2026-03-23T17:43:25.044Z" }, + { url = "https://files.pythonhosted.org/packages/1b/27/6d723fa0c3c9872d7b8c3fb1c76edef65c1db2de441ee87d119128cb82f7/spacy-3.8.13-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c26258bfed2f3bf5563c5994ee752ea8662bbac70924d28fbea4d1c10df48fd0", size = 6015435, upload-time = "2026-03-23T17:43:28.281Z" }, + { url = "https://files.pythonhosted.org/packages/c0/36/d13f36204290e7753ce849106fb732a1a1ff6c1af0a200f2c2f493a56e60/spacy-3.8.13-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a5f02eab8e6e8e9e790b1e8da05b70b3b1d4b1fb10dc4f44203da96939fdb977", size = 32510675, upload-time = "2026-03-23T17:43:32.657Z" }, + { url = "https://files.pythonhosted.org/packages/19/37/f2a0c986bc2d59b18547d5ffaabf57fd9d88e129ad7df5ebc9ccdc91e643/spacy-3.8.13-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:17b14f28d83fe85390f420f89760ae36d515f3e0a236f8266c46335549806e3a", size = 32841103, upload-time = "2026-03-23T17:43:38.139Z" }, + { url = "https://files.pythonhosted.org/packages/37/31/b4a86cc013e4ac3c3c290ecade748c4623709a78db8a368ee0e152931c0b/spacy-3.8.13-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:10fffb387e6afeba582e01efd9b5e0c5431ac323af134f4a71a749255182f4c8", size = 31763223, upload-time = "2026-03-23T17:43:42.823Z" }, + { url = "https://files.pythonhosted.org/packages/4f/fa/b3a406bdc2636aaa315b8539b88f262e78ca391afc4083e3e9806953b24a/spacy-3.8.13-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:500f6eb9e4711e75fc07d517962dbfb553ba144ccf1b1e8ffc71b014c9ad91b2", size = 32717863, upload-time = "2026-03-23T17:43:47.939Z" }, + { url = "https://files.pythonhosted.org/packages/d9/97/11ed2a980e7225b0a1a4041cb1cba44f40bd83682a08e450dd6171f054e4/spacy-3.8.13-cp313-cp313-win_amd64.whl", hash = "sha256:1e679a8c5dd86c564a1185d894b1b8e50b52fa81bee518120fc2f86349d1879c", size = 14220413, upload-time = "2026-03-23T17:43:51.922Z" }, + { url = "https://files.pythonhosted.org/packages/ca/e2/06633e779486f62b3ec7ddeeb4accc10fb9a356b59f5bed3771dfb89931b/spacy-3.8.13-cp313-cp313-win_arm64.whl", hash = "sha256:c086bff909d73be892b4eb6883070dd3e328592b8933f0059a6569c8c7904928", size = 13619060, upload-time = "2026-03-23T17:43:55.449Z" }, + { url = "https://files.pythonhosted.org/packages/f2/02/bf2943f61a8bd21cca90e4fe19c4da8752c386f02a76e326b33199e09953/spacy-3.8.13-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:edcdd4f99e2711fc90c6ba3e8e07f7dc9753d111377ba7b7b5eb0d7259b0d211", size = 6209920, upload-time = "2026-03-23T17:43:58.441Z" }, + { url = "https://files.pythonhosted.org/packages/e7/87/fa5e4eb2ccb660d5042eb9144134dc6238c132ec812131bb0aca296bcdd9/spacy-3.8.13-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b8fd0188f78e032ac58f70a00748d591e244f3d4718e0ead2d9418b4148c744d", size = 6040696, upload-time = "2026-03-23T17:44:00.704Z" }, + { url = "https://files.pythonhosted.org/packages/ed/f3/9132878e0c18d627adffc1d23129a3706385d4f0fbe2ea5839523519452c/spacy-3.8.13-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0dc2d058cb919102bf0634b1b41aff64b867d9bab36bd35979ab658b5b2e4c27", size = 32431416, upload-time = "2026-03-23T17:44:05.435Z" }, + { url = "https://files.pythonhosted.org/packages/80/73/396c5c3aaef5004d04abbda4ec736ce4d33944aff6722e974cebdd4023fd/spacy-3.8.13-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ef884df658d3b60d91cbc1e9b62a6d932589d2fa3f322c3f739b6ffdf3303e0c", size = 32483676, upload-time = "2026-03-23T17:44:10.913Z" }, + { url = "https://files.pythonhosted.org/packages/71/01/15ceb2097a817c2912297eac29f801bd71d895e93d7557110937c88f7c77/spacy-3.8.13-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:86f58d686d1ba6f0dc818005ee6ad87ade8dde7224012f1870a0d31abccd349e", size = 31732468, upload-time = "2026-03-23T17:44:15.967Z" }, + { url = "https://files.pythonhosted.org/packages/03/c4/b4c39083df6209414faec7d7471994316dc6fca68c4d2f1f8f099f25c2e3/spacy-3.8.13-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:dbcb411b9749a1cd5e4325953212b3984d8c0f60b4976943e77fb0c7d5027383", size = 32473870, upload-time = "2026-03-23T17:44:21.36Z" }, + { url = "https://files.pythonhosted.org/packages/f2/5c/9b0fa420b01809c0170b3cf0bf509ec06b4da96214995360815615b8e8fa/spacy-3.8.13-cp314-cp314-win_amd64.whl", hash = "sha256:bf9b6879d70201acb73fc5f07d550ff488d3b5f15a959e9896717b2635c8e137", size = 14405303, upload-time = "2026-03-23T17:44:25.83Z" }, + { url = "https://files.pythonhosted.org/packages/fa/58/0001fd8124b62a2a9984278d793e3abfa1b70e969fc26a8668755178db84/spacy-3.8.13-cp314-cp314-win_arm64.whl", hash = "sha256:b2a402f229fcb5dba5454c346468757bd3a5215809e784b85d739ca84916f05b", size = 13834663, upload-time = "2026-03-23T17:44:29.43Z" }, +] + +[[package]] +name = "spacy-legacy" +version = "3.0.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d9/79/91f9d7cc8db5642acad830dcc4b49ba65a7790152832c4eceb305e46d681/spacy-legacy-3.0.12.tar.gz", hash = "sha256:b37d6e0c9b6e1d7ca1cf5bc7152ab64a4c4671f59c85adaf7a3fcb870357a774", size = 23806, upload-time = "2023-01-23T09:04:15.104Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/55/12e842c70ff8828e34e543a2c7176dac4da006ca6901c9e8b43efab8bc6b/spacy_legacy-3.0.12-py2.py3-none-any.whl", hash = "sha256:476e3bd0d05f8c339ed60f40986c07387c0a71479245d6d0f4298dbd52cda55f", size = 29971, upload-time = "2023-01-23T09:04:13.45Z" }, +] + +[[package]] +name = "spacy-loggers" +version = "1.0.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/67/3d/926db774c9c98acf66cb4ed7faf6c377746f3e00b84b700d0868b95d0712/spacy-loggers-1.0.5.tar.gz", hash = "sha256:d60b0bdbf915a60e516cc2e653baeff946f0cfc461b452d11a4d5458c6fe5f24", size = 20811, upload-time = "2023-09-11T12:26:52.323Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/78/d1a1a026ef3af911159398c939b1509d5c36fe524c7b644f34a5146c4e16/spacy_loggers-1.0.5-py3-none-any.whl", hash = "sha256:196284c9c446cc0cdb944005384270d775fdeaf4f494d8e269466cfa497ef645", size = 22343, upload-time = "2023-09-11T12:26:50.586Z" }, +] + +[[package]] +name = "srsly" +version = "2.5.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "catalogue" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2b/db/f794f219a6c788b881252d2536a8c4a97d2bdaadc690391e1cb53d123d71/srsly-2.5.3.tar.gz", hash = "sha256:08f98dbecbff3a31466c4ae7c833131f59d3655a0ad8ac749e6e2c149e2b0680", size = 490881, upload-time = "2026-03-23T11:56:59.865Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/67/e6d4decfb0cdc95b54c60854a1a6d1702983c39206c2b9f70f4ab18b17c8/srsly-2.5.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c812302a9acfe171e82f680b7ad642014cd017380b2c678441b3da4fb513c498", size = 657202, upload-time = "2026-03-23T11:55:34.938Z" }, + { url = "https://files.pythonhosted.org/packages/cc/5d/cb8b093d0836e59c152de6dfdb5db80c6408b00def0123f26d24bffde480/srsly-2.5.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:91688edb1f49110870d2c215db2cf445f1763c14173698ead0818908c51fb2a1", size = 657951, upload-time = "2026-03-23T11:55:36.571Z" }, + { url = "https://files.pythonhosted.org/packages/71/a1/5d2fb4c6a8e0e39dd1fb23bdd8feb1f2525ce90b28946f9f58ac5d3a039c/srsly-2.5.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1fd6c35c65c4d2435ae5bfb57b59682cf9b61606318a2a761856be9d7cc2d9e3", size = 1119766, upload-time = "2026-03-23T11:55:38.351Z" }, + { url = "https://files.pythonhosted.org/packages/ff/83/0862ffac8c06ed595dd1e28f261c37956585b9cf6b9bd049f8430a4c2daf/srsly-2.5.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b9df76d5a6bbf50967589bd42df3c522dd88babea2be745a507f56b41ab40626", size = 1120674, upload-time = "2026-03-23T11:55:39.644Z" }, + { url = "https://files.pythonhosted.org/packages/f1/06/42f72bab50876a708a10e6fc026ae8c7f185507d9f27544fa4ee8567c5fd/srsly-2.5.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a595958d0b1ff6d59c2570a3f0d1c8e36ab9f89d6e1b9c96fa7eb5e1a8698510", size = 1078505, upload-time = "2026-03-23T11:55:41.299Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f4/dfb86bc5c3abee267fb2f34895ea80d0159a084987a93d56ed1bf5ebefe4/srsly-2.5.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:bc0ad5be2aeb9ff29c8512848d39d7c63fdd4bfbb5516bc523f5de5a77e55e6d", size = 1090635, upload-time = "2026-03-23T11:55:42.7Z" }, + { url = "https://files.pythonhosted.org/packages/2a/a6/561b46eff4477191dd649e09dd9b88afc44aad7ce204c45f4e45ad04861d/srsly-2.5.3-cp310-cp310-win_amd64.whl", hash = "sha256:d2b8cfd8aee4d06ab335d359e4095d206102300a5e105a4b4bc69acca42427a6", size = 651653, upload-time = "2026-03-23T11:55:44.429Z" }, + { url = "https://files.pythonhosted.org/packages/dc/05/b122a1afaf8e8644d10f0203ad5174993910e6f727843089f0d48b444340/srsly-2.5.3-cp310-cp310-win_arm64.whl", hash = "sha256:c378afcb7dd7c42f426a66112496c949fc39e5883de6817d86e60afa51720ccc", size = 639118, upload-time = "2026-03-23T11:55:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/9a/36/5d7bb412d52e9cca787f9bfe838b596367189b254e50bf90f234a97184bf/srsly-2.5.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:785a09216ac31570fb301ddb9f61ee73d1f18f8b9561f712dce0b8ac8628bc88", size = 656760, upload-time = "2026-03-23T11:55:47.155Z" }, + { url = "https://files.pythonhosted.org/packages/d6/dc/124f008cd2be3e887e972cbdeb17c5aee0f42093eca02c7cfd63bb5daf19/srsly-2.5.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0017c7d2a0cd9a4f1bdc00d946b45edcf90bb0e271e8f084c1ce542bf6708c32", size = 657503, upload-time = "2026-03-23T11:55:48.681Z" }, + { url = "https://files.pythonhosted.org/packages/35/8a/2c97244ebab125d55f1bfb7bb94e9572b3e819410dffd6a040eca1112350/srsly-2.5.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:66ebae2c70305987341519ec1a720072a3cb3e4b1d52ac0e9e841f4d02658d3d", size = 1139161, upload-time = "2026-03-23T11:55:50.179Z" }, + { url = "https://files.pythonhosted.org/packages/fc/ea/ecd396188f7591d80b89665f7af9e3ae02e42683daef57033ad7993ad3f9/srsly-2.5.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4ca4a068f6e14d84113a02fcb875c6b50a6285a12938c0e7a157eb3a63c50a86", size = 1142438, upload-time = "2026-03-23T11:55:52.607Z" }, + { url = "https://files.pythonhosted.org/packages/9c/65/143e2e143c53d498ad0956f69d0e09189aa7a6e0ee6017758c285ba1ab2d/srsly-2.5.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e283fa2a8f7350fb9fb70ecdee28d59d39c92f4c7f1cc90a44d6b86db3b3a8b3", size = 1101783, upload-time = "2026-03-23T11:55:53.906Z" }, + { url = "https://files.pythonhosted.org/packages/6b/86/1392a5593de0cd3d08c2d6c071b877c84358a37f63172c4e9cb71706842d/srsly-2.5.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9ffc97e22730ea97b00f7c303ccc60b1305e786afadb2a4a46578dafa4d29da0", size = 1115876, upload-time = "2026-03-23T11:55:55.624Z" }, + { url = "https://files.pythonhosted.org/packages/d4/a5/6193aa4c08e488821538fcbce2282449e228fd2183ed67d118bb5ccd8b54/srsly-2.5.3-cp311-cp311-win_amd64.whl", hash = "sha256:f09b551f6c3e334652831ac68c770ee4284741ce0a3895bf1ccf2a1178d66cdd", size = 651733, upload-time = "2026-03-23T11:55:56.964Z" }, + { url = "https://files.pythonhosted.org/packages/66/a8/a73181743b6d237026615ca75c3fb3e4780736f1390550a7350d0c7f1149/srsly-2.5.3-cp311-cp311-win_arm64.whl", hash = "sha256:21cf09e417d3e4f3fbf7dd337fd6d948c97abd01896b9b4cb80e81cd9778a73a", size = 639124, upload-time = "2026-03-23T11:55:58.532Z" }, + { url = "https://files.pythonhosted.org/packages/02/cc/e9f7fcec4cc92ad8bad6316c4241638b8cf7380382d4489d94ec6c436452/srsly-2.5.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:71e51c046ccbeefb86524c6b1e17574f579c6ac4dc8ea4a09437d3e8f88342d3", size = 658379, upload-time = "2026-03-23T11:55:59.85Z" }, + { url = "https://files.pythonhosted.org/packages/21/e4/fea4512e9785f58509b2cf67d993323848e583161b5fcfdc7dd9d7c1f3df/srsly-2.5.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2f73c0db911552e94fe2016e1759d261d2f47926f68826664cada3723c87006a", size = 658513, upload-time = "2026-03-23T11:56:01.239Z" }, + { url = "https://files.pythonhosted.org/packages/20/b1/53591681b6ff2699a4f97b2d5552ba196eaa6a979b0873605f4c04b5f7ee/srsly-2.5.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c1ac27ae5f4bb9163c7d2c45fc8ec173aac3d92e32086d9472b326c5c6e570e", size = 1172265, upload-time = "2026-03-23T11:56:02.589Z" }, + { url = "https://files.pythonhosted.org/packages/4e/c9/741e29f534919a944a16da4184924b1d3404c4bf60716ab2b91be771d1e3/srsly-2.5.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:99026bcd9cbd3211cc36517400b04ca0fc5d3e412b14daf84ee6e65f67d9a2d8", size = 1180873, upload-time = "2026-03-23T11:56:03.944Z" }, + { url = "https://files.pythonhosted.org/packages/89/57/5554f786eccf78b2750d6ac63be126e1b67badec2cb409dd611cf6f8c52b/srsly-2.5.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:07d682679e639eb46ff7e6da4a92714f4d5ffe351d088ee66f221e9b1f8865bb", size = 1120437, upload-time = "2026-03-23T11:56:05.283Z" }, + { url = "https://files.pythonhosted.org/packages/eb/95/9b4f73b1be3692f86d72ccc131c8e50f26f824d5c8830a59390bcc5b60ef/srsly-2.5.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8e0542d85d6b55cf2934050d6ffcb1cd76c768dcf9572e7467002cf087bb366d", size = 1137376, upload-time = "2026-03-23T11:56:06.613Z" }, + { url = "https://files.pythonhosted.org/packages/5a/de/89ca640ca1953c4612279ce515d0af35658df3c06cdb324329bc91b4a7e1/srsly-2.5.3-cp312-cp312-win_amd64.whl", hash = "sha256:598f1e494c18cacb978299d77125415a586417081959f8ec3f068b32d97f8933", size = 652459, upload-time = "2026-03-23T11:56:07.994Z" }, + { url = "https://files.pythonhosted.org/packages/6d/4f/7ab6d49e36d9cc72ee15746cabd116eb6f338be8a06c1882968ee9d6c7d7/srsly-2.5.3-cp312-cp312-win_arm64.whl", hash = "sha256:4b1b721cd3ad1a9b2343519aadc786a4d09d5c0666962d49852eb12d6ec3fe26", size = 638411, upload-time = "2026-03-23T11:56:09.31Z" }, + { url = "https://files.pythonhosted.org/packages/9d/5c/12901e3794f4158abc6da750725aad6c2afddb1e4227b300fe7c71f66957/srsly-2.5.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e67b6bbacbfadea5e100266d2797f2d4cec9883ea4dc84a5537673850036a8d8", size = 656750, upload-time = "2026-03-23T11:56:10.708Z" }, + { url = "https://files.pythonhosted.org/packages/04/61/181c26370995f96f56f1b64b801e3ca1e0d703fc36506ae28606d62369fb/srsly-2.5.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:348c231b4477d8fe86603131d0f166d2feac9c372704dfc4398be71cc5b6fb07", size = 656746, upload-time = "2026-03-23T11:56:12.28Z" }, + { url = "https://files.pythonhosted.org/packages/77/c6/35876c78889f8ffe11ed3521644e666c3aef20ea31527b70f47456cf35c2/srsly-2.5.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b0938c2978c91ae1ef9c1f2ba35abb86330e198fb23469e356eba311e02233ee", size = 1155762, upload-time = "2026-03-23T11:56:14.075Z" }, + { url = "https://files.pythonhosted.org/packages/3e/da/40b71ca9906c8eb8f8feb6ac11d33dad458c85a56e1de764b96d402168a0/srsly-2.5.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5f6a837954429ecbe6dcdd27390d2fb4c7d01a3f99c9ffcf9ce66b2a6dd1b738", size = 1161092, upload-time = "2026-03-23T11:56:15.778Z" }, + { url = "https://files.pythonhosted.org/packages/dc/14/c0dd30cc8b93ce8137ff4766f743c882440ce49195fffc5d50eaeef311a6/srsly-2.5.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3576c125c486ce2958c2047e8858fe3cfc9ea877adfa05203b0986f9badee355", size = 1109984, upload-time = "2026-03-23T11:56:17.056Z" }, + { url = "https://files.pythonhosted.org/packages/08/f3/34354f183d8faafc631585571224b54d1b4b67e796972c36519c074ca355/srsly-2.5.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5fb59c42922e095d1ea36085c55bc16e2adb06a7bfe57b24d381e0194ae699f2", size = 1128409, upload-time = "2026-03-23T11:56:18.761Z" }, + { url = "https://files.pythonhosted.org/packages/a4/d9/5531f8a19492060b4e76e4ab06aca6f096fb5128fe18cc813d1772daf653/srsly-2.5.3-cp313-cp313-win_amd64.whl", hash = "sha256:111805927f05f5db440aeeacb85ce43da0b19ce7b2a09567a9ef8d30f3cc4d83", size = 650820, upload-time = "2026-03-23T11:56:20.096Z" }, + { url = "https://files.pythonhosted.org/packages/8e/8a/62fb7a971eca29e12f03fb9ddacb058548c14d33e5b5675ff0f85839cc7b/srsly-2.5.3-cp313-cp313-win_arm64.whl", hash = "sha256:0f106b0a700ab56e4a7c431b0f1444009ab6cb332edc7bbf6811c2a43f4722cb", size = 637278, upload-time = "2026-03-23T11:56:21.439Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5b/e4ef43c2a381711230af98d4c94a5323df48d6a7899ee652e05bf889290e/srsly-2.5.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:39c13d552a9f9674a12cdcdc66b0c2f02f3430d0cd04c5f9cf598824c2bd3d65", size = 661294, upload-time = "2026-03-23T11:56:23.29Z" }, + { url = "https://files.pythonhosted.org/packages/92/2d/ebce7f3717e52cd0a01f4ec570f388f3b7098526794fcf1ad734e0b8f852/srsly-2.5.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:14c930767cc169611a2dc14e23bc7638cfb616d6f79029700ade033607343540", size = 660952, upload-time = "2026-03-23T11:56:24.908Z" }, + { url = "https://files.pythonhosted.org/packages/22/47/a8f3e9b214be2624c8e8a78d38ca7b1d4e26b92d57018412e4bfc4abe89a/srsly-2.5.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2f2d464f0d0237e32fb53f0ec6f05418652c550e772b50e9918e83a1577cba4d", size = 1154554, upload-time = "2026-03-23T11:56:26.608Z" }, + { url = "https://files.pythonhosted.org/packages/d6/71/2a89dc3180a51e633a87a079ca064225f4aaf46c7b2a5fc720e28f261d98/srsly-2.5.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d18933248a5bb0ad56a1bae6003a9a7f37daac2ecb0c5bcbfaaf081b317e1c84", size = 1155746, upload-time = "2026-03-23T11:56:28.102Z" }, + { url = "https://files.pythonhosted.org/packages/b8/36/72e5ce3153927ca404b6f5bf5280e6ff3399c11557df472b153945468e0a/srsly-2.5.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7ea5412ea229e571ac9738cbe14f845cc06c8e4e956afb5f42061ccd087ef31f", size = 1112374, upload-time = "2026-03-23T11:56:29.591Z" }, + { url = "https://files.pythonhosted.org/packages/04/b2/0895de109c28eca0d41a811ab7c076d4e4a505e8466f06bae22f5180a1dd/srsly-2.5.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8d3988970b4cf7d03bdd5b5169302ff84562dd2e1e0f84aeb34df3e5b5dc19bf", size = 1127732, upload-time = "2026-03-23T11:56:31.458Z" }, + { url = "https://files.pythonhosted.org/packages/c7/79/a37fa7759797fbdfe0a2e029ab13e78b1e81e191220d2bb8ff57d869aefb/srsly-2.5.3-cp314-cp314-win_amd64.whl", hash = "sha256:6a02d7dcc16126c8fae1c1c09b2072798a1dc482ab5f9c52b12c7114dac47325", size = 656467, upload-time = "2026-03-23T11:56:33.14Z" }, + { url = "https://files.pythonhosted.org/packages/d7/25/0dae019b3b90ad9037f91de4c390555cdaac9460a93ad62b02b03babdff5/srsly-2.5.3-cp314-cp314-win_arm64.whl", hash = "sha256:1c9129c4abe31903ff7996904a51afdd5428060de6c3d12af49a4da5e8df2821", size = 643040, upload-time = "2026-03-23T11:56:34.448Z" }, + { url = "https://files.pythonhosted.org/packages/3a/44/72dd5285b2e05435d98b0797f101d91d9b345d491ddc1fdb9bd09e27ccb8/srsly-2.5.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:29d5d01ba4c2e9c01f936e5e6d5babc4a47b38c9cbd6e1ec23f6d5a49df32605", size = 666200, upload-time = "2026-03-23T11:56:35.753Z" }, + { url = "https://files.pythonhosted.org/packages/d2/ad/002c71b87fc3f648c9bf0ec47de0c3822bf2c95c8896a589dd03e7fd3977/srsly-2.5.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5c8df4039426d99f0148b5743542842ab96b82daded0b342555e15a639927757", size = 667409, upload-time = "2026-03-23T11:56:37.172Z" }, + { url = "https://files.pythonhosted.org/packages/2a/35/2cea3d5e80aeecfc4ece9e7e1783e7792cc3bad7ab85ab585882e1db4e38/srsly-2.5.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:06a43d63bde2e8cccadb953d7fff70b18196ca286b65dd2ad16006d65f3f8166", size = 1265941, upload-time = "2026-03-23T11:56:38.825Z" }, + { url = "https://files.pythonhosted.org/packages/aa/38/8a4d7e86dd0370a2e5af251b646000197bb5b7e0f9aa360c71bbfb253d0d/srsly-2.5.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:808cfafc047f0dec507a34c8fa8e4cda5722737fd33577df73452f52f7aca644", size = 1250693, upload-time = "2026-03-23T11:56:40.449Z" }, + { url = "https://files.pythonhosted.org/packages/99/05/340129de5ea7b237271b12f8a6962cfa7eb0c5a3056794626d348c5ae7c7/srsly-2.5.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:71d4cbe2b2a1335c76ed0acae2dc862163787d8b01a705e1949796907ed94ccd", size = 1242408, upload-time = "2026-03-23T11:56:41.8Z" }, + { url = "https://files.pythonhosted.org/packages/01/cb/d7fee7ab27c6aa2e3f865fb7b50ba18c81a4c763bba12bdf53df246441bc/srsly-2.5.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:565f69083d33cb329cfc74317da937fb3270c0f40fabc1b4488702d8074b4a3e", size = 1242749, upload-time = "2026-03-23T11:56:43.246Z" }, + { url = "https://files.pythonhosted.org/packages/d8/d1/9bad3a0f2fa7b72f4e0cf1d267b00513092d20ef538c47f72823ae4f7656/srsly-2.5.3-cp314-cp314t-win_amd64.whl", hash = "sha256:8ac016ffaeac35bc010992b71bf8afdd39d458f201c8138d84cf78778a936e6c", size = 673783, upload-time = "2026-03-23T11:56:44.875Z" }, + { url = "https://files.pythonhosted.org/packages/2a/ae/57d1d7af907e20c077e113e0e4976f87b82c0a415403d99284a262229dd0/srsly-2.5.3-cp314-cp314t-win_arm64.whl", hash = "sha256:d822083fe26ec6728bd8c273ac121fc4ab3864a0fdf0cf0ff3efb188fcd209ed", size = 650229, upload-time = "2026-03-23T11:56:46.148Z" }, +] + +[[package]] +name = "stack-data" +version = "0.6.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asttokens" }, + { name = "executing" }, + { name = "pure-eval" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/28/e3/55dcc2cfbc3ca9c29519eb6884dd1415ecb53b0e934862d3559ddcb7e20b/stack_data-0.6.3.tar.gz", hash = "sha256:836a778de4fec4dcd1dcd89ed8abff8a221f58308462e1c4aa2a3cf30148f0b9", size = 44707, upload-time = "2023-09-30T13:58:05.479Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695", size = 24521, upload-time = "2023-09-30T13:58:03.53Z" }, +] + +[[package]] +name = "terminado" +version = "0.18.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ptyprocess", marker = "os_name != 'nt'" }, + { name = "pywinpty", marker = "os_name == 'nt'" }, + { name = "tornado" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8a/11/965c6fd8e5cc254f1fe142d547387da17a8ebfd75a3455f637c663fb38a0/terminado-0.18.1.tar.gz", hash = "sha256:de09f2c4b85de4765f7714688fff57d3e75bad1f909b589fde880460c753fd2e", size = 32701, upload-time = "2024-03-12T14:34:39.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/9e/2064975477fdc887e47ad42157e214526dcad8f317a948dee17e1659a62f/terminado-0.18.1-py3-none-any.whl", hash = "sha256:a4468e1b37bb318f8a86514f65814e1afc977cf29b3992a4500d9dd305dcceb0", size = 14154, upload-time = "2024-03-12T14:34:36.569Z" }, +] + +[[package]] +name = "thinc" +version = "8.3.13" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "blis" }, + { name = "catalogue" }, + { name = "confection" }, + { name = "cymem" }, + { name = "murmurhash" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "packaging" }, + { name = "preshed" }, + { name = "pydantic" }, + { name = "setuptools" }, + { name = "srsly" }, + { name = "wasabi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/13/46/76df95f2c327f9a9cef30c1523bf285627897097163584dcf5f77b2ebce2/thinc-8.3.13.tar.gz", hash = "sha256:68e658549fc1eb3ff92aed5147fcbb9c15d6e9cc0e623b4d0998d16522ffb4f9", size = 194640, upload-time = "2026-03-23T07:22:36.41Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ad/e3/df570d55f38250d153e209d998f60e334026ea60cf9a887cffb85d7ee9bf/thinc-8.3.13-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:84fb50fe572a1860165f2e7a640c7cb70d43d6962366e69f643fa9a27e4a2127", size = 846996, upload-time = "2026-03-23T07:21:32.701Z" }, + { url = "https://files.pythonhosted.org/packages/ec/72/e97c9cb863ef0a645ba069c24e0981bfaedf8241ba199512ebcd64ba090a/thinc-8.3.13-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:3dac18a0fb0a42f711c2ce9c02cbb090385aecae92089aa17b9dfd808a542013", size = 815368, upload-time = "2026-03-23T07:21:34.392Z" }, + { url = "https://files.pythonhosted.org/packages/b6/7a/9283f52b1210dc052b795e22ec739d13929b914d1289e49336bede34c4eb/thinc-8.3.13-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e08b1577a56e7315770af280aabd8fa5f2a1fb6afd1c50a4183c06e907faf558", size = 3885033, upload-time = "2026-03-23T07:21:35.772Z" }, + { url = "https://files.pythonhosted.org/packages/93/9a/aa8f2e19819c02781b282c3a9cfb57c76ff1fbe0b6deaa1ffd04dc920894/thinc-8.3.13-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:303477eb51b9b39c94a7fc7967ee8a039eca1ca37d95dcce1234c83b95b4ee9f", size = 3912947, upload-time = "2026-03-23T07:21:37.219Z" }, + { url = "https://files.pythonhosted.org/packages/51/fa/ea7c67667b8a875178bea5a42dc9c8b0622c34e7eba3d8e42874f2c4b4c1/thinc-8.3.13-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d7a9654f9ca362a4be7f5e590fdfee26e2e2084da9fd3306032ec037e99f2f8e", size = 4887518, upload-time = "2026-03-23T07:21:38.76Z" }, + { url = "https://files.pythonhosted.org/packages/36/44/99c391e951e3b706b9a7552ced720e9ec3bddd6707a99d53e4354ebefa45/thinc-8.3.13-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e1f8d13bf92ee10595c40692fd4cf8e7bbe73bd9f260107e975fd5dbee1af42b", size = 5044691, upload-time = "2026-03-23T07:21:40.257Z" }, + { url = "https://files.pythonhosted.org/packages/ce/cf/9d95fb5f12d76ad1c7570a9a38da2f2f60dba721c87630bfabaabef91bc3/thinc-8.3.13-cp310-cp310-win_amd64.whl", hash = "sha256:e7f046d8914055cad51e83ff0da1a892acb73cd58556d7c1a5d4015a3766a899", size = 1795372, upload-time = "2026-03-23T07:21:41.709Z" }, + { url = "https://files.pythonhosted.org/packages/b4/72/ca06842a007e8c794e8c59462f242cdfd6167d7cc9d0155ad004b194b015/thinc-8.3.13-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4565102638038a01a2193c7f5d41ccbd6233fbdcb1f1b184322a06add4f51f18", size = 844359, upload-time = "2026-03-23T07:21:43.017Z" }, + { url = "https://files.pythonhosted.org/packages/48/44/e6aef092f478d263f72eb3933b55a6f37ba97c6a0ea0a61d13fbf9bf0c19/thinc-8.3.13-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:859fbd9d9b16af5278da23589b4afbe2ab6b0dd615df4d3229b7c4e67cd3107e", size = 812089, upload-time = "2026-03-23T07:21:44.618Z" }, + { url = "https://files.pythonhosted.org/packages/ff/8a/9ce0424d456cd3580cc3a855b23a7ff86b81d5299fceb496a2f56f06c1c0/thinc-8.3.13-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a518d5c761a0f2341e530e867de133dc3ed814558365b2a68ec53b89c482a43f", size = 4101388, upload-time = "2026-03-23T07:21:46.135Z" }, + { url = "https://files.pythonhosted.org/packages/ad/51/ec91c0434bd9a1096ab874bbd6dc110c5089d7fc513137e6af59bd051eec/thinc-8.3.13-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:81337dfbee37f58f36c0c70f9a819dce1b32cdc13d959181e10de079621f6ac6", size = 4131972, upload-time = "2026-03-23T07:21:48.403Z" }, + { url = "https://files.pythonhosted.org/packages/ff/67/e30dea753c90cff5cb9e5feb34948fdb89a6774b84d849585b49e16a730e/thinc-8.3.13-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:fbc0ee16edd260c6a4a9e365ff36d0a682c9e7ca6d7b985682659ef2e3e73826", size = 5101283, upload-time = "2026-03-23T07:21:49.991Z" }, + { url = "https://files.pythonhosted.org/packages/00/e9/b7544eddababa16e548b26a96fff29eeb307ce938df5fa4af9371fe8ed5d/thinc-8.3.13-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0355c37e40d1a9fc2a1b8e9c2e294d8586f6baa97bcac6b9002f2dddb4b82ae9", size = 5264488, upload-time = "2026-03-23T07:21:51.747Z" }, + { url = "https://files.pythonhosted.org/packages/4c/a9/49391a40d703efc0f7a451310373261835f71fd3e6e2e8cfc08ee02f78ad/thinc-8.3.13-cp311-cp311-win_amd64.whl", hash = "sha256:0a0fa13dcfe4b319c3a396432c1dbff30d3de37dbbdee559e76600ee2b9486df", size = 1795058, upload-time = "2026-03-23T07:21:53.424Z" }, + { url = "https://files.pythonhosted.org/packages/c1/31/fd5348d44beda12a3ee415cbba9ed4fd0b17ce65db1d473c38a29a8d6153/thinc-8.3.13-cp311-cp311-win_arm64.whl", hash = "sha256:cd8a2b714c061969eee65802965167a6ada1fe708d82fe176d98dcb95ebe182a", size = 1721215, upload-time = "2026-03-23T07:21:55.027Z" }, + { url = "https://files.pythonhosted.org/packages/3e/af/f7c1ebfe92eb5d27d7f2f3da67a11e2eb57bc30ab1553279af6dc65b65a8/thinc-8.3.13-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:77a41f66285321d20aaedaea1e87d7cd48dca6d2427bed1867ec7cba7109fc8d", size = 821097, upload-time = "2026-03-23T07:21:56.698Z" }, + { url = "https://files.pythonhosted.org/packages/45/8f/69d7338575d98df85d0b54c0f5fc277dba72587fe9ab846ecdd12a998bcb/thinc-8.3.13-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3710d318b4e5460cf366a6f7b5ddbefb5d39dbd4cfa408222750fdc6c27c4411", size = 791932, upload-time = "2026-03-23T07:21:58.38Z" }, + { url = "https://files.pythonhosted.org/packages/4b/a5/21d010c81e81e1589e5ccb4950e521804d13726e541e87f644c51815673b/thinc-8.3.13-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5a08c87143a6d20177652dca1ec0dc815d88216d8fc62594a57e8bc45bf5ed49", size = 3854219, upload-time = "2026-03-23T07:21:59.819Z" }, + { url = "https://files.pythonhosted.org/packages/f9/ff/6914bf370bd1d604d89e6dfb46b97d10cd9b00d42ff8c036283e92314a8c/thinc-8.3.13-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4b5ec9ff313819e7d8667794a3559463fa89ff45aaa73e3fd8d6273b1e0d7a7f", size = 3903307, upload-time = "2026-03-23T07:22:01.652Z" }, + { url = "https://files.pythonhosted.org/packages/f3/3d/5572b47fa155fb3388c071515b74024fa17a6efd1df9406da378f0aa84ef/thinc-8.3.13-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5c9a48f2bc1e04f138240ed5f9b815a9141a5de26accd0f08fa0137fcefed258", size = 4836882, upload-time = "2026-03-23T07:22:03.565Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f0/a8d77c7bac089697c6df302cc3c936a1ab36a4720deae889e6f1dbcbd0eb/thinc-8.3.13-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:79a29a44d76bd02f5ac0624268c6e42b3576ae472c791a8ae9c2d813ae789b59", size = 5033398, upload-time = "2026-03-23T07:22:05.045Z" }, + { url = "https://files.pythonhosted.org/packages/21/82/5651bb1f904d04220fc7670035ada921bf0638e2cff6444d67c12887a968/thinc-8.3.13-cp312-cp312-win_amd64.whl", hash = "sha256:ed1dc709ac4f2f03b710457889e4e02f05de51bc8456980c241d0b28798bc7cb", size = 1721248, upload-time = "2026-03-23T07:22:06.749Z" }, + { url = "https://files.pythonhosted.org/packages/94/8d/683703de021ffbe46833d722b70f49ffbbca8e5bd6876256977555d92d7d/thinc-8.3.13-cp312-cp312-win_arm64.whl", hash = "sha256:c6a049703a6011c8fe26ee41af7e70272145594140d82f79bb23de619c6a6525", size = 1645777, upload-time = "2026-03-23T07:22:08.104Z" }, + { url = "https://files.pythonhosted.org/packages/af/b9/7b46942176df459d1804a9e77b0976f7c56f3abf3ec7485d0e5f836a0382/thinc-8.3.13-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c2811dfd8d46d8b5d3b39051b23e64006b2994a5143b1978b436938018792af8", size = 817337, upload-time = "2026-03-23T07:22:09.538Z" }, + { url = "https://files.pythonhosted.org/packages/a7/79/53085a72cd8f4fc4e6e313d05ea5aa98e870684f4a0fb318a9875fc0a964/thinc-8.3.13-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5593e6300cb1ebe0c0e546e9c9fb49e7c2627a0aa688795cd4f995a8b820d2ec", size = 788120, upload-time = "2026-03-23T07:22:11.215Z" }, + { url = "https://files.pythonhosted.org/packages/9e/3e/d61b462b16da95ac6885f95bb395e672040ee594833e571a6edcffd234f5/thinc-8.3.13-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f697174d3fb474966ce50b430bbafa101a6d2f7ffb559dac4b5c59389ef72d22", size = 3844666, upload-time = "2026-03-23T07:22:12.67Z" }, + { url = "https://files.pythonhosted.org/packages/78/4c/898cc654bb123734c71ec5a425c02ca34439517d01ce1c95a6563295580e/thinc-8.3.13-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e9c7c5c104737b414c8c4ec578e67d78b6c859afe25cbc0684402e721415bd7f", size = 3890658, upload-time = "2026-03-23T07:22:14.668Z" }, + { url = "https://files.pythonhosted.org/packages/cd/56/1abdbf0a4ad628e8a05d6516fe0745969649d805367a3dccad8ee872981b/thinc-8.3.13-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7a99d0e242d1ccd23f9ae6bea7cd502f8626efa65c156b91d84581d0356696c3", size = 4819933, upload-time = "2026-03-23T07:22:16.85Z" }, + { url = "https://files.pythonhosted.org/packages/f1/22/b84dbdc6be5055bbdb2a7352e2c393f67e8593c137f1b83c82bf1e062b6e/thinc-8.3.13-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e676edd21a747afbe3e6b9f3fca8b962e36d146ded03b070cb0c28e2dfbe9499", size = 5018099, upload-time = "2026-03-23T07:22:18.356Z" }, + { url = "https://files.pythonhosted.org/packages/0f/a8/763cd7ba949334c9d2cddc92dadb68b344cb9546dc01b8d4a733dcaa16c1/thinc-8.3.13-cp313-cp313-win_amd64.whl", hash = "sha256:8ad40307f20e83f77af28ff5c6be0b86af7a8b251d1231c545508d2763157d8f", size = 1720309, upload-time = "2026-03-23T07:22:19.81Z" }, + { url = "https://files.pythonhosted.org/packages/f5/15/a11f7bb3cbc97dfecf32a90552f5a8f8a5c99316a99c6c17bdabf5baf256/thinc-8.3.13-cp313-cp313-win_arm64.whl", hash = "sha256:723949cab11d1925c15447928513a718276316cec6e0de28337cca0a62be0521", size = 1644606, upload-time = "2026-03-23T07:22:21.339Z" }, + { url = "https://files.pythonhosted.org/packages/80/40/f4937d113912c6d669ffe982356ab29dcb6c7fe3be926a15981dbbb6a91c/thinc-8.3.13-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:7badb0be4825535e6362c19e8a41872b65409e9da46d3453a391b843a0720865", size = 817024, upload-time = "2026-03-23T07:22:23.005Z" }, + { url = "https://files.pythonhosted.org/packages/d2/00/4d4ed1a11ba2920b85a03a0683b16d97dc5beb2e78078dbf0e13e43bcea7/thinc-8.3.13-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:565300b7e13de799e5abff00d445f537e9256cf7da4dcb0d0f005fc16748a29e", size = 792096, upload-time = "2026-03-23T07:22:24.349Z" }, + { url = "https://files.pythonhosted.org/packages/44/5d/dc33d6932be8721af2ef76b4a3a6e8020648630eabae61fb916d2a861d1d/thinc-8.3.13-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c17cef1900a1aba7e1487493d16b8aa0a8633116f1b2a51c6649a4000697f17b", size = 3842215, upload-time = "2026-03-23T07:22:25.836Z" }, + { url = "https://files.pythonhosted.org/packages/af/bc/a6d37d8dadc2c5b524f51192413481160c42c9dd6105e8d5551531623225/thinc-8.3.13-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f4f26d1eec9b2a6a8f2e0298a5515d13eb06d70730d0d9e1040bb329e12bf3fb", size = 3849253, upload-time = "2026-03-23T07:22:27.845Z" }, + { url = "https://files.pythonhosted.org/packages/7a/59/ce9c7067f1dfe5985875927de9cf7a79f9dae3e69487fd650dfba558029d/thinc-8.3.13-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a61a31fd0ce3c2771cf4901ba6df70e774ffe32febf1024c5b43d63575cd58fe", size = 4831163, upload-time = "2026-03-23T07:22:29.395Z" }, + { url = "https://files.pythonhosted.org/packages/4f/a8/f57819347fc4d8bef2204d15fcbb9d7dff2d6cdd5f83d5ed91456ddacc55/thinc-8.3.13-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ba8119daf84a12259ae4d251d36426417bafa0b34108890b4b7e2b50966bd990", size = 4986051, upload-time = "2026-03-23T07:22:30.933Z" }, + { url = "https://files.pythonhosted.org/packages/05/ef/a82214bb7c7c1e2d92b69e1a7654be90cfab180082c6108e45a98af2422c/thinc-8.3.13-cp314-cp314-win_amd64.whl", hash = "sha256:433e3826e018da489f1a8068e6de677f6eff3cc93991a599d90f12cd1bc26cdc", size = 1740382, upload-time = "2026-03-23T07:22:32.869Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ef/1648fda54e9689058335ff54f650a7a314db2a42e21af1b83949b2dc748e/thinc-8.3.13-cp314-cp314-win_arm64.whl", hash = "sha256:11754fada9ad5ba2e02d5f3f234f940e24015b82333db58372f4a6aedad9b43f", size = 1667687, upload-time = "2026-03-23T07:22:34.967Z" }, +] + +[[package]] +name = "tinycss2" +version = "1.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "webencodings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/ae/2ca4913e5c0f09781d75482874c3a95db9105462a92ddd303c7d285d3df2/tinycss2-1.5.1.tar.gz", hash = "sha256:d339d2b616ba90ccce58da8495a78f46e55d4d25f9fd71dfd526f07e7d53f957", size = 88195, upload-time = "2025-11-23T10:29:10.082Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/45/c7b5c3168458db837e8ceab06dc77824e18202679d0463f0e8f002143a97/tinycss2-1.5.1-py3-none-any.whl", hash = "sha256:3415ba0f5839c062696996998176c4a3751d18b7edaaeeb658c9ce21ec150661", size = 28404, upload-time = "2025-11-23T10:29:08.676Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "tomli-w" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/19/75/241269d1da26b624c0d5e110e8149093c759b7a286138f4efd61a60e75fe/tomli_w-1.2.0.tar.gz", hash = "sha256:2dd14fac5a47c27be9cd4c976af5a12d87fb1f0b4512f81d69cce3b35ae25021", size = 7184, upload-time = "2025-01-15T12:07:24.262Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/18/c86eb8e0202e32dd3df50d43d7ff9854f8e0603945ff398974c1d91ac1ef/tomli_w-1.2.0-py3-none-any.whl", hash = "sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90", size = 6675, upload-time = "2025-01-15T12:07:22.074Z" }, +] + +[[package]] +name = "toolz" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/11/d6/114b492226588d6ff54579d95847662fc69196bdeec318eb45393b24c192/toolz-1.1.0.tar.gz", hash = "sha256:27a5c770d068c110d9ed9323f24f1543e83b2f300a687b7891c1a6d56b697b5b", size = 52613, upload-time = "2025-10-17T04:03:21.661Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/12/5911ae3eeec47800503a238d971e51722ccea5feb8569b735184d5fcdbc0/toolz-1.1.0-py3-none-any.whl", hash = "sha256:15ccc861ac51c53696de0a5d6d4607f99c210739caf987b5d2054f3efed429d8", size = 58093, upload-time = "2025-10-17T04:03:20.435Z" }, +] + +[[package]] +name = "tornado" +version = "6.5.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/64/24/95ec527ad67b76d59299e5465b3935d05e4294b7e0290a3924b7487df30b/tornado-6.5.7.tar.gz", hash = "sha256:66c513a76cda70d53907bc27cf1447557699c2e95aa48ba27a442ff61c3ddfc2", size = 519252, upload-time = "2026-06-08T17:34:51.232Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/dc/c7043cab6fed8ae159fc1923ce829ada35c4dbd797d408a43858ffaf9639/tornado-6.5.7-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:148b2eb15c2c765a50796172c1e499649b35f30d2e3c3d3e15913cfa56bfb163", size = 448543, upload-time = "2026-06-08T17:34:38.052Z" }, + { url = "https://files.pythonhosted.org/packages/92/4f/090b1431e5a43df696feceffc268c5383cc079ecb5f08ce58f917109aafe/tornado-6.5.7-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:9da38de27f1da3b78a966f0dae12b5a1ea9afe72ca805d84ff06508272ddf100", size = 446707, upload-time = "2026-06-08T17:34:39.594Z" }, + { url = "https://files.pythonhosted.org/packages/37/d8/ef374952fd5da67d4463122c2b8e5a96536ec10b4b339254c6dcde81d01c/tornado-6.5.7-cp39-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8d759e71906ee783f8867b93bf26a265743da4c1e2f4a018464c1ba019862972", size = 449774, upload-time = "2026-06-08T17:34:41.204Z" }, + { url = "https://files.pythonhosted.org/packages/35/37/d434c73f4c6e014b745b9b37085f34f40c022f007efff3d7fe65991899f3/tornado-6.5.7-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a46347a18f23fb92b396beebe0fb78f61dda0cc302445202c16203d8a18848b", size = 450745, upload-time = "2026-06-08T17:34:42.531Z" }, + { url = "https://files.pythonhosted.org/packages/b6/2b/56b9aff361d7f1ab728a805ec7d7ea835f8807afa9f5cc690ea0e630efb9/tornado-6.5.7-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7778b30bef919231265e91c69963ce0f49a1e9c07ac900bbe75b19ce2575ba92", size = 450578, upload-time = "2026-06-08T17:34:43.787Z" }, + { url = "https://files.pythonhosted.org/packages/02/30/a7444fb23aa76860a14198fab96ac79f1866b0a6e19e26c4381b0938e50f/tornado-6.5.7-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e726f0c75da7726eec023aa62751ff8878bd2737e34fbdd33b1ae5897d2200f5", size = 449985, upload-time = "2026-06-08T17:34:45.326Z" }, + { url = "https://files.pythonhosted.org/packages/5c/42/5f0e56c01e8d9d36f4e23f367b85ae6cae0c1ecddd5e6977d8388ad27488/tornado-6.5.7-cp39-abi3-win32.whl", hash = "sha256:f8de3bf12d3efdd0cbe7c8887868198f8a91415e3f29fcf258d9b8eb7b1d9ae4", size = 451047, upload-time = "2026-06-08T17:34:46.784Z" }, + { url = "https://files.pythonhosted.org/packages/c9/a4/b393076ffb21b469eec5b328a0534cf03a3b90bfc6b1f09507cdd075d938/tornado-6.5.7-cp39-abi3-win_amd64.whl", hash = "sha256:de942f843533a039ef9fa3d9c88c7cd8a7c94553fb5ad0154270989b3d99a2c4", size = 451485, upload-time = "2026-06-08T17:34:48.248Z" }, + { url = "https://files.pythonhosted.org/packages/71/2e/7b1c769803121b809112cf9a00681c472eae1d80e32d7ec0e0bd61d0d0e1/tornado-6.5.7-cp39-abi3-win_arm64.whl", hash = "sha256:ff934fce95643af5f11efdae618eaa73d469dc588641e5c8d19295a0c65c4796", size = 450506, upload-time = "2026-06-08T17:34:49.702Z" }, +] + +[[package]] +name = "tox" +version = "4.58.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cachetools" }, + { name = "colorama" }, + { name = "filelock" }, + { name = "packaging" }, + { name = "platformdirs" }, + { name = "pluggy" }, + { name = "pyproject-api" }, + { name = "python-discovery" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "tomli-w" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6e/8e/4d2b1b2a81f4de1cd4e54fa40df1ab5f9bb88fe2e37461fc44aba8f9d302/tox-4.58.0.tar.gz", hash = "sha256:ab0b126a04dd56bc18e6d216386db09335247f2289b54cf534deb5c4ae3a8d2e", size = 296926, upload-time = "2026-07-21T13:10:36.622Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6b/3d/7ba55871e9d794d40b6c8424f2e5d1b267ea5d9a4bd2175e08b57960ba13/tox-4.58.0-py3-none-any.whl", hash = "sha256:dcae21f5f015f3a67658e35644cce0d1aa0dedcd06f3927f95d84e1717f6cea5", size = 223298, upload-time = "2026-07-21T13:10:34.731Z" }, +] + +[[package]] +name = "tqdm" +version = "4.70.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/21/3b/6c24bec5be5e743ffd99576daa5cc077722fc7d5bbc00bd133fa0c698dc6/tqdm-4.70.0.tar.gz", hash = "sha256:55b0b0dbd97462d06ebee91e4dac24ed4d4702be82b24f07e6c1d27e08cea220", size = 795438, upload-time = "2026-07-27T11:33:15.271Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/1c/01bfd571a64e7f270e6bab5e33777debe0edc56759233ce84f27dec92d14/tqdm-4.70.0-py3-none-any.whl", hash = "sha256:7f585706bfddbdebf89daac705b2dfcc16890130727d3197ca62c732b4310953", size = 80184, upload-time = "2026-07-27T11:33:13.167Z" }, +] + +[[package]] +name = "traitlets" +version = "5.15.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/a9/a2584b8313b89f94869ddb3c4074617a691de1812a614d2d50e32ca5a7a6/traitlets-5.15.1.tar.gz", hash = "sha256:7b1c07854fe25acb39e009bae49f11b79ff6cbb2f27999104e9110e7a6b53722", size = 163344, upload-time = "2026-06-03T12:26:06.181Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/8d/1080ee4c231f361b6ce4470d556c8c435b67c7e0753aaa641497ee92f88b/traitlets-5.15.1-py3-none-any.whl", hash = "sha256:770a53705f84b81ac107e83a1b3328ff2dae16094d8fc3cfc004e4b22dfd8e92", size = 85858, upload-time = "2026-06-03T12:26:04.395Z" }, +] + +[[package]] +name = "typer" +version = "0.25.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "click" }, + { name = "rich" }, + { name = "shellingham" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/51/9aed62104cea109b820bbd6c14245af756112017d309da813ef107d42e7e/typer-0.25.1.tar.gz", hash = "sha256:9616eb8853a09ffeabab1698952f33c6f29ffdbceb4eaeecf571880e8d7664cc", size = 122276, upload-time = "2026-04-30T19:32:16.964Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/f9/2b3ff4e56e5fa7debfaf9eb135d0da96f3e9a1d5b27222223c7296336e5f/typer-0.25.1-py3-none-any.whl", hash = "sha256:75caa44ed46a03fb2dab8808753ffacdbfea88495e74c85a28c5eefcf5f39c89", size = 58409, upload-time = "2026-04-30T19:32:18.271Z" }, +] + +[[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 = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "tzdata" +version = "2026.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/92/ff/5a28bdfd8c3ebec42564ac7d0e54ca3db65044a9314a97f9564fa7a1e926/tzdata-2026.3.tar.gz", hash = "sha256:4a1518b8993086a7982523e071643f3c0e5f213e75b21318e78bcabfff9d1415", size = 198674, upload-time = "2026-07-10T08:50:37.887Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/6d/b53b99a9f2766d095985947a5782f1702cabb129a34f7a802d7197af832f/tzdata-2026.3-py2.py3-none-any.whl", hash = "sha256:dc096730c87af6cab1b171c9d532be840741ff5d459015e7f6947bd7d7e54931", size = 348168, upload-time = "2026-07-10T08:50:36.46Z" }, +] + +[[package]] +name = "uri-template" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/31/c7/0336f2bd0bcbada6ccef7aaa25e443c118a704f828a0620c6fa0207c1b64/uri-template-1.3.0.tar.gz", hash = "sha256:0e00f8eb65e18c7de20d595a14336e9f337ead580c70934141624b6d1ffdacc7", size = 21678, upload-time = "2023-06-21T01:49:05.374Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/00/3fca040d7cf8a32776d3d81a00c8ee7457e00f80c649f1e4a863c8321ae9/uri_template-1.3.0-py3-none-any.whl", hash = "sha256:a44a133ea12d44a0c0f06d7d42a52d71282e77e2f937d8abd5655b8d56fc1363", size = 11140, upload-time = "2023-06-21T01:49:03.467Z" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] + +[[package]] +name = "virtualenv" +version = "21.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, + { name = "python-discovery" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fe/25/e367a7229b0914772ca8d81b41fde012d9feda68523b52644a571bb21ce8/virtualenv-21.7.0.tar.gz", hash = "sha256:7f9519b9432ff11b6e1a3e94061664efc2ff99ea21780e3cf4f6bd0a5da8b37c", size = 5527510, upload-time = "2026-07-21T13:12:14.109Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/7a/ae29312b1e88a22e81f5d21fc11526d2a114089776c2550d2b205b6c2a47/virtualenv-21.7.0-py3-none-any.whl", hash = "sha256:a8370c1c5530fbabf955e40b8fbbc68a431648b10f9433faa587db30a06e51dd", size = 5507078, upload-time = "2026-07-21T13:12:12.136Z" }, +] + +[[package]] +name = "wasabi" +version = "1.1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/f9/054e6e2f1071e963b5e746b48d1e3727470b2a490834d18ad92364929db3/wasabi-1.1.3.tar.gz", hash = "sha256:4bb3008f003809db0c3e28b4daf20906ea871a2bb43f9914197d540f4f2e0878", size = 30391, upload-time = "2024-05-31T16:56:18.99Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/7c/34330a89da55610daa5f245ddce5aab81244321101614751e7537f125133/wasabi-1.1.3-py3-none-any.whl", hash = "sha256:f76e16e8f7e79f8c4c8be49b4024ac725713ab10cd7f19350ad18a8e3f71728c", size = 27880, upload-time = "2024-05-31T16:56:16.699Z" }, +] + +[[package]] +name = "wcwidth" +version = "0.8.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/34/74/c6428f875774288bec1396f5bfcbc2d925700a4dad61727fd5f2b12f249d/wcwidth-0.8.2.tar.gz", hash = "sha256:91fbef97204b96a3d4d421609b80340b760cf33e26da123ff243d76b1fda8dda", size = 1466253, upload-time = "2026-06-29T18:11:11.601Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/42/3e5985a0a7e57de470b320c6d6a1a67c844f6737a587f3d44dd13d1819e7/wcwidth-0.8.2-py3-none-any.whl", hash = "sha256:d63947694a0539a1d51e01eda7caf800c291020e6cdd7e28ad7b14dd33ad4f85", size = 323166, upload-time = "2026-06-29T18:11:09.888Z" }, +] + +[[package]] +name = "weasel" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cloudpathlib" }, + { name = "confection" }, + { name = "httpx" }, + { name = "packaging" }, + { name = "pydantic" }, + { name = "smart-open" }, + { name = "srsly" }, + { name = "typer" }, + { name = "wasabi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ce/e5/e272bb9a045105a1fdf4b798d8086f5932a178f4d738f17a74f5c9e0ae9a/weasel-1.0.0.tar.gz", hash = "sha256:7b129b44c90cc543b760532974ca1e4eb30dad2aa2026f57bdce66354ae610fc", size = 38682, upload-time = "2026-03-20T08:10:25.266Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/07/57ebf7a6798b016c064bd0ca81b4c6a99daa4dc377b898bc7b41eb6b5af0/weasel-1.0.0-py3-none-any.whl", hash = "sha256:89518acee027f49d743126c3502d35e6dd14f5768be5c37c9af47c171b6005cc", size = 50713, upload-time = "2026-03-20T08:10:23.637Z" }, +] + +[[package]] +name = "webcolors" +version = "25.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/7a/eb316761ec35664ea5174709a68bbd3389de60d4a1ebab8808bfc264ed67/webcolors-25.10.0.tar.gz", hash = "sha256:62abae86504f66d0f6364c2a8520de4a0c47b80c03fc3a5f1815fedbef7c19bf", size = 53491, upload-time = "2025-10-31T07:51:03.977Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e2/cc/e097523dd85c9cf5d354f78310927f1656c422bd7b2613b2db3e3f9a0f2c/webcolors-25.10.0-py3-none-any.whl", hash = "sha256:032c727334856fc0b968f63daa252a1ac93d33db2f5267756623c210e57a4f1d", size = 14905, upload-time = "2025-10-31T07:51:01.778Z" }, +] + +[[package]] +name = "webencodings" +version = "0.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/02/ae6ceac1baeda530866a85075641cec12989bd8d31af6d5ab4a3e8c92f47/webencodings-0.5.1.tar.gz", hash = "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923", size = 9721, upload-time = "2017-04-05T20:21:34.189Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/24/2a3e3df732393fed8b3ebf2ec078f05546de641fe1b667ee316ec1dcf3b7/webencodings-0.5.1-py2.py3-none-any.whl", hash = "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78", size = 11774, upload-time = "2017-04-05T20:21:32.581Z" }, +] + +[[package]] +name = "websocket-client" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/41/aa4bf9664e4cda14c3b39865b12251e8e7d239f4cd0e3cc1b6c2ccde25c1/websocket_client-1.9.0.tar.gz", hash = "sha256:9e813624b6eb619999a97dc7958469217c3176312b3a16a4bd1bc7e08a46ec98", size = 70576, upload-time = "2025-10-07T21:16:36.495Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/34/db/b10e48aa8fff7407e67470363eac595018441cf32d5e1001567a7aeba5d2/websocket_client-1.9.0-py3-none-any.whl", hash = "sha256:af248a825037ef591efbf6ed20cc5faa03d3b47b9e5a2230a529eeee1c1fc3ef", size = 82616, upload-time = "2025-10-07T21:16:34.951Z" }, +] + +[[package]] +name = "wheel" +version = "0.47.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/39/62/75f18a0f03b4219c456652c7780e4d749b929eb605c098ce3a5b6b6bc081/wheel-0.47.0.tar.gz", hash = "sha256:cc72bd1009ba0cf63922e28f94d9d83b920aa2bb28f798a31d0691b02fa3c9b3", size = 63854, upload-time = "2026-04-22T15:51:27.727Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/1b/9e33c09813d65e248f7f773119148a612516a4bea93e9c6f545f78455b7c/wheel-0.47.0-py3-none-any.whl", hash = "sha256:212281cab4dff978f6cedd499cd893e1f620791ca6ff7107cf270781e587eced", size = 32218, upload-time = "2026-04-22T15:51:26.296Z" }, +] + +[[package]] +name = "widgetsnbextension" +version = "4.0.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/f4/c67440c7fb409a71b7404b7aefcd7569a9c0d6bd071299bf4198ae7a5d95/widgetsnbextension-4.0.15.tar.gz", hash = "sha256:de8610639996f1567952d763a5a41af8af37f2575a41f9852a38f947eb82a3b9", size = 1097402, upload-time = "2025-11-01T21:15:55.178Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/0e/fa3b193432cfc60c93b42f3be03365f5f909d2b3ea410295cf36df739e31/widgetsnbextension-4.0.15-py3-none-any.whl", hash = "sha256:8156704e4346a571d9ce73b84bee86a29906c9abfd7223b7228a28899ccf3366", size = 2196503, upload-time = "2025-11-01T21:15:53.565Z" }, +] + +[[package]] +name = "wrapt" +version = "2.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/a4/282c8e64300a59fc834518a54bf0afabb4ff9218b5fa76958b450459a844/wrapt-2.2.2.tar.gz", hash = "sha256:0788e321027c999bf221b667bd4a54aaefd1a36283749a860ac3eb77daed0302", size = 129068, upload-time = "2026-06-20T23:49:44.49Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/8b/59781d0fe7b0adfbea37f600857de4be68921e454aeecf1a11bda35cdccc/wrapt-2.2.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:055e6fcfaa28e58c6a8c247d48b92be9d56f818b7068aa4f22b15b3343a09931", size = 80556, upload-time = "2026-06-20T23:47:28.473Z" }, + { url = "https://files.pythonhosted.org/packages/94/dc/66c61aca927230c9cf97a3cb005c803971a1076ff9f7d61085d035c20085/wrapt-2.2.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8374eb6b1a58809211e84ff835a182bb17ab2807a5bfef23204c8cff38178a00", size = 81648, upload-time = "2026-06-20T23:47:30.504Z" }, + { url = "https://files.pythonhosted.org/packages/23/1b/545eee1c18f3af4cf140bb5822b6ef81ebe569df0a63ac109973103a30a5/wrapt-2.2.2-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:656593bb3f5529f03d27af4136c4d7b11990e470bcbc6fefa5ef218695bece55", size = 152956, upload-time = "2026-06-20T23:47:31.867Z" }, + { url = "https://files.pythonhosted.org/packages/44/a7/6f42a3d03e44dc612a5dcff324e7366075a7857f0be2d49a8cb8a68279b8/wrapt-2.2.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dfb00cb7bb22099e2f64b7340fb96113639aa7260c0972af3797ace2297b936c", size = 154771, upload-time = "2026-06-20T23:47:33.352Z" }, + { url = "https://files.pythonhosted.org/packages/bf/55/4d76175aaa97523c38f1d28f79d18ab41a1b116814158a818bc0eba00571/wrapt-2.2.2-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e7f10ee0bd53673bfd52b67cbce83336fe6cad90d2377b03baf66491d2bbfb91", size = 149460, upload-time = "2026-06-20T23:47:34.712Z" }, + { url = "https://files.pythonhosted.org/packages/84/9b/12e23264d8f4735e8483262f95c5a6b03c3665fd2a84bdf99a45b6a2f4ec/wrapt-2.2.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:4402f57c5f0d0579599858ffbdd9bf4e3f0972f51096f2bd6cc7dab6b76ee49e", size = 153648, upload-time = "2026-06-20T23:47:36.092Z" }, + { url = "https://files.pythonhosted.org/packages/d6/a3/bcd5ec37289dcd85ecd4d15395a6a6063d60bc45ff94a9d77814e1e54d64/wrapt-2.2.2-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:3a4eb7964ff4643d333c84f880bcf554652b2a1050aebc54ae696327f61acfaf", size = 148502, upload-time = "2026-06-20T23:47:37.623Z" }, + { url = "https://files.pythonhosted.org/packages/f2/be/716d708f607fa70f8a6eb47dff8ee945d5278dfc89ffeeff33039d052e63/wrapt-2.2.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e542b7c5af91e2123a8aabf19894319d5ec4268d2a9ffd2f239386133fc47746", size = 152238, upload-time = "2026-06-20T23:47:39.118Z" }, + { url = "https://files.pythonhosted.org/packages/b5/c0/1a48e7e54501274f5d906f18372221b13183b0afbb5b8bb4c7ca0392c0b4/wrapt-2.2.2-cp310-cp310-win32.whl", hash = "sha256:6e7e45b43d3c774d244fe7264378f5a3f0f383bc55a54a9866434e524540110f", size = 77278, upload-time = "2026-06-20T23:47:40.476Z" }, + { url = "https://files.pythonhosted.org/packages/b0/82/9cd69a1af288fbdedf01a10e3c8a0b6890b08c7f3f96d36a213699dbcd94/wrapt-2.2.2-cp310-cp310-win_amd64.whl", hash = "sha256:955f1d6e72a352e478de8d8b503abe301c5e139a141b62eb0923bd694995025f", size = 80131, upload-time = "2026-06-20T23:47:41.785Z" }, + { url = "https://files.pythonhosted.org/packages/7f/73/8db7e27daef37ae70a53ea62bef7fe80cc51a8b5e9e9181a8be6eb9a999c/wrapt-2.2.2-cp310-cp310-win_arm64.whl", hash = "sha256:b89d8d73c82db2bb7e6090b3afd7973f980d24e905cc34394eab60b884b3bf67", size = 79615, upload-time = "2026-06-20T23:47:43.109Z" }, + { url = "https://files.pythonhosted.org/packages/27/15/0c2d55168707465abfc41f33c0b23d792a5fa9b65c26983606940900a120/wrapt-2.2.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f1a2ff355ece6a111ca7a20dc86df6659c9205d3fcee674ca34f2a2854fd4e73", size = 80782, upload-time = "2026-06-20T23:47:44.367Z" }, + { url = "https://files.pythonhosted.org/packages/7d/b5/5c0b093eb48f8a062ef6267d3cb36e9bb1b88440181f6545a383c60efdf8/wrapt-2.2.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:55b9a899e6fff5444f229d30aa6e9ac92d2216d9d60f33c771b5d76a760d5f8e", size = 81678, upload-time = "2026-06-20T23:47:45.857Z" }, + { url = "https://files.pythonhosted.org/packages/34/f3/de70937472dd3e8a4e6811192f9c6075efdffd4a2cd9b4596bf160f89668/wrapt-2.2.2-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a2d78c363f97d8bd718ee40432c66395685e9e98528ccaa423c3355d1715a26d", size = 159671, upload-time = "2026-06-20T23:47:47.345Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ec/40aed2330e7f02ecf74386ffcfef9ccb7108c6a430f15b6a252b663b1bed/wrapt-2.2.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d619e1eed9bd4f6ed9f24cd61971aa086fa86505289628d464bcf8a2c2e3f328", size = 160785, upload-time = "2026-06-20T23:47:48.759Z" }, + { url = "https://files.pythonhosted.org/packages/45/04/aa5309beed5344b00220ae6b3b24055852192656194c27947bee1736306a/wrapt-2.2.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:518b0c5e323511ec56a38894802ddd5e1222626484e68efe63f201854ad788e5", size = 153699, upload-time = "2026-06-20T23:47:50.177Z" }, + { url = "https://files.pythonhosted.org/packages/01/df/2def7e99d1fe87eea413f95f671924cdddcb08823b1ffd212748dfa6d062/wrapt-2.2.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4bccea5cdecffa9dd70e343741f0e41e0a16619313d04b72f78bb525162ebcd0", size = 159695, upload-time = "2026-06-20T23:47:51.602Z" }, + { url = "https://files.pythonhosted.org/packages/c7/f6/a906d01a2ce12157bad2404957b3e2140da354b8a70b2fa48bbf282871c0/wrapt-2.2.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:209112cafd963710a05d199aae431d79a28bc76eb8e6d1bbbb8ad24340722cae", size = 152813, upload-time = "2026-06-20T23:47:53.03Z" }, + { url = "https://files.pythonhosted.org/packages/02/49/bc0086292d239575b4c08f4cf8a4079fa58abbad58ec23abf84833a283ed/wrapt-2.2.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e5a5290e4bf2f332fc29ce72ffb9a2fff678aaac047e2e9f5f7165cd7792e099", size = 158809, upload-time = "2026-06-20T23:47:54.391Z" }, + { url = "https://files.pythonhosted.org/packages/55/83/8fbd034de1f3e907edaa18786d5dd8f6932874edee0826c7cecb5cab03a1/wrapt-2.2.2-cp311-cp311-win32.whl", hash = "sha256:5499236ad1dc116012e2a5dd943f3f31af12fce452128e2bbcbd55a7d3d4d14c", size = 77414, upload-time = "2026-06-20T23:47:55.882Z" }, + { url = "https://files.pythonhosted.org/packages/7e/9c/23695baa331c6de4e874c3d78b8e0bed92e1d2a274e665b29858f6841672/wrapt-2.2.2-cp311-cp311-win_amd64.whl", hash = "sha256:8636809939152be6ae20a6cef0fed9fe60f411b47847d0426a826884b469e971", size = 80368, upload-time = "2026-06-20T23:47:57.237Z" }, + { url = "https://files.pythonhosted.org/packages/08/49/40cefc342bf89b234a4490d741290fce781774b831aefb39c25471da96c9/wrapt-2.2.2-cp311-cp311-win_arm64.whl", hash = "sha256:5d0a142f7af07caeb5e5da87493162a7b8efa19ba919e550a746f7446e13fb30", size = 79489, upload-time = "2026-06-20T23:47:58.56Z" }, + { url = "https://files.pythonhosted.org/packages/2a/85/180b40628b23772692a0c76e8030114e1c0ae068470ed531919f0a5f2a4a/wrapt-2.2.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8417fd3c674d3c8023d080292d29301531a12daf8bd938dd419710dd2f464f2b", size = 81484, upload-time = "2026-06-20T23:47:59.924Z" }, + { url = "https://files.pythonhosted.org/packages/94/f2/21c90f2a16689702e2aaff45795b11018dff2c9b1242bac10d225483f676/wrapt-2.2.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0e7070c7472582e31af3dfc2622b2381a0df7435110a9388ed8db5ffbce67efb", size = 82151, upload-time = "2026-06-20T23:48:01.303Z" }, + { url = "https://files.pythonhosted.org/packages/5f/b3/7e6e9fcf4fe7e1b69a49fe6cc5a44e8224bab6283c5233c97e132f14908e/wrapt-2.2.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2e096c9d39a59b35b63c9aacfbbbec2088ff51ff1fc31051acc60a07f42f273a", size = 169828, upload-time = "2026-06-20T23:48:02.719Z" }, + { url = "https://files.pythonhosted.org/packages/0b/43/894f132d857ed5a9904d937baf368badcbe5ea9e436e2f1930fe21c9f1f0/wrapt-2.2.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d1a6050405bf334be33bf66296f113563622972a34900ae6fa60fd283a1a900", size = 171544, upload-time = "2026-06-20T23:48:04.266Z" }, + { url = "https://files.pythonhosted.org/packages/29/de/3c833e03725b477e9ea34028224dd21a48781830101e4e036f77e8b6b102/wrapt-2.2.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:10adb01371408c6de504a6658b9886480f1a4919a83752748a387a504a21df79", size = 160663, upload-time = "2026-06-20T23:48:05.708Z" }, + { url = "https://files.pythonhosted.org/packages/33/be/27edce350b24e3054d9d047f65f16d4c4d4c1f3f31c4278a1f8a95c723c8/wrapt-2.2.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3442eee2a5798f9b451f1b2cd7518ce8b7e28a2a364696c414460a0e295c012a", size = 169387, upload-time = "2026-06-20T23:48:07.243Z" }, + { url = "https://files.pythonhosted.org/packages/e2/c4/9fd9679af8bf38e146652c7f47b6b352c3e5795b4ad1c0b7f94e15ac2aa7/wrapt-2.2.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:6c99012a22f735a85eed7c4b86a3e99c30fdd57d9e115b2b45f796264b58d0bf", size = 158849, upload-time = "2026-06-20T23:48:08.91Z" }, + { url = "https://files.pythonhosted.org/packages/bc/c2/aa6c0c2206803068c6859dabe01f8c84c43744da93d4c67b8946d21655ee/wrapt-2.2.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3b686cfc008776a3952d6213cb296ed7f45d782a8453936406faa89eac0835ab", size = 168147, upload-time = "2026-06-20T23:48:10.374Z" }, + { url = "https://files.pythonhosted.org/packages/42/63/3eb25da41049d20ae18fcab2dd8b056e02387c4bfa626cbdfb7c3b872e4f/wrapt-2.2.2-cp312-cp312-win32.whl", hash = "sha256:ef2cce266b5b0b07e19fa82e59673b81142b7a3607c8ed1254113d048ed668da", size = 77734, upload-time = "2026-06-20T23:48:11.769Z" }, + { url = "https://files.pythonhosted.org/packages/da/09/0390e008a305360948fa9ce69507d041ac12cb2ee5d28e34467e2ee79391/wrapt-2.2.2-cp312-cp312-win_amd64.whl", hash = "sha256:abf8c20a2d72ee69e16328b3c91342c446e723bfe48bfcc4dded3b9722ac027f", size = 80585, upload-time = "2026-06-20T23:48:13.117Z" }, + { url = "https://files.pythonhosted.org/packages/d3/b3/84c445c66969f2d3457276b183a48c91097d59bbef9af6c075366b0f8c36/wrapt-2.2.2-cp312-cp312-win_arm64.whl", hash = "sha256:c6c64c5d02578bc4c4bca4f0aef1504de933c1d5b4ac2710b9131111459506c8", size = 79553, upload-time = "2026-06-20T23:48:14.5Z" }, + { url = "https://files.pythonhosted.org/packages/43/fc/f32f4b22c6511173c11d9e541ab4e7d8467a0f1b3455acaf784115d31ff8/wrapt-2.2.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9e8b648270c613720a202d9a45ebabc33261b22c3a839b115ac5bce8c0bb0d69", size = 81296, upload-time = "2026-06-20T23:48:15.881Z" }, + { url = "https://files.pythonhosted.org/packages/72/06/4d117d5d77a9344776c0248b24dae3d3dd2f58e5f765fa08cf887072e719/wrapt-2.2.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6fb7e94e8fe3e4c3067bb1653a91cce7c5e83acc119fdd41501b1bf74654617", size = 81841, upload-time = "2026-06-20T23:48:17.262Z" }, + { url = "https://files.pythonhosted.org/packages/15/ff/63ad96f98eb58a742b1a20d80f21da88924405910149950b912368150468/wrapt-2.2.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb18fc51e813df0d9c98049e3bf2298a5495a648602040e21fa3c7329371159e", size = 167882, upload-time = "2026-06-20T23:48:18.764Z" }, + { url = "https://files.pythonhosted.org/packages/20/1f/8bb62d8933df7acf3247194e6e9fc68edf9d2fa203252c89c94b319dd472/wrapt-2.2.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:94b00b00f806eb3ef2abe9049ed45994a81ee9284884d96e6b8314927c6cea3d", size = 167411, upload-time = "2026-06-20T23:48:20.315Z" }, + { url = "https://files.pythonhosted.org/packages/17/09/8789dcb09ee1de715727db7521aabbb68ffa68dfade3a49468440cfced49/wrapt-2.2.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:62415fd095bc590b842b6d092f2b5d9ccbaeb7e0b28535c03dcea2718b48636b", size = 158607, upload-time = "2026-06-20T23:48:21.728Z" }, + { url = "https://files.pythonhosted.org/packages/9c/20/66e02562d53ee67d841f175e38e3c993c2d78a3e104c576cad61c028b43c/wrapt-2.2.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a41e758d80dc0ab8c210f641ac892009d356cf1f955d97db544c8dd317b4d14c", size = 166367, upload-time = "2026-06-20T23:48:23.177Z" }, + { url = "https://files.pythonhosted.org/packages/bd/a3/832ac4e41222fb263b3042d42c2f08d305db7d0f0c9b1d3a271a9eede8f6/wrapt-2.2.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:b84cd4058001c9727b0e9980b7a9e66325b5ca748b1b578e822cade1bc6b304f", size = 157176, upload-time = "2026-06-20T23:48:24.711Z" }, + { url = "https://files.pythonhosted.org/packages/b7/01/1bd5e4d2df9c0178989ac8da9186543465388588ee2ef153e2591accebef/wrapt-2.2.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:26fc73a1b15e0946d2942b9a4426d162b51676338327dc067ccd8d2d76385f94", size = 167025, upload-time = "2026-06-20T23:48:26.118Z" }, + { url = "https://files.pythonhosted.org/packages/1c/69/583ed25291ab53e1ec117135fb1c33425e2f46d2bc8f29c17f7a94cf4274/wrapt-2.2.2-cp313-cp313-win32.whl", hash = "sha256:3c4095803491f6ef72128914c28ec05bbad9758433bb35f6715a3e9c8e46fb2d", size = 77605, upload-time = "2026-06-20T23:48:27.643Z" }, + { url = "https://files.pythonhosted.org/packages/29/68/e69fc6d06e1523c68e0d00f95c9aed1158ce9908ee41603f7f2eae3d5db6/wrapt-2.2.2-cp313-cp313-win_amd64.whl", hash = "sha256:2cb07f414fab25dbe6b5c7398e1491423a5c81a6209533639969a6c928d474a4", size = 80508, upload-time = "2026-06-20T23:48:29.013Z" }, + { url = "https://files.pythonhosted.org/packages/55/21/fe7a393d9e5dc0923bed8f5d857e9dcff210f1fa0888c02cc8f3ffaa55aa/wrapt-2.2.2-cp313-cp313-win_arm64.whl", hash = "sha256:1fc7691f070220215cccb2a20836b9adbaecb8ff22ad47abe63de5f110994fac", size = 79565, upload-time = "2026-06-20T23:48:30.429Z" }, + { url = "https://files.pythonhosted.org/packages/b6/e5/c120d13bf5091164f68c3c1657e84f16f57e71d978421b626393ac5bd7eb/wrapt-2.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:ec8f83949028366531383603139403cac7a826e4011955813cdd640017845ce5", size = 83264, upload-time = "2026-06-20T23:48:31.807Z" }, + { url = "https://files.pythonhosted.org/packages/d3/b0/d4a1eb97e0e286625bdf21bc7f702637f9607787ffbbdb5ec14d50c79dbf/wrapt-2.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4b481fb0c40d9fd90a5809911208da700987d373a20a4709dc9e3944af7a6bec", size = 83791, upload-time = "2026-06-20T23:48:33.482Z" }, + { url = "https://files.pythonhosted.org/packages/18/1e/f060df47755e87b57684cee7bfc1362b204df55fac96ffebc0631b697b79/wrapt-2.2.2-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0065a3b657cec06813b4241d2462ccec287f6863103d7445b725fb3a889736f9", size = 203399, upload-time = "2026-06-20T23:48:34.97Z" }, + { url = "https://files.pythonhosted.org/packages/c4/de/2316a757a1abb6453700b79d83e532146dcef2611348282d4d8889792161/wrapt-2.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:30f7424af5c5c345b7f26490e097f74a2ef45b3d08b664dc33571aee3bd3b56c", size = 210461, upload-time = "2026-06-20T23:48:36.569Z" }, + { url = "https://files.pythonhosted.org/packages/ed/29/d1160785ae18ca2495a6d82a21154103d74f656c9fd457fb35f6b11b965a/wrapt-2.2.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:07fdcb012821859168641acf68afad61ef9783cf37100af85f152550e9677194", size = 195313, upload-time = "2026-06-20T23:48:38.175Z" }, + { url = "https://files.pythonhosted.org/packages/f5/2d/7caa9598ae61a9cf0989cc501739cbeeb7d650ab3193cca1407b9af0c6ab/wrapt-2.2.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:f90038ab58fafb584801ca62d72384d7d5225d93c76f7b773c22fae545bd8066", size = 206116, upload-time = "2026-06-20T23:48:39.804Z" }, + { url = "https://files.pythonhosted.org/packages/ac/02/281ea1088b8650d865f311b35cf86fd21df89128e2909714f1161e01c9d0/wrapt-2.2.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:c5d7825491bfa2d08b97e9557768987952c7b9ae687d06c3320b40a37ccb7f20", size = 192668, upload-time = "2026-06-20T23:48:41.346Z" }, + { url = "https://files.pythonhosted.org/packages/be/7d/976e2d5b4b5c5babda40974edd54d0a5585cb60132ed86b46f4b80239b16/wrapt-2.2.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:0ad520e6daa9bbf136f14de735474dbec7dcc0891f718e1d274ce8dc92e645af", size = 198891, upload-time = "2026-06-20T23:48:43.056Z" }, + { url = "https://files.pythonhosted.org/packages/59/b7/e47651797c097f75a37e2ce86dcf04048ff576f3a674f7c558df7b5e9622/wrapt-2.2.2-cp313-cp313t-win32.whl", hash = "sha256:25904acb9475f46c24fe0423dbc8fda8cc5fbc282ab3dc6e72e919748c53f4e9", size = 78537, upload-time = "2026-06-20T23:48:44.509Z" }, + { url = "https://files.pythonhosted.org/packages/d1/6f/9fa5d59fb06d890defb5a8f727ce6a14d2932c8760153f96956628559fee/wrapt-2.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:305d4c247d61c4115794a169141823c62f719525ddb90b23aa332741c77d2c28", size = 82005, upload-time = "2026-06-20T23:48:46.391Z" }, + { url = "https://files.pythonhosted.org/packages/15/80/4c7bd9873d1f9f7d138d93556b500469dbe24f42710b877519c2b9eb380d/wrapt-2.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:c20279cd1a29800815d7b2d6338b60a6c6e78263f9d6e62e0eda251ba9cae2d0", size = 80762, upload-time = "2026-06-20T23:48:47.964Z" }, + { url = "https://files.pythonhosted.org/packages/24/05/7fd9c3f83b2c74cbfc572a0b88aa37431e04bd8aed70d2c0efd3464206de/wrapt-2.2.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:0e64826f920c42d9d9f87e8cc09ffae66c51ede12d59061a5a426deb9aa71745", size = 81341, upload-time = "2026-06-20T23:48:49.39Z" }, + { url = "https://files.pythonhosted.org/packages/4b/68/1bfa43100dd90d4ef74a05897b86275cf57e1313ca14aae2545bc9f872c9/wrapt-2.2.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:dcaa5e1451bd8751d7bd1568dfa3321c78092a52a7ecb5d1a0f18a5791e1fd00", size = 81921, upload-time = "2026-06-20T23:48:50.986Z" }, + { url = "https://files.pythonhosted.org/packages/74/eb/df7b7f0b631dbbc750f39be27d8b55f65777d8ac86da80e12be41a644c4b/wrapt-2.2.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0abfd648dac9ac9c5b3aa9b523d27f1789046640b58dcd5652a720ddb325e1fc", size = 167713, upload-time = "2026-06-20T23:48:52.598Z" }, + { url = "https://files.pythonhosted.org/packages/4d/9a/d1bd36f6d088c8e652a9383cabbd49af30b8c576302a7eccddbab6963e3f/wrapt-2.2.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f4bfd8d1eb438153eff8b8cfe87f032ba65731e1ce06138b5090f745a33f6f95", size = 166779, upload-time = "2026-06-20T23:48:54.33Z" }, + { url = "https://files.pythonhosted.org/packages/4c/ae/24ffacd4187fac2740a1972093929e836dea092d42c87d728cd98fee11a6/wrapt-2.2.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c427c9d06d859848a69f0d928fe28b5c33a941b2265d10a0e1f15cd244f1ee33", size = 158407, upload-time = "2026-06-20T23:48:55.944Z" }, + { url = "https://files.pythonhosted.org/packages/a3/ed/974427668249a356051e8d67d47fa54ef6c777f0fcf3bae9d292c047d4b6/wrapt-2.2.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4250b43d1a129d947e083c4dc6baf333c9bb34edd26f912d5b0457841fc858ab", size = 166594, upload-time = "2026-06-20T23:48:57.617Z" }, + { url = "https://files.pythonhosted.org/packages/fb/5f/e1d7c6e4523f78db2fbd7826babd0348da1d5e0834c4f918b9ab5757dfae/wrapt-2.2.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:173e5bb5ca350a6e0abab60b7ec7cdd7992a814cb14b4de670a28f067f105663", size = 157068, upload-time = "2026-06-20T23:48:59.171Z" }, + { url = "https://files.pythonhosted.org/packages/1e/c1/7ebd1027f00700c0b0233b20aceef2b4784294ed64971424c4a78e069e34/wrapt-2.2.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:aa14b01804bce36c6d63d7b6a4f55df390f29f8648cc13a1f40b166f4d54680d", size = 166470, upload-time = "2026-06-20T23:49:00.737Z" }, + { url = "https://files.pythonhosted.org/packages/99/eb/974e471a6a978b8180186b8a9dc5ae3361ce269a967190b709b8ce17abfb/wrapt-2.2.2-cp314-cp314-win32.whl", hash = "sha256:58f9f8d637c9a6e245c6ef5b109b67ec187d2faed23d1405656b51d96e0a5b56", size = 78062, upload-time = "2026-06-20T23:49:02.327Z" }, + { url = "https://files.pythonhosted.org/packages/49/ec/e1281156cdc7a66693838ad7a0865ad641c74abd337a957d668b575aaffb/wrapt-2.2.2-cp314-cp314-win_amd64.whl", hash = "sha256:385cb1866f20479e83299af585375bfa0a4b0c6c9907a981483ea782ea8ae406", size = 80832, upload-time = "2026-06-20T23:49:03.837Z" }, + { url = "https://files.pythonhosted.org/packages/45/7d/1b6b5ddd94005a2dac97a4490c9838f3154977850d633abcb65b30089437/wrapt-2.2.2-cp314-cp314-win_arm64.whl", hash = "sha256:8ffbeaea6771a6eba6e6eeb09767864995726bc8240bb54baf88a9bb1db34d5c", size = 80029, upload-time = "2026-06-20T23:49:05.237Z" }, + { url = "https://files.pythonhosted.org/packages/b0/33/9ebcf8aafe91c601127cbd93708c16aa8f688f34a10bf004046803ecdc4f/wrapt-2.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:09f811d43f6f33ec7515f0be76b159569f4057ab54d3e079c3204dddb90afa2a", size = 83357, upload-time = "2026-06-20T23:49:06.632Z" }, + { url = "https://files.pythonhosted.org/packages/39/38/ec45b635153327b52e52732a0ea980e5f00b7efba65f9e018828f1e69daa/wrapt-2.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a795d3c06e5fbf9ea2f13196180b77aeab1b4685917256ee0d014cc163d90063", size = 83794, upload-time = "2026-06-20T23:49:08.098Z" }, + { url = "https://files.pythonhosted.org/packages/4e/ea/1a89e6d3b7a83c3affe5c09cde77792c947e63e4bc85ad84cd5bb9abb0d8/wrapt-2.2.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:45c2f2768e790c9f8db90f239ef23a2af8e7570f25a35619ef902df4a738447f", size = 203362, upload-time = "2026-06-20T23:49:09.811Z" }, + { url = "https://files.pythonhosted.org/packages/19/d8/3b58763d9863b5a73771c0d97110f9595d248db454009e07e1535ee905a4/wrapt-2.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bbf00ee0cb55ec24e2b0995a71942b85b21a066db8f3f46e1dbfdb9433ffba81", size = 210449, upload-time = "2026-06-20T23:49:11.521Z" }, + { url = "https://files.pythonhosted.org/packages/2d/6f/17fd9e053103d8be148d20d5d7505facc72d5fe1f9127973904ceaed79cf/wrapt-2.2.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2252f77663651b89255895f58cc6ac08fcb206d4371813e5af61bb62d4f7689c", size = 195349, upload-time = "2026-06-20T23:49:13.346Z" }, + { url = "https://files.pythonhosted.org/packages/ef/04/d0d1ccaaa12cb7dccf28a23f0279a608ba498f71e81d949d5ed54bcfd5c1/wrapt-2.2.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2cd7181ab1c31192ff5219269830744b5a62020b3a6d433588c4f1c95b8f8bff", size = 206099, upload-time = "2026-06-20T23:49:15.051Z" }, + { url = "https://files.pythonhosted.org/packages/44/b3/e8aa07b619890a2aa6cde1931b1887abb08820721b564a5f80b7ca3f3aa0/wrapt-2.2.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:6fe35fd51b74867d8b80174c277bd6bbf6a73e443f908129dc531c4b688a20d5", size = 192728, upload-time = "2026-06-20T23:49:16.854Z" }, + { url = "https://files.pythonhosted.org/packages/b7/f0/1819fb50f0d3c9bd758d8a83b56f1b470dee8b5b8eac8702b7c137cea9d4/wrapt-2.2.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:11d95fc2fbad3163596c39d440e6f21ca9fccece74b56e30a37ac2fca786a07c", size = 198842, upload-time = "2026-06-20T23:49:18.504Z" }, + { url = "https://files.pythonhosted.org/packages/67/7c/e88313f16a99930b899ef970d91c281544a470749a359decad994483bbda/wrapt-2.2.2-cp314-cp314t-win32.whl", hash = "sha256:d8a15813215f33fa83667bfc978b300e35669ea8bb424e970a1426bcb7bc6cca", size = 79059, upload-time = "2026-06-20T23:49:20.107Z" }, + { url = "https://files.pythonhosted.org/packages/a0/4f/ac12fda57a55068a094ec42851fb0a40e8489d8941863d517452de62e507/wrapt-2.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:d09db0f7e8357060d3c38fc22a018aba683a796bf184360fd1a58f6fc180dc77", size = 82462, upload-time = "2026-06-20T23:49:21.631Z" }, + { url = "https://files.pythonhosted.org/packages/48/a7/df732dac86d9b2027c56bd163dbc883e037b16c3469614752e148d219c61/wrapt-2.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:f32fe639c39561ccc187bcae17e9271be0eb45f1c2952510d2f29b33ab577347", size = 81182, upload-time = "2026-06-20T23:49:23.199Z" }, + { url = "https://files.pythonhosted.org/packages/6e/d2/6317eb6d4554855bbf12d61857774af34747bf88a42c19bf306de67e2fa3/wrapt-2.2.2-py3-none-any.whl", hash = "sha256:5bad217350f19ce99ca5b5e71d406765ea86fe541628426772b657375ee1c048", size = 61460, upload-time = "2026-06-20T23:49:42.966Z" }, +] + +[[package]] +name = "xarray" +version = "2025.6.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +dependencies = [ + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } }, + { name = "packaging" }, + { name = "pandas", version = "2.3.3", source = { registry = "https://pypi.org/simple" } }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/ec/e50d833518f10b0c24feb184b209bb6856f25b919ba8c1f89678b930b1cd/xarray-2025.6.1.tar.gz", hash = "sha256:a84f3f07544634a130d7dc615ae44175419f4c77957a7255161ed99c69c7c8b0", size = 3003185, upload-time = "2025-06-12T03:04:09.099Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/8a/6b50c1dd2260d407c1a499d47cf829f59f07007e0dcebafdabb24d1d26a5/xarray-2025.6.1-py3-none-any.whl", hash = "sha256:8b988b47f67a383bdc3b04c5db475cd165e580134c1f1943d52aee4a9c97651b", size = 1314739, upload-time = "2025-06-12T03:04:06.708Z" }, +] + +[[package]] +name = "xarray" +version = "2026.7.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.15' and sys_platform == 'win32'", + "python_full_version >= '3.15' and sys_platform == 'emscripten'", + "python_full_version >= '3.15' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.14.*' and sys_platform == 'win32'", + "python_full_version == '3.14.*' and sys_platform == 'emscripten'", + "python_full_version == '3.14.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version == '3.11.*' and sys_platform == 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'emscripten'", + "python_full_version == '3.11.*' and sys_platform == 'emscripten'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.11.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] +dependencies = [ + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" } }, + { name = "packaging" }, + { name = "pandas", version = "3.0.5", source = { registry = "https://pypi.org/simple" } }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ea/96/3f7bdd00e505ec3698903415b30135024703e017b28c6b61f98da3193b0d/xarray-2026.7.0.tar.gz", hash = "sha256:361b495928fdbf5b58d0969bb6775339019da5e93ca74d61ddf4eb5edd6ce604", size = 3145348, upload-time = "2026-07-09T17:38:26.515Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/57/5b/28365212062939d213802e5e5fe855cdb231e1c2a93254ba1690066504e3/xarray-2026.7.0-py3-none-any.whl", hash = "sha256:bf9dd130b93806dc78e90c1b7ac24851b31557b888674c53622235691cf21824", size = 1426778, upload-time = "2026-07-09T17:38:24.224Z" }, +] + +[[package]] +name = "zipp" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b9/d8/eab98a517c14134c0b2eb4e2387bc5f457334293ec5d2dd3857ec2966802/zipp-4.1.0.tar.gz", hash = "sha256:4cb57381f544315db7688e976e922a2b18cdb513d21cc194eb42232ba2a3e602", size = 26214, upload-time = "2026-05-18T20:08:57.967Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/13/547360d81e6d88d58492968ffda9f9542854f11310ee556fef14260cc886/zipp-4.1.0-py3-none-any.whl", hash = "sha256:25ad4e16390cd314347dd8f1de67a2ac538ae658ed4ab9db16029c07c188e97f", size = 10238, upload-time = "2026-05-18T20:08:57.045Z" }, +]