diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..06c33f7 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2025 Ed Cuss and any other contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md index b46d5af..e0a3aed 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,109 @@ # io-adapters +A small utility library for decoupling I/O from business logic by combining +dependency injection with lightweight, automatically generated fakes. + +### Install +```shell +uv add io-adapters +``` + ### API Reference [io-adapters API docs](https://second-ed.github.io/io-adapters/) -Testing use cases that involve I/O is inherently difficult because they depend on external state and side effects. However, combining Dependency Injection (DI) with the Repository pattern significantly reduces this complexity. +Testing use cases that involve I/O is inherently difficult because they depend on: + +- external state (filesystems, databases, services) + +- side effects that are hard to observe directly + +- slow or flaky infrastructure + +A common mitigation is to combine: + +- Dependency Injection (DI) + +- The Repository / Adapter pattern + +This allows business logic to depend on an abstract interface rather than concrete I/O. + +However, in practice this usually requires: + +- writing and maintaining bespoke fake implementations + +- keeping fake behaviour in sync with real implementations + +- duplicating boilerplate across domains + +For small or medium-sized projects, this overhead can outweigh the benefits. + + Simply register each I/O function with one of the register decorators and the functionality will be added to the `RealAdapter` object, on top of that a stub will be added to the `FakeAdapter` object too so you can pass in either to your usecase and the functionality will work. + +### Example + +```python +from enum import Enum +from pathlib import Path -By substituting real I/O implementations with fakes that simulate their behaviour, stateful interactions can be captured entirely in memory. This allows changes to the external world to be accumulated deterministically and the final state to be asserted directly, without relying on real filesystems, networks, or services. +from io_adapters import ( + IoAdapter, + RealAdapter, + add_domain, + get_fake_adapter, + get_real_adapter, + register_domain_read_fn, + register_domain_write_fn, +) -The result is faster, more reliable tests that focus on behaviour rather than infrastructure. -However, creating these fakes can be time consuming and result in a maintenance burden that may not outweigh the benefit. +# you can use any hashable object to register an I/O function +class FileFormat(Enum): + JSON = "json" + + +add_domain("orders") +add_domain("payment") + + +@register_domain_read_fn("orders", "str") +def read_str(path: str | Path, **kwargs: dict) -> str: ... + + +# stack decorators to register the same function to multiple domains +@register_domain_read_fn("orders", FileFormat.JSON) +@register_domain_read_fn("payment", FileFormat.JSON) +def read_json(path: str | Path, **kwargs: dict) -> dict: ... + + +@register_domain_write_fn("orders", "str") +def write_str(data: dict, path: str | Path, **kwargs: dict) -> None: ... + + +@register_domain_write_fn("orders", FileFormat.JSON) +@register_domain_write_fn("payment", FileFormat.JSON) +def write_json(data: dict, path: str | Path, **kwargs: dict) -> None: ... + + +def some_usecase(adapter: IoAdapter, path: str) -> None: + adapter.read(path, "str") + # Some business logic + new_path = f"{path}_new.json" + + adapter.write({"a": 1}, new_path, FileFormat.JSON) + + +# in production inject the real adapter +orders_adapter: RealAdapter = get_real_adapter("orders") +some_usecase(orders_adapter, "some/path/to/file.json") + + +# in testing inject the fake which has all the same funcitonality as the +# `RealAdapter` and assert that the fakes end state is as expected +fake = get_fake_adapter("orders") +some_usecase(fake, "some/path/to/file.json") +assert fake.files["some/path/to/file.json"] == {"a": 1} +``` -This is where `io-adapters` can help. Simply register each I/O function with one of the register decorators and the functionality will be added to the `RealAdapter` object, on top of that a stub will be added to the `FakeAdapter` object too so you can pass in either to your usecase and the functionality will work. # Repo map ``` @@ -28,6 +120,7 @@ This is where `io-adapters` can help. Simply register each I/O function with one │ └── io_adapters │ ├── __init__.py │ ├── _adapters.py +│ ├── _clock.py │ ├── _container.py │ ├── _io_funcs.py │ └── _registries.py diff --git a/docs/Makefile b/docs/Makefile index a454791..61f71d7 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -24,6 +24,10 @@ deploy: fi @cd docs-build && git worktree add -f html gh-pages || true +local: + @uv run sphinx-apidoc -o source src/io_adapters/ --separate ; uv run sphinx-build source docs-build/html + + # Catch-all target: route all unknown targets to Sphinx using the new # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). %: Makefile diff --git a/docs/source/index.rst b/docs/source/index.rst index 181c50b..3f677c2 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -4,15 +4,100 @@ io-adapters documentation Motivation ---------- -Testing use cases that involve I/O is inherently difficult because they depend on external state and side effects. However, combining Dependency Injection (DI) with the Repository pattern significantly reduces this complexity. +Testing use cases that involve I/O is inherently difficult because they depend on: -By substituting real I/O implementations with fakes that simulate their behaviour, stateful interactions can be captured entirely in memory. This allows changes to the external world to be accumulated deterministically and the final state to be asserted directly, without relying on real filesystems, networks, or services. +- external state (filesystems, databases, services) -The result is faster, more reliable tests that focus on behaviour rather than infrastructure. +- side effects that are hard to observe directly -However, creating these fakes can be time consuming and result in a maintenance burden that may not outweigh the benefit. +- slow or flaky infrastructure + +A common mitigation is to combine: + +- Dependency Injection (DI) + +- The Repository / Adapter pattern + +This allows business logic to depend on an abstract interface rather than concrete I/O. + +However, in practice this usually requires: + +- writing and maintaining bespoke fake implementations + +- keeping fake behaviour in sync with real implementations + +- duplicating boilerplate across domains + +For small or medium-sized projects, this overhead can outweigh the benefits. + +Simply register each I/O function with one of the register decorators and the functionality will be added to the ``RealAdapter`` object, on top of that a stub will be added to the ``FakeAdapter`` object too so you can pass in either to your usecase and the functionality will work. + +Example +------- + +.. code-block:: python + + from enum import Enum + from pathlib import Path + + from io_adapters import ( + IoAdapter, + RealAdapter, + add_domain, + get_fake_adapter, + get_real_adapter, + register_domain_read_fn, + register_domain_write_fn, + ) + + + # you can use any hashable object to register an I/O function + class FileFormat(Enum): + JSON = "json" + + + add_domain("orders") + add_domain("payment") + + + @register_domain_read_fn("orders", "str") + def read_str(path: str | Path, **kwargs: dict) -> str: ... + + + # stack decorators to register the same function to multiple domains + @register_domain_read_fn("orders", FileFormat.JSON) + @register_domain_read_fn("payment", FileFormat.JSON) + def read_json(path: str | Path, **kwargs: dict) -> dict: ... + + + @register_domain_write_fn("orders", "str") + def write_str(data: dict, path: str | Path, **kwargs: dict) -> None: ... + + + @register_domain_write_fn("orders", FileFormat.JSON) + @register_domain_write_fn("payment", FileFormat.JSON) + def write_json(data: dict, path: str | Path, **kwargs: dict) -> None: ... + + + def some_usecase(adapter: IoAdapter, path: str) -> None: + adapter.read(path, "str") + # Some business logic + new_path = f"{path}_new.json" + + adapter.write({"a": 1}, new_path, FileFormat.JSON) + + + # in production inject the real adapter + orders_adapter: RealAdapter = get_real_adapter("orders") + some_usecase(orders_adapter, "some/path/to/file.json") + + + # in testing inject the fake which has all the same funcitonality as the + # `RealAdapter` and assert that the fakes end state is as expected + fake_adapter = get_fake_adapter("orders") + some_usecase(fake_adapter, "some/path/to/file.json") + assert fake_adapter.files["some/path/to/file.json"] == {"a": 1} -This is where ``io-adapters`` can help. Simply register each I/O function with one of the register decorators and the functionality will be added to the ``RealAdapter`` object, on top of that a stub will be added to the ``FakeAdapter`` object too so you can pass in either to your usecase and the functionality will work. .. automodule:: io_adapters :members: diff --git a/pyproject.toml b/pyproject.toml index 6db9869..7343421 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,11 +1,13 @@ [project] name = "io-adapters" version = "0.1.0" -description = "Add your description here" +description = "Dependency Injection Adapters" readme = "README.md" authors = [ { name = "ed cuss", email = "edcussmusic@gmail.com" } ] +license = "MIT" +license-files = ["LICENSE"] requires-python = ">=3.11" dependencies = [ "attrs>=25.4.0", @@ -24,4 +26,9 @@ dev = [ "repo-mapper-rs>=0.3.0", "ruff>=0.14.9", "sphinx>=9.0.4", + "ty>=0.0.9", ] + +[tool.ty.src] +include = ["src", "tests"] +exclude = ["src/io_adapters/_io_funcs.py"] \ No newline at end of file diff --git a/src/io_adapters/__init__.py b/src/io_adapters/__init__.py index 9e7e75f..6283b2f 100644 --- a/src/io_adapters/__init__.py +++ b/src/io_adapters/__init__.py @@ -8,6 +8,7 @@ register_domain_write_fn, ) from io_adapters._io_funcs import read_json, write_json # noqa: F401 +from io_adapters._registries import register_read_fn, register_write_fn __all__ = [ "Container", @@ -19,4 +20,6 @@ "get_real_adapter", "register_domain_read_fn", "register_domain_write_fn", + "register_read_fn", + "register_write_fn", ] diff --git a/src/io_adapters/_adapters.py b/src/io_adapters/_adapters.py index a04e5f5..e34e887 100644 --- a/src/io_adapters/_adapters.py +++ b/src/io_adapters/_adapters.py @@ -2,23 +2,22 @@ import datetime import logging -from abc import ABC, abstractmethod -from collections.abc import Hashable +from collections.abc import Callable, Hashable from pathlib import Path from types import MappingProxyType -from uuid import uuid4 import attrs -from attrs.validators import deep_mapping, instance_of, is_callable +from attrs.validators import deep_mapping, instance_of, is_callable, optional -from io_adapters._registries import READ_FNS, WRITE_FNS, Data, standardise_key +from io_adapters._clock import default_datetime, default_guid, fake_datetime, fake_guid +from io_adapters._registries import READ_FNS, WRITE_FNS, Data, ReadFn, WriteFn, standardise_key logger = logging.getLogger(__name__) @attrs.define -class IoAdapter(ABC): - read_fns: MappingProxyType = attrs.field( +class IoAdapter: + read_fns: MappingProxyType[Hashable, ReadFn] = attrs.field( default=READ_FNS, validator=[ deep_mapping( @@ -29,7 +28,7 @@ class IoAdapter(ABC): ], converter=MappingProxyType, ) - write_fns: MappingProxyType = attrs.field( + write_fns: MappingProxyType[Hashable, WriteFn] = attrs.field( default=WRITE_FNS, validator=[ deep_mapping( @@ -40,8 +39,12 @@ class IoAdapter(ABC): ], converter=MappingProxyType, ) + guid_fn: Callable[[], str] = attrs.field(default=None, validator=optional(is_callable())) + datetime_fn: Callable[[], datetime.datetime] = attrs.field( + default=None, validator=optional(is_callable()) + ) - def read(self, path: str | Path, file_type: str, **kwargs: dict) -> Data: + def read(self, path: str | Path, file_type: Hashable, **kwargs: dict) -> Data: """Read `path` using the registered function for `file_type`. Raises: @@ -77,7 +80,7 @@ def read_json(path: str | Path, **kwargs: dict) -> dict: raise NotImplementedError(msg) return self.read_fns[file_type](path, **kwargs) - def write(self, data: Data, path: str | Path, file_type: str, **kwargs: dict) -> None: + def write(self, data: Data, path: str | Path, file_type: Hashable, **kwargs: dict) -> None: """Write `data` to `path` using the registered function for `file_type`. Raises: @@ -118,7 +121,7 @@ def write_json(data: dict, path: str | Path, **kwargs: dict) -> None: def some_usecase(adapter: IoAdapter, path: str) -> None: # Some business logic - adapter.write({"a": 1}, , WriteFormat.JSON) + adapter.write({"a": 1}, path, WriteFormat.JSON) # in production inject the real adapter some_usecase(RealAdapter(), "some/path/to/file.json") @@ -138,20 +141,18 @@ def some_usecase(adapter: IoAdapter, path: str) -> None: raise NotImplementedError(msg) return self.write_fns[file_type](data, path, **kwargs) - @abstractmethod - def get_guid(self) -> str: ... + def get_guid(self) -> str: + return self.guid_fn() - @abstractmethod - def get_datetime(self) -> datetime.datetime: ... + def get_datetime(self) -> datetime.datetime: + return self.datetime_fn() @attrs.define class RealAdapter(IoAdapter): - def get_guid(self) -> str: - return str(uuid4()) - - def get_datetime(self) -> datetime.datetime: - return datetime.datetime.now(datetime.UTC) + def __attrs_post_init__(self) -> None: + self.guid_fn = self.guid_fn or default_guid + self.datetime_fn = self.datetime_fn or default_datetime @attrs.define @@ -161,6 +162,8 @@ class FakeAdapter(IoAdapter): def __attrs_post_init__(self) -> None: self.read_fns = MappingProxyType(dict.fromkeys(self.read_fns.keys(), self._read_fn)) self.write_fns = MappingProxyType(dict.fromkeys(self.write_fns.keys(), self._write_fn)) + self.guid_fn = self.guid_fn or fake_guid + self.datetime_fn = self.datetime_fn or fake_datetime def _read_fn(self, path: str) -> Data: try: @@ -172,7 +175,7 @@ def _write_fn(self, data: Data, path: str) -> None: self.files[str(path)] = data def get_guid(self) -> str: - return "abc-123" + return self.guid_fn() def get_datetime(self) -> datetime.datetime: - return datetime.datetime(2025, 1, 1, 12, tzinfo=datetime.UTC) + return self.datetime_fn() diff --git a/src/io_adapters/_clock.py b/src/io_adapters/_clock.py new file mode 100644 index 0000000..1f95071 --- /dev/null +++ b/src/io_adapters/_clock.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +import datetime +from uuid import uuid4 + + +def default_guid() -> str: + return str(uuid4()) + + +def fake_guid() -> str: + return "abc-123" + + +def default_datetime() -> datetime.datetime: + return datetime.datetime.now(datetime.UTC) + + +def fake_datetime() -> datetime.datetime: + return datetime.datetime(2025, 1, 1, 12, tzinfo=datetime.UTC) diff --git a/src/io_adapters/_container.py b/src/io_adapters/_container.py index 3bec400..75b51ce 100644 --- a/src/io_adapters/_container.py +++ b/src/io_adapters/_container.py @@ -203,7 +203,7 @@ def get_real_adapter(self, domain: Hashable) -> RealAdapter: write_fns=self.domain_fns[domain][_FnType.WRITE], ) - def get_fake_adapter(self, domain: Hashable, files: dict | None = None) -> RealAdapter: + def get_fake_adapter(self, domain: Hashable, files: dict | None = None) -> FakeAdapter: """Get a ``FakeAdapter`` for the given domain. The returned adapter will have all of the functions registered to that domain. diff --git a/src/io_adapters/_io_funcs.py b/src/io_adapters/_io_funcs.py index 90695a4..20735ed 100644 --- a/src/io_adapters/_io_funcs.py +++ b/src/io_adapters/_io_funcs.py @@ -2,17 +2,18 @@ import json from pathlib import Path +from typing import Any from io_adapters._registries import register_read_fn, register_write_fn @register_read_fn("json") -def read_json(path: str | Path, **kwargs: dict) -> dict: +def read_json(path: str | Path, **kwargs: dict[str, Any]) -> dict: return json.loads(Path(path).read_text(), **kwargs) @register_write_fn("json") -def write_json(data: dict, path: str | Path, **kwargs: dict) -> None: +def write_json(data: dict, path: str | Path, **kwargs: dict[str, Any]) -> None: path = Path(path) path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(data, **kwargs)) diff --git a/src/io_adapters/_registries.py b/src/io_adapters/_registries.py index d9ef1b4..a445b5f 100644 --- a/src/io_adapters/_registries.py +++ b/src/io_adapters/_registries.py @@ -14,11 +14,26 @@ WriteFn = Callable[Concatenate[Data, str | Path, P], None] -READ_FNS: dict[str, ReadFn] = {} -WRITE_FNS: dict[str, WriteFn] = {} +READ_FNS: dict[Hashable, ReadFn] = {} +WRITE_FNS: dict[Hashable, WriteFn] = {} def register_read_fn(key: Hashable) -> Callable: + """Register a read function to the read functions constant. + + This is useful for smaller projects where domain isolation isn't required. + + .. code-block:: python + + from io_adapters import RealAdapter, register_read_fn + + @register_read_fn("json") + def read_json(path: str | Path, **kwargs: dict) -> dict: + ... + + This function will be accessible when you initialise a ``RealAdapter`` + and a stub of the functionality will be added to a ``FakeAdapter``. + """ key = standardise_key(key) def wrapper(func: Callable) -> Callable: @@ -30,6 +45,25 @@ def wrapper(func: Callable) -> Callable: def register_write_fn(key: Hashable) -> Callable: + """Register a write function to the write functions constant. + + This is useful for smaller projects where domain isolation isn't required. + + .. code-block:: python + + from enum import Enum + from io_adapters import RealAdapter, register_write_fn + + class WriteFormat(Enum): + JSON = "json" + + @register_write_fn(WriteFormat.JSON) + def write_json(data: dict, path: str | Path, **kwargs: dict) -> None: + ... + + This function will be accessible when you initialise a ``RealAdapter`` + and a stub of the functionality will be added to a ``FakeAdapter``. + """ key = standardise_key(key) def wrapper(func: Callable) -> Callable: diff --git a/tests/test_adapters.py b/tests/test_adapters.py index 8ab0f9c..e731104 100644 --- a/tests/test_adapters.py +++ b/tests/test_adapters.py @@ -7,6 +7,7 @@ from src.io_adapters import FakeAdapter, RealAdapter REPO_ROOT = Path(__file__).parents[1] +MOCK_DATA_PATH = f"{REPO_ROOT}/tests/mock_data/mock.json" @pytest.mark.parametrize( @@ -17,10 +18,9 @@ ], ) def test_adapter(adapter, data, file_type): - path = f"{REPO_ROOT}/tests/mock_data/mock.json" io = adapter() - io.write(data, path, file_type) - assert io.read(path, file_type) == data + io.write(data, MOCK_DATA_PATH, file_type) + assert io.read(MOCK_DATA_PATH, file_type) == data @pytest.mark.parametrize( @@ -32,8 +32,7 @@ def test_adapter(adapter, data, file_type): ) def test_raises_when_given_invalid_read_file_type(file_type, expected_context): with expected_context: - path = f"{REPO_ROOT}/tests/mock_data/mock.json" - RealAdapter().read(path, file_type) + RealAdapter().read(MOCK_DATA_PATH, file_type) @pytest.mark.parametrize( @@ -45,20 +44,19 @@ def test_raises_when_given_invalid_read_file_type(file_type, expected_context): ) def test_raises_when_given_invalid_write_file_type(file_type, expected_context): with expected_context: - path = f"{REPO_ROOT}/tests/mock_data/mock.json" - FakeAdapter().write({"a": 0}, path, file_type) + FakeAdapter().write({"a": 0}, MOCK_DATA_PATH, file_type) @pytest.mark.parametrize( ("path", "expected_context"), [ - pytest.param(f"{REPO_ROOT}/tests/mock_data/mock.json", nullcontext()), + pytest.param(MOCK_DATA_PATH, nullcontext()), pytest.param("invalid", pytest.raises(FileNotFoundError)), ], ) def test_raises_when_given_invalid_file_path(path, expected_context): with expected_context: - FakeAdapter(files={f"{REPO_ROOT}/tests/mock_data/mock.json": {"a": 0}}).read(path, "json") + FakeAdapter(files={MOCK_DATA_PATH: {"a": 0}}).read(path, "json") @pytest.mark.parametrize( diff --git a/uv.lock b/uv.lock index d555eef..e9ac4a2 100644 --- a/uv.lock +++ b/uv.lock @@ -441,6 +441,7 @@ dev = [ { name = "repo-mapper-rs" }, { name = "ruff" }, { name = "sphinx" }, + { name = "ty" }, ] [package.metadata] @@ -455,6 +456,7 @@ dev = [ { name = "repo-mapper-rs", specifier = ">=0.3.0" }, { name = "ruff", specifier = ">=0.14.9" }, { name = "sphinx", specifier = ">=9.0.4" }, + { name = "ty", specifier = ">=0.0.9" }, ] [[package]] @@ -1223,6 +1225,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/00/c0/8f5d070730d7836adc9c9b6408dec68c6ced86b304a9b26a14df072a6e8c/traitlets-5.14.3-py3-none-any.whl", hash = "sha256:b74e89e397b1ed28cc831db7aea759ba6640cb3de13090ca145426688ff1ac4f", size = 85359, upload-time = "2024-04-19T11:11:46.763Z" }, ] +[[package]] +name = "ty" +version = "0.0.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/19/7b/4f677c622d58563c593c32081f8a8572afd90e43dc15b0dedd27b4305038/ty-0.0.9.tar.gz", hash = "sha256:83f980c46df17586953ab3060542915827b43c4748a59eea04190c59162957fe", size = 4858642, upload-time = "2026-01-05T12:24:56.528Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/3f/c1ee119738b401a8081ff84341781122296b66982e5982e6f162d946a1ff/ty-0.0.9-py3-none-linux_armv6l.whl", hash = "sha256:dd270d4dd6ebeb0abb37aee96cbf9618610723677f500fec1ba58f35bfa8337d", size = 9763596, upload-time = "2026-01-05T12:24:37.43Z" }, + { url = "https://files.pythonhosted.org/packages/63/41/6b0669ef4cd806d4bd5c30263e6b732a362278abac1bc3a363a316cde896/ty-0.0.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:debfb2ba418b00e86ffd5403cb666b3f04e16853f070439517dd1eaaeeff9255", size = 9591514, upload-time = "2026-01-05T12:24:26.891Z" }, + { url = "https://files.pythonhosted.org/packages/02/a1/874aa756aee5118e690340a771fb9ded0d0c2168c0b7cc7d9561c2a750b0/ty-0.0.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:107c76ebb05a13cdb669172956421f7ffd289ad98f36d42a44a465588d434d58", size = 9097773, upload-time = "2026-01-05T12:24:14.442Z" }, + { url = "https://files.pythonhosted.org/packages/32/62/cb9a460cf03baab77b3361d13106b93b40c98e274d07c55f333ce3c716f6/ty-0.0.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6868ca5c87ca0caa1b3cb84603c767356242b0659b88307eda69b2fb0bfa416b", size = 9581824, upload-time = "2026-01-05T12:24:35.074Z" }, + { url = "https://files.pythonhosted.org/packages/5a/97/633ecb348c75c954f09f8913669de8c440b13b43ea7d214503f3f1c4bb60/ty-0.0.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d14a4aa0eb5c1d3591c2adbdda4e44429a6bb5d2e298a704398bb2a7ccdafdfe", size = 9591050, upload-time = "2026-01-05T12:24:08.804Z" }, + { url = "https://files.pythonhosted.org/packages/6f/e6/4b0c6a7a8a234e2113f88c80cc7aaa9af5868de7a693859f3c49da981934/ty-0.0.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:01bd4466504cefa36b465c6608e9af4504415fa67f6affc01c7d6ce36663c7f4", size = 10018262, upload-time = "2026-01-05T12:24:53.791Z" }, + { url = "https://files.pythonhosted.org/packages/cb/97/076d72a028f6b31e0b87287aa27c5b71a2f9927ee525260ea9f2f56828b8/ty-0.0.9-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:76c8253d1b30bc2c3eaa1b1411a1c34423decde0f4de0277aa6a5ceacfea93d9", size = 10911642, upload-time = "2026-01-05T12:24:48.264Z" }, + { url = "https://files.pythonhosted.org/packages/3f/5a/705d6a5ed07ea36b1f23592c3f0dbc8fc7649267bfbb3bf06464cdc9a98a/ty-0.0.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8992fa4a9c6a5434eae4159fdd4842ec8726259bfd860e143ab95d078de6f8e3", size = 10632468, upload-time = "2026-01-05T12:24:24.118Z" }, + { url = "https://files.pythonhosted.org/packages/44/78/4339a254537488d62bf392a936b3ec047702c0cc33d6ce3a5d613f275cd0/ty-0.0.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c79d503d151acb4a145a3d98702d07cb641c47292f63e5ffa0151e4020a5d33", size = 10273422, upload-time = "2026-01-05T12:24:45.8Z" }, + { url = "https://files.pythonhosted.org/packages/90/40/e7f386e87c9abd3670dcee8311674d7e551baa23b2e4754e2405976e6c92/ty-0.0.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b7a7ebf89ed276b564baa1f0dd9cd708e7b5aa89f19ce1b2f7d7132075abf93e", size = 10120289, upload-time = "2026-01-05T12:24:17.424Z" }, + { url = "https://files.pythonhosted.org/packages/f7/46/1027442596e725c50d0d1ab5179e9fa78a398ab412994b3006d0ee0899c7/ty-0.0.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:ae3866e50109d2400a886bb11d9ef607f23afc020b226af773615cf82ae61141", size = 9566657, upload-time = "2026-01-05T12:24:51.048Z" }, + { url = "https://files.pythonhosted.org/packages/56/be/df921cf1967226aa01690152002b370a7135c6cced81e86c12b86552cdc4/ty-0.0.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:185244a5eacfcd8f5e2d85b95e4276316772f1e586520a6cb24aa072ec1bac26", size = 9610334, upload-time = "2026-01-05T12:24:20.334Z" }, + { url = "https://files.pythonhosted.org/packages/ac/e8/f085268860232cc92ebe95415e5c8640f7f1797ac3a49ddd137c6222924d/ty-0.0.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:f834ff27d940edb24b2e86bbb3fb45ab9e07cf59ca8c5ac615095b2542786408", size = 9726701, upload-time = "2026-01-05T12:24:29.785Z" }, + { url = "https://files.pythonhosted.org/packages/42/b4/9394210c66041cd221442e38f68a596945103d9446ece505889ffa9b3da9/ty-0.0.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:773f4b3ba046de952d7c1ad3a2c09b24f3ed4bc8342ae3cbff62ebc14aa6d48c", size = 10227082, upload-time = "2026-01-05T12:24:40.132Z" }, + { url = "https://files.pythonhosted.org/packages/dc/9f/75951eb573b473d35dd9570546fc1319f7ca2d5b5c50a5825ba6ea6cb33a/ty-0.0.9-py3-none-win32.whl", hash = "sha256:1f20f67e373038ff20f36d5449e787c0430a072b92d5933c5b6e6fc79d3de4c8", size = 9176458, upload-time = "2026-01-05T12:24:32.559Z" }, + { url = "https://files.pythonhosted.org/packages/9b/80/b1cdf71ac874e72678161e25e2326a7d30bc3489cd3699561355a168e54f/ty-0.0.9-py3-none-win_amd64.whl", hash = "sha256:2c415f3bbb730f8de2e6e0b3c42eb3a91f1b5fbbcaaead2e113056c3b361c53c", size = 10040479, upload-time = "2026-01-05T12:24:42.697Z" }, + { url = "https://files.pythonhosted.org/packages/b5/8f/abc75c4bb774b12698629f02d0d12501b0a7dff9c31dc3bd6b6c6467e90a/ty-0.0.9-py3-none-win_arm64.whl", hash = "sha256:48e339d794542afeed710ea4f846ead865cc38cecc335a9c781804d02eaa2722", size = 9543127, upload-time = "2026-01-05T12:24:11.731Z" }, +] + [[package]] name = "typing-extensions" version = "4.15.0"