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.
- ninja_devx/__init__.py +273 -0
- ninja_devx/_internal/__init__.py +1 -0
- ninja_devx/_internal/cache.py +16 -0
- ninja_devx/_internal/compat.py +19 -0
- ninja_devx/_internal/generics.py +130 -0
- ninja_devx/_internal/i18n.py +14 -0
- ninja_devx/_internal/streaming.py +154 -0
- ninja_devx/_internal/types.py +79 -0
- ninja_devx/_permission_eval.py +63 -0
- ninja_devx/_permission_eval_async.py +60 -0
- ninja_devx/apps.py +21 -0
- ninja_devx/codegen/__init__.py +14 -0
- ninja_devx/codegen/openapi.py +300 -0
- ninja_devx/codegen/python.py +833 -0
- ninja_devx/codegen/source.py +87 -0
- ninja_devx/codegen/typescript.py +367 -0
- ninja_devx/configuration/__init__.py +1 -0
- ninja_devx/configuration/checks.py +143 -0
- ninja_devx/configuration/runtime.py +33 -0
- ninja_devx/configuration/settings.py +222 -0
- ninja_devx/contrib/__init__.py +0 -0
- ninja_devx/contrib/apikeys/__init__.py +16 -0
- ninja_devx/contrib/apikeys/admin.py +51 -0
- ninja_devx/contrib/apikeys/api.py +92 -0
- ninja_devx/contrib/apikeys/apps.py +8 -0
- ninja_devx/contrib/apikeys/auth.py +213 -0
- ninja_devx/contrib/apikeys/management/__init__.py +0 -0
- ninja_devx/contrib/apikeys/management/commands/__init__.py +0 -0
- ninja_devx/contrib/apikeys/management/commands/devx_apikey.py +58 -0
- ninja_devx/contrib/apikeys/migrations/0001_initial.py +58 -0
- ninja_devx/contrib/apikeys/migrations/__init__.py +0 -0
- ninja_devx/contrib/apikeys/models.py +43 -0
- ninja_devx/contrib/audit/__init__.py +13 -0
- ninja_devx/contrib/audit/admin.py +36 -0
- ninja_devx/contrib/audit/api.py +122 -0
- ninja_devx/contrib/audit/apps.py +8 -0
- ninja_devx/contrib/audit/log.py +230 -0
- ninja_devx/contrib/audit/migrations/0001_initial.py +68 -0
- ninja_devx/contrib/audit/migrations/__init__.py +0 -0
- ninja_devx/contrib/audit/models.py +47 -0
- ninja_devx/contrib/audit/privacy.py +65 -0
- ninja_devx/contrib/dishka.py +135 -0
- ninja_devx/contrib/grants/__init__.py +6 -0
- ninja_devx/contrib/grants/admin.py +22 -0
- ninja_devx/contrib/grants/apps.py +8 -0
- ninja_devx/contrib/grants/backends.py +53 -0
- ninja_devx/contrib/grants/migrations/0001_initial.py +82 -0
- ninja_devx/contrib/grants/migrations/__init__.py +0 -0
- ninja_devx/contrib/grants/models.py +59 -0
- ninja_devx/contrib/otel.py +122 -0
- ninja_devx/contrib/svcs.py +54 -0
- ninja_devx/contrib/uploads/__init__.py +23 -0
- ninja_devx/contrib/uploads/api.py +317 -0
- ninja_devx/contrib/uploads/backends.py +201 -0
- ninja_devx/contrib/uploads/models.py +27 -0
- ninja_devx/contrib/webhooks/__init__.py +19 -0
- ninja_devx/contrib/webhooks/admin.py +146 -0
- ninja_devx/contrib/webhooks/api.py +198 -0
- ninja_devx/contrib/webhooks/apps.py +8 -0
- ninja_devx/contrib/webhooks/maintenance.py +57 -0
- ninja_devx/contrib/webhooks/management/__init__.py +0 -0
- ninja_devx/contrib/webhooks/management/commands/__init__.py +0 -0
- ninja_devx/contrib/webhooks/management/commands/devx_webhooks.py +115 -0
- ninja_devx/contrib/webhooks/migrations/0001_initial.py +126 -0
- ninja_devx/contrib/webhooks/migrations/0002_outboxevent_audience_webhookdelivery_lease_token_and_more.py +27 -0
- ninja_devx/contrib/webhooks/migrations/__init__.py +0 -0
- ninja_devx/contrib/webhooks/models.py +119 -0
- ninja_devx/contrib/webhooks/network.py +173 -0
- ninja_devx/contrib/webhooks/outbox.py +375 -0
- ninja_devx/contrib/webhooks/secrets.py +95 -0
- ninja_devx/contrib/webhooks/signing.py +104 -0
- ninja_devx/contrib/webhooks/tasks.py +35 -0
- ninja_devx/crud/__init__.py +84 -0
- ninja_devx/crud/annotations.py +274 -0
- ninja_devx/crud/async_controllers.py +21 -0
- ninja_devx/crud/auto.py +140 -0
- ninja_devx/crud/bulk.py +198 -0
- ninja_devx/crud/controllers.py +822 -0
- ninja_devx/crud/fields.py +94 -0
- ninja_devx/crud/filters.py +119 -0
- ninja_devx/crud/nested.py +130 -0
- ninja_devx/crud/optimization.py +187 -0
- ninja_devx/crud/pagination.py +252 -0
- ninja_devx/crud/persistence.py +5 -0
- ninja_devx/crud/scoping.py +58 -0
- ninja_devx/crud/shaping.py +136 -0
- ninja_devx/crud/sharing.py +164 -0
- ninja_devx/crud/soft_delete.py +182 -0
- ninja_devx/crud/transfer.py +350 -0
- ninja_devx/crud/writes.py +51 -0
- ninja_devx/dependencies/__init__.py +1 -0
- ninja_devx/dependencies/container.py +302 -0
- ninja_devx/dependencies/contracts.py +67 -0
- ninja_devx/dependencies/engine.py +287 -0
- ninja_devx/dependencies/injection.py +144 -0
- ninja_devx/dependencies/instances.py +206 -0
- ninja_devx/dependencies/introspection.py +51 -0
- ninja_devx/dependencies/scope.py +61 -0
- ninja_devx/dependencies/state.py +82 -0
- ninja_devx/exceptions.py +42 -0
- ninja_devx/http/__init__.py +1 -0
- ninja_devx/http/conditional.py +207 -0
- ninja_devx/http/errors.py +180 -0
- ninja_devx/http/health.py +221 -0
- ninja_devx/http/middleware.py +284 -0
- ninja_devx/http/throttling.py +226 -0
- ninja_devx/idempotency/__init__.py +5 -0
- ninja_devx/idempotency/models.py +29 -0
- ninja_devx/idempotency/policy.py +172 -0
- ninja_devx/idempotency/store.py +114 -0
- ninja_devx/layers/__init__.py +58 -0
- ninja_devx/layers/context.py +39 -0
- ninja_devx/layers/dual.py +98 -0
- ninja_devx/layers/errors.py +113 -0
- ninja_devx/layers/persistence.py +82 -0
- ninja_devx/layers/policies.py +41 -0
- ninja_devx/layers/repository.py +111 -0
- ninja_devx/layers/selectors.py +19 -0
- ninja_devx/layers/services.py +54 -0
- ninja_devx/layers/tasks.py +93 -0
- ninja_devx/layers/testing.py +75 -0
- ninja_devx/management/__init__.py +0 -0
- ninja_devx/management/commands/__init__.py +0 -0
- ninja_devx/management/commands/devx_openapi.py +71 -0
- ninja_devx/management/commands/devx_scaffold.py +124 -0
- ninja_devx/management/commands/devx_startapp.py +33 -0
- ninja_devx/management/commands/devx_uploads.py +31 -0
- ninja_devx/migrations/0001_initial.py +31 -0
- ninja_devx/migrations/0002_uploadrecord.py +37 -0
- ninja_devx/migrations/__init__.py +0 -0
- ninja_devx/models.py +6 -0
- ninja_devx/py.typed +0 -0
- ninja_devx/pytest_plugin.py +3 -0
- ninja_devx/routing/__init__.py +1 -0
- ninja_devx/routing/bindings.py +58 -0
- ninja_devx/routing/compiler.py +510 -0
- ninja_devx/routing/controller.py +436 -0
- ninja_devx/routing/hooks.py +136 -0
- ninja_devx/routing/invocation.py +368 -0
- ninja_devx/routing/mounting.py +82 -0
- ninja_devx/routing/operations.py +203 -0
- ninja_devx/routing/plugins.py +46 -0
- ninja_devx/routing/use_cases.py +128 -0
- ninja_devx/security/__init__.py +1 -0
- ninja_devx/security/auth.py +224 -0
- ninja_devx/security/object_permissions.py +456 -0
- ninja_devx/security/permission_leaf.py +50 -0
- ninja_devx/security/permissions.py +486 -0
- ninja_devx/security/tenancy.py +130 -0
- ninja_devx/serialization/__init__.py +1 -0
- ninja_devx/serialization/pydantic.py +24 -0
- ninja_devx/serialization/renderers.py +64 -0
- ninja_devx/serialization/schemas.py +167 -0
- ninja_devx/serialization/visibility.py +278 -0
- ninja_devx/templates/app_template/__init__.py-tpl +0 -0
- ninja_devx/templates/app_template/api.py-tpl +19 -0
- ninja_devx/templates/app_template/apps.py-tpl +6 -0
- ninja_devx/templates/app_template/migrations/__init__.py-tpl +0 -0
- ninja_devx/templates/app_template/models.py-tpl +4 -0
- ninja_devx/templates/app_template/schemas.py-tpl +6 -0
- ninja_devx/templates/app_template/services.py-tpl +6 -0
- ninja_devx/templates/app_template/tests/__init__.py-tpl +0 -0
- ninja_devx/templates/app_template/tests/test_api.py-tpl +13 -0
- ninja_devx/testing/__init__.py +1 -0
- ninja_devx/testing/clients.py +200 -0
- ninja_devx/testing/contracts.py +58 -0
- ninja_devx/testing/plugin.py +169 -0
- ninja_devx/tooling/__init__.py +1 -0
- ninja_devx/tooling/drift.py +252 -0
- ninja_devx/tooling/scaffold.py +419 -0
- ninja_devx/tooling/unasync.py +183 -0
- ninja_devx/unasync.py +6 -0
- ninja_devx-0.0.1.dist-info/METADATA +171 -0
- ninja_devx-0.0.1.dist-info/RECORD +178 -0
- ninja_devx-0.0.1.dist-info/WHEEL +4 -0
- ninja_devx-0.0.1.dist-info/entry_points.txt +2 -0
- ninja_devx-0.0.1.dist-info/licenses/LICENSE +202 -0
- 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"
|