Skip to content

Commit 4150f37

Browse files
🔀 Merge branch 'feature/response-envelope' into develop
2 parents 25621a5 + c5a1ad4 commit 4150f37

File tree

131 files changed

+676
-260
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

131 files changed

+676
-260
lines changed

‎.gitignore‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
/dist/
2+
__pycache__/

‎src/lapidary/render/main.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,7 @@ def __exit__(self, exc_type, exc_val, exc_tb) -> bool | None:
137137

138138
def writing_file(self, template: pathlib.PurePath, target: pathlib.Path) -> None:
139139
super().writing_file(template, target)
140-
if str(template) == '{{model.package}}/{{loop_over(model.modules).rel_path}}.py.jinja':
140+
if str(template) == '{{loop_over(model.modules).file_path}}.py.jinja':
141141
self._progress_bar.update(1, str(target).split('/')[-2])
142142

143143

‎src/lapidary/render/model/openapi/model.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -329,7 +329,7 @@ def _validate(values: Mapping[str, typing.Any]):
329329

330330
class Response(ExtendableModel):
331331
description: str
332-
headers: dict[str, Reference[Header] | Header] | None = None
332+
headers: Annotated[dict[str, Reference[Header] | Header], pydantic.Field(default_factory=dict)]
333333
content: 'typing.Annotated[dict[str, MediaType], pydantic.Field(default_factory=dict)]'
334334
links: dict[str, Reference[Link] | Link] | None = None
335335

‎src/lapidary/render/model/openapi_conv.py‎

Lines changed: 71 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
import functools
22
import logging
3-
from collections.abc import Callable, Iterable, Mapping
3+
from collections.abc import Callable, Iterable, Mapping, MutableMapping
44
from typing import Any, cast
55

66
from mimeparse import parse_media_range
77

88
from .. import json_pointer, names
99
from . import openapi, python
1010
from .refs import resolve_ref
11-
from .schema import OpenApi30SchemaConverter
11+
from .schema import OpenApi30SchemaConverter, resolve_type_hint
1212
from .stack import Stack
1313

1414
logger = logging.getLogger(__name__)
@@ -39,6 +39,8 @@ def __init__(
3939
package=str(root_package),
4040
)
4141

42+
self._response_mime_maps: MutableMapping[Stack, python.MimeMap] = {}
43+
4244
def process(self) -> python.ClientModel:
4345
stack = Stack()
4446

@@ -98,25 +100,35 @@ def process_global_responses(self, value: openapi.Responses | None, stack: Stack
98100
# Not providing process_parameters (plural) as each caller calls it in a different context
99101
# (list, map or map with defaults)
100102

101-
@resolve_ref
102-
def process_parameter(self, value: openapi.Parameter, stack: Stack) -> python.Parameter:
103-
logger.debug('process_parameter %s', stack)
104-
105-
if not isinstance(value, openapi.ParameterBase):
106-
raise TypeError(f'Expected Parameter object at {stack}, got {type(value).__name__}.')
103+
def _process_schema_or_content(
104+
self,
105+
value: openapi.ParameterBase,
106+
stack: Stack,
107+
) -> tuple[python.TypeHint, str]:
108+
if value.schema_ and value.content:
109+
raise ValueError()
107110
if value.schema_:
108111
media_type: str | None = None
109112
# encoding: str | None = None
110-
typ = self.schema_converter.process_schema(value.schema_, stack.push('schema'), value.required)
113+
return self.schema_converter.process_schema(value.schema_, stack.push('schema'), value.required), media_type
111114
elif value.content:
112115
media_type, media_type_obj = next(iter(value.content.items()))
113116
# encoding = media_type_obj.encoding
114-
typ = self.schema_converter.process_schema(
117+
return self.schema_converter.process_schema(
115118
media_type_obj.schema_, stack.push('content', media_type), value.required
116-
)
119+
), media_type
117120
else:
118121
raise TypeError(f'{stack}: schema or content is required')
119122

123+
@resolve_ref
124+
def process_parameter(self, value: openapi.Parameter, stack: Stack) -> python.Parameter:
125+
logger.debug('process_parameter %s', stack)
126+
127+
if not isinstance(value, openapi.ParameterBase):
128+
raise TypeError(f'Expected Parameter object at {stack}, got {type(value).__name__}.')
129+
130+
typ, media_type = self._process_schema_or_content(value, stack)
131+
120132
return python.Parameter(
121133
name=parameter_name(value),
122134
alias=value.name,
@@ -161,9 +173,56 @@ def process_response(
161173
stack: Stack,
162174
) -> python.MimeMap:
163175
assert isinstance(value, openapi.Response)
164-
return self.process_content(value.content, stack.push('content'))
176+
177+
if stack in self._response_mime_maps:
178+
return self._response_mime_maps[stack]
179+
180+
headers_stack = stack.push('headers')
181+
182+
headers = [self.process_header(header, headers_stack.push(name)) for name, header in value.headers.items()]
183+
184+
mime_map_body_only = self.process_content(value.content, stack.push('content'))
185+
mime_map = {
186+
mime_type: self._create_envelope_model(
187+
body_type=body_type,
188+
headers=headers,
189+
stack=stack,
190+
)
191+
for mime_type, body_type in mime_map_body_only.items()
192+
}
193+
self._response_mime_maps[stack] = mime_map
194+
return mime_map
195+
196+
@resolve_ref
197+
def process_header(self, value: openapi.Header, stack: Stack) -> python.ResponseHeader:
198+
alias = stack.top()
199+
200+
typ, _ = self._process_schema_or_content(value, stack)
201+
202+
return python.ResponseHeader(name=names.maybe_mangle_name(alias), alias=alias, type=typ, annotation='Header')
203+
204+
def _create_envelope_model(
205+
self,
206+
body_type: python.TypeHint,
207+
headers: Iterable[python.ResponseHeader],
208+
stack: Stack,
209+
) -> python.TypeHint:
210+
envelope = python.ResponseEnvelopeModel(
211+
name='Response',
212+
headers=headers,
213+
body_type=body_type,
214+
)
215+
type_hint = resolve_type_hint(str(self.root_package), stack.push('Response'))
216+
self.target.add_response_envelope_module(
217+
python.ResponseEnvelopeModule(
218+
path=python.ModulePath(type_hint.module, is_module=False),
219+
body=envelope,
220+
)
221+
)
222+
return type_hint
165223

166224
def process_content(self, value: Mapping[str, openapi.MediaType], stack: Stack) -> python.MimeMap:
225+
"""Returns: {mime_type: response body type hint}"""
167226
types = {}
168227
for mime, media_type in value.items():
169228
mime_parsed = parse_media_range(mime)

‎src/lapidary/render/model/python/__init__.py‎

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@
2222
'ParamLocation',
2323
'Parameter',
2424
'PasswordOAuth2Flow',
25+
'ResponseEnvelopeModel',
26+
'ResponseHeader',
2527
'ResponseMap',
2628
'SchemaClass',
2729
'SchemaModule',
@@ -31,6 +33,7 @@
3133
)
3234

3335
import dataclasses as dc
36+
import itertools
3437
from collections.abc import Iterable, MutableMapping, MutableSequence, MutableSet, Sequence
3538
from functools import cached_property
3639
from typing import Self
@@ -53,11 +56,13 @@
5356
OperationFunction,
5457
Parameter,
5558
PasswordOAuth2Flow,
59+
ResponseEnvelopeModel,
60+
ResponseHeader,
5661
ResponseMap,
5762
SchemaClass,
5863
SecurityRequirements,
5964
)
60-
from .module import AbstractModule, AuthModule, ClientModule, EmptyModule, SchemaModule
65+
from .module import AbstractModule, AuthModule, ClientModule, EmptyModule, ResponseEnvelopeModule, SchemaModule
6166
from .module_path import ModulePath
6267
from .type_hint import NONE, BuiltinTypeHint, GenericTypeHint, TypeHint, type_hint_or_union
6368

@@ -68,18 +73,18 @@ class ClientModel:
6873
package: str
6974
schemas: MutableSequence[SchemaModule] = dc.field(default_factory=list)
7075
security_schemes: MutableMapping[str, Auth] = dc.field(default_factory=dict)
76+
_response_envelopes: MutableSequence[ResponseEnvelopeModule] = dc.field(default_factory=list)
7177

7278
def packages(self: Self) -> Iterable[ModulePath]:
7379
# Used to create __init__.py files in otherwise empty packages
7480

75-
known_packages: MutableSet[ModulePath] = {ModulePath.root()}
81+
known_packages: MutableSet[ModulePath] = {ModulePath(self.package)}
7682

77-
# for each schema module get its package
78-
for schema in self.schemas:
79-
path: ModulePath | None = schema.path
83+
for mod in itertools.chain(self.schemas, self._response_envelopes):
84+
path: ModulePath | None = mod.path
8085
while path := path.parent():
8186
if path in known_packages:
82-
continue
87+
break
8388
yield path
8489
known_packages.add(path)
8590

@@ -88,6 +93,15 @@ def modules(self) -> Sequence[AbstractModule]:
8893
return list(self._modules())
8994

9095
def _modules(self) -> Iterable[AbstractModule]:
91-
yield from self.schemas
96+
known_modules = set()
97+
for mod in itertools.chain(self.schemas, self._response_envelopes):
98+
assert mod.path not in known_modules, mod.path
99+
known_modules.add(mod.path)
100+
yield mod
101+
92102
for package in self.packages():
93-
yield EmptyModule(path=package, body=None)
103+
if package not in known_modules:
104+
yield EmptyModule(path=package, body=None)
105+
106+
def add_response_envelope_module(self, mod: ResponseEnvelopeModule):
107+
self._response_envelopes.append(mod)

‎src/lapidary/render/model/python/model.py‎

Lines changed: 28 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import dataclasses as dc
22
import enum
33
from collections.abc import Iterable, Mapping
4-
from typing import Any, TypeAlias
4+
from typing import Any, Literal, TypeAlias
55

66
from ..openapi import ParameterLocation as ParamLocation
77
from ..openapi import Style as ParamStyle
@@ -35,10 +35,6 @@ class Field:
3535
deprecated: bool = False
3636
"""Currently not used"""
3737

38-
@property
39-
def dependencies(self) -> Iterable[TypeHint]:
40-
return [self.annotation.type]
41-
4238

4339
@dc.dataclass
4440
class Auth:
@@ -119,11 +115,10 @@ class OperationFunction:
119115
responses: ResponseMap
120116
security: SecurityRequirements | None
121117

122-
@property
123118
def dependencies(self) -> Iterable[TypeHint]:
124119
yield self.request_body_type
125120
for param in self.params:
126-
yield from param.dependencies
121+
yield from param.dependencies()
127122
yield self.response_body_type
128123

129124
@property
@@ -164,7 +159,6 @@ class Parameter:
164159
style: ParamStyle | None
165160
explode: bool | None
166161

167-
@property
168162
def dependencies(self) -> Iterable[TypeHint]:
169163
yield self.type
170164

@@ -184,11 +178,10 @@ class SchemaClass:
184178
fields: list[Field] = dc.field(default_factory=list)
185179
model_type: ModelType = ModelType.model
186180

187-
@property
188181
def dependencies(self) -> Iterable[TypeHint]:
189182
yield self.base_type
190183
for prop in self.fields:
191-
yield from prop.dependencies
184+
yield prop.annotation.type
192185

193186

194187
@dc.dataclass
@@ -200,7 +193,6 @@ class ClientInit:
200193
response_map: ResponseMap = dc.field(default_factory=dict)
201194
security: SecurityRequirements | None = None
202195

203-
@property
204196
def dependencies(self) -> Iterable[TypeHint]:
205197
for mime_map in self.response_map.values():
206198
yield from mime_map.values()
@@ -211,8 +203,30 @@ class ClientClass:
211203
init_method: ClientInit
212204
methods: list[OperationFunction] = dc.field(default_factory=list)
213205

214-
@property
215206
def dependencies(self) -> Iterable[TypeHint]:
216-
yield from self.init_method.dependencies
207+
yield from self.init_method.dependencies()
217208
for fn in self.methods:
218-
yield from fn.dependencies
209+
yield from fn.dependencies()
210+
211+
212+
@dc.dataclass(frozen=True)
213+
class ResponseHeader:
214+
name: str
215+
alias: str
216+
type: TypeHint
217+
annotation: Literal['Cookie', 'Header', 'Link']
218+
219+
def dependencies(self) -> Iterable[TypeHint]:
220+
yield self.type
221+
222+
223+
@dc.dataclass(frozen=True)
224+
class ResponseEnvelopeModel:
225+
name: str
226+
headers: Iterable[ResponseHeader]
227+
body_type: TypeHint
228+
229+
def dependencies(self) -> Iterable[TypeHint]:
230+
yield self.body_type
231+
for header in self.headers:
232+
yield from header.dependencies()

0 commit comments

Comments
 (0)