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,296 @@
|
|
|
1
|
+
"""Including ready OpenAPI documents (``aggregate`` mode).
|
|
2
|
+
|
|
3
|
+
``Document`` turns a raw document into a contribution: components are
|
|
4
|
+
renamed into a namespace, every local ``$ref`` is rewritten, paths get a
|
|
5
|
+
prefix and the document's root ``security`` moves into its operations.
|
|
6
|
+
The builder then merges contributions in include order; collisions go
|
|
7
|
+
through the inclusion's conflict policy.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import copy
|
|
13
|
+
import importlib
|
|
14
|
+
import json
|
|
15
|
+
|
|
16
|
+
from collections.abc import Mapping
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any, Callable, Literal, Optional, Protocol, Union, cast
|
|
20
|
+
|
|
21
|
+
from qstd_openapi.core.sources import HTTP_METHODS
|
|
22
|
+
from qstd_openapi.errors import UnsupportedDocumentError
|
|
23
|
+
|
|
24
|
+
JsonObject = dict[str, Any]
|
|
25
|
+
ConflictAction = Literal['error', 'keep', 'replace']
|
|
26
|
+
|
|
27
|
+
_IGNORED_ROOT = ('info', 'servers', 'externalDocs', 'jsonSchemaDialect')
|
|
28
|
+
_HANDLED_ROOT = ('openapi', 'paths', 'components', 'tags', 'security', *_IGNORED_ROOT)
|
|
29
|
+
_UNNAMESPACED_SECTIONS = frozenset({'securitySchemes'})
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class Conflict:
|
|
34
|
+
"""Two sources define the same thing differently."""
|
|
35
|
+
|
|
36
|
+
kind: str
|
|
37
|
+
"""``operation``, ``webhook``, ``path-item``, ``component``, ``tag`` or ``root``."""
|
|
38
|
+
key: str
|
|
39
|
+
existing: Any
|
|
40
|
+
incoming: Any
|
|
41
|
+
existing_origin: str
|
|
42
|
+
incoming_origin: str
|
|
43
|
+
|
|
44
|
+
def describe(self) -> str:
|
|
45
|
+
return (
|
|
46
|
+
f'{self.kind} {self.key} is defined by both {self.existing_origin} '
|
|
47
|
+
f'and {self.incoming_origin}'
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class ConflictPolicy(Protocol):
|
|
52
|
+
"""Decides each conflict of an included document.
|
|
53
|
+
|
|
54
|
+
**Provisional:** the signature may change before 1.0; the string policies
|
|
55
|
+
(``'error'``, ``'keep'``, ``'replace'``) are stable.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
def __call__(self, conflict: Conflict) -> ConflictAction:
|
|
59
|
+
"""``'keep'`` the existing value, ``'replace'`` it or ``'error'``."""
|
|
60
|
+
...
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass
|
|
64
|
+
class DocumentContribution:
|
|
65
|
+
origin: str
|
|
66
|
+
policy: Union[ConflictAction, ConflictPolicy]
|
|
67
|
+
paths: JsonObject = field(default_factory=lambda: JsonObject())
|
|
68
|
+
webhooks: JsonObject = field(default_factory=lambda: JsonObject())
|
|
69
|
+
components: dict[str, JsonObject] = field(
|
|
70
|
+
default_factory=lambda: dict[str, JsonObject](),
|
|
71
|
+
)
|
|
72
|
+
tags: list[JsonObject] = field(default_factory=lambda: list[JsonObject]())
|
|
73
|
+
extensions: JsonObject = field(default_factory=lambda: JsonObject())
|
|
74
|
+
notes: list[tuple[str, str, str]] = field(
|
|
75
|
+
default_factory=lambda: list[tuple[str, str, str]](),
|
|
76
|
+
)
|
|
77
|
+
"""``(code, level, message)`` diagnostics."""
|
|
78
|
+
|
|
79
|
+
def decide(self, conflict: Conflict) -> ConflictAction:
|
|
80
|
+
action = self.policy if isinstance(self.policy, str) else self.policy(conflict)
|
|
81
|
+
if action not in ('error', 'keep', 'replace'):
|
|
82
|
+
raise ValueError(f'Conflict policy returned {action!r}')
|
|
83
|
+
return action
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class DocumentChecker(Protocol):
|
|
87
|
+
"""The part of :class:`OpenAPIDialect` needed to read a foreign document."""
|
|
88
|
+
|
|
89
|
+
def check_document(self, document: Mapping[str, Any]) -> None: ...
|
|
90
|
+
|
|
91
|
+
def extract_webhooks(self, document: JsonObject) -> JsonObject: ...
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _decode(token: str) -> str:
|
|
95
|
+
return token.replace('~1', '/').replace('~0', '~')
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _encode(token: str) -> str:
|
|
99
|
+
return token.replace('~', '~0').replace('/', '~1')
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class Document:
|
|
103
|
+
"""An existing OpenAPI document included into another one.
|
|
104
|
+
|
|
105
|
+
``document`` is a mapping, or a callable returning one (loaded at build
|
|
106
|
+
time). Use :meth:`from_file` for JSON/YAML files. The input is never
|
|
107
|
+
modified.
|
|
108
|
+
|
|
109
|
+
- ``path_prefix`` is prepended to every path;
|
|
110
|
+
- ``component_namespace`` renames components to ``namespace_format``
|
|
111
|
+
(``'{namespace}.{name}'``: ``User`` → ``profiles.User``) in every section
|
|
112
|
+
except ``securitySchemes``, which are usually shared between services;
|
|
113
|
+
- ``on_conflict``: ``'error'`` (default), ``'keep'`` the earlier
|
|
114
|
+
definition, ``'replace'`` it, or a :class:`ConflictPolicy` (provisional).
|
|
115
|
+
|
|
116
|
+
The document's ``info``, ``servers``, ``externalDocs`` and
|
|
117
|
+
``jsonSchemaDialect`` are ignored (the including document has its own);
|
|
118
|
+
its root ``security`` is copied into operations without their own.
|
|
119
|
+
"""
|
|
120
|
+
|
|
121
|
+
def __init__(
|
|
122
|
+
self,
|
|
123
|
+
document: Union[Mapping[str, Any], Callable[[], Mapping[str, Any]]],
|
|
124
|
+
*,
|
|
125
|
+
origin: str,
|
|
126
|
+
path_prefix: str = '',
|
|
127
|
+
component_namespace: Optional[str] = None,
|
|
128
|
+
namespace_format: str = '{namespace}.{name}',
|
|
129
|
+
on_conflict: Union[ConflictAction, ConflictPolicy] = 'error',
|
|
130
|
+
) -> None:
|
|
131
|
+
if path_prefix and (
|
|
132
|
+
not path_prefix.startswith('/') or path_prefix.endswith('/')
|
|
133
|
+
):
|
|
134
|
+
raise ValueError(
|
|
135
|
+
f'path_prefix must start and not end with "/": {path_prefix!r}',
|
|
136
|
+
)
|
|
137
|
+
self.origin = origin
|
|
138
|
+
self.path_prefix = path_prefix
|
|
139
|
+
self.component_namespace = component_namespace
|
|
140
|
+
self.namespace_format = namespace_format
|
|
141
|
+
self.on_conflict: Union[ConflictAction, ConflictPolicy] = on_conflict
|
|
142
|
+
if callable(document):
|
|
143
|
+
self._load: Callable[[], Mapping[str, Any]] = document
|
|
144
|
+
else:
|
|
145
|
+
snapshot = copy.deepcopy(dict(document))
|
|
146
|
+
self._check_structure(snapshot)
|
|
147
|
+
self._load = lambda: snapshot
|
|
148
|
+
|
|
149
|
+
@classmethod
|
|
150
|
+
def from_file(cls, path: Union[str, Path], **options: Any) -> Document:
|
|
151
|
+
"""Load a ``.json`` or ``.yaml``/``.yml`` file (YAML needs PyYAML)."""
|
|
152
|
+
file = Path(path)
|
|
153
|
+
text = file.read_text(encoding='utf-8')
|
|
154
|
+
if file.suffix in ('.yaml', '.yml'):
|
|
155
|
+
yaml: Any = importlib.import_module('yaml')
|
|
156
|
+
loaded: Any = yaml.safe_load(text)
|
|
157
|
+
else:
|
|
158
|
+
loaded = json.loads(text)
|
|
159
|
+
options.setdefault('origin', str(file))
|
|
160
|
+
return cls(cast('Mapping[str, Any]', loaded), **options)
|
|
161
|
+
|
|
162
|
+
def collect(self) -> tuple[()]:
|
|
163
|
+
"""A document contributes no operations of its own (see :meth:`contribution`)."""
|
|
164
|
+
return ()
|
|
165
|
+
|
|
166
|
+
# --- contribution ---------------------------------------------------
|
|
167
|
+
|
|
168
|
+
def contribution(self, checker: DocumentChecker) -> DocumentContribution:
|
|
169
|
+
document = copy.deepcopy(dict(self._load()))
|
|
170
|
+
self._check_structure(document)
|
|
171
|
+
try:
|
|
172
|
+
checker.check_document(document)
|
|
173
|
+
except ValueError as exc:
|
|
174
|
+
raise UnsupportedDocumentError(f'{self.origin}: {exc}') from exc
|
|
175
|
+
|
|
176
|
+
result = DocumentContribution(self.origin, self.on_conflict)
|
|
177
|
+
renames = self._renames(
|
|
178
|
+
cast('Mapping[str, Any]', document.get('components', {})),
|
|
179
|
+
)
|
|
180
|
+
self._rewrite(document, renames, result)
|
|
181
|
+
root_security = document.get('security')
|
|
182
|
+
|
|
183
|
+
for path, item in cast(
|
|
184
|
+
'Mapping[str, JsonObject]',
|
|
185
|
+
document.get('paths', {}),
|
|
186
|
+
).items():
|
|
187
|
+
if root_security is not None:
|
|
188
|
+
self._push_security(item, root_security)
|
|
189
|
+
result.paths[self._prefixed(path)] = item
|
|
190
|
+
for name, item in checker.extract_webhooks(document).items():
|
|
191
|
+
if root_security is not None:
|
|
192
|
+
self._push_security(cast('JsonObject', item), root_security)
|
|
193
|
+
result.webhooks[name] = item
|
|
194
|
+
|
|
195
|
+
for section, entries in cast(
|
|
196
|
+
'Mapping[str, JsonObject]',
|
|
197
|
+
document.get('components', {}),
|
|
198
|
+
).items():
|
|
199
|
+
result.components[section] = {
|
|
200
|
+
renames.get((section, name), name): value
|
|
201
|
+
for name, value in entries.items()
|
|
202
|
+
}
|
|
203
|
+
result.tags = list(cast('list[JsonObject]', document.get('tags', [])))
|
|
204
|
+
for key, value in document.items():
|
|
205
|
+
if key in _HANDLED_ROOT:
|
|
206
|
+
continue
|
|
207
|
+
result.extensions[key] = value
|
|
208
|
+
for key in _IGNORED_ROOT:
|
|
209
|
+
if key in document:
|
|
210
|
+
result.notes.append(
|
|
211
|
+
(
|
|
212
|
+
'ignored-root-field',
|
|
213
|
+
'info',
|
|
214
|
+
f'{self.origin}: root {key!r} is not merged',
|
|
215
|
+
),
|
|
216
|
+
)
|
|
217
|
+
return result
|
|
218
|
+
|
|
219
|
+
@staticmethod
|
|
220
|
+
def _check_structure(document: Mapping[str, Any]) -> None:
|
|
221
|
+
if not isinstance(document.get('openapi'), str):
|
|
222
|
+
raise UnsupportedDocumentError(
|
|
223
|
+
'an OpenAPI document needs a string "openapi" field',
|
|
224
|
+
)
|
|
225
|
+
for key in ('paths', 'components'):
|
|
226
|
+
if key in document and not isinstance(document[key], Mapping):
|
|
227
|
+
raise UnsupportedDocumentError(f'"{key}" must be an object')
|
|
228
|
+
|
|
229
|
+
def _renames(self, components: Mapping[str, Any]) -> dict[tuple[str, str], str]:
|
|
230
|
+
if not self.component_namespace:
|
|
231
|
+
return {}
|
|
232
|
+
return {
|
|
233
|
+
(section, name): self.namespace_format.format(
|
|
234
|
+
namespace=self.component_namespace,
|
|
235
|
+
name=name,
|
|
236
|
+
)
|
|
237
|
+
for section, entries in components.items()
|
|
238
|
+
if section not in _UNNAMESPACED_SECTIONS
|
|
239
|
+
for name in cast('Mapping[str, Any]', entries)
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
def _prefixed(self, path: str) -> str:
|
|
243
|
+
return f'{self.path_prefix}{path}' if self.path_prefix else path
|
|
244
|
+
|
|
245
|
+
def _rewrite_ref(self, ref: str, renames: Mapping[tuple[str, str], str]) -> str:
|
|
246
|
+
tokens = [_decode(token) for token in ref[2:].split('/')]
|
|
247
|
+
if len(tokens) >= 3 and tokens[0] == 'components':
|
|
248
|
+
tokens[2] = renames.get((tokens[1], tokens[2]), tokens[2])
|
|
249
|
+
elif len(tokens) >= 2 and tokens[0] == 'paths':
|
|
250
|
+
tokens[1] = self._prefixed(tokens[1])
|
|
251
|
+
return '#/' + '/'.join(_encode(token) for token in tokens)
|
|
252
|
+
|
|
253
|
+
def _rewrite(
|
|
254
|
+
self,
|
|
255
|
+
value: Any,
|
|
256
|
+
renames: Mapping[tuple[str, str], str],
|
|
257
|
+
result: DocumentContribution,
|
|
258
|
+
) -> None:
|
|
259
|
+
if isinstance(value, list):
|
|
260
|
+
for item in cast('list[object]', value):
|
|
261
|
+
self._rewrite(item, renames, result)
|
|
262
|
+
return
|
|
263
|
+
if not isinstance(value, dict):
|
|
264
|
+
return
|
|
265
|
+
mapping = cast('JsonObject', value)
|
|
266
|
+
ref = mapping.get('$ref')
|
|
267
|
+
if isinstance(ref, str):
|
|
268
|
+
if ref.startswith('#/'):
|
|
269
|
+
mapping['$ref'] = self._rewrite_ref(ref, renames)
|
|
270
|
+
else:
|
|
271
|
+
result.notes.append(
|
|
272
|
+
(
|
|
273
|
+
'external-ref',
|
|
274
|
+
'warning',
|
|
275
|
+
f'{self.origin}: external $ref {ref!r} is kept as is',
|
|
276
|
+
),
|
|
277
|
+
)
|
|
278
|
+
discriminator = mapping.get('discriminator')
|
|
279
|
+
if isinstance(discriminator, dict):
|
|
280
|
+
targets = cast('JsonObject', discriminator).get('mapping')
|
|
281
|
+
if isinstance(targets, dict):
|
|
282
|
+
for key, target in cast('JsonObject', targets).items():
|
|
283
|
+
if isinstance(target, str) and target.startswith('#/'):
|
|
284
|
+
cast('JsonObject', targets)[key] = self._rewrite_ref(
|
|
285
|
+
target,
|
|
286
|
+
renames,
|
|
287
|
+
)
|
|
288
|
+
for item in mapping.values():
|
|
289
|
+
self._rewrite(item, renames, result)
|
|
290
|
+
|
|
291
|
+
@staticmethod
|
|
292
|
+
def _push_security(item: JsonObject, security: Any) -> None:
|
|
293
|
+
for method in HTTP_METHODS:
|
|
294
|
+
operation = item.get(method)
|
|
295
|
+
if isinstance(operation, dict) and 'security' not in operation:
|
|
296
|
+
cast('JsonObject', operation)['security'] = copy.deepcopy(security)
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Error objects (``errors=[...]``) turned into responses by error providers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any, Optional, Protocol
|
|
8
|
+
|
|
9
|
+
from qstd_openapi.core.schemas import JsonSchema, SchemaContext, SchemaRequest
|
|
10
|
+
from qstd_openapi.errors import UnsupportedErrorObjectError, UnsupportedSchemaError
|
|
11
|
+
from qstd_openapi.meta.model import StatusCode
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class ErrorResponse:
|
|
16
|
+
status: StatusCode
|
|
17
|
+
schema: Any
|
|
18
|
+
"""Anything the schema providers understand, usually a raw dict with ``Ref``."""
|
|
19
|
+
name: Optional[str] = None
|
|
20
|
+
"""Component name; ``None`` keeps the schema inline."""
|
|
21
|
+
description: Optional[str] = None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ErrorProvider(Protocol):
|
|
25
|
+
def supports(self, error: object) -> bool: ...
|
|
26
|
+
|
|
27
|
+
def describe(self, error: object) -> ErrorResponse: ...
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ErrorSchemas:
|
|
31
|
+
"""Adapts error providers to the schema provider protocol.
|
|
32
|
+
|
|
33
|
+
Error classes become components, so an error referenced from another
|
|
34
|
+
error's field (a validation error listing item errors) is a ``$ref``.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(self, providers: Sequence[ErrorProvider]) -> None:
|
|
38
|
+
self._providers = tuple(providers)
|
|
39
|
+
|
|
40
|
+
def provider_for(self, error: object) -> Optional[ErrorProvider]:
|
|
41
|
+
for provider in self._providers:
|
|
42
|
+
if provider.supports(error):
|
|
43
|
+
return provider
|
|
44
|
+
return None
|
|
45
|
+
|
|
46
|
+
def describe(self, error: object) -> ErrorResponse:
|
|
47
|
+
provider = self.provider_for(error)
|
|
48
|
+
if provider is None:
|
|
49
|
+
raise UnsupportedErrorObjectError(
|
|
50
|
+
f'No error provider supports {error!r}; pass errors=[...] to OpenAPI '
|
|
51
|
+
'(for example AppErrors from qstd_openapi.contrib.app_errors).',
|
|
52
|
+
)
|
|
53
|
+
return provider.describe(error)
|
|
54
|
+
|
|
55
|
+
def supports(self, target: object) -> bool:
|
|
56
|
+
return self.provider_for(target) is not None
|
|
57
|
+
|
|
58
|
+
def generate(
|
|
59
|
+
self,
|
|
60
|
+
requests: Sequence[SchemaRequest],
|
|
61
|
+
context: SchemaContext,
|
|
62
|
+
) -> Sequence[JsonSchema]:
|
|
63
|
+
results: list[JsonSchema] = []
|
|
64
|
+
for request in requests:
|
|
65
|
+
response = self.describe(request.target)
|
|
66
|
+
try:
|
|
67
|
+
schema = context.resolve(response.schema, 'serialization')
|
|
68
|
+
except UnsupportedSchemaError as exc:
|
|
69
|
+
raise _unsupported_field(request.target, exc) from None
|
|
70
|
+
if response.name:
|
|
71
|
+
origin = f'error provider for {request.target!r}'
|
|
72
|
+
schema = context.add_component(response.name, schema, origin)
|
|
73
|
+
results.append(schema)
|
|
74
|
+
return results
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _unsupported_field(
|
|
78
|
+
error: object,
|
|
79
|
+
exc: UnsupportedSchemaError,
|
|
80
|
+
) -> UnsupportedSchemaError:
|
|
81
|
+
name = getattr(error, '__qualname__', None) or repr(error)
|
|
82
|
+
location = exc.location
|
|
83
|
+
if len(location) == 2 and location[0] == 'properties':
|
|
84
|
+
where = f'field {location[1]!r}'
|
|
85
|
+
hint = (
|
|
86
|
+
' If the field is not part of the error response, keep it out of the '
|
|
87
|
+
f'schema: AppErrors skips private (_{location[1]}) and ClassVar fields; '
|
|
88
|
+
'for another rule subclass AppErrors or implement ErrorProvider.'
|
|
89
|
+
)
|
|
90
|
+
else:
|
|
91
|
+
where = '/'.join(location) or 'schema'
|
|
92
|
+
hint = ''
|
|
93
|
+
return UnsupportedSchemaError(
|
|
94
|
+
f'Cannot describe error {name}: no schema provider supports '
|
|
95
|
+
f'{exc.target!r} in its {where}.{hint}',
|
|
96
|
+
target=exc.target,
|
|
97
|
+
location=exc.location,
|
|
98
|
+
)
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Which operations go into a document and which tags they get."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Hashable, Iterable
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Optional, Protocol
|
|
8
|
+
|
|
9
|
+
from qstd_openapi.meta.model import OperationMeta
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True, init=False)
|
|
13
|
+
class ScopeFilter:
|
|
14
|
+
"""Select operations by their ``scope`` labels.
|
|
15
|
+
|
|
16
|
+
- ``include``: an operation with scopes is kept if it has at least one of
|
|
17
|
+
them; ``None`` keeps every scoped operation;
|
|
18
|
+
- ``exclude``: an operation with any of these scopes is dropped;
|
|
19
|
+
- ``unscoped``: whether operations without scopes are kept.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
include: Optional[frozenset[Hashable]] = None
|
|
23
|
+
exclude: frozenset[Hashable] = frozenset()
|
|
24
|
+
unscoped: bool = True
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
include: Optional[Iterable[Hashable]] = None,
|
|
29
|
+
exclude: Iterable[Hashable] = (),
|
|
30
|
+
*,
|
|
31
|
+
unscoped: bool = True,
|
|
32
|
+
) -> None:
|
|
33
|
+
object.__setattr__(
|
|
34
|
+
self,
|
|
35
|
+
'include',
|
|
36
|
+
frozenset(include) if include is not None else None,
|
|
37
|
+
)
|
|
38
|
+
object.__setattr__(self, 'exclude', frozenset(exclude))
|
|
39
|
+
object.__setattr__(self, 'unscoped', unscoped)
|
|
40
|
+
|
|
41
|
+
def allows(self, scopes: Iterable[Hashable]) -> bool:
|
|
42
|
+
labels = set(scopes)
|
|
43
|
+
if not labels:
|
|
44
|
+
return self.unscoped
|
|
45
|
+
if labels & self.exclude:
|
|
46
|
+
return False
|
|
47
|
+
return self.include is None or bool(labels & self.include)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class TagRule(Protocol):
|
|
51
|
+
"""Extra tags for an operation; ``path`` is ``None`` for webhooks."""
|
|
52
|
+
|
|
53
|
+
def __call__(
|
|
54
|
+
self,
|
|
55
|
+
path: Optional[str],
|
|
56
|
+
method: str,
|
|
57
|
+
meta: OperationMeta,
|
|
58
|
+
) -> Iterable[str]: ...
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True)
|
|
62
|
+
class PathTag:
|
|
63
|
+
"""Tag operations whose path contains ``includes`` and not ``excludes``."""
|
|
64
|
+
|
|
65
|
+
name: str
|
|
66
|
+
includes: Optional[str] = None
|
|
67
|
+
excludes: Optional[str] = None
|
|
68
|
+
|
|
69
|
+
def __call__(
|
|
70
|
+
self,
|
|
71
|
+
path: Optional[str],
|
|
72
|
+
method: str, # noqa: ARG002
|
|
73
|
+
meta: OperationMeta, # noqa: ARG002
|
|
74
|
+
) -> Iterable[str]:
|
|
75
|
+
if path is None:
|
|
76
|
+
return ()
|
|
77
|
+
if self.includes is not None and self.includes not in path:
|
|
78
|
+
return ()
|
|
79
|
+
if self.excludes is not None and self.excludes in path:
|
|
80
|
+
return ()
|
|
81
|
+
return (self.name,)
|