From 511943cf4b6e3a52e60d57fe6b5d2f24b1587a9e Mon Sep 17 00:00:00 2001 From: Lancetnik Date: Thu, 13 Aug 2026 00:15:38 +0300 Subject: [PATCH 1/2] test: reach 100% coverage The reported 98% was measured over a reduced set of lines: the `'\.\.\.'` exclude pattern is unanchored, so it also matched annotations like `tuple[Any, ...]`. Coverage drops the whole statement a matched line belongs to - and for a `def` that means signature *and* body - so `CallModel.__init__`, `_solve`, `solve` and `asolve` were not measured at all. `"from .*"`/`"import .*"` were unanchored too and swallowed `) from err` continuation lines. Anchor those patterns (and drop the ones matching nothing), which puts 2806 statements under measurement instead of 1966, then close the gap: - `Provider.merge` via call-level `dependency_provider=` on solve/asolve - `ExceptionGroup` unwrapping for async `field=True` customs - `evaluate_forwardref` / `eval_type_backport` / `is_backport_fixable_error` - `ValidationError.__str__` - `SerializerProto.encode` and `Serializer.get_aliases` defaults - msgspec `use_fastdepends_errors=False` and `msgspec.field(name=...)` - pydantic non-class response types and the `model_fields` fallback - `inject()` with a prebuilt `CallModel` Coverage stays cumulative across CI jobs. Pydantic v1 encodes through orjson/ujson when installed, so add a `test-json-backends` job for both and make the v1 encode assertions separator-agnostic. `coverage report` now runs with `--fail-under=100`. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/tests.yml | 42 ++++++- fast_depends/core/model.py | 2 +- fast_depends/use.py | 11 +- pyproject.toml | 18 +-- tests/library/test_custom.py | 21 ++++ tests/library/test_serializer.py | 51 +++++++++ tests/serializers/msgspec/test_custom_type.py | 6 +- tests/serializers/msgspec/test_serializer.py | 99 +++++++++++++++++ tests/serializers/pydantic/test_compat.py | 24 ++++ tests/serializers/pydantic/test_encode.py | 17 +-- tests/serializers/pydantic/test_serializer.py | 20 ++++ tests/test_compat.py | 105 ++++++++++++++++++ tests/test_exceptions.py | 57 ++++++++++ tests/test_inject.py | 24 ++++ tests/test_provider.py | 86 ++++++++++++++ tests/test_utils.py | 6 +- 16 files changed, 552 insertions(+), 37 deletions(-) create mode 100644 tests/library/test_serializer.py create mode 100644 tests/serializers/msgspec/test_serializer.py create mode 100644 tests/serializers/pydantic/test_compat.py create mode 100644 tests/serializers/pydantic/test_serializer.py create mode 100644 tests/test_compat.py create mode 100644 tests/test_exceptions.py create mode 100644 tests/test_inject.py create mode 100644 tests/test_provider.py diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 9bdf6484..9a9db632 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -122,8 +122,44 @@ jobs: if-no-files-found: error include-hidden-files: true + # Pydantic v1 encodes JSON through orjson/ujson when either is installed, + # so both backends need a run of their own. + test-json-backends: + runs-on: ubuntu-latest + strategy: + matrix: + json-backend: ["orjson", "ujson"] + fail-fast: false + + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-python@v7 + with: + python-version: '3.12' + - uses: actions/cache@v6 + id: cache + with: + path: ${{ env.pythonLocation }} + key: ${{ matrix.json-backend }}-python-${{ env.pythonLocation }}-${{ hashFiles('pyproject.toml') }} + - name: Install Dependencies + if: steps.cache.outputs.cache-hit != 'true' + run: pip install --group test . "pydantic>=1.10.0,<2.0.0" ${{ matrix.json-backend }} + - run: mkdir coverage + - name: Test + run: bash scripts/test.sh + env: + COVERAGE_FILE: coverage/.coverage.${{ matrix.json-backend }} + CONTEXT: ${{ matrix.json-backend }} + - name: Store coverage files + uses: actions/upload-artifact@v7 + with: + name: .coverage.${{ matrix.json-backend }} + path: coverage + if-no-files-found: error + include-hidden-files: true + coverage-combine: - needs: [test-pydantic,test-msgspec,test-no-serializer] + needs: [test-pydantic,test-msgspec,test-no-serializer,test-json-backends] runs-on: ubuntu-latest steps: @@ -144,10 +180,12 @@ jobs: - run: ls -la coverage - run: coverage combine coverage - - run: coverage report + - run: coverage report --fail-under=100 - run: coverage html --show-contexts --title "FastDepends coverage for ${{ github.sha }}" + if: always() - name: Store coverage html + if: always() uses: actions/upload-artifact@v7 with: name: coverage-html diff --git a/fast_depends/core/model.py b/fast_depends/core/model.py index 928c9ccc..bab5c1d8 100644 --- a/fast_depends/core/model.py +++ b/fast_depends/core/model.py @@ -332,7 +332,7 @@ async def asolve( custom_to_solve.append(custom) except ExceptionGroup as exgr: - for ex in exgr.exceptions: + for ex in exgr.exceptions: # pragma: no branch raise ex from None for j in custom_to_solve: diff --git a/fast_depends/use.py b/fast_depends/use.py index 5b81c6be..230b91f5 100644 --- a/fast_depends/use.py +++ b/fast_depends/use.py @@ -20,13 +20,12 @@ SerializerCls: Optional["SerializerProto"] = None -if SerializerCls is None: - try: - from fast_depends.pydantic import PydanticSerializer +try: + from fast_depends.pydantic import PydanticSerializer - SerializerCls = PydanticSerializer() - except ImportError: - pass + SerializerCls = PydanticSerializer() +except ImportError: + pass if SerializerCls is None: try: diff --git a/pyproject.toml b/pyproject.toml index 0b23fefb..1a4b0244 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -184,19 +184,19 @@ omit = [ show_missing = true skip_empty = true exclude_also = [ - "if __name__ == .__main__.:", - "self.logger", + # Never executed at runtime. + "if TYPE_CHECKING:", + 'if __name__ == "__main__":', "def __repr__", - "lambda: None", - "from .*", - "import .*", '@(abc\.)?abstractmethod', "raise NotImplementedError", 'raise AssertionError', - 'raise ValueError', - 'logger\..*', - "pass", - '\.\.\.', + # Stub bodies only: a bare `pass`/`...` on its own line or right after a colon. + # Do not relax these into unanchored patterns - `\.\.\.` alone also matches + # annotations such as `tuple[Any, ...]`, and coverage then drops the whole + # `def` (signature *and* body) from measurement. + '^\s*pass$', + '(^|:)\s*\.\.\.$', ] omit = [ '*/__about__.py', diff --git a/tests/library/test_custom.py b/tests/library/test_custom.py index 40ab9fc3..fd9962b8 100644 --- a/tests/library/test_custom.py +++ b/tests/library/test_custom.py @@ -45,6 +45,15 @@ async def use_field(self, kwargs: Any) -> None: kwargs[self.param_name] = v +class FailingAsyncFieldHeader(Header): + def __init__(self) -> None: + super().__init__() + self.field = True + + async def use_field(self, kwargs: Any) -> None: + raise ValueError("failed to resolve field") + + def test_header(): @inject def sync_catch(key: int = Header()): # noqa: B008 @@ -53,6 +62,18 @@ def sync_catch(key: int = Header()): # noqa: B008 assert sync_catch(headers={"key": 1}) == 1 +@pytest.mark.anyio +async def test_async_field_header_error_is_unwrapped(): + @inject + async def async_catch(key=FailingAsyncFieldHeader()): # noqa: B008 + raise AssertionError("unreachable") + + # `field=True` customs are resolved inside an `anyio` task group, which wraps + # failures into an `ExceptionGroup` - the original error must be re-raised as is + with pytest.raises(ValueError, match="failed to resolve field"): + await async_catch(headers={"key": 1}) + + def test_custom_with_class(): class T: @inject diff --git a/tests/library/test_serializer.py b/tests/library/test_serializer.py new file mode 100644 index 00000000..49cacba0 --- /dev/null +++ b/tests/library/test_serializer.py @@ -0,0 +1,51 @@ +from typing import Any + +from fast_depends import Depends, Provider, inject +from fast_depends.library.serializer import OptionItem, Serializer, SerializerProto + + +class EchoSerializer(Serializer): + """The smallest possible `Serializer`: everything else is inherited.""" + + def __call__(self, call_kwargs: dict[str, Any]) -> dict[str, Any]: + return call_kwargs + + +class EchoSerializerFactory(SerializerProto): + def __call__( + self, + *, + name: str, + options: list[OptionItem], + response_type: Any, + ) -> EchoSerializer: + return EchoSerializer( + name=name, + options=options, + response_type=response_type, + ) + + +def test_default_encode_uses_stdlib_json() -> None: + assert EchoSerializerFactory.encode({"a": 1}) == b'{"a": 1}' + + +def test_default_get_aliases_is_empty() -> None: + serializer = EchoSerializer(name="func", options=[], response_type=None) + assert serializer.get_aliases() == () + + +def test_minimal_serializer_is_usable() -> None: + def dep() -> int: + return 1 + + @inject( + serializer_cls=EchoSerializerFactory(), + dependency_provider=Provider(), + cast_result=True, + ) + def func(a: int, *, b: str = "b", d: int = Depends(dep)) -> str: + return f"{a}-{b}-{d}" + + # nothing is casted - the serializer echoes its input back + assert func("1") == "1-b-1" diff --git a/tests/serializers/msgspec/test_custom_type.py b/tests/serializers/msgspec/test_custom_type.py index 82f1e03b..d60b64b2 100644 --- a/tests/serializers/msgspec/test_custom_type.py +++ b/tests/serializers/msgspec/test_custom_type.py @@ -15,9 +15,9 @@ def __init__(self, value): def msgspec_custom_type_decoder(t: type[T], obj: Any) -> T: - if not isinstance(obj, t): - return t(obj) - return obj + # msgspec only calls `dec_hook` for types it cannot handle itself, so `obj` + # is always the still-undecoded raw value here + return t(obj) def dep(a: CustomType) -> str: diff --git a/tests/serializers/msgspec/test_serializer.py b/tests/serializers/msgspec/test_serializer.py new file mode 100644 index 00000000..1a288d5e --- /dev/null +++ b/tests/serializers/msgspec/test_serializer.py @@ -0,0 +1,99 @@ +import msgspec +import pytest + +from fast_depends import Depends, Provider, inject +from fast_depends.exceptions import ValidationError +from fast_depends.msgspec import MsgSpecSerializer + + +class TestNativeErrors: + """`use_fastdepends_errors=False` keeps the original `msgspec` errors.""" + + serializer = MsgSpecSerializer(use_fastdepends_errors=False) + + def test_arguments_are_casted(self) -> None: + @inject(serializer_cls=self.serializer, dependency_provider=Provider()) + def func(a: int, b: float): + return a, b + + assert func("1", b="2.5") == (1, 2.5) + + def test_response_is_casted(self) -> None: + @inject( + serializer_cls=self.serializer, + dependency_provider=Provider(), + cast_result=True, + ) + def func(a: int) -> float: + return a + + result = func("1") + assert isinstance(result, float) + assert result == 1.0 + + def test_argument_error_is_not_wrapped(self) -> None: + @inject(serializer_cls=self.serializer, dependency_provider=Provider()) + def func(a: int): + raise AssertionError("unreachable") + + with pytest.raises(msgspec.ValidationError): + func("not-an-int") + + def test_response_error_is_not_wrapped(self) -> None: + @inject( + serializer_cls=self.serializer, + dependency_provider=Provider(), + cast_result=True, + ) + def func() -> int: + return "not-an-int" + + with pytest.raises(msgspec.ValidationError): + func() + + +class TestFastDependsErrors: + """`use_fastdepends_errors=True` (the default) wraps them instead.""" + + serializer = MsgSpecSerializer(use_fastdepends_errors=True) + + def test_argument_error_is_wrapped(self) -> None: + @inject(serializer_cls=self.serializer, dependency_provider=Provider()) + def func(a: int): + raise AssertionError("unreachable") + + with pytest.raises(ValidationError): + func("not-an-int") + + def test_response_error_is_wrapped(self) -> None: + @inject( + serializer_cls=self.serializer, + dependency_provider=Provider(), + cast_result=True, + ) + def func() -> int: + return "not-an-int" + + with pytest.raises(ValidationError): + func() + + +@pytest.mark.parametrize( + "serializer", + ( + pytest.param(MsgSpecSerializer(use_fastdepends_errors=True), id="wrapped"), + pytest.param(MsgSpecSerializer(use_fastdepends_errors=False), id="native"), + ), +) +def test_field_alias(serializer: MsgSpecSerializer) -> None: + def dep(nested: int = msgspec.field(name="nestedAlias")) -> int: + return nested + + @inject(serializer_cls=serializer, dependency_provider=Provider()) + def func( + a: int = msgspec.field(name="aliasedA"), + d: int = Depends(dep), + ) -> tuple[int, int]: + return a, d + + assert func(aliasedA="1", nestedAlias="2") == (1, 2) diff --git a/tests/serializers/pydantic/test_compat.py b/tests/serializers/pydantic/test_compat.py new file mode 100644 index 00000000..85738642 --- /dev/null +++ b/tests/serializers/pydantic/test_compat.py @@ -0,0 +1,24 @@ +from typing import Any + +from pydantic import BaseModel + +from fast_depends.pydantic._compat import get_model_fields +from tests.marks import pydanticV2 + + +class Model(BaseModel): + a: int + + +@pydanticV2 +def test_get_model_fields_falls_back_to_model_fields() -> None: + """Pydantic < 2.11 exposes no `__pydantic_fields__`, only the (now deprecated) + `model_fields` attribute. + """ + + class WithoutPydanticFields: + model_fields: dict[str, Any] = dict(get_model_fields(Model)) + + assert get_model_fields(WithoutPydanticFields) == ( # type: ignore[arg-type] + WithoutPydanticFields.model_fields + ) diff --git a/tests/serializers/pydantic/test_encode.py b/tests/serializers/pydantic/test_encode.py index 35fbac9c..ba7b8d79 100644 --- a/tests/serializers/pydantic/test_encode.py +++ b/tests/serializers/pydantic/test_encode.py @@ -37,19 +37,10 @@ def test_encode_v2( ("message", "expected_message"), ( *parametrized, - pytest.param( - {"m": 1}, - b'{"m": 1}', - id="dict", - ), - pytest.param( - [1, 2, 3], - b"[1, 2, 3]", - id="list", - ), + *comptex_params, pytest.param( SimpleModel(r="hello!"), - b'{"r": "hello!"}', + b'{"r":"hello!"}', id="model", ), ), @@ -60,4 +51,6 @@ def test_encode_v1( expected_message: bytes, ) -> None: msg = PydanticSerializer.encode(message) - assert msg == expected_message + # Pydantic v1 encodes through orjson/ujson/stdlib json, whichever is + # installed, and they disagree on separator whitespace only. + assert msg.replace(b", ", b",").replace(b": ", b":") == expected_message diff --git a/tests/serializers/pydantic/test_serializer.py b/tests/serializers/pydantic/test_serializer.py new file mode 100644 index 00000000..028af51f --- /dev/null +++ b/tests/serializers/pydantic/test_serializer.py @@ -0,0 +1,20 @@ +from fast_depends import Provider, inject +from fast_depends.pydantic import PydanticSerializer + + +def test_non_class_response_type() -> None: + """`issubclass()` raises for non-class annotations such as unions. + + The serializer has to fall back to a `TypeAdapter` instead of treating the + annotation as a model. + """ + + @inject( + serializer_cls=PydanticSerializer(), + dependency_provider=Provider(), + cast_result=True, + ) + def func(a: int) -> int | None: + return a + + assert func("1") == 1 diff --git a/tests/test_compat.py b/tests/test_compat.py new file mode 100644 index 00000000..7f01da1a --- /dev/null +++ b/tests/test_compat.py @@ -0,0 +1,105 @@ +import sys +import types +import typing +from types import NoneType + +import pytest + +from fast_depends._compat import ( + eval_type_backport, + evaluate_forwardref, + is_backport_fixable_error, +) + +# `X | Y` and `X[Y]` are valid syntax on every supported Python version, so a +# backport-fixable `TypeError` can only be provoked with a non-type operand. +NOT_A_TYPE = object() +BACKPORT_NS = {"NOT_A_TYPE": NOT_A_TYPE, "typing": typing} + + +def forwardref(annotation: str) -> typing.ForwardRef: + return typing.ForwardRef(annotation, is_argument=False, is_class=True) + + +def test_evaluate_forwardref_none() -> None: + assert evaluate_forwardref(None) is NoneType + + +def test_evaluate_forwardref_string() -> None: + assert evaluate_forwardref("int", {"int": int}, {}) is int + + +def test_evaluate_forwardref_tolerates_unresolvable_name() -> None: + # The whole point of the helper: an unresolvable reference is returned as is + # instead of raising `NameError`. + resolved = evaluate_forwardref("Undefined", {}, {}) + assert isinstance(resolved, typing.ForwardRef) + assert resolved.__forward_arg__ == "Undefined" + + +@pytest.mark.parametrize( + ("message", "expected"), + ( + pytest.param( + "unsupported operand type(s) for |: 'object' and 'NoneType'", + True, + id="union-syntax", + ), + pytest.param( + "'object' object is not subscriptable", + True, + id="subscription-syntax", + ), + pytest.param("some other problem", False, id="unrelated"), + ), +) +def test_is_backport_fixable_error(message: str, expected: bool) -> None: + assert is_backport_fixable_error(TypeError(message)) is expected + + +def test_eval_type_backport_reraises_unrelated_type_error() -> None: + with pytest.raises(TypeError, match="requires a single type"): + eval_type_backport(forwardref("typing.Optional[int, str]"), BACKPORT_NS, {}) + + +@pytest.mark.parametrize( + "annotation", + ( + pytest.param("NOT_A_TYPE | None", id="union-syntax"), + pytest.param("NOT_A_TYPE[int]", id="subscription-syntax"), + ), +) +def test_eval_type_backport_without_package_installed( + annotation: str, + monkeypatch: pytest.MonkeyPatch, +) -> None: + # A `None` entry in `sys.modules` makes the import fail deterministically, + # whether or not `eval_type_backport` is actually installed. + monkeypatch.setitem(sys.modules, "eval_type_backport", None) + + with pytest.raises(TypeError, match="install the `eval_type_backport` package"): + eval_type_backport(forwardref(annotation), BACKPORT_NS, {}) + + +def test_eval_type_backport_delegates_to_package( + monkeypatch: pytest.MonkeyPatch, +) -> None: + calls = [] + + def fake_backport( + value: typing.Any, + globalns: typing.Any, + localns: typing.Any, + try_default: bool, + ) -> str: + calls.append(try_default) + return "delegated" + + stub = types.ModuleType("eval_type_backport") + stub.eval_type_backport = fake_backport # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, "eval_type_backport", stub) + + assert eval_type_backport(forwardref("NOT_A_TYPE | None"), BACKPORT_NS, {}) == ( + "delegated" + ) + assert calls == [False] diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py new file mode 100644 index 00000000..5251aca0 --- /dev/null +++ b/tests/test_exceptions.py @@ -0,0 +1,57 @@ +import pytest + +from fast_depends.exceptions import ValidationError +from fast_depends.library.serializer import OptionItem + +EXPECTED = {"a": OptionItem(field_name="a", field_type=int)} + + +def test_str_with_keyword_options() -> None: + error = ValidationError( + incoming_options={"a": "not-an-int"}, + locations=("a",), + expected=EXPECTED, + original_error=ValueError("original"), + ) + + assert str(error) == ( + "\n Incoming options: a=`not-an-int`" + "\n In the following option types error occurred:" + "\n OptionItem[a, type=`int`]" + ) + + +def test_str_with_positional_options() -> None: + error = ValidationError( + incoming_options="not-an-int", + locations=("a",), + expected=EXPECTED, + original_error=ValueError("original"), + ) + + assert str(error) == ( + "\n Incoming options: `not-an-int`" + "\n In the following option types error occurred:" + "\n OptionItem[a, type=`int`]" + ) + + +def test_unknown_location_falls_back_to_all_expected_options() -> None: + error = ValidationError( + incoming_options={"a": "not-an-int"}, + locations=("unknown",), + expected=EXPECTED, + original_error=ValueError("original"), + ) + + assert error.error_fields == tuple(EXPECTED.values()) + + +def test_is_a_value_error() -> None: + with pytest.raises(ValueError): # noqa: PT011 + raise ValidationError( + incoming_options={}, + locations=(), + expected={}, + original_error=ValueError("original"), + ) diff --git a/tests/test_inject.py b/tests/test_inject.py new file mode 100644 index 00000000..cd7fd9c3 --- /dev/null +++ b/tests/test_inject.py @@ -0,0 +1,24 @@ +from fast_depends import Depends, Provider, inject +from fast_depends.core import build_call_model + + +def dep() -> int: + return 1 + + +def func(d: int = Depends(dep)) -> int: + return d + + +def test_inject_reuses_a_prebuilt_model() -> None: + """`inject()` accepts an already built `CallModel` instead of building one. + + This is what integrations (e.g. FastStream) rely on to build the model once, + inspect it, and only then wrap the call. + """ + provider = Provider() + model = build_call_model(func, dependency_provider=provider) + + injected = inject(None, dependency_provider=provider)(func, model) + + assert injected() == 1 diff --git a/tests/test_provider.py b/tests/test_provider.py new file mode 100644 index 00000000..d8cf5210 --- /dev/null +++ b/tests/test_provider.py @@ -0,0 +1,86 @@ +from contextlib import AsyncExitStack, ExitStack + +import pytest + +from fast_depends import Depends, Provider +from fast_depends.core import build_call_model + + +def base_dep() -> int: + return 1 + + +def override_dep() -> int: + return 2 + + +def sync_func(d: int = Depends(base_dep)) -> int: + return d + + +async def async_func(d: int = Depends(base_dep)) -> int: + return d + + +def call_level_provider() -> Provider: + provider = Provider() + provider.override(base_dep, override_dep) + return provider + + +def test_merge_keeps_both_sides() -> None: + original, extra = Provider(), call_level_provider() + original.add_dependant(build_call_model(sync_func, dependency_provider=original)) + + merged = original.merge(extra) + + assert merged is not original + assert merged is not extra + assert merged.dependencies == original.dependencies | extra.dependencies + assert merged.overrides == original.overrides | extra.overrides + # merging must not mutate either side + assert not original.overrides + + +def test_sync_call_level_provider() -> None: + provider = Provider() + model = build_call_model(sync_func, dependency_provider=provider) + + with ExitStack() as stack: + assert model.solve(stack=stack, cache_dependencies={}) == 1 + + with ExitStack() as stack: + assert ( + model.solve( + stack=stack, + cache_dependencies={}, + dependency_provider=call_level_provider(), + ) + == 2 + ) + + # the call-level provider must not leak into the model's own provider + with ExitStack() as stack: + assert model.solve(stack=stack, cache_dependencies={}) == 1 + + +@pytest.mark.anyio +async def test_async_call_level_provider() -> None: + provider = Provider() + model = build_call_model(async_func, dependency_provider=provider) + + async with AsyncExitStack() as stack: + assert await model.asolve(stack=stack, cache_dependencies={}) == 1 + + async with AsyncExitStack() as stack: + assert ( + await model.asolve( + stack=stack, + cache_dependencies={}, + dependency_provider=call_level_provider(), + ) + == 2 + ) + + async with AsyncExitStack() as stack: + assert await model.asolve(stack=stack, cache_dependencies={}) == 1 diff --git a/tests/test_utils.py b/tests/test_utils.py index 4d9d43de..29a6e997 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -4,13 +4,11 @@ def test_is_coroutine_callable() -> None: - async def coroutine_func() -> int: - return 1 + async def coroutine_func() -> None: ... assert is_coroutine_callable(coroutine_func) - def sync_func() -> int: - return 1 + def sync_func() -> None: ... assert not is_coroutine_callable(sync_func) From 60d6d0ada8d4efb5e77fa8735922dbfda35a5897 Mon Sep 17 00:00:00 2001 From: Lancetnik Date: Thu, 13 Aug 2026 07:49:57 +0300 Subject: [PATCH 2/2] test: cover the JSON backend branches without extra CI jobs `fast_depends.pydantic._compat` selects orjson/ujson/stdlib at import time, so those branches used to need a CI run per backend. Re-execute the real module from disk against stubbed `sys.modules` entries instead: coverage keys line data off the file, so the actual branches are measured, and nothing lands in the live module tree. Drops the `test-json-backends` job again. The v1 encode assertions stay separator-agnostic - the three backends differ only in separator whitespace, and a contributor with orjson installed locally would otherwise get spurious failures. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/tests.yml | 38 +-------- .../serializers/pydantic/test_json_backend.py | 78 +++++++++++++++++++ 2 files changed, 79 insertions(+), 37 deletions(-) create mode 100644 tests/serializers/pydantic/test_json_backend.py diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 9a9db632..bb815d6e 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -122,44 +122,8 @@ jobs: if-no-files-found: error include-hidden-files: true - # Pydantic v1 encodes JSON through orjson/ujson when either is installed, - # so both backends need a run of their own. - test-json-backends: - runs-on: ubuntu-latest - strategy: - matrix: - json-backend: ["orjson", "ujson"] - fail-fast: false - - steps: - - uses: actions/checkout@v7 - - uses: actions/setup-python@v7 - with: - python-version: '3.12' - - uses: actions/cache@v6 - id: cache - with: - path: ${{ env.pythonLocation }} - key: ${{ matrix.json-backend }}-python-${{ env.pythonLocation }}-${{ hashFiles('pyproject.toml') }} - - name: Install Dependencies - if: steps.cache.outputs.cache-hit != 'true' - run: pip install --group test . "pydantic>=1.10.0,<2.0.0" ${{ matrix.json-backend }} - - run: mkdir coverage - - name: Test - run: bash scripts/test.sh - env: - COVERAGE_FILE: coverage/.coverage.${{ matrix.json-backend }} - CONTEXT: ${{ matrix.json-backend }} - - name: Store coverage files - uses: actions/upload-artifact@v7 - with: - name: .coverage.${{ matrix.json-backend }} - path: coverage - if-no-files-found: error - include-hidden-files: true - coverage-combine: - needs: [test-pydantic,test-msgspec,test-no-serializer,test-json-backends] + needs: [test-pydantic,test-msgspec,test-no-serializer] runs-on: ubuntu-latest steps: diff --git a/tests/serializers/pydantic/test_json_backend.py b/tests/serializers/pydantic/test_json_backend.py new file mode 100644 index 00000000..263956e5 --- /dev/null +++ b/tests/serializers/pydantic/test_json_backend.py @@ -0,0 +1,78 @@ +"""`fast_depends.pydantic._compat` picks its JSON backend at import time. + +Which branch runs depends on what happens to be installed, so the module is +re-executed here against stubbed `sys.modules` entries instead. Coverage keys +line data off the file, so re-executing the real file from disk does measure +the real branches, and nothing is imported into the live module tree. +""" + +import importlib.util +import json +import sys +from types import ModuleType + +import pytest + +import fast_depends.pydantic._compat as live_compat + +BACKENDS = ("orjson", "ujson") + + +def make_backend(name: str) -> ModuleType: + stub = ModuleType(name) + stub.loads = json.loads # type: ignore[attr-defined] + stub.dumps = json.dumps # type: ignore[attr-defined] + return stub + + +def reload_compat( + monkeypatch: pytest.MonkeyPatch, + installed: dict[str, ModuleType], +) -> ModuleType: + for name in BACKENDS: + # a `None` entry makes the import fail the same way a missing + # distribution would + monkeypatch.setitem(sys.modules, name, installed.get(name)) + + spec = importlib.util.spec_from_file_location( + "fast_depends_compat_probe", + live_compat.__file__, + ) + assert spec is not None + assert spec.loader is not None + + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def test_orjson_is_preferred(monkeypatch: pytest.MonkeyPatch) -> None: + orjson, ujson = make_backend("orjson"), make_backend("ujson") + + compat = reload_compat(monkeypatch, {"orjson": orjson, "ujson": ujson}) + + assert compat.orjson is orjson + assert compat.ujson is ujson + assert compat.json_loads is orjson.loads + # orjson already returns `bytes`, so it is used as is + assert compat.json_dumps is orjson.dumps + + +def test_ujson_is_used_without_orjson(monkeypatch: pytest.MonkeyPatch) -> None: + ujson = make_backend("ujson") + + compat = reload_compat(monkeypatch, {"ujson": ujson}) + + assert compat.orjson is None + assert compat.json_loads is ujson.loads + # ujson returns `str`, so the wrapper has to encode it + assert compat.json_dumps({"a": 1}) == b'{"a": 1}' + + +def test_stdlib_json_is_the_fallback(monkeypatch: pytest.MonkeyPatch) -> None: + compat = reload_compat(monkeypatch, {}) + + assert compat.orjson is None + assert compat.ujson is None + assert compat.json_loads is json.loads + assert compat.json_dumps({"a": 1}) == b'{"a": 1}'