diff --git a/README.md b/README.md index 55c5beb..7e3a2d3 100644 --- a/README.md +++ b/README.md @@ -286,5 +286,7 @@ Alternatively the map method can be used to return a new type instance with the ├── pyproject.toml ├── ruff.toml └── uv.lock + +(generated with repo-mapper-rs) :: ``` \ No newline at end of file diff --git a/docs/Makefile b/docs/Makefile index a0fc2ab..a047a72 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -24,6 +24,8 @@ deploy: fi @cd docs-build && git worktree add -f html gh-pages || true +doctest: + @uv run sphinx-build -b doctest ./source ./docs-build/doctest local: @uv run sphinx-apidoc -o source src/danom/ --separate ; uv run sphinx-build source docs-build/html diff --git a/docs/source/conf.py b/docs/source/conf.py index 42364b0..aed9501 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -25,6 +25,7 @@ extensions = [ "sphinx.ext.autodoc", "sphinx.ext.coverage", + "sphinx.ext.doctest", "sphinx.ext.napoleon", "sphinx.ext.githubpages", ] diff --git a/pyproject.toml b/pyproject.toml index 135b9de..33d2ceb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "danom" -version = "0.8.2" +version = "0.9.0" description = "Functional streams and monads" readme = "README.md" license = "MIT" @@ -29,7 +29,7 @@ dev = [ "pytest-asyncio>=1.3.0", "pytest-codspeed>=4.2.0", "pytest-cov>=7.0.0", - "repo-mapper-rs>=0.3.0", + "repo-mapper-rs>=0.4.0", "ruff>=0.14.6", "sphinx>=9.0.4", "ty>=0.0.8", diff --git a/src/danom/_new_type.py b/src/danom/_new_type.py index 4ec902c..5e50aab 100644 --- a/src/danom/_new_type.py +++ b/src/danom/_new_type.py @@ -19,15 +19,16 @@ def new_type( # noqa: ANN202 ): """Create a NewType based on another type. - .. code-block:: python + .. doctest:: - from danom import new_type + >>> from danom import new_type - def is_positive(value): - return value >= 0 + >>> def is_positive[T](value: T) -> bool: + ... return value >= 0 - ValidBalance = new_type("ValidBalance", float, validators=[is_positive]) - ValidBalance("20") == ValidBalance(inner=20.0) + >>> ValidBalance = new_type("ValidBalance", float, validators=[is_positive]) + >>> ValidBalance(20.0) == ValidBalance(inner=20.0) + True Unlike an inherited class, the type will not return `True` for an isinstance check. @@ -99,7 +100,7 @@ def _callables_to_kwargs( def _validate_bool_func[T]( bool_fn: Callable[..., bool], ) -> Callable[[attrs.AttrsInstance, attrs.Attribute, T], None]: - if not isinstance(bool_fn, Callable): + if not callable(bool_fn): raise TypeError("provided boolean function must be callable") @wraps(bool_fn) @@ -118,6 +119,11 @@ def wrapper(_instance: attrs.AttrsInstance, attribute: attrs.Attribute, value: T def _to_list(value: C | Sequence[C] | None) -> list[C]: if value is None: return [] - if isinstance(value, Sequence) and not isinstance(value, (str, bytes)): - return list(value) - return [value] + + if callable(value): + return [value] + + if isinstance(value, Sequence) and not all(callable(fn) for fn in value): + raise TypeError(f"Given items are not all callable: {value = }") + + return list(value) diff --git a/src/danom/_result.py b/src/danom/_result.py index 0623a39..d566b33 100644 --- a/src/danom/_result.py +++ b/src/danom/_result.py @@ -16,6 +16,7 @@ T_co = TypeVar("T_co", covariant=True) U_co = TypeVar("U_co", covariant=True) E_co = TypeVar("E_co", bound=object, covariant=True) +F_co = TypeVar("F_co", bound=object, covariant=True) P = ParamSpec("P") Mappable = Callable[P, U_co] @@ -33,13 +34,18 @@ class Result(ABC): def unit(cls, inner: T_co) -> Ok[T_co]: """Unit method. Given an item of type `T_co` return `Ok(T_co)` - .. code-block:: python + .. doctest:: + + >>> from danom import Err, Ok, Result + + >>> Result.unit(0) == Ok(inner=0) + True - from danom import Err, Ok, Result + >>> Ok.unit(0) == Ok(inner=0) + True - Result.unit(0) == Ok(inner=0) - Ok.unit(0) == Ok(inner=0) - Err.unit(0) == Ok(inner=0) + >>> Err.unit(0) == Ok(inner=0) + True """ return Ok(inner) @@ -48,12 +54,15 @@ def is_ok(self) -> bool: """Returns `True` if the result type is `Ok`. Returns `False` if the result type is `Err`. - .. code-block:: python + .. doctest:: - from danom import Err, Ok + >>> from danom import Err, Ok - Ok().is_ok() == True - Err().is_ok() == False + >>> Ok().is_ok() == True + True + + >>> Err().is_ok() == False + True """ ... @@ -71,6 +80,20 @@ def map(self, func: Mappable, **kwargs: P.kwargs) -> ResultReturnType: """ ... + @abstractmethod + def map_err(self, func: Mappable, **kwargs: P.kwargs) -> ResultReturnType: + """Pipe a pure function and wrap the return value with `Err`. + Given an `Ok` will return self. + + .. code-block:: python + + from danom import Err, Ok + + Err(error=TypeError()).map_err(type_err_to_value_err) == Err(error=ValueError()) + Ok(1).map(type_err_to_value_err) == Ok(1) + """ + ... + @abstractmethod def and_then(self, func: Bindable, **kwargs: P.kwargs) -> ResultReturnType: """Pipe another function that returns a monad. For `Err` will return original error. @@ -87,32 +110,40 @@ def and_then(self, func: Bindable, **kwargs: P.kwargs) -> ResultReturnType: ... @abstractmethod - def unwrap(self) -> T_co: - """Unwrap the `Ok` monad and get the inner value. - Unwrap the `Err` monad will raise the inner error. + def or_else(self, func: Bindable, **kwargs: P.kwargs) -> ResultReturnType: + """Pipe a function that returns a monad to recover from an `Err`. For `Ok` will return original `Result`. .. code-block:: python from danom import Err, Ok - Ok().unwrap() == None - Ok(1).unwrap() == 1 - Ok("ok").unwrap() == 'ok' - Err(error=TypeError()).unwrap() raise TypeError(...) + Ok(1).or_else(replace_err_with_zero) == Ok(1) + Err(error=TypeError()).or_else(replace_err_with_zero) == Ok(0) """ ... @abstractmethod - def match(self, if_ok_func: Mappable, if_err_func: Mappable) -> ResultReturnType: - """Map `ok_func` to `Ok` and `err_func` to `Err` + def unwrap(self) -> T_co: + """Unwrap the `Ok` monad and get the inner value. + Unwrap the `Err` monad will raise the inner error. - .. code-block:: python + .. doctest:: - from danom import Err, Ok + >>> from danom import Err, Ok + + >>> Ok().unwrap() == None + True + + >>> Ok(1).unwrap() == 1 + True + + >>> Ok("ok").unwrap() == 'ok' + True - Ok(1).match(add_one, mock_get_error_type) == Ok(inner=2) - Ok("ok").match(double, mock_get_error_type) == Ok(inner='okok') - Err(error=TypeError()).match(double, mock_get_error_type) == Ok(inner='TypeError') + >>> Err(error=TypeError()).unwrap() + Traceback (most recent call last): + ... + TypeError: """ ... @@ -121,7 +152,7 @@ def __class_getitem__(cls, _params: tuple) -> Self: @attrs.define(frozen=True, hash=True) -class Ok[T_co](Result): +class Ok(Result): inner: Any = attrs.field(default=None) def is_ok(self) -> Literal[True]: @@ -130,23 +161,26 @@ def is_ok(self) -> Literal[True]: def map(self, func: Mappable, **kwargs: P.kwargs) -> Ok[U_co]: return Ok(func(self.inner, **kwargs)) + def map_err(self, func: Mappable, **kwargs: P.kwargs) -> Ok[U_co]: # noqa: ARG002 + return self + def and_then(self, func: Bindable, **kwargs: P.kwargs) -> ResultReturnType: return func(self.inner, **kwargs) + def or_else(self, func: Bindable, **kwargs: P.kwargs) -> Ok[T_co]: # noqa: ARG002 + return self + def unwrap(self) -> T_co: return self.inner - def match(self, if_ok_func: Mappable, if_err_func: Mappable) -> ResultReturnType: # noqa: ARG002 - return if_ok_func(self.inner) - SafeArgs = tuple[tuple[Any, ...], dict[str, Any]] SafeMethodArgs = tuple[object, tuple[Any, ...], dict[str, Any]] @attrs.define(frozen=True) -class Err[E_co](Result): - error: E_co | Exception = attrs.field(default=None) +class Err(Result): + error: Any = attrs.field(default=None) input_args: tuple[()] | SafeArgs | SafeMethodArgs = attrs.field(default=(), repr=False) details: list[dict[str, Any]] = attrs.field(factory=list, init=False, repr=False) @@ -176,17 +210,20 @@ def is_ok(self) -> Literal[False]: def map(self, func: Mappable, **kwargs: P.kwargs) -> Err[E_co]: # noqa: ARG002 return self + def map_err(self, func: Mappable, **kwargs: P.kwargs) -> Err[F_co]: + return Err(func(self.error, **kwargs)) + def and_then(self, func: Bindable, **kwargs: P.kwargs) -> Err[E_co]: # noqa: ARG002 return self + def or_else(self, func: Bindable, **kwargs: P.kwargs) -> ResultReturnType: + return func(self.error, **kwargs) + def unwrap(self) -> None: if isinstance(self.error, Exception): raise self.error raise ValueError(f"Err does not have a caught error to raise: {self.error = }") - def match(self, if_ok_func: Mappable, if_err_func: Mappable) -> ResultReturnType: # noqa: ARG002 - return if_err_func(self.error) - def __eq__(self, other: object) -> bool: if not isinstance(other, Err): return False diff --git a/src/danom/_stream.py b/src/danom/_stream.py index faf3f6b..d6aa010 100644 --- a/src/danom/_stream.py +++ b/src/danom/_stream.py @@ -33,7 +33,7 @@ @attrs.define(frozen=True) class _BaseStream(ABC): - seq: Iterable = attrs.field(validator=attrs.validators.instance_of(Iterable), repr=False) + seq: tuple = attrs.field(validator=attrs.validators.instance_of(tuple)) ops: tuple = attrs.field(default=(), validator=attrs.validators.instance_of(tuple), repr=False) @classmethod @@ -161,12 +161,12 @@ class Stream(_BaseStream): def from_iterable(cls, it: Iterable) -> Stream[T]: """This is the recommended way of creating a `Stream` object. - .. code-block:: python - - from danom import Stream + .. doctest:: - Stream.from_iterable([0, 1, 2, 3]).collect() == (0, 1, 2, 3) + >>> from danom import Stream + >>> Stream.from_iterable([0, 1, 2, 3]).collect() == (0, 1, 2, 3) + True """ if not isinstance(it, Iterable): it = [it] @@ -206,17 +206,18 @@ def two_div_value(x: float) -> float: """ plan = (*self.ops, *tuple((_MAP, fn) for fn in fns)) - return Stream(seq=self.seq, ops=plan) + object.__setattr__(self, "ops", plan) + return self def filter(self, *fns: FilterFn | AsyncFilterFn) -> Stream[T]: """Filter the stream based on a predicate. Will return a new `Stream` with the modified sequence. - .. code-block:: python - - from danom import Stream + .. doctest:: - Stream.from_iterable([0, 1, 2, 3]).filter(lambda x: x % 2 == 0).collect() == (0, 2) + >>> from danom import Stream + >>> Stream.from_iterable([0, 1, 2, 3]).filter(lambda x: x % 2 == 0).collect() == (0, 2) + True Simple functions can be passed in sequence to compose more complex filters @@ -228,7 +229,8 @@ def filter(self, *fns: FilterFn | AsyncFilterFn) -> Stream[T]: """ plan = (*self.ops, *tuple((_FILTER, fn) for fn in fns)) - return Stream(seq=self.seq, ops=plan) + object.__setattr__(self, "ops", plan) + return self def tap(self, *fns: TapFn | AsyncTapFn) -> Stream[T]: """Tap the values to another process that returns None. Will return a new `Stream` with the modified sequence. @@ -271,7 +273,8 @@ def tap(self, *fns: TapFn | AsyncTapFn) -> Stream[T]: """ plan = (*self.ops, *tuple((_TAP, fn) for fn in fns)) - return Stream(seq=self.seq, ops=plan) + object.__setattr__(self, "ops", plan) + return self def partition( self, fn: FilterFn, *, workers: int = 1, use_threads: bool = False @@ -280,14 +283,15 @@ def partition( Each partition is independently replayable. - .. code-block:: python + .. doctest:: from danom import Stream - part1, part2 = Stream.from_iterable([0, 1, 2, 3]).partition(lambda x: x % 2 == 0) - part1.collect() == (0, 2) - part2.collect() == (1, 3) - + >>> part1, part2 = Stream.from_iterable([0, 1, 2, 3]).partition(lambda x: x % 2 == 0) + >>> part1.collect() == (0, 2) + True + >>> part2.collect() == (1, 3) + True As `partition` triggers an action, the parameters will be forwarded to the `par_collect` call if the `workers` are greater than 1. @@ -315,13 +319,16 @@ def fold( ) -> T: """Fold the results into a single value. `fold` triggers an action so will incur a `collect`. - .. code-block:: python + .. doctest:: - from danom import Stream + >>> from danom import Stream - Stream.from_iterable([1, 2, 3, 4]).fold(0, lambda a, b: a + b) == 10 - Stream.from_iterable([[1], [2], [3], [4]]).fold([0], lambda a, b: a + b) == [0, 1, 2, 3, 4] - Stream.from_iterable([1, 2, 3, 4]).fold(1, lambda a, b: a * b) == 24 + >>> Stream.from_iterable([1, 2, 3, 4]).fold(0, lambda a, b: a + b) == 10 + True + >>> Stream.from_iterable([[1], [2], [3], [4]]).fold([0], lambda a, b: a + b) == [0, 1, 2, 3, 4] + True + >>> Stream.from_iterable([1, 2, 3, 4]).fold(1, lambda a, b: a * b) == 24 + True As `fold` triggers an action, the parameters will be forwarded to the `par_collect` call if the `workers` are greater than 1. @@ -440,13 +447,13 @@ class _Nothing(Enum): def _apply_fns_worker[T]( - args: tuple[T, tuple[PlannedOps, ...]], + args: tuple[tuple[T], tuple[PlannedOps, ...]], ) -> tuple[T]: seq, ops = args return _par_apply_fns(seq, ops) -def _apply_fns[T](elements: tuple[T], ops: tuple[PlannedOps, ...]) -> Generator[None, None, T]: +def _apply_fns[T](elements: tuple[T], ops: tuple[PlannedOps, ...]) -> Generator[T, None, None]: for elem in elements: valid = True res = elem @@ -462,7 +469,7 @@ def _apply_fns[T](elements: tuple[T], ops: tuple[PlannedOps, ...]) -> Generator[ yield res -def _par_apply_fns[T](elements: tuple[T], ops: tuple[PlannedOps, ...]) -> list[T]: +def _par_apply_fns[T](elements: tuple[T], ops: tuple[PlannedOps, ...]) -> tuple[T]: results = [] for elem in elements: valid = True @@ -477,7 +484,7 @@ def _par_apply_fns[T](elements: tuple[T], ops: tuple[PlannedOps, ...]) -> list[T op_fn(deepcopy(res)) if valid: results.append(res) - return results + return tuple(results) async def _async_apply_fns[T](elem: T, ops: tuple[AsyncPlannedOps, ...]) -> T | _Nothing: diff --git a/tests/conftest.py b/tests/conftest.py index 31b0098..9883f5e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +from multiprocessing.managers import ListProxy from pathlib import Path from typing import Any, Self @@ -94,7 +95,7 @@ def cls_raises(self, *_args: tuple, **_kwargs: dict) -> None: class ValueLogger: - def __init__(self, values: list | None = None) -> None: + def __init__(self, values: list | ListProxy | None = None) -> None: self.values = values if values is not None else [] def __call__[T](self, value: T) -> None: diff --git a/tests/test_monad_laws.py b/tests/test_monad_laws.py index 4353697..5f03166 100644 --- a/tests/test_monad_laws.py +++ b/tests/test_monad_laws.py @@ -14,7 +14,7 @@ st.integers().map(Result.unit), st.text().map(Result.unit), st.floats(allow_nan=False, allow_infinity=False).map(Result.unit), - st.just(Err()), + st.just(Err(1)), ) @@ -31,6 +31,8 @@ def test_monadic_right_identity(monad): assert monad.and_then(Result.unit) == monad -@given(monad=results, f=safe_fns, g=safe_fns) -def test_monadic_associativity(monad, f, g): - assert monad.and_then(f).and_then(g) == monad.and_then(lambda x: f(x).and_then(g)) +@given(monad=results, f=safe_fns, g=safe_fns, h=safe_fns) +def test_monadic_associativity(monad, f, g, h): + assert monad.and_then(f).and_then(g).or_else(h) == monad.and_then( + lambda x: f(x).and_then(g) + ).or_else(h) diff --git a/tests/test_new_type.py b/tests/test_new_type.py index 21bf000..5807dd5 100644 --- a/tests/test_new_type.py +++ b/tests/test_new_type.py @@ -1,8 +1,12 @@ from contextlib import nullcontext +from types import SimpleNamespace +import hypothesis.strategies as st import pytest +from hypothesis import given from src.danom import new_type +from src.danom._new_type import _validate_bool_func from tests.conftest import has_len @@ -72,3 +76,29 @@ def test_new_type_map(initial_value, base_type, map_fn, get_attr, expected_inner TestType = new_type("TestType", base_type) # noqa: N806 assert TestType(initial_value).map(map_fn) == TestType(expected_inner) assert getattr(TestType(initial_value), get_attr)() == expected_inner + + +@given( + st.one_of( + st.tuples( + st.functions(like=lambda x: True, returns=st.just(True)), + st.integers(), + st.just(nullcontext()), + ), + st.tuples( + st.functions(like=lambda x: False, returns=st.just(False)), + st.integers(), + st.just(pytest.raises(ValueError)), + ), + st.tuples( + st.integers(), + st.just(None), + st.just(pytest.raises(TypeError)), + ), + ) +) +def test_validate_bool_func(args): + bool_fn, value, expected_context = args + + with expected_context: + _validate_bool_func(bool_fn)(object(), SimpleNamespace(name="x"), value) diff --git a/tests/test_result.py b/tests/test_result.py index 91b4d44..b2e31d6 100644 --- a/tests/test_result.py +++ b/tests/test_result.py @@ -92,6 +92,17 @@ def test_map(monad, func, expected_result): assert monad.map(func) == expected_result +@pytest.mark.parametrize( + ("monad", "func", "expected_result"), + [ + pytest.param(Ok(0), add_one, Ok(0)), + pytest.param(Err(0), add_one, Err(1)), + ], +) +def test_map_err(monad, func, expected_result): + assert monad.map_err(func) == expected_result + + class OnlyIsOk(Result): def is_ok(self) -> bool: return False diff --git a/tests/test_safe.py b/tests/test_safe.py index c37298e..ffbe6ec 100644 --- a/tests/test_safe.py +++ b/tests/test_safe.py @@ -10,7 +10,8 @@ def test_valid_safe_pipeline(): safe_add(1, 2) .and_then(safe_add, b=1) .and_then(safe_add, b=1) - .match(partial(safe_add, b=1), safe_get_error_type) + .and_then(partial(safe_add, b=1)) + .or_else(safe_get_error_type) ) assert pipeline.is_ok() assert pipeline.unwrap() == 6 @@ -29,7 +30,8 @@ def test_invalid_safe_pipeline_with_match(): safe_add(1, 2) .and_then(safe_raise_type_error) .and_then(safe_add, b=1) - .match(partial(safe_add, b=1), safe_get_error_type) + .and_then(partial(safe_add, b=1)) + .or_else(safe_get_error_type) ) assert pipeline.is_ok() assert pipeline.unwrap() == "TypeError" diff --git a/uv.lock b/uv.lock index d991d84..88f1a8d 100644 --- a/uv.lock +++ b/uv.lock @@ -273,7 +273,7 @@ wheels = [ [[package]] name = "danom" -version = "0.8.2" +version = "0.9.0" source = { editable = "." } dependencies = [ { name = "attrs" }, @@ -306,7 +306,7 @@ dev = [ { name = "pytest-asyncio", specifier = ">=1.3.0" }, { name = "pytest-codspeed", specifier = ">=4.2.0" }, { name = "pytest-cov", specifier = ">=7.0.0" }, - { name = "repo-mapper-rs", specifier = ">=0.3.0" }, + { name = "repo-mapper-rs", specifier = ">=0.4.0" }, { name = "ruff", specifier = ">=0.14.6" }, { name = "sphinx", specifier = ">=9.0.4" }, { name = "ty", specifier = ">=0.0.8" }, @@ -956,16 +956,16 @@ wheels = [ [[package]] name = "repo-mapper-rs" -version = "0.3.0" +version = "0.4.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/97/88/7025225dc64f08218a15e1f36a3da694f0b513b88a04132fca356f60ba67/repo_mapper_rs-0.3.0.tar.gz", hash = "sha256:c319087a78930977a53a4322c58c90cd1c016fc2bb2a6b2b097efd2c2cf71297", size = 23703, upload-time = "2025-08-13T20:28:39.251Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ab/92/a86236660a5820e36b6bb9f76b3d15fb2169b7795a94ca84df0bb8ff409a/repo_mapper_rs-0.4.0.tar.gz", hash = "sha256:1b10da208bf18f6a9fcae168af8647afc35378756a64fb8b2d3dc7a56f12840b", size = 23838, upload-time = "2026-01-24T21:46:18.205Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/37/5e/b82d2a4235fd0c7bc75cf04fd688f80f39b52c485443f6aabafa8ffd1049/repo_mapper_rs-0.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:354504aef5d2eeefffc50a267146a6a0120ec35b37864772a5ef6c594a15ad70", size = 776755, upload-time = "2025-08-13T20:28:27.803Z" }, - { url = "https://files.pythonhosted.org/packages/48/e8/5af0af9be629fad4ed76747346e815c707baa1b243d387d7fd79b064c2d6/repo_mapper_rs-0.3.0-cp312-cp312-manylinux_2_34_x86_64.whl", hash = "sha256:7cd2373714a0901c53d7d85cb657a020e385d13c0781bcc7b5cd4dfc490969d0", size = 922475, upload-time = "2025-08-13T20:28:29.116Z" }, - { url = "https://files.pythonhosted.org/packages/3f/ad/a3de567a59cf3f4cce6b52b9ef8ae289a39502b9e71d4242cff16a4754db/repo_mapper_rs-0.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:e961b3a4b17d615a3ac0b9e55308c3fe51ad0159ee97217ee145d5e2439a2ca0", size = 788757, upload-time = "2025-08-13T20:28:31.22Z" }, - { url = "https://files.pythonhosted.org/packages/4f/45/200104e672f43d263b15b1107314d999ebf7145bea148f0cd102c289480f/repo_mapper_rs-0.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:80588563197422068cbb5b0c3cb23811f7dc4fc04ab03d5bcada2fe19c010700", size = 776607, upload-time = "2025-08-13T20:28:32.835Z" }, - { url = "https://files.pythonhosted.org/packages/0a/72/9fafdee20f496a8cc71b622c7c3dbbc9231423e7f4e0a0f43218f78b7fc6/repo_mapper_rs-0.3.0-cp313-cp313-manylinux_2_34_x86_64.whl", hash = "sha256:e73c9335348aac980e7f25f44d4ba1be87b065855151411a0402cb720c564b61", size = 921899, upload-time = "2025-08-13T20:28:35.907Z" }, - { url = "https://files.pythonhosted.org/packages/1b/14/dedd1b243fdc2a16b291d9b3a664ee593bff997f1dcb5eaffedf5b776ade/repo_mapper_rs-0.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:c0815eff543c46a051b93247e98bad42213333d47415367b4d0716b4a01e88ef", size = 787923, upload-time = "2025-08-13T20:28:37.508Z" }, + { url = "https://files.pythonhosted.org/packages/a1/79/765fdd8680b2b314d4ce917aec5e0d73a3d95f5cd6137996310263dd1b41/repo_mapper_rs-0.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7016d60abe7077f4f2f8243909ba444c99b4e1d2d72a34fcb0ec53725c1ef2d5", size = 817597, upload-time = "2026-01-24T21:46:09.82Z" }, + { url = "https://files.pythonhosted.org/packages/7c/2a/eed5594e26ebc1ec186b813500ee9ec944154552c626ad5f1f1852e6c0c5/repo_mapper_rs-0.4.0-cp312-cp312-manylinux_2_34_x86_64.whl", hash = "sha256:916aa59fea3307901c68e6d181c3fcd703f2f3170976ea4e4977aff5fbcc7a4e", size = 966002, upload-time = "2026-01-24T21:46:11.05Z" }, + { url = "https://files.pythonhosted.org/packages/77/3a/d8bf8055d1c5d1b25fe29745d050f1c14373d58a6a2ed3d375e2959f39f8/repo_mapper_rs-0.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:8d2fe43e5ccf6e33337ad81aa37874440574e5dc3ce673388c7e051a598c5638", size = 850188, upload-time = "2026-01-24T21:46:12.62Z" }, + { url = "https://files.pythonhosted.org/packages/fe/00/2e8f622d325fbd1887fad2b71323ce29e933851a60ce38d50dd27b136ae8/repo_mapper_rs-0.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bffde57100266dfbf0f1ec00354aff174bdc946093c11bd894930e33f8189600", size = 817748, upload-time = "2026-01-24T21:46:14.111Z" }, + { url = "https://files.pythonhosted.org/packages/33/0b/04195e7c3d962952b113cf0a15db041b2805997cfbe127919dc8dea1ffe4/repo_mapper_rs-0.4.0-cp313-cp313-manylinux_2_34_x86_64.whl", hash = "sha256:78139929709acb97adf5cbe2126685f421cbc94565c56fcdc6a4a7789996e27c", size = 965450, upload-time = "2026-01-24T21:46:15.336Z" }, + { url = "https://files.pythonhosted.org/packages/80/b8/94e8fa92dcb29a1bcd5544b494714d4b76318b08b9778eab8e74e7bb37d5/repo_mapper_rs-0.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:ffc14e422fe1bfbc354a5987bf41b619594dec95387621a071b3e2e29fd4c764", size = 849603, upload-time = "2026-01-24T21:46:16.934Z" }, ] [[package]]