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.
Files changed (76) hide show
  1. seampact/codegen/__init__.py +2 -0
  2. seampact/codegen/boot_check_hooks.py +38 -0
  3. seampact/codegen/contract_module.py +146 -0
  4. seampact/codegen/credentials_classes.py +99 -0
  5. seampact/codegen/handler_forms.py +132 -0
  6. seampact/codegen/input_schemas.py +130 -0
  7. seampact/codegen/kept_forms.py +522 -0
  8. seampact/codegen/matrix_row.py +23 -0
  9. seampact/codegen/names.py +146 -0
  10. seampact/codegen/native_docs_file.py +169 -0
  11. seampact/codegen/operation_types.py +425 -0
  12. seampact/codegen/placement_lines.py +79 -0
  13. seampact/codegen/printer.py +196 -0
  14. seampact/codegen/py.typed +0 -0
  15. seampact/codegen/pydantic_forms.py +53 -0
  16. seampact/codegen/pydantic_layout.py +178 -0
  17. seampact/codegen/pydantic_models.py +405 -0
  18. seampact/codegen/redacted_members.py +47 -0
  19. seampact/codegen/refusals.py +84 -0
  20. seampact/codegen/request_deadline.py +68 -0
  21. seampact/codegen/response_header_forms.py +73 -0
  22. seampact/codegen/restricted_sort_keys.py +18 -0
  23. seampact/codegen/route_helpers.py +109 -0
  24. seampact/codegen/routes.py +139 -0
  25. seampact/codegen/runtime/__init__.py +7 -0
  26. seampact/codegen/runtime/access_kinds.py +24 -0
  27. seampact/codegen/runtime/access_steps.py +520 -0
  28. seampact/codegen/runtime/body_reader.py +64 -0
  29. seampact/codegen/runtime/cache_headers.py +89 -0
  30. seampact/codegen/runtime/contract.py +232 -0
  31. seampact/codegen/runtime/cors_preflight.py +20 -0
  32. seampact/codegen/runtime/deadline.py +72 -0
  33. seampact/codegen/runtime/digests.py +98 -0
  34. seampact/codegen/runtime/failures.py +33 -0
  35. seampact/codegen/runtime/field_rights.py +193 -0
  36. seampact/codegen/runtime/framework_routes.py +36 -0
  37. seampact/codegen/runtime/framework_versions.py +91 -0
  38. seampact/codegen/runtime/handler_call.py +31 -0
  39. seampact/codegen/runtime/handler_reply.py +148 -0
  40. seampact/codegen/runtime/hinted_values.py +48 -0
  41. seampact/codegen/runtime/hook_check.py +77 -0
  42. seampact/codegen/runtime/idempotency.py +279 -0
  43. seampact/codegen/runtime/input_decoding.py +226 -0
  44. seampact/codegen/runtime/ir_view.py +47 -0
  45. seampact/codegen/runtime/native_docs.py +130 -0
  46. seampact/codegen/runtime/page_facts.py +33 -0
  47. seampact/codegen/runtime/page_reply.py +396 -0
  48. seampact/codegen/runtime/pattern.py +69 -0
  49. seampact/codegen/runtime/principal_members.py +41 -0
  50. seampact/codegen/runtime/public_origin.py +102 -0
  51. seampact/codegen/runtime/pydantic/__init__.py +165 -0
  52. seampact/codegen/runtime/pydantic/checked_input.py +52 -0
  53. seampact/codegen/runtime/pydantic/contract_input.py +183 -0
  54. seampact/codegen/runtime/pydantic/issues.py +142 -0
  55. seampact/codegen/runtime/raw_target.py +103 -0
  56. seampact/codegen/runtime/replies.py +144 -0
  57. seampact/codegen/runtime/reply_verdict.py +215 -0
  58. seampact/codegen/runtime/requirement_params.py +170 -0
  59. seampact/codegen/runtime/response_header_entries.py +226 -0
  60. seampact/codegen/runtime/response_headers.py +114 -0
  61. seampact/codegen/runtime/route_match.py +88 -0
  62. seampact/codegen/runtime/run_settings.py +35 -0
  63. seampact/codegen/runtime/signature_header.py +92 -0
  64. seampact/codegen/runtime/sync_driver.py +35 -0
  65. seampact/codegen/runtime/templates.py +132 -0
  66. seampact/codegen/runtime/uuid_text.py +7 -0
  67. seampact/codegen/runtime/value_rules.py +218 -0
  68. seampact/codegen/runtime/version.py +59 -0
  69. seampact/codegen/runtime/version_token.py +108 -0
  70. seampact/codegen/runtime/wire_codec.py +229 -0
  71. seampact/codegen/runtime/wire_values.py +260 -0
  72. seampact/codegen/source_lines.py +35 -0
  73. seampact_codegen-1.0.0.dist-info/METADATA +18 -0
  74. seampact_codegen-1.0.0.dist-info/RECORD +76 -0
  75. seampact_codegen-1.0.0.dist-info/WHEEL +4 -0
  76. 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])