diff --git a/.github/workflows/_checks.yml b/.github/workflows/_checks.yml index 189868f2..1169b2cf 100644 --- a/.github/workflows/_checks.yml +++ b/.github/workflows/_checks.yml @@ -19,6 +19,7 @@ jobs: pytest: runs-on: ubuntu-latest + continue-on-error: ${{ matrix.prerelease }} strategy: fail-fast: false matrix: @@ -28,6 +29,12 @@ jobs: - "3.13" - "3.14" - "3.14t" + prerelease: [false] + include: + - python-version: "3.15" + prerelease: true + - python-version: "3.15t" + prerelease: true steps: - uses: actions/checkout@v6 - uses: extractions/setup-just@v4 @@ -39,12 +46,12 @@ jobs: - run: uv python pin ${{ matrix.python-version }} - run: just install - name: Confirm the interpreter is free-threaded - if: matrix.python-version == '3.14t' + if: endsWith(matrix.python-version, 't') run: uv run --no-sync python -c "import sys; assert not sys._is_gil_enabled(), 'GIL is enabled on a t-build'" - run: just test-ci - - name: Stress the concurrency test (free-threaded only) - if: matrix.python-version == '3.14t' - run: just test tests/test_free_threading.py --count=50 -W error::RuntimeWarning + - name: Stress the thread-race tests (free-threaded only) + if: endsWith(matrix.python-version, 't') + run: just test-race --count=50 docs: runs-on: ubuntu-latest diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0d0134b1..2edde0b2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -23,7 +23,7 @@ jobs: steps: - uses: actions/checkout@v6 - uses: extractions/setup-just@v4 - - uses: astral-sh/setup-uv@v7 + - uses: astral-sh/setup-uv@v8.2.0 # PyPI is irreversible, so it runs FIRST: if it fails the job stops and no # GitHub Release is created advertising a version that never reached PyPI. diff --git a/AGENTS.md b/AGENTS.md index 39ebd1ae..6f86afa5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,8 +13,9 @@ repository** and ships as a separate PyPI package, `modern-di-pytest` included. `just` (task runner) and `uv` (package manager). The [`justfile`](justfile) is the source of truth — `just --list`, or read it. Every recipe carries its intent as a comment. The one thing it does not -say: nothing validates Markdown links outside `docs/`. `just docs-build` runs `mkdocs --strict` over -the site only, and root Markdown, `.github/`, and `docs/agents/` are unchecked. +say: `just docs-build` runs `mkdocs --strict` over the site only. Local links in every Markdown file, +root and `docs/agents/` included, are checked by the `links` job in CI (lychee, offline), which has +no `just` recipe. Run `just install` before `just lint`, in every checkout and worktree. `uv.lock` is gitignored and CI's `just install` runs `uv lock --upgrade` before it syncs, so CI always lints with the newest diff --git a/benchmarks/README.md b/benchmarks/README.md index 7b73ae85..177f2c6b 100644 --- a/benchmarks/README.md +++ b/benchmarks/README.md @@ -29,6 +29,8 @@ cost. Runs in CI (informational, non-gating) and locally via `just bench`. | G13b | Batch of K=100 request cycles, 10 finalizer-less cached REQUEST providers, `await close_async()` | the async close loop when there is nothing to finalize | | G14 | Concurrent cached-hit throughput, N threads (lock-free read) | free-threaded read scaling | | G15 | Concurrent first-resolve, N threads (per-item double-checked creation lock) | free-threaded creation-lock contention | +| G15b | Concurrent first-resolve, N threads, each in its own REQUEST child | that sibling children's creations do not contend | +| G15c | Control: an empty job on the G14/G15 worker pool, N threads | barrier floor inside every G14/G15/G15b batch | | G16 | Warm by-type `resolve(SomeType)`, small graph | `find_provider` lookup on the integration/`@inject` path | | G17 | Warm by-type `resolve(SomeType)`, 200-provider registry | lookup cost at realistic registry scale | | G18 | Warm resolve through an `Alias` to a cached source | the alias hop, read against G2 | @@ -91,9 +93,14 @@ threshold this low workable at all. ### Concurrency (G14/G15) -G14/G15 use a custom N-thread harness (`test_guard_concurrency.py`) — pytest-benchmark -times a parallel batch of worker threads released together behind a barrier, -parametrized over thread count `{1, 2, 4}` so the scaling trend shows within one run. +G14, G15 and G15b use a custom N-thread harness (`test_guard_concurrency.py`). pytest-benchmark +times a parallel batch: one job run by each of N persistent worker threads, released together +behind a barrier and parametrized over thread count `{1, 2, 4}` so the scaling trend shows within +one run. The workers start once per benchmark, outside the timed call, so thread start-up and +join are not in the number; G15c times an empty job on the same pool, which is the floor left in +every batch. G15 compiles its resolvers once and empties the cache in an untimed per-round +setup, so each round times creation and nothing else. Each scenario asserts on what the timed +batch resolved. The GIL vs free-threaded (PEP 703) comparison comes from running the file under each build (same version/arch): diff --git a/benchmarks/test_guard_concurrency.py b/benchmarks/test_guard_concurrency.py index c616f0e2..1ca5d75f 100644 --- a/benchmarks/test_guard_concurrency.py +++ b/benchmarks/test_guard_concurrency.py @@ -1,10 +1,11 @@ # ruff: noqa: ANN001, ANN201 """Guard tier — concurrent-resolution throughput (custom N-thread harness). -pytest-benchmark measures single-thread wall time, so these time a *parallel batch* (N worker -threads released together behind a barrier) as one unit, parametrized over thread count so the -scaling curve is visible **within a single interpreter run** (no cross-interpreter comparison -needed). Two sub-cases: +pytest-benchmark measures single-thread wall time, so these time a *parallel batch* (one job run +by each of N persistent worker threads, released together behind a barrier) as one unit, +parametrized over thread count so the scaling curve is visible **within a single interpreter run** +(no cross-interpreter comparison needed). The workers are started once per benchmark, outside the +timed call, so a batch costs two barrier crossings and no thread start-up. Sub-cases: - G14 concurrent cached-hit: a fixed total number of reads of a warm cached singleton, split across N threads. The cached-hit path is lock-free, so on a free-threaded build (PEP 703) the @@ -13,6 +14,9 @@ they contend on each item's double-checked creation lock (`CacheItem.get_or_create`). Creating one singleton is serialized by design, so this is expected *not* to scale even free-threaded: the measured cost is the contention itself (the known trade-off vs lock-free-slot rivals). +- G15b concurrent first-resolve in sibling children: the same K cold misses, each thread in its + own REQUEST child, so no two threads share a cache item. +- G15c control: an empty job on the same pool, the harness floor inside every batch above. Read the batch-time-vs-thread-count trend, not the absolutes. The GIL vs free-threaded comparison comes from running the whole file under each build (same version/arch), e.g.: @@ -25,6 +29,7 @@ import dataclasses import threading +import typing import pytest @@ -32,21 +37,62 @@ _THREAD_COUNTS = [1, 2, 4] - - -def _run_parallel(worker, n_threads: int) -> None: - # Release all threads together (barrier) so the work overlaps maximally. - barrier = threading.Barrier(n_threads) - - def _target() -> None: - barrier.wait() - worker() - - threads = [threading.Thread(target=_target) for _ in range(n_threads)] - for thread in threads: - thread.start() - for thread in threads: - thread.join() +_TIMEOUT = 30 + + +class _WorkerPool: + """N persistent threads that each run the current job once per ``run``.""" + + def __init__(self, n_threads: int) -> None: + self._start = threading.Barrier(n_threads + 1, timeout=_TIMEOUT) + self._done = threading.Barrier(n_threads + 1, timeout=_TIMEOUT) + self._job: typing.Callable[[int], object] = lambda _: None + self._stopping = False + self._errors: list[BaseException] = [] + self.results: list[object] = [None] * n_threads + self._threads = [threading.Thread(target=self._loop, args=(i,), daemon=True) for i in range(n_threads)] + for thread in self._threads: + thread.start() + + def _loop(self, index: int) -> None: + while True: + try: + self._start.wait() + except threading.BrokenBarrierError: + return + if self._stopping: + return + try: + self.results[index] = self._job(index) + except BaseException as exc: # noqa: BLE001 + self._errors.append(exc) + try: + self._done.wait() + except threading.BrokenBarrierError: + return + + def run(self, job: typing.Callable[[int], object]) -> None: + self._job = job + self._start.wait() + self._done.wait() + if self._errors: + raise self._errors[0] + + def stop(self) -> None: + self._stopping = True + if not (self._start.broken or self._done.broken): + self._start.wait() + self._start.abort() + self._done.abort() + for thread in self._threads: + thread.join(timeout=_TIMEOUT) + + +@pytest.fixture +def pool(n_threads: int) -> typing.Iterator[_WorkerPool]: + worker_pool = _WorkerPool(n_threads) + yield worker_pool + worker_pool.stop() # --- G14: concurrent cached-hit (lock-free read path, fixed total work) ----- @@ -63,19 +109,20 @@ class CachedGroup(Group): @pytest.mark.parametrize("n_threads", _THREAD_COUNTS) -def test_g14_concurrent_cached_hit(benchmark, n_threads): +def test_g14_concurrent_cached_hit(benchmark, n_threads, pool): # Fixed total reads split across N threads: batch time drops with N iff the read path scales. container = Container(scope=Scope.APP, groups=[CachedGroup]) - container.open() warm = container.resolve_provider(CachedGroup.obj) reads_per_thread = _TOTAL_READS // n_threads - def _worker() -> None: + def _job(_: int) -> object: + result = None for _ in range(reads_per_thread): - container.resolve_provider(CachedGroup.obj) + result = container.resolve_provider(CachedGroup.obj) + return result - benchmark(_run_parallel, _worker, n_threads) - assert container.resolve_provider(CachedGroup.obj) is warm # same cached instance + benchmark(pool.run, _job) + assert all(result is warm for result in pool.results) # --- G15: concurrent first-resolve (creation under the double-checked lock) -- @@ -90,26 +137,28 @@ def _worker() -> None: @pytest.mark.parametrize("n_threads", _THREAD_COUNTS) -def test_g15_concurrent_first_resolve(benchmark, n_threads): +def test_g15_concurrent_first_resolve(benchmark, pool): # All N threads race to first-resolve the SAME K cold singletons -> contention on each - # creation lock. Fresh container per round (untimed setup) so every round actually creates. - check = Container(scope=Scope.APP, groups=[_COLD_GROUP]) - check.open() - assert all(check.resolve_provider(p) is not None for p in _COLD_PROVIDERS) - - def _setup() -> "tuple[tuple[Container], dict[str, object]]": - container = Container(scope=Scope.APP, groups=[_COLD_GROUP]) + # creation lock. Resolvers are compiled once up front; the untimed per-round setup only empties + # the cache, so every round creates and none compiles. + container = Container(scope=Scope.APP, groups=[_COLD_GROUP]) + for provider in _COLD_PROVIDERS: + container.resolve_provider(provider) + + def _setup() -> None: + container.close_sync() container.open() - return (container,), {} - - def _batch(container) -> None: - def _worker() -> None: - for provider in _COLD_PROVIDERS: - container.resolve_provider(provider) - _run_parallel(_worker, n_threads) + def _job(_: int) -> list[object]: + return [container.resolve_provider(provider) for provider in _COLD_PROVIDERS] - benchmark.pedantic(_batch, setup=_setup, rounds=120, iterations=1) + benchmark.pedantic(pool.run, args=(_job,), setup=_setup, rounds=120, iterations=1) + first = typing.cast("list[object]", pool.results[0]) + assert [type(obj) for obj in first] == _COLD_TYPES + assert all( + all(mine is theirs for mine, theirs in zip(typing.cast("list[object]", result), first, strict=True)) + for result in pool.results + ) # --- G15b: concurrent first-resolve in sibling children --------------------- @@ -123,29 +172,32 @@ def _worker() -> None: @pytest.mark.parametrize("n_threads", _THREAD_COUNTS) -def test_g15b_concurrent_first_resolve_sibling_children(benchmark, n_threads): +def test_g15b_concurrent_first_resolve_sibling_children(benchmark, n_threads, pool): """Each thread builds its own REQUEST child and first-resolves K cached providers in it. Every creation is a cold miss in a container no other thread touches. Each cache item has its own lock, so these creations never contend; under a lock shared by the tree they would serialize. G15 does not cover this: it races on one root's items, whose locks are shared. """ - check = Container(scope=Scope.APP, groups=[_REQUEST_GROUP]) - check.open() - probe = check.build_child_container(scope=Scope.REQUEST) - assert all(probe.resolve_provider(p) is not None for p in _REQUEST_PROVIDERS) + container = Container(scope=Scope.APP, groups=[_REQUEST_GROUP]) + with container.build_child_container(scope=Scope.REQUEST) as probe: + for provider in _REQUEST_PROVIDERS: + probe.resolve_provider(provider) - def _setup() -> "tuple[tuple[Container], dict[str, object]]": - container = Container(scope=Scope.APP, groups=[_REQUEST_GROUP]) - container.open() - return (container,), {} + def _job(_: int) -> list[object]: + child = container.build_child_container(scope=Scope.REQUEST) + return [child.resolve_provider(provider) for provider in _REQUEST_PROVIDERS] - def _batch(container) -> None: - def _worker() -> None: - child = container.build_child_container(scope=Scope.REQUEST) - for provider in _REQUEST_PROVIDERS: - child.resolve_provider(provider) + benchmark.pedantic(pool.run, args=(_job,), rounds=120, iterations=1) + results = [typing.cast("list[object]", result) for result in pool.results] + assert all([type(obj) for obj in result] == _REQUEST_TYPES for result in results) + assert len({id(obj) for result in results for obj in result}) == n_threads * _K_COLD - _run_parallel(_worker, n_threads) - benchmark.pedantic(_batch, setup=_setup, rounds=120, iterations=1) +# --- G15c: control, the harness floor ---------------------------------------- +@pytest.mark.parametrize("n_threads", _THREAD_COUNTS) +def test_g15c_worker_pool_floor_control(benchmark, n_threads, pool): + # Harness floor: the same batch with an empty job, so the barrier cost inside every + # G14/G15/G15b number is visible in the same run. + benchmark.pedantic(pool.run, args=(lambda index: index,), rounds=120, iterations=1) + assert pool.results == list(range(n_threads)) diff --git a/docs/dev/contributing.md b/docs/dev/contributing.md index 12717c1f..c2994e00 100644 --- a/docs/dev/contributing.md +++ b/docs/dev/contributing.md @@ -22,7 +22,7 @@ CI runs the coverage-enforcing recipe `just test-ci` along with `just lint-ci`. ## Submitting changes 1. Fork the repo and branch off `main`. -2. Make your change with tests; keep **100% line coverage** (CI runs `just test-ci`, which fails below the `report.fail_under = 100` gate in `pyproject.toml`). +2. Make your change with tests; keep **100% line and branch coverage** of `modern_di` (CI runs `just test-ci`, which fails below the `report.fail_under = 100` gate in `pyproject.toml`). 3. Run `just lint` and `just test` locally before pushing (CI runs the non-fixing variants `just lint-ci` / `just test-ci`). 4. For non-trivial changes, the PR body is the spec; the pull-request template walks you through it (why, design, non-goals, verification). 5. Open a pull request upstream. diff --git a/justfile b/justfile index f9454d0b..6cbd9a5c 100644 --- a/justfile +++ b/justfile @@ -34,13 +34,13 @@ adr-check: test *args: uv run --no-sync pytest {{ args }} -# The gated full run: 100% line coverage required. CI runs this. -test-ci: - uv run --no-sync pytest --cov=. --cov-report term-missing --cov-report xml +# Run the thread_race tests only, collecting just the files that hold them. Passes args through. +test-race *args: + uv run --no-sync pytest -m thread_race tests/test_free_threading.py tests/providers/test_cached_factory.py tests/registries/test_providers_registry.py {{ args }} -# Branch-coverage run (diagnostic; line coverage is the enforced gate, not branch). -test-branch: - uv run --no-sync pytest --cov=. --cov-branch +# The gated full run: 100% line and branch coverage of modern_di required. CI runs this. +test-ci: + uv run --no-sync pytest --cov --cov-report term-missing --cov-report xml # Run the guard-tier benchmark suite (zero-dep; pytest-benchmark). Excludes the # comparative tier, whose deps live in benchmarks/comparative and are not in this env. diff --git a/modern_di/dependency_graph.py b/modern_di/dependency_graph.py index ecc4da4c..9d7853bc 100644 --- a/modern_di/dependency_graph.py +++ b/modern_di/dependency_graph.py @@ -207,4 +207,6 @@ def collect_errors(container: "Container", registry: "ProvidersRegistry") -> lis ) case Cycle(providers): errors.append(build_cycle_error(providers, container)) + case _: + typing.assert_never(event) return errors diff --git a/pyproject.toml b/pyproject.toml index acf85c51..b760ba1c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -67,7 +67,6 @@ ignore = [ "D203", # conflicts with D211 "D213", # conflicts with D212 "COM812", # conflicts with the formatter - "ISC001", # conflicts with the formatter "CPY001", # no per-file copyright header "FBT", # boolean positional arguments are fine "TCH", # imports stay real; TYPE_CHECKING-only imports break runtime introspection @@ -79,21 +78,34 @@ isort.no-lines-before = ["standard-library", "local-folder"] # The resolver compiler reads `Factory._creator` and friends by design (one compiler, closed provider # set), and every resolver returns `typing.Any`. "modern_di/resolver_compiler.py" = ["SLF001", "ANN401"] -# White-box tests assert on container and registry internals; that is what they are for. -"tests/**" = ["S101", "SLF001"] +"tests/**" = ["S101"] +# White-box tests that assert on container and registry internals with no public equivalent. +"tests/helpers.py" = ["SLF001"] +"tests/providers/test_alias.py" = ["SLF001"] +"tests/providers/test_cached_factory.py" = ["SLF001"] +"tests/providers/test_context_provider.py" = ["SLF001"] +"tests/providers/test_factory.py" = ["SLF001"] +"tests/registries/test_cache_registry.py" = ["SLF001"] +"tests/registries/test_providers_registry.py" = ["SLF001"] +"tests/test_container.py" = ["SLF001"] +"tests/test_custom_scope.py" = ["SLF001"] +"tests/test_exception_pickling.py" = ["SLF001"] +"tests/test_free_threading.py" = ["SLF001"] +"tests/test_resolver_compiler.py" = ["SLF001"] +"tests/test_wiring.py" = ["SLF001"] # Guard-tier benchmarks are pytest tests too: they assert on the measured ratios. "benchmarks/**" = ["S101"] [tool.pytest.ini_options] -addopts = "" testpaths = ["tests"] asyncio_mode = "auto" filterwarnings = ["error"] +markers = ["thread_race: races threads; CI repeats it 50 times on the free-threaded build"] pythonpath = ["."] asyncio_default_fixture_loop_scope = "function" [tool.coverage] -run.concurrency = ["thread"] -run.omit = ["benchmarks/*"] -report.exclude_also = ["if typing.TYPE_CHECKING:"] +run.source = ["modern_di"] +run.branch = true +report.exclude_also = ["if typing.TYPE_CHECKING:", "typing.assert_never\\("] report.fail_under = 100 diff --git a/tests/providers/test_cached_factory.py b/tests/providers/test_cached_factory.py index 46e65bd7..4040cf19 100644 --- a/tests/providers/test_cached_factory.py +++ b/tests/providers/test_cached_factory.py @@ -406,6 +406,7 @@ def wait_for_waiters(self, count: int) -> bool: return self._state.wait_for(lambda: self.waiting >= count, timeout=5) +@pytest.mark.thread_race def test_concurrent_cache_misses_build_the_value_and_its_dependencies_once() -> None: """Threads that miss a cached `Svc(conn: Conn)` together build one Svc and one transient Conn. @@ -781,3 +782,32 @@ class SlowGroup(Group): assert events == ["second", "first"] assert slow_calls == [0, 1] assert container._cache_registry.cached_count() == 0 + + +async def test_close_async_after_a_cancelled_close_does_not_refinalize_closed_items() -> None: + events: list[str] = [] + + async def slow_finalizer(_: _First) -> None: + if "first-cancelled" not in events: + events.append("first-cancelled") + await asyncio.Event().wait() + events.append("first") + + def sync_finalizer(_: _Second) -> None: + events.append("second") + + class SlowGroup(Group): + first = providers.Factory(creator=_First, cache=providers.CacheSettings(finalizer=slow_finalizer)) + second = providers.Factory(creator=_Second, cache=providers.CacheSettings(finalizer=sync_finalizer)) + + container = Container(groups=[SlowGroup]) + container.resolve(_First) + container.resolve(_Second) + + with pytest.raises(TimeoutError): + await asyncio.wait_for(container.close_async(), timeout=0.05) + assert events == ["second", "first-cancelled"] + + await container.close_async() + + assert events == ["second", "first-cancelled", "first"] diff --git a/tests/providers/test_context_provider.py b/tests/providers/test_context_provider.py index 57edf383..17655778 100644 --- a/tests/providers/test_context_provider.py +++ b/tests/providers/test_context_provider.py @@ -737,7 +737,7 @@ class G(Group): def test_unset_context_error_skips_a_defaulted_provider_of_the_same_type() -> None: def creator(*, optional: _NamedCtx | None, required: _NamedCtx) -> str: - raise NotImplementedError # pragma: no cover - the required argument raises first + raise NotImplementedError optional = providers.ContextProvider(_NamedCtx, scope=Scope.APP, bound_type=None, default=None) @@ -850,11 +850,11 @@ class _SecondCtx: ... def _positional_pair(first: _FirstCtx, second: _SecondCtx) -> str: - raise NotImplementedError # pragma: no cover - the second argument raises first + raise NotImplementedError def _keyword_pair(*, first: _FirstCtx, second: _SecondCtx) -> str: - raise NotImplementedError # pragma: no cover - the second argument raises first + raise NotImplementedError @pytest.mark.parametrize("cache", [False, True]) @@ -874,7 +874,7 @@ class G(Group): def test_unset_context_error_names_the_failing_one_of_two_same_type_providers() -> None: def creator(*, first: _FirstCtx, second: _FirstCtx) -> str: - raise NotImplementedError # pragma: no cover - the second argument raises first + raise NotImplementedError present = providers.ContextProvider(_FirstCtx, scope=Scope.APP, bound_type=None) absent = providers.ContextProvider(_FirstCtx, scope=Scope.REQUEST, bound_type=None) @@ -893,7 +893,7 @@ class G(Group): def test_unset_context_error_names_the_failing_one_of_two_same_type_same_scope_providers() -> None: def creator(*, first: _FirstCtx, second: _FirstCtx) -> str: - raise NotImplementedError # pragma: no cover - the second argument raises first + raise NotImplementedError overridden = providers.ContextProvider(_FirstCtx, scope=Scope.APP, bound_type=None) unset = providers.ContextProvider(_FirstCtx, scope=Scope.APP, bound_type=None) @@ -1143,11 +1143,11 @@ class _NeedsOptCtx: def _takes_nullable_inner(inner: _NeedsOptCtx | None = None) -> _NeedsOptCtx | None: - raise NotImplementedError # pragma: no cover - the inner argument raises first + raise NotImplementedError def _takes_nullable_inner_by_keyword(*, inner: _NeedsOptCtx | None = None) -> _NeedsOptCtx | None: - raise NotImplementedError # pragma: no cover - the inner argument raises first + raise NotImplementedError @pytest.mark.parametrize("cache", [False, True]) diff --git a/tests/providers/test_factory.py b/tests/providers/test_factory.py index a3fa5784..b8c4b27a 100644 --- a/tests/providers/test_factory.py +++ b/tests/providers/test_factory.py @@ -5,7 +5,6 @@ import re import typing import unittest.mock -import warnings import pytest @@ -85,7 +84,7 @@ def test_func_with_broken_annotation() -> None: factory = providers.Factory(creator=func_with_broken_annotation, bound_type=None) app_container = Container() - app_container._providers_registry.add_providers(factory) + app_container.add_providers(factory) with pytest.raises(ArgumentResolutionError, match="has no usable type annotation"): app_container.resolve_provider(factory) @@ -199,7 +198,7 @@ def second_creator(first_factory: str) -> str: second_factory = providers.Factory(creator=second_creator, kwargs={"first_factory": first_factory}) app_container = Container() - app_container._providers_registry.add_providers(first_factory, second_factory) + app_container.add_providers(first_factory, second_factory) assert app_container.resolve_provider(second_factory) == "one two" @@ -214,7 +213,7 @@ def make(x: int | SelfRef = 1) -> SelfRef: factory = providers.Factory(creator=make) app_container = Container() - app_container._providers_registry.add_providers(factory) + app_container.add_providers(factory) result = app_container.resolve(SelfRef) assert isinstance(result, SelfRef) @@ -231,7 +230,7 @@ def make(nested: SelfRefByType = SelfRefByType()) -> SelfRefByType: # noqa: B00 factory = providers.Factory(creator=make) app_container = Container() - app_container._providers_registry.add_providers(factory) + app_container.add_providers(factory) # `nested` is typed as the factory's own bound type: it must not wire to itself, # and with no other provider it falls through to the creator default. @@ -256,7 +255,7 @@ def test_factory_skip_creator_parsing_without_bound_type_warns() -> None: def _union_return_creator() -> int | str: - return 0 # pragma: no cover - never called; only its return annotation is read + return 0 @pytest.mark.parametrize( @@ -275,9 +274,7 @@ def test_factory_warning_points_at_the_factory_call(build: typing.Callable[[], o def test_factory_skip_creator_parsing_with_bound_type_no_warning() -> None: - with warnings.catch_warnings(): - warnings.simplefilter("error") - providers.Factory(creator=str, skip_creator_parsing=True, bound_type=str) + assert providers.Factory(creator=str, skip_creator_parsing=True, bound_type=str).bound_type is str def test_factory_rejects_unknown_kwarg_at_construction() -> None: @@ -299,8 +296,9 @@ def test_factory_unknown_kwarg_suggests_close_match() -> None: def test_factory_kwarg_validation_skips_when_signature_unavailable() -> None: # When inspect.signature raises (e.g. for some C-implemented callables), # the validator silently skips rather than crashing. - with unittest.mock.patch("inspect.signature", side_effect=ValueError): + with unittest.mock.patch("inspect.signature", side_effect=ValueError) as signature: providers.Factory(creator=lambda x=1: x, kwargs={"anything": 1}) + signature.assert_called() def test_factory_allows_extra_kwargs_when_creator_accepts_var_keyword() -> None: @@ -309,7 +307,7 @@ def make(**kwargs: object) -> dict[str, object]: factory = providers.Factory(creator=make, kwargs={"anything": 1, "extra": 2}) container = Container() - container._providers_registry.add_providers(factory) + container.add_providers(factory) result = container.resolve(dict) assert result == {"anything": 1, "extra": 2} @@ -323,7 +321,7 @@ def make(x: SomeUnregisteredType = unittest.mock.ANY) -> str: factory = providers.Factory(creator=make) container = Container() - container._providers_registry.add_providers(factory) + container.add_providers(factory) result = container.resolve(str) assert result == repr(unittest.mock.ANY) @@ -337,8 +335,6 @@ class _UnannotatedGroup(Group): def test_unannotated_param_error_explains_missing_annotation() -> None: - sentinel = object() - assert _unannotated_creator(sentinel) is sentinel # exercise body for coverage container = Container(scope=Scope.APP, groups=[_UnannotatedGroup]) with pytest.raises(ArgumentResolutionError, match="has no usable type annotation"): container.resolve(object) @@ -359,8 +355,6 @@ class _UnionGroup(Group): def test_union_param_error_names_the_union_members() -> None: - dep = _UnionDep1() - assert _union_creator(dep) == str(dep) # exercise body for coverage container = Container(scope=Scope.APP, groups=[_UnionGroup]) with pytest.raises(ArgumentResolutionError, match=r"_UnionDep1 \| _UnionDep2") as exc: container.resolve(str) @@ -521,7 +515,7 @@ def __init__(self, dep: "_OptionalDep | _OtherDep | None") -> None: def test_optional_param_injects_none_when_no_provider() -> None: factory: providers.Factory[_NeedsOptionalSingle] = providers.Factory(creator=_NeedsOptionalSingle, scope=Scope.APP) container = Container(scope=Scope.APP) - container._providers_registry.register(_NeedsOptionalSingle, factory) + container.add_providers(factory) obj = container.resolve(_NeedsOptionalSingle) assert obj.dep is None @@ -530,8 +524,8 @@ def test_optional_param_uses_provider_when_present() -> None: dep_factory: providers.Factory[_OptionalDep] = providers.Factory(creator=_OptionalDep, scope=Scope.APP) factory: providers.Factory[_NeedsOptionalSingle] = providers.Factory(creator=_NeedsOptionalSingle, scope=Scope.APP) container = Container(scope=Scope.APP) - container._providers_registry.register(_OptionalDep, dep_factory) - container._providers_registry.register(_NeedsOptionalSingle, factory) + container.add_providers(dep_factory) + container.add_providers(factory) obj = container.resolve(_NeedsOptionalSingle) assert isinstance(obj.dep, _OptionalDep) @@ -539,7 +533,7 @@ def test_optional_param_uses_provider_when_present() -> None: def test_optional_multi_member_union_injects_none_when_no_provider() -> None: factory: providers.Factory[_NeedsOptionalUnion] = providers.Factory(creator=_NeedsOptionalUnion, scope=Scope.APP) container = Container(scope=Scope.APP) - container._providers_registry.register(_NeedsOptionalUnion, factory) + container.add_providers(factory) obj = container.resolve(_NeedsOptionalUnion) assert obj.dep is None @@ -547,7 +541,7 @@ def test_optional_multi_member_union_injects_none_when_no_provider() -> None: def test_validate_does_not_flag_optional_param_without_provider() -> None: factory: providers.Factory[_NeedsOptionalSingle] = providers.Factory(creator=_NeedsOptionalSingle, scope=Scope.APP) container = Container(scope=Scope.APP) - container._providers_registry.register(_NeedsOptionalSingle, factory) + container.add_providers(factory) container.validate() # must not raise @@ -568,15 +562,13 @@ def test_optional_param_backed_by_unset_context_provider_injects_none() -> None: ) factory: providers.Factory[_NeedsOptionalCtx] = providers.Factory(creator=_NeedsOptionalCtx, scope=Scope.APP) container = Container(scope=Scope.APP) - container._providers_registry.register(_OptionalCtx, ctx_provider) - container._providers_registry.register(_NeedsOptionalCtx, factory) + container.add_providers(ctx_provider) + container.add_providers(factory) assert container.resolve(_NeedsOptionalCtx).ctx is None defaulted = Container(scope=Scope.APP) - defaulted._providers_registry.register( - _OptionalCtx, providers.ContextProvider(scope=Scope.APP, context_type=_OptionalCtx, default=None) - ) - defaulted._providers_registry.register(_NeedsOptionalCtx, factory) + defaulted.add_providers(providers.ContextProvider(scope=Scope.APP, context_type=_OptionalCtx, default=None)) + defaulted.add_providers(factory) assert defaulted.resolve(_NeedsOptionalCtx).ctx is None @@ -592,12 +584,11 @@ def test_skip_creator_parsing_missing_args_raises_di_error() -> None: creator=_needs_two_args, bound_type=int, skip_creator_parsing=True, kwargs={"a": 1} ) container = Container(scope=Scope.APP) - container._providers_registry.register(int, factory) + container.add_providers(factory) with pytest.raises(exceptions.CreatorCallError) as exc_info: container.resolve(int) assert "_needs_two_args" in str(exc_info.value) assert isinstance(exc_info.value, exceptions.ResolutionError) - assert _needs_two_args(1, 2) == 1 + 2 # exercise helper body def test_skip_creator_parsing_missing_args_cached_raises_di_error() -> None: @@ -609,7 +600,7 @@ def test_skip_creator_parsing_missing_args_cached_raises_di_error() -> None: cache=True, ) container = Container(scope=Scope.APP) - container._providers_registry.register(int, factory) + container.add_providers(factory) with pytest.raises(exceptions.CreatorCallError) as exc_info: container.resolve(int) assert "_needs_two_args" in str(exc_info.value) @@ -628,7 +619,7 @@ def test_internal_typeerror_from_creator_body_is_not_wrapped() -> None: creator=_InternalTypeErrorService, bound_type=_InternalTypeErrorService, skip_creator_parsing=True ) container = Container(scope=Scope.APP) - container._providers_registry.register(_InternalTypeErrorService, factory) + container.add_providers(factory) with pytest.raises(TypeError) as exc_info: container.resolve(_InternalTypeErrorService) assert not isinstance(exc_info.value, exceptions.CreatorCallError) @@ -644,7 +635,7 @@ class _UnregisteredDep: class _NeedsUnregistered: def __init__(self, dep: _UnregisteredDep) -> None: - self.dep = dep # pragma: no cover - _UnregisteredDep has no provider, so resolution fails first + self.dep = dep def test_repeated_failing_resolve_breadcrumb_does_not_compound() -> None: @@ -656,14 +647,14 @@ def test_repeated_failing_resolve_breadcrumb_does_not_compound() -> None: """ factory: providers.Factory[_NeedsUnregistered] = providers.Factory(creator=_NeedsUnregistered, scope=Scope.APP) container = Container(scope=Scope.APP) # exercise resolve-time breadcrumb, not validation - container._providers_registry.register(_NeedsUnregistered, factory) + container.add_providers(factory) def _grab() -> str: try: container.resolve(_NeedsUnregistered) except exceptions.ResolutionError as exc: return str(exc) - return "" # pragma: no cover - runs only if resolve() stops raising + return "" first = _grab() second = _grab() @@ -684,17 +675,17 @@ class _MissingDep: class _Leaf2: def __init__(self, dep: _MissingDep) -> None: - self.dep = dep # pragma: no cover - _MissingDep has no provider, so resolution fails first + self.dep = dep class _Parent2: def __init__(self, leaf: _Leaf2) -> None: - self.leaf = leaf # pragma: no cover - _Leaf2 fails to resolve, so _Parent2 is never built + self.leaf = leaf leaf2: providers.Factory[_Leaf2] = providers.Factory(creator=_Leaf2, scope=Scope.APP) parent2: providers.Factory[_Parent2] = providers.Factory(creator=_Parent2, scope=Scope.APP) c2 = Container(scope=Scope.APP) # exercise resolve-time breadcrumb, not validation - c2._providers_registry.register(_Leaf2, leaf2) - c2._providers_registry.register(_Parent2, parent2) + c2.add_providers(leaf2) + c2.add_providers(parent2) # Resolve parent — propagates through leaf → parent step prepended with contextlib.suppress(exceptions.ResolutionError): @@ -707,7 +698,7 @@ def __init__(self, leaf: _Leaf2) -> None: leaf_err = str(exc) assert "Parent2" not in leaf_err, f"Parent2 leaked into leaf error: {leaf_err!r}" else: - pytest.fail("Expected ResolutionError when resolving _Leaf2 directly") # pragma: no cover - if _Leaf2 resolves + pytest.fail("Expected ResolutionError when resolving _Leaf2 directly") def test_cache_true_returns_same_instance() -> None: @@ -797,7 +788,6 @@ def _definition_site_func() -> str: def test_definition_site_function_creator() -> None: - assert _definition_site_func() == "x" # exercise body for coverage factory = providers.Factory(_definition_site_func, bound_type=None) expected = f"{_definition_site_func.__module__}:{_definition_site_func.__code__.co_firstlineno}" assert factory.definition_site == expected @@ -825,7 +815,7 @@ def test_definition_site_memoized(monkeypatch: pytest.MonkeyPatch) -> None: assert first is not None def _boom(_obj: object) -> tuple[list[str], int]: - raise AssertionError # pragma: no cover — must not run; memoization short-circuits before inspect + raise AssertionError monkeypatch.setattr("modern_di.providers.factory.inspect.getsourcelines", _boom) assert factory.definition_site == first # cached; inspect not called again @@ -840,7 +830,6 @@ def test_definition_site_creator_without_module_is_none() -> None: # A creator whose __module__ can't be determined (e.g. a dynamically built callable): # _compute_definition_site must bail out before touching __code__/inspect. creator = _NoModuleCreator() - assert creator() == "x" # exercise body for coverage creator.__module__ = None # ty: ignore[invalid-assignment] factory = providers.Factory(creator, bound_type=str, skip_creator_parsing=True) assert factory.definition_site is None @@ -858,7 +847,6 @@ def __call__(self) -> int: return 1 creator = _Pathological() - assert creator() == 1 # exercise body for coverage factory = providers.Factory(creator, bound_type=None, skip_creator_parsing=True) assert factory.definition_site is None @@ -878,7 +866,6 @@ def __call__(self) -> int: return 1 creator = _StackExhausted() - assert creator() == 1 # exercise body for coverage factory = providers.Factory(creator, bound_type=None, skip_creator_parsing=True) with pytest.raises(RecursionError): _ = factory.definition_site @@ -897,7 +884,7 @@ def __init__(self, hook: None = None) -> None: factory = providers.Factory(scope=Scope.APP, creator=Svc) container = Container() - container._providers_registry.add_providers(factory) + container.add_providers(factory) result = container.resolve(Svc) assert result.hook is None @@ -912,7 +899,7 @@ def __init__(self, hook: None) -> None: factory = providers.Factory(scope=Scope.APP, creator=Svc) container = Container() - container._providers_registry.add_providers(factory) + container.add_providers(factory) result = container.resolve(Svc) assert result.hook is None @@ -954,7 +941,6 @@ def test_positional_only_with_default_stays_on_kwargs_path() -> None: # `prefix` is positional-only WITH a default: the parser drops it from _params, leaving # names == ("dep",) -- a clean-looking prefix. The positional-only guard in _can_call_positionally # must reject it, or `creator(dep_instance)` would bind dep to `prefix` and swallow the "P". - assert _cov_pos_only_creator(dep=_CovLeaf()) == _CovPosOnlyResult(prefix="P", dep=_CovLeaf()) # exercise body class G(Group): dep = providers.Factory(creator=_CovLeaf, scope=Scope.APP) @@ -1042,7 +1028,6 @@ def _cov_needs_one_arg(required: _CovLeaf) -> _CovOneArgResult: def test_cached_positional_binding_typeerror_wraps() -> None: # skip_creator_parsing -> 0 parsed args -> positional-eligible, but the creator needs one. # `creator()` raises a binding TypeError (no inner frame); create_positional must wrap it. - assert _cov_needs_one_arg(_CovLeaf()) == _CovOneArgResult(required=_CovLeaf()) # exercise body class G(Group): thing = providers.Factory( @@ -1155,9 +1140,6 @@ def test_from_type_error_wraps_binding_and_prepends_step() -> None: def _one_arg(x: int) -> int: return x - arg = 5 - assert _one_arg(arg) == arg # exercise body - step = exceptions.ResolutionStep(scope=Scope.APP, name="one_arg", location=None) try: _one_arg() # ty: ignore[missing-argument] # missing arg: binding TypeError, no inner frame (tb_next is None) diff --git a/tests/registries/test_cache_registry.py b/tests/registries/test_cache_registry.py index 2f249be0..99eca76b 100644 --- a/tests/registries/test_cache_registry.py +++ b/tests/registries/test_cache_registry.py @@ -54,11 +54,11 @@ def test_get_or_create_hit_returns_cache_without_resolving() -> None: item = _item() item.cache = "cached" - def resolve() -> object: # pragma: no cover - a cache hit must not resolve + def resolve() -> object: msg = "resolve must not run on a cache hit" raise AssertionError(msg) - def create(_: object) -> str: # pragma: no cover - a cache hit must not create + def create(_: object) -> str: msg = "create must not run on a cache hit" raise AssertionError(msg) @@ -85,11 +85,11 @@ def test_get_or_create_double_checks_under_the_lock() -> None: item = _item() item.lock = _LosingRaceLock(item) # ty: ignore[invalid-assignment] - def resolve() -> object: # pragma: no cover - the re-check under the lock must skip resolve + def resolve() -> object: msg = "resolve must not run when another thread already stored the value" raise AssertionError(msg) - def create(_: object) -> str: # pragma: no cover - the re-check under the lock must skip create + def create(_: object) -> str: msg = "create must not run when another thread already stored the value" raise AssertionError(msg) diff --git a/tests/registries/test_providers_registry.py b/tests/registries/test_providers_registry.py index 58cd1f54..f3a64f33 100644 --- a/tests/registries/test_providers_registry.py +++ b/tests/registries/test_providers_registry.py @@ -112,6 +112,7 @@ def test_providers_registry_register_duplicate_raises() -> None: class _RaceBase: ... +@pytest.mark.thread_race def test_iteration_is_safe_while_another_thread_registers() -> None: registry = ProvidersRegistry() race_types = [type(f"_Race{i}", (_RaceBase,), {}) for i in range(2000)] @@ -140,20 +141,22 @@ def reader() -> None: for _ in range(50): list(iter(registry)) suggester.suggest(_RaceBase, registry) - except BaseException as e: # noqa: BLE001 # pragma: no cover - only if a racing register() breaks reads - errors_seen.append(e) # pragma: no cover - only if a racing register() breaks reads + except BaseException as e: # noqa: BLE001 + errors_seen.append(e) - threads = [threading.Thread(target=writer), threading.Thread(target=reader)] + threads = [threading.Thread(target=writer, daemon=True), threading.Thread(target=reader, daemon=True)] for t in threads: t.start() for t in threads: - t.join() + t.join(timeout=5) + assert not any(t.is_alive() for t in threads) finally: sys.setswitchinterval(old_interval) assert errors_seen == [] +@pytest.mark.thread_race def test_concurrent_first_resolve_of_same_provider_does_not_false_cycle( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -190,23 +193,24 @@ def stalling_compile( def first() -> None: try: container.resolve(_Root) - except BaseException as exc: # noqa: BLE001 # pragma: no cover - pre-fix path only + except BaseException as exc: # noqa: BLE001 errors.append(exc) def second() -> None: entered.wait(timeout=5) # enter only once thread 1 is mid-compile of _Root try: container.resolve(_Root) - except BaseException as exc: # noqa: BLE001 # pragma: no cover - pre-fix path only + except BaseException as exc: # noqa: BLE001 errors.append(exc) finally: release.set() # let thread 1 finish - threads = [threading.Thread(target=first), threading.Thread(target=second)] + threads = [threading.Thread(target=first, daemon=True), threading.Thread(target=second, daemon=True)] for thread in threads: thread.start() for thread in threads: - thread.join() + thread.join(timeout=5) + assert not any(thread.is_alive() for thread in threads) assert not errors # pre-fix: thread 2 raises RecursionError (a false cycle) diff --git a/tests/test_container.py b/tests/test_container.py index 668576b2..ec56998a 100644 --- a/tests/test_container.py +++ b/tests/test_container.py @@ -290,7 +290,7 @@ def __init__(self, inner: object = None) -> None: ... outer = providers.Factory(scope=Scope.APP, creator=Outer, kwargs={"inner": inner}) container = Container() - container._providers_registry.add_providers(inner, outer) + container.add_providers(inner, outer) with pytest.raises(ValidationFailedError) as exc: container.validate() diff --git a/tests/test_dependency_graph.py b/tests/test_dependency_graph.py index 58d34b6d..9e802c38 100644 --- a/tests/test_dependency_graph.py +++ b/tests/test_dependency_graph.py @@ -206,7 +206,7 @@ def test_walk_emits_cycle_closed_through_kwargs_overlay() -> None: b = Factory(scope=Scope.APP, creator=KwCycB, kwargs={"a": a}) # kwargs edge B -> A c = Container(scope=Scope.APP) - c._providers_registry.add_providers(a, b) + c.add_providers(a, b) events = list(walk([a], c)) cycles = [e for e in events if isinstance(e, Cycle)] diff --git a/tests/test_dependency_graph_contract.py b/tests/test_dependency_graph_contract.py index eb93feca..526d0c47 100644 --- a/tests/test_dependency_graph_contract.py +++ b/tests/test_dependency_graph_contract.py @@ -117,7 +117,7 @@ class G(Group): container = Container(scope=Scope.APP, groups=[G]) container.validate() # this call does the walk (clean graph) and sets the registry's validated flag - def _explode(*_: object, **__: object) -> object: # pragma: no cover - a validated registry must not re-walk + def _explode(*_: object, **__: object) -> object: msg = "re-walked" raise AssertionError(msg) @@ -148,7 +148,7 @@ class _Leaf: ... class _Root: def __init__(self, leaf: _Leaf) -> None: - self.leaf = leaf # pragma: no cover - validate() never instantiates providers + self.leaf = leaf class G(Group): leaf = Factory(scope=Scope.REQUEST, creator=_Leaf) diff --git a/tests/test_error_rendering.py b/tests/test_error_rendering.py index 7723ec89..da604e7c 100644 --- a/tests/test_error_rendering.py +++ b/tests/test_error_rendering.py @@ -124,8 +124,6 @@ def test_unknown_factory_kwarg_error_derives_its_own_suggestions() -> None: def creator(timeout: int, retries: int) -> int: return timeout + retries - assert creator(1, retries=2) == creator(2, retries=1) # exercise the body; only __name__ is read below - error = exceptions.UnknownFactoryKwargError( creator=creator, unknown_keys=["timout", "zzz"], known_keys=["retries", "timeout"] ) diff --git a/tests/test_exception_pickling.py b/tests/test_exception_pickling.py index bc8e307f..0faef53d 100644 --- a/tests/test_exception_pickling.py +++ b/tests/test_exception_pickling.py @@ -25,7 +25,7 @@ class Repository: def make_repository(database: Database) -> Repository: - raise NotImplementedError # pragma: no cover - named in errors, never called + raise NotImplementedError class KeywordOnlyError(Exception): diff --git a/tests/test_free_threading.py b/tests/test_free_threading.py index 3d7f0388..6dd3c87c 100644 --- a/tests/test_free_threading.py +++ b/tests/test_free_threading.py @@ -4,16 +4,21 @@ Under the GIL it passes trivially but still exercises the double-checked cache lock and the setdefault-shared CacheItem; on a 3.14t build it runs those paths GIL-free. The free-threaded *interpreter* assertion lives in CI (_checks.yml), not here, to -keep this suite version-agnostic and 100%-line-covered on every build. +keep this suite version-agnostic. See docs/introduction/design-decisions.md for the supported thread-safety boundary. """ import threading +import pytest + from modern_di import Container, Group, Scope, providers from modern_di.exceptions import ContainerClosedError +pytestmark = pytest.mark.thread_race + + class _Leaf: ... @@ -42,7 +47,7 @@ class _StressGroup(Group): def test_concurrent_resolution_shares_app_singletons() -> None: container = Container(groups=[_StressGroup]) n = 32 - barrier = threading.Barrier(n) + barrier = threading.Barrier(n, timeout=5) top_results: list[_Top | None] = [None] * n request_ok: list[bool] = [False] * n errors: list[BaseException] = [] @@ -55,14 +60,15 @@ def worker(i: int) -> None: with container.build_child_container(scope=Scope.REQUEST) as child: obj = child.resolve(_RequestObj) # REQUEST obj wiring the shared APP singleton request_ok[i] = obj.top is top # child sees the same APP instance - except BaseException as exc: # noqa: BLE001 # pragma: no cover - a race would surface here + except BaseException as exc: # noqa: BLE001 errors.append(exc) - threads = [threading.Thread(target=worker, args=(i,)) for i in range(n)] + threads = [threading.Thread(target=worker, args=(i,), daemon=True) for i in range(n)] for thread in threads: thread.start() for thread in threads: - thread.join() + thread.join(timeout=5) + assert not any(thread.is_alive() for thread in threads) assert not errors assert len({id(result) for result in top_results}) == 1 # exactly one shared APP singleton @@ -77,7 +83,7 @@ class G(Group): container.close_sync() n = 8 raised: list[BaseException] = [] - barrier = threading.Barrier(n) + barrier = threading.Barrier(n, timeout=5) def worker() -> None: barrier.wait() # maximize the odds every thread sees the closed container at once @@ -86,11 +92,12 @@ def worker() -> None: except ContainerClosedError as exc: raised.append(exc) - threads = [threading.Thread(target=worker) for _ in range(n)] + threads = [threading.Thread(target=worker, daemon=True) for _ in range(n)] for thread in threads: thread.start() for thread in threads: - thread.join() + thread.join(timeout=5) + assert not any(thread.is_alive() for thread in threads) assert len(raised) == n assert container.closed is True diff --git a/tests/test_integrations.py b/tests/test_integrations.py index 79cd1466..ce1606ad 100644 --- a/tests/test_integrations.py +++ b/tests/test_integrations.py @@ -90,7 +90,7 @@ def test_parse_markers_finds_annotated_marker_params() -> None: marker = Marker(_Service) def handler(a: int, b: typing.Annotated[_Service, marker], *, c: str = "x") -> None: - pass # pragma: no cover - only its signature is parsed; never called + pass assert parse_markers(handler) == {"b": marker} @@ -99,7 +99,7 @@ def test_parse_markers_skips_return_annotation() -> None: marker = Marker(_Service) def handler() -> typing.Annotated[_Service, marker]: - raise NotImplementedError # pragma: no cover - only its signature is parsed; never called + raise NotImplementedError assert parse_markers(handler) == {} @@ -109,14 +109,14 @@ def test_parse_markers_first_marker_wins_per_parameter() -> None: second: Marker[int] = Marker(int) def handler(a: typing.Annotated[_Service, first, second]) -> None: - pass # pragma: no cover - only its signature is parsed; never called + pass assert parse_markers(handler) == {"a": first} def test_parse_markers_returns_empty_dict_when_no_markers() -> None: def handler(a: int) -> None: - pass # pragma: no cover - only its signature is parsed; never called + pass assert parse_markers(handler) == {} @@ -138,7 +138,7 @@ def test_resolve_markers_empty_input_returns_empty_dict() -> None: def test_mark_injected_then_is_injected_round_trips() -> None: def handler() -> None: - pass # pragma: no cover - only marked and checked for the injected flag; never called + pass assert is_injected(handler) is False mark_injected(handler) @@ -167,6 +167,6 @@ def test_integrations_accessible_from_top_level_namespace() -> None: def test_parse_markers_ignores_annotated_metadata_that_is_not_a_marker() -> None: def handler(a: typing.Annotated[int, "not a marker"]) -> None: - pass # pragma: no cover - only its signature is parsed; never called + pass assert parse_markers(handler) == {} diff --git a/tests/test_resolver_compiler.py b/tests/test_resolver_compiler.py index 31b1e4fc..7912b6a4 100644 --- a/tests/test_resolver_compiler.py +++ b/tests/test_resolver_compiler.py @@ -144,11 +144,9 @@ def _count_python_calls(fn: "typing.Callable[[], object]") -> int: calls = 0 def profiler(_frame: object, event: str, _arg: object) -> None: - # pragma: no cover - CPython does not trace inside a profile callback, so coverage - # cannot see this body. That it runs is exactly what the caller's assertion proves. nonlocal calls - if event == "call": # pragma: no cover - runs inside a sys.setprofile callback, which CPython does not trace - calls += 1 # pragma: no cover - runs inside a sys.setprofile callback, which CPython does not trace + if event == "call": + calls += 1 sys.setprofile(profiler) try: @@ -279,7 +277,7 @@ def test_arity_rung_raises_for_a_closed_target(arity: int) -> None: def test_arity_rung_wraps_a_creator_type_error(arity: int) -> None: # A creator whose real signature needs one more argument than the parser reports: the # positional call then raises TypeError, which the resolver must convert to CreatorCallError. - def _needs_one_more(*args: object, extra: object) -> _Bag: # noqa: ARG001 # pragma: no cover - binding fails first + def _needs_one_more(*args: object, extra: object) -> _Bag: # noqa: ARG001 msg = "unreachable - binding fails before the body runs; that is the point" raise AssertionError(msg) @@ -365,7 +363,7 @@ def test_can_call_positionally_rejects_static_kwarg() -> None: # rule 1: a static kwarg makes the plan non-pure, so the kwargs build must run. def creator(dep: _A, req: _Req) -> _Ordered: - raise NotImplementedError # pragma: no cover - parsed for wiring, never resolved + raise NotImplementedError registry = ProvidersRegistry() registry.add_providers(providers.Factory(creator=_A, scope=Scope.APP)) @@ -377,7 +375,7 @@ def creator(dep: _A, req: _Req) -> _Ordered: def test_can_call_positionally_accepts_a_context_provider_dependency() -> None: def creator(dep: _A, req: _Req) -> _Ordered: - raise NotImplementedError # pragma: no cover - parsed for wiring, never resolved + raise NotImplementedError registry = ProvidersRegistry() registry.add_providers( @@ -400,7 +398,7 @@ def test_can_call_positionally_rejects_defaulted_omitted_param() -> None: # rule 2a: `opt` has a default and no provider, so it is omitted -> provider_kwargs is a # strict prefix of the signature, not the whole of it. def creator(dep: _A, opt: int = 5) -> _Ordered: - raise NotImplementedError # pragma: no cover - parsed for wiring, never resolved + raise NotImplementedError registry = ProvidersRegistry() registry.add_providers(providers.Factory(creator=_A, scope=Scope.APP)) @@ -420,7 +418,7 @@ def test_can_call_positionally_rejects_kwargs_overlay_reorder() -> None: # rule 2b: supplying `a` via the kwargs overlay defers it to the end of provider_kwargs, # so the binding order (b, a) no longer matches the signature (a, b). def creator(a: _A, b: _B) -> _Ordered: - raise NotImplementedError # pragma: no cover - parsed for wiring, never resolved + raise NotImplementedError registry = ProvidersRegistry() factory_a = providers.Factory(creator=_A, scope=Scope.APP) @@ -442,7 +440,7 @@ def test_can_call_positionally_rejects_keyword_only_param() -> None: # rule 3: a keyword-only dep can never be passed positionally. def creator(*, dep: _A) -> _Ordered: - raise NotImplementedError # pragma: no cover - parsed for wiring, never resolved + raise NotImplementedError registry = ProvidersRegistry() registry.add_providers(providers.Factory(creator=_A, scope=Scope.APP)) @@ -463,7 +461,7 @@ def test_can_call_positionally_rejects_positional_only_param() -> None: # remaining names look like a clean prefix ("dep",) -- but a positional call would bind # `dep` to the `prefix` slot. The parser's has_positional_only_gap flag must reject it. def creator(prefix: str = "P", /, dep: _A = None) -> _Ordered: # ty: ignore[invalid-parameter-default] - raise NotImplementedError # pragma: no cover - parsed for wiring, never resolved + raise NotImplementedError registry = ProvidersRegistry() registry.add_providers(providers.Factory(creator=_A, scope=Scope.APP)) @@ -486,9 +484,7 @@ class G(Group): calls: list[object] = [] real_signature = inspect.signature - def _spy( - obj: object, *args: object, **kwargs: object - ) -> inspect.Signature: # pragma: no cover - runs only on regression + def _spy(obj: object, *args: object, **kwargs: object) -> inspect.Signature: calls.append(obj) return real_signature(obj, *args, **kwargs) # ty: ignore[invalid-argument-type] @@ -939,7 +935,7 @@ def test_unset_context_error_is_named_from_the_line_of_the_failing_call(cached: """ def _creator(**kwargs: object) -> _Bag: - raise NotImplementedError # pragma: no cover - an argument raises first + raise NotImplementedError present = ContextProvider(_A, scope=Scope.APP, bound_type=None) unset = ContextProvider(_B, scope=Scope.APP, bound_type=None) diff --git a/tests/test_runtime_cycle_guard.py b/tests/test_runtime_cycle_guard.py index b394c53d..f72cb226 100644 --- a/tests/test_runtime_cycle_guard.py +++ b/tests/test_runtime_cycle_guard.py @@ -103,7 +103,7 @@ def test_unvalidated_cycle_raises_circular_dependency_error() -> None: container.resolve(NodeA) except exceptions.CircularDependencyError as exc: _assert_simple_cycle(exc) - else: # pragma: no cover - runs only if the cycle guard stops raising + else: pytest.fail("expected CircularDependencyError") finally: sys.setrecursionlimit(original_limit) @@ -170,7 +170,7 @@ def test_deep_chain_cycle_is_self_contained() -> None: container.resolve(Root) except exceptions.CircularDependencyError as exc: _assert_deep_chain_cycle_is_self_contained(exc) - else: # pragma: no cover - runs only if the cycle guard stops raising + else: pytest.fail("expected CircularDependencyError") finally: sys.setrecursionlimit(original_limit) @@ -207,7 +207,7 @@ class G(Group): container = Container(scope=Scope.APP, groups=[G]) container.validate() # marks the graph validated -> the recursion guard short-circuits below - def _explode(*_: object, **__: object) -> object: # pragma: no cover - validated graph must skip find_cycle_from + def _explode(*_: object, **__: object) -> object: msg = "walked" raise AssertionError(msg) @@ -250,7 +250,7 @@ class G(Group): container.resolve(_CanonicalA) except exceptions.CircularDependencyError as exc: _assert_cycle_is_canonical_and_self_contained(exc) - else: # pragma: no cover - runs only if the cycle guard stops raising + else: pytest.fail("expected CircularDependencyError") finally: sys.setrecursionlimit(limit) @@ -270,7 +270,7 @@ def test_by_reference_cycle_raises_circular_dependency_error() -> None: container.resolve_provider(CycleGroup.a) except exceptions.CircularDependencyError as exc: _assert_simple_cycle(exc) - else: # pragma: no cover - runs only if the cycle guard stops raising + else: pytest.fail("expected CircularDependencyError") finally: sys.setrecursionlimit(original_limit) diff --git a/tests/test_types.py b/tests/test_types.py new file mode 100644 index 00000000..a4495b96 --- /dev/null +++ b/tests/test_types.py @@ -0,0 +1,5 @@ +from modern_di import UNSET + + +def test_unset_repr_names_the_sentinel() -> None: + assert repr(UNSET) == "UNSET" diff --git a/tests/test_types_parser.py b/tests/test_types_parser.py index cd920c4c..b66297de 100644 --- a/tests/test_types_parser.py +++ b/tests/test_types_parser.py @@ -2,7 +2,6 @@ import functools import sys import typing -import warnings import pytest @@ -257,11 +256,12 @@ def test_partial_creator_does_not_crash() -> None: # supported Python. On <=3.13 get_type_hints raises TypeError (warn-skipped); on 3.14+ it # returns {} (parsed cleanly). Either way construction succeeds. partial = functools.partial(_partial_target, y=1) - assert partial(x=2) == _partial_target(x=2, y=1) # exercise _partial_target body for coverage - with warnings.catch_warnings(): - warnings.simplefilter("ignore") + if sys.version_info >= (3, 14): provider = providers.Factory(creator=partial, bound_type=int) - assert provider is not None + else: + with pytest.warns(UserWarning, match="skip_creator_parsing"): + provider = providers.Factory(creator=partial, bound_type=int) + assert provider.bound_type is int class _GenericDep: ... @@ -282,7 +282,7 @@ def test_parameterized_generic_param_supplied_via_kwargs_is_allowed() -> None: sentinel = [_GenericDep()] provider = providers.Factory(creator=_generic_param_creator, kwargs={"x": sentinel}) container = Container(scope=Scope.APP) - container._providers_registry.register(str, provider) + container.add_providers(provider) assert container.resolve(str) == str(sentinel) @@ -294,7 +294,7 @@ def test_parameterized_generic_param_with_default_is_allowed() -> None: assert _generic_param_with_default(("a",)) == str(("a",)) provider = providers.Factory(creator=_generic_param_with_default) container = Container(scope=Scope.APP) - container._providers_registry.register(str, provider) + container.add_providers(provider) assert container.resolve(str) == str(()) @@ -333,7 +333,6 @@ def _mixed_kind_creator(pos_or_kw: int, *, kw_only: int) -> int: def test_keyword_only_signal_recorded() -> None: # A keyword-only parameter records is_keyword_only=True; a positional-or-keyword one records # False. This is the only param-kind signal the compiled positional fast path consults. - assert _mixed_kind_creator(1, kw_only=2) == 1 + 2 # exercise the creator body for coverage params = parse_creator(_mixed_kind_creator).params assert params["pos_or_kw"].is_keyword_only is False assert params["kw_only"].is_keyword_only is True @@ -455,6 +454,4 @@ def test_union_return_type_without_bound_type_warns() -> None: def test_union_return_type_is_silent_when_bound_type_is_known( creator: typing.Callable[..., typing.Any], bound_type: type | None ) -> None: - with warnings.catch_warnings(): - warnings.simplefilter("error") - providers.Factory(creator, bound_type=bound_type) + providers.Factory(creator, bound_type=bound_type) diff --git a/tests/test_wiring.py b/tests/test_wiring.py index 2a5a9291..6139e4f1 100644 --- a/tests/test_wiring.py +++ b/tests/test_wiring.py @@ -48,7 +48,7 @@ def __init__( req: _Request, with_default: int = 42, ) -> None: - pass # pragma: no cover - never instantiated; WiringPlan.build only reads its signature + pass class _NullableNoDefaultCreator: @@ -58,7 +58,7 @@ class _NullableNoDefaultCreator: """ def __init__(self, nullable: str | None) -> None: - pass # pragma: no cover - never instantiated; WiringPlan.build only reads its signature + pass def test_wiring_plan_partitioning() -> None: @@ -120,7 +120,7 @@ def test_wiring_plan_nullable_no_default_goes_to_static_kwargs() -> None: class _UnwirableCreator: def __init__(self, required_dep: _ServiceA) -> None: - pass # pragma: no cover - never instantiated; WiringPlan.build only reads its signature + pass def test_wiring_plan_unwireable_no_raise() -> None: @@ -151,7 +151,7 @@ def test_wiring_plan_unwireable_no_raise() -> None: class _MixedOwner: def __init__(self, x: _ServiceA, y: _ServiceB) -> None: - pass # pragma: no cover - never instantiated; WiringPlan.build only reads its signature + pass def test_wiring_plan_edges_include_static_supplied_providers() -> None: @@ -279,7 +279,7 @@ def test_find_dep_provider_union_members_skips_owner() -> None: class _OrderedDeps: def __init__(self, first: _ServiceA, second: _ServiceB, third: _Request) -> None: - pass # pragma: no cover - never instantiated; WiringPlan.build only reads its signature + pass def test_provider_kwargs_preserves_signature_order() -> None: