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.
@@ -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)