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,165 @@
1
+ """The type layer of the generated Pydantic models FastAPI and Ninja share: annotated types whose `BeforeValidator`
2
+ turns a decoded wire value into the Python object Pydantic's strict mode expects (a whole-number float into an
3
+ `int`; `date-time`, `date`, `uuid` and decimal strings, and enum values, into their objects), and the validators of
4
+ `rules: seampact-consistent`, each failing with the contract rule's name as its Pydantic error type."""
5
+
6
+ from collections.abc import Callable
7
+ from datetime import date, datetime
8
+ from decimal import Decimal
9
+ from enum import Enum
10
+ from typing import Annotated, Final, TypeVar
11
+ from uuid import UUID
12
+
13
+ from pydantic import AfterValidator, BeforeValidator, JsonValue
14
+ from pydantic_core import PydanticCustomError
15
+
16
+ from seampact.codegen.runtime import value_rules
17
+ from seampact.codegen.runtime.pattern import contract_pattern
18
+ from seampact.codegen.runtime.value_rules import ValueRuleError
19
+
20
+ _FORMAT_RULE: Final = "format"
21
+ _TYPE_RULE: Final = "type"
22
+ _ENUM_RULE: Final = "enum"
23
+
24
+
25
+ def whole_number(value: object) -> object:
26
+ """A whole-number float in the safe range as an `int` (ADR 0003: `1.0` is an integer); `1.5`, `true` and `"1"`
27
+ stay, for the strict type rule to refuse. An `int` outside the safe range is refused: it needs `int64`."""
28
+ try:
29
+ return value_rules.whole_number(value)
30
+ except ValueRuleError as refused:
31
+ raise _rule_error(refused.rule, refused.message) from None
32
+
33
+
34
+ def refuse_null(value: object) -> object:
35
+ """A wire `null` on an optional member that is not nullable (ADR 0003 section 5: absent and null differ)."""
36
+ if value is None:
37
+ raise PydanticCustomError(_TYPE_RULE, "null on a member that is not nullable")
38
+
39
+ return value
40
+
41
+
42
+ def date_time_from_wire(value: object) -> object:
43
+ """An RFC 3339 date-time string as an aware `datetime`, kept with the offset it was sent with."""
44
+ return _from_text(value, value_rules.date_time_from_text, "date-time")
45
+
46
+
47
+ def date_from_wire(value: object) -> object:
48
+ return _from_text(value, value_rules.date_from_text, "date")
49
+
50
+
51
+ def uuid_from_wire(value: object) -> object:
52
+ return _from_text(value, value_rules.uuid_from_text, "uuid")
53
+
54
+
55
+ def decimal_from_wire(value: object) -> object:
56
+ return _from_text(value, value_rules.decimal_from_text, "decimal")
57
+
58
+
59
+ def enum_from_wire[E: Enum](enum_type: type[E]) -> BeforeValidator:
60
+ """A string that is one of the enum's values as its member, another string refused by the `enum` rule; a value
61
+ that is no string stays, for the strict type rule."""
62
+
63
+ def member(value: object) -> object:
64
+ if not isinstance(value, str):
65
+ return value
66
+
67
+ listed = next((item for item in enum_type if item.value == value), None)
68
+
69
+ if listed is None:
70
+ raise PydanticCustomError(_ENUM_RULE, "a value the enum does not list")
71
+
72
+ return listed
73
+
74
+ return BeforeValidator(member)
75
+
76
+
77
+ WholeNumber = Annotated[int, BeforeValidator(whole_number)]
78
+ WireDateTime = Annotated[datetime, BeforeValidator(date_time_from_wire)]
79
+ WireDate = Annotated[date, BeforeValidator(date_from_wire)]
80
+ WireUuid = Annotated[UUID, BeforeValidator(uuid_from_wire)]
81
+ WireDecimal = Annotated[Decimal, BeforeValidator(decimal_from_wire)]
82
+
83
+ _T = TypeVar("_T")
84
+
85
+ # An optional member that is not nullable: absent, it takes the default None, which Pydantic does not validate; a
86
+ # wire `null` meets `refuse_null` (ADR 0003 section 5: absent and null differ). A generic `Annotated` alias, not a
87
+ # `type` statement: Pydantic 2.9, FastAPI 0.162's floor, builds no schema from a subscripted PEP 695 alias.
88
+ Omittable = Annotated[_T | None, BeforeValidator(refuse_null)]
89
+
90
+ # An opaque value, any JSON but a wire `null`, which only a nullable use takes (ADR 0003 section 5).
91
+ Opaque = Annotated[JsonValue, BeforeValidator(refuse_null)]
92
+
93
+
94
+ def length(min_length: int | None = None, max_length: int | None = None) -> AfterValidator:
95
+ """`minLength` and `maxLength` of a string or a list, counted in code points for a string."""
96
+
97
+ def check[V: str | list[object]](value: V) -> V:
98
+ _checked(lambda: value_rules.check_length(value, min_length, max_length))
99
+
100
+ return value
101
+
102
+ return AfterValidator(check)
103
+
104
+
105
+ def bounds(
106
+ minimum: str | None = None,
107
+ maximum: str | None = None,
108
+ exclusive_minimum: str | None = None,
109
+ exclusive_maximum: str | None = None,
110
+ ) -> AfterValidator:
111
+ """The bounds of ADR 0003, compared exactly as decimals; each bound is the IR's decimal literal."""
112
+
113
+ def check[N: int | float | Decimal](value: N) -> N:
114
+ _checked(lambda: value_rules.check_bounds(value, minimum, maximum, exclusive_minimum, exclusive_maximum))
115
+
116
+ return value
117
+
118
+ return AfterValidator(check)
119
+
120
+
121
+ def multiple_of(divisor: str) -> AfterValidator:
122
+ def check[N: int | float | Decimal](value: N) -> N:
123
+ _checked(lambda: value_rules.check_multiple_of(value, divisor))
124
+
125
+ return value
126
+
127
+ return AfterValidator(check)
128
+
129
+
130
+ def pattern(normalized: str) -> AfterValidator:
131
+ """The contract pattern, checked by the framework-free pattern check on Python's `re`."""
132
+ compiled = contract_pattern(normalized)
133
+
134
+ def check(value: str) -> str:
135
+ if not compiled.matches(value):
136
+ raise _rule_error("pattern", "does not match the contract pattern")
137
+
138
+ return value
139
+
140
+ return AfterValidator(check)
141
+
142
+
143
+ def _from_text(value: object, convert: Callable[[str], object], format_: str) -> object:
144
+ if not isinstance(value, str):
145
+ return value
146
+
147
+ try:
148
+ return convert(value)
149
+ except ValueRuleError:
150
+ raise _format_error(format_) from None
151
+
152
+
153
+ def _checked(check: Callable[[], None]) -> None:
154
+ try:
155
+ check()
156
+ except ValueRuleError as refused:
157
+ raise _rule_error(refused.rule, refused.message) from None
158
+
159
+
160
+ def _format_error(format_: str) -> PydanticCustomError:
161
+ return PydanticCustomError(_FORMAT_RULE, "not a {format}", {"format": format_})
162
+
163
+
164
+ def _rule_error(rule: str, message: str) -> PydanticCustomError:
165
+ return PydanticCustomError(rule, message)
@@ -0,0 +1,52 @@
1
+ """The operation features around steps 6 to 8 of one contract request, in the order every target runs them: the sent
2
+ version first, so a missing `If-Match` answers `preconditionRequired` and never `schemaInvalid`; the typed input as
3
+ sent; the paged list's limit filled in and the input field rights, which leave out or refuse a restricted member;
4
+ then the deadline. The idempotency claim comes next, in the placement, which holds the store."""
5
+
6
+ import dataclasses
7
+ from dataclasses import dataclass
8
+ from datetime import UTC, datetime
9
+
10
+ from seampact.codegen.runtime.access_steps import AccessHooks, AccessState, HookCaller
11
+ from seampact.codegen.runtime.contract import ApiContract
12
+ from seampact.codegen.runtime.deadline import request_deadline
13
+ from seampact.codegen.runtime.field_rights import checked_input_fields
14
+ from seampact.codegen.runtime.input_decoding import DecodedInput
15
+ from seampact.codegen.runtime.page_reply import filled_page_query
16
+ from seampact.codegen.runtime.pydantic.contract_input import InputBuilder
17
+ from seampact.codegen.runtime.version_token import with_sent_version
18
+
19
+
20
+ @dataclass(frozen=True, slots=True)
21
+ class CheckedInput:
22
+ """The typed request the handler receives, and the decoded input it was built from: the filled page limit and
23
+ the body its field rights leave."""
24
+
25
+ typed: object
26
+ decoded: DecodedInput
27
+
28
+
29
+ async def checked_input(
30
+ contract: ApiContract,
31
+ operation_id: str,
32
+ decoded: DecodedInput,
33
+ raw_query: str,
34
+ access: AccessState,
35
+ hooks: AccessHooks,
36
+ builder: InputBuilder,
37
+ call: HookCaller,
38
+ ) -> CheckedInput:
39
+ """Raises `ContractRefusal` for a refused input and `BoundaryFailureError` for a hook's undeclared answer."""
40
+ sent = with_sent_version(contract, operation_id, decoded)
41
+ typed = builder.build(sent, access, raw_query)
42
+ filled_query = filled_page_query(contract, operation_id, sent.query)
43
+ checked_body = await checked_input_fields(access, hooks, call)
44
+ handed = dataclasses.replace(sent, query=filled_query, body=checked_body)
45
+
46
+ # The input as sent was checked; the handler receives the filled limit and the body its field rights leave.
47
+ if filled_query != sent.query or checked_body != sent.body:
48
+ typed = builder.build(handed, access, raw_query)
49
+
50
+ request_deadline(contract, operation_id, handed, datetime.now(UTC))
51
+
52
+ return CheckedInput(typed, handed)
@@ -0,0 +1,183 @@
1
+ """Steps 6 to 8 of one operation (ADR 0026 "Types and validation"): its path, query and header parts and its body,
2
+ decoded by the boundary, validated by the generated models in Pydantic's strict mode; every issue of every part is
3
+ reported together as the operation's `schemaInvalid` refusal. Then the typed request: the input, the principal the
4
+ access steps admitted and the scopes they resolved, one attribute per scope in declared order."""
5
+
6
+ import dataclasses
7
+ import typing
8
+ from collections.abc import Mapping
9
+ from typing import Final, TypeGuard
10
+
11
+ import pydantic
12
+
13
+ from seampact.codegen.runtime.access_steps import AccessState
14
+ from seampact.codegen.runtime.contract import HEADER_PART, PATH_PART, PROOFS_PART, QUERY_PART, ApiContract
15
+ from seampact.codegen.runtime.failures import BoundaryFailureError
16
+ from seampact.codegen.runtime.input_decoding import DecodedInput, Issue, schema_invalid
17
+ from seampact.codegen.runtime.ir_view import ir_map, ir_maps, ir_string
18
+ from seampact.codegen.runtime.page_reply import cursor_issues
19
+ from seampact.codegen.runtime.pydantic.issues import MAX_RENDERED_ISSUES, body_issues, part_issues, proof_issues
20
+ from seampact.codegen.runtime.run_settings import OperationTypes
21
+ from seampact.codegen.runtime.wire_codec import WireCodec
22
+ from seampact.codegen.runtime.wire_values import raw_query
23
+
24
+ _INPUT_FIELD: Final = "input"
25
+ _PRINCIPAL_FIELD: Final = "principal"
26
+ _SCOPES_FIELD: Final = "scopes"
27
+ _BODY_FIELD: Final = "body"
28
+ _UNKNOWN_KEYS_RULE: Final = "unknownKeys"
29
+ _REFUSE: Final = "refuse"
30
+ _IGNORE: Final = "ignore"
31
+ # The input attribute of each part and where its issues are reported.
32
+ _PARTS: Final = (("path", PATH_PART), ("query", QUERY_PART), ("headers", HEADER_PART))
33
+
34
+
35
+ class InputBuilder:
36
+ """Built once per operation, when its route is added; reads the generated types' annotations."""
37
+
38
+ def __init__(self, contract: ApiContract, operation_id: str, types: OperationTypes) -> None:
39
+ self._contract = contract
40
+ self._operation_id = operation_id
41
+ self._request_type = types.request
42
+ request_fields = typing.get_type_hints(types.request)
43
+ self._input_type = request_fields.get(_INPUT_FIELD)
44
+ self._scopes_type = request_fields.get(_SCOPES_FIELD)
45
+ self._has_principal = _PRINCIPAL_FIELD in request_fields
46
+ input_fields = {} if self._input_type is None else typing.get_type_hints(self._input_type)
47
+ self._part_models = {
48
+ attribute: model
49
+ for attribute, _ in _PARTS
50
+ if isinstance(model := input_fields.get(attribute), type) and issubclass(model, pydantic.BaseModel)
51
+ }
52
+ body_type = input_fields.get(_BODY_FIELD)
53
+ self._body = None if body_type is None else pydantic.TypeAdapter[object](body_type)
54
+ self._proofs_model = types.proofs if _is_model(types.proofs) else None
55
+ self._proof_types = {
56
+ ir_string(proof, "name") or "": ir_map(proof, "schema")
57
+ for proof in ir_maps(ir_map(contract.operation(operation_id), "input"), "proofs")
58
+ }
59
+
60
+ def build(self, decoded: DecodedInput, access: AccessState, raw_query_string: str) -> object:
61
+ """Raises the operation's `schemaInvalid` refusal with every issue at once."""
62
+ issues: list[Issue] = []
63
+ values: dict[str, object] = {}
64
+ wire_parts = {"path": decoded.path, "query": decoded.query, "headers": decoded.headers}
65
+
66
+ for attribute, part in _PARTS:
67
+ model = self._part_models.get(attribute)
68
+ members = self._contract.part_members(self._operation_id, part)
69
+
70
+ if part == QUERY_PART:
71
+ issues += self._unknown_query(members, raw_query_string)
72
+
73
+ if model is None:
74
+ continue
75
+
76
+ renamed = {members[name]: value for name, value in wire_parts[attribute].items() if name in members}
77
+
78
+ try:
79
+ values[attribute] = model.model_validate(renamed, strict=True)
80
+ except pydantic.ValidationError as error:
81
+ issues += part_issues(part, members, error)
82
+
83
+ if self._body is not None:
84
+ issues += self._validate_body(decoded.body, values)
85
+
86
+ issues += cursor_issues(self._contract, self._operation_id, decoded.query)
87
+
88
+ if issues:
89
+ raise schema_invalid(self._contract, self._operation_id, issues[:MAX_RENDERED_ISSUES])
90
+
91
+ return self._typed_request(values, access)
92
+
93
+ def _unknown_query(self, members: Mapping[str, str], raw_query_string: str) -> list[Issue]:
94
+ if self._contract.ignores_unknown_query(self._operation_id):
95
+ return []
96
+
97
+ return [
98
+ Issue(QUERY_PART, f"/{name}", _UNKNOWN_KEYS_RULE)
99
+ for name in raw_query(raw_query_string)
100
+ if name not in members
101
+ ]
102
+
103
+ def _validate_body(self, tree: object, values: dict[str, object]) -> list[Issue]:
104
+ if self._body is None:
105
+ return []
106
+
107
+ type_use = ir_map(ir_map(self._contract.operation(self._operation_id), "input"), "schema")
108
+ policy = self._contract.unknown_keys_policy(self._operation_id)
109
+
110
+ if policy not in (_REFUSE, _IGNORE):
111
+ raise BoundaryFailureError(
112
+ f"seampact: operation '{self._operation_id}' keeps unknown body keys, which this seampact-fastapi "
113
+ "release does not serve; leave the operation out with the entry's `operations` filter."
114
+ )
115
+
116
+ issues = self._proof_issues(tree)
117
+ python_tree = WireCodec(self._contract).to_python(type_use, self._without_proofs(tree))
118
+
119
+ if policy == _REFUSE:
120
+ issues += [Issue(_BODY_FIELD, pointer, _UNKNOWN_KEYS_RULE) for pointer in python_tree.unknown_keys]
121
+
122
+ try:
123
+ values[_BODY_FIELD] = self._body.validate_python(python_tree.value, strict=True)
124
+ except pydantic.ValidationError as error:
125
+ issues += body_issues(self._contract, type_use, error)
126
+
127
+ return issues
128
+
129
+ def _proof_issues(self, tree: object) -> list[Issue]:
130
+ """Each rule proof checked by its full type (enum, pattern, bounds, nested members), as the body's other
131
+ members are; the access steps only checked its scalar type before the decider read it."""
132
+ if self._proofs_model is None or not isinstance(tree, dict):
133
+ return []
134
+
135
+ members = self._contract.part_members(self._operation_id, PROOFS_PART)
136
+ codec = WireCodec(self._contract)
137
+ renamed = {
138
+ members[name]: codec.to_python(type_use, tree[name]).value
139
+ for name, type_use in self._proof_types.items()
140
+ if name in tree and name in members
141
+ }
142
+
143
+ try:
144
+ self._proofs_model.model_validate(renamed, strict=True)
145
+ except pydantic.ValidationError as error:
146
+ return proof_issues(self._contract, self._proof_types, members, error)
147
+
148
+ return []
149
+
150
+ def _without_proofs(self, tree: object) -> object:
151
+ """The body without the rule proofs, which the access steps read and the handler never sees."""
152
+ if not isinstance(tree, dict) or not self._proof_types:
153
+ return tree
154
+
155
+ return {name: value for name, value in tree.items() if name not in self._proof_types}
156
+
157
+ def _typed_request(self, values: dict[str, object], access: AccessState) -> object:
158
+ if self._input_type is None:
159
+ raise BoundaryFailureError(
160
+ f"seampact: the request type of operation '{self._operation_id}' has no input; regenerate the api."
161
+ )
162
+
163
+ arguments: dict[str, object] = {_INPUT_FIELD: self._input_type(**values)}
164
+
165
+ if self._has_principal:
166
+ arguments[_PRINCIPAL_FIELD] = access.principal
167
+
168
+ if self._scopes_type is not None:
169
+ names = [field.name for field in dataclasses.fields(self._scopes_type)]
170
+ resolved = {scope.scope: scope for scope in access.scopes}
171
+ scope_ids = [
172
+ ir_string(entry, "scope") or ""
173
+ for entry in ir_maps(self._contract.operation(self._operation_id), "scopes")
174
+ ]
175
+ arguments[_SCOPES_FIELD] = self._scopes_type(
176
+ **{name: resolved[scope_id] for name, scope_id in zip(names, scope_ids, strict=True)}
177
+ )
178
+
179
+ return self._request_type(**arguments)
180
+
181
+
182
+ def _is_model(candidate: type | None) -> TypeGuard[type[pydantic.BaseModel]]:
183
+ return isinstance(candidate, type) and issubclass(candidate, pydantic.BaseModel)
@@ -0,0 +1,142 @@
1
+ """Step 6's issues from a Pydantic `ValidationError`: each error's location read back to the wire's JSON pointer through
2
+ the api's member tables, and its type to the contract rule name (ADR 0026 "Types and validation"), bounded by
3
+ `MAX_RENDERED_ISSUES`. FastAPI and Ninja share it."""
4
+
5
+ from collections.abc import Mapping, Sequence
6
+ from typing import Final
7
+
8
+ from pydantic import ValidationError
9
+
10
+ from seampact.codegen.runtime.contract import HEADER_PART, ApiContract
11
+ from seampact.codegen.runtime.input_decoding import Issue
12
+ from seampact.codegen.runtime.ir_view import IrMap, ir_map, ir_string
13
+
14
+ MAX_RENDERED_ISSUES: Final = 100
15
+
16
+ _CONTRACT_RULES: Final = frozenset(
17
+ {
18
+ "required",
19
+ "type",
20
+ "format",
21
+ "minLength",
22
+ "maxLength",
23
+ "minItems",
24
+ "maxItems",
25
+ "minimum",
26
+ "maximum",
27
+ "exclusiveMinimum",
28
+ "exclusiveMaximum",
29
+ "multipleOf",
30
+ "pattern",
31
+ "enum",
32
+ "const",
33
+ "unknownKeys",
34
+ }
35
+ )
36
+ _PYDANTIC_RULES: Final = {
37
+ "missing": "required",
38
+ "string_too_short": "minLength",
39
+ "string_too_long": "maxLength",
40
+ "too_short": "minItems",
41
+ "too_long": "maxItems",
42
+ "greater_than_equal": "minimum",
43
+ "greater_than": "exclusiveMinimum",
44
+ "less_than_equal": "maximum",
45
+ "less_than": "exclusiveMaximum",
46
+ "multiple_of": "multipleOf",
47
+ "string_pattern_mismatch": "pattern",
48
+ "enum": "enum",
49
+ "literal_error": "enum",
50
+ "extra_forbidden": "unknownKeys",
51
+ }
52
+ _TYPE_RULE: Final = "type"
53
+
54
+
55
+ def part_issues(in_: str, members: Mapping[str, str], error: ValidationError) -> list[Issue]:
56
+ """The issues of a path, query or header model, whose members `members` maps from wire name to attribute."""
57
+ wire_names = {attribute: wire_name for wire_name, attribute in members.items()}
58
+ issues: list[Issue] = []
59
+
60
+ for detail in error.errors():
61
+ location = detail["loc"]
62
+ first = str(location[0]) if location else ""
63
+ rest = "".join(f"/{_escaped(str(step))}" for step in location[1:])
64
+ wire_name = wire_names.get(first, first)
65
+ # Header names are case-insensitive: a header issue names its header in lower case, as the wire reads it.
66
+ named = wire_name.lower() if in_ == HEADER_PART else wire_name
67
+ issues.append(Issue(in_, f"/{_escaped(named)}{rest}", contract_rule(detail["type"])))
68
+
69
+ return issues[:MAX_RENDERED_ISSUES]
70
+
71
+
72
+ def body_issues(contract: ApiContract, type_use: IrMap, error: ValidationError) -> list[Issue]:
73
+ """The issues of a body validated as `type_use`, their pointers in wire names."""
74
+ return [
75
+ Issue("body", body_pointer(contract, type_use, detail["loc"]), contract_rule(detail["type"]))
76
+ for detail in error.errors()
77
+ ][:MAX_RENDERED_ISSUES]
78
+
79
+
80
+ def proof_issues(
81
+ contract: ApiContract, proofs: Mapping[str, IrMap], members: Mapping[str, str], error: ValidationError
82
+ ) -> list[Issue]:
83
+ """The issues of a body's rule proofs validated as one model: `proofs` holds each proof's type use by wire name,
84
+ `members` maps each wire name to its attribute; a pointer starts at the proof's body member."""
85
+ wire_names = {attribute: wire_name for wire_name, attribute in members.items()}
86
+ issues: list[Issue] = []
87
+
88
+ for detail in error.errors():
89
+ location = detail["loc"]
90
+ attribute = str(location[0]) if location else ""
91
+ wire_name = wire_names.get(attribute, attribute)
92
+ inner = body_pointer(contract, proofs.get(wire_name, {}), location[1:])
93
+ issues.append(Issue("body", f"/{_escaped(wire_name)}{inner}", contract_rule(detail["type"])))
94
+
95
+ return issues[:MAX_RENDERED_ISSUES]
96
+
97
+
98
+ def contract_rule(error_type: str) -> str:
99
+ """The contract rule a Pydantic error type stands for; the type layer's own errors are named after their rule."""
100
+ if error_type in _CONTRACT_RULES:
101
+ return error_type
102
+
103
+ return _PYDANTIC_RULES.get(error_type, _TYPE_RULE)
104
+
105
+
106
+ def body_pointer(contract: ApiContract, type_use: IrMap, location: Sequence[int | str]) -> str:
107
+ """The JSON pointer of a Pydantic location in a body of `type_use`, attributes read back to wire names."""
108
+ pointer = ""
109
+ current = type_use
110
+
111
+ for step in location:
112
+ kind = ir_string(current, "kind")
113
+
114
+ if kind == "ref" and isinstance(step, str):
115
+ schema_id = ir_string(current, "schema") or ""
116
+ wire_names = {attribute: wire for wire, attribute in contract.members.get(schema_id, {}).items()}
117
+ wire_name = wire_names.get(step, step)
118
+ pointer += f"/{_escaped(wire_name)}"
119
+ current = _member_type(contract, schema_id, wire_name)
120
+ continue
121
+
122
+ if kind == "array":
123
+ current = ir_map(current, "items")
124
+
125
+ pointer += f"/{_escaped(str(step))}"
126
+
127
+ return pointer
128
+
129
+
130
+ def _member_type(contract: ApiContract, schema_id: str, wire_name: str) -> IrMap:
131
+ schema = contract.declaration("schemas", schema_id)
132
+ properties = schema.get("properties")
133
+
134
+ for member in properties if isinstance(properties, list) else []:
135
+ if isinstance(member, Mapping) and ir_string(member, "name") == wire_name:
136
+ return ir_map(member, "type")
137
+
138
+ return {}
139
+
140
+
141
+ def _escaped(name: str) -> str:
142
+ return name.replace("~", "~0").replace("/", "~1")
@@ -0,0 +1,103 @@
1
+ """The raw request target as the client sent it, without its query: the ASGI `raw_path` (minus a raw prefix equal to
2
+ `root_path`), or the WSGI `RAW_URI` or `REQUEST_URI` (minus `SCRIPT_NAME`). A server that gives no raw target leaves
3
+ the decoded path, marked so the boundary logs once which raw-path rules it cannot apply."""
4
+
5
+ import re
6
+ from collections.abc import Mapping
7
+ from dataclasses import dataclass
8
+ from typing import Final
9
+
10
+ # An absolute-form request target (`http://host/path`) keeps only its path.
11
+ _ORIGIN: Final = re.compile(r"^[a-z][a-z0-9+.-]*://[^/]*", re.IGNORECASE)
12
+ # ASGI and WSGI carry raw bytes as Latin-1 text, one character per byte.
13
+ _BYTES_AS_TEXT: Final = "latin-1"
14
+
15
+
16
+ @dataclass(frozen=True, slots=True)
17
+ class RawTarget:
18
+ """`path` starts with `/`; `query` is the raw query string; `raw` is False when the server gave no raw target
19
+ and `path` is the decoded one."""
20
+
21
+ path: str
22
+ query: str
23
+ raw: bool
24
+
25
+
26
+ def asgi_raw_target(scope: Mapping[str, object]) -> RawTarget | None:
27
+ """None when `root_path` is set and the target does not start with it: the request is not a contract request."""
28
+ raw_path = scope.get("raw_path")
29
+ query = scope.get("query_string")
30
+ query_text = query.decode(_BYTES_AS_TEXT) if isinstance(query, bytes) else ""
31
+
32
+ if isinstance(raw_path, bytes):
33
+ path = _without_query(raw_path.decode(_BYTES_AS_TEXT))
34
+ root = _raw_text(_text(scope, "root_path"))
35
+ raw = True
36
+ else:
37
+ path = _text(scope, "path")
38
+ root = _text(scope, "root_path")
39
+ raw = False
40
+
41
+ path = without_origin(path)
42
+
43
+ if root == "":
44
+ return RawTarget(_rooted(path), query_text, raw)
45
+
46
+ stripped = _stripped_prefix(path, root)
47
+
48
+ return None if stripped is None else RawTarget(_rooted(stripped), query_text, raw)
49
+
50
+
51
+ def wsgi_raw_target(environ: Mapping[str, object]) -> RawTarget | None:
52
+ """The raw target minus `SCRIPT_NAME`, None (not a contract request) when it does not start with a non-empty
53
+ `SCRIPT_NAME`, as `asgi_raw_target` treats `root_path`; under a server that gives no `RAW_URI` or `REQUEST_URI`
54
+ (Django's `runserver`), the decoded `PATH_INFO`."""
55
+ query = _text(environ, "QUERY_STRING")
56
+ uri = _text(environ, "RAW_URI") or _text(environ, "REQUEST_URI")
57
+
58
+ if uri == "":
59
+ return RawTarget(_rooted(_text(environ, "PATH_INFO")), query, raw=False)
60
+
61
+ path = without_origin(_without_query(uri))
62
+ script = _raw_text(_text(environ, "SCRIPT_NAME").rstrip("/"))
63
+
64
+ if script == "":
65
+ return RawTarget(_rooted(path), query, raw=True)
66
+
67
+ stripped = _stripped_prefix(path, script)
68
+
69
+ return None if stripped is None else RawTarget(_rooted(stripped), query, raw=True)
70
+
71
+
72
+ def _text(values: Mapping[str, object], key: str) -> str:
73
+ value = values.get(key)
74
+
75
+ return value if isinstance(value, str) else ""
76
+
77
+
78
+ def _raw_text(decoded: str) -> str:
79
+ """A decoded prefix as the raw bytes of a target spell it when sent unescaped."""
80
+ return decoded.encode("utf-8").decode(_BYTES_AS_TEXT)
81
+
82
+
83
+ def _without_query(target: str) -> str:
84
+ return target.split("?", 1)[0]
85
+
86
+
87
+ def without_origin(path: str) -> str:
88
+ """The path of an absolute-form target (RFC 9112 section 3.2.2) without its `scheme://authority`."""
89
+ origin = _ORIGIN.match(path)
90
+
91
+ return path if origin is None else path[origin.end() :]
92
+
93
+
94
+ def _stripped_prefix(path: str, prefix: str) -> str | None:
95
+ """`path` after `prefix` when the prefix ends at a segment boundary, else None."""
96
+ if path == prefix:
97
+ return ""
98
+
99
+ return path[len(prefix) :] if path.startswith(prefix + "/") else None
100
+
101
+
102
+ def _rooted(path: str) -> str:
103
+ return path if path.startswith("/") else "/" + path