qstd-openapi 0.1.0__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.
- qstd_openapi/__init__.py +56 -0
- qstd_openapi/__main__.py +103 -0
- qstd_openapi/_compat.py +16 -0
- qstd_openapi/contrib/__init__.py +1 -0
- qstd_openapi/contrib/app_errors.py +173 -0
- qstd_openapi/core/__init__.py +56 -0
- qstd_openapi/core/document.py +1029 -0
- qstd_openapi/core/documents.py +296 -0
- qstd_openapi/core/error_responses.py +98 -0
- qstd_openapi/core/filters.py +81 -0
- qstd_openapi/core/schemas.py +554 -0
- qstd_openapi/core/sources.py +107 -0
- qstd_openapi/dialects/__init__.py +19 -0
- qstd_openapi/dialects/base.py +47 -0
- qstd_openapi/dialects/openapi30.py +312 -0
- qstd_openapi/dialects/openapi31.py +82 -0
- qstd_openapi/errors.py +117 -0
- qstd_openapi/fastapi.py +441 -0
- qstd_openapi/markers.py +57 -0
- qstd_openapi/meta/__init__.py +55 -0
- qstd_openapi/meta/merge.py +271 -0
- qstd_openapi/meta/model.py +182 -0
- qstd_openapi/meta/storage.py +112 -0
- qstd_openapi/openapi.py +656 -0
- qstd_openapi/py.typed +0 -0
- qstd_openapi/pydantic.py +494 -0
- qstd_openapi/sanic.py +343 -0
- qstd_openapi/serialization.py +63 -0
- qstd_openapi/tags.py +58 -0
- qstd_openapi/testing.py +98 -0
- qstd_openapi/ui.py +109 -0
- qstd_openapi-0.1.0.dist-info/METADATA +324 -0
- qstd_openapi-0.1.0.dist-info/RECORD +36 -0
- qstd_openapi-0.1.0.dist-info/WHEEL +5 -0
- qstd_openapi-0.1.0.dist-info/licenses/LICENSE +21 -0
- qstd_openapi-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
"""Merging contributions into a single :class:`OperationMeta`.
|
|
2
|
+
|
|
3
|
+
Collections accumulate (duplicates are dropped, first occurrence wins the
|
|
4
|
+
position). Single-valued fields go through a :class:`ScalarMergeStrategy`.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections.abc import Hashable, Iterable, Mapping, Sequence
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from types import MappingProxyType
|
|
12
|
+
from typing import Any, Literal, Protocol, TypeVar, cast
|
|
13
|
+
|
|
14
|
+
from qstd_openapi.errors import ScalarConflictError
|
|
15
|
+
from qstd_openapi.meta.model import (
|
|
16
|
+
BodyPart,
|
|
17
|
+
Content,
|
|
18
|
+
Contribution,
|
|
19
|
+
Example,
|
|
20
|
+
OperationMeta,
|
|
21
|
+
Origin,
|
|
22
|
+
Parameter,
|
|
23
|
+
ParameterModel,
|
|
24
|
+
Response,
|
|
25
|
+
ResponseHeader,
|
|
26
|
+
ResponsePart,
|
|
27
|
+
StatusCode,
|
|
28
|
+
)
|
|
29
|
+
from qstd_openapi.meta.storage import read_contributions
|
|
30
|
+
|
|
31
|
+
T = TypeVar('T')
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class Located:
|
|
36
|
+
value: Any
|
|
37
|
+
origin: Origin
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ScalarMergeStrategy(Protocol):
|
|
41
|
+
"""Internal: how a single-valued field is chosen.
|
|
42
|
+
|
|
43
|
+
Not part of the public API before 1.0; ``scalar_conflicts`` takes the
|
|
44
|
+
names in :data:`SCALAR_STRATEGIES`.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
def resolve(self, field: str, values: Sequence[Located]) -> Any:
|
|
48
|
+
"""Pick the value of ``field``; ``values`` are in application order."""
|
|
49
|
+
...
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class ErrorOnConflict:
|
|
53
|
+
"""Different values for the same field are an error."""
|
|
54
|
+
|
|
55
|
+
def resolve(self, field: str, values: Sequence[Located]) -> Any:
|
|
56
|
+
distinct = _unique(item.value for item in values)
|
|
57
|
+
if len(distinct) > 1:
|
|
58
|
+
raise ScalarConflictError(
|
|
59
|
+
field,
|
|
60
|
+
[(item.value, item.origin) for item in values],
|
|
61
|
+
)
|
|
62
|
+
return values[-1].value
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class LastWins:
|
|
66
|
+
"""The value applied last (the outermost one) wins."""
|
|
67
|
+
|
|
68
|
+
def resolve(self, field: str, values: Sequence[Located]) -> Any: # noqa: ARG002
|
|
69
|
+
return values[-1].value
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
SCALAR_STRATEGIES: Mapping[str, ScalarMergeStrategy] = MappingProxyType(
|
|
73
|
+
{'error': ErrorOnConflict(), 'last_wins': LastWins()},
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
ScalarConflicts = Literal['error', 'last_wins']
|
|
77
|
+
"""``'error'``: different values for one field fail the build; ``'last_wins'``:
|
|
78
|
+
the outermost decorator wins."""
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def get_scalar_strategy(value: ScalarConflicts) -> ScalarMergeStrategy:
|
|
82
|
+
raw = cast(object, value) # callers may pass anything at runtime
|
|
83
|
+
strategy = SCALAR_STRATEGIES.get(raw) if isinstance(raw, str) else None
|
|
84
|
+
if strategy is None:
|
|
85
|
+
known = ', '.join(repr(name) for name in SCALAR_STRATEGIES)
|
|
86
|
+
raise ValueError(
|
|
87
|
+
f'Unknown scalar_conflicts {value!r}; expected one of {known}',
|
|
88
|
+
)
|
|
89
|
+
return strategy
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _unique(items: Iterable[T]) -> list[T]:
|
|
93
|
+
"""Order-preserving de-duplication by equality (values may be unhashable)."""
|
|
94
|
+
result: list[T] = []
|
|
95
|
+
for item in items:
|
|
96
|
+
if item not in result:
|
|
97
|
+
result.append(item)
|
|
98
|
+
return result
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _normalize_status(status: StatusCode) -> StatusCode:
|
|
102
|
+
if isinstance(status, str) and status.isdigit():
|
|
103
|
+
return int(status)
|
|
104
|
+
return status
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class _Merger:
|
|
108
|
+
def __init__(self, strategy: ScalarMergeStrategy) -> None:
|
|
109
|
+
self._strategy = strategy
|
|
110
|
+
|
|
111
|
+
def scalar(self, field: str, values: list[Located]) -> Any:
|
|
112
|
+
if not values:
|
|
113
|
+
return None
|
|
114
|
+
return self._strategy.resolve(field, values)
|
|
115
|
+
|
|
116
|
+
def keyed(
|
|
117
|
+
self,
|
|
118
|
+
field: str,
|
|
119
|
+
items: Iterable[tuple[Hashable, Any, Origin]],
|
|
120
|
+
) -> list[Any]:
|
|
121
|
+
"""One value per key, conflicts per key go through the strategy."""
|
|
122
|
+
grouped: dict[Hashable, list[Located]] = {}
|
|
123
|
+
for key, value, origin in items:
|
|
124
|
+
grouped.setdefault(key, []).append(Located(value, origin))
|
|
125
|
+
return [
|
|
126
|
+
self.scalar(f'{field}[{key!r}]', values) for key, values in grouped.items()
|
|
127
|
+
]
|
|
128
|
+
|
|
129
|
+
def body(
|
|
130
|
+
self,
|
|
131
|
+
parts: Iterable[tuple[BodyPart, Origin]],
|
|
132
|
+
field: str = 'body',
|
|
133
|
+
) -> tuple[Content, ...]:
|
|
134
|
+
grouped: dict[str, list[Any]] = {}
|
|
135
|
+
examples: dict[str, list[tuple[str, Example, Origin]]] = {}
|
|
136
|
+
for part, origin in parts:
|
|
137
|
+
schemas = grouped.setdefault(part.media_type, [])
|
|
138
|
+
if part.schema is not None and part.schema not in schemas:
|
|
139
|
+
schemas.append(part.schema)
|
|
140
|
+
examples.setdefault(part.media_type, []).extend(
|
|
141
|
+
(name, example, origin) for name, example in part.examples
|
|
142
|
+
)
|
|
143
|
+
return tuple(
|
|
144
|
+
Content(
|
|
145
|
+
media,
|
|
146
|
+
tuple(schemas),
|
|
147
|
+
tuple(
|
|
148
|
+
zip(
|
|
149
|
+
_unique(name for name, _, _ in examples[media]),
|
|
150
|
+
self.keyed(
|
|
151
|
+
f'{field}[{media!r}].examples',
|
|
152
|
+
examples[media],
|
|
153
|
+
),
|
|
154
|
+
),
|
|
155
|
+
),
|
|
156
|
+
)
|
|
157
|
+
for media, schemas in grouped.items()
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
def responses(
|
|
161
|
+
self,
|
|
162
|
+
parts: Iterable[tuple[ResponsePart, Origin]],
|
|
163
|
+
) -> tuple[Response, ...]:
|
|
164
|
+
by_status: dict[StatusCode, list[tuple[ResponsePart, Origin]]] = {}
|
|
165
|
+
for part, origin in parts:
|
|
166
|
+
by_status.setdefault(_normalize_status(part.status), []).append(
|
|
167
|
+
(part, origin),
|
|
168
|
+
)
|
|
169
|
+
responses: list[Response] = []
|
|
170
|
+
for status, items in by_status.items():
|
|
171
|
+
description = self.scalar(
|
|
172
|
+
f'responses[{status!r}].description',
|
|
173
|
+
[
|
|
174
|
+
Located(part.description, origin)
|
|
175
|
+
for part, origin in items
|
|
176
|
+
if part.description is not None
|
|
177
|
+
],
|
|
178
|
+
)
|
|
179
|
+
content = self.body(
|
|
180
|
+
(
|
|
181
|
+
(BodyPart(part.media_type, part.schema, part.examples), origin)
|
|
182
|
+
for part, origin in items
|
|
183
|
+
if part.media_type is not None
|
|
184
|
+
and (part.schema is not None or part.examples)
|
|
185
|
+
),
|
|
186
|
+
f'responses[{status!r}]',
|
|
187
|
+
)
|
|
188
|
+
headers: list[ResponseHeader] = self.keyed(
|
|
189
|
+
f'responses[{status!r}].headers',
|
|
190
|
+
(
|
|
191
|
+
(header.name.lower(), header, origin)
|
|
192
|
+
for part, origin in items
|
|
193
|
+
for header in part.headers
|
|
194
|
+
),
|
|
195
|
+
)
|
|
196
|
+
responses.append(Response(status, description, content, tuple(headers)))
|
|
197
|
+
return tuple(responses)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
_SCALARS = (
|
|
201
|
+
'summary',
|
|
202
|
+
'description',
|
|
203
|
+
'operation_id',
|
|
204
|
+
'deprecated',
|
|
205
|
+
'exclude',
|
|
206
|
+
'webhook',
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def merge_contributions(
|
|
211
|
+
contributions: Iterable[Contribution],
|
|
212
|
+
scalar_conflicts: ScalarConflicts = 'error',
|
|
213
|
+
) -> OperationMeta:
|
|
214
|
+
merger = _Merger(get_scalar_strategy(scalar_conflicts))
|
|
215
|
+
items = list(contributions)
|
|
216
|
+
|
|
217
|
+
scalars: dict[str, Any] = {}
|
|
218
|
+
for name in _SCALARS:
|
|
219
|
+
located = [
|
|
220
|
+
Located(getattr(item.patch, name), item.origin)
|
|
221
|
+
for item in items
|
|
222
|
+
if getattr(item.patch, name) is not None
|
|
223
|
+
]
|
|
224
|
+
scalars[name] = merger.scalar(name, located)
|
|
225
|
+
|
|
226
|
+
explicit: list[tuple[Hashable, Parameter, Origin]] = []
|
|
227
|
+
models: list[ParameterModel] = []
|
|
228
|
+
for item in items:
|
|
229
|
+
for parameter in item.patch.parameters:
|
|
230
|
+
if isinstance(parameter, Parameter):
|
|
231
|
+
key = (parameter.location, _parameter_key(parameter))
|
|
232
|
+
explicit.append((key, parameter, item.origin))
|
|
233
|
+
elif parameter not in models:
|
|
234
|
+
models.append(parameter)
|
|
235
|
+
|
|
236
|
+
return OperationMeta(
|
|
237
|
+
summary=scalars['summary'],
|
|
238
|
+
description=scalars['description'],
|
|
239
|
+
operation_id=scalars['operation_id'],
|
|
240
|
+
deprecated=bool(scalars['deprecated']),
|
|
241
|
+
exclude=bool(scalars['exclude']),
|
|
242
|
+
webhook=scalars['webhook'],
|
|
243
|
+
tags=tuple(_unique(tag for item in items for tag in item.patch.tags)),
|
|
244
|
+
scopes=tuple(_unique(scope for item in items for scope in item.patch.scopes)),
|
|
245
|
+
security=tuple(
|
|
246
|
+
_unique(req for item in items for req in item.patch.security),
|
|
247
|
+
),
|
|
248
|
+
parameters=tuple(merger.keyed('parameters', explicit)),
|
|
249
|
+
parameter_models=tuple(models),
|
|
250
|
+
body=merger.body(
|
|
251
|
+
(part, item.origin) for item in items for part in item.patch.body
|
|
252
|
+
),
|
|
253
|
+
responses=merger.responses(
|
|
254
|
+
(part, item.origin) for item in items for part in item.patch.responses
|
|
255
|
+
),
|
|
256
|
+
errors=tuple(_unique(err for item in items for err in item.patch.errors)),
|
|
257
|
+
extra=tuple(extra for item in items for extra in item.patch.extra),
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _parameter_key(parameter: Parameter) -> str:
|
|
262
|
+
# Header names are case-insensitive.
|
|
263
|
+
return parameter.name.lower() if parameter.location == 'header' else parameter.name
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def read_operation(
|
|
267
|
+
obj: object,
|
|
268
|
+
scalar_conflicts: ScalarConflicts = 'error',
|
|
269
|
+
) -> OperationMeta:
|
|
270
|
+
"""Merged description of a handler, following its ``__wrapped__`` chain."""
|
|
271
|
+
return merge_contributions(read_contributions(obj), scalar_conflicts)
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""Version-neutral description of an operation.
|
|
2
|
+
|
|
3
|
+
Nothing here is OpenAPI-version specific: schemas are kept as references
|
|
4
|
+
(model classes, Python types, raw JSON Schema dicts, markers from
|
|
5
|
+
:mod:`qstd_openapi.markers`) and are resolved only when a document is built.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Hashable, Mapping
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from typing import Any, Literal, Optional, Union
|
|
13
|
+
|
|
14
|
+
ParameterLocation = Literal['query', 'path', 'header', 'cookie']
|
|
15
|
+
StatusCode = Union[int, str]
|
|
16
|
+
"""HTTP status code, or an OpenAPI range/default key such as ``'4XX'``."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class Example:
|
|
21
|
+
"""A named example of a body, a response or a parameter (OpenAPI Example Object)."""
|
|
22
|
+
|
|
23
|
+
value: Any
|
|
24
|
+
summary: Optional[str] = None
|
|
25
|
+
description: Optional[str] = None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
Examples = tuple[tuple[str, Example], ...]
|
|
29
|
+
"""Named examples in the order they were given."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class Parameter:
|
|
34
|
+
"""A single named parameter."""
|
|
35
|
+
|
|
36
|
+
location: ParameterLocation
|
|
37
|
+
name: str
|
|
38
|
+
schema: Any = str
|
|
39
|
+
required: Optional[bool] = None
|
|
40
|
+
"""``None`` means the default: required for ``path``, optional otherwise."""
|
|
41
|
+
description: Optional[str] = None
|
|
42
|
+
deprecated: bool = False
|
|
43
|
+
examples: Examples = ()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True)
|
|
47
|
+
class ParameterModel:
|
|
48
|
+
"""A model whose fields are expanded into parameters of one location."""
|
|
49
|
+
|
|
50
|
+
location: ParameterLocation
|
|
51
|
+
model: Any
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True)
|
|
55
|
+
class BodyPart:
|
|
56
|
+
media_type: str
|
|
57
|
+
schema: Any
|
|
58
|
+
"""``None`` adds only ``examples`` to the media type."""
|
|
59
|
+
examples: Examples = ()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True)
|
|
63
|
+
class ResponseHeader:
|
|
64
|
+
name: str
|
|
65
|
+
schema: Any = str
|
|
66
|
+
description: Optional[str] = None
|
|
67
|
+
required: bool = False
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass(frozen=True)
|
|
71
|
+
class ResponsePart:
|
|
72
|
+
status: StatusCode
|
|
73
|
+
media_type: Optional[str] = None
|
|
74
|
+
schema: Any = None
|
|
75
|
+
"""``None`` means a response without a body."""
|
|
76
|
+
description: Optional[str] = None
|
|
77
|
+
headers: tuple[ResponseHeader, ...] = ()
|
|
78
|
+
examples: Examples = ()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass(frozen=True)
|
|
82
|
+
class ErrorRef:
|
|
83
|
+
"""An error object resolved into a response by an error provider."""
|
|
84
|
+
|
|
85
|
+
error: Any
|
|
86
|
+
media_type: str = 'application/json'
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass(frozen=True)
|
|
90
|
+
class Security:
|
|
91
|
+
"""One security requirement object: all schemes listed are required together.
|
|
92
|
+
|
|
93
|
+
Several :class:`Security` values on an operation are alternatives.
|
|
94
|
+
An empty requirement means the operation may also be called anonymously.
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
schemes: tuple[tuple[str, tuple[str, ...]], ...] = ()
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@dataclass(frozen=True)
|
|
101
|
+
class Webhook:
|
|
102
|
+
name: str
|
|
103
|
+
method: str = 'post'
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@dataclass(frozen=True)
|
|
107
|
+
class OperationPatch:
|
|
108
|
+
"""What a single ``attach``/decorator call contributes.
|
|
109
|
+
|
|
110
|
+
``None`` scalars and empty tuples mean "not set by this call".
|
|
111
|
+
"""
|
|
112
|
+
|
|
113
|
+
summary: Optional[str] = None
|
|
114
|
+
description: Optional[str] = None
|
|
115
|
+
operation_id: Optional[str] = None
|
|
116
|
+
deprecated: Optional[bool] = None
|
|
117
|
+
exclude: Optional[bool] = None
|
|
118
|
+
webhook: Optional[Webhook] = None
|
|
119
|
+
tags: tuple[str, ...] = ()
|
|
120
|
+
scopes: tuple[Hashable, ...] = ()
|
|
121
|
+
security: tuple[Security, ...] = ()
|
|
122
|
+
parameters: tuple[Union[Parameter, ParameterModel], ...] = ()
|
|
123
|
+
body: tuple[BodyPart, ...] = ()
|
|
124
|
+
responses: tuple[ResponsePart, ...] = ()
|
|
125
|
+
errors: tuple[ErrorRef, ...] = ()
|
|
126
|
+
extra: tuple[Mapping[str, Any], ...] = ()
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@dataclass(frozen=True)
|
|
130
|
+
class Origin:
|
|
131
|
+
"""Where a contribution came from, for diagnostics."""
|
|
132
|
+
|
|
133
|
+
owner: str
|
|
134
|
+
api: str
|
|
135
|
+
index: int
|
|
136
|
+
|
|
137
|
+
def __str__(self) -> str:
|
|
138
|
+
return f'{self.owner} ({self.api} #{self.index})'
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@dataclass(frozen=True)
|
|
142
|
+
class Contribution:
|
|
143
|
+
patch: OperationPatch
|
|
144
|
+
origin: Origin
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@dataclass(frozen=True)
|
|
148
|
+
class Content:
|
|
149
|
+
"""Schemas accepted for one media type; several schemas mean ``oneOf``."""
|
|
150
|
+
|
|
151
|
+
media_type: str
|
|
152
|
+
schemas: tuple[Any, ...]
|
|
153
|
+
examples: Examples = ()
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@dataclass(frozen=True)
|
|
157
|
+
class Response:
|
|
158
|
+
status: StatusCode
|
|
159
|
+
description: Optional[str] = None
|
|
160
|
+
content: tuple[Content, ...] = ()
|
|
161
|
+
headers: tuple[ResponseHeader, ...] = ()
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
@dataclass(frozen=True)
|
|
165
|
+
class OperationMeta:
|
|
166
|
+
"""Merged description of one handler, still version-neutral."""
|
|
167
|
+
|
|
168
|
+
summary: Optional[str] = None
|
|
169
|
+
description: Optional[str] = None
|
|
170
|
+
operation_id: Optional[str] = None
|
|
171
|
+
deprecated: bool = False
|
|
172
|
+
exclude: bool = False
|
|
173
|
+
webhook: Optional[Webhook] = None
|
|
174
|
+
tags: tuple[str, ...] = ()
|
|
175
|
+
scopes: tuple[Hashable, ...] = ()
|
|
176
|
+
security: tuple[Security, ...] = ()
|
|
177
|
+
parameters: tuple[Parameter, ...] = ()
|
|
178
|
+
parameter_models: tuple[ParameterModel, ...] = ()
|
|
179
|
+
body: tuple[Content, ...] = ()
|
|
180
|
+
responses: tuple[Response, ...] = ()
|
|
181
|
+
errors: tuple[ErrorRef, ...] = ()
|
|
182
|
+
extra: tuple[Mapping[str, Any], ...] = field(default=())
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Storing contributions on the described objects themselves.
|
|
2
|
+
|
|
3
|
+
There is no registry: every function carries an immutable record of what was
|
|
4
|
+
attached to it. The record lives under an attribute name derived from the
|
|
5
|
+
owner's ``id``. ``functools.wraps`` copies the wrapped function's
|
|
6
|
+
``__dict__`` into the wrapper, so with a single shared attribute name the
|
|
7
|
+
copy would overwrite whatever had already been attached to the wrapper
|
|
8
|
+
(and the same record would be read twice while walking ``__wrapped__``).
|
|
9
|
+
With per-owner names the copy lands next to the wrapper's own record and is
|
|
10
|
+
ignored when the wrapper is read.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import functools
|
|
16
|
+
import inspect
|
|
17
|
+
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from typing import Any, cast
|
|
20
|
+
|
|
21
|
+
from qstd_openapi.errors import AttachError
|
|
22
|
+
from qstd_openapi.meta.model import Contribution, OperationPatch, Origin
|
|
23
|
+
|
|
24
|
+
_ATTR_PREFIX = '__qstd_openapi_'
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class _Record:
|
|
29
|
+
owner: object
|
|
30
|
+
entries: tuple[tuple[OperationPatch, str], ...]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _attr_name(holder: object) -> str:
|
|
34
|
+
return f'{_ATTR_PREFIX}{id(holder):x}__'
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _holder(obj: object) -> object:
|
|
38
|
+
"""The object that actually stores metadata for ``obj``."""
|
|
39
|
+
if isinstance(obj, (staticmethod, classmethod)) or inspect.ismethod(obj):
|
|
40
|
+
return cast('object', obj.__func__) # pyright: ignore[reportUnknownMemberType]
|
|
41
|
+
return obj
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def describe_owner(obj: object) -> str:
|
|
45
|
+
module = getattr(obj, '__module__', None)
|
|
46
|
+
qualname = getattr(obj, '__qualname__', None) or type(obj).__qualname__
|
|
47
|
+
return f'{module}.{qualname}' if module else qualname
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def attach_patch(obj: object, patch: OperationPatch, api: str) -> None:
|
|
51
|
+
holder = _holder(obj)
|
|
52
|
+
if not hasattr(holder, '__dict__'):
|
|
53
|
+
raise AttachError(
|
|
54
|
+
f'Cannot attach OpenAPI metadata to {obj!r}: '
|
|
55
|
+
f'{type(holder).__qualname__} objects have no __dict__. '
|
|
56
|
+
'Wrap it in a regular function and describe that function.',
|
|
57
|
+
)
|
|
58
|
+
name = _attr_name(holder)
|
|
59
|
+
record = vars(holder).get(name)
|
|
60
|
+
if not isinstance(record, _Record) or record.owner is not holder:
|
|
61
|
+
record = _Record(owner=holder, entries=())
|
|
62
|
+
try:
|
|
63
|
+
setattr(holder, name, _Record(holder, (*record.entries, (patch, api))))
|
|
64
|
+
except (AttributeError, TypeError) as exc:
|
|
65
|
+
raise AttachError(
|
|
66
|
+
f'Cannot attach OpenAPI metadata to {obj!r}: {exc}',
|
|
67
|
+
) from exc
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _own_record(holder: object) -> _Record | None:
|
|
71
|
+
try:
|
|
72
|
+
record = vars(holder).get(_attr_name(holder))
|
|
73
|
+
except TypeError:
|
|
74
|
+
return None
|
|
75
|
+
if isinstance(record, _Record) and record.owner is holder:
|
|
76
|
+
return record
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _wrap_chain(obj: object) -> list[object]:
|
|
81
|
+
"""Holders from the outermost object down to the innermost wrapped one."""
|
|
82
|
+
chain: list[object] = []
|
|
83
|
+
seen: set[int] = set()
|
|
84
|
+
current: Any = obj
|
|
85
|
+
while current is not None:
|
|
86
|
+
holder = _holder(current)
|
|
87
|
+
if id(holder) in seen:
|
|
88
|
+
break
|
|
89
|
+
seen.add(id(holder))
|
|
90
|
+
chain.append(holder)
|
|
91
|
+
nxt = getattr(holder, '__wrapped__', None)
|
|
92
|
+
if nxt is None and isinstance(holder, functools.partial):
|
|
93
|
+
nxt = holder.func
|
|
94
|
+
current = nxt
|
|
95
|
+
return chain
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def read_contributions(obj: object) -> tuple[Contribution, ...]:
|
|
99
|
+
"""All contributions for ``obj`` in application order.
|
|
100
|
+
|
|
101
|
+
The innermost wrapped function comes first and the outermost wrapper
|
|
102
|
+
last; within one object entries keep the order they were attached in.
|
|
103
|
+
"""
|
|
104
|
+
contributions: list[Contribution] = []
|
|
105
|
+
for holder in reversed(_wrap_chain(obj)):
|
|
106
|
+
record = _own_record(holder)
|
|
107
|
+
if record is None:
|
|
108
|
+
continue
|
|
109
|
+
owner = describe_owner(holder)
|
|
110
|
+
for index, (patch, api) in enumerate(record.entries):
|
|
111
|
+
contributions.append(Contribution(patch, Origin(owner, api, index)))
|
|
112
|
+
return tuple(contributions)
|