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
@@ -0,0 +1,63 @@
1
+ # Generated by `python -m ninja_devx.tooling.unasync` from:
2
+ # src/ninja_devx/_permission_eval_async.py
3
+ # Do not edit.
4
+ """Evaluate a permission tree, deferring object-dependent decisions until lookup.
5
+
6
+ ``_permission_eval.py`` is generated from this file with ``python -m ninja_devx.tooling.unasync``.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import TYPE_CHECKING
12
+
13
+ from django.http import HttpRequest
14
+
15
+ from .security import permission_leaf as _permission_leaf
16
+ from .security.permission_leaf import Target
17
+
18
+ if TYPE_CHECKING:
19
+ from .security.permissions import AnyPermission
20
+
21
+
22
+ def denied(
23
+ permission: AnyPermission, request: HttpRequest, target: Target
24
+ ) -> AnyPermission | None:
25
+ allowed, refused = _evaluate(permission, request, target)
26
+ return refused if allowed is False else None
27
+
28
+
29
+ def _evaluate(
30
+ permission: AnyPermission, request: HttpRequest, target: Target
31
+ ) -> tuple[bool | None, AnyPermission | None]:
32
+ """None is unknown, not allowed: object checks cannot run before an object exists.
33
+
34
+ Preserve unknown through negation and nested Boolean expressions. At object time
35
+ each leaf must satisfy both its request and object checks, so OR cannot switch
36
+ between two independently failing branches.
37
+ """
38
+ combinator = permission.combinator
39
+ if combinator in {"all", "any"}:
40
+ pending = False
41
+ refused = permission
42
+ for child in permission.operands:
43
+ allowed, child_refused = _evaluate(child, request, target)
44
+ if combinator == "all" and allowed is False:
45
+ return False, child_refused
46
+ if combinator == "any" and allowed is True:
47
+ return True, None
48
+ pending |= allowed is None
49
+ if child_refused is not None:
50
+ refused = child_refused
51
+ if pending:
52
+ return None, None
53
+ return (True, None) if combinator == "all" else (False, refused)
54
+ if combinator == "not":
55
+ allowed, _ = _evaluate(permission.operands[0], request, target)
56
+ if allowed is None:
57
+ return None, None
58
+ return (False, permission) if allowed else (True, None)
59
+ if not _permission_leaf.leaf_allows(permission, request, target):
60
+ return False, permission
61
+ if target is None and _permission_leaf.checks_objects(permission):
62
+ return None, None
63
+ return True, None
@@ -0,0 +1,60 @@
1
+ """Evaluate a permission tree, deferring object-dependent decisions until lookup.
2
+
3
+ ``_permission_eval.py`` is generated from this file with ``python -m ninja_devx.tooling.unasync``.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from typing import TYPE_CHECKING
9
+
10
+ from django.http import HttpRequest
11
+
12
+ from .security import permission_leaf as _permission_leaf
13
+ from .security.permission_leaf import Target
14
+
15
+ if TYPE_CHECKING:
16
+ from .security.permissions import AnyPermission
17
+
18
+
19
+ async def adenied(
20
+ permission: AnyPermission, request: HttpRequest, target: Target
21
+ ) -> AnyPermission | None:
22
+ allowed, refused = await _evaluate(permission, request, target)
23
+ return refused if allowed is False else None
24
+
25
+
26
+ async def _evaluate(
27
+ permission: AnyPermission, request: HttpRequest, target: Target
28
+ ) -> tuple[bool | None, AnyPermission | None]:
29
+ """None is unknown, not allowed: object checks cannot run before an object exists.
30
+
31
+ Preserve unknown through negation and nested Boolean expressions. At object time
32
+ each leaf must satisfy both its request and object checks, so OR cannot switch
33
+ between two independently failing branches.
34
+ """
35
+ combinator = permission.combinator
36
+ if combinator in {"all", "any"}:
37
+ pending = False
38
+ refused = permission
39
+ for child in permission.operands:
40
+ allowed, child_refused = await _evaluate(child, request, target)
41
+ if combinator == "all" and allowed is False:
42
+ return False, child_refused
43
+ if combinator == "any" and allowed is True:
44
+ return True, None
45
+ pending |= allowed is None
46
+ if child_refused is not None:
47
+ refused = child_refused
48
+ if pending:
49
+ return None, None
50
+ return (True, None) if combinator == "all" else (False, refused)
51
+ if combinator == "not":
52
+ allowed, _ = await _evaluate(permission.operands[0], request, target)
53
+ if allowed is None:
54
+ return None, None
55
+ return (False, permission) if allowed else (True, None)
56
+ if not await _permission_leaf.aleaf_allows(permission, request, target):
57
+ return False, permission
58
+ if target is None and _permission_leaf.checks_objects(permission):
59
+ return None, None
60
+ return True, None
ninja_devx/apps.py ADDED
@@ -0,0 +1,21 @@
1
+ from types import ModuleType
2
+
3
+ from django.apps import AppConfig
4
+
5
+ from .configuration.runtime import RuntimeState
6
+
7
+
8
+ class NinjaDevXConfig(AppConfig):
9
+ """Add ``"ninja_devx"`` to ``INSTALLED_APPS`` for the management commands."""
10
+
11
+ name = "ninja_devx"
12
+ verbose_name = "Ninja DevX"
13
+
14
+ def __init__(self, app_name: str, app_module: ModuleType) -> None:
15
+ super().__init__(app_name, app_module)
16
+ self.runtime = RuntimeState()
17
+
18
+ def ready(self) -> None:
19
+ from .configuration.checks import check_controllers
20
+
21
+ del check_controllers # imported to register the system checks
@@ -0,0 +1,14 @@
1
+ """Typed API clients generated from a Django Ninja OpenAPI schema."""
2
+
3
+ from .openapi import Operation, Parameter, load_api, read_operations
4
+ from .python import generate_python
5
+ from .typescript import generate_typescript
6
+
7
+ __all__ = [
8
+ "Operation",
9
+ "Parameter",
10
+ "generate_python",
11
+ "generate_typescript",
12
+ "load_api",
13
+ "read_operations",
14
+ ]
@@ -0,0 +1,300 @@
1
+ """A small, typed view of the OpenAPI documents Django Ninja produces."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from collections.abc import Mapping, Sequence
7
+ from dataclasses import dataclass
8
+ from typing import Literal, TypeAlias, cast
9
+
10
+ from django.utils.module_loading import import_string
11
+ from ninja import NinjaAPI
12
+
13
+ __all__ = [
14
+ "Operation",
15
+ "Parameter",
16
+ "Schema",
17
+ "as_list",
18
+ "as_mapping",
19
+ "as_strings",
20
+ "load_api",
21
+ "read_operations",
22
+ "ref_name",
23
+ ]
24
+
25
+ Schema: TypeAlias = Mapping[str, object]
26
+ Location = Literal["path", "query", "header", "cookie"]
27
+ _METHODS = ("get", "post", "put", "patch", "delete", "head", "options")
28
+
29
+
30
+ @dataclass(frozen=True, slots=True)
31
+ class Parameter:
32
+ name: str
33
+ location: Location
34
+ required: bool
35
+ schema: Schema
36
+
37
+
38
+ @dataclass(frozen=True, slots=True)
39
+ class ResponseVariant:
40
+ status: str
41
+ media_type: str | None
42
+ schema: Schema
43
+
44
+
45
+ @dataclass(frozen=True, slots=True)
46
+ class Operation:
47
+ operation_id: str
48
+ method: str
49
+ path: str
50
+ summary: str
51
+ parameters: tuple[Parameter, ...]
52
+ body: Schema | None
53
+ body_required: bool
54
+ response: Schema | None
55
+ """Union of all documented 2xx JSON, text and binary representations."""
56
+ has_body: bool
57
+ """Whether any documented success response has a body."""
58
+ response_variants: tuple[ResponseVariant, ...] = ()
59
+ body_media_type: str = "application/json"
60
+
61
+
62
+ def load_api(path: str) -> NinjaAPI:
63
+ """Import a ``NinjaAPI`` from a dotted path, e.g. ``config.urls.api``."""
64
+ api: object = import_string(path)
65
+ if not isinstance(api, NinjaAPI):
66
+ raise TypeError(f"{path} is not a NinjaAPI instance")
67
+ return api
68
+
69
+
70
+ def ref_name(reference: str) -> str:
71
+ return reference.rsplit("/", 1)[-1]
72
+
73
+
74
+ def named_multipart_bodies(document: Schema) -> Schema:
75
+ """Give inline multipart objects stable model names without mutating the input."""
76
+ components = dict(_mapping(document.get("components")))
77
+ schemas = dict(_mapping(components.get("schemas")))
78
+ paths: dict[str, object] = {}
79
+ counter = 0
80
+ for path, raw_path in _mapping(document.get("paths")).items():
81
+ path_item = dict(_mapping(raw_path))
82
+ for method in _METHODS:
83
+ if method not in path_item:
84
+ continue
85
+ operation = dict(_mapping(path_item[method]))
86
+ body = dict(_resolve(document, operation.get("requestBody"), "requestBodies"))
87
+ content = dict(_mapping(body.get("content")))
88
+ part = dict(_mapping(content.get("multipart/form-data")))
89
+ schema = _mapping(part.get("schema"))
90
+ if schema.get("type") != "object" or not schema.get("properties"):
91
+ continue
92
+ while f"MultipartBody{counter}" in schemas:
93
+ counter += 1
94
+ name = f"MultipartBody{counter}"
95
+ counter += 1
96
+ schemas[name] = schema
97
+ part["schema"] = {"$ref": f"#/components/schemas/{name}"}
98
+ content["multipart/form-data"] = part
99
+ body["content"] = content
100
+ operation["requestBody"] = body
101
+ path_item[method] = operation
102
+ paths[path] = path_item
103
+ components["schemas"] = schemas
104
+ return {**document, "paths": paths, "components": components}
105
+
106
+
107
+ def read_operations(document: Schema) -> list[Operation]:
108
+ operations: list[Operation] = []
109
+ for path, item in _mapping(document.get("paths")).items():
110
+ path_item = _mapping(item)
111
+ for method, raw in path_item.items():
112
+ if method not in _METHODS:
113
+ continue
114
+ operation = _mapping(raw)
115
+ operation_id = str(operation.get("operationId") or _fallback_id(method, path))
116
+ parameters: dict[tuple[Location, str], Parameter] = {}
117
+ for raw_parameter in [
118
+ *_sequence(path_item.get("parameters")),
119
+ *_sequence(operation.get("parameters")),
120
+ ]:
121
+ parameter = _resolve(document, raw_parameter, "parameters")
122
+ location = _location(parameter.get("in"))
123
+ name = str(parameter["name"])
124
+ if location == "cookie":
125
+ raise ValueError(
126
+ f"{operation_id}: cookie parameter {name!r} is unsupported; "
127
+ "configure session credentials on the HTTP client"
128
+ )
129
+ expected_style = "form" if location == "query" else "simple"
130
+ if parameter.get("style", expected_style) != expected_style:
131
+ raise ValueError(f"{operation_id}: unsupported parameter style for {name!r}")
132
+ if location == "query" and parameter.get("explode", True) is not True:
133
+ raise ValueError(
134
+ f"{operation_id}: query explode=False is unsupported for {name!r}"
135
+ )
136
+ if parameter.get("allowReserved"):
137
+ raise ValueError(f"{operation_id}: allowReserved is unsupported for {name!r}")
138
+ schema = _mapping(parameter.get("schema"))
139
+ if parameter.get("content") or schema.get("type") == "object":
140
+ raise ValueError(
141
+ f"{operation_id}: structured parameter {name!r} is unsupported"
142
+ )
143
+ parameters[location, name] = Parameter(
144
+ name=name,
145
+ location=location,
146
+ required=bool(parameter.get("required", False)),
147
+ schema=schema,
148
+ )
149
+ body = _resolve(document, operation.get("requestBody"), "requestBodies")
150
+ body_content = _mapping(body.get("content"))
151
+ body_schema = _json_schema(body_content) if body else None
152
+ body_media_type = "application/json"
153
+ if body and body_schema is None and "multipart/form-data" in body_content:
154
+ part = _mapping(body_content["multipart/form-data"])
155
+ if part.get("encoding"):
156
+ raise ValueError(f"{operation_id}: custom multipart encoding is unsupported")
157
+ body_schema = _mapping(part.get("schema"))
158
+ resolved = _resolve(document, body_schema, "schemas")
159
+ if resolved.get("type") != "object":
160
+ raise ValueError(f"{operation_id}: multipart body must be an object")
161
+ for field_name, value in _mapping(resolved.get("properties")).items():
162
+ field = _mapping(value)
163
+ if field.get("type") not in {"string", "integer", "number", "boolean"}:
164
+ raise ValueError(
165
+ f"{operation_id}: unsupported multipart field {field_name!r}"
166
+ )
167
+ required = set(as_strings(resolved.get("required")))
168
+ if not any(
169
+ name in required and _mapping(value).get("format") == "binary"
170
+ for name, value in _mapping(resolved.get("properties")).items()
171
+ ):
172
+ raise ValueError(
173
+ f"{operation_id}: unsupported multipart body without a required file"
174
+ )
175
+ body_media_type = "multipart/form-data"
176
+ if body and body_schema is None:
177
+ raise ValueError(
178
+ f"{operation_id}: unsupported request media types {sorted(body_content)}; "
179
+ "JSON or simple multipart/form-data is required"
180
+ )
181
+ responses = {
182
+ code: _resolve(document, value, "responses")
183
+ for code, value in _mapping(operation.get("responses")).items()
184
+ }
185
+ response, has_body, variants = _success_schema(responses, operation_id)
186
+ operations.append(
187
+ Operation(
188
+ operation_id=operation_id,
189
+ method=method.upper(),
190
+ path=path,
191
+ summary=str(operation.get("summary", "")),
192
+ parameters=tuple(parameters.values()),
193
+ body=body_schema,
194
+ body_required=bool(body.get("required", False)),
195
+ response=response,
196
+ has_body=has_body,
197
+ response_variants=variants,
198
+ body_media_type=body_media_type,
199
+ )
200
+ )
201
+ return operations
202
+
203
+
204
+ def _resolve(document: Schema, value: object, section: str) -> Schema:
205
+ mapping = _mapping(value)
206
+ seen: set[str] = set()
207
+ while "$ref" in mapping:
208
+ reference = str(mapping["$ref"])
209
+ prefix = f"#/components/{section}/"
210
+ if not reference.startswith(prefix) or reference in seen:
211
+ raise ValueError(f"Unsupported or circular {section} reference: {reference!r}")
212
+ seen.add(reference)
213
+ name = reference.removeprefix(prefix).replace("~1", "/").replace("~0", "~")
214
+ entries = _mapping(_mapping(document.get("components")).get(section))
215
+ if name not in entries:
216
+ raise ValueError(f"Unknown {section} reference: {reference!r}")
217
+ mapping = _mapping(entries[name])
218
+ return mapping
219
+
220
+
221
+ def _success_schema(
222
+ responses: Schema, operation_id: str
223
+ ) -> tuple[Schema | None, bool, tuple[ResponseVariant, ...]]:
224
+ schemas: list[Schema] = []
225
+ variants: list[ResponseVariant] = []
226
+ has_body = False
227
+ for code, raw in sorted(responses.items()):
228
+ if not code.startswith("2"):
229
+ continue
230
+ content = _mapping(_mapping(raw).get("content"))
231
+ if code == "204":
232
+ empty = {"type": "null", "x-empty-response": True}
233
+ schemas.append(empty)
234
+ variants.append(ResponseVariant(code, None, empty))
235
+ continue
236
+ has_body = True
237
+ if not content:
238
+ # Ninja uses a description-only 200 for an unannotated response. Its
239
+ # body is unknown, not necessarily empty.
240
+ schemas.append({})
241
+ variants.append(ResponseVariant(code, None, {}))
242
+ continue
243
+ for media_type, representation in content.items():
244
+ media_type = media_type.split(";", 1)[0].lower()
245
+ if media_type == "text/event-stream":
246
+ raise ValueError(f"{operation_id}: streaming responses are unsupported")
247
+ if _is_json(media_type):
248
+ schema = _mapping(_mapping(representation).get("schema"))
249
+ elif media_type.startswith("text/"):
250
+ schema = {"type": "string"}
251
+ else:
252
+ schema = {"type": "string", "x-binary-response": True}
253
+ variants.append(ResponseVariant(code, media_type, schema))
254
+ if schema not in schemas:
255
+ schemas.append(schema)
256
+ if not has_body:
257
+ return None, False, tuple(variants)
258
+ return (schemas[0] if len(schemas) == 1 else {"anyOf": schemas}), True, tuple(variants)
259
+
260
+
261
+ def _is_json(media_type: str) -> bool:
262
+ media_type = media_type.split(";", 1)[0].lower()
263
+ return media_type == "application/json" or media_type.endswith("+json")
264
+
265
+
266
+ def _json_schema(content: Schema) -> Schema | None:
267
+ for media_type, value in content.items():
268
+ if _is_json(media_type):
269
+ return _mapping(_mapping(value).get("schema"))
270
+ return None
271
+
272
+
273
+ def _location(value: object) -> Location:
274
+ if value in ("path", "query", "header", "cookie"):
275
+ return value
276
+ raise ValueError(f"Unsupported parameter location {value!r}")
277
+
278
+
279
+ def _fallback_id(method: str, path: str) -> str:
280
+ return f"{method}_{re.sub(r'[^0-9a-zA-Z]+', '_', path).strip('_')}"
281
+
282
+
283
+ def as_mapping(value: object) -> Schema:
284
+ """``value`` as a string-keyed mapping, or an empty one."""
285
+ if not isinstance(value, Mapping):
286
+ return {}
287
+ items = cast("Mapping[object, object]", value).items()
288
+ return {str(key): item for key, item in items}
289
+
290
+
291
+ def as_list(value: object) -> list[object]:
292
+ return list(cast("Sequence[object]", value)) if isinstance(value, list | tuple) else []
293
+
294
+
295
+ def as_strings(value: object) -> list[str]:
296
+ return [str(item) for item in as_list(value)]
297
+
298
+
299
+ _mapping = as_mapping
300
+ _sequence = as_list