From 611884b0631869ceb57ae6261134bec0ed500b86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roberto=20Abdelkader=20Mart=C3=ADnez=20P=C3=A9rez?= Date: Wed, 8 Jul 2026 12:00:33 +0200 Subject: [PATCH 1/3] Port zero-dependency stochastic core Note: pytest and behave integration extras are intentionally deferred; this change ports the zero-dependency stochastic core only. --- README.md | 34 +++- pyproject.toml | 6 +- src/montest/__init__.py | 22 +++ src/montest/_composite.py | 184 ++++++++++++++++++++++ src/montest/_criterion.py | 29 ++++ src/montest/_iterator_async.py | 81 ++++++++++ src/montest/_iterator_sync.py | 52 ++++++ src/montest/_types.py | 35 ++++ src/montest/algorithms/__init__.py | 15 ++ src/montest/algorithms/sprt/__init__.py | 18 +++ src/montest/algorithms/sprt/_algorithm.py | 110 +++++++++++++ src/montest/algorithms/sprt/_result.py | 30 ++++ src/montest/py.typed | 0 tests/conftest.py | 39 +++++ tests/test_async_sequential_iterator.py | 93 +++++++++++ tests/test_composite.py | 160 +++++++++++++++++++ tests/test_integration.py | 109 +++++++++++++ tests/test_sequential_iterator.py | 53 +++++++ tests/test_sprt.py | 169 ++++++++++++++++++++ uv.lock | 16 -- 20 files changed, 1234 insertions(+), 21 deletions(-) create mode 100644 src/montest/_composite.py create mode 100644 src/montest/_criterion.py create mode 100644 src/montest/_iterator_async.py create mode 100644 src/montest/_iterator_sync.py create mode 100644 src/montest/_types.py create mode 100644 src/montest/algorithms/__init__.py create mode 100644 src/montest/algorithms/sprt/__init__.py create mode 100644 src/montest/algorithms/sprt/_algorithm.py create mode 100644 src/montest/algorithms/sprt/_result.py create mode 100644 src/montest/py.typed create mode 100644 tests/conftest.py create mode 100644 tests/test_async_sequential_iterator.py create mode 100644 tests/test_composite.py create mode 100644 tests/test_integration.py create mode 100644 tests/test_sequential_iterator.py create mode 100644 tests/test_sprt.py diff --git a/README.md b/README.md index 625c309..bb1cc5c 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,39 @@ [![CI](https://github.com/BBVA/montest/actions/workflows/ci.yml/badge.svg)](https://github.com/BBVA/montest/actions/workflows/ci.yml) -A stochastic BDD framework for Python designed to test non-deterministic systems (e.g., LLMs) by evaluating statistical distributions rather than binary outcomes. +A stochastic testing framework for Python designed to test non-deterministic systems by evaluating statistical evidence across repeated observations instead of relying on one-shot binary assertions. + +## Core install + +```bash +pip install montest +``` + +The base install ships the core stochastic engine only and has no runtime dependencies. + +## Quick start + +```python +import math +import random + +from montest import Decision, SequentialIterator, sprt + + +def bernoulli_llr(value: int) -> float: + return math.log(0.6 / 0.3) if value else math.log(0.4 / 0.7) + + +rng = random.Random(42) +criterion = sprt(llr=bernoulli_llr, alpha=0.05, beta=0.10) + +for sample in SequentialIterator(lambda: int(rng.random() < 0.6), criterion): + print(sample.index, sample.value, sample.decision.value) + if sample.decision is not Decision.CONTINUE: + break +``` + +Future testing-tool integrations are intended to live behind optional install groups such as `montest[pytest]` and `montest[behave]`; this release only ships the zero-dependency core. ## Development Setup diff --git a/pyproject.toml b/pyproject.toml index 4d80c5d..6cf996b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "hatchling.build" [project] name = "montest" version = "0.1.0" -description = "A stochastic BDD testing framework for non-deterministic systems." +description = "A stochastic testing framework for non-deterministic systems." authors = [{ email = "robertomartinezp@gmail.com" }] license = { text = "Apache-2.0" } readme = "README.md" @@ -17,9 +17,7 @@ classifiers = [ "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", ] -dependencies = [ - "gherkin-official>=29.0.0", -] +dependencies = [] [project.optional-dependencies] dev = [ diff --git a/src/montest/__init__.py b/src/montest/__init__.py index baf442b..ea82233 100644 --- a/src/montest/__init__.py +++ b/src/montest/__init__.py @@ -12,4 +12,26 @@ # See the License for the specific language governing permissions and # limitations under the License. +from montest._composite import AllOf, AnyOf, CompositeResult +from montest._criterion import StoppingCriterion +from montest._iterator_async import AsyncSequentialIterator +from montest._iterator_sync import SequentialIterator +from montest._types import Decision, ObservationResult +from montest.algorithms.sprt import SPRT, SPRTResult, sprt + __version__ = "0.1.0" + +__all__ = [ + "__version__", + "AllOf", + "AnyOf", + "AsyncSequentialIterator", + "CompositeResult", + "Decision", + "ObservationResult", + "SPRT", + "SPRTResult", + "SequentialIterator", + "StoppingCriterion", + "sprt", +] diff --git a/src/montest/_composite.py b/src/montest/_composite.py new file mode 100644 index 0000000..2de7d61 --- /dev/null +++ b/src/montest/_composite.py @@ -0,0 +1,184 @@ +# Copyright 2026 Banco Bilbao Vizcaya Argentaria, S.A. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import dataclasses +from collections.abc import Callable, Mapping, Sequence +from typing import Any, Generic, TypeVar, cast + +from montest._criterion import StoppingCriterion +from montest._types import Decision, ObservationResult + +S = TypeVar("S") + + +def _default_resolve(decisions: Sequence[Decision]) -> Decision: + if any(decision is Decision.ACCEPT_H1 for decision in decisions): + return Decision.ACCEPT_H1 + if any(decision is Decision.INCONCLUSIVE for decision in decisions): + return Decision.INCONCLUSIVE + return Decision.ACCEPT_H0 + + +@dataclasses.dataclass(frozen=True, slots=True) +class CompositeResult(ObservationResult[S], Generic[S]): + results: Mapping[str, ObservationResult[S] | None] + n_decided: int + n_total: int + + +class _CompositeBase(Generic[S]): + def __init__( + self, + criteria: Mapping[str, StoppingCriterion[S, ObservationResult[Any]]], + *, + resolve: Callable[[Sequence[Decision]], Decision] | None = None, + ) -> None: + if not criteria: + raise ValueError("criteria must not be empty") + if any(not isinstance(key, str) or not key for key in criteria): + raise ValueError("criterion keys must be non-empty strings") + + self._criteria = dict(criteria) + self._resolve = resolve or _default_resolve + self._terminal_decisions: dict[str, Decision] = {} + self._terminal = False + + def reset(self) -> None: + for criterion in self._criteria.values(): + criterion.reset() + self._terminal_decisions.clear() + self._terminal = False + + def _terminal_decision_sequence(self) -> list[Decision]: + return [ + self._terminal_decisions[key] + for key in self._criteria + if key in self._terminal_decisions + ] + + def _result( + self, + *, + sample: S, + index: int, + decision: Decision, + results: Mapping[str, ObservationResult[Any] | None], + n_decided: int, + ) -> CompositeResult[S]: + return CompositeResult( + value=sample, + index=index, + decision=decision, + results=cast(Mapping[str, ObservationResult[S] | None], dict(results)), + n_decided=n_decided, + n_total=len(self._criteria), + ) + + def _ensure_running(self) -> None: + if self._terminal: + raise RuntimeError( + "Criterion already reached a decision; call reset() first." + ) + + +class AllOf(_CompositeBase[S]): + def __init__( + self, + criteria: Mapping[str, StoppingCriterion[S, ObservationResult[Any]]], + *, + resolve: Callable[[Sequence[Decision]], Decision] | None = None, + ) -> None: + super().__init__(criteria, resolve=resolve) + + def observe(self, sample: S, *, index: int) -> CompositeResult[S]: + self._ensure_running() + + results: dict[str, ObservationResult[Any] | None] = {} + for key, criterion in self._criteria.items(): + if key in self._terminal_decisions: + results[key] = None + continue + + result = criterion.observe(sample, index=index) + results[key] = result + if result.decision is not Decision.CONTINUE: + self._terminal_decisions[key] = result.decision + + n_decided = len(self._terminal_decisions) + decision = Decision.CONTINUE + if n_decided == len(self._criteria): + decision = self._resolve(self._terminal_decision_sequence()) + self._terminal = True + + return self._result( + sample=sample, + index=index, + decision=decision, + results=results, + n_decided=n_decided, + ) + + +class AnyOf(_CompositeBase[S]): + def __init__( + self, + criteria: Mapping[str, StoppingCriterion[S, ObservationResult[Any]]], + *, + resolve: Callable[[Sequence[Decision]], Decision] | None = None, + ) -> None: + super().__init__(criteria, resolve=resolve) + + def observe(self, sample: S, *, index: int) -> CompositeResult[S]: + self._ensure_running() + + results: dict[str, ObservationResult[Any] | None] = {} + stopped_early = False + for key, criterion in self._criteria.items(): + if key in self._terminal_decisions: + results[key] = None + continue + + if stopped_early: + results[key] = None + continue + + result = criterion.observe(sample, index=index) + results[key] = result + if result.decision is Decision.CONTINUE: + continue + + self._terminal_decisions[key] = result.decision + if result.decision is Decision.ACCEPT_H1: + stopped_early = True + + n_total = len(self._criteria) + n_decided = len(self._terminal_decisions) + decision = Decision.CONTINUE + if stopped_early: + decision = self._resolve(self._terminal_decision_sequence()) + n_decided = n_total + self._terminal = True + elif n_decided == n_total: + decision = self._resolve(self._terminal_decision_sequence()) + self._terminal = True + + return self._result( + sample=sample, + index=index, + decision=decision, + results=results, + n_decided=n_decided, + ) diff --git a/src/montest/_criterion.py b/src/montest/_criterion.py new file mode 100644 index 0000000..f0e9f40 --- /dev/null +++ b/src/montest/_criterion.py @@ -0,0 +1,29 @@ +# Copyright 2026 Banco Bilbao Vizcaya Argentaria, S.A. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +from typing import Any, Protocol, TypeVar, runtime_checkable + +from montest._types import ObservationResult + +S_contra = TypeVar("S_contra", contravariant=True) +R_co = TypeVar("R_co", covariant=True, bound=ObservationResult[Any]) + + +@runtime_checkable +class StoppingCriterion(Protocol[S_contra, R_co]): + def observe(self, sample: S_contra, *, index: int) -> R_co: ... + + def reset(self) -> None: ... diff --git a/src/montest/_iterator_async.py b/src/montest/_iterator_async.py new file mode 100644 index 0000000..af3eca2 --- /dev/null +++ b/src/montest/_iterator_async.py @@ -0,0 +1,81 @@ +# Copyright 2026 Banco Bilbao Vizcaya Argentaria, S.A. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import asyncio +import inspect +from collections import deque +from collections.abc import AsyncIterator, Awaitable, Callable +from typing import Generic, TypeAlias, TypeVar, cast + +from montest._criterion import StoppingCriterion +from montest._types import Decision, ObservationResult + +S = TypeVar("S") +R = TypeVar("R", bound=ObservationResult[object]) +GenerateFn: TypeAlias = Callable[[], S] | Callable[[], Awaitable[S]] + + +class AsyncSequentialIterator(Generic[S, R], AsyncIterator[R]): + def __init__( + self, + generate: GenerateFn[S], + criterion: StoppingCriterion[S, R], + concurrency: int = 1, + ) -> None: + if concurrency < 1: + raise ValueError(f"concurrency must be >= 1, got {concurrency}") + + self._generate = generate + self._criterion = criterion + self._concurrency = concurrency + self._index = 0 + self._stopped = False + self._pending: deque[R] = deque() + self._is_async_generate = inspect.iscoroutinefunction(generate) + + def __aiter__(self) -> AsyncSequentialIterator[S, R]: + return self + + async def __anext__(self) -> R: + if self._pending: + return self._pending.popleft() + + if self._stopped: + raise StopAsyncIteration + + values = await asyncio.gather( + *(self._generate_one() for _ in range(self._concurrency)) + ) + for value in values: + result = self._criterion.observe(value, index=self._index) + self._index += 1 + self._pending.append(result) + if result.decision is not Decision.CONTINUE: + self._stopped = True + break + + if self._pending: + return self._pending.popleft() + + raise StopAsyncIteration + + async def _generate_one(self) -> S: + if self._is_async_generate: + result = self._generate() + return await cast(Awaitable[S], result) + + generate = cast(Callable[[], S], self._generate) + return await asyncio.to_thread(generate) diff --git a/src/montest/_iterator_sync.py b/src/montest/_iterator_sync.py new file mode 100644 index 0000000..6145fbf --- /dev/null +++ b/src/montest/_iterator_sync.py @@ -0,0 +1,52 @@ +# Copyright 2026 Banco Bilbao Vizcaya Argentaria, S.A. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +from collections.abc import Callable, Iterator +from typing import Generic, TypeVar + +from montest._criterion import StoppingCriterion +from montest._types import Decision, ObservationResult + +S = TypeVar("S") +R = TypeVar("R", bound=ObservationResult[object]) + + +class SequentialIterator(Generic[S, R], Iterator[R]): + def __init__( + self, + generate: Callable[[], S], + criterion: StoppingCriterion[S, R], + ) -> None: + self._generate = generate + self._criterion = criterion + self._index = 0 + self._stopped = False + + def __iter__(self) -> SequentialIterator[S, R]: + return self + + def __next__(self) -> R: + if self._stopped: + raise StopIteration + + value = self._generate() + result = self._criterion.observe(value, index=self._index) + self._index += 1 + + if result.decision is not Decision.CONTINUE: + self._stopped = True + + return result diff --git a/src/montest/_types.py b/src/montest/_types.py new file mode 100644 index 0000000..8df0ff0 --- /dev/null +++ b/src/montest/_types.py @@ -0,0 +1,35 @@ +# Copyright 2026 Banco Bilbao Vizcaya Argentaria, S.A. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import dataclasses +import enum +from typing import Generic, TypeVar + +S = TypeVar("S") + + +class Decision(enum.Enum): + CONTINUE = "continue" + ACCEPT_H1 = "accept_h1" + ACCEPT_H0 = "accept_h0" + INCONCLUSIVE = "inconclusive" + + +@dataclasses.dataclass(frozen=True, slots=True) +class ObservationResult(Generic[S]): + value: S + index: int + decision: Decision diff --git a/src/montest/algorithms/__init__.py b/src/montest/algorithms/__init__.py new file mode 100644 index 0000000..9a6906e --- /dev/null +++ b/src/montest/algorithms/__init__.py @@ -0,0 +1,15 @@ +# Copyright 2026 Banco Bilbao Vizcaya Argentaria, S.A. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +__all__: list[str] = [] diff --git a/src/montest/algorithms/sprt/__init__.py b/src/montest/algorithms/sprt/__init__.py new file mode 100644 index 0000000..9aa42e6 --- /dev/null +++ b/src/montest/algorithms/sprt/__init__.py @@ -0,0 +1,18 @@ +# Copyright 2026 Banco Bilbao Vizcaya Argentaria, S.A. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from montest.algorithms.sprt._algorithm import SPRT, sprt +from montest.algorithms.sprt._result import SPRTResult + +__all__ = ["SPRT", "SPRTResult", "sprt"] diff --git a/src/montest/algorithms/sprt/_algorithm.py b/src/montest/algorithms/sprt/_algorithm.py new file mode 100644 index 0000000..bc6d3a7 --- /dev/null +++ b/src/montest/algorithms/sprt/_algorithm.py @@ -0,0 +1,110 @@ +# Copyright 2026 Banco Bilbao Vizcaya Argentaria, S.A. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import math +from collections.abc import Callable +from typing import Generic, TypeVar + +from montest._types import Decision +from montest.algorithms.sprt._result import SPRTResult + +S = TypeVar("S") + + +class SPRT(Generic[S]): + def __init__( + self, + *, + llr: Callable[[S], float], + alpha: float = 0.05, + beta: float = 0.10, + max_samples: int | None = None, + ) -> None: + if not (0.0 < alpha < 1.0): + raise ValueError(f"alpha must be in (0, 1), got {alpha}") + if not (0.0 < beta < 1.0): + raise ValueError(f"beta must be in (0, 1), got {beta}") + if max_samples is not None and max_samples < 1: + raise ValueError(f"max_samples must be >= 1, got {max_samples}") + + self._llr = llr + self._max_samples = max_samples + self._lower = math.log(beta / (1.0 - alpha)) + self._upper = math.log((1.0 - beta) / alpha) + self._cumulative_llr = 0.0 + self._n_observed = 0 + self._terminal = False + + @property + def lower_bound(self) -> float: + return self._lower + + @property + def upper_bound(self) -> float: + return self._upper + + @property + def cumulative_llr(self) -> float: + return self._cumulative_llr + + @property + def n_observed(self) -> int: + return self._n_observed + + def observe(self, sample: S, *, index: int) -> SPRTResult[S]: + if self._terminal: + raise RuntimeError( + "Criterion already reached a decision; call reset() first." + ) + + self._n_observed += 1 + self._cumulative_llr += self._llr(sample) + + decision = Decision.CONTINUE + if self._cumulative_llr >= self._upper: + decision = Decision.ACCEPT_H1 + elif self._cumulative_llr <= self._lower: + decision = Decision.ACCEPT_H0 + elif self._max_samples is not None and self._n_observed >= self._max_samples: + decision = Decision.INCONCLUSIVE + + if decision is not Decision.CONTINUE: + self._terminal = True + + return SPRTResult( + value=sample, + index=index, + decision=decision, + cumulative_llr=self._cumulative_llr, + lower_bound=self._lower, + upper_bound=self._upper, + n_observed=self._n_observed, + ) + + def reset(self) -> None: + self._cumulative_llr = 0.0 + self._n_observed = 0 + self._terminal = False + + +def sprt( + *, + llr: Callable[[S], float], + alpha: float = 0.05, + beta: float = 0.10, + max_samples: int | None = None, +) -> SPRT[S]: + return SPRT(llr=llr, alpha=alpha, beta=beta, max_samples=max_samples) diff --git a/src/montest/algorithms/sprt/_result.py b/src/montest/algorithms/sprt/_result.py new file mode 100644 index 0000000..af02d8b --- /dev/null +++ b/src/montest/algorithms/sprt/_result.py @@ -0,0 +1,30 @@ +# Copyright 2026 Banco Bilbao Vizcaya Argentaria, S.A. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import dataclasses +from typing import Generic, TypeVar + +from montest._types import ObservationResult + +S = TypeVar("S") + + +@dataclasses.dataclass(frozen=True, slots=True) +class SPRTResult(ObservationResult[S], Generic[S]): + cumulative_llr: float + lower_bound: float + upper_bound: float + n_observed: int diff --git a/src/montest/py.typed b/src/montest/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..c239aa6 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,39 @@ +# Copyright 2026 Banco Bilbao Vizcaya Argentaria, S.A. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +from montest import Decision, ObservationResult + + +class StopAfterN: + def __init__( + self, + n: int, + *, + decision: Decision = Decision.ACCEPT_H1, + ) -> None: + self._n = n + self._decision = decision + self._observed = 0 + + def observe(self, sample: object, *, index: int) -> ObservationResult[object]: + self._observed += 1 + decision = ( + self._decision if self._observed >= self._n else Decision.CONTINUE + ) + return ObservationResult(value=sample, index=index, decision=decision) + + def reset(self) -> None: + self._observed = 0 diff --git a/tests/test_async_sequential_iterator.py b/tests/test_async_sequential_iterator.py new file mode 100644 index 0000000..1b829c7 --- /dev/null +++ b/tests/test_async_sequential_iterator.py @@ -0,0 +1,93 @@ +# Copyright 2026 Banco Bilbao Vizcaya Argentaria, S.A. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import asyncio +from collections.abc import AsyncIterator +from itertools import count + +import pytest + +from montest import AsyncSequentialIterator, Decision, ObservationResult +from tests.conftest import StopAfterN + + +async def _collect( + iterator: AsyncIterator[ObservationResult[object]], +) -> list[ObservationResult[object]]: + return [result async for result in iterator] + + +def test_async_iterator_yields_exact_count_with_default_concurrency() -> None: + values = count() + results = asyncio.run( + _collect(AsyncSequentialIterator(lambda: next(values), StopAfterN(3))) + ) + + assert [result.value for result in results] == [0, 1, 2] + assert [result.index for result in results] == [0, 1, 2] + assert [result.decision for result in results[:-1]] == [ + Decision.CONTINUE, + Decision.CONTINUE, + ] + assert results[-1].decision is Decision.ACCEPT_H1 + + +def test_async_sequential_iterator_accepts_async_generator_function() -> None: + calls = 0 + + async def generate() -> int: + nonlocal calls + calls += 1 + return calls + + results = asyncio.run(_collect(AsyncSequentialIterator(generate, StopAfterN(2)))) + + assert [result.value for result in results] == [1, 2] + assert results[-1].decision is Decision.ACCEPT_H1 + + +def test_async_sequential_iterator_accepts_sync_generator_function() -> None: + values = count(10) + + results = asyncio.run( + _collect(AsyncSequentialIterator(lambda: next(values), StopAfterN(2))) + ) + + assert [result.value for result in results] == [10, 11] + assert results[-1].decision is Decision.ACCEPT_H1 + + +def test_async_sequential_iterator_rejects_non_positive_concurrency() -> None: + with pytest.raises(ValueError, match="concurrency"): + AsyncSequentialIterator(lambda: object(), StopAfterN(1), concurrency=0) + + +def test_async_sequential_iterator_discards_surplus_batch_results() -> None: + calls = 0 + + async def generate() -> int: + nonlocal calls + calls += 1 + return calls + + results = asyncio.run( + _collect(AsyncSequentialIterator(generate, StopAfterN(3), concurrency=4)) + ) + + assert [result.value for result in results] == [1, 2, 3] + assert [result.index for result in results] == [0, 1, 2] + assert results[-1].decision is Decision.ACCEPT_H1 + assert calls == 4 diff --git a/tests/test_composite.py b/tests/test_composite.py new file mode 100644 index 0000000..cce7154 --- /dev/null +++ b/tests/test_composite.py @@ -0,0 +1,160 @@ +# Copyright 2026 Banco Bilbao Vizcaya Argentaria, S.A. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +from collections.abc import Mapping + +import pytest + +from montest import AllOf, AnyOf, CompositeResult, Decision, ObservationResult, sprt +from tests.conftest import StopAfterN + + +def _child_decisions( + result: CompositeResult[object], +) -> Mapping[str, Decision | None]: + return { + key: None if child is None else child.decision + for key, child in result.results.items() + } + + +def test_all_of_runs_until_longest_child_decides() -> None: + criterion = AllOf({"a": StopAfterN(2), "b": StopAfterN(4)}) + + results = [criterion.observe(object(), index=index) for index in range(4)] + + assert [result.index for result in results] == [0, 1, 2, 3] + assert [result.n_decided for result in results] == [0, 1, 1, 2] + assert [result.decision for result in results] == [ + Decision.CONTINUE, + Decision.CONTINUE, + Decision.CONTINUE, + Decision.ACCEPT_H1, + ] + assert results[-1].n_total == 2 + + +def test_all_of_returns_none_for_child_results_after_that_child_decides() -> None: + criterion = AllOf({"a": StopAfterN(2), "b": StopAfterN(4)}) + + criterion.observe(object(), index=0) + first_terminal = criterion.observe(object(), index=1) + after_terminal = criterion.observe(object(), index=2) + + assert first_terminal.results["a"] is not None + assert after_terminal.results["a"] is None + assert isinstance(after_terminal.results["b"], ObservationResult) + + +def test_any_of_stops_when_first_child_accepts_h1() -> None: + criterion = AnyOf({"a": StopAfterN(2), "b": StopAfterN(5)}) + + first = criterion.observe(object(), index=0) + terminal = criterion.observe(object(), index=1) + + assert first.decision is Decision.CONTINUE + assert terminal.decision is Decision.ACCEPT_H1 + assert terminal.n_decided == terminal.n_total == 2 + assert terminal.results["a"] is not None + assert terminal.results["a"].decision is Decision.ACCEPT_H1 + assert terminal.results["b"] is None + + +def test_any_of_waits_for_all_children_when_no_child_accepts_h1() -> None: + criterion = AnyOf( + { + "a": StopAfterN(2, decision=Decision.ACCEPT_H0), + "b": StopAfterN(4, decision=Decision.ACCEPT_H0), + } + ) + + results = [criterion.observe(object(), index=index) for index in range(4)] + + assert [result.decision for result in results] == [ + Decision.CONTINUE, + Decision.CONTINUE, + Decision.CONTINUE, + Decision.ACCEPT_H0, + ] + assert results[2].results["a"] is None + assert results[-1].n_decided == results[-1].n_total == 2 + + +def test_default_resolve_returns_inconclusive_when_no_child_accepts_h1() -> None: + criterion = AllOf( + { + "a": StopAfterN(1, decision=Decision.INCONCLUSIVE), + "b": StopAfterN(1, decision=Decision.ACCEPT_H0), + } + ) + + result = criterion.observe(object(), index=0) + + assert result.decision is Decision.INCONCLUSIVE + assert _child_decisions(result) == { + "a": Decision.INCONCLUSIVE, + "b": Decision.ACCEPT_H0, + } + + +def test_composite_rejects_empty_criteria_mapping() -> None: + with pytest.raises(ValueError, match="^criteria must not be empty$"): + AllOf({}) + + +def test_composite_rejects_empty_string_key() -> None: + with pytest.raises(ValueError, match="^criterion keys must be non-empty strings$"): + AnyOf({"": StopAfterN(1)}) + + +def test_composite_observe_after_terminal_decision_raises_runtime_error() -> None: + criterion = AnyOf({"a": StopAfterN(1)}) + criterion.observe(object(), index=0) + + with pytest.raises(RuntimeError, match="Criterion already reached a decision"): + criterion.observe(object(), index=1) + + +def test_composite_reset_reproduces_same_decision_sequence() -> None: + criterion = AllOf( + { + "a": StopAfterN(1, decision=Decision.ACCEPT_H0), + "b": StopAfterN(3, decision=Decision.INCONCLUSIVE), + } + ) + + first_sequence = [] + for index in range(3): + result = criterion.observe(object(), index=index) + first_sequence.append( + (result.decision, result.n_decided, _child_decisions(result)) + ) + + criterion.reset() + + second_sequence = [] + for index in range(3): + result = criterion.observe(object(), index=index) + second_sequence.append( + (result.decision, result.n_decided, _child_decisions(result)) + ) + + assert second_sequence == first_sequence + + +def test_sprt_does_not_expose_operator_composition_api() -> None: + with pytest.raises(TypeError): + sprt(llr=lambda _: 0.0) & sprt(llr=lambda _: 0.0) diff --git a/tests/test_integration.py b/tests/test_integration.py new file mode 100644 index 0000000..84d479c --- /dev/null +++ b/tests/test_integration.py @@ -0,0 +1,109 @@ +# Copyright 2026 Banco Bilbao Vizcaya Argentaria, S.A. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import asyncio +import math +import random +from collections.abc import AsyncIterator, Callable + +from montest import ( + AsyncSequentialIterator, + Decision, + SequentialIterator, + SPRTResult, + sprt, +) + + +def bernoulli_llr(p0: float, p1: float) -> Callable[[int], float]: + success_llr = math.log(p1 / p0) + failure_llr = math.log((1.0 - p1) / (1.0 - p0)) + + def llr(sample: int) -> float: + return success_llr if sample else failure_llr + + return llr + + +def test_sync_bernoulli_h1_samples_accept_h1_before_two_hundred_samples() -> None: + rng = random.Random(42) + criterion = sprt(llr=bernoulli_llr(0.3, 0.6), max_samples=200) + + results = list( + SequentialIterator(lambda: int(rng.random() < 0.6), criterion) + ) + + assert len(results) < 200 + assert results[-1].decision is Decision.ACCEPT_H1 + + +def test_sync_bernoulli_h0_samples_accept_h0_before_two_hundred_samples() -> None: + rng = random.Random(123) + criterion = sprt(llr=bernoulli_llr(0.3, 0.6), max_samples=200) + + results = list( + SequentialIterator(lambda: int(rng.random() < 0.3), criterion) + ) + + assert len(results) < 200 + assert results[-1].decision is Decision.ACCEPT_H0 + + +async def _last_result( + iterator: AsyncIterator[SPRTResult[int]], +) -> tuple[int, SPRTResult[int]]: + count = 0 + last = None + async for result in iterator: + count += 1 + last = result + assert last is not None + return count, last + + +def test_async_bernoulli_h1_path_returns_sprt_result_and_accepts_h1() -> None: + rng = random.Random(42) + criterion = sprt(llr=bernoulli_llr(0.3, 0.6), max_samples=200) + + async def generate() -> int: + return int(rng.random() < 0.6) + + count, result = asyncio.run( + _last_result(AsyncSequentialIterator(generate, criterion, concurrency=1)) + ) + + assert count < 200 + assert isinstance(result, SPRTResult) + assert result.decision is Decision.ACCEPT_H1 + + +def test_async_bernoulli_concurrent_path_terminates_with_terminal_decision() -> None: + rng = random.Random(42) + criterion = sprt(llr=bernoulli_llr(0.3, 0.6), max_samples=199) + + async def generate() -> int: + return int(rng.random() < 0.6) + + count, result = asyncio.run( + _last_result(AsyncSequentialIterator(generate, criterion, concurrency=4)) + ) + + assert count < 200 + assert result.decision in { + Decision.ACCEPT_H1, + Decision.ACCEPT_H0, + Decision.INCONCLUSIVE, + } diff --git a/tests/test_sequential_iterator.py b/tests/test_sequential_iterator.py new file mode 100644 index 0000000..c418117 --- /dev/null +++ b/tests/test_sequential_iterator.py @@ -0,0 +1,53 @@ +# Copyright 2026 Banco Bilbao Vizcaya Argentaria, S.A. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +from itertools import count + +import pytest +from hypothesis import given +from hypothesis import strategies as st + +from montest import Decision, SequentialIterator +from tests.conftest import StopAfterN + + +def test_sequential_iterator_yields_terminal_result_once_then_stops() -> None: + values = count() + iterator = SequentialIterator( + lambda: next(values), + StopAfterN(4, decision=Decision.ACCEPT_H0), + ) + + results = [next(iterator), next(iterator), next(iterator), next(iterator)] + + assert [result.value for result in results] == [0, 1, 2, 3] + assert [result.index for result in results] == [0, 1, 2, 3] + assert [result.decision for result in results[:-1]] == [ + Decision.CONTINUE, + Decision.CONTINUE, + Decision.CONTINUE, + ] + assert results[-1].decision is Decision.ACCEPT_H0 + with pytest.raises(StopIteration): + next(iterator) + + +@given(st.integers(min_value=1, max_value=200)) +def test_sequential_iterator_yield_count_matches_stopping_boundary(n: int) -> None: + results = list(SequentialIterator(lambda: object(), StopAfterN(n))) + + assert len(results) == n + assert results[-1].decision is not Decision.CONTINUE diff --git a/tests/test_sprt.py b/tests/test_sprt.py new file mode 100644 index 0000000..f20264e --- /dev/null +++ b/tests/test_sprt.py @@ -0,0 +1,169 @@ +# Copyright 2026 Banco Bilbao Vizcaya Argentaria, S.A. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import math + +import pytest +from hypothesis import given +from hypothesis import strategies as st + +from montest import Decision, SPRTResult, sprt + + +def test_sprt_computes_wald_bounds() -> None: + criterion = sprt(llr=lambda _: 0.0, alpha=0.05, beta=0.10) + + assert math.isclose(criterion.lower_bound, math.log(0.10 / (1.0 - 0.05))) + assert math.isclose(criterion.upper_bound, math.log((1.0 - 0.10) / 0.05)) + + +def test_sprt_accepts_h1_when_positive_llr_crosses_upper_bound() -> None: + result = sprt(llr=lambda _: 10.0).observe("sample", index=7) + + assert result.value == "sample" + assert result.index == 7 + assert result.decision is Decision.ACCEPT_H1 + + +def test_sprt_accepts_h0_when_negative_llr_crosses_lower_bound() -> None: + result = sprt(llr=lambda _: -10.0).observe("sample", index=3) + + assert result.decision is Decision.ACCEPT_H0 + + +def test_sprt_continues_while_cumulative_llr_stays_inside_bounds() -> None: + result = sprt(llr=lambda _: 0.0).observe("sample", index=0) + + assert result.decision is Decision.CONTINUE + assert result.cumulative_llr == 0.0 + assert result.n_observed == 1 + + +def test_sprt_accumulates_llr_until_repeated_observations_cross_upper_bound() -> None: + criterion = sprt(llr=lambda _: 1.0, alpha=0.05, beta=0.10) + + decisions = [ + criterion.observe("sample", index=index).decision for index in range(3) + ] + + assert decisions == [Decision.CONTINUE, Decision.CONTINUE, Decision.ACCEPT_H1] + assert criterion.cumulative_llr == 3.0 + assert criterion.n_observed == 3 + + +def test_sprt_result_includes_observation_bounds_and_accumulated_state() -> None: + criterion = sprt(llr=lambda sample: float(sample), alpha=0.05, beta=0.10) + + result = criterion.observe(1.25, index=42) + + assert isinstance(result, SPRTResult) + assert result.value == 1.25 + assert result.index == 42 + assert result.decision is Decision.CONTINUE + assert result.cumulative_llr == 1.25 + assert result.lower_bound == criterion.lower_bound + assert result.upper_bound == criterion.upper_bound + assert result.n_observed == 1 + + +def test_sprt_returns_inconclusive_when_max_samples_reached_inside_bounds() -> None: + criterion = sprt(llr=lambda _: 0.0, max_samples=2) + + first = criterion.observe("first", index=0) + second = criterion.observe("second", index=1) + + assert first.decision is Decision.CONTINUE + assert second.decision is Decision.INCONCLUSIVE + assert second.n_observed == 2 + + +def test_sprt_reset_clears_state_and_allows_reuse_after_terminal_decision() -> None: + criterion = sprt(llr=lambda _: 10.0) + + terminal = criterion.observe("first", index=0) + criterion.reset() + + assert terminal.decision is Decision.ACCEPT_H1 + assert criterion.cumulative_llr == 0.0 + assert criterion.n_observed == 0 + + reused = criterion.observe("second", index=0) + + assert criterion.cumulative_llr == 10.0 + assert criterion.n_observed == 1 + assert reused.value == "second" + assert reused.decision is Decision.ACCEPT_H1 + + +def test_sprt_observe_after_terminal_decision_raises_runtime_error() -> None: + criterion = sprt(llr=lambda _: 10.0) + criterion.observe("sample", index=0) + + with pytest.raises(RuntimeError, match="Criterion already reached a decision"): + criterion.observe("sample", index=1) + + +@pytest.mark.parametrize( + ("kwargs", "match"), + [ + ({"alpha": 0.0}, "alpha"), + ({"alpha": 1.0}, "alpha"), + ({"beta": 0.0}, "beta"), + ({"beta": 1.0}, "beta"), + ({"max_samples": 0}, "max_samples"), + ], +) +def test_sprt_rejects_invalid_error_rates_and_sample_limits( + kwargs: dict[str, float | int], + match: str, +) -> None: + with pytest.raises(ValueError, match=match): + sprt(llr=lambda _: 0.0, **kwargs) + + +@given( + st.lists( + st.floats( + min_value=-2.0, + max_value=2.0, + allow_nan=False, + allow_infinity=False, + width=32, + ), + min_size=1, + max_size=30, + ) +) +def test_sprt_reset_replays_same_decision_trace(increments: list[float]) -> None: + criterion = sprt(llr=lambda sample: sample, max_samples=len(increments)) + + first_trace = [] + for index, increment in enumerate(increments): + result = criterion.observe(increment, index=index) + first_trace.append((result.decision, result.cumulative_llr, result.n_observed)) + if result.decision is not Decision.CONTINUE: + break + + criterion.reset() + + second_trace = [] + for index, increment in enumerate(increments): + result = criterion.observe(increment, index=index) + second_trace.append((result.decision, result.cumulative_llr, result.n_observed)) + if result.decision is not Decision.CONTINUE: + break + + assert second_trace == first_trace diff --git a/uv.lock b/uv.lock index a1ad14f..f0165a2 100644 --- a/uv.lock +++ b/uv.lock @@ -142,18 +142,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f9/0b/de6f54d4a8bedfe8645c41497f3c18d749f0bd3218170c667bf4b81d0cdd/filelock-3.25.0-py3-none-any.whl", hash = "sha256:5ccf8069f7948f494968fc0713c10e5c182a9c9d9eef3a636307a20c2490f047", size = 26427, upload-time = "2026-03-01T15:08:44.593Z" }, ] -[[package]] -name = "gherkin-official" -version = "39.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/2f/cf/8c0f7ec0e041c12ab59fae0c01b95ac69113a2fecb45618780525f8ca5ee/gherkin_official-39.0.0.tar.gz", hash = "sha256:675b9c6c0c342b0ec44bddf927de923adbd79879277816ce96bf248533677060", size = 33683, upload-time = "2026-03-01T16:46:42.382Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/61/b3/743f97b16ef781283cde3c7b06a95b309a75ae2f4003a6611d35abc3c613/gherkin_official-39.0.0-py3-none-any.whl", hash = "sha256:1fd9b8709c00d946c0fd617a9834d4cb2af026213a2e8e7822fe24dd5064fe22", size = 38471, upload-time = "2026-03-01T16:46:43.308Z" }, -] - [[package]] name = "hypothesis" version = "6.151.9" @@ -252,9 +240,6 @@ wheels = [ name = "montest" version = "0.1.0" source = { editable = "." } -dependencies = [ - { name = "gherkin-official" }, -] [package.optional-dependencies] dev = [ @@ -268,7 +253,6 @@ dev = [ [package.metadata] requires-dist = [ - { name = "gherkin-official", specifier = ">=29.0.0" }, { name = "hypothesis", marker = "extra == 'dev'", specifier = ">=6.0.0" }, { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.10.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" }, From 556c66a2eaca0e934434f935afa690e4fa8a54a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roberto=20Abdelkader=20Mart=C3=ADnez=20P=C3=A9rez?= Date: Wed, 8 Jul 2026 12:33:41 +0200 Subject: [PATCH 2/3] Add property tests for stochastic core --- tests/test_composite_properties.py | 429 +++++++++++++++++++++++++++++ tests/test_sprt_properties.py | 281 +++++++++++++++++++ 2 files changed, 710 insertions(+) create mode 100644 tests/test_composite_properties.py create mode 100644 tests/test_sprt_properties.py diff --git a/tests/test_composite_properties.py b/tests/test_composite_properties.py new file mode 100644 index 0000000..edca6bc --- /dev/null +++ b/tests/test_composite_properties.py @@ -0,0 +1,429 @@ +# Copyright 2026 Banco Bilbao Vizcaya Argentaria, S.A. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import dataclasses +from collections.abc import Callable, Mapping + +import pytest +from hypothesis import given, settings +from hypothesis import strategies as st + +from montest import AllOf, AnyOf, CompositeResult, Decision, ObservationResult + +TERMINAL_DECISIONS = st.sampled_from( + [Decision.ACCEPT_H1, Decision.ACCEPT_H0, Decision.INCONCLUSIVE] +) +CompositeFactory = Callable[[Mapping[str, object]], AllOf[object] | AnyOf[object]] + + +@dataclasses.dataclass(frozen=True, slots=True) +class ChildScript: + name: str + terminal_after: int + terminal_decision: Decision + + +@dataclasses.dataclass(frozen=True, slots=True) +class ExpectedChildResult: + value: int + index: int + decision: Decision + + +@dataclasses.dataclass(frozen=True, slots=True) +class ExpectedCompositeResult: + value: int + index: int + decision: Decision + results: Mapping[str, ExpectedChildResult | None] + n_decided: int + n_total: int + + +class ScriptedCriterion: + def __init__(self, terminal_after: int, terminal_decision: Decision) -> None: + self._terminal_after = terminal_after + self._terminal_decision = terminal_decision + self._observed_count = 0 + self._terminal = False + self._observed_indices: list[int] = [] + + @property + def observed_indices(self) -> tuple[int, ...]: + return tuple(self._observed_indices) + + def observe(self, sample: object, *, index: int) -> ObservationResult[object]: + if self._terminal: + raise AssertionError("terminal scripted criterion was observed again") + + self._observed_count += 1 + self._observed_indices.append(index) + decision = Decision.CONTINUE + if self._observed_count >= self._terminal_after: + decision = self._terminal_decision + self._terminal = True + + return ObservationResult(value=sample, index=index, decision=decision) + + def reset(self) -> None: + self._observed_count = 0 + self._terminal = False + self._observed_indices.clear() + + +@st.composite +def child_scripts(draw: st.DrawFn) -> tuple[ChildScript, ...]: + size = draw(st.integers(min_value=1, max_value=6)) + terminal_after_values = draw( + st.lists(st.integers(min_value=1, max_value=20), min_size=size, max_size=size) + ) + terminal_decisions = draw( + st.lists(TERMINAL_DECISIONS, min_size=size, max_size=size) + ) + return tuple( + ChildScript( + name=f"child_{index}", + terminal_after=terminal_after_values[index], + terminal_decision=terminal_decisions[index], + ) + for index in range(size) + ) + + +def _criteria_from_scripts( + scripts: tuple[ChildScript, ...], +) -> dict[str, ScriptedCriterion]: + return { + script.name: ScriptedCriterion( + script.terminal_after, script.terminal_decision + ) + for script in scripts + } + + +def _default_resolve(decisions: list[Decision]) -> Decision: + if any(decision is Decision.ACCEPT_H1 for decision in decisions): + return Decision.ACCEPT_H1 + if any(decision is Decision.INCONCLUSIVE for decision in decisions): + return Decision.INCONCLUSIVE + return Decision.ACCEPT_H0 + + +def _terminal_sequence( + scripts: tuple[ChildScript, ...], terminal_decisions: Mapping[str, Decision] +) -> list[Decision]: + return [ + terminal_decisions[script.name] + for script in scripts + if script.name in terminal_decisions + ] + + +def _all_of_oracle( + scripts: tuple[ChildScript, ...], +) -> tuple[list[ExpectedCompositeResult], dict[str, tuple[int, ...]]]: + observed_counts = {script.name: 0 for script in scripts} + observed_indices: dict[str, list[int]] = {script.name: [] for script in scripts} + terminal_decisions: dict[str, Decision] = {} + results: list[ExpectedCompositeResult] = [] + + for index in range(max(script.terminal_after for script in scripts)): + child_results: dict[str, ExpectedChildResult | None] = {} + for script in scripts: + if script.name in terminal_decisions: + child_results[script.name] = None + continue + + observed_counts[script.name] += 1 + observed_indices[script.name].append(index) + decision = Decision.CONTINUE + if observed_counts[script.name] >= script.terminal_after: + decision = script.terminal_decision + terminal_decisions[script.name] = decision + child_results[script.name] = ExpectedChildResult( + value=index, index=index, decision=decision + ) + + n_decided = len(terminal_decisions) + decision = Decision.CONTINUE + if n_decided == len(scripts): + decision = _default_resolve(_terminal_sequence(scripts, terminal_decisions)) + + results.append( + ExpectedCompositeResult( + value=index, + index=index, + decision=decision, + results=child_results, + n_decided=n_decided, + n_total=len(scripts), + ) + ) + if decision is not Decision.CONTINUE: + return results, { + name: tuple(indices) for name, indices in observed_indices.items() + } + + raise AssertionError("AllOf oracle did not terminate") + + +def _any_of_oracle( + scripts: tuple[ChildScript, ...], +) -> tuple[list[ExpectedCompositeResult], dict[str, tuple[int, ...]]]: + observed_counts = {script.name: 0 for script in scripts} + observed_indices: dict[str, list[int]] = {script.name: [] for script in scripts} + terminal_decisions: dict[str, Decision] = {} + results: list[ExpectedCompositeResult] = [] + + for index in range(max(script.terminal_after for script in scripts)): + child_results: dict[str, ExpectedChildResult | None] = {} + stopped_early = False + for script in scripts: + if script.name in terminal_decisions or stopped_early: + child_results[script.name] = None + continue + + observed_counts[script.name] += 1 + observed_indices[script.name].append(index) + decision = Decision.CONTINUE + if observed_counts[script.name] >= script.terminal_after: + decision = script.terminal_decision + terminal_decisions[script.name] = decision + if decision is Decision.ACCEPT_H1: + stopped_early = True + child_results[script.name] = ExpectedChildResult( + value=index, index=index, decision=decision + ) + + n_decided = len(terminal_decisions) + decision = Decision.CONTINUE + if stopped_early: + decision = _default_resolve(_terminal_sequence(scripts, terminal_decisions)) + n_decided = len(scripts) + elif n_decided == len(scripts): + decision = _default_resolve(_terminal_sequence(scripts, terminal_decisions)) + + results.append( + ExpectedCompositeResult( + value=index, + index=index, + decision=decision, + results=child_results, + n_decided=n_decided, + n_total=len(scripts), + ) + ) + if decision is not Decision.CONTINUE: + return results, { + name: tuple(indices) for name, indices in observed_indices.items() + } + + raise AssertionError("AnyOf oracle did not terminate") + + +def _assert_composite_result_matches( + actual: CompositeResult[object], expected: ExpectedCompositeResult +) -> None: + assert actual.value == expected.value + assert actual.index == expected.index + assert actual.decision is expected.decision + assert actual.n_decided == expected.n_decided + assert actual.n_total == expected.n_total + assert list(actual.results) == list(expected.results) + + for key, expected_child in expected.results.items(): + actual_child = actual.results[key] + if expected_child is None: + assert actual_child is None + continue + + assert isinstance(actual_child, ObservationResult) + assert actual_child.value == expected_child.value + assert actual_child.index == expected_child.index + assert actual_child.decision is expected_child.decision + + +def _run_until_terminal( + criterion: AllOf[object] | AnyOf[object], *, max_steps: int = 25 +) -> list[CompositeResult[object]]: + results: list[CompositeResult[object]] = [] + for index in range(max_steps): + result = criterion.observe(index, index=index) + results.append(result) + if result.decision is not Decision.CONTINUE: + return results + raise AssertionError("composite did not terminate") + + +def _direct_signature( + result: CompositeResult[object], +) -> tuple[object, ...]: + return ( + result.value, + result.index, + result.decision, + result.n_decided, + result.n_total, + tuple( + ( + key, + None + if child is None + else (child.value, child.index, child.decision), + ) + for key, child in result.results.items() + ), + ) + + +def _nested_signature(result: ObservationResult[object] | None) -> object: + if result is None: + return None + if isinstance(result, CompositeResult): + return ( + result.value, + result.index, + result.decision, + result.n_decided, + result.n_total, + tuple( + (key, _nested_signature(child)) + for key, child in result.results.items() + ), + ) + return result.value, result.index, result.decision + + +@settings(max_examples=75, deadline=None, derandomize=True) +@given(scripts=child_scripts()) +def test_all_of_matches_direct_composite_oracle( + scripts: tuple[ChildScript, ...] +) -> None: + criteria = _criteria_from_scripts(scripts) + criterion = AllOf(criteria) + expected_results, expected_indices = _all_of_oracle(scripts) + + for expected in expected_results: + actual = criterion.observe(expected.value, index=expected.index) + _assert_composite_result_matches(actual, expected) + + assert expected_results[-1].decision is _default_resolve( + [script.terminal_decision for script in scripts] + ) + assert { + name: criterion.observed_indices for name, criterion in criteria.items() + } == expected_indices + with pytest.raises(RuntimeError, match="Criterion already reached a decision"): + criterion.observe(0, index=len(expected_results)) + + +@settings(max_examples=75, deadline=None, derandomize=True) +@given(scripts=child_scripts()) +def test_any_of_matches_direct_composite_oracle( + scripts: tuple[ChildScript, ...] +) -> None: + criteria = _criteria_from_scripts(scripts) + criterion = AnyOf(criteria) + expected_results, expected_indices = _any_of_oracle(scripts) + + for expected in expected_results: + actual = criterion.observe(expected.value, index=expected.index) + _assert_composite_result_matches(actual, expected) + + if all(script.terminal_decision is not Decision.ACCEPT_H1 for script in scripts): + assert len(expected_results) == max(script.terminal_after for script in scripts) + assert { + name: criterion.observed_indices for name, criterion in criteria.items() + } == expected_indices + with pytest.raises(RuntimeError, match="Criterion already reached a decision"): + criterion.observe(0, index=len(expected_results)) + + +@pytest.mark.parametrize("composite_factory", [AllOf, AnyOf]) +@settings(max_examples=75, deadline=None, derandomize=True) +@given(scripts=child_scripts()) +def test_composite_reset_replays_trace( + composite_factory: CompositeFactory, scripts: tuple[ChildScript, ...] +) -> None: + criteria = _criteria_from_scripts(scripts) + criterion = composite_factory(criteria) + + first_trace = [ + _direct_signature(result) for result in _run_until_terminal(criterion) + ] + criterion.reset() + assert all(not child.observed_indices for child in criteria.values()) + second_trace = [ + _direct_signature(result) for result in _run_until_terminal(criterion) + ] + + assert second_trace == first_trace + + +@pytest.mark.parametrize( + ("parent_factory", "nested_factory"), + [(AllOf, AllOf), (AllOf, AnyOf), (AnyOf, AllOf), (AnyOf, AnyOf)], +) +@settings(max_examples=25, deadline=None, derandomize=True) +@given( + inner_left_after=st.integers(min_value=1, max_value=4), + inner_left_decision=TERMINAL_DECISIONS, + inner_right_after=st.integers(min_value=1, max_value=4), + inner_right_decision=TERMINAL_DECISIONS, + outer_after=st.integers(min_value=1, max_value=4), + outer_decision=TERMINAL_DECISIONS, +) +def test_nested_composite_results_are_preserved_under_direct_keys( + parent_factory: CompositeFactory, + nested_factory: CompositeFactory, + inner_left_after: int, + inner_left_decision: Decision, + inner_right_after: int, + inner_right_decision: Decision, + outer_after: int, + outer_decision: Decision, +) -> None: + inner_left = ScriptedCriterion(inner_left_after, inner_left_decision) + inner_right = ScriptedCriterion(inner_right_after, inner_right_decision) + nested = nested_factory({"inner_a": inner_left, "inner_b": inner_right}) + outer = ScriptedCriterion(outer_after, outer_decision) + parent = parent_factory({"nested": nested, "outer": outer}) + + first_trace = _run_until_terminal(parent, max_steps=8) + seen_nested_result = False + for result in first_trace: + assert list(result.results) == ["nested", "outer"] + assert "inner_a" not in result.results + assert "inner_b" not in result.results + assert result.n_total == 2 + assert result.n_decided <= 2 + + nested_result = result.results["nested"] + if nested_result is None: + continue + assert isinstance(nested_result, CompositeResult) + assert list(nested_result.results) == ["inner_a", "inner_b"] + assert nested_result.n_total == 2 + seen_nested_result = True + + assert seen_nested_result + assert first_trace[-1].n_decided == 2 + + parent.reset() + second_trace = _run_until_terminal(parent, max_steps=8) + assert [_nested_signature(result) for result in second_trace] == [ + _nested_signature(result) for result in first_trace + ] diff --git a/tests/test_sprt_properties.py b/tests/test_sprt_properties.py new file mode 100644 index 0000000..5de0ffe --- /dev/null +++ b/tests/test_sprt_properties.py @@ -0,0 +1,281 @@ +# Copyright 2026 Banco Bilbao Vizcaya Argentaria, S.A. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import dataclasses +import math + +import pytest +from hypothesis import assume, given, settings +from hypothesis import strategies as st + +from montest import Decision, SPRTResult, sprt + +FINITE_LLR = st.floats( + min_value=-5.0, + max_value=5.0, + allow_nan=False, + allow_infinity=False, + width=64, +) +ERROR_RATE = st.floats( + min_value=0.01, + max_value=0.40, + allow_nan=False, + allow_infinity=False, + width=64, +) + + +@dataclasses.dataclass(frozen=True, slots=True) +class SPRTCase: + increments: tuple[float, ...] + alpha: float + beta: float + max_samples: int | None + + +@dataclasses.dataclass(frozen=True, slots=True) +class ExpectedSPRTResult: + value: float + index: int + decision: Decision + cumulative_llr: float + lower_bound: float + upper_bound: float + n_observed: int + + +@st.composite +def terminal_sprt_cases(draw: st.DrawFn) -> SPRTCase: + alpha = draw(ERROR_RATE) + beta = draw(ERROR_RATE) + increments = list(draw(st.lists(FINITE_LLR, min_size=1, max_size=30))) + use_max_samples = draw(st.booleans()) + + if use_max_samples: + max_samples = draw(st.integers(min_value=1, max_value=len(increments))) + else: + max_samples = None + lower_bound = math.log(beta / (1.0 - alpha)) + upper_bound = math.log((1.0 - beta) / alpha) + if not _trace_crosses_boundary(increments, lower_bound, upper_bound): + cumulative_llr = sum(increments) + increments.append(upper_bound - cumulative_llr + 0.5) + + return SPRTCase( + increments=tuple(increments), + alpha=alpha, + beta=beta, + max_samples=max_samples, + ) + + +def _trace_crosses_boundary( + increments: list[float], lower_bound: float, upper_bound: float +) -> bool: + cumulative_llr = 0.0 + for increment in increments: + cumulative_llr += increment + if cumulative_llr >= upper_bound or cumulative_llr <= lower_bound: + return True + return False + + +def _sprt_oracle(case: SPRTCase) -> list[ExpectedSPRTResult]: + lower_bound = math.log(case.beta / (1.0 - case.alpha)) + upper_bound = math.log((1.0 - case.beta) / case.alpha) + cumulative_llr = 0.0 + results: list[ExpectedSPRTResult] = [] + + for index, increment in enumerate(case.increments): + cumulative_llr += increment + n_observed = index + 1 + decision = Decision.CONTINUE + if cumulative_llr >= upper_bound: + decision = Decision.ACCEPT_H1 + elif cumulative_llr <= lower_bound: + decision = Decision.ACCEPT_H0 + elif case.max_samples is not None and n_observed >= case.max_samples: + decision = Decision.INCONCLUSIVE + + results.append( + ExpectedSPRTResult( + value=increment, + index=index, + decision=decision, + cumulative_llr=cumulative_llr, + lower_bound=lower_bound, + upper_bound=upper_bound, + n_observed=n_observed, + ) + ) + if decision is not Decision.CONTINUE: + break + + return results + + +def _observe_case(case: SPRTCase) -> list[SPRTResult[float]]: + criterion = sprt( + llr=lambda sample: sample, + alpha=case.alpha, + beta=case.beta, + max_samples=case.max_samples, + ) + results: list[SPRTResult[float]] = [] + for index, increment in enumerate(case.increments): + result = criterion.observe(increment, index=index) + results.append(result) + if result.decision is not Decision.CONTINUE: + break + return results + + +def _assert_result_matches_oracle( + result: SPRTResult[float], expected: ExpectedSPRTResult +) -> None: + assert result.value == expected.value + assert result.index == expected.index + assert result.decision is expected.decision + assert result.n_observed == expected.n_observed + assert math.isclose(result.cumulative_llr, expected.cumulative_llr) + assert math.isclose(result.lower_bound, expected.lower_bound) + assert math.isclose(result.upper_bound, expected.upper_bound) + + +def _assert_trace_matches_oracle(case: SPRTCase) -> None: + criterion = sprt( + llr=lambda sample: sample, + alpha=case.alpha, + beta=case.beta, + max_samples=case.max_samples, + ) + expected_results = _sprt_oracle(case) + + for expected in expected_results: + result = criterion.observe(expected.value, index=expected.index) + _assert_result_matches_oracle(result, expected) + + assert expected_results[-1].decision is not Decision.CONTINUE + with pytest.raises(RuntimeError, match="Criterion already reached a decision"): + criterion.observe(0.0, index=len(expected_results)) + + +def _dual(decision: Decision) -> Decision: + if decision is Decision.ACCEPT_H1: + return Decision.ACCEPT_H0 + if decision is Decision.ACCEPT_H0: + return Decision.ACCEPT_H1 + return decision + + +def _is_far_from_boundaries(case: SPRTCase) -> bool: + lower_bound = math.log(case.beta / (1.0 - case.alpha)) + upper_bound = math.log((1.0 - case.beta) / case.alpha) + dual_lower_bound = math.log(case.alpha / (1.0 - case.beta)) + dual_upper_bound = math.log((1.0 - case.alpha) / case.beta) + cumulative_llr = 0.0 + for increment in case.increments: + cumulative_llr += increment + if ( + math.isclose(cumulative_llr, lower_bound, abs_tol=1e-10) + or math.isclose(cumulative_llr, upper_bound, abs_tol=1e-10) + or math.isclose(-cumulative_llr, dual_lower_bound, abs_tol=1e-10) + or math.isclose(-cumulative_llr, dual_upper_bound, abs_tol=1e-10) + ): + return False + return True + + +@settings(max_examples=100, deadline=None, derandomize=True) +@given(case=terminal_sprt_cases()) +def test_sprt_matches_oracle_for_finite_llr_traces(case: SPRTCase) -> None: + _assert_trace_matches_oracle(case) + + +@settings(max_examples=100, deadline=None, derandomize=True) +@given(case=terminal_sprt_cases()) +def test_sprt_reset_replays_oracle_trace(case: SPRTCase) -> None: + criterion = sprt( + llr=lambda sample: sample, + alpha=case.alpha, + beta=case.beta, + max_samples=case.max_samples, + ) + expected_results = _sprt_oracle(case) + + first_trace = [ + criterion.observe(expected.value, index=expected.index) + for expected in expected_results + ] + for actual, expected in zip(first_trace, expected_results, strict=True): + _assert_result_matches_oracle(actual, expected) + + criterion.reset() + assert criterion.cumulative_llr == 0.0 + assert criterion.n_observed == 0 + + second_trace = [ + criterion.observe(expected.value, index=expected.index) + for expected in expected_results + ] + assert second_trace == first_trace + with pytest.raises(RuntimeError, match="Criterion already reached a decision"): + criterion.observe(0.0, index=len(expected_results)) + + +@settings(max_examples=75, deadline=None, derandomize=True) +@given(alpha=ERROR_RATE, beta=ERROR_RATE) +def test_sprt_boundary_equality_is_terminal(alpha: float, beta: float) -> None: + lower_bound = math.log(beta / (1.0 - alpha)) + upper_bound = math.log((1.0 - beta) / alpha) + + upper_result = sprt(llr=lambda sample: sample, alpha=alpha, beta=beta).observe( + upper_bound, index=0 + ) + assert upper_result.decision is Decision.ACCEPT_H1 + assert math.isclose(upper_result.cumulative_llr, upper_bound) + + lower_result = sprt(llr=lambda sample: sample, alpha=alpha, beta=beta).observe( + lower_bound, index=0 + ) + assert lower_result.decision is Decision.ACCEPT_H0 + assert math.isclose(lower_result.cumulative_llr, lower_bound) + + +@settings(max_examples=100, deadline=None, derandomize=True) +@given(case=terminal_sprt_cases()) +def test_sprt_dual_sign_trace_swaps_h0_h1(case: SPRTCase) -> None: + assume(_is_far_from_boundaries(case)) + dual_case = SPRTCase( + increments=tuple(-increment for increment in case.increments), + alpha=case.beta, + beta=case.alpha, + max_samples=case.max_samples, + ) + + original_results = _observe_case(case) + dual_results = _observe_case(dual_case) + + assert len(dual_results) == len(original_results) + for original, dual in zip(original_results, dual_results, strict=True): + assert dual.index == original.index + assert dual.value == -original.value + assert math.isclose(dual.cumulative_llr, -original.cumulative_llr) + assert math.isclose(dual.lower_bound, -original.upper_bound) + assert math.isclose(dual.upper_bound, -original.lower_bound) + assert dual.n_observed == original.n_observed + assert dual.decision is _dual(original.decision) From 5e6f8a777fd2b1e921cca9e16c8128fbb9f914bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roberto=20Abdelkader=20Mart=C3=ADnez=20P=C3=A9rez?= Date: Wed, 8 Jul 2026 12:47:04 +0200 Subject: [PATCH 3/3] Add initial Sphinx documentation --- .readthedocs.yaml | 22 +++ README.md | 8 + Taskfile.yml | 5 + docs/api.rst | 40 +++++ docs/composition.rst | 81 +++++++++ docs/concepts.rst | 51 ++++++ docs/conf.py | 37 +++++ docs/index.rst | 35 ++++ docs/installation.rst | 58 +++++++ docs/integrations.rst | 62 +++++++ docs/iterators.rst | 53 ++++++ docs/quickstart.rst | 38 +++++ docs/sprt.rst | 78 +++++++++ docs/testing.rst | 42 +++++ pyproject.toml | 1 + uv.lock | 379 ++++++++++++++++++++++++++++++++++++++++++ 16 files changed, 990 insertions(+) create mode 100644 .readthedocs.yaml create mode 100644 docs/api.rst create mode 100644 docs/composition.rst create mode 100644 docs/concepts.rst create mode 100644 docs/conf.py create mode 100644 docs/index.rst create mode 100644 docs/installation.rst create mode 100644 docs/integrations.rst create mode 100644 docs/iterators.rst create mode 100644 docs/quickstart.rst create mode 100644 docs/sprt.rst create mode 100644 docs/testing.rst diff --git a/.readthedocs.yaml b/.readthedocs.yaml new file mode 100644 index 0000000..f72c324 --- /dev/null +++ b/.readthedocs.yaml @@ -0,0 +1,22 @@ +# Read the Docs configuration file +# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details + +# Required +version: 2 + +# Set the OS, Python version, and other tools you might need +build: + os: ubuntu-24.04 + tools: + python: "3.13" + +# Build documentation in the "docs/" directory with Sphinx +sphinx: + configuration: docs/conf.py + +# Optionally, but recommended, +# declare the Python requirements required to build your documentation +# See https://docs.readthedocs.io/en/stable/guides/reproducible-builds.html +# python: +# install: +# - requirements: docs/requirements.txt diff --git a/README.md b/README.md index bb1cc5c..c0113a5 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,14 @@ for sample in SequentialIterator(lambda: int(rng.random() < 0.6), criterion): Future testing-tool integrations are intended to live behind optional install groups such as `montest[pytest]` and `montest[behave]`; this release only ships the zero-dependency core. +## Documentation + +Build local documentation with: + +```bash +task docs +``` + ## Development Setup ### Prerequisites (without Nix) diff --git a/Taskfile.yml b/Taskfile.yml index 60456a4..2150c99 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -22,3 +22,8 @@ tasks: TOX_ENV: '{{.TOX_ENV | default "py311"}}' cmds: - uv run tox -e {{.TOX_ENV}} + + docs: + desc: "Build Sphinx documentation" + cmds: + - uv run sphinx-build -W --keep-going -b html docs docs/_build/html diff --git a/docs/api.rst b/docs/api.rst new file mode 100644 index 0000000..79fcc2a --- /dev/null +++ b/docs/api.rst @@ -0,0 +1,40 @@ +API reference +============= + +Core API +-------- + +.. autoclass:: montest.Decision + :members: + +.. autoclass:: montest.ObservationResult + :members: + +.. autoclass:: montest.StoppingCriterion + :members: + +.. autoclass:: montest.SequentialIterator + :members: + +.. autoclass:: montest.AsyncSequentialIterator + :members: + +.. autoclass:: montest.AllOf + :members: + +.. autoclass:: montest.AnyOf + :members: + +.. autoclass:: montest.CompositeResult + :members: + +SPRT +---- + +.. autoclass:: montest.SPRT + :members: + +.. autoclass:: montest.SPRTResult + :members: + +.. autofunction:: montest.sprt diff --git a/docs/composition.rst b/docs/composition.rst new file mode 100644 index 0000000..33882b8 --- /dev/null +++ b/docs/composition.rst @@ -0,0 +1,81 @@ +Composition +=========== + +Montest composes criteria explicitly with ``AllOf`` and ``AnyOf``. Composite +keys are owned by the mapping passed to the composite, not by child criteria. + +Default resolver +---------------- + +When a composite reaches a terminal state, the default resolver applies this +order: + +.. code-block:: python + + if any(decision is Decision.ACCEPT_H1 for decision in decisions): + return Decision.ACCEPT_H1 + if any(decision is Decision.INCONCLUSIVE for decision in decisions): + return Decision.INCONCLUSIVE + return Decision.ACCEPT_H0 + +AllOf +----- + +``AllOf`` observes every non-terminal direct child on every sample. It continues +until all direct children are terminal. + +Rules: + +* already-terminal children are not observed again; +* already-terminal child result entries are ``None`` on later observations; +* ``n_decided`` counts direct children terminal after the current sample; +* ``n_total`` counts direct children; +* the terminal decision is resolved from all direct terminal decisions. + +AnyOf +----- + +``AnyOf`` observes non-terminal children in mapping order. It stops immediately +when any child returns ``Decision.ACCEPT_H1``. + +Rules: + +* ``ACCEPT_H1`` short-circuits the composite; +* unobserved or non-terminal child result entries are ``None`` in an early + terminal result; +* ``ACCEPT_H0`` and ``INCONCLUSIVE`` do not short-circuit; +* if no child accepts H1, the composite continues until all direct children are + terminal; +* on terminal output, ``n_decided`` is ``n_total``. + +Nested composites +----------------- + +Nested composites are stored under their direct mapping key. Their child keys are +not flattened into the parent result. + +.. code-block:: python + + criterion = AllOf( + { + "primary": sprt(llr=primary_llr), + "secondary-group": AnyOf( + { + "secondary-a": sprt(llr=secondary_a_llr), + "secondary-b": sprt(llr=secondary_b_llr), + } + ), + } + ) + +The parent result has direct keys only: + +.. code-block:: python + + result.results.keys() == {"primary", "secondary-group"} + +Operator overloads +------------------ + +Montest does not expose ``&`` or ``|`` composition. Use explicit ``AllOf`` and +``AnyOf`` mappings. diff --git a/docs/concepts.rst b/docs/concepts.rst new file mode 100644 index 0000000..6ecd84f --- /dev/null +++ b/docs/concepts.rst @@ -0,0 +1,51 @@ +Core concepts +============= + +Observations +------------ + +A sample is generated by user code and passed to a stopping criterion. The +criterion returns an observation result describing the sample, its index, and the +current decision. + +``ObservationResult`` contains: + +* ``value``: the observed sample; +* ``index``: the zero-based observation index; +* ``decision``: a :class:`montest.Decision`. + +Decisions +--------- + +``Decision`` has four values: + +* ``CONTINUE``: more observations are needed; +* ``ACCEPT_H1``: terminal acceptance of the alternative hypothesis; +* ``ACCEPT_H0``: terminal acceptance of the null hypothesis; +* ``INCONCLUSIVE``: terminal result when sampling stops without enough evidence. + +Any decision other than ``CONTINUE`` is terminal for the built-in iterators. + +Stopping criteria +----------------- + +A stopping criterion implements this protocol: + +.. code-block:: python + + class StoppingCriterion(Protocol[S, R]): + def observe(self, sample: S, *, index: int) -> R: ... + def reset(self) -> None: ... + +``observe`` consumes one sample. ``reset`` restores reusable initial state. The +current criteria raise ``RuntimeError`` if observed after a terminal decision +before ``reset`` is called. + +Lifecycle +--------- + +.. code-block:: text + + running --CONTINUE--> running + running --ACCEPT_H1/ACCEPT_H0/INCONCLUSIVE--> terminal + terminal --reset()--> running diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 0000000..bf6a43b --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,37 @@ +# Copyright 2026 Banco Bilbao Vizcaya Argentaria, S.A. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path("..", "src").resolve())) + +project = "montest" +author = "Banco Bilbao Vizcaya Argentaria, S.A." +copyright = "2026, Banco Bilbao Vizcaya Argentaria, S.A." + +extensions = [ + "sphinx.ext.autodoc", + "sphinx.ext.autosummary", + "sphinx.ext.intersphinx", +] + +html_theme = "alabaster" + +autodoc_typehints = "description" +intersphinx_mapping = { + "python": ("https://docs.python.org/3", None), +} diff --git a/docs/index.rst b/docs/index.rst new file mode 100644 index 0000000..e02c721 --- /dev/null +++ b/docs/index.rst @@ -0,0 +1,35 @@ +montest documentation +===================== + +Montest is a stochastic testing framework for Python. It tests +non-deterministic systems by evaluating statistical evidence across repeated +observations instead of relying on one-shot binary assertions. + +Current scope +------------- + +The current package ships the zero-dependency stochastic core: + +* sequential sampling helpers; +* sync and async iterators; +* typed observation results; +* stopping criteria; +* Wald SPRT; +* explicit ``AllOf`` and ``AnyOf`` composition. + +Testing-tool integrations are intentionally not part of the current package. +Future pytest and behave integrations should live behind optional install +groups only after those adapters exist and are tested. + +.. toctree:: + :maxdepth: 2 + + installation + quickstart + concepts + sprt + composition + iterators + integrations + testing + api diff --git a/docs/installation.rst b/docs/installation.rst new file mode 100644 index 0000000..a754c8d --- /dev/null +++ b/docs/installation.rst @@ -0,0 +1,58 @@ +Installation +============ + +Core install +------------ + +Install Montest with pip: + +.. code-block:: bash + + pip install montest + +The base install has no runtime dependencies. It contains only the stochastic +core: decisions, observation results, stopping criteria, sequential iterators, +SPRT, and explicit composites. + +Development setup +----------------- + +Install development dependencies with the repository task runner: + +.. code-block:: bash + + task sync + +Common local checks: + +.. code-block:: bash + + task lint + task typecheck + task test + task docs + +Nix development shell +--------------------- + +If Nix flakes are enabled, enter the dev shell first: + +.. code-block:: bash + + nix develop + +The shell provides ``uv``, ``task``, and the supported Python versions. The same +``task`` commands apply inside the shell. + +Documentation tooling +--------------------- + +Sphinx is a development dependency. It is not a runtime dependency of the base +package. + +Future integration extras +------------------------- + +pytest and behave integrations are planned but not present. Do not document or +use runnable ``montest[pytest]`` or ``montest[behave]`` install commands until +those adapters exist. diff --git a/docs/integrations.rst b/docs/integrations.rst new file mode 100644 index 0000000..ce720da --- /dev/null +++ b/docs/integrations.rst @@ -0,0 +1,62 @@ +Testing-framework integrations +============================== + +Current status +-------------- + +Montest currently ships the zero-dependency stochastic core only. pytest and +behave integrations are planned but not implemented. + +This page is contributor-facing. It defines boundaries for future testing +framework adapters; it is not user-facing installation documentation for adapters +that do not exist yet. + +Integration boundary +-------------------- + +A testing-framework integration should: + +* depend on the core API; +* not duplicate SPRT logic; +* not change core result semantics; +* map framework-specific outcomes onto ``Decision`` values; +* preserve typed result records; +* make framework dependencies optional; +* keep the base ``montest`` install dependency-free. + +Packaging rule +-------------- + +Future integrations should live behind optional groups only when they exist and +are tested. Planned names include: + +* ``montest[pytest]``; +* ``montest[behave]``. + +Do not add those extras before the corresponding adapter is implemented. + +Adapter design checklist +------------------------ + +For each integration, answer: + +* What is the framework entry point? +* How are repeated observations generated? +* How is a terminal decision reported? +* How are inconclusive outcomes represented? +* How are stochastic traces exposed for debugging? +* How does async behavior fit? +* How are framework errors distinguished from terminal stochastic decisions? +* What is the minimum extra dependency set? + +Current non-goals +----------------- + +The current package does not include: + +* pytest markers or fixtures; +* behave step decorators; +* Gherkin parsing; +* CLI commands; +* report generation; +* feature-file support. diff --git a/docs/iterators.rst b/docs/iterators.rst new file mode 100644 index 0000000..8dbbea6 --- /dev/null +++ b/docs/iterators.rst @@ -0,0 +1,53 @@ +Iterators +========= + +Criteria can be observed manually or through sequential iterators. + +Manual observation +------------------ + +.. code-block:: python + + criterion = sprt(llr=llr) + result = criterion.observe(sample, index=0) + +Manual observation is useful when another framework owns the sampling loop. + +SequentialIterator +------------------ + +``SequentialIterator`` accepts a zero-argument generator and a stopping criterion. + +Rules: + +* indices are zero-based; +* one sample is generated per ``__next__`` call; +* the terminal result is yielded once; +* the next call after a terminal result raises ``StopIteration``. + +.. code-block:: python + + for result in SequentialIterator(generate, criterion): + handle(result) + +AsyncSequentialIterator +----------------------- + +``AsyncSequentialIterator`` accepts a sync or async zero-argument generator. + +.. code-block:: python + + iterator = AsyncSequentialIterator(generate, criterion, concurrency=4) + +Rules: + +* ``concurrency`` must be at least 1; +* async generators are awaited directly; +* sync generators run through ``asyncio.to_thread``; +* batches are generated with ``asyncio.gather``; +* gathered values are observed in request order, not completion order; +* generated-but-unobserved surplus values are discarded after a terminal decision + inside a batch. + +With ``concurrency > 1``, the generator may be called more times than the number +of yielded results. diff --git a/docs/quickstart.rst b/docs/quickstart.rst new file mode 100644 index 0000000..d33be9a --- /dev/null +++ b/docs/quickstart.rst @@ -0,0 +1,38 @@ +Quick start +=========== + +This example uses a Bernoulli SPRT. The null hypothesis is ``p = 0.3`` and the +alternative hypothesis is ``p = 0.6``. + +.. code-block:: python + + import math + import random + + from montest import Decision, SequentialIterator, sprt + + + def bernoulli_llr(value: int) -> float: + return math.log(0.6 / 0.3) if value else math.log(0.4 / 0.7) + + + rng = random.Random(42) + criterion = sprt(llr=bernoulli_llr, alpha=0.05, beta=0.10) + + for sample in SequentialIterator(lambda: int(rng.random() < 0.6), criterion): + print(sample.index, sample.value, sample.decision.value) + if sample.decision is not Decision.CONTINUE: + break + +Each yielded result records: + +* ``index``: the zero-based observation index; +* ``value``: the generated sample; +* ``decision``: the current decision. + +SPRT results also expose: + +* ``cumulative_llr``; +* ``lower_bound``; +* ``upper_bound``; +* ``n_observed``. diff --git a/docs/sprt.rst b/docs/sprt.rst new file mode 100644 index 0000000..cdfb5f8 --- /dev/null +++ b/docs/sprt.rst @@ -0,0 +1,78 @@ +SPRT +==== + +The Sequential Probability Ratio Test accumulates log-likelihood ratios across +observations. Positive cumulative evidence supports H1. Negative cumulative +evidence supports H0. + +Construction +------------ + +.. code-block:: python + + sprt( + *, + llr: Callable[[S], float], + alpha: float = 0.05, + beta: float = 0.10, + max_samples: int | None = None, + ) + +Parameters: + +* ``llr``: per-sample log-likelihood ratio; +* ``alpha``: Type I error bound; +* ``beta``: Type II error bound; +* ``max_samples``: optional finite cap. + +Wald bounds +----------- + +Montest computes Wald bounds as: + +.. code-block:: python + + lower = math.log(beta / (1.0 - alpha)) + upper = math.log((1.0 - beta) / alpha) + +Decision rules: + +* ``cumulative_llr >= upper`` returns ``Decision.ACCEPT_H1``; +* ``cumulative_llr <= lower`` returns ``Decision.ACCEPT_H0``; +* reaching ``max_samples`` inside the bounds returns ``Decision.INCONCLUSIVE``; +* otherwise the result is ``Decision.CONTINUE``. + +Result fields +------------- + +``SPRTResult`` includes the base observation fields plus: + +* ``cumulative_llr``; +* ``lower_bound``; +* ``upper_bound``; +* ``n_observed``. + +Bernoulli LLR example +--------------------- + +.. code-block:: python + + import math + from collections.abc import Callable + + + def bernoulli_llr(p0: float, p1: float) -> Callable[[int], float]: + success = math.log(p1 / p0) + failure = math.log((1.0 - p1) / (1.0 - p0)) + + def llr(sample: int) -> float: + return success if sample else failure + + return llr + +Numerical responsibility +------------------------ + +``llr`` must return finite floats. Montest executes the sequential test mechanics; +it does not validate that the caller's statistical model is appropriate for the +system under test. diff --git a/docs/testing.rst b/docs/testing.rst new file mode 100644 index 0000000..4c81e9e --- /dev/null +++ b/docs/testing.rst @@ -0,0 +1,42 @@ +Testing Montest +=============== + +Test philosophy +--------------- + +Prefer deterministic oracle tests over probabilistic simulations. Hypothesis is +used to generate finite traces and composite shapes, not to assert empirical +false-positive or false-negative rates. + +High-signal invariants +---------------------- + +SPRT tests should cover: + +* cumulative log-likelihood traces; +* Wald boundary equality; +* H0/H1 sign convention; +* reset replay; +* terminal-state errors; +* inconclusive ``max_samples`` behavior. + +Composite tests should cover: + +* mapping-order observation; +* ``AllOf`` waiting for all direct children; +* ``AnyOf`` short-circuiting only on ``ACCEPT_H1``; +* ``None`` entries for terminal or skipped children; +* direct-child ``n_decided`` and ``n_total`` counts; +* nested composite results being preserved under direct keys; +* reset replay. + +Avoid +----- + +Do not add tests that: + +* depend on unseeded randomness; +* assert statistical guarantees through small simulations; +* duplicate private implementation details as the oracle; +* require optional testing-framework adapters that do not exist yet; +* assert private attributes. diff --git a/pyproject.toml b/pyproject.toml index 6cf996b..21260e7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,6 +27,7 @@ dev = [ "tox>=4.0.0", "ruff>=0.4.0", "mypy>=1.10.0", + "sphinx>=8.0.0", ] [tool.hatch.build.targets.wheel] diff --git a/uv.lock b/uv.lock index f0165a2..ed05565 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,28 @@ version = 1 revision = 3 requires-python = ">=3.11" +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version < '3.12'", +] + +[[package]] +name = "alabaster" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a6/f8/d9c74d0daf3f742840fd818d69cfae176fa332022fd44e3469487d5a9420/alabaster-1.0.0.tar.gz", hash = "sha256:c00dca57bca26fa62a6d7d0a9fcce65f3e026e9bfe33e9c538fd3fbb2144fd9e", size = 24210, upload-time = "2024-07-26T18:15:03.762Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/b3/6b4067be973ae96ba0d615946e314c5ae35f9f993eca561b356540bb0c2b/alabaster-1.0.0-py3-none-any.whl", hash = "sha256:fc6786402dc3fcb2de3cabd5fe455a2db534b371124f1f21de8731783dec828b", size = 13929, upload-time = "2024-07-26T18:15:02.05Z" }, +] + +[[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 = "cachetools" @@ -11,6 +33,89 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ed/9e/5faefbf9db1db466d633735faceda1f94aa99ce506ac450d232536266b32/cachetools-7.0.1-py3-none-any.whl", hash = "sha256:8f086515c254d5664ae2146d14fc7f65c9a4bce75152eb247e5a9c5e6d7b2ecf", size = 13484, upload-time = "2026-02-10T22:24:03.741Z" }, ] +[[package]] +name = "certifi" +version = "2026.6.17" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", size = 134594, upload-time = "2026-06-17T10:31:07.894Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" }, +] + +[[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/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 = "colorama" version = "0.4.6" @@ -133,6 +238,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, ] +[[package]] +name = "docutils" +version = "0.22.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/b6/03bb70946330e88ffec97aefd3ea75ba575cb2e762061e0e62a213befee8/docutils-0.22.4.tar.gz", hash = "sha256:4db53b1fde9abecbb74d91230d32ab626d94f6badfc575d6db9194a49df29968", size = 2291750, upload-time = "2025-12-18T19:00:26.443Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/10/5da547df7a391dcde17f59520a231527b8571e6f46fc8efb02ccb370ab12/docutils-0.22.4-py3-none-any.whl", hash = "sha256:d0013f540772d1420576855455d050a2180186c91c15779301ac2ccb3eeb68de", size = 633196, upload-time = "2025-12-18T19:00:18.077Z" }, +] + [[package]] name = "filelock" version = "3.25.0" @@ -154,6 +268,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c4/f7/5cc291d701094754a1d327b44d80a44971e13962881d9a400235726171da/hypothesis-6.151.9-py3-none-any.whl", hash = "sha256:7b7220585c67759b1b1ef839b1e6e9e3d82ed468cfc1ece43c67184848d7edd9", size = 529307, upload-time = "2026-02-16T22:59:20.443Z" }, ] +[[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 = "imagesize" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6c/e6/7bf14eeb8f8b7251141944835abd42eb20a658d89084b7e1f3e5fe394090/imagesize-2.0.0.tar.gz", hash = "sha256:8e8358c4a05c304f1fccf7ff96f036e7243a189e9e42e90851993c558cfe9ee3", size = 1773045, upload-time = "2026-03-03T14:18:29.941Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/53/fb7122b71361a0d121b669dcf3d31244ef75badbbb724af388948de543e2/imagesize-2.0.0-py2.py3-none-any.whl", hash = "sha256:5667c5bbb57ab3f1fa4bc366f4fbc971db3d5ed011fd2715fd8001f782718d96", size = 9441, upload-time = "2026-03-03T14:18:27.892Z" }, +] + [[package]] name = "iniconfig" version = "2.3.0" @@ -163,6 +295,18 @@ 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 = "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 = "librt" version = "0.8.1" @@ -236,6 +380,80 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b2/c8/d148e041732d631fc76036f8b30fae4e77b027a1e95b7a84bb522481a940/librt-0.8.1-cp314-cp314t-win_arm64.whl", hash = "sha256:bf512a71a23504ed08103a13c941f763db13fb11177beb3d9244c98c29fb4a61", size = 48755, upload-time = "2026-02-17T16:12:47.943Z" }, ] +[[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/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 = "montest" version = "0.1.0" @@ -248,6 +466,8 @@ dev = [ { name = "pytest" }, { name = "pytest-cov" }, { name = "ruff" }, + { name = "sphinx", version = "9.0.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "sphinx", version = "9.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, { name = "tox" }, ] @@ -258,6 +478,7 @@ requires-dist = [ { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" }, { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=5.0.0" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.4.0" }, + { name = "sphinx", marker = "extra == 'dev'", specifier = ">=8.0.0" }, { name = "tox", marker = "extra == 'dev'", specifier = ">=4.0.0" }, ] provides-extras = ["dev"] @@ -410,6 +631,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/06/54/82a6e2ef37f0f23dccac604b9585bdcbd0698604feb64807dcb72853693e/python_discovery-1.1.0-py3-none-any.whl", hash = "sha256:a162893b8809727f54594a99ad2179d2ede4bf953e12d4c7abc3cc9cdbd1437b", size = 30687, upload-time = "2026-02-26T09:42:48.548Z" }, ] +[[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 = "roman-numerals" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/f9/41dc953bbeb056c17d5f7a519f50fdf010bd0553be2d630bc69d1e022703/roman_numerals-4.1.0.tar.gz", hash = "sha256:1af8b147eb1405d5839e78aeb93131690495fe9da5c91856cb33ad55a7f1e5b2", size = 9077, upload-time = "2025-12-17T18:25:34.381Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/54/6f679c435d28e0a568d8e8a7c0a93a09010818634c3c3907fc98d8983770/roman_numerals-4.1.0-py3-none-any.whl", hash = "sha256:647ba99caddc2cc1e55a51e4360689115551bf4476d90e8162cf8c345fe233c7", size = 7676, upload-time = "2025-12-17T18:25:33.098Z" }, +] + [[package]] name = "ruff" version = "0.15.4" @@ -435,6 +680,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3e/0a/9e1be9035b37448ce2e68c978f0591da94389ade5a5abafa4cf99985d1b2/ruff-0.15.4-py3-none-win_arm64.whl", hash = "sha256:60d5177e8cfc70e51b9c5fad936c634872a74209f934c1e79107d11787ad5453", size = 10966776, upload-time = "2026-02-26T20:03:56.908Z" }, ] +[[package]] +name = "snowballstemmer" +version = "3.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/f8/0a71edf031f03c40db17503cb8ca78a69a171254e568e7db241b0ab57ea1/snowballstemmer-3.1.1.tar.gz", hash = "sha256:e07bbc54a0d798fe6010a12398422e62a8bfbba95c394fd0956ef58cb4d3e260", size = 123314, upload-time = "2026-06-03T00:56:40.194Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4c/07/2ebca9b11fb9be7340a818d8d6f63feaebb146be2c4afbd6061701d6df6e/snowballstemmer-3.1.1-py3-none-any.whl", hash = "sha256:7e207fa178741da09cdee59d3ecec3827ad5f92b1fc5c9ff3755b639f71f5752", size = 104164, upload-time = "2026-06-03T00:56:38.614Z" }, +] + [[package]] name = "sortedcontainers" version = "2.4.0" @@ -444,6 +698,122 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" }, ] +[[package]] +name = "sphinx" +version = "9.0.4" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.12'", +] +dependencies = [ + { name = "alabaster", marker = "python_full_version < '3.12'" }, + { name = "babel", marker = "python_full_version < '3.12'" }, + { name = "colorama", marker = "python_full_version < '3.12' and sys_platform == 'win32'" }, + { name = "docutils", marker = "python_full_version < '3.12'" }, + { name = "imagesize", marker = "python_full_version < '3.12'" }, + { name = "jinja2", marker = "python_full_version < '3.12'" }, + { name = "packaging", marker = "python_full_version < '3.12'" }, + { name = "pygments", marker = "python_full_version < '3.12'" }, + { name = "requests", marker = "python_full_version < '3.12'" }, + { name = "roman-numerals", marker = "python_full_version < '3.12'" }, + { name = "snowballstemmer", marker = "python_full_version < '3.12'" }, + { name = "sphinxcontrib-applehelp", marker = "python_full_version < '3.12'" }, + { name = "sphinxcontrib-devhelp", marker = "python_full_version < '3.12'" }, + { name = "sphinxcontrib-htmlhelp", marker = "python_full_version < '3.12'" }, + { name = "sphinxcontrib-jsmath", marker = "python_full_version < '3.12'" }, + { name = "sphinxcontrib-qthelp", marker = "python_full_version < '3.12'" }, + { name = "sphinxcontrib-serializinghtml", marker = "python_full_version < '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/50/a8c6ccc36d5eacdfd7913ddccd15a9cee03ecafc5ee2bc40e1f168d85022/sphinx-9.0.4.tar.gz", hash = "sha256:594ef59d042972abbc581d8baa577404abe4e6c3b04ef61bd7fc2acbd51f3fa3", size = 8710502, upload-time = "2025-12-04T07:45:27.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/3f/4bbd76424c393caead2e1eb89777f575dee5c8653e2d4b6afd7a564f5974/sphinx-9.0.4-py3-none-any.whl", hash = "sha256:5bebc595a5e943ea248b99c13814c1c5e10b3ece718976824ffa7959ff95fffb", size = 3917713, upload-time = "2025-12-04T07:45:24.944Z" }, +] + +[[package]] +name = "sphinx" +version = "9.1.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", +] +dependencies = [ + { name = "alabaster", marker = "python_full_version >= '3.12'" }, + { name = "babel", marker = "python_full_version >= '3.12'" }, + { name = "colorama", marker = "python_full_version >= '3.12' and sys_platform == 'win32'" }, + { name = "docutils", marker = "python_full_version >= '3.12'" }, + { name = "imagesize", marker = "python_full_version >= '3.12'" }, + { name = "jinja2", marker = "python_full_version >= '3.12'" }, + { name = "packaging", marker = "python_full_version >= '3.12'" }, + { name = "pygments", marker = "python_full_version >= '3.12'" }, + { name = "requests", marker = "python_full_version >= '3.12'" }, + { name = "roman-numerals", marker = "python_full_version >= '3.12'" }, + { name = "snowballstemmer", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-applehelp", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-devhelp", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-htmlhelp", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-jsmath", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-qthelp", marker = "python_full_version >= '3.12'" }, + { name = "sphinxcontrib-serializinghtml", marker = "python_full_version >= '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/bd/f08eb0f4eed5c83f1ba2a3bd18f7745a2b1525fad70660a1c00224ec468a/sphinx-9.1.0.tar.gz", hash = "sha256:7741722357dd75f8190766926071fed3bdc211c74dd2d7d4df5404da95930ddb", size = 8718324, upload-time = "2025-12-31T15:09:27.646Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/73/f7/b1884cb3188ab181fc81fa00c266699dab600f927a964df02ec3d5d1916a/sphinx-9.1.0-py3-none-any.whl", hash = "sha256:c84fdd4e782504495fe4f2c0b3413d6c2bf388589bb352d439b2a3bb99991978", size = 3921742, upload-time = "2025-12-31T15:09:25.561Z" }, +] + +[[package]] +name = "sphinxcontrib-applehelp" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/6e/b837e84a1a704953c62ef8776d45c3e8d759876b4a84fe14eba2859106fe/sphinxcontrib_applehelp-2.0.0.tar.gz", hash = "sha256:2f29ef331735ce958efa4734873f084941970894c6090408b079c61b2e1c06d1", size = 20053, upload-time = "2024-07-29T01:09:00.465Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/85/9ebeae2f76e9e77b952f4b274c27238156eae7979c5421fba91a28f4970d/sphinxcontrib_applehelp-2.0.0-py3-none-any.whl", hash = "sha256:4cd3f0ec4ac5dd9c17ec65e9ab272c9b867ea77425228e68ecf08d6b28ddbdb5", size = 119300, upload-time = "2024-07-29T01:08:58.99Z" }, +] + +[[package]] +name = "sphinxcontrib-devhelp" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/d2/5beee64d3e4e747f316bae86b55943f51e82bb86ecd325883ef65741e7da/sphinxcontrib_devhelp-2.0.0.tar.gz", hash = "sha256:411f5d96d445d1d73bb5d52133377b4248ec79db5c793ce7dbe59e074b4dd1ad", size = 12967, upload-time = "2024-07-29T01:09:23.417Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/35/7a/987e583882f985fe4d7323774889ec58049171828b58c2217e7f79cdf44e/sphinxcontrib_devhelp-2.0.0-py3-none-any.whl", hash = "sha256:aefb8b83854e4b0998877524d1029fd3e6879210422ee3780459e28a1f03a8a2", size = 82530, upload-time = "2024-07-29T01:09:21.945Z" }, +] + +[[package]] +name = "sphinxcontrib-htmlhelp" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/93/983afd9aa001e5201eab16b5a444ed5b9b0a7a010541e0ddfbbfd0b2470c/sphinxcontrib_htmlhelp-2.1.0.tar.gz", hash = "sha256:c9e2916ace8aad64cc13a0d233ee22317f2b9025b9cf3295249fa985cc7082e9", size = 22617, upload-time = "2024-07-29T01:09:37.889Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/7b/18a8c0bcec9182c05a0b3ec2a776bba4ead82750a55ff798e8d406dae604/sphinxcontrib_htmlhelp-2.1.0-py3-none-any.whl", hash = "sha256:166759820b47002d22914d64a075ce08f4c46818e17cfc9470a9786b759b19f8", size = 98705, upload-time = "2024-07-29T01:09:36.407Z" }, +] + +[[package]] +name = "sphinxcontrib-jsmath" +version = "1.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/e8/9ed3830aeed71f17c026a07a5097edcf44b692850ef215b161b8ad875729/sphinxcontrib-jsmath-1.0.1.tar.gz", hash = "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8", size = 5787, upload-time = "2019-01-21T16:10:16.347Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/42/4c8646762ee83602e3fb3fbe774c2fac12f317deb0b5dbeeedd2d3ba4b77/sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl", hash = "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178", size = 5071, upload-time = "2019-01-21T16:10:14.333Z" }, +] + +[[package]] +name = "sphinxcontrib-qthelp" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/68/bc/9104308fc285eb3e0b31b67688235db556cd5b0ef31d96f30e45f2e51cae/sphinxcontrib_qthelp-2.0.0.tar.gz", hash = "sha256:4fe7d0ac8fc171045be623aba3e2a8f613f8682731f9153bb2e40ece16b9bbab", size = 17165, upload-time = "2024-07-29T01:09:56.435Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/83/859ecdd180cacc13b1f7e857abf8582a64552ea7a061057a6c716e790fce/sphinxcontrib_qthelp-2.0.0-py3-none-any.whl", hash = "sha256:b18a828cdba941ccd6ee8445dbe72ffa3ef8cbe7505d8cd1fa0d42d3f2d5f3eb", size = 88743, upload-time = "2024-07-29T01:09:54.885Z" }, +] + +[[package]] +name = "sphinxcontrib-serializinghtml" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/44/6716b257b0aa6bfd51a1b31665d1c205fb12cb5ad56de752dfa15657de2f/sphinxcontrib_serializinghtml-2.0.0.tar.gz", hash = "sha256:e9d912827f872c029017a53f0ef2180b327c3f7fd23c87229f7a8e8b70031d4d", size = 16080, upload-time = "2024-07-29T01:10:09.332Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/a7/d2782e4e3f77c8450f727ba74a8f12756d5ba823d81b941f1b04da9d033a/sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl", hash = "sha256:6e2cb0eef194e10c27ec0023bfeb25badbbb5868244cf5bc5bdc04e4464bf331", size = 92072, upload-time = "2024-07-29T01:10:08.203Z" }, +] + [[package]] name = "tomli" version = "2.4.0" @@ -526,6 +896,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, ] +[[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.1.0"