Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -144,10 +144,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
Expand Down
2 changes: 1 addition & 1 deletion fast_depends/core/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
11 changes: 5 additions & 6 deletions fast_depends/use.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
18 changes: 9 additions & 9 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
21 changes: 21 additions & 0 deletions tests/library/test_custom.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
51 changes: 51 additions & 0 deletions tests/library/test_serializer.py
Original file line number Diff line number Diff line change
@@ -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"
6 changes: 3 additions & 3 deletions tests/serializers/msgspec/test_custom_type.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
99 changes: 99 additions & 0 deletions tests/serializers/msgspec/test_serializer.py
Original file line number Diff line number Diff line change
@@ -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)
24 changes: 24 additions & 0 deletions tests/serializers/pydantic/test_compat.py
Original file line number Diff line number Diff line change
@@ -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
)
17 changes: 5 additions & 12 deletions tests/serializers/pydantic/test_encode.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
),
),
Expand All @@ -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
Loading