Skip to content

Commit cd3df0f

Browse files
committed
Use pydantic in openapi spec
1 parent 3c8e257 commit cd3df0f

11 files changed

Lines changed: 816 additions & 403 deletions

File tree

lower_bounds_constraints.lock

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,6 @@ schema==0.7.5
77
tomli==2.0.0
88
tomli-w==1.0.0
99
pygments==2.17.2
10-
pydantic==2.11.7
10+
pydantic==2.9.2
1111
click-shell==2.1
1212
SecretStorage==3.3.3

pulp-glue/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ classifiers = [
2424
dependencies = [
2525
"multidict>=6.0.5,<6.8",
2626
"packaging>=22.0,<=26.0", # CalVer
27-
"pydantic>=2.11.7,<2.13",
27+
"pydantic>=2.9.2,<2.13",
2828
"requests>=2.24.0,<2.33",
2929
"tomli>=2.0.0,<2.1;python_version<'3.11'",
3030
]

pulp-glue/src/pulp_glue/common/authentication.py

Lines changed: 21 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import typing as t
22

3+
from pulp_glue.common import oas
4+
35

46
class AuthProviderBase:
57
"""
@@ -18,26 +20,32 @@ def can_complete_mutualTLS(self) -> bool:
1820
def can_complete_oauth2_client_credentials(self, scopes: list[str]) -> bool:
1921
return False
2022

21-
def can_complete_scheme(self, scheme: dict[str, t.Any], scopes: list[str]) -> bool:
22-
if scheme["type"] == "http":
23-
if scheme["scheme"] == "basic":
23+
def can_complete_scheme(self, security_scheme: oas.SecurityScheme, scopes: list[str]) -> bool:
24+
if isinstance(security_scheme, oas.SecuritySchemeHttp):
25+
if security_scheme.scheme == "basic":
2426
return self.can_complete_http_basic()
25-
elif scheme["type"] == "mutualTLS":
27+
elif isinstance(security_scheme, oas.SecuritySchemeMutualTLS):
2628
return self.can_complete_mutualTLS()
27-
elif scheme["type"] == "oauth2":
28-
for flow_name, flow in scheme["flows"].items():
29-
if flow_name == "clientCredentials" and self.can_complete_oauth2_client_credentials(
30-
flow["scopes"]
31-
):
32-
return True
29+
elif isinstance(security_scheme, oas.SecuritySchemeOAuth2):
30+
client_credentials_flow = security_scheme.flows.client_credentials
31+
if client_credentials_flow is not None and self.can_complete_oauth2_client_credentials(
32+
list(client_credentials_flow.scopes.keys())
33+
):
34+
return True
3335
return False
3436

3537
def can_complete(
36-
self, proposal: dict[str, list[str]], security_schemes: dict[str, dict[str, t.Any]]
38+
self,
39+
proposal: dict[str, list[str]],
40+
security_schemes: dict[str, oas.SecurityScheme | oas.Reference],
3741
) -> bool:
3842
for name, scopes in proposal.items():
39-
scheme = security_schemes.get(name)
40-
if scheme is None or not self.can_complete_scheme(scheme, scopes):
43+
security_scheme = security_schemes.get(name)
44+
if (
45+
security_scheme is None
46+
or isinstance(security_scheme, oas.Reference)
47+
or not self.can_complete_scheme(security_scheme, scopes)
48+
):
4149
return False
4250
# This covers the case where `[]` allows for no auth at all.
4351
return True

pulp-glue/src/pulp_glue/common/exceptions.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,5 +59,9 @@ class ValidationError(OpenAPIError):
5959
"""Exception raised for failed client side validation of parameters or request bodies."""
6060

6161

62+
class SchemaError(OpenAPIError):
63+
"""Exception raised for unsurmountable inconsistencies of the openapi schema."""
64+
65+
6266
class UnsafeCallError(OpenAPIError):
6367
"""Exception raised for POST, PUT, PATCH or DELETE calls with `safe_calls_only=True`."""

pulp-glue/src/pulp_glue/common/pydantic_oas.py renamed to pulp-glue/src/pulp_glue/common/oas.py

Lines changed: 136 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import typing as t
2+
from functools import cached_property
23

34
import pydantic
45
import pydantic.alias_generators
@@ -15,7 +16,7 @@ class OASBase(pydantic.BaseModel, alias_generator=to_alias, extra="forbid"):
1516

1617
class ExtensibleOASBase(OASBase, extra="allow"):
1718
@pydantic.model_validator(mode="after")
18-
def _check_extensions(self) -> t.Self:
19+
def _check_extensions(self) -> "t.Self":
1920
if self.__pydantic_extra__ is not None:
2021
invalid_keys = [
2122
key for key in self.__pydantic_extra__.keys() if not key.startswith("x-")
@@ -30,8 +31,31 @@ def _check_extensions(self) -> t.Self:
3031
return self
3132

3233

34+
OperationName = t.Literal[
35+
"get",
36+
"put",
37+
"post",
38+
"patch",
39+
"delete",
40+
"options",
41+
"head",
42+
"trace",
43+
]
44+
45+
METHODS: list[OperationName] = [
46+
"get",
47+
"put",
48+
"post",
49+
"patch",
50+
"delete",
51+
"options",
52+
"head",
53+
"trace",
54+
]
55+
56+
3357
class Reference(OASBase):
34-
_ref: t.Annotated[str, pydantic.Field(alias="$ref")]
58+
ref: t.Annotated[str, pydantic.Field(alias="$ref")]
3559
summary: str | None = None
3660
description: str | None = None
3761

@@ -164,11 +188,48 @@ class XML(ExtensibleOASBase):
164188
wrapped: bool = False
165189

166190

167-
class Schema(OASBase, extra="allow"):
191+
class SchemaBase(OASBase, extra="allow"):
168192
discriminator: Discriminator | None = None
169193
xml: XML | None = None
170194
external_docs: ExternalDocumentation | None = None
171195
example: t.Any = None
196+
examples: dict[str, Example | Reference] | None = None
197+
nullable: bool = False
198+
199+
200+
class AllOfSchema(SchemaBase):
201+
all_of: list["Schema"]
202+
203+
204+
class AnyOfSchema(SchemaBase):
205+
any_of: list["Schema"]
206+
207+
208+
class OneOfSchema(SchemaBase):
209+
one_of: list["Schema"]
210+
211+
212+
class TypeSchema(SchemaBase):
213+
type_: str | list[str]
214+
215+
min_items: int | None = None
216+
max_items: int | None = None
217+
unique_items: bool = False
218+
items: "Schema | None" = None
219+
220+
minimum: int | float | None = None
221+
exclusive_minimum: bool = False
222+
maximum: int | float | None = None
223+
exclusive_maximum: bool = False
224+
multiple_of: int | None = None
225+
format_: str | None = None
226+
enum: list[str] | None = None
227+
properties: dict[str, "Schema"] | None = None
228+
additional_properties: "Schema" = True
229+
required: list[str] | None = None
230+
231+
232+
Schema = bool | Reference | TypeSchema | AllOfSchema | AnyOfSchema | OneOfSchema | SchemaBase
172233

173234

174235
class Link(ExtensibleOASBase):
@@ -192,18 +253,38 @@ class Encoding(ExtensibleOASBase):
192253
# TODO These only apply to RFC6570
193254
style: (
194255
t.Literal[
195-
"simple", "form", "matrix", "label", "spaceDelimited", "pipeDelimited", "deepObject"
256+
"simple",
257+
"form",
258+
"matrix",
259+
"label",
260+
"spaceDelimited",
261+
"pipeDelimited",
262+
"deepObject",
196263
]
197264
| None
198265
) = None
199-
explode: t.Annotated[bool, pydantic.Field(default_factory=lambda data: data["style"] == "form")]
266+
explode: t.Annotated[
267+
bool,
268+
pydantic.Field(
269+
# default_factory=lambda data: data["style"] == "form"
270+
),
271+
]
200272
allow_reserved: bool = False
201273

274+
@pydantic.model_validator(mode="before")
275+
@classmethod
276+
def explode_default(cls, data: t.Any) -> t.Any:
277+
# This is a workaround for pydantic < 2.10 .
278+
# default_factory didn't have the extra data argument.
279+
data["explode"] = data.get("style") == "form"
280+
return data
281+
202282

203283
class MediaType(ExtensibleOASBase):
204284
schema_: Schema
205285
example: t.Any = None
206286
examples: dict[str, Example | Reference] | None = None
287+
# TODO encoding seems to be reserved to request body.
207288
encoding: dict[str, Encoding] = {}
208289

209290

@@ -237,7 +318,7 @@ class ParameterBase(ExtensibleOASBase):
237318
allow_empty_value: bool = False
238319

239320
@pydantic.model_validator(mode="after")
240-
def _path_required(self) -> t.Self:
321+
def _path_required(self) -> "t.Self":
241322
if self.in_ == "path":
242323
assert self.required
243324
return self
@@ -251,17 +332,42 @@ class SchemaParameter(ParameterBase):
251332
schema_: Schema
252333
style: t.Annotated[
253334
t.Literal[
254-
"simple", "form", "matrix", "label", "spaceDelimited", "pipeDelimited", "deepObject"
335+
"simple",
336+
"form",
337+
"matrix",
338+
"label",
339+
"spaceDelimited",
340+
"pipeDelimited",
341+
"deepObject",
255342
],
256343
pydantic.Field(
257-
default_factory=lambda data: "simple" if data["in_"] in ["header", "path"] else "form"
344+
# default_factory=lambda data: (
345+
# "simple" if data.get("in_") in ["header", "path"] else "form"
346+
# )
347+
),
348+
]
349+
explode: t.Annotated[
350+
bool,
351+
pydantic.Field(
352+
# default_factory=lambda data: data["style"] == "form"
258353
),
259354
]
260-
explode: t.Annotated[bool, pydantic.Field(default_factory=lambda data: data["style"] == "form")]
261355
allowed_reserved: bool = False
262356
example: t.Any = None
263357
examples: dict[str, Example | Reference] | None = None
264358

359+
@pydantic.model_validator(mode="before")
360+
@classmethod
361+
def explode_default(cls, data: t.Any) -> t.Any:
362+
# This is a workaround for pydantic < 2.10 .
363+
# default_factory didn't have the extra data argument.
364+
if "schema" in data:
365+
if data.get("style") is None:
366+
data["style"] = "simple" if data.get("in") in ["header", "path"] else "form"
367+
if data.get("explode") is None:
368+
data["explode"] = data["style"] == "form"
369+
return data
370+
265371

266372
Parameter = ContentParameter | SchemaParameter
267373

@@ -275,7 +381,7 @@ class RequestBody(ExtensibleOASBase):
275381
class Response(ExtensibleOASBase):
276382
description: str
277383
headers: dict[str, Header | Reference] | None = None
278-
content: dict[str, MediaType] | None = None
384+
content: dict[str, MediaType] = {}
279385
links: dict[str, Link | Reference] | None = None
280386

281387

@@ -291,16 +397,17 @@ class Operation(ExtensibleOASBase):
291397
description: str | None = None
292398
external_docs: ExternalDocumentation | None = None
293399
operation_id: str
294-
parameters: list[Parameter | Reference] | None = None
400+
parameters: list[Parameter | Reference] = []
295401
request_body: RequestBody | Reference | None = None
296-
responses: Responses | None = None
402+
responses: Responses = {}
297403
callbacks: dict[str, Callback | Reference] | None = None
298404
deprecated: bool = False
299405
security: SecurityRequirements | None = None
300406
servers: list[Server] | None = None
301407

302408

303-
class PathItem(Reference, ExtensibleOASBase):
409+
class PathItem(ExtensibleOASBase):
410+
# TODO $ref
304411
get: Operation | None = None
305412
put: Operation | None = None
306413
post: Operation | None = None
@@ -310,17 +417,17 @@ class PathItem(Reference, ExtensibleOASBase):
310417
head: Operation | None = None
311418
trace: Operation | None = None
312419
servers: list[Server] | None = None
313-
parameters: list[Parameter | Reference] | None = None
420+
parameters: list[Parameter | Reference] = []
314421

315422

316423
class Components(ExtensibleOASBase):
317-
schemas: dict[str, Schema] | None = None
424+
schemas: dict[str, Schema] = {}
318425
responses: dict[str, Response | Reference] | None = None
319-
parameters: dict[str, Parameter | Reference] | None = None
426+
parameters: dict[str, Parameter | Reference] = {}
320427
examples: dict[str, Example | Reference] | None = None
321428
request_bodies: dict[str, RequestBody | Reference] | None = None
322429
headers: dict[str, Header | Reference] | None = None
323-
security_schemes: dict[str, SecurityScheme | Reference] | None = None
430+
security_schemes: dict[str, SecurityScheme | Reference] = {}
324431
links: dict[str, Link | Reference] | None = None
325432
callbacks: dict[str, Callback | Reference] | None = None
326433
path_items: dict[str, PathItem] | None = None
@@ -333,9 +440,19 @@ class OpenAPISpec(ExtensibleOASBase):
333440
servers: list[Server] | None = None
334441
# In the specification there is a Paths Object,
335442
# probably because paths as keys can get extra validation.
336-
paths: dict[str, PathItem] | None = None
443+
paths: dict[str, PathItem] = {}
337444
webhooks: dict[str, PathItem] | None = None
338-
components: Components | None = None
445+
components: Components = Components()
339446
security: SecurityRequirements | None = None
340447
tags: list[Tag] | None = None
341448
external_docs: ExternalDocumentation | None = None
449+
450+
# @pydantic.computed_field # type: ignore[prop-decorator]
451+
@cached_property
452+
def operations(self) -> dict[str, tuple[OperationName, str]]:
453+
return {
454+
operation.operation_id: (method, path)
455+
for path, path_item in self.paths.items()
456+
for method, operation in ((method, getattr(path_item, method)) for method in METHODS)
457+
if operation is not None
458+
}

0 commit comments

Comments
 (0)