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,196 @@
1
+ """The deterministic line printer of generated Python modules: a docstring, the imports as `import x as _x` sorted in
2
+ ruff's sections (standard library, third party, seampact, then the api's own modules as `from . import x as _x`),
3
+ then top-level blocks two blank lines apart. Output never
4
+ depends on the installed Python's `ast.unparse`; the golden files check it against `ruff format --check`."""
5
+
6
+ import re
7
+ import sys
8
+ from collections.abc import Sequence
9
+ from dataclasses import dataclass, field
10
+ from typing import Final
11
+
12
+ LINE_LENGTH: Final = 120
13
+ INDENT: Final = " "
14
+
15
+ _FIRST_PARTY: Final = "seampact"
16
+ _SIBLING_PREFIX: Final = "."
17
+ _SECTIONS: Final = 4
18
+ _DEFINITIONS: Final = ("class ", "def ", "@")
19
+
20
+
21
+ _DOUBLE_QUOTE: Final = '"'
22
+ _SINGLE_QUOTE: Final = "'"
23
+ _NAMED_ESCAPES: Final = {"\\": "\\\\", "\n": "\\n", "\r": "\\r", "\t": "\\t"}
24
+ _LAST_PRINTABLE_ASCII: Final = 0x7E
25
+ _FIRST_PRINTABLE_ASCII: Final = 0x20
26
+ _LAST_BMP: Final = 0xFFFF
27
+ # Each piece of a long string ends after a space or a slash, where a part may end.
28
+ _BREAK_AFTER: Final = re.compile(r"[^ /]*[ /]|[^ /]+")
29
+
30
+
31
+ def string_literal(text: str) -> str:
32
+ """A Python string literal of `text`, ASCII only, quoted as ruff quotes it: double quotes unless the text holds more
33
+ double quotes than single ones; a character outside the Basic Multilingual Plane is one `\\U` escape."""
34
+ quote = _SINGLE_QUOTE if text.count(_DOUBLE_QUOTE) > text.count(_SINGLE_QUOTE) else _DOUBLE_QUOTE
35
+
36
+ return quote + "".join(_escaped(character, quote) for character in text) + quote
37
+
38
+
39
+ def _escaped(character: str, quote: str) -> str:
40
+ if character == quote:
41
+ return "\\" + character
42
+
43
+ named = _NAMED_ESCAPES.get(character)
44
+
45
+ if named is not None:
46
+ return named
47
+
48
+ code_point = ord(character)
49
+
50
+ if _FIRST_PRINTABLE_ASCII <= code_point <= _LAST_PRINTABLE_ASCII:
51
+ return character
52
+
53
+ if code_point < _FIRST_PRINTABLE_ASCII or code_point == _LAST_PRINTABLE_ASCII + 1:
54
+ return f"\\x{code_point:02x}"
55
+
56
+ return f"\\u{code_point:04x}" if code_point <= _LAST_BMP else f"\\U{code_point:08x}"
57
+
58
+
59
+ def literal_lines(indent: str, head: str, value: object, tail: str) -> list[str]:
60
+ """`head`, the Python literal of the JSON value `value`, then `tail`, at `indent`, as ruff formats it: a non-empty
61
+ object or array one item per line with a trailing comma, which keeps it so, and a string too long for its line
62
+ split at spaces into parenthesised parts."""
63
+ if isinstance(value, dict):
64
+ items = [
65
+ line
66
+ for key, item in value.items()
67
+ for line in literal_lines(indent + INDENT, f"{string_literal(str(key))}: ", item, ",")
68
+ ]
69
+
70
+ return _bracketed_items(indent, head, "{", items, "}", tail)
71
+
72
+ if isinstance(value, list | tuple):
73
+ items = [line for item in value for line in literal_lines(indent + INDENT, "", item, ",")]
74
+
75
+ return _bracketed_items(indent, head, "[", items, "]", tail)
76
+
77
+ if isinstance(value, str):
78
+ return _string_lines(indent, head, value, tail)
79
+
80
+ return [f"{indent}{head}{_scalar_literal(value)}{tail}"]
81
+
82
+
83
+ def _bracketed_items(indent: str, head: str, opening: str, items: list[str], closing: str, tail: str) -> list[str]:
84
+ if not items:
85
+ return [f"{indent}{head}{opening}{closing}{tail}"]
86
+
87
+ return [f"{indent}{head}{opening}", *items, f"{indent}{closing}{tail}"]
88
+
89
+
90
+ def _string_lines(indent: str, head: str, text: str, tail: str) -> list[str]:
91
+ single = f"{indent}{head}{string_literal(text)}{tail}"
92
+
93
+ if fits(single):
94
+ return [single]
95
+
96
+ parts = _string_parts(text, len(indent) + len(INDENT))
97
+
98
+ return [f"{indent}{head}(", *(f"{indent}{INDENT}{string_literal(part)}" for part in parts), f"{indent}){tail}"]
99
+
100
+
101
+ def _string_parts(text: str, indent_width: int) -> list[str]:
102
+ """`text` cut after spaces and slashes into parts whose literal fits a line at `indent_width`."""
103
+ parts: list[str] = []
104
+ current = ""
105
+
106
+ for piece in _BREAK_AFTER.findall(text):
107
+ if current and indent_width + len(string_literal(current + piece)) > LINE_LENGTH:
108
+ parts.append(current)
109
+ current = piece
110
+ else:
111
+ current += piece
112
+
113
+ return [*parts, current]
114
+
115
+
116
+ def _scalar_literal(value: object) -> str:
117
+ if value is None or isinstance(value, bool | int | float):
118
+ return repr(value)
119
+
120
+ raise TypeError(f"seampact-codegen: {type(value).__name__} is not a JSON value.")
121
+
122
+
123
+ @dataclass(slots=True)
124
+ class PythonModule:
125
+ docstring: str
126
+ _imports: dict[str, str] = field(default_factory=dict)
127
+ _blocks: list[list[str]] = field(default_factory=list)
128
+
129
+ def imported(self, module: str, alias: str) -> str:
130
+ """Imports `module` under a private alias, so no framework name sits bare beside contract names (ADR 0026
131
+ "Naming"); returns the alias. A module written `.name` is a sibling module of the api's package."""
132
+ self._imports[module] = alias
133
+
134
+ return alias
135
+
136
+ def add_block(self, lines: Sequence[str]) -> None:
137
+ self._blocks.append(list(lines))
138
+
139
+ def text(self) -> str:
140
+ sections = [
141
+ [line for module, line in self._import_lines() if _section(module) == section]
142
+ for section in range(_SECTIONS)
143
+ ]
144
+ parts = [f'"""{self.docstring}"""', *("\n".join(lines) for lines in sections if lines)]
145
+ head = "\n\n".join(parts)
146
+ body = "\n\n\n".join("\n".join(block) for block in self._blocks)
147
+
148
+ if not body:
149
+ return f"{head}\n"
150
+
151
+ # ruff's import sorting leaves two blank lines before a class or function, one before a statement.
152
+ separator = "\n\n\n" if body.startswith(_DEFINITIONS) else "\n\n"
153
+
154
+ return f"{head}{separator}{body}\n"
155
+
156
+ def _import_lines(self) -> list[tuple[str, str]]:
157
+ return [(module, _import_line(module, self._imports[module])) for module in sorted(self._imports)]
158
+
159
+
160
+ def _import_line(module: str, alias: str) -> str:
161
+ if module.startswith(_SIBLING_PREFIX):
162
+ return f"from {_SIBLING_PREFIX} import {module.removeprefix(_SIBLING_PREFIX)} as {alias}"
163
+
164
+ return f"import {module} as {alias}"
165
+
166
+
167
+ def _section(module: str) -> int:
168
+ """ruff's import sections: the standard library, third party, seampact (first party), the api's own modules."""
169
+ if module.startswith(_SIBLING_PREFIX):
170
+ return 3
171
+
172
+ top = module.split(".", 1)[0]
173
+
174
+ if top == _FIRST_PARTY:
175
+ return 2
176
+
177
+ return 0 if top in sys.stdlib_module_names else 1
178
+
179
+
180
+ def bracketed(indent: str, head: str, arguments: Sequence[str], tail: str) -> list[str]:
181
+ """`head(arguments)tail` at `indent`, split as ruff splits it."""
182
+ single = f"{indent}{head}({', '.join(arguments)}){tail}"
183
+
184
+ if fits(single):
185
+ return [single]
186
+
187
+ hugged = f"{indent}{INDENT}{', '.join(arguments)}"
188
+
189
+ if fits(hugged) and not any(argument == "*" for argument in arguments):
190
+ return [f"{indent}{head}(", hugged, f"{indent}){tail}"]
191
+
192
+ return [f"{indent}{head}(", *(f"{indent}{INDENT}{argument}," for argument in arguments), f"{indent}){tail}"]
193
+
194
+
195
+ def fits(line: str) -> bool:
196
+ return len(line) <= LINE_LENGTH
File without changes
@@ -0,0 +1,53 @@
1
+ """The contract forms the Pydantic targets, FastAPI and Ninja, do not generate yet (ADR 0026 "Refused by name or
2
+ waiting"), each refused by name: a body other than JSON, a binary or redirect response, native access, a page fact other
3
+ than those the page class carries, and a sort key restricted by a right. The forms no Python target serves are refused
4
+ by `seampact.codegen.kept_forms`, and unions and other schema forms by the shared Pydantic emitter."""
5
+
6
+ from seampact.codegen.refusals import Delivery, IrPlace, Refusals
7
+ from seampact.codegen.restricted_sort_keys import refuse_restricted_sort_keys
8
+ from seampact.ir.model import (
9
+ ContractIR,
10
+ IdentityAccess,
11
+ InputMediaType,
12
+ OptionalAccess,
13
+ PublicAccess,
14
+ ResponseShape,
15
+ )
16
+
17
+ _SERVED_SHAPES = frozenset(
18
+ {ResponseShape.ITEM, ResponseShape.TABLE, ResponseShape.LIST, ResponseShape.PAGE, ResponseShape.EMPTY}
19
+ )
20
+
21
+
22
+ def refuse_unserved_forms(ir: ContractIR, refusals: Refusals) -> None:
23
+ for index, operation in enumerate(ir.operations):
24
+ at = IrPlace(f"/operations/{index}", operation.id)
25
+
26
+ if operation.input is not None and operation.input.media is not InputMediaType.APPLICATION_JSON:
27
+ refusals.waiting(
28
+ "input.media",
29
+ f"A '{operation.input.media}' request body",
30
+ at.below("input", "media"),
31
+ Delivery.CONSUMER,
32
+ )
33
+
34
+ for position, response in enumerate(operation.responses):
35
+ if isinstance(response.shape, ResponseShape) and response.shape not in _SERVED_SHAPES:
36
+ refusals.waiting(
37
+ f"response.{response.shape.value}",
38
+ f"A '{response.shape.value}' response",
39
+ at.below("responses", position, "shape"),
40
+ Delivery.CONSUMER,
41
+ )
42
+ elif not isinstance(response.shape, ResponseShape):
43
+ refusals.unknown_value(response.shape.raw, at.below("responses", position, "shape"))
44
+
45
+ if not isinstance(operation.access, IdentityAccess | OptionalAccess | PublicAccess):
46
+ refusals.waiting(
47
+ f"access.{operation.access.kind}",
48
+ f"The access kind '{operation.access.kind}'",
49
+ at.below("access"),
50
+ Delivery.CONSUMER,
51
+ )
52
+
53
+ refuse_restricted_sort_keys(operation, at, refusals)
@@ -0,0 +1,178 @@
1
+ """The Python names of one api's package on the Pydantic targets, FastAPI and Ninja (ADR 0026 "Placement"): per
2
+ operation its request, input parts, results, typed errors, page and scopes, and its handler method; the credentials
3
+ class of each identity an operation reads, with one attribute per scheme; the endpoints protocol of each resource (an
4
+ operation without a resource joins its surface's); one exception per error code and the api's contract exception base;
5
+ and the placement's own names. Two classes of one module taking one name are refused as `name.collision`."""
6
+
7
+ from collections.abc import Mapping
8
+ from dataclasses import dataclass
9
+
10
+ from seampact.codegen.credentials_classes import CredentialsNames, identity_credentials
11
+ from seampact.codegen.refusals import IrPlace, Refusals
12
+ from seampact.ir.model import ContractIR, Operation, ResponseShape
13
+ from seampact.ir.naming import IrName, NameSource, NamingRole, python_identifier, segments_of
14
+
15
+
16
+ @dataclass(frozen=True, slots=True)
17
+ class OperationNames:
18
+ method: str
19
+ request: str
20
+ input: str
21
+ result: str
22
+ errors: str
23
+ responses: Mapping[int, str]
24
+ page: str | None
25
+ scopes: str | None
26
+ endpoints: str
27
+ owner_attribute: str
28
+
29
+
30
+ @dataclass(frozen=True, slots=True)
31
+ class ApiLayout:
32
+ contract_error: str
33
+ handler_factories: str
34
+ unexpected_error_listener: str
35
+ bind_function: str
36
+ error_classes: Mapping[str, str]
37
+ error_factories: Mapping[str, str]
38
+ operations: Mapping[str, OperationNames]
39
+ credentials: Mapping[str, CredentialsNames]
40
+
41
+ def endpoints_owners(self) -> dict[str, str]:
42
+ """Each endpoints protocol and the attribute of `<Api>HandlerFactories` that builds it, in operation order."""
43
+ return {names.endpoints: names.owner_attribute for names in self.operations.values()}
44
+
45
+
46
+ class _Claims:
47
+ """The names of one module, each with what took it first."""
48
+
49
+ def __init__(self, module: str, refusals: Refusals) -> None:
50
+ self._module = module
51
+ self._refusals = refusals
52
+ self._owners: dict[str, str] = {}
53
+
54
+ def claim(self, name: str, owner: str, at: IrPlace) -> str:
55
+ previous = self._owners.setdefault(name, owner)
56
+
57
+ if previous != owner:
58
+ self._refusals.add(
59
+ "name.collision",
60
+ f"The Python name '{name}' of the {self._module} module would be written for both {previous} and "
61
+ f"{owner}.",
62
+ "Rename one of them in the contract (a schema name, an operation's `operationId` or an error code).",
63
+ at,
64
+ )
65
+
66
+ return name
67
+
68
+
69
+ def api_layout(ir: ContractIR, error_codes: list[str], refusals: Refusals) -> ApiLayout:
70
+ root = _Claims("operation types", refusals)
71
+ api_segments = tuple(ir.api.name.segments)
72
+ at_api = IrPlace("/api/name")
73
+ contract_error = root.claim(
74
+ python_identifier(IrName((*api_segments, "contract"), NamingRole.ERROR)), "the api's contract error", at_api
75
+ )
76
+ handler_factories = root.claim(
77
+ python_identifier(IrName((*api_segments, "handler", "factories"), NamingRole.SCHEMA)),
78
+ "the api's handler factories",
79
+ at_api,
80
+ )
81
+ unexpected_error_listener = root.claim(
82
+ python_identifier(IrName((*api_segments, "unexpected", "error", "listener"), NamingRole.SCHEMA)),
83
+ "the api's unexpected-error listener",
84
+ at_api,
85
+ )
86
+ error_classes: dict[str, str] = {}
87
+ error_factories: dict[str, str] = {}
88
+
89
+ for code in error_codes:
90
+ at = IrPlace(f"/errors/{code}")
91
+ segments = segments_of(code, NameSource.NAME)
92
+
93
+ if not segments:
94
+ refusals.inconsistent("error.code.no-segment", f"The error code '{code}' yields no name segment", at)
95
+ continue
96
+
97
+ error_classes[code] = root.claim(
98
+ python_identifier(IrName(tuple(segments), NamingRole.ERROR)), f"the exception of error '{code}'", at
99
+ )
100
+ error_factories[code] = python_identifier(IrName(tuple(segments), NamingRole.MEMBER))
101
+
102
+ operations: dict[str, OperationNames] = {}
103
+
104
+ for index, operation in enumerate(ir.operations):
105
+ at = IrPlace(f"/operations/{index}", operation.id)
106
+ owner = _endpoints_owner(ir, operation)
107
+
108
+ if not owner:
109
+ refusals.inconsistent(
110
+ "endpoints.owner.no-segment",
111
+ f"The operation '{operation.id}' has no resource or surface that yields a name segment",
112
+ at.below("surface"),
113
+ )
114
+ continue
115
+
116
+ operations[operation.id] = _operation_names(operation, tuple(owner), root, at)
117
+
118
+ return ApiLayout(
119
+ contract_error,
120
+ handler_factories,
121
+ unexpected_error_listener,
122
+ python_identifier(IrName(("bind", *api_segments), NamingRole.OPERATION)),
123
+ error_classes,
124
+ error_factories,
125
+ operations,
126
+ identity_credentials(ir, operations, root.claim, refusals),
127
+ )
128
+
129
+
130
+ def _operation_names(operation: Operation, owner: tuple[str, ...], root: _Claims, at: IrPlace) -> OperationNames:
131
+ segments = tuple(operation.name.segments)
132
+ owned = f"a type of operation '{operation.id}'"
133
+
134
+ def in_root(extra: tuple[str, ...], role: NamingRole = NamingRole.SCHEMA) -> str:
135
+ return root.claim(python_identifier(IrName((*segments, *extra), role)), owned, at.below("name"))
136
+
137
+ responses = {
138
+ response.status: root.claim(
139
+ python_identifier(IrName(segments, NamingRole.RESPONSE, response.status)),
140
+ f"the {response.status} result of operation '{operation.id}'",
141
+ at.below("name"),
142
+ )
143
+ for response in operation.responses
144
+ }
145
+ paged = operation.paging is not None and any(
146
+ response.shape is ResponseShape.PAGE for response in operation.responses
147
+ )
148
+ return OperationNames(
149
+ method=python_identifier(IrName(segments, NamingRole.OPERATION)),
150
+ request=in_root(("request",)),
151
+ input=in_root((), NamingRole.INPUT),
152
+ result=in_root(("result",)),
153
+ errors=in_root((), NamingRole.ERRORS),
154
+ responses=responses,
155
+ page=in_root(("page",)) if paged else None,
156
+ scopes=in_root(("scopes",)) if operation.scopes else None,
157
+ endpoints=root.claim(
158
+ python_identifier(IrName(owner, NamingRole.ENDPOINTS)),
159
+ f"the endpoints protocol of '{'-'.join(owner)}'",
160
+ at.below("name"),
161
+ ),
162
+ owner_attribute=python_identifier(IrName(owner, NamingRole.MEMBER)),
163
+ )
164
+
165
+
166
+ def _endpoints_owner(ir: ContractIR, operation: Operation) -> list[str] | None:
167
+ if operation.resource is not None:
168
+ segments = segments_of(operation.resource.type, NameSource.NAME)
169
+
170
+ if segments:
171
+ return segments
172
+
173
+ surface = ir.api.surfaces.get(operation.surface)
174
+
175
+ if surface is not None:
176
+ return list(surface.name.segments)
177
+
178
+ return segments_of(operation.surface, NameSource.NAME)