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,132 @@
|
|
|
1
|
+
"""Renders an IR template (ADR 0004 section 8) over its slots: the format's error body, a role's params and a
|
|
2
|
+
success envelope. A slot with no value renders as missing; an object member that renders as missing is left out, or
|
|
3
|
+
written as null when the format says `absent: null`."""
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Final
|
|
8
|
+
|
|
9
|
+
from seampact.codegen.runtime.ir_view import IrMap, ir_list, ir_map, ir_maps, ir_string, ir_strings
|
|
10
|
+
|
|
11
|
+
_ITEM_SLOT: Final = "item"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True, slots=True)
|
|
15
|
+
class Rendered:
|
|
16
|
+
present: bool
|
|
17
|
+
value: object = None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
_MISSING: Final = Rendered(False)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def render(template: object, slots: Mapping[str, object], write_nulls: bool) -> Rendered:
|
|
24
|
+
"""Raises `LookupError` for a template kind the boundary does not render."""
|
|
25
|
+
kind = ir_string(template, "kind")
|
|
26
|
+
|
|
27
|
+
if kind == "object":
|
|
28
|
+
return _object(template if isinstance(template, Mapping) else {}, slots, write_nulls)
|
|
29
|
+
|
|
30
|
+
if kind == "array":
|
|
31
|
+
items = [render(item, slots, write_nulls) for item in ir_list(template, "items")]
|
|
32
|
+
return Rendered(True, [item.value for item in items if item.present])
|
|
33
|
+
|
|
34
|
+
if kind == "each":
|
|
35
|
+
return _each(template, slots, write_nulls)
|
|
36
|
+
|
|
37
|
+
if kind == "slot":
|
|
38
|
+
return _slot(template, slots)
|
|
39
|
+
|
|
40
|
+
if kind == "text":
|
|
41
|
+
return _text(template, slots)
|
|
42
|
+
|
|
43
|
+
if kind == "literal":
|
|
44
|
+
return Rendered(True, template.get("value") if isinstance(template, Mapping) else None)
|
|
45
|
+
|
|
46
|
+
raise LookupError(f"seampact: the template kind {kind!r} is not one the boundary renders.")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _object(template: IrMap, slots: Mapping[str, object], write_nulls: bool) -> Rendered:
|
|
50
|
+
rendered: dict[str, object] = {}
|
|
51
|
+
|
|
52
|
+
for member in ir_maps(template, "members"):
|
|
53
|
+
name = _member_name(ir_map(member, "name"), slots)
|
|
54
|
+
|
|
55
|
+
if name is None:
|
|
56
|
+
continue
|
|
57
|
+
|
|
58
|
+
value = render(member.get("value"), slots, write_nulls)
|
|
59
|
+
|
|
60
|
+
if value.present:
|
|
61
|
+
rendered[name] = value.value
|
|
62
|
+
elif write_nulls:
|
|
63
|
+
rendered[name] = None
|
|
64
|
+
|
|
65
|
+
merged = slots.get(ir_string(template, "merge") or "")
|
|
66
|
+
|
|
67
|
+
if isinstance(merged, Mapping):
|
|
68
|
+
rendered.update({str(key): value for key, value in merged.items()})
|
|
69
|
+
|
|
70
|
+
return Rendered(True, rendered) if rendered else _MISSING
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _member_name(name: IrMap, slots: Mapping[str, object]) -> str | None:
|
|
74
|
+
if ir_string(name, "kind") == "text":
|
|
75
|
+
return ir_string(name, "text")
|
|
76
|
+
|
|
77
|
+
value = slots.get(ir_string(name, "slot") or "")
|
|
78
|
+
|
|
79
|
+
return str(value) if isinstance(value, str | int) and not isinstance(value, bool) else None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _each(template: object, slots: Mapping[str, object], write_nulls: bool) -> Rendered:
|
|
83
|
+
source = slots.get(ir_string(template, "of") or "")
|
|
84
|
+
|
|
85
|
+
if not isinstance(source, list | tuple):
|
|
86
|
+
return _MISSING
|
|
87
|
+
|
|
88
|
+
items = [render(ir_map(template, "as"), {**slots, _ITEM_SLOT: item}, write_nulls) for item in source]
|
|
89
|
+
|
|
90
|
+
return Rendered(True, [item.value for item in items if item.present])
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _text(template: object, slots: Mapping[str, object]) -> Rendered:
|
|
94
|
+
text = ""
|
|
95
|
+
|
|
96
|
+
for part in ir_maps(template, "parts"):
|
|
97
|
+
if ir_string(part, "kind") == "text":
|
|
98
|
+
text += ir_string(part, "text") or ""
|
|
99
|
+
continue
|
|
100
|
+
|
|
101
|
+
value = _slot(part, slots)
|
|
102
|
+
|
|
103
|
+
if not value.present or not isinstance(value.value, str | int | float | bool):
|
|
104
|
+
return _MISSING
|
|
105
|
+
|
|
106
|
+
text += _scalar_text(value.value)
|
|
107
|
+
|
|
108
|
+
return Rendered(True, text)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _scalar_text(value: str | int | float | bool) -> str:
|
|
112
|
+
if isinstance(value, bool):
|
|
113
|
+
return "true" if value else "false"
|
|
114
|
+
|
|
115
|
+
return str(value)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _slot(template: object, slots: Mapping[str, object]) -> Rendered:
|
|
119
|
+
name = ir_string(template, "slot") or ""
|
|
120
|
+
|
|
121
|
+
if name not in slots:
|
|
122
|
+
return _MISSING
|
|
123
|
+
|
|
124
|
+
value = slots[name]
|
|
125
|
+
|
|
126
|
+
for key in ir_strings(template, "path"):
|
|
127
|
+
if not isinstance(value, Mapping) or key not in value:
|
|
128
|
+
return _MISSING
|
|
129
|
+
|
|
130
|
+
value = value[key]
|
|
131
|
+
|
|
132
|
+
return _MISSING if value is None else Rendered(True, value)
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""The text of a UUID as the contract's `uuid` format takes it: 8-4-4-4-12 hexadecimal digits in either case, shared
|
|
2
|
+
by the access steps' type gate and the Pydantic type layer."""
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
from typing import Final
|
|
6
|
+
|
|
7
|
+
UUID_TEXT: Final = re.compile(r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}")
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""The framework-free type layer and value rules of ADR 0003: a decoded wire value checked against its scalar type and
|
|
2
|
+
turned into the Python object the handler reads (a whole-number float into an `int`; `date-time`, `date`, `uuid`
|
|
3
|
+
and decimal strings into their objects), and the length, bound and `multipleOf` rules of `rules:
|
|
4
|
+
seampact-consistent`. Each refusal raises `ValueRuleError` with the contract rule's name; a placement turns it into
|
|
5
|
+
its framework's error (Pydantic's, DRF's) or an issue of the boundary's `schemaInvalid`."""
|
|
6
|
+
|
|
7
|
+
import math
|
|
8
|
+
import re
|
|
9
|
+
from collections.abc import Callable
|
|
10
|
+
from datetime import date, datetime
|
|
11
|
+
from decimal import Decimal, InvalidOperation
|
|
12
|
+
from fractions import Fraction
|
|
13
|
+
from typing import Final
|
|
14
|
+
from uuid import UUID
|
|
15
|
+
|
|
16
|
+
from seampact.codegen.runtime.ir_view import ir_int, ir_map, ir_string
|
|
17
|
+
from seampact.codegen.runtime.pattern import contract_pattern
|
|
18
|
+
from seampact.codegen.runtime.uuid_text import UUID_TEXT
|
|
19
|
+
|
|
20
|
+
MAX_SAFE_INTEGER: Final = 2**53 - 1
|
|
21
|
+
TYPE_RULE: Final = "type"
|
|
22
|
+
FORMAT_RULE: Final = "format"
|
|
23
|
+
|
|
24
|
+
_DATE_TIME: Final = re.compile(
|
|
25
|
+
r"(\d{4}-\d{2}-\d{2})[Tt]((?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d)"
|
|
26
|
+
r"(?:\.(\d{1,9}))?([Zz]|[+-](?:[01]\d|2[0-3]):[0-5]\d)",
|
|
27
|
+
re.ASCII,
|
|
28
|
+
)
|
|
29
|
+
_DATE: Final = re.compile(r"\d{4}-\d{2}-\d{2}", re.ASCII)
|
|
30
|
+
_DECIMAL: Final = re.compile(r"-?(0|[1-9][0-9]*)(\.[0-9]+)?", re.ASCII)
|
|
31
|
+
_MICROSECOND_DIGITS: Final = 6
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ValueRuleError(ValueError):
|
|
35
|
+
"""A value the contract refuses; `rule` is the contract rule's name (`type`, `format`, `minLength`, ...)."""
|
|
36
|
+
|
|
37
|
+
def __init__(self, rule: str, message: str) -> None:
|
|
38
|
+
super().__init__(message)
|
|
39
|
+
self.rule = rule
|
|
40
|
+
self.message = message
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def whole_number(value: object) -> object:
|
|
44
|
+
"""A whole-number float in the safe range as an `int` (ADR 0003: `1.0` is an integer); `1.5`, `true` and `"1"`
|
|
45
|
+
stay, for the type rule to refuse. An `int` outside the safe range is refused: it needs `int64`."""
|
|
46
|
+
if isinstance(value, float) and value.is_integer() and abs(value) <= MAX_SAFE_INTEGER:
|
|
47
|
+
return int(value)
|
|
48
|
+
|
|
49
|
+
if isinstance(value, int) and not isinstance(value, bool) and abs(value) > MAX_SAFE_INTEGER:
|
|
50
|
+
raise ValueRuleError(TYPE_RULE, "an integer outside the safe range")
|
|
51
|
+
|
|
52
|
+
return value
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def date_time_from_text(text: str) -> datetime:
|
|
56
|
+
"""An RFC 3339 date-time as an aware `datetime`, kept with the offset it was sent with."""
|
|
57
|
+
matched = _DATE_TIME.fullmatch(text)
|
|
58
|
+
|
|
59
|
+
if matched is None:
|
|
60
|
+
raise _format_error("date-time")
|
|
61
|
+
|
|
62
|
+
day, time, fraction, offset = matched.groups()
|
|
63
|
+
microseconds = "" if fraction is None else "." + fraction[:_MICROSECOND_DIGITS].ljust(_MICROSECOND_DIGITS, "0")
|
|
64
|
+
offset = "+00:00" if offset in ("Z", "z") else offset
|
|
65
|
+
|
|
66
|
+
try:
|
|
67
|
+
return datetime.fromisoformat(f"{day}T{time}{microseconds}{offset}")
|
|
68
|
+
except ValueError:
|
|
69
|
+
raise _format_error("date-time") from None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def date_from_text(text: str) -> date:
|
|
73
|
+
try:
|
|
74
|
+
if _DATE.fullmatch(text) is None:
|
|
75
|
+
raise ValueError(text)
|
|
76
|
+
|
|
77
|
+
return date.fromisoformat(text)
|
|
78
|
+
except ValueError:
|
|
79
|
+
raise _format_error("date") from None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def uuid_from_text(text: str) -> UUID:
|
|
83
|
+
if UUID_TEXT.fullmatch(text) is None:
|
|
84
|
+
raise _format_error("uuid")
|
|
85
|
+
|
|
86
|
+
return UUID(text)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def decimal_from_text(text: str) -> Decimal:
|
|
90
|
+
try:
|
|
91
|
+
if _DECIMAL.fullmatch(text) is None:
|
|
92
|
+
raise InvalidOperation(text)
|
|
93
|
+
|
|
94
|
+
return Decimal(text)
|
|
95
|
+
except InvalidOperation:
|
|
96
|
+
raise _format_error("decimal") from None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
_FORMATS: Final[dict[str, Callable[[str], object]]] = {
|
|
100
|
+
"date-time": date_time_from_text,
|
|
101
|
+
"date": date_from_text,
|
|
102
|
+
"uuid": uuid_from_text,
|
|
103
|
+
"decimal": decimal_from_text,
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def scalar_from_wire(type_use: object, value: object) -> object:
|
|
108
|
+
"""A decoded value of a `scalar`, `enum` or `opaque` type use, checked by its type and converted by its format;
|
|
109
|
+
an enum value stays its wire text, which the placement turns into its generated member. Raises
|
|
110
|
+
`ValueRuleError`."""
|
|
111
|
+
kind = ir_string(type_use, "kind")
|
|
112
|
+
|
|
113
|
+
if kind == "enum":
|
|
114
|
+
return _checked(value, isinstance(value, str))
|
|
115
|
+
|
|
116
|
+
if kind != "scalar":
|
|
117
|
+
return value
|
|
118
|
+
|
|
119
|
+
scalar_type = ir_string(type_use, "type")
|
|
120
|
+
|
|
121
|
+
if scalar_type == "integer":
|
|
122
|
+
number = whole_number(value)
|
|
123
|
+
return _checked(number, isinstance(number, int) and not isinstance(number, bool))
|
|
124
|
+
|
|
125
|
+
if scalar_type == "number":
|
|
126
|
+
finite = isinstance(value, int | float) and not isinstance(value, bool) and math.isfinite(value)
|
|
127
|
+
return _checked(value, finite)
|
|
128
|
+
|
|
129
|
+
if scalar_type == "boolean":
|
|
130
|
+
return _checked(value, isinstance(value, bool))
|
|
131
|
+
|
|
132
|
+
if not isinstance(value, str):
|
|
133
|
+
raise ValueRuleError(TYPE_RULE, "not a string")
|
|
134
|
+
|
|
135
|
+
convert = _FORMATS.get(ir_string(type_use, "format") or "")
|
|
136
|
+
|
|
137
|
+
return value if convert is None else convert(value)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def checked_scalar(type_use: object, value: object) -> object:
|
|
141
|
+
"""`scalar_from_wire`, then every refinement of the type use with ADR 0003's meaning; a framework-free check for
|
|
142
|
+
the values the boundary reads itself (a scope id, a subject id, a rule proof)."""
|
|
143
|
+
converted = scalar_from_wire(type_use, value)
|
|
144
|
+
refinements = ir_map(type_use, "refinements")
|
|
145
|
+
|
|
146
|
+
if isinstance(converted, str):
|
|
147
|
+
check_length(converted, ir_int(refinements, "minLength"), ir_int(refinements, "maxLength"))
|
|
148
|
+
normalized = ir_string(ir_map(refinements, "pattern"), "normalized")
|
|
149
|
+
|
|
150
|
+
if normalized is not None and not contract_pattern(normalized).matches(converted):
|
|
151
|
+
raise ValueRuleError("pattern", "does not match the contract pattern")
|
|
152
|
+
|
|
153
|
+
if isinstance(converted, int | float | Decimal) and not isinstance(converted, bool):
|
|
154
|
+
check_bounds(
|
|
155
|
+
converted,
|
|
156
|
+
ir_string(refinements, "minimum"),
|
|
157
|
+
ir_string(refinements, "maximum"),
|
|
158
|
+
ir_string(refinements, "exclusiveMinimum"),
|
|
159
|
+
ir_string(refinements, "exclusiveMaximum"),
|
|
160
|
+
)
|
|
161
|
+
divisor = ir_string(refinements, "multipleOf")
|
|
162
|
+
|
|
163
|
+
if divisor is not None:
|
|
164
|
+
check_multiple_of(converted, divisor)
|
|
165
|
+
|
|
166
|
+
return converted
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def check_length(value: str | list[object], min_length: int | None, max_length: int | None) -> None:
|
|
170
|
+
"""`minLength` and `maxLength`, counted in code points for a string."""
|
|
171
|
+
if min_length is not None and len(value) < min_length:
|
|
172
|
+
raise ValueRuleError("minLength", f"at least {min_length} long")
|
|
173
|
+
|
|
174
|
+
if max_length is not None and len(value) > max_length:
|
|
175
|
+
raise ValueRuleError("maxLength", f"at most {max_length} long")
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def check_bounds(
|
|
179
|
+
value: int | float | Decimal,
|
|
180
|
+
minimum: str | None = None,
|
|
181
|
+
maximum: str | None = None,
|
|
182
|
+
exclusive_minimum: str | None = None,
|
|
183
|
+
exclusive_maximum: str | None = None,
|
|
184
|
+
) -> None:
|
|
185
|
+
"""The bounds of ADR 0003, compared exactly as decimals; each bound is the IR's decimal literal."""
|
|
186
|
+
exact = exact_decimal(value)
|
|
187
|
+
rules: tuple[tuple[str, str | None, Callable[[Decimal, Decimal], bool]], ...] = (
|
|
188
|
+
("minimum", minimum, Decimal.__ge__),
|
|
189
|
+
("maximum", maximum, Decimal.__le__),
|
|
190
|
+
("exclusiveMinimum", exclusive_minimum, Decimal.__gt__),
|
|
191
|
+
("exclusiveMaximum", exclusive_maximum, Decimal.__lt__),
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
for rule, bound, holds in rules:
|
|
195
|
+
if bound is not None and not holds(exact, Decimal(bound)):
|
|
196
|
+
raise ValueRuleError(rule, f"outside the bound {bound}")
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def check_multiple_of(value: int | float | Decimal, divisor: str) -> None:
|
|
200
|
+
"""Compared as exact fractions: a decimal remainder raises past the context's precision on a large value."""
|
|
201
|
+
if Fraction(exact_decimal(value)) % Fraction(Decimal(divisor)) != 0:
|
|
202
|
+
raise ValueRuleError("multipleOf", f"not a multiple of {divisor}")
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def exact_decimal(value: int | float | Decimal) -> Decimal:
|
|
206
|
+
"""A float as the shortest decimal that reads back to it, the text the wire wrote, never its binary expansion."""
|
|
207
|
+
return Decimal(repr(value)) if isinstance(value, float) else Decimal(value)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _checked(value: object, holds: bool) -> object:
|
|
211
|
+
if not holds:
|
|
212
|
+
raise ValueRuleError(TYPE_RULE, "not a value of the contract's type")
|
|
213
|
+
|
|
214
|
+
return value
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _format_error(format_: str) -> ValueRuleError:
|
|
218
|
+
return ValueRuleError(FORMAT_RULE, f"not a {format_}")
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""The version of the installed runtime and of the generator shipped with it (ADR 0025 "Version guards", G3): the
|
|
2
|
+
generator stamps it into each api's code as `GENERATED_BY`, and the boot check, first of all, refuses code stamped for
|
|
3
|
+
another `major.minor` or a higher patch. The release pipeline stamps the constant; unreleased, it holds the
|
|
4
|
+
development version, which matches only itself."""
|
|
5
|
+
|
|
6
|
+
import re
|
|
7
|
+
from typing import Final
|
|
8
|
+
|
|
9
|
+
RUNTIME_VERSION: Final = "1.0.0"
|
|
10
|
+
GENERATED_FOR_OTHER_VERSION: Final = "runtime.generated-for-other-version"
|
|
11
|
+
|
|
12
|
+
_VERSION: Final = re.compile(r"(\d+)\.(\d+)\.(\d+)(-[0-9A-Za-z.-]+)?")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class RuntimeVersionError(RuntimeError):
|
|
16
|
+
"""The api's generated code was written for another runtime; the message names both versions and the fix."""
|
|
17
|
+
|
|
18
|
+
code = GENERATED_FOR_OTHER_VERSION
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def accepts(generated_by: str, installed: str = RUNTIME_VERSION) -> bool:
|
|
22
|
+
"""The same version, or the same `major.minor` with an installed patch at or above the generator's; a
|
|
23
|
+
pre-release matches only itself."""
|
|
24
|
+
if generated_by == installed:
|
|
25
|
+
return True
|
|
26
|
+
|
|
27
|
+
generated = _VERSION.fullmatch(generated_by)
|
|
28
|
+
runtime = _VERSION.fullmatch(installed)
|
|
29
|
+
|
|
30
|
+
if generated is None or runtime is None or generated.group(1, 2) != runtime.group(1, 2):
|
|
31
|
+
return False
|
|
32
|
+
|
|
33
|
+
generated_patch = int(generated.group(3))
|
|
34
|
+
runtime_patch = int(runtime.group(3))
|
|
35
|
+
|
|
36
|
+
if generated_patch != runtime_patch:
|
|
37
|
+
return runtime_patch > generated_patch and generated.group(4) is None and runtime.group(4) is None
|
|
38
|
+
|
|
39
|
+
return generated.group(4) == runtime.group(4)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def other_version_problem(api: str, generated_by: str, installed: str = RUNTIME_VERSION) -> str | None:
|
|
43
|
+
"""The boot check's first line, or None when the runtime accepts the generated code."""
|
|
44
|
+
if accepts(generated_by, installed):
|
|
45
|
+
return None
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
f"seampact {GENERATED_FOR_OTHER_VERSION}: The '{api}' boundary was generated by seampact {generated_by}; "
|
|
49
|
+
f"the installed runtime is {installed}. Run seampact generate again with the generator at {installed}, "
|
|
50
|
+
f"or install the runtime at {generated_by} or a later patch of it."
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def refuse_other_version(api: str, generated_by: str, installed: str = RUNTIME_VERSION) -> None:
|
|
55
|
+
"""Raises `RuntimeVersionError` when the runtime does not accept the generated code."""
|
|
56
|
+
problem = other_version_problem(api, generated_by, installed)
|
|
57
|
+
|
|
58
|
+
if problem is not None:
|
|
59
|
+
raise RuntimeVersionError(problem)
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""The version check of an operation with `concurrency` (grammar section 6): the client sends the version it last
|
|
2
|
+
read in the declared source, which the input step decoded and checked; the handler compares it with the stored one
|
|
3
|
+
and answers the stale role's code when they differ: `stateConflict` (409) for a body or query version,
|
|
4
|
+
`preconditionFailed` (412) for a header one, whose absence is `preconditionRequired` (428). `If-Match` carries the
|
|
5
|
+
version as an entity tag, strong or weak (a compressing server or proxy weakens the tag it read, and the version is
|
|
6
|
+
the same), which the boundary unquotes. The boundary reads the version and builds those
|
|
7
|
+
refusals; it never stores a version."""
|
|
8
|
+
|
|
9
|
+
import dataclasses
|
|
10
|
+
from collections.abc import Mapping
|
|
11
|
+
from typing import Final
|
|
12
|
+
|
|
13
|
+
from seampact.codegen.runtime.contract import ApiContract
|
|
14
|
+
from seampact.codegen.runtime.failures import BoundaryFailureError, ContractRefusal
|
|
15
|
+
from seampact.codegen.runtime.input_decoding import DecodedInput, role_refusal
|
|
16
|
+
from seampact.codegen.runtime.ir_view import ir_map, ir_string
|
|
17
|
+
|
|
18
|
+
STATE_CONFLICT_ROLE: Final = "stateConflict"
|
|
19
|
+
PRECONDITION_FAILED_ROLE: Final = "preconditionFailed"
|
|
20
|
+
PRECONDITION_REQUIRED_ROLE: Final = "preconditionRequired"
|
|
21
|
+
_HEADER_SOURCE: Final = "header"
|
|
22
|
+
_IF_MATCH: Final = "if-match"
|
|
23
|
+
_TAG_QUOTE: Final = '"'
|
|
24
|
+
_WEAK_TAG_PREFIX: Final = "W/"
|
|
25
|
+
_ANY_TAG: Final = "*"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def sent_version(contract: ApiContract, operation_id: str, decoded: DecodedInput) -> object:
|
|
29
|
+
"""The version the request carries, None when the client sent none in a body or query source.
|
|
30
|
+
|
|
31
|
+
A placement calls it before the input check of the version parameter, so a missing header is never
|
|
32
|
+
`schemaInvalid`. Raises `ContractRefusal` with the `preconditionRequired` role for a header version the client did
|
|
33
|
+
not send or sent empty, `If-Match: *` included (it carries no version), and with the `preconditionFailed` role
|
|
34
|
+
for an `If-Match` value that is not one entity tag (it can match no version). Raises `BoundaryFailureError` for an
|
|
35
|
+
operation without `concurrency`, or a version source the boundary does not read."""
|
|
36
|
+
source = ir_map(ir_map(contract.operation(operation_id), "concurrency"), "version")
|
|
37
|
+
where = ir_string(source, "in")
|
|
38
|
+
name = ir_string(source, "name") or ""
|
|
39
|
+
parts: Mapping[str, object] = {
|
|
40
|
+
"path": decoded.path,
|
|
41
|
+
"query": decoded.query,
|
|
42
|
+
"header": decoded.headers,
|
|
43
|
+
"body": decoded.body,
|
|
44
|
+
}
|
|
45
|
+
part = parts.get(where or "")
|
|
46
|
+
|
|
47
|
+
if where is None or where not in parts:
|
|
48
|
+
raise BoundaryFailureError(
|
|
49
|
+
f"seampact: operation '{operation_id}' reads its version from {where!r}, which is not a request part the "
|
|
50
|
+
"boundary reads; regenerate the api."
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
version = part.get(name) if isinstance(part, Mapping) else None
|
|
54
|
+
|
|
55
|
+
if where != _HEADER_SOURCE:
|
|
56
|
+
return version
|
|
57
|
+
|
|
58
|
+
if version is None or (isinstance(version, str) and version.strip() == ""):
|
|
59
|
+
raise role_refusal(contract, operation_id, PRECONDITION_REQUIRED_ROLE)
|
|
60
|
+
|
|
61
|
+
if name.lower() != _IF_MATCH:
|
|
62
|
+
return version
|
|
63
|
+
|
|
64
|
+
if isinstance(version, str) and version.strip() == _ANY_TAG:
|
|
65
|
+
raise role_refusal(contract, operation_id, PRECONDITION_REQUIRED_ROLE)
|
|
66
|
+
|
|
67
|
+
return _tag_value(contract, operation_id, version)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def with_sent_version(contract: ApiContract, operation_id: str, decoded: DecodedInput) -> DecodedInput:
|
|
71
|
+
"""The decoded input after the version check, a header version unquoted (`If-Match: "7"` or `W/"7"` reads `7`),
|
|
72
|
+
as the handler compares it; the input itself for an operation without `concurrency`. Raises as `sent_version`."""
|
|
73
|
+
concurrency = ir_map(contract.operation(operation_id), "concurrency")
|
|
74
|
+
|
|
75
|
+
if not concurrency:
|
|
76
|
+
return decoded
|
|
77
|
+
|
|
78
|
+
version = sent_version(contract, operation_id, decoded)
|
|
79
|
+
source = ir_map(concurrency, "version")
|
|
80
|
+
|
|
81
|
+
if ir_string(source, "in") != _HEADER_SOURCE:
|
|
82
|
+
return decoded
|
|
83
|
+
|
|
84
|
+
headers = {**decoded.headers, ir_string(source, "name") or "": version}
|
|
85
|
+
|
|
86
|
+
return dataclasses.replace(decoded, headers=headers)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def stale_version(contract: ApiContract, operation_id: str) -> ContractRefusal:
|
|
90
|
+
"""The refusal a handler raises when the sent version is not the stored one."""
|
|
91
|
+
source = ir_map(ir_map(contract.operation(operation_id), "concurrency"), "version")
|
|
92
|
+
role = PRECONDITION_FAILED_ROLE if ir_string(source, "in") == _HEADER_SOURCE else STATE_CONFLICT_ROLE
|
|
93
|
+
|
|
94
|
+
return role_refusal(contract, operation_id, role)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _tag_value(contract: ApiContract, operation_id: str, tag: object) -> str:
|
|
98
|
+
"""The version inside `"7"` or `W/"7"`; a list or bare text names no version, so it is stale."""
|
|
99
|
+
if not isinstance(tag, str):
|
|
100
|
+
raise role_refusal(contract, operation_id, PRECONDITION_FAILED_ROLE)
|
|
101
|
+
|
|
102
|
+
value = tag.strip().removeprefix(_WEAK_TAG_PREFIX)
|
|
103
|
+
quoted = len(value) >= 2 and value.startswith(_TAG_QUOTE) and value.endswith(_TAG_QUOTE)
|
|
104
|
+
|
|
105
|
+
if not quoted or _TAG_QUOTE in value[1:-1]:
|
|
106
|
+
raise role_refusal(contract, operation_id, PRECONDITION_FAILED_ROLE)
|
|
107
|
+
|
|
108
|
+
return value[1:-1]
|