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,56 @@
1
+ """Framework-agnostic, declarative OpenAPI documentation."""
2
+
3
+ from qstd_openapi import markers, openapi
4
+ from qstd_openapi.core import (
5
+ BuildResult,
6
+ Conflict,
7
+ Diagnostic,
8
+ Document,
9
+ ErrorProvider,
10
+ ErrorResponse,
11
+ OpenAPI,
12
+ PathTag,
13
+ RouteEntry,
14
+ Routes,
15
+ ScopeFilter,
16
+ TypeOverride,
17
+ WebhookSet,
18
+ validate_document,
19
+ )
20
+ from qstd_openapi.errors import (
21
+ AttachError,
22
+ BuildError,
23
+ QstdOpenAPIError,
24
+ ScalarConflictError,
25
+ )
26
+ from qstd_openapi.meta import OperationMeta, read_operation
27
+ from qstd_openapi.serialization import dumps, dumps_yaml
28
+ from qstd_openapi.tags import tags_from_markdown
29
+
30
+ __all__ = (
31
+ 'AttachError',
32
+ 'BuildError',
33
+ 'BuildResult',
34
+ 'Conflict',
35
+ 'Diagnostic',
36
+ 'Document',
37
+ 'ErrorProvider',
38
+ 'ErrorResponse',
39
+ 'OpenAPI',
40
+ 'OperationMeta',
41
+ 'PathTag',
42
+ 'QstdOpenAPIError',
43
+ 'RouteEntry',
44
+ 'Routes',
45
+ 'ScalarConflictError',
46
+ 'ScopeFilter',
47
+ 'TypeOverride',
48
+ 'WebhookSet',
49
+ 'dumps',
50
+ 'dumps_yaml',
51
+ 'markers',
52
+ 'openapi',
53
+ 'read_operation',
54
+ 'tags_from_markdown',
55
+ 'validate_document',
56
+ )
@@ -0,0 +1,103 @@
1
+ """Command line: ``python -m qstd_openapi dump package.module:spec``.
2
+
3
+ ``TARGET`` is ``module:attribute`` (dotted attributes allowed); the attribute
4
+ is an ``OpenAPI`` instance or a function without arguments returning one —
5
+ the place to create the application and include its routes.
6
+
7
+ Options: ``-o FILE`` (stdout by default), ``--yaml`` (also chosen by a
8
+ ``.yaml``/``.yml`` file name), ``--dialect`` (a version accepted by
9
+ ``qstd_openapi.dialects.dialect_for``), and ``--check`` (compare with ``FILE``
10
+ instead of writing it; exit code 1 and a diff on mismatch).
11
+ Errors while loading or building exit with code 2.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import argparse
17
+ import importlib
18
+ import os
19
+ import sys
20
+
21
+ from pathlib import Path
22
+ from typing import Any, Optional
23
+
24
+ from qstd_openapi.core.document import OpenAPI
25
+ from qstd_openapi.dialects import VERSIONS, dialect_for
26
+ from qstd_openapi.errors import QstdOpenAPIError
27
+ from qstd_openapi.testing import diff, render
28
+
29
+
30
+ def load(target: str) -> OpenAPI:
31
+ """Resolve ``module:attribute`` to an ``OpenAPI`` (calling a factory)."""
32
+ module_name, _, attribute = target.partition(':')
33
+ if not module_name or not attribute:
34
+ raise ValueError(f'Expected module:attribute, got {target!r}')
35
+ if os.getcwd() not in sys.path:
36
+ sys.path.insert(0, os.getcwd())
37
+ value: Any = importlib.import_module(module_name)
38
+ for part in attribute.split('.'):
39
+ value = getattr(value, part)
40
+ if not isinstance(value, OpenAPI) and callable(value):
41
+ value = value()
42
+ if not isinstance(value, OpenAPI):
43
+ raise TypeError(f'{target} is {type(value).__name__}, not OpenAPI')
44
+ return value
45
+
46
+
47
+ def _parser() -> argparse.ArgumentParser:
48
+ parser = argparse.ArgumentParser(prog='python -m qstd_openapi')
49
+ commands = parser.add_subparsers(dest='command', required=True)
50
+ dump = commands.add_parser('dump', help='build a document and write it')
51
+ dump.add_argument('target', help='module:attribute — OpenAPI or a factory')
52
+ dump.add_argument('-o', '--output', help='file to write (default: stdout)')
53
+ dump.add_argument('--yaml', action='store_true', help='YAML instead of JSON')
54
+ dump.add_argument('--dialect', choices=VERSIONS, help='OpenAPI version')
55
+ dump.add_argument(
56
+ '--check',
57
+ action='store_true',
58
+ help='compare with --output instead of writing; exit 1 on difference',
59
+ )
60
+ return parser
61
+
62
+
63
+ def main(argv: Optional[list[str]] = None) -> int:
64
+ args = _parser().parse_args(argv)
65
+ output = Path(args.output) if args.output else None
66
+ if args.check and output is None:
67
+ sys.stderr.write('error: --check requires -o FILE\n')
68
+ return 2
69
+ yaml = bool(args.yaml) or (
70
+ output is not None and output.suffix in ('.yaml', '.yml')
71
+ )
72
+ try:
73
+ spec = load(args.target)
74
+ if args.dialect:
75
+ spec = spec.derive(dialect=dialect_for(args.dialect))
76
+ text = render(spec, yaml=yaml)
77
+ except (
78
+ QstdOpenAPIError,
79
+ ImportError,
80
+ AttributeError,
81
+ TypeError,
82
+ ValueError,
83
+ ) as exc:
84
+ sys.stderr.write(f'error: {exc}\n')
85
+ return 2
86
+ if args.check:
87
+ assert output is not None
88
+ expected = output.read_text(encoding='utf-8') if output.exists() else ''
89
+ if expected != text:
90
+ sys.stderr.write(f'{output} is out of date\n')
91
+ sys.stderr.write(diff(expected, text, name=str(output)))
92
+ return 1
93
+ return 0
94
+ if output is None:
95
+ sys.stdout.write(text)
96
+ else:
97
+ output.parent.mkdir(parents=True, exist_ok=True)
98
+ output.write_text(text, encoding='utf-8')
99
+ return 0
100
+
101
+
102
+ if __name__ == '__main__': # pragma: no cover - entry point
103
+ sys.exit(main())
@@ -0,0 +1,16 @@
1
+ """Python version differences."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, get_origin
6
+
7
+ from typing_extensions import TypeGuard
8
+
9
+
10
+ def is_class(obj: object) -> TypeGuard[type[Any]]:
11
+ """``isinstance(obj, type)`` without parametrized generics.
12
+
13
+ On Python 3.9 and 3.10 ``isinstance(list[int], type)`` is ``True``, and a
14
+ following ``issubclass`` against an ABC raises ``TypeError``.
15
+ """
16
+ return isinstance(obj, type) and get_origin(obj) is None
@@ -0,0 +1 @@
1
+ """Optional providers for common application conventions."""
@@ -0,0 +1,173 @@
1
+ """Error provider for "static error classes".
2
+
3
+ The convention: an application error class has a unique integer ``code``,
4
+ a ``message`` (usually constant) and public annotated payload fields, and is
5
+ serialized as ``{"code": ..., "error": <class name>, "message": ..., **payload}``.
6
+ The HTTP status comes from the project (a function, a class attribute or a
7
+ mapping of base classes). Nothing here imports project code.
8
+
9
+ Usage::
10
+
11
+ from qstd_openapi.contrib.app_errors import AppErrors
12
+
13
+ OpenAPI(..., errors=[AppErrors(base=ApplicationError, status=get_http_status)])
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import sys
19
+
20
+ from collections.abc import Mapping
21
+ from typing import (
22
+ Any,
23
+ Callable,
24
+ ClassVar,
25
+ Optional,
26
+ cast,
27
+ get_origin,
28
+ get_type_hints,
29
+ )
30
+
31
+ from qstd_openapi._compat import is_class
32
+ from qstd_openapi.core.error_responses import ErrorResponse
33
+ from qstd_openapi.core.schemas import JsonSchema
34
+ from qstd_openapi.errors import ErrorAnnotationError
35
+ from qstd_openapi.markers import Ref
36
+
37
+ __all__ = ('AppErrors',)
38
+
39
+ MESSAGE_MODE_ATTR = '__openapi_message__'
40
+ """Class attribute forcing the message schema: ``'const'`` or ``'dynamic'``."""
41
+
42
+
43
+ class AppErrors:
44
+ """Describe application error classes derived from ``base``.
45
+
46
+ Status resolution, first match wins:
47
+
48
+ 1. ``status`` — a callable ``(error_class) -> int`` (use the same function
49
+ the exception handler uses, so the document cannot drift);
50
+ 2. the class attribute named ``status_attr`` (``status_code``), if set;
51
+ 3. ``status_by_class`` — the nearest base class in the MRO;
52
+ 4. ``default_status``.
53
+
54
+ The message is a ``const`` when the class defines a constant one; it is a
55
+ plain string when it is missing, contains ``{`` (a template filled in at
56
+ runtime) or the class sets ``__openapi_message__ = 'dynamic'``.
57
+
58
+ Payload fields are the public annotated attributes over the MRO; names
59
+ starting with ``_`` and ``ClassVar`` annotations are skipped, so the
60
+ schema follows a ``to_dict()`` built by the same rule.
61
+
62
+ Every step is a public method a subclass may override: ``status_for``,
63
+ ``schema_for`` (the whole schema), ``payload_fields`` (which fields and
64
+ their types), ``code_schema`` and ``message_schema``. For a different
65
+ convention altogether implement ``ErrorProvider`` instead.
66
+ """
67
+
68
+ def __init__(
69
+ self,
70
+ base: type,
71
+ *,
72
+ status: Optional[Callable[[type], int]] = None,
73
+ status_attr: Optional[str] = 'status_code',
74
+ status_by_class: Optional[Mapping[Any, int]] = None,
75
+ default_status: int = 500,
76
+ code_field: str = 'code',
77
+ message_field: str = 'message',
78
+ name_field: str = 'error',
79
+ ) -> None:
80
+ self.base = base
81
+ self._status = status
82
+ self._status_attr = status_attr
83
+ self._status_by_class = dict(status_by_class or {})
84
+ self._default_status = default_status
85
+ self._code_field = code_field
86
+ self._message_field = message_field
87
+ self._name_field = name_field
88
+
89
+ def supports(self, error: object) -> bool:
90
+ return is_class(error) and issubclass(error, self.base)
91
+
92
+ def describe(self, error: object) -> ErrorResponse:
93
+ error_class = cast('type', error)
94
+ return ErrorResponse(
95
+ status=self.status_for(error_class),
96
+ schema=self.schema_for(error_class),
97
+ name=error_class.__name__,
98
+ )
99
+
100
+ def status_for(self, error: type) -> int:
101
+ if self._status is not None:
102
+ return self._status(error)
103
+ if self._status_attr:
104
+ value = getattr(error, self._status_attr, None)
105
+ if isinstance(value, int):
106
+ return value
107
+ for klass in error.__mro__:
108
+ if klass in self._status_by_class:
109
+ return self._status_by_class[klass]
110
+ return self._default_status
111
+
112
+ def schema_for(self, error: type) -> JsonSchema:
113
+ properties: dict[str, Any] = {
114
+ self._code_field: self.code_schema(error),
115
+ self._name_field: {'type': 'string', 'const': error.__name__},
116
+ self._message_field: self.message_schema(error),
117
+ }
118
+ for name, annotation in self.payload_fields(error).items():
119
+ properties[name] = Ref(annotation)
120
+ schema: JsonSchema = {
121
+ 'title': error.__name__,
122
+ 'type': 'object',
123
+ 'properties': properties,
124
+ 'required': list(properties),
125
+ 'additionalProperties': False,
126
+ }
127
+ doc = vars(error).get('__doc__')
128
+ if isinstance(doc, str) and doc.strip():
129
+ schema['description'] = doc.strip()
130
+ return schema
131
+
132
+ def code_schema(self, error: type) -> JsonSchema:
133
+ code = getattr(error, self._code_field, None)
134
+ if isinstance(code, int) and not isinstance(code, bool):
135
+ return {'type': 'integer', 'const': code}
136
+ return {'type': 'integer'}
137
+
138
+ def message_schema(self, error: type) -> JsonSchema:
139
+ message = getattr(error, self._message_field, None)
140
+ mode = getattr(error, MESSAGE_MODE_ATTR, None)
141
+ if not isinstance(message, str):
142
+ return {'type': 'string'}
143
+ if mode == 'const' or (mode != 'dynamic' and '{' not in message):
144
+ return {'type': 'string', 'const': message}
145
+ return {'type': 'string', 'examples': [message]}
146
+
147
+ def payload_fields(self, error: type) -> dict[str, Any]:
148
+ """Payload field names and types: public annotated fields over the MRO.
149
+
150
+ Base classes come first; ``code``, ``message``, the name field,
151
+ ``status_code``, ``_``-prefixed names and ``ClassVar`` are skipped.
152
+ """
153
+ skip = {self._code_field, self._message_field, self._name_field, 'status_code'}
154
+ names: list[str] = []
155
+ for klass in reversed(error.__mro__):
156
+ for name in vars(klass).get('__annotations__', {}):
157
+ if not name.startswith('_') and name not in skip and name not in names:
158
+ names.append(name)
159
+ if not names:
160
+ return {}
161
+ try:
162
+ hints = get_type_hints(error, include_extras=True)
163
+ except Exception as exc:
164
+ raise ErrorAnnotationError(
165
+ f'Cannot evaluate annotations of {error.__module__}.{error.__qualname__}: '
166
+ f'{exc}. On Python {sys.version_info.major}.{sys.version_info.minor} '
167
+ 'use typing.Optional/Union instead of "X | Y" in annotations.',
168
+ ) from exc
169
+ return {
170
+ name: hints[name]
171
+ for name in names
172
+ if name in hints and get_origin(hints[name]) is not ClassVar
173
+ }
@@ -0,0 +1,56 @@
1
+ from qstd_openapi.core.document import (
2
+ BuildResult,
3
+ Diagnostic,
4
+ OpenAPI,
5
+ OperationIds,
6
+ OperationIdStrategy,
7
+ validate_document,
8
+ )
9
+ from qstd_openapi.core.documents import Conflict, ConflictPolicy, Document
10
+ from qstd_openapi.core.error_responses import ErrorProvider, ErrorResponse
11
+ from qstd_openapi.core.filters import PathTag, ScopeFilter, TagRule
12
+ from qstd_openapi.core.schemas import (
13
+ BuiltinSchemas,
14
+ JsonSchema,
15
+ SchemaContext,
16
+ SchemaMode,
17
+ SchemaProvider,
18
+ SchemaRequest,
19
+ TypeOverride,
20
+ )
21
+ from qstd_openapi.core.sources import (
22
+ OperationSource,
23
+ RouteEntry,
24
+ Routes,
25
+ WebhookEntry,
26
+ WebhookSet,
27
+ )
28
+
29
+ __all__ = (
30
+ 'BuildResult',
31
+ 'BuiltinSchemas',
32
+ 'Conflict',
33
+ 'ConflictPolicy',
34
+ 'Diagnostic',
35
+ 'Document',
36
+ 'ErrorProvider',
37
+ 'ErrorResponse',
38
+ 'JsonSchema',
39
+ 'OpenAPI',
40
+ 'OperationIdStrategy',
41
+ 'OperationIds',
42
+ 'OperationSource',
43
+ 'PathTag',
44
+ 'RouteEntry',
45
+ 'Routes',
46
+ 'SchemaContext',
47
+ 'SchemaMode',
48
+ 'SchemaProvider',
49
+ 'SchemaRequest',
50
+ 'ScopeFilter',
51
+ 'TagRule',
52
+ 'TypeOverride',
53
+ 'WebhookEntry',
54
+ 'WebhookSet',
55
+ 'validate_document',
56
+ )