seampact-codegen 1.0.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.
- seampact/codegen/__init__.py +2 -0
- seampact/codegen/boot_check_hooks.py +38 -0
- seampact/codegen/contract_module.py +146 -0
- seampact/codegen/credentials_classes.py +99 -0
- seampact/codegen/handler_forms.py +132 -0
- seampact/codegen/input_schemas.py +130 -0
- seampact/codegen/kept_forms.py +522 -0
- seampact/codegen/matrix_row.py +23 -0
- seampact/codegen/names.py +146 -0
- seampact/codegen/native_docs_file.py +169 -0
- seampact/codegen/operation_types.py +425 -0
- seampact/codegen/placement_lines.py +79 -0
- seampact/codegen/printer.py +196 -0
- seampact/codegen/py.typed +0 -0
- seampact/codegen/pydantic_forms.py +53 -0
- seampact/codegen/pydantic_layout.py +178 -0
- seampact/codegen/pydantic_models.py +405 -0
- seampact/codegen/redacted_members.py +47 -0
- seampact/codegen/refusals.py +84 -0
- seampact/codegen/request_deadline.py +68 -0
- seampact/codegen/response_header_forms.py +73 -0
- seampact/codegen/restricted_sort_keys.py +18 -0
- seampact/codegen/route_helpers.py +109 -0
- seampact/codegen/routes.py +139 -0
- seampact/codegen/runtime/__init__.py +7 -0
- seampact/codegen/runtime/access_kinds.py +24 -0
- seampact/codegen/runtime/access_steps.py +520 -0
- seampact/codegen/runtime/body_reader.py +64 -0
- seampact/codegen/runtime/cache_headers.py +89 -0
- seampact/codegen/runtime/contract.py +232 -0
- seampact/codegen/runtime/cors_preflight.py +20 -0
- seampact/codegen/runtime/deadline.py +72 -0
- seampact/codegen/runtime/digests.py +98 -0
- seampact/codegen/runtime/failures.py +33 -0
- seampact/codegen/runtime/field_rights.py +193 -0
- seampact/codegen/runtime/framework_routes.py +36 -0
- seampact/codegen/runtime/framework_versions.py +91 -0
- seampact/codegen/runtime/handler_call.py +31 -0
- seampact/codegen/runtime/handler_reply.py +148 -0
- seampact/codegen/runtime/hinted_values.py +48 -0
- seampact/codegen/runtime/hook_check.py +77 -0
- seampact/codegen/runtime/idempotency.py +279 -0
- seampact/codegen/runtime/input_decoding.py +226 -0
- seampact/codegen/runtime/ir_view.py +47 -0
- seampact/codegen/runtime/native_docs.py +130 -0
- seampact/codegen/runtime/page_facts.py +33 -0
- seampact/codegen/runtime/page_reply.py +396 -0
- seampact/codegen/runtime/pattern.py +69 -0
- seampact/codegen/runtime/principal_members.py +41 -0
- seampact/codegen/runtime/public_origin.py +102 -0
- seampact/codegen/runtime/pydantic/__init__.py +165 -0
- seampact/codegen/runtime/pydantic/checked_input.py +52 -0
- seampact/codegen/runtime/pydantic/contract_input.py +183 -0
- seampact/codegen/runtime/pydantic/issues.py +142 -0
- seampact/codegen/runtime/raw_target.py +103 -0
- seampact/codegen/runtime/replies.py +144 -0
- seampact/codegen/runtime/reply_verdict.py +215 -0
- seampact/codegen/runtime/requirement_params.py +170 -0
- seampact/codegen/runtime/response_header_entries.py +226 -0
- seampact/codegen/runtime/response_headers.py +114 -0
- seampact/codegen/runtime/route_match.py +88 -0
- seampact/codegen/runtime/run_settings.py +35 -0
- seampact/codegen/runtime/signature_header.py +92 -0
- seampact/codegen/runtime/sync_driver.py +35 -0
- seampact/codegen/runtime/templates.py +132 -0
- seampact/codegen/runtime/uuid_text.py +7 -0
- seampact/codegen/runtime/value_rules.py +218 -0
- seampact/codegen/runtime/version.py +59 -0
- seampact/codegen/runtime/version_token.py +108 -0
- seampact/codegen/runtime/wire_codec.py +229 -0
- seampact/codegen/runtime/wire_values.py +260 -0
- seampact/codegen/source_lines.py +35 -0
- seampact_codegen-1.0.0.dist-info/METADATA +18 -0
- seampact_codegen-1.0.0.dist-info/RECORD +76 -0
- seampact_codegen-1.0.0.dist-info/WHEEL +4 -0
- seampact_codegen-1.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""The Python names of one api (ADR 0007 sections 4 and 5, ADR 0026 "Naming"): a class per object schema and per
|
|
2
|
+
enum, an attribute per object member and an upper snake case member per enum value. Two members of one object that
|
|
3
|
+
give the same identifier (`2fa` and `field_2fa`) are refused, naming both, and so are two parameters of one part and
|
|
4
|
+
two classes of one module."""
|
|
5
|
+
|
|
6
|
+
from collections.abc import Mapping, Sequence
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
|
|
9
|
+
from seampact.codegen.refusals import IrPlace, Refusals
|
|
10
|
+
from seampact.ir.model import ContractIR, Name, NameRole, ObjectSchema, Param
|
|
11
|
+
from seampact.ir.naming import IrName, NameSource, NamingRole, python_identifier, segments_of
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True, slots=True)
|
|
15
|
+
class ApiNames:
|
|
16
|
+
"""`schema_classes` (object schemas) and `enum_classes` by id; `members` maps each object schema's wire names to
|
|
17
|
+
attributes; `enum_members` each enum value to its member."""
|
|
18
|
+
|
|
19
|
+
schema_classes: Mapping[str, str]
|
|
20
|
+
enum_classes: Mapping[str, str]
|
|
21
|
+
members: Mapping[str, Mapping[str, str]]
|
|
22
|
+
enum_members: Mapping[str, Mapping[str, str]]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def api_names(ir: ContractIR, refusals: Refusals) -> ApiNames:
|
|
26
|
+
schema_classes = {
|
|
27
|
+
schema_id: _type_identifier(schema.name, IrPlace(f"/schemas/{schema_id}/name"), refusals)
|
|
28
|
+
for schema_id, schema in sorted(ir.schemas.items())
|
|
29
|
+
if isinstance(schema, ObjectSchema)
|
|
30
|
+
}
|
|
31
|
+
enum_classes = {
|
|
32
|
+
enum_id: _type_identifier(ir.enums[enum_id].name, IrPlace(f"/enums/{enum_id}/name"), refusals)
|
|
33
|
+
for enum_id in sorted(ir.enums)
|
|
34
|
+
}
|
|
35
|
+
members: dict[str, dict[str, str]] = {}
|
|
36
|
+
enum_members: dict[str, dict[str, str]] = {}
|
|
37
|
+
|
|
38
|
+
_refuse_collisions(
|
|
39
|
+
[(name, IrPlace(f"/schemas/{schema_id}/name")) for schema_id, name in schema_classes.items()]
|
|
40
|
+
+ [(name, IrPlace(f"/enums/{enum_id}/name")) for enum_id, name in enum_classes.items()],
|
|
41
|
+
"class",
|
|
42
|
+
refusals,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
for schema_id, schema in sorted(ir.schemas.items()):
|
|
46
|
+
if isinstance(schema, ObjectSchema):
|
|
47
|
+
members[schema_id] = _object_members(schema_id, schema, refusals)
|
|
48
|
+
|
|
49
|
+
for enum_id, enum in sorted(ir.enums.items()):
|
|
50
|
+
at = IrPlace(f"/enums/{enum_id}")
|
|
51
|
+
enum_members[enum_id] = {
|
|
52
|
+
value: _enum_member(value, position, enum.code_names, at.below("values", position - 1), refusals)
|
|
53
|
+
for position, value in enumerate(enum.values, start=1)
|
|
54
|
+
}
|
|
55
|
+
_refuse_collisions(
|
|
56
|
+
[(member, at.below("values", index)) for index, member in enumerate(enum_members[enum_id].values())],
|
|
57
|
+
"enum member",
|
|
58
|
+
refusals,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
return ApiNames(schema_classes, enum_classes, members, enum_members)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def member_identifier(written: str, code_name: Name | None = None) -> str | None:
|
|
65
|
+
"""The attribute of a member or parameter written `written`; None when the name has no segment."""
|
|
66
|
+
segments = list(code_name.segments) if code_name is not None else segments_of(written, NameSource.NAME, None, None)
|
|
67
|
+
|
|
68
|
+
return None if segments is None else python_identifier(IrName(tuple(segments), NamingRole.MEMBER))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def part_attributes(params: Sequence[Param], at: IrPlace, refusals: Refusals) -> list[tuple[int, Param, str]]:
|
|
72
|
+
"""The position, parameter and attribute of each parameter of one part (path, query or headers) placed at `at`; a
|
|
73
|
+
parameter with no name segment is refused, and so are two that give one attribute."""
|
|
74
|
+
attributes: list[tuple[int, Param, str]] = []
|
|
75
|
+
|
|
76
|
+
for index, param in enumerate(params):
|
|
77
|
+
attribute = member_identifier(param.name)
|
|
78
|
+
|
|
79
|
+
if attribute is None:
|
|
80
|
+
refusals.inconsistent("name.segment.missing", f"The parameter '{param.name}' has no name segment", at)
|
|
81
|
+
continue
|
|
82
|
+
|
|
83
|
+
attributes.append((index, param, attribute))
|
|
84
|
+
|
|
85
|
+
_refuse_collisions([(attribute, at.below(index)) for index, _, attribute in attributes], "parameter", refusals)
|
|
86
|
+
|
|
87
|
+
return attributes
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _object_members(schema_id: str, schema: ObjectSchema, refusals: Refusals) -> dict[str, str]:
|
|
91
|
+
at = IrPlace(f"/schemas/{schema_id}/properties")
|
|
92
|
+
attributes: dict[str, str] = {}
|
|
93
|
+
placed: list[tuple[str, IrPlace]] = []
|
|
94
|
+
|
|
95
|
+
for index, member in enumerate(schema.properties):
|
|
96
|
+
attribute = member_identifier(member.name, member.code_name)
|
|
97
|
+
|
|
98
|
+
if attribute is None:
|
|
99
|
+
refusals.inconsistent("name.segment.missing", f"The member '{member.name}' has no name segment", at)
|
|
100
|
+
continue
|
|
101
|
+
|
|
102
|
+
attributes[member.name] = attribute
|
|
103
|
+
placed.append((attribute, at.below(index)))
|
|
104
|
+
|
|
105
|
+
_refuse_collisions(placed, "member", refusals)
|
|
106
|
+
|
|
107
|
+
return attributes
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _enum_member(
|
|
111
|
+
value: str, position: int, code_names: Mapping[str, Name] | None, at: IrPlace, refusals: Refusals
|
|
112
|
+
) -> str:
|
|
113
|
+
code_name = None if code_names is None else code_names.get(value)
|
|
114
|
+
segments = (
|
|
115
|
+
list(code_name.segments) if code_name is not None else segments_of(value, NameSource.ENUM_VALUE, None, position)
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
if segments is None:
|
|
119
|
+
refusals.inconsistent("name.segment.missing", f"The enum value '{value}' has no name segment", at)
|
|
120
|
+
return value
|
|
121
|
+
|
|
122
|
+
return python_identifier(IrName(tuple(segments), NamingRole.ENUM_MEMBER))
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _type_identifier(name: Name, at: IrPlace, refusals: Refusals) -> str:
|
|
126
|
+
if not isinstance(name.role, NameRole):
|
|
127
|
+
refusals.unknown_value(name.role.raw, at.below("role"))
|
|
128
|
+
return "_"
|
|
129
|
+
|
|
130
|
+
return python_identifier(IrName(tuple(name.segments), NamingRole(name.role.value), name.status))
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _refuse_collisions(placed: list[tuple[str, IrPlace]], kind: str, refusals: Refusals) -> None:
|
|
134
|
+
first_place: dict[str, IrPlace] = {}
|
|
135
|
+
|
|
136
|
+
for identifier, at in placed:
|
|
137
|
+
earlier = first_place.setdefault(identifier, at)
|
|
138
|
+
|
|
139
|
+
if earlier is not at:
|
|
140
|
+
refusals.add(
|
|
141
|
+
"name.collision",
|
|
142
|
+
f"The {kind} at {earlier.pointer} and the {kind} at {at.pointer} both give the Python name "
|
|
143
|
+
f"'{identifier}'.",
|
|
144
|
+
"Rename one of them in the contract, or give one a `codeName`.",
|
|
145
|
+
at,
|
|
146
|
+
)
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""`native_docs.json`, which `nativeDocs` adds beside the placement (ADR 0026 "Native docs"): the operation object of
|
|
2
|
+
each kept operation, taken by its `operationId` from the seampact OpenAPI document the runner hands the generator,
|
|
3
|
+
with the path and method it sits under, and the document's components those operations reach. The runtime places each
|
|
4
|
+
object at that path under the entry's `mount` in the framework's own document (FastAPI's, Django Ninja's)."""
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
from collections.abc import Mapping, Sequence
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import Final
|
|
10
|
+
|
|
11
|
+
from seampact.codegen.refusals import IrPlace, Refusals
|
|
12
|
+
from seampact.ir.config.json_value import JsonObject, JsonValue
|
|
13
|
+
from seampact.ir.run.generator import NativeDocsDocument
|
|
14
|
+
from seampact.ir.write import GeneratedFile
|
|
15
|
+
|
|
16
|
+
NATIVE_DOCS_FILE: Final = "native_docs.json"
|
|
17
|
+
_HTTP_METHODS: Final = ("get", "put", "post", "delete", "options", "head", "patch", "trace")
|
|
18
|
+
_AT: Final = IrPlace("/artefacts/nativeDocs")
|
|
19
|
+
_COMPONENTS: Final = "components"
|
|
20
|
+
_REF: Final = "$ref"
|
|
21
|
+
_COMPONENT_REF_PREFIX: Final = "#/components/"
|
|
22
|
+
_SECURITY: Final = "security"
|
|
23
|
+
_SECURITY_SCHEMES: Final = "securitySchemes"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True, slots=True)
|
|
27
|
+
class FrameworkDocument:
|
|
28
|
+
"""The target whose framework document the operations join, its framework's name, and the schema names that
|
|
29
|
+
document always holds, which a contract schema could never take."""
|
|
30
|
+
|
|
31
|
+
target: str
|
|
32
|
+
framework: str
|
|
33
|
+
owned_schemas: tuple[str, ...] = ()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def native_docs_file(
|
|
37
|
+
native_docs: NativeDocsDocument | None,
|
|
38
|
+
kept_operations: Mapping[str, object],
|
|
39
|
+
framework_document: FrameworkDocument,
|
|
40
|
+
refusals: Refusals,
|
|
41
|
+
) -> GeneratedFile | None:
|
|
42
|
+
"""None, with a refusal, when the runner handed no document or the document lacks a kept operation."""
|
|
43
|
+
if native_docs is None:
|
|
44
|
+
refusals.add(
|
|
45
|
+
"artefacts.nativeDocs.document-missing",
|
|
46
|
+
f"The runner handed the {framework_document.target} target no seampact OpenAPI document, which "
|
|
47
|
+
"'nativeDocs: true' needs.",
|
|
48
|
+
f"Update seampact-ir to the release of seampact-{framework_document.target}, whose runner hands the "
|
|
49
|
+
"document.",
|
|
50
|
+
_AT,
|
|
51
|
+
)
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
found = _operations_by_id(native_docs.document)
|
|
55
|
+
missing = [operation_id for operation_id in kept_operations if operation_id not in found]
|
|
56
|
+
|
|
57
|
+
if missing:
|
|
58
|
+
refusals.add(
|
|
59
|
+
"artefacts.nativeDocs.operation-missing",
|
|
60
|
+
f"The seampact OpenAPI document '{native_docs.name or '(the api)'}' holds no operation "
|
|
61
|
+
f"'{"', '".join(missing)}', which the entry generates.",
|
|
62
|
+
"Set the entry's `document` to a document that holds every operation it generates, or switch "
|
|
63
|
+
"'nativeDocs' off.",
|
|
64
|
+
_AT,
|
|
65
|
+
)
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
operations = {operation_id: found[operation_id] for operation_id in kept_operations}
|
|
69
|
+
components = native_docs.document.get(_COMPONENTS)
|
|
70
|
+
reached = reached_components(list(operations.values()), components if isinstance(components, dict) else {})
|
|
71
|
+
|
|
72
|
+
if _refuse_framework_clashes(reached, framework_document, refusals):
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
content = {"operations": operations, _COMPONENTS: reached}
|
|
76
|
+
|
|
77
|
+
return GeneratedFile(NATIVE_DOCS_FILE, json.dumps(content, indent=2, sort_keys=True, ensure_ascii=False) + "\n")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _refuse_framework_clashes(
|
|
81
|
+
reached: Mapping[str, object], framework_document: FrameworkDocument, refusals: Refusals
|
|
82
|
+
) -> bool:
|
|
83
|
+
"""The framework's document owns these schemas, so a contract schema of the same name could never join it."""
|
|
84
|
+
schemas = reached.get("schemas")
|
|
85
|
+
framework = framework_document.framework
|
|
86
|
+
clashing = [name for name in framework_document.owned_schemas if isinstance(schemas, dict) and name in schemas]
|
|
87
|
+
|
|
88
|
+
for name in clashing:
|
|
89
|
+
refusals.add(
|
|
90
|
+
"artefacts.nativeDocs.component-clash",
|
|
91
|
+
f"The contract schema '{name}' has the name of a component {framework}'s own document holds, so "
|
|
92
|
+
"'nativeDocs' cannot add it.",
|
|
93
|
+
f"Switch 'nativeDocs' off for this api, or rename the schema in the contract ({framework} owns the name "
|
|
94
|
+
f"'{name}', so its model cannot be renamed).",
|
|
95
|
+
_AT,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
return bool(clashing)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _operations_by_id(document: JsonObject) -> dict[str, JsonValue]:
|
|
102
|
+
"""Each operation object of the document's `paths`, by its `operationId`, with its path and method."""
|
|
103
|
+
paths = document.get("paths")
|
|
104
|
+
found: dict[str, JsonValue] = {}
|
|
105
|
+
|
|
106
|
+
for path, item in (paths if isinstance(paths, dict) else {}).items():
|
|
107
|
+
if not isinstance(item, dict):
|
|
108
|
+
continue
|
|
109
|
+
|
|
110
|
+
for method in _HTTP_METHODS:
|
|
111
|
+
operation = item.get(method)
|
|
112
|
+
operation_id = operation.get("operationId") if isinstance(operation, dict) else None
|
|
113
|
+
|
|
114
|
+
if isinstance(operation_id, str):
|
|
115
|
+
found[operation_id] = {"path": path, "method": method, "operation": operation}
|
|
116
|
+
|
|
117
|
+
return found
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def reached_components(operations: Sequence[object], components: Mapping[str, object]) -> dict[str, object]:
|
|
121
|
+
"""The components the operations reach: each `$ref` into `#/components/`, followed through the components it
|
|
122
|
+
names, and each security scheme an operation's `security` names."""
|
|
123
|
+
reached: dict[tuple[str, str], object] = {}
|
|
124
|
+
pending: list[object] = list(operations)
|
|
125
|
+
|
|
126
|
+
for native in operations:
|
|
127
|
+
operation = native.get("operation") if isinstance(native, dict) else None
|
|
128
|
+
requirements = operation.get(_SECURITY) if isinstance(operation, dict) else None
|
|
129
|
+
|
|
130
|
+
for requirement in requirements if isinstance(requirements, list) else []:
|
|
131
|
+
for scheme in requirement if isinstance(requirement, dict) else {}:
|
|
132
|
+
_reach(components, _SECURITY_SCHEMES, scheme, reached, pending)
|
|
133
|
+
|
|
134
|
+
while pending:
|
|
135
|
+
value = pending.pop()
|
|
136
|
+
|
|
137
|
+
if isinstance(value, list):
|
|
138
|
+
pending.extend(value)
|
|
139
|
+
elif isinstance(value, dict):
|
|
140
|
+
reference = value.get(_REF)
|
|
141
|
+
|
|
142
|
+
if isinstance(reference, str) and reference.startswith(_COMPONENT_REF_PREFIX):
|
|
143
|
+
kind, _, name = reference.removeprefix(_COMPONENT_REF_PREFIX).partition("/")
|
|
144
|
+
_reach(components, kind, name, reached, pending)
|
|
145
|
+
|
|
146
|
+
pending.extend(value.values())
|
|
147
|
+
|
|
148
|
+
kept: dict[str, dict[str, object]] = {}
|
|
149
|
+
|
|
150
|
+
for (kind, name), component in sorted(reached.items()):
|
|
151
|
+
kept.setdefault(kind, {})[name] = component
|
|
152
|
+
|
|
153
|
+
return dict(kept)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _reach(
|
|
157
|
+
components: Mapping[str, object],
|
|
158
|
+
kind: str,
|
|
159
|
+
name: str,
|
|
160
|
+
reached: dict[tuple[str, str], object],
|
|
161
|
+
pending: list[object],
|
|
162
|
+
) -> None:
|
|
163
|
+
named = components.get(kind)
|
|
164
|
+
|
|
165
|
+
if (kind, name) in reached or not isinstance(named, dict) or name not in named:
|
|
166
|
+
return
|
|
167
|
+
|
|
168
|
+
reached[(kind, name)] = named[name]
|
|
169
|
+
pending.append(named[name])
|