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,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]