From 75b954ad9366bb52006a978755ed29f80f2e24fe Mon Sep 17 00:00:00 2001 From: Roey Berman Date: Fri, 14 Aug 2026 09:11:42 -0700 Subject: [PATCH 01/20] Emit Python JSON-Schema models as dataclasses with transfer type converters Generated Python models were the only emitter output requiring a contrib dependency: every model was a strict `pydantic.BaseModel` carrying its wire contract as `ConfigDict`, `Field(alias=...)`, `Annotated` validator aliases, five flavors of `model_validator`, and a `model_serializer` keyed on `model_fields_set`. That forced `pydantic` on consumers, forced the non-default `pydantic_data_converter`, and left Python the odd one out on error aggregation. Models are now plain `@dataclasses.dataclass(slots=True, kw_only=True)` types whose entire wire contract lives in a generated `_TransferTypeConverter`, registered on the class so the *default* Temporal data converter picks it up with no user wiring. This is structurally the TypeScript design -- inert data plus an off-model converter owning both directions -- expressed through the SDK's transfer-type hook. Validation aggregates into a generated `ValidationError` over `Violation { path, reason }`, so all four targets now share one error shape. Two deliberate behavior changes, both keeping Python in line with the other targets rather than ahead of them: - Optional+nullable collapses. A dataclass has no presence channel, so absent and explicit `null` both read as `None` and both re-serialize as omitted. This extends P1's exception (a) from Go/Java to Python. - `default` is advisory, as in TypeScript. The field stays `T | None = None` and the value moves to a module-level `DEFAULT_` constant. Baking the default into the field would have made Python's wire a superset of the shared fixtures -- a third exception to round-trip byte-identity, where P1 allows two. Python trades pydantic's free materialize-on-read for byte-identity. `additionalProperties` becomes an explicit `additional_properties` member, matching Go/Java/TS and folding Python into the catch-all name collision that the loader already rejected for it. The runtime `_definitions.py` wraps `transfer_type_convertible` in a shim that erases the converter's value-type parameter: binding it directly on the decorated class is circular for a static type checker and degrades every model to `Unknown`. `src/generator/proto/python.rs` has the same latent issue, benign only because a test imports those models first. Also corrects pre-existing spec drift found while auditing the Python cells: the string-length reason was documented as `length must be <= N, got M` for all four languages where every emitter emits `must have length <= N, got M`; `required.md` documented a `required property "x" is missing` reason that no emitter produces; `additionalProperties.md` claimed Python was exempt from the catch-all collision reject that the loader applies to every language; and `uniqueItems.md` described a hash-set membership test that raises on the unhashable dataclass elements Python now generates. --- CHANGELOG.md | 35 + TODO.md | 10 + .../json_schema/api/chat/_definitions.py | 412 +- .../python/json_schema/api/chat/models.py | 605 +- .../python/json_schema/api/kb/_definitions.py | 412 +- .../python/json_schema/api/kb/_recursive.py | 300 +- .../api/kb/content/block/models.py | 115 +- .../json_schema/api/kb/content/page/models.py | 100 +- .../python/json_schema/api/kb/kb/models.py | 198 +- .../api/kb/tree/category/models.py | 203 +- .../json_schema/api/showcase/_definitions.py | 412 +- .../python/json_schema/api/showcase/models.py | 5142 +++++++++++++---- .../json_schema/api/temporal/_definitions.py | 412 +- .../python/json_schema/api/temporal/models.py | 302 +- advanced/samples/python/pyproject.toml | 1 - advanced/samples/python/uv.lock | 156 +- samples/python/chat/_definitions.py | 412 +- samples/python/chat/models.py | 605 +- samples/python/kb/_definitions.py | 412 +- samples/python/kb/_recursive.py | 300 +- samples/python/kb/content/block/models.py | 115 +- samples/python/kb/content/page/models.py | 100 +- samples/python/kb/kb/models.py | 198 +- samples/python/kb/tree/category/models.py | 203 +- samples/python/pyproject.toml | 1 - samples/python/showcase/_definitions.py | 412 +- samples/python/showcase/models.py | 5142 +++++++++++++---- samples/python/temporal/_definitions.py | 412 +- samples/python/temporal/models.py | 302 +- samples/python/tests/json_converter_helper.py | 102 + samples/python/tests/test_chat.py | 282 +- samples/python/tests/test_kb.py | 136 +- samples/python/tests/test_kb_nexus.py | 35 +- samples/python/tests/test_showcase.py | 1239 ++-- samples/python/tests/test_temporal.py | 131 +- samples/python/uv.lock | 156 +- specs/json-schema/PRINCIPLES.md | 16 +- .../features/additionalProperties.md | 75 +- specs/json-schema/features/allOf.md | 2 +- specs/json-schema/features/const.md | 15 +- specs/json-schema/features/contains.md | 2 +- specs/json-schema/features/contentEncoding.md | 17 +- specs/json-schema/features/default.md | 70 +- .../json-schema/features/dependentRequired.md | 2 +- specs/json-schema/features/deprecated.md | 8 +- specs/json-schema/features/description.md | 6 +- specs/json-schema/features/enum.md | 15 +- specs/json-schema/features/examples.md | 4 +- .../json-schema/features/exclusiveMaximum.md | 2 +- .../json-schema/features/exclusiveMinimum.md | 2 +- specs/json-schema/features/format.md | 2 +- specs/json-schema/features/items.md | 10 +- specs/json-schema/features/maxContains.md | 2 +- specs/json-schema/features/maxItems.md | 2 +- specs/json-schema/features/maxLength.md | 12 +- specs/json-schema/features/maxProperties.md | 13 +- specs/json-schema/features/maximum.md | 22 +- specs/json-schema/features/minContains.md | 2 +- specs/json-schema/features/minItems.md | 4 +- specs/json-schema/features/minLength.md | 15 +- specs/json-schema/features/minProperties.md | 15 +- specs/json-schema/features/minimum.md | 6 +- specs/json-schema/features/multipleOf.md | 26 +- specs/json-schema/features/oneOf.md | 144 +- specs/json-schema/features/pattern.md | 11 +- specs/json-schema/features/properties.md | 38 +- specs/json-schema/features/propertyNames.md | 4 +- specs/json-schema/features/ref.md | 2 +- specs/json-schema/features/required.md | 22 +- specs/json-schema/features/title.md | 8 +- specs/json-schema/features/type.md | 54 +- specs/json-schema/features/uniqueItems.md | 17 +- specs/json-schema/generated-file-layout.md | 59 +- specs/json-schema/nullability.md | 247 +- src/generator/json_schema/python.rs | 4548 ++++++++++----- src/parser/json_schema.rs | 132 +- tests/generate_python.rs | 102 +- 77 files changed, 16956 insertions(+), 8309 deletions(-) create mode 100644 TODO.md create mode 100644 samples/python/tests/json_converter_helper.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 5be2a557..c5efda50 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,6 +48,41 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 same validation as operation-used models, and Java now reports its existing lack of protobuf model support instead of silently dropping protobuf operation types. +- Python: Generated JSON-Schema models are now plain + `@dataclasses.dataclass(slots=True, kw_only=True)` types instead of + `pydantic.BaseModel`s. Each model carries a generated transfer type converter + registered with `temporalio.converter.transfer_type_convertible`, so the models + work with the **default** Temporal data converter — the + `temporalio.contrib.pydantic.pydantic_data_converter` wiring is no longer + needed, and `pydantic` is no longer a dependency of generated code. Every field + is keyword-only, and the wire name of a member is pinned by the converter + rather than by a `Field(alias=...)`. +- Python: `additionalProperties` is now carried by an explicit + `additional_properties: dict[str, V]` member instead of Pydantic's + `model_extra` bag, both for an open declared-property object and for a + map-shaped model. This matches Go, TypeScript, and Java, and keeps the emitted + type's kind stable if `properties` are added to that schema later. Read + `model.additional_properties` where `model.model_extra` was read, and construct + a map-shaped model as `Labels(additional_properties={...})`. +- Python: Validation errors are now a generated `ValidationError` over + `Violation { path, reason }` — the same structured, aggregating error Go, + TypeScript, and Java already surface — instead of `pydantic.ValidationError`. + One bad payload reports every violation it contains, with the JSON path of each + and a reason naming the concrete bound and the offending value. Both types live + in the package's `_definitions` module and are deliberately not re-exported + through `__init__.py`, matching the other three languages. +- Python: An **optional and nullable** member now collapses on round-trip, as it + already does in Go and Java. A dataclass has no presence channel, so an absent + member and an explicit wire `null` read as the same `None`, and both + re-serialize as *omitted*. The set of accepted and rejected values is + unchanged — only the byte-identity of an explicit `null` on the way back out. +- Python: A schema `default` is now **advisory**, matching TypeScript. It is no + longer materialized on read: the member is encoded like any other optional one + (`T | None = None`, omitted when unset, so the wire stays byte-identical), and + the default is emitted as a module-level `DEFAULT_` constant + (`DEFAULT__` when the member name is not unique in the module) + for the consumer to apply — `x if x is not None else DEFAULT_X`. Pydantic used + to surface the default as the field's value; read the constant instead. - JSON Schema: An `x--name` alongside a `$ref` is no longer merged as an implicit-`allOf` conjunct, which cloned the referenced target into the use site. It names the _member_ the reference is bound to and leaves the reference intact diff --git a/TODO.md b/TODO.md new file mode 100644 index 00000000..b7de28a6 --- /dev/null +++ b/TODO.md @@ -0,0 +1,10 @@ +1. default became advisory (T | None = None + DEFAULT_ const) rather than baked into the field. A subagent caught that my original choice made Python's wire a superset of the shared fixtures — a third exception to P1's byte-identity, where P1 allows two. Reversing it cost re-work across the tests, default.md, and the loader's P15 reservation. +2. A _transfer_type_convertible shim in _definitions.py. The bare SDK decorator is circular for pyright, degrading every model to Unknown. The shim keeps the idiomatic decorator on the model and confines the workaround to the runtime. +3. Union serialize dispatch falls through to its last branch instead of guarding it and raising. Guarding it is statically unreachable given the declared type, and basedpyright's warnings fail the gate. P12's real guarantee holds — nothing invalid reaches the wire, it just fails as an AttributeError rather than an aggregated ValidationError, in a case a type checker already rejects. I've documented this explicitly in oneOf.md rather than leaving it implicit. This is the one I'd most want you to sanity-check. + +Two things I deliberately did not fix, both real: + +- src/generator/proto/python.rs has the same decorator typing bug. It's latent only because a test imports those models first and forces a working evaluation order. Generate WIT models without such a test and basedpyright breaks. Out of scope here, but it wants a follow-up — arguably an upstream fix to the SDK's annotations. +- Two leftovers under gitignored target/ (uvbin, validate*.log) that the agent couldn't remove because rm was denied. + +Also fixed five pre-existing drift bugs found en route, none of them mine: the length must be <= N docs wording (wrong for all four languages, 12 occurrences), required.md's invented required property "x" is missing, the false "Python alone is exempt" catch-all claim, uniqueItems.md describing a hash-set that would raise on unhashable dataclasses, and the Go/TS reason-string divergence — documented rather than papered over. diff --git a/advanced/samples/python/json_schema/api/chat/_definitions.py b/advanced/samples/python/json_schema/api/chat/_definitions.py index 00b1bd60..f2b94492 100644 --- a/advanced/samples/python/json_schema/api/chat/_definitions.py +++ b/advanced/samples/python/json_schema/api/chat/_definitions.py @@ -4,143 +4,180 @@ import base64 import collections.abc +import dataclasses import datetime -import math +import json import re import typing - -import pydantic -import pydantic.functional_validators -import pydantic_core +import temporalio.converter __all__ = [ - "SpecInt", - "DateTimeField", - "DateField", - "TimeField", - "DurationField", - "Base64Field", - "Base64UrlField", - "_check_multiple_of", - "_check_pattern", - "_check_format", - "_check_unique_items", + "ValidationError", + "Violation", "_check_contains", - "_reject_explicit_null", - "_emit_set_fields", + "_check_unique_items", + "_collect", + "_format_base64", + "_format_base64url", + "_format_date", + "_format_date_time", + "_format_duration", + "_format_time", + "_parse_base64", + "_parse_base64url", + "_parse_date", + "_parse_date_time", + "_parse_duration", + "_parse_spec_integer", + "_parse_time", + "_quote", + "_transfer_type_convertible", ] -_INTEGER_CAP = (1 << 53) - 1 +@dataclasses.dataclass(frozen=True, slots=True) +class Violation: + """A single constraint failure, located by JSON path.""" + path: str + reason: str -def _parse_spec_integer(value: object) -> int: - if isinstance(value, bool): - raise ValueError("expected integer, got boolean") - if isinstance(value, int): - out = value - elif isinstance(value, float): - if not value.is_integer(): - raise ValueError("number has a fractional part; not an integer") - out = int(value) - else: - raise ValueError(f"expected integer, got {type(value).__name__}") - if abs(out) > _INTEGER_CAP: - raise ValueError("integer exceeds +/-(2**53-1) cap") - return out +class ValidationError(Exception): + """Every constraint failure found in one (de)serialization pass.""" + + violations: list[Violation] + + def __init__(self, violations: list[Violation]) -> None: + self.violations = violations + detail = "; ".join(f"{item.path}: {item.reason}" for item in violations) + super().__init__(f"{len(violations)} validation error(s): {detail}") -SpecInt: typing.TypeAlias = typing.Annotated[ - int, pydantic.functional_validators.BeforeValidator(_parse_spec_integer) -] +def _quote(value: object) -> str: + """Renders a value in the JSON form every target quotes offending values in.""" -def _check_multiple_of( - divisor: float, -) -> typing.Callable[[float], float]: - """Builds an AfterValidator asserting `math.fmod`-exact divisibility for number fields.""" + try: + return json.dumps(value, ensure_ascii=False) + except (TypeError, ValueError): + return repr(value) - def validate(value: float) -> float: - if math.fmod(value, divisor) != 0: - raise ValueError(f"must be a multiple of {divisor}, got {value}") - return value - return validate +def _collect(violations: list[Violation], path: str, error: ValidationError) -> None: + """Re-paths a nested model's violations under `path` and appends them.""" + for inner in error.violations: + # A nested violation about the value *itself* carries no path of its own + # (a union branch's own constraint, an element-level check), so the + # prefix is the whole path -- never a dangling separator (P11). + nested = f"{path}.{inner.path}" if inner.path else path + violations.append(Violation(path=nested, reason=inner.reason)) -def _check_pattern( - pattern: str, -) -> typing.Callable[[str], str]: - """Builds an AfterValidator asserting an unanchored, ASCII-class regex match for string fields.""" - compiled = re.compile(pattern, re.ASCII) +_ModelT = typing.TypeVar("_ModelT") - def validate(value: str) -> str: - if compiled.search(value) is None: - raise ValueError(f"must match pattern {pattern}, got {value!r}") - return value - return validate +def _transfer_type_convertible( + converter: type[temporalio.converter.TransferTypeConverter[typing.Any, typing.Any]], +) -> collections.abc.Callable[[type[_ModelT]], type[_ModelT]]: + """Registers a transfer type converter on a model class. + Wraps `temporalio.converter.transfer_type_convertible` to erase the + converter's value-type parameter. Binding it directly on the decorated class + is circular for a static type checker -- the class's type depends on the + decorator, whose value type depends on the class -- which degrades the model + to `Unknown`. Erasing it here keeps the decorator idiomatic at each model and + resolves the cycle. + """ -def _check_format( - format_name: str, - pattern: str, - max_code_points: int | None = None, -) -> typing.Callable[[str], str]: - """Builds an AfterValidator asserting a value matches a pinned `format` regex (+ optional length guard).""" + return temporalio.converter.transfer_type_convertible(converter) - compiled = re.compile(pattern, re.ASCII) - def validate(value: str) -> str: - if ( - max_code_points is not None and len(value) > max_code_points - ) or compiled.search(value) is None: - raise ValueError(f"must be a valid {format_name}, got {value!r}") - return value +_INTEGER_CAP = (1 << 53) - 1 - return validate + +def _parse_spec_integer( + value: object, path: str, violations: list[Violation] +) -> int | None: + """Parses a JSON number as a spec integer (`1.0` accepted, `1.5` rejected).""" + + # `bool` is a subclass of `int`, so it must be excluded before the int check. + if isinstance(value, bool) or not isinstance(value, (int, float)): + violations.append(Violation(path=path, reason="expected integer")) + return None + if isinstance(value, float): + if not value.is_integer(): + violations.append(Violation(path=path, reason="expected integer")) + return None + out = int(value) + else: + out = value + if abs(out) > _INTEGER_CAP: + violations.append(Violation(path=path, reason="expected integer")) + return None + return out def _check_unique_items( - value: list[typing.Any], -) -> list[typing.Any]: - """An AfterValidator asserting an array's elements are pairwise distinct.""" + value: list[typing.Any], path: str, violations: list[Violation] +) -> None: + """Asserts an array's elements are pairwise distinct.""" - seen: dict[object, int] = {} + seen: list[typing.Any] = [] for index, element in enumerate(value): - if element in seen: - raise ValueError( - f"duplicate items: element at index {index} equals index {seen[element]}" - ) - seen[element] = index - return value + for earlier, previous in enumerate(seen): + if previous == element: + violations.append( + Violation( + path=path, + reason=( + f"duplicate items: element at index {index} " + f"equals index {earlier}" + ), + ) + ) + break + seen.append(element) def _check_contains( + value: list[typing.Any], matches: typing.Callable[[typing.Any], bool], min_contains: int, - max_contains: int | None = None, - bounded_min: bool = False, -) -> typing.Callable[[list[typing.Any]], list[typing.Any]]: - """Builds an AfterValidator asserting how many elements match the `contains` schema.""" - - def validate(value: list[typing.Any]) -> list[typing.Any]: - match_count = sum(1 for element in value if matches(element)) - if match_count < min_contains: - if bounded_min: - raise ValueError( - f"too few matching items: at least {min_contains}, got {match_count}" + max_contains: int | None, + bounded_min: bool, + path: str, + violations: list[Violation], +) -> None: + """Asserts how many of an array's elements match the `contains` schema.""" + + match_count = sum(1 for element in value if matches(element)) + if match_count < min_contains: + if bounded_min: + violations.append( + Violation( + path=path, + reason=( + f"too few matching items: at least {min_contains}, " + f"got {match_count}" + ), ) - raise ValueError("no element matches the required schema") - if max_contains is not None and match_count > max_contains: - raise ValueError( - f"too many matching items: at most {max_contains}, got {match_count}" ) - return value - - return validate + else: + violations.append( + Violation(path=path, reason="no element matches the required schema") + ) + if max_contains is not None and match_count > max_contains: + violations.append( + Violation( + path=path, + reason=( + f"too many matching items: at most {max_contains}, " + f"got {match_count}" + ), + ) + ) _TEMPORAL_DATE_TIME_RE = re.compile( @@ -177,43 +214,59 @@ def _valid_temporal_calendar(value: str) -> bool: return maximum > 0 and 1 <= day <= maximum -def _parse_date_time(value: object) -> object: - if not isinstance(value, str): - return value +def _parse_date_time( + value: str, path: str, violations: list[Violation] +) -> datetime.datetime | None: if _TEMPORAL_DATE_TIME_RE.match(value) is None or not _valid_temporal_calendar( value ): - raise ValueError(f"must be a valid date-time, got {value!r}") + violations.append( + Violation( + path=path, reason=f"must be a valid date-time, got {_quote(value)}" + ) + ) + return None normalized = value.upper() if normalized.endswith("Z"): normalized = normalized[:-1] + "+00:00" return datetime.datetime.fromisoformat(normalized) -def _parse_date(value: object) -> object: - if not isinstance(value, str): - return value +def _parse_date( + value: str, path: str, violations: list[Violation] +) -> datetime.date | None: if _TEMPORAL_DATE_RE.match(value) is None or not _valid_temporal_calendar(value): - raise ValueError(f"must be a valid date, got {value!r}") + violations.append( + Violation(path=path, reason=f"must be a valid date, got {_quote(value)}") + ) + return None return datetime.date.fromisoformat(value) -def _parse_time(value: object) -> object: - if not isinstance(value, str): - return value +def _parse_time( + value: str, path: str, violations: list[Violation] +) -> datetime.time | None: if _TEMPORAL_TIME_RE.match(value) is None: - raise ValueError(f"must be a valid time, got {value!r}") + violations.append( + Violation(path=path, reason=f"must be a valid time, got {_quote(value)}") + ) + return None normalized = value.upper() if normalized.endswith("Z"): normalized = normalized[:-1] + "+00:00" return datetime.time.fromisoformat(normalized) -def _parse_duration(value: object) -> object: - if not isinstance(value, str): - return value +def _parse_duration( + value: str, path: str, violations: list[Violation] +) -> datetime.timedelta | None: if _TEMPORAL_DURATION_RE.match(value) is None: - raise ValueError(f"must be a valid duration, got {value!r}") + violations.append( + Violation( + path=path, reason=f"must be a valid duration, got {_quote(value)}" + ) + ) + return None total = 0 number = "" for char in value[2:]: @@ -223,7 +276,12 @@ def _parse_duration(value: object) -> object: total += int(number) * {"H": 3600, "M": 60, "S": 1}[char] number = "" if total > _TEMPORAL_MAX_DURATION_SECONDS: - raise ValueError(f"must be a valid duration, got {value!r}") + violations.append( + Violation( + path=path, reason=f"must be a valid duration, got {_quote(value)}" + ) + ) + return None return datetime.timedelta(seconds=total) @@ -280,39 +338,18 @@ def _format_duration(value: datetime.timedelta) -> str: return out -DateTimeField: typing.TypeAlias = typing.Annotated[ - datetime.datetime, - pydantic.BeforeValidator(_parse_date_time), - pydantic.PlainSerializer(_format_date_time, return_type=str), -] -DateField: typing.TypeAlias = typing.Annotated[ - datetime.date, - pydantic.BeforeValidator(_parse_date), - pydantic.PlainSerializer(_format_date, return_type=str), -] -TimeField: typing.TypeAlias = typing.Annotated[ - datetime.time, - pydantic.BeforeValidator(_parse_time), - pydantic.PlainSerializer(_format_time, return_type=str), -] -DurationField: typing.TypeAlias = typing.Annotated[ - datetime.timedelta, - pydantic.BeforeValidator(_parse_duration), - pydantic.PlainSerializer(_format_duration, return_type=str), -] - - _BASE64_RE = re.compile( "^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?\\Z", re.ASCII ) _BASE64URL_RE = re.compile("^(?:[A-Za-z0-9_-]{4})*(?:[A-Za-z0-9_-]{2,3})?\\Z", re.ASCII) -def _parse_base64(value: typing.Any) -> bytes: - if isinstance(value, bytes): - return value - if not isinstance(value, str) or _BASE64_RE.match(value) is None: - raise ValueError(f"must be base64-encoded, got {value!r}") +def _parse_base64(value: str, path: str, violations: list[Violation]) -> bytes | None: + if _BASE64_RE.match(value) is None: + violations.append( + Violation(path=path, reason=f"must be base64-encoded, got {_quote(value)}") + ) + return None return base64.b64decode(value, validate=True) @@ -320,91 +357,18 @@ def _format_base64(value: bytes) -> str: return base64.b64encode(value).decode("ascii") -def _parse_base64url(value: typing.Any) -> bytes: - if isinstance(value, bytes): - return value - if not isinstance(value, str) or _BASE64URL_RE.match(value) is None: - raise ValueError(f"must be base64url-encoded, got {value!r}") +def _parse_base64url( + value: str, path: str, violations: list[Violation] +) -> bytes | None: + if _BASE64URL_RE.match(value) is None: + violations.append( + Violation( + path=path, reason=f"must be base64url-encoded, got {_quote(value)}" + ) + ) + return None return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4)) def _format_base64url(value: bytes) -> str: return base64.urlsafe_b64encode(value).rstrip(b"=").decode("ascii") - - -Base64Field: typing.TypeAlias = typing.Annotated[ - bytes, - pydantic.BeforeValidator(_parse_base64), - pydantic.PlainSerializer(_format_base64, return_type=str), -] -Base64UrlField: typing.TypeAlias = typing.Annotated[ - bytes, - pydantic.BeforeValidator(_parse_base64url), - pydantic.PlainSerializer(_format_base64url, return_type=str), -] - - -def _reject_explicit_null( - cls: type[pydantic.BaseModel], - data: object, - handler: typing.Callable[[object], typing.Any], -) -> typing.Any: - null_fields = typing.cast( - frozenset[str], getattr(cls, "_OPTIONAL_NON_NULLABLE_FIELDS") - ) - raw_data = data - pre_errors: list[pydantic_core.InitErrorDetails] = [] - if isinstance(data, dict): - values = typing.cast(dict[str, object], data) - pre_errors = [ - pydantic_core.InitErrorDetails( - type=pydantic_core.PydanticCustomError( - "null_for_nonnullable", "explicit null not allowed" - ), - loc=(field,), - input=None, - ) - for field in null_fields - if field in values and values[field] is None - ] - try: - instance = handler(raw_data) - except pydantic.ValidationError as error: - field_errors: list[pydantic_core.InitErrorDetails] = [] - for error_detail in typing.cast(list[dict[str, object]], error.errors()): - loc: tuple[str | int, ...] = tuple( - typing.cast(collections.abc.Iterable[str | int], error_detail["loc"]) - ) - field_errors.append( - pydantic_core.InitErrorDetails( - type=pydantic_core.PydanticCustomError( - typing.cast(typing.Any, error_detail["type"]), - typing.cast(typing.Any, error_detail["msg"]), - ), - loc=loc, - input=error_detail.get("input"), - ) - ) - raise pydantic.ValidationError.from_exception_data( - title=cls.__name__, line_errors=pre_errors + field_errors - ) from None - if pre_errors: - raise pydantic.ValidationError.from_exception_data( - title=cls.__name__, line_errors=pre_errors - ) - return instance - - -def _emit_set_fields( - model: pydantic.BaseModel, - handler: typing.Callable[[pydantic.BaseModel], typing.Any], -) -> dict[str, object]: - dumped = typing.cast(dict[str, object], handler(model)) - alias_of = { - name: (field.alias or name) for name, field in type(model).model_fields.items() - } - keep = {alias_of.get(name, name) for name in model.model_fields_set} - out = {key: value for key, value in dumped.items() if key in keep} - if model.model_extra: - out.update(typing.cast(dict[str, object], model.model_extra)) - return out diff --git a/advanced/samples/python/json_schema/api/chat/models.py b/advanced/samples/python/json_schema/api/chat/models.py index ba0d0f4b..bd9219f6 100644 --- a/advanced/samples/python/json_schema/api/chat/models.py +++ b/advanced/samples/python/json_schema/api/chat/models.py @@ -2,206 +2,483 @@ from __future__ import annotations +import dataclasses import typing -import pydantic -import pydantic_core +import typing_extensions +import temporalio.converter from ._definitions import ( - SpecInt, - _emit_set_fields, - _reject_explicit_null, + ValidationError, + Violation, + _collect, + _parse_spec_integer, + _transfer_type_convertible, ) -class GetRoomInput(pydantic.BaseModel): - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - strict=True, populate_by_name=True, extra="forbid" - ) +DEFAULT_PRIORITY = 0 - room_id: str = pydantic.Field(alias="roomId") - @pydantic.model_serializer(mode="wrap") - def _serialize( - self, - handler: typing.Callable[[pydantic.BaseModel], typing.Any], - ) -> dict[str, object]: - return _emit_set_fields(self, handler) +_ROOM_DECLARED: frozenset[str] = frozenset( + {"roomId", "displayName", "topic", "members", "labels"} +) -class Labels(pydantic.BaseModel): - """Arbitrary string key/value labels.""" +class _GetRoomInputTransferTypeConverter( + temporalio.converter.TransferTypeConverter["GetRoomInput", typing.Any] +): + @typing_extensions.override + def from_transfer_type( + self, value: typing.Any, type_hint: type["GetRoomInput"] + ) -> "GetRoomInput": + violations: list[Violation] = [] + if not isinstance(value, dict): + raise ValidationError([Violation(path="", reason="expected object")]) + raw = typing.cast("dict[str, typing.Any]", value) + + room_id: str = typing.cast("typing.Any", None) + if "roomId" not in raw or raw["roomId"] is None: + violations.append(Violation(path="roomId", reason="required")) + else: + room_id_raw = raw["roomId"] + if not isinstance(room_id_raw, str): + violations.append(Violation(path="roomId", reason="expected string")) + else: + room_id = room_id_raw + + for key in raw: + if key != "roomId": + violations.append(Violation(path=key, reason="unknown field")) + if violations: + raise ValidationError(violations) + return GetRoomInput( + room_id=room_id, + ) + + @typing_extensions.override + def to_transfer_type(self, value: "GetRoomInput") -> typing.Any: + out: dict[str, typing.Any] = {} + out["roomId"] = value.room_id + return out + + +@_transfer_type_convertible(_GetRoomInputTransferTypeConverter) +@dataclasses.dataclass(slots=True, kw_only=True) +class GetRoomInput: + room_id: str + + +class _LabelsTransferTypeConverter( + temporalio.converter.TransferTypeConverter["Labels", typing.Any] +): + @typing_extensions.override + def from_transfer_type( + self, value: typing.Any, type_hint: type["Labels"] + ) -> "Labels": + violations: list[Violation] = [] + if not isinstance(value, dict): + raise ValidationError([Violation(path="", reason="expected object")]) + raw = typing.cast("dict[str, typing.Any]", value) + if len(raw) > 50: + violations.append( + Violation( + path="", reason=f"must have at most 50 properties, got {len(raw)}" + ) + ) + additional_properties: dict[str, str] = {} + for key in raw: + member: str = typing.cast("typing.Any", None) + member_raw = raw[key] + if not isinstance(member_raw, str): + violations.append(Violation(path=key, reason="expected string")) + else: + member = member_raw + additional_properties[key] = member + if violations: + raise ValidationError(violations) + return Labels(additional_properties=additional_properties) + + @typing_extensions.override + def to_transfer_type(self, value: "Labels") -> typing.Any: + violations: list[Violation] = [] + out: dict[str, typing.Any] = {} + for key, entry in value.additional_properties.items(): + out[key] = entry + if len(out) > 50: + violations.append( + Violation( + path="", reason=f"must have at most 50 properties, got {len(out)}" + ) + ) + if violations: + raise ValidationError(violations) + return out - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - strict=True, populate_by_name=True, extra="allow" - ) - @pydantic.model_validator(mode="after") - def _validate_extras(self) -> typing.Any: - extra = typing.cast(dict[str, object], self.model_extra or {}) - errors: list[pydantic_core.InitErrorDetails] = [] - for key, value in list(extra.items()): - try: - extra[key] = _LABELS_MEMBER.validate_python(value) - except pydantic.ValidationError as error: - for detail in error.errors(): - errors.append( - pydantic_core.InitErrorDetails( - type=pydantic_core.PydanticCustomError( - typing.cast(typing.Any, detail["type"]), - typing.cast(typing.Any, detail["msg"]), - ), - loc=(key, *detail["loc"]), - input=detail["input"], - ) +@_transfer_type_convertible(_LabelsTransferTypeConverter) +@dataclasses.dataclass(slots=True, kw_only=True) +class Labels: + """Arbitrary string key/value labels.""" + + additional_properties: dict[str, str] = dataclasses.field(default_factory=dict) + + +class _MessageTransferTypeConverter( + temporalio.converter.TransferTypeConverter["Message", typing.Any] +): + @typing_extensions.override + def from_transfer_type( + self, value: typing.Any, type_hint: type["Message"] + ) -> "Message": + violations: list[Violation] = [] + if not isinstance(value, dict): + raise ValidationError([Violation(path="", reason="expected object")]) + raw = typing.cast("dict[str, typing.Any]", value) + + kind: typing.Literal["text"] = typing.cast("typing.Any", None) + if "kind" not in raw or raw["kind"] is None: + violations.append(Violation(path="kind", reason="required")) + else: + kind_raw = raw["kind"] + if not isinstance(kind_raw, str): + violations.append(Violation(path="kind", reason="expected string")) + elif kind_raw != "text": + violations.append(Violation(path="kind", reason='must equal "text"')) + else: + kind = kind_raw + + body: str = typing.cast("typing.Any", None) + if "body" not in raw or raw["body"] is None: + violations.append(Violation(path="body", reason="required")) + else: + body_raw = raw["body"] + if not isinstance(body_raw, str): + violations.append(Violation(path="body", reason="expected string")) + else: + body = body_raw + + reply_to_id: str | None = None + if "replyToId" in raw: + reply_to_id_raw = raw["replyToId"] + if reply_to_id_raw is None: + reply_to_id = None + else: + if not isinstance(reply_to_id_raw, str): + violations.append( + Violation(path="replyToId", reason="expected string") ) - if len(extra) > 50: - errors.append( - pydantic_core.InitErrorDetails( - type=pydantic_core.PydanticCustomError( - "too_many_properties", - typing.cast( - typing.Any, - f"must have at most 50 properties, got {len(extra)}", - ), - ), - loc=(), - input=len(extra), + else: + reply_to_id = reply_to_id_raw + + priority: int | None = None + if "priority" in raw: + priority_raw = raw["priority"] + if priority_raw is None: + violations.append( + Violation(path="priority", reason="explicit null not allowed") ) - ) - if errors: - raise pydantic.ValidationError.from_exception_data( - title=type(self).__name__, line_errors=errors - ) - return self - - @pydantic.model_serializer(mode="wrap") - def _serialize( - self, - _handler: typing.Callable[[pydantic.BaseModel], typing.Any], - ) -> dict[str, object]: - return { - key: _LABELS_MEMBER.dump_python(value, mode="json", by_alias=True) - for key, value in typing.cast( - dict[str, object], self.model_extra or {} - ).items() - } - - -class Message(pydantic.BaseModel): + else: + priority_parsed = _parse_spec_integer( + priority_raw, "priority", violations + ) + if priority_parsed is not None: + priority = priority_parsed + + for key in raw: + if ( + key != "kind" + and key != "body" + and key != "replyToId" + and key != "priority" + ): + violations.append(Violation(path=key, reason="unknown field")) + if violations: + raise ValidationError(violations) + return Message( + kind=kind, + body=body, + reply_to_id=reply_to_id, + priority=priority, + ) + + @typing_extensions.override + def to_transfer_type(self, value: "Message") -> typing.Any: + violations: list[Violation] = [] + out: dict[str, typing.Any] = {} + if typing.cast("object", value.kind) not in ("text",): + violations.append(Violation(path="kind", reason='must equal "text"')) + out["kind"] = value.kind + out["body"] = value.body + if value.reply_to_id is not None: + out["replyToId"] = value.reply_to_id + if value.priority is not None: + out["priority"] = value.priority + if violations: + raise ValidationError(violations) + return out + + +@_transfer_type_convertible(_MessageTransferTypeConverter) +@dataclasses.dataclass(slots=True, kw_only=True) +class Message: """A chat message.""" - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - strict=True, populate_by_name=True, extra="forbid" - ) - - kind: typing.Literal["text"] = pydantic.Field(default="text") + kind: typing.Literal["text"] = "text" """Discriminator; always "text".""" - body: str = pydantic.Field() + body: str - reply_to_id: str | None = pydantic.Field(default=None, alias="replyToId") + reply_to_id: str | None = None """Id of the message this replies to, if any.""" - priority: SpecInt = pydantic.Field(default=0) + priority: int | None = None """Delivery priority.""" - @pydantic.model_validator(mode="before") - @classmethod - def _inject_const_kind( - cls, - data: object, - ) -> object: - if isinstance(data, dict): - values = typing.cast(dict[str, object], data) - if "kind" not in values: - data = {**values, "kind": "text"} - elif values.get("kind", values.get("kind")) != "text": - raise pydantic_core.PydanticCustomError( - "const", 'kind must equal "text"' - ) - return typing.cast(object, data) - @pydantic.model_serializer(mode="wrap") - def _serialize( - self, - handler: typing.Callable[[pydantic.BaseModel], typing.Any], - ) -> dict[str, object]: - return _emit_set_fields(self, handler) +class _RoomTransferTypeConverter( + temporalio.converter.TransferTypeConverter["Room", typing.Any] +): + @typing_extensions.override + def from_transfer_type(self, value: typing.Any, type_hint: type["Room"]) -> "Room": + violations: list[Violation] = [] + if not isinstance(value, dict): + raise ValidationError([Violation(path="", reason="expected object")]) + raw = typing.cast("dict[str, typing.Any]", value) + + room_id: str = typing.cast("typing.Any", None) + if "roomId" not in raw or raw["roomId"] is None: + violations.append(Violation(path="roomId", reason="required")) + else: + room_id_raw = raw["roomId"] + if not isinstance(room_id_raw, str): + violations.append(Violation(path="roomId", reason="expected string")) + else: + room_id = room_id_raw + + display_name: str = typing.cast("typing.Any", None) + if "displayName" not in raw or raw["displayName"] is None: + violations.append(Violation(path="displayName", reason="required")) + else: + display_name_raw = raw["displayName"] + if not isinstance(display_name_raw, str): + violations.append( + Violation(path="displayName", reason="expected string") + ) + else: + display_name = display_name_raw + + topic: str | None = None + if "topic" not in raw: + violations.append(Violation(path="topic", reason="required")) + else: + topic_raw = raw["topic"] + if topic_raw is None: + topic = None + else: + if not isinstance(topic_raw, str): + violations.append(Violation(path="topic", reason="expected string")) + else: + topic = topic_raw + + members: list[str] | None = None + if "members" in raw: + members_raw = raw["members"] + if members_raw is None: + violations.append( + Violation(path="members", reason="explicit null not allowed") + ) + else: + if not isinstance(members_raw, list): + violations.append( + Violation(path="members", reason="expected array") + ) + else: + members_list: list[str] = [] + for members_index, members_element in enumerate( + typing.cast("list[typing.Any]", members_raw) + ): + members_item_path = f"members[{members_index}]" + members_item: str = typing.cast("typing.Any", None) + if not isinstance(members_element, str): + violations.append( + Violation( + path=members_item_path, reason="expected element" + ) + ) + else: + members_item = members_element + members_list.append(members_item) + members = members_list + + labels: Labels | None = None + if "labels" in raw: + labels_raw = raw["labels"] + if labels_raw is None: + violations.append( + Violation(path="labels", reason="explicit null not allowed") + ) + else: + try: + labels = _LabelsTransferTypeConverter().from_transfer_type( + labels_raw, Labels + ) + except ValidationError as error: + _collect(violations, "labels", error) + + additional_properties: dict[str, typing.Any] = {} + for key in raw: + if key not in _ROOM_DECLARED: + additional_properties[key] = raw[key] + if violations: + raise ValidationError(violations) + return Room( + room_id=room_id, + display_name=display_name, + topic=topic, + members=members, + labels=labels, + additional_properties=additional_properties, + ) + + @typing_extensions.override + def to_transfer_type(self, value: "Room") -> typing.Any: + out: dict[str, typing.Any] = {} + out["roomId"] = value.room_id + out["displayName"] = value.display_name + out["topic"] = value.topic + if value.members is not None: + out["members"] = value.members + if value.labels is not None: + out["labels"] = _LabelsTransferTypeConverter().to_transfer_type( + value.labels + ) + for key, entry in value.additional_properties.items(): + out[key] = entry + return out -class Room(pydantic.BaseModel): +@_transfer_type_convertible(_RoomTransferTypeConverter) +@dataclasses.dataclass(slots=True, kw_only=True) +class Room: """A chat room. Open to forward-compatible extension.""" - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - strict=True, populate_by_name=True, extra="allow" - ) + room_id: str - room_id: str = pydantic.Field(alias="roomId") + display_name: str - display_name: str = pydantic.Field(alias="displayName") - - topic: str | None = pydantic.Field() + topic: str | None """Room topic; may be explicitly cleared to null.""" - members: list[str] | None = pydantic.Field(default=None) + members: list[str] | None = None - labels: Labels | None = pydantic.Field(default=None) + labels: Labels | None = None - _OPTIONAL_NON_NULLABLE_FIELDS: typing.ClassVar[frozenset[str]] = frozenset( - {"labels", "members"} + additional_properties: dict[str, typing.Any] = dataclasses.field( + default_factory=dict ) - @pydantic.model_validator(mode="wrap") - @classmethod - def _reject_null( - cls, - data: object, - handler: typing.Callable[[object], typing.Any], - ) -> typing.Any: - return _reject_explicit_null(cls, data, handler) - - @pydantic.model_serializer(mode="wrap") - def _serialize( - self, - handler: typing.Callable[[pydantic.BaseModel], typing.Any], - ) -> dict[str, object]: - return _emit_set_fields(self, handler) - -class SendMessageInput(pydantic.BaseModel): +class _SendMessageInputTransferTypeConverter( + temporalio.converter.TransferTypeConverter["SendMessageInput", typing.Any] +): + @typing_extensions.override + def from_transfer_type( + self, value: typing.Any, type_hint: type["SendMessageInput"] + ) -> "SendMessageInput": + violations: list[Violation] = [] + if not isinstance(value, dict): + raise ValidationError([Violation(path="", reason="expected object")]) + raw = typing.cast("dict[str, typing.Any]", value) + + room_id: str = typing.cast("typing.Any", None) + if "roomId" not in raw or raw["roomId"] is None: + violations.append(Violation(path="roomId", reason="required")) + else: + room_id_raw = raw["roomId"] + if not isinstance(room_id_raw, str): + violations.append(Violation(path="roomId", reason="expected string")) + else: + room_id = room_id_raw + + message: Message = typing.cast("typing.Any", None) + if "message" not in raw or raw["message"] is None: + violations.append(Violation(path="message", reason="required")) + else: + message_raw = raw["message"] + try: + message = _MessageTransferTypeConverter().from_transfer_type( + message_raw, Message + ) + except ValidationError as error: + _collect(violations, "message", error) + + for key in raw: + if key != "roomId" and key != "message": + violations.append(Violation(path=key, reason="unknown field")) + if violations: + raise ValidationError(violations) + return SendMessageInput( + room_id=room_id, + message=message, + ) + + @typing_extensions.override + def to_transfer_type(self, value: "SendMessageInput") -> typing.Any: + out: dict[str, typing.Any] = {} + out["roomId"] = value.room_id + out["message"] = _MessageTransferTypeConverter().to_transfer_type(value.message) + return out + + +@_transfer_type_convertible(_SendMessageInputTransferTypeConverter) +@dataclasses.dataclass(slots=True, kw_only=True) +class SendMessageInput: """Request to post a message.""" - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - strict=True, populate_by_name=True, extra="forbid" - ) - - room_id: str = pydantic.Field(alias="roomId") - - message: Message = pydantic.Field() - - @pydantic.model_serializer(mode="wrap") - def _serialize( - self, - handler: typing.Callable[[pydantic.BaseModel], typing.Any], - ) -> dict[str, object]: - return _emit_set_fields(self, handler) - - -class SendMessageOutput(pydantic.BaseModel): - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - strict=True, populate_by_name=True, extra="forbid" - ) - - message_id: str = pydantic.Field(alias="messageId") - - @pydantic.model_serializer(mode="wrap") - def _serialize( - self, - handler: typing.Callable[[pydantic.BaseModel], typing.Any], - ) -> dict[str, object]: - return _emit_set_fields(self, handler) - - -_LABELS_MEMBER: pydantic.TypeAdapter[typing.Any] = pydantic.TypeAdapter( - str, config=pydantic.ConfigDict(strict=True) -) + room_id: str + + message: Message + + +class _SendMessageOutputTransferTypeConverter( + temporalio.converter.TransferTypeConverter["SendMessageOutput", typing.Any] +): + @typing_extensions.override + def from_transfer_type( + self, value: typing.Any, type_hint: type["SendMessageOutput"] + ) -> "SendMessageOutput": + violations: list[Violation] = [] + if not isinstance(value, dict): + raise ValidationError([Violation(path="", reason="expected object")]) + raw = typing.cast("dict[str, typing.Any]", value) + + message_id: str = typing.cast("typing.Any", None) + if "messageId" not in raw or raw["messageId"] is None: + violations.append(Violation(path="messageId", reason="required")) + else: + message_id_raw = raw["messageId"] + if not isinstance(message_id_raw, str): + violations.append(Violation(path="messageId", reason="expected string")) + else: + message_id = message_id_raw + + for key in raw: + if key != "messageId": + violations.append(Violation(path=key, reason="unknown field")) + if violations: + raise ValidationError(violations) + return SendMessageOutput( + message_id=message_id, + ) + + @typing_extensions.override + def to_transfer_type(self, value: "SendMessageOutput") -> typing.Any: + out: dict[str, typing.Any] = {} + out["messageId"] = value.message_id + return out + + +@_transfer_type_convertible(_SendMessageOutputTransferTypeConverter) +@dataclasses.dataclass(slots=True, kw_only=True) +class SendMessageOutput: + message_id: str diff --git a/advanced/samples/python/json_schema/api/kb/_definitions.py b/advanced/samples/python/json_schema/api/kb/_definitions.py index 00b1bd60..f2b94492 100644 --- a/advanced/samples/python/json_schema/api/kb/_definitions.py +++ b/advanced/samples/python/json_schema/api/kb/_definitions.py @@ -4,143 +4,180 @@ import base64 import collections.abc +import dataclasses import datetime -import math +import json import re import typing - -import pydantic -import pydantic.functional_validators -import pydantic_core +import temporalio.converter __all__ = [ - "SpecInt", - "DateTimeField", - "DateField", - "TimeField", - "DurationField", - "Base64Field", - "Base64UrlField", - "_check_multiple_of", - "_check_pattern", - "_check_format", - "_check_unique_items", + "ValidationError", + "Violation", "_check_contains", - "_reject_explicit_null", - "_emit_set_fields", + "_check_unique_items", + "_collect", + "_format_base64", + "_format_base64url", + "_format_date", + "_format_date_time", + "_format_duration", + "_format_time", + "_parse_base64", + "_parse_base64url", + "_parse_date", + "_parse_date_time", + "_parse_duration", + "_parse_spec_integer", + "_parse_time", + "_quote", + "_transfer_type_convertible", ] -_INTEGER_CAP = (1 << 53) - 1 +@dataclasses.dataclass(frozen=True, slots=True) +class Violation: + """A single constraint failure, located by JSON path.""" + path: str + reason: str -def _parse_spec_integer(value: object) -> int: - if isinstance(value, bool): - raise ValueError("expected integer, got boolean") - if isinstance(value, int): - out = value - elif isinstance(value, float): - if not value.is_integer(): - raise ValueError("number has a fractional part; not an integer") - out = int(value) - else: - raise ValueError(f"expected integer, got {type(value).__name__}") - if abs(out) > _INTEGER_CAP: - raise ValueError("integer exceeds +/-(2**53-1) cap") - return out +class ValidationError(Exception): + """Every constraint failure found in one (de)serialization pass.""" + + violations: list[Violation] + + def __init__(self, violations: list[Violation]) -> None: + self.violations = violations + detail = "; ".join(f"{item.path}: {item.reason}" for item in violations) + super().__init__(f"{len(violations)} validation error(s): {detail}") -SpecInt: typing.TypeAlias = typing.Annotated[ - int, pydantic.functional_validators.BeforeValidator(_parse_spec_integer) -] +def _quote(value: object) -> str: + """Renders a value in the JSON form every target quotes offending values in.""" -def _check_multiple_of( - divisor: float, -) -> typing.Callable[[float], float]: - """Builds an AfterValidator asserting `math.fmod`-exact divisibility for number fields.""" + try: + return json.dumps(value, ensure_ascii=False) + except (TypeError, ValueError): + return repr(value) - def validate(value: float) -> float: - if math.fmod(value, divisor) != 0: - raise ValueError(f"must be a multiple of {divisor}, got {value}") - return value - return validate +def _collect(violations: list[Violation], path: str, error: ValidationError) -> None: + """Re-paths a nested model's violations under `path` and appends them.""" + for inner in error.violations: + # A nested violation about the value *itself* carries no path of its own + # (a union branch's own constraint, an element-level check), so the + # prefix is the whole path -- never a dangling separator (P11). + nested = f"{path}.{inner.path}" if inner.path else path + violations.append(Violation(path=nested, reason=inner.reason)) -def _check_pattern( - pattern: str, -) -> typing.Callable[[str], str]: - """Builds an AfterValidator asserting an unanchored, ASCII-class regex match for string fields.""" - compiled = re.compile(pattern, re.ASCII) +_ModelT = typing.TypeVar("_ModelT") - def validate(value: str) -> str: - if compiled.search(value) is None: - raise ValueError(f"must match pattern {pattern}, got {value!r}") - return value - return validate +def _transfer_type_convertible( + converter: type[temporalio.converter.TransferTypeConverter[typing.Any, typing.Any]], +) -> collections.abc.Callable[[type[_ModelT]], type[_ModelT]]: + """Registers a transfer type converter on a model class. + Wraps `temporalio.converter.transfer_type_convertible` to erase the + converter's value-type parameter. Binding it directly on the decorated class + is circular for a static type checker -- the class's type depends on the + decorator, whose value type depends on the class -- which degrades the model + to `Unknown`. Erasing it here keeps the decorator idiomatic at each model and + resolves the cycle. + """ -def _check_format( - format_name: str, - pattern: str, - max_code_points: int | None = None, -) -> typing.Callable[[str], str]: - """Builds an AfterValidator asserting a value matches a pinned `format` regex (+ optional length guard).""" + return temporalio.converter.transfer_type_convertible(converter) - compiled = re.compile(pattern, re.ASCII) - def validate(value: str) -> str: - if ( - max_code_points is not None and len(value) > max_code_points - ) or compiled.search(value) is None: - raise ValueError(f"must be a valid {format_name}, got {value!r}") - return value +_INTEGER_CAP = (1 << 53) - 1 - return validate + +def _parse_spec_integer( + value: object, path: str, violations: list[Violation] +) -> int | None: + """Parses a JSON number as a spec integer (`1.0` accepted, `1.5` rejected).""" + + # `bool` is a subclass of `int`, so it must be excluded before the int check. + if isinstance(value, bool) or not isinstance(value, (int, float)): + violations.append(Violation(path=path, reason="expected integer")) + return None + if isinstance(value, float): + if not value.is_integer(): + violations.append(Violation(path=path, reason="expected integer")) + return None + out = int(value) + else: + out = value + if abs(out) > _INTEGER_CAP: + violations.append(Violation(path=path, reason="expected integer")) + return None + return out def _check_unique_items( - value: list[typing.Any], -) -> list[typing.Any]: - """An AfterValidator asserting an array's elements are pairwise distinct.""" + value: list[typing.Any], path: str, violations: list[Violation] +) -> None: + """Asserts an array's elements are pairwise distinct.""" - seen: dict[object, int] = {} + seen: list[typing.Any] = [] for index, element in enumerate(value): - if element in seen: - raise ValueError( - f"duplicate items: element at index {index} equals index {seen[element]}" - ) - seen[element] = index - return value + for earlier, previous in enumerate(seen): + if previous == element: + violations.append( + Violation( + path=path, + reason=( + f"duplicate items: element at index {index} " + f"equals index {earlier}" + ), + ) + ) + break + seen.append(element) def _check_contains( + value: list[typing.Any], matches: typing.Callable[[typing.Any], bool], min_contains: int, - max_contains: int | None = None, - bounded_min: bool = False, -) -> typing.Callable[[list[typing.Any]], list[typing.Any]]: - """Builds an AfterValidator asserting how many elements match the `contains` schema.""" - - def validate(value: list[typing.Any]) -> list[typing.Any]: - match_count = sum(1 for element in value if matches(element)) - if match_count < min_contains: - if bounded_min: - raise ValueError( - f"too few matching items: at least {min_contains}, got {match_count}" + max_contains: int | None, + bounded_min: bool, + path: str, + violations: list[Violation], +) -> None: + """Asserts how many of an array's elements match the `contains` schema.""" + + match_count = sum(1 for element in value if matches(element)) + if match_count < min_contains: + if bounded_min: + violations.append( + Violation( + path=path, + reason=( + f"too few matching items: at least {min_contains}, " + f"got {match_count}" + ), ) - raise ValueError("no element matches the required schema") - if max_contains is not None and match_count > max_contains: - raise ValueError( - f"too many matching items: at most {max_contains}, got {match_count}" ) - return value - - return validate + else: + violations.append( + Violation(path=path, reason="no element matches the required schema") + ) + if max_contains is not None and match_count > max_contains: + violations.append( + Violation( + path=path, + reason=( + f"too many matching items: at most {max_contains}, " + f"got {match_count}" + ), + ) + ) _TEMPORAL_DATE_TIME_RE = re.compile( @@ -177,43 +214,59 @@ def _valid_temporal_calendar(value: str) -> bool: return maximum > 0 and 1 <= day <= maximum -def _parse_date_time(value: object) -> object: - if not isinstance(value, str): - return value +def _parse_date_time( + value: str, path: str, violations: list[Violation] +) -> datetime.datetime | None: if _TEMPORAL_DATE_TIME_RE.match(value) is None or not _valid_temporal_calendar( value ): - raise ValueError(f"must be a valid date-time, got {value!r}") + violations.append( + Violation( + path=path, reason=f"must be a valid date-time, got {_quote(value)}" + ) + ) + return None normalized = value.upper() if normalized.endswith("Z"): normalized = normalized[:-1] + "+00:00" return datetime.datetime.fromisoformat(normalized) -def _parse_date(value: object) -> object: - if not isinstance(value, str): - return value +def _parse_date( + value: str, path: str, violations: list[Violation] +) -> datetime.date | None: if _TEMPORAL_DATE_RE.match(value) is None or not _valid_temporal_calendar(value): - raise ValueError(f"must be a valid date, got {value!r}") + violations.append( + Violation(path=path, reason=f"must be a valid date, got {_quote(value)}") + ) + return None return datetime.date.fromisoformat(value) -def _parse_time(value: object) -> object: - if not isinstance(value, str): - return value +def _parse_time( + value: str, path: str, violations: list[Violation] +) -> datetime.time | None: if _TEMPORAL_TIME_RE.match(value) is None: - raise ValueError(f"must be a valid time, got {value!r}") + violations.append( + Violation(path=path, reason=f"must be a valid time, got {_quote(value)}") + ) + return None normalized = value.upper() if normalized.endswith("Z"): normalized = normalized[:-1] + "+00:00" return datetime.time.fromisoformat(normalized) -def _parse_duration(value: object) -> object: - if not isinstance(value, str): - return value +def _parse_duration( + value: str, path: str, violations: list[Violation] +) -> datetime.timedelta | None: if _TEMPORAL_DURATION_RE.match(value) is None: - raise ValueError(f"must be a valid duration, got {value!r}") + violations.append( + Violation( + path=path, reason=f"must be a valid duration, got {_quote(value)}" + ) + ) + return None total = 0 number = "" for char in value[2:]: @@ -223,7 +276,12 @@ def _parse_duration(value: object) -> object: total += int(number) * {"H": 3600, "M": 60, "S": 1}[char] number = "" if total > _TEMPORAL_MAX_DURATION_SECONDS: - raise ValueError(f"must be a valid duration, got {value!r}") + violations.append( + Violation( + path=path, reason=f"must be a valid duration, got {_quote(value)}" + ) + ) + return None return datetime.timedelta(seconds=total) @@ -280,39 +338,18 @@ def _format_duration(value: datetime.timedelta) -> str: return out -DateTimeField: typing.TypeAlias = typing.Annotated[ - datetime.datetime, - pydantic.BeforeValidator(_parse_date_time), - pydantic.PlainSerializer(_format_date_time, return_type=str), -] -DateField: typing.TypeAlias = typing.Annotated[ - datetime.date, - pydantic.BeforeValidator(_parse_date), - pydantic.PlainSerializer(_format_date, return_type=str), -] -TimeField: typing.TypeAlias = typing.Annotated[ - datetime.time, - pydantic.BeforeValidator(_parse_time), - pydantic.PlainSerializer(_format_time, return_type=str), -] -DurationField: typing.TypeAlias = typing.Annotated[ - datetime.timedelta, - pydantic.BeforeValidator(_parse_duration), - pydantic.PlainSerializer(_format_duration, return_type=str), -] - - _BASE64_RE = re.compile( "^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?\\Z", re.ASCII ) _BASE64URL_RE = re.compile("^(?:[A-Za-z0-9_-]{4})*(?:[A-Za-z0-9_-]{2,3})?\\Z", re.ASCII) -def _parse_base64(value: typing.Any) -> bytes: - if isinstance(value, bytes): - return value - if not isinstance(value, str) or _BASE64_RE.match(value) is None: - raise ValueError(f"must be base64-encoded, got {value!r}") +def _parse_base64(value: str, path: str, violations: list[Violation]) -> bytes | None: + if _BASE64_RE.match(value) is None: + violations.append( + Violation(path=path, reason=f"must be base64-encoded, got {_quote(value)}") + ) + return None return base64.b64decode(value, validate=True) @@ -320,91 +357,18 @@ def _format_base64(value: bytes) -> str: return base64.b64encode(value).decode("ascii") -def _parse_base64url(value: typing.Any) -> bytes: - if isinstance(value, bytes): - return value - if not isinstance(value, str) or _BASE64URL_RE.match(value) is None: - raise ValueError(f"must be base64url-encoded, got {value!r}") +def _parse_base64url( + value: str, path: str, violations: list[Violation] +) -> bytes | None: + if _BASE64URL_RE.match(value) is None: + violations.append( + Violation( + path=path, reason=f"must be base64url-encoded, got {_quote(value)}" + ) + ) + return None return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4)) def _format_base64url(value: bytes) -> str: return base64.urlsafe_b64encode(value).rstrip(b"=").decode("ascii") - - -Base64Field: typing.TypeAlias = typing.Annotated[ - bytes, - pydantic.BeforeValidator(_parse_base64), - pydantic.PlainSerializer(_format_base64, return_type=str), -] -Base64UrlField: typing.TypeAlias = typing.Annotated[ - bytes, - pydantic.BeforeValidator(_parse_base64url), - pydantic.PlainSerializer(_format_base64url, return_type=str), -] - - -def _reject_explicit_null( - cls: type[pydantic.BaseModel], - data: object, - handler: typing.Callable[[object], typing.Any], -) -> typing.Any: - null_fields = typing.cast( - frozenset[str], getattr(cls, "_OPTIONAL_NON_NULLABLE_FIELDS") - ) - raw_data = data - pre_errors: list[pydantic_core.InitErrorDetails] = [] - if isinstance(data, dict): - values = typing.cast(dict[str, object], data) - pre_errors = [ - pydantic_core.InitErrorDetails( - type=pydantic_core.PydanticCustomError( - "null_for_nonnullable", "explicit null not allowed" - ), - loc=(field,), - input=None, - ) - for field in null_fields - if field in values and values[field] is None - ] - try: - instance = handler(raw_data) - except pydantic.ValidationError as error: - field_errors: list[pydantic_core.InitErrorDetails] = [] - for error_detail in typing.cast(list[dict[str, object]], error.errors()): - loc: tuple[str | int, ...] = tuple( - typing.cast(collections.abc.Iterable[str | int], error_detail["loc"]) - ) - field_errors.append( - pydantic_core.InitErrorDetails( - type=pydantic_core.PydanticCustomError( - typing.cast(typing.Any, error_detail["type"]), - typing.cast(typing.Any, error_detail["msg"]), - ), - loc=loc, - input=error_detail.get("input"), - ) - ) - raise pydantic.ValidationError.from_exception_data( - title=cls.__name__, line_errors=pre_errors + field_errors - ) from None - if pre_errors: - raise pydantic.ValidationError.from_exception_data( - title=cls.__name__, line_errors=pre_errors - ) - return instance - - -def _emit_set_fields( - model: pydantic.BaseModel, - handler: typing.Callable[[pydantic.BaseModel], typing.Any], -) -> dict[str, object]: - dumped = typing.cast(dict[str, object], handler(model)) - alias_of = { - name: (field.alias or name) for name, field in type(model).model_fields.items() - } - keep = {alias_of.get(name, name) for name in model.model_fields_set} - out = {key: value for key, value in dumped.items() if key in keep} - if model.model_extra: - out.update(typing.cast(dict[str, object], model.model_extra)) - return out diff --git a/advanced/samples/python/json_schema/api/kb/_recursive.py b/advanced/samples/python/json_schema/api/kb/_recursive.py index b8aa9cc9..1cac48ca 100644 --- a/advanced/samples/python/json_schema/api/kb/_recursive.py +++ b/advanced/samples/python/json_schema/api/kb/_recursive.py @@ -2,13 +2,17 @@ from __future__ import annotations +import dataclasses import typing -import pydantic +import typing_extensions +import temporalio.converter from ._definitions import ( - SpecInt, - _emit_set_fields, - _reject_explicit_null, + ValidationError, + Violation, + _collect, + _parse_spec_integer, + _transfer_type_convertible, ) from .content.block.models import BlockStyle @@ -16,98 +20,266 @@ from .content.page.models import PageMeta -class Block(pydantic.BaseModel): +class _BlockTransferTypeConverter( + temporalio.converter.TransferTypeConverter["Block", typing.Any] +): + @typing_extensions.override + def from_transfer_type( + self, value: typing.Any, type_hint: type["Block"] + ) -> "Block": + violations: list[Violation] = [] + if not isinstance(value, dict): + raise ValidationError([Violation(path="", reason="expected object")]) + raw = typing.cast("dict[str, typing.Any]", value) + + block_id: str = typing.cast("typing.Any", None) + if "blockId" not in raw or raw["blockId"] is None: + violations.append(Violation(path="blockId", reason="required")) + else: + block_id_raw = raw["blockId"] + if not isinstance(block_id_raw, str): + violations.append(Violation(path="blockId", reason="expected string")) + else: + block_id = block_id_raw + + order: int = typing.cast("typing.Any", None) + if "order" not in raw or raw["order"] is None: + violations.append(Violation(path="order", reason="required")) + else: + order_raw = raw["order"] + order_parsed = _parse_spec_integer(order_raw, "order", violations) + if order_parsed is not None: + order = order_parsed + if order < 0: + violations.append( + Violation(path="order", reason=f"must be >= 0, got {order}") + ) + + text: str | None = None + if "text" in raw: + text_raw = raw["text"] + if text_raw is None: + violations.append( + Violation(path="text", reason="explicit null not allowed") + ) + else: + if not isinstance(text_raw, str): + violations.append(Violation(path="text", reason="expected string")) + else: + text = text_raw + + style: BlockStyle | None = None + if "style" in raw: + style_raw = raw["style"] + if style_raw is None: + violations.append( + Violation(path="style", reason="explicit null not allowed") + ) + else: + try: + style = getattr( + BlockStyle, "__temporal_transfer_type_converter" + ).from_transfer_type(style_raw, BlockStyle) + except ValidationError as error: + _collect(violations, "style", error) + + page: Page | None = None + if "page" in raw: + page_raw = raw["page"] + if page_raw is None: + page = None + else: + try: + page = _PageTransferTypeConverter().from_transfer_type( + page_raw, Page + ) + except ValidationError as error: + _collect(violations, "page", error) + + for key in raw: + if ( + key != "blockId" + and key != "order" + and key != "text" + and key != "style" + and key != "page" + ): + violations.append(Violation(path=key, reason="unknown field")) + if violations: + raise ValidationError(violations) + return Block( + block_id=block_id, + order=order, + text=text, + style=style, + page=page, + ) + + @typing_extensions.override + def to_transfer_type(self, value: "Block") -> typing.Any: + violations: list[Violation] = [] + out: dict[str, typing.Any] = {} + out["blockId"] = value.block_id + if value.order < 0: + violations.append( + Violation(path="order", reason=f"must be >= 0, got {value.order}") + ) + out["order"] = value.order + if value.text is not None: + out["text"] = value.text + if value.style is not None: + out["style"] = getattr( + BlockStyle, "__temporal_transfer_type_converter" + ).to_transfer_type(value.style) + if value.page is not None: + out["page"] = _PageTransferTypeConverter().to_transfer_type(value.page) + if violations: + raise ValidationError(violations) + return out + + +@_transfer_type_convertible(_BlockTransferTypeConverter) +@dataclasses.dataclass(slots=True, kw_only=True) +class Block: """A content block. The other half of the Page <-> Block cross-file cycle. The `page` back-reference is optional + nullable, which terminates the cycle so it is satisfiable. """ - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - strict=True, populate_by_name=True, extra="forbid" - ) + block_id: str - block_id: str = pydantic.Field(alias="blockId") - - order: SpecInt = pydantic.Field(ge=0) + order: int """Non-negative position within the page. Exercises a numeric `minimum` bound over an integer field. """ - text: str | None = pydantic.Field(default=None) + text: str | None = None - style: BlockStyle | None = pydantic.Field(default=None) + style: BlockStyle | None = None - page: Page | None = pydantic.Field(default=None) + page: Page | None = None """Optional back-reference to the containing page - closes the Page <-> Block cycle. Optional + nullable, so this edge terminates. """ - _OPTIONAL_NON_NULLABLE_FIELDS: typing.ClassVar[frozenset[str]] = frozenset( - {"style", "text"} - ) - @pydantic.model_validator(mode="wrap") - @classmethod - def _reject_null( - cls, - data: object, - handler: typing.Callable[[object], typing.Any], - ) -> typing.Any: - return _reject_explicit_null(cls, data, handler) +class _PageTransferTypeConverter( + temporalio.converter.TransferTypeConverter["Page", typing.Any] +): + @typing_extensions.override + def from_transfer_type(self, value: typing.Any, type_hint: type["Page"]) -> "Page": + violations: list[Violation] = [] + if not isinstance(value, dict): + raise ValidationError([Violation(path="", reason="expected object")]) + raw = typing.cast("dict[str, typing.Any]", value) + + page_id: str = typing.cast("typing.Any", None) + if "pageId" not in raw or raw["pageId"] is None: + violations.append(Violation(path="pageId", reason="required")) + else: + page_id_raw = raw["pageId"] + if not isinstance(page_id_raw, str): + violations.append(Violation(path="pageId", reason="expected string")) + else: + page_id = page_id_raw + + title: str = typing.cast("typing.Any", None) + if "title" not in raw or raw["title"] is None: + violations.append(Violation(path="title", reason="required")) + else: + title_raw = raw["title"] + if not isinstance(title_raw, str): + violations.append(Violation(path="title", reason="expected string")) + else: + title = title_raw + + meta: PageMeta = typing.cast("typing.Any", None) + if "meta" not in raw or raw["meta"] is None: + violations.append(Violation(path="meta", reason="required")) + else: + meta_raw = raw["meta"] + try: + meta = getattr( + PageMeta, "__temporal_transfer_type_converter" + ).from_transfer_type(meta_raw, PageMeta) + except ValidationError as error: + _collect(violations, "meta", error) - @pydantic.model_serializer(mode="wrap") - def _serialize( - self, - handler: typing.Callable[[pydantic.BaseModel], typing.Any], - ) -> dict[str, object]: - return _emit_set_fields(self, handler) + blocks: list[Block] | None = None + if "blocks" in raw: + blocks_raw = raw["blocks"] + if blocks_raw is None: + violations.append( + Violation(path="blocks", reason="explicit null not allowed") + ) + else: + if not isinstance(blocks_raw, list): + violations.append(Violation(path="blocks", reason="expected array")) + else: + blocks_list: list[Block] = [] + for blocks_index, blocks_element in enumerate( + typing.cast("list[typing.Any]", blocks_raw) + ): + blocks_item_path = f"blocks[{blocks_index}]" + blocks_item: Block = typing.cast("typing.Any", None) + try: + blocks_item = ( + _BlockTransferTypeConverter().from_transfer_type( + blocks_element, Block + ) + ) + except ValidationError as error: + _collect(violations, blocks_item_path, error) + blocks_list.append(blocks_item) + blocks = blocks_list + for key in raw: + if key != "pageId" and key != "title" and key != "meta" and key != "blocks": + violations.append(Violation(path=key, reason="unknown field")) + if violations: + raise ValidationError(violations) + return Page( + page_id=page_id, + title=title, + meta=meta, + blocks=blocks, + ) -class Page(pydantic.BaseModel): + @typing_extensions.override + def to_transfer_type(self, value: "Page") -> typing.Any: + out: dict[str, typing.Any] = {} + out["pageId"] = value.page_id + out["title"] = value.title + out["meta"] = getattr( + PageMeta, "__temporal_transfer_type_converter" + ).to_transfer_type(value.meta) + if value.blocks is not None: + out["blocks"] = [ + _BlockTransferTypeConverter().to_transfer_type(element) + for element in value.blocks + ] + return out + + +@_transfer_type_convertible(_PageTransferTypeConverter) +@dataclasses.dataclass(slots=True, kw_only=True) +class Page: """A page. One half of the Page <-> Block cross-file cycle. Because the cycle spans two input files, Page and Block hoist together into Python's _recursive.py; the non-cyclic PageMeta helper stays in this module. """ - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - strict=True, populate_by_name=True, extra="forbid" - ) + page_id: str - page_id: str = pydantic.Field(alias="pageId") + title: str - title: str = pydantic.Field() + meta: PageMeta - meta: PageMeta = pydantic.Field() - - blocks: list[Block] | None = pydantic.Field(default=None) + blocks: list[Block] | None = None """Ordered content blocks. Cross-file `$ref` to block.json (same directory); the array is the terminating edge of the cycle. """ - _OPTIONAL_NON_NULLABLE_FIELDS: typing.ClassVar[frozenset[str]] = frozenset( - {"blocks"} - ) - - @pydantic.model_validator(mode="wrap") - @classmethod - def _reject_null( - cls, - data: object, - handler: typing.Callable[[object], typing.Any], - ) -> typing.Any: - return _reject_explicit_null(cls, data, handler) - - @pydantic.model_serializer(mode="wrap") - def _serialize( - self, - handler: typing.Callable[[pydantic.BaseModel], typing.Any], - ) -> dict[str, object]: - return _emit_set_fields(self, handler) - - -_ = Block.model_rebuild() -_ = Page.model_rebuild() - __all__ = [ "Block", diff --git a/advanced/samples/python/json_schema/api/kb/content/block/models.py b/advanced/samples/python/json_schema/api/kb/content/block/models.py index d1b2cb7a..93ea5e5a 100644 --- a/advanced/samples/python/json_schema/api/kb/content/block/models.py +++ b/advanced/samples/python/json_schema/api/kb/content/block/models.py @@ -2,43 +2,94 @@ from __future__ import annotations +import dataclasses import typing -import pydantic +import typing_extensions +import temporalio.converter from ..._definitions import ( - SpecInt, - _emit_set_fields, - _reject_explicit_null, + ValidationError, + Violation, + _parse_spec_integer, + _transfer_type_convertible, ) -class BlockStyle(pydantic.BaseModel): +class _BlockStyleTransferTypeConverter( + temporalio.converter.TransferTypeConverter["BlockStyle", typing.Any] +): + @typing_extensions.override + def from_transfer_type( + self, value: typing.Any, type_hint: type["BlockStyle"] + ) -> "BlockStyle": + violations: list[Violation] = [] + if not isinstance(value, dict): + raise ValidationError([Violation(path="", reason="expected object")]) + raw = typing.cast("dict[str, typing.Any]", value) + + bold: bool | None = None + if "bold" in raw: + bold_raw = raw["bold"] + if bold_raw is None: + violations.append( + Violation(path="bold", reason="explicit null not allowed") + ) + else: + if not isinstance(bold_raw, bool): + violations.append(Violation(path="bold", reason="expected boolean")) + else: + bold = bold_raw + + indent: int | None = None + if "indent" in raw: + indent_raw = raw["indent"] + if indent_raw is None: + violations.append( + Violation(path="indent", reason="explicit null not allowed") + ) + else: + indent_parsed = _parse_spec_integer(indent_raw, "indent", violations) + if indent_parsed is not None: + indent = indent_parsed + if indent < 0: + violations.append( + Violation( + path="indent", reason=f"must be >= 0, got {indent}" + ) + ) + + for key in raw: + if key != "bold" and key != "indent": + violations.append(Violation(path=key, reason="unknown field")) + if violations: + raise ValidationError(violations) + return BlockStyle( + bold=bold, + indent=indent, + ) + + @typing_extensions.override + def to_transfer_type(self, value: "BlockStyle") -> typing.Any: + violations: list[Violation] = [] + out: dict[str, typing.Any] = {} + if value.bold is not None: + out["bold"] = value.bold + if value.indent is not None: + if value.indent < 0: + violations.append( + Violation(path="indent", reason=f"must be >= 0, got {value.indent}") + ) + out["indent"] = value.indent + if violations: + raise ValidationError(violations) + return out + + +@_transfer_type_convertible(_BlockStyleTransferTypeConverter) +@dataclasses.dataclass(slots=True, kw_only=True) +class BlockStyle: """Non-cyclic helper; stays in the content_block module. All members optional.""" - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - strict=True, populate_by_name=True, extra="forbid" - ) - - bold: bool | None = pydantic.Field(default=None) - - indent: SpecInt | None = pydantic.Field(default=None, ge=0) - - _OPTIONAL_NON_NULLABLE_FIELDS: typing.ClassVar[frozenset[str]] = frozenset( - {"bold", "indent"} - ) - - @pydantic.model_validator(mode="wrap") - @classmethod - def _reject_null( - cls, - data: object, - handler: typing.Callable[[object], typing.Any], - ) -> typing.Any: - return _reject_explicit_null(cls, data, handler) - - @pydantic.model_serializer(mode="wrap") - def _serialize( - self, - handler: typing.Callable[[pydantic.BaseModel], typing.Any], - ) -> dict[str, object]: - return _emit_set_fields(self, handler) + bold: bool | None = None + + indent: int | None = None diff --git a/advanced/samples/python/json_schema/api/kb/content/page/models.py b/advanced/samples/python/json_schema/api/kb/content/page/models.py index 54fe9f62..4704f8fa 100644 --- a/advanced/samples/python/json_schema/api/kb/content/page/models.py +++ b/advanced/samples/python/json_schema/api/kb/content/page/models.py @@ -2,45 +2,81 @@ from __future__ import annotations +import dataclasses import typing -import pydantic +import typing_extensions +import temporalio.converter from ..._definitions import ( - SpecInt, - _emit_set_fields, - _reject_explicit_null, + ValidationError, + Violation, + _parse_spec_integer, + _transfer_type_convertible, ) -class PageMeta(pydantic.BaseModel): +class _PageMetaTransferTypeConverter( + temporalio.converter.TransferTypeConverter["PageMeta", typing.Any] +): + @typing_extensions.override + def from_transfer_type( + self, value: typing.Any, type_hint: type["PageMeta"] + ) -> "PageMeta": + violations: list[Violation] = [] + if not isinstance(value, dict): + raise ValidationError([Violation(path="", reason="expected object")]) + raw = typing.cast("dict[str, typing.Any]", value) + + author: str = typing.cast("typing.Any", None) + if "author" not in raw or raw["author"] is None: + violations.append(Violation(path="author", reason="required")) + else: + author_raw = raw["author"] + if not isinstance(author_raw, str): + violations.append(Violation(path="author", reason="expected string")) + else: + author = author_raw + + word_count: int | None = None + if "wordCount" in raw: + word_count_raw = raw["wordCount"] + if word_count_raw is None: + violations.append( + Violation(path="wordCount", reason="explicit null not allowed") + ) + else: + word_count_parsed = _parse_spec_integer( + word_count_raw, "wordCount", violations + ) + if word_count_parsed is not None: + word_count = word_count_parsed + + for key in raw: + if key != "author" and key != "wordCount": + violations.append(Violation(path=key, reason="unknown field")) + if violations: + raise ValidationError(violations) + return PageMeta( + author=author, + word_count=word_count, + ) + + @typing_extensions.override + def to_transfer_type(self, value: "PageMeta") -> typing.Any: + out: dict[str, typing.Any] = {} + out["author"] = value.author + if value.word_count is not None: + out["wordCount"] = value.word_count + return out + + +@_transfer_type_convertible(_PageMetaTransferTypeConverter) +@dataclasses.dataclass(slots=True, kw_only=True) +class PageMeta: """Non-cyclic helper. Referenced only by Page, references nothing recursive, so it stays in the content_page module even though Page is hoisted. """ - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - strict=True, populate_by_name=True, extra="forbid" - ) - - author: str = pydantic.Field() - - word_count: SpecInt | None = pydantic.Field(default=None, alias="wordCount") - - _OPTIONAL_NON_NULLABLE_FIELDS: typing.ClassVar[frozenset[str]] = frozenset( - {"wordCount", "word_count"} - ) - - @pydantic.model_validator(mode="wrap") - @classmethod - def _reject_null( - cls, - data: object, - handler: typing.Callable[[object], typing.Any], - ) -> typing.Any: - return _reject_explicit_null(cls, data, handler) - - @pydantic.model_serializer(mode="wrap") - def _serialize( - self, - handler: typing.Callable[[pydantic.BaseModel], typing.Any], - ) -> dict[str, object]: - return _emit_set_fields(self, handler) + author: str + + word_count: int | None = None diff --git a/advanced/samples/python/json_schema/api/kb/kb/models.py b/advanced/samples/python/json_schema/api/kb/kb/models.py index b5326b96..e40c9873 100644 --- a/advanced/samples/python/json_schema/api/kb/kb/models.py +++ b/advanced/samples/python/json_schema/api/kb/kb/models.py @@ -2,57 +2,159 @@ from __future__ import annotations +import dataclasses import typing -import pydantic +import typing_extensions +import temporalio.converter from .._definitions import ( - SpecInt, - _emit_set_fields, + ValidationError, + Violation, + _parse_spec_integer, + _transfer_type_convertible, ) -class GetCategoryTreeInput(pydantic.BaseModel): - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - strict=True, populate_by_name=True, extra="forbid" - ) - - root_id: str = pydantic.Field(alias="rootId") - - @pydantic.model_serializer(mode="wrap") - def _serialize( - self, - handler: typing.Callable[[pydantic.BaseModel], typing.Any], - ) -> dict[str, object]: - return _emit_set_fields(self, handler) - - -class GetPageInput(pydantic.BaseModel): - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - strict=True, populate_by_name=True, extra="forbid" - ) - - page_id: str = pydantic.Field(alias="pageId") - - @pydantic.model_serializer(mode="wrap") - def _serialize( - self, - handler: typing.Callable[[pydantic.BaseModel], typing.Any], - ) -> dict[str, object]: - return _emit_set_fields(self, handler) - - -class PutBlockOutput(pydantic.BaseModel): - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - strict=True, populate_by_name=True, extra="forbid" - ) - - block_id: str = pydantic.Field(alias="blockId") - - revision: SpecInt = pydantic.Field() - - @pydantic.model_serializer(mode="wrap") - def _serialize( - self, - handler: typing.Callable[[pydantic.BaseModel], typing.Any], - ) -> dict[str, object]: - return _emit_set_fields(self, handler) +class _GetCategoryTreeInputTransferTypeConverter( + temporalio.converter.TransferTypeConverter["GetCategoryTreeInput", typing.Any] +): + @typing_extensions.override + def from_transfer_type( + self, value: typing.Any, type_hint: type["GetCategoryTreeInput"] + ) -> "GetCategoryTreeInput": + violations: list[Violation] = [] + if not isinstance(value, dict): + raise ValidationError([Violation(path="", reason="expected object")]) + raw = typing.cast("dict[str, typing.Any]", value) + + root_id: str = typing.cast("typing.Any", None) + if "rootId" not in raw or raw["rootId"] is None: + violations.append(Violation(path="rootId", reason="required")) + else: + root_id_raw = raw["rootId"] + if not isinstance(root_id_raw, str): + violations.append(Violation(path="rootId", reason="expected string")) + else: + root_id = root_id_raw + + for key in raw: + if key != "rootId": + violations.append(Violation(path=key, reason="unknown field")) + if violations: + raise ValidationError(violations) + return GetCategoryTreeInput( + root_id=root_id, + ) + + @typing_extensions.override + def to_transfer_type(self, value: "GetCategoryTreeInput") -> typing.Any: + out: dict[str, typing.Any] = {} + out["rootId"] = value.root_id + return out + + +@_transfer_type_convertible(_GetCategoryTreeInputTransferTypeConverter) +@dataclasses.dataclass(slots=True, kw_only=True) +class GetCategoryTreeInput: + root_id: str + + +class _GetPageInputTransferTypeConverter( + temporalio.converter.TransferTypeConverter["GetPageInput", typing.Any] +): + @typing_extensions.override + def from_transfer_type( + self, value: typing.Any, type_hint: type["GetPageInput"] + ) -> "GetPageInput": + violations: list[Violation] = [] + if not isinstance(value, dict): + raise ValidationError([Violation(path="", reason="expected object")]) + raw = typing.cast("dict[str, typing.Any]", value) + + page_id: str = typing.cast("typing.Any", None) + if "pageId" not in raw or raw["pageId"] is None: + violations.append(Violation(path="pageId", reason="required")) + else: + page_id_raw = raw["pageId"] + if not isinstance(page_id_raw, str): + violations.append(Violation(path="pageId", reason="expected string")) + else: + page_id = page_id_raw + + for key in raw: + if key != "pageId": + violations.append(Violation(path=key, reason="unknown field")) + if violations: + raise ValidationError(violations) + return GetPageInput( + page_id=page_id, + ) + + @typing_extensions.override + def to_transfer_type(self, value: "GetPageInput") -> typing.Any: + out: dict[str, typing.Any] = {} + out["pageId"] = value.page_id + return out + + +@_transfer_type_convertible(_GetPageInputTransferTypeConverter) +@dataclasses.dataclass(slots=True, kw_only=True) +class GetPageInput: + page_id: str + + +class _PutBlockOutputTransferTypeConverter( + temporalio.converter.TransferTypeConverter["PutBlockOutput", typing.Any] +): + @typing_extensions.override + def from_transfer_type( + self, value: typing.Any, type_hint: type["PutBlockOutput"] + ) -> "PutBlockOutput": + violations: list[Violation] = [] + if not isinstance(value, dict): + raise ValidationError([Violation(path="", reason="expected object")]) + raw = typing.cast("dict[str, typing.Any]", value) + + block_id: str = typing.cast("typing.Any", None) + if "blockId" not in raw or raw["blockId"] is None: + violations.append(Violation(path="blockId", reason="required")) + else: + block_id_raw = raw["blockId"] + if not isinstance(block_id_raw, str): + violations.append(Violation(path="blockId", reason="expected string")) + else: + block_id = block_id_raw + + revision: int = typing.cast("typing.Any", None) + if "revision" not in raw or raw["revision"] is None: + violations.append(Violation(path="revision", reason="required")) + else: + revision_raw = raw["revision"] + revision_parsed = _parse_spec_integer(revision_raw, "revision", violations) + if revision_parsed is not None: + revision = revision_parsed + + for key in raw: + if key != "blockId" and key != "revision": + violations.append(Violation(path=key, reason="unknown field")) + if violations: + raise ValidationError(violations) + return PutBlockOutput( + block_id=block_id, + revision=revision, + ) + + @typing_extensions.override + def to_transfer_type(self, value: "PutBlockOutput") -> typing.Any: + out: dict[str, typing.Any] = {} + out["blockId"] = value.block_id + out["revision"] = value.revision + return out + + +@_transfer_type_convertible(_PutBlockOutputTransferTypeConverter) +@dataclasses.dataclass(slots=True, kw_only=True) +class PutBlockOutput: + block_id: str + + revision: int diff --git a/advanced/samples/python/json_schema/api/kb/tree/category/models.py b/advanced/samples/python/json_schema/api/kb/tree/category/models.py index c1ef693a..89642623 100644 --- a/advanced/samples/python/json_schema/api/kb/tree/category/models.py +++ b/advanced/samples/python/json_schema/api/kb/tree/category/models.py @@ -2,71 +2,180 @@ from __future__ import annotations +import dataclasses import typing -import pydantic +import typing_extensions +import temporalio.converter from ..._definitions import ( - _emit_set_fields, - _reject_explicit_null, + ValidationError, + Violation, + _collect, + _transfer_type_convertible, ) -class Category(pydantic.BaseModel): +class _CategoryTransferTypeConverter( + temporalio.converter.TransferTypeConverter["Category", typing.Any] +): + @typing_extensions.override + def from_transfer_type( + self, value: typing.Any, type_hint: type["Category"] + ) -> "Category": + violations: list[Violation] = [] + if not isinstance(value, dict): + raise ValidationError([Violation(path="", reason="expected object")]) + raw = typing.cast("dict[str, typing.Any]", value) + + id: str = typing.cast("typing.Any", None) + if "id" not in raw or raw["id"] is None: + violations.append(Violation(path="id", reason="required")) + else: + id_raw = raw["id"] + if not isinstance(id_raw, str): + violations.append(Violation(path="id", reason="expected string")) + else: + id = id_raw + + name: str = typing.cast("typing.Any", None) + if "name" not in raw or raw["name"] is None: + violations.append(Violation(path="name", reason="required")) + else: + name_raw = raw["name"] + if not isinstance(name_raw, str): + violations.append(Violation(path="name", reason="expected string")) + else: + name = name_raw + + children: list[Category] | None = None + if "children" in raw: + children_raw = raw["children"] + if children_raw is None: + violations.append( + Violation(path="children", reason="explicit null not allowed") + ) + else: + if not isinstance(children_raw, list): + violations.append( + Violation(path="children", reason="expected array") + ) + else: + children_list: list[Category] = [] + for children_index, children_element in enumerate( + typing.cast("list[typing.Any]", children_raw) + ): + children_item_path = f"children[{children_index}]" + children_item: Category = typing.cast("typing.Any", None) + try: + children_item = ( + _CategoryTransferTypeConverter().from_transfer_type( + children_element, Category + ) + ) + except ValidationError as error: + _collect(violations, children_item_path, error) + children_list.append(children_item) + children = children_list + + for key in raw: + if key != "id" and key != "name" and key != "children": + violations.append(Violation(path=key, reason="unknown field")) + if violations: + raise ValidationError(violations) + return Category( + id=id, + name=name, + children=children, + ) + + @typing_extensions.override + def to_transfer_type(self, value: "Category") -> typing.Any: + out: dict[str, typing.Any] = {} + out["id"] = value.id + out["name"] = value.name + if value.children is not None: + out["children"] = [ + _CategoryTransferTypeConverter().to_transfer_type(element) + for element in value.children + ] + return out + + +@_transfer_type_convertible(_CategoryTransferTypeConverter) +@dataclasses.dataclass(slots=True, kw_only=True) +class Category: """A node in a self-recursive category tree. The root of this file is itself a type (pure JSON Schema file), named Category from the basename. """ - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - strict=True, populate_by_name=True, extra="forbid" - ) + id: str - id: str = pydantic.Field() + name: str - name: str = pydantic.Field() - - children: list[Category] | None = pydantic.Field(default=None) + children: list[Category] | None = None """Sub-categories. A within-file self-cycle via `$ref: '#'`; the possibly-empty array is the terminating edge, so it stays in this module. """ - _OPTIONAL_NON_NULLABLE_FIELDS: typing.ClassVar[frozenset[str]] = frozenset( - {"children"} - ) - - @pydantic.model_validator(mode="wrap") - @classmethod - def _reject_null( - cls, - data: object, - handler: typing.Callable[[object], typing.Any], - ) -> typing.Any: - return _reject_explicit_null(cls, data, handler) - - @pydantic.model_serializer(mode="wrap") - def _serialize( - self, - handler: typing.Callable[[pydantic.BaseModel], typing.Any], - ) -> dict[str, object]: - return _emit_set_fields(self, handler) - -class Palette(pydantic.BaseModel): +class _PaletteTransferTypeConverter( + temporalio.converter.TransferTypeConverter["Palette", typing.Any] +): + @typing_extensions.override + def from_transfer_type( + self, value: typing.Any, type_hint: type["Palette"] + ) -> "Palette": + violations: list[Violation] = [] + if not isinstance(value, dict): + raise ValidationError([Violation(path="", reason="expected object")]) + raw = typing.cast("dict[str, typing.Any]", value) + + swatches: list[str] = typing.cast("typing.Any", None) + if "swatches" not in raw or raw["swatches"] is None: + violations.append(Violation(path="swatches", reason="required")) + else: + swatches_raw = raw["swatches"] + if not isinstance(swatches_raw, list): + violations.append(Violation(path="swatches", reason="expected array")) + else: + swatches_list: list[str] = [] + for swatches_index, swatches_element in enumerate( + typing.cast("list[typing.Any]", swatches_raw) + ): + swatches_item_path = f"swatches[{swatches_index}]" + swatches_item: str = typing.cast("typing.Any", None) + if not isinstance(swatches_element, str): + violations.append( + Violation( + path=swatches_item_path, reason="expected element" + ) + ) + else: + swatches_item = swatches_element + swatches_list.append(swatches_item) + swatches = swatches_list + + for key in raw: + if key != "swatches": + violations.append(Violation(path=key, reason="unknown field")) + if violations: + raise ValidationError(violations) + return Palette( + swatches=swatches, + ) + + @typing_extensions.override + def to_transfer_type(self, value: "Palette") -> typing.Any: + out: dict[str, typing.Any] = {} + out["swatches"] = value.swatches + return out + + +@_transfer_type_convertible(_PaletteTransferTypeConverter) +@dataclasses.dataclass(slots=True, kw_only=True) +class Palette: """A dead $def - defined but never referenced anywhere. Still generated and exported as intended reusable API surface (see the $ref spec). """ - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - strict=True, populate_by_name=True, extra="forbid" - ) - - swatches: list[str] = pydantic.Field() - - @pydantic.model_serializer(mode="wrap") - def _serialize( - self, - handler: typing.Callable[[pydantic.BaseModel], typing.Any], - ) -> dict[str, object]: - return _emit_set_fields(self, handler) - - -_ = Category.model_rebuild() + swatches: list[str] diff --git a/advanced/samples/python/json_schema/api/showcase/_definitions.py b/advanced/samples/python/json_schema/api/showcase/_definitions.py index 00b1bd60..f2b94492 100644 --- a/advanced/samples/python/json_schema/api/showcase/_definitions.py +++ b/advanced/samples/python/json_schema/api/showcase/_definitions.py @@ -4,143 +4,180 @@ import base64 import collections.abc +import dataclasses import datetime -import math +import json import re import typing - -import pydantic -import pydantic.functional_validators -import pydantic_core +import temporalio.converter __all__ = [ - "SpecInt", - "DateTimeField", - "DateField", - "TimeField", - "DurationField", - "Base64Field", - "Base64UrlField", - "_check_multiple_of", - "_check_pattern", - "_check_format", - "_check_unique_items", + "ValidationError", + "Violation", "_check_contains", - "_reject_explicit_null", - "_emit_set_fields", + "_check_unique_items", + "_collect", + "_format_base64", + "_format_base64url", + "_format_date", + "_format_date_time", + "_format_duration", + "_format_time", + "_parse_base64", + "_parse_base64url", + "_parse_date", + "_parse_date_time", + "_parse_duration", + "_parse_spec_integer", + "_parse_time", + "_quote", + "_transfer_type_convertible", ] -_INTEGER_CAP = (1 << 53) - 1 +@dataclasses.dataclass(frozen=True, slots=True) +class Violation: + """A single constraint failure, located by JSON path.""" + path: str + reason: str -def _parse_spec_integer(value: object) -> int: - if isinstance(value, bool): - raise ValueError("expected integer, got boolean") - if isinstance(value, int): - out = value - elif isinstance(value, float): - if not value.is_integer(): - raise ValueError("number has a fractional part; not an integer") - out = int(value) - else: - raise ValueError(f"expected integer, got {type(value).__name__}") - if abs(out) > _INTEGER_CAP: - raise ValueError("integer exceeds +/-(2**53-1) cap") - return out +class ValidationError(Exception): + """Every constraint failure found in one (de)serialization pass.""" + + violations: list[Violation] + + def __init__(self, violations: list[Violation]) -> None: + self.violations = violations + detail = "; ".join(f"{item.path}: {item.reason}" for item in violations) + super().__init__(f"{len(violations)} validation error(s): {detail}") -SpecInt: typing.TypeAlias = typing.Annotated[ - int, pydantic.functional_validators.BeforeValidator(_parse_spec_integer) -] +def _quote(value: object) -> str: + """Renders a value in the JSON form every target quotes offending values in.""" -def _check_multiple_of( - divisor: float, -) -> typing.Callable[[float], float]: - """Builds an AfterValidator asserting `math.fmod`-exact divisibility for number fields.""" + try: + return json.dumps(value, ensure_ascii=False) + except (TypeError, ValueError): + return repr(value) - def validate(value: float) -> float: - if math.fmod(value, divisor) != 0: - raise ValueError(f"must be a multiple of {divisor}, got {value}") - return value - return validate +def _collect(violations: list[Violation], path: str, error: ValidationError) -> None: + """Re-paths a nested model's violations under `path` and appends them.""" + for inner in error.violations: + # A nested violation about the value *itself* carries no path of its own + # (a union branch's own constraint, an element-level check), so the + # prefix is the whole path -- never a dangling separator (P11). + nested = f"{path}.{inner.path}" if inner.path else path + violations.append(Violation(path=nested, reason=inner.reason)) -def _check_pattern( - pattern: str, -) -> typing.Callable[[str], str]: - """Builds an AfterValidator asserting an unanchored, ASCII-class regex match for string fields.""" - compiled = re.compile(pattern, re.ASCII) +_ModelT = typing.TypeVar("_ModelT") - def validate(value: str) -> str: - if compiled.search(value) is None: - raise ValueError(f"must match pattern {pattern}, got {value!r}") - return value - return validate +def _transfer_type_convertible( + converter: type[temporalio.converter.TransferTypeConverter[typing.Any, typing.Any]], +) -> collections.abc.Callable[[type[_ModelT]], type[_ModelT]]: + """Registers a transfer type converter on a model class. + Wraps `temporalio.converter.transfer_type_convertible` to erase the + converter's value-type parameter. Binding it directly on the decorated class + is circular for a static type checker -- the class's type depends on the + decorator, whose value type depends on the class -- which degrades the model + to `Unknown`. Erasing it here keeps the decorator idiomatic at each model and + resolves the cycle. + """ -def _check_format( - format_name: str, - pattern: str, - max_code_points: int | None = None, -) -> typing.Callable[[str], str]: - """Builds an AfterValidator asserting a value matches a pinned `format` regex (+ optional length guard).""" + return temporalio.converter.transfer_type_convertible(converter) - compiled = re.compile(pattern, re.ASCII) - def validate(value: str) -> str: - if ( - max_code_points is not None and len(value) > max_code_points - ) or compiled.search(value) is None: - raise ValueError(f"must be a valid {format_name}, got {value!r}") - return value +_INTEGER_CAP = (1 << 53) - 1 - return validate + +def _parse_spec_integer( + value: object, path: str, violations: list[Violation] +) -> int | None: + """Parses a JSON number as a spec integer (`1.0` accepted, `1.5` rejected).""" + + # `bool` is a subclass of `int`, so it must be excluded before the int check. + if isinstance(value, bool) or not isinstance(value, (int, float)): + violations.append(Violation(path=path, reason="expected integer")) + return None + if isinstance(value, float): + if not value.is_integer(): + violations.append(Violation(path=path, reason="expected integer")) + return None + out = int(value) + else: + out = value + if abs(out) > _INTEGER_CAP: + violations.append(Violation(path=path, reason="expected integer")) + return None + return out def _check_unique_items( - value: list[typing.Any], -) -> list[typing.Any]: - """An AfterValidator asserting an array's elements are pairwise distinct.""" + value: list[typing.Any], path: str, violations: list[Violation] +) -> None: + """Asserts an array's elements are pairwise distinct.""" - seen: dict[object, int] = {} + seen: list[typing.Any] = [] for index, element in enumerate(value): - if element in seen: - raise ValueError( - f"duplicate items: element at index {index} equals index {seen[element]}" - ) - seen[element] = index - return value + for earlier, previous in enumerate(seen): + if previous == element: + violations.append( + Violation( + path=path, + reason=( + f"duplicate items: element at index {index} " + f"equals index {earlier}" + ), + ) + ) + break + seen.append(element) def _check_contains( + value: list[typing.Any], matches: typing.Callable[[typing.Any], bool], min_contains: int, - max_contains: int | None = None, - bounded_min: bool = False, -) -> typing.Callable[[list[typing.Any]], list[typing.Any]]: - """Builds an AfterValidator asserting how many elements match the `contains` schema.""" - - def validate(value: list[typing.Any]) -> list[typing.Any]: - match_count = sum(1 for element in value if matches(element)) - if match_count < min_contains: - if bounded_min: - raise ValueError( - f"too few matching items: at least {min_contains}, got {match_count}" + max_contains: int | None, + bounded_min: bool, + path: str, + violations: list[Violation], +) -> None: + """Asserts how many of an array's elements match the `contains` schema.""" + + match_count = sum(1 for element in value if matches(element)) + if match_count < min_contains: + if bounded_min: + violations.append( + Violation( + path=path, + reason=( + f"too few matching items: at least {min_contains}, " + f"got {match_count}" + ), ) - raise ValueError("no element matches the required schema") - if max_contains is not None and match_count > max_contains: - raise ValueError( - f"too many matching items: at most {max_contains}, got {match_count}" ) - return value - - return validate + else: + violations.append( + Violation(path=path, reason="no element matches the required schema") + ) + if max_contains is not None and match_count > max_contains: + violations.append( + Violation( + path=path, + reason=( + f"too many matching items: at most {max_contains}, " + f"got {match_count}" + ), + ) + ) _TEMPORAL_DATE_TIME_RE = re.compile( @@ -177,43 +214,59 @@ def _valid_temporal_calendar(value: str) -> bool: return maximum > 0 and 1 <= day <= maximum -def _parse_date_time(value: object) -> object: - if not isinstance(value, str): - return value +def _parse_date_time( + value: str, path: str, violations: list[Violation] +) -> datetime.datetime | None: if _TEMPORAL_DATE_TIME_RE.match(value) is None or not _valid_temporal_calendar( value ): - raise ValueError(f"must be a valid date-time, got {value!r}") + violations.append( + Violation( + path=path, reason=f"must be a valid date-time, got {_quote(value)}" + ) + ) + return None normalized = value.upper() if normalized.endswith("Z"): normalized = normalized[:-1] + "+00:00" return datetime.datetime.fromisoformat(normalized) -def _parse_date(value: object) -> object: - if not isinstance(value, str): - return value +def _parse_date( + value: str, path: str, violations: list[Violation] +) -> datetime.date | None: if _TEMPORAL_DATE_RE.match(value) is None or not _valid_temporal_calendar(value): - raise ValueError(f"must be a valid date, got {value!r}") + violations.append( + Violation(path=path, reason=f"must be a valid date, got {_quote(value)}") + ) + return None return datetime.date.fromisoformat(value) -def _parse_time(value: object) -> object: - if not isinstance(value, str): - return value +def _parse_time( + value: str, path: str, violations: list[Violation] +) -> datetime.time | None: if _TEMPORAL_TIME_RE.match(value) is None: - raise ValueError(f"must be a valid time, got {value!r}") + violations.append( + Violation(path=path, reason=f"must be a valid time, got {_quote(value)}") + ) + return None normalized = value.upper() if normalized.endswith("Z"): normalized = normalized[:-1] + "+00:00" return datetime.time.fromisoformat(normalized) -def _parse_duration(value: object) -> object: - if not isinstance(value, str): - return value +def _parse_duration( + value: str, path: str, violations: list[Violation] +) -> datetime.timedelta | None: if _TEMPORAL_DURATION_RE.match(value) is None: - raise ValueError(f"must be a valid duration, got {value!r}") + violations.append( + Violation( + path=path, reason=f"must be a valid duration, got {_quote(value)}" + ) + ) + return None total = 0 number = "" for char in value[2:]: @@ -223,7 +276,12 @@ def _parse_duration(value: object) -> object: total += int(number) * {"H": 3600, "M": 60, "S": 1}[char] number = "" if total > _TEMPORAL_MAX_DURATION_SECONDS: - raise ValueError(f"must be a valid duration, got {value!r}") + violations.append( + Violation( + path=path, reason=f"must be a valid duration, got {_quote(value)}" + ) + ) + return None return datetime.timedelta(seconds=total) @@ -280,39 +338,18 @@ def _format_duration(value: datetime.timedelta) -> str: return out -DateTimeField: typing.TypeAlias = typing.Annotated[ - datetime.datetime, - pydantic.BeforeValidator(_parse_date_time), - pydantic.PlainSerializer(_format_date_time, return_type=str), -] -DateField: typing.TypeAlias = typing.Annotated[ - datetime.date, - pydantic.BeforeValidator(_parse_date), - pydantic.PlainSerializer(_format_date, return_type=str), -] -TimeField: typing.TypeAlias = typing.Annotated[ - datetime.time, - pydantic.BeforeValidator(_parse_time), - pydantic.PlainSerializer(_format_time, return_type=str), -] -DurationField: typing.TypeAlias = typing.Annotated[ - datetime.timedelta, - pydantic.BeforeValidator(_parse_duration), - pydantic.PlainSerializer(_format_duration, return_type=str), -] - - _BASE64_RE = re.compile( "^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?\\Z", re.ASCII ) _BASE64URL_RE = re.compile("^(?:[A-Za-z0-9_-]{4})*(?:[A-Za-z0-9_-]{2,3})?\\Z", re.ASCII) -def _parse_base64(value: typing.Any) -> bytes: - if isinstance(value, bytes): - return value - if not isinstance(value, str) or _BASE64_RE.match(value) is None: - raise ValueError(f"must be base64-encoded, got {value!r}") +def _parse_base64(value: str, path: str, violations: list[Violation]) -> bytes | None: + if _BASE64_RE.match(value) is None: + violations.append( + Violation(path=path, reason=f"must be base64-encoded, got {_quote(value)}") + ) + return None return base64.b64decode(value, validate=True) @@ -320,91 +357,18 @@ def _format_base64(value: bytes) -> str: return base64.b64encode(value).decode("ascii") -def _parse_base64url(value: typing.Any) -> bytes: - if isinstance(value, bytes): - return value - if not isinstance(value, str) or _BASE64URL_RE.match(value) is None: - raise ValueError(f"must be base64url-encoded, got {value!r}") +def _parse_base64url( + value: str, path: str, violations: list[Violation] +) -> bytes | None: + if _BASE64URL_RE.match(value) is None: + violations.append( + Violation( + path=path, reason=f"must be base64url-encoded, got {_quote(value)}" + ) + ) + return None return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4)) def _format_base64url(value: bytes) -> str: return base64.urlsafe_b64encode(value).rstrip(b"=").decode("ascii") - - -Base64Field: typing.TypeAlias = typing.Annotated[ - bytes, - pydantic.BeforeValidator(_parse_base64), - pydantic.PlainSerializer(_format_base64, return_type=str), -] -Base64UrlField: typing.TypeAlias = typing.Annotated[ - bytes, - pydantic.BeforeValidator(_parse_base64url), - pydantic.PlainSerializer(_format_base64url, return_type=str), -] - - -def _reject_explicit_null( - cls: type[pydantic.BaseModel], - data: object, - handler: typing.Callable[[object], typing.Any], -) -> typing.Any: - null_fields = typing.cast( - frozenset[str], getattr(cls, "_OPTIONAL_NON_NULLABLE_FIELDS") - ) - raw_data = data - pre_errors: list[pydantic_core.InitErrorDetails] = [] - if isinstance(data, dict): - values = typing.cast(dict[str, object], data) - pre_errors = [ - pydantic_core.InitErrorDetails( - type=pydantic_core.PydanticCustomError( - "null_for_nonnullable", "explicit null not allowed" - ), - loc=(field,), - input=None, - ) - for field in null_fields - if field in values and values[field] is None - ] - try: - instance = handler(raw_data) - except pydantic.ValidationError as error: - field_errors: list[pydantic_core.InitErrorDetails] = [] - for error_detail in typing.cast(list[dict[str, object]], error.errors()): - loc: tuple[str | int, ...] = tuple( - typing.cast(collections.abc.Iterable[str | int], error_detail["loc"]) - ) - field_errors.append( - pydantic_core.InitErrorDetails( - type=pydantic_core.PydanticCustomError( - typing.cast(typing.Any, error_detail["type"]), - typing.cast(typing.Any, error_detail["msg"]), - ), - loc=loc, - input=error_detail.get("input"), - ) - ) - raise pydantic.ValidationError.from_exception_data( - title=cls.__name__, line_errors=pre_errors + field_errors - ) from None - if pre_errors: - raise pydantic.ValidationError.from_exception_data( - title=cls.__name__, line_errors=pre_errors - ) - return instance - - -def _emit_set_fields( - model: pydantic.BaseModel, - handler: typing.Callable[[pydantic.BaseModel], typing.Any], -) -> dict[str, object]: - dumped = typing.cast(dict[str, object], handler(model)) - alias_of = { - name: (field.alias or name) for name, field in type(model).model_fields.items() - } - keep = {alias_of.get(name, name) for name in model.model_fields_set} - out = {key: value for key, value in dumped.items() if key in keep} - if model.model_extra: - out.update(typing.cast(dict[str, object], model.model_extra)) - return out diff --git a/advanced/samples/python/json_schema/api/showcase/models.py b/advanced/samples/python/json_schema/api/showcase/models.py index 9d49fdc9..dce10b2b 100644 --- a/advanced/samples/python/json_schema/api/showcase/models.py +++ b/advanced/samples/python/json_schema/api/showcase/models.py @@ -2,234 +2,533 @@ from __future__ import annotations +import dataclasses import typing import typing_extensions -import pydantic -import pydantic_core +import math +import re +import temporalio.converter from ._definitions import ( - Base64Field, - Base64UrlField, - SpecInt, - _check_format, - _check_multiple_of, - _check_pattern, + ValidationError, + Violation, + _check_contains, _check_unique_items, - _emit_set_fields, - _reject_explicit_null, + _collect, + _format_base64, + _format_base64url, + _parse_base64, + _parse_base64url, + _parse_spec_integer, + _quote, + _transfer_type_convertible, ) -class Address(pydantic.BaseModel): - """A nested object, open to forward-compatible extension.""" +DEFAULT_RETRIES = 3 +DEFAULT_GREETING = "hello" +DEFAULT_DEBUG = False - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - strict=True, populate_by_name=True, extra="allow" - ) - street: str = pydantic.Field() +_PATTERN_CD24623C0C29CA35 = re.compile("^[A-Z]{2,4}\\Z", re.ASCII) +_PATTERN_B4BA2CA20EB1B963 = re.compile( + "^[^\\t\\n\\x0B\\f\\r ]+[\\t\\n\\x0B\\f\\r ][^\\t\\n\\x0B\\f\\r ]+\\Z", re.ASCII +) +_PATTERN_EAAFA3F3BF5456C8 = re.compile( + "^[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}\\Z", + re.ASCII, +) +_PATTERN_67B8088E6C41E9D2 = re.compile( + "^[a-zA-Z0-9!#$%&'*+/=?^_`{|}~-]+(?:\\.[a-zA-Z0-9!#$%&'*+/=?^_`{|}~-]+)*@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+\\Z", + re.ASCII, +) +_PATTERN_C3551EE088DD1057 = re.compile( + "^[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?(?:\\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*\\Z", + re.ASCII, +) +_PATTERN_BECE32B4DA20247D = re.compile( + "^(?:[A-Za-z][A-Za-z0-9+.-]*:(?://(?:(?:[A-Za-z0-9._~!$&'()*+,;=:-]|%[0-9A-Fa-f][0-9A-Fa-f])*@)?(?:(?:\\[(?:([0-9a-fA-F]{1,4}:){6}([0-9a-fA-F]{1,4}:[0-9a-fA-F]{1,4}|((25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])))|::([0-9a-fA-F]{1,4}:){5}([0-9a-fA-F]{1,4}:[0-9a-fA-F]{1,4}|((25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])))|([0-9a-fA-F]{1,4})?::([0-9a-fA-F]{1,4}:){4}([0-9a-fA-F]{1,4}:[0-9a-fA-F]{1,4}|((25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])))|(([0-9a-fA-F]{1,4}:){0,1}[0-9a-fA-F]{1,4})?::([0-9a-fA-F]{1,4}:){3}([0-9a-fA-F]{1,4}:[0-9a-fA-F]{1,4}|((25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])))|(([0-9a-fA-F]{1,4}:){0,2}[0-9a-fA-F]{1,4})?::([0-9a-fA-F]{1,4}:){2}([0-9a-fA-F]{1,4}:[0-9a-fA-F]{1,4}|((25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])))|(([0-9a-fA-F]{1,4}:){0,3}[0-9a-fA-F]{1,4})?::([0-9a-fA-F]{1,4}:)([0-9a-fA-F]{1,4}:[0-9a-fA-F]{1,4}|((25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])))|(([0-9a-fA-F]{1,4}:){0,4}[0-9a-fA-F]{1,4})?::([0-9a-fA-F]{1,4}:[0-9a-fA-F]{1,4}|((25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])))|(([0-9a-fA-F]{1,4}:){0,5}[0-9a-fA-F]{1,4})?::[0-9a-fA-F]{1,4}|(([0-9a-fA-F]{1,4}:){0,6}[0-9a-fA-F]{1,4})?::)\\]|\\[v[0-9A-Fa-f]+\\.[A-Za-z0-9._~!$&'()*+,;=:-]+\\])|(?:[A-Za-z0-9._~!$&'()*+,;=-]|%[0-9A-Fa-f][0-9A-Fa-f])*)(?::[0-9]*)?(?:/(?:[A-Za-z0-9._~!$&'()*+,;=:@-]|%[0-9A-Fa-f][0-9A-Fa-f])*)*|/(?:(?:[A-Za-z0-9._~!$&'()*+,;=:@-]|%[0-9A-Fa-f][0-9A-Fa-f])+(?:/(?:[A-Za-z0-9._~!$&'()*+,;=:@-]|%[0-9A-Fa-f][0-9A-Fa-f])*)*)?|(?:[A-Za-z0-9._~!$&'()*+,;=:@-]|%[0-9A-Fa-f][0-9A-Fa-f])+(?:/(?:[A-Za-z0-9._~!$&'()*+,;=:@-]|%[0-9A-Fa-f][0-9A-Fa-f])*)*)?(?:\\?(?:(?:[A-Za-z0-9._~!$&'()*+,;=:@-]|%[0-9A-Fa-f][0-9A-Fa-f])|[/?])*)?(?:#(?:(?:[A-Za-z0-9._~!$&'()*+,;=:@-]|%[0-9A-Fa-f][0-9A-Fa-f])|[/?])*)?)\\Z", + re.ASCII, +) +_PATTERN_4A45C0D214B9083D = re.compile( + "^(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\Z", + re.ASCII, +) +_PATTERN_F242E3A159C2422C = re.compile("^[a-z]+\\Z", re.ASCII) - city: str | None = pydantic.Field(default=None) - zip: SpecInt | None = pydantic.Field(default=None) +_ADDRESS_DECLARED: frozenset[str] = frozenset({"street", "city", "zip"}) - _OPTIONAL_NON_NULLABLE_FIELDS: typing.ClassVar[frozenset[str]] = frozenset( - {"city", "zip"} - ) - @pydantic.model_validator(mode="wrap") - @classmethod - def _reject_null( - cls, - data: object, - handler: typing.Callable[[object], typing.Any], - ) -> typing.Any: - return _reject_explicit_null(cls, data, handler) +_CIRCLE_DECLARED: frozenset[str] = frozenset({"kind", "radius"}) - @pydantic.model_serializer(mode="wrap") - def _serialize( - self, - handler: typing.Callable[[pydantic.BaseModel], typing.Any], - ) -> dict[str, object]: - return _emit_set_fields(self, handler) +_CONTACT_PY_DECLARED: frozenset[str] = frozenset( + {"email", "shippingStreet", "shippingZip"} +) -class Attributes(pydantic.BaseModel): - """A string map with member-count and key-shape constraints: 1 to 3 entries, each key - at most 8 code points (minProperties/maxProperties/propertyNames on a map-shaped - object). - """ - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - strict=True, populate_by_name=True, extra="allow" +_LINK_NOTE_DECLARED: frozenset[str] = frozenset({"kind", "href"}) + + +_SHOWCASE_AUDIT_DECLARED: frozenset[str] = frozenset({"by"}) + + +_SHOWCASE_DETAIL_OBJECT_DECLARED: frozenset[str] = frozenset({"code", "hint"}) + + +_SHOWCASE_LEDGER_VALUE_DECLARED: frozenset[str] = frozenset({"amount"}) + + +_SHOWCASE_LOCATION_DECLARED: frozenset[str] = frozenset({"city", "geo"}) + + +_SHOWCASE_LOCATION_GEO_DECLARED: frozenset[str] = frozenset({"lat", "lon"}) + + +_SHOWCASE_ROWS_ITEM_DECLARED: frozenset[str] = frozenset({"cell"}) + + +_SQUARE_DECLARED: frozenset[str] = frozenset({"kind", "side"}) + + +_TEXT_NOTE_DECLARED: frozenset[str] = frozenset({"kind", "body"}) + + +_WIDGET_DECLARED: frozenset[str] = frozenset({"id", "kind", "name", "size"}) + + +_WIDGET_BASE_DECLARED: frozenset[str] = frozenset({"id", "kind"}) + + +class _AddressTransferTypeConverter( + temporalio.converter.TransferTypeConverter["Address", typing.Any] +): + @typing_extensions.override + def from_transfer_type( + self, value: typing.Any, type_hint: type["Address"] + ) -> "Address": + violations: list[Violation] = [] + if not isinstance(value, dict): + raise ValidationError([Violation(path="", reason="expected object")]) + raw = typing.cast("dict[str, typing.Any]", value) + + street: str = typing.cast("typing.Any", None) + if "street" not in raw or raw["street"] is None: + violations.append(Violation(path="street", reason="required")) + else: + street_raw = raw["street"] + if not isinstance(street_raw, str): + violations.append(Violation(path="street", reason="expected string")) + else: + street = street_raw + + city: str | None = None + if "city" in raw: + city_raw = raw["city"] + if city_raw is None: + violations.append( + Violation(path="city", reason="explicit null not allowed") + ) + else: + if not isinstance(city_raw, str): + violations.append(Violation(path="city", reason="expected string")) + else: + city = city_raw + + zip: int | None = None + if "zip" in raw: + zip_raw = raw["zip"] + if zip_raw is None: + violations.append( + Violation(path="zip", reason="explicit null not allowed") + ) + else: + zip_parsed = _parse_spec_integer(zip_raw, "zip", violations) + if zip_parsed is not None: + zip = zip_parsed + + additional_properties: dict[str, typing.Any] = {} + for key in raw: + if key not in _ADDRESS_DECLARED: + additional_properties[key] = raw[key] + if violations: + raise ValidationError(violations) + return Address( + street=street, + city=city, + zip=zip, + additional_properties=additional_properties, + ) + + @typing_extensions.override + def to_transfer_type(self, value: "Address") -> typing.Any: + out: dict[str, typing.Any] = {} + out["street"] = value.street + if value.city is not None: + out["city"] = value.city + if value.zip is not None: + out["zip"] = value.zip + for key, entry in value.additional_properties.items(): + out[key] = entry + return out + + +@_transfer_type_convertible(_AddressTransferTypeConverter) +@dataclasses.dataclass(slots=True, kw_only=True) +class Address: + """A nested object, open to forward-compatible extension.""" + + street: str + + city: str | None = None + + zip: int | None = None + + additional_properties: dict[str, typing.Any] = dataclasses.field( + default_factory=dict ) - @pydantic.model_validator(mode="after") - def _validate_extras(self) -> typing.Any: - extra = typing.cast(dict[str, object], self.model_extra or {}) - errors: list[pydantic_core.InitErrorDetails] = [] - for key, value in list(extra.items()): - try: - extra[key] = _ATTRIBUTES_MEMBER.validate_python(value) - except pydantic.ValidationError as error: - for detail in error.errors(): - errors.append( - pydantic_core.InitErrorDetails( - type=pydantic_core.PydanticCustomError( - typing.cast(typing.Any, detail["type"]), - typing.cast(typing.Any, detail["msg"]), - ), - loc=(key, *detail["loc"]), - input=detail["input"], - ) + +class _AttributesTransferTypeConverter( + temporalio.converter.TransferTypeConverter["Attributes", typing.Any] +): + @typing_extensions.override + def from_transfer_type( + self, value: typing.Any, type_hint: type["Attributes"] + ) -> "Attributes": + violations: list[Violation] = [] + if not isinstance(value, dict): + raise ValidationError([Violation(path="", reason="expected object")]) + raw = typing.cast("dict[str, typing.Any]", value) + if len(raw) < 1: + violations.append( + Violation( + path="", reason=f"must have at least 1 properties, got {len(raw)}" + ) + ) + if len(raw) > 3: + violations.append( + Violation( + path="", reason=f"must have at most 3 properties, got {len(raw)}" + ) + ) + for key in raw: + if len(key) > 8: + violations.append( + Violation( + path=key, + reason=f"invalid property name {_quote(key)}: must have length <= 8, got {len(key)}", ) - if len(extra) < 1: - errors.append( - pydantic_core.InitErrorDetails( - type=pydantic_core.PydanticCustomError( - "too_few_properties", - typing.cast( - typing.Any, - f"must have at least 1 properties, got {len(extra)}", - ), - ), - loc=(), - input=len(extra), + ) + additional_properties: dict[str, str] = {} + for key in raw: + member: str = typing.cast("typing.Any", None) + member_raw = raw[key] + if not isinstance(member_raw, str): + violations.append(Violation(path=key, reason="expected string")) + else: + member = member_raw + additional_properties[key] = member + if violations: + raise ValidationError(violations) + return Attributes(additional_properties=additional_properties) + + @typing_extensions.override + def to_transfer_type(self, value: "Attributes") -> typing.Any: + violations: list[Violation] = [] + out: dict[str, typing.Any] = {} + for key, entry in value.additional_properties.items(): + out[key] = entry + if len(out) < 1: + violations.append( + Violation( + path="", reason=f"must have at least 1 properties, got {len(out)}" ) ) - if len(extra) > 3: - errors.append( - pydantic_core.InitErrorDetails( - type=pydantic_core.PydanticCustomError( - "too_many_properties", - typing.cast( - typing.Any, - f"must have at most 3 properties, got {len(extra)}", - ), - ), - loc=(), - input=len(extra), + if len(out) > 3: + violations.append( + Violation( + path="", reason=f"must have at most 3 properties, got {len(out)}" ) ) - for key in extra: + for key in out: if len(key) > 8: - errors.append( - pydantic_core.InitErrorDetails( - type=pydantic_core.PydanticCustomError( - "invalid_property_name", - typing.cast( - typing.Any, - f'invalid property name "{key}": must have length <= 8, got {len(key)}', - ), - ), - loc=(key,), - input=key, - ) - ) - if errors: - raise pydantic.ValidationError.from_exception_data( - title=type(self).__name__, line_errors=errors + violations.append( + Violation( + path=key, + reason=f"invalid property name {_quote(key)}: must have length <= 8, got {len(key)}", + ) + ) + if violations: + raise ValidationError(violations) + return out + + +@_transfer_type_convertible(_AttributesTransferTypeConverter) +@dataclasses.dataclass(slots=True, kw_only=True) +class Attributes: + """A string map with member-count and key-shape constraints: 1 to 3 entries, each key + at most 8 code points (minProperties/maxProperties/propertyNames on a map-shaped + object). + """ + + additional_properties: dict[str, str] = dataclasses.field(default_factory=dict) + + +class _ChoicesTransferTypeConverter( + temporalio.converter.TransferTypeConverter["Choices", typing.Any] +): + @typing_extensions.override + def from_transfer_type( + self, value: typing.Any, type_hint: type["Choices"] + ) -> "Choices": + violations: list[Violation] = [] + if not isinstance(value, dict): + raise ValidationError([Violation(path="", reason="expected object")]) + raw = typing.cast("dict[str, typing.Any]", value) + additional_properties: dict[str, ChoicesValue] = {} + for key in raw: + member: ChoicesValue = typing.cast("typing.Any", None) + member_raw = raw[key] + member_parsed = _choices_value_from_transfer_type( + member_raw, key, violations ) - return self - - @pydantic.model_serializer(mode="wrap") - def _serialize( - self, - _handler: typing.Callable[[pydantic.BaseModel], typing.Any], - ) -> dict[str, object]: - return { - key: _ATTRIBUTES_MEMBER.dump_python(value, mode="json", by_alias=True) - for key, value in typing.cast( - dict[str, object], self.model_extra or {} - ).items() - } - - -class Choices(pydantic.BaseModel): + if member_parsed is not None: + member = member_parsed + additional_properties[key] = member + if violations: + raise ValidationError(violations) + return Choices(additional_properties=additional_properties) + + @typing_extensions.override + def to_transfer_type(self, value: "Choices") -> typing.Any: + out: dict[str, typing.Any] = {} + for key, entry in value.additional_properties.items(): + out[key] = _choices_value_to_transfer_type(entry) + return out + + +@_transfer_type_convertible(_ChoicesTransferTypeConverter) +@dataclasses.dataclass(slots=True, kw_only=True) +class Choices: """A map whose *member* type is a union written inline in `additionalProperties`. Like an element union it has no name of its own, so it is named after its position — `ChoicesValue` — and moved into `$defs`; each member then decodes through that union's selector, with the member key carrying into the violation path. """ - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - strict=True, populate_by_name=True, extra="allow" + additional_properties: dict[str, ChoicesValue] = dataclasses.field( + default_factory=dict ) - @pydantic.model_validator(mode="after") - def _validate_extras(self) -> typing.Any: - extra = typing.cast(dict[str, object], self.model_extra or {}) - errors: list[pydantic_core.InitErrorDetails] = [] - for key, value in list(extra.items()): - try: - extra[key] = _CHOICES_MEMBER.validate_python(value) - except pydantic.ValidationError as error: - for detail in error.errors(): - errors.append( - pydantic_core.InitErrorDetails( - type=pydantic_core.PydanticCustomError( - typing.cast(typing.Any, detail["type"]), - typing.cast(typing.Any, detail["msg"]), - ), - loc=(key, *detail["loc"]), - input=detail["input"], - ) - ) - if errors: - raise pydantic.ValidationError.from_exception_data( - title=type(self).__name__, line_errors=errors - ) - return self - - @pydantic.model_serializer(mode="wrap") - def _serialize( - self, - _handler: typing.Callable[[pydantic.BaseModel], typing.Any], - ) -> dict[str, object]: - return { - key: _CHOICES_MEMBER.dump_python(value, mode="json", by_alias=True) - for key, value in typing.cast( - dict[str, object], self.model_extra or {} - ).items() - } - - -class Circle(pydantic.BaseModel): + +class _CircleTransferTypeConverter( + temporalio.converter.TransferTypeConverter["Circle", typing.Any] +): + @typing_extensions.override + def from_transfer_type( + self, value: typing.Any, type_hint: type["Circle"] + ) -> "Circle": + violations: list[Violation] = [] + if not isinstance(value, dict): + raise ValidationError([Violation(path="", reason="expected object")]) + raw = typing.cast("dict[str, typing.Any]", value) + + kind: typing.Literal["circle"] = typing.cast("typing.Any", None) + if "kind" not in raw or raw["kind"] is None: + violations.append(Violation(path="kind", reason="required")) + else: + kind_raw = raw["kind"] + if not isinstance(kind_raw, str): + violations.append(Violation(path="kind", reason="expected string")) + elif kind_raw != "circle": + violations.append(Violation(path="kind", reason='must equal "circle"')) + else: + kind = kind_raw + + radius: float = typing.cast("typing.Any", None) + if "radius" not in raw or raw["radius"] is None: + violations.append(Violation(path="radius", reason="required")) + else: + radius_raw = raw["radius"] + if not ( + not isinstance(radius_raw, bool) + and isinstance(radius_raw, (int, float)) + ): + violations.append(Violation(path="radius", reason="expected number")) + else: + radius = radius_raw + + additional_properties: dict[str, typing.Any] = {} + for key in raw: + if key not in _CIRCLE_DECLARED: + additional_properties[key] = raw[key] + if violations: + raise ValidationError(violations) + return Circle( + kind=kind, + radius=radius, + additional_properties=additional_properties, + ) + + @typing_extensions.override + def to_transfer_type(self, value: "Circle") -> typing.Any: + violations: list[Violation] = [] + out: dict[str, typing.Any] = {} + if typing.cast("object", value.kind) not in ("circle",): + violations.append(Violation(path="kind", reason='must equal "circle"')) + out["kind"] = value.kind + out["radius"] = value.radius + for key, entry in value.additional_properties.items(): + out[key] = entry + if violations: + raise ValidationError(violations) + return out + + +@_transfer_type_convertible(_CircleTransferTypeConverter) +@dataclasses.dataclass(slots=True, kw_only=True) +class Circle: """A circle branch of the Shape and shapeOrName tagged unions.""" - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - strict=True, populate_by_name=True, extra="allow" - ) + kind: typing.Literal["circle"] = "circle" - kind: typing.Literal["circle"] = pydantic.Field(default="circle") + radius: float - radius: float = pydantic.Field() + additional_properties: dict[str, typing.Any] = dataclasses.field( + default_factory=dict + ) - @pydantic.model_validator(mode="before") - @classmethod - def _inject_const_kind( - cls, - data: object, - ) -> object: - if isinstance(data, dict): - values = typing.cast(dict[str, object], data) - if "kind" not in values: - data = {**values, "kind": "circle"} - elif values.get("kind", values.get("kind")) != "circle": - raise pydantic_core.PydanticCustomError( - "const", 'kind must equal "circle"' - ) - return typing.cast(object, data) - @pydantic.model_serializer(mode="wrap") - def _serialize( - self, - handler: typing.Callable[[pydantic.BaseModel], typing.Any], - ) -> dict[str, object]: - return _emit_set_fields(self, handler) +class _ContactPyTransferTypeConverter( + temporalio.converter.TransferTypeConverter["ContactPy", typing.Any] +): + @typing_extensions.override + def from_transfer_type( + self, value: typing.Any, type_hint: type["ContactPy"] + ) -> "ContactPy": + violations: list[Violation] = [] + if not isinstance(value, dict): + raise ValidationError([Violation(path="", reason="expected object")]) + raw = typing.cast("dict[str, typing.Any]", value) + + email: str | None = None + if "email" in raw: + email_raw = raw["email"] + if email_raw is None: + violations.append( + Violation(path="email", reason="explicit null not allowed") + ) + else: + if not isinstance(email_raw, str): + violations.append(Violation(path="email", reason="expected string")) + else: + email = email_raw + + shipping_street: str | None = None + if "shippingStreet" in raw: + shipping_street_raw = raw["shippingStreet"] + if shipping_street_raw is None: + violations.append( + Violation(path="shippingStreet", reason="explicit null not allowed") + ) + else: + if not isinstance(shipping_street_raw, str): + violations.append( + Violation(path="shippingStreet", reason="expected string") + ) + else: + shipping_street = shipping_street_raw + + shipping_zip: str | None = None + if "shippingZip" in raw: + shipping_zip_raw = raw["shippingZip"] + if shipping_zip_raw is None: + violations.append( + Violation(path="shippingZip", reason="explicit null not allowed") + ) + else: + if not isinstance(shipping_zip_raw, str): + violations.append( + Violation(path="shippingZip", reason="expected string") + ) + else: + shipping_zip = shipping_zip_raw + + additional_properties: dict[str, typing.Any] = {} + for key in raw: + if key not in _CONTACT_PY_DECLARED: + additional_properties[key] = raw[key] + if len(raw) < 1: + violations.append( + Violation( + path="", reason=f"must have at least 1 properties, got {len(raw)}" + ) + ) + if len(raw) > 3: + violations.append( + Violation( + path="", reason=f"must have at most 3 properties, got {len(raw)}" + ) + ) + if "shippingStreet" in raw: + if "shippingZip" not in raw: + violations.append( + Violation( + path="shippingZip", + reason='property "shippingZip" is required when "shippingStreet" is present', + ) + ) + if violations: + raise ValidationError(violations) + return ContactPy( + email=email, + shipping_street=shipping_street, + shipping_zip=shipping_zip, + additional_properties=additional_properties, + ) + + @typing_extensions.override + def to_transfer_type(self, value: "ContactPy") -> typing.Any: + violations: list[Violation] = [] + out: dict[str, typing.Any] = {} + if value.email is not None: + out["email"] = value.email + if value.shipping_street is not None: + out["shippingStreet"] = value.shipping_street + if value.shipping_zip is not None: + out["shippingZip"] = value.shipping_zip + for key, entry in value.additional_properties.items(): + out[key] = entry + if len(out) < 1: + violations.append( + Violation( + path="", reason=f"must have at least 1 properties, got {len(out)}" + ) + ) + if len(out) > 3: + violations.append( + Violation( + path="", reason=f"must have at most 3 properties, got {len(out)}" + ) + ) + if "shippingStreet" in out: + if "shippingZip" not in out: + violations.append( + Violation( + path="shippingZip", + reason='property "shippingZip" is required when "shippingStreet" is present', + ) + ) + if violations: + raise ValidationError(violations) + return out -class ContactPy(pydantic.BaseModel): +@_transfer_type_convertible(_ContactPyTransferTypeConverter) +@dataclasses.dataclass(slots=True, kw_only=True) +class ContactPy: """Contact details with a conditional requirement and a member-count bound: a shipping street requires a shipping zip (dependentRequired), and the object must carry 1 to 3 members (minProperties/maxProperties on a declared-property object). Also exercises @@ -239,353 +538,2126 @@ class ContactPy(pydantic.BaseModel): `$ref`, while the wire `$ref` name stays `Contact`. """ - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - strict=True, populate_by_name=True, extra="allow" + email: str | None = None + + shipping_street: str | None = None + + shipping_zip: str | None = None + + additional_properties: dict[str, typing.Any] = dataclasses.field( + default_factory=dict ) - email: str | None = pydantic.Field(default=None) - - shipping_street: str | None = pydantic.Field(default=None, alias="shippingStreet") - - shipping_zip: str | None = pydantic.Field(default=None, alias="shippingZip") - - @pydantic.model_validator(mode="after") - def _validate_object(self) -> typing.Any: - errors: list[pydantic_core.InitErrorDetails] = [] - present = self.model_fields_set - if len(present) < 1: - errors.append( - pydantic_core.InitErrorDetails( - type=pydantic_core.PydanticCustomError( - "too_few_properties", - typing.cast( - typing.Any, - f"must have at least 1 properties, got {len(present)}", - ), - ), - loc=(), - input=len(present), + +class _ExtrasTransferTypeConverter( + temporalio.converter.TransferTypeConverter["Extras", typing.Any] +): + @typing_extensions.override + def from_transfer_type( + self, value: typing.Any, type_hint: type["Extras"] + ) -> "Extras": + violations: list[Violation] = [] + if not isinstance(value, dict): + raise ValidationError([Violation(path="", reason="expected object")]) + raw = typing.cast("dict[str, typing.Any]", value) + if len(raw) > 4: + violations.append( + Violation( + path="", reason=f"must have at most 4 properties, got {len(raw)}" ) ) - if len(present) > 3: - errors.append( - pydantic_core.InitErrorDetails( - type=pydantic_core.PydanticCustomError( - "too_many_properties", - typing.cast( - typing.Any, - f"must have at most 3 properties, got {len(present)}", - ), - ), - loc=(), - input=len(present), + additional_properties: dict[str, typing.Any] = {} + for key in raw: + additional_properties[key] = raw[key] + if violations: + raise ValidationError(violations) + return Extras(additional_properties=additional_properties) + + @typing_extensions.override + def to_transfer_type(self, value: "Extras") -> typing.Any: + violations: list[Violation] = [] + out: dict[str, typing.Any] = {} + for key, entry in value.additional_properties.items(): + out[key] = entry + if len(out) > 4: + violations.append( + Violation( + path="", reason=f"must have at most 4 properties, got {len(out)}" ) ) - if "shipping_street" in present: - if "shipping_zip" not in present: - errors.append( - pydantic_core.InitErrorDetails( - type=pydantic_core.PydanticCustomError( - "dependent_required", - 'property "shippingZip" is required when "shippingStreet" is present', - ), - loc=("shippingZip",), - input=None, - ) - ) - if errors: - raise pydantic.ValidationError.from_exception_data( - title=type(self).__name__, line_errors=errors - ) - return self + if violations: + raise ValidationError(violations) + return out - _OPTIONAL_NON_NULLABLE_FIELDS: typing.ClassVar[frozenset[str]] = frozenset( - {"email", "shippingStreet", "shippingZip", "shipping_street", "shipping_zip"} - ) - - @pydantic.model_validator(mode="wrap") - @classmethod - def _reject_null( - cls, - data: object, - handler: typing.Callable[[object], typing.Any], - ) -> typing.Any: - return _reject_explicit_null(cls, data, handler) - - @pydantic.model_serializer(mode="wrap") - def _serialize( - self, - handler: typing.Callable[[pydantic.BaseModel], typing.Any], - ) -> dict[str, object]: - return _emit_set_fields(self, handler) - -class Extras(pydantic.BaseModel): +@_transfer_type_convertible(_ExtrasTransferTypeConverter) +@dataclasses.dataclass(slots=True, kw_only=True) +class Extras: """A free-form object (`additionalProperties: true` with no declared properties): every member is carried verbatim, bounded to at most 4 members. Members keep their wire form, so large integers survive a round-trip untruncated. """ - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - strict=True, populate_by_name=True, extra="allow" + additional_properties: dict[str, typing.Any] = dataclasses.field( + default_factory=dict ) - @pydantic.model_validator(mode="after") - def _validate_extras(self) -> typing.Any: - extra = typing.cast(dict[str, object], self.model_extra or {}) - errors: list[pydantic_core.InitErrorDetails] = [] - if len(extra) > 4: - errors.append( - pydantic_core.InitErrorDetails( - type=pydantic_core.PydanticCustomError( - "too_many_properties", - typing.cast( - typing.Any, - f"must have at most 4 properties, got {len(extra)}", - ), - ), - loc=(), - input=len(extra), + +class _LabelsTransferTypeConverter( + temporalio.converter.TransferTypeConverter["Labels", typing.Any] +): + @typing_extensions.override + def from_transfer_type( + self, value: typing.Any, type_hint: type["Labels"] + ) -> "Labels": + violations: list[Violation] = [] + if not isinstance(value, dict): + raise ValidationError([Violation(path="", reason="expected object")]) + raw = typing.cast("dict[str, typing.Any]", value) + if len(raw) > 50: + violations.append( + Violation( + path="", reason=f"must have at most 50 properties, got {len(raw)}" ) ) - if errors: - raise pydantic.ValidationError.from_exception_data( - title=type(self).__name__, line_errors=errors + additional_properties: dict[str, str] = {} + for key in raw: + member: str = typing.cast("typing.Any", None) + member_raw = raw[key] + if not isinstance(member_raw, str): + violations.append(Violation(path=key, reason="expected string")) + else: + member = member_raw + additional_properties[key] = member + if violations: + raise ValidationError(violations) + return Labels(additional_properties=additional_properties) + + @typing_extensions.override + def to_transfer_type(self, value: "Labels") -> typing.Any: + violations: list[Violation] = [] + out: dict[str, typing.Any] = {} + for key, entry in value.additional_properties.items(): + out[key] = entry + if len(out) > 50: + violations.append( + Violation( + path="", reason=f"must have at most 50 properties, got {len(out)}" + ) ) - return self + if violations: + raise ValidationError(violations) + return out - @pydantic.model_serializer(mode="wrap") - def _serialize( - self, - _handler: typing.Callable[[pydantic.BaseModel], typing.Any], - ) -> dict[str, object]: - return dict(typing.cast(dict[str, object], self.model_extra or {})) - -class Labels(pydantic.BaseModel): +@_transfer_type_convertible(_LabelsTransferTypeConverter) +@dataclasses.dataclass(slots=True, kw_only=True) +class Labels: """Arbitrary string key/value labels (typed map).""" - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - strict=True, populate_by_name=True, extra="allow" - ) - - @pydantic.model_validator(mode="after") - def _validate_extras(self) -> typing.Any: - extra = typing.cast(dict[str, object], self.model_extra or {}) - errors: list[pydantic_core.InitErrorDetails] = [] - for key, value in list(extra.items()): - try: - extra[key] = _LABELS_MEMBER.validate_python(value) - except pydantic.ValidationError as error: - for detail in error.errors(): - errors.append( - pydantic_core.InitErrorDetails( - type=pydantic_core.PydanticCustomError( - typing.cast(typing.Any, detail["type"]), - typing.cast(typing.Any, detail["msg"]), - ), - loc=(key, *detail["loc"]), - input=detail["input"], + additional_properties: dict[str, str] = dataclasses.field(default_factory=dict) + + +class _LinkNoteTransferTypeConverter( + temporalio.converter.TransferTypeConverter["LinkNote", typing.Any] +): + @typing_extensions.override + def from_transfer_type( + self, value: typing.Any, type_hint: type["LinkNote"] + ) -> "LinkNote": + violations: list[Violation] = [] + if not isinstance(value, dict): + raise ValidationError([Violation(path="", reason="expected object")]) + raw = typing.cast("dict[str, typing.Any]", value) + + kind: typing.Literal["link"] = typing.cast("typing.Any", None) + if "kind" not in raw or raw["kind"] is None: + violations.append(Violation(path="kind", reason="required")) + else: + kind_raw = raw["kind"] + if not isinstance(kind_raw, str): + violations.append(Violation(path="kind", reason="expected string")) + elif kind_raw != "link": + violations.append(Violation(path="kind", reason='must equal "link"')) + else: + kind = kind_raw + + href: str = typing.cast("typing.Any", None) + if "href" not in raw or raw["href"] is None: + violations.append(Violation(path="href", reason="required")) + else: + href_raw = raw["href"] + if not isinstance(href_raw, str): + violations.append(Violation(path="href", reason="expected string")) + else: + href = href_raw + if len(href_raw) < 1: + violations.append( + Violation( + path="href", + reason=f"must have length >= 1, got {len(href_raw)}", ) ) - if len(extra) > 50: - errors.append( - pydantic_core.InitErrorDetails( - type=pydantic_core.PydanticCustomError( - "too_many_properties", - typing.cast( - typing.Any, - f"must have at most 50 properties, got {len(extra)}", - ), - ), - loc=(), - input=len(extra), + + additional_properties: dict[str, typing.Any] = {} + for key in raw: + if key not in _LINK_NOTE_DECLARED: + additional_properties[key] = raw[key] + if violations: + raise ValidationError(violations) + return LinkNote( + kind=kind, + href=href, + additional_properties=additional_properties, + ) + + @typing_extensions.override + def to_transfer_type(self, value: "LinkNote") -> typing.Any: + violations: list[Violation] = [] + out: dict[str, typing.Any] = {} + if typing.cast("object", value.kind) not in ("link",): + violations.append(Violation(path="kind", reason='must equal "link"')) + out["kind"] = value.kind + if len(value.href) < 1: + violations.append( + Violation( + path="href", reason=f"must have length >= 1, got {len(value.href)}" ) ) - if errors: - raise pydantic.ValidationError.from_exception_data( - title=type(self).__name__, line_errors=errors - ) - return self - - @pydantic.model_serializer(mode="wrap") - def _serialize( - self, - _handler: typing.Callable[[pydantic.BaseModel], typing.Any], - ) -> dict[str, object]: - return { - key: _LABELS_MEMBER.dump_python(value, mode="json", by_alias=True) - for key, value in typing.cast( - dict[str, object], self.model_extra or {} - ).items() - } - - -class LinkNote(pydantic.BaseModel): + out["href"] = value.href + for key, entry in value.additional_properties.items(): + out[key] = entry + if violations: + raise ValidationError(violations) + return out + + +@_transfer_type_convertible(_LinkNoteTransferTypeConverter) +@dataclasses.dataclass(slots=True, kw_only=True) +class LinkNote: """A link note branch, named inline.""" - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - strict=True, populate_by_name=True, extra="allow" - ) + kind: typing.Literal["link"] = "link" - kind: typing.Literal["link"] = pydantic.Field(default="link") + href: str - href: str = pydantic.Field(min_length=1) + additional_properties: dict[str, typing.Any] = dataclasses.field( + default_factory=dict + ) - @pydantic.model_validator(mode="before") - @classmethod - def _inject_const_kind( - cls, - data: object, - ) -> object: - if isinstance(data, dict): - values = typing.cast(dict[str, object], data) - if "kind" not in values: - data = {**values, "kind": "link"} - elif values.get("kind", values.get("kind")) != "link": - raise pydantic_core.PydanticCustomError( - "const", 'kind must equal "link"' - ) - return typing.cast(object, data) - @pydantic.model_serializer(mode="wrap") - def _serialize( - self, - handler: typing.Callable[[pydantic.BaseModel], typing.Any], - ) -> dict[str, object]: - return _emit_set_fields(self, handler) +class _NicknamesTransferTypeConverter( + temporalio.converter.TransferTypeConverter["Nicknames", typing.Any] +): + @typing_extensions.override + def from_transfer_type( + self, value: typing.Any, type_hint: type["Nicknames"] + ) -> "Nicknames": + violations: list[Violation] = [] + if not isinstance(value, dict): + raise ValidationError([Violation(path="", reason="expected object")]) + raw = typing.cast("dict[str, typing.Any]", value) + additional_properties: dict[str, str | None] = {} + for key in raw: + member: str | None = None + member_raw = raw[key] + if member_raw is None: + member = None + else: + if not isinstance(member_raw, str): + violations.append(Violation(path=key, reason="expected string")) + else: + member = member_raw + if len(member_raw) < 2: + violations.append( + Violation( + path=key, + reason=f"must have length >= 2, got {len(member_raw)}", + ) + ) + additional_properties[key] = member + if violations: + raise ValidationError(violations) + return Nicknames(additional_properties=additional_properties) + + @typing_extensions.override + def to_transfer_type(self, value: "Nicknames") -> typing.Any: + violations: list[Violation] = [] + out: dict[str, typing.Any] = {} + for key, entry in value.additional_properties.items(): + if entry is not None: + if len(entry) < 2: + violations.append( + Violation( + path=key, reason=f"must have length >= 2, got {len(entry)}" + ) + ) + out[key] = entry + if violations: + raise ValidationError(violations) + return out -class Nicknames(pydantic.BaseModel): +@_transfer_type_convertible(_NicknamesTransferTypeConverter) +@dataclasses.dataclass(slots=True, kw_only=True) +class Nicknames: """A typed map of **nullable** members: a member may be an explicit null, which is kept as a null member rather than dropped from the map, while a present member still carries its own constraint. """ - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - strict=True, populate_by_name=True, extra="allow" + additional_properties: dict[str, str | None] = dataclasses.field( + default_factory=dict ) - @pydantic.model_validator(mode="after") - def _validate_extras(self) -> typing.Any: - extra = typing.cast(dict[str, object], self.model_extra or {}) - errors: list[pydantic_core.InitErrorDetails] = [] - for key, value in list(extra.items()): - try: - extra[key] = _NICKNAMES_MEMBER.validate_python(value) - except pydantic.ValidationError as error: - for detail in error.errors(): - errors.append( - pydantic_core.InitErrorDetails( - type=pydantic_core.PydanticCustomError( - typing.cast(typing.Any, detail["type"]), - typing.cast(typing.Any, detail["msg"]), - ), - loc=(key, *detail["loc"]), - input=detail["input"], + +class _QuotasTransferTypeConverter( + temporalio.converter.TransferTypeConverter["Quotas", typing.Any] +): + @typing_extensions.override + def from_transfer_type( + self, value: typing.Any, type_hint: type["Quotas"] + ) -> "Quotas": + violations: list[Violation] = [] + if not isinstance(value, dict): + raise ValidationError([Violation(path="", reason="expected object")]) + raw = typing.cast("dict[str, typing.Any]", value) + additional_properties: dict[str, int] = {} + for key in raw: + member: int = typing.cast("typing.Any", None) + member_raw = raw[key] + member_parsed = _parse_spec_integer(member_raw, key, violations) + if member_parsed is not None: + member = member_parsed + if member < 0: + violations.append( + Violation(path=key, reason=f"must be >= 0, got {member}") + ) + if member > 100: + violations.append( + Violation(path=key, reason=f"must be <= 100, got {member}") + ) + if member % 5 != 0: + violations.append( + Violation( + path=key, reason=f"must be a multiple of 5, got {member}" + ) + ) + additional_properties[key] = member + if violations: + raise ValidationError(violations) + return Quotas(additional_properties=additional_properties) + + @typing_extensions.override + def to_transfer_type(self, value: "Quotas") -> typing.Any: + violations: list[Violation] = [] + out: dict[str, typing.Any] = {} + for key, entry in value.additional_properties.items(): + if entry < 0: + violations.append( + Violation(path=key, reason=f"must be >= 0, got {entry}") + ) + if entry > 100: + violations.append( + Violation(path=key, reason=f"must be <= 100, got {entry}") + ) + if entry % 5 != 0: + violations.append( + Violation(path=key, reason=f"must be a multiple of 5, got {entry}") + ) + out[key] = entry + if violations: + raise ValidationError(violations) + return out + + +@_transfer_type_convertible(_QuotasTransferTypeConverter) +@dataclasses.dataclass(slots=True, kw_only=True) +class Quotas: + """A typed map whose members carry their own constraints: every member is a + non-negative multiple of 5, at most 100. A member is held to exactly what a declared + field of that type is held to, in both directions, with the offending member's key + as the violation path. + """ + + additional_properties: dict[str, int] = dataclasses.field(default_factory=dict) + + +class _SettingsTransferTypeConverter( + temporalio.converter.TransferTypeConverter["Settings", typing.Any] +): + @typing_extensions.override + def from_transfer_type( + self, value: typing.Any, type_hint: type["Settings"] + ) -> "Settings": + violations: list[Violation] = [] + if not isinstance(value, dict): + raise ValidationError([Violation(path="", reason="expected object")]) + raw = typing.cast("dict[str, typing.Any]", value) + + theme: str | None = None + if "theme" in raw: + theme_raw = raw["theme"] + if theme_raw is None: + violations.append( + Violation(path="theme", reason="explicit null not allowed") + ) + else: + if not isinstance(theme_raw, str): + violations.append(Violation(path="theme", reason="expected string")) + else: + theme = theme_raw + + font_size: int | None = None + if "fontSize" in raw: + font_size_raw = raw["fontSize"] + if font_size_raw is None: + violations.append( + Violation(path="fontSize", reason="explicit null not allowed") + ) + else: + font_size_parsed = _parse_spec_integer( + font_size_raw, "fontSize", violations + ) + if font_size_parsed is not None: + font_size = font_size_parsed + + for key in raw: + if key != "theme" and key != "fontSize": + violations.append(Violation(path=key, reason="unknown field")) + if violations: + raise ValidationError(violations) + return Settings( + theme=theme, + font_size=font_size, + ) + + @typing_extensions.override + def to_transfer_type(self, value: "Settings") -> typing.Any: + out: dict[str, typing.Any] = {} + if value.theme is not None: + out["theme"] = value.theme + if value.font_size is not None: + out["fontSize"] = value.font_size + return out + + +@_transfer_type_convertible(_SettingsTransferTypeConverter) +@dataclasses.dataclass(slots=True, kw_only=True) +class Settings: + """A closed object; unknown members are rejected.""" + + theme: str | None = None + + font_size: int | None = None + + +class _ShowcaseTransferTypeConverter( + temporalio.converter.TransferTypeConverter["Showcase", typing.Any] +): + @typing_extensions.override + def from_transfer_type( + self, value: typing.Any, type_hint: type["Showcase"] + ) -> "Showcase": + violations: list[Violation] = [] + if not isinstance(value, dict): + raise ValidationError([Violation(path="", reason="expected object")]) + raw = typing.cast("dict[str, typing.Any]", value) + + kind: typing.Literal["showcase"] = typing.cast("typing.Any", None) + if "kind" not in raw or raw["kind"] is None: + violations.append(Violation(path="kind", reason="required")) + else: + kind_raw = raw["kind"] + if not isinstance(kind_raw, str): + violations.append(Violation(path="kind", reason="expected string")) + elif kind_raw != "showcase": + violations.append( + Violation(path="kind", reason='must equal "showcase"') + ) + else: + kind = kind_raw + + revision: typing.Literal[1] = typing.cast("typing.Any", None) + if "revision" not in raw or raw["revision"] is None: + violations.append(Violation(path="revision", reason="required")) + else: + revision_raw = raw["revision"] + if not ( + not isinstance(revision_raw, bool) + and isinstance(revision_raw, (int, float)) + ): + violations.append(Violation(path="revision", reason="expected number")) + elif revision_raw != 1: + violations.append(Violation(path="revision", reason="must equal 1")) + else: + revision = typing.cast("typing.Literal[1]", revision_raw) + + enabled: typing.Literal[True] = typing.cast("typing.Any", None) + if "enabled" not in raw or raw["enabled"] is None: + violations.append(Violation(path="enabled", reason="required")) + else: + enabled_raw = raw["enabled"] + if not isinstance(enabled_raw, bool): + violations.append(Violation(path="enabled", reason="expected boolean")) + elif enabled_raw != True: + violations.append(Violation(path="enabled", reason="must equal true")) + else: + enabled = enabled_raw + + status: typing.Literal["active", "inactive", "pending"] = typing.cast( + "typing.Any", None + ) + if "status" not in raw or raw["status"] is None: + violations.append(Violation(path="status", reason="required")) + else: + status_raw = raw["status"] + if not isinstance(status_raw, str): + violations.append(Violation(path="status", reason="expected string")) + elif ( + status_raw != "active" + and status_raw != "inactive" + and status_raw != "pending" + ): + violations.append( + Violation( + path="status", + reason=f'must be one of ["active", "inactive", "pending"], got {_quote(status_raw)}', + ) + ) + else: + status = status_raw + + tier: typing.Literal[1, 2, 3] = typing.cast("typing.Any", None) + if "tier" not in raw or raw["tier"] is None: + violations.append(Violation(path="tier", reason="required")) + else: + tier_raw = raw["tier"] + if not ( + not isinstance(tier_raw, bool) and isinstance(tier_raw, (int, float)) + ): + violations.append(Violation(path="tier", reason="expected number")) + elif tier_raw != 1 and tier_raw != 2 and tier_raw != 3: + violations.append( + Violation( + path="tier", + reason=f"must be one of [1, 2, 3], got {_quote(tier_raw)}", + ) + ) + else: + tier = typing.cast("typing.Literal[1, 2, 3]", tier_raw) + + scale: float = typing.cast("typing.Any", None) + if "scale" not in raw or raw["scale"] is None: + violations.append(Violation(path="scale", reason="required")) + else: + scale_raw = raw["scale"] + if not ( + not isinstance(scale_raw, bool) and isinstance(scale_raw, (int, float)) + ): + violations.append(Violation(path="scale", reason="expected number")) + elif scale_raw != 1.5 and scale_raw != 2.5: + violations.append( + Violation( + path="scale", + reason=f"must be one of [1.5, 2.5], got {_quote(scale_raw)}", + ) + ) + else: + scale = scale_raw + + name: str = typing.cast("typing.Any", None) + if "name" not in raw or raw["name"] is None: + violations.append(Violation(path="name", reason="required")) + else: + name_raw = raw["name"] + if not isinstance(name_raw, str): + violations.append(Violation(path="name", reason="expected string")) + else: + name = name_raw + if len(name_raw) < 1: + violations.append( + Violation( + path="name", + reason=f"must have length >= 1, got {len(name_raw)}", + ) + ) + if len(name_raw) > 64: + violations.append( + Violation( + path="name", + reason=f"must have length <= 64, got {len(name_raw)}", + ) + ) + + count: int = typing.cast("typing.Any", None) + if "count" not in raw or raw["count"] is None: + violations.append(Violation(path="count", reason="required")) + else: + count_raw = raw["count"] + count_parsed = _parse_spec_integer(count_raw, "count", violations) + if count_parsed is not None: + count = count_parsed + + active: bool = typing.cast("typing.Any", None) + if "active" not in raw or raw["active"] is None: + violations.append(Violation(path="active", reason="required")) + else: + active_raw = raw["active"] + if not isinstance(active_raw, bool): + violations.append(Violation(path="active", reason="expected boolean")) + else: + active = active_raw + + nickname: str | None = None + if "nickname" in raw: + nickname_raw = raw["nickname"] + if nickname_raw is None: + violations.append( + Violation(path="nickname", reason="explicit null not allowed") + ) + else: + if not isinstance(nickname_raw, str): + violations.append( + Violation(path="nickname", reason="expected string") + ) + else: + nickname = nickname_raw + if len(nickname_raw) > 12: + violations.append( + Violation( + path="nickname", + reason=f"must have length <= 12, got {len(nickname_raw)}", + ) + ) + + code: str | None = None + if "code" in raw: + code_raw = raw["code"] + if code_raw is None: + violations.append( + Violation(path="code", reason="explicit null not allowed") + ) + else: + if not isinstance(code_raw, str): + violations.append(Violation(path="code", reason="expected string")) + else: + code = code_raw + if len(code_raw) < 2: + violations.append( + Violation( + path="code", + reason=f"must have length >= 2, got {len(code_raw)}", + ) + ) + if len(code_raw) > 5: + violations.append( + Violation( + path="code", + reason=f"must have length <= 5, got {len(code_raw)}", + ) + ) + + sku: str | None = None + if "sku" in raw: + sku_raw = raw["sku"] + if sku_raw is None: + violations.append( + Violation(path="sku", reason="explicit null not allowed") + ) + else: + if not isinstance(sku_raw, str): + violations.append(Violation(path="sku", reason="expected string")) + else: + sku = sku_raw + if _PATTERN_CD24623C0C29CA35.search(sku_raw) is None: + violations.append( + Violation( + path="sku", + reason=f"must match pattern {_PATTERN_CD24623C0C29CA35.pattern}, got {_quote(sku_raw)}", + ) + ) + + phrase: str | None = None + if "phrase" in raw: + phrase_raw = raw["phrase"] + if phrase_raw is None: + violations.append( + Violation(path="phrase", reason="explicit null not allowed") + ) + else: + if not isinstance(phrase_raw, str): + violations.append( + Violation(path="phrase", reason="expected string") + ) + else: + phrase = phrase_raw + if _PATTERN_B4BA2CA20EB1B963.search(phrase_raw) is None: + violations.append( + Violation( + path="phrase", + reason=f"must match pattern {_PATTERN_B4BA2CA20EB1B963.pattern}, got {_quote(phrase_raw)}", + ) + ) + + request_id: str | None = None + if "requestId" in raw: + request_id_raw = raw["requestId"] + if request_id_raw is None: + violations.append( + Violation(path="requestId", reason="explicit null not allowed") + ) + else: + if not isinstance(request_id_raw, str): + violations.append( + Violation(path="requestId", reason="expected string") + ) + else: + request_id = request_id_raw + if _PATTERN_EAAFA3F3BF5456C8.search(request_id_raw) is None: + violations.append( + Violation( + path="requestId", + reason=f"must be a valid uuid, got {_quote(request_id_raw)}", + ) + ) + + contact_email: str | None = None + if "contactEmail" in raw: + contact_email_raw = raw["contactEmail"] + if contact_email_raw is None: + violations.append( + Violation(path="contactEmail", reason="explicit null not allowed") + ) + else: + if not isinstance(contact_email_raw, str): + violations.append( + Violation(path="contactEmail", reason="expected string") + ) + else: + contact_email = contact_email_raw + if ( + len(contact_email_raw) > 254 + or _PATTERN_67B8088E6C41E9D2.search(contact_email_raw) is None + ): + violations.append( + Violation( + path="contactEmail", + reason=f"must be a valid email, got {_quote(contact_email_raw)}", + ) + ) + + host: str | None = None + if "host" in raw: + host_raw = raw["host"] + if host_raw is None: + violations.append( + Violation(path="host", reason="explicit null not allowed") + ) + else: + if not isinstance(host_raw, str): + violations.append(Violation(path="host", reason="expected string")) + else: + host = host_raw + if ( + len(host_raw) > 253 + or _PATTERN_C3551EE088DD1057.search(host_raw) is None + ): + violations.append( + Violation( + path="host", + reason=f"must be a valid hostname, got {_quote(host_raw)}", + ) + ) + + homepage: str | None = None + if "homepage" in raw: + homepage_raw = raw["homepage"] + if homepage_raw is None: + violations.append( + Violation(path="homepage", reason="explicit null not allowed") + ) + else: + if not isinstance(homepage_raw, str): + violations.append( + Violation(path="homepage", reason="expected string") + ) + else: + homepage = homepage_raw + if _PATTERN_BECE32B4DA20247D.search(homepage_raw) is None: + violations.append( + Violation( + path="homepage", + reason=f"must be a valid uri, got {_quote(homepage_raw)}", + ) + ) + + gateway: str | None = None + if "gateway" in raw: + gateway_raw = raw["gateway"] + if gateway_raw is None: + violations.append( + Violation(path="gateway", reason="explicit null not allowed") + ) + else: + if not isinstance(gateway_raw, str): + violations.append( + Violation(path="gateway", reason="expected string") + ) + else: + gateway = gateway_raw + if _PATTERN_4A45C0D214B9083D.search(gateway_raw) is None: + violations.append( + Violation( + path="gateway", + reason=f"must be a valid ipv4, got {_quote(gateway_raw)}", + ) + ) + + blob: bytes | None = None + if "blob" in raw: + blob_raw = raw["blob"] + if blob_raw is None: + violations.append( + Violation(path="blob", reason="explicit null not allowed") + ) + else: + if not isinstance(blob_raw, str): + violations.append(Violation(path="blob", reason="expected string")) + else: + blob_parsed = _parse_base64(blob_raw, "blob", violations) + if blob_parsed is not None: + blob = blob_parsed + + url_blob: bytes | None = None + if "urlBlob" in raw: + url_blob_raw = raw["urlBlob"] + if url_blob_raw is None: + violations.append( + Violation(path="urlBlob", reason="explicit null not allowed") + ) + else: + if not isinstance(url_blob_raw, str): + violations.append( + Violation(path="urlBlob", reason="expected string") + ) + else: + url_blob_parsed = _parse_base64url( + url_blob_raw, "urlBlob", violations + ) + if url_blob_parsed is not None: + url_blob = url_blob_parsed + + retries: int | None = None + if "retries" in raw: + retries_raw = raw["retries"] + if retries_raw is None: + violations.append( + Violation(path="retries", reason="explicit null not allowed") + ) + else: + retries_parsed = _parse_spec_integer(retries_raw, "retries", violations) + if retries_parsed is not None: + retries = retries_parsed + + verbose: bool | None = None + if "verbose" in raw: + verbose_raw = raw["verbose"] + if verbose_raw is None: + violations.append( + Violation(path="verbose", reason="explicit null not allowed") + ) + else: + if not isinstance(verbose_raw, bool): + violations.append( + Violation(path="verbose", reason="expected boolean") + ) + else: + verbose = verbose_raw + + greeting: str | None = None + if "greeting" in raw: + greeting_raw = raw["greeting"] + if greeting_raw is None: + violations.append( + Violation(path="greeting", reason="explicit null not allowed") + ) + else: + if not isinstance(greeting_raw, str): + violations.append( + Violation(path="greeting", reason="expected string") + ) + else: + greeting = greeting_raw + + debug: bool | None = None + if "debug" in raw: + debug_raw = raw["debug"] + if debug_raw is None: + violations.append( + Violation(path="debug", reason="explicit null not allowed") + ) + else: + if not isinstance(debug_raw, bool): + violations.append( + Violation(path="debug", reason="expected boolean") + ) + else: + debug = debug_raw + + legacy_id_py: str | None = None + if "legacyId" in raw: + legacy_id_py_raw = raw["legacyId"] + if legacy_id_py_raw is None: + violations.append( + Violation(path="legacyId", reason="explicit null not allowed") + ) + else: + if not isinstance(legacy_id_py_raw, str): + violations.append( + Violation(path="legacyId", reason="expected string") + ) + else: + legacy_id_py = legacy_id_py_raw + + middle_name: str | None = None + if "middleName" in raw: + middle_name_raw = raw["middleName"] + if middle_name_raw is None: + middle_name = None + else: + if not isinstance(middle_name_raw, str): + violations.append( + Violation(path="middleName", reason="expected string") + ) + else: + middle_name = middle_name_raw + + category: str | None = None + if "category" not in raw: + violations.append(Violation(path="category", reason="required")) + else: + category_raw = raw["category"] + if category_raw is None: + category = None + else: + if not isinstance(category_raw, str): + violations.append( + Violation(path="category", reason="expected string") + ) + else: + category = category_raw + + priority: int | None = None + if "priority" in raw: + priority_raw = raw["priority"] + if priority_raw is None: + violations.append( + Violation(path="priority", reason="explicit null not allowed") + ) + else: + priority_parsed = _parse_spec_integer( + priority_raw, "priority", violations + ) + if priority_parsed is not None: + priority = priority_parsed + if priority < 1: + violations.append( + Violation( + path="priority", reason=f"must be >= 1, got {priority}" + ) + ) + if priority > 10: + violations.append( + Violation( + path="priority", reason=f"must be <= 10, got {priority}" + ) + ) + + level: int | None = None + if "level" in raw: + level_raw = raw["level"] + if level_raw is None: + violations.append( + Violation(path="level", reason="explicit null not allowed") + ) + else: + level_parsed = _parse_spec_integer(level_raw, "level", violations) + if level_parsed is not None: + level = level_parsed + if level <= 0: + violations.append( + Violation(path="level", reason=f"must be > 0, got {level}") + ) + + ratio: float | None = None + if "ratio" in raw: + ratio_raw = raw["ratio"] + if ratio_raw is None: + violations.append( + Violation(path="ratio", reason="explicit null not allowed") + ) + else: + if not ( + not isinstance(ratio_raw, bool) + and isinstance(ratio_raw, (int, float)) + ): + violations.append(Violation(path="ratio", reason="expected number")) + else: + ratio = ratio_raw + if ratio_raw < 5: + violations.append( + Violation( + path="ratio", reason=f"must be >= 5, got {ratio_raw}" + ) + ) + if math.fmod(ratio_raw, 5) != 0: + violations.append( + Violation( + path="ratio", + reason=f"must be a multiple of 5, got {ratio_raw}", + ) + ) + + step: int | None = None + if "step" in raw: + step_raw = raw["step"] + if step_raw is None: + violations.append( + Violation(path="step", reason="explicit null not allowed") + ) + else: + step_parsed = _parse_spec_integer(step_raw, "step", violations) + if step_parsed is not None: + step = step_parsed + if step % 3 != 0: + violations.append( + Violation( + path="step", + reason=f"must be a multiple of 3, got {step}", + ) + ) + + tags: list[str] | None = None + if "tags" in raw: + tags_raw = raw["tags"] + if tags_raw is None: + violations.append( + Violation(path="tags", reason="explicit null not allowed") + ) + else: + if not isinstance(tags_raw, list): + violations.append(Violation(path="tags", reason="expected array")) + else: + tags_list: list[str] = [] + for tags_index, tags_element in enumerate( + typing.cast("list[typing.Any]", tags_raw) + ): + tags_item_path = f"tags[{tags_index}]" + tags_item: str = typing.cast("typing.Any", None) + if not isinstance(tags_element, str): + violations.append( + Violation( + path=tags_item_path, reason="expected element" + ) + ) + else: + tags_item = tags_element + tags_list.append(tags_item) + if len(tags_list) < 1: + violations.append( + Violation( + path="tags", + reason=f"must have at least 1 items, got {len(tags_list)}", + ) + ) + if len(tags_list) > 5: + violations.append( + Violation( + path="tags", + reason=f"must have at most 5 items, got {len(tags_list)}", + ) + ) + tags = tags_list + + aliases: list[str] | None = None + if "aliases" in raw: + aliases_raw = raw["aliases"] + if aliases_raw is None: + violations.append( + Violation(path="aliases", reason="explicit null not allowed") + ) + else: + if not isinstance(aliases_raw, list): + violations.append( + Violation(path="aliases", reason="expected array") + ) + else: + aliases_list: list[str] = [] + for aliases_index, aliases_element in enumerate( + typing.cast("list[typing.Any]", aliases_raw) + ): + aliases_item_path = f"aliases[{aliases_index}]" + aliases_item: str = typing.cast("typing.Any", None) + if not isinstance(aliases_element, str): + violations.append( + Violation( + path=aliases_item_path, reason="expected element" + ) + ) + else: + aliases_item = aliases_element + aliases_list.append(aliases_item) + _check_unique_items(aliases_list, "aliases", violations) + aliases = aliases_list + + roles: list[str] | None = None + if "roles" in raw: + roles_raw = raw["roles"] + if roles_raw is None: + violations.append( + Violation(path="roles", reason="explicit null not allowed") + ) + else: + if not isinstance(roles_raw, list): + violations.append(Violation(path="roles", reason="expected array")) + else: + roles_list: list[str] = [] + for roles_index, roles_element in enumerate( + typing.cast("list[typing.Any]", roles_raw) + ): + roles_item_path = f"roles[{roles_index}]" + roles_item: str = typing.cast("typing.Any", None) + if not isinstance(roles_element, str): + violations.append( + Violation( + path=roles_item_path, reason="expected element" + ) + ) + else: + roles_item = roles_element + roles_list.append(roles_item) + _check_contains( + roles_list, + lambda element: element == "admin", + 1, + 2, + True, + "roles", + violations, + ) + roles = roles_list + + id_or_name: str | int | None = None + if "idOrName" in raw: + id_or_name_raw = raw["idOrName"] + if id_or_name_raw is None: + violations.append( + Violation(path="idOrName", reason="explicit null not allowed") + ) + else: + id_or_name_parsed = _showcase_id_or_name_from_transfer_type( + id_or_name_raw, "idOrName", violations + ) + if id_or_name_parsed is not None: + id_or_name = id_or_name_parsed + + mode: typing.Literal["auto", "manual"] | int | None = None + if "mode" in raw: + mode_raw = raw["mode"] + if mode_raw is None: + violations.append( + Violation(path="mode", reason="explicit null not allowed") + ) + else: + mode_parsed = _showcase_mode_from_transfer_type( + mode_raw, "mode", violations + ) + if mode_parsed is not None: + mode = mode_parsed + + payload: dict[str, typing.Any] | str | None = None + if "payload" in raw: + payload_raw = raw["payload"] + if payload_raw is None: + violations.append( + Violation(path="payload", reason="explicit null not allowed") + ) + else: + payload_parsed = _showcase_payload_from_transfer_type( + payload_raw, "payload", violations + ) + if payload_parsed is not None: + payload = payload_parsed + + detail: ShowcaseDetailObject | str | None = None + if "detail" in raw: + detail_raw = raw["detail"] + if detail_raw is None: + violations.append( + Violation(path="detail", reason="explicit null not allowed") + ) + else: + detail_parsed = _showcase_detail_from_transfer_type( + detail_raw, "detail", violations + ) + if detail_parsed is not None: + detail = detail_parsed + + shape_or_name: Circle | Square | str | None = None + if "shapeOrName" in raw: + shape_or_name_raw = raw["shapeOrName"] + if shape_or_name_raw is None: + violations.append( + Violation(path="shapeOrName", reason="explicit null not allowed") + ) + else: + shape_or_name_parsed = _showcase_shape_or_name_from_transfer_type( + shape_or_name_raw, "shapeOrName", violations + ) + if shape_or_name_parsed is not None: + shape_or_name = shape_or_name_parsed + + measurements: list[float] | str | None = None + if "measurements" in raw: + measurements_raw = raw["measurements"] + if measurements_raw is None: + violations.append( + Violation(path="measurements", reason="explicit null not allowed") + ) + else: + measurements_parsed = _showcase_measurements_from_transfer_type( + measurements_raw, "measurements", violations + ) + if measurements_parsed is not None: + measurements = measurements_parsed + + shapes: list[Shape] | None = None + if "shapes" in raw: + shapes_raw = raw["shapes"] + if shapes_raw is None: + violations.append( + Violation(path="shapes", reason="explicit null not allowed") + ) + else: + if not isinstance(shapes_raw, list): + violations.append(Violation(path="shapes", reason="expected array")) + else: + shapes_list: list[Shape] = [] + for shapes_index, shapes_element in enumerate( + typing.cast("list[typing.Any]", shapes_raw) + ): + shapes_item_path = f"shapes[{shapes_index}]" + shapes_item: Shape = typing.cast("typing.Any", None) + shapes_item_parsed = _shape_from_transfer_type( + shapes_element, shapes_item_path, violations + ) + if shapes_item_parsed is not None: + shapes_item = shapes_item_parsed + shapes_list.append(shapes_item) + shapes = shapes_list + + segments: list[ShowcaseSegmentsItem] | None = None + if "segments" in raw: + segments_raw = raw["segments"] + if segments_raw is None: + violations.append( + Violation(path="segments", reason="explicit null not allowed") + ) + else: + if not isinstance(segments_raw, list): + violations.append( + Violation(path="segments", reason="expected array") + ) + else: + segments_list: list[ShowcaseSegmentsItem] = [] + for segments_index, segments_element in enumerate( + typing.cast("list[typing.Any]", segments_raw) + ): + segments_item_path = f"segments[{segments_index}]" + segments_item: ShowcaseSegmentsItem = typing.cast( + "typing.Any", None + ) + segments_item_parsed = ( + _showcase_segments_item_from_transfer_type( + segments_element, segments_item_path, violations + ) + ) + if segments_item_parsed is not None: + segments_item = segments_item_parsed + segments_list.append(segments_item) + segments = segments_list + + slots: list[str | None] | None = None + if "slots" in raw: + slots_raw = raw["slots"] + if slots_raw is None: + violations.append( + Violation(path="slots", reason="explicit null not allowed") + ) + else: + if not isinstance(slots_raw, list): + violations.append(Violation(path="slots", reason="expected array")) + else: + slots_list: list[str | None] = [] + for slots_index, slots_element in enumerate( + typing.cast("list[typing.Any]", slots_raw) + ): + slots_item_path = f"slots[{slots_index}]" + slots_item: str | None = None + if slots_element is None: + slots_item = None + else: + if not isinstance(slots_element, str): + violations.append( + Violation( + path=slots_item_path, reason="expected string" + ) + ) + else: + slots_item = slots_element + slots_list.append(slots_item) + slots = slots_list + + grid: list[list[int]] | None = None + if "grid" in raw: + grid_raw = raw["grid"] + if grid_raw is None: + violations.append( + Violation(path="grid", reason="explicit null not allowed") + ) + else: + if not isinstance(grid_raw, list): + violations.append(Violation(path="grid", reason="expected array")) + else: + grid_list: list[list[int]] = [] + for grid_index, grid_element in enumerate( + typing.cast("list[typing.Any]", grid_raw) + ): + grid_item_path = f"grid[{grid_index}]" + grid_item: list[int] = typing.cast("typing.Any", None) + if not isinstance(grid_element, list): + violations.append( + Violation(path=grid_item_path, reason="expected array") + ) + else: + grid_item_list: list[int] = [] + for grid_item_index, grid_item_element in enumerate( + typing.cast("list[typing.Any]", grid_element) + ): + grid_item_item_path = ( + f"{grid_item_path}[{grid_item_index}]" + ) + grid_item_item: int = typing.cast("typing.Any", None) + grid_item_item_parsed = _parse_spec_integer( + grid_item_element, grid_item_item_path, violations + ) + if grid_item_item_parsed is not None: + grid_item_item = grid_item_item_parsed + grid_item_list.append(grid_item_item) + grid_item = grid_item_list + grid_list.append(grid_item) + grid = grid_list + + location: ShowcaseLocation | None = None + if "location" in raw: + location_raw = raw["location"] + if location_raw is None: + violations.append( + Violation(path="location", reason="explicit null not allowed") + ) + else: + try: + location = ( + _ShowcaseLocationTransferTypeConverter().from_transfer_type( + location_raw, ShowcaseLocation + ) + ) + except ValidationError as error: + _collect(violations, "location", error) + + audit: ShowcaseAudit | None = None + if "audit" in raw: + audit_raw = raw["audit"] + if audit_raw is None: + audit = None + else: + try: + audit = _ShowcaseAuditTransferTypeConverter().from_transfer_type( + audit_raw, ShowcaseAudit + ) + except ValidationError as error: + _collect(violations, "audit", error) + + rows: list[ShowcaseRowsItem] | None = None + if "rows" in raw: + rows_raw = raw["rows"] + if rows_raw is None: + violations.append( + Violation(path="rows", reason="explicit null not allowed") + ) + else: + if not isinstance(rows_raw, list): + violations.append(Violation(path="rows", reason="expected array")) + else: + rows_list: list[ShowcaseRowsItem] = [] + for rows_index, rows_element in enumerate( + typing.cast("list[typing.Any]", rows_raw) + ): + rows_item_path = f"rows[{rows_index}]" + rows_item: ShowcaseRowsItem = typing.cast("typing.Any", None) + try: + rows_item = _ShowcaseRowsItemTransferTypeConverter().from_transfer_type( + rows_element, ShowcaseRowsItem + ) + except ValidationError as error: + _collect(violations, rows_item_path, error) + rows_list.append(rows_item) + rows = rows_list + + ledger_py: ShowcaseLedger | None = None + if "ledger" in raw: + ledger_py_raw = raw["ledger"] + if ledger_py_raw is None: + violations.append( + Violation(path="ledger", reason="explicit null not allowed") + ) + else: + try: + ledger_py = ( + _ShowcaseLedgerTransferTypeConverter().from_transfer_type( + ledger_py_raw, ShowcaseLedger + ) + ) + except ValidationError as error: + _collect(violations, "ledger", error) + + metadata: ShowcaseMetadata | None = None + if "metadata" in raw: + metadata_raw = raw["metadata"] + if metadata_raw is None: + violations.append( + Violation(path="metadata", reason="explicit null not allowed") + ) + else: + try: + metadata = ( + _ShowcaseMetadataTransferTypeConverter().from_transfer_type( + metadata_raw, ShowcaseMetadata + ) + ) + except ValidationError as error: + _collect(violations, "metadata", error) + + quotas: Quotas | None = None + if "quotas" in raw: + quotas_raw = raw["quotas"] + if quotas_raw is None: + violations.append( + Violation(path="quotas", reason="explicit null not allowed") + ) + else: + try: + quotas = _QuotasTransferTypeConverter().from_transfer_type( + quotas_raw, Quotas + ) + except ValidationError as error: + _collect(violations, "quotas", error) + + tokens: Tokens | None = None + if "tokens" in raw: + tokens_raw = raw["tokens"] + if tokens_raw is None: + violations.append( + Violation(path="tokens", reason="explicit null not allowed") + ) + else: + try: + tokens = _TokensTransferTypeConverter().from_transfer_type( + tokens_raw, Tokens + ) + except ValidationError as error: + _collect(violations, "tokens", error) + + nicknames: Nicknames | None = None + if "nicknames" in raw: + nicknames_raw = raw["nicknames"] + if nicknames_raw is None: + violations.append( + Violation(path="nicknames", reason="explicit null not allowed") + ) + else: + try: + nicknames = _NicknamesTransferTypeConverter().from_transfer_type( + nicknames_raw, Nicknames + ) + except ValidationError as error: + _collect(violations, "nicknames", error) + + choices: Choices | None = None + if "choices" in raw: + choices_raw = raw["choices"] + if choices_raw is None: + violations.append( + Violation(path="choices", reason="explicit null not allowed") + ) + else: + try: + choices = _ChoicesTransferTypeConverter().from_transfer_type( + choices_raw, Choices + ) + except ValidationError as error: + _collect(violations, "choices", error) + + extras: Extras | None = None + if "extras" in raw: + extras_raw = raw["extras"] + if extras_raw is None: + violations.append( + Violation(path="extras", reason="explicit null not allowed") + ) + else: + try: + extras = _ExtrasTransferTypeConverter().from_transfer_type( + extras_raw, Extras + ) + except ValidationError as error: + _collect(violations, "extras", error) + + shape: Shape | None = None + if "shape" in raw: + shape_raw = raw["shape"] + if shape_raw is None: + violations.append( + Violation(path="shape", reason="explicit null not allowed") + ) + else: + shape_parsed = _shape_from_transfer_type(shape_raw, "shape", violations) + if shape_parsed is not None: + shape = shape_parsed + + note: Note | None = None + if "note" in raw: + note_raw = raw["note"] + if note_raw is None: + violations.append( + Violation(path="note", reason="explicit null not allowed") + ) + else: + note_parsed = _note_from_transfer_type(note_raw, "note", violations) + if note_parsed is not None: + note = note_parsed + + address: Address | None = None + if "address" in raw: + address_raw = raw["address"] + if address_raw is None: + violations.append( + Violation(path="address", reason="explicit null not allowed") + ) + else: + try: + address = _AddressTransferTypeConverter().from_transfer_type( + address_raw, Address + ) + except ValidationError as error: + _collect(violations, "address", error) + + labels: Labels | None = None + if "labels" in raw: + labels_raw = raw["labels"] + if labels_raw is None: + violations.append( + Violation(path="labels", reason="explicit null not allowed") + ) + else: + try: + labels = _LabelsTransferTypeConverter().from_transfer_type( + labels_raw, Labels + ) + except ValidationError as error: + _collect(violations, "labels", error) + + settings: Settings | None = None + if "settings" in raw: + settings_raw = raw["settings"] + if settings_raw is None: + violations.append( + Violation(path="settings", reason="explicit null not allowed") + ) + else: + try: + settings = _SettingsTransferTypeConverter().from_transfer_type( + settings_raw, Settings + ) + except ValidationError as error: + _collect(violations, "settings", error) + + attributes: Attributes | None = None + if "attributes" in raw: + attributes_raw = raw["attributes"] + if attributes_raw is None: + violations.append( + Violation(path="attributes", reason="explicit null not allowed") + ) + else: + try: + attributes = _AttributesTransferTypeConverter().from_transfer_type( + attributes_raw, Attributes + ) + except ValidationError as error: + _collect(violations, "attributes", error) + + contact: ContactPy | None = None + if "contact" in raw: + contact_raw = raw["contact"] + if contact_raw is None: + violations.append( + Violation(path="contact", reason="explicit null not allowed") + ) + else: + try: + contact = _ContactPyTransferTypeConverter().from_transfer_type( + contact_raw, ContactPy + ) + except ValidationError as error: + _collect(violations, "contact", error) + + for key in raw: + if ( + key != "kind" + and key != "revision" + and key != "enabled" + and key != "status" + and key != "tier" + and key != "scale" + and key != "name" + and key != "count" + and key != "active" + and key != "nickname" + and key != "code" + and key != "sku" + and key != "phrase" + and key != "requestId" + and key != "contactEmail" + and key != "host" + and key != "homepage" + and key != "gateway" + and key != "blob" + and key != "urlBlob" + and key != "retries" + and key != "verbose" + and key != "greeting" + and key != "debug" + and key != "legacyId" + and key != "middleName" + and key != "category" + and key != "priority" + and key != "level" + and key != "ratio" + and key != "step" + and key != "tags" + and key != "aliases" + and key != "roles" + and key != "idOrName" + and key != "mode" + and key != "payload" + and key != "detail" + and key != "shapeOrName" + and key != "measurements" + and key != "shapes" + and key != "segments" + and key != "slots" + and key != "grid" + and key != "location" + and key != "audit" + and key != "rows" + and key != "ledger" + and key != "metadata" + and key != "quotas" + and key != "tokens" + and key != "nicknames" + and key != "choices" + and key != "extras" + and key != "shape" + and key != "note" + and key != "address" + and key != "labels" + and key != "settings" + and key != "attributes" + and key != "contact" + ): + violations.append(Violation(path=key, reason="unknown field")) + if violations: + raise ValidationError(violations) + return Showcase( + kind=kind, + revision=revision, + enabled=enabled, + status=status, + tier=tier, + scale=scale, + name=name, + count=count, + active=active, + nickname=nickname, + code=code, + sku=sku, + phrase=phrase, + request_id=request_id, + contact_email=contact_email, + host=host, + homepage=homepage, + gateway=gateway, + blob=blob, + url_blob=url_blob, + retries=retries, + verbose=verbose, + greeting=greeting, + debug=debug, + legacy_id_py=legacy_id_py, + middle_name=middle_name, + category=category, + priority=priority, + level=level, + ratio=ratio, + step=step, + tags=tags, + aliases=aliases, + roles=roles, + id_or_name=id_or_name, + mode=mode, + payload=payload, + detail=detail, + shape_or_name=shape_or_name, + measurements=measurements, + shapes=shapes, + segments=segments, + slots=slots, + grid=grid, + location=location, + audit=audit, + rows=rows, + ledger_py=ledger_py, + metadata=metadata, + quotas=quotas, + tokens=tokens, + nicknames=nicknames, + choices=choices, + extras=extras, + shape=shape, + note=note, + address=address, + labels=labels, + settings=settings, + attributes=attributes, + contact=contact, + ) + + @typing_extensions.override + def to_transfer_type(self, value: "Showcase") -> typing.Any: + violations: list[Violation] = [] + out: dict[str, typing.Any] = {} + if typing.cast("object", value.kind) not in ("showcase",): + violations.append(Violation(path="kind", reason='must equal "showcase"')) + out["kind"] = value.kind + if typing.cast("object", value.revision) not in (1,): + violations.append(Violation(path="revision", reason="must equal 1")) + out["revision"] = value.revision + if typing.cast("object", value.enabled) not in (True,): + violations.append(Violation(path="enabled", reason="must equal true")) + out["enabled"] = value.enabled + if typing.cast("object", value.status) not in ( + "active", + "inactive", + "pending", + ): + violations.append( + Violation( + path="status", + reason=f'must be one of ["active", "inactive", "pending"], got {_quote(value.status)}', + ) + ) + out["status"] = value.status + if typing.cast("object", value.tier) not in ( + 1, + 2, + 3, + ): + violations.append( + Violation( + path="tier", + reason=f"must be one of [1, 2, 3], got {_quote(value.tier)}", + ) + ) + out["tier"] = value.tier + if typing.cast("object", value.scale) not in ( + 1.5, + 2.5, + ): + violations.append( + Violation( + path="scale", + reason=f"must be one of [1.5, 2.5], got {_quote(value.scale)}", + ) + ) + out["scale"] = value.scale + if len(value.name) < 1: + violations.append( + Violation( + path="name", reason=f"must have length >= 1, got {len(value.name)}" + ) + ) + if len(value.name) > 64: + violations.append( + Violation( + path="name", reason=f"must have length <= 64, got {len(value.name)}" + ) + ) + out["name"] = value.name + out["count"] = value.count + out["active"] = value.active + if value.nickname is not None: + if len(value.nickname) > 12: + violations.append( + Violation( + path="nickname", + reason=f"must have length <= 12, got {len(value.nickname)}", + ) + ) + out["nickname"] = value.nickname + if value.code is not None: + if len(value.code) < 2: + violations.append( + Violation( + path="code", + reason=f"must have length >= 2, got {len(value.code)}", + ) + ) + if len(value.code) > 5: + violations.append( + Violation( + path="code", + reason=f"must have length <= 5, got {len(value.code)}", + ) + ) + out["code"] = value.code + if value.sku is not None: + if _PATTERN_CD24623C0C29CA35.search(value.sku) is None: + violations.append( + Violation( + path="sku", + reason=f"must match pattern {_PATTERN_CD24623C0C29CA35.pattern}, got {_quote(value.sku)}", + ) + ) + out["sku"] = value.sku + if value.phrase is not None: + if _PATTERN_B4BA2CA20EB1B963.search(value.phrase) is None: + violations.append( + Violation( + path="phrase", + reason=f"must match pattern {_PATTERN_B4BA2CA20EB1B963.pattern}, got {_quote(value.phrase)}", + ) + ) + out["phrase"] = value.phrase + if value.request_id is not None: + if _PATTERN_EAAFA3F3BF5456C8.search(value.request_id) is None: + violations.append( + Violation( + path="requestId", + reason=f"must be a valid uuid, got {_quote(value.request_id)}", + ) + ) + out["requestId"] = value.request_id + if value.contact_email is not None: + if ( + len(value.contact_email) > 254 + or _PATTERN_67B8088E6C41E9D2.search(value.contact_email) is None + ): + violations.append( + Violation( + path="contactEmail", + reason=f"must be a valid email, got {_quote(value.contact_email)}", + ) + ) + out["contactEmail"] = value.contact_email + if value.host is not None: + if ( + len(value.host) > 253 + or _PATTERN_C3551EE088DD1057.search(value.host) is None + ): + violations.append( + Violation( + path="host", + reason=f"must be a valid hostname, got {_quote(value.host)}", + ) + ) + out["host"] = value.host + if value.homepage is not None: + if _PATTERN_BECE32B4DA20247D.search(value.homepage) is None: + violations.append( + Violation( + path="homepage", + reason=f"must be a valid uri, got {_quote(value.homepage)}", + ) + ) + out["homepage"] = value.homepage + if value.gateway is not None: + if _PATTERN_4A45C0D214B9083D.search(value.gateway) is None: + violations.append( + Violation( + path="gateway", + reason=f"must be a valid ipv4, got {_quote(value.gateway)}", + ) + ) + out["gateway"] = value.gateway + if value.blob is not None: + out["blob"] = _format_base64(value.blob) + if value.url_blob is not None: + out["urlBlob"] = _format_base64url(value.url_blob) + if value.retries is not None: + out["retries"] = value.retries + if value.verbose is not None: + out["verbose"] = value.verbose + if value.greeting is not None: + out["greeting"] = value.greeting + if value.debug is not None: + out["debug"] = value.debug + if value.legacy_id_py is not None: + out["legacyId"] = value.legacy_id_py + if value.middle_name is not None: + out["middleName"] = value.middle_name + out["category"] = value.category + if value.priority is not None: + if value.priority < 1: + violations.append( + Violation( + path="priority", reason=f"must be >= 1, got {value.priority}" + ) + ) + if value.priority > 10: + violations.append( + Violation( + path="priority", reason=f"must be <= 10, got {value.priority}" + ) + ) + out["priority"] = value.priority + if value.level is not None: + if value.level <= 0: + violations.append( + Violation(path="level", reason=f"must be > 0, got {value.level}") + ) + out["level"] = value.level + if value.ratio is not None: + if value.ratio < 5: + violations.append( + Violation(path="ratio", reason=f"must be >= 5, got {value.ratio}") + ) + if math.fmod(value.ratio, 5) != 0: + violations.append( + Violation( + path="ratio", + reason=f"must be a multiple of 5, got {value.ratio}", + ) + ) + out["ratio"] = value.ratio + if value.step is not None: + if value.step % 3 != 0: + violations.append( + Violation( + path="step", reason=f"must be a multiple of 3, got {value.step}" + ) + ) + out["step"] = value.step + if value.tags is not None: + if len(value.tags) < 1: + violations.append( + Violation( + path="tags", + reason=f"must have at least 1 items, got {len(value.tags)}", + ) + ) + if len(value.tags) > 5: + violations.append( + Violation( + path="tags", + reason=f"must have at most 5 items, got {len(value.tags)}", + ) + ) + out["tags"] = value.tags + if value.aliases is not None: + _check_unique_items(value.aliases, "aliases", violations) + out["aliases"] = value.aliases + if value.roles is not None: + _check_contains( + value.roles, + lambda element: element == "admin", + 1, + 2, + True, + "roles", + violations, + ) + out["roles"] = value.roles + if value.id_or_name is not None: + if isinstance(value.id_or_name, str): + if len(value.id_or_name) < 3: + violations.append( + Violation( + path="idOrName", + reason=f"must have length >= 3, got {len(value.id_or_name)}", + ) + ) + if not isinstance(value.id_or_name, bool) and isinstance( + value.id_or_name, int + ): + if value.id_or_name < 1: + violations.append( + Violation( + path="idOrName", + reason=f"must be >= 1, got {value.id_or_name}", + ) + ) + out["idOrName"] = value.id_or_name + if value.mode is not None: + if isinstance(value.mode, str): + if typing.cast("object", value.mode) not in ( + "auto", + "manual", + ): + violations.append( + Violation( + path="mode", + reason=f'must be one of ["auto", "manual"], got {_quote(value.mode)}', ) ) - if errors: - raise pydantic.ValidationError.from_exception_data( - title=type(self).__name__, line_errors=errors + if not isinstance(value.mode, bool) and isinstance(value.mode, int): + if value.mode < 0: + violations.append( + Violation(path="mode", reason=f"must be >= 0, got {value.mode}") + ) + out["mode"] = value.mode + if value.payload is not None: + out["payload"] = value.payload + if value.detail is not None: + out["detail"] = _showcase_detail_to_transfer_type(value.detail) + if value.shape_or_name is not None: + if isinstance(value.shape_or_name, str): + if len(value.shape_or_name) > 32: + violations.append( + Violation( + path="shapeOrName", + reason=f"must have length <= 32, got {len(value.shape_or_name)}", + ) + ) + out["shapeOrName"] = _showcase_shape_or_name_to_transfer_type( + value.shape_or_name ) - return self - - @pydantic.model_serializer(mode="wrap") - def _serialize( - self, - _handler: typing.Callable[[pydantic.BaseModel], typing.Any], - ) -> dict[str, object]: - return { - key: _NICKNAMES_MEMBER.dump_python(value, mode="json", by_alias=True) - for key, value in typing.cast( - dict[str, object], self.model_extra or {} - ).items() - } - - -class Quotas(pydantic.BaseModel): - """A typed map whose members carry their own constraints: every member is a - non-negative multiple of 5, at most 100. A member is held to exactly what a declared - field of that type is held to, in both directions, with the offending member's key - as the violation path. - """ - - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - strict=True, populate_by_name=True, extra="allow" - ) - - @pydantic.model_validator(mode="after") - def _validate_extras(self) -> typing.Any: - extra = typing.cast(dict[str, object], self.model_extra or {}) - errors: list[pydantic_core.InitErrorDetails] = [] - for key, value in list(extra.items()): - try: - extra[key] = _QUOTAS_MEMBER.validate_python(value) - except pydantic.ValidationError as error: - for detail in error.errors(): - errors.append( - pydantic_core.InitErrorDetails( - type=pydantic_core.PydanticCustomError( - typing.cast(typing.Any, detail["type"]), - typing.cast(typing.Any, detail["msg"]), - ), - loc=(key, *detail["loc"]), - input=detail["input"], + if value.measurements is not None: + if isinstance(value.measurements, list): + if len(value.measurements) < 1: + violations.append( + Violation( + path="measurements", + reason=f"must have at least 1 items, got {len(value.measurements)}", ) ) - if errors: - raise pydantic.ValidationError.from_exception_data( - title=type(self).__name__, line_errors=errors + _check_unique_items(value.measurements, "measurements", violations) + if isinstance(value.measurements, str): + if _PATTERN_F242E3A159C2422C.search(value.measurements) is None: + violations.append( + Violation( + path="measurements", + reason=f"must match pattern {_PATTERN_F242E3A159C2422C.pattern}, got {_quote(value.measurements)}", + ) + ) + out["measurements"] = value.measurements + if value.shapes is not None: + out["shapes"] = [ + _shape_to_transfer_type(element) for element in value.shapes + ] + if value.segments is not None: + out["segments"] = [ + _showcase_segments_item_to_transfer_type(element) + for element in value.segments + ] + if value.slots is not None: + out["slots"] = value.slots + if value.grid is not None: + out["grid"] = value.grid + if value.location is not None: + out["location"] = _ShowcaseLocationTransferTypeConverter().to_transfer_type( + value.location ) - return self - - @pydantic.model_serializer(mode="wrap") - def _serialize( - self, - _handler: typing.Callable[[pydantic.BaseModel], typing.Any], - ) -> dict[str, object]: - return { - key: _QUOTAS_MEMBER.dump_python(value, mode="json", by_alias=True) - for key, value in typing.cast( - dict[str, object], self.model_extra or {} - ).items() - } - - -class Settings(pydantic.BaseModel): - """A closed object; unknown members are rejected.""" - - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - strict=True, populate_by_name=True, extra="forbid" - ) - - theme: str | None = pydantic.Field(default=None) - - font_size: SpecInt | None = pydantic.Field(default=None, alias="fontSize") - - _OPTIONAL_NON_NULLABLE_FIELDS: typing.ClassVar[frozenset[str]] = frozenset( - {"fontSize", "font_size", "theme"} - ) - - @pydantic.model_validator(mode="wrap") - @classmethod - def _reject_null( - cls, - data: object, - handler: typing.Callable[[object], typing.Any], - ) -> typing.Any: - return _reject_explicit_null(cls, data, handler) - - @pydantic.model_serializer(mode="wrap") - def _serialize( - self, - handler: typing.Callable[[pydantic.BaseModel], typing.Any], - ) -> dict[str, object]: - return _emit_set_fields(self, handler) + if value.audit is not None: + out["audit"] = _ShowcaseAuditTransferTypeConverter().to_transfer_type( + value.audit + ) + if value.rows is not None: + out["rows"] = [ + _ShowcaseRowsItemTransferTypeConverter().to_transfer_type(element) + for element in value.rows + ] + if value.ledger_py is not None: + out["ledger"] = _ShowcaseLedgerTransferTypeConverter().to_transfer_type( + value.ledger_py + ) + if value.metadata is not None: + out["metadata"] = _ShowcaseMetadataTransferTypeConverter().to_transfer_type( + value.metadata + ) + if value.quotas is not None: + out["quotas"] = _QuotasTransferTypeConverter().to_transfer_type( + value.quotas + ) + if value.tokens is not None: + out["tokens"] = _TokensTransferTypeConverter().to_transfer_type( + value.tokens + ) + if value.nicknames is not None: + out["nicknames"] = _NicknamesTransferTypeConverter().to_transfer_type( + value.nicknames + ) + if value.choices is not None: + out["choices"] = _ChoicesTransferTypeConverter().to_transfer_type( + value.choices + ) + if value.extras is not None: + out["extras"] = _ExtrasTransferTypeConverter().to_transfer_type( + value.extras + ) + if value.shape is not None: + out["shape"] = _shape_to_transfer_type(value.shape) + if value.note is not None: + out["note"] = _note_to_transfer_type(value.note) + if value.address is not None: + out["address"] = _AddressTransferTypeConverter().to_transfer_type( + value.address + ) + if value.labels is not None: + out["labels"] = _LabelsTransferTypeConverter().to_transfer_type( + value.labels + ) + if value.settings is not None: + out["settings"] = _SettingsTransferTypeConverter().to_transfer_type( + value.settings + ) + if value.attributes is not None: + out["attributes"] = _AttributesTransferTypeConverter().to_transfer_type( + value.attributes + ) + if value.contact is not None: + out["contact"] = _ContactPyTransferTypeConverter().to_transfer_type( + value.contact + ) + if violations: + raise ValidationError(violations) + return out -class Showcase(pydantic.BaseModel): +@_transfer_type_convertible(_ShowcaseTransferTypeConverter) +@dataclasses.dataclass(slots=True, kw_only=True) +class Showcase: """Showcase Root object exercising the supported JSON-Schema feature subset: required and optional fields of every scalar type, optional+nullable and required+nullable @@ -593,14 +2665,10 @@ class Showcase(pydantic.BaseModel): (catch-all) object, a string const, a scalar default, and member docs. """ - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - strict=True, populate_by_name=True, extra="forbid" - ) - - kind: typing.Literal["showcase"] = pydantic.Field(default="showcase") + kind: typing.Literal["showcase"] = "showcase" """Discriminator; always "showcase".""" - revision: typing.Literal[1] = pydantic.Field(default=1) + revision: typing.Literal[1] = 1 """Integer const; always 1. Also exercises the single-`const` value override: `x-go-const-name`/`x-java-const-name` rename the emitted constant to the derived name plus a per-language suffix (Go `RevisionGo`, Java `REVISION_JAVA`) while the @@ -608,10 +2676,10 @@ class Showcase(pydantic.BaseModel): value is emitted as a plain literal type). """ - enabled: typing.Literal[True] = pydantic.Field(default=True) + enabled: typing.Literal[True] = True """Boolean const; always true.""" - status: typing.Literal["active", "inactive", "pending"] = pydantic.Field() + status: typing.Literal["active", "inactive", "pending"] """Closed string value set. Also exercises the enum value-constant override: `x-go-enum-names`/`x-java-enum-names` rename the `active` value's emitted constant to the value name plus a per-language suffix (Go `ActiveGo`, Java `ACTIVE_JAVA`) @@ -619,165 +2687,95 @@ class Showcase(pydantic.BaseModel): keyword). """ - tier: typing.Literal[1, 2, 3] = pydantic.Field() + tier: typing.Literal[1, 2, 3] """Closed integer value set.""" - scale: float = pydantic.Field() + scale: float """Closed number value set (exercises the Python float exception: emitted as plain float, validated by membership). """ - name: str = pydantic.Field(min_length=1, max_length=64) + name: str """Display name Required human-readable name, 1 to 64 code points. """ - count: SpecInt = pydantic.Field() + count: int """Required integer scalar.""" - active: bool = pydantic.Field() + active: bool """Required boolean scalar.""" - nickname: str | None = pydantic.Field(default=None, max_length=12) + nickname: str | None = None """Optional short name, at most 12 code points.""" - code: str | None = pydantic.Field(default=None, min_length=2, max_length=5) + code: str | None = None """Optional code, 2 to 5 code points. Counted in Unicode code points, so a multi-byte value (e.g. "a😀b", 3 code points / 6 UTF-8 bytes) is valid. """ - sku: ( - typing.Annotated[str, pydantic.AfterValidator(_check_pattern("^[A-Z]{2,4}\\Z"))] - | None - ) = pydantic.Field(default=None) + sku: str | None = None """Optional product code: 2 to 4 uppercase ASCII letters, anchored (`^[A-Z]{2,4}$`). Exercises the RE2-safe `pattern` gate. """ - phrase: ( - typing.Annotated[ - str, - pydantic.AfterValidator( - _check_pattern( - "^[^\\t\\n\\x0B\\f\\r ]+[\\t\\n\\x0B\\f\\r ][^\\t\\n\\x0B\\f\\r ]+\\Z" - ) - ), - ] - | None - ) = pydantic.Field(default=None) + phrase: str | None = None """Optional two-word phrase separated by whitespace (`^\\S+\\s\\S+$`). Exercises the loader's `\\s`/`\\S` → ASCII-class normalization and the per-target `$` end-anchor rewrite (Python `\\Z` / Java `\\z`), so a Unicode space (NBSP) and a trailing newline are rejected consistently across all four languages. """ - request_id: ( - typing.Annotated[ - str, - pydantic.AfterValidator( - _check_format( - "uuid", - "^[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}\\Z", - ) - ), - ] - | None - ) = pydantic.Field(default=None, alias="requestId") + request_id: str | None = None """Optional request identifier; asserted RFC 4122 UUID via `format: uuid`. Stays `string`-typed (format assertion, no materialization); the pinned regex is validated identically across all four languages. """ - contact_email: ( - typing.Annotated[ - str, - pydantic.AfterValidator( - _check_format( - "email", - "^[a-zA-Z0-9!#$%&'*+/=?^_`{|}~-]+(?:\\.[a-zA-Z0-9!#$%&'*+/=?^_`{|}~-]+)*@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+\\Z", - 254, - ) - ), - ] - | None - ) = pydantic.Field(default=None, alias="contactEmail") + contact_email: str | None = None """Optional contact address; asserted ASCII dot-atom `format: email` (single `@`, >=2-label domain, total length <= 254, guard-before-regex). """ - host: ( - typing.Annotated[ - str, - pydantic.AfterValidator( - _check_format( - "hostname", - "^[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?(?:\\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*\\Z", - 253, - ) - ), - ] - | None - ) = pydantic.Field(default=None) + host: str | None = None """Optional host name; asserted RFC 1123 `format: hostname` (LDH labels, total length <= 253). """ - homepage: ( - typing.Annotated[ - str, - pydantic.AfterValidator( - _check_format( - "uri", - "^(?:[A-Za-z][A-Za-z0-9+.-]*:(?://(?:(?:[A-Za-z0-9._~!$&'()*+,;=:-]|%[0-9A-Fa-f][0-9A-Fa-f])*@)?(?:(?:\\[(?:([0-9a-fA-F]{1,4}:){6}([0-9a-fA-F]{1,4}:[0-9a-fA-F]{1,4}|((25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])))|::([0-9a-fA-F]{1,4}:){5}([0-9a-fA-F]{1,4}:[0-9a-fA-F]{1,4}|((25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])))|([0-9a-fA-F]{1,4})?::([0-9a-fA-F]{1,4}:){4}([0-9a-fA-F]{1,4}:[0-9a-fA-F]{1,4}|((25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])))|(([0-9a-fA-F]{1,4}:){0,1}[0-9a-fA-F]{1,4})?::([0-9a-fA-F]{1,4}:){3}([0-9a-fA-F]{1,4}:[0-9a-fA-F]{1,4}|((25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])))|(([0-9a-fA-F]{1,4}:){0,2}[0-9a-fA-F]{1,4})?::([0-9a-fA-F]{1,4}:){2}([0-9a-fA-F]{1,4}:[0-9a-fA-F]{1,4}|((25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])))|(([0-9a-fA-F]{1,4}:){0,3}[0-9a-fA-F]{1,4})?::([0-9a-fA-F]{1,4}:)([0-9a-fA-F]{1,4}:[0-9a-fA-F]{1,4}|((25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])))|(([0-9a-fA-F]{1,4}:){0,4}[0-9a-fA-F]{1,4})?::([0-9a-fA-F]{1,4}:[0-9a-fA-F]{1,4}|((25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])))|(([0-9a-fA-F]{1,4}:){0,5}[0-9a-fA-F]{1,4})?::[0-9a-fA-F]{1,4}|(([0-9a-fA-F]{1,4}:){0,6}[0-9a-fA-F]{1,4})?::)\\]|\\[v[0-9A-Fa-f]+\\.[A-Za-z0-9._~!$&'()*+,;=:-]+\\])|(?:[A-Za-z0-9._~!$&'()*+,;=-]|%[0-9A-Fa-f][0-9A-Fa-f])*)(?::[0-9]*)?(?:/(?:[A-Za-z0-9._~!$&'()*+,;=:@-]|%[0-9A-Fa-f][0-9A-Fa-f])*)*|/(?:(?:[A-Za-z0-9._~!$&'()*+,;=:@-]|%[0-9A-Fa-f][0-9A-Fa-f])+(?:/(?:[A-Za-z0-9._~!$&'()*+,;=:@-]|%[0-9A-Fa-f][0-9A-Fa-f])*)*)?|(?:[A-Za-z0-9._~!$&'()*+,;=:@-]|%[0-9A-Fa-f][0-9A-Fa-f])+(?:/(?:[A-Za-z0-9._~!$&'()*+,;=:@-]|%[0-9A-Fa-f][0-9A-Fa-f])*)*)?(?:\\?(?:(?:[A-Za-z0-9._~!$&'()*+,;=:@-]|%[0-9A-Fa-f][0-9A-Fa-f])|[/?])*)?(?:#(?:(?:[A-Za-z0-9._~!$&'()*+,;=:@-]|%[0-9A-Fa-f][0-9A-Fa-f])|[/?])*)?)\\Z", - ) - ), - ] - | None - ) = pydantic.Field(default=None) + homepage: str | None = None """Optional homepage; asserted RFC 3986 `format: uri` (scheme required, ASCII only; an IP-literal host is validated by the spliced ipv6 grammar). """ - gateway: ( - typing.Annotated[ - str, - pydantic.AfterValidator( - _check_format( - "ipv4", - "^(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\Z", - ) - ), - ] - | None - ) = pydantic.Field(default=None) + gateway: str | None = None """Optional gateway address; asserted dotted-quad IPv4 via format ipv4.""" - blob: Base64Field | None = pydantic.Field(default=None) + blob: bytes | None = None """Optional binary payload carried as a `contentEncoding: base64` string, materialized to native bytes (Go []byte, TS Uint8Array, Python bytes, Java byte[]). The wire is canonical padded standard base64; a malformed value is rejected by the pinned regex before decode. """ - url_blob: Base64UrlField | None = pydantic.Field(default=None, alias="urlBlob") + url_blob: bytes | None = None """Optional binary payload carried as a `contentEncoding: base64url` string (URL-safe alphabet, unpadded, RFC 4648 §5), materialized to the same native bytes type. The same bytes encode to a different wire than base64 ("Pj4+" vs "Pj4-"). """ - retries: SpecInt = pydantic.Field(default=3) + retries: int | None = None """Retry budget Optional integer with a schema default. """ - verbose: bool | None = pydantic.Field(default=None) + verbose: bool | None = None - greeting: str = pydantic.Field(default="hello") + greeting: str | None = None """Greeting Optional string with a schema default, surfaced on read. """ - debug: bool = pydantic.Field(default=False) + debug: bool | None = None """Debug flag Optional boolean with a schema default. """ @@ -788,7 +2786,7 @@ class Showcase(pydantic.BaseModel): typing_extensions.deprecated("This field is deprecated.", category=None), ] | None - ) = pydantic.Field(default=None, alias="legacyId") + ) = None """Deprecated legacy identifier; prefer `requestId`. Exercises the native deprecation marker (Go // Deprecated:, TS @deprecated, Java @Deprecated, Python PEP 702 @deprecated). Also exercises the property-level `x--name` override (the Stage @@ -798,40 +2796,34 @@ class Showcase(pydantic.BaseModel): @JsonProperty). """ - middle_name: str | None = pydantic.Field(default=None, alias="middleName") + middle_name: str | None = None """Optional and nullable; may be absent or explicitly null.""" - category: str | None = pydantic.Field() + category: str | None """Required but nullable; may be explicitly cleared to null.""" - priority: SpecInt | None = pydantic.Field(default=None, ge=1, le=10) + priority: int | None = None """Optional integer bounded to the inclusive range [1, 10].""" - level: SpecInt | None = pydantic.Field(default=None, gt=0) + level: int | None = None """Optional integer that must be strictly greater than 0.""" - ratio: ( - typing.Annotated[float, pydantic.AfterValidator(_check_multiple_of(5))] | None - ) = pydantic.Field(default=None, ge=5) + ratio: float | None = None """Optional number that must be a non-negative multiple of 5.""" - step: SpecInt | None = pydantic.Field(default=None, multiple_of=3) + step: int | None = None """Optional integer that must be a multiple of 3.""" - tags: list[str] | None = pydantic.Field(default=None, min_length=1, max_length=5) + tags: list[str] | None = None """Ordered list of free-form tags; 1 to 5 entries.""" - aliases: list[str] | None = pydantic.Field(default=None) + aliases: list[str] | None = None """Alternate names; each must be distinct.""" - roles: list[str] | None = pydantic.Field(default=None) + roles: list[str] | None = None """Access roles; must contain between one and two "admin" entries.""" - id_or_name: ( - typing.Annotated[str, pydantic.Field(min_length=3)] - | typing.Annotated[SpecInt, pydantic.Field(ge=1)] - | None - ) = pydantic.Field(default=None, alias="idOrName") + id_or_name: str | int | None = None """Disjoint-kind union (oneOf sum type): the wire value is either a string of at least 3 code points or an integer of at least 1, selected by its JSON token. Not a member of a discriminated union — the token itself is the selector. Each branch also @@ -840,18 +2832,14 @@ class Showcase(pydantic.BaseModel): violation. """ - mode: ( - typing.Literal["auto", "manual"] - | typing.Annotated[SpecInt, pydantic.Field(ge=0)] - | None - ) = pydantic.Field(default=None) + mode: typing.Literal["auto", "manual"] | int | None = None """A union whose string branch is a **closed value set**: either one of two named modes or an unbounded non-negative integer. The branch narrows to its own admissible values (a Go/Java membership check, a TypeScript literal union, a Python `Literal`), so an unknown string is a Violation while any non-negative integer is accepted. """ - payload: dict[str, typing.Any] | str | None = pydantic.Field(default=None) + payload: dict[str, typing.Any] | str | None = None """Mixed-kind union whose object branch is an inline free-form object: the wire value is either an arbitrary object (members carried verbatim) or a string, selected by its JSON token. The free-form object is the one object branch that needs no type @@ -859,7 +2847,7 @@ class Showcase(pydantic.BaseModel): `Object`. """ - detail: ShowcaseDetailObject | str | None = pydantic.Field(default=None) + detail: ShowcaseDetailObject | str | None = None """Mixed-kind union whose object branch is an inline *structured* object, written directly on the property rather than in `$defs`. It is the only object branch of this union, so it derives its name from the union it belongs to — @@ -867,9 +2855,7 @@ class Showcase(pydantic.BaseModel): own constraints and it stays open to unknown ones. """ - shape_or_name: ( - Circle | Square | typing.Annotated[str, pydantic.Field(max_length=32)] | None - ) = pydantic.Field(default=None, alias="shapeOrName") + shape_or_name: Circle | Square | str | None = None """Tagged object union mixed with a scalar kind: the two selector layers compose — the JSON token picks object-vs-string, and, for an object, the shared required `kind` const picks Circle-vs-Square. Written inline on the property, so the union itself is @@ -880,14 +2866,7 @@ class Showcase(pydantic.BaseModel): validate through their own models. """ - measurements: ( - typing.Annotated[ - typing.Annotated[list[float], pydantic.Field(min_length=1)], - pydantic.AfterValidator(_check_unique_items), - ] - | typing.Annotated[str, pydantic.AfterValidator(_check_pattern("^[a-z]+\\Z"))] - | None - ) = pydantic.Field(default=None) + measurements: list[float] | str | None = None """Mixed-kind union with an array branch: the wire value is either a non-empty list of distinct numbers or a lowercase preset name, selected by its JSON token. An array branch has no definition to take a name from, so Go and Java emit it as the @@ -897,352 +2876,267 @@ class Showcase(pydantic.BaseModel): string's `pattern` — so the array-vs-string choice is validated as well as selected. """ - shapes: list[Shape] | None = pydantic.Field(default=None) + shapes: list[Shape] | None = None """A list whose element type is a named union: every element is routed to exactly one branch by the union's own selector, and its index carries into the violation path (`shapes[1]`). Go and Java cannot decode a sealed interface as a whole, so the element decodes through the union's dispatcher one at a time. """ - segments: list[ShowcaseSegmentsItem] | None = pydantic.Field(default=None) + segments: list[ShowcaseSegmentsItem] | None = None """A list whose element union is written **inline**. An element has no name of its own, so the union is named after its position — `ShowcaseSegmentsItem` — moved into `$defs`, and the element becomes a `$ref` at it; from there it is an ordinary named union in every language. """ - slots: list[str | None] | None = pydantic.Field(default=None) + slots: list[str | None] | None = None """A list of **nullable elements** — the two-branch nullability `oneOf` rather than a sum type, so nothing is named: the elements themselves become nullable (`[]*string`, `(string | null)[]`, `list[str | None]`, `List<@Nullable String>`) while the list stays a list. """ - grid: list[list[SpecInt]] | None = pydantic.Field(default=None) + grid: list[list[int]] | None = None """A nested array: `items` at depth two. Each level decodes elementwise, so a bad element is reported at its own two-dimensional index (`grid[1][0]`). """ - location: ShowcaseLocation | None = pydantic.Field(default=None) + location: ShowcaseLocation | None = None - audit: ShowcaseAudit | None = pydantic.Field(default=None) + audit: ShowcaseAudit | None = None """A nullable inline object. The nullability wrapper emits no type of its own, so the object inside it takes the property's name — `ShowcaseAudit`, the same name it would take written plainly: adding or removing nullability never renames the type. """ - rows: list[ShowcaseRowsItem] | None = pydantic.Field(default=None) + rows: list[ShowcaseRowsItem] | None = None """A list whose element is an inline object, named after its position (`ShowcaseRowsItem`) exactly as an inline element *union* is. """ - ledger_py: ShowcaseLedger | None = pydantic.Field(default=None, alias="ledger") - - metadata: ShowcaseMetadata | None = pydantic.Field(default=None) - - quotas: Quotas | None = pydantic.Field(default=None) - - tokens: Tokens | None = pydantic.Field(default=None) - - nicknames: Nicknames | None = pydantic.Field(default=None) - - choices: Choices | None = pydantic.Field(default=None) - - extras: Extras | None = pydantic.Field(default=None) - - shape: Shape | None = pydantic.Field(default=None) - - note: Note | None = pydantic.Field(default=None) - - address: Address | None = pydantic.Field(default=None) - - labels: Labels | None = pydantic.Field(default=None) - - settings: Settings | None = pydantic.Field(default=None) - - attributes: Attributes | None = pydantic.Field(default=None) - - contact: ContactPy | None = pydantic.Field(default=None) - - @pydantic.model_validator(mode="before") - @classmethod - def _inject_const_kind( - cls, - data: object, - ) -> object: - if isinstance(data, dict): - values = typing.cast(dict[str, object], data) - if "kind" not in values: - data = {**values, "kind": "showcase"} - elif values.get("kind", values.get("kind")) != "showcase": - raise pydantic_core.PydanticCustomError( - "const", 'kind must equal "showcase"' - ) - return typing.cast(object, data) - - @pydantic.model_validator(mode="before") - @classmethod - def _inject_const_revision( - cls, - data: object, - ) -> object: - if isinstance(data, dict): - values = typing.cast(dict[str, object], data) - if "revision" not in values: - data = {**values, "revision": 1} - elif values.get("revision", values.get("revision")) != 1: - raise pydantic_core.PydanticCustomError( - "const", "revision must equal 1" - ) - return typing.cast(object, data) - - @pydantic.model_validator(mode="before") - @classmethod - def _inject_const_enabled( - cls, - data: object, - ) -> object: - if isinstance(data, dict): - values = typing.cast(dict[str, object], data) - if "enabled" not in values: - data = {**values, "enabled": True} - elif values.get("enabled", values.get("enabled")) != True: - raise pydantic_core.PydanticCustomError( - "const", "enabled must equal True" - ) - return typing.cast(object, data) - - @pydantic.model_validator(mode="before") - @classmethod - def _check_enum_status( - cls, - data: object, - ) -> object: - if isinstance(data, dict): - values = typing.cast(dict[str, object], data) - if "status" in values: - got = values.get("status") - if got not in ["active", "inactive", "pending"]: - raise pydantic_core.PydanticCustomError( - "enum", - 'status must be one of ["active", "inactive", "pending"], got {got}', - {"got": got}, - ) - return typing.cast(object, data) - - @pydantic.model_validator(mode="before") - @classmethod - def _check_enum_tier( - cls, - data: object, - ) -> object: - if isinstance(data, dict): - values = typing.cast(dict[str, object], data) - if "tier" in values: - got = values.get("tier") - if got not in [1, 2, 3]: - raise pydantic_core.PydanticCustomError( - "enum", "tier must be one of [1, 2, 3], got {got}", {"got": got} - ) - return typing.cast(object, data) - - @pydantic.model_validator(mode="before") - @classmethod - def _check_enum_scale( - cls, - data: object, - ) -> object: - if isinstance(data, dict): - values = typing.cast(dict[str, object], data) - if "scale" in values: - got = values.get("scale") - if got not in [1.5, 2.5]: - raise pydantic_core.PydanticCustomError( - "enum", - "scale must be one of [1.5, 2.5], got {got}", - {"got": got}, - ) - return typing.cast(object, data) - - @pydantic.model_validator(mode="after") - def _validate_arrays(self) -> typing.Any: - errors: list[pydantic_core.InitErrorDetails] = [] - value = self.aliases - if value is not None: - seen: dict[object, int] = {} - for index, element in enumerate(value): - if element in seen: - errors.append( - pydantic_core.InitErrorDetails( - type=pydantic_core.PydanticCustomError( - "unique_items", - typing.cast( - typing.Any, - f"duplicate items: element at index {index} equals index {seen[element]}", - ), - ), - loc=("aliases",), - input=element, + ledger_py: ShowcaseLedger | None = None + + metadata: ShowcaseMetadata | None = None + + quotas: Quotas | None = None + + tokens: Tokens | None = None + + nicknames: Nicknames | None = None + + choices: Choices | None = None + + extras: Extras | None = None + + shape: Shape | None = None + + note: Note | None = None + + address: Address | None = None + + labels: Labels | None = None + + settings: Settings | None = None + + attributes: Attributes | None = None + + contact: ContactPy | None = None + + +class _ShowcaseAuditTransferTypeConverter( + temporalio.converter.TransferTypeConverter["ShowcaseAudit", typing.Any] +): + @typing_extensions.override + def from_transfer_type( + self, value: typing.Any, type_hint: type["ShowcaseAudit"] + ) -> "ShowcaseAudit": + violations: list[Violation] = [] + if not isinstance(value, dict): + raise ValidationError([Violation(path="", reason="expected object")]) + raw = typing.cast("dict[str, typing.Any]", value) + + by: str = typing.cast("typing.Any", None) + if "by" not in raw or raw["by"] is None: + violations.append(Violation(path="by", reason="required")) + else: + by_raw = raw["by"] + if not isinstance(by_raw, str): + violations.append(Violation(path="by", reason="expected string")) + else: + by = by_raw + if len(by_raw) < 1: + violations.append( + Violation( + path="by", + reason=f"must have length >= 1, got {len(by_raw)}", ) ) - else: - seen[element] = index - value = self.roles - if value is not None: - match_count = sum(1 for element in value if element == "admin") - if match_count < 1: - errors.append( - pydantic_core.InitErrorDetails( - type=pydantic_core.PydanticCustomError( - "too_few_matching_items", - typing.cast( - typing.Any, - f"too few matching items: at least 1, got {match_count}", - ), - ), - loc=("roles",), - input=value, - ) - ) - if match_count > 2: - errors.append( - pydantic_core.InitErrorDetails( - type=pydantic_core.PydanticCustomError( - "too_many_matching_items", - typing.cast( - typing.Any, - f"too many matching items: at most 2, got {match_count}", - ), - ), - loc=("roles",), - input=value, - ) - ) - if errors: - raise pydantic.ValidationError.from_exception_data( - title=type(self).__name__, line_errors=errors + + additional_properties: dict[str, typing.Any] = {} + for key in raw: + if key not in _SHOWCASE_AUDIT_DECLARED: + additional_properties[key] = raw[key] + if violations: + raise ValidationError(violations) + return ShowcaseAudit( + by=by, + additional_properties=additional_properties, + ) + + @typing_extensions.override + def to_transfer_type(self, value: "ShowcaseAudit") -> typing.Any: + violations: list[Violation] = [] + out: dict[str, typing.Any] = {} + if len(value.by) < 1: + violations.append( + Violation( + path="by", reason=f"must have length >= 1, got {len(value.by)}" + ) ) - return self - - _OPTIONAL_NON_NULLABLE_FIELDS: typing.ClassVar[frozenset[str]] = frozenset( - { - "address", - "aliases", - "attributes", - "blob", - "choices", - "code", - "contact", - "contactEmail", - "contact_email", - "detail", - "extras", - "gateway", - "grid", - "homepage", - "host", - "idOrName", - "id_or_name", - "labels", - "ledger", - "ledger_py", - "legacyId", - "legacy_id_py", - "level", - "location", - "measurements", - "metadata", - "mode", - "nickname", - "nicknames", - "note", - "payload", - "phrase", - "priority", - "quotas", - "ratio", - "requestId", - "request_id", - "roles", - "rows", - "segments", - "settings", - "shape", - "shapeOrName", - "shape_or_name", - "shapes", - "sku", - "slots", - "step", - "tags", - "tokens", - "urlBlob", - "url_blob", - "verbose", - } - ) + out["by"] = value.by + for key, entry in value.additional_properties.items(): + out[key] = entry + if violations: + raise ValidationError(violations) + return out + + +@_transfer_type_convertible(_ShowcaseAuditTransferTypeConverter) +@dataclasses.dataclass(slots=True, kw_only=True) +class ShowcaseAudit: + by: str - @pydantic.model_validator(mode="wrap") - @classmethod - def _reject_null( - cls, - data: object, - handler: typing.Callable[[object], typing.Any], - ) -> typing.Any: - return _reject_explicit_null(cls, data, handler) - - @pydantic.model_serializer(mode="wrap") - def _serialize( - self, - handler: typing.Callable[[pydantic.BaseModel], typing.Any], - ) -> dict[str, object]: - return _emit_set_fields(self, handler) - - -class ShowcaseAudit(pydantic.BaseModel): - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - strict=True, populate_by_name=True, extra="allow" + additional_properties: dict[str, typing.Any] = dataclasses.field( + default_factory=dict ) - by: str = pydantic.Field(min_length=1) - @pydantic.model_serializer(mode="wrap") - def _serialize( - self, - handler: typing.Callable[[pydantic.BaseModel], typing.Any], - ) -> dict[str, object]: - return _emit_set_fields(self, handler) +class _ShowcaseDetailObjectTransferTypeConverter( + temporalio.converter.TransferTypeConverter["ShowcaseDetailObject", typing.Any] +): + @typing_extensions.override + def from_transfer_type( + self, value: typing.Any, type_hint: type["ShowcaseDetailObject"] + ) -> "ShowcaseDetailObject": + violations: list[Violation] = [] + if not isinstance(value, dict): + raise ValidationError([Violation(path="", reason="expected object")]) + raw = typing.cast("dict[str, typing.Any]", value) + + code: str = typing.cast("typing.Any", None) + if "code" not in raw or raw["code"] is None: + violations.append(Violation(path="code", reason="required")) + else: + code_raw = raw["code"] + if not isinstance(code_raw, str): + violations.append(Violation(path="code", reason="expected string")) + else: + code = code_raw + if len(code_raw) < 1: + violations.append( + Violation( + path="code", + reason=f"must have length >= 1, got {len(code_raw)}", + ) + ) + hint: str | None = None + if "hint" in raw: + hint_raw = raw["hint"] + if hint_raw is None: + violations.append( + Violation(path="hint", reason="explicit null not allowed") + ) + else: + if not isinstance(hint_raw, str): + violations.append(Violation(path="hint", reason="expected string")) + else: + hint = hint_raw + + additional_properties: dict[str, typing.Any] = {} + for key in raw: + if key not in _SHOWCASE_DETAIL_OBJECT_DECLARED: + additional_properties[key] = raw[key] + if violations: + raise ValidationError(violations) + return ShowcaseDetailObject( + code=code, + hint=hint, + additional_properties=additional_properties, + ) + + @typing_extensions.override + def to_transfer_type(self, value: "ShowcaseDetailObject") -> typing.Any: + violations: list[Violation] = [] + out: dict[str, typing.Any] = {} + if len(value.code) < 1: + violations.append( + Violation( + path="code", reason=f"must have length >= 1, got {len(value.code)}" + ) + ) + out["code"] = value.code + if value.hint is not None: + out["hint"] = value.hint + for key, entry in value.additional_properties.items(): + out[key] = entry + if violations: + raise ValidationError(violations) + return out -class ShowcaseDetailObject(pydantic.BaseModel): - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - strict=True, populate_by_name=True, extra="allow" - ) - code: str = pydantic.Field(min_length=1) +@_transfer_type_convertible(_ShowcaseDetailObjectTransferTypeConverter) +@dataclasses.dataclass(slots=True, kw_only=True) +class ShowcaseDetailObject: + code: str - hint: str | None = pydantic.Field(default=None) + hint: str | None = None - _OPTIONAL_NON_NULLABLE_FIELDS: typing.ClassVar[frozenset[str]] = frozenset({"hint"}) + additional_properties: dict[str, typing.Any] = dataclasses.field( + default_factory=dict + ) - @pydantic.model_validator(mode="wrap") - @classmethod - def _reject_null( - cls, - data: object, - handler: typing.Callable[[object], typing.Any], - ) -> typing.Any: - return _reject_explicit_null(cls, data, handler) - @pydantic.model_serializer(mode="wrap") - def _serialize( - self, - handler: typing.Callable[[pydantic.BaseModel], typing.Any], - ) -> dict[str, object]: - return _emit_set_fields(self, handler) +class _ShowcaseLedgerTransferTypeConverter( + temporalio.converter.TransferTypeConverter["ShowcaseLedger", typing.Any] +): + @typing_extensions.override + def from_transfer_type( + self, value: typing.Any, type_hint: type["ShowcaseLedger"] + ) -> "ShowcaseLedger": + violations: list[Violation] = [] + if not isinstance(value, dict): + raise ValidationError([Violation(path="", reason="expected object")]) + raw = typing.cast("dict[str, typing.Any]", value) + additional_properties: dict[str, ShowcaseLedgerValue] = {} + for key in raw: + member: ShowcaseLedgerValue = typing.cast("typing.Any", None) + member_raw = raw[key] + try: + member = _ShowcaseLedgerValueTransferTypeConverter().from_transfer_type( + member_raw, ShowcaseLedgerValue + ) + except ValidationError as error: + _collect(violations, key, error) + additional_properties[key] = member + if violations: + raise ValidationError(violations) + return ShowcaseLedger(additional_properties=additional_properties) + + @typing_extensions.override + def to_transfer_type(self, value: "ShowcaseLedger") -> typing.Any: + out: dict[str, typing.Any] = {} + for key, entry in value.additional_properties.items(): + out[key] = _ShowcaseLedgerValueTransferTypeConverter().to_transfer_type( + entry + ) + return out -class ShowcaseLedger(pydantic.BaseModel): +@_transfer_type_convertible(_ShowcaseLedgerTransferTypeConverter) +@dataclasses.dataclass(slots=True, kw_only=True) +class ShowcaseLedger: """A typed map written inline on the property: the map itself is named `ShowcaseLedger` and its inline member shape `ShowcaseLedgerValue`, so both the map and its members are ordinary named models. Also exercises the member-name override on a hoisted @@ -1251,64 +3145,156 @@ class ShowcaseLedger(pydantic.BaseModel): name. """ - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - strict=True, populate_by_name=True, extra="allow" + additional_properties: dict[str, ShowcaseLedgerValue] = dataclasses.field( + default_factory=dict ) - @pydantic.model_validator(mode="after") - def _validate_extras(self) -> typing.Any: - extra = typing.cast(dict[str, object], self.model_extra or {}) - errors: list[pydantic_core.InitErrorDetails] = [] - for key, value in list(extra.items()): - try: - extra[key] = _SHOWCASE_LEDGER_MEMBER.validate_python(value) - except pydantic.ValidationError as error: - for detail in error.errors(): - errors.append( - pydantic_core.InitErrorDetails( - type=pydantic_core.PydanticCustomError( - typing.cast(typing.Any, detail["type"]), - typing.cast(typing.Any, detail["msg"]), - ), - loc=(key, *detail["loc"]), - input=detail["input"], - ) + +class _ShowcaseLedgerValueTransferTypeConverter( + temporalio.converter.TransferTypeConverter["ShowcaseLedgerValue", typing.Any] +): + @typing_extensions.override + def from_transfer_type( + self, value: typing.Any, type_hint: type["ShowcaseLedgerValue"] + ) -> "ShowcaseLedgerValue": + violations: list[Violation] = [] + if not isinstance(value, dict): + raise ValidationError([Violation(path="", reason="expected object")]) + raw = typing.cast("dict[str, typing.Any]", value) + + amount: int = typing.cast("typing.Any", None) + if "amount" not in raw or raw["amount"] is None: + violations.append(Violation(path="amount", reason="required")) + else: + amount_raw = raw["amount"] + amount_parsed = _parse_spec_integer(amount_raw, "amount", violations) + if amount_parsed is not None: + amount = amount_parsed + if amount < 0: + violations.append( + Violation(path="amount", reason=f"must be >= 0, got {amount}") ) - if errors: - raise pydantic.ValidationError.from_exception_data( - title=type(self).__name__, line_errors=errors + + additional_properties: dict[str, typing.Any] = {} + for key in raw: + if key not in _SHOWCASE_LEDGER_VALUE_DECLARED: + additional_properties[key] = raw[key] + if violations: + raise ValidationError(violations) + return ShowcaseLedgerValue( + amount=amount, + additional_properties=additional_properties, + ) + + @typing_extensions.override + def to_transfer_type(self, value: "ShowcaseLedgerValue") -> typing.Any: + violations: list[Violation] = [] + out: dict[str, typing.Any] = {} + if value.amount < 0: + violations.append( + Violation(path="amount", reason=f"must be >= 0, got {value.amount}") ) - return self - - @pydantic.model_serializer(mode="wrap") - def _serialize( - self, - _handler: typing.Callable[[pydantic.BaseModel], typing.Any], - ) -> dict[str, object]: - return { - key: _SHOWCASE_LEDGER_MEMBER.dump_python(value, mode="json", by_alias=True) - for key, value in typing.cast( - dict[str, object], self.model_extra or {} - ).items() - } - - -class ShowcaseLedgerValue(pydantic.BaseModel): - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - strict=True, populate_by_name=True, extra="allow" + out["amount"] = value.amount + for key, entry in value.additional_properties.items(): + out[key] = entry + if violations: + raise ValidationError(violations) + return out + + +@_transfer_type_convertible(_ShowcaseLedgerValueTransferTypeConverter) +@dataclasses.dataclass(slots=True, kw_only=True) +class ShowcaseLedgerValue: + amount: int + + additional_properties: dict[str, typing.Any] = dataclasses.field( + default_factory=dict ) - amount: SpecInt = pydantic.Field(ge=0) - @pydantic.model_serializer(mode="wrap") - def _serialize( - self, - handler: typing.Callable[[pydantic.BaseModel], typing.Any], - ) -> dict[str, object]: - return _emit_set_fields(self, handler) +class _ShowcaseLocationTransferTypeConverter( + temporalio.converter.TransferTypeConverter["ShowcaseLocation", typing.Any] +): + @typing_extensions.override + def from_transfer_type( + self, value: typing.Any, type_hint: type["ShowcaseLocation"] + ) -> "ShowcaseLocation": + violations: list[Violation] = [] + if not isinstance(value, dict): + raise ValidationError([Violation(path="", reason="expected object")]) + raw = typing.cast("dict[str, typing.Any]", value) + + city: str = typing.cast("typing.Any", None) + if "city" not in raw or raw["city"] is None: + violations.append(Violation(path="city", reason="required")) + else: + city_raw = raw["city"] + if not isinstance(city_raw, str): + violations.append(Violation(path="city", reason="expected string")) + else: + city = city_raw + if len(city_raw) < 1: + violations.append( + Violation( + path="city", + reason=f"must have length >= 1, got {len(city_raw)}", + ) + ) + + geo: ShowcaseLocationGeo | None = None + if "geo" in raw: + geo_raw = raw["geo"] + if geo_raw is None: + violations.append( + Violation(path="geo", reason="explicit null not allowed") + ) + else: + try: + geo = ( + _ShowcaseLocationGeoTransferTypeConverter().from_transfer_type( + geo_raw, ShowcaseLocationGeo + ) + ) + except ValidationError as error: + _collect(violations, "geo", error) + + additional_properties: dict[str, typing.Any] = {} + for key in raw: + if key not in _SHOWCASE_LOCATION_DECLARED: + additional_properties[key] = raw[key] + if violations: + raise ValidationError(violations) + return ShowcaseLocation( + city=city, + geo=geo, + additional_properties=additional_properties, + ) + + @typing_extensions.override + def to_transfer_type(self, value: "ShowcaseLocation") -> typing.Any: + violations: list[Violation] = [] + out: dict[str, typing.Any] = {} + if len(value.city) < 1: + violations.append( + Violation( + path="city", reason=f"must have length >= 1, got {len(value.city)}" + ) + ) + out["city"] = value.city + if value.geo is not None: + out["geo"] = _ShowcaseLocationGeoTransferTypeConverter().to_transfer_type( + value.geo + ) + for key, entry in value.additional_properties.items(): + out[key] = entry + if violations: + raise ValidationError(violations) + return out -class ShowcaseLocation(pydantic.BaseModel): +@_transfer_type_convertible(_ShowcaseLocationTransferTypeConverter) +@dataclasses.dataclass(slots=True, kw_only=True) +class ShowcaseLocation: """An object written **inline** on the property rather than in `$defs`. It is named after the position it occupies — `ShowcaseLocation` — moved into `$defs`, and the property becomes a `$ref` at it, so it emits as the ordinary named model an authored @@ -1317,254 +3303,617 @@ class ShowcaseLocation(pydantic.BaseModel): `$defs` boilerplate at any depth. """ - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - strict=True, populate_by_name=True, extra="allow" - ) - - city: str = pydantic.Field(min_length=1) - - geo: ShowcaseLocationGeo | None = pydantic.Field(default=None) - - _OPTIONAL_NON_NULLABLE_FIELDS: typing.ClassVar[frozenset[str]] = frozenset({"geo"}) - - @pydantic.model_validator(mode="wrap") - @classmethod - def _reject_null( - cls, - data: object, - handler: typing.Callable[[object], typing.Any], - ) -> typing.Any: - return _reject_explicit_null(cls, data, handler) + city: str - @pydantic.model_serializer(mode="wrap") - def _serialize( - self, - handler: typing.Callable[[pydantic.BaseModel], typing.Any], - ) -> dict[str, object]: - return _emit_set_fields(self, handler) + geo: ShowcaseLocationGeo | None = None - -class ShowcaseLocationGeo(pydantic.BaseModel): - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - strict=True, populate_by_name=True, extra="allow" + additional_properties: dict[str, typing.Any] = dataclasses.field( + default_factory=dict ) - lat: float | None = pydantic.Field(default=None) - - lon: float | None = pydantic.Field(default=None) - _OPTIONAL_NON_NULLABLE_FIELDS: typing.ClassVar[frozenset[str]] = frozenset( - {"lat", "lon"} +class _ShowcaseLocationGeoTransferTypeConverter( + temporalio.converter.TransferTypeConverter["ShowcaseLocationGeo", typing.Any] +): + @typing_extensions.override + def from_transfer_type( + self, value: typing.Any, type_hint: type["ShowcaseLocationGeo"] + ) -> "ShowcaseLocationGeo": + violations: list[Violation] = [] + if not isinstance(value, dict): + raise ValidationError([Violation(path="", reason="expected object")]) + raw = typing.cast("dict[str, typing.Any]", value) + + lat: float | None = None + if "lat" in raw: + lat_raw = raw["lat"] + if lat_raw is None: + violations.append( + Violation(path="lat", reason="explicit null not allowed") + ) + else: + if not ( + not isinstance(lat_raw, bool) and isinstance(lat_raw, (int, float)) + ): + violations.append(Violation(path="lat", reason="expected number")) + else: + lat = lat_raw + + lon: float | None = None + if "lon" in raw: + lon_raw = raw["lon"] + if lon_raw is None: + violations.append( + Violation(path="lon", reason="explicit null not allowed") + ) + else: + if not ( + not isinstance(lon_raw, bool) and isinstance(lon_raw, (int, float)) + ): + violations.append(Violation(path="lon", reason="expected number")) + else: + lon = lon_raw + + additional_properties: dict[str, typing.Any] = {} + for key in raw: + if key not in _SHOWCASE_LOCATION_GEO_DECLARED: + additional_properties[key] = raw[key] + if violations: + raise ValidationError(violations) + return ShowcaseLocationGeo( + lat=lat, + lon=lon, + additional_properties=additional_properties, + ) + + @typing_extensions.override + def to_transfer_type(self, value: "ShowcaseLocationGeo") -> typing.Any: + out: dict[str, typing.Any] = {} + if value.lat is not None: + out["lat"] = value.lat + if value.lon is not None: + out["lon"] = value.lon + for key, entry in value.additional_properties.items(): + out[key] = entry + return out + + +@_transfer_type_convertible(_ShowcaseLocationGeoTransferTypeConverter) +@dataclasses.dataclass(slots=True, kw_only=True) +class ShowcaseLocationGeo: + lat: float | None = None + + lon: float | None = None + + additional_properties: dict[str, typing.Any] = dataclasses.field( + default_factory=dict ) - @pydantic.model_validator(mode="wrap") - @classmethod - def _reject_null( - cls, - data: object, - handler: typing.Callable[[object], typing.Any], - ) -> typing.Any: - return _reject_explicit_null(cls, data, handler) - @pydantic.model_serializer(mode="wrap") - def _serialize( - self, - handler: typing.Callable[[pydantic.BaseModel], typing.Any], - ) -> dict[str, object]: - return _emit_set_fields(self, handler) +class _ShowcaseMetadataTransferTypeConverter( + temporalio.converter.TransferTypeConverter["ShowcaseMetadata", typing.Any] +): + @typing_extensions.override + def from_transfer_type( + self, value: typing.Any, type_hint: type["ShowcaseMetadata"] + ) -> "ShowcaseMetadata": + violations: list[Violation] = [] + if not isinstance(value, dict): + raise ValidationError([Violation(path="", reason="expected object")]) + raw = typing.cast("dict[str, typing.Any]", value) + if len(raw) > 3: + violations.append( + Violation( + path="", reason=f"must have at most 3 properties, got {len(raw)}" + ) + ) + additional_properties: dict[str, typing.Any] = {} + for key in raw: + additional_properties[key] = raw[key] + if violations: + raise ValidationError(violations) + return ShowcaseMetadata(additional_properties=additional_properties) + + @typing_extensions.override + def to_transfer_type(self, value: "ShowcaseMetadata") -> typing.Any: + violations: list[Violation] = [] + out: dict[str, typing.Any] = {} + for key, entry in value.additional_properties.items(): + out[key] = entry + if len(out) > 3: + violations.append( + Violation( + path="", reason=f"must have at most 3 properties, got {len(out)}" + ) + ) + if violations: + raise ValidationError(violations) + return out -class ShowcaseMetadata(pydantic.BaseModel): +@_transfer_type_convertible(_ShowcaseMetadataTransferTypeConverter) +@dataclasses.dataclass(slots=True, kw_only=True) +class ShowcaseMetadata: """A free-form object written inline. Even this is named (`ShowcaseMetadata`): every object emits as a named aggregate holding its members in a catch-all, so adding `properties` to it later only adds fields rather than changing the emitted type's kind, and its member-count bound rides along with it. """ - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - strict=True, populate_by_name=True, extra="allow" + additional_properties: dict[str, typing.Any] = dataclasses.field( + default_factory=dict ) - @pydantic.model_validator(mode="after") - def _validate_extras(self) -> typing.Any: - extra = typing.cast(dict[str, object], self.model_extra or {}) - errors: list[pydantic_core.InitErrorDetails] = [] - if len(extra) > 3: - errors.append( - pydantic_core.InitErrorDetails( - type=pydantic_core.PydanticCustomError( - "too_many_properties", - typing.cast( - typing.Any, - f"must have at most 3 properties, got {len(extra)}", - ), - ), - loc=(), - input=len(extra), - ) - ) - if errors: - raise pydantic.ValidationError.from_exception_data( - title=type(self).__name__, line_errors=errors - ) - return self - - @pydantic.model_serializer(mode="wrap") - def _serialize( - self, - _handler: typing.Callable[[pydantic.BaseModel], typing.Any], - ) -> dict[str, object]: - return dict(typing.cast(dict[str, object], self.model_extra or {})) +class _ShowcaseRowsItemTransferTypeConverter( + temporalio.converter.TransferTypeConverter["ShowcaseRowsItem", typing.Any] +): + @typing_extensions.override + def from_transfer_type( + self, value: typing.Any, type_hint: type["ShowcaseRowsItem"] + ) -> "ShowcaseRowsItem": + violations: list[Violation] = [] + if not isinstance(value, dict): + raise ValidationError([Violation(path="", reason="expected object")]) + raw = typing.cast("dict[str, typing.Any]", value) + + cell: str = typing.cast("typing.Any", None) + if "cell" not in raw or raw["cell"] is None: + violations.append(Violation(path="cell", reason="required")) + else: + cell_raw = raw["cell"] + if not isinstance(cell_raw, str): + violations.append(Violation(path="cell", reason="expected string")) + else: + cell = cell_raw + if len(cell_raw) < 1: + violations.append( + Violation( + path="cell", + reason=f"must have length >= 1, got {len(cell_raw)}", + ) + ) -class ShowcaseRowsItem(pydantic.BaseModel): - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - strict=True, populate_by_name=True, extra="allow" - ) - - cell: str = pydantic.Field(min_length=1) + additional_properties: dict[str, typing.Any] = {} + for key in raw: + if key not in _SHOWCASE_ROWS_ITEM_DECLARED: + additional_properties[key] = raw[key] + if violations: + raise ValidationError(violations) + return ShowcaseRowsItem( + cell=cell, + additional_properties=additional_properties, + ) + + @typing_extensions.override + def to_transfer_type(self, value: "ShowcaseRowsItem") -> typing.Any: + violations: list[Violation] = [] + out: dict[str, typing.Any] = {} + if len(value.cell) < 1: + violations.append( + Violation( + path="cell", reason=f"must have length >= 1, got {len(value.cell)}" + ) + ) + out["cell"] = value.cell + for key, entry in value.additional_properties.items(): + out[key] = entry + if violations: + raise ValidationError(violations) + return out - @pydantic.model_serializer(mode="wrap") - def _serialize( - self, - handler: typing.Callable[[pydantic.BaseModel], typing.Any], - ) -> dict[str, object]: - return _emit_set_fields(self, handler) +@_transfer_type_convertible(_ShowcaseRowsItemTransferTypeConverter) +@dataclasses.dataclass(slots=True, kw_only=True) +class ShowcaseRowsItem: + cell: str -class GetShowcaseInput(pydantic.BaseModel): - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - strict=True, populate_by_name=True, extra="forbid" + additional_properties: dict[str, typing.Any] = dataclasses.field( + default_factory=dict ) - id: str = pydantic.Field() - @pydantic.model_serializer(mode="wrap") - def _serialize( - self, - handler: typing.Callable[[pydantic.BaseModel], typing.Any], - ) -> dict[str, object]: - return _emit_set_fields(self, handler) +class _GetShowcaseInputTransferTypeConverter( + temporalio.converter.TransferTypeConverter["GetShowcaseInput", typing.Any] +): + @typing_extensions.override + def from_transfer_type( + self, value: typing.Any, type_hint: type["GetShowcaseInput"] + ) -> "GetShowcaseInput": + violations: list[Violation] = [] + if not isinstance(value, dict): + raise ValidationError([Violation(path="", reason="expected object")]) + raw = typing.cast("dict[str, typing.Any]", value) + + id: str = typing.cast("typing.Any", None) + if "id" not in raw or raw["id"] is None: + violations.append(Violation(path="id", reason="required")) + else: + id_raw = raw["id"] + if not isinstance(id_raw, str): + violations.append(Violation(path="id", reason="expected string")) + else: + id = id_raw + + for key in raw: + if key != "id": + violations.append(Violation(path=key, reason="unknown field")) + if violations: + raise ValidationError(violations) + return GetShowcaseInput( + id=id, + ) + + @typing_extensions.override + def to_transfer_type(self, value: "GetShowcaseInput") -> typing.Any: + out: dict[str, typing.Any] = {} + out["id"] = value.id + return out + + +@_transfer_type_convertible(_GetShowcaseInputTransferTypeConverter) +@dataclasses.dataclass(slots=True, kw_only=True) +class GetShowcaseInput: + id: str + + +class _SquareTransferTypeConverter( + temporalio.converter.TransferTypeConverter["Square", typing.Any] +): + @typing_extensions.override + def from_transfer_type( + self, value: typing.Any, type_hint: type["Square"] + ) -> "Square": + violations: list[Violation] = [] + if not isinstance(value, dict): + raise ValidationError([Violation(path="", reason="expected object")]) + raw = typing.cast("dict[str, typing.Any]", value) + + kind: typing.Literal["square"] = typing.cast("typing.Any", None) + if "kind" not in raw or raw["kind"] is None: + violations.append(Violation(path="kind", reason="required")) + else: + kind_raw = raw["kind"] + if not isinstance(kind_raw, str): + violations.append(Violation(path="kind", reason="expected string")) + elif kind_raw != "square": + violations.append(Violation(path="kind", reason='must equal "square"')) + else: + kind = kind_raw + + side: float = typing.cast("typing.Any", None) + if "side" not in raw or raw["side"] is None: + violations.append(Violation(path="side", reason="required")) + else: + side_raw = raw["side"] + if not ( + not isinstance(side_raw, bool) and isinstance(side_raw, (int, float)) + ): + violations.append(Violation(path="side", reason="expected number")) + else: + side = side_raw + + additional_properties: dict[str, typing.Any] = {} + for key in raw: + if key not in _SQUARE_DECLARED: + additional_properties[key] = raw[key] + if violations: + raise ValidationError(violations) + return Square( + kind=kind, + side=side, + additional_properties=additional_properties, + ) + + @typing_extensions.override + def to_transfer_type(self, value: "Square") -> typing.Any: + violations: list[Violation] = [] + out: dict[str, typing.Any] = {} + if typing.cast("object", value.kind) not in ("square",): + violations.append(Violation(path="kind", reason='must equal "square"')) + out["kind"] = value.kind + out["side"] = value.side + for key, entry in value.additional_properties.items(): + out[key] = entry + if violations: + raise ValidationError(violations) + return out + + +@_transfer_type_convertible(_SquareTransferTypeConverter) +@dataclasses.dataclass(slots=True, kw_only=True) +class Square: + """A square branch of the Shape and shapeOrName tagged unions.""" + kind: typing.Literal["square"] = "square" -class Square(pydantic.BaseModel): - """A square branch of the Shape and shapeOrName tagged unions.""" + side: float - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - strict=True, populate_by_name=True, extra="allow" + additional_properties: dict[str, typing.Any] = dataclasses.field( + default_factory=dict ) - kind: typing.Literal["square"] = pydantic.Field(default="square") - side: float = pydantic.Field() +class _TextNoteTransferTypeConverter( + temporalio.converter.TransferTypeConverter["TextNote", typing.Any] +): + @typing_extensions.override + def from_transfer_type( + self, value: typing.Any, type_hint: type["TextNote"] + ) -> "TextNote": + violations: list[Violation] = [] + if not isinstance(value, dict): + raise ValidationError([Violation(path="", reason="expected object")]) + raw = typing.cast("dict[str, typing.Any]", value) + + kind: typing.Literal["text"] = typing.cast("typing.Any", None) + if "kind" not in raw or raw["kind"] is None: + violations.append(Violation(path="kind", reason="required")) + else: + kind_raw = raw["kind"] + if not isinstance(kind_raw, str): + violations.append(Violation(path="kind", reason="expected string")) + elif kind_raw != "text": + violations.append(Violation(path="kind", reason='must equal "text"')) + else: + kind = kind_raw + + body: str = typing.cast("typing.Any", None) + if "body" not in raw or raw["body"] is None: + violations.append(Violation(path="body", reason="required")) + else: + body_raw = raw["body"] + if not isinstance(body_raw, str): + violations.append(Violation(path="body", reason="expected string")) + else: + body = body_raw + if len(body_raw) < 1: + violations.append( + Violation( + path="body", + reason=f"must have length >= 1, got {len(body_raw)}", + ) + ) - @pydantic.model_validator(mode="before") - @classmethod - def _inject_const_kind( - cls, - data: object, - ) -> object: - if isinstance(data, dict): - values = typing.cast(dict[str, object], data) - if "kind" not in values: - data = {**values, "kind": "square"} - elif values.get("kind", values.get("kind")) != "square": - raise pydantic_core.PydanticCustomError( - "const", 'kind must equal "square"' + additional_properties: dict[str, typing.Any] = {} + for key in raw: + if key not in _TEXT_NOTE_DECLARED: + additional_properties[key] = raw[key] + if violations: + raise ValidationError(violations) + return TextNote( + kind=kind, + body=body, + additional_properties=additional_properties, + ) + + @typing_extensions.override + def to_transfer_type(self, value: "TextNote") -> typing.Any: + violations: list[Violation] = [] + out: dict[str, typing.Any] = {} + if typing.cast("object", value.kind) not in ("text",): + violations.append(Violation(path="kind", reason='must equal "text"')) + out["kind"] = value.kind + if len(value.body) < 1: + violations.append( + Violation( + path="body", reason=f"must have length >= 1, got {len(value.body)}" ) - return typing.cast(object, data) - - @pydantic.model_serializer(mode="wrap") - def _serialize( - self, - handler: typing.Callable[[pydantic.BaseModel], typing.Any], - ) -> dict[str, object]: - return _emit_set_fields(self, handler) + ) + out["body"] = value.body + for key, entry in value.additional_properties.items(): + out[key] = entry + if violations: + raise ValidationError(violations) + return out -class TextNote(pydantic.BaseModel): +@_transfer_type_convertible(_TextNoteTransferTypeConverter) +@dataclasses.dataclass(slots=True, kw_only=True) +class TextNote: """A text note branch, named inline.""" - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - strict=True, populate_by_name=True, extra="allow" - ) + kind: typing.Literal["text"] = "text" - kind: typing.Literal["text"] = pydantic.Field(default="text") + body: str - body: str = pydantic.Field(min_length=1) + additional_properties: dict[str, typing.Any] = dataclasses.field( + default_factory=dict + ) - @pydantic.model_validator(mode="before") - @classmethod - def _inject_const_kind( - cls, - data: object, - ) -> object: - if isinstance(data, dict): - values = typing.cast(dict[str, object], data) - if "kind" not in values: - data = {**values, "kind": "text"} - elif values.get("kind", values.get("kind")) != "text": - raise pydantic_core.PydanticCustomError( - "const", 'kind must equal "text"' - ) - return typing.cast(object, data) - @pydantic.model_serializer(mode="wrap") - def _serialize( - self, - handler: typing.Callable[[pydantic.BaseModel], typing.Any], - ) -> dict[str, object]: - return _emit_set_fields(self, handler) +class _TokensTransferTypeConverter( + temporalio.converter.TransferTypeConverter["Tokens", typing.Any] +): + @typing_extensions.override + def from_transfer_type( + self, value: typing.Any, type_hint: type["Tokens"] + ) -> "Tokens": + violations: list[Violation] = [] + if not isinstance(value, dict): + raise ValidationError([Violation(path="", reason="expected object")]) + raw = typing.cast("dict[str, typing.Any]", value) + additional_properties: dict[str, str] = {} + for key in raw: + member: str = typing.cast("typing.Any", None) + member_raw = raw[key] + if not isinstance(member_raw, str): + violations.append(Violation(path=key, reason="expected string")) + else: + member = member_raw + if len(member_raw) < 2: + violations.append( + Violation( + path=key, + reason=f"must have length >= 2, got {len(member_raw)}", + ) + ) + if len(member_raw) > 8: + violations.append( + Violation( + path=key, + reason=f"must have length <= 8, got {len(member_raw)}", + ) + ) + if _PATTERN_F242E3A159C2422C.search(member_raw) is None: + violations.append( + Violation( + path=key, + reason=f"must match pattern {_PATTERN_F242E3A159C2422C.pattern}, got {_quote(member_raw)}", + ) + ) + additional_properties[key] = member + if violations: + raise ValidationError(violations) + return Tokens(additional_properties=additional_properties) + + @typing_extensions.override + def to_transfer_type(self, value: "Tokens") -> typing.Any: + violations: list[Violation] = [] + out: dict[str, typing.Any] = {} + for key, entry in value.additional_properties.items(): + if len(entry) < 2: + violations.append( + Violation( + path=key, reason=f"must have length >= 2, got {len(entry)}" + ) + ) + if len(entry) > 8: + violations.append( + Violation( + path=key, reason=f"must have length <= 8, got {len(entry)}" + ) + ) + if _PATTERN_F242E3A159C2422C.search(entry) is None: + violations.append( + Violation( + path=key, + reason=f"must match pattern {_PATTERN_F242E3A159C2422C.pattern}, got {_quote(entry)}", + ) + ) + out[key] = entry + if violations: + raise ValidationError(violations) + return out -class Tokens(pydantic.BaseModel): +@_transfer_type_convertible(_TokensTransferTypeConverter) +@dataclasses.dataclass(slots=True, kw_only=True) +class Tokens: """A typed map with a refined *string* member: 2 to 8 code points of lowercase ASCII. Exercises the member-level `minLength`/`maxLength`/`pattern` in every language. """ - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - strict=True, populate_by_name=True, extra="allow" - ) - - @pydantic.model_validator(mode="after") - def _validate_extras(self) -> typing.Any: - extra = typing.cast(dict[str, object], self.model_extra or {}) - errors: list[pydantic_core.InitErrorDetails] = [] - for key, value in list(extra.items()): - try: - extra[key] = _TOKENS_MEMBER.validate_python(value) - except pydantic.ValidationError as error: - for detail in error.errors(): - errors.append( - pydantic_core.InitErrorDetails( - type=pydantic_core.PydanticCustomError( - typing.cast(typing.Any, detail["type"]), - typing.cast(typing.Any, detail["msg"]), - ), - loc=(key, *detail["loc"]), - input=detail["input"], + additional_properties: dict[str, str] = dataclasses.field(default_factory=dict) + + +class _WidgetTransferTypeConverter( + temporalio.converter.TransferTypeConverter["Widget", typing.Any] +): + @typing_extensions.override + def from_transfer_type( + self, value: typing.Any, type_hint: type["Widget"] + ) -> "Widget": + violations: list[Violation] = [] + if not isinstance(value, dict): + raise ValidationError([Violation(path="", reason="expected object")]) + raw = typing.cast("dict[str, typing.Any]", value) + + id: str = typing.cast("typing.Any", None) + if "id" not in raw or raw["id"] is None: + violations.append(Violation(path="id", reason="required")) + else: + id_raw = raw["id"] + if not isinstance(id_raw, str): + violations.append(Violation(path="id", reason="expected string")) + else: + id = id_raw + + kind: str | None = None + if "kind" in raw: + kind_raw = raw["kind"] + if kind_raw is None: + violations.append( + Violation(path="kind", reason="explicit null not allowed") + ) + else: + if not isinstance(kind_raw, str): + violations.append(Violation(path="kind", reason="expected string")) + else: + kind = kind_raw + + name: str = typing.cast("typing.Any", None) + if "name" not in raw or raw["name"] is None: + violations.append(Violation(path="name", reason="required")) + else: + name_raw = raw["name"] + if not isinstance(name_raw, str): + violations.append(Violation(path="name", reason="expected string")) + else: + name = name_raw + + size: int | None = None + if "size" in raw: + size_raw = raw["size"] + if size_raw is None: + violations.append( + Violation(path="size", reason="explicit null not allowed") + ) + else: + size_parsed = _parse_spec_integer(size_raw, "size", violations) + if size_parsed is not None: + size = size_parsed + if size < 10: + violations.append( + Violation(path="size", reason=f"must be >= 10, got {size}") ) - ) - if errors: - raise pydantic.ValidationError.from_exception_data( - title=type(self).__name__, line_errors=errors - ) - return self - - @pydantic.model_serializer(mode="wrap") - def _serialize( - self, - _handler: typing.Callable[[pydantic.BaseModel], typing.Any], - ) -> dict[str, object]: - return { - key: _TOKENS_MEMBER.dump_python(value, mode="json", by_alias=True) - for key, value in typing.cast( - dict[str, object], self.model_extra or {} - ).items() - } - - -class Widget(pydantic.BaseModel): + if size > 20: + violations.append( + Violation(path="size", reason=f"must be <= 20, got {size}") + ) + + additional_properties: dict[str, typing.Any] = {} + for key in raw: + if key not in _WIDGET_DECLARED: + additional_properties[key] = raw[key] + if violations: + raise ValidationError(violations) + return Widget( + id=id, + kind=kind, + name=name, + size=size, + additional_properties=additional_properties, + ) + + @typing_extensions.override + def to_transfer_type(self, value: "Widget") -> typing.Any: + violations: list[Violation] = [] + out: dict[str, typing.Any] = {} + out["id"] = value.id + if value.kind is not None: + out["kind"] = value.kind + out["name"] = value.name + if value.size is not None: + if value.size < 10: + violations.append( + Violation(path="size", reason=f"must be >= 10, got {value.size}") + ) + if value.size > 20: + violations.append( + Violation(path="size", reason=f"must be <= 20, got {value.size}") + ) + out["size"] = value.size + for key, entry in value.additional_properties.items(): + out[key] = entry + if violations: + raise ValidationError(violations) + return out + + +@_transfer_type_convertible(_WidgetTransferTypeConverter) +@dataclasses.dataclass(slots=True, kw_only=True) +class Widget: """Base-type extension via allOf: WidgetBase is flattened in and the extension branch adds fields, so Widget merges to one standalone object with the union of properties ({id, kind, name, size}) and required ([id, name]). The `size` member is itself an @@ -1572,70 +3921,411 @@ class Widget(pydantic.BaseModel): outside it is rejected by the merged constraint. No allOf survives past the loader. """ - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - strict=True, populate_by_name=True, extra="allow" - ) - - id: str = pydantic.Field() + id: str - kind: str | None = pydantic.Field(default=None) + kind: str | None = None - name: str = pydantic.Field() + name: str - size: SpecInt | None = pydantic.Field(default=None, ge=10, le=20) + size: int | None = None """Optional integer with two allOf branches tightened to [10, 20].""" - _OPTIONAL_NON_NULLABLE_FIELDS: typing.ClassVar[frozenset[str]] = frozenset( - {"kind", "size"} + additional_properties: dict[str, typing.Any] = dataclasses.field( + default_factory=dict ) - @pydantic.model_validator(mode="wrap") - @classmethod - def _reject_null( - cls, - data: object, - handler: typing.Callable[[object], typing.Any], - ) -> typing.Any: - return _reject_explicit_null(cls, data, handler) - - @pydantic.model_serializer(mode="wrap") - def _serialize( - self, - handler: typing.Callable[[pydantic.BaseModel], typing.Any], - ) -> dict[str, object]: - return _emit_set_fields(self, handler) - -class WidgetBase(pydantic.BaseModel): +class _WidgetBaseTransferTypeConverter( + temporalio.converter.TransferTypeConverter["WidgetBase", typing.Any] +): + @typing_extensions.override + def from_transfer_type( + self, value: typing.Any, type_hint: type["WidgetBase"] + ) -> "WidgetBase": + violations: list[Violation] = [] + if not isinstance(value, dict): + raise ValidationError([Violation(path="", reason="expected object")]) + raw = typing.cast("dict[str, typing.Any]", value) + + id: str = typing.cast("typing.Any", None) + if "id" not in raw or raw["id"] is None: + violations.append(Violation(path="id", reason="required")) + else: + id_raw = raw["id"] + if not isinstance(id_raw, str): + violations.append(Violation(path="id", reason="expected string")) + else: + id = id_raw + + kind: str | None = None + if "kind" in raw: + kind_raw = raw["kind"] + if kind_raw is None: + violations.append( + Violation(path="kind", reason="explicit null not allowed") + ) + else: + if not isinstance(kind_raw, str): + violations.append(Violation(path="kind", reason="expected string")) + else: + kind = kind_raw + + additional_properties: dict[str, typing.Any] = {} + for key in raw: + if key not in _WIDGET_BASE_DECLARED: + additional_properties[key] = raw[key] + if violations: + raise ValidationError(violations) + return WidgetBase( + id=id, + kind=kind, + additional_properties=additional_properties, + ) + + @typing_extensions.override + def to_transfer_type(self, value: "WidgetBase") -> typing.Any: + out: dict[str, typing.Any] = {} + out["id"] = value.id + if value.kind is not None: + out["kind"] = value.kind + for key, entry in value.additional_properties.items(): + out[key] = entry + return out + + +@_transfer_type_convertible(_WidgetBaseTransferTypeConverter) +@dataclasses.dataclass(slots=True, kw_only=True) +class WidgetBase: """A base object folded into Widget via allOf. It stays its own type; Widget copies its fields rather than referencing or subtyping it. """ - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - strict=True, populate_by_name=True, extra="allow" + id: str + + kind: str | None = None + + additional_properties: dict[str, typing.Any] = dataclasses.field( + default_factory=dict + ) + + +def _choices_value_from_transfer_type( + value: typing.Any, path: str, violations: list[Violation] +) -> ChoicesValue | None: + if isinstance(value, dict): + tagged = typing.cast("dict[str, typing.Any]", value) + tag = tagged.get("kind") + if tag == "circle": + try: + return _CircleTransferTypeConverter().from_transfer_type(value, Circle) + except ValidationError as error: + _collect(violations, path, error) + return None + if tag == "square": + try: + return _SquareTransferTypeConverter().from_transfer_type(value, Square) + except ValidationError as error: + _collect(violations, path, error) + return None + violations.append( + Violation( + path=path, + reason=f'unknown discriminator kind {tag}: expected one of ["circle", "square"]', + ) + ) + return None + violations.append(Violation(path=path, reason="expected one of: Circle, Square")) + return None + + +def _choices_value_to_transfer_type(value: ChoicesValue) -> typing.Any: + if isinstance(value, Circle): + return _CircleTransferTypeConverter().to_transfer_type(value) + return _SquareTransferTypeConverter().to_transfer_type(value) + + +def _note_from_transfer_type( + value: typing.Any, path: str, violations: list[Violation] +) -> Note | None: + if isinstance(value, dict): + tagged = typing.cast("dict[str, typing.Any]", value) + tag = tagged.get("kind") + if tag == "text": + try: + return _TextNoteTransferTypeConverter().from_transfer_type( + value, TextNote + ) + except ValidationError as error: + _collect(violations, path, error) + return None + if tag == "link": + try: + return _LinkNoteTransferTypeConverter().from_transfer_type( + value, LinkNote + ) + except ValidationError as error: + _collect(violations, path, error) + return None + violations.append( + Violation( + path=path, + reason=f'unknown discriminator kind {tag}: expected one of ["text", "link"]', + ) + ) + return None + violations.append( + Violation(path=path, reason="expected one of: TextNote, LinkNote") ) + return None + + +def _note_to_transfer_type(value: Note) -> typing.Any: + if isinstance(value, TextNote): + return _TextNoteTransferTypeConverter().to_transfer_type(value) + return _LinkNoteTransferTypeConverter().to_transfer_type(value) + + +def _shape_from_transfer_type( + value: typing.Any, path: str, violations: list[Violation] +) -> Shape | None: + if isinstance(value, dict): + tagged = typing.cast("dict[str, typing.Any]", value) + tag = tagged.get("kind") + if tag == "circle": + try: + return _CircleTransferTypeConverter().from_transfer_type(value, Circle) + except ValidationError as error: + _collect(violations, path, error) + return None + if tag == "square": + try: + return _SquareTransferTypeConverter().from_transfer_type(value, Square) + except ValidationError as error: + _collect(violations, path, error) + return None + violations.append( + Violation( + path=path, + reason=f'unknown discriminator kind {tag}: expected one of ["circle", "square"]', + ) + ) + return None + violations.append(Violation(path=path, reason="expected one of: Circle, Square")) + return None + + +def _shape_to_transfer_type(value: Shape) -> typing.Any: + if isinstance(value, Circle): + return _CircleTransferTypeConverter().to_transfer_type(value) + return _SquareTransferTypeConverter().to_transfer_type(value) + - id: str = pydantic.Field() +def _showcase_segments_item_from_transfer_type( + value: typing.Any, path: str, violations: list[Violation] +) -> ShowcaseSegmentsItem | None: + if isinstance(value, str): + if len(value) < 2: + violations.append( + Violation(path=path, reason=f"must have length >= 2, got {len(value)}") + ) + return value + if ( + not isinstance(value, bool) + and isinstance(value, (int, float)) + and abs(value) <= 9007199254740991 + and float(value).is_integer() + ): + number = int(value) + if number < 0: + violations.append( + Violation(path=path, reason=f"must be >= 0, got {number}") + ) + return number + violations.append(Violation(path=path, reason="expected one of: string, integer")) + return None + + +def _showcase_segments_item_to_transfer_type(value: ShowcaseSegmentsItem) -> typing.Any: + violations: list[Violation] = [] + if isinstance(value, str): + if len(value) < 2: + violations.append( + Violation(path="", reason=f"must have length >= 2, got {len(value)}") + ) + if not isinstance(value, bool) and isinstance(value, int): + if value < 0: + violations.append(Violation(path="", reason=f"must be >= 0, got {value}")) + if violations: + raise ValidationError(violations) + return value + + +def _showcase_id_or_name_from_transfer_type( + value: typing.Any, path: str, violations: list[Violation] +) -> str | int | None: + if isinstance(value, str): + if len(value) < 3: + violations.append( + Violation(path=path, reason=f"must have length >= 3, got {len(value)}") + ) + return value + if ( + not isinstance(value, bool) + and isinstance(value, (int, float)) + and abs(value) <= 9007199254740991 + and float(value).is_integer() + ): + number = int(value) + if number < 1: + violations.append( + Violation(path=path, reason=f"must be >= 1, got {number}") + ) + return number + violations.append(Violation(path=path, reason="expected one of: string, integer")) + return None + + +def _showcase_mode_from_transfer_type( + value: typing.Any, path: str, violations: list[Violation] +) -> typing.Literal["auto", "manual"] | int | None: + if isinstance(value, str): + narrowed = typing.cast('typing.Literal["auto", "manual"]', value) + if typing.cast("object", narrowed) not in ( + "auto", + "manual", + ): + violations.append( + Violation( + path=path, + reason=f'must be one of ["auto", "manual"], got {_quote(narrowed)}', + ) + ) + return narrowed + if ( + not isinstance(value, bool) + and isinstance(value, (int, float)) + and abs(value) <= 9007199254740991 + and float(value).is_integer() + ): + number = int(value) + if number < 0: + violations.append( + Violation(path=path, reason=f"must be >= 0, got {number}") + ) + return number + violations.append(Violation(path=path, reason="expected one of: string, integer")) + return None + + +def _showcase_payload_from_transfer_type( + value: typing.Any, path: str, violations: list[Violation] +) -> dict[str, typing.Any] | str | None: + if isinstance(value, dict): + return typing.cast("dict[str, typing.Any]", value) + if isinstance(value, str): + return value + violations.append(Violation(path=path, reason="expected one of: object, string")) + return None + + +def _showcase_detail_from_transfer_type( + value: typing.Any, path: str, violations: list[Violation] +) -> ShowcaseDetailObject | str | None: + if isinstance(value, dict): + try: + return _ShowcaseDetailObjectTransferTypeConverter().from_transfer_type( + value, ShowcaseDetailObject + ) + except ValidationError as error: + _collect(violations, path, error) + return None + if isinstance(value, str): + return value + violations.append( + Violation(path=path, reason="expected one of: ShowcaseDetailObject, string") + ) + return None - kind: str | None = pydantic.Field(default=None) - _OPTIONAL_NON_NULLABLE_FIELDS: typing.ClassVar[frozenset[str]] = frozenset({"kind"}) +def _showcase_detail_to_transfer_type(value: ShowcaseDetailObject | str) -> typing.Any: + if isinstance(value, ShowcaseDetailObject): + return _ShowcaseDetailObjectTransferTypeConverter().to_transfer_type(value) + return value - @pydantic.model_validator(mode="wrap") - @classmethod - def _reject_null( - cls, - data: object, - handler: typing.Callable[[object], typing.Any], - ) -> typing.Any: - return _reject_explicit_null(cls, data, handler) - @pydantic.model_serializer(mode="wrap") - def _serialize( - self, - handler: typing.Callable[[pydantic.BaseModel], typing.Any], - ) -> dict[str, object]: - return _emit_set_fields(self, handler) +def _showcase_shape_or_name_from_transfer_type( + value: typing.Any, path: str, violations: list[Violation] +) -> Circle | Square | str | None: + if isinstance(value, dict): + tagged = typing.cast("dict[str, typing.Any]", value) + tag = tagged.get("kind") + if tag == "circle": + try: + return _CircleTransferTypeConverter().from_transfer_type(value, Circle) + except ValidationError as error: + _collect(violations, path, error) + return None + if tag == "square": + try: + return _SquareTransferTypeConverter().from_transfer_type(value, Square) + except ValidationError as error: + _collect(violations, path, error) + return None + violations.append( + Violation( + path=path, + reason=f'unknown discriminator kind {tag}: expected one of ["circle", "square"]', + ) + ) + return None + if isinstance(value, str): + if len(value) > 32: + violations.append( + Violation(path=path, reason=f"must have length <= 32, got {len(value)}") + ) + return value + violations.append( + Violation(path=path, reason="expected one of: Circle, Square, string") + ) + return None + + +def _showcase_shape_or_name_to_transfer_type( + value: Circle | Square | str, +) -> typing.Any: + if isinstance(value, Circle): + return _CircleTransferTypeConverter().to_transfer_type(value) + if isinstance(value, Square): + return _SquareTransferTypeConverter().to_transfer_type(value) + return value + + +def _showcase_measurements_from_transfer_type( + value: typing.Any, path: str, violations: list[Violation] +) -> list[float] | str | None: + if isinstance(value, list): + items = typing.cast("list[float]", value) + if len(items) < 1: + violations.append( + Violation( + path=path, reason=f"must have at least 1 items, got {len(items)}" + ) + ) + _check_unique_items(items, path, violations) + return items + if isinstance(value, str): + if _PATTERN_F242E3A159C2422C.search(value) is None: + violations.append( + Violation( + path=path, + reason=f"must match pattern {_PATTERN_F242E3A159C2422C.pattern}, got {_quote(value)}", + ) + ) + return value + violations.append( + Violation(path=path, reason="expected one of: list[float], string") + ) + return None ChoicesValue: typing.TypeAlias = Circle | Square @@ -1658,38 +4348,4 @@ def _serialize( Shape: typing.TypeAlias = Circle | Square -ShowcaseSegmentsItem: typing.TypeAlias = ( - typing.Annotated[str, pydantic.Field(min_length=2)] - | typing.Annotated[SpecInt, pydantic.Field(ge=0)] -) - - -_ = Choices.model_rebuild() -_ = Showcase.model_rebuild() -_ATTRIBUTES_MEMBER: pydantic.TypeAdapter[typing.Any] = pydantic.TypeAdapter( - str, config=pydantic.ConfigDict(strict=True) -) -_CHOICES_MEMBER: pydantic.TypeAdapter[typing.Any] = pydantic.TypeAdapter( - ChoicesValue, config=pydantic.ConfigDict(strict=True) -) -_LABELS_MEMBER: pydantic.TypeAdapter[typing.Any] = pydantic.TypeAdapter( - str, config=pydantic.ConfigDict(strict=True) -) -_NICKNAMES_MEMBER: pydantic.TypeAdapter[typing.Any] = pydantic.TypeAdapter( - typing.Annotated[str | None, pydantic.Field(min_length=2)], - config=pydantic.ConfigDict(strict=True), -) -_QUOTAS_MEMBER: pydantic.TypeAdapter[typing.Any] = pydantic.TypeAdapter( - typing.Annotated[SpecInt, pydantic.Field(ge=0, le=100, multiple_of=5)], - config=pydantic.ConfigDict(strict=True), -) -_SHOWCASE_LEDGER_MEMBER: pydantic.TypeAdapter[typing.Any] = pydantic.TypeAdapter( - ShowcaseLedgerValue -) -_TOKENS_MEMBER: pydantic.TypeAdapter[typing.Any] = pydantic.TypeAdapter( - typing.Annotated[ - typing.Annotated[str, pydantic.Field(min_length=2, max_length=8)], - pydantic.AfterValidator(_check_pattern("^[a-z]+\\Z")), - ], - config=pydantic.ConfigDict(strict=True), -) +ShowcaseSegmentsItem: typing.TypeAlias = str | int diff --git a/advanced/samples/python/json_schema/api/temporal/_definitions.py b/advanced/samples/python/json_schema/api/temporal/_definitions.py index 00b1bd60..f2b94492 100644 --- a/advanced/samples/python/json_schema/api/temporal/_definitions.py +++ b/advanced/samples/python/json_schema/api/temporal/_definitions.py @@ -4,143 +4,180 @@ import base64 import collections.abc +import dataclasses import datetime -import math +import json import re import typing - -import pydantic -import pydantic.functional_validators -import pydantic_core +import temporalio.converter __all__ = [ - "SpecInt", - "DateTimeField", - "DateField", - "TimeField", - "DurationField", - "Base64Field", - "Base64UrlField", - "_check_multiple_of", - "_check_pattern", - "_check_format", - "_check_unique_items", + "ValidationError", + "Violation", "_check_contains", - "_reject_explicit_null", - "_emit_set_fields", + "_check_unique_items", + "_collect", + "_format_base64", + "_format_base64url", + "_format_date", + "_format_date_time", + "_format_duration", + "_format_time", + "_parse_base64", + "_parse_base64url", + "_parse_date", + "_parse_date_time", + "_parse_duration", + "_parse_spec_integer", + "_parse_time", + "_quote", + "_transfer_type_convertible", ] -_INTEGER_CAP = (1 << 53) - 1 +@dataclasses.dataclass(frozen=True, slots=True) +class Violation: + """A single constraint failure, located by JSON path.""" + path: str + reason: str -def _parse_spec_integer(value: object) -> int: - if isinstance(value, bool): - raise ValueError("expected integer, got boolean") - if isinstance(value, int): - out = value - elif isinstance(value, float): - if not value.is_integer(): - raise ValueError("number has a fractional part; not an integer") - out = int(value) - else: - raise ValueError(f"expected integer, got {type(value).__name__}") - if abs(out) > _INTEGER_CAP: - raise ValueError("integer exceeds +/-(2**53-1) cap") - return out +class ValidationError(Exception): + """Every constraint failure found in one (de)serialization pass.""" + + violations: list[Violation] + + def __init__(self, violations: list[Violation]) -> None: + self.violations = violations + detail = "; ".join(f"{item.path}: {item.reason}" for item in violations) + super().__init__(f"{len(violations)} validation error(s): {detail}") -SpecInt: typing.TypeAlias = typing.Annotated[ - int, pydantic.functional_validators.BeforeValidator(_parse_spec_integer) -] +def _quote(value: object) -> str: + """Renders a value in the JSON form every target quotes offending values in.""" -def _check_multiple_of( - divisor: float, -) -> typing.Callable[[float], float]: - """Builds an AfterValidator asserting `math.fmod`-exact divisibility for number fields.""" + try: + return json.dumps(value, ensure_ascii=False) + except (TypeError, ValueError): + return repr(value) - def validate(value: float) -> float: - if math.fmod(value, divisor) != 0: - raise ValueError(f"must be a multiple of {divisor}, got {value}") - return value - return validate +def _collect(violations: list[Violation], path: str, error: ValidationError) -> None: + """Re-paths a nested model's violations under `path` and appends them.""" + for inner in error.violations: + # A nested violation about the value *itself* carries no path of its own + # (a union branch's own constraint, an element-level check), so the + # prefix is the whole path -- never a dangling separator (P11). + nested = f"{path}.{inner.path}" if inner.path else path + violations.append(Violation(path=nested, reason=inner.reason)) -def _check_pattern( - pattern: str, -) -> typing.Callable[[str], str]: - """Builds an AfterValidator asserting an unanchored, ASCII-class regex match for string fields.""" - compiled = re.compile(pattern, re.ASCII) +_ModelT = typing.TypeVar("_ModelT") - def validate(value: str) -> str: - if compiled.search(value) is None: - raise ValueError(f"must match pattern {pattern}, got {value!r}") - return value - return validate +def _transfer_type_convertible( + converter: type[temporalio.converter.TransferTypeConverter[typing.Any, typing.Any]], +) -> collections.abc.Callable[[type[_ModelT]], type[_ModelT]]: + """Registers a transfer type converter on a model class. + Wraps `temporalio.converter.transfer_type_convertible` to erase the + converter's value-type parameter. Binding it directly on the decorated class + is circular for a static type checker -- the class's type depends on the + decorator, whose value type depends on the class -- which degrades the model + to `Unknown`. Erasing it here keeps the decorator idiomatic at each model and + resolves the cycle. + """ -def _check_format( - format_name: str, - pattern: str, - max_code_points: int | None = None, -) -> typing.Callable[[str], str]: - """Builds an AfterValidator asserting a value matches a pinned `format` regex (+ optional length guard).""" + return temporalio.converter.transfer_type_convertible(converter) - compiled = re.compile(pattern, re.ASCII) - def validate(value: str) -> str: - if ( - max_code_points is not None and len(value) > max_code_points - ) or compiled.search(value) is None: - raise ValueError(f"must be a valid {format_name}, got {value!r}") - return value +_INTEGER_CAP = (1 << 53) - 1 - return validate + +def _parse_spec_integer( + value: object, path: str, violations: list[Violation] +) -> int | None: + """Parses a JSON number as a spec integer (`1.0` accepted, `1.5` rejected).""" + + # `bool` is a subclass of `int`, so it must be excluded before the int check. + if isinstance(value, bool) or not isinstance(value, (int, float)): + violations.append(Violation(path=path, reason="expected integer")) + return None + if isinstance(value, float): + if not value.is_integer(): + violations.append(Violation(path=path, reason="expected integer")) + return None + out = int(value) + else: + out = value + if abs(out) > _INTEGER_CAP: + violations.append(Violation(path=path, reason="expected integer")) + return None + return out def _check_unique_items( - value: list[typing.Any], -) -> list[typing.Any]: - """An AfterValidator asserting an array's elements are pairwise distinct.""" + value: list[typing.Any], path: str, violations: list[Violation] +) -> None: + """Asserts an array's elements are pairwise distinct.""" - seen: dict[object, int] = {} + seen: list[typing.Any] = [] for index, element in enumerate(value): - if element in seen: - raise ValueError( - f"duplicate items: element at index {index} equals index {seen[element]}" - ) - seen[element] = index - return value + for earlier, previous in enumerate(seen): + if previous == element: + violations.append( + Violation( + path=path, + reason=( + f"duplicate items: element at index {index} " + f"equals index {earlier}" + ), + ) + ) + break + seen.append(element) def _check_contains( + value: list[typing.Any], matches: typing.Callable[[typing.Any], bool], min_contains: int, - max_contains: int | None = None, - bounded_min: bool = False, -) -> typing.Callable[[list[typing.Any]], list[typing.Any]]: - """Builds an AfterValidator asserting how many elements match the `contains` schema.""" - - def validate(value: list[typing.Any]) -> list[typing.Any]: - match_count = sum(1 for element in value if matches(element)) - if match_count < min_contains: - if bounded_min: - raise ValueError( - f"too few matching items: at least {min_contains}, got {match_count}" + max_contains: int | None, + bounded_min: bool, + path: str, + violations: list[Violation], +) -> None: + """Asserts how many of an array's elements match the `contains` schema.""" + + match_count = sum(1 for element in value if matches(element)) + if match_count < min_contains: + if bounded_min: + violations.append( + Violation( + path=path, + reason=( + f"too few matching items: at least {min_contains}, " + f"got {match_count}" + ), ) - raise ValueError("no element matches the required schema") - if max_contains is not None and match_count > max_contains: - raise ValueError( - f"too many matching items: at most {max_contains}, got {match_count}" ) - return value - - return validate + else: + violations.append( + Violation(path=path, reason="no element matches the required schema") + ) + if max_contains is not None and match_count > max_contains: + violations.append( + Violation( + path=path, + reason=( + f"too many matching items: at most {max_contains}, " + f"got {match_count}" + ), + ) + ) _TEMPORAL_DATE_TIME_RE = re.compile( @@ -177,43 +214,59 @@ def _valid_temporal_calendar(value: str) -> bool: return maximum > 0 and 1 <= day <= maximum -def _parse_date_time(value: object) -> object: - if not isinstance(value, str): - return value +def _parse_date_time( + value: str, path: str, violations: list[Violation] +) -> datetime.datetime | None: if _TEMPORAL_DATE_TIME_RE.match(value) is None or not _valid_temporal_calendar( value ): - raise ValueError(f"must be a valid date-time, got {value!r}") + violations.append( + Violation( + path=path, reason=f"must be a valid date-time, got {_quote(value)}" + ) + ) + return None normalized = value.upper() if normalized.endswith("Z"): normalized = normalized[:-1] + "+00:00" return datetime.datetime.fromisoformat(normalized) -def _parse_date(value: object) -> object: - if not isinstance(value, str): - return value +def _parse_date( + value: str, path: str, violations: list[Violation] +) -> datetime.date | None: if _TEMPORAL_DATE_RE.match(value) is None or not _valid_temporal_calendar(value): - raise ValueError(f"must be a valid date, got {value!r}") + violations.append( + Violation(path=path, reason=f"must be a valid date, got {_quote(value)}") + ) + return None return datetime.date.fromisoformat(value) -def _parse_time(value: object) -> object: - if not isinstance(value, str): - return value +def _parse_time( + value: str, path: str, violations: list[Violation] +) -> datetime.time | None: if _TEMPORAL_TIME_RE.match(value) is None: - raise ValueError(f"must be a valid time, got {value!r}") + violations.append( + Violation(path=path, reason=f"must be a valid time, got {_quote(value)}") + ) + return None normalized = value.upper() if normalized.endswith("Z"): normalized = normalized[:-1] + "+00:00" return datetime.time.fromisoformat(normalized) -def _parse_duration(value: object) -> object: - if not isinstance(value, str): - return value +def _parse_duration( + value: str, path: str, violations: list[Violation] +) -> datetime.timedelta | None: if _TEMPORAL_DURATION_RE.match(value) is None: - raise ValueError(f"must be a valid duration, got {value!r}") + violations.append( + Violation( + path=path, reason=f"must be a valid duration, got {_quote(value)}" + ) + ) + return None total = 0 number = "" for char in value[2:]: @@ -223,7 +276,12 @@ def _parse_duration(value: object) -> object: total += int(number) * {"H": 3600, "M": 60, "S": 1}[char] number = "" if total > _TEMPORAL_MAX_DURATION_SECONDS: - raise ValueError(f"must be a valid duration, got {value!r}") + violations.append( + Violation( + path=path, reason=f"must be a valid duration, got {_quote(value)}" + ) + ) + return None return datetime.timedelta(seconds=total) @@ -280,39 +338,18 @@ def _format_duration(value: datetime.timedelta) -> str: return out -DateTimeField: typing.TypeAlias = typing.Annotated[ - datetime.datetime, - pydantic.BeforeValidator(_parse_date_time), - pydantic.PlainSerializer(_format_date_time, return_type=str), -] -DateField: typing.TypeAlias = typing.Annotated[ - datetime.date, - pydantic.BeforeValidator(_parse_date), - pydantic.PlainSerializer(_format_date, return_type=str), -] -TimeField: typing.TypeAlias = typing.Annotated[ - datetime.time, - pydantic.BeforeValidator(_parse_time), - pydantic.PlainSerializer(_format_time, return_type=str), -] -DurationField: typing.TypeAlias = typing.Annotated[ - datetime.timedelta, - pydantic.BeforeValidator(_parse_duration), - pydantic.PlainSerializer(_format_duration, return_type=str), -] - - _BASE64_RE = re.compile( "^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?\\Z", re.ASCII ) _BASE64URL_RE = re.compile("^(?:[A-Za-z0-9_-]{4})*(?:[A-Za-z0-9_-]{2,3})?\\Z", re.ASCII) -def _parse_base64(value: typing.Any) -> bytes: - if isinstance(value, bytes): - return value - if not isinstance(value, str) or _BASE64_RE.match(value) is None: - raise ValueError(f"must be base64-encoded, got {value!r}") +def _parse_base64(value: str, path: str, violations: list[Violation]) -> bytes | None: + if _BASE64_RE.match(value) is None: + violations.append( + Violation(path=path, reason=f"must be base64-encoded, got {_quote(value)}") + ) + return None return base64.b64decode(value, validate=True) @@ -320,91 +357,18 @@ def _format_base64(value: bytes) -> str: return base64.b64encode(value).decode("ascii") -def _parse_base64url(value: typing.Any) -> bytes: - if isinstance(value, bytes): - return value - if not isinstance(value, str) or _BASE64URL_RE.match(value) is None: - raise ValueError(f"must be base64url-encoded, got {value!r}") +def _parse_base64url( + value: str, path: str, violations: list[Violation] +) -> bytes | None: + if _BASE64URL_RE.match(value) is None: + violations.append( + Violation( + path=path, reason=f"must be base64url-encoded, got {_quote(value)}" + ) + ) + return None return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4)) def _format_base64url(value: bytes) -> str: return base64.urlsafe_b64encode(value).rstrip(b"=").decode("ascii") - - -Base64Field: typing.TypeAlias = typing.Annotated[ - bytes, - pydantic.BeforeValidator(_parse_base64), - pydantic.PlainSerializer(_format_base64, return_type=str), -] -Base64UrlField: typing.TypeAlias = typing.Annotated[ - bytes, - pydantic.BeforeValidator(_parse_base64url), - pydantic.PlainSerializer(_format_base64url, return_type=str), -] - - -def _reject_explicit_null( - cls: type[pydantic.BaseModel], - data: object, - handler: typing.Callable[[object], typing.Any], -) -> typing.Any: - null_fields = typing.cast( - frozenset[str], getattr(cls, "_OPTIONAL_NON_NULLABLE_FIELDS") - ) - raw_data = data - pre_errors: list[pydantic_core.InitErrorDetails] = [] - if isinstance(data, dict): - values = typing.cast(dict[str, object], data) - pre_errors = [ - pydantic_core.InitErrorDetails( - type=pydantic_core.PydanticCustomError( - "null_for_nonnullable", "explicit null not allowed" - ), - loc=(field,), - input=None, - ) - for field in null_fields - if field in values and values[field] is None - ] - try: - instance = handler(raw_data) - except pydantic.ValidationError as error: - field_errors: list[pydantic_core.InitErrorDetails] = [] - for error_detail in typing.cast(list[dict[str, object]], error.errors()): - loc: tuple[str | int, ...] = tuple( - typing.cast(collections.abc.Iterable[str | int], error_detail["loc"]) - ) - field_errors.append( - pydantic_core.InitErrorDetails( - type=pydantic_core.PydanticCustomError( - typing.cast(typing.Any, error_detail["type"]), - typing.cast(typing.Any, error_detail["msg"]), - ), - loc=loc, - input=error_detail.get("input"), - ) - ) - raise pydantic.ValidationError.from_exception_data( - title=cls.__name__, line_errors=pre_errors + field_errors - ) from None - if pre_errors: - raise pydantic.ValidationError.from_exception_data( - title=cls.__name__, line_errors=pre_errors - ) - return instance - - -def _emit_set_fields( - model: pydantic.BaseModel, - handler: typing.Callable[[pydantic.BaseModel], typing.Any], -) -> dict[str, object]: - dumped = typing.cast(dict[str, object], handler(model)) - alias_of = { - name: (field.alias or name) for name, field in type(model).model_fields.items() - } - keep = {alias_of.get(name, name) for name in model.model_fields_set} - out = {key: value for key, value in dumped.items() if key in keep} - if model.model_extra: - out.update(typing.cast(dict[str, object], model.model_extra)) - return out diff --git a/advanced/samples/python/json_schema/api/temporal/models.py b/advanced/samples/python/json_schema/api/temporal/models.py index f844ec7f..8de3e071 100644 --- a/advanced/samples/python/json_schema/api/temporal/models.py +++ b/advanced/samples/python/json_schema/api/temporal/models.py @@ -2,20 +2,254 @@ from __future__ import annotations +import dataclasses import typing -import pydantic +import typing_extensions +import datetime +import temporalio.converter from ._definitions import ( - DateField, - DateTimeField, - DurationField, - TimeField, - _emit_set_fields, - _reject_explicit_null, + ValidationError, + Violation, + _format_date, + _format_date_time, + _format_duration, + _format_time, + _parse_date, + _parse_date_time, + _parse_duration, + _parse_time, + _transfer_type_convertible, ) -class Temporal(pydantic.BaseModel): +class _TemporalTransferTypeConverter( + temporalio.converter.TransferTypeConverter["Temporal", typing.Any] +): + @typing_extensions.override + def from_transfer_type( + self, value: typing.Any, type_hint: type["Temporal"] + ) -> "Temporal": + violations: list[Violation] = [] + if not isinstance(value, dict): + raise ValidationError([Violation(path="", reason="expected object")]) + raw = typing.cast("dict[str, typing.Any]", value) + + created_at: datetime.datetime = typing.cast("typing.Any", None) + if "createdAt" not in raw or raw["createdAt"] is None: + violations.append(Violation(path="createdAt", reason="required")) + else: + created_at_raw = raw["createdAt"] + if not isinstance(created_at_raw, str): + violations.append(Violation(path="createdAt", reason="expected string")) + else: + created_at_parsed = _parse_date_time( + created_at_raw, "createdAt", violations + ) + if created_at_parsed is not None: + created_at = created_at_parsed + + birthday: datetime.date = typing.cast("typing.Any", None) + if "birthday" not in raw or raw["birthday"] is None: + violations.append(Violation(path="birthday", reason="required")) + else: + birthday_raw = raw["birthday"] + if not isinstance(birthday_raw, str): + violations.append(Violation(path="birthday", reason="expected string")) + else: + birthday_parsed = _parse_date(birthday_raw, "birthday", violations) + if birthday_parsed is not None: + birthday = birthday_parsed + + alarm: datetime.time = typing.cast("typing.Any", None) + if "alarm" not in raw or raw["alarm"] is None: + violations.append(Violation(path="alarm", reason="required")) + else: + alarm_raw = raw["alarm"] + if not isinstance(alarm_raw, str): + violations.append(Violation(path="alarm", reason="expected string")) + else: + alarm_parsed = _parse_time(alarm_raw, "alarm", violations) + if alarm_parsed is not None: + alarm = alarm_parsed + + timeout: datetime.timedelta = typing.cast("typing.Any", None) + if "timeout" not in raw or raw["timeout"] is None: + violations.append(Violation(path="timeout", reason="required")) + else: + timeout_raw = raw["timeout"] + if not isinstance(timeout_raw, str): + violations.append(Violation(path="timeout", reason="expected string")) + else: + timeout_parsed = _parse_duration(timeout_raw, "timeout", violations) + if timeout_parsed is not None: + timeout = timeout_parsed + + updated_at: datetime.datetime | None = None + if "updatedAt" in raw: + updated_at_raw = raw["updatedAt"] + if updated_at_raw is None: + violations.append( + Violation(path="updatedAt", reason="explicit null not allowed") + ) + else: + if not isinstance(updated_at_raw, str): + violations.append( + Violation(path="updatedAt", reason="expected string") + ) + else: + updated_at_parsed = _parse_date_time( + updated_at_raw, "updatedAt", violations + ) + if updated_at_parsed is not None: + updated_at = updated_at_parsed + + expires_on: datetime.date | None = None + if "expiresOn" in raw: + expires_on_raw = raw["expiresOn"] + if expires_on_raw is None: + violations.append( + Violation(path="expiresOn", reason="explicit null not allowed") + ) + else: + if not isinstance(expires_on_raw, str): + violations.append( + Violation(path="expiresOn", reason="expected string") + ) + else: + expires_on_parsed = _parse_date( + expires_on_raw, "expiresOn", violations + ) + if expires_on_parsed is not None: + expires_on = expires_on_parsed + + reminder: datetime.time | None = None + if "reminder" in raw: + reminder_raw = raw["reminder"] + if reminder_raw is None: + violations.append( + Violation(path="reminder", reason="explicit null not allowed") + ) + else: + if not isinstance(reminder_raw, str): + violations.append( + Violation(path="reminder", reason="expected string") + ) + else: + reminder_parsed = _parse_time(reminder_raw, "reminder", violations) + if reminder_parsed is not None: + reminder = reminder_parsed + + retry_delay: datetime.timedelta | None = None + if "retryDelay" in raw: + retry_delay_raw = raw["retryDelay"] + if retry_delay_raw is None: + violations.append( + Violation(path="retryDelay", reason="explicit null not allowed") + ) + else: + if not isinstance(retry_delay_raw, str): + violations.append( + Violation(path="retryDelay", reason="expected string") + ) + else: + retry_delay_parsed = _parse_duration( + retry_delay_raw, "retryDelay", violations + ) + if retry_delay_parsed is not None: + retry_delay = retry_delay_parsed + + deleted_at: datetime.datetime | None = None + if "deletedAt" in raw: + deleted_at_raw = raw["deletedAt"] + if deleted_at_raw is None: + deleted_at = None + else: + if not isinstance(deleted_at_raw, str): + violations.append( + Violation(path="deletedAt", reason="expected string") + ) + else: + deleted_at_parsed = _parse_date_time( + deleted_at_raw, "deletedAt", violations + ) + if deleted_at_parsed is not None: + deleted_at = deleted_at_parsed + + archived_on: datetime.date | None = None + if "archivedOn" in raw: + archived_on_raw = raw["archivedOn"] + if archived_on_raw is None: + archived_on = None + else: + if not isinstance(archived_on_raw, str): + violations.append( + Violation(path="archivedOn", reason="expected string") + ) + else: + archived_on_parsed = _parse_date( + archived_on_raw, "archivedOn", violations + ) + if archived_on_parsed is not None: + archived_on = archived_on_parsed + + for key in raw: + if ( + key != "createdAt" + and key != "birthday" + and key != "alarm" + and key != "timeout" + and key != "updatedAt" + and key != "expiresOn" + and key != "reminder" + and key != "retryDelay" + and key != "deletedAt" + and key != "archivedOn" + ): + violations.append(Violation(path=key, reason="unknown field")) + if violations: + raise ValidationError(violations) + return Temporal( + created_at=created_at, + birthday=birthday, + alarm=alarm, + timeout=timeout, + updated_at=updated_at, + expires_on=expires_on, + reminder=reminder, + retry_delay=retry_delay, + deleted_at=deleted_at, + archived_on=archived_on, + ) + + @typing_extensions.override + def to_transfer_type(self, value: "Temporal") -> typing.Any: + violations: list[Violation] = [] + out: dict[str, typing.Any] = {} + out["createdAt"] = _format_date_time(value.created_at) + out["birthday"] = _format_date(value.birthday) + out["alarm"] = _format_time(value.alarm) + out["timeout"] = _format_duration(value.timeout) + if value.updated_at is not None: + out["updatedAt"] = _format_date_time(value.updated_at) + if value.expires_on is not None: + out["expiresOn"] = _format_date(value.expires_on) + if value.reminder is not None: + out["reminder"] = _format_time(value.reminder) + if value.retry_delay is not None: + out["retryDelay"] = _format_duration(value.retry_delay) + if value.deleted_at is not None: + out["deletedAt"] = _format_date_time(value.deleted_at) + if value.archived_on is not None: + out["archivedOn"] = _format_date(value.archived_on) + if violations: + raise ValidationError(violations) + return out + + +@_transfer_type_convertible(_TemporalTransferTypeConverter) +@dataclasses.dataclass(slots=True, kw_only=True) +class Temporal: """Temporal Root object materializing the four RFC 3339 temporal formats as native typed fields: date-time (offset & sub-second precision preserved), date, time (offset preserved @@ -23,70 +257,38 @@ class Temporal(pydantic.BaseModel): and nullable members of each. """ - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - strict=True, populate_by_name=True, extra="forbid" - ) - - created_at: DateTimeField = pydantic.Field(alias="createdAt") + created_at: datetime.datetime """Required event timestamp; materialized date-time (offset required, sub-second precision & offset preserved on round-trip). """ - birthday: DateField = pydantic.Field() + birthday: datetime.date """Required calendar date; materialized date (YYYY-MM-DD, lossless).""" - alarm: TimeField = pydantic.Field() + alarm: datetime.time """Required wall-clock time; materialized time (offset preserved when present, otherwise offset-less). """ - timeout: DurationField = pydantic.Field() + timeout: datetime.timedelta """Required time-only duration; materialized duration, canonicalized to PT…H…M…S (e.g. PT90M → PT1H30M). """ - updated_at: DateTimeField | None = pydantic.Field(default=None, alias="updatedAt") + updated_at: datetime.datetime | None = None """Optional date-time.""" - expires_on: DateField | None = pydantic.Field(default=None, alias="expiresOn") + expires_on: datetime.date | None = None """Optional date.""" - reminder: TimeField | None = pydantic.Field(default=None) + reminder: datetime.time | None = None """Optional time.""" - retry_delay: DurationField | None = pydantic.Field(default=None, alias="retryDelay") + retry_delay: datetime.timedelta | None = None """Optional duration.""" - deleted_at: DateTimeField | None = pydantic.Field(default=None, alias="deletedAt") + deleted_at: datetime.datetime | None = None """Optional and nullable date-time (may be absent or explicitly null).""" - archived_on: DateField | None = pydantic.Field(default=None, alias="archivedOn") + archived_on: datetime.date | None = None """Optional and nullable date.""" - - _OPTIONAL_NON_NULLABLE_FIELDS: typing.ClassVar[frozenset[str]] = frozenset( - { - "expiresOn", - "expires_on", - "reminder", - "retryDelay", - "retry_delay", - "updatedAt", - "updated_at", - } - ) - - @pydantic.model_validator(mode="wrap") - @classmethod - def _reject_null( - cls, - data: object, - handler: typing.Callable[[object], typing.Any], - ) -> typing.Any: - return _reject_explicit_null(cls, data, handler) - - @pydantic.model_serializer(mode="wrap") - def _serialize( - self, - handler: typing.Callable[[pydantic.BaseModel], typing.Any], - ) -> dict[str, object]: - return _emit_set_fields(self, handler) diff --git a/advanced/samples/python/pyproject.toml b/advanced/samples/python/pyproject.toml index 2d7eca04..49d48993 100644 --- a/advanced/samples/python/pyproject.toml +++ b/advanced/samples/python/pyproject.toml @@ -4,7 +4,6 @@ version = "0.1.0" requires-python = ">=3.10" dependencies = [ "basedpyright==1.31.4", - "pydantic>=2.12.0", "pytest>=8.4.2", "pytest-asyncio>=1.2.0", "ruff>=0.15.12", diff --git a/advanced/samples/python/uv.lock b/advanced/samples/python/uv.lock index 01bedb23..24a8954e 100644 --- a/advanced/samples/python/uv.lock +++ b/advanced/samples/python/uv.lock @@ -8,7 +8,6 @@ version = "0.1.0" source = { virtual = "." } dependencies = [ { name = "basedpyright" }, - { name = "pydantic" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "ruff" }, @@ -19,7 +18,6 @@ dependencies = [ [package.metadata] requires-dist = [ { name = "basedpyright", specifier = "==1.31.4" }, - { name = "pydantic", specifier = ">=2.12.0" }, { name = "pytest", specifier = ">=8.4.2" }, { name = "pytest-asyncio", specifier = ">=1.2.0" }, { name = "ruff", specifier = ">=0.15.12" }, @@ -27,15 +25,6 @@ requires-dist = [ { name = "typing-extensions", specifier = ">=4.15.0" }, ] -[[package]] -name = "annotated-types" -version = "0.7.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, -] - [[package]] name = "backports-asyncio-runner" version = "1.2.0" @@ -71,7 +60,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ @@ -148,137 +137,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c4/72/02445137af02769918a93807b2b7890047c32bfb9f90371cbc12688819eb/protobuf-6.33.6-py3-none-any.whl", hash = "sha256:77179e006c476e69bf8e8ce866640091ec42e1beb80b213c3900006ecfba6901", size = 170656, upload-time = "2026-03-18T19:04:59.826Z" }, ] -[[package]] -name = "pydantic" -version = "2.13.4" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "annotated-types" }, - { name = "pydantic-core" }, - { name = "typing-extensions" }, - { name = "typing-inspection" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, -] - -[[package]] -name = "pydantic-core" -version = "2.46.4" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e7/08/f1ba952f1c8ae5581c70fa9c6da89f247b83e3dd8c09c035d5d7931fc23d/pydantic_core-2.46.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:a396dcc17e5a0b164dbe026896245a4fa9ff402edca1dff0be3d53a517f74de4", size = 2113146, upload-time = "2026-05-06T13:37:36.537Z" }, - { url = "https://files.pythonhosted.org/packages/56/c6/65f646c7ff09bd257f660434adb45c4dfcbbcebcc030562fecf6f5bf887d/pydantic_core-2.46.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:da4b951fe36dc7c3a1ccb4e3cd1747c3542b8c9ceede8fc86cae054e764485f5", size = 1949769, upload-time = "2026-05-06T13:37:46.365Z" }, - { url = "https://files.pythonhosted.org/packages/64/ba/bfb1d928fd5b49e1258935ff104ae356e9fd89384a55bf9f847e9193ad40/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb63e0198ca18aad131c089b9204c23079c3afa95487e561f4c522d519e55aba", size = 1974958, upload-time = "2026-05-06T13:37:28.611Z" }, - { url = "https://files.pythonhosted.org/packages/4e/74/76223bfb117b64af743c9b6670d1364516f5c0604f96b48f3272f6af6cc6/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f47286a97f0bc9b8859519809077b91b2cefe4ae47fcbf5e466a009c1c5d742b", size = 2042118, upload-time = "2026-05-06T13:36:55.216Z" }, - { url = "https://files.pythonhosted.org/packages/cb/7b/848732968bc8f48f3187542f08358b9d842db564147b256669426ebb1652/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:905a0ed8ea6f2d61c1738835f99b699348d7857379083e5fc497fa0c967a407c", size = 2222876, upload-time = "2026-05-06T13:38:25.455Z" }, - { url = "https://files.pythonhosted.org/packages/b5/2f/e90b63ee2e14bd8d3db8f705a6d75d64e6ee1b7c2c8833747ce706e1e0ce/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea793e075b70290d89d8142074262885d3f7da19634845135751bd6344f73b50", size = 2286703, upload-time = "2026-05-06T13:37:53.304Z" }, - { url = "https://files.pythonhosted.org/packages/ba/1e/acc4d70f88a0a277e4a1fa77ebb985ceabaf900430f875bf9338e11c9420/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:395aebd9183f9d112f569aeb5b2214d1a10a33bec8456447f7fbdfa51d38d4cd", size = 2092042, upload-time = "2026-05-06T13:38:46.981Z" }, - { url = "https://files.pythonhosted.org/packages/a9/da/0a422b57bf8504102bf3c4ccea9c41bab5a5cee6a54650acf8faf67f5a24/pydantic_core-2.46.4-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:b078afbc25f3a1436c7a1d2cd3e322497ee99615ba97c563566fdf46aff1ee01", size = 2117231, upload-time = "2026-05-06T13:39:23.146Z" }, - { url = "https://files.pythonhosted.org/packages/bd/2a/2ac13c3af305843e23c5078c53d135656b3f05a2fd78cb7bbbb12e97b473/pydantic_core-2.46.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f747929cf940cddb5b3668a390056ddd5ba2e5010615ea2dcf4f9c4f3ab8791d", size = 2168388, upload-time = "2026-05-06T13:40:08.06Z" }, - { url = "https://files.pythonhosted.org/packages/72/04/2beacf7e1607e93eefe4aed1b4709f079b905fb77530179d4f7c71745f22/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:daa27d92c36f24388fe3ad306b174781c747627f134452e4f128ea00ce1fe8c4", size = 2184769, upload-time = "2026-05-06T13:38:13.901Z" }, - { url = "https://files.pythonhosted.org/packages/9e/29/d2b9fd9f539133548eaf622c06a4ce176cb46ac59f32d0359c4abc0de047/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:19e51f073cd3df251856a8a4189fbdf1de4012c3ebacfb1884f94f1eb406079f", size = 2319312, upload-time = "2026-05-06T13:39:08.24Z" }, - { url = "https://files.pythonhosted.org/packages/7c/af/0f7a5b85fec6075bea96e3ef9187de38fccced0de92c1e7feda8d5cc7bb9/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c1747f85cee84c26985853c6f3d9bd3e75da5212912443fa111c113b9c246f39", size = 2361817, upload-time = "2026-05-06T13:38:43.2Z" }, - { url = "https://files.pythonhosted.org/packages/25/a4/73363fec545fd3ec025490bdda2743c56d0dd5b6266b1a53bbe9e4265375/pydantic_core-2.46.4-cp310-cp310-win32.whl", hash = "sha256:2f84c03c8607173d16b5a854ec68a2f9079ae03237a54fb506d13af47e1d018d", size = 1987085, upload-time = "2026-05-06T13:39:25.497Z" }, - { url = "https://files.pythonhosted.org/packages/01/aa/62f082da2c91fac1c234bc9ee0066257ce83f0604abd72e4c9d5991f2d84/pydantic_core-2.46.4-cp310-cp310-win_amd64.whl", hash = "sha256:8358a950c8909158e3df31538a7e4edc2d7265a7c54b47f0864d9e5bae9dcebf", size = 2074311, upload-time = "2026-05-06T13:39:59.922Z" }, - { url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" }, - { url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" }, - { url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" }, - { url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051, upload-time = "2026-05-06T13:38:10.447Z" }, - { url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314, upload-time = "2026-05-06T13:40:13.089Z" }, - { url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146, upload-time = "2026-05-06T13:38:59.224Z" }, - { url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685, upload-time = "2026-05-06T13:38:17.762Z" }, - { url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420, upload-time = "2026-05-06T13:37:58.195Z" }, - { url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122, upload-time = "2026-05-06T13:37:01.167Z" }, - { url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573, upload-time = "2026-05-06T13:38:45.04Z" }, - { url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139, upload-time = "2026-05-06T13:37:15.539Z" }, - { url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433, upload-time = "2026-05-06T13:37:30.099Z" }, - { url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513, upload-time = "2026-05-06T13:38:15.669Z" }, - { url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114, upload-time = "2026-05-06T13:40:35.416Z" }, - { url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298, upload-time = "2026-05-06T13:38:29.754Z" }, - { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, - { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, - { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, - { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, - { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, - { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, - { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, - { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, - { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, - { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, - { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, - { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, - { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, - { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, - { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, - { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, - { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, - { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, - { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, - { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, - { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, - { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, - { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, - { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, - { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, - { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, - { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, - { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, - { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, - { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, - { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, - { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, - { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, - { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, - { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, - { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, - { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, - { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, - { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, - { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, - { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, - { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, - { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, - { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, - { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, - { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, - { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, - { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, - { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, - { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, - { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, - { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, - { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, - { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, - { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, - { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, - { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, - { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, - { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, - { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, - { url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589, upload-time = "2026-05-06T13:37:10.817Z" }, - { url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552, upload-time = "2026-05-06T13:36:56.717Z" }, - { url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984, upload-time = "2026-05-06T13:39:06.207Z" }, - { url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417, upload-time = "2026-05-06T13:39:45.476Z" }, - { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, - { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, - { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, - { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, - { url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782, upload-time = "2026-05-06T13:37:04.016Z" }, - { url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146, upload-time = "2026-05-06T13:39:43.092Z" }, - { url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492, upload-time = "2026-05-06T13:36:58.124Z" }, - { url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604, upload-time = "2026-05-06T13:37:49.88Z" }, - { url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828, upload-time = "2026-05-06T13:37:43.053Z" }, - { url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000, upload-time = "2026-05-06T13:37:56.694Z" }, - { url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286, upload-time = "2026-05-06T13:40:05.667Z" }, - { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" }, -] - [[package]] name = "pygments" version = "2.20.0" @@ -459,15 +317,3 @@ sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac8 wheels = [ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, ] - -[[package]] -name = "typing-inspection" -version = "0.4.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, -] diff --git a/samples/python/chat/_definitions.py b/samples/python/chat/_definitions.py index 00b1bd60..f2b94492 100644 --- a/samples/python/chat/_definitions.py +++ b/samples/python/chat/_definitions.py @@ -4,143 +4,180 @@ import base64 import collections.abc +import dataclasses import datetime -import math +import json import re import typing - -import pydantic -import pydantic.functional_validators -import pydantic_core +import temporalio.converter __all__ = [ - "SpecInt", - "DateTimeField", - "DateField", - "TimeField", - "DurationField", - "Base64Field", - "Base64UrlField", - "_check_multiple_of", - "_check_pattern", - "_check_format", - "_check_unique_items", + "ValidationError", + "Violation", "_check_contains", - "_reject_explicit_null", - "_emit_set_fields", + "_check_unique_items", + "_collect", + "_format_base64", + "_format_base64url", + "_format_date", + "_format_date_time", + "_format_duration", + "_format_time", + "_parse_base64", + "_parse_base64url", + "_parse_date", + "_parse_date_time", + "_parse_duration", + "_parse_spec_integer", + "_parse_time", + "_quote", + "_transfer_type_convertible", ] -_INTEGER_CAP = (1 << 53) - 1 +@dataclasses.dataclass(frozen=True, slots=True) +class Violation: + """A single constraint failure, located by JSON path.""" + path: str + reason: str -def _parse_spec_integer(value: object) -> int: - if isinstance(value, bool): - raise ValueError("expected integer, got boolean") - if isinstance(value, int): - out = value - elif isinstance(value, float): - if not value.is_integer(): - raise ValueError("number has a fractional part; not an integer") - out = int(value) - else: - raise ValueError(f"expected integer, got {type(value).__name__}") - if abs(out) > _INTEGER_CAP: - raise ValueError("integer exceeds +/-(2**53-1) cap") - return out +class ValidationError(Exception): + """Every constraint failure found in one (de)serialization pass.""" + + violations: list[Violation] + + def __init__(self, violations: list[Violation]) -> None: + self.violations = violations + detail = "; ".join(f"{item.path}: {item.reason}" for item in violations) + super().__init__(f"{len(violations)} validation error(s): {detail}") -SpecInt: typing.TypeAlias = typing.Annotated[ - int, pydantic.functional_validators.BeforeValidator(_parse_spec_integer) -] +def _quote(value: object) -> str: + """Renders a value in the JSON form every target quotes offending values in.""" -def _check_multiple_of( - divisor: float, -) -> typing.Callable[[float], float]: - """Builds an AfterValidator asserting `math.fmod`-exact divisibility for number fields.""" + try: + return json.dumps(value, ensure_ascii=False) + except (TypeError, ValueError): + return repr(value) - def validate(value: float) -> float: - if math.fmod(value, divisor) != 0: - raise ValueError(f"must be a multiple of {divisor}, got {value}") - return value - return validate +def _collect(violations: list[Violation], path: str, error: ValidationError) -> None: + """Re-paths a nested model's violations under `path` and appends them.""" + for inner in error.violations: + # A nested violation about the value *itself* carries no path of its own + # (a union branch's own constraint, an element-level check), so the + # prefix is the whole path -- never a dangling separator (P11). + nested = f"{path}.{inner.path}" if inner.path else path + violations.append(Violation(path=nested, reason=inner.reason)) -def _check_pattern( - pattern: str, -) -> typing.Callable[[str], str]: - """Builds an AfterValidator asserting an unanchored, ASCII-class regex match for string fields.""" - compiled = re.compile(pattern, re.ASCII) +_ModelT = typing.TypeVar("_ModelT") - def validate(value: str) -> str: - if compiled.search(value) is None: - raise ValueError(f"must match pattern {pattern}, got {value!r}") - return value - return validate +def _transfer_type_convertible( + converter: type[temporalio.converter.TransferTypeConverter[typing.Any, typing.Any]], +) -> collections.abc.Callable[[type[_ModelT]], type[_ModelT]]: + """Registers a transfer type converter on a model class. + Wraps `temporalio.converter.transfer_type_convertible` to erase the + converter's value-type parameter. Binding it directly on the decorated class + is circular for a static type checker -- the class's type depends on the + decorator, whose value type depends on the class -- which degrades the model + to `Unknown`. Erasing it here keeps the decorator idiomatic at each model and + resolves the cycle. + """ -def _check_format( - format_name: str, - pattern: str, - max_code_points: int | None = None, -) -> typing.Callable[[str], str]: - """Builds an AfterValidator asserting a value matches a pinned `format` regex (+ optional length guard).""" + return temporalio.converter.transfer_type_convertible(converter) - compiled = re.compile(pattern, re.ASCII) - def validate(value: str) -> str: - if ( - max_code_points is not None and len(value) > max_code_points - ) or compiled.search(value) is None: - raise ValueError(f"must be a valid {format_name}, got {value!r}") - return value +_INTEGER_CAP = (1 << 53) - 1 - return validate + +def _parse_spec_integer( + value: object, path: str, violations: list[Violation] +) -> int | None: + """Parses a JSON number as a spec integer (`1.0` accepted, `1.5` rejected).""" + + # `bool` is a subclass of `int`, so it must be excluded before the int check. + if isinstance(value, bool) or not isinstance(value, (int, float)): + violations.append(Violation(path=path, reason="expected integer")) + return None + if isinstance(value, float): + if not value.is_integer(): + violations.append(Violation(path=path, reason="expected integer")) + return None + out = int(value) + else: + out = value + if abs(out) > _INTEGER_CAP: + violations.append(Violation(path=path, reason="expected integer")) + return None + return out def _check_unique_items( - value: list[typing.Any], -) -> list[typing.Any]: - """An AfterValidator asserting an array's elements are pairwise distinct.""" + value: list[typing.Any], path: str, violations: list[Violation] +) -> None: + """Asserts an array's elements are pairwise distinct.""" - seen: dict[object, int] = {} + seen: list[typing.Any] = [] for index, element in enumerate(value): - if element in seen: - raise ValueError( - f"duplicate items: element at index {index} equals index {seen[element]}" - ) - seen[element] = index - return value + for earlier, previous in enumerate(seen): + if previous == element: + violations.append( + Violation( + path=path, + reason=( + f"duplicate items: element at index {index} " + f"equals index {earlier}" + ), + ) + ) + break + seen.append(element) def _check_contains( + value: list[typing.Any], matches: typing.Callable[[typing.Any], bool], min_contains: int, - max_contains: int | None = None, - bounded_min: bool = False, -) -> typing.Callable[[list[typing.Any]], list[typing.Any]]: - """Builds an AfterValidator asserting how many elements match the `contains` schema.""" - - def validate(value: list[typing.Any]) -> list[typing.Any]: - match_count = sum(1 for element in value if matches(element)) - if match_count < min_contains: - if bounded_min: - raise ValueError( - f"too few matching items: at least {min_contains}, got {match_count}" + max_contains: int | None, + bounded_min: bool, + path: str, + violations: list[Violation], +) -> None: + """Asserts how many of an array's elements match the `contains` schema.""" + + match_count = sum(1 for element in value if matches(element)) + if match_count < min_contains: + if bounded_min: + violations.append( + Violation( + path=path, + reason=( + f"too few matching items: at least {min_contains}, " + f"got {match_count}" + ), ) - raise ValueError("no element matches the required schema") - if max_contains is not None and match_count > max_contains: - raise ValueError( - f"too many matching items: at most {max_contains}, got {match_count}" ) - return value - - return validate + else: + violations.append( + Violation(path=path, reason="no element matches the required schema") + ) + if max_contains is not None and match_count > max_contains: + violations.append( + Violation( + path=path, + reason=( + f"too many matching items: at most {max_contains}, " + f"got {match_count}" + ), + ) + ) _TEMPORAL_DATE_TIME_RE = re.compile( @@ -177,43 +214,59 @@ def _valid_temporal_calendar(value: str) -> bool: return maximum > 0 and 1 <= day <= maximum -def _parse_date_time(value: object) -> object: - if not isinstance(value, str): - return value +def _parse_date_time( + value: str, path: str, violations: list[Violation] +) -> datetime.datetime | None: if _TEMPORAL_DATE_TIME_RE.match(value) is None or not _valid_temporal_calendar( value ): - raise ValueError(f"must be a valid date-time, got {value!r}") + violations.append( + Violation( + path=path, reason=f"must be a valid date-time, got {_quote(value)}" + ) + ) + return None normalized = value.upper() if normalized.endswith("Z"): normalized = normalized[:-1] + "+00:00" return datetime.datetime.fromisoformat(normalized) -def _parse_date(value: object) -> object: - if not isinstance(value, str): - return value +def _parse_date( + value: str, path: str, violations: list[Violation] +) -> datetime.date | None: if _TEMPORAL_DATE_RE.match(value) is None or not _valid_temporal_calendar(value): - raise ValueError(f"must be a valid date, got {value!r}") + violations.append( + Violation(path=path, reason=f"must be a valid date, got {_quote(value)}") + ) + return None return datetime.date.fromisoformat(value) -def _parse_time(value: object) -> object: - if not isinstance(value, str): - return value +def _parse_time( + value: str, path: str, violations: list[Violation] +) -> datetime.time | None: if _TEMPORAL_TIME_RE.match(value) is None: - raise ValueError(f"must be a valid time, got {value!r}") + violations.append( + Violation(path=path, reason=f"must be a valid time, got {_quote(value)}") + ) + return None normalized = value.upper() if normalized.endswith("Z"): normalized = normalized[:-1] + "+00:00" return datetime.time.fromisoformat(normalized) -def _parse_duration(value: object) -> object: - if not isinstance(value, str): - return value +def _parse_duration( + value: str, path: str, violations: list[Violation] +) -> datetime.timedelta | None: if _TEMPORAL_DURATION_RE.match(value) is None: - raise ValueError(f"must be a valid duration, got {value!r}") + violations.append( + Violation( + path=path, reason=f"must be a valid duration, got {_quote(value)}" + ) + ) + return None total = 0 number = "" for char in value[2:]: @@ -223,7 +276,12 @@ def _parse_duration(value: object) -> object: total += int(number) * {"H": 3600, "M": 60, "S": 1}[char] number = "" if total > _TEMPORAL_MAX_DURATION_SECONDS: - raise ValueError(f"must be a valid duration, got {value!r}") + violations.append( + Violation( + path=path, reason=f"must be a valid duration, got {_quote(value)}" + ) + ) + return None return datetime.timedelta(seconds=total) @@ -280,39 +338,18 @@ def _format_duration(value: datetime.timedelta) -> str: return out -DateTimeField: typing.TypeAlias = typing.Annotated[ - datetime.datetime, - pydantic.BeforeValidator(_parse_date_time), - pydantic.PlainSerializer(_format_date_time, return_type=str), -] -DateField: typing.TypeAlias = typing.Annotated[ - datetime.date, - pydantic.BeforeValidator(_parse_date), - pydantic.PlainSerializer(_format_date, return_type=str), -] -TimeField: typing.TypeAlias = typing.Annotated[ - datetime.time, - pydantic.BeforeValidator(_parse_time), - pydantic.PlainSerializer(_format_time, return_type=str), -] -DurationField: typing.TypeAlias = typing.Annotated[ - datetime.timedelta, - pydantic.BeforeValidator(_parse_duration), - pydantic.PlainSerializer(_format_duration, return_type=str), -] - - _BASE64_RE = re.compile( "^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?\\Z", re.ASCII ) _BASE64URL_RE = re.compile("^(?:[A-Za-z0-9_-]{4})*(?:[A-Za-z0-9_-]{2,3})?\\Z", re.ASCII) -def _parse_base64(value: typing.Any) -> bytes: - if isinstance(value, bytes): - return value - if not isinstance(value, str) or _BASE64_RE.match(value) is None: - raise ValueError(f"must be base64-encoded, got {value!r}") +def _parse_base64(value: str, path: str, violations: list[Violation]) -> bytes | None: + if _BASE64_RE.match(value) is None: + violations.append( + Violation(path=path, reason=f"must be base64-encoded, got {_quote(value)}") + ) + return None return base64.b64decode(value, validate=True) @@ -320,91 +357,18 @@ def _format_base64(value: bytes) -> str: return base64.b64encode(value).decode("ascii") -def _parse_base64url(value: typing.Any) -> bytes: - if isinstance(value, bytes): - return value - if not isinstance(value, str) or _BASE64URL_RE.match(value) is None: - raise ValueError(f"must be base64url-encoded, got {value!r}") +def _parse_base64url( + value: str, path: str, violations: list[Violation] +) -> bytes | None: + if _BASE64URL_RE.match(value) is None: + violations.append( + Violation( + path=path, reason=f"must be base64url-encoded, got {_quote(value)}" + ) + ) + return None return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4)) def _format_base64url(value: bytes) -> str: return base64.urlsafe_b64encode(value).rstrip(b"=").decode("ascii") - - -Base64Field: typing.TypeAlias = typing.Annotated[ - bytes, - pydantic.BeforeValidator(_parse_base64), - pydantic.PlainSerializer(_format_base64, return_type=str), -] -Base64UrlField: typing.TypeAlias = typing.Annotated[ - bytes, - pydantic.BeforeValidator(_parse_base64url), - pydantic.PlainSerializer(_format_base64url, return_type=str), -] - - -def _reject_explicit_null( - cls: type[pydantic.BaseModel], - data: object, - handler: typing.Callable[[object], typing.Any], -) -> typing.Any: - null_fields = typing.cast( - frozenset[str], getattr(cls, "_OPTIONAL_NON_NULLABLE_FIELDS") - ) - raw_data = data - pre_errors: list[pydantic_core.InitErrorDetails] = [] - if isinstance(data, dict): - values = typing.cast(dict[str, object], data) - pre_errors = [ - pydantic_core.InitErrorDetails( - type=pydantic_core.PydanticCustomError( - "null_for_nonnullable", "explicit null not allowed" - ), - loc=(field,), - input=None, - ) - for field in null_fields - if field in values and values[field] is None - ] - try: - instance = handler(raw_data) - except pydantic.ValidationError as error: - field_errors: list[pydantic_core.InitErrorDetails] = [] - for error_detail in typing.cast(list[dict[str, object]], error.errors()): - loc: tuple[str | int, ...] = tuple( - typing.cast(collections.abc.Iterable[str | int], error_detail["loc"]) - ) - field_errors.append( - pydantic_core.InitErrorDetails( - type=pydantic_core.PydanticCustomError( - typing.cast(typing.Any, error_detail["type"]), - typing.cast(typing.Any, error_detail["msg"]), - ), - loc=loc, - input=error_detail.get("input"), - ) - ) - raise pydantic.ValidationError.from_exception_data( - title=cls.__name__, line_errors=pre_errors + field_errors - ) from None - if pre_errors: - raise pydantic.ValidationError.from_exception_data( - title=cls.__name__, line_errors=pre_errors - ) - return instance - - -def _emit_set_fields( - model: pydantic.BaseModel, - handler: typing.Callable[[pydantic.BaseModel], typing.Any], -) -> dict[str, object]: - dumped = typing.cast(dict[str, object], handler(model)) - alias_of = { - name: (field.alias or name) for name, field in type(model).model_fields.items() - } - keep = {alias_of.get(name, name) for name in model.model_fields_set} - out = {key: value for key, value in dumped.items() if key in keep} - if model.model_extra: - out.update(typing.cast(dict[str, object], model.model_extra)) - return out diff --git a/samples/python/chat/models.py b/samples/python/chat/models.py index ba0d0f4b..bd9219f6 100644 --- a/samples/python/chat/models.py +++ b/samples/python/chat/models.py @@ -2,206 +2,483 @@ from __future__ import annotations +import dataclasses import typing -import pydantic -import pydantic_core +import typing_extensions +import temporalio.converter from ._definitions import ( - SpecInt, - _emit_set_fields, - _reject_explicit_null, + ValidationError, + Violation, + _collect, + _parse_spec_integer, + _transfer_type_convertible, ) -class GetRoomInput(pydantic.BaseModel): - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - strict=True, populate_by_name=True, extra="forbid" - ) +DEFAULT_PRIORITY = 0 - room_id: str = pydantic.Field(alias="roomId") - @pydantic.model_serializer(mode="wrap") - def _serialize( - self, - handler: typing.Callable[[pydantic.BaseModel], typing.Any], - ) -> dict[str, object]: - return _emit_set_fields(self, handler) +_ROOM_DECLARED: frozenset[str] = frozenset( + {"roomId", "displayName", "topic", "members", "labels"} +) -class Labels(pydantic.BaseModel): - """Arbitrary string key/value labels.""" +class _GetRoomInputTransferTypeConverter( + temporalio.converter.TransferTypeConverter["GetRoomInput", typing.Any] +): + @typing_extensions.override + def from_transfer_type( + self, value: typing.Any, type_hint: type["GetRoomInput"] + ) -> "GetRoomInput": + violations: list[Violation] = [] + if not isinstance(value, dict): + raise ValidationError([Violation(path="", reason="expected object")]) + raw = typing.cast("dict[str, typing.Any]", value) + + room_id: str = typing.cast("typing.Any", None) + if "roomId" not in raw or raw["roomId"] is None: + violations.append(Violation(path="roomId", reason="required")) + else: + room_id_raw = raw["roomId"] + if not isinstance(room_id_raw, str): + violations.append(Violation(path="roomId", reason="expected string")) + else: + room_id = room_id_raw + + for key in raw: + if key != "roomId": + violations.append(Violation(path=key, reason="unknown field")) + if violations: + raise ValidationError(violations) + return GetRoomInput( + room_id=room_id, + ) + + @typing_extensions.override + def to_transfer_type(self, value: "GetRoomInput") -> typing.Any: + out: dict[str, typing.Any] = {} + out["roomId"] = value.room_id + return out + + +@_transfer_type_convertible(_GetRoomInputTransferTypeConverter) +@dataclasses.dataclass(slots=True, kw_only=True) +class GetRoomInput: + room_id: str + + +class _LabelsTransferTypeConverter( + temporalio.converter.TransferTypeConverter["Labels", typing.Any] +): + @typing_extensions.override + def from_transfer_type( + self, value: typing.Any, type_hint: type["Labels"] + ) -> "Labels": + violations: list[Violation] = [] + if not isinstance(value, dict): + raise ValidationError([Violation(path="", reason="expected object")]) + raw = typing.cast("dict[str, typing.Any]", value) + if len(raw) > 50: + violations.append( + Violation( + path="", reason=f"must have at most 50 properties, got {len(raw)}" + ) + ) + additional_properties: dict[str, str] = {} + for key in raw: + member: str = typing.cast("typing.Any", None) + member_raw = raw[key] + if not isinstance(member_raw, str): + violations.append(Violation(path=key, reason="expected string")) + else: + member = member_raw + additional_properties[key] = member + if violations: + raise ValidationError(violations) + return Labels(additional_properties=additional_properties) + + @typing_extensions.override + def to_transfer_type(self, value: "Labels") -> typing.Any: + violations: list[Violation] = [] + out: dict[str, typing.Any] = {} + for key, entry in value.additional_properties.items(): + out[key] = entry + if len(out) > 50: + violations.append( + Violation( + path="", reason=f"must have at most 50 properties, got {len(out)}" + ) + ) + if violations: + raise ValidationError(violations) + return out - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - strict=True, populate_by_name=True, extra="allow" - ) - @pydantic.model_validator(mode="after") - def _validate_extras(self) -> typing.Any: - extra = typing.cast(dict[str, object], self.model_extra or {}) - errors: list[pydantic_core.InitErrorDetails] = [] - for key, value in list(extra.items()): - try: - extra[key] = _LABELS_MEMBER.validate_python(value) - except pydantic.ValidationError as error: - for detail in error.errors(): - errors.append( - pydantic_core.InitErrorDetails( - type=pydantic_core.PydanticCustomError( - typing.cast(typing.Any, detail["type"]), - typing.cast(typing.Any, detail["msg"]), - ), - loc=(key, *detail["loc"]), - input=detail["input"], - ) +@_transfer_type_convertible(_LabelsTransferTypeConverter) +@dataclasses.dataclass(slots=True, kw_only=True) +class Labels: + """Arbitrary string key/value labels.""" + + additional_properties: dict[str, str] = dataclasses.field(default_factory=dict) + + +class _MessageTransferTypeConverter( + temporalio.converter.TransferTypeConverter["Message", typing.Any] +): + @typing_extensions.override + def from_transfer_type( + self, value: typing.Any, type_hint: type["Message"] + ) -> "Message": + violations: list[Violation] = [] + if not isinstance(value, dict): + raise ValidationError([Violation(path="", reason="expected object")]) + raw = typing.cast("dict[str, typing.Any]", value) + + kind: typing.Literal["text"] = typing.cast("typing.Any", None) + if "kind" not in raw or raw["kind"] is None: + violations.append(Violation(path="kind", reason="required")) + else: + kind_raw = raw["kind"] + if not isinstance(kind_raw, str): + violations.append(Violation(path="kind", reason="expected string")) + elif kind_raw != "text": + violations.append(Violation(path="kind", reason='must equal "text"')) + else: + kind = kind_raw + + body: str = typing.cast("typing.Any", None) + if "body" not in raw or raw["body"] is None: + violations.append(Violation(path="body", reason="required")) + else: + body_raw = raw["body"] + if not isinstance(body_raw, str): + violations.append(Violation(path="body", reason="expected string")) + else: + body = body_raw + + reply_to_id: str | None = None + if "replyToId" in raw: + reply_to_id_raw = raw["replyToId"] + if reply_to_id_raw is None: + reply_to_id = None + else: + if not isinstance(reply_to_id_raw, str): + violations.append( + Violation(path="replyToId", reason="expected string") ) - if len(extra) > 50: - errors.append( - pydantic_core.InitErrorDetails( - type=pydantic_core.PydanticCustomError( - "too_many_properties", - typing.cast( - typing.Any, - f"must have at most 50 properties, got {len(extra)}", - ), - ), - loc=(), - input=len(extra), + else: + reply_to_id = reply_to_id_raw + + priority: int | None = None + if "priority" in raw: + priority_raw = raw["priority"] + if priority_raw is None: + violations.append( + Violation(path="priority", reason="explicit null not allowed") ) - ) - if errors: - raise pydantic.ValidationError.from_exception_data( - title=type(self).__name__, line_errors=errors - ) - return self - - @pydantic.model_serializer(mode="wrap") - def _serialize( - self, - _handler: typing.Callable[[pydantic.BaseModel], typing.Any], - ) -> dict[str, object]: - return { - key: _LABELS_MEMBER.dump_python(value, mode="json", by_alias=True) - for key, value in typing.cast( - dict[str, object], self.model_extra or {} - ).items() - } - - -class Message(pydantic.BaseModel): + else: + priority_parsed = _parse_spec_integer( + priority_raw, "priority", violations + ) + if priority_parsed is not None: + priority = priority_parsed + + for key in raw: + if ( + key != "kind" + and key != "body" + and key != "replyToId" + and key != "priority" + ): + violations.append(Violation(path=key, reason="unknown field")) + if violations: + raise ValidationError(violations) + return Message( + kind=kind, + body=body, + reply_to_id=reply_to_id, + priority=priority, + ) + + @typing_extensions.override + def to_transfer_type(self, value: "Message") -> typing.Any: + violations: list[Violation] = [] + out: dict[str, typing.Any] = {} + if typing.cast("object", value.kind) not in ("text",): + violations.append(Violation(path="kind", reason='must equal "text"')) + out["kind"] = value.kind + out["body"] = value.body + if value.reply_to_id is not None: + out["replyToId"] = value.reply_to_id + if value.priority is not None: + out["priority"] = value.priority + if violations: + raise ValidationError(violations) + return out + + +@_transfer_type_convertible(_MessageTransferTypeConverter) +@dataclasses.dataclass(slots=True, kw_only=True) +class Message: """A chat message.""" - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - strict=True, populate_by_name=True, extra="forbid" - ) - - kind: typing.Literal["text"] = pydantic.Field(default="text") + kind: typing.Literal["text"] = "text" """Discriminator; always "text".""" - body: str = pydantic.Field() + body: str - reply_to_id: str | None = pydantic.Field(default=None, alias="replyToId") + reply_to_id: str | None = None """Id of the message this replies to, if any.""" - priority: SpecInt = pydantic.Field(default=0) + priority: int | None = None """Delivery priority.""" - @pydantic.model_validator(mode="before") - @classmethod - def _inject_const_kind( - cls, - data: object, - ) -> object: - if isinstance(data, dict): - values = typing.cast(dict[str, object], data) - if "kind" not in values: - data = {**values, "kind": "text"} - elif values.get("kind", values.get("kind")) != "text": - raise pydantic_core.PydanticCustomError( - "const", 'kind must equal "text"' - ) - return typing.cast(object, data) - @pydantic.model_serializer(mode="wrap") - def _serialize( - self, - handler: typing.Callable[[pydantic.BaseModel], typing.Any], - ) -> dict[str, object]: - return _emit_set_fields(self, handler) +class _RoomTransferTypeConverter( + temporalio.converter.TransferTypeConverter["Room", typing.Any] +): + @typing_extensions.override + def from_transfer_type(self, value: typing.Any, type_hint: type["Room"]) -> "Room": + violations: list[Violation] = [] + if not isinstance(value, dict): + raise ValidationError([Violation(path="", reason="expected object")]) + raw = typing.cast("dict[str, typing.Any]", value) + + room_id: str = typing.cast("typing.Any", None) + if "roomId" not in raw or raw["roomId"] is None: + violations.append(Violation(path="roomId", reason="required")) + else: + room_id_raw = raw["roomId"] + if not isinstance(room_id_raw, str): + violations.append(Violation(path="roomId", reason="expected string")) + else: + room_id = room_id_raw + + display_name: str = typing.cast("typing.Any", None) + if "displayName" not in raw or raw["displayName"] is None: + violations.append(Violation(path="displayName", reason="required")) + else: + display_name_raw = raw["displayName"] + if not isinstance(display_name_raw, str): + violations.append( + Violation(path="displayName", reason="expected string") + ) + else: + display_name = display_name_raw + + topic: str | None = None + if "topic" not in raw: + violations.append(Violation(path="topic", reason="required")) + else: + topic_raw = raw["topic"] + if topic_raw is None: + topic = None + else: + if not isinstance(topic_raw, str): + violations.append(Violation(path="topic", reason="expected string")) + else: + topic = topic_raw + + members: list[str] | None = None + if "members" in raw: + members_raw = raw["members"] + if members_raw is None: + violations.append( + Violation(path="members", reason="explicit null not allowed") + ) + else: + if not isinstance(members_raw, list): + violations.append( + Violation(path="members", reason="expected array") + ) + else: + members_list: list[str] = [] + for members_index, members_element in enumerate( + typing.cast("list[typing.Any]", members_raw) + ): + members_item_path = f"members[{members_index}]" + members_item: str = typing.cast("typing.Any", None) + if not isinstance(members_element, str): + violations.append( + Violation( + path=members_item_path, reason="expected element" + ) + ) + else: + members_item = members_element + members_list.append(members_item) + members = members_list + + labels: Labels | None = None + if "labels" in raw: + labels_raw = raw["labels"] + if labels_raw is None: + violations.append( + Violation(path="labels", reason="explicit null not allowed") + ) + else: + try: + labels = _LabelsTransferTypeConverter().from_transfer_type( + labels_raw, Labels + ) + except ValidationError as error: + _collect(violations, "labels", error) + + additional_properties: dict[str, typing.Any] = {} + for key in raw: + if key not in _ROOM_DECLARED: + additional_properties[key] = raw[key] + if violations: + raise ValidationError(violations) + return Room( + room_id=room_id, + display_name=display_name, + topic=topic, + members=members, + labels=labels, + additional_properties=additional_properties, + ) + + @typing_extensions.override + def to_transfer_type(self, value: "Room") -> typing.Any: + out: dict[str, typing.Any] = {} + out["roomId"] = value.room_id + out["displayName"] = value.display_name + out["topic"] = value.topic + if value.members is not None: + out["members"] = value.members + if value.labels is not None: + out["labels"] = _LabelsTransferTypeConverter().to_transfer_type( + value.labels + ) + for key, entry in value.additional_properties.items(): + out[key] = entry + return out -class Room(pydantic.BaseModel): +@_transfer_type_convertible(_RoomTransferTypeConverter) +@dataclasses.dataclass(slots=True, kw_only=True) +class Room: """A chat room. Open to forward-compatible extension.""" - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - strict=True, populate_by_name=True, extra="allow" - ) + room_id: str - room_id: str = pydantic.Field(alias="roomId") + display_name: str - display_name: str = pydantic.Field(alias="displayName") - - topic: str | None = pydantic.Field() + topic: str | None """Room topic; may be explicitly cleared to null.""" - members: list[str] | None = pydantic.Field(default=None) + members: list[str] | None = None - labels: Labels | None = pydantic.Field(default=None) + labels: Labels | None = None - _OPTIONAL_NON_NULLABLE_FIELDS: typing.ClassVar[frozenset[str]] = frozenset( - {"labels", "members"} + additional_properties: dict[str, typing.Any] = dataclasses.field( + default_factory=dict ) - @pydantic.model_validator(mode="wrap") - @classmethod - def _reject_null( - cls, - data: object, - handler: typing.Callable[[object], typing.Any], - ) -> typing.Any: - return _reject_explicit_null(cls, data, handler) - - @pydantic.model_serializer(mode="wrap") - def _serialize( - self, - handler: typing.Callable[[pydantic.BaseModel], typing.Any], - ) -> dict[str, object]: - return _emit_set_fields(self, handler) - -class SendMessageInput(pydantic.BaseModel): +class _SendMessageInputTransferTypeConverter( + temporalio.converter.TransferTypeConverter["SendMessageInput", typing.Any] +): + @typing_extensions.override + def from_transfer_type( + self, value: typing.Any, type_hint: type["SendMessageInput"] + ) -> "SendMessageInput": + violations: list[Violation] = [] + if not isinstance(value, dict): + raise ValidationError([Violation(path="", reason="expected object")]) + raw = typing.cast("dict[str, typing.Any]", value) + + room_id: str = typing.cast("typing.Any", None) + if "roomId" not in raw or raw["roomId"] is None: + violations.append(Violation(path="roomId", reason="required")) + else: + room_id_raw = raw["roomId"] + if not isinstance(room_id_raw, str): + violations.append(Violation(path="roomId", reason="expected string")) + else: + room_id = room_id_raw + + message: Message = typing.cast("typing.Any", None) + if "message" not in raw or raw["message"] is None: + violations.append(Violation(path="message", reason="required")) + else: + message_raw = raw["message"] + try: + message = _MessageTransferTypeConverter().from_transfer_type( + message_raw, Message + ) + except ValidationError as error: + _collect(violations, "message", error) + + for key in raw: + if key != "roomId" and key != "message": + violations.append(Violation(path=key, reason="unknown field")) + if violations: + raise ValidationError(violations) + return SendMessageInput( + room_id=room_id, + message=message, + ) + + @typing_extensions.override + def to_transfer_type(self, value: "SendMessageInput") -> typing.Any: + out: dict[str, typing.Any] = {} + out["roomId"] = value.room_id + out["message"] = _MessageTransferTypeConverter().to_transfer_type(value.message) + return out + + +@_transfer_type_convertible(_SendMessageInputTransferTypeConverter) +@dataclasses.dataclass(slots=True, kw_only=True) +class SendMessageInput: """Request to post a message.""" - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - strict=True, populate_by_name=True, extra="forbid" - ) - - room_id: str = pydantic.Field(alias="roomId") - - message: Message = pydantic.Field() - - @pydantic.model_serializer(mode="wrap") - def _serialize( - self, - handler: typing.Callable[[pydantic.BaseModel], typing.Any], - ) -> dict[str, object]: - return _emit_set_fields(self, handler) - - -class SendMessageOutput(pydantic.BaseModel): - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - strict=True, populate_by_name=True, extra="forbid" - ) - - message_id: str = pydantic.Field(alias="messageId") - - @pydantic.model_serializer(mode="wrap") - def _serialize( - self, - handler: typing.Callable[[pydantic.BaseModel], typing.Any], - ) -> dict[str, object]: - return _emit_set_fields(self, handler) - - -_LABELS_MEMBER: pydantic.TypeAdapter[typing.Any] = pydantic.TypeAdapter( - str, config=pydantic.ConfigDict(strict=True) -) + room_id: str + + message: Message + + +class _SendMessageOutputTransferTypeConverter( + temporalio.converter.TransferTypeConverter["SendMessageOutput", typing.Any] +): + @typing_extensions.override + def from_transfer_type( + self, value: typing.Any, type_hint: type["SendMessageOutput"] + ) -> "SendMessageOutput": + violations: list[Violation] = [] + if not isinstance(value, dict): + raise ValidationError([Violation(path="", reason="expected object")]) + raw = typing.cast("dict[str, typing.Any]", value) + + message_id: str = typing.cast("typing.Any", None) + if "messageId" not in raw or raw["messageId"] is None: + violations.append(Violation(path="messageId", reason="required")) + else: + message_id_raw = raw["messageId"] + if not isinstance(message_id_raw, str): + violations.append(Violation(path="messageId", reason="expected string")) + else: + message_id = message_id_raw + + for key in raw: + if key != "messageId": + violations.append(Violation(path=key, reason="unknown field")) + if violations: + raise ValidationError(violations) + return SendMessageOutput( + message_id=message_id, + ) + + @typing_extensions.override + def to_transfer_type(self, value: "SendMessageOutput") -> typing.Any: + out: dict[str, typing.Any] = {} + out["messageId"] = value.message_id + return out + + +@_transfer_type_convertible(_SendMessageOutputTransferTypeConverter) +@dataclasses.dataclass(slots=True, kw_only=True) +class SendMessageOutput: + message_id: str diff --git a/samples/python/kb/_definitions.py b/samples/python/kb/_definitions.py index 00b1bd60..f2b94492 100644 --- a/samples/python/kb/_definitions.py +++ b/samples/python/kb/_definitions.py @@ -4,143 +4,180 @@ import base64 import collections.abc +import dataclasses import datetime -import math +import json import re import typing - -import pydantic -import pydantic.functional_validators -import pydantic_core +import temporalio.converter __all__ = [ - "SpecInt", - "DateTimeField", - "DateField", - "TimeField", - "DurationField", - "Base64Field", - "Base64UrlField", - "_check_multiple_of", - "_check_pattern", - "_check_format", - "_check_unique_items", + "ValidationError", + "Violation", "_check_contains", - "_reject_explicit_null", - "_emit_set_fields", + "_check_unique_items", + "_collect", + "_format_base64", + "_format_base64url", + "_format_date", + "_format_date_time", + "_format_duration", + "_format_time", + "_parse_base64", + "_parse_base64url", + "_parse_date", + "_parse_date_time", + "_parse_duration", + "_parse_spec_integer", + "_parse_time", + "_quote", + "_transfer_type_convertible", ] -_INTEGER_CAP = (1 << 53) - 1 +@dataclasses.dataclass(frozen=True, slots=True) +class Violation: + """A single constraint failure, located by JSON path.""" + path: str + reason: str -def _parse_spec_integer(value: object) -> int: - if isinstance(value, bool): - raise ValueError("expected integer, got boolean") - if isinstance(value, int): - out = value - elif isinstance(value, float): - if not value.is_integer(): - raise ValueError("number has a fractional part; not an integer") - out = int(value) - else: - raise ValueError(f"expected integer, got {type(value).__name__}") - if abs(out) > _INTEGER_CAP: - raise ValueError("integer exceeds +/-(2**53-1) cap") - return out +class ValidationError(Exception): + """Every constraint failure found in one (de)serialization pass.""" + + violations: list[Violation] + + def __init__(self, violations: list[Violation]) -> None: + self.violations = violations + detail = "; ".join(f"{item.path}: {item.reason}" for item in violations) + super().__init__(f"{len(violations)} validation error(s): {detail}") -SpecInt: typing.TypeAlias = typing.Annotated[ - int, pydantic.functional_validators.BeforeValidator(_parse_spec_integer) -] +def _quote(value: object) -> str: + """Renders a value in the JSON form every target quotes offending values in.""" -def _check_multiple_of( - divisor: float, -) -> typing.Callable[[float], float]: - """Builds an AfterValidator asserting `math.fmod`-exact divisibility for number fields.""" + try: + return json.dumps(value, ensure_ascii=False) + except (TypeError, ValueError): + return repr(value) - def validate(value: float) -> float: - if math.fmod(value, divisor) != 0: - raise ValueError(f"must be a multiple of {divisor}, got {value}") - return value - return validate +def _collect(violations: list[Violation], path: str, error: ValidationError) -> None: + """Re-paths a nested model's violations under `path` and appends them.""" + for inner in error.violations: + # A nested violation about the value *itself* carries no path of its own + # (a union branch's own constraint, an element-level check), so the + # prefix is the whole path -- never a dangling separator (P11). + nested = f"{path}.{inner.path}" if inner.path else path + violations.append(Violation(path=nested, reason=inner.reason)) -def _check_pattern( - pattern: str, -) -> typing.Callable[[str], str]: - """Builds an AfterValidator asserting an unanchored, ASCII-class regex match for string fields.""" - compiled = re.compile(pattern, re.ASCII) +_ModelT = typing.TypeVar("_ModelT") - def validate(value: str) -> str: - if compiled.search(value) is None: - raise ValueError(f"must match pattern {pattern}, got {value!r}") - return value - return validate +def _transfer_type_convertible( + converter: type[temporalio.converter.TransferTypeConverter[typing.Any, typing.Any]], +) -> collections.abc.Callable[[type[_ModelT]], type[_ModelT]]: + """Registers a transfer type converter on a model class. + Wraps `temporalio.converter.transfer_type_convertible` to erase the + converter's value-type parameter. Binding it directly on the decorated class + is circular for a static type checker -- the class's type depends on the + decorator, whose value type depends on the class -- which degrades the model + to `Unknown`. Erasing it here keeps the decorator idiomatic at each model and + resolves the cycle. + """ -def _check_format( - format_name: str, - pattern: str, - max_code_points: int | None = None, -) -> typing.Callable[[str], str]: - """Builds an AfterValidator asserting a value matches a pinned `format` regex (+ optional length guard).""" + return temporalio.converter.transfer_type_convertible(converter) - compiled = re.compile(pattern, re.ASCII) - def validate(value: str) -> str: - if ( - max_code_points is not None and len(value) > max_code_points - ) or compiled.search(value) is None: - raise ValueError(f"must be a valid {format_name}, got {value!r}") - return value +_INTEGER_CAP = (1 << 53) - 1 - return validate + +def _parse_spec_integer( + value: object, path: str, violations: list[Violation] +) -> int | None: + """Parses a JSON number as a spec integer (`1.0` accepted, `1.5` rejected).""" + + # `bool` is a subclass of `int`, so it must be excluded before the int check. + if isinstance(value, bool) or not isinstance(value, (int, float)): + violations.append(Violation(path=path, reason="expected integer")) + return None + if isinstance(value, float): + if not value.is_integer(): + violations.append(Violation(path=path, reason="expected integer")) + return None + out = int(value) + else: + out = value + if abs(out) > _INTEGER_CAP: + violations.append(Violation(path=path, reason="expected integer")) + return None + return out def _check_unique_items( - value: list[typing.Any], -) -> list[typing.Any]: - """An AfterValidator asserting an array's elements are pairwise distinct.""" + value: list[typing.Any], path: str, violations: list[Violation] +) -> None: + """Asserts an array's elements are pairwise distinct.""" - seen: dict[object, int] = {} + seen: list[typing.Any] = [] for index, element in enumerate(value): - if element in seen: - raise ValueError( - f"duplicate items: element at index {index} equals index {seen[element]}" - ) - seen[element] = index - return value + for earlier, previous in enumerate(seen): + if previous == element: + violations.append( + Violation( + path=path, + reason=( + f"duplicate items: element at index {index} " + f"equals index {earlier}" + ), + ) + ) + break + seen.append(element) def _check_contains( + value: list[typing.Any], matches: typing.Callable[[typing.Any], bool], min_contains: int, - max_contains: int | None = None, - bounded_min: bool = False, -) -> typing.Callable[[list[typing.Any]], list[typing.Any]]: - """Builds an AfterValidator asserting how many elements match the `contains` schema.""" - - def validate(value: list[typing.Any]) -> list[typing.Any]: - match_count = sum(1 for element in value if matches(element)) - if match_count < min_contains: - if bounded_min: - raise ValueError( - f"too few matching items: at least {min_contains}, got {match_count}" + max_contains: int | None, + bounded_min: bool, + path: str, + violations: list[Violation], +) -> None: + """Asserts how many of an array's elements match the `contains` schema.""" + + match_count = sum(1 for element in value if matches(element)) + if match_count < min_contains: + if bounded_min: + violations.append( + Violation( + path=path, + reason=( + f"too few matching items: at least {min_contains}, " + f"got {match_count}" + ), ) - raise ValueError("no element matches the required schema") - if max_contains is not None and match_count > max_contains: - raise ValueError( - f"too many matching items: at most {max_contains}, got {match_count}" ) - return value - - return validate + else: + violations.append( + Violation(path=path, reason="no element matches the required schema") + ) + if max_contains is not None and match_count > max_contains: + violations.append( + Violation( + path=path, + reason=( + f"too many matching items: at most {max_contains}, " + f"got {match_count}" + ), + ) + ) _TEMPORAL_DATE_TIME_RE = re.compile( @@ -177,43 +214,59 @@ def _valid_temporal_calendar(value: str) -> bool: return maximum > 0 and 1 <= day <= maximum -def _parse_date_time(value: object) -> object: - if not isinstance(value, str): - return value +def _parse_date_time( + value: str, path: str, violations: list[Violation] +) -> datetime.datetime | None: if _TEMPORAL_DATE_TIME_RE.match(value) is None or not _valid_temporal_calendar( value ): - raise ValueError(f"must be a valid date-time, got {value!r}") + violations.append( + Violation( + path=path, reason=f"must be a valid date-time, got {_quote(value)}" + ) + ) + return None normalized = value.upper() if normalized.endswith("Z"): normalized = normalized[:-1] + "+00:00" return datetime.datetime.fromisoformat(normalized) -def _parse_date(value: object) -> object: - if not isinstance(value, str): - return value +def _parse_date( + value: str, path: str, violations: list[Violation] +) -> datetime.date | None: if _TEMPORAL_DATE_RE.match(value) is None or not _valid_temporal_calendar(value): - raise ValueError(f"must be a valid date, got {value!r}") + violations.append( + Violation(path=path, reason=f"must be a valid date, got {_quote(value)}") + ) + return None return datetime.date.fromisoformat(value) -def _parse_time(value: object) -> object: - if not isinstance(value, str): - return value +def _parse_time( + value: str, path: str, violations: list[Violation] +) -> datetime.time | None: if _TEMPORAL_TIME_RE.match(value) is None: - raise ValueError(f"must be a valid time, got {value!r}") + violations.append( + Violation(path=path, reason=f"must be a valid time, got {_quote(value)}") + ) + return None normalized = value.upper() if normalized.endswith("Z"): normalized = normalized[:-1] + "+00:00" return datetime.time.fromisoformat(normalized) -def _parse_duration(value: object) -> object: - if not isinstance(value, str): - return value +def _parse_duration( + value: str, path: str, violations: list[Violation] +) -> datetime.timedelta | None: if _TEMPORAL_DURATION_RE.match(value) is None: - raise ValueError(f"must be a valid duration, got {value!r}") + violations.append( + Violation( + path=path, reason=f"must be a valid duration, got {_quote(value)}" + ) + ) + return None total = 0 number = "" for char in value[2:]: @@ -223,7 +276,12 @@ def _parse_duration(value: object) -> object: total += int(number) * {"H": 3600, "M": 60, "S": 1}[char] number = "" if total > _TEMPORAL_MAX_DURATION_SECONDS: - raise ValueError(f"must be a valid duration, got {value!r}") + violations.append( + Violation( + path=path, reason=f"must be a valid duration, got {_quote(value)}" + ) + ) + return None return datetime.timedelta(seconds=total) @@ -280,39 +338,18 @@ def _format_duration(value: datetime.timedelta) -> str: return out -DateTimeField: typing.TypeAlias = typing.Annotated[ - datetime.datetime, - pydantic.BeforeValidator(_parse_date_time), - pydantic.PlainSerializer(_format_date_time, return_type=str), -] -DateField: typing.TypeAlias = typing.Annotated[ - datetime.date, - pydantic.BeforeValidator(_parse_date), - pydantic.PlainSerializer(_format_date, return_type=str), -] -TimeField: typing.TypeAlias = typing.Annotated[ - datetime.time, - pydantic.BeforeValidator(_parse_time), - pydantic.PlainSerializer(_format_time, return_type=str), -] -DurationField: typing.TypeAlias = typing.Annotated[ - datetime.timedelta, - pydantic.BeforeValidator(_parse_duration), - pydantic.PlainSerializer(_format_duration, return_type=str), -] - - _BASE64_RE = re.compile( "^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?\\Z", re.ASCII ) _BASE64URL_RE = re.compile("^(?:[A-Za-z0-9_-]{4})*(?:[A-Za-z0-9_-]{2,3})?\\Z", re.ASCII) -def _parse_base64(value: typing.Any) -> bytes: - if isinstance(value, bytes): - return value - if not isinstance(value, str) or _BASE64_RE.match(value) is None: - raise ValueError(f"must be base64-encoded, got {value!r}") +def _parse_base64(value: str, path: str, violations: list[Violation]) -> bytes | None: + if _BASE64_RE.match(value) is None: + violations.append( + Violation(path=path, reason=f"must be base64-encoded, got {_quote(value)}") + ) + return None return base64.b64decode(value, validate=True) @@ -320,91 +357,18 @@ def _format_base64(value: bytes) -> str: return base64.b64encode(value).decode("ascii") -def _parse_base64url(value: typing.Any) -> bytes: - if isinstance(value, bytes): - return value - if not isinstance(value, str) or _BASE64URL_RE.match(value) is None: - raise ValueError(f"must be base64url-encoded, got {value!r}") +def _parse_base64url( + value: str, path: str, violations: list[Violation] +) -> bytes | None: + if _BASE64URL_RE.match(value) is None: + violations.append( + Violation( + path=path, reason=f"must be base64url-encoded, got {_quote(value)}" + ) + ) + return None return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4)) def _format_base64url(value: bytes) -> str: return base64.urlsafe_b64encode(value).rstrip(b"=").decode("ascii") - - -Base64Field: typing.TypeAlias = typing.Annotated[ - bytes, - pydantic.BeforeValidator(_parse_base64), - pydantic.PlainSerializer(_format_base64, return_type=str), -] -Base64UrlField: typing.TypeAlias = typing.Annotated[ - bytes, - pydantic.BeforeValidator(_parse_base64url), - pydantic.PlainSerializer(_format_base64url, return_type=str), -] - - -def _reject_explicit_null( - cls: type[pydantic.BaseModel], - data: object, - handler: typing.Callable[[object], typing.Any], -) -> typing.Any: - null_fields = typing.cast( - frozenset[str], getattr(cls, "_OPTIONAL_NON_NULLABLE_FIELDS") - ) - raw_data = data - pre_errors: list[pydantic_core.InitErrorDetails] = [] - if isinstance(data, dict): - values = typing.cast(dict[str, object], data) - pre_errors = [ - pydantic_core.InitErrorDetails( - type=pydantic_core.PydanticCustomError( - "null_for_nonnullable", "explicit null not allowed" - ), - loc=(field,), - input=None, - ) - for field in null_fields - if field in values and values[field] is None - ] - try: - instance = handler(raw_data) - except pydantic.ValidationError as error: - field_errors: list[pydantic_core.InitErrorDetails] = [] - for error_detail in typing.cast(list[dict[str, object]], error.errors()): - loc: tuple[str | int, ...] = tuple( - typing.cast(collections.abc.Iterable[str | int], error_detail["loc"]) - ) - field_errors.append( - pydantic_core.InitErrorDetails( - type=pydantic_core.PydanticCustomError( - typing.cast(typing.Any, error_detail["type"]), - typing.cast(typing.Any, error_detail["msg"]), - ), - loc=loc, - input=error_detail.get("input"), - ) - ) - raise pydantic.ValidationError.from_exception_data( - title=cls.__name__, line_errors=pre_errors + field_errors - ) from None - if pre_errors: - raise pydantic.ValidationError.from_exception_data( - title=cls.__name__, line_errors=pre_errors - ) - return instance - - -def _emit_set_fields( - model: pydantic.BaseModel, - handler: typing.Callable[[pydantic.BaseModel], typing.Any], -) -> dict[str, object]: - dumped = typing.cast(dict[str, object], handler(model)) - alias_of = { - name: (field.alias or name) for name, field in type(model).model_fields.items() - } - keep = {alias_of.get(name, name) for name in model.model_fields_set} - out = {key: value for key, value in dumped.items() if key in keep} - if model.model_extra: - out.update(typing.cast(dict[str, object], model.model_extra)) - return out diff --git a/samples/python/kb/_recursive.py b/samples/python/kb/_recursive.py index b8aa9cc9..1cac48ca 100644 --- a/samples/python/kb/_recursive.py +++ b/samples/python/kb/_recursive.py @@ -2,13 +2,17 @@ from __future__ import annotations +import dataclasses import typing -import pydantic +import typing_extensions +import temporalio.converter from ._definitions import ( - SpecInt, - _emit_set_fields, - _reject_explicit_null, + ValidationError, + Violation, + _collect, + _parse_spec_integer, + _transfer_type_convertible, ) from .content.block.models import BlockStyle @@ -16,98 +20,266 @@ from .content.page.models import PageMeta -class Block(pydantic.BaseModel): +class _BlockTransferTypeConverter( + temporalio.converter.TransferTypeConverter["Block", typing.Any] +): + @typing_extensions.override + def from_transfer_type( + self, value: typing.Any, type_hint: type["Block"] + ) -> "Block": + violations: list[Violation] = [] + if not isinstance(value, dict): + raise ValidationError([Violation(path="", reason="expected object")]) + raw = typing.cast("dict[str, typing.Any]", value) + + block_id: str = typing.cast("typing.Any", None) + if "blockId" not in raw or raw["blockId"] is None: + violations.append(Violation(path="blockId", reason="required")) + else: + block_id_raw = raw["blockId"] + if not isinstance(block_id_raw, str): + violations.append(Violation(path="blockId", reason="expected string")) + else: + block_id = block_id_raw + + order: int = typing.cast("typing.Any", None) + if "order" not in raw or raw["order"] is None: + violations.append(Violation(path="order", reason="required")) + else: + order_raw = raw["order"] + order_parsed = _parse_spec_integer(order_raw, "order", violations) + if order_parsed is not None: + order = order_parsed + if order < 0: + violations.append( + Violation(path="order", reason=f"must be >= 0, got {order}") + ) + + text: str | None = None + if "text" in raw: + text_raw = raw["text"] + if text_raw is None: + violations.append( + Violation(path="text", reason="explicit null not allowed") + ) + else: + if not isinstance(text_raw, str): + violations.append(Violation(path="text", reason="expected string")) + else: + text = text_raw + + style: BlockStyle | None = None + if "style" in raw: + style_raw = raw["style"] + if style_raw is None: + violations.append( + Violation(path="style", reason="explicit null not allowed") + ) + else: + try: + style = getattr( + BlockStyle, "__temporal_transfer_type_converter" + ).from_transfer_type(style_raw, BlockStyle) + except ValidationError as error: + _collect(violations, "style", error) + + page: Page | None = None + if "page" in raw: + page_raw = raw["page"] + if page_raw is None: + page = None + else: + try: + page = _PageTransferTypeConverter().from_transfer_type( + page_raw, Page + ) + except ValidationError as error: + _collect(violations, "page", error) + + for key in raw: + if ( + key != "blockId" + and key != "order" + and key != "text" + and key != "style" + and key != "page" + ): + violations.append(Violation(path=key, reason="unknown field")) + if violations: + raise ValidationError(violations) + return Block( + block_id=block_id, + order=order, + text=text, + style=style, + page=page, + ) + + @typing_extensions.override + def to_transfer_type(self, value: "Block") -> typing.Any: + violations: list[Violation] = [] + out: dict[str, typing.Any] = {} + out["blockId"] = value.block_id + if value.order < 0: + violations.append( + Violation(path="order", reason=f"must be >= 0, got {value.order}") + ) + out["order"] = value.order + if value.text is not None: + out["text"] = value.text + if value.style is not None: + out["style"] = getattr( + BlockStyle, "__temporal_transfer_type_converter" + ).to_transfer_type(value.style) + if value.page is not None: + out["page"] = _PageTransferTypeConverter().to_transfer_type(value.page) + if violations: + raise ValidationError(violations) + return out + + +@_transfer_type_convertible(_BlockTransferTypeConverter) +@dataclasses.dataclass(slots=True, kw_only=True) +class Block: """A content block. The other half of the Page <-> Block cross-file cycle. The `page` back-reference is optional + nullable, which terminates the cycle so it is satisfiable. """ - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - strict=True, populate_by_name=True, extra="forbid" - ) + block_id: str - block_id: str = pydantic.Field(alias="blockId") - - order: SpecInt = pydantic.Field(ge=0) + order: int """Non-negative position within the page. Exercises a numeric `minimum` bound over an integer field. """ - text: str | None = pydantic.Field(default=None) + text: str | None = None - style: BlockStyle | None = pydantic.Field(default=None) + style: BlockStyle | None = None - page: Page | None = pydantic.Field(default=None) + page: Page | None = None """Optional back-reference to the containing page - closes the Page <-> Block cycle. Optional + nullable, so this edge terminates. """ - _OPTIONAL_NON_NULLABLE_FIELDS: typing.ClassVar[frozenset[str]] = frozenset( - {"style", "text"} - ) - @pydantic.model_validator(mode="wrap") - @classmethod - def _reject_null( - cls, - data: object, - handler: typing.Callable[[object], typing.Any], - ) -> typing.Any: - return _reject_explicit_null(cls, data, handler) +class _PageTransferTypeConverter( + temporalio.converter.TransferTypeConverter["Page", typing.Any] +): + @typing_extensions.override + def from_transfer_type(self, value: typing.Any, type_hint: type["Page"]) -> "Page": + violations: list[Violation] = [] + if not isinstance(value, dict): + raise ValidationError([Violation(path="", reason="expected object")]) + raw = typing.cast("dict[str, typing.Any]", value) + + page_id: str = typing.cast("typing.Any", None) + if "pageId" not in raw or raw["pageId"] is None: + violations.append(Violation(path="pageId", reason="required")) + else: + page_id_raw = raw["pageId"] + if not isinstance(page_id_raw, str): + violations.append(Violation(path="pageId", reason="expected string")) + else: + page_id = page_id_raw + + title: str = typing.cast("typing.Any", None) + if "title" not in raw or raw["title"] is None: + violations.append(Violation(path="title", reason="required")) + else: + title_raw = raw["title"] + if not isinstance(title_raw, str): + violations.append(Violation(path="title", reason="expected string")) + else: + title = title_raw + + meta: PageMeta = typing.cast("typing.Any", None) + if "meta" not in raw or raw["meta"] is None: + violations.append(Violation(path="meta", reason="required")) + else: + meta_raw = raw["meta"] + try: + meta = getattr( + PageMeta, "__temporal_transfer_type_converter" + ).from_transfer_type(meta_raw, PageMeta) + except ValidationError as error: + _collect(violations, "meta", error) - @pydantic.model_serializer(mode="wrap") - def _serialize( - self, - handler: typing.Callable[[pydantic.BaseModel], typing.Any], - ) -> dict[str, object]: - return _emit_set_fields(self, handler) + blocks: list[Block] | None = None + if "blocks" in raw: + blocks_raw = raw["blocks"] + if blocks_raw is None: + violations.append( + Violation(path="blocks", reason="explicit null not allowed") + ) + else: + if not isinstance(blocks_raw, list): + violations.append(Violation(path="blocks", reason="expected array")) + else: + blocks_list: list[Block] = [] + for blocks_index, blocks_element in enumerate( + typing.cast("list[typing.Any]", blocks_raw) + ): + blocks_item_path = f"blocks[{blocks_index}]" + blocks_item: Block = typing.cast("typing.Any", None) + try: + blocks_item = ( + _BlockTransferTypeConverter().from_transfer_type( + blocks_element, Block + ) + ) + except ValidationError as error: + _collect(violations, blocks_item_path, error) + blocks_list.append(blocks_item) + blocks = blocks_list + for key in raw: + if key != "pageId" and key != "title" and key != "meta" and key != "blocks": + violations.append(Violation(path=key, reason="unknown field")) + if violations: + raise ValidationError(violations) + return Page( + page_id=page_id, + title=title, + meta=meta, + blocks=blocks, + ) -class Page(pydantic.BaseModel): + @typing_extensions.override + def to_transfer_type(self, value: "Page") -> typing.Any: + out: dict[str, typing.Any] = {} + out["pageId"] = value.page_id + out["title"] = value.title + out["meta"] = getattr( + PageMeta, "__temporal_transfer_type_converter" + ).to_transfer_type(value.meta) + if value.blocks is not None: + out["blocks"] = [ + _BlockTransferTypeConverter().to_transfer_type(element) + for element in value.blocks + ] + return out + + +@_transfer_type_convertible(_PageTransferTypeConverter) +@dataclasses.dataclass(slots=True, kw_only=True) +class Page: """A page. One half of the Page <-> Block cross-file cycle. Because the cycle spans two input files, Page and Block hoist together into Python's _recursive.py; the non-cyclic PageMeta helper stays in this module. """ - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - strict=True, populate_by_name=True, extra="forbid" - ) + page_id: str - page_id: str = pydantic.Field(alias="pageId") + title: str - title: str = pydantic.Field() + meta: PageMeta - meta: PageMeta = pydantic.Field() - - blocks: list[Block] | None = pydantic.Field(default=None) + blocks: list[Block] | None = None """Ordered content blocks. Cross-file `$ref` to block.json (same directory); the array is the terminating edge of the cycle. """ - _OPTIONAL_NON_NULLABLE_FIELDS: typing.ClassVar[frozenset[str]] = frozenset( - {"blocks"} - ) - - @pydantic.model_validator(mode="wrap") - @classmethod - def _reject_null( - cls, - data: object, - handler: typing.Callable[[object], typing.Any], - ) -> typing.Any: - return _reject_explicit_null(cls, data, handler) - - @pydantic.model_serializer(mode="wrap") - def _serialize( - self, - handler: typing.Callable[[pydantic.BaseModel], typing.Any], - ) -> dict[str, object]: - return _emit_set_fields(self, handler) - - -_ = Block.model_rebuild() -_ = Page.model_rebuild() - __all__ = [ "Block", diff --git a/samples/python/kb/content/block/models.py b/samples/python/kb/content/block/models.py index d1b2cb7a..93ea5e5a 100644 --- a/samples/python/kb/content/block/models.py +++ b/samples/python/kb/content/block/models.py @@ -2,43 +2,94 @@ from __future__ import annotations +import dataclasses import typing -import pydantic +import typing_extensions +import temporalio.converter from ..._definitions import ( - SpecInt, - _emit_set_fields, - _reject_explicit_null, + ValidationError, + Violation, + _parse_spec_integer, + _transfer_type_convertible, ) -class BlockStyle(pydantic.BaseModel): +class _BlockStyleTransferTypeConverter( + temporalio.converter.TransferTypeConverter["BlockStyle", typing.Any] +): + @typing_extensions.override + def from_transfer_type( + self, value: typing.Any, type_hint: type["BlockStyle"] + ) -> "BlockStyle": + violations: list[Violation] = [] + if not isinstance(value, dict): + raise ValidationError([Violation(path="", reason="expected object")]) + raw = typing.cast("dict[str, typing.Any]", value) + + bold: bool | None = None + if "bold" in raw: + bold_raw = raw["bold"] + if bold_raw is None: + violations.append( + Violation(path="bold", reason="explicit null not allowed") + ) + else: + if not isinstance(bold_raw, bool): + violations.append(Violation(path="bold", reason="expected boolean")) + else: + bold = bold_raw + + indent: int | None = None + if "indent" in raw: + indent_raw = raw["indent"] + if indent_raw is None: + violations.append( + Violation(path="indent", reason="explicit null not allowed") + ) + else: + indent_parsed = _parse_spec_integer(indent_raw, "indent", violations) + if indent_parsed is not None: + indent = indent_parsed + if indent < 0: + violations.append( + Violation( + path="indent", reason=f"must be >= 0, got {indent}" + ) + ) + + for key in raw: + if key != "bold" and key != "indent": + violations.append(Violation(path=key, reason="unknown field")) + if violations: + raise ValidationError(violations) + return BlockStyle( + bold=bold, + indent=indent, + ) + + @typing_extensions.override + def to_transfer_type(self, value: "BlockStyle") -> typing.Any: + violations: list[Violation] = [] + out: dict[str, typing.Any] = {} + if value.bold is not None: + out["bold"] = value.bold + if value.indent is not None: + if value.indent < 0: + violations.append( + Violation(path="indent", reason=f"must be >= 0, got {value.indent}") + ) + out["indent"] = value.indent + if violations: + raise ValidationError(violations) + return out + + +@_transfer_type_convertible(_BlockStyleTransferTypeConverter) +@dataclasses.dataclass(slots=True, kw_only=True) +class BlockStyle: """Non-cyclic helper; stays in the content_block module. All members optional.""" - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - strict=True, populate_by_name=True, extra="forbid" - ) - - bold: bool | None = pydantic.Field(default=None) - - indent: SpecInt | None = pydantic.Field(default=None, ge=0) - - _OPTIONAL_NON_NULLABLE_FIELDS: typing.ClassVar[frozenset[str]] = frozenset( - {"bold", "indent"} - ) - - @pydantic.model_validator(mode="wrap") - @classmethod - def _reject_null( - cls, - data: object, - handler: typing.Callable[[object], typing.Any], - ) -> typing.Any: - return _reject_explicit_null(cls, data, handler) - - @pydantic.model_serializer(mode="wrap") - def _serialize( - self, - handler: typing.Callable[[pydantic.BaseModel], typing.Any], - ) -> dict[str, object]: - return _emit_set_fields(self, handler) + bold: bool | None = None + + indent: int | None = None diff --git a/samples/python/kb/content/page/models.py b/samples/python/kb/content/page/models.py index 54fe9f62..4704f8fa 100644 --- a/samples/python/kb/content/page/models.py +++ b/samples/python/kb/content/page/models.py @@ -2,45 +2,81 @@ from __future__ import annotations +import dataclasses import typing -import pydantic +import typing_extensions +import temporalio.converter from ..._definitions import ( - SpecInt, - _emit_set_fields, - _reject_explicit_null, + ValidationError, + Violation, + _parse_spec_integer, + _transfer_type_convertible, ) -class PageMeta(pydantic.BaseModel): +class _PageMetaTransferTypeConverter( + temporalio.converter.TransferTypeConverter["PageMeta", typing.Any] +): + @typing_extensions.override + def from_transfer_type( + self, value: typing.Any, type_hint: type["PageMeta"] + ) -> "PageMeta": + violations: list[Violation] = [] + if not isinstance(value, dict): + raise ValidationError([Violation(path="", reason="expected object")]) + raw = typing.cast("dict[str, typing.Any]", value) + + author: str = typing.cast("typing.Any", None) + if "author" not in raw or raw["author"] is None: + violations.append(Violation(path="author", reason="required")) + else: + author_raw = raw["author"] + if not isinstance(author_raw, str): + violations.append(Violation(path="author", reason="expected string")) + else: + author = author_raw + + word_count: int | None = None + if "wordCount" in raw: + word_count_raw = raw["wordCount"] + if word_count_raw is None: + violations.append( + Violation(path="wordCount", reason="explicit null not allowed") + ) + else: + word_count_parsed = _parse_spec_integer( + word_count_raw, "wordCount", violations + ) + if word_count_parsed is not None: + word_count = word_count_parsed + + for key in raw: + if key != "author" and key != "wordCount": + violations.append(Violation(path=key, reason="unknown field")) + if violations: + raise ValidationError(violations) + return PageMeta( + author=author, + word_count=word_count, + ) + + @typing_extensions.override + def to_transfer_type(self, value: "PageMeta") -> typing.Any: + out: dict[str, typing.Any] = {} + out["author"] = value.author + if value.word_count is not None: + out["wordCount"] = value.word_count + return out + + +@_transfer_type_convertible(_PageMetaTransferTypeConverter) +@dataclasses.dataclass(slots=True, kw_only=True) +class PageMeta: """Non-cyclic helper. Referenced only by Page, references nothing recursive, so it stays in the content_page module even though Page is hoisted. """ - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - strict=True, populate_by_name=True, extra="forbid" - ) - - author: str = pydantic.Field() - - word_count: SpecInt | None = pydantic.Field(default=None, alias="wordCount") - - _OPTIONAL_NON_NULLABLE_FIELDS: typing.ClassVar[frozenset[str]] = frozenset( - {"wordCount", "word_count"} - ) - - @pydantic.model_validator(mode="wrap") - @classmethod - def _reject_null( - cls, - data: object, - handler: typing.Callable[[object], typing.Any], - ) -> typing.Any: - return _reject_explicit_null(cls, data, handler) - - @pydantic.model_serializer(mode="wrap") - def _serialize( - self, - handler: typing.Callable[[pydantic.BaseModel], typing.Any], - ) -> dict[str, object]: - return _emit_set_fields(self, handler) + author: str + + word_count: int | None = None diff --git a/samples/python/kb/kb/models.py b/samples/python/kb/kb/models.py index b5326b96..e40c9873 100644 --- a/samples/python/kb/kb/models.py +++ b/samples/python/kb/kb/models.py @@ -2,57 +2,159 @@ from __future__ import annotations +import dataclasses import typing -import pydantic +import typing_extensions +import temporalio.converter from .._definitions import ( - SpecInt, - _emit_set_fields, + ValidationError, + Violation, + _parse_spec_integer, + _transfer_type_convertible, ) -class GetCategoryTreeInput(pydantic.BaseModel): - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - strict=True, populate_by_name=True, extra="forbid" - ) - - root_id: str = pydantic.Field(alias="rootId") - - @pydantic.model_serializer(mode="wrap") - def _serialize( - self, - handler: typing.Callable[[pydantic.BaseModel], typing.Any], - ) -> dict[str, object]: - return _emit_set_fields(self, handler) - - -class GetPageInput(pydantic.BaseModel): - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - strict=True, populate_by_name=True, extra="forbid" - ) - - page_id: str = pydantic.Field(alias="pageId") - - @pydantic.model_serializer(mode="wrap") - def _serialize( - self, - handler: typing.Callable[[pydantic.BaseModel], typing.Any], - ) -> dict[str, object]: - return _emit_set_fields(self, handler) - - -class PutBlockOutput(pydantic.BaseModel): - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - strict=True, populate_by_name=True, extra="forbid" - ) - - block_id: str = pydantic.Field(alias="blockId") - - revision: SpecInt = pydantic.Field() - - @pydantic.model_serializer(mode="wrap") - def _serialize( - self, - handler: typing.Callable[[pydantic.BaseModel], typing.Any], - ) -> dict[str, object]: - return _emit_set_fields(self, handler) +class _GetCategoryTreeInputTransferTypeConverter( + temporalio.converter.TransferTypeConverter["GetCategoryTreeInput", typing.Any] +): + @typing_extensions.override + def from_transfer_type( + self, value: typing.Any, type_hint: type["GetCategoryTreeInput"] + ) -> "GetCategoryTreeInput": + violations: list[Violation] = [] + if not isinstance(value, dict): + raise ValidationError([Violation(path="", reason="expected object")]) + raw = typing.cast("dict[str, typing.Any]", value) + + root_id: str = typing.cast("typing.Any", None) + if "rootId" not in raw or raw["rootId"] is None: + violations.append(Violation(path="rootId", reason="required")) + else: + root_id_raw = raw["rootId"] + if not isinstance(root_id_raw, str): + violations.append(Violation(path="rootId", reason="expected string")) + else: + root_id = root_id_raw + + for key in raw: + if key != "rootId": + violations.append(Violation(path=key, reason="unknown field")) + if violations: + raise ValidationError(violations) + return GetCategoryTreeInput( + root_id=root_id, + ) + + @typing_extensions.override + def to_transfer_type(self, value: "GetCategoryTreeInput") -> typing.Any: + out: dict[str, typing.Any] = {} + out["rootId"] = value.root_id + return out + + +@_transfer_type_convertible(_GetCategoryTreeInputTransferTypeConverter) +@dataclasses.dataclass(slots=True, kw_only=True) +class GetCategoryTreeInput: + root_id: str + + +class _GetPageInputTransferTypeConverter( + temporalio.converter.TransferTypeConverter["GetPageInput", typing.Any] +): + @typing_extensions.override + def from_transfer_type( + self, value: typing.Any, type_hint: type["GetPageInput"] + ) -> "GetPageInput": + violations: list[Violation] = [] + if not isinstance(value, dict): + raise ValidationError([Violation(path="", reason="expected object")]) + raw = typing.cast("dict[str, typing.Any]", value) + + page_id: str = typing.cast("typing.Any", None) + if "pageId" not in raw or raw["pageId"] is None: + violations.append(Violation(path="pageId", reason="required")) + else: + page_id_raw = raw["pageId"] + if not isinstance(page_id_raw, str): + violations.append(Violation(path="pageId", reason="expected string")) + else: + page_id = page_id_raw + + for key in raw: + if key != "pageId": + violations.append(Violation(path=key, reason="unknown field")) + if violations: + raise ValidationError(violations) + return GetPageInput( + page_id=page_id, + ) + + @typing_extensions.override + def to_transfer_type(self, value: "GetPageInput") -> typing.Any: + out: dict[str, typing.Any] = {} + out["pageId"] = value.page_id + return out + + +@_transfer_type_convertible(_GetPageInputTransferTypeConverter) +@dataclasses.dataclass(slots=True, kw_only=True) +class GetPageInput: + page_id: str + + +class _PutBlockOutputTransferTypeConverter( + temporalio.converter.TransferTypeConverter["PutBlockOutput", typing.Any] +): + @typing_extensions.override + def from_transfer_type( + self, value: typing.Any, type_hint: type["PutBlockOutput"] + ) -> "PutBlockOutput": + violations: list[Violation] = [] + if not isinstance(value, dict): + raise ValidationError([Violation(path="", reason="expected object")]) + raw = typing.cast("dict[str, typing.Any]", value) + + block_id: str = typing.cast("typing.Any", None) + if "blockId" not in raw or raw["blockId"] is None: + violations.append(Violation(path="blockId", reason="required")) + else: + block_id_raw = raw["blockId"] + if not isinstance(block_id_raw, str): + violations.append(Violation(path="blockId", reason="expected string")) + else: + block_id = block_id_raw + + revision: int = typing.cast("typing.Any", None) + if "revision" not in raw or raw["revision"] is None: + violations.append(Violation(path="revision", reason="required")) + else: + revision_raw = raw["revision"] + revision_parsed = _parse_spec_integer(revision_raw, "revision", violations) + if revision_parsed is not None: + revision = revision_parsed + + for key in raw: + if key != "blockId" and key != "revision": + violations.append(Violation(path=key, reason="unknown field")) + if violations: + raise ValidationError(violations) + return PutBlockOutput( + block_id=block_id, + revision=revision, + ) + + @typing_extensions.override + def to_transfer_type(self, value: "PutBlockOutput") -> typing.Any: + out: dict[str, typing.Any] = {} + out["blockId"] = value.block_id + out["revision"] = value.revision + return out + + +@_transfer_type_convertible(_PutBlockOutputTransferTypeConverter) +@dataclasses.dataclass(slots=True, kw_only=True) +class PutBlockOutput: + block_id: str + + revision: int diff --git a/samples/python/kb/tree/category/models.py b/samples/python/kb/tree/category/models.py index c1ef693a..89642623 100644 --- a/samples/python/kb/tree/category/models.py +++ b/samples/python/kb/tree/category/models.py @@ -2,71 +2,180 @@ from __future__ import annotations +import dataclasses import typing -import pydantic +import typing_extensions +import temporalio.converter from ..._definitions import ( - _emit_set_fields, - _reject_explicit_null, + ValidationError, + Violation, + _collect, + _transfer_type_convertible, ) -class Category(pydantic.BaseModel): +class _CategoryTransferTypeConverter( + temporalio.converter.TransferTypeConverter["Category", typing.Any] +): + @typing_extensions.override + def from_transfer_type( + self, value: typing.Any, type_hint: type["Category"] + ) -> "Category": + violations: list[Violation] = [] + if not isinstance(value, dict): + raise ValidationError([Violation(path="", reason="expected object")]) + raw = typing.cast("dict[str, typing.Any]", value) + + id: str = typing.cast("typing.Any", None) + if "id" not in raw or raw["id"] is None: + violations.append(Violation(path="id", reason="required")) + else: + id_raw = raw["id"] + if not isinstance(id_raw, str): + violations.append(Violation(path="id", reason="expected string")) + else: + id = id_raw + + name: str = typing.cast("typing.Any", None) + if "name" not in raw or raw["name"] is None: + violations.append(Violation(path="name", reason="required")) + else: + name_raw = raw["name"] + if not isinstance(name_raw, str): + violations.append(Violation(path="name", reason="expected string")) + else: + name = name_raw + + children: list[Category] | None = None + if "children" in raw: + children_raw = raw["children"] + if children_raw is None: + violations.append( + Violation(path="children", reason="explicit null not allowed") + ) + else: + if not isinstance(children_raw, list): + violations.append( + Violation(path="children", reason="expected array") + ) + else: + children_list: list[Category] = [] + for children_index, children_element in enumerate( + typing.cast("list[typing.Any]", children_raw) + ): + children_item_path = f"children[{children_index}]" + children_item: Category = typing.cast("typing.Any", None) + try: + children_item = ( + _CategoryTransferTypeConverter().from_transfer_type( + children_element, Category + ) + ) + except ValidationError as error: + _collect(violations, children_item_path, error) + children_list.append(children_item) + children = children_list + + for key in raw: + if key != "id" and key != "name" and key != "children": + violations.append(Violation(path=key, reason="unknown field")) + if violations: + raise ValidationError(violations) + return Category( + id=id, + name=name, + children=children, + ) + + @typing_extensions.override + def to_transfer_type(self, value: "Category") -> typing.Any: + out: dict[str, typing.Any] = {} + out["id"] = value.id + out["name"] = value.name + if value.children is not None: + out["children"] = [ + _CategoryTransferTypeConverter().to_transfer_type(element) + for element in value.children + ] + return out + + +@_transfer_type_convertible(_CategoryTransferTypeConverter) +@dataclasses.dataclass(slots=True, kw_only=True) +class Category: """A node in a self-recursive category tree. The root of this file is itself a type (pure JSON Schema file), named Category from the basename. """ - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - strict=True, populate_by_name=True, extra="forbid" - ) + id: str - id: str = pydantic.Field() + name: str - name: str = pydantic.Field() - - children: list[Category] | None = pydantic.Field(default=None) + children: list[Category] | None = None """Sub-categories. A within-file self-cycle via `$ref: '#'`; the possibly-empty array is the terminating edge, so it stays in this module. """ - _OPTIONAL_NON_NULLABLE_FIELDS: typing.ClassVar[frozenset[str]] = frozenset( - {"children"} - ) - - @pydantic.model_validator(mode="wrap") - @classmethod - def _reject_null( - cls, - data: object, - handler: typing.Callable[[object], typing.Any], - ) -> typing.Any: - return _reject_explicit_null(cls, data, handler) - - @pydantic.model_serializer(mode="wrap") - def _serialize( - self, - handler: typing.Callable[[pydantic.BaseModel], typing.Any], - ) -> dict[str, object]: - return _emit_set_fields(self, handler) - -class Palette(pydantic.BaseModel): +class _PaletteTransferTypeConverter( + temporalio.converter.TransferTypeConverter["Palette", typing.Any] +): + @typing_extensions.override + def from_transfer_type( + self, value: typing.Any, type_hint: type["Palette"] + ) -> "Palette": + violations: list[Violation] = [] + if not isinstance(value, dict): + raise ValidationError([Violation(path="", reason="expected object")]) + raw = typing.cast("dict[str, typing.Any]", value) + + swatches: list[str] = typing.cast("typing.Any", None) + if "swatches" not in raw or raw["swatches"] is None: + violations.append(Violation(path="swatches", reason="required")) + else: + swatches_raw = raw["swatches"] + if not isinstance(swatches_raw, list): + violations.append(Violation(path="swatches", reason="expected array")) + else: + swatches_list: list[str] = [] + for swatches_index, swatches_element in enumerate( + typing.cast("list[typing.Any]", swatches_raw) + ): + swatches_item_path = f"swatches[{swatches_index}]" + swatches_item: str = typing.cast("typing.Any", None) + if not isinstance(swatches_element, str): + violations.append( + Violation( + path=swatches_item_path, reason="expected element" + ) + ) + else: + swatches_item = swatches_element + swatches_list.append(swatches_item) + swatches = swatches_list + + for key in raw: + if key != "swatches": + violations.append(Violation(path=key, reason="unknown field")) + if violations: + raise ValidationError(violations) + return Palette( + swatches=swatches, + ) + + @typing_extensions.override + def to_transfer_type(self, value: "Palette") -> typing.Any: + out: dict[str, typing.Any] = {} + out["swatches"] = value.swatches + return out + + +@_transfer_type_convertible(_PaletteTransferTypeConverter) +@dataclasses.dataclass(slots=True, kw_only=True) +class Palette: """A dead $def - defined but never referenced anywhere. Still generated and exported as intended reusable API surface (see the $ref spec). """ - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - strict=True, populate_by_name=True, extra="forbid" - ) - - swatches: list[str] = pydantic.Field() - - @pydantic.model_serializer(mode="wrap") - def _serialize( - self, - handler: typing.Callable[[pydantic.BaseModel], typing.Any], - ) -> dict[str, object]: - return _emit_set_fields(self, handler) - - -_ = Category.model_rebuild() + swatches: list[str] diff --git a/samples/python/pyproject.toml b/samples/python/pyproject.toml index 67eb1148..e536fd2a 100644 --- a/samples/python/pyproject.toml +++ b/samples/python/pyproject.toml @@ -4,7 +4,6 @@ version = "0.1.0" requires-python = ">=3.10" dependencies = [ "basedpyright==1.31.4", - "pydantic>=2.12.0", "pytest>=8.4.2", "pytest-asyncio>=1.2.0", "ruff>=0.15.12", diff --git a/samples/python/showcase/_definitions.py b/samples/python/showcase/_definitions.py index 00b1bd60..f2b94492 100644 --- a/samples/python/showcase/_definitions.py +++ b/samples/python/showcase/_definitions.py @@ -4,143 +4,180 @@ import base64 import collections.abc +import dataclasses import datetime -import math +import json import re import typing - -import pydantic -import pydantic.functional_validators -import pydantic_core +import temporalio.converter __all__ = [ - "SpecInt", - "DateTimeField", - "DateField", - "TimeField", - "DurationField", - "Base64Field", - "Base64UrlField", - "_check_multiple_of", - "_check_pattern", - "_check_format", - "_check_unique_items", + "ValidationError", + "Violation", "_check_contains", - "_reject_explicit_null", - "_emit_set_fields", + "_check_unique_items", + "_collect", + "_format_base64", + "_format_base64url", + "_format_date", + "_format_date_time", + "_format_duration", + "_format_time", + "_parse_base64", + "_parse_base64url", + "_parse_date", + "_parse_date_time", + "_parse_duration", + "_parse_spec_integer", + "_parse_time", + "_quote", + "_transfer_type_convertible", ] -_INTEGER_CAP = (1 << 53) - 1 +@dataclasses.dataclass(frozen=True, slots=True) +class Violation: + """A single constraint failure, located by JSON path.""" + path: str + reason: str -def _parse_spec_integer(value: object) -> int: - if isinstance(value, bool): - raise ValueError("expected integer, got boolean") - if isinstance(value, int): - out = value - elif isinstance(value, float): - if not value.is_integer(): - raise ValueError("number has a fractional part; not an integer") - out = int(value) - else: - raise ValueError(f"expected integer, got {type(value).__name__}") - if abs(out) > _INTEGER_CAP: - raise ValueError("integer exceeds +/-(2**53-1) cap") - return out +class ValidationError(Exception): + """Every constraint failure found in one (de)serialization pass.""" + + violations: list[Violation] + + def __init__(self, violations: list[Violation]) -> None: + self.violations = violations + detail = "; ".join(f"{item.path}: {item.reason}" for item in violations) + super().__init__(f"{len(violations)} validation error(s): {detail}") -SpecInt: typing.TypeAlias = typing.Annotated[ - int, pydantic.functional_validators.BeforeValidator(_parse_spec_integer) -] +def _quote(value: object) -> str: + """Renders a value in the JSON form every target quotes offending values in.""" -def _check_multiple_of( - divisor: float, -) -> typing.Callable[[float], float]: - """Builds an AfterValidator asserting `math.fmod`-exact divisibility for number fields.""" + try: + return json.dumps(value, ensure_ascii=False) + except (TypeError, ValueError): + return repr(value) - def validate(value: float) -> float: - if math.fmod(value, divisor) != 0: - raise ValueError(f"must be a multiple of {divisor}, got {value}") - return value - return validate +def _collect(violations: list[Violation], path: str, error: ValidationError) -> None: + """Re-paths a nested model's violations under `path` and appends them.""" + for inner in error.violations: + # A nested violation about the value *itself* carries no path of its own + # (a union branch's own constraint, an element-level check), so the + # prefix is the whole path -- never a dangling separator (P11). + nested = f"{path}.{inner.path}" if inner.path else path + violations.append(Violation(path=nested, reason=inner.reason)) -def _check_pattern( - pattern: str, -) -> typing.Callable[[str], str]: - """Builds an AfterValidator asserting an unanchored, ASCII-class regex match for string fields.""" - compiled = re.compile(pattern, re.ASCII) +_ModelT = typing.TypeVar("_ModelT") - def validate(value: str) -> str: - if compiled.search(value) is None: - raise ValueError(f"must match pattern {pattern}, got {value!r}") - return value - return validate +def _transfer_type_convertible( + converter: type[temporalio.converter.TransferTypeConverter[typing.Any, typing.Any]], +) -> collections.abc.Callable[[type[_ModelT]], type[_ModelT]]: + """Registers a transfer type converter on a model class. + Wraps `temporalio.converter.transfer_type_convertible` to erase the + converter's value-type parameter. Binding it directly on the decorated class + is circular for a static type checker -- the class's type depends on the + decorator, whose value type depends on the class -- which degrades the model + to `Unknown`. Erasing it here keeps the decorator idiomatic at each model and + resolves the cycle. + """ -def _check_format( - format_name: str, - pattern: str, - max_code_points: int | None = None, -) -> typing.Callable[[str], str]: - """Builds an AfterValidator asserting a value matches a pinned `format` regex (+ optional length guard).""" + return temporalio.converter.transfer_type_convertible(converter) - compiled = re.compile(pattern, re.ASCII) - def validate(value: str) -> str: - if ( - max_code_points is not None and len(value) > max_code_points - ) or compiled.search(value) is None: - raise ValueError(f"must be a valid {format_name}, got {value!r}") - return value +_INTEGER_CAP = (1 << 53) - 1 - return validate + +def _parse_spec_integer( + value: object, path: str, violations: list[Violation] +) -> int | None: + """Parses a JSON number as a spec integer (`1.0` accepted, `1.5` rejected).""" + + # `bool` is a subclass of `int`, so it must be excluded before the int check. + if isinstance(value, bool) or not isinstance(value, (int, float)): + violations.append(Violation(path=path, reason="expected integer")) + return None + if isinstance(value, float): + if not value.is_integer(): + violations.append(Violation(path=path, reason="expected integer")) + return None + out = int(value) + else: + out = value + if abs(out) > _INTEGER_CAP: + violations.append(Violation(path=path, reason="expected integer")) + return None + return out def _check_unique_items( - value: list[typing.Any], -) -> list[typing.Any]: - """An AfterValidator asserting an array's elements are pairwise distinct.""" + value: list[typing.Any], path: str, violations: list[Violation] +) -> None: + """Asserts an array's elements are pairwise distinct.""" - seen: dict[object, int] = {} + seen: list[typing.Any] = [] for index, element in enumerate(value): - if element in seen: - raise ValueError( - f"duplicate items: element at index {index} equals index {seen[element]}" - ) - seen[element] = index - return value + for earlier, previous in enumerate(seen): + if previous == element: + violations.append( + Violation( + path=path, + reason=( + f"duplicate items: element at index {index} " + f"equals index {earlier}" + ), + ) + ) + break + seen.append(element) def _check_contains( + value: list[typing.Any], matches: typing.Callable[[typing.Any], bool], min_contains: int, - max_contains: int | None = None, - bounded_min: bool = False, -) -> typing.Callable[[list[typing.Any]], list[typing.Any]]: - """Builds an AfterValidator asserting how many elements match the `contains` schema.""" - - def validate(value: list[typing.Any]) -> list[typing.Any]: - match_count = sum(1 for element in value if matches(element)) - if match_count < min_contains: - if bounded_min: - raise ValueError( - f"too few matching items: at least {min_contains}, got {match_count}" + max_contains: int | None, + bounded_min: bool, + path: str, + violations: list[Violation], +) -> None: + """Asserts how many of an array's elements match the `contains` schema.""" + + match_count = sum(1 for element in value if matches(element)) + if match_count < min_contains: + if bounded_min: + violations.append( + Violation( + path=path, + reason=( + f"too few matching items: at least {min_contains}, " + f"got {match_count}" + ), ) - raise ValueError("no element matches the required schema") - if max_contains is not None and match_count > max_contains: - raise ValueError( - f"too many matching items: at most {max_contains}, got {match_count}" ) - return value - - return validate + else: + violations.append( + Violation(path=path, reason="no element matches the required schema") + ) + if max_contains is not None and match_count > max_contains: + violations.append( + Violation( + path=path, + reason=( + f"too many matching items: at most {max_contains}, " + f"got {match_count}" + ), + ) + ) _TEMPORAL_DATE_TIME_RE = re.compile( @@ -177,43 +214,59 @@ def _valid_temporal_calendar(value: str) -> bool: return maximum > 0 and 1 <= day <= maximum -def _parse_date_time(value: object) -> object: - if not isinstance(value, str): - return value +def _parse_date_time( + value: str, path: str, violations: list[Violation] +) -> datetime.datetime | None: if _TEMPORAL_DATE_TIME_RE.match(value) is None or not _valid_temporal_calendar( value ): - raise ValueError(f"must be a valid date-time, got {value!r}") + violations.append( + Violation( + path=path, reason=f"must be a valid date-time, got {_quote(value)}" + ) + ) + return None normalized = value.upper() if normalized.endswith("Z"): normalized = normalized[:-1] + "+00:00" return datetime.datetime.fromisoformat(normalized) -def _parse_date(value: object) -> object: - if not isinstance(value, str): - return value +def _parse_date( + value: str, path: str, violations: list[Violation] +) -> datetime.date | None: if _TEMPORAL_DATE_RE.match(value) is None or not _valid_temporal_calendar(value): - raise ValueError(f"must be a valid date, got {value!r}") + violations.append( + Violation(path=path, reason=f"must be a valid date, got {_quote(value)}") + ) + return None return datetime.date.fromisoformat(value) -def _parse_time(value: object) -> object: - if not isinstance(value, str): - return value +def _parse_time( + value: str, path: str, violations: list[Violation] +) -> datetime.time | None: if _TEMPORAL_TIME_RE.match(value) is None: - raise ValueError(f"must be a valid time, got {value!r}") + violations.append( + Violation(path=path, reason=f"must be a valid time, got {_quote(value)}") + ) + return None normalized = value.upper() if normalized.endswith("Z"): normalized = normalized[:-1] + "+00:00" return datetime.time.fromisoformat(normalized) -def _parse_duration(value: object) -> object: - if not isinstance(value, str): - return value +def _parse_duration( + value: str, path: str, violations: list[Violation] +) -> datetime.timedelta | None: if _TEMPORAL_DURATION_RE.match(value) is None: - raise ValueError(f"must be a valid duration, got {value!r}") + violations.append( + Violation( + path=path, reason=f"must be a valid duration, got {_quote(value)}" + ) + ) + return None total = 0 number = "" for char in value[2:]: @@ -223,7 +276,12 @@ def _parse_duration(value: object) -> object: total += int(number) * {"H": 3600, "M": 60, "S": 1}[char] number = "" if total > _TEMPORAL_MAX_DURATION_SECONDS: - raise ValueError(f"must be a valid duration, got {value!r}") + violations.append( + Violation( + path=path, reason=f"must be a valid duration, got {_quote(value)}" + ) + ) + return None return datetime.timedelta(seconds=total) @@ -280,39 +338,18 @@ def _format_duration(value: datetime.timedelta) -> str: return out -DateTimeField: typing.TypeAlias = typing.Annotated[ - datetime.datetime, - pydantic.BeforeValidator(_parse_date_time), - pydantic.PlainSerializer(_format_date_time, return_type=str), -] -DateField: typing.TypeAlias = typing.Annotated[ - datetime.date, - pydantic.BeforeValidator(_parse_date), - pydantic.PlainSerializer(_format_date, return_type=str), -] -TimeField: typing.TypeAlias = typing.Annotated[ - datetime.time, - pydantic.BeforeValidator(_parse_time), - pydantic.PlainSerializer(_format_time, return_type=str), -] -DurationField: typing.TypeAlias = typing.Annotated[ - datetime.timedelta, - pydantic.BeforeValidator(_parse_duration), - pydantic.PlainSerializer(_format_duration, return_type=str), -] - - _BASE64_RE = re.compile( "^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?\\Z", re.ASCII ) _BASE64URL_RE = re.compile("^(?:[A-Za-z0-9_-]{4})*(?:[A-Za-z0-9_-]{2,3})?\\Z", re.ASCII) -def _parse_base64(value: typing.Any) -> bytes: - if isinstance(value, bytes): - return value - if not isinstance(value, str) or _BASE64_RE.match(value) is None: - raise ValueError(f"must be base64-encoded, got {value!r}") +def _parse_base64(value: str, path: str, violations: list[Violation]) -> bytes | None: + if _BASE64_RE.match(value) is None: + violations.append( + Violation(path=path, reason=f"must be base64-encoded, got {_quote(value)}") + ) + return None return base64.b64decode(value, validate=True) @@ -320,91 +357,18 @@ def _format_base64(value: bytes) -> str: return base64.b64encode(value).decode("ascii") -def _parse_base64url(value: typing.Any) -> bytes: - if isinstance(value, bytes): - return value - if not isinstance(value, str) or _BASE64URL_RE.match(value) is None: - raise ValueError(f"must be base64url-encoded, got {value!r}") +def _parse_base64url( + value: str, path: str, violations: list[Violation] +) -> bytes | None: + if _BASE64URL_RE.match(value) is None: + violations.append( + Violation( + path=path, reason=f"must be base64url-encoded, got {_quote(value)}" + ) + ) + return None return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4)) def _format_base64url(value: bytes) -> str: return base64.urlsafe_b64encode(value).rstrip(b"=").decode("ascii") - - -Base64Field: typing.TypeAlias = typing.Annotated[ - bytes, - pydantic.BeforeValidator(_parse_base64), - pydantic.PlainSerializer(_format_base64, return_type=str), -] -Base64UrlField: typing.TypeAlias = typing.Annotated[ - bytes, - pydantic.BeforeValidator(_parse_base64url), - pydantic.PlainSerializer(_format_base64url, return_type=str), -] - - -def _reject_explicit_null( - cls: type[pydantic.BaseModel], - data: object, - handler: typing.Callable[[object], typing.Any], -) -> typing.Any: - null_fields = typing.cast( - frozenset[str], getattr(cls, "_OPTIONAL_NON_NULLABLE_FIELDS") - ) - raw_data = data - pre_errors: list[pydantic_core.InitErrorDetails] = [] - if isinstance(data, dict): - values = typing.cast(dict[str, object], data) - pre_errors = [ - pydantic_core.InitErrorDetails( - type=pydantic_core.PydanticCustomError( - "null_for_nonnullable", "explicit null not allowed" - ), - loc=(field,), - input=None, - ) - for field in null_fields - if field in values and values[field] is None - ] - try: - instance = handler(raw_data) - except pydantic.ValidationError as error: - field_errors: list[pydantic_core.InitErrorDetails] = [] - for error_detail in typing.cast(list[dict[str, object]], error.errors()): - loc: tuple[str | int, ...] = tuple( - typing.cast(collections.abc.Iterable[str | int], error_detail["loc"]) - ) - field_errors.append( - pydantic_core.InitErrorDetails( - type=pydantic_core.PydanticCustomError( - typing.cast(typing.Any, error_detail["type"]), - typing.cast(typing.Any, error_detail["msg"]), - ), - loc=loc, - input=error_detail.get("input"), - ) - ) - raise pydantic.ValidationError.from_exception_data( - title=cls.__name__, line_errors=pre_errors + field_errors - ) from None - if pre_errors: - raise pydantic.ValidationError.from_exception_data( - title=cls.__name__, line_errors=pre_errors - ) - return instance - - -def _emit_set_fields( - model: pydantic.BaseModel, - handler: typing.Callable[[pydantic.BaseModel], typing.Any], -) -> dict[str, object]: - dumped = typing.cast(dict[str, object], handler(model)) - alias_of = { - name: (field.alias or name) for name, field in type(model).model_fields.items() - } - keep = {alias_of.get(name, name) for name in model.model_fields_set} - out = {key: value for key, value in dumped.items() if key in keep} - if model.model_extra: - out.update(typing.cast(dict[str, object], model.model_extra)) - return out diff --git a/samples/python/showcase/models.py b/samples/python/showcase/models.py index 9d49fdc9..dce10b2b 100644 --- a/samples/python/showcase/models.py +++ b/samples/python/showcase/models.py @@ -2,234 +2,533 @@ from __future__ import annotations +import dataclasses import typing import typing_extensions -import pydantic -import pydantic_core +import math +import re +import temporalio.converter from ._definitions import ( - Base64Field, - Base64UrlField, - SpecInt, - _check_format, - _check_multiple_of, - _check_pattern, + ValidationError, + Violation, + _check_contains, _check_unique_items, - _emit_set_fields, - _reject_explicit_null, + _collect, + _format_base64, + _format_base64url, + _parse_base64, + _parse_base64url, + _parse_spec_integer, + _quote, + _transfer_type_convertible, ) -class Address(pydantic.BaseModel): - """A nested object, open to forward-compatible extension.""" +DEFAULT_RETRIES = 3 +DEFAULT_GREETING = "hello" +DEFAULT_DEBUG = False - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - strict=True, populate_by_name=True, extra="allow" - ) - street: str = pydantic.Field() +_PATTERN_CD24623C0C29CA35 = re.compile("^[A-Z]{2,4}\\Z", re.ASCII) +_PATTERN_B4BA2CA20EB1B963 = re.compile( + "^[^\\t\\n\\x0B\\f\\r ]+[\\t\\n\\x0B\\f\\r ][^\\t\\n\\x0B\\f\\r ]+\\Z", re.ASCII +) +_PATTERN_EAAFA3F3BF5456C8 = re.compile( + "^[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}\\Z", + re.ASCII, +) +_PATTERN_67B8088E6C41E9D2 = re.compile( + "^[a-zA-Z0-9!#$%&'*+/=?^_`{|}~-]+(?:\\.[a-zA-Z0-9!#$%&'*+/=?^_`{|}~-]+)*@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+\\Z", + re.ASCII, +) +_PATTERN_C3551EE088DD1057 = re.compile( + "^[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?(?:\\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*\\Z", + re.ASCII, +) +_PATTERN_BECE32B4DA20247D = re.compile( + "^(?:[A-Za-z][A-Za-z0-9+.-]*:(?://(?:(?:[A-Za-z0-9._~!$&'()*+,;=:-]|%[0-9A-Fa-f][0-9A-Fa-f])*@)?(?:(?:\\[(?:([0-9a-fA-F]{1,4}:){6}([0-9a-fA-F]{1,4}:[0-9a-fA-F]{1,4}|((25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])))|::([0-9a-fA-F]{1,4}:){5}([0-9a-fA-F]{1,4}:[0-9a-fA-F]{1,4}|((25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])))|([0-9a-fA-F]{1,4})?::([0-9a-fA-F]{1,4}:){4}([0-9a-fA-F]{1,4}:[0-9a-fA-F]{1,4}|((25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])))|(([0-9a-fA-F]{1,4}:){0,1}[0-9a-fA-F]{1,4})?::([0-9a-fA-F]{1,4}:){3}([0-9a-fA-F]{1,4}:[0-9a-fA-F]{1,4}|((25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])))|(([0-9a-fA-F]{1,4}:){0,2}[0-9a-fA-F]{1,4})?::([0-9a-fA-F]{1,4}:){2}([0-9a-fA-F]{1,4}:[0-9a-fA-F]{1,4}|((25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])))|(([0-9a-fA-F]{1,4}:){0,3}[0-9a-fA-F]{1,4})?::([0-9a-fA-F]{1,4}:)([0-9a-fA-F]{1,4}:[0-9a-fA-F]{1,4}|((25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])))|(([0-9a-fA-F]{1,4}:){0,4}[0-9a-fA-F]{1,4})?::([0-9a-fA-F]{1,4}:[0-9a-fA-F]{1,4}|((25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])))|(([0-9a-fA-F]{1,4}:){0,5}[0-9a-fA-F]{1,4})?::[0-9a-fA-F]{1,4}|(([0-9a-fA-F]{1,4}:){0,6}[0-9a-fA-F]{1,4})?::)\\]|\\[v[0-9A-Fa-f]+\\.[A-Za-z0-9._~!$&'()*+,;=:-]+\\])|(?:[A-Za-z0-9._~!$&'()*+,;=-]|%[0-9A-Fa-f][0-9A-Fa-f])*)(?::[0-9]*)?(?:/(?:[A-Za-z0-9._~!$&'()*+,;=:@-]|%[0-9A-Fa-f][0-9A-Fa-f])*)*|/(?:(?:[A-Za-z0-9._~!$&'()*+,;=:@-]|%[0-9A-Fa-f][0-9A-Fa-f])+(?:/(?:[A-Za-z0-9._~!$&'()*+,;=:@-]|%[0-9A-Fa-f][0-9A-Fa-f])*)*)?|(?:[A-Za-z0-9._~!$&'()*+,;=:@-]|%[0-9A-Fa-f][0-9A-Fa-f])+(?:/(?:[A-Za-z0-9._~!$&'()*+,;=:@-]|%[0-9A-Fa-f][0-9A-Fa-f])*)*)?(?:\\?(?:(?:[A-Za-z0-9._~!$&'()*+,;=:@-]|%[0-9A-Fa-f][0-9A-Fa-f])|[/?])*)?(?:#(?:(?:[A-Za-z0-9._~!$&'()*+,;=:@-]|%[0-9A-Fa-f][0-9A-Fa-f])|[/?])*)?)\\Z", + re.ASCII, +) +_PATTERN_4A45C0D214B9083D = re.compile( + "^(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\Z", + re.ASCII, +) +_PATTERN_F242E3A159C2422C = re.compile("^[a-z]+\\Z", re.ASCII) - city: str | None = pydantic.Field(default=None) - zip: SpecInt | None = pydantic.Field(default=None) +_ADDRESS_DECLARED: frozenset[str] = frozenset({"street", "city", "zip"}) - _OPTIONAL_NON_NULLABLE_FIELDS: typing.ClassVar[frozenset[str]] = frozenset( - {"city", "zip"} - ) - @pydantic.model_validator(mode="wrap") - @classmethod - def _reject_null( - cls, - data: object, - handler: typing.Callable[[object], typing.Any], - ) -> typing.Any: - return _reject_explicit_null(cls, data, handler) +_CIRCLE_DECLARED: frozenset[str] = frozenset({"kind", "radius"}) - @pydantic.model_serializer(mode="wrap") - def _serialize( - self, - handler: typing.Callable[[pydantic.BaseModel], typing.Any], - ) -> dict[str, object]: - return _emit_set_fields(self, handler) +_CONTACT_PY_DECLARED: frozenset[str] = frozenset( + {"email", "shippingStreet", "shippingZip"} +) -class Attributes(pydantic.BaseModel): - """A string map with member-count and key-shape constraints: 1 to 3 entries, each key - at most 8 code points (minProperties/maxProperties/propertyNames on a map-shaped - object). - """ - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - strict=True, populate_by_name=True, extra="allow" +_LINK_NOTE_DECLARED: frozenset[str] = frozenset({"kind", "href"}) + + +_SHOWCASE_AUDIT_DECLARED: frozenset[str] = frozenset({"by"}) + + +_SHOWCASE_DETAIL_OBJECT_DECLARED: frozenset[str] = frozenset({"code", "hint"}) + + +_SHOWCASE_LEDGER_VALUE_DECLARED: frozenset[str] = frozenset({"amount"}) + + +_SHOWCASE_LOCATION_DECLARED: frozenset[str] = frozenset({"city", "geo"}) + + +_SHOWCASE_LOCATION_GEO_DECLARED: frozenset[str] = frozenset({"lat", "lon"}) + + +_SHOWCASE_ROWS_ITEM_DECLARED: frozenset[str] = frozenset({"cell"}) + + +_SQUARE_DECLARED: frozenset[str] = frozenset({"kind", "side"}) + + +_TEXT_NOTE_DECLARED: frozenset[str] = frozenset({"kind", "body"}) + + +_WIDGET_DECLARED: frozenset[str] = frozenset({"id", "kind", "name", "size"}) + + +_WIDGET_BASE_DECLARED: frozenset[str] = frozenset({"id", "kind"}) + + +class _AddressTransferTypeConverter( + temporalio.converter.TransferTypeConverter["Address", typing.Any] +): + @typing_extensions.override + def from_transfer_type( + self, value: typing.Any, type_hint: type["Address"] + ) -> "Address": + violations: list[Violation] = [] + if not isinstance(value, dict): + raise ValidationError([Violation(path="", reason="expected object")]) + raw = typing.cast("dict[str, typing.Any]", value) + + street: str = typing.cast("typing.Any", None) + if "street" not in raw or raw["street"] is None: + violations.append(Violation(path="street", reason="required")) + else: + street_raw = raw["street"] + if not isinstance(street_raw, str): + violations.append(Violation(path="street", reason="expected string")) + else: + street = street_raw + + city: str | None = None + if "city" in raw: + city_raw = raw["city"] + if city_raw is None: + violations.append( + Violation(path="city", reason="explicit null not allowed") + ) + else: + if not isinstance(city_raw, str): + violations.append(Violation(path="city", reason="expected string")) + else: + city = city_raw + + zip: int | None = None + if "zip" in raw: + zip_raw = raw["zip"] + if zip_raw is None: + violations.append( + Violation(path="zip", reason="explicit null not allowed") + ) + else: + zip_parsed = _parse_spec_integer(zip_raw, "zip", violations) + if zip_parsed is not None: + zip = zip_parsed + + additional_properties: dict[str, typing.Any] = {} + for key in raw: + if key not in _ADDRESS_DECLARED: + additional_properties[key] = raw[key] + if violations: + raise ValidationError(violations) + return Address( + street=street, + city=city, + zip=zip, + additional_properties=additional_properties, + ) + + @typing_extensions.override + def to_transfer_type(self, value: "Address") -> typing.Any: + out: dict[str, typing.Any] = {} + out["street"] = value.street + if value.city is not None: + out["city"] = value.city + if value.zip is not None: + out["zip"] = value.zip + for key, entry in value.additional_properties.items(): + out[key] = entry + return out + + +@_transfer_type_convertible(_AddressTransferTypeConverter) +@dataclasses.dataclass(slots=True, kw_only=True) +class Address: + """A nested object, open to forward-compatible extension.""" + + street: str + + city: str | None = None + + zip: int | None = None + + additional_properties: dict[str, typing.Any] = dataclasses.field( + default_factory=dict ) - @pydantic.model_validator(mode="after") - def _validate_extras(self) -> typing.Any: - extra = typing.cast(dict[str, object], self.model_extra or {}) - errors: list[pydantic_core.InitErrorDetails] = [] - for key, value in list(extra.items()): - try: - extra[key] = _ATTRIBUTES_MEMBER.validate_python(value) - except pydantic.ValidationError as error: - for detail in error.errors(): - errors.append( - pydantic_core.InitErrorDetails( - type=pydantic_core.PydanticCustomError( - typing.cast(typing.Any, detail["type"]), - typing.cast(typing.Any, detail["msg"]), - ), - loc=(key, *detail["loc"]), - input=detail["input"], - ) + +class _AttributesTransferTypeConverter( + temporalio.converter.TransferTypeConverter["Attributes", typing.Any] +): + @typing_extensions.override + def from_transfer_type( + self, value: typing.Any, type_hint: type["Attributes"] + ) -> "Attributes": + violations: list[Violation] = [] + if not isinstance(value, dict): + raise ValidationError([Violation(path="", reason="expected object")]) + raw = typing.cast("dict[str, typing.Any]", value) + if len(raw) < 1: + violations.append( + Violation( + path="", reason=f"must have at least 1 properties, got {len(raw)}" + ) + ) + if len(raw) > 3: + violations.append( + Violation( + path="", reason=f"must have at most 3 properties, got {len(raw)}" + ) + ) + for key in raw: + if len(key) > 8: + violations.append( + Violation( + path=key, + reason=f"invalid property name {_quote(key)}: must have length <= 8, got {len(key)}", ) - if len(extra) < 1: - errors.append( - pydantic_core.InitErrorDetails( - type=pydantic_core.PydanticCustomError( - "too_few_properties", - typing.cast( - typing.Any, - f"must have at least 1 properties, got {len(extra)}", - ), - ), - loc=(), - input=len(extra), + ) + additional_properties: dict[str, str] = {} + for key in raw: + member: str = typing.cast("typing.Any", None) + member_raw = raw[key] + if not isinstance(member_raw, str): + violations.append(Violation(path=key, reason="expected string")) + else: + member = member_raw + additional_properties[key] = member + if violations: + raise ValidationError(violations) + return Attributes(additional_properties=additional_properties) + + @typing_extensions.override + def to_transfer_type(self, value: "Attributes") -> typing.Any: + violations: list[Violation] = [] + out: dict[str, typing.Any] = {} + for key, entry in value.additional_properties.items(): + out[key] = entry + if len(out) < 1: + violations.append( + Violation( + path="", reason=f"must have at least 1 properties, got {len(out)}" ) ) - if len(extra) > 3: - errors.append( - pydantic_core.InitErrorDetails( - type=pydantic_core.PydanticCustomError( - "too_many_properties", - typing.cast( - typing.Any, - f"must have at most 3 properties, got {len(extra)}", - ), - ), - loc=(), - input=len(extra), + if len(out) > 3: + violations.append( + Violation( + path="", reason=f"must have at most 3 properties, got {len(out)}" ) ) - for key in extra: + for key in out: if len(key) > 8: - errors.append( - pydantic_core.InitErrorDetails( - type=pydantic_core.PydanticCustomError( - "invalid_property_name", - typing.cast( - typing.Any, - f'invalid property name "{key}": must have length <= 8, got {len(key)}', - ), - ), - loc=(key,), - input=key, - ) - ) - if errors: - raise pydantic.ValidationError.from_exception_data( - title=type(self).__name__, line_errors=errors + violations.append( + Violation( + path=key, + reason=f"invalid property name {_quote(key)}: must have length <= 8, got {len(key)}", + ) + ) + if violations: + raise ValidationError(violations) + return out + + +@_transfer_type_convertible(_AttributesTransferTypeConverter) +@dataclasses.dataclass(slots=True, kw_only=True) +class Attributes: + """A string map with member-count and key-shape constraints: 1 to 3 entries, each key + at most 8 code points (minProperties/maxProperties/propertyNames on a map-shaped + object). + """ + + additional_properties: dict[str, str] = dataclasses.field(default_factory=dict) + + +class _ChoicesTransferTypeConverter( + temporalio.converter.TransferTypeConverter["Choices", typing.Any] +): + @typing_extensions.override + def from_transfer_type( + self, value: typing.Any, type_hint: type["Choices"] + ) -> "Choices": + violations: list[Violation] = [] + if not isinstance(value, dict): + raise ValidationError([Violation(path="", reason="expected object")]) + raw = typing.cast("dict[str, typing.Any]", value) + additional_properties: dict[str, ChoicesValue] = {} + for key in raw: + member: ChoicesValue = typing.cast("typing.Any", None) + member_raw = raw[key] + member_parsed = _choices_value_from_transfer_type( + member_raw, key, violations ) - return self - - @pydantic.model_serializer(mode="wrap") - def _serialize( - self, - _handler: typing.Callable[[pydantic.BaseModel], typing.Any], - ) -> dict[str, object]: - return { - key: _ATTRIBUTES_MEMBER.dump_python(value, mode="json", by_alias=True) - for key, value in typing.cast( - dict[str, object], self.model_extra or {} - ).items() - } - - -class Choices(pydantic.BaseModel): + if member_parsed is not None: + member = member_parsed + additional_properties[key] = member + if violations: + raise ValidationError(violations) + return Choices(additional_properties=additional_properties) + + @typing_extensions.override + def to_transfer_type(self, value: "Choices") -> typing.Any: + out: dict[str, typing.Any] = {} + for key, entry in value.additional_properties.items(): + out[key] = _choices_value_to_transfer_type(entry) + return out + + +@_transfer_type_convertible(_ChoicesTransferTypeConverter) +@dataclasses.dataclass(slots=True, kw_only=True) +class Choices: """A map whose *member* type is a union written inline in `additionalProperties`. Like an element union it has no name of its own, so it is named after its position — `ChoicesValue` — and moved into `$defs`; each member then decodes through that union's selector, with the member key carrying into the violation path. """ - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - strict=True, populate_by_name=True, extra="allow" + additional_properties: dict[str, ChoicesValue] = dataclasses.field( + default_factory=dict ) - @pydantic.model_validator(mode="after") - def _validate_extras(self) -> typing.Any: - extra = typing.cast(dict[str, object], self.model_extra or {}) - errors: list[pydantic_core.InitErrorDetails] = [] - for key, value in list(extra.items()): - try: - extra[key] = _CHOICES_MEMBER.validate_python(value) - except pydantic.ValidationError as error: - for detail in error.errors(): - errors.append( - pydantic_core.InitErrorDetails( - type=pydantic_core.PydanticCustomError( - typing.cast(typing.Any, detail["type"]), - typing.cast(typing.Any, detail["msg"]), - ), - loc=(key, *detail["loc"]), - input=detail["input"], - ) - ) - if errors: - raise pydantic.ValidationError.from_exception_data( - title=type(self).__name__, line_errors=errors - ) - return self - - @pydantic.model_serializer(mode="wrap") - def _serialize( - self, - _handler: typing.Callable[[pydantic.BaseModel], typing.Any], - ) -> dict[str, object]: - return { - key: _CHOICES_MEMBER.dump_python(value, mode="json", by_alias=True) - for key, value in typing.cast( - dict[str, object], self.model_extra or {} - ).items() - } - - -class Circle(pydantic.BaseModel): + +class _CircleTransferTypeConverter( + temporalio.converter.TransferTypeConverter["Circle", typing.Any] +): + @typing_extensions.override + def from_transfer_type( + self, value: typing.Any, type_hint: type["Circle"] + ) -> "Circle": + violations: list[Violation] = [] + if not isinstance(value, dict): + raise ValidationError([Violation(path="", reason="expected object")]) + raw = typing.cast("dict[str, typing.Any]", value) + + kind: typing.Literal["circle"] = typing.cast("typing.Any", None) + if "kind" not in raw or raw["kind"] is None: + violations.append(Violation(path="kind", reason="required")) + else: + kind_raw = raw["kind"] + if not isinstance(kind_raw, str): + violations.append(Violation(path="kind", reason="expected string")) + elif kind_raw != "circle": + violations.append(Violation(path="kind", reason='must equal "circle"')) + else: + kind = kind_raw + + radius: float = typing.cast("typing.Any", None) + if "radius" not in raw or raw["radius"] is None: + violations.append(Violation(path="radius", reason="required")) + else: + radius_raw = raw["radius"] + if not ( + not isinstance(radius_raw, bool) + and isinstance(radius_raw, (int, float)) + ): + violations.append(Violation(path="radius", reason="expected number")) + else: + radius = radius_raw + + additional_properties: dict[str, typing.Any] = {} + for key in raw: + if key not in _CIRCLE_DECLARED: + additional_properties[key] = raw[key] + if violations: + raise ValidationError(violations) + return Circle( + kind=kind, + radius=radius, + additional_properties=additional_properties, + ) + + @typing_extensions.override + def to_transfer_type(self, value: "Circle") -> typing.Any: + violations: list[Violation] = [] + out: dict[str, typing.Any] = {} + if typing.cast("object", value.kind) not in ("circle",): + violations.append(Violation(path="kind", reason='must equal "circle"')) + out["kind"] = value.kind + out["radius"] = value.radius + for key, entry in value.additional_properties.items(): + out[key] = entry + if violations: + raise ValidationError(violations) + return out + + +@_transfer_type_convertible(_CircleTransferTypeConverter) +@dataclasses.dataclass(slots=True, kw_only=True) +class Circle: """A circle branch of the Shape and shapeOrName tagged unions.""" - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - strict=True, populate_by_name=True, extra="allow" - ) + kind: typing.Literal["circle"] = "circle" - kind: typing.Literal["circle"] = pydantic.Field(default="circle") + radius: float - radius: float = pydantic.Field() + additional_properties: dict[str, typing.Any] = dataclasses.field( + default_factory=dict + ) - @pydantic.model_validator(mode="before") - @classmethod - def _inject_const_kind( - cls, - data: object, - ) -> object: - if isinstance(data, dict): - values = typing.cast(dict[str, object], data) - if "kind" not in values: - data = {**values, "kind": "circle"} - elif values.get("kind", values.get("kind")) != "circle": - raise pydantic_core.PydanticCustomError( - "const", 'kind must equal "circle"' - ) - return typing.cast(object, data) - @pydantic.model_serializer(mode="wrap") - def _serialize( - self, - handler: typing.Callable[[pydantic.BaseModel], typing.Any], - ) -> dict[str, object]: - return _emit_set_fields(self, handler) +class _ContactPyTransferTypeConverter( + temporalio.converter.TransferTypeConverter["ContactPy", typing.Any] +): + @typing_extensions.override + def from_transfer_type( + self, value: typing.Any, type_hint: type["ContactPy"] + ) -> "ContactPy": + violations: list[Violation] = [] + if not isinstance(value, dict): + raise ValidationError([Violation(path="", reason="expected object")]) + raw = typing.cast("dict[str, typing.Any]", value) + + email: str | None = None + if "email" in raw: + email_raw = raw["email"] + if email_raw is None: + violations.append( + Violation(path="email", reason="explicit null not allowed") + ) + else: + if not isinstance(email_raw, str): + violations.append(Violation(path="email", reason="expected string")) + else: + email = email_raw + + shipping_street: str | None = None + if "shippingStreet" in raw: + shipping_street_raw = raw["shippingStreet"] + if shipping_street_raw is None: + violations.append( + Violation(path="shippingStreet", reason="explicit null not allowed") + ) + else: + if not isinstance(shipping_street_raw, str): + violations.append( + Violation(path="shippingStreet", reason="expected string") + ) + else: + shipping_street = shipping_street_raw + + shipping_zip: str | None = None + if "shippingZip" in raw: + shipping_zip_raw = raw["shippingZip"] + if shipping_zip_raw is None: + violations.append( + Violation(path="shippingZip", reason="explicit null not allowed") + ) + else: + if not isinstance(shipping_zip_raw, str): + violations.append( + Violation(path="shippingZip", reason="expected string") + ) + else: + shipping_zip = shipping_zip_raw + + additional_properties: dict[str, typing.Any] = {} + for key in raw: + if key not in _CONTACT_PY_DECLARED: + additional_properties[key] = raw[key] + if len(raw) < 1: + violations.append( + Violation( + path="", reason=f"must have at least 1 properties, got {len(raw)}" + ) + ) + if len(raw) > 3: + violations.append( + Violation( + path="", reason=f"must have at most 3 properties, got {len(raw)}" + ) + ) + if "shippingStreet" in raw: + if "shippingZip" not in raw: + violations.append( + Violation( + path="shippingZip", + reason='property "shippingZip" is required when "shippingStreet" is present', + ) + ) + if violations: + raise ValidationError(violations) + return ContactPy( + email=email, + shipping_street=shipping_street, + shipping_zip=shipping_zip, + additional_properties=additional_properties, + ) + + @typing_extensions.override + def to_transfer_type(self, value: "ContactPy") -> typing.Any: + violations: list[Violation] = [] + out: dict[str, typing.Any] = {} + if value.email is not None: + out["email"] = value.email + if value.shipping_street is not None: + out["shippingStreet"] = value.shipping_street + if value.shipping_zip is not None: + out["shippingZip"] = value.shipping_zip + for key, entry in value.additional_properties.items(): + out[key] = entry + if len(out) < 1: + violations.append( + Violation( + path="", reason=f"must have at least 1 properties, got {len(out)}" + ) + ) + if len(out) > 3: + violations.append( + Violation( + path="", reason=f"must have at most 3 properties, got {len(out)}" + ) + ) + if "shippingStreet" in out: + if "shippingZip" not in out: + violations.append( + Violation( + path="shippingZip", + reason='property "shippingZip" is required when "shippingStreet" is present', + ) + ) + if violations: + raise ValidationError(violations) + return out -class ContactPy(pydantic.BaseModel): +@_transfer_type_convertible(_ContactPyTransferTypeConverter) +@dataclasses.dataclass(slots=True, kw_only=True) +class ContactPy: """Contact details with a conditional requirement and a member-count bound: a shipping street requires a shipping zip (dependentRequired), and the object must carry 1 to 3 members (minProperties/maxProperties on a declared-property object). Also exercises @@ -239,353 +538,2126 @@ class ContactPy(pydantic.BaseModel): `$ref`, while the wire `$ref` name stays `Contact`. """ - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - strict=True, populate_by_name=True, extra="allow" + email: str | None = None + + shipping_street: str | None = None + + shipping_zip: str | None = None + + additional_properties: dict[str, typing.Any] = dataclasses.field( + default_factory=dict ) - email: str | None = pydantic.Field(default=None) - - shipping_street: str | None = pydantic.Field(default=None, alias="shippingStreet") - - shipping_zip: str | None = pydantic.Field(default=None, alias="shippingZip") - - @pydantic.model_validator(mode="after") - def _validate_object(self) -> typing.Any: - errors: list[pydantic_core.InitErrorDetails] = [] - present = self.model_fields_set - if len(present) < 1: - errors.append( - pydantic_core.InitErrorDetails( - type=pydantic_core.PydanticCustomError( - "too_few_properties", - typing.cast( - typing.Any, - f"must have at least 1 properties, got {len(present)}", - ), - ), - loc=(), - input=len(present), + +class _ExtrasTransferTypeConverter( + temporalio.converter.TransferTypeConverter["Extras", typing.Any] +): + @typing_extensions.override + def from_transfer_type( + self, value: typing.Any, type_hint: type["Extras"] + ) -> "Extras": + violations: list[Violation] = [] + if not isinstance(value, dict): + raise ValidationError([Violation(path="", reason="expected object")]) + raw = typing.cast("dict[str, typing.Any]", value) + if len(raw) > 4: + violations.append( + Violation( + path="", reason=f"must have at most 4 properties, got {len(raw)}" ) ) - if len(present) > 3: - errors.append( - pydantic_core.InitErrorDetails( - type=pydantic_core.PydanticCustomError( - "too_many_properties", - typing.cast( - typing.Any, - f"must have at most 3 properties, got {len(present)}", - ), - ), - loc=(), - input=len(present), + additional_properties: dict[str, typing.Any] = {} + for key in raw: + additional_properties[key] = raw[key] + if violations: + raise ValidationError(violations) + return Extras(additional_properties=additional_properties) + + @typing_extensions.override + def to_transfer_type(self, value: "Extras") -> typing.Any: + violations: list[Violation] = [] + out: dict[str, typing.Any] = {} + for key, entry in value.additional_properties.items(): + out[key] = entry + if len(out) > 4: + violations.append( + Violation( + path="", reason=f"must have at most 4 properties, got {len(out)}" ) ) - if "shipping_street" in present: - if "shipping_zip" not in present: - errors.append( - pydantic_core.InitErrorDetails( - type=pydantic_core.PydanticCustomError( - "dependent_required", - 'property "shippingZip" is required when "shippingStreet" is present', - ), - loc=("shippingZip",), - input=None, - ) - ) - if errors: - raise pydantic.ValidationError.from_exception_data( - title=type(self).__name__, line_errors=errors - ) - return self + if violations: + raise ValidationError(violations) + return out - _OPTIONAL_NON_NULLABLE_FIELDS: typing.ClassVar[frozenset[str]] = frozenset( - {"email", "shippingStreet", "shippingZip", "shipping_street", "shipping_zip"} - ) - - @pydantic.model_validator(mode="wrap") - @classmethod - def _reject_null( - cls, - data: object, - handler: typing.Callable[[object], typing.Any], - ) -> typing.Any: - return _reject_explicit_null(cls, data, handler) - - @pydantic.model_serializer(mode="wrap") - def _serialize( - self, - handler: typing.Callable[[pydantic.BaseModel], typing.Any], - ) -> dict[str, object]: - return _emit_set_fields(self, handler) - -class Extras(pydantic.BaseModel): +@_transfer_type_convertible(_ExtrasTransferTypeConverter) +@dataclasses.dataclass(slots=True, kw_only=True) +class Extras: """A free-form object (`additionalProperties: true` with no declared properties): every member is carried verbatim, bounded to at most 4 members. Members keep their wire form, so large integers survive a round-trip untruncated. """ - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - strict=True, populate_by_name=True, extra="allow" + additional_properties: dict[str, typing.Any] = dataclasses.field( + default_factory=dict ) - @pydantic.model_validator(mode="after") - def _validate_extras(self) -> typing.Any: - extra = typing.cast(dict[str, object], self.model_extra or {}) - errors: list[pydantic_core.InitErrorDetails] = [] - if len(extra) > 4: - errors.append( - pydantic_core.InitErrorDetails( - type=pydantic_core.PydanticCustomError( - "too_many_properties", - typing.cast( - typing.Any, - f"must have at most 4 properties, got {len(extra)}", - ), - ), - loc=(), - input=len(extra), + +class _LabelsTransferTypeConverter( + temporalio.converter.TransferTypeConverter["Labels", typing.Any] +): + @typing_extensions.override + def from_transfer_type( + self, value: typing.Any, type_hint: type["Labels"] + ) -> "Labels": + violations: list[Violation] = [] + if not isinstance(value, dict): + raise ValidationError([Violation(path="", reason="expected object")]) + raw = typing.cast("dict[str, typing.Any]", value) + if len(raw) > 50: + violations.append( + Violation( + path="", reason=f"must have at most 50 properties, got {len(raw)}" ) ) - if errors: - raise pydantic.ValidationError.from_exception_data( - title=type(self).__name__, line_errors=errors + additional_properties: dict[str, str] = {} + for key in raw: + member: str = typing.cast("typing.Any", None) + member_raw = raw[key] + if not isinstance(member_raw, str): + violations.append(Violation(path=key, reason="expected string")) + else: + member = member_raw + additional_properties[key] = member + if violations: + raise ValidationError(violations) + return Labels(additional_properties=additional_properties) + + @typing_extensions.override + def to_transfer_type(self, value: "Labels") -> typing.Any: + violations: list[Violation] = [] + out: dict[str, typing.Any] = {} + for key, entry in value.additional_properties.items(): + out[key] = entry + if len(out) > 50: + violations.append( + Violation( + path="", reason=f"must have at most 50 properties, got {len(out)}" + ) ) - return self + if violations: + raise ValidationError(violations) + return out - @pydantic.model_serializer(mode="wrap") - def _serialize( - self, - _handler: typing.Callable[[pydantic.BaseModel], typing.Any], - ) -> dict[str, object]: - return dict(typing.cast(dict[str, object], self.model_extra or {})) - -class Labels(pydantic.BaseModel): +@_transfer_type_convertible(_LabelsTransferTypeConverter) +@dataclasses.dataclass(slots=True, kw_only=True) +class Labels: """Arbitrary string key/value labels (typed map).""" - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - strict=True, populate_by_name=True, extra="allow" - ) - - @pydantic.model_validator(mode="after") - def _validate_extras(self) -> typing.Any: - extra = typing.cast(dict[str, object], self.model_extra or {}) - errors: list[pydantic_core.InitErrorDetails] = [] - for key, value in list(extra.items()): - try: - extra[key] = _LABELS_MEMBER.validate_python(value) - except pydantic.ValidationError as error: - for detail in error.errors(): - errors.append( - pydantic_core.InitErrorDetails( - type=pydantic_core.PydanticCustomError( - typing.cast(typing.Any, detail["type"]), - typing.cast(typing.Any, detail["msg"]), - ), - loc=(key, *detail["loc"]), - input=detail["input"], + additional_properties: dict[str, str] = dataclasses.field(default_factory=dict) + + +class _LinkNoteTransferTypeConverter( + temporalio.converter.TransferTypeConverter["LinkNote", typing.Any] +): + @typing_extensions.override + def from_transfer_type( + self, value: typing.Any, type_hint: type["LinkNote"] + ) -> "LinkNote": + violations: list[Violation] = [] + if not isinstance(value, dict): + raise ValidationError([Violation(path="", reason="expected object")]) + raw = typing.cast("dict[str, typing.Any]", value) + + kind: typing.Literal["link"] = typing.cast("typing.Any", None) + if "kind" not in raw or raw["kind"] is None: + violations.append(Violation(path="kind", reason="required")) + else: + kind_raw = raw["kind"] + if not isinstance(kind_raw, str): + violations.append(Violation(path="kind", reason="expected string")) + elif kind_raw != "link": + violations.append(Violation(path="kind", reason='must equal "link"')) + else: + kind = kind_raw + + href: str = typing.cast("typing.Any", None) + if "href" not in raw or raw["href"] is None: + violations.append(Violation(path="href", reason="required")) + else: + href_raw = raw["href"] + if not isinstance(href_raw, str): + violations.append(Violation(path="href", reason="expected string")) + else: + href = href_raw + if len(href_raw) < 1: + violations.append( + Violation( + path="href", + reason=f"must have length >= 1, got {len(href_raw)}", ) ) - if len(extra) > 50: - errors.append( - pydantic_core.InitErrorDetails( - type=pydantic_core.PydanticCustomError( - "too_many_properties", - typing.cast( - typing.Any, - f"must have at most 50 properties, got {len(extra)}", - ), - ), - loc=(), - input=len(extra), + + additional_properties: dict[str, typing.Any] = {} + for key in raw: + if key not in _LINK_NOTE_DECLARED: + additional_properties[key] = raw[key] + if violations: + raise ValidationError(violations) + return LinkNote( + kind=kind, + href=href, + additional_properties=additional_properties, + ) + + @typing_extensions.override + def to_transfer_type(self, value: "LinkNote") -> typing.Any: + violations: list[Violation] = [] + out: dict[str, typing.Any] = {} + if typing.cast("object", value.kind) not in ("link",): + violations.append(Violation(path="kind", reason='must equal "link"')) + out["kind"] = value.kind + if len(value.href) < 1: + violations.append( + Violation( + path="href", reason=f"must have length >= 1, got {len(value.href)}" ) ) - if errors: - raise pydantic.ValidationError.from_exception_data( - title=type(self).__name__, line_errors=errors - ) - return self - - @pydantic.model_serializer(mode="wrap") - def _serialize( - self, - _handler: typing.Callable[[pydantic.BaseModel], typing.Any], - ) -> dict[str, object]: - return { - key: _LABELS_MEMBER.dump_python(value, mode="json", by_alias=True) - for key, value in typing.cast( - dict[str, object], self.model_extra or {} - ).items() - } - - -class LinkNote(pydantic.BaseModel): + out["href"] = value.href + for key, entry in value.additional_properties.items(): + out[key] = entry + if violations: + raise ValidationError(violations) + return out + + +@_transfer_type_convertible(_LinkNoteTransferTypeConverter) +@dataclasses.dataclass(slots=True, kw_only=True) +class LinkNote: """A link note branch, named inline.""" - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - strict=True, populate_by_name=True, extra="allow" - ) + kind: typing.Literal["link"] = "link" - kind: typing.Literal["link"] = pydantic.Field(default="link") + href: str - href: str = pydantic.Field(min_length=1) + additional_properties: dict[str, typing.Any] = dataclasses.field( + default_factory=dict + ) - @pydantic.model_validator(mode="before") - @classmethod - def _inject_const_kind( - cls, - data: object, - ) -> object: - if isinstance(data, dict): - values = typing.cast(dict[str, object], data) - if "kind" not in values: - data = {**values, "kind": "link"} - elif values.get("kind", values.get("kind")) != "link": - raise pydantic_core.PydanticCustomError( - "const", 'kind must equal "link"' - ) - return typing.cast(object, data) - @pydantic.model_serializer(mode="wrap") - def _serialize( - self, - handler: typing.Callable[[pydantic.BaseModel], typing.Any], - ) -> dict[str, object]: - return _emit_set_fields(self, handler) +class _NicknamesTransferTypeConverter( + temporalio.converter.TransferTypeConverter["Nicknames", typing.Any] +): + @typing_extensions.override + def from_transfer_type( + self, value: typing.Any, type_hint: type["Nicknames"] + ) -> "Nicknames": + violations: list[Violation] = [] + if not isinstance(value, dict): + raise ValidationError([Violation(path="", reason="expected object")]) + raw = typing.cast("dict[str, typing.Any]", value) + additional_properties: dict[str, str | None] = {} + for key in raw: + member: str | None = None + member_raw = raw[key] + if member_raw is None: + member = None + else: + if not isinstance(member_raw, str): + violations.append(Violation(path=key, reason="expected string")) + else: + member = member_raw + if len(member_raw) < 2: + violations.append( + Violation( + path=key, + reason=f"must have length >= 2, got {len(member_raw)}", + ) + ) + additional_properties[key] = member + if violations: + raise ValidationError(violations) + return Nicknames(additional_properties=additional_properties) + + @typing_extensions.override + def to_transfer_type(self, value: "Nicknames") -> typing.Any: + violations: list[Violation] = [] + out: dict[str, typing.Any] = {} + for key, entry in value.additional_properties.items(): + if entry is not None: + if len(entry) < 2: + violations.append( + Violation( + path=key, reason=f"must have length >= 2, got {len(entry)}" + ) + ) + out[key] = entry + if violations: + raise ValidationError(violations) + return out -class Nicknames(pydantic.BaseModel): +@_transfer_type_convertible(_NicknamesTransferTypeConverter) +@dataclasses.dataclass(slots=True, kw_only=True) +class Nicknames: """A typed map of **nullable** members: a member may be an explicit null, which is kept as a null member rather than dropped from the map, while a present member still carries its own constraint. """ - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - strict=True, populate_by_name=True, extra="allow" + additional_properties: dict[str, str | None] = dataclasses.field( + default_factory=dict ) - @pydantic.model_validator(mode="after") - def _validate_extras(self) -> typing.Any: - extra = typing.cast(dict[str, object], self.model_extra or {}) - errors: list[pydantic_core.InitErrorDetails] = [] - for key, value in list(extra.items()): - try: - extra[key] = _NICKNAMES_MEMBER.validate_python(value) - except pydantic.ValidationError as error: - for detail in error.errors(): - errors.append( - pydantic_core.InitErrorDetails( - type=pydantic_core.PydanticCustomError( - typing.cast(typing.Any, detail["type"]), - typing.cast(typing.Any, detail["msg"]), - ), - loc=(key, *detail["loc"]), - input=detail["input"], + +class _QuotasTransferTypeConverter( + temporalio.converter.TransferTypeConverter["Quotas", typing.Any] +): + @typing_extensions.override + def from_transfer_type( + self, value: typing.Any, type_hint: type["Quotas"] + ) -> "Quotas": + violations: list[Violation] = [] + if not isinstance(value, dict): + raise ValidationError([Violation(path="", reason="expected object")]) + raw = typing.cast("dict[str, typing.Any]", value) + additional_properties: dict[str, int] = {} + for key in raw: + member: int = typing.cast("typing.Any", None) + member_raw = raw[key] + member_parsed = _parse_spec_integer(member_raw, key, violations) + if member_parsed is not None: + member = member_parsed + if member < 0: + violations.append( + Violation(path=key, reason=f"must be >= 0, got {member}") + ) + if member > 100: + violations.append( + Violation(path=key, reason=f"must be <= 100, got {member}") + ) + if member % 5 != 0: + violations.append( + Violation( + path=key, reason=f"must be a multiple of 5, got {member}" + ) + ) + additional_properties[key] = member + if violations: + raise ValidationError(violations) + return Quotas(additional_properties=additional_properties) + + @typing_extensions.override + def to_transfer_type(self, value: "Quotas") -> typing.Any: + violations: list[Violation] = [] + out: dict[str, typing.Any] = {} + for key, entry in value.additional_properties.items(): + if entry < 0: + violations.append( + Violation(path=key, reason=f"must be >= 0, got {entry}") + ) + if entry > 100: + violations.append( + Violation(path=key, reason=f"must be <= 100, got {entry}") + ) + if entry % 5 != 0: + violations.append( + Violation(path=key, reason=f"must be a multiple of 5, got {entry}") + ) + out[key] = entry + if violations: + raise ValidationError(violations) + return out + + +@_transfer_type_convertible(_QuotasTransferTypeConverter) +@dataclasses.dataclass(slots=True, kw_only=True) +class Quotas: + """A typed map whose members carry their own constraints: every member is a + non-negative multiple of 5, at most 100. A member is held to exactly what a declared + field of that type is held to, in both directions, with the offending member's key + as the violation path. + """ + + additional_properties: dict[str, int] = dataclasses.field(default_factory=dict) + + +class _SettingsTransferTypeConverter( + temporalio.converter.TransferTypeConverter["Settings", typing.Any] +): + @typing_extensions.override + def from_transfer_type( + self, value: typing.Any, type_hint: type["Settings"] + ) -> "Settings": + violations: list[Violation] = [] + if not isinstance(value, dict): + raise ValidationError([Violation(path="", reason="expected object")]) + raw = typing.cast("dict[str, typing.Any]", value) + + theme: str | None = None + if "theme" in raw: + theme_raw = raw["theme"] + if theme_raw is None: + violations.append( + Violation(path="theme", reason="explicit null not allowed") + ) + else: + if not isinstance(theme_raw, str): + violations.append(Violation(path="theme", reason="expected string")) + else: + theme = theme_raw + + font_size: int | None = None + if "fontSize" in raw: + font_size_raw = raw["fontSize"] + if font_size_raw is None: + violations.append( + Violation(path="fontSize", reason="explicit null not allowed") + ) + else: + font_size_parsed = _parse_spec_integer( + font_size_raw, "fontSize", violations + ) + if font_size_parsed is not None: + font_size = font_size_parsed + + for key in raw: + if key != "theme" and key != "fontSize": + violations.append(Violation(path=key, reason="unknown field")) + if violations: + raise ValidationError(violations) + return Settings( + theme=theme, + font_size=font_size, + ) + + @typing_extensions.override + def to_transfer_type(self, value: "Settings") -> typing.Any: + out: dict[str, typing.Any] = {} + if value.theme is not None: + out["theme"] = value.theme + if value.font_size is not None: + out["fontSize"] = value.font_size + return out + + +@_transfer_type_convertible(_SettingsTransferTypeConverter) +@dataclasses.dataclass(slots=True, kw_only=True) +class Settings: + """A closed object; unknown members are rejected.""" + + theme: str | None = None + + font_size: int | None = None + + +class _ShowcaseTransferTypeConverter( + temporalio.converter.TransferTypeConverter["Showcase", typing.Any] +): + @typing_extensions.override + def from_transfer_type( + self, value: typing.Any, type_hint: type["Showcase"] + ) -> "Showcase": + violations: list[Violation] = [] + if not isinstance(value, dict): + raise ValidationError([Violation(path="", reason="expected object")]) + raw = typing.cast("dict[str, typing.Any]", value) + + kind: typing.Literal["showcase"] = typing.cast("typing.Any", None) + if "kind" not in raw or raw["kind"] is None: + violations.append(Violation(path="kind", reason="required")) + else: + kind_raw = raw["kind"] + if not isinstance(kind_raw, str): + violations.append(Violation(path="kind", reason="expected string")) + elif kind_raw != "showcase": + violations.append( + Violation(path="kind", reason='must equal "showcase"') + ) + else: + kind = kind_raw + + revision: typing.Literal[1] = typing.cast("typing.Any", None) + if "revision" not in raw or raw["revision"] is None: + violations.append(Violation(path="revision", reason="required")) + else: + revision_raw = raw["revision"] + if not ( + not isinstance(revision_raw, bool) + and isinstance(revision_raw, (int, float)) + ): + violations.append(Violation(path="revision", reason="expected number")) + elif revision_raw != 1: + violations.append(Violation(path="revision", reason="must equal 1")) + else: + revision = typing.cast("typing.Literal[1]", revision_raw) + + enabled: typing.Literal[True] = typing.cast("typing.Any", None) + if "enabled" not in raw or raw["enabled"] is None: + violations.append(Violation(path="enabled", reason="required")) + else: + enabled_raw = raw["enabled"] + if not isinstance(enabled_raw, bool): + violations.append(Violation(path="enabled", reason="expected boolean")) + elif enabled_raw != True: + violations.append(Violation(path="enabled", reason="must equal true")) + else: + enabled = enabled_raw + + status: typing.Literal["active", "inactive", "pending"] = typing.cast( + "typing.Any", None + ) + if "status" not in raw or raw["status"] is None: + violations.append(Violation(path="status", reason="required")) + else: + status_raw = raw["status"] + if not isinstance(status_raw, str): + violations.append(Violation(path="status", reason="expected string")) + elif ( + status_raw != "active" + and status_raw != "inactive" + and status_raw != "pending" + ): + violations.append( + Violation( + path="status", + reason=f'must be one of ["active", "inactive", "pending"], got {_quote(status_raw)}', + ) + ) + else: + status = status_raw + + tier: typing.Literal[1, 2, 3] = typing.cast("typing.Any", None) + if "tier" not in raw or raw["tier"] is None: + violations.append(Violation(path="tier", reason="required")) + else: + tier_raw = raw["tier"] + if not ( + not isinstance(tier_raw, bool) and isinstance(tier_raw, (int, float)) + ): + violations.append(Violation(path="tier", reason="expected number")) + elif tier_raw != 1 and tier_raw != 2 and tier_raw != 3: + violations.append( + Violation( + path="tier", + reason=f"must be one of [1, 2, 3], got {_quote(tier_raw)}", + ) + ) + else: + tier = typing.cast("typing.Literal[1, 2, 3]", tier_raw) + + scale: float = typing.cast("typing.Any", None) + if "scale" not in raw or raw["scale"] is None: + violations.append(Violation(path="scale", reason="required")) + else: + scale_raw = raw["scale"] + if not ( + not isinstance(scale_raw, bool) and isinstance(scale_raw, (int, float)) + ): + violations.append(Violation(path="scale", reason="expected number")) + elif scale_raw != 1.5 and scale_raw != 2.5: + violations.append( + Violation( + path="scale", + reason=f"must be one of [1.5, 2.5], got {_quote(scale_raw)}", + ) + ) + else: + scale = scale_raw + + name: str = typing.cast("typing.Any", None) + if "name" not in raw or raw["name"] is None: + violations.append(Violation(path="name", reason="required")) + else: + name_raw = raw["name"] + if not isinstance(name_raw, str): + violations.append(Violation(path="name", reason="expected string")) + else: + name = name_raw + if len(name_raw) < 1: + violations.append( + Violation( + path="name", + reason=f"must have length >= 1, got {len(name_raw)}", + ) + ) + if len(name_raw) > 64: + violations.append( + Violation( + path="name", + reason=f"must have length <= 64, got {len(name_raw)}", + ) + ) + + count: int = typing.cast("typing.Any", None) + if "count" not in raw or raw["count"] is None: + violations.append(Violation(path="count", reason="required")) + else: + count_raw = raw["count"] + count_parsed = _parse_spec_integer(count_raw, "count", violations) + if count_parsed is not None: + count = count_parsed + + active: bool = typing.cast("typing.Any", None) + if "active" not in raw or raw["active"] is None: + violations.append(Violation(path="active", reason="required")) + else: + active_raw = raw["active"] + if not isinstance(active_raw, bool): + violations.append(Violation(path="active", reason="expected boolean")) + else: + active = active_raw + + nickname: str | None = None + if "nickname" in raw: + nickname_raw = raw["nickname"] + if nickname_raw is None: + violations.append( + Violation(path="nickname", reason="explicit null not allowed") + ) + else: + if not isinstance(nickname_raw, str): + violations.append( + Violation(path="nickname", reason="expected string") + ) + else: + nickname = nickname_raw + if len(nickname_raw) > 12: + violations.append( + Violation( + path="nickname", + reason=f"must have length <= 12, got {len(nickname_raw)}", + ) + ) + + code: str | None = None + if "code" in raw: + code_raw = raw["code"] + if code_raw is None: + violations.append( + Violation(path="code", reason="explicit null not allowed") + ) + else: + if not isinstance(code_raw, str): + violations.append(Violation(path="code", reason="expected string")) + else: + code = code_raw + if len(code_raw) < 2: + violations.append( + Violation( + path="code", + reason=f"must have length >= 2, got {len(code_raw)}", + ) + ) + if len(code_raw) > 5: + violations.append( + Violation( + path="code", + reason=f"must have length <= 5, got {len(code_raw)}", + ) + ) + + sku: str | None = None + if "sku" in raw: + sku_raw = raw["sku"] + if sku_raw is None: + violations.append( + Violation(path="sku", reason="explicit null not allowed") + ) + else: + if not isinstance(sku_raw, str): + violations.append(Violation(path="sku", reason="expected string")) + else: + sku = sku_raw + if _PATTERN_CD24623C0C29CA35.search(sku_raw) is None: + violations.append( + Violation( + path="sku", + reason=f"must match pattern {_PATTERN_CD24623C0C29CA35.pattern}, got {_quote(sku_raw)}", + ) + ) + + phrase: str | None = None + if "phrase" in raw: + phrase_raw = raw["phrase"] + if phrase_raw is None: + violations.append( + Violation(path="phrase", reason="explicit null not allowed") + ) + else: + if not isinstance(phrase_raw, str): + violations.append( + Violation(path="phrase", reason="expected string") + ) + else: + phrase = phrase_raw + if _PATTERN_B4BA2CA20EB1B963.search(phrase_raw) is None: + violations.append( + Violation( + path="phrase", + reason=f"must match pattern {_PATTERN_B4BA2CA20EB1B963.pattern}, got {_quote(phrase_raw)}", + ) + ) + + request_id: str | None = None + if "requestId" in raw: + request_id_raw = raw["requestId"] + if request_id_raw is None: + violations.append( + Violation(path="requestId", reason="explicit null not allowed") + ) + else: + if not isinstance(request_id_raw, str): + violations.append( + Violation(path="requestId", reason="expected string") + ) + else: + request_id = request_id_raw + if _PATTERN_EAAFA3F3BF5456C8.search(request_id_raw) is None: + violations.append( + Violation( + path="requestId", + reason=f"must be a valid uuid, got {_quote(request_id_raw)}", + ) + ) + + contact_email: str | None = None + if "contactEmail" in raw: + contact_email_raw = raw["contactEmail"] + if contact_email_raw is None: + violations.append( + Violation(path="contactEmail", reason="explicit null not allowed") + ) + else: + if not isinstance(contact_email_raw, str): + violations.append( + Violation(path="contactEmail", reason="expected string") + ) + else: + contact_email = contact_email_raw + if ( + len(contact_email_raw) > 254 + or _PATTERN_67B8088E6C41E9D2.search(contact_email_raw) is None + ): + violations.append( + Violation( + path="contactEmail", + reason=f"must be a valid email, got {_quote(contact_email_raw)}", + ) + ) + + host: str | None = None + if "host" in raw: + host_raw = raw["host"] + if host_raw is None: + violations.append( + Violation(path="host", reason="explicit null not allowed") + ) + else: + if not isinstance(host_raw, str): + violations.append(Violation(path="host", reason="expected string")) + else: + host = host_raw + if ( + len(host_raw) > 253 + or _PATTERN_C3551EE088DD1057.search(host_raw) is None + ): + violations.append( + Violation( + path="host", + reason=f"must be a valid hostname, got {_quote(host_raw)}", + ) + ) + + homepage: str | None = None + if "homepage" in raw: + homepage_raw = raw["homepage"] + if homepage_raw is None: + violations.append( + Violation(path="homepage", reason="explicit null not allowed") + ) + else: + if not isinstance(homepage_raw, str): + violations.append( + Violation(path="homepage", reason="expected string") + ) + else: + homepage = homepage_raw + if _PATTERN_BECE32B4DA20247D.search(homepage_raw) is None: + violations.append( + Violation( + path="homepage", + reason=f"must be a valid uri, got {_quote(homepage_raw)}", + ) + ) + + gateway: str | None = None + if "gateway" in raw: + gateway_raw = raw["gateway"] + if gateway_raw is None: + violations.append( + Violation(path="gateway", reason="explicit null not allowed") + ) + else: + if not isinstance(gateway_raw, str): + violations.append( + Violation(path="gateway", reason="expected string") + ) + else: + gateway = gateway_raw + if _PATTERN_4A45C0D214B9083D.search(gateway_raw) is None: + violations.append( + Violation( + path="gateway", + reason=f"must be a valid ipv4, got {_quote(gateway_raw)}", + ) + ) + + blob: bytes | None = None + if "blob" in raw: + blob_raw = raw["blob"] + if blob_raw is None: + violations.append( + Violation(path="blob", reason="explicit null not allowed") + ) + else: + if not isinstance(blob_raw, str): + violations.append(Violation(path="blob", reason="expected string")) + else: + blob_parsed = _parse_base64(blob_raw, "blob", violations) + if blob_parsed is not None: + blob = blob_parsed + + url_blob: bytes | None = None + if "urlBlob" in raw: + url_blob_raw = raw["urlBlob"] + if url_blob_raw is None: + violations.append( + Violation(path="urlBlob", reason="explicit null not allowed") + ) + else: + if not isinstance(url_blob_raw, str): + violations.append( + Violation(path="urlBlob", reason="expected string") + ) + else: + url_blob_parsed = _parse_base64url( + url_blob_raw, "urlBlob", violations + ) + if url_blob_parsed is not None: + url_blob = url_blob_parsed + + retries: int | None = None + if "retries" in raw: + retries_raw = raw["retries"] + if retries_raw is None: + violations.append( + Violation(path="retries", reason="explicit null not allowed") + ) + else: + retries_parsed = _parse_spec_integer(retries_raw, "retries", violations) + if retries_parsed is not None: + retries = retries_parsed + + verbose: bool | None = None + if "verbose" in raw: + verbose_raw = raw["verbose"] + if verbose_raw is None: + violations.append( + Violation(path="verbose", reason="explicit null not allowed") + ) + else: + if not isinstance(verbose_raw, bool): + violations.append( + Violation(path="verbose", reason="expected boolean") + ) + else: + verbose = verbose_raw + + greeting: str | None = None + if "greeting" in raw: + greeting_raw = raw["greeting"] + if greeting_raw is None: + violations.append( + Violation(path="greeting", reason="explicit null not allowed") + ) + else: + if not isinstance(greeting_raw, str): + violations.append( + Violation(path="greeting", reason="expected string") + ) + else: + greeting = greeting_raw + + debug: bool | None = None + if "debug" in raw: + debug_raw = raw["debug"] + if debug_raw is None: + violations.append( + Violation(path="debug", reason="explicit null not allowed") + ) + else: + if not isinstance(debug_raw, bool): + violations.append( + Violation(path="debug", reason="expected boolean") + ) + else: + debug = debug_raw + + legacy_id_py: str | None = None + if "legacyId" in raw: + legacy_id_py_raw = raw["legacyId"] + if legacy_id_py_raw is None: + violations.append( + Violation(path="legacyId", reason="explicit null not allowed") + ) + else: + if not isinstance(legacy_id_py_raw, str): + violations.append( + Violation(path="legacyId", reason="expected string") + ) + else: + legacy_id_py = legacy_id_py_raw + + middle_name: str | None = None + if "middleName" in raw: + middle_name_raw = raw["middleName"] + if middle_name_raw is None: + middle_name = None + else: + if not isinstance(middle_name_raw, str): + violations.append( + Violation(path="middleName", reason="expected string") + ) + else: + middle_name = middle_name_raw + + category: str | None = None + if "category" not in raw: + violations.append(Violation(path="category", reason="required")) + else: + category_raw = raw["category"] + if category_raw is None: + category = None + else: + if not isinstance(category_raw, str): + violations.append( + Violation(path="category", reason="expected string") + ) + else: + category = category_raw + + priority: int | None = None + if "priority" in raw: + priority_raw = raw["priority"] + if priority_raw is None: + violations.append( + Violation(path="priority", reason="explicit null not allowed") + ) + else: + priority_parsed = _parse_spec_integer( + priority_raw, "priority", violations + ) + if priority_parsed is not None: + priority = priority_parsed + if priority < 1: + violations.append( + Violation( + path="priority", reason=f"must be >= 1, got {priority}" + ) + ) + if priority > 10: + violations.append( + Violation( + path="priority", reason=f"must be <= 10, got {priority}" + ) + ) + + level: int | None = None + if "level" in raw: + level_raw = raw["level"] + if level_raw is None: + violations.append( + Violation(path="level", reason="explicit null not allowed") + ) + else: + level_parsed = _parse_spec_integer(level_raw, "level", violations) + if level_parsed is not None: + level = level_parsed + if level <= 0: + violations.append( + Violation(path="level", reason=f"must be > 0, got {level}") + ) + + ratio: float | None = None + if "ratio" in raw: + ratio_raw = raw["ratio"] + if ratio_raw is None: + violations.append( + Violation(path="ratio", reason="explicit null not allowed") + ) + else: + if not ( + not isinstance(ratio_raw, bool) + and isinstance(ratio_raw, (int, float)) + ): + violations.append(Violation(path="ratio", reason="expected number")) + else: + ratio = ratio_raw + if ratio_raw < 5: + violations.append( + Violation( + path="ratio", reason=f"must be >= 5, got {ratio_raw}" + ) + ) + if math.fmod(ratio_raw, 5) != 0: + violations.append( + Violation( + path="ratio", + reason=f"must be a multiple of 5, got {ratio_raw}", + ) + ) + + step: int | None = None + if "step" in raw: + step_raw = raw["step"] + if step_raw is None: + violations.append( + Violation(path="step", reason="explicit null not allowed") + ) + else: + step_parsed = _parse_spec_integer(step_raw, "step", violations) + if step_parsed is not None: + step = step_parsed + if step % 3 != 0: + violations.append( + Violation( + path="step", + reason=f"must be a multiple of 3, got {step}", + ) + ) + + tags: list[str] | None = None + if "tags" in raw: + tags_raw = raw["tags"] + if tags_raw is None: + violations.append( + Violation(path="tags", reason="explicit null not allowed") + ) + else: + if not isinstance(tags_raw, list): + violations.append(Violation(path="tags", reason="expected array")) + else: + tags_list: list[str] = [] + for tags_index, tags_element in enumerate( + typing.cast("list[typing.Any]", tags_raw) + ): + tags_item_path = f"tags[{tags_index}]" + tags_item: str = typing.cast("typing.Any", None) + if not isinstance(tags_element, str): + violations.append( + Violation( + path=tags_item_path, reason="expected element" + ) + ) + else: + tags_item = tags_element + tags_list.append(tags_item) + if len(tags_list) < 1: + violations.append( + Violation( + path="tags", + reason=f"must have at least 1 items, got {len(tags_list)}", + ) + ) + if len(tags_list) > 5: + violations.append( + Violation( + path="tags", + reason=f"must have at most 5 items, got {len(tags_list)}", + ) + ) + tags = tags_list + + aliases: list[str] | None = None + if "aliases" in raw: + aliases_raw = raw["aliases"] + if aliases_raw is None: + violations.append( + Violation(path="aliases", reason="explicit null not allowed") + ) + else: + if not isinstance(aliases_raw, list): + violations.append( + Violation(path="aliases", reason="expected array") + ) + else: + aliases_list: list[str] = [] + for aliases_index, aliases_element in enumerate( + typing.cast("list[typing.Any]", aliases_raw) + ): + aliases_item_path = f"aliases[{aliases_index}]" + aliases_item: str = typing.cast("typing.Any", None) + if not isinstance(aliases_element, str): + violations.append( + Violation( + path=aliases_item_path, reason="expected element" + ) + ) + else: + aliases_item = aliases_element + aliases_list.append(aliases_item) + _check_unique_items(aliases_list, "aliases", violations) + aliases = aliases_list + + roles: list[str] | None = None + if "roles" in raw: + roles_raw = raw["roles"] + if roles_raw is None: + violations.append( + Violation(path="roles", reason="explicit null not allowed") + ) + else: + if not isinstance(roles_raw, list): + violations.append(Violation(path="roles", reason="expected array")) + else: + roles_list: list[str] = [] + for roles_index, roles_element in enumerate( + typing.cast("list[typing.Any]", roles_raw) + ): + roles_item_path = f"roles[{roles_index}]" + roles_item: str = typing.cast("typing.Any", None) + if not isinstance(roles_element, str): + violations.append( + Violation( + path=roles_item_path, reason="expected element" + ) + ) + else: + roles_item = roles_element + roles_list.append(roles_item) + _check_contains( + roles_list, + lambda element: element == "admin", + 1, + 2, + True, + "roles", + violations, + ) + roles = roles_list + + id_or_name: str | int | None = None + if "idOrName" in raw: + id_or_name_raw = raw["idOrName"] + if id_or_name_raw is None: + violations.append( + Violation(path="idOrName", reason="explicit null not allowed") + ) + else: + id_or_name_parsed = _showcase_id_or_name_from_transfer_type( + id_or_name_raw, "idOrName", violations + ) + if id_or_name_parsed is not None: + id_or_name = id_or_name_parsed + + mode: typing.Literal["auto", "manual"] | int | None = None + if "mode" in raw: + mode_raw = raw["mode"] + if mode_raw is None: + violations.append( + Violation(path="mode", reason="explicit null not allowed") + ) + else: + mode_parsed = _showcase_mode_from_transfer_type( + mode_raw, "mode", violations + ) + if mode_parsed is not None: + mode = mode_parsed + + payload: dict[str, typing.Any] | str | None = None + if "payload" in raw: + payload_raw = raw["payload"] + if payload_raw is None: + violations.append( + Violation(path="payload", reason="explicit null not allowed") + ) + else: + payload_parsed = _showcase_payload_from_transfer_type( + payload_raw, "payload", violations + ) + if payload_parsed is not None: + payload = payload_parsed + + detail: ShowcaseDetailObject | str | None = None + if "detail" in raw: + detail_raw = raw["detail"] + if detail_raw is None: + violations.append( + Violation(path="detail", reason="explicit null not allowed") + ) + else: + detail_parsed = _showcase_detail_from_transfer_type( + detail_raw, "detail", violations + ) + if detail_parsed is not None: + detail = detail_parsed + + shape_or_name: Circle | Square | str | None = None + if "shapeOrName" in raw: + shape_or_name_raw = raw["shapeOrName"] + if shape_or_name_raw is None: + violations.append( + Violation(path="shapeOrName", reason="explicit null not allowed") + ) + else: + shape_or_name_parsed = _showcase_shape_or_name_from_transfer_type( + shape_or_name_raw, "shapeOrName", violations + ) + if shape_or_name_parsed is not None: + shape_or_name = shape_or_name_parsed + + measurements: list[float] | str | None = None + if "measurements" in raw: + measurements_raw = raw["measurements"] + if measurements_raw is None: + violations.append( + Violation(path="measurements", reason="explicit null not allowed") + ) + else: + measurements_parsed = _showcase_measurements_from_transfer_type( + measurements_raw, "measurements", violations + ) + if measurements_parsed is not None: + measurements = measurements_parsed + + shapes: list[Shape] | None = None + if "shapes" in raw: + shapes_raw = raw["shapes"] + if shapes_raw is None: + violations.append( + Violation(path="shapes", reason="explicit null not allowed") + ) + else: + if not isinstance(shapes_raw, list): + violations.append(Violation(path="shapes", reason="expected array")) + else: + shapes_list: list[Shape] = [] + for shapes_index, shapes_element in enumerate( + typing.cast("list[typing.Any]", shapes_raw) + ): + shapes_item_path = f"shapes[{shapes_index}]" + shapes_item: Shape = typing.cast("typing.Any", None) + shapes_item_parsed = _shape_from_transfer_type( + shapes_element, shapes_item_path, violations + ) + if shapes_item_parsed is not None: + shapes_item = shapes_item_parsed + shapes_list.append(shapes_item) + shapes = shapes_list + + segments: list[ShowcaseSegmentsItem] | None = None + if "segments" in raw: + segments_raw = raw["segments"] + if segments_raw is None: + violations.append( + Violation(path="segments", reason="explicit null not allowed") + ) + else: + if not isinstance(segments_raw, list): + violations.append( + Violation(path="segments", reason="expected array") + ) + else: + segments_list: list[ShowcaseSegmentsItem] = [] + for segments_index, segments_element in enumerate( + typing.cast("list[typing.Any]", segments_raw) + ): + segments_item_path = f"segments[{segments_index}]" + segments_item: ShowcaseSegmentsItem = typing.cast( + "typing.Any", None + ) + segments_item_parsed = ( + _showcase_segments_item_from_transfer_type( + segments_element, segments_item_path, violations + ) + ) + if segments_item_parsed is not None: + segments_item = segments_item_parsed + segments_list.append(segments_item) + segments = segments_list + + slots: list[str | None] | None = None + if "slots" in raw: + slots_raw = raw["slots"] + if slots_raw is None: + violations.append( + Violation(path="slots", reason="explicit null not allowed") + ) + else: + if not isinstance(slots_raw, list): + violations.append(Violation(path="slots", reason="expected array")) + else: + slots_list: list[str | None] = [] + for slots_index, slots_element in enumerate( + typing.cast("list[typing.Any]", slots_raw) + ): + slots_item_path = f"slots[{slots_index}]" + slots_item: str | None = None + if slots_element is None: + slots_item = None + else: + if not isinstance(slots_element, str): + violations.append( + Violation( + path=slots_item_path, reason="expected string" + ) + ) + else: + slots_item = slots_element + slots_list.append(slots_item) + slots = slots_list + + grid: list[list[int]] | None = None + if "grid" in raw: + grid_raw = raw["grid"] + if grid_raw is None: + violations.append( + Violation(path="grid", reason="explicit null not allowed") + ) + else: + if not isinstance(grid_raw, list): + violations.append(Violation(path="grid", reason="expected array")) + else: + grid_list: list[list[int]] = [] + for grid_index, grid_element in enumerate( + typing.cast("list[typing.Any]", grid_raw) + ): + grid_item_path = f"grid[{grid_index}]" + grid_item: list[int] = typing.cast("typing.Any", None) + if not isinstance(grid_element, list): + violations.append( + Violation(path=grid_item_path, reason="expected array") + ) + else: + grid_item_list: list[int] = [] + for grid_item_index, grid_item_element in enumerate( + typing.cast("list[typing.Any]", grid_element) + ): + grid_item_item_path = ( + f"{grid_item_path}[{grid_item_index}]" + ) + grid_item_item: int = typing.cast("typing.Any", None) + grid_item_item_parsed = _parse_spec_integer( + grid_item_element, grid_item_item_path, violations + ) + if grid_item_item_parsed is not None: + grid_item_item = grid_item_item_parsed + grid_item_list.append(grid_item_item) + grid_item = grid_item_list + grid_list.append(grid_item) + grid = grid_list + + location: ShowcaseLocation | None = None + if "location" in raw: + location_raw = raw["location"] + if location_raw is None: + violations.append( + Violation(path="location", reason="explicit null not allowed") + ) + else: + try: + location = ( + _ShowcaseLocationTransferTypeConverter().from_transfer_type( + location_raw, ShowcaseLocation + ) + ) + except ValidationError as error: + _collect(violations, "location", error) + + audit: ShowcaseAudit | None = None + if "audit" in raw: + audit_raw = raw["audit"] + if audit_raw is None: + audit = None + else: + try: + audit = _ShowcaseAuditTransferTypeConverter().from_transfer_type( + audit_raw, ShowcaseAudit + ) + except ValidationError as error: + _collect(violations, "audit", error) + + rows: list[ShowcaseRowsItem] | None = None + if "rows" in raw: + rows_raw = raw["rows"] + if rows_raw is None: + violations.append( + Violation(path="rows", reason="explicit null not allowed") + ) + else: + if not isinstance(rows_raw, list): + violations.append(Violation(path="rows", reason="expected array")) + else: + rows_list: list[ShowcaseRowsItem] = [] + for rows_index, rows_element in enumerate( + typing.cast("list[typing.Any]", rows_raw) + ): + rows_item_path = f"rows[{rows_index}]" + rows_item: ShowcaseRowsItem = typing.cast("typing.Any", None) + try: + rows_item = _ShowcaseRowsItemTransferTypeConverter().from_transfer_type( + rows_element, ShowcaseRowsItem + ) + except ValidationError as error: + _collect(violations, rows_item_path, error) + rows_list.append(rows_item) + rows = rows_list + + ledger_py: ShowcaseLedger | None = None + if "ledger" in raw: + ledger_py_raw = raw["ledger"] + if ledger_py_raw is None: + violations.append( + Violation(path="ledger", reason="explicit null not allowed") + ) + else: + try: + ledger_py = ( + _ShowcaseLedgerTransferTypeConverter().from_transfer_type( + ledger_py_raw, ShowcaseLedger + ) + ) + except ValidationError as error: + _collect(violations, "ledger", error) + + metadata: ShowcaseMetadata | None = None + if "metadata" in raw: + metadata_raw = raw["metadata"] + if metadata_raw is None: + violations.append( + Violation(path="metadata", reason="explicit null not allowed") + ) + else: + try: + metadata = ( + _ShowcaseMetadataTransferTypeConverter().from_transfer_type( + metadata_raw, ShowcaseMetadata + ) + ) + except ValidationError as error: + _collect(violations, "metadata", error) + + quotas: Quotas | None = None + if "quotas" in raw: + quotas_raw = raw["quotas"] + if quotas_raw is None: + violations.append( + Violation(path="quotas", reason="explicit null not allowed") + ) + else: + try: + quotas = _QuotasTransferTypeConverter().from_transfer_type( + quotas_raw, Quotas + ) + except ValidationError as error: + _collect(violations, "quotas", error) + + tokens: Tokens | None = None + if "tokens" in raw: + tokens_raw = raw["tokens"] + if tokens_raw is None: + violations.append( + Violation(path="tokens", reason="explicit null not allowed") + ) + else: + try: + tokens = _TokensTransferTypeConverter().from_transfer_type( + tokens_raw, Tokens + ) + except ValidationError as error: + _collect(violations, "tokens", error) + + nicknames: Nicknames | None = None + if "nicknames" in raw: + nicknames_raw = raw["nicknames"] + if nicknames_raw is None: + violations.append( + Violation(path="nicknames", reason="explicit null not allowed") + ) + else: + try: + nicknames = _NicknamesTransferTypeConverter().from_transfer_type( + nicknames_raw, Nicknames + ) + except ValidationError as error: + _collect(violations, "nicknames", error) + + choices: Choices | None = None + if "choices" in raw: + choices_raw = raw["choices"] + if choices_raw is None: + violations.append( + Violation(path="choices", reason="explicit null not allowed") + ) + else: + try: + choices = _ChoicesTransferTypeConverter().from_transfer_type( + choices_raw, Choices + ) + except ValidationError as error: + _collect(violations, "choices", error) + + extras: Extras | None = None + if "extras" in raw: + extras_raw = raw["extras"] + if extras_raw is None: + violations.append( + Violation(path="extras", reason="explicit null not allowed") + ) + else: + try: + extras = _ExtrasTransferTypeConverter().from_transfer_type( + extras_raw, Extras + ) + except ValidationError as error: + _collect(violations, "extras", error) + + shape: Shape | None = None + if "shape" in raw: + shape_raw = raw["shape"] + if shape_raw is None: + violations.append( + Violation(path="shape", reason="explicit null not allowed") + ) + else: + shape_parsed = _shape_from_transfer_type(shape_raw, "shape", violations) + if shape_parsed is not None: + shape = shape_parsed + + note: Note | None = None + if "note" in raw: + note_raw = raw["note"] + if note_raw is None: + violations.append( + Violation(path="note", reason="explicit null not allowed") + ) + else: + note_parsed = _note_from_transfer_type(note_raw, "note", violations) + if note_parsed is not None: + note = note_parsed + + address: Address | None = None + if "address" in raw: + address_raw = raw["address"] + if address_raw is None: + violations.append( + Violation(path="address", reason="explicit null not allowed") + ) + else: + try: + address = _AddressTransferTypeConverter().from_transfer_type( + address_raw, Address + ) + except ValidationError as error: + _collect(violations, "address", error) + + labels: Labels | None = None + if "labels" in raw: + labels_raw = raw["labels"] + if labels_raw is None: + violations.append( + Violation(path="labels", reason="explicit null not allowed") + ) + else: + try: + labels = _LabelsTransferTypeConverter().from_transfer_type( + labels_raw, Labels + ) + except ValidationError as error: + _collect(violations, "labels", error) + + settings: Settings | None = None + if "settings" in raw: + settings_raw = raw["settings"] + if settings_raw is None: + violations.append( + Violation(path="settings", reason="explicit null not allowed") + ) + else: + try: + settings = _SettingsTransferTypeConverter().from_transfer_type( + settings_raw, Settings + ) + except ValidationError as error: + _collect(violations, "settings", error) + + attributes: Attributes | None = None + if "attributes" in raw: + attributes_raw = raw["attributes"] + if attributes_raw is None: + violations.append( + Violation(path="attributes", reason="explicit null not allowed") + ) + else: + try: + attributes = _AttributesTransferTypeConverter().from_transfer_type( + attributes_raw, Attributes + ) + except ValidationError as error: + _collect(violations, "attributes", error) + + contact: ContactPy | None = None + if "contact" in raw: + contact_raw = raw["contact"] + if contact_raw is None: + violations.append( + Violation(path="contact", reason="explicit null not allowed") + ) + else: + try: + contact = _ContactPyTransferTypeConverter().from_transfer_type( + contact_raw, ContactPy + ) + except ValidationError as error: + _collect(violations, "contact", error) + + for key in raw: + if ( + key != "kind" + and key != "revision" + and key != "enabled" + and key != "status" + and key != "tier" + and key != "scale" + and key != "name" + and key != "count" + and key != "active" + and key != "nickname" + and key != "code" + and key != "sku" + and key != "phrase" + and key != "requestId" + and key != "contactEmail" + and key != "host" + and key != "homepage" + and key != "gateway" + and key != "blob" + and key != "urlBlob" + and key != "retries" + and key != "verbose" + and key != "greeting" + and key != "debug" + and key != "legacyId" + and key != "middleName" + and key != "category" + and key != "priority" + and key != "level" + and key != "ratio" + and key != "step" + and key != "tags" + and key != "aliases" + and key != "roles" + and key != "idOrName" + and key != "mode" + and key != "payload" + and key != "detail" + and key != "shapeOrName" + and key != "measurements" + and key != "shapes" + and key != "segments" + and key != "slots" + and key != "grid" + and key != "location" + and key != "audit" + and key != "rows" + and key != "ledger" + and key != "metadata" + and key != "quotas" + and key != "tokens" + and key != "nicknames" + and key != "choices" + and key != "extras" + and key != "shape" + and key != "note" + and key != "address" + and key != "labels" + and key != "settings" + and key != "attributes" + and key != "contact" + ): + violations.append(Violation(path=key, reason="unknown field")) + if violations: + raise ValidationError(violations) + return Showcase( + kind=kind, + revision=revision, + enabled=enabled, + status=status, + tier=tier, + scale=scale, + name=name, + count=count, + active=active, + nickname=nickname, + code=code, + sku=sku, + phrase=phrase, + request_id=request_id, + contact_email=contact_email, + host=host, + homepage=homepage, + gateway=gateway, + blob=blob, + url_blob=url_blob, + retries=retries, + verbose=verbose, + greeting=greeting, + debug=debug, + legacy_id_py=legacy_id_py, + middle_name=middle_name, + category=category, + priority=priority, + level=level, + ratio=ratio, + step=step, + tags=tags, + aliases=aliases, + roles=roles, + id_or_name=id_or_name, + mode=mode, + payload=payload, + detail=detail, + shape_or_name=shape_or_name, + measurements=measurements, + shapes=shapes, + segments=segments, + slots=slots, + grid=grid, + location=location, + audit=audit, + rows=rows, + ledger_py=ledger_py, + metadata=metadata, + quotas=quotas, + tokens=tokens, + nicknames=nicknames, + choices=choices, + extras=extras, + shape=shape, + note=note, + address=address, + labels=labels, + settings=settings, + attributes=attributes, + contact=contact, + ) + + @typing_extensions.override + def to_transfer_type(self, value: "Showcase") -> typing.Any: + violations: list[Violation] = [] + out: dict[str, typing.Any] = {} + if typing.cast("object", value.kind) not in ("showcase",): + violations.append(Violation(path="kind", reason='must equal "showcase"')) + out["kind"] = value.kind + if typing.cast("object", value.revision) not in (1,): + violations.append(Violation(path="revision", reason="must equal 1")) + out["revision"] = value.revision + if typing.cast("object", value.enabled) not in (True,): + violations.append(Violation(path="enabled", reason="must equal true")) + out["enabled"] = value.enabled + if typing.cast("object", value.status) not in ( + "active", + "inactive", + "pending", + ): + violations.append( + Violation( + path="status", + reason=f'must be one of ["active", "inactive", "pending"], got {_quote(value.status)}', + ) + ) + out["status"] = value.status + if typing.cast("object", value.tier) not in ( + 1, + 2, + 3, + ): + violations.append( + Violation( + path="tier", + reason=f"must be one of [1, 2, 3], got {_quote(value.tier)}", + ) + ) + out["tier"] = value.tier + if typing.cast("object", value.scale) not in ( + 1.5, + 2.5, + ): + violations.append( + Violation( + path="scale", + reason=f"must be one of [1.5, 2.5], got {_quote(value.scale)}", + ) + ) + out["scale"] = value.scale + if len(value.name) < 1: + violations.append( + Violation( + path="name", reason=f"must have length >= 1, got {len(value.name)}" + ) + ) + if len(value.name) > 64: + violations.append( + Violation( + path="name", reason=f"must have length <= 64, got {len(value.name)}" + ) + ) + out["name"] = value.name + out["count"] = value.count + out["active"] = value.active + if value.nickname is not None: + if len(value.nickname) > 12: + violations.append( + Violation( + path="nickname", + reason=f"must have length <= 12, got {len(value.nickname)}", + ) + ) + out["nickname"] = value.nickname + if value.code is not None: + if len(value.code) < 2: + violations.append( + Violation( + path="code", + reason=f"must have length >= 2, got {len(value.code)}", + ) + ) + if len(value.code) > 5: + violations.append( + Violation( + path="code", + reason=f"must have length <= 5, got {len(value.code)}", + ) + ) + out["code"] = value.code + if value.sku is not None: + if _PATTERN_CD24623C0C29CA35.search(value.sku) is None: + violations.append( + Violation( + path="sku", + reason=f"must match pattern {_PATTERN_CD24623C0C29CA35.pattern}, got {_quote(value.sku)}", + ) + ) + out["sku"] = value.sku + if value.phrase is not None: + if _PATTERN_B4BA2CA20EB1B963.search(value.phrase) is None: + violations.append( + Violation( + path="phrase", + reason=f"must match pattern {_PATTERN_B4BA2CA20EB1B963.pattern}, got {_quote(value.phrase)}", + ) + ) + out["phrase"] = value.phrase + if value.request_id is not None: + if _PATTERN_EAAFA3F3BF5456C8.search(value.request_id) is None: + violations.append( + Violation( + path="requestId", + reason=f"must be a valid uuid, got {_quote(value.request_id)}", + ) + ) + out["requestId"] = value.request_id + if value.contact_email is not None: + if ( + len(value.contact_email) > 254 + or _PATTERN_67B8088E6C41E9D2.search(value.contact_email) is None + ): + violations.append( + Violation( + path="contactEmail", + reason=f"must be a valid email, got {_quote(value.contact_email)}", + ) + ) + out["contactEmail"] = value.contact_email + if value.host is not None: + if ( + len(value.host) > 253 + or _PATTERN_C3551EE088DD1057.search(value.host) is None + ): + violations.append( + Violation( + path="host", + reason=f"must be a valid hostname, got {_quote(value.host)}", + ) + ) + out["host"] = value.host + if value.homepage is not None: + if _PATTERN_BECE32B4DA20247D.search(value.homepage) is None: + violations.append( + Violation( + path="homepage", + reason=f"must be a valid uri, got {_quote(value.homepage)}", + ) + ) + out["homepage"] = value.homepage + if value.gateway is not None: + if _PATTERN_4A45C0D214B9083D.search(value.gateway) is None: + violations.append( + Violation( + path="gateway", + reason=f"must be a valid ipv4, got {_quote(value.gateway)}", + ) + ) + out["gateway"] = value.gateway + if value.blob is not None: + out["blob"] = _format_base64(value.blob) + if value.url_blob is not None: + out["urlBlob"] = _format_base64url(value.url_blob) + if value.retries is not None: + out["retries"] = value.retries + if value.verbose is not None: + out["verbose"] = value.verbose + if value.greeting is not None: + out["greeting"] = value.greeting + if value.debug is not None: + out["debug"] = value.debug + if value.legacy_id_py is not None: + out["legacyId"] = value.legacy_id_py + if value.middle_name is not None: + out["middleName"] = value.middle_name + out["category"] = value.category + if value.priority is not None: + if value.priority < 1: + violations.append( + Violation( + path="priority", reason=f"must be >= 1, got {value.priority}" + ) + ) + if value.priority > 10: + violations.append( + Violation( + path="priority", reason=f"must be <= 10, got {value.priority}" + ) + ) + out["priority"] = value.priority + if value.level is not None: + if value.level <= 0: + violations.append( + Violation(path="level", reason=f"must be > 0, got {value.level}") + ) + out["level"] = value.level + if value.ratio is not None: + if value.ratio < 5: + violations.append( + Violation(path="ratio", reason=f"must be >= 5, got {value.ratio}") + ) + if math.fmod(value.ratio, 5) != 0: + violations.append( + Violation( + path="ratio", + reason=f"must be a multiple of 5, got {value.ratio}", + ) + ) + out["ratio"] = value.ratio + if value.step is not None: + if value.step % 3 != 0: + violations.append( + Violation( + path="step", reason=f"must be a multiple of 3, got {value.step}" + ) + ) + out["step"] = value.step + if value.tags is not None: + if len(value.tags) < 1: + violations.append( + Violation( + path="tags", + reason=f"must have at least 1 items, got {len(value.tags)}", + ) + ) + if len(value.tags) > 5: + violations.append( + Violation( + path="tags", + reason=f"must have at most 5 items, got {len(value.tags)}", + ) + ) + out["tags"] = value.tags + if value.aliases is not None: + _check_unique_items(value.aliases, "aliases", violations) + out["aliases"] = value.aliases + if value.roles is not None: + _check_contains( + value.roles, + lambda element: element == "admin", + 1, + 2, + True, + "roles", + violations, + ) + out["roles"] = value.roles + if value.id_or_name is not None: + if isinstance(value.id_or_name, str): + if len(value.id_or_name) < 3: + violations.append( + Violation( + path="idOrName", + reason=f"must have length >= 3, got {len(value.id_or_name)}", + ) + ) + if not isinstance(value.id_or_name, bool) and isinstance( + value.id_or_name, int + ): + if value.id_or_name < 1: + violations.append( + Violation( + path="idOrName", + reason=f"must be >= 1, got {value.id_or_name}", + ) + ) + out["idOrName"] = value.id_or_name + if value.mode is not None: + if isinstance(value.mode, str): + if typing.cast("object", value.mode) not in ( + "auto", + "manual", + ): + violations.append( + Violation( + path="mode", + reason=f'must be one of ["auto", "manual"], got {_quote(value.mode)}', ) ) - if errors: - raise pydantic.ValidationError.from_exception_data( - title=type(self).__name__, line_errors=errors + if not isinstance(value.mode, bool) and isinstance(value.mode, int): + if value.mode < 0: + violations.append( + Violation(path="mode", reason=f"must be >= 0, got {value.mode}") + ) + out["mode"] = value.mode + if value.payload is not None: + out["payload"] = value.payload + if value.detail is not None: + out["detail"] = _showcase_detail_to_transfer_type(value.detail) + if value.shape_or_name is not None: + if isinstance(value.shape_or_name, str): + if len(value.shape_or_name) > 32: + violations.append( + Violation( + path="shapeOrName", + reason=f"must have length <= 32, got {len(value.shape_or_name)}", + ) + ) + out["shapeOrName"] = _showcase_shape_or_name_to_transfer_type( + value.shape_or_name ) - return self - - @pydantic.model_serializer(mode="wrap") - def _serialize( - self, - _handler: typing.Callable[[pydantic.BaseModel], typing.Any], - ) -> dict[str, object]: - return { - key: _NICKNAMES_MEMBER.dump_python(value, mode="json", by_alias=True) - for key, value in typing.cast( - dict[str, object], self.model_extra or {} - ).items() - } - - -class Quotas(pydantic.BaseModel): - """A typed map whose members carry their own constraints: every member is a - non-negative multiple of 5, at most 100. A member is held to exactly what a declared - field of that type is held to, in both directions, with the offending member's key - as the violation path. - """ - - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - strict=True, populate_by_name=True, extra="allow" - ) - - @pydantic.model_validator(mode="after") - def _validate_extras(self) -> typing.Any: - extra = typing.cast(dict[str, object], self.model_extra or {}) - errors: list[pydantic_core.InitErrorDetails] = [] - for key, value in list(extra.items()): - try: - extra[key] = _QUOTAS_MEMBER.validate_python(value) - except pydantic.ValidationError as error: - for detail in error.errors(): - errors.append( - pydantic_core.InitErrorDetails( - type=pydantic_core.PydanticCustomError( - typing.cast(typing.Any, detail["type"]), - typing.cast(typing.Any, detail["msg"]), - ), - loc=(key, *detail["loc"]), - input=detail["input"], + if value.measurements is not None: + if isinstance(value.measurements, list): + if len(value.measurements) < 1: + violations.append( + Violation( + path="measurements", + reason=f"must have at least 1 items, got {len(value.measurements)}", ) ) - if errors: - raise pydantic.ValidationError.from_exception_data( - title=type(self).__name__, line_errors=errors + _check_unique_items(value.measurements, "measurements", violations) + if isinstance(value.measurements, str): + if _PATTERN_F242E3A159C2422C.search(value.measurements) is None: + violations.append( + Violation( + path="measurements", + reason=f"must match pattern {_PATTERN_F242E3A159C2422C.pattern}, got {_quote(value.measurements)}", + ) + ) + out["measurements"] = value.measurements + if value.shapes is not None: + out["shapes"] = [ + _shape_to_transfer_type(element) for element in value.shapes + ] + if value.segments is not None: + out["segments"] = [ + _showcase_segments_item_to_transfer_type(element) + for element in value.segments + ] + if value.slots is not None: + out["slots"] = value.slots + if value.grid is not None: + out["grid"] = value.grid + if value.location is not None: + out["location"] = _ShowcaseLocationTransferTypeConverter().to_transfer_type( + value.location ) - return self - - @pydantic.model_serializer(mode="wrap") - def _serialize( - self, - _handler: typing.Callable[[pydantic.BaseModel], typing.Any], - ) -> dict[str, object]: - return { - key: _QUOTAS_MEMBER.dump_python(value, mode="json", by_alias=True) - for key, value in typing.cast( - dict[str, object], self.model_extra or {} - ).items() - } - - -class Settings(pydantic.BaseModel): - """A closed object; unknown members are rejected.""" - - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - strict=True, populate_by_name=True, extra="forbid" - ) - - theme: str | None = pydantic.Field(default=None) - - font_size: SpecInt | None = pydantic.Field(default=None, alias="fontSize") - - _OPTIONAL_NON_NULLABLE_FIELDS: typing.ClassVar[frozenset[str]] = frozenset( - {"fontSize", "font_size", "theme"} - ) - - @pydantic.model_validator(mode="wrap") - @classmethod - def _reject_null( - cls, - data: object, - handler: typing.Callable[[object], typing.Any], - ) -> typing.Any: - return _reject_explicit_null(cls, data, handler) - - @pydantic.model_serializer(mode="wrap") - def _serialize( - self, - handler: typing.Callable[[pydantic.BaseModel], typing.Any], - ) -> dict[str, object]: - return _emit_set_fields(self, handler) + if value.audit is not None: + out["audit"] = _ShowcaseAuditTransferTypeConverter().to_transfer_type( + value.audit + ) + if value.rows is not None: + out["rows"] = [ + _ShowcaseRowsItemTransferTypeConverter().to_transfer_type(element) + for element in value.rows + ] + if value.ledger_py is not None: + out["ledger"] = _ShowcaseLedgerTransferTypeConverter().to_transfer_type( + value.ledger_py + ) + if value.metadata is not None: + out["metadata"] = _ShowcaseMetadataTransferTypeConverter().to_transfer_type( + value.metadata + ) + if value.quotas is not None: + out["quotas"] = _QuotasTransferTypeConverter().to_transfer_type( + value.quotas + ) + if value.tokens is not None: + out["tokens"] = _TokensTransferTypeConverter().to_transfer_type( + value.tokens + ) + if value.nicknames is not None: + out["nicknames"] = _NicknamesTransferTypeConverter().to_transfer_type( + value.nicknames + ) + if value.choices is not None: + out["choices"] = _ChoicesTransferTypeConverter().to_transfer_type( + value.choices + ) + if value.extras is not None: + out["extras"] = _ExtrasTransferTypeConverter().to_transfer_type( + value.extras + ) + if value.shape is not None: + out["shape"] = _shape_to_transfer_type(value.shape) + if value.note is not None: + out["note"] = _note_to_transfer_type(value.note) + if value.address is not None: + out["address"] = _AddressTransferTypeConverter().to_transfer_type( + value.address + ) + if value.labels is not None: + out["labels"] = _LabelsTransferTypeConverter().to_transfer_type( + value.labels + ) + if value.settings is not None: + out["settings"] = _SettingsTransferTypeConverter().to_transfer_type( + value.settings + ) + if value.attributes is not None: + out["attributes"] = _AttributesTransferTypeConverter().to_transfer_type( + value.attributes + ) + if value.contact is not None: + out["contact"] = _ContactPyTransferTypeConverter().to_transfer_type( + value.contact + ) + if violations: + raise ValidationError(violations) + return out -class Showcase(pydantic.BaseModel): +@_transfer_type_convertible(_ShowcaseTransferTypeConverter) +@dataclasses.dataclass(slots=True, kw_only=True) +class Showcase: """Showcase Root object exercising the supported JSON-Schema feature subset: required and optional fields of every scalar type, optional+nullable and required+nullable @@ -593,14 +2665,10 @@ class Showcase(pydantic.BaseModel): (catch-all) object, a string const, a scalar default, and member docs. """ - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - strict=True, populate_by_name=True, extra="forbid" - ) - - kind: typing.Literal["showcase"] = pydantic.Field(default="showcase") + kind: typing.Literal["showcase"] = "showcase" """Discriminator; always "showcase".""" - revision: typing.Literal[1] = pydantic.Field(default=1) + revision: typing.Literal[1] = 1 """Integer const; always 1. Also exercises the single-`const` value override: `x-go-const-name`/`x-java-const-name` rename the emitted constant to the derived name plus a per-language suffix (Go `RevisionGo`, Java `REVISION_JAVA`) while the @@ -608,10 +2676,10 @@ class Showcase(pydantic.BaseModel): value is emitted as a plain literal type). """ - enabled: typing.Literal[True] = pydantic.Field(default=True) + enabled: typing.Literal[True] = True """Boolean const; always true.""" - status: typing.Literal["active", "inactive", "pending"] = pydantic.Field() + status: typing.Literal["active", "inactive", "pending"] """Closed string value set. Also exercises the enum value-constant override: `x-go-enum-names`/`x-java-enum-names` rename the `active` value's emitted constant to the value name plus a per-language suffix (Go `ActiveGo`, Java `ACTIVE_JAVA`) @@ -619,165 +2687,95 @@ class Showcase(pydantic.BaseModel): keyword). """ - tier: typing.Literal[1, 2, 3] = pydantic.Field() + tier: typing.Literal[1, 2, 3] """Closed integer value set.""" - scale: float = pydantic.Field() + scale: float """Closed number value set (exercises the Python float exception: emitted as plain float, validated by membership). """ - name: str = pydantic.Field(min_length=1, max_length=64) + name: str """Display name Required human-readable name, 1 to 64 code points. """ - count: SpecInt = pydantic.Field() + count: int """Required integer scalar.""" - active: bool = pydantic.Field() + active: bool """Required boolean scalar.""" - nickname: str | None = pydantic.Field(default=None, max_length=12) + nickname: str | None = None """Optional short name, at most 12 code points.""" - code: str | None = pydantic.Field(default=None, min_length=2, max_length=5) + code: str | None = None """Optional code, 2 to 5 code points. Counted in Unicode code points, so a multi-byte value (e.g. "a😀b", 3 code points / 6 UTF-8 bytes) is valid. """ - sku: ( - typing.Annotated[str, pydantic.AfterValidator(_check_pattern("^[A-Z]{2,4}\\Z"))] - | None - ) = pydantic.Field(default=None) + sku: str | None = None """Optional product code: 2 to 4 uppercase ASCII letters, anchored (`^[A-Z]{2,4}$`). Exercises the RE2-safe `pattern` gate. """ - phrase: ( - typing.Annotated[ - str, - pydantic.AfterValidator( - _check_pattern( - "^[^\\t\\n\\x0B\\f\\r ]+[\\t\\n\\x0B\\f\\r ][^\\t\\n\\x0B\\f\\r ]+\\Z" - ) - ), - ] - | None - ) = pydantic.Field(default=None) + phrase: str | None = None """Optional two-word phrase separated by whitespace (`^\\S+\\s\\S+$`). Exercises the loader's `\\s`/`\\S` → ASCII-class normalization and the per-target `$` end-anchor rewrite (Python `\\Z` / Java `\\z`), so a Unicode space (NBSP) and a trailing newline are rejected consistently across all four languages. """ - request_id: ( - typing.Annotated[ - str, - pydantic.AfterValidator( - _check_format( - "uuid", - "^[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}\\Z", - ) - ), - ] - | None - ) = pydantic.Field(default=None, alias="requestId") + request_id: str | None = None """Optional request identifier; asserted RFC 4122 UUID via `format: uuid`. Stays `string`-typed (format assertion, no materialization); the pinned regex is validated identically across all four languages. """ - contact_email: ( - typing.Annotated[ - str, - pydantic.AfterValidator( - _check_format( - "email", - "^[a-zA-Z0-9!#$%&'*+/=?^_`{|}~-]+(?:\\.[a-zA-Z0-9!#$%&'*+/=?^_`{|}~-]+)*@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+\\Z", - 254, - ) - ), - ] - | None - ) = pydantic.Field(default=None, alias="contactEmail") + contact_email: str | None = None """Optional contact address; asserted ASCII dot-atom `format: email` (single `@`, >=2-label domain, total length <= 254, guard-before-regex). """ - host: ( - typing.Annotated[ - str, - pydantic.AfterValidator( - _check_format( - "hostname", - "^[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?(?:\\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*\\Z", - 253, - ) - ), - ] - | None - ) = pydantic.Field(default=None) + host: str | None = None """Optional host name; asserted RFC 1123 `format: hostname` (LDH labels, total length <= 253). """ - homepage: ( - typing.Annotated[ - str, - pydantic.AfterValidator( - _check_format( - "uri", - "^(?:[A-Za-z][A-Za-z0-9+.-]*:(?://(?:(?:[A-Za-z0-9._~!$&'()*+,;=:-]|%[0-9A-Fa-f][0-9A-Fa-f])*@)?(?:(?:\\[(?:([0-9a-fA-F]{1,4}:){6}([0-9a-fA-F]{1,4}:[0-9a-fA-F]{1,4}|((25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])))|::([0-9a-fA-F]{1,4}:){5}([0-9a-fA-F]{1,4}:[0-9a-fA-F]{1,4}|((25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])))|([0-9a-fA-F]{1,4})?::([0-9a-fA-F]{1,4}:){4}([0-9a-fA-F]{1,4}:[0-9a-fA-F]{1,4}|((25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])))|(([0-9a-fA-F]{1,4}:){0,1}[0-9a-fA-F]{1,4})?::([0-9a-fA-F]{1,4}:){3}([0-9a-fA-F]{1,4}:[0-9a-fA-F]{1,4}|((25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])))|(([0-9a-fA-F]{1,4}:){0,2}[0-9a-fA-F]{1,4})?::([0-9a-fA-F]{1,4}:){2}([0-9a-fA-F]{1,4}:[0-9a-fA-F]{1,4}|((25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])))|(([0-9a-fA-F]{1,4}:){0,3}[0-9a-fA-F]{1,4})?::([0-9a-fA-F]{1,4}:)([0-9a-fA-F]{1,4}:[0-9a-fA-F]{1,4}|((25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])))|(([0-9a-fA-F]{1,4}:){0,4}[0-9a-fA-F]{1,4})?::([0-9a-fA-F]{1,4}:[0-9a-fA-F]{1,4}|((25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])))|(([0-9a-fA-F]{1,4}:){0,5}[0-9a-fA-F]{1,4})?::[0-9a-fA-F]{1,4}|(([0-9a-fA-F]{1,4}:){0,6}[0-9a-fA-F]{1,4})?::)\\]|\\[v[0-9A-Fa-f]+\\.[A-Za-z0-9._~!$&'()*+,;=:-]+\\])|(?:[A-Za-z0-9._~!$&'()*+,;=-]|%[0-9A-Fa-f][0-9A-Fa-f])*)(?::[0-9]*)?(?:/(?:[A-Za-z0-9._~!$&'()*+,;=:@-]|%[0-9A-Fa-f][0-9A-Fa-f])*)*|/(?:(?:[A-Za-z0-9._~!$&'()*+,;=:@-]|%[0-9A-Fa-f][0-9A-Fa-f])+(?:/(?:[A-Za-z0-9._~!$&'()*+,;=:@-]|%[0-9A-Fa-f][0-9A-Fa-f])*)*)?|(?:[A-Za-z0-9._~!$&'()*+,;=:@-]|%[0-9A-Fa-f][0-9A-Fa-f])+(?:/(?:[A-Za-z0-9._~!$&'()*+,;=:@-]|%[0-9A-Fa-f][0-9A-Fa-f])*)*)?(?:\\?(?:(?:[A-Za-z0-9._~!$&'()*+,;=:@-]|%[0-9A-Fa-f][0-9A-Fa-f])|[/?])*)?(?:#(?:(?:[A-Za-z0-9._~!$&'()*+,;=:@-]|%[0-9A-Fa-f][0-9A-Fa-f])|[/?])*)?)\\Z", - ) - ), - ] - | None - ) = pydantic.Field(default=None) + homepage: str | None = None """Optional homepage; asserted RFC 3986 `format: uri` (scheme required, ASCII only; an IP-literal host is validated by the spliced ipv6 grammar). """ - gateway: ( - typing.Annotated[ - str, - pydantic.AfterValidator( - _check_format( - "ipv4", - "^(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\\Z", - ) - ), - ] - | None - ) = pydantic.Field(default=None) + gateway: str | None = None """Optional gateway address; asserted dotted-quad IPv4 via format ipv4.""" - blob: Base64Field | None = pydantic.Field(default=None) + blob: bytes | None = None """Optional binary payload carried as a `contentEncoding: base64` string, materialized to native bytes (Go []byte, TS Uint8Array, Python bytes, Java byte[]). The wire is canonical padded standard base64; a malformed value is rejected by the pinned regex before decode. """ - url_blob: Base64UrlField | None = pydantic.Field(default=None, alias="urlBlob") + url_blob: bytes | None = None """Optional binary payload carried as a `contentEncoding: base64url` string (URL-safe alphabet, unpadded, RFC 4648 §5), materialized to the same native bytes type. The same bytes encode to a different wire than base64 ("Pj4+" vs "Pj4-"). """ - retries: SpecInt = pydantic.Field(default=3) + retries: int | None = None """Retry budget Optional integer with a schema default. """ - verbose: bool | None = pydantic.Field(default=None) + verbose: bool | None = None - greeting: str = pydantic.Field(default="hello") + greeting: str | None = None """Greeting Optional string with a schema default, surfaced on read. """ - debug: bool = pydantic.Field(default=False) + debug: bool | None = None """Debug flag Optional boolean with a schema default. """ @@ -788,7 +2786,7 @@ class Showcase(pydantic.BaseModel): typing_extensions.deprecated("This field is deprecated.", category=None), ] | None - ) = pydantic.Field(default=None, alias="legacyId") + ) = None """Deprecated legacy identifier; prefer `requestId`. Exercises the native deprecation marker (Go // Deprecated:, TS @deprecated, Java @Deprecated, Python PEP 702 @deprecated). Also exercises the property-level `x--name` override (the Stage @@ -798,40 +2796,34 @@ class Showcase(pydantic.BaseModel): @JsonProperty). """ - middle_name: str | None = pydantic.Field(default=None, alias="middleName") + middle_name: str | None = None """Optional and nullable; may be absent or explicitly null.""" - category: str | None = pydantic.Field() + category: str | None """Required but nullable; may be explicitly cleared to null.""" - priority: SpecInt | None = pydantic.Field(default=None, ge=1, le=10) + priority: int | None = None """Optional integer bounded to the inclusive range [1, 10].""" - level: SpecInt | None = pydantic.Field(default=None, gt=0) + level: int | None = None """Optional integer that must be strictly greater than 0.""" - ratio: ( - typing.Annotated[float, pydantic.AfterValidator(_check_multiple_of(5))] | None - ) = pydantic.Field(default=None, ge=5) + ratio: float | None = None """Optional number that must be a non-negative multiple of 5.""" - step: SpecInt | None = pydantic.Field(default=None, multiple_of=3) + step: int | None = None """Optional integer that must be a multiple of 3.""" - tags: list[str] | None = pydantic.Field(default=None, min_length=1, max_length=5) + tags: list[str] | None = None """Ordered list of free-form tags; 1 to 5 entries.""" - aliases: list[str] | None = pydantic.Field(default=None) + aliases: list[str] | None = None """Alternate names; each must be distinct.""" - roles: list[str] | None = pydantic.Field(default=None) + roles: list[str] | None = None """Access roles; must contain between one and two "admin" entries.""" - id_or_name: ( - typing.Annotated[str, pydantic.Field(min_length=3)] - | typing.Annotated[SpecInt, pydantic.Field(ge=1)] - | None - ) = pydantic.Field(default=None, alias="idOrName") + id_or_name: str | int | None = None """Disjoint-kind union (oneOf sum type): the wire value is either a string of at least 3 code points or an integer of at least 1, selected by its JSON token. Not a member of a discriminated union — the token itself is the selector. Each branch also @@ -840,18 +2832,14 @@ class Showcase(pydantic.BaseModel): violation. """ - mode: ( - typing.Literal["auto", "manual"] - | typing.Annotated[SpecInt, pydantic.Field(ge=0)] - | None - ) = pydantic.Field(default=None) + mode: typing.Literal["auto", "manual"] | int | None = None """A union whose string branch is a **closed value set**: either one of two named modes or an unbounded non-negative integer. The branch narrows to its own admissible values (a Go/Java membership check, a TypeScript literal union, a Python `Literal`), so an unknown string is a Violation while any non-negative integer is accepted. """ - payload: dict[str, typing.Any] | str | None = pydantic.Field(default=None) + payload: dict[str, typing.Any] | str | None = None """Mixed-kind union whose object branch is an inline free-form object: the wire value is either an arbitrary object (members carried verbatim) or a string, selected by its JSON token. The free-form object is the one object branch that needs no type @@ -859,7 +2847,7 @@ class Showcase(pydantic.BaseModel): `Object`. """ - detail: ShowcaseDetailObject | str | None = pydantic.Field(default=None) + detail: ShowcaseDetailObject | str | None = None """Mixed-kind union whose object branch is an inline *structured* object, written directly on the property rather than in `$defs`. It is the only object branch of this union, so it derives its name from the union it belongs to — @@ -867,9 +2855,7 @@ class Showcase(pydantic.BaseModel): own constraints and it stays open to unknown ones. """ - shape_or_name: ( - Circle | Square | typing.Annotated[str, pydantic.Field(max_length=32)] | None - ) = pydantic.Field(default=None, alias="shapeOrName") + shape_or_name: Circle | Square | str | None = None """Tagged object union mixed with a scalar kind: the two selector layers compose — the JSON token picks object-vs-string, and, for an object, the shared required `kind` const picks Circle-vs-Square. Written inline on the property, so the union itself is @@ -880,14 +2866,7 @@ class Showcase(pydantic.BaseModel): validate through their own models. """ - measurements: ( - typing.Annotated[ - typing.Annotated[list[float], pydantic.Field(min_length=1)], - pydantic.AfterValidator(_check_unique_items), - ] - | typing.Annotated[str, pydantic.AfterValidator(_check_pattern("^[a-z]+\\Z"))] - | None - ) = pydantic.Field(default=None) + measurements: list[float] | str | None = None """Mixed-kind union with an array branch: the wire value is either a non-empty list of distinct numbers or a lowercase preset name, selected by its JSON token. An array branch has no definition to take a name from, so Go and Java emit it as the @@ -897,352 +2876,267 @@ class Showcase(pydantic.BaseModel): string's `pattern` — so the array-vs-string choice is validated as well as selected. """ - shapes: list[Shape] | None = pydantic.Field(default=None) + shapes: list[Shape] | None = None """A list whose element type is a named union: every element is routed to exactly one branch by the union's own selector, and its index carries into the violation path (`shapes[1]`). Go and Java cannot decode a sealed interface as a whole, so the element decodes through the union's dispatcher one at a time. """ - segments: list[ShowcaseSegmentsItem] | None = pydantic.Field(default=None) + segments: list[ShowcaseSegmentsItem] | None = None """A list whose element union is written **inline**. An element has no name of its own, so the union is named after its position — `ShowcaseSegmentsItem` — moved into `$defs`, and the element becomes a `$ref` at it; from there it is an ordinary named union in every language. """ - slots: list[str | None] | None = pydantic.Field(default=None) + slots: list[str | None] | None = None """A list of **nullable elements** — the two-branch nullability `oneOf` rather than a sum type, so nothing is named: the elements themselves become nullable (`[]*string`, `(string | null)[]`, `list[str | None]`, `List<@Nullable String>`) while the list stays a list. """ - grid: list[list[SpecInt]] | None = pydantic.Field(default=None) + grid: list[list[int]] | None = None """A nested array: `items` at depth two. Each level decodes elementwise, so a bad element is reported at its own two-dimensional index (`grid[1][0]`). """ - location: ShowcaseLocation | None = pydantic.Field(default=None) + location: ShowcaseLocation | None = None - audit: ShowcaseAudit | None = pydantic.Field(default=None) + audit: ShowcaseAudit | None = None """A nullable inline object. The nullability wrapper emits no type of its own, so the object inside it takes the property's name — `ShowcaseAudit`, the same name it would take written plainly: adding or removing nullability never renames the type. """ - rows: list[ShowcaseRowsItem] | None = pydantic.Field(default=None) + rows: list[ShowcaseRowsItem] | None = None """A list whose element is an inline object, named after its position (`ShowcaseRowsItem`) exactly as an inline element *union* is. """ - ledger_py: ShowcaseLedger | None = pydantic.Field(default=None, alias="ledger") - - metadata: ShowcaseMetadata | None = pydantic.Field(default=None) - - quotas: Quotas | None = pydantic.Field(default=None) - - tokens: Tokens | None = pydantic.Field(default=None) - - nicknames: Nicknames | None = pydantic.Field(default=None) - - choices: Choices | None = pydantic.Field(default=None) - - extras: Extras | None = pydantic.Field(default=None) - - shape: Shape | None = pydantic.Field(default=None) - - note: Note | None = pydantic.Field(default=None) - - address: Address | None = pydantic.Field(default=None) - - labels: Labels | None = pydantic.Field(default=None) - - settings: Settings | None = pydantic.Field(default=None) - - attributes: Attributes | None = pydantic.Field(default=None) - - contact: ContactPy | None = pydantic.Field(default=None) - - @pydantic.model_validator(mode="before") - @classmethod - def _inject_const_kind( - cls, - data: object, - ) -> object: - if isinstance(data, dict): - values = typing.cast(dict[str, object], data) - if "kind" not in values: - data = {**values, "kind": "showcase"} - elif values.get("kind", values.get("kind")) != "showcase": - raise pydantic_core.PydanticCustomError( - "const", 'kind must equal "showcase"' - ) - return typing.cast(object, data) - - @pydantic.model_validator(mode="before") - @classmethod - def _inject_const_revision( - cls, - data: object, - ) -> object: - if isinstance(data, dict): - values = typing.cast(dict[str, object], data) - if "revision" not in values: - data = {**values, "revision": 1} - elif values.get("revision", values.get("revision")) != 1: - raise pydantic_core.PydanticCustomError( - "const", "revision must equal 1" - ) - return typing.cast(object, data) - - @pydantic.model_validator(mode="before") - @classmethod - def _inject_const_enabled( - cls, - data: object, - ) -> object: - if isinstance(data, dict): - values = typing.cast(dict[str, object], data) - if "enabled" not in values: - data = {**values, "enabled": True} - elif values.get("enabled", values.get("enabled")) != True: - raise pydantic_core.PydanticCustomError( - "const", "enabled must equal True" - ) - return typing.cast(object, data) - - @pydantic.model_validator(mode="before") - @classmethod - def _check_enum_status( - cls, - data: object, - ) -> object: - if isinstance(data, dict): - values = typing.cast(dict[str, object], data) - if "status" in values: - got = values.get("status") - if got not in ["active", "inactive", "pending"]: - raise pydantic_core.PydanticCustomError( - "enum", - 'status must be one of ["active", "inactive", "pending"], got {got}', - {"got": got}, - ) - return typing.cast(object, data) - - @pydantic.model_validator(mode="before") - @classmethod - def _check_enum_tier( - cls, - data: object, - ) -> object: - if isinstance(data, dict): - values = typing.cast(dict[str, object], data) - if "tier" in values: - got = values.get("tier") - if got not in [1, 2, 3]: - raise pydantic_core.PydanticCustomError( - "enum", "tier must be one of [1, 2, 3], got {got}", {"got": got} - ) - return typing.cast(object, data) - - @pydantic.model_validator(mode="before") - @classmethod - def _check_enum_scale( - cls, - data: object, - ) -> object: - if isinstance(data, dict): - values = typing.cast(dict[str, object], data) - if "scale" in values: - got = values.get("scale") - if got not in [1.5, 2.5]: - raise pydantic_core.PydanticCustomError( - "enum", - "scale must be one of [1.5, 2.5], got {got}", - {"got": got}, - ) - return typing.cast(object, data) - - @pydantic.model_validator(mode="after") - def _validate_arrays(self) -> typing.Any: - errors: list[pydantic_core.InitErrorDetails] = [] - value = self.aliases - if value is not None: - seen: dict[object, int] = {} - for index, element in enumerate(value): - if element in seen: - errors.append( - pydantic_core.InitErrorDetails( - type=pydantic_core.PydanticCustomError( - "unique_items", - typing.cast( - typing.Any, - f"duplicate items: element at index {index} equals index {seen[element]}", - ), - ), - loc=("aliases",), - input=element, + ledger_py: ShowcaseLedger | None = None + + metadata: ShowcaseMetadata | None = None + + quotas: Quotas | None = None + + tokens: Tokens | None = None + + nicknames: Nicknames | None = None + + choices: Choices | None = None + + extras: Extras | None = None + + shape: Shape | None = None + + note: Note | None = None + + address: Address | None = None + + labels: Labels | None = None + + settings: Settings | None = None + + attributes: Attributes | None = None + + contact: ContactPy | None = None + + +class _ShowcaseAuditTransferTypeConverter( + temporalio.converter.TransferTypeConverter["ShowcaseAudit", typing.Any] +): + @typing_extensions.override + def from_transfer_type( + self, value: typing.Any, type_hint: type["ShowcaseAudit"] + ) -> "ShowcaseAudit": + violations: list[Violation] = [] + if not isinstance(value, dict): + raise ValidationError([Violation(path="", reason="expected object")]) + raw = typing.cast("dict[str, typing.Any]", value) + + by: str = typing.cast("typing.Any", None) + if "by" not in raw or raw["by"] is None: + violations.append(Violation(path="by", reason="required")) + else: + by_raw = raw["by"] + if not isinstance(by_raw, str): + violations.append(Violation(path="by", reason="expected string")) + else: + by = by_raw + if len(by_raw) < 1: + violations.append( + Violation( + path="by", + reason=f"must have length >= 1, got {len(by_raw)}", ) ) - else: - seen[element] = index - value = self.roles - if value is not None: - match_count = sum(1 for element in value if element == "admin") - if match_count < 1: - errors.append( - pydantic_core.InitErrorDetails( - type=pydantic_core.PydanticCustomError( - "too_few_matching_items", - typing.cast( - typing.Any, - f"too few matching items: at least 1, got {match_count}", - ), - ), - loc=("roles",), - input=value, - ) - ) - if match_count > 2: - errors.append( - pydantic_core.InitErrorDetails( - type=pydantic_core.PydanticCustomError( - "too_many_matching_items", - typing.cast( - typing.Any, - f"too many matching items: at most 2, got {match_count}", - ), - ), - loc=("roles",), - input=value, - ) - ) - if errors: - raise pydantic.ValidationError.from_exception_data( - title=type(self).__name__, line_errors=errors + + additional_properties: dict[str, typing.Any] = {} + for key in raw: + if key not in _SHOWCASE_AUDIT_DECLARED: + additional_properties[key] = raw[key] + if violations: + raise ValidationError(violations) + return ShowcaseAudit( + by=by, + additional_properties=additional_properties, + ) + + @typing_extensions.override + def to_transfer_type(self, value: "ShowcaseAudit") -> typing.Any: + violations: list[Violation] = [] + out: dict[str, typing.Any] = {} + if len(value.by) < 1: + violations.append( + Violation( + path="by", reason=f"must have length >= 1, got {len(value.by)}" + ) ) - return self - - _OPTIONAL_NON_NULLABLE_FIELDS: typing.ClassVar[frozenset[str]] = frozenset( - { - "address", - "aliases", - "attributes", - "blob", - "choices", - "code", - "contact", - "contactEmail", - "contact_email", - "detail", - "extras", - "gateway", - "grid", - "homepage", - "host", - "idOrName", - "id_or_name", - "labels", - "ledger", - "ledger_py", - "legacyId", - "legacy_id_py", - "level", - "location", - "measurements", - "metadata", - "mode", - "nickname", - "nicknames", - "note", - "payload", - "phrase", - "priority", - "quotas", - "ratio", - "requestId", - "request_id", - "roles", - "rows", - "segments", - "settings", - "shape", - "shapeOrName", - "shape_or_name", - "shapes", - "sku", - "slots", - "step", - "tags", - "tokens", - "urlBlob", - "url_blob", - "verbose", - } - ) + out["by"] = value.by + for key, entry in value.additional_properties.items(): + out[key] = entry + if violations: + raise ValidationError(violations) + return out + + +@_transfer_type_convertible(_ShowcaseAuditTransferTypeConverter) +@dataclasses.dataclass(slots=True, kw_only=True) +class ShowcaseAudit: + by: str - @pydantic.model_validator(mode="wrap") - @classmethod - def _reject_null( - cls, - data: object, - handler: typing.Callable[[object], typing.Any], - ) -> typing.Any: - return _reject_explicit_null(cls, data, handler) - - @pydantic.model_serializer(mode="wrap") - def _serialize( - self, - handler: typing.Callable[[pydantic.BaseModel], typing.Any], - ) -> dict[str, object]: - return _emit_set_fields(self, handler) - - -class ShowcaseAudit(pydantic.BaseModel): - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - strict=True, populate_by_name=True, extra="allow" + additional_properties: dict[str, typing.Any] = dataclasses.field( + default_factory=dict ) - by: str = pydantic.Field(min_length=1) - @pydantic.model_serializer(mode="wrap") - def _serialize( - self, - handler: typing.Callable[[pydantic.BaseModel], typing.Any], - ) -> dict[str, object]: - return _emit_set_fields(self, handler) +class _ShowcaseDetailObjectTransferTypeConverter( + temporalio.converter.TransferTypeConverter["ShowcaseDetailObject", typing.Any] +): + @typing_extensions.override + def from_transfer_type( + self, value: typing.Any, type_hint: type["ShowcaseDetailObject"] + ) -> "ShowcaseDetailObject": + violations: list[Violation] = [] + if not isinstance(value, dict): + raise ValidationError([Violation(path="", reason="expected object")]) + raw = typing.cast("dict[str, typing.Any]", value) + + code: str = typing.cast("typing.Any", None) + if "code" not in raw or raw["code"] is None: + violations.append(Violation(path="code", reason="required")) + else: + code_raw = raw["code"] + if not isinstance(code_raw, str): + violations.append(Violation(path="code", reason="expected string")) + else: + code = code_raw + if len(code_raw) < 1: + violations.append( + Violation( + path="code", + reason=f"must have length >= 1, got {len(code_raw)}", + ) + ) + hint: str | None = None + if "hint" in raw: + hint_raw = raw["hint"] + if hint_raw is None: + violations.append( + Violation(path="hint", reason="explicit null not allowed") + ) + else: + if not isinstance(hint_raw, str): + violations.append(Violation(path="hint", reason="expected string")) + else: + hint = hint_raw + + additional_properties: dict[str, typing.Any] = {} + for key in raw: + if key not in _SHOWCASE_DETAIL_OBJECT_DECLARED: + additional_properties[key] = raw[key] + if violations: + raise ValidationError(violations) + return ShowcaseDetailObject( + code=code, + hint=hint, + additional_properties=additional_properties, + ) + + @typing_extensions.override + def to_transfer_type(self, value: "ShowcaseDetailObject") -> typing.Any: + violations: list[Violation] = [] + out: dict[str, typing.Any] = {} + if len(value.code) < 1: + violations.append( + Violation( + path="code", reason=f"must have length >= 1, got {len(value.code)}" + ) + ) + out["code"] = value.code + if value.hint is not None: + out["hint"] = value.hint + for key, entry in value.additional_properties.items(): + out[key] = entry + if violations: + raise ValidationError(violations) + return out -class ShowcaseDetailObject(pydantic.BaseModel): - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - strict=True, populate_by_name=True, extra="allow" - ) - code: str = pydantic.Field(min_length=1) +@_transfer_type_convertible(_ShowcaseDetailObjectTransferTypeConverter) +@dataclasses.dataclass(slots=True, kw_only=True) +class ShowcaseDetailObject: + code: str - hint: str | None = pydantic.Field(default=None) + hint: str | None = None - _OPTIONAL_NON_NULLABLE_FIELDS: typing.ClassVar[frozenset[str]] = frozenset({"hint"}) + additional_properties: dict[str, typing.Any] = dataclasses.field( + default_factory=dict + ) - @pydantic.model_validator(mode="wrap") - @classmethod - def _reject_null( - cls, - data: object, - handler: typing.Callable[[object], typing.Any], - ) -> typing.Any: - return _reject_explicit_null(cls, data, handler) - @pydantic.model_serializer(mode="wrap") - def _serialize( - self, - handler: typing.Callable[[pydantic.BaseModel], typing.Any], - ) -> dict[str, object]: - return _emit_set_fields(self, handler) +class _ShowcaseLedgerTransferTypeConverter( + temporalio.converter.TransferTypeConverter["ShowcaseLedger", typing.Any] +): + @typing_extensions.override + def from_transfer_type( + self, value: typing.Any, type_hint: type["ShowcaseLedger"] + ) -> "ShowcaseLedger": + violations: list[Violation] = [] + if not isinstance(value, dict): + raise ValidationError([Violation(path="", reason="expected object")]) + raw = typing.cast("dict[str, typing.Any]", value) + additional_properties: dict[str, ShowcaseLedgerValue] = {} + for key in raw: + member: ShowcaseLedgerValue = typing.cast("typing.Any", None) + member_raw = raw[key] + try: + member = _ShowcaseLedgerValueTransferTypeConverter().from_transfer_type( + member_raw, ShowcaseLedgerValue + ) + except ValidationError as error: + _collect(violations, key, error) + additional_properties[key] = member + if violations: + raise ValidationError(violations) + return ShowcaseLedger(additional_properties=additional_properties) + + @typing_extensions.override + def to_transfer_type(self, value: "ShowcaseLedger") -> typing.Any: + out: dict[str, typing.Any] = {} + for key, entry in value.additional_properties.items(): + out[key] = _ShowcaseLedgerValueTransferTypeConverter().to_transfer_type( + entry + ) + return out -class ShowcaseLedger(pydantic.BaseModel): +@_transfer_type_convertible(_ShowcaseLedgerTransferTypeConverter) +@dataclasses.dataclass(slots=True, kw_only=True) +class ShowcaseLedger: """A typed map written inline on the property: the map itself is named `ShowcaseLedger` and its inline member shape `ShowcaseLedgerValue`, so both the map and its members are ordinary named models. Also exercises the member-name override on a hoisted @@ -1251,64 +3145,156 @@ class ShowcaseLedger(pydantic.BaseModel): name. """ - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - strict=True, populate_by_name=True, extra="allow" + additional_properties: dict[str, ShowcaseLedgerValue] = dataclasses.field( + default_factory=dict ) - @pydantic.model_validator(mode="after") - def _validate_extras(self) -> typing.Any: - extra = typing.cast(dict[str, object], self.model_extra or {}) - errors: list[pydantic_core.InitErrorDetails] = [] - for key, value in list(extra.items()): - try: - extra[key] = _SHOWCASE_LEDGER_MEMBER.validate_python(value) - except pydantic.ValidationError as error: - for detail in error.errors(): - errors.append( - pydantic_core.InitErrorDetails( - type=pydantic_core.PydanticCustomError( - typing.cast(typing.Any, detail["type"]), - typing.cast(typing.Any, detail["msg"]), - ), - loc=(key, *detail["loc"]), - input=detail["input"], - ) + +class _ShowcaseLedgerValueTransferTypeConverter( + temporalio.converter.TransferTypeConverter["ShowcaseLedgerValue", typing.Any] +): + @typing_extensions.override + def from_transfer_type( + self, value: typing.Any, type_hint: type["ShowcaseLedgerValue"] + ) -> "ShowcaseLedgerValue": + violations: list[Violation] = [] + if not isinstance(value, dict): + raise ValidationError([Violation(path="", reason="expected object")]) + raw = typing.cast("dict[str, typing.Any]", value) + + amount: int = typing.cast("typing.Any", None) + if "amount" not in raw or raw["amount"] is None: + violations.append(Violation(path="amount", reason="required")) + else: + amount_raw = raw["amount"] + amount_parsed = _parse_spec_integer(amount_raw, "amount", violations) + if amount_parsed is not None: + amount = amount_parsed + if amount < 0: + violations.append( + Violation(path="amount", reason=f"must be >= 0, got {amount}") ) - if errors: - raise pydantic.ValidationError.from_exception_data( - title=type(self).__name__, line_errors=errors + + additional_properties: dict[str, typing.Any] = {} + for key in raw: + if key not in _SHOWCASE_LEDGER_VALUE_DECLARED: + additional_properties[key] = raw[key] + if violations: + raise ValidationError(violations) + return ShowcaseLedgerValue( + amount=amount, + additional_properties=additional_properties, + ) + + @typing_extensions.override + def to_transfer_type(self, value: "ShowcaseLedgerValue") -> typing.Any: + violations: list[Violation] = [] + out: dict[str, typing.Any] = {} + if value.amount < 0: + violations.append( + Violation(path="amount", reason=f"must be >= 0, got {value.amount}") ) - return self - - @pydantic.model_serializer(mode="wrap") - def _serialize( - self, - _handler: typing.Callable[[pydantic.BaseModel], typing.Any], - ) -> dict[str, object]: - return { - key: _SHOWCASE_LEDGER_MEMBER.dump_python(value, mode="json", by_alias=True) - for key, value in typing.cast( - dict[str, object], self.model_extra or {} - ).items() - } - - -class ShowcaseLedgerValue(pydantic.BaseModel): - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - strict=True, populate_by_name=True, extra="allow" + out["amount"] = value.amount + for key, entry in value.additional_properties.items(): + out[key] = entry + if violations: + raise ValidationError(violations) + return out + + +@_transfer_type_convertible(_ShowcaseLedgerValueTransferTypeConverter) +@dataclasses.dataclass(slots=True, kw_only=True) +class ShowcaseLedgerValue: + amount: int + + additional_properties: dict[str, typing.Any] = dataclasses.field( + default_factory=dict ) - amount: SpecInt = pydantic.Field(ge=0) - @pydantic.model_serializer(mode="wrap") - def _serialize( - self, - handler: typing.Callable[[pydantic.BaseModel], typing.Any], - ) -> dict[str, object]: - return _emit_set_fields(self, handler) +class _ShowcaseLocationTransferTypeConverter( + temporalio.converter.TransferTypeConverter["ShowcaseLocation", typing.Any] +): + @typing_extensions.override + def from_transfer_type( + self, value: typing.Any, type_hint: type["ShowcaseLocation"] + ) -> "ShowcaseLocation": + violations: list[Violation] = [] + if not isinstance(value, dict): + raise ValidationError([Violation(path="", reason="expected object")]) + raw = typing.cast("dict[str, typing.Any]", value) + + city: str = typing.cast("typing.Any", None) + if "city" not in raw or raw["city"] is None: + violations.append(Violation(path="city", reason="required")) + else: + city_raw = raw["city"] + if not isinstance(city_raw, str): + violations.append(Violation(path="city", reason="expected string")) + else: + city = city_raw + if len(city_raw) < 1: + violations.append( + Violation( + path="city", + reason=f"must have length >= 1, got {len(city_raw)}", + ) + ) + + geo: ShowcaseLocationGeo | None = None + if "geo" in raw: + geo_raw = raw["geo"] + if geo_raw is None: + violations.append( + Violation(path="geo", reason="explicit null not allowed") + ) + else: + try: + geo = ( + _ShowcaseLocationGeoTransferTypeConverter().from_transfer_type( + geo_raw, ShowcaseLocationGeo + ) + ) + except ValidationError as error: + _collect(violations, "geo", error) + + additional_properties: dict[str, typing.Any] = {} + for key in raw: + if key not in _SHOWCASE_LOCATION_DECLARED: + additional_properties[key] = raw[key] + if violations: + raise ValidationError(violations) + return ShowcaseLocation( + city=city, + geo=geo, + additional_properties=additional_properties, + ) + + @typing_extensions.override + def to_transfer_type(self, value: "ShowcaseLocation") -> typing.Any: + violations: list[Violation] = [] + out: dict[str, typing.Any] = {} + if len(value.city) < 1: + violations.append( + Violation( + path="city", reason=f"must have length >= 1, got {len(value.city)}" + ) + ) + out["city"] = value.city + if value.geo is not None: + out["geo"] = _ShowcaseLocationGeoTransferTypeConverter().to_transfer_type( + value.geo + ) + for key, entry in value.additional_properties.items(): + out[key] = entry + if violations: + raise ValidationError(violations) + return out -class ShowcaseLocation(pydantic.BaseModel): +@_transfer_type_convertible(_ShowcaseLocationTransferTypeConverter) +@dataclasses.dataclass(slots=True, kw_only=True) +class ShowcaseLocation: """An object written **inline** on the property rather than in `$defs`. It is named after the position it occupies — `ShowcaseLocation` — moved into `$defs`, and the property becomes a `$ref` at it, so it emits as the ordinary named model an authored @@ -1317,254 +3303,617 @@ class ShowcaseLocation(pydantic.BaseModel): `$defs` boilerplate at any depth. """ - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - strict=True, populate_by_name=True, extra="allow" - ) - - city: str = pydantic.Field(min_length=1) - - geo: ShowcaseLocationGeo | None = pydantic.Field(default=None) - - _OPTIONAL_NON_NULLABLE_FIELDS: typing.ClassVar[frozenset[str]] = frozenset({"geo"}) - - @pydantic.model_validator(mode="wrap") - @classmethod - def _reject_null( - cls, - data: object, - handler: typing.Callable[[object], typing.Any], - ) -> typing.Any: - return _reject_explicit_null(cls, data, handler) + city: str - @pydantic.model_serializer(mode="wrap") - def _serialize( - self, - handler: typing.Callable[[pydantic.BaseModel], typing.Any], - ) -> dict[str, object]: - return _emit_set_fields(self, handler) + geo: ShowcaseLocationGeo | None = None - -class ShowcaseLocationGeo(pydantic.BaseModel): - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - strict=True, populate_by_name=True, extra="allow" + additional_properties: dict[str, typing.Any] = dataclasses.field( + default_factory=dict ) - lat: float | None = pydantic.Field(default=None) - - lon: float | None = pydantic.Field(default=None) - _OPTIONAL_NON_NULLABLE_FIELDS: typing.ClassVar[frozenset[str]] = frozenset( - {"lat", "lon"} +class _ShowcaseLocationGeoTransferTypeConverter( + temporalio.converter.TransferTypeConverter["ShowcaseLocationGeo", typing.Any] +): + @typing_extensions.override + def from_transfer_type( + self, value: typing.Any, type_hint: type["ShowcaseLocationGeo"] + ) -> "ShowcaseLocationGeo": + violations: list[Violation] = [] + if not isinstance(value, dict): + raise ValidationError([Violation(path="", reason="expected object")]) + raw = typing.cast("dict[str, typing.Any]", value) + + lat: float | None = None + if "lat" in raw: + lat_raw = raw["lat"] + if lat_raw is None: + violations.append( + Violation(path="lat", reason="explicit null not allowed") + ) + else: + if not ( + not isinstance(lat_raw, bool) and isinstance(lat_raw, (int, float)) + ): + violations.append(Violation(path="lat", reason="expected number")) + else: + lat = lat_raw + + lon: float | None = None + if "lon" in raw: + lon_raw = raw["lon"] + if lon_raw is None: + violations.append( + Violation(path="lon", reason="explicit null not allowed") + ) + else: + if not ( + not isinstance(lon_raw, bool) and isinstance(lon_raw, (int, float)) + ): + violations.append(Violation(path="lon", reason="expected number")) + else: + lon = lon_raw + + additional_properties: dict[str, typing.Any] = {} + for key in raw: + if key not in _SHOWCASE_LOCATION_GEO_DECLARED: + additional_properties[key] = raw[key] + if violations: + raise ValidationError(violations) + return ShowcaseLocationGeo( + lat=lat, + lon=lon, + additional_properties=additional_properties, + ) + + @typing_extensions.override + def to_transfer_type(self, value: "ShowcaseLocationGeo") -> typing.Any: + out: dict[str, typing.Any] = {} + if value.lat is not None: + out["lat"] = value.lat + if value.lon is not None: + out["lon"] = value.lon + for key, entry in value.additional_properties.items(): + out[key] = entry + return out + + +@_transfer_type_convertible(_ShowcaseLocationGeoTransferTypeConverter) +@dataclasses.dataclass(slots=True, kw_only=True) +class ShowcaseLocationGeo: + lat: float | None = None + + lon: float | None = None + + additional_properties: dict[str, typing.Any] = dataclasses.field( + default_factory=dict ) - @pydantic.model_validator(mode="wrap") - @classmethod - def _reject_null( - cls, - data: object, - handler: typing.Callable[[object], typing.Any], - ) -> typing.Any: - return _reject_explicit_null(cls, data, handler) - @pydantic.model_serializer(mode="wrap") - def _serialize( - self, - handler: typing.Callable[[pydantic.BaseModel], typing.Any], - ) -> dict[str, object]: - return _emit_set_fields(self, handler) +class _ShowcaseMetadataTransferTypeConverter( + temporalio.converter.TransferTypeConverter["ShowcaseMetadata", typing.Any] +): + @typing_extensions.override + def from_transfer_type( + self, value: typing.Any, type_hint: type["ShowcaseMetadata"] + ) -> "ShowcaseMetadata": + violations: list[Violation] = [] + if not isinstance(value, dict): + raise ValidationError([Violation(path="", reason="expected object")]) + raw = typing.cast("dict[str, typing.Any]", value) + if len(raw) > 3: + violations.append( + Violation( + path="", reason=f"must have at most 3 properties, got {len(raw)}" + ) + ) + additional_properties: dict[str, typing.Any] = {} + for key in raw: + additional_properties[key] = raw[key] + if violations: + raise ValidationError(violations) + return ShowcaseMetadata(additional_properties=additional_properties) + + @typing_extensions.override + def to_transfer_type(self, value: "ShowcaseMetadata") -> typing.Any: + violations: list[Violation] = [] + out: dict[str, typing.Any] = {} + for key, entry in value.additional_properties.items(): + out[key] = entry + if len(out) > 3: + violations.append( + Violation( + path="", reason=f"must have at most 3 properties, got {len(out)}" + ) + ) + if violations: + raise ValidationError(violations) + return out -class ShowcaseMetadata(pydantic.BaseModel): +@_transfer_type_convertible(_ShowcaseMetadataTransferTypeConverter) +@dataclasses.dataclass(slots=True, kw_only=True) +class ShowcaseMetadata: """A free-form object written inline. Even this is named (`ShowcaseMetadata`): every object emits as a named aggregate holding its members in a catch-all, so adding `properties` to it later only adds fields rather than changing the emitted type's kind, and its member-count bound rides along with it. """ - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - strict=True, populate_by_name=True, extra="allow" + additional_properties: dict[str, typing.Any] = dataclasses.field( + default_factory=dict ) - @pydantic.model_validator(mode="after") - def _validate_extras(self) -> typing.Any: - extra = typing.cast(dict[str, object], self.model_extra or {}) - errors: list[pydantic_core.InitErrorDetails] = [] - if len(extra) > 3: - errors.append( - pydantic_core.InitErrorDetails( - type=pydantic_core.PydanticCustomError( - "too_many_properties", - typing.cast( - typing.Any, - f"must have at most 3 properties, got {len(extra)}", - ), - ), - loc=(), - input=len(extra), - ) - ) - if errors: - raise pydantic.ValidationError.from_exception_data( - title=type(self).__name__, line_errors=errors - ) - return self - - @pydantic.model_serializer(mode="wrap") - def _serialize( - self, - _handler: typing.Callable[[pydantic.BaseModel], typing.Any], - ) -> dict[str, object]: - return dict(typing.cast(dict[str, object], self.model_extra or {})) +class _ShowcaseRowsItemTransferTypeConverter( + temporalio.converter.TransferTypeConverter["ShowcaseRowsItem", typing.Any] +): + @typing_extensions.override + def from_transfer_type( + self, value: typing.Any, type_hint: type["ShowcaseRowsItem"] + ) -> "ShowcaseRowsItem": + violations: list[Violation] = [] + if not isinstance(value, dict): + raise ValidationError([Violation(path="", reason="expected object")]) + raw = typing.cast("dict[str, typing.Any]", value) + + cell: str = typing.cast("typing.Any", None) + if "cell" not in raw or raw["cell"] is None: + violations.append(Violation(path="cell", reason="required")) + else: + cell_raw = raw["cell"] + if not isinstance(cell_raw, str): + violations.append(Violation(path="cell", reason="expected string")) + else: + cell = cell_raw + if len(cell_raw) < 1: + violations.append( + Violation( + path="cell", + reason=f"must have length >= 1, got {len(cell_raw)}", + ) + ) -class ShowcaseRowsItem(pydantic.BaseModel): - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - strict=True, populate_by_name=True, extra="allow" - ) - - cell: str = pydantic.Field(min_length=1) + additional_properties: dict[str, typing.Any] = {} + for key in raw: + if key not in _SHOWCASE_ROWS_ITEM_DECLARED: + additional_properties[key] = raw[key] + if violations: + raise ValidationError(violations) + return ShowcaseRowsItem( + cell=cell, + additional_properties=additional_properties, + ) + + @typing_extensions.override + def to_transfer_type(self, value: "ShowcaseRowsItem") -> typing.Any: + violations: list[Violation] = [] + out: dict[str, typing.Any] = {} + if len(value.cell) < 1: + violations.append( + Violation( + path="cell", reason=f"must have length >= 1, got {len(value.cell)}" + ) + ) + out["cell"] = value.cell + for key, entry in value.additional_properties.items(): + out[key] = entry + if violations: + raise ValidationError(violations) + return out - @pydantic.model_serializer(mode="wrap") - def _serialize( - self, - handler: typing.Callable[[pydantic.BaseModel], typing.Any], - ) -> dict[str, object]: - return _emit_set_fields(self, handler) +@_transfer_type_convertible(_ShowcaseRowsItemTransferTypeConverter) +@dataclasses.dataclass(slots=True, kw_only=True) +class ShowcaseRowsItem: + cell: str -class GetShowcaseInput(pydantic.BaseModel): - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - strict=True, populate_by_name=True, extra="forbid" + additional_properties: dict[str, typing.Any] = dataclasses.field( + default_factory=dict ) - id: str = pydantic.Field() - @pydantic.model_serializer(mode="wrap") - def _serialize( - self, - handler: typing.Callable[[pydantic.BaseModel], typing.Any], - ) -> dict[str, object]: - return _emit_set_fields(self, handler) +class _GetShowcaseInputTransferTypeConverter( + temporalio.converter.TransferTypeConverter["GetShowcaseInput", typing.Any] +): + @typing_extensions.override + def from_transfer_type( + self, value: typing.Any, type_hint: type["GetShowcaseInput"] + ) -> "GetShowcaseInput": + violations: list[Violation] = [] + if not isinstance(value, dict): + raise ValidationError([Violation(path="", reason="expected object")]) + raw = typing.cast("dict[str, typing.Any]", value) + + id: str = typing.cast("typing.Any", None) + if "id" not in raw or raw["id"] is None: + violations.append(Violation(path="id", reason="required")) + else: + id_raw = raw["id"] + if not isinstance(id_raw, str): + violations.append(Violation(path="id", reason="expected string")) + else: + id = id_raw + + for key in raw: + if key != "id": + violations.append(Violation(path=key, reason="unknown field")) + if violations: + raise ValidationError(violations) + return GetShowcaseInput( + id=id, + ) + + @typing_extensions.override + def to_transfer_type(self, value: "GetShowcaseInput") -> typing.Any: + out: dict[str, typing.Any] = {} + out["id"] = value.id + return out + + +@_transfer_type_convertible(_GetShowcaseInputTransferTypeConverter) +@dataclasses.dataclass(slots=True, kw_only=True) +class GetShowcaseInput: + id: str + + +class _SquareTransferTypeConverter( + temporalio.converter.TransferTypeConverter["Square", typing.Any] +): + @typing_extensions.override + def from_transfer_type( + self, value: typing.Any, type_hint: type["Square"] + ) -> "Square": + violations: list[Violation] = [] + if not isinstance(value, dict): + raise ValidationError([Violation(path="", reason="expected object")]) + raw = typing.cast("dict[str, typing.Any]", value) + + kind: typing.Literal["square"] = typing.cast("typing.Any", None) + if "kind" not in raw or raw["kind"] is None: + violations.append(Violation(path="kind", reason="required")) + else: + kind_raw = raw["kind"] + if not isinstance(kind_raw, str): + violations.append(Violation(path="kind", reason="expected string")) + elif kind_raw != "square": + violations.append(Violation(path="kind", reason='must equal "square"')) + else: + kind = kind_raw + + side: float = typing.cast("typing.Any", None) + if "side" not in raw or raw["side"] is None: + violations.append(Violation(path="side", reason="required")) + else: + side_raw = raw["side"] + if not ( + not isinstance(side_raw, bool) and isinstance(side_raw, (int, float)) + ): + violations.append(Violation(path="side", reason="expected number")) + else: + side = side_raw + + additional_properties: dict[str, typing.Any] = {} + for key in raw: + if key not in _SQUARE_DECLARED: + additional_properties[key] = raw[key] + if violations: + raise ValidationError(violations) + return Square( + kind=kind, + side=side, + additional_properties=additional_properties, + ) + + @typing_extensions.override + def to_transfer_type(self, value: "Square") -> typing.Any: + violations: list[Violation] = [] + out: dict[str, typing.Any] = {} + if typing.cast("object", value.kind) not in ("square",): + violations.append(Violation(path="kind", reason='must equal "square"')) + out["kind"] = value.kind + out["side"] = value.side + for key, entry in value.additional_properties.items(): + out[key] = entry + if violations: + raise ValidationError(violations) + return out + + +@_transfer_type_convertible(_SquareTransferTypeConverter) +@dataclasses.dataclass(slots=True, kw_only=True) +class Square: + """A square branch of the Shape and shapeOrName tagged unions.""" + kind: typing.Literal["square"] = "square" -class Square(pydantic.BaseModel): - """A square branch of the Shape and shapeOrName tagged unions.""" + side: float - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - strict=True, populate_by_name=True, extra="allow" + additional_properties: dict[str, typing.Any] = dataclasses.field( + default_factory=dict ) - kind: typing.Literal["square"] = pydantic.Field(default="square") - side: float = pydantic.Field() +class _TextNoteTransferTypeConverter( + temporalio.converter.TransferTypeConverter["TextNote", typing.Any] +): + @typing_extensions.override + def from_transfer_type( + self, value: typing.Any, type_hint: type["TextNote"] + ) -> "TextNote": + violations: list[Violation] = [] + if not isinstance(value, dict): + raise ValidationError([Violation(path="", reason="expected object")]) + raw = typing.cast("dict[str, typing.Any]", value) + + kind: typing.Literal["text"] = typing.cast("typing.Any", None) + if "kind" not in raw or raw["kind"] is None: + violations.append(Violation(path="kind", reason="required")) + else: + kind_raw = raw["kind"] + if not isinstance(kind_raw, str): + violations.append(Violation(path="kind", reason="expected string")) + elif kind_raw != "text": + violations.append(Violation(path="kind", reason='must equal "text"')) + else: + kind = kind_raw + + body: str = typing.cast("typing.Any", None) + if "body" not in raw or raw["body"] is None: + violations.append(Violation(path="body", reason="required")) + else: + body_raw = raw["body"] + if not isinstance(body_raw, str): + violations.append(Violation(path="body", reason="expected string")) + else: + body = body_raw + if len(body_raw) < 1: + violations.append( + Violation( + path="body", + reason=f"must have length >= 1, got {len(body_raw)}", + ) + ) - @pydantic.model_validator(mode="before") - @classmethod - def _inject_const_kind( - cls, - data: object, - ) -> object: - if isinstance(data, dict): - values = typing.cast(dict[str, object], data) - if "kind" not in values: - data = {**values, "kind": "square"} - elif values.get("kind", values.get("kind")) != "square": - raise pydantic_core.PydanticCustomError( - "const", 'kind must equal "square"' + additional_properties: dict[str, typing.Any] = {} + for key in raw: + if key not in _TEXT_NOTE_DECLARED: + additional_properties[key] = raw[key] + if violations: + raise ValidationError(violations) + return TextNote( + kind=kind, + body=body, + additional_properties=additional_properties, + ) + + @typing_extensions.override + def to_transfer_type(self, value: "TextNote") -> typing.Any: + violations: list[Violation] = [] + out: dict[str, typing.Any] = {} + if typing.cast("object", value.kind) not in ("text",): + violations.append(Violation(path="kind", reason='must equal "text"')) + out["kind"] = value.kind + if len(value.body) < 1: + violations.append( + Violation( + path="body", reason=f"must have length >= 1, got {len(value.body)}" ) - return typing.cast(object, data) - - @pydantic.model_serializer(mode="wrap") - def _serialize( - self, - handler: typing.Callable[[pydantic.BaseModel], typing.Any], - ) -> dict[str, object]: - return _emit_set_fields(self, handler) + ) + out["body"] = value.body + for key, entry in value.additional_properties.items(): + out[key] = entry + if violations: + raise ValidationError(violations) + return out -class TextNote(pydantic.BaseModel): +@_transfer_type_convertible(_TextNoteTransferTypeConverter) +@dataclasses.dataclass(slots=True, kw_only=True) +class TextNote: """A text note branch, named inline.""" - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - strict=True, populate_by_name=True, extra="allow" - ) + kind: typing.Literal["text"] = "text" - kind: typing.Literal["text"] = pydantic.Field(default="text") + body: str - body: str = pydantic.Field(min_length=1) + additional_properties: dict[str, typing.Any] = dataclasses.field( + default_factory=dict + ) - @pydantic.model_validator(mode="before") - @classmethod - def _inject_const_kind( - cls, - data: object, - ) -> object: - if isinstance(data, dict): - values = typing.cast(dict[str, object], data) - if "kind" not in values: - data = {**values, "kind": "text"} - elif values.get("kind", values.get("kind")) != "text": - raise pydantic_core.PydanticCustomError( - "const", 'kind must equal "text"' - ) - return typing.cast(object, data) - @pydantic.model_serializer(mode="wrap") - def _serialize( - self, - handler: typing.Callable[[pydantic.BaseModel], typing.Any], - ) -> dict[str, object]: - return _emit_set_fields(self, handler) +class _TokensTransferTypeConverter( + temporalio.converter.TransferTypeConverter["Tokens", typing.Any] +): + @typing_extensions.override + def from_transfer_type( + self, value: typing.Any, type_hint: type["Tokens"] + ) -> "Tokens": + violations: list[Violation] = [] + if not isinstance(value, dict): + raise ValidationError([Violation(path="", reason="expected object")]) + raw = typing.cast("dict[str, typing.Any]", value) + additional_properties: dict[str, str] = {} + for key in raw: + member: str = typing.cast("typing.Any", None) + member_raw = raw[key] + if not isinstance(member_raw, str): + violations.append(Violation(path=key, reason="expected string")) + else: + member = member_raw + if len(member_raw) < 2: + violations.append( + Violation( + path=key, + reason=f"must have length >= 2, got {len(member_raw)}", + ) + ) + if len(member_raw) > 8: + violations.append( + Violation( + path=key, + reason=f"must have length <= 8, got {len(member_raw)}", + ) + ) + if _PATTERN_F242E3A159C2422C.search(member_raw) is None: + violations.append( + Violation( + path=key, + reason=f"must match pattern {_PATTERN_F242E3A159C2422C.pattern}, got {_quote(member_raw)}", + ) + ) + additional_properties[key] = member + if violations: + raise ValidationError(violations) + return Tokens(additional_properties=additional_properties) + + @typing_extensions.override + def to_transfer_type(self, value: "Tokens") -> typing.Any: + violations: list[Violation] = [] + out: dict[str, typing.Any] = {} + for key, entry in value.additional_properties.items(): + if len(entry) < 2: + violations.append( + Violation( + path=key, reason=f"must have length >= 2, got {len(entry)}" + ) + ) + if len(entry) > 8: + violations.append( + Violation( + path=key, reason=f"must have length <= 8, got {len(entry)}" + ) + ) + if _PATTERN_F242E3A159C2422C.search(entry) is None: + violations.append( + Violation( + path=key, + reason=f"must match pattern {_PATTERN_F242E3A159C2422C.pattern}, got {_quote(entry)}", + ) + ) + out[key] = entry + if violations: + raise ValidationError(violations) + return out -class Tokens(pydantic.BaseModel): +@_transfer_type_convertible(_TokensTransferTypeConverter) +@dataclasses.dataclass(slots=True, kw_only=True) +class Tokens: """A typed map with a refined *string* member: 2 to 8 code points of lowercase ASCII. Exercises the member-level `minLength`/`maxLength`/`pattern` in every language. """ - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - strict=True, populate_by_name=True, extra="allow" - ) - - @pydantic.model_validator(mode="after") - def _validate_extras(self) -> typing.Any: - extra = typing.cast(dict[str, object], self.model_extra or {}) - errors: list[pydantic_core.InitErrorDetails] = [] - for key, value in list(extra.items()): - try: - extra[key] = _TOKENS_MEMBER.validate_python(value) - except pydantic.ValidationError as error: - for detail in error.errors(): - errors.append( - pydantic_core.InitErrorDetails( - type=pydantic_core.PydanticCustomError( - typing.cast(typing.Any, detail["type"]), - typing.cast(typing.Any, detail["msg"]), - ), - loc=(key, *detail["loc"]), - input=detail["input"], + additional_properties: dict[str, str] = dataclasses.field(default_factory=dict) + + +class _WidgetTransferTypeConverter( + temporalio.converter.TransferTypeConverter["Widget", typing.Any] +): + @typing_extensions.override + def from_transfer_type( + self, value: typing.Any, type_hint: type["Widget"] + ) -> "Widget": + violations: list[Violation] = [] + if not isinstance(value, dict): + raise ValidationError([Violation(path="", reason="expected object")]) + raw = typing.cast("dict[str, typing.Any]", value) + + id: str = typing.cast("typing.Any", None) + if "id" not in raw or raw["id"] is None: + violations.append(Violation(path="id", reason="required")) + else: + id_raw = raw["id"] + if not isinstance(id_raw, str): + violations.append(Violation(path="id", reason="expected string")) + else: + id = id_raw + + kind: str | None = None + if "kind" in raw: + kind_raw = raw["kind"] + if kind_raw is None: + violations.append( + Violation(path="kind", reason="explicit null not allowed") + ) + else: + if not isinstance(kind_raw, str): + violations.append(Violation(path="kind", reason="expected string")) + else: + kind = kind_raw + + name: str = typing.cast("typing.Any", None) + if "name" not in raw or raw["name"] is None: + violations.append(Violation(path="name", reason="required")) + else: + name_raw = raw["name"] + if not isinstance(name_raw, str): + violations.append(Violation(path="name", reason="expected string")) + else: + name = name_raw + + size: int | None = None + if "size" in raw: + size_raw = raw["size"] + if size_raw is None: + violations.append( + Violation(path="size", reason="explicit null not allowed") + ) + else: + size_parsed = _parse_spec_integer(size_raw, "size", violations) + if size_parsed is not None: + size = size_parsed + if size < 10: + violations.append( + Violation(path="size", reason=f"must be >= 10, got {size}") ) - ) - if errors: - raise pydantic.ValidationError.from_exception_data( - title=type(self).__name__, line_errors=errors - ) - return self - - @pydantic.model_serializer(mode="wrap") - def _serialize( - self, - _handler: typing.Callable[[pydantic.BaseModel], typing.Any], - ) -> dict[str, object]: - return { - key: _TOKENS_MEMBER.dump_python(value, mode="json", by_alias=True) - for key, value in typing.cast( - dict[str, object], self.model_extra or {} - ).items() - } - - -class Widget(pydantic.BaseModel): + if size > 20: + violations.append( + Violation(path="size", reason=f"must be <= 20, got {size}") + ) + + additional_properties: dict[str, typing.Any] = {} + for key in raw: + if key not in _WIDGET_DECLARED: + additional_properties[key] = raw[key] + if violations: + raise ValidationError(violations) + return Widget( + id=id, + kind=kind, + name=name, + size=size, + additional_properties=additional_properties, + ) + + @typing_extensions.override + def to_transfer_type(self, value: "Widget") -> typing.Any: + violations: list[Violation] = [] + out: dict[str, typing.Any] = {} + out["id"] = value.id + if value.kind is not None: + out["kind"] = value.kind + out["name"] = value.name + if value.size is not None: + if value.size < 10: + violations.append( + Violation(path="size", reason=f"must be >= 10, got {value.size}") + ) + if value.size > 20: + violations.append( + Violation(path="size", reason=f"must be <= 20, got {value.size}") + ) + out["size"] = value.size + for key, entry in value.additional_properties.items(): + out[key] = entry + if violations: + raise ValidationError(violations) + return out + + +@_transfer_type_convertible(_WidgetTransferTypeConverter) +@dataclasses.dataclass(slots=True, kw_only=True) +class Widget: """Base-type extension via allOf: WidgetBase is flattened in and the extension branch adds fields, so Widget merges to one standalone object with the union of properties ({id, kind, name, size}) and required ([id, name]). The `size` member is itself an @@ -1572,70 +3921,411 @@ class Widget(pydantic.BaseModel): outside it is rejected by the merged constraint. No allOf survives past the loader. """ - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - strict=True, populate_by_name=True, extra="allow" - ) - - id: str = pydantic.Field() + id: str - kind: str | None = pydantic.Field(default=None) + kind: str | None = None - name: str = pydantic.Field() + name: str - size: SpecInt | None = pydantic.Field(default=None, ge=10, le=20) + size: int | None = None """Optional integer with two allOf branches tightened to [10, 20].""" - _OPTIONAL_NON_NULLABLE_FIELDS: typing.ClassVar[frozenset[str]] = frozenset( - {"kind", "size"} + additional_properties: dict[str, typing.Any] = dataclasses.field( + default_factory=dict ) - @pydantic.model_validator(mode="wrap") - @classmethod - def _reject_null( - cls, - data: object, - handler: typing.Callable[[object], typing.Any], - ) -> typing.Any: - return _reject_explicit_null(cls, data, handler) - - @pydantic.model_serializer(mode="wrap") - def _serialize( - self, - handler: typing.Callable[[pydantic.BaseModel], typing.Any], - ) -> dict[str, object]: - return _emit_set_fields(self, handler) - -class WidgetBase(pydantic.BaseModel): +class _WidgetBaseTransferTypeConverter( + temporalio.converter.TransferTypeConverter["WidgetBase", typing.Any] +): + @typing_extensions.override + def from_transfer_type( + self, value: typing.Any, type_hint: type["WidgetBase"] + ) -> "WidgetBase": + violations: list[Violation] = [] + if not isinstance(value, dict): + raise ValidationError([Violation(path="", reason="expected object")]) + raw = typing.cast("dict[str, typing.Any]", value) + + id: str = typing.cast("typing.Any", None) + if "id" not in raw or raw["id"] is None: + violations.append(Violation(path="id", reason="required")) + else: + id_raw = raw["id"] + if not isinstance(id_raw, str): + violations.append(Violation(path="id", reason="expected string")) + else: + id = id_raw + + kind: str | None = None + if "kind" in raw: + kind_raw = raw["kind"] + if kind_raw is None: + violations.append( + Violation(path="kind", reason="explicit null not allowed") + ) + else: + if not isinstance(kind_raw, str): + violations.append(Violation(path="kind", reason="expected string")) + else: + kind = kind_raw + + additional_properties: dict[str, typing.Any] = {} + for key in raw: + if key not in _WIDGET_BASE_DECLARED: + additional_properties[key] = raw[key] + if violations: + raise ValidationError(violations) + return WidgetBase( + id=id, + kind=kind, + additional_properties=additional_properties, + ) + + @typing_extensions.override + def to_transfer_type(self, value: "WidgetBase") -> typing.Any: + out: dict[str, typing.Any] = {} + out["id"] = value.id + if value.kind is not None: + out["kind"] = value.kind + for key, entry in value.additional_properties.items(): + out[key] = entry + return out + + +@_transfer_type_convertible(_WidgetBaseTransferTypeConverter) +@dataclasses.dataclass(slots=True, kw_only=True) +class WidgetBase: """A base object folded into Widget via allOf. It stays its own type; Widget copies its fields rather than referencing or subtyping it. """ - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - strict=True, populate_by_name=True, extra="allow" + id: str + + kind: str | None = None + + additional_properties: dict[str, typing.Any] = dataclasses.field( + default_factory=dict + ) + + +def _choices_value_from_transfer_type( + value: typing.Any, path: str, violations: list[Violation] +) -> ChoicesValue | None: + if isinstance(value, dict): + tagged = typing.cast("dict[str, typing.Any]", value) + tag = tagged.get("kind") + if tag == "circle": + try: + return _CircleTransferTypeConverter().from_transfer_type(value, Circle) + except ValidationError as error: + _collect(violations, path, error) + return None + if tag == "square": + try: + return _SquareTransferTypeConverter().from_transfer_type(value, Square) + except ValidationError as error: + _collect(violations, path, error) + return None + violations.append( + Violation( + path=path, + reason=f'unknown discriminator kind {tag}: expected one of ["circle", "square"]', + ) + ) + return None + violations.append(Violation(path=path, reason="expected one of: Circle, Square")) + return None + + +def _choices_value_to_transfer_type(value: ChoicesValue) -> typing.Any: + if isinstance(value, Circle): + return _CircleTransferTypeConverter().to_transfer_type(value) + return _SquareTransferTypeConverter().to_transfer_type(value) + + +def _note_from_transfer_type( + value: typing.Any, path: str, violations: list[Violation] +) -> Note | None: + if isinstance(value, dict): + tagged = typing.cast("dict[str, typing.Any]", value) + tag = tagged.get("kind") + if tag == "text": + try: + return _TextNoteTransferTypeConverter().from_transfer_type( + value, TextNote + ) + except ValidationError as error: + _collect(violations, path, error) + return None + if tag == "link": + try: + return _LinkNoteTransferTypeConverter().from_transfer_type( + value, LinkNote + ) + except ValidationError as error: + _collect(violations, path, error) + return None + violations.append( + Violation( + path=path, + reason=f'unknown discriminator kind {tag}: expected one of ["text", "link"]', + ) + ) + return None + violations.append( + Violation(path=path, reason="expected one of: TextNote, LinkNote") ) + return None + + +def _note_to_transfer_type(value: Note) -> typing.Any: + if isinstance(value, TextNote): + return _TextNoteTransferTypeConverter().to_transfer_type(value) + return _LinkNoteTransferTypeConverter().to_transfer_type(value) + + +def _shape_from_transfer_type( + value: typing.Any, path: str, violations: list[Violation] +) -> Shape | None: + if isinstance(value, dict): + tagged = typing.cast("dict[str, typing.Any]", value) + tag = tagged.get("kind") + if tag == "circle": + try: + return _CircleTransferTypeConverter().from_transfer_type(value, Circle) + except ValidationError as error: + _collect(violations, path, error) + return None + if tag == "square": + try: + return _SquareTransferTypeConverter().from_transfer_type(value, Square) + except ValidationError as error: + _collect(violations, path, error) + return None + violations.append( + Violation( + path=path, + reason=f'unknown discriminator kind {tag}: expected one of ["circle", "square"]', + ) + ) + return None + violations.append(Violation(path=path, reason="expected one of: Circle, Square")) + return None + + +def _shape_to_transfer_type(value: Shape) -> typing.Any: + if isinstance(value, Circle): + return _CircleTransferTypeConverter().to_transfer_type(value) + return _SquareTransferTypeConverter().to_transfer_type(value) + - id: str = pydantic.Field() +def _showcase_segments_item_from_transfer_type( + value: typing.Any, path: str, violations: list[Violation] +) -> ShowcaseSegmentsItem | None: + if isinstance(value, str): + if len(value) < 2: + violations.append( + Violation(path=path, reason=f"must have length >= 2, got {len(value)}") + ) + return value + if ( + not isinstance(value, bool) + and isinstance(value, (int, float)) + and abs(value) <= 9007199254740991 + and float(value).is_integer() + ): + number = int(value) + if number < 0: + violations.append( + Violation(path=path, reason=f"must be >= 0, got {number}") + ) + return number + violations.append(Violation(path=path, reason="expected one of: string, integer")) + return None + + +def _showcase_segments_item_to_transfer_type(value: ShowcaseSegmentsItem) -> typing.Any: + violations: list[Violation] = [] + if isinstance(value, str): + if len(value) < 2: + violations.append( + Violation(path="", reason=f"must have length >= 2, got {len(value)}") + ) + if not isinstance(value, bool) and isinstance(value, int): + if value < 0: + violations.append(Violation(path="", reason=f"must be >= 0, got {value}")) + if violations: + raise ValidationError(violations) + return value + + +def _showcase_id_or_name_from_transfer_type( + value: typing.Any, path: str, violations: list[Violation] +) -> str | int | None: + if isinstance(value, str): + if len(value) < 3: + violations.append( + Violation(path=path, reason=f"must have length >= 3, got {len(value)}") + ) + return value + if ( + not isinstance(value, bool) + and isinstance(value, (int, float)) + and abs(value) <= 9007199254740991 + and float(value).is_integer() + ): + number = int(value) + if number < 1: + violations.append( + Violation(path=path, reason=f"must be >= 1, got {number}") + ) + return number + violations.append(Violation(path=path, reason="expected one of: string, integer")) + return None + + +def _showcase_mode_from_transfer_type( + value: typing.Any, path: str, violations: list[Violation] +) -> typing.Literal["auto", "manual"] | int | None: + if isinstance(value, str): + narrowed = typing.cast('typing.Literal["auto", "manual"]', value) + if typing.cast("object", narrowed) not in ( + "auto", + "manual", + ): + violations.append( + Violation( + path=path, + reason=f'must be one of ["auto", "manual"], got {_quote(narrowed)}', + ) + ) + return narrowed + if ( + not isinstance(value, bool) + and isinstance(value, (int, float)) + and abs(value) <= 9007199254740991 + and float(value).is_integer() + ): + number = int(value) + if number < 0: + violations.append( + Violation(path=path, reason=f"must be >= 0, got {number}") + ) + return number + violations.append(Violation(path=path, reason="expected one of: string, integer")) + return None + + +def _showcase_payload_from_transfer_type( + value: typing.Any, path: str, violations: list[Violation] +) -> dict[str, typing.Any] | str | None: + if isinstance(value, dict): + return typing.cast("dict[str, typing.Any]", value) + if isinstance(value, str): + return value + violations.append(Violation(path=path, reason="expected one of: object, string")) + return None + + +def _showcase_detail_from_transfer_type( + value: typing.Any, path: str, violations: list[Violation] +) -> ShowcaseDetailObject | str | None: + if isinstance(value, dict): + try: + return _ShowcaseDetailObjectTransferTypeConverter().from_transfer_type( + value, ShowcaseDetailObject + ) + except ValidationError as error: + _collect(violations, path, error) + return None + if isinstance(value, str): + return value + violations.append( + Violation(path=path, reason="expected one of: ShowcaseDetailObject, string") + ) + return None - kind: str | None = pydantic.Field(default=None) - _OPTIONAL_NON_NULLABLE_FIELDS: typing.ClassVar[frozenset[str]] = frozenset({"kind"}) +def _showcase_detail_to_transfer_type(value: ShowcaseDetailObject | str) -> typing.Any: + if isinstance(value, ShowcaseDetailObject): + return _ShowcaseDetailObjectTransferTypeConverter().to_transfer_type(value) + return value - @pydantic.model_validator(mode="wrap") - @classmethod - def _reject_null( - cls, - data: object, - handler: typing.Callable[[object], typing.Any], - ) -> typing.Any: - return _reject_explicit_null(cls, data, handler) - @pydantic.model_serializer(mode="wrap") - def _serialize( - self, - handler: typing.Callable[[pydantic.BaseModel], typing.Any], - ) -> dict[str, object]: - return _emit_set_fields(self, handler) +def _showcase_shape_or_name_from_transfer_type( + value: typing.Any, path: str, violations: list[Violation] +) -> Circle | Square | str | None: + if isinstance(value, dict): + tagged = typing.cast("dict[str, typing.Any]", value) + tag = tagged.get("kind") + if tag == "circle": + try: + return _CircleTransferTypeConverter().from_transfer_type(value, Circle) + except ValidationError as error: + _collect(violations, path, error) + return None + if tag == "square": + try: + return _SquareTransferTypeConverter().from_transfer_type(value, Square) + except ValidationError as error: + _collect(violations, path, error) + return None + violations.append( + Violation( + path=path, + reason=f'unknown discriminator kind {tag}: expected one of ["circle", "square"]', + ) + ) + return None + if isinstance(value, str): + if len(value) > 32: + violations.append( + Violation(path=path, reason=f"must have length <= 32, got {len(value)}") + ) + return value + violations.append( + Violation(path=path, reason="expected one of: Circle, Square, string") + ) + return None + + +def _showcase_shape_or_name_to_transfer_type( + value: Circle | Square | str, +) -> typing.Any: + if isinstance(value, Circle): + return _CircleTransferTypeConverter().to_transfer_type(value) + if isinstance(value, Square): + return _SquareTransferTypeConverter().to_transfer_type(value) + return value + + +def _showcase_measurements_from_transfer_type( + value: typing.Any, path: str, violations: list[Violation] +) -> list[float] | str | None: + if isinstance(value, list): + items = typing.cast("list[float]", value) + if len(items) < 1: + violations.append( + Violation( + path=path, reason=f"must have at least 1 items, got {len(items)}" + ) + ) + _check_unique_items(items, path, violations) + return items + if isinstance(value, str): + if _PATTERN_F242E3A159C2422C.search(value) is None: + violations.append( + Violation( + path=path, + reason=f"must match pattern {_PATTERN_F242E3A159C2422C.pattern}, got {_quote(value)}", + ) + ) + return value + violations.append( + Violation(path=path, reason="expected one of: list[float], string") + ) + return None ChoicesValue: typing.TypeAlias = Circle | Square @@ -1658,38 +4348,4 @@ def _serialize( Shape: typing.TypeAlias = Circle | Square -ShowcaseSegmentsItem: typing.TypeAlias = ( - typing.Annotated[str, pydantic.Field(min_length=2)] - | typing.Annotated[SpecInt, pydantic.Field(ge=0)] -) - - -_ = Choices.model_rebuild() -_ = Showcase.model_rebuild() -_ATTRIBUTES_MEMBER: pydantic.TypeAdapter[typing.Any] = pydantic.TypeAdapter( - str, config=pydantic.ConfigDict(strict=True) -) -_CHOICES_MEMBER: pydantic.TypeAdapter[typing.Any] = pydantic.TypeAdapter( - ChoicesValue, config=pydantic.ConfigDict(strict=True) -) -_LABELS_MEMBER: pydantic.TypeAdapter[typing.Any] = pydantic.TypeAdapter( - str, config=pydantic.ConfigDict(strict=True) -) -_NICKNAMES_MEMBER: pydantic.TypeAdapter[typing.Any] = pydantic.TypeAdapter( - typing.Annotated[str | None, pydantic.Field(min_length=2)], - config=pydantic.ConfigDict(strict=True), -) -_QUOTAS_MEMBER: pydantic.TypeAdapter[typing.Any] = pydantic.TypeAdapter( - typing.Annotated[SpecInt, pydantic.Field(ge=0, le=100, multiple_of=5)], - config=pydantic.ConfigDict(strict=True), -) -_SHOWCASE_LEDGER_MEMBER: pydantic.TypeAdapter[typing.Any] = pydantic.TypeAdapter( - ShowcaseLedgerValue -) -_TOKENS_MEMBER: pydantic.TypeAdapter[typing.Any] = pydantic.TypeAdapter( - typing.Annotated[ - typing.Annotated[str, pydantic.Field(min_length=2, max_length=8)], - pydantic.AfterValidator(_check_pattern("^[a-z]+\\Z")), - ], - config=pydantic.ConfigDict(strict=True), -) +ShowcaseSegmentsItem: typing.TypeAlias = str | int diff --git a/samples/python/temporal/_definitions.py b/samples/python/temporal/_definitions.py index 00b1bd60..f2b94492 100644 --- a/samples/python/temporal/_definitions.py +++ b/samples/python/temporal/_definitions.py @@ -4,143 +4,180 @@ import base64 import collections.abc +import dataclasses import datetime -import math +import json import re import typing - -import pydantic -import pydantic.functional_validators -import pydantic_core +import temporalio.converter __all__ = [ - "SpecInt", - "DateTimeField", - "DateField", - "TimeField", - "DurationField", - "Base64Field", - "Base64UrlField", - "_check_multiple_of", - "_check_pattern", - "_check_format", - "_check_unique_items", + "ValidationError", + "Violation", "_check_contains", - "_reject_explicit_null", - "_emit_set_fields", + "_check_unique_items", + "_collect", + "_format_base64", + "_format_base64url", + "_format_date", + "_format_date_time", + "_format_duration", + "_format_time", + "_parse_base64", + "_parse_base64url", + "_parse_date", + "_parse_date_time", + "_parse_duration", + "_parse_spec_integer", + "_parse_time", + "_quote", + "_transfer_type_convertible", ] -_INTEGER_CAP = (1 << 53) - 1 +@dataclasses.dataclass(frozen=True, slots=True) +class Violation: + """A single constraint failure, located by JSON path.""" + path: str + reason: str -def _parse_spec_integer(value: object) -> int: - if isinstance(value, bool): - raise ValueError("expected integer, got boolean") - if isinstance(value, int): - out = value - elif isinstance(value, float): - if not value.is_integer(): - raise ValueError("number has a fractional part; not an integer") - out = int(value) - else: - raise ValueError(f"expected integer, got {type(value).__name__}") - if abs(out) > _INTEGER_CAP: - raise ValueError("integer exceeds +/-(2**53-1) cap") - return out +class ValidationError(Exception): + """Every constraint failure found in one (de)serialization pass.""" + + violations: list[Violation] + + def __init__(self, violations: list[Violation]) -> None: + self.violations = violations + detail = "; ".join(f"{item.path}: {item.reason}" for item in violations) + super().__init__(f"{len(violations)} validation error(s): {detail}") -SpecInt: typing.TypeAlias = typing.Annotated[ - int, pydantic.functional_validators.BeforeValidator(_parse_spec_integer) -] +def _quote(value: object) -> str: + """Renders a value in the JSON form every target quotes offending values in.""" -def _check_multiple_of( - divisor: float, -) -> typing.Callable[[float], float]: - """Builds an AfterValidator asserting `math.fmod`-exact divisibility for number fields.""" + try: + return json.dumps(value, ensure_ascii=False) + except (TypeError, ValueError): + return repr(value) - def validate(value: float) -> float: - if math.fmod(value, divisor) != 0: - raise ValueError(f"must be a multiple of {divisor}, got {value}") - return value - return validate +def _collect(violations: list[Violation], path: str, error: ValidationError) -> None: + """Re-paths a nested model's violations under `path` and appends them.""" + for inner in error.violations: + # A nested violation about the value *itself* carries no path of its own + # (a union branch's own constraint, an element-level check), so the + # prefix is the whole path -- never a dangling separator (P11). + nested = f"{path}.{inner.path}" if inner.path else path + violations.append(Violation(path=nested, reason=inner.reason)) -def _check_pattern( - pattern: str, -) -> typing.Callable[[str], str]: - """Builds an AfterValidator asserting an unanchored, ASCII-class regex match for string fields.""" - compiled = re.compile(pattern, re.ASCII) +_ModelT = typing.TypeVar("_ModelT") - def validate(value: str) -> str: - if compiled.search(value) is None: - raise ValueError(f"must match pattern {pattern}, got {value!r}") - return value - return validate +def _transfer_type_convertible( + converter: type[temporalio.converter.TransferTypeConverter[typing.Any, typing.Any]], +) -> collections.abc.Callable[[type[_ModelT]], type[_ModelT]]: + """Registers a transfer type converter on a model class. + Wraps `temporalio.converter.transfer_type_convertible` to erase the + converter's value-type parameter. Binding it directly on the decorated class + is circular for a static type checker -- the class's type depends on the + decorator, whose value type depends on the class -- which degrades the model + to `Unknown`. Erasing it here keeps the decorator idiomatic at each model and + resolves the cycle. + """ -def _check_format( - format_name: str, - pattern: str, - max_code_points: int | None = None, -) -> typing.Callable[[str], str]: - """Builds an AfterValidator asserting a value matches a pinned `format` regex (+ optional length guard).""" + return temporalio.converter.transfer_type_convertible(converter) - compiled = re.compile(pattern, re.ASCII) - def validate(value: str) -> str: - if ( - max_code_points is not None and len(value) > max_code_points - ) or compiled.search(value) is None: - raise ValueError(f"must be a valid {format_name}, got {value!r}") - return value +_INTEGER_CAP = (1 << 53) - 1 - return validate + +def _parse_spec_integer( + value: object, path: str, violations: list[Violation] +) -> int | None: + """Parses a JSON number as a spec integer (`1.0` accepted, `1.5` rejected).""" + + # `bool` is a subclass of `int`, so it must be excluded before the int check. + if isinstance(value, bool) or not isinstance(value, (int, float)): + violations.append(Violation(path=path, reason="expected integer")) + return None + if isinstance(value, float): + if not value.is_integer(): + violations.append(Violation(path=path, reason="expected integer")) + return None + out = int(value) + else: + out = value + if abs(out) > _INTEGER_CAP: + violations.append(Violation(path=path, reason="expected integer")) + return None + return out def _check_unique_items( - value: list[typing.Any], -) -> list[typing.Any]: - """An AfterValidator asserting an array's elements are pairwise distinct.""" + value: list[typing.Any], path: str, violations: list[Violation] +) -> None: + """Asserts an array's elements are pairwise distinct.""" - seen: dict[object, int] = {} + seen: list[typing.Any] = [] for index, element in enumerate(value): - if element in seen: - raise ValueError( - f"duplicate items: element at index {index} equals index {seen[element]}" - ) - seen[element] = index - return value + for earlier, previous in enumerate(seen): + if previous == element: + violations.append( + Violation( + path=path, + reason=( + f"duplicate items: element at index {index} " + f"equals index {earlier}" + ), + ) + ) + break + seen.append(element) def _check_contains( + value: list[typing.Any], matches: typing.Callable[[typing.Any], bool], min_contains: int, - max_contains: int | None = None, - bounded_min: bool = False, -) -> typing.Callable[[list[typing.Any]], list[typing.Any]]: - """Builds an AfterValidator asserting how many elements match the `contains` schema.""" - - def validate(value: list[typing.Any]) -> list[typing.Any]: - match_count = sum(1 for element in value if matches(element)) - if match_count < min_contains: - if bounded_min: - raise ValueError( - f"too few matching items: at least {min_contains}, got {match_count}" + max_contains: int | None, + bounded_min: bool, + path: str, + violations: list[Violation], +) -> None: + """Asserts how many of an array's elements match the `contains` schema.""" + + match_count = sum(1 for element in value if matches(element)) + if match_count < min_contains: + if bounded_min: + violations.append( + Violation( + path=path, + reason=( + f"too few matching items: at least {min_contains}, " + f"got {match_count}" + ), ) - raise ValueError("no element matches the required schema") - if max_contains is not None and match_count > max_contains: - raise ValueError( - f"too many matching items: at most {max_contains}, got {match_count}" ) - return value - - return validate + else: + violations.append( + Violation(path=path, reason="no element matches the required schema") + ) + if max_contains is not None and match_count > max_contains: + violations.append( + Violation( + path=path, + reason=( + f"too many matching items: at most {max_contains}, " + f"got {match_count}" + ), + ) + ) _TEMPORAL_DATE_TIME_RE = re.compile( @@ -177,43 +214,59 @@ def _valid_temporal_calendar(value: str) -> bool: return maximum > 0 and 1 <= day <= maximum -def _parse_date_time(value: object) -> object: - if not isinstance(value, str): - return value +def _parse_date_time( + value: str, path: str, violations: list[Violation] +) -> datetime.datetime | None: if _TEMPORAL_DATE_TIME_RE.match(value) is None or not _valid_temporal_calendar( value ): - raise ValueError(f"must be a valid date-time, got {value!r}") + violations.append( + Violation( + path=path, reason=f"must be a valid date-time, got {_quote(value)}" + ) + ) + return None normalized = value.upper() if normalized.endswith("Z"): normalized = normalized[:-1] + "+00:00" return datetime.datetime.fromisoformat(normalized) -def _parse_date(value: object) -> object: - if not isinstance(value, str): - return value +def _parse_date( + value: str, path: str, violations: list[Violation] +) -> datetime.date | None: if _TEMPORAL_DATE_RE.match(value) is None or not _valid_temporal_calendar(value): - raise ValueError(f"must be a valid date, got {value!r}") + violations.append( + Violation(path=path, reason=f"must be a valid date, got {_quote(value)}") + ) + return None return datetime.date.fromisoformat(value) -def _parse_time(value: object) -> object: - if not isinstance(value, str): - return value +def _parse_time( + value: str, path: str, violations: list[Violation] +) -> datetime.time | None: if _TEMPORAL_TIME_RE.match(value) is None: - raise ValueError(f"must be a valid time, got {value!r}") + violations.append( + Violation(path=path, reason=f"must be a valid time, got {_quote(value)}") + ) + return None normalized = value.upper() if normalized.endswith("Z"): normalized = normalized[:-1] + "+00:00" return datetime.time.fromisoformat(normalized) -def _parse_duration(value: object) -> object: - if not isinstance(value, str): - return value +def _parse_duration( + value: str, path: str, violations: list[Violation] +) -> datetime.timedelta | None: if _TEMPORAL_DURATION_RE.match(value) is None: - raise ValueError(f"must be a valid duration, got {value!r}") + violations.append( + Violation( + path=path, reason=f"must be a valid duration, got {_quote(value)}" + ) + ) + return None total = 0 number = "" for char in value[2:]: @@ -223,7 +276,12 @@ def _parse_duration(value: object) -> object: total += int(number) * {"H": 3600, "M": 60, "S": 1}[char] number = "" if total > _TEMPORAL_MAX_DURATION_SECONDS: - raise ValueError(f"must be a valid duration, got {value!r}") + violations.append( + Violation( + path=path, reason=f"must be a valid duration, got {_quote(value)}" + ) + ) + return None return datetime.timedelta(seconds=total) @@ -280,39 +338,18 @@ def _format_duration(value: datetime.timedelta) -> str: return out -DateTimeField: typing.TypeAlias = typing.Annotated[ - datetime.datetime, - pydantic.BeforeValidator(_parse_date_time), - pydantic.PlainSerializer(_format_date_time, return_type=str), -] -DateField: typing.TypeAlias = typing.Annotated[ - datetime.date, - pydantic.BeforeValidator(_parse_date), - pydantic.PlainSerializer(_format_date, return_type=str), -] -TimeField: typing.TypeAlias = typing.Annotated[ - datetime.time, - pydantic.BeforeValidator(_parse_time), - pydantic.PlainSerializer(_format_time, return_type=str), -] -DurationField: typing.TypeAlias = typing.Annotated[ - datetime.timedelta, - pydantic.BeforeValidator(_parse_duration), - pydantic.PlainSerializer(_format_duration, return_type=str), -] - - _BASE64_RE = re.compile( "^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?\\Z", re.ASCII ) _BASE64URL_RE = re.compile("^(?:[A-Za-z0-9_-]{4})*(?:[A-Za-z0-9_-]{2,3})?\\Z", re.ASCII) -def _parse_base64(value: typing.Any) -> bytes: - if isinstance(value, bytes): - return value - if not isinstance(value, str) or _BASE64_RE.match(value) is None: - raise ValueError(f"must be base64-encoded, got {value!r}") +def _parse_base64(value: str, path: str, violations: list[Violation]) -> bytes | None: + if _BASE64_RE.match(value) is None: + violations.append( + Violation(path=path, reason=f"must be base64-encoded, got {_quote(value)}") + ) + return None return base64.b64decode(value, validate=True) @@ -320,91 +357,18 @@ def _format_base64(value: bytes) -> str: return base64.b64encode(value).decode("ascii") -def _parse_base64url(value: typing.Any) -> bytes: - if isinstance(value, bytes): - return value - if not isinstance(value, str) or _BASE64URL_RE.match(value) is None: - raise ValueError(f"must be base64url-encoded, got {value!r}") +def _parse_base64url( + value: str, path: str, violations: list[Violation] +) -> bytes | None: + if _BASE64URL_RE.match(value) is None: + violations.append( + Violation( + path=path, reason=f"must be base64url-encoded, got {_quote(value)}" + ) + ) + return None return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4)) def _format_base64url(value: bytes) -> str: return base64.urlsafe_b64encode(value).rstrip(b"=").decode("ascii") - - -Base64Field: typing.TypeAlias = typing.Annotated[ - bytes, - pydantic.BeforeValidator(_parse_base64), - pydantic.PlainSerializer(_format_base64, return_type=str), -] -Base64UrlField: typing.TypeAlias = typing.Annotated[ - bytes, - pydantic.BeforeValidator(_parse_base64url), - pydantic.PlainSerializer(_format_base64url, return_type=str), -] - - -def _reject_explicit_null( - cls: type[pydantic.BaseModel], - data: object, - handler: typing.Callable[[object], typing.Any], -) -> typing.Any: - null_fields = typing.cast( - frozenset[str], getattr(cls, "_OPTIONAL_NON_NULLABLE_FIELDS") - ) - raw_data = data - pre_errors: list[pydantic_core.InitErrorDetails] = [] - if isinstance(data, dict): - values = typing.cast(dict[str, object], data) - pre_errors = [ - pydantic_core.InitErrorDetails( - type=pydantic_core.PydanticCustomError( - "null_for_nonnullable", "explicit null not allowed" - ), - loc=(field,), - input=None, - ) - for field in null_fields - if field in values and values[field] is None - ] - try: - instance = handler(raw_data) - except pydantic.ValidationError as error: - field_errors: list[pydantic_core.InitErrorDetails] = [] - for error_detail in typing.cast(list[dict[str, object]], error.errors()): - loc: tuple[str | int, ...] = tuple( - typing.cast(collections.abc.Iterable[str | int], error_detail["loc"]) - ) - field_errors.append( - pydantic_core.InitErrorDetails( - type=pydantic_core.PydanticCustomError( - typing.cast(typing.Any, error_detail["type"]), - typing.cast(typing.Any, error_detail["msg"]), - ), - loc=loc, - input=error_detail.get("input"), - ) - ) - raise pydantic.ValidationError.from_exception_data( - title=cls.__name__, line_errors=pre_errors + field_errors - ) from None - if pre_errors: - raise pydantic.ValidationError.from_exception_data( - title=cls.__name__, line_errors=pre_errors - ) - return instance - - -def _emit_set_fields( - model: pydantic.BaseModel, - handler: typing.Callable[[pydantic.BaseModel], typing.Any], -) -> dict[str, object]: - dumped = typing.cast(dict[str, object], handler(model)) - alias_of = { - name: (field.alias or name) for name, field in type(model).model_fields.items() - } - keep = {alias_of.get(name, name) for name in model.model_fields_set} - out = {key: value for key, value in dumped.items() if key in keep} - if model.model_extra: - out.update(typing.cast(dict[str, object], model.model_extra)) - return out diff --git a/samples/python/temporal/models.py b/samples/python/temporal/models.py index f844ec7f..8de3e071 100644 --- a/samples/python/temporal/models.py +++ b/samples/python/temporal/models.py @@ -2,20 +2,254 @@ from __future__ import annotations +import dataclasses import typing -import pydantic +import typing_extensions +import datetime +import temporalio.converter from ._definitions import ( - DateField, - DateTimeField, - DurationField, - TimeField, - _emit_set_fields, - _reject_explicit_null, + ValidationError, + Violation, + _format_date, + _format_date_time, + _format_duration, + _format_time, + _parse_date, + _parse_date_time, + _parse_duration, + _parse_time, + _transfer_type_convertible, ) -class Temporal(pydantic.BaseModel): +class _TemporalTransferTypeConverter( + temporalio.converter.TransferTypeConverter["Temporal", typing.Any] +): + @typing_extensions.override + def from_transfer_type( + self, value: typing.Any, type_hint: type["Temporal"] + ) -> "Temporal": + violations: list[Violation] = [] + if not isinstance(value, dict): + raise ValidationError([Violation(path="", reason="expected object")]) + raw = typing.cast("dict[str, typing.Any]", value) + + created_at: datetime.datetime = typing.cast("typing.Any", None) + if "createdAt" not in raw or raw["createdAt"] is None: + violations.append(Violation(path="createdAt", reason="required")) + else: + created_at_raw = raw["createdAt"] + if not isinstance(created_at_raw, str): + violations.append(Violation(path="createdAt", reason="expected string")) + else: + created_at_parsed = _parse_date_time( + created_at_raw, "createdAt", violations + ) + if created_at_parsed is not None: + created_at = created_at_parsed + + birthday: datetime.date = typing.cast("typing.Any", None) + if "birthday" not in raw or raw["birthday"] is None: + violations.append(Violation(path="birthday", reason="required")) + else: + birthday_raw = raw["birthday"] + if not isinstance(birthday_raw, str): + violations.append(Violation(path="birthday", reason="expected string")) + else: + birthday_parsed = _parse_date(birthday_raw, "birthday", violations) + if birthday_parsed is not None: + birthday = birthday_parsed + + alarm: datetime.time = typing.cast("typing.Any", None) + if "alarm" not in raw or raw["alarm"] is None: + violations.append(Violation(path="alarm", reason="required")) + else: + alarm_raw = raw["alarm"] + if not isinstance(alarm_raw, str): + violations.append(Violation(path="alarm", reason="expected string")) + else: + alarm_parsed = _parse_time(alarm_raw, "alarm", violations) + if alarm_parsed is not None: + alarm = alarm_parsed + + timeout: datetime.timedelta = typing.cast("typing.Any", None) + if "timeout" not in raw or raw["timeout"] is None: + violations.append(Violation(path="timeout", reason="required")) + else: + timeout_raw = raw["timeout"] + if not isinstance(timeout_raw, str): + violations.append(Violation(path="timeout", reason="expected string")) + else: + timeout_parsed = _parse_duration(timeout_raw, "timeout", violations) + if timeout_parsed is not None: + timeout = timeout_parsed + + updated_at: datetime.datetime | None = None + if "updatedAt" in raw: + updated_at_raw = raw["updatedAt"] + if updated_at_raw is None: + violations.append( + Violation(path="updatedAt", reason="explicit null not allowed") + ) + else: + if not isinstance(updated_at_raw, str): + violations.append( + Violation(path="updatedAt", reason="expected string") + ) + else: + updated_at_parsed = _parse_date_time( + updated_at_raw, "updatedAt", violations + ) + if updated_at_parsed is not None: + updated_at = updated_at_parsed + + expires_on: datetime.date | None = None + if "expiresOn" in raw: + expires_on_raw = raw["expiresOn"] + if expires_on_raw is None: + violations.append( + Violation(path="expiresOn", reason="explicit null not allowed") + ) + else: + if not isinstance(expires_on_raw, str): + violations.append( + Violation(path="expiresOn", reason="expected string") + ) + else: + expires_on_parsed = _parse_date( + expires_on_raw, "expiresOn", violations + ) + if expires_on_parsed is not None: + expires_on = expires_on_parsed + + reminder: datetime.time | None = None + if "reminder" in raw: + reminder_raw = raw["reminder"] + if reminder_raw is None: + violations.append( + Violation(path="reminder", reason="explicit null not allowed") + ) + else: + if not isinstance(reminder_raw, str): + violations.append( + Violation(path="reminder", reason="expected string") + ) + else: + reminder_parsed = _parse_time(reminder_raw, "reminder", violations) + if reminder_parsed is not None: + reminder = reminder_parsed + + retry_delay: datetime.timedelta | None = None + if "retryDelay" in raw: + retry_delay_raw = raw["retryDelay"] + if retry_delay_raw is None: + violations.append( + Violation(path="retryDelay", reason="explicit null not allowed") + ) + else: + if not isinstance(retry_delay_raw, str): + violations.append( + Violation(path="retryDelay", reason="expected string") + ) + else: + retry_delay_parsed = _parse_duration( + retry_delay_raw, "retryDelay", violations + ) + if retry_delay_parsed is not None: + retry_delay = retry_delay_parsed + + deleted_at: datetime.datetime | None = None + if "deletedAt" in raw: + deleted_at_raw = raw["deletedAt"] + if deleted_at_raw is None: + deleted_at = None + else: + if not isinstance(deleted_at_raw, str): + violations.append( + Violation(path="deletedAt", reason="expected string") + ) + else: + deleted_at_parsed = _parse_date_time( + deleted_at_raw, "deletedAt", violations + ) + if deleted_at_parsed is not None: + deleted_at = deleted_at_parsed + + archived_on: datetime.date | None = None + if "archivedOn" in raw: + archived_on_raw = raw["archivedOn"] + if archived_on_raw is None: + archived_on = None + else: + if not isinstance(archived_on_raw, str): + violations.append( + Violation(path="archivedOn", reason="expected string") + ) + else: + archived_on_parsed = _parse_date( + archived_on_raw, "archivedOn", violations + ) + if archived_on_parsed is not None: + archived_on = archived_on_parsed + + for key in raw: + if ( + key != "createdAt" + and key != "birthday" + and key != "alarm" + and key != "timeout" + and key != "updatedAt" + and key != "expiresOn" + and key != "reminder" + and key != "retryDelay" + and key != "deletedAt" + and key != "archivedOn" + ): + violations.append(Violation(path=key, reason="unknown field")) + if violations: + raise ValidationError(violations) + return Temporal( + created_at=created_at, + birthday=birthday, + alarm=alarm, + timeout=timeout, + updated_at=updated_at, + expires_on=expires_on, + reminder=reminder, + retry_delay=retry_delay, + deleted_at=deleted_at, + archived_on=archived_on, + ) + + @typing_extensions.override + def to_transfer_type(self, value: "Temporal") -> typing.Any: + violations: list[Violation] = [] + out: dict[str, typing.Any] = {} + out["createdAt"] = _format_date_time(value.created_at) + out["birthday"] = _format_date(value.birthday) + out["alarm"] = _format_time(value.alarm) + out["timeout"] = _format_duration(value.timeout) + if value.updated_at is not None: + out["updatedAt"] = _format_date_time(value.updated_at) + if value.expires_on is not None: + out["expiresOn"] = _format_date(value.expires_on) + if value.reminder is not None: + out["reminder"] = _format_time(value.reminder) + if value.retry_delay is not None: + out["retryDelay"] = _format_duration(value.retry_delay) + if value.deleted_at is not None: + out["deletedAt"] = _format_date_time(value.deleted_at) + if value.archived_on is not None: + out["archivedOn"] = _format_date(value.archived_on) + if violations: + raise ValidationError(violations) + return out + + +@_transfer_type_convertible(_TemporalTransferTypeConverter) +@dataclasses.dataclass(slots=True, kw_only=True) +class Temporal: """Temporal Root object materializing the four RFC 3339 temporal formats as native typed fields: date-time (offset & sub-second precision preserved), date, time (offset preserved @@ -23,70 +257,38 @@ class Temporal(pydantic.BaseModel): and nullable members of each. """ - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - strict=True, populate_by_name=True, extra="forbid" - ) - - created_at: DateTimeField = pydantic.Field(alias="createdAt") + created_at: datetime.datetime """Required event timestamp; materialized date-time (offset required, sub-second precision & offset preserved on round-trip). """ - birthday: DateField = pydantic.Field() + birthday: datetime.date """Required calendar date; materialized date (YYYY-MM-DD, lossless).""" - alarm: TimeField = pydantic.Field() + alarm: datetime.time """Required wall-clock time; materialized time (offset preserved when present, otherwise offset-less). """ - timeout: DurationField = pydantic.Field() + timeout: datetime.timedelta """Required time-only duration; materialized duration, canonicalized to PT…H…M…S (e.g. PT90M → PT1H30M). """ - updated_at: DateTimeField | None = pydantic.Field(default=None, alias="updatedAt") + updated_at: datetime.datetime | None = None """Optional date-time.""" - expires_on: DateField | None = pydantic.Field(default=None, alias="expiresOn") + expires_on: datetime.date | None = None """Optional date.""" - reminder: TimeField | None = pydantic.Field(default=None) + reminder: datetime.time | None = None """Optional time.""" - retry_delay: DurationField | None = pydantic.Field(default=None, alias="retryDelay") + retry_delay: datetime.timedelta | None = None """Optional duration.""" - deleted_at: DateTimeField | None = pydantic.Field(default=None, alias="deletedAt") + deleted_at: datetime.datetime | None = None """Optional and nullable date-time (may be absent or explicitly null).""" - archived_on: DateField | None = pydantic.Field(default=None, alias="archivedOn") + archived_on: datetime.date | None = None """Optional and nullable date.""" - - _OPTIONAL_NON_NULLABLE_FIELDS: typing.ClassVar[frozenset[str]] = frozenset( - { - "expiresOn", - "expires_on", - "reminder", - "retryDelay", - "retry_delay", - "updatedAt", - "updated_at", - } - ) - - @pydantic.model_validator(mode="wrap") - @classmethod - def _reject_null( - cls, - data: object, - handler: typing.Callable[[object], typing.Any], - ) -> typing.Any: - return _reject_explicit_null(cls, data, handler) - - @pydantic.model_serializer(mode="wrap") - def _serialize( - self, - handler: typing.Callable[[pydantic.BaseModel], typing.Any], - ) -> dict[str, object]: - return _emit_set_fields(self, handler) diff --git a/samples/python/tests/json_converter_helper.py b/samples/python/tests/json_converter_helper.py new file mode 100644 index 00000000..e984dbe0 --- /dev/null +++ b/samples/python/tests/json_converter_helper.py @@ -0,0 +1,102 @@ +"""Shared helpers for the generated JSON-Schema Python packages. + +Generated models are plain ``@dataclasses.dataclass(slots=True, kw_only=True)`` +types whose entire wire contract lives in a private +``_TransferTypeConverter`` registered through +``temporalio.converter.transfer_type_convertible``. + +The Temporal SDK wraps *every* payload converter — including +``DataConverter.default`` — in ``_TemporalTransferTypePayloadConverter``, which +looks that converter up on the value's class. So the generated models round-trip +through the **default** data converter with no user wiring and no contrib +package. That is the load-bearing claim of this design, which is why +:func:`decode` and :func:`encode` go through +``temporalio.converter.DataConverter.default.payload_converter`` rather than +through the converter object directly. + +:func:`converter_for` reaches the registered converter off the class the way +``advanced/samples/python/tests/test_start_workflow.py`` does. Negative tests use +it so the generated ``ValidationError`` surfaces unwrapped (the payload converter +would otherwise wrap it). +""" + +from __future__ import annotations + +import json +from pathlib import Path +import typing + +import temporalio.converter +from temporalio.api.common.v1 import Payload + +T = typing.TypeVar("T") + +#: Canonical cross-language wire fixtures, shared with the Go/TS/Java suites. +#: Never modify them — they are the polyglot contract. +WIRE_FIXTURE_ROOT = Path(__file__).resolve().parents[2] / "wire" / "json_schema" + + +def fixture_dir(suite: str) -> Path: + """Directory holding one suite's canonical wire fixtures.""" + return WIRE_FIXTURE_ROOT / suite + + +def fixture_bytes(suite: str, name: str) -> bytes: + """Raw bytes of a canonical wire fixture, exactly as they arrive on the wire.""" + return (fixture_dir(suite) / name).read_bytes() + + +def load_fixture(suite: str, name: str) -> typing.Any: + """A canonical wire fixture parsed as a plain JSON value.""" + return json.loads((fixture_dir(suite) / name).read_text(encoding="utf-8")) + + +def converter_for( + cls: type[T], +) -> temporalio.converter.TransferTypeConverter[T, typing.Any]: + """The ``TransferTypeConverter`` the generator registered on ``cls``. + + Used by negative tests: calling ``from_transfer_type`` / ``to_transfer_type`` + directly surfaces the generated ``ValidationError`` (and its structured + ``violations``) instead of whatever the payload converter wraps it in. + """ + return typing.cast( + "temporalio.converter.TransferTypeConverter[T, typing.Any]", + getattr(cls, "__temporal_transfer_type_converter"), + ) + + +def _payload_converter() -> temporalio.converter.PayloadConverter: + return temporalio.converter.DataConverter.default.payload_converter + + +def decode(cls: type[T], data: bytes) -> T: + """Deserialize json/plain wire bytes into ``cls`` via the *default* converter.""" + payload = Payload(metadata={"encoding": b"json/plain"}, data=data) + return _payload_converter().from_payloads([payload], [cls])[0] + + +def encode(model: object) -> typing.Any: + """Serialize a model via the *default* converter, returned as a JSON value.""" + encoded = _payload_converter().to_payloads([model]) + assert encoded, "payload converter produced no payloads" + return json.loads(encoded[0].data) + + +def decode_fixture(cls: type[T], suite: str, name: str) -> T: + """Deserialize a canonical wire fixture into ``cls`` via the default converter.""" + return decode(cls, fixture_bytes(suite, name)) + + +def violation_pairs(error: typing.Any) -> list[tuple[str, str]]: + """A generated ``ValidationError``'s violations as ``(path, reason)`` pairs. + + Aggregation (P11) is asserted on this list: one bad payload yields every + violation it contains, in declared-property order. + """ + return [(violation.path, violation.reason) for violation in error.violations] + + +def violation_paths(error: typing.Any) -> list[str]: + """Just the paths of a generated ``ValidationError``'s violations.""" + return [violation.path for violation in error.violations] diff --git a/samples/python/tests/test_chat.py b/samples/python/tests/test_chat.py index d9ed5446..2231e99e 100644 --- a/samples/python/tests/test_chat.py +++ b/samples/python/tests/test_chat.py @@ -1,11 +1,6 @@ -import json -from pathlib import Path import typing import pytest -from pydantic import ValidationError -from temporalio.api.common.v1 import Payload -from temporalio.contrib.pydantic import pydantic_data_converter from chat import ( Labels, @@ -14,121 +9,254 @@ SendMessageInput, SendMessageOutput, ) +from chat._definitions import ValidationError +from chat.models import DEFAULT_PRIORITY + +from tests.json_converter_helper import ( + converter_for, + decode_fixture, + encode, + load_fixture, + violation_pairs, +) - -WIRE_FIXTURE_DIR = Path(__file__).resolve().parents[2] / "wire" / "json_schema" / "chat" - - -def load_fixture(name: str) -> object: - return json.loads((WIRE_FIXTURE_DIR / name).read_text(encoding="utf-8")) - - -def fixture_bytes(name: str) -> bytes: - return (WIRE_FIXTURE_DIR / name).read_bytes() - - -def roundtrip_fixture(name: str, model_type: type[typing.Any]) -> typing.Any: - payload = Payload( - metadata={"encoding": b"json/plain"}, - data=fixture_bytes(name), - ) - converter = pydantic_data_converter.payload_converter - model = converter.from_payloads([payload], [model_type])[0] - encoded = converter.to_payloads([model]) - assert encoded is not None - assert json.loads(encoded[0].data) == load_fixture(name) +SUITE = "chat" + + +def expect_roundtrip( + name: str, + model_type: type[typing.Any], + *, + collapsed: tuple[str, ...] = (), +) -> typing.Any: + """Decode a fixture through the default converter, re-encode, compare. + + ``collapsed`` names top-level keys the fixture carries as an explicit `null` + on an optional+nullable member: Python now drops them on re-serialize (see + :func:`test_message_full_optional_nullable_null_collapses`). Everything else + round-trips byte-identically. + """ + expected = typing.cast("dict[str, typing.Any]", load_fixture(SUITE, name)) + for key in collapsed: + del expected[key] + model = decode_fixture(model_type, SUITE, name) + assert encode(model) == expected return model -def test_optional_non_nullable_fields_reject_explicit_null() -> None: - _ = Room(roomId="room-1", displayName="General", topic=None) - - with pytest.raises(ValidationError): - _ = Room(roomId="room-1", displayName="General", topic=None, members=None) - - with pytest.raises(ValidationError): - _ = Room(roomId="room-1", displayName="General", topic=None, labels=None) - +def test_optional_non_nullable_members_reject_explicit_null() -> None: + converter = converter_for(Room) -def test_model_dump_omits_unset_defaults() -> None: - message = Message(body="hello") - assert message.model_dump(by_alias=True) == {"kind": "text", "body": "hello"} - - message_with_priority = Message(body="hello", priority=0) - assert message_with_priority.model_dump(by_alias=True) == { + # `topic` is required+nullable, so an explicit null IS the value. + room = converter.from_transfer_type( + {"roomId": "room-1", "displayName": "General", "topic": None}, Room + ) + assert room.topic is None + assert room.members is None + assert room.labels is None + assert room.additional_properties == {} + + # `members` and `labels` are optional and NON-nullable, so an explicit null is + # a violation — and both are reported from the one payload (P11 aggregation), + # in declared-property order. + with pytest.raises(ValidationError) as excinfo: + _ = converter.from_transfer_type( + { + "roomId": "room-1", + "displayName": "General", + "topic": None, + "members": None, + "labels": None, + }, + Room, + ) + assert violation_pairs(excinfo.value) == [ + ("members", "explicit null not allowed"), + ("labels", "explicit null not allowed"), + ] + + +def test_required_members_and_unknown_fields_aggregate() -> None: + # A closed object reports every structural problem at once. + with pytest.raises(ValidationError) as excinfo: + _ = converter_for(SendMessageInput).from_transfer_type( + {"extra": True}, SendMessageInput + ) + assert violation_pairs(excinfo.value) == [ + ("roomId", "required"), + ("message", "required"), + ("extra", "unknown field"), + ] + + with pytest.raises(ValidationError) as excinfo: + _ = converter_for(SendMessageOutput).from_transfer_type({}, SendMessageOutput) + assert violation_pairs(excinfo.value) == [("messageId", "required")] + + +def test_serialize_omits_unset_defaulted_members() -> None: + # `priority` carries `default: 0`, which is **advisory**: it is not the + # dataclass field default, so an unset `priority` stays `None` and is OMITTED + # on serialize, keeping the wire byte-identical to the fixtures. The default is + # exposed as the module-level `DEFAULT_PRIORITY` constant the consumer applies, + # exactly as TypeScript does (Go uses `PriorityOrDefault()`, Java + # `@JsonInclude(NON_NULL)`). + converter = converter_for(Message) + unset = Message(body="hello") + assert unset.priority is None + assert converter.to_transfer_type(unset) == {"kind": "text", "body": "hello"} + + assert converter.to_transfer_type(Message(body="hello", priority=7)) == { "kind": "text", "body": "hello", - "priority": 0, + "priority": 7, } + # A `const` member, unlike a `default`, DOES carry its value as the dataclass + # default — it is the only admissible value, not a suggestion. + assert unset.kind == "text" -def test_model_dump_preserves_set_fields_and_extra_values() -> None: - room = Room.model_validate( - {"roomId": "room-1", "displayName": "General", "topic": None, "color": "blue"} +def test_default_constants_are_advisory() -> None: + # The advisory contract, stated directly: reading a defaulted member applies + # the emitted constant, and doing so changes nothing about the wire. + assert DEFAULT_PRIORITY == 0 + message = converter_for(Message).from_transfer_type( + {"kind": "text", "body": "hi"}, Message ) + assert message.priority is None + assert ( + message.priority if message.priority is not None else DEFAULT_PRIORITY + ) == DEFAULT_PRIORITY + assert "priority" not in converter_for(Message).to_transfer_type(message) + - assert room.model_dump(by_alias=True) == { +def test_serialize_preserves_extras_and_required_nullable_null() -> None: + room = converter_for(Room).from_transfer_type( + {"roomId": "room-1", "displayName": "General", "topic": None, "color": "blue"}, + Room, + ) + # An open object's undeclared keys land in the explicit catch-all member. + assert room.additional_properties == {"color": "blue"} + assert converter_for(Room).to_transfer_type(room) == { "roomId": "room-1", "displayName": "General", + # required+nullable: the explicit null survives the round-trip. "topic": None, "color": "blue", } def test_labels_validates_and_serializes_as_typed_map() -> None: - labels = Labels.model_validate({"channel": "general", "team": "support"}) - assert labels.model_dump() == {"channel": "general", "team": "support"} - - with pytest.raises(ValidationError): - _ = Labels.model_validate({"channel": 42}) + converter = converter_for(Labels) - too_many = {f"key-{index}": "value" for index in range(51)} - with pytest.raises(ValidationError): - _ = Labels.model_validate(too_many) + labels = converter.from_transfer_type( + {"channel": "general", "team": "support"}, Labels + ) + assert labels.additional_properties == {"channel": "general", "team": "support"} + assert converter.to_transfer_type(labels) == { + "channel": "general", + "team": "support", + } + # A map-shaped model is constructed through its catch-all member. + assert converter.to_transfer_type(Labels(additional_properties={"a": "b"})) == { + "a": "b" + } + with pytest.raises(ValidationError) as excinfo: + _ = converter.from_transfer_type({"channel": 42}, Labels) + assert violation_pairs(excinfo.value) == [("channel", "expected string")] -def test_integer_fields_follow_json_schema_number_semantics() -> None: - message = Message.model_validate({"body": "hello", "priority": 1.0}) - assert message.priority == 1 + too_many = {f"key-{index}": "value" for index in range(51)} + with pytest.raises(ValidationError) as excinfo: + _ = converter.from_transfer_type(too_many, Labels) + assert violation_pairs(excinfo.value) == [ + ("", "must have at most 50 properties, got 51") + ] + + +def test_integer_members_follow_json_schema_number_semantics() -> None: + converter = converter_for(Message) + + # An integral JSON number is an integer. + assert ( + converter.from_transfer_type( + {"kind": "text", "body": "hi", "priority": 1.0}, Message + ).priority + == 1 + ) - with pytest.raises(ValidationError): - _ = Message.model_validate({"body": "hello", "priority": True}) + # A boolean, a fractional number, and a value past the +/-(2**53-1) cap are + # all "expected integer" — the single reason TypeScript uses for all three. + for bad in (True, 1.5, 2**53): + with pytest.raises(ValidationError) as excinfo: + _ = converter.from_transfer_type( + {"kind": "text", "body": "hi", "priority": bad}, Message + ) + assert violation_pairs(excinfo.value) == [("priority", "expected integer")] - with pytest.raises(ValidationError): - _ = Message.model_validate({"body": "hello", "priority": 1.5}) - with pytest.raises(ValidationError): - _ = Message.model_validate({"body": "hello", "priority": 2**53}) +def test_const_member_rejects_a_wrong_wire_value() -> None: + with pytest.raises(ValidationError) as excinfo: + _ = converter_for(Message).from_transfer_type( + {"kind": "image", "body": "hi"}, Message + ) + assert violation_pairs(excinfo.value) == [("kind", 'must equal "text"')] -def test_canonical_wire_fixtures_roundtrip_through_temporal_pydantic_converter() -> ( - None -): - message = typing.cast(Message, roundtrip_fixture("message-minimal.json", Message)) +def test_canonical_wire_fixtures_roundtrip_through_the_default_converter() -> None: + message = typing.cast(Message, expect_roundtrip("message-minimal.json", Message)) assert message.kind == "text" assert message.body == "hi" assert message.reply_to_id is None - assert message.priority == 0 - - full_message = typing.cast(Message, roundtrip_fixture("message-full.json", Message)) + # `priority` is unset on the wire, so it stays unset in memory and omitted on + # the way back out; the schema default is advisory (DEFAULT_PRIORITY). + assert message.priority is None + assert (message.priority if message.priority is not None else DEFAULT_PRIORITY) == 0 + + full_message = typing.cast( + Message, + expect_roundtrip("message-full.json", Message, collapsed=("replyToId",)), + ) assert full_message.reply_to_id is None assert full_message.priority == 7 - room = typing.cast(Room, roundtrip_fixture("room-open.json", Room)) + room = typing.cast(Room, expect_roundtrip("room-open.json", Room)) assert room.room_id == "r1" - assert room.model_extra == {"x-extra": 42} + assert room.topic is None + assert room.members == ["a"] + assert room.additional_properties == {"x-extra": 42} - labels = typing.cast(Labels, roundtrip_fixture("labels.json", Labels)) - assert labels.model_extra == {"env": "prod", "team": "core"} + labels = typing.cast(Labels, expect_roundtrip("labels.json", Labels)) + assert labels.additional_properties == {"env": "prod", "team": "core"} request = typing.cast( SendMessageInput, - roundtrip_fixture("send-message-input.json", SendMessageInput), + expect_roundtrip("send-message-input.json", SendMessageInput), ) assert request.message.body == "hi" response = typing.cast( SendMessageOutput, - roundtrip_fixture("send-message-output.json", SendMessageOutput), + expect_roundtrip("send-message-output.json", SendMessageOutput), ) assert response.message_id == "m1" + + +def test_message_full_optional_nullable_null_collapses() -> None: + # message-full.json carries `replyToId: null` on an optional+nullable member. + # Absent and explicit null are the same in-memory state (None), and both + # re-serialize as omitted, so the explicit null does NOT survive. Python now + # matches Go and Java here (samples/go/tests/json_schema_chat_test.go verifies + # message-full.json by field checks for exactly this reason); TypeScript is + # the only target that still preserves it. + wire = typing.cast( + "dict[str, typing.Any]", load_fixture(SUITE, "message-full.json") + ) + assert wire["replyToId"] is None + + converter = converter_for(Message) + from_null = converter.from_transfer_type(wire, Message) + from_absent = converter.from_transfer_type( + {key: value for key, value in wire.items() if key != "replyToId"}, Message + ) + assert from_null == from_absent + assert "replyToId" not in converter.to_transfer_type(from_null) diff --git a/samples/python/tests/test_kb.py b/samples/python/tests/test_kb.py index 50313b94..27774ca3 100644 --- a/samples/python/tests/test_kb.py +++ b/samples/python/tests/test_kb.py @@ -1,11 +1,8 @@ from __future__ import annotations -import json -from pathlib import Path import typing -from temporalio.api.common.v1 import Payload -from temporalio.contrib.pydantic import pydantic_data_converter +import pytest from kb import Block from kb import Category @@ -13,68 +10,113 @@ from kb import GetPageInput from kb import Page from kb import PutBlockOutput +from kb._definitions import ValidationError -WIRE_FIXTURE_DIR = Path(__file__).resolve().parents[2] / "wire" / "json_schema" / "kb" +from tests.json_converter_helper import ( + converter_for, + decode_fixture, + encode, + load_fixture, + violation_pairs, +) +SUITE = "kb" -def load_fixture(name: str) -> object: - return json.loads((WIRE_FIXTURE_DIR / name).read_text(encoding="utf-8")) - -def fixture_bytes(name: str) -> bytes: - return (WIRE_FIXTURE_DIR / name).read_bytes() - - -def roundtrip_fixture(name: str, model_type: type[typing.Any]) -> typing.Any: - payload = Payload( - metadata={"encoding": b"json/plain"}, - data=fixture_bytes(name), - ) - converter = pydantic_data_converter.payload_converter - model = converter.from_payloads([payload], [model_type])[0] - encoded = converter.to_payloads([model]) - assert encoded is not None - assert json.loads(encoded[0].data) == load_fixture(name) +def expect_roundtrip(name: str, model_type: type[typing.Any]) -> typing.Any: + """Decode a fixture through the default converter, re-encode, compare.""" + model = decode_fixture(model_type, SUITE, name) + assert encode(model) == load_fixture(SUITE, name) return model -def test_kb_wire_fixtures_roundtrip_through_pydantic_converter() -> None: - page = typing.cast(Page, roundtrip_fixture("page.json", Page)) - assert page.page_id == "page-1" - assert page.blocks is not None - assert page.blocks[0].block_id == "block-1" - assert page.blocks[0].page is None - assert page.blocks[0].style is not None - assert page.blocks[0].style.bold is True - - block = typing.cast( - Block, - roundtrip_fixture("block.json", Block), - ) - assert block.block_id == "block-1" - assert block.page is None - - category = typing.cast( - Category, - roundtrip_fixture("category-tree.json", Category), - ) +def test_kb_wire_fixtures_roundtrip_through_the_default_converter() -> None: + category = typing.cast(Category, expect_roundtrip("category-tree.json", Category)) assert category.children is not None assert category.children[0].id == "child" request = typing.cast( - GetPageInput, - roundtrip_fixture("get-page-input.json", GetPageInput), + GetPageInput, expect_roundtrip("get-page-input.json", GetPageInput) ) assert request.page_id == "page-1" category_request = typing.cast( GetCategoryTreeInput, - roundtrip_fixture("get-category-tree-input.json", GetCategoryTreeInput), + expect_roundtrip("get-category-tree-input.json", GetCategoryTreeInput), ) assert category_request.root_id == "root" response = typing.cast( - PutBlockOutput, - roundtrip_fixture("put-block-output.json", PutBlockOutput), + PutBlockOutput, expect_roundtrip("put-block-output.json", PutBlockOutput) ) assert response.revision == 7 + + +def test_block_back_reference_null_collapses_on_roundtrip() -> None: + # `Block.page` is the optional+nullable back-reference that terminates the + # Page <-> Block cycle. A dataclass has no presence channel, so absent and + # explicit `null` are the same in-memory state (None) and both re-serialize as + # OMITTED — the explicit `"page": null` in the fixtures does not survive. + # Python now matches Go and Java, which verify page.json/block.json by field + # checks for exactly this reason (samples/go/tests/json_schema_kb_test.go). + block = decode_fixture(Block, SUITE, "block.json") + assert block.block_id == "block-1" + assert block.order == 0 + assert block.page is None + assert block.style is not None + assert block.style.bold is True + + block_wire = typing.cast("dict[str, typing.Any]", load_fixture(SUITE, "block.json")) + assert block_wire["page"] is None + assert encode(block) == { + key: value for key, value in block_wire.items() if key != "page" + } + + page = decode_fixture(Page, SUITE, "page.json") + assert page.page_id == "page-1" + assert page.meta.author == "nexgen" + assert page.blocks is not None + assert page.blocks[0].block_id == "block-1" + assert page.blocks[0].page is None + assert page.blocks[0].style is not None + assert page.blocks[0].style.bold is True + + page_wire = typing.cast("dict[str, typing.Any]", load_fixture(SUITE, "page.json")) + expected_page = {**page_wire} + expected_page["blocks"] = [ + { + key: value + for key, value in typing.cast("dict[str, typing.Any]", nested).items() + if key != "page" + } + for nested in typing.cast("list[typing.Any]", page_wire["blocks"]) + ] + assert encode(page) == expected_page + + +def test_nested_violations_carry_the_parent_path() -> None: + # A nested `$ref` re-paths its violations under the parent member, and the + # element index rides along for an array of models (P11 aggregation across + # two different sub-objects of one payload). + with pytest.raises(ValidationError) as excinfo: + _ = converter_for(Page).from_transfer_type( + { + "pageId": "page-1", + "title": "Runtime coverage", + "meta": {}, + "blocks": [{"blockId": "block-1", "order": 0}, {"order": 1}], + }, + Page, + ) + assert violation_pairs(excinfo.value) == [ + ("meta.author", "required"), + ("blocks[1].blockId", "required"), + ] + + +def test_numeric_bound_on_a_nested_member() -> None: + with pytest.raises(ValidationError) as excinfo: + _ = converter_for(Block).from_transfer_type( + {"blockId": "block-1", "order": -1}, Block + ) + assert violation_pairs(excinfo.value) == [("order", "must be >= 0, got -1")] diff --git a/samples/python/tests/test_kb_nexus.py b/samples/python/tests/test_kb_nexus.py index fb546ed7..94efca11 100644 --- a/samples/python/tests/test_kb_nexus.py +++ b/samples/python/tests/test_kb_nexus.py @@ -6,19 +6,21 @@ workflow uses the Temporal SDK's built-in Nexus client directly — there is no generated API client — and references the generated operation definitions for end-to-end type safety. + +It is also the end-to-end proof that the generated dataclasses need **no data +converter wiring at all**: the environment below runs on the SDK's default data +converter, which finds each model's ``TransferTypeConverter`` through the class +attribute ``temporalio.converter.transfer_type_convertible`` set. """ from __future__ import annotations -import json import shutil -from pathlib import Path import typing import uuid from nexusrpc.handler import StartOperationContext, service_handler, sync_operation from temporalio import workflow -from temporalio.contrib.pydantic import pydantic_data_converter from temporalio.testing import WorkflowEnvironment from temporalio.worker import UnsandboxedWorkflowRunner, Worker @@ -32,12 +34,16 @@ PutBlockOutput, ) -WIRE_FIXTURE_DIR = Path(__file__).resolve().parents[2] / "wire" / "json_schema" / "kb" +from tests.json_converter_helper import converter_for, load_fixture + +SUITE = "kb" ENDPOINT = "knowledge-base" -def load_fixture(name: str) -> typing.Any: - return json.loads((WIRE_FIXTURE_DIR / name).read_text(encoding="utf-8")) +def parse_fixture(model_type: type[typing.Any], name: str) -> typing.Any: + return converter_for(model_type).from_transfer_type( + load_fixture(SUITE, name), model_type + ) @service_handler(service=KnowledgeBaseService) @@ -49,7 +55,7 @@ def __init__(self) -> None: async def get_page(self, _ctx: StartOperationContext, input: GetPageInput) -> Page: self.calls.append(("GetPage", input)) assert input.page_id == "page-1" - return Page.model_validate(load_fixture("page.json")) + return typing.cast(Page, parse_fixture(Page, "page.json")) @sync_operation async def put_block( @@ -59,7 +65,9 @@ async def put_block( assert input.block_id == "block-1" assert input.style is not None assert input.style.bold is True - return PutBlockOutput.model_validate(load_fixture("put-block-output.json")) + return typing.cast( + PutBlockOutput, parse_fixture(PutBlockOutput, "put-block-output.json") + ) @sync_operation async def get_category_tree( @@ -67,19 +75,19 @@ async def get_category_tree( ) -> Category: self.calls.append(("GetCategoryTree", input)) assert input.root_id == "root" - return Category.model_validate(load_fixture("category-tree.json")) + return typing.cast(Category, parse_fixture(Category, "category-tree.json")) @workflow.defn class KnowledgeBaseCallerWorkflow: @workflow.run - async def run(self) -> dict[str, object]: + async def run(self) -> dict[str, typing.Any]: client = workflow.create_nexus_client( service=KnowledgeBaseService, endpoint=ENDPOINT ) page = await client.execute_operation( - KnowledgeBaseService.get_page, GetPageInput(pageId="page-1") + KnowledgeBaseService.get_page, GetPageInput(page_id="page-1") ) block = page.blocks[0] if page.blocks is not None else None if block is None: @@ -91,7 +99,7 @@ async def run(self) -> dict[str, object]: category = await client.execute_operation( KnowledgeBaseService.get_category_tree, - GetCategoryTreeInput(rootId="root"), + GetCategoryTreeInput(root_id="root"), ) return { @@ -105,8 +113,9 @@ async def run(self) -> dict[str, object]: async def test_kb_operations_use_real_nexus_client() -> None: + # No `data_converter=` argument: the default data converter already carries + # the generated models. env = await WorkflowEnvironment.start_local( - data_converter=pydantic_data_converter, dev_server_existing_path=shutil.which("temporal"), ) task_queue = str(uuid.uuid4()) diff --git a/samples/python/tests/test_showcase.py b/samples/python/tests/test_showcase.py index 143ee597..c8660fef 100644 --- a/samples/python/tests/test_showcase.py +++ b/samples/python/tests/test_showcase.py @@ -1,11 +1,7 @@ -import json -from pathlib import Path +import dataclasses import typing import pytest -from pydantic import ValidationError -from temporalio.api.common.v1 import Payload -from temporalio.contrib.pydantic import pydantic_data_converter from showcase import ( Address, @@ -23,164 +19,215 @@ TextNote, Widget, ) - - -WIRE_FIXTURE_DIR = ( - Path(__file__).resolve().parents[2] / "wire" / "json_schema" / "showcase" +from showcase._definitions import ValidationError +from showcase.models import DEFAULT_DEBUG, DEFAULT_GREETING, DEFAULT_RETRIES + +from tests.json_converter_helper import ( + converter_for, + decode_fixture, + encode, + load_fixture, + violation_pairs, ) +SUITE = "showcase" + +# The ten required members of Showcase; every negative payload starts here so the +# only violations reported are the ones under test. Mirrors the `base` object the +# Go and TypeScript suites use. +BASE: dict[str, typing.Any] = { + "kind": "showcase", + "revision": 1, + "enabled": True, + "status": "active", + "tier": 1, + "scale": 1.5, + "name": "w", + "count": 1, + "active": True, + "category": "tools", +} + + +def expect_roundtrip( + name: str, + model_type: type[typing.Any], + *, + collapsed: tuple[str, ...] = (), +) -> typing.Any: + """Decode a fixture through the *default* data converter, re-encode, compare. + + ``collapsed`` names keys the fixture carries as an explicit `null` on an + optional+nullable member; Python drops those on re-serialize. Everything else + round-trips byte-identically — including a key the fixture omits on a member + carrying a schema `default`, which is advisory and never injected. + """ + expected = typing.cast("dict[str, typing.Any]", load_fixture(SUITE, name)) + for key in collapsed: + del expected[key] + model = decode_fixture(model_type, SUITE, name) + assert encode(model) == expected + return model -def load_fixture(name: str) -> object: - return json.loads((WIRE_FIXTURE_DIR / name).read_text(encoding="utf-8")) +def expect_showcase(name: str, *, collapsed: tuple[str, ...] = ()) -> Showcase: + return typing.cast(Showcase, expect_roundtrip(name, Showcase, collapsed=collapsed)) -def fixture_bytes(name: str) -> bytes: - return (WIRE_FIXTURE_DIR / name).read_bytes() +def parse(raw: dict[str, typing.Any]) -> Showcase: + return converter_for(Showcase).from_transfer_type(raw, Showcase) -def roundtrip_fixture(name: str, model_type: type[typing.Any]) -> typing.Any: - payload = Payload( - metadata={"encoding": b"json/plain"}, - data=fixture_bytes(name), - ) - converter = pydantic_data_converter.payload_converter - model = converter.from_payloads([payload], [model_type])[0] - encoded = converter.to_payloads([model]) - assert encoded is not None - assert json.loads(encoded[0].data) == load_fixture(name) - return model +def parse_violations(raw: dict[str, typing.Any]) -> list[tuple[str, str]]: + """The ``(path, reason)`` pairs one bad Showcase payload produces.""" + with pytest.raises(ValidationError) as excinfo: + _ = parse(raw) + return violation_pairs(excinfo.value) -def test_const_default_and_reject_null_semantics() -> None: - # Const `kind`/`revision`/`enabled` are injected when omitted and rejected - # when wrong; enum fields (status/tier/scale) are required (no injection). - minimal = Showcase.model_validate( - { - "name": "Widget", - "count": 3, - "active": True, - "category": "tools", - "status": "active", - "tier": 1, - "scale": 1.5, - } - ) + +def test_const_and_enum_value_sets() -> None: + # `kind`/`revision`/`enabled` are required consts; `status`/`tier`/`scale` are + # required closed value sets. All six must be on the wire. + minimal = parse(BASE) assert minimal.kind == "showcase" assert minimal.revision == 1 assert minimal.enabled is True assert minimal.status == "active" assert minimal.tier == 1 assert minimal.scale == 1.5 - # Default is present as a value but omitted from the serialized wire form. - assert minimal.retries == 3 - assert minimal.model_dump(by_alias=True) == { - "kind": "showcase", - "revision": 1, - "enabled": True, - "status": "active", - "tier": 1, - "scale": 1.5, - "name": "Widget", - "count": 3, - "active": True, - "category": "tools", - } + # A `default` is advisory: it is NOT the dataclass field default, so an unset + # member stays `None` and is omitted on the way back out. + assert minimal.retries is None + assert minimal.greeting is None + assert minimal.debug is None + assert converter_for(Showcase).to_transfer_type(minimal) == BASE + + # A `const` member, unlike a `default`, DOES carry its value as the dataclass + # default — it is the only admissible value, not a suggestion — so a + # hand-constructed model needs only the non-const required members. + constructed = Showcase( + status="active", + tier=1, + scale=1.5, + name="w", + count=1, + active=True, + category="tools", + ) + assert constructed.kind == "showcase" + assert constructed.revision == 1 + assert constructed.enabled is True + assert converter_for(Showcase).to_transfer_type(constructed) == BASE + + # Wrong const values. + assert parse_violations({**BASE, "kind": "nope"}) == [ + ("kind", 'must equal "showcase"') + ] + assert parse_violations({**BASE, "revision": 2}) == [("revision", "must equal 1")] + assert parse_violations({**BASE, "enabled": False}) == [ + ("enabled", "must equal true") + ] - with pytest.raises(ValidationError): - _ = Showcase.model_validate( - {"kind": "nope", "name": "w", "count": 1, "active": True, "category": None} - ) + # Out-of-set enum values, named with the admissible set and the offending value. + assert parse_violations({**BASE, "status": "archived"}) == [ + ("status", 'must be one of ["active", "inactive", "pending"], got "archived"') + ] + assert parse_violations({**BASE, "tier": 9}) == [ + ("tier", "must be one of [1, 2, 3], got 9") + ] + assert parse_violations({**BASE, "scale": 3.5}) == [ + ("scale", "must be one of [1.5, 2.5], got 3.5") + ] - # A wrong integer const value is rejected. - with pytest.raises(ValidationError) as const_exc: - _ = Showcase.model_validate( - { - "revision": 2, - "name": "w", - "count": 1, - "active": True, - "category": "tools", - "status": "active", - "tier": 1, - "scale": 1.5, - } - ) - assert "revision must equal 1" in str(const_exc.value) - - # Out-of-set enum values are rejected with an informative reason (the float - # enum `scale` is plain `float`, closed only by the membership validator). - enum_base = { - "name": "w", - "count": 1, - "active": True, - "category": "tools", - "status": "active", - "tier": 1, - "scale": 1.5, - } - with pytest.raises(ValidationError) as status_exc: - _ = Showcase.model_validate({**enum_base, "status": "archived"}) - assert "must be one of" in str(status_exc.value) - with pytest.raises(ValidationError) as tier_exc: - _ = Showcase.model_validate({**enum_base, "tier": 9}) - assert "must be one of [1, 2, 3]" in str(tier_exc.value) - with pytest.raises(ValidationError) as scale_exc: - _ = Showcase.model_validate({**enum_base, "scale": 3.5}) - assert "must be one of [1.5, 2.5]" in str(scale_exc.value) - # A valid alternative member is accepted. - ok = Showcase.model_validate( - {**enum_base, "status": "pending", "tier": 3, "scale": 2.5} - ) + # Valid alternative members are accepted. + ok = parse({**BASE, "status": "pending", "tier": 3, "scale": 2.5}) assert ok.status == "pending" + assert ok.tier == 3 assert ok.scale == 2.5 - # An optional, non-nullable field rejects an explicit null. - with pytest.raises(ValidationError): - _ = Showcase.model_validate( - { - "name": "w", - "count": 1, - "active": True, - "category": None, - "nickname": None, - } - ) +def test_nullability_states() -> None: + # required+nullable (`category`): absent is a violation, explicit null is the + # value, and the null is emitted back. + assert parse_violations( + {key: value for key, value in BASE.items() if key != "category"} + ) == [("category", "required")] + with_null = parse({**BASE, "category": None}) + assert with_null.category is None + assert converter_for(Showcase).to_transfer_type(with_null)["category"] is None + + # optional, non-nullable (`nickname`, and the default-bearing `greeting`): + # an explicit null is a violation, and both are reported at once (P11). + assert parse_violations({**BASE, "nickname": None, "greeting": None}) == [ + ("nickname", "explicit null not allowed"), + ("greeting", "explicit null not allowed"), + ] -def test_labels_typed_map_and_settings_closed_object() -> None: - labels = Labels.model_validate({"env": "prod", "team": "core"}) - assert labels.model_dump() == {"env": "prod", "team": "core"} + # optional+nullable (`middleName`): absent and explicit null COLLAPSE to the + # same in-memory state, and both re-serialize as omitted. This matches Go and + # Java (samples/go/tests/json_schema_showcase_test.go verifies + # showcase-nulls.json by field checks for exactly this reason); TypeScript is + # the only target that still round-trips the explicit null. + from_absent = parse(BASE) + from_null = parse({**BASE, "middleName": None}) + assert from_absent == from_null + assert from_null.middle_name is None + assert "middleName" not in converter_for(Showcase).to_transfer_type(from_null) - with pytest.raises(ValidationError): - _ = Labels.model_validate({"env": 42}) + # A closed object rejects an unknown member. + assert parse_violations({**BASE, "nope": 1}) == [("nope", "unknown field")] - with pytest.raises(ValidationError): - _ = Settings.model_validate({"theme": "dark", "unknown": 1}) + # A non-object payload is a single structural violation at the root. + with pytest.raises(ValidationError) as excinfo: + _ = converter_for(Showcase).from_transfer_type(7, Showcase) + assert violation_pairs(excinfo.value) == [("", "expected object")] -def test_canonical_wire_fixtures_roundtrip_through_temporal_pydantic_converter() -> ( - None -): - minimal = typing.cast( - Showcase, roundtrip_fixture("showcase-minimal.json", Showcase) +def test_labels_typed_map_and_settings_closed_object() -> None: + labels = converter_for(Labels).from_transfer_type( + {"env": "prod", "team": "core"}, Labels ) + assert labels.additional_properties == {"env": "prod", "team": "core"} + assert converter_for(Labels).to_transfer_type(labels) == { + "env": "prod", + "team": "core", + } + # A map-shaped model is constructed through its explicit catch-all member. + assert converter_for(Labels).to_transfer_type( + Labels(additional_properties={"env": "prod"}) + ) == {"env": "prod"} + + with pytest.raises(ValidationError) as excinfo: + _ = converter_for(Labels).from_transfer_type({"env": 42}, Labels) + assert violation_pairs(excinfo.value) == [("env", "expected string")] + + with pytest.raises(ValidationError) as excinfo: + _ = converter_for(Settings).from_transfer_type( + {"theme": "dark", "unknown": 1}, Settings + ) + assert violation_pairs(excinfo.value) == [("unknown", "unknown field")] + + +def test_canonical_wire_fixtures_roundtrip_through_the_default_converter() -> None: + minimal = expect_showcase("showcase-minimal.json") assert minimal.kind == "showcase" assert minimal.count == 3 assert minimal.active is True assert minimal.category == "tools" - assert minimal.retries == 3 - # Scalar defaults of each kind: absent on the wire, surfaced on read as the - # native Pydantic field default; omitted on re-serialize (not in fields_set). - assert minimal.greeting == "hello" - assert minimal.debug is False - assert "greeting" not in minimal.model_fields_set - assert "debug" not in minimal.model_fields_set - dumped = minimal.model_dump(by_alias=True) - assert "greeting" not in dumped - assert "debug" not in dumped - assert "retries" not in dumped - - full = typing.cast(Showcase, roundtrip_fixture("showcase-full.json", Showcase)) + # Scalar defaults of each kind: unset on the wire, so unset in memory and + # omitted on re-serialize (expect_showcase asserted byte-identity above). The + # consumer applies the emitted DEFAULT_ constant on read, exactly as in + # TypeScript. + assert minimal.retries is None + assert (minimal.retries if minimal.retries is not None else DEFAULT_RETRIES) == 3 + assert minimal.greeting is None + assert ( + minimal.greeting if minimal.greeting is not None else DEFAULT_GREETING + ) == "hello" + assert minimal.debug is None + assert (minimal.debug if minimal.debug is not None else DEFAULT_DEBUG) is False + + full = expect_showcase("showcase-full.json") assert full.retries == 5 assert full.middle_name == "Q" assert full.tags == ["a", "b"] @@ -188,300 +235,226 @@ def test_canonical_wire_fixtures_roundtrip_through_temporal_pydantic_converter() assert full.roles == ["admin", "user"] assert full.address is not None assert full.address.street == "1 Main St" - assert full.address.model_extra == {"region": "west"} + assert full.address.zip == 90210 + assert full.address.additional_properties == {"region": "west"} assert full.labels is not None - assert full.labels.model_extra == {"env": "prod", "team": "core"} + assert full.labels.additional_properties == {"env": "prod", "team": "core"} assert full.settings is not None assert full.settings.font_size == 14 - # Explicit nulls on nullable fields survive the round-trip in Python. - nulls = typing.cast(Showcase, roundtrip_fixture("showcase-nulls.json", Showcase)) + # showcase-nulls.json carries `middleName: null` on an optional+nullable + # member, which collapses and is therefore dropped on re-serialize. + nulls = expect_showcase("showcase-nulls.json", collapsed=("middleName",)) assert nulls.middle_name is None + # `category` is required+nullable, so ITS explicit null does survive. assert nulls.category is None assert nulls.active is False + assert nulls.count == 0 - address = typing.cast(Address, roundtrip_fixture("address-open.json", Address)) + address = typing.cast(Address, expect_roundtrip("address-open.json", Address)) assert address.street == "1 Main St" - assert address.model_extra == {"x-extra": 7} + assert address.additional_properties == {"x-extra": 7} - labels = typing.cast(Labels, roundtrip_fixture("labels.json", Labels)) - assert labels.model_extra == {"env": "prod", "team": "core"} + labels = typing.cast(Labels, expect_roundtrip("labels.json", Labels)) + assert labels.additional_properties == {"env": "prod", "team": "core"} - settings = typing.cast(Settings, roundtrip_fixture("settings.json", Settings)) + settings = typing.cast(Settings, expect_roundtrip("settings.json", Settings)) assert settings.theme == "dark" assert settings.font_size == 14 def test_numeric_constraints_roundtrip_and_reject() -> None: - metrics = typing.cast( - Showcase, roundtrip_fixture("showcase-metrics.json", Showcase) - ) + metrics = expect_showcase("showcase-metrics.json") assert metrics.priority == 5 assert metrics.level == 2 assert metrics.ratio == 15.0 assert metrics.step == 9 - base = { - "kind": "showcase", - "name": "w", - "count": 1, - "active": True, - "category": "tools", - "status": "active", - "tier": 1, - "scale": 1.5, - } - - # Integer above `maximum` (Pydantic's native Le already names the bound). - with pytest.raises(ValidationError) as excinfo: - _ = Showcase.model_validate({**base, "priority": 99}) - assert "less than or equal to 10" in str(excinfo.value) - - # Integer below `exclusiveMinimum`. - with pytest.raises(ValidationError) as excinfo: - _ = Showcase.model_validate({**base, "level": 0}) - assert "greater than 0" in str(excinfo.value) + assert parse_violations({**BASE, "priority": 99}) == [ + ("priority", "must be <= 10, got 99") + ] + assert parse_violations({**BASE, "level": 0}) == [("level", "must be > 0, got 0")] + assert parse_violations({**BASE, "step": 7}) == [ + ("step", "must be a multiple of 3, got 7") + ] + assert parse_violations({**BASE, "ratio": 7}) == [ + ("ratio", "must be a multiple of 5, got 7") + ] + # P11: one member can produce SEVERAL violations — `ratio` is both below + # `minimum` and off the `multipleOf` grid, and both are reported. + assert parse_violations({**BASE, "ratio": 3}) == [ + ("ratio", "must be >= 5, got 3"), + ("ratio", "must be a multiple of 5, got 3"), + ] - # Integer that is not a multiple (native Pydantic multiple_of for ints). - with pytest.raises(ValidationError) as excinfo: - _ = Showcase.model_validate({**base, "step": 7}) - assert "multiple of 3" in str(excinfo.value) - # Number that is not a multiple (explicit fmod AfterValidator, informative). - with pytest.raises(ValidationError) as excinfo: - _ = Showcase.model_validate({**base, "ratio": 7}) - assert "must be a multiple of 5, got 7" in str(excinfo.value) +def test_integer_semantics() -> None: + # An integral JSON number is an integer. + assert parse({**BASE, "count": 3.0}).count == 3 + # A boolean, a fractional number, and a value past the +/-(2**53-1) cap all + # report the single "expected integer" reason. + for bad in (True, 1.5, 2**53): + assert parse_violations({**BASE, "count": bad}) == [ + ("count", "expected integer") + ] def test_string_length_constraints_roundtrip_and_reject() -> None: - # The astral crux: "a😀b" is 3 code points but 6 UTF-8 bytes; Pydantic's - # native max_length counts code points, so it passes code (maxLength:5). - strings = typing.cast( - Showcase, roundtrip_fixture("showcase-strings.json", Showcase) - ) + # The astral crux: "a😀b" is 3 code points but 6 UTF-8 bytes, so it passes + # `code` maxLength:5 — lengths are counted in code points, not bytes. + strings = expect_showcase("showcase-strings.json") assert strings.code == "a😀b" assert strings.nickname == "buddy" - base = { - "kind": "showcase", - "name": "w", - "count": 1, - "active": True, - "category": "tools", - "status": "active", - "tier": 1, - "scale": 1.5, - } - - # A too-short `code` (1 code point, below minLength:2). - with pytest.raises(ValidationError) as excinfo: - _ = Showcase.model_validate({**base, "code": "a"}) - assert "at least 2 characters" in str(excinfo.value) - - # An over-long `code` (6 code points, above maxLength:5). - with pytest.raises(ValidationError) as excinfo: - _ = Showcase.model_validate({**base, "code": "abcdef"}) - assert "at most 5 characters" in str(excinfo.value) - - # Astral: 6 emoji = 6 code points (24 bytes); rejected by code-point count. - with pytest.raises(ValidationError) as excinfo: - _ = Showcase.model_validate({**base, "code": "😀😀😀😀😀😀"}) - assert "at most 5 characters" in str(excinfo.value) - - # A multi-byte value within the code-point bound is accepted (byte count 6 - # would exceed maxLength:5 — proving code points, not bytes). - ok = Showcase.model_validate({**base, "code": "a😀b"}) - assert ok.code == "a😀b" + assert parse_violations({**BASE, "code": "a"}) == [ + ("code", "must have length >= 2, got 1") + ] + assert parse_violations({**BASE, "code": "abcdef"}) == [ + ("code", "must have length <= 5, got 6") + ] + # 6 emoji = 6 code points (24 bytes); rejected by code-point count. + assert parse_violations({**BASE, "code": "😀😀😀😀😀😀"}) == [ + ("code", "must have length <= 5, got 6") + ] + # A multi-byte value within the code-point bound is accepted (a byte count of + # 6 would exceed maxLength:5 — proving code points, not bytes). + assert parse({**BASE, "code": "a😀b"}).code == "a😀b" def test_pattern_constraints_roundtrip_and_reject() -> None: # sku `^[A-Z]{2,4}$` and phrase `^\S+\s\S+$` round-trip. - patterns = typing.cast( - Showcase, roundtrip_fixture("showcase-patterns.json", Showcase) - ) + patterns = expect_showcase("showcase-patterns.json") assert patterns.sku == "AB" assert patterns.phrase == "hello world" - base = { - "kind": "showcase", - "name": "w", - "count": 1, - "active": True, - "category": "tools", - "status": "active", - "tier": 1, - "scale": 1.5, - } - - # Lowercase sku (not [A-Z]). - with pytest.raises(ValidationError) as excinfo: - _ = Showcase.model_validate({**base, "sku": "ab"}) - assert "must match pattern" in str(excinfo.value) - - # Too-long sku (5 letters). - with pytest.raises(ValidationError) as excinfo: - _ = Showcase.model_validate({**base, "sku": "ABCDE"}) - assert "must match pattern" in str(excinfo.value) - - # phrase with no whitespace separator. - with pytest.raises(ValidationError) as excinfo: - _ = Showcase.model_validate({**base, "phrase": "helloworld"}) - assert "must match pattern" in str(excinfo.value) - - # `\s` ASCII-class crux: a NBSP (U+00A0) is not ASCII whitespace, so the - # normalized `[\t\n\x0B\f\r ]` (matched with re.ASCII) rejects it — matching - # Go/TS/Java (JS's native Unicode `\s` would otherwise have accepted it). - with pytest.raises(ValidationError) as excinfo: - _ = Showcase.model_validate({**base, "phrase": "hello world"}) - assert "must match pattern" in str(excinfo.value) - - # `$` end-anchor crux: a trailing newline is rejected. Python `re`'s `$` - # matches before a trailing `\n`; the loader rewrote `$`→`\Z` (strict end) - # so this rejects, consistent with Go/TS/Java. - with pytest.raises(ValidationError) as excinfo: - _ = Showcase.model_validate({**base, "phrase": "hello world\n"}) - assert "must match pattern" in str(excinfo.value) - - # A valid ASCII-space phrase and sku are accepted. - ok = Showcase.model_validate({**base, "sku": "XY", "phrase": "hello world"}) + # The reason names the *lowered* pattern (the loader rewrote `\s`/`\S` to an + # ASCII class and `$` to Python's `\Z`), so only its head is asserted here. + for member, value in [ + ("sku", "ab"), # lowercase, not [A-Z] + ("sku", "ABCDE"), # 5 letters, above {2,4} + ("phrase", "helloworld"), # no whitespace separator + # `\s` ASCII-class crux: a NBSP (U+00A0) is not ASCII whitespace, so the + # normalized `[\t\n\x0B\f\r ]` rejects it — matching Go/TS/Java. + ("phrase", "hello world"), + # `$` end-anchor crux: Python `re`'s `$` matches before a trailing `\n`, + # so the loader rewrote `$` -> `\Z` and a trailing newline is rejected, + # consistent with Go/TS/Java. + ("phrase", "hello world\n"), + ]: + violations = parse_violations({**BASE, member: value}) + assert [path for path, _ in violations] == [member] + assert violations[0][1].startswith("must match pattern ") + + ok = parse({**BASE, "sku": "XY", "phrase": "hello world"}) assert ok.sku == "XY" assert ok.phrase == "hello world" def test_format_constraints_roundtrip_and_reject() -> None: # uuid/email/hostname/uri/ipv4 round-trip (string-typed, no materialization). - formats = typing.cast(Showcase, roundtrip_fixture("showcase-format.json", Showcase)) + formats = expect_showcase("showcase-format.json") assert formats.request_id == "de305d54-75b4-431b-adb2-eb6b9e546013" assert formats.contact_email == "user@example.com" assert formats.host == "api.example.com" assert formats.homepage == "https://example.com/path?q=1#frag" assert formats.gateway == "192.168.0.1" - base = { - "kind": "showcase", - "name": "w", - "count": 1, - "active": True, - "category": "tools", - "status": "active", - "tier": 1, - "scale": 1.5, - } - - # A malformed uuid. - with pytest.raises(ValidationError) as excinfo: - _ = Showcase.model_validate({**base, "requestId": "not-a-uuid"}) - assert "must be a valid uuid" in str(excinfo.value) - - # Single-label email domain (user@localhost) is rejected. - with pytest.raises(ValidationError) as excinfo: - _ = Showcase.model_validate({**base, "contactEmail": "user@localhost"}) - assert "must be a valid email" in str(excinfo.value) - - # ipv4 octet out of range. - with pytest.raises(ValidationError) as excinfo: - _ = Showcase.model_validate({**base, "gateway": "256.0.0.1"}) - assert "must be a valid ipv4" in str(excinfo.value) - - # uri with a double-`::` IPv6 IP-literal host (spliced ipv6 grammar rejects). - with pytest.raises(ValidationError) as excinfo: - _ = Showcase.model_validate({**base, "homepage": "http://[1::2::3]"}) - assert "must be a valid uri" in str(excinfo.value) - - # An over-long hostname (> 253 code points) is rejected by the length guard. - long_host = ".".join(["abc"] * 64) - with pytest.raises(ValidationError) as excinfo: - _ = Showcase.model_validate({**base, "host": long_host}) - assert "must be a valid hostname" in str(excinfo.value) + long_host = ".".join(["abc"] * 64) # > 253 code points + for member, value, format_name in [ + ("requestId", "not-a-uuid", "uuid"), + # A single-label email domain (user@localhost) is rejected. + ("contactEmail", "user@localhost", "email"), + ("gateway", "256.0.0.1", "ipv4"), + # A double-`::` IPv6 IP-literal host: the spliced ipv6 grammar rejects it. + ("homepage", "http://[1::2::3]", "uri"), + ("host", long_host, "hostname"), + ]: + # The offending value is rendered in its JSON form, exactly as Go and + # TypeScript render it. + assert parse_violations({**BASE, member: value}) == [ + (member, f'must be a valid {format_name}, got "{value}"') + ] def test_array_constraints_roundtrip_and_reject() -> None: - base = { - "kind": "showcase", - "name": "w", - "count": 1, - "active": True, - "category": "tools", - "status": "active", - "tier": 1, - "scale": 1.5, - } - - # Valid arrays are accepted. - ok = Showcase.model_validate( - {**base, "tags": ["a"], "aliases": ["x", "y"], "roles": ["admin"]} - ) + ok = parse({**BASE, "tags": ["a"], "aliases": ["x", "y"], "roles": ["admin"]}) assert ok.roles == ["admin"] - # Too few items (minItems:1) — Pydantic's native min_length names the bound. - with pytest.raises(ValidationError) as excinfo: - _ = Showcase.model_validate({**base, "tags": []}) - assert "at least 1 item" in str(excinfo.value) - - # Too many items (maxItems:5). - with pytest.raises(ValidationError) as excinfo: - _ = Showcase.model_validate({**base, "tags": ["a", "b", "c", "d", "e", "f"]}) - assert "at most 5 item" in str(excinfo.value) - - # Duplicate element (uniqueItems). - with pytest.raises(ValidationError) as excinfo: - _ = Showcase.model_validate({**base, "aliases": ["x", "x"]}) - assert "duplicate items: element at index 1 equals index 0" in str(excinfo.value) - - # Missing required contains match (no "admin"). - with pytest.raises(ValidationError) as excinfo: - _ = Showcase.model_validate({**base, "roles": ["user"]}) - assert "too few matching items: at least 1, got 0" in str(excinfo.value) - - # Too many contains matches (maxContains:2). - with pytest.raises(ValidationError) as excinfo: - _ = Showcase.model_validate({**base, "roles": ["admin", "admin", "admin"]}) - assert "too many matching items: at most 2, got 3" in str(excinfo.value) + assert parse_violations({**BASE, "tags": []}) == [ + ("tags", "must have at least 1 items, got 0") + ] + assert parse_violations({**BASE, "tags": ["a", "b", "c", "d", "e", "f"]}) == [ + ("tags", "must have at most 5 items, got 6") + ] + assert parse_violations({**BASE, "aliases": ["x", "x"]}) == [ + ("aliases", "duplicate items: element at index 1 equals index 0") + ] + assert parse_violations({**BASE, "roles": ["user"]}) == [ + ("roles", "too few matching items: at least 1, got 0") + ] + assert parse_violations({**BASE, "roles": ["admin", "admin", "admin"]}) == [ + ("roles", "too many matching items: at most 2, got 3") + ] def test_object_constraints_roundtrip_and_reject() -> None: - # Valid map and object round-trip through the Temporal converter. attributes = typing.cast( - Attributes, roundtrip_fixture("attributes.json", Attributes) + Attributes, expect_roundtrip("attributes.json", Attributes) ) - assert attributes.model_extra == {"host": "a", "port": "8080"} - contact = typing.cast(ContactPy, roundtrip_fixture("contact.json", ContactPy)) + assert attributes.additional_properties == {"host": "a", "port": "8080"} + contact = typing.cast(ContactPy, expect_roundtrip("contact.json", ContactPy)) assert contact.shipping_street == "1 Main St" assert contact.shipping_zip == "90210" - # minProperties:1 on a map — an empty object is too few (counted over the - # distinct wire keys via model_fields_set, never a declared+extras sum). + # minProperties/maxProperties over the distinct wire-key count sit at the + # object root, so their violation path is empty. with pytest.raises(ValidationError) as excinfo: - _ = Attributes.model_validate({}) - assert "must have at least 1 properties, got 0" in str(excinfo.value) + _ = converter_for(Attributes).from_transfer_type({}, Attributes) + assert violation_pairs(excinfo.value) == [ + ("", "must have at least 1 properties, got 0") + ] - # maxProperties:3 on a map. with pytest.raises(ValidationError) as excinfo: - _ = Attributes.model_validate({"a": "1", "b": "2", "c": "3", "d": "4"}) - assert "must have at most 3 properties, got 4" in str(excinfo.value) + _ = converter_for(Attributes).from_transfer_type( + {"a": "1", "b": "2", "c": "3", "d": "4"}, Attributes + ) + assert violation_pairs(excinfo.value) == [ + ("", "must have at most 3 properties, got 4") + ] - # propertyNames maxLength:8 — an over-long key (code-point length). + # propertyNames maxLength:8 — an over-long key, keyed by the offending key. with pytest.raises(ValidationError) as excinfo: - _ = Attributes.model_validate({"toolongkey": "1"}) - assert 'invalid property name "toolongkey": must have length <= 8, got 10' in str( - excinfo.value - ) + _ = converter_for(Attributes).from_transfer_type( + {"toolongkey": "1"}, Attributes + ) + assert violation_pairs(excinfo.value) == [ + ( + "toolongkey", + 'invalid property name "toolongkey": must have length <= 8, got 10', + ) + ] # dependentRequired — a shipping street present without a shipping zip. with pytest.raises(ValidationError) as excinfo: - _ = ContactPy.model_validate({"shippingStreet": "1 Main St"}) - assert 'property "shippingZip" is required when "shippingStreet" is present' in str( - excinfo.value - ) + _ = converter_for(ContactPy).from_transfer_type( + {"shippingStreet": "1 Main St"}, ContactPy + ) + assert violation_pairs(excinfo.value) == [ + ( + "shippingZip", + 'property "shippingZip" is required when "shippingStreet" is present', + ) + ] # minProperties:1 on a declared-property object — an empty object. with pytest.raises(ValidationError) as excinfo: - _ = ContactPy.model_validate({}) - assert "must have at least 1 properties, got 0" in str(excinfo.value) + _ = converter_for(ContactPy).from_transfer_type({}, ContactPy) + assert violation_pairs(excinfo.value) == [ + ("", "must have at least 1 properties, got 0") + ] - # A satisfied dependency validates. - ok = ContactPy.model_validate( - {"shippingStreet": "1 Main St", "shippingZip": "90210"} + ok = converter_for(ContactPy).from_transfer_type( + {"shippingStreet": "1 Main St", "shippingZip": "90210"}, ContactPy ) assert ok.shipping_zip == "90210" @@ -490,408 +463,300 @@ def test_all_of_merged_widget() -> None: # Widget is an allOf base-type extension (WidgetBase folded in + an extension # branch): a flat standalone object with the union of properties ({id, kind, # name, size}) and required ([id, name]), with no allOf residue. - widget = roundtrip_fixture("widget.json", Widget) + widget = typing.cast(Widget, expect_roundtrip("widget.json", Widget)) assert widget.id == "w-1" assert widget.kind == "gadget" assert widget.name == "Widget One" assert widget.size == 15 + converter = converter_for(Widget) + # `size` carries a bound tightened from two allOf branches to [10, 20]. - with pytest.raises(ValidationError): - _ = Widget.model_validate({"id": "w-1", "name": "Widget One", "size": 5}) - with pytest.raises(ValidationError): - _ = Widget.model_validate({"id": "w-1", "name": "Widget One", "size": 25}) + with pytest.raises(ValidationError) as excinfo: + _ = converter.from_transfer_type( + {"id": "w-1", "name": "Widget One", "size": 5}, Widget + ) + assert violation_pairs(excinfo.value) == [("size", "must be >= 10, got 5")] + + with pytest.raises(ValidationError) as excinfo: + _ = converter.from_transfer_type( + {"id": "w-1", "name": "Widget One", "size": 25}, Widget + ) + assert violation_pairs(excinfo.value) == [("size", "must be <= 20, got 25")] # A missing required member contributed by the extension branch is rejected. - with pytest.raises(ValidationError): - _ = Widget.model_validate({"id": "w-1"}) + with pytest.raises(ValidationError) as excinfo: + _ = converter.from_transfer_type({"id": "w-1"}, Widget) + assert violation_pairs(excinfo.value) == [("name", "required")] # A value on the tightened boundary validates. - ok = Widget.model_validate({"id": "w-1", "name": "Widget One", "size": 10}) + ok = converter.from_transfer_type( + {"id": "w-1", "name": "Widget One", "size": 10}, Widget + ) assert ok.size == 10 def test_one_of_sum_types_roundtrip_and_reject() -> None: - # Disjoint-kind union (str | int): each branch round-trips and is selected - # by the wire token (Pydantic smart-union mode). - as_string = typing.cast( - Showcase, roundtrip_fixture("showcase-union-string.json", Showcase) - ) + # Disjoint-kind union (str | int): each branch round-trips and is selected by + # the wire token. + as_string = expect_showcase("showcase-union-string.json") assert as_string.id_or_name == "abc" - as_int = typing.cast( - Showcase, roundtrip_fixture("showcase-union-int.json", Showcase) - ) + as_int = expect_showcase("showcase-union-int.json") assert as_int.id_or_name == 7 # Discriminated (tagged) union (Circle | Square) selected by `kind`. - circle = typing.cast( - Showcase, roundtrip_fixture("showcase-shape-circle.json", Showcase) - ) + circle = expect_showcase("showcase-shape-circle.json") assert isinstance(circle.shape, Circle) assert circle.shape.kind == "circle" assert circle.shape.radius == 2.5 - square = typing.cast( - Showcase, roundtrip_fixture("showcase-shape-square.json", Showcase) - ) + square = expect_showcase("showcase-shape-square.json") assert isinstance(square.shape, Square) assert square.shape.kind == "square" assert square.shape.side == 4 - base = { - "kind": "showcase", - "revision": 1, - "enabled": True, - "status": "active", - "tier": 1, - "scale": 1.5, - "name": "w", - "count": 1, - "active": True, - "category": "tools", - } - # An unmatchable wire token (boolean) matches no branch of str | int. - with pytest.raises(ValidationError): - _ = Showcase.model_validate({**base, "idOrName": True}) + assert parse_violations({**BASE, "idOrName": True}) == [ + ("idOrName", "expected one of: string, integer") + ] # An unknown discriminator value is rejected (closed value set, P13.1). - with pytest.raises(ValidationError): - _ = Showcase.model_validate({**base, "shape": {"kind": "triangle"}}) + assert parse_violations({**BASE, "shape": {"kind": "triangle"}}) == [ + ( + "shape", + 'unknown discriminator kind triangle: expected one of ["circle", "square"]', + ) + ] def test_one_of_branch_constraints() -> None: """Once the token selects a branch, the value is held to everything that branch declares: each union member carries its own constraints.""" - base = { - "kind": "showcase", - "revision": 1, - "enabled": True, - "status": "active", - "tier": 1, - "scale": 1.5, - "name": "w", - "count": 1, - "active": True, - "category": "tools", - } - - # The string branch's own `minLength` and the integer branch's own `minimum` - # — each enforced only for the branch that declares it. - assert Showcase.model_validate({**base, "idOrName": "abc"}).id_or_name == "abc" - assert Showcase.model_validate({**base, "idOrName": 1}).id_or_name == 1 - with pytest.raises(ValidationError): - _ = Showcase.model_validate({**base, "idOrName": "ab"}) - with pytest.raises(ValidationError): - _ = Showcase.model_validate({**base, "idOrName": 0}) + assert parse({**BASE, "idOrName": "abc"}).id_or_name == "abc" + assert parse({**BASE, "idOrName": 1}).id_or_name == 1 + assert parse_violations({**BASE, "idOrName": "ab"}) == [ + ("idOrName", "must have length >= 3, got 2") + ] + assert parse_violations({**BASE, "idOrName": 0}) == [ + ("idOrName", "must be >= 1, got 0") + ] # A closed value set on a branch: an unknown string matches no member. - assert Showcase.model_validate({**base, "mode": "manual"}).mode == "manual" - assert Showcase.model_validate({**base, "mode": 7}).mode == 7 - with pytest.raises(ValidationError): - _ = Showcase.model_validate({**base, "mode": "turbo"}) - with pytest.raises(ValidationError): - _ = Showcase.model_validate({**base, "mode": -1}) + assert parse({**BASE, "mode": "manual"}).mode == "manual" + assert parse({**BASE, "mode": 7}).mode == 7 + assert parse_violations({**BASE, "mode": "turbo"}) == [ + ("mode", 'must be one of ["auto", "manual"], got "turbo"') + ] + assert parse_violations({**BASE, "mode": -1}) == [("mode", "must be >= 0, got -1")] # The array branch's `minItems`/`uniqueItems` and the string branch's # `pattern`, on the same union. - assert Showcase.model_validate( - {**base, "measurements": [1.5, 2.5]} - ).measurements == [1.5, 2.5] - with pytest.raises(ValidationError): - _ = Showcase.model_validate({**base, "measurements": []}) - with pytest.raises(ValidationError): - _ = Showcase.model_validate({**base, "measurements": [1.5, 1.5]}) - with pytest.raises(ValidationError): - _ = Showcase.model_validate({**base, "measurements": "AUTO"}) - - # An element union's branch constraints hold per element. - assert Showcase.model_validate({**base, "segments": ["ab", 0]}).segments == [ - "ab", - 0, - ] - with pytest.raises(ValidationError): - _ = Showcase.model_validate({**base, "segments": ["a"]}) - with pytest.raises(ValidationError): - _ = Showcase.model_validate({**base, "segments": [-1]}) + assert parse({**BASE, "measurements": [1.5, 2.5]}).measurements == [1.5, 2.5] + assert parse_violations({**BASE, "measurements": []}) == [ + ("measurements", "must have at least 1 items, got 0") + ] + assert parse_violations({**BASE, "measurements": [1.5, 1.5]}) == [ + ("measurements", "duplicate items: element at index 1 equals index 0") + ] + measurement_violations = parse_violations({**BASE, "measurements": "AUTO"}) + assert [path for path, _ in measurement_violations] == ["measurements"] + assert measurement_violations[0][1].startswith("must match pattern ") + + # An element union's branch constraints hold per element, at its own index. + assert parse({**BASE, "segments": ["ab", 0]}).segments == ["ab", 0] + assert parse_violations({**BASE, "segments": ["a"]}) == [ + ("segments[0]", "must have length >= 2, got 1") + ] + assert parse_violations({**BASE, "segments": [-1]}) == [ + ("segments[0]", "must be >= 0, got -1") + ] def test_free_form_object_roundtrip_and_reject() -> None: # The free-form object in both positions: the inline object branch of the - # `payload` union, and the named `Extras` model. Members are carried - # verbatim, so a large integer survives untruncated. - as_object = typing.cast( - Showcase, roundtrip_fixture("showcase-freeform.json", Showcase) - ) + # `payload` union (carried structurally as a dict) and the named `Extras` + # model (carried in its catch-all member). Members are kept verbatim, so a + # large integer survives untruncated. + as_object = expect_showcase("showcase-freeform.json") assert isinstance(as_object.payload, dict) assert as_object.payload["big"] == 9007199254740992 assert as_object.extras is not None - assert (as_object.extras.model_extra or {})["note"] == "free-form" + assert as_object.extras.additional_properties["note"] == "free-form" # The same union's string branch, selected by the wire token. - as_string = typing.cast( - Showcase, roundtrip_fixture("showcase-freeform-string.json", Showcase) - ) + as_string = expect_showcase("showcase-freeform-string.json") assert as_string.payload == "text" # The named free-form model round-trips standalone, nested members included. - extras = typing.cast(Extras, roundtrip_fixture("extras.json", Extras)) - members = extras.model_extra or {} - assert members["nested"] == {"a": 1} - assert members["count"] == 9007199254740992 + extras = typing.cast(Extras, expect_roundtrip("extras.json", Extras)) + assert extras.additional_properties["nested"] == {"a": 1} + assert extras.additional_properties["count"] == 9007199254740992 - # maxProperties over the member set is enforced. + # maxProperties over the member set is enforced, at the object root. with pytest.raises(ValidationError) as excinfo: - _ = Extras.model_validate({"a": 1, "b": 2, "c": 3, "d": 4, "e": 5}) - assert "must have at most 4 properties" in str(excinfo.value) - - base = { - "kind": "showcase", - "revision": 1, - "enabled": True, - "status": "active", - "tier": 1, - "scale": 1.5, - "name": "w", - "count": 1, - "active": True, - "category": "tools", - } + _ = converter_for(Extras).from_transfer_type( + {"a": 1, "b": 2, "c": 3, "d": 4, "e": 5}, Extras + ) + assert violation_pairs(excinfo.value) == [ + ("", "must have at most 4 properties, got 5") + ] # An unmatchable wire token (boolean) matches no branch of object | string. - with pytest.raises(ValidationError): - _ = Showcase.model_validate({**base, "payload": True}) + assert parse_violations({**BASE, "payload": True}) == [ + ("payload", "expected one of: object, string") + ] def test_inline_object_union_roundtrip_and_reject() -> None: # The `note` tagged union's branches are written inline in the schema and - # named by their `x-py-name` overrides, so each is a `BaseModel` Pydantic - # selects on — with its own constraints and its own open member set. - text = typing.cast(Showcase, roundtrip_fixture("showcase-note-text.json", Showcase)) + # named by their `x-py-name` overrides, so each is an ordinary dataclass the + # union's free functions dispatch to on the `kind` const. + text = expect_showcase("showcase-note-text.json") assert isinstance(text.note, TextNote) assert text.note.kind == "text" assert text.note.body == "remember the milk" # The branch stays open: an unknown member is preserved (P13). - assert (text.note.model_extra or {})["pinned"] is True + assert text.note.additional_properties == {"pinned": True} - link = typing.cast(Showcase, roundtrip_fixture("showcase-note-link.json", Showcase)) + link = expect_showcase("showcase-note-link.json") assert isinstance(link.note, LinkNote) assert link.note.href == "https://example.test/notes/1" - base = { - "kind": "showcase", - "revision": 1, - "enabled": True, - "status": "active", - "tier": 1, - "scale": 1.5, - "name": "w", - "count": 1, - "active": True, - "category": "tools", - } - - # The selected branch's own constraints are enforced. - with pytest.raises(ValidationError) as excinfo: - _ = Showcase.model_validate({**base, "note": {"kind": "text", "body": ""}}) - assert "at least 1 character" in str(excinfo.value) + # The selected branch's own constraints are enforced, at the nested path. + assert parse_violations({**BASE, "note": {"kind": "text", "body": ""}}) == [ + ("note.body", "must have length >= 1, got 0") + ] # An unknown tag value matches no branch. - with pytest.raises(ValidationError): - _ = Showcase.model_validate({**base, "note": {"kind": "audio"}}) + assert parse_violations({**BASE, "note": {"kind": "audio"}}) == [ + ("note", 'unknown discriminator kind audio: expected one of ["text", "link"]') + ] def test_property_inline_object_union_roundtrip_and_reject() -> None: # `detail`'s union is written inline on the property; its lone structured # object branch derives `ShowcaseDetailObject` from the union it belongs to - # and is an ordinary model, so Pydantic selects on it by shape. - object_detail = typing.cast( - Showcase, roundtrip_fixture("showcase-detail-object.json", Showcase) - ) + # and is an ordinary model. + object_detail = expect_showcase("showcase-detail-object.json") assert isinstance(object_detail.detail, ShowcaseDetailObject) assert object_detail.detail.code == "E_LIMIT" assert object_detail.detail.hint == "retry later" # The branch stays open: an unknown member is preserved (P13). - assert (object_detail.detail.model_extra or {})["retryAfterMs"] == 250 + assert object_detail.detail.additional_properties == {"retryAfterMs": 250} - text = typing.cast( - Showcase, roundtrip_fixture("showcase-detail-string.json", Showcase) - ) + text = expect_showcase("showcase-detail-string.json") assert text.detail == "E_LIMIT" - base = { - "kind": "showcase", - "revision": 1, - "enabled": True, - "status": "active", - "tier": 1, - "scale": 1.5, - "name": "w", - "count": 1, - "active": True, - "category": "tools", - } - - # The object branch's own constraints are enforced. - with pytest.raises(ValidationError) as excinfo: - _ = Showcase.model_validate({**base, "detail": {"code": ""}}) - assert "at least 1 character" in str(excinfo.value) + assert parse_violations({**BASE, "detail": {"code": ""}}) == [ + ("detail.code", "must have length >= 1, got 0") + ] - # A value admitted by no branch is rejected. - with pytest.raises(ValidationError): - _ = Showcase.model_validate({**base, "detail": 7}) + # A value admitted by no branch is rejected, naming the admissible branches. + assert parse_violations({**BASE, "detail": 7}) == [ + ("detail", "expected one of: ShowcaseDetailObject, string") + ] def test_tagged_union_with_scalar_branch_roundtrip_and_reject() -> None: # `shapeOrName` composes both selector layers: the JSON token picks # object-vs-string, then the `kind` const picks Circle-vs-Square. Both branch # models are the ones the `shape` union already uses. - square = typing.cast( - Showcase, roundtrip_fixture("showcase-shape-or-name-square.json", Showcase) - ) + square = expect_showcase("showcase-shape-or-name-square.json") assert isinstance(square.shape_or_name, Square) assert square.shape_or_name.side == 4 - named = typing.cast( - Showcase, roundtrip_fixture("showcase-shape-or-name-string.json", Showcase) - ) + named = expect_showcase("showcase-shape-or-name-string.json") assert named.shape_or_name == "unit-square" - base = { - "kind": "showcase", - "revision": 1, - "enabled": True, - "status": "active", - "tier": 1, - "scale": 1.5, - "name": "w", - "count": 1, - "active": True, - "category": "tools", - } - # An object with an unknown tag matches no branch — it does not fall back to # the string branch. - with pytest.raises(ValidationError): - _ = Showcase.model_validate({**base, "shapeOrName": {"kind": "triangle"}}) + assert parse_violations({**BASE, "shapeOrName": {"kind": "triangle"}}) == [ + ( + "shapeOrName", + 'unknown discriminator kind triangle: expected one of ["circle", "square"]', + ) + ] - # A value admitted by no branch is rejected. - with pytest.raises(ValidationError): - _ = Showcase.model_validate({**base, "shapeOrName": 7}) + # A value admitted by no branch names all three. + assert parse_violations({**BASE, "shapeOrName": 7}) == [ + ("shapeOrName", "expected one of: Circle, Square, string") + ] def test_array_branch_union_roundtrip_and_reject() -> None: # `measurements` is `list[float] | str`: Python carries the array branch - # structurally (no synthesized variant model) and Pydantic selects by kind. - values = typing.cast( - Showcase, roundtrip_fixture("showcase-measurements-array.json", Showcase) - ) + # structurally (no synthesized variant model). + values = expect_showcase("showcase-measurements-array.json") assert values.measurements == [1.5, 2.5, 3.75] - preset = typing.cast( - Showcase, roundtrip_fixture("showcase-measurements-string.json", Showcase) - ) + preset = expect_showcase("showcase-measurements-string.json") assert preset.measurements == "auto" - base = { - "kind": "showcase", - "revision": 1, - "enabled": True, - "status": "active", - "tier": 1, - "scale": 1.5, - "name": "w", - "count": 1, - "active": True, - "category": "tools", - } - - # A value admitted by neither branch is rejected. - with pytest.raises(ValidationError): - _ = Showcase.model_validate({**base, "measurements": True}) + # A value admitted by neither branch is rejected, naming both admissible + # kinds. An array branch has no name to take, so its label is the language's + # own type spelling (`list[float]`, where TypeScript says `number[]`); scalar + # branches use the JSON-Schema kind word. + assert parse_violations({**BASE, "measurements": True}) == [ + ("measurements", "expected one of: list[float], string") + ] def test_element_position_unions_roundtrip_and_reject() -> None: # Unions in positions with no property of their own: an array element at a - # named union (`shapes`), an array element at an inline union the loader - # names `ShowcaseSegmentsItem`, and a map member at an inline union named - # `ChoicesValue`. Pydantic selects the branch per element/member. - value = typing.cast( - Showcase, roundtrip_fixture("showcase-element-unions.json", Showcase) - ) + # named union (`shapes`), an array element at an inline union the loader names + # `ShowcaseSegmentsItem`, and a map member at an inline union named + # `ChoicesValue`. + value = expect_showcase("showcase-element-unions.json") assert value.shapes is not None assert isinstance(value.shapes[0], Circle) assert value.shapes[0].radius == 2.5 assert isinstance(value.shapes[1], Square) assert value.shapes[1].side == 4 assert value.segments == ["alpha", 7] - # Element nullability is the element's own concern: `list[str | None]`, so - # an explicit null is a member rather than a violation. + # Element nullability is the element's own concern: `list[str | None]`, so an + # explicit null is a member rather than a violation, and it survives the + # round-trip (the optional+nullable collapse is a *property* rule). assert value.slots == ["first", None, "third"] - # A map's members live in Pydantic's extras bag, materialized into their + # A map's members live in the explicit catch-all, materialized into their # declared member type — here the union each member is routed to. assert value.choices is not None - assert value.choices.model_extra is not None - primary = value.choices.model_extra["primary"] + primary = value.choices.additional_properties["primary"] assert isinstance(primary, Circle) assert primary.radius == 1 - base = { - "kind": "showcase", - "revision": 1, - "enabled": True, - "status": "active", - "tier": 1, - "scale": 1.5, - "name": "w", - "count": 1, - "active": True, - "category": "tools", - } - # An element admitted by no branch is rejected, at its own index. - with pytest.raises(ValidationError) as excinfo: - _ = Showcase.model_validate( - {**base, "shapes": [{"kind": "circle", "radius": 1}, True]} - ) - assert "shapes" in str(excinfo.value) + assert parse_violations( + {**BASE, "shapes": [{"kind": "circle", "radius": 1}, True]} + ) == [("shapes[1]", "expected one of: Circle, Square")] + + assert parse_violations({**BASE, "segments": ["ok", 1.5]}) == [ + ("segments[1]", "expected one of: string, integer") + ] - with pytest.raises(ValidationError): - _ = Showcase.model_validate({**base, "segments": ["ok", 1.5]}) + # A map member's violation carries its key under the map's own path. + assert parse_violations({**BASE, "choices": {"primary": "circle"}}) == [ + ("choices.primary", "expected one of: Circle, Square") + ] def test_content_encoding_roundtrip_and_reject() -> None: - # blob (base64) and urlBlob (base64url) round-trip: JSON string on the wire, + # blob (base64) and urlBlob (base64url) round-trip: a JSON string on the wire, # native `bytes` in the model, re-encoded byte-identically. The same bytes # (">>>") encode differently per encoding ("Pj4+" vs "Pj4-"). - parsed = typing.cast(Showcase, roundtrip_fixture("showcase-bytes.json", Showcase)) + parsed = expect_showcase("showcase-bytes.json") assert parsed.blob == b">>>" assert parsed.url_blob == b">>>" - base = { - "kind": "showcase", - "name": "w", - "count": 1, - "active": True, - "category": "tools", - "status": "active", - "tier": 1, - "scale": 1.5, - } - - # A base64 field using the URL-safe alphabet is rejected by the pinned regex. - with pytest.raises(ValidationError) as excinfo: - _ = Showcase.model_validate({**base, "blob": "Pj4-"}) - assert "must be base64-encoded" in str(excinfo.value) - - # A base64 field missing padding is rejected. - with pytest.raises(ValidationError) as excinfo: - _ = Showcase.model_validate({**base, "blob": "aGk"}) - assert "must be base64-encoded" in str(excinfo.value) - - # A base64url field carrying padding is rejected. - with pytest.raises(ValidationError) as excinfo: - _ = Showcase.model_validate({**base, "urlBlob": "aGk="}) - assert "must be base64url-encoded" in str(excinfo.value) + for member, value, encoding in [ + # A base64 field using the URL-safe alphabet. + ("blob", "Pj4-", "base64"), + # A base64 field missing padding. + ("blob", "aGk", "base64"), + # A base64url field carrying padding. + ("urlBlob", "aGk=", "base64url"), + ]: + assert parse_violations({**BASE, member: value}) == [ + (member, f'must be {encoding}-encoded, got "{value}"') + ] def test_inline_object_shapes_roundtrip_and_reject() -> None: @@ -901,9 +766,7 @@ def test_inline_object_shapes_roundtrip_and_reject() -> None: # map and its member (`ledger`), and a free-form bag (`metadata`). The same # fixture covers a typed map's member constraints (`quotas`, `tokens`, # `nicknames`) and a nested array (`grid`). - value = typing.cast( - Showcase, roundtrip_fixture("showcase-inline-shapes.json", Showcase) - ) + value = expect_showcase("showcase-inline-shapes.json") assert value.grid == [[1, 2], [3]] assert value.location is not None assert value.location.city == "Springfield" @@ -914,61 +777,91 @@ def test_inline_object_shapes_roundtrip_and_reject() -> None: assert value.rows is not None assert value.rows[0].cell == "a1" # The member override renamed the member (`ledger_py`); the hoisted types keep - # their position-derived names. A map's members are materialized into their - # declared member type. + # their position-derived names. assert value.ledger_py is not None - opening = (value.ledger_py.model_extra or {})["opening"] + opening = value.ledger_py.additional_properties["opening"] assert isinstance(opening, ShowcaseLedgerValue) assert opening.amount == 100 assert value.metadata is not None - assert (value.metadata.model_extra or {}) == {"source": "import", "batch": 7} + assert value.metadata.additional_properties == {"source": "import", "batch": 7} assert value.quotas is not None - assert (value.quotas.model_extra or {}) == {"cpu": 20, "memory": 100} - # A null member of a nullable map is a member, not a violation. + assert value.quotas.additional_properties == {"cpu": 20, "memory": 100} + # A null member of a map of nullable members is a member, not a violation. assert value.nicknames is not None - assert (value.nicknames.model_extra or {}) == {"short": "al", "none": None} - - base = { - "kind": "showcase", - "name": "w", - "count": 1, - "active": True, - "category": "tools", - "status": "active", - "tier": 1, - "scale": 1.5, - } + assert value.nicknames.additional_properties == {"short": "al", "none": None} # A hoisted shape validates like any other model, at the nested path. - with pytest.raises(ValidationError) as excinfo: - _ = Showcase.model_validate({**base, "location": {"city": ""}}) - assert "location.city" in str(excinfo.value) - - with pytest.raises(ValidationError) as excinfo: - _ = Showcase.model_validate({**base, "rows": [{"cell": "ok"}, {}]}) - assert "rows.1.cell" in str(excinfo.value) - + assert parse_violations({**BASE, "location": {"city": ""}}) == [ + ("location.city", "must have length >= 1, got 0") + ] + assert parse_violations({**BASE, "rows": [{"cell": "ok"}, {}]}) == [ + ("rows[1].cell", "required") + ] # A nested array reports the failing element at its own two-dimensional index. - with pytest.raises(ValidationError) as excinfo: - _ = Showcase.model_validate({**base, "grid": [[1], [2, 1.5]]}) - assert "grid.1.1" in str(excinfo.value) - - # A typed map's member constraints are enforced, keyed by the member. - with pytest.raises(ValidationError) as excinfo: - _ = Showcase.model_validate({**base, "quotas": {"cpu": 7}}) - assert "cpu" in str(excinfo.value) + assert parse_violations({**BASE, "grid": [[1], [2, 1.5]]}) == [ + ("grid[1][1]", "expected integer") + ] + # A typed map's member constraints are enforced, keyed by the member under + # the map's own path. + assert parse_violations({**BASE, "quotas": {"cpu": 7}}) == [ + ("quotas.cpu", "must be a multiple of 5, got 7") + ] + token_violations = parse_violations({**BASE, "tokens": {"primary": "AB"}}) + assert [path for path, _ in token_violations] == ["tokens.primary"] + assert token_violations[0][1].startswith("must match pattern ") + assert parse_violations({**BASE, "nicknames": {"tiny": "a"}}) == [ + ("nicknames.tiny", "must have length >= 2, got 1") + ] + # The free-form bag's member-count bound rides with the hoisted type. + assert parse_violations({**BASE, "metadata": {"a": 1, "b": 2, "c": 3, "d": 4}}) == [ + ("metadata", "must have at most 3 properties, got 4") + ] - with pytest.raises(ValidationError) as excinfo: - _ = Showcase.model_validate({**base, "tokens": {"primary": "AB"}}) - assert "primary" in str(excinfo.value) - with pytest.raises(ValidationError) as excinfo: - _ = Showcase.model_validate({**base, "nicknames": {"tiny": "a"}}) - assert "tiny" in str(excinfo.value) +def test_serialize_rejects_invalid_in_memory_values() -> None: + """P12: `to_transfer_type` re-runs every check the parse side runs, so an + in-memory value past a bound is rejected before any wire form is produced.""" + converter = converter_for(Showcase) + full = decode_fixture(Showcase, SUITE, "showcase-full.json") + # A valid model still serializes cleanly (no false rejection). + _ = converter.to_transfer_type(full) + + for replacement, expected in [ + ({"priority": 42}, ("priority", "must be <= 10, got 42")), + ({"code": "abcdef"}, ("code", "must have length <= 5, got 6")), + ( + {"aliases": ["dup", "dup"]}, + ("aliases", "duplicate items: element at index 1 equals index 0"), + ), + ( + {"status": typing.cast(typing.Any, "archived")}, + ( + "status", + 'must be one of ["active", "inactive", "pending"], got "archived"', + ), + ), + ({"revision": typing.cast(typing.Any, 2)}, ("revision", "must equal 1")), + ]: + with pytest.raises(ValidationError) as excinfo: + _ = converter.to_transfer_type(dataclasses.replace(full, **replacement)) + assert violation_pairs(excinfo.value) == [expected] + + # Object-level checks fire on serialize too. + with pytest.raises(ValidationError) as excinfo: + _ = converter_for(Attributes).to_transfer_type( + Attributes(additional_properties={}) + ) + assert violation_pairs(excinfo.value) == [ + ("", "must have at least 1 properties, got 0") + ] - # The free-form bag's member-count bound rides with the hoisted type. with pytest.raises(ValidationError) as excinfo: - _ = Showcase.model_validate( - {**base, "metadata": {"a": 1, "b": 2, "c": 3, "d": 4}} + _ = converter_for(ContactPy).to_transfer_type( + ContactPy(shipping_street="1 Main St") + ) + assert violation_pairs(excinfo.value) == [ + ( + "shippingZip", + 'property "shippingZip" is required when "shippingStreet" is present', ) - assert "at most 3 properties" in str(excinfo.value) + ] diff --git a/samples/python/tests/test_temporal.py b/samples/python/tests/test_temporal.py index 4292c6e8..5d6c0af7 100644 --- a/samples/python/tests/test_temporal.py +++ b/samples/python/tests/test_temporal.py @@ -1,46 +1,31 @@ import datetime -import json -from pathlib import Path import typing import pytest -from temporalio.api.common.v1 import Payload -from temporalio.contrib.pydantic import pydantic_data_converter from temporal import Temporal - - -WIRE_FIXTURE_DIR = ( - Path(__file__).resolve().parents[2] / "wire" / "json_schema" / "temporal" +from temporal._definitions import ValidationError + +from tests.json_converter_helper import ( + converter_for, + decode_fixture, + encode, + load_fixture, + violation_pairs, ) - -def load_fixture(name: str) -> object: - return json.loads((WIRE_FIXTURE_DIR / name).read_text(encoding="utf-8")) - - -def fixture_bytes(name: str) -> bytes: - return (WIRE_FIXTURE_DIR / name).read_bytes() +SUITE = "temporal" def decode(name: str) -> Temporal: - payload = Payload(metadata={"encoding": b"json/plain"}, data=fixture_bytes(name)) - converter = pydantic_data_converter.payload_converter - return converter.from_payloads([payload], [Temporal])[0] - - -def encode(model: Temporal) -> object: - converter = pydantic_data_converter.payload_converter - encoded = converter.to_payloads([model]) - assert encoded is not None - return json.loads(encoded[0].data) + return decode_fixture(Temporal, SUITE, name) def test_temporal_roundtrip_full() -> None: # Materialized temporals become native datetime/date/time/timedelta and # re-serialize (generator-owned) byte-identically for microsecond precision. model = decode("temporal-full.json") - assert encode(model) == load_fixture("temporal-full.json") + assert encode(model) == load_fixture(SUITE, "temporal-full.json") assert model.created_at.utcoffset() == datetime.timedelta(hours=2) assert model.created_at.microsecond == 123456 assert model.timeout == datetime.timedelta(minutes=90) @@ -49,7 +34,7 @@ def test_temporal_roundtrip_full() -> None: def test_temporal_roundtrip_minimal() -> None: assert encode(decode("temporal-minimal.json")) == load_fixture( - "temporal-minimal.json" + SUITE, "temporal-minimal.json" ) @@ -65,23 +50,92 @@ def test_temporal_canonicalization() -> None: } -def test_temporal_nulls() -> None: +def test_temporal_nulls_collapse_on_roundtrip() -> None: + # `deletedAt`/`archivedOn` are optional+nullable. A dataclass has no presence + # channel, so absent and explicit `null` are the same in-memory state (None) + # and both re-serialize as OMITTED. Python now matches Go and Java here (see + # samples/go/tests/json_schema_temporal_test.go, TestJSONSchemaTemporalNulls); + # only TypeScript still preserves the explicit null. model = decode("temporal-nulls.json") assert model.deleted_at is None assert model.archived_on is None assert model.timeout == datetime.timedelta(0) + wire = typing.cast( + "dict[str, typing.Any]", load_fixture(SUITE, "temporal-nulls.json") + ) + assert wire["deletedAt"] is None + assert wire["archivedOn"] is None + # The explicit nulls are gone from the re-encoded wire; everything else survives. + assert encode(model) == { + key: value + for key, value in wire.items() + if key not in ("deletedAt", "archivedOn") + } + + +def test_temporal_absent_and_explicit_null_are_indistinguishable() -> None: + # The collapse, stated directly: the two payloads produce equal models. + base: dict[str, typing.Any] = { + "createdAt": "2021-06-15T12:30:45Z", + "birthday": "2000-01-01", + "alarm": "09:00:00", + "timeout": "PT0S", + } + converter = converter_for(Temporal) + absent = converter.from_transfer_type(base, Temporal) + explicit_null = converter.from_transfer_type( + {**base, "deletedAt": None, "archivedOn": None}, Temporal + ) + assert absent == explicit_null + + +def test_missing_required_members_aggregate() -> None: + # P11: one bad payload surfaces every violation it contains, in declared + # property order — the aggregation pydantic used to provide for free. + with pytest.raises(ValidationError) as excinfo: + _ = converter_for(Temporal).from_transfer_type({}, Temporal) + assert violation_pairs(excinfo.value) == [ + ("createdAt", "required"), + ("birthday", "required"), + ("alarm", "required"), + ("timeout", "required"), + ] + + +def test_non_object_payload_is_a_single_structural_violation() -> None: + with pytest.raises(ValidationError) as excinfo: + _ = converter_for(Temporal).from_transfer_type("nope", Temporal) + assert violation_pairs(excinfo.value) == [("", "expected object")] + + +def test_unknown_member_is_rejected() -> None: + with pytest.raises(ValidationError) as excinfo: + _ = converter_for(Temporal).from_transfer_type( + { + "createdAt": "2021-06-15T12:30:45Z", + "birthday": "2000-01-01", + "alarm": "09:00:00", + "timeout": "PT0S", + "nope": 1, + }, + Temporal, + ) + assert violation_pairs(excinfo.value) == [("nope", "unknown field")] + @pytest.mark.parametrize( - "field,value", + "field,value,format_name", [ - ("createdAt", "2021-12-31T23:59:60Z"), # leap second - ("timeout", "P1Y"), # calendar duration - ("birthday", "2021-02-29"), # invalid calendar date - ("createdAt", "2021-06-15T12:30:45"), # missing offset + ("createdAt", "2021-12-31T23:59:60Z", "date-time"), # leap second + ("timeout", "P1Y", "duration"), # calendar duration + ("birthday", "2021-02-29", "date"), # invalid calendar date + ("createdAt", "2021-06-15T12:30:45", "date-time"), # missing offset ], ) -def test_temporal_materialized_narrowing_rejects(field: str, value: str) -> None: +def test_temporal_materialized_narrowing_rejects( + field: str, value: str, format_name: str +) -> None: base: dict[str, typing.Any] = { "createdAt": "2021-06-15T12:30:45Z", "birthday": "2000-01-01", @@ -89,5 +143,10 @@ def test_temporal_materialized_narrowing_rejects(field: str, value: str) -> None "timeout": "PT0S", } base[field] = value - with pytest.raises(Exception): - _ = Temporal.model_validate(base) + with pytest.raises(ValidationError) as excinfo: + _ = converter_for(Temporal).from_transfer_type(base, Temporal) + # The reason names the format and the offending value, rendered in its JSON + # form exactly as Go and TypeScript render it. + assert violation_pairs(excinfo.value) == [ + (field, f'must be a valid {format_name}, got "{value}"') + ] diff --git a/samples/python/uv.lock b/samples/python/uv.lock index 2408ed47..c62d8a08 100644 --- a/samples/python/uv.lock +++ b/samples/python/uv.lock @@ -2,15 +2,6 @@ version = 1 revision = 3 requires-python = ">=3.10" -[[package]] -name = "annotated-types" -version = "0.7.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, -] - [[package]] name = "backports-asyncio-runner" version = "1.2.0" @@ -46,7 +37,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ @@ -123,137 +114,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c4/72/02445137af02769918a93807b2b7890047c32bfb9f90371cbc12688819eb/protobuf-6.33.6-py3-none-any.whl", hash = "sha256:77179e006c476e69bf8e8ce866640091ec42e1beb80b213c3900006ecfba6901", size = 170656, upload-time = "2026-03-18T19:04:59.826Z" }, ] -[[package]] -name = "pydantic" -version = "2.13.4" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "annotated-types" }, - { name = "pydantic-core" }, - { name = "typing-extensions" }, - { name = "typing-inspection" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, -] - -[[package]] -name = "pydantic-core" -version = "2.46.4" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e7/08/f1ba952f1c8ae5581c70fa9c6da89f247b83e3dd8c09c035d5d7931fc23d/pydantic_core-2.46.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:a396dcc17e5a0b164dbe026896245a4fa9ff402edca1dff0be3d53a517f74de4", size = 2113146, upload-time = "2026-05-06T13:37:36.537Z" }, - { url = "https://files.pythonhosted.org/packages/56/c6/65f646c7ff09bd257f660434adb45c4dfcbbcebcc030562fecf6f5bf887d/pydantic_core-2.46.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:da4b951fe36dc7c3a1ccb4e3cd1747c3542b8c9ceede8fc86cae054e764485f5", size = 1949769, upload-time = "2026-05-06T13:37:46.365Z" }, - { url = "https://files.pythonhosted.org/packages/64/ba/bfb1d928fd5b49e1258935ff104ae356e9fd89384a55bf9f847e9193ad40/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb63e0198ca18aad131c089b9204c23079c3afa95487e561f4c522d519e55aba", size = 1974958, upload-time = "2026-05-06T13:37:28.611Z" }, - { url = "https://files.pythonhosted.org/packages/4e/74/76223bfb117b64af743c9b6670d1364516f5c0604f96b48f3272f6af6cc6/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f47286a97f0bc9b8859519809077b91b2cefe4ae47fcbf5e466a009c1c5d742b", size = 2042118, upload-time = "2026-05-06T13:36:55.216Z" }, - { url = "https://files.pythonhosted.org/packages/cb/7b/848732968bc8f48f3187542f08358b9d842db564147b256669426ebb1652/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:905a0ed8ea6f2d61c1738835f99b699348d7857379083e5fc497fa0c967a407c", size = 2222876, upload-time = "2026-05-06T13:38:25.455Z" }, - { url = "https://files.pythonhosted.org/packages/b5/2f/e90b63ee2e14bd8d3db8f705a6d75d64e6ee1b7c2c8833747ce706e1e0ce/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea793e075b70290d89d8142074262885d3f7da19634845135751bd6344f73b50", size = 2286703, upload-time = "2026-05-06T13:37:53.304Z" }, - { url = "https://files.pythonhosted.org/packages/ba/1e/acc4d70f88a0a277e4a1fa77ebb985ceabaf900430f875bf9338e11c9420/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:395aebd9183f9d112f569aeb5b2214d1a10a33bec8456447f7fbdfa51d38d4cd", size = 2092042, upload-time = "2026-05-06T13:38:46.981Z" }, - { url = "https://files.pythonhosted.org/packages/a9/da/0a422b57bf8504102bf3c4ccea9c41bab5a5cee6a54650acf8faf67f5a24/pydantic_core-2.46.4-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:b078afbc25f3a1436c7a1d2cd3e322497ee99615ba97c563566fdf46aff1ee01", size = 2117231, upload-time = "2026-05-06T13:39:23.146Z" }, - { url = "https://files.pythonhosted.org/packages/bd/2a/2ac13c3af305843e23c5078c53d135656b3f05a2fd78cb7bbbb12e97b473/pydantic_core-2.46.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f747929cf940cddb5b3668a390056ddd5ba2e5010615ea2dcf4f9c4f3ab8791d", size = 2168388, upload-time = "2026-05-06T13:40:08.06Z" }, - { url = "https://files.pythonhosted.org/packages/72/04/2beacf7e1607e93eefe4aed1b4709f079b905fb77530179d4f7c71745f22/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:daa27d92c36f24388fe3ad306b174781c747627f134452e4f128ea00ce1fe8c4", size = 2184769, upload-time = "2026-05-06T13:38:13.901Z" }, - { url = "https://files.pythonhosted.org/packages/9e/29/d2b9fd9f539133548eaf622c06a4ce176cb46ac59f32d0359c4abc0de047/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:19e51f073cd3df251856a8a4189fbdf1de4012c3ebacfb1884f94f1eb406079f", size = 2319312, upload-time = "2026-05-06T13:39:08.24Z" }, - { url = "https://files.pythonhosted.org/packages/7c/af/0f7a5b85fec6075bea96e3ef9187de38fccced0de92c1e7feda8d5cc7bb9/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c1747f85cee84c26985853c6f3d9bd3e75da5212912443fa111c113b9c246f39", size = 2361817, upload-time = "2026-05-06T13:38:43.2Z" }, - { url = "https://files.pythonhosted.org/packages/25/a4/73363fec545fd3ec025490bdda2743c56d0dd5b6266b1a53bbe9e4265375/pydantic_core-2.46.4-cp310-cp310-win32.whl", hash = "sha256:2f84c03c8607173d16b5a854ec68a2f9079ae03237a54fb506d13af47e1d018d", size = 1987085, upload-time = "2026-05-06T13:39:25.497Z" }, - { url = "https://files.pythonhosted.org/packages/01/aa/62f082da2c91fac1c234bc9ee0066257ce83f0604abd72e4c9d5991f2d84/pydantic_core-2.46.4-cp310-cp310-win_amd64.whl", hash = "sha256:8358a950c8909158e3df31538a7e4edc2d7265a7c54b47f0864d9e5bae9dcebf", size = 2074311, upload-time = "2026-05-06T13:39:59.922Z" }, - { url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" }, - { url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" }, - { url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" }, - { url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051, upload-time = "2026-05-06T13:38:10.447Z" }, - { url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314, upload-time = "2026-05-06T13:40:13.089Z" }, - { url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146, upload-time = "2026-05-06T13:38:59.224Z" }, - { url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685, upload-time = "2026-05-06T13:38:17.762Z" }, - { url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420, upload-time = "2026-05-06T13:37:58.195Z" }, - { url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122, upload-time = "2026-05-06T13:37:01.167Z" }, - { url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573, upload-time = "2026-05-06T13:38:45.04Z" }, - { url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139, upload-time = "2026-05-06T13:37:15.539Z" }, - { url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433, upload-time = "2026-05-06T13:37:30.099Z" }, - { url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513, upload-time = "2026-05-06T13:38:15.669Z" }, - { url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114, upload-time = "2026-05-06T13:40:35.416Z" }, - { url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298, upload-time = "2026-05-06T13:38:29.754Z" }, - { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, - { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, - { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, - { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, - { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, - { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, - { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, - { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, - { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, - { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, - { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, - { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, - { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, - { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, - { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, - { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, - { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, - { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, - { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, - { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, - { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, - { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, - { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, - { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, - { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, - { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, - { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, - { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, - { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, - { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, - { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, - { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, - { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, - { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, - { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, - { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, - { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, - { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, - { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, - { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, - { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, - { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, - { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, - { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, - { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, - { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, - { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, - { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, - { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, - { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, - { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, - { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, - { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, - { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, - { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, - { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, - { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, - { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, - { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, - { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, - { url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589, upload-time = "2026-05-06T13:37:10.817Z" }, - { url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552, upload-time = "2026-05-06T13:36:56.717Z" }, - { url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984, upload-time = "2026-05-06T13:39:06.207Z" }, - { url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417, upload-time = "2026-05-06T13:39:45.476Z" }, - { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, - { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, - { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, - { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, - { url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782, upload-time = "2026-05-06T13:37:04.016Z" }, - { url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146, upload-time = "2026-05-06T13:39:43.092Z" }, - { url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492, upload-time = "2026-05-06T13:36:58.124Z" }, - { url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604, upload-time = "2026-05-06T13:37:49.88Z" }, - { url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828, upload-time = "2026-05-06T13:37:43.053Z" }, - { url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000, upload-time = "2026-05-06T13:37:56.694Z" }, - { url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286, upload-time = "2026-05-06T13:40:05.667Z" }, - { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" }, -] - [[package]] name = "pygments" version = "2.20.0" @@ -338,7 +198,6 @@ version = "0.1.0" source = { virtual = "." } dependencies = [ { name = "basedpyright" }, - { name = "pydantic" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "ruff" }, @@ -349,7 +208,6 @@ dependencies = [ [package.metadata] requires-dist = [ { name = "basedpyright", specifier = "==1.31.4" }, - { name = "pydantic", specifier = ">=2.12.0" }, { name = "pytest", specifier = ">=8.4.2" }, { name = "pytest-asyncio", specifier = ">=1.2.0" }, { name = "ruff", specifier = ">=0.15.12" }, @@ -459,15 +317,3 @@ sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac8 wheels = [ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, ] - -[[package]] -name = "typing-inspection" -version = "0.4.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, -] diff --git a/specs/json-schema/PRINCIPLES.md b/specs/json-schema/PRINCIPLES.md index 11bb2928..5733aa1c 100644 --- a/specs/json-schema/PRINCIPLES.md +++ b/specs/json-schema/PRINCIPLES.md @@ -11,12 +11,12 @@ renumber them. ### Foundational mandates -1. **Polyglot wire compatibility is mandatory.** One schema generates models in every supported language (Go, TypeScript, Python, Java), and a JSON payload accepted by one language's deserializer must be accepted — and validated *identically* — by every other's. Validation semantics are part of the wire contract, not a per-language implementation detail: a value one language rejects (`1.5` for an `integer`, a `minLength` underflow, a missing required key) must be rejected by all, and a value one accepts must round-trip through any other unchanged. This is enforced from both ends — the strict subset (P6) refuses at load time any feature that cannot carry identical semantics across all four targets rather than approximate it, and the shared validator (P12) is specified once as language-agnostic constraint predicates whose per-language reimplementations must agree value-for-value. This takes precedence: where cross-language wire agreement conflicts with per-language ergonomics (P2), the wire wins — save **two bounded exceptions to round-trip byte-identity alone**, each a case where an idiomatic in-memory model provably cannot carry a wire distinction without an unidiomatic wrapper: (a) the Go/Java **optional+nullable collapse**, where an explicit `null` reads back as absent (see [[nullability]]); and (b) **native-type materialization** of a temporal [[format]], where precision may be lost *only* at the target type's genuine capacity limit — never an artificial common-denominator floor, and recoverable through a per-field `string` opt-out. Both touch round-trip fidelity **alone**: the accepted-and-rejected value set stays identical across all four languages (validation semantics are never excepted), and each loss is bounded and documented at its own keyword. Everything else an accepted schema expresses round-trips unchanged. +1. **Polyglot wire compatibility is mandatory.** One schema generates models in every supported language (Go, TypeScript, Python, Java), and a JSON payload accepted by one language's deserializer must be accepted — and validated *identically* — by every other's. Validation semantics are part of the wire contract, not a per-language implementation detail: a value one language rejects (`1.5` for an `integer`, a `minLength` underflow, a missing required key) must be rejected by all, and a value one accepts must round-trip through any other unchanged. This is enforced from both ends — the strict subset (P6) refuses at load time any feature that cannot carry identical semantics across all four targets rather than approximate it, and the shared validator (P12) is specified once as language-agnostic constraint predicates whose per-language reimplementations must agree value-for-value. This takes precedence: where cross-language wire agreement conflicts with per-language ergonomics (P2), the wire wins — save **two bounded exceptions to round-trip byte-identity alone**, each a case where an idiomatic in-memory model provably cannot carry a wire distinction without an unidiomatic wrapper: (a) the Go/Java/Python **optional+nullable collapse**, where an explicit `null` reads back as absent (see [[nullability]]); and (b) **native-type materialization** of a temporal [[format]], where precision may be lost *only* at the target type's genuine capacity limit — never an artificial common-denominator floor, and recoverable through a per-field `string` opt-out. Both touch round-trip fidelity **alone**: the accepted-and-rejected value set stays identical across all four languages (validation semantics are never excepted), and each loss is bounded and documented at its own keyword. Everything else an accepted schema expresses round-trips unchanged. 2. **Prefer ergonomics over performance — including idiomatic, hand-written-feeling output.** Pay for normalization and conversion rather than give a subpar per-language experience, and shape output to read like code a human wrote for that language: generated code lands in users' repositories and is read, reviewed, grepped, and debugged like any other source, so alien-looking codegen (the protoc / openapi-generator house style) is a real adoption and maintenance tax. This is the quality tiebreaker between otherwise-acceptable designs, and it is **always subordinate to polyglot consistency (P1)**. ### Operating constraints -3. **Works with the default Temporal payload converter setup.** Contrib libraries allowed only where necessary (e.g. Pydantic). +3. **Works with the default Temporal payload converter setup.** A contrib library is admissible only where a target genuinely cannot express the wire contract without one — a bar none of the four targets meets, so this holds literally in all of them: every language's output runs on the stock converter (`DataConverter.default`) with **no** contrib dependency and no user wiring. Python reaches it through the SDK's own transfer-type hook (Python §3), which the default converter consults for every payload. 4. **Minimal external runtime dependencies on generated code.** Only the `nexus-rpc` SDKs (service contracts) and Temporal SDKs (typed client generation). ### Input contract @@ -34,12 +34,12 @@ renumber them. ### Validation & error behavior 10. **Validation is enforced, not advisory.** Constraints (`minLength`, `pattern`, `minimum`, …), `const`, and discriminator strings are checked at the (de)serializer boundary in **both** directions (P12) — schemas are not just documentation. Violations aggregate per P11. -11. **Aggregate validation errors.** Surface every violation in one shot using the language-native aggregation primitive (see each language section): structured `{path, reason}` payloads, never stringly-typed messages. The error set becomes the cause of a Nexus RPC `HandlerError` with `BAD_REQUEST` error type. +11. **Aggregate validation errors.** Surface every violation in one shot using the language-native aggregation primitive (see each language section): structured `{path, reason}` payloads, never stringly-typed messages. The shape is uniform across all four targets — one aggregating error type per language holding a list of `Violation { path, reason }` — so a caller reads a rejection the same way whichever language produced it. The `reason` *text* is not held byte-identical across targets; the cross-language contract is the accepted-and-rejected value set (P1) plus this structure. The error set becomes the cause of a Nexus RPC `HandlerError` with `BAD_REQUEST` error type. 12. **Serialize-side validation; one shared validator, mirror-image adapters.** Validation runs in *both* directions (P10 is literal). Every (de)serializer decomposes into three layers, and crucially **no intermediate representation is round-tripped** to achieve sharing: 1. **Parse adapter (deserialize-only).** Wire → decoded value. Owns the checks that only exist on the wire: spec-number parsing (`1.0` accepted, `1.5` rejected), explicit-`null` rejection, wire-absence → required-presence, type-token classification, unknown-key preservation. These can't live in the shared layer because the decoded value no longer carries the wire information they inspect. 2. **Shared `Validate(model)` over the decoded model.** Every constraint predicate — the integer cap, numeric ranges, string `minLength`/`pattern`, `const`/`enum` checks, property counts, nested recursion. Pure functions over decoded values, *identical in both directions*, called by both — the single source of truth (P1). 3. **Encode adapter (serialize-only).** Decoded value → wire. Owns omit-vs-emit-`null` (per-field, from the optional/nullable/required declaration — see [[nullability]]) and `default` omission (see [[default]]). `const` adds nothing here — it is a pure assertion in the shared `Validate`. - Serialize runs the shared `Validate` **before emitting a byte** and fails with the same aggregated primitive as deserialize (P11). In statically-typed languages (Go/TS/Java) in-memory construction is unchecked, so serialize-side validation has real teeth. + Serialize runs the shared `Validate` **before emitting a byte** and fails with the same aggregated primitive as deserialize (P11). In-memory construction is unchecked in every target — Go/TS/Java carry only what static typing can express, and a Python dataclass validates nothing on `__init__` — so serialize-side validation has real teeth. ### Forward compatibility @@ -60,14 +60,14 @@ renumber them. 1. **Hand-emitted validators, no runtime schema library (P4).** The generated runtime ships only plain `typeof`/`Array.isArray`/`Number.isSafeInteger` checks — no `zod`/`ajv`/`lossless-json` dependency. The ±(2^53−1) integer cap (see [[type]]) is what makes this possible. 2. **Models emit `interface`s, not classes.** Models stay structural types — plain objects with no methods or runtime footprint (tree-shakeable, hand-written-feeling, P2). Conversion and validation live *off* the model, in a companion `TransferTypeConverter` (§4). -3. **Aggregate via a single `ValidationError` (extends `Error`) holding `Violation[]` (P11).** Collect every `Violation { path, reason }` into the list and throw **one** custom `ValidationError` — *not* a built-in `AggregateError` — whose `message` surfaces every violation and whose `violations` array exposes them structured, mirroring Java's `ValidationException` and Python's `pydantic.ValidationError`. Structured `path`/`reason`, never stringly-typed. +3. **Aggregate via a single `ValidationError` (extends `Error`) holding `Violation[]` (P11).** Collect every `Violation { path, reason }` into the list and throw **one** custom `ValidationError` — *not* a built-in `AggregateError` — whose `message` surfaces every violation and whose `violations` array exposes them structured, mirroring Java's `ValidationException` and Python's `ValidationError`. Structured `path`/`reason`, never stringly-typed. 4. **A companion `TransferTypeConverter` instance converts model ⇄ transfer value and validates; it does not stringify (P12).** Each model gets one exported converter — `export const userTransferTypeConverter = new class implements TransferTypeConverter { fromTransferType(raw: unknown): User; toTransferType(value: User): unknown }()` — implementing nexus-rpc's `TransferTypeConverter` contract, where `toTransferType` is the encode adapter (model → plain JSON value) and `fromTransferType` the parse adapter (untrusted JSON value → model). It is an **instance**, not a class: the SDK's operation type info holds a converter, so emitting the instance is what lets each operation carry its own (see [[services]]) with no construction at the use site. Both directions run the same hand-emitted validators (collecting `Violation`s into one `ValidationError`), so validation lives *inside* the conversion — that is what makes them the single source of truth. The transfer value is plain `unknown` (raw JSON), never a `string`: the byte-level `JSON.stringify`/`JSON.parse` is the Temporal converter's boundary, which hands the converter the parsed (or about-to-be-stringified) value. Working in transfer values (not strings) is also what makes conversions **composable** — a parent's `toTransferType` calls its children's on nested values and embeds the results, and `fromTransferType` likewise; a `string` could not nest. The per-field omit-vs-`null` decision follows the [[nullability]] serialize table. Because the contract name is imported into every model module, `TransferTypeConverter` joins the module's reserved generated identifiers — a `$defs` type of that name is a §15 load reject, not a shadowed import. The derived `TransferTypeConverter` identifiers join that same per-module namespace: lower-camel-casing folds type names the namespace keeps apart, so two models whose converters coincide reject at load rather than emitting the same `export const` twice. ## Python -1. **Pydantic v2 in strict mode, globally.** Every generated model is a strict `pydantic.BaseModel`; strict mode rejects lax coercions (`"1"`→`1`, `1`→`True`) that would otherwise violate P10/P7. -2. **Aggregate via Pydantic's native `pydantic.ValidationError` (P11).** It already collects every field error (`.errors()` → `loc`/`msg`/`type`). For violations Pydantic can't see on its own (e.g. optional-non-nullable explicit `null`), a `model_validator(mode='wrap')` runs the inner handler, catches its `ValidationError`, and merges pre-errors + field errors into one (the `mode='wrap'`-vs-`before` rationale lives in [[nullability]]). -3. **Serialize via a generated `@model_serializer(mode='wrap')` (P12).** The default Temporal `pydantic_data_converter` **owns** the serialize call — a plain `pydantic_core.to_json(value)` (`exclude_unset=False`, no validation) — so we cannot pass `model_dump(exclude_unset=True)` ourselves (P3) and instead bake the behavior into the model, keyed on `model_fields_set`, where `to_json` honors it; the resulting omit-vs-`null` follows the [[nullability]] serialize table. The same serializer re-validates current field values to catch `model_construct`/mutation bypasses (`validate_assignment` covers in-place mutation); the read side needs no extra hook (the converter's `validate_json` runs every validator). See [[nullability]], [[const]], [[default]]. +1. **Models emit `@dataclasses.dataclass`es, not a validating model base (P2/P4).** Every model is `@dataclasses.dataclass(slots=True, kw_only=True)` — inert data with no methods, no runtime footprint, and **no validation on construction**. Field annotations are plain Python types (`int`, `str`, `datetime.datetime`, `datetime.timedelta`, `bytes`, `typing.Literal[...]`, `list[T]`, `dict[str, V]`), never annotated validator aliases, so the class reads like a hand-written dataclass (P2) and the runtime dependency set stays at the SDKs alone (P4). `slots=True, kw_only=True` is unconditional: JSON Schema interleaves required and optional properties freely, so positional ordering is never safe, and keyword-only construction keeps a later added optional property from reordering an existing call site (P13). Conversion and validation live *off* the model, in a companion transfer-type converter (§3) — which also means in-memory construction is unchecked here exactly as it is in Go/TS/Java, and that is what gives serialize-side validation real teeth (P12). +2. **Aggregate via a single generated `ValidationError` (extends `Exception`) holding `list[Violation]` (P11).** Collect every `Violation { path, reason }` — a `@dataclasses.dataclass(frozen=True, slots=True)` in the shared `definitions` module — into one list and raise **one** generated `ValidationError`, whose `str()` enumerates every violation and whose `violations` attribute exposes them structured. This is the same primitive Go, TypeScript and Java surface, with the same structured `{path, reason}` shape — so all four targets now report a rejection the same way (P11), and the cross-language guarantee is the accepted-and-rejected value set (P1), not byte-identical message text. Python's `reason` strings mirror **TypeScript's** verbatim, Python being the same design. It is never stringly-typed and never a language-native exception group. A nested value's `ValidationError` is re-pathed under the enclosing field by a `_collect(violations, path, error)` runtime helper and merged into the parent's list, so one payload yields one flat, fully-pathed violation set — the analogue of TypeScript's `collect`. +3. **A companion `_TransferTypeConverter` converts model ⇄ intermediate and validates; the *default* Temporal converter finds it through the SDK's transfer-type hook (P12/P3).** Each model gets a private converter class — `class _UserTransferTypeConverter(temporalio.converter.TransferTypeConverter["User", typing.Any])` with `from_transfer_type(value: typing.Any, type_hint: type[User]) -> User` as the parse adapter (untrusted JSON value → model) and `to_transfer_type(value: User) -> typing.Any` as the encode adapter (model → plain JSON value) — attached to the class by `@_transfer_type_convertible(_UserTransferTypeConverter)`, the runtime module's one-line shim over `temporalio.converter.transfer_type_convertible` that erases the converter's value-type parameter (binding it on the decorated class is circular for a static type checker: the class's type depends on the decorator, whose value type depends on the class). Both directions run the same emitted checks, collecting `Violation`s into one `ValidationError` (§2), so validation lives *inside* the conversion — that is what makes them the single source of truth in both directions. The intermediate is a plain `dict`/`list`/scalar, never a `str`: the byte-level JSON encode/decode is the Temporal payload converter's boundary, which hands the transfer-type converter the parsed (or about-to-be-encoded) value. Working in intermediate values (not strings) is also what makes conversions **composable** — a parent's `to_transfer_type` calls its children's on nested values and embeds the results, `from_transfer_type` likewise; a `str` could not nest. That composition is load-bearing rather than stylistic: the SDK hooks only the **top-level** value, so a nested model is always converted by its parent's body. Registration is the whole of the wiring — the stock `DataConverter.default` consults the hook, so generated models need no contrib package and no user setup (P3). A `typing.TypeAlias` cannot be decorated, so a named or inline `oneOf` union is served by module-private free functions (`__from_transfer_type` / `__to_transfer_type`) instead of a converter class; unions can only appear nested, so nothing is lost. The per-field omit-vs-`null` decision follows the [[nullability]] serialize table. See [[nullability]], [[const]], [[default]]. ## Java diff --git a/specs/json-schema/features/additionalProperties.md b/specs/json-schema/features/additionalProperties.md index 6898c615..6e97947a 100644 --- a/specs/json-schema/features/additionalProperties.md +++ b/specs/json-schema/features/additionalProperties.md @@ -101,8 +101,9 @@ catch-all member: bare `map[string]T`. - **Java** — class with `Map additionalProperties`, **not** a top-level `Map`. -- **Python** — a Pydantic `BaseModel` (`extra='allow'`), extras in - `model_extra`, **not** a `dict[str,T]` alias. +- **Python** — a dataclass with an `additional_properties: dict[str, V] = + dataclasses.field(default_factory=dict)` member, **not** a `dict[str,T]` + alias. - **TypeScript** — an `interface` with an `additionalProperties: Record` member, **not** an inline index signature or a bare `Record` alias. @@ -112,9 +113,9 @@ This buys two things: 1. **Shape stability** (**P2**/**P13**): adding `properties` later only *adds fields/attributes* to the same type — it never changes kind ("map alias" → "struct/model"), so downstream call sites keep - compiling. Verified the Python instability this avoids - (`/tmp/pyd_map_shape.py`): a `dict[str,T]` alias that becomes a - `BaseModel` breaks `m["k"]` with `TypeError: not subscriptable`. + compiling. The Python instability this avoids: a `dict[str,T]` alias + that becomes a dataclass breaks `m["k"]` with `TypeError: not + subscriptable`. 2. **A clean separation of declared keys from extra keys.** Declared members are renamed to canonical language identifiers (the identifier case-mapping in [[properties]]); extra keys are @@ -148,8 +149,9 @@ JSON members; the in-memory catch-all is bridged by the generated into the catch-all map and spreads them back on write, **not** `@JsonAnySetter`/`@JsonAnyGetter` (a class-level custom (de)serializer bypasses those), TS hand-emitted ser/deser that lifts top-level extras -into `additionalProperties` and spreads them back out, Python -`model_extra`). +into `additionalProperties` and spreads them back out, Python the +generated transfer type converter doing the same for +`additional_properties`). ## Type mapping @@ -161,24 +163,24 @@ generator guessing their shape (**P13**). | Case | Go | TypeScript | Python | Java | |---|---|---|---|---| -| Open struct, untyped extras (default / `true`) | struct + `AdditionalProperties map[string]json.RawMessage` | `interface` + `additionalProperties: Record` | model `extra='allow'` (extras in `model_extra`) | POJO + `Map`, populated/emitted by the collecting (de)serializer (Java §5) | -| **Typed extras + `properties` (`{type:T}`)** | struct + `AdditionalProperties map[string]T` | `interface` + `additionalProperties: Record` | model `extra='allow'` + per-extra `T` validation (extras in `model_extra`) | POJO + `Map`, populated/emitted by the collecting (de)serializer (Java §5) with per-extra `T` validation | -| Closed struct (`false`) | no catch-all field; unknown → error | exact `interface`, no `additionalProperties`; unknown → error | model `extra='forbid'` | no catch-all field; the collecting deserializer (Java §5) flags each undeclared tree key as a `Violation` | -| Open opaque map (`true`, no props) | struct + `AdditionalProperties map[string]json.RawMessage` (wrapper) | `interface` + `additionalProperties: Record` (wrapper) | `BaseModel` `extra='allow'` (extras in `model_extra`) | class + `Map additionalProperties` (wrapper) | -| Typed map (`{type:T}`, no props) | struct + `AdditionalProperties map[string]T` (wrapper) | `interface` + `additionalProperties: Record` (wrapper) | `BaseModel` `extra='allow'` + per-extra `T` validation | class + `Map additionalProperties` (wrapper) | -| Closed empty object (`false`, no props) | empty `struct{}`; any member → error | empty `interface`; any member → error | `extra='forbid'`, no fields | empty POJO; the collecting deserializer (Java §5) flags any tree key as a `Violation` | - -The TS `additionalProperties` member is always present when extras are -allowed (an empty `{}` when none were received), so the surface is -uniform whether or not a given instance carried extras. +| Open struct, untyped extras (default / `true`) | struct + `AdditionalProperties map[string]json.RawMessage` | `interface` + `additionalProperties: Record` | dataclass + `additional_properties: dict[str, typing.Any]`, populated/emitted by the converter (Python §3) | POJO + `Map`, populated/emitted by the collecting (de)serializer (Java §5) | +| **Typed extras + `properties` (`{type:T}`)** | struct + `AdditionalProperties map[string]T` | `interface` + `additionalProperties: Record` | dataclass + `additional_properties: dict[str, T]`, populated/emitted by the converter (Python §3) with per-extra `T` validation | POJO + `Map`, populated/emitted by the collecting (de)serializer (Java §5) with per-extra `T` validation | +| Closed struct (`false`) | no catch-all field; unknown → error | exact `interface`, no `additionalProperties`; unknown → error | no catch-all member; the converter flags each undeclared key as a `Violation` | no catch-all field; the collecting deserializer (Java §5) flags each undeclared tree key as a `Violation` | +| Open opaque map (`true`, no props) | struct + `AdditionalProperties map[string]json.RawMessage` (wrapper) | `interface` + `additionalProperties: Record` (wrapper) | dataclass + `additional_properties: dict[str, typing.Any]` (wrapper) | class + `Map additionalProperties` (wrapper) | +| Typed map (`{type:T}`, no props) | struct + `AdditionalProperties map[string]T` (wrapper) | `interface` + `additionalProperties: Record` (wrapper) | dataclass + `additional_properties: dict[str, T]` (wrapper) + per-extra `T` validation | class + `Map additionalProperties` (wrapper) | +| Closed empty object (`false`, no props) | empty `struct{}`; any member → error | empty `interface`; any member → error | empty dataclass; the converter flags any key as a `Violation` | empty POJO; the collecting deserializer (Java §5) flags any tree key as a `Violation` | + +The TS `additionalProperties` member and the Python +`additional_properties` field are always present when extras are allowed +(empty when none were received — Python via +`dataclasses.field(default_factory=dict)`), so the surface is uniform +whether or not a given instance carried extras. A declared [[properties]] member literally named `additionalProperties` -collides with the generated catch-all member in **Go** -(`AdditionalProperties`), **Java** (`additionalProperties`), and **TS** -(`additionalProperties`) → reject at load time with a diagnostic. -Python alone is exempt — extras live in Pydantic's `model_extra`, not a -declared field, so a property named `additionalProperties` is just a -normal attribute there. +collides with the generated catch-all member in **all four languages** — +**Go** (`AdditionalProperties`), **Java** (`additionalProperties`), **TS** +(`additionalProperties`), **Python** (`additional_properties`) → reject at +load time with a diagnostic. ### Why `json.RawMessage`, not `any`, for Go untyped extras @@ -220,7 +222,7 @@ violation aggregates. |---|---|---|---| | Go | `UnmarshalJSON` routes unmatched keys into `AdditionalProperties`; `MarshalJSON` re-emits them | same routing, but each value goes through `T`'s runtime helper; failures → `Violation{Path:key}` | `UnmarshalJSON` emits `Violation{Path:key, Reason: fmt.Sprintf("unknown property %q", key)}` per unmatched key, collected into one `ValidationError` | | TypeScript | deser lifts non-declared keys into the `additionalProperties` Record; reser spreads them back to top-level | same, but each value validated as `T` before going into `additionalProperties` (member stays fully typed `Record`) | check parsed keys against the known set; push `Violation{path:key}` per extra, throw one `ValidationError` | -| Python | `extra='allow'` — extras land in `model_extra`, **round-trip via `model_dump_json`** (verified, `/tmp/pyd_extra_probe.py`) | `extra='allow'` + a post-init validator checks each `model_extra` value is `T`, aggregating per-key failures (verified, `/tmp/pyd_typed_extra.py`) | `extra='forbid'` — Pydantic raises `extra_forbidden` per extra key, aggregated (verified) | +| Python | `from_transfer_type` lifts non-declared keys into the `additional_properties` dict verbatim; `to_transfer_type` spreads them back to top-level | same, but each value is validated and materialized as `T` before going into `additional_properties` (member stays fully typed `dict[str, T]`) | check parsed keys against `__DECLARED`; append `Violation(path=key, reason="unknown field")` per extra, raise one `ValidationError` | | Java | the per-POJO collecting deserializer (Java §5) routes parsed-tree keys not in the declared set into the `additionalProperties` map; the matching serializer spreads them back | same routing, but each extra value is validated as `T` (bad keys → `Violation{path:key}`) | the collecting deserializer pushes a `Violation{path:key, "unknown property \"" + key + "\""}` per undeclared tree key into the single `ValidationException` — no fail-fast `ignoreUnknown=false`/`UnrecognizedPropertyException` | ### Per-member `T` validation @@ -240,11 +242,13 @@ Per language, the mechanism is the one that position already uses: - **Go / TypeScript / Java** run the same check emitters a *property* of that type runs, over the decoded member inside the member loop — one set of predicates, two call sites. -- **Python** validates and **materializes** each member through a module-level - `pydantic.TypeAdapter` over the member's annotation (declared after the classes - so a referenced model resolves), then re-encodes each member through that same - adapter on the way out. So `model_extra` holds the *declared* member type — an - `Inner` instance, an `int` parsed from `1.0`, a `datetime`, `bytes` — rather +- **Python** validates and **materializes** each member inside the converter's + member loop, calling the same `_parse_*` / `_check_*` helpers (or the + referenced model's own converter) a *property* of that type calls, then + re-encoding each member through the matching `_format_*` / serialize path + on the way out. So `additional_properties` holds the *declared* member + type — an `Inner` instance, an `int` parsed from `1.0`, a `datetime`, + `bytes` — rather than the raw wire value. - A **closed member value set** (`const`/`enum`) is a validator-only closedness in Go and Java: a member has no field to hang a defined type or value class @@ -259,20 +263,11 @@ dropped from the map or rejected: Go `map[string]*T`, Java `Map`, TypeScript `Record`, Python `T | None`. A present member still carries its own constraints. -Empirical notes (Pydantic 2.13): -- `extra='allow'` + `strict=True` coexist: declared fields stay strict - (`"1"` rejected for an `int`) while extras are preserved. -- `extra='forbid'` aggregates: `{"id":1,"name":"x","a":1,"b":2}` - yields two `extra_forbidden` entries in one `ValidationError`. -- Typed extras: `{"id":1,"name":"x","a":1,"b":true,"c":"ok"}` against - `additionalProperties:{type:string}` yields two `extra_type` entries - (`a`, `b`) in one `ValidationError`, while `c` passes and round-trips. - ### Serialize-side (P12) The catch-all is re-emitted by spreading its members back to top-level JSON (Go `MarshalJSON` / TS reserializer / Java the per-POJO collecting -serializer, Java §5 / Python `model_dump`). Symmetry per mode: +serializer, Java §5 / Python `to_transfer_type`). Symmetry per mode: - **Open, untyped** — extras pass through **verbatim**; Go's `json.RawMessage` element type guarantees byte-faithful re-emit (no @@ -331,7 +326,7 @@ unambiguous in both directions. - Open opaque map round-trips arbitrary nested JSON unchanged. - Pure map (all four languages) decodes into the wrapper's catch-all (`AdditionalProperties` member / `additionalProperties` Record / - `model_extra`), not a bare map/dict. + `additional_properties` dict), not a bare map/dict. ## Interactions diff --git a/specs/json-schema/features/allOf.md b/specs/json-schema/features/allOf.md index a99f7058..3e53a7c9 100644 --- a/specs/json-schema/features/allOf.md +++ b/specs/json-schema/features/allOf.md @@ -291,7 +291,7 @@ $defs: `Widget` merges to a single object with `{id, size, name}`, `required: [id, name]` — copied fields, no inheritance. Every target emits it as it would the hand-written combined object (Go struct, TS `interface`, Python -`BaseModel`, Java POJO); `Base` and `Sized` remain their own types, +dataclass, Java POJO); `Base` and `Sized` remain their own types, unrelated to `Widget`. ## Validator mapping diff --git a/specs/json-schema/features/const.md b/specs/json-schema/features/const.md index cbe85d72..72af58f6 100644 --- a/specs/json-schema/features/const.md +++ b/specs/json-schema/features/const.md @@ -154,9 +154,11 @@ wrong value is a compile error); the deserialize validator compares the wire value against the same literal. **Python.** The **closed literal** via `Literal` — `Literal["user"]`, -`Literal[1]`, `Literal[True]`. **`float` consts are the exception:** -`Literal` forbids float members (PEP 586), so a number const is plain -`float` and closedness rests on the `model_validator` alone. +`Literal[1]`, `Literal[True]` — carried as the dataclass field's default so +a consumer never has to restate the fixed value. **`float` consts are the +exception:** `Literal` forbids float members (PEP 586), so a number const +is plain `float` and closedness rests on the converter's equality check +alone. **Java.** A generated **value class** wrapping the primitive, for every scalar kind — a known constant, a private constructor, and Jackson @@ -323,7 +325,7 @@ value — the **shared `Validate`** layer of **P12**). |---|---| | Go | A predicate in the shared `Validate`, which `UnmarshalJSON` calls after decoding: `if v != UserEventKindUser { … Violation{Path, Reason: fmt.Sprintf("must equal %q, got %q", UserEventKindUser, v)} }`, collected into one `ValidationError`. The field is the defined type; the typed constant is both the compared value and the idiomatic setter (`UserEvent{Kind: UserEventKindUser}`). | | TypeScript | the shared `Validate` predicate compares against the literal: ``if (v !== "user") push(Violation{path, reason: `must equal "user", got ${JSON.stringify(v)}`})``, throwing one `ValidationError`. The field's literal type closes it in-language. | -| Python | a field/`model_validator` comparing `== "user"` (the literal), raising `InitErrorDetails` into the aggregated `pydantic.ValidationError`. The field is the closed `Literal` (`float` consts are plain `float`, validated the same way). | +| Python | the transfer type converter (PRINCIPLES Python §3) compares against the literal — `v != "user"` → `Violation(path=…, reason='must equal "user", got ')`, the same reason string TypeScript emits — aggregated into the single generated `ValidationError`. The field is the closed `Literal` (`float` consts are plain `float`, validated the same way). | | Java | the aggregating path is the per-POJO collecting deserializer (PRINCIPLES Java §5), which does a **non-throwing membership lookup** — known value → the constant, otherwise record a `Violation{path, "must equal \"user\", got …"}` — so multiple bad fields all collect into the single `ValidationException`, consistent with every other §5 constraint helper. The value class's `@JsonCreator fromString` *throws* only on the **standalone/interop** path, where fail-fast is expected. Serialize needs no separate check: the value class can only hold a known constant. | ### Serialize-side (P12) @@ -348,12 +350,13 @@ in: |---|---| | Go | Field typed with the defined type (`Kind UserEventKind`), set idiomatically via the typed value constant (`UserEvent{Kind: UserEventKindUser}`). A forgotten field is the zero value (`UserEventKind("")`), which the shared `Validate` rejects **loudly** on serialize — consistent with how Go treats every required field. optional+const uses a pointer to the defined type + `,omitempty`, validated when non-nil. | | TypeScript | The field is the closed literal (`kind: "user"`); a wrong value is a compile error, so a required+const is always correct in memory and emitted by the normal `toTransferType`. optional+const emits when not `undefined`. | -| Python | Presence follows [[required]] like any field — **no auto-fill**: a required+const absent on the wire is a required violation (Pydantic's own missing-field error), an optional+const absent stays omitted, and a `model_validator` enforces `== "user"` whenever the value is present. A required+const is set by the consumer, so it is already in `model_fields_set` and emits under plain `to_json` (the **default Temporal converter** path); the generated `@model_serializer(mode='wrap')` re-validates it before emit. | +| Python | Presence follows [[required]] like any field — **no auto-fill on parse**: a required+const absent on the wire is a `"required"` violation, an optional+const absent stays omitted, and `from_transfer_type` enforces `== "user"` whenever the value is present. In memory the dataclass field carries the const as its **default**, so a consumer never has to restate it and `to_transfer_type` always has the right value to write; it re-checks equality before emitting the key. This all runs under the **default Temporal converter** (PRINCIPLES Python §3). | | Java | `private final UserEventKind kind = UserEventKind.USER;` for required+const, getter only. The value class can only hold a known constant, so the getter (via `@JsonValue`) emits `"user"` by the normal path. On the way in, the collecting deserializer's membership lookup records a `Violation` for a non-`"user"` wire value. optional+const is a `@Nullable UserEventKind` constructor parameter, validated if non-null. Numeric/boolean consts use their value classes the same way. | The serialize equality check has teeth only where a wrong value can be set in memory before emit: an optional+const set to a wrong value, a Go -zero-value/mutated field, or a Python `model_construct` bypass. In TS and +zero-value/mutated field, or any Python in-memory assignment (a dataclass +validates nothing on construction, PRINCIPLES Python §1). In TS and Java required+const the value cannot be wrong in memory (type / `final`), so the check is effectively a deserialize-direction guard there. diff --git a/specs/json-schema/features/contains.md b/specs/json-schema/features/contains.md index f9fb0ac8..453dee3d 100644 --- a/specs/json-schema/features/contains.md +++ b/specs/json-schema/features/contains.md @@ -129,7 +129,7 @@ pushed. |---|---| | Go | A predicate in the shared `Validate`, called by `UnmarshalJSON` after decoding into the `[]T`: `matched := false; for _, e := range v { if matchesContains(e) { matched = true; break } }; if !matched { push(Violation{Path, Reason: "no element matches the required schema"}) }`, collected into one `ValidationError`. `matchesContains` reuses the matcher's own scalar predicates. | | TypeScript | After the `Array.isArray` guard ([[items]]), the shared `Validate` scans: ``if (!v.some(e => matchesContains(e))) push(Violation{path, reason})``, throwing one `ValidationError`. | -| Python | A `model_validator` over the decoded `list[T]` (Pydantic v2 has no native `contains`): `if not any(_matches_contains(e) for e in v): raise InitErrorDetails(...)`, aggregated into `pydantic.ValidationError`. In a position with **no declared field** to key a model validator on — a typed map's member, a [[oneOf]] branch — the same count predicate rides in the annotation as a `_check_contains` AfterValidator, with the identical reasons. | +| Python | The transfer type converter (PRINCIPLES Python §3) calls `_check_contains(v, …, path, violations)`, which scans `any(_matches_contains(e) for e in v)` and on no match appends `Violation(path, reason)` into the single generated `ValidationError`. `_matches_contains` reuses the matcher's own scalar predicates. Because the check is a plain call rather than a hook keyed on a declared field, positions with no field of their own — a typed map's member, a [[oneOf]] branch — run the identical call with the identical reasons. | | Java | The per-POJO collecting deserializer (PRINCIPLES Java §5) reads the `List`, scans against the matcher predicate, and on no match pushes a `Violation{path, reason}` into the single `ValidationException`. Not bean-validation. | Reason strings name **what was required**, not a bare keyword — the matcher diff --git a/specs/json-schema/features/contentEncoding.md b/specs/json-schema/features/contentEncoding.md index e67af854..16c5b13c 100644 --- a/specs/json-schema/features/contentEncoding.md +++ b/specs/json-schema/features/contentEncoding.md @@ -60,9 +60,9 @@ The defining choices (citing [[PRINCIPLES.md]]): browser-portable stdlib codec, so it gets a small generator-owned pure-JS codec (below). We already own the parse/encode adapters (PRINCIPLES: shadow-layout `UnmarshalJSON`, the collecting Jackson - (de)serializer, the TS transfer type converter, the Python model hooks), so selecting - the standard vs URL-safe codec per node is a codec choice, not new - machinery. + (de)serializer, the TS and Python transfer type converters), so + selecting the standard vs URL-safe codec per node is a codec choice, not + new machinery. - **A native bytes field is the idiomatic shape (P2).** A base64 blob modeled as a bare `string` forces every consumer to decode by hand at each use site; `[]byte` / `byte[]` / `bytes` / `Uint8Array` is what a @@ -151,10 +151,11 @@ binding), choosing the standard or URL-safe variant per the declared (P4). *(Keeping a canonical `string` in TS — the [[format]]-style fallback used for `date`/`duration` — is the alternative in Open questions.)* -- **Python** — we own the codec via the model hooks rather than lean on - Pydantic's `Base64Bytes` / `Base64UrlBytes`, for the same reason - [[format]] avoids native `datetime` coercion: full control of the - accept/reject line and the canonical output. `urlsafe_b64decode` +- **Python** — the codec is generator-owned, living in the + `_parse_base64` / `_parse_base64url` / `_format_base64` / + `_format_base64url` runtime helpers rather than in a library bytes type, + for the same reason [[format]] owns its temporal parsing: full control of + the accept/reject line and the canonical output. `urlsafe_b64decode` requires padding, so the unpadded wire is re-padded before decode (`s + "=" * (-len(s) % 4)`). @@ -191,7 +192,7 @@ wire is unambiguous and the stdlib decoder below agrees. |---|---| | Go | Parse adapter: run the encoding's pinned regex over the wire string, pushing a `Violation` on failure; else decode with the codec above → `[]byte`. Encode adapter: re-encode with the same codec. `regexp.MustCompile` compiled once at init. | | TypeScript | `fromTransferType`: pinned regex (`/…/u`) — **essential**, since the pure-JS decoder assumes canonical input and won't itself reject malformed text — then the generator-owned decoder → `Uint8Array`. `toTransferType`: the generator-owned encoder. Lookup table + arithmetic; **no `Buffer`/`atob`**, so it runs in the browser. | -| Python | Parse hook (an `AfterValidator` / model validator): regex over the wire string, then `b64decode(s, validate=True)` (`base64`) or `urlsafe_b64decode(s + pad)` (`base64url`) → `bytes`. Serialize: `@model_serializer` emits `b64encode(b)` / `urlsafe_b64encode(b).rstrip(b"=")` as ASCII. | +| Python | `_parse_base64(v, path, violations)` / `_parse_base64url(...)`, called from the converter: regex over the wire string, then `b64decode(s, validate=True)` (`base64`) or `urlsafe_b64decode(s + pad)` (`base64url`) → `bytes`; on failure they append a `Violation` and return `None` so the rest of the object still validates. Serialize: `_format_base64` / `_format_base64url` emit `b64encode(b)` / `urlsafe_b64encode(b).rstrip(b"=")` as ASCII. | | Java | The per-POJO collecting deserializer (PRINCIPLES Java §5): regex over the `String`, then `Base64.getDecoder()` / `getUrlDecoder()` `.decode(s)` → `byte[]`, pushing a `Violation` on failure. The `Serializer` emits with `getEncoder()` / `getUrlEncoder().withoutPadding()`. | **Informative `reason` strings.** The `Violation` `reason` names the diff --git a/specs/json-schema/features/default.md b/specs/json-schema/features/default.md index acd45131..9a591960 100644 --- a/specs/json-schema/features/default.md +++ b/specs/json-schema/features/default.md @@ -7,8 +7,9 @@ Supplies a fallback value for an absent member. In the spec it is a pure **annotation** — it never affects validation pass/fail. We give it the **off-the-wire, materialized-on-read** operational semantics: set-ness tracked, omit-unset on serialize (no deep-equals), materialized **on read** via a -generated `OrDefault()` accessor in Go and native language -mechanisms elsewhere. +generated `OrDefault()` accessor in Go, a native getter in Java, and +a generated `DEFAULT_` constant the consumer applies in TypeScript +and Python. ## Spec summary @@ -45,7 +46,10 @@ The defining choices (citing [[PRINCIPLES.md]]): **never** written into the field on deserialize and **never** emitted on serialize. The generator tracks field *set-ness*; serialize omits any unset field with **no value comparison** (never a deep-equals - against the default). The default is surfaced lazily *on read*. + against the default). The default is surfaced lazily *on read*. All four + languages omit an unset defaulted key, so all four preserve wire + byte-identity (**P1**) — the wire beats ergonomics (**P2**), which is why + no target bakes the default into the field itself. - **P9 (absent ≠ zero / set)**: tracking set-ness (not value) is what preserves the absent-vs-explicitly-set distinction. Explicitly setting a field to a value *equal to* the default marks it set and **pins it @@ -61,10 +65,10 @@ The defining choices (citing [[PRINCIPLES.md]]): at load time, and a `null` default is rejected as degenerate (see Loader behavior). The blocker is purely the composite case: a literal object/array default would have to be materialized into a constructed - language value (a populated struct/`record`/`BaseModel`, or a typed + language value (a populated struct/`record`/dataclass, or a typed slice/`List`) on read and woven into the per-field omit-unset machinery — a meaningfully harder problem than emitting a scalar literal in - `OrDefault()` / `?? DEFAULT_X` / a Pydantic field default. **This + `OrDefault()` / a `DEFAULT_X` constant / a getter fallback. **This scope limit is provisional and expected to relax** once composite-value materialization is specified; it mirrors how [[const]] also defers composite values in v1. @@ -114,33 +118,39 @@ Loader behavior: **None of its own.** `default` does not change the emitted type — the type comes from [[type]] + [[nullability]], and `default` implies the member is **optional**, so it takes the optional form (`*T` / `x?: T` / -`Optional[T]` / boxed-or-`@Nullable`). What `default` *does* add is the +`T | None = None` / boxed-or-`@Nullable`). The default value never appears +in the field itself in any target. What `default` *does* add is the **read-side surfacing mechanism** and the generated default value itself, which differ per language: | Language | Set-ness signal (omit-unset) | Read-side surfacing of the default | |---|---|---| -| Python | `model_fields_set`, applied by a generated `@model_serializer` | **native** — the Pydantic field `default=` makes the attribute *read* as the default; the generated `@model_serializer(mode='wrap')` omits it on the way out by emitting only `model_fields_set` keys. Omission is baked into the model (the default Temporal converter owns the `to_json` call, so we can't pass `exclude_unset` ourselves). | +| Python | `None` (the `T \| None = None` field) | **advisory** — a dataclass carries no methods (PRINCIPLES Python §1), so the consumer applies the default with `x if x is not None else DEFAULT_X`; the generator emits a module-level `DEFAULT_X = "anon"`. No accessor needed. | | Java | `null` field + `@JsonInclude(NON_NULL)` | **native** — the generated **getter** returns the default when the backing field is `null` (`return nickname != null ? nickname : "anon";`). Getters already exist in the POJO design (PRINCIPLES Java §1). | | TypeScript | `undefined` (the `?` field) | **advisory** — interfaces have no methods (PRINCIPLES TS §2), so the consumer applies the default with the native `?? DEFAULT_X`; the generator emits `export const DEFAULT_X = "anon"`. No accessor needed; `??` is the idiom. | | Go | `*T` `nil` + `,omitempty` | **generated accessor** — a `func (m M) OrDefault() T` returns `*m.Field` when set and the default literal when `nil` (`func (u User) NicknameOrDefault() string { if u.Nickname != nil { return *u.Nickname }; return "anon" }`). The bare field stays `*T` (set-ness intact); the accessor is the materialize-on-read path. Emitted **only** for default-bearing fields. Modeled on proto3's `GetX()` — the same omit-default-on-wire + accessor-materializes-default pattern already familiar to Temporal users. Named `OrDefault` rather than `Get` to read as "the value, or its default" and to avoid implying a getter on every field. Alternative approaches considered: (a) advisory constant (`DEFAULT_X` + caller nil-checks) — pushes nil-checks to every call site; (b) populate on deserialize — destroys set-ness, forces deep-equals, breaks P9. | ### Naming and collisions (P15) -The read-side surfacing synthesizes **one new identifier in two targets** +The read-side surfacing synthesizes **one new identifier in three targets** — names absent from the schema, so they can collide: | Target | Synthesized identifier | Scope | Collision risk | |---|---|---|---| | Go | `OrDefault()` method | struct method-set | a **declared** member whose name maps to `OrDefault` (Go forbids a field and method of the same name — a **hard compile error**); another `OrDefault` from a sibling field | | TypeScript | `DEFAULT_` const | module | another `DEFAULT_` from a field that case-maps the same. [[const]] synthesizes no named *type* in TS (the type closes to an inline literal) but does emit a module-scope `_CONST` binding holding the wire value, which shares this scope — unexported, yet still a redeclaration error if it coincides | -| Python | none (native Pydantic field `default=`) | — | — | +| Python | `DEFAULT_` const | module | another `DEFAULT_` from a field that case-maps the same ([[const]] synthesizes no Python identifier — the value is an inline `Literal`) | | Java | none (default folds into the existing getter) | — | — | +The constant is named `DEFAULT_`, or **`DEFAULT__`** +when that field name is not unique across the module's models — the same +qualification rule in TypeScript and Python, since both put the constant in +module scope. A collision that survives qualification rejects. + Per **P15** these participate in the single per-scope collision pass and **reject at load** on any coincidence — never auto-mangled (a `NicknameOrDefault2` would renumber under schema evolution, a P13 break). -Python and Java add no name, so they carry no default-specific collision. +Java adds no name, so it carries no default-specific collision. The rename **escape hatch** is the [[properties]] case-mapping override (`x-go-name`, …) on the *declaring* field — re-mapping it moves the synthesized `OrDefault` / `DEFAULT_` names with it, because @@ -153,13 +163,14 @@ would reject with a fix-it the author cannot act on — the only remaining escape being a rename of the JSON property, i.e. a change to the wire contract (P15, P7.1). -Python and Java materialize-on-read for free (attribute default / getter); -Go does so via the generated `OrDefault()` accessor. TypeScript has -no method (interfaces, PRINCIPLES TS §2), so it leans on the native `??` + -a generated constant — an idiomatic stand-in for the same thing. In every -language the **bare field still carries set-ness** (`nil` / `undefined` / -out-of-`model_fields_set` / `null`); the default is layered on read, never -written back into the field, so omit-on-serialize stays faithful. +Java materializes-on-read for free (the getter); Go does so via the +generated `OrDefault()` accessor. TypeScript and Python have no +method on the model (interfaces, PRINCIPLES TS §2; inert dataclasses, +PRINCIPLES Python §1), so both lean on a generated constant the consumer +applies — an idiomatic stand-in for the same thing. In every language the +**bare field still carries set-ness** (`nil` / `undefined` / `None` / +`null`); the default is layered on read, never written back into the field, +so omit-on-serialize stays faithful. ## Validator mapping @@ -168,12 +179,11 @@ never appears in the shared `Validate` and never causes a runtime pass/fail. Its operational behavior is entirely in the **adapters**: - **Parse adapter (deserialize-only):** when the member is absent on the - wire, leave the set-ness signal "unset" (nil / `undefined` / not in - `model_fields_set` / `null`). Do **not** write the default into the - field. Required-presence and constraint checks are unaffected (a client - sending fewer keys is judged on the wire, before any default — this is - why [[minProperties]]/[[maxProperties]] count *before* default - population). + wire, leave the set-ness signal "unset" (nil / `undefined` / `None` / + `null`). Do **not** write the default into the field. Required-presence + and constraint checks are unaffected (a client sending fewer keys is + judged on the wire, before any default — this is why + [[minProperties]]/[[maxProperties]] count *before* default population). - **Encode adapter (serialize-only), P12:** omit any unset member — declaratively, via the per-language set-ness signal above — with **no deep-equals**. An explicitly-set member (even to the default value) @@ -184,13 +194,13 @@ pass/fail. Its operational behavior is entirely in the **adapters**: The whole point of `default` lives here. The encode adapter omits unset members so the wire stays minimal and the round-trip is faithful: a value that arrived absent leaves absent, never echoed back as a materialized -default. Mechanisms (all empirically verified): +default. Mechanisms: | Language | Omit-unset mechanism | |---|---| | Go | `*T` with `,omitempty` → `nil` omitted by the stdlib encoder via the type-alias `MarshalJSON`. Pointer-to-zero-value still emits, so set-ness ≡ pointer-presence. | | TypeScript | `toTransferType` skips keys whose value is `undefined` when building the transfer value (PRINCIPLES TS §4). | -| Python | generated `@model_serializer(mode='wrap')` emits only `model_fields_set` keys — omits unset while the attribute still reads the default; explicit-set (incl. set-to-default) pins. No deep-equals. Baked into the model so the **default Temporal `pydantic_data_converter`** (which owns `to_json`, not us) honors it. | +| Python | `to_transfer_type` skips a key whose attribute is `None` when building the intermediate dict, exactly as it does for any other optional member. | | Java | `@JsonInclude(NON_NULL)` — `null` (unset) omitted; getter still returns the default to the consumer. | Three consequences that the count specs already encode: @@ -225,7 +235,7 @@ Three consequences that the count specs already encode: | **Array default (deferred)** | `{type:"array", items:{type:"string"}, default:["a"]}` | | `default: null` (degenerate) | `{oneOf:[{type:"string"},{type:"null"}], default:null}` | | With `const` | `{type:"string", const:"v1", default:"v1"}` | -| Synthesized-name collision (P15) | a field `nickname` with a `default` **and** a sibling member mapping to `NicknameOrDefault` (Go field/method clash); two `DEFAULT_` consts that case-map the same (TS) | +| Synthesized-name collision (P15) | a field `nickname` with a `default` **and** a sibling member mapping to `NicknameOrDefault` (Go field/method clash); two `DEFAULT_` consts that case-map the same after qualification (TS / Python) | ### Runtime fixtures (validator / adapters) @@ -248,10 +258,10 @@ Three consequences that the count specs already encode: omit-unsets. See [[const]]. - **[[nullability]]**: composable. For an optional+nullable member with a default, **absence** materializes the default on read while an - **explicit `null`** pins `null` (faithful in TS/Python via the - presence signal; Go/Java collapse absent-vs-`null` to the conservative - omit — see [[nullability]] round-trip tiers). The default applies to - *absence*, never overriding an explicit `null`. + **explicit `null`** pins `null` (faithful in TS via the presence signal; + Go, Java and Python collapse absent-vs-`null` — see [[nullability]] + round-trip tiers). The default applies to *absence*, never overriding an + explicit `null`. - **[[minProperties]] / [[maxProperties]]**: a default-filled key is never on the wire, so the count (taken before default population on the way in, over to-be-emitted keys on the way out) excludes it. Already diff --git a/specs/json-schema/features/dependentRequired.md b/specs/json-schema/features/dependentRequired.md index 73b2a761..c17de6a3 100644 --- a/specs/json-schema/features/dependentRequired.md +++ b/specs/json-schema/features/dependentRequired.md @@ -77,7 +77,7 @@ dependent is also present. |---|---| | Go | The cross-field check is a predicate in the shared `Validate`, which `UnmarshalJSON` calls after decoding the shadow: for each present trigger, each dependent's shadow must be non-`nil`; a missing one → `Violation{Path:dependent, Reason: fmt.Sprintf("property %q is required when %q is present", dependent, trigger)}`, collected into one `ValidationError`. | | TypeScript | the shared `Validate` predicate: for each present trigger key, each dependent must be `!== undefined`; a missing one → push ``Violation{path, reason: `property "${dependent}" is required when "${trigger}" is present`}``, throw one `ValidationError`. | -| Python | `model_validator(mode='wrap')` reading the raw dict: for each present trigger, raise `InitErrorDetails` (message `property "" is required when "" is present`) for each absent dependent, merged into the aggregated `pydantic.ValidationError`. Dependency map stored as a `ClassVar` constant (per the `ClassVar` pattern in [[nullability]]). | +| Python | `from_transfer_type` reads the raw wire dict: for each present trigger, append `Violation(path=dependent, reason=f'property "{dependent}" is required when "{trigger}" is present')` per absent dependent, into the single generated `ValidationError`. The dependency map is a module-level private constant, alongside `__DECLARED`. | | Java | in the per-POJO collecting deserializer (PRINCIPLES Java §5): over the parsed tree's present-key set, for each present trigger push a `Violation{path:dependent, "property \"" + dependent + "\" is required when \"" + trigger + "\" is present"}` per missing dependent into the single `ValidationException`. | ### Serialize-side (P12) diff --git a/specs/json-schema/features/deprecated.md b/specs/json-schema/features/deprecated.md index 835055d0..79cde723 100644 --- a/specs/json-schema/features/deprecated.md +++ b/specs/json-schema/features/deprecated.md @@ -130,7 +130,7 @@ text): |---|---|---| | Go | a `// Deprecated: This is deprecated.` paragraph in the doc comment (godoc convention; generic reason — see below) | `gopls` / `staticcheck` SA1019 flag every use; `go doc` renders it. A doc-comment tag, not a keyword. | | TypeScript | a bare JSDoc `@deprecated` tag in the `/** … */` block | `tsc` and editors strike-through and warn at call sites. | -| Python | PEP 702 `@deprecated("…", category=None)` (`typing_extensions` backport / `warnings.deprecated`) on the **type / service / operation**; for a **field**, `Annotated[T, deprecated("…", category=None)]` | static type checkers flag every use; **`category=None` suppresses the access-time `DeprecationWarning`**, so there is no runtime side-effect (parity with the other three). We do *not* emit Pydantic's runtime `Field(deprecated=True)`. | +| Python | PEP 702 `@deprecated("…", category=None)` (`typing_extensions` backport / `warnings.deprecated`) on the **type / service / operation**; for a **field**, `Annotated[T, deprecated("…", category=None)]` | static type checkers flag every use; **`category=None` suppresses the access-time `DeprecationWarning`**, so there is no runtime side-effect (parity with the other three). | | Java | the `@Deprecated` annotation on the type / getter / method, paired with a Javadoc `@deprecated` tag | `javac` warns at every use; IDEs strike-through. | No new identifier is ever synthesized, so `deprecated` has **no P15 @@ -183,9 +183,9 @@ and re-serializes identically to a non-deprecated one. There is **no runtime side-effect in any target** — every marker is a compile-/lint-time signal only. Python is deliberately held to this bar: its PEP 702 marker is emitted with `category=None`, which suppresses the -access-time `DeprecationWarning` (see Type mapping), and we do not emit -Pydantic's runtime `Field(deprecated=True)`. This keeps the four targets -in parity (P1) — deprecation is purely a generation-time annotation. +access-time `DeprecationWarning` (see Type mapping). This keeps the four +targets in parity (P1) — deprecation is purely a generation-time +annotation. ## Property-testing matrix diff --git a/specs/json-schema/features/description.md b/specs/json-schema/features/description.md index 1cbfd55c..42c38dba 100644 --- a/specs/json-schema/features/description.md +++ b/specs/json-schema/features/description.md @@ -8,8 +8,8 @@ documentation for a generated type, member, service, or operation. In the spec it is a **pure annotation** — it never affects validation, and it never affects the emitted *type* or any *identifier*. Its single operational role is to become the **body of the generated doc comment** -(a Go `//` block, a TS JSDoc, a Python docstring / Pydantic -`Field(description=…)`, a Java Javadoc). Because it is the primary doc +(a Go `//` block, a TS JSDoc, a Python class or attribute docstring, a +Java Javadoc). Because it is the primary doc source, this spec **owns the shared doc-comment machinery** — assembly order, line-wrapping, and per-language escaping — that its sibling [[title]] defers to it. @@ -107,7 +107,7 @@ Per-language block and placement: |---|---| | Go | `// ` line-comment block above the `type`/field/method, **name-led first line** (see below). | | TypeScript | `/** … */` JSDoc above the `interface`/field. | -| Python | class **docstring** for a type/service; for a **field**, the native Pydantic `Field(description="…")` argument. | +| Python | class **docstring** for a type/service; for a **field**, an **attribute docstring** — a bare string literal on the line(s) immediately after the field declaration, which every Python documentation tool and editor picks up. | | Java | `/** … */` Javadoc above the class/getter/method. | No new identifier is ever synthesized, so `description` has **no P15 diff --git a/specs/json-schema/features/enum.md b/specs/json-schema/features/enum.md index 55b1b13c..e636c097 100644 --- a/specs/json-schema/features/enum.md +++ b/specs/json-schema/features/enum.md @@ -151,8 +151,8 @@ wire value against the same set. **Python.** The **closed literal set** via `Literal` — `Literal["red","green","blue"]`. **`float` members are the exception:** `Literal` forbids float members (PEP 586), so a number enum is plain -`float` and closedness rests on the `model_validator` alone (as in -[[const]]). +`float` and closedness rests on the converter's membership check alone (as +in [[const]]). **Java.** A generated **value class** wrapping the primitive, carrying one known constant per member — a private constructor, a membership `switch`, @@ -284,12 +284,14 @@ identical in both directions (a pure predicate over the decoded value — the |---|---| | Go | A predicate in the shared `Validate`, called by `UnmarshalJSON` after decoding: `switch v { case ColorRed, ColorGreen, ColorBlue: default: … Violation{Path, Reason: fmt.Sprintf("must be one of [%s], got %q", set, v)} }`, collected into one `ValidationError`. The field is the defined type; the typed constants are both the compared set and the idiomatic setters. | | TypeScript | the shared `Validate` predicate tests set membership: ``if (!SET.has(v)) push(Violation{path, reason: `must be one of [...], got ${JSON.stringify(v)}`})``, throwing one `ValidationError`. The field's union type closes it in-language. | -| Python | a field/`model_validator` testing `v in SET`, raising `InitErrorDetails` into the aggregated `pydantic.ValidationError`. The field is the closed `Literal` (`float` enums are plain `float`, validated the same way). | +| Python | the transfer type converter (PRINCIPLES Python §3) tests `v not in SET` — a module-level `frozenset` — and appends `Violation(path=…, reason='must be one of [...], got ')` into the single generated `ValidationError`. The field is the closed `Literal` (`float` enums are plain `float`, validated the same way). | | Java | the aggregating path is the per-POJO collecting deserializer (PRINCIPLES Java §5): a **non-throwing membership lookup** — known value → the constant, otherwise record a `Violation{path, "must be one of [...], got …"}` — so multiple bad fields collect into the single `ValidationException`. The value class's `@JsonCreator fromString` *throws* only on the **standalone/interop** path, where fail-fast is expected. Serialize needs no separate check: the value class can only hold a known constant. | The reason string names the **expected set and the offending value** (`must be one of ["red","green","blue"], got "purple"`), never a bare -keyword. +keyword. Go renders the set with no space after the comma; TypeScript and +Python render `["red", "green", "blue"]` — a divergence in the set's +rendering only, never in the accepted value set. ### Serialize-side (P12) @@ -311,8 +313,9 @@ required enum is a [[required]] violation, and Python has no auto-fill step (for enum the generator could not pick which member to fill in any case). The serialize membership check has teeth wherever an out-of-set value can be set in memory before emit: an optional+enum mutated to a wrong value, a Go -zero-value/mutated field (`Color("")` is not a member), or a Python -`model_construct` bypass. In TS and Java the value cannot be out of set in +zero-value/mutated field (`Color("")` is not a member), or any Python +in-memory assignment (a dataclass validates nothing on construction, +PRINCIPLES Python §1). In TS and Java the value cannot be out of set in memory (union / value class), so the check is effectively a deserialize-direction guard there. diff --git a/specs/json-schema/features/examples.md b/specs/json-schema/features/examples.md index 520a77ee..ac1e3338 100644 --- a/specs/json-schema/features/examples.md +++ b/specs/json-schema/features/examples.md @@ -135,8 +135,8 @@ blocks generation and produces no output while ignored. 1. **Doc-comment rendering (the deferred design).** When supported, `examples` renders into the generated doc comment via [[description]]'s machinery, using each language's native slot where one exists — JSDoc - `@example` (TS), Pydantic `Field(examples=[...])` for a field / an - `Examples:` docstring section for a type (Python), a rendered + `@example` (TS), an `Examples:` section in the attribute docstring for a + field / in the class docstring for a type (Python), a rendered `Example:` line (Go godoc, Java Javadoc `{@code …}`). Each array value is serialized to a canonical JSON literal; multiple values → multiple tags/lines; merged occurrences flatten per the spec's flat-array rule. diff --git a/specs/json-schema/features/exclusiveMaximum.md b/specs/json-schema/features/exclusiveMaximum.md index 81c440f1..4069d198 100644 --- a/specs/json-schema/features/exclusiveMaximum.md +++ b/specs/json-schema/features/exclusiveMaximum.md @@ -69,7 +69,7 @@ comparison changed to `≥` as the failing test (`v ≥ exclusiveMaximum` → a |---|---| | Go | The `if v >= exclMax { push(Violation{Reason: fmt.Sprintf("must be < %v, got %v", exclMax, v)}) }` predicate lives in the shared `Validate`, which the generated `UnmarshalJSON` calls after decoding; violations collect into one `ValidationError`. | | TypeScript | ``if (v >= exclMax) push(Violation{path, reason: `must be < ${exclMax}, got ${v}`})``. | -| Python | Pydantic `Lt(exclMax)` (`annotated_types`), composing over `SpecInt` on integer fields (see [[type]] / `pyd_numeric_probe.py`); its message names the bound (`Input should be less than 10`). | +| Python | `if v >= exclMax: violations.append(Violation(path=…, reason=f"must be < {exclMax}, got {v}"))` in the transfer type converter, after `_parse_spec_integer` normalizes an integer field's wire value (see [[type]]). | | Java | Collecting deserializer (PRINCIPLES Java §5) checks `v >= exclMax` via the [[type]] `SpecNumbers` helper, pushing a `Violation{path, "must be < " + exclMax + ", got " + v}` into the `ValidationException`. | Reason strings name the bound and offending value (`must be < 10, got 10`), diff --git a/specs/json-schema/features/exclusiveMinimum.md b/specs/json-schema/features/exclusiveMinimum.md index 2929fc75..6a8b99fd 100644 --- a/specs/json-schema/features/exclusiveMinimum.md +++ b/specs/json-schema/features/exclusiveMinimum.md @@ -62,7 +62,7 @@ failing test (`v ≤ exclusiveMinimum` → a `Violation` reading |---|---| | Go | `if v <= exclMin { push(Violation{Reason: fmt.Sprintf("must be > %v, got %v", exclMin, v)}) }` — a predicate in the shared `Validate`, which `UnmarshalJSON` calls after decoding, collecting into one `ValidationError`. | | TypeScript | ``if (v <= exclMin) push(Violation{path, reason: `must be > ${exclMin}, got ${v}`})``. | -| Python | Pydantic `Gt(exclMin)` (`annotated_types`), composing over `SpecInt` on integer fields (see [[type]] / `pyd_numeric_probe.py`); its message names the bound (`Input should be greater than 0`). | +| Python | `if v <= exclMin: violations.append(Violation(path=…, reason=f"must be > {exclMin}, got {v}"))` in the transfer type converter, after `_parse_spec_integer` normalizes an integer field's wire value (see [[type]]). | | Java | Collecting deserializer (PRINCIPLES Java §5) checks `v <= exclMin` via the `SpecNumbers` helper, pushing a `Violation{path, "must be > " + exclMin + ", got " + v}` into the `ValidationException`. | Reason strings name the bound and offending value (`must be > 0, got 0`), diff --git a/specs/json-schema/features/format.md b/specs/json-schema/features/format.md index dbed4e41..114e6392 100644 --- a/specs/json-schema/features/format.md +++ b/specs/json-schema/features/format.md @@ -381,7 +381,7 @@ add back the `|60` seconds alternative; `duration` uses the full |---|---| | Go | Parse adapter: run the pinned regex + `validRFC3339(...)` over the wire string, pushing a `Violation` on failure; else `t, _ := time.Parse(time.RFC3339, strings.ToUpper(s))` → store `t` **as parsed** (offset and nanoseconds retained; no `UTC()`, no truncation) for `date-time`, or `time.Parse("2006-01-02", s)` (`date`); `duration` parses the `PT…` components into a `time.Duration`. Encode adapter: `t.Format(time.RFC3339Nano)` (offset preserved, `Z` for zero offset, trailing-zero fractional trimmed). `regexp.MustCompile` compiled once at init. | | TypeScript | Parse adapter: pinned regex (`/…/u`) + calendar/range check, then per `--date-time-types`: **`string`** (default) store the generator-serialized string for every temporal; **`date`** `new Date(s)` for `date-time` (others string); **`temporal`** `Temporal.ZonedDateTime.from` (`date-time`, in the wire's offset zone) / `PlainDate.from` (`date`) / `Duration.from` (`duration`), with `time` staying a string. Encode adapter: **`string`** emit the stored string; **`date`** `date-time` → `.toISOString()` (UTC, ms, 3 digits); **`temporal`** the value's `.toString()` — `ZonedDateTime.toString({ timeZoneName: 'never' })` (offset kept, then `+00:00`→`Z`), `PlainDate` / `Duration` exact. | -| Python | Parse adapter (an `AfterValidator` / model hook): regex + calendar over the wire string, then `datetime.fromisoformat(s.upper())` **retaining the parsed offset** (`date-time`; `datetime`'s native microsecond resolution truncates any finer input — the one Python-side loss), `date.fromisoformat(s)` (`date`), `time.fromisoformat(s)` **retaining any offset** as an aware `time` (`time`), or parse `PT…` into a `timedelta` (`duration`). Encode: generator-owned string via `@model_serializer` (offset preserved, fractional trimmed). We do **not** use Pydantic's native `datetime` coercion (it accepts a missing offset and normalizes differently). | +| Python | Parse: the `_parse_date_time` / `_parse_date` / `_parse_time` / `_parse_duration` runtime helpers, called from the transfer type converter (PRINCIPLES Python §3) — regex + calendar over the wire string, then `datetime.fromisoformat(s.upper())` **retaining the parsed offset** (`date-time`; `datetime`'s native microsecond resolution truncates any finer input — the one Python-side loss), `date.fromisoformat(s)` (`date`), `time.fromisoformat(s)` **retaining any offset** as an aware `time` (`time`), or parse `PT…` into a `timedelta` (`duration`). Each appends a `Violation` and returns `None` on failure so the rest of the object still validates. Encode: the matching generator-owned `_format_*` helper (offset preserved, fractional trimmed). The dataclass field is the plain `datetime.datetime` / `date` / `time` / `timedelta`, so no library's own coercion is in the path (native coercions typically accept a missing offset and normalize differently). | | Java | The per-POJO collecting deserializer (PRINCIPLES Java §5): regex + calendar over the `String`, then `OffsetDateTime.parse(s)` **retaining the offset and nanoseconds** (no `atOffset(UTC)`, no `truncatedTo`) (`date-time`), `LocalDate.parse` (`date`), `OffsetTime.parse` **retaining the offset** (or `LocalTime.parse` when the wire omits it) (`time`), or `Duration.parse` for the `PT…` form. The `Serializer` emits the **generator-owned** string (offset preserved, fractional trimmed) — **not** `Duration.toString()` for `.NET` parity and **not** the BCL serializer (`.NET XmlConvert` rolls `PT24H`→`P1D`). | There is **no truncation floor**: Go and Java retain nanoseconds, Python its diff --git a/specs/json-schema/features/items.md b/specs/json-schema/features/items.md index e10d5176..75500558 100644 --- a/specs/json-schema/features/items.md +++ b/specs/json-schema/features/items.md @@ -101,7 +101,7 @@ Notes: - **Element nullability is the element's own concern.** An element schema that is the recognized [[nullability]] `oneOf` pattern makes the *elements* nullable — `[]*T` (Go), `(T | null)[]` (TS), - `list[Optional[T]]` (Python), `List<@Nullable T>` (Java) — distinct + `list[T | None]` (Python), `List<@Nullable T>` (Java) — distinct from the array field itself being optional/nullable, which wraps the whole collection. The two axes compose (an optional array of nullable elements is legal), and neither implies the other: an optional array of @@ -128,14 +128,14 @@ Notes: Per **P10** the array type and every element are validated at the (de)serializer boundary; per **P11** element failures aggregate. `items` contributes the per-element dispatch; the outer array-type check -(`Array.isArray` / typed slice / Pydantic `list` / typed `List` binding) -comes from [[type]]'s `"array"` row. +(`Array.isArray` / typed slice / `isinstance(v, list)` / typed `List` +binding) comes from [[type]]'s `"array"` row. | Language | Strategy | |---|---| | Go | Custom `UnmarshalJSON` decodes the field into a shadow `[]*json.RawMessage`, then dispatches each element through `T`'s runtime helper, collecting `Violation{Path, Reason}` into the one `ValidationError`. `Path` threads the index: `tags[2]`. | | TypeScript | Hand-emitted `Array.isArray` guard, then a per-element loop running `T`'s checks; push `Violation { path: "tags[2]", reason }` per bad element into the list, throw one `ValidationError`. | -| Python | Pydantic `list[T]` in strict mode; per-element validation is native and aggregates via `pydantic.ValidationError.errors()` (`loc` carries the element index). | +| Python | The transfer type converter (PRINCIPLES Python §3) guards `isinstance(v, list)`, then loops the raw elements through `T`'s parse helper / converter, appending `Violation(path="tags[2]", reason=…)` per bad element and raising one generated `ValidationError`. The TypeScript parallel. | | Java | the per-POJO collecting deserializer (PRINCIPLES Java §5) reads the array node, walks each element through `T`'s spec-strict/constraint helper, and collects `Violation{path:"tags[2]", reason}` into the one `ValidationException`. The Go parallel. | - **Path convention.** Element failures use bracketed indices appended to @@ -156,7 +156,7 @@ comes from [[type]]'s `"array"` row. `items` is symmetric across directions: serialize recurses the shared `Validate` into each element (a nested aggregate element runs its own -`MarshalJSON`/`toTransferType`/`model_dump`; a scalar element re-runs the +`MarshalJSON`/`toTransferType`/`to_transfer_type`; a scalar element re-runs the same predicate the deserializer used) **before emitting a byte**, failing with the same aggregated primitive (**P11**), and re-emits elements in order (arrays are ordered — unlike object members, element order is part diff --git a/specs/json-schema/features/maxContains.md b/specs/json-schema/features/maxContains.md index 35e7006c..5f48d643 100644 --- a/specs/json-schema/features/maxContains.md +++ b/specs/json-schema/features/maxContains.md @@ -97,7 +97,7 @@ compares. `matchCount` reuses the [[contains]] matcher predicate |---|---| | Go | A predicate in the shared `Validate`, called by `UnmarshalJSON` after decoding into the `[]T`: `n := 0; for _, e := range v { if matchesContains(e) { n++ } }; if n > max { push(Violation{Path, Reason: fmt.Sprintf("too many matching items: at most %d, got %d", max, n)}) }`, collected into one `ValidationError`. | | TypeScript | After the `Array.isArray` guard ([[items]]), the shared `Validate` counts: ``const n = v.filter(matchesContains).length; if (n > max) push(Violation{path, reason: `too many matching items: at most ${max}, got ${n}`})``, throwing one `ValidationError`. `max` is an emitted numeric constant. | -| Python | A `model_validator` over the decoded `list[T]` (Pydantic v2 has no native `maxContains`): `n = sum(1 for e in v if _matches_contains(e)); if n > max: raise InitErrorDetails(...)`, aggregated into `pydantic.ValidationError`. | +| Python | `_check_contains` in the transfer type converter tallies `n = sum(1 for e in v if _matches_contains(e))` and on `n > max` appends `Violation(path=…, reason=f"too many matching items: at most {max}, got {n}")` into the single generated `ValidationError`. | | Java | The per-POJO collecting deserializer (PRINCIPLES Java §5) reads the `List`, tallies matches against the matcher predicate, and on `n > max` pushes a `Violation{path, "too many matching items: at most " + max + ", got " + n}` into the single `ValidationException`. Not bean-validation. | **Informative `reason` strings.** The `reason` names the **concrete bound diff --git a/specs/json-schema/features/maxItems.md b/specs/json-schema/features/maxItems.md index eeea905c..cfff0fa5 100644 --- a/specs/json-schema/features/maxItems.md +++ b/specs/json-schema/features/maxItems.md @@ -76,7 +76,7 @@ primitive. |---|---| | Go | The comparison is a predicate in the shared `Validate(model)` (`if n := len(v); n > max { push(Violation{Path, Reason: fmt.Sprintf("too many items: at most %d, got %d", max, n)}) }`), which the generated `UnmarshalJSON` calls after decoding into the `[]T`; violations collect into one `ValidationError`. | | TypeScript | After the `Array.isArray` guard ([[items]]), the shared `Validate`'s `v.length > max` check pushes ``Violation{path, reason: `too many items: at most ${max}, got ${v.length}`}``, throw one `ValidationError`. `max` is an emitted numeric constant. | -| Python | Pydantic `Annotated[list[T], Field(max_length=max)]` — for sequences `max_length` bounds the element count; aggregates in `pydantic.ValidationError`, whose message names the limit (`List should have at most 3 items`). | +| Python | After the `isinstance(v, list)` guard ([[items]]), `if (n := len(v)) > max: violations.append(Violation(path=…, reason=f"too many items: at most {max}, got {n}"))` in the transfer type converter (PRINCIPLES Python §3), aggregated into the single generated `ValidationError`. | | Java | The per-POJO collecting deserializer (PRINCIPLES Java §5) reads the array node into the `List`, then checks `int n = v.size(); if (n > max)`, pushing a `Violation{path, "too many items: at most " + max + ", got " + n}` into the single `ValidationException`. Not bean-validation `@Size` — hand-written like every other constraint. | **Informative `reason` strings.** The `Violation` `reason` names the diff --git a/specs/json-schema/features/maxLength.md b/specs/json-schema/features/maxLength.md index 0685f950..578d7b8c 100644 --- a/specs/json-schema/features/maxLength.md +++ b/specs/json-schema/features/maxLength.md @@ -103,13 +103,13 @@ the whole string (see the TS row). | Language | Strategy | |---|---| -| Go | The comparison is a predicate in the shared `Validate(model)` (`if n := utf8.RuneCountInString(v); n > max { push(Violation{Path, Reason: fmt.Sprintf("length must be <= %d, got %d", max, n)}) }`), which the generated `UnmarshalJSON` calls after decoding; violations collect into one `ValidationError`. **`utf8.RuneCountInString`, not `len`** — `len` is the UTF-8 byte count (verified `len("a😀b") == 6`). | -| TypeScript | An **allocation-free** surrogate-aware pass that **early-exits** the moment the bound is crossed: walk `v` by UTF-16 unit counting code points (each well-formed high+low surrogate pair is one code point), and stop as soon as the running count exceeds `max`. On the (rare) failure path compute the exact count with the shared `codePointLength(v)` helper for the message: ``push(Violation{path, reason: `length must be <= ${max}, got ${codePointLength(v)}`})``; throw one `ValidationError`. **Never `v.length`** — that is the UTF-16 code-unit count (verified `"a😀b".length === 4`). `max` is an emitted numeric constant. This beats the obvious `[...v].length` (which allocates a full code-point array) ~3.5×, and early-exit bounds work on adversarially long input regardless of `max`. | -| Python | Pydantic `Annotated[str, Field(max_length=max)]` (equivalently `StringConstraints(max_length=max)`). **Verified to count code points** (pydantic 2.13.4): a single astral emoji — 1 code point but 4 UTF-8 bytes / 2 UTF-16 units — passes `max_length=1`, and two emoji (2 code points) fail. So it is spec-correct without a custom validator, matching the other three. Aggregates in `pydantic.ValidationError`, whose message names the limit (`String should have at most 2 characters`). | -| Java | The per-POJO collecting deserializer (PRINCIPLES Java §5) reads the field node as a `String`, then checks `int n = v.codePointCount(0, v.length()); if (n > max)`, pushing a `Violation{path, "length must be <= " + max + ", got " + n}` into the single `ValidationException`. **`codePointCount(0, length())`, not `length()`** — `length()` is the UTF-16 code-unit count (verified `"a😀b".length() == 4`). Not bean-validation `@Size` — the check is hand-written in the collecting deserializer like every other constraint. | +| Go | The comparison is a predicate in the shared `Validate(model)` (`if n := utf8.RuneCountInString(v); n > max { push(Violation{Path, Reason: fmt.Sprintf("must have length <= %d, got %d", max, n)}) }`), which the generated `UnmarshalJSON` calls after decoding; violations collect into one `ValidationError`. **`utf8.RuneCountInString`, not `len`** — `len` is the UTF-8 byte count (verified `len("a😀b") == 6`). | +| TypeScript | An **allocation-free** surrogate-aware pass that **early-exits** the moment the bound is crossed: walk `v` by UTF-16 unit counting code points (each well-formed high+low surrogate pair is one code point), and stop as soon as the running count exceeds `max`. On the (rare) failure path compute the exact count with the shared `codePointLength(v)` helper for the message: ``push(Violation{path, reason: `must have length <= ${max}, got ${codePointLength(v)}`})``; throw one `ValidationError`. **Never `v.length`** — that is the UTF-16 code-unit count (verified `"a😀b".length === 4`). `max` is an emitted numeric constant. This beats the obvious `[...v].length` (which allocates a full code-point array) ~3.5×, and early-exit bounds work on adversarially long input regardless of `max`. | +| Python | `if (n := len(v)) > max: violations.append(Violation(path=…, reason=f"must have length <= {max}, got {n}"))` in the transfer type converter (PRINCIPLES Python §3). **`len` on a `str` is the code-point count** — a single astral emoji is 1 code point but 4 UTF-8 bytes / 2 UTF-16 units (`len("a😀b") == 3`) — so it is spec-correct with no extra scan, matching the other three. Aggregates into the single generated `ValidationError`. | +| Java | The per-POJO collecting deserializer (PRINCIPLES Java §5) reads the field node as a `String`, then checks `int n = v.codePointCount(0, v.length()); if (n > max)`, pushing a `Violation{path, "must have length <= " + max + ", got " + n}` into the single `ValidationException`. **`codePointCount(0, length())`, not `length()`** — `length()` is the UTF-16 code-unit count (verified `"a😀b".length() == 4`). Not bean-validation `@Size` — the check is hand-written in the collecting deserializer like every other constraint. | **Informative `reason` strings.** The `Violation` `reason` names the -**concrete bound and the offending count** — `length must be <= 2, got 3` +**concrete bound and the offending count** — `must have length <= 2, got 3` — per the [[maximum]] convention, so the aggregated error tells the caller which limit was crossed and by how much. The bound is an emitted compile-time constant; the count is computed at runtime. @@ -162,7 +162,7 @@ is simply projected from the native value on the encode side. - `codePointCount(v) == max` → OK (`≤` is inclusive). - `v` one code point over `max` → one `ValidationError` whose reason names - the bound and count (`length must be <= 2, got 3`). + the bound and count (`must have length <= 2, got 3`). - **Astral / multi-byte fixtures (the P1 core):** `"a😀b"` counts as **3** in all four (not 6/4/4); `"😀😀"` counts as **2**; NFC `"é"` counts as **1** and NFD `"e"+U+0301` as **2** — every language agrees on each diff --git a/specs/json-schema/features/maxProperties.md b/specs/json-schema/features/maxProperties.md index efcff960..06ec908a 100644 --- a/specs/json-schema/features/maxProperties.md +++ b/specs/json-schema/features/maxProperties.md @@ -52,14 +52,14 @@ member keys present on the wire**, taken at the deserialize boundary **before** default population (see [[default]]) — a default-filled key is never on the wire and does not count (see Interactions). Count the wire object as a single number; do **not** sum a declared-fields bucket and an extras -bucket separately (case-mapping can route a key to either, and in Pydantic -the two sets overlap). +bucket separately (case-mapping can route a key to either, and the +declared-vs-extras split is a language-side artifact, not a wire fact). | Language | Strategy | |---|---| | Go | `UnmarshalJSON` counts decoded members (wire keys, pre-population) and hands the count to the shared `Validate`, whose `> max` predicate raises `Violation{Path:"", Reason: fmt.Sprintf("too many properties: at most %d, got %d", max, n)}`; collected into one `ValidationError`. | | TypeScript | count `Object.keys(parsed).length` on the raw parsed wire object (before defaults applied); the shared `Validate`'s `> max` check pushes ``Violation{path, reason: `too many properties: at most ${max}, got ${n}`}``, throw one `ValidationError`. | -| Python | `model_validator`; `len(model_fields_set) > max` — `model_fields_set` already includes extras and excludes default-filled fields, so it is the exact wire-key count; raise into the aggregated `ValidationError`. | +| Python | `from_transfer_type` counts `len(raw)` on the raw wire dict — one number over the wire object, taken before any default is materialized — and appends `Violation(path="", reason=f"too many properties: at most {max}, got {n}")` when `n > max`, into the single generated `ValidationError`. | | Java | the per-POJO collecting deserializer (PRINCIPLES Java §5) counts distinct keys in the parsed tree (`> max`) — one number over the wire object, **not** populated POJO fields + catch-all map summed post-bind; a violation joins the single `ValidationException`. | ### Serialize-side (P12) @@ -68,9 +68,10 @@ The count runs again before emit, over the keys that **will actually be written** — i.e. *after* default omission and the omit-vs-`null` decision (the serialize mirror of "before default population"). A field whose default is unset is omitted and does **not** count, exactly as it didn't -on the way in. `model_fields_set` (Python) is again the exact emitted-key -count under `exclude_unset`; Go/TS count the members the encoder will -emit; an over-cap model fails `MarshalJSON`/`toTransferType`/`model_dump` +on the way in. Each language counts the members its own encoder will +emit — in Python, `len(out)` on the dict `to_transfer_type` has built — +and an over-cap model fails +`MarshalJSON`/`toTransferType`/`to_transfer_type` rather than emitting an out-of-bounds object. Because the in-memory model can *read* a default as present that *serializes* as absent, a model can legitimately fail `maxProperties`/`minProperties` on serialize that diff --git a/specs/json-schema/features/maximum.md b/specs/json-schema/features/maximum.md index df0719ba..9de91020 100644 --- a/specs/json-schema/features/maximum.md +++ b/specs/json-schema/features/maximum.md @@ -45,11 +45,8 @@ Loader behavior: - **On an `integer` field the bound MUST be integer-valued.** `maximum:5.0` is accepted (≡ `5`, honoring the `1.0`-as-integer rule from [[type]]); `maximum:5.5` is **rejected** with a fix-it ("use an integer bound, or - make the field `number`"). Empirically Pydantic cannot even represent a - fractional `le` on an `int` field — `Field(le=5.5)` fails to build with - "'le' must be coercible to an integer" — and an integer bound lets - all four languages compare against one integer value with no - float/round ambiguity. + make the field `number`"). An integer bound lets all four languages + compare against one integer value with no float/round ambiguity. - On a `number` field any finite numeric bound is accepted. - A `maximum` larger than the [[type]] integer cap `+(2^53−1)` (or `minimum` below `−(2^53−1)`) on an `integer` field is **redundant** (the @@ -85,7 +82,7 @@ the **shared `Validate`** layer of **P12**). |---|---| | Go | The comparison is a predicate in the shared `Validate(model)` (`if v > max { push(Violation{Path, Reason: fmt.Sprintf("must be <= %v, got %v", max, v)}) }`), which the generated `UnmarshalJSON` calls after decoding; violations collect into one `ValidationError`. Integer field: compare the decoded `int64` to the integer bound directly (exact). Number field: compare `float64` to the `float64` bound. | | TypeScript | ``if (v > max) push(Violation{path, reason: `must be <= ${max}, got ${v}`})``, throw one `ValidationError`. `number` covers both `integer` and `number` fields; `max` is an emitted numeric constant. | -| Python | Pydantic constraint `Le(max)` (`annotated_types`) on the field. On an `integer` field it composes over the `SpecInt` `BeforeValidator` (see [[type]]): the wire value normalizes (`5.0`→`5`) **then** `Le` applies — verified in `pyd_numeric_probe.py`. Aggregates natively in `pydantic.ValidationError`, whose message **already names the bound** (`Input should be less than or equal to 10`), so no custom string is needed. | +| Python | An explicit comparison in the transfer type converter (PRINCIPLES Python §3): `if v > max: violations.append(Violation(path=…, reason=f"must be <= {max}, got {v}"))`, aggregated into the single generated `ValidationError`. On an `integer` field it runs **after** `_parse_spec_integer` has normalized the wire value (`5.0`→`5`, see [[type]]), so the comparison is against a Python `int`. `max` is an inlined numeric literal. | | Java | The per-POJO collecting deserializer (PRINCIPLES Java §5) reads the field node via the [[type]] `SpecNumbers` helper, then checks `v > max` (integer field: `long` vs `long`; number field: `double` vs `double`), pushing a `Violation{path, "must be <= " + max + ", got " + v}` into the single `ValidationException`. **Not** bean-validation `@Max` — the check is hand-written in the collecting deserializer like every other constraint. | **Informative `reason` strings.** The `Violation` `reason` is *not* a bare @@ -94,8 +91,8 @@ value** — `must be <= 10, got 15` — so the aggregated error tells the caller exactly which limit was crossed and by what. The bound is an emitted compile-time constant; the actual value is interpolated at runtime. This matches [[type]]'s descriptive style (`expected integer`, `exceeds cap`). -Pydantic's native `Le`/`Ge`/`Lt`/`Gt`/`MultipleOf` already produce this -form; Go/TS/Java hand-build the equivalent. The rest of the numeric family +All four targets hand-build the string from the emitted bound and the +runtime value. The rest of the numeric family ([[minimum]], [[exclusiveMaximum]], [[exclusiveMinimum]], [[multipleOf]]) follows the same convention with its own operator/word. @@ -107,7 +104,8 @@ comparison in `double`; this still agrees value-for-value because both the capped value and the bound lie within `±(2^53−1)`, which is exactly representable as a double — the probe confirms `(double)cap == cap` (and e.g. `(double)cap <= 5.5 == false`). Python normalizes the wire value to -`int` (`SpecInt`) before `Le`, so it too compares exactly. This is the same +`int` via `_parse_spec_integer` before comparing, so it too compares +exactly. This is the same cap guarantee the integer runtime helpers lean on in [[type]]. (This is *why* an integer-field bound is required to be integer-valued: it keeps even the mixed integer/float comparison exact and unambiguous.) @@ -179,9 +177,9 @@ not here. `min ≥ exclusiveMax`, or `exclusiveMin ≥ exclusiveMax`. - `integer` field: empty iff the interval contains **no integer** (e.g. `exclusiveMinimum:1, exclusiveMaximum:2` — nothing strictly between; - Pydantic *builds* it but no value passes, so we reject at load - instead). `minimum == maximum` on an integer is the one-value case and - is fine. + each bound is individually well-formed but no value passes, so we + reject at load instead). `minimum == maximum` on an integer is the + one-value case and is fine. - **[[multipleOf]]**: with a range present, if no multiple of the divisor lies in the accepted interval the schema is unsatisfiable → reject (detail in [[multipleOf]]). diff --git a/specs/json-schema/features/minContains.md b/specs/json-schema/features/minContains.md index 5ff3d487..ecd5f177 100644 --- a/specs/json-schema/features/minContains.md +++ b/specs/json-schema/features/minContains.md @@ -102,7 +102,7 @@ comparison; a `minContains ≥ 2` (like any [[maxContains]]) **cancels the |---|---| | Go | `n := 0; for _, e := range v { if matchesContains(e) { n++ } }; if n < min { push(Violation{Path, Reason: fmt.Sprintf("too few matching items: at least %d, got %d", min, n)}) }` — a predicate in the shared `Validate`, called by `UnmarshalJSON` after decoding, collected into one `ValidationError`. | | TypeScript | After the `Array.isArray` guard ([[items]]), ``const n = v.filter(matchesContains).length; if (n < min) push(Violation{path, reason: `too few matching items: at least ${min}, got ${n}`})``, throw one `ValidationError`. | -| Python | A `model_validator` over the decoded `list[T]`: `n = sum(1 for e in v if _matches_contains(e)); if n < min: raise InitErrorDetails(...)`, aggregated into `pydantic.ValidationError`. | +| Python | `_check_contains` in the transfer type converter tallies `n = sum(1 for e in v if _matches_contains(e))` and on `n < min` appends `Violation(path=…, reason=f"too few matching items: at least {min}, got {n}")` into the single generated `ValidationError`. | | Java | The per-POJO collecting deserializer (PRINCIPLES Java §5) tallies matches over the `List` and on `n < min` pushes a `Violation{path, "too few matching items: at least " + min + ", got " + n}` into the single `ValidationException`. Not bean-validation. | Reason strings name the concrete bound and offending match count diff --git a/specs/json-schema/features/minItems.md b/specs/json-schema/features/minItems.md index b4fb94d3..f37bf832 100644 --- a/specs/json-schema/features/minItems.md +++ b/specs/json-schema/features/minItems.md @@ -63,13 +63,13 @@ Per **P10**/**P11**. A single `≥` comparison of the **element count** against the fixed bound, identical in both directions (shared `Validate`, **P12**). Same per-language strategy as [[maxItems]] with `< min` as the failing comparison — the count is the decoded collection's native length -(`len(v)` / `v.length` / list `min_length` / `v.size()`). +(`len(v)` / `v.length` / `len(v)` / `v.size()`). | Language | Strategy | |---|---| | Go | `if n := len(v); n < min { push(Violation{Reason: fmt.Sprintf("too few items: at least %d, got %d", min, n)}) }` — a predicate in the shared `Validate`, which `UnmarshalJSON` calls after decoding, collecting into one `ValidationError`. | | TypeScript | After the `Array.isArray` guard ([[items]]), `v.length < min` pushes ``Violation{path, reason: `too few items: at least ${min}, got ${v.length}`}``, throw one `ValidationError`. | -| Python | Pydantic `Annotated[list[T], Field(min_length=min)]` — for sequences `min_length` bounds the element count; aggregates in `pydantic.ValidationError`, whose message names the bound (`List should have at least 2 items`). | +| Python | After the `isinstance(v, list)` guard ([[items]]), `if (n := len(v)) < min: violations.append(Violation(path=…, reason=f"too few items: at least {min}, got {n}"))` in the transfer type converter, aggregated into the single generated `ValidationError`. | | Java | The per-POJO collecting deserializer (PRINCIPLES Java §5) reads the `List`, checks `int n = v.size(); if (n < min)`, pushing a `Violation{path, "too few items: at least " + min + ", got " + n}` into the single `ValidationException`. Not bean-validation `@Size`. | Reason strings name the concrete bound and offending count diff --git a/specs/json-schema/features/minLength.md b/specs/json-schema/features/minLength.md index c60ae264..5eaebfec 100644 --- a/specs/json-schema/features/minLength.md +++ b/specs/json-schema/features/minLength.md @@ -76,13 +76,13 @@ early-exit, see [[maxLength]]), never the bare `len`/`.length`. | Language | Strategy | |---|---| -| Go | `if n := utf8.RuneCountInString(v); n < min { push(Violation{Reason: fmt.Sprintf("length must be >= %d, got %d", min, n)}) }` — a predicate in the shared `Validate`, which `UnmarshalJSON` calls after decoding, collecting into one `ValidationError`. | -| TypeScript | The shared `codePointLength` surrogate-aware scan (see [[maxLength]]) with **early-exit** the moment the running count reaches `min` (pass — no need to count the rest). If the string ends first the full count `n` is already in hand, so the failure path needs **no second pass** (the asymmetry with [[maxLength]], where the over-length case must recount): ``push(Violation{path, reason: `length must be >= ${min}, got ${n}`})``, throw one `ValidationError`. **Never `v.length`** (UTF-16 units). | -| Python | Pydantic `Annotated[str, Field(min_length=min)]` (`StringConstraints(min_length=min)`) — **verified to count code points** (see [[maxLength]] / `pydantic_length_probe.py`); aggregates in `pydantic.ValidationError`, whose message names the bound (`String should have at least 5 characters`). | -| Java | The per-POJO collecting deserializer (PRINCIPLES Java §5) reads the `String`, checks `int n = v.codePointCount(0, v.length()); if (n < min)`, pushing a `Violation{path, "length must be >= " + min + ", got " + n}` into the single `ValidationException`. Not bean-validation `@Size`. | +| Go | `if n := utf8.RuneCountInString(v); n < min { push(Violation{Reason: fmt.Sprintf("must have length >= %d, got %d", min, n)}) }` — a predicate in the shared `Validate`, which `UnmarshalJSON` calls after decoding, collecting into one `ValidationError`. | +| TypeScript | The shared `codePointLength` surrogate-aware scan (see [[maxLength]]) with **early-exit** the moment the running count reaches `min` (pass — no need to count the rest). If the string ends first the full count `n` is already in hand, so the failure path needs **no second pass** (the asymmetry with [[maxLength]], where the over-length case must recount): ``push(Violation{path, reason: `must have length >= ${min}, got ${n}`})``, throw one `ValidationError`. **Never `v.length`** (UTF-16 units). | +| Python | `if (n := len(v)) < min: violations.append(Violation(path=…, reason=f"must have length >= {min}, got {n}"))` in the transfer type converter — `len` on a `str` counts code points (see [[maxLength]]); aggregates into the single generated `ValidationError`. | +| Java | The per-POJO collecting deserializer (PRINCIPLES Java §5) reads the `String`, checks `int n = v.codePointCount(0, v.length()); if (n < min)`, pushing a `Violation{path, "must have length >= " + min + ", got " + n}` into the single `ValidationException`. Not bean-validation `@Size`. | Reason strings name the concrete bound and offending count -(`length must be >= 5, got 2`), per the [[maximum]] convention. +(`must have length >= 5, got 2`), per the [[maximum]] convention. ### Serialize-side (P12) @@ -116,7 +116,7 @@ than being written. See [[maxLength]] serialize note (symmetric). - `codePointCount(v) == min` → OK (`≥` inclusive). - `v` one code point under `min` → one `ValidationError` naming the bound - and count (`length must be >= 5, got 4`). + and count (`must have length >= 5, got 4`). - **Astral fixtures:** `"😀"` counts as **1** (satisfies `minLength:1`); the empty string `""` counts as **0**. Every language agrees (see [[maxLength]]). @@ -151,8 +151,7 @@ than being written. See [[maxLength]] serialize note (symmetric). ## See also - [[maxLength]] — the paired inclusive upper bound; owns the shared - code-point machinery, the astral/normalization discussion, and the - Pydantic open question. + code-point machinery and the astral/normalization discussion. - [[pattern]] — the other string assertion (regex). - [[type]] — supplies the emitted `string`; gates applicability. - [[const]], [[default]], [[enum]] — supplied string literals validated diff --git a/specs/json-schema/features/minProperties.md b/specs/json-schema/features/minProperties.md index 30f44a72..1ef8e493 100644 --- a/specs/json-schema/features/minProperties.md +++ b/specs/json-schema/features/minProperties.md @@ -51,8 +51,8 @@ member keys present on the wire**, taken at the deserialize boundary **before** default population (see [[default]]) — a default-filled key is never on the wire and does not count (see Interactions). Count the wire object as a single number; do **not** sum a declared-fields bucket and an extras -bucket separately (case-mapping can route a key to either, and in Pydantic -the two sets overlap). Same +bucket separately (case-mapping can route a key to either, and the +declared-vs-extras split is a language-side artifact, not a wire fact). Same per-language strategy as [[maxProperties]] with `< min` as the failing comparison: @@ -60,7 +60,7 @@ comparison: |---|---| | Go | `UnmarshalJSON` counts decoded members (wire keys, pre-population) and hands the count to the shared `Validate`, whose `< min` predicate raises `Violation{Reason: fmt.Sprintf("too few properties: at least %d, got %d", min, n)}`; collected into one `ValidationError`. | | TypeScript | count `Object.keys(parsed).length` on the raw parsed wire object (before defaults applied); the shared `Validate`'s `< min` check pushes ``Violation{path, reason: `too few properties: at least ${min}, got ${n}`}`` + throw one `ValidationError`. | -| Python | `model_validator`; `len(model_fields_set) < min` — `model_fields_set` already includes extras and excludes default-filled fields, so it is the exact wire-key count; raise into aggregated `ValidationError`. | +| Python | `from_transfer_type` counts `len(raw)` on the raw wire dict — one number over the wire object, taken before any default is materialized — and appends `Violation(path="", reason=f"too few properties: at least {min}, got {n}")` when `n < min`, into the single generated `ValidationError`. | | Java | the per-POJO collecting deserializer (PRINCIPLES Java §5) counts distinct keys in the parsed tree (`< min`) — one number over the wire object, **not** POJO fields + catch-all map summed post-bind; a violation joins the single `ValidationException`. | ### Serialize-side (P12) @@ -71,10 +71,11 @@ serialize mirror of "before default population"). A field whose default is unset is omitted and does **not** count toward the floor, exactly as it didn't on the way in — so a model that reads as populated in memory (defaults visible) can legitimately fall **under** `minProperties` on the -wire, and serialize fails (`MarshalJSON`/`toTransferType`/`model_dump`) rather -than emitting an under-floor object. `model_fields_set` is again the -exact emitted-key count under `exclude_unset`. See [[maxProperties]] -serialize note (symmetric). +wire, and serialize fails +(`MarshalJSON`/`toTransferType`/`to_transfer_type`) rather than emitting +an under-floor object; in Python the count is `len(out)` on the dict +`to_transfer_type` has built. See [[maxProperties]] serialize note +(symmetric). ## Property-testing matrix diff --git a/specs/json-schema/features/minimum.md b/specs/json-schema/features/minimum.md index d5f9e7c7..1f0252cf 100644 --- a/specs/json-schema/features/minimum.md +++ b/specs/json-schema/features/minimum.md @@ -37,8 +37,8 @@ Loader behavior (mirror of [[maximum]] with `≥`): - Value not a number → reject. - `minimum` on a non-numeric [[type]] → reject (**P7.1**). - **On an `integer` field the bound MUST be integer-valued** — `minimum:0.0` - accepted (≡ `0`), `minimum:0.5` rejected with a fix-it (same Pydantic - build constraint as [[maximum]]). + accepted (≡ `0`), `minimum:0.5` rejected with a fix-it (same + exact-comparison rationale as [[maximum]]). - On a `number` field any finite bound is accepted. - `minimum` below the [[type]] integer cap `−(2^53−1)` on an `integer` field is redundant (cap already rejects) but allowed. @@ -68,7 +68,7 @@ comparison: |---|---| | Go | `if v < min { push(Violation{Reason: fmt.Sprintf("must be >= %v, got %v", min, v)}) }` — a predicate in the shared `Validate`, which `UnmarshalJSON` calls after decoding, collecting into one `ValidationError`. Integer field compares `int64`; number field compares `float64`. | | TypeScript | ``if (v < min) push(Violation{path, reason: `must be >= ${min}, got ${v}`})``, throw one `ValidationError`. | -| Python | Pydantic `Ge(min)` (`annotated_types`), composing over the `SpecInt` `BeforeValidator` on integer fields (normalize `0.0`→`0`, then `Ge`) — verified in `pyd_numeric_probe.py`; aggregates in `pydantic.ValidationError`, whose message already names the bound (`Input should be greater than or equal to 0`). | +| Python | `if v < min: violations.append(Violation(path=…, reason=f"must be >= {min}, got {v}"))` in the transfer type converter, run after `_parse_spec_integer` normalizes an integer field's wire value (`0.0`→`0`, see [[type]]); aggregates into the single generated `ValidationError`. | | Java | The per-POJO collecting deserializer (PRINCIPLES Java §5) reads the node via the [[type]] `SpecNumbers` helper and checks `v < min` (`long`/`double`), pushing a `Violation{path, "must be >= " + min + ", got " + v}` into the single `ValidationException`. Not bean-validation `@Min`. | Reason strings name the concrete bound and offending value diff --git a/specs/json-schema/features/multipleOf.md b/specs/json-schema/features/multipleOf.md index ab48c4e5..94d2724f 100644 --- a/specs/json-schema/features/multipleOf.md +++ b/specs/json-schema/features/multipleOf.md @@ -5,10 +5,9 @@ Source: JSON Schema 2020-12, Validation vocabulary, §6.2.1 Asserts that a numeric instance is an exact multiple of a divisor. A pure runtime assertion — no type impact. The one numeric keyword with a genuine -cross-language hazard: **floating-point divisibility does not agree -value-for-value across the four targets for fractional divisors**, so the -supported form is narrowed to positive **integer** divisors, where the -check is exact and portable. +floating-point hazard: **divisibility has no portable, intent-preserving +answer for fractional divisors**, so the supported form is narrowed to +positive **integer** divisors, where the check is exact and portable. ## Spec summary @@ -39,16 +38,17 @@ Rationale (citing [[PRINCIPLES.md]]): - **Integer divisor → exact and portable.** Integer modulo (`integer` fields) and IEEE `fmod` (`number` fields) agree value-for-value across all four: Go `math.Mod`, Java `%`, JS `%`, and Python `math.fmod` - return identical results, and Pydantic's native `multiple_of` matches - them for integer divisors (`10.0`/`6.0` accepted, `7.5` rejected, - `1e300` accepted for divisor `2`). - - **Fractional divisor → the languages disagree.** `fmod` treats the + return identical results for integer divisors (`10.0`/`6.0` accepted, + `7.5` rejected, `1e300` accepted for divisor `2`). + - **Fractional divisor → no defensible answer.** `fmod` treats the stored doubles literally, so `0.3 % 0.1 == 0.09999999999999998` and `1.1 % 0.1 == 2.77e-17` — i.e. Go/Java/JS/Python all *reject* `0.3` - against `multipleOf: 0.1`. Pydantic's native float `multiple_of`, - however, is a **tolerant** check and *accepts* `0.3`, `1.1`, and `0.2`. - So Python would silently disagree with the other three. This cannot be - reconciled without imposing a shared decimal algorithm on every + against `multipleOf: 0.1`, which is not what an author writing + `multipleOf: 0.1` means. The alternative — a **tolerant** divisibility + check, of the kind several validation libraries ship — *accepts* `0.3`, + `1.1` and `0.2`, but the tolerance is unspecified and per-library, so + any target left on raw `fmod` then disagrees. Neither branch is + reconcilable without imposing a shared decimal algorithm on every target. - **P4 (minimal runtime deps).** A correct fractional check needs decimal scaling / big-decimal arithmetic; TypeScript and Go have no native @@ -100,7 +100,7 @@ integer divisor `m`, identical in both directions (shared `Validate`, |---|---| | Go | A predicate in the shared `Validate`, which `UnmarshalJSON` calls after decoding. Integer field: `if v % m != 0 { push(Violation{Reason: fmt.Sprintf("must be a multiple of %v, got %v", m, v)}) }` (`int64`). Number field: same message when `math.Mod(v, m) != 0` (`float64`). Violations collect into one `ValidationError`. | | TypeScript | ``if (v % m !== 0) push(Violation{path, reason: `must be a multiple of ${m}, got ${v}`})`` — `%` is IEEE `fmod`, and integer fields are safe integers so it is exact for both kinds. `m` is an emitted numeric constant. Throw one `ValidationError`. | -| Python | **Integer field:** native Pydantic `MultipleOf(m)` (`annotated_types`) over `SpecInt` — Python-int modulo, exact, and verified to match (see `pyd_numeric_probe.py`); its message names the divisor (`Input should be a multiple of 2`). **Number field:** an explicit `AfterValidator` raising `must be a multiple of {m}, got {v}` when `math.fmod(v, m) != 0`, rather than Pydantic's native `multiple_of`, so the check is **bit-identical `fmod`** to the other three targets — Pydantic's native float `multiple_of` is *tolerant* and must not be relied on for numbers (it is safe only because we reject the fractional divisors where the tolerance would bite, but standardizing on `fmod` for numbers keeps the predicate provably identical). Aggregates into `pydantic.ValidationError`. | +| Python | An inline check in the transfer type converter (PRINCIPLES Python §3), emitted the same way TypeScript emits it rather than behind a runtime helper, appending `Violation(path, reason=f"must be a multiple of {m}, got {v}")`. **Integer field:** exact Python-`int` modulo, over the value `_parse_spec_integer` has already normalized. **Number field:** `math.fmod(v, m) != 0` — deliberately the same primitive as the other three rather than any *tolerant* native divisibility check, so the predicate is **bit-identical `fmod`** across targets (a tolerant check would only be safe because we reject the fractional divisors where the tolerance would bite; standardizing on `fmod` keeps the predicate provably identical instead). Aggregates into the single generated `ValidationError`. | | Java | The per-POJO collecting deserializer (PRINCIPLES Java §5) reads the field via the [[type]] `SpecNumbers` helper, then checks `v % m != 0` — `long % long` (integer field) or `double % double` (number field, IEEE `fmod`, matching the others) — pushing a `Violation{path, "must be a multiple of " + m + ", got " + v}` into the single `ValidationException`. Not `BigDecimal.remainder` (would risk decimal-vs-`fmod` divergence on the number path). | Reason strings name the divisor and offending value (`must be a multiple of diff --git a/specs/json-schema/features/oneOf.md b/specs/json-schema/features/oneOf.md index 156a7c3e..0977c441 100644 --- a/specs/json-schema/features/oneOf.md +++ b/specs/json-schema/features/oneOf.md @@ -92,9 +92,10 @@ An object branch is admitted whatever its shape (declared [[properties]], a typed map, a free-form object, member-count bounds). The constraint is not the shape, it is the **name**: every target has to materialize a *type* for a **structured** object branch — Go a defined type to carry the marker method, -Java a class to `implement` the interface, Python a `BaseModel` for Pydantic -to select, TS an interface plus the converter that validates its members — and a -type needs a name. So every object branch must resolve to a determinate name: +Java a class to `implement` the interface, TS an interface plus the converter +that validates its members, Python a dataclass plus the converter that validates +them — and a type needs a name. So every object branch must resolve to a +determinate name: - **`$ref` to a named definition** — the definition's name *is* the branch type, already emitted with its own validation. The recommended form for @@ -209,12 +210,12 @@ any other. It doesn't add a sum-type *member*; it marks the whole field The nullable union needs no new machinery — every target already has a nullable channel for the union type: a Go interface is nilable (`nil` = -`null`), Python wraps in `Optional[...]`, TS adds `| null`, and a Java +`null`), Python adds `| None`, TS adds `| null`, and a Java reference is `@Nullable`. The `null` token selects "no value" exactly as the value tokens select their branches; decode/encode of the null state follow the [[nullability]] tables over the union type (including the -optional-vs-null collapse in Go/Java and the faithful round-trip in -TS/Python). Required-vs-optional and the nullable state remain orthogonal +optional-vs-null collapse in Go/Java/Python and the faithful round-trip in +TS). Required-vs-optional and the nullable state remain orthogonal (**P8**), so all four presence/null combinations apply to a union just as to a scalar. @@ -421,28 +422,60 @@ intact. ### Python -A `Union` (PEP 604 `X | Y` on 3.10+, `Union[...]` alias otherwise); a -named `$def` becomes a `TypeAlias`. Pydantic v2 strict mode discriminates -disjoint kinds natively: +A PEP 604 union, inline on the field; a named `$def` becomes a +`TypeAlias`: ```python -Foo = Union[Widget, str, list[float]] # TypeAlias for the $def +Foo: typing.TypeAlias = Widget | str | list[float] # TypeAlias for the $def ``` -For an object tagged union, the const tag becomes a `Literal` field -([[const]]) and the union carries `Field(discriminator=...)` — Pydantic's -native discriminated-union feature, which gives O(1) selection and precise -errors (see "Discriminated object unions" below). +This is structurally the TypeScript design — a plain type plus an off-type +converter that owns both directions — with one mechanical difference: a +`TypeAlias` cannot carry a decorator and `type[A | B]` is not a valid +annotation, so a union gets **no** `_TransferTypeConverter` class +(**PRINCIPLES Python §3**). Its conversion is emitted as a pair of +module-private free functions instead: -Python inlines the union but **not** a structured object branch's shape: -Pydantic selects on a model, so such a branch becomes a module-level -`BaseModel` named by the rule above (`Object`, or the branch's -`x-py-name`) and enters the union under that name. The free-form object is -the exception — `dict[str, Any]` needs no class. +```python +def _foo_from_transfer_type( + value: typing.Any, path: str, violations: list[Violation] +) -> Foo | None: ... + +def _foo_to_transfer_type(value: Foo) -> typing.Any: ... +``` + +The parse function classifies the wire token, delegates to the selected +branch, and appends any `Violation` to the caller's list — returning `None` +on failure so its siblings are still checked (**P11**); the serialize +function dispatches on the in-memory value's Python type. An inline +property-level union gets the same pair, named after its position +(`___from_transfer_type` / `_..._to_transfer_type`) — the +analogue of TS's module-private `serialize`. + +One consequence worth stating: a union carries no *registered* converter, so +it cannot be a top-level Nexus operation input/output type. That costs +nothing — the loader already requires operation I/O to be an object type and +rejects a `oneOf` there ([[services]]) — so a union only ever appears +nested, where the declaring model's converter calls these functions. + +For an object tagged union the `const` tag stays a `typing.Literal` member of +each branch ([[const]]) and the parse function switches on the discriminant +value read out of the raw dict (see "Discriminated object unions" below). + +Python inlines the union but **not** a structured object branch's shape: the +branch's members have to be validated, and that validation lives in a +converter keyed to a type, so such a branch becomes a module-level dataclass +named by the rule above (`Object`, or the branch's `x-py-name`) and +enters the union under that name. The free-form object is the exception — +`dict[str, typing.Any]` has no declared members to validate and needs no +class. ```python -class FooObject(BaseModel): ... # the inline object branch -Foo = Union[FooObject, str] +@_transfer_type_convertible(_FooObjectTransferTypeConverter) +@dataclasses.dataclass(slots=True, kw_only=True) +class FooObject: ... # the inline object branch + +Foo: typing.TypeAlias = FooObject | str ``` ### Go @@ -568,11 +601,11 @@ to the union type rather than a scalar: |---|---|---| | Go | `Foo` (interface) | already nilable — `nil` = `null`; no `*Foo` wrapper | | TypeScript | `Foo` | `Foo \| null` (optional adds `?`) | -| Python | `Union[…]` | `Optional[Union[…]]` | +| Python | `Foo` (`TypeAlias`) | `Foo \| None` | | Java | `Foo` | `@Nullable Foo` | The presence/null state machine (required+nullable emits `null`, optional -collapses in Go/Java, faithful in TS/Python) is exactly the +collapses in Go/Java/Python, faithful in TS) is exactly the [[nullability]] serialize/round-trip tables, unchanged — the union type simply takes the place of the scalar. @@ -597,11 +630,16 @@ $defs: export type Animal = Cat | Dog; switch (a.kind) { case "cat": /* Cat */ break; case "dog": /* Dog */ break; } ``` -- **Python** — Pydantic native discriminated union: +- **Python** — dataclass branches plus the union's parse function switching + on the tag: ```python - class Cat(BaseModel): kind: Literal["cat"]; meow: str - class Dog(BaseModel): kind: Literal["dog"]; bark: str - Animal = Annotated[Union[Cat, Dog], Field(discriminator="kind")] + @dataclasses.dataclass(slots=True, kw_only=True) + class Cat: kind: typing.Literal["cat"] = "cat"; meow: str + @dataclasses.dataclass(slots=True, kw_only=True) + class Dog: kind: typing.Literal["dog"] = "dog"; bark: str + Animal: typing.TypeAlias = Cat | Dog + # _animal_from_transfer_type: raw["kind"] == "cat" → Cat's converter ; + # "dog" → Dog's ; else a Violation naming the admissible values ``` - **Go** — the sealed interface; the container's `UnmarshalJSON` peeks the discriminator on an object token, then unmarshals into the concrete @@ -638,13 +676,15 @@ then delegate**, never a trial-all-branches loop. |---|---| | Go | The container's collecting `UnmarshalJSON` (shadow `*json.RawMessage` layout, **PRINCIPLES Go** / [[nullability]]) peeks the field's first non-space token, routes to the branch of that kind (`{`→object; `[`→array: `FooArray`; `"`→string: `FooString`; number→the numeric branch via `parseSpecInteger`/spec-number so `1.5` still yields a `Violation`). For an object token with 2+ object branches it further reads the discriminator property and selects the branch with that `const`. It then runs that branch's shared `Validate` and assigns the concrete type to the interface field. No matching kind / unknown discriminator value → `Violation` collected into the single `ValidationError`. | | TypeScript | `fromTransferType` is the `typeof`/`Array.isArray` chain shown above; for an object it switches on the discriminant literal (`raw.kind`) and delegates to that branch's converter (e.g. `catTransferTypeConverter.fromTransferType`); the fall-through pushes one `Violation`. Plain checks only (**PRINCIPLES TS §1** — no runtime schema lib). | -| Python | Pydantic v2 strict `Union` selects by kind; an object tagged union uses `Field(discriminator=...)` for O(1) selection. Zero matches / unknown discriminator raise, aggregated into `pydantic.ValidationError`. | +| Python | The union's module-private `__from_transfer_type(value, path, violations)`, called by the declaring model's `_TransferTypeConverter` (**PRINCIPLES Python §3**), classifies the raw value with `isinstance` — `dict`→object, `list`→array, `str`→string, a non-`bool` `int`/`float`→the numeric branch via `_parse_spec_integer`/the spec-number rule so `1.5` still yields a `Violation`, `bool`→boolean, `None`→null. For an object token with 2+ object branches it reads the discriminator key out of the raw dict and delegates to that branch's converter, re-pathing its `ValidationError` under the current path with `_collect`. No decidable branch / unknown discriminator value → a `Violation` appended and `None` returned, so sibling members are still checked and the model's converter raises the one `ValidationError` (**PRINCIPLES Python §2**). | | Java | The union interface's static `fromNode` (called by the enclosing POJO's collecting deserializer, **PRINCIPLES Java §5**) switches on the `JsonNode` kind (`isObject`/`isArray`/`isTextual`/`isNumber`/`isBoolean`); for an object with 2+ object branches it peeks the discriminator node and dispatches to the matching POJO's collecting deserializer. On no match / unknown discriminator it pushes a `Violation` into the single `ValidationException` and returns `null`. One dispatcher serves both positions: a named union def and a union written inline on a property. | -Reason strings name **what was expected** — the set of admissible kinds/ -branch types (`expected Widget, string, or number[]`), never a bare -`oneOf` — per the informative-reason convention the constraint families -use. +Reason strings name **what was expected**, never a bare `oneOf`, per the +informative-reason convention the constraint families use: no decidable +branch → `expected one of: ` over the admissible kinds / branch +types; an object whose discriminator value matches no branch → +`unknown discriminator : expected one of [...]` over the +admissible tag values. Both strings are identical in all four targets. ### Branch constraints @@ -663,29 +703,43 @@ violation path (`idOrName`, `shapes[1]`, `choices.primary`): |---|---| | Go | the synthesized `` wrapper's `Validate`, over a conversion back to the underlying type (`string(v)`, `[]float64(v)`). The dispatcher calls it on the selected branch, and the declaring model's `Validate` — which `MarshalJSON` runs first — calls it again before emit. A branch `pattern`/`format` compiles to a package-level regex var keyed by the wrapper type (`fooStringPattern`). | | TypeScript | the narrowing chain itself: each `typeof`/`Array.isArray` arm runs the branch's checks over the narrowed value, in `fromIntermediate` and again on the serialize side (a named union in its `Mapper.toIntermediate`, an inline one in the declaring model's, so a branch violation aggregates with its siblings). | -| Python | the union member's own annotation — the native `pydantic.Field` bounds innermost (next to the type they bound), the refinement validators (`multipleOf`, `pattern`, `format`) wrapping them, and `uniqueItems`/`contains` as the AfterValidators Pydantic has no native form for. Selecting the branch *is* validating it. | +| Python | the classification arm itself, exactly as in TypeScript: each `isinstance` arm runs the branch's checks over the classified value — the numeric bounds, length bounds, `multipleOf`, and the `pattern`/`format` regex match all inline; only `uniqueItems` and `contains` go through a runtime helper (`_check_unique_items` / `_check_contains`) — in `__from_transfer_type` and again in `__to_transfer_type`, so a branch violation aggregates with its siblings. Selecting the branch *is* validating it. | | Java | a package-private `validate(path, violations)` on the wrapper class, with its compiled `pattern`/`format` `Pattern` statics. `fromNode` calls it on the wrapper it just built; the interface's static `validate` dispatches on the member's runtime class and is called by the declaring POJO's `Serializer` (and per element/member for a collection of unions) before any wire member is written. | A **closed value set** (`const`/`enum`) on a branch closes the *type* where the target can express that — a TypeScript literal union (`"auto" | "manual" | number`), -a Python `Literal` — and is a membership check in the validator in Go and Java, +a Python `typing.Literal` — and is a membership check in the validator in Go and Java, which have no field to hang a defined type or value class off (the same treatment a typed map's member gets, [[additionalProperties]] §"Per-member `T` validation"). The accepted value set is identical in all four. ### Serialize-side (P12) -In the statically typed targets (Go/TS/Java) the in-memory value **is** a -single branch member, so "exactly one" is structurally guaranteed and the -encode adapter simply emits the held variant: Go `json.Marshal` on the -interface marshals its dynamic type (a `FooString`/`FooArray` named type -serializes as its underlying JSON kind; an object branch emits its -fields); TS `toTransferType` branches on `typeof`/`Array.isArray` and -delegates to the member's converter; Java's `Serializer` writes by runtime -class. The shared `Validate` still **re-runs the selected branch's -constraints before emit**, so an in-memory member violating its own -branch's rules fails serialize with the same aggregated primitive rather -than being written (real teeth where construction is unchecked). +The in-memory value **is** a single branch member, so "exactly one" is +structurally guaranteed and the encode adapter simply emits the held +variant: Go `json.Marshal` on the interface marshals its dynamic type (a +`FooString`/`FooArray` named type serializes as its underlying JSON kind; +an object branch emits its fields); TS `toTransferType` branches on +`typeof`/`Array.isArray` and delegates to the member's converter; Java's +`Serializer` writes by runtime class; Python's +`__to_transfer_type` dispatches on the member's Python type with +`isinstance` and calls the branch's converter. The shared `Validate` still +**re-runs the selected branch's constraints before emit**, so an in-memory +member violating its own branch's rules fails serialize with the same +aggregated primitive rather than being written (real teeth where +construction is unchecked). + +Python's serialize dispatch tests every branch but the last, then falls +through to it: given a member typed as the union, the final `isinstance` +is provably redundant, and emitting it would leave the guard and the +`expected one of` raise behind it statically unreachable. A member whose +runtime type contradicts the field's declared union therefore fails inside +the fallthrough branch's converter rather than with the union's own +aggregated error. What **P12** guarantees is unchanged — nothing invalid +reaches the wire, because the failure still happens before a byte is +written — and the case is one a type checker rejects at the assignment. +The parse direction, which is the one that sees untrusted input, tests +every branch and raises `expected one of: ` with no fallthrough. ## Property-testing matrix diff --git a/specs/json-schema/features/pattern.md b/specs/json-schema/features/pattern.md index 6b64e9f2..05d48ec8 100644 --- a/specs/json-schema/features/pattern.md +++ b/specs/json-schema/features/pattern.md @@ -226,7 +226,7 @@ with them. |---|---| | Go | Package-level `var patRe = regexp.MustCompile()` (compiled once at init; the load-time gate already proved it compiles). The shared `Validate` checks `if !patRe.MatchString(v) { push(Violation{Path, Reason: fmt.Sprintf("must match pattern %q, got %q", , v)}) }` — `MatchString` is unanchored; RE2 is ASCII-class + rune-`.`. Collected into one `ValidationError`. | | TypeScript | Module-level ``const PAT_RE = //u;`` (or `new RegExp(, "u")` when the literal can't be spelled). **The `u` flag is mandatory** (code-point `.`; verified). ``if (!PAT_RE.test(v)) push(Violation{path, reason: `must match pattern ${PAT_RE}, got ${JSON.stringify(v)}`})``. `test` is unanchored and — with no `g` flag — stateless. Throw one `ValidationError`. | -| Python | Module-level `PAT_RE = re.compile(, re.ASCII)` (with the `$`→`\Z` normalization applied) and an explicit `AfterValidator` on the field: `if PAT_RE.search(v) is None: raise ValueError(...)`, aggregating into `pydantic.ValidationError`. **`re.search` (unanchored), `re.ASCII` (ASCII `\d\w\s`).** We deliberately do **not** use Pydantic's native `pattern=`/`StringConstraints(pattern=…)`: it matches with pydantic-core's Rust `regex` engine, whose `\d\w\s` are **Unicode** (verified — `^\d+$` accepts Arabic-Indic `٣`, and `\w`/`\s` accept accented letters / NBSP), so it disagrees with our pinned ASCII on 4/32 corpus pairs (pydantic 2.13.4). Its anchoring (unanchored) and dot (code point) *do* match `re.search`, and it rejects lookaround/backref at model-build time — but the class divergence is a hard blocker, so we standardize the runtime match on `re` + `re.ASCII` + `search` for provable P1 (the same reasoning [[multipleOf]] uses to reject Pydantic's tolerant native `multiple_of`). Using the same `regex` crate for the loader's *compile gate* is not contradictory — there we trust it for *compilability*, never for match semantics. | +| Python | A module-level `_PATTERN_ = re.compile(, re.ASCII)` (with the `$`→`\Z` normalization applied), keyed by the pattern text so identical patterns share one compiled instance per module. Both directions of the model's `_TransferTypeConverter` inline the check — `if _PATTERN_.search(value) is None: violations.append(Violation(path=…, reason=f"must match pattern , got {_quote(value)}"))` — collected into the single `ValidationError` (**PRINCIPLES Python §2/§3**). The comparison is emitted inline rather than behind a runtime helper, the same way TypeScript emits it. **`re.search` (unanchored — never `re.match`, which anchors the start, or `fullmatch`), `re.ASCII` (ASCII `\d\w\s`).** | | Java | Static `private static final Pattern PAT_RE = Pattern.compile();` (**default flags** — ASCII `\d\w\s`, code-point `.`; with the `$`→`\z` normalization applied). The per-POJO collecting deserializer (PRINCIPLES Java §5) reads the `String` and checks `if (!PAT_RE.matcher(v).find())`, pushing a `Violation{path, "must match pattern " + + ", got " + v}` into the single `ValidationException`. **`Matcher.find` (unanchored), never `matches()`** (which anchors the whole input — verified footgun). Not bean-validation `@Pattern`. | **Informative `reason` strings.** The `Violation` `reason` names the @@ -250,9 +250,12 @@ The match is a shared-`Validate` predicate, so it **re-runs before emit** over the decoded value — a model constructed with a non-matching string (a Go `string` / Java `String` / Python `str` set to an off-pattern value in memory) fails serialize with the same aggregated primitive rather than -emitting an invalid value. Real teeth in the statically-typed targets, -where in-memory construction is unchecked. No parse-adapter-only or -encode-adapter-only logic: the match is pure and direction-agnostic. +emitting an invalid value. Real teeth in every target, since constructing a +value in memory is unchecked in all four — a Go struct literal, a TS object +literal, a Java setter, and an inert Python dataclass alike bypass the parse +adapter, so the only place the match can be re-asserted is before emit. No +parse-adapter-only or encode-adapter-only logic: the match is pure and +direction-agnostic. **On a materialized node** ([[format]] temporal / [[contentEncoding]] bytes) the decoded value is not a `string`, so the regex matches the diff --git a/specs/json-schema/features/properties.md b/specs/json-schema/features/properties.md index 7fbc384c..6156a1bf 100644 --- a/specs/json-schema/features/properties.md +++ b/specs/json-schema/features/properties.md @@ -77,14 +77,15 @@ from [[type]]; optional/nullable wrapping from [[required]] + | Aspect | Go | TypeScript | Python | Java | |---|---|---|---|---| -| Aggregate | `struct` | `interface` (**not class**) | Pydantic `BaseModel` | POJO `class` (Java 8; **not records**) | -| Member | struct field | interface member | model attribute | private field + getter | -| JSON-name binding | `json:""` tag | exact key (index access) | `Field(alias="")` / `populate_by_name` | `@JsonProperty("")` | +| Aggregate | `struct` | `interface` (**not class**) | `@dataclasses.dataclass(slots=True, kw_only=True)` (**not a validating base**) | POJO `class` (Java 8; **not records**) | +| Member | struct field | interface member | dataclass field | private field + getter | +| JSON-name binding | `json:""` tag | exact key (index access) | the wire key, read and written by the converter | `@JsonProperty("")` | Field naming: JSON member names are mapped to each language's idiomatic -identifier and the **original JSON name is always pinned** via -tag/alias/annotation so the wire contract is stable regardless of the -emitted identifier (**P2**, **P3**). The exact transform, collision +identifier and the **original JSON name is always pinned** — by a Go +struct tag, a Java annotation, and in TS/Python by the wire key the +converter reads and writes — so the wire contract is stable regardless of +the emitted identifier (**P2**, **P3**). The exact transform, collision policy, and escape hatch are specified in [Identifier case-mapping](#identifier-case-mapping) below. @@ -204,9 +205,9 @@ namespace and cannot collide with a coincidentally-named top-level type: - **Java** — `public static final class Kind` nested in `UserEvent`, referenced `UserEvent.Kind`. Java is the only target that cannot inline a const/enum, so it is where nesting matters most. -- **Python** — a const/enum is an inline `Literal[…]`, so there is no - named type to nest and nothing synthesized in the module namespace; the - fixed value is compared inline in the `model_validator`. +- **Python** — a const/enum is an inline `typing.Literal[…]`, so there is + no named type to nest and nothing synthesized in the module namespace; + the fixed value is compared inline in the converter. - **TypeScript** — a const/enum is an inline literal / union of literals, so there is nothing to nest and nothing synthesized; the validator compares the wire value against the inline literal. @@ -237,7 +238,7 @@ Order: ``` That object is a type in every target — a Go struct, a TS interface, a -Pydantic model, a Java class — so, exactly like an inline [[oneOf]] object +Python dataclass, a Java class — so, exactly like an inline [[oneOf]] object branch, the constraint on it is not its shape but its **name**. It is resolved the same way: the shape is **named after the position it was written in, moved into `$defs`, and the position rewritten to a `$ref`** at @@ -310,7 +311,7 @@ per-member dispatch; presence/absence is [[required]], extras are |---|---| | Go | Custom `UnmarshalJSON` decodes into a shadow of `*json.RawMessage` per member, dispatches each present member through its type helper, collects `Violation{Path, Reason}` into a single `ValidationError`. `Path` is the JSON member name. | | TypeScript | Hand-emitted per-member checks over the parsed object; push `Violation { path, reason }` into the list, throw one `ValidationError`. | -| Python | Pydantic model in strict mode; per-field validation is native and aggregates via `pydantic.ValidationError.errors()` (`loc` = member). | +| Python | hand-emitted per-member checks over the raw `dict` in the model's `_TransferTypeConverter` (**PRINCIPLES Python §3**); each appends a `Violation { path, reason }` (`path` = the JSON member name) to the list raised as one `ValidationError`. The TypeScript strategy, expressed through the SDK's transfer-type hook. | | Java | per-POJO collecting `@JsonDeserialize` (PRINCIPLES Java §5): a two-stage bind that reads the object into a `JsonNode` tree, then dispatches each present member through its spec-strict/constraint helper (see [[type]]), collecting `Violation{path,reason}` into one `ValidationException`. The Go parallel. | A member subschema validates recursively — nested objects become nested @@ -320,13 +321,14 @@ aggregates, arrays use [[items]], etc. `properties` is symmetric across directions: serialize recurses the shared `Validate` into each present member (a nested aggregate's own -`MarshalJSON`/`toTransferType`/`model_dump` validates it), and the JSON-name -binding (`json` tag / alias / `@JsonProperty`) re-emits each member under -its **original wire name**, not the case-mapped identifier — so the -contract is stable in both directions. Member omit-vs-emit-`null` is -owned by [[required]] + [[nullability]]; the per-member value checks are -the same predicates the deserializer runs. `path` on a serialize-side -failure is the JSON member name, identical to deserialize. +`MarshalJSON`/`toTransferType`/`to_transfer_type` validates it), and the +JSON-name binding (`json` tag / `@JsonProperty` / the converter's wire key) +re-emits each member under its **original wire name**, not the case-mapped +identifier — so the contract is stable in both directions. Member +omit-vs-emit-`null` is owned by [[required]] + [[nullability]]; the +per-member value checks are the same predicates the deserializer runs. +`path` on a serialize-side failure is the JSON member name, identical to +deserialize. ## Property-testing matrix diff --git a/specs/json-schema/features/propertyNames.md b/specs/json-schema/features/propertyNames.md index 940044e2..4d35c32c 100644 --- a/specs/json-schema/features/propertyNames.md +++ b/specs/json-schema/features/propertyNames.md @@ -64,7 +64,7 @@ None of its own. The host object's type comes from [[additionalProperties]] — all four languages wrap the map in a named catch-all member (`AdditionalProperties map[string]T` / `Map additionalProperties` / `additionalProperties: -Record` / Pydantic `BaseModel` with extras in `model_extra`). +Record` / `additional_properties: dict[str, V]`). `propertyNames` only adds a key validator over those keys. ## Validator mapping @@ -76,7 +76,7 @@ string against the (string) constraint. |---|---| | Go | The key-constraint check is a predicate in the shared `Validate`, which `UnmarshalJSON` calls after decoding: iterate the decoded keys and run the check (compiled `regexp` for [[pattern]], length checks); a failure → `Violation{Path:key, Reason: fmt.Sprintf("invalid property name %q: %s", key, why)}` (`why` is the underlying assertion's reason, e.g. `must match ^[a-z]+$`), collected into one `ValidationError`. | | TypeScript | the shared `Validate` predicate over `Object.keys(parsed)` applies the check; a failure → push ``Violation{path:k, reason: `invalid property name "${k}": ${why}`}``, throw one `ValidationError`. | -| Python | a field/model validator over `__pydantic_extra__` / the dict keys, raising `InitErrorDetails` (message `invalid property name "": `) per bad key into the aggregated `pydantic.ValidationError`. | +| Python | both directions of the `_TransferTypeConverter` (**PRINCIPLES Python §3**) loop the map's keys and apply the shared key check; a failure appends ``Violation(path=key, reason=f'invalid property name "{key}": {why}')`` per bad key into the single `ValidationError`. | | Java | in the per-POJO collecting deserializer (PRINCIPLES Java §5), iterate the parsed tree's keys, apply the shared key check, and push a `Violation{path:key, "invalid property name \"" + key + "\": " + why}` per bad key into the single `ValidationException`. | Reuses whatever the string-assertion specs ([[pattern]], [[minLength]], diff --git a/specs/json-schema/features/ref.md b/specs/json-schema/features/ref.md index 9b1ddc6a..2d0d0873 100644 --- a/specs/json-schema/features/ref.md +++ b/specs/json-schema/features/ref.md @@ -212,7 +212,7 @@ to `_recursive.py` (Python only — see [[generated-file-layout]]). | **Go** | pointer `*T` (even when required + non-nullable) | a bare recursive `T` is an infinitely-sized struct (compile error); `[]T`/`map[string]T` already carry indirection | | **Java** | bare reference | object fields are references already; recursion is free | | **TypeScript** | bare reference | interfaces reference themselves/each other freely | -| **Python** | string forward annotation + one `model_rebuild()` per SCC | acyclic deps emit in topological order with concrete annotations — **no** rebuild; only a cycle's back-edge forces a forward ref | +| **Python** | bare reference | `from __future__ import annotations` makes every annotation a string that is never evaluated, so a dataclass references itself and its cycle peers freely; the emitted order is topological only for readability | **Satisfiability check.** A recursion cycle has a finite instance only if **at least one edge in it can terminate**. An edge *terminates* when it diff --git a/specs/json-schema/features/required.md b/specs/json-schema/features/required.md index ed26562d..e5427d88 100644 --- a/specs/json-schema/features/required.md +++ b/specs/json-schema/features/required.md @@ -73,10 +73,10 @@ for the non-nullable case (mirrors [[nullability]]): | `type` token | Required (this keyword) | Optional (name absent from `required`) | |---|---|---| -| `"integer"` | Go `int64` · TS `x: number` · Py `int` · Java `long` | Go `*int64` · TS `x?: number` · Py `Optional[int]` · Java `@Nullable Long` | -| `"string"` | Go `string` *(non-null validator)* · TS `x: string` · Py `str` · Java `String` *(non-null; `@NullMarked` default)* | Go `*string` · TS `x?: string` · Py `Optional[str]` · Java `@Nullable String` | -| `"object"` | Go `T` *(non-null)* · TS `x: T` · Py `T` · Java `T` *(non-null; `@NullMarked` default)* | Go `*T` · TS `x?: T` · Py `Optional[T]` · Java `@Nullable T` | -| `"array"` | Go `[]T` *(non-null)* · TS `x: T[]` · Py `list[T]` · Java `List` *(non-null; `@NullMarked` default)* | Go `[]T` *(nil=absent)* · TS `x?: T[]` · Py `Optional[list[T]]` · Java `@Nullable List` | +| `"integer"` | Go `int64` · TS `x: number` · Py `int` · Java `long` | Go `*int64` · TS `x?: number` · Py `int \| None = None` · Java `@Nullable Long` | +| `"string"` | Go `string` *(non-null validator)* · TS `x: string` · Py `str` · Java `String` *(non-null; `@NullMarked` default)* | Go `*string` · TS `x?: string` · Py `str \| None = None` · Java `@Nullable String` | +| `"object"` | Go `T` *(non-null)* · TS `x: T` · Py `T` · Java `T` *(non-null; `@NullMarked` default)* | Go `*T` · TS `x?: T` · Py `T \| None = None` · Java `@Nullable T` | +| `"array"` | Go `[]T` *(non-null)* · TS `x: T[]` · Py `list[T]` · Java `List` *(non-null; `@NullMarked` default)* | Go `[]T` *(nil=absent)* · TS `x?: T[]` · Py `list[T] \| None = None` · Java `@Nullable List` | Reference types (Go slices, TS/Java/Python reference values) can't carry "must be present" in the type system, so they lean on the validator. In @@ -93,10 +93,10 @@ Per **P10**/**P11**. The "Required, non-nullable" row of | Language | Presence enforcement | |---|---| -| Go | shadow `*json.RawMessage` per member; `nil` → `Violation{Path:name, Reason: fmt.Sprintf("required property %q is missing", name)}`, collected into one `ValidationError`. | -| TypeScript | `parsed.x === undefined \|\| parsed.x === null` → push ``Violation{path, reason: `required property "${name}" is missing`}``, throw one `ValidationError`. | -| Python | Pydantic field with no default → strict mode raises automatically (its `missing` error already names the field); aggregated in `pydantic.ValidationError`. | -| Java | in the per-POJO collecting deserializer (PRINCIPLES Java §5): a missing or `null` tree node for a required member → `Violation{path:name, reason: "required property \"" + name + "\" is missing"}`; the strict-vs-non-strict `null`-token logic (Java §4) runs as a helper here, not as a per-field binder. Collected into one `ValidationException`. | +| Go | shadow `*json.RawMessage` per member; `nil` → `Violation{Path:name, Reason:"required"}`, collected into one `ValidationError`. | +| TypeScript | `parsed.x === undefined \|\| parsed.x === null` → push `Violation{path:name, reason:"required"}`, throw one `ValidationError`. | +| Python | in `from_transfer_type` (**PRINCIPLES Python §3**): an absent or `null` key for a required non-nullable member → `Violation(path=name, reason="required")`, appended and the field left unset, so its siblings are still checked; collected into the single `ValidationError`. | +| Java | in the per-POJO collecting deserializer (PRINCIPLES Java §5): a missing or `null` tree node for a required member → `Violation{path:name, reason:"required"}`; the strict-vs-non-strict `null`-token logic (Java §4) runs as a helper here, not as a per-field binder. Collected into one `ValidationException`. | Required + explicit `null`: for a required **non-nullable** member, rejected (may not be `null`) — same machinery as the @@ -105,9 +105,9 @@ optional-non-nullable null rejection in [[nullability]]. For a required **Serialize side (P12).** The presence check runs again before emit, off the in-memory value: a required member that is empty in memory (Go `nil` -pointer · TS `undefined` · Python unset · Java `null` reference) is a -`ValidationError`, so `MarshalJSON`/`toTransferType`/`model_dump` fails -rather than emitting a malformed object. A required member is therefore +pointer · TS `undefined` · Python `None` · Java `null` reference) is a +`ValidationError`, so `MarshalJSON`/`toTransferType`/`to_transfer_type` +fails rather than emitting a malformed object. A required member is therefore **never omitted** on serialize — required-non-nullable always emits its value; required+nullable emits the value or `null`, never absent (see the [[nullability]] serialize table). This mirrors the deserialize diff --git a/specs/json-schema/features/title.md b/specs/json-schema/features/title.md index e1f63b43..7dfa0748 100644 --- a/specs/json-schema/features/title.md +++ b/specs/json-schema/features/title.md @@ -7,8 +7,8 @@ A short human-readable label for the schema it sits on. In the spec it is a **pure annotation** — it never affects validation, and it never affects the emitted *type*. We give it exactly one operational role: it becomes the **summary line of the generated doc comment** on the type or member it -decorates (a Go `//` comment, a TS/Java block comment, a Python docstring -/ Pydantic `Field(title=…)`). Crucially, and unlike much of the ecosystem, +decorates (a Go `//` comment, a TS/Java block comment, a Python +docstring). Crucially, and unlike much of the ecosystem, `title` is **never** used to derive a type or field *name* — names come from the `$defs` key and the [[properties]] resolved policy, never from free-form prose (see Type mapping and Ecosystem variance). @@ -119,7 +119,7 @@ mechanism — placement, line-wrapping, escaping — is owned by |---|---| | Go | leading `// ` line of the doc comment above the `type`/field, **led by the identifier name** per Go convention — `// ` (see below). | | TypeScript | first line of the `/** … */` JSDoc above the `interface`/field. | -| Python | first line of the class **docstring**; for a **field**, the native Pydantic `Field(title="…")` argument (Pydantic models `title` first-class, distinct from `description`). | +| Python | first line of the class **docstring**; for a **field**, the first line of the string literal that follows the field declaration (the attribute-docstring convention documentation tooling reads). | | Java | first sentence of the `/** … */` Javadoc above the class/getter. | **Go — the identifier-led first line.** Idiomatic Go doc comments for an @@ -245,7 +245,7 @@ we refuse. it diverges from the generator's own naming conventions and can cause import failures — the subject of issue #5248, "Do not use the title attribute to control code generation." -- **datamodel-code-generator** (Pydantic) exposes `--use-title-as-name`, +- **datamodel-code-generator** exposes `--use-title-as-name`, an **opt-in** flag to use `title` as the class name — off by default, precisely because it is surprising. diff --git a/specs/json-schema/features/type.md b/specs/json-schema/features/type.md index 15c6004a..cbf9e849 100644 --- a/specs/json-schema/features/type.md +++ b/specs/json-schema/features/type.md @@ -73,7 +73,7 @@ this table is the bare type only. Required form below. Optional fields wrap per [[nullability]] (Java boxes to `Long`/`Double`/`Boolean`; Go uses `*T`; TS uses `?` on the -field; Python uses `Optional[T]`). +field; Python uses `T | None`). | `type` token | Go | TypeScript | Python | Java | |---|---|---|---|---| @@ -81,7 +81,7 @@ field; Python uses `Optional[T]`). | `"integer"` | `int64` | `number` | `int` | `long` | | `"number"` | `float64` | `number` | `float` | `double` | | `"boolean"` | `bool` | `boolean` | `bool` | `boolean` | -| `"object"` | struct from [[properties]] | interface from [[properties]] (**not classes**) | Pydantic model | POJO class (Java 8; **not records** — see PRINCIPLES Java §1) | +| `"object"` | struct from [[properties]] | interface from [[properties]] (**not classes**) | `@dataclasses.dataclass` from [[properties]] (an inline anonymous object schema stays a `dict[str, V]`) | POJO class (Java 8; **not records** — see PRINCIPLES Java §1) | | `"array"` | `[]T` (T from [[items]]) | `T[]` | `list[T]` | `List<T>` | | `"null"` | only inside [[nullability]] pattern | only inside [[nullability]] pattern | only inside [[nullability]] pattern | only inside [[nullability]] pattern | @@ -92,8 +92,9 @@ Notes: `Double`/`Boolean` for optional fields (see [[nullability]]). The primitive-vs-boxed split is what the JVM gives us for free; reference types like `String`/`List<T>` use a non-null validator instead. -- **Python**: `bool <: int`; Pydantic strict mode (the only mode we use) - keeps them distinct. +- **Python**: `bool <: int`, so every generated integer/number check + excludes `bool` explicitly — `True` is not `1` on the wire (see the + validator mapping). ## Validator mapping @@ -102,12 +103,12 @@ errors aggregate into the language-native primitive. | `type` token | Go | TypeScript | Python | Java | |---|---|---|---|---| -| `"string"` | typed `Unmarshal` into `string` | `typeof v === 'string'` | Pydantic `str` strict | Jackson typed binding | -| `"integer"` | shadow `*json.Number` → runtime `parseSpecInteger` → `int64` (accepts `1.0`, rejects `1.5`, caps ±(2^53−1)) | `typeof v === 'number' && Number.isSafeInteger(v)` (accepts `1.0` natively; caps ±(2^53−1)) | `SpecInt = Annotated[int, BeforeValidator(_parse_spec_integer)]` in runtime | node helper `SpecNumbers.specLong(node, path, errs)` called by the collecting deserializer (accepts `1.0`, rejects `1.5`, caps ±(2^53−1)) | -| `"number"` | `float64` unmarshal | `typeof v === 'number'` | Pydantic `float` strict | `Double` binding | -| `"boolean"` | `bool` unmarshal | `typeof v === 'boolean'` | Pydantic `bool` strict (rejects `1`/`0`) | `Boolean` binding | -| `"object"` | typed struct unmarshal | `typeof v === 'object' && v !== null && !Array.isArray(v)` | Pydantic model | typed class binding | -| `"array"` | typed slice unmarshal | `Array.isArray(v)` | Pydantic `list` | typed `List` binding | +| `"string"` | typed `Unmarshal` into `string` | `typeof v === 'string'` | `isinstance(v, str)` | Jackson typed binding | +| `"integer"` | shadow `*json.Number` → runtime `parseSpecInteger` → `int64` (accepts `1.0`, rejects `1.5`, caps ±(2^53−1)) | `typeof v === 'number' && Number.isSafeInteger(v)` (accepts `1.0` natively; caps ±(2^53−1)) | runtime `_parse_spec_integer(v, path, violations)` → `int` (accepts `1.0`, rejects `1.5` and `bool`, caps ±(2^53−1)) | node helper `SpecNumbers.specLong(node, path, errs)` called by the collecting deserializer (accepts `1.0`, rejects `1.5`, caps ±(2^53−1)) | +| `"number"` | `float64` unmarshal | `typeof v === 'number'` | `isinstance(v, (int, float)) and not isinstance(v, bool)` → `float(v)` | `Double` binding | +| `"boolean"` | `bool` unmarshal | `typeof v === 'boolean'` | `isinstance(v, bool)` (rejects `1`/`0`) | `Boolean` binding | +| `"object"` | typed struct unmarshal | `typeof v === 'object' && v !== null && !Array.isArray(v)` | `isinstance(v, dict)`, then the branch/member converter builds the dataclass | typed class binding | +| `"array"` | typed slice unmarshal | `Array.isArray(v)` | `isinstance(v, list)` | typed `List` binding | | `"null"` | `raw == nil` / `bytes.Equal(raw, []byte("null"))` | `v === null` | `v is None` | `v == null` | Strategy per language: @@ -148,18 +149,27 @@ Strategy per language: `Number.isSafeInteger` (e.g. `9007199254740993` → `9007199254740992`, which is `> MAX_SAFE_INTEGER` → rejected). Integer fields therefore emit `typeof v === 'number' && Number.isSafeInteger(v)`. -- **Python**: Pydantic v2 models in strict mode. `pydantic.ValidationError` - already aggregates via `.errors()`. Integer fields are typed as - `SpecInt = Annotated[int, BeforeValidator(_parse_spec_integer)]` from - the generated runtime; the helper explicitly rejects `bool` (closes - the `bool <: int` trap), accepts `int`, accepts `float` with zero - fractional part. User-facing field type remains `int`. - Rationale (empirically verified, Pydantic 2.13): strict mode alone - rejects `1.0` and `1e2` (spec-valid integers); lax mode alone accepts - `True`, `"1"`, `"1.0"` (spec-invalid). The `BeforeValidator` is the - only way to hit the spec exactly. Python ints are unbounded, so the - runtime helper also enforces the cross-language cap `±(2^53−1)`: - `abs(v) > 9007199254740991` → reject. +- **Python**: models are inert dataclasses (**PRINCIPLES Python §1**), so + every type-classification check is a hand-emitted `isinstance` call in the + model's `_<Model>TransferTypeConverter` (**PRINCIPLES Python §3**), each + mismatch appending a `Violation { path, reason }` to the list the converter + raises as one `ValidationError` (**PRINCIPLES Python §2**). Because `bool` + is a subclass of `int`, an integer or number check **must exclude `bool` + explicitly** — otherwise `True` classifies as `1`. Integer fields stay a + plain `int` and run through the generated runtime's + `_parse_spec_integer(value, path, violations)`: it rejects `bool`, accepts + an `int`, accepts a `float` with zero fractional part (`1.0`, `1e2`), and + rejects a fractional one (`1.5`) — the same accept/reject set as Go's + `parseSpecInteger` and Java's `SpecNumbers.specLong`, reached by the same + mechanism: like the Java helper it **pushes a `Violation` and returns + `None`** rather than raising, so one bad integer never aborts the rest of + the object's checks (**P11**). Python ints are unbounded, so the helper + also enforces the cross-language cap `±(2^53−1)`: + `abs(v) > 9007199254740991` → reject. The accepted *values* are identical + in all four targets; the `reason` **text** for a rejected number is not — + Python follows TypeScript, collapsing both the fractional and the + over-cap failure into `expected integer`, where Go names them separately + (`not an integer` / the cap message). - **Java**: POJOs (Java 8 floor; not records, see PRINCIPLES Java §1) bound by the per-POJO collecting deserializer (Java §5) — **no** per-field `@JsonDeserialize`, no `Long` binding. It calls a node-based diff --git a/specs/json-schema/features/uniqueItems.md b/specs/json-schema/features/uniqueItems.md index 9f6d3805..04dab591 100644 --- a/specs/json-schema/features/uniqueItems.md +++ b/specs/json-schema/features/uniqueItems.md @@ -86,16 +86,19 @@ None. The emitted collection type is [[items]]'s `[]T` / `T[]` / Per **P10**/**P11**. A single **all-distinct** check over the decoded elements, identical in both directions (a pure predicate over the decoded -value — the **shared `Validate`** layer of **P12**). Because the element -[[type]] is scalar, each language tracks seen values in its native -hash/set primitive and reports the first collision; equality is the same -value comparison [[enum]] uses (exact `==` for numbers — see below). +value — the **shared `Validate`** layer of **P12**). Every element that +repeats an earlier one is reported, naming both indexes; equality is the +same value comparison [[enum]] uses (exact `==` for numbers — see below). +Because the element [[type]] is scalar, Go, TypeScript and Java track seen +values in their native hash/set primitive; Python compares by `==` over a +list instead, because a generated model is a non-frozen dataclass and +therefore unhashable (see the Python row). | Language | Strategy | |---|---| | Go | A predicate in the shared `Validate`, called by `UnmarshalJSON` after decoding into the `[]T`: `seen := make(map[T]int, len(v)); for i, e := range v { if j, ok := seen[e]; ok { push(Violation{Path, Reason: fmt.Sprintf("duplicate items: element at index %d equals index %d", i, j)}) } else { seen[e] = i } }`, collected into one `ValidationError`. The scalar element type is a comparable map key. | | TypeScript | After the `Array.isArray` guard ([[items]]), the shared `Validate` walks the array tracking a `Set`: ``if (seen.has(e)) push(Violation{path, reason: `duplicate items: element at index ${i} equals index ${seen.get(e)}`}); else seen.set(e, i)``, throwing one `ValidationError`. `Set`/`Map` compare scalars by value. | -| Python | A `model_validator` over the decoded `list[T]` (Pydantic v2 has no native `unique_items`) tracking a `set`; the first repeat raises `InitErrorDetails` into the aggregated `pydantic.ValidationError`, whose message names the colliding indexes. In a position with **no declared field** to key a model validator on — a typed map's member, a [[oneOf]] branch — the same predicate rides in the annotation as a `_check_unique_items` AfterValidator, with the identical reason. | +| Python | The runtime's `_check_unique_items(value, path, violations)`, called from both directions of the `_<Model>TransferTypeConverter` over the `list[T]`: it walks the list accumulating seen elements and appends a `Violation` naming the colliding indexes for each element that repeats an earlier one, into the single `ValidationError`. Comparison is `==` against the accumulated list, **not** a `set`/`dict` — `@dataclasses.dataclass` without `frozen=True` sets `__hash__ = None`, so a model element is unhashable and would raise; arrays are small and correctness beats the O(n²) (**P2**). One helper serves every position — a declared field, a typed map's member, a [[oneOf]] branch — with the identical reason. | | Java | The per-POJO collecting deserializer (PRINCIPLES Java §5) reads the `List<T>`, walks it against a `HashSet<T>`, and on a repeat pushes a `Violation{path, "duplicate items: element at index i equals index j"}` into the single `ValidationException`. Not bean-validation. | Reason strings name the **colliding positions** (`duplicate items: element @@ -117,8 +120,8 @@ runtime duplicate (both are the same value), not two distinct elements. Identical to the count pair: the predicate **re-runs before emit** over the decoded value, so an in-memory slice/list holding duplicates fails serialize with the same aggregated primitive rather than being written. -Real teeth in the statically-typed targets (Go/TS/Java), where in-memory -construction is unchecked (same rationale as the [[maxItems]] bound +Real teeth in every target: building the collection in memory is +unchecked in all four (same rationale as the [[maxItems]] bound re-check). The element count and values are the same in memory as on the wire, so the check is the identical all-distinct walk in both directions. diff --git a/specs/json-schema/generated-file-layout.md b/specs/json-schema/generated-file-layout.md index e86bd28c..dbd6b73d 100644 --- a/specs/json-schema/generated-file-layout.md +++ b/specs/json-schema/generated-file-layout.md @@ -120,20 +120,29 @@ many input files that package aggregates. - Error types — a **single aggregating error holding a list of `Violation { path, reason }`**, identical in spirit across all four - targets: Python the Pydantic aggregation machinery (`pydantic.ValidationError`); - Go a `ValidationError` struct implementing `error` over `[]Violation` - (its `Error()` surfaces every violation — *not* `errors.Join`); TS a + targets: Python a `ValidationError(Exception)` over `list[Violation]` + (its `str()` enumerates every violation), with `Violation` a frozen + dataclass; Go a `ValidationError` struct implementing `error` over + `[]Violation` (its `Error()` surfaces every violation — *not* + `errors.Join`); TS a `ValidationError` class extending `Error` over `Violation[]` (*not* a built-in `AggregateError`); Java `ValidationException extends JsonMappingException` holding `List<Violation>`. One error type, every - violation surfaced in one shot (P11). -- Spec-number helpers — `parseSpecInteger` (Go), `SpecInt` / - `_parse_spec_integer` (Python), `SpecNumbers.specLong` (Java), TS's - safe-integer check. -- Shared (de)serialize scaffolding — the **P12** three-layer base, the - Python optional-non-nullable `model_validator` helper. Java's - collecting (de)serializer stays per-class, but the shared `Violation` / - `ValidationException` / `SpecNumbers` classes live here. + violation surfaced in one shot, and the same structured `{path, reason}` + in every target (P11). +- Spec-number helpers — `parseSpecInteger` (Go), `_parse_spec_integer` + (Python), `SpecNumbers.specLong` (Java), TS's safe-integer check. +- Shared (de)serialize scaffolding — the **P12** three-layer base: the + temporal and content-encoding parse/format helpers, the constraint + checks both directions call, and the helper that re-paths a nested + violation list under its parent field (Python `_collect`, TS + `collect`). Python additionally keeps its `_transfer_type_convertible` + decorator shim here, so the value-type erasure each model registers + through is written once rather than per model. The per-model conversion + machinery stays with its type — + Python's transfer-type converter, Java's collecting (de)serializer — + but the shared `Violation` / `ValidationException` / `SpecNumbers` + classes live here. ## Module paths @@ -254,12 +263,15 @@ types: ([[ref]]). An SCC spanning **≥2 input files** is a cross-file cycle. - **Python**: the cross-file SCC moves wholesale into `_recursive.py` at the package root, where it becomes a within-module cycle (topological - order + a string forward-ref back-edge + one `model_rebuild()`). It - imports the leaf, non-cyclic types it needs from the per-input modules; - those modules and the aggregators import the finished classes back from + order + a forward-ref back-edge in the annotation). It imports the + leaf, non-cyclic types it needs from the per-input modules; those + modules and the aggregators import the finished classes back from `_recursive.py`, which imports nothing back from them — so the - cross-module import cycle is gone. A cycle **within** a single file - stays in its module. + cross-module import cycle is gone. The hoist is what makes the cycle + tractable: a per-input module names its siblings' classes in + **module-level `import` statements**, which is a real Python import + cycle no annotation treatment can defuse. A cycle **within** a single + file stays in its module. - **TypeScript**: no recursive file. Type references erase (`import type` is always cycle-safe), and the imported *values* — a sibling model's transfer type converter, a validator function — are ESM @@ -275,9 +287,18 @@ types: - **Java**: object references handle cycles natively across packages. No recursive file. -`model_rebuild()` is a **cycle** concern, not a same-module concern: -acyclic references emit in topological order with concrete annotations -and need no rebuild. +**Python: annotations are lazy, union assignments are not.** Generated +modules open with `from __future__ import annotations`, so every +annotation is stored as a string and never evaluated — a dataclass field +may name a class defined later in the same module, and a *class* cycle +therefore needs no fix-up step of any kind once the SCC shares a module. +A named union is the exception, because it is an assignment rather than +an annotation: the right-hand side of +`Note: typing.TypeAlias = TextNote | LinkNote` is an ordinary expression +evaluated when the module runs, so its members must already exist and +**unions are emitted after every class they reference**. That ordering +constraint is the only intra-module one; it is orthogonal to the +cross-module hoist above. ## Exports / visibility diff --git a/specs/json-schema/nullability.md b/specs/json-schema/nullability.md index c01fd464..e4f32586 100644 --- a/specs/json-schema/nullability.md +++ b/specs/json-schema/nullability.md @@ -133,34 +133,40 @@ nullability convention (`x?: T | null` is the optional+nullable form). ### Python -Use `Optional[T]` (alias for `Union[T, None]`) on the field type. -Default value is `None` for absence. +Optional fields widen to `T | None` and default to `None`; required +fields carry the bare type and no default. Emitted as a plain +`@dataclasses.dataclass(slots=True, kw_only=True)` (see PRINCIPLES +Python §1), so the fields below are the whole class — every field is +keyword-only, which is why a bare-typed field may follow a defaulted +one: ```python -from typing import Optional -from pydantic import BaseModel - -class User(BaseModel): - id: int # required - nickname: Optional[int] = None # optional — None if absent - name: str # required - email: Optional[str] = None # optional +from __future__ import annotations + +import dataclasses + + +@dataclasses.dataclass(slots=True, kw_only=True) +class User: + id: int # required + nickname: int | None = None # optional — None if absent + name: str # required + email: str | None = None # optional ``` | `type` token | required | optional | |---|---|---| -| any | `T` | `Optional[T]` (with `= None` default) | - -Pydantic strict mode + `Optional[T]` accepts `None` for the optional -case. At the *value* level absence and explicit `null` both read as -`None`, but Pydantic's `model_fields_set` records **which keys the wire -actually carried**, so the distinction is *not* lost at the model level -— it is recoverable for serialization. The generated -`@model_serializer` (keyed on `model_fields_set`) therefore round-trips -optional+nullable **faithfully** (wire `null` → set → re-emitted as -`null`; wire-absent → unset → omitted), putting Python in the same -faithful tier as TypeScript (see "Round-trip behavior" and -"Serialize-side behavior" below, and PRINCIPLES Python §3). +| any | `T` | `T \| None` (with `= None` default) | + +Absence is `None`. The dataclass itself neither coerces nor checks +anything: the only path from wire to field is the model's transfer-type +converter (PRINCIPLES Python §3), whose parse adapter classifies the +type token outright and so admits no lax coercion (`"1"`→`1`, +`1`→`True`) that would violate P10/P7. At the value level absence and +explicit `null` both read as `None`, and a dataclass field has nowhere +to record which of the two the wire carried, so optional+nullable +collapses to the same tier as Go and Java (see "Round-trip behavior" +and "Serialize-side behavior" below). ## Nullability convention @@ -220,32 +226,33 @@ legal. Two nullable states exist: **optional+nullable** (absent / `null` / T) and **required+nullable** (`null` / T; absent rejected). They share the same emitted *type* in every language; only the presence check differs, -and TypeScript/Python also differ at the field-modifier level. +and TypeScript/Python also differ at the declaration level (TS's `?` +modifier, Python's `= None` default). **Optional + nullable** (absent OK, `null` OK, T OK): | `type` token | Java | Go | TypeScript | Python | |---|---|---|---|---| -| `"integer"` | `@Nullable Long` | `*int64` | `x?: number \| null` | `Optional[int] = None` | -| `"number"` | `@Nullable Double` | `*float64` | `x?: number \| null` | `Optional[float] = None` | -| `"boolean"` | `@Nullable Boolean` | `*bool` | `x?: boolean \| null` | `Optional[bool] = None` | -| `"string"` | `@Nullable String` | `*string` | `x?: string \| null` | `Optional[str] = None` | -| `"object"` | `@Nullable T` | `*T` | `x?: T \| null` | `Optional[T] = None` | -| `"array"` | `@Nullable List<T>` | `[]T` (nil = absent or null) | `x?: T[] \| null` | `Optional[list[T]] = None` | +| `"integer"` | `@Nullable Long` | `*int64` | `x?: number \| null` | `x: int \| None = None` | +| `"number"` | `@Nullable Double` | `*float64` | `x?: number \| null` | `x: float \| None = None` | +| `"boolean"` | `@Nullable Boolean` | `*bool` | `x?: boolean \| null` | `x: bool \| None = None` | +| `"string"` | `@Nullable String` | `*string` | `x?: string \| null` | `x: str \| None = None` | +| `"object"` | `@Nullable T` | `*T` | `x?: T \| null` | `x: T \| None = None` | +| `"array"` | `@Nullable List<T>` | `[]T` (nil = absent or null) | `x?: T[] \| null` | `x: list[T] \| None = None` | **Required + nullable** (`null` OK, T OK, absent rejected) — same type, presence enforced by the validator; TS drops the `?`, Python drops the -`= None` default (Pydantic v2: `Optional[T]` with no default is -required-and-nullable): +`= None` default (a dataclass field with no default must be supplied at +construction): | `type` token | Java | Go | TypeScript | Python | |---|---|---|---|---| -| `"integer"` | `@Nullable Long` | `*int64` | `x: number \| null` | `Optional[int]` | -| `"number"` | `@Nullable Double` | `*float64` | `x: number \| null` | `Optional[float]` | -| `"boolean"` | `@Nullable Boolean` | `*bool` | `x: boolean \| null` | `Optional[bool]` | -| `"string"` | `@Nullable String` | `*string` | `x: string \| null` | `Optional[str]` | -| `"object"` | `@Nullable T` | `*T` | `x: T \| null` | `Optional[T]` | -| `"array"` | `@Nullable List<T>` | `[]T` (nil = null) | `x: T[] \| null` | `Optional[list[T]]` | +| `"integer"` | `@Nullable Long` | `*int64` | `x: number \| null` | `x: int \| None` | +| `"number"` | `@Nullable Double` | `*float64` | `x: number \| null` | `x: float \| None` | +| `"boolean"` | `@Nullable Boolean` | `*bool` | `x: boolean \| null` | `x: bool \| None` | +| `"string"` | `@Nullable String` | `*string` | `x: string \| null` | `x: str \| None` | +| `"object"` | `@Nullable T` | `*T` | `x: T \| null` | `x: T \| None` | +| `"array"` | `@Nullable List<T>` | `[]T` (nil = null) | `x: T[] \| null` | `x: list[T] \| None` | (Java is `@Nullable` across every nullable column — the annotation tracks in-memory nullness, not the wire distinction; see the optionality @@ -261,31 +268,33 @@ already rely on a validator the type can't express.) unambiguously means "the wire sent `null`"; the serializer always emits the key (never omits it), and `null` ⟷ `null`. There is no absent state to confuse it with. -- **Optional + nullable round-trips faithfully in TypeScript and - Python; collapses in Go and Java.** TS keeps `undefined` (absent) vs - `null` distinct in memory. Python reads both as `None` at the value - level but tracks `model_fields_set`, so the generated - `@model_serializer` re-emits a wire `null` as `null` and omits a - wire-absent key. Go (`*T` `nil`) and Java (`null`) genuinely cannot - distinguish the two in memory, so they emit a single canonical form - — the key is **omitted** (the conservative choice; emitting `null` - would fabricate a value the client may never have sent). A client - that sent explicit `null` on an optional+nullable field reads it - back as absent **in Go/Java only**. - -**Collapse note (Go / Java only):** the in-memory representations of -"absent" and "JSON null" are the same (`nil`, `null`), and — unlike -Python's `model_fields_set` and TS's `undefined` — there is no side -channel recording which the wire carried, so post-validation user code -can't recover it. This matches **P8**'s framing — optional and -nullable are distinct *schema* concerns; runtime collapse is acceptable -when the language can't represent the difference. Making Go/Java -faithful would require a presence-tracking wrapper (`Null[T]` / shadow -bit); this is rejected as ergonomic overhead (P2) that the conservative -omit avoids. - -TypeScript and Python enforce *and* preserve the distinction; Go and -Java enforce it at the boundary but collapse it in memory. +- **Optional + nullable round-trips faithfully in TypeScript; + collapses in Go, Java and Python.** TS keeps `undefined` (absent) vs + `null` distinct in memory, so its serializer re-emits a wire `null` + as `null` and omits a wire-absent key. Go (`*T` `nil`), Java + (`null`) and Python (`None`) genuinely cannot distinguish the two in + memory, so they emit a single canonical form — the key is + **omitted** (the conservative choice; emitting `null` would + fabricate a value the client may never have sent). A client that + sent explicit `null` on an optional+nullable field reads it back as + absent **in Go/Java/Python**. + +**Collapse note (Go / Java / Python):** the in-memory representations of +"absent" and "JSON null" are the same (`nil`, `null`, `None`), and — +unlike TS's `undefined` — there is no side channel recording which the +wire carried, so post-validation user code can't recover it. This +matches **P8**'s framing — optional and nullable are distinct *schema* +concerns; runtime collapse is acceptable when the language can't +represent the difference. Making any of the three faithful would require +a presence-tracking channel — a `Null[T]` wrapper or shadow bit in +Go/Java, an `UNSET` sentinel widening every optional field to +`T | None | UnsetType` or a hidden per-instance presence set in Python; +each is rejected as ergonomic overhead (P2) that the conservative omit +avoids, and the sentinel additionally forces every consumer to test +against a generated marker instead of plain `None`. + +TypeScript enforces *and* preserves the distinction; Go, Java and Python +enforce it at the boundary but collapse it in memory. ### Diagnostics @@ -307,10 +316,10 @@ absent) and **null acceptance** (non-nullable = reject `null`; nullable | State | Java | Go | TS | Python | |---|---|---|---|---| -| **Required, non-nullable** — must be present, must be T | type is `long`/`String`/etc.; emit `field == null` reject + type binding | type is `int64`/`string`/etc.; shadow `*T` field, reject on `nil` | type is `x: T`; emit `parsed.x === undefined \|\| parsed.x === null` reject | Pydantic field with no default → strict mode raises automatically | -| **Optional, non-nullable** — absent OK, T OK, explicit `null` rejected | strict-variant custom deserializer (see strategy below) | shadow `*json.RawMessage` with explicit `bytes.Equal(*raw, []byte("null"))` reject | `parsed.x === null` rejected; `=== undefined` OK | `model_validator(mode='wrap')` rejects keys present with `None` | -| **Optional + nullable** — absent OK, `null` OK, T OK | type is `@Nullable Long`/`String`/etc.; no extra check beyond type binding | type is `*int64`/`*string`/etc.; no extra check beyond type binding | type is `x?: T \| null`; both `undefined` and `null` accepted | `Optional[T] = None`; both forms accepted | -| **Required + nullable** — must be present, `null` OK, T OK, absent rejected | base (non-strict) deserializer accepts `null`; presence enforced (`field`-present check / required-field machinery) | shadow `*json.RawMessage`; reject on absent (`nil` shadow), accept `null` token | type is `x: T \| null`; emit `parsed.x === undefined` reject; `null` accepted | `Optional[T]` with **no** default → required, accepts `None` | +| **Required, non-nullable** — must be present, must be T | type is `long`/`String`/etc.; emit `field == null` reject + type binding | type is `int64`/`string`/etc.; shadow `*T` field, reject on `nil` | type is `x: T`; emit `parsed.x === undefined \|\| parsed.x === null` reject | type is `x: T` with no default; converter rejects an absent key **and** a `null` token with `required` | +| **Optional, non-nullable** — absent OK, T OK, explicit `null` rejected | strict-variant custom deserializer (see strategy below) | shadow `*json.RawMessage` with explicit `bytes.Equal(*raw, []byte("null"))` reject | `parsed.x === null` rejected; `=== undefined` OK | type is `x: T \| None = None`; converter branch over the raw dict rejects a key present with `None` (see strategy below) | +| **Optional + nullable** — absent OK, `null` OK, T OK | type is `@Nullable Long`/`String`/etc.; no extra check beyond type binding | type is `*int64`/`*string`/etc.; no extra check beyond type binding | type is `x?: T \| null`; both `undefined` and `null` accepted | type is `x: T \| None = None`; both absent and `null` accepted, no extra check | +| **Required + nullable** — must be present, `null` OK, T OK, absent rejected | base (non-strict) deserializer accepts `null`; presence enforced (`field`-present check / required-field machinery) | shadow `*json.RawMessage`; reject on absent (`nil` shadow), accept `null` token | type is `x: T \| null`; emit `parsed.x === undefined` reject; `null` accepted | type is `x: T \| None` with **no** default; converter rejects an absent key, accepts the `null` token as `None` | ## Serialize-side behavior @@ -326,7 +335,7 @@ optional-non-nullable. | required | nullable | empty-value serialize action | |---|---|---| | optional | non-nullable | **omit** the key (emitting `null` is invalid; `Validate` also rejects an explicit in-memory `null` where the language can hold one) | -| optional | nullable | **omit** (conservative) in Go/Java; **faithful** in TS/Python — omit if unset, emit `null` if explicitly null | +| optional | nullable | **omit** (conservative) in Go/Java/Python; **faithful** in TS — omit if `undefined`, emit `null` if explicitly `null` | | required | non-nullable | cannot be empty — `Validate` rejects; always emit the value | | required | nullable | **emit `key: null`** — omitting violates `required` | @@ -339,12 +348,13 @@ Per-language mechanism (all are *encode-adapter* concerns; the shared `MarshalJSON` lets the tags do the work. - **TypeScript** — `toTransferType` omits `undefined`, emits `null`; the three-state gives faithful optional+nullable for free. -- **Python** — a generated `@model_serializer(mode='wrap')` emits only - `model_fields_set` keys (plus const fields), implementing the whole - table; `model_fields_set` drives the faithful optional+nullable - behavior. Baked into the model because the default Temporal - `pydantic_data_converter` owns the `to_json` call — we don't pass - `exclude_unset` ourselves (PRINCIPLES Python §3). +- **Python** — the model's `to_transfer_type` builds the outgoing dict + key by key (PRINCIPLES Python §3): an optional field is written only + when its attribute is not `None`; a required+nullable field is always + written, as `None` when the attribute is `None` (which encodes to + JSON `null`); required-non-nullable, `const` and defaulted fields are + always written. Optional+nullable takes the conservative omit — a + `None` attribute cannot say whether the wire carried `null`. - **Java** — `@JsonInclude(NON_NULL)` on optional fields; `@JsonInclude(ALWAYS)` forces the required+nullable `null`; optional+nullable collapses to the conservative omit (PRINCIPLES @@ -404,75 +414,36 @@ No runtime helper needed. ### Python -A model-level `model_validator(mode='wrap')` wraps Pydantic's -field-validation pass. It pre-scans the raw input dict for -optional-non-nullable keys present with `None`, runs the inner -handler, and combines any pre-errors with field-validation errors -into a single `ValidationError` — preserving P11 aggregation across -both sources. - -Each generated model carries a `ClassVar[frozenset]` listing the -affected field names. The `ClassVar` annotation is required — -without it Pydantic treats `_NAME` as a private model attribute and -the validator can't iterate it. +The model's `from_transfer_type` (PRINCIPLES Python §3) decides the +three-way per field over the raw decoded dict, structurally identical to +Go's and Java's: + +1. key absent (`name not in raw`) → key absent + (required → push `Violation(path, "required")`; optional → leave the + field at `None`) +2. key present and `raw[name] is None` → explicit `null` + (optional-non-nullable → push + `Violation(path, "explicit null not allowed")`; nullable → accept as + `None`) +3. otherwise → call the type-specific violation-collecting parse helper + (e.g. `_parse_spec_integer(raw[name], path, violations)`), which + pushes its own `Violation` and returns `None` on a spec violation ```python -from typing import ClassVar, Optional -from pydantic import BaseModel, ValidationError, model_validator -from pydantic_core import InitErrorDetails, PydanticCustomError - -class User(BaseModel): - id: SpecInt - nickname: Optional[SpecInt] = None # optional, non-nullable - bio: Optional[str] = None # optional + nullable - - _OPTIONAL_NON_NULLABLE: ClassVar[frozenset] = frozenset({"nickname"}) - - @model_validator(mode="wrap") - @classmethod - def _reject_explicit_null(cls, data, handler): - pre_errs = [] - if isinstance(data, dict): - pre_errs = [ - InitErrorDetails( - type=PydanticCustomError( - "null_for_nonnullable", "explicit null not allowed" - ), - loc=(f,), - input=None, - ) - for f in cls._OPTIONAL_NON_NULLABLE - if f in data and data[f] is None - ] - try: - instance = handler(data) - except ValidationError as e: - field_errs = [ - InitErrorDetails( - type=PydanticCustomError(err["type"], err["msg"]), - loc=err["loc"], - input=err.get("input"), - ) - for err in e.errors() - ] - raise ValidationError.from_exception_data( - title=cls.__name__, line_errors=pre_errs + field_errs - ) from None - if pre_errs: - raise ValidationError.from_exception_data( - title=cls.__name__, line_errors=pre_errs - ) - return instance +if "nickname" in raw: # optional, non-nullable + if raw["nickname"] is None: + violations.append( + Violation(path="nickname", reason="explicit null not allowed") + ) + else: + nickname = _parse_spec_integer(raw["nickname"], "nickname", violations) ``` -Why `mode='wrap'` rather than `mode='before'`: a `mode='before'` -validator that raises short-circuits Pydantic's own field validation, -breaking P11 aggregation across error sources. `mode='wrap'` lets us -run the inner handler, catch its errors, and combine. - -Why not a `BeforeValidator` per field: `BeforeValidator` receives -only the value, not "was the key present" — the absent-vs-explicit- -`None` distinction is only recoverable at the dict level. +The absent-vs-`None` distinction is available here because the converter +inspects the **raw dict**, before any field is assigned — which is +exactly why this check can only live in the parse adapter (**P12** layer +1). Once the value has landed on the dataclass, `None` means only +"empty", and the wire information the check needs is gone. ## See also diff --git a/src/generator/json_schema/python.rs b/src/generator/json_schema/python.rs index c80c8941..64dce23d 100644 --- a/src/generator/json_schema/python.rs +++ b/src/generator/json_schema/python.rs @@ -2,7 +2,7 @@ use std::cell::RefCell; use std::collections::{BTreeMap, BTreeSet}; use std::path::PathBuf; -use heck::{ToShoutySnakeCase, ToUpperCamelCase}; +use heck::{ToShoutySnakeCase, ToSnakeCase, ToUpperCamelCase}; use indexmap::IndexMap; use serde::Deserialize; use serde_json::Value; @@ -23,7 +23,7 @@ use crate::planning::{PlannedFamily, PlannedJsonType, PlannedSpec}; use crate::spec::{ApiSpecBranch, ApiSpecNode}; use crate::spec::{ExternalTypeSpec, ModulePath, RecordSpec}; -#[derive(Debug, Deserialize, Default)] +#[derive(Debug, Clone, Deserialize, Default)] struct Schema { #[serde(rename = "$ref")] reference: Option<String>, @@ -95,51 +95,6 @@ impl Schema { } } -impl Schema { - fn is_integer_field(&self) -> bool { - self.ty.as_ref().and_then(Value::as_str) == Some("integer") - } - - fn is_array_field(&self) -> bool { - self.ty.as_ref().and_then(Value::as_str) == Some("array") - } - - /// True when the array field needs a custom after-validator: `uniqueItems` - /// and `contains` (with `minContains`/`maxContains`) have no native Pydantic - /// equivalent. `minItems`/`maxItems` map to native `min_length`/`max_length` - /// and are handled in the field expression instead. - fn needs_array_validator(&self) -> bool { - self.is_array_field() && (self.unique_items == Some(true) || self.contains.is_some()) - } - - fn is_number_field(&self) -> bool { - self.ty.as_ref().and_then(Value::as_str) == Some("number") - } - - fn is_string_field(&self) -> bool { - self.ty.as_ref().and_then(Value::as_str) == Some("string") - } - - /// Number-field `multipleOf` uses an explicit `math.fmod` AfterValidator - /// (not Pydantic's tolerant native `multiple_of`) for bit-identical - /// divisibility across the four targets — see `multipleOf.md`. - fn number_multiple_of(&self) -> Option<&serde_json::Number> { - if self.is_number_field() { - self.multiple_of.as_ref() - } else { - None - } - } - - /// True when the declared-property object carries a member-count or - /// cross-field object constraint that lowers to a custom model validator. - fn has_object_count_or_dependency(&self) -> bool { - self.min_properties.is_some() - || self.max_properties.is_some() - || self.dependent_required.is_some() - } -} - fn py_bound_literal(number: &serde_json::Number, is_integer: bool) -> String { if is_integer && let Some(value) = number.as_f64() { return (value.trunc() as i64).to_string(); @@ -154,12 +109,91 @@ thread_local! { /// Generation is single-threaded per file, so a thread-local avoids threading the map /// through every recursive `annotation` call that resolves a `$ref`. static REF_NAMES: RefCell<BTreeMap<String, String>> = const { RefCell::new(BTreeMap::new()) }; + /// The emitted type identifiers declared in the module currently rendering. + /// A `$ref` at one of them reaches its converter class directly; anything + /// else is only available as the imported class, so its converter is read + /// off the class attribute the SDK decorator set. + static LOCAL_MODELS: RefCell<BTreeSet<String>> = const { RefCell::new(BTreeSet::new()) }; + /// The emitted type identifiers that are `oneOf` sum types. A union carries + /// no converter class — its conversion is a pair of module-private free + /// functions — so a `$ref` at one dispatches differently. + static UNION_NAMES: RefCell<BTreeSet<String>> = const { RefCell::new(BTreeSet::new()) }; } fn set_ref_names(ref_names: &BTreeMap<String, String>) { REF_NAMES.with(|cell| cell.borrow_mut().clone_from(ref_names)); } +/// Records the module-scoped facts the value emitters resolve `$ref`s against. +fn set_module_context(json_models: &[&PlannedJsonType]) -> Result<()> { + let locals = json_models + .iter() + .map(|model| model.model_name.clone()) + .collect::<BTreeSet<_>>(); + let unions = json_models + .iter() + .filter(|model| is_python_union_model(model)) + .map(|model| model.model_name.clone()) + .collect::<BTreeSet<_>>(); + LOCAL_MODELS.with(|cell| *cell.borrow_mut() = locals); + UNION_NAMES.with(|cell| *cell.borrow_mut() = unions); + Ok(()) +} + +fn is_local_model(name: &str) -> bool { + LOCAL_MODELS.with(|cell| cell.borrow().contains(name)) +} + +fn is_union_type_name(name: &str) -> bool { + UNION_NAMES.with(|cell| cell.borrow().contains(name)) +} + +/// The private converter class a model's wire contract lives in. +fn converter_class_name(model_name: &str) -> String { + format!("_{model_name}TransferTypeConverter") +} + +/// The expression that reaches a referenced model's converter. A model declared +/// in this module is reached through its own converter class (fully typed); one +/// imported from a sibling module is reached through the class attribute the SDK +/// decorator set, because the converter itself is module-private there. +fn converter_expr(model_name: &str) -> String { + if is_local_model(model_name) { + format!("{}()", converter_class_name(model_name)) + } else { + format!("getattr({model_name}, \"__temporal_transfer_type_converter\")") + } +} + +/// The `_<base>_{from,to}_transfer_type` function-name base for a named union. +fn union_fn_base(model_name: &str) -> String { + model_name.to_snake_case() +} + +/// The function-name base for an **inline** (property-level) union, mirroring the +/// `<Model><Property>` synthesized-name rule. +fn inline_union_fn_base(model_name: &str, json_name: &str) -> String { + format!( + "{}_{}", + model_name.to_snake_case(), + json_name.to_snake_case() + ) +} + +fn union_parse_fn(base: &str) -> String { + format!("_{base}_from_transfer_type") +} + +fn union_serialize_fn(base: &str) -> String { + format!("_{base}_to_transfer_type") +} + +/// The module-level `frozenset` of declared wire keys an open object splits its +/// catch-all on, mirroring TypeScript's `<MODEL>_DECLARED`. +fn declared_fields_const_name(model_name: &str) -> String { + format!("_{}_DECLARED", model_name.to_shouty_snake_case()) +} + pub(in crate::generator) fn model_type_ref(json_type: &PlannedJsonType) -> String { json_type.model_name.clone() } @@ -576,98 +610,80 @@ pub(in crate::generator) fn render_external_models( .filter(|model| is_python_union_model(model)) .collect(); - let mut models_body = String::new(); - let mut needs_optional_non_nullable_helper = false; - let mut needs_set_fields_helper = false; - let mut needs_pydantic_core = false; - let mut needs_spec_int_helper = false; - for (index, model) in class_models.iter().enumerate() { - render_model( - &mut models_body, - model, - &mut needs_optional_non_nullable_helper, - &mut needs_set_fields_helper, - &mut needs_pydantic_core, - &mut needs_spec_int_helper, - )?; - if index + 1 != class_models.len() { - models_body.push_str("\n\n"); + set_module_context(json_models)?; + + let mut body = String::new(); + // Module-level constants first: the advisory `DEFAULT_<FIELD>` values, the + // shared compiled `pattern`/`format` regexes, and the declared-key sets an + // open object splits its catch-all on. + render_default_constants(&mut body, json_models)?; + render_pattern_regexes(&mut body, json_models)?; + for model in &class_models { + let schema = decode_schema(model)?; + if is_open_object(&schema) { + push_section(&mut body); + render_declared_field_set(&mut body, model, &schema); } } + + // Each model is a plain dataclass plus a private off-model converter owning + // both wire directions. The converter is emitted first and refers to the + // model by forward-ref string, so the dataclass can carry the + // `transfer_type_convertible` decorator that registers it. + for model in &class_models { + let schema = decode_schema(model)?; + push_section(&mut body); + render_model_converter(&mut body, model, &schema, json_models)?; + push_section(&mut body); + render_model_dataclass(&mut body, model, &schema)?; + } + + // A `TypeAlias` cannot be decorated and `type[A | B]` is not a valid + // annotation, so a union's conversion is emitted as module-private free + // functions instead. + render_union_transfer_functions(&mut body, json_models)?; + for model in &union_models { let schema = decode_schema(model)?; - needs_spec_int_helper |= schema_uses_integer(&schema); - models_body.push_str("\n\n\n"); + push_section(&mut body); render_python_docstring( - &mut models_body, + &mut body, "", schema.description.as_deref(), &[], None, false, ); - models_body.push_str(&model.model_name); - models_body.push_str(": typing.TypeAlias = "); - models_body.push_str(&annotation(&schema)?); - models_body.push('\n'); - } - let mut body = String::new(); - body.push_str(&models_body); - - let mut post_model_statements = String::new(); - render_cyclic_model_rebuilds(&mut post_model_statements, json_models); - render_union_ref_rebuilds(&mut post_model_statements, &class_models, &union_models); - render_map_member_adapters( - &mut post_model_statements, - &class_models, - &union_models - .iter() - .map(|model| model.model_name.clone()) - .collect(), - )?; - let mut module_imports = BTreeSet::from(["pydantic".to_string()]); - if needs_pydantic_core { - module_imports.insert("pydantic_core".to_string()); + body.push_str(&model.model_name); + body.push_str(": typing.TypeAlias = "); + body.push_str(&annotation(&schema)?); + body.push('\n'); } + + // Each is emitted only when the rendered body actually references the module + // (a materialized temporal field, a `multipleOf` on a number, a hoisted + // `pattern`/`format` regex); the shared import writer does that filtering. + let module_imports = BTreeSet::from([ + "temporalio.converter".to_string(), + "datetime".to_string(), + "math".to_string(), + "re".to_string(), + ]); let mut relative_imports = BTreeMap::<String, BTreeSet<String>>::new(); - let mut runtime_imports = BTreeSet::new(); - if needs_spec_int_helper || post_model_statements.contains("SpecInt") { - runtime_imports.insert("SpecInt".to_string()); - } - // Import the refinement validators and the materialized-temporal / bytes - // field aliases actually referenced by the rendered module (defined once in - // the runtime module) — by a model's own field, by a map's member adapter, or - // by a union branch's refined member type. - for alias in [ - "_check_multiple_of", - "_check_pattern", - "_check_format", - "_check_unique_items", - "_check_contains", - "DateTimeField", - "DateField", - "TimeField", - "DurationField", - "Base64Field", - "Base64UrlField", - ] { - if models_body.contains(alias) || post_model_statements.contains(alias) { - runtime_imports.insert(alias.to_string()); - } - } - if needs_optional_non_nullable_helper { - runtime_imports.insert("_reject_explicit_null".to_string()); - } - if needs_set_fields_helper { - runtime_imports.insert("_emit_set_fields".to_string()); - } + // Import exactly the runtime symbols the emitted body references. Longer + // names are checked as whole identifiers so `_parse_base64url` does not drag + // `_parse_base64` in with it. + let runtime_imports = JSON_RUNTIME_SYMBOLS + .iter() + .filter(|symbol| body_references_symbol(&body, symbol)) + .map(|symbol| (*symbol).to_string()) + .collect::<BTreeSet<_>>(); if !runtime_imports.is_empty() { relative_imports.insert(runtime_import_module.to_string(), runtime_imports); } - Ok(RenderedModelFragments { body, - post_model_statements, + post_model_statements: String::new(), module_imports, relative_imports, exported_names: json_models @@ -678,112 +694,75 @@ pub(in crate::generator) fn render_external_models( }) } -/// True when a model's schema is a `oneOf` sum type (two or more non-null -/// branches) — emitted as a `typing.Union` TypeAlias, not a Pydantic class. -fn is_python_union_model(model: &PlannedJsonType) -> bool { - decode_schema(model).is_ok_and(|schema| { - schema.one_of.as_ref().is_some_and(|branches| { - branches - .iter() - .filter(|branch| branch.ty.as_ref().and_then(Value::as_str) != Some("null")) - .count() - >= 2 - }) +/// The runtime symbols a generated model module may import from `_definitions`. +const JSON_RUNTIME_SYMBOLS: &[&str] = &[ + "ValidationError", + "Violation", + "_check_contains", + "_check_unique_items", + "_collect", + "_format_base64", + "_format_base64url", + "_format_date", + "_format_date_time", + "_format_duration", + "_format_time", + "_parse_base64", + "_parse_base64url", + "_parse_date", + "_parse_date_time", + "_parse_duration", + "_parse_spec_integer", + "_parse_time", + "_quote", + "_transfer_type_convertible", +]; + +/// True when `body` references `symbol` as a whole Python identifier (not as the +/// prefix or suffix of a longer one, and not as an attribute of something else). +fn body_references_symbol(body: &str, symbol: &str) -> bool { + let is_ident = |character: char| character.is_ascii_alphanumeric() || character == '_'; + body.match_indices(symbol).any(|(index, _)| { + let previous = body[..index].chars().next_back(); + let next = body[index + symbol.len()..].chars().next(); + !previous.is_some_and(|character| is_ident(character) || character == '.') + && !next.is_some_and(is_ident) }) } -fn schema_references_union(schema: &Schema, union_names: &BTreeSet<String>) -> bool { - if let Some(reference) = &schema.reference - && union_names.contains(&reference_model_name(reference)) - { - return true; - } - if let Some(properties) = &schema.properties - && properties - .values() - .any(|property| schema_references_union(property, union_names)) - { - return true; - } - if let Some(items) = &schema.items - && schema_references_union(items, union_names) - { - return true; - } - if let Some(one_of) = &schema.one_of - && one_of - .iter() - .any(|branch| schema_references_union(branch, union_names)) - { - return true; - } - if let Some(additional) = &schema.additional_properties - && let Ok(additional_schema) = serde_json::from_value::<Schema>(additional.clone()) - && schema_references_union(&additional_schema, union_names) - { - return true; +/// Starts a new top-level section, separated from the previous one by a blank +/// line. `ruff format` normalizes the exact count. +fn push_section(body: &mut String) { + if !body.is_empty() { + body.push_str("\n\n"); } - false } -/// A class model referencing a named union def carries a deferred (`from -/// __future__ import annotations`) `field: Union` annotation the alias only -/// satisfies once defined (after all classes), so rebuild it here. -fn render_union_ref_rebuilds( - output: &mut String, - class_models: &[&PlannedJsonType], - union_models: &[&PlannedJsonType], -) { - if union_models.is_empty() { - return; - } - let union_names: BTreeSet<String> = union_models - .iter() - .map(|model| model.model_name.clone()) - .collect(); - for model in class_models { - let Ok(schema) = decode_schema(model) else { +fn push_indented(output: &mut String, body: &str, indent: &str) { + for line in body.lines() { + if line.is_empty() { + output.push('\n'); continue; - }; - if schema_references_union(&schema, &union_names) { - output.push_str("_ = "); - output.push_str(&model.model_name); - output.push_str(".model_rebuild()\n"); } + output.push_str(indent); + output.push_str(line); + output.push('\n'); } } -fn render_cyclic_model_rebuilds(output: &mut String, models: &[&PlannedJsonType]) { - let local_models = models - .iter() - .map(|model| { - ( - model.full_name.clone(), - (ModulePath::default(), (*model).clone()), - ) - }) - .collect::<BTreeMap<_, _>>(); - let graph = models - .iter() - .map(|model| { - let mut refs = BTreeSet::new(); - collect_json_schema_model_refs(&model.schema, &local_models, &mut refs); - (model.full_name.clone(), refs) +/// True when a model's schema is a `oneOf` sum type (two or more non-null +/// branches) — emitted as a `typing.TypeAlias` over the branch union, not a +/// dataclass. +fn is_python_union_model(model: &PlannedJsonType) -> bool { + decode_schema(model).is_ok_and(|schema| { + schema.one_of.as_ref().is_some_and(|branches| { + branches + .iter() + .filter(|branch| branch.ty.as_ref().and_then(Value::as_str) != Some("null")) + .count() + >= 2 }) - .collect::<BTreeMap<_, _>>(); - - for model in models { - let Some(refs) = graph.get(&model.full_name) else { - continue; - }; - if refs.iter().any(|reference| { - json_model_can_reach(reference, &model.full_name, &graph, &mut BTreeSet::new()) - }) { - output.push_str("_ = "); - output.push_str(&model.model_name); - output.push_str(".model_rebuild()\n"); - } - } + }) } fn render_json_runtime_module() -> String { @@ -792,45 +771,48 @@ fn render_json_runtime_module() -> String { output.push_str("from __future__ import annotations\n\n"); output.push_str("import base64\n"); output.push_str("import collections.abc\n"); + output.push_str("import dataclasses\n"); output.push_str("import datetime\n"); - output.push_str("import math\n"); + output.push_str("import json\n"); output.push_str("import re\n"); - output.push_str("import typing\n\n"); - output.push_str("import pydantic\n"); - output.push_str("import pydantic.functional_validators\n"); - output.push_str("import pydantic_core\n\n\n"); - // The underscore-prefixed helpers below are still imported by sibling - // generated modules (e.g. `models.py`); listing them keeps type checkers - // from flagging them as unused private symbols. + output.push_str("import typing\n"); + output.push_str("import temporalio.converter\n\n\n"); + // The underscore-prefixed helpers below are imported by sibling generated + // modules (e.g. `models.py`); listing them keeps type checkers from flagging + // them as unused private symbols. output.push_str("__all__ = [\n"); for name in [ - "SpecInt", - "DateTimeField", - "DateField", - "TimeField", - "DurationField", - "Base64Field", - "Base64UrlField", - "_check_multiple_of", - "_check_pattern", - "_check_format", - "_check_unique_items", + "ValidationError", + "Violation", "_check_contains", - "_reject_explicit_null", - "_emit_set_fields", + "_check_unique_items", + "_collect", + "_format_base64", + "_format_base64url", + "_format_date", + "_format_date_time", + "_format_duration", + "_format_time", + "_parse_base64", + "_parse_base64url", + "_parse_date", + "_parse_date_time", + "_parse_duration", + "_parse_spec_integer", + "_parse_time", + "_quote", + "_transfer_type_convertible", ] { output.push_str(" \""); output.push_str(name); output.push_str("\",\n"); } output.push_str("]\n\n\n"); - render_spec_int_helper(&mut output); - output.push_str("\n\n"); - render_multiple_of_helper(&mut output); + render_validator_core(&mut output); output.push_str("\n\n"); - render_pattern_helper(&mut output); + render_transfer_type_convertible_helper(&mut output); output.push_str("\n\n"); - render_format_helper(&mut output); + render_spec_int_helper(&mut output); output.push_str("\n\n"); render_unique_items_helper(&mut output); output.push_str("\n\n"); @@ -839,19 +821,89 @@ fn render_json_runtime_module() -> String { render_temporal_helpers(&mut output); output.push_str("\n\n"); render_content_encoding_helpers(&mut output); - output.push_str("\n\n"); - render_optional_non_nullable_helper(&mut output); - output.push_str("\n\n"); - render_set_fields_helper(&mut output); output } +/// Emits the error-aggregation core: the `Violation` record, the single +/// aggregating `ValidationError`, and the `_collect` re-pather that lifts a +/// nested model's violations under the parent's path. One error type carrying +/// every violation, structurally identical to Go/TypeScript/Java (P11). +fn render_validator_core(output: &mut String) { + output.push_str(VALIDATOR_CORE_BODY); +} + +const VALIDATOR_CORE_BODY: &str = r#"@dataclasses.dataclass(frozen=True, slots=True) +class Violation: + """A single constraint failure, located by JSON path.""" + + path: str + reason: str + + +class ValidationError(Exception): + """Every constraint failure found in one (de)serialization pass.""" + + violations: list[Violation] + + def __init__(self, violations: list[Violation]) -> None: + self.violations = violations + detail = "; ".join(f"{item.path}: {item.reason}" for item in violations) + super().__init__(f"{len(violations)} validation error(s): {detail}") + + +def _quote(value: object) -> str: + """Renders a value in the JSON form every target quotes offending values in.""" + + try: + return json.dumps(value, ensure_ascii=False) + except (TypeError, ValueError): + return repr(value) + + +def _collect(violations: list[Violation], path: str, error: ValidationError) -> None: + """Re-paths a nested model's violations under `path` and appends them.""" + + for inner in error.violations: + # A nested violation about the value *itself* carries no path of its own + # (a union branch's own constraint, an element-level check), so the + # prefix is the whole path -- never a dangling separator (P11). + nested = f"{path}.{inner.path}" if inner.path else path + violations.append(Violation(path=nested, reason=inner.reason)) +"#; + +/// Emits the decorator shim every model registers its converter through. It +/// exists purely to erase the converter's value-type parameter: binding it on the +/// decorated class is circular for a static type checker, which degrades the +/// model to `Unknown` and poisons every annotation naming it. +fn render_transfer_type_convertible_helper(output: &mut String) { + output.push_str(TRANSFER_TYPE_CONVERTIBLE_BODY); +} + +const TRANSFER_TYPE_CONVERTIBLE_BODY: &str = r#"_ModelT = typing.TypeVar("_ModelT") + + +def _transfer_type_convertible( + converter: type[temporalio.converter.TransferTypeConverter[typing.Any, typing.Any]], +) -> collections.abc.Callable[[type[_ModelT]], type[_ModelT]]: + """Registers a transfer type converter on a model class. + + Wraps `temporalio.converter.transfer_type_convertible` to erase the + converter's value-type parameter. Binding it directly on the decorated class + is circular for a static type checker -- the class's type depends on the + decorator, whose value type depends on the class -- which degrades the model + to `Unknown`. Erasing it here keeps the decorator idiomatic at each model and + resolves the cycle. + """ + + return temporalio.converter.transfer_type_convertible(converter) +"#; + /// Emits the materialized-temporal runtime: the pinned narrowed regexes, the -/// Gregorian calendar predicate, the parse (`BeforeValidator`) + generator-owned -/// serialize (`PlainSerializer`) adapters, and the four `Annotated` field -/// aliases (`DateTimeField` / `DateField` / `TimeField` / `DurationField`). See -/// `specs/json-schema/features/format.md`. We do NOT use Pydantic's native `datetime` -/// coercion (it accepts a missing offset and normalizes differently). +/// Gregorian calendar predicate, and the violation-collecting parse / canonical +/// serialize helpers the converters call for each of the four kinds. See +/// `specs/json-schema/features/format.md`. The parse is generator-owned rather +/// than `datetime.fromisoformat` alone, which accepts a missing offset and +/// normalizes differently from the narrowed grammar. fn render_temporal_helpers(output: &mut String) { use crate::json_schema::format::TemporalKind; output.push_str(&format!( @@ -897,41 +949,53 @@ def _valid_temporal_calendar(value: str) -> bool: return maximum > 0 and 1 <= day <= maximum -def _parse_date_time(value: object) -> object: - if not isinstance(value, str): - return value +def _parse_date_time( + value: str, path: str, violations: list[Violation] +) -> datetime.datetime | None: if _TEMPORAL_DATE_TIME_RE.match(value) is None or not _valid_temporal_calendar(value): - raise ValueError(f"must be a valid date-time, got {value!r}") + violations.append( + Violation(path=path, reason=f"must be a valid date-time, got {_quote(value)}") + ) + return None normalized = value.upper() if normalized.endswith("Z"): normalized = normalized[:-1] + "+00:00" return datetime.datetime.fromisoformat(normalized) -def _parse_date(value: object) -> object: - if not isinstance(value, str): - return value +def _parse_date( + value: str, path: str, violations: list[Violation] +) -> datetime.date | None: if _TEMPORAL_DATE_RE.match(value) is None or not _valid_temporal_calendar(value): - raise ValueError(f"must be a valid date, got {value!r}") + violations.append( + Violation(path=path, reason=f"must be a valid date, got {_quote(value)}") + ) + return None return datetime.date.fromisoformat(value) -def _parse_time(value: object) -> object: - if not isinstance(value, str): - return value +def _parse_time( + value: str, path: str, violations: list[Violation] +) -> datetime.time | None: if _TEMPORAL_TIME_RE.match(value) is None: - raise ValueError(f"must be a valid time, got {value!r}") + violations.append( + Violation(path=path, reason=f"must be a valid time, got {_quote(value)}") + ) + return None normalized = value.upper() if normalized.endswith("Z"): normalized = normalized[:-1] + "+00:00" return datetime.time.fromisoformat(normalized) -def _parse_duration(value: object) -> object: - if not isinstance(value, str): - return value +def _parse_duration( + value: str, path: str, violations: list[Violation] +) -> datetime.timedelta | None: if _TEMPORAL_DURATION_RE.match(value) is None: - raise ValueError(f"must be a valid duration, got {value!r}") + violations.append( + Violation(path=path, reason=f"must be a valid duration, got {_quote(value)}") + ) + return None total = 0 number = "" for char in value[2:]: @@ -941,7 +1005,10 @@ def _parse_duration(value: object) -> object: total += int(number) * {"H": 3600, "M": 60, "S": 1}[char] number = "" if total > _TEMPORAL_MAX_DURATION_SECONDS: - raise ValueError(f"must be a valid duration, got {value!r}") + violations.append( + Violation(path=path, reason=f"must be a valid duration, got {_quote(value)}") + ) + return None return datetime.timedelta(seconds=total) @@ -996,37 +1063,13 @@ def _format_duration(value: datetime.timedelta) -> str: if seconds: out += f"{seconds}S" return out - - -DateTimeField: typing.TypeAlias = typing.Annotated[ - datetime.datetime, - pydantic.BeforeValidator(_parse_date_time), - pydantic.PlainSerializer(_format_date_time, return_type=str), -] -DateField: typing.TypeAlias = typing.Annotated[ - datetime.date, - pydantic.BeforeValidator(_parse_date), - pydantic.PlainSerializer(_format_date, return_type=str), -] -TimeField: typing.TypeAlias = typing.Annotated[ - datetime.time, - pydantic.BeforeValidator(_parse_time), - pydantic.PlainSerializer(_format_time, return_type=str), -] -DurationField: typing.TypeAlias = typing.Annotated[ - datetime.timedelta, - pydantic.BeforeValidator(_parse_duration), - pydantic.PlainSerializer(_format_duration, return_type=str), -] "#; /// Emits the materialized-`contentEncoding` runtime: the pinned canonical -/// base64 / base64url regexes (the validity oracle), the parse -/// (`BeforeValidator`) + generator-owned canonical serialize (`PlainSerializer`) -/// adapters, and the two `Annotated` bytes field aliases. We own the codec via -/// the model hooks rather than lean on Pydantic's `Base64Bytes`, for full control -/// of the accept/reject line and the canonical output. See -/// `specs/json-schema/features/contentEncoding.md`. +/// base64 / base64url regexes (the validity oracle) plus the violation-collecting +/// decode and canonical encode helpers the converters call. The codec is +/// generator-owned so the accept/reject line and the canonical output are the +/// same in every target. See `specs/json-schema/features/contentEncoding.md`. fn render_content_encoding_helpers(output: &mut String) { use crate::json_schema::content_encoding::Encoding; output.push_str(&format!( @@ -1048,11 +1091,12 @@ fn render_content_encoding_helpers(output: &mut String) { const CONTENT_ENCODING_HELPER_BODY: &str = r#" -def _parse_base64(value: typing.Any) -> bytes: - if isinstance(value, bytes): - return value - if not isinstance(value, str) or _BASE64_RE.match(value) is None: - raise ValueError(f"must be base64-encoded, got {value!r}") +def _parse_base64(value: str, path: str, violations: list[Violation]) -> bytes | None: + if _BASE64_RE.match(value) is None: + violations.append( + Violation(path=path, reason=f"must be base64-encoded, got {_quote(value)}") + ) + return None return base64.b64decode(value, validate=True) @@ -1060,328 +1104,2762 @@ def _format_base64(value: bytes) -> str: return base64.b64encode(value).decode("ascii") -def _parse_base64url(value: typing.Any) -> bytes: - if isinstance(value, bytes): - return value - if not isinstance(value, str) or _BASE64URL_RE.match(value) is None: - raise ValueError(f"must be base64url-encoded, got {value!r}") +def _parse_base64url(value: str, path: str, violations: list[Violation]) -> bytes | None: + if _BASE64URL_RE.match(value) is None: + violations.append( + Violation(path=path, reason=f"must be base64url-encoded, got {_quote(value)}") + ) + return None return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4)) def _format_base64url(value: bytes) -> str: return base64.urlsafe_b64encode(value).rstrip(b"=").decode("ascii") +"#; +/// Emits the `_check_unique_items` runtime helper: asserts pairwise-distinct +/// elements, reporting the first duplicate's index and the index it repeats -- +/// the same reason every other target emits. Comparison is by `==` over a list +/// rather than hashing, because a generated model is a non-frozen dataclass and +/// therefore unhashable; arrays are small and correctness beats the O(n^2) (P2). +/// See `specs/json-schema/features/uniqueItems.md`. +fn render_unique_items_helper(output: &mut String) { + output.push_str(UNIQUE_ITEMS_HELPER_BODY); +} -Base64Field: typing.TypeAlias = typing.Annotated[ - bytes, - pydantic.BeforeValidator(_parse_base64), - pydantic.PlainSerializer(_format_base64, return_type=str), -] -Base64UrlField: typing.TypeAlias = typing.Annotated[ - bytes, - pydantic.BeforeValidator(_parse_base64url), - pydantic.PlainSerializer(_format_base64url, return_type=str), -] +const UNIQUE_ITEMS_HELPER_BODY: &str = r#"def _check_unique_items( + value: list[typing.Any], path: str, violations: list[Violation] +) -> None: + """Asserts an array's elements are pairwise distinct.""" + + seen: list[typing.Any] = [] + for index, element in enumerate(value): + for earlier, previous in enumerate(seen): + if previous == element: + violations.append( + Violation( + path=path, + reason=( + f"duplicate items: element at index {index} " + f"equals index {earlier}" + ), + ) + ) + break + seen.append(element) "#; -fn render_multiple_of_helper(output: &mut String) { - output.push_str("def _check_multiple_of(\n"); - output.push_str(" divisor: float,\n"); - output.push_str(") -> typing.Callable[[float], float]:\n"); - output.push_str( - " \"\"\"Builds an AfterValidator asserting `math.fmod`-exact divisibility for number fields.\"\"\"\n", - ); - output.push_str("\n"); - output.push_str(" def validate(value: float) -> float:\n"); - output.push_str(" if math.fmod(value, divisor) != 0:\n"); - output.push_str( - " raise ValueError(f\"must be a multiple of {divisor}, got {value}\")\n", - ); - output.push_str(" return value\n"); - output.push_str("\n"); - output.push_str(" return validate\n"); +/// Emits the `_check_contains` runtime helper: asserts the number of elements +/// matching a predicate falls in `[min_contains, max_contains]`, with the same +/// reasons every other target emits. See +/// `specs/json-schema/features/contains.md`. +fn render_contains_helper(output: &mut String) { + output.push_str(CONTAINS_HELPER_BODY); } -fn render_pattern_helper(output: &mut String) { - output.push_str("def _check_pattern(\n"); - output.push_str(" pattern: str,\n"); - output.push_str(") -> typing.Callable[[str], str]:\n"); - output.push_str( - " \"\"\"Builds an AfterValidator asserting an unanchored, ASCII-class regex match for string fields.\"\"\"\n", - ); - output.push_str("\n"); - output.push_str(" compiled = re.compile(pattern, re.ASCII)\n"); - output.push_str("\n"); - output.push_str(" def validate(value: str) -> str:\n"); - output.push_str(" if compiled.search(value) is None:\n"); - output.push_str( - " raise ValueError(f\"must match pattern {pattern}, got {value!r}\")\n", - ); - output.push_str(" return value\n"); - output.push_str("\n"); - output.push_str(" return validate\n"); -} - -/// Emits the `_check_unique_items` runtime helper: an AfterValidator asserting -/// pairwise-distinct elements, reporting the first duplicate's index and the -/// index it repeats — the same reason every other target emits. Pydantic has no -/// native `uniqueItems`, and unlike the declared-property path (a model validator -/// keyed by field name) a position with no field of its own — a typed map's -/// member, a `oneOf` branch — has to carry the check in its annotation. See -/// `specs/json-schema/features/uniqueItems.md`. -fn render_unique_items_helper(output: &mut String) { - output.push_str("def _check_unique_items(\n"); - output.push_str(" value: list[typing.Any],\n"); - output.push_str(") -> list[typing.Any]:\n"); - output.push_str( - " \"\"\"An AfterValidator asserting an array's elements are pairwise distinct.\"\"\"\n", - ); - output.push_str("\n"); - output.push_str(" seen: dict[object, int] = {}\n"); - output.push_str(" for index, element in enumerate(value):\n"); - output.push_str(" if element in seen:\n"); - output.push_str( - " raise ValueError(\n f\"duplicate items: element at index {index} equals index {seen[element]}\"\n )\n", - ); - output.push_str(" seen[element] = index\n"); - output.push_str(" return value\n"); +const CONTAINS_HELPER_BODY: &str = r#"def _check_contains( + value: list[typing.Any], + matches: typing.Callable[[typing.Any], bool], + min_contains: int, + max_contains: int | None, + bounded_min: bool, + path: str, + violations: list[Violation], +) -> None: + """Asserts how many of an array's elements match the `contains` schema.""" + + match_count = sum(1 for element in value if matches(element)) + if match_count < min_contains: + if bounded_min: + violations.append( + Violation( + path=path, + reason=( + f"too few matching items: at least {min_contains}, " + f"got {match_count}" + ), + ) + ) + else: + violations.append( + Violation(path=path, reason="no element matches the required schema") + ) + if max_contains is not None and match_count > max_contains: + violations.append( + Violation( + path=path, + reason=( + f"too many matching items: at most {max_contains}, " + f"got {match_count}" + ), + ) + ) +"#; + +// --------------------------------------------------------------------------- +// Shared constraint checks (P12 layer 2) +// +// One set of emitters, called by both converter directions, so a value is held +// to identical predicates on the way in and on the way out. Every check appends +// a `Violation` and keeps going; the caller raises the single aggregated +// `ValidationError` (P11). The emitted lines are deliberately unwrapped — +// `ruff format` reflows them to the 88-column budget. +// --------------------------------------------------------------------------- + +/// The module-level compiled-regex const name for a `pattern`, keyed by the +/// (normalized) pattern text so identical patterns share one compiled instance +/// per module. Stable FNV-1a hash → a valid Python identifier. +fn py_pattern_const_name(pattern: &str) -> String { + let mut hash: u64 = 0xcbf2_9ce4_8422_2325; + for byte in pattern.as_bytes() { + hash ^= u64::from(*byte); + hash = hash.wrapping_mul(0x0000_0100_0000_01b3); + } + format!("_PATTERN_{hash:016X}") } -/// Emits the `_check_contains` runtime helper: an AfterValidator asserting the -/// number of elements matching a predicate falls in -/// `[min_contains, max_contains]`, with the same reasons the property path emits. -/// Pydantic has no native `contains`; see -/// `specs/json-schema/features/contains.md` (and [`render_unique_items_helper`] -/// for why an annotation-carried check is needed). -fn render_contains_helper(output: &mut String) { - output.push_str("def _check_contains(\n"); - output.push_str(" matches: typing.Callable[[typing.Any], bool],\n"); - output.push_str(" min_contains: int,\n"); - output.push_str(" max_contains: int | None = None,\n"); - output.push_str(" bounded_min: bool = False,\n"); - output.push_str(") -> typing.Callable[[list[typing.Any]], list[typing.Any]]:\n"); - output.push_str( - " \"\"\"Builds an AfterValidator asserting how many elements match the `contains` schema.\"\"\"\n", - ); - output.push_str("\n"); - output.push_str(" def validate(value: list[typing.Any]) -> list[typing.Any]:\n"); - output.push_str(" match_count = sum(1 for element in value if matches(element))\n"); - output.push_str(" if match_count < min_contains:\n"); - output.push_str(" if bounded_min:\n"); - output.push_str( - " raise ValueError(\n f\"too few matching items: at least {min_contains}, got {match_count}\"\n )\n", - ); - output.push_str(" raise ValueError(\"no element matches the required schema\")\n"); - output.push_str(" if max_contains is not None and match_count > max_contains:\n"); - output.push_str( - " raise ValueError(\n f\"too many matching items: at most {max_contains}, got {match_count}\"\n )\n", - ); - output.push_str(" return value\n"); - output.push_str("\n"); - output.push_str(" return validate\n"); -} - -/// Emits the `_check_format` runtime helper: an AfterValidator that asserts a -/// string matches a pinned `format` regex, with an optional total-length guard -/// run **first** (short-circuit — the email order neutralizes a matcher-recursion -/// hazard). `len(value)` is the Unicode code-point count. See -/// `specs/json-schema/features/format.md`. -fn render_format_helper(output: &mut String) { - output.push_str("def _check_format(\n"); - output.push_str(" format_name: str,\n"); - output.push_str(" pattern: str,\n"); - output.push_str(" max_code_points: int | None = None,\n"); - output.push_str(") -> typing.Callable[[str], str]:\n"); - output.push_str( - " \"\"\"Builds an AfterValidator asserting a value matches a pinned `format` regex (+ optional length guard).\"\"\"\n", - ); - output.push_str("\n"); - output.push_str(" compiled = re.compile(pattern, re.ASCII)\n"); - output.push_str("\n"); - output.push_str(" def validate(value: str) -> str:\n"); - output.push_str( - " if (max_code_points is not None and len(value) > max_code_points) or compiled.search(value) is None:\n", - ); - output.push_str( - " raise ValueError(f\"must be a valid {format_name}, got {value!r}\")\n", - ); - output.push_str(" return value\n"); - output.push_str("\n"); - output.push_str(" return validate\n"); +/// The JSON rendering of a closed-value literal, for the *message* half of a +/// `const`/`enum` violation. Deliberately JSON and not a Python literal so the +/// reason reads identically to every other target (`true`, not `True`); the +/// *comparison* half uses [`python_value_literal`]. +fn py_reason_literal(value: &Value) -> String { + serde_json::to_string(value).unwrap_or_else(|_| "null".to_string()) } -fn render_model( +/// Emits `if <condition>: violations.append(Violation(path=…, reason=…))`. +/// `reason` is a complete Python string expression — usually an f-string whose +/// interpolation names the offending value. +fn render_py_violation_if( output: &mut String, - model: &PlannedJsonType, - needs_optional_non_nullable_helper: &mut bool, - needs_set_fields_helper: &mut bool, - needs_pydantic_core: &mut bool, - needs_spec_int_helper: &mut bool, -) -> Result<()> { - let schema = decode_schema(model)?; - // A `oneOf` sum-type union def is emitted as a TypeAlias by the caller. - if is_python_union_model(model) { - return Ok(()); - } - *needs_spec_int_helper |= schema_uses_integer(&schema); - let extra = match schema.additional_properties.as_ref() { - Some(Value::Bool(false)) => "forbid", - _ => "allow", + indent: &str, + condition: &str, + path_expr: &str, + reason: &str, +) { + output.push_str(indent); + output.push_str("if "); + output.push_str(condition); + output.push_str(":\n"); + output.push_str(indent); + output.push_str(" violations.append(Violation(path="); + output.push_str(path_expr); + output.push_str(", reason="); + output.push_str(reason); + output.push_str("))\n"); +} + +/// Emits the numeric-constraint predicates over `value_expr` (an in-scope +/// `int`/`float`). `value_expr` is always a bare or dotted name, never a +/// subscript, so it is safe to interpolate inside a double-quoted f-string. +fn render_py_numeric_checks( + output: &mut String, + value_expr: &str, + path_expr: &str, + schema: &Schema, + indent: &str, +) { + let is_integer = schema.ty.as_ref().and_then(Value::as_str) == Some("integer"); + let mut emit = |condition: String, reason: String| { + render_py_violation_if(output, indent, &condition, path_expr, &reason); }; - // Native deprecation marker (PEP 702) on the type; `category=None` is the - // no-runtime-warning form. See specs/json-schema/features/deprecated.md. - if schema.deprecated == Some(true) { - output.push_str( - "@typing_extensions.deprecated(\"This type is deprecated.\", category=None)\n", + if let Some(min) = &schema.minimum { + let bound = py_bound_literal(min, is_integer); + emit( + format!("{value_expr} < {bound}"), + format!("f\"must be >= {bound}, got {{{value_expr}}}\""), ); } - output.push_str("class "); - output.push_str(&model.model_name); - output.push_str("(pydantic.BaseModel):\n"); - render_python_docstring( - output, - " ", - compose_python_doc(schema.title.as_deref(), schema.description.as_deref()).as_deref(), - &[], - None, - false, - ); - output.push_str( - " model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(strict=True, populate_by_name=True, extra=", - ); - output.push_str(&python_string_literal(extra)); - output.push_str(")\n"); - - if is_python_map_model(&schema) { - // A map-shaped model has no declared fields: its members live in - // Pydantic's `model_extra`, validated by the generated model validator. - let value_schema = typed_map_value_schema(&schema)?; - if value_schema.is_some() - || schema.min_properties.is_some() - || schema.max_properties.is_some() - || schema.property_names.is_some() - { - render_map_model_methods(output, &schema, &model.model_name, value_schema.is_some()); - *needs_pydantic_core = true; - } - return Ok(()); + if let Some(max) = &schema.maximum { + let bound = py_bound_literal(max, is_integer); + emit( + format!("{value_expr} > {bound}"), + format!("f\"must be <= {bound}, got {{{value_expr}}}\""), + ); } - - let Some(properties) = &schema.properties else { - return Ok(()); - }; - if properties.is_empty() { - return Ok(()); + if let Some(min) = &schema.exclusive_minimum { + let bound = py_bound_literal(min, is_integer); + emit( + format!("{value_expr} <= {bound}"), + format!("f\"must be > {bound}, got {{{value_expr}}}\""), + ); } - - let required = schema - .required - .iter() - .flatten() - .cloned() - .collect::<BTreeSet<_>>(); - let mut optional_non_nullable_fields = BTreeSet::new(); - let mut const_fields = Vec::new(); - let mut enum_fields: Vec<(String, String, Vec<Value>)> = Vec::new(); - let mut array_validator_fields: Vec<(String, String, &Schema)> = Vec::new(); - for (json_name, property) in properties { - output.push('\n'); - let field_name = property.py_member_name(json_name); - let mut annotation = refined_annotation(property, None)?; - // Native deprecation marker (PEP 702) on the field; `category=None` is - // the no-runtime-warning form. See specs/json-schema/features/deprecated.md. - if property.deprecated == Some(true) { - annotation = format!( - "typing.Annotated[{annotation}, typing_extensions.deprecated(\"This field is deprecated.\", category=None)]" + if let Some(max) = &schema.exclusive_maximum { + let bound = py_bound_literal(max, is_integer); + emit( + format!("{value_expr} >= {bound}"), + format!("f\"must be < {bound}, got {{{value_expr}}}\""), + ); + } + if let Some(divisor) = &schema.multiple_of { + let bound = py_bound_literal(divisor, is_integer); + // An integer field divides exactly; a number field goes through + // `math.fmod` so divisibility is bit-identical across all four targets + // rather than merely close. See features/multipleOf.md. + let condition = if is_integer { + format!("{value_expr} % {bound} != 0") + } else { + format!("math.fmod({value_expr}, {bound}) != 0") + }; + emit( + condition, + format!("f\"must be a multiple of {bound}, got {{{value_expr}}}\""), + ); + } +} + +/// Emits the string predicates over `value_expr` (an in-scope `str`). +/// `len()` on a `str` is the Unicode code-point count, which is what the spec +/// means — no surrogate correction needed as in TypeScript. +fn render_py_string_checks( + output: &mut String, + value_expr: &str, + path_expr: &str, + schema: &Schema, + indent: &str, +) { + let length = format!("len({value_expr})"); + if let Some(min) = schema.min_length { + render_py_violation_if( + output, + indent, + &format!("{length} < {min}"), + path_expr, + &format!("f\"must have length >= {min}, got {{{length}}}\""), + ); + } + if let Some(max) = schema.max_length { + render_py_violation_if( + output, + indent, + &format!("{length} > {max}"), + path_expr, + &format!("f\"must have length <= {max}, got {{{length}}}\""), + ); + } + if let Some(pattern) = &schema.pattern { + render_py_pattern_check(output, value_expr, path_expr, pattern, indent); + } + if let Some(format) = &schema.format { + render_py_format_check(output, value_expr, path_expr, format, indent); + } +} + +/// Emits the `pattern` predicate. The message reads the pattern text back off +/// the compiled object (`.pattern`) rather than embedding it in the f-string, +/// which sidesteps escaping a regex inside a Python string literal entirely. +/// `re.search` is unanchored — never `match` (anchors the start) or `fullmatch`. +fn render_py_pattern_check( + output: &mut String, + value_expr: &str, + path_expr: &str, + pattern: &str, + indent: &str, +) { + let rewritten = crate::json_schema::pattern::rewrite_end_anchor(pattern, r"\Z"); + let const_name = py_pattern_const_name(&rewritten); + render_py_violation_if( + output, + indent, + &format!("{const_name}.search({value_expr}) is None"), + path_expr, + &format!("f\"must match pattern {{{const_name}.pattern}}, got {{_quote({value_expr})}}\""), + ); +} + +/// Emits the `format` predicate: the length guard (when the format has one) +/// short-circuits **before** the pinned regex, so one combined condition +/// produces a single violation naming the format and the value. +fn render_py_format_check( + output: &mut String, + value_expr: &str, + path_expr: &str, + format: &str, + indent: &str, +) { + let Some(check) = crate::json_schema::format::check_for(format) else { + return; + }; + let rewritten = crate::json_schema::pattern::rewrite_end_anchor(&check.pattern, r"\Z"); + let const_name = py_pattern_const_name(&rewritten); + let mut condition = String::new(); + if let Some(max) = check.max_code_points { + condition.push_str(&format!("len({value_expr}) > {max} or ")); + } + condition.push_str(&format!("{const_name}.search({value_expr}) is None")); + render_py_violation_if( + output, + indent, + &condition, + path_expr, + &format!( + "f\"must be a valid {}, got {{_quote({value_expr})}}\"", + check.name + ), + ); +} + +/// Emits the array predicates over `array_expr` (an in-scope `list`). +fn render_py_array_checks( + output: &mut String, + array_expr: &str, + path_expr: &str, + schema: &Schema, + indent: &str, +) -> Result<()> { + let length = format!("len({array_expr})"); + if let Some(min) = schema.min_items { + render_py_violation_if( + output, + indent, + &format!("{length} < {min}"), + path_expr, + &format!("f\"must have at least {min} items, got {{{length}}}\""), + ); + } + if let Some(max) = schema.max_items { + render_py_violation_if( + output, + indent, + &format!("{length} > {max}"), + path_expr, + &format!("f\"must have at most {max} items, got {{{length}}}\""), + ); + } + if schema.unique_items == Some(true) { + output.push_str(indent); + output.push_str(&format!( + "_check_unique_items({array_expr}, {path_expr}, violations)\n" + )); + } + if let Some(matcher) = &schema.contains { + let condition = py_matcher_condition(matcher, "element")?; + let effective_min = schema.min_contains.unwrap_or(1); + let max_arg = match schema.max_contains { + Some(max) => max.to_string(), + None => "None".to_string(), + }; + let bounded_min = if schema.min_contains.is_some() { + "True" + } else { + "False" + }; + output.push_str(indent); + output.push_str(&format!( + "_check_contains({array_expr}, lambda element: {condition}, {effective_min}, {max_arg}, {bounded_min}, {path_expr}, violations)\n" + )); + } + Ok(()) +} + +/// Emits the object member-count predicates over `count_expr` (the number of +/// distinct wire member keys). These are whole-object constraints, so the path +/// is the empty string. +fn render_py_property_count_checks( + output: &mut String, + count_expr: &str, + schema: &Schema, + indent: &str, +) { + if let Some(min) = schema.min_properties { + render_py_violation_if( + output, + indent, + &format!("{count_expr} < {min}"), + "\"\"", + &format!("f\"must have at least {min} properties, got {{{count_expr}}}\""), + ); + } + if let Some(max) = schema.max_properties { + render_py_violation_if( + output, + indent, + &format!("{count_expr} > {max}"), + "\"\"", + &format!("f\"must have at most {max} properties, got {{{count_expr}}}\""), + ); + } +} + +/// Emits the `propertyNames` key-shape predicate over `keys_expr`, applying the +/// (string-length) key subschema to each key. +fn render_py_property_name_checks( + output: &mut String, + keys_expr: &str, + subschema: &Schema, + indent: &str, +) { + if subschema.min_length.is_none() && subschema.max_length.is_none() { + return; + } + output.push_str(indent); + output.push_str(&format!("for key in {keys_expr}:\n")); + let inner = format!("{indent} "); + if let Some(min) = subschema.min_length { + render_py_violation_if( + output, + &inner, + &format!("len(key) < {min}"), + "key", + &format!( + "f\"invalid property name {{_quote(key)}}: must have length >= {min}, got {{len(key)}}\"" + ), + ); + } + if let Some(max) = subschema.max_length { + render_py_violation_if( + output, + &inner, + &format!("len(key) > {max}"), + "key", + &format!( + "f\"invalid property name {{_quote(key)}}: must have length <= {max}, got {{len(key)}}\"" + ), + ); + } +} + +/// Emits the `dependentRequired` cross-field presence predicate over the +/// presence mapping `obj_expr`: for each present trigger key, each dependent +/// key must also be present. +fn render_py_dependent_required( + output: &mut String, + obj_expr: &str, + schema: &Schema, + indent: &str, +) { + let Some(dependent_required) = &schema.dependent_required else { + return; + }; + for (trigger, deps) in dependent_required { + output.push_str(indent); + output.push_str(&format!( + "if {} in {obj_expr}:\n", + python_string_literal(trigger) + )); + let inner = format!("{indent} "); + for dep in deps { + render_py_violation_if( + output, + &inner, + &format!("{} not in {obj_expr}", python_string_literal(dep)), + &python_string_literal(dep), + &python_string_literal(&format!( + "property \"{dep}\" is required when \"{trigger}\" is present" + )), ); } - if property.needs_array_validator() { - array_validator_fields.push((json_name.clone(), field_name.clone(), property)); + } +} + +/// Emits the closed value-set membership predicate over an in-memory +/// `value_expr` for the serialize path, producing the same informative reason +/// the parse path does. `compare_exprs` are the admissible Python literals. +fn render_py_closed_value_check( + output: &mut String, + compare_exprs: &[String], + value_expr: &str, + path_expr: &str, + indent: &str, + reason: &str, +) { + // The member is typed by the closed set it belongs to, so a direct `!=` + // against each admissible value is statically dead code. Widening to `object` + // keeps the runtime check — a value mutated past the type system still has to + // fail before it reaches the wire (P12). + let membership = format!( + "typing.cast(\"object\", {value_expr}) not in ({},)", + compare_exprs.join(", ") + ); + render_py_violation_if(output, indent, &membership, path_expr, reason); +} + +/// True when a field schema carries a constraint the serialize path must +/// re-check over the in-memory value (P12, both directions). Mirrors the +/// dispatch in [`render_py_field_checks`]. +fn py_field_needs_serialize_check(schema: &Schema) -> bool { + // A nullability wrapper declares nothing itself; its non-null branch carries + // the constraints, checked under a `is not None` guard. + if let Some(non_null) = nullable_member_schema(schema) { + return py_field_needs_serialize_check(non_null); + } + if schema.const_value.is_some() || schema.enum_values.is_some() { + return true; + } + // An inline sum type: any branch that declares something is re-checked + // against the member it holds. A `$ref` branch validates through its own + // converter, so only the non-reference branches count. + if schema.one_of.is_some() { + return schema + .one_of + .iter() + .flatten() + .filter(|branch| branch.reference.is_none()) + .any(py_field_needs_serialize_check); + } + match schema.ty.as_ref().and_then(Value::as_str) { + Some("string") => { + schema.min_length.is_some() + || schema.max_length.is_some() + || schema.pattern.is_some() + || schema.format.is_some() } - if let Some(values) = &property.enum_values { - enum_fields.push((json_name.clone(), field_name.clone(), values.clone())); + Some("number") | Some("integer") => { + schema.minimum.is_some() + || schema.maximum.is_some() + || schema.exclusive_minimum.is_some() + || schema.exclusive_maximum.is_some() + || schema.multiple_of.is_some() } - let required_field = required.contains(json_name); - output.push_str(" "); - output.push_str(&field_name); - output.push_str(": "); - if let Some(const_value) = &property.const_value { - const_fields.push((json_name.clone(), field_name.clone(), const_value.clone())); - output.push_str(&annotation); - output.push_str(" = "); - let default = python_value_literal(const_value)?; - render_field_expr(output, json_name, &field_name, Some(&default), property); - } else if required_field { - output.push_str(&annotation); - output.push_str(" = "); - render_field_expr(output, json_name, &field_name, None, property); - } else if let Some(default) = &property.default { - output.push_str(&annotation); - output.push_str(" = "); - let default = python_value_literal(default)?; - render_field_expr(output, json_name, &field_name, Some(&default), property); - } else { - if !allows_null(property) { - optional_non_nullable_fields.insert(json_name.clone()); - if field_name != *json_name { - optional_non_nullable_fields.insert(field_name.clone()); - } + Some("array") => { + schema.min_items.is_some() + || schema.max_items.is_some() + || schema.unique_items == Some(true) + || schema.contains.is_some() + } + _ => false, + } +} + +/// True when a model's `to_transfer_type` must run collecting validation before +/// building the wire object: any constrained declared field, a constrained +/// typed-map member, or an object-level count/name/dependency constraint. +fn py_model_needs_serialize_validation(schema: &Schema) -> Result<bool> { + if schema.min_properties.is_some() + || schema.max_properties.is_some() + || schema.dependent_required.is_some() + || schema.property_names.is_some() + { + return Ok(true); + } + if let Some(value_schema) = typed_map_value_schema(schema)? + && py_field_needs_serialize_check(&value_schema) + { + return Ok(true); + } + if let Some(properties) = &schema.properties { + for property in properties.values() { + if py_field_needs_serialize_check(property) { + return Ok(true); } - output.push_str(&optional_annotation(&annotation)); - output.push_str(" = "); - render_field_expr(output, json_name, &field_name, Some("None"), property); } - output.push('\n'); - render_python_docstring( + } + Ok(false) +} + +/// Emits the per-value constraint checks over an in-memory `value_expr`, +/// reusing the same emitters the parse path calls. References, temporal, and +/// contentEncoding carry no check here — a nested converter validates its own +/// value, and a materialized repr re-encodes losslessly. +fn render_py_field_checks( + output: &mut String, + schema: &Schema, + value_expr: &str, + path_expr: &str, + indent: &str, +) -> Result<()> { + // A nullability wrapper's constraints live on its non-null branch; the + // caller has already guarded the value against `None`. + if let Some(non_null) = nullable_member_schema(schema) { + return render_py_field_checks(output, non_null, value_expr, path_expr, indent); + } + // An inline sum type narrows to the branch it holds and runs that branch's + // own checks. The branches that matter here are the non-object ones, which + // need no `$ref` resolution; an object branch validates through its own + // converter instead. + if is_py_union(schema) { + if let Some(union) = classify_py_union(schema, &[])? { + render_py_union_value_checks(output, &union, value_expr, path_expr, indent)?; + } + return Ok(()); + } + if let Some(const_value) = &schema.const_value { + let literal = python_value_literal(const_value)?; + let reason = + python_string_literal(&format!("must equal {}", py_reason_literal(const_value))); + render_py_closed_value_check( output, - " ", - compose_python_doc(property.title.as_deref(), property.description.as_deref()) - .as_deref(), - &[], - None, - false, + std::slice::from_ref(&literal), + value_expr, + path_expr, + indent, + &reason, ); + return Ok(()); + } + if let Some(values) = &schema.enum_values { + let literals = values + .iter() + .map(python_value_literal) + .collect::<Result<Vec<_>>>()?; + let reason = py_enum_reason(values, value_expr); + render_py_closed_value_check(output, &literals, value_expr, path_expr, indent, &reason); + return Ok(()); + } + match schema.ty.as_ref().and_then(Value::as_str) { + Some("string") => render_py_string_checks(output, value_expr, path_expr, schema, indent), + Some("number") | Some("integer") => { + render_py_numeric_checks(output, value_expr, path_expr, schema, indent) + } + Some("array") => render_py_array_checks(output, value_expr, path_expr, schema, indent)?, + _ => {} } - render_const_validators(output, &const_fields)?; - render_enum_validators(output, &enum_fields)?; - render_array_validators(output, &array_validator_fields)?; - render_object_constraints_validator(output, &schema); - render_optional_non_nullable_validator(output, &optional_non_nullable_fields); - *needs_optional_non_nullable_helper |= !optional_non_nullable_fields.is_empty(); - *needs_pydantic_core |= !optional_non_nullable_fields.is_empty() - || !const_fields.is_empty() - || !enum_fields.is_empty() - || !array_validator_fields.is_empty() - || schema.has_object_count_or_dependency(); - render_set_fields_serializer(output); - *needs_set_fields_helper = true; Ok(()) } -fn render_spec_int_helper(output: &mut String) { - output.push_str("_INTEGER_CAP = (1 << 53) - 1\n\n\n"); - output.push_str("def _parse_spec_integer(value: object) -> int:\n"); - output.push_str(" if isinstance(value, bool):\n"); - output.push_str(" raise ValueError(\"expected integer, got boolean\")\n"); - output.push_str(" if isinstance(value, int):\n"); - output.push_str(" out = value\n"); - output.push_str(" elif isinstance(value, float):\n"); - output.push_str(" if not value.is_integer():\n"); - output.push_str( - " raise ValueError(\"number has a fractional part; not an integer\")\n", - ); - output.push_str(" out = int(value)\n"); - output.push_str(" else:\n"); - output - .push_str(" raise ValueError(f\"expected integer, got {type(value).__name__}\")\n"); - output.push_str(" if abs(out) > _INTEGER_CAP:\n"); - output.push_str(" raise ValueError(\"integer exceeds +/-(2**53-1) cap\")\n"); - output.push_str(" return out\n\n\n"); - output.push_str( - "SpecInt: typing.TypeAlias = typing.Annotated[int, pydantic.functional_validators.BeforeValidator(_parse_spec_integer)]\n", - ); +// --------------------------------------------------------------------------- +// Reason-string composition +// +// Every reason that quotes a JSON value is an f-string delimited by single +// quotes, so the double quotes JSON (and therefore every other target) reports +// offending values in can appear verbatim. A double-quoted f-string could not +// carry them: nesting the delimiter inside an f-string only became legal in +// Python 3.12, and the emitted floor is 3.10. +// --------------------------------------------------------------------------- + +/// Escapes text for the *literal* portion of a single-quoted Python f-string. +/// Backslash escapes are legal there on every supported version (only the +/// expression portion forbids them before 3.12). +fn py_fstring_text(text: &str) -> String { + text.replace('\\', "\\\\") + .replace('\'', "\\'") + .replace('{', "{{") + .replace('}', "}}") +} + +/// The `enum` membership reason, naming the admissible set in its JSON form and +/// the offending value through `_quote`. +fn py_enum_reason(values: &[Value], value_expr: &str) -> String { + let rendered = values + .iter() + .map(py_reason_literal) + .collect::<Vec<_>>() + .join(", "); + format!( + "f'must be one of [{}], got {{_quote({value_expr})}}'", + py_fstring_text(&rendered) + ) +} + +/// The text of a Python double-quoted string literal, when it carries no escape +/// that would have to be re-escaped for an f-string. +fn py_literal_text(expr: &str) -> Option<String> { + let inner = expr.strip_prefix('"')?.strip_suffix('"')?; + if inner.contains('\\') || inner.contains('"') { + return None; + } + Some(inner.to_string()) +} + +/// The path expression for an array element: `tags[0]`, or `<parent>[0]` when the +/// parent path is itself a runtime expression. The parent is interpolated by +/// *name* rather than nested as an f-string, because a nested f-string is 3.12+. +fn py_indexed_path(path_expr: &str, index_var: &str) -> String { + match py_literal_text(path_expr) { + Some(text) => format!("f'{}[{{{index_var}}}]'", py_fstring_text(&text)), + None => format!("f'{{{path_expr}}}[{{{index_var}}}]'"), + } +} + +// --------------------------------------------------------------------------- +// Materialized value types +// --------------------------------------------------------------------------- + +/// The native Python type a materialized temporal `format` field carries. +fn python_temporal_type(kind: crate::json_schema::format::TemporalKind) -> &'static str { + use crate::json_schema::format::TemporalKind; + match kind { + TemporalKind::DateTime => "datetime.datetime", + TemporalKind::Date => "datetime.date", + TemporalKind::Time => "datetime.time", + TemporalKind::Duration => "datetime.timedelta", + } +} + +fn python_temporal_parse_fn(kind: crate::json_schema::format::TemporalKind) -> &'static str { + use crate::json_schema::format::TemporalKind; + match kind { + TemporalKind::DateTime => "_parse_date_time", + TemporalKind::Date => "_parse_date", + TemporalKind::Time => "_parse_time", + TemporalKind::Duration => "_parse_duration", + } +} + +fn python_temporal_format_fn(kind: crate::json_schema::format::TemporalKind) -> &'static str { + use crate::json_schema::format::TemporalKind; + match kind { + TemporalKind::DateTime => "_format_date_time", + TemporalKind::Date => "_format_date", + TemporalKind::Time => "_format_time", + TemporalKind::Duration => "_format_duration", + } +} + +fn python_content_encoding_parse_fn( + encoding: crate::json_schema::content_encoding::Encoding, +) -> &'static str { + use crate::json_schema::content_encoding::Encoding; + match encoding { + Encoding::Base64 => "_parse_base64", + Encoding::Base64Url => "_parse_base64url", + } +} + +fn python_content_encoding_format_fn( + encoding: crate::json_schema::content_encoding::Encoding, +) -> &'static str { + use crate::json_schema::content_encoding::Encoding; + match encoding { + Encoding::Base64 => "_format_base64", + Encoding::Base64Url => "_format_base64url", + } +} + +// --------------------------------------------------------------------------- +// Module-level constants +// --------------------------------------------------------------------------- + +/// Emits the advisory `DEFAULT_<FIELD>` constants. A schema `default` is not the +/// dataclass field default — the member is encoded like any other optional one so +/// the wire stays byte-identical — and the consumer applies the constant on read +/// (`x if x is not None else DEFAULT_X`), exactly as in TypeScript. See +/// `specs/json-schema/features/default.md`. +fn render_default_constants(output: &mut String, models: &[&PlannedJsonType]) -> Result<()> { + let mut constants = Vec::new(); + for model in models { + let schema = decode_schema(model)?; + let Some(properties) = &schema.properties else { + continue; + }; + for (json_name, property) in properties { + let Some(default) = &property.default else { + continue; + }; + constants.push(( + default_const_name(&model.model_name, json_name, models)?, + python_value_literal(default)?, + )); + } + } + if constants.is_empty() { + return Ok(()); + } + push_section(output); + for (name, value) in constants { + output.push_str(&name); + output.push_str(" = "); + output.push_str(&value); + output.push('\n'); + } + Ok(()) +} + +/// `DEFAULT_<FIELD>` when exactly one model in the module declares a defaulted +/// field of that JSON name, else `DEFAULT_<MODEL>_<FIELD>`. The loader replicates +/// this rule to reserve the name in the module namespace (P15), so the two must +/// stay in step. +fn default_const_name( + model_name: &str, + field_name: &str, + models: &[&PlannedJsonType], +) -> Result<String> { + let field_count = models + .iter() + .map(|model| decode_schema(model)) + .collect::<Result<Vec<_>>>()? + .into_iter() + .filter(|schema| { + schema.properties.as_ref().is_some_and(|properties| { + properties + .get(field_name) + .is_some_and(|property| property.default.is_some()) + }) + }) + .count(); + Ok(if field_count == 1 { + format!("DEFAULT_{}", field_name.to_shouty_snake_case()) + } else { + format!( + "DEFAULT_{}_{}", + model_name.to_shouty_snake_case(), + field_name.to_shouty_snake_case() + ) + }) +} + +/// Emits one compiled regex per distinct `pattern` / `format` source across the +/// module's schemas, so a check reads a shared pre-compiled object rather than +/// recompiling per call. `re.ASCII` pins the character classes the loader +/// normalized to their ASCII meaning. +fn render_pattern_regexes(output: &mut String, models: &[&PlannedJsonType]) -> Result<()> { + let mut patterns = Vec::new(); + for model in models { + collect_schema_patterns(&decode_schema(model)?, &mut patterns); + } + let mut seen = BTreeSet::new(); + let mut emitted = false; + for pattern in patterns { + let name = py_pattern_const_name(&pattern); + if !seen.insert(name.clone()) { + continue; + } + if !emitted { + push_section(output); + emitted = true; + } + output.push_str(&format!( + "{name} = re.compile({}, re.ASCII)\n", + python_string_literal(&pattern) + )); + } + Ok(()) +} + +/// Collects every compiled-regex source the module's checks reference, in each +/// string position one can occur: a declared property, an array element at any +/// depth, a typed map's member, a key-shape subschema, and a nullability +/// wrapper's branch. The stored form is the emitted one (`$` already rewritten to +/// `\Z`), so the const name matches what the check emitters compute. +fn collect_schema_patterns(schema: &Schema, patterns: &mut Vec<String>) { + if let Some(pattern) = &schema.pattern { + patterns.push(crate::json_schema::pattern::rewrite_end_anchor( + pattern, r"\Z", + )); + } + if let Some(format) = &schema.format + && let Some(check) = crate::json_schema::format::check_for(format) + { + patterns.push(crate::json_schema::pattern::rewrite_end_anchor( + &check.pattern, + r"\Z", + )); + } + for property in schema + .properties + .iter() + .flat_map(|entries| entries.values()) + { + collect_schema_patterns(property, patterns); + } + if let Some(items) = &schema.items { + collect_schema_patterns(items, patterns); + } + for branch in schema.one_of.iter().flatten() { + collect_schema_patterns(branch, patterns); + } + if let Some(names) = &schema.property_names { + collect_schema_patterns(names, patterns); + } + if let Some(Value::Object(members)) = &schema.additional_properties + && let Ok(member) = serde_json::from_value::<Schema>(Value::Object(members.clone())) + { + collect_schema_patterns(&member, patterns); + } +} + +/// Emits the module-level declared-key set an open object splits its catch-all +/// on, mirroring TypeScript's `<MODEL>_DECLARED`. +fn render_declared_field_set(output: &mut String, model: &PlannedJsonType, schema: &Schema) { + let fields = schema + .properties + .as_ref() + .map(|properties| { + properties + .keys() + .map(|field| python_string_literal(field)) + .collect::<Vec<_>>() + }) + .unwrap_or_default(); + output.push_str(&declared_fields_const_name(&model.model_name)); + output.push_str(": frozenset[str] = "); + if fields.is_empty() { + output.push_str("frozenset()\n"); + } else { + output.push_str("frozenset({"); + output.push_str(&fields.join(", ")); + output.push_str("})\n"); + } +} + +// --------------------------------------------------------------------------- +// Object shape classification +// --------------------------------------------------------------------------- + +fn required_fields(schema: &Schema) -> BTreeSet<String> { + schema.required.iter().flatten().cloned().collect() +} + +/// True when a declared-property object stays open to unknown members, which is +/// what gives it an explicit `additional_properties` catch-all. +fn is_open_object(schema: &Schema) -> bool { + schema.ty.as_ref().and_then(Value::as_str) == Some("object") + && schema + .properties + .as_ref() + .is_some_and(|properties| !properties.is_empty()) + && schema.additional_properties.as_ref() != Some(&Value::Bool(false)) +} + +/// The annotation of an open object's catch-all member. +fn additional_properties_annotation(schema: &Schema) -> Result<String> { + match &schema.additional_properties { + Some(Value::Object(members)) => { + let member: Schema = + serde_json::from_value(Value::Object(members.clone())).map_err(|error| { + Error::InvalidJsonSchema { + path: PathBuf::from("<json-generator>"), + reason: format!("failed to read `additionalProperties`: {error}"), + } + })?; + annotation(&member) + } + _ => Ok("typing.Any".to_string()), + } +} + +/// A map-shaped model — no declared `properties`, members governed by +/// `additionalProperties` — emitted as a dataclass whose only member is the +/// catch-all (specs/json-schema/features/additionalProperties.md). +#[derive(Debug, Clone)] +struct PyMapShape { + /// The declared member schema; `None` for free-form members, which are + /// carried verbatim as `typing.Any`. + value_schema: Option<Schema>, + value_annotation: String, +} + +fn py_map_shape(schema: &Schema) -> Result<Option<PyMapShape>> { + if !is_python_map_model(schema) { + return Ok(None); + } + if let Some(value_schema) = typed_map_value_schema(schema)? { + let value_annotation = annotation(&value_schema)?; + return Ok(Some(PyMapShape { + value_schema: Some(value_schema), + value_annotation, + })); + } + Ok(Some(PyMapShape { + value_schema: None, + value_annotation: "typing.Any".to_string(), + })) +} + +/// The annotation of a model's catch-all member, whichever open shape it is. +fn catch_all_annotation(schema: &Schema) -> Result<String> { + match py_map_shape(schema)? { + Some(shape) => Ok(shape.value_annotation), + None => additional_properties_annotation(schema), + } +} + +// --------------------------------------------------------------------------- +// `oneOf` closed sum types (specs/json-schema/features/oneOf.md) +// --------------------------------------------------------------------------- + +/// The ±(2^53−1) spec-integer cap, inlined into the union's integer selector so +/// the branch is chosen by exactly the predicate a declared integer field is +/// parsed with. +const PY_INTEGER_CAP: &str = "9007199254740991"; + +/// The JSON token a union branch is selected by. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum PyToken { + Object, + Array, + String, + Integer, + Number, + Boolean, +} + +impl PyToken { + /// The narrowing guard over a raw wire value. `expr` is always a bare or + /// dotted name, so repeating it is free of side effects. + fn wire_guard(self, expr: &str) -> String { + match self { + Self::Object => format!("isinstance({expr}, dict)"), + Self::Array => format!("isinstance({expr}, list)"), + Self::String => format!("isinstance({expr}, str)"), + Self::Boolean => format!("isinstance({expr}, bool)"), + // `bool` is an `int` subclass, so it is excluded before the numeric + // test or `True` would select a numeric branch. + Self::Integer => format!( + "not isinstance({expr}, bool) and isinstance({expr}, (int, float)) and abs({expr}) <= {PY_INTEGER_CAP} and float({expr}).is_integer()" + ), + Self::Number => { + format!("not isinstance({expr}, bool) and isinstance({expr}, (int, float))") + } + } + } + + /// The narrowing guard over the in-memory value, which selects the same + /// branch on the way out. An integer member is an `int` by then, so the wire + /// form's `1.0`-accepting predicate would be wrong here. + fn memory_guard(self, expr: &str) -> String { + match self { + Self::Integer => format!("not isinstance({expr}, bool) and isinstance({expr}, int)"), + other => other.wire_guard(expr), + } + } +} + +/// A member of a Python union (a native `A | B | …` annotation). +#[derive(Debug, Clone)] +struct PyUnionVariant { + /// The member's own annotation, so a `const`/`enum` branch narrows to the + /// closed literal set it declares rather than the wider primitive. + py_type: String, + is_object: bool, + /// The referenced model's converter expression, for a `$ref` object branch. + converter: Option<String>, + /// The referenced union's free-function base, for a `$ref` at a named union. + union_base: Option<String>, + /// The runtime type an in-memory member is recognized by, when the token + /// alone cannot say so (a model class, a materialized temporal, `bytes`). + memory_type: Option<String>, + /// The runtime helpers a materialized branch converts through. + parse_fn: Option<String>, + serialize_fn: Option<String>, + discriminant_value: Option<Value>, + token: PyToken, + /// True when the branch's declared type is narrower than the JSON token it is + /// selected by (a `const`/`enum` literal set, a typed array), so the selected + /// value has to be cast to it. + narrowed: bool, + /// The name this branch is reported under in `expected one of: …`. + label: String, + /// The branch's own schema, whose constraints the narrowed value is held to. + schema: Schema, +} + +impl PyUnionVariant { + fn memory_guard(&self, expr: &str) -> String { + match &self.memory_type { + Some(ty) => format!("isinstance({expr}, {ty})"), + None => self.token.memory_guard(expr), + } + } + + fn serialize_expr(&self, value_expr: &str) -> String { + if let Some(converter) = &self.converter { + return format!("{converter}.to_transfer_type({value_expr})"); + } + if let Some(base) = &self.union_base { + return format!("{}({value_expr})", union_serialize_fn(base)); + } + if let Some(function) = &self.serialize_fn { + return format!("{function}({value_expr})"); + } + value_expr.to_string() + } + + fn needs_transform(&self) -> bool { + self.converter.is_some() || self.union_base.is_some() || self.serialize_fn.is_some() + } +} + +#[derive(Debug, Clone)] +struct PyUnion { + nullable: bool, + discriminant: Option<String>, + variants: Vec<PyUnionVariant>, +} + +impl PyUnion { + /// The admissible branch names, as `expected one of: …` reports them. + fn admissible(&self) -> String { + self.variants + .iter() + .map(|variant| variant.label.clone()) + .collect::<Vec<_>>() + .join(", ") + } + + fn needs_serializer(&self) -> bool { + self.variants + .iter() + .any(|variant| variant.needs_transform()) + } +} + +/// True when a schema is a `oneOf` sum type (two or more non-null branches), +/// rather than the degenerate nullability pattern. +fn is_py_union(schema: &Schema) -> bool { + schema.one_of.as_ref().is_some_and(|branches| { + branches + .iter() + .filter(|branch| !schema_type_includes(branch, "null")) + .count() + >= 2 + }) +} + +fn py_discriminator_const(property: &Schema) -> Option<Value> { + if let Some(value) = &property.const_value { + return Some(value.clone()); + } + if let Some(values) = &property.enum_values + && values.len() == 1 + { + return Some(values[0].clone()); + } + None +} + +fn py_branch_discriminator_tags(object: &Schema) -> BTreeMap<String, Value> { + let required = required_fields(object); + let mut tags = BTreeMap::new(); + if let Some(properties) = &object.properties { + for (name, property) in properties { + if required.contains(name) + && let Some(value) = py_discriminator_const(property) + { + tags.insert(name.clone(), value); + } + } + } + tags +} + +fn find_ref_model<'a>( + reference: &str, + models: &'a [&PlannedJsonType], +) -> Option<&'a PlannedJsonType> { + let target = reference_model_name(reference); + models + .iter() + .copied() + .find(|model| model.model_name == target || model.full_name == target) +} + +/// Classifies a `oneOf` schema into a Python union, or `None` for the degenerate +/// nullability pattern. +fn classify_py_union(schema: &Schema, models: &[&PlannedJsonType]) -> Result<Option<PyUnion>> { + if !is_py_union(schema) { + return Ok(None); + } + let Some(branches) = schema.one_of.as_ref() else { + return Ok(None); + }; + let mut nullable = false; + let mut variants: Vec<PyUnionVariant> = Vec::new(); + let mut object_schemas: Vec<Schema> = Vec::new(); + for branch in branches { + let resolved = if let Some(reference) = &branch.reference { + find_ref_model(reference, models) + .and_then(|model| decode_schema(model).ok()) + .unwrap_or_else(|| branch.clone()) + } else { + branch.clone() + }; + let scalar = |token: PyToken, primitive: &str, label: &str| -> PyUnionVariant { + let py_type = annotation(&resolved).unwrap_or_else(|_| primitive.to_string()); + let py_type_is_narrowed = py_type != primitive; + let (memory_type, parse_fn, serialize_fn) = + if let Some(kind) = temporal_kind_direct(&resolved) { + ( + Some(python_temporal_type(kind).to_string()), + Some(python_temporal_parse_fn(kind).to_string()), + Some(python_temporal_format_fn(kind).to_string()), + ) + } else if let Some(encoding) = content_encoding_direct(&resolved) { + ( + Some("bytes".to_string()), + Some(python_content_encoding_parse_fn(encoding).to_string()), + Some(python_content_encoding_format_fn(encoding).to_string()), + ) + } else { + (None, None, None) + }; + PyUnionVariant { + py_type, + is_object: false, + converter: None, + union_base: None, + memory_type, + parse_fn, + serialize_fn, + discriminant_value: None, + token, + narrowed: py_type_is_narrowed, + label: label.to_string(), + schema: resolved.clone(), + } + }; + match resolved.ty.as_ref().and_then(Value::as_str) { + Some("null") => nullable = true, + Some("object") => { + // A `$ref` branch is the named model (converted by its own + // converter); an inline branch is the free-form object + // (loader-enforced), carried structurally as a mapping — Python + // needs no synthesized name to narrow on the object token. + let (py_type, converter, union_base, memory_type, label) = match &branch.reference { + Some(reference) => { + let name = reference_model_name(reference); + if is_union_type_name(&name) { + ( + name.clone(), + None, + Some(union_fn_base(&name)), + Some(name.clone()), + name, + ) + } else { + ( + name.clone(), + Some(converter_expr(&name)), + None, + Some(name.clone()), + name, + ) + } + } + None => ( + object_annotation(&resolved)?, + None, + None, + None, + "object".to_string(), + ), + }; + object_schemas.push(resolved.clone()); + variants.push(PyUnionVariant { + py_type, + is_object: true, + converter, + union_base, + memory_type, + parse_fn: None, + serialize_fn: None, + discriminant_value: None, + token: PyToken::Object, + narrowed: true, + label, + schema: resolved.clone(), + }); + } + Some("string") => variants.push(scalar(PyToken::String, "str", "string")), + Some("integer") => variants.push(scalar(PyToken::Integer, "int", "integer")), + Some("number") => variants.push(scalar(PyToken::Number, "float", "number")), + Some("boolean") => variants.push(scalar(PyToken::Boolean, "bool", "boolean")), + Some("array") => { + let py_type = + annotation(&resolved).unwrap_or_else(|_| "list[typing.Any]".to_string()); + variants.push(PyUnionVariant { + py_type: py_type.clone(), + is_object: false, + converter: None, + union_base: None, + memory_type: None, + parse_fn: None, + serialize_fn: None, + discriminant_value: None, + token: PyToken::Array, + narrowed: true, + // An array branch has no definition to take a name from, so + // it reports under Python's own type spelling. + label: py_type, + schema: resolved.clone(), + }); + } + _ => {} + } + } + + let mut discriminant = None; + if object_schemas.len() >= 2 { + let mut shared: Option<BTreeMap<String, Value>> = None; + for object in &object_schemas { + let tags = py_branch_discriminator_tags(object); + shared = Some(match shared { + None => tags, + Some(existing) => existing + .into_iter() + .filter(|(name, _)| tags.contains_key(name)) + .collect(), + }); + } + let shared = shared.unwrap_or_default(); + let name = shared + .keys() + .find(|name| { + let values: Vec<Value> = object_schemas + .iter() + .filter_map(|object| py_branch_discriminator_tags(object).get(*name).cloned()) + .collect(); + values + .iter() + .enumerate() + .all(|(index, value)| !values[..index].iter().any(|existing| existing == value)) + }) + .cloned(); + if let Some(name) = &name { + let mut object_index = 0; + for variant in variants.iter_mut().filter(|variant| variant.is_object) { + variant.discriminant_value = + py_branch_discriminator_tags(&object_schemas[object_index]) + .get(name) + .cloned(); + object_index += 1; + } + } + discriminant = name; + } + + Ok(Some(PyUnion { + nullable, + discriminant, + variants, + })) +} + +/// Emits the body of a union's `_<base>_from_transfer_type`: token / +/// discriminant selection, returning the selected member or `None` after +/// recording why nothing matched. +fn render_py_union_parse( + output: &mut String, + union: &PyUnion, + value_expr: &str, + path_expr: &str, + indent: &str, +) -> Result<()> { + let object_variants: Vec<&PyUnionVariant> = union + .variants + .iter() + .filter(|variant| variant.is_object) + .collect(); + if !object_variants.is_empty() { + output.push_str(indent); + output.push_str(&format!("if isinstance({value_expr}, dict):\n")); + let inner = format!("{indent} "); + if let Some(discriminant) = &union.discriminant { + output.push_str(&inner); + output.push_str(&format!( + "tagged = typing.cast(\"dict[str, typing.Any]\", {value_expr})\n" + )); + output.push_str(&inner); + output.push_str(&format!( + "tag = tagged.get({})\n", + python_string_literal(discriminant) + )); + let mut values_display = Vec::new(); + for variant in &object_variants { + let Some(value) = &variant.discriminant_value else { + continue; + }; + values_display.push(py_reason_literal(value)); + output.push_str(&inner); + output.push_str(&format!("if tag == {}:\n", python_value_literal(value)?)); + render_py_union_object_branch( + output, + variant, + value_expr, + path_expr, + &format!("{inner} "), + ); + } + output.push_str(&inner); + output.push_str(&format!( + "violations.append(Violation(path={path_expr}, reason=f'unknown discriminator {} {{tag}}: expected one of [{}]'))\n", + py_fstring_text(discriminant), + py_fstring_text(&values_display.join(", ")) + )); + output.push_str(&inner); + output.push_str("return None\n"); + } else { + let variant = object_variants[0]; + render_py_union_object_branch(output, variant, value_expr, path_expr, &inner); + } + } + + for variant in union.variants.iter().filter(|variant| !variant.is_object) { + output.push_str(indent); + output.push_str(&format!("if {}:\n", variant.token.wire_guard(value_expr))); + let inner = format!("{indent} "); + // The token has selected the branch; the value is now held to everything + // the branch declares (P12 — the same predicates a property of that type + // runs). A branch whose declared type is narrower than its token is cast + // to it once, and the checks run over the narrowed name. + let selected = match (&variant.parse_fn, variant.token) { + // A materialized branch parses through its runtime helper; the token + // guard has already established the wire is a string. + (Some(parse_fn), _) => { + output.push_str(&inner); + output.push_str(&format!( + "parsed = {parse_fn}({value_expr}, {path_expr}, violations)\n" + )); + "parsed".to_string() + } + // The token accepted `1.0` as an integer, so the member is normalized + // before its own bounds are checked. + (None, PyToken::Integer) => { + output.push_str(&inner); + output.push_str(&format!("number = int({value_expr})\n")); + render_py_field_checks(output, &variant.schema, "number", path_expr, &inner)?; + if variant.narrowed { + output.push_str(&inner); + output.push_str(&format!( + "narrowed = typing.cast({}, number)\n", + python_string_literal(&variant.py_type) + )); + "narrowed".to_string() + } else { + "number".to_string() + } + } + (None, PyToken::Array) => { + output.push_str(&inner); + output.push_str(&format!( + "items = typing.cast({}, {value_expr})\n", + python_string_literal(&variant.py_type) + )); + render_py_field_checks(output, &variant.schema, "items", path_expr, &inner)?; + "items".to_string() + } + _ if variant.narrowed => { + output.push_str(&inner); + output.push_str(&format!( + "narrowed = typing.cast({}, {value_expr})\n", + python_string_literal(&variant.py_type) + )); + render_py_field_checks(output, &variant.schema, "narrowed", path_expr, &inner)?; + "narrowed".to_string() + } + _ => { + render_py_field_checks(output, &variant.schema, value_expr, path_expr, &inner)?; + value_expr.to_string() + } + }; + output.push_str(&inner); + output.push_str(&format!("return {selected}\n")); + } + + if union.nullable { + output.push_str(indent); + output.push_str(&format!("if {value_expr} is None:\n")); + output.push_str(indent); + output.push_str(" return None\n"); + } + + output.push_str(indent); + output.push_str(&format!( + "violations.append(Violation(path={path_expr}, reason={}))\n", + python_string_literal(&format!("expected one of: {}", union.admissible())) + )); + output.push_str(indent); + output.push_str("return None\n"); + Ok(()) +} + +/// Emits the object-branch arm of a union's parse: the member converts through +/// its own converter (or the nested union's dispatcher), with its violations +/// re-pathed under the union's path. +fn render_py_union_object_branch( + output: &mut String, + variant: &PyUnionVariant, + value_expr: &str, + path_expr: &str, + indent: &str, +) { + if let Some(base) = &variant.union_base { + output.push_str(indent); + output.push_str(&format!( + "return {}({value_expr}, {path_expr}, violations)\n", + union_parse_fn(base) + )); + return; + } + let Some(converter) = &variant.converter else { + // A free-form object branch: the wire object is already the member. + output.push_str(indent); + output.push_str(&format!( + "return typing.cast({}, {value_expr})\n", + python_string_literal(&variant.py_type) + )); + return; + }; + output.push_str(indent); + output.push_str("try:\n"); + output.push_str(indent); + output.push_str(&format!( + " return {converter}.from_transfer_type({value_expr}, {})\n", + variant.py_type + )); + output.push_str(indent); + output.push_str("except ValidationError as error:\n"); + output.push_str(indent); + output.push_str(&format!(" _collect(violations, {path_expr}, error)\n")); + output.push_str(indent); + output.push_str(" return None\n"); +} + +/// Emits the constraint checks a union's **in-memory** value is held to, narrowed +/// to the branch it holds: one guarded block per non-object branch that declares +/// anything (P12). Object branches carry their own validation in their model's +/// converter, so they contribute no block. +fn render_py_union_value_checks( + output: &mut String, + union: &PyUnion, + value_expr: &str, + path_expr: &str, + indent: &str, +) -> Result<()> { + for variant in union.variants.iter().filter(|variant| !variant.is_object) { + let mut body = String::new(); + render_py_field_checks( + &mut body, + &variant.schema, + value_expr, + path_expr, + &format!("{indent} "), + )?; + if body.is_empty() { + continue; + } + output.push_str(indent); + output.push_str(&format!("if {}:\n", variant.memory_guard(value_expr))); + output.push_str(&body); + } + Ok(()) +} + +/// Emits the dispatch of a union's `_<base>_to_transfer_type`. Unlike the parse +/// side, which is handed an untyped wire value, this direction receives the +/// declared union, so each guard *narrows* it and the final branch is whatever is +/// left over. Guarding that one as well would be provably redundant — and would +/// put an unreachable `expected one of` raise behind it — so it is emitted as the +/// fallthrough instead. When no branch transforms its value at all the dispatch +/// collapses to returning it unchanged. +fn render_py_union_serialize(output: &mut String, union: &PyUnion, value_expr: &str, indent: &str) { + if union.nullable { + output.push_str(indent); + output.push_str(&format!("if {value_expr} is None:\n")); + output.push_str(indent); + output.push_str(" return None\n"); + } + let Some((last, leading)) = union.variants.split_last() else { + output.push_str(indent); + output.push_str(&format!("return {value_expr}\n")); + return; + }; + if union.needs_serializer() { + for variant in leading { + output.push_str(indent); + output.push_str(&format!("if {}:\n", variant.memory_guard(value_expr))); + output.push_str(indent); + output.push_str(&format!( + " return {}\n", + variant.serialize_expr(value_expr) + )); + } + } + output.push_str(indent); + output.push_str(&format!("return {}\n", last.serialize_expr(value_expr))); +} + +/// Emits the module-private free functions a union's conversion lives in. A +/// `typing.TypeAlias` cannot be decorated and `type[A | B]` is not a valid +/// annotation, so a union is never registered with the SDK; it is only ever +/// reached from an enclosing model's converter. +fn render_union_transfer_functions(output: &mut String, models: &[&PlannedJsonType]) -> Result<()> { + for model in models { + let schema = decode_schema(model)?; + if !is_python_union_model(model) { + continue; + } + let Some(union) = classify_py_union(&schema, models)? else { + continue; + }; + let base = union_fn_base(&model.model_name); + render_union_parse_function(output, &base, &model.model_name, &union)?; + // A named union has no enclosing property to run its branch checks, so + // it collects its own and raises the one aggregated error (P11/P12). + render_union_serialize_function(output, &base, &model.model_name, &union, true)?; + } + for model in models { + let schema = decode_schema(model)?; + let Some(properties) = &schema.properties else { + continue; + }; + for (json_name, property) in properties { + let Some(union) = classify_py_union(property, models)? else { + continue; + }; + let base = inline_union_fn_base(&model.model_name, json_name); + let member_type = annotation(property)?; + render_union_parse_function(output, &base, &member_type, &union)?; + // The enclosing property already runs the branch checks on the way + // out, so the serializer is pure dispatch — and is only needed when + // some member's in-memory form differs from its wire form. + if union.needs_serializer() { + render_union_serialize_function(output, &base, &member_type, &union, false)?; + } + } + } + Ok(()) +} + +fn render_union_parse_function( + output: &mut String, + base: &str, + member_type: &str, + union: &PyUnion, +) -> Result<()> { + push_section(output); + output.push_str(&format!( + "def {}(\n value: typing.Any, path: str, violations: list[Violation]\n) -> {}:\n", + union_parse_fn(base), + optional_annotation(member_type) + )); + render_py_union_parse(output, union, "value", "path", " ") +} + +fn render_union_serialize_function( + output: &mut String, + base: &str, + member_type: &str, + union: &PyUnion, + with_checks: bool, +) -> Result<()> { + push_section(output); + output.push_str(&format!( + "def {}(value: {member_type}) -> typing.Any:\n", + union_serialize_fn(base) + )); + if with_checks { + let mut checks = String::new(); + render_py_union_value_checks(&mut checks, union, "value", "\"\"", " ")?; + if !checks.is_empty() { + output.push_str(" violations: list[Violation] = []\n"); + output.push_str(&checks); + output.push_str(" if violations:\n"); + output.push_str(" raise ValidationError(violations)\n"); + } + } + render_py_union_serialize(output, union, "value", " "); + Ok(()) +} + +// --------------------------------------------------------------------------- +// Model + converter emission +// --------------------------------------------------------------------------- + +/// Emits the dataclass: a plain data carrier with no conversion of its own, +/// registered with the SDK so the **default** data converter finds its +/// converter. Decorator order is fixed — `transfer_type_convertible` above +/// `dataclass`, because `slots=True` returns a new class object and the attribute +/// must land on the final one. +fn render_model_dataclass( + output: &mut String, + model: &PlannedJsonType, + schema: &Schema, +) -> Result<()> { + // Native deprecation marker (PEP 702) on the type; `category=None` is the + // no-runtime-warning form. See specs/json-schema/features/deprecated.md. + if schema.deprecated == Some(true) { + output.push_str( + "@typing_extensions.deprecated(\"This type is deprecated.\", category=None)\n", + ); + } + output.push_str(&format!( + "@_transfer_type_convertible({})\n", + converter_class_name(&model.model_name) + )); + // Every member is keyword-only: JSON Schema interleaves required and + // optional properties freely, so a positional order is never safe. + output.push_str("@dataclasses.dataclass(slots=True, kw_only=True)\n"); + output.push_str("class "); + output.push_str(&model.model_name); + output.push_str(":\n"); + render_python_docstring( + output, + " ", + compose_python_doc(schema.title.as_deref(), schema.description.as_deref()).as_deref(), + &[], + None, + false, + ); + + let required = required_fields(schema); + let mut members = 0usize; + if let Some(properties) = &schema.properties { + for (json_name, property) in properties { + output.push('\n'); + members += 1; + let field_name = property.py_member_name(json_name); + let mut member_type = annotation(property)?; + // A PEP 702 decorator cannot apply to a field, so a deprecated + // member carries the marker inside its annotation instead. + if property.deprecated == Some(true) { + member_type = format!( + "typing.Annotated[{member_type}, typing_extensions.deprecated(\"This field is deprecated.\", category=None)]" + ); + } + output.push_str(" "); + output.push_str(&field_name); + output.push_str(": "); + if let Some(const_value) = &property.const_value { + // The only admissible value, so it is the field's default — a + // schema `default`, being a suggestion, is not (see + // `render_default_constants`). + output.push_str(&member_type); + output.push_str(" = "); + output.push_str(&python_value_literal(const_value)?); + } else if required.contains(json_name) { + // Required and nullable keeps the `| None` (an explicit null is + // the value) but takes no default: the member must be supplied. + if allows_null(property) { + output.push_str(&optional_annotation(&member_type)); + } else { + output.push_str(&member_type); + } + } else { + output.push_str(&optional_annotation(&member_type)); + output.push_str(" = None"); + } + output.push('\n'); + render_python_docstring( + output, + " ", + compose_python_doc(property.title.as_deref(), property.description.as_deref()) + .as_deref(), + &[], + None, + false, + ); + } + } + + if is_python_map_model(schema) || is_open_object(schema) { + output.push('\n'); + members += 1; + output.push_str(&format!( + " additional_properties: dict[str, {}] = dataclasses.field(default_factory=dict)\n", + catch_all_annotation(schema)? + )); + } + if members == 0 { + output.push_str("\n pass\n"); + } + Ok(()) +} + +/// Emits the private `TransferTypeConverter` a model's whole wire contract lives +/// in. `transfer_type` is left at its inherited `None`, which is what makes the +/// inner payload converter hand us the raw `json.loads` value. +fn render_model_converter( + output: &mut String, + model: &PlannedJsonType, + schema: &Schema, + models: &[&PlannedJsonType], +) -> Result<()> { + let name = &model.model_name; + output.push_str(&format!( + "class {}(\n temporalio.converter.TransferTypeConverter[\"{name}\", typing.Any]\n):\n", + converter_class_name(name) + )); + output.push_str(" @typing_extensions.override\n"); + output.push_str(&format!( + " def from_transfer_type(\n self, value: typing.Any, type_hint: type[\"{name}\"]\n ) -> \"{name}\":\n" + )); + let mut parser = String::new(); + render_model_parser_body(&mut parser, model, schema, models)?; + push_indented(output, &parser, " "); + output.push('\n'); + output.push_str(" @typing_extensions.override\n"); + output.push_str(&format!( + " def to_transfer_type(self, value: \"{name}\") -> typing.Any:\n" + )); + let mut serializer = String::new(); + render_model_serializer_body(&mut serializer, model, schema, models)?; + push_indented(output, &serializer, " "); + Ok(()) +} + +fn render_model_parser_body( + output: &mut String, + model: &PlannedJsonType, + schema: &Schema, + models: &[&PlannedJsonType], +) -> Result<()> { + output.push_str("violations: list[Violation] = []\n"); + // The one non-aggregating failure: without an object there is no member to + // report a violation against. + output.push_str("if not isinstance(value, dict):\n"); + output.push_str( + " raise ValidationError([Violation(path=\"\", reason=\"expected object\")])\n", + ); + output.push_str("raw = typing.cast(\"dict[str, typing.Any]\", value)\n"); + + if let Some(shape) = py_map_shape(schema)? { + render_map_parser_body(output, model, schema, &shape)?; + return Ok(()); + } + + let required = required_fields(schema); + let mut fields: Vec<String> = Vec::new(); + if let Some(properties) = &schema.properties { + for (json_name, property) in properties { + output.push('\n'); + render_property_parser( + output, + model, + models, + json_name, + property, + required.contains(json_name), + )?; + fields.push(property.py_member_name(json_name)); + } + } + + let open = is_open_object(schema); + output.push('\n'); + if schema.additional_properties.as_ref() == Some(&Value::Bool(false)) { + render_closed_object_unknown_key_check(output, schema); + } else if open { + render_open_object_collection(output, model, schema)?; + } + + // Object member-count and cross-field constraints over the wire member set + // (`raw` holds every distinct wire key). + render_py_property_count_checks(output, "len(raw)", schema, ""); + render_py_dependent_required(output, "raw", schema, ""); + + output.push_str("if violations:\n"); + output.push_str(" raise ValidationError(violations)\n"); + if fields.is_empty() && !open { + output.push_str(&format!("return {}()\n", model.model_name)); + return Ok(()); + } + output.push_str(&format!("return {}(\n", model.model_name)); + for field_name in &fields { + output.push_str(&format!(" {field_name}={field_name},\n")); + } + if open { + output.push_str(" additional_properties=additional_properties,\n"); + } + output.push_str(")\n"); + Ok(()) +} + +/// Emits the parse body of a map-shaped model: the member-count and key-shape +/// checks over the wire keys, then every member through its declared type into +/// the catch-all. +fn render_map_parser_body( + output: &mut String, + model: &PlannedJsonType, + schema: &Schema, + shape: &PyMapShape, +) -> Result<()> { + render_py_property_count_checks(output, "len(raw)", schema, ""); + if let Some(subschema) = &schema.property_names { + render_py_property_name_checks(output, "raw", subschema, ""); + } + output.push_str(&format!( + "additional_properties: dict[str, {}] = {{}}\n", + shape.value_annotation + )); + output.push_str("for key in raw:\n"); + match &shape.value_schema { + // Free-form members are carried verbatim, `null` included (P13). + None => output.push_str(" additional_properties[key] = raw[key]\n"), + Some(value_schema) => { + render_py_slot_declaration(output, " ", "member", &shape.value_annotation); + output.push_str(" member_raw = raw[key]\n"); + render_value_parser( + output, + value_schema, + "member_raw", + "member", + "key", + " ", + "member", + )?; + // A member that failed to parse has already recorded a violation, so + // the value stored here never reaches the caller. + output.push_str(" additional_properties[key] = member\n"); + } + } + output.push_str("if violations:\n"); + output.push_str(" raise ValidationError(violations)\n"); + output.push_str(&format!( + "return {}(additional_properties=additional_properties)\n", + model.model_name + )); + Ok(()) +} + +fn render_model_serializer_body( + output: &mut String, + model: &PlannedJsonType, + schema: &Schema, + models: &[&PlannedJsonType], +) -> Result<()> { + // Serialize-side (P12): re-run the shared field validation over the + // in-memory model and raise the aggregated `ValidationError` before emitting + // the wire object — both directions over one set of check emitters. + let needs_validation = py_model_needs_serialize_validation(schema)?; + if needs_validation { + output.push_str("violations: list[Violation] = []\n"); + } + output.push_str("out: dict[str, typing.Any] = {}\n"); + + if let Some(shape) = py_map_shape(schema)? { + output.push_str("for key, entry in value.additional_properties.items():\n"); + if let Some(value_schema) = &shape.value_schema { + render_py_member_check(output, value_schema, "entry", "key", " ")?; + } + let entry = match &shape.value_schema { + Some(value_schema) => serialize_expr(value_schema, "entry", 0), + None => "entry".to_string(), + }; + output.push_str(&format!(" out[key] = {entry}\n")); + if needs_validation { + render_py_property_count_checks(output, "len(out)", schema, ""); + if let Some(subschema) = &schema.property_names { + render_py_property_name_checks(output, "out", subschema, ""); + } + output.push_str("if violations:\n"); + output.push_str(" raise ValidationError(violations)\n"); + } + output.push_str("return out\n"); + return Ok(()); + } + + let required = required_fields(schema); + if let Some(properties) = &schema.properties { + for (json_name, property) in properties { + let field_name = property.py_member_name(json_name); + let value_expr = format!("value.{field_name}"); + let key = python_string_literal(json_name); + // An optional member is emitted under an `is not None` guard, so the + // nullability wrapper's own `None` branch is already ruled out and the + // transform is taken straight from the member's non-null shape. + let emitted = match ( + required.contains(json_name), + nullable_member_schema(property), + ) { + (false, Some(non_null)) => non_null, + _ => property, + }; + // A union whose members need a transform goes through the module's + // union serializer; everything else is a plain expression. + let assignment = match classify_py_union(property, models)? { + Some(union) if union.needs_serializer() => format!( + "{}({value_expr})", + union_serialize_fn(&inline_union_fn_base(&model.model_name, json_name)) + ), + _ => serialize_expr(emitted, &value_expr, 0), + }; + if required.contains(json_name) { + render_py_serialize_property_check(output, json_name, property, "")?; + output.push_str(&format!("out[{key}] = {assignment}\n")); + } else { + // Absent and explicit `null` collapsed to `None` on the way in, + // so both re-serialize as omitted. + output.push_str(&format!("if {value_expr} is not None:\n")); + render_py_serialize_property_check(output, json_name, property, " ")?; + output.push_str(&format!(" out[{key}] = {assignment}\n")); + } + } + } + if is_open_object(schema) { + output.push_str("for key, entry in value.additional_properties.items():\n"); + output.push_str(" out[key] = entry\n"); + } + if needs_validation { + // Object member-count and cross-field constraints over the to-be-emitted + // wire key set (`out` holds every distinct wire key, JSON-named). + render_py_property_count_checks(output, "len(out)", schema, ""); + render_py_dependent_required(output, "out", schema, ""); + output.push_str("if violations:\n"); + output.push_str(" raise ValidationError(violations)\n"); + } + output.push_str("return out\n"); + Ok(()) +} + +/// Emits the serialize-side validation of one declared property, guarding a +/// nullable member so the checks only fire on a materialized value. The caller +/// owns the optional (`is not None`) guard. +fn render_py_serialize_property_check( + output: &mut String, + json_name: &str, + property: &Schema, + indent: &str, +) -> Result<()> { + let value_expr = format!("value.{}", property.py_member_name(json_name)); + let path_expr = python_string_literal(json_name); + let guard_null = allows_null(property); + let body_indent = if guard_null { + format!("{indent} ") + } else { + indent.to_string() + }; + let mut body = String::new(); + render_py_field_checks(&mut body, property, &value_expr, &path_expr, &body_indent)?; + if body.is_empty() { + return Ok(()); + } + if guard_null { + output.push_str(indent); + output.push_str(&format!("if {value_expr} is not None:\n")); + } + output.push_str(&body); + Ok(()) +} + +/// The member counterpart of [`render_py_serialize_property_check`]: same +/// predicates, same nullable guard, keyed by the member's own key — a catch-all +/// mutated to an invalid value fails serialization rather than emitting bad data. +fn render_py_member_check( + output: &mut String, + value_schema: &Schema, + value_expr: &str, + path_expr: &str, + indent: &str, +) -> Result<()> { + let guard_null = allows_null(value_schema); + let body_indent = if guard_null { + format!("{indent} ") + } else { + indent.to_string() + }; + let mut body = String::new(); + render_py_field_checks(&mut body, value_schema, value_expr, path_expr, &body_indent)?; + if body.is_empty() { + return Ok(()); + } + if guard_null { + output.push_str(indent); + output.push_str(&format!("if {value_expr} is not None:\n")); + } + output.push_str(&body); + Ok(()) +} + +/// Emits the three-way presence branch of one declared property, per the +/// field-encoding table: required rejects an absent (and, when non-nullable, a +/// null) member; optional non-nullable rejects an explicit null; optional +/// nullable collapses both to `None`. +fn render_property_parser( + output: &mut String, + model: &PlannedJsonType, + models: &[&PlannedJsonType], + json_name: &str, + property: &Schema, + required: bool, +) -> Result<()> { + let field_name = property.py_member_name(json_name); + let member_type = annotation(property)?; + let key = python_string_literal(json_name); + let path_expr = python_string_literal(json_name); + let raw_local = format!("{field_name}_raw"); + let nullable = allows_null(property); + + // Not yet assigned; a failure to parse records a violation, so the + // placeholder never escapes the converter. + let declared_type = if required && !nullable { + member_type.clone() + } else { + optional_annotation(&member_type) + }; + render_py_slot_declaration(output, "", &field_name, &declared_type); + + if required { + if nullable { + output.push_str(&format!("if {key} not in raw:\n")); + } else { + output.push_str(&format!("if {key} not in raw or raw[{key}] is None:\n")); + } + output.push_str(&format!( + " violations.append(Violation(path={path_expr}, reason=\"required\"))\n" + )); + output.push_str("else:\n"); + output.push_str(&format!(" {raw_local} = raw[{key}]\n")); + render_property_value_parser( + output, + model, + models, + json_name, + property, + &field_name, + &raw_local, + " ", + )?; + return Ok(()); + } + + output.push_str(&format!("if {key} in raw:\n")); + output.push_str(&format!(" {raw_local} = raw[{key}]\n")); + if nullable { + render_property_value_parser( + output, + model, + models, + json_name, + property, + &field_name, + &raw_local, + " ", + )?; + return Ok(()); + } + output.push_str(&format!(" if {raw_local} is None:\n")); + output.push_str(&format!( + " violations.append(Violation(path={path_expr}, reason=\"explicit null not allowed\"))\n" + )); + output.push_str(" else:\n"); + render_property_value_parser( + output, + model, + models, + json_name, + property, + &field_name, + &raw_local, + " ", + ) +} + +fn render_property_value_parser( + output: &mut String, + model: &PlannedJsonType, + models: &[&PlannedJsonType], + json_name: &str, + property: &Schema, + target: &str, + raw_expr: &str, + indent: &str, +) -> Result<()> { + let path_expr = python_string_literal(json_name); + // An inline `oneOf` sum type dispatches through the module's union parser (a + // `$ref` at a named union routes through the reference path below). + if classify_py_union(property, models)?.is_some() { + let base = inline_union_fn_base(&model.model_name, json_name); + let parsed = format!("{target}_parsed"); + output.push_str(indent); + output.push_str(&format!( + "{parsed} = {}({raw_expr}, {path_expr}, violations)\n", + union_parse_fn(&base) + )); + output.push_str(indent); + output.push_str(&format!("if {parsed} is not None:\n")); + output.push_str(indent); + output.push_str(&format!(" {target} = {parsed}\n")); + return Ok(()); + } + render_value_parser( + output, property, raw_expr, target, &path_expr, indent, target, + ) +} + +/// Parses one value into `target`. `raw_expr` is always a bare or dotted name, so +/// every reason f-string can interpolate it without the subscript that only +/// became legal inside a same-quoted f-string in Python 3.12. `slot` prefixes the +/// temporaries this position needs, keeping them distinct from every other +/// position's in the same (function-wide) Python scope. +fn render_value_parser( + output: &mut String, + schema: &Schema, + raw_expr: &str, + target: &str, + path_expr: &str, + indent: &str, + slot: &str, +) -> Result<()> { + if let Some(reference) = &schema.reference { + let model_name = reference_model_name(reference); + if is_union_type_name(&model_name) { + let parsed = format!("{slot}_parsed"); + output.push_str(indent); + output.push_str(&format!( + "{parsed} = {}({raw_expr}, {path_expr}, violations)\n", + union_parse_fn(&union_fn_base(&model_name)) + )); + output.push_str(indent); + output.push_str(&format!("if {parsed} is not None:\n")); + output.push_str(indent); + output.push_str(&format!(" {target} = {parsed}\n")); + return Ok(()); + } + output.push_str(indent); + output.push_str("try:\n"); + output.push_str(indent); + output.push_str(&format!( + " {target} = {}.from_transfer_type({raw_expr}, {model_name})\n", + converter_expr(&model_name) + )); + output.push_str(indent); + output.push_str("except ValidationError as error:\n"); + output.push_str(indent); + output.push_str(&format!(" _collect(violations, {path_expr}, error)\n")); + return Ok(()); + } + + // The nullability wrapper: an explicit null is the value, and the non-null + // branch carries everything the member declares. + if let Some(branches) = &schema.one_of + && branches + .iter() + .any(|branch| schema_type_includes(branch, "null")) + && !is_py_union(schema) + { + output.push_str(indent); + output.push_str(&format!("if {raw_expr} is None:\n")); + output.push_str(indent); + output.push_str(&format!(" {target} = None\n")); + let non_null = branches + .iter() + .find(|branch| !schema_type_includes(branch, "null")); + match non_null { + Some(branch) => { + output.push_str(indent); + output.push_str("else:\n"); + render_value_parser( + output, + branch, + raw_expr, + target, + path_expr, + &format!("{indent} "), + slot, + )?; + } + None => {} + } + return Ok(()); + } + + // A materialized temporal: the wire must be a string, which the runtime + // helper then parses (returning `None` after recording its own violation). + if let Some(kind) = temporal_kind_direct(schema) { + render_py_materialized_parser( + output, + python_temporal_parse_fn(kind), + raw_expr, + target, + path_expr, + indent, + slot, + ); + return Ok(()); + } + // A materialized `contentEncoding`: the same shape, decoding to `bytes`. + if let Some(encoding) = content_encoding_direct(schema) { + render_py_materialized_parser( + output, + python_content_encoding_parse_fn(encoding), + raw_expr, + target, + path_expr, + indent, + slot, + ); + return Ok(()); + } + + if let Some(const_value) = &schema.const_value { + let literal = python_value_literal(const_value)?; + let reason = + python_string_literal(&format!("must equal {}", py_reason_literal(const_value))); + render_py_closed_value_parser( + output, + std::slice::from_ref(const_value), + std::slice::from_ref(&literal), + raw_expr, + target, + &annotation(schema)?, + path_expr, + indent, + &reason, + ); + return Ok(()); + } + if let Some(values) = &schema.enum_values { + let literals = values + .iter() + .map(python_value_literal) + .collect::<Result<Vec<_>>>()?; + let reason = py_enum_reason(values, raw_expr); + render_py_closed_value_parser( + output, + values, + &literals, + raw_expr, + target, + &annotation(schema)?, + path_expr, + indent, + &reason, + ); + return Ok(()); + } + + match schema.ty.as_ref().and_then(Value::as_str) { + Some("string") => { + render_py_isinstance_parser( + output, + &format!("isinstance({raw_expr}, str)"), + "expected string", + raw_expr, + target, + path_expr, + indent, + ); + render_py_string_checks( + output, + raw_expr, + path_expr, + schema, + &format!("{indent} "), + ); + } + Some("boolean") => render_py_isinstance_parser( + output, + &format!("isinstance({raw_expr}, bool)"), + "expected boolean", + raw_expr, + target, + path_expr, + indent, + ), + Some("number") => { + render_py_isinstance_parser( + output, + &format!( + "not isinstance({raw_expr}, bool) and isinstance({raw_expr}, (int, float))" + ), + "expected number", + raw_expr, + target, + path_expr, + indent, + ); + render_py_numeric_checks( + output, + raw_expr, + path_expr, + schema, + &format!("{indent} "), + ); + } + Some("integer") => { + // `1.0` is an integer and `1.5` is not, so the parse is the shared + // spec-integer helper rather than a bare type test. + let parsed = format!("{slot}_parsed"); + output.push_str(indent); + output.push_str(&format!( + "{parsed} = _parse_spec_integer({raw_expr}, {path_expr}, violations)\n" + )); + output.push_str(indent); + output.push_str(&format!("if {parsed} is not None:\n")); + output.push_str(indent); + output.push_str(&format!(" {target} = {parsed}\n")); + render_py_numeric_checks(output, target, path_expr, schema, &format!("{indent} ")); + } + Some("array") => { + render_array_parser(output, schema, raw_expr, target, path_expr, indent, slot)? + } + Some("null") => { + output.push_str(indent); + output.push_str(&format!("{target} = None\n")); + } + // A free-form or inline object, and anything untyped: the wire value is + // already the member (P13). + _ => { + output.push_str(indent); + output.push_str(&format!("{target} = {raw_expr}\n")); + } + } + Ok(()) +} + +/// Emits the parse of a materialized value: the string guard the runtime helpers +/// require, then the helper itself. +fn render_py_materialized_parser( + output: &mut String, + parse_fn: &str, + raw_expr: &str, + target: &str, + path_expr: &str, + indent: &str, + slot: &str, +) { + let parsed = format!("{slot}_parsed"); + output.push_str(indent); + output.push_str(&format!("if not isinstance({raw_expr}, str):\n")); + output.push_str(indent); + output.push_str(&format!( + " violations.append(Violation(path={path_expr}, reason=\"expected string\"))\n" + )); + output.push_str(indent); + output.push_str("else:\n"); + output.push_str(indent); + output.push_str(&format!( + " {parsed} = {parse_fn}({raw_expr}, {path_expr}, violations)\n" + )); + output.push_str(indent); + output.push_str(&format!(" if {parsed} is not None:\n")); + output.push_str(indent); + output.push_str(&format!(" {target} = {parsed}\n")); +} + +/// Emits `if not <guard>: <reason> else: <assign>`, the shape every scalar kind's +/// parse shares. Any per-kind constraint check is appended by the caller at the +/// `else` body's indent. +fn render_py_isinstance_parser( + output: &mut String, + guard: &str, + reason: &str, + raw_expr: &str, + target: &str, + path_expr: &str, + indent: &str, +) { + output.push_str(indent); + output.push_str(&format!("if not {}:\n", py_negatable(guard))); + output.push_str(indent); + output.push_str(&format!( + " violations.append(Violation(path={path_expr}, reason={}))\n", + python_string_literal(reason) + )); + output.push_str(indent); + output.push_str("else:\n"); + output.push_str(indent); + output.push_str(&format!(" {target} = {raw_expr}\n")); +} + +/// Declares a local for a value that has not been parsed yet. The placeholder is +/// widened through `typing.Any` rather than cast from `None`, which would be a +/// type error on any annotation that does not admit it. +fn render_py_slot_declaration(output: &mut String, indent: &str, name: &str, member_type: &str) { + output.push_str(indent); + if member_type == "typing.Any" || admits_none(member_type) { + output.push_str(&format!("{name}: {member_type} = None\n")); + } else { + output.push_str(&format!( + "{name}: {member_type} = typing.cast(\"typing.Any\", None)\n" + )); + } +} + +/// Parenthesizes a condition only when `not` would otherwise bind tighter than +/// its operators, so a single `isinstance(...)` call keeps its plain reading. +fn py_negatable(condition: &str) -> String { + if condition.contains(" and ") || condition.contains(" or ") { + format!("({condition})") + } else { + condition.to_string() + } +} + +/// The kind test and reason word for a closed value set, taken from its first +/// member's JSON kind. +fn py_closed_value_guard(value: &Value, raw_expr: &str) -> Option<(String, &'static str)> { + match value { + Value::String(_) => Some((format!("isinstance({raw_expr}, str)"), "expected string")), + Value::Bool(_) => Some((format!("isinstance({raw_expr}, bool)"), "expected boolean")), + Value::Number(_) => Some(( + format!("not isinstance({raw_expr}, bool) and isinstance({raw_expr}, (int, float))"), + "expected number", + )), + _ => None, + } +} + +/// Emits the closed-value (`const` single-value / `enum` multi-value) parse: a +/// kind test, a membership test against the fixed set, and the assignment on +/// success. See `specs/json-schema/features/{const,enum}.md`. +#[allow(clippy::too_many_arguments)] +fn render_py_closed_value_parser( + output: &mut String, + values: &[Value], + compare_exprs: &[String], + raw_expr: &str, + target: &str, + member_type: &str, + path_expr: &str, + indent: &str, + reason: &str, +) { + let membership = compare_exprs + .iter() + .map(|expr| format!("{raw_expr} != {expr}")) + .collect::<Vec<_>>() + .join(" and "); + match values + .first() + .and_then(|value| py_closed_value_guard(value, raw_expr)) + { + Some((guard, kind_reason)) => { + output.push_str(indent); + output.push_str(&format!("if not {}:\n", py_negatable(&guard))); + output.push_str(indent); + output.push_str(&format!( + " violations.append(Violation(path={path_expr}, reason={}))\n", + python_string_literal(kind_reason) + )); + output.push_str(indent); + output.push_str(&format!("elif {membership}:\n")); + } + None => { + output.push_str(indent); + output.push_str(&format!("if {membership}:\n")); + } + } + output.push_str(indent); + output.push_str(&format!( + " violations.append(Violation(path={path_expr}, reason={reason}))\n" + )); + output.push_str(indent); + output.push_str("else:\n"); + output.push_str(indent); + // A string or boolean value set narrows to its literal type through the + // membership test itself. A numeric one does not: the kind test admits + // `int | float` (`1.0` is the integer `1`), so the member is cast to the + // closed literal type it declares. + if member_type.starts_with("typing.Literal[") && values.first().is_some_and(Value::is_number) { + output.push_str(&format!( + " {target} = typing.cast({}, {raw_expr})\n", + python_string_literal(member_type) + )); + } else { + output.push_str(&format!(" {target} = {raw_expr}\n")); + } +} + +/// Emits the elementwise parse of an array. Every element is appended, valid or +/// not — an element that failed has already recorded a violation, so the built +/// list only reaches the caller when it is whole (which is also what keeps a +/// legitimately-null element from being mistaken for a failure). +#[allow(clippy::too_many_arguments)] +fn render_array_parser( + output: &mut String, + schema: &Schema, + raw_expr: &str, + target: &str, + path_expr: &str, + indent: &str, + slot: &str, +) -> Result<()> { + let item_slot = format!("{slot}_item"); + let list_local = format!("{slot}_list"); + let index_local = format!("{slot}_index"); + let element_local = format!("{slot}_element"); + let item_path_local = format!("{item_slot}_path"); + let item_type = schema + .items + .as_ref() + .map(|item| annotation(item)) + .transpose()? + .unwrap_or_else(|| "typing.Any".to_string()); + + output.push_str(indent); + output.push_str(&format!("if not isinstance({raw_expr}, list):\n")); + output.push_str(indent); + output.push_str(&format!( + " violations.append(Violation(path={path_expr}, reason=\"expected array\"))\n" + )); + output.push_str(indent); + output.push_str("else:\n"); + let body = format!("{indent} "); + output.push_str(&body); + output.push_str(&format!("{list_local}: list[{item_type}] = []\n")); + output.push_str(&body); + output.push_str(&format!( + "for {index_local}, {element_local} in enumerate(typing.cast(\"list[typing.Any]\", {raw_expr})):\n" + )); + let loop_body = format!("{body} "); + output.push_str(&loop_body); + output.push_str(&format!( + "{item_path_local} = {}\n", + py_indexed_path(path_expr, &index_local) + )); + render_py_slot_declaration(output, &loop_body, &item_slot, &item_type); + match &schema.items { + Some(item_schema) if is_plain_string_schema(item_schema) => { + // A plain string element reports the element-level reason every + // target uses for a mistyped member of a string list. + output.push_str(&loop_body); + output.push_str(&format!("if not isinstance({element_local}, str):\n")); + output.push_str(&loop_body); + output.push_str(&format!( + " violations.append(Violation(path={item_path_local}, reason=\"expected element\"))\n" + )); + output.push_str(&loop_body); + output.push_str("else:\n"); + output.push_str(&loop_body); + output.push_str(&format!(" {item_slot} = {element_local}\n")); + } + Some(item_schema) => render_value_parser( + output, + item_schema, + &element_local, + &item_slot, + &item_path_local, + &loop_body, + &item_slot, + )?, + None => { + output.push_str(&loop_body); + output.push_str(&format!("{item_slot} = {element_local}\n")); + } + } + output.push_str(&loop_body); + output.push_str(&format!("{list_local}.append({item_slot})\n")); + render_py_array_checks(output, &list_local, path_expr, schema, &body)?; + output.push_str(&body); + output.push_str(&format!("{target} = {list_local}\n")); + Ok(()) +} + +/// True when a schema is a bare `string` with nothing else to enforce, which is +/// the only element shape that takes the element-level reason shortcut. +fn is_plain_string_schema(schema: &Schema) -> bool { + schema.ty.as_ref().and_then(Value::as_str) == Some("string") + && schema.const_value.is_none() + && schema.enum_values.is_none() + && schema.format.is_none() + && schema.content_encoding.is_none() + && schema.pattern.is_none() + && schema.min_length.is_none() + && schema.max_length.is_none() +} + +fn render_closed_object_unknown_key_check(output: &mut String, schema: &Schema) { + let fields = schema + .properties + .as_ref() + .map(|properties| { + properties + .keys() + .map(|field| format!("key != {}", python_string_literal(field))) + .collect::<Vec<_>>() + }) + .unwrap_or_default(); + output.push_str("for key in raw:\n"); + if fields.is_empty() { + // A closed object with no declared members admits nothing at all. + output.push_str(" violations.append(Violation(path=key, reason=\"unknown field\"))\n"); + return; + } + output.push_str(&format!(" if {}:\n", fields.join(" and "))); + output.push_str(" violations.append(Violation(path=key, reason=\"unknown field\"))\n"); +} + +fn render_open_object_collection( + output: &mut String, + model: &PlannedJsonType, + schema: &Schema, +) -> Result<()> { + output.push_str(&format!( + "additional_properties: dict[str, {}] = {{}}\n", + additional_properties_annotation(schema)? + )); + output.push_str("for key in raw:\n"); + output.push_str(&format!( + " if key not in {}:\n", + declared_fields_const_name(&model.model_name) + )); + output.push_str(" additional_properties[key] = raw[key]\n"); + Ok(()) +} + +/// The wire form of an in-memory value. A Python dataclass is not its own wire +/// shape (snake_case attributes, an explicit catch-all, native temporals and +/// `bytes`), so a container whose elements transform is always descended into — +/// unlike TypeScript, where a closed interface can be copied verbatim. +fn serialize_expr(schema: &Schema, value_expr: &str, depth: usize) -> String { + if let Some(reference) = &schema.reference { + let model_name = reference_model_name(reference); + if is_union_type_name(&model_name) { + return format!( + "{}({value_expr})", + union_serialize_fn(&union_fn_base(&model_name)) + ); + } + return format!( + "{}.to_transfer_type({value_expr})", + converter_expr(&model_name) + ); + } + if let Some(kind) = temporal_kind_direct(schema) { + return format!("{}({value_expr})", python_temporal_format_fn(kind)); + } + if let Some(encoding) = content_encoding_direct(schema) { + return format!( + "{}({value_expr})", + python_content_encoding_format_fn(encoding) + ); + } + if let Some(branches) = &schema.one_of + && branches + .iter() + .any(|branch| schema_type_includes(branch, "null")) + && !is_py_union(schema) + && let Some(non_null) = branches + .iter() + .find(|branch| !schema_type_includes(branch, "null")) + { + let inner = serialize_expr(non_null, value_expr, depth); + if inner != value_expr { + return format!("None if {value_expr} is None else {inner}"); + } + } + if schema.ty.as_ref().and_then(Value::as_str) == Some("array") + && let Some(items) = schema.items.as_deref() + { + // Comprehension scopes nest, so each level names its own element. + let element = if depth == 0 { + "element".to_string() + } else { + format!("element{depth}") + }; + let mapped = serialize_expr(items, &element, depth + 1); + if mapped != element { + return format!("[{mapped} for {element} in {value_expr}]"); + } + } + value_expr.to_string() +} + +/// Emits the spec-integer parse helper: accepts `1` and `1.0`, rejects `1.5` and +/// anything beyond the ±(2^53−1) cap, and rejects `bool` (a Python `int` +/// subclass, so `isinstance(True, int)` is `True` and must be excluded +/// explicitly). Pushes a `Violation` and returns `None` on failure so the caller +/// keeps going and aggregates. See `specs/json-schema/features/type.md`. +fn render_spec_int_helper(output: &mut String) { + output.push_str(SPEC_INT_HELPER_BODY); } +const SPEC_INT_HELPER_BODY: &str = r#"_INTEGER_CAP = (1 << 53) - 1 + + +def _parse_spec_integer( + value: object, path: str, violations: list[Violation] +) -> int | None: + """Parses a JSON number as a spec integer (`1.0` accepted, `1.5` rejected).""" + + # `bool` is a subclass of `int`, so it must be excluded before the int check. + if isinstance(value, bool) or not isinstance(value, (int, float)): + violations.append(Violation(path=path, reason="expected integer")) + return None + if isinstance(value, float): + if not value.is_integer(): + violations.append(Violation(path=path, reason="expected integer")) + return None + out = int(value) + else: + out = value + if abs(out) > _INTEGER_CAP: + violations.append(Violation(path=path, reason="expected integer")) + return None + return out +"#; + fn typed_map_value_schema(schema: &Schema) -> Result<Option<Schema>> { if schema .properties @@ -1420,117 +3898,6 @@ fn is_python_map_model(schema: &Schema) -> bool { && schema.additional_properties.as_ref() != Some(&Value::Bool(false)) } -/// Emits a map-shaped model's `_validate_extras` validator: the per-member `T` -/// validation (typed maps only) plus the member-count and key-shape constraints. -fn render_map_model_methods( - output: &mut String, - schema: &Schema, - model_name: &str, - typed_members: bool, -) { - // The checks are rendered first so the `extra` binding is only emitted when - // one of them reads it: an unused local is a type-checker diagnostic, and a - // map may carry no constraint at all (an unconstrained member type). - let mut checks = String::new(); - if typed_members { - render_typed_map_value_validator(&mut checks, model_name); - } - // `len(extra)` is the distinct wire-key count for a map (no declared - // fields), counted as one number (never a declared + extras sum). - if let Some(min) = schema.min_properties { - checks.push_str(&format!(" if len(extra) < {min}:\n")); - render_py_count_violation( - &mut checks, - "too_few_properties", - &format!("must have at least {min} properties, got {{len(extra)}}"), - "len(extra)", - " ", - ); - } - if let Some(max) = schema.max_properties { - checks.push_str(&format!(" if len(extra) > {max}:\n")); - render_py_count_violation( - &mut checks, - "too_many_properties", - &format!("must have at most {max} properties, got {{len(extra)}}"), - "len(extra)", - " ", - ); - } - if let Some(subschema) = &schema.property_names { - render_py_property_name_validator(&mut checks, subschema); - } - - output.push_str("\n @pydantic.model_validator(mode=\"after\")\n"); - output.push_str(" def _validate_extras(self) -> typing.Any:\n"); - if checks.contains("extra") { - output.push_str(" extra = typing.cast(dict[str, object], self.model_extra or {})\n"); - } - output.push_str(" errors: list[pydantic_core.InitErrorDetails] = []\n"); - output.push_str(&checks); - output.push_str(" if errors:\n"); - output.push_str(" raise pydantic.ValidationError.from_exception_data(\n"); - output.push_str(" title=type(self).__name__, line_errors=errors\n"); - output.push_str(" )\n"); - output.push_str(" return self\n\n"); - output.push_str(" @pydantic.model_serializer(mode=\"wrap\")\n"); - output.push_str(" def _serialize(\n"); - output.push_str(" self,\n"); - output.push_str(" _handler: typing.Callable[[pydantic.BaseModel], typing.Any],\n"); - output.push_str(" ) -> dict[str, object]:\n"); - if typed_members { - // Each member re-encodes through the same adapter that validated it, so a - // materialized member (a referenced model, a native temporal or bytes - // construct) reaches the wire in its declared form rather than however - // Pydantic happens to render an untyped value. - let adapter = map_member_adapter_name(model_name); - output.push_str(" return {\n"); - output.push_str(&format!( - " key: {adapter}.dump_python(value, mode=\"json\", by_alias=True)\n" - )); - output.push_str( - " for key, value in typing.cast(dict[str, object], self.model_extra or {}).items()\n", - ); - output.push_str(" }\n"); - return; - } - output - .push_str(" return dict(typing.cast(dict[str, object], self.model_extra or {}))\n"); -} - -/// Emits the per-member validation loop of a typed map: each member is validated -/// **and materialized** through the model's member `TypeAdapter`, which carries -/// the member type's whole annotation — the spec-strict integer parse, a native -/// temporal/bytes construct, a `Literal` value set, a referenced model, the -/// numeric/length bounds, and the `pattern`/`format`/`multipleOf` validators — so -/// a member is held to exactly what a declared field of that type is held to -/// ([[additionalProperties]] §"Validator mapping": per-member `T` validation). -/// Pydantic's own violations are merged under the member's key, so the reported -/// path threads the member (`labels.env`, `entries.a.street`) per **P11**. -fn render_typed_map_value_validator(output: &mut String, model_name: &str) { - let adapter = map_member_adapter_name(model_name); - output.push_str(" for key, value in list(extra.items()):\n"); - output.push_str(" try:\n"); - output.push_str(&format!( - " extra[key] = {adapter}.validate_python(value)\n" - )); - output.push_str(" except pydantic.ValidationError as error:\n"); - output.push_str(" for detail in error.errors():\n"); - output.push_str(" errors.append(\n"); - output.push_str(" pydantic_core.InitErrorDetails(\n"); - output.push_str(" type=pydantic_core.PydanticCustomError(\n"); - // `PydanticCustomError` types its arguments as `LiteralString`; a nested - // error's own type and message are ordinary `str`, so both are cast (the - // count/name validators do the same for their f-strings). - output.push_str(" typing.cast(typing.Any, detail[\"type\"]),\n"); - output.push_str(" typing.cast(typing.Any, detail[\"msg\"]),\n"); - output.push_str(" ),\n"); - output.push_str(" loc=(key, *detail[\"loc\"]),\n"); - output.push_str(" input=detail[\"input\"],\n"); - output.push_str(" )\n"); - output.push_str(" )\n"); -} - /// The non-null branch of a member schema that is the nullability `oneOf` /// wrapper, which carries the member's own constraints. fn nullable_member_schema(schema: &Schema) -> Option<&Schema> { @@ -1545,296 +3912,6 @@ fn nullable_member_schema(schema: &Schema) -> Option<&Schema> { } } -/// The module-level `TypeAdapter` a map-shaped model validates its members with. -/// It is defined *after* every class so its eagerly-evaluated annotation sees a -/// referenced model that is declared later in the module — the same reason union -/// aliases and `model_rebuild()` calls sit there. -fn map_member_adapter_name(model_name: &str) -> String { - format!("_{}_MEMBER", model_name.to_shouty_snake_case()) -} - -/// Emits the member `TypeAdapter` definitions for every map-shaped model in the -/// module (see [`map_member_adapter_name`]). `strict=True` is passed as the -/// adapter's config so a member is held to the same strict mode a declared field -/// is (PRINCIPLES Python §1) — except when the member type *is* a model or union -/// alias, which carries its own config and rejects an override. -fn render_map_member_adapters( - output: &mut String, - models: &[&PlannedJsonType], - union_names: &BTreeSet<String>, -) -> Result<()> { - for model in models { - let schema = decode_schema(model)?; - if !is_python_map_model(&schema) { - continue; - } - let Some(value_schema) = typed_map_value_schema(&schema)? else { - continue; - }; - // A nullable member's constraints sit on the non-null branch of its - // wrapper, so the refinements are composed over that branch and the - // wrapper's `| None` is re-added around the result. - let (member, nullable): (&Schema, bool) = match nullable_member_schema(&value_schema) { - Some(inner) => (inner, true), - None => (&value_schema, false), - }; - let annotation = if nullable { - // A nullable member widens the type inside the same `Annotated` a - // declared field pairs `T | None` with. - constrained_annotation_over(member, optional_annotation(&annotation(member)?))? - } else { - constrained_annotation(member)? - }; - // Pydantic rejects a `config` override on a `BaseModel` — a referenced - // model carries its own strict config. A union alias is not a model, so - // it takes the override like any other annotation. - let is_model_class = member - .reference - .as_ref() - .map(|reference| reference_model_name(reference)) - .is_some_and(|name| !union_names.contains(&name)); - let config = if is_model_class { - String::new() - } else { - ", config=pydantic.ConfigDict(strict=True)".to_string() - }; - output.push_str(&format!( - "{}: pydantic.TypeAdapter[typing.Any] = pydantic.TypeAdapter(\n {annotation}{config}\n)\n", - map_member_adapter_name(&model.model_name) - )); - } - Ok(()) -} - -/// Emits an `errors.append(...)` for an object member-count violation (`{indent}` -/// is the body indent of the enclosing `if`). `message` is a Python f-string -/// body (may reference the runtime count) and `count_expr` is the reported -/// input value. -fn render_py_count_violation( - output: &mut String, - error_type: &str, - message: &str, - count_expr: &str, - indent: &str, -) { - output.push_str(indent); - output.push_str("errors.append(\n"); - output.push_str(indent); - output.push_str(" pydantic_core.InitErrorDetails(\n"); - output.push_str(indent); - output.push_str(" type=pydantic_core.PydanticCustomError(\n"); - output.push_str(indent); - output.push_str(&format!( - " {}, typing.cast(typing.Any, f{})\n", - python_string_literal(error_type), - python_string_literal(message) - )); - output.push_str(indent); - output.push_str(" ),\n"); - output.push_str(indent); - output.push_str(" loc=(),\n"); - output.push_str(indent); - output.push_str(&format!(" input={count_expr},\n")); - output.push_str(indent); - output.push_str(" )\n"); - output.push_str(indent); - output.push_str(")\n"); -} - -/// Emits the `propertyNames` key-shape loop for a typed map (over `extra`), -/// pushing an `InitErrorDetails` per key whose string length is out of bounds. -/// `len(key)` counts Unicode code points in Python — spec-correct. -fn render_py_property_name_validator(output: &mut String, subschema: &Schema) { - if subschema.min_length.is_none() && subschema.max_length.is_none() { - return; - } - output.push_str(" for key in extra:\n"); - let mut emit = |condition: &str, reason: &str| { - output.push_str(&format!(" if {condition}:\n")); - output.push_str(" errors.append(\n"); - output.push_str(" pydantic_core.InitErrorDetails(\n"); - output.push_str(" type=pydantic_core.PydanticCustomError(\n"); - output.push_str(&format!( - " \"invalid_property_name\", typing.cast(typing.Any, f{})\n", - python_string_literal(&format!("invalid property name \"{{key}}\": {reason}")) - )); - output.push_str(" ),\n"); - output.push_str(" loc=(key,),\n"); - output.push_str(" input=key,\n"); - output.push_str(" )\n"); - output.push_str(" )\n"); - }; - if let Some(min) = subschema.min_length { - emit( - &format!("len(key) < {min}"), - &format!("must have length >= {min}, got {{len(key)}}"), - ); - } - if let Some(max) = subschema.max_length { - emit( - &format!("len(key) > {max}"), - &format!("must have length <= {max}, got {{len(key)}}"), - ); - } -} - -/// Emits a `_validate_object` after-validator for a declared-property object -/// covering `minProperties`/`maxProperties` (over the distinct wire-key count, -/// `len(model_fields_set)`, which includes extras and excludes default-filled -/// fields) and `dependentRequired` (cross-field presence over the same set). -fn render_object_constraints_validator(output: &mut String, schema: &Schema) { - if !schema.has_object_count_or_dependency() { - return; - } - output.push_str("\n @pydantic.model_validator(mode=\"after\")\n"); - output.push_str(" def _validate_object(self) -> typing.Any:\n"); - output.push_str(" errors: list[pydantic_core.InitErrorDetails] = []\n"); - output.push_str(" present = self.model_fields_set\n"); - let count = "len(present)"; - if let Some(min) = schema.min_properties { - output.push_str(&format!(" if {count} < {min}:\n")); - render_py_count_violation( - output, - "too_few_properties", - &format!("must have at least {min} properties, got {{{count}}}"), - count, - " ", - ); - } - if let Some(max) = schema.max_properties { - output.push_str(&format!(" if {count} > {max}:\n")); - render_py_count_violation( - output, - "too_many_properties", - &format!("must have at most {max} properties, got {{{count}}}"), - count, - " ", - ); - } - if let Some(dependent_required) = &schema.dependent_required { - let member = |name: &str| -> String { - schema - .properties - .as_ref() - .and_then(|properties| properties.get(name)) - .map(|property| property.py_member_name(name)) - .unwrap_or_else(|| python_field_name(name)) - }; - for (trigger, deps) in dependent_required { - let trigger_field = member(trigger); - output.push_str(&format!( - " if {} in present:\n", - python_string_literal(&trigger_field) - )); - for dep in deps { - let dep_field = member(dep); - output.push_str(&format!( - " if {} not in present:\n", - python_string_literal(&dep_field) - )); - output.push_str(" errors.append(\n"); - output.push_str(" pydantic_core.InitErrorDetails(\n"); - output - .push_str(" type=pydantic_core.PydanticCustomError(\n"); - let reason = - format!("property \"{dep}\" is required when \"{trigger}\" is present"); - output.push_str(&format!( - " \"dependent_required\", {}\n", - python_string_literal(&reason) - )); - output.push_str(" ),\n"); - output.push_str(&format!( - " loc=({},),\n", - python_string_literal(dep) - )); - output.push_str(" input=None,\n"); - output.push_str(" )\n"); - output.push_str(" )\n"); - } - } - } - output.push_str(" if errors:\n"); - output.push_str(" raise pydantic.ValidationError.from_exception_data(\n"); - output.push_str(" title=type(self).__name__, line_errors=errors\n"); - output.push_str(" )\n"); - output.push_str(" return self\n"); -} - -fn render_optional_non_nullable_helper(output: &mut String) { - output.push_str("def _reject_explicit_null(\n"); - output.push_str(" cls: type[pydantic.BaseModel],\n"); - output.push_str(" data: object,\n"); - output.push_str(" handler: typing.Callable[[object], typing.Any],\n"); - output.push_str(") -> typing.Any:\n"); - output.push_str( - " null_fields = typing.cast(frozenset[str], getattr(cls, \"_OPTIONAL_NON_NULLABLE_FIELDS\"))\n", - ); - output.push_str(" raw_data = data\n"); - output.push_str(" pre_errors: list[pydantic_core.InitErrorDetails] = []\n"); - output.push_str(" if isinstance(data, dict):\n"); - output.push_str(" values = typing.cast(dict[str, object], data)\n"); - output.push_str(" pre_errors = [\n"); - output.push_str(" pydantic_core.InitErrorDetails(\n"); - output.push_str(" type=pydantic_core.PydanticCustomError(\n"); - output - .push_str(" \"null_for_nonnullable\", \"explicit null not allowed\"\n"); - output.push_str(" ),\n"); - output.push_str(" loc=(field,),\n"); - output.push_str(" input=None,\n"); - output.push_str(" )\n"); - output.push_str(" for field in null_fields\n"); - output.push_str(" if field in values and values[field] is None\n"); - output.push_str(" ]\n"); - output.push_str(" try:\n"); - output.push_str(" instance = handler(raw_data)\n"); - output.push_str(" except pydantic.ValidationError as error:\n"); - output.push_str(" field_errors: list[pydantic_core.InitErrorDetails] = []\n"); - output.push_str( - " for error_detail in typing.cast(list[dict[str, object]], error.errors()):\n", - ); - output.push_str(" loc: tuple[str | int, ...] = tuple(\n"); - output.push_str( - " typing.cast(collections.abc.Iterable[str | int], error_detail[\"loc\"])\n", - ); - output.push_str(" )\n"); - output.push_str(" field_errors.append(\n"); - output.push_str(" pydantic_core.InitErrorDetails(\n"); - output.push_str(" type=pydantic_core.PydanticCustomError(\n"); - output.push_str(" typing.cast(typing.Any, error_detail[\"type\"]),\n"); - output.push_str(" typing.cast(typing.Any, error_detail[\"msg\"]),\n"); - output.push_str(" ),\n"); - output.push_str(" loc=loc,\n"); - output.push_str(" input=error_detail.get(\"input\"),\n"); - output.push_str(" )\n"); - output.push_str(" )\n"); - output.push_str(" raise pydantic.ValidationError.from_exception_data(\n"); - output.push_str(" title=cls.__name__, line_errors=pre_errors + field_errors\n"); - output.push_str(" ) from None\n"); - output.push_str(" if pre_errors:\n"); - output.push_str(" raise pydantic.ValidationError.from_exception_data(\n"); - output.push_str(" title=cls.__name__, line_errors=pre_errors\n"); - output.push_str(" )\n"); - output.push_str(" return instance\n"); -} - -fn render_set_fields_helper(output: &mut String) { - output.push_str("def _emit_set_fields(\n"); - output.push_str(" model: pydantic.BaseModel,\n"); - output.push_str(" handler: typing.Callable[[pydantic.BaseModel], typing.Any],\n"); - output.push_str(") -> dict[str, object]:\n"); - output.push_str(" dumped = typing.cast(dict[str, object], handler(model))\n"); - output.push_str(" alias_of = {\n"); - output.push_str( - " name: (field.alias or name) for name, field in type(model).model_fields.items()\n", - ); - output.push_str(" }\n"); - output.push_str(" keep = {alias_of.get(name, name) for name in model.model_fields_set}\n"); - output.push_str(" out = {key: value for key, value in dumped.items() if key in keep}\n"); - output.push_str(" if model.model_extra:\n"); - output.push_str(" out.update(typing.cast(dict[str, object], model.model_extra))\n"); - output.push_str(" return out\n"); -} - /// Builds the boolean Python sub-conditions that define "match" for a scalar /// `contains` matcher over `elem`. A type-only matcher matches every element, so /// an empty condition set renders as the literal `True`. @@ -1885,319 +3962,6 @@ fn py_matcher_condition(matcher: &Schema, elem: &str) -> Result<String> { } } -/// Emits one `_validate_arrays` after-validator covering every array field that -/// needs `uniqueItems` / `contains` enforcement (both lack a native Pydantic -/// equivalent). Violations aggregate into a single `pydantic.ValidationError`. -fn render_array_validators( - output: &mut String, - fields: &[(String, String, &Schema)], -) -> Result<()> { - if fields.is_empty() { - return Ok(()); - } - - output.push_str("\n @pydantic.model_validator(mode=\"after\")\n"); - output.push_str(" def _validate_arrays(self) -> typing.Any:\n"); - output.push_str(" errors: list[pydantic_core.InitErrorDetails] = []\n"); - for (json_name, field_name, schema) in fields { - let loc = python_string_literal(json_name); - output.push_str(" value = self."); - output.push_str(field_name); - output.push('\n'); - output.push_str(" if value is not None:\n"); - if schema.unique_items == Some(true) { - output.push_str(" seen: dict[object, int] = {}\n"); - output.push_str(" for index, element in enumerate(value):\n"); - output.push_str(" if element in seen:\n"); - output.push_str(" errors.append(\n"); - output.push_str(" pydantic_core.InitErrorDetails(\n"); - output - .push_str(" type=pydantic_core.PydanticCustomError(\n"); - output.push_str( - " \"unique_items\", typing.cast(typing.Any, f\"duplicate items: element at index {index} equals index {seen[element]}\")\n", - ); - output.push_str(" ),\n"); - output.push_str(&format!(" loc=({loc},),\n")); - output.push_str(" input=element,\n"); - output.push_str(" )\n"); - output.push_str(" )\n"); - output.push_str(" else:\n"); - output.push_str(" seen[element] = index\n"); - } - if let Some(matcher) = &schema.contains { - let condition = py_matcher_condition(matcher, "element")?; - let effective_min = schema.min_contains.unwrap_or(1); - output.push_str(&format!( - " match_count = sum(1 for element in value if {condition})\n" - )); - if effective_min > 0 { - output.push_str(&format!(" if match_count < {effective_min}:\n")); - let message = if schema.min_contains.is_some() { - format!( - "typing.cast(typing.Any, f\"too few matching items: at least {effective_min}, got {{match_count}}\")" - ) - } else { - "\"no element matches the required schema\"".to_string() - }; - let error_type = if schema.min_contains.is_some() { - "too_few_matching_items" - } else { - "contains" - }; - output.push_str(" errors.append(\n"); - output.push_str(" pydantic_core.InitErrorDetails(\n"); - output.push_str(&format!( - " type=pydantic_core.PydanticCustomError(\n {}, {message}\n ),\n", - python_string_literal(error_type) - )); - output.push_str(&format!(" loc=({loc},),\n")); - output.push_str(" input=value,\n"); - output.push_str(" )\n"); - output.push_str(" )\n"); - } - if let Some(max) = schema.max_contains { - output.push_str(&format!(" if match_count > {max}:\n")); - output.push_str(" errors.append(\n"); - output.push_str(" pydantic_core.InitErrorDetails(\n"); - output.push_str(&format!( - " type=pydantic_core.PydanticCustomError(\n \"too_many_matching_items\", typing.cast(typing.Any, f\"too many matching items: at most {max}, got {{match_count}}\")\n ),\n" - )); - output.push_str(&format!(" loc=({loc},),\n")); - output.push_str(" input=value,\n"); - output.push_str(" )\n"); - output.push_str(" )\n"); - } - } - } - output.push_str(" if errors:\n"); - output.push_str(" raise pydantic.ValidationError.from_exception_data(\n"); - output.push_str(" title=type(self).__name__, line_errors=errors\n"); - output.push_str(" )\n"); - output.push_str(" return self\n"); - Ok(()) -} - -fn render_const_validators(output: &mut String, fields: &[(String, String, Value)]) -> Result<()> { - if fields.is_empty() { - return Ok(()); - } - - for (json_name, field_name, const_value) in fields { - let const_literal = python_value_literal(const_value)?; - let error_message = format!("{json_name} must equal {const_literal}"); - output.push_str("\n @pydantic.model_validator(mode=\"before\")\n"); - output.push_str(" @classmethod\n"); - output.push_str(" def _inject_const_"); - output.push_str(field_name); - output.push_str("(\n"); - output.push_str(" cls,\n"); - output.push_str(" data: object,\n"); - output.push_str(" ) -> object:\n"); - output.push_str(" if isinstance(data, dict):\n"); - output.push_str(" values = typing.cast(dict[str, object], data)\n"); - output.push_str(" if "); - output.push_str(&python_string_literal(json_name)); - output.push_str(" not in values"); - if field_name != json_name { - output.push_str(" and "); - output.push_str(&python_string_literal(field_name)); - output.push_str(" not in values"); - } - output.push_str(":\n"); - output.push_str(" data = {**values, "); - output.push_str(&python_string_literal(json_name)); - output.push_str(": "); - output.push_str(&const_literal); - output.push_str("}\n"); - output.push_str(" elif values.get("); - output.push_str(&python_string_literal(json_name)); - output.push_str(", values.get("); - output.push_str(&python_string_literal(field_name)); - output.push_str(")) != "); - output.push_str(&const_literal); - output.push_str(":\n"); - output.push_str(" raise pydantic_core.PydanticCustomError(\n"); - output.push_str(" \"const\", "); - output.push_str(&python_string_literal(&error_message)); - output.push_str("\n"); - output.push_str(" )\n"); - output.push_str(" return typing.cast(object, data)\n"); - } - - Ok(()) -} - -/// Emits, per `enum` field, a `model_validator(mode="before")` membership check. -/// Unlike `const` there is no injection (no single value to fill on absence — -/// presence is owned by `required`); a present out-of-set value raises an -/// aggregated `enum` error naming the set and the offending value. String/int/ -/// bool enums are additionally closed by their `Literal` annotation; float enums -/// (plain `float`) rest on this check alone. See `specs/json-schema/features/enum.md`. -fn render_enum_validators( - output: &mut String, - fields: &[(String, String, Vec<Value>)], -) -> Result<()> { - if fields.is_empty() { - return Ok(()); - } - - for (json_name, field_name, values) in fields { - let literals = values - .iter() - .map(python_value_literal) - .collect::<Result<Vec<_>>>()?; - let set_literal = format!("[{}]", literals.join(", ")); - let message = format!( - "{json_name} must be one of [{}], got {{got}}", - literals.join(", ") - ); - output.push_str("\n @pydantic.model_validator(mode=\"before\")\n"); - output.push_str(" @classmethod\n"); - output.push_str(" def _check_enum_"); - output.push_str(field_name); - output.push_str("(\n"); - output.push_str(" cls,\n"); - output.push_str(" data: object,\n"); - output.push_str(" ) -> object:\n"); - output.push_str(" if isinstance(data, dict):\n"); - output.push_str(" values = typing.cast(dict[str, object], data)\n"); - output.push_str(" if "); - output.push_str(&python_string_literal(json_name)); - output.push_str(" in values"); - if field_name != json_name { - output.push_str(" or "); - output.push_str(&python_string_literal(field_name)); - output.push_str(" in values"); - } - output.push_str(":\n"); - output.push_str(" got = values.get("); - output.push_str(&python_string_literal(json_name)); - if field_name != json_name { - output.push_str(", values.get("); - output.push_str(&python_string_literal(field_name)); - output.push_str(")"); - } - output.push_str(")\n"); - output.push_str(" if got not in "); - output.push_str(&set_literal); - output.push_str(":\n"); - output.push_str(" raise pydantic_core.PydanticCustomError(\n"); - output.push_str(" \"enum\", "); - output.push_str(&python_string_literal(&message)); - output.push_str(", {\"got\": got}\n"); - output.push_str(" )\n"); - output.push_str(" return typing.cast(object, data)\n"); - } - - Ok(()) -} - -fn render_optional_non_nullable_validator(output: &mut String, fields: &BTreeSet<String>) { - if fields.is_empty() { - return; - } - - output.push_str( - "\n _OPTIONAL_NON_NULLABLE_FIELDS: typing.ClassVar[frozenset[str]] = frozenset({", - ); - for (index, field) in fields.iter().enumerate() { - if index != 0 { - output.push_str(", "); - } - output.push_str(&python_string_literal(field)); - } - output.push_str("})\n\n"); - output.push_str(" @pydantic.model_validator(mode=\"wrap\")\n"); - output.push_str(" @classmethod\n"); - output.push_str(" def _reject_null(\n"); - output.push_str(" cls,\n"); - output.push_str(" data: object,\n"); - output.push_str(" handler: typing.Callable[[object], typing.Any],\n"); - output.push_str(" ) -> typing.Any:\n"); - output.push_str(" return _reject_explicit_null(cls, data, handler)\n"); -} - -fn render_set_fields_serializer(output: &mut String) { - output.push_str("\n @pydantic.model_serializer(mode=\"wrap\")\n"); - output.push_str(" def _serialize(\n"); - output.push_str(" self,\n"); - output.push_str(" handler: typing.Callable[[pydantic.BaseModel], typing.Any],\n"); - output.push_str(" ) -> dict[str, object]:\n"); - output.push_str(" return _emit_set_fields(self, handler)\n"); -} - -fn render_field_expr( - output: &mut String, - json_name: &str, - field_name: &str, - default: Option<&str>, - property: &Schema, -) { - output.push_str("pydantic.Field("); - let mut arguments = Vec::new(); - if let Some(default) = default { - arguments.push(format!("default={default}")); - } - if json_name != field_name { - arguments.push(format!("alias={}", python_string_literal(json_name))); - } - arguments.extend(field_constraint_args(property)); - output.push_str(&arguments.join(", ")); - output.push(')'); -} - -/// The `pydantic.Field(...)` arguments for the bounds Pydantic enforces natively. -/// Shared by a declared field and by a typed map's member, which composes them -/// into its own `Annotated[...]` (it has no field to hang them off). -fn field_constraint_args(schema: &Schema) -> Vec<String> { - let mut arguments = Vec::new(); - // Numeric bounds map to native Pydantic constraints (annotated_types - // Ge/Le/Gt/Lt). Integer `multipleOf` uses Pydantic's native `multiple_of` - // (exact for ints); number `multipleOf` is handled by an explicit `fmod` - // AfterValidator in the annotation instead. - let is_integer = schema.is_integer_field(); - if is_integer || schema.is_number_field() { - if let Some(min) = &schema.minimum { - arguments.push(format!("ge={}", py_bound_literal(min, is_integer))); - } - if let Some(max) = &schema.maximum { - arguments.push(format!("le={}", py_bound_literal(max, is_integer))); - } - if let Some(min) = &schema.exclusive_minimum { - arguments.push(format!("gt={}", py_bound_literal(min, is_integer))); - } - if let Some(max) = &schema.exclusive_maximum { - arguments.push(format!("lt={}", py_bound_literal(max, is_integer))); - } - if is_integer && let Some(divisor) = &schema.multiple_of { - arguments.push(format!("multiple_of={}", py_bound_literal(divisor, true))); - } - } - // String-length bounds map to Pydantic's native `min_length`/`max_length`, - // which count Unicode code points (verified in `maxLength.md`) — spec-correct - // without a custom validator. - if schema.is_string_field() { - if let Some(min) = schema.min_length { - arguments.push(format!("min_length={min}")); - } - if let Some(max) = schema.max_length { - arguments.push(format!("max_length={max}")); - } - } - // Array `minItems`/`maxItems` map to Pydantic's native `min_length`/ - // `max_length`, which bound the element count for sequences — spec-correct - // without a custom validator (see minItems.md / maxItems.md). - if schema.is_array_field() { - if let Some(min) = schema.min_items { - arguments.push(format!("min_length={min}")); - } - if let Some(max) = schema.max_items { - arguments.push(format!("max_length={max}")); - } - } - arguments -} - /// Composes a docstring from a `title` (summary line) and `description` (body); /// returns `None` when both are empty. See specs/json-schema/features/{title,description}.md. fn compose_python_doc(title: Option<&str>, description: Option<&str>) -> Option<String> { @@ -2268,16 +4032,6 @@ fn temporal_kind_direct(schema: &Schema) -> Option<crate::json_schema::format::T .and_then(crate::json_schema::format::TemporalKind::from_name) } -/// The runtime-module `Annotated` alias name for a materialized `contentEncoding`. -fn content_encoding_field_alias( - encoding: crate::json_schema::content_encoding::Encoding, -) -> &'static str { - match encoding { - crate::json_schema::content_encoding::Encoding::Base64 => "Base64Field", - crate::json_schema::content_encoding::Encoding::Base64Url => "Base64UrlField", - } -} - /// The materialized `contentEncoding` of a schema that is directly a bytes string /// (the `oneOf[…, null]` wrapper is handled by `annotation` recursion). fn content_encoding_direct( @@ -2292,119 +4046,6 @@ fn content_encoding_direct( .and_then(crate::json_schema::content_encoding::Encoding::from_name) } -/// The runtime-module `Annotated` alias name for a materialized temporal kind. -fn temporal_field_alias(kind: crate::json_schema::format::TemporalKind) -> &'static str { - match kind { - crate::json_schema::format::TemporalKind::DateTime => "DateTimeField", - crate::json_schema::format::TemporalKind::Date => "DateField", - crate::json_schema::format::TemporalKind::Time => "TimeField", - crate::json_schema::format::TemporalKind::Duration => "DurationField", - } -} - -/// The emitted annotation for a value, with the refinements Pydantic expresses as -/// `Annotated` validators layered on: a number's `multipleOf` (`math.fmod`-exact), -/// a string's `pattern`, and a string's `format`. The bounds Pydantic takes as -/// native `Field` arguments are added separately (see [`field_constraint_args`]), -/// so a value position that has no `Field` — a typed map's member — composes the -/// two itself. -/// The annotation for a value in a position that has **no declared field of its -/// own** to hang `pydantic.Field(...)` off — a typed map's member, a `oneOf` sum -/// type's member — so every constraint the schema declares rides inside the -/// annotation itself. This is the same set of predicates a declared field of that -/// type is held to, so the position validates identically. -fn constrained_annotation(schema: &Schema) -> Result<String> { - constrained_annotation_over(schema, annotation(schema)?) -} - -/// [`constrained_annotation`] over an already-built base annotation (a nullable -/// member widens the base before the constraints are applied). -/// -/// The `Field` bounds sit *innermost*, next to the type they bound — the position -/// a declared field puts them in — so Pydantic reads `min_length` as the string's -/// own length and not as the length of whatever an outer validator returned; the -/// refinement validators wrap that result. -fn constrained_annotation_over(schema: &Schema, base: String) -> Result<String> { - let mut annotation = base; - let constraints = field_constraint_args(schema); - if !constraints.is_empty() { - annotation = format!( - "typing.Annotated[{annotation}, pydantic.Field({})]", - constraints.join(", ") - ); - } - // `uniqueItems` / `contains` have no native Pydantic form, and there is no - // declared field here to hang a model validator off, so they ride as - // AfterValidators in the annotation itself. - if schema.is_array_field() { - if schema.unique_items == Some(true) { - annotation = format!( - "typing.Annotated[{annotation}, pydantic.AfterValidator(_check_unique_items)]" - ); - } - if let Some(matcher) = &schema.contains { - let condition = py_matcher_condition(matcher, "element")?; - let min = schema.min_contains.unwrap_or(1); - let max = match schema.max_contains { - Some(max) => max.to_string(), - None => "None".to_string(), - }; - annotation = format!( - "typing.Annotated[{annotation}, pydantic.AfterValidator(_check_contains(lambda element: {condition}, {min}, {max}, {}))]", - if schema.min_contains.is_some() { - "True" - } else { - "False" - } - ); - } - } - refined_annotation(schema, Some(annotation)) -} - -fn refined_annotation(schema: &Schema, base: Option<String>) -> Result<String> { - let mut annotation = match base { - Some(base) => base, - None => annotation(schema)?, - }; - if let Some(divisor) = schema.number_multiple_of() { - annotation = format!( - "typing.Annotated[{annotation}, pydantic.AfterValidator(_check_multiple_of({}))]", - py_bound_literal(divisor, false) - ); - } - if let Some(pattern) = &schema.pattern - && schema.ty.as_ref().and_then(Value::as_str) == Some("string") - { - // Per-target `$`→`\Z` rewrite: `re`'s `\Z` is the strict - // end-of-string anchor (no trailing-`\n` exception). See - // `specs/json-schema/features/pattern.md`. - let rewritten = crate::json_schema::pattern::rewrite_end_anchor(pattern, r"\Z"); - annotation = format!( - "typing.Annotated[{annotation}, pydantic.AfterValidator(_check_pattern({}))]", - python_string_literal(&rewritten) - ); - } - if let Some(format) = &schema.format - && schema.ty.as_ref().and_then(Value::as_str) == Some("string") - && let Some(check) = crate::json_schema::format::check_for(format) - { - // Per-target `$`→`\Z` rewrite (strict end-of-string, no trailing-`\n` - // exception), matching `_check_pattern`. - let rewritten = crate::json_schema::pattern::rewrite_end_anchor(&check.pattern, r"\Z"); - let max_arg = match check.max_code_points { - Some(max) => format!(", {max}"), - None => String::new(), - }; - annotation = format!( - "typing.Annotated[{annotation}, pydantic.AfterValidator(_check_format({}, {}{max_arg}))]", - python_string_literal(check.name), - python_string_literal(&rewritten) - ); - } - Ok(annotation) -} - fn annotation(schema: &Schema) -> Result<String> { if let Some(const_value) = &schema.const_value && let Some(annotation) = python_literal_annotation(const_value) @@ -2436,16 +4077,17 @@ fn annotation(schema: &Schema) -> Result<String> { let nullable = one_of .iter() .any(|branch| branch.ty.as_ref().and_then(Value::as_str) == Some("null")); - // Two or more non-null branches form a closed sum type — a - // `typing.Union[...]` (Pydantic v2 smart mode selects the branch by - // token / `Literal` discriminant). One non-null branch is the - // degenerate nullability pattern. + // Two or more non-null branches form a closed sum type — a native + // `A | B` union the converter selects a branch of by JSON token and + // discriminant. One non-null branch is the degenerate nullability + // pattern. if non_null.len() >= 2 { - // Each member carries its own branch's constraints, so the branch is - // validated once the union selects it ([[oneOf]] §"Validator mapping"). + // A branch's own constraints are checked by the union's dispatcher, + // not carried on the annotation, so the member type is the plain + // branch type ([[oneOf]] §"Validator mapping"). let mut members = non_null .iter() - .map(|branch| constrained_annotation(branch)) + .map(|branch| annotation(branch)) .collect::<Result<Vec<_>>>()?; if nullable { members.push("None".to_string()); @@ -2457,22 +4099,21 @@ fn annotation(schema: &Schema) -> Result<String> { }; return Ok(optional_annotation(&annotation(branch)?)); } - // A materialized temporal `format` replaces `str` with a native typed field, - // carried by a runtime-module `Annotated` alias (BeforeValidator parse + - // PlainSerializer generator-owned serialize). The `oneOf[…, null]` nullable - // wrapper is handled above by recursing into the non-null branch. + // A materialized temporal `format` replaces `str` with the native Python + // type; the converter owns the parse and the canonical serialize. The + // `oneOf[…, null]` nullable wrapper is handled above by recursing into the + // non-null branch. if let Some(kind) = temporal_kind_direct(schema) { - return Ok(temporal_field_alias(kind).to_string()); + return Ok(python_temporal_type(kind).to_string()); } - // A materialized `contentEncoding` replaces `str` with `bytes`, carried by a - // runtime-module `Annotated` alias (BeforeValidator parse + PlainSerializer - // generator-owned canonical serialize). - if let Some(encoding) = content_encoding_direct(schema) { - return Ok(content_encoding_field_alias(encoding).to_string()); + // A materialized `contentEncoding` replaces `str` with `bytes`; the converter + // owns the codec in both directions. + if content_encoding_direct(schema).is_some() { + return Ok("bytes".to_string()); } match schema.ty.as_ref().and_then(Value::as_str) { Some("string") => Ok("str".to_string()), - Some("integer") => Ok("SpecInt".to_string()), + Some("integer") => Ok("int".to_string()), Some("number") => Ok("float".to_string()), Some("boolean") => Ok("bool".to_string()), Some("array") => { @@ -2525,29 +4166,6 @@ fn schema_type_includes(schema: &Schema, ty: &str) -> bool { } } -fn schema_uses_integer(schema: &Schema) -> bool { - schema_type_includes(schema, "integer") - || schema - .properties - .as_ref() - .is_some_and(|properties| properties.values().any(schema_uses_integer)) - || schema - .items - .as_ref() - .is_some_and(|items| schema_uses_integer(items)) - || schema - .one_of - .as_ref() - .is_some_and(|branches| branches.iter().any(schema_uses_integer)) - || schema - .additional_properties - .as_ref() - .and_then(|additional_properties| { - serde_json::from_value::<Schema>(additional_properties.clone()).ok() - }) - .is_some_and(|additional_properties| schema_uses_integer(&additional_properties)) -} - fn object_annotation(schema: &Schema) -> Result<String> { if schema .properties diff --git a/src/parser/json_schema.rs b/src/parser/json_schema.rs index 4d140f8c..0b4bb9d3 100644 --- a/src/parser/json_schema.rs +++ b/src/parser/json_schema.rs @@ -5861,11 +5861,14 @@ pub(crate) fn build_name_manifest( service.origin_label(), )?; } - // TypeScript `DEFAULT_<FIELD>` / `<FIELD>_CONST` constants and per-model - // transfer type converters share the module scope; make them participate - // rather than silently coexist (P15). + // The Python and TypeScript `DEFAULT_<FIELD>` constants share the module + // scope; make them participate rather than silently coexist (P15). + if matches!(language, Language::Python | Language::TypeScript) { + collect_default_constants(language, module_key, &ns_models, &mut top)?; + } + // TypeScript additionally emits `<FIELD>_CONST` bindings and a per-model + // transfer type converter into that same module scope. if language == Language::TypeScript { - collect_ts_default_constants(module_key, &ns_models, &mut top)?; collect_ts_const_constants(module_key, &ns_models, &mut top)?; collect_ts_transfer_type_converters(module_key, &ns_models, &mut top)?; } @@ -5894,28 +5897,17 @@ pub(crate) fn build_name_manifest( /// `./definitions` beside `export *` of the model modules, so a user type of /// either name is silently shadowed out of the package surface (P7). The /// runtime helper functions (`isPlainObject`, `collect`, …) are `camelCase`. -/// - Python (`src/generator/json/python.rs`): the `UpperCamelCase` runtime type -/// aliases imported into model modules (`SpecInt`, the materialized temporal -/// and base64 field aliases). There is no generated `Violation`/error class — -/// aggregation uses `pydantic.ValidationError`. The other runtime helpers are -/// `_`-prefixed. +/// - Python (`src/generator/json/python.rs`): `Violation` (dataclass) and +/// `ValidationError` (exception) are imported by bare name into every model +/// module; the other runtime helpers are `_`-prefixed. /// - Java (`src/generator/java.rs`): the root-package runtime classes /// `Violation`, `ValidationException`, and `SpecNumbers`, each emitted as its /// own always-present public file and imported into model files. /// (`TemporalSupport`/`Base64Support` are schema-dependent, so excluded.) fn boilerplate_idents(language: Language) -> &'static [&'static str] { match language { - Language::Go => &["Violation", "ValidationError"], + Language::Go | Language::Python => &["Violation", "ValidationError"], Language::TypeScript => &["Violation", "ValidationError", "TransferTypeConverter"], - Language::Python => &[ - "SpecInt", - "DateTimeField", - "DateField", - "TimeField", - "DurationField", - "Base64Field", - "Base64UrlField", - ], Language::Java => &["Violation", "ValidationException", "SpecNumbers"], _ => &[], } @@ -6116,10 +6108,10 @@ fn validate_member_scope(language: Language, model_full_name: &str, schema: &Sch Ok(()) } -/// TypeScript `DEFAULT_<FIELD>` constants (module scope). The generator names a -/// default constant `DEFAULT_<FIELD>` when the member identifier is unique across -/// the module's models, else `DEFAULT_<MODEL>_<FIELD>`. Replicate that name and -/// enter it into the shared module namespace so a genuine clash rejects (P15) +/// Python and TypeScript `DEFAULT_<FIELD>` constants (module scope). Both +/// generators name a default constant `DEFAULT_<FIELD>` when the member is unique +/// across the module's models, else `DEFAULT_<MODEL>_<FIELD>`. Replicate that name +/// and enter it into the shared module namespace so a genuine clash rejects (P15) /// rather than silently coexisting behind the model-name prefix. /// /// The identifier is built from the **emitted member identifier**, so an @@ -6128,7 +6120,8 @@ fn validate_member_scope(language: Language, model_full_name: &str, schema: &Sch /// from the JSON name, two members that recase alike would collide here with no /// way to author around it: the override would move the members apart while /// leaving both constants on the colliding name. -fn collect_ts_default_constants( +fn collect_default_constants( + language: Language, module_key: &str, models: &[NsModel], top: &mut Namespace, @@ -6175,7 +6168,7 @@ fn collect_ts_default_constants( ) }; top.insert( - Language::TypeScript, + language, ident, format!("`{}.{json_name}` DEFAULT_ constant", model.full_name), )?; @@ -9658,6 +9651,41 @@ properties: parse_for(Language::Python, input).expect("Python has no OrDefault accessor"); } + #[test] + fn rejects_colliding_default_constants_python_and_typescript() { + // Python and TypeScript hoist a defaulted field's value to a module-level + // `DEFAULT_<FIELD>` constant (unprefixed, because each field name occurs + // in exactly one model). `fooBar` and `foo_bar` shouty-snake-case to the + // same `DEFAULT_FOO_BAR`, a module-scope clash. + let input = r##" +$schema: https://json-schema.org/draft/2020-12/schema +type: object +properties: + a: { $ref: "#/$defs/A" } + b: { $ref: "#/$defs/B" } +$defs: + A: + type: object + properties: + fooBar: { type: string, default: "x" } + B: + type: object + properties: + foo_bar: { type: string, default: "y" } +"##; + for language in [Language::Python, Language::TypeScript] { + let error = reject_for(language, input); + assert!( + error.contains("collision") && error.contains("DEFAULT_FOO_BAR"), + "{language:?}: {error}" + ); + } + // Go and Java keep the default on the model (no module-level constant), + // so the same schema is accepted there. + parse_for(Language::Go, input).expect("Go emits no DEFAULT_ constants"); + parse_for(Language::Java, input).expect("Java emits no DEFAULT_ constants"); + } + #[test] fn rejects_synthesized_operation_input_colliding_with_defs_type() { // The synthesized `<Op>Input` type collides with a declared `$defs` type @@ -10273,9 +10301,10 @@ $defs: } #[test] - fn rejects_type_colliding_with_go_runtime_boilerplate() { + fn rejects_type_colliding_with_go_and_python_runtime_boilerplate() { // Go emits the exported runtime type `ValidationError` into the models' - // own package, so a `$defs` type of that name is a package-scope clash. + // own package, so a `$defs` type of that name is a package-scope clash; + // Python imports the same name into every model module. let input = r##" $schema: https://json-schema.org/draft/2020-12/schema type: object @@ -10286,14 +10315,16 @@ $defs: type: object properties: { a: { type: string } } "##; - let error = reject_for(Language::Go, input); - assert!( - error.contains("collision") && error.contains("ValidationError"), - "{error}" - ); - // Python aggregates via `pydantic.ValidationError` (a qualified name), so - // it emits no top-level `ValidationError` and the same schema is accepted. - parse_for(Language::Python, input).expect("Python has no ValidationError boilerplate"); + for language in [Language::Go, Language::Python] { + let error = reject_for(language, input); + assert!( + error.contains("collision") && error.contains("ValidationError"), + "{language:?}: {error}" + ); + } + // Java names its aggregate error `ValidationException`, not + // `ValidationError`, so the same schema is accepted for Java. + parse_for(Language::Java, input).expect("Java has no ValidationError boilerplate"); } #[test] @@ -10315,8 +10346,11 @@ $defs: error.contains("collision") && error.contains("Violation"), "{error}" ); - // Python has no `Violation` symbol, so it accepts the schema. - parse_for(Language::Python, input).expect("Python has no Violation boilerplate"); + // Java names its aggregate error `ValidationException`; TypeScript has no + // such symbol, so that name is accepted. + let input = input.replace("Violation", "ValidationException"); + parse_for(Language::TypeScript, &input) + .expect("TypeScript has no ValidationException boilerplate"); } #[test] @@ -10437,10 +10471,8 @@ $defs: #[test] fn rejects_type_colliding_with_java_violation_boilerplate() { - // Per the task: Java `$defs: { Violation: {...} }` rejects for Java (Java - // emits a public `Violation` record). `Violation` is boilerplate for Go, - // TypeScript and Java alike; only Python (which has no `Violation`) - // accepts it, so Python is the "not boilerplate" language here. + // Java emits a public `Violation` record in the root package, imported + // into model files, so a `$defs` type of that name clashes. let input = r##" $schema: https://json-schema.org/draft/2020-12/schema type: object @@ -10456,29 +10488,31 @@ $defs: error.contains("collision") && error.contains("Violation"), "{error}" ); - parse_for(Language::Python, input).expect("Python has no Violation boilerplate"); } #[test] fn rejects_type_colliding_with_python_runtime_boilerplate() { - // Python imports the runtime type alias `SpecInt` into model modules for - // any integer field, so a `$defs` type named `SpecInt` clashes. + // Python imports the runtime `Violation` dataclass into every model + // module by bare name, so a `$defs` type of that name clashes with the + // import. let input = r##" $schema: https://json-schema.org/draft/2020-12/schema type: object properties: - s: { $ref: "#/$defs/SpecInt" } + v: { $ref: "#/$defs/Violation" } $defs: - SpecInt: + Violation: type: object properties: { a: { type: string } } "##; let error = reject_for(Language::Python, input); assert!( - error.contains("collision") && error.contains("SpecInt"), + error.contains("collision") && error.contains("Violation"), "{error}" ); - // `SpecInt` is Python-specific runtime naming; Go has no such symbol. - parse_for(Language::Go, input).expect("Go has no SpecInt boilerplate"); + // Java names its aggregate error `ValidationException`; Python has no + // such symbol, so that name is accepted. + let input = input.replace("Violation", "ValidationException"); + parse_for(Language::Python, &input).expect("Python has no ValidationException boilerplate"); } } diff --git a/tests/generate_python.rs b/tests/generate_python.rs index e57e7762..707f62f6 100644 --- a/tests/generate_python.rs +++ b/tests/generate_python.rs @@ -387,29 +387,57 @@ fn python_json_example_generation_matches_checked_in_output() { assert_eq!(rendered, expected, "snapshot mismatch for {example_id}"); if example_id == "showcase" { let all = rendered.values().cloned().collect::<Vec<_>>().join("\n"); - // Scalar defaults surface natively via the Pydantic field default. - assert!(all.contains("greeting: str = pydantic.Field(default=\"hello\")")); - assert!(all.contains("debug: bool = pydantic.Field(default=False)")); + // A scalar `default` is advisory: the member is encoded like any other + // optional one (so an unset key stays omitted and the wire stays + // byte-identical) and the default rides on a `DEFAULT_<FIELD>` + // constant the consumer applies, as in TypeScript. + assert!(all.contains("greeting: str | None = None")); + assert!(all.contains("debug: bool | None = None")); + assert!(all.contains("DEFAULT_GREETING")); + assert!(all.contains("DEFAULT_DEBUG")); + assert!(all.contains("DEFAULT_RETRIES")); // `deprecated` → PEP 702 marker (no runtime warning); `title` → docstring. assert!(all.contains( "typing_extensions.deprecated(\"This field is deprecated.\", category=None)" )); assert!(all.contains("Retry budget")); // `x-py-name` override (Stage 4): the attribute uses the override - // while the wire name is pinned by `Field(alias="legacyId")`. + // while the wire name stays `legacyId`, pinned by the converter body. assert!(all.contains("legacy_id_py:")); - assert!(all.contains("alias=\"legacyId\"")); - // A free-form object inlines as a mapping — both as a union branch - // and (extra="allow" + a member-count validator) as a named model. + assert!(all.contains("\"legacyId\"")); + // A free-form object inlines as a mapping as a union branch, and as a + // named model with an explicit `additional_properties` catch-all. assert!(all.contains("payload: dict[str, typing.Any] | str | None")); - assert!(all.contains("class Extras(pydantic.BaseModel):")); + assert!(all.contains("class Extras:")); + assert!( + all.contains("additional_properties: dict[str, typing.Any] = dataclasses.field(") + ); + // Each model is a plain dataclass carrying a private transfer type + // converter, so the default Temporal data converter picks it up. The + // registration goes through the runtime's `_transfer_type_convertible` + // shim, which erases the converter's value-type parameter — binding it + // on the decorated class is circular for a static type checker. + assert!(all.contains("@dataclasses.dataclass(slots=True, kw_only=True)")); + assert!(all.contains("@_transfer_type_convertible(_ExtrasTransferTypeConverter)")); + assert!(all.contains( + "def _transfer_type_convertible(\n converter: type[temporalio.converter.TransferTypeConverter[typing.Any, typing.Any]],\n) -> collections.abc.Callable[[type[_ModelT]], type[_ModelT]]:" + )); + assert!( + all.contains( + " return temporalio.converter.transfer_type_convertible(converter)" + ) + ); // A tagged union whose branches are written inline: each branch names - // itself with `x-py-name` and becomes a model Pydantic selects on. - assert!(all.contains("class TextNote(pydantic.BaseModel):")); + // itself with `x-py-name` and becomes a model of its own. + assert!(all.contains("class TextNote:")); assert!(all.contains("Note: typing.TypeAlias = TextNote | LinkNote")); + // A named union cannot be decorated, so its conversion is emitted as + // module-private free functions instead. + assert!(all.contains("def _note_from_transfer_type(")); + assert!(all.contains("def _note_to_transfer_type(")); // The lone inline object branch of a property union derives its name // from the union it belongs to. - assert!(all.contains("class ShowcaseDetailObject(pydantic.BaseModel):")); + assert!(all.contains("class ShowcaseDetailObject:")); assert!(all.contains("detail: ShowcaseDetailObject | str | None")); assert!(all.contains("must have at most 4 properties")); } @@ -858,8 +886,8 @@ fn python_rejects_support_namespace() { } /// An inline **structured** object `oneOf` branch on a property: the branch is -/// named `<Union>Object` and emitted as a module-level `BaseModel`, which is what -/// Pydantic selects on for the object member of the union. +/// named `<Union>Object` and emitted as a module-level dataclass, which the +/// union's dispatcher selects for the object member of the union. /// See `specs/json-schema/features/oneOf.md` ("Object branches"). #[test] fn python_json_names_inline_object_union_branch() { @@ -884,7 +912,8 @@ fn python_json_names_inline_object_union_branch() { let rendered = fs::read_to_string(output_path.join("models.py")).unwrap(); assert!(rendered.contains("payload: DetailPayloadObject | str | None")); - assert!(rendered.contains("class DetailPayloadObject(pydantic.BaseModel):")); + assert!(rendered.contains("class DetailPayloadObject:")); + assert!(rendered.contains("class _DetailPayloadObjectTransferTypeConverter(")); assert!(rendered.contains("text: str")); // The branch model is part of the module surface, like any named definition. let exports = fs::read_to_string(output_path.join("__init__.py")).unwrap(); @@ -892,11 +921,11 @@ fn python_json_names_inline_object_union_branch() { fs::remove_dir_all(temp_dir).unwrap(); } -/// Every constraint a **non-object** branch declares rides inside the union -/// member's own annotation, so Pydantic holds the value to the branch it selected -/// — the native `Field` bounds innermost, the refinement validators wrapping -/// them, and the `uniqueItems`/`contains` validators Pydantic has no native form -/// for. See `specs/json-schema/features/oneOf.md` ("Validator mapping"). +/// Every constraint a **non-object** branch declares is enforced by the union's +/// dispatcher rather than by the annotation, so the field annotation is the plain +/// union of the branch types while the bound, the `pattern`, and the +/// `uniqueItems` check all live in the converter body. +/// See `specs/json-schema/features/oneOf.md` ("Validator mapping"). #[test] fn python_json_validates_non_object_union_branch_constraints() { let temp_dir = unique_output_path("py-json-branch-constraints"); @@ -919,26 +948,27 @@ fn python_json_validates_non_object_union_branch_constraints() { .unwrap(); let rendered = fs::read_to_string(output_path.join("models.py")).unwrap(); - // The string branch: native length bound innermost, `pattern` wrapping it; - // the integer branch's bound is its own. - assert!(rendered.contains( - "value: typing.Annotated[typing.Annotated[str, pydantic.Field(min_length=3)], pydantic.AfterValidator(_check_pattern(\"^[a-z]+\\\\Z\"))] | typing.Annotated[SpecInt, pydantic.Field(ge=1)] | None" - )); - // The array branch: `minItems` natively, `uniqueItems` through the validator - // a position with no declared field of its own needs. - assert!(rendered.contains( - "typing.Annotated[typing.Annotated[list[float], pydantic.Field(min_length=1)], pydantic.AfterValidator(_check_unique_items)] | typing.Literal[\"auto\", \"manual\"] | None" - )); - // The validators are imported from the runtime module. - assert!(rendered.contains("_check_pattern,")); - assert!(rendered.contains("_check_unique_items,")); + // The string branch's `minLength`/`pattern` and the integer branch's + // `minimum` leave no residue on the annotation: it is the plain branch union. + assert!(rendered.contains("value: str | int | None")); + // Same for the array branch's `minItems`/`uniqueItems`; a closed value set + // still narrows to a `typing.Literal`. + assert!(rendered.contains("list[float] | typing.Literal[\"auto\", \"manual\"] | None")); + // The branch checks themselves live in the converter body: a `pattern` lowers + // to a `.search` against a module-level compiled regex const, `uniqueItems` to + // a runtime helper imported from the definitions module. + assert!( + rendered.contains("_PATTERN_F242E3A159C2422C = re.compile(\"^[a-z]+\\\\Z\", re.ASCII)") + ); + assert!(rendered.contains("if _PATTERN_F242E3A159C2422C.search(value) is None:")); + assert!(rendered.contains("_check_unique_items(")); fs::remove_dir_all(temp_dir).unwrap(); } /// A union in an element position: the loader names it, so Python emits an -/// ordinary union alias and Pydantic selects the branch per element. An optional -/// field whose *elements* are nullable still needs its own `| None` — the -/// element's `None` is not the field's. +/// ordinary union alias and the converter dispatches the branch per element. An +/// optional field whose *elements* are nullable still needs its own `| None` — +/// the element's `None` is not the field's. /// See `specs/json-schema/features/oneOf.md` ("Unions in element positions"). #[test] fn python_json_annotates_element_position_unions() { @@ -962,7 +992,7 @@ fn python_json_annotates_element_position_unions() { .unwrap(); let rendered = fs::read_to_string(output_path.join("models.py")).unwrap(); - assert!(rendered.contains("BagSegmentsItem: typing.TypeAlias = str | SpecInt")); + assert!(rendered.contains("BagSegmentsItem: typing.TypeAlias = str | int")); assert!(rendered.contains("segments: list[BagSegmentsItem] | None")); assert!(rendered.contains("choices: list[Choice] | None")); assert!(rendered.contains("slots: list[str | None] | None")); From 09977881e665004aad3e217c9be2ddb175b32eb4 Mon Sep 17 00:00:00 2001 From: Roey Berman <roey.berman@gmail.com> Date: Fri, 14 Aug 2026 10:25:14 -0700 Subject: [PATCH 02/20] Fix the Python temporal runtime: no bare ValueError, no invalid output The generated temporal helpers validated with the pinned regex and then handed the string to a `datetime` parser that could still raise, so three classes of input escaped the converter as a bare `ValueError` instead of the aggregated `ValidationError` (P11) -- and the serialize direction had no validation at all, so an unchecked dataclass silently produced wire bytes its own parser rejects (P12). - Year 0000. `datetime.MINYEAR` is 1, so the value has no Python representation; `_valid_temporal_calendar` now rejects it and `_temporal_reason` names the limit. Go/TypeScript/Java all accept year 0, so this is a genuine per-language accept-set divergence Python cannot avoid. - Sub-second precision. Before 3.11 `fromisoformat` parses only the fraction widths `isoformat` writes, so RFC 3339 `.1` and `.1234567` raised on the declared 3.10 floor while every other target accepted them. `_temporal_isoformat` pads or truncates the fraction to 6 digits (and folds in the `Z` rewrite), so the accepted set no longer depends on the interpreter version. Digits past the sixth are dropped at `datetime`'s own resolution -- the loss P1 exception (b) allows, mirroring Go's truncation at nanoseconds. Canonical output is unchanged: `_temporal_frac` re-trims, so `.1` still writes as `.1`. - Duration overflow. CPython refuses `int()` on more than 4300 digits, so the guard now bounds the magnitude by digit count before converting (leading zeros stripped first, matching TypeScript's `Number()`), and a 5000-digit component produces a violation instead of crashing. - Serialize-side representability. `_check_date_time`/`_check_time`/ `_check_duration` hold a materialized value to what the narrowed grammar can spell -- a naive datetime, a sub-minute UTC offset, a negative, sub-second or over-cap duration -- and append a `Violation` under the field's own path rather than emitting bad data. `date` needs no predicate: every `datetime.date` writes a valid wire date. The predicates land in the shared `Validate` layer (P12.2) via `render_py_field_checks`, so the call site stays a single expression and the serialize path picks them up for declared properties, nullable members and typed map members alike, with the correct path and full aggregation. --- src/generator/json_schema/python.rs | 224 ++++++++++++++++++++++++---- 1 file changed, 198 insertions(+), 26 deletions(-) diff --git a/src/generator/json_schema/python.rs b/src/generator/json_schema/python.rs index 64dce23d..749c777b 100644 --- a/src/generator/json_schema/python.rs +++ b/src/generator/json_schema/python.rs @@ -699,6 +699,9 @@ const JSON_RUNTIME_SYMBOLS: &[&str] = &[ "ValidationError", "Violation", "_check_contains", + "_check_date_time", + "_check_duration", + "_check_time", "_check_unique_items", "_collect", "_format_base64", @@ -785,6 +788,9 @@ fn render_json_runtime_module() -> String { "ValidationError", "Violation", "_check_contains", + "_check_date_time", + "_check_duration", + "_check_time", "_check_unique_items", "_collect", "_format_base64", @@ -899,11 +905,14 @@ def _transfer_type_convertible( "#; /// Emits the materialized-temporal runtime: the pinned narrowed regexes, the -/// Gregorian calendar predicate, and the violation-collecting parse / canonical -/// serialize helpers the converters call for each of the four kinds. See +/// Gregorian calendar predicate, the violation-collecting parse helpers, the +/// serialize-side representability predicates, and the canonical formatters the +/// converters call for each of the four kinds. See /// `specs/json-schema/features/format.md`. The parse is generator-owned rather /// than `datetime.fromisoformat` alone, which accepts a missing offset and -/// normalizes differently from the narrowed grammar. +/// normalizes differently from the narrowed grammar; every value the regex admits +/// is normalized into a spelling `fromisoformat` cannot raise on, so a rejection +/// is always an aggregated `Violation` and never a `ValueError` (P11). fn render_temporal_helpers(output: &mut String) { use crate::json_schema::format::TemporalKind; output.push_str(&format!( @@ -926,6 +935,13 @@ fn render_temporal_helpers(output: &mut String) { } const TEMPORAL_HELPER_BODY: &str = r#"_TEMPORAL_MAX_DURATION_SECONDS = ((1 << 63) - 1) // 1_000_000_000 +# A duration component with more digits than the cap itself is over the cap +# whatever those digits are, which is how the magnitude is bounded before `int()` +# sees it: CPython refuses to convert a string of more than 4300 digits. +_TEMPORAL_MAX_DURATION_DIGITS = len(str(_TEMPORAL_MAX_DURATION_SECONDS)) +# `datetime` resolves to microseconds, and `fromisoformat` before Python 3.11 +# parses only the fraction widths `isoformat` writes. +_TEMPORAL_FRACTION_DIGITS = 6 def _days_in_month(year: int, month: int) -> int: @@ -945,31 +961,75 @@ def _valid_temporal_calendar(value: str) -> bool: year, month, day = int(value[0:4]), int(value[5:7]), int(value[8:10]) except ValueError: return False + # `datetime.MINYEAR` is 1, so year 0000 -- which the wire grammar admits and + # the other three targets materialize -- has no Python value at all. It is + # rejected rather than shifted into range, and `_temporal_reason` says so. + if year < datetime.MINYEAR: + return False maximum = _days_in_month(year, month) return maximum > 0 and 1 <= day <= maximum +def _temporal_reason(name: str, value: str) -> str: + """The reason a rejected temporal string is reported under. + + Year 0000 earns its own clause: it is a valid wire value the other targets + accept, so a caller needs to read Python's floor rather than conclude the + timestamp was malformed. + """ + + if value[0:4] == "0000": + return ( + f"must be a valid {name}, got {_quote(value)}: year 0000 is not" + f" representable (datetime.MINYEAR is {datetime.MINYEAR})" + ) + return f"must be a valid {name}, got {_quote(value)}" + + +def _temporal_isoformat(value: str) -> str: + """Rewrites a wire temporal into the spelling `fromisoformat` accepts. + + `Z` becomes `+00:00`, and the fractional second is padded or truncated to + exactly `_TEMPORAL_FRACTION_DIGITS`: before Python 3.11 `fromisoformat` + parses only what `isoformat` writes, so an RFC 3339 `.1` or `.1234567` -- + which every other target accepts -- would otherwise raise. Digits past the + sixth are dropped, the loss at `datetime`'s own resolution that P1 allows; + the canonical output re-trims the padding, so `.1` still writes as `.1`. + """ + + normalized = value.upper() + if normalized.endswith("Z"): + normalized = normalized[:-1] + "+00:00" + dot = normalized.find(".") + if dot < 0: + return normalized + end = dot + 1 + while end < len(normalized) and normalized[end].isdigit(): + end += 1 + fraction = normalized[dot + 1 : end].ljust(_TEMPORAL_FRACTION_DIGITS, "0") + return ( + normalized[: dot + 1] + + fraction[:_TEMPORAL_FRACTION_DIGITS] + + normalized[end:] + ) + + def _parse_date_time( value: str, path: str, violations: list[Violation] ) -> datetime.datetime | None: if _TEMPORAL_DATE_TIME_RE.match(value) is None or not _valid_temporal_calendar(value): violations.append( - Violation(path=path, reason=f"must be a valid date-time, got {_quote(value)}") + Violation(path=path, reason=_temporal_reason("date-time", value)) ) return None - normalized = value.upper() - if normalized.endswith("Z"): - normalized = normalized[:-1] + "+00:00" - return datetime.datetime.fromisoformat(normalized) + return datetime.datetime.fromisoformat(_temporal_isoformat(value)) def _parse_date( value: str, path: str, violations: list[Violation] ) -> datetime.date | None: if _TEMPORAL_DATE_RE.match(value) is None or not _valid_temporal_calendar(value): - violations.append( - Violation(path=path, reason=f"must be a valid date, got {_quote(value)}") - ) + violations.append(Violation(path=path, reason=_temporal_reason("date", value))) return None return datetime.date.fromisoformat(value) @@ -978,14 +1038,9 @@ def _parse_time( value: str, path: str, violations: list[Violation] ) -> datetime.time | None: if _TEMPORAL_TIME_RE.match(value) is None: - violations.append( - Violation(path=path, reason=f"must be a valid time, got {_quote(value)}") - ) + violations.append(Violation(path=path, reason=_temporal_reason("time", value))) return None - normalized = value.upper() - if normalized.endswith("Z"): - normalized = normalized[:-1] + "+00:00" - return datetime.time.fromisoformat(normalized) + return datetime.time.fromisoformat(_temporal_isoformat(value)) def _parse_duration( @@ -993,7 +1048,7 @@ def _parse_duration( ) -> datetime.timedelta | None: if _TEMPORAL_DURATION_RE.match(value) is None: violations.append( - Violation(path=path, reason=f"must be a valid duration, got {_quote(value)}") + Violation(path=path, reason=_temporal_reason("duration", value)) ) return None total = 0 @@ -1002,16 +1057,103 @@ def _parse_duration( if char.isdigit(): number += char continue - total += int(number) * {"H": 3600, "M": 60, "S": 1}[char] + digits = number.lstrip("0") number = "" + if len(digits) > _TEMPORAL_MAX_DURATION_DIGITS: + # Over the cap by digit count alone (see the constant), so the + # conversion `int()` would refuse is never attempted. + total = _TEMPORAL_MAX_DURATION_SECONDS + 1 + break + total += int(digits or "0") * {"H": 3600, "M": 60, "S": 1}[char] if total > _TEMPORAL_MAX_DURATION_SECONDS: - violations.append( - Violation(path=path, reason=f"must be a valid duration, got {_quote(value)}") - ) - return None + break + if total > _TEMPORAL_MAX_DURATION_SECONDS: + violations.append( + Violation(path=path, reason=_temporal_reason("duration", value)) + ) + return None return datetime.timedelta(seconds=total) +def _check_temporal_offset( + name: str, + value: datetime.datetime | datetime.time, + offset: datetime.timedelta, + path: str, + violations: list[Violation], +) -> None: + """Asserts a UTC offset is a whole number of minutes, the finest the wire + form spells (`tzinfo` allows seconds, which the offset would silently lose). + """ + + if offset % datetime.timedelta(minutes=1): + violations.append( + Violation( + path=path, + reason=( + f"must be a valid {name}, got {_quote(str(value))}: " + f"the UTC offset {offset} is not a whole number of minutes" + ), + ) + ) + + +def _check_date_time( + value: datetime.datetime, path: str, violations: list[Violation] +) -> None: + """Asserts a datetime is writable as a wire date-time (P12). + + A dataclass is constructed unchecked, so a naive datetime -- with no offset + the required wire form could carry -- reaches serialize; without this it + would emit a value this module's own parser rejects. + """ + + offset = value.utcoffset() + if offset is None: + violations.append( + Violation( + path=path, + reason=( + f"must be a valid date-time, got {_quote(str(value))}: " + "a naive datetime carries no UTC offset" + ), + ) + ) + return + _check_temporal_offset("date-time", value, offset, path, violations) + + +def _check_time(value: datetime.time, path: str, violations: list[Violation]) -> None: + """Asserts a time is writable as a wire time (P12). The offset is optional in + the grammar, so only its precision is held to anything.""" + + offset = value.utcoffset() + if offset is not None: + _check_temporal_offset("time", value, offset, path, violations) + + +def _check_duration( + value: datetime.timedelta, path: str, violations: list[Violation] +) -> None: + """Asserts a timedelta is writable as a wire duration (P12): the grammar is + unsigned, whole-second and capped, and a `timedelta` is none of those.""" + + if value < datetime.timedelta(0): + reason = "a duration cannot be negative" + elif value % datetime.timedelta(seconds=1): + reason = "a duration cannot carry a fraction of a second" + elif value.total_seconds() > _TEMPORAL_MAX_DURATION_SECONDS: + reason = f"a duration cannot exceed {_TEMPORAL_MAX_DURATION_SECONDS} seconds" + else: + return + violations.append( + Violation( + path=path, + reason=f"must be a valid duration, got {_quote(str(value))}: {reason}", + ) + ) + + def _temporal_frac(microsecond: int) -> str: if microsecond == 0: return "" @@ -1647,9 +1789,11 @@ fn py_model_needs_serialize_validation(schema: &Schema) -> Result<bool> { } /// Emits the per-value constraint checks over an in-memory `value_expr`, -/// reusing the same emitters the parse path calls. References, temporal, and +/// reusing the same emitters the parse path calls. References and /// contentEncoding carry no check here — a nested converter validates its own -/// value, and a materialized repr re-encodes losslessly. +/// value, and `bytes` re-encodes losslessly. A materialized temporal does carry +/// one: the native type is wider than the narrowed wire grammar, so it is held to +/// what that grammar can spell. fn render_py_field_checks( output: &mut String, schema: &Schema, @@ -1672,6 +1816,18 @@ fn render_py_field_checks( } return Ok(()); } + // A materialized temporal is held to what the wire grammar can spell: a + // `datetime` may be naive and a `timedelta` may be negative, sub-second, or + // past the cap, none of which has a wire form. Construction is unchecked, so + // without this the formatter would emit bytes this module's own parser + // rejects (P12). + if let Some(kind) = temporal_kind_direct(schema) { + if let Some(check) = python_temporal_check_fn(kind) { + output.push_str(indent); + output.push_str(&format!("{check}({value_expr}, {path_expr}, violations)\n")); + } + return Ok(()); + } if let Some(const_value) = &schema.const_value { let literal = python_value_literal(const_value)?; let reason = @@ -1795,6 +1951,22 @@ fn python_temporal_format_fn(kind: crate::json_schema::format::TemporalKind) -> } } +/// The predicate a materialized temporal value is held to before it is written, +/// or `None` for `date` — every `datetime.date` Python can hold writes a valid +/// wire date, so there is nothing to assert. See `_check_date_time` in the +/// runtime for why the other three do have something to assert. +fn python_temporal_check_fn( + kind: crate::json_schema::format::TemporalKind, +) -> Option<&'static str> { + use crate::json_schema::format::TemporalKind; + match kind { + TemporalKind::DateTime => Some("_check_date_time"), + TemporalKind::Time => Some("_check_time"), + TemporalKind::Duration => Some("_check_duration"), + TemporalKind::Date => None, + } +} + fn python_content_encoding_parse_fn( encoding: crate::json_schema::content_encoding::Encoding, ) -> &'static str { From 0fe597b94eacc5921c1f1baeb0fb7c4635f9ef41 Mon Sep 17 00:00:00 2001 From: Roey Berman <roey.berman@gmail.com> Date: Fri, 14 Aug 2026 10:29:08 -0700 Subject: [PATCH 03/20] Reject non-finite numbers, normalize integral closed values (Python) Two wire-compatibility fixes in the JSON-Schema Python emitter. `multipleOf` on a `number` crashed instead of validating. Python's `json.loads` accepts the `Infinity`/`-Infinity`/`NaN` literals its dialect adds, and `math.fmod(inf, n)` raises `ValueError` (an integer literal past the binary64 range raises `OverflowError`), so untrusted wire bytes escaped the aggregated `ValidationError` (P11). A `number` without `multipleOf` was worse: `inf`/`nan` parsed and re-serialized verbatim, bytes that Go's `json.Unmarshal`, `JSON.parse` and Jackson all reject (P1). The shared numeric checks now guard finiteness first and hang the remaining predicates off its `else`, so the check runs in both directions (P12). This converges on Go, which rejects every non-finite and out-of-binary64-range `number` on parse and refuses to marshal one on the way out. An integer `const`/`enum` kept the wire float: `{"revision": 1.0}` stored `1.0` and re-serialized as `1.0`, where Go (`parseIntegerField`) and Java (`SpecNumbers.specLong`) normalize to an integer and emit `1`. A closed numeric set now routes the wire value through `_parse_spec_integer` before the membership comparison, which also reinstates the `1.5` reject and the integer cap those fields bypassed. Float-valued sets have no `Literal` form (PEP 586) and keep the wire value as it arrived. The checked-in samples are regenerated separately. --- src/generator/json_schema/python.rs | 234 +++++++++++++++++++++------- 1 file changed, 176 insertions(+), 58 deletions(-) diff --git a/src/generator/json_schema/python.rs b/src/generator/json_schema/python.rs index 749c777b..086c3d56 100644 --- a/src/generator/json_schema/python.rs +++ b/src/generator/json_schema/python.rs @@ -1390,9 +1390,29 @@ fn render_py_violation_if( output.push_str("))\n"); } +/// The largest finite IEEE-754 binary64 magnitude, the range a JSON `number` +/// carries in every other target (Go `float64`, TS `number`, Java `double`). +const PY_BINARY64_MAX: &str = "1.7976931348623157e308"; + /// Emits the numeric-constraint predicates over `value_expr` (an in-scope /// `int`/`float`). `value_expr` is always a bare or dotted name, never a /// subscript, so it is safe to interpolate inside a double-quoted f-string. +/// +/// A `number` is guarded for finiteness first, and every other predicate hangs +/// off that guard's `else`. Python is the only target that can hold a +/// non-finite `number` at all: `json.loads` accepts the `Infinity`, +/// `-Infinity` and `NaN` literals its dialect adds (Go's `json.Unmarshal` and +/// `JSON.parse` reject the bytes outright, and Jackson rejects them by +/// default), and it decodes an over-range literal as `inf` or as an unbounded +/// `int` where Go's `json.Number.Float64` reports a range error. Left +/// unchecked the value re-serializes as `Infinity`/`NaN` — bytes no other +/// target can read (P1) — so it is rejected in both directions (P12). It also +/// has to be rejected *before* the other predicates: `nan` compares false +/// against every bound, and `math.fmod` raises rather than returns +/// (`ValueError` on `inf`, `OverflowError` on an int past the binary64 range), +/// which would escape the aggregated `ValidationError` (P11). An `integer` +/// needs no guard — `_parse_spec_integer` rejects a non-finite wire value and +/// caps the magnitude, and an in-memory `int` is always finite. fn render_py_numeric_checks( output: &mut String, value_expr: &str, @@ -1401,8 +1421,14 @@ fn render_py_numeric_checks( indent: &str, ) { let is_integer = schema.ty.as_ref().and_then(Value::as_str) == Some("integer"); + let body_indent = if is_integer { + indent.to_string() + } else { + format!("{indent} ") + }; + let mut body = String::new(); let mut emit = |condition: String, reason: String| { - render_py_violation_if(output, indent, &condition, path_expr, &reason); + render_py_violation_if(&mut body, &body_indent, &condition, path_expr, &reason); }; if let Some(min) = &schema.minimum { let bound = py_bound_literal(min, is_integer); @@ -1447,6 +1473,27 @@ fn render_py_numeric_checks( format!("f\"must be a multiple of {bound}, got {{{value_expr}}}\""), ); } + if is_integer { + output.push_str(&body); + return; + } + // The chained comparison is the one finiteness test that never raises: + // `math.isfinite` overflows on an int past the binary64 range, while an + // `int`/`float` comparison against a float bound is exact for any + // magnitude, and `nan` fails it the way every other value out of range + // does. + render_py_violation_if( + output, + indent, + &format!("not (-{PY_BINARY64_MAX} <= {value_expr} <= {PY_BINARY64_MAX})"), + path_expr, + &format!("f\"must be a finite number, got {{{value_expr}}}\""), + ); + if !body.is_empty() { + output.push_str(indent); + output.push_str("else:\n"); + output.push_str(&body); + } } /// Emits the string predicates over `value_expr` (an in-scope `str`). @@ -1745,7 +1792,12 @@ fn py_field_needs_serialize_check(schema: &Schema) -> bool { || schema.pattern.is_some() || schema.format.is_some() } - Some("number") | Some("integer") => { + // A `number` always carries one: the finiteness guard applies whether or + // not a bound is declared, because `json.dumps` would otherwise write an + // in-memory `inf`/`nan` out as bytes no other target can read (see + // [`render_py_numeric_checks`]). + Some("number") => true, + Some("integer") => { schema.minimum.is_some() || schema.maximum.is_some() || schema.exclusive_minimum.is_some() @@ -3533,40 +3585,57 @@ fn render_value_parser( return Ok(()); } - if let Some(const_value) = &schema.const_value { - let literal = python_value_literal(const_value)?; - let reason = - python_string_literal(&format!("must equal {}", py_reason_literal(const_value))); - render_py_closed_value_parser( - output, - std::slice::from_ref(const_value), - std::slice::from_ref(&literal), - raw_expr, - target, - &annotation(schema)?, - path_expr, - indent, - &reason, - ); - return Ok(()); - } - if let Some(values) = &schema.enum_values { + // A `const` is the one-member case of the closed value set an `enum` + // declares, so both take the same parse. + if let Some(values) = py_closed_value_set(schema) { let literals = values .iter() .map(python_value_literal) .collect::<Result<Vec<_>>>()?; - let reason = py_enum_reason(values, raw_expr); - render_py_closed_value_parser( - output, - values, - &literals, - raw_expr, - target, - &annotation(schema)?, - path_expr, - indent, - &reason, - ); + let member_type = annotation(schema)?; + if py_closed_set_holds_integer(values, &member_type) { + // An integral closed set holds an `int`, so the wire number is + // normalized through the shared spec-integer parse before the + // comparison — the wire `1.0` *is* the integer `1`, and only an + // `int` re-serializes as `1` rather than `1.0` (Go routes the same + // value through `parseIntegerField`, Java through + // `SpecNumbers.specLong`). Normalizing here is also what reinstates + // the `1.5` reject and the integer cap for these fields, which a + // bare membership test bypasses. + let parsed = format!("{slot}_parsed"); + output.push_str(indent); + output.push_str(&format!( + "{parsed} = _parse_spec_integer({raw_expr}, {path_expr}, violations)\n" + )); + output.push_str(indent); + output.push_str(&format!("if {parsed} is not None:\n")); + // The membership test narrows the normalized `int` to the closed + // literal type it declares, so the member takes it as it is — no + // cast, unlike the pre-normalization form where the comparison ran + // against an `int | float`. + render_py_closed_value_membership( + output, + "if", + &literals, + &parsed, + &parsed, + target, + path_expr, + &format!("{indent} "), + &py_closed_value_reason(schema, values, &parsed), + ); + } else { + render_py_closed_value_parser( + output, + values, + &literals, + raw_expr, + target, + path_expr, + indent, + &py_closed_value_reason(schema, values, raw_expr), + ); + } return Ok(()); } @@ -3742,6 +3811,38 @@ fn py_closed_value_guard(value: &Value, raw_expr: &str) -> Option<(String, &'sta } } +/// The closed value set a schema declares: the single `const` value, or the +/// `enum` members. Both are the same assertion — membership in a fixed set — +/// so both are emitted by one path. +fn py_closed_value_set(schema: &Schema) -> Option<&[Value]> { + schema + .const_value + .as_ref() + .map(std::slice::from_ref) + .or(schema.enum_values.as_deref()) + .filter(|values| !values.is_empty()) +} + +/// True when a closed numeric value set holds an `int` at rest: every member is +/// an integral JSON number, which is exactly when the emitted annotation is a +/// numeric `typing.Literal[…]`. A float-valued set has no `Literal` form (PEP +/// 586 admits no float member), falls through to a plain `float`, and keeps the +/// wire value as it arrived. See `specs/json-schema/features/enum.md`. +fn py_closed_set_holds_integer(values: &[Value], member_type: &str) -> bool { + member_type.starts_with("typing.Literal[") && values.iter().all(Value::is_number) +} + +/// The membership violation reason for a closed value set: a `const` names its +/// single value, an `enum` names the admissible set and the offending value. +fn py_closed_value_reason(schema: &Schema, values: &[Value], value_expr: &str) -> String { + match &schema.const_value { + Some(const_value) => { + python_string_literal(&format!("must equal {}", py_reason_literal(const_value))) + } + None => py_enum_reason(values, value_expr), + } +} + /// Emits the closed-value (`const` single-value / `enum` multi-value) parse: a /// kind test, a membership test against the fixed set, and the assignment on /// success. See `specs/json-schema/features/{const,enum}.md`. @@ -3752,17 +3853,11 @@ fn render_py_closed_value_parser( compare_exprs: &[String], raw_expr: &str, target: &str, - member_type: &str, path_expr: &str, indent: &str, reason: &str, ) { - let membership = compare_exprs - .iter() - .map(|expr| format!("{raw_expr} != {expr}")) - .collect::<Vec<_>>() - .join(" and "); - match values + let keyword = match values .first() .and_then(|value| py_closed_value_guard(value, raw_expr)) { @@ -3774,14 +3869,48 @@ fn render_py_closed_value_parser( " violations.append(Violation(path={path_expr}, reason={}))\n", python_string_literal(kind_reason) )); - output.push_str(indent); - output.push_str(&format!("elif {membership}:\n")); - } - None => { - output.push_str(indent); - output.push_str(&format!("if {membership}:\n")); + "elif" } - } + None => "if", + }; + // The value reaches the member as it arrived: a string, boolean or float + // set narrows to the type it declares through the membership test itself. + render_py_closed_value_membership( + output, + keyword, + compare_exprs, + raw_expr, + raw_expr, + target, + path_expr, + indent, + reason, + ); +} + +/// Emits the membership test of a closed value set and the assignment on +/// success. `keyword` chains the test onto a preceding kind test (`elif`) or +/// opens it (`if`); `compared` is the expression held to the set and +/// `assignment` the value stored once it passes. +#[allow(clippy::too_many_arguments)] +fn render_py_closed_value_membership( + output: &mut String, + keyword: &str, + compare_exprs: &[String], + compared: &str, + assignment: &str, + target: &str, + path_expr: &str, + indent: &str, + reason: &str, +) { + let membership = compare_exprs + .iter() + .map(|expr| format!("{compared} != {expr}")) + .collect::<Vec<_>>() + .join(" and "); + output.push_str(indent); + output.push_str(&format!("{keyword} {membership}:\n")); output.push_str(indent); output.push_str(&format!( " violations.append(Violation(path={path_expr}, reason={reason}))\n" @@ -3789,18 +3918,7 @@ fn render_py_closed_value_parser( output.push_str(indent); output.push_str("else:\n"); output.push_str(indent); - // A string or boolean value set narrows to its literal type through the - // membership test itself. A numeric one does not: the kind test admits - // `int | float` (`1.0` is the integer `1`), so the member is cast to the - // closed literal type it declares. - if member_type.starts_with("typing.Literal[") && values.first().is_some_and(Value::is_number) { - output.push_str(&format!( - " {target} = typing.cast({}, {raw_expr})\n", - python_string_literal(member_type) - )); - } else { - output.push_str(&format!(" {target} = {raw_expr}\n")); - } + output.push_str(&format!(" {target} = {assignment}\n")); } /// Emits the elementwise parse of an array. Every element is appended, valid or From 00cd39c1146c274032518e4e4eda4fd4f57a059c Mon Sep 17 00:00:00 2001 From: Roey Berman <roey.berman@gmail.com> Date: Fri, 14 Aug 2026 10:32:51 -0700 Subject: [PATCH 04/20] Fix Python union element typing, serialize aggregation, no-branch and alias docs Four fixes to the generated Python JSON-Schema union layer and serialize path. 1. A union's array branch now decodes elementwise (P1). It cast the whole value to `list[float]` and ran only `minItems`/`uniqueItems`, so any list was admitted: `{"measurements": ["a", "b"]}` round-tripped verbatim while Go decodes `[]float64` and Java binds a typed list, both rejecting. The branch reuses the same element parse a declared array member runs (split out of `render_array_parser` as `render_py_array_elements`, which the token guard lets the union call without re-testing `isinstance(value, list)`), so a bad element reports at its own index (`measurements[0]`). This also resolves the `True == 1` uniqueness discrepancy at its root: a `bool` in a `list[float]` is now rejected as a bad element before `_check_unique_items` sees it. 2. Serialize-side nested violations are re-pathed and aggregated (P11/P12). `to_transfer_type` wrapped no nested conversion, so a nested `ValidationError` propagated raw -- discarding the parent's already collected violations and its own path prefix. Every nested conversion -- `$ref` members, array elements, typed-map members and union dispatchers -- now runs under a `try` that funnels through `_collect`, the analogue of Go's `mergeNested`: a `Showcase` with a bad `name` and a bad `location.city` reports both, under those paths, and a bad element reports at `segments[1]` / `rows[1].cell`. 3. A union's serialize now rejects a value matching no branch (P12). The dispatch still falls through to its last branch unguarded (that decision is deliberate and documented), but a value in *no* branch was emitted verbatim -- bytes every parser rejects. The terminal test widens the value to `object` first, so the guards are neither provably exhaustive nor redundant and basedpyright stays at zero errors and zero warnings. 4. A union `TypeAlias`'s docstring follows its assignment, as a module-level variable docstring must and as dataclass members already do. Emitted before it, each union's description documented the preceding statement and the last alias's text was dropped. An optional+nullable member no longer repeats the caller's `is not None` guard around its serialize-side checks, which pyright reports as an unnecessary comparison. Checked-in samples are intentionally not regenerated here. --- src/generator/json_schema/python.rs | 463 ++++++++++++++++++++++++---- 1 file changed, 395 insertions(+), 68 deletions(-) diff --git a/src/generator/json_schema/python.rs b/src/generator/json_schema/python.rs index 086c3d56..f6df4d0b 100644 --- a/src/generator/json_schema/python.rs +++ b/src/generator/json_schema/python.rs @@ -646,6 +646,13 @@ pub(in crate::generator) fn render_external_models( for model in &union_models { let schema = decode_schema(model)?; push_section(&mut body); + body.push_str(&model.model_name); + body.push_str(": typing.TypeAlias = "); + body.push_str(&annotation(&schema)?); + body.push('\n'); + // A module-level variable docstring *follows* its assignment — the same + // placement a dataclass member's docstring takes. Emitted before it, the + // string would document whatever statement precedes the alias. render_python_docstring( &mut body, "", @@ -654,10 +661,6 @@ pub(in crate::generator) fn render_external_models( None, false, ); - body.push_str(&model.model_name); - body.push_str(": typing.TypeAlias = "); - body.push_str(&annotation(&schema)?); - body.push('\n'); } // Each is emitted only when the rendered body actually references the module @@ -1774,6 +1777,12 @@ fn py_field_needs_serialize_check(schema: &Schema) -> bool { if schema.const_value.is_some() || schema.enum_values.is_some() { return true; } + // A sum type always has something to check: even with no constraint on any + // branch, the member is held to matching *some* branch (see + // `render_py_union_value_checks`). + if is_py_union(schema) { + return true; + } // An inline sum type: any branch that declares something is re-checked // against the member it holds. A `$ref` branch validates through its own // converter, so only the non-reference branches count. @@ -1845,10 +1854,12 @@ fn py_model_needs_serialize_validation(schema: &Schema) -> Result<bool> { /// contentEncoding carry no check here — a nested converter validates its own /// value, and `bytes` re-encodes losslessly. A materialized temporal does carry /// one: the native type is wider than the narrowed wire grammar, so it is held to -/// what that grammar can spell. +/// what that grammar can spell. `models` resolves a union branch's `$ref` to the +/// shape it names; a position that cannot hold a union passes none. fn render_py_field_checks( output: &mut String, schema: &Schema, + models: &[&PlannedJsonType], value_expr: &str, path_expr: &str, indent: &str, @@ -1856,15 +1867,14 @@ fn render_py_field_checks( // A nullability wrapper's constraints live on its non-null branch; the // caller has already guarded the value against `None`. if let Some(non_null) = nullable_member_schema(schema) { - return render_py_field_checks(output, non_null, value_expr, path_expr, indent); + return render_py_field_checks(output, non_null, models, value_expr, path_expr, indent); } // An inline sum type narrows to the branch it holds and runs that branch's - // own checks. The branches that matter here are the non-object ones, which - // need no `$ref` resolution; an object branch validates through its own - // converter instead. + // own checks. An object branch validates through its own converter instead, + // and contributes only its arm of the no-branch-matched test. if is_py_union(schema) { - if let Some(union) = classify_py_union(schema, &[])? { - render_py_union_value_checks(output, &union, value_expr, path_expr, indent)?; + if let Some(union) = classify_py_union(schema, models)? { + render_py_union_value_checks(output, &union, models, value_expr, path_expr, indent)?; } return Ok(()); } @@ -2704,7 +2714,9 @@ fn render_py_union_parse( // The token has selected the branch; the value is now held to everything // the branch declares (P12 — the same predicates a property of that type // runs). A branch whose declared type is narrower than its token is cast - // to it once, and the checks run over the narrowed name. + // to it once, and the checks run over the narrowed name. A branch is a + // scalar or array shape, never a union of its own, so its checks need no + // model list to resolve branch `$ref`s with. let selected = match (&variant.parse_fn, variant.token) { // A materialized branch parses through its runtime helper; the token // guard has already established the wire is a string. @@ -2720,7 +2732,7 @@ fn render_py_union_parse( (None, PyToken::Integer) => { output.push_str(&inner); output.push_str(&format!("number = int({value_expr})\n")); - render_py_field_checks(output, &variant.schema, "number", path_expr, &inner)?; + render_py_field_checks(output, &variant.schema, &[], "number", path_expr, &inner)?; if variant.narrowed { output.push_str(&inner); output.push_str(&format!( @@ -2732,26 +2744,44 @@ fn render_py_union_parse( "number".to_string() } } - (None, PyToken::Array) => { - output.push_str(&inner); - output.push_str(&format!( - "items = typing.cast({}, {value_expr})\n", - python_string_literal(&variant.py_type) - )); - render_py_field_checks(output, &variant.schema, "items", path_expr, &inner)?; - "items".to_string() - } + // An array branch is decoded elementwise, exactly as a declared array + // member is: the `list` token selects the branch, but it says nothing + // about what the elements are, and `list[float]` admits only numbers + // (P1 — Go and Java decode into a typed list and reject a bad + // element). The array-level predicates then run over the built list. + (None, PyToken::Array) => render_py_array_elements( + output, + &variant.schema, + value_expr, + path_expr, + &inner, + "items", + )?, _ if variant.narrowed => { output.push_str(&inner); output.push_str(&format!( "narrowed = typing.cast({}, {value_expr})\n", python_string_literal(&variant.py_type) )); - render_py_field_checks(output, &variant.schema, "narrowed", path_expr, &inner)?; + render_py_field_checks( + output, + &variant.schema, + &[], + "narrowed", + path_expr, + &inner, + )?; "narrowed".to_string() } _ => { - render_py_field_checks(output, &variant.schema, value_expr, path_expr, &inner)?; + render_py_field_checks( + output, + &variant.schema, + &[], + value_expr, + path_expr, + &inner, + )?; value_expr.to_string() } }; @@ -2818,13 +2848,19 @@ fn render_py_union_object_branch( output.push_str(" return None\n"); } +/// The local a union's in-memory value is widened through before the +/// no-branch-matched test. See [`render_py_union_value_checks`]. +const PY_UNION_CANDIDATE: &str = "candidate"; + /// Emits the constraint checks a union's **in-memory** value is held to, narrowed /// to the branch it holds: one guarded block per non-object branch that declares -/// anything (P12). Object branches carry their own validation in their model's -/// converter, so they contribute no block. +/// anything (P12), then the terminal test that *some* branch matched at all. +/// Object branches carry their own validation in their model's converter, so they +/// contribute no constraint block — only their arm of that terminal test. fn render_py_union_value_checks( output: &mut String, union: &PyUnion, + models: &[&PlannedJsonType], value_expr: &str, path_expr: &str, indent: &str, @@ -2834,6 +2870,7 @@ fn render_py_union_value_checks( render_py_field_checks( &mut body, &variant.schema, + models, value_expr, path_expr, &format!("{indent} "), @@ -2845,6 +2882,41 @@ fn render_py_union_value_checks( output.push_str(&format!("if {}:\n", variant.memory_guard(value_expr))); output.push_str(&body); } + + // Nothing enforces a Python annotation at runtime, so a member holding a value + // admitted by *no* branch is a real state — and one the per-branch blocks above + // say nothing about, since each is guarded by its own kind test. Left + // unreported it would serialize verbatim, emitting bytes every parser + // (Python's own included) rejects, so it is the same aggregated violation the + // parse side reports for an inadmissible wire token (P12: both directions run + // the same checks). The value is widened to `object` first: read through the + // declared union a closed set of guards can be provably exhaustive, which puts + // the violation in code pyright reports as unreachable — and the widening + // costs nothing, because the guards are the runtime tests either way. The + // serialize *dispatch* is unaffected and still falls through to its last + // branch unguarded (see `render_py_union_serialize`). + let mut guards: Vec<String> = union + .variants + .iter() + .map(|variant| py_negatable(&variant.memory_guard(PY_UNION_CANDIDATE))) + .collect(); + if union.nullable { + guards.push(format!("{PY_UNION_CANDIDATE} is None")); + } + if guards.is_empty() { + return Ok(()); + } + output.push_str(indent); + output.push_str(&format!( + "{PY_UNION_CANDIDATE} = typing.cast(\"object\", {value_expr})\n" + )); + output.push_str(indent); + output.push_str(&format!("if not ({}):\n", guards.join(" or "))); + output.push_str(indent); + output.push_str(&format!( + " violations.append(Violation(path={path_expr}, reason={}))\n", + python_string_literal(&format!("expected one of: {}", union.admissible())) + )); Ok(()) } @@ -2899,7 +2971,7 @@ fn render_union_transfer_functions(output: &mut String, models: &[&PlannedJsonTy render_union_parse_function(output, &base, &model.model_name, &union)?; // A named union has no enclosing property to run its branch checks, so // it collects its own and raises the one aggregated error (P11/P12). - render_union_serialize_function(output, &base, &model.model_name, &union, true)?; + render_union_serialize_function(output, &base, &model.model_name, &union, models, true)?; } for model in models { let schema = decode_schema(model)?; @@ -2917,7 +2989,14 @@ fn render_union_transfer_functions(output: &mut String, models: &[&PlannedJsonTy // out, so the serializer is pure dispatch — and is only needed when // some member's in-memory form differs from its wire form. if union.needs_serializer() { - render_union_serialize_function(output, &base, &member_type, &union, false)?; + render_union_serialize_function( + output, + &base, + &member_type, + &union, + models, + false, + )?; } } } @@ -2944,6 +3023,7 @@ fn render_union_serialize_function( base: &str, member_type: &str, union: &PyUnion, + models: &[&PlannedJsonType], with_checks: bool, ) -> Result<()> { push_section(output); @@ -2953,7 +3033,7 @@ fn render_union_serialize_function( )); if with_checks { let mut checks = String::new(); - render_py_union_value_checks(&mut checks, union, "value", "\"\"", " ")?; + render_py_union_value_checks(&mut checks, union, models, "value", "\"\"", " ")?; if !checks.is_empty() { output.push_str(" violations: list[Violation] = []\n"); output.push_str(&checks); @@ -3223,9 +3303,12 @@ fn render_model_serializer_body( ) -> Result<()> { // Serialize-side (P12): re-run the shared field validation over the // in-memory model and raise the aggregated `ValidationError` before emitting - // the wire object — both directions over one set of check emitters. - let needs_validation = py_model_needs_serialize_validation(schema)?; - if needs_validation { + // the wire object — both directions over one set of check emitters. A nested + // conversion aggregates into the same list, so the violations declared here + // also hold everything the members below report (P11). + let needs_violations = + py_model_needs_serialize_validation(schema)? || py_model_serialize_can_raise(schema)?; + if needs_violations { output.push_str("violations: list[Violation] = []\n"); } output.push_str("out: dict[str, typing.Any] = {}\n"); @@ -3233,14 +3316,22 @@ fn render_model_serializer_body( if let Some(shape) = py_map_shape(schema)? { output.push_str("for key, entry in value.additional_properties.items():\n"); if let Some(value_schema) = &shape.value_schema { - render_py_member_check(output, value_schema, "entry", "key", " ")?; + render_py_member_check(output, value_schema, models, "entry", "key", " ")?; } - let entry = match &shape.value_schema { - Some(value_schema) => serialize_expr(value_schema, "entry", 0), - None => "entry".to_string(), - }; - output.push_str(&format!(" out[key] = {entry}\n")); - if needs_validation { + match &shape.value_schema { + Some(value_schema) => render_py_serialize_value( + output, + value_schema, + PySerializeSink::Assign("out[key]"), + "entry", + "key", + " ", + "entry", + )?, + // Free-form members carry no declared shape to convert through. + None => output.push_str(" out[key] = entry\n"), + } + if needs_violations { render_py_property_count_checks(output, "len(out)", schema, ""); if let Some(subschema) = &schema.property_names { render_py_property_name_checks(output, "out", subschema, ""); @@ -3258,6 +3349,8 @@ fn render_model_serializer_body( let field_name = property.py_member_name(json_name); let value_expr = format!("value.{field_name}"); let key = python_string_literal(json_name); + let target = format!("out[{key}]"); + let path_expr = python_string_literal(json_name); // An optional member is emitted under an `is not None` guard, so the // nullability wrapper's own `None` branch is already ruled out and the // transform is taken straight from the member's non-null shape. @@ -3269,23 +3362,50 @@ fn render_model_serializer_body( _ => property, }; // A union whose members need a transform goes through the module's - // union serializer; everything else is a plain expression. - let assignment = match classify_py_union(property, models)? { - Some(union) if union.needs_serializer() => format!( + // union serializer, which is the one conversion not derivable from the + // schema alone (it is named after this property's position). + let inline_union = match classify_py_union(property, models)? { + Some(union) if union.needs_serializer() => Some(format!( "{}({value_expr})", union_serialize_fn(&inline_union_fn_base(&model.model_name, json_name)) - ), - _ => serialize_expr(emitted, &value_expr, 0), + )), + _ => None, }; - if required.contains(json_name) { - render_py_serialize_property_check(output, json_name, property, "")?; - output.push_str(&format!("out[{key}] = {assignment}\n")); - } else { + let guarded = !required.contains(json_name); + let indent = if guarded { // Absent and explicit `null` collapsed to `None` on the way in, // so both re-serialize as omitted. output.push_str(&format!("if {value_expr} is not None:\n")); - render_py_serialize_property_check(output, json_name, property, " ")?; - output.push_str(&format!(" out[{key}] = {assignment}\n")); + " " + } else { + "" + }; + render_py_serialize_property_check( + output, json_name, property, models, guarded, indent, + )?; + match inline_union { + Some(call) if py_serialize_can_raise(property) => render_py_serialize_call( + output, + PySerializeSink::Assign(&target), + &call, + &path_expr, + indent, + ), + // A dispatch over scalar branches alone materializes values; it + // never validates, so there is nothing to re-path. + Some(call) => { + output.push_str(indent); + output.push_str(&format!("{target} = {call}\n")); + } + None => render_py_serialize_value( + output, + emitted, + PySerializeSink::Assign(&target), + &value_expr, + &path_expr, + indent, + &field_name, + )?, } } } @@ -3293,7 +3413,7 @@ fn render_model_serializer_body( output.push_str("for key, entry in value.additional_properties.items():\n"); output.push_str(" out[key] = entry\n"); } - if needs_validation { + if needs_violations { // Object member-count and cross-field constraints over the to-be-emitted // wire key set (`out` holds every distinct wire key, JSON-named). render_py_property_count_checks(output, "len(out)", schema, ""); @@ -3305,25 +3425,198 @@ fn render_model_serializer_body( Ok(()) } +/// Where one value's wire form goes: assigned to a target, or appended to the +/// list an enclosing array is building. +#[derive(Debug, Clone, Copy)] +enum PySerializeSink<'a> { + Assign(&'a str), + Append(&'a str), +} + +impl PySerializeSink<'_> { + fn statement(self, expr: &str) -> String { + match self { + Self::Assign(target) => format!("{target} = {expr}"), + Self::Append(list) => format!("{list}.append({expr})"), + } + } +} + +/// True when any of a model's members converts through a call that can raise, so +/// its serializer needs the violation list even with no constraint of its own. +fn py_model_serialize_can_raise(schema: &Schema) -> Result<bool> { + if let Some(value_schema) = typed_map_value_schema(schema)? + && py_serialize_can_raise(&value_schema) + { + return Ok(true); + } + Ok(schema + .properties + .iter() + .flatten() + .any(|(_, property)| py_serialize_can_raise(property))) +} + +/// True when a value's wire form is produced by a nested converter or a union +/// dispatcher — the calls that raise their own `ValidationError`, whose violations +/// are relative to the nested value and so have to be re-pathed and merged into +/// the caller's list rather than left to propagate (P11; Go's `mergeNested`). +fn py_serialize_can_raise(schema: &Schema) -> bool { + if schema.reference.is_some() { + return true; + } + if is_py_union(schema) { + // Only an object (or nested-union) branch converts through a call; a union + // of plain scalars is emitted as-is, and its checks run at this level. + return schema + .one_of + .iter() + .flatten() + .any(|branch| branch.reference.is_some()); + } + if let Some(non_null) = nullable_member_schema(schema) { + return py_serialize_can_raise(non_null); + } + match schema.items.as_deref() { + Some(items) => py_serialize_can_raise(items), + None => false, + } +} + +/// Emits the statements that put one value's wire form into `sink`, descending +/// into arrays so every nested conversion runs under its own `try` and reports at +/// its own path (`segments[1]`, `location.city`). A value that needs no converting +/// call is a plain assignment, exactly as before. +#[allow(clippy::too_many_arguments)] +fn render_py_serialize_value( + output: &mut String, + schema: &Schema, + sink: PySerializeSink<'_>, + value_expr: &str, + path_expr: &str, + indent: &str, + slot: &str, +) -> Result<()> { + if !py_serialize_can_raise(schema) { + output.push_str(indent); + output.push_str(&sink.statement(&serialize_expr(schema, value_expr, 0))); + output.push('\n'); + return Ok(()); + } + // A nullability wrapper: `None` is the wire value, and the non-null branch + // carries the conversion. + if let Some(non_null) = nullable_member_schema(schema) { + output.push_str(indent); + output.push_str(&format!("if {value_expr} is None:\n")); + output.push_str(indent); + output.push_str(" "); + output.push_str(&sink.statement("None")); + output.push('\n'); + output.push_str(indent); + output.push_str("else:\n"); + return render_py_serialize_value( + output, + non_null, + sink, + value_expr, + path_expr, + &format!("{indent} "), + slot, + ); + } + if schema.ty.as_ref().and_then(Value::as_str) == Some("array") + && let Some(items) = schema.items.as_deref() + { + // Elementwise, so a bad element is reported at its own index and the rest + // of the list is still converted (P11). + let list_local = format!("{slot}_out"); + let index_local = format!("{slot}_index"); + let element_local = format!("{slot}_element"); + output.push_str(indent); + output.push_str(&format!("{list_local}: list[typing.Any] = []\n")); + output.push_str(indent); + output.push_str(&format!( + "for {index_local}, {element_local} in enumerate({value_expr}):\n" + )); + let loop_body = format!("{indent} "); + render_py_serialize_value( + output, + items, + PySerializeSink::Append(&list_local), + &element_local, + &py_indexed_path(path_expr, &index_local), + &loop_body, + &format!("{slot}_item"), + )?; + output.push_str(indent); + output.push_str(&sink.statement(&list_local)); + output.push('\n'); + return Ok(()); + } + render_py_serialize_call( + output, + sink, + &serialize_expr(schema, value_expr, 0), + path_expr, + indent, + ); + Ok(()) +} + +/// Emits one converting call under a `try`, re-pathing its violations under +/// `path_expr` and merging them into the caller's list — the analogue of Go's +/// `mergeNested`, so a nested failure neither escapes alone nor discards the +/// violations already collected (P11/P12). +fn render_py_serialize_call( + output: &mut String, + sink: PySerializeSink<'_>, + call_expr: &str, + path_expr: &str, + indent: &str, +) { + output.push_str(indent); + output.push_str("try:\n"); + output.push_str(indent); + output.push_str(" "); + output.push_str(&sink.statement(call_expr)); + output.push('\n'); + output.push_str(indent); + output.push_str("except ValidationError as error:\n"); + output.push_str(indent); + output.push_str(&format!(" _collect(violations, {path_expr}, error)\n")); +} + /// Emits the serialize-side validation of one declared property, guarding a -/// nullable member so the checks only fire on a materialized value. The caller -/// owns the optional (`is not None`) guard. +/// nullable member so the checks only fire on a materialized value. `guarded` says +/// the caller has already established the member is not `None` — as the optional +/// members' emit guard does — in which case repeating the test here would be a +/// comparison pyright reports as unnecessary (and basedpyright fails the build +/// over), so only a *required* nullable member guards itself. fn render_py_serialize_property_check( output: &mut String, json_name: &str, property: &Schema, + models: &[&PlannedJsonType], + guarded: bool, indent: &str, ) -> Result<()> { let value_expr = format!("value.{}", property.py_member_name(json_name)); let path_expr = python_string_literal(json_name); - let guard_null = allows_null(property); + let guard_null = allows_null(property) && !guarded; let body_indent = if guard_null { format!("{indent} ") } else { indent.to_string() }; let mut body = String::new(); - render_py_field_checks(&mut body, property, &value_expr, &path_expr, &body_indent)?; + render_py_field_checks( + &mut body, + property, + models, + &value_expr, + &path_expr, + &body_indent, + )?; if body.is_empty() { return Ok(()); } @@ -3341,6 +3634,7 @@ fn render_py_serialize_property_check( fn render_py_member_check( output: &mut String, value_schema: &Schema, + models: &[&PlannedJsonType], value_expr: &str, path_expr: &str, indent: &str, @@ -3352,7 +3646,14 @@ fn render_py_member_check( indent.to_string() }; let mut body = String::new(); - render_py_field_checks(&mut body, value_schema, value_expr, path_expr, &body_indent)?; + render_py_field_checks( + &mut body, + value_schema, + models, + value_expr, + path_expr, + &body_indent, + )?; if body.is_empty() { return Ok(()); } @@ -3935,6 +4236,42 @@ fn render_array_parser( indent: &str, slot: &str, ) -> Result<()> { + output.push_str(indent); + output.push_str(&format!("if not isinstance({raw_expr}, list):\n")); + output.push_str(indent); + output.push_str(&format!( + " violations.append(Violation(path={path_expr}, reason=\"expected array\"))\n" + )); + output.push_str(indent); + output.push_str("else:\n"); + let list_local = render_py_array_elements( + output, + schema, + raw_expr, + path_expr, + &format!("{indent} "), + slot, + )?; + output.push_str(indent); + output.push_str(&format!(" {target} = {list_local}\n")); + Ok(()) +} + +/// Emits the elementwise parse of a value the caller has *already* established is +/// a `list`: every element through its own declared type, then the array-level +/// predicates over the built list. Returns the name of the local holding it, so a +/// caller that only needs the value takes it without a copy. Split out of +/// [`render_array_parser`] so a union branch — whose token guard is that same +/// `isinstance` test — reuses it without re-testing (a redundant guard would trip +/// pyright's narrowing diagnostics). +fn render_py_array_elements( + output: &mut String, + schema: &Schema, + raw_expr: &str, + path_expr: &str, + body: &str, + slot: &str, +) -> Result<String> { let item_slot = format!("{slot}_item"); let list_local = format!("{slot}_list"); let index_local = format!("{slot}_index"); @@ -3947,15 +4284,7 @@ fn render_array_parser( .transpose()? .unwrap_or_else(|| "typing.Any".to_string()); - output.push_str(indent); - output.push_str(&format!("if not isinstance({raw_expr}, list):\n")); - output.push_str(indent); - output.push_str(&format!( - " violations.append(Violation(path={path_expr}, reason=\"expected array\"))\n" - )); - output.push_str(indent); - output.push_str("else:\n"); - let body = format!("{indent} "); + let body = body.to_string(); output.push_str(&body); output.push_str(&format!("{list_local}: list[{item_type}] = []\n")); output.push_str(&body); @@ -4001,9 +4330,7 @@ fn render_array_parser( output.push_str(&loop_body); output.push_str(&format!("{list_local}.append({item_slot})\n")); render_py_array_checks(output, &list_local, path_expr, schema, &body)?; - output.push_str(&body); - output.push_str(&format!("{target} = {list_local}\n")); - Ok(()) + Ok(list_local) } /// True when a schema is a bare `string` with nothing else to enforce, which is From 5672a9e11cf64764b4637a78b9e7d00ee8d035a2 Mon Sep 17 00:00:00 2001 From: Roey Berman <roey.berman@gmail.com> Date: Fri, 14 Aug 2026 10:33:39 -0700 Subject: [PATCH 05/20] Python: close the converter's identifier shadowing holes (P15) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A declared property named after one of the converter's own locals silently disabled validation. `violations: list[Violation] = []` was rebound by the `violations` property's slot (`violations: str | None = None`), so the collected violations were thrown away and an invalid payload was returned as a model; `raw`, `len`, `int`, `str`, `bool`, `dict`, `isinstance`, `typing`, `math` and `out` crashed every payload instead. Rather than blocklist names — a property may be named anything — the parse body now holds each property's value in a `<member>_value` slot local, extending the discipline the emitter already applied to its temporaries (`{slot}_raw`, `{slot}_parsed`, …). No fixed local, builtin, imported module, or synthesized module-level name ends in `_value`, and no derived temporary does either, so the slot cannot collide with anything: the shadow is structurally impossible instead of merely unlisted. The module-level names the emitter synthesizes beyond `DEFAULT_<FIELD>` now participate in the P15 collision pass, computed through the emitter's own naming helpers so the check cannot drift from what is emitted: - `_<MODEL>_DECLARED` — `to_shouty_snake_case` is not injective over verbatim `x-py-name` overrides, so `ContactPy` and `ContactPY` shared one frozenset and the loser's declared properties leaked into its catch-all (a P13 break). - `_<base>_{from,to}_transfer_type` — likewise non-injective, and a named union's base can coincide with an inline `<model>_<member>` one. An inline union is now named from the member's *emitted* identifier, so an override moves it (P15's escape hatch has to reach every synthesized name). - `_<Model>TransferTypeConverter` and `_PATTERN_<HEX>` — injective in their own family, but a verbatim override can spell either; the pattern constants are keyed by pattern text, so identical patterns still share one constant. - the converter bodies' own locals, for the mirror-image case: a type overridden to `raw` is shadowed inside every body that parses one. Also reserves `_definitions` as an input-module name alongside `definitions`: Python emits its shared runtime as `_definitions.py`, so an input named `_definitions.yaml` emitted a `_definitions/` package directory at that module's own import path, shadowing it and breaking every generated `from .._definitions import ...`. The checked-in Python JSON samples are left unregenerated; the two snapshot tests fail until they are rebuilt. --- CHANGELOG.md | 24 ++ src/generator/json_schema/python.rs | 97 ++++--- src/parser/json_schema.rs | 436 +++++++++++++++++++++++++++- 3 files changed, 515 insertions(+), 42 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c5efda50..e3ff324d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -152,6 +152,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 overrides, including TypeScript default constants and Go closed-value types. - JSON Schema: A root model can no longer silently collapse with a same-named `$defs` or synthesized model; the loader reports the conflicting origins. +- Python: A declared property named after one of the converter's own locals + **silently disabled validation**. A property named `violations` rebound the + violation accumulator, so a payload that broke a constraint was returned as a + model instead of raising; ten other names (`raw`, `len`, `int`, `str`, `bool`, + `dict`, `isinstance`, `typing`, `math`, `out`) crashed the converter on every + payload. The parse body now holds each property's value in a `<member>_value` + slot local, which cannot coincide with a runtime local, a builtin, an imported + module, or a synthesized module-level name — so no property name can shadow + anything (P15). +- Python: The module-level names the generator synthesizes beyond + `DEFAULT_<FIELD>` — the `_<MODEL>_DECLARED` declared-key sets, the union + `_<base>_{from,to}_transfer_type` functions, the `_<Model>TransferTypeConverter` + classes, and the `_PATTERN_<HEX>` compiled regexes — now participate in the P15 + collision pass. Two types whose `x-py-name` overrides differ only in case + (`ContactPy` / `ContactPY`) previously emitted one `_CONTACT_PY_DECLARED` for + both, and the loser's declared properties leaked into its catch-all; such a + schema is now rejected at load with a fix-it diagnostic. An inline union's + functions are also named from the member's *emitted* identifier, so an + `x-py-name` override moves them. +- JSON Schema: `_definitions` is now a reserved input-module name alongside + `definitions`. Python emits its shared runtime as `_definitions.py`, so an input + named `_definitions.yaml` emitted a `_definitions/` package directory at that + module's own import path — shadowing it and breaking every generated + `from .._definitions import ...`. - JSON Schema: A **non-object `oneOf` branch's own constraints** were dropped in three of four languages: only Go carried them, in the synthesized `<Union><Kind>` variant's `Validate`. TypeScript cast the narrowed value diff --git a/src/generator/json_schema/python.rs b/src/generator/json_schema/python.rs index f6df4d0b..6f112ed0 100644 --- a/src/generator/json_schema/python.rs +++ b/src/generator/json_schema/python.rs @@ -149,10 +149,36 @@ fn is_union_type_name(name: &str) -> bool { } /// The private converter class a model's wire contract lives in. -fn converter_class_name(model_name: &str) -> String { +pub(crate) fn converter_class_name(model_name: &str) -> String { format!("_{model_name}TransferTypeConverter") } +/// The converter body's local holding one declared property's parsed value, +/// until the final keyword-argument construction reads it back. +/// +/// The name is **not** the member identifier: `from_transfer_type` is one Python +/// scope, so a property-derived local shares it with the converter's own locals +/// (`violations`, `raw`), with the runtime helpers and modules the body calls +/// (`_collect`, `typing`, `re`, `math`), and with the builtins it calls +/// (`isinstance`, `len`, `int`). A member identifier used verbatim would shadow +/// any of them — a property named `violations` silently rebound the violation +/// accumulator and dropped every collected violation, which is exactly the +/// silently-wrong output P15 exists to prevent. +/// +/// The `_value` suffix makes that structurally impossible instead of +/// blocklisting names: no fixed local, builtin, imported module, or synthesized +/// module-level identifier (`DEFAULT_<FIELD>`, `_PATTERN_<HEX>`, +/// `_<MODEL>_DECLARED`, `_<base>_{from,to}_transfer_type`, +/// `_<Model>TransferTypeConverter`) ends in `_value`. It stays collision-free +/// *within* the property family too: every temporary this position needs appends +/// a further suffix (`_raw`, `_parsed`, `_list`, `_index`, `_element`, `_item`, +/// `_path`), none of which ends in `_value`, so no property's slot can be +/// another property's temporary and distinct members (already one P15 scope) +/// stay distinct here. +fn parse_slot_local(field_name: &str) -> String { + format!("{field_name}_value") +} + /// The expression that reaches a referenced model's converter. A model declared /// in this module is reached through its own converter class (fully typed); one /// imported from a sibling module is reached through the class attribute the SDK @@ -166,31 +192,33 @@ fn converter_expr(model_name: &str) -> String { } /// The `_<base>_{from,to}_transfer_type` function-name base for a named union. -fn union_fn_base(model_name: &str) -> String { +pub(crate) fn union_fn_base(model_name: &str) -> String { model_name.to_snake_case() } /// The function-name base for an **inline** (property-level) union, mirroring the -/// `<Model><Property>` synthesized-name rule. -fn inline_union_fn_base(model_name: &str, json_name: &str) -> String { +/// `<Model><Property>` synthesized-name rule. `member_ident` is the member's +/// *emitted* identifier, so a `x-py-name` override moves this name with it — P15's +/// escape hatch has to reach every name synthesized from the property. +pub(crate) fn inline_union_fn_base(model_name: &str, member_ident: &str) -> String { format!( "{}_{}", model_name.to_snake_case(), - json_name.to_snake_case() + member_ident.to_snake_case() ) } -fn union_parse_fn(base: &str) -> String { +pub(crate) fn union_parse_fn(base: &str) -> String { format!("_{base}_from_transfer_type") } -fn union_serialize_fn(base: &str) -> String { +pub(crate) fn union_serialize_fn(base: &str) -> String { format!("_{base}_to_transfer_type") } /// The module-level `frozenset` of declared wire keys an open object splits its /// catch-all on, mirroring TypeScript's `<MODEL>_DECLARED`. -fn declared_fields_const_name(model_name: &str) -> String { +pub(crate) fn declared_fields_const_name(model_name: &str) -> String { format!("_{}_DECLARED", model_name.to_shouty_snake_case()) } @@ -1354,7 +1382,7 @@ const CONTAINS_HELPER_BODY: &str = r#"def _check_contains( /// The module-level compiled-regex const name for a `pattern`, keyed by the /// (normalized) pattern text so identical patterns share one compiled instance /// per module. Stable FNV-1a hash → a valid Python identifier. -fn py_pattern_const_name(pattern: &str) -> String { +pub(crate) fn py_pattern_const_name(pattern: &str) -> String { let mut hash: u64 = 0xcbf2_9ce4_8422_2325; for byte in pattern.as_bytes() { hash ^= u64::from(*byte); @@ -2982,7 +3010,7 @@ fn render_union_transfer_functions(output: &mut String, models: &[&PlannedJsonTy let Some(union) = classify_py_union(property, models)? else { continue; }; - let base = inline_union_fn_base(&model.model_name, json_name); + let base = inline_union_fn_base(&model.model_name, &property.py_member_name(json_name)); let member_type = annotation(property)?; render_union_parse_function(output, &base, &member_type, &union)?; // The enclosing property already runs the branch checks on the way @@ -3239,7 +3267,12 @@ fn render_model_parser_body( } output.push_str(&format!("return {}(\n", model.model_name)); for field_name in &fields { - output.push_str(&format!(" {field_name}={field_name},\n")); + // The member identifier names the keyword; the value comes off the + // property's slot local (see `parse_slot_local`). + output.push_str(&format!( + " {field_name}={},\n", + parse_slot_local(field_name) + )); } if open { output.push_str(" additional_properties=additional_properties,\n"); @@ -3367,7 +3400,10 @@ fn render_model_serializer_body( let inline_union = match classify_py_union(property, models)? { Some(union) if union.needs_serializer() => Some(format!( "{}({value_expr})", - union_serialize_fn(&inline_union_fn_base(&model.model_name, json_name)) + union_serialize_fn(&inline_union_fn_base( + &model.model_name, + &property.py_member_name(json_name) + )) )), _ => None, }; @@ -3677,11 +3713,13 @@ fn render_property_parser( property: &Schema, required: bool, ) -> Result<()> { - let field_name = property.py_member_name(json_name); + // Every local this position binds hangs off the property's slot name, never + // off the member identifier itself (see `parse_slot_local`). + let slot = parse_slot_local(&property.py_member_name(json_name)); let member_type = annotation(property)?; let key = python_string_literal(json_name); let path_expr = python_string_literal(json_name); - let raw_local = format!("{field_name}_raw"); + let raw_local = format!("{slot}_raw"); let nullable = allows_null(property); // Not yet assigned; a failure to parse records a violation, so the @@ -3691,7 +3729,7 @@ fn render_property_parser( } else { optional_annotation(&member_type) }; - render_py_slot_declaration(output, "", &field_name, &declared_type); + render_py_slot_declaration(output, "", &slot, &declared_type); if required { if nullable { @@ -3705,14 +3743,7 @@ fn render_property_parser( output.push_str("else:\n"); output.push_str(&format!(" {raw_local} = raw[{key}]\n")); render_property_value_parser( - output, - model, - models, - json_name, - property, - &field_name, - &raw_local, - " ", + output, model, models, json_name, property, &slot, &raw_local, " ", )?; return Ok(()); } @@ -3721,14 +3752,7 @@ fn render_property_parser( output.push_str(&format!(" {raw_local} = raw[{key}]\n")); if nullable { render_property_value_parser( - output, - model, - models, - json_name, - property, - &field_name, - &raw_local, - " ", + output, model, models, json_name, property, &slot, &raw_local, " ", )?; return Ok(()); } @@ -3738,14 +3762,7 @@ fn render_property_parser( )); output.push_str(" else:\n"); render_property_value_parser( - output, - model, - models, - json_name, - property, - &field_name, - &raw_local, - " ", + output, model, models, json_name, property, &slot, &raw_local, " ", ) } @@ -3763,7 +3780,7 @@ fn render_property_value_parser( // An inline `oneOf` sum type dispatches through the module's union parser (a // `$ref` at a named union routes through the reference path below). if classify_py_union(property, models)?.is_some() { - let base = inline_union_fn_base(&model.model_name, json_name); + let base = inline_union_fn_base(&model.model_name, &property.py_member_name(json_name)); let parsed = format!("{target}_parsed"); output.push_str(indent); output.push_str(&format!( diff --git a/src/parser/json_schema.rs b/src/parser/json_schema.rs index 0b4bb9d3..986fca34 100644 --- a/src/parser/json_schema.rs +++ b/src/parser/json_schema.rs @@ -8,6 +8,9 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; use crate::error::{Error, Result}; +// The P15 collision pass names every synthesized identifier through the emitter's +// own naming helpers, so the load-time check cannot drift from what is emitted. +use crate::generator::json_schema::python; use crate::language::Language; use crate::spec::{ ApiSpec, ExternalTypeBindingSpec, ExternalTypeSpec, JsonModelSpec, LanguageStringSpec, @@ -302,7 +305,7 @@ fn api_spec_tree_from_json_schema_sources( return Err(Error::InvalidJsonSchema { path: source.path.clone(), reason: format!( - "input `{}` maps to the reserved module name `{segment}`, which collides with a generated file (models/services/definitions/index/_recursive); rename the input file or directory", + "input `{}` maps to the reserved module name `{segment}`, which collides with a generated file (models/services/definitions/_definitions/index/_recursive/__init__); rename the input file or directory", source.relative_path.display() ), }); @@ -392,10 +395,24 @@ fn insert_leaf_at( /// `specs/json-schema/generated-file-layout.md`). Reserving the union means a name /// reserved in *any* target is rejected for *all*, keeping the flat package /// coherent everywhere. +/// +/// Both spellings of the shared runtime module are reserved, because the targets +/// spell it differently: Go and TypeScript emit `definitions.go` / `definitions.ts`, +/// while Python emits `_definitions.py` (module-private, like the `_recursive.py` +/// hoist module beside it). An input named `_definitions.yaml` would otherwise +/// emit a `_definitions/` package *directory* at the runtime module's own import +/// path — and a package shadows a sibling module, so every +/// `from .._definitions import ...` in the tree fails at import. fn is_reserved_module_name(segment: &str) -> bool { matches!( segment, - "definitions" | "_recursive" | "models" | "services" | "index" | "__init__" + "definitions" + | "_definitions" + | "_recursive" + | "models" + | "services" + | "index" + | "__init__" ) } @@ -5872,6 +5889,12 @@ pub(crate) fn build_name_manifest( collect_ts_const_constants(module_key, &ns_models, &mut top)?; collect_ts_transfer_type_converters(module_key, &ns_models, &mut top)?; } + // Everything else the Python emitter synthesizes at module scope: the + // converter classes, the declared-key frozensets, the union conversion + // functions, and the compiled-pattern constants (P15). + if language == Language::Python { + collect_python_module_idents(module_key, &ns_models, &mut top)?; + } } Ok(manifest) @@ -6238,6 +6261,197 @@ fn collect_ts_const_constants( Ok(()) } +/// The remaining module-scope identifiers the Python JSON-Schema generator +/// synthesizes, entered into the same namespace as the user types, services, and +/// `DEFAULT_<FIELD>` constants so a coincidence rejects at load instead of one +/// definition silently overwriting the other (P15). +/// +/// Each is named by [`build_name_manifest`]'s resolved `type_ident`, so a +/// type-level `x-py-name` override moves all of them together — and every ident +/// is computed by the *generator's* own naming helper, never re-derived here, so +/// the check cannot drift from what is emitted: +/// +/// - `_<Model>TransferTypeConverter` — the converter class carrying the model's +/// whole wire contract (class models only; a union has no converter class). +/// - `_<MODEL>_DECLARED` — the declared-key `frozenset` an *open* object splits +/// its catch-all on. `to_shouty_snake_case` is not injective over the verbatim +/// overrides (`ContactPy` and `ContactPY` both shout to `CONTACT_PY`), which is +/// how a declared property used to leak into the catch-all of whichever model +/// lost the race. +/// - `_<base>_from_transfer_type` / `_<base>_to_transfer_type` — a union's +/// conversion functions. `to_snake_case` is likewise non-injective, and a named +/// union's base can also coincide with an inline (`<model>_<member>`) one. +/// - `_PATTERN_<HEX>` — the shared compiled regexes. Identical pattern text +/// *intentionally* shares one constant, so the origin is keyed by that text: +/// a repeat is deduplication (accepted), while two distinct patterns landing on +/// one name — or a user type overridden to that shape — is a collision. +/// - the converter bodies' own locals ([`PYTHON_CONVERTER_BODY_LOCALS`]). +fn collect_python_module_idents( + module_key: &str, + models: &[NsModel], + top: &mut Namespace, +) -> Result<()> { + let language = Language::Python; + // A converter body reads the module's own classes and constants by bare name + // while binding these locals in the same scope, so a module-level identifier + // spelled like one of them is shadowed inside every body that binds it. + // Nothing *derived* lands here — user types are `UpperCamelCase` and the + // synthesized names are `_`-prefixed or shouty — so this only ever fires on a + // verbatim `x-py-name` that spells a runtime local (P15). + for local in PYTHON_CONVERTER_BODY_LOCALS { + top.insert( + language, + (*local).to_string(), + format!("generated converter-body local `{local}`"), + )?; + } + for model in models.iter().filter(|m| m.module_key == module_key) { + let origin = |what: &str| format!("`{}` {what}", model.full_name); + // A sum-type def is emitted as a `TypeAlias` whose conversion lives in a + // pair of module-private free functions, so it has no converter class and + // no declared-key set. This one predicate covers the emitter's + // `is_python_union_model` / `is_py_union` pair: they can only disagree on a + // branch typed `["string", "null"]`, a form the loader has already + // rejected by the time the manifest is built. + if is_sum_type_union(&model.schema) { + let base = python::union_fn_base(&model.type_ident); + top.insert( + language, + python::union_parse_fn(&base), + origin("union parse function"), + )?; + top.insert( + language, + python::union_serialize_fn(&base), + origin("union serialize function"), + )?; + } else { + top.insert( + language, + python::converter_class_name(&model.type_ident), + origin("transfer-type converter class"), + )?; + if python_open_object(&model.schema) { + top.insert( + language, + python::declared_fields_const_name(&model.type_ident), + origin("declared-key frozenset"), + )?; + } + } + // An inline (property-level) union gets its own function pair, named + // `<model>_<member>` — so a member-level `x-py-name` moves it. + for (json_name, property) in model.schema.properties.iter().flatten() { + if !is_sum_type_union(property) { + continue; + } + let base = python::inline_union_fn_base( + &model.type_ident, + &member_identifier(language, json_name, property), + ); + top.insert( + language, + python::union_parse_fn(&base), + origin(&format!("`{json_name}` inline union parse function")), + )?; + top.insert( + language, + python::union_serialize_fn(&base), + origin(&format!("`{json_name}` inline union serialize function")), + )?; + } + collect_python_pattern_constants(&model.schema, top)?; + } + Ok(()) +} + +/// Every fixed identifier a generated Python converter body binds or receives: +/// the accumulator and wire dictionaries, the loop and dispatch temporaries, and +/// the function parameters. The property-derived slots are absent by +/// construction — they are suffixed `_value` precisely so they cannot coincide +/// with anything here (see the generator's `parse_slot_local`). +const PYTHON_CONVERTER_BODY_LOCALS: &[&str] = &[ + "additional_properties", + "entry", + "error", + "items", + "key", + "member", + "narrowed", + "number", + "out", + "parsed", + "path", + "raw", + "self", + "tag", + "tagged", + "type_hint", + "value", + "violations", +]; + +/// Mirrors the Python emitter's `is_open_object`: a declared-property object that +/// stays open to unknown members, which is what gives it the catch-all — and the +/// module-level declared-key set the catch-all is split on. +fn python_open_object(schema: &Schema) -> bool { + schema.ty.as_ref().and_then(Value::as_str) == Some("object") + && schema + .properties + .as_ref() + .is_some_and(|properties| !properties.is_empty()) + && schema.additional_properties.as_ref() != Some(&Value::Bool(false)) +} + +/// Walks every string position that hoists a compiled regex — mirroring the +/// emitter's `collect_schema_patterns` — and enters each constant under an origin +/// keyed by the pattern text, so identical patterns dedupe and distinct ones +/// collide. +fn collect_python_pattern_constants(schema: &Schema, top: &mut Namespace) -> Result<()> { + let insert = |pattern: &str, top: &mut Namespace| -> Result<()> { + let emitted = crate::json_schema::pattern::rewrite_end_anchor(pattern, r"\Z"); + top.insert( + Language::Python, + python::py_pattern_const_name(&emitted), + format!("compiled pattern constant for {emitted:?}"), + ) + }; + if let Some(Value::String(pattern)) = schema.extra.get("pattern") { + insert(pattern, top)?; + } + if let Some(Value::String(format)) = schema.extra.get("format") + && let Some(check) = crate::json_schema::format::check_for(format) + { + insert(&check.pattern, top)?; + } + for property in schema + .properties + .iter() + .flat_map(|entries| entries.values()) + { + collect_python_pattern_constants(property, top)?; + } + if let Some(items) = &schema.items { + collect_python_pattern_constants(items, top)?; + } + for branch in schema.one_of.iter().flatten() { + collect_python_pattern_constants(branch, top)?; + } + // A key-shape subschema and a typed map's member schema are both carried as + // raw values here; decode them the same way the emitter does. + for nested in [ + schema.extra.get("propertyNames"), + schema.additional_properties.as_ref(), + ] { + if let Some(value @ Value::Object(_)) = nested + && let Ok(subschema) = serde_json::from_value::<Schema>(value.clone()) + { + collect_python_pattern_constants(&subschema, top)?; + } + } + Ok(()) +} + /// TypeScript per-model `TransferTypeConverter` instances (module scope). The /// identifier is derived from the model's type identifier /// ([`ts_transfer_type_converter_name`]), and lower-camel-casing is not @@ -9686,6 +9900,194 @@ $defs: parse_for(Language::Java, input).expect("Java emits no DEFAULT_ constants"); } + #[test] + fn rejects_colliding_declared_field_sets_python() { + // An open object hoists its declared wire keys to a module-level + // `_<MODEL>_DECLARED` frozenset. `to_shouty_snake_case` is not injective + // over the verbatim type overrides — `ContactPy` and `ContactPY` both + // shout to `CONTACT_PY` — and the loser's declared property would leak + // into the winner's catch-all instead (P13/P15). + let input = r##" +$schema: https://json-schema.org/draft/2020-12/schema +type: object +properties: + a: { $ref: "#/$defs/Alpha" } + b: { $ref: "#/$defs/Beta" } +$defs: + Alpha: + x-py-name: ContactPy + type: object + properties: + count: { type: integer } + Beta: + x-py-name: ContactPY + type: object + properties: + b: { type: string } +"##; + let error = reject_for(Language::Python, input); + assert!( + error.contains("collision") && error.contains("_CONTACT_PY_DECLARED"), + "{error}" + ); + // The overrides are Python-only, so every other target sees `Alpha` and + // `Beta` and is unaffected. + for language in [Language::Go, Language::TypeScript, Language::Java] { + parse_for(language, input) + .unwrap_or_else(|error| panic!("{language:?} sees no override: {error}")); + } + } + + #[test] + fn rejects_colliding_converter_class_python() { + // A model's converter class is `_<Model>TransferTypeConverter`; a verbatim + // type override can name a *type* that exact identifier. + let input = r##" +$schema: https://json-schema.org/draft/2020-12/schema +type: object +properties: + a: { $ref: "#/$defs/Contact" } + b: { $ref: "#/$defs/Other" } +$defs: + Contact: + type: object + properties: + count: { type: integer } + Other: + x-py-name: _ContactTransferTypeConverter + type: object + properties: + b: { type: string } +"##; + let error = reject_for(Language::Python, input); + assert!( + error.contains("collision") && error.contains("_ContactTransferTypeConverter"), + "{error}" + ); + } + + #[test] + fn rejects_colliding_union_functions_python() { + // A union's conversion lives in `_<base>_{from,to}_transfer_type` free + // functions: `to_snake_case` on the named union `FooBar` and the + // `<model>_<member>` base of `Foo.bar`'s inline union both give `foo_bar`. + let input = r##" +$schema: https://json-schema.org/draft/2020-12/schema +type: object +properties: + u: { $ref: "#/$defs/FooBar" } + f: { $ref: "#/$defs/Foo" } +$defs: + FooBar: + oneOf: + - { type: string } + - { type: integer } + Foo: + type: object + additionalProperties: false + properties: + bar: + oneOf: + - { type: string } + - { type: boolean } +"##; + let error = reject_for(Language::Python, input); + assert!( + error.contains("collision") && error.contains("_foo_bar_from_transfer_type"), + "{error}" + ); + // P15's escape hatch has to reach the synthesized function name too: the + // member override renames the inline union's functions with the member. + let renamed = input.replace( + " bar:\n oneOf:", + " bar:\n x-py-name: renamed\n oneOf:", + ); + parse_for(Language::Python, &renamed) + .expect("an `x-py-name` override moves the inline union's function names"); + } + + #[test] + fn rejects_type_colliding_with_pattern_constant_python() { + // A `pattern` is hoisted to a module-level compiled-regex constant named + // `_PATTERN_<FNV-1a of the pattern text>`; `^a` hashes to this one. A + // verbatim type override can name a type that identifier. + let input = r##" +$schema: https://json-schema.org/draft/2020-12/schema +type: object +properties: + a: { type: string, pattern: "^a" } + b: { $ref: "#/$defs/Other" } +$defs: + Other: + x-py-name: _PATTERN_09572B07B5E46120 + type: object + properties: + b: { type: string } +"##; + assert_eq!( + python::py_pattern_const_name("^a"), + "_PATTERN_09572B07B5E46120" + ); + let error = reject_for(Language::Python, input); + assert!( + error.contains("collision") && error.contains("_PATTERN_09572B07B5E46120"), + "{error}" + ); + } + + #[test] + fn rejects_type_named_after_a_converter_body_local_python() { + // The mirror image of a member shadowing a runtime local: a converter body + // reads the module's classes by bare name while binding `raw`, so a *type* + // overridden to `raw` is shadowed inside every body that parses one. + let input = r##" +$schema: https://json-schema.org/draft/2020-12/schema +type: object +properties: + a: { $ref: "#/$defs/Other" } +$defs: + Other: + x-py-name: raw + type: object + properties: + b: { type: string } +"##; + let error = reject_for(Language::Python, input); + assert!( + error.contains("collision") && error.contains("`raw`"), + "{error}" + ); + // Only Python binds that local, and only the Python override renames the + // type, so the other targets are unaffected. + for language in [Language::Go, Language::TypeScript, Language::Java] { + parse_for(language, input) + .unwrap_or_else(|error| panic!("{language:?} sees no override: {error}")); + } + } + + #[test] + fn accepts_repeated_pattern_across_positions_python() { + // One compiled constant per *distinct* pattern text is deliberate + // deduplication, not a collision: the same pattern in several positions + // (and the same `format`'s pinned regex twice) shares one constant. + parse_for( + Language::Python, + r##" +$schema: https://json-schema.org/draft/2020-12/schema +type: object +properties: + a: { type: string, pattern: "^a" } + b: { type: string, pattern: "^a" } + c: + type: array + items: { type: string, pattern: "^a" } + d: { type: string, format: "email" } + e: { type: string, format: "email" } +"##, + ) + .expect("identical patterns share one module constant"); + } + #[test] fn rejects_synthesized_operation_input_colliding_with_defs_type() { // The synthesized `<Op>Input` type collides with a declared `$defs` type @@ -9947,6 +10349,36 @@ properties: assert!(error.contains("reserved module name"), "{error}"); } + #[test] + fn rejects_shared_runtime_module_names() { + // Both spellings of the shared runtime module are reserved for every + // target: `definitions` (Go/TypeScript) and `_definitions` (Python). A + // `_definitions` input emits a package directory at the Python runtime + // module's own import path, which shadows it and breaks every + // `from .._definitions import ...` in the tree. + for segment in ["definitions", "_definitions", "_recursive"] { + for language in [ + Language::Python, + Language::TypeScript, + Language::Go, + Language::Java, + ] { + let sources = vec![ + module_collision_source(&format!("{segment}.yaml"), "Shadow"), + module_collision_source("other.yaml", "Other"), + ]; + let error = api_spec_tree_from_json_schema_sources(language, sources) + .err() + .unwrap_or_else(|| panic!("`{segment}` must be rejected for {language:?}")) + .to_string(); + assert!( + error.contains("reserved module name") && error.contains(segment), + "{language:?}: {error}" + ); + } + } + } + #[test] fn rejects_object_keyword_on_scalar() { let error = numeric_reject("type: string\nproperties:\n a: { type: string }"); From c304d079b8c3cec3dbaf207549a16ed28f7e2846 Mon Sep 17 00:00:00 2001 From: Roey Berman <roey.berman@gmail.com> Date: Fri, 14 Aug 2026 10:38:37 -0700 Subject: [PATCH 06/20] Regenerate Python samples and correct the reserved-name spec Applies the five review fixes to the checked-in generated output, and records that both `definitions` and `_definitions` are reserved module names now that the loader rejects either spelling for every target. The regenerated diff is dominated by two mechanical changes -- the converter's per-property local is now `<member>_value` rather than the bare member identifier, and the serialize path assigns through statements so a nested conversion can be wrapped -- plus the new finiteness, temporal and closed-value checks. --- CHANGELOG.md | 27 +- .../json_schema/api/chat/_definitions.py | 182 +- .../python/json_schema/api/chat/models.py | 191 +- .../python/json_schema/api/kb/_definitions.py | 182 +- .../python/json_schema/api/kb/_recursive.py | 167 +- .../api/kb/content/block/models.py | 33 +- .../json_schema/api/kb/content/page/models.py | 26 +- .../python/json_schema/api/kb/kb/models.py | 44 +- .../api/kb/tree/category/models.py | 96 +- .../json_schema/api/showcase/_definitions.py | 182 +- .../python/json_schema/api/showcase/models.py | 2004 ++++++++++------- .../json_schema/api/temporal/_definitions.py | 182 +- .../python/json_schema/api/temporal/models.py | 184 +- samples/python/chat/_definitions.py | 182 +- samples/python/chat/models.py | 191 +- samples/python/kb/_definitions.py | 182 +- samples/python/kb/_recursive.py | 167 +- samples/python/kb/content/block/models.py | 33 +- samples/python/kb/content/page/models.py | 26 +- samples/python/kb/kb/models.py | 44 +- samples/python/kb/tree/category/models.py | 96 +- samples/python/showcase/_definitions.py | 182 +- samples/python/showcase/models.py | 2004 ++++++++++------- samples/python/temporal/_definitions.py | 182 +- samples/python/temporal/models.py | 184 +- specs/json-schema/features/type.md | 10 +- specs/json-schema/generated-file-layout.md | 41 +- 27 files changed, 4448 insertions(+), 2576 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e3ff324d..95b49589 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -69,8 +69,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 TypeScript, and Java already surface — instead of `pydantic.ValidationError`. One bad payload reports every violation it contains, with the JSON path of each and a reason naming the concrete bound and the offending value. Both types live - in the package's `_definitions` module and are deliberately not re-exported - through `__init__.py`, matching the other three languages. + in the package's `_definitions` module and are not re-exported through + `__init__.py`, so catching the aggregating error takes + `from <package>._definitions import ValidationError` — Python is the only + target that reaches its error type through a private name (Go's is exported + from the one flat package, Java's is `public`, and the TypeScript root barrel + re-exports it). - Python: An **optional and nullable** member now collapses on round-trip, as it already does in Go and Java. A dataclass has no presence channel, so an absent member and an explicit wire `null` read as the same `None`, and both @@ -194,10 +198,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 own constraint, an element-level check — was reported with a dangling separator (`segments[0].`). The prefix is now the whole path, matching Go and Java. - JSON Schema: `uniqueItems` and `contains` were dropped on an array-typed **typed - map member** in Python, for want of a native Pydantic form. Both now ride in the - member's annotation as AfterValidators, with the same reasons the property - position emits (and the same mechanism now serves a `oneOf` branch). -- JSON Schema: A typed map's members were validated against their type _token_ + map member** in Python. Both now run in the member's converter through the + runtime's `_check_unique_items` / `_check_contains`, with the same reasons the + property position emits (and the same mechanism now serves a `oneOf` branch). +- JSON Schema: A typed map's members were validated against their type *token* only, so every constraint the member type declared was silently dropped — a string's `minLength`/`maxLength`/`pattern`/`format`, a number's bounds and `multipleOf`, an array's `minItems`/`uniqueItems`/`contains`, a `const`/`enum` @@ -205,11 +209,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 directions, with the member's key as the violation path. Python additionally validated only that a member was a _string_, leaving an object, union, or numeric member unchecked and unmaterialized; members now validate and - materialize through the member type's own annotation, so `model_extra` holds the - declared type (an `Inner`, an `int` parsed from `1.0`, a `datetime`, `bytes`) and - re-encodes through it on the way out. TypeScript checked members on the way in - but not on the way out, and dropped a nullable value's constraints in both - positions (a member's _and_ a declared field's). + materialize through the member type's own converter, so + `additional_properties` holds the declared type (an `Inner`, an `int` parsed + from `1.0`, a `datetime`, `bytes`) and re-encodes through it on the way out. + TypeScript checked members on the way in but not on the way out, and dropped + a nullable value's constraints in both positions (a member's *and* a + declared field's). - JSON Schema: A **nullable** typed-map member (`additionalProperties` as the nullability `oneOf`) was mishandled: Go typed the member `T` and dropped a `null` member from the map entirely, and Java rejected it. A null member is now diff --git a/advanced/samples/python/json_schema/api/chat/_definitions.py b/advanced/samples/python/json_schema/api/chat/_definitions.py index f2b94492..3bf7f712 100644 --- a/advanced/samples/python/json_schema/api/chat/_definitions.py +++ b/advanced/samples/python/json_schema/api/chat/_definitions.py @@ -16,6 +16,9 @@ "ValidationError", "Violation", "_check_contains", + "_check_date_time", + "_check_duration", + "_check_time", "_check_unique_items", "_collect", "_format_base64", @@ -191,6 +194,13 @@ def _check_contains( r"^PT(?:[0-9]+H(?:[0-9]+M(?:[0-9]+S)?)?|[0-9]+M(?:[0-9]+S)?|[0-9]+S)$" ) _TEMPORAL_MAX_DURATION_SECONDS = ((1 << 63) - 1) // 1_000_000_000 +# A duration component with more digits than the cap itself is over the cap +# whatever those digits are, which is how the magnitude is bounded before `int()` +# sees it: CPython refuses to convert a string of more than 4300 digits. +_TEMPORAL_MAX_DURATION_DIGITS = len(str(_TEMPORAL_MAX_DURATION_SECONDS)) +# `datetime` resolves to microseconds, and `fromisoformat` before Python 3.11 +# parses only the fraction widths `isoformat` writes. +_TEMPORAL_FRACTION_DIGITS = 6 def _days_in_month(year: int, month: int) -> int: @@ -210,10 +220,57 @@ def _valid_temporal_calendar(value: str) -> bool: year, month, day = int(value[0:4]), int(value[5:7]), int(value[8:10]) except ValueError: return False + # `datetime.MINYEAR` is 1, so year 0000 -- which the wire grammar admits and + # the other three targets materialize -- has no Python value at all. It is + # rejected rather than shifted into range, and `_temporal_reason` says so. + if year < datetime.MINYEAR: + return False maximum = _days_in_month(year, month) return maximum > 0 and 1 <= day <= maximum +def _temporal_reason(name: str, value: str) -> str: + """The reason a rejected temporal string is reported under. + + Year 0000 earns its own clause: it is a valid wire value the other targets + accept, so a caller needs to read Python's floor rather than conclude the + timestamp was malformed. + """ + + if value[0:4] == "0000": + return ( + f"must be a valid {name}, got {_quote(value)}: year 0000 is not" + f" representable (datetime.MINYEAR is {datetime.MINYEAR})" + ) + return f"must be a valid {name}, got {_quote(value)}" + + +def _temporal_isoformat(value: str) -> str: + """Rewrites a wire temporal into the spelling `fromisoformat` accepts. + + `Z` becomes `+00:00`, and the fractional second is padded or truncated to + exactly `_TEMPORAL_FRACTION_DIGITS`: before Python 3.11 `fromisoformat` + parses only what `isoformat` writes, so an RFC 3339 `.1` or `.1234567` -- + which every other target accepts -- would otherwise raise. Digits past the + sixth are dropped, the loss at `datetime`'s own resolution that P1 allows; + the canonical output re-trims the padding, so `.1` still writes as `.1`. + """ + + normalized = value.upper() + if normalized.endswith("Z"): + normalized = normalized[:-1] + "+00:00" + dot = normalized.find(".") + if dot < 0: + return normalized + end = dot + 1 + while end < len(normalized) and normalized[end].isdigit(): + end += 1 + fraction = normalized[dot + 1 : end].ljust(_TEMPORAL_FRACTION_DIGITS, "0") + return ( + normalized[: dot + 1] + fraction[:_TEMPORAL_FRACTION_DIGITS] + normalized[end:] + ) + + def _parse_date_time( value: str, path: str, violations: list[Violation] ) -> datetime.datetime | None: @@ -221,24 +278,17 @@ def _parse_date_time( value ): violations.append( - Violation( - path=path, reason=f"must be a valid date-time, got {_quote(value)}" - ) + Violation(path=path, reason=_temporal_reason("date-time", value)) ) return None - normalized = value.upper() - if normalized.endswith("Z"): - normalized = normalized[:-1] + "+00:00" - return datetime.datetime.fromisoformat(normalized) + return datetime.datetime.fromisoformat(_temporal_isoformat(value)) def _parse_date( value: str, path: str, violations: list[Violation] ) -> datetime.date | None: if _TEMPORAL_DATE_RE.match(value) is None or not _valid_temporal_calendar(value): - violations.append( - Violation(path=path, reason=f"must be a valid date, got {_quote(value)}") - ) + violations.append(Violation(path=path, reason=_temporal_reason("date", value))) return None return datetime.date.fromisoformat(value) @@ -247,14 +297,9 @@ def _parse_time( value: str, path: str, violations: list[Violation] ) -> datetime.time | None: if _TEMPORAL_TIME_RE.match(value) is None: - violations.append( - Violation(path=path, reason=f"must be a valid time, got {_quote(value)}") - ) + violations.append(Violation(path=path, reason=_temporal_reason("time", value))) return None - normalized = value.upper() - if normalized.endswith("Z"): - normalized = normalized[:-1] + "+00:00" - return datetime.time.fromisoformat(normalized) + return datetime.time.fromisoformat(_temporal_isoformat(value)) def _parse_duration( @@ -262,9 +307,7 @@ def _parse_duration( ) -> datetime.timedelta | None: if _TEMPORAL_DURATION_RE.match(value) is None: violations.append( - Violation( - path=path, reason=f"must be a valid duration, got {_quote(value)}" - ) + Violation(path=path, reason=_temporal_reason("duration", value)) ) return None total = 0 @@ -273,18 +316,103 @@ def _parse_duration( if char.isdigit(): number += char continue - total += int(number) * {"H": 3600, "M": 60, "S": 1}[char] + digits = number.lstrip("0") number = "" + if len(digits) > _TEMPORAL_MAX_DURATION_DIGITS: + # Over the cap by digit count alone (see the constant), so the + # conversion `int()` would refuse is never attempted. + total = _TEMPORAL_MAX_DURATION_SECONDS + 1 + break + total += int(digits or "0") * {"H": 3600, "M": 60, "S": 1}[char] if total > _TEMPORAL_MAX_DURATION_SECONDS: - violations.append( - Violation( - path=path, reason=f"must be a valid duration, got {_quote(value)}" - ) - ) - return None + break + if total > _TEMPORAL_MAX_DURATION_SECONDS: + violations.append( + Violation(path=path, reason=_temporal_reason("duration", value)) + ) + return None return datetime.timedelta(seconds=total) +def _check_temporal_offset( + name: str, + value: datetime.datetime | datetime.time, + offset: datetime.timedelta, + path: str, + violations: list[Violation], +) -> None: + """Asserts a UTC offset is a whole number of minutes, the finest the wire + form spells (`tzinfo` allows seconds, which the offset would silently lose). + """ + + if offset % datetime.timedelta(minutes=1): + violations.append( + Violation( + path=path, + reason=( + f"must be a valid {name}, got {_quote(str(value))}: " + f"the UTC offset {offset} is not a whole number of minutes" + ), + ) + ) + + +def _check_date_time( + value: datetime.datetime, path: str, violations: list[Violation] +) -> None: + """Asserts a datetime is writable as a wire date-time (P12). + + A dataclass is constructed unchecked, so a naive datetime -- with no offset + the required wire form could carry -- reaches serialize; without this it + would emit a value this module's own parser rejects. + """ + + offset = value.utcoffset() + if offset is None: + violations.append( + Violation( + path=path, + reason=( + f"must be a valid date-time, got {_quote(str(value))}: " + "a naive datetime carries no UTC offset" + ), + ) + ) + return + _check_temporal_offset("date-time", value, offset, path, violations) + + +def _check_time(value: datetime.time, path: str, violations: list[Violation]) -> None: + """Asserts a time is writable as a wire time (P12). The offset is optional in + the grammar, so only its precision is held to anything.""" + + offset = value.utcoffset() + if offset is not None: + _check_temporal_offset("time", value, offset, path, violations) + + +def _check_duration( + value: datetime.timedelta, path: str, violations: list[Violation] +) -> None: + """Asserts a timedelta is writable as a wire duration (P12): the grammar is + unsigned, whole-second and capped, and a `timedelta` is none of those.""" + + if value < datetime.timedelta(0): + reason = "a duration cannot be negative" + elif value % datetime.timedelta(seconds=1): + reason = "a duration cannot carry a fraction of a second" + elif value.total_seconds() > _TEMPORAL_MAX_DURATION_SECONDS: + reason = f"a duration cannot exceed {_TEMPORAL_MAX_DURATION_SECONDS} seconds" + else: + return + violations.append( + Violation( + path=path, + reason=f"must be a valid duration, got {_quote(str(value))}: {reason}", + ) + ) + + def _temporal_frac(microsecond: int) -> str: if microsecond == 0: return "" diff --git a/advanced/samples/python/json_schema/api/chat/models.py b/advanced/samples/python/json_schema/api/chat/models.py index bd9219f6..ce375e5b 100644 --- a/advanced/samples/python/json_schema/api/chat/models.py +++ b/advanced/samples/python/json_schema/api/chat/models.py @@ -36,15 +36,15 @@ def from_transfer_type( raise ValidationError([Violation(path="", reason="expected object")]) raw = typing.cast("dict[str, typing.Any]", value) - room_id: str = typing.cast("typing.Any", None) + room_id_value: str = typing.cast("typing.Any", None) if "roomId" not in raw or raw["roomId"] is None: violations.append(Violation(path="roomId", reason="required")) else: - room_id_raw = raw["roomId"] - if not isinstance(room_id_raw, str): + room_id_value_raw = raw["roomId"] + if not isinstance(room_id_value_raw, str): violations.append(Violation(path="roomId", reason="expected string")) else: - room_id = room_id_raw + room_id_value = room_id_value_raw for key in raw: if key != "roomId": @@ -52,7 +52,7 @@ def from_transfer_type( if violations: raise ValidationError(violations) return GetRoomInput( - room_id=room_id, + room_id=room_id_value, ) @typing_extensions.override @@ -135,54 +135,54 @@ def from_transfer_type( raise ValidationError([Violation(path="", reason="expected object")]) raw = typing.cast("dict[str, typing.Any]", value) - kind: typing.Literal["text"] = typing.cast("typing.Any", None) + kind_value: typing.Literal["text"] = typing.cast("typing.Any", None) if "kind" not in raw or raw["kind"] is None: violations.append(Violation(path="kind", reason="required")) else: - kind_raw = raw["kind"] - if not isinstance(kind_raw, str): + kind_value_raw = raw["kind"] + if not isinstance(kind_value_raw, str): violations.append(Violation(path="kind", reason="expected string")) - elif kind_raw != "text": + elif kind_value_raw != "text": violations.append(Violation(path="kind", reason='must equal "text"')) else: - kind = kind_raw + kind_value = kind_value_raw - body: str = typing.cast("typing.Any", None) + body_value: str = typing.cast("typing.Any", None) if "body" not in raw or raw["body"] is None: violations.append(Violation(path="body", reason="required")) else: - body_raw = raw["body"] - if not isinstance(body_raw, str): + body_value_raw = raw["body"] + if not isinstance(body_value_raw, str): violations.append(Violation(path="body", reason="expected string")) else: - body = body_raw + body_value = body_value_raw - reply_to_id: str | None = None + reply_to_id_value: str | None = None if "replyToId" in raw: - reply_to_id_raw = raw["replyToId"] - if reply_to_id_raw is None: - reply_to_id = None + reply_to_id_value_raw = raw["replyToId"] + if reply_to_id_value_raw is None: + reply_to_id_value = None else: - if not isinstance(reply_to_id_raw, str): + if not isinstance(reply_to_id_value_raw, str): violations.append( Violation(path="replyToId", reason="expected string") ) else: - reply_to_id = reply_to_id_raw + reply_to_id_value = reply_to_id_value_raw - priority: int | None = None + priority_value: int | None = None if "priority" in raw: - priority_raw = raw["priority"] - if priority_raw is None: + priority_value_raw = raw["priority"] + if priority_value_raw is None: violations.append( Violation(path="priority", reason="explicit null not allowed") ) else: - priority_parsed = _parse_spec_integer( - priority_raw, "priority", violations + priority_value_parsed = _parse_spec_integer( + priority_value_raw, "priority", violations ) - if priority_parsed is not None: - priority = priority_parsed + if priority_value_parsed is not None: + priority_value = priority_value_parsed for key in raw: if ( @@ -195,10 +195,10 @@ def from_transfer_type( if violations: raise ValidationError(violations) return Message( - kind=kind, - body=body, - reply_to_id=reply_to_id, - priority=priority, + kind=kind_value, + body=body_value, + reply_to_id=reply_to_id_value, + priority=priority_value, ) @typing_extensions.override @@ -245,82 +245,83 @@ def from_transfer_type(self, value: typing.Any, type_hint: type["Room"]) -> "Roo raise ValidationError([Violation(path="", reason="expected object")]) raw = typing.cast("dict[str, typing.Any]", value) - room_id: str = typing.cast("typing.Any", None) + room_id_value: str = typing.cast("typing.Any", None) if "roomId" not in raw or raw["roomId"] is None: violations.append(Violation(path="roomId", reason="required")) else: - room_id_raw = raw["roomId"] - if not isinstance(room_id_raw, str): + room_id_value_raw = raw["roomId"] + if not isinstance(room_id_value_raw, str): violations.append(Violation(path="roomId", reason="expected string")) else: - room_id = room_id_raw + room_id_value = room_id_value_raw - display_name: str = typing.cast("typing.Any", None) + display_name_value: str = typing.cast("typing.Any", None) if "displayName" not in raw or raw["displayName"] is None: violations.append(Violation(path="displayName", reason="required")) else: - display_name_raw = raw["displayName"] - if not isinstance(display_name_raw, str): + display_name_value_raw = raw["displayName"] + if not isinstance(display_name_value_raw, str): violations.append( Violation(path="displayName", reason="expected string") ) else: - display_name = display_name_raw + display_name_value = display_name_value_raw - topic: str | None = None + topic_value: str | None = None if "topic" not in raw: violations.append(Violation(path="topic", reason="required")) else: - topic_raw = raw["topic"] - if topic_raw is None: - topic = None + topic_value_raw = raw["topic"] + if topic_value_raw is None: + topic_value = None else: - if not isinstance(topic_raw, str): + if not isinstance(topic_value_raw, str): violations.append(Violation(path="topic", reason="expected string")) else: - topic = topic_raw + topic_value = topic_value_raw - members: list[str] | None = None + members_value: list[str] | None = None if "members" in raw: - members_raw = raw["members"] - if members_raw is None: + members_value_raw = raw["members"] + if members_value_raw is None: violations.append( Violation(path="members", reason="explicit null not allowed") ) else: - if not isinstance(members_raw, list): + if not isinstance(members_value_raw, list): violations.append( Violation(path="members", reason="expected array") ) else: - members_list: list[str] = [] - for members_index, members_element in enumerate( - typing.cast("list[typing.Any]", members_raw) + members_value_list: list[str] = [] + for members_value_index, members_value_element in enumerate( + typing.cast("list[typing.Any]", members_value_raw) ): - members_item_path = f"members[{members_index}]" - members_item: str = typing.cast("typing.Any", None) - if not isinstance(members_element, str): + members_value_item_path = f"members[{members_value_index}]" + members_value_item: str = typing.cast("typing.Any", None) + if not isinstance(members_value_element, str): violations.append( Violation( - path=members_item_path, reason="expected element" + path=members_value_item_path, + reason="expected element", ) ) else: - members_item = members_element - members_list.append(members_item) - members = members_list + members_value_item = members_value_element + members_value_list.append(members_value_item) + members_value = members_value_list - labels: Labels | None = None + labels_value: Labels | None = None if "labels" in raw: - labels_raw = raw["labels"] - if labels_raw is None: + labels_value_raw = raw["labels"] + if labels_value_raw is None: violations.append( Violation(path="labels", reason="explicit null not allowed") ) else: try: - labels = _LabelsTransferTypeConverter().from_transfer_type( - labels_raw, Labels + labels_value = _LabelsTransferTypeConverter().from_transfer_type( + labels_value_raw, Labels ) except ValidationError as error: _collect(violations, "labels", error) @@ -332,16 +333,17 @@ def from_transfer_type(self, value: typing.Any, type_hint: type["Room"]) -> "Roo if violations: raise ValidationError(violations) return Room( - room_id=room_id, - display_name=display_name, - topic=topic, - members=members, - labels=labels, + room_id=room_id_value, + display_name=display_name_value, + topic=topic_value, + members=members_value, + labels=labels_value, additional_properties=additional_properties, ) @typing_extensions.override def to_transfer_type(self, value: "Room") -> typing.Any: + violations: list[Violation] = [] out: dict[str, typing.Any] = {} out["roomId"] = value.room_id out["displayName"] = value.display_name @@ -349,11 +351,16 @@ def to_transfer_type(self, value: "Room") -> typing.Any: if value.members is not None: out["members"] = value.members if value.labels is not None: - out["labels"] = _LabelsTransferTypeConverter().to_transfer_type( - value.labels - ) + try: + out["labels"] = _LabelsTransferTypeConverter().to_transfer_type( + value.labels + ) + except ValidationError as error: + _collect(violations, "labels", error) for key, entry in value.additional_properties.items(): out[key] = entry + if violations: + raise ValidationError(violations) return out @@ -390,24 +397,24 @@ def from_transfer_type( raise ValidationError([Violation(path="", reason="expected object")]) raw = typing.cast("dict[str, typing.Any]", value) - room_id: str = typing.cast("typing.Any", None) + room_id_value: str = typing.cast("typing.Any", None) if "roomId" not in raw or raw["roomId"] is None: violations.append(Violation(path="roomId", reason="required")) else: - room_id_raw = raw["roomId"] - if not isinstance(room_id_raw, str): + room_id_value_raw = raw["roomId"] + if not isinstance(room_id_value_raw, str): violations.append(Violation(path="roomId", reason="expected string")) else: - room_id = room_id_raw + room_id_value = room_id_value_raw - message: Message = typing.cast("typing.Any", None) + message_value: Message = typing.cast("typing.Any", None) if "message" not in raw or raw["message"] is None: violations.append(Violation(path="message", reason="required")) else: - message_raw = raw["message"] + message_value_raw = raw["message"] try: - message = _MessageTransferTypeConverter().from_transfer_type( - message_raw, Message + message_value = _MessageTransferTypeConverter().from_transfer_type( + message_value_raw, Message ) except ValidationError as error: _collect(violations, "message", error) @@ -418,15 +425,23 @@ def from_transfer_type( if violations: raise ValidationError(violations) return SendMessageInput( - room_id=room_id, - message=message, + room_id=room_id_value, + message=message_value, ) @typing_extensions.override def to_transfer_type(self, value: "SendMessageInput") -> typing.Any: + violations: list[Violation] = [] out: dict[str, typing.Any] = {} out["roomId"] = value.room_id - out["message"] = _MessageTransferTypeConverter().to_transfer_type(value.message) + try: + out["message"] = _MessageTransferTypeConverter().to_transfer_type( + value.message + ) + except ValidationError as error: + _collect(violations, "message", error) + if violations: + raise ValidationError(violations) return out @@ -452,15 +467,15 @@ def from_transfer_type( raise ValidationError([Violation(path="", reason="expected object")]) raw = typing.cast("dict[str, typing.Any]", value) - message_id: str = typing.cast("typing.Any", None) + message_id_value: str = typing.cast("typing.Any", None) if "messageId" not in raw or raw["messageId"] is None: violations.append(Violation(path="messageId", reason="required")) else: - message_id_raw = raw["messageId"] - if not isinstance(message_id_raw, str): + message_id_value_raw = raw["messageId"] + if not isinstance(message_id_value_raw, str): violations.append(Violation(path="messageId", reason="expected string")) else: - message_id = message_id_raw + message_id_value = message_id_value_raw for key in raw: if key != "messageId": @@ -468,7 +483,7 @@ def from_transfer_type( if violations: raise ValidationError(violations) return SendMessageOutput( - message_id=message_id, + message_id=message_id_value, ) @typing_extensions.override diff --git a/advanced/samples/python/json_schema/api/kb/_definitions.py b/advanced/samples/python/json_schema/api/kb/_definitions.py index f2b94492..3bf7f712 100644 --- a/advanced/samples/python/json_schema/api/kb/_definitions.py +++ b/advanced/samples/python/json_schema/api/kb/_definitions.py @@ -16,6 +16,9 @@ "ValidationError", "Violation", "_check_contains", + "_check_date_time", + "_check_duration", + "_check_time", "_check_unique_items", "_collect", "_format_base64", @@ -191,6 +194,13 @@ def _check_contains( r"^PT(?:[0-9]+H(?:[0-9]+M(?:[0-9]+S)?)?|[0-9]+M(?:[0-9]+S)?|[0-9]+S)$" ) _TEMPORAL_MAX_DURATION_SECONDS = ((1 << 63) - 1) // 1_000_000_000 +# A duration component with more digits than the cap itself is over the cap +# whatever those digits are, which is how the magnitude is bounded before `int()` +# sees it: CPython refuses to convert a string of more than 4300 digits. +_TEMPORAL_MAX_DURATION_DIGITS = len(str(_TEMPORAL_MAX_DURATION_SECONDS)) +# `datetime` resolves to microseconds, and `fromisoformat` before Python 3.11 +# parses only the fraction widths `isoformat` writes. +_TEMPORAL_FRACTION_DIGITS = 6 def _days_in_month(year: int, month: int) -> int: @@ -210,10 +220,57 @@ def _valid_temporal_calendar(value: str) -> bool: year, month, day = int(value[0:4]), int(value[5:7]), int(value[8:10]) except ValueError: return False + # `datetime.MINYEAR` is 1, so year 0000 -- which the wire grammar admits and + # the other three targets materialize -- has no Python value at all. It is + # rejected rather than shifted into range, and `_temporal_reason` says so. + if year < datetime.MINYEAR: + return False maximum = _days_in_month(year, month) return maximum > 0 and 1 <= day <= maximum +def _temporal_reason(name: str, value: str) -> str: + """The reason a rejected temporal string is reported under. + + Year 0000 earns its own clause: it is a valid wire value the other targets + accept, so a caller needs to read Python's floor rather than conclude the + timestamp was malformed. + """ + + if value[0:4] == "0000": + return ( + f"must be a valid {name}, got {_quote(value)}: year 0000 is not" + f" representable (datetime.MINYEAR is {datetime.MINYEAR})" + ) + return f"must be a valid {name}, got {_quote(value)}" + + +def _temporal_isoformat(value: str) -> str: + """Rewrites a wire temporal into the spelling `fromisoformat` accepts. + + `Z` becomes `+00:00`, and the fractional second is padded or truncated to + exactly `_TEMPORAL_FRACTION_DIGITS`: before Python 3.11 `fromisoformat` + parses only what `isoformat` writes, so an RFC 3339 `.1` or `.1234567` -- + which every other target accepts -- would otherwise raise. Digits past the + sixth are dropped, the loss at `datetime`'s own resolution that P1 allows; + the canonical output re-trims the padding, so `.1` still writes as `.1`. + """ + + normalized = value.upper() + if normalized.endswith("Z"): + normalized = normalized[:-1] + "+00:00" + dot = normalized.find(".") + if dot < 0: + return normalized + end = dot + 1 + while end < len(normalized) and normalized[end].isdigit(): + end += 1 + fraction = normalized[dot + 1 : end].ljust(_TEMPORAL_FRACTION_DIGITS, "0") + return ( + normalized[: dot + 1] + fraction[:_TEMPORAL_FRACTION_DIGITS] + normalized[end:] + ) + + def _parse_date_time( value: str, path: str, violations: list[Violation] ) -> datetime.datetime | None: @@ -221,24 +278,17 @@ def _parse_date_time( value ): violations.append( - Violation( - path=path, reason=f"must be a valid date-time, got {_quote(value)}" - ) + Violation(path=path, reason=_temporal_reason("date-time", value)) ) return None - normalized = value.upper() - if normalized.endswith("Z"): - normalized = normalized[:-1] + "+00:00" - return datetime.datetime.fromisoformat(normalized) + return datetime.datetime.fromisoformat(_temporal_isoformat(value)) def _parse_date( value: str, path: str, violations: list[Violation] ) -> datetime.date | None: if _TEMPORAL_DATE_RE.match(value) is None or not _valid_temporal_calendar(value): - violations.append( - Violation(path=path, reason=f"must be a valid date, got {_quote(value)}") - ) + violations.append(Violation(path=path, reason=_temporal_reason("date", value))) return None return datetime.date.fromisoformat(value) @@ -247,14 +297,9 @@ def _parse_time( value: str, path: str, violations: list[Violation] ) -> datetime.time | None: if _TEMPORAL_TIME_RE.match(value) is None: - violations.append( - Violation(path=path, reason=f"must be a valid time, got {_quote(value)}") - ) + violations.append(Violation(path=path, reason=_temporal_reason("time", value))) return None - normalized = value.upper() - if normalized.endswith("Z"): - normalized = normalized[:-1] + "+00:00" - return datetime.time.fromisoformat(normalized) + return datetime.time.fromisoformat(_temporal_isoformat(value)) def _parse_duration( @@ -262,9 +307,7 @@ def _parse_duration( ) -> datetime.timedelta | None: if _TEMPORAL_DURATION_RE.match(value) is None: violations.append( - Violation( - path=path, reason=f"must be a valid duration, got {_quote(value)}" - ) + Violation(path=path, reason=_temporal_reason("duration", value)) ) return None total = 0 @@ -273,18 +316,103 @@ def _parse_duration( if char.isdigit(): number += char continue - total += int(number) * {"H": 3600, "M": 60, "S": 1}[char] + digits = number.lstrip("0") number = "" + if len(digits) > _TEMPORAL_MAX_DURATION_DIGITS: + # Over the cap by digit count alone (see the constant), so the + # conversion `int()` would refuse is never attempted. + total = _TEMPORAL_MAX_DURATION_SECONDS + 1 + break + total += int(digits or "0") * {"H": 3600, "M": 60, "S": 1}[char] if total > _TEMPORAL_MAX_DURATION_SECONDS: - violations.append( - Violation( - path=path, reason=f"must be a valid duration, got {_quote(value)}" - ) - ) - return None + break + if total > _TEMPORAL_MAX_DURATION_SECONDS: + violations.append( + Violation(path=path, reason=_temporal_reason("duration", value)) + ) + return None return datetime.timedelta(seconds=total) +def _check_temporal_offset( + name: str, + value: datetime.datetime | datetime.time, + offset: datetime.timedelta, + path: str, + violations: list[Violation], +) -> None: + """Asserts a UTC offset is a whole number of minutes, the finest the wire + form spells (`tzinfo` allows seconds, which the offset would silently lose). + """ + + if offset % datetime.timedelta(minutes=1): + violations.append( + Violation( + path=path, + reason=( + f"must be a valid {name}, got {_quote(str(value))}: " + f"the UTC offset {offset} is not a whole number of minutes" + ), + ) + ) + + +def _check_date_time( + value: datetime.datetime, path: str, violations: list[Violation] +) -> None: + """Asserts a datetime is writable as a wire date-time (P12). + + A dataclass is constructed unchecked, so a naive datetime -- with no offset + the required wire form could carry -- reaches serialize; without this it + would emit a value this module's own parser rejects. + """ + + offset = value.utcoffset() + if offset is None: + violations.append( + Violation( + path=path, + reason=( + f"must be a valid date-time, got {_quote(str(value))}: " + "a naive datetime carries no UTC offset" + ), + ) + ) + return + _check_temporal_offset("date-time", value, offset, path, violations) + + +def _check_time(value: datetime.time, path: str, violations: list[Violation]) -> None: + """Asserts a time is writable as a wire time (P12). The offset is optional in + the grammar, so only its precision is held to anything.""" + + offset = value.utcoffset() + if offset is not None: + _check_temporal_offset("time", value, offset, path, violations) + + +def _check_duration( + value: datetime.timedelta, path: str, violations: list[Violation] +) -> None: + """Asserts a timedelta is writable as a wire duration (P12): the grammar is + unsigned, whole-second and capped, and a `timedelta` is none of those.""" + + if value < datetime.timedelta(0): + reason = "a duration cannot be negative" + elif value % datetime.timedelta(seconds=1): + reason = "a duration cannot carry a fraction of a second" + elif value.total_seconds() > _TEMPORAL_MAX_DURATION_SECONDS: + reason = f"a duration cannot exceed {_TEMPORAL_MAX_DURATION_SECONDS} seconds" + else: + return + violations.append( + Violation( + path=path, + reason=f"must be a valid duration, got {_quote(str(value))}: {reason}", + ) + ) + + def _temporal_frac(microsecond: int) -> str: if microsecond == 0: return "" diff --git a/advanced/samples/python/json_schema/api/kb/_recursive.py b/advanced/samples/python/json_schema/api/kb/_recursive.py index 1cac48ca..0d5b9f43 100644 --- a/advanced/samples/python/json_schema/api/kb/_recursive.py +++ b/advanced/samples/python/json_schema/api/kb/_recursive.py @@ -32,66 +32,70 @@ def from_transfer_type( raise ValidationError([Violation(path="", reason="expected object")]) raw = typing.cast("dict[str, typing.Any]", value) - block_id: str = typing.cast("typing.Any", None) + block_id_value: str = typing.cast("typing.Any", None) if "blockId" not in raw or raw["blockId"] is None: violations.append(Violation(path="blockId", reason="required")) else: - block_id_raw = raw["blockId"] - if not isinstance(block_id_raw, str): + block_id_value_raw = raw["blockId"] + if not isinstance(block_id_value_raw, str): violations.append(Violation(path="blockId", reason="expected string")) else: - block_id = block_id_raw + block_id_value = block_id_value_raw - order: int = typing.cast("typing.Any", None) + order_value: int = typing.cast("typing.Any", None) if "order" not in raw or raw["order"] is None: violations.append(Violation(path="order", reason="required")) else: - order_raw = raw["order"] - order_parsed = _parse_spec_integer(order_raw, "order", violations) - if order_parsed is not None: - order = order_parsed - if order < 0: + order_value_raw = raw["order"] + order_value_parsed = _parse_spec_integer( + order_value_raw, "order", violations + ) + if order_value_parsed is not None: + order_value = order_value_parsed + if order_value < 0: violations.append( - Violation(path="order", reason=f"must be >= 0, got {order}") + Violation( + path="order", reason=f"must be >= 0, got {order_value}" + ) ) - text: str | None = None + text_value: str | None = None if "text" in raw: - text_raw = raw["text"] - if text_raw is None: + text_value_raw = raw["text"] + if text_value_raw is None: violations.append( Violation(path="text", reason="explicit null not allowed") ) else: - if not isinstance(text_raw, str): + if not isinstance(text_value_raw, str): violations.append(Violation(path="text", reason="expected string")) else: - text = text_raw + text_value = text_value_raw - style: BlockStyle | None = None + style_value: BlockStyle | None = None if "style" in raw: - style_raw = raw["style"] - if style_raw is None: + style_value_raw = raw["style"] + if style_value_raw is None: violations.append( Violation(path="style", reason="explicit null not allowed") ) else: try: - style = getattr( + style_value = getattr( BlockStyle, "__temporal_transfer_type_converter" - ).from_transfer_type(style_raw, BlockStyle) + ).from_transfer_type(style_value_raw, BlockStyle) except ValidationError as error: _collect(violations, "style", error) - page: Page | None = None + page_value: Page | None = None if "page" in raw: - page_raw = raw["page"] - if page_raw is None: - page = None + page_value_raw = raw["page"] + if page_value_raw is None: + page_value = None else: try: - page = _PageTransferTypeConverter().from_transfer_type( - page_raw, Page + page_value = _PageTransferTypeConverter().from_transfer_type( + page_value_raw, Page ) except ValidationError as error: _collect(violations, "page", error) @@ -108,11 +112,11 @@ def from_transfer_type( if violations: raise ValidationError(violations) return Block( - block_id=block_id, - order=order, - text=text, - style=style, - page=page, + block_id=block_id_value, + order=order_value, + text=text_value, + style=style_value, + page=page_value, ) @typing_extensions.override @@ -128,11 +132,17 @@ def to_transfer_type(self, value: "Block") -> typing.Any: if value.text is not None: out["text"] = value.text if value.style is not None: - out["style"] = getattr( - BlockStyle, "__temporal_transfer_type_converter" - ).to_transfer_type(value.style) + try: + out["style"] = getattr( + BlockStyle, "__temporal_transfer_type_converter" + ).to_transfer_type(value.style) + except ValidationError as error: + _collect(violations, "style", error) if value.page is not None: - out["page"] = _PageTransferTypeConverter().to_transfer_type(value.page) + try: + out["page"] = _PageTransferTypeConverter().to_transfer_type(value.page) + except ValidationError as error: + _collect(violations, "page", error) if violations: raise ValidationError(violations) return out @@ -173,65 +183,65 @@ def from_transfer_type(self, value: typing.Any, type_hint: type["Page"]) -> "Pag raise ValidationError([Violation(path="", reason="expected object")]) raw = typing.cast("dict[str, typing.Any]", value) - page_id: str = typing.cast("typing.Any", None) + page_id_value: str = typing.cast("typing.Any", None) if "pageId" not in raw or raw["pageId"] is None: violations.append(Violation(path="pageId", reason="required")) else: - page_id_raw = raw["pageId"] - if not isinstance(page_id_raw, str): + page_id_value_raw = raw["pageId"] + if not isinstance(page_id_value_raw, str): violations.append(Violation(path="pageId", reason="expected string")) else: - page_id = page_id_raw + page_id_value = page_id_value_raw - title: str = typing.cast("typing.Any", None) + title_value: str = typing.cast("typing.Any", None) if "title" not in raw or raw["title"] is None: violations.append(Violation(path="title", reason="required")) else: - title_raw = raw["title"] - if not isinstance(title_raw, str): + title_value_raw = raw["title"] + if not isinstance(title_value_raw, str): violations.append(Violation(path="title", reason="expected string")) else: - title = title_raw + title_value = title_value_raw - meta: PageMeta = typing.cast("typing.Any", None) + meta_value: PageMeta = typing.cast("typing.Any", None) if "meta" not in raw or raw["meta"] is None: violations.append(Violation(path="meta", reason="required")) else: - meta_raw = raw["meta"] + meta_value_raw = raw["meta"] try: - meta = getattr( + meta_value = getattr( PageMeta, "__temporal_transfer_type_converter" - ).from_transfer_type(meta_raw, PageMeta) + ).from_transfer_type(meta_value_raw, PageMeta) except ValidationError as error: _collect(violations, "meta", error) - blocks: list[Block] | None = None + blocks_value: list[Block] | None = None if "blocks" in raw: - blocks_raw = raw["blocks"] - if blocks_raw is None: + blocks_value_raw = raw["blocks"] + if blocks_value_raw is None: violations.append( Violation(path="blocks", reason="explicit null not allowed") ) else: - if not isinstance(blocks_raw, list): + if not isinstance(blocks_value_raw, list): violations.append(Violation(path="blocks", reason="expected array")) else: - blocks_list: list[Block] = [] - for blocks_index, blocks_element in enumerate( - typing.cast("list[typing.Any]", blocks_raw) + blocks_value_list: list[Block] = [] + for blocks_value_index, blocks_value_element in enumerate( + typing.cast("list[typing.Any]", blocks_value_raw) ): - blocks_item_path = f"blocks[{blocks_index}]" - blocks_item: Block = typing.cast("typing.Any", None) + blocks_value_item_path = f"blocks[{blocks_value_index}]" + blocks_value_item: Block = typing.cast("typing.Any", None) try: - blocks_item = ( + blocks_value_item = ( _BlockTransferTypeConverter().from_transfer_type( - blocks_element, Block + blocks_value_element, Block ) ) except ValidationError as error: - _collect(violations, blocks_item_path, error) - blocks_list.append(blocks_item) - blocks = blocks_list + _collect(violations, blocks_value_item_path, error) + blocks_value_list.append(blocks_value_item) + blocks_value = blocks_value_list for key in raw: if key != "pageId" and key != "title" and key != "meta" and key != "blocks": @@ -239,25 +249,36 @@ def from_transfer_type(self, value: typing.Any, type_hint: type["Page"]) -> "Pag if violations: raise ValidationError(violations) return Page( - page_id=page_id, - title=title, - meta=meta, - blocks=blocks, + page_id=page_id_value, + title=title_value, + meta=meta_value, + blocks=blocks_value, ) @typing_extensions.override def to_transfer_type(self, value: "Page") -> typing.Any: + violations: list[Violation] = [] out: dict[str, typing.Any] = {} out["pageId"] = value.page_id out["title"] = value.title - out["meta"] = getattr( - PageMeta, "__temporal_transfer_type_converter" - ).to_transfer_type(value.meta) + try: + out["meta"] = getattr( + PageMeta, "__temporal_transfer_type_converter" + ).to_transfer_type(value.meta) + except ValidationError as error: + _collect(violations, "meta", error) if value.blocks is not None: - out["blocks"] = [ - _BlockTransferTypeConverter().to_transfer_type(element) - for element in value.blocks - ] + blocks_out: list[typing.Any] = [] + for blocks_index, blocks_element in enumerate(value.blocks): + try: + blocks_out.append( + _BlockTransferTypeConverter().to_transfer_type(blocks_element) + ) + except ValidationError as error: + _collect(violations, f"blocks[{blocks_index}]", error) + out["blocks"] = blocks_out + if violations: + raise ValidationError(violations) return out diff --git a/advanced/samples/python/json_schema/api/kb/content/block/models.py b/advanced/samples/python/json_schema/api/kb/content/block/models.py index 93ea5e5a..5d368d93 100644 --- a/advanced/samples/python/json_schema/api/kb/content/block/models.py +++ b/advanced/samples/python/json_schema/api/kb/content/block/models.py @@ -27,34 +27,37 @@ def from_transfer_type( raise ValidationError([Violation(path="", reason="expected object")]) raw = typing.cast("dict[str, typing.Any]", value) - bold: bool | None = None + bold_value: bool | None = None if "bold" in raw: - bold_raw = raw["bold"] - if bold_raw is None: + bold_value_raw = raw["bold"] + if bold_value_raw is None: violations.append( Violation(path="bold", reason="explicit null not allowed") ) else: - if not isinstance(bold_raw, bool): + if not isinstance(bold_value_raw, bool): violations.append(Violation(path="bold", reason="expected boolean")) else: - bold = bold_raw + bold_value = bold_value_raw - indent: int | None = None + indent_value: int | None = None if "indent" in raw: - indent_raw = raw["indent"] - if indent_raw is None: + indent_value_raw = raw["indent"] + if indent_value_raw is None: violations.append( Violation(path="indent", reason="explicit null not allowed") ) else: - indent_parsed = _parse_spec_integer(indent_raw, "indent", violations) - if indent_parsed is not None: - indent = indent_parsed - if indent < 0: + indent_value_parsed = _parse_spec_integer( + indent_value_raw, "indent", violations + ) + if indent_value_parsed is not None: + indent_value = indent_value_parsed + if indent_value < 0: violations.append( Violation( - path="indent", reason=f"must be >= 0, got {indent}" + path="indent", + reason=f"must be >= 0, got {indent_value}", ) ) @@ -64,8 +67,8 @@ def from_transfer_type( if violations: raise ValidationError(violations) return BlockStyle( - bold=bold, - indent=indent, + bold=bold_value, + indent=indent_value, ) @typing_extensions.override diff --git a/advanced/samples/python/json_schema/api/kb/content/page/models.py b/advanced/samples/python/json_schema/api/kb/content/page/models.py index 4704f8fa..5719f97f 100644 --- a/advanced/samples/python/json_schema/api/kb/content/page/models.py +++ b/advanced/samples/python/json_schema/api/kb/content/page/models.py @@ -27,29 +27,29 @@ def from_transfer_type( raise ValidationError([Violation(path="", reason="expected object")]) raw = typing.cast("dict[str, typing.Any]", value) - author: str = typing.cast("typing.Any", None) + author_value: str = typing.cast("typing.Any", None) if "author" not in raw or raw["author"] is None: violations.append(Violation(path="author", reason="required")) else: - author_raw = raw["author"] - if not isinstance(author_raw, str): + author_value_raw = raw["author"] + if not isinstance(author_value_raw, str): violations.append(Violation(path="author", reason="expected string")) else: - author = author_raw + author_value = author_value_raw - word_count: int | None = None + word_count_value: int | None = None if "wordCount" in raw: - word_count_raw = raw["wordCount"] - if word_count_raw is None: + word_count_value_raw = raw["wordCount"] + if word_count_value_raw is None: violations.append( Violation(path="wordCount", reason="explicit null not allowed") ) else: - word_count_parsed = _parse_spec_integer( - word_count_raw, "wordCount", violations + word_count_value_parsed = _parse_spec_integer( + word_count_value_raw, "wordCount", violations ) - if word_count_parsed is not None: - word_count = word_count_parsed + if word_count_value_parsed is not None: + word_count_value = word_count_value_parsed for key in raw: if key != "author" and key != "wordCount": @@ -57,8 +57,8 @@ def from_transfer_type( if violations: raise ValidationError(violations) return PageMeta( - author=author, - word_count=word_count, + author=author_value, + word_count=word_count_value, ) @typing_extensions.override diff --git a/advanced/samples/python/json_schema/api/kb/kb/models.py b/advanced/samples/python/json_schema/api/kb/kb/models.py index e40c9873..3a3a2b70 100644 --- a/advanced/samples/python/json_schema/api/kb/kb/models.py +++ b/advanced/samples/python/json_schema/api/kb/kb/models.py @@ -27,15 +27,15 @@ def from_transfer_type( raise ValidationError([Violation(path="", reason="expected object")]) raw = typing.cast("dict[str, typing.Any]", value) - root_id: str = typing.cast("typing.Any", None) + root_id_value: str = typing.cast("typing.Any", None) if "rootId" not in raw or raw["rootId"] is None: violations.append(Violation(path="rootId", reason="required")) else: - root_id_raw = raw["rootId"] - if not isinstance(root_id_raw, str): + root_id_value_raw = raw["rootId"] + if not isinstance(root_id_value_raw, str): violations.append(Violation(path="rootId", reason="expected string")) else: - root_id = root_id_raw + root_id_value = root_id_value_raw for key in raw: if key != "rootId": @@ -43,7 +43,7 @@ def from_transfer_type( if violations: raise ValidationError(violations) return GetCategoryTreeInput( - root_id=root_id, + root_id=root_id_value, ) @typing_extensions.override @@ -71,15 +71,15 @@ def from_transfer_type( raise ValidationError([Violation(path="", reason="expected object")]) raw = typing.cast("dict[str, typing.Any]", value) - page_id: str = typing.cast("typing.Any", None) + page_id_value: str = typing.cast("typing.Any", None) if "pageId" not in raw or raw["pageId"] is None: violations.append(Violation(path="pageId", reason="required")) else: - page_id_raw = raw["pageId"] - if not isinstance(page_id_raw, str): + page_id_value_raw = raw["pageId"] + if not isinstance(page_id_value_raw, str): violations.append(Violation(path="pageId", reason="expected string")) else: - page_id = page_id_raw + page_id_value = page_id_value_raw for key in raw: if key != "pageId": @@ -87,7 +87,7 @@ def from_transfer_type( if violations: raise ValidationError(violations) return GetPageInput( - page_id=page_id, + page_id=page_id_value, ) @typing_extensions.override @@ -115,24 +115,26 @@ def from_transfer_type( raise ValidationError([Violation(path="", reason="expected object")]) raw = typing.cast("dict[str, typing.Any]", value) - block_id: str = typing.cast("typing.Any", None) + block_id_value: str = typing.cast("typing.Any", None) if "blockId" not in raw or raw["blockId"] is None: violations.append(Violation(path="blockId", reason="required")) else: - block_id_raw = raw["blockId"] - if not isinstance(block_id_raw, str): + block_id_value_raw = raw["blockId"] + if not isinstance(block_id_value_raw, str): violations.append(Violation(path="blockId", reason="expected string")) else: - block_id = block_id_raw + block_id_value = block_id_value_raw - revision: int = typing.cast("typing.Any", None) + revision_value: int = typing.cast("typing.Any", None) if "revision" not in raw or raw["revision"] is None: violations.append(Violation(path="revision", reason="required")) else: - revision_raw = raw["revision"] - revision_parsed = _parse_spec_integer(revision_raw, "revision", violations) - if revision_parsed is not None: - revision = revision_parsed + revision_value_raw = raw["revision"] + revision_value_parsed = _parse_spec_integer( + revision_value_raw, "revision", violations + ) + if revision_value_parsed is not None: + revision_value = revision_value_parsed for key in raw: if key != "blockId" and key != "revision": @@ -140,8 +142,8 @@ def from_transfer_type( if violations: raise ValidationError(violations) return PutBlockOutput( - block_id=block_id, - revision=revision, + block_id=block_id_value, + revision=revision_value, ) @typing_extensions.override diff --git a/advanced/samples/python/json_schema/api/kb/tree/category/models.py b/advanced/samples/python/json_schema/api/kb/tree/category/models.py index 89642623..bf64975e 100644 --- a/advanced/samples/python/json_schema/api/kb/tree/category/models.py +++ b/advanced/samples/python/json_schema/api/kb/tree/category/models.py @@ -27,55 +27,55 @@ def from_transfer_type( raise ValidationError([Violation(path="", reason="expected object")]) raw = typing.cast("dict[str, typing.Any]", value) - id: str = typing.cast("typing.Any", None) + id_value: str = typing.cast("typing.Any", None) if "id" not in raw or raw["id"] is None: violations.append(Violation(path="id", reason="required")) else: - id_raw = raw["id"] - if not isinstance(id_raw, str): + id_value_raw = raw["id"] + if not isinstance(id_value_raw, str): violations.append(Violation(path="id", reason="expected string")) else: - id = id_raw + id_value = id_value_raw - name: str = typing.cast("typing.Any", None) + name_value: str = typing.cast("typing.Any", None) if "name" not in raw or raw["name"] is None: violations.append(Violation(path="name", reason="required")) else: - name_raw = raw["name"] - if not isinstance(name_raw, str): + name_value_raw = raw["name"] + if not isinstance(name_value_raw, str): violations.append(Violation(path="name", reason="expected string")) else: - name = name_raw + name_value = name_value_raw - children: list[Category] | None = None + children_value: list[Category] | None = None if "children" in raw: - children_raw = raw["children"] - if children_raw is None: + children_value_raw = raw["children"] + if children_value_raw is None: violations.append( Violation(path="children", reason="explicit null not allowed") ) else: - if not isinstance(children_raw, list): + if not isinstance(children_value_raw, list): violations.append( Violation(path="children", reason="expected array") ) else: - children_list: list[Category] = [] - for children_index, children_element in enumerate( - typing.cast("list[typing.Any]", children_raw) + children_value_list: list[Category] = [] + for children_value_index, children_value_element in enumerate( + typing.cast("list[typing.Any]", children_value_raw) ): - children_item_path = f"children[{children_index}]" - children_item: Category = typing.cast("typing.Any", None) + children_value_item_path = f"children[{children_value_index}]" + children_value_item: Category = typing.cast("typing.Any", None) try: - children_item = ( + children_value_item = ( _CategoryTransferTypeConverter().from_transfer_type( - children_element, Category + children_value_element, Category ) ) except ValidationError as error: - _collect(violations, children_item_path, error) - children_list.append(children_item) - children = children_list + _collect(violations, children_value_item_path, error) + children_value_list.append(children_value_item) + children_value = children_value_list for key in raw: if key != "id" and key != "name" and key != "children": @@ -83,21 +83,31 @@ def from_transfer_type( if violations: raise ValidationError(violations) return Category( - id=id, - name=name, - children=children, + id=id_value, + name=name_value, + children=children_value, ) @typing_extensions.override def to_transfer_type(self, value: "Category") -> typing.Any: + violations: list[Violation] = [] out: dict[str, typing.Any] = {} out["id"] = value.id out["name"] = value.name if value.children is not None: - out["children"] = [ - _CategoryTransferTypeConverter().to_transfer_type(element) - for element in value.children - ] + children_out: list[typing.Any] = [] + for children_index, children_element in enumerate(value.children): + try: + children_out.append( + _CategoryTransferTypeConverter().to_transfer_type( + children_element + ) + ) + except ValidationError as error: + _collect(violations, f"children[{children_index}]", error) + out["children"] = children_out + if violations: + raise ValidationError(violations) return out @@ -130,30 +140,30 @@ def from_transfer_type( raise ValidationError([Violation(path="", reason="expected object")]) raw = typing.cast("dict[str, typing.Any]", value) - swatches: list[str] = typing.cast("typing.Any", None) + swatches_value: list[str] = typing.cast("typing.Any", None) if "swatches" not in raw or raw["swatches"] is None: violations.append(Violation(path="swatches", reason="required")) else: - swatches_raw = raw["swatches"] - if not isinstance(swatches_raw, list): + swatches_value_raw = raw["swatches"] + if not isinstance(swatches_value_raw, list): violations.append(Violation(path="swatches", reason="expected array")) else: - swatches_list: list[str] = [] - for swatches_index, swatches_element in enumerate( - typing.cast("list[typing.Any]", swatches_raw) + swatches_value_list: list[str] = [] + for swatches_value_index, swatches_value_element in enumerate( + typing.cast("list[typing.Any]", swatches_value_raw) ): - swatches_item_path = f"swatches[{swatches_index}]" - swatches_item: str = typing.cast("typing.Any", None) - if not isinstance(swatches_element, str): + swatches_value_item_path = f"swatches[{swatches_value_index}]" + swatches_value_item: str = typing.cast("typing.Any", None) + if not isinstance(swatches_value_element, str): violations.append( Violation( - path=swatches_item_path, reason="expected element" + path=swatches_value_item_path, reason="expected element" ) ) else: - swatches_item = swatches_element - swatches_list.append(swatches_item) - swatches = swatches_list + swatches_value_item = swatches_value_element + swatches_value_list.append(swatches_value_item) + swatches_value = swatches_value_list for key in raw: if key != "swatches": @@ -161,7 +171,7 @@ def from_transfer_type( if violations: raise ValidationError(violations) return Palette( - swatches=swatches, + swatches=swatches_value, ) @typing_extensions.override diff --git a/advanced/samples/python/json_schema/api/showcase/_definitions.py b/advanced/samples/python/json_schema/api/showcase/_definitions.py index f2b94492..3bf7f712 100644 --- a/advanced/samples/python/json_schema/api/showcase/_definitions.py +++ b/advanced/samples/python/json_schema/api/showcase/_definitions.py @@ -16,6 +16,9 @@ "ValidationError", "Violation", "_check_contains", + "_check_date_time", + "_check_duration", + "_check_time", "_check_unique_items", "_collect", "_format_base64", @@ -191,6 +194,13 @@ def _check_contains( r"^PT(?:[0-9]+H(?:[0-9]+M(?:[0-9]+S)?)?|[0-9]+M(?:[0-9]+S)?|[0-9]+S)$" ) _TEMPORAL_MAX_DURATION_SECONDS = ((1 << 63) - 1) // 1_000_000_000 +# A duration component with more digits than the cap itself is over the cap +# whatever those digits are, which is how the magnitude is bounded before `int()` +# sees it: CPython refuses to convert a string of more than 4300 digits. +_TEMPORAL_MAX_DURATION_DIGITS = len(str(_TEMPORAL_MAX_DURATION_SECONDS)) +# `datetime` resolves to microseconds, and `fromisoformat` before Python 3.11 +# parses only the fraction widths `isoformat` writes. +_TEMPORAL_FRACTION_DIGITS = 6 def _days_in_month(year: int, month: int) -> int: @@ -210,10 +220,57 @@ def _valid_temporal_calendar(value: str) -> bool: year, month, day = int(value[0:4]), int(value[5:7]), int(value[8:10]) except ValueError: return False + # `datetime.MINYEAR` is 1, so year 0000 -- which the wire grammar admits and + # the other three targets materialize -- has no Python value at all. It is + # rejected rather than shifted into range, and `_temporal_reason` says so. + if year < datetime.MINYEAR: + return False maximum = _days_in_month(year, month) return maximum > 0 and 1 <= day <= maximum +def _temporal_reason(name: str, value: str) -> str: + """The reason a rejected temporal string is reported under. + + Year 0000 earns its own clause: it is a valid wire value the other targets + accept, so a caller needs to read Python's floor rather than conclude the + timestamp was malformed. + """ + + if value[0:4] == "0000": + return ( + f"must be a valid {name}, got {_quote(value)}: year 0000 is not" + f" representable (datetime.MINYEAR is {datetime.MINYEAR})" + ) + return f"must be a valid {name}, got {_quote(value)}" + + +def _temporal_isoformat(value: str) -> str: + """Rewrites a wire temporal into the spelling `fromisoformat` accepts. + + `Z` becomes `+00:00`, and the fractional second is padded or truncated to + exactly `_TEMPORAL_FRACTION_DIGITS`: before Python 3.11 `fromisoformat` + parses only what `isoformat` writes, so an RFC 3339 `.1` or `.1234567` -- + which every other target accepts -- would otherwise raise. Digits past the + sixth are dropped, the loss at `datetime`'s own resolution that P1 allows; + the canonical output re-trims the padding, so `.1` still writes as `.1`. + """ + + normalized = value.upper() + if normalized.endswith("Z"): + normalized = normalized[:-1] + "+00:00" + dot = normalized.find(".") + if dot < 0: + return normalized + end = dot + 1 + while end < len(normalized) and normalized[end].isdigit(): + end += 1 + fraction = normalized[dot + 1 : end].ljust(_TEMPORAL_FRACTION_DIGITS, "0") + return ( + normalized[: dot + 1] + fraction[:_TEMPORAL_FRACTION_DIGITS] + normalized[end:] + ) + + def _parse_date_time( value: str, path: str, violations: list[Violation] ) -> datetime.datetime | None: @@ -221,24 +278,17 @@ def _parse_date_time( value ): violations.append( - Violation( - path=path, reason=f"must be a valid date-time, got {_quote(value)}" - ) + Violation(path=path, reason=_temporal_reason("date-time", value)) ) return None - normalized = value.upper() - if normalized.endswith("Z"): - normalized = normalized[:-1] + "+00:00" - return datetime.datetime.fromisoformat(normalized) + return datetime.datetime.fromisoformat(_temporal_isoformat(value)) def _parse_date( value: str, path: str, violations: list[Violation] ) -> datetime.date | None: if _TEMPORAL_DATE_RE.match(value) is None or not _valid_temporal_calendar(value): - violations.append( - Violation(path=path, reason=f"must be a valid date, got {_quote(value)}") - ) + violations.append(Violation(path=path, reason=_temporal_reason("date", value))) return None return datetime.date.fromisoformat(value) @@ -247,14 +297,9 @@ def _parse_time( value: str, path: str, violations: list[Violation] ) -> datetime.time | None: if _TEMPORAL_TIME_RE.match(value) is None: - violations.append( - Violation(path=path, reason=f"must be a valid time, got {_quote(value)}") - ) + violations.append(Violation(path=path, reason=_temporal_reason("time", value))) return None - normalized = value.upper() - if normalized.endswith("Z"): - normalized = normalized[:-1] + "+00:00" - return datetime.time.fromisoformat(normalized) + return datetime.time.fromisoformat(_temporal_isoformat(value)) def _parse_duration( @@ -262,9 +307,7 @@ def _parse_duration( ) -> datetime.timedelta | None: if _TEMPORAL_DURATION_RE.match(value) is None: violations.append( - Violation( - path=path, reason=f"must be a valid duration, got {_quote(value)}" - ) + Violation(path=path, reason=_temporal_reason("duration", value)) ) return None total = 0 @@ -273,18 +316,103 @@ def _parse_duration( if char.isdigit(): number += char continue - total += int(number) * {"H": 3600, "M": 60, "S": 1}[char] + digits = number.lstrip("0") number = "" + if len(digits) > _TEMPORAL_MAX_DURATION_DIGITS: + # Over the cap by digit count alone (see the constant), so the + # conversion `int()` would refuse is never attempted. + total = _TEMPORAL_MAX_DURATION_SECONDS + 1 + break + total += int(digits or "0") * {"H": 3600, "M": 60, "S": 1}[char] if total > _TEMPORAL_MAX_DURATION_SECONDS: - violations.append( - Violation( - path=path, reason=f"must be a valid duration, got {_quote(value)}" - ) - ) - return None + break + if total > _TEMPORAL_MAX_DURATION_SECONDS: + violations.append( + Violation(path=path, reason=_temporal_reason("duration", value)) + ) + return None return datetime.timedelta(seconds=total) +def _check_temporal_offset( + name: str, + value: datetime.datetime | datetime.time, + offset: datetime.timedelta, + path: str, + violations: list[Violation], +) -> None: + """Asserts a UTC offset is a whole number of minutes, the finest the wire + form spells (`tzinfo` allows seconds, which the offset would silently lose). + """ + + if offset % datetime.timedelta(minutes=1): + violations.append( + Violation( + path=path, + reason=( + f"must be a valid {name}, got {_quote(str(value))}: " + f"the UTC offset {offset} is not a whole number of minutes" + ), + ) + ) + + +def _check_date_time( + value: datetime.datetime, path: str, violations: list[Violation] +) -> None: + """Asserts a datetime is writable as a wire date-time (P12). + + A dataclass is constructed unchecked, so a naive datetime -- with no offset + the required wire form could carry -- reaches serialize; without this it + would emit a value this module's own parser rejects. + """ + + offset = value.utcoffset() + if offset is None: + violations.append( + Violation( + path=path, + reason=( + f"must be a valid date-time, got {_quote(str(value))}: " + "a naive datetime carries no UTC offset" + ), + ) + ) + return + _check_temporal_offset("date-time", value, offset, path, violations) + + +def _check_time(value: datetime.time, path: str, violations: list[Violation]) -> None: + """Asserts a time is writable as a wire time (P12). The offset is optional in + the grammar, so only its precision is held to anything.""" + + offset = value.utcoffset() + if offset is not None: + _check_temporal_offset("time", value, offset, path, violations) + + +def _check_duration( + value: datetime.timedelta, path: str, violations: list[Violation] +) -> None: + """Asserts a timedelta is writable as a wire duration (P12): the grammar is + unsigned, whole-second and capped, and a `timedelta` is none of those.""" + + if value < datetime.timedelta(0): + reason = "a duration cannot be negative" + elif value % datetime.timedelta(seconds=1): + reason = "a duration cannot carry a fraction of a second" + elif value.total_seconds() > _TEMPORAL_MAX_DURATION_SECONDS: + reason = f"a duration cannot exceed {_TEMPORAL_MAX_DURATION_SECONDS} seconds" + else: + return + violations.append( + Violation( + path=path, + reason=f"must be a valid duration, got {_quote(str(value))}: {reason}", + ) + ) + + def _temporal_frac(microsecond: int) -> str: if microsecond == 0: return "" diff --git a/advanced/samples/python/json_schema/api/showcase/models.py b/advanced/samples/python/json_schema/api/showcase/models.py index dce10b2b..e621b9b3 100644 --- a/advanced/samples/python/json_schema/api/showcase/models.py +++ b/advanced/samples/python/json_schema/api/showcase/models.py @@ -113,40 +113,40 @@ def from_transfer_type( raise ValidationError([Violation(path="", reason="expected object")]) raw = typing.cast("dict[str, typing.Any]", value) - street: str = typing.cast("typing.Any", None) + street_value: str = typing.cast("typing.Any", None) if "street" not in raw or raw["street"] is None: violations.append(Violation(path="street", reason="required")) else: - street_raw = raw["street"] - if not isinstance(street_raw, str): + street_value_raw = raw["street"] + if not isinstance(street_value_raw, str): violations.append(Violation(path="street", reason="expected string")) else: - street = street_raw + street_value = street_value_raw - city: str | None = None + city_value: str | None = None if "city" in raw: - city_raw = raw["city"] - if city_raw is None: + city_value_raw = raw["city"] + if city_value_raw is None: violations.append( Violation(path="city", reason="explicit null not allowed") ) else: - if not isinstance(city_raw, str): + if not isinstance(city_value_raw, str): violations.append(Violation(path="city", reason="expected string")) else: - city = city_raw + city_value = city_value_raw - zip: int | None = None + zip_value: int | None = None if "zip" in raw: - zip_raw = raw["zip"] - if zip_raw is None: + zip_value_raw = raw["zip"] + if zip_value_raw is None: violations.append( Violation(path="zip", reason="explicit null not allowed") ) else: - zip_parsed = _parse_spec_integer(zip_raw, "zip", violations) - if zip_parsed is not None: - zip = zip_parsed + zip_value_parsed = _parse_spec_integer(zip_value_raw, "zip", violations) + if zip_value_parsed is not None: + zip_value = zip_value_parsed additional_properties: dict[str, typing.Any] = {} for key in raw: @@ -155,9 +155,9 @@ def from_transfer_type( if violations: raise ValidationError(violations) return Address( - street=street, - city=city, - zip=zip, + street=street_value, + city=city_value, + zip=zip_value, additional_properties=additional_properties, ) @@ -303,9 +303,15 @@ def from_transfer_type( @typing_extensions.override def to_transfer_type(self, value: "Choices") -> typing.Any: + violations: list[Violation] = [] out: dict[str, typing.Any] = {} for key, entry in value.additional_properties.items(): - out[key] = _choices_value_to_transfer_type(entry) + try: + out[key] = _choices_value_to_transfer_type(entry) + except ValidationError as error: + _collect(violations, key, error) + if violations: + raise ValidationError(violations) return out @@ -335,30 +341,41 @@ def from_transfer_type( raise ValidationError([Violation(path="", reason="expected object")]) raw = typing.cast("dict[str, typing.Any]", value) - kind: typing.Literal["circle"] = typing.cast("typing.Any", None) + kind_value: typing.Literal["circle"] = typing.cast("typing.Any", None) if "kind" not in raw or raw["kind"] is None: violations.append(Violation(path="kind", reason="required")) else: - kind_raw = raw["kind"] - if not isinstance(kind_raw, str): + kind_value_raw = raw["kind"] + if not isinstance(kind_value_raw, str): violations.append(Violation(path="kind", reason="expected string")) - elif kind_raw != "circle": + elif kind_value_raw != "circle": violations.append(Violation(path="kind", reason='must equal "circle"')) else: - kind = kind_raw + kind_value = kind_value_raw - radius: float = typing.cast("typing.Any", None) + radius_value: float = typing.cast("typing.Any", None) if "radius" not in raw or raw["radius"] is None: violations.append(Violation(path="radius", reason="required")) else: - radius_raw = raw["radius"] + radius_value_raw = raw["radius"] if not ( - not isinstance(radius_raw, bool) - and isinstance(radius_raw, (int, float)) + not isinstance(radius_value_raw, bool) + and isinstance(radius_value_raw, (int, float)) ): violations.append(Violation(path="radius", reason="expected number")) else: - radius = radius_raw + radius_value = radius_value_raw + if not ( + -1.7976931348623157e308 + <= radius_value_raw + <= 1.7976931348623157e308 + ): + violations.append( + Violation( + path="radius", + reason=f"must be a finite number, got {radius_value_raw}", + ) + ) additional_properties: dict[str, typing.Any] = {} for key in raw: @@ -367,8 +384,8 @@ def from_transfer_type( if violations: raise ValidationError(violations) return Circle( - kind=kind, - radius=radius, + kind=kind_value, + radius=radius_value, additional_properties=additional_properties, ) @@ -379,6 +396,12 @@ def to_transfer_type(self, value: "Circle") -> typing.Any: if typing.cast("object", value.kind) not in ("circle",): violations.append(Violation(path="kind", reason='must equal "circle"')) out["kind"] = value.kind + if not (-1.7976931348623157e308 <= value.radius <= 1.7976931348623157e308): + violations.append( + Violation( + path="radius", reason=f"must be a finite number, got {value.radius}" + ) + ) out["radius"] = value.radius for key, entry in value.additional_properties.items(): out[key] = entry @@ -413,48 +436,48 @@ def from_transfer_type( raise ValidationError([Violation(path="", reason="expected object")]) raw = typing.cast("dict[str, typing.Any]", value) - email: str | None = None + email_value: str | None = None if "email" in raw: - email_raw = raw["email"] - if email_raw is None: + email_value_raw = raw["email"] + if email_value_raw is None: violations.append( Violation(path="email", reason="explicit null not allowed") ) else: - if not isinstance(email_raw, str): + if not isinstance(email_value_raw, str): violations.append(Violation(path="email", reason="expected string")) else: - email = email_raw + email_value = email_value_raw - shipping_street: str | None = None + shipping_street_value: str | None = None if "shippingStreet" in raw: - shipping_street_raw = raw["shippingStreet"] - if shipping_street_raw is None: + shipping_street_value_raw = raw["shippingStreet"] + if shipping_street_value_raw is None: violations.append( Violation(path="shippingStreet", reason="explicit null not allowed") ) else: - if not isinstance(shipping_street_raw, str): + if not isinstance(shipping_street_value_raw, str): violations.append( Violation(path="shippingStreet", reason="expected string") ) else: - shipping_street = shipping_street_raw + shipping_street_value = shipping_street_value_raw - shipping_zip: str | None = None + shipping_zip_value: str | None = None if "shippingZip" in raw: - shipping_zip_raw = raw["shippingZip"] - if shipping_zip_raw is None: + shipping_zip_value_raw = raw["shippingZip"] + if shipping_zip_value_raw is None: violations.append( Violation(path="shippingZip", reason="explicit null not allowed") ) else: - if not isinstance(shipping_zip_raw, str): + if not isinstance(shipping_zip_value_raw, str): violations.append( Violation(path="shippingZip", reason="expected string") ) else: - shipping_zip = shipping_zip_raw + shipping_zip_value = shipping_zip_value_raw additional_properties: dict[str, typing.Any] = {} for key in raw: @@ -483,9 +506,9 @@ def from_transfer_type( if violations: raise ValidationError(violations) return ContactPy( - email=email, - shipping_street=shipping_street, - shipping_zip=shipping_zip, + email=email_value, + shipping_street=shipping_street_value, + shipping_zip=shipping_zip_value, additional_properties=additional_properties, ) @@ -670,32 +693,32 @@ def from_transfer_type( raise ValidationError([Violation(path="", reason="expected object")]) raw = typing.cast("dict[str, typing.Any]", value) - kind: typing.Literal["link"] = typing.cast("typing.Any", None) + kind_value: typing.Literal["link"] = typing.cast("typing.Any", None) if "kind" not in raw or raw["kind"] is None: violations.append(Violation(path="kind", reason="required")) else: - kind_raw = raw["kind"] - if not isinstance(kind_raw, str): + kind_value_raw = raw["kind"] + if not isinstance(kind_value_raw, str): violations.append(Violation(path="kind", reason="expected string")) - elif kind_raw != "link": + elif kind_value_raw != "link": violations.append(Violation(path="kind", reason='must equal "link"')) else: - kind = kind_raw + kind_value = kind_value_raw - href: str = typing.cast("typing.Any", None) + href_value: str = typing.cast("typing.Any", None) if "href" not in raw or raw["href"] is None: violations.append(Violation(path="href", reason="required")) else: - href_raw = raw["href"] - if not isinstance(href_raw, str): + href_value_raw = raw["href"] + if not isinstance(href_value_raw, str): violations.append(Violation(path="href", reason="expected string")) else: - href = href_raw - if len(href_raw) < 1: + href_value = href_value_raw + if len(href_value_raw) < 1: violations.append( Violation( path="href", - reason=f"must have length >= 1, got {len(href_raw)}", + reason=f"must have length >= 1, got {len(href_value_raw)}", ) ) @@ -706,8 +729,8 @@ def from_transfer_type( if violations: raise ValidationError(violations) return LinkNote( - kind=kind, - href=href, + kind=kind_value, + href=href_value, additional_properties=additional_properties, ) @@ -895,32 +918,32 @@ def from_transfer_type( raise ValidationError([Violation(path="", reason="expected object")]) raw = typing.cast("dict[str, typing.Any]", value) - theme: str | None = None + theme_value: str | None = None if "theme" in raw: - theme_raw = raw["theme"] - if theme_raw is None: + theme_value_raw = raw["theme"] + if theme_value_raw is None: violations.append( Violation(path="theme", reason="explicit null not allowed") ) else: - if not isinstance(theme_raw, str): + if not isinstance(theme_value_raw, str): violations.append(Violation(path="theme", reason="expected string")) else: - theme = theme_raw + theme_value = theme_value_raw - font_size: int | None = None + font_size_value: int | None = None if "fontSize" in raw: - font_size_raw = raw["fontSize"] - if font_size_raw is None: + font_size_value_raw = raw["fontSize"] + if font_size_value_raw is None: violations.append( Violation(path="fontSize", reason="explicit null not allowed") ) else: - font_size_parsed = _parse_spec_integer( - font_size_raw, "fontSize", violations + font_size_value_parsed = _parse_spec_integer( + font_size_value_raw, "fontSize", violations ) - if font_size_parsed is not None: - font_size = font_size_parsed + if font_size_value_parsed is not None: + font_size_value = font_size_value_parsed for key in raw: if key != "theme" and key != "fontSize": @@ -928,8 +951,8 @@ def from_transfer_type( if violations: raise ValidationError(violations) return Settings( - theme=theme, - font_size=font_size, + theme=theme_value, + font_size=font_size_value, ) @typing_extensions.override @@ -964,681 +987,713 @@ def from_transfer_type( raise ValidationError([Violation(path="", reason="expected object")]) raw = typing.cast("dict[str, typing.Any]", value) - kind: typing.Literal["showcase"] = typing.cast("typing.Any", None) + kind_value: typing.Literal["showcase"] = typing.cast("typing.Any", None) if "kind" not in raw or raw["kind"] is None: violations.append(Violation(path="kind", reason="required")) else: - kind_raw = raw["kind"] - if not isinstance(kind_raw, str): + kind_value_raw = raw["kind"] + if not isinstance(kind_value_raw, str): violations.append(Violation(path="kind", reason="expected string")) - elif kind_raw != "showcase": + elif kind_value_raw != "showcase": violations.append( Violation(path="kind", reason='must equal "showcase"') ) else: - kind = kind_raw + kind_value = kind_value_raw - revision: typing.Literal[1] = typing.cast("typing.Any", None) + revision_value: typing.Literal[1] = typing.cast("typing.Any", None) if "revision" not in raw or raw["revision"] is None: violations.append(Violation(path="revision", reason="required")) else: - revision_raw = raw["revision"] - if not ( - not isinstance(revision_raw, bool) - and isinstance(revision_raw, (int, float)) - ): - violations.append(Violation(path="revision", reason="expected number")) - elif revision_raw != 1: - violations.append(Violation(path="revision", reason="must equal 1")) - else: - revision = typing.cast("typing.Literal[1]", revision_raw) + revision_value_raw = raw["revision"] + revision_value_parsed = _parse_spec_integer( + revision_value_raw, "revision", violations + ) + if revision_value_parsed is not None: + if revision_value_parsed != 1: + violations.append(Violation(path="revision", reason="must equal 1")) + else: + revision_value = revision_value_parsed - enabled: typing.Literal[True] = typing.cast("typing.Any", None) + enabled_value: typing.Literal[True] = typing.cast("typing.Any", None) if "enabled" not in raw or raw["enabled"] is None: violations.append(Violation(path="enabled", reason="required")) else: - enabled_raw = raw["enabled"] - if not isinstance(enabled_raw, bool): + enabled_value_raw = raw["enabled"] + if not isinstance(enabled_value_raw, bool): violations.append(Violation(path="enabled", reason="expected boolean")) - elif enabled_raw != True: + elif enabled_value_raw != True: violations.append(Violation(path="enabled", reason="must equal true")) else: - enabled = enabled_raw + enabled_value = enabled_value_raw - status: typing.Literal["active", "inactive", "pending"] = typing.cast( + status_value: typing.Literal["active", "inactive", "pending"] = typing.cast( "typing.Any", None ) if "status" not in raw or raw["status"] is None: violations.append(Violation(path="status", reason="required")) else: - status_raw = raw["status"] - if not isinstance(status_raw, str): + status_value_raw = raw["status"] + if not isinstance(status_value_raw, str): violations.append(Violation(path="status", reason="expected string")) elif ( - status_raw != "active" - and status_raw != "inactive" - and status_raw != "pending" + status_value_raw != "active" + and status_value_raw != "inactive" + and status_value_raw != "pending" ): violations.append( Violation( path="status", - reason=f'must be one of ["active", "inactive", "pending"], got {_quote(status_raw)}', + reason=f'must be one of ["active", "inactive", "pending"], got {_quote(status_value_raw)}', ) ) else: - status = status_raw + status_value = status_value_raw - tier: typing.Literal[1, 2, 3] = typing.cast("typing.Any", None) + tier_value: typing.Literal[1, 2, 3] = typing.cast("typing.Any", None) if "tier" not in raw or raw["tier"] is None: violations.append(Violation(path="tier", reason="required")) else: - tier_raw = raw["tier"] - if not ( - not isinstance(tier_raw, bool) and isinstance(tier_raw, (int, float)) - ): - violations.append(Violation(path="tier", reason="expected number")) - elif tier_raw != 1 and tier_raw != 2 and tier_raw != 3: - violations.append( - Violation( - path="tier", - reason=f"must be one of [1, 2, 3], got {_quote(tier_raw)}", + tier_value_raw = raw["tier"] + tier_value_parsed = _parse_spec_integer(tier_value_raw, "tier", violations) + if tier_value_parsed is not None: + if ( + tier_value_parsed != 1 + and tier_value_parsed != 2 + and tier_value_parsed != 3 + ): + violations.append( + Violation( + path="tier", + reason=f"must be one of [1, 2, 3], got {_quote(tier_value_parsed)}", + ) ) - ) - else: - tier = typing.cast("typing.Literal[1, 2, 3]", tier_raw) + else: + tier_value = tier_value_parsed - scale: float = typing.cast("typing.Any", None) + scale_value: float = typing.cast("typing.Any", None) if "scale" not in raw or raw["scale"] is None: violations.append(Violation(path="scale", reason="required")) else: - scale_raw = raw["scale"] + scale_value_raw = raw["scale"] if not ( - not isinstance(scale_raw, bool) and isinstance(scale_raw, (int, float)) + not isinstance(scale_value_raw, bool) + and isinstance(scale_value_raw, (int, float)) ): violations.append(Violation(path="scale", reason="expected number")) - elif scale_raw != 1.5 and scale_raw != 2.5: + elif scale_value_raw != 1.5 and scale_value_raw != 2.5: violations.append( Violation( path="scale", - reason=f"must be one of [1.5, 2.5], got {_quote(scale_raw)}", + reason=f"must be one of [1.5, 2.5], got {_quote(scale_value_raw)}", ) ) else: - scale = scale_raw + scale_value = scale_value_raw - name: str = typing.cast("typing.Any", None) + name_value: str = typing.cast("typing.Any", None) if "name" not in raw or raw["name"] is None: violations.append(Violation(path="name", reason="required")) else: - name_raw = raw["name"] - if not isinstance(name_raw, str): + name_value_raw = raw["name"] + if not isinstance(name_value_raw, str): violations.append(Violation(path="name", reason="expected string")) else: - name = name_raw - if len(name_raw) < 1: + name_value = name_value_raw + if len(name_value_raw) < 1: violations.append( Violation( path="name", - reason=f"must have length >= 1, got {len(name_raw)}", + reason=f"must have length >= 1, got {len(name_value_raw)}", ) ) - if len(name_raw) > 64: + if len(name_value_raw) > 64: violations.append( Violation( path="name", - reason=f"must have length <= 64, got {len(name_raw)}", + reason=f"must have length <= 64, got {len(name_value_raw)}", ) ) - count: int = typing.cast("typing.Any", None) + count_value: int = typing.cast("typing.Any", None) if "count" not in raw or raw["count"] is None: violations.append(Violation(path="count", reason="required")) else: - count_raw = raw["count"] - count_parsed = _parse_spec_integer(count_raw, "count", violations) - if count_parsed is not None: - count = count_parsed + count_value_raw = raw["count"] + count_value_parsed = _parse_spec_integer( + count_value_raw, "count", violations + ) + if count_value_parsed is not None: + count_value = count_value_parsed - active: bool = typing.cast("typing.Any", None) + active_value: bool = typing.cast("typing.Any", None) if "active" not in raw or raw["active"] is None: violations.append(Violation(path="active", reason="required")) else: - active_raw = raw["active"] - if not isinstance(active_raw, bool): + active_value_raw = raw["active"] + if not isinstance(active_value_raw, bool): violations.append(Violation(path="active", reason="expected boolean")) else: - active = active_raw + active_value = active_value_raw - nickname: str | None = None + nickname_value: str | None = None if "nickname" in raw: - nickname_raw = raw["nickname"] - if nickname_raw is None: + nickname_value_raw = raw["nickname"] + if nickname_value_raw is None: violations.append( Violation(path="nickname", reason="explicit null not allowed") ) else: - if not isinstance(nickname_raw, str): + if not isinstance(nickname_value_raw, str): violations.append( Violation(path="nickname", reason="expected string") ) else: - nickname = nickname_raw - if len(nickname_raw) > 12: + nickname_value = nickname_value_raw + if len(nickname_value_raw) > 12: violations.append( Violation( path="nickname", - reason=f"must have length <= 12, got {len(nickname_raw)}", + reason=f"must have length <= 12, got {len(nickname_value_raw)}", ) ) - code: str | None = None + code_value: str | None = None if "code" in raw: - code_raw = raw["code"] - if code_raw is None: + code_value_raw = raw["code"] + if code_value_raw is None: violations.append( Violation(path="code", reason="explicit null not allowed") ) else: - if not isinstance(code_raw, str): + if not isinstance(code_value_raw, str): violations.append(Violation(path="code", reason="expected string")) else: - code = code_raw - if len(code_raw) < 2: + code_value = code_value_raw + if len(code_value_raw) < 2: violations.append( Violation( path="code", - reason=f"must have length >= 2, got {len(code_raw)}", + reason=f"must have length >= 2, got {len(code_value_raw)}", ) ) - if len(code_raw) > 5: + if len(code_value_raw) > 5: violations.append( Violation( path="code", - reason=f"must have length <= 5, got {len(code_raw)}", + reason=f"must have length <= 5, got {len(code_value_raw)}", ) ) - sku: str | None = None + sku_value: str | None = None if "sku" in raw: - sku_raw = raw["sku"] - if sku_raw is None: + sku_value_raw = raw["sku"] + if sku_value_raw is None: violations.append( Violation(path="sku", reason="explicit null not allowed") ) else: - if not isinstance(sku_raw, str): + if not isinstance(sku_value_raw, str): violations.append(Violation(path="sku", reason="expected string")) else: - sku = sku_raw - if _PATTERN_CD24623C0C29CA35.search(sku_raw) is None: + sku_value = sku_value_raw + if _PATTERN_CD24623C0C29CA35.search(sku_value_raw) is None: violations.append( Violation( path="sku", - reason=f"must match pattern {_PATTERN_CD24623C0C29CA35.pattern}, got {_quote(sku_raw)}", + reason=f"must match pattern {_PATTERN_CD24623C0C29CA35.pattern}, got {_quote(sku_value_raw)}", ) ) - phrase: str | None = None + phrase_value: str | None = None if "phrase" in raw: - phrase_raw = raw["phrase"] - if phrase_raw is None: + phrase_value_raw = raw["phrase"] + if phrase_value_raw is None: violations.append( Violation(path="phrase", reason="explicit null not allowed") ) else: - if not isinstance(phrase_raw, str): + if not isinstance(phrase_value_raw, str): violations.append( Violation(path="phrase", reason="expected string") ) else: - phrase = phrase_raw - if _PATTERN_B4BA2CA20EB1B963.search(phrase_raw) is None: + phrase_value = phrase_value_raw + if _PATTERN_B4BA2CA20EB1B963.search(phrase_value_raw) is None: violations.append( Violation( path="phrase", - reason=f"must match pattern {_PATTERN_B4BA2CA20EB1B963.pattern}, got {_quote(phrase_raw)}", + reason=f"must match pattern {_PATTERN_B4BA2CA20EB1B963.pattern}, got {_quote(phrase_value_raw)}", ) ) - request_id: str | None = None + request_id_value: str | None = None if "requestId" in raw: - request_id_raw = raw["requestId"] - if request_id_raw is None: + request_id_value_raw = raw["requestId"] + if request_id_value_raw is None: violations.append( Violation(path="requestId", reason="explicit null not allowed") ) else: - if not isinstance(request_id_raw, str): + if not isinstance(request_id_value_raw, str): violations.append( Violation(path="requestId", reason="expected string") ) else: - request_id = request_id_raw - if _PATTERN_EAAFA3F3BF5456C8.search(request_id_raw) is None: + request_id_value = request_id_value_raw + if _PATTERN_EAAFA3F3BF5456C8.search(request_id_value_raw) is None: violations.append( Violation( path="requestId", - reason=f"must be a valid uuid, got {_quote(request_id_raw)}", + reason=f"must be a valid uuid, got {_quote(request_id_value_raw)}", ) ) - contact_email: str | None = None + contact_email_value: str | None = None if "contactEmail" in raw: - contact_email_raw = raw["contactEmail"] - if contact_email_raw is None: + contact_email_value_raw = raw["contactEmail"] + if contact_email_value_raw is None: violations.append( Violation(path="contactEmail", reason="explicit null not allowed") ) else: - if not isinstance(contact_email_raw, str): + if not isinstance(contact_email_value_raw, str): violations.append( Violation(path="contactEmail", reason="expected string") ) else: - contact_email = contact_email_raw + contact_email_value = contact_email_value_raw if ( - len(contact_email_raw) > 254 - or _PATTERN_67B8088E6C41E9D2.search(contact_email_raw) is None + len(contact_email_value_raw) > 254 + or _PATTERN_67B8088E6C41E9D2.search(contact_email_value_raw) + is None ): violations.append( Violation( path="contactEmail", - reason=f"must be a valid email, got {_quote(contact_email_raw)}", + reason=f"must be a valid email, got {_quote(contact_email_value_raw)}", ) ) - host: str | None = None + host_value: str | None = None if "host" in raw: - host_raw = raw["host"] - if host_raw is None: + host_value_raw = raw["host"] + if host_value_raw is None: violations.append( Violation(path="host", reason="explicit null not allowed") ) else: - if not isinstance(host_raw, str): + if not isinstance(host_value_raw, str): violations.append(Violation(path="host", reason="expected string")) else: - host = host_raw + host_value = host_value_raw if ( - len(host_raw) > 253 - or _PATTERN_C3551EE088DD1057.search(host_raw) is None + len(host_value_raw) > 253 + or _PATTERN_C3551EE088DD1057.search(host_value_raw) is None ): violations.append( Violation( path="host", - reason=f"must be a valid hostname, got {_quote(host_raw)}", + reason=f"must be a valid hostname, got {_quote(host_value_raw)}", ) ) - homepage: str | None = None + homepage_value: str | None = None if "homepage" in raw: - homepage_raw = raw["homepage"] - if homepage_raw is None: + homepage_value_raw = raw["homepage"] + if homepage_value_raw is None: violations.append( Violation(path="homepage", reason="explicit null not allowed") ) else: - if not isinstance(homepage_raw, str): + if not isinstance(homepage_value_raw, str): violations.append( Violation(path="homepage", reason="expected string") ) else: - homepage = homepage_raw - if _PATTERN_BECE32B4DA20247D.search(homepage_raw) is None: + homepage_value = homepage_value_raw + if _PATTERN_BECE32B4DA20247D.search(homepage_value_raw) is None: violations.append( Violation( path="homepage", - reason=f"must be a valid uri, got {_quote(homepage_raw)}", + reason=f"must be a valid uri, got {_quote(homepage_value_raw)}", ) ) - gateway: str | None = None + gateway_value: str | None = None if "gateway" in raw: - gateway_raw = raw["gateway"] - if gateway_raw is None: + gateway_value_raw = raw["gateway"] + if gateway_value_raw is None: violations.append( Violation(path="gateway", reason="explicit null not allowed") ) else: - if not isinstance(gateway_raw, str): + if not isinstance(gateway_value_raw, str): violations.append( Violation(path="gateway", reason="expected string") ) else: - gateway = gateway_raw - if _PATTERN_4A45C0D214B9083D.search(gateway_raw) is None: + gateway_value = gateway_value_raw + if _PATTERN_4A45C0D214B9083D.search(gateway_value_raw) is None: violations.append( Violation( path="gateway", - reason=f"must be a valid ipv4, got {_quote(gateway_raw)}", + reason=f"must be a valid ipv4, got {_quote(gateway_value_raw)}", ) ) - blob: bytes | None = None + blob_value: bytes | None = None if "blob" in raw: - blob_raw = raw["blob"] - if blob_raw is None: + blob_value_raw = raw["blob"] + if blob_value_raw is None: violations.append( Violation(path="blob", reason="explicit null not allowed") ) else: - if not isinstance(blob_raw, str): + if not isinstance(blob_value_raw, str): violations.append(Violation(path="blob", reason="expected string")) else: - blob_parsed = _parse_base64(blob_raw, "blob", violations) - if blob_parsed is not None: - blob = blob_parsed + blob_value_parsed = _parse_base64( + blob_value_raw, "blob", violations + ) + if blob_value_parsed is not None: + blob_value = blob_value_parsed - url_blob: bytes | None = None + url_blob_value: bytes | None = None if "urlBlob" in raw: - url_blob_raw = raw["urlBlob"] - if url_blob_raw is None: + url_blob_value_raw = raw["urlBlob"] + if url_blob_value_raw is None: violations.append( Violation(path="urlBlob", reason="explicit null not allowed") ) else: - if not isinstance(url_blob_raw, str): + if not isinstance(url_blob_value_raw, str): violations.append( Violation(path="urlBlob", reason="expected string") ) else: - url_blob_parsed = _parse_base64url( - url_blob_raw, "urlBlob", violations + url_blob_value_parsed = _parse_base64url( + url_blob_value_raw, "urlBlob", violations ) - if url_blob_parsed is not None: - url_blob = url_blob_parsed + if url_blob_value_parsed is not None: + url_blob_value = url_blob_value_parsed - retries: int | None = None + retries_value: int | None = None if "retries" in raw: - retries_raw = raw["retries"] - if retries_raw is None: + retries_value_raw = raw["retries"] + if retries_value_raw is None: violations.append( Violation(path="retries", reason="explicit null not allowed") ) else: - retries_parsed = _parse_spec_integer(retries_raw, "retries", violations) - if retries_parsed is not None: - retries = retries_parsed + retries_value_parsed = _parse_spec_integer( + retries_value_raw, "retries", violations + ) + if retries_value_parsed is not None: + retries_value = retries_value_parsed - verbose: bool | None = None + verbose_value: bool | None = None if "verbose" in raw: - verbose_raw = raw["verbose"] - if verbose_raw is None: + verbose_value_raw = raw["verbose"] + if verbose_value_raw is None: violations.append( Violation(path="verbose", reason="explicit null not allowed") ) else: - if not isinstance(verbose_raw, bool): + if not isinstance(verbose_value_raw, bool): violations.append( Violation(path="verbose", reason="expected boolean") ) else: - verbose = verbose_raw + verbose_value = verbose_value_raw - greeting: str | None = None + greeting_value: str | None = None if "greeting" in raw: - greeting_raw = raw["greeting"] - if greeting_raw is None: + greeting_value_raw = raw["greeting"] + if greeting_value_raw is None: violations.append( Violation(path="greeting", reason="explicit null not allowed") ) else: - if not isinstance(greeting_raw, str): + if not isinstance(greeting_value_raw, str): violations.append( Violation(path="greeting", reason="expected string") ) else: - greeting = greeting_raw + greeting_value = greeting_value_raw - debug: bool | None = None + debug_value: bool | None = None if "debug" in raw: - debug_raw = raw["debug"] - if debug_raw is None: + debug_value_raw = raw["debug"] + if debug_value_raw is None: violations.append( Violation(path="debug", reason="explicit null not allowed") ) else: - if not isinstance(debug_raw, bool): + if not isinstance(debug_value_raw, bool): violations.append( Violation(path="debug", reason="expected boolean") ) else: - debug = debug_raw + debug_value = debug_value_raw - legacy_id_py: str | None = None + legacy_id_py_value: str | None = None if "legacyId" in raw: - legacy_id_py_raw = raw["legacyId"] - if legacy_id_py_raw is None: + legacy_id_py_value_raw = raw["legacyId"] + if legacy_id_py_value_raw is None: violations.append( Violation(path="legacyId", reason="explicit null not allowed") ) else: - if not isinstance(legacy_id_py_raw, str): + if not isinstance(legacy_id_py_value_raw, str): violations.append( Violation(path="legacyId", reason="expected string") ) else: - legacy_id_py = legacy_id_py_raw + legacy_id_py_value = legacy_id_py_value_raw - middle_name: str | None = None + middle_name_value: str | None = None if "middleName" in raw: - middle_name_raw = raw["middleName"] - if middle_name_raw is None: - middle_name = None + middle_name_value_raw = raw["middleName"] + if middle_name_value_raw is None: + middle_name_value = None else: - if not isinstance(middle_name_raw, str): + if not isinstance(middle_name_value_raw, str): violations.append( Violation(path="middleName", reason="expected string") ) else: - middle_name = middle_name_raw + middle_name_value = middle_name_value_raw - category: str | None = None + category_value: str | None = None if "category" not in raw: violations.append(Violation(path="category", reason="required")) else: - category_raw = raw["category"] - if category_raw is None: - category = None + category_value_raw = raw["category"] + if category_value_raw is None: + category_value = None else: - if not isinstance(category_raw, str): + if not isinstance(category_value_raw, str): violations.append( Violation(path="category", reason="expected string") ) else: - category = category_raw + category_value = category_value_raw - priority: int | None = None + priority_value: int | None = None if "priority" in raw: - priority_raw = raw["priority"] - if priority_raw is None: + priority_value_raw = raw["priority"] + if priority_value_raw is None: violations.append( Violation(path="priority", reason="explicit null not allowed") ) else: - priority_parsed = _parse_spec_integer( - priority_raw, "priority", violations + priority_value_parsed = _parse_spec_integer( + priority_value_raw, "priority", violations ) - if priority_parsed is not None: - priority = priority_parsed - if priority < 1: + if priority_value_parsed is not None: + priority_value = priority_value_parsed + if priority_value < 1: violations.append( Violation( - path="priority", reason=f"must be >= 1, got {priority}" + path="priority", + reason=f"must be >= 1, got {priority_value}", ) ) - if priority > 10: + if priority_value > 10: violations.append( Violation( - path="priority", reason=f"must be <= 10, got {priority}" + path="priority", + reason=f"must be <= 10, got {priority_value}", ) ) - level: int | None = None + level_value: int | None = None if "level" in raw: - level_raw = raw["level"] - if level_raw is None: + level_value_raw = raw["level"] + if level_value_raw is None: violations.append( Violation(path="level", reason="explicit null not allowed") ) else: - level_parsed = _parse_spec_integer(level_raw, "level", violations) - if level_parsed is not None: - level = level_parsed - if level <= 0: + level_value_parsed = _parse_spec_integer( + level_value_raw, "level", violations + ) + if level_value_parsed is not None: + level_value = level_value_parsed + if level_value <= 0: violations.append( - Violation(path="level", reason=f"must be > 0, got {level}") + Violation( + path="level", reason=f"must be > 0, got {level_value}" + ) ) - ratio: float | None = None + ratio_value: float | None = None if "ratio" in raw: - ratio_raw = raw["ratio"] - if ratio_raw is None: + ratio_value_raw = raw["ratio"] + if ratio_value_raw is None: violations.append( Violation(path="ratio", reason="explicit null not allowed") ) else: if not ( - not isinstance(ratio_raw, bool) - and isinstance(ratio_raw, (int, float)) + not isinstance(ratio_value_raw, bool) + and isinstance(ratio_value_raw, (int, float)) ): violations.append(Violation(path="ratio", reason="expected number")) else: - ratio = ratio_raw - if ratio_raw < 5: - violations.append( - Violation( - path="ratio", reason=f"must be >= 5, got {ratio_raw}" - ) - ) - if math.fmod(ratio_raw, 5) != 0: + ratio_value = ratio_value_raw + if not ( + -1.7976931348623157e308 + <= ratio_value_raw + <= 1.7976931348623157e308 + ): violations.append( Violation( path="ratio", - reason=f"must be a multiple of 5, got {ratio_raw}", + reason=f"must be a finite number, got {ratio_value_raw}", ) ) + else: + if ratio_value_raw < 5: + violations.append( + Violation( + path="ratio", + reason=f"must be >= 5, got {ratio_value_raw}", + ) + ) + if math.fmod(ratio_value_raw, 5) != 0: + violations.append( + Violation( + path="ratio", + reason=f"must be a multiple of 5, got {ratio_value_raw}", + ) + ) - step: int | None = None + step_value: int | None = None if "step" in raw: - step_raw = raw["step"] - if step_raw is None: + step_value_raw = raw["step"] + if step_value_raw is None: violations.append( Violation(path="step", reason="explicit null not allowed") ) else: - step_parsed = _parse_spec_integer(step_raw, "step", violations) - if step_parsed is not None: - step = step_parsed - if step % 3 != 0: + step_value_parsed = _parse_spec_integer( + step_value_raw, "step", violations + ) + if step_value_parsed is not None: + step_value = step_value_parsed + if step_value % 3 != 0: violations.append( Violation( path="step", - reason=f"must be a multiple of 3, got {step}", + reason=f"must be a multiple of 3, got {step_value}", ) ) - tags: list[str] | None = None + tags_value: list[str] | None = None if "tags" in raw: - tags_raw = raw["tags"] - if tags_raw is None: + tags_value_raw = raw["tags"] + if tags_value_raw is None: violations.append( Violation(path="tags", reason="explicit null not allowed") ) else: - if not isinstance(tags_raw, list): + if not isinstance(tags_value_raw, list): violations.append(Violation(path="tags", reason="expected array")) else: - tags_list: list[str] = [] - for tags_index, tags_element in enumerate( - typing.cast("list[typing.Any]", tags_raw) + tags_value_list: list[str] = [] + for tags_value_index, tags_value_element in enumerate( + typing.cast("list[typing.Any]", tags_value_raw) ): - tags_item_path = f"tags[{tags_index}]" - tags_item: str = typing.cast("typing.Any", None) - if not isinstance(tags_element, str): + tags_value_item_path = f"tags[{tags_value_index}]" + tags_value_item: str = typing.cast("typing.Any", None) + if not isinstance(tags_value_element, str): violations.append( Violation( - path=tags_item_path, reason="expected element" + path=tags_value_item_path, reason="expected element" ) ) else: - tags_item = tags_element - tags_list.append(tags_item) - if len(tags_list) < 1: + tags_value_item = tags_value_element + tags_value_list.append(tags_value_item) + if len(tags_value_list) < 1: violations.append( Violation( path="tags", - reason=f"must have at least 1 items, got {len(tags_list)}", + reason=f"must have at least 1 items, got {len(tags_value_list)}", ) ) - if len(tags_list) > 5: + if len(tags_value_list) > 5: violations.append( Violation( path="tags", - reason=f"must have at most 5 items, got {len(tags_list)}", + reason=f"must have at most 5 items, got {len(tags_value_list)}", ) ) - tags = tags_list + tags_value = tags_value_list - aliases: list[str] | None = None + aliases_value: list[str] | None = None if "aliases" in raw: - aliases_raw = raw["aliases"] - if aliases_raw is None: + aliases_value_raw = raw["aliases"] + if aliases_value_raw is None: violations.append( Violation(path="aliases", reason="explicit null not allowed") ) else: - if not isinstance(aliases_raw, list): + if not isinstance(aliases_value_raw, list): violations.append( Violation(path="aliases", reason="expected array") ) else: - aliases_list: list[str] = [] - for aliases_index, aliases_element in enumerate( - typing.cast("list[typing.Any]", aliases_raw) + aliases_value_list: list[str] = [] + for aliases_value_index, aliases_value_element in enumerate( + typing.cast("list[typing.Any]", aliases_value_raw) ): - aliases_item_path = f"aliases[{aliases_index}]" - aliases_item: str = typing.cast("typing.Any", None) - if not isinstance(aliases_element, str): + aliases_value_item_path = f"aliases[{aliases_value_index}]" + aliases_value_item: str = typing.cast("typing.Any", None) + if not isinstance(aliases_value_element, str): violations.append( Violation( - path=aliases_item_path, reason="expected element" + path=aliases_value_item_path, + reason="expected element", ) ) else: - aliases_item = aliases_element - aliases_list.append(aliases_item) - _check_unique_items(aliases_list, "aliases", violations) - aliases = aliases_list + aliases_value_item = aliases_value_element + aliases_value_list.append(aliases_value_item) + _check_unique_items(aliases_value_list, "aliases", violations) + aliases_value = aliases_value_list - roles: list[str] | None = None + roles_value: list[str] | None = None if "roles" in raw: - roles_raw = raw["roles"] - if roles_raw is None: + roles_value_raw = raw["roles"] + if roles_value_raw is None: violations.append( Violation(path="roles", reason="explicit null not allowed") ) else: - if not isinstance(roles_raw, list): + if not isinstance(roles_value_raw, list): violations.append(Violation(path="roles", reason="expected array")) else: - roles_list: list[str] = [] - for roles_index, roles_element in enumerate( - typing.cast("list[typing.Any]", roles_raw) + roles_value_list: list[str] = [] + for roles_value_index, roles_value_element in enumerate( + typing.cast("list[typing.Any]", roles_value_raw) ): - roles_item_path = f"roles[{roles_index}]" - roles_item: str = typing.cast("typing.Any", None) - if not isinstance(roles_element, str): + roles_value_item_path = f"roles[{roles_value_index}]" + roles_value_item: str = typing.cast("typing.Any", None) + if not isinstance(roles_value_element, str): violations.append( Violation( - path=roles_item_path, reason="expected element" + path=roles_value_item_path, + reason="expected element", ) ) else: - roles_item = roles_element - roles_list.append(roles_item) + roles_value_item = roles_value_element + roles_value_list.append(roles_value_item) _check_contains( - roles_list, + roles_value_list, lambda element: element == "admin", 1, 2, @@ -1646,479 +1701,507 @@ def from_transfer_type( "roles", violations, ) - roles = roles_list + roles_value = roles_value_list - id_or_name: str | int | None = None + id_or_name_value: str | int | None = None if "idOrName" in raw: - id_or_name_raw = raw["idOrName"] - if id_or_name_raw is None: + id_or_name_value_raw = raw["idOrName"] + if id_or_name_value_raw is None: violations.append( Violation(path="idOrName", reason="explicit null not allowed") ) else: - id_or_name_parsed = _showcase_id_or_name_from_transfer_type( - id_or_name_raw, "idOrName", violations + id_or_name_value_parsed = _showcase_id_or_name_from_transfer_type( + id_or_name_value_raw, "idOrName", violations ) - if id_or_name_parsed is not None: - id_or_name = id_or_name_parsed + if id_or_name_value_parsed is not None: + id_or_name_value = id_or_name_value_parsed - mode: typing.Literal["auto", "manual"] | int | None = None + mode_value: typing.Literal["auto", "manual"] | int | None = None if "mode" in raw: - mode_raw = raw["mode"] - if mode_raw is None: + mode_value_raw = raw["mode"] + if mode_value_raw is None: violations.append( Violation(path="mode", reason="explicit null not allowed") ) else: - mode_parsed = _showcase_mode_from_transfer_type( - mode_raw, "mode", violations + mode_value_parsed = _showcase_mode_from_transfer_type( + mode_value_raw, "mode", violations ) - if mode_parsed is not None: - mode = mode_parsed + if mode_value_parsed is not None: + mode_value = mode_value_parsed - payload: dict[str, typing.Any] | str | None = None + payload_value: dict[str, typing.Any] | str | None = None if "payload" in raw: - payload_raw = raw["payload"] - if payload_raw is None: + payload_value_raw = raw["payload"] + if payload_value_raw is None: violations.append( Violation(path="payload", reason="explicit null not allowed") ) else: - payload_parsed = _showcase_payload_from_transfer_type( - payload_raw, "payload", violations + payload_value_parsed = _showcase_payload_from_transfer_type( + payload_value_raw, "payload", violations ) - if payload_parsed is not None: - payload = payload_parsed + if payload_value_parsed is not None: + payload_value = payload_value_parsed - detail: ShowcaseDetailObject | str | None = None + detail_value: ShowcaseDetailObject | str | None = None if "detail" in raw: - detail_raw = raw["detail"] - if detail_raw is None: + detail_value_raw = raw["detail"] + if detail_value_raw is None: violations.append( Violation(path="detail", reason="explicit null not allowed") ) else: - detail_parsed = _showcase_detail_from_transfer_type( - detail_raw, "detail", violations + detail_value_parsed = _showcase_detail_from_transfer_type( + detail_value_raw, "detail", violations ) - if detail_parsed is not None: - detail = detail_parsed + if detail_value_parsed is not None: + detail_value = detail_value_parsed - shape_or_name: Circle | Square | str | None = None + shape_or_name_value: Circle | Square | str | None = None if "shapeOrName" in raw: - shape_or_name_raw = raw["shapeOrName"] - if shape_or_name_raw is None: + shape_or_name_value_raw = raw["shapeOrName"] + if shape_or_name_value_raw is None: violations.append( Violation(path="shapeOrName", reason="explicit null not allowed") ) else: - shape_or_name_parsed = _showcase_shape_or_name_from_transfer_type( - shape_or_name_raw, "shapeOrName", violations + shape_or_name_value_parsed = _showcase_shape_or_name_from_transfer_type( + shape_or_name_value_raw, "shapeOrName", violations ) - if shape_or_name_parsed is not None: - shape_or_name = shape_or_name_parsed + if shape_or_name_value_parsed is not None: + shape_or_name_value = shape_or_name_value_parsed - measurements: list[float] | str | None = None + measurements_value: list[float] | str | None = None if "measurements" in raw: - measurements_raw = raw["measurements"] - if measurements_raw is None: + measurements_value_raw = raw["measurements"] + if measurements_value_raw is None: violations.append( Violation(path="measurements", reason="explicit null not allowed") ) else: - measurements_parsed = _showcase_measurements_from_transfer_type( - measurements_raw, "measurements", violations + measurements_value_parsed = _showcase_measurements_from_transfer_type( + measurements_value_raw, "measurements", violations ) - if measurements_parsed is not None: - measurements = measurements_parsed + if measurements_value_parsed is not None: + measurements_value = measurements_value_parsed - shapes: list[Shape] | None = None + shapes_value: list[Shape] | None = None if "shapes" in raw: - shapes_raw = raw["shapes"] - if shapes_raw is None: + shapes_value_raw = raw["shapes"] + if shapes_value_raw is None: violations.append( Violation(path="shapes", reason="explicit null not allowed") ) else: - if not isinstance(shapes_raw, list): + if not isinstance(shapes_value_raw, list): violations.append(Violation(path="shapes", reason="expected array")) else: - shapes_list: list[Shape] = [] - for shapes_index, shapes_element in enumerate( - typing.cast("list[typing.Any]", shapes_raw) + shapes_value_list: list[Shape] = [] + for shapes_value_index, shapes_value_element in enumerate( + typing.cast("list[typing.Any]", shapes_value_raw) ): - shapes_item_path = f"shapes[{shapes_index}]" - shapes_item: Shape = typing.cast("typing.Any", None) - shapes_item_parsed = _shape_from_transfer_type( - shapes_element, shapes_item_path, violations + shapes_value_item_path = f"shapes[{shapes_value_index}]" + shapes_value_item: Shape = typing.cast("typing.Any", None) + shapes_value_item_parsed = _shape_from_transfer_type( + shapes_value_element, shapes_value_item_path, violations ) - if shapes_item_parsed is not None: - shapes_item = shapes_item_parsed - shapes_list.append(shapes_item) - shapes = shapes_list + if shapes_value_item_parsed is not None: + shapes_value_item = shapes_value_item_parsed + shapes_value_list.append(shapes_value_item) + shapes_value = shapes_value_list - segments: list[ShowcaseSegmentsItem] | None = None + segments_value: list[ShowcaseSegmentsItem] | None = None if "segments" in raw: - segments_raw = raw["segments"] - if segments_raw is None: + segments_value_raw = raw["segments"] + if segments_value_raw is None: violations.append( Violation(path="segments", reason="explicit null not allowed") ) else: - if not isinstance(segments_raw, list): + if not isinstance(segments_value_raw, list): violations.append( Violation(path="segments", reason="expected array") ) else: - segments_list: list[ShowcaseSegmentsItem] = [] - for segments_index, segments_element in enumerate( - typing.cast("list[typing.Any]", segments_raw) + segments_value_list: list[ShowcaseSegmentsItem] = [] + for segments_value_index, segments_value_element in enumerate( + typing.cast("list[typing.Any]", segments_value_raw) ): - segments_item_path = f"segments[{segments_index}]" - segments_item: ShowcaseSegmentsItem = typing.cast( + segments_value_item_path = f"segments[{segments_value_index}]" + segments_value_item: ShowcaseSegmentsItem = typing.cast( "typing.Any", None ) - segments_item_parsed = ( + segments_value_item_parsed = ( _showcase_segments_item_from_transfer_type( - segments_element, segments_item_path, violations + segments_value_element, + segments_value_item_path, + violations, ) ) - if segments_item_parsed is not None: - segments_item = segments_item_parsed - segments_list.append(segments_item) - segments = segments_list + if segments_value_item_parsed is not None: + segments_value_item = segments_value_item_parsed + segments_value_list.append(segments_value_item) + segments_value = segments_value_list - slots: list[str | None] | None = None + slots_value: list[str | None] | None = None if "slots" in raw: - slots_raw = raw["slots"] - if slots_raw is None: + slots_value_raw = raw["slots"] + if slots_value_raw is None: violations.append( Violation(path="slots", reason="explicit null not allowed") ) else: - if not isinstance(slots_raw, list): + if not isinstance(slots_value_raw, list): violations.append(Violation(path="slots", reason="expected array")) else: - slots_list: list[str | None] = [] - for slots_index, slots_element in enumerate( - typing.cast("list[typing.Any]", slots_raw) + slots_value_list: list[str | None] = [] + for slots_value_index, slots_value_element in enumerate( + typing.cast("list[typing.Any]", slots_value_raw) ): - slots_item_path = f"slots[{slots_index}]" - slots_item: str | None = None - if slots_element is None: - slots_item = None + slots_value_item_path = f"slots[{slots_value_index}]" + slots_value_item: str | None = None + if slots_value_element is None: + slots_value_item = None else: - if not isinstance(slots_element, str): + if not isinstance(slots_value_element, str): violations.append( Violation( - path=slots_item_path, reason="expected string" + path=slots_value_item_path, + reason="expected string", ) ) else: - slots_item = slots_element - slots_list.append(slots_item) - slots = slots_list + slots_value_item = slots_value_element + slots_value_list.append(slots_value_item) + slots_value = slots_value_list - grid: list[list[int]] | None = None + grid_value: list[list[int]] | None = None if "grid" in raw: - grid_raw = raw["grid"] - if grid_raw is None: + grid_value_raw = raw["grid"] + if grid_value_raw is None: violations.append( Violation(path="grid", reason="explicit null not allowed") ) else: - if not isinstance(grid_raw, list): + if not isinstance(grid_value_raw, list): violations.append(Violation(path="grid", reason="expected array")) else: - grid_list: list[list[int]] = [] - for grid_index, grid_element in enumerate( - typing.cast("list[typing.Any]", grid_raw) + grid_value_list: list[list[int]] = [] + for grid_value_index, grid_value_element in enumerate( + typing.cast("list[typing.Any]", grid_value_raw) ): - grid_item_path = f"grid[{grid_index}]" - grid_item: list[int] = typing.cast("typing.Any", None) - if not isinstance(grid_element, list): + grid_value_item_path = f"grid[{grid_value_index}]" + grid_value_item: list[int] = typing.cast("typing.Any", None) + if not isinstance(grid_value_element, list): violations.append( - Violation(path=grid_item_path, reason="expected array") + Violation( + path=grid_value_item_path, reason="expected array" + ) ) else: - grid_item_list: list[int] = [] - for grid_item_index, grid_item_element in enumerate( - typing.cast("list[typing.Any]", grid_element) + grid_value_item_list: list[int] = [] + for ( + grid_value_item_index, + grid_value_item_element, + ) in enumerate( + typing.cast("list[typing.Any]", grid_value_element) ): - grid_item_item_path = ( - f"{grid_item_path}[{grid_item_index}]" + grid_value_item_item_path = ( + f"{grid_value_item_path}[{grid_value_item_index}]" ) - grid_item_item: int = typing.cast("typing.Any", None) - grid_item_item_parsed = _parse_spec_integer( - grid_item_element, grid_item_item_path, violations + grid_value_item_item: int = typing.cast( + "typing.Any", None ) - if grid_item_item_parsed is not None: - grid_item_item = grid_item_item_parsed - grid_item_list.append(grid_item_item) - grid_item = grid_item_list - grid_list.append(grid_item) - grid = grid_list - - location: ShowcaseLocation | None = None + grid_value_item_item_parsed = _parse_spec_integer( + grid_value_item_element, + grid_value_item_item_path, + violations, + ) + if grid_value_item_item_parsed is not None: + grid_value_item_item = grid_value_item_item_parsed + grid_value_item_list.append(grid_value_item_item) + grid_value_item = grid_value_item_list + grid_value_list.append(grid_value_item) + grid_value = grid_value_list + + location_value: ShowcaseLocation | None = None if "location" in raw: - location_raw = raw["location"] - if location_raw is None: + location_value_raw = raw["location"] + if location_value_raw is None: violations.append( Violation(path="location", reason="explicit null not allowed") ) else: try: - location = ( + location_value = ( _ShowcaseLocationTransferTypeConverter().from_transfer_type( - location_raw, ShowcaseLocation + location_value_raw, ShowcaseLocation ) ) except ValidationError as error: _collect(violations, "location", error) - audit: ShowcaseAudit | None = None + audit_value: ShowcaseAudit | None = None if "audit" in raw: - audit_raw = raw["audit"] - if audit_raw is None: - audit = None + audit_value_raw = raw["audit"] + if audit_value_raw is None: + audit_value = None else: try: - audit = _ShowcaseAuditTransferTypeConverter().from_transfer_type( - audit_raw, ShowcaseAudit + audit_value = ( + _ShowcaseAuditTransferTypeConverter().from_transfer_type( + audit_value_raw, ShowcaseAudit + ) ) except ValidationError as error: _collect(violations, "audit", error) - rows: list[ShowcaseRowsItem] | None = None + rows_value: list[ShowcaseRowsItem] | None = None if "rows" in raw: - rows_raw = raw["rows"] - if rows_raw is None: + rows_value_raw = raw["rows"] + if rows_value_raw is None: violations.append( Violation(path="rows", reason="explicit null not allowed") ) else: - if not isinstance(rows_raw, list): + if not isinstance(rows_value_raw, list): violations.append(Violation(path="rows", reason="expected array")) else: - rows_list: list[ShowcaseRowsItem] = [] - for rows_index, rows_element in enumerate( - typing.cast("list[typing.Any]", rows_raw) + rows_value_list: list[ShowcaseRowsItem] = [] + for rows_value_index, rows_value_element in enumerate( + typing.cast("list[typing.Any]", rows_value_raw) ): - rows_item_path = f"rows[{rows_index}]" - rows_item: ShowcaseRowsItem = typing.cast("typing.Any", None) + rows_value_item_path = f"rows[{rows_value_index}]" + rows_value_item: ShowcaseRowsItem = typing.cast( + "typing.Any", None + ) try: - rows_item = _ShowcaseRowsItemTransferTypeConverter().from_transfer_type( - rows_element, ShowcaseRowsItem + rows_value_item = _ShowcaseRowsItemTransferTypeConverter().from_transfer_type( + rows_value_element, ShowcaseRowsItem ) except ValidationError as error: - _collect(violations, rows_item_path, error) - rows_list.append(rows_item) - rows = rows_list + _collect(violations, rows_value_item_path, error) + rows_value_list.append(rows_value_item) + rows_value = rows_value_list - ledger_py: ShowcaseLedger | None = None + ledger_py_value: ShowcaseLedger | None = None if "ledger" in raw: - ledger_py_raw = raw["ledger"] - if ledger_py_raw is None: + ledger_py_value_raw = raw["ledger"] + if ledger_py_value_raw is None: violations.append( Violation(path="ledger", reason="explicit null not allowed") ) else: try: - ledger_py = ( + ledger_py_value = ( _ShowcaseLedgerTransferTypeConverter().from_transfer_type( - ledger_py_raw, ShowcaseLedger + ledger_py_value_raw, ShowcaseLedger ) ) except ValidationError as error: _collect(violations, "ledger", error) - metadata: ShowcaseMetadata | None = None + metadata_value: ShowcaseMetadata | None = None if "metadata" in raw: - metadata_raw = raw["metadata"] - if metadata_raw is None: + metadata_value_raw = raw["metadata"] + if metadata_value_raw is None: violations.append( Violation(path="metadata", reason="explicit null not allowed") ) else: try: - metadata = ( + metadata_value = ( _ShowcaseMetadataTransferTypeConverter().from_transfer_type( - metadata_raw, ShowcaseMetadata + metadata_value_raw, ShowcaseMetadata ) ) except ValidationError as error: _collect(violations, "metadata", error) - quotas: Quotas | None = None + quotas_value: Quotas | None = None if "quotas" in raw: - quotas_raw = raw["quotas"] - if quotas_raw is None: + quotas_value_raw = raw["quotas"] + if quotas_value_raw is None: violations.append( Violation(path="quotas", reason="explicit null not allowed") ) else: try: - quotas = _QuotasTransferTypeConverter().from_transfer_type( - quotas_raw, Quotas + quotas_value = _QuotasTransferTypeConverter().from_transfer_type( + quotas_value_raw, Quotas ) except ValidationError as error: _collect(violations, "quotas", error) - tokens: Tokens | None = None + tokens_value: Tokens | None = None if "tokens" in raw: - tokens_raw = raw["tokens"] - if tokens_raw is None: + tokens_value_raw = raw["tokens"] + if tokens_value_raw is None: violations.append( Violation(path="tokens", reason="explicit null not allowed") ) else: try: - tokens = _TokensTransferTypeConverter().from_transfer_type( - tokens_raw, Tokens + tokens_value = _TokensTransferTypeConverter().from_transfer_type( + tokens_value_raw, Tokens ) except ValidationError as error: _collect(violations, "tokens", error) - nicknames: Nicknames | None = None + nicknames_value: Nicknames | None = None if "nicknames" in raw: - nicknames_raw = raw["nicknames"] - if nicknames_raw is None: + nicknames_value_raw = raw["nicknames"] + if nicknames_value_raw is None: violations.append( Violation(path="nicknames", reason="explicit null not allowed") ) else: try: - nicknames = _NicknamesTransferTypeConverter().from_transfer_type( - nicknames_raw, Nicknames + nicknames_value = ( + _NicknamesTransferTypeConverter().from_transfer_type( + nicknames_value_raw, Nicknames + ) ) except ValidationError as error: _collect(violations, "nicknames", error) - choices: Choices | None = None + choices_value: Choices | None = None if "choices" in raw: - choices_raw = raw["choices"] - if choices_raw is None: + choices_value_raw = raw["choices"] + if choices_value_raw is None: violations.append( Violation(path="choices", reason="explicit null not allowed") ) else: try: - choices = _ChoicesTransferTypeConverter().from_transfer_type( - choices_raw, Choices + choices_value = _ChoicesTransferTypeConverter().from_transfer_type( + choices_value_raw, Choices ) except ValidationError as error: _collect(violations, "choices", error) - extras: Extras | None = None + extras_value: Extras | None = None if "extras" in raw: - extras_raw = raw["extras"] - if extras_raw is None: + extras_value_raw = raw["extras"] + if extras_value_raw is None: violations.append( Violation(path="extras", reason="explicit null not allowed") ) else: try: - extras = _ExtrasTransferTypeConverter().from_transfer_type( - extras_raw, Extras + extras_value = _ExtrasTransferTypeConverter().from_transfer_type( + extras_value_raw, Extras ) except ValidationError as error: _collect(violations, "extras", error) - shape: Shape | None = None + shape_value: Shape | None = None if "shape" in raw: - shape_raw = raw["shape"] - if shape_raw is None: + shape_value_raw = raw["shape"] + if shape_value_raw is None: violations.append( Violation(path="shape", reason="explicit null not allowed") ) else: - shape_parsed = _shape_from_transfer_type(shape_raw, "shape", violations) - if shape_parsed is not None: - shape = shape_parsed + shape_value_parsed = _shape_from_transfer_type( + shape_value_raw, "shape", violations + ) + if shape_value_parsed is not None: + shape_value = shape_value_parsed - note: Note | None = None + note_value: Note | None = None if "note" in raw: - note_raw = raw["note"] - if note_raw is None: + note_value_raw = raw["note"] + if note_value_raw is None: violations.append( Violation(path="note", reason="explicit null not allowed") ) else: - note_parsed = _note_from_transfer_type(note_raw, "note", violations) - if note_parsed is not None: - note = note_parsed + note_value_parsed = _note_from_transfer_type( + note_value_raw, "note", violations + ) + if note_value_parsed is not None: + note_value = note_value_parsed - address: Address | None = None + address_value: Address | None = None if "address" in raw: - address_raw = raw["address"] - if address_raw is None: + address_value_raw = raw["address"] + if address_value_raw is None: violations.append( Violation(path="address", reason="explicit null not allowed") ) else: try: - address = _AddressTransferTypeConverter().from_transfer_type( - address_raw, Address + address_value = _AddressTransferTypeConverter().from_transfer_type( + address_value_raw, Address ) except ValidationError as error: _collect(violations, "address", error) - labels: Labels | None = None + labels_value: Labels | None = None if "labels" in raw: - labels_raw = raw["labels"] - if labels_raw is None: + labels_value_raw = raw["labels"] + if labels_value_raw is None: violations.append( Violation(path="labels", reason="explicit null not allowed") ) else: try: - labels = _LabelsTransferTypeConverter().from_transfer_type( - labels_raw, Labels + labels_value = _LabelsTransferTypeConverter().from_transfer_type( + labels_value_raw, Labels ) except ValidationError as error: _collect(violations, "labels", error) - settings: Settings | None = None + settings_value: Settings | None = None if "settings" in raw: - settings_raw = raw["settings"] - if settings_raw is None: + settings_value_raw = raw["settings"] + if settings_value_raw is None: violations.append( Violation(path="settings", reason="explicit null not allowed") ) else: try: - settings = _SettingsTransferTypeConverter().from_transfer_type( - settings_raw, Settings + settings_value = ( + _SettingsTransferTypeConverter().from_transfer_type( + settings_value_raw, Settings + ) ) except ValidationError as error: _collect(violations, "settings", error) - attributes: Attributes | None = None + attributes_value: Attributes | None = None if "attributes" in raw: - attributes_raw = raw["attributes"] - if attributes_raw is None: + attributes_value_raw = raw["attributes"] + if attributes_value_raw is None: violations.append( Violation(path="attributes", reason="explicit null not allowed") ) else: try: - attributes = _AttributesTransferTypeConverter().from_transfer_type( - attributes_raw, Attributes + attributes_value = ( + _AttributesTransferTypeConverter().from_transfer_type( + attributes_value_raw, Attributes + ) ) except ValidationError as error: _collect(violations, "attributes", error) - contact: ContactPy | None = None + contact_value: ContactPy | None = None if "contact" in raw: - contact_raw = raw["contact"] - if contact_raw is None: + contact_value_raw = raw["contact"] + if contact_value_raw is None: violations.append( Violation(path="contact", reason="explicit null not allowed") ) else: try: - contact = _ContactPyTransferTypeConverter().from_transfer_type( - contact_raw, ContactPy + contact_value = ( + _ContactPyTransferTypeConverter().from_transfer_type( + contact_value_raw, ContactPy + ) ) except ValidationError as error: _collect(violations, "contact", error) @@ -2191,67 +2274,67 @@ def from_transfer_type( if violations: raise ValidationError(violations) return Showcase( - kind=kind, - revision=revision, - enabled=enabled, - status=status, - tier=tier, - scale=scale, - name=name, - count=count, - active=active, - nickname=nickname, - code=code, - sku=sku, - phrase=phrase, - request_id=request_id, - contact_email=contact_email, - host=host, - homepage=homepage, - gateway=gateway, - blob=blob, - url_blob=url_blob, - retries=retries, - verbose=verbose, - greeting=greeting, - debug=debug, - legacy_id_py=legacy_id_py, - middle_name=middle_name, - category=category, - priority=priority, - level=level, - ratio=ratio, - step=step, - tags=tags, - aliases=aliases, - roles=roles, - id_or_name=id_or_name, - mode=mode, - payload=payload, - detail=detail, - shape_or_name=shape_or_name, - measurements=measurements, - shapes=shapes, - segments=segments, - slots=slots, - grid=grid, - location=location, - audit=audit, - rows=rows, - ledger_py=ledger_py, - metadata=metadata, - quotas=quotas, - tokens=tokens, - nicknames=nicknames, - choices=choices, - extras=extras, - shape=shape, - note=note, - address=address, - labels=labels, - settings=settings, - attributes=attributes, - contact=contact, + kind=kind_value, + revision=revision_value, + enabled=enabled_value, + status=status_value, + tier=tier_value, + scale=scale_value, + name=name_value, + count=count_value, + active=active_value, + nickname=nickname_value, + code=code_value, + sku=sku_value, + phrase=phrase_value, + request_id=request_id_value, + contact_email=contact_email_value, + host=host_value, + homepage=homepage_value, + gateway=gateway_value, + blob=blob_value, + url_blob=url_blob_value, + retries=retries_value, + verbose=verbose_value, + greeting=greeting_value, + debug=debug_value, + legacy_id_py=legacy_id_py_value, + middle_name=middle_name_value, + category=category_value, + priority=priority_value, + level=level_value, + ratio=ratio_value, + step=step_value, + tags=tags_value, + aliases=aliases_value, + roles=roles_value, + id_or_name=id_or_name_value, + mode=mode_value, + payload=payload_value, + detail=detail_value, + shape_or_name=shape_or_name_value, + measurements=measurements_value, + shapes=shapes_value, + segments=segments_value, + slots=slots_value, + grid=grid_value, + location=location_value, + audit=audit_value, + rows=rows_value, + ledger_py=ledger_py_value, + metadata=metadata_value, + quotas=quotas_value, + tokens=tokens_value, + nicknames=nicknames_value, + choices=choices_value, + extras=extras_value, + shape=shape_value, + note=note_value, + address=address_value, + labels=labels_value, + settings=settings_value, + attributes=attributes_value, + contact=contact_value, ) @typing_extensions.override @@ -2449,17 +2532,27 @@ def to_transfer_type(self, value: "Showcase") -> typing.Any: ) out["level"] = value.level if value.ratio is not None: - if value.ratio < 5: - violations.append( - Violation(path="ratio", reason=f"must be >= 5, got {value.ratio}") - ) - if math.fmod(value.ratio, 5) != 0: + if not (-1.7976931348623157e308 <= value.ratio <= 1.7976931348623157e308): violations.append( Violation( path="ratio", - reason=f"must be a multiple of 5, got {value.ratio}", + reason=f"must be a finite number, got {value.ratio}", ) ) + else: + if value.ratio < 5: + violations.append( + Violation( + path="ratio", reason=f"must be >= 5, got {value.ratio}" + ) + ) + if math.fmod(value.ratio, 5) != 0: + violations.append( + Violation( + path="ratio", + reason=f"must be a multiple of 5, got {value.ratio}", + ) + ) out["ratio"] = value.ratio if value.step is not None: if value.step % 3 != 0: @@ -2518,6 +2611,16 @@ def to_transfer_type(self, value: "Showcase") -> typing.Any: reason=f"must be >= 1, got {value.id_or_name}", ) ) + candidate = typing.cast("object", value.id_or_name) + if not ( + isinstance(candidate, str) + or (not isinstance(candidate, bool) and isinstance(candidate, int)) + ): + violations.append( + Violation( + path="idOrName", reason="expected one of: string, integer" + ) + ) out["idOrName"] = value.id_or_name if value.mode is not None: if isinstance(value.mode, str): @@ -2536,11 +2639,38 @@ def to_transfer_type(self, value: "Showcase") -> typing.Any: violations.append( Violation(path="mode", reason=f"must be >= 0, got {value.mode}") ) + candidate = typing.cast("object", value.mode) + if not ( + isinstance(candidate, str) + or (not isinstance(candidate, bool) and isinstance(candidate, int)) + ): + violations.append( + Violation(path="mode", reason="expected one of: string, integer") + ) out["mode"] = value.mode if value.payload is not None: + candidate = typing.cast("object", value.payload) + if not (isinstance(candidate, dict) or isinstance(candidate, str)): + violations.append( + Violation(path="payload", reason="expected one of: object, string") + ) out["payload"] = value.payload if value.detail is not None: - out["detail"] = _showcase_detail_to_transfer_type(value.detail) + candidate = typing.cast("object", value.detail) + if not ( + isinstance(candidate, ShowcaseDetailObject) + or isinstance(candidate, str) + ): + violations.append( + Violation( + path="detail", + reason="expected one of: ShowcaseDetailObject, string", + ) + ) + try: + out["detail"] = _showcase_detail_to_transfer_type(value.detail) + except ValidationError as error: + _collect(violations, "detail", error) if value.shape_or_name is not None: if isinstance(value.shape_or_name, str): if len(value.shape_or_name) > 32: @@ -2550,9 +2680,24 @@ def to_transfer_type(self, value: "Showcase") -> typing.Any: reason=f"must have length <= 32, got {len(value.shape_or_name)}", ) ) - out["shapeOrName"] = _showcase_shape_or_name_to_transfer_type( - value.shape_or_name - ) + candidate = typing.cast("object", value.shape_or_name) + if not ( + isinstance(candidate, Circle) + or isinstance(candidate, Square) + or isinstance(candidate, str) + ): + violations.append( + Violation( + path="shapeOrName", + reason="expected one of: Circle, Square, string", + ) + ) + try: + out["shapeOrName"] = _showcase_shape_or_name_to_transfer_type( + value.shape_or_name + ) + except ValidationError as error: + _collect(violations, "shapeOrName", error) if value.measurements is not None: if isinstance(value.measurements, list): if len(value.measurements) < 1: @@ -2571,85 +2716,161 @@ def to_transfer_type(self, value: "Showcase") -> typing.Any: reason=f"must match pattern {_PATTERN_F242E3A159C2422C.pattern}, got {_quote(value.measurements)}", ) ) + candidate = typing.cast("object", value.measurements) + if not (isinstance(candidate, list) or isinstance(candidate, str)): + violations.append( + Violation( + path="measurements", + reason="expected one of: list[float], string", + ) + ) out["measurements"] = value.measurements if value.shapes is not None: - out["shapes"] = [ - _shape_to_transfer_type(element) for element in value.shapes - ] + shapes_out: list[typing.Any] = [] + for shapes_index, shapes_element in enumerate(value.shapes): + try: + shapes_out.append(_shape_to_transfer_type(shapes_element)) + except ValidationError as error: + _collect(violations, f"shapes[{shapes_index}]", error) + out["shapes"] = shapes_out if value.segments is not None: - out["segments"] = [ - _showcase_segments_item_to_transfer_type(element) - for element in value.segments - ] + segments_out: list[typing.Any] = [] + for segments_index, segments_element in enumerate(value.segments): + try: + segments_out.append( + _showcase_segments_item_to_transfer_type(segments_element) + ) + except ValidationError as error: + _collect(violations, f"segments[{segments_index}]", error) + out["segments"] = segments_out if value.slots is not None: out["slots"] = value.slots if value.grid is not None: out["grid"] = value.grid if value.location is not None: - out["location"] = _ShowcaseLocationTransferTypeConverter().to_transfer_type( - value.location - ) + try: + out["location"] = ( + _ShowcaseLocationTransferTypeConverter().to_transfer_type( + value.location + ) + ) + except ValidationError as error: + _collect(violations, "location", error) if value.audit is not None: - out["audit"] = _ShowcaseAuditTransferTypeConverter().to_transfer_type( - value.audit - ) + try: + out["audit"] = _ShowcaseAuditTransferTypeConverter().to_transfer_type( + value.audit + ) + except ValidationError as error: + _collect(violations, "audit", error) if value.rows is not None: - out["rows"] = [ - _ShowcaseRowsItemTransferTypeConverter().to_transfer_type(element) - for element in value.rows - ] + rows_out: list[typing.Any] = [] + for rows_index, rows_element in enumerate(value.rows): + try: + rows_out.append( + _ShowcaseRowsItemTransferTypeConverter().to_transfer_type( + rows_element + ) + ) + except ValidationError as error: + _collect(violations, f"rows[{rows_index}]", error) + out["rows"] = rows_out if value.ledger_py is not None: - out["ledger"] = _ShowcaseLedgerTransferTypeConverter().to_transfer_type( - value.ledger_py - ) + try: + out["ledger"] = _ShowcaseLedgerTransferTypeConverter().to_transfer_type( + value.ledger_py + ) + except ValidationError as error: + _collect(violations, "ledger", error) if value.metadata is not None: - out["metadata"] = _ShowcaseMetadataTransferTypeConverter().to_transfer_type( - value.metadata - ) + try: + out["metadata"] = ( + _ShowcaseMetadataTransferTypeConverter().to_transfer_type( + value.metadata + ) + ) + except ValidationError as error: + _collect(violations, "metadata", error) if value.quotas is not None: - out["quotas"] = _QuotasTransferTypeConverter().to_transfer_type( - value.quotas - ) + try: + out["quotas"] = _QuotasTransferTypeConverter().to_transfer_type( + value.quotas + ) + except ValidationError as error: + _collect(violations, "quotas", error) if value.tokens is not None: - out["tokens"] = _TokensTransferTypeConverter().to_transfer_type( - value.tokens - ) + try: + out["tokens"] = _TokensTransferTypeConverter().to_transfer_type( + value.tokens + ) + except ValidationError as error: + _collect(violations, "tokens", error) if value.nicknames is not None: - out["nicknames"] = _NicknamesTransferTypeConverter().to_transfer_type( - value.nicknames - ) + try: + out["nicknames"] = _NicknamesTransferTypeConverter().to_transfer_type( + value.nicknames + ) + except ValidationError as error: + _collect(violations, "nicknames", error) if value.choices is not None: - out["choices"] = _ChoicesTransferTypeConverter().to_transfer_type( - value.choices - ) + try: + out["choices"] = _ChoicesTransferTypeConverter().to_transfer_type( + value.choices + ) + except ValidationError as error: + _collect(violations, "choices", error) if value.extras is not None: - out["extras"] = _ExtrasTransferTypeConverter().to_transfer_type( - value.extras - ) + try: + out["extras"] = _ExtrasTransferTypeConverter().to_transfer_type( + value.extras + ) + except ValidationError as error: + _collect(violations, "extras", error) if value.shape is not None: - out["shape"] = _shape_to_transfer_type(value.shape) + try: + out["shape"] = _shape_to_transfer_type(value.shape) + except ValidationError as error: + _collect(violations, "shape", error) if value.note is not None: - out["note"] = _note_to_transfer_type(value.note) + try: + out["note"] = _note_to_transfer_type(value.note) + except ValidationError as error: + _collect(violations, "note", error) if value.address is not None: - out["address"] = _AddressTransferTypeConverter().to_transfer_type( - value.address - ) + try: + out["address"] = _AddressTransferTypeConverter().to_transfer_type( + value.address + ) + except ValidationError as error: + _collect(violations, "address", error) if value.labels is not None: - out["labels"] = _LabelsTransferTypeConverter().to_transfer_type( - value.labels - ) + try: + out["labels"] = _LabelsTransferTypeConverter().to_transfer_type( + value.labels + ) + except ValidationError as error: + _collect(violations, "labels", error) if value.settings is not None: - out["settings"] = _SettingsTransferTypeConverter().to_transfer_type( - value.settings - ) + try: + out["settings"] = _SettingsTransferTypeConverter().to_transfer_type( + value.settings + ) + except ValidationError as error: + _collect(violations, "settings", error) if value.attributes is not None: - out["attributes"] = _AttributesTransferTypeConverter().to_transfer_type( - value.attributes - ) + try: + out["attributes"] = _AttributesTransferTypeConverter().to_transfer_type( + value.attributes + ) + except ValidationError as error: + _collect(violations, "attributes", error) if value.contact is not None: - out["contact"] = _ContactPyTransferTypeConverter().to_transfer_type( - value.contact - ) + try: + out["contact"] = _ContactPyTransferTypeConverter().to_transfer_type( + value.contact + ) + except ValidationError as error: + _collect(violations, "contact", error) if violations: raise ValidationError(violations) return out @@ -2956,20 +3177,20 @@ def from_transfer_type( raise ValidationError([Violation(path="", reason="expected object")]) raw = typing.cast("dict[str, typing.Any]", value) - by: str = typing.cast("typing.Any", None) + by_value: str = typing.cast("typing.Any", None) if "by" not in raw or raw["by"] is None: violations.append(Violation(path="by", reason="required")) else: - by_raw = raw["by"] - if not isinstance(by_raw, str): + by_value_raw = raw["by"] + if not isinstance(by_value_raw, str): violations.append(Violation(path="by", reason="expected string")) else: - by = by_raw - if len(by_raw) < 1: + by_value = by_value_raw + if len(by_value_raw) < 1: violations.append( Violation( path="by", - reason=f"must have length >= 1, got {len(by_raw)}", + reason=f"must have length >= 1, got {len(by_value_raw)}", ) ) @@ -2980,7 +3201,7 @@ def from_transfer_type( if violations: raise ValidationError(violations) return ShowcaseAudit( - by=by, + by=by_value, additional_properties=additional_properties, ) @@ -3024,35 +3245,35 @@ def from_transfer_type( raise ValidationError([Violation(path="", reason="expected object")]) raw = typing.cast("dict[str, typing.Any]", value) - code: str = typing.cast("typing.Any", None) + code_value: str = typing.cast("typing.Any", None) if "code" not in raw or raw["code"] is None: violations.append(Violation(path="code", reason="required")) else: - code_raw = raw["code"] - if not isinstance(code_raw, str): + code_value_raw = raw["code"] + if not isinstance(code_value_raw, str): violations.append(Violation(path="code", reason="expected string")) else: - code = code_raw - if len(code_raw) < 1: + code_value = code_value_raw + if len(code_value_raw) < 1: violations.append( Violation( path="code", - reason=f"must have length >= 1, got {len(code_raw)}", + reason=f"must have length >= 1, got {len(code_value_raw)}", ) ) - hint: str | None = None + hint_value: str | None = None if "hint" in raw: - hint_raw = raw["hint"] - if hint_raw is None: + hint_value_raw = raw["hint"] + if hint_value_raw is None: violations.append( Violation(path="hint", reason="explicit null not allowed") ) else: - if not isinstance(hint_raw, str): + if not isinstance(hint_value_raw, str): violations.append(Violation(path="hint", reason="expected string")) else: - hint = hint_raw + hint_value = hint_value_raw additional_properties: dict[str, typing.Any] = {} for key in raw: @@ -3061,8 +3282,8 @@ def from_transfer_type( if violations: raise ValidationError(violations) return ShowcaseDetailObject( - code=code, - hint=hint, + code=code_value, + hint=hint_value, additional_properties=additional_properties, ) @@ -3126,11 +3347,17 @@ def from_transfer_type( @typing_extensions.override def to_transfer_type(self, value: "ShowcaseLedger") -> typing.Any: + violations: list[Violation] = [] out: dict[str, typing.Any] = {} for key, entry in value.additional_properties.items(): - out[key] = _ShowcaseLedgerValueTransferTypeConverter().to_transfer_type( - entry - ) + try: + out[key] = _ShowcaseLedgerValueTransferTypeConverter().to_transfer_type( + entry + ) + except ValidationError as error: + _collect(violations, key, error) + if violations: + raise ValidationError(violations) return out @@ -3162,17 +3389,21 @@ def from_transfer_type( raise ValidationError([Violation(path="", reason="expected object")]) raw = typing.cast("dict[str, typing.Any]", value) - amount: int = typing.cast("typing.Any", None) + amount_value: int = typing.cast("typing.Any", None) if "amount" not in raw or raw["amount"] is None: violations.append(Violation(path="amount", reason="required")) else: - amount_raw = raw["amount"] - amount_parsed = _parse_spec_integer(amount_raw, "amount", violations) - if amount_parsed is not None: - amount = amount_parsed - if amount < 0: + amount_value_raw = raw["amount"] + amount_value_parsed = _parse_spec_integer( + amount_value_raw, "amount", violations + ) + if amount_value_parsed is not None: + amount_value = amount_value_parsed + if amount_value < 0: violations.append( - Violation(path="amount", reason=f"must be >= 0, got {amount}") + Violation( + path="amount", reason=f"must be >= 0, got {amount_value}" + ) ) additional_properties: dict[str, typing.Any] = {} @@ -3182,7 +3413,7 @@ def from_transfer_type( if violations: raise ValidationError(violations) return ShowcaseLedgerValue( - amount=amount, + amount=amount_value, additional_properties=additional_properties, ) @@ -3224,35 +3455,35 @@ def from_transfer_type( raise ValidationError([Violation(path="", reason="expected object")]) raw = typing.cast("dict[str, typing.Any]", value) - city: str = typing.cast("typing.Any", None) + city_value: str = typing.cast("typing.Any", None) if "city" not in raw or raw["city"] is None: violations.append(Violation(path="city", reason="required")) else: - city_raw = raw["city"] - if not isinstance(city_raw, str): + city_value_raw = raw["city"] + if not isinstance(city_value_raw, str): violations.append(Violation(path="city", reason="expected string")) else: - city = city_raw - if len(city_raw) < 1: + city_value = city_value_raw + if len(city_value_raw) < 1: violations.append( Violation( path="city", - reason=f"must have length >= 1, got {len(city_raw)}", + reason=f"must have length >= 1, got {len(city_value_raw)}", ) ) - geo: ShowcaseLocationGeo | None = None + geo_value: ShowcaseLocationGeo | None = None if "geo" in raw: - geo_raw = raw["geo"] - if geo_raw is None: + geo_value_raw = raw["geo"] + if geo_value_raw is None: violations.append( Violation(path="geo", reason="explicit null not allowed") ) else: try: - geo = ( + geo_value = ( _ShowcaseLocationGeoTransferTypeConverter().from_transfer_type( - geo_raw, ShowcaseLocationGeo + geo_value_raw, ShowcaseLocationGeo ) ) except ValidationError as error: @@ -3265,8 +3496,8 @@ def from_transfer_type( if violations: raise ValidationError(violations) return ShowcaseLocation( - city=city, - geo=geo, + city=city_value, + geo=geo_value, additional_properties=additional_properties, ) @@ -3282,9 +3513,14 @@ def to_transfer_type(self, value: "ShowcaseLocation") -> typing.Any: ) out["city"] = value.city if value.geo is not None: - out["geo"] = _ShowcaseLocationGeoTransferTypeConverter().to_transfer_type( - value.geo - ) + try: + out["geo"] = ( + _ShowcaseLocationGeoTransferTypeConverter().to_transfer_type( + value.geo + ) + ) + except ValidationError as error: + _collect(violations, "geo", error) for key, entry in value.additional_properties.items(): out[key] = entry if violations: @@ -3324,35 +3560,59 @@ def from_transfer_type( raise ValidationError([Violation(path="", reason="expected object")]) raw = typing.cast("dict[str, typing.Any]", value) - lat: float | None = None + lat_value: float | None = None if "lat" in raw: - lat_raw = raw["lat"] - if lat_raw is None: + lat_value_raw = raw["lat"] + if lat_value_raw is None: violations.append( Violation(path="lat", reason="explicit null not allowed") ) else: if not ( - not isinstance(lat_raw, bool) and isinstance(lat_raw, (int, float)) + not isinstance(lat_value_raw, bool) + and isinstance(lat_value_raw, (int, float)) ): violations.append(Violation(path="lat", reason="expected number")) else: - lat = lat_raw + lat_value = lat_value_raw + if not ( + -1.7976931348623157e308 + <= lat_value_raw + <= 1.7976931348623157e308 + ): + violations.append( + Violation( + path="lat", + reason=f"must be a finite number, got {lat_value_raw}", + ) + ) - lon: float | None = None + lon_value: float | None = None if "lon" in raw: - lon_raw = raw["lon"] - if lon_raw is None: + lon_value_raw = raw["lon"] + if lon_value_raw is None: violations.append( Violation(path="lon", reason="explicit null not allowed") ) else: if not ( - not isinstance(lon_raw, bool) and isinstance(lon_raw, (int, float)) + not isinstance(lon_value_raw, bool) + and isinstance(lon_value_raw, (int, float)) ): violations.append(Violation(path="lon", reason="expected number")) else: - lon = lon_raw + lon_value = lon_value_raw + if not ( + -1.7976931348623157e308 + <= lon_value_raw + <= 1.7976931348623157e308 + ): + violations.append( + Violation( + path="lon", + reason=f"must be a finite number, got {lon_value_raw}", + ) + ) additional_properties: dict[str, typing.Any] = {} for key in raw: @@ -3361,20 +3621,35 @@ def from_transfer_type( if violations: raise ValidationError(violations) return ShowcaseLocationGeo( - lat=lat, - lon=lon, + lat=lat_value, + lon=lon_value, additional_properties=additional_properties, ) @typing_extensions.override def to_transfer_type(self, value: "ShowcaseLocationGeo") -> typing.Any: + violations: list[Violation] = [] out: dict[str, typing.Any] = {} if value.lat is not None: + if not (-1.7976931348623157e308 <= value.lat <= 1.7976931348623157e308): + violations.append( + Violation( + path="lat", reason=f"must be a finite number, got {value.lat}" + ) + ) out["lat"] = value.lat if value.lon is not None: + if not (-1.7976931348623157e308 <= value.lon <= 1.7976931348623157e308): + violations.append( + Violation( + path="lon", reason=f"must be a finite number, got {value.lon}" + ) + ) out["lon"] = value.lon for key, entry in value.additional_properties.items(): out[key] = entry + if violations: + raise ValidationError(violations) return out @@ -3457,20 +3732,20 @@ def from_transfer_type( raise ValidationError([Violation(path="", reason="expected object")]) raw = typing.cast("dict[str, typing.Any]", value) - cell: str = typing.cast("typing.Any", None) + cell_value: str = typing.cast("typing.Any", None) if "cell" not in raw or raw["cell"] is None: violations.append(Violation(path="cell", reason="required")) else: - cell_raw = raw["cell"] - if not isinstance(cell_raw, str): + cell_value_raw = raw["cell"] + if not isinstance(cell_value_raw, str): violations.append(Violation(path="cell", reason="expected string")) else: - cell = cell_raw - if len(cell_raw) < 1: + cell_value = cell_value_raw + if len(cell_value_raw) < 1: violations.append( Violation( path="cell", - reason=f"must have length >= 1, got {len(cell_raw)}", + reason=f"must have length >= 1, got {len(cell_value_raw)}", ) ) @@ -3481,7 +3756,7 @@ def from_transfer_type( if violations: raise ValidationError(violations) return ShowcaseRowsItem( - cell=cell, + cell=cell_value, additional_properties=additional_properties, ) @@ -3525,15 +3800,15 @@ def from_transfer_type( raise ValidationError([Violation(path="", reason="expected object")]) raw = typing.cast("dict[str, typing.Any]", value) - id: str = typing.cast("typing.Any", None) + id_value: str = typing.cast("typing.Any", None) if "id" not in raw or raw["id"] is None: violations.append(Violation(path="id", reason="required")) else: - id_raw = raw["id"] - if not isinstance(id_raw, str): + id_value_raw = raw["id"] + if not isinstance(id_value_raw, str): violations.append(Violation(path="id", reason="expected string")) else: - id = id_raw + id_value = id_value_raw for key in raw: if key != "id": @@ -3541,7 +3816,7 @@ def from_transfer_type( if violations: raise ValidationError(violations) return GetShowcaseInput( - id=id, + id=id_value, ) @typing_extensions.override @@ -3569,29 +3844,39 @@ def from_transfer_type( raise ValidationError([Violation(path="", reason="expected object")]) raw = typing.cast("dict[str, typing.Any]", value) - kind: typing.Literal["square"] = typing.cast("typing.Any", None) + kind_value: typing.Literal["square"] = typing.cast("typing.Any", None) if "kind" not in raw or raw["kind"] is None: violations.append(Violation(path="kind", reason="required")) else: - kind_raw = raw["kind"] - if not isinstance(kind_raw, str): + kind_value_raw = raw["kind"] + if not isinstance(kind_value_raw, str): violations.append(Violation(path="kind", reason="expected string")) - elif kind_raw != "square": + elif kind_value_raw != "square": violations.append(Violation(path="kind", reason='must equal "square"')) else: - kind = kind_raw + kind_value = kind_value_raw - side: float = typing.cast("typing.Any", None) + side_value: float = typing.cast("typing.Any", None) if "side" not in raw or raw["side"] is None: violations.append(Violation(path="side", reason="required")) else: - side_raw = raw["side"] + side_value_raw = raw["side"] if not ( - not isinstance(side_raw, bool) and isinstance(side_raw, (int, float)) + not isinstance(side_value_raw, bool) + and isinstance(side_value_raw, (int, float)) ): violations.append(Violation(path="side", reason="expected number")) else: - side = side_raw + side_value = side_value_raw + if not ( + -1.7976931348623157e308 <= side_value_raw <= 1.7976931348623157e308 + ): + violations.append( + Violation( + path="side", + reason=f"must be a finite number, got {side_value_raw}", + ) + ) additional_properties: dict[str, typing.Any] = {} for key in raw: @@ -3600,8 +3885,8 @@ def from_transfer_type( if violations: raise ValidationError(violations) return Square( - kind=kind, - side=side, + kind=kind_value, + side=side_value, additional_properties=additional_properties, ) @@ -3612,6 +3897,12 @@ def to_transfer_type(self, value: "Square") -> typing.Any: if typing.cast("object", value.kind) not in ("square",): violations.append(Violation(path="kind", reason='must equal "square"')) out["kind"] = value.kind + if not (-1.7976931348623157e308 <= value.side <= 1.7976931348623157e308): + violations.append( + Violation( + path="side", reason=f"must be a finite number, got {value.side}" + ) + ) out["side"] = value.side for key, entry in value.additional_properties.items(): out[key] = entry @@ -3646,32 +3937,32 @@ def from_transfer_type( raise ValidationError([Violation(path="", reason="expected object")]) raw = typing.cast("dict[str, typing.Any]", value) - kind: typing.Literal["text"] = typing.cast("typing.Any", None) + kind_value: typing.Literal["text"] = typing.cast("typing.Any", None) if "kind" not in raw or raw["kind"] is None: violations.append(Violation(path="kind", reason="required")) else: - kind_raw = raw["kind"] - if not isinstance(kind_raw, str): + kind_value_raw = raw["kind"] + if not isinstance(kind_value_raw, str): violations.append(Violation(path="kind", reason="expected string")) - elif kind_raw != "text": + elif kind_value_raw != "text": violations.append(Violation(path="kind", reason='must equal "text"')) else: - kind = kind_raw + kind_value = kind_value_raw - body: str = typing.cast("typing.Any", None) + body_value: str = typing.cast("typing.Any", None) if "body" not in raw or raw["body"] is None: violations.append(Violation(path="body", reason="required")) else: - body_raw = raw["body"] - if not isinstance(body_raw, str): + body_value_raw = raw["body"] + if not isinstance(body_value_raw, str): violations.append(Violation(path="body", reason="expected string")) else: - body = body_raw - if len(body_raw) < 1: + body_value = body_value_raw + if len(body_value_raw) < 1: violations.append( Violation( path="body", - reason=f"must have length >= 1, got {len(body_raw)}", + reason=f"must have length >= 1, got {len(body_value_raw)}", ) ) @@ -3682,8 +3973,8 @@ def from_transfer_type( if violations: raise ValidationError(violations) return TextNote( - kind=kind, - body=body, + kind=kind_value, + body=body_value, additional_properties=additional_properties, ) @@ -3819,57 +4110,63 @@ def from_transfer_type( raise ValidationError([Violation(path="", reason="expected object")]) raw = typing.cast("dict[str, typing.Any]", value) - id: str = typing.cast("typing.Any", None) + id_value: str = typing.cast("typing.Any", None) if "id" not in raw or raw["id"] is None: violations.append(Violation(path="id", reason="required")) else: - id_raw = raw["id"] - if not isinstance(id_raw, str): + id_value_raw = raw["id"] + if not isinstance(id_value_raw, str): violations.append(Violation(path="id", reason="expected string")) else: - id = id_raw + id_value = id_value_raw - kind: str | None = None + kind_value: str | None = None if "kind" in raw: - kind_raw = raw["kind"] - if kind_raw is None: + kind_value_raw = raw["kind"] + if kind_value_raw is None: violations.append( Violation(path="kind", reason="explicit null not allowed") ) else: - if not isinstance(kind_raw, str): + if not isinstance(kind_value_raw, str): violations.append(Violation(path="kind", reason="expected string")) else: - kind = kind_raw + kind_value = kind_value_raw - name: str = typing.cast("typing.Any", None) + name_value: str = typing.cast("typing.Any", None) if "name" not in raw or raw["name"] is None: violations.append(Violation(path="name", reason="required")) else: - name_raw = raw["name"] - if not isinstance(name_raw, str): + name_value_raw = raw["name"] + if not isinstance(name_value_raw, str): violations.append(Violation(path="name", reason="expected string")) else: - name = name_raw + name_value = name_value_raw - size: int | None = None + size_value: int | None = None if "size" in raw: - size_raw = raw["size"] - if size_raw is None: + size_value_raw = raw["size"] + if size_value_raw is None: violations.append( Violation(path="size", reason="explicit null not allowed") ) else: - size_parsed = _parse_spec_integer(size_raw, "size", violations) - if size_parsed is not None: - size = size_parsed - if size < 10: + size_value_parsed = _parse_spec_integer( + size_value_raw, "size", violations + ) + if size_value_parsed is not None: + size_value = size_value_parsed + if size_value < 10: violations.append( - Violation(path="size", reason=f"must be >= 10, got {size}") + Violation( + path="size", reason=f"must be >= 10, got {size_value}" + ) ) - if size > 20: + if size_value > 20: violations.append( - Violation(path="size", reason=f"must be <= 20, got {size}") + Violation( + path="size", reason=f"must be <= 20, got {size_value}" + ) ) additional_properties: dict[str, typing.Any] = {} @@ -3879,10 +4176,10 @@ def from_transfer_type( if violations: raise ValidationError(violations) return Widget( - id=id, - kind=kind, - name=name, - size=size, + id=id_value, + kind=kind_value, + name=name_value, + size=size_value, additional_properties=additional_properties, ) @@ -3947,28 +4244,28 @@ def from_transfer_type( raise ValidationError([Violation(path="", reason="expected object")]) raw = typing.cast("dict[str, typing.Any]", value) - id: str = typing.cast("typing.Any", None) + id_value: str = typing.cast("typing.Any", None) if "id" not in raw or raw["id"] is None: violations.append(Violation(path="id", reason="required")) else: - id_raw = raw["id"] - if not isinstance(id_raw, str): + id_value_raw = raw["id"] + if not isinstance(id_value_raw, str): violations.append(Violation(path="id", reason="expected string")) else: - id = id_raw + id_value = id_value_raw - kind: str | None = None + kind_value: str | None = None if "kind" in raw: - kind_raw = raw["kind"] - if kind_raw is None: + kind_value_raw = raw["kind"] + if kind_value_raw is None: violations.append( Violation(path="kind", reason="explicit null not allowed") ) else: - if not isinstance(kind_raw, str): + if not isinstance(kind_value_raw, str): violations.append(Violation(path="kind", reason="expected string")) else: - kind = kind_raw + kind_value = kind_value_raw additional_properties: dict[str, typing.Any] = {} for key in raw: @@ -3977,8 +4274,8 @@ def from_transfer_type( if violations: raise ValidationError(violations) return WidgetBase( - id=id, - kind=kind, + id=id_value, + kind=kind_value, additional_properties=additional_properties, ) @@ -4039,6 +4336,12 @@ def _choices_value_from_transfer_type( def _choices_value_to_transfer_type(value: ChoicesValue) -> typing.Any: + violations: list[Violation] = [] + candidate = typing.cast("object", value) + if not (isinstance(candidate, Circle) or isinstance(candidate, Square)): + violations.append(Violation(path="", reason="expected one of: Circle, Square")) + if violations: + raise ValidationError(violations) if isinstance(value, Circle): return _CircleTransferTypeConverter().to_transfer_type(value) return _SquareTransferTypeConverter().to_transfer_type(value) @@ -4080,6 +4383,14 @@ def _note_from_transfer_type( def _note_to_transfer_type(value: Note) -> typing.Any: + violations: list[Violation] = [] + candidate = typing.cast("object", value) + if not (isinstance(candidate, TextNote) or isinstance(candidate, LinkNote)): + violations.append( + Violation(path="", reason="expected one of: TextNote, LinkNote") + ) + if violations: + raise ValidationError(violations) if isinstance(value, TextNote): return _TextNoteTransferTypeConverter().to_transfer_type(value) return _LinkNoteTransferTypeConverter().to_transfer_type(value) @@ -4115,6 +4426,12 @@ def _shape_from_transfer_type( def _shape_to_transfer_type(value: Shape) -> typing.Any: + violations: list[Violation] = [] + candidate = typing.cast("object", value) + if not (isinstance(candidate, Circle) or isinstance(candidate, Square)): + violations.append(Violation(path="", reason="expected one of: Circle, Square")) + if violations: + raise ValidationError(violations) if isinstance(value, Circle): return _CircleTransferTypeConverter().to_transfer_type(value) return _SquareTransferTypeConverter().to_transfer_type(value) @@ -4155,6 +4472,12 @@ def _showcase_segments_item_to_transfer_type(value: ShowcaseSegmentsItem) -> typ if not isinstance(value, bool) and isinstance(value, int): if value < 0: violations.append(Violation(path="", reason=f"must be >= 0, got {value}")) + candidate = typing.cast("object", value) + if not ( + isinstance(candidate, str) + or (not isinstance(candidate, bool) and isinstance(candidate, int)) + ): + violations.append(Violation(path="", reason="expected one of: string, integer")) if violations: raise ValidationError(violations) return value @@ -4304,15 +4627,40 @@ def _showcase_measurements_from_transfer_type( value: typing.Any, path: str, violations: list[Violation] ) -> list[float] | str | None: if isinstance(value, list): - items = typing.cast("list[float]", value) - if len(items) < 1: + items_list: list[float] = [] + for items_index, items_element in enumerate( + typing.cast("list[typing.Any]", value) + ): + items_item_path = f"{path}[{items_index}]" + items_item: float = typing.cast("typing.Any", None) + if not ( + not isinstance(items_element, bool) + and isinstance(items_element, (int, float)) + ): + violations.append( + Violation(path=items_item_path, reason="expected number") + ) + else: + items_item = items_element + if not ( + -1.7976931348623157e308 <= items_element <= 1.7976931348623157e308 + ): + violations.append( + Violation( + path=items_item_path, + reason=f"must be a finite number, got {items_element}", + ) + ) + items_list.append(items_item) + if len(items_list) < 1: violations.append( Violation( - path=path, reason=f"must have at least 1 items, got {len(items)}" + path=path, + reason=f"must have at least 1 items, got {len(items_list)}", ) ) - _check_unique_items(items, path, violations) - return items + _check_unique_items(items_list, path, violations) + return items_list if isinstance(value, str): if _PATTERN_F242E3A159C2422C.search(value) is None: violations.append( @@ -4331,6 +4679,7 @@ def _showcase_measurements_from_transfer_type( ChoicesValue: typing.TypeAlias = Circle | Square +Note: typing.TypeAlias = TextNote | LinkNote """A tagged union whose object branches are written **inline** rather than `$ref`ed: each branch is emitted as a named type, so each names itself with the per-language `x-<lang>-name` override (two or more inline object branches cannot derive @@ -4338,14 +4687,13 @@ def _showcase_measurements_from_transfer_type( Selection reads the shared required `kind` const, and each branch keeps its own constraints and stays open to unknown members. """ -Note: typing.TypeAlias = TextNote | LinkNote +Shape: typing.TypeAlias = Circle | Square """A closed sum type (discriminated union) of Circle | Square, tagged by the shared required `kind` const. Selection reads `kind` and routes to the matching branch; an unknown tag is a Violation. """ -Shape: typing.TypeAlias = Circle | Square ShowcaseSegmentsItem: typing.TypeAlias = str | int diff --git a/advanced/samples/python/json_schema/api/temporal/_definitions.py b/advanced/samples/python/json_schema/api/temporal/_definitions.py index f2b94492..3bf7f712 100644 --- a/advanced/samples/python/json_schema/api/temporal/_definitions.py +++ b/advanced/samples/python/json_schema/api/temporal/_definitions.py @@ -16,6 +16,9 @@ "ValidationError", "Violation", "_check_contains", + "_check_date_time", + "_check_duration", + "_check_time", "_check_unique_items", "_collect", "_format_base64", @@ -191,6 +194,13 @@ def _check_contains( r"^PT(?:[0-9]+H(?:[0-9]+M(?:[0-9]+S)?)?|[0-9]+M(?:[0-9]+S)?|[0-9]+S)$" ) _TEMPORAL_MAX_DURATION_SECONDS = ((1 << 63) - 1) // 1_000_000_000 +# A duration component with more digits than the cap itself is over the cap +# whatever those digits are, which is how the magnitude is bounded before `int()` +# sees it: CPython refuses to convert a string of more than 4300 digits. +_TEMPORAL_MAX_DURATION_DIGITS = len(str(_TEMPORAL_MAX_DURATION_SECONDS)) +# `datetime` resolves to microseconds, and `fromisoformat` before Python 3.11 +# parses only the fraction widths `isoformat` writes. +_TEMPORAL_FRACTION_DIGITS = 6 def _days_in_month(year: int, month: int) -> int: @@ -210,10 +220,57 @@ def _valid_temporal_calendar(value: str) -> bool: year, month, day = int(value[0:4]), int(value[5:7]), int(value[8:10]) except ValueError: return False + # `datetime.MINYEAR` is 1, so year 0000 -- which the wire grammar admits and + # the other three targets materialize -- has no Python value at all. It is + # rejected rather than shifted into range, and `_temporal_reason` says so. + if year < datetime.MINYEAR: + return False maximum = _days_in_month(year, month) return maximum > 0 and 1 <= day <= maximum +def _temporal_reason(name: str, value: str) -> str: + """The reason a rejected temporal string is reported under. + + Year 0000 earns its own clause: it is a valid wire value the other targets + accept, so a caller needs to read Python's floor rather than conclude the + timestamp was malformed. + """ + + if value[0:4] == "0000": + return ( + f"must be a valid {name}, got {_quote(value)}: year 0000 is not" + f" representable (datetime.MINYEAR is {datetime.MINYEAR})" + ) + return f"must be a valid {name}, got {_quote(value)}" + + +def _temporal_isoformat(value: str) -> str: + """Rewrites a wire temporal into the spelling `fromisoformat` accepts. + + `Z` becomes `+00:00`, and the fractional second is padded or truncated to + exactly `_TEMPORAL_FRACTION_DIGITS`: before Python 3.11 `fromisoformat` + parses only what `isoformat` writes, so an RFC 3339 `.1` or `.1234567` -- + which every other target accepts -- would otherwise raise. Digits past the + sixth are dropped, the loss at `datetime`'s own resolution that P1 allows; + the canonical output re-trims the padding, so `.1` still writes as `.1`. + """ + + normalized = value.upper() + if normalized.endswith("Z"): + normalized = normalized[:-1] + "+00:00" + dot = normalized.find(".") + if dot < 0: + return normalized + end = dot + 1 + while end < len(normalized) and normalized[end].isdigit(): + end += 1 + fraction = normalized[dot + 1 : end].ljust(_TEMPORAL_FRACTION_DIGITS, "0") + return ( + normalized[: dot + 1] + fraction[:_TEMPORAL_FRACTION_DIGITS] + normalized[end:] + ) + + def _parse_date_time( value: str, path: str, violations: list[Violation] ) -> datetime.datetime | None: @@ -221,24 +278,17 @@ def _parse_date_time( value ): violations.append( - Violation( - path=path, reason=f"must be a valid date-time, got {_quote(value)}" - ) + Violation(path=path, reason=_temporal_reason("date-time", value)) ) return None - normalized = value.upper() - if normalized.endswith("Z"): - normalized = normalized[:-1] + "+00:00" - return datetime.datetime.fromisoformat(normalized) + return datetime.datetime.fromisoformat(_temporal_isoformat(value)) def _parse_date( value: str, path: str, violations: list[Violation] ) -> datetime.date | None: if _TEMPORAL_DATE_RE.match(value) is None or not _valid_temporal_calendar(value): - violations.append( - Violation(path=path, reason=f"must be a valid date, got {_quote(value)}") - ) + violations.append(Violation(path=path, reason=_temporal_reason("date", value))) return None return datetime.date.fromisoformat(value) @@ -247,14 +297,9 @@ def _parse_time( value: str, path: str, violations: list[Violation] ) -> datetime.time | None: if _TEMPORAL_TIME_RE.match(value) is None: - violations.append( - Violation(path=path, reason=f"must be a valid time, got {_quote(value)}") - ) + violations.append(Violation(path=path, reason=_temporal_reason("time", value))) return None - normalized = value.upper() - if normalized.endswith("Z"): - normalized = normalized[:-1] + "+00:00" - return datetime.time.fromisoformat(normalized) + return datetime.time.fromisoformat(_temporal_isoformat(value)) def _parse_duration( @@ -262,9 +307,7 @@ def _parse_duration( ) -> datetime.timedelta | None: if _TEMPORAL_DURATION_RE.match(value) is None: violations.append( - Violation( - path=path, reason=f"must be a valid duration, got {_quote(value)}" - ) + Violation(path=path, reason=_temporal_reason("duration", value)) ) return None total = 0 @@ -273,18 +316,103 @@ def _parse_duration( if char.isdigit(): number += char continue - total += int(number) * {"H": 3600, "M": 60, "S": 1}[char] + digits = number.lstrip("0") number = "" + if len(digits) > _TEMPORAL_MAX_DURATION_DIGITS: + # Over the cap by digit count alone (see the constant), so the + # conversion `int()` would refuse is never attempted. + total = _TEMPORAL_MAX_DURATION_SECONDS + 1 + break + total += int(digits or "0") * {"H": 3600, "M": 60, "S": 1}[char] if total > _TEMPORAL_MAX_DURATION_SECONDS: - violations.append( - Violation( - path=path, reason=f"must be a valid duration, got {_quote(value)}" - ) - ) - return None + break + if total > _TEMPORAL_MAX_DURATION_SECONDS: + violations.append( + Violation(path=path, reason=_temporal_reason("duration", value)) + ) + return None return datetime.timedelta(seconds=total) +def _check_temporal_offset( + name: str, + value: datetime.datetime | datetime.time, + offset: datetime.timedelta, + path: str, + violations: list[Violation], +) -> None: + """Asserts a UTC offset is a whole number of minutes, the finest the wire + form spells (`tzinfo` allows seconds, which the offset would silently lose). + """ + + if offset % datetime.timedelta(minutes=1): + violations.append( + Violation( + path=path, + reason=( + f"must be a valid {name}, got {_quote(str(value))}: " + f"the UTC offset {offset} is not a whole number of minutes" + ), + ) + ) + + +def _check_date_time( + value: datetime.datetime, path: str, violations: list[Violation] +) -> None: + """Asserts a datetime is writable as a wire date-time (P12). + + A dataclass is constructed unchecked, so a naive datetime -- with no offset + the required wire form could carry -- reaches serialize; without this it + would emit a value this module's own parser rejects. + """ + + offset = value.utcoffset() + if offset is None: + violations.append( + Violation( + path=path, + reason=( + f"must be a valid date-time, got {_quote(str(value))}: " + "a naive datetime carries no UTC offset" + ), + ) + ) + return + _check_temporal_offset("date-time", value, offset, path, violations) + + +def _check_time(value: datetime.time, path: str, violations: list[Violation]) -> None: + """Asserts a time is writable as a wire time (P12). The offset is optional in + the grammar, so only its precision is held to anything.""" + + offset = value.utcoffset() + if offset is not None: + _check_temporal_offset("time", value, offset, path, violations) + + +def _check_duration( + value: datetime.timedelta, path: str, violations: list[Violation] +) -> None: + """Asserts a timedelta is writable as a wire duration (P12): the grammar is + unsigned, whole-second and capped, and a `timedelta` is none of those.""" + + if value < datetime.timedelta(0): + reason = "a duration cannot be negative" + elif value % datetime.timedelta(seconds=1): + reason = "a duration cannot carry a fraction of a second" + elif value.total_seconds() > _TEMPORAL_MAX_DURATION_SECONDS: + reason = f"a duration cannot exceed {_TEMPORAL_MAX_DURATION_SECONDS} seconds" + else: + return + violations.append( + Violation( + path=path, + reason=f"must be a valid duration, got {_quote(str(value))}: {reason}", + ) + ) + + def _temporal_frac(microsecond: int) -> str: if microsecond == 0: return "" diff --git a/advanced/samples/python/json_schema/api/temporal/models.py b/advanced/samples/python/json_schema/api/temporal/models.py index 8de3e071..442114a3 100644 --- a/advanced/samples/python/json_schema/api/temporal/models.py +++ b/advanced/samples/python/json_schema/api/temporal/models.py @@ -11,6 +11,9 @@ from ._definitions import ( ValidationError, Violation, + _check_date_time, + _check_duration, + _check_time, _format_date, _format_date_time, _format_duration, @@ -35,163 +38,169 @@ def from_transfer_type( raise ValidationError([Violation(path="", reason="expected object")]) raw = typing.cast("dict[str, typing.Any]", value) - created_at: datetime.datetime = typing.cast("typing.Any", None) + created_at_value: datetime.datetime = typing.cast("typing.Any", None) if "createdAt" not in raw or raw["createdAt"] is None: violations.append(Violation(path="createdAt", reason="required")) else: - created_at_raw = raw["createdAt"] - if not isinstance(created_at_raw, str): + created_at_value_raw = raw["createdAt"] + if not isinstance(created_at_value_raw, str): violations.append(Violation(path="createdAt", reason="expected string")) else: - created_at_parsed = _parse_date_time( - created_at_raw, "createdAt", violations + created_at_value_parsed = _parse_date_time( + created_at_value_raw, "createdAt", violations ) - if created_at_parsed is not None: - created_at = created_at_parsed + if created_at_value_parsed is not None: + created_at_value = created_at_value_parsed - birthday: datetime.date = typing.cast("typing.Any", None) + birthday_value: datetime.date = typing.cast("typing.Any", None) if "birthday" not in raw or raw["birthday"] is None: violations.append(Violation(path="birthday", reason="required")) else: - birthday_raw = raw["birthday"] - if not isinstance(birthday_raw, str): + birthday_value_raw = raw["birthday"] + if not isinstance(birthday_value_raw, str): violations.append(Violation(path="birthday", reason="expected string")) else: - birthday_parsed = _parse_date(birthday_raw, "birthday", violations) - if birthday_parsed is not None: - birthday = birthday_parsed + birthday_value_parsed = _parse_date( + birthday_value_raw, "birthday", violations + ) + if birthday_value_parsed is not None: + birthday_value = birthday_value_parsed - alarm: datetime.time = typing.cast("typing.Any", None) + alarm_value: datetime.time = typing.cast("typing.Any", None) if "alarm" not in raw or raw["alarm"] is None: violations.append(Violation(path="alarm", reason="required")) else: - alarm_raw = raw["alarm"] - if not isinstance(alarm_raw, str): + alarm_value_raw = raw["alarm"] + if not isinstance(alarm_value_raw, str): violations.append(Violation(path="alarm", reason="expected string")) else: - alarm_parsed = _parse_time(alarm_raw, "alarm", violations) - if alarm_parsed is not None: - alarm = alarm_parsed + alarm_value_parsed = _parse_time(alarm_value_raw, "alarm", violations) + if alarm_value_parsed is not None: + alarm_value = alarm_value_parsed - timeout: datetime.timedelta = typing.cast("typing.Any", None) + timeout_value: datetime.timedelta = typing.cast("typing.Any", None) if "timeout" not in raw or raw["timeout"] is None: violations.append(Violation(path="timeout", reason="required")) else: - timeout_raw = raw["timeout"] - if not isinstance(timeout_raw, str): + timeout_value_raw = raw["timeout"] + if not isinstance(timeout_value_raw, str): violations.append(Violation(path="timeout", reason="expected string")) else: - timeout_parsed = _parse_duration(timeout_raw, "timeout", violations) - if timeout_parsed is not None: - timeout = timeout_parsed + timeout_value_parsed = _parse_duration( + timeout_value_raw, "timeout", violations + ) + if timeout_value_parsed is not None: + timeout_value = timeout_value_parsed - updated_at: datetime.datetime | None = None + updated_at_value: datetime.datetime | None = None if "updatedAt" in raw: - updated_at_raw = raw["updatedAt"] - if updated_at_raw is None: + updated_at_value_raw = raw["updatedAt"] + if updated_at_value_raw is None: violations.append( Violation(path="updatedAt", reason="explicit null not allowed") ) else: - if not isinstance(updated_at_raw, str): + if not isinstance(updated_at_value_raw, str): violations.append( Violation(path="updatedAt", reason="expected string") ) else: - updated_at_parsed = _parse_date_time( - updated_at_raw, "updatedAt", violations + updated_at_value_parsed = _parse_date_time( + updated_at_value_raw, "updatedAt", violations ) - if updated_at_parsed is not None: - updated_at = updated_at_parsed + if updated_at_value_parsed is not None: + updated_at_value = updated_at_value_parsed - expires_on: datetime.date | None = None + expires_on_value: datetime.date | None = None if "expiresOn" in raw: - expires_on_raw = raw["expiresOn"] - if expires_on_raw is None: + expires_on_value_raw = raw["expiresOn"] + if expires_on_value_raw is None: violations.append( Violation(path="expiresOn", reason="explicit null not allowed") ) else: - if not isinstance(expires_on_raw, str): + if not isinstance(expires_on_value_raw, str): violations.append( Violation(path="expiresOn", reason="expected string") ) else: - expires_on_parsed = _parse_date( - expires_on_raw, "expiresOn", violations + expires_on_value_parsed = _parse_date( + expires_on_value_raw, "expiresOn", violations ) - if expires_on_parsed is not None: - expires_on = expires_on_parsed + if expires_on_value_parsed is not None: + expires_on_value = expires_on_value_parsed - reminder: datetime.time | None = None + reminder_value: datetime.time | None = None if "reminder" in raw: - reminder_raw = raw["reminder"] - if reminder_raw is None: + reminder_value_raw = raw["reminder"] + if reminder_value_raw is None: violations.append( Violation(path="reminder", reason="explicit null not allowed") ) else: - if not isinstance(reminder_raw, str): + if not isinstance(reminder_value_raw, str): violations.append( Violation(path="reminder", reason="expected string") ) else: - reminder_parsed = _parse_time(reminder_raw, "reminder", violations) - if reminder_parsed is not None: - reminder = reminder_parsed + reminder_value_parsed = _parse_time( + reminder_value_raw, "reminder", violations + ) + if reminder_value_parsed is not None: + reminder_value = reminder_value_parsed - retry_delay: datetime.timedelta | None = None + retry_delay_value: datetime.timedelta | None = None if "retryDelay" in raw: - retry_delay_raw = raw["retryDelay"] - if retry_delay_raw is None: + retry_delay_value_raw = raw["retryDelay"] + if retry_delay_value_raw is None: violations.append( Violation(path="retryDelay", reason="explicit null not allowed") ) else: - if not isinstance(retry_delay_raw, str): + if not isinstance(retry_delay_value_raw, str): violations.append( Violation(path="retryDelay", reason="expected string") ) else: - retry_delay_parsed = _parse_duration( - retry_delay_raw, "retryDelay", violations + retry_delay_value_parsed = _parse_duration( + retry_delay_value_raw, "retryDelay", violations ) - if retry_delay_parsed is not None: - retry_delay = retry_delay_parsed + if retry_delay_value_parsed is not None: + retry_delay_value = retry_delay_value_parsed - deleted_at: datetime.datetime | None = None + deleted_at_value: datetime.datetime | None = None if "deletedAt" in raw: - deleted_at_raw = raw["deletedAt"] - if deleted_at_raw is None: - deleted_at = None + deleted_at_value_raw = raw["deletedAt"] + if deleted_at_value_raw is None: + deleted_at_value = None else: - if not isinstance(deleted_at_raw, str): + if not isinstance(deleted_at_value_raw, str): violations.append( Violation(path="deletedAt", reason="expected string") ) else: - deleted_at_parsed = _parse_date_time( - deleted_at_raw, "deletedAt", violations + deleted_at_value_parsed = _parse_date_time( + deleted_at_value_raw, "deletedAt", violations ) - if deleted_at_parsed is not None: - deleted_at = deleted_at_parsed + if deleted_at_value_parsed is not None: + deleted_at_value = deleted_at_value_parsed - archived_on: datetime.date | None = None + archived_on_value: datetime.date | None = None if "archivedOn" in raw: - archived_on_raw = raw["archivedOn"] - if archived_on_raw is None: - archived_on = None + archived_on_value_raw = raw["archivedOn"] + if archived_on_value_raw is None: + archived_on_value = None else: - if not isinstance(archived_on_raw, str): + if not isinstance(archived_on_value_raw, str): violations.append( Violation(path="archivedOn", reason="expected string") ) else: - archived_on_parsed = _parse_date( - archived_on_raw, "archivedOn", violations + archived_on_value_parsed = _parse_date( + archived_on_value_raw, "archivedOn", violations ) - if archived_on_parsed is not None: - archived_on = archived_on_parsed + if archived_on_value_parsed is not None: + archived_on_value = archived_on_value_parsed for key in raw: if ( @@ -210,35 +219,42 @@ def from_transfer_type( if violations: raise ValidationError(violations) return Temporal( - created_at=created_at, - birthday=birthday, - alarm=alarm, - timeout=timeout, - updated_at=updated_at, - expires_on=expires_on, - reminder=reminder, - retry_delay=retry_delay, - deleted_at=deleted_at, - archived_on=archived_on, + created_at=created_at_value, + birthday=birthday_value, + alarm=alarm_value, + timeout=timeout_value, + updated_at=updated_at_value, + expires_on=expires_on_value, + reminder=reminder_value, + retry_delay=retry_delay_value, + deleted_at=deleted_at_value, + archived_on=archived_on_value, ) @typing_extensions.override def to_transfer_type(self, value: "Temporal") -> typing.Any: violations: list[Violation] = [] out: dict[str, typing.Any] = {} + _check_date_time(value.created_at, "createdAt", violations) out["createdAt"] = _format_date_time(value.created_at) out["birthday"] = _format_date(value.birthday) + _check_time(value.alarm, "alarm", violations) out["alarm"] = _format_time(value.alarm) + _check_duration(value.timeout, "timeout", violations) out["timeout"] = _format_duration(value.timeout) if value.updated_at is not None: + _check_date_time(value.updated_at, "updatedAt", violations) out["updatedAt"] = _format_date_time(value.updated_at) if value.expires_on is not None: out["expiresOn"] = _format_date(value.expires_on) if value.reminder is not None: + _check_time(value.reminder, "reminder", violations) out["reminder"] = _format_time(value.reminder) if value.retry_delay is not None: + _check_duration(value.retry_delay, "retryDelay", violations) out["retryDelay"] = _format_duration(value.retry_delay) if value.deleted_at is not None: + _check_date_time(value.deleted_at, "deletedAt", violations) out["deletedAt"] = _format_date_time(value.deleted_at) if value.archived_on is not None: out["archivedOn"] = _format_date(value.archived_on) diff --git a/samples/python/chat/_definitions.py b/samples/python/chat/_definitions.py index f2b94492..3bf7f712 100644 --- a/samples/python/chat/_definitions.py +++ b/samples/python/chat/_definitions.py @@ -16,6 +16,9 @@ "ValidationError", "Violation", "_check_contains", + "_check_date_time", + "_check_duration", + "_check_time", "_check_unique_items", "_collect", "_format_base64", @@ -191,6 +194,13 @@ def _check_contains( r"^PT(?:[0-9]+H(?:[0-9]+M(?:[0-9]+S)?)?|[0-9]+M(?:[0-9]+S)?|[0-9]+S)$" ) _TEMPORAL_MAX_DURATION_SECONDS = ((1 << 63) - 1) // 1_000_000_000 +# A duration component with more digits than the cap itself is over the cap +# whatever those digits are, which is how the magnitude is bounded before `int()` +# sees it: CPython refuses to convert a string of more than 4300 digits. +_TEMPORAL_MAX_DURATION_DIGITS = len(str(_TEMPORAL_MAX_DURATION_SECONDS)) +# `datetime` resolves to microseconds, and `fromisoformat` before Python 3.11 +# parses only the fraction widths `isoformat` writes. +_TEMPORAL_FRACTION_DIGITS = 6 def _days_in_month(year: int, month: int) -> int: @@ -210,10 +220,57 @@ def _valid_temporal_calendar(value: str) -> bool: year, month, day = int(value[0:4]), int(value[5:7]), int(value[8:10]) except ValueError: return False + # `datetime.MINYEAR` is 1, so year 0000 -- which the wire grammar admits and + # the other three targets materialize -- has no Python value at all. It is + # rejected rather than shifted into range, and `_temporal_reason` says so. + if year < datetime.MINYEAR: + return False maximum = _days_in_month(year, month) return maximum > 0 and 1 <= day <= maximum +def _temporal_reason(name: str, value: str) -> str: + """The reason a rejected temporal string is reported under. + + Year 0000 earns its own clause: it is a valid wire value the other targets + accept, so a caller needs to read Python's floor rather than conclude the + timestamp was malformed. + """ + + if value[0:4] == "0000": + return ( + f"must be a valid {name}, got {_quote(value)}: year 0000 is not" + f" representable (datetime.MINYEAR is {datetime.MINYEAR})" + ) + return f"must be a valid {name}, got {_quote(value)}" + + +def _temporal_isoformat(value: str) -> str: + """Rewrites a wire temporal into the spelling `fromisoformat` accepts. + + `Z` becomes `+00:00`, and the fractional second is padded or truncated to + exactly `_TEMPORAL_FRACTION_DIGITS`: before Python 3.11 `fromisoformat` + parses only what `isoformat` writes, so an RFC 3339 `.1` or `.1234567` -- + which every other target accepts -- would otherwise raise. Digits past the + sixth are dropped, the loss at `datetime`'s own resolution that P1 allows; + the canonical output re-trims the padding, so `.1` still writes as `.1`. + """ + + normalized = value.upper() + if normalized.endswith("Z"): + normalized = normalized[:-1] + "+00:00" + dot = normalized.find(".") + if dot < 0: + return normalized + end = dot + 1 + while end < len(normalized) and normalized[end].isdigit(): + end += 1 + fraction = normalized[dot + 1 : end].ljust(_TEMPORAL_FRACTION_DIGITS, "0") + return ( + normalized[: dot + 1] + fraction[:_TEMPORAL_FRACTION_DIGITS] + normalized[end:] + ) + + def _parse_date_time( value: str, path: str, violations: list[Violation] ) -> datetime.datetime | None: @@ -221,24 +278,17 @@ def _parse_date_time( value ): violations.append( - Violation( - path=path, reason=f"must be a valid date-time, got {_quote(value)}" - ) + Violation(path=path, reason=_temporal_reason("date-time", value)) ) return None - normalized = value.upper() - if normalized.endswith("Z"): - normalized = normalized[:-1] + "+00:00" - return datetime.datetime.fromisoformat(normalized) + return datetime.datetime.fromisoformat(_temporal_isoformat(value)) def _parse_date( value: str, path: str, violations: list[Violation] ) -> datetime.date | None: if _TEMPORAL_DATE_RE.match(value) is None or not _valid_temporal_calendar(value): - violations.append( - Violation(path=path, reason=f"must be a valid date, got {_quote(value)}") - ) + violations.append(Violation(path=path, reason=_temporal_reason("date", value))) return None return datetime.date.fromisoformat(value) @@ -247,14 +297,9 @@ def _parse_time( value: str, path: str, violations: list[Violation] ) -> datetime.time | None: if _TEMPORAL_TIME_RE.match(value) is None: - violations.append( - Violation(path=path, reason=f"must be a valid time, got {_quote(value)}") - ) + violations.append(Violation(path=path, reason=_temporal_reason("time", value))) return None - normalized = value.upper() - if normalized.endswith("Z"): - normalized = normalized[:-1] + "+00:00" - return datetime.time.fromisoformat(normalized) + return datetime.time.fromisoformat(_temporal_isoformat(value)) def _parse_duration( @@ -262,9 +307,7 @@ def _parse_duration( ) -> datetime.timedelta | None: if _TEMPORAL_DURATION_RE.match(value) is None: violations.append( - Violation( - path=path, reason=f"must be a valid duration, got {_quote(value)}" - ) + Violation(path=path, reason=_temporal_reason("duration", value)) ) return None total = 0 @@ -273,18 +316,103 @@ def _parse_duration( if char.isdigit(): number += char continue - total += int(number) * {"H": 3600, "M": 60, "S": 1}[char] + digits = number.lstrip("0") number = "" + if len(digits) > _TEMPORAL_MAX_DURATION_DIGITS: + # Over the cap by digit count alone (see the constant), so the + # conversion `int()` would refuse is never attempted. + total = _TEMPORAL_MAX_DURATION_SECONDS + 1 + break + total += int(digits or "0") * {"H": 3600, "M": 60, "S": 1}[char] if total > _TEMPORAL_MAX_DURATION_SECONDS: - violations.append( - Violation( - path=path, reason=f"must be a valid duration, got {_quote(value)}" - ) - ) - return None + break + if total > _TEMPORAL_MAX_DURATION_SECONDS: + violations.append( + Violation(path=path, reason=_temporal_reason("duration", value)) + ) + return None return datetime.timedelta(seconds=total) +def _check_temporal_offset( + name: str, + value: datetime.datetime | datetime.time, + offset: datetime.timedelta, + path: str, + violations: list[Violation], +) -> None: + """Asserts a UTC offset is a whole number of minutes, the finest the wire + form spells (`tzinfo` allows seconds, which the offset would silently lose). + """ + + if offset % datetime.timedelta(minutes=1): + violations.append( + Violation( + path=path, + reason=( + f"must be a valid {name}, got {_quote(str(value))}: " + f"the UTC offset {offset} is not a whole number of minutes" + ), + ) + ) + + +def _check_date_time( + value: datetime.datetime, path: str, violations: list[Violation] +) -> None: + """Asserts a datetime is writable as a wire date-time (P12). + + A dataclass is constructed unchecked, so a naive datetime -- with no offset + the required wire form could carry -- reaches serialize; without this it + would emit a value this module's own parser rejects. + """ + + offset = value.utcoffset() + if offset is None: + violations.append( + Violation( + path=path, + reason=( + f"must be a valid date-time, got {_quote(str(value))}: " + "a naive datetime carries no UTC offset" + ), + ) + ) + return + _check_temporal_offset("date-time", value, offset, path, violations) + + +def _check_time(value: datetime.time, path: str, violations: list[Violation]) -> None: + """Asserts a time is writable as a wire time (P12). The offset is optional in + the grammar, so only its precision is held to anything.""" + + offset = value.utcoffset() + if offset is not None: + _check_temporal_offset("time", value, offset, path, violations) + + +def _check_duration( + value: datetime.timedelta, path: str, violations: list[Violation] +) -> None: + """Asserts a timedelta is writable as a wire duration (P12): the grammar is + unsigned, whole-second and capped, and a `timedelta` is none of those.""" + + if value < datetime.timedelta(0): + reason = "a duration cannot be negative" + elif value % datetime.timedelta(seconds=1): + reason = "a duration cannot carry a fraction of a second" + elif value.total_seconds() > _TEMPORAL_MAX_DURATION_SECONDS: + reason = f"a duration cannot exceed {_TEMPORAL_MAX_DURATION_SECONDS} seconds" + else: + return + violations.append( + Violation( + path=path, + reason=f"must be a valid duration, got {_quote(str(value))}: {reason}", + ) + ) + + def _temporal_frac(microsecond: int) -> str: if microsecond == 0: return "" diff --git a/samples/python/chat/models.py b/samples/python/chat/models.py index bd9219f6..ce375e5b 100644 --- a/samples/python/chat/models.py +++ b/samples/python/chat/models.py @@ -36,15 +36,15 @@ def from_transfer_type( raise ValidationError([Violation(path="", reason="expected object")]) raw = typing.cast("dict[str, typing.Any]", value) - room_id: str = typing.cast("typing.Any", None) + room_id_value: str = typing.cast("typing.Any", None) if "roomId" not in raw or raw["roomId"] is None: violations.append(Violation(path="roomId", reason="required")) else: - room_id_raw = raw["roomId"] - if not isinstance(room_id_raw, str): + room_id_value_raw = raw["roomId"] + if not isinstance(room_id_value_raw, str): violations.append(Violation(path="roomId", reason="expected string")) else: - room_id = room_id_raw + room_id_value = room_id_value_raw for key in raw: if key != "roomId": @@ -52,7 +52,7 @@ def from_transfer_type( if violations: raise ValidationError(violations) return GetRoomInput( - room_id=room_id, + room_id=room_id_value, ) @typing_extensions.override @@ -135,54 +135,54 @@ def from_transfer_type( raise ValidationError([Violation(path="", reason="expected object")]) raw = typing.cast("dict[str, typing.Any]", value) - kind: typing.Literal["text"] = typing.cast("typing.Any", None) + kind_value: typing.Literal["text"] = typing.cast("typing.Any", None) if "kind" not in raw or raw["kind"] is None: violations.append(Violation(path="kind", reason="required")) else: - kind_raw = raw["kind"] - if not isinstance(kind_raw, str): + kind_value_raw = raw["kind"] + if not isinstance(kind_value_raw, str): violations.append(Violation(path="kind", reason="expected string")) - elif kind_raw != "text": + elif kind_value_raw != "text": violations.append(Violation(path="kind", reason='must equal "text"')) else: - kind = kind_raw + kind_value = kind_value_raw - body: str = typing.cast("typing.Any", None) + body_value: str = typing.cast("typing.Any", None) if "body" not in raw or raw["body"] is None: violations.append(Violation(path="body", reason="required")) else: - body_raw = raw["body"] - if not isinstance(body_raw, str): + body_value_raw = raw["body"] + if not isinstance(body_value_raw, str): violations.append(Violation(path="body", reason="expected string")) else: - body = body_raw + body_value = body_value_raw - reply_to_id: str | None = None + reply_to_id_value: str | None = None if "replyToId" in raw: - reply_to_id_raw = raw["replyToId"] - if reply_to_id_raw is None: - reply_to_id = None + reply_to_id_value_raw = raw["replyToId"] + if reply_to_id_value_raw is None: + reply_to_id_value = None else: - if not isinstance(reply_to_id_raw, str): + if not isinstance(reply_to_id_value_raw, str): violations.append( Violation(path="replyToId", reason="expected string") ) else: - reply_to_id = reply_to_id_raw + reply_to_id_value = reply_to_id_value_raw - priority: int | None = None + priority_value: int | None = None if "priority" in raw: - priority_raw = raw["priority"] - if priority_raw is None: + priority_value_raw = raw["priority"] + if priority_value_raw is None: violations.append( Violation(path="priority", reason="explicit null not allowed") ) else: - priority_parsed = _parse_spec_integer( - priority_raw, "priority", violations + priority_value_parsed = _parse_spec_integer( + priority_value_raw, "priority", violations ) - if priority_parsed is not None: - priority = priority_parsed + if priority_value_parsed is not None: + priority_value = priority_value_parsed for key in raw: if ( @@ -195,10 +195,10 @@ def from_transfer_type( if violations: raise ValidationError(violations) return Message( - kind=kind, - body=body, - reply_to_id=reply_to_id, - priority=priority, + kind=kind_value, + body=body_value, + reply_to_id=reply_to_id_value, + priority=priority_value, ) @typing_extensions.override @@ -245,82 +245,83 @@ def from_transfer_type(self, value: typing.Any, type_hint: type["Room"]) -> "Roo raise ValidationError([Violation(path="", reason="expected object")]) raw = typing.cast("dict[str, typing.Any]", value) - room_id: str = typing.cast("typing.Any", None) + room_id_value: str = typing.cast("typing.Any", None) if "roomId" not in raw or raw["roomId"] is None: violations.append(Violation(path="roomId", reason="required")) else: - room_id_raw = raw["roomId"] - if not isinstance(room_id_raw, str): + room_id_value_raw = raw["roomId"] + if not isinstance(room_id_value_raw, str): violations.append(Violation(path="roomId", reason="expected string")) else: - room_id = room_id_raw + room_id_value = room_id_value_raw - display_name: str = typing.cast("typing.Any", None) + display_name_value: str = typing.cast("typing.Any", None) if "displayName" not in raw or raw["displayName"] is None: violations.append(Violation(path="displayName", reason="required")) else: - display_name_raw = raw["displayName"] - if not isinstance(display_name_raw, str): + display_name_value_raw = raw["displayName"] + if not isinstance(display_name_value_raw, str): violations.append( Violation(path="displayName", reason="expected string") ) else: - display_name = display_name_raw + display_name_value = display_name_value_raw - topic: str | None = None + topic_value: str | None = None if "topic" not in raw: violations.append(Violation(path="topic", reason="required")) else: - topic_raw = raw["topic"] - if topic_raw is None: - topic = None + topic_value_raw = raw["topic"] + if topic_value_raw is None: + topic_value = None else: - if not isinstance(topic_raw, str): + if not isinstance(topic_value_raw, str): violations.append(Violation(path="topic", reason="expected string")) else: - topic = topic_raw + topic_value = topic_value_raw - members: list[str] | None = None + members_value: list[str] | None = None if "members" in raw: - members_raw = raw["members"] - if members_raw is None: + members_value_raw = raw["members"] + if members_value_raw is None: violations.append( Violation(path="members", reason="explicit null not allowed") ) else: - if not isinstance(members_raw, list): + if not isinstance(members_value_raw, list): violations.append( Violation(path="members", reason="expected array") ) else: - members_list: list[str] = [] - for members_index, members_element in enumerate( - typing.cast("list[typing.Any]", members_raw) + members_value_list: list[str] = [] + for members_value_index, members_value_element in enumerate( + typing.cast("list[typing.Any]", members_value_raw) ): - members_item_path = f"members[{members_index}]" - members_item: str = typing.cast("typing.Any", None) - if not isinstance(members_element, str): + members_value_item_path = f"members[{members_value_index}]" + members_value_item: str = typing.cast("typing.Any", None) + if not isinstance(members_value_element, str): violations.append( Violation( - path=members_item_path, reason="expected element" + path=members_value_item_path, + reason="expected element", ) ) else: - members_item = members_element - members_list.append(members_item) - members = members_list + members_value_item = members_value_element + members_value_list.append(members_value_item) + members_value = members_value_list - labels: Labels | None = None + labels_value: Labels | None = None if "labels" in raw: - labels_raw = raw["labels"] - if labels_raw is None: + labels_value_raw = raw["labels"] + if labels_value_raw is None: violations.append( Violation(path="labels", reason="explicit null not allowed") ) else: try: - labels = _LabelsTransferTypeConverter().from_transfer_type( - labels_raw, Labels + labels_value = _LabelsTransferTypeConverter().from_transfer_type( + labels_value_raw, Labels ) except ValidationError as error: _collect(violations, "labels", error) @@ -332,16 +333,17 @@ def from_transfer_type(self, value: typing.Any, type_hint: type["Room"]) -> "Roo if violations: raise ValidationError(violations) return Room( - room_id=room_id, - display_name=display_name, - topic=topic, - members=members, - labels=labels, + room_id=room_id_value, + display_name=display_name_value, + topic=topic_value, + members=members_value, + labels=labels_value, additional_properties=additional_properties, ) @typing_extensions.override def to_transfer_type(self, value: "Room") -> typing.Any: + violations: list[Violation] = [] out: dict[str, typing.Any] = {} out["roomId"] = value.room_id out["displayName"] = value.display_name @@ -349,11 +351,16 @@ def to_transfer_type(self, value: "Room") -> typing.Any: if value.members is not None: out["members"] = value.members if value.labels is not None: - out["labels"] = _LabelsTransferTypeConverter().to_transfer_type( - value.labels - ) + try: + out["labels"] = _LabelsTransferTypeConverter().to_transfer_type( + value.labels + ) + except ValidationError as error: + _collect(violations, "labels", error) for key, entry in value.additional_properties.items(): out[key] = entry + if violations: + raise ValidationError(violations) return out @@ -390,24 +397,24 @@ def from_transfer_type( raise ValidationError([Violation(path="", reason="expected object")]) raw = typing.cast("dict[str, typing.Any]", value) - room_id: str = typing.cast("typing.Any", None) + room_id_value: str = typing.cast("typing.Any", None) if "roomId" not in raw or raw["roomId"] is None: violations.append(Violation(path="roomId", reason="required")) else: - room_id_raw = raw["roomId"] - if not isinstance(room_id_raw, str): + room_id_value_raw = raw["roomId"] + if not isinstance(room_id_value_raw, str): violations.append(Violation(path="roomId", reason="expected string")) else: - room_id = room_id_raw + room_id_value = room_id_value_raw - message: Message = typing.cast("typing.Any", None) + message_value: Message = typing.cast("typing.Any", None) if "message" not in raw or raw["message"] is None: violations.append(Violation(path="message", reason="required")) else: - message_raw = raw["message"] + message_value_raw = raw["message"] try: - message = _MessageTransferTypeConverter().from_transfer_type( - message_raw, Message + message_value = _MessageTransferTypeConverter().from_transfer_type( + message_value_raw, Message ) except ValidationError as error: _collect(violations, "message", error) @@ -418,15 +425,23 @@ def from_transfer_type( if violations: raise ValidationError(violations) return SendMessageInput( - room_id=room_id, - message=message, + room_id=room_id_value, + message=message_value, ) @typing_extensions.override def to_transfer_type(self, value: "SendMessageInput") -> typing.Any: + violations: list[Violation] = [] out: dict[str, typing.Any] = {} out["roomId"] = value.room_id - out["message"] = _MessageTransferTypeConverter().to_transfer_type(value.message) + try: + out["message"] = _MessageTransferTypeConverter().to_transfer_type( + value.message + ) + except ValidationError as error: + _collect(violations, "message", error) + if violations: + raise ValidationError(violations) return out @@ -452,15 +467,15 @@ def from_transfer_type( raise ValidationError([Violation(path="", reason="expected object")]) raw = typing.cast("dict[str, typing.Any]", value) - message_id: str = typing.cast("typing.Any", None) + message_id_value: str = typing.cast("typing.Any", None) if "messageId" not in raw or raw["messageId"] is None: violations.append(Violation(path="messageId", reason="required")) else: - message_id_raw = raw["messageId"] - if not isinstance(message_id_raw, str): + message_id_value_raw = raw["messageId"] + if not isinstance(message_id_value_raw, str): violations.append(Violation(path="messageId", reason="expected string")) else: - message_id = message_id_raw + message_id_value = message_id_value_raw for key in raw: if key != "messageId": @@ -468,7 +483,7 @@ def from_transfer_type( if violations: raise ValidationError(violations) return SendMessageOutput( - message_id=message_id, + message_id=message_id_value, ) @typing_extensions.override diff --git a/samples/python/kb/_definitions.py b/samples/python/kb/_definitions.py index f2b94492..3bf7f712 100644 --- a/samples/python/kb/_definitions.py +++ b/samples/python/kb/_definitions.py @@ -16,6 +16,9 @@ "ValidationError", "Violation", "_check_contains", + "_check_date_time", + "_check_duration", + "_check_time", "_check_unique_items", "_collect", "_format_base64", @@ -191,6 +194,13 @@ def _check_contains( r"^PT(?:[0-9]+H(?:[0-9]+M(?:[0-9]+S)?)?|[0-9]+M(?:[0-9]+S)?|[0-9]+S)$" ) _TEMPORAL_MAX_DURATION_SECONDS = ((1 << 63) - 1) // 1_000_000_000 +# A duration component with more digits than the cap itself is over the cap +# whatever those digits are, which is how the magnitude is bounded before `int()` +# sees it: CPython refuses to convert a string of more than 4300 digits. +_TEMPORAL_MAX_DURATION_DIGITS = len(str(_TEMPORAL_MAX_DURATION_SECONDS)) +# `datetime` resolves to microseconds, and `fromisoformat` before Python 3.11 +# parses only the fraction widths `isoformat` writes. +_TEMPORAL_FRACTION_DIGITS = 6 def _days_in_month(year: int, month: int) -> int: @@ -210,10 +220,57 @@ def _valid_temporal_calendar(value: str) -> bool: year, month, day = int(value[0:4]), int(value[5:7]), int(value[8:10]) except ValueError: return False + # `datetime.MINYEAR` is 1, so year 0000 -- which the wire grammar admits and + # the other three targets materialize -- has no Python value at all. It is + # rejected rather than shifted into range, and `_temporal_reason` says so. + if year < datetime.MINYEAR: + return False maximum = _days_in_month(year, month) return maximum > 0 and 1 <= day <= maximum +def _temporal_reason(name: str, value: str) -> str: + """The reason a rejected temporal string is reported under. + + Year 0000 earns its own clause: it is a valid wire value the other targets + accept, so a caller needs to read Python's floor rather than conclude the + timestamp was malformed. + """ + + if value[0:4] == "0000": + return ( + f"must be a valid {name}, got {_quote(value)}: year 0000 is not" + f" representable (datetime.MINYEAR is {datetime.MINYEAR})" + ) + return f"must be a valid {name}, got {_quote(value)}" + + +def _temporal_isoformat(value: str) -> str: + """Rewrites a wire temporal into the spelling `fromisoformat` accepts. + + `Z` becomes `+00:00`, and the fractional second is padded or truncated to + exactly `_TEMPORAL_FRACTION_DIGITS`: before Python 3.11 `fromisoformat` + parses only what `isoformat` writes, so an RFC 3339 `.1` or `.1234567` -- + which every other target accepts -- would otherwise raise. Digits past the + sixth are dropped, the loss at `datetime`'s own resolution that P1 allows; + the canonical output re-trims the padding, so `.1` still writes as `.1`. + """ + + normalized = value.upper() + if normalized.endswith("Z"): + normalized = normalized[:-1] + "+00:00" + dot = normalized.find(".") + if dot < 0: + return normalized + end = dot + 1 + while end < len(normalized) and normalized[end].isdigit(): + end += 1 + fraction = normalized[dot + 1 : end].ljust(_TEMPORAL_FRACTION_DIGITS, "0") + return ( + normalized[: dot + 1] + fraction[:_TEMPORAL_FRACTION_DIGITS] + normalized[end:] + ) + + def _parse_date_time( value: str, path: str, violations: list[Violation] ) -> datetime.datetime | None: @@ -221,24 +278,17 @@ def _parse_date_time( value ): violations.append( - Violation( - path=path, reason=f"must be a valid date-time, got {_quote(value)}" - ) + Violation(path=path, reason=_temporal_reason("date-time", value)) ) return None - normalized = value.upper() - if normalized.endswith("Z"): - normalized = normalized[:-1] + "+00:00" - return datetime.datetime.fromisoformat(normalized) + return datetime.datetime.fromisoformat(_temporal_isoformat(value)) def _parse_date( value: str, path: str, violations: list[Violation] ) -> datetime.date | None: if _TEMPORAL_DATE_RE.match(value) is None or not _valid_temporal_calendar(value): - violations.append( - Violation(path=path, reason=f"must be a valid date, got {_quote(value)}") - ) + violations.append(Violation(path=path, reason=_temporal_reason("date", value))) return None return datetime.date.fromisoformat(value) @@ -247,14 +297,9 @@ def _parse_time( value: str, path: str, violations: list[Violation] ) -> datetime.time | None: if _TEMPORAL_TIME_RE.match(value) is None: - violations.append( - Violation(path=path, reason=f"must be a valid time, got {_quote(value)}") - ) + violations.append(Violation(path=path, reason=_temporal_reason("time", value))) return None - normalized = value.upper() - if normalized.endswith("Z"): - normalized = normalized[:-1] + "+00:00" - return datetime.time.fromisoformat(normalized) + return datetime.time.fromisoformat(_temporal_isoformat(value)) def _parse_duration( @@ -262,9 +307,7 @@ def _parse_duration( ) -> datetime.timedelta | None: if _TEMPORAL_DURATION_RE.match(value) is None: violations.append( - Violation( - path=path, reason=f"must be a valid duration, got {_quote(value)}" - ) + Violation(path=path, reason=_temporal_reason("duration", value)) ) return None total = 0 @@ -273,18 +316,103 @@ def _parse_duration( if char.isdigit(): number += char continue - total += int(number) * {"H": 3600, "M": 60, "S": 1}[char] + digits = number.lstrip("0") number = "" + if len(digits) > _TEMPORAL_MAX_DURATION_DIGITS: + # Over the cap by digit count alone (see the constant), so the + # conversion `int()` would refuse is never attempted. + total = _TEMPORAL_MAX_DURATION_SECONDS + 1 + break + total += int(digits or "0") * {"H": 3600, "M": 60, "S": 1}[char] if total > _TEMPORAL_MAX_DURATION_SECONDS: - violations.append( - Violation( - path=path, reason=f"must be a valid duration, got {_quote(value)}" - ) - ) - return None + break + if total > _TEMPORAL_MAX_DURATION_SECONDS: + violations.append( + Violation(path=path, reason=_temporal_reason("duration", value)) + ) + return None return datetime.timedelta(seconds=total) +def _check_temporal_offset( + name: str, + value: datetime.datetime | datetime.time, + offset: datetime.timedelta, + path: str, + violations: list[Violation], +) -> None: + """Asserts a UTC offset is a whole number of minutes, the finest the wire + form spells (`tzinfo` allows seconds, which the offset would silently lose). + """ + + if offset % datetime.timedelta(minutes=1): + violations.append( + Violation( + path=path, + reason=( + f"must be a valid {name}, got {_quote(str(value))}: " + f"the UTC offset {offset} is not a whole number of minutes" + ), + ) + ) + + +def _check_date_time( + value: datetime.datetime, path: str, violations: list[Violation] +) -> None: + """Asserts a datetime is writable as a wire date-time (P12). + + A dataclass is constructed unchecked, so a naive datetime -- with no offset + the required wire form could carry -- reaches serialize; without this it + would emit a value this module's own parser rejects. + """ + + offset = value.utcoffset() + if offset is None: + violations.append( + Violation( + path=path, + reason=( + f"must be a valid date-time, got {_quote(str(value))}: " + "a naive datetime carries no UTC offset" + ), + ) + ) + return + _check_temporal_offset("date-time", value, offset, path, violations) + + +def _check_time(value: datetime.time, path: str, violations: list[Violation]) -> None: + """Asserts a time is writable as a wire time (P12). The offset is optional in + the grammar, so only its precision is held to anything.""" + + offset = value.utcoffset() + if offset is not None: + _check_temporal_offset("time", value, offset, path, violations) + + +def _check_duration( + value: datetime.timedelta, path: str, violations: list[Violation] +) -> None: + """Asserts a timedelta is writable as a wire duration (P12): the grammar is + unsigned, whole-second and capped, and a `timedelta` is none of those.""" + + if value < datetime.timedelta(0): + reason = "a duration cannot be negative" + elif value % datetime.timedelta(seconds=1): + reason = "a duration cannot carry a fraction of a second" + elif value.total_seconds() > _TEMPORAL_MAX_DURATION_SECONDS: + reason = f"a duration cannot exceed {_TEMPORAL_MAX_DURATION_SECONDS} seconds" + else: + return + violations.append( + Violation( + path=path, + reason=f"must be a valid duration, got {_quote(str(value))}: {reason}", + ) + ) + + def _temporal_frac(microsecond: int) -> str: if microsecond == 0: return "" diff --git a/samples/python/kb/_recursive.py b/samples/python/kb/_recursive.py index 1cac48ca..0d5b9f43 100644 --- a/samples/python/kb/_recursive.py +++ b/samples/python/kb/_recursive.py @@ -32,66 +32,70 @@ def from_transfer_type( raise ValidationError([Violation(path="", reason="expected object")]) raw = typing.cast("dict[str, typing.Any]", value) - block_id: str = typing.cast("typing.Any", None) + block_id_value: str = typing.cast("typing.Any", None) if "blockId" not in raw or raw["blockId"] is None: violations.append(Violation(path="blockId", reason="required")) else: - block_id_raw = raw["blockId"] - if not isinstance(block_id_raw, str): + block_id_value_raw = raw["blockId"] + if not isinstance(block_id_value_raw, str): violations.append(Violation(path="blockId", reason="expected string")) else: - block_id = block_id_raw + block_id_value = block_id_value_raw - order: int = typing.cast("typing.Any", None) + order_value: int = typing.cast("typing.Any", None) if "order" not in raw or raw["order"] is None: violations.append(Violation(path="order", reason="required")) else: - order_raw = raw["order"] - order_parsed = _parse_spec_integer(order_raw, "order", violations) - if order_parsed is not None: - order = order_parsed - if order < 0: + order_value_raw = raw["order"] + order_value_parsed = _parse_spec_integer( + order_value_raw, "order", violations + ) + if order_value_parsed is not None: + order_value = order_value_parsed + if order_value < 0: violations.append( - Violation(path="order", reason=f"must be >= 0, got {order}") + Violation( + path="order", reason=f"must be >= 0, got {order_value}" + ) ) - text: str | None = None + text_value: str | None = None if "text" in raw: - text_raw = raw["text"] - if text_raw is None: + text_value_raw = raw["text"] + if text_value_raw is None: violations.append( Violation(path="text", reason="explicit null not allowed") ) else: - if not isinstance(text_raw, str): + if not isinstance(text_value_raw, str): violations.append(Violation(path="text", reason="expected string")) else: - text = text_raw + text_value = text_value_raw - style: BlockStyle | None = None + style_value: BlockStyle | None = None if "style" in raw: - style_raw = raw["style"] - if style_raw is None: + style_value_raw = raw["style"] + if style_value_raw is None: violations.append( Violation(path="style", reason="explicit null not allowed") ) else: try: - style = getattr( + style_value = getattr( BlockStyle, "__temporal_transfer_type_converter" - ).from_transfer_type(style_raw, BlockStyle) + ).from_transfer_type(style_value_raw, BlockStyle) except ValidationError as error: _collect(violations, "style", error) - page: Page | None = None + page_value: Page | None = None if "page" in raw: - page_raw = raw["page"] - if page_raw is None: - page = None + page_value_raw = raw["page"] + if page_value_raw is None: + page_value = None else: try: - page = _PageTransferTypeConverter().from_transfer_type( - page_raw, Page + page_value = _PageTransferTypeConverter().from_transfer_type( + page_value_raw, Page ) except ValidationError as error: _collect(violations, "page", error) @@ -108,11 +112,11 @@ def from_transfer_type( if violations: raise ValidationError(violations) return Block( - block_id=block_id, - order=order, - text=text, - style=style, - page=page, + block_id=block_id_value, + order=order_value, + text=text_value, + style=style_value, + page=page_value, ) @typing_extensions.override @@ -128,11 +132,17 @@ def to_transfer_type(self, value: "Block") -> typing.Any: if value.text is not None: out["text"] = value.text if value.style is not None: - out["style"] = getattr( - BlockStyle, "__temporal_transfer_type_converter" - ).to_transfer_type(value.style) + try: + out["style"] = getattr( + BlockStyle, "__temporal_transfer_type_converter" + ).to_transfer_type(value.style) + except ValidationError as error: + _collect(violations, "style", error) if value.page is not None: - out["page"] = _PageTransferTypeConverter().to_transfer_type(value.page) + try: + out["page"] = _PageTransferTypeConverter().to_transfer_type(value.page) + except ValidationError as error: + _collect(violations, "page", error) if violations: raise ValidationError(violations) return out @@ -173,65 +183,65 @@ def from_transfer_type(self, value: typing.Any, type_hint: type["Page"]) -> "Pag raise ValidationError([Violation(path="", reason="expected object")]) raw = typing.cast("dict[str, typing.Any]", value) - page_id: str = typing.cast("typing.Any", None) + page_id_value: str = typing.cast("typing.Any", None) if "pageId" not in raw or raw["pageId"] is None: violations.append(Violation(path="pageId", reason="required")) else: - page_id_raw = raw["pageId"] - if not isinstance(page_id_raw, str): + page_id_value_raw = raw["pageId"] + if not isinstance(page_id_value_raw, str): violations.append(Violation(path="pageId", reason="expected string")) else: - page_id = page_id_raw + page_id_value = page_id_value_raw - title: str = typing.cast("typing.Any", None) + title_value: str = typing.cast("typing.Any", None) if "title" not in raw or raw["title"] is None: violations.append(Violation(path="title", reason="required")) else: - title_raw = raw["title"] - if not isinstance(title_raw, str): + title_value_raw = raw["title"] + if not isinstance(title_value_raw, str): violations.append(Violation(path="title", reason="expected string")) else: - title = title_raw + title_value = title_value_raw - meta: PageMeta = typing.cast("typing.Any", None) + meta_value: PageMeta = typing.cast("typing.Any", None) if "meta" not in raw or raw["meta"] is None: violations.append(Violation(path="meta", reason="required")) else: - meta_raw = raw["meta"] + meta_value_raw = raw["meta"] try: - meta = getattr( + meta_value = getattr( PageMeta, "__temporal_transfer_type_converter" - ).from_transfer_type(meta_raw, PageMeta) + ).from_transfer_type(meta_value_raw, PageMeta) except ValidationError as error: _collect(violations, "meta", error) - blocks: list[Block] | None = None + blocks_value: list[Block] | None = None if "blocks" in raw: - blocks_raw = raw["blocks"] - if blocks_raw is None: + blocks_value_raw = raw["blocks"] + if blocks_value_raw is None: violations.append( Violation(path="blocks", reason="explicit null not allowed") ) else: - if not isinstance(blocks_raw, list): + if not isinstance(blocks_value_raw, list): violations.append(Violation(path="blocks", reason="expected array")) else: - blocks_list: list[Block] = [] - for blocks_index, blocks_element in enumerate( - typing.cast("list[typing.Any]", blocks_raw) + blocks_value_list: list[Block] = [] + for blocks_value_index, blocks_value_element in enumerate( + typing.cast("list[typing.Any]", blocks_value_raw) ): - blocks_item_path = f"blocks[{blocks_index}]" - blocks_item: Block = typing.cast("typing.Any", None) + blocks_value_item_path = f"blocks[{blocks_value_index}]" + blocks_value_item: Block = typing.cast("typing.Any", None) try: - blocks_item = ( + blocks_value_item = ( _BlockTransferTypeConverter().from_transfer_type( - blocks_element, Block + blocks_value_element, Block ) ) except ValidationError as error: - _collect(violations, blocks_item_path, error) - blocks_list.append(blocks_item) - blocks = blocks_list + _collect(violations, blocks_value_item_path, error) + blocks_value_list.append(blocks_value_item) + blocks_value = blocks_value_list for key in raw: if key != "pageId" and key != "title" and key != "meta" and key != "blocks": @@ -239,25 +249,36 @@ def from_transfer_type(self, value: typing.Any, type_hint: type["Page"]) -> "Pag if violations: raise ValidationError(violations) return Page( - page_id=page_id, - title=title, - meta=meta, - blocks=blocks, + page_id=page_id_value, + title=title_value, + meta=meta_value, + blocks=blocks_value, ) @typing_extensions.override def to_transfer_type(self, value: "Page") -> typing.Any: + violations: list[Violation] = [] out: dict[str, typing.Any] = {} out["pageId"] = value.page_id out["title"] = value.title - out["meta"] = getattr( - PageMeta, "__temporal_transfer_type_converter" - ).to_transfer_type(value.meta) + try: + out["meta"] = getattr( + PageMeta, "__temporal_transfer_type_converter" + ).to_transfer_type(value.meta) + except ValidationError as error: + _collect(violations, "meta", error) if value.blocks is not None: - out["blocks"] = [ - _BlockTransferTypeConverter().to_transfer_type(element) - for element in value.blocks - ] + blocks_out: list[typing.Any] = [] + for blocks_index, blocks_element in enumerate(value.blocks): + try: + blocks_out.append( + _BlockTransferTypeConverter().to_transfer_type(blocks_element) + ) + except ValidationError as error: + _collect(violations, f"blocks[{blocks_index}]", error) + out["blocks"] = blocks_out + if violations: + raise ValidationError(violations) return out diff --git a/samples/python/kb/content/block/models.py b/samples/python/kb/content/block/models.py index 93ea5e5a..5d368d93 100644 --- a/samples/python/kb/content/block/models.py +++ b/samples/python/kb/content/block/models.py @@ -27,34 +27,37 @@ def from_transfer_type( raise ValidationError([Violation(path="", reason="expected object")]) raw = typing.cast("dict[str, typing.Any]", value) - bold: bool | None = None + bold_value: bool | None = None if "bold" in raw: - bold_raw = raw["bold"] - if bold_raw is None: + bold_value_raw = raw["bold"] + if bold_value_raw is None: violations.append( Violation(path="bold", reason="explicit null not allowed") ) else: - if not isinstance(bold_raw, bool): + if not isinstance(bold_value_raw, bool): violations.append(Violation(path="bold", reason="expected boolean")) else: - bold = bold_raw + bold_value = bold_value_raw - indent: int | None = None + indent_value: int | None = None if "indent" in raw: - indent_raw = raw["indent"] - if indent_raw is None: + indent_value_raw = raw["indent"] + if indent_value_raw is None: violations.append( Violation(path="indent", reason="explicit null not allowed") ) else: - indent_parsed = _parse_spec_integer(indent_raw, "indent", violations) - if indent_parsed is not None: - indent = indent_parsed - if indent < 0: + indent_value_parsed = _parse_spec_integer( + indent_value_raw, "indent", violations + ) + if indent_value_parsed is not None: + indent_value = indent_value_parsed + if indent_value < 0: violations.append( Violation( - path="indent", reason=f"must be >= 0, got {indent}" + path="indent", + reason=f"must be >= 0, got {indent_value}", ) ) @@ -64,8 +67,8 @@ def from_transfer_type( if violations: raise ValidationError(violations) return BlockStyle( - bold=bold, - indent=indent, + bold=bold_value, + indent=indent_value, ) @typing_extensions.override diff --git a/samples/python/kb/content/page/models.py b/samples/python/kb/content/page/models.py index 4704f8fa..5719f97f 100644 --- a/samples/python/kb/content/page/models.py +++ b/samples/python/kb/content/page/models.py @@ -27,29 +27,29 @@ def from_transfer_type( raise ValidationError([Violation(path="", reason="expected object")]) raw = typing.cast("dict[str, typing.Any]", value) - author: str = typing.cast("typing.Any", None) + author_value: str = typing.cast("typing.Any", None) if "author" not in raw or raw["author"] is None: violations.append(Violation(path="author", reason="required")) else: - author_raw = raw["author"] - if not isinstance(author_raw, str): + author_value_raw = raw["author"] + if not isinstance(author_value_raw, str): violations.append(Violation(path="author", reason="expected string")) else: - author = author_raw + author_value = author_value_raw - word_count: int | None = None + word_count_value: int | None = None if "wordCount" in raw: - word_count_raw = raw["wordCount"] - if word_count_raw is None: + word_count_value_raw = raw["wordCount"] + if word_count_value_raw is None: violations.append( Violation(path="wordCount", reason="explicit null not allowed") ) else: - word_count_parsed = _parse_spec_integer( - word_count_raw, "wordCount", violations + word_count_value_parsed = _parse_spec_integer( + word_count_value_raw, "wordCount", violations ) - if word_count_parsed is not None: - word_count = word_count_parsed + if word_count_value_parsed is not None: + word_count_value = word_count_value_parsed for key in raw: if key != "author" and key != "wordCount": @@ -57,8 +57,8 @@ def from_transfer_type( if violations: raise ValidationError(violations) return PageMeta( - author=author, - word_count=word_count, + author=author_value, + word_count=word_count_value, ) @typing_extensions.override diff --git a/samples/python/kb/kb/models.py b/samples/python/kb/kb/models.py index e40c9873..3a3a2b70 100644 --- a/samples/python/kb/kb/models.py +++ b/samples/python/kb/kb/models.py @@ -27,15 +27,15 @@ def from_transfer_type( raise ValidationError([Violation(path="", reason="expected object")]) raw = typing.cast("dict[str, typing.Any]", value) - root_id: str = typing.cast("typing.Any", None) + root_id_value: str = typing.cast("typing.Any", None) if "rootId" not in raw or raw["rootId"] is None: violations.append(Violation(path="rootId", reason="required")) else: - root_id_raw = raw["rootId"] - if not isinstance(root_id_raw, str): + root_id_value_raw = raw["rootId"] + if not isinstance(root_id_value_raw, str): violations.append(Violation(path="rootId", reason="expected string")) else: - root_id = root_id_raw + root_id_value = root_id_value_raw for key in raw: if key != "rootId": @@ -43,7 +43,7 @@ def from_transfer_type( if violations: raise ValidationError(violations) return GetCategoryTreeInput( - root_id=root_id, + root_id=root_id_value, ) @typing_extensions.override @@ -71,15 +71,15 @@ def from_transfer_type( raise ValidationError([Violation(path="", reason="expected object")]) raw = typing.cast("dict[str, typing.Any]", value) - page_id: str = typing.cast("typing.Any", None) + page_id_value: str = typing.cast("typing.Any", None) if "pageId" not in raw or raw["pageId"] is None: violations.append(Violation(path="pageId", reason="required")) else: - page_id_raw = raw["pageId"] - if not isinstance(page_id_raw, str): + page_id_value_raw = raw["pageId"] + if not isinstance(page_id_value_raw, str): violations.append(Violation(path="pageId", reason="expected string")) else: - page_id = page_id_raw + page_id_value = page_id_value_raw for key in raw: if key != "pageId": @@ -87,7 +87,7 @@ def from_transfer_type( if violations: raise ValidationError(violations) return GetPageInput( - page_id=page_id, + page_id=page_id_value, ) @typing_extensions.override @@ -115,24 +115,26 @@ def from_transfer_type( raise ValidationError([Violation(path="", reason="expected object")]) raw = typing.cast("dict[str, typing.Any]", value) - block_id: str = typing.cast("typing.Any", None) + block_id_value: str = typing.cast("typing.Any", None) if "blockId" not in raw or raw["blockId"] is None: violations.append(Violation(path="blockId", reason="required")) else: - block_id_raw = raw["blockId"] - if not isinstance(block_id_raw, str): + block_id_value_raw = raw["blockId"] + if not isinstance(block_id_value_raw, str): violations.append(Violation(path="blockId", reason="expected string")) else: - block_id = block_id_raw + block_id_value = block_id_value_raw - revision: int = typing.cast("typing.Any", None) + revision_value: int = typing.cast("typing.Any", None) if "revision" not in raw or raw["revision"] is None: violations.append(Violation(path="revision", reason="required")) else: - revision_raw = raw["revision"] - revision_parsed = _parse_spec_integer(revision_raw, "revision", violations) - if revision_parsed is not None: - revision = revision_parsed + revision_value_raw = raw["revision"] + revision_value_parsed = _parse_spec_integer( + revision_value_raw, "revision", violations + ) + if revision_value_parsed is not None: + revision_value = revision_value_parsed for key in raw: if key != "blockId" and key != "revision": @@ -140,8 +142,8 @@ def from_transfer_type( if violations: raise ValidationError(violations) return PutBlockOutput( - block_id=block_id, - revision=revision, + block_id=block_id_value, + revision=revision_value, ) @typing_extensions.override diff --git a/samples/python/kb/tree/category/models.py b/samples/python/kb/tree/category/models.py index 89642623..bf64975e 100644 --- a/samples/python/kb/tree/category/models.py +++ b/samples/python/kb/tree/category/models.py @@ -27,55 +27,55 @@ def from_transfer_type( raise ValidationError([Violation(path="", reason="expected object")]) raw = typing.cast("dict[str, typing.Any]", value) - id: str = typing.cast("typing.Any", None) + id_value: str = typing.cast("typing.Any", None) if "id" not in raw or raw["id"] is None: violations.append(Violation(path="id", reason="required")) else: - id_raw = raw["id"] - if not isinstance(id_raw, str): + id_value_raw = raw["id"] + if not isinstance(id_value_raw, str): violations.append(Violation(path="id", reason="expected string")) else: - id = id_raw + id_value = id_value_raw - name: str = typing.cast("typing.Any", None) + name_value: str = typing.cast("typing.Any", None) if "name" not in raw or raw["name"] is None: violations.append(Violation(path="name", reason="required")) else: - name_raw = raw["name"] - if not isinstance(name_raw, str): + name_value_raw = raw["name"] + if not isinstance(name_value_raw, str): violations.append(Violation(path="name", reason="expected string")) else: - name = name_raw + name_value = name_value_raw - children: list[Category] | None = None + children_value: list[Category] | None = None if "children" in raw: - children_raw = raw["children"] - if children_raw is None: + children_value_raw = raw["children"] + if children_value_raw is None: violations.append( Violation(path="children", reason="explicit null not allowed") ) else: - if not isinstance(children_raw, list): + if not isinstance(children_value_raw, list): violations.append( Violation(path="children", reason="expected array") ) else: - children_list: list[Category] = [] - for children_index, children_element in enumerate( - typing.cast("list[typing.Any]", children_raw) + children_value_list: list[Category] = [] + for children_value_index, children_value_element in enumerate( + typing.cast("list[typing.Any]", children_value_raw) ): - children_item_path = f"children[{children_index}]" - children_item: Category = typing.cast("typing.Any", None) + children_value_item_path = f"children[{children_value_index}]" + children_value_item: Category = typing.cast("typing.Any", None) try: - children_item = ( + children_value_item = ( _CategoryTransferTypeConverter().from_transfer_type( - children_element, Category + children_value_element, Category ) ) except ValidationError as error: - _collect(violations, children_item_path, error) - children_list.append(children_item) - children = children_list + _collect(violations, children_value_item_path, error) + children_value_list.append(children_value_item) + children_value = children_value_list for key in raw: if key != "id" and key != "name" and key != "children": @@ -83,21 +83,31 @@ def from_transfer_type( if violations: raise ValidationError(violations) return Category( - id=id, - name=name, - children=children, + id=id_value, + name=name_value, + children=children_value, ) @typing_extensions.override def to_transfer_type(self, value: "Category") -> typing.Any: + violations: list[Violation] = [] out: dict[str, typing.Any] = {} out["id"] = value.id out["name"] = value.name if value.children is not None: - out["children"] = [ - _CategoryTransferTypeConverter().to_transfer_type(element) - for element in value.children - ] + children_out: list[typing.Any] = [] + for children_index, children_element in enumerate(value.children): + try: + children_out.append( + _CategoryTransferTypeConverter().to_transfer_type( + children_element + ) + ) + except ValidationError as error: + _collect(violations, f"children[{children_index}]", error) + out["children"] = children_out + if violations: + raise ValidationError(violations) return out @@ -130,30 +140,30 @@ def from_transfer_type( raise ValidationError([Violation(path="", reason="expected object")]) raw = typing.cast("dict[str, typing.Any]", value) - swatches: list[str] = typing.cast("typing.Any", None) + swatches_value: list[str] = typing.cast("typing.Any", None) if "swatches" not in raw or raw["swatches"] is None: violations.append(Violation(path="swatches", reason="required")) else: - swatches_raw = raw["swatches"] - if not isinstance(swatches_raw, list): + swatches_value_raw = raw["swatches"] + if not isinstance(swatches_value_raw, list): violations.append(Violation(path="swatches", reason="expected array")) else: - swatches_list: list[str] = [] - for swatches_index, swatches_element in enumerate( - typing.cast("list[typing.Any]", swatches_raw) + swatches_value_list: list[str] = [] + for swatches_value_index, swatches_value_element in enumerate( + typing.cast("list[typing.Any]", swatches_value_raw) ): - swatches_item_path = f"swatches[{swatches_index}]" - swatches_item: str = typing.cast("typing.Any", None) - if not isinstance(swatches_element, str): + swatches_value_item_path = f"swatches[{swatches_value_index}]" + swatches_value_item: str = typing.cast("typing.Any", None) + if not isinstance(swatches_value_element, str): violations.append( Violation( - path=swatches_item_path, reason="expected element" + path=swatches_value_item_path, reason="expected element" ) ) else: - swatches_item = swatches_element - swatches_list.append(swatches_item) - swatches = swatches_list + swatches_value_item = swatches_value_element + swatches_value_list.append(swatches_value_item) + swatches_value = swatches_value_list for key in raw: if key != "swatches": @@ -161,7 +171,7 @@ def from_transfer_type( if violations: raise ValidationError(violations) return Palette( - swatches=swatches, + swatches=swatches_value, ) @typing_extensions.override diff --git a/samples/python/showcase/_definitions.py b/samples/python/showcase/_definitions.py index f2b94492..3bf7f712 100644 --- a/samples/python/showcase/_definitions.py +++ b/samples/python/showcase/_definitions.py @@ -16,6 +16,9 @@ "ValidationError", "Violation", "_check_contains", + "_check_date_time", + "_check_duration", + "_check_time", "_check_unique_items", "_collect", "_format_base64", @@ -191,6 +194,13 @@ def _check_contains( r"^PT(?:[0-9]+H(?:[0-9]+M(?:[0-9]+S)?)?|[0-9]+M(?:[0-9]+S)?|[0-9]+S)$" ) _TEMPORAL_MAX_DURATION_SECONDS = ((1 << 63) - 1) // 1_000_000_000 +# A duration component with more digits than the cap itself is over the cap +# whatever those digits are, which is how the magnitude is bounded before `int()` +# sees it: CPython refuses to convert a string of more than 4300 digits. +_TEMPORAL_MAX_DURATION_DIGITS = len(str(_TEMPORAL_MAX_DURATION_SECONDS)) +# `datetime` resolves to microseconds, and `fromisoformat` before Python 3.11 +# parses only the fraction widths `isoformat` writes. +_TEMPORAL_FRACTION_DIGITS = 6 def _days_in_month(year: int, month: int) -> int: @@ -210,10 +220,57 @@ def _valid_temporal_calendar(value: str) -> bool: year, month, day = int(value[0:4]), int(value[5:7]), int(value[8:10]) except ValueError: return False + # `datetime.MINYEAR` is 1, so year 0000 -- which the wire grammar admits and + # the other three targets materialize -- has no Python value at all. It is + # rejected rather than shifted into range, and `_temporal_reason` says so. + if year < datetime.MINYEAR: + return False maximum = _days_in_month(year, month) return maximum > 0 and 1 <= day <= maximum +def _temporal_reason(name: str, value: str) -> str: + """The reason a rejected temporal string is reported under. + + Year 0000 earns its own clause: it is a valid wire value the other targets + accept, so a caller needs to read Python's floor rather than conclude the + timestamp was malformed. + """ + + if value[0:4] == "0000": + return ( + f"must be a valid {name}, got {_quote(value)}: year 0000 is not" + f" representable (datetime.MINYEAR is {datetime.MINYEAR})" + ) + return f"must be a valid {name}, got {_quote(value)}" + + +def _temporal_isoformat(value: str) -> str: + """Rewrites a wire temporal into the spelling `fromisoformat` accepts. + + `Z` becomes `+00:00`, and the fractional second is padded or truncated to + exactly `_TEMPORAL_FRACTION_DIGITS`: before Python 3.11 `fromisoformat` + parses only what `isoformat` writes, so an RFC 3339 `.1` or `.1234567` -- + which every other target accepts -- would otherwise raise. Digits past the + sixth are dropped, the loss at `datetime`'s own resolution that P1 allows; + the canonical output re-trims the padding, so `.1` still writes as `.1`. + """ + + normalized = value.upper() + if normalized.endswith("Z"): + normalized = normalized[:-1] + "+00:00" + dot = normalized.find(".") + if dot < 0: + return normalized + end = dot + 1 + while end < len(normalized) and normalized[end].isdigit(): + end += 1 + fraction = normalized[dot + 1 : end].ljust(_TEMPORAL_FRACTION_DIGITS, "0") + return ( + normalized[: dot + 1] + fraction[:_TEMPORAL_FRACTION_DIGITS] + normalized[end:] + ) + + def _parse_date_time( value: str, path: str, violations: list[Violation] ) -> datetime.datetime | None: @@ -221,24 +278,17 @@ def _parse_date_time( value ): violations.append( - Violation( - path=path, reason=f"must be a valid date-time, got {_quote(value)}" - ) + Violation(path=path, reason=_temporal_reason("date-time", value)) ) return None - normalized = value.upper() - if normalized.endswith("Z"): - normalized = normalized[:-1] + "+00:00" - return datetime.datetime.fromisoformat(normalized) + return datetime.datetime.fromisoformat(_temporal_isoformat(value)) def _parse_date( value: str, path: str, violations: list[Violation] ) -> datetime.date | None: if _TEMPORAL_DATE_RE.match(value) is None or not _valid_temporal_calendar(value): - violations.append( - Violation(path=path, reason=f"must be a valid date, got {_quote(value)}") - ) + violations.append(Violation(path=path, reason=_temporal_reason("date", value))) return None return datetime.date.fromisoformat(value) @@ -247,14 +297,9 @@ def _parse_time( value: str, path: str, violations: list[Violation] ) -> datetime.time | None: if _TEMPORAL_TIME_RE.match(value) is None: - violations.append( - Violation(path=path, reason=f"must be a valid time, got {_quote(value)}") - ) + violations.append(Violation(path=path, reason=_temporal_reason("time", value))) return None - normalized = value.upper() - if normalized.endswith("Z"): - normalized = normalized[:-1] + "+00:00" - return datetime.time.fromisoformat(normalized) + return datetime.time.fromisoformat(_temporal_isoformat(value)) def _parse_duration( @@ -262,9 +307,7 @@ def _parse_duration( ) -> datetime.timedelta | None: if _TEMPORAL_DURATION_RE.match(value) is None: violations.append( - Violation( - path=path, reason=f"must be a valid duration, got {_quote(value)}" - ) + Violation(path=path, reason=_temporal_reason("duration", value)) ) return None total = 0 @@ -273,18 +316,103 @@ def _parse_duration( if char.isdigit(): number += char continue - total += int(number) * {"H": 3600, "M": 60, "S": 1}[char] + digits = number.lstrip("0") number = "" + if len(digits) > _TEMPORAL_MAX_DURATION_DIGITS: + # Over the cap by digit count alone (see the constant), so the + # conversion `int()` would refuse is never attempted. + total = _TEMPORAL_MAX_DURATION_SECONDS + 1 + break + total += int(digits or "0") * {"H": 3600, "M": 60, "S": 1}[char] if total > _TEMPORAL_MAX_DURATION_SECONDS: - violations.append( - Violation( - path=path, reason=f"must be a valid duration, got {_quote(value)}" - ) - ) - return None + break + if total > _TEMPORAL_MAX_DURATION_SECONDS: + violations.append( + Violation(path=path, reason=_temporal_reason("duration", value)) + ) + return None return datetime.timedelta(seconds=total) +def _check_temporal_offset( + name: str, + value: datetime.datetime | datetime.time, + offset: datetime.timedelta, + path: str, + violations: list[Violation], +) -> None: + """Asserts a UTC offset is a whole number of minutes, the finest the wire + form spells (`tzinfo` allows seconds, which the offset would silently lose). + """ + + if offset % datetime.timedelta(minutes=1): + violations.append( + Violation( + path=path, + reason=( + f"must be a valid {name}, got {_quote(str(value))}: " + f"the UTC offset {offset} is not a whole number of minutes" + ), + ) + ) + + +def _check_date_time( + value: datetime.datetime, path: str, violations: list[Violation] +) -> None: + """Asserts a datetime is writable as a wire date-time (P12). + + A dataclass is constructed unchecked, so a naive datetime -- with no offset + the required wire form could carry -- reaches serialize; without this it + would emit a value this module's own parser rejects. + """ + + offset = value.utcoffset() + if offset is None: + violations.append( + Violation( + path=path, + reason=( + f"must be a valid date-time, got {_quote(str(value))}: " + "a naive datetime carries no UTC offset" + ), + ) + ) + return + _check_temporal_offset("date-time", value, offset, path, violations) + + +def _check_time(value: datetime.time, path: str, violations: list[Violation]) -> None: + """Asserts a time is writable as a wire time (P12). The offset is optional in + the grammar, so only its precision is held to anything.""" + + offset = value.utcoffset() + if offset is not None: + _check_temporal_offset("time", value, offset, path, violations) + + +def _check_duration( + value: datetime.timedelta, path: str, violations: list[Violation] +) -> None: + """Asserts a timedelta is writable as a wire duration (P12): the grammar is + unsigned, whole-second and capped, and a `timedelta` is none of those.""" + + if value < datetime.timedelta(0): + reason = "a duration cannot be negative" + elif value % datetime.timedelta(seconds=1): + reason = "a duration cannot carry a fraction of a second" + elif value.total_seconds() > _TEMPORAL_MAX_DURATION_SECONDS: + reason = f"a duration cannot exceed {_TEMPORAL_MAX_DURATION_SECONDS} seconds" + else: + return + violations.append( + Violation( + path=path, + reason=f"must be a valid duration, got {_quote(str(value))}: {reason}", + ) + ) + + def _temporal_frac(microsecond: int) -> str: if microsecond == 0: return "" diff --git a/samples/python/showcase/models.py b/samples/python/showcase/models.py index dce10b2b..e621b9b3 100644 --- a/samples/python/showcase/models.py +++ b/samples/python/showcase/models.py @@ -113,40 +113,40 @@ def from_transfer_type( raise ValidationError([Violation(path="", reason="expected object")]) raw = typing.cast("dict[str, typing.Any]", value) - street: str = typing.cast("typing.Any", None) + street_value: str = typing.cast("typing.Any", None) if "street" not in raw or raw["street"] is None: violations.append(Violation(path="street", reason="required")) else: - street_raw = raw["street"] - if not isinstance(street_raw, str): + street_value_raw = raw["street"] + if not isinstance(street_value_raw, str): violations.append(Violation(path="street", reason="expected string")) else: - street = street_raw + street_value = street_value_raw - city: str | None = None + city_value: str | None = None if "city" in raw: - city_raw = raw["city"] - if city_raw is None: + city_value_raw = raw["city"] + if city_value_raw is None: violations.append( Violation(path="city", reason="explicit null not allowed") ) else: - if not isinstance(city_raw, str): + if not isinstance(city_value_raw, str): violations.append(Violation(path="city", reason="expected string")) else: - city = city_raw + city_value = city_value_raw - zip: int | None = None + zip_value: int | None = None if "zip" in raw: - zip_raw = raw["zip"] - if zip_raw is None: + zip_value_raw = raw["zip"] + if zip_value_raw is None: violations.append( Violation(path="zip", reason="explicit null not allowed") ) else: - zip_parsed = _parse_spec_integer(zip_raw, "zip", violations) - if zip_parsed is not None: - zip = zip_parsed + zip_value_parsed = _parse_spec_integer(zip_value_raw, "zip", violations) + if zip_value_parsed is not None: + zip_value = zip_value_parsed additional_properties: dict[str, typing.Any] = {} for key in raw: @@ -155,9 +155,9 @@ def from_transfer_type( if violations: raise ValidationError(violations) return Address( - street=street, - city=city, - zip=zip, + street=street_value, + city=city_value, + zip=zip_value, additional_properties=additional_properties, ) @@ -303,9 +303,15 @@ def from_transfer_type( @typing_extensions.override def to_transfer_type(self, value: "Choices") -> typing.Any: + violations: list[Violation] = [] out: dict[str, typing.Any] = {} for key, entry in value.additional_properties.items(): - out[key] = _choices_value_to_transfer_type(entry) + try: + out[key] = _choices_value_to_transfer_type(entry) + except ValidationError as error: + _collect(violations, key, error) + if violations: + raise ValidationError(violations) return out @@ -335,30 +341,41 @@ def from_transfer_type( raise ValidationError([Violation(path="", reason="expected object")]) raw = typing.cast("dict[str, typing.Any]", value) - kind: typing.Literal["circle"] = typing.cast("typing.Any", None) + kind_value: typing.Literal["circle"] = typing.cast("typing.Any", None) if "kind" not in raw or raw["kind"] is None: violations.append(Violation(path="kind", reason="required")) else: - kind_raw = raw["kind"] - if not isinstance(kind_raw, str): + kind_value_raw = raw["kind"] + if not isinstance(kind_value_raw, str): violations.append(Violation(path="kind", reason="expected string")) - elif kind_raw != "circle": + elif kind_value_raw != "circle": violations.append(Violation(path="kind", reason='must equal "circle"')) else: - kind = kind_raw + kind_value = kind_value_raw - radius: float = typing.cast("typing.Any", None) + radius_value: float = typing.cast("typing.Any", None) if "radius" not in raw or raw["radius"] is None: violations.append(Violation(path="radius", reason="required")) else: - radius_raw = raw["radius"] + radius_value_raw = raw["radius"] if not ( - not isinstance(radius_raw, bool) - and isinstance(radius_raw, (int, float)) + not isinstance(radius_value_raw, bool) + and isinstance(radius_value_raw, (int, float)) ): violations.append(Violation(path="radius", reason="expected number")) else: - radius = radius_raw + radius_value = radius_value_raw + if not ( + -1.7976931348623157e308 + <= radius_value_raw + <= 1.7976931348623157e308 + ): + violations.append( + Violation( + path="radius", + reason=f"must be a finite number, got {radius_value_raw}", + ) + ) additional_properties: dict[str, typing.Any] = {} for key in raw: @@ -367,8 +384,8 @@ def from_transfer_type( if violations: raise ValidationError(violations) return Circle( - kind=kind, - radius=radius, + kind=kind_value, + radius=radius_value, additional_properties=additional_properties, ) @@ -379,6 +396,12 @@ def to_transfer_type(self, value: "Circle") -> typing.Any: if typing.cast("object", value.kind) not in ("circle",): violations.append(Violation(path="kind", reason='must equal "circle"')) out["kind"] = value.kind + if not (-1.7976931348623157e308 <= value.radius <= 1.7976931348623157e308): + violations.append( + Violation( + path="radius", reason=f"must be a finite number, got {value.radius}" + ) + ) out["radius"] = value.radius for key, entry in value.additional_properties.items(): out[key] = entry @@ -413,48 +436,48 @@ def from_transfer_type( raise ValidationError([Violation(path="", reason="expected object")]) raw = typing.cast("dict[str, typing.Any]", value) - email: str | None = None + email_value: str | None = None if "email" in raw: - email_raw = raw["email"] - if email_raw is None: + email_value_raw = raw["email"] + if email_value_raw is None: violations.append( Violation(path="email", reason="explicit null not allowed") ) else: - if not isinstance(email_raw, str): + if not isinstance(email_value_raw, str): violations.append(Violation(path="email", reason="expected string")) else: - email = email_raw + email_value = email_value_raw - shipping_street: str | None = None + shipping_street_value: str | None = None if "shippingStreet" in raw: - shipping_street_raw = raw["shippingStreet"] - if shipping_street_raw is None: + shipping_street_value_raw = raw["shippingStreet"] + if shipping_street_value_raw is None: violations.append( Violation(path="shippingStreet", reason="explicit null not allowed") ) else: - if not isinstance(shipping_street_raw, str): + if not isinstance(shipping_street_value_raw, str): violations.append( Violation(path="shippingStreet", reason="expected string") ) else: - shipping_street = shipping_street_raw + shipping_street_value = shipping_street_value_raw - shipping_zip: str | None = None + shipping_zip_value: str | None = None if "shippingZip" in raw: - shipping_zip_raw = raw["shippingZip"] - if shipping_zip_raw is None: + shipping_zip_value_raw = raw["shippingZip"] + if shipping_zip_value_raw is None: violations.append( Violation(path="shippingZip", reason="explicit null not allowed") ) else: - if not isinstance(shipping_zip_raw, str): + if not isinstance(shipping_zip_value_raw, str): violations.append( Violation(path="shippingZip", reason="expected string") ) else: - shipping_zip = shipping_zip_raw + shipping_zip_value = shipping_zip_value_raw additional_properties: dict[str, typing.Any] = {} for key in raw: @@ -483,9 +506,9 @@ def from_transfer_type( if violations: raise ValidationError(violations) return ContactPy( - email=email, - shipping_street=shipping_street, - shipping_zip=shipping_zip, + email=email_value, + shipping_street=shipping_street_value, + shipping_zip=shipping_zip_value, additional_properties=additional_properties, ) @@ -670,32 +693,32 @@ def from_transfer_type( raise ValidationError([Violation(path="", reason="expected object")]) raw = typing.cast("dict[str, typing.Any]", value) - kind: typing.Literal["link"] = typing.cast("typing.Any", None) + kind_value: typing.Literal["link"] = typing.cast("typing.Any", None) if "kind" not in raw or raw["kind"] is None: violations.append(Violation(path="kind", reason="required")) else: - kind_raw = raw["kind"] - if not isinstance(kind_raw, str): + kind_value_raw = raw["kind"] + if not isinstance(kind_value_raw, str): violations.append(Violation(path="kind", reason="expected string")) - elif kind_raw != "link": + elif kind_value_raw != "link": violations.append(Violation(path="kind", reason='must equal "link"')) else: - kind = kind_raw + kind_value = kind_value_raw - href: str = typing.cast("typing.Any", None) + href_value: str = typing.cast("typing.Any", None) if "href" not in raw or raw["href"] is None: violations.append(Violation(path="href", reason="required")) else: - href_raw = raw["href"] - if not isinstance(href_raw, str): + href_value_raw = raw["href"] + if not isinstance(href_value_raw, str): violations.append(Violation(path="href", reason="expected string")) else: - href = href_raw - if len(href_raw) < 1: + href_value = href_value_raw + if len(href_value_raw) < 1: violations.append( Violation( path="href", - reason=f"must have length >= 1, got {len(href_raw)}", + reason=f"must have length >= 1, got {len(href_value_raw)}", ) ) @@ -706,8 +729,8 @@ def from_transfer_type( if violations: raise ValidationError(violations) return LinkNote( - kind=kind, - href=href, + kind=kind_value, + href=href_value, additional_properties=additional_properties, ) @@ -895,32 +918,32 @@ def from_transfer_type( raise ValidationError([Violation(path="", reason="expected object")]) raw = typing.cast("dict[str, typing.Any]", value) - theme: str | None = None + theme_value: str | None = None if "theme" in raw: - theme_raw = raw["theme"] - if theme_raw is None: + theme_value_raw = raw["theme"] + if theme_value_raw is None: violations.append( Violation(path="theme", reason="explicit null not allowed") ) else: - if not isinstance(theme_raw, str): + if not isinstance(theme_value_raw, str): violations.append(Violation(path="theme", reason="expected string")) else: - theme = theme_raw + theme_value = theme_value_raw - font_size: int | None = None + font_size_value: int | None = None if "fontSize" in raw: - font_size_raw = raw["fontSize"] - if font_size_raw is None: + font_size_value_raw = raw["fontSize"] + if font_size_value_raw is None: violations.append( Violation(path="fontSize", reason="explicit null not allowed") ) else: - font_size_parsed = _parse_spec_integer( - font_size_raw, "fontSize", violations + font_size_value_parsed = _parse_spec_integer( + font_size_value_raw, "fontSize", violations ) - if font_size_parsed is not None: - font_size = font_size_parsed + if font_size_value_parsed is not None: + font_size_value = font_size_value_parsed for key in raw: if key != "theme" and key != "fontSize": @@ -928,8 +951,8 @@ def from_transfer_type( if violations: raise ValidationError(violations) return Settings( - theme=theme, - font_size=font_size, + theme=theme_value, + font_size=font_size_value, ) @typing_extensions.override @@ -964,681 +987,713 @@ def from_transfer_type( raise ValidationError([Violation(path="", reason="expected object")]) raw = typing.cast("dict[str, typing.Any]", value) - kind: typing.Literal["showcase"] = typing.cast("typing.Any", None) + kind_value: typing.Literal["showcase"] = typing.cast("typing.Any", None) if "kind" not in raw or raw["kind"] is None: violations.append(Violation(path="kind", reason="required")) else: - kind_raw = raw["kind"] - if not isinstance(kind_raw, str): + kind_value_raw = raw["kind"] + if not isinstance(kind_value_raw, str): violations.append(Violation(path="kind", reason="expected string")) - elif kind_raw != "showcase": + elif kind_value_raw != "showcase": violations.append( Violation(path="kind", reason='must equal "showcase"') ) else: - kind = kind_raw + kind_value = kind_value_raw - revision: typing.Literal[1] = typing.cast("typing.Any", None) + revision_value: typing.Literal[1] = typing.cast("typing.Any", None) if "revision" not in raw or raw["revision"] is None: violations.append(Violation(path="revision", reason="required")) else: - revision_raw = raw["revision"] - if not ( - not isinstance(revision_raw, bool) - and isinstance(revision_raw, (int, float)) - ): - violations.append(Violation(path="revision", reason="expected number")) - elif revision_raw != 1: - violations.append(Violation(path="revision", reason="must equal 1")) - else: - revision = typing.cast("typing.Literal[1]", revision_raw) + revision_value_raw = raw["revision"] + revision_value_parsed = _parse_spec_integer( + revision_value_raw, "revision", violations + ) + if revision_value_parsed is not None: + if revision_value_parsed != 1: + violations.append(Violation(path="revision", reason="must equal 1")) + else: + revision_value = revision_value_parsed - enabled: typing.Literal[True] = typing.cast("typing.Any", None) + enabled_value: typing.Literal[True] = typing.cast("typing.Any", None) if "enabled" not in raw or raw["enabled"] is None: violations.append(Violation(path="enabled", reason="required")) else: - enabled_raw = raw["enabled"] - if not isinstance(enabled_raw, bool): + enabled_value_raw = raw["enabled"] + if not isinstance(enabled_value_raw, bool): violations.append(Violation(path="enabled", reason="expected boolean")) - elif enabled_raw != True: + elif enabled_value_raw != True: violations.append(Violation(path="enabled", reason="must equal true")) else: - enabled = enabled_raw + enabled_value = enabled_value_raw - status: typing.Literal["active", "inactive", "pending"] = typing.cast( + status_value: typing.Literal["active", "inactive", "pending"] = typing.cast( "typing.Any", None ) if "status" not in raw or raw["status"] is None: violations.append(Violation(path="status", reason="required")) else: - status_raw = raw["status"] - if not isinstance(status_raw, str): + status_value_raw = raw["status"] + if not isinstance(status_value_raw, str): violations.append(Violation(path="status", reason="expected string")) elif ( - status_raw != "active" - and status_raw != "inactive" - and status_raw != "pending" + status_value_raw != "active" + and status_value_raw != "inactive" + and status_value_raw != "pending" ): violations.append( Violation( path="status", - reason=f'must be one of ["active", "inactive", "pending"], got {_quote(status_raw)}', + reason=f'must be one of ["active", "inactive", "pending"], got {_quote(status_value_raw)}', ) ) else: - status = status_raw + status_value = status_value_raw - tier: typing.Literal[1, 2, 3] = typing.cast("typing.Any", None) + tier_value: typing.Literal[1, 2, 3] = typing.cast("typing.Any", None) if "tier" not in raw or raw["tier"] is None: violations.append(Violation(path="tier", reason="required")) else: - tier_raw = raw["tier"] - if not ( - not isinstance(tier_raw, bool) and isinstance(tier_raw, (int, float)) - ): - violations.append(Violation(path="tier", reason="expected number")) - elif tier_raw != 1 and tier_raw != 2 and tier_raw != 3: - violations.append( - Violation( - path="tier", - reason=f"must be one of [1, 2, 3], got {_quote(tier_raw)}", + tier_value_raw = raw["tier"] + tier_value_parsed = _parse_spec_integer(tier_value_raw, "tier", violations) + if tier_value_parsed is not None: + if ( + tier_value_parsed != 1 + and tier_value_parsed != 2 + and tier_value_parsed != 3 + ): + violations.append( + Violation( + path="tier", + reason=f"must be one of [1, 2, 3], got {_quote(tier_value_parsed)}", + ) ) - ) - else: - tier = typing.cast("typing.Literal[1, 2, 3]", tier_raw) + else: + tier_value = tier_value_parsed - scale: float = typing.cast("typing.Any", None) + scale_value: float = typing.cast("typing.Any", None) if "scale" not in raw or raw["scale"] is None: violations.append(Violation(path="scale", reason="required")) else: - scale_raw = raw["scale"] + scale_value_raw = raw["scale"] if not ( - not isinstance(scale_raw, bool) and isinstance(scale_raw, (int, float)) + not isinstance(scale_value_raw, bool) + and isinstance(scale_value_raw, (int, float)) ): violations.append(Violation(path="scale", reason="expected number")) - elif scale_raw != 1.5 and scale_raw != 2.5: + elif scale_value_raw != 1.5 and scale_value_raw != 2.5: violations.append( Violation( path="scale", - reason=f"must be one of [1.5, 2.5], got {_quote(scale_raw)}", + reason=f"must be one of [1.5, 2.5], got {_quote(scale_value_raw)}", ) ) else: - scale = scale_raw + scale_value = scale_value_raw - name: str = typing.cast("typing.Any", None) + name_value: str = typing.cast("typing.Any", None) if "name" not in raw or raw["name"] is None: violations.append(Violation(path="name", reason="required")) else: - name_raw = raw["name"] - if not isinstance(name_raw, str): + name_value_raw = raw["name"] + if not isinstance(name_value_raw, str): violations.append(Violation(path="name", reason="expected string")) else: - name = name_raw - if len(name_raw) < 1: + name_value = name_value_raw + if len(name_value_raw) < 1: violations.append( Violation( path="name", - reason=f"must have length >= 1, got {len(name_raw)}", + reason=f"must have length >= 1, got {len(name_value_raw)}", ) ) - if len(name_raw) > 64: + if len(name_value_raw) > 64: violations.append( Violation( path="name", - reason=f"must have length <= 64, got {len(name_raw)}", + reason=f"must have length <= 64, got {len(name_value_raw)}", ) ) - count: int = typing.cast("typing.Any", None) + count_value: int = typing.cast("typing.Any", None) if "count" not in raw or raw["count"] is None: violations.append(Violation(path="count", reason="required")) else: - count_raw = raw["count"] - count_parsed = _parse_spec_integer(count_raw, "count", violations) - if count_parsed is not None: - count = count_parsed + count_value_raw = raw["count"] + count_value_parsed = _parse_spec_integer( + count_value_raw, "count", violations + ) + if count_value_parsed is not None: + count_value = count_value_parsed - active: bool = typing.cast("typing.Any", None) + active_value: bool = typing.cast("typing.Any", None) if "active" not in raw or raw["active"] is None: violations.append(Violation(path="active", reason="required")) else: - active_raw = raw["active"] - if not isinstance(active_raw, bool): + active_value_raw = raw["active"] + if not isinstance(active_value_raw, bool): violations.append(Violation(path="active", reason="expected boolean")) else: - active = active_raw + active_value = active_value_raw - nickname: str | None = None + nickname_value: str | None = None if "nickname" in raw: - nickname_raw = raw["nickname"] - if nickname_raw is None: + nickname_value_raw = raw["nickname"] + if nickname_value_raw is None: violations.append( Violation(path="nickname", reason="explicit null not allowed") ) else: - if not isinstance(nickname_raw, str): + if not isinstance(nickname_value_raw, str): violations.append( Violation(path="nickname", reason="expected string") ) else: - nickname = nickname_raw - if len(nickname_raw) > 12: + nickname_value = nickname_value_raw + if len(nickname_value_raw) > 12: violations.append( Violation( path="nickname", - reason=f"must have length <= 12, got {len(nickname_raw)}", + reason=f"must have length <= 12, got {len(nickname_value_raw)}", ) ) - code: str | None = None + code_value: str | None = None if "code" in raw: - code_raw = raw["code"] - if code_raw is None: + code_value_raw = raw["code"] + if code_value_raw is None: violations.append( Violation(path="code", reason="explicit null not allowed") ) else: - if not isinstance(code_raw, str): + if not isinstance(code_value_raw, str): violations.append(Violation(path="code", reason="expected string")) else: - code = code_raw - if len(code_raw) < 2: + code_value = code_value_raw + if len(code_value_raw) < 2: violations.append( Violation( path="code", - reason=f"must have length >= 2, got {len(code_raw)}", + reason=f"must have length >= 2, got {len(code_value_raw)}", ) ) - if len(code_raw) > 5: + if len(code_value_raw) > 5: violations.append( Violation( path="code", - reason=f"must have length <= 5, got {len(code_raw)}", + reason=f"must have length <= 5, got {len(code_value_raw)}", ) ) - sku: str | None = None + sku_value: str | None = None if "sku" in raw: - sku_raw = raw["sku"] - if sku_raw is None: + sku_value_raw = raw["sku"] + if sku_value_raw is None: violations.append( Violation(path="sku", reason="explicit null not allowed") ) else: - if not isinstance(sku_raw, str): + if not isinstance(sku_value_raw, str): violations.append(Violation(path="sku", reason="expected string")) else: - sku = sku_raw - if _PATTERN_CD24623C0C29CA35.search(sku_raw) is None: + sku_value = sku_value_raw + if _PATTERN_CD24623C0C29CA35.search(sku_value_raw) is None: violations.append( Violation( path="sku", - reason=f"must match pattern {_PATTERN_CD24623C0C29CA35.pattern}, got {_quote(sku_raw)}", + reason=f"must match pattern {_PATTERN_CD24623C0C29CA35.pattern}, got {_quote(sku_value_raw)}", ) ) - phrase: str | None = None + phrase_value: str | None = None if "phrase" in raw: - phrase_raw = raw["phrase"] - if phrase_raw is None: + phrase_value_raw = raw["phrase"] + if phrase_value_raw is None: violations.append( Violation(path="phrase", reason="explicit null not allowed") ) else: - if not isinstance(phrase_raw, str): + if not isinstance(phrase_value_raw, str): violations.append( Violation(path="phrase", reason="expected string") ) else: - phrase = phrase_raw - if _PATTERN_B4BA2CA20EB1B963.search(phrase_raw) is None: + phrase_value = phrase_value_raw + if _PATTERN_B4BA2CA20EB1B963.search(phrase_value_raw) is None: violations.append( Violation( path="phrase", - reason=f"must match pattern {_PATTERN_B4BA2CA20EB1B963.pattern}, got {_quote(phrase_raw)}", + reason=f"must match pattern {_PATTERN_B4BA2CA20EB1B963.pattern}, got {_quote(phrase_value_raw)}", ) ) - request_id: str | None = None + request_id_value: str | None = None if "requestId" in raw: - request_id_raw = raw["requestId"] - if request_id_raw is None: + request_id_value_raw = raw["requestId"] + if request_id_value_raw is None: violations.append( Violation(path="requestId", reason="explicit null not allowed") ) else: - if not isinstance(request_id_raw, str): + if not isinstance(request_id_value_raw, str): violations.append( Violation(path="requestId", reason="expected string") ) else: - request_id = request_id_raw - if _PATTERN_EAAFA3F3BF5456C8.search(request_id_raw) is None: + request_id_value = request_id_value_raw + if _PATTERN_EAAFA3F3BF5456C8.search(request_id_value_raw) is None: violations.append( Violation( path="requestId", - reason=f"must be a valid uuid, got {_quote(request_id_raw)}", + reason=f"must be a valid uuid, got {_quote(request_id_value_raw)}", ) ) - contact_email: str | None = None + contact_email_value: str | None = None if "contactEmail" in raw: - contact_email_raw = raw["contactEmail"] - if contact_email_raw is None: + contact_email_value_raw = raw["contactEmail"] + if contact_email_value_raw is None: violations.append( Violation(path="contactEmail", reason="explicit null not allowed") ) else: - if not isinstance(contact_email_raw, str): + if not isinstance(contact_email_value_raw, str): violations.append( Violation(path="contactEmail", reason="expected string") ) else: - contact_email = contact_email_raw + contact_email_value = contact_email_value_raw if ( - len(contact_email_raw) > 254 - or _PATTERN_67B8088E6C41E9D2.search(contact_email_raw) is None + len(contact_email_value_raw) > 254 + or _PATTERN_67B8088E6C41E9D2.search(contact_email_value_raw) + is None ): violations.append( Violation( path="contactEmail", - reason=f"must be a valid email, got {_quote(contact_email_raw)}", + reason=f"must be a valid email, got {_quote(contact_email_value_raw)}", ) ) - host: str | None = None + host_value: str | None = None if "host" in raw: - host_raw = raw["host"] - if host_raw is None: + host_value_raw = raw["host"] + if host_value_raw is None: violations.append( Violation(path="host", reason="explicit null not allowed") ) else: - if not isinstance(host_raw, str): + if not isinstance(host_value_raw, str): violations.append(Violation(path="host", reason="expected string")) else: - host = host_raw + host_value = host_value_raw if ( - len(host_raw) > 253 - or _PATTERN_C3551EE088DD1057.search(host_raw) is None + len(host_value_raw) > 253 + or _PATTERN_C3551EE088DD1057.search(host_value_raw) is None ): violations.append( Violation( path="host", - reason=f"must be a valid hostname, got {_quote(host_raw)}", + reason=f"must be a valid hostname, got {_quote(host_value_raw)}", ) ) - homepage: str | None = None + homepage_value: str | None = None if "homepage" in raw: - homepage_raw = raw["homepage"] - if homepage_raw is None: + homepage_value_raw = raw["homepage"] + if homepage_value_raw is None: violations.append( Violation(path="homepage", reason="explicit null not allowed") ) else: - if not isinstance(homepage_raw, str): + if not isinstance(homepage_value_raw, str): violations.append( Violation(path="homepage", reason="expected string") ) else: - homepage = homepage_raw - if _PATTERN_BECE32B4DA20247D.search(homepage_raw) is None: + homepage_value = homepage_value_raw + if _PATTERN_BECE32B4DA20247D.search(homepage_value_raw) is None: violations.append( Violation( path="homepage", - reason=f"must be a valid uri, got {_quote(homepage_raw)}", + reason=f"must be a valid uri, got {_quote(homepage_value_raw)}", ) ) - gateway: str | None = None + gateway_value: str | None = None if "gateway" in raw: - gateway_raw = raw["gateway"] - if gateway_raw is None: + gateway_value_raw = raw["gateway"] + if gateway_value_raw is None: violations.append( Violation(path="gateway", reason="explicit null not allowed") ) else: - if not isinstance(gateway_raw, str): + if not isinstance(gateway_value_raw, str): violations.append( Violation(path="gateway", reason="expected string") ) else: - gateway = gateway_raw - if _PATTERN_4A45C0D214B9083D.search(gateway_raw) is None: + gateway_value = gateway_value_raw + if _PATTERN_4A45C0D214B9083D.search(gateway_value_raw) is None: violations.append( Violation( path="gateway", - reason=f"must be a valid ipv4, got {_quote(gateway_raw)}", + reason=f"must be a valid ipv4, got {_quote(gateway_value_raw)}", ) ) - blob: bytes | None = None + blob_value: bytes | None = None if "blob" in raw: - blob_raw = raw["blob"] - if blob_raw is None: + blob_value_raw = raw["blob"] + if blob_value_raw is None: violations.append( Violation(path="blob", reason="explicit null not allowed") ) else: - if not isinstance(blob_raw, str): + if not isinstance(blob_value_raw, str): violations.append(Violation(path="blob", reason="expected string")) else: - blob_parsed = _parse_base64(blob_raw, "blob", violations) - if blob_parsed is not None: - blob = blob_parsed + blob_value_parsed = _parse_base64( + blob_value_raw, "blob", violations + ) + if blob_value_parsed is not None: + blob_value = blob_value_parsed - url_blob: bytes | None = None + url_blob_value: bytes | None = None if "urlBlob" in raw: - url_blob_raw = raw["urlBlob"] - if url_blob_raw is None: + url_blob_value_raw = raw["urlBlob"] + if url_blob_value_raw is None: violations.append( Violation(path="urlBlob", reason="explicit null not allowed") ) else: - if not isinstance(url_blob_raw, str): + if not isinstance(url_blob_value_raw, str): violations.append( Violation(path="urlBlob", reason="expected string") ) else: - url_blob_parsed = _parse_base64url( - url_blob_raw, "urlBlob", violations + url_blob_value_parsed = _parse_base64url( + url_blob_value_raw, "urlBlob", violations ) - if url_blob_parsed is not None: - url_blob = url_blob_parsed + if url_blob_value_parsed is not None: + url_blob_value = url_blob_value_parsed - retries: int | None = None + retries_value: int | None = None if "retries" in raw: - retries_raw = raw["retries"] - if retries_raw is None: + retries_value_raw = raw["retries"] + if retries_value_raw is None: violations.append( Violation(path="retries", reason="explicit null not allowed") ) else: - retries_parsed = _parse_spec_integer(retries_raw, "retries", violations) - if retries_parsed is not None: - retries = retries_parsed + retries_value_parsed = _parse_spec_integer( + retries_value_raw, "retries", violations + ) + if retries_value_parsed is not None: + retries_value = retries_value_parsed - verbose: bool | None = None + verbose_value: bool | None = None if "verbose" in raw: - verbose_raw = raw["verbose"] - if verbose_raw is None: + verbose_value_raw = raw["verbose"] + if verbose_value_raw is None: violations.append( Violation(path="verbose", reason="explicit null not allowed") ) else: - if not isinstance(verbose_raw, bool): + if not isinstance(verbose_value_raw, bool): violations.append( Violation(path="verbose", reason="expected boolean") ) else: - verbose = verbose_raw + verbose_value = verbose_value_raw - greeting: str | None = None + greeting_value: str | None = None if "greeting" in raw: - greeting_raw = raw["greeting"] - if greeting_raw is None: + greeting_value_raw = raw["greeting"] + if greeting_value_raw is None: violations.append( Violation(path="greeting", reason="explicit null not allowed") ) else: - if not isinstance(greeting_raw, str): + if not isinstance(greeting_value_raw, str): violations.append( Violation(path="greeting", reason="expected string") ) else: - greeting = greeting_raw + greeting_value = greeting_value_raw - debug: bool | None = None + debug_value: bool | None = None if "debug" in raw: - debug_raw = raw["debug"] - if debug_raw is None: + debug_value_raw = raw["debug"] + if debug_value_raw is None: violations.append( Violation(path="debug", reason="explicit null not allowed") ) else: - if not isinstance(debug_raw, bool): + if not isinstance(debug_value_raw, bool): violations.append( Violation(path="debug", reason="expected boolean") ) else: - debug = debug_raw + debug_value = debug_value_raw - legacy_id_py: str | None = None + legacy_id_py_value: str | None = None if "legacyId" in raw: - legacy_id_py_raw = raw["legacyId"] - if legacy_id_py_raw is None: + legacy_id_py_value_raw = raw["legacyId"] + if legacy_id_py_value_raw is None: violations.append( Violation(path="legacyId", reason="explicit null not allowed") ) else: - if not isinstance(legacy_id_py_raw, str): + if not isinstance(legacy_id_py_value_raw, str): violations.append( Violation(path="legacyId", reason="expected string") ) else: - legacy_id_py = legacy_id_py_raw + legacy_id_py_value = legacy_id_py_value_raw - middle_name: str | None = None + middle_name_value: str | None = None if "middleName" in raw: - middle_name_raw = raw["middleName"] - if middle_name_raw is None: - middle_name = None + middle_name_value_raw = raw["middleName"] + if middle_name_value_raw is None: + middle_name_value = None else: - if not isinstance(middle_name_raw, str): + if not isinstance(middle_name_value_raw, str): violations.append( Violation(path="middleName", reason="expected string") ) else: - middle_name = middle_name_raw + middle_name_value = middle_name_value_raw - category: str | None = None + category_value: str | None = None if "category" not in raw: violations.append(Violation(path="category", reason="required")) else: - category_raw = raw["category"] - if category_raw is None: - category = None + category_value_raw = raw["category"] + if category_value_raw is None: + category_value = None else: - if not isinstance(category_raw, str): + if not isinstance(category_value_raw, str): violations.append( Violation(path="category", reason="expected string") ) else: - category = category_raw + category_value = category_value_raw - priority: int | None = None + priority_value: int | None = None if "priority" in raw: - priority_raw = raw["priority"] - if priority_raw is None: + priority_value_raw = raw["priority"] + if priority_value_raw is None: violations.append( Violation(path="priority", reason="explicit null not allowed") ) else: - priority_parsed = _parse_spec_integer( - priority_raw, "priority", violations + priority_value_parsed = _parse_spec_integer( + priority_value_raw, "priority", violations ) - if priority_parsed is not None: - priority = priority_parsed - if priority < 1: + if priority_value_parsed is not None: + priority_value = priority_value_parsed + if priority_value < 1: violations.append( Violation( - path="priority", reason=f"must be >= 1, got {priority}" + path="priority", + reason=f"must be >= 1, got {priority_value}", ) ) - if priority > 10: + if priority_value > 10: violations.append( Violation( - path="priority", reason=f"must be <= 10, got {priority}" + path="priority", + reason=f"must be <= 10, got {priority_value}", ) ) - level: int | None = None + level_value: int | None = None if "level" in raw: - level_raw = raw["level"] - if level_raw is None: + level_value_raw = raw["level"] + if level_value_raw is None: violations.append( Violation(path="level", reason="explicit null not allowed") ) else: - level_parsed = _parse_spec_integer(level_raw, "level", violations) - if level_parsed is not None: - level = level_parsed - if level <= 0: + level_value_parsed = _parse_spec_integer( + level_value_raw, "level", violations + ) + if level_value_parsed is not None: + level_value = level_value_parsed + if level_value <= 0: violations.append( - Violation(path="level", reason=f"must be > 0, got {level}") + Violation( + path="level", reason=f"must be > 0, got {level_value}" + ) ) - ratio: float | None = None + ratio_value: float | None = None if "ratio" in raw: - ratio_raw = raw["ratio"] - if ratio_raw is None: + ratio_value_raw = raw["ratio"] + if ratio_value_raw is None: violations.append( Violation(path="ratio", reason="explicit null not allowed") ) else: if not ( - not isinstance(ratio_raw, bool) - and isinstance(ratio_raw, (int, float)) + not isinstance(ratio_value_raw, bool) + and isinstance(ratio_value_raw, (int, float)) ): violations.append(Violation(path="ratio", reason="expected number")) else: - ratio = ratio_raw - if ratio_raw < 5: - violations.append( - Violation( - path="ratio", reason=f"must be >= 5, got {ratio_raw}" - ) - ) - if math.fmod(ratio_raw, 5) != 0: + ratio_value = ratio_value_raw + if not ( + -1.7976931348623157e308 + <= ratio_value_raw + <= 1.7976931348623157e308 + ): violations.append( Violation( path="ratio", - reason=f"must be a multiple of 5, got {ratio_raw}", + reason=f"must be a finite number, got {ratio_value_raw}", ) ) + else: + if ratio_value_raw < 5: + violations.append( + Violation( + path="ratio", + reason=f"must be >= 5, got {ratio_value_raw}", + ) + ) + if math.fmod(ratio_value_raw, 5) != 0: + violations.append( + Violation( + path="ratio", + reason=f"must be a multiple of 5, got {ratio_value_raw}", + ) + ) - step: int | None = None + step_value: int | None = None if "step" in raw: - step_raw = raw["step"] - if step_raw is None: + step_value_raw = raw["step"] + if step_value_raw is None: violations.append( Violation(path="step", reason="explicit null not allowed") ) else: - step_parsed = _parse_spec_integer(step_raw, "step", violations) - if step_parsed is not None: - step = step_parsed - if step % 3 != 0: + step_value_parsed = _parse_spec_integer( + step_value_raw, "step", violations + ) + if step_value_parsed is not None: + step_value = step_value_parsed + if step_value % 3 != 0: violations.append( Violation( path="step", - reason=f"must be a multiple of 3, got {step}", + reason=f"must be a multiple of 3, got {step_value}", ) ) - tags: list[str] | None = None + tags_value: list[str] | None = None if "tags" in raw: - tags_raw = raw["tags"] - if tags_raw is None: + tags_value_raw = raw["tags"] + if tags_value_raw is None: violations.append( Violation(path="tags", reason="explicit null not allowed") ) else: - if not isinstance(tags_raw, list): + if not isinstance(tags_value_raw, list): violations.append(Violation(path="tags", reason="expected array")) else: - tags_list: list[str] = [] - for tags_index, tags_element in enumerate( - typing.cast("list[typing.Any]", tags_raw) + tags_value_list: list[str] = [] + for tags_value_index, tags_value_element in enumerate( + typing.cast("list[typing.Any]", tags_value_raw) ): - tags_item_path = f"tags[{tags_index}]" - tags_item: str = typing.cast("typing.Any", None) - if not isinstance(tags_element, str): + tags_value_item_path = f"tags[{tags_value_index}]" + tags_value_item: str = typing.cast("typing.Any", None) + if not isinstance(tags_value_element, str): violations.append( Violation( - path=tags_item_path, reason="expected element" + path=tags_value_item_path, reason="expected element" ) ) else: - tags_item = tags_element - tags_list.append(tags_item) - if len(tags_list) < 1: + tags_value_item = tags_value_element + tags_value_list.append(tags_value_item) + if len(tags_value_list) < 1: violations.append( Violation( path="tags", - reason=f"must have at least 1 items, got {len(tags_list)}", + reason=f"must have at least 1 items, got {len(tags_value_list)}", ) ) - if len(tags_list) > 5: + if len(tags_value_list) > 5: violations.append( Violation( path="tags", - reason=f"must have at most 5 items, got {len(tags_list)}", + reason=f"must have at most 5 items, got {len(tags_value_list)}", ) ) - tags = tags_list + tags_value = tags_value_list - aliases: list[str] | None = None + aliases_value: list[str] | None = None if "aliases" in raw: - aliases_raw = raw["aliases"] - if aliases_raw is None: + aliases_value_raw = raw["aliases"] + if aliases_value_raw is None: violations.append( Violation(path="aliases", reason="explicit null not allowed") ) else: - if not isinstance(aliases_raw, list): + if not isinstance(aliases_value_raw, list): violations.append( Violation(path="aliases", reason="expected array") ) else: - aliases_list: list[str] = [] - for aliases_index, aliases_element in enumerate( - typing.cast("list[typing.Any]", aliases_raw) + aliases_value_list: list[str] = [] + for aliases_value_index, aliases_value_element in enumerate( + typing.cast("list[typing.Any]", aliases_value_raw) ): - aliases_item_path = f"aliases[{aliases_index}]" - aliases_item: str = typing.cast("typing.Any", None) - if not isinstance(aliases_element, str): + aliases_value_item_path = f"aliases[{aliases_value_index}]" + aliases_value_item: str = typing.cast("typing.Any", None) + if not isinstance(aliases_value_element, str): violations.append( Violation( - path=aliases_item_path, reason="expected element" + path=aliases_value_item_path, + reason="expected element", ) ) else: - aliases_item = aliases_element - aliases_list.append(aliases_item) - _check_unique_items(aliases_list, "aliases", violations) - aliases = aliases_list + aliases_value_item = aliases_value_element + aliases_value_list.append(aliases_value_item) + _check_unique_items(aliases_value_list, "aliases", violations) + aliases_value = aliases_value_list - roles: list[str] | None = None + roles_value: list[str] | None = None if "roles" in raw: - roles_raw = raw["roles"] - if roles_raw is None: + roles_value_raw = raw["roles"] + if roles_value_raw is None: violations.append( Violation(path="roles", reason="explicit null not allowed") ) else: - if not isinstance(roles_raw, list): + if not isinstance(roles_value_raw, list): violations.append(Violation(path="roles", reason="expected array")) else: - roles_list: list[str] = [] - for roles_index, roles_element in enumerate( - typing.cast("list[typing.Any]", roles_raw) + roles_value_list: list[str] = [] + for roles_value_index, roles_value_element in enumerate( + typing.cast("list[typing.Any]", roles_value_raw) ): - roles_item_path = f"roles[{roles_index}]" - roles_item: str = typing.cast("typing.Any", None) - if not isinstance(roles_element, str): + roles_value_item_path = f"roles[{roles_value_index}]" + roles_value_item: str = typing.cast("typing.Any", None) + if not isinstance(roles_value_element, str): violations.append( Violation( - path=roles_item_path, reason="expected element" + path=roles_value_item_path, + reason="expected element", ) ) else: - roles_item = roles_element - roles_list.append(roles_item) + roles_value_item = roles_value_element + roles_value_list.append(roles_value_item) _check_contains( - roles_list, + roles_value_list, lambda element: element == "admin", 1, 2, @@ -1646,479 +1701,507 @@ def from_transfer_type( "roles", violations, ) - roles = roles_list + roles_value = roles_value_list - id_or_name: str | int | None = None + id_or_name_value: str | int | None = None if "idOrName" in raw: - id_or_name_raw = raw["idOrName"] - if id_or_name_raw is None: + id_or_name_value_raw = raw["idOrName"] + if id_or_name_value_raw is None: violations.append( Violation(path="idOrName", reason="explicit null not allowed") ) else: - id_or_name_parsed = _showcase_id_or_name_from_transfer_type( - id_or_name_raw, "idOrName", violations + id_or_name_value_parsed = _showcase_id_or_name_from_transfer_type( + id_or_name_value_raw, "idOrName", violations ) - if id_or_name_parsed is not None: - id_or_name = id_or_name_parsed + if id_or_name_value_parsed is not None: + id_or_name_value = id_or_name_value_parsed - mode: typing.Literal["auto", "manual"] | int | None = None + mode_value: typing.Literal["auto", "manual"] | int | None = None if "mode" in raw: - mode_raw = raw["mode"] - if mode_raw is None: + mode_value_raw = raw["mode"] + if mode_value_raw is None: violations.append( Violation(path="mode", reason="explicit null not allowed") ) else: - mode_parsed = _showcase_mode_from_transfer_type( - mode_raw, "mode", violations + mode_value_parsed = _showcase_mode_from_transfer_type( + mode_value_raw, "mode", violations ) - if mode_parsed is not None: - mode = mode_parsed + if mode_value_parsed is not None: + mode_value = mode_value_parsed - payload: dict[str, typing.Any] | str | None = None + payload_value: dict[str, typing.Any] | str | None = None if "payload" in raw: - payload_raw = raw["payload"] - if payload_raw is None: + payload_value_raw = raw["payload"] + if payload_value_raw is None: violations.append( Violation(path="payload", reason="explicit null not allowed") ) else: - payload_parsed = _showcase_payload_from_transfer_type( - payload_raw, "payload", violations + payload_value_parsed = _showcase_payload_from_transfer_type( + payload_value_raw, "payload", violations ) - if payload_parsed is not None: - payload = payload_parsed + if payload_value_parsed is not None: + payload_value = payload_value_parsed - detail: ShowcaseDetailObject | str | None = None + detail_value: ShowcaseDetailObject | str | None = None if "detail" in raw: - detail_raw = raw["detail"] - if detail_raw is None: + detail_value_raw = raw["detail"] + if detail_value_raw is None: violations.append( Violation(path="detail", reason="explicit null not allowed") ) else: - detail_parsed = _showcase_detail_from_transfer_type( - detail_raw, "detail", violations + detail_value_parsed = _showcase_detail_from_transfer_type( + detail_value_raw, "detail", violations ) - if detail_parsed is not None: - detail = detail_parsed + if detail_value_parsed is not None: + detail_value = detail_value_parsed - shape_or_name: Circle | Square | str | None = None + shape_or_name_value: Circle | Square | str | None = None if "shapeOrName" in raw: - shape_or_name_raw = raw["shapeOrName"] - if shape_or_name_raw is None: + shape_or_name_value_raw = raw["shapeOrName"] + if shape_or_name_value_raw is None: violations.append( Violation(path="shapeOrName", reason="explicit null not allowed") ) else: - shape_or_name_parsed = _showcase_shape_or_name_from_transfer_type( - shape_or_name_raw, "shapeOrName", violations + shape_or_name_value_parsed = _showcase_shape_or_name_from_transfer_type( + shape_or_name_value_raw, "shapeOrName", violations ) - if shape_or_name_parsed is not None: - shape_or_name = shape_or_name_parsed + if shape_or_name_value_parsed is not None: + shape_or_name_value = shape_or_name_value_parsed - measurements: list[float] | str | None = None + measurements_value: list[float] | str | None = None if "measurements" in raw: - measurements_raw = raw["measurements"] - if measurements_raw is None: + measurements_value_raw = raw["measurements"] + if measurements_value_raw is None: violations.append( Violation(path="measurements", reason="explicit null not allowed") ) else: - measurements_parsed = _showcase_measurements_from_transfer_type( - measurements_raw, "measurements", violations + measurements_value_parsed = _showcase_measurements_from_transfer_type( + measurements_value_raw, "measurements", violations ) - if measurements_parsed is not None: - measurements = measurements_parsed + if measurements_value_parsed is not None: + measurements_value = measurements_value_parsed - shapes: list[Shape] | None = None + shapes_value: list[Shape] | None = None if "shapes" in raw: - shapes_raw = raw["shapes"] - if shapes_raw is None: + shapes_value_raw = raw["shapes"] + if shapes_value_raw is None: violations.append( Violation(path="shapes", reason="explicit null not allowed") ) else: - if not isinstance(shapes_raw, list): + if not isinstance(shapes_value_raw, list): violations.append(Violation(path="shapes", reason="expected array")) else: - shapes_list: list[Shape] = [] - for shapes_index, shapes_element in enumerate( - typing.cast("list[typing.Any]", shapes_raw) + shapes_value_list: list[Shape] = [] + for shapes_value_index, shapes_value_element in enumerate( + typing.cast("list[typing.Any]", shapes_value_raw) ): - shapes_item_path = f"shapes[{shapes_index}]" - shapes_item: Shape = typing.cast("typing.Any", None) - shapes_item_parsed = _shape_from_transfer_type( - shapes_element, shapes_item_path, violations + shapes_value_item_path = f"shapes[{shapes_value_index}]" + shapes_value_item: Shape = typing.cast("typing.Any", None) + shapes_value_item_parsed = _shape_from_transfer_type( + shapes_value_element, shapes_value_item_path, violations ) - if shapes_item_parsed is not None: - shapes_item = shapes_item_parsed - shapes_list.append(shapes_item) - shapes = shapes_list + if shapes_value_item_parsed is not None: + shapes_value_item = shapes_value_item_parsed + shapes_value_list.append(shapes_value_item) + shapes_value = shapes_value_list - segments: list[ShowcaseSegmentsItem] | None = None + segments_value: list[ShowcaseSegmentsItem] | None = None if "segments" in raw: - segments_raw = raw["segments"] - if segments_raw is None: + segments_value_raw = raw["segments"] + if segments_value_raw is None: violations.append( Violation(path="segments", reason="explicit null not allowed") ) else: - if not isinstance(segments_raw, list): + if not isinstance(segments_value_raw, list): violations.append( Violation(path="segments", reason="expected array") ) else: - segments_list: list[ShowcaseSegmentsItem] = [] - for segments_index, segments_element in enumerate( - typing.cast("list[typing.Any]", segments_raw) + segments_value_list: list[ShowcaseSegmentsItem] = [] + for segments_value_index, segments_value_element in enumerate( + typing.cast("list[typing.Any]", segments_value_raw) ): - segments_item_path = f"segments[{segments_index}]" - segments_item: ShowcaseSegmentsItem = typing.cast( + segments_value_item_path = f"segments[{segments_value_index}]" + segments_value_item: ShowcaseSegmentsItem = typing.cast( "typing.Any", None ) - segments_item_parsed = ( + segments_value_item_parsed = ( _showcase_segments_item_from_transfer_type( - segments_element, segments_item_path, violations + segments_value_element, + segments_value_item_path, + violations, ) ) - if segments_item_parsed is not None: - segments_item = segments_item_parsed - segments_list.append(segments_item) - segments = segments_list + if segments_value_item_parsed is not None: + segments_value_item = segments_value_item_parsed + segments_value_list.append(segments_value_item) + segments_value = segments_value_list - slots: list[str | None] | None = None + slots_value: list[str | None] | None = None if "slots" in raw: - slots_raw = raw["slots"] - if slots_raw is None: + slots_value_raw = raw["slots"] + if slots_value_raw is None: violations.append( Violation(path="slots", reason="explicit null not allowed") ) else: - if not isinstance(slots_raw, list): + if not isinstance(slots_value_raw, list): violations.append(Violation(path="slots", reason="expected array")) else: - slots_list: list[str | None] = [] - for slots_index, slots_element in enumerate( - typing.cast("list[typing.Any]", slots_raw) + slots_value_list: list[str | None] = [] + for slots_value_index, slots_value_element in enumerate( + typing.cast("list[typing.Any]", slots_value_raw) ): - slots_item_path = f"slots[{slots_index}]" - slots_item: str | None = None - if slots_element is None: - slots_item = None + slots_value_item_path = f"slots[{slots_value_index}]" + slots_value_item: str | None = None + if slots_value_element is None: + slots_value_item = None else: - if not isinstance(slots_element, str): + if not isinstance(slots_value_element, str): violations.append( Violation( - path=slots_item_path, reason="expected string" + path=slots_value_item_path, + reason="expected string", ) ) else: - slots_item = slots_element - slots_list.append(slots_item) - slots = slots_list + slots_value_item = slots_value_element + slots_value_list.append(slots_value_item) + slots_value = slots_value_list - grid: list[list[int]] | None = None + grid_value: list[list[int]] | None = None if "grid" in raw: - grid_raw = raw["grid"] - if grid_raw is None: + grid_value_raw = raw["grid"] + if grid_value_raw is None: violations.append( Violation(path="grid", reason="explicit null not allowed") ) else: - if not isinstance(grid_raw, list): + if not isinstance(grid_value_raw, list): violations.append(Violation(path="grid", reason="expected array")) else: - grid_list: list[list[int]] = [] - for grid_index, grid_element in enumerate( - typing.cast("list[typing.Any]", grid_raw) + grid_value_list: list[list[int]] = [] + for grid_value_index, grid_value_element in enumerate( + typing.cast("list[typing.Any]", grid_value_raw) ): - grid_item_path = f"grid[{grid_index}]" - grid_item: list[int] = typing.cast("typing.Any", None) - if not isinstance(grid_element, list): + grid_value_item_path = f"grid[{grid_value_index}]" + grid_value_item: list[int] = typing.cast("typing.Any", None) + if not isinstance(grid_value_element, list): violations.append( - Violation(path=grid_item_path, reason="expected array") + Violation( + path=grid_value_item_path, reason="expected array" + ) ) else: - grid_item_list: list[int] = [] - for grid_item_index, grid_item_element in enumerate( - typing.cast("list[typing.Any]", grid_element) + grid_value_item_list: list[int] = [] + for ( + grid_value_item_index, + grid_value_item_element, + ) in enumerate( + typing.cast("list[typing.Any]", grid_value_element) ): - grid_item_item_path = ( - f"{grid_item_path}[{grid_item_index}]" + grid_value_item_item_path = ( + f"{grid_value_item_path}[{grid_value_item_index}]" ) - grid_item_item: int = typing.cast("typing.Any", None) - grid_item_item_parsed = _parse_spec_integer( - grid_item_element, grid_item_item_path, violations + grid_value_item_item: int = typing.cast( + "typing.Any", None ) - if grid_item_item_parsed is not None: - grid_item_item = grid_item_item_parsed - grid_item_list.append(grid_item_item) - grid_item = grid_item_list - grid_list.append(grid_item) - grid = grid_list - - location: ShowcaseLocation | None = None + grid_value_item_item_parsed = _parse_spec_integer( + grid_value_item_element, + grid_value_item_item_path, + violations, + ) + if grid_value_item_item_parsed is not None: + grid_value_item_item = grid_value_item_item_parsed + grid_value_item_list.append(grid_value_item_item) + grid_value_item = grid_value_item_list + grid_value_list.append(grid_value_item) + grid_value = grid_value_list + + location_value: ShowcaseLocation | None = None if "location" in raw: - location_raw = raw["location"] - if location_raw is None: + location_value_raw = raw["location"] + if location_value_raw is None: violations.append( Violation(path="location", reason="explicit null not allowed") ) else: try: - location = ( + location_value = ( _ShowcaseLocationTransferTypeConverter().from_transfer_type( - location_raw, ShowcaseLocation + location_value_raw, ShowcaseLocation ) ) except ValidationError as error: _collect(violations, "location", error) - audit: ShowcaseAudit | None = None + audit_value: ShowcaseAudit | None = None if "audit" in raw: - audit_raw = raw["audit"] - if audit_raw is None: - audit = None + audit_value_raw = raw["audit"] + if audit_value_raw is None: + audit_value = None else: try: - audit = _ShowcaseAuditTransferTypeConverter().from_transfer_type( - audit_raw, ShowcaseAudit + audit_value = ( + _ShowcaseAuditTransferTypeConverter().from_transfer_type( + audit_value_raw, ShowcaseAudit + ) ) except ValidationError as error: _collect(violations, "audit", error) - rows: list[ShowcaseRowsItem] | None = None + rows_value: list[ShowcaseRowsItem] | None = None if "rows" in raw: - rows_raw = raw["rows"] - if rows_raw is None: + rows_value_raw = raw["rows"] + if rows_value_raw is None: violations.append( Violation(path="rows", reason="explicit null not allowed") ) else: - if not isinstance(rows_raw, list): + if not isinstance(rows_value_raw, list): violations.append(Violation(path="rows", reason="expected array")) else: - rows_list: list[ShowcaseRowsItem] = [] - for rows_index, rows_element in enumerate( - typing.cast("list[typing.Any]", rows_raw) + rows_value_list: list[ShowcaseRowsItem] = [] + for rows_value_index, rows_value_element in enumerate( + typing.cast("list[typing.Any]", rows_value_raw) ): - rows_item_path = f"rows[{rows_index}]" - rows_item: ShowcaseRowsItem = typing.cast("typing.Any", None) + rows_value_item_path = f"rows[{rows_value_index}]" + rows_value_item: ShowcaseRowsItem = typing.cast( + "typing.Any", None + ) try: - rows_item = _ShowcaseRowsItemTransferTypeConverter().from_transfer_type( - rows_element, ShowcaseRowsItem + rows_value_item = _ShowcaseRowsItemTransferTypeConverter().from_transfer_type( + rows_value_element, ShowcaseRowsItem ) except ValidationError as error: - _collect(violations, rows_item_path, error) - rows_list.append(rows_item) - rows = rows_list + _collect(violations, rows_value_item_path, error) + rows_value_list.append(rows_value_item) + rows_value = rows_value_list - ledger_py: ShowcaseLedger | None = None + ledger_py_value: ShowcaseLedger | None = None if "ledger" in raw: - ledger_py_raw = raw["ledger"] - if ledger_py_raw is None: + ledger_py_value_raw = raw["ledger"] + if ledger_py_value_raw is None: violations.append( Violation(path="ledger", reason="explicit null not allowed") ) else: try: - ledger_py = ( + ledger_py_value = ( _ShowcaseLedgerTransferTypeConverter().from_transfer_type( - ledger_py_raw, ShowcaseLedger + ledger_py_value_raw, ShowcaseLedger ) ) except ValidationError as error: _collect(violations, "ledger", error) - metadata: ShowcaseMetadata | None = None + metadata_value: ShowcaseMetadata | None = None if "metadata" in raw: - metadata_raw = raw["metadata"] - if metadata_raw is None: + metadata_value_raw = raw["metadata"] + if metadata_value_raw is None: violations.append( Violation(path="metadata", reason="explicit null not allowed") ) else: try: - metadata = ( + metadata_value = ( _ShowcaseMetadataTransferTypeConverter().from_transfer_type( - metadata_raw, ShowcaseMetadata + metadata_value_raw, ShowcaseMetadata ) ) except ValidationError as error: _collect(violations, "metadata", error) - quotas: Quotas | None = None + quotas_value: Quotas | None = None if "quotas" in raw: - quotas_raw = raw["quotas"] - if quotas_raw is None: + quotas_value_raw = raw["quotas"] + if quotas_value_raw is None: violations.append( Violation(path="quotas", reason="explicit null not allowed") ) else: try: - quotas = _QuotasTransferTypeConverter().from_transfer_type( - quotas_raw, Quotas + quotas_value = _QuotasTransferTypeConverter().from_transfer_type( + quotas_value_raw, Quotas ) except ValidationError as error: _collect(violations, "quotas", error) - tokens: Tokens | None = None + tokens_value: Tokens | None = None if "tokens" in raw: - tokens_raw = raw["tokens"] - if tokens_raw is None: + tokens_value_raw = raw["tokens"] + if tokens_value_raw is None: violations.append( Violation(path="tokens", reason="explicit null not allowed") ) else: try: - tokens = _TokensTransferTypeConverter().from_transfer_type( - tokens_raw, Tokens + tokens_value = _TokensTransferTypeConverter().from_transfer_type( + tokens_value_raw, Tokens ) except ValidationError as error: _collect(violations, "tokens", error) - nicknames: Nicknames | None = None + nicknames_value: Nicknames | None = None if "nicknames" in raw: - nicknames_raw = raw["nicknames"] - if nicknames_raw is None: + nicknames_value_raw = raw["nicknames"] + if nicknames_value_raw is None: violations.append( Violation(path="nicknames", reason="explicit null not allowed") ) else: try: - nicknames = _NicknamesTransferTypeConverter().from_transfer_type( - nicknames_raw, Nicknames + nicknames_value = ( + _NicknamesTransferTypeConverter().from_transfer_type( + nicknames_value_raw, Nicknames + ) ) except ValidationError as error: _collect(violations, "nicknames", error) - choices: Choices | None = None + choices_value: Choices | None = None if "choices" in raw: - choices_raw = raw["choices"] - if choices_raw is None: + choices_value_raw = raw["choices"] + if choices_value_raw is None: violations.append( Violation(path="choices", reason="explicit null not allowed") ) else: try: - choices = _ChoicesTransferTypeConverter().from_transfer_type( - choices_raw, Choices + choices_value = _ChoicesTransferTypeConverter().from_transfer_type( + choices_value_raw, Choices ) except ValidationError as error: _collect(violations, "choices", error) - extras: Extras | None = None + extras_value: Extras | None = None if "extras" in raw: - extras_raw = raw["extras"] - if extras_raw is None: + extras_value_raw = raw["extras"] + if extras_value_raw is None: violations.append( Violation(path="extras", reason="explicit null not allowed") ) else: try: - extras = _ExtrasTransferTypeConverter().from_transfer_type( - extras_raw, Extras + extras_value = _ExtrasTransferTypeConverter().from_transfer_type( + extras_value_raw, Extras ) except ValidationError as error: _collect(violations, "extras", error) - shape: Shape | None = None + shape_value: Shape | None = None if "shape" in raw: - shape_raw = raw["shape"] - if shape_raw is None: + shape_value_raw = raw["shape"] + if shape_value_raw is None: violations.append( Violation(path="shape", reason="explicit null not allowed") ) else: - shape_parsed = _shape_from_transfer_type(shape_raw, "shape", violations) - if shape_parsed is not None: - shape = shape_parsed + shape_value_parsed = _shape_from_transfer_type( + shape_value_raw, "shape", violations + ) + if shape_value_parsed is not None: + shape_value = shape_value_parsed - note: Note | None = None + note_value: Note | None = None if "note" in raw: - note_raw = raw["note"] - if note_raw is None: + note_value_raw = raw["note"] + if note_value_raw is None: violations.append( Violation(path="note", reason="explicit null not allowed") ) else: - note_parsed = _note_from_transfer_type(note_raw, "note", violations) - if note_parsed is not None: - note = note_parsed + note_value_parsed = _note_from_transfer_type( + note_value_raw, "note", violations + ) + if note_value_parsed is not None: + note_value = note_value_parsed - address: Address | None = None + address_value: Address | None = None if "address" in raw: - address_raw = raw["address"] - if address_raw is None: + address_value_raw = raw["address"] + if address_value_raw is None: violations.append( Violation(path="address", reason="explicit null not allowed") ) else: try: - address = _AddressTransferTypeConverter().from_transfer_type( - address_raw, Address + address_value = _AddressTransferTypeConverter().from_transfer_type( + address_value_raw, Address ) except ValidationError as error: _collect(violations, "address", error) - labels: Labels | None = None + labels_value: Labels | None = None if "labels" in raw: - labels_raw = raw["labels"] - if labels_raw is None: + labels_value_raw = raw["labels"] + if labels_value_raw is None: violations.append( Violation(path="labels", reason="explicit null not allowed") ) else: try: - labels = _LabelsTransferTypeConverter().from_transfer_type( - labels_raw, Labels + labels_value = _LabelsTransferTypeConverter().from_transfer_type( + labels_value_raw, Labels ) except ValidationError as error: _collect(violations, "labels", error) - settings: Settings | None = None + settings_value: Settings | None = None if "settings" in raw: - settings_raw = raw["settings"] - if settings_raw is None: + settings_value_raw = raw["settings"] + if settings_value_raw is None: violations.append( Violation(path="settings", reason="explicit null not allowed") ) else: try: - settings = _SettingsTransferTypeConverter().from_transfer_type( - settings_raw, Settings + settings_value = ( + _SettingsTransferTypeConverter().from_transfer_type( + settings_value_raw, Settings + ) ) except ValidationError as error: _collect(violations, "settings", error) - attributes: Attributes | None = None + attributes_value: Attributes | None = None if "attributes" in raw: - attributes_raw = raw["attributes"] - if attributes_raw is None: + attributes_value_raw = raw["attributes"] + if attributes_value_raw is None: violations.append( Violation(path="attributes", reason="explicit null not allowed") ) else: try: - attributes = _AttributesTransferTypeConverter().from_transfer_type( - attributes_raw, Attributes + attributes_value = ( + _AttributesTransferTypeConverter().from_transfer_type( + attributes_value_raw, Attributes + ) ) except ValidationError as error: _collect(violations, "attributes", error) - contact: ContactPy | None = None + contact_value: ContactPy | None = None if "contact" in raw: - contact_raw = raw["contact"] - if contact_raw is None: + contact_value_raw = raw["contact"] + if contact_value_raw is None: violations.append( Violation(path="contact", reason="explicit null not allowed") ) else: try: - contact = _ContactPyTransferTypeConverter().from_transfer_type( - contact_raw, ContactPy + contact_value = ( + _ContactPyTransferTypeConverter().from_transfer_type( + contact_value_raw, ContactPy + ) ) except ValidationError as error: _collect(violations, "contact", error) @@ -2191,67 +2274,67 @@ def from_transfer_type( if violations: raise ValidationError(violations) return Showcase( - kind=kind, - revision=revision, - enabled=enabled, - status=status, - tier=tier, - scale=scale, - name=name, - count=count, - active=active, - nickname=nickname, - code=code, - sku=sku, - phrase=phrase, - request_id=request_id, - contact_email=contact_email, - host=host, - homepage=homepage, - gateway=gateway, - blob=blob, - url_blob=url_blob, - retries=retries, - verbose=verbose, - greeting=greeting, - debug=debug, - legacy_id_py=legacy_id_py, - middle_name=middle_name, - category=category, - priority=priority, - level=level, - ratio=ratio, - step=step, - tags=tags, - aliases=aliases, - roles=roles, - id_or_name=id_or_name, - mode=mode, - payload=payload, - detail=detail, - shape_or_name=shape_or_name, - measurements=measurements, - shapes=shapes, - segments=segments, - slots=slots, - grid=grid, - location=location, - audit=audit, - rows=rows, - ledger_py=ledger_py, - metadata=metadata, - quotas=quotas, - tokens=tokens, - nicknames=nicknames, - choices=choices, - extras=extras, - shape=shape, - note=note, - address=address, - labels=labels, - settings=settings, - attributes=attributes, - contact=contact, + kind=kind_value, + revision=revision_value, + enabled=enabled_value, + status=status_value, + tier=tier_value, + scale=scale_value, + name=name_value, + count=count_value, + active=active_value, + nickname=nickname_value, + code=code_value, + sku=sku_value, + phrase=phrase_value, + request_id=request_id_value, + contact_email=contact_email_value, + host=host_value, + homepage=homepage_value, + gateway=gateway_value, + blob=blob_value, + url_blob=url_blob_value, + retries=retries_value, + verbose=verbose_value, + greeting=greeting_value, + debug=debug_value, + legacy_id_py=legacy_id_py_value, + middle_name=middle_name_value, + category=category_value, + priority=priority_value, + level=level_value, + ratio=ratio_value, + step=step_value, + tags=tags_value, + aliases=aliases_value, + roles=roles_value, + id_or_name=id_or_name_value, + mode=mode_value, + payload=payload_value, + detail=detail_value, + shape_or_name=shape_or_name_value, + measurements=measurements_value, + shapes=shapes_value, + segments=segments_value, + slots=slots_value, + grid=grid_value, + location=location_value, + audit=audit_value, + rows=rows_value, + ledger_py=ledger_py_value, + metadata=metadata_value, + quotas=quotas_value, + tokens=tokens_value, + nicknames=nicknames_value, + choices=choices_value, + extras=extras_value, + shape=shape_value, + note=note_value, + address=address_value, + labels=labels_value, + settings=settings_value, + attributes=attributes_value, + contact=contact_value, ) @typing_extensions.override @@ -2449,17 +2532,27 @@ def to_transfer_type(self, value: "Showcase") -> typing.Any: ) out["level"] = value.level if value.ratio is not None: - if value.ratio < 5: - violations.append( - Violation(path="ratio", reason=f"must be >= 5, got {value.ratio}") - ) - if math.fmod(value.ratio, 5) != 0: + if not (-1.7976931348623157e308 <= value.ratio <= 1.7976931348623157e308): violations.append( Violation( path="ratio", - reason=f"must be a multiple of 5, got {value.ratio}", + reason=f"must be a finite number, got {value.ratio}", ) ) + else: + if value.ratio < 5: + violations.append( + Violation( + path="ratio", reason=f"must be >= 5, got {value.ratio}" + ) + ) + if math.fmod(value.ratio, 5) != 0: + violations.append( + Violation( + path="ratio", + reason=f"must be a multiple of 5, got {value.ratio}", + ) + ) out["ratio"] = value.ratio if value.step is not None: if value.step % 3 != 0: @@ -2518,6 +2611,16 @@ def to_transfer_type(self, value: "Showcase") -> typing.Any: reason=f"must be >= 1, got {value.id_or_name}", ) ) + candidate = typing.cast("object", value.id_or_name) + if not ( + isinstance(candidate, str) + or (not isinstance(candidate, bool) and isinstance(candidate, int)) + ): + violations.append( + Violation( + path="idOrName", reason="expected one of: string, integer" + ) + ) out["idOrName"] = value.id_or_name if value.mode is not None: if isinstance(value.mode, str): @@ -2536,11 +2639,38 @@ def to_transfer_type(self, value: "Showcase") -> typing.Any: violations.append( Violation(path="mode", reason=f"must be >= 0, got {value.mode}") ) + candidate = typing.cast("object", value.mode) + if not ( + isinstance(candidate, str) + or (not isinstance(candidate, bool) and isinstance(candidate, int)) + ): + violations.append( + Violation(path="mode", reason="expected one of: string, integer") + ) out["mode"] = value.mode if value.payload is not None: + candidate = typing.cast("object", value.payload) + if not (isinstance(candidate, dict) or isinstance(candidate, str)): + violations.append( + Violation(path="payload", reason="expected one of: object, string") + ) out["payload"] = value.payload if value.detail is not None: - out["detail"] = _showcase_detail_to_transfer_type(value.detail) + candidate = typing.cast("object", value.detail) + if not ( + isinstance(candidate, ShowcaseDetailObject) + or isinstance(candidate, str) + ): + violations.append( + Violation( + path="detail", + reason="expected one of: ShowcaseDetailObject, string", + ) + ) + try: + out["detail"] = _showcase_detail_to_transfer_type(value.detail) + except ValidationError as error: + _collect(violations, "detail", error) if value.shape_or_name is not None: if isinstance(value.shape_or_name, str): if len(value.shape_or_name) > 32: @@ -2550,9 +2680,24 @@ def to_transfer_type(self, value: "Showcase") -> typing.Any: reason=f"must have length <= 32, got {len(value.shape_or_name)}", ) ) - out["shapeOrName"] = _showcase_shape_or_name_to_transfer_type( - value.shape_or_name - ) + candidate = typing.cast("object", value.shape_or_name) + if not ( + isinstance(candidate, Circle) + or isinstance(candidate, Square) + or isinstance(candidate, str) + ): + violations.append( + Violation( + path="shapeOrName", + reason="expected one of: Circle, Square, string", + ) + ) + try: + out["shapeOrName"] = _showcase_shape_or_name_to_transfer_type( + value.shape_or_name + ) + except ValidationError as error: + _collect(violations, "shapeOrName", error) if value.measurements is not None: if isinstance(value.measurements, list): if len(value.measurements) < 1: @@ -2571,85 +2716,161 @@ def to_transfer_type(self, value: "Showcase") -> typing.Any: reason=f"must match pattern {_PATTERN_F242E3A159C2422C.pattern}, got {_quote(value.measurements)}", ) ) + candidate = typing.cast("object", value.measurements) + if not (isinstance(candidate, list) or isinstance(candidate, str)): + violations.append( + Violation( + path="measurements", + reason="expected one of: list[float], string", + ) + ) out["measurements"] = value.measurements if value.shapes is not None: - out["shapes"] = [ - _shape_to_transfer_type(element) for element in value.shapes - ] + shapes_out: list[typing.Any] = [] + for shapes_index, shapes_element in enumerate(value.shapes): + try: + shapes_out.append(_shape_to_transfer_type(shapes_element)) + except ValidationError as error: + _collect(violations, f"shapes[{shapes_index}]", error) + out["shapes"] = shapes_out if value.segments is not None: - out["segments"] = [ - _showcase_segments_item_to_transfer_type(element) - for element in value.segments - ] + segments_out: list[typing.Any] = [] + for segments_index, segments_element in enumerate(value.segments): + try: + segments_out.append( + _showcase_segments_item_to_transfer_type(segments_element) + ) + except ValidationError as error: + _collect(violations, f"segments[{segments_index}]", error) + out["segments"] = segments_out if value.slots is not None: out["slots"] = value.slots if value.grid is not None: out["grid"] = value.grid if value.location is not None: - out["location"] = _ShowcaseLocationTransferTypeConverter().to_transfer_type( - value.location - ) + try: + out["location"] = ( + _ShowcaseLocationTransferTypeConverter().to_transfer_type( + value.location + ) + ) + except ValidationError as error: + _collect(violations, "location", error) if value.audit is not None: - out["audit"] = _ShowcaseAuditTransferTypeConverter().to_transfer_type( - value.audit - ) + try: + out["audit"] = _ShowcaseAuditTransferTypeConverter().to_transfer_type( + value.audit + ) + except ValidationError as error: + _collect(violations, "audit", error) if value.rows is not None: - out["rows"] = [ - _ShowcaseRowsItemTransferTypeConverter().to_transfer_type(element) - for element in value.rows - ] + rows_out: list[typing.Any] = [] + for rows_index, rows_element in enumerate(value.rows): + try: + rows_out.append( + _ShowcaseRowsItemTransferTypeConverter().to_transfer_type( + rows_element + ) + ) + except ValidationError as error: + _collect(violations, f"rows[{rows_index}]", error) + out["rows"] = rows_out if value.ledger_py is not None: - out["ledger"] = _ShowcaseLedgerTransferTypeConverter().to_transfer_type( - value.ledger_py - ) + try: + out["ledger"] = _ShowcaseLedgerTransferTypeConverter().to_transfer_type( + value.ledger_py + ) + except ValidationError as error: + _collect(violations, "ledger", error) if value.metadata is not None: - out["metadata"] = _ShowcaseMetadataTransferTypeConverter().to_transfer_type( - value.metadata - ) + try: + out["metadata"] = ( + _ShowcaseMetadataTransferTypeConverter().to_transfer_type( + value.metadata + ) + ) + except ValidationError as error: + _collect(violations, "metadata", error) if value.quotas is not None: - out["quotas"] = _QuotasTransferTypeConverter().to_transfer_type( - value.quotas - ) + try: + out["quotas"] = _QuotasTransferTypeConverter().to_transfer_type( + value.quotas + ) + except ValidationError as error: + _collect(violations, "quotas", error) if value.tokens is not None: - out["tokens"] = _TokensTransferTypeConverter().to_transfer_type( - value.tokens - ) + try: + out["tokens"] = _TokensTransferTypeConverter().to_transfer_type( + value.tokens + ) + except ValidationError as error: + _collect(violations, "tokens", error) if value.nicknames is not None: - out["nicknames"] = _NicknamesTransferTypeConverter().to_transfer_type( - value.nicknames - ) + try: + out["nicknames"] = _NicknamesTransferTypeConverter().to_transfer_type( + value.nicknames + ) + except ValidationError as error: + _collect(violations, "nicknames", error) if value.choices is not None: - out["choices"] = _ChoicesTransferTypeConverter().to_transfer_type( - value.choices - ) + try: + out["choices"] = _ChoicesTransferTypeConverter().to_transfer_type( + value.choices + ) + except ValidationError as error: + _collect(violations, "choices", error) if value.extras is not None: - out["extras"] = _ExtrasTransferTypeConverter().to_transfer_type( - value.extras - ) + try: + out["extras"] = _ExtrasTransferTypeConverter().to_transfer_type( + value.extras + ) + except ValidationError as error: + _collect(violations, "extras", error) if value.shape is not None: - out["shape"] = _shape_to_transfer_type(value.shape) + try: + out["shape"] = _shape_to_transfer_type(value.shape) + except ValidationError as error: + _collect(violations, "shape", error) if value.note is not None: - out["note"] = _note_to_transfer_type(value.note) + try: + out["note"] = _note_to_transfer_type(value.note) + except ValidationError as error: + _collect(violations, "note", error) if value.address is not None: - out["address"] = _AddressTransferTypeConverter().to_transfer_type( - value.address - ) + try: + out["address"] = _AddressTransferTypeConverter().to_transfer_type( + value.address + ) + except ValidationError as error: + _collect(violations, "address", error) if value.labels is not None: - out["labels"] = _LabelsTransferTypeConverter().to_transfer_type( - value.labels - ) + try: + out["labels"] = _LabelsTransferTypeConverter().to_transfer_type( + value.labels + ) + except ValidationError as error: + _collect(violations, "labels", error) if value.settings is not None: - out["settings"] = _SettingsTransferTypeConverter().to_transfer_type( - value.settings - ) + try: + out["settings"] = _SettingsTransferTypeConverter().to_transfer_type( + value.settings + ) + except ValidationError as error: + _collect(violations, "settings", error) if value.attributes is not None: - out["attributes"] = _AttributesTransferTypeConverter().to_transfer_type( - value.attributes - ) + try: + out["attributes"] = _AttributesTransferTypeConverter().to_transfer_type( + value.attributes + ) + except ValidationError as error: + _collect(violations, "attributes", error) if value.contact is not None: - out["contact"] = _ContactPyTransferTypeConverter().to_transfer_type( - value.contact - ) + try: + out["contact"] = _ContactPyTransferTypeConverter().to_transfer_type( + value.contact + ) + except ValidationError as error: + _collect(violations, "contact", error) if violations: raise ValidationError(violations) return out @@ -2956,20 +3177,20 @@ def from_transfer_type( raise ValidationError([Violation(path="", reason="expected object")]) raw = typing.cast("dict[str, typing.Any]", value) - by: str = typing.cast("typing.Any", None) + by_value: str = typing.cast("typing.Any", None) if "by" not in raw or raw["by"] is None: violations.append(Violation(path="by", reason="required")) else: - by_raw = raw["by"] - if not isinstance(by_raw, str): + by_value_raw = raw["by"] + if not isinstance(by_value_raw, str): violations.append(Violation(path="by", reason="expected string")) else: - by = by_raw - if len(by_raw) < 1: + by_value = by_value_raw + if len(by_value_raw) < 1: violations.append( Violation( path="by", - reason=f"must have length >= 1, got {len(by_raw)}", + reason=f"must have length >= 1, got {len(by_value_raw)}", ) ) @@ -2980,7 +3201,7 @@ def from_transfer_type( if violations: raise ValidationError(violations) return ShowcaseAudit( - by=by, + by=by_value, additional_properties=additional_properties, ) @@ -3024,35 +3245,35 @@ def from_transfer_type( raise ValidationError([Violation(path="", reason="expected object")]) raw = typing.cast("dict[str, typing.Any]", value) - code: str = typing.cast("typing.Any", None) + code_value: str = typing.cast("typing.Any", None) if "code" not in raw or raw["code"] is None: violations.append(Violation(path="code", reason="required")) else: - code_raw = raw["code"] - if not isinstance(code_raw, str): + code_value_raw = raw["code"] + if not isinstance(code_value_raw, str): violations.append(Violation(path="code", reason="expected string")) else: - code = code_raw - if len(code_raw) < 1: + code_value = code_value_raw + if len(code_value_raw) < 1: violations.append( Violation( path="code", - reason=f"must have length >= 1, got {len(code_raw)}", + reason=f"must have length >= 1, got {len(code_value_raw)}", ) ) - hint: str | None = None + hint_value: str | None = None if "hint" in raw: - hint_raw = raw["hint"] - if hint_raw is None: + hint_value_raw = raw["hint"] + if hint_value_raw is None: violations.append( Violation(path="hint", reason="explicit null not allowed") ) else: - if not isinstance(hint_raw, str): + if not isinstance(hint_value_raw, str): violations.append(Violation(path="hint", reason="expected string")) else: - hint = hint_raw + hint_value = hint_value_raw additional_properties: dict[str, typing.Any] = {} for key in raw: @@ -3061,8 +3282,8 @@ def from_transfer_type( if violations: raise ValidationError(violations) return ShowcaseDetailObject( - code=code, - hint=hint, + code=code_value, + hint=hint_value, additional_properties=additional_properties, ) @@ -3126,11 +3347,17 @@ def from_transfer_type( @typing_extensions.override def to_transfer_type(self, value: "ShowcaseLedger") -> typing.Any: + violations: list[Violation] = [] out: dict[str, typing.Any] = {} for key, entry in value.additional_properties.items(): - out[key] = _ShowcaseLedgerValueTransferTypeConverter().to_transfer_type( - entry - ) + try: + out[key] = _ShowcaseLedgerValueTransferTypeConverter().to_transfer_type( + entry + ) + except ValidationError as error: + _collect(violations, key, error) + if violations: + raise ValidationError(violations) return out @@ -3162,17 +3389,21 @@ def from_transfer_type( raise ValidationError([Violation(path="", reason="expected object")]) raw = typing.cast("dict[str, typing.Any]", value) - amount: int = typing.cast("typing.Any", None) + amount_value: int = typing.cast("typing.Any", None) if "amount" not in raw or raw["amount"] is None: violations.append(Violation(path="amount", reason="required")) else: - amount_raw = raw["amount"] - amount_parsed = _parse_spec_integer(amount_raw, "amount", violations) - if amount_parsed is not None: - amount = amount_parsed - if amount < 0: + amount_value_raw = raw["amount"] + amount_value_parsed = _parse_spec_integer( + amount_value_raw, "amount", violations + ) + if amount_value_parsed is not None: + amount_value = amount_value_parsed + if amount_value < 0: violations.append( - Violation(path="amount", reason=f"must be >= 0, got {amount}") + Violation( + path="amount", reason=f"must be >= 0, got {amount_value}" + ) ) additional_properties: dict[str, typing.Any] = {} @@ -3182,7 +3413,7 @@ def from_transfer_type( if violations: raise ValidationError(violations) return ShowcaseLedgerValue( - amount=amount, + amount=amount_value, additional_properties=additional_properties, ) @@ -3224,35 +3455,35 @@ def from_transfer_type( raise ValidationError([Violation(path="", reason="expected object")]) raw = typing.cast("dict[str, typing.Any]", value) - city: str = typing.cast("typing.Any", None) + city_value: str = typing.cast("typing.Any", None) if "city" not in raw or raw["city"] is None: violations.append(Violation(path="city", reason="required")) else: - city_raw = raw["city"] - if not isinstance(city_raw, str): + city_value_raw = raw["city"] + if not isinstance(city_value_raw, str): violations.append(Violation(path="city", reason="expected string")) else: - city = city_raw - if len(city_raw) < 1: + city_value = city_value_raw + if len(city_value_raw) < 1: violations.append( Violation( path="city", - reason=f"must have length >= 1, got {len(city_raw)}", + reason=f"must have length >= 1, got {len(city_value_raw)}", ) ) - geo: ShowcaseLocationGeo | None = None + geo_value: ShowcaseLocationGeo | None = None if "geo" in raw: - geo_raw = raw["geo"] - if geo_raw is None: + geo_value_raw = raw["geo"] + if geo_value_raw is None: violations.append( Violation(path="geo", reason="explicit null not allowed") ) else: try: - geo = ( + geo_value = ( _ShowcaseLocationGeoTransferTypeConverter().from_transfer_type( - geo_raw, ShowcaseLocationGeo + geo_value_raw, ShowcaseLocationGeo ) ) except ValidationError as error: @@ -3265,8 +3496,8 @@ def from_transfer_type( if violations: raise ValidationError(violations) return ShowcaseLocation( - city=city, - geo=geo, + city=city_value, + geo=geo_value, additional_properties=additional_properties, ) @@ -3282,9 +3513,14 @@ def to_transfer_type(self, value: "ShowcaseLocation") -> typing.Any: ) out["city"] = value.city if value.geo is not None: - out["geo"] = _ShowcaseLocationGeoTransferTypeConverter().to_transfer_type( - value.geo - ) + try: + out["geo"] = ( + _ShowcaseLocationGeoTransferTypeConverter().to_transfer_type( + value.geo + ) + ) + except ValidationError as error: + _collect(violations, "geo", error) for key, entry in value.additional_properties.items(): out[key] = entry if violations: @@ -3324,35 +3560,59 @@ def from_transfer_type( raise ValidationError([Violation(path="", reason="expected object")]) raw = typing.cast("dict[str, typing.Any]", value) - lat: float | None = None + lat_value: float | None = None if "lat" in raw: - lat_raw = raw["lat"] - if lat_raw is None: + lat_value_raw = raw["lat"] + if lat_value_raw is None: violations.append( Violation(path="lat", reason="explicit null not allowed") ) else: if not ( - not isinstance(lat_raw, bool) and isinstance(lat_raw, (int, float)) + not isinstance(lat_value_raw, bool) + and isinstance(lat_value_raw, (int, float)) ): violations.append(Violation(path="lat", reason="expected number")) else: - lat = lat_raw + lat_value = lat_value_raw + if not ( + -1.7976931348623157e308 + <= lat_value_raw + <= 1.7976931348623157e308 + ): + violations.append( + Violation( + path="lat", + reason=f"must be a finite number, got {lat_value_raw}", + ) + ) - lon: float | None = None + lon_value: float | None = None if "lon" in raw: - lon_raw = raw["lon"] - if lon_raw is None: + lon_value_raw = raw["lon"] + if lon_value_raw is None: violations.append( Violation(path="lon", reason="explicit null not allowed") ) else: if not ( - not isinstance(lon_raw, bool) and isinstance(lon_raw, (int, float)) + not isinstance(lon_value_raw, bool) + and isinstance(lon_value_raw, (int, float)) ): violations.append(Violation(path="lon", reason="expected number")) else: - lon = lon_raw + lon_value = lon_value_raw + if not ( + -1.7976931348623157e308 + <= lon_value_raw + <= 1.7976931348623157e308 + ): + violations.append( + Violation( + path="lon", + reason=f"must be a finite number, got {lon_value_raw}", + ) + ) additional_properties: dict[str, typing.Any] = {} for key in raw: @@ -3361,20 +3621,35 @@ def from_transfer_type( if violations: raise ValidationError(violations) return ShowcaseLocationGeo( - lat=lat, - lon=lon, + lat=lat_value, + lon=lon_value, additional_properties=additional_properties, ) @typing_extensions.override def to_transfer_type(self, value: "ShowcaseLocationGeo") -> typing.Any: + violations: list[Violation] = [] out: dict[str, typing.Any] = {} if value.lat is not None: + if not (-1.7976931348623157e308 <= value.lat <= 1.7976931348623157e308): + violations.append( + Violation( + path="lat", reason=f"must be a finite number, got {value.lat}" + ) + ) out["lat"] = value.lat if value.lon is not None: + if not (-1.7976931348623157e308 <= value.lon <= 1.7976931348623157e308): + violations.append( + Violation( + path="lon", reason=f"must be a finite number, got {value.lon}" + ) + ) out["lon"] = value.lon for key, entry in value.additional_properties.items(): out[key] = entry + if violations: + raise ValidationError(violations) return out @@ -3457,20 +3732,20 @@ def from_transfer_type( raise ValidationError([Violation(path="", reason="expected object")]) raw = typing.cast("dict[str, typing.Any]", value) - cell: str = typing.cast("typing.Any", None) + cell_value: str = typing.cast("typing.Any", None) if "cell" not in raw or raw["cell"] is None: violations.append(Violation(path="cell", reason="required")) else: - cell_raw = raw["cell"] - if not isinstance(cell_raw, str): + cell_value_raw = raw["cell"] + if not isinstance(cell_value_raw, str): violations.append(Violation(path="cell", reason="expected string")) else: - cell = cell_raw - if len(cell_raw) < 1: + cell_value = cell_value_raw + if len(cell_value_raw) < 1: violations.append( Violation( path="cell", - reason=f"must have length >= 1, got {len(cell_raw)}", + reason=f"must have length >= 1, got {len(cell_value_raw)}", ) ) @@ -3481,7 +3756,7 @@ def from_transfer_type( if violations: raise ValidationError(violations) return ShowcaseRowsItem( - cell=cell, + cell=cell_value, additional_properties=additional_properties, ) @@ -3525,15 +3800,15 @@ def from_transfer_type( raise ValidationError([Violation(path="", reason="expected object")]) raw = typing.cast("dict[str, typing.Any]", value) - id: str = typing.cast("typing.Any", None) + id_value: str = typing.cast("typing.Any", None) if "id" not in raw or raw["id"] is None: violations.append(Violation(path="id", reason="required")) else: - id_raw = raw["id"] - if not isinstance(id_raw, str): + id_value_raw = raw["id"] + if not isinstance(id_value_raw, str): violations.append(Violation(path="id", reason="expected string")) else: - id = id_raw + id_value = id_value_raw for key in raw: if key != "id": @@ -3541,7 +3816,7 @@ def from_transfer_type( if violations: raise ValidationError(violations) return GetShowcaseInput( - id=id, + id=id_value, ) @typing_extensions.override @@ -3569,29 +3844,39 @@ def from_transfer_type( raise ValidationError([Violation(path="", reason="expected object")]) raw = typing.cast("dict[str, typing.Any]", value) - kind: typing.Literal["square"] = typing.cast("typing.Any", None) + kind_value: typing.Literal["square"] = typing.cast("typing.Any", None) if "kind" not in raw or raw["kind"] is None: violations.append(Violation(path="kind", reason="required")) else: - kind_raw = raw["kind"] - if not isinstance(kind_raw, str): + kind_value_raw = raw["kind"] + if not isinstance(kind_value_raw, str): violations.append(Violation(path="kind", reason="expected string")) - elif kind_raw != "square": + elif kind_value_raw != "square": violations.append(Violation(path="kind", reason='must equal "square"')) else: - kind = kind_raw + kind_value = kind_value_raw - side: float = typing.cast("typing.Any", None) + side_value: float = typing.cast("typing.Any", None) if "side" not in raw or raw["side"] is None: violations.append(Violation(path="side", reason="required")) else: - side_raw = raw["side"] + side_value_raw = raw["side"] if not ( - not isinstance(side_raw, bool) and isinstance(side_raw, (int, float)) + not isinstance(side_value_raw, bool) + and isinstance(side_value_raw, (int, float)) ): violations.append(Violation(path="side", reason="expected number")) else: - side = side_raw + side_value = side_value_raw + if not ( + -1.7976931348623157e308 <= side_value_raw <= 1.7976931348623157e308 + ): + violations.append( + Violation( + path="side", + reason=f"must be a finite number, got {side_value_raw}", + ) + ) additional_properties: dict[str, typing.Any] = {} for key in raw: @@ -3600,8 +3885,8 @@ def from_transfer_type( if violations: raise ValidationError(violations) return Square( - kind=kind, - side=side, + kind=kind_value, + side=side_value, additional_properties=additional_properties, ) @@ -3612,6 +3897,12 @@ def to_transfer_type(self, value: "Square") -> typing.Any: if typing.cast("object", value.kind) not in ("square",): violations.append(Violation(path="kind", reason='must equal "square"')) out["kind"] = value.kind + if not (-1.7976931348623157e308 <= value.side <= 1.7976931348623157e308): + violations.append( + Violation( + path="side", reason=f"must be a finite number, got {value.side}" + ) + ) out["side"] = value.side for key, entry in value.additional_properties.items(): out[key] = entry @@ -3646,32 +3937,32 @@ def from_transfer_type( raise ValidationError([Violation(path="", reason="expected object")]) raw = typing.cast("dict[str, typing.Any]", value) - kind: typing.Literal["text"] = typing.cast("typing.Any", None) + kind_value: typing.Literal["text"] = typing.cast("typing.Any", None) if "kind" not in raw or raw["kind"] is None: violations.append(Violation(path="kind", reason="required")) else: - kind_raw = raw["kind"] - if not isinstance(kind_raw, str): + kind_value_raw = raw["kind"] + if not isinstance(kind_value_raw, str): violations.append(Violation(path="kind", reason="expected string")) - elif kind_raw != "text": + elif kind_value_raw != "text": violations.append(Violation(path="kind", reason='must equal "text"')) else: - kind = kind_raw + kind_value = kind_value_raw - body: str = typing.cast("typing.Any", None) + body_value: str = typing.cast("typing.Any", None) if "body" not in raw or raw["body"] is None: violations.append(Violation(path="body", reason="required")) else: - body_raw = raw["body"] - if not isinstance(body_raw, str): + body_value_raw = raw["body"] + if not isinstance(body_value_raw, str): violations.append(Violation(path="body", reason="expected string")) else: - body = body_raw - if len(body_raw) < 1: + body_value = body_value_raw + if len(body_value_raw) < 1: violations.append( Violation( path="body", - reason=f"must have length >= 1, got {len(body_raw)}", + reason=f"must have length >= 1, got {len(body_value_raw)}", ) ) @@ -3682,8 +3973,8 @@ def from_transfer_type( if violations: raise ValidationError(violations) return TextNote( - kind=kind, - body=body, + kind=kind_value, + body=body_value, additional_properties=additional_properties, ) @@ -3819,57 +4110,63 @@ def from_transfer_type( raise ValidationError([Violation(path="", reason="expected object")]) raw = typing.cast("dict[str, typing.Any]", value) - id: str = typing.cast("typing.Any", None) + id_value: str = typing.cast("typing.Any", None) if "id" not in raw or raw["id"] is None: violations.append(Violation(path="id", reason="required")) else: - id_raw = raw["id"] - if not isinstance(id_raw, str): + id_value_raw = raw["id"] + if not isinstance(id_value_raw, str): violations.append(Violation(path="id", reason="expected string")) else: - id = id_raw + id_value = id_value_raw - kind: str | None = None + kind_value: str | None = None if "kind" in raw: - kind_raw = raw["kind"] - if kind_raw is None: + kind_value_raw = raw["kind"] + if kind_value_raw is None: violations.append( Violation(path="kind", reason="explicit null not allowed") ) else: - if not isinstance(kind_raw, str): + if not isinstance(kind_value_raw, str): violations.append(Violation(path="kind", reason="expected string")) else: - kind = kind_raw + kind_value = kind_value_raw - name: str = typing.cast("typing.Any", None) + name_value: str = typing.cast("typing.Any", None) if "name" not in raw or raw["name"] is None: violations.append(Violation(path="name", reason="required")) else: - name_raw = raw["name"] - if not isinstance(name_raw, str): + name_value_raw = raw["name"] + if not isinstance(name_value_raw, str): violations.append(Violation(path="name", reason="expected string")) else: - name = name_raw + name_value = name_value_raw - size: int | None = None + size_value: int | None = None if "size" in raw: - size_raw = raw["size"] - if size_raw is None: + size_value_raw = raw["size"] + if size_value_raw is None: violations.append( Violation(path="size", reason="explicit null not allowed") ) else: - size_parsed = _parse_spec_integer(size_raw, "size", violations) - if size_parsed is not None: - size = size_parsed - if size < 10: + size_value_parsed = _parse_spec_integer( + size_value_raw, "size", violations + ) + if size_value_parsed is not None: + size_value = size_value_parsed + if size_value < 10: violations.append( - Violation(path="size", reason=f"must be >= 10, got {size}") + Violation( + path="size", reason=f"must be >= 10, got {size_value}" + ) ) - if size > 20: + if size_value > 20: violations.append( - Violation(path="size", reason=f"must be <= 20, got {size}") + Violation( + path="size", reason=f"must be <= 20, got {size_value}" + ) ) additional_properties: dict[str, typing.Any] = {} @@ -3879,10 +4176,10 @@ def from_transfer_type( if violations: raise ValidationError(violations) return Widget( - id=id, - kind=kind, - name=name, - size=size, + id=id_value, + kind=kind_value, + name=name_value, + size=size_value, additional_properties=additional_properties, ) @@ -3947,28 +4244,28 @@ def from_transfer_type( raise ValidationError([Violation(path="", reason="expected object")]) raw = typing.cast("dict[str, typing.Any]", value) - id: str = typing.cast("typing.Any", None) + id_value: str = typing.cast("typing.Any", None) if "id" not in raw or raw["id"] is None: violations.append(Violation(path="id", reason="required")) else: - id_raw = raw["id"] - if not isinstance(id_raw, str): + id_value_raw = raw["id"] + if not isinstance(id_value_raw, str): violations.append(Violation(path="id", reason="expected string")) else: - id = id_raw + id_value = id_value_raw - kind: str | None = None + kind_value: str | None = None if "kind" in raw: - kind_raw = raw["kind"] - if kind_raw is None: + kind_value_raw = raw["kind"] + if kind_value_raw is None: violations.append( Violation(path="kind", reason="explicit null not allowed") ) else: - if not isinstance(kind_raw, str): + if not isinstance(kind_value_raw, str): violations.append(Violation(path="kind", reason="expected string")) else: - kind = kind_raw + kind_value = kind_value_raw additional_properties: dict[str, typing.Any] = {} for key in raw: @@ -3977,8 +4274,8 @@ def from_transfer_type( if violations: raise ValidationError(violations) return WidgetBase( - id=id, - kind=kind, + id=id_value, + kind=kind_value, additional_properties=additional_properties, ) @@ -4039,6 +4336,12 @@ def _choices_value_from_transfer_type( def _choices_value_to_transfer_type(value: ChoicesValue) -> typing.Any: + violations: list[Violation] = [] + candidate = typing.cast("object", value) + if not (isinstance(candidate, Circle) or isinstance(candidate, Square)): + violations.append(Violation(path="", reason="expected one of: Circle, Square")) + if violations: + raise ValidationError(violations) if isinstance(value, Circle): return _CircleTransferTypeConverter().to_transfer_type(value) return _SquareTransferTypeConverter().to_transfer_type(value) @@ -4080,6 +4383,14 @@ def _note_from_transfer_type( def _note_to_transfer_type(value: Note) -> typing.Any: + violations: list[Violation] = [] + candidate = typing.cast("object", value) + if not (isinstance(candidate, TextNote) or isinstance(candidate, LinkNote)): + violations.append( + Violation(path="", reason="expected one of: TextNote, LinkNote") + ) + if violations: + raise ValidationError(violations) if isinstance(value, TextNote): return _TextNoteTransferTypeConverter().to_transfer_type(value) return _LinkNoteTransferTypeConverter().to_transfer_type(value) @@ -4115,6 +4426,12 @@ def _shape_from_transfer_type( def _shape_to_transfer_type(value: Shape) -> typing.Any: + violations: list[Violation] = [] + candidate = typing.cast("object", value) + if not (isinstance(candidate, Circle) or isinstance(candidate, Square)): + violations.append(Violation(path="", reason="expected one of: Circle, Square")) + if violations: + raise ValidationError(violations) if isinstance(value, Circle): return _CircleTransferTypeConverter().to_transfer_type(value) return _SquareTransferTypeConverter().to_transfer_type(value) @@ -4155,6 +4472,12 @@ def _showcase_segments_item_to_transfer_type(value: ShowcaseSegmentsItem) -> typ if not isinstance(value, bool) and isinstance(value, int): if value < 0: violations.append(Violation(path="", reason=f"must be >= 0, got {value}")) + candidate = typing.cast("object", value) + if not ( + isinstance(candidate, str) + or (not isinstance(candidate, bool) and isinstance(candidate, int)) + ): + violations.append(Violation(path="", reason="expected one of: string, integer")) if violations: raise ValidationError(violations) return value @@ -4304,15 +4627,40 @@ def _showcase_measurements_from_transfer_type( value: typing.Any, path: str, violations: list[Violation] ) -> list[float] | str | None: if isinstance(value, list): - items = typing.cast("list[float]", value) - if len(items) < 1: + items_list: list[float] = [] + for items_index, items_element in enumerate( + typing.cast("list[typing.Any]", value) + ): + items_item_path = f"{path}[{items_index}]" + items_item: float = typing.cast("typing.Any", None) + if not ( + not isinstance(items_element, bool) + and isinstance(items_element, (int, float)) + ): + violations.append( + Violation(path=items_item_path, reason="expected number") + ) + else: + items_item = items_element + if not ( + -1.7976931348623157e308 <= items_element <= 1.7976931348623157e308 + ): + violations.append( + Violation( + path=items_item_path, + reason=f"must be a finite number, got {items_element}", + ) + ) + items_list.append(items_item) + if len(items_list) < 1: violations.append( Violation( - path=path, reason=f"must have at least 1 items, got {len(items)}" + path=path, + reason=f"must have at least 1 items, got {len(items_list)}", ) ) - _check_unique_items(items, path, violations) - return items + _check_unique_items(items_list, path, violations) + return items_list if isinstance(value, str): if _PATTERN_F242E3A159C2422C.search(value) is None: violations.append( @@ -4331,6 +4679,7 @@ def _showcase_measurements_from_transfer_type( ChoicesValue: typing.TypeAlias = Circle | Square +Note: typing.TypeAlias = TextNote | LinkNote """A tagged union whose object branches are written **inline** rather than `$ref`ed: each branch is emitted as a named type, so each names itself with the per-language `x-<lang>-name` override (two or more inline object branches cannot derive @@ -4338,14 +4687,13 @@ def _showcase_measurements_from_transfer_type( Selection reads the shared required `kind` const, and each branch keeps its own constraints and stays open to unknown members. """ -Note: typing.TypeAlias = TextNote | LinkNote +Shape: typing.TypeAlias = Circle | Square """A closed sum type (discriminated union) of Circle | Square, tagged by the shared required `kind` const. Selection reads `kind` and routes to the matching branch; an unknown tag is a Violation. """ -Shape: typing.TypeAlias = Circle | Square ShowcaseSegmentsItem: typing.TypeAlias = str | int diff --git a/samples/python/temporal/_definitions.py b/samples/python/temporal/_definitions.py index f2b94492..3bf7f712 100644 --- a/samples/python/temporal/_definitions.py +++ b/samples/python/temporal/_definitions.py @@ -16,6 +16,9 @@ "ValidationError", "Violation", "_check_contains", + "_check_date_time", + "_check_duration", + "_check_time", "_check_unique_items", "_collect", "_format_base64", @@ -191,6 +194,13 @@ def _check_contains( r"^PT(?:[0-9]+H(?:[0-9]+M(?:[0-9]+S)?)?|[0-9]+M(?:[0-9]+S)?|[0-9]+S)$" ) _TEMPORAL_MAX_DURATION_SECONDS = ((1 << 63) - 1) // 1_000_000_000 +# A duration component with more digits than the cap itself is over the cap +# whatever those digits are, which is how the magnitude is bounded before `int()` +# sees it: CPython refuses to convert a string of more than 4300 digits. +_TEMPORAL_MAX_DURATION_DIGITS = len(str(_TEMPORAL_MAX_DURATION_SECONDS)) +# `datetime` resolves to microseconds, and `fromisoformat` before Python 3.11 +# parses only the fraction widths `isoformat` writes. +_TEMPORAL_FRACTION_DIGITS = 6 def _days_in_month(year: int, month: int) -> int: @@ -210,10 +220,57 @@ def _valid_temporal_calendar(value: str) -> bool: year, month, day = int(value[0:4]), int(value[5:7]), int(value[8:10]) except ValueError: return False + # `datetime.MINYEAR` is 1, so year 0000 -- which the wire grammar admits and + # the other three targets materialize -- has no Python value at all. It is + # rejected rather than shifted into range, and `_temporal_reason` says so. + if year < datetime.MINYEAR: + return False maximum = _days_in_month(year, month) return maximum > 0 and 1 <= day <= maximum +def _temporal_reason(name: str, value: str) -> str: + """The reason a rejected temporal string is reported under. + + Year 0000 earns its own clause: it is a valid wire value the other targets + accept, so a caller needs to read Python's floor rather than conclude the + timestamp was malformed. + """ + + if value[0:4] == "0000": + return ( + f"must be a valid {name}, got {_quote(value)}: year 0000 is not" + f" representable (datetime.MINYEAR is {datetime.MINYEAR})" + ) + return f"must be a valid {name}, got {_quote(value)}" + + +def _temporal_isoformat(value: str) -> str: + """Rewrites a wire temporal into the spelling `fromisoformat` accepts. + + `Z` becomes `+00:00`, and the fractional second is padded or truncated to + exactly `_TEMPORAL_FRACTION_DIGITS`: before Python 3.11 `fromisoformat` + parses only what `isoformat` writes, so an RFC 3339 `.1` or `.1234567` -- + which every other target accepts -- would otherwise raise. Digits past the + sixth are dropped, the loss at `datetime`'s own resolution that P1 allows; + the canonical output re-trims the padding, so `.1` still writes as `.1`. + """ + + normalized = value.upper() + if normalized.endswith("Z"): + normalized = normalized[:-1] + "+00:00" + dot = normalized.find(".") + if dot < 0: + return normalized + end = dot + 1 + while end < len(normalized) and normalized[end].isdigit(): + end += 1 + fraction = normalized[dot + 1 : end].ljust(_TEMPORAL_FRACTION_DIGITS, "0") + return ( + normalized[: dot + 1] + fraction[:_TEMPORAL_FRACTION_DIGITS] + normalized[end:] + ) + + def _parse_date_time( value: str, path: str, violations: list[Violation] ) -> datetime.datetime | None: @@ -221,24 +278,17 @@ def _parse_date_time( value ): violations.append( - Violation( - path=path, reason=f"must be a valid date-time, got {_quote(value)}" - ) + Violation(path=path, reason=_temporal_reason("date-time", value)) ) return None - normalized = value.upper() - if normalized.endswith("Z"): - normalized = normalized[:-1] + "+00:00" - return datetime.datetime.fromisoformat(normalized) + return datetime.datetime.fromisoformat(_temporal_isoformat(value)) def _parse_date( value: str, path: str, violations: list[Violation] ) -> datetime.date | None: if _TEMPORAL_DATE_RE.match(value) is None or not _valid_temporal_calendar(value): - violations.append( - Violation(path=path, reason=f"must be a valid date, got {_quote(value)}") - ) + violations.append(Violation(path=path, reason=_temporal_reason("date", value))) return None return datetime.date.fromisoformat(value) @@ -247,14 +297,9 @@ def _parse_time( value: str, path: str, violations: list[Violation] ) -> datetime.time | None: if _TEMPORAL_TIME_RE.match(value) is None: - violations.append( - Violation(path=path, reason=f"must be a valid time, got {_quote(value)}") - ) + violations.append(Violation(path=path, reason=_temporal_reason("time", value))) return None - normalized = value.upper() - if normalized.endswith("Z"): - normalized = normalized[:-1] + "+00:00" - return datetime.time.fromisoformat(normalized) + return datetime.time.fromisoformat(_temporal_isoformat(value)) def _parse_duration( @@ -262,9 +307,7 @@ def _parse_duration( ) -> datetime.timedelta | None: if _TEMPORAL_DURATION_RE.match(value) is None: violations.append( - Violation( - path=path, reason=f"must be a valid duration, got {_quote(value)}" - ) + Violation(path=path, reason=_temporal_reason("duration", value)) ) return None total = 0 @@ -273,18 +316,103 @@ def _parse_duration( if char.isdigit(): number += char continue - total += int(number) * {"H": 3600, "M": 60, "S": 1}[char] + digits = number.lstrip("0") number = "" + if len(digits) > _TEMPORAL_MAX_DURATION_DIGITS: + # Over the cap by digit count alone (see the constant), so the + # conversion `int()` would refuse is never attempted. + total = _TEMPORAL_MAX_DURATION_SECONDS + 1 + break + total += int(digits or "0") * {"H": 3600, "M": 60, "S": 1}[char] if total > _TEMPORAL_MAX_DURATION_SECONDS: - violations.append( - Violation( - path=path, reason=f"must be a valid duration, got {_quote(value)}" - ) - ) - return None + break + if total > _TEMPORAL_MAX_DURATION_SECONDS: + violations.append( + Violation(path=path, reason=_temporal_reason("duration", value)) + ) + return None return datetime.timedelta(seconds=total) +def _check_temporal_offset( + name: str, + value: datetime.datetime | datetime.time, + offset: datetime.timedelta, + path: str, + violations: list[Violation], +) -> None: + """Asserts a UTC offset is a whole number of minutes, the finest the wire + form spells (`tzinfo` allows seconds, which the offset would silently lose). + """ + + if offset % datetime.timedelta(minutes=1): + violations.append( + Violation( + path=path, + reason=( + f"must be a valid {name}, got {_quote(str(value))}: " + f"the UTC offset {offset} is not a whole number of minutes" + ), + ) + ) + + +def _check_date_time( + value: datetime.datetime, path: str, violations: list[Violation] +) -> None: + """Asserts a datetime is writable as a wire date-time (P12). + + A dataclass is constructed unchecked, so a naive datetime -- with no offset + the required wire form could carry -- reaches serialize; without this it + would emit a value this module's own parser rejects. + """ + + offset = value.utcoffset() + if offset is None: + violations.append( + Violation( + path=path, + reason=( + f"must be a valid date-time, got {_quote(str(value))}: " + "a naive datetime carries no UTC offset" + ), + ) + ) + return + _check_temporal_offset("date-time", value, offset, path, violations) + + +def _check_time(value: datetime.time, path: str, violations: list[Violation]) -> None: + """Asserts a time is writable as a wire time (P12). The offset is optional in + the grammar, so only its precision is held to anything.""" + + offset = value.utcoffset() + if offset is not None: + _check_temporal_offset("time", value, offset, path, violations) + + +def _check_duration( + value: datetime.timedelta, path: str, violations: list[Violation] +) -> None: + """Asserts a timedelta is writable as a wire duration (P12): the grammar is + unsigned, whole-second and capped, and a `timedelta` is none of those.""" + + if value < datetime.timedelta(0): + reason = "a duration cannot be negative" + elif value % datetime.timedelta(seconds=1): + reason = "a duration cannot carry a fraction of a second" + elif value.total_seconds() > _TEMPORAL_MAX_DURATION_SECONDS: + reason = f"a duration cannot exceed {_TEMPORAL_MAX_DURATION_SECONDS} seconds" + else: + return + violations.append( + Violation( + path=path, + reason=f"must be a valid duration, got {_quote(str(value))}: {reason}", + ) + ) + + def _temporal_frac(microsecond: int) -> str: if microsecond == 0: return "" diff --git a/samples/python/temporal/models.py b/samples/python/temporal/models.py index 8de3e071..442114a3 100644 --- a/samples/python/temporal/models.py +++ b/samples/python/temporal/models.py @@ -11,6 +11,9 @@ from ._definitions import ( ValidationError, Violation, + _check_date_time, + _check_duration, + _check_time, _format_date, _format_date_time, _format_duration, @@ -35,163 +38,169 @@ def from_transfer_type( raise ValidationError([Violation(path="", reason="expected object")]) raw = typing.cast("dict[str, typing.Any]", value) - created_at: datetime.datetime = typing.cast("typing.Any", None) + created_at_value: datetime.datetime = typing.cast("typing.Any", None) if "createdAt" not in raw or raw["createdAt"] is None: violations.append(Violation(path="createdAt", reason="required")) else: - created_at_raw = raw["createdAt"] - if not isinstance(created_at_raw, str): + created_at_value_raw = raw["createdAt"] + if not isinstance(created_at_value_raw, str): violations.append(Violation(path="createdAt", reason="expected string")) else: - created_at_parsed = _parse_date_time( - created_at_raw, "createdAt", violations + created_at_value_parsed = _parse_date_time( + created_at_value_raw, "createdAt", violations ) - if created_at_parsed is not None: - created_at = created_at_parsed + if created_at_value_parsed is not None: + created_at_value = created_at_value_parsed - birthday: datetime.date = typing.cast("typing.Any", None) + birthday_value: datetime.date = typing.cast("typing.Any", None) if "birthday" not in raw or raw["birthday"] is None: violations.append(Violation(path="birthday", reason="required")) else: - birthday_raw = raw["birthday"] - if not isinstance(birthday_raw, str): + birthday_value_raw = raw["birthday"] + if not isinstance(birthday_value_raw, str): violations.append(Violation(path="birthday", reason="expected string")) else: - birthday_parsed = _parse_date(birthday_raw, "birthday", violations) - if birthday_parsed is not None: - birthday = birthday_parsed + birthday_value_parsed = _parse_date( + birthday_value_raw, "birthday", violations + ) + if birthday_value_parsed is not None: + birthday_value = birthday_value_parsed - alarm: datetime.time = typing.cast("typing.Any", None) + alarm_value: datetime.time = typing.cast("typing.Any", None) if "alarm" not in raw or raw["alarm"] is None: violations.append(Violation(path="alarm", reason="required")) else: - alarm_raw = raw["alarm"] - if not isinstance(alarm_raw, str): + alarm_value_raw = raw["alarm"] + if not isinstance(alarm_value_raw, str): violations.append(Violation(path="alarm", reason="expected string")) else: - alarm_parsed = _parse_time(alarm_raw, "alarm", violations) - if alarm_parsed is not None: - alarm = alarm_parsed + alarm_value_parsed = _parse_time(alarm_value_raw, "alarm", violations) + if alarm_value_parsed is not None: + alarm_value = alarm_value_parsed - timeout: datetime.timedelta = typing.cast("typing.Any", None) + timeout_value: datetime.timedelta = typing.cast("typing.Any", None) if "timeout" not in raw or raw["timeout"] is None: violations.append(Violation(path="timeout", reason="required")) else: - timeout_raw = raw["timeout"] - if not isinstance(timeout_raw, str): + timeout_value_raw = raw["timeout"] + if not isinstance(timeout_value_raw, str): violations.append(Violation(path="timeout", reason="expected string")) else: - timeout_parsed = _parse_duration(timeout_raw, "timeout", violations) - if timeout_parsed is not None: - timeout = timeout_parsed + timeout_value_parsed = _parse_duration( + timeout_value_raw, "timeout", violations + ) + if timeout_value_parsed is not None: + timeout_value = timeout_value_parsed - updated_at: datetime.datetime | None = None + updated_at_value: datetime.datetime | None = None if "updatedAt" in raw: - updated_at_raw = raw["updatedAt"] - if updated_at_raw is None: + updated_at_value_raw = raw["updatedAt"] + if updated_at_value_raw is None: violations.append( Violation(path="updatedAt", reason="explicit null not allowed") ) else: - if not isinstance(updated_at_raw, str): + if not isinstance(updated_at_value_raw, str): violations.append( Violation(path="updatedAt", reason="expected string") ) else: - updated_at_parsed = _parse_date_time( - updated_at_raw, "updatedAt", violations + updated_at_value_parsed = _parse_date_time( + updated_at_value_raw, "updatedAt", violations ) - if updated_at_parsed is not None: - updated_at = updated_at_parsed + if updated_at_value_parsed is not None: + updated_at_value = updated_at_value_parsed - expires_on: datetime.date | None = None + expires_on_value: datetime.date | None = None if "expiresOn" in raw: - expires_on_raw = raw["expiresOn"] - if expires_on_raw is None: + expires_on_value_raw = raw["expiresOn"] + if expires_on_value_raw is None: violations.append( Violation(path="expiresOn", reason="explicit null not allowed") ) else: - if not isinstance(expires_on_raw, str): + if not isinstance(expires_on_value_raw, str): violations.append( Violation(path="expiresOn", reason="expected string") ) else: - expires_on_parsed = _parse_date( - expires_on_raw, "expiresOn", violations + expires_on_value_parsed = _parse_date( + expires_on_value_raw, "expiresOn", violations ) - if expires_on_parsed is not None: - expires_on = expires_on_parsed + if expires_on_value_parsed is not None: + expires_on_value = expires_on_value_parsed - reminder: datetime.time | None = None + reminder_value: datetime.time | None = None if "reminder" in raw: - reminder_raw = raw["reminder"] - if reminder_raw is None: + reminder_value_raw = raw["reminder"] + if reminder_value_raw is None: violations.append( Violation(path="reminder", reason="explicit null not allowed") ) else: - if not isinstance(reminder_raw, str): + if not isinstance(reminder_value_raw, str): violations.append( Violation(path="reminder", reason="expected string") ) else: - reminder_parsed = _parse_time(reminder_raw, "reminder", violations) - if reminder_parsed is not None: - reminder = reminder_parsed + reminder_value_parsed = _parse_time( + reminder_value_raw, "reminder", violations + ) + if reminder_value_parsed is not None: + reminder_value = reminder_value_parsed - retry_delay: datetime.timedelta | None = None + retry_delay_value: datetime.timedelta | None = None if "retryDelay" in raw: - retry_delay_raw = raw["retryDelay"] - if retry_delay_raw is None: + retry_delay_value_raw = raw["retryDelay"] + if retry_delay_value_raw is None: violations.append( Violation(path="retryDelay", reason="explicit null not allowed") ) else: - if not isinstance(retry_delay_raw, str): + if not isinstance(retry_delay_value_raw, str): violations.append( Violation(path="retryDelay", reason="expected string") ) else: - retry_delay_parsed = _parse_duration( - retry_delay_raw, "retryDelay", violations + retry_delay_value_parsed = _parse_duration( + retry_delay_value_raw, "retryDelay", violations ) - if retry_delay_parsed is not None: - retry_delay = retry_delay_parsed + if retry_delay_value_parsed is not None: + retry_delay_value = retry_delay_value_parsed - deleted_at: datetime.datetime | None = None + deleted_at_value: datetime.datetime | None = None if "deletedAt" in raw: - deleted_at_raw = raw["deletedAt"] - if deleted_at_raw is None: - deleted_at = None + deleted_at_value_raw = raw["deletedAt"] + if deleted_at_value_raw is None: + deleted_at_value = None else: - if not isinstance(deleted_at_raw, str): + if not isinstance(deleted_at_value_raw, str): violations.append( Violation(path="deletedAt", reason="expected string") ) else: - deleted_at_parsed = _parse_date_time( - deleted_at_raw, "deletedAt", violations + deleted_at_value_parsed = _parse_date_time( + deleted_at_value_raw, "deletedAt", violations ) - if deleted_at_parsed is not None: - deleted_at = deleted_at_parsed + if deleted_at_value_parsed is not None: + deleted_at_value = deleted_at_value_parsed - archived_on: datetime.date | None = None + archived_on_value: datetime.date | None = None if "archivedOn" in raw: - archived_on_raw = raw["archivedOn"] - if archived_on_raw is None: - archived_on = None + archived_on_value_raw = raw["archivedOn"] + if archived_on_value_raw is None: + archived_on_value = None else: - if not isinstance(archived_on_raw, str): + if not isinstance(archived_on_value_raw, str): violations.append( Violation(path="archivedOn", reason="expected string") ) else: - archived_on_parsed = _parse_date( - archived_on_raw, "archivedOn", violations + archived_on_value_parsed = _parse_date( + archived_on_value_raw, "archivedOn", violations ) - if archived_on_parsed is not None: - archived_on = archived_on_parsed + if archived_on_value_parsed is not None: + archived_on_value = archived_on_value_parsed for key in raw: if ( @@ -210,35 +219,42 @@ def from_transfer_type( if violations: raise ValidationError(violations) return Temporal( - created_at=created_at, - birthday=birthday, - alarm=alarm, - timeout=timeout, - updated_at=updated_at, - expires_on=expires_on, - reminder=reminder, - retry_delay=retry_delay, - deleted_at=deleted_at, - archived_on=archived_on, + created_at=created_at_value, + birthday=birthday_value, + alarm=alarm_value, + timeout=timeout_value, + updated_at=updated_at_value, + expires_on=expires_on_value, + reminder=reminder_value, + retry_delay=retry_delay_value, + deleted_at=deleted_at_value, + archived_on=archived_on_value, ) @typing_extensions.override def to_transfer_type(self, value: "Temporal") -> typing.Any: violations: list[Violation] = [] out: dict[str, typing.Any] = {} + _check_date_time(value.created_at, "createdAt", violations) out["createdAt"] = _format_date_time(value.created_at) out["birthday"] = _format_date(value.birthday) + _check_time(value.alarm, "alarm", violations) out["alarm"] = _format_time(value.alarm) + _check_duration(value.timeout, "timeout", violations) out["timeout"] = _format_duration(value.timeout) if value.updated_at is not None: + _check_date_time(value.updated_at, "updatedAt", violations) out["updatedAt"] = _format_date_time(value.updated_at) if value.expires_on is not None: out["expiresOn"] = _format_date(value.expires_on) if value.reminder is not None: + _check_time(value.reminder, "reminder", violations) out["reminder"] = _format_time(value.reminder) if value.retry_delay is not None: + _check_duration(value.retry_delay, "retryDelay", violations) out["retryDelay"] = _format_duration(value.retry_delay) if value.deleted_at is not None: + _check_date_time(value.deleted_at, "deletedAt", violations) out["deletedAt"] = _format_date_time(value.deleted_at) if value.archived_on is not None: out["archivedOn"] = _format_date(value.archived_on) diff --git a/specs/json-schema/features/type.md b/specs/json-schema/features/type.md index cbf9e849..2ce1e037 100644 --- a/specs/json-schema/features/type.md +++ b/specs/json-schema/features/type.md @@ -105,7 +105,7 @@ errors aggregate into the language-native primitive. |---|---|---|---|---| | `"string"` | typed `Unmarshal` into `string` | `typeof v === 'string'` | `isinstance(v, str)` | Jackson typed binding | | `"integer"` | shadow `*json.Number` → runtime `parseSpecInteger` → `int64` (accepts `1.0`, rejects `1.5`, caps ±(2^53−1)) | `typeof v === 'number' && Number.isSafeInteger(v)` (accepts `1.0` natively; caps ±(2^53−1)) | runtime `_parse_spec_integer(v, path, violations)` → `int` (accepts `1.0`, rejects `1.5` and `bool`, caps ±(2^53−1)) | node helper `SpecNumbers.specLong(node, path, errs)` called by the collecting deserializer (accepts `1.0`, rejects `1.5`, caps ±(2^53−1)) | -| `"number"` | `float64` unmarshal | `typeof v === 'number'` | `isinstance(v, (int, float)) and not isinstance(v, bool)` → `float(v)` | `Double` binding | +| `"number"` | `float64` unmarshal | `typeof v === 'number'` | `not isinstance(v, bool) and isinstance(v, (int, float))`, stored as-is | `Double` binding | | `"boolean"` | `bool` unmarshal | `typeof v === 'boolean'` | `isinstance(v, bool)` (rejects `1`/`0`) | `Boolean` binding | | `"object"` | typed struct unmarshal | `typeof v === 'object' && v !== null && !Array.isArray(v)` | `isinstance(v, dict)`, then the branch/member converter builds the dataclass | typed class binding | | `"array"` | typed slice unmarshal | `Array.isArray(v)` | `isinstance(v, list)` | typed `List` binding | @@ -155,8 +155,12 @@ Strategy per language: mismatch appending a `Violation { path, reason }` to the list the converter raises as one `ValidationError` (**PRINCIPLES Python §2**). Because `bool` is a subclass of `int`, an integer or number check **must exclude `bool` - explicitly** — otherwise `True` classifies as `1`. Integer fields stay a - plain `int` and run through the generated runtime's + explicitly** — otherwise `True` classifies as `1`. A classified `number` is + stored **exactly as it arrived**, never coerced: an integral `5` stays an + `int` in a `float`-annotated member, because `float(5)` would re-serialize + as `5.0` where Go and TypeScript emit `5` — a per-language nicety paid for + in round-trip byte-identity, which **P1** does not permit. Integer fields + stay a plain `int` and run through the generated runtime's `_parse_spec_integer(value, path, violations)`: it rejects `bool`, accepts an `int`, accepts a `float` with zero fractional part (`1.0`, `1e2`), and rejects a fractional one (`1.5`) — the same accept/reject set as Go's diff --git a/specs/json-schema/generated-file-layout.md b/specs/json-schema/generated-file-layout.md index dbd6b73d..57d95dc1 100644 --- a/specs/json-schema/generated-file-layout.md +++ b/specs/json-schema/generated-file-layout.md @@ -62,7 +62,7 @@ collide across files — the loader validates this (see Collisions). When the closure is **exactly one input file**, there is no directory tree to mirror: the file's output lands **directly at the package root** rather than in a per-input subdirectory. A single `chat.yaml` → package `chat/` -holding `models.py`, `services.py`, the shared `definitions.py`, and the +holding `models.py`, `services.py`, the shared `_definitions.py`, and the `__init__.py` aggregator side by side. No per-input subdirectory, and no `_recursive` (a cross-file cycle is impossible with one file). The models/services split and the shared-runtime file are still present in @@ -78,7 +78,7 @@ Per input file `<subpath>/<name>`: | Language | Per-input module | Shared runtime (once) | Recursive module | Aggregator | |---|---|---|---|---| -| **Python** | `<subpath>/<name>/models.py` (+ `services.py` if it declares services) | `definitions.py` (package root) | `_recursive.py` (package root) | `__init__.py` per directory — the per-input directory, every intermediate directory, and the package root | +| **Python** | `<subpath>/<name>/models.py` (+ `services.py` if it declares services) | `_definitions.py` (package root) | `_recursive.py` (package root) | `__init__.py` per directory — the per-input directory, every intermediate directory, and the package root | | **TypeScript** | `<subpath>/<name>/models.ts` (+ `services.ts`) | `definitions.ts` (package root) | — | `index.ts` per directory (barrels chain upward) | | **Go** | `<module>.go` in the one flat package (`<module>` = flattened path) | `definitions.go` (same package) | — | — (capitalized = exported) | | **Java** | one `<ClassName>.java` per exported class, in a package mirroring `<subpath>/<name>/` | each runtime class its own file in the root package (`ValidationException.java`, `Violation.java`, `SpecNumbers.java`, …) | — | — (`public` = exported) | @@ -105,18 +105,20 @@ All output lands at the package root (no per-input subdirectory, no | Language | Output | |---|---| -| **Python** | `models.py` (+ `services.py`), the shared `definitions.py`, and the `__init__.py` aggregator — the same split as multi-input, flattened to the package root | +| **Python** | `models.py` (+ `services.py`), the shared `_definitions.py`, and the `__init__.py` aggregator — the same split as multi-input, flattened to the package root | | **TypeScript** | `models.ts` (+ `services.ts`), `definitions.ts`, `index.ts` | | **Go** | one `<package>.go` (types and services) + the shared `definitions.go` | | **Java** | one `.java` per public class + the runtime classes; nothing to aggregate | ## The shared `definitions` file -Holds the schema-independent runtime, defined once per package (`definitions.py` -/ `definitions.ts` / `definitions.go`; Java splits it into one class file each). For -Python/TypeScript/Java it sits at the package root; for Go it sits in the -one flat package, always as its own `definitions.go` file regardless of how -many input files that package aggregates. +Holds the schema-independent runtime, defined once per package +(`_definitions.py` / `definitions.ts` / `definitions.go`; Java splits it into +one class file each). For Python/TypeScript/Java it sits at the package root; +for Go it sits in the one flat package, always as its own `definitions.go` +file regardless of how many input files that package aggregates. Python's +file is underscore-prefixed, the language's own marking for a module that is +generator-internal rather than part of the package's surface. - Error types — a **single aggregating error holding a list of `Violation { path, reason }`**, identical in spirit across all four @@ -199,7 +201,8 @@ reject costs little. ## Collisions **Go** — one unified namespace per package holds the **reserved generated -names** (currently `definitions`) plus one entry per input module. Any collision +names** (`definitions` and `_definitions`, the union across languages) plus one +entry per input module. Any collision in that namespace → **load reject** with a fix-it (`x-output-module` override or rename): @@ -221,8 +224,11 @@ distinct modules, so files no longer contend for one flat *module* name. What remains is a small set of **reserved generated names** per scope; an input file or directory that maps onto one → load reject with the same fix-it: -- at the **package root**: the shared `definitions` runtime module, `_recursive` - (Python), and the root aggregator (`__init__` / `index`); +- at the **package root**: the shared runtime module — **both** spellings, + `definitions` (Go/TypeScript) and `_definitions` (Python), are reserved in + every target, the union across languages, so an input named either way + rejects regardless of which target is being generated — plus `_recursive` + (Python) and the root aggregator (`__init__` / `index`); - within a **per-input directory**: `models`, `services`, and that directory's own aggregator. @@ -310,13 +316,16 @@ bindings: types (and services) via `__all__`; each intermediate directory's `__init__.py` re-exports its children; the package-root `__init__.py` re-exports the whole tree, pulling hoisted types from `_recursive`. The - shared runtime (`ValidationError`, etc.) is **not** surfaced through the - aggregators — consumers import it directly from `definitions`. + shared runtime (`ValidationError`, `Violation`) is **not** surfaced through + the aggregators — catching the aggregating error means naming the private + module (`from <package>._definitions import ValidationError`). Python is the + only target where the error type is reachable through a private name alone; + the other three carry it on their public surface. - **TypeScript** — `index.ts` per directory: per-input barrels `export … from './models'` (and `./services`), intermediate barrels - `export * from './<child>'`, and the root barrel re-exports the tree. - `ValidationError` is likewise imported directly from `./definitions`, not - re-exported. + `export * from './<child>'`, and the root barrel re-exports the tree plus + the runtime's `ValidationError` and the `Violation` type from + `./definitions`. - **Go** — no aggregator; capitalized identifiers are exported from the one flat package. - **Java** — `public` class per file; runtime classes public too. From 32a21164dc128623de7ea99f42adf189bad2c818 Mon Sep 17 00:00:00 2001 From: Roey Berman <roey.berman@gmail.com> Date: Fri, 14 Aug 2026 11:50:45 -0700 Subject: [PATCH 07/20] Compare wire bytes, and cover the five Python review fixes The Python round-trip suite compared `json.loads(payload.data)` on both sides, so every assertion ran on a parsed value and nothing verified the wire bytes. That is the whole justification for one of the design decisions -- a schema `default` stays off the dataclass field so Python's output is byte-identical to the shared fixtures -- and it let a real bug through: an integer `const`/`enum` kept its wire `float`, so `{"revision": 1.0}` re-emitted as `1.0`, and `1 == 1.0` in Python. `json_converter_helper` now offers `encode_bytes` (the payload's own `data`) against `canonical_fixture_bytes` (the fixture through `json.dumps(..., sort_keys=True, separators=(",", ":"))`). Canonicalizing normalizes only insignificant whitespace and member order -- neither part of the contract -- while preserving the numeric form, string escaping and the presence of every key. Every per-suite round-trip assertion moves to that comparison. The two deviations are one central, documented table rather than a weakened assertion: `COLLAPSED_NULL_MEMBERS` names the five fixtures carrying an explicit `null` on an optional+nullable member (P1 exception (a)), and `NON_CANONICAL_FIXTURES` the one fixture that exists to be normalized. A schema `default` needs no entry -- an unset defaulted key is omitted, which is *why* the bytes match. `test_wire_fixtures.py` sweeps all 48 fixtures, fails if one is added without a model, and asserts each exception entry is both necessary and sufficient. Per fix, a test that fails without it: - Integer closed values normalize (`revision`/`tier` from `1.0`/`2.0` re-emit as `1`/`2`, asserted on bytes -- the parsed comparison passes either way), plus the `1.5` and integer-cap paths the closed set bypassed. - Non-finite numbers are rejected in both directions, reached through raw wire text since `json.loads` accepts `Infinity`/`-Infinity`/`NaN`, plus `1e400` and a 401-digit integer literal. - Year 0000 is a violation naming `datetime.MINYEAR`; sub-second widths `.1`, `.12` and `.1234567890` are accepted and re-emit canonically; a 5000-digit duration component is a violation. - Serialize-side temporal representability: a naive `datetime`, a negative, fractional and over-cap `timedelta`, and a sub-minute offset, each under the field's own path and aggregating. - Serialize-side nested aggregation: one model with a flat failure plus `location.city`, `segments[1]`, `rows[1].cell` and `choices.a.kind` reports all five, fully pathed. - A union's array branch types every element; a value in no branch is rejected on serialize rather than emitted. The identifier-shadowing fix needs a schema no sample declares, so it lands in `tests/generate_python.rs`: sixteen properties named after the converter's own locals, builtins, imported modules and method parameters. The rendered output is asserted for the `_value` slot mechanism, and the generated package is then *run*, because the failure was silent -- a `violations` property rebound the accumulator and an invalid payload came back as a model. Finally the sample suite now also runs on 3.10, the declared floor. The existing AST check validates syntax only and the project environment is 3.13, so nothing would have caught the sub-second bug -- it raised on 3.10 alone. `uv run --python 3.10 --locked pytest` reuses the same lockfile in an environment under `target/`, adding ~3s and no second lockfile to maintain. --- samples/python/tests/json_converter_helper.py | 145 +++++++- samples/python/tests/test_chat.py | 41 +-- samples/python/tests/test_kb.py | 26 +- samples/python/tests/test_showcase.py | 300 ++++++++++++++-- samples/python/tests/test_temporal.py | 335 +++++++++++++++--- samples/python/tests/test_wire_fixtures.py | 152 ++++++++ tests/generate_python.rs | 260 +++++++++++++- 7 files changed, 1150 insertions(+), 109 deletions(-) create mode 100644 samples/python/tests/test_wire_fixtures.py diff --git a/samples/python/tests/json_converter_helper.py b/samples/python/tests/json_converter_helper.py index e984dbe0..ebe8b53a 100644 --- a/samples/python/tests/json_converter_helper.py +++ b/samples/python/tests/json_converter_helper.py @@ -18,6 +18,12 @@ ``advanced/samples/python/tests/test_start_workflow.py`` does. Negative tests use it so the generated ``ValidationError`` surfaces unwrapped (the payload converter would otherwise wrap it). + +Round-trip assertions run on **bytes** (:func:`encode_bytes` against +:func:`canonical_fixture_bytes`), never on a parsed value: a parsed comparison +cannot see the wire form of a number, and `1 == 1.0` in Python, so it silently +admits an ``integer`` member that re-emitted as ``1.0``. :func:`encode` remains +available for the rare assertion that really is about structure. """ from __future__ import annotations @@ -35,6 +41,43 @@ #: Never modify them — they are the polyglot contract. WIRE_FIXTURE_ROOT = Path(__file__).resolve().parents[2] / "wire" / "json_schema" +#: The **complete** list of fixture members Python cannot re-emit, keyed by +#: ``(suite, fixture)`` and valued by the paths dropped from the expected wire. +#: +#: Every entry is one instance of the same documented exception: the +#: **optional+nullable collapse** (P1 exception (a), see +#: `specs/json-schema/features/nullability.md`). A dataclass has no presence +#: channel, so absent and an explicit wire ``null`` are the same in-memory state +#: (``None``) and both re-serialize as *omitted* — matching Go and Java, where the +#: same fixtures are verified by field checks for exactly this reason. TypeScript +#: is the only target that still round-trips the explicit ``null``. +#: +#: Nothing else belongs here. In particular a schema ``default`` is **not** an +#: entry: it is advisory, the field stays ``T | None = None`` with the value on a +#: ``DEFAULT_<FIELD>`` constant, so an unset defaulted key is omitted on the way +#: out exactly as it was absent on the way in — which is *why* the fixtures that +#: omit one (``showcase-minimal.json``, ``message-minimal.json``) round-trip +#: byte-identically, and the reason that design was chosen. +#: +#: A path is dot-separated; a ``[]`` segment means "every element of this array". +COLLAPSED_NULL_MEMBERS: dict[tuple[str, str], tuple[str, ...]] = { + ("chat", "message-full.json"): ("replyToId",), + ("kb", "block.json"): ("page",), + ("kb", "page.json"): ("blocks[].page",), + ("showcase", "showcase-nulls.json"): ("middleName",), + ("temporal", "temporal-nulls.json"): ("deletedAt", "archivedOn"), +} + +#: Fixtures that are **deliberately non-canonical input** — they exist to be +#: normalized, so their re-emitted bytes differ from their own. Not an exception +#: to round-trip fidelity: the value survives, only its spelling is canonicalized +#: (lowercase ``t``/``z`` → ``T``/``Z``, ``+00:00`` → ``Z``, ``PT90M`` → +#: ``PT1H30M``), identically in all four targets. The exact expected bytes are +#: asserted by the suite's own test (``test_temporal_canonicalization``). +NON_CANONICAL_FIXTURES: frozenset[tuple[str, str]] = frozenset( + {("temporal", "temporal-canonicalize.json")} +) + def fixture_dir(suite: str) -> Path: """Directory holding one suite's canonical wire fixtures.""" @@ -76,11 +119,84 @@ def decode(cls: type[T], data: bytes) -> T: return _payload_converter().from_payloads([payload], [cls])[0] -def encode(model: object) -> typing.Any: - """Serialize a model via the *default* converter, returned as a JSON value.""" +def encode_bytes(model: object) -> bytes: + """The exact wire bytes the *default* converter writes for ``model``. + + This is the payload's own ``data``, not a re-serialization of a parsed value, + so it is the byte form a Go/TypeScript/Java peer would actually receive. + """ encoded = _payload_converter().to_payloads([model]) assert encoded, "payload converter produced no payloads" - return json.loads(encoded[0].data) + return encoded[0].data + + +def encode(model: object) -> typing.Any: + """Serialize a model via the *default* converter, returned as a JSON value. + + The parsed view, for the occasional assertion that is genuinely about + structure. Round-trip assertions use :func:`encode_bytes` instead: a parsed + comparison cannot distinguish ``1`` from ``1.0``. + """ + return json.loads(encode_bytes(model)) + + +def canonical_json_bytes(value: typing.Any) -> bytes: + """``value`` in the byte form the SDK's JSON payload converter writes. + + ``JSONPlainPayloadConverter.to_payload`` serializes with + ``separators=(",", ":"), sort_keys=True``, so its output is compact and + key-sorted while a fixture file is formatted for humans. Canonicalizing the + expectation through this function normalizes exactly those two properties — + insignificant whitespace and member order, neither of which is part of the + wire contract — while preserving everything that *is*: + + * the **numeric form**: ``json.loads("1.0")`` yields a ``float`` that + ``json.dumps`` writes back as ``1.0``, and ``json.loads("1")`` an ``int`` + that writes back as ``1``, so an ``integer`` member that kept its wire + ``float`` is caught here and nowhere else; + * string escaping and every code point of every string; + * the presence or absence of every key, at every depth. + """ + return json.dumps(value, sort_keys=True, separators=(",", ":")).encode() + + +def _drop_path(value: typing.Any, path: str) -> None: + """Delete ``path`` from a parsed fixture, in place. + + ``a.b`` walks into an object member; ``a[].b`` walks into every element of an + array member. A missing key raises, so a stale entry in + :data:`COLLAPSED_NULL_MEMBERS` fails loudly rather than silently expecting the + unmodified fixture. + """ + segment, _, rest = path.partition(".") + if segment.endswith("[]"): + assert rest, f"an array segment needs a member after it: {path!r}" + members = typing.cast("dict[str, typing.Any]", value)[segment[:-2]] + for element in typing.cast("list[typing.Any]", members): + _drop_path(element, rest) + return + mapping = typing.cast("dict[str, typing.Any]", value) + if rest: + _drop_path(mapping[segment], rest) + return + del mapping[segment] + + +def canonical_fixture_bytes(suite: str, name: str) -> bytes: + """The bytes re-serializing a canonical wire fixture's model must produce. + + The fixture, canonicalized by :func:`canonical_json_bytes`, minus the members + :data:`COLLAPSED_NULL_MEMBERS` documents Python cannot re-emit. Everything + else must match byte for byte. + """ + assert (suite, name) not in NON_CANONICAL_FIXTURES, ( + f"{suite}/{name} is deliberately non-canonical input; assert its expected" + " bytes explicitly" + ) + value = load_fixture(suite, name) + for path in COLLAPSED_NULL_MEMBERS.get((suite, name), ()): + _drop_path(value, path) + return canonical_json_bytes(value) def decode_fixture(cls: type[T], suite: str, name: str) -> T: @@ -88,6 +204,29 @@ def decode_fixture(cls: type[T], suite: str, name: str) -> T: return decode(cls, fixture_bytes(suite, name)) +def roundtrip_fixture(cls: type[T], suite: str, name: str) -> T: + """Decode a canonical wire fixture and assert the re-emitted **bytes** match. + + The byte-level statement of P1: a payload one language accepts round-trips + through any other unchanged. The comparison is against + :func:`canonical_fixture_bytes`, never against a parsed value. + + The failure message spells both sides out: pytest rewrites assertions only in + test modules, so a bare ``assert`` here would report nothing but + ``AssertionError`` — and a one-character difference in a long payload is + otherwise unfindable. + """ + model = decode_fixture(cls, suite, name) + emitted = encode_bytes(model) + expected = canonical_fixture_bytes(suite, name) + assert emitted == expected, ( + f"{suite}/{name} did not round-trip byte-identically\n" + f" emitted: {emitted.decode()}\n" + f" expected: {expected.decode()}" + ) + return model + + def violation_pairs(error: typing.Any) -> list[tuple[str, str]]: """A generated ``ValidationError``'s violations as ``(path, reason)`` pairs. diff --git a/samples/python/tests/test_chat.py b/samples/python/tests/test_chat.py index 2231e99e..f56ef4a0 100644 --- a/samples/python/tests/test_chat.py +++ b/samples/python/tests/test_chat.py @@ -13,35 +13,26 @@ from chat.models import DEFAULT_PRIORITY from tests.json_converter_helper import ( + canonical_json_bytes, converter_for, - decode_fixture, - encode, + encode_bytes, load_fixture, + roundtrip_fixture, violation_pairs, ) SUITE = "chat" -def expect_roundtrip( - name: str, - model_type: type[typing.Any], - *, - collapsed: tuple[str, ...] = (), -) -> typing.Any: - """Decode a fixture through the default converter, re-encode, compare. +def expect_roundtrip(name: str, model_type: type[typing.Any]) -> typing.Any: + """Decode a fixture through the default converter, re-encode, compare **bytes**. - ``collapsed`` names top-level keys the fixture carries as an explicit `null` - on an optional+nullable member: Python now drops them on re-serialize (see - :func:`test_message_full_optional_nullable_null_collapses`). Everything else - round-trips byte-identically. + The members Python cannot re-emit — an explicit `null` on an optional+nullable + member, which collapses (see + :func:`test_message_full_optional_nullable_null_collapses`) — are declared once + in ``COLLAPSED_NULL_MEMBERS``. Everything else matches byte for byte. """ - expected = typing.cast("dict[str, typing.Any]", load_fixture(SUITE, name)) - for key in collapsed: - del expected[key] - model = decode_fixture(model_type, SUITE, name) - assert encode(model) == expected - return model + return roundtrip_fixture(model_type, SUITE, name) def test_optional_non_nullable_members_reject_explicit_null() -> None: @@ -104,12 +95,22 @@ def test_serialize_omits_unset_defaulted_members() -> None: unset = Message(body="hello") assert unset.priority is None assert converter.to_transfer_type(unset) == {"kind": "text", "body": "hello"} + # The byte-level form of the claim: the wire the default-bearing member produces + # is exactly the wire that omits it. Byte-identity is the whole justification for + # keeping `default` off the dataclass field, so it is asserted on bytes. + assert encode_bytes(unset) == canonical_json_bytes( + {"kind": "text", "body": "hello"} + ) assert converter.to_transfer_type(Message(body="hello", priority=7)) == { "kind": "text", "body": "hello", "priority": 7, } + # A set integer member emits as an integer, never as `7.0`. + assert encode_bytes(Message(body="hello", priority=7)) == canonical_json_bytes( + {"kind": "text", "body": "hello", "priority": 7} + ) # A `const` member, unlike a `default`, DOES carry its value as the dataclass # default — it is the only admissible value, not a suggestion. assert unset.kind == "text" @@ -214,7 +215,7 @@ def test_canonical_wire_fixtures_roundtrip_through_the_default_converter() -> No full_message = typing.cast( Message, - expect_roundtrip("message-full.json", Message, collapsed=("replyToId",)), + expect_roundtrip("message-full.json", Message), ) assert full_message.reply_to_id is None assert full_message.priority == 7 diff --git a/samples/python/tests/test_kb.py b/samples/python/tests/test_kb.py index 27774ca3..f097dee5 100644 --- a/samples/python/tests/test_kb.py +++ b/samples/python/tests/test_kb.py @@ -13,10 +13,13 @@ from kb._definitions import ValidationError from tests.json_converter_helper import ( + canonical_fixture_bytes, + canonical_json_bytes, converter_for, decode_fixture, - encode, + encode_bytes, load_fixture, + roundtrip_fixture, violation_pairs, ) @@ -24,10 +27,8 @@ def expect_roundtrip(name: str, model_type: type[typing.Any]) -> typing.Any: - """Decode a fixture through the default converter, re-encode, compare.""" - model = decode_fixture(model_type, SUITE, name) - assert encode(model) == load_fixture(SUITE, name) - return model + """Decode a fixture through the default converter, re-encode, compare **bytes**.""" + return roundtrip_fixture(model_type, SUITE, name) def test_kb_wire_fixtures_roundtrip_through_the_default_converter() -> None: @@ -68,9 +69,13 @@ def test_block_back_reference_null_collapses_on_roundtrip() -> None: block_wire = typing.cast("dict[str, typing.Any]", load_fixture(SUITE, "block.json")) assert block_wire["page"] is None - assert encode(block) == { - key: value for key, value in block_wire.items() if key != "page" - } + # The collapse, on bytes: `page` is the only difference, and + # `canonical_fixture_bytes` derives the expectation from the one central + # exception list (`COLLAPSED_NULL_MEMBERS`). + assert encode_bytes(block) == canonical_fixture_bytes(SUITE, "block.json") + assert encode_bytes(block) == canonical_json_bytes( + {key: value for key, value in block_wire.items() if key != "page"} + ) page = decode_fixture(Page, SUITE, "page.json") assert page.page_id == "page-1" @@ -81,6 +86,8 @@ def test_block_back_reference_null_collapses_on_roundtrip() -> None: assert page.blocks[0].style is not None assert page.blocks[0].style.bold is True + # The collapse holds at depth: the back-reference is dropped from every nested + # block, and nothing else about the page changes. page_wire = typing.cast("dict[str, typing.Any]", load_fixture(SUITE, "page.json")) expected_page = {**page_wire} expected_page["blocks"] = [ @@ -91,7 +98,8 @@ def test_block_back_reference_null_collapses_on_roundtrip() -> None: } for nested in typing.cast("list[typing.Any]", page_wire["blocks"]) ] - assert encode(page) == expected_page + assert encode_bytes(page) == canonical_json_bytes(expected_page) + assert encode_bytes(page) == canonical_fixture_bytes(SUITE, "page.json") def test_nested_violations_carry_the_parent_path() -> None: diff --git a/samples/python/tests/test_showcase.py b/samples/python/tests/test_showcase.py index c8660fef..ebfcaa7d 100644 --- a/samples/python/tests/test_showcase.py +++ b/samples/python/tests/test_showcase.py @@ -1,4 +1,5 @@ import dataclasses +import json import typing import pytest @@ -6,6 +7,7 @@ from showcase import ( Address, Attributes, + Choices, Circle, ContactPy, Extras, @@ -15,6 +17,8 @@ Showcase, ShowcaseDetailObject, ShowcaseLedgerValue, + ShowcaseLocation, + ShowcaseRowsItem, Square, TextNote, Widget, @@ -23,10 +27,11 @@ from showcase.models import DEFAULT_DEBUG, DEFAULT_GREETING, DEFAULT_RETRIES from tests.json_converter_helper import ( + canonical_json_bytes, converter_for, decode_fixture, - encode, - load_fixture, + encode_bytes, + roundtrip_fixture, violation_pairs, ) @@ -49,29 +54,20 @@ } -def expect_roundtrip( - name: str, - model_type: type[typing.Any], - *, - collapsed: tuple[str, ...] = (), -) -> typing.Any: - """Decode a fixture through the *default* data converter, re-encode, compare. +def expect_roundtrip(name: str, model_type: type[typing.Any]) -> typing.Any: + """Decode a fixture through the *default* data converter, re-encode, compare **bytes**. - ``collapsed`` names keys the fixture carries as an explicit `null` on an - optional+nullable member; Python drops those on re-serialize. Everything else - round-trips byte-identically — including a key the fixture omits on a member - carrying a schema `default`, which is advisory and never injected. + The only members that may differ are the explicit `null`s + ``COLLAPSED_NULL_MEMBERS`` declares on an optional+nullable member, which + collapse. Everything else round-trips byte-identically — including a key the + fixture omits on a member carrying a schema `default`, which is advisory and + never injected. """ - expected = typing.cast("dict[str, typing.Any]", load_fixture(SUITE, name)) - for key in collapsed: - del expected[key] - model = decode_fixture(model_type, SUITE, name) - assert encode(model) == expected - return model + return roundtrip_fixture(model_type, SUITE, name) -def expect_showcase(name: str, *, collapsed: tuple[str, ...] = ()) -> Showcase: - return typing.cast(Showcase, expect_roundtrip(name, Showcase, collapsed=collapsed)) +def expect_showcase(name: str) -> Showcase: + return typing.cast(Showcase, expect_roundtrip(name, Showcase)) def parse(raw: dict[str, typing.Any]) -> Showcase: @@ -85,6 +81,42 @@ def parse_violations(raw: dict[str, typing.Any]) -> list[tuple[str, str]]: return violation_pairs(excinfo.value) +def wire_with(member: str, literal: str) -> str: + """``BASE`` as wire *text*, with ``member`` set to a raw JSON ``literal``. + + Some wire values exist only as text: Python's ``json.loads`` accepts the + ``Infinity``/``-Infinity``/``NaN`` literals its dialect adds, decodes ``1e400`` + to ``inf``, and decodes an integer literal of any length to an unbounded + ``int``. Splicing the literal in and parsing it with ``json.loads`` is exactly + what the SDK's ``JSONPlainPayloadConverter`` does to an incoming payload, so + these values genuinely reach the converter from untrusted bytes. + """ + members = [f"{json.dumps(key)}: {json.dumps(value)}" for key, value in BASE.items()] + members.append(f"{json.dumps(member)}: {literal}") + return "{" + ", ".join(members) + "}" + + +def parse_wire_violations(member: str, literal: str) -> list[tuple[str, str]]: + """The violations raw wire text carrying ``literal`` at ``member`` produces.""" + raw = typing.cast("dict[str, typing.Any]", json.loads(wire_with(member, literal))) + with pytest.raises(ValidationError) as excinfo: + _ = parse(raw) + return violation_pairs(excinfo.value) + + +def serialize_violations(**replacements: typing.Any) -> list[tuple[str, str]]: + """The violations serializing a ``BASE`` model with ``replacements`` produces. + + In-memory construction is unchecked (Python §1), so a value past a bound only + surfaces here — and it must surface *before* any wire form exists (P12), which + is why the assertion is on the raised violations rather than on output. + """ + model = dataclasses.replace(parse(BASE), **replacements) + with pytest.raises(ValidationError) as excinfo: + _ = converter_for(Showcase).to_transfer_type(model) + return violation_pairs(excinfo.value) + + def test_const_and_enum_value_sets() -> None: # `kind`/`revision`/`enabled` are required consts; `status`/`tier`/`scale` are # required closed value sets. All six must be on the wire. @@ -244,7 +276,7 @@ def test_canonical_wire_fixtures_roundtrip_through_the_default_converter() -> No # showcase-nulls.json carries `middleName: null` on an optional+nullable # member, which collapses and is therefore dropped on re-serialize. - nulls = expect_showcase("showcase-nulls.json", collapsed=("middleName",)) + nulls = expect_showcase("showcase-nulls.json") assert nulls.middle_name is None # `category` is required+nullable, so ITS explicit null does survive. assert nulls.category is None @@ -297,6 +329,97 @@ def test_integer_semantics() -> None: assert parse_violations({**BASE, "count": bad}) == [ ("count", "expected integer") ] + # A plain integer member normalizes its wire form too, on bytes. + assert encode_bytes(parse({**BASE, "count": 3.0})) == canonical_json_bytes( + {**BASE, "count": 3} + ) + + +def test_integral_closed_value_sets_normalize_to_the_integer_wire_form() -> None: + """An integer `const`/`enum` routes the wire value through the spec-integer + parse before the membership comparison, so `1.0` becomes an `int` and re-emits + as `1` — matching Go's `parseIntegerField` and Java's `SpecNumbers.specLong`. + + Asserted on **bytes**: `1 == 1.0` in Python, so a parsed comparison passes + either way and cannot see a closed set that kept its wire `float`. + """ + model = parse({**BASE, "revision": 1.0, "tier": 2.0}) + # `is int` rather than `== 1`: a `float` would satisfy the equality. + assert type(model.revision) is int + assert type(model.tier) is int + assert encode_bytes(model) == canonical_json_bytes({**BASE, "tier": 2}) + + # Routing through the spec-integer parse also reinstates the two checks a + # closed numeric set used to bypass entirely: the fractional reject... + assert parse_violations({**BASE, "revision": 1.5}) == [ + ("revision", "expected integer") + ] + assert parse_violations({**BASE, "tier": 2.5}) == [("tier", "expected integer")] + # ...and the +/-(2**53-1) cap, which a value inside the closed set can exceed + # only by being outside it — so the cap must be reported before membership. + assert parse_violations({**BASE, "revision": 2**53 + 1}) == [ + ("revision", "expected integer") + ] + assert parse_violations({**BASE, "tier": -(2**53) - 1}) == [ + ("tier", "expected integer") + ] + + # A **float**-valued closed set has no `Literal` form (PEP 586) and keeps the + # wire value as it arrived, so `2.5` stays `2.5` rather than becoming `2`. + scaled = parse({**BASE, "scale": 2.5}) + assert scaled.scale == 2.5 + assert encode_bytes(scaled) == canonical_json_bytes({**BASE, "scale": 2.5}) + + +def test_non_finite_numbers_are_rejected_in_both_directions() -> None: + """`Infinity`/`-Infinity`/`NaN` are reachable from the wire — Python's + `json.loads` accepts the literals its dialect adds — and every one of them is a + violation rather than a crash or a round-trip. + + Bytes Go's `json.Unmarshal`, `JSON.parse` and Jackson all reject must never + become a model here, and must never be emitted either (P1/P12). + """ + # `ratio` carries `multipleOf`, whose `math.fmod(inf, 5)` raised `ValueError` + # (and `OverflowError` for an out-of-binary64 integer literal) — escaping the + # aggregated `ValidationError` entirely (P11). + for literal, rendered in [ + ("Infinity", "inf"), + ("-Infinity", "-inf"), + ("NaN", "nan"), + ("1e400", "inf"), + ]: + assert parse_wire_violations("ratio", literal) == [ + ("ratio", f"must be a finite number, got {rendered}") + ] + + # A 401-digit integer literal decodes to an unbounded Python `int`, which is + # past binary64 without ever being a `float`. + digits = "9" * 401 + assert parse_wire_violations("ratio", digits) == [ + ("ratio", f"must be a finite number, got {digits}") + ] + + # A `number` with **no** other constraint was the worse case: it parsed and + # re-serialized `inf` verbatim. `Circle.radius` is one, reached through a union. + assert parse_wire_violations("shape", '{"kind": "circle", "radius": Infinity}') == [ + ("shape.radius", "must be a finite number, got inf") + ] + + # The serialize direction: an unchecked dataclass holding `inf` fails before a + # byte is written, rather than emitting a value this module's own parser rejects. + assert serialize_violations(ratio=float("inf")) == [ + ("ratio", "must be a finite number, got inf") + ] + assert serialize_violations(ratio=float("-inf")) == [ + ("ratio", "must be a finite number, got -inf") + ] + assert serialize_violations(ratio=float("nan")) == [ + ("ratio", "must be a finite number, got nan") + ] + # A finite value on the boundary still serializes. + assert encode_bytes( + dataclasses.replace(parse(BASE), ratio=5.0) + ) == canonical_json_bytes({**BASE, "ratio": 5.0}) def test_string_length_constraints_roundtrip_and_reject() -> None: @@ -700,6 +823,55 @@ def test_array_branch_union_roundtrip_and_reject() -> None: ] +def test_union_array_branch_types_every_element() -> None: + """Once the wire token selects the array branch, the branch decodes + **elementwise** — the same element parse a declared array member runs — so a bad + element is rejected at its own index. + + The branch used to cast the whole value to `list[float]` and run only + `minItems`/`uniqueItems`, so any list at all was admitted: `["a", "b"]` + round-tripped verbatim while Go decodes `[]float64` and Java binds a typed + list, both rejecting (P1). + """ + assert parse_violations({**BASE, "measurements": [{"x": 1}]}) == [ + ("measurements[0]", "expected number") + ] + assert parse_violations({**BASE, "measurements": ["a"]}) == [ + ("measurements[0]", "expected number") + ] + # A `bool` is not a number, which is also what resolves the `True == 1` + # uniqueness discrepancy at its root: the element is rejected before + # `_check_unique_items` ever compares it against a `1`. + assert parse_violations({**BASE, "measurements": [1.0, True]}) == [ + ("measurements[1]", "expected number") + ] + # A non-finite element is caught per element too. + assert parse_wire_violations("measurements", "[Infinity]") == [ + ("measurements[0]", "must be a finite number, got inf") + ] + + two = parse_violations({**BASE, "measurements": ["a", "b"]}) + assert two[:2] == [ + ("measurements[0]", "expected number"), + ("measurements[1]", "expected number"), + ] + # `uniqueItems` then compares the two placeholders the rejected elements left + # behind and reports one more violation. A declared array member with + # `uniqueItems` does exactly the same (`aliases: [1, 2]`), so this is shared + # element-placeholder behaviour rather than anything the union adds; the + # accepted-and-rejected value set — the part P1 fixes — is unaffected. + assert two[2:] == [ + ("measurements", "duplicate items: element at index 1 equals index 0") + ] + + # Valid elements still decode, and re-emit with their wire form intact. + values = parse({**BASE, "measurements": [1.5, 2, 3.75]}) + assert values.measurements == [1.5, 2, 3.75] + assert encode_bytes(values) == canonical_json_bytes( + {**BASE, "measurements": [1.5, 2, 3.75]} + ) + + def test_element_position_unions_roundtrip_and_reject() -> None: # Unions in positions with no property of their own: an array element at a # named union (`shapes`), an array element at an inline union the loader names @@ -865,3 +1037,87 @@ def test_serialize_rejects_invalid_in_memory_values() -> None: 'property "shippingZip" is required when "shippingStreet" is present', ) ] + + +def test_serialize_aggregates_nested_violations_under_their_own_paths() -> None: + """P11/P12 on the serialize side: one model with independent failures at + several depths reports **all** of them, each fully pathed. + + `to_transfer_type` wrapped no nested conversion, so the first nested + `ValidationError` propagated raw — discarding both the parent's already + collected violations and its own path prefix. Every nested conversion now funnels + through `_collect`, the analogue of Go's `mergeNested`. + """ + model = dataclasses.replace( + parse(BASE), + # A flat member of the parent itself, which a raw nested error would discard. + name="", + # A union in an element position, reported at its index. + segments=["ab", -1], + # A `$ref` member (hoisted inline object), reported under the member. + location=ShowcaseLocation(city="", geo=None), + # An array of `$ref`, reported at index *and* member. + rows=[ShowcaseRowsItem(cell="a1"), ShowcaseRowsItem(cell="")], + # A typed map whose member type is a union, reported at key *and* member. + choices=Choices( + additional_properties={ + "a": Circle(kind=typing.cast(typing.Any, "nope"), radius=1.0) + } + ), + ) + with pytest.raises(ValidationError) as excinfo: + _ = converter_for(Showcase).to_transfer_type(model) + assert violation_pairs(excinfo.value) == [ + ("name", "must have length >= 1, got 0"), + ("segments[1]", "must be >= 0, got -1"), + ("location.city", "must have length >= 1, got 0"), + ("rows[1].cell", "must have length >= 1, got 0"), + ("choices.a.kind", 'must equal "circle"'), + ] + + # The nested shapes serialize cleanly when valid — the aggregation above is not + # a blanket rejection of nesting. + ok = dataclasses.replace( + parse(BASE), + location=ShowcaseLocation(city="Springfield", geo=None), + rows=[ShowcaseRowsItem(cell="a1")], + ) + assert encode_bytes(ok) == canonical_json_bytes( + {**BASE, "location": {"city": "Springfield"}, "rows": [{"cell": "a1"}]} + ) + + +def test_serialize_rejects_a_value_matching_no_union_branch() -> None: + """A union's serialize dispatch rejects a value in **no** branch (P12). + + The dispatch falls through to its last branch unguarded once a value has + matched, which is deliberate; a value matching nothing used to be emitted + verbatim — bytes every parser, including this one, rejects. + """ + # A `float` in a `str | int` union: past static typing only through a cast, and + # exactly what an untyped caller or a `typing.Any` boundary produces. + assert serialize_violations(id_or_name=typing.cast(typing.Any, 1.5)) == [ + ("idOrName", "expected one of: string, integer") + ] + assert serialize_violations(mode=typing.cast(typing.Any, 1.5)) == [ + ("mode", "expected one of: string, integer") + ] + # The same on a union mixing object and scalar branches, which names all three. + assert serialize_violations(shape_or_name=typing.cast(typing.Any, 1.5)) == [ + ("shapeOrName", "expected one of: Circle, Square, string") + ] + # ...and on one whose branches are an array and a string. + assert serialize_violations(measurements=typing.cast(typing.Any, 1.5)) == [ + ("measurements", "expected one of: list[float], string") + ] + # A no-branch value aggregates with an unrelated failure rather than short- + # circuiting it (P11). + assert serialize_violations(name="", id_or_name=typing.cast(typing.Any, 1.5)) == [ + ("name", "must have length >= 1, got 0"), + ("idOrName", "expected one of: string, integer"), + ] + # Each branch's own value still serializes. + for value in ("abc", 7): + assert encode_bytes( + dataclasses.replace(parse(BASE), id_or_name=value) + ) == canonical_json_bytes({**BASE, "idOrName": value}) diff --git a/samples/python/tests/test_temporal.py b/samples/python/tests/test_temporal.py index 5d6c0af7..c44df839 100644 --- a/samples/python/tests/test_temporal.py +++ b/samples/python/tests/test_temporal.py @@ -1,31 +1,77 @@ +import dataclasses import datetime import typing import pytest from temporal import Temporal -from temporal._definitions import ValidationError +from temporal._definitions import ( + _TEMPORAL_FRACTION_DIGITS, + _TEMPORAL_MAX_DURATION_SECONDS, + ValidationError, + _temporal_isoformat, +) from tests.json_converter_helper import ( + canonical_json_bytes, converter_for, decode_fixture, - encode, + encode_bytes, load_fixture, + roundtrip_fixture, violation_pairs, ) SUITE = "temporal" +#: The four required members, so a negative payload reports only what is under test. +BASE: dict[str, typing.Any] = { + "createdAt": "2021-06-15T12:30:45Z", + "birthday": "2000-01-01", + "alarm": "09:00:00", + "timeout": "PT0S", +} + def decode(name: str) -> Temporal: return decode_fixture(Temporal, SUITE, name) +def parse(**overrides: typing.Any) -> Temporal: + return converter_for(Temporal).from_transfer_type({**BASE, **overrides}, Temporal) + + +def parse_violations(**overrides: typing.Any) -> list[tuple[str, str]]: + """The ``(path, reason)`` pairs one bad Temporal payload produces. + + Every value under test here used to escape as a bare ``ValueError`` from a + ``datetime`` parser rather than as an aggregated ``ValidationError`` (P11), so + the assertion is as much that ``ValidationError`` is what surfaces as it is + about the reason text. + """ + with pytest.raises(ValidationError) as excinfo: + _ = parse(**overrides) + return violation_pairs(excinfo.value) + + +def serialize_violations(**replacements: typing.Any) -> list[tuple[str, str]]: + """The violations serializing a ``BASE`` model with ``replacements`` produces.""" + model = dataclasses.replace(parse(), **replacements) + with pytest.raises(ValidationError) as excinfo: + _ = converter_for(Temporal).to_transfer_type(model) + return violation_pairs(excinfo.value) + + +def unrepresentable(format_name: str, value: object, detail: str) -> str: + """The reason a materialized value the wire grammar cannot spell is reported + under: the format, the offending value, and *why* it cannot be written.""" + return f'must be a valid {format_name}, got "{value}": {detail}' + + def test_temporal_roundtrip_full() -> None: # Materialized temporals become native datetime/date/time/timedelta and # re-serialize (generator-owned) byte-identically for microsecond precision. - model = decode("temporal-full.json") - assert encode(model) == load_fixture(SUITE, "temporal-full.json") + model = roundtrip_fixture(Temporal, SUITE, "temporal-full.json") assert model.created_at.utcoffset() == datetime.timedelta(hours=2) assert model.created_at.microsecond == 123456 assert model.timeout == datetime.timedelta(minutes=90) @@ -33,21 +79,23 @@ def test_temporal_roundtrip_full() -> None: def test_temporal_roundtrip_minimal() -> None: - assert encode(decode("temporal-minimal.json")) == load_fixture( - SUITE, "temporal-minimal.json" - ) + _ = roundtrip_fixture(Temporal, SUITE, "temporal-minimal.json") def test_temporal_canonicalization() -> None: # Non-canonical input normalizes on re-serialize (uppercase T/Z, +00:00 -> Z, - # PT90M -> PT1H30M). + # PT90M -> PT1H30M). This is the one fixture whose re-emitted bytes differ from + # its own by design, so its expectation is spelled out rather than derived + # (`NON_CANONICAL_FIXTURES`). model = decode("temporal-canonicalize.json") - assert encode(model) == { - "createdAt": "2021-06-15T12:30:45Z", - "birthday": "2021-02-28", - "alarm": "12:30:45Z", - "timeout": "PT1H30M", - } + assert encode_bytes(model) == canonical_json_bytes( + { + "createdAt": "2021-06-15T12:30:45Z", + "birthday": "2021-02-28", + "alarm": "12:30:45Z", + "timeout": "PT1H30M", + } + ) def test_temporal_nulls_collapse_on_roundtrip() -> None: @@ -66,28 +114,19 @@ def test_temporal_nulls_collapse_on_roundtrip() -> None: ) assert wire["deletedAt"] is None assert wire["archivedOn"] is None - # The explicit nulls are gone from the re-encoded wire; everything else survives. - assert encode(model) == { - key: value - for key, value in wire.items() - if key not in ("deletedAt", "archivedOn") - } + # The explicit nulls are gone from the re-encoded wire; every other byte survives. + assert encode_bytes(model) == canonical_json_bytes( + { + key: value + for key, value in wire.items() + if key not in ("deletedAt", "archivedOn") + } + ) def test_temporal_absent_and_explicit_null_are_indistinguishable() -> None: # The collapse, stated directly: the two payloads produce equal models. - base: dict[str, typing.Any] = { - "createdAt": "2021-06-15T12:30:45Z", - "birthday": "2000-01-01", - "alarm": "09:00:00", - "timeout": "PT0S", - } - converter = converter_for(Temporal) - absent = converter.from_transfer_type(base, Temporal) - explicit_null = converter.from_transfer_type( - {**base, "deletedAt": None, "archivedOn": None}, Temporal - ) - assert absent == explicit_null + assert parse() == parse(deletedAt=None, archivedOn=None) def test_missing_required_members_aggregate() -> None: @@ -110,18 +149,7 @@ def test_non_object_payload_is_a_single_structural_violation() -> None: def test_unknown_member_is_rejected() -> None: - with pytest.raises(ValidationError) as excinfo: - _ = converter_for(Temporal).from_transfer_type( - { - "createdAt": "2021-06-15T12:30:45Z", - "birthday": "2000-01-01", - "alarm": "09:00:00", - "timeout": "PT0S", - "nope": 1, - }, - Temporal, - ) - assert violation_pairs(excinfo.value) == [("nope", "unknown field")] + assert parse_violations(nope=1) == [("nope", "unknown field")] @pytest.mark.parametrize( @@ -136,17 +164,216 @@ def test_unknown_member_is_rejected() -> None: def test_temporal_materialized_narrowing_rejects( field: str, value: str, format_name: str ) -> None: - base: dict[str, typing.Any] = { - "createdAt": "2021-06-15T12:30:45Z", - "birthday": "2000-01-01", - "alarm": "09:00:00", - "timeout": "PT0S", - } - base[field] = value - with pytest.raises(ValidationError) as excinfo: - _ = converter_for(Temporal).from_transfer_type(base, Temporal) # The reason names the format and the offending value, rendered in its JSON # form exactly as Go and TypeScript render it. - assert violation_pairs(excinfo.value) == [ + assert parse_violations(**{field: value}) == [ (field, f'must be a valid {format_name}, got "{value}"') ] + + +def test_year_zero_is_a_violation_rather_than_a_value_error() -> None: + """`datetime.MINYEAR` is 1, so year 0000 — which the wire grammar admits and + Go/TypeScript/Java all materialize — has no Python representation at all. + + The pinned regex accepts it, so the value reached `fromisoformat` and escaped as + a bare `ValueError` instead of the aggregated `ValidationError` (P11). It is now + rejected, and the reason names the limit rather than implying the timestamp was + malformed: this is a genuine per-language accept-set divergence a caller has to + be able to read. + """ + limit = f"year 0000 is not representable (datetime.MINYEAR is {datetime.MINYEAR})" + assert parse_violations(createdAt="0000-01-01T00:00:00Z") == [ + ("createdAt", f'must be a valid date-time, got "0000-01-01T00:00:00Z": {limit}') + ] + assert parse_violations(birthday="0000-12-31") == [ + ("birthday", f'must be a valid date, got "0000-12-31": {limit}') + ] + # Aggregation still holds — the year-0000 reject is a violation like any other, + # not an early exit. + assert parse_violations( + createdAt="0000-01-01T00:00:00Z", birthday="0000-01-01" + ) == [ + ( + "createdAt", + f'must be a valid date-time, got "0000-01-01T00:00:00Z": {limit}', + ), + ("birthday", f'must be a valid date, got "0000-01-01": {limit}'), + ] + # Year 0001 is the first representable year and is accepted. + assert parse(createdAt="0001-01-01T00:00:00Z").created_at.year == 1 + + +@pytest.mark.parametrize( + "fraction,microsecond,emitted", + [ + # RFC 3339 allows any number of fractional digits. `isoformat` writes only + # 3 or 6, and before 3.11 `fromisoformat` parses only what `isoformat` + # writes -- so every width below except `.123456` used to raise on the + # declared 3.10 floor while every other target accepted it. + (".1", 100000, ".1"), + (".12", 120000, ".12"), + (".123", 123000, ".123"), + (".12345", 123450, ".12345"), + (".123456", 123456, ".123456"), + # Past `datetime`'s own microsecond resolution the extra digits are dropped + # -- the bounded loss P1 exception (b) allows, mirroring Go's truncation at + # nanoseconds -- rather than the value being rejected. + (".1234567", 123456, ".123456"), + (".1234567890", 123456, ".123456"), + # A fraction of all zeros carries no sub-second component at all, so the + # canonical form drops it. + (".000", 0, ""), + ], +) +def test_sub_second_precision_is_accepted_at_every_width( + fraction: str, microsecond: int, emitted: str +) -> None: + """Every fractional-second width the wire grammar admits parses, and re-emits + canonically — `.1` still writes as `.1`, not as `.100000`.""" + model = parse( + createdAt=f"2021-06-15T12:30:45{fraction}Z", + alarm=f"09:00:00{fraction}Z", + ) + assert model.created_at.microsecond == microsecond + assert model.alarm.microsecond == microsecond + assert encode_bytes(model) == canonical_json_bytes( + { + **BASE, + "createdAt": f"2021-06-15T12:30:45{emitted}Z", + "alarm": f"09:00:00{emitted}Z", + } + ) + + +def test_temporal_isoformat_pads_the_fraction_to_datetime_resolution() -> None: + """The interpreter-independent statement of the fix above. + + `test_sub_second_precision_is_accepted_at_every_width` only *fails* on an + interpreter whose `fromisoformat` is picky about the fraction width — 3.10, the + declared floor. From 3.11 on, `fromisoformat` accepts every width itself, so on + a newer interpreter that test passes with or without the normalization and + proves nothing. This asserts the normalization directly, so the guard holds on + every interpreter: the fraction handed to `fromisoformat` is always exactly + `_TEMPORAL_FRACTION_DIGITS` wide, which is the one width every supported + version parses. + """ + assert _TEMPORAL_FRACTION_DIGITS == 6 + for wire, normalized in [ + ("2021-06-15T12:30:45.1Z", "2021-06-15T12:30:45.100000+00:00"), + ("2021-06-15T12:30:45.12Z", "2021-06-15T12:30:45.120000+00:00"), + ("2021-06-15T12:30:45.1234567890Z", "2021-06-15T12:30:45.123456+00:00"), + ("2021-06-15t12:30:45.1-05:00", "2021-06-15T12:30:45.100000-05:00"), + # No fraction and no `Z` are both left alone. + ("2021-06-15T12:30:45+02:00", "2021-06-15T12:30:45+02:00"), + ]: + assert _temporal_isoformat(wire) == normalized + # The point of the padding: this is the spelling every supported + # interpreter's `fromisoformat` accepts, 3.10 included. + _ = datetime.datetime.fromisoformat(normalized) + + for wire, normalized in [ + ("09:00:00.1Z", "09:00:00.100000+00:00"), + ("09:00:00.1234567890z", "09:00:00.123456+00:00"), + ("09:00:00", "09:00:00"), + ]: + assert _temporal_isoformat(wire) == normalized + _ = datetime.time.fromisoformat(normalized) + + +def test_oversized_duration_components_are_violations_rather_than_crashes() -> None: + """CPython refuses `int()` on a string of more than 4300 digits, so a long + numeric component crashed the parse. The magnitude is now bounded by digit count + before any conversion is attempted.""" + huge = "9" * 5000 + assert parse_violations(timeout=f"PT{huge}S") == [ + ("timeout", f'must be a valid duration, got "PT{huge}S"') + ] + # Leading zeros are stripped before the digit count, matching TypeScript's + # `Number()`, so a padded but in-range value is still accepted. + assert parse(timeout="PT" + "0" * 5000 + "30S").timeout == datetime.timedelta( + seconds=30 + ) + # The cap itself: one second over is rejected, the cap exactly is accepted. + assert parse_violations(timeout=f"PT{_TEMPORAL_MAX_DURATION_SECONDS + 1}S") == [ + ( + "timeout", + f'must be a valid duration, got "PT{_TEMPORAL_MAX_DURATION_SECONDS + 1}S"', + ) + ] + assert parse( + timeout=f"PT{_TEMPORAL_MAX_DURATION_SECONDS}S" + ).timeout == datetime.timedelta(seconds=_TEMPORAL_MAX_DURATION_SECONDS) + # A multi-component duration overflows on the sum, not on one component. + summed = f"PT{_TEMPORAL_MAX_DURATION_SECONDS // 3600}H59M59S" + assert parse_violations(timeout=summed) == [ + ("timeout", f'must be a valid duration, got "{summed}"') + ] + + +def test_serialize_rejects_temporal_values_the_wire_form_cannot_carry() -> None: + """P12 on the serialize side: a dataclass is constructed unchecked, so a value + the narrowed wire grammar cannot spell reaches serialize. + + Without these predicates the converter emitted wire bytes its own parser + rejects — the exact asymmetry P12 exists to forbid. Each violation is reported + under the field's own name, so a caller reads it like any other. + """ + naive = datetime.datetime(2021, 6, 15, 12, 30, 45) + negative = datetime.timedelta(seconds=-1) + fractional = datetime.timedelta(milliseconds=500) + over_cap = datetime.timedelta(seconds=_TEMPORAL_MAX_DURATION_SECONDS + 1) + # A UTC offset finer than the minute the wire form spells would be silently lost. + sub_minute = datetime.timezone(datetime.timedelta(seconds=30)) + offset_datetime = datetime.datetime(2021, 6, 15, 12, 30, 45, tzinfo=sub_minute) + offset_time = datetime.time(9, 0, tzinfo=sub_minute) + + naive_reason = unrepresentable( + "date-time", naive, "a naive datetime carries no UTC offset" + ) + negative_reason = unrepresentable( + "duration", negative, "a duration cannot be negative" + ) + + # A naive datetime has no offset the required wire form could carry. + assert serialize_violations(created_at=naive) == [("createdAt", naive_reason)] + # The wire duration grammar is unsigned... + assert serialize_violations(timeout=negative) == [("timeout", negative_reason)] + # ...whole-second... + assert serialize_violations(timeout=fractional) == [ + ( + "timeout", + unrepresentable( + "duration", fractional, "a duration cannot carry a fraction of a second" + ), + ) + ] + # ...and capped. + assert serialize_violations(timeout=over_cap) == [ + ( + "timeout", + unrepresentable( + "duration", + over_cap, + f"a duration cannot exceed {_TEMPORAL_MAX_DURATION_SECONDS} seconds", + ), + ) + ] + # The sub-minute offset, on a date-time and on a time alike. + sub_minute_detail = "the UTC offset 0:00:30 is not a whole number of minutes" + assert serialize_violations(created_at=offset_datetime) == [ + ("createdAt", unrepresentable("date-time", offset_datetime, sub_minute_detail)) + ] + assert serialize_violations(alarm=offset_time) == [ + ("alarm", unrepresentable("time", offset_time, sub_minute_detail)) + ] + + # Independent failures at different members aggregate into one error (P11). + assert serialize_violations(created_at=naive, timeout=negative) == [ + ("createdAt", naive_reason), + ("timeout", negative_reason), + ] + + # A `date` needs no predicate: every `datetime.date` writes a valid wire date. + assert encode_bytes( + dataclasses.replace(parse(), birthday=datetime.date(1, 1, 1)) + ) == canonical_json_bytes({**BASE, "birthday": "0001-01-01"}) diff --git a/samples/python/tests/test_wire_fixtures.py b/samples/python/tests/test_wire_fixtures.py new file mode 100644 index 00000000..74886014 --- /dev/null +++ b/samples/python/tests/test_wire_fixtures.py @@ -0,0 +1,152 @@ +"""The byte-level round-trip sweep over *every* canonical wire fixture. + +`samples/wire/json_schema/` is the cross-language contract: the Go, TypeScript and +Java suites read the same files. P1 says a value one language accepts round-trips +through any other unchanged, so the statement this file makes is deliberately +blunt — decode each fixture through the **default** data converter, re-encode, and +compare the payload's own **bytes** against the canonicalized fixture. + +Bytes, not a parsed value, because a parsed comparison is blind to the two things +that matter most here: the wire form of a number (`1` vs `1.0` — `1 == 1.0` in +Python, which is how an `integer` `const` that kept its wire `float` went +unnoticed) and the exact escaping of a string. + +Canonicalization normalizes only insignificant whitespace and member order (the +SDK's payload converter writes compact, key-sorted JSON while the fixture files are +formatted for humans) — see `canonical_json_bytes`. + +The per-suite files assert what each fixture *means*; this one asserts that no +fixture escapes the byte comparison. The model table is exhaustive by test, so a +newly added fixture fails here until it is declared. +""" + +from __future__ import annotations + +import typing + +import chat +import kb +import showcase +import temporal + +from tests.json_converter_helper import ( + COLLAPSED_NULL_MEMBERS, + NON_CANONICAL_FIXTURES, + canonical_fixture_bytes, + canonical_json_bytes, + decode, + decode_fixture, + encode_bytes, + fixture_bytes, + fixture_dir, + load_fixture, + roundtrip_fixture, +) + +#: Every fixture in `samples/wire/json_schema/`, with the model it round-trips +#: through. Exhaustive: `test_every_wire_fixture_is_declared` fails if a file is +#: added, removed or renamed without updating this table. +WIRE_FIXTURES: dict[tuple[str, str], type[typing.Any]] = { + ("chat", "labels.json"): chat.Labels, + ("chat", "message-full.json"): chat.Message, + ("chat", "message-minimal.json"): chat.Message, + ("chat", "room-open.json"): chat.Room, + ("chat", "send-message-input.json"): chat.SendMessageInput, + ("chat", "send-message-output.json"): chat.SendMessageOutput, + ("kb", "block.json"): kb.Block, + ("kb", "category-tree.json"): kb.Category, + ("kb", "get-category-tree-input.json"): kb.GetCategoryTreeInput, + ("kb", "get-page-input.json"): kb.GetPageInput, + ("kb", "page.json"): kb.Page, + ("kb", "put-block-output.json"): kb.PutBlockOutput, + ("showcase", "address-open.json"): showcase.Address, + ("showcase", "attributes.json"): showcase.Attributes, + ("showcase", "contact.json"): showcase.ContactPy, + ("showcase", "extras.json"): showcase.Extras, + ("showcase", "labels.json"): showcase.Labels, + ("showcase", "settings.json"): showcase.Settings, + ("showcase", "showcase-bytes.json"): showcase.Showcase, + ("showcase", "showcase-detail-object.json"): showcase.Showcase, + ("showcase", "showcase-detail-string.json"): showcase.Showcase, + ("showcase", "showcase-element-unions.json"): showcase.Showcase, + ("showcase", "showcase-format.json"): showcase.Showcase, + ("showcase", "showcase-freeform-string.json"): showcase.Showcase, + ("showcase", "showcase-freeform.json"): showcase.Showcase, + ("showcase", "showcase-full.json"): showcase.Showcase, + ("showcase", "showcase-inline-shapes.json"): showcase.Showcase, + ("showcase", "showcase-measurements-array.json"): showcase.Showcase, + ("showcase", "showcase-measurements-string.json"): showcase.Showcase, + ("showcase", "showcase-metrics.json"): showcase.Showcase, + ("showcase", "showcase-minimal.json"): showcase.Showcase, + ("showcase", "showcase-note-link.json"): showcase.Showcase, + ("showcase", "showcase-note-text.json"): showcase.Showcase, + ("showcase", "showcase-nulls.json"): showcase.Showcase, + ("showcase", "showcase-patterns.json"): showcase.Showcase, + ("showcase", "showcase-shape-circle.json"): showcase.Showcase, + ("showcase", "showcase-shape-or-name-square.json"): showcase.Showcase, + ("showcase", "showcase-shape-or-name-string.json"): showcase.Showcase, + ("showcase", "showcase-shape-square.json"): showcase.Showcase, + ("showcase", "showcase-strings.json"): showcase.Showcase, + ("showcase", "showcase-union-int.json"): showcase.Showcase, + ("showcase", "showcase-union-string.json"): showcase.Showcase, + ("showcase", "widget.json"): showcase.Widget, + ("temporal", "temporal-canonicalize.json"): temporal.Temporal, + ("temporal", "temporal-full.json"): temporal.Temporal, + ("temporal", "temporal-minimal.json"): temporal.Temporal, + ("temporal", "temporal-nulls.json"): temporal.Temporal, +} + +SUITES = ("chat", "kb", "showcase", "temporal") + + +def test_every_wire_fixture_is_declared() -> None: + """The table covers the fixture tree exactly — no file left unswept.""" + on_disk = { + (suite, path.name) + for suite in SUITES + for path in fixture_dir(suite).iterdir() + if path.suffix == ".json" + } + assert on_disk == set(WIRE_FIXTURES) + + # Both exception lists are keyed by real fixtures, so a stale or misspelled + # entry cannot sit there silently weakening the sweep. + assert set(COLLAPSED_NULL_MEMBERS) <= on_disk + assert NON_CANONICAL_FIXTURES <= on_disk + + +def test_every_wire_fixture_roundtrips_byte_identically() -> None: + """P1 on bytes, for every fixture but the documented exceptions.""" + for (suite, name), model_type in sorted(WIRE_FIXTURES.items()): + if (suite, name) in NON_CANONICAL_FIXTURES: + # Deliberately non-canonical input; its expected bytes are asserted by + # `test_temporal.test_temporal_canonicalization`. + continue + _ = roundtrip_fixture(model_type, suite, name) + + +def test_collapsed_members_are_the_only_difference() -> None: + """Every exception entry earns its place, and drops nothing more. + + Two directions at once: the fixture with its declared members intact must + *fail* the byte comparison (so an entry cannot be added for a fixture that + already round-trips, silently weakening the sweep), and with them dropped it + must pass exactly (so an entry cannot drop a member Python does re-emit). + """ + for (suite, name), dropped in sorted(COLLAPSED_NULL_MEMBERS.items()): + assert dropped, f"{suite}/{name} declares no collapsed member" + model = decode_fixture(WIRE_FIXTURES[suite, name], suite, name) + assert encode_bytes(model) != canonical_json_bytes(load_fixture(suite, name)) + assert encode_bytes(model) == canonical_fixture_bytes(suite, name) + + +def test_re_encoding_is_idempotent() -> None: + """A model decoded from re-emitted bytes re-emits those same bytes. + + The loop a peer language exercises by replying with what it received: the + encoder's own output must be a fixed point of decode-then-encode, so no + normalization keeps drifting on each hop. + """ + for (suite, name), model_type in sorted(WIRE_FIXTURES.items()): + first = encode_bytes(decode(model_type, fixture_bytes(suite, name))) + assert encode_bytes(decode(model_type, first)) == first diff --git a/tests/generate_python.rs b/tests/generate_python.rs index 707f62f6..9519e308 100644 --- a/tests/generate_python.rs +++ b/tests/generate_python.rs @@ -86,6 +86,117 @@ properties: - { type: string, enum: [auto, manual] } "#; +/// Properties named after the converter body's *own* identifiers — its locals +/// (`violations`, `raw`, `out`), the builtins it calls (`len`, `int`, `str`, +/// `bool`, `dict`, `isinstance`), the modules it imports (`typing`, `math`, `re`), +/// the loop temporaries it uses (`key`, `value`) and the converter method's +/// parameters (`self`, `type_hint`). +/// +/// A property may be named anything, so none of these is reserved. No sample +/// schema declares one, which is why this lives here rather than in the Python +/// sample suite: the shadow it used to cause was *silently* wrong (the collected +/// violations were thrown away and an invalid payload came back as a model), so +/// nothing short of running the generated converter proves it is gone. +/// +/// The object is open so the catch-all's `_<MODEL>_DECLARED` frozenset — another +/// name synthesized from these properties — is exercised too. +const SHADOWED_NAME_SCHEMA: &str = r#"$schema: https://json-schema.org/draft/2020-12/schema +type: object +additionalProperties: true +required: [violations] +properties: + violations: { type: string, minLength: 2 } + raw: { type: string } + out: { type: string } + len: { type: integer } + int: { type: integer } + str: { type: string } + bool: { type: boolean } + dict: { type: object, additionalProperties: true } + isinstance: { type: string } + typing: { type: string } + math: { type: number } + re: { type: string, pattern: "^[a-z]+$" } + key: { type: string } + value: { type: string } + self: { type: string } + typeHint: { type: string } +"#; + +/// Drives the generated converter for `SHADOWED_NAME_SCHEMA` end to end: a valid +/// payload must round-trip, and an invalid one must raise the aggregated +/// `ValidationError` with every violation intact in **both** directions. +const SHADOWED_NAME_RUNTIME_CHECK: &str = r#" +import sys + +root, package = sys.argv[1], sys.argv[2] +sys.path.insert(0, root) +models = __import__(package + ".models", fromlist=["*"]) +definitions = __import__(package + "._definitions", fromlist=["*"]) + +Shadow = models.Shadow +converter = getattr(Shadow, "__temporal_transfer_type_converter") + +valid = { + "violations": "ok", + "raw": "r", + "out": "o", + "len": 1, + "int": 2, + "str": "s", + "bool": True, + "dict": {"a": 1}, + "isinstance": "i", + "typing": "t", + "math": 1.5, + "re": "abc", + "key": "k", + "value": "v", + "self": "me", + "typeHint": "h", + "unknown": [1, 2], +} + +# Every one of `raw`, `len`, `int`, `str`, `bool`, `dict`, `isinstance`, `typing`, +# `math` and `out` used to crash *every* payload, valid ones included. +model = converter.from_transfer_type(valid, Shadow) +assert model.violations == "ok", model.violations +assert model.type_hint == "h", model.type_hint +assert model.additional_properties == {"unknown": [1, 2]}, model.additional_properties +assert converter.to_transfer_type(model) == valid, converter.to_transfer_type(model) + +expected = [ + ("violations", "must have length >= 2, got 1"), + ("math", "must be a finite number, got inf"), + ("re", 'must match pattern ^[a-z]+\\Z, got "ABC"'), +] + +# The critical case: a property named `violations` rebound the violation +# accumulator, so the collected violations were discarded and the invalid payload +# came back as a model. Reaching the `else` here is that silent failure. +bad = dict(valid, violations="a", re="ABC", math=float("inf")) +try: + converter.from_transfer_type(bad, Shadow) +except definitions.ValidationError as error: + got = [(item.path, item.reason) for item in error.violations] + assert got == expected, got +else: + raise AssertionError("an invalid payload was accepted: validation was disabled") + +# The serialize body has locals of its own, so it needs the same proof (P12). A +# dataclass validates nothing on assignment, so the model is simply mutated. +model.violations = "a" +model.re = "ABC" +model.math = float("inf") +try: + converter.to_transfer_type(model) +except definitions.ValidationError as error: + got = [(item.path, item.reason) for item in error.violations] + assert got == expected, got +else: + raise AssertionError("an invalid model was serialized: validation was disabled") +"#; + fn project_root() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")) } @@ -351,6 +462,28 @@ for path in sorted(root.rglob("*.py")): assert!(status.success()); } +/// The interpreter the generated packages are exercised with. The advanced +/// project's environment is the one already provisioned with `temporalio`, which +/// the generated converters import. +fn sample_python_interpreter() -> PathBuf { + project_root().join("advanced/samples/python/.venv/bin/python") +} + +/// Runs `script` under that interpreter, failing the test on a non-zero exit. Used +/// where a rendered-output assertion cannot reach the behavior under test — a +/// silently disabled validator renders perfectly readable code. +fn assert_python_script_succeeds(script: &str, args: &[&str]) { + let status = Command::new(sample_python_interpreter()) + .args(["-c", script]) + .args(args) + .status() + .unwrap(); + assert!( + status.success(), + "generated package failed its runtime check" + ); +} + fn unique_output_path(label: &str) -> PathBuf { let unique = SystemTime::now() .duration_since(UNIX_EPOCH) @@ -605,6 +738,38 @@ fn python_example_suite_type_checks_and_runs() { } } +/// The generated JSON-Schema runtime must also *run* on the declared floor, +/// `requires-python = ">=3.10"` — not merely parse as 3.10 syntax. +/// +/// `assert_python_310_syntax_compatible` checks the AST at +/// `feature_version=(3, 10)`, which is a syntax check only, and the project +/// environments above are whatever interpreter `uv` picked (3.13 here). That left a +/// real class of bug uncovered: before 3.11, `datetime.fromisoformat` parses only +/// the fractional-second widths `isoformat` writes, so an RFC 3339 `.1` raised on +/// 3.10 while passing everywhere else. Every test in the suite was green. +/// +/// The environment lives outside the project directory so the checked-in one is +/// untouched and neither `basedpyright` nor `ruff` picks it up (their excludes name +/// `.venv`). It is created on demand in well under a second from the same locked +/// `uv.lock`, so this is one extra resolve, not a second maintained lockfile; `uv` +/// fetches a managed CPython 3.10 if the host has none. +#[test] +fn python_json_samples_run_on_the_declared_python_floor() { + let root = project_root(); + let floor_environment = root.join("target/python-floor-venv"); + + let status = Command::new("uv") + .current_dir(samples_python_root(&root)) + .env("UV_PROJECT_ENVIRONMENT", &floor_environment) + .args(["run", "--python", "3.10", "--locked", "pytest"]) + .status() + .unwrap(); + assert!( + status.success(), + "the JSON-Schema sample suite failed on Python 3.10, the declared floor" + ); +} + #[test] fn python_request_models_are_bidirectional_wire_models() { let root = project_root(); @@ -1082,7 +1247,7 @@ fn python_json_cross_module_py_name_override_moves_every_reference() { .unwrap(); let declaring = fs::read_to_string(output_path.join("content/page/models.py")).unwrap(); - assert!(declaring.contains("class RenamedPage(pydantic.BaseModel):")); + assert!(declaring.contains("class RenamedPage:")); let services = fs::read_to_string(output_path.join("kb/services.py")).unwrap(); for expected in [ @@ -1230,3 +1395,96 @@ services: assert_eq!(barrel.matches("import Page").count(), 1, "{barrel}"); fs::remove_dir_all(temp_dir).unwrap(); } + +/// A declared property named after one of the converter's own identifiers must not +/// shadow it: the parse body holds each property's value in a `<member>_value` slot +/// local, so the shadow is structurally impossible rather than merely unlisted. +/// +/// Rendered output is asserted for the mechanism, and the generated package is then +/// **run**, because the failure this guards against is silent: `violations: +/// list[Violation] = []` rebound by a `violations` property's local discarded every +/// collected violation and returned an invalid payload as a model. +/// See `specs/json-schema/PRINCIPLES.md` (P15) and +/// `specs/json-schema/features/properties.md`. +#[test] +fn python_json_property_names_never_shadow_converter_locals() { + let temp_dir = unique_output_path("py-json-shadowed-names"); + fs::create_dir_all(&temp_dir).unwrap(); + let input_path = temp_dir.join("shadow.yaml"); + fs::write(&input_path, SHADOWED_NAME_SCHEMA).unwrap(); + let output_path = temp_dir.join("shadow_package"); + + generate_to_file(&GenerateRequest { + language: nexgen::language::Language::Python, + input_paths: vec![input_path], + support_paths: Vec::new(), + descriptor_paths: Vec::new(), + output_path: output_path.clone(), + format: false, + generate_native_api: false, + java_package_name: None, + ts_date_time_types: Default::default(), + }) + .unwrap(); + let rendered = fs::read_to_string(output_path.join("models.py")).unwrap(); + + // The accumulator, the decoded mapping and the emitted mapping keep their own + // names, unshadowed. + assert!(rendered.contains("violations: list[Violation] = []")); + assert!(rendered.contains("raw = typing.cast(\"dict[str, typing.Any]\", value)")); + assert!(rendered.contains("out: dict[str, typing.Any] = {}")); + // Every property is held in a `_value` slot instead, and its temporaries hang + // off that slot rather than off the bare member identifier. + for member in [ + "violations", + "raw", + "out", + "len", + "int", + "str", + "bool", + "dict", + "isinstance", + "typing", + "math", + "re", + "key", + "value", + "self", + "type_hint", + ] { + assert!( + rendered.contains(&format!("{member}_value")), + "no `_value` slot for the `{member}` property" + ); + } + // No property is ever assigned to its bare identifier, which is what shadowed. + for shadowing in [ + "\n violations = ", + "\n raw = raw[", + "\n len = ", + "\n int = ", + "\n str = ", + "\n dict = ", + "\n isinstance = ", + "\n typing = ", + "\n math = ", + "\n re = ", + "\n self = ", + ] { + assert!( + !rendered.contains(shadowing), + "a property rebound the converter's own `{}`", + shadowing.trim().trim_end_matches(" =").trim() + ); + } + // The synthesized catch-all frozenset carries the *wire* names, not the locals. + assert!(rendered.contains("_SHADOW_DECLARED: frozenset[str] = frozenset({\"violations\",")); + assert!(rendered.contains("\"typeHint\"}")); + + assert_python_script_succeeds( + SHADOWED_NAME_RUNTIME_CHECK, + &[temp_dir.to_str().unwrap(), "shadow_package"], + ); + fs::remove_dir_all(temp_dir).unwrap(); +} From 44ad00fad4c535b568e558d77e779a38930bcc8a Mon Sep 17 00:00:00 2001 From: Roey Berman <roey.berman@gmail.com> Date: Fri, 14 Aug 2026 13:59:48 -0700 Subject: [PATCH 08/20] Python: tuple membership for closed value sets, typed array-element reason Two lint/consistency fixes in the generated Python JSON-Schema layer, plus the gate that let the first one through. 1. A `const`/`enum` check now tests membership in a tuple of the admissible values in both directions. The parse side chained one `!=` per member, so a boolean `const` emitted `enabled_value_raw != True` -- a lint error in the user's repository (ruff E712) and nothing like hand-written Python (P2) -- and a multi-member `enum` emitted one comparison per member. The `!=` chain would likewise emit `!= None` (E711) for any future null-valued set. Parse and serialize now share one shape (`py_value_tuple`), which also drops the magic trailing comma that exploded every multi-member serialize-side test across five lines. A `contains` matcher's `const` takes the same shape, where a boolean matcher would have emitted `element == True`. 2. A mistyped array element now reports the type it failed to be (`tags[0]`, `expected string`), as an element of every other type already did and as Java's element loop reports. A plain `string` element took a special case that reported a bare `expected element`, naming neither the expected type nor anything the element's own indexed path did not already carry; the special case existed only to mirror the TypeScript emitter, whose `expected element` branch already diverges from Python for a *constrained* string element (and drops that element's constraints entirely -- a separate defect, TypeScript being out of scope here). Every element kind now takes the same parse the value in that position takes anywhere else, and `items.md` states the reason convention. `scripts/validate.sh` ran `ruff format --check` but never `ruff check`, so lint defects in generated Python were ungated -- which is how the E712 survived. Both Python tiers now run it. It also surfaced eight pre-existing E402s in the advanced tier's hand-written tests, whose `wit.*` imports sat below two path constants for no reason; they move up with the other imports, matching the sibling test module. --- CHANGELOG.md | 12 ++++ .../python/json_schema/api/chat/models.py | 4 +- .../api/kb/tree/category/models.py | 2 +- .../python/json_schema/api/showcase/models.py | 66 ++++++----------- .../python/tests/test_type_showcase.py | 6 +- .../samples/python/tests/test_user_service.py | 6 +- samples/python/chat/models.py | 4 +- samples/python/kb/tree/category/models.py | 2 +- samples/python/showcase/models.py | 66 ++++++----------- samples/python/tests/test_showcase.py | 16 +++++ scripts/validate.sh | 1 + specs/json-schema/features/items.md | 11 +++ src/generator/json_schema/python.rs | 70 ++++++++----------- 13 files changed, 123 insertions(+), 143 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 95b49589..2ebf3d29 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -156,6 +156,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 overrides, including TypeScript default constants and Go closed-value types. - JSON Schema: A root model can no longer silently collapse with a same-named `$defs` or synthesized model; the loader reports the conflicting origins. +- Python: A `const`/`enum` check now tests membership in a tuple of the + admissible values (`if value not in (True,)`) in both directions, where the + parse side chained one `!=` per member. A boolean `const` emitted + `value != True`, which is a lint error in the user's repository (ruff E712) + and reads nothing like hand-written Python, and a multi-member `enum` emitted + one comparison per member. The parse and serialize sides now share one + membership shape. +- Python: A mistyped **array element** now reports the type it failed to be + (`tags[0]`, `expected string`), as an element of every other type already did + and as Java reports. A plain `string` element reported a bare + `expected element`, which named neither the expected type nor anything the + element's own indexed path did not already carry. - Python: A declared property named after one of the converter's own locals **silently disabled validation**. A property named `violations` rebound the violation accumulator, so a payload that broke a constraint was returned as a diff --git a/advanced/samples/python/json_schema/api/chat/models.py b/advanced/samples/python/json_schema/api/chat/models.py index ce375e5b..5d9319d2 100644 --- a/advanced/samples/python/json_schema/api/chat/models.py +++ b/advanced/samples/python/json_schema/api/chat/models.py @@ -142,7 +142,7 @@ def from_transfer_type( kind_value_raw = raw["kind"] if not isinstance(kind_value_raw, str): violations.append(Violation(path="kind", reason="expected string")) - elif kind_value_raw != "text": + elif kind_value_raw not in ("text",): violations.append(Violation(path="kind", reason='must equal "text"')) else: kind_value = kind_value_raw @@ -303,7 +303,7 @@ def from_transfer_type(self, value: typing.Any, type_hint: type["Room"]) -> "Roo violations.append( Violation( path=members_value_item_path, - reason="expected element", + reason="expected string", ) ) else: diff --git a/advanced/samples/python/json_schema/api/kb/tree/category/models.py b/advanced/samples/python/json_schema/api/kb/tree/category/models.py index bf64975e..56213f62 100644 --- a/advanced/samples/python/json_schema/api/kb/tree/category/models.py +++ b/advanced/samples/python/json_schema/api/kb/tree/category/models.py @@ -157,7 +157,7 @@ def from_transfer_type( if not isinstance(swatches_value_element, str): violations.append( Violation( - path=swatches_value_item_path, reason="expected element" + path=swatches_value_item_path, reason="expected string" ) ) else: diff --git a/advanced/samples/python/json_schema/api/showcase/models.py b/advanced/samples/python/json_schema/api/showcase/models.py index e621b9b3..3cddd9ea 100644 --- a/advanced/samples/python/json_schema/api/showcase/models.py +++ b/advanced/samples/python/json_schema/api/showcase/models.py @@ -348,7 +348,7 @@ def from_transfer_type( kind_value_raw = raw["kind"] if not isinstance(kind_value_raw, str): violations.append(Violation(path="kind", reason="expected string")) - elif kind_value_raw != "circle": + elif kind_value_raw not in ("circle",): violations.append(Violation(path="kind", reason='must equal "circle"')) else: kind_value = kind_value_raw @@ -700,7 +700,7 @@ def from_transfer_type( kind_value_raw = raw["kind"] if not isinstance(kind_value_raw, str): violations.append(Violation(path="kind", reason="expected string")) - elif kind_value_raw != "link": + elif kind_value_raw not in ("link",): violations.append(Violation(path="kind", reason='must equal "link"')) else: kind_value = kind_value_raw @@ -994,7 +994,7 @@ def from_transfer_type( kind_value_raw = raw["kind"] if not isinstance(kind_value_raw, str): violations.append(Violation(path="kind", reason="expected string")) - elif kind_value_raw != "showcase": + elif kind_value_raw not in ("showcase",): violations.append( Violation(path="kind", reason='must equal "showcase"') ) @@ -1010,7 +1010,7 @@ def from_transfer_type( revision_value_raw, "revision", violations ) if revision_value_parsed is not None: - if revision_value_parsed != 1: + if revision_value_parsed not in (1,): violations.append(Violation(path="revision", reason="must equal 1")) else: revision_value = revision_value_parsed @@ -1022,7 +1022,7 @@ def from_transfer_type( enabled_value_raw = raw["enabled"] if not isinstance(enabled_value_raw, bool): violations.append(Violation(path="enabled", reason="expected boolean")) - elif enabled_value_raw != True: + elif enabled_value_raw not in (True,): violations.append(Violation(path="enabled", reason="must equal true")) else: enabled_value = enabled_value_raw @@ -1036,11 +1036,7 @@ def from_transfer_type( status_value_raw = raw["status"] if not isinstance(status_value_raw, str): violations.append(Violation(path="status", reason="expected string")) - elif ( - status_value_raw != "active" - and status_value_raw != "inactive" - and status_value_raw != "pending" - ): + elif status_value_raw not in ("active", "inactive", "pending"): violations.append( Violation( path="status", @@ -1057,11 +1053,7 @@ def from_transfer_type( tier_value_raw = raw["tier"] tier_value_parsed = _parse_spec_integer(tier_value_raw, "tier", violations) if tier_value_parsed is not None: - if ( - tier_value_parsed != 1 - and tier_value_parsed != 2 - and tier_value_parsed != 3 - ): + if tier_value_parsed not in (1, 2, 3): violations.append( Violation( path="tier", @@ -1081,7 +1073,7 @@ def from_transfer_type( and isinstance(scale_value_raw, (int, float)) ): violations.append(Violation(path="scale", reason="expected number")) - elif scale_value_raw != 1.5 and scale_value_raw != 2.5: + elif scale_value_raw not in (1.5, 2.5): violations.append( Violation( path="scale", @@ -1611,7 +1603,7 @@ def from_transfer_type( if not isinstance(tags_value_element, str): violations.append( Violation( - path=tags_value_item_path, reason="expected element" + path=tags_value_item_path, reason="expected string" ) ) else: @@ -1656,7 +1648,7 @@ def from_transfer_type( violations.append( Violation( path=aliases_value_item_path, - reason="expected element", + reason="expected string", ) ) else: @@ -1685,8 +1677,7 @@ def from_transfer_type( if not isinstance(roles_value_element, str): violations.append( Violation( - path=roles_value_item_path, - reason="expected element", + path=roles_value_item_path, reason="expected string" ) ) else: @@ -1694,7 +1685,7 @@ def from_transfer_type( roles_value_list.append(roles_value_item) _check_contains( roles_value_list, - lambda element: element == "admin", + lambda element: element in ("admin",), 1, 2, True, @@ -2350,11 +2341,7 @@ def to_transfer_type(self, value: "Showcase") -> typing.Any: if typing.cast("object", value.enabled) not in (True,): violations.append(Violation(path="enabled", reason="must equal true")) out["enabled"] = value.enabled - if typing.cast("object", value.status) not in ( - "active", - "inactive", - "pending", - ): + if typing.cast("object", value.status) not in ("active", "inactive", "pending"): violations.append( Violation( path="status", @@ -2362,11 +2349,7 @@ def to_transfer_type(self, value: "Showcase") -> typing.Any: ) ) out["status"] = value.status - if typing.cast("object", value.tier) not in ( - 1, - 2, - 3, - ): + if typing.cast("object", value.tier) not in (1, 2, 3): violations.append( Violation( path="tier", @@ -2374,10 +2357,7 @@ def to_transfer_type(self, value: "Showcase") -> typing.Any: ) ) out["tier"] = value.tier - if typing.cast("object", value.scale) not in ( - 1.5, - 2.5, - ): + if typing.cast("object", value.scale) not in (1.5, 2.5): violations.append( Violation( path="scale", @@ -2584,7 +2564,7 @@ def to_transfer_type(self, value: "Showcase") -> typing.Any: if value.roles is not None: _check_contains( value.roles, - lambda element: element == "admin", + lambda element: element in ("admin",), 1, 2, True, @@ -2624,10 +2604,7 @@ def to_transfer_type(self, value: "Showcase") -> typing.Any: out["idOrName"] = value.id_or_name if value.mode is not None: if isinstance(value.mode, str): - if typing.cast("object", value.mode) not in ( - "auto", - "manual", - ): + if typing.cast("object", value.mode) not in ("auto", "manual"): violations.append( Violation( path="mode", @@ -3851,7 +3828,7 @@ def from_transfer_type( kind_value_raw = raw["kind"] if not isinstance(kind_value_raw, str): violations.append(Violation(path="kind", reason="expected string")) - elif kind_value_raw != "square": + elif kind_value_raw not in ("square",): violations.append(Violation(path="kind", reason='must equal "square"')) else: kind_value = kind_value_raw @@ -3944,7 +3921,7 @@ def from_transfer_type( kind_value_raw = raw["kind"] if not isinstance(kind_value_raw, str): violations.append(Violation(path="kind", reason="expected string")) - elif kind_value_raw != "text": + elif kind_value_raw not in ("text",): violations.append(Violation(path="kind", reason='must equal "text"')) else: kind_value = kind_value_raw @@ -4513,10 +4490,7 @@ def _showcase_mode_from_transfer_type( ) -> typing.Literal["auto", "manual"] | int | None: if isinstance(value, str): narrowed = typing.cast('typing.Literal["auto", "manual"]', value) - if typing.cast("object", narrowed) not in ( - "auto", - "manual", - ): + if typing.cast("object", narrowed) not in ("auto", "manual"): violations.append( Violation( path=path, diff --git a/advanced/samples/python/tests/test_type_showcase.py b/advanced/samples/python/tests/test_type_showcase.py index bd169734..a29d4efb 100644 --- a/advanced/samples/python/tests/test_type_showcase.py +++ b/advanced/samples/python/tests/test_type_showcase.py @@ -9,14 +9,14 @@ from temporalio.testing import WorkflowEnvironment from temporalio.worker import UnsandboxedWorkflowRunner, Worker -APP_ROOT = Path(__file__).resolve().parent -OUTPUT_PATH = APP_ROOT.parent / "wit" / "type_showcase" - import wit.type_showcase as type_showcase import wit.type_showcase.models as type_showcase_models import wit.type_showcase.services as type_showcase_services from wit.type_showcase._resources import User +APP_ROOT = Path(__file__).resolve().parent +OUTPUT_PATH = APP_ROOT.parent / "wit" / "type_showcase" + GET_USER_OPERATION_INFO = type_showcase.__nexus_operation_registry__[ ("TypeShowcase", "GetUser") ] diff --git a/advanced/samples/python/tests/test_user_service.py b/advanced/samples/python/tests/test_user_service.py index a2bf6f6a..3b15020f 100644 --- a/advanced/samples/python/tests/test_user_service.py +++ b/advanced/samples/python/tests/test_user_service.py @@ -9,14 +9,14 @@ from temporalio.testing import WorkflowEnvironment from temporalio.worker import UnsandboxedWorkflowRunner, Worker -APP_ROOT = Path(__file__).resolve().parent -OUTPUT_PATH = APP_ROOT.parent / "wit" / "user_service" - import wit.user_service as user_service import wit.user_service.models as user_service_models import wit.user_service.services as user_service_services from wit.user_service._resources import User +APP_ROOT = Path(__file__).resolve().parent +OUTPUT_PATH = APP_ROOT.parent / "wit" / "user_service" + GET_USER_OPERATION_INFO = user_service.__nexus_operation_registry__[ ("UserService", "GetUser") ] diff --git a/samples/python/chat/models.py b/samples/python/chat/models.py index ce375e5b..5d9319d2 100644 --- a/samples/python/chat/models.py +++ b/samples/python/chat/models.py @@ -142,7 +142,7 @@ def from_transfer_type( kind_value_raw = raw["kind"] if not isinstance(kind_value_raw, str): violations.append(Violation(path="kind", reason="expected string")) - elif kind_value_raw != "text": + elif kind_value_raw not in ("text",): violations.append(Violation(path="kind", reason='must equal "text"')) else: kind_value = kind_value_raw @@ -303,7 +303,7 @@ def from_transfer_type(self, value: typing.Any, type_hint: type["Room"]) -> "Roo violations.append( Violation( path=members_value_item_path, - reason="expected element", + reason="expected string", ) ) else: diff --git a/samples/python/kb/tree/category/models.py b/samples/python/kb/tree/category/models.py index bf64975e..56213f62 100644 --- a/samples/python/kb/tree/category/models.py +++ b/samples/python/kb/tree/category/models.py @@ -157,7 +157,7 @@ def from_transfer_type( if not isinstance(swatches_value_element, str): violations.append( Violation( - path=swatches_value_item_path, reason="expected element" + path=swatches_value_item_path, reason="expected string" ) ) else: diff --git a/samples/python/showcase/models.py b/samples/python/showcase/models.py index e621b9b3..3cddd9ea 100644 --- a/samples/python/showcase/models.py +++ b/samples/python/showcase/models.py @@ -348,7 +348,7 @@ def from_transfer_type( kind_value_raw = raw["kind"] if not isinstance(kind_value_raw, str): violations.append(Violation(path="kind", reason="expected string")) - elif kind_value_raw != "circle": + elif kind_value_raw not in ("circle",): violations.append(Violation(path="kind", reason='must equal "circle"')) else: kind_value = kind_value_raw @@ -700,7 +700,7 @@ def from_transfer_type( kind_value_raw = raw["kind"] if not isinstance(kind_value_raw, str): violations.append(Violation(path="kind", reason="expected string")) - elif kind_value_raw != "link": + elif kind_value_raw not in ("link",): violations.append(Violation(path="kind", reason='must equal "link"')) else: kind_value = kind_value_raw @@ -994,7 +994,7 @@ def from_transfer_type( kind_value_raw = raw["kind"] if not isinstance(kind_value_raw, str): violations.append(Violation(path="kind", reason="expected string")) - elif kind_value_raw != "showcase": + elif kind_value_raw not in ("showcase",): violations.append( Violation(path="kind", reason='must equal "showcase"') ) @@ -1010,7 +1010,7 @@ def from_transfer_type( revision_value_raw, "revision", violations ) if revision_value_parsed is not None: - if revision_value_parsed != 1: + if revision_value_parsed not in (1,): violations.append(Violation(path="revision", reason="must equal 1")) else: revision_value = revision_value_parsed @@ -1022,7 +1022,7 @@ def from_transfer_type( enabled_value_raw = raw["enabled"] if not isinstance(enabled_value_raw, bool): violations.append(Violation(path="enabled", reason="expected boolean")) - elif enabled_value_raw != True: + elif enabled_value_raw not in (True,): violations.append(Violation(path="enabled", reason="must equal true")) else: enabled_value = enabled_value_raw @@ -1036,11 +1036,7 @@ def from_transfer_type( status_value_raw = raw["status"] if not isinstance(status_value_raw, str): violations.append(Violation(path="status", reason="expected string")) - elif ( - status_value_raw != "active" - and status_value_raw != "inactive" - and status_value_raw != "pending" - ): + elif status_value_raw not in ("active", "inactive", "pending"): violations.append( Violation( path="status", @@ -1057,11 +1053,7 @@ def from_transfer_type( tier_value_raw = raw["tier"] tier_value_parsed = _parse_spec_integer(tier_value_raw, "tier", violations) if tier_value_parsed is not None: - if ( - tier_value_parsed != 1 - and tier_value_parsed != 2 - and tier_value_parsed != 3 - ): + if tier_value_parsed not in (1, 2, 3): violations.append( Violation( path="tier", @@ -1081,7 +1073,7 @@ def from_transfer_type( and isinstance(scale_value_raw, (int, float)) ): violations.append(Violation(path="scale", reason="expected number")) - elif scale_value_raw != 1.5 and scale_value_raw != 2.5: + elif scale_value_raw not in (1.5, 2.5): violations.append( Violation( path="scale", @@ -1611,7 +1603,7 @@ def from_transfer_type( if not isinstance(tags_value_element, str): violations.append( Violation( - path=tags_value_item_path, reason="expected element" + path=tags_value_item_path, reason="expected string" ) ) else: @@ -1656,7 +1648,7 @@ def from_transfer_type( violations.append( Violation( path=aliases_value_item_path, - reason="expected element", + reason="expected string", ) ) else: @@ -1685,8 +1677,7 @@ def from_transfer_type( if not isinstance(roles_value_element, str): violations.append( Violation( - path=roles_value_item_path, - reason="expected element", + path=roles_value_item_path, reason="expected string" ) ) else: @@ -1694,7 +1685,7 @@ def from_transfer_type( roles_value_list.append(roles_value_item) _check_contains( roles_value_list, - lambda element: element == "admin", + lambda element: element in ("admin",), 1, 2, True, @@ -2350,11 +2341,7 @@ def to_transfer_type(self, value: "Showcase") -> typing.Any: if typing.cast("object", value.enabled) not in (True,): violations.append(Violation(path="enabled", reason="must equal true")) out["enabled"] = value.enabled - if typing.cast("object", value.status) not in ( - "active", - "inactive", - "pending", - ): + if typing.cast("object", value.status) not in ("active", "inactive", "pending"): violations.append( Violation( path="status", @@ -2362,11 +2349,7 @@ def to_transfer_type(self, value: "Showcase") -> typing.Any: ) ) out["status"] = value.status - if typing.cast("object", value.tier) not in ( - 1, - 2, - 3, - ): + if typing.cast("object", value.tier) not in (1, 2, 3): violations.append( Violation( path="tier", @@ -2374,10 +2357,7 @@ def to_transfer_type(self, value: "Showcase") -> typing.Any: ) ) out["tier"] = value.tier - if typing.cast("object", value.scale) not in ( - 1.5, - 2.5, - ): + if typing.cast("object", value.scale) not in (1.5, 2.5): violations.append( Violation( path="scale", @@ -2584,7 +2564,7 @@ def to_transfer_type(self, value: "Showcase") -> typing.Any: if value.roles is not None: _check_contains( value.roles, - lambda element: element == "admin", + lambda element: element in ("admin",), 1, 2, True, @@ -2624,10 +2604,7 @@ def to_transfer_type(self, value: "Showcase") -> typing.Any: out["idOrName"] = value.id_or_name if value.mode is not None: if isinstance(value.mode, str): - if typing.cast("object", value.mode) not in ( - "auto", - "manual", - ): + if typing.cast("object", value.mode) not in ("auto", "manual"): violations.append( Violation( path="mode", @@ -3851,7 +3828,7 @@ def from_transfer_type( kind_value_raw = raw["kind"] if not isinstance(kind_value_raw, str): violations.append(Violation(path="kind", reason="expected string")) - elif kind_value_raw != "square": + elif kind_value_raw not in ("square",): violations.append(Violation(path="kind", reason='must equal "square"')) else: kind_value = kind_value_raw @@ -3944,7 +3921,7 @@ def from_transfer_type( kind_value_raw = raw["kind"] if not isinstance(kind_value_raw, str): violations.append(Violation(path="kind", reason="expected string")) - elif kind_value_raw != "text": + elif kind_value_raw not in ("text",): violations.append(Violation(path="kind", reason='must equal "text"')) else: kind_value = kind_value_raw @@ -4513,10 +4490,7 @@ def _showcase_mode_from_transfer_type( ) -> typing.Literal["auto", "manual"] | int | None: if isinstance(value, str): narrowed = typing.cast('typing.Literal["auto", "manual"]', value) - if typing.cast("object", narrowed) not in ( - "auto", - "manual", - ): + if typing.cast("object", narrowed) not in ("auto", "manual"): violations.append( Violation( path=path, diff --git a/samples/python/tests/test_showcase.py b/samples/python/tests/test_showcase.py index ebfcaa7d..7d37c370 100644 --- a/samples/python/tests/test_showcase.py +++ b/samples/python/tests/test_showcase.py @@ -520,6 +520,22 @@ def test_array_constraints_roundtrip_and_reject() -> None: ] +def test_array_element_type_mismatch_names_the_expected_type() -> None: + """A mistyped element reports the type it failed to be, at its own index. + + Every element kind takes the same parse the value in that position would take + anywhere else, so a `string` element reads `expected string` — the same reason a + `string` member reports, and the same one Java's element loop and a union's array + branch (`measurements[0]`, `expected number`) use. The index in the path is what + identifies the element; the reason names the type. + """ + assert parse_violations({**BASE, "tags": [1]}) == [("tags[0]", "expected string")] + assert parse_violations({**BASE, "tags": ["a", None, {}]}) == [ + ("tags[1]", "expected string"), + ("tags[2]", "expected string"), + ] + + def test_object_constraints_roundtrip_and_reject() -> None: attributes = typing.cast( Attributes, expect_roundtrip("attributes.json", Attributes) diff --git a/scripts/validate.sh b/scripts/validate.sh index d839b2d8..e21c697d 100755 --- a/scripts/validate.sh +++ b/scripts/validate.sh @@ -28,6 +28,7 @@ run cargo fmt --check run cargo test --features advanced for tier in samples advanced/samples; do + run_in "$tier/python" uv run ruff check . run_in "$tier/python" uv run ruff format --check . run_in "$tier/typescript" npm exec -- prettier --check . run_in "$tier/go" bash -c 'unformatted="$(gofmt -l .)"; if [ -n "$unformatted" ]; then echo "gofmt required for:" >&2; echo "$unformatted" >&2; exit 1; fi' diff --git a/specs/json-schema/features/items.md b/specs/json-schema/features/items.md index 75500558..9dc67d6c 100644 --- a/specs/json-schema/features/items.md +++ b/specs/json-schema/features/items.md @@ -142,6 +142,17 @@ binding) comes from [[type]]'s `"array"` row. the field path (`tags[2]`, and for nested arrays `matrix[1][2]`), distinct from the dotted member paths [[properties]] uses — so a caller can locate the offending element unambiguously (**P11**). +- **Reason convention.** An element takes the *same* checks — and so the + same `reason` text — the value in that position would take anywhere + else: a mistyped element reads `expected string` / `expected number` + from [[type]]'s row for `T`, and a constraint failure reads that + keyword's own reason (`must have length >= 3, got 1`). Nothing about the + reason marks it as an element: the bracketed index in the path already + does that, which leaves the reason free to name the type or bound that + was missed. This holds for an element of a `oneOf` array branch exactly + as for a declared array member (see [[oneOf]]). Reason *text* is not held + byte-identical across targets (**P11**), but the shape is the same one + everywhere. - **Element recursion.** Each element validates recursively — an array of objects runs each object's own `Validate`, an array of arrays recurses again, an array of `$ref` follows the reference (see [[ref]]). A nested diff --git a/src/generator/json_schema/python.rs b/src/generator/json_schema/python.rs index 6f112ed0..d4a579db 100644 --- a/src/generator/json_schema/python.rs +++ b/src/generator/json_schema/python.rs @@ -1782,17 +1782,31 @@ fn render_py_closed_value_check( indent: &str, reason: &str, ) { - // The member is typed by the closed set it belongs to, so a direct `!=` + // The member is typed by the closed set it belongs to, so a direct comparison // against each admissible value is statically dead code. Widening to `object` // keeps the runtime check — a value mutated past the type system still has to // fail before it reaches the wire (P12). let membership = format!( - "typing.cast(\"object\", {value_expr}) not in ({},)", - compare_exprs.join(", ") + "typing.cast(\"object\", {value_expr}) not in {}", + py_value_tuple(compare_exprs) ); render_py_violation_if(output, indent, &membership, path_expr, reason); } +/// The Python tuple of admissible literals a closed value set is tested against, +/// with the comma a one-member tuple needs. Membership against a tuple is the +/// one shape both directions and both keywords use — a `const` is the one-member +/// `enum` — and it is what keeps the emitted test out of the `!= True` / +/// `!= None` comparisons a per-member `!=` chain would produce, which read as +/// unidiomatic Python and are lint errors (ruff E712/E711) in the generated +/// output (P2). +fn py_value_tuple(compare_exprs: &[String]) -> String { + match compare_exprs { + [single] => format!("({single},)"), + many => format!("({})", many.join(", ")), + } +} + /// True when a field schema carries a constraint the serialize path must /// re-check over the in-memory value (P12, both directions). Mirrors the /// dispatch in [`render_py_field_checks`]. @@ -4222,11 +4236,7 @@ fn render_py_closed_value_membership( indent: &str, reason: &str, ) { - let membership = compare_exprs - .iter() - .map(|expr| format!("{compared} != {expr}")) - .collect::<Vec<_>>() - .join(" and "); + let membership = format!("{compared} not in {}", py_value_tuple(compare_exprs)); output.push_str(indent); output.push_str(&format!("{keyword} {membership}:\n")); output.push_str(indent); @@ -4316,20 +4326,10 @@ fn render_py_array_elements( )); render_py_slot_declaration(output, &loop_body, &item_slot, &item_type); match &schema.items { - Some(item_schema) if is_plain_string_schema(item_schema) => { - // A plain string element reports the element-level reason every - // target uses for a mistyped member of a string list. - output.push_str(&loop_body); - output.push_str(&format!("if not isinstance({element_local}, str):\n")); - output.push_str(&loop_body); - output.push_str(&format!( - " violations.append(Violation(path={item_path_local}, reason=\"expected element\"))\n" - )); - output.push_str(&loop_body); - output.push_str("else:\n"); - output.push_str(&loop_body); - output.push_str(&format!(" {item_slot} = {element_local}\n")); - } + // Every element kind takes the same parse the value in that position + // would take anywhere else, so a mistyped element names the type it + // failed to be (`expected string`) at its own index — see + // `specs/json-schema/features/items.md`. Some(item_schema) => render_value_parser( output, item_schema, @@ -4350,19 +4350,6 @@ fn render_py_array_elements( Ok(list_local) } -/// True when a schema is a bare `string` with nothing else to enforce, which is -/// the only element shape that takes the element-level reason shortcut. -fn is_plain_string_schema(schema: &Schema) -> bool { - schema.ty.as_ref().and_then(Value::as_str) == Some("string") - && schema.const_value.is_none() - && schema.enum_values.is_none() - && schema.format.is_none() - && schema.content_encoding.is_none() - && schema.pattern.is_none() - && schema.min_length.is_none() - && schema.max_length.is_none() -} - fn render_closed_object_unknown_key_check(output: &mut String, schema: &Schema) { let fields = schema .properties @@ -4552,16 +4539,21 @@ fn nullable_member_schema(schema: &Schema) -> Option<&Schema> { fn py_matcher_condition(matcher: &Schema, elem: &str) -> Result<String> { let mut parts: Vec<String> = Vec::new(); if let Some(value) = &matcher.const_value { - parts.push(format!("{elem} == {}", python_value_literal(value)?)); + // The one-member case of the closed set below, and emitted the same way: + // a tuple membership test rather than a comparison, which a boolean + // matcher would render as the unidiomatic `elem == True` (ruff E712). + parts.push(format!( + "{elem} in {}", + py_value_tuple(&[python_value_literal(value)?]) + )); } if let Some(values) = &matcher.enum_values { let alternatives = values .iter() .map(python_value_literal) - .collect::<Result<Vec<_>>>()? - .join(", "); + .collect::<Result<Vec<_>>>()?; if !alternatives.is_empty() { - parts.push(format!("{elem} in ({alternatives},)")); + parts.push(format!("{elem} in {}", py_value_tuple(&alternatives))); } } let is_integer = matcher.ty.as_ref().and_then(Value::as_str) == Some("integer"); From e185e3d55565cfd814f1b9a05c256ea4b759a117 Mon Sep 17 00:00:00 2001 From: Roey Berman <roey.berman@gmail.com> Date: Fri, 14 Aug 2026 14:34:36 -0700 Subject: [PATCH 09/20] TypeScript: enforce a string array element's own constraints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A `string` element schema took a special case in the array parser: a bare `typeof element !== 'string'` check followed by an assignment, skipping `render_ts_string_checks` entirely. An `items: { type: string, minLength: 3, pattern: "^[a-z]+$" }` array therefore accepted `["a"]` and `["A"]` in TypeScript while Go, Python and Java rejected both -- an accept-set divergence, which P1 makes part of the wire contract rather than a per-language detail. The element's compiled `PATTERN_<HEX>` constant was emitted and never referenced, dead code in the user's repository and the independent signal that the constraint had been dropped. Deleting the special case routes a `string` element through `render_value_parser_at_depth`, the same parse every other element kind (and every non-element position) already took. That applies the missing `minLength`/`maxLength`/`pattern`/`format` checks at the element's own index, and reports a mistyped element as `expected string` rather than the bare `expected element`, which named neither the expected type nor anything the indexed path did not already carry. Python removed the identical inherited special case in e6948ca, so the two languages' element reasons are byte-equal again -- the standing goal recorded in PRINCIPLES Python §2. The regenerated samples change only that reason text: no checked-in schema has a *constrained* string element, which is why the dropped constraints never surfaced in a sample diff. The showcase suite gains the element-reason assertion Python's suite already carries. --- CHANGELOG.md | 13 ++++++ .../typescript/json_schema/api/chat/models.ts | 5 +-- .../api/kb/tree/category/models.ts | 2 +- .../json_schema/api/showcase/models.ts | 9 ++-- samples/typescript/chat/models.ts | 5 +-- samples/typescript/kb/tree/category/models.ts | 2 +- samples/typescript/showcase/models.ts | 9 ++-- .../tests/json-schema-showcase.test.ts | 44 +++++++++++++++++++ src/generator/json_schema/typescript.rs | 40 +++++++---------- 9 files changed, 82 insertions(+), 47 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ebf3d29..c6944e53 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -156,6 +156,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 overrides, including TypeScript default constants and Go closed-value types. - JSON Schema: A root model can no longer silently collapse with a same-named `$defs` or synthesized model; the loader reports the conflicting origins. +- TypeScript: A **`string` array element's own constraints are now enforced**. + An element schema of `type: string` took a bare `typeof` check, so an + `items: { type: string, minLength: 3, pattern: "^[a-z]+$" }` array accepted + `["a"]` and `["A"]` — payloads Go, Python and Java all reject — and the + element's compiled pattern constant was emitted but never referenced. Every + element kind now takes the same parse the value in that position takes + anywhere else, so `minLength`, `maxLength`, `pattern` and `format` fire at the + element's own index (`codes[0]`, `must have length >= 3, got 1`). +- TypeScript: A mistyped **array element** now reports the type it failed to be + (`tags[0]`, `expected string`), as an element of every other type already did + and as Python and Java report. A `string` element reported a bare + `expected element`, which named neither the expected type nor anything the + element's own indexed path did not already carry. - Python: A `const`/`enum` check now tests membership in a tuple of the admissible values (`if value not in (True,)`) in both directions, where the parse side chained one `!=` per member. A boolean `const` emitted diff --git a/advanced/samples/typescript/json_schema/api/chat/models.ts b/advanced/samples/typescript/json_schema/api/chat/models.ts index bd1b1482..ac8e4116 100644 --- a/advanced/samples/typescript/json_schema/api/chat/models.ts +++ b/advanced/samples/typescript/json_schema/api/chat/models.ts @@ -336,10 +336,7 @@ export const roomTransferTypeConverter = raw.members.forEach((element: unknown, index: number) => { let item: string = undefined as unknown as string; if (typeof element !== "string") { - violations.push({ - path: `members[${index}]`, - reason: "expected element", - }); + violations.push({ path: `members[${index}]`, reason: "expected string" }); } else { item = element; } diff --git a/advanced/samples/typescript/json_schema/api/kb/tree/category/models.ts b/advanced/samples/typescript/json_schema/api/kb/tree/category/models.ts index bf8f188e..274e43ce 100644 --- a/advanced/samples/typescript/json_schema/api/kb/tree/category/models.ts +++ b/advanced/samples/typescript/json_schema/api/kb/tree/category/models.ts @@ -141,7 +141,7 @@ export const paletteTransferTypeConverter = if (typeof element !== "string") { violations.push({ path: `swatches[${index}]`, - reason: "expected element", + reason: "expected string", }); } else { item = element; diff --git a/advanced/samples/typescript/json_schema/api/showcase/models.ts b/advanced/samples/typescript/json_schema/api/showcase/models.ts index a2ad18b3..aba5206a 100644 --- a/advanced/samples/typescript/json_schema/api/showcase/models.ts +++ b/advanced/samples/typescript/json_schema/api/showcase/models.ts @@ -1939,7 +1939,7 @@ export const showcaseTransferTypeConverter = raw.tags.forEach((element: unknown, index: number) => { let item: string = undefined as unknown as string; if (typeof element !== "string") { - violations.push({ path: `tags[${index}]`, reason: "expected element" }); + violations.push({ path: `tags[${index}]`, reason: "expected string" }); } else { item = element; } @@ -1973,10 +1973,7 @@ export const showcaseTransferTypeConverter = raw.aliases.forEach((element: unknown, index: number) => { let item: string = undefined as unknown as string; if (typeof element !== "string") { - violations.push({ - path: `aliases[${index}]`, - reason: "expected element", - }); + violations.push({ path: `aliases[${index}]`, reason: "expected string" }); } else { item = element; } @@ -2011,7 +2008,7 @@ export const showcaseTransferTypeConverter = raw.roles.forEach((element: unknown, index: number) => { let item: string = undefined as unknown as string; if (typeof element !== "string") { - violations.push({ path: `roles[${index}]`, reason: "expected element" }); + violations.push({ path: `roles[${index}]`, reason: "expected string" }); } else { item = element; } diff --git a/samples/typescript/chat/models.ts b/samples/typescript/chat/models.ts index b0865ca7..cc2277c1 100644 --- a/samples/typescript/chat/models.ts +++ b/samples/typescript/chat/models.ts @@ -325,10 +325,7 @@ export const roomTransferTypeConverter = raw.members.forEach((element: unknown, index: number) => { let item: string = undefined as unknown as string; if (typeof element !== "string") { - violations.push({ - path: `members[${index}]`, - reason: "expected element", - }); + violations.push({ path: `members[${index}]`, reason: "expected string" }); } else { item = element; } diff --git a/samples/typescript/kb/tree/category/models.ts b/samples/typescript/kb/tree/category/models.ts index 120f0b65..5e130b9b 100644 --- a/samples/typescript/kb/tree/category/models.ts +++ b/samples/typescript/kb/tree/category/models.ts @@ -130,7 +130,7 @@ export const paletteTransferTypeConverter = if (typeof element !== "string") { violations.push({ path: `swatches[${index}]`, - reason: "expected element", + reason: "expected string", }); } else { item = element; diff --git a/samples/typescript/showcase/models.ts b/samples/typescript/showcase/models.ts index d4b5d9a0..249ad99b 100644 --- a/samples/typescript/showcase/models.ts +++ b/samples/typescript/showcase/models.ts @@ -1928,7 +1928,7 @@ export const showcaseTransferTypeConverter = raw.tags.forEach((element: unknown, index: number) => { let item: string = undefined as unknown as string; if (typeof element !== "string") { - violations.push({ path: `tags[${index}]`, reason: "expected element" }); + violations.push({ path: `tags[${index}]`, reason: "expected string" }); } else { item = element; } @@ -1962,10 +1962,7 @@ export const showcaseTransferTypeConverter = raw.aliases.forEach((element: unknown, index: number) => { let item: string = undefined as unknown as string; if (typeof element !== "string") { - violations.push({ - path: `aliases[${index}]`, - reason: "expected element", - }); + violations.push({ path: `aliases[${index}]`, reason: "expected string" }); } else { item = element; } @@ -2000,7 +1997,7 @@ export const showcaseTransferTypeConverter = raw.roles.forEach((element: unknown, index: number) => { let item: string = undefined as unknown as string; if (typeof element !== "string") { - violations.push({ path: `roles[${index}]`, reason: "expected element" }); + violations.push({ path: `roles[${index}]`, reason: "expected string" }); } else { item = element; } diff --git a/samples/typescript/tests/json-schema-showcase.test.ts b/samples/typescript/tests/json-schema-showcase.test.ts index 6bdf66f1..ef72b452 100644 --- a/samples/typescript/tests/json-schema-showcase.test.ts +++ b/samples/typescript/tests/json-schema-showcase.test.ts @@ -46,6 +46,20 @@ function expectRoundTrip<T>(name: string, converter: TransferTypeConverter<T>): return value; } +// The structured violations a rejected payload produces, in order — for the +// assertions that pin an exact `{ path, reason }` set rather than one message. +function parseViolations(raw: unknown): { path: string; reason: string }[] { + try { + new ShowcaseMapper().fromIntermediate(raw); + } catch (error) { + if (error instanceof ValidationError) { + return error.violations.map(({ path, reason }) => ({ path, reason })); + } + throw error; + } + throw new Error("expected the payload to be rejected"); +} + describe("json-schema showcase generated definitions", () => { test("roundtrips canonical wire fixtures through the Temporal converter", () => { const minimal = expectRoundTrip( @@ -304,6 +318,36 @@ describe("json-schema showcase generated definitions", () => { expect(ok.roles).toEqual(["admin"]); }); + test("a mistyped array element names the type it failed to be", () => { + // Every element kind takes the same parse the value in that position would + // take anywhere else, so a `string` element reads `expected string` — the + // same reason a `string` member reports, and the one Python's element loop + // and Java's report. The bracketed index in the path identifies the element; + // the reason names the type (specs/json-schema/features/items.md). Because + // the element takes that ordinary parse, a *constrained* element's own + // `minLength`/`maxLength`/`pattern`/`format` are enforced there too. + const base = { + kind: "showcase", + revision: 1, + enabled: true, + status: "active", + tier: 1, + scale: 1.5, + name: "w", + count: 1, + active: true, + category: "tools", + } as const; + + expect(parseViolations({ ...base, tags: [1, "b"] })).toEqual([ + { path: "tags[0]", reason: "expected string" }, + ]); + expect(parseViolations({ ...base, tags: ["a", null, {}] })).toEqual([ + { path: "tags[1]", reason: "expected string" }, + { path: "tags[2]", reason: "expected string" }, + ]); + }); + test("enforces pattern constraints with RE2-safe portable semantics", () => { // sku `^[A-Z]{2,4}$` and phrase `^\S+\s\S+$` round-trip. const patterns = expectRoundTrip( diff --git a/src/generator/json_schema/typescript.rs b/src/generator/json_schema/typescript.rs index ca67e2dd..783f6203 100644 --- a/src/generator/json_schema/typescript.rs +++ b/src/generator/json_schema/typescript.rs @@ -3276,31 +3276,21 @@ fn render_array_parser( } else { format!("`${{{path_expr}}}[${{{index}}}]`") }; - if element_schema.ty.as_ref().and_then(Value::as_str) == Some("string") { - output.push_str(indent); - output.push_str(&format!(" if (typeof {element} !== 'string') {{\n")); - output.push_str(indent); - output.push_str(" violations.push({ path: "); - output.push_str(&item_path_expr); - output.push_str(", reason: 'expected element' });\n"); - output.push_str(indent); - output.push_str(" } else {\n"); - output.push_str(indent); - output.push_str(&format!(" {item} = {element};\n")); - output.push_str(indent); - output.push_str(" }\n"); - } else { - render_value_parser_at_depth( - output, - element_schema, - &element, - &item, - &item_path_expr, - &format!("{indent} "), - false, - depth + 1, - ); - } + // Every element kind takes the same parse the value in that position + // would take anywhere else, so a `string` element's own constraints + // (`minLength`, `pattern`, `format`, …) are enforced and a mistyped + // element names the type it failed to be (`expected string`) at its own + // index — see `specs/json-schema/features/items.md`. + render_value_parser_at_depth( + output, + element_schema, + &element, + &item, + &item_path_expr, + &format!("{indent} "), + false, + depth + 1, + ); } else { output.push_str(indent); output.push_str(&format!(" {item} = {element} as unknown;\n")); From 1349578601b86cf6983ca304f57f5be608e61633 Mon Sep 17 00:00:00 2001 From: Roey Berman <roey.berman@gmail.com> Date: Fri, 14 Aug 2026 16:45:13 -0700 Subject: [PATCH 10/20] Reconcile the Python dataclass work with the TS transfer-type branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rebase put two independent reworks of the same files together, and three seams needed closing: - `DEFAULT_<FIELD>` is now named off the **emitted member identifier** in Python as it already is in TypeScript, so an `x-py-name` override moves the constant with its member (P15) and the loader's shared replication of the rule matches what each generator emits. The loader also derives the identifier per language rather than always through TypeScript's mapping. - The collision test that used two *models* no longer describes a collision: `DEFAULT_<MODEL>_<FIELD>` qualification separates them. It now pins the case qualification cannot resolve — two members of one model — in both languages, plus the override that opens the escape hatch, and keeps the two-model shape as an accepted case. - A TypeScript-only converter-collision test asserted Python accepts the same schema. Python now derives module-level names from the type name too, and `HTTPError`/`HttpError` fold together in `_HTTP_ERROR_DECLARED`, so it rejects for its own reason. Also updates two assertions the dataclass rewrite invalidated (`class Page(` is now `class Page:`) and one showcase test still calling `ShowcaseMapper`. --- .../tests/json-schema-showcase.test.ts | 2 +- specs/json-schema/features/default.md | 7 +- src/generator/json_schema/python.rs | 27 +++--- src/parser/json_schema.rs | 82 ++++++++++++++----- tests/generate_python.rs | 2 +- 5 files changed, 86 insertions(+), 34 deletions(-) diff --git a/samples/typescript/tests/json-schema-showcase.test.ts b/samples/typescript/tests/json-schema-showcase.test.ts index ef72b452..cbe572e3 100644 --- a/samples/typescript/tests/json-schema-showcase.test.ts +++ b/samples/typescript/tests/json-schema-showcase.test.ts @@ -50,7 +50,7 @@ function expectRoundTrip<T>(name: string, converter: TransferTypeConverter<T>): // assertions that pin an exact `{ path, reason }` set rather than one message. function parseViolations(raw: unknown): { path: string; reason: string }[] { try { - new ShowcaseMapper().fromIntermediate(raw); + showcaseTransferTypeConverter.fromTransferType(raw); } catch (error) { if (error instanceof ValidationError) { return error.violations.map(({ path, reason }) => ({ path, reason })); diff --git a/specs/json-schema/features/default.md b/specs/json-schema/features/default.md index 9a591960..c4b2803a 100644 --- a/specs/json-schema/features/default.md +++ b/specs/json-schema/features/default.md @@ -143,9 +143,10 @@ The read-side surfacing synthesizes **one new identifier in three targets** | Java | none (default folds into the existing getter) | — | — | The constant is named `DEFAULT_<FIELD>`, or **`DEFAULT_<MODEL>_<FIELD>`** -when that field name is not unique across the module's models — the same -qualification rule in TypeScript and Python, since both put the constant in -module scope. A collision that survives qualification rejects. +when that member identifier is not unique across the module's models — the +same qualification rule in TypeScript and Python, since both put the constant +in module scope. Qualification separates two *models*; two members of **one** +model that shout alike are a collision it cannot resolve, and rejects. Per **P15** these participate in the single per-scope collision pass and **reject at load** on any coincidence — never auto-mangled (a diff --git a/src/generator/json_schema/python.rs b/src/generator/json_schema/python.rs index d4a579db..cba178c8 100644 --- a/src/generator/json_schema/python.rs +++ b/src/generator/json_schema/python.rs @@ -2112,7 +2112,11 @@ fn render_default_constants(output: &mut String, models: &[&PlannedJsonType]) -> continue; }; constants.push(( - default_const_name(&model.model_name, json_name, models)?, + default_const_name( + &model.model_name, + &property.py_member_name(json_name), + models, + )?, python_value_literal(default)?, )); } @@ -2131,12 +2135,15 @@ fn render_default_constants(output: &mut String, models: &[&PlannedJsonType]) -> } /// `DEFAULT_<FIELD>` when exactly one model in the module declares a defaulted -/// field of that JSON name, else `DEFAULT_<MODEL>_<FIELD>`. The loader replicates -/// this rule to reserve the name in the module namespace (P15), so the two must -/// stay in step. +/// member emitting that identifier, else `DEFAULT_<MODEL>_<FIELD>`. The name is +/// built from the **emitted member identifier**, as TypeScript's is, so an +/// `x-py-name` override on the declaring property moves the constant with it — a +/// name synthesized *from the member* follows the member (P15). The loader +/// replicates this rule to reserve the name in the module namespace, so the two +/// must stay in step. fn default_const_name( model_name: &str, - field_name: &str, + member_ident: &str, models: &[&PlannedJsonType], ) -> Result<String> { let field_count = models @@ -2146,19 +2153,19 @@ fn default_const_name( .into_iter() .filter(|schema| { schema.properties.as_ref().is_some_and(|properties| { - properties - .get(field_name) - .is_some_and(|property| property.default.is_some()) + properties.iter().any(|(json_name, property)| { + property.py_member_name(json_name) == member_ident && property.default.is_some() + }) }) }) .count(); Ok(if field_count == 1 { - format!("DEFAULT_{}", field_name.to_shouty_snake_case()) + format!("DEFAULT_{}", member_ident.to_shouty_snake_case()) } else { format!( "DEFAULT_{}_{}", model_name.to_shouty_snake_case(), - field_name.to_shouty_snake_case() + member_ident.to_shouty_snake_case() ) }) } diff --git a/src/parser/json_schema.rs b/src/parser/json_schema.rs index 986fca34..938b2ba0 100644 --- a/src/parser/json_schema.rs +++ b/src/parser/json_schema.rs @@ -6160,7 +6160,7 @@ fn collect_default_constants( .filter(|model| { model.schema.properties.as_ref().is_some_and(|properties| { properties.iter().any(|(json_name, property)| { - member_identifier(Language::TypeScript, json_name, property) == member_ident + member_identifier(language, json_name, property) == member_ident && property.extra.get("default").is_some_and(|default| { !default.is_null() && !default.is_object() && !default.is_array() }) @@ -6180,7 +6180,7 @@ fn collect_default_constants( if default.is_null() || default.is_object() || default.is_array() { continue; } - let member_ident = member_identifier(Language::TypeScript, json_name, property); + let member_ident = member_identifier(language, json_name, property); let field_shouty = member_ident.to_shouty_snake_case(); let ident = if field_count(&member_ident) == 1 { format!("DEFAULT_{field_shouty}") @@ -9867,11 +9867,54 @@ properties: #[test] fn rejects_colliding_default_constants_python_and_typescript() { - // Python and TypeScript hoist a defaulted field's value to a module-level - // `DEFAULT_<FIELD>` constant (unprefixed, because each field name occurs - // in exactly one model). `fooBar` and `foo_bar` shouty-snake-case to the - // same `DEFAULT_FOO_BAR`, a module-scope clash. - let input = r##" + // Python and TypeScript hoist a defaulted member's value to a module-level + // `DEFAULT_<FIELD>` constant, named off the **emitted** member identifier. + // Two members that stay distinct as identifiers (`fooBar` / `foo_bar`, held + // apart by their overrides) still shout to one `DEFAULT_FOO_BAR`, and the + // model-name qualification cannot separate two members of one model. + for (language, override_key) in [ + (Language::Python, "x-py-name"), + (Language::TypeScript, "x-ts-name"), + ] { + let input = format!( + r##" +$schema: https://json-schema.org/draft/2020-12/schema +type: object +additionalProperties: false +properties: + first: {{ type: string, default: "x", {override_key}: fooBar }} + second: {{ type: string, default: "y", {override_key}: foo_bar }} +"## + ); + let error = reject_for(language, &input); + assert!( + error.contains("collision") && error.contains("DEFAULT_FOO_BAR"), + "{language:?}: {error}" + ); + // Go and Java keep the default on the model (no module-level constant), + // so the same schema is accepted there. + parse_for(Language::Go, &input).expect("Go emits no DEFAULT_ constants"); + parse_for(Language::Java, &input).expect("Java emits no DEFAULT_ constants"); + + // The escape hatch reaches the constant, because the constant follows + // the member it was synthesized from (P15). + let resolved = format!( + r##" +$schema: https://json-schema.org/draft/2020-12/schema +type: object +additionalProperties: false +properties: + first: {{ type: string, default: "x", {override_key}: fooBar }} + second: {{ type: string, default: "y", {override_key}: fooBarTwo }} +"## + ); + parse_for(language, &resolved) + .expect("the override moves the DEFAULT_ constant with the member"); + } + + // Two *models* each declaring a member of that identifier are separated by + // the model-name qualification instead, so they load. + let across_models = r##" $schema: https://json-schema.org/draft/2020-12/schema type: object properties: @@ -9888,16 +9931,9 @@ $defs: foo_bar: { type: string, default: "y" } "##; for language in [Language::Python, Language::TypeScript] { - let error = reject_for(language, input); - assert!( - error.contains("collision") && error.contains("DEFAULT_FOO_BAR"), - "{language:?}: {error}" - ); + parse_for(language, across_models) + .expect("`DEFAULT_<MODEL>_<FIELD>` keeps the two apart"); } - // Go and Java keep the default on the model (no module-level constant), - // so the same schema is accepted there. - parse_for(Language::Go, input).expect("Go emits no DEFAULT_ constants"); - parse_for(Language::Java, input).expect("Java emits no DEFAULT_ constants"); } #[test] @@ -10842,11 +10878,19 @@ $defs: error.contains("collision") && error.contains("httpErrorTransferTypeConverter"), "{error}" ); - // The other targets derive no value identifier from a type name, so the - // two distinct type names are all they have to keep apart. + // Go and Java derive no value identifier from a type name, so the two + // distinct type names are all they have to keep apart. parse_for(Language::Go, input).expect("Go derives no converter identifier"); - parse_for(Language::Python, input).expect("Python derives no converter identifier"); parse_for(Language::Java, input).expect("Java derives no converter identifier"); + // Python derives module-level names from the type name too. Its converter + // classes stay apart (`_HTTPError…` / `_HttpError…`), but the declared-key + // frozensets both shout to `_HTTP_ERROR_DECLARED`, so it rejects for that + // reason rather than accepting. + let python_error = reject_for(Language::Python, input); + assert!( + python_error.contains("collision") && python_error.contains("_HTTP_ERROR_DECLARED"), + "{python_error}" + ); } #[test] diff --git a/tests/generate_python.rs b/tests/generate_python.rs index 9519e308..da4b4a5b 100644 --- a/tests/generate_python.rs +++ b/tests/generate_python.rs @@ -1386,7 +1386,7 @@ services: .collect::<Vec<_>>() .join("\n"); assert_eq!( - rendered.matches("class Page(").count(), + rendered.matches("class Page:").count(), 1, "`Page` must be declared once\n{rendered}" ); From 03e3455d997fd76957470fea615c660d7a72ad4d Mon Sep 17 00:00:00 2001 From: Roey Berman <roey.berman@gmail.com> Date: Fri, 14 Aug 2026 20:31:25 -0700 Subject: [PATCH 11/20] Python: validate a union before dispatching its serializer A union serialize function guards every branch but the last and falls through to it. For a *named* union that was already safe -- the function ran the union's no-branch-matched test first, so the fallthrough was only reached by a value some branch admitted. An inline property-position union had no such test. The declaring converter appended the `expected one of` violation and then handed the same value to the dispatch, so a member holding a value no branch admits reached the last branch's converter and raised whatever its first attribute access raised -- `AttributeError: 'int' object has no attribute 'kind'`, which is not the `ValidationError` the caller catches, so it propagated alone and discarded every violation already collected (P11). Run the checks inside the serialize function for every union, named or inline, ahead of the dispatch: that is the only place that can stop it. The declaring member drops its now-duplicate check and keeps only the `_collect` re-path, which reports the union function's empty-path violations under the member's path exactly as before. Nothing becomes statically unreachable and no pyright suppression is added. --- .../python/json_schema/api/showcase/models.py | 56 +++---- samples/python/showcase/models.py | 56 +++---- specs/json-schema/features/oneOf.md | 32 ++-- src/generator/json_schema/python.rs | 86 ++++++----- tests/generate_python.rs | 146 ++++++++++++++++++ 5 files changed, 263 insertions(+), 113 deletions(-) diff --git a/advanced/samples/python/json_schema/api/showcase/models.py b/advanced/samples/python/json_schema/api/showcase/models.py index 3cddd9ea..4739a8fe 100644 --- a/advanced/samples/python/json_schema/api/showcase/models.py +++ b/advanced/samples/python/json_schema/api/showcase/models.py @@ -2633,42 +2633,11 @@ def to_transfer_type(self, value: "Showcase") -> typing.Any: ) out["payload"] = value.payload if value.detail is not None: - candidate = typing.cast("object", value.detail) - if not ( - isinstance(candidate, ShowcaseDetailObject) - or isinstance(candidate, str) - ): - violations.append( - Violation( - path="detail", - reason="expected one of: ShowcaseDetailObject, string", - ) - ) try: out["detail"] = _showcase_detail_to_transfer_type(value.detail) except ValidationError as error: _collect(violations, "detail", error) if value.shape_or_name is not None: - if isinstance(value.shape_or_name, str): - if len(value.shape_or_name) > 32: - violations.append( - Violation( - path="shapeOrName", - reason=f"must have length <= 32, got {len(value.shape_or_name)}", - ) - ) - candidate = typing.cast("object", value.shape_or_name) - if not ( - isinstance(candidate, Circle) - or isinstance(candidate, Square) - or isinstance(candidate, str) - ): - violations.append( - Violation( - path="shapeOrName", - reason="expected one of: Circle, Square, string", - ) - ) try: out["shapeOrName"] = _showcase_shape_or_name_to_transfer_type( value.shape_or_name @@ -4545,6 +4514,14 @@ def _showcase_detail_from_transfer_type( def _showcase_detail_to_transfer_type(value: ShowcaseDetailObject | str) -> typing.Any: + violations: list[Violation] = [] + candidate = typing.cast("object", value) + if not (isinstance(candidate, ShowcaseDetailObject) or isinstance(candidate, str)): + violations.append( + Violation(path="", reason="expected one of: ShowcaseDetailObject, string") + ) + if violations: + raise ValidationError(violations) if isinstance(value, ShowcaseDetailObject): return _ShowcaseDetailObjectTransferTypeConverter().to_transfer_type(value) return value @@ -4590,6 +4567,23 @@ def _showcase_shape_or_name_from_transfer_type( def _showcase_shape_or_name_to_transfer_type( value: Circle | Square | str, ) -> typing.Any: + violations: list[Violation] = [] + if isinstance(value, str): + if len(value) > 32: + violations.append( + Violation(path="", reason=f"must have length <= 32, got {len(value)}") + ) + candidate = typing.cast("object", value) + if not ( + isinstance(candidate, Circle) + or isinstance(candidate, Square) + or isinstance(candidate, str) + ): + violations.append( + Violation(path="", reason="expected one of: Circle, Square, string") + ) + if violations: + raise ValidationError(violations) if isinstance(value, Circle): return _CircleTransferTypeConverter().to_transfer_type(value) if isinstance(value, Square): diff --git a/samples/python/showcase/models.py b/samples/python/showcase/models.py index 3cddd9ea..4739a8fe 100644 --- a/samples/python/showcase/models.py +++ b/samples/python/showcase/models.py @@ -2633,42 +2633,11 @@ def to_transfer_type(self, value: "Showcase") -> typing.Any: ) out["payload"] = value.payload if value.detail is not None: - candidate = typing.cast("object", value.detail) - if not ( - isinstance(candidate, ShowcaseDetailObject) - or isinstance(candidate, str) - ): - violations.append( - Violation( - path="detail", - reason="expected one of: ShowcaseDetailObject, string", - ) - ) try: out["detail"] = _showcase_detail_to_transfer_type(value.detail) except ValidationError as error: _collect(violations, "detail", error) if value.shape_or_name is not None: - if isinstance(value.shape_or_name, str): - if len(value.shape_or_name) > 32: - violations.append( - Violation( - path="shapeOrName", - reason=f"must have length <= 32, got {len(value.shape_or_name)}", - ) - ) - candidate = typing.cast("object", value.shape_or_name) - if not ( - isinstance(candidate, Circle) - or isinstance(candidate, Square) - or isinstance(candidate, str) - ): - violations.append( - Violation( - path="shapeOrName", - reason="expected one of: Circle, Square, string", - ) - ) try: out["shapeOrName"] = _showcase_shape_or_name_to_transfer_type( value.shape_or_name @@ -4545,6 +4514,14 @@ def _showcase_detail_from_transfer_type( def _showcase_detail_to_transfer_type(value: ShowcaseDetailObject | str) -> typing.Any: + violations: list[Violation] = [] + candidate = typing.cast("object", value) + if not (isinstance(candidate, ShowcaseDetailObject) or isinstance(candidate, str)): + violations.append( + Violation(path="", reason="expected one of: ShowcaseDetailObject, string") + ) + if violations: + raise ValidationError(violations) if isinstance(value, ShowcaseDetailObject): return _ShowcaseDetailObjectTransferTypeConverter().to_transfer_type(value) return value @@ -4590,6 +4567,23 @@ def _showcase_shape_or_name_from_transfer_type( def _showcase_shape_or_name_to_transfer_type( value: Circle | Square | str, ) -> typing.Any: + violations: list[Violation] = [] + if isinstance(value, str): + if len(value) > 32: + violations.append( + Violation(path="", reason=f"must have length <= 32, got {len(value)}") + ) + candidate = typing.cast("object", value) + if not ( + isinstance(candidate, Circle) + or isinstance(candidate, Square) + or isinstance(candidate, str) + ): + violations.append( + Violation(path="", reason="expected one of: Circle, Square, string") + ) + if violations: + raise ValidationError(violations) if isinstance(value, Circle): return _CircleTransferTypeConverter().to_transfer_type(value) if isinstance(value, Square): diff --git a/specs/json-schema/features/oneOf.md b/specs/json-schema/features/oneOf.md index 0977c441..ec24b5c7 100644 --- a/specs/json-schema/features/oneOf.md +++ b/specs/json-schema/features/oneOf.md @@ -702,7 +702,7 @@ violation path (`idOrName`, `shapes[1]`, `choices.primary`): | Language | Where a non-object branch's constraints live | |---|---| | Go | the synthesized `<Union><Kind>` wrapper's `Validate`, over a conversion back to the underlying type (`string(v)`, `[]float64(v)`). The dispatcher calls it on the selected branch, and the declaring model's `Validate` — which `MarshalJSON` runs first — calls it again before emit. A branch `pattern`/`format` compiles to a package-level regex var keyed by the wrapper type (`fooStringPattern`). | -| TypeScript | the narrowing chain itself: each `typeof`/`Array.isArray` arm runs the branch's checks over the narrowed value, in `fromIntermediate` and again on the serialize side (a named union in its `Mapper.toIntermediate`, an inline one in the declaring model's, so a branch violation aggregates with its siblings). | +| TypeScript | the narrowing chain itself: each `typeof`/`Array.isArray` arm runs the branch's checks over the narrowed value, in `fromTransferType` and again on the serialize side (a named union in its own converter's `toTransferType`, an inline one in the declaring model's, so a branch violation aggregates with its siblings). | | Python | the classification arm itself, exactly as in TypeScript: each `isinstance` arm runs the branch's checks over the classified value — the numeric bounds, length bounds, `multipleOf`, and the `pattern`/`format` regex match all inline; only `uniqueItems` and `contains` go through a runtime helper (`_check_unique_items` / `_check_contains`) — in `_<union>_from_transfer_type` and again in `_<union>_to_transfer_type`, so a branch violation aggregates with its siblings. Selecting the branch *is* validating it. | | Java | a package-private `validate(path, violations)` on the wrapper class, with its compiled `pattern`/`format` `Pattern` statics. `fromNode` calls it on the wrapper it just built; the interface's static `validate` dispatches on the member's runtime class and is called by the declaring POJO's `Serializer` (and per element/member for a collection of unions) before any wire member is written. | @@ -730,16 +730,26 @@ aggregated primitive rather than being written (real teeth where construction is unchecked). Python's serialize dispatch tests every branch but the last, then falls -through to it: given a member typed as the union, the final `isinstance` -is provably redundant, and emitting it would leave the guard and the -`expected one of` raise behind it statically unreachable. A member whose -runtime type contradicts the field's declared union therefore fails inside -the fallthrough branch's converter rather than with the union's own -aggregated error. What **P12** guarantees is unchanged — nothing invalid -reaches the wire, because the failure still happens before a byte is -written — and the case is one a type checker rejects at the assignment. -The parse direction, which is the one that sees untrusted input, tests -every branch and raises `expected one of: <labels>` with no fallthrough. +through to it. The fallthrough is safe by **precondition**: +`_<union>_to_transfer_type` runs the union's checks — each branch's own +constraints and the no-branch-matched test — and raises the aggregated +`ValidationError` *before* the dispatch, so a value that reaches the +guards is already known to match some branch and the final `isinstance` +is redundant. Emitting it anyway would leave the guard and the +`expected one of` raise behind it statically unreachable. + +Those checks live in that function for every union that has one, named or +inline, which is what puts them ahead of the dispatch — the only place +that can stop it. A union no branch of which transforms its value needs no +such function at all: the member is emitted as-is and the declaring +model's converter runs the checks inline. Either way the violations are +reported under the member's path — a union function collects them at the +empty path and the declaring converter re-paths them through `_collect` +(**P11**) — and aggregate with the member's siblings. + +The parse direction, which is handed untrusted input rather than a value +of the declared union type, tests every branch and raises +`expected one of: <labels>` with no fallthrough. ## Property-testing matrix diff --git a/src/generator/json_schema/python.rs b/src/generator/json_schema/python.rs index cba178c8..62b44829 100644 --- a/src/generator/json_schema/python.rs +++ b/src/generator/json_schema/python.rs @@ -2941,9 +2941,10 @@ fn render_py_union_value_checks( // the same checks). The value is widened to `object` first: read through the // declared union a closed set of guards can be provably exhaustive, which puts // the violation in code pyright reports as unreachable — and the widening - // costs nothing, because the guards are the runtime tests either way. The - // serialize *dispatch* is unaffected and still falls through to its last - // branch unguarded (see `render_py_union_serialize`). + // costs nothing, because the guards are the runtime tests either way. This + // test is also what makes the dispatch's unguarded last branch safe, so a + // union that has a serializer runs it inside that function rather than at the + // enclosing member (see `render_union_serialize_function`). let mut guards: Vec<String> = union .variants .iter() @@ -2970,12 +2971,13 @@ fn render_py_union_value_checks( } /// Emits the dispatch of a union's `_<base>_to_transfer_type`. Unlike the parse -/// side, which is handed an untyped wire value, this direction receives the -/// declared union, so each guard *narrows* it and the final branch is whatever is -/// left over. Guarding that one as well would be provably redundant — and would -/// put an unreachable `expected one of` raise behind it — so it is emitted as the -/// fallthrough instead. When no branch transforms its value at all the dispatch -/// collapses to returning it unchanged. +/// side, which is handed an untyped wire value, this direction runs only after +/// the checks above it have established that the value matches *some* branch, so +/// each guard narrows a set already known to be inhabited and the final branch is +/// whatever is left over. Guarding that one as well would be provably redundant — +/// and would put an unreachable `expected one of` raise behind it — so it is +/// emitted as the fallthrough instead. When no branch transforms its value at all +/// the dispatch collapses to returning it unchanged. fn render_py_union_serialize(output: &mut String, union: &PyUnion, value_expr: &str, indent: &str) { if union.nullable { output.push_str(indent); @@ -3018,9 +3020,7 @@ fn render_union_transfer_functions(output: &mut String, models: &[&PlannedJsonTy }; let base = union_fn_base(&model.model_name); render_union_parse_function(output, &base, &model.model_name, &union)?; - // A named union has no enclosing property to run its branch checks, so - // it collects its own and raises the one aggregated error (P11/P12). - render_union_serialize_function(output, &base, &model.model_name, &union, models, true)?; + render_union_serialize_function(output, &base, &model.model_name, &union, models)?; } for model in models { let schema = decode_schema(model)?; @@ -3034,18 +3034,11 @@ fn render_union_transfer_functions(output: &mut String, models: &[&PlannedJsonTy let base = inline_union_fn_base(&model.model_name, &property.py_member_name(json_name)); let member_type = annotation(property)?; render_union_parse_function(output, &base, &member_type, &union)?; - // The enclosing property already runs the branch checks on the way - // out, so the serializer is pure dispatch — and is only needed when - // some member's in-memory form differs from its wire form. + // Only needed when some branch's in-memory form differs from its wire + // form; otherwise the member is emitted as-is and the enclosing + // property runs its checks inline. if union.needs_serializer() { - render_union_serialize_function( - output, - &base, - &member_type, - &union, - models, - false, - )?; + render_union_serialize_function(output, &base, &member_type, &union, models)?; } } } @@ -3067,28 +3060,32 @@ fn render_union_parse_function( render_py_union_parse(output, union, "value", "path", " ") } +/// Emits a union's `_<base>_to_transfer_type`: the value's checks, the raise that +/// aggregates them, then the dispatch. The order is the point — the dispatch's +/// last branch is unguarded, so a value matching no branch has to fail here, as +/// the union's own aggregated `ValidationError`, rather than reach a converter +/// that would raise whatever its first attribute access raises. Callers report the +/// checks' violations under the member's path through `_collect` (P11), which is +/// what the checks' empty `path` is for. fn render_union_serialize_function( output: &mut String, base: &str, member_type: &str, union: &PyUnion, models: &[&PlannedJsonType], - with_checks: bool, ) -> Result<()> { push_section(output); output.push_str(&format!( "def {}(value: {member_type}) -> typing.Any:\n", union_serialize_fn(base) )); - if with_checks { - let mut checks = String::new(); - render_py_union_value_checks(&mut checks, union, models, "value", "\"\"", " ")?; - if !checks.is_empty() { - output.push_str(" violations: list[Violation] = []\n"); - output.push_str(&checks); - output.push_str(" if violations:\n"); - output.push_str(" raise ValidationError(violations)\n"); - } + let mut checks = String::new(); + render_py_union_value_checks(&mut checks, union, models, "value", "\"\"", " ")?; + if !checks.is_empty() { + output.push_str(" violations: list[Violation] = []\n"); + output.push_str(&checks); + output.push_str(" if violations:\n"); + output.push_str(" raise ValidationError(violations)\n"); } render_py_union_serialize(output, union, "value", " "); Ok(()) @@ -3441,19 +3438,15 @@ fn render_model_serializer_body( output, json_name, property, models, guarded, indent, )?; match inline_union { - Some(call) if py_serialize_can_raise(property) => render_py_serialize_call( + // Every union serializer validates before dispatching, so the + // call always needs its violations re-pathed under this member. + Some(call) => render_py_serialize_call( output, PySerializeSink::Assign(&target), &call, &path_expr, indent, ), - // A dispatch over scalar branches alone materializes values; it - // never validates, so there is nothing to re-path. - Some(call) => { - output.push_str(indent); - output.push_str(&format!("{target} = {call}\n")); - } None => render_py_serialize_value( output, emitted, @@ -3518,6 +3511,11 @@ fn py_model_serialize_can_raise(schema: &Schema) -> Result<bool> { /// dispatcher — the calls that raise their own `ValidationError`, whose violations /// are relative to the nested value and so have to be re-pathed and merged into /// the caller's list rather than left to propagate (P11; Go's `mergeNested`). +/// +/// A property routing through a `_<base>_to_transfer_type` is not decided here — +/// `render_model_serializer_body` sees the union's own classification and always +/// re-paths that call, because every union serializer validates before +/// dispatching. fn py_serialize_can_raise(schema: &Schema) -> bool { if schema.reference.is_some() { return true; @@ -3649,6 +3647,11 @@ fn render_py_serialize_call( /// members' emit guard does — in which case repeating the test here would be a /// comparison pyright reports as unnecessary (and basedpyright fails the build /// over), so only a *required* nullable member guards itself. +/// +/// A union the member converts through a `_<base>_to_transfer_type` is the one +/// exception: that function runs the union's checks itself, ahead of a dispatch +/// its last branch leaves unguarded, and the caller re-paths what it raises. Any +/// check emitted here would be that same check reported twice. fn render_py_serialize_property_check( output: &mut String, json_name: &str, @@ -3657,6 +3660,9 @@ fn render_py_serialize_property_check( guarded: bool, indent: &str, ) -> Result<()> { + if classify_py_union(property, models)?.is_some_and(|union| union.needs_serializer()) { + return Ok(()); + } let value_expr = format!("value.{}", property.py_member_name(json_name)); let path_expr = python_string_literal(json_name); let guard_null = allows_null(property) && !guarded; diff --git a/tests/generate_python.rs b/tests/generate_python.rs index da4b4a5b..f1a18563 100644 --- a/tests/generate_python.rs +++ b/tests/generate_python.rs @@ -86,6 +86,86 @@ properties: - { type: string, enum: [auto, manual] } "#; +/// A property-position union whose **last** branch converts through a model's +/// converter. Nothing in the annotation stops a member holding a value no branch +/// admits, and the serialize dispatch guards every branch but the last, so that +/// value reaches the last branch's converter — which fails on whatever attribute +/// it reads first. `mixed`'s last branch is a scalar, so its fallthrough returns +/// the bad value instead: same missing check, quieter symptom. +const UNION_DISPATCH_FALLTHROUGH_SCHEMA: &str = r##"$schema: https://json-schema.org/draft/2020-12/schema +type: object +properties: + pick: + oneOf: + - { $ref: "#/$defs/Circle" } + - { $ref: "#/$defs/Square" } + mixed: + oneOf: + - { $ref: "#/$defs/Circle" } + - { type: string, minLength: 2 } +$defs: + Circle: + type: object + required: [kind, radius] + properties: + kind: { type: string, const: circle } + radius: { type: number } + Square: + type: object + required: [kind, side] + properties: + kind: { type: string, const: square } + side: { type: number } +"##; + +/// Drives the generated converter for `UNION_DISPATCH_FALLTHROUGH_SCHEMA`: a +/// member matching no branch must raise the union's own aggregated +/// `ValidationError`, at the member's path, alongside every other violation the +/// model collected — not the `AttributeError` the fallthrough branch's converter +/// used to raise, which escaped the `except ValidationError` and discarded them. +const UNION_DISPATCH_FALLTHROUGH_RUNTIME_CHECK: &str = r#" +import sys + +root, package = sys.argv[1], sys.argv[2] +sys.path.insert(0, root) +models = __import__(package + ".models", fromlist=["*"]) +definitions = __import__(package + "._definitions", fromlist=["*"]) + +Bag, Circle = models.Bag, models.Circle +ValidationError = definitions.ValidationError +converter = getattr(Bag, "__temporal_transfer_type_converter") + +valid = {"pick": {"kind": "circle", "radius": 1.5}, "mixed": "ok"} +model = converter.from_transfer_type(valid, Bag) +assert converter.to_transfer_type(model) == valid, converter.to_transfer_type(model) + +# Neither member is admitted by any branch: `pick` used to reach +# `_SquareTransferTypeConverter` and raise `AttributeError: 'int' object has no +# attribute 'kind'`, taking `mixed`'s violation down with it. +try: + converter.to_transfer_type(Bag(pick=42, mixed=7, additional_properties={})) +except ValidationError as error: + reported = [(violation.path, violation.reason) for violation in error.violations] +else: + raise AssertionError("serializing members no branch admits did not raise") + +assert reported == [ + ("pick", "expected one of: Circle, Square"), + ("mixed", "expected one of: Circle, string"), +], reported + +# A branch's own constraint is still reported under the member's path, not the +# empty path the union function collects it at. +try: + converter.to_transfer_type(Bag(pick=Circle(kind="circle", radius=1.5), mixed="x", additional_properties={})) +except ValidationError as error: + reported = [(violation.path, violation.reason) for violation in error.violations] +else: + raise AssertionError("a short string branch did not raise") + +assert reported == [("mixed", "must have length >= 2, got 1")], reported +"#; + /// Properties named after the converter body's *own* identifiers — its locals /// (`violations`, `raw`, `out`), the builtins it calls (`len`, `int`, `str`, /// `bool`, `dict`, `isinstance`), the modules it imports (`typing`, `math`, `re`), @@ -1130,6 +1210,72 @@ fn python_json_validates_non_object_union_branch_constraints() { fs::remove_dir_all(temp_dir).unwrap(); } +/// A union that converts through a `_<base>_to_transfer_type` runs its checks +/// *inside* that function, ahead of the dispatch, so the unguarded last branch is +/// only ever reached by a value some branch admits. The enclosing member emits no +/// check of its own and re-paths what the function raises. +/// See `specs/json-schema/features/oneOf.md` ("Serialize-side (P12)"). +#[test] +fn python_json_union_serializer_validates_before_dispatching() { + let temp_dir = unique_output_path("py-json-union-dispatch"); + fs::create_dir_all(&temp_dir).unwrap(); + let input_path = temp_dir.join("bag.yaml"); + fs::write(&input_path, UNION_DISPATCH_FALLTHROUGH_SCHEMA).unwrap(); + let output_path = temp_dir.join("bag_package"); + + generate_to_file(&GenerateRequest { + language: nexgen::language::Language::Python, + input_paths: vec![input_path], + support_paths: Vec::new(), + descriptor_paths: Vec::new(), + output_path: output_path.clone(), + format: false, + generate_native_api: false, + java_package_name: None, + ts_date_time_types: Default::default(), + }) + .unwrap(); + let rendered = fs::read_to_string(output_path.join("models.py")).unwrap(); + + // The checks precede the dispatch, and the raise separates them: the last + // branch's converter is unreachable for a value no branch admits. + let dispatch = rendered + .split_once("def _bag_pick_to_transfer_type(value: Circle | Square) -> typing.Any:\n") + .expect("no serialize function for the `pick` union") + .1; + assert!(dispatch.starts_with(concat!( + " violations: list[Violation] = []\n", + " candidate = typing.cast(\"object\", value)\n", + " if not (isinstance(candidate, Circle) or isinstance(candidate, Square)):\n", + " violations.append(Violation(path=\"\", reason=\"expected one of: Circle, Square\"))\n", + " if violations:\n", + " raise ValidationError(violations)\n", + " if isinstance(value, Circle):\n", + )), + "the `pick` dispatch is not preceded by the no-branch-matched test:\n{dispatch}"); + + // The enclosing member holds no copy of that test; it only re-paths. + let member = rendered + .split_once(" if value.pick is not None:\n") + .expect("no serialize block for the `pick` member") + .1; + assert!( + member.starts_with(concat!( + " try:\n", + " out[\"pick\"] = _bag_pick_to_transfer_type(value.pick)\n", + " except ValidationError as error:\n", + " _collect(violations, \"pick\", error)\n", + )), + "the `pick` member repeats the union's checks:\n{member}" + ); + + assert_python_script_succeeds( + UNION_DISPATCH_FALLTHROUGH_RUNTIME_CHECK, + &[temp_dir.to_str().unwrap(), "bag_package"], + ); + fs::remove_dir_all(temp_dir).unwrap(); +} + /// A union in an element position: the loader names it, so Python emits an /// ordinary union alias and the converter dispatches the branch per element. An /// optional field whose *elements* are nullable still needs its own `| None` — From 950d1823d6493282e2f2b462064efd763f8d1eed Mon Sep 17 00:00:00 2001 From: Roey Berman <roey.berman@gmail.com> Date: Fri, 14 Aug 2026 21:12:36 -0700 Subject: [PATCH 12/20] Drop a scratch notes file committed by mistake TODO.md is a local worklist, swept into the branch's first commit by a rebase. It was never meant to ship. --- TODO.md | 10 ---------- 1 file changed, 10 deletions(-) delete mode 100644 TODO.md diff --git a/TODO.md b/TODO.md deleted file mode 100644 index b7de28a6..00000000 --- a/TODO.md +++ /dev/null @@ -1,10 +0,0 @@ -1. default became advisory (T | None = None + DEFAULT_<FIELD> const) rather than baked into the field. A subagent caught that my original choice made Python's wire a superset of the shared fixtures — a third exception to P1's byte-identity, where P1 allows two. Reversing it cost re-work across the tests, default.md, and the loader's P15 reservation. -2. A _transfer_type_convertible shim in _definitions.py. The bare SDK decorator is circular for pyright, degrading every model to Unknown. The shim keeps the idiomatic decorator on the model and confines the workaround to the runtime. -3. Union serialize dispatch falls through to its last branch instead of guarding it and raising. Guarding it is statically unreachable given the declared type, and basedpyright's warnings fail the gate. P12's real guarantee holds — nothing invalid reaches the wire, it just fails as an AttributeError rather than an aggregated ValidationError, in a case a type checker already rejects. I've documented this explicitly in oneOf.md rather than leaving it implicit. This is the one I'd most want you to sanity-check. - -Two things I deliberately did not fix, both real: - -- src/generator/proto/python.rs has the same decorator typing bug. It's latent only because a test imports those models first and forces a working evaluation order. Generate WIT models without such a test and basedpyright breaks. Out of scope here, but it wants a follow-up — arguably an upstream fix to the SDK's annotations. -- Two leftovers under gitignored target/ (uvbin, validate*.log) that the agent couldn't remove because rm was denied. - -Also fixed five pre-existing drift bugs found en route, none of them mine: the length must be <= N docs wording (wrong for all four languages, 12 occurrences), required.md's invented required property "x" is missing, the false "Python alone is exempt" catch-all claim, uniqueItems.md describing a hash-set that would raise on unhashable dataclasses, and the Go/TS reason-string divergence — documented rather than papered over. From 345a7e5e156dd472a10ab1ae3af6aec79197a4cf Mon Sep 17 00:00:00 2001 From: Roey Berman <roey.berman@gmail.com> Date: Sun, 16 Aug 2026 14:41:07 -0700 Subject: [PATCH 13/20] Python: materialize schema defaults through properties --- CHANGELOG.md | 14 +- .../python/json_schema/api/chat/models.py | 41 +- .../python/json_schema/api/kb/_recursive.py | 11 +- .../api/kb/content/block/models.py | 7 +- .../json_schema/api/kb/content/page/models.py | 5 +- .../python/json_schema/api/kb/kb/models.py | 3 + .../api/kb/tree/category/models.py | 5 +- .../python/json_schema/api/showcase/models.py | 349 +++++++++++++----- .../python/json_schema/api/temporal/models.py | 15 +- samples/python/chat/models.py | 41 +- samples/python/kb/_recursive.py | 11 +- samples/python/kb/content/block/models.py | 7 +- samples/python/kb/content/page/models.py | 5 +- samples/python/kb/kb/models.py | 3 + samples/python/kb/tree/category/models.py | 5 +- samples/python/showcase/models.py | 349 +++++++++++++----- samples/python/temporal/models.py | 15 +- samples/python/tests/json_converter_helper.py | 8 +- samples/python/tests/test_chat.py | 35 +- samples/python/tests/test_showcase.py | 28 +- specs/json-schema/PRINCIPLES.md | 8 +- specs/json-schema/features/default.md | 39 +- specs/json-schema/features/properties.md | 13 +- specs/json-schema/nullability.md | 47 ++- src/generator/json_schema/python.rs | 287 +++++++++----- src/parser/json_schema.rs | 85 ++++- tests/generate_python.rs | 148 +++++++- 27 files changed, 1119 insertions(+), 465 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c6944e53..7c09979c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Python: JSON Schema model properties that are optional or nullable now use + `typing.Optional[T]` on the public dataclass surface. Default-bearing properties + now expose mutable same-name properties backed by private optional fields: reads + materialize the schema default, while converters preserve unset state and omit it + from the wire. The public keyword constructor remains compatible, explicit values + (including the default itself) remain present on the wire, assigning `None` resets + the field to unset, and Python no longer emits module-level `DEFAULT_*` constants. - Protobuf-backed models now consistently generate conversions in both directions whenever they are reachable. Go and TypeScript emit previously suppressed complementary helpers, operation-free exported models receive the @@ -80,13 +87,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 member and an explicit wire `null` read as the same `None`, and both re-serialize as *omitted*. The set of accepted and rejected values is unchanged — only the byte-identity of an explicit `null` on the way back out. -- Python: A schema `default` is now **advisory**, matching TypeScript. It is no - longer materialized on read: the member is encoded like any other optional one - (`T | None = None`, omitted when unset, so the wire stays byte-identical), and - the default is emitted as a module-level `DEFAULT_<FIELD>` constant - (`DEFAULT_<MODEL>_<FIELD>` when the member name is not unique in the module) - for the consumer to apply — `x if x is not None else DEFAULT_X`. Pydantic used - to surface the default as the field's value; read the constant instead. - JSON Schema: An `x-<lang>-name` alongside a `$ref` is no longer merged as an implicit-`allOf` conjunct, which cloned the referenced target into the use site. It names the _member_ the reference is bound to and leaves the reference intact diff --git a/advanced/samples/python/json_schema/api/chat/models.py b/advanced/samples/python/json_schema/api/chat/models.py index 5d9319d2..07088aa6 100644 --- a/advanced/samples/python/json_schema/api/chat/models.py +++ b/advanced/samples/python/json_schema/api/chat/models.py @@ -16,7 +16,7 @@ ) -DEFAULT_PRIORITY = 0 +# pyright: reportDeprecated=false, reportPropertyTypeMismatch=false _ROOM_DECLARED: frozenset[str] = frozenset( @@ -211,15 +211,15 @@ def to_transfer_type(self, value: "Message") -> typing.Any: out["body"] = value.body if value.reply_to_id is not None: out["replyToId"] = value.reply_to_id - if value.priority is not None: - out["priority"] = value.priority + if value._priority is not None: + out["priority"] = value._priority if violations: raise ValidationError(violations) return out @_transfer_type_convertible(_MessageTransferTypeConverter) -@dataclasses.dataclass(slots=True, kw_only=True) +@dataclasses.dataclass(slots=True, kw_only=True, init=False) class Message: """A chat message.""" @@ -228,12 +228,35 @@ class Message: body: str - reply_to_id: str | None = None + reply_to_id: typing.Optional[str] = None """Id of the message this replies to, if any.""" - priority: int | None = None + _priority: typing.Optional[int] = dataclasses.field(default=None, repr=False) """Delivery priority.""" + def __init__( + self, + *, + kind: typing.Literal["text"] = "text", + body: str, + reply_to_id: typing.Optional[str] = None, + priority: typing.Optional[int] = None, + _priority: typing.Optional[int] = None, + ) -> None: + self.kind = kind + self.body = body + self.reply_to_id = reply_to_id + self._priority = _priority if _priority is not None else priority + + @property + def priority(self) -> int: + """Delivery priority.""" + return self._priority if self._priority is not None else 0 + + @priority.setter + def priority(self, value: typing.Optional[int]) -> None: + self._priority = value + class _RoomTransferTypeConverter( temporalio.converter.TransferTypeConverter["Room", typing.Any] @@ -373,12 +396,12 @@ class Room: display_name: str - topic: str | None + topic: typing.Optional[str] """Room topic; may be explicitly cleared to null.""" - members: list[str] | None = None + members: typing.Optional[list[str]] = None - labels: Labels | None = None + labels: typing.Optional[Labels] = None additional_properties: dict[str, typing.Any] = dataclasses.field( default_factory=dict diff --git a/advanced/samples/python/json_schema/api/kb/_recursive.py b/advanced/samples/python/json_schema/api/kb/_recursive.py index 0d5b9f43..f9db85ec 100644 --- a/advanced/samples/python/json_schema/api/kb/_recursive.py +++ b/advanced/samples/python/json_schema/api/kb/_recursive.py @@ -20,6 +20,9 @@ from .content.page.models import PageMeta +# pyright: reportDeprecated=false, reportPropertyTypeMismatch=false + + class _BlockTransferTypeConverter( temporalio.converter.TransferTypeConverter["Block", typing.Any] ): @@ -163,11 +166,11 @@ class Block: integer field. """ - text: str | None = None + text: typing.Optional[str] = None - style: BlockStyle | None = None + style: typing.Optional[BlockStyle] = None - page: Page | None = None + page: typing.Optional[Page] = None """Optional back-reference to the containing page - closes the Page <-> Block cycle. Optional + nullable, so this edge terminates. """ @@ -296,7 +299,7 @@ class Page: meta: PageMeta - blocks: list[Block] | None = None + blocks: typing.Optional[list[Block]] = None """Ordered content blocks. Cross-file `$ref` to block.json (same directory); the array is the terminating edge of the cycle. """ diff --git a/advanced/samples/python/json_schema/api/kb/content/block/models.py b/advanced/samples/python/json_schema/api/kb/content/block/models.py index 5d368d93..4e90412b 100644 --- a/advanced/samples/python/json_schema/api/kb/content/block/models.py +++ b/advanced/samples/python/json_schema/api/kb/content/block/models.py @@ -15,6 +15,9 @@ ) +# pyright: reportDeprecated=false, reportPropertyTypeMismatch=false + + class _BlockStyleTransferTypeConverter( temporalio.converter.TransferTypeConverter["BlockStyle", typing.Any] ): @@ -93,6 +96,6 @@ def to_transfer_type(self, value: "BlockStyle") -> typing.Any: class BlockStyle: """Non-cyclic helper; stays in the content_block module. All members optional.""" - bold: bool | None = None + bold: typing.Optional[bool] = None - indent: int | None = None + indent: typing.Optional[int] = None diff --git a/advanced/samples/python/json_schema/api/kb/content/page/models.py b/advanced/samples/python/json_schema/api/kb/content/page/models.py index 5719f97f..22e21a83 100644 --- a/advanced/samples/python/json_schema/api/kb/content/page/models.py +++ b/advanced/samples/python/json_schema/api/kb/content/page/models.py @@ -15,6 +15,9 @@ ) +# pyright: reportDeprecated=false, reportPropertyTypeMismatch=false + + class _PageMetaTransferTypeConverter( temporalio.converter.TransferTypeConverter["PageMeta", typing.Any] ): @@ -79,4 +82,4 @@ class PageMeta: author: str - word_count: int | None = None + word_count: typing.Optional[int] = None diff --git a/advanced/samples/python/json_schema/api/kb/kb/models.py b/advanced/samples/python/json_schema/api/kb/kb/models.py index 3a3a2b70..112dabe4 100644 --- a/advanced/samples/python/json_schema/api/kb/kb/models.py +++ b/advanced/samples/python/json_schema/api/kb/kb/models.py @@ -15,6 +15,9 @@ ) +# pyright: reportDeprecated=false, reportPropertyTypeMismatch=false + + class _GetCategoryTreeInputTransferTypeConverter( temporalio.converter.TransferTypeConverter["GetCategoryTreeInput", typing.Any] ): diff --git a/advanced/samples/python/json_schema/api/kb/tree/category/models.py b/advanced/samples/python/json_schema/api/kb/tree/category/models.py index 56213f62..9bd9dbe5 100644 --- a/advanced/samples/python/json_schema/api/kb/tree/category/models.py +++ b/advanced/samples/python/json_schema/api/kb/tree/category/models.py @@ -15,6 +15,9 @@ ) +# pyright: reportDeprecated=false, reportPropertyTypeMismatch=false + + class _CategoryTransferTypeConverter( temporalio.converter.TransferTypeConverter["Category", typing.Any] ): @@ -122,7 +125,7 @@ class Category: name: str - children: list[Category] | None = None + children: typing.Optional[list[Category]] = None """Sub-categories. A within-file self-cycle via `$ref: '#'`; the possibly-empty array is the terminating edge, so it stays in this module. """ diff --git a/advanced/samples/python/json_schema/api/showcase/models.py b/advanced/samples/python/json_schema/api/showcase/models.py index 4739a8fe..1e07cf9c 100644 --- a/advanced/samples/python/json_schema/api/showcase/models.py +++ b/advanced/samples/python/json_schema/api/showcase/models.py @@ -25,9 +25,7 @@ ) -DEFAULT_RETRIES = 3 -DEFAULT_GREETING = "hello" -DEFAULT_DEBUG = False +# pyright: reportDeprecated=false, reportPropertyTypeMismatch=false _PATTERN_CD24623C0C29CA35 = re.compile("^[A-Z]{2,4}\\Z", re.ASCII) @@ -181,9 +179,9 @@ class Address: street: str - city: str | None = None + city: typing.Optional[str] = None - zip: int | None = None + zip: typing.Optional[int] = None additional_properties: dict[str, typing.Any] = dataclasses.field( default_factory=dict @@ -561,11 +559,11 @@ class ContactPy: `$ref`, while the wire `$ref` name stays `Contact`. """ - email: str | None = None + email: typing.Optional[str] = None - shipping_street: str | None = None + shipping_street: typing.Optional[str] = None - shipping_zip: str | None = None + shipping_zip: typing.Optional[str] = None additional_properties: dict[str, typing.Any] = dataclasses.field( default_factory=dict @@ -970,9 +968,9 @@ def to_transfer_type(self, value: "Settings") -> typing.Any: class Settings: """A closed object; unknown members are rejected.""" - theme: str | None = None + theme: typing.Optional[str] = None - font_size: int | None = None + font_size: typing.Optional[int] = None class _ShowcaseTransferTypeConverter( @@ -2478,14 +2476,14 @@ def to_transfer_type(self, value: "Showcase") -> typing.Any: out["blob"] = _format_base64(value.blob) if value.url_blob is not None: out["urlBlob"] = _format_base64url(value.url_blob) - if value.retries is not None: - out["retries"] = value.retries + if value._retries is not None: + out["retries"] = value._retries if value.verbose is not None: out["verbose"] = value.verbose - if value.greeting is not None: - out["greeting"] = value.greeting - if value.debug is not None: - out["debug"] = value.debug + if value._greeting is not None: + out["greeting"] = value._greeting + if value._debug is not None: + out["debug"] = value._debug if value.legacy_id_py is not None: out["legacyId"] = value.legacy_id_py if value.middle_name is not None: @@ -2823,7 +2821,7 @@ def to_transfer_type(self, value: "Showcase") -> typing.Any: @_transfer_type_convertible(_ShowcaseTransferTypeConverter) -@dataclasses.dataclass(slots=True, kw_only=True) +@dataclasses.dataclass(slots=True, kw_only=True, init=False) class Showcase: """Showcase Root object exercising the supported JSON-Schema feature subset: required and @@ -2873,87 +2871,86 @@ class Showcase: active: bool """Required boolean scalar.""" - nickname: str | None = None + nickname: typing.Optional[str] = None """Optional short name, at most 12 code points.""" - code: str | None = None + code: typing.Optional[str] = None """Optional code, 2 to 5 code points. Counted in Unicode code points, so a multi-byte value (e.g. "a😀b", 3 code points / 6 UTF-8 bytes) is valid. """ - sku: str | None = None + sku: typing.Optional[str] = None """Optional product code: 2 to 4 uppercase ASCII letters, anchored (`^[A-Z]{2,4}$`). Exercises the RE2-safe `pattern` gate. """ - phrase: str | None = None + phrase: typing.Optional[str] = None """Optional two-word phrase separated by whitespace (`^\\S+\\s\\S+$`). Exercises the loader's `\\s`/`\\S` → ASCII-class normalization and the per-target `$` end-anchor rewrite (Python `\\Z` / Java `\\z`), so a Unicode space (NBSP) and a trailing newline are rejected consistently across all four languages. """ - request_id: str | None = None + request_id: typing.Optional[str] = None """Optional request identifier; asserted RFC 4122 UUID via `format: uuid`. Stays `string`-typed (format assertion, no materialization); the pinned regex is validated identically across all four languages. """ - contact_email: str | None = None + contact_email: typing.Optional[str] = None """Optional contact address; asserted ASCII dot-atom `format: email` (single `@`, >=2-label domain, total length <= 254, guard-before-regex). """ - host: str | None = None + host: typing.Optional[str] = None """Optional host name; asserted RFC 1123 `format: hostname` (LDH labels, total length <= 253). """ - homepage: str | None = None + homepage: typing.Optional[str] = None """Optional homepage; asserted RFC 3986 `format: uri` (scheme required, ASCII only; an IP-literal host is validated by the spliced ipv6 grammar). """ - gateway: str | None = None + gateway: typing.Optional[str] = None """Optional gateway address; asserted dotted-quad IPv4 via format ipv4.""" - blob: bytes | None = None + blob: typing.Optional[bytes] = None """Optional binary payload carried as a `contentEncoding: base64` string, materialized to native bytes (Go []byte, TS Uint8Array, Python bytes, Java byte[]). The wire is canonical padded standard base64; a malformed value is rejected by the pinned regex before decode. """ - url_blob: bytes | None = None + url_blob: typing.Optional[bytes] = None """Optional binary payload carried as a `contentEncoding: base64url` string (URL-safe alphabet, unpadded, RFC 4648 §5), materialized to the same native bytes type. The same bytes encode to a different wire than base64 ("Pj4+" vs "Pj4-"). """ - retries: int | None = None + _retries: typing.Optional[int] = dataclasses.field(default=None, repr=False) """Retry budget Optional integer with a schema default. """ - verbose: bool | None = None + verbose: typing.Optional[bool] = None - greeting: str | None = None + _greeting: typing.Optional[str] = dataclasses.field(default=None, repr=False) """Greeting Optional string with a schema default, surfaced on read. """ - debug: bool | None = None + _debug: typing.Optional[bool] = dataclasses.field(default=None, repr=False) """Debug flag Optional boolean with a schema default. """ - legacy_id_py: ( + legacy_id_py: typing.Optional[ typing.Annotated[ str, typing_extensions.deprecated("This field is deprecated.", category=None), ] - | None - ) = None + ] = None """Deprecated legacy identifier; prefer `requestId`. Exercises the native deprecation marker (Go // Deprecated:, TS @deprecated, Java @Deprecated, Python PEP 702 @deprecated). Also exercises the property-level `x-<lang>-name` override (the Stage @@ -2963,34 +2960,34 @@ class Showcase: @JsonProperty). """ - middle_name: str | None = None + middle_name: typing.Optional[str] = None """Optional and nullable; may be absent or explicitly null.""" - category: str | None + category: typing.Optional[str] """Required but nullable; may be explicitly cleared to null.""" - priority: int | None = None + priority: typing.Optional[int] = None """Optional integer bounded to the inclusive range [1, 10].""" - level: int | None = None + level: typing.Optional[int] = None """Optional integer that must be strictly greater than 0.""" - ratio: float | None = None + ratio: typing.Optional[float] = None """Optional number that must be a non-negative multiple of 5.""" - step: int | None = None + step: typing.Optional[int] = None """Optional integer that must be a multiple of 3.""" - tags: list[str] | None = None + tags: typing.Optional[list[str]] = None """Ordered list of free-form tags; 1 to 5 entries.""" - aliases: list[str] | None = None + aliases: typing.Optional[list[str]] = None """Alternate names; each must be distinct.""" - roles: list[str] | None = None + roles: typing.Optional[list[str]] = None """Access roles; must contain between one and two "admin" entries.""" - id_or_name: str | int | None = None + id_or_name: typing.Optional[str | int] = None """Disjoint-kind union (oneOf sum type): the wire value is either a string of at least 3 code points or an integer of at least 1, selected by its JSON token. Not a member of a discriminated union — the token itself is the selector. Each branch also @@ -2999,14 +2996,14 @@ class Showcase: violation. """ - mode: typing.Literal["auto", "manual"] | int | None = None + mode: typing.Optional[typing.Literal["auto", "manual"] | int] = None """A union whose string branch is a **closed value set**: either one of two named modes or an unbounded non-negative integer. The branch narrows to its own admissible values (a Go/Java membership check, a TypeScript literal union, a Python `Literal`), so an unknown string is a Violation while any non-negative integer is accepted. """ - payload: dict[str, typing.Any] | str | None = None + payload: typing.Optional[dict[str, typing.Any] | str] = None """Mixed-kind union whose object branch is an inline free-form object: the wire value is either an arbitrary object (members carried verbatim) or a string, selected by its JSON token. The free-form object is the one object branch that needs no type @@ -3014,7 +3011,7 @@ class Showcase: `<Union>Object`. """ - detail: ShowcaseDetailObject | str | None = None + detail: typing.Optional[ShowcaseDetailObject | str] = None """Mixed-kind union whose object branch is an inline *structured* object, written directly on the property rather than in `$defs`. It is the only object branch of this union, so it derives its name from the union it belongs to — @@ -3022,7 +3019,7 @@ class Showcase: own constraints and it stays open to unknown ones. """ - shape_or_name: Circle | Square | str | None = None + shape_or_name: typing.Optional[Circle | Square | str] = None """Tagged object union mixed with a scalar kind: the two selector layers compose — the JSON token picks object-vs-string, and, for an object, the shared required `kind` const picks Circle-vs-Square. Written inline on the property, so the union itself is @@ -3033,7 +3030,7 @@ class Showcase: validate through their own models. """ - measurements: list[float] | str | None = None + measurements: typing.Optional[list[float] | str] = None """Mixed-kind union with an array branch: the wire value is either a non-empty list of distinct numbers or a lowercase preset name, selected by its JSON token. An array branch has no definition to take a name from, so Go and Java emit it as the @@ -3043,72 +3040,242 @@ class Showcase: string's `pattern` — so the array-vs-string choice is validated as well as selected. """ - shapes: list[Shape] | None = None + shapes: typing.Optional[list[Shape]] = None """A list whose element type is a named union: every element is routed to exactly one branch by the union's own selector, and its index carries into the violation path (`shapes[1]`). Go and Java cannot decode a sealed interface as a whole, so the element decodes through the union's dispatcher one at a time. """ - segments: list[ShowcaseSegmentsItem] | None = None + segments: typing.Optional[list[ShowcaseSegmentsItem]] = None """A list whose element union is written **inline**. An element has no name of its own, so the union is named after its position — `ShowcaseSegmentsItem` — moved into `$defs`, and the element becomes a `$ref` at it; from there it is an ordinary named union in every language. """ - slots: list[str | None] | None = None + slots: typing.Optional[list[str | None]] = None """A list of **nullable elements** — the two-branch nullability `oneOf` rather than a sum type, so nothing is named: the elements themselves become nullable (`[]*string`, `(string | null)[]`, `list[str | None]`, `List<@Nullable String>`) while the list stays a list. """ - grid: list[list[int]] | None = None + grid: typing.Optional[list[list[int]]] = None """A nested array: `items` at depth two. Each level decodes elementwise, so a bad element is reported at its own two-dimensional index (`grid[1][0]`). """ - location: ShowcaseLocation | None = None + location: typing.Optional[ShowcaseLocation] = None - audit: ShowcaseAudit | None = None + audit: typing.Optional[ShowcaseAudit] = None """A nullable inline object. The nullability wrapper emits no type of its own, so the object inside it takes the property's name — `ShowcaseAudit`, the same name it would take written plainly: adding or removing nullability never renames the type. """ - rows: list[ShowcaseRowsItem] | None = None + rows: typing.Optional[list[ShowcaseRowsItem]] = None """A list whose element is an inline object, named after its position (`ShowcaseRowsItem`) exactly as an inline element *union* is. """ - ledger_py: ShowcaseLedger | None = None - - metadata: ShowcaseMetadata | None = None - - quotas: Quotas | None = None - - tokens: Tokens | None = None - - nicknames: Nicknames | None = None - - choices: Choices | None = None - - extras: Extras | None = None - - shape: Shape | None = None - - note: Note | None = None - - address: Address | None = None - - labels: Labels | None = None - - settings: Settings | None = None - - attributes: Attributes | None = None - - contact: ContactPy | None = None + ledger_py: typing.Optional[ShowcaseLedger] = None + + metadata: typing.Optional[ShowcaseMetadata] = None + + quotas: typing.Optional[Quotas] = None + + tokens: typing.Optional[Tokens] = None + + nicknames: typing.Optional[Nicknames] = None + + choices: typing.Optional[Choices] = None + + extras: typing.Optional[Extras] = None + + shape: typing.Optional[Shape] = None + + note: typing.Optional[Note] = None + + address: typing.Optional[Address] = None + + labels: typing.Optional[Labels] = None + + settings: typing.Optional[Settings] = None + + attributes: typing.Optional[Attributes] = None + + contact: typing.Optional[ContactPy] = None + + def __init__( + self, + *, + kind: typing.Literal["showcase"] = "showcase", + revision: typing.Literal[1] = 1, + enabled: typing.Literal[True] = True, + status: typing.Literal["active", "inactive", "pending"], + tier: typing.Literal[1, 2, 3], + scale: float, + name: str, + count: int, + active: bool, + nickname: typing.Optional[str] = None, + code: typing.Optional[str] = None, + sku: typing.Optional[str] = None, + phrase: typing.Optional[str] = None, + request_id: typing.Optional[str] = None, + contact_email: typing.Optional[str] = None, + host: typing.Optional[str] = None, + homepage: typing.Optional[str] = None, + gateway: typing.Optional[str] = None, + blob: typing.Optional[bytes] = None, + url_blob: typing.Optional[bytes] = None, + retries: typing.Optional[int] = None, + verbose: typing.Optional[bool] = None, + greeting: typing.Optional[str] = None, + debug: typing.Optional[bool] = None, + legacy_id_py: typing.Optional[ + typing.Annotated[ + str, + typing_extensions.deprecated( + "This field is deprecated.", category=None + ), + ] + ] = None, + middle_name: typing.Optional[str] = None, + category: typing.Optional[str], + priority: typing.Optional[int] = None, + level: typing.Optional[int] = None, + ratio: typing.Optional[float] = None, + step: typing.Optional[int] = None, + tags: typing.Optional[list[str]] = None, + aliases: typing.Optional[list[str]] = None, + roles: typing.Optional[list[str]] = None, + id_or_name: typing.Optional[str | int] = None, + mode: typing.Optional[typing.Literal["auto", "manual"] | int] = None, + payload: typing.Optional[dict[str, typing.Any] | str] = None, + detail: typing.Optional[ShowcaseDetailObject | str] = None, + shape_or_name: typing.Optional[Circle | Square | str] = None, + measurements: typing.Optional[list[float] | str] = None, + shapes: typing.Optional[list[Shape]] = None, + segments: typing.Optional[list[ShowcaseSegmentsItem]] = None, + slots: typing.Optional[list[str | None]] = None, + grid: typing.Optional[list[list[int]]] = None, + location: typing.Optional[ShowcaseLocation] = None, + audit: typing.Optional[ShowcaseAudit] = None, + rows: typing.Optional[list[ShowcaseRowsItem]] = None, + ledger_py: typing.Optional[ShowcaseLedger] = None, + metadata: typing.Optional[ShowcaseMetadata] = None, + quotas: typing.Optional[Quotas] = None, + tokens: typing.Optional[Tokens] = None, + nicknames: typing.Optional[Nicknames] = None, + choices: typing.Optional[Choices] = None, + extras: typing.Optional[Extras] = None, + shape: typing.Optional[Shape] = None, + note: typing.Optional[Note] = None, + address: typing.Optional[Address] = None, + labels: typing.Optional[Labels] = None, + settings: typing.Optional[Settings] = None, + attributes: typing.Optional[Attributes] = None, + contact: typing.Optional[ContactPy] = None, + _retries: typing.Optional[int] = None, + _greeting: typing.Optional[str] = None, + _debug: typing.Optional[bool] = None, + ) -> None: + self.kind = kind + self.revision = revision + self.enabled = enabled + self.status = status + self.tier = tier + self.scale = scale + self.name = name + self.count = count + self.active = active + self.nickname = nickname + self.code = code + self.sku = sku + self.phrase = phrase + self.request_id = request_id + self.contact_email = contact_email + self.host = host + self.homepage = homepage + self.gateway = gateway + self.blob = blob + self.url_blob = url_blob + self._retries = _retries if _retries is not None else retries + self.verbose = verbose + self._greeting = _greeting if _greeting is not None else greeting + self._debug = _debug if _debug is not None else debug + self.legacy_id_py = legacy_id_py + self.middle_name = middle_name + self.category = category + self.priority = priority + self.level = level + self.ratio = ratio + self.step = step + self.tags = tags + self.aliases = aliases + self.roles = roles + self.id_or_name = id_or_name + self.mode = mode + self.payload = payload + self.detail = detail + self.shape_or_name = shape_or_name + self.measurements = measurements + self.shapes = shapes + self.segments = segments + self.slots = slots + self.grid = grid + self.location = location + self.audit = audit + self.rows = rows + self.ledger_py = ledger_py + self.metadata = metadata + self.quotas = quotas + self.tokens = tokens + self.nicknames = nicknames + self.choices = choices + self.extras = extras + self.shape = shape + self.note = note + self.address = address + self.labels = labels + self.settings = settings + self.attributes = attributes + self.contact = contact + + @property + def retries(self) -> int: + """Retry budget + Optional integer with a schema default. + """ + return self._retries if self._retries is not None else 3 + + @retries.setter + def retries(self, value: typing.Optional[int]) -> None: + self._retries = value + + @property + def greeting(self) -> str: + """Greeting + Optional string with a schema default, surfaced on read. + """ + return self._greeting if self._greeting is not None else "hello" + + @greeting.setter + def greeting(self, value: typing.Optional[str]) -> None: + self._greeting = value + + @property + def debug(self) -> bool: + """Debug flag + Optional boolean with a schema default. + """ + return self._debug if self._debug is not None else False + + @debug.setter + def debug(self, value: typing.Optional[bool]) -> None: + self._debug = value class _ShowcaseAuditTransferTypeConverter( @@ -3258,7 +3425,7 @@ def to_transfer_type(self, value: "ShowcaseDetailObject") -> typing.Any: class ShowcaseDetailObject: code: str - hint: str | None = None + hint: typing.Optional[str] = None additional_properties: dict[str, typing.Any] = dataclasses.field( default_factory=dict @@ -3487,7 +3654,7 @@ class ShowcaseLocation: city: str - geo: ShowcaseLocationGeo | None = None + geo: typing.Optional[ShowcaseLocationGeo] = None additional_properties: dict[str, typing.Any] = dataclasses.field( default_factory=dict @@ -3602,9 +3769,9 @@ def to_transfer_type(self, value: "ShowcaseLocationGeo") -> typing.Any: @_transfer_type_convertible(_ShowcaseLocationGeoTransferTypeConverter) @dataclasses.dataclass(slots=True, kw_only=True) class ShowcaseLocationGeo: - lat: float | None = None + lat: typing.Optional[float] = None - lon: float | None = None + lon: typing.Optional[float] = None additional_properties: dict[str, typing.Any] = dataclasses.field( default_factory=dict @@ -4166,11 +4333,11 @@ class Widget: id: str - kind: str | None = None + kind: typing.Optional[str] = None name: str - size: int | None = None + size: typing.Optional[int] = None """Optional integer with two allOf branches tightened to [10, 20].""" additional_properties: dict[str, typing.Any] = dataclasses.field( @@ -4245,7 +4412,7 @@ class WidgetBase: id: str - kind: str | None = None + kind: typing.Optional[str] = None additional_properties: dict[str, typing.Any] = dataclasses.field( default_factory=dict diff --git a/advanced/samples/python/json_schema/api/temporal/models.py b/advanced/samples/python/json_schema/api/temporal/models.py index 442114a3..e75ed34e 100644 --- a/advanced/samples/python/json_schema/api/temporal/models.py +++ b/advanced/samples/python/json_schema/api/temporal/models.py @@ -26,6 +26,9 @@ ) +# pyright: reportDeprecated=false, reportPropertyTypeMismatch=false + + class _TemporalTransferTypeConverter( temporalio.converter.TransferTypeConverter["Temporal", typing.Any] ): @@ -291,20 +294,20 @@ class Temporal: PT90M → PT1H30M). """ - updated_at: datetime.datetime | None = None + updated_at: typing.Optional[datetime.datetime] = None """Optional date-time.""" - expires_on: datetime.date | None = None + expires_on: typing.Optional[datetime.date] = None """Optional date.""" - reminder: datetime.time | None = None + reminder: typing.Optional[datetime.time] = None """Optional time.""" - retry_delay: datetime.timedelta | None = None + retry_delay: typing.Optional[datetime.timedelta] = None """Optional duration.""" - deleted_at: datetime.datetime | None = None + deleted_at: typing.Optional[datetime.datetime] = None """Optional and nullable date-time (may be absent or explicitly null).""" - archived_on: datetime.date | None = None + archived_on: typing.Optional[datetime.date] = None """Optional and nullable date.""" diff --git a/samples/python/chat/models.py b/samples/python/chat/models.py index 5d9319d2..07088aa6 100644 --- a/samples/python/chat/models.py +++ b/samples/python/chat/models.py @@ -16,7 +16,7 @@ ) -DEFAULT_PRIORITY = 0 +# pyright: reportDeprecated=false, reportPropertyTypeMismatch=false _ROOM_DECLARED: frozenset[str] = frozenset( @@ -211,15 +211,15 @@ def to_transfer_type(self, value: "Message") -> typing.Any: out["body"] = value.body if value.reply_to_id is not None: out["replyToId"] = value.reply_to_id - if value.priority is not None: - out["priority"] = value.priority + if value._priority is not None: + out["priority"] = value._priority if violations: raise ValidationError(violations) return out @_transfer_type_convertible(_MessageTransferTypeConverter) -@dataclasses.dataclass(slots=True, kw_only=True) +@dataclasses.dataclass(slots=True, kw_only=True, init=False) class Message: """A chat message.""" @@ -228,12 +228,35 @@ class Message: body: str - reply_to_id: str | None = None + reply_to_id: typing.Optional[str] = None """Id of the message this replies to, if any.""" - priority: int | None = None + _priority: typing.Optional[int] = dataclasses.field(default=None, repr=False) """Delivery priority.""" + def __init__( + self, + *, + kind: typing.Literal["text"] = "text", + body: str, + reply_to_id: typing.Optional[str] = None, + priority: typing.Optional[int] = None, + _priority: typing.Optional[int] = None, + ) -> None: + self.kind = kind + self.body = body + self.reply_to_id = reply_to_id + self._priority = _priority if _priority is not None else priority + + @property + def priority(self) -> int: + """Delivery priority.""" + return self._priority if self._priority is not None else 0 + + @priority.setter + def priority(self, value: typing.Optional[int]) -> None: + self._priority = value + class _RoomTransferTypeConverter( temporalio.converter.TransferTypeConverter["Room", typing.Any] @@ -373,12 +396,12 @@ class Room: display_name: str - topic: str | None + topic: typing.Optional[str] """Room topic; may be explicitly cleared to null.""" - members: list[str] | None = None + members: typing.Optional[list[str]] = None - labels: Labels | None = None + labels: typing.Optional[Labels] = None additional_properties: dict[str, typing.Any] = dataclasses.field( default_factory=dict diff --git a/samples/python/kb/_recursive.py b/samples/python/kb/_recursive.py index 0d5b9f43..f9db85ec 100644 --- a/samples/python/kb/_recursive.py +++ b/samples/python/kb/_recursive.py @@ -20,6 +20,9 @@ from .content.page.models import PageMeta +# pyright: reportDeprecated=false, reportPropertyTypeMismatch=false + + class _BlockTransferTypeConverter( temporalio.converter.TransferTypeConverter["Block", typing.Any] ): @@ -163,11 +166,11 @@ class Block: integer field. """ - text: str | None = None + text: typing.Optional[str] = None - style: BlockStyle | None = None + style: typing.Optional[BlockStyle] = None - page: Page | None = None + page: typing.Optional[Page] = None """Optional back-reference to the containing page - closes the Page <-> Block cycle. Optional + nullable, so this edge terminates. """ @@ -296,7 +299,7 @@ class Page: meta: PageMeta - blocks: list[Block] | None = None + blocks: typing.Optional[list[Block]] = None """Ordered content blocks. Cross-file `$ref` to block.json (same directory); the array is the terminating edge of the cycle. """ diff --git a/samples/python/kb/content/block/models.py b/samples/python/kb/content/block/models.py index 5d368d93..4e90412b 100644 --- a/samples/python/kb/content/block/models.py +++ b/samples/python/kb/content/block/models.py @@ -15,6 +15,9 @@ ) +# pyright: reportDeprecated=false, reportPropertyTypeMismatch=false + + class _BlockStyleTransferTypeConverter( temporalio.converter.TransferTypeConverter["BlockStyle", typing.Any] ): @@ -93,6 +96,6 @@ def to_transfer_type(self, value: "BlockStyle") -> typing.Any: class BlockStyle: """Non-cyclic helper; stays in the content_block module. All members optional.""" - bold: bool | None = None + bold: typing.Optional[bool] = None - indent: int | None = None + indent: typing.Optional[int] = None diff --git a/samples/python/kb/content/page/models.py b/samples/python/kb/content/page/models.py index 5719f97f..22e21a83 100644 --- a/samples/python/kb/content/page/models.py +++ b/samples/python/kb/content/page/models.py @@ -15,6 +15,9 @@ ) +# pyright: reportDeprecated=false, reportPropertyTypeMismatch=false + + class _PageMetaTransferTypeConverter( temporalio.converter.TransferTypeConverter["PageMeta", typing.Any] ): @@ -79,4 +82,4 @@ class PageMeta: author: str - word_count: int | None = None + word_count: typing.Optional[int] = None diff --git a/samples/python/kb/kb/models.py b/samples/python/kb/kb/models.py index 3a3a2b70..112dabe4 100644 --- a/samples/python/kb/kb/models.py +++ b/samples/python/kb/kb/models.py @@ -15,6 +15,9 @@ ) +# pyright: reportDeprecated=false, reportPropertyTypeMismatch=false + + class _GetCategoryTreeInputTransferTypeConverter( temporalio.converter.TransferTypeConverter["GetCategoryTreeInput", typing.Any] ): diff --git a/samples/python/kb/tree/category/models.py b/samples/python/kb/tree/category/models.py index 56213f62..9bd9dbe5 100644 --- a/samples/python/kb/tree/category/models.py +++ b/samples/python/kb/tree/category/models.py @@ -15,6 +15,9 @@ ) +# pyright: reportDeprecated=false, reportPropertyTypeMismatch=false + + class _CategoryTransferTypeConverter( temporalio.converter.TransferTypeConverter["Category", typing.Any] ): @@ -122,7 +125,7 @@ class Category: name: str - children: list[Category] | None = None + children: typing.Optional[list[Category]] = None """Sub-categories. A within-file self-cycle via `$ref: '#'`; the possibly-empty array is the terminating edge, so it stays in this module. """ diff --git a/samples/python/showcase/models.py b/samples/python/showcase/models.py index 4739a8fe..1e07cf9c 100644 --- a/samples/python/showcase/models.py +++ b/samples/python/showcase/models.py @@ -25,9 +25,7 @@ ) -DEFAULT_RETRIES = 3 -DEFAULT_GREETING = "hello" -DEFAULT_DEBUG = False +# pyright: reportDeprecated=false, reportPropertyTypeMismatch=false _PATTERN_CD24623C0C29CA35 = re.compile("^[A-Z]{2,4}\\Z", re.ASCII) @@ -181,9 +179,9 @@ class Address: street: str - city: str | None = None + city: typing.Optional[str] = None - zip: int | None = None + zip: typing.Optional[int] = None additional_properties: dict[str, typing.Any] = dataclasses.field( default_factory=dict @@ -561,11 +559,11 @@ class ContactPy: `$ref`, while the wire `$ref` name stays `Contact`. """ - email: str | None = None + email: typing.Optional[str] = None - shipping_street: str | None = None + shipping_street: typing.Optional[str] = None - shipping_zip: str | None = None + shipping_zip: typing.Optional[str] = None additional_properties: dict[str, typing.Any] = dataclasses.field( default_factory=dict @@ -970,9 +968,9 @@ def to_transfer_type(self, value: "Settings") -> typing.Any: class Settings: """A closed object; unknown members are rejected.""" - theme: str | None = None + theme: typing.Optional[str] = None - font_size: int | None = None + font_size: typing.Optional[int] = None class _ShowcaseTransferTypeConverter( @@ -2478,14 +2476,14 @@ def to_transfer_type(self, value: "Showcase") -> typing.Any: out["blob"] = _format_base64(value.blob) if value.url_blob is not None: out["urlBlob"] = _format_base64url(value.url_blob) - if value.retries is not None: - out["retries"] = value.retries + if value._retries is not None: + out["retries"] = value._retries if value.verbose is not None: out["verbose"] = value.verbose - if value.greeting is not None: - out["greeting"] = value.greeting - if value.debug is not None: - out["debug"] = value.debug + if value._greeting is not None: + out["greeting"] = value._greeting + if value._debug is not None: + out["debug"] = value._debug if value.legacy_id_py is not None: out["legacyId"] = value.legacy_id_py if value.middle_name is not None: @@ -2823,7 +2821,7 @@ def to_transfer_type(self, value: "Showcase") -> typing.Any: @_transfer_type_convertible(_ShowcaseTransferTypeConverter) -@dataclasses.dataclass(slots=True, kw_only=True) +@dataclasses.dataclass(slots=True, kw_only=True, init=False) class Showcase: """Showcase Root object exercising the supported JSON-Schema feature subset: required and @@ -2873,87 +2871,86 @@ class Showcase: active: bool """Required boolean scalar.""" - nickname: str | None = None + nickname: typing.Optional[str] = None """Optional short name, at most 12 code points.""" - code: str | None = None + code: typing.Optional[str] = None """Optional code, 2 to 5 code points. Counted in Unicode code points, so a multi-byte value (e.g. "a😀b", 3 code points / 6 UTF-8 bytes) is valid. """ - sku: str | None = None + sku: typing.Optional[str] = None """Optional product code: 2 to 4 uppercase ASCII letters, anchored (`^[A-Z]{2,4}$`). Exercises the RE2-safe `pattern` gate. """ - phrase: str | None = None + phrase: typing.Optional[str] = None """Optional two-word phrase separated by whitespace (`^\\S+\\s\\S+$`). Exercises the loader's `\\s`/`\\S` → ASCII-class normalization and the per-target `$` end-anchor rewrite (Python `\\Z` / Java `\\z`), so a Unicode space (NBSP) and a trailing newline are rejected consistently across all four languages. """ - request_id: str | None = None + request_id: typing.Optional[str] = None """Optional request identifier; asserted RFC 4122 UUID via `format: uuid`. Stays `string`-typed (format assertion, no materialization); the pinned regex is validated identically across all four languages. """ - contact_email: str | None = None + contact_email: typing.Optional[str] = None """Optional contact address; asserted ASCII dot-atom `format: email` (single `@`, >=2-label domain, total length <= 254, guard-before-regex). """ - host: str | None = None + host: typing.Optional[str] = None """Optional host name; asserted RFC 1123 `format: hostname` (LDH labels, total length <= 253). """ - homepage: str | None = None + homepage: typing.Optional[str] = None """Optional homepage; asserted RFC 3986 `format: uri` (scheme required, ASCII only; an IP-literal host is validated by the spliced ipv6 grammar). """ - gateway: str | None = None + gateway: typing.Optional[str] = None """Optional gateway address; asserted dotted-quad IPv4 via format ipv4.""" - blob: bytes | None = None + blob: typing.Optional[bytes] = None """Optional binary payload carried as a `contentEncoding: base64` string, materialized to native bytes (Go []byte, TS Uint8Array, Python bytes, Java byte[]). The wire is canonical padded standard base64; a malformed value is rejected by the pinned regex before decode. """ - url_blob: bytes | None = None + url_blob: typing.Optional[bytes] = None """Optional binary payload carried as a `contentEncoding: base64url` string (URL-safe alphabet, unpadded, RFC 4648 §5), materialized to the same native bytes type. The same bytes encode to a different wire than base64 ("Pj4+" vs "Pj4-"). """ - retries: int | None = None + _retries: typing.Optional[int] = dataclasses.field(default=None, repr=False) """Retry budget Optional integer with a schema default. """ - verbose: bool | None = None + verbose: typing.Optional[bool] = None - greeting: str | None = None + _greeting: typing.Optional[str] = dataclasses.field(default=None, repr=False) """Greeting Optional string with a schema default, surfaced on read. """ - debug: bool | None = None + _debug: typing.Optional[bool] = dataclasses.field(default=None, repr=False) """Debug flag Optional boolean with a schema default. """ - legacy_id_py: ( + legacy_id_py: typing.Optional[ typing.Annotated[ str, typing_extensions.deprecated("This field is deprecated.", category=None), ] - | None - ) = None + ] = None """Deprecated legacy identifier; prefer `requestId`. Exercises the native deprecation marker (Go // Deprecated:, TS @deprecated, Java @Deprecated, Python PEP 702 @deprecated). Also exercises the property-level `x-<lang>-name` override (the Stage @@ -2963,34 +2960,34 @@ class Showcase: @JsonProperty). """ - middle_name: str | None = None + middle_name: typing.Optional[str] = None """Optional and nullable; may be absent or explicitly null.""" - category: str | None + category: typing.Optional[str] """Required but nullable; may be explicitly cleared to null.""" - priority: int | None = None + priority: typing.Optional[int] = None """Optional integer bounded to the inclusive range [1, 10].""" - level: int | None = None + level: typing.Optional[int] = None """Optional integer that must be strictly greater than 0.""" - ratio: float | None = None + ratio: typing.Optional[float] = None """Optional number that must be a non-negative multiple of 5.""" - step: int | None = None + step: typing.Optional[int] = None """Optional integer that must be a multiple of 3.""" - tags: list[str] | None = None + tags: typing.Optional[list[str]] = None """Ordered list of free-form tags; 1 to 5 entries.""" - aliases: list[str] | None = None + aliases: typing.Optional[list[str]] = None """Alternate names; each must be distinct.""" - roles: list[str] | None = None + roles: typing.Optional[list[str]] = None """Access roles; must contain between one and two "admin" entries.""" - id_or_name: str | int | None = None + id_or_name: typing.Optional[str | int] = None """Disjoint-kind union (oneOf sum type): the wire value is either a string of at least 3 code points or an integer of at least 1, selected by its JSON token. Not a member of a discriminated union — the token itself is the selector. Each branch also @@ -2999,14 +2996,14 @@ class Showcase: violation. """ - mode: typing.Literal["auto", "manual"] | int | None = None + mode: typing.Optional[typing.Literal["auto", "manual"] | int] = None """A union whose string branch is a **closed value set**: either one of two named modes or an unbounded non-negative integer. The branch narrows to its own admissible values (a Go/Java membership check, a TypeScript literal union, a Python `Literal`), so an unknown string is a Violation while any non-negative integer is accepted. """ - payload: dict[str, typing.Any] | str | None = None + payload: typing.Optional[dict[str, typing.Any] | str] = None """Mixed-kind union whose object branch is an inline free-form object: the wire value is either an arbitrary object (members carried verbatim) or a string, selected by its JSON token. The free-form object is the one object branch that needs no type @@ -3014,7 +3011,7 @@ class Showcase: `<Union>Object`. """ - detail: ShowcaseDetailObject | str | None = None + detail: typing.Optional[ShowcaseDetailObject | str] = None """Mixed-kind union whose object branch is an inline *structured* object, written directly on the property rather than in `$defs`. It is the only object branch of this union, so it derives its name from the union it belongs to — @@ -3022,7 +3019,7 @@ class Showcase: own constraints and it stays open to unknown ones. """ - shape_or_name: Circle | Square | str | None = None + shape_or_name: typing.Optional[Circle | Square | str] = None """Tagged object union mixed with a scalar kind: the two selector layers compose — the JSON token picks object-vs-string, and, for an object, the shared required `kind` const picks Circle-vs-Square. Written inline on the property, so the union itself is @@ -3033,7 +3030,7 @@ class Showcase: validate through their own models. """ - measurements: list[float] | str | None = None + measurements: typing.Optional[list[float] | str] = None """Mixed-kind union with an array branch: the wire value is either a non-empty list of distinct numbers or a lowercase preset name, selected by its JSON token. An array branch has no definition to take a name from, so Go and Java emit it as the @@ -3043,72 +3040,242 @@ class Showcase: string's `pattern` — so the array-vs-string choice is validated as well as selected. """ - shapes: list[Shape] | None = None + shapes: typing.Optional[list[Shape]] = None """A list whose element type is a named union: every element is routed to exactly one branch by the union's own selector, and its index carries into the violation path (`shapes[1]`). Go and Java cannot decode a sealed interface as a whole, so the element decodes through the union's dispatcher one at a time. """ - segments: list[ShowcaseSegmentsItem] | None = None + segments: typing.Optional[list[ShowcaseSegmentsItem]] = None """A list whose element union is written **inline**. An element has no name of its own, so the union is named after its position — `ShowcaseSegmentsItem` — moved into `$defs`, and the element becomes a `$ref` at it; from there it is an ordinary named union in every language. """ - slots: list[str | None] | None = None + slots: typing.Optional[list[str | None]] = None """A list of **nullable elements** — the two-branch nullability `oneOf` rather than a sum type, so nothing is named: the elements themselves become nullable (`[]*string`, `(string | null)[]`, `list[str | None]`, `List<@Nullable String>`) while the list stays a list. """ - grid: list[list[int]] | None = None + grid: typing.Optional[list[list[int]]] = None """A nested array: `items` at depth two. Each level decodes elementwise, so a bad element is reported at its own two-dimensional index (`grid[1][0]`). """ - location: ShowcaseLocation | None = None + location: typing.Optional[ShowcaseLocation] = None - audit: ShowcaseAudit | None = None + audit: typing.Optional[ShowcaseAudit] = None """A nullable inline object. The nullability wrapper emits no type of its own, so the object inside it takes the property's name — `ShowcaseAudit`, the same name it would take written plainly: adding or removing nullability never renames the type. """ - rows: list[ShowcaseRowsItem] | None = None + rows: typing.Optional[list[ShowcaseRowsItem]] = None """A list whose element is an inline object, named after its position (`ShowcaseRowsItem`) exactly as an inline element *union* is. """ - ledger_py: ShowcaseLedger | None = None - - metadata: ShowcaseMetadata | None = None - - quotas: Quotas | None = None - - tokens: Tokens | None = None - - nicknames: Nicknames | None = None - - choices: Choices | None = None - - extras: Extras | None = None - - shape: Shape | None = None - - note: Note | None = None - - address: Address | None = None - - labels: Labels | None = None - - settings: Settings | None = None - - attributes: Attributes | None = None - - contact: ContactPy | None = None + ledger_py: typing.Optional[ShowcaseLedger] = None + + metadata: typing.Optional[ShowcaseMetadata] = None + + quotas: typing.Optional[Quotas] = None + + tokens: typing.Optional[Tokens] = None + + nicknames: typing.Optional[Nicknames] = None + + choices: typing.Optional[Choices] = None + + extras: typing.Optional[Extras] = None + + shape: typing.Optional[Shape] = None + + note: typing.Optional[Note] = None + + address: typing.Optional[Address] = None + + labels: typing.Optional[Labels] = None + + settings: typing.Optional[Settings] = None + + attributes: typing.Optional[Attributes] = None + + contact: typing.Optional[ContactPy] = None + + def __init__( + self, + *, + kind: typing.Literal["showcase"] = "showcase", + revision: typing.Literal[1] = 1, + enabled: typing.Literal[True] = True, + status: typing.Literal["active", "inactive", "pending"], + tier: typing.Literal[1, 2, 3], + scale: float, + name: str, + count: int, + active: bool, + nickname: typing.Optional[str] = None, + code: typing.Optional[str] = None, + sku: typing.Optional[str] = None, + phrase: typing.Optional[str] = None, + request_id: typing.Optional[str] = None, + contact_email: typing.Optional[str] = None, + host: typing.Optional[str] = None, + homepage: typing.Optional[str] = None, + gateway: typing.Optional[str] = None, + blob: typing.Optional[bytes] = None, + url_blob: typing.Optional[bytes] = None, + retries: typing.Optional[int] = None, + verbose: typing.Optional[bool] = None, + greeting: typing.Optional[str] = None, + debug: typing.Optional[bool] = None, + legacy_id_py: typing.Optional[ + typing.Annotated[ + str, + typing_extensions.deprecated( + "This field is deprecated.", category=None + ), + ] + ] = None, + middle_name: typing.Optional[str] = None, + category: typing.Optional[str], + priority: typing.Optional[int] = None, + level: typing.Optional[int] = None, + ratio: typing.Optional[float] = None, + step: typing.Optional[int] = None, + tags: typing.Optional[list[str]] = None, + aliases: typing.Optional[list[str]] = None, + roles: typing.Optional[list[str]] = None, + id_or_name: typing.Optional[str | int] = None, + mode: typing.Optional[typing.Literal["auto", "manual"] | int] = None, + payload: typing.Optional[dict[str, typing.Any] | str] = None, + detail: typing.Optional[ShowcaseDetailObject | str] = None, + shape_or_name: typing.Optional[Circle | Square | str] = None, + measurements: typing.Optional[list[float] | str] = None, + shapes: typing.Optional[list[Shape]] = None, + segments: typing.Optional[list[ShowcaseSegmentsItem]] = None, + slots: typing.Optional[list[str | None]] = None, + grid: typing.Optional[list[list[int]]] = None, + location: typing.Optional[ShowcaseLocation] = None, + audit: typing.Optional[ShowcaseAudit] = None, + rows: typing.Optional[list[ShowcaseRowsItem]] = None, + ledger_py: typing.Optional[ShowcaseLedger] = None, + metadata: typing.Optional[ShowcaseMetadata] = None, + quotas: typing.Optional[Quotas] = None, + tokens: typing.Optional[Tokens] = None, + nicknames: typing.Optional[Nicknames] = None, + choices: typing.Optional[Choices] = None, + extras: typing.Optional[Extras] = None, + shape: typing.Optional[Shape] = None, + note: typing.Optional[Note] = None, + address: typing.Optional[Address] = None, + labels: typing.Optional[Labels] = None, + settings: typing.Optional[Settings] = None, + attributes: typing.Optional[Attributes] = None, + contact: typing.Optional[ContactPy] = None, + _retries: typing.Optional[int] = None, + _greeting: typing.Optional[str] = None, + _debug: typing.Optional[bool] = None, + ) -> None: + self.kind = kind + self.revision = revision + self.enabled = enabled + self.status = status + self.tier = tier + self.scale = scale + self.name = name + self.count = count + self.active = active + self.nickname = nickname + self.code = code + self.sku = sku + self.phrase = phrase + self.request_id = request_id + self.contact_email = contact_email + self.host = host + self.homepage = homepage + self.gateway = gateway + self.blob = blob + self.url_blob = url_blob + self._retries = _retries if _retries is not None else retries + self.verbose = verbose + self._greeting = _greeting if _greeting is not None else greeting + self._debug = _debug if _debug is not None else debug + self.legacy_id_py = legacy_id_py + self.middle_name = middle_name + self.category = category + self.priority = priority + self.level = level + self.ratio = ratio + self.step = step + self.tags = tags + self.aliases = aliases + self.roles = roles + self.id_or_name = id_or_name + self.mode = mode + self.payload = payload + self.detail = detail + self.shape_or_name = shape_or_name + self.measurements = measurements + self.shapes = shapes + self.segments = segments + self.slots = slots + self.grid = grid + self.location = location + self.audit = audit + self.rows = rows + self.ledger_py = ledger_py + self.metadata = metadata + self.quotas = quotas + self.tokens = tokens + self.nicknames = nicknames + self.choices = choices + self.extras = extras + self.shape = shape + self.note = note + self.address = address + self.labels = labels + self.settings = settings + self.attributes = attributes + self.contact = contact + + @property + def retries(self) -> int: + """Retry budget + Optional integer with a schema default. + """ + return self._retries if self._retries is not None else 3 + + @retries.setter + def retries(self, value: typing.Optional[int]) -> None: + self._retries = value + + @property + def greeting(self) -> str: + """Greeting + Optional string with a schema default, surfaced on read. + """ + return self._greeting if self._greeting is not None else "hello" + + @greeting.setter + def greeting(self, value: typing.Optional[str]) -> None: + self._greeting = value + + @property + def debug(self) -> bool: + """Debug flag + Optional boolean with a schema default. + """ + return self._debug if self._debug is not None else False + + @debug.setter + def debug(self, value: typing.Optional[bool]) -> None: + self._debug = value class _ShowcaseAuditTransferTypeConverter( @@ -3258,7 +3425,7 @@ def to_transfer_type(self, value: "ShowcaseDetailObject") -> typing.Any: class ShowcaseDetailObject: code: str - hint: str | None = None + hint: typing.Optional[str] = None additional_properties: dict[str, typing.Any] = dataclasses.field( default_factory=dict @@ -3487,7 +3654,7 @@ class ShowcaseLocation: city: str - geo: ShowcaseLocationGeo | None = None + geo: typing.Optional[ShowcaseLocationGeo] = None additional_properties: dict[str, typing.Any] = dataclasses.field( default_factory=dict @@ -3602,9 +3769,9 @@ def to_transfer_type(self, value: "ShowcaseLocationGeo") -> typing.Any: @_transfer_type_convertible(_ShowcaseLocationGeoTransferTypeConverter) @dataclasses.dataclass(slots=True, kw_only=True) class ShowcaseLocationGeo: - lat: float | None = None + lat: typing.Optional[float] = None - lon: float | None = None + lon: typing.Optional[float] = None additional_properties: dict[str, typing.Any] = dataclasses.field( default_factory=dict @@ -4166,11 +4333,11 @@ class Widget: id: str - kind: str | None = None + kind: typing.Optional[str] = None name: str - size: int | None = None + size: typing.Optional[int] = None """Optional integer with two allOf branches tightened to [10, 20].""" additional_properties: dict[str, typing.Any] = dataclasses.field( @@ -4245,7 +4412,7 @@ class WidgetBase: id: str - kind: str | None = None + kind: typing.Optional[str] = None additional_properties: dict[str, typing.Any] = dataclasses.field( default_factory=dict diff --git a/samples/python/temporal/models.py b/samples/python/temporal/models.py index 442114a3..e75ed34e 100644 --- a/samples/python/temporal/models.py +++ b/samples/python/temporal/models.py @@ -26,6 +26,9 @@ ) +# pyright: reportDeprecated=false, reportPropertyTypeMismatch=false + + class _TemporalTransferTypeConverter( temporalio.converter.TransferTypeConverter["Temporal", typing.Any] ): @@ -291,20 +294,20 @@ class Temporal: PT90M → PT1H30M). """ - updated_at: datetime.datetime | None = None + updated_at: typing.Optional[datetime.datetime] = None """Optional date-time.""" - expires_on: datetime.date | None = None + expires_on: typing.Optional[datetime.date] = None """Optional date.""" - reminder: datetime.time | None = None + reminder: typing.Optional[datetime.time] = None """Optional time.""" - retry_delay: datetime.timedelta | None = None + retry_delay: typing.Optional[datetime.timedelta] = None """Optional duration.""" - deleted_at: datetime.datetime | None = None + deleted_at: typing.Optional[datetime.datetime] = None """Optional and nullable date-time (may be absent or explicitly null).""" - archived_on: datetime.date | None = None + archived_on: typing.Optional[datetime.date] = None """Optional and nullable date.""" diff --git a/samples/python/tests/json_converter_helper.py b/samples/python/tests/json_converter_helper.py index ebe8b53a..6267c064 100644 --- a/samples/python/tests/json_converter_helper.py +++ b/samples/python/tests/json_converter_helper.py @@ -53,11 +53,9 @@ #: is the only target that still round-trips the explicit ``null``. #: #: Nothing else belongs here. In particular a schema ``default`` is **not** an -#: entry: it is advisory, the field stays ``T | None = None`` with the value on a -#: ``DEFAULT_<FIELD>`` constant, so an unset defaulted key is omitted on the way -#: out exactly as it was absent on the way in — which is *why* the fixtures that -#: omit one (``showcase-minimal.json``, ``message-minimal.json``) round-trip -#: byte-identically, and the reason that design was chosen. +#: entry: its public property materializes the default, while a private optional +#: backing field retains presence so an unset key is omitted on the way out +#: exactly as it was absent on the way in. #: #: A path is dot-separated; a ``[]`` segment means "every element of this array". COLLAPSED_NULL_MEMBERS: dict[tuple[str, str], tuple[str, ...]] = { diff --git a/samples/python/tests/test_chat.py b/samples/python/tests/test_chat.py index f56ef4a0..f791cc35 100644 --- a/samples/python/tests/test_chat.py +++ b/samples/python/tests/test_chat.py @@ -10,7 +10,6 @@ SendMessageOutput, ) from chat._definitions import ValidationError -from chat.models import DEFAULT_PRIORITY from tests.json_converter_helper import ( canonical_json_bytes, @@ -85,15 +84,11 @@ def test_required_members_and_unknown_fields_aggregate() -> None: def test_serialize_omits_unset_defaulted_members() -> None: - # `priority` carries `default: 0`, which is **advisory**: it is not the - # dataclass field default, so an unset `priority` stays `None` and is OMITTED - # on serialize, keeping the wire byte-identical to the fixtures. The default is - # exposed as the module-level `DEFAULT_PRIORITY` constant the consumer applies, - # exactly as TypeScript does (Go uses `PriorityOrDefault()`, Java - # `@JsonInclude(NON_NULL)`). + # The property materializes `default: 0` on read, while its private backing + # field retains the unset state used by the converter. converter = converter_for(Message) unset = Message(body="hello") - assert unset.priority is None + assert unset.priority == 0 assert converter.to_transfer_type(unset) == {"kind": "text", "body": "hello"} # The byte-level form of the claim: the wire the default-bearing member produces # is exactly the wire that omits it. Byte-identity is the whole justification for @@ -111,22 +106,23 @@ def test_serialize_omits_unset_defaulted_members() -> None: assert encode_bytes(Message(body="hello", priority=7)) == canonical_json_bytes( {"kind": "text", "body": "hello", "priority": 7} ) + # Explicitly assigning the schema default still marks the property present. + unset.priority = 0 + assert converter.to_transfer_type(unset)["priority"] == 0 + # Assigning None restores the unset state without changing the read value. + unset.priority = None + assert unset.priority == 0 + assert "priority" not in converter.to_transfer_type(unset) # A `const` member, unlike a `default`, DOES carry its value as the dataclass # default — it is the only admissible value, not a suggestion. assert unset.kind == "text" -def test_default_constants_are_advisory() -> None: - # The advisory contract, stated directly: reading a defaulted member applies - # the emitted constant, and doing so changes nothing about the wire. - assert DEFAULT_PRIORITY == 0 +def test_default_property_materializes_without_changing_the_wire() -> None: message = converter_for(Message).from_transfer_type( {"kind": "text", "body": "hi"}, Message ) - assert message.priority is None - assert ( - message.priority if message.priority is not None else DEFAULT_PRIORITY - ) == DEFAULT_PRIORITY + assert message.priority == 0 assert "priority" not in converter_for(Message).to_transfer_type(message) @@ -208,10 +204,9 @@ def test_canonical_wire_fixtures_roundtrip_through_the_default_converter() -> No assert message.kind == "text" assert message.body == "hi" assert message.reply_to_id is None - # `priority` is unset on the wire, so it stays unset in memory and omitted on - # the way back out; the schema default is advisory (DEFAULT_PRIORITY). - assert message.priority is None - assert (message.priority if message.priority is not None else DEFAULT_PRIORITY) == 0 + # The public property materializes the default while the private presence + # state remains unset and omitted on the way back out. + assert message.priority == 0 full_message = typing.cast( Message, diff --git a/samples/python/tests/test_showcase.py b/samples/python/tests/test_showcase.py index 7d37c370..681b36b5 100644 --- a/samples/python/tests/test_showcase.py +++ b/samples/python/tests/test_showcase.py @@ -24,7 +24,6 @@ Widget, ) from showcase._definitions import ValidationError -from showcase.models import DEFAULT_DEBUG, DEFAULT_GREETING, DEFAULT_RETRIES from tests.json_converter_helper import ( canonical_json_bytes, @@ -127,11 +126,11 @@ def test_const_and_enum_value_sets() -> None: assert minimal.status == "active" assert minimal.tier == 1 assert minimal.scale == 1.5 - # A `default` is advisory: it is NOT the dataclass field default, so an unset - # member stays `None` and is omitted on the way back out. - assert minimal.retries is None - assert minimal.greeting is None - assert minimal.debug is None + # Public properties materialize defaults without populating the private + # presence-bearing fields, so the values still omit on the way back out. + assert minimal.retries == 3 + assert minimal.greeting == "hello" + assert minimal.debug is False assert converter_for(Showcase).to_transfer_type(minimal) == BASE # A `const` member, unlike a `default`, DOES carry its value as the dataclass @@ -246,18 +245,11 @@ def test_canonical_wire_fixtures_roundtrip_through_the_default_converter() -> No assert minimal.count == 3 assert minimal.active is True assert minimal.category == "tools" - # Scalar defaults of each kind: unset on the wire, so unset in memory and - # omitted on re-serialize (expect_showcase asserted byte-identity above). The - # consumer applies the emitted DEFAULT_<FIELD> constant on read, exactly as in - # TypeScript. - assert minimal.retries is None - assert (minimal.retries if minimal.retries is not None else DEFAULT_RETRIES) == 3 - assert minimal.greeting is None - assert ( - minimal.greeting if minimal.greeting is not None else DEFAULT_GREETING - ) == "hello" - assert minimal.debug is None - assert (minimal.debug if minimal.debug is not None else DEFAULT_DEBUG) is False + # Scalar defaults materialize through properties while their unset private + # values remain omitted on re-serialize (byte-identity asserted above). + assert minimal.retries == 3 + assert minimal.greeting == "hello" + assert minimal.debug is False full = expect_showcase("showcase-full.json") assert full.retries == 5 diff --git a/specs/json-schema/PRINCIPLES.md b/specs/json-schema/PRINCIPLES.md index 5733aa1c..cce37d45 100644 --- a/specs/json-schema/PRINCIPLES.md +++ b/specs/json-schema/PRINCIPLES.md @@ -65,7 +65,12 @@ renumber them. ## Python -1. **Models emit `@dataclasses.dataclass`es, not a validating model base (P2/P4).** Every model is `@dataclasses.dataclass(slots=True, kw_only=True)` — inert data with no methods, no runtime footprint, and **no validation on construction**. Field annotations are plain Python types (`int`, `str`, `datetime.datetime`, `datetime.timedelta`, `bytes`, `typing.Literal[...]`, `list[T]`, `dict[str, V]`), never annotated validator aliases, so the class reads like a hand-written dataclass (P2) and the runtime dependency set stays at the SDKs alone (P4). `slots=True, kw_only=True` is unconditional: JSON Schema interleaves required and optional properties freely, so positional ordering is never safe, and keyword-only construction keeps a later added optional property from reordering an existing call site (P13). Conversion and validation live *off* the model, in a companion transfer-type converter (§3) — which also means in-memory construction is unchecked here exactly as it is in Go/TS/Java, and that is what gives serialize-side validation real teeth (P12). +Python-specific P15 rule: a default-bearing property `field` synthesizes the +private `_<field>` slot in the same class namespace as declared members. A +collision rejects at load time, and `x-py-name` moves the public property and +its backing slot together. Python emits no module-level `DEFAULT_*` identifier. + +1. **Models emit `@dataclasses.dataclass`es, not a validating model base (P2/P4).** Every model is a slotted, keyword-only dataclass with **no validation on construction**. Required non-nullable property annotations are bare `T`; optional or nullable property annotations use `typing.Optional[T]`. Nested nullable values and converter/helper annotations retain `T | None`, so this spelling policy is limited to the public model surface. A model with a schema-defaulted property uses `init=False` plus a generated keyword-only `__init__`: the public constructor keyword initializes a private `_<field>: typing.Optional[T]` presence slot, while a mutable same-name property returns either that raw value or the scalar schema default. Its setter accepts `typing.Optional[T]`, and assigning `None` restores the unset state. The private slot is excluded from `repr` but remains a dataclass comparison field, preserving raw presence/value equality. Open-model constructors initialize an omitted `additional_properties` to a fresh dictionary. Other models use `@dataclasses.dataclass(slots=True, kw_only=True)` directly. Field annotations remain plain Python types (`int`, `str`, `datetime.datetime`, `datetime.timedelta`, `bytes`, `typing.Literal[...]`, `list[T]`, `dict[str, V]`), never annotated validator aliases, so the class reads like hand-written Python (P2) and the runtime dependency set stays at the SDKs alone (P4). Conversion and validation live *off* the model, in a companion transfer-type converter (§3), which gives serialize-side validation real teeth (P12). 2. **Aggregate via a single generated `ValidationError` (extends `Exception`) holding `list[Violation]` (P11).** Collect every `Violation { path, reason }` — a `@dataclasses.dataclass(frozen=True, slots=True)` in the shared `definitions` module — into one list and raise **one** generated `ValidationError`, whose `str()` enumerates every violation and whose `violations` attribute exposes them structured. This is the same primitive Go, TypeScript and Java surface, with the same structured `{path, reason}` shape — so all four targets now report a rejection the same way (P11), and the cross-language guarantee is the accepted-and-rejected value set (P1), not byte-identical message text. Python's `reason` strings mirror **TypeScript's** verbatim, Python being the same design. It is never stringly-typed and never a language-native exception group. A nested value's `ValidationError` is re-pathed under the enclosing field by a `_collect(violations, path, error)` runtime helper and merged into the parent's list, so one payload yields one flat, fully-pathed violation set — the analogue of TypeScript's `collect`. 3. **A companion `_<Model>TransferTypeConverter` converts model ⇄ intermediate and validates; the *default* Temporal converter finds it through the SDK's transfer-type hook (P12/P3).** Each model gets a private converter class — `class _UserTransferTypeConverter(temporalio.converter.TransferTypeConverter["User", typing.Any])` with `from_transfer_type(value: typing.Any, type_hint: type[User]) -> User` as the parse adapter (untrusted JSON value → model) and `to_transfer_type(value: User) -> typing.Any` as the encode adapter (model → plain JSON value) — attached to the class by `@_transfer_type_convertible(_UserTransferTypeConverter)`, the runtime module's one-line shim over `temporalio.converter.transfer_type_convertible` that erases the converter's value-type parameter (binding it on the decorated class is circular for a static type checker: the class's type depends on the decorator, whose value type depends on the class). Both directions run the same emitted checks, collecting `Violation`s into one `ValidationError` (§2), so validation lives *inside* the conversion — that is what makes them the single source of truth in both directions. The intermediate is a plain `dict`/`list`/scalar, never a `str`: the byte-level JSON encode/decode is the Temporal payload converter's boundary, which hands the transfer-type converter the parsed (or about-to-be-encoded) value. Working in intermediate values (not strings) is also what makes conversions **composable** — a parent's `to_transfer_type` calls its children's on nested values and embeds the results, `from_transfer_type` likewise; a `str` could not nest. That composition is load-bearing rather than stylistic: the SDK hooks only the **top-level** value, so a nested model is always converted by its parent's body. Registration is the whole of the wiring — the stock `DataConverter.default` consults the hook, so generated models need no contrib package and no user setup (P3). A `typing.TypeAlias` cannot be decorated, so a named or inline `oneOf` union is served by module-private free functions (`_<name>_from_transfer_type` / `_<name>_to_transfer_type`) instead of a converter class; unions can only appear nested, so nothing is lost. The per-field omit-vs-`null` decision follows the [[nullability]] serialize table. See [[nullability]], [[const]], [[default]]. @@ -81,4 +86,3 @@ renumber them. ## Go 1. **Every exported declaration carries a name-led doc comment (P2, `golint`/godoc convention).** Every exported type, struct field, function, method, var, and const the generator emits — schema-derived or generator-owned runtime alike — gets a `//` doc comment whose opening line leads with the identifier itself, never a bare, unattributed sentence; this is stricter than TypeScript/Python/Java, which have no equivalent per-declaration mandate. For a schema-derived type or field, [[title]]/[[description]] supply the text (see [[description]]'s Doc-comment assembly for the name-led rule in both the title-present and title-absent cases); when neither is authored, the generator falls back to a short, honest, name-led line synthesized from information already at hand — the `$defs`/property name, the JSON member name, a union's admissible members — rather than leaving the declaration undocumented. For the fixed runtime the generator owns outright (`Violation`, `ValidationError`, `Validate`/`UnmarshalJSON`/`MarshalJSON`, the closed-value/`oneOf` synthesized types and their constants, service/operation client bindings), the doc text is one hand-authored, name-led sentence per declaration kind, reused verbatim everywhere that kind is emitted. Unexported identifiers (the parse/format helpers, compiled regex vars) are unaffected — godoc does not require them, and the terser, hand-written-feeling output stays terse. - diff --git a/specs/json-schema/features/default.md b/specs/json-schema/features/default.md index c4b2803a..bf459ce8 100644 --- a/specs/json-schema/features/default.md +++ b/specs/json-schema/features/default.md @@ -7,9 +7,8 @@ Supplies a fallback value for an absent member. In the spec it is a pure **annotation** — it never affects validation pass/fail. We give it the **off-the-wire, materialized-on-read** operational semantics: set-ness tracked, omit-unset on serialize (no deep-equals), materialized **on read** via a -generated `<Field>OrDefault()` accessor in Go, a native getter in Java, and -a generated `DEFAULT_<FIELD>` constant the consumer applies in TypeScript -and Python. +generated `<Field>OrDefault()` accessor in Go, native getters in Java and +Python, and a generated `DEFAULT_<FIELD>` constant in TypeScript. ## Spec summary @@ -118,14 +117,14 @@ Loader behavior: **None of its own.** `default` does not change the emitted type — the type comes from [[type]] + [[nullability]], and `default` implies the member is **optional**, so it takes the optional form (`*T` / `x?: T` / -`T | None = None` / boxed-or-`@Nullable`). The default value never appears +`typing.Optional[T]` / boxed-or-`@Nullable`). The default value never appears in the field itself in any target. What `default` *does* add is the **read-side surfacing mechanism** and the generated default value itself, which differ per language: | Language | Set-ness signal (omit-unset) | Read-side surfacing of the default | |---|---|---| -| Python | `None` (the `T \| None = None` field) | **advisory** — a dataclass carries no methods (PRINCIPLES Python §1), so the consumer applies the default with `x if x is not None else DEFAULT_X`; the generator emits a module-level `DEFAULT_X = "anon"`. No accessor needed. | +| Python | private `_<field>: typing.Optional[T]` | **native property** — `@property def field(self) -> T` returns the private value when set and the scalar default otherwise. A setter accepts `typing.Optional[T]`; assigning `None` restores unset. Models with defaults receive a generated keyword-only constructor so `Model(field=...)` remains the public construction API. | | Java | `null` field + `@JsonInclude(NON_NULL)` | **native** — the generated **getter** returns the default when the backing field is `null` (`return nickname != null ? nickname : "anon";`). Getters already exist in the POJO design (PRINCIPLES Java §1). | | TypeScript | `undefined` (the `?` field) | **advisory** — interfaces have no methods (PRINCIPLES TS §2), so the consumer applies the default with the native `?? DEFAULT_X`; the generator emits `export const DEFAULT_X = "anon"`. No accessor needed; `??` is the idiom. | | Go | `*T` `nil` + `,omitempty` | **generated accessor** — a `func (m M) <Field>OrDefault() T` returns `*m.Field` when set and the default literal when `nil` (`func (u User) NicknameOrDefault() string { if u.Nickname != nil { return *u.Nickname }; return "anon" }`). The bare field stays `*T` (set-ness intact); the accessor is the materialize-on-read path. Emitted **only** for default-bearing fields. Modeled on proto3's `GetX()` — the same omit-default-on-wire + accessor-materializes-default pattern already familiar to Temporal users. Named `<Field>OrDefault` rather than `Get<Field>` to read as "the value, or its default" and to avoid implying a getter on every field. Alternative approaches considered: (a) advisory constant (`DEFAULT_X` + caller nil-checks) — pushes nil-checks to every call site; (b) populate on deserialize — destroys set-ness, forces deep-equals, breaks P9. | @@ -139,14 +138,13 @@ The read-side surfacing synthesizes **one new identifier in three targets** |---|---|---|---| | Go | `<Field>OrDefault()` method | struct method-set | a **declared** member whose name maps to `<Field>OrDefault` (Go forbids a field and method of the same name — a **hard compile error**); another `<Field>OrDefault` from a sibling field | | TypeScript | `DEFAULT_<FIELD>` const | module | another `DEFAULT_<FIELD>` from a field that case-maps the same. [[const]] synthesizes no named *type* in TS (the type closes to an inline literal) but does emit a module-scope `<FIELD>_CONST` binding holding the wire value, which shares this scope — unexported, yet still a redeclaration error if it coincides | -| Python | `DEFAULT_<FIELD>` const | module | another `DEFAULT_<FIELD>` from a field that case-maps the same ([[const]] synthesizes no Python identifier — the value is an inline `Literal`) | +| Python | `_<field>` backing slot | class/member | a declared member overridden to that private identifier; another backing slot after member mapping | | Java | none (default folds into the existing getter) | — | — | -The constant is named `DEFAULT_<FIELD>`, or **`DEFAULT_<MODEL>_<FIELD>`** -when that member identifier is not unique across the module's models — the -same qualification rule in TypeScript and Python, since both put the constant -in module scope. Qualification separates two *models*; two members of **one** -model that shout alike are a collision it cannot resolve, and rejects. +The TypeScript constant is named `DEFAULT_<FIELD>`, or +**`DEFAULT_<MODEL>_<FIELD>`** when that member identifier is not unique across +the module's models. Python emits no `DEFAULT_*` binding: its private backing +slot is exactly the emitted member identifier prefixed with `_`. Per **P15** these participate in the single per-scope collision pass and **reject at load** on any coincidence — never auto-mangled (a @@ -154,8 +152,8 @@ Per **P15** these participate in the single per-scope collision pass and Java adds no name, so it carries no default-specific collision. The rename **escape hatch** is the [[properties]] case-mapping override (`x-go-name`, …) on the *declaring* field — re-mapping it moves the -synthesized `<Field>OrDefault` / `DEFAULT_<FIELD>` names with it, because -both are named off the **emitted** member identifier rather than the JSON +synthesized `<Field>OrDefault` / `DEFAULT_<FIELD>` / `_<field>` names with it, +because all are named off the **emitted** member identifier rather than the JSON key (`retryCount` + `x-ts-name: attempts` → `DEFAULT_ATTEMPTS`). The derivation has to work that way for the hatch to open at all: two members that recase alike collide on `DEFAULT_<FIELD>`, and an override that moved @@ -164,11 +162,10 @@ would reject with a fix-it the author cannot act on — the only remaining escape being a rename of the JSON property, i.e. a change to the wire contract (P15, P7.1). -Java materializes-on-read for free (the getter); Go does so via the -generated `<Field>OrDefault()` accessor. TypeScript and Python have no -method on the model (interfaces, PRINCIPLES TS §2; inert dataclasses, -PRINCIPLES Python §1), so both lean on a generated constant the consumer -applies — an idiomatic stand-in for the same thing. In every language the +Java materializes-on-read through its getter; Go does so via the generated +`<Field>OrDefault()` accessor, and Python through a generated property. +TypeScript interfaces have no methods, so TypeScript alone leans on a generated +constant the consumer applies. In every language the **bare field still carries set-ness** (`nil` / `undefined` / `None` / `null`); the default is layered on read, never written back into the field, so omit-on-serialize stays faithful. @@ -201,7 +198,7 @@ default. Mechanisms: |---|---| | Go | `*T` with `,omitempty` → `nil` omitted by the stdlib encoder via the type-alias `MarshalJSON`. Pointer-to-zero-value still emits, so set-ness ≡ pointer-presence. | | TypeScript | `toTransferType` skips keys whose value is `undefined` when building the transfer value (PRINCIPLES TS §4). | -| Python | `to_transfer_type` skips a key whose attribute is `None` when building the intermediate dict, exactly as it does for any other optional member. | +| Python | `to_transfer_type` reads the private backing slot and skips the key when that slot is `None`; it never reads the materializing public property. | | Java | `@JsonInclude(NON_NULL)` — `null` (unset) omitted; getter still returns the default to the consumer. | Three consequences that the count specs already encode: @@ -236,7 +233,7 @@ Three consequences that the count specs already encode: | **Array default (deferred)** | `{type:"array", items:{type:"string"}, default:["a"]}` | | `default: null` (degenerate) | `{oneOf:[{type:"string"},{type:"null"}], default:null}` | | With `const` | `{type:"string", const:"v1", default:"v1"}` | -| Synthesized-name collision (P15) | a field `nickname` with a `default` **and** a sibling member mapping to `NicknameOrDefault` (Go field/method clash); two `DEFAULT_<FIELD>` consts that case-map the same after qualification (TS / Python) | +| Synthesized-name collision (P15) | a field `nickname` with a `default` **and** a sibling member mapping to `NicknameOrDefault` (Go field/method clash); two `DEFAULT_<FIELD>` consts that case-map the same after qualification (TS); a Python sibling explicitly renamed to `_nickname` (private-backing clash) | ### Runtime fixtures (validator / adapters) @@ -303,4 +300,4 @@ with those features — see Loader behavior.) - [[type]] — the default value must be valid for the declared type. - [[properties]] — hosts the member subschema and the set-ness machinery, and owns the case-mapping + collision/escape-hatch policy that governs - the synthesized `<Field>OrDefault` / `DEFAULT_<FIELD>` names (P15). + the synthesized `<Field>OrDefault` / `DEFAULT_<FIELD>` / `_<field>` names (P15). diff --git a/specs/json-schema/features/properties.md b/specs/json-schema/features/properties.md index 6156a1bf..5cc1d891 100644 --- a/specs/json-schema/features/properties.md +++ b/specs/json-schema/features/properties.md @@ -78,7 +78,7 @@ from [[type]]; optional/nullable wrapping from [[required]] + | Aspect | Go | TypeScript | Python | Java | |---|---|---|---|---| | Aggregate | `struct` | `interface` (**not class**) | `@dataclasses.dataclass(slots=True, kw_only=True)` (**not a validating base**) | POJO `class` (Java 8; **not records**) | -| Member | struct field | interface member | dataclass field | private field + getter | +| Member | struct field | interface member | dataclass field; default-bearing property over private storage | private field + getter | | JSON-name binding | `json:"<name>"` tag | exact key (index access) | the wire key, read and written by the converter | `@JsonProperty("<name>")` | Field naming: JSON member names are mapped to each language's idiomatic @@ -163,16 +163,17 @@ collisions are evaluated only for languages being generated. The check is not limited to declared members. The generator also synthesizes identifiers from member/type names — [[const]]'s named type (Go defined type / Java value class), the Go `<Field>OrDefault()` -accessor and TS `DEFAULT_<FIELD>` constant ([[default]]), the [[enum]] +accessor, TS `DEFAULT_<FIELD>` constant, and Python `_<field>` default +backing slot ([[default]]), the [[enum]] value class — and these enter the **same per-scope namespace** as the declared names and each other (package/module scope for package-level -types/consts; the struct method-set for the Go accessor, where Go -forbids a field/method clash outright). The single collision pass runs +types/consts; the struct method-set for the Go accessor; the Python class +scope for the backing slot). The single collision pass runs over that full union and rejects on any coincidence; the `x-*-name` override (Stage 4) on the declaring member is the escape hatch for these, and re-mapping the member moves every name synthesized *from the member* -with it — the Go `<Field>OrDefault()` accessor and TS `DEFAULT_<FIELD>` -constant ([[default]]), the Go closed-value type and Java value class +with it — the Go `<Field>OrDefault()` accessor, TS `DEFAULT_<FIELD>` +constant, Python `_<field>` backing slot ([[default]]), the Go closed-value type and Java value class ([[const]]) are all named off the **emitted** member identifier, not the JSON key, so the override reaches them. A name synthesized from a **position** rather than a member does not move: an inline object hoisted diff --git a/specs/json-schema/nullability.md b/specs/json-schema/nullability.md index e4f32586..39c2758a 100644 --- a/specs/json-schema/nullability.md +++ b/specs/json-schema/nullability.md @@ -133,30 +133,29 @@ nullability convention (`x?: T | null` is the optional+nullable form). ### Python -Optional fields widen to `T | None` and default to `None`; required -fields carry the bare type and no default. Emitted as a plain -`@dataclasses.dataclass(slots=True, kw_only=True)` (see PRINCIPLES -Python §1), so the fields below are the whole class — every field is -keyword-only, which is why a bare-typed field may follow a defaulted -one: +Optional or nullable model properties use `typing.Optional[T]`; required +non-nullable properties carry bare `T`. Nested nullable values and the +converter/helper annotations keep their `T | None` spelling. Every public +constructor argument is keyword-only (see PRINCIPLES Python §1): ```python from __future__ import annotations import dataclasses +import typing @dataclasses.dataclass(slots=True, kw_only=True) class User: id: int # required - nickname: int | None = None # optional — None if absent + nickname: typing.Optional[int] = None # optional — None if absent name: str # required - email: str | None = None # optional + email: typing.Optional[str] = None # optional ``` | `type` token | required | optional | |---|---|---| -| any | `T` | `T \| None` (with `= None` default) | +| any | `T` | `typing.Optional[T]` (with `= None` default) | Absence is `None`. The dataclass itself neither coerces nor checks anything: the only path from wire to field is the model's transfer-type @@ -233,12 +232,12 @@ modifier, Python's `= None` default). | `type` token | Java | Go | TypeScript | Python | |---|---|---|---|---| -| `"integer"` | `@Nullable Long` | `*int64` | `x?: number \| null` | `x: int \| None = None` | -| `"number"` | `@Nullable Double` | `*float64` | `x?: number \| null` | `x: float \| None = None` | -| `"boolean"` | `@Nullable Boolean` | `*bool` | `x?: boolean \| null` | `x: bool \| None = None` | -| `"string"` | `@Nullable String` | `*string` | `x?: string \| null` | `x: str \| None = None` | -| `"object"` | `@Nullable T` | `*T` | `x?: T \| null` | `x: T \| None = None` | -| `"array"` | `@Nullable List<T>` | `[]T` (nil = absent or null) | `x?: T[] \| null` | `x: list[T] \| None = None` | +| `"integer"` | `@Nullable Long` | `*int64` | `x?: number \| null` | `x: typing.Optional[int] = None` | +| `"number"` | `@Nullable Double` | `*float64` | `x?: number \| null` | `x: typing.Optional[float] = None` | +| `"boolean"` | `@Nullable Boolean` | `*bool` | `x?: boolean \| null` | `x: typing.Optional[bool] = None` | +| `"string"` | `@Nullable String` | `*string` | `x?: string \| null` | `x: typing.Optional[str] = None` | +| `"object"` | `@Nullable T` | `*T` | `x?: T \| null` | `x: typing.Optional[T] = None` | +| `"array"` | `@Nullable List<T>` | `[]T` (nil = absent or null) | `x?: T[] \| null` | `x: typing.Optional[list[T]] = None` | **Required + nullable** (`null` OK, T OK, absent rejected) — same type, presence enforced by the validator; TS drops the `?`, Python drops the @@ -247,12 +246,12 @@ construction): | `type` token | Java | Go | TypeScript | Python | |---|---|---|---|---| -| `"integer"` | `@Nullable Long` | `*int64` | `x: number \| null` | `x: int \| None` | -| `"number"` | `@Nullable Double` | `*float64` | `x: number \| null` | `x: float \| None` | -| `"boolean"` | `@Nullable Boolean` | `*bool` | `x: boolean \| null` | `x: bool \| None` | -| `"string"` | `@Nullable String` | `*string` | `x: string \| null` | `x: str \| None` | -| `"object"` | `@Nullable T` | `*T` | `x: T \| null` | `x: T \| None` | -| `"array"` | `@Nullable List<T>` | `[]T` (nil = null) | `x: T[] \| null` | `x: list[T] \| None` | +| `"integer"` | `@Nullable Long` | `*int64` | `x: number \| null` | `x: typing.Optional[int]` | +| `"number"` | `@Nullable Double` | `*float64` | `x: number \| null` | `x: typing.Optional[float]` | +| `"boolean"` | `@Nullable Boolean` | `*bool` | `x: boolean \| null` | `x: typing.Optional[bool]` | +| `"string"` | `@Nullable String` | `*string` | `x: string \| null` | `x: typing.Optional[str]` | +| `"object"` | `@Nullable T` | `*T` | `x: T \| null` | `x: typing.Optional[T]` | +| `"array"` | `@Nullable List<T>` | `[]T` (nil = null) | `x: T[] \| null` | `x: typing.Optional[list[T]]` | (Java is `@Nullable` across every nullable column — the annotation tracks in-memory nullness, not the wire distinction; see the optionality @@ -317,9 +316,9 @@ absent) and **null acceptance** (non-nullable = reject `null`; nullable | State | Java | Go | TS | Python | |---|---|---|---|---| | **Required, non-nullable** — must be present, must be T | type is `long`/`String`/etc.; emit `field == null` reject + type binding | type is `int64`/`string`/etc.; shadow `*T` field, reject on `nil` | type is `x: T`; emit `parsed.x === undefined \|\| parsed.x === null` reject | type is `x: T` with no default; converter rejects an absent key **and** a `null` token with `required` | -| **Optional, non-nullable** — absent OK, T OK, explicit `null` rejected | strict-variant custom deserializer (see strategy below) | shadow `*json.RawMessage` with explicit `bytes.Equal(*raw, []byte("null"))` reject | `parsed.x === null` rejected; `=== undefined` OK | type is `x: T \| None = None`; converter branch over the raw dict rejects a key present with `None` (see strategy below) | -| **Optional + nullable** — absent OK, `null` OK, T OK | type is `@Nullable Long`/`String`/etc.; no extra check beyond type binding | type is `*int64`/`*string`/etc.; no extra check beyond type binding | type is `x?: T \| null`; both `undefined` and `null` accepted | type is `x: T \| None = None`; both absent and `null` accepted, no extra check | -| **Required + nullable** — must be present, `null` OK, T OK, absent rejected | base (non-strict) deserializer accepts `null`; presence enforced (`field`-present check / required-field machinery) | shadow `*json.RawMessage`; reject on absent (`nil` shadow), accept `null` token | type is `x: T \| null`; emit `parsed.x === undefined` reject; `null` accepted | type is `x: T \| None` with **no** default; converter rejects an absent key, accepts the `null` token as `None` | +| **Optional, non-nullable** — absent OK, T OK, explicit `null` rejected | strict-variant custom deserializer (see strategy below) | shadow `*json.RawMessage` with explicit `bytes.Equal(*raw, []byte("null"))` reject | `parsed.x === null` rejected; `=== undefined` OK | type is `x: typing.Optional[T] = None`; converter branch over the raw dict rejects a key present with `None` (see strategy below) | +| **Optional + nullable** — absent OK, `null` OK, T OK | type is `@Nullable Long`/`String`/etc.; no extra check beyond type binding | type is `*int64`/`*string`/etc.; no extra check beyond type binding | type is `x?: T \| null`; both `undefined` and `null` accepted | type is `x: typing.Optional[T] = None`; both absent and `null` accepted, no extra check | +| **Required + nullable** — must be present, `null` OK, T OK, absent rejected | base (non-strict) deserializer accepts `null`; presence enforced (`field`-present check / required-field machinery) | shadow `*json.RawMessage`; reject on absent (`nil` shadow), accept `null` token | type is `x: T \| null`; emit `parsed.x === undefined` reject; `null` accepted | type is `x: typing.Optional[T]` with **no** default; converter rejects an absent key, accepts the `null` token as `None` | ## Serialize-side behavior diff --git a/src/generator/json_schema/python.rs b/src/generator/json_schema/python.rs index 62b44829..e4336edb 100644 --- a/src/generator/json_schema/python.rs +++ b/src/generator/json_schema/python.rs @@ -167,8 +167,8 @@ pub(crate) fn converter_class_name(model_name: &str) -> String { /// /// The `_value` suffix makes that structurally impossible instead of /// blocklisting names: no fixed local, builtin, imported module, or synthesized -/// module-level identifier (`DEFAULT_<FIELD>`, `_PATTERN_<HEX>`, -/// `_<MODEL>_DECLARED`, `_<base>_{from,to}_transfer_type`, +/// module-level identifier (`_PATTERN_<HEX>`, `_<MODEL>_DECLARED`, +/// `_<base>_{from,to}_transfer_type`, /// `_<Model>TransferTypeConverter`) ends in `_value`. It stays collision-free /// *within* the property family too: every temporary this position needs appends /// a further suffix (`_raw`, `_parsed`, `_list`, `_index`, `_element`, `_item`, @@ -640,11 +640,14 @@ pub(in crate::generator) fn render_external_models( set_module_context(json_models)?; - let mut body = String::new(); - // Module-level constants first: the advisory `DEFAULT_<FIELD>` values, the - // shared compiled `pattern`/`format` regexes, and the declared-key sets an - // open object splits its catch-all on. - render_default_constants(&mut body, json_models)?; + // `typing.Optional` is the intentional public spelling for JSON Schema + // properties, and a default property's setter deliberately accepts `None` + // even though its getter materializes a non-optional value. Keep generated + // modules quiet under basedpyright without weakening any other diagnostic. + let mut body = + String::from("# pyright: reportDeprecated=false, reportPropertyTypeMismatch=false\n"); + // Module-level constants first: the shared compiled `pattern`/`format` + // regexes and the declared-key sets an open object splits its catch-all on. render_pattern_regexes(&mut body, json_models)?; for model in &class_models { let schema = decode_schema(model)?; @@ -2091,85 +2094,6 @@ fn python_content_encoding_format_fn( } } -// --------------------------------------------------------------------------- -// Module-level constants -// --------------------------------------------------------------------------- - -/// Emits the advisory `DEFAULT_<FIELD>` constants. A schema `default` is not the -/// dataclass field default — the member is encoded like any other optional one so -/// the wire stays byte-identical — and the consumer applies the constant on read -/// (`x if x is not None else DEFAULT_X`), exactly as in TypeScript. See -/// `specs/json-schema/features/default.md`. -fn render_default_constants(output: &mut String, models: &[&PlannedJsonType]) -> Result<()> { - let mut constants = Vec::new(); - for model in models { - let schema = decode_schema(model)?; - let Some(properties) = &schema.properties else { - continue; - }; - for (json_name, property) in properties { - let Some(default) = &property.default else { - continue; - }; - constants.push(( - default_const_name( - &model.model_name, - &property.py_member_name(json_name), - models, - )?, - python_value_literal(default)?, - )); - } - } - if constants.is_empty() { - return Ok(()); - } - push_section(output); - for (name, value) in constants { - output.push_str(&name); - output.push_str(" = "); - output.push_str(&value); - output.push('\n'); - } - Ok(()) -} - -/// `DEFAULT_<FIELD>` when exactly one model in the module declares a defaulted -/// member emitting that identifier, else `DEFAULT_<MODEL>_<FIELD>`. The name is -/// built from the **emitted member identifier**, as TypeScript's is, so an -/// `x-py-name` override on the declaring property moves the constant with it — a -/// name synthesized *from the member* follows the member (P15). The loader -/// replicates this rule to reserve the name in the module namespace, so the two -/// must stay in step. -fn default_const_name( - model_name: &str, - member_ident: &str, - models: &[&PlannedJsonType], -) -> Result<String> { - let field_count = models - .iter() - .map(|model| decode_schema(model)) - .collect::<Result<Vec<_>>>()? - .into_iter() - .filter(|schema| { - schema.properties.as_ref().is_some_and(|properties| { - properties.iter().any(|(json_name, property)| { - property.py_member_name(json_name) == member_ident && property.default.is_some() - }) - }) - }) - .count(); - Ok(if field_count == 1 { - format!("DEFAULT_{}", member_ident.to_shouty_snake_case()) - } else { - format!( - "DEFAULT_{}_{}", - model_name.to_shouty_snake_case(), - member_ident.to_shouty_snake_case() - ) - }) -} - /// Emits one compiled regex per distinct `pattern` / `format` source across the /// module's schemas, so a check reads a shared pre-compiled object rather than /// recompiling per call. `re.ASCII` pins the character classes the loader @@ -3116,9 +3040,20 @@ fn render_model_dataclass( "@_transfer_type_convertible({})\n", converter_class_name(&model.model_name) )); + let has_defaults = schema.properties.as_ref().is_some_and(|properties| { + properties + .values() + .any(|property| property.default.is_some()) + }); // Every member is keyword-only: JSON Schema interleaves required and - // optional properties freely, so a positional order is never safe. - output.push_str("@dataclasses.dataclass(slots=True, kw_only=True)\n"); + // optional properties freely, so a positional order is never safe. Models + // with schema defaults need a handwritten initializer so the public keyword + // can initialize its private presence-bearing slot. + if has_defaults { + output.push_str("@dataclasses.dataclass(slots=True, kw_only=True, init=False)\n"); + } else { + output.push_str("@dataclasses.dataclass(slots=True, kw_only=True)\n"); + } output.push_str("class "); output.push_str(&model.model_name); output.push_str(":\n"); @@ -3146,26 +3081,37 @@ fn render_model_dataclass( "typing.Annotated[{member_type}, typing_extensions.deprecated(\"This field is deprecated.\", category=None)]" ); } + let storage_name = if property.default.is_some() { + format!("_{field_name}") + } else { + field_name.clone() + }; output.push_str(" "); - output.push_str(&field_name); + output.push_str(&storage_name); output.push_str(": "); - if let Some(const_value) = &property.const_value { + if property.default.is_some() { + output.push_str(&model_optional_annotation(&member_type)); + output.push_str(" = dataclasses.field(default=None, repr=False)"); + } else if let Some(const_value) = &property.const_value { // The only admissible value, so it is the field's default — a - // schema `default`, being a suggestion, is not (see - // `render_default_constants`). - output.push_str(&member_type); + // schema `default`, being a suggestion, is not. + if required.contains(json_name) { + output.push_str(&member_type); + } else { + output.push_str(&model_optional_annotation(&member_type)); + } output.push_str(" = "); output.push_str(&python_value_literal(const_value)?); } else if required.contains(json_name) { // Required and nullable keeps the `| None` (an explicit null is // the value) but takes no default: the member must be supplied. if allows_null(property) { - output.push_str(&optional_annotation(&member_type)); + output.push_str(&model_optional_annotation(&member_type)); } else { output.push_str(&member_type); } } else { - output.push_str(&optional_annotation(&member_type)); + output.push_str(&model_optional_annotation(&member_type)); output.push_str(" = None"); } output.push('\n'); @@ -3189,12 +3135,146 @@ fn render_model_dataclass( catch_all_annotation(schema)? )); } + if has_defaults { + render_model_init(output, schema)?; + render_default_properties(output, schema)?; + } if members == 0 { output.push_str("\n pass\n"); } Ok(()) } +/// Emits the public keyword-only constructor for a dataclass whose defaulted +/// properties are backed by private presence-bearing fields. +fn render_model_init(output: &mut String, schema: &Schema) -> Result<()> { + let required = required_fields(schema); + output.push_str("\n def __init__(\n self,\n *,\n"); + if let Some(properties) = &schema.properties { + for (json_name, property) in properties { + let field_name = property.py_member_name(json_name); + let mut member_type = annotation(property)?; + if property.deprecated == Some(true) { + member_type = format!( + "typing.Annotated[{member_type}, typing_extensions.deprecated(\"This field is deprecated.\", category=None)]" + ); + } + output.push_str(" "); + output.push_str(&field_name); + output.push_str(": "); + if property.default.is_some() || !required.contains(json_name) || allows_null(property) + { + output.push_str(&model_optional_annotation(&member_type)); + } else { + output.push_str(&member_type); + } + if let Some(const_value) = &property.const_value { + output.push_str(" = "); + output.push_str(&python_value_literal(const_value)?); + } else if !required.contains(json_name) { + output.push_str(" = None"); + } + output.push_str(",\n"); + } + } + if is_python_map_model(schema) || is_open_object(schema) { + output.push_str(&format!( + " additional_properties: dict[str, {}] | None = None,\n", + catch_all_annotation(schema)? + )); + } + // `dataclasses.replace()` reconstructs from declared dataclass field names, + // which are the private backing names here. Accept those private keywords so + // replacing an unrelated field preserves raw default presence/value state. + if let Some(properties) = &schema.properties { + for (json_name, property) in properties { + if property.default.is_none() { + continue; + } + let field_name = property.py_member_name(json_name); + let mut member_type = annotation(property)?; + if property.deprecated == Some(true) { + member_type = format!( + "typing.Annotated[{member_type}, typing_extensions.deprecated(\"This field is deprecated.\", category=None)]" + ); + } + output.push_str(&format!( + " _{field_name}: {} = None,\n", + model_optional_annotation(&member_type) + )); + } + } + output.push_str(" ) -> None:\n"); + let mut assignments = 0usize; + if let Some(properties) = &schema.properties { + for (json_name, property) in properties { + assignments += 1; + let field_name = property.py_member_name(json_name); + let target = if property.default.is_some() { + format!("_{field_name}") + } else { + field_name.clone() + }; + if property.default.is_some() { + output.push_str(&format!( + " self.{target} = _{field_name} if _{field_name} is not None else {field_name}\n" + )); + } else { + output.push_str(&format!(" self.{target} = {field_name}\n")); + } + } + } + if is_python_map_model(schema) || is_open_object(schema) { + assignments += 1; + output.push_str( + " self.additional_properties = (\n {} if additional_properties is None else additional_properties\n )\n", + ); + } + if assignments == 0 { + output.push_str(" pass\n"); + } + Ok(()) +} + +fn render_default_properties(output: &mut String, schema: &Schema) -> Result<()> { + let Some(properties) = &schema.properties else { + return Ok(()); + }; + for (json_name, property) in properties { + let Some(default) = &property.default else { + continue; + }; + let field_name = property.py_member_name(json_name); + let mut member_type = annotation(nullable_member_schema(property).unwrap_or(property))?; + if property.deprecated == Some(true) { + member_type = format!( + "typing.Annotated[{member_type}, typing_extensions.deprecated(\"This field is deprecated.\", category=None)]" + ); + } + output.push_str(&format!( + "\n @property\n def {field_name}(self) -> {member_type}:\n" + )); + render_python_docstring( + output, + " ", + compose_python_doc(property.title.as_deref(), property.description.as_deref()) + .as_deref(), + &[], + None, + false, + ); + output.push_str(&format!( + " return self._{field_name} if self._{field_name} is not None else {}\n", + python_value_literal(default)? + )); + output.push_str(&format!( + "\n @{field_name}.setter\n def {field_name}(self, value: {}) -> None:\n self._{field_name} = value\n", + model_optional_annotation(&member_type) + )); + } + Ok(()) +} + /// Emits the private `TransferTypeConverter` a model's whole wire contract lives /// in. `transfer_type` is left at its inherited `None`, which is what makes the /// inner payload converter hand us the raw `json.loads` value. @@ -3398,7 +3478,11 @@ fn render_model_serializer_body( if let Some(properties) = &schema.properties { for (json_name, property) in properties { let field_name = property.py_member_name(json_name); - let value_expr = format!("value.{field_name}"); + let value_expr = if property.default.is_some() { + format!("value._{field_name}") + } else { + format!("value.{field_name}") + }; let key = python_string_literal(json_name); let target = format!("out[{key}]"); let path_expr = python_string_literal(json_name); @@ -4839,6 +4923,17 @@ fn optional_annotation(annotation: &str) -> String { } } +/// Model-property syntax uses `typing.Optional[T]`; converter/helper annotations +/// deliberately retain their existing `T | None` spelling. +fn model_optional_annotation(annotation: &str) -> String { + let members = split_top_level_union(annotation) + .into_iter() + .filter(|member| *member != "None") + .collect::<Vec<_>>(); + let inner = members.join(" | "); + format!("typing.Optional[{inner}]") +} + /// True when the annotation itself already admits `None` — a `None` member of /// the *top-level* union. A nested one does not count: in `list[str | None]` /// the elements are nullable while the list is not, so an optional field of diff --git a/src/parser/json_schema.rs b/src/parser/json_schema.rs index 938b2ba0..e22a075d 100644 --- a/src/parser/json_schema.rs +++ b/src/parser/json_schema.rs @@ -5878,9 +5878,10 @@ pub(crate) fn build_name_manifest( service.origin_label(), )?; } - // The Python and TypeScript `DEFAULT_<FIELD>` constants share the module - // scope; make them participate rather than silently coexist (P15). - if matches!(language, Language::Python | Language::TypeScript) { + // TypeScript `DEFAULT_<FIELD>` constants share the module scope; make + // them participate rather than silently coexist (P15). Python surfaces + // defaults through properties and emits no module-level constant. + if language == Language::TypeScript { collect_default_constants(language, module_key, &ns_models, &mut top)?; } // TypeScript additionally emits `<FIELD>_CONST` bindings and a per-model @@ -6058,9 +6059,9 @@ fn collect_synthesized_top_level( } /// Per-model member-scope collision checks (one scope per aggregate): two -/// members that recase/override to the same identifier collide; and in Go the -/// synthesized `<Field>OrDefault()` accessor shares the struct method-set, so a -/// field/accessor clash is a hard compile error. +/// members that recase/override to the same identifier collide. Synthesized +/// member-scope names participate too: Go's `<Field>OrDefault()` method and +/// Python's private `_<field>` storage for a default-bearing property. fn validate_member_scope(language: Language, model_full_name: &str, schema: &Schema) -> Result<()> { let Some(properties) = &schema.properties else { return Ok(()); @@ -6099,6 +6100,25 @@ fn validate_member_scope(language: Language, model_full_name: &str, schema: &Sch format!("`{model_full_name}` additional-properties catch-all"), )?; } + // A Python default-bearing property stores presence in `_<field>`. The + // backing slot and every declared member occupy the same class namespace; + // `x-py-name` moves both the public property and its backing name. + if language == Language::Python { + for (json_name, property) in properties { + let Some(default) = property.extra.get("default") else { + continue; + }; + if default.is_null() || default.is_object() || default.is_array() { + continue; + } + let member = member_identifier(language, json_name, property); + scope.insert( + language, + format!("_{member}"), + format!("`{model_full_name}.{json_name}` default backing field"), + )?; + } + } // Go `<Field>OrDefault()` accessor (scalar `default` on an optional member). if language == Language::Go { let required: BTreeSet<&str> = schema @@ -6131,8 +6151,8 @@ fn validate_member_scope(language: Language, model_full_name: &str, schema: &Sch Ok(()) } -/// Python and TypeScript `DEFAULT_<FIELD>` constants (module scope). Both -/// generators name a default constant `DEFAULT_<FIELD>` when the member is unique +/// TypeScript `DEFAULT_<FIELD>` constants (module scope). The generator names a +/// default constant `DEFAULT_<FIELD>` when the member is unique /// across the module's models, else `DEFAULT_<MODEL>_<FIELD>`. Replicate that name /// and enter it into the shared module namespace so a genuine clash rejects (P15) /// rather than silently coexisting behind the model-name prefix. @@ -6262,8 +6282,8 @@ fn collect_ts_const_constants( } /// The remaining module-scope identifiers the Python JSON-Schema generator -/// synthesizes, entered into the same namespace as the user types, services, and -/// `DEFAULT_<FIELD>` constants so a coincidence rejects at load instead of one +/// synthesizes, entered into the same namespace as the user types and services +/// so a coincidence rejects at load instead of one /// definition silently overwriting the other (P15). /// /// Each is named by [`build_name_manifest`]'s resolved `type_ident`, so a @@ -9866,16 +9886,13 @@ properties: } #[test] - fn rejects_colliding_default_constants_python_and_typescript() { - // Python and TypeScript hoist a defaulted member's value to a module-level + fn rejects_colliding_default_constants_typescript() { + // TypeScript hoists a defaulted member's value to a module-level // `DEFAULT_<FIELD>` constant, named off the **emitted** member identifier. // Two members that stay distinct as identifiers (`fooBar` / `foo_bar`, held // apart by their overrides) still shout to one `DEFAULT_FOO_BAR`, and the // model-name qualification cannot separate two members of one model. - for (language, override_key) in [ - (Language::Python, "x-py-name"), - (Language::TypeScript, "x-ts-name"), - ] { + for (language, override_key) in [(Language::TypeScript, "x-ts-name")] { let input = format!( r##" $schema: https://json-schema.org/draft/2020-12/schema @@ -9930,10 +9947,38 @@ $defs: properties: foo_bar: { type: string, default: "y" } "##; - for language in [Language::Python, Language::TypeScript] { - parse_for(language, across_models) - .expect("`DEFAULT_<MODEL>_<FIELD>` keeps the two apart"); - } + parse_for(Language::TypeScript, across_models) + .expect("`DEFAULT_<MODEL>_<FIELD>` keeps the two apart"); + parse_for(Language::Python, across_models) + .expect("Python emits properties rather than DEFAULT_ constants"); + } + + #[test] + fn rejects_python_default_backing_field_collision() { + let colliding = r#" +$schema: https://json-schema.org/draft/2020-12/schema +type: object +additionalProperties: false +properties: + greeting: { type: string, default: hello } + raw: { type: string, x-py-name: _greeting } +"#; + let error = reject_for(Language::Python, colliding); + assert!( + error.contains("collision") && error.contains("_greeting"), + "{error}" + ); + + let resolved = r#" +$schema: https://json-schema.org/draft/2020-12/schema +type: object +additionalProperties: false +properties: + greeting: { type: string, default: hello, x-py-name: salutation } + raw: { type: string, x-py-name: _greeting } +"#; + parse_for(Language::Python, resolved) + .expect("x-py-name moves the property and its private backing field"); } #[test] diff --git a/tests/generate_python.rs b/tests/generate_python.rs index f1a18563..0208f407 100644 --- a/tests/generate_python.rs +++ b/tests/generate_python.rs @@ -277,6 +277,72 @@ else: raise AssertionError("an invalid model was serialized: validation was disabled") "#; +const PYTHON_DATACLASS_DEFAULT_SCHEMA: &str = r#"$schema: https://json-schema.org/draft/2020-12/schema +type: object +required: [requiredPlain, requiredNullable] +properties: + requiredPlain: { type: string } + requiredNullable: + oneOf: [{ type: integer }, { type: "null" }] + optionalPlain: { type: boolean } + optionalNullable: + oneOf: [{ type: string }, { type: "null" }] + nullableItems: + type: array + items: + oneOf: [{ type: string }, { type: "null" }] + greeting: + type: string + default: hello + deprecated: true + x-py-name: salutation +"#; + +const PYTHON_DATACLASS_DEFAULT_RUNTIME_CHECK: &str = r#" +import sys + +root, package = sys.argv[1], sys.argv[2] +sys.path.insert(0, root) +models = __import__(package + ".models", fromlist=["*"]) + +Model = models.Model +converter = getattr(Model, "__temporal_transfer_type_converter") + +try: + Model(required_plain="x") +except TypeError: + pass +else: + raise AssertionError("required nullable constructor argument became optional") + +unset = Model(required_plain="x", required_nullable=None) +other = Model(required_plain="x", required_nullable=None) +assert unset.salutation == "hello" +assert converter.to_transfer_type(unset) == { + "requiredPlain": "x", + "requiredNullable": None, +} +assert unset.additional_properties == {} +assert other.additional_properties == {} +assert unset.additional_properties is not other.additional_properties +assert unset == other +assert "_salutation" not in repr(unset) + +explicit_default = Model( + required_plain="x", required_nullable=None, salutation="hello" +) +assert converter.to_transfer_type(explicit_default)["greeting"] == "hello" +assert explicit_default != unset + +unset.salutation = "bye" +assert unset.salutation == "bye" +assert converter.to_transfer_type(unset)["greeting"] == "bye" +unset.salutation = None +assert unset.salutation == "hello" +assert "greeting" not in converter.to_transfer_type(unset) +assert unset == other +"#; + fn project_root() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")) } @@ -600,15 +666,14 @@ fn python_json_example_generation_matches_checked_in_output() { assert_eq!(rendered, expected, "snapshot mismatch for {example_id}"); if example_id == "showcase" { let all = rendered.values().cloned().collect::<Vec<_>>().join("\n"); - // A scalar `default` is advisory: the member is encoded like any other - // optional one (so an unset key stays omitted and the wire stays - // byte-identical) and the default rides on a `DEFAULT_<FIELD>` - // constant the consumer applies, as in TypeScript. - assert!(all.contains("greeting: str | None = None")); - assert!(all.contains("debug: bool | None = None")); - assert!(all.contains("DEFAULT_GREETING")); - assert!(all.contains("DEFAULT_DEBUG")); - assert!(all.contains("DEFAULT_RETRIES")); + // A default-bearing property materializes on read while its private + // optional storage retains unset state for wire omission. + assert!(all.contains("_greeting: typing.Optional[str]")); + assert!(all.contains("def greeting(self) -> str:")); + assert!(all.contains("def greeting(self, value: typing.Optional[str])")); + assert!(!all.contains("DEFAULT_GREETING")); + assert!(!all.contains("DEFAULT_DEBUG")); + assert!(!all.contains("DEFAULT_RETRIES")); // `deprecated` → PEP 702 marker (no runtime warning); `title` → docstring. assert!(all.contains( "typing_extensions.deprecated(\"This field is deprecated.\", category=None)" @@ -620,7 +685,7 @@ fn python_json_example_generation_matches_checked_in_output() { assert!(all.contains("\"legacyId\"")); // A free-form object inlines as a mapping as a union branch, and as a // named model with an explicit `additional_properties` catch-all. - assert!(all.contains("payload: dict[str, typing.Any] | str | None")); + assert!(all.contains("payload: typing.Optional[dict[str, typing.Any] | str]")); assert!(all.contains("class Extras:")); assert!( all.contains("additional_properties: dict[str, typing.Any] = dataclasses.field(") @@ -651,7 +716,7 @@ fn python_json_example_generation_matches_checked_in_output() { // The lone inline object branch of a property union derives its name // from the union it belongs to. assert!(all.contains("class ShowcaseDetailObject:")); - assert!(all.contains("detail: ShowcaseDetailObject | str | None")); + assert!(all.contains("detail: typing.Optional[ShowcaseDetailObject | str]")); assert!(all.contains("must have at most 4 properties")); } fs::remove_dir_all(output_path).unwrap(); @@ -1156,7 +1221,7 @@ fn python_json_names_inline_object_union_branch() { .unwrap(); let rendered = fs::read_to_string(output_path.join("models.py")).unwrap(); - assert!(rendered.contains("payload: DetailPayloadObject | str | None")); + assert!(rendered.contains("payload: typing.Optional[DetailPayloadObject | str]")); assert!(rendered.contains("class DetailPayloadObject:")); assert!(rendered.contains("class _DetailPayloadObjectTransferTypeConverter(")); assert!(rendered.contains("text: str")); @@ -1195,10 +1260,12 @@ fn python_json_validates_non_object_union_branch_constraints() { // The string branch's `minLength`/`pattern` and the integer branch's // `minimum` leave no residue on the annotation: it is the plain branch union. - assert!(rendered.contains("value: str | int | None")); + assert!(rendered.contains("value: typing.Optional[str | int]")); // Same for the array branch's `minItems`/`uniqueItems`; a closed value set // still narrows to a `typing.Literal`. - assert!(rendered.contains("list[float] | typing.Literal[\"auto\", \"manual\"] | None")); + assert!( + rendered.contains("typing.Optional[list[float] | typing.Literal[\"auto\", \"manual\"]]") + ); // The branch checks themselves live in the converter body: a `pattern` lowers // to a `.search` against a module-level compiled regex const, `uniqueItems` to // a runtime helper imported from the definitions module. @@ -1304,9 +1371,9 @@ fn python_json_annotates_element_position_unions() { let rendered = fs::read_to_string(output_path.join("models.py")).unwrap(); assert!(rendered.contains("BagSegmentsItem: typing.TypeAlias = str | int")); - assert!(rendered.contains("segments: list[BagSegmentsItem] | None")); - assert!(rendered.contains("choices: list[Choice] | None")); - assert!(rendered.contains("slots: list[str | None] | None")); + assert!(rendered.contains("segments: typing.Optional[list[BagSegmentsItem]]")); + assert!(rendered.contains("choices: typing.Optional[list[Choice]]")); + assert!(rendered.contains("slots: typing.Optional[list[str | None]]")); let exports = fs::read_to_string(output_path.join("__init__.py")).unwrap(); assert!(exports.contains("BagSegmentsItem")); fs::remove_dir_all(temp_dir).unwrap(); @@ -1406,7 +1473,7 @@ fn python_json_cross_module_py_name_override_moves_every_reference() { let models = fs::read_to_string(output_path.join("kb/models.py")).unwrap(); for expected in [ "from ..content.page.models import RenamedPage", - " page: RenamedPage | None", + " page: typing.Optional[RenamedPage]", ] { assert!(models.contains(expected), "{expected}\n{models}"); } @@ -1634,3 +1701,48 @@ fn python_json_property_names_never_shadow_converter_locals() { ); fs::remove_dir_all(temp_dir).unwrap(); } + +#[test] +fn python_json_model_properties_use_optional_and_defaults_preserve_presence() { + let temp_dir = unique_output_path("py-json-dataclass-default"); + fs::create_dir_all(&temp_dir).unwrap(); + let input_path = temp_dir.join("model.yaml"); + fs::write(&input_path, PYTHON_DATACLASS_DEFAULT_SCHEMA).unwrap(); + let output_path = temp_dir.join("default_package"); + + generate_to_file(&GenerateRequest { + language: nexgen::language::Language::Python, + input_paths: vec![input_path], + support_paths: Vec::new(), + descriptor_paths: Vec::new(), + output_path: output_path.clone(), + format: false, + generate_native_api: false, + java_package_name: None, + ts_date_time_types: Default::default(), + }) + .unwrap(); + let rendered = fs::read_to_string(output_path.join("models.py")).unwrap(); + + assert!(rendered.contains("required_plain: str")); + assert!(rendered.contains("required_nullable: typing.Optional[int]")); + assert!(rendered.contains("optional_plain: typing.Optional[bool] = None")); + assert!(rendered.contains("optional_nullable: typing.Optional[str] = None")); + assert!(rendered.contains("nullable_items: typing.Optional[list[str | None]] = None")); + assert!(rendered.contains( + "_salutation: typing.Optional[typing.Annotated[str, typing_extensions.deprecated" + )); + assert!(rendered.contains("def salutation(self) -> typing.Annotated[str,")); + assert!(rendered.contains("value: typing.Optional[typing.Annotated[")); + assert!(rendered.contains("if value._salutation is not None:")); + assert!(!rendered.contains("DEFAULT_SALUTATION")); + // Converter/helper annotations intentionally retain the compact union style. + assert!(rendered.contains("optional_plain_value: bool | None = None")); + assert!(rendered.contains("nullable_items_value: list[str | None] | None = None")); + + assert_python_script_succeeds( + PYTHON_DATACLASS_DEFAULT_RUNTIME_CHECK, + &[temp_dir.to_str().unwrap(), "default_package"], + ); + fs::remove_dir_all(temp_dir).unwrap(); +} From 1dda9609fb90ae9d5a0fb29e88957e96cf63d97a Mon Sep 17 00:00:00 2001 From: Roey Berman <roey.berman@gmail.com> Date: Sun, 16 Aug 2026 14:56:54 -0700 Subject: [PATCH 14/20] Python: keep union syntax for optional properties --- CHANGELOG.md | 5 +- .../python/json_schema/api/chat/models.py | 21 +- .../python/json_schema/api/kb/_recursive.py | 11 +- .../api/kb/content/block/models.py | 7 +- .../json_schema/api/kb/content/page/models.py | 5 +- .../python/json_schema/api/kb/kb/models.py | 3 - .../api/kb/tree/category/models.py | 5 +- .../python/json_schema/api/showcase/models.py | 265 +++++++++--------- .../python/json_schema/api/temporal/models.py | 15 +- samples/python/chat/models.py | 21 +- samples/python/kb/_recursive.py | 11 +- samples/python/kb/content/block/models.py | 7 +- samples/python/kb/content/page/models.py | 5 +- samples/python/kb/kb/models.py | 3 - samples/python/kb/tree/category/models.py | 5 +- samples/python/showcase/models.py | 265 +++++++++--------- samples/python/temporal/models.py | 15 +- specs/json-schema/PRINCIPLES.md | 2 +- specs/json-schema/features/default.md | 4 +- specs/json-schema/nullability.md | 44 ++- src/generator/json_schema/python.rs | 34 +-- tests/generate_python.rs | 48 ++-- 22 files changed, 366 insertions(+), 435 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c09979c..25292931 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,9 +42,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- Python: JSON Schema model properties that are optional or nullable now use - `typing.Optional[T]` on the public dataclass surface. Default-bearing properties - now expose mutable same-name properties backed by private optional fields: reads +- Python: JSON Schema default-bearing properties now expose mutable same-name + properties backed by private `T | None` fields: reads materialize the schema default, while converters preserve unset state and omit it from the wire. The public keyword constructor remains compatible, explicit values (including the default itself) remain present on the wire, assigning `None` resets diff --git a/advanced/samples/python/json_schema/api/chat/models.py b/advanced/samples/python/json_schema/api/chat/models.py index 07088aa6..5687120e 100644 --- a/advanced/samples/python/json_schema/api/chat/models.py +++ b/advanced/samples/python/json_schema/api/chat/models.py @@ -16,9 +16,6 @@ ) -# pyright: reportDeprecated=false, reportPropertyTypeMismatch=false - - _ROOM_DECLARED: frozenset[str] = frozenset( {"roomId", "displayName", "topic", "members", "labels"} ) @@ -228,10 +225,10 @@ class Message: body: str - reply_to_id: typing.Optional[str] = None + reply_to_id: str | None = None """Id of the message this replies to, if any.""" - _priority: typing.Optional[int] = dataclasses.field(default=None, repr=False) + _priority: int | None = dataclasses.field(default=None, repr=False) """Delivery priority.""" def __init__( @@ -239,9 +236,9 @@ def __init__( *, kind: typing.Literal["text"] = "text", body: str, - reply_to_id: typing.Optional[str] = None, - priority: typing.Optional[int] = None, - _priority: typing.Optional[int] = None, + reply_to_id: str | None = None, + priority: int | None = None, + _priority: int | None = None, ) -> None: self.kind = kind self.body = body @@ -254,7 +251,7 @@ def priority(self) -> int: return self._priority if self._priority is not None else 0 @priority.setter - def priority(self, value: typing.Optional[int]) -> None: + def priority(self, value: int | None) -> None: # pyright: ignore[reportPropertyTypeMismatch] self._priority = value @@ -396,12 +393,12 @@ class Room: display_name: str - topic: typing.Optional[str] + topic: str | None """Room topic; may be explicitly cleared to null.""" - members: typing.Optional[list[str]] = None + members: list[str] | None = None - labels: typing.Optional[Labels] = None + labels: Labels | None = None additional_properties: dict[str, typing.Any] = dataclasses.field( default_factory=dict diff --git a/advanced/samples/python/json_schema/api/kb/_recursive.py b/advanced/samples/python/json_schema/api/kb/_recursive.py index f9db85ec..0d5b9f43 100644 --- a/advanced/samples/python/json_schema/api/kb/_recursive.py +++ b/advanced/samples/python/json_schema/api/kb/_recursive.py @@ -20,9 +20,6 @@ from .content.page.models import PageMeta -# pyright: reportDeprecated=false, reportPropertyTypeMismatch=false - - class _BlockTransferTypeConverter( temporalio.converter.TransferTypeConverter["Block", typing.Any] ): @@ -166,11 +163,11 @@ class Block: integer field. """ - text: typing.Optional[str] = None + text: str | None = None - style: typing.Optional[BlockStyle] = None + style: BlockStyle | None = None - page: typing.Optional[Page] = None + page: Page | None = None """Optional back-reference to the containing page - closes the Page <-> Block cycle. Optional + nullable, so this edge terminates. """ @@ -299,7 +296,7 @@ class Page: meta: PageMeta - blocks: typing.Optional[list[Block]] = None + blocks: list[Block] | None = None """Ordered content blocks. Cross-file `$ref` to block.json (same directory); the array is the terminating edge of the cycle. """ diff --git a/advanced/samples/python/json_schema/api/kb/content/block/models.py b/advanced/samples/python/json_schema/api/kb/content/block/models.py index 4e90412b..5d368d93 100644 --- a/advanced/samples/python/json_schema/api/kb/content/block/models.py +++ b/advanced/samples/python/json_schema/api/kb/content/block/models.py @@ -15,9 +15,6 @@ ) -# pyright: reportDeprecated=false, reportPropertyTypeMismatch=false - - class _BlockStyleTransferTypeConverter( temporalio.converter.TransferTypeConverter["BlockStyle", typing.Any] ): @@ -96,6 +93,6 @@ def to_transfer_type(self, value: "BlockStyle") -> typing.Any: class BlockStyle: """Non-cyclic helper; stays in the content_block module. All members optional.""" - bold: typing.Optional[bool] = None + bold: bool | None = None - indent: typing.Optional[int] = None + indent: int | None = None diff --git a/advanced/samples/python/json_schema/api/kb/content/page/models.py b/advanced/samples/python/json_schema/api/kb/content/page/models.py index 22e21a83..5719f97f 100644 --- a/advanced/samples/python/json_schema/api/kb/content/page/models.py +++ b/advanced/samples/python/json_schema/api/kb/content/page/models.py @@ -15,9 +15,6 @@ ) -# pyright: reportDeprecated=false, reportPropertyTypeMismatch=false - - class _PageMetaTransferTypeConverter( temporalio.converter.TransferTypeConverter["PageMeta", typing.Any] ): @@ -82,4 +79,4 @@ class PageMeta: author: str - word_count: typing.Optional[int] = None + word_count: int | None = None diff --git a/advanced/samples/python/json_schema/api/kb/kb/models.py b/advanced/samples/python/json_schema/api/kb/kb/models.py index 112dabe4..3a3a2b70 100644 --- a/advanced/samples/python/json_schema/api/kb/kb/models.py +++ b/advanced/samples/python/json_schema/api/kb/kb/models.py @@ -15,9 +15,6 @@ ) -# pyright: reportDeprecated=false, reportPropertyTypeMismatch=false - - class _GetCategoryTreeInputTransferTypeConverter( temporalio.converter.TransferTypeConverter["GetCategoryTreeInput", typing.Any] ): diff --git a/advanced/samples/python/json_schema/api/kb/tree/category/models.py b/advanced/samples/python/json_schema/api/kb/tree/category/models.py index 9bd9dbe5..56213f62 100644 --- a/advanced/samples/python/json_schema/api/kb/tree/category/models.py +++ b/advanced/samples/python/json_schema/api/kb/tree/category/models.py @@ -15,9 +15,6 @@ ) -# pyright: reportDeprecated=false, reportPropertyTypeMismatch=false - - class _CategoryTransferTypeConverter( temporalio.converter.TransferTypeConverter["Category", typing.Any] ): @@ -125,7 +122,7 @@ class Category: name: str - children: typing.Optional[list[Category]] = None + children: list[Category] | None = None """Sub-categories. A within-file self-cycle via `$ref: '#'`; the possibly-empty array is the terminating edge, so it stays in this module. """ diff --git a/advanced/samples/python/json_schema/api/showcase/models.py b/advanced/samples/python/json_schema/api/showcase/models.py index 1e07cf9c..1f309e97 100644 --- a/advanced/samples/python/json_schema/api/showcase/models.py +++ b/advanced/samples/python/json_schema/api/showcase/models.py @@ -25,9 +25,6 @@ ) -# pyright: reportDeprecated=false, reportPropertyTypeMismatch=false - - _PATTERN_CD24623C0C29CA35 = re.compile("^[A-Z]{2,4}\\Z", re.ASCII) _PATTERN_B4BA2CA20EB1B963 = re.compile( "^[^\\t\\n\\x0B\\f\\r ]+[\\t\\n\\x0B\\f\\r ][^\\t\\n\\x0B\\f\\r ]+\\Z", re.ASCII @@ -179,9 +176,9 @@ class Address: street: str - city: typing.Optional[str] = None + city: str | None = None - zip: typing.Optional[int] = None + zip: int | None = None additional_properties: dict[str, typing.Any] = dataclasses.field( default_factory=dict @@ -559,11 +556,11 @@ class ContactPy: `$ref`, while the wire `$ref` name stays `Contact`. """ - email: typing.Optional[str] = None + email: str | None = None - shipping_street: typing.Optional[str] = None + shipping_street: str | None = None - shipping_zip: typing.Optional[str] = None + shipping_zip: str | None = None additional_properties: dict[str, typing.Any] = dataclasses.field( default_factory=dict @@ -968,9 +965,9 @@ def to_transfer_type(self, value: "Settings") -> typing.Any: class Settings: """A closed object; unknown members are rejected.""" - theme: typing.Optional[str] = None + theme: str | None = None - font_size: typing.Optional[int] = None + font_size: int | None = None class _ShowcaseTransferTypeConverter( @@ -2871,86 +2868,87 @@ class Showcase: active: bool """Required boolean scalar.""" - nickname: typing.Optional[str] = None + nickname: str | None = None """Optional short name, at most 12 code points.""" - code: typing.Optional[str] = None + code: str | None = None """Optional code, 2 to 5 code points. Counted in Unicode code points, so a multi-byte value (e.g. "a😀b", 3 code points / 6 UTF-8 bytes) is valid. """ - sku: typing.Optional[str] = None + sku: str | None = None """Optional product code: 2 to 4 uppercase ASCII letters, anchored (`^[A-Z]{2,4}$`). Exercises the RE2-safe `pattern` gate. """ - phrase: typing.Optional[str] = None + phrase: str | None = None """Optional two-word phrase separated by whitespace (`^\\S+\\s\\S+$`). Exercises the loader's `\\s`/`\\S` → ASCII-class normalization and the per-target `$` end-anchor rewrite (Python `\\Z` / Java `\\z`), so a Unicode space (NBSP) and a trailing newline are rejected consistently across all four languages. """ - request_id: typing.Optional[str] = None + request_id: str | None = None """Optional request identifier; asserted RFC 4122 UUID via `format: uuid`. Stays `string`-typed (format assertion, no materialization); the pinned regex is validated identically across all four languages. """ - contact_email: typing.Optional[str] = None + contact_email: str | None = None """Optional contact address; asserted ASCII dot-atom `format: email` (single `@`, >=2-label domain, total length <= 254, guard-before-regex). """ - host: typing.Optional[str] = None + host: str | None = None """Optional host name; asserted RFC 1123 `format: hostname` (LDH labels, total length <= 253). """ - homepage: typing.Optional[str] = None + homepage: str | None = None """Optional homepage; asserted RFC 3986 `format: uri` (scheme required, ASCII only; an IP-literal host is validated by the spliced ipv6 grammar). """ - gateway: typing.Optional[str] = None + gateway: str | None = None """Optional gateway address; asserted dotted-quad IPv4 via format ipv4.""" - blob: typing.Optional[bytes] = None + blob: bytes | None = None """Optional binary payload carried as a `contentEncoding: base64` string, materialized to native bytes (Go []byte, TS Uint8Array, Python bytes, Java byte[]). The wire is canonical padded standard base64; a malformed value is rejected by the pinned regex before decode. """ - url_blob: typing.Optional[bytes] = None + url_blob: bytes | None = None """Optional binary payload carried as a `contentEncoding: base64url` string (URL-safe alphabet, unpadded, RFC 4648 §5), materialized to the same native bytes type. The same bytes encode to a different wire than base64 ("Pj4+" vs "Pj4-"). """ - _retries: typing.Optional[int] = dataclasses.field(default=None, repr=False) + _retries: int | None = dataclasses.field(default=None, repr=False) """Retry budget Optional integer with a schema default. """ - verbose: typing.Optional[bool] = None + verbose: bool | None = None - _greeting: typing.Optional[str] = dataclasses.field(default=None, repr=False) + _greeting: str | None = dataclasses.field(default=None, repr=False) """Greeting Optional string with a schema default, surfaced on read. """ - _debug: typing.Optional[bool] = dataclasses.field(default=None, repr=False) + _debug: bool | None = dataclasses.field(default=None, repr=False) """Debug flag Optional boolean with a schema default. """ - legacy_id_py: typing.Optional[ + legacy_id_py: ( typing.Annotated[ str, typing_extensions.deprecated("This field is deprecated.", category=None), ] - ] = None + | None + ) = None """Deprecated legacy identifier; prefer `requestId`. Exercises the native deprecation marker (Go // Deprecated:, TS @deprecated, Java @Deprecated, Python PEP 702 @deprecated). Also exercises the property-level `x-<lang>-name` override (the Stage @@ -2960,34 +2958,34 @@ class Showcase: @JsonProperty). """ - middle_name: typing.Optional[str] = None + middle_name: str | None = None """Optional and nullable; may be absent or explicitly null.""" - category: typing.Optional[str] + category: str | None """Required but nullable; may be explicitly cleared to null.""" - priority: typing.Optional[int] = None + priority: int | None = None """Optional integer bounded to the inclusive range [1, 10].""" - level: typing.Optional[int] = None + level: int | None = None """Optional integer that must be strictly greater than 0.""" - ratio: typing.Optional[float] = None + ratio: float | None = None """Optional number that must be a non-negative multiple of 5.""" - step: typing.Optional[int] = None + step: int | None = None """Optional integer that must be a multiple of 3.""" - tags: typing.Optional[list[str]] = None + tags: list[str] | None = None """Ordered list of free-form tags; 1 to 5 entries.""" - aliases: typing.Optional[list[str]] = None + aliases: list[str] | None = None """Alternate names; each must be distinct.""" - roles: typing.Optional[list[str]] = None + roles: list[str] | None = None """Access roles; must contain between one and two "admin" entries.""" - id_or_name: typing.Optional[str | int] = None + id_or_name: str | int | None = None """Disjoint-kind union (oneOf sum type): the wire value is either a string of at least 3 code points or an integer of at least 1, selected by its JSON token. Not a member of a discriminated union — the token itself is the selector. Each branch also @@ -2996,14 +2994,14 @@ class Showcase: violation. """ - mode: typing.Optional[typing.Literal["auto", "manual"] | int] = None + mode: typing.Literal["auto", "manual"] | int | None = None """A union whose string branch is a **closed value set**: either one of two named modes or an unbounded non-negative integer. The branch narrows to its own admissible values (a Go/Java membership check, a TypeScript literal union, a Python `Literal`), so an unknown string is a Violation while any non-negative integer is accepted. """ - payload: typing.Optional[dict[str, typing.Any] | str] = None + payload: dict[str, typing.Any] | str | None = None """Mixed-kind union whose object branch is an inline free-form object: the wire value is either an arbitrary object (members carried verbatim) or a string, selected by its JSON token. The free-form object is the one object branch that needs no type @@ -3011,7 +3009,7 @@ class Showcase: `<Union>Object`. """ - detail: typing.Optional[ShowcaseDetailObject | str] = None + detail: ShowcaseDetailObject | str | None = None """Mixed-kind union whose object branch is an inline *structured* object, written directly on the property rather than in `$defs`. It is the only object branch of this union, so it derives its name from the union it belongs to — @@ -3019,7 +3017,7 @@ class Showcase: own constraints and it stays open to unknown ones. """ - shape_or_name: typing.Optional[Circle | Square | str] = None + shape_or_name: Circle | Square | str | None = None """Tagged object union mixed with a scalar kind: the two selector layers compose — the JSON token picks object-vs-string, and, for an object, the shared required `kind` const picks Circle-vs-Square. Written inline on the property, so the union itself is @@ -3030,7 +3028,7 @@ class Showcase: validate through their own models. """ - measurements: typing.Optional[list[float] | str] = None + measurements: list[float] | str | None = None """Mixed-kind union with an array branch: the wire value is either a non-empty list of distinct numbers or a lowercase preset name, selected by its JSON token. An array branch has no definition to take a name from, so Go and Java emit it as the @@ -3040,72 +3038,72 @@ class Showcase: string's `pattern` — so the array-vs-string choice is validated as well as selected. """ - shapes: typing.Optional[list[Shape]] = None + shapes: list[Shape] | None = None """A list whose element type is a named union: every element is routed to exactly one branch by the union's own selector, and its index carries into the violation path (`shapes[1]`). Go and Java cannot decode a sealed interface as a whole, so the element decodes through the union's dispatcher one at a time. """ - segments: typing.Optional[list[ShowcaseSegmentsItem]] = None + segments: list[ShowcaseSegmentsItem] | None = None """A list whose element union is written **inline**. An element has no name of its own, so the union is named after its position — `ShowcaseSegmentsItem` — moved into `$defs`, and the element becomes a `$ref` at it; from there it is an ordinary named union in every language. """ - slots: typing.Optional[list[str | None]] = None + slots: list[str | None] | None = None """A list of **nullable elements** — the two-branch nullability `oneOf` rather than a sum type, so nothing is named: the elements themselves become nullable (`[]*string`, `(string | null)[]`, `list[str | None]`, `List<@Nullable String>`) while the list stays a list. """ - grid: typing.Optional[list[list[int]]] = None + grid: list[list[int]] | None = None """A nested array: `items` at depth two. Each level decodes elementwise, so a bad element is reported at its own two-dimensional index (`grid[1][0]`). """ - location: typing.Optional[ShowcaseLocation] = None + location: ShowcaseLocation | None = None - audit: typing.Optional[ShowcaseAudit] = None + audit: ShowcaseAudit | None = None """A nullable inline object. The nullability wrapper emits no type of its own, so the object inside it takes the property's name — `ShowcaseAudit`, the same name it would take written plainly: adding or removing nullability never renames the type. """ - rows: typing.Optional[list[ShowcaseRowsItem]] = None + rows: list[ShowcaseRowsItem] | None = None """A list whose element is an inline object, named after its position (`ShowcaseRowsItem`) exactly as an inline element *union* is. """ - ledger_py: typing.Optional[ShowcaseLedger] = None + ledger_py: ShowcaseLedger | None = None - metadata: typing.Optional[ShowcaseMetadata] = None + metadata: ShowcaseMetadata | None = None - quotas: typing.Optional[Quotas] = None + quotas: Quotas | None = None - tokens: typing.Optional[Tokens] = None + tokens: Tokens | None = None - nicknames: typing.Optional[Nicknames] = None + nicknames: Nicknames | None = None - choices: typing.Optional[Choices] = None + choices: Choices | None = None - extras: typing.Optional[Extras] = None + extras: Extras | None = None - shape: typing.Optional[Shape] = None + shape: Shape | None = None - note: typing.Optional[Note] = None + note: Note | None = None - address: typing.Optional[Address] = None + address: Address | None = None - labels: typing.Optional[Labels] = None + labels: Labels | None = None - settings: typing.Optional[Settings] = None + settings: Settings | None = None - attributes: typing.Optional[Attributes] = None + attributes: Attributes | None = None - contact: typing.Optional[ContactPy] = None + contact: ContactPy | None = None def __init__( self, @@ -3119,68 +3117,65 @@ def __init__( name: str, count: int, active: bool, - nickname: typing.Optional[str] = None, - code: typing.Optional[str] = None, - sku: typing.Optional[str] = None, - phrase: typing.Optional[str] = None, - request_id: typing.Optional[str] = None, - contact_email: typing.Optional[str] = None, - host: typing.Optional[str] = None, - homepage: typing.Optional[str] = None, - gateway: typing.Optional[str] = None, - blob: typing.Optional[bytes] = None, - url_blob: typing.Optional[bytes] = None, - retries: typing.Optional[int] = None, - verbose: typing.Optional[bool] = None, - greeting: typing.Optional[str] = None, - debug: typing.Optional[bool] = None, - legacy_id_py: typing.Optional[ - typing.Annotated[ - str, - typing_extensions.deprecated( - "This field is deprecated.", category=None - ), - ] - ] = None, - middle_name: typing.Optional[str] = None, - category: typing.Optional[str], - priority: typing.Optional[int] = None, - level: typing.Optional[int] = None, - ratio: typing.Optional[float] = None, - step: typing.Optional[int] = None, - tags: typing.Optional[list[str]] = None, - aliases: typing.Optional[list[str]] = None, - roles: typing.Optional[list[str]] = None, - id_or_name: typing.Optional[str | int] = None, - mode: typing.Optional[typing.Literal["auto", "manual"] | int] = None, - payload: typing.Optional[dict[str, typing.Any] | str] = None, - detail: typing.Optional[ShowcaseDetailObject | str] = None, - shape_or_name: typing.Optional[Circle | Square | str] = None, - measurements: typing.Optional[list[float] | str] = None, - shapes: typing.Optional[list[Shape]] = None, - segments: typing.Optional[list[ShowcaseSegmentsItem]] = None, - slots: typing.Optional[list[str | None]] = None, - grid: typing.Optional[list[list[int]]] = None, - location: typing.Optional[ShowcaseLocation] = None, - audit: typing.Optional[ShowcaseAudit] = None, - rows: typing.Optional[list[ShowcaseRowsItem]] = None, - ledger_py: typing.Optional[ShowcaseLedger] = None, - metadata: typing.Optional[ShowcaseMetadata] = None, - quotas: typing.Optional[Quotas] = None, - tokens: typing.Optional[Tokens] = None, - nicknames: typing.Optional[Nicknames] = None, - choices: typing.Optional[Choices] = None, - extras: typing.Optional[Extras] = None, - shape: typing.Optional[Shape] = None, - note: typing.Optional[Note] = None, - address: typing.Optional[Address] = None, - labels: typing.Optional[Labels] = None, - settings: typing.Optional[Settings] = None, - attributes: typing.Optional[Attributes] = None, - contact: typing.Optional[ContactPy] = None, - _retries: typing.Optional[int] = None, - _greeting: typing.Optional[str] = None, - _debug: typing.Optional[bool] = None, + nickname: str | None = None, + code: str | None = None, + sku: str | None = None, + phrase: str | None = None, + request_id: str | None = None, + contact_email: str | None = None, + host: str | None = None, + homepage: str | None = None, + gateway: str | None = None, + blob: bytes | None = None, + url_blob: bytes | None = None, + retries: int | None = None, + verbose: bool | None = None, + greeting: str | None = None, + debug: bool | None = None, + legacy_id_py: typing.Annotated[ + str, + typing_extensions.deprecated("This field is deprecated.", category=None), + ] + | None = None, + middle_name: str | None = None, + category: str | None, + priority: int | None = None, + level: int | None = None, + ratio: float | None = None, + step: int | None = None, + tags: list[str] | None = None, + aliases: list[str] | None = None, + roles: list[str] | None = None, + id_or_name: str | int | None = None, + mode: typing.Literal["auto", "manual"] | int | None = None, + payload: dict[str, typing.Any] | str | None = None, + detail: ShowcaseDetailObject | str | None = None, + shape_or_name: Circle | Square | str | None = None, + measurements: list[float] | str | None = None, + shapes: list[Shape] | None = None, + segments: list[ShowcaseSegmentsItem] | None = None, + slots: list[str | None] | None = None, + grid: list[list[int]] | None = None, + location: ShowcaseLocation | None = None, + audit: ShowcaseAudit | None = None, + rows: list[ShowcaseRowsItem] | None = None, + ledger_py: ShowcaseLedger | None = None, + metadata: ShowcaseMetadata | None = None, + quotas: Quotas | None = None, + tokens: Tokens | None = None, + nicknames: Nicknames | None = None, + choices: Choices | None = None, + extras: Extras | None = None, + shape: Shape | None = None, + note: Note | None = None, + address: Address | None = None, + labels: Labels | None = None, + settings: Settings | None = None, + attributes: Attributes | None = None, + contact: ContactPy | None = None, + _retries: int | None = None, + _greeting: str | None = None, + _debug: bool | None = None, ) -> None: self.kind = kind self.revision = revision @@ -3252,7 +3247,7 @@ def retries(self) -> int: return self._retries if self._retries is not None else 3 @retries.setter - def retries(self, value: typing.Optional[int]) -> None: + def retries(self, value: int | None) -> None: # pyright: ignore[reportPropertyTypeMismatch] self._retries = value @property @@ -3263,7 +3258,7 @@ def greeting(self) -> str: return self._greeting if self._greeting is not None else "hello" @greeting.setter - def greeting(self, value: typing.Optional[str]) -> None: + def greeting(self, value: str | None) -> None: # pyright: ignore[reportPropertyTypeMismatch] self._greeting = value @property @@ -3274,7 +3269,7 @@ def debug(self) -> bool: return self._debug if self._debug is not None else False @debug.setter - def debug(self, value: typing.Optional[bool]) -> None: + def debug(self, value: bool | None) -> None: # pyright: ignore[reportPropertyTypeMismatch] self._debug = value @@ -3425,7 +3420,7 @@ def to_transfer_type(self, value: "ShowcaseDetailObject") -> typing.Any: class ShowcaseDetailObject: code: str - hint: typing.Optional[str] = None + hint: str | None = None additional_properties: dict[str, typing.Any] = dataclasses.field( default_factory=dict @@ -3654,7 +3649,7 @@ class ShowcaseLocation: city: str - geo: typing.Optional[ShowcaseLocationGeo] = None + geo: ShowcaseLocationGeo | None = None additional_properties: dict[str, typing.Any] = dataclasses.field( default_factory=dict @@ -3769,9 +3764,9 @@ def to_transfer_type(self, value: "ShowcaseLocationGeo") -> typing.Any: @_transfer_type_convertible(_ShowcaseLocationGeoTransferTypeConverter) @dataclasses.dataclass(slots=True, kw_only=True) class ShowcaseLocationGeo: - lat: typing.Optional[float] = None + lat: float | None = None - lon: typing.Optional[float] = None + lon: float | None = None additional_properties: dict[str, typing.Any] = dataclasses.field( default_factory=dict @@ -4333,11 +4328,11 @@ class Widget: id: str - kind: typing.Optional[str] = None + kind: str | None = None name: str - size: typing.Optional[int] = None + size: int | None = None """Optional integer with two allOf branches tightened to [10, 20].""" additional_properties: dict[str, typing.Any] = dataclasses.field( @@ -4412,7 +4407,7 @@ class WidgetBase: id: str - kind: typing.Optional[str] = None + kind: str | None = None additional_properties: dict[str, typing.Any] = dataclasses.field( default_factory=dict diff --git a/advanced/samples/python/json_schema/api/temporal/models.py b/advanced/samples/python/json_schema/api/temporal/models.py index e75ed34e..442114a3 100644 --- a/advanced/samples/python/json_schema/api/temporal/models.py +++ b/advanced/samples/python/json_schema/api/temporal/models.py @@ -26,9 +26,6 @@ ) -# pyright: reportDeprecated=false, reportPropertyTypeMismatch=false - - class _TemporalTransferTypeConverter( temporalio.converter.TransferTypeConverter["Temporal", typing.Any] ): @@ -294,20 +291,20 @@ class Temporal: PT90M → PT1H30M). """ - updated_at: typing.Optional[datetime.datetime] = None + updated_at: datetime.datetime | None = None """Optional date-time.""" - expires_on: typing.Optional[datetime.date] = None + expires_on: datetime.date | None = None """Optional date.""" - reminder: typing.Optional[datetime.time] = None + reminder: datetime.time | None = None """Optional time.""" - retry_delay: typing.Optional[datetime.timedelta] = None + retry_delay: datetime.timedelta | None = None """Optional duration.""" - deleted_at: typing.Optional[datetime.datetime] = None + deleted_at: datetime.datetime | None = None """Optional and nullable date-time (may be absent or explicitly null).""" - archived_on: typing.Optional[datetime.date] = None + archived_on: datetime.date | None = None """Optional and nullable date.""" diff --git a/samples/python/chat/models.py b/samples/python/chat/models.py index 07088aa6..5687120e 100644 --- a/samples/python/chat/models.py +++ b/samples/python/chat/models.py @@ -16,9 +16,6 @@ ) -# pyright: reportDeprecated=false, reportPropertyTypeMismatch=false - - _ROOM_DECLARED: frozenset[str] = frozenset( {"roomId", "displayName", "topic", "members", "labels"} ) @@ -228,10 +225,10 @@ class Message: body: str - reply_to_id: typing.Optional[str] = None + reply_to_id: str | None = None """Id of the message this replies to, if any.""" - _priority: typing.Optional[int] = dataclasses.field(default=None, repr=False) + _priority: int | None = dataclasses.field(default=None, repr=False) """Delivery priority.""" def __init__( @@ -239,9 +236,9 @@ def __init__( *, kind: typing.Literal["text"] = "text", body: str, - reply_to_id: typing.Optional[str] = None, - priority: typing.Optional[int] = None, - _priority: typing.Optional[int] = None, + reply_to_id: str | None = None, + priority: int | None = None, + _priority: int | None = None, ) -> None: self.kind = kind self.body = body @@ -254,7 +251,7 @@ def priority(self) -> int: return self._priority if self._priority is not None else 0 @priority.setter - def priority(self, value: typing.Optional[int]) -> None: + def priority(self, value: int | None) -> None: # pyright: ignore[reportPropertyTypeMismatch] self._priority = value @@ -396,12 +393,12 @@ class Room: display_name: str - topic: typing.Optional[str] + topic: str | None """Room topic; may be explicitly cleared to null.""" - members: typing.Optional[list[str]] = None + members: list[str] | None = None - labels: typing.Optional[Labels] = None + labels: Labels | None = None additional_properties: dict[str, typing.Any] = dataclasses.field( default_factory=dict diff --git a/samples/python/kb/_recursive.py b/samples/python/kb/_recursive.py index f9db85ec..0d5b9f43 100644 --- a/samples/python/kb/_recursive.py +++ b/samples/python/kb/_recursive.py @@ -20,9 +20,6 @@ from .content.page.models import PageMeta -# pyright: reportDeprecated=false, reportPropertyTypeMismatch=false - - class _BlockTransferTypeConverter( temporalio.converter.TransferTypeConverter["Block", typing.Any] ): @@ -166,11 +163,11 @@ class Block: integer field. """ - text: typing.Optional[str] = None + text: str | None = None - style: typing.Optional[BlockStyle] = None + style: BlockStyle | None = None - page: typing.Optional[Page] = None + page: Page | None = None """Optional back-reference to the containing page - closes the Page <-> Block cycle. Optional + nullable, so this edge terminates. """ @@ -299,7 +296,7 @@ class Page: meta: PageMeta - blocks: typing.Optional[list[Block]] = None + blocks: list[Block] | None = None """Ordered content blocks. Cross-file `$ref` to block.json (same directory); the array is the terminating edge of the cycle. """ diff --git a/samples/python/kb/content/block/models.py b/samples/python/kb/content/block/models.py index 4e90412b..5d368d93 100644 --- a/samples/python/kb/content/block/models.py +++ b/samples/python/kb/content/block/models.py @@ -15,9 +15,6 @@ ) -# pyright: reportDeprecated=false, reportPropertyTypeMismatch=false - - class _BlockStyleTransferTypeConverter( temporalio.converter.TransferTypeConverter["BlockStyle", typing.Any] ): @@ -96,6 +93,6 @@ def to_transfer_type(self, value: "BlockStyle") -> typing.Any: class BlockStyle: """Non-cyclic helper; stays in the content_block module. All members optional.""" - bold: typing.Optional[bool] = None + bold: bool | None = None - indent: typing.Optional[int] = None + indent: int | None = None diff --git a/samples/python/kb/content/page/models.py b/samples/python/kb/content/page/models.py index 22e21a83..5719f97f 100644 --- a/samples/python/kb/content/page/models.py +++ b/samples/python/kb/content/page/models.py @@ -15,9 +15,6 @@ ) -# pyright: reportDeprecated=false, reportPropertyTypeMismatch=false - - class _PageMetaTransferTypeConverter( temporalio.converter.TransferTypeConverter["PageMeta", typing.Any] ): @@ -82,4 +79,4 @@ class PageMeta: author: str - word_count: typing.Optional[int] = None + word_count: int | None = None diff --git a/samples/python/kb/kb/models.py b/samples/python/kb/kb/models.py index 112dabe4..3a3a2b70 100644 --- a/samples/python/kb/kb/models.py +++ b/samples/python/kb/kb/models.py @@ -15,9 +15,6 @@ ) -# pyright: reportDeprecated=false, reportPropertyTypeMismatch=false - - class _GetCategoryTreeInputTransferTypeConverter( temporalio.converter.TransferTypeConverter["GetCategoryTreeInput", typing.Any] ): diff --git a/samples/python/kb/tree/category/models.py b/samples/python/kb/tree/category/models.py index 9bd9dbe5..56213f62 100644 --- a/samples/python/kb/tree/category/models.py +++ b/samples/python/kb/tree/category/models.py @@ -15,9 +15,6 @@ ) -# pyright: reportDeprecated=false, reportPropertyTypeMismatch=false - - class _CategoryTransferTypeConverter( temporalio.converter.TransferTypeConverter["Category", typing.Any] ): @@ -125,7 +122,7 @@ class Category: name: str - children: typing.Optional[list[Category]] = None + children: list[Category] | None = None """Sub-categories. A within-file self-cycle via `$ref: '#'`; the possibly-empty array is the terminating edge, so it stays in this module. """ diff --git a/samples/python/showcase/models.py b/samples/python/showcase/models.py index 1e07cf9c..1f309e97 100644 --- a/samples/python/showcase/models.py +++ b/samples/python/showcase/models.py @@ -25,9 +25,6 @@ ) -# pyright: reportDeprecated=false, reportPropertyTypeMismatch=false - - _PATTERN_CD24623C0C29CA35 = re.compile("^[A-Z]{2,4}\\Z", re.ASCII) _PATTERN_B4BA2CA20EB1B963 = re.compile( "^[^\\t\\n\\x0B\\f\\r ]+[\\t\\n\\x0B\\f\\r ][^\\t\\n\\x0B\\f\\r ]+\\Z", re.ASCII @@ -179,9 +176,9 @@ class Address: street: str - city: typing.Optional[str] = None + city: str | None = None - zip: typing.Optional[int] = None + zip: int | None = None additional_properties: dict[str, typing.Any] = dataclasses.field( default_factory=dict @@ -559,11 +556,11 @@ class ContactPy: `$ref`, while the wire `$ref` name stays `Contact`. """ - email: typing.Optional[str] = None + email: str | None = None - shipping_street: typing.Optional[str] = None + shipping_street: str | None = None - shipping_zip: typing.Optional[str] = None + shipping_zip: str | None = None additional_properties: dict[str, typing.Any] = dataclasses.field( default_factory=dict @@ -968,9 +965,9 @@ def to_transfer_type(self, value: "Settings") -> typing.Any: class Settings: """A closed object; unknown members are rejected.""" - theme: typing.Optional[str] = None + theme: str | None = None - font_size: typing.Optional[int] = None + font_size: int | None = None class _ShowcaseTransferTypeConverter( @@ -2871,86 +2868,87 @@ class Showcase: active: bool """Required boolean scalar.""" - nickname: typing.Optional[str] = None + nickname: str | None = None """Optional short name, at most 12 code points.""" - code: typing.Optional[str] = None + code: str | None = None """Optional code, 2 to 5 code points. Counted in Unicode code points, so a multi-byte value (e.g. "a😀b", 3 code points / 6 UTF-8 bytes) is valid. """ - sku: typing.Optional[str] = None + sku: str | None = None """Optional product code: 2 to 4 uppercase ASCII letters, anchored (`^[A-Z]{2,4}$`). Exercises the RE2-safe `pattern` gate. """ - phrase: typing.Optional[str] = None + phrase: str | None = None """Optional two-word phrase separated by whitespace (`^\\S+\\s\\S+$`). Exercises the loader's `\\s`/`\\S` → ASCII-class normalization and the per-target `$` end-anchor rewrite (Python `\\Z` / Java `\\z`), so a Unicode space (NBSP) and a trailing newline are rejected consistently across all four languages. """ - request_id: typing.Optional[str] = None + request_id: str | None = None """Optional request identifier; asserted RFC 4122 UUID via `format: uuid`. Stays `string`-typed (format assertion, no materialization); the pinned regex is validated identically across all four languages. """ - contact_email: typing.Optional[str] = None + contact_email: str | None = None """Optional contact address; asserted ASCII dot-atom `format: email` (single `@`, >=2-label domain, total length <= 254, guard-before-regex). """ - host: typing.Optional[str] = None + host: str | None = None """Optional host name; asserted RFC 1123 `format: hostname` (LDH labels, total length <= 253). """ - homepage: typing.Optional[str] = None + homepage: str | None = None """Optional homepage; asserted RFC 3986 `format: uri` (scheme required, ASCII only; an IP-literal host is validated by the spliced ipv6 grammar). """ - gateway: typing.Optional[str] = None + gateway: str | None = None """Optional gateway address; asserted dotted-quad IPv4 via format ipv4.""" - blob: typing.Optional[bytes] = None + blob: bytes | None = None """Optional binary payload carried as a `contentEncoding: base64` string, materialized to native bytes (Go []byte, TS Uint8Array, Python bytes, Java byte[]). The wire is canonical padded standard base64; a malformed value is rejected by the pinned regex before decode. """ - url_blob: typing.Optional[bytes] = None + url_blob: bytes | None = None """Optional binary payload carried as a `contentEncoding: base64url` string (URL-safe alphabet, unpadded, RFC 4648 §5), materialized to the same native bytes type. The same bytes encode to a different wire than base64 ("Pj4+" vs "Pj4-"). """ - _retries: typing.Optional[int] = dataclasses.field(default=None, repr=False) + _retries: int | None = dataclasses.field(default=None, repr=False) """Retry budget Optional integer with a schema default. """ - verbose: typing.Optional[bool] = None + verbose: bool | None = None - _greeting: typing.Optional[str] = dataclasses.field(default=None, repr=False) + _greeting: str | None = dataclasses.field(default=None, repr=False) """Greeting Optional string with a schema default, surfaced on read. """ - _debug: typing.Optional[bool] = dataclasses.field(default=None, repr=False) + _debug: bool | None = dataclasses.field(default=None, repr=False) """Debug flag Optional boolean with a schema default. """ - legacy_id_py: typing.Optional[ + legacy_id_py: ( typing.Annotated[ str, typing_extensions.deprecated("This field is deprecated.", category=None), ] - ] = None + | None + ) = None """Deprecated legacy identifier; prefer `requestId`. Exercises the native deprecation marker (Go // Deprecated:, TS @deprecated, Java @Deprecated, Python PEP 702 @deprecated). Also exercises the property-level `x-<lang>-name` override (the Stage @@ -2960,34 +2958,34 @@ class Showcase: @JsonProperty). """ - middle_name: typing.Optional[str] = None + middle_name: str | None = None """Optional and nullable; may be absent or explicitly null.""" - category: typing.Optional[str] + category: str | None """Required but nullable; may be explicitly cleared to null.""" - priority: typing.Optional[int] = None + priority: int | None = None """Optional integer bounded to the inclusive range [1, 10].""" - level: typing.Optional[int] = None + level: int | None = None """Optional integer that must be strictly greater than 0.""" - ratio: typing.Optional[float] = None + ratio: float | None = None """Optional number that must be a non-negative multiple of 5.""" - step: typing.Optional[int] = None + step: int | None = None """Optional integer that must be a multiple of 3.""" - tags: typing.Optional[list[str]] = None + tags: list[str] | None = None """Ordered list of free-form tags; 1 to 5 entries.""" - aliases: typing.Optional[list[str]] = None + aliases: list[str] | None = None """Alternate names; each must be distinct.""" - roles: typing.Optional[list[str]] = None + roles: list[str] | None = None """Access roles; must contain between one and two "admin" entries.""" - id_or_name: typing.Optional[str | int] = None + id_or_name: str | int | None = None """Disjoint-kind union (oneOf sum type): the wire value is either a string of at least 3 code points or an integer of at least 1, selected by its JSON token. Not a member of a discriminated union — the token itself is the selector. Each branch also @@ -2996,14 +2994,14 @@ class Showcase: violation. """ - mode: typing.Optional[typing.Literal["auto", "manual"] | int] = None + mode: typing.Literal["auto", "manual"] | int | None = None """A union whose string branch is a **closed value set**: either one of two named modes or an unbounded non-negative integer. The branch narrows to its own admissible values (a Go/Java membership check, a TypeScript literal union, a Python `Literal`), so an unknown string is a Violation while any non-negative integer is accepted. """ - payload: typing.Optional[dict[str, typing.Any] | str] = None + payload: dict[str, typing.Any] | str | None = None """Mixed-kind union whose object branch is an inline free-form object: the wire value is either an arbitrary object (members carried verbatim) or a string, selected by its JSON token. The free-form object is the one object branch that needs no type @@ -3011,7 +3009,7 @@ class Showcase: `<Union>Object`. """ - detail: typing.Optional[ShowcaseDetailObject | str] = None + detail: ShowcaseDetailObject | str | None = None """Mixed-kind union whose object branch is an inline *structured* object, written directly on the property rather than in `$defs`. It is the only object branch of this union, so it derives its name from the union it belongs to — @@ -3019,7 +3017,7 @@ class Showcase: own constraints and it stays open to unknown ones. """ - shape_or_name: typing.Optional[Circle | Square | str] = None + shape_or_name: Circle | Square | str | None = None """Tagged object union mixed with a scalar kind: the two selector layers compose — the JSON token picks object-vs-string, and, for an object, the shared required `kind` const picks Circle-vs-Square. Written inline on the property, so the union itself is @@ -3030,7 +3028,7 @@ class Showcase: validate through their own models. """ - measurements: typing.Optional[list[float] | str] = None + measurements: list[float] | str | None = None """Mixed-kind union with an array branch: the wire value is either a non-empty list of distinct numbers or a lowercase preset name, selected by its JSON token. An array branch has no definition to take a name from, so Go and Java emit it as the @@ -3040,72 +3038,72 @@ class Showcase: string's `pattern` — so the array-vs-string choice is validated as well as selected. """ - shapes: typing.Optional[list[Shape]] = None + shapes: list[Shape] | None = None """A list whose element type is a named union: every element is routed to exactly one branch by the union's own selector, and its index carries into the violation path (`shapes[1]`). Go and Java cannot decode a sealed interface as a whole, so the element decodes through the union's dispatcher one at a time. """ - segments: typing.Optional[list[ShowcaseSegmentsItem]] = None + segments: list[ShowcaseSegmentsItem] | None = None """A list whose element union is written **inline**. An element has no name of its own, so the union is named after its position — `ShowcaseSegmentsItem` — moved into `$defs`, and the element becomes a `$ref` at it; from there it is an ordinary named union in every language. """ - slots: typing.Optional[list[str | None]] = None + slots: list[str | None] | None = None """A list of **nullable elements** — the two-branch nullability `oneOf` rather than a sum type, so nothing is named: the elements themselves become nullable (`[]*string`, `(string | null)[]`, `list[str | None]`, `List<@Nullable String>`) while the list stays a list. """ - grid: typing.Optional[list[list[int]]] = None + grid: list[list[int]] | None = None """A nested array: `items` at depth two. Each level decodes elementwise, so a bad element is reported at its own two-dimensional index (`grid[1][0]`). """ - location: typing.Optional[ShowcaseLocation] = None + location: ShowcaseLocation | None = None - audit: typing.Optional[ShowcaseAudit] = None + audit: ShowcaseAudit | None = None """A nullable inline object. The nullability wrapper emits no type of its own, so the object inside it takes the property's name — `ShowcaseAudit`, the same name it would take written plainly: adding or removing nullability never renames the type. """ - rows: typing.Optional[list[ShowcaseRowsItem]] = None + rows: list[ShowcaseRowsItem] | None = None """A list whose element is an inline object, named after its position (`ShowcaseRowsItem`) exactly as an inline element *union* is. """ - ledger_py: typing.Optional[ShowcaseLedger] = None + ledger_py: ShowcaseLedger | None = None - metadata: typing.Optional[ShowcaseMetadata] = None + metadata: ShowcaseMetadata | None = None - quotas: typing.Optional[Quotas] = None + quotas: Quotas | None = None - tokens: typing.Optional[Tokens] = None + tokens: Tokens | None = None - nicknames: typing.Optional[Nicknames] = None + nicknames: Nicknames | None = None - choices: typing.Optional[Choices] = None + choices: Choices | None = None - extras: typing.Optional[Extras] = None + extras: Extras | None = None - shape: typing.Optional[Shape] = None + shape: Shape | None = None - note: typing.Optional[Note] = None + note: Note | None = None - address: typing.Optional[Address] = None + address: Address | None = None - labels: typing.Optional[Labels] = None + labels: Labels | None = None - settings: typing.Optional[Settings] = None + settings: Settings | None = None - attributes: typing.Optional[Attributes] = None + attributes: Attributes | None = None - contact: typing.Optional[ContactPy] = None + contact: ContactPy | None = None def __init__( self, @@ -3119,68 +3117,65 @@ def __init__( name: str, count: int, active: bool, - nickname: typing.Optional[str] = None, - code: typing.Optional[str] = None, - sku: typing.Optional[str] = None, - phrase: typing.Optional[str] = None, - request_id: typing.Optional[str] = None, - contact_email: typing.Optional[str] = None, - host: typing.Optional[str] = None, - homepage: typing.Optional[str] = None, - gateway: typing.Optional[str] = None, - blob: typing.Optional[bytes] = None, - url_blob: typing.Optional[bytes] = None, - retries: typing.Optional[int] = None, - verbose: typing.Optional[bool] = None, - greeting: typing.Optional[str] = None, - debug: typing.Optional[bool] = None, - legacy_id_py: typing.Optional[ - typing.Annotated[ - str, - typing_extensions.deprecated( - "This field is deprecated.", category=None - ), - ] - ] = None, - middle_name: typing.Optional[str] = None, - category: typing.Optional[str], - priority: typing.Optional[int] = None, - level: typing.Optional[int] = None, - ratio: typing.Optional[float] = None, - step: typing.Optional[int] = None, - tags: typing.Optional[list[str]] = None, - aliases: typing.Optional[list[str]] = None, - roles: typing.Optional[list[str]] = None, - id_or_name: typing.Optional[str | int] = None, - mode: typing.Optional[typing.Literal["auto", "manual"] | int] = None, - payload: typing.Optional[dict[str, typing.Any] | str] = None, - detail: typing.Optional[ShowcaseDetailObject | str] = None, - shape_or_name: typing.Optional[Circle | Square | str] = None, - measurements: typing.Optional[list[float] | str] = None, - shapes: typing.Optional[list[Shape]] = None, - segments: typing.Optional[list[ShowcaseSegmentsItem]] = None, - slots: typing.Optional[list[str | None]] = None, - grid: typing.Optional[list[list[int]]] = None, - location: typing.Optional[ShowcaseLocation] = None, - audit: typing.Optional[ShowcaseAudit] = None, - rows: typing.Optional[list[ShowcaseRowsItem]] = None, - ledger_py: typing.Optional[ShowcaseLedger] = None, - metadata: typing.Optional[ShowcaseMetadata] = None, - quotas: typing.Optional[Quotas] = None, - tokens: typing.Optional[Tokens] = None, - nicknames: typing.Optional[Nicknames] = None, - choices: typing.Optional[Choices] = None, - extras: typing.Optional[Extras] = None, - shape: typing.Optional[Shape] = None, - note: typing.Optional[Note] = None, - address: typing.Optional[Address] = None, - labels: typing.Optional[Labels] = None, - settings: typing.Optional[Settings] = None, - attributes: typing.Optional[Attributes] = None, - contact: typing.Optional[ContactPy] = None, - _retries: typing.Optional[int] = None, - _greeting: typing.Optional[str] = None, - _debug: typing.Optional[bool] = None, + nickname: str | None = None, + code: str | None = None, + sku: str | None = None, + phrase: str | None = None, + request_id: str | None = None, + contact_email: str | None = None, + host: str | None = None, + homepage: str | None = None, + gateway: str | None = None, + blob: bytes | None = None, + url_blob: bytes | None = None, + retries: int | None = None, + verbose: bool | None = None, + greeting: str | None = None, + debug: bool | None = None, + legacy_id_py: typing.Annotated[ + str, + typing_extensions.deprecated("This field is deprecated.", category=None), + ] + | None = None, + middle_name: str | None = None, + category: str | None, + priority: int | None = None, + level: int | None = None, + ratio: float | None = None, + step: int | None = None, + tags: list[str] | None = None, + aliases: list[str] | None = None, + roles: list[str] | None = None, + id_or_name: str | int | None = None, + mode: typing.Literal["auto", "manual"] | int | None = None, + payload: dict[str, typing.Any] | str | None = None, + detail: ShowcaseDetailObject | str | None = None, + shape_or_name: Circle | Square | str | None = None, + measurements: list[float] | str | None = None, + shapes: list[Shape] | None = None, + segments: list[ShowcaseSegmentsItem] | None = None, + slots: list[str | None] | None = None, + grid: list[list[int]] | None = None, + location: ShowcaseLocation | None = None, + audit: ShowcaseAudit | None = None, + rows: list[ShowcaseRowsItem] | None = None, + ledger_py: ShowcaseLedger | None = None, + metadata: ShowcaseMetadata | None = None, + quotas: Quotas | None = None, + tokens: Tokens | None = None, + nicknames: Nicknames | None = None, + choices: Choices | None = None, + extras: Extras | None = None, + shape: Shape | None = None, + note: Note | None = None, + address: Address | None = None, + labels: Labels | None = None, + settings: Settings | None = None, + attributes: Attributes | None = None, + contact: ContactPy | None = None, + _retries: int | None = None, + _greeting: str | None = None, + _debug: bool | None = None, ) -> None: self.kind = kind self.revision = revision @@ -3252,7 +3247,7 @@ def retries(self) -> int: return self._retries if self._retries is not None else 3 @retries.setter - def retries(self, value: typing.Optional[int]) -> None: + def retries(self, value: int | None) -> None: # pyright: ignore[reportPropertyTypeMismatch] self._retries = value @property @@ -3263,7 +3258,7 @@ def greeting(self) -> str: return self._greeting if self._greeting is not None else "hello" @greeting.setter - def greeting(self, value: typing.Optional[str]) -> None: + def greeting(self, value: str | None) -> None: # pyright: ignore[reportPropertyTypeMismatch] self._greeting = value @property @@ -3274,7 +3269,7 @@ def debug(self) -> bool: return self._debug if self._debug is not None else False @debug.setter - def debug(self, value: typing.Optional[bool]) -> None: + def debug(self, value: bool | None) -> None: # pyright: ignore[reportPropertyTypeMismatch] self._debug = value @@ -3425,7 +3420,7 @@ def to_transfer_type(self, value: "ShowcaseDetailObject") -> typing.Any: class ShowcaseDetailObject: code: str - hint: typing.Optional[str] = None + hint: str | None = None additional_properties: dict[str, typing.Any] = dataclasses.field( default_factory=dict @@ -3654,7 +3649,7 @@ class ShowcaseLocation: city: str - geo: typing.Optional[ShowcaseLocationGeo] = None + geo: ShowcaseLocationGeo | None = None additional_properties: dict[str, typing.Any] = dataclasses.field( default_factory=dict @@ -3769,9 +3764,9 @@ def to_transfer_type(self, value: "ShowcaseLocationGeo") -> typing.Any: @_transfer_type_convertible(_ShowcaseLocationGeoTransferTypeConverter) @dataclasses.dataclass(slots=True, kw_only=True) class ShowcaseLocationGeo: - lat: typing.Optional[float] = None + lat: float | None = None - lon: typing.Optional[float] = None + lon: float | None = None additional_properties: dict[str, typing.Any] = dataclasses.field( default_factory=dict @@ -4333,11 +4328,11 @@ class Widget: id: str - kind: typing.Optional[str] = None + kind: str | None = None name: str - size: typing.Optional[int] = None + size: int | None = None """Optional integer with two allOf branches tightened to [10, 20].""" additional_properties: dict[str, typing.Any] = dataclasses.field( @@ -4412,7 +4407,7 @@ class WidgetBase: id: str - kind: typing.Optional[str] = None + kind: str | None = None additional_properties: dict[str, typing.Any] = dataclasses.field( default_factory=dict diff --git a/samples/python/temporal/models.py b/samples/python/temporal/models.py index e75ed34e..442114a3 100644 --- a/samples/python/temporal/models.py +++ b/samples/python/temporal/models.py @@ -26,9 +26,6 @@ ) -# pyright: reportDeprecated=false, reportPropertyTypeMismatch=false - - class _TemporalTransferTypeConverter( temporalio.converter.TransferTypeConverter["Temporal", typing.Any] ): @@ -294,20 +291,20 @@ class Temporal: PT90M → PT1H30M). """ - updated_at: typing.Optional[datetime.datetime] = None + updated_at: datetime.datetime | None = None """Optional date-time.""" - expires_on: typing.Optional[datetime.date] = None + expires_on: datetime.date | None = None """Optional date.""" - reminder: typing.Optional[datetime.time] = None + reminder: datetime.time | None = None """Optional time.""" - retry_delay: typing.Optional[datetime.timedelta] = None + retry_delay: datetime.timedelta | None = None """Optional duration.""" - deleted_at: typing.Optional[datetime.datetime] = None + deleted_at: datetime.datetime | None = None """Optional and nullable date-time (may be absent or explicitly null).""" - archived_on: typing.Optional[datetime.date] = None + archived_on: datetime.date | None = None """Optional and nullable date.""" diff --git a/specs/json-schema/PRINCIPLES.md b/specs/json-schema/PRINCIPLES.md index cce37d45..11031a9f 100644 --- a/specs/json-schema/PRINCIPLES.md +++ b/specs/json-schema/PRINCIPLES.md @@ -70,7 +70,7 @@ private `_<field>` slot in the same class namespace as declared members. A collision rejects at load time, and `x-py-name` moves the public property and its backing slot together. Python emits no module-level `DEFAULT_*` identifier. -1. **Models emit `@dataclasses.dataclass`es, not a validating model base (P2/P4).** Every model is a slotted, keyword-only dataclass with **no validation on construction**. Required non-nullable property annotations are bare `T`; optional or nullable property annotations use `typing.Optional[T]`. Nested nullable values and converter/helper annotations retain `T | None`, so this spelling policy is limited to the public model surface. A model with a schema-defaulted property uses `init=False` plus a generated keyword-only `__init__`: the public constructor keyword initializes a private `_<field>: typing.Optional[T]` presence slot, while a mutable same-name property returns either that raw value or the scalar schema default. Its setter accepts `typing.Optional[T]`, and assigning `None` restores the unset state. The private slot is excluded from `repr` but remains a dataclass comparison field, preserving raw presence/value equality. Open-model constructors initialize an omitted `additional_properties` to a fresh dictionary. Other models use `@dataclasses.dataclass(slots=True, kw_only=True)` directly. Field annotations remain plain Python types (`int`, `str`, `datetime.datetime`, `datetime.timedelta`, `bytes`, `typing.Literal[...]`, `list[T]`, `dict[str, V]`), never annotated validator aliases, so the class reads like hand-written Python (P2) and the runtime dependency set stays at the SDKs alone (P4). Conversion and validation live *off* the model, in a companion transfer-type converter (§3), which gives serialize-side validation real teeth (P12). +1. **Models emit `@dataclasses.dataclass`es, not a validating model base (P2/P4).** Every model is a slotted, keyword-only dataclass with **no validation on construction**. Required non-nullable property annotations are bare `T`; optional or nullable property annotations use `T | None`. A model with a schema-defaulted property uses `init=False` plus a generated keyword-only `__init__`: the public constructor keyword initializes a private `_<field>: T | None` presence slot, while a mutable same-name property returns either that raw value or the scalar schema default. Its setter accepts `T | None`, and assigning `None` restores the unset state. The private slot is excluded from `repr` but remains a dataclass comparison field, preserving raw presence/value equality. Open-model constructors initialize an omitted `additional_properties` to a fresh dictionary. Other models use `@dataclasses.dataclass(slots=True, kw_only=True)` directly. Field annotations remain plain Python types (`int`, `str`, `datetime.datetime`, `datetime.timedelta`, `bytes`, `typing.Literal[...]`, `list[T]`, `dict[str, V]`), never annotated validator aliases, so the class reads like hand-written Python (P2) and the runtime dependency set stays at the SDKs alone (P4). Conversion and validation live *off* the model, in a companion transfer-type converter (§3), which gives serialize-side validation real teeth (P12). 2. **Aggregate via a single generated `ValidationError` (extends `Exception`) holding `list[Violation]` (P11).** Collect every `Violation { path, reason }` — a `@dataclasses.dataclass(frozen=True, slots=True)` in the shared `definitions` module — into one list and raise **one** generated `ValidationError`, whose `str()` enumerates every violation and whose `violations` attribute exposes them structured. This is the same primitive Go, TypeScript and Java surface, with the same structured `{path, reason}` shape — so all four targets now report a rejection the same way (P11), and the cross-language guarantee is the accepted-and-rejected value set (P1), not byte-identical message text. Python's `reason` strings mirror **TypeScript's** verbatim, Python being the same design. It is never stringly-typed and never a language-native exception group. A nested value's `ValidationError` is re-pathed under the enclosing field by a `_collect(violations, path, error)` runtime helper and merged into the parent's list, so one payload yields one flat, fully-pathed violation set — the analogue of TypeScript's `collect`. 3. **A companion `_<Model>TransferTypeConverter` converts model ⇄ intermediate and validates; the *default* Temporal converter finds it through the SDK's transfer-type hook (P12/P3).** Each model gets a private converter class — `class _UserTransferTypeConverter(temporalio.converter.TransferTypeConverter["User", typing.Any])` with `from_transfer_type(value: typing.Any, type_hint: type[User]) -> User` as the parse adapter (untrusted JSON value → model) and `to_transfer_type(value: User) -> typing.Any` as the encode adapter (model → plain JSON value) — attached to the class by `@_transfer_type_convertible(_UserTransferTypeConverter)`, the runtime module's one-line shim over `temporalio.converter.transfer_type_convertible` that erases the converter's value-type parameter (binding it on the decorated class is circular for a static type checker: the class's type depends on the decorator, whose value type depends on the class). Both directions run the same emitted checks, collecting `Violation`s into one `ValidationError` (§2), so validation lives *inside* the conversion — that is what makes them the single source of truth in both directions. The intermediate is a plain `dict`/`list`/scalar, never a `str`: the byte-level JSON encode/decode is the Temporal payload converter's boundary, which hands the transfer-type converter the parsed (or about-to-be-encoded) value. Working in intermediate values (not strings) is also what makes conversions **composable** — a parent's `to_transfer_type` calls its children's on nested values and embeds the results, `from_transfer_type` likewise; a `str` could not nest. That composition is load-bearing rather than stylistic: the SDK hooks only the **top-level** value, so a nested model is always converted by its parent's body. Registration is the whole of the wiring — the stock `DataConverter.default` consults the hook, so generated models need no contrib package and no user setup (P3). A `typing.TypeAlias` cannot be decorated, so a named or inline `oneOf` union is served by module-private free functions (`_<name>_from_transfer_type` / `_<name>_to_transfer_type`) instead of a converter class; unions can only appear nested, so nothing is lost. The per-field omit-vs-`null` decision follows the [[nullability]] serialize table. See [[nullability]], [[const]], [[default]]. diff --git a/specs/json-schema/features/default.md b/specs/json-schema/features/default.md index bf459ce8..c22d2a3b 100644 --- a/specs/json-schema/features/default.md +++ b/specs/json-schema/features/default.md @@ -117,14 +117,14 @@ Loader behavior: **None of its own.** `default` does not change the emitted type — the type comes from [[type]] + [[nullability]], and `default` implies the member is **optional**, so it takes the optional form (`*T` / `x?: T` / -`typing.Optional[T]` / boxed-or-`@Nullable`). The default value never appears +`T | None` / boxed-or-`@Nullable`). The default value never appears in the field itself in any target. What `default` *does* add is the **read-side surfacing mechanism** and the generated default value itself, which differ per language: | Language | Set-ness signal (omit-unset) | Read-side surfacing of the default | |---|---|---| -| Python | private `_<field>: typing.Optional[T]` | **native property** — `@property def field(self) -> T` returns the private value when set and the scalar default otherwise. A setter accepts `typing.Optional[T]`; assigning `None` restores unset. Models with defaults receive a generated keyword-only constructor so `Model(field=...)` remains the public construction API. | +| Python | private `_<field>: T | None` | **native property** — `@property def field(self) -> T` returns the private value when set and the scalar default otherwise. A setter accepts `T | None`; assigning `None` restores unset. Models with defaults receive a generated keyword-only constructor so `Model(field=...)` remains the public construction API. | | Java | `null` field + `@JsonInclude(NON_NULL)` | **native** — the generated **getter** returns the default when the backing field is `null` (`return nickname != null ? nickname : "anon";`). Getters already exist in the POJO design (PRINCIPLES Java §1). | | TypeScript | `undefined` (the `?` field) | **advisory** — interfaces have no methods (PRINCIPLES TS §2), so the consumer applies the default with the native `?? DEFAULT_X`; the generator emits `export const DEFAULT_X = "anon"`. No accessor needed; `??` is the idiom. | | Go | `*T` `nil` + `,omitempty` | **generated accessor** — a `func (m M) <Field>OrDefault() T` returns `*m.Field` when set and the default literal when `nil` (`func (u User) NicknameOrDefault() string { if u.Nickname != nil { return *u.Nickname }; return "anon" }`). The bare field stays `*T` (set-ness intact); the accessor is the materialize-on-read path. Emitted **only** for default-bearing fields. Modeled on proto3's `GetX()` — the same omit-default-on-wire + accessor-materializes-default pattern already familiar to Temporal users. Named `<Field>OrDefault` rather than `Get<Field>` to read as "the value, or its default" and to avoid implying a getter on every field. Alternative approaches considered: (a) advisory constant (`DEFAULT_X` + caller nil-checks) — pushes nil-checks to every call site; (b) populate on deserialize — destroys set-ness, forces deep-equals, breaks P9. | diff --git a/specs/json-schema/nullability.md b/specs/json-schema/nullability.md index 39c2758a..9a4dff87 100644 --- a/specs/json-schema/nullability.md +++ b/specs/json-schema/nullability.md @@ -133,29 +133,27 @@ nullability convention (`x?: T | null` is the optional+nullable form). ### Python -Optional or nullable model properties use `typing.Optional[T]`; required -non-nullable properties carry bare `T`. Nested nullable values and the -converter/helper annotations keep their `T | None` spelling. Every public -constructor argument is keyword-only (see PRINCIPLES Python §1): +Optional or nullable model properties use `T | None`; required +non-nullable properties carry bare `T`. Every public constructor argument is +keyword-only (see PRINCIPLES Python §1): ```python from __future__ import annotations import dataclasses -import typing @dataclasses.dataclass(slots=True, kw_only=True) class User: id: int # required - nickname: typing.Optional[int] = None # optional — None if absent + nickname: int | None = None # optional — None if absent name: str # required - email: typing.Optional[str] = None # optional + email: str | None = None # optional ``` | `type` token | required | optional | |---|---|---| -| any | `T` | `typing.Optional[T]` (with `= None` default) | +| any | `T` | `T | None` (with `= None` default) | Absence is `None`. The dataclass itself neither coerces nor checks anything: the only path from wire to field is the model's transfer-type @@ -232,12 +230,12 @@ modifier, Python's `= None` default). | `type` token | Java | Go | TypeScript | Python | |---|---|---|---|---| -| `"integer"` | `@Nullable Long` | `*int64` | `x?: number \| null` | `x: typing.Optional[int] = None` | -| `"number"` | `@Nullable Double` | `*float64` | `x?: number \| null` | `x: typing.Optional[float] = None` | -| `"boolean"` | `@Nullable Boolean` | `*bool` | `x?: boolean \| null` | `x: typing.Optional[bool] = None` | -| `"string"` | `@Nullable String` | `*string` | `x?: string \| null` | `x: typing.Optional[str] = None` | -| `"object"` | `@Nullable T` | `*T` | `x?: T \| null` | `x: typing.Optional[T] = None` | -| `"array"` | `@Nullable List<T>` | `[]T` (nil = absent or null) | `x?: T[] \| null` | `x: typing.Optional[list[T]] = None` | +| `"integer"` | `@Nullable Long` | `*int64` | `x?: number \| null` | `x: int \| None = None` | +| `"number"` | `@Nullable Double` | `*float64` | `x?: number \| null` | `x: float \| None = None` | +| `"boolean"` | `@Nullable Boolean` | `*bool` | `x?: boolean \| null` | `x: bool \| None = None` | +| `"string"` | `@Nullable String` | `*string` | `x?: string \| null` | `x: str \| None = None` | +| `"object"` | `@Nullable T` | `*T` | `x?: T \| null` | `x: T \| None = None` | +| `"array"` | `@Nullable List<T>` | `[]T` (nil = absent or null) | `x?: T[] \| null` | `x: list[T] \| None = None` | **Required + nullable** (`null` OK, T OK, absent rejected) — same type, presence enforced by the validator; TS drops the `?`, Python drops the @@ -246,12 +244,12 @@ construction): | `type` token | Java | Go | TypeScript | Python | |---|---|---|---|---| -| `"integer"` | `@Nullable Long` | `*int64` | `x: number \| null` | `x: typing.Optional[int]` | -| `"number"` | `@Nullable Double` | `*float64` | `x: number \| null` | `x: typing.Optional[float]` | -| `"boolean"` | `@Nullable Boolean` | `*bool` | `x: boolean \| null` | `x: typing.Optional[bool]` | -| `"string"` | `@Nullable String` | `*string` | `x: string \| null` | `x: typing.Optional[str]` | -| `"object"` | `@Nullable T` | `*T` | `x: T \| null` | `x: typing.Optional[T]` | -| `"array"` | `@Nullable List<T>` | `[]T` (nil = null) | `x: T[] \| null` | `x: typing.Optional[list[T]]` | +| `"integer"` | `@Nullable Long` | `*int64` | `x: number \| null` | `x: int \| None` | +| `"number"` | `@Nullable Double` | `*float64` | `x: number \| null` | `x: float \| None` | +| `"boolean"` | `@Nullable Boolean` | `*bool` | `x: boolean \| null` | `x: bool \| None` | +| `"string"` | `@Nullable String` | `*string` | `x: string \| null` | `x: str \| None` | +| `"object"` | `@Nullable T` | `*T` | `x: T \| null` | `x: T \| None` | +| `"array"` | `@Nullable List<T>` | `[]T` (nil = null) | `x: T[] \| null` | `x: list[T] \| None` | (Java is `@Nullable` across every nullable column — the annotation tracks in-memory nullness, not the wire distinction; see the optionality @@ -316,9 +314,9 @@ absent) and **null acceptance** (non-nullable = reject `null`; nullable | State | Java | Go | TS | Python | |---|---|---|---|---| | **Required, non-nullable** — must be present, must be T | type is `long`/`String`/etc.; emit `field == null` reject + type binding | type is `int64`/`string`/etc.; shadow `*T` field, reject on `nil` | type is `x: T`; emit `parsed.x === undefined \|\| parsed.x === null` reject | type is `x: T` with no default; converter rejects an absent key **and** a `null` token with `required` | -| **Optional, non-nullable** — absent OK, T OK, explicit `null` rejected | strict-variant custom deserializer (see strategy below) | shadow `*json.RawMessage` with explicit `bytes.Equal(*raw, []byte("null"))` reject | `parsed.x === null` rejected; `=== undefined` OK | type is `x: typing.Optional[T] = None`; converter branch over the raw dict rejects a key present with `None` (see strategy below) | -| **Optional + nullable** — absent OK, `null` OK, T OK | type is `@Nullable Long`/`String`/etc.; no extra check beyond type binding | type is `*int64`/`*string`/etc.; no extra check beyond type binding | type is `x?: T \| null`; both `undefined` and `null` accepted | type is `x: typing.Optional[T] = None`; both absent and `null` accepted, no extra check | -| **Required + nullable** — must be present, `null` OK, T OK, absent rejected | base (non-strict) deserializer accepts `null`; presence enforced (`field`-present check / required-field machinery) | shadow `*json.RawMessage`; reject on absent (`nil` shadow), accept `null` token | type is `x: T \| null`; emit `parsed.x === undefined` reject; `null` accepted | type is `x: typing.Optional[T]` with **no** default; converter rejects an absent key, accepts the `null` token as `None` | +| **Optional, non-nullable** — absent OK, T OK, explicit `null` rejected | strict-variant custom deserializer (see strategy below) | shadow `*json.RawMessage` with explicit `bytes.Equal(*raw, []byte("null"))` reject | `parsed.x === null` rejected; `=== undefined` OK | type is `x: T \| None = None`; converter branch over the raw dict rejects a key present with `None` (see strategy below) | +| **Optional + nullable** — absent OK, `null` OK, T OK | type is `@Nullable Long`/`String`/etc.; no extra check beyond type binding | type is `*int64`/`*string`/etc.; no extra check beyond type binding | type is `x?: T \| null`; both `undefined` and `null` accepted | type is `x: T \| None = None`; both absent and `null` accepted, no extra check | +| **Required + nullable** — must be present, `null` OK, T OK, absent rejected | base (non-strict) deserializer accepts `null`; presence enforced (`field`-present check / required-field machinery) | shadow `*json.RawMessage`; reject on absent (`nil` shadow), accept `null` token | type is `x: T \| null`; emit `parsed.x === undefined` reject; `null` accepted | type is `x: T \| None` with **no** default; converter rejects an absent key, accepts the `null` token as `None` | ## Serialize-side behavior diff --git a/src/generator/json_schema/python.rs b/src/generator/json_schema/python.rs index e4336edb..fce53c1c 100644 --- a/src/generator/json_schema/python.rs +++ b/src/generator/json_schema/python.rs @@ -640,12 +640,7 @@ pub(in crate::generator) fn render_external_models( set_module_context(json_models)?; - // `typing.Optional` is the intentional public spelling for JSON Schema - // properties, and a default property's setter deliberately accepts `None` - // even though its getter materializes a non-optional value. Keep generated - // modules quiet under basedpyright without weakening any other diagnostic. - let mut body = - String::from("# pyright: reportDeprecated=false, reportPropertyTypeMismatch=false\n"); + let mut body = String::new(); // Module-level constants first: the shared compiled `pattern`/`format` // regexes and the declared-key sets an open object splits its catch-all on. render_pattern_regexes(&mut body, json_models)?; @@ -3090,7 +3085,7 @@ fn render_model_dataclass( output.push_str(&storage_name); output.push_str(": "); if property.default.is_some() { - output.push_str(&model_optional_annotation(&member_type)); + output.push_str(&optional_annotation(&member_type)); output.push_str(" = dataclasses.field(default=None, repr=False)"); } else if let Some(const_value) = &property.const_value { // The only admissible value, so it is the field's default — a @@ -3098,7 +3093,7 @@ fn render_model_dataclass( if required.contains(json_name) { output.push_str(&member_type); } else { - output.push_str(&model_optional_annotation(&member_type)); + output.push_str(&optional_annotation(&member_type)); } output.push_str(" = "); output.push_str(&python_value_literal(const_value)?); @@ -3106,12 +3101,12 @@ fn render_model_dataclass( // Required and nullable keeps the `| None` (an explicit null is // the value) but takes no default: the member must be supplied. if allows_null(property) { - output.push_str(&model_optional_annotation(&member_type)); + output.push_str(&optional_annotation(&member_type)); } else { output.push_str(&member_type); } } else { - output.push_str(&model_optional_annotation(&member_type)); + output.push_str(&optional_annotation(&member_type)); output.push_str(" = None"); } output.push('\n'); @@ -3164,7 +3159,7 @@ fn render_model_init(output: &mut String, schema: &Schema) -> Result<()> { output.push_str(": "); if property.default.is_some() || !required.contains(json_name) || allows_null(property) { - output.push_str(&model_optional_annotation(&member_type)); + output.push_str(&optional_annotation(&member_type)); } else { output.push_str(&member_type); } @@ -3200,7 +3195,7 @@ fn render_model_init(output: &mut String, schema: &Schema) -> Result<()> { } output.push_str(&format!( " _{field_name}: {} = None,\n", - model_optional_annotation(&member_type) + optional_annotation(&member_type), )); } } @@ -3268,8 +3263,8 @@ fn render_default_properties(output: &mut String, schema: &Schema) -> Result<()> python_value_literal(default)? )); output.push_str(&format!( - "\n @{field_name}.setter\n def {field_name}(self, value: {}) -> None:\n self._{field_name} = value\n", - model_optional_annotation(&member_type) + "\n @{field_name}.setter\n def {field_name}(self, value: {}) -> None: # pyright: ignore[reportPropertyTypeMismatch]\n self._{field_name} = value\n", + optional_annotation(&member_type) )); } Ok(()) @@ -4923,17 +4918,6 @@ fn optional_annotation(annotation: &str) -> String { } } -/// Model-property syntax uses `typing.Optional[T]`; converter/helper annotations -/// deliberately retain their existing `T | None` spelling. -fn model_optional_annotation(annotation: &str) -> String { - let members = split_top_level_union(annotation) - .into_iter() - .filter(|member| *member != "None") - .collect::<Vec<_>>(); - let inner = members.join(" | "); - format!("typing.Optional[{inner}]") -} - /// True when the annotation itself already admits `None` — a `None` member of /// the *top-level* union. A nested one does not count: in `list[str | None]` /// the elements are nullable while the list is not, so an optional field of diff --git a/tests/generate_python.rs b/tests/generate_python.rs index 0208f407..09a0d098 100644 --- a/tests/generate_python.rs +++ b/tests/generate_python.rs @@ -668,9 +668,11 @@ fn python_json_example_generation_matches_checked_in_output() { let all = rendered.values().cloned().collect::<Vec<_>>().join("\n"); // A default-bearing property materializes on read while its private // optional storage retains unset state for wire omission. - assert!(all.contains("_greeting: typing.Optional[str]")); + assert!(all.contains("_greeting: str | None")); assert!(all.contains("def greeting(self) -> str:")); - assert!(all.contains("def greeting(self, value: typing.Optional[str])")); + assert!(all.contains( + "def greeting(self, value: str | None) -> None: # pyright: ignore[reportPropertyTypeMismatch]" + )); assert!(!all.contains("DEFAULT_GREETING")); assert!(!all.contains("DEFAULT_DEBUG")); assert!(!all.contains("DEFAULT_RETRIES")); @@ -685,7 +687,7 @@ fn python_json_example_generation_matches_checked_in_output() { assert!(all.contains("\"legacyId\"")); // A free-form object inlines as a mapping as a union branch, and as a // named model with an explicit `additional_properties` catch-all. - assert!(all.contains("payload: typing.Optional[dict[str, typing.Any] | str]")); + assert!(all.contains("payload: dict[str, typing.Any] | str | None")); assert!(all.contains("class Extras:")); assert!( all.contains("additional_properties: dict[str, typing.Any] = dataclasses.field(") @@ -716,7 +718,7 @@ fn python_json_example_generation_matches_checked_in_output() { // The lone inline object branch of a property union derives its name // from the union it belongs to. assert!(all.contains("class ShowcaseDetailObject:")); - assert!(all.contains("detail: typing.Optional[ShowcaseDetailObject | str]")); + assert!(all.contains("detail: ShowcaseDetailObject | str | None")); assert!(all.contains("must have at most 4 properties")); } fs::remove_dir_all(output_path).unwrap(); @@ -1221,7 +1223,7 @@ fn python_json_names_inline_object_union_branch() { .unwrap(); let rendered = fs::read_to_string(output_path.join("models.py")).unwrap(); - assert!(rendered.contains("payload: typing.Optional[DetailPayloadObject | str]")); + assert!(rendered.contains("payload: DetailPayloadObject | str | None")); assert!(rendered.contains("class DetailPayloadObject:")); assert!(rendered.contains("class _DetailPayloadObjectTransferTypeConverter(")); assert!(rendered.contains("text: str")); @@ -1260,12 +1262,10 @@ fn python_json_validates_non_object_union_branch_constraints() { // The string branch's `minLength`/`pattern` and the integer branch's // `minimum` leave no residue on the annotation: it is the plain branch union. - assert!(rendered.contains("value: typing.Optional[str | int]")); + assert!(rendered.contains("value: str | int | None")); // Same for the array branch's `minItems`/`uniqueItems`; a closed value set // still narrows to a `typing.Literal`. - assert!( - rendered.contains("typing.Optional[list[float] | typing.Literal[\"auto\", \"manual\"]]") - ); + assert!(rendered.contains("list[float] | typing.Literal[\"auto\", \"manual\"] | None")); // The branch checks themselves live in the converter body: a `pattern` lowers // to a `.search` against a module-level compiled regex const, `uniqueItems` to // a runtime helper imported from the definitions module. @@ -1371,9 +1371,9 @@ fn python_json_annotates_element_position_unions() { let rendered = fs::read_to_string(output_path.join("models.py")).unwrap(); assert!(rendered.contains("BagSegmentsItem: typing.TypeAlias = str | int")); - assert!(rendered.contains("segments: typing.Optional[list[BagSegmentsItem]]")); - assert!(rendered.contains("choices: typing.Optional[list[Choice]]")); - assert!(rendered.contains("slots: typing.Optional[list[str | None]]")); + assert!(rendered.contains("segments: list[BagSegmentsItem] | None")); + assert!(rendered.contains("choices: list[Choice] | None")); + assert!(rendered.contains("slots: list[str | None] | None")); let exports = fs::read_to_string(output_path.join("__init__.py")).unwrap(); assert!(exports.contains("BagSegmentsItem")); fs::remove_dir_all(temp_dir).unwrap(); @@ -1473,7 +1473,7 @@ fn python_json_cross_module_py_name_override_moves_every_reference() { let models = fs::read_to_string(output_path.join("kb/models.py")).unwrap(); for expected in [ "from ..content.page.models import RenamedPage", - " page: typing.Optional[RenamedPage]", + " page: RenamedPage | None", ] { assert!(models.contains(expected), "{expected}\n{models}"); } @@ -1703,7 +1703,7 @@ fn python_json_property_names_never_shadow_converter_locals() { } #[test] -fn python_json_model_properties_use_optional_and_defaults_preserve_presence() { +fn python_json_model_properties_use_union_none_and_defaults_preserve_presence() { let temp_dir = unique_output_path("py-json-dataclass-default"); fs::create_dir_all(&temp_dir).unwrap(); let input_path = temp_dir.join("model.yaml"); @@ -1725,18 +1725,20 @@ fn python_json_model_properties_use_optional_and_defaults_preserve_presence() { let rendered = fs::read_to_string(output_path.join("models.py")).unwrap(); assert!(rendered.contains("required_plain: str")); - assert!(rendered.contains("required_nullable: typing.Optional[int]")); - assert!(rendered.contains("optional_plain: typing.Optional[bool] = None")); - assert!(rendered.contains("optional_nullable: typing.Optional[str] = None")); - assert!(rendered.contains("nullable_items: typing.Optional[list[str | None]] = None")); - assert!(rendered.contains( - "_salutation: typing.Optional[typing.Annotated[str, typing_extensions.deprecated" - )); + assert!(rendered.contains("required_nullable: int | None")); + assert!(rendered.contains("optional_plain: bool | None = None")); + assert!(rendered.contains("optional_nullable: str | None = None")); + assert!(rendered.contains("nullable_items: list[str | None] | None = None")); + assert!(rendered.contains("_salutation: typing.Annotated[str, typing_extensions.deprecated")); assert!(rendered.contains("def salutation(self) -> typing.Annotated[str,")); - assert!(rendered.contains("value: typing.Optional[typing.Annotated[")); + assert!(rendered.contains("value: typing.Annotated[")); assert!(rendered.contains("if value._salutation is not None:")); assert!(!rendered.contains("DEFAULT_SALUTATION")); - // Converter/helper annotations intentionally retain the compact union style. + assert!(!rendered.contains("reportDeprecated=false")); + assert!(!rendered.contains("reportPropertyTypeMismatch=false")); + assert!(!rendered.contains("reportDeprecated")); + assert!(rendered.contains("# pyright: ignore[reportPropertyTypeMismatch]")); + // Converter/helper annotations use the same compact union style. assert!(rendered.contains("optional_plain_value: bool | None = None")); assert!(rendered.contains("nullable_items_value: list[str | None] | None = None")); From f804328ee086634f646bd4cb12bc67b1c4574398 Mon Sep 17 00:00:00 2001 From: Roey Berman <roey.berman@gmail.com> Date: Mon, 17 Aug 2026 08:35:58 -0700 Subject: [PATCH 15/20] Python: clear default properties with deleters --- CHANGELOG.md | 5 +++-- .../python/json_schema/api/chat/models.py | 6 +++++- .../python/json_schema/api/showcase/models.py | 18 +++++++++++++++--- samples/python/chat/models.py | 6 +++++- samples/python/showcase/models.py | 18 +++++++++++++++--- samples/python/tests/test_chat.py | 4 ++-- specs/json-schema/PRINCIPLES.md | 2 +- specs/json-schema/features/default.md | 2 +- src/generator/json_schema/python.rs | 6 ++++-- tests/generate_python.rs | 10 +++++----- 10 files changed, 56 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 25292931..ff8a4797 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,8 +46,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 properties backed by private `T | None` fields: reads materialize the schema default, while converters preserve unset state and omit it from the wire. The public keyword constructor remains compatible, explicit values - (including the default itself) remain present on the wire, assigning `None` resets - the field to unset, and Python no longer emits module-level `DEFAULT_*` constants. + (including the default itself) remain present on the wire, deleting the property + resets the field to unset, and Python no longer emits module-level `DEFAULT_*` + constants. - Protobuf-backed models now consistently generate conversions in both directions whenever they are reachable. Go and TypeScript emit previously suppressed complementary helpers, operation-free exported models receive the diff --git a/advanced/samples/python/json_schema/api/chat/models.py b/advanced/samples/python/json_schema/api/chat/models.py index 5687120e..7a10a675 100644 --- a/advanced/samples/python/json_schema/api/chat/models.py +++ b/advanced/samples/python/json_schema/api/chat/models.py @@ -251,9 +251,13 @@ def priority(self) -> int: return self._priority if self._priority is not None else 0 @priority.setter - def priority(self, value: int | None) -> None: # pyright: ignore[reportPropertyTypeMismatch] + def priority(self, value: int) -> None: self._priority = value + @priority.deleter + def priority(self) -> None: + self._priority = None + class _RoomTransferTypeConverter( temporalio.converter.TransferTypeConverter["Room", typing.Any] diff --git a/advanced/samples/python/json_schema/api/showcase/models.py b/advanced/samples/python/json_schema/api/showcase/models.py index 1f309e97..5aca212d 100644 --- a/advanced/samples/python/json_schema/api/showcase/models.py +++ b/advanced/samples/python/json_schema/api/showcase/models.py @@ -3247,9 +3247,13 @@ def retries(self) -> int: return self._retries if self._retries is not None else 3 @retries.setter - def retries(self, value: int | None) -> None: # pyright: ignore[reportPropertyTypeMismatch] + def retries(self, value: int) -> None: self._retries = value + @retries.deleter + def retries(self) -> None: + self._retries = None + @property def greeting(self) -> str: """Greeting @@ -3258,9 +3262,13 @@ def greeting(self) -> str: return self._greeting if self._greeting is not None else "hello" @greeting.setter - def greeting(self, value: str | None) -> None: # pyright: ignore[reportPropertyTypeMismatch] + def greeting(self, value: str) -> None: self._greeting = value + @greeting.deleter + def greeting(self) -> None: + self._greeting = None + @property def debug(self) -> bool: """Debug flag @@ -3269,9 +3277,13 @@ def debug(self) -> bool: return self._debug if self._debug is not None else False @debug.setter - def debug(self, value: bool | None) -> None: # pyright: ignore[reportPropertyTypeMismatch] + def debug(self, value: bool) -> None: self._debug = value + @debug.deleter + def debug(self) -> None: + self._debug = None + class _ShowcaseAuditTransferTypeConverter( temporalio.converter.TransferTypeConverter["ShowcaseAudit", typing.Any] diff --git a/samples/python/chat/models.py b/samples/python/chat/models.py index 5687120e..7a10a675 100644 --- a/samples/python/chat/models.py +++ b/samples/python/chat/models.py @@ -251,9 +251,13 @@ def priority(self) -> int: return self._priority if self._priority is not None else 0 @priority.setter - def priority(self, value: int | None) -> None: # pyright: ignore[reportPropertyTypeMismatch] + def priority(self, value: int) -> None: self._priority = value + @priority.deleter + def priority(self) -> None: + self._priority = None + class _RoomTransferTypeConverter( temporalio.converter.TransferTypeConverter["Room", typing.Any] diff --git a/samples/python/showcase/models.py b/samples/python/showcase/models.py index 1f309e97..5aca212d 100644 --- a/samples/python/showcase/models.py +++ b/samples/python/showcase/models.py @@ -3247,9 +3247,13 @@ def retries(self) -> int: return self._retries if self._retries is not None else 3 @retries.setter - def retries(self, value: int | None) -> None: # pyright: ignore[reportPropertyTypeMismatch] + def retries(self, value: int) -> None: self._retries = value + @retries.deleter + def retries(self) -> None: + self._retries = None + @property def greeting(self) -> str: """Greeting @@ -3258,9 +3262,13 @@ def greeting(self) -> str: return self._greeting if self._greeting is not None else "hello" @greeting.setter - def greeting(self, value: str | None) -> None: # pyright: ignore[reportPropertyTypeMismatch] + def greeting(self, value: str) -> None: self._greeting = value + @greeting.deleter + def greeting(self) -> None: + self._greeting = None + @property def debug(self) -> bool: """Debug flag @@ -3269,9 +3277,13 @@ def debug(self) -> bool: return self._debug if self._debug is not None else False @debug.setter - def debug(self, value: bool | None) -> None: # pyright: ignore[reportPropertyTypeMismatch] + def debug(self, value: bool) -> None: self._debug = value + @debug.deleter + def debug(self) -> None: + self._debug = None + class _ShowcaseAuditTransferTypeConverter( temporalio.converter.TransferTypeConverter["ShowcaseAudit", typing.Any] diff --git a/samples/python/tests/test_chat.py b/samples/python/tests/test_chat.py index f791cc35..5722b4e4 100644 --- a/samples/python/tests/test_chat.py +++ b/samples/python/tests/test_chat.py @@ -109,8 +109,8 @@ def test_serialize_omits_unset_defaulted_members() -> None: # Explicitly assigning the schema default still marks the property present. unset.priority = 0 assert converter.to_transfer_type(unset)["priority"] == 0 - # Assigning None restores the unset state without changing the read value. - unset.priority = None + # Deleting the property restores the unset state without changing the read value. + del unset.priority assert unset.priority == 0 assert "priority" not in converter.to_transfer_type(unset) # A `const` member, unlike a `default`, DOES carry its value as the dataclass diff --git a/specs/json-schema/PRINCIPLES.md b/specs/json-schema/PRINCIPLES.md index 11031a9f..ba74c60e 100644 --- a/specs/json-schema/PRINCIPLES.md +++ b/specs/json-schema/PRINCIPLES.md @@ -70,7 +70,7 @@ private `_<field>` slot in the same class namespace as declared members. A collision rejects at load time, and `x-py-name` moves the public property and its backing slot together. Python emits no module-level `DEFAULT_*` identifier. -1. **Models emit `@dataclasses.dataclass`es, not a validating model base (P2/P4).** Every model is a slotted, keyword-only dataclass with **no validation on construction**. Required non-nullable property annotations are bare `T`; optional or nullable property annotations use `T | None`. A model with a schema-defaulted property uses `init=False` plus a generated keyword-only `__init__`: the public constructor keyword initializes a private `_<field>: T | None` presence slot, while a mutable same-name property returns either that raw value or the scalar schema default. Its setter accepts `T | None`, and assigning `None` restores the unset state. The private slot is excluded from `repr` but remains a dataclass comparison field, preserving raw presence/value equality. Open-model constructors initialize an omitted `additional_properties` to a fresh dictionary. Other models use `@dataclasses.dataclass(slots=True, kw_only=True)` directly. Field annotations remain plain Python types (`int`, `str`, `datetime.datetime`, `datetime.timedelta`, `bytes`, `typing.Literal[...]`, `list[T]`, `dict[str, V]`), never annotated validator aliases, so the class reads like hand-written Python (P2) and the runtime dependency set stays at the SDKs alone (P4). Conversion and validation live *off* the model, in a companion transfer-type converter (§3), which gives serialize-side validation real teeth (P12). +1. **Models emit `@dataclasses.dataclass`es, not a validating model base (P2/P4).** Every model is a slotted, keyword-only dataclass with **no validation on construction**. Required non-nullable property annotations are bare `T`; optional or nullable property annotations use `T | None`. A model with a schema-defaulted property uses `init=False` plus a generated keyword-only `__init__`: the public constructor keyword initializes a private `_<field>: T | None` presence slot, while a mutable same-name property returns either that raw value or the scalar schema default. Its setter accepts `T`, and its deleter restores the unset state. The private slot is excluded from `repr` but remains a dataclass comparison field, preserving raw presence/value equality. Open-model constructors initialize an omitted `additional_properties` to a fresh dictionary. Other models use `@dataclasses.dataclass(slots=True, kw_only=True)` directly. Field annotations remain plain Python types (`int`, `str`, `datetime.datetime`, `datetime.timedelta`, `bytes`, `typing.Literal[...]`, `list[T]`, `dict[str, V]`), never annotated validator aliases, so the class reads like hand-written Python (P2) and the runtime dependency set stays at the SDKs alone (P4). Conversion and validation live *off* the model, in a companion transfer-type converter (§3), which gives serialize-side validation real teeth (P12). 2. **Aggregate via a single generated `ValidationError` (extends `Exception`) holding `list[Violation]` (P11).** Collect every `Violation { path, reason }` — a `@dataclasses.dataclass(frozen=True, slots=True)` in the shared `definitions` module — into one list and raise **one** generated `ValidationError`, whose `str()` enumerates every violation and whose `violations` attribute exposes them structured. This is the same primitive Go, TypeScript and Java surface, with the same structured `{path, reason}` shape — so all four targets now report a rejection the same way (P11), and the cross-language guarantee is the accepted-and-rejected value set (P1), not byte-identical message text. Python's `reason` strings mirror **TypeScript's** verbatim, Python being the same design. It is never stringly-typed and never a language-native exception group. A nested value's `ValidationError` is re-pathed under the enclosing field by a `_collect(violations, path, error)` runtime helper and merged into the parent's list, so one payload yields one flat, fully-pathed violation set — the analogue of TypeScript's `collect`. 3. **A companion `_<Model>TransferTypeConverter` converts model ⇄ intermediate and validates; the *default* Temporal converter finds it through the SDK's transfer-type hook (P12/P3).** Each model gets a private converter class — `class _UserTransferTypeConverter(temporalio.converter.TransferTypeConverter["User", typing.Any])` with `from_transfer_type(value: typing.Any, type_hint: type[User]) -> User` as the parse adapter (untrusted JSON value → model) and `to_transfer_type(value: User) -> typing.Any` as the encode adapter (model → plain JSON value) — attached to the class by `@_transfer_type_convertible(_UserTransferTypeConverter)`, the runtime module's one-line shim over `temporalio.converter.transfer_type_convertible` that erases the converter's value-type parameter (binding it on the decorated class is circular for a static type checker: the class's type depends on the decorator, whose value type depends on the class). Both directions run the same emitted checks, collecting `Violation`s into one `ValidationError` (§2), so validation lives *inside* the conversion — that is what makes them the single source of truth in both directions. The intermediate is a plain `dict`/`list`/scalar, never a `str`: the byte-level JSON encode/decode is the Temporal payload converter's boundary, which hands the transfer-type converter the parsed (or about-to-be-encoded) value. Working in intermediate values (not strings) is also what makes conversions **composable** — a parent's `to_transfer_type` calls its children's on nested values and embeds the results, `from_transfer_type` likewise; a `str` could not nest. That composition is load-bearing rather than stylistic: the SDK hooks only the **top-level** value, so a nested model is always converted by its parent's body. Registration is the whole of the wiring — the stock `DataConverter.default` consults the hook, so generated models need no contrib package and no user setup (P3). A `typing.TypeAlias` cannot be decorated, so a named or inline `oneOf` union is served by module-private free functions (`_<name>_from_transfer_type` / `_<name>_to_transfer_type`) instead of a converter class; unions can only appear nested, so nothing is lost. The per-field omit-vs-`null` decision follows the [[nullability]] serialize table. See [[nullability]], [[const]], [[default]]. diff --git a/specs/json-schema/features/default.md b/specs/json-schema/features/default.md index c22d2a3b..020a0412 100644 --- a/specs/json-schema/features/default.md +++ b/specs/json-schema/features/default.md @@ -124,7 +124,7 @@ which differ per language: | Language | Set-ness signal (omit-unset) | Read-side surfacing of the default | |---|---|---| -| Python | private `_<field>: T | None` | **native property** — `@property def field(self) -> T` returns the private value when set and the scalar default otherwise. A setter accepts `T | None`; assigning `None` restores unset. Models with defaults receive a generated keyword-only constructor so `Model(field=...)` remains the public construction API. | +| Python | private `_<field>: T | None` | **native property** — `@property def field(self) -> T` returns the private value when set and the scalar default otherwise. Its setter accepts `T`; `del model.field` invokes a property deleter that restores unset. Models with defaults receive a generated keyword-only constructor so `Model(field=...)` remains the public construction API. | | Java | `null` field + `@JsonInclude(NON_NULL)` | **native** — the generated **getter** returns the default when the backing field is `null` (`return nickname != null ? nickname : "anon";`). Getters already exist in the POJO design (PRINCIPLES Java §1). | | TypeScript | `undefined` (the `?` field) | **advisory** — interfaces have no methods (PRINCIPLES TS §2), so the consumer applies the default with the native `?? DEFAULT_X`; the generator emits `export const DEFAULT_X = "anon"`. No accessor needed; `??` is the idiom. | | Go | `*T` `nil` + `,omitempty` | **generated accessor** — a `func (m M) <Field>OrDefault() T` returns `*m.Field` when set and the default literal when `nil` (`func (u User) NicknameOrDefault() string { if u.Nickname != nil { return *u.Nickname }; return "anon" }`). The bare field stays `*T` (set-ness intact); the accessor is the materialize-on-read path. Emitted **only** for default-bearing fields. Modeled on proto3's `GetX()` — the same omit-default-on-wire + accessor-materializes-default pattern already familiar to Temporal users. Named `<Field>OrDefault` rather than `Get<Field>` to read as "the value, or its default" and to avoid implying a getter on every field. Alternative approaches considered: (a) advisory constant (`DEFAULT_X` + caller nil-checks) — pushes nil-checks to every call site; (b) populate on deserialize — destroys set-ness, forces deep-equals, breaks P9. | diff --git a/src/generator/json_schema/python.rs b/src/generator/json_schema/python.rs index fce53c1c..f55c0445 100644 --- a/src/generator/json_schema/python.rs +++ b/src/generator/json_schema/python.rs @@ -3263,8 +3263,10 @@ fn render_default_properties(output: &mut String, schema: &Schema) -> Result<()> python_value_literal(default)? )); output.push_str(&format!( - "\n @{field_name}.setter\n def {field_name}(self, value: {}) -> None: # pyright: ignore[reportPropertyTypeMismatch]\n self._{field_name} = value\n", - optional_annotation(&member_type) + "\n @{field_name}.setter\n def {field_name}(self, value: {member_type}) -> None:\n self._{field_name} = value\n" + )); + output.push_str(&format!( + "\n @{field_name}.deleter\n def {field_name}(self) -> None:\n self._{field_name} = None\n" )); } Ok(()) diff --git a/tests/generate_python.rs b/tests/generate_python.rs index 09a0d098..7004001b 100644 --- a/tests/generate_python.rs +++ b/tests/generate_python.rs @@ -337,7 +337,7 @@ assert explicit_default != unset unset.salutation = "bye" assert unset.salutation == "bye" assert converter.to_transfer_type(unset)["greeting"] == "bye" -unset.salutation = None +del unset.salutation assert unset.salutation == "hello" assert "greeting" not in converter.to_transfer_type(unset) assert unset == other @@ -670,9 +670,8 @@ fn python_json_example_generation_matches_checked_in_output() { // optional storage retains unset state for wire omission. assert!(all.contains("_greeting: str | None")); assert!(all.contains("def greeting(self) -> str:")); - assert!(all.contains( - "def greeting(self, value: str | None) -> None: # pyright: ignore[reportPropertyTypeMismatch]" - )); + assert!(all.contains("def greeting(self, value: str) -> None:")); + assert!(all.contains("@greeting.deleter\n def greeting(self) -> None:")); assert!(!all.contains("DEFAULT_GREETING")); assert!(!all.contains("DEFAULT_DEBUG")); assert!(!all.contains("DEFAULT_RETRIES")); @@ -1732,12 +1731,13 @@ fn python_json_model_properties_use_union_none_and_defaults_preserve_presence() assert!(rendered.contains("_salutation: typing.Annotated[str, typing_extensions.deprecated")); assert!(rendered.contains("def salutation(self) -> typing.Annotated[str,")); assert!(rendered.contains("value: typing.Annotated[")); + assert!(rendered.contains("@salutation.deleter\n def salutation(self) -> None:")); assert!(rendered.contains("if value._salutation is not None:")); assert!(!rendered.contains("DEFAULT_SALUTATION")); assert!(!rendered.contains("reportDeprecated=false")); assert!(!rendered.contains("reportPropertyTypeMismatch=false")); assert!(!rendered.contains("reportDeprecated")); - assert!(rendered.contains("# pyright: ignore[reportPropertyTypeMismatch]")); + assert!(!rendered.contains("reportPropertyTypeMismatch")); // Converter/helper annotations use the same compact union style. assert!(rendered.contains("optional_plain_value: bool | None = None")); assert!(rendered.contains("nullable_items_value: list[str | None] | None = None")); From ff22b8e3b433fca236c39ea60dfbb1f668ddb039 Mon Sep 17 00:00:00 2001 From: Roey Berman <roey.berman@gmail.com> Date: Mon, 17 Aug 2026 08:39:59 -0700 Subject: [PATCH 16/20] Compact the branch changelog --- CHANGELOG.md | 124 +++++++++------------------------------------------ 1 file changed, 20 insertions(+), 104 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ff8a4797..9064f3c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,62 +42,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- Python: JSON Schema default-bearing properties now expose mutable same-name - properties backed by private `T | None` fields: reads - materialize the schema default, while converters preserve unset state and omit it - from the wire. The public keyword constructor remains compatible, explicit values - (including the default itself) remain present on the wire, deleting the property - resets the field to unset, and Python no longer emits module-level `DEFAULT_*` - constants. +- Python: JSON Schema output now uses slotted, keyword-only dataclasses and the + default Temporal converter instead of Pydantic. Generated transfer converters + preserve wire names and unknown fields in `additional_properties`, aggregate + structured validation errors, collapse absent and explicit-null optional values + to `None`, and surface schema defaults through mutable properties (`del field` + restores unset) rather than `DEFAULT_*` constants. - Protobuf-backed models now consistently generate conversions in both directions whenever they are reachable. Go and TypeScript emit previously suppressed complementary helpers, operation-free exported models receive the same validation as operation-used models, and Java now reports its existing lack of protobuf model support instead of silently dropping protobuf operation types. -- Python: Generated JSON-Schema models are now plain - `@dataclasses.dataclass(slots=True, kw_only=True)` types instead of - `pydantic.BaseModel`s. Each model carries a generated transfer type converter - registered with `temporalio.converter.transfer_type_convertible`, so the models - work with the **default** Temporal data converter — the - `temporalio.contrib.pydantic.pydantic_data_converter` wiring is no longer - needed, and `pydantic` is no longer a dependency of generated code. Every field - is keyword-only, and the wire name of a member is pinned by the converter - rather than by a `Field(alias=...)`. -- Python: `additionalProperties` is now carried by an explicit - `additional_properties: dict[str, V]` member instead of Pydantic's - `model_extra` bag, both for an open declared-property object and for a - map-shaped model. This matches Go, TypeScript, and Java, and keeps the emitted - type's kind stable if `properties` are added to that schema later. Read - `model.additional_properties` where `model.model_extra` was read, and construct - a map-shaped model as `Labels(additional_properties={...})`. -- Python: Validation errors are now a generated `ValidationError` over - `Violation { path, reason }` — the same structured, aggregating error Go, - TypeScript, and Java already surface — instead of `pydantic.ValidationError`. - One bad payload reports every violation it contains, with the JSON path of each - and a reason naming the concrete bound and the offending value. Both types live - in the package's `_definitions` module and are not re-exported through - `__init__.py`, so catching the aggregating error takes - `from <package>._definitions import ValidationError` — Python is the only - target that reaches its error type through a private name (Go's is exported - from the one flat package, Java's is `public`, and the TypeScript root barrel - re-exports it). -- Python: An **optional and nullable** member now collapses on round-trip, as it - already does in Go and Java. A dataclass has no presence channel, so an absent - member and an explicit wire `null` read as the same `None`, and both - re-serialize as *omitted*. The set of accepted and rejected values is - unchanged — only the byte-identity of an explicit `null` on the way back out. - JSON Schema: An `x-<lang>-name` alongside a `$ref` is no longer merged as an implicit-`allOf` conjunct, which cloned the referenced target into the use site. It names the _member_ the reference is bound to and leaves the reference intact — the one sibling keyword treated this way, because it asserts nothing about the value, and the only way to rename a member whose type is a `$ref` (a member named `class` was otherwise unfixable in Python and Java). -- TypeScript: JSON models now export companion `TransferTypeConverter` - instances with `fromTransferType`/`toTransferType`, replacing the previous - mapper classes and intermediate-value terminology. -- TypeScript: JSON Schema operations now attach their model converters as - `inputType`/`outputType` metadata. WIT-generated operations are unchanged. +- TypeScript: JSON Schema models now export `TransferTypeConverter` instances + (`fromTransferType`/`toTransferType`), and generated operations reference them + through `inputType`/`outputType`. Converter names follow resolved model names, + participate in collision checks, and require the nexus-rpc type-info API. - Generating into an existing `--output` directory no longer deletes it first. The directory is written into instead, so pre-existing files and subdirectories are preserved; generated files are still overwritten in place. @@ -147,64 +113,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- JSON Schema: Modules that own no types now import foreign `$ref` targets - without re-emitting duplicate declarations. -- JSON Schema: Identifier collisions now use each target's actual emitted - namespace, including Go's flat package and the TypeScript/Python root barrels. - TypeScript service constants are checked under their emitted lower-camel names. -- JSON Schema: Member-derived synthesized names now follow `x-<lang>-name` - overrides, including TypeScript default constants and Go closed-value types. -- JSON Schema: A root model can no longer silently collapse with a same-named - `$defs` or synthesized model; the loader reports the conflicting origins. -- TypeScript: A **`string` array element's own constraints are now enforced**. - An element schema of `type: string` took a bare `typeof` check, so an - `items: { type: string, minLength: 3, pattern: "^[a-z]+$" }` array accepted - `["a"]` and `["A"]` — payloads Go, Python and Java all reject — and the - element's compiled pattern constant was emitted but never referenced. Every - element kind now takes the same parse the value in that position takes - anywhere else, so `minLength`, `maxLength`, `pattern` and `format` fire at the - element's own index (`codes[0]`, `must have length >= 3, got 1`). -- TypeScript: A mistyped **array element** now reports the type it failed to be - (`tags[0]`, `expected string`), as an element of every other type already did - and as Python and Java report. A `string` element reported a bare - `expected element`, which named neither the expected type nor anything the - element's own indexed path did not already carry. -- Python: A `const`/`enum` check now tests membership in a tuple of the - admissible values (`if value not in (True,)`) in both directions, where the - parse side chained one `!=` per member. A boolean `const` emitted - `value != True`, which is a lint error in the user's repository (ruff E712) - and reads nothing like hand-written Python, and a multi-member `enum` emitted - one comparison per member. The parse and serialize sides now share one - membership shape. -- Python: A mistyped **array element** now reports the type it failed to be - (`tags[0]`, `expected string`), as an element of every other type already did - and as Java reports. A plain `string` element reported a bare - `expected element`, which named neither the expected type nor anything the - element's own indexed path did not already carry. -- Python: A declared property named after one of the converter's own locals - **silently disabled validation**. A property named `violations` rebound the - violation accumulator, so a payload that broke a constraint was returned as a - model instead of raising; ten other names (`raw`, `len`, `int`, `str`, `bool`, - `dict`, `isinstance`, `typing`, `math`, `out`) crashed the converter on every - payload. The parse body now holds each property's value in a `<member>_value` - slot local, which cannot coincide with a runtime local, a builtin, an imported - module, or a synthesized module-level name — so no property name can shadow - anything (P15). -- Python: The module-level names the generator synthesizes beyond - `DEFAULT_<FIELD>` — the `_<MODEL>_DECLARED` declared-key sets, the union - `_<base>_{from,to}_transfer_type` functions, the `_<Model>TransferTypeConverter` - classes, and the `_PATTERN_<HEX>` compiled regexes — now participate in the P15 - collision pass. Two types whose `x-py-name` overrides differ only in case - (`ContactPy` / `ContactPY`) previously emitted one `_CONTACT_PY_DECLARED` for - both, and the loser's declared properties leaked into its catch-all; such a - schema is now rejected at load with a fix-it diagnostic. An inline union's - functions are also named from the member's *emitted* identifier, so an - `x-py-name` override moves them. -- JSON Schema: `_definitions` is now a reserved input-module name alongside - `definitions`. Python emits its shared runtime as `_definitions.py`, so an input - named `_definitions.yaml` emitted a `_definitions/` package directory at that - module's own import path — shadowing it and breaking every generated - `from .._definitions import ...`. +- JSON Schema: Cross-input emission and naming now follow each target's actual + scope and `x-<lang>-name` overrides. Foreign types are imported rather than + duplicated, empty TypeScript model modules are omitted, member-derived names + stay aligned, and root/`$defs`/synthesized collisions fail at load time. +- TypeScript: String array elements now enforce their own constraints and report + type errors at the indexed element path. +- Python: Closed-value checks now use tuple membership, array-element errors name + the expected type, converter locals cannot be shadowed by properties, and all + synthesized module names participate in collision checks. `_definitions` is + reserved for the generated runtime module. - JSON Schema: A **non-object `oneOf` branch's own constraints** were dropped in three of four languages: only Go carried them, in the synthesized `<Union><Kind>` variant's `Validate`. TypeScript cast the narrowed value @@ -301,8 +219,6 @@ array"` at runtime, though `items.md` accepts them. Both now decode elementwise, a time.") added that package to the import block, and an unused import is a Go compile error. Package use is now read off the emitted code, not the doc comments. -- JSON Schema: Cross-file `$ref` and operation references now honor the target - model's `x-<lang>-name` override. - JSON Schema: A `oneOf` with an inline object branch generated uncompilable Go (a marker method on an undeclared `<Union>Object` type) and uncompilable TypeScript (a converter named after the anonymous `Record<string, unknown>` From 007b5d7f132470edb802140e8939b34189b3d022 Mon Sep 17 00:00:00 2001 From: Roey Berman <roey.berman@gmail.com> Date: Mon, 17 Aug 2026 09:22:59 -0700 Subject: [PATCH 17/20] Export Python validation errors from packages --- CHANGELOG.md | 3 + .../python/json_schema/api/chat/__init__.py | 6 ++ .../python/json_schema/api/kb/__init__.py | 6 ++ .../json_schema/api/showcase/__init__.py | 6 ++ .../json_schema/api/temporal/__init__.py | 9 ++- samples/python/chat/__init__.py | 6 ++ samples/python/kb/__init__.py | 6 ++ samples/python/showcase/__init__.py | 6 ++ samples/python/temporal/__init__.py | 6 ++ samples/python/tests/test_chat.py | 2 +- samples/python/tests/test_kb.py | 2 +- samples/python/tests/test_showcase.py | 10 +++- samples/python/tests/test_temporal.py | 3 +- specs/json-schema/PRINCIPLES.md | 2 +- src/generator/python.rs | 55 ++++++++++++++++++- src/parser/json_schema.rs | 3 +- tests/generate_python.rs | 23 ++++++++ 17 files changed, 143 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9064f3c5..d6181395 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - WIT signal-with-start request models now carry Temporal headers while keeping them out of generated convenience operation APIs. +- Python JSON Schema packages now export `ValidationError` and `Violation` + from their root `__init__.py`; callers no longer need to import the private + `_definitions` module. - Added grouped protobuf `oneof` authoring and bidirectional Python conversion, including required and optional oneofs, scaffolding through `add-rpc` and `add-message`, and explicit diagnostics for unsupported target backends. diff --git a/advanced/samples/python/json_schema/api/chat/__init__.py b/advanced/samples/python/json_schema/api/chat/__init__.py index c464b580..0a83bde6 100644 --- a/advanced/samples/python/json_schema/api/chat/__init__.py +++ b/advanced/samples/python/json_schema/api/chat/__init__.py @@ -2,6 +2,10 @@ from __future__ import annotations +from ._definitions import ( + ValidationError, + Violation, +) import collections.abc import typing @@ -11,6 +15,8 @@ from . import services as _services __all__ = [ + "ValidationError", + "Violation", "ChatServiceClient", ] diff --git a/advanced/samples/python/json_schema/api/kb/__init__.py b/advanced/samples/python/json_schema/api/kb/__init__.py index a3320ba4..1229a130 100644 --- a/advanced/samples/python/json_schema/api/kb/__init__.py +++ b/advanced/samples/python/json_schema/api/kb/__init__.py @@ -1,5 +1,9 @@ # Generated by nexgen. DO NOT EDIT! +from ._definitions import ( + ValidationError, + Violation, +) from .content import ( BlockStyle, PageMeta, @@ -31,4 +35,6 @@ "PageMeta", "Palette", "PutBlockOutput", + "ValidationError", + "Violation", ] diff --git a/advanced/samples/python/json_schema/api/showcase/__init__.py b/advanced/samples/python/json_schema/api/showcase/__init__.py index 7422cae6..062b9a9e 100644 --- a/advanced/samples/python/json_schema/api/showcase/__init__.py +++ b/advanced/samples/python/json_schema/api/showcase/__init__.py @@ -2,6 +2,10 @@ from __future__ import annotations +from ._definitions import ( + ValidationError, + Violation, +) import collections.abc import typing @@ -11,6 +15,8 @@ from . import services as _services __all__ = [ + "ValidationError", + "Violation", "ShowcaseServicePyClient", ] diff --git a/advanced/samples/python/json_schema/api/temporal/__init__.py b/advanced/samples/python/json_schema/api/temporal/__init__.py index e1288b9e..b885e9d9 100644 --- a/advanced/samples/python/json_schema/api/temporal/__init__.py +++ b/advanced/samples/python/json_schema/api/temporal/__init__.py @@ -2,5 +2,12 @@ from __future__ import annotations +from ._definitions import ( + ValidationError, + Violation, +) -__all__ = [] +__all__ = [ + "ValidationError", + "Violation", +] diff --git a/samples/python/chat/__init__.py b/samples/python/chat/__init__.py index 27ec5377..5e0ee532 100644 --- a/samples/python/chat/__init__.py +++ b/samples/python/chat/__init__.py @@ -2,6 +2,10 @@ from __future__ import annotations +from ._definitions import ( + ValidationError, + Violation, +) from .models import ( GetRoomInput, Labels, @@ -13,6 +17,8 @@ from .services import ChatService __all__ = [ + "ValidationError", + "Violation", "GetRoomInput", "Labels", "Message", diff --git a/samples/python/kb/__init__.py b/samples/python/kb/__init__.py index 31b9b4e5..42131351 100644 --- a/samples/python/kb/__init__.py +++ b/samples/python/kb/__init__.py @@ -1,5 +1,9 @@ # Generated by nexgen. DO NOT EDIT! +from ._definitions import ( + ValidationError, + Violation, +) from .content import ( BlockStyle, PageMeta, @@ -31,4 +35,6 @@ "PageMeta", "Palette", "PutBlockOutput", + "ValidationError", + "Violation", ] diff --git a/samples/python/showcase/__init__.py b/samples/python/showcase/__init__.py index 005c209f..10e82016 100644 --- a/samples/python/showcase/__init__.py +++ b/samples/python/showcase/__init__.py @@ -2,6 +2,10 @@ from __future__ import annotations +from ._definitions import ( + ValidationError, + Violation, +) from .models import ( Address, Attributes, @@ -37,6 +41,8 @@ from .services import ShowcaseServicePy __all__ = [ + "ValidationError", + "Violation", "Address", "Attributes", "Choices", diff --git a/samples/python/temporal/__init__.py b/samples/python/temporal/__init__.py index b4503914..acc0a9f8 100644 --- a/samples/python/temporal/__init__.py +++ b/samples/python/temporal/__init__.py @@ -2,8 +2,14 @@ from __future__ import annotations +from ._definitions import ( + ValidationError, + Violation, +) from .models import Temporal __all__ = [ + "ValidationError", + "Violation", "Temporal", ] diff --git a/samples/python/tests/test_chat.py b/samples/python/tests/test_chat.py index 5722b4e4..0450c7e3 100644 --- a/samples/python/tests/test_chat.py +++ b/samples/python/tests/test_chat.py @@ -8,8 +8,8 @@ Room, SendMessageInput, SendMessageOutput, + ValidationError, ) -from chat._definitions import ValidationError from tests.json_converter_helper import ( canonical_json_bytes, diff --git a/samples/python/tests/test_kb.py b/samples/python/tests/test_kb.py index f097dee5..6b2a212d 100644 --- a/samples/python/tests/test_kb.py +++ b/samples/python/tests/test_kb.py @@ -10,7 +10,7 @@ from kb import GetPageInput from kb import Page from kb import PutBlockOutput -from kb._definitions import ValidationError +from kb import ValidationError from tests.json_converter_helper import ( canonical_fixture_bytes, diff --git a/samples/python/tests/test_showcase.py b/samples/python/tests/test_showcase.py index 681b36b5..d04ae5c9 100644 --- a/samples/python/tests/test_showcase.py +++ b/samples/python/tests/test_showcase.py @@ -21,9 +21,10 @@ ShowcaseRowsItem, Square, TextNote, + ValidationError, + Violation, Widget, ) -from showcase._definitions import ValidationError from tests.json_converter_helper import ( canonical_json_bytes, @@ -36,6 +37,13 @@ SUITE = "showcase" + +def test_validation_types_are_exported_from_the_package() -> None: + violation = Violation(path="name", reason="required") + error = ValidationError([violation]) + assert error.violations == [violation] + + # The ten required members of Showcase; every negative payload starts here so the # only violations reported are the ones under test. Mirrors the `base` object the # Go and TypeScript suites use. diff --git a/samples/python/tests/test_temporal.py b/samples/python/tests/test_temporal.py index c44df839..abfc1485 100644 --- a/samples/python/tests/test_temporal.py +++ b/samples/python/tests/test_temporal.py @@ -4,11 +4,10 @@ import pytest -from temporal import Temporal +from temporal import Temporal, ValidationError from temporal._definitions import ( _TEMPORAL_FRACTION_DIGITS, _TEMPORAL_MAX_DURATION_SECONDS, - ValidationError, _temporal_isoformat, ) diff --git a/specs/json-schema/PRINCIPLES.md b/specs/json-schema/PRINCIPLES.md index ba74c60e..f6a7496b 100644 --- a/specs/json-schema/PRINCIPLES.md +++ b/specs/json-schema/PRINCIPLES.md @@ -71,7 +71,7 @@ collision rejects at load time, and `x-py-name` moves the public property and its backing slot together. Python emits no module-level `DEFAULT_*` identifier. 1. **Models emit `@dataclasses.dataclass`es, not a validating model base (P2/P4).** Every model is a slotted, keyword-only dataclass with **no validation on construction**. Required non-nullable property annotations are bare `T`; optional or nullable property annotations use `T | None`. A model with a schema-defaulted property uses `init=False` plus a generated keyword-only `__init__`: the public constructor keyword initializes a private `_<field>: T | None` presence slot, while a mutable same-name property returns either that raw value or the scalar schema default. Its setter accepts `T`, and its deleter restores the unset state. The private slot is excluded from `repr` but remains a dataclass comparison field, preserving raw presence/value equality. Open-model constructors initialize an omitted `additional_properties` to a fresh dictionary. Other models use `@dataclasses.dataclass(slots=True, kw_only=True)` directly. Field annotations remain plain Python types (`int`, `str`, `datetime.datetime`, `datetime.timedelta`, `bytes`, `typing.Literal[...]`, `list[T]`, `dict[str, V]`), never annotated validator aliases, so the class reads like hand-written Python (P2) and the runtime dependency set stays at the SDKs alone (P4). Conversion and validation live *off* the model, in a companion transfer-type converter (§3), which gives serialize-side validation real teeth (P12). -2. **Aggregate via a single generated `ValidationError` (extends `Exception`) holding `list[Violation]` (P11).** Collect every `Violation { path, reason }` — a `@dataclasses.dataclass(frozen=True, slots=True)` in the shared `definitions` module — into one list and raise **one** generated `ValidationError`, whose `str()` enumerates every violation and whose `violations` attribute exposes them structured. This is the same primitive Go, TypeScript and Java surface, with the same structured `{path, reason}` shape — so all four targets now report a rejection the same way (P11), and the cross-language guarantee is the accepted-and-rejected value set (P1), not byte-identical message text. Python's `reason` strings mirror **TypeScript's** verbatim, Python being the same design. It is never stringly-typed and never a language-native exception group. A nested value's `ValidationError` is re-pathed under the enclosing field by a `_collect(violations, path, error)` runtime helper and merged into the parent's list, so one payload yields one flat, fully-pathed violation set — the analogue of TypeScript's `collect`. +2. **Aggregate via a single generated `ValidationError` (extends `Exception`) holding `list[Violation]` (P11).** Collect every `Violation { path, reason }` — a `@dataclasses.dataclass(frozen=True, slots=True)` in the shared `definitions` module — into one list and raise **one** generated `ValidationError`, whose `str()` enumerates every violation and whose `violations` attribute exposes them structured. Both types are re-exported by the generated package's root `__init__.py`, so callers never import the private `_definitions` module. This is the same primitive Go, TypeScript and Java surface, with the same structured `{path, reason}` shape — so all four targets now report a rejection the same way (P11), and the cross-language guarantee is the accepted-and-rejected value set (P1), not byte-identical message text. Python's `reason` strings mirror **TypeScript's** verbatim, Python being the same design. It is never stringly-typed and never a language-native exception group. A nested value's `ValidationError` is re-pathed under the enclosing field by a `_collect(violations, path, error)` runtime helper and merged into the parent's list, so one payload yields one flat, fully-pathed violation set — the analogue of TypeScript's `collect`. 3. **A companion `_<Model>TransferTypeConverter` converts model ⇄ intermediate and validates; the *default* Temporal converter finds it through the SDK's transfer-type hook (P12/P3).** Each model gets a private converter class — `class _UserTransferTypeConverter(temporalio.converter.TransferTypeConverter["User", typing.Any])` with `from_transfer_type(value: typing.Any, type_hint: type[User]) -> User` as the parse adapter (untrusted JSON value → model) and `to_transfer_type(value: User) -> typing.Any` as the encode adapter (model → plain JSON value) — attached to the class by `@_transfer_type_convertible(_UserTransferTypeConverter)`, the runtime module's one-line shim over `temporalio.converter.transfer_type_convertible` that erases the converter's value-type parameter (binding it on the decorated class is circular for a static type checker: the class's type depends on the decorator, whose value type depends on the class). Both directions run the same emitted checks, collecting `Violation`s into one `ValidationError` (§2), so validation lives *inside* the conversion — that is what makes them the single source of truth in both directions. The intermediate is a plain `dict`/`list`/scalar, never a `str`: the byte-level JSON encode/decode is the Temporal payload converter's boundary, which hands the transfer-type converter the parsed (or about-to-be-encoded) value. Working in intermediate values (not strings) is also what makes conversions **composable** — a parent's `to_transfer_type` calls its children's on nested values and embeds the results, `from_transfer_type` likewise; a `str` could not nest. That composition is load-bearing rather than stylistic: the SDK hooks only the **top-level** value, so a nested model is always converted by its parent's body. Registration is the whole of the wiring — the stock `DataConverter.default` consults the hook, so generated models need no contrib package and no user setup (P3). A `typing.TypeAlias` cannot be decorated, so a named or inline `oneOf` union is served by module-private free functions (`_<name>_from_transfer_type` / `_<name>_to_transfer_type`) instead of a converter class; unions can only appear nested, so nothing is lost. The per-field omit-vs-`null` decision follows the [[nullability]] serialize table. See [[nullability]], [[const]], [[default]]. ## Java diff --git a/src/generator/python.rs b/src/generator/python.rs index a668beae..2ae30656 100644 --- a/src/generator/python.rs +++ b/src/generator/python.rs @@ -29,6 +29,7 @@ use crate::spec::{ const GENERATED_HEADER: &str = "# Generated by nexgen. DO NOT EDIT!"; const PYTHON_FORMAT_LINE_LENGTH: usize = 88; const EXPERIMENTAL_WARNING: &str = "This API is experimental and subject to change."; +const JSON_PUBLIC_RUNTIME_NAMES: &[&str] = &["ValidationError", "Violation"]; pub(crate) fn generate( tree: &crate::spec::ApiSpecTree<PlannedFamily>, @@ -164,6 +165,11 @@ fn insert_branch_index_file( path.push("__init__.py"); let mut contents = String::from("# Generated by nexgen. DO NOT EDIT!\n\n"); let mut wrote_import = false; + let exports_json_runtime = branch.module_path.is_root() && branch_has_json_models(branch); + if exports_json_runtime { + render_json_public_runtime_import(&mut contents); + wrote_import = true; + } for (name, node) in &branch.children { let module_name = name.replace('-', "_"); let names = node_export_names(node, model_hoists, mode) @@ -189,7 +195,14 @@ fn insert_branch_index_file( .collect::<Vec<_>>(), ); } - let export_names = branch_export_names(branch, model_hoists, mode); + let mut export_names = branch_export_names(branch, model_hoists, mode); + if exports_json_runtime { + export_names.extend( + JSON_PUBLIC_RUNTIME_NAMES + .iter() + .map(|name| (*name).to_string()), + ); + } if !export_names.is_empty() { contents.push_str("\n__all__ = [\n"); for name in export_names { @@ -662,6 +675,12 @@ impl<'a> ApiPlanner<'a> { .cloned(), ); let package_model_names = package_model_names.into_iter().collect::<Vec<_>>(); + let exports_json_runtime = self.model_hoists.is_none() + && self + .api_plan + .external_types() + .map(|(_, binding)| binding) + .any(|binding| matches!(binding.external_type, ExternalTypeSpec::Json(_))); insert_generated_file( &mut files, "__init__.py", @@ -674,9 +693,10 @@ impl<'a> ApiPlanner<'a> { &support_names, self.api_plan, self.model_hoists, + exports_json_runtime, ) } else { - render_definitions_only_package_init(services, &model_names) + render_definitions_only_package_init(services, &model_names, exports_json_runtime) }, )?; insert_generated_file( @@ -3219,6 +3239,7 @@ pub(in crate::generator) fn enum_default_expr( fn render_definitions_only_package_init( services: &[RenderedService<'_>], model_names: &[String], + exports_json_runtime: bool, ) -> String { let mut output = String::new(); render_generated_file_header(&mut output); @@ -3227,6 +3248,9 @@ fn render_definitions_only_package_init( .iter() .map(|service| service.name.to_string()) .collect::<Vec<_>>(); + if exports_json_runtime { + render_json_public_runtime_import(&mut output); + } if !model_names.is_empty() { render_named_python_import(&mut output, ".models", model_names); } @@ -3235,7 +3259,13 @@ fn render_definitions_only_package_init( } output.push_str("\n__all__ = [\n"); - for name in model_names.iter().chain(service_names.iter()) { + for name in JSON_PUBLIC_RUNTIME_NAMES + .iter() + .copied() + .filter(|_| exports_json_runtime) + .chain(model_names.iter().map(String::as_str)) + .chain(service_names.iter().map(String::as_str)) + { output.push_str(" "); output.push_str(&python_string_literal(name)); output.push_str(",\n"); @@ -3428,6 +3458,14 @@ pub(in crate::generator) fn render_named_python_import( render_named_python_import_with_indent(output, module, names, ""); } +fn render_json_public_runtime_import(output: &mut String) { + let names = JSON_PUBLIC_RUNTIME_NAMES + .iter() + .map(|name| (*name).to_string()) + .collect::<Vec<_>>(); + render_named_python_import(output, "._definitions", &names); +} + fn render_named_python_import_with_indent( output: &mut String, module: &str, @@ -4526,6 +4564,7 @@ fn render_package_init( support_names: &[String], _api_plan: &PlannedSpec, _model_hoists: Option<&PythonModelHoists>, + exports_json_runtime: bool, ) -> String { let operation_function_names = services .iter() @@ -4550,6 +4589,9 @@ fn render_package_init( let mut output = String::new(); render_generated_file_header(&mut output); output.push('\n'); + if exports_json_runtime { + render_json_public_runtime_import(&mut output); + } if !model_names.is_empty() { render_named_python_import(&mut output, ".models", model_names); } @@ -4596,6 +4638,13 @@ fn render_package_init( render_named_python_import(&mut output, "._support", &operation_registry_support_names); } output.push_str("\n__all__ = [\n"); + if exports_json_runtime { + for name in JSON_PUBLIC_RUNTIME_NAMES { + output.push_str(" "); + output.push_str(&python_string_literal(name)); + output.push_str(",\n"); + } + } for name in model_names { output.push_str(" "); output.push_str(&python_string_literal(name)); diff --git a/src/parser/json_schema.rs b/src/parser/json_schema.rs index e22a075d..9e4d83b0 100644 --- a/src/parser/json_schema.rs +++ b/src/parser/json_schema.rs @@ -5923,7 +5923,8 @@ pub(crate) fn build_name_manifest( /// runtime helper functions (`isPlainObject`, `collect`, …) are `camelCase`. /// - Python (`src/generator/json/python.rs`): `Violation` (dataclass) and /// `ValidationError` (exception) are imported by bare name into every model -/// module; the other runtime helpers are `_`-prefixed. +/// module and re-exported by the root package barrel; the other runtime helpers +/// are `_`-prefixed. /// - Java (`src/generator/java.rs`): the root-package runtime classes /// `Violation`, `ValidationException`, and `SpecNumbers`, each emitted as its /// own always-present public file and imported into model files. diff --git a/tests/generate_python.rs b/tests/generate_python.rs index 7004001b..cfcc188b 100644 --- a/tests/generate_python.rs +++ b/tests/generate_python.rs @@ -453,6 +453,21 @@ fn read_python_package_files(dir: &Path) -> BTreeMap<PathBuf, String> { files } +fn assert_python_validation_exports(package_init: &str) { + for expected in [ + "from ._definitions import (", + " ValidationError,", + " Violation,", + " \"ValidationError\",", + " \"Violation\",", + ] { + assert!( + package_init.contains(expected), + "{expected}\n{package_init}" + ); + } +} + fn render_output_files(files: BTreeMap<PathBuf, String>) -> String { files .into_iter() @@ -664,6 +679,10 @@ fn python_json_example_generation_matches_checked_in_output() { let expected = read_python_package_files(&python_json_definitions_output_path(&root, example_id)); assert_eq!(rendered, expected, "snapshot mismatch for {example_id}"); + let package_init = rendered + .get(&PathBuf::from("__init__.py")) + .expect("JSON Schema package should include a root __init__.py"); + assert_python_validation_exports(package_init); if example_id == "showcase" { let all = rendered.values().cloned().collect::<Vec<_>>().join("\n"); // A default-bearing property materializes on read while its private @@ -734,6 +753,10 @@ fn python_json_api_example_generation_matches_checked_in_output() { let rendered = read_python_package_files(&output_path); let expected = read_python_package_files(&python_json_api_output_path(&root, example_id)); assert_eq!(rendered, expected, "snapshot mismatch for {example_id}"); + let package_init = rendered + .get(&PathBuf::from("__init__.py")) + .expect("JSON Schema package should include a root __init__.py"); + assert_python_validation_exports(package_init); fs::remove_dir_all(output_path).unwrap(); } } From d22dbe67b5176bfd20f2bb2091e372bbe649ed29 Mon Sep 17 00:00:00 2001 From: Roey Berman <roey.berman@gmail.com> Date: Fri, 21 Aug 2026 10:22:42 -0700 Subject: [PATCH 18/20] Move Python package exports into backend metadata --- src/generator/json_schema/python.rs | 18 ++- src/generator/python.rs | 190 +++++++++++++++++++--------- 2 files changed, 150 insertions(+), 58 deletions(-) diff --git a/src/generator/json_schema/python.rs b/src/generator/json_schema/python.rs index f55c0445..a387375c 100644 --- a/src/generator/json_schema/python.rs +++ b/src/generator/json_schema/python.rs @@ -23,6 +23,8 @@ use crate::planning::{PlannedFamily, PlannedJsonType, PlannedSpec}; use crate::spec::{ApiSpecBranch, ApiSpecNode}; use crate::spec::{ExternalTypeSpec, ModulePath, RecordSpec}; +const JSON_PUBLIC_RUNTIME_NAMES: &[&str] = &["ValidationError", "Violation"]; + #[derive(Debug, Clone, Deserialize, Default)] struct Schema { #[serde(rename = "$ref")] @@ -289,7 +291,20 @@ impl ExternalModelBackend<PlannedJsonType> for ModelBackend { fn render_models(&self) -> Result<RenderedModelFragments> { set_ref_names(&self.ref_names); let json_models = self.json_models.iter().collect::<Vec<_>>(); - render_external_models(json_models.as_slice(), &self.runtime_import_module) + let mut fragments = + render_external_models(json_models.as_slice(), &self.runtime_import_module)?; + if !self.json_models.is_empty() || !self.hoisted_json_models.is_empty() { + // Validation failures are part of the JSON backend's public runtime surface. + // Keep the request even when every local model was moved to `_recursive`. + fragments.root_package_imports.insert( + "._definitions".to_string(), + JSON_PUBLIC_RUNTIME_NAMES + .iter() + .map(|name| (*name).to_string()) + .collect(), + ); + } + Ok(fragments) } fn render_support_files(&self) -> Result<BTreeMap<PathBuf, String>> { @@ -715,6 +730,7 @@ pub(in crate::generator) fn render_external_models( post_model_statements: String::new(), module_imports, relative_imports, + root_package_imports: BTreeMap::new(), exported_names: json_models .iter() .map(|model| model.model_name.clone()) diff --git a/src/generator/python.rs b/src/generator/python.rs index 2ae30656..f93bb5c9 100644 --- a/src/generator/python.rs +++ b/src/generator/python.rs @@ -29,7 +29,13 @@ use crate::spec::{ const GENERATED_HEADER: &str = "# Generated by nexgen. DO NOT EDIT!"; const PYTHON_FORMAT_LINE_LENGTH: usize = 88; const EXPERIMENTAL_WARNING: &str = "This API is experimental and subject to change."; -const JSON_PUBLIC_RUNTIME_NAMES: &[&str] = &["ValidationError", "Violation"]; + +pub(in crate::generator) type RootPackageImports = BTreeMap<String, BTreeSet<String>>; + +struct PythonGenerationResult { + generated_files: GeneratedFiles, + root_package_imports: RootPackageImports, +} pub(crate) fn generate( tree: &crate::spec::ApiSpecTree<PlannedFamily>, @@ -56,7 +62,9 @@ fn generate_leaf( .module_imports .values() .all(BTreeSet::is_empty); - ApiPlanner::new(api_plan, inline_model_rebuilds, None)?.build(support_fragments, mode) + let generated = + ApiPlanner::new(api_plan, inline_model_rebuilds, None)?.build(support_fragments, mode)?; + Ok(generated.generated_files) } fn generate_leaf_with_model_hoists( @@ -64,7 +72,7 @@ fn generate_leaf_with_model_hoists( support_fragments: &[SupportFragmentSpec], mode: GenerationMode, model_hoists: &PythonModelHoists, -) -> Result<GeneratedFiles> { +) -> Result<PythonGenerationResult> { reject_support_namespaces(Language::Python, support_fragments)?; ApiPlanner::new(api_plan, true, Some(model_hoists))?.build(support_fragments, mode) } @@ -77,7 +85,7 @@ fn generate_tree( let model_hoists = tree_model_hoists(branch)?; let mut files = BTreeMap::new(); let mut warnings = Vec::new(); - insert_branch_index_file(&mut files, branch, &model_hoists, mode)?; + let mut root_package_imports = RootPackageImports::new(); insert_files(&mut files, render_tree_support_files(branch))?; for (path, contents) in model_hoists.files() { insert_generated_file(&mut files, path.clone(), contents.clone())?; @@ -90,8 +98,16 @@ fn generate_tree( &model_hoists, &mut files, &mut warnings, + &mut root_package_imports, )?; } + insert_branch_index_file( + &mut files, + branch, + &model_hoists, + mode, + &root_package_imports, + )?; Ok(GeneratedFiles { layout: crate::generator::GeneratedOutputLayout::Directory, files, @@ -106,6 +122,7 @@ fn generate_tree_node( model_hoists: &PythonModelHoists, files: &mut BTreeMap<PathBuf, String>, warnings: &mut Vec<String>, + root_package_imports: &mut RootPackageImports, ) -> Result<()> { match node { ApiSpecNode::Leaf(leaf) => { @@ -116,17 +133,32 @@ fn generate_tree_node( mode, model_hoists, )?; - warnings.extend(generated.warnings); + extend_root_package_imports(root_package_imports, generated.root_package_imports); + warnings.extend(generated.generated_files.warnings); let prefix = leaf.module_path.to_path_buf(); - for (path, contents) in generated.files { + for (path, contents) in generated.generated_files.files { insert_generated_file(files, prefix.join(path), contents)?; } Ok(()) } ApiSpecNode::Branch(branch) => { - insert_branch_index_file(files, branch, model_hoists, mode)?; + insert_branch_index_file( + files, + branch, + model_hoists, + mode, + &RootPackageImports::new(), + )?; for node in branch.children.values() { - generate_tree_node(node, support, mode, model_hoists, files, warnings)?; + generate_tree_node( + node, + support, + mode, + model_hoists, + files, + warnings, + root_package_imports, + )?; } Ok(()) } @@ -160,14 +192,14 @@ fn insert_branch_index_file( branch: &ApiSpecBranch<PlannedFamily>, model_hoists: &PythonModelHoists, mode: GenerationMode, + root_package_imports: &RootPackageImports, ) -> Result<()> { let mut path = branch.module_path.to_path_buf(); path.push("__init__.py"); let mut contents = String::from("# Generated by nexgen. DO NOT EDIT!\n\n"); let mut wrote_import = false; - let exports_json_runtime = branch.module_path.is_root() && branch_has_json_models(branch); - if exports_json_runtime { - render_json_public_runtime_import(&mut contents); + if !root_package_imports.is_empty() { + render_root_package_imports(&mut contents, root_package_imports); wrote_import = true; } for (name, node) in &branch.children { @@ -196,13 +228,7 @@ fn insert_branch_index_file( ); } let mut export_names = branch_export_names(branch, model_hoists, mode); - if exports_json_runtime { - export_names.extend( - JSON_PUBLIC_RUNTIME_NAMES - .iter() - .map(|name| (*name).to_string()), - ); - } + export_names.extend(root_package_export_names(root_package_imports)); if !export_names.is_empty() { contents.push_str("\n__all__ = [\n"); for name in export_names { @@ -554,7 +580,7 @@ impl<'a> ApiPlanner<'a> { mut self, support_fragments: &[SupportFragmentSpec], mode: GenerationMode, - ) -> Result<GeneratedFiles> { + ) -> Result<PythonGenerationResult> { let api_plan = self.api_plan; let services = api_plan .services @@ -604,7 +630,12 @@ impl<'a> ApiPlanner<'a> { let model_refs = self.models.values().collect::<Vec<_>>(); let model_fragments = self.render_model_fragments(model_refs.as_slice())?; - self.render_package(&model_fragments, &services, support_fragments, mode) + let generated_files = + self.render_package(&model_fragments, &services, support_fragments, mode)?; + Ok(PythonGenerationResult { + generated_files, + root_package_imports: model_fragments.root_package_imports, + }) } fn render_model_fragments(&self, models: &[&RenderedModel]) -> Result<RenderedModelFragments> { @@ -675,15 +706,7 @@ impl<'a> ApiPlanner<'a> { .cloned(), ); let package_model_names = package_model_names.into_iter().collect::<Vec<_>>(); - let exports_json_runtime = self.model_hoists.is_none() - && self - .api_plan - .external_types() - .map(|(_, binding)| binding) - .any(|binding| matches!(binding.external_type, ExternalTypeSpec::Json(_))); - insert_generated_file( - &mut files, - "__init__.py", + let render_init = |root_package_imports: &RootPackageImports| { if mode == GenerationMode::NativeApi { render_package_init( services, @@ -693,12 +716,19 @@ impl<'a> ApiPlanner<'a> { &support_names, self.api_plan, self.model_hoists, - exports_json_runtime, + root_package_imports, ) } else { - render_definitions_only_package_init(services, &model_names, exports_json_runtime) - }, - )?; + render_definitions_only_package_init(services, &model_names, root_package_imports) + } + }; + let empty_root_package_imports = RootPackageImports::new(); + let root_package_imports = if self.model_hoists.is_none() { + &model_fragments.root_package_imports + } else { + &empty_root_package_imports + }; + insert_generated_file(&mut files, "__init__.py", render_init(root_package_imports))?; insert_generated_file( &mut files, "models.py", @@ -2177,6 +2207,7 @@ fn render_record_models( post_model_statements: String::new(), module_imports, relative_imports: BTreeMap::new(), + root_package_imports: RootPackageImports::new(), exported_names: models.iter().map(|model| model.name.clone()).collect(), allows_private_wire_access: false, }) @@ -2790,6 +2821,8 @@ pub(in crate::generator) struct RenderedModelFragments { pub(in crate::generator) post_model_statements: String, pub(in crate::generator) module_imports: BTreeSet<String>, pub(in crate::generator) relative_imports: BTreeMap<String, BTreeSet<String>>, + /// Backend-owned public names imported only by the generated package tree's root barrel. + pub(in crate::generator) root_package_imports: RootPackageImports, pub(in crate::generator) exported_names: BTreeSet<String>, pub(in crate::generator) allows_private_wire_access: bool, } @@ -2817,6 +2850,7 @@ impl RenderedModelFragments { .or_default() .extend(names); } + extend_root_package_imports(&mut self.root_package_imports, other.root_package_imports); self.exported_names.extend(other.exported_names); } } @@ -3239,7 +3273,7 @@ pub(in crate::generator) fn enum_default_expr( fn render_definitions_only_package_init( services: &[RenderedService<'_>], model_names: &[String], - exports_json_runtime: bool, + root_package_imports: &RootPackageImports, ) -> String { let mut output = String::new(); render_generated_file_header(&mut output); @@ -3248,9 +3282,7 @@ fn render_definitions_only_package_init( .iter() .map(|service| service.name.to_string()) .collect::<Vec<_>>(); - if exports_json_runtime { - render_json_public_runtime_import(&mut output); - } + render_root_package_imports(&mut output, root_package_imports); if !model_names.is_empty() { render_named_python_import(&mut output, ".models", model_names); } @@ -3259,10 +3291,9 @@ fn render_definitions_only_package_init( } output.push_str("\n__all__ = [\n"); - for name in JSON_PUBLIC_RUNTIME_NAMES + for name in root_package_export_names(root_package_imports) .iter() - .copied() - .filter(|_| exports_json_runtime) + .map(String::as_str) .chain(model_names.iter().map(String::as_str)) .chain(service_names.iter().map(String::as_str)) { @@ -3458,12 +3489,23 @@ pub(in crate::generator) fn render_named_python_import( render_named_python_import_with_indent(output, module, names, ""); } -fn render_json_public_runtime_import(output: &mut String) { - let names = JSON_PUBLIC_RUNTIME_NAMES - .iter() - .map(|name| (*name).to_string()) - .collect::<Vec<_>>(); - render_named_python_import(output, "._definitions", &names); +fn render_root_package_imports(output: &mut String, imports: &RootPackageImports) { + for (module, names) in imports { + render_named_python_import(output, module, &names.iter().cloned().collect::<Vec<_>>()); + } +} + +fn root_package_export_names(imports: &RootPackageImports) -> BTreeSet<String> { + imports + .values() + .flat_map(|names| names.iter().cloned()) + .collect() +} + +fn extend_root_package_imports(target: &mut RootPackageImports, source: RootPackageImports) { + for (module, names) in source { + target.entry(module).or_default().extend(names); + } } fn render_named_python_import_with_indent( @@ -4564,7 +4606,7 @@ fn render_package_init( support_names: &[String], _api_plan: &PlannedSpec, _model_hoists: Option<&PythonModelHoists>, - exports_json_runtime: bool, + root_package_imports: &RootPackageImports, ) -> String { let operation_function_names = services .iter() @@ -4589,9 +4631,7 @@ fn render_package_init( let mut output = String::new(); render_generated_file_header(&mut output); output.push('\n'); - if exports_json_runtime { - render_json_public_runtime_import(&mut output); - } + render_root_package_imports(&mut output, root_package_imports); if !model_names.is_empty() { render_named_python_import(&mut output, ".models", model_names); } @@ -4638,12 +4678,10 @@ fn render_package_init( render_named_python_import(&mut output, "._support", &operation_registry_support_names); } output.push_str("\n__all__ = [\n"); - if exports_json_runtime { - for name in JSON_PUBLIC_RUNTIME_NAMES { - output.push_str(" "); - output.push_str(&python_string_literal(name)); - output.push_str(",\n"); - } + for name in root_package_export_names(root_package_imports) { + output.push_str(" "); + output.push_str(&python_string_literal(&name)); + output.push_str(",\n"); } for name in model_names { output.push_str(" "); @@ -7421,7 +7459,7 @@ fn is_python_keyword(name: &str) -> bool { #[cfg(test)] mod tests { - use std::collections::BTreeMap; + use std::collections::{BTreeMap, BTreeSet}; use std::fs; use std::path::{Path, PathBuf}; use std::process::Command; @@ -7474,6 +7512,44 @@ mod tests { ); } + #[test] + fn rendered_model_fragments_merge_root_package_imports() { + let mut fragments = super::RenderedModelFragments::default(); + fragments.root_package_imports.insert( + ".runtime".to_string(), + BTreeSet::from(["First".to_string(), "Shared".to_string()]), + ); + let mut other = super::RenderedModelFragments::default(); + other.root_package_imports.insert( + ".runtime".to_string(), + BTreeSet::from(["Second".to_string(), "Shared".to_string()]), + ); + other.root_package_imports.insert( + ".support".to_string(), + BTreeSet::from(["Support".to_string()]), + ); + + fragments.extend(other); + + assert_eq!( + fragments.root_package_imports, + BTreeMap::from([ + ( + ".runtime".to_string(), + BTreeSet::from([ + "First".to_string(), + "Second".to_string(), + "Shared".to_string(), + ]), + ), + ( + ".support".to_string(), + BTreeSet::from(["Support".to_string()]), + ), + ]) + ); + } + fn unique_temp_dir(label: &str) -> PathBuf { let unique = SystemTime::now() .duration_since(UNIX_EPOCH) From 41a22e1a787f1edcd1ebdb0a83caa75584c034c4 Mon Sep 17 00:00:00 2001 From: Roey Berman <roey.berman@gmail.com> Date: Fri, 21 Aug 2026 12:40:31 -0700 Subject: [PATCH 19/20] Clarify generated model migration notes --- CHANGELOG.md | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d6181395..7cc85330 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,12 +45,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- Python: JSON Schema output now uses slotted, keyword-only dataclasses and the - default Temporal converter instead of Pydantic. Generated transfer converters - preserve wire names and unknown fields in `additional_properties`, aggregate - structured validation errors, collapse absent and explicit-null optional values - to `None`, and surface schema defaults through mutable properties (`del field` - restores unset) rather than `DEFAULT_*` constants. - Protobuf-backed models now consistently generate conversions in both directions whenever they are reachable. Go and TypeScript emit previously suppressed complementary helpers, operation-free exported models receive the @@ -64,9 +58,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 value, and the only way to rename a member whose type is a `$ref` (a member named `class` was otherwise unfixable in Python and Java). - TypeScript: JSON Schema models now export `TransferTypeConverter` instances - (`fromTransferType`/`toTransferType`), and generated operations reference them - through `inputType`/`outputType`. Converter names follow resolved model names, - participate in collision checks, and require the nexus-rpc type-info API. + (`fromTransferType`/`toTransferType`) instead of mapper classes, and generated + operations reference them through `inputType`/`outputType`. Converter names + follow resolved model names, participate in collision checks, and require the + nexus-rpc type-info API. - Generating into an existing `--output` directory no longer deletes it first. The directory is written into instead, so pre-existing files and subdirectories are preserved; generated files are still overwritten in place. @@ -98,6 +93,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Breaking Changes +- Python: JSON Schema output now uses slotted, keyword-only dataclasses instead + of Pydantic and works with the default Temporal converter, removing the + Pydantic dependency and contrib converter wiring. Generated transfer converters + preserve wire names, carry unknown fields in `additional_properties` instead of + `model_extra`, aggregate structured validation errors, collapse absent and + explicit-null optional-and-nullable values to `None`, and surface schema + defaults through mutable properties whose deleter restores unset state. - Java: A map-shaped model (a pure typed map — `additionalProperties` with no declared `properties`) now names its catch-all member `additionalProperties`, matching the struct-shaped POJOs and the other languages (Go From d958345280651c7664a61337c617f5a94aeabc3a Mon Sep 17 00:00:00 2001 From: Roey Berman <roey.berman@gmail.com> Date: Fri, 21 Aug 2026 12:45:12 -0700 Subject: [PATCH 20/20] Fix comment from code review --- src/generator/python.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/generator/python.rs b/src/generator/python.rs index f93bb5c9..4b6307f6 100644 --- a/src/generator/python.rs +++ b/src/generator/python.rs @@ -2821,7 +2821,7 @@ pub(in crate::generator) struct RenderedModelFragments { pub(in crate::generator) post_model_statements: String, pub(in crate::generator) module_imports: BTreeSet<String>, pub(in crate::generator) relative_imports: BTreeMap<String, BTreeSet<String>>, - /// Backend-owned public names imported only by the generated package tree's root barrel. + /// Backend-owned public names imported only by the generated package tree's root module. pub(in crate::generator) root_package_imports: RootPackageImports, pub(in crate::generator) exported_names: BTreeSet<String>, pub(in crate::generator) allows_private_wire_access: bool,