ninja-devx 0.0.1__py3-none-any.whl

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 (178) hide show
  1. ninja_devx/__init__.py +273 -0
  2. ninja_devx/_internal/__init__.py +1 -0
  3. ninja_devx/_internal/cache.py +16 -0
  4. ninja_devx/_internal/compat.py +19 -0
  5. ninja_devx/_internal/generics.py +130 -0
  6. ninja_devx/_internal/i18n.py +14 -0
  7. ninja_devx/_internal/streaming.py +154 -0
  8. ninja_devx/_internal/types.py +79 -0
  9. ninja_devx/_permission_eval.py +63 -0
  10. ninja_devx/_permission_eval_async.py +60 -0
  11. ninja_devx/apps.py +21 -0
  12. ninja_devx/codegen/__init__.py +14 -0
  13. ninja_devx/codegen/openapi.py +300 -0
  14. ninja_devx/codegen/python.py +833 -0
  15. ninja_devx/codegen/source.py +87 -0
  16. ninja_devx/codegen/typescript.py +367 -0
  17. ninja_devx/configuration/__init__.py +1 -0
  18. ninja_devx/configuration/checks.py +143 -0
  19. ninja_devx/configuration/runtime.py +33 -0
  20. ninja_devx/configuration/settings.py +222 -0
  21. ninja_devx/contrib/__init__.py +0 -0
  22. ninja_devx/contrib/apikeys/__init__.py +16 -0
  23. ninja_devx/contrib/apikeys/admin.py +51 -0
  24. ninja_devx/contrib/apikeys/api.py +92 -0
  25. ninja_devx/contrib/apikeys/apps.py +8 -0
  26. ninja_devx/contrib/apikeys/auth.py +213 -0
  27. ninja_devx/contrib/apikeys/management/__init__.py +0 -0
  28. ninja_devx/contrib/apikeys/management/commands/__init__.py +0 -0
  29. ninja_devx/contrib/apikeys/management/commands/devx_apikey.py +58 -0
  30. ninja_devx/contrib/apikeys/migrations/0001_initial.py +58 -0
  31. ninja_devx/contrib/apikeys/migrations/__init__.py +0 -0
  32. ninja_devx/contrib/apikeys/models.py +43 -0
  33. ninja_devx/contrib/audit/__init__.py +13 -0
  34. ninja_devx/contrib/audit/admin.py +36 -0
  35. ninja_devx/contrib/audit/api.py +122 -0
  36. ninja_devx/contrib/audit/apps.py +8 -0
  37. ninja_devx/contrib/audit/log.py +230 -0
  38. ninja_devx/contrib/audit/migrations/0001_initial.py +68 -0
  39. ninja_devx/contrib/audit/migrations/__init__.py +0 -0
  40. ninja_devx/contrib/audit/models.py +47 -0
  41. ninja_devx/contrib/audit/privacy.py +65 -0
  42. ninja_devx/contrib/dishka.py +135 -0
  43. ninja_devx/contrib/grants/__init__.py +6 -0
  44. ninja_devx/contrib/grants/admin.py +22 -0
  45. ninja_devx/contrib/grants/apps.py +8 -0
  46. ninja_devx/contrib/grants/backends.py +53 -0
  47. ninja_devx/contrib/grants/migrations/0001_initial.py +82 -0
  48. ninja_devx/contrib/grants/migrations/__init__.py +0 -0
  49. ninja_devx/contrib/grants/models.py +59 -0
  50. ninja_devx/contrib/otel.py +122 -0
  51. ninja_devx/contrib/svcs.py +54 -0
  52. ninja_devx/contrib/uploads/__init__.py +23 -0
  53. ninja_devx/contrib/uploads/api.py +317 -0
  54. ninja_devx/contrib/uploads/backends.py +201 -0
  55. ninja_devx/contrib/uploads/models.py +27 -0
  56. ninja_devx/contrib/webhooks/__init__.py +19 -0
  57. ninja_devx/contrib/webhooks/admin.py +146 -0
  58. ninja_devx/contrib/webhooks/api.py +198 -0
  59. ninja_devx/contrib/webhooks/apps.py +8 -0
  60. ninja_devx/contrib/webhooks/maintenance.py +57 -0
  61. ninja_devx/contrib/webhooks/management/__init__.py +0 -0
  62. ninja_devx/contrib/webhooks/management/commands/__init__.py +0 -0
  63. ninja_devx/contrib/webhooks/management/commands/devx_webhooks.py +115 -0
  64. ninja_devx/contrib/webhooks/migrations/0001_initial.py +126 -0
  65. ninja_devx/contrib/webhooks/migrations/0002_outboxevent_audience_webhookdelivery_lease_token_and_more.py +27 -0
  66. ninja_devx/contrib/webhooks/migrations/__init__.py +0 -0
  67. ninja_devx/contrib/webhooks/models.py +119 -0
  68. ninja_devx/contrib/webhooks/network.py +173 -0
  69. ninja_devx/contrib/webhooks/outbox.py +375 -0
  70. ninja_devx/contrib/webhooks/secrets.py +95 -0
  71. ninja_devx/contrib/webhooks/signing.py +104 -0
  72. ninja_devx/contrib/webhooks/tasks.py +35 -0
  73. ninja_devx/crud/__init__.py +84 -0
  74. ninja_devx/crud/annotations.py +274 -0
  75. ninja_devx/crud/async_controllers.py +21 -0
  76. ninja_devx/crud/auto.py +140 -0
  77. ninja_devx/crud/bulk.py +198 -0
  78. ninja_devx/crud/controllers.py +822 -0
  79. ninja_devx/crud/fields.py +94 -0
  80. ninja_devx/crud/filters.py +119 -0
  81. ninja_devx/crud/nested.py +130 -0
  82. ninja_devx/crud/optimization.py +187 -0
  83. ninja_devx/crud/pagination.py +252 -0
  84. ninja_devx/crud/persistence.py +5 -0
  85. ninja_devx/crud/scoping.py +58 -0
  86. ninja_devx/crud/shaping.py +136 -0
  87. ninja_devx/crud/sharing.py +164 -0
  88. ninja_devx/crud/soft_delete.py +182 -0
  89. ninja_devx/crud/transfer.py +350 -0
  90. ninja_devx/crud/writes.py +51 -0
  91. ninja_devx/dependencies/__init__.py +1 -0
  92. ninja_devx/dependencies/container.py +302 -0
  93. ninja_devx/dependencies/contracts.py +67 -0
  94. ninja_devx/dependencies/engine.py +287 -0
  95. ninja_devx/dependencies/injection.py +144 -0
  96. ninja_devx/dependencies/instances.py +206 -0
  97. ninja_devx/dependencies/introspection.py +51 -0
  98. ninja_devx/dependencies/scope.py +61 -0
  99. ninja_devx/dependencies/state.py +82 -0
  100. ninja_devx/exceptions.py +42 -0
  101. ninja_devx/http/__init__.py +1 -0
  102. ninja_devx/http/conditional.py +207 -0
  103. ninja_devx/http/errors.py +180 -0
  104. ninja_devx/http/health.py +221 -0
  105. ninja_devx/http/middleware.py +284 -0
  106. ninja_devx/http/throttling.py +226 -0
  107. ninja_devx/idempotency/__init__.py +5 -0
  108. ninja_devx/idempotency/models.py +29 -0
  109. ninja_devx/idempotency/policy.py +172 -0
  110. ninja_devx/idempotency/store.py +114 -0
  111. ninja_devx/layers/__init__.py +58 -0
  112. ninja_devx/layers/context.py +39 -0
  113. ninja_devx/layers/dual.py +98 -0
  114. ninja_devx/layers/errors.py +113 -0
  115. ninja_devx/layers/persistence.py +82 -0
  116. ninja_devx/layers/policies.py +41 -0
  117. ninja_devx/layers/repository.py +111 -0
  118. ninja_devx/layers/selectors.py +19 -0
  119. ninja_devx/layers/services.py +54 -0
  120. ninja_devx/layers/tasks.py +93 -0
  121. ninja_devx/layers/testing.py +75 -0
  122. ninja_devx/management/__init__.py +0 -0
  123. ninja_devx/management/commands/__init__.py +0 -0
  124. ninja_devx/management/commands/devx_openapi.py +71 -0
  125. ninja_devx/management/commands/devx_scaffold.py +124 -0
  126. ninja_devx/management/commands/devx_startapp.py +33 -0
  127. ninja_devx/management/commands/devx_uploads.py +31 -0
  128. ninja_devx/migrations/0001_initial.py +31 -0
  129. ninja_devx/migrations/0002_uploadrecord.py +37 -0
  130. ninja_devx/migrations/__init__.py +0 -0
  131. ninja_devx/models.py +6 -0
  132. ninja_devx/py.typed +0 -0
  133. ninja_devx/pytest_plugin.py +3 -0
  134. ninja_devx/routing/__init__.py +1 -0
  135. ninja_devx/routing/bindings.py +58 -0
  136. ninja_devx/routing/compiler.py +510 -0
  137. ninja_devx/routing/controller.py +436 -0
  138. ninja_devx/routing/hooks.py +136 -0
  139. ninja_devx/routing/invocation.py +368 -0
  140. ninja_devx/routing/mounting.py +82 -0
  141. ninja_devx/routing/operations.py +203 -0
  142. ninja_devx/routing/plugins.py +46 -0
  143. ninja_devx/routing/use_cases.py +128 -0
  144. ninja_devx/security/__init__.py +1 -0
  145. ninja_devx/security/auth.py +224 -0
  146. ninja_devx/security/object_permissions.py +456 -0
  147. ninja_devx/security/permission_leaf.py +50 -0
  148. ninja_devx/security/permissions.py +486 -0
  149. ninja_devx/security/tenancy.py +130 -0
  150. ninja_devx/serialization/__init__.py +1 -0
  151. ninja_devx/serialization/pydantic.py +24 -0
  152. ninja_devx/serialization/renderers.py +64 -0
  153. ninja_devx/serialization/schemas.py +167 -0
  154. ninja_devx/serialization/visibility.py +278 -0
  155. ninja_devx/templates/app_template/__init__.py-tpl +0 -0
  156. ninja_devx/templates/app_template/api.py-tpl +19 -0
  157. ninja_devx/templates/app_template/apps.py-tpl +6 -0
  158. ninja_devx/templates/app_template/migrations/__init__.py-tpl +0 -0
  159. ninja_devx/templates/app_template/models.py-tpl +4 -0
  160. ninja_devx/templates/app_template/schemas.py-tpl +6 -0
  161. ninja_devx/templates/app_template/services.py-tpl +6 -0
  162. ninja_devx/templates/app_template/tests/__init__.py-tpl +0 -0
  163. ninja_devx/templates/app_template/tests/test_api.py-tpl +13 -0
  164. ninja_devx/testing/__init__.py +1 -0
  165. ninja_devx/testing/clients.py +200 -0
  166. ninja_devx/testing/contracts.py +58 -0
  167. ninja_devx/testing/plugin.py +169 -0
  168. ninja_devx/tooling/__init__.py +1 -0
  169. ninja_devx/tooling/drift.py +252 -0
  170. ninja_devx/tooling/scaffold.py +419 -0
  171. ninja_devx/tooling/unasync.py +183 -0
  172. ninja_devx/unasync.py +6 -0
  173. ninja_devx-0.0.1.dist-info/METADATA +171 -0
  174. ninja_devx-0.0.1.dist-info/RECORD +178 -0
  175. ninja_devx-0.0.1.dist-info/WHEEL +4 -0
  176. ninja_devx-0.0.1.dist-info/entry_points.txt +2 -0
  177. ninja_devx-0.0.1.dist-info/licenses/LICENSE +202 -0
  178. ninja_devx-0.0.1.dist-info/licenses/NOTICE +4 -0
ninja_devx/__init__.py ADDED
@@ -0,0 +1,273 @@
1
+ """Typed class-based controllers, permissions, CRUD and DI for Django Ninja.
2
+
3
+ Attributes are imported lazily (PEP 562), so ``import ninja_devx`` does not require
4
+ configured Django settings (the pytest plugin relies on this).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import importlib
10
+ from collections.abc import Mapping
11
+ from importlib.metadata import PackageNotFoundError, version
12
+ from types import MappingProxyType
13
+ from typing import TYPE_CHECKING, Final
14
+
15
+ if TYPE_CHECKING:
16
+ from .dependencies.container import (
17
+ AsyncRequestScopeProvider,
18
+ AsyncResolver,
19
+ Container,
20
+ ContainerLike,
21
+ Lifetime,
22
+ RequestScopeProvider,
23
+ Resolver,
24
+ )
25
+ from .dependencies.injection import Inject, Resolve, resolve
26
+ from .dependencies.instances import Invocation, get_invocation
27
+ from .exceptions import (
28
+ AsyncLazyAccessError,
29
+ BlockingCallWarning,
30
+ CircularDependencyError,
31
+ ControllerConfigError,
32
+ DependencyResolutionError,
33
+ MixedPathWarning,
34
+ NinjaDevXError,
35
+ )
36
+ from .http.conditional import ETag, PreconditionFailed, PreconditionRequired, conditional
37
+ from .http.errors import ErrorMap
38
+ from .idempotency import idempotent
39
+ from .routing.controller import Controller, ControllerOptions, Scope
40
+ from .routing.hooks import (
41
+ AsyncOperationHook,
42
+ LoggingHook,
43
+ OperationHook,
44
+ OperationInfo,
45
+ get_operation,
46
+ )
47
+ from .routing.mounting import Mount, mount
48
+ from .routing.operations import (
49
+ OperationOptions,
50
+ OperationSpec,
51
+ RouteOptions,
52
+ api_operation,
53
+ async_variant,
54
+ delete,
55
+ get,
56
+ patch,
57
+ post,
58
+ put,
59
+ )
60
+ from .routing.plugins import ControllerPlugin
61
+ from .routing.use_cases import use_case
62
+ from .security.auth import (
63
+ AuthedRequest,
64
+ aauthenticated_user,
65
+ acurrent_user,
66
+ arequest_context,
67
+ arequest_user,
68
+ authenticated_user,
69
+ current_user,
70
+ request_context,
71
+ request_user,
72
+ )
73
+ from .security.permissions import (
74
+ AllowAny,
75
+ Also,
76
+ BasePermission,
77
+ DenyAll,
78
+ DjangoModelPermissions,
79
+ HasDjangoPermission,
80
+ IsAuthenticated,
81
+ IsAuthenticatedOrReadOnly,
82
+ IsOwner,
83
+ IsReadOnly,
84
+ IsStaff,
85
+ IsSuperuser,
86
+ as_permission,
87
+ )
88
+ from .security.tenancy import MissingTenant, current_tenant
89
+ from .serialization.schemas import Input, Output, Patch, PatchData, ReadOnly, WriteOnly
90
+ from .serialization.visibility import FieldVisibility, VisibleTo
91
+
92
+ _EXPORTS: Final[Mapping[str, str]] = MappingProxyType(
93
+ {
94
+ "get_invocation": ".dependencies.instances",
95
+ "Invocation": ".dependencies.instances",
96
+ "aauthenticated_user": ".security.auth",
97
+ "acurrent_user": ".security.auth",
98
+ "arequest_context": ".security.auth",
99
+ "arequest_user": ".security.auth",
100
+ "AuthedRequest": ".security.auth",
101
+ "authenticated_user": ".security.auth",
102
+ "current_user": ".security.auth",
103
+ "request_context": ".security.auth",
104
+ "request_user": ".security.auth",
105
+ "conditional": ".http.conditional",
106
+ "ETag": ".http.conditional",
107
+ "PreconditionFailed": ".http.conditional",
108
+ "PreconditionRequired": ".http.conditional",
109
+ "Controller": ".routing.controller",
110
+ "ControllerOptions": ".routing.controller",
111
+ "Scope": ".routing.controller",
112
+ "AsyncRequestScopeProvider": ".dependencies.container",
113
+ "AsyncResolver": ".dependencies.container",
114
+ "Container": ".dependencies.container",
115
+ "ContainerLike": ".dependencies.container",
116
+ "Lifetime": ".dependencies.container",
117
+ "RequestScopeProvider": ".dependencies.container",
118
+ "Resolver": ".dependencies.container",
119
+ "ErrorMap": ".http.errors",
120
+ "AsyncLazyAccessError": ".exceptions",
121
+ "BlockingCallWarning": ".exceptions",
122
+ "CircularDependencyError": ".exceptions",
123
+ "ControllerConfigError": ".exceptions",
124
+ "DependencyResolutionError": ".exceptions",
125
+ "MixedPathWarning": ".exceptions",
126
+ "NinjaDevXError": ".exceptions",
127
+ "AsyncOperationHook": ".routing.hooks",
128
+ "get_operation": ".routing.hooks",
129
+ "LoggingHook": ".routing.hooks",
130
+ "OperationHook": ".routing.hooks",
131
+ "OperationInfo": ".routing.hooks",
132
+ "idempotent": ".idempotency",
133
+ "Inject": ".dependencies.injection",
134
+ "Resolve": ".dependencies.injection",
135
+ "resolve": ".dependencies.injection",
136
+ "Mount": ".routing.mounting",
137
+ "mount": ".routing.mounting",
138
+ "api_operation": ".routing.operations",
139
+ "async_variant": ".routing.operations",
140
+ "delete": ".routing.operations",
141
+ "get": ".routing.operations",
142
+ "OperationOptions": ".routing.operations",
143
+ "OperationSpec": ".routing.operations",
144
+ "patch": ".routing.operations",
145
+ "post": ".routing.operations",
146
+ "put": ".routing.operations",
147
+ "RouteOptions": ".routing.operations",
148
+ "AllowAny": ".security.permissions",
149
+ "Also": ".security.permissions",
150
+ "as_permission": ".security.permissions",
151
+ "BasePermission": ".security.permissions",
152
+ "DenyAll": ".security.permissions",
153
+ "DjangoModelPermissions": ".security.permissions",
154
+ "HasDjangoPermission": ".security.permissions",
155
+ "IsAuthenticated": ".security.permissions",
156
+ "IsAuthenticatedOrReadOnly": ".security.permissions",
157
+ "IsOwner": ".security.permissions",
158
+ "IsReadOnly": ".security.permissions",
159
+ "IsStaff": ".security.permissions",
160
+ "IsSuperuser": ".security.permissions",
161
+ "ControllerPlugin": ".routing.plugins",
162
+ "Input": ".serialization.schemas",
163
+ "Output": ".serialization.schemas",
164
+ "Patch": ".serialization.schemas",
165
+ "PatchData": ".serialization.schemas",
166
+ "ReadOnly": ".serialization.schemas",
167
+ "WriteOnly": ".serialization.schemas",
168
+ "current_tenant": ".security.tenancy",
169
+ "MissingTenant": ".security.tenancy",
170
+ "use_case": ".routing.use_cases",
171
+ "FieldVisibility": ".serialization.visibility",
172
+ "VisibleTo": ".serialization.visibility",
173
+ }
174
+ )
175
+
176
+ __all__ = [
177
+ "AllowAny",
178
+ "Also",
179
+ "AsyncLazyAccessError",
180
+ "AsyncOperationHook",
181
+ "AsyncRequestScopeProvider",
182
+ "AsyncResolver",
183
+ "AuthedRequest",
184
+ "BasePermission",
185
+ "BlockingCallWarning",
186
+ "CircularDependencyError",
187
+ "Container",
188
+ "ContainerLike",
189
+ "Controller",
190
+ "ControllerConfigError",
191
+ "ControllerOptions",
192
+ "ControllerPlugin",
193
+ "DenyAll",
194
+ "DependencyResolutionError",
195
+ "DjangoModelPermissions",
196
+ "ETag",
197
+ "ErrorMap",
198
+ "FieldVisibility",
199
+ "HasDjangoPermission",
200
+ "Inject",
201
+ "Input",
202
+ "Invocation",
203
+ "IsAuthenticated",
204
+ "IsAuthenticatedOrReadOnly",
205
+ "IsOwner",
206
+ "IsReadOnly",
207
+ "IsStaff",
208
+ "IsSuperuser",
209
+ "Lifetime",
210
+ "LoggingHook",
211
+ "MissingTenant",
212
+ "MixedPathWarning",
213
+ "Mount",
214
+ "NinjaDevXError",
215
+ "OperationHook",
216
+ "OperationInfo",
217
+ "OperationOptions",
218
+ "OperationSpec",
219
+ "Output",
220
+ "Patch",
221
+ "PatchData",
222
+ "PreconditionFailed",
223
+ "PreconditionRequired",
224
+ "ReadOnly",
225
+ "RequestScopeProvider",
226
+ "Resolve",
227
+ "Resolver",
228
+ "RouteOptions",
229
+ "Scope",
230
+ "VisibleTo",
231
+ "WriteOnly",
232
+ "aauthenticated_user",
233
+ "acurrent_user",
234
+ "api_operation",
235
+ "arequest_context",
236
+ "arequest_user",
237
+ "as_permission",
238
+ "async_variant",
239
+ "authenticated_user",
240
+ "conditional",
241
+ "current_tenant",
242
+ "current_user",
243
+ "delete",
244
+ "get",
245
+ "get_invocation",
246
+ "get_operation",
247
+ "idempotent",
248
+ "mount",
249
+ "patch",
250
+ "post",
251
+ "put",
252
+ "request_context",
253
+ "request_user",
254
+ "resolve",
255
+ "use_case",
256
+ ]
257
+
258
+ try:
259
+ __version__ = version("ninja-devx")
260
+ except PackageNotFoundError: # pragma: no cover - running from a source tree
261
+ __version__ = "0.0.1"
262
+
263
+
264
+ def __getattr__(name: str) -> object:
265
+ module = _EXPORTS.get(name)
266
+ if module is None:
267
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
268
+ value: object = getattr(importlib.import_module(module, __name__), name)
269
+ return value
270
+
271
+
272
+ def __dir__() -> list[str]:
273
+ return sorted(__all__)
@@ -0,0 +1 @@
1
+ """Internal components for ninja-devx."""
@@ -0,0 +1,16 @@
1
+ """Class-owned caches that cannot accidentally inherit a parent's resolved values."""
2
+
3
+ from typing import TypeVar, cast
4
+
5
+ K = TypeVar("K")
6
+ V = TypeVar("V")
7
+
8
+
9
+ def owned_cache(owner: type[object], name: str) -> dict[K, V]: # pyright: ignore[reportInvalidTypeVarUse]
10
+ """Keep generated values with their source class, including self-referential types."""
11
+ attribute = f"__devx_cache_{name}"
12
+ cache = vars(owner).get(attribute)
13
+ if cache is None:
14
+ cache = {}
15
+ setattr(owner, attribute, cache)
16
+ return cast("dict[K, V]", cache)
@@ -0,0 +1,19 @@
1
+ import inspect
2
+ import sys
3
+ from collections.abc import Callable
4
+
5
+ __all__ = ["signature"]
6
+
7
+
8
+ if sys.version_info >= (3, 14): # pragma: no cover - version specific
9
+ from annotationlib import Format
10
+
11
+ def signature(obj: Callable[..., object]) -> inspect.Signature:
12
+ """``inspect.signature`` that tolerates annotations referring to undefined names."""
13
+ return inspect.signature(obj, annotation_format=Format.FORWARDREF)
14
+
15
+ else: # pragma: no cover - version specific
16
+
17
+ def signature(obj: Callable[..., object]) -> inspect.Signature:
18
+ """``inspect.signature`` (annotations are never evaluated before Python 3.14)."""
19
+ return inspect.signature(obj)
@@ -0,0 +1,130 @@
1
+ """Resolve generic type parameters of controller classes at registration time.
2
+
3
+ ``class ArticleController(CRUDController[Article, ArticleOut, ArticleIn])`` binds the
4
+ TypeVars used in inherited method annotations and operation options; handlers are
5
+ built with the concrete types so Ninja sees ``payload: ArticleIn``.
6
+ """
7
+
8
+ from collections.abc import Mapping
9
+ from types import MappingProxyType
10
+ from typing import Annotated, Generic, TypeVar, cast, get_args, get_origin
11
+
12
+ from ..exceptions import ControllerConfigError
13
+ from .cache import owned_cache
14
+
15
+ __all__ = ["LazyAnnotation", "defined_in", "resolve_annotation", "substitute", "type_arguments"]
16
+
17
+
18
+ class LazyAnnotation:
19
+ """``Annotated`` metadata replaced by a concrete annotation when a controller is built.
20
+
21
+ ``Annotated[T, marker]`` becomes ``marker.resolve(T, controller)``.
22
+ """
23
+
24
+ def resolve(self, annotation: object, controller: type[object]) -> object:
25
+ raise NotImplementedError
26
+
27
+
28
+ def type_arguments(cls: type[object]) -> Mapping[TypeVar, object]:
29
+ """Map every TypeVar bound anywhere in ``cls``'s generic ancestry to its argument.
30
+
31
+ Cached per class: controllers and services call this on every request.
32
+ """
33
+ _type_arguments: dict[type[object], Mapping[TypeVar, object]] = owned_cache(
34
+ cls, "generics_type_arguments"
35
+ )
36
+ cached = _type_arguments.get(cls)
37
+ if cached is None:
38
+ computed = _compute_type_arguments(cls)
39
+ # A class may derive some arguments from others (``AutoCRUDController[Post]``
40
+ # generates its schemas from the model).
41
+ derive: object = getattr(cls, "derive_type_arguments", None)
42
+ if callable(derive):
43
+ extra = cast("Mapping[TypeVar, object]", derive(MappingProxyType(computed)))
44
+ computed.update(extra)
45
+ cached = _type_arguments[cls] = MappingProxyType(computed)
46
+ return cached
47
+
48
+
49
+ def _compute_type_arguments(cls: type[object]) -> dict[TypeVar, object]:
50
+ mapping: dict[TypeVar, object] = {}
51
+ # Subclasses come first in the MRO, so their bindings are known before a base
52
+ # re-parameterizes its own bases with the same TypeVars.
53
+ for klass in cls.__mro__:
54
+ bases: tuple[object, ...] = klass.__dict__.get("__orig_bases__", ())
55
+ for base in bases:
56
+ origin = get_origin(base)
57
+ if origin is None or origin is Generic:
58
+ continue
59
+ parameters: tuple[object, ...] = getattr(origin, "__parameters__", ())
60
+ for parameter, argument in zip(parameters, get_args(base), strict=False):
61
+ if not isinstance(parameter, TypeVar):
62
+ continue
63
+ value = substitute(argument, mapping)
64
+ if value is parameter:
65
+ continue
66
+ existing = mapping.setdefault(parameter, value)
67
+ if existing != value and not isinstance(value, TypeVar):
68
+ raise ControllerConfigError(
69
+ f"{cls.__qualname__}: conflicting arguments for {parameter}: "
70
+ f"{existing!r} and {value!r}"
71
+ )
72
+ return mapping
73
+
74
+
75
+ def substitute(annotation: object, mapping: Mapping[TypeVar, object]) -> object:
76
+ """Replace TypeVars in ``annotation`` (``T``, ``list[T]``, ``Annotated[T, ...]``...)."""
77
+ if isinstance(annotation, TypeVar):
78
+ return mapping.get(annotation, annotation)
79
+ parameters: tuple[object, ...] = getattr(annotation, "__parameters__", ())
80
+ if not parameters or not any(parameter in mapping for parameter in parameters):
81
+ return annotation
82
+ arguments = tuple(
83
+ mapping.get(parameter, parameter) if isinstance(parameter, TypeVar) else parameter
84
+ for parameter in parameters
85
+ )
86
+ try:
87
+ return annotation[arguments] # type: ignore[index] # pyright: ignore[reportIndexIssue, reportUnknownVariableType]
88
+ except TypeError as exc:
89
+ raise ControllerConfigError(f"Cannot substitute type arguments in {annotation!r}") from exc
90
+
91
+
92
+ def resolve_annotation(
93
+ annotation: object, mapping: Mapping[TypeVar, object], controller: type[object]
94
+ ) -> object:
95
+ """Substitute TypeVars, then expand ``LazyAnnotation`` markers."""
96
+ annotation = substitute(annotation, mapping)
97
+ if get_origin(annotation) is not Annotated:
98
+ return annotation
99
+ base, *metadata = get_args(annotation)
100
+ for index, item in enumerate(metadata):
101
+ if isinstance(item, LazyAnnotation):
102
+ resolved = item.resolve(base, controller)
103
+ rest = [*metadata[:index], *metadata[index + 1 :]]
104
+ return Annotated[resolved, *rest] if rest else resolved
105
+ return annotation
106
+
107
+
108
+ def find_unbound(annotation: object) -> tuple[TypeVar, ...]:
109
+ if isinstance(annotation, TypeVar):
110
+ return (annotation,)
111
+ parameters: tuple[object, ...] = getattr(annotation, "__parameters__", ())
112
+ return tuple(parameter for parameter in parameters if isinstance(parameter, TypeVar))
113
+
114
+
115
+ def defined_in(
116
+ cls: type[object], attribute: str, *, through_wrappers: bool = False
117
+ ) -> type[object] | None:
118
+ """The class in ``cls``'s MRO that defines ``attribute``.
119
+
120
+ With ``through_wrappers``, classes listing ``attribute`` in their own
121
+ ``__devx_wraps__`` (mixins that only add behaviour around ``super()``) are skipped.
122
+ """
123
+ for klass in cls.__mro__:
124
+ if attribute not in vars(klass):
125
+ continue
126
+ wraps: object = vars(klass).get("__devx_wraps__", ())
127
+ if through_wrappers and isinstance(wraps, frozenset) and attribute in wraps:
128
+ continue
129
+ return klass
130
+ return None
@@ -0,0 +1,14 @@
1
+ """Translation helpers shared by the package (catalog: ``ninja_devx/locale``)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from django.db.models import Model
6
+ from django.utils.text import capfirst
7
+ from django.utils.translation import gettext as _
8
+
9
+ __all__ = ["not_found"]
10
+
11
+
12
+ def not_found(model: type[Model]) -> str:
13
+ """``"Article not found"``, with the model's (translated) verbose name."""
14
+ return _("%(name)s not found") % {"name": capfirst(str(model._meta.verbose_name))}
@@ -0,0 +1,154 @@
1
+ """Preflight and context-preserving cleanup for Ninja 1.x async streaming operations.
2
+
3
+ Ninja normally starts headers before advancing an async generator. Our operation
4
+ subclass consumes a private readiness marker first. A single producer task owns all
5
+ context managers, including cancellation cleanup; items are pulled only on demand.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import logging
12
+ from collections.abc import AsyncIterator, Callable
13
+ from dataclasses import dataclass
14
+ from typing import cast
15
+
16
+ from django.http import HttpRequest, HttpResponse, StreamingHttpResponse
17
+ from ninja.operation import AsyncOperation, Operation
18
+
19
+ from ..exceptions import ControllerConfigError
20
+
21
+
22
+ @dataclass(frozen=True, slots=True)
23
+ class StreamReady:
24
+ response: HttpResponse | None = None
25
+
26
+
27
+ @dataclass(frozen=True, slots=True)
28
+ class _Item:
29
+ value: object = None
30
+ done: bool = False
31
+
32
+
33
+ class PreparedStream:
34
+ def __init__(
35
+ self, source: AsyncIterator[object], *, on_cancel: Callable[[], None] | None = None
36
+ ) -> None:
37
+ self.source = source
38
+ self.on_cancel = on_cancel
39
+ self.loop = asyncio.get_running_loop()
40
+ self.ready: asyncio.Future[StreamReady] = self.loop.create_future()
41
+ self.demand: asyncio.Queue[asyncio.Future[_Item]] = asyncio.Queue(maxsize=1)
42
+ self.task = self.loop.create_task(self._serve())
43
+ self.task.add_done_callback(self._cleanup_result)
44
+
45
+ @staticmethod
46
+ def _cleanup_result(task: asyncio.Task[None]) -> None:
47
+ if not task.cancelled() and (error := task.exception()) is not None:
48
+ logging.getLogger("ninja_devx").error(
49
+ "Async stream cleanup failed", exc_info=(type(error), error, error.__traceback__)
50
+ )
51
+
52
+ async def _serve(self) -> None:
53
+ current: asyncio.Future[_Item] | None = None
54
+ try:
55
+ marker = await anext(self.source)
56
+ if not isinstance(marker, StreamReady):
57
+ raise RuntimeError("Async stream did not provide its preflight marker")
58
+ self.ready.set_result(marker)
59
+ if marker.response is not None:
60
+ return
61
+ while True:
62
+ current = await self.demand.get()
63
+ try:
64
+ value = await anext(self.source)
65
+ except StopAsyncIteration:
66
+ if not current.done():
67
+ current.set_result(_Item(done=True))
68
+ return
69
+ if not current.done():
70
+ current.set_result(_Item(value))
71
+ current = None
72
+ except BaseException as exc:
73
+ waiting: asyncio.Future[StreamReady] | asyncio.Future[_Item] | None = (
74
+ self.ready if not self.ready.done() else current
75
+ )
76
+ if waiting is not None and not waiting.done():
77
+ if isinstance(exc, asyncio.CancelledError):
78
+ waiting.cancel()
79
+ else:
80
+ waiting.set_exception(exc)
81
+ finally:
82
+ close = getattr(self.source, "aclose", None)
83
+ if close is not None:
84
+ await close()
85
+
86
+ def __aiter__(self) -> PreparedStream:
87
+ return self
88
+
89
+ async def __anext__(self) -> object:
90
+ if self.task.done():
91
+ raise StopAsyncIteration
92
+ result: asyncio.Future[_Item] = self.loop.create_future()
93
+ try:
94
+ await self.demand.put(result)
95
+ item = await result
96
+ if item.done:
97
+ raise StopAsyncIteration
98
+ return item.value
99
+ except asyncio.CancelledError:
100
+ try:
101
+ await self.aclose()
102
+ finally:
103
+ # Django 4.2's ASGI send path does not close the request when cancelled.
104
+ # File cleanup is idempotent on newer Django versions as well.
105
+ if self.on_cancel is not None:
106
+ self.on_cancel()
107
+ raise
108
+ except BaseException:
109
+ await self.aclose()
110
+ raise
111
+
112
+ async def aclose(self) -> None:
113
+ if not self.task.done():
114
+ self.task.cancel()
115
+ await asyncio.gather(self.task, return_exceptions=True)
116
+
117
+ def close(self) -> None:
118
+ """Django closes responses synchronously, sometimes from a worker thread."""
119
+ if not self.task.done() and not self.loop.is_closed():
120
+ self.loop.call_soon_threadsafe(self.task.cancel)
121
+
122
+
123
+ class PreflightAsyncOperation(AsyncOperation):
124
+ async def _async_stream_response( # type: ignore[override] # pyright: ignore[reportIncompatibleMethodOverride]
125
+ self, request: HttpRequest, generator: object, temporal_response: HttpResponse
126
+ ) -> HttpResponse | StreamingHttpResponse:
127
+ stream = PreparedStream(cast("AsyncIterator[object]", generator), on_cancel=request.close)
128
+ try:
129
+ ready = await stream.ready
130
+ if ready.response is not None:
131
+ await stream.aclose()
132
+ return ready.response
133
+ response = await super()._async_stream_response(request, stream, temporal_response)
134
+ except BaseException:
135
+ await stream.aclose()
136
+ raise
137
+ original_close = response.close
138
+
139
+ def close_response() -> None:
140
+ stream.close()
141
+ original_close()
142
+
143
+ response.close = close_response # type: ignore[method-assign]
144
+ return response
145
+
146
+
147
+ def enable_preflight(operation: Operation) -> None:
148
+ """Adapt only our operation; Ninja's clone preserves its concrete class."""
149
+ if (
150
+ not isinstance(operation, AsyncOperation)
151
+ or getattr(operation, "stream_format", None) is None
152
+ ):
153
+ raise ControllerConfigError("Async generators require a Ninja streaming response schema")
154
+ operation.__class__ = PreflightAsyncOperation
@@ -0,0 +1,79 @@
1
+ """Type aliases describing what Django Ninja accepts, without ``typing.Any``."""
2
+
3
+ from collections.abc import Callable, Iterable, Mapping, Sequence
4
+ from types import GenericAlias, MappingProxyType, UnionType
5
+ from typing import Final, TypeAlias
6
+
7
+ from django.http import HttpRequest
8
+ from ninja.constants import NOT_SET_TYPE
9
+ from ninja.security.base import AuthBase
10
+ from ninja.throttling import BaseThrottle
11
+
12
+ __all__ = [
13
+ "AuthCallable",
14
+ "AuthSpec",
15
+ "JSONValue",
16
+ "MethodFunction",
17
+ "ResponseSchema",
18
+ "ResponseSpec",
19
+ "ThrottleSpec",
20
+ "ViewDecorator",
21
+ "ViewFunction",
22
+ "did_you_mean",
23
+ "status_phrase",
24
+ ]
25
+
26
+ JSONValue: TypeAlias = (
27
+ str | int | float | bool | Sequence["JSONValue"] | Mapping[str, "JSONValue"] | None
28
+ )
29
+
30
+ AuthCallable: TypeAlias = Callable[[HttpRequest], object]
31
+ """A callable returning a truthy value (stored as ``request.auth``) when authenticated."""
32
+
33
+ AuthSpec: TypeAlias = (
34
+ AuthBase | AuthCallable | Sequence[AuthBase | AuthCallable] | NOT_SET_TYPE | None
35
+ )
36
+ """``None`` disables authentication, ``NOT_SET`` inherits it."""
37
+
38
+ ThrottleSpec: TypeAlias = BaseThrottle | Sequence[BaseThrottle] | NOT_SET_TYPE
39
+
40
+ ResponseSchema: TypeAlias = type[object] | UnionType | GenericAlias | None
41
+ """A schema class, ``list[Schema]``, ``A | B`` or ``None`` (empty body)."""
42
+
43
+ ResponseSpec: TypeAlias = (
44
+ ResponseSchema | Mapping[int | frozenset[int], ResponseSchema] | NOT_SET_TYPE
45
+ )
46
+ """A single schema or a mapping of status codes (``ninja.responses.codes_4xx``...) to schemas."""
47
+
48
+ MethodFunction: TypeAlias = Callable[..., object]
49
+ """An unbound controller method."""
50
+
51
+ ViewFunction: TypeAlias = Callable[..., object]
52
+ ViewDecorator: TypeAlias = Callable[[ViewFunction], ViewFunction]
53
+ """A Django Ninja view decorator such as ``ninja.pagination.paginate(...)``."""
54
+
55
+
56
+ _PHRASES: Final[Mapping[int, str]] = MappingProxyType(
57
+ {
58
+ 413: "Content Too Large",
59
+ 422: "Unprocessable Content",
60
+ }
61
+ )
62
+
63
+
64
+ def did_you_mean(name: str, candidates: Iterable[str]) -> str:
65
+ """`` Did you mean 'title'?`` when a close candidate exists, else an empty string."""
66
+ import difflib
67
+
68
+ matches = difflib.get_close_matches(name, list(candidates), n=1, cutoff=0.6)
69
+ return f" Did you mean {matches[0]!r}?" if matches else ""
70
+
71
+
72
+ def status_phrase(status: int) -> str:
73
+ """The RFC 9110 reason phrase, the same on every Python version (3.13 renamed some)."""
74
+ from http import HTTPStatus
75
+
76
+ try:
77
+ return _PHRASES.get(status) or HTTPStatus(status).phrase
78
+ except ValueError:
79
+ return "Error"