dynamicforms-fastapi-viewsets 0.5.6__tar.gz → 0.6.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. {dynamicforms_fastapi_viewsets-0.5.6 → dynamicforms_fastapi_viewsets-0.6.0}/PKG-INFO +1 -1
  2. dynamicforms_fastapi_viewsets-0.6.0/fastapi_viewsets/__init__.py +1 -0
  3. {dynamicforms_fastapi_viewsets-0.5.6 → dynamicforms_fastapi_viewsets-0.6.0}/fastapi_viewsets/backends/django_orm.py +2 -2
  4. {dynamicforms_fastapi_viewsets-0.5.6 → dynamicforms_fastapi_viewsets-0.6.0}/fastapi_viewsets/collection_viewset.py +1 -1
  5. {dynamicforms_fastapi_viewsets-0.5.6 → dynamicforms_fastapi_viewsets-0.6.0}/fastapi_viewsets/cursor.py +23 -5
  6. dynamicforms_fastapi_viewsets-0.6.0/fastapi_viewsets/exceptions.py +81 -0
  7. {dynamicforms_fastapi_viewsets-0.5.6 → dynamicforms_fastapi_viewsets-0.6.0}/fastapi_viewsets/middleware/auth/__init__.py +2 -3
  8. {dynamicforms_fastapi_viewsets-0.5.6 → dynamicforms_fastapi_viewsets-0.6.0}/fastapi_viewsets/middleware/auth/authorization.py +17 -12
  9. {dynamicforms_fastapi_viewsets-0.5.6 → dynamicforms_fastapi_viewsets-0.6.0}/fastapi_viewsets/middleware/rate_limiter.py +2 -3
  10. {dynamicforms_fastapi_viewsets-0.5.6 → dynamicforms_fastapi_viewsets-0.6.0}/fastapi_viewsets/mixins.py +4 -6
  11. {dynamicforms_fastapi_viewsets-0.5.6 → dynamicforms_fastapi_viewsets-0.6.0}/fastapi_viewsets/mux_ws/protocol.py +2 -2
  12. dynamicforms_fastapi_viewsets-0.6.0/fastapi_viewsets/response_classes.py +8 -0
  13. dynamicforms_fastapi_viewsets-0.5.6/fastapi_viewsets/__init__.py +0 -1
  14. dynamicforms_fastapi_viewsets-0.5.6/fastapi_viewsets/response_classes.py +0 -17
  15. {dynamicforms_fastapi_viewsets-0.5.6 → dynamicforms_fastapi_viewsets-0.6.0}/.gitignore +0 -0
  16. {dynamicforms_fastapi_viewsets-0.5.6 → dynamicforms_fastapi_viewsets-0.6.0}/LICENSE +0 -0
  17. {dynamicforms_fastapi_viewsets-0.5.6 → dynamicforms_fastapi_viewsets-0.6.0}/README.md +0 -0
  18. {dynamicforms_fastapi_viewsets-0.5.6 → dynamicforms_fastapi_viewsets-0.6.0}/fastapi_viewsets/action_configuration.py +0 -0
  19. {dynamicforms_fastapi_viewsets-0.5.6 → dynamicforms_fastapi_viewsets-0.6.0}/fastapi_viewsets/backends/__init__.py +0 -0
  20. {dynamicforms_fastapi_viewsets-0.5.6 → dynamicforms_fastapi_viewsets-0.6.0}/fastapi_viewsets/conf.py +0 -0
  21. {dynamicforms_fastapi_viewsets-0.5.6 → dynamicforms_fastapi_viewsets-0.6.0}/fastapi_viewsets/context/__init__.py +0 -0
  22. {dynamicforms_fastapi_viewsets-0.5.6 → dynamicforms_fastapi_viewsets-0.6.0}/fastapi_viewsets/context/auth/__init__.py +0 -0
  23. {dynamicforms_fastapi_viewsets-0.5.6 → dynamicforms_fastapi_viewsets-0.6.0}/fastapi_viewsets/context/auth/django.py +0 -0
  24. {dynamicforms_fastapi_viewsets-0.5.6 → dynamicforms_fastapi_viewsets-0.6.0}/fastapi_viewsets/context/auth/jwt.py +0 -0
  25. {dynamicforms_fastapi_viewsets-0.5.6 → dynamicforms_fastapi_viewsets-0.6.0}/fastapi_viewsets/context/auth/static.py +0 -0
  26. {dynamicforms_fastapi_viewsets-0.5.6 → dynamicforms_fastapi_viewsets-0.6.0}/fastapi_viewsets/decorators/__init__.py +0 -0
  27. {dynamicforms_fastapi_viewsets-0.5.6 → dynamicforms_fastapi_viewsets-0.6.0}/fastapi_viewsets/decorators/build_schema.py +0 -0
  28. {dynamicforms_fastapi_viewsets-0.5.6 → dynamicforms_fastapi_viewsets-0.6.0}/fastapi_viewsets/decorators/celery_viewset/__init__.py +0 -0
  29. {dynamicforms_fastapi_viewsets-0.5.6 → dynamicforms_fastapi_viewsets-0.6.0}/fastapi_viewsets/decorators/celery_viewset/client.py +0 -0
  30. {dynamicforms_fastapi_viewsets-0.5.6 → dynamicforms_fastapi_viewsets-0.6.0}/fastapi_viewsets/decorators/celery_viewset/result_reader.py +0 -0
  31. {dynamicforms_fastapi_viewsets-0.5.6 → dynamicforms_fastapi_viewsets-0.6.0}/fastapi_viewsets/decorators/celery_viewset/server.py +0 -0
  32. {dynamicforms_fastapi_viewsets-0.5.6 → dynamicforms_fastapi_viewsets-0.6.0}/fastapi_viewsets/decorators/conftest.py +0 -0
  33. {dynamicforms_fastapi_viewsets-0.5.6 → dynamicforms_fastapi_viewsets-0.6.0}/fastapi_viewsets/decorators/lifecycle_runner.py +0 -0
  34. {dynamicforms_fastapi_viewsets-0.5.6 → dynamicforms_fastapi_viewsets-0.6.0}/fastapi_viewsets/decorators/primary_key_model_helper.py +0 -0
  35. {dynamicforms_fastapi_viewsets-0.5.6 → dynamicforms_fastapi_viewsets-0.6.0}/fastapi_viewsets/decorators/route_viewset.py +0 -0
  36. {dynamicforms_fastapi_viewsets-0.5.6 → dynamicforms_fastapi_viewsets-0.6.0}/fastapi_viewsets/endpoint_docs.py +0 -0
  37. {dynamicforms_fastapi_viewsets-0.5.6 → dynamicforms_fastapi_viewsets-0.6.0}/fastapi_viewsets/filters/__init__.py +0 -0
  38. {dynamicforms_fastapi_viewsets-0.5.6 → dynamicforms_fastapi_viewsets-0.6.0}/fastapi_viewsets/filters/base.py +0 -0
  39. {dynamicforms_fastapi_viewsets-0.5.6 → dynamicforms_fastapi_viewsets-0.6.0}/fastapi_viewsets/filters/declaration.py +0 -0
  40. {dynamicforms_fastapi_viewsets-0.5.6 → dynamicforms_fastapi_viewsets-0.6.0}/fastapi_viewsets/filters/operators.py +0 -0
  41. {dynamicforms_fastapi_viewsets-0.5.6 → dynamicforms_fastapi_viewsets-0.6.0}/fastapi_viewsets/filters/registry.py +0 -0
  42. {dynamicforms_fastapi_viewsets-0.5.6 → dynamicforms_fastapi_viewsets-0.6.0}/fastapi_viewsets/list_query.py +0 -0
  43. {dynamicforms_fastapi_viewsets-0.5.6 → dynamicforms_fastapi_viewsets-0.6.0}/fastapi_viewsets/list_shapes.py +0 -0
  44. {dynamicforms_fastapi_viewsets-0.5.6 → dynamicforms_fastapi_viewsets-0.6.0}/fastapi_viewsets/middleware/__init__.py +0 -0
  45. {dynamicforms_fastapi_viewsets-0.5.6 → dynamicforms_fastapi_viewsets-0.6.0}/fastapi_viewsets/mux_ws/__init__.py +0 -0
  46. {dynamicforms_fastapi_viewsets-0.5.6 → dynamicforms_fastapi_viewsets-0.6.0}/fastapi_viewsets/mux_ws/asgi.py +0 -0
  47. {dynamicforms_fastapi_viewsets-0.5.6 → dynamicforms_fastapi_viewsets-0.6.0}/fastapi_viewsets/mux_ws/registry.py +0 -0
  48. {dynamicforms_fastapi_viewsets-0.5.6 → dynamicforms_fastapi_viewsets-0.6.0}/fastapi_viewsets/mux_ws/server.py +0 -0
  49. {dynamicforms_fastapi_viewsets-0.5.6 → dynamicforms_fastapi_viewsets-0.6.0}/fastapi_viewsets/mux_ws/transports.py +0 -0
  50. {dynamicforms_fastapi_viewsets-0.5.6 → dynamicforms_fastapi_viewsets-0.6.0}/fastapi_viewsets/py.typed +0 -0
  51. {dynamicforms_fastapi_viewsets-0.5.6 → dynamicforms_fastapi_viewsets-0.6.0}/fastapi_viewsets/save_state/__init__.py +0 -0
  52. {dynamicforms_fastapi_viewsets-0.5.6 → dynamicforms_fastapi_viewsets-0.6.0}/fastapi_viewsets/save_state/save_state.py +0 -0
  53. {dynamicforms_fastapi_viewsets-0.5.6 → dynamicforms_fastapi_viewsets-0.6.0}/fastapi_viewsets/save_state/serialize_state.py +0 -0
  54. {dynamicforms_fastapi_viewsets-0.5.6 → dynamicforms_fastapi_viewsets-0.6.0}/pyproject.toml +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: dynamicforms-fastapi-viewsets
3
- Version: 0.5.6
3
+ Version: 0.6.0
4
4
  Summary: Django REST Framework-style viewsets for FastAPI, with optional Celery-backed async execution and a Vue/TypeScript client counterpart.
5
5
  Project-URL: Homepage, https://github.com/dynamicforms/fastapi-viewsets
6
6
  Project-URL: Repository, https://github.com/dynamicforms/fastapi-viewsets
@@ -0,0 +1 @@
1
+ __version__ = "0.6.0"
@@ -27,12 +27,12 @@ from typing import Any, ClassVar, Generic, TYPE_CHECKING
27
27
 
28
28
  from asgiref.sync import sync_to_async
29
29
  from django.db.models import BooleanField, F, Func, Q, Value
30
- from fastapi import HTTPException
31
30
  from pydantic import BaseModel
32
31
  from typing_extensions import TypeVar
33
32
 
34
33
  from ..context import Context
35
34
  from ..cursor import CursorPredicate, wants_greater
35
+ from ..exceptions import NotFoundError
36
36
  from ..filters import (
37
37
  can_compile_all,
38
38
  compile_all,
@@ -299,7 +299,7 @@ class DjangoORMViewSet(ImplMixin[K, T], Generic[K, T]):
299
299
  try:
300
300
  return await self.get_queryset(context).aget(**{self.pk_field: pk})
301
301
  except ObjectDoesNotExist:
302
- raise HTTPException(status_code=404, detail=f"Item with pk {pk} not found") from None
302
+ raise NotFoundError(pk) from None
303
303
 
304
304
  def _writable_fields(self, data: T, exclude_unset: bool = False) -> dict[str, Any]:
305
305
  """
@@ -5,8 +5,8 @@ from typing import Any, Generic, TypeVar
5
5
  from pydantic import BaseModel
6
6
 
7
7
  from .context import Context
8
+ from .exceptions import NotFoundError
8
9
  from .mixins import ImplMixin
9
- from .response_classes import NotFoundError
10
10
 
11
11
  T = TypeVar("T")
12
12
  K = TypeVar("K")
@@ -45,6 +45,11 @@ TItem = TypeVar("TItem")
45
45
  class CursorError(ValueError):
46
46
  """The cursor is unreadable, or does not belong to the query it was sent with."""
47
47
 
48
+ def __init__(self, message: str, code: str, params: dict[str, Any] | None = None):
49
+ super().__init__(message)
50
+ self.code = code
51
+ self.params = params or {}
52
+
48
53
 
49
54
  CursorKeys = tuple[tuple[str, bool], ...]
50
55
  """Ordering keys as (field name, descending), primary key last. The shape a position is read against."""
@@ -129,18 +134,27 @@ def decode_cursor(raw: str, keys: CursorKeys, expected_query: str, annotations:
129
134
  padded = raw + "=" * (-len(raw) % 4)
130
135
  payload = json.loads(base64.urlsafe_b64decode(padded))
131
136
  except (ValueError, binascii.Error) as error:
132
- raise CursorError(f"cursor is not readable: {error}") from None
137
+ raise CursorError(
138
+ f"cursor is not readable: {error}", code="cursor_unreadable", params={"error": str(error)}
139
+ ) from None
133
140
 
134
141
  if not isinstance(payload, dict) or not isinstance(payload.get("p"), dict):
135
- raise CursorError("cursor is not readable: no position in it")
142
+ raise CursorError("cursor is not readable: no position in it", code="cursor_missing_position")
136
143
 
137
144
  if payload.get("q") != expected_query:
138
- raise CursorError("this cursor was issued for a different ordering or filter - start from the first page")
145
+ raise CursorError(
146
+ "this cursor was issued for a different ordering or filter - start from the first page",
147
+ code="cursor_stale",
148
+ )
139
149
 
140
150
  position = payload["p"]
141
151
  missing = [name for name, _ in keys if name not in position]
142
152
  if missing:
143
- raise CursorError(f"cursor has no value for ordering key(s): {', '.join(missing)}")
153
+ raise CursorError(
154
+ f"cursor has no value for ordering key(s): {', '.join(missing)}",
155
+ code="cursor_missing_keys",
156
+ params={"missing": missing},
157
+ )
144
158
 
145
159
  coerced: dict[str, Any] = {}
146
160
  for name, _ in keys:
@@ -152,7 +166,11 @@ def decode_cursor(raw: str, keys: CursorKeys, expected_query: str, annotations:
152
166
  try:
153
167
  coerced[name] = TypeAdapter(annotation).validate_python(value)
154
168
  except Exception as error:
155
- raise CursorError(f"cursor value for {name!r} does not fit the field: {error}") from None
169
+ raise CursorError(
170
+ f"cursor value for {name!r} does not fit the field: {error}",
171
+ code="cursor_value_mismatch",
172
+ params={"name": name, "error": str(error)},
173
+ ) from None
156
174
 
157
175
  return CursorState(
158
176
  position=coerced,
@@ -0,0 +1,81 @@
1
+ """
2
+ The HTTP exceptions this package raises on its own behalf.
3
+
4
+ `detail` is the English default, fully interpolated - a plain string, exactly as it always was, so
5
+ an application that does nothing further sees exactly what it always has. `code` names what went
6
+ wrong independently of `detail`'s wording, and `params` are the raw values `detail` was
7
+ interpolated with; both reach the response body only once the application registers
8
+ `df_viewset_exception_handler` for `DfViewSetError` (see that function). A view's own
9
+ `raise HTTPException(status_code, detail="...")` is unaffected either way.
10
+ """
11
+
12
+ from typing import Any
13
+
14
+ from fastapi import HTTPException, Request
15
+ from fastapi.responses import JSONResponse
16
+
17
+ from .cursor import CursorError
18
+
19
+
20
+ class DfViewSetError(HTTPException):
21
+ """Base for every `HTTPException` this package raises on its own behalf."""
22
+
23
+ def __init__(self, status_code: int, message: str, code: str, params: dict[str, Any] | None = None):
24
+ super().__init__(status_code=status_code, detail=message)
25
+ self.code = code
26
+ self.params = params or {}
27
+
28
+
29
+ class NotFoundError(DfViewSetError):
30
+ def __init__(self, pk: Any):
31
+ super().__init__(404, f"Item with pk {pk} not found", "not_found", {"pk": pk})
32
+
33
+
34
+ class SessionExpiredError(DfViewSetError):
35
+ def __init__(self):
36
+ super().__init__(401, "Session expired or invalid", "session_expired")
37
+
38
+
39
+ class NotAuthorizedError(DfViewSetError):
40
+ def __init__(self):
41
+ super().__init__(403, "Not authorized to perform this action", "not_authorized")
42
+
43
+
44
+ class RateLimitedError(DfViewSetError):
45
+ def __init__(self):
46
+ super().__init__(429, "Rate limit exceeded", "rate_limited")
47
+
48
+
49
+ class UnsupportedListShapeError(DfViewSetError):
50
+ def __init__(self, shape: str, allowed: list[str]):
51
+ message = f"unsupported list shape {shape!r}; this endpoint offers {', '.join(allowed)}"
52
+ super().__init__(422, message, "unsupported_list_shape", {"shape": shape, "allowed": allowed})
53
+
54
+
55
+ class CursorRequestError(DfViewSetError):
56
+ """Raised in place of a caught `CursorError`, carrying the same `code`/`params` as a 400 response."""
57
+
58
+ def __init__(self, error: CursorError):
59
+ super().__init__(400, str(error), error.code, error.params)
60
+
61
+
62
+ async def df_viewset_exception_handler(_request: Request, exc: DfViewSetError) -> JSONResponse:
63
+ """
64
+ Adds `detail_code` and `detail_params` alongside `detail` in the response body.
65
+
66
+ Opt in once, for `DfViewSetError` - every exception this package raises on its own descends
67
+ from it:
68
+
69
+ from fastapi_viewsets.exceptions import DfViewSetError, df_viewset_exception_handler
70
+
71
+ app.add_exception_handler(DfViewSetError, df_viewset_exception_handler)
72
+
73
+ An application that never registers this sees exactly what it always has -
74
+ `{"detail": "..."}`, from FastAPI's own default `HTTPException` handling; `code` and `params`
75
+ reach nothing without it.
76
+ """
77
+ return JSONResponse(
78
+ status_code=exc.status_code,
79
+ content={"detail": exc.detail, "detail_code": exc.code, "detail_params": exc.params},
80
+ headers=exc.headers,
81
+ )
@@ -1,8 +1,7 @@
1
1
  from collections.abc import Awaitable, Callable
2
2
  from typing import Any, TYPE_CHECKING
3
3
 
4
- from fastapi import HTTPException
5
-
4
+ from ...exceptions import SessionExpiredError
6
5
  from .. import Middleware, ViewSetResult
7
6
 
8
7
  if TYPE_CHECKING:
@@ -41,7 +40,7 @@ class Session(Middleware):
41
40
  return
42
41
  user = await context.user
43
42
  if user is None:
44
- raise HTTPException(status_code=401, detail="Session expired or invalid")
43
+ raise SessionExpiredError()
45
44
 
46
45
  async def __call__(
47
46
  self,
@@ -3,8 +3,7 @@ import inspect
3
3
  from collections.abc import Awaitable, Callable
4
4
  from typing import Any, TYPE_CHECKING
5
5
 
6
- from fastapi import HTTPException
7
-
6
+ from ...exceptions import NotAuthorizedError
8
7
  from .. import Middleware, ViewSetResult
9
8
 
10
9
  if TYPE_CHECKING:
@@ -23,14 +22,18 @@ class Authorization(Middleware):
23
22
  - If it's callable, it's evaluated as `check(request, cls, context)` (sync or async) in
24
23
  `depends()` - bridged onto FastAPI's own `Depends()` by route_viewset - a falsy result
25
24
  rejects the request with 403 before `perform_*` (or even FastAPI's own body parsing) ever
26
- runs. Use this for checks that don't need a specific record (e.g. a role check).
27
- - Either way (callable or not, or even unconfigured), the resolved value is made available as
28
- `context.authorization` (set in `__call__`, unconditionally, in the onion chain - there's no
29
- reject decision here, so there's no benefit to doing this any earlier) - `perform_*` can
30
- inspect it once it has whatever record-specific info a check needs (e.g. the object a `pk`
31
- resolved to) and reject itself (`raise HTTPException(403, ...)`) if it decides to. This is
32
- the "no hook in the middleware layer needed" case - just raise, same as anywhere else in the
33
- pipeline (see docs/guide/action-configuration.md's "Rejecting a request: just raise").
25
+ runs. Use this for checks that don't need a specific record (e.g. a role check). A callable
26
+ config is consumed entirely by `depends()` and never reaches `context.authorization` - there
27
+ is nothing left for `perform_*` to do with the check function itself, and a bare function
28
+ isn't something a `celery_viewset` worker could receive anyway.
29
+ - Otherwise (non-callable, or unconfigured/`None`), nothing is enforced by the middleware
30
+ itself; the value is made available as `context.authorization` (set in `__call__`, in the
31
+ onion chain - there's no reject decision here, so there's no benefit to doing this any
32
+ earlier) - `perform_*` can inspect it once it has whatever record-specific info a check
33
+ needs (e.g. the object a `pk` resolved to) and reject itself (`raise HTTPException(403,
34
+ ...)`) if it decides to. This is the "no hook in the middleware layer needed" case - just
35
+ raise, same as anywhere else in the pipeline (see docs/guide/action-configuration.md's
36
+ "Rejecting a request: just raise").
34
37
 
35
38
  Unconfigured (no `@action_configuration` entry for `Authorization` at all): nothing is
36
39
  enforced, `context.authorization` resolves to `None`.
@@ -54,7 +57,7 @@ class Authorization(Middleware):
54
57
  if inspect.isawaitable(allowed):
55
58
  allowed = await allowed
56
59
  if not allowed:
57
- raise HTTPException(status_code=403, detail="Not authorized to perform this action")
60
+ raise NotAuthorizedError()
58
61
 
59
62
  async def __call__(
60
63
  self,
@@ -63,5 +66,7 @@ class Authorization(Middleware):
63
66
  context: "Context",
64
67
  call_next: Callable[[], Awaitable[ViewSetResult]],
65
68
  ) -> ViewSetResult:
66
- context.set("authorization", self.config_from(context))
69
+ config = self.config_from(context)
70
+ if not callable(config):
71
+ context.set("authorization", config)
67
72
  return await call_next()
@@ -4,8 +4,7 @@ import time
4
4
  from collections.abc import Awaitable, Callable
5
5
  from typing import Any, TYPE_CHECKING
6
6
 
7
- from fastapi import HTTPException
8
-
7
+ from ..exceptions import RateLimitedError
9
8
  from . import Middleware, ViewSetResult
10
9
 
11
10
  if TYPE_CHECKING:
@@ -72,7 +71,7 @@ class RateLimiter(Middleware):
72
71
  key = await key
73
72
  allowed = await self._increment(key, limit)
74
73
  if not allowed:
75
- raise HTTPException(status_code=429, detail="Rate limit exceeded")
74
+ raise RateLimitedError()
76
75
 
77
76
  async def __call__(
78
77
  self,
@@ -2,7 +2,7 @@ from abc import ABC, abstractmethod
2
2
  from enum import Enum
3
3
  from typing import Annotated, Any, final, Generic, get_args, get_origin, Union
4
4
 
5
- from fastapi import APIRouter, Header, HTTPException, Query
5
+ from fastapi import APIRouter, Header, Query
6
6
  from pydantic import BaseModel, ConfigDict, create_model
7
7
  from pydantic.alias_generators import to_camel
8
8
  from typing_extensions import TypeVar
@@ -21,6 +21,7 @@ from fastapi_viewsets.cursor import (
21
21
  make_predicate,
22
22
  position_of,
23
23
  )
24
+ from fastapi_viewsets.exceptions import CursorRequestError, UnsupportedListShapeError
24
25
  from fastapi_viewsets.filters import filters_from, FilterSet
25
26
  from fastapi_viewsets.list_query import (
26
27
  build_list_query,
@@ -315,10 +316,7 @@ class ListMixin(Generic[T, TFilter], ABC):
315
316
  default, allowed = self.resolve_shapes()
316
317
  shape = x_list_shape or default
317
318
  if shape not in allowed:
318
- raise HTTPException(
319
- status_code=422,
320
- detail=f"unsupported list shape {shape!r}; this endpoint offers {', '.join(allowed)}",
321
- )
319
+ raise UnsupportedListShapeError(shape, list(allowed))
322
320
 
323
321
  query = build_list_query(
324
322
  fltr,
@@ -446,7 +444,7 @@ class ListMixin(Generic[T, TFilter], ABC):
446
444
  except CursorError as error:
447
445
  # The client sent something wrong, not the server - a stale cursor after a sort
448
446
  # change is the ordinary case, and it deserves a message rather than a traceback.
449
- raise HTTPException(status_code=400, detail=str(error)) from None
447
+ raise CursorRequestError(error) from None
450
448
  query.extra_filters.append(make_predicate(state, keys, self.nulls_first))
451
449
 
452
450
  backwards = bool(state and state.backwards)
@@ -37,8 +37,6 @@ consume the whole body before learning whether it was an error at all.
37
37
  from typing import Any
38
38
  from urllib.parse import urlencode
39
39
 
40
- from muxws.frames import ABSENT
41
-
42
40
  METHOD_KEY = ":method"
43
41
  PATH_KEY = ":path"
44
42
  QUERY_KEY = ":query"
@@ -120,4 +118,6 @@ def build_response_meta(status: int, headers: dict[str, str]) -> dict[str, Any]:
120
118
 
121
119
  def body_to_payload(body: Any) -> Any:
122
120
  """`ABSENT` is muxws' "no payload at all", which is a different frame from a null payload."""
121
+ from muxws.frames import ABSENT
122
+
123
123
  return ABSENT if body is None else body
@@ -0,0 +1,8 @@
1
+ from pydantic import BaseModel
2
+
3
+
4
+ class NotFoundResponse(BaseModel):
5
+ detail: str = "Item with pk {pk} not found"
6
+
7
+
8
+ NOT_FOUND_RESPONSE = {"404": {"model": NotFoundResponse}}
@@ -1 +0,0 @@
1
- __version__ = "0.5.6"
@@ -1,17 +0,0 @@
1
- from typing import Any
2
-
3
- from fastapi import HTTPException
4
- from pydantic import BaseModel
5
-
6
-
7
- class NotFoundResponse(BaseModel):
8
- detail: str = "Item with pk {pk} not found"
9
-
10
-
11
- NOT_FOUND_RESPONSE = {"404": {"model": NotFoundResponse, "message": NotFoundResponse().detail}}
12
-
13
-
14
- class NotFoundError(HTTPException):
15
- def __init__(self, pk: Any):
16
- # NotFoundResponse has only "detail", so it deconstructs into exactly the same parameter for HTTPException
17
- super().__init__(status_code=404, **NotFoundResponse(detail=f"Item with pk {pk} not found").model_dump())