diff --git a/CHANGELOG.md b/CHANGELOG.md index 5be2a557..7cc85330 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. @@ -54,11 +57,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 — 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`) 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. @@ -90,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 @@ -108,15 +118,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--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. +- JSON Schema: Cross-input emission and naming now follow each target's actual + scope and `x--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 `` variant's `Validate`. TypeScript cast the narrowed value @@ -135,10 +146,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` @@ -146,11 +157,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 @@ -212,8 +224,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--name` override. - JSON Schema: A `oneOf` with an inline object branch generated uncompilable Go (a marker method on an undeclared `Object` type) and uncompilable TypeScript (a converter named after the anonymous `Record` 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/chat/_definitions.py b/advanced/samples/python/json_schema/api/chat/_definitions.py index 00b1bd60..3bf7f712 100644 --- a/advanced/samples/python/json_schema/api/chat/_definitions.py +++ b/advanced/samples/python/json_schema/api/chat/_definitions.py @@ -4,143 +4,183 @@ 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_date_time", + "_check_duration", + "_check_time", + "_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( @@ -154,6 +194,13 @@ def validate(value: list[typing.Any]) -> list[typing.Any]: 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: @@ -173,60 +220,199 @@ 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 _parse_date_time(value: object) -> object: - if not isinstance(value, str): - return value - 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}") +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" - return datetime.datetime.fromisoformat(normalized) + 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=_temporal_reason("date-time", value)) + ) + return None + return datetime.datetime.fromisoformat(_temporal_isoformat(value)) -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=_temporal_reason("date", 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}") - normalized = value.upper() - if normalized.endswith("Z"): - normalized = normalized[:-1] + "+00:00" - return datetime.time.fromisoformat(normalized) + violations.append(Violation(path=path, reason=_temporal_reason("time", value))) + return None + return datetime.time.fromisoformat(_temporal_isoformat(value)) -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=_temporal_reason("duration", value)) + ) + return None total = 0 number = "" for char in value[2:]: 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: - raise ValueError(f"must be a valid duration, got {value!r}") + 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 "" @@ -280,39 +466,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 +485,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..7a10a675 100644 --- a/advanced/samples/python/json_schema/api/chat/models.py +++ b/advanced/samples/python/json_schema/api/chat/models.py @@ -2,206 +2,522 @@ 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" - ) - - 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) - - -class Labels(pydantic.BaseModel): - """Arbitrary string key/value labels.""" +_ROOM_DECLARED: frozenset[str] = frozenset( + {"roomId", "displayName", "topic", "members", "labels"} +) - 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"], - ) - ) - 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), +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_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_value_raw = raw["roomId"] + if not isinstance(room_id_value_raw, str): + violations.append(Violation(path="roomId", reason="expected string")) + else: + room_id_value = room_id_value_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_value, + ) + + @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)}" ) ) - 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 { - 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() - } +@_transfer_type_convertible(_LabelsTransferTypeConverter) +@dataclasses.dataclass(slots=True, kw_only=True) +class Labels: + """Arbitrary string key/value labels.""" -class Message(pydantic.BaseModel): + 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_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_value_raw = raw["kind"] + if not isinstance(kind_value_raw, str): + violations.append(Violation(path="kind", reason="expected string")) + elif kind_value_raw not in ("text",): + violations.append(Violation(path="kind", reason='must equal "text"')) + else: + kind_value = kind_value_raw + + 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_value_raw = raw["body"] + if not isinstance(body_value_raw, str): + violations.append(Violation(path="body", reason="expected string")) + else: + body_value = body_value_raw + + reply_to_id_value: str | None = None + if "replyToId" in raw: + 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_value_raw, str): + violations.append( + Violation(path="replyToId", reason="expected string") + ) + else: + reply_to_id_value = reply_to_id_value_raw + + priority_value: int | None = None + if "priority" in raw: + priority_value_raw = raw["priority"] + if priority_value_raw is None: + violations.append( + Violation(path="priority", reason="explicit null not allowed") + ) + else: + priority_value_parsed = _parse_spec_integer( + priority_value_raw, "priority", violations + ) + if priority_value_parsed is not None: + priority_value = priority_value_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_value, + body=body_value, + reply_to_id=reply_to_id_value, + priority=priority_value, + ) + + @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, init=False) +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 = dataclasses.field(default=None, repr=False) """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( + def __init__( self, - handler: typing.Callable[[pydantic.BaseModel], typing.Any], - ) -> dict[str, object]: - return _emit_set_fields(self, handler) - - -class Room(pydantic.BaseModel): + *, + kind: typing.Literal["text"] = "text", + body: str, + reply_to_id: str | None = None, + priority: int | None = None, + _priority: int | None = 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: int) -> None: + self._priority = value + + @priority.deleter + def priority(self) -> None: + self._priority = None + + +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_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_value_raw = raw["roomId"] + if not isinstance(room_id_value_raw, str): + violations.append(Violation(path="roomId", reason="expected string")) + else: + room_id_value = room_id_value_raw + + 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_value_raw = raw["displayName"] + if not isinstance(display_name_value_raw, str): + violations.append( + Violation(path="displayName", reason="expected string") + ) + else: + display_name_value = display_name_value_raw + + topic_value: str | None = None + if "topic" not in raw: + violations.append(Violation(path="topic", reason="required")) + else: + topic_value_raw = raw["topic"] + if topic_value_raw is None: + topic_value = None + else: + if not isinstance(topic_value_raw, str): + violations.append(Violation(path="topic", reason="expected string")) + else: + topic_value = topic_value_raw + + members_value: list[str] | None = None + if "members" in raw: + 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_value_raw, list): + violations.append( + Violation(path="members", reason="expected array") + ) + else: + members_value_list: list[str] = [] + for members_value_index, members_value_element in enumerate( + typing.cast("list[typing.Any]", members_value_raw) + ): + 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_value_item_path, + reason="expected string", + ) + ) + else: + members_value_item = members_value_element + members_value_list.append(members_value_item) + members_value = members_value_list + + labels_value: Labels | None = None + if "labels" in raw: + labels_value_raw = raw["labels"] + if labels_value_raw is None: + violations.append( + Violation(path="labels", reason="explicit null not allowed") + ) + else: + try: + labels_value = _LabelsTransferTypeConverter().from_transfer_type( + labels_value_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_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 + out["topic"] = value.topic + if value.members is not None: + out["members"] = value.members + if value.labels is not None: + 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 + + +@_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 = pydantic.Field(alias="roomId") + room_id: str - display_name: str = pydantic.Field(alias="displayName") + display_name: str - topic: str | None = pydantic.Field() + topic: str | None """Room topic; may be explicitly cleared to null.""" - members: list[str] | None = pydantic.Field(default=None) - - labels: Labels | None = pydantic.Field(default=None) + members: list[str] | None = None - _OPTIONAL_NON_NULLABLE_FIELDS: typing.ClassVar[frozenset[str]] = frozenset( - {"labels", "members"} - ) - - @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): - """Request to post a message.""" + labels: Labels | None = None - 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 ) - 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") +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_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_value_raw = raw["roomId"] + if not isinstance(room_id_value_raw, str): + violations.append(Violation(path="roomId", reason="expected string")) + else: + room_id_value = room_id_value_raw + + 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_value_raw = raw["message"] + try: + message_value = _MessageTransferTypeConverter().from_transfer_type( + message_value_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_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 + try: + out["message"] = _MessageTransferTypeConverter().to_transfer_type( + value.message + ) + except ValidationError as error: + _collect(violations, "message", error) + 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(_SendMessageInputTransferTypeConverter) +@dataclasses.dataclass(slots=True, kw_only=True) +class SendMessageInput: + """Request to post a message.""" -_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_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_value_raw = raw["messageId"] + if not isinstance(message_id_value_raw, str): + violations.append(Violation(path="messageId", reason="expected string")) + else: + message_id_value = message_id_value_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_value, + ) + + @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/__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/kb/_definitions.py b/advanced/samples/python/json_schema/api/kb/_definitions.py index 00b1bd60..3bf7f712 100644 --- a/advanced/samples/python/json_schema/api/kb/_definitions.py +++ b/advanced/samples/python/json_schema/api/kb/_definitions.py @@ -4,143 +4,183 @@ 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_date_time", + "_check_duration", + "_check_time", + "_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( @@ -154,6 +194,13 @@ def validate(value: list[typing.Any]) -> list[typing.Any]: 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: @@ -173,60 +220,199 @@ 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 _parse_date_time(value: object) -> object: - if not isinstance(value, str): - return value - 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}") +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" - return datetime.datetime.fromisoformat(normalized) + 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=_temporal_reason("date-time", value)) + ) + return None + return datetime.datetime.fromisoformat(_temporal_isoformat(value)) -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=_temporal_reason("date", 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}") - normalized = value.upper() - if normalized.endswith("Z"): - normalized = normalized[:-1] + "+00:00" - return datetime.time.fromisoformat(normalized) + violations.append(Violation(path=path, reason=_temporal_reason("time", value))) + return None + return datetime.time.fromisoformat(_temporal_isoformat(value)) -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=_temporal_reason("duration", value)) + ) + return None total = 0 number = "" for char in value[2:]: 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: - raise ValueError(f"must be a valid duration, got {value!r}") + 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 "" @@ -280,39 +466,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 +485,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..0d5b9f43 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,287 @@ 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_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_value_raw = raw["blockId"] + if not isinstance(block_id_value_raw, str): + violations.append(Violation(path="blockId", reason="expected string")) + else: + block_id_value = block_id_value_raw + + 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_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_value}" + ) + ) + + text_value: str | None = None + if "text" in raw: + 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_value_raw, str): + violations.append(Violation(path="text", reason="expected string")) + else: + text_value = text_value_raw + + style_value: BlockStyle | None = None + if "style" in raw: + style_value_raw = raw["style"] + if style_value_raw is None: + violations.append( + Violation(path="style", reason="explicit null not allowed") + ) + else: + try: + style_value = getattr( + BlockStyle, "__temporal_transfer_type_converter" + ).from_transfer_type(style_value_raw, BlockStyle) + except ValidationError as error: + _collect(violations, "style", error) + + page_value: Page | None = None + if "page" in raw: + page_value_raw = raw["page"] + if page_value_raw is None: + page_value = None + else: + try: + page_value = _PageTransferTypeConverter().from_transfer_type( + page_value_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_value, + order=order_value, + text=text_value, + style=style_value, + page=page_value, + ) + + @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: + 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: + try: + out["page"] = _PageTransferTypeConverter().to_transfer_type(value.page) + except ValidationError as error: + _collect(violations, "page", error) + 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_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_value_raw = raw["pageId"] + if not isinstance(page_id_value_raw, str): + violations.append(Violation(path="pageId", reason="expected string")) + else: + page_id_value = page_id_value_raw + + 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_value_raw = raw["title"] + if not isinstance(title_value_raw, str): + violations.append(Violation(path="title", reason="expected string")) + else: + title_value = title_value_raw + + 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_value_raw = raw["meta"] + try: + meta_value = getattr( + PageMeta, "__temporal_transfer_type_converter" + ).from_transfer_type(meta_value_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_value: list[Block] | None = None + if "blocks" in raw: + 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_value_raw, list): + violations.append(Violation(path="blocks", reason="expected array")) + else: + blocks_value_list: list[Block] = [] + for blocks_value_index, blocks_value_element in enumerate( + typing.cast("list[typing.Any]", blocks_value_raw) + ): + blocks_value_item_path = f"blocks[{blocks_value_index}]" + blocks_value_item: Block = typing.cast("typing.Any", None) + try: + blocks_value_item = ( + _BlockTransferTypeConverter().from_transfer_type( + blocks_value_element, Block + ) + ) + except ValidationError as error: + _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": + violations.append(Violation(path=key, reason="unknown field")) + if violations: + raise ValidationError(violations) + return Page( + page_id=page_id_value, + title=title_value, + meta=meta_value, + blocks=blocks_value, + ) -class Page(pydantic.BaseModel): + @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 + 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: + 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 + + +@_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..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 @@ -2,43 +2,97 @@ 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_value: bool | None = None + if "bold" in raw: + 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_value_raw, bool): + violations.append(Violation(path="bold", reason="expected boolean")) + else: + bold_value = bold_value_raw + + indent_value: int | None = None + if "indent" in raw: + indent_value_raw = raw["indent"] + if indent_value_raw is None: + violations.append( + Violation(path="indent", reason="explicit null not allowed") + ) + else: + 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_value}", + ) + ) + + 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_value, + indent=indent_value, + ) + + @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..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 @@ -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_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_value_raw = raw["author"] + if not isinstance(author_value_raw, str): + violations.append(Violation(path="author", reason="expected string")) + else: + author_value = author_value_raw + + word_count_value: int | None = None + if "wordCount" in raw: + 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_value_parsed = _parse_spec_integer( + word_count_value_raw, "wordCount", violations + ) + 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": + violations.append(Violation(path=key, reason="unknown field")) + if violations: + raise ValidationError(violations) + return PageMeta( + author=author_value, + word_count=word_count_value, + ) + + @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..3a3a2b70 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,161 @@ 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_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_value_raw = raw["rootId"] + if not isinstance(root_id_value_raw, str): + violations.append(Violation(path="rootId", reason="expected string")) + else: + root_id_value = root_id_value_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_value, + ) + + @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_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_value_raw = raw["pageId"] + if not isinstance(page_id_value_raw, str): + violations.append(Violation(path="pageId", reason="expected string")) + else: + page_id_value = page_id_value_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_value, + ) + + @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_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_value_raw = raw["blockId"] + if not isinstance(block_id_value_raw, str): + violations.append(Violation(path="blockId", reason="expected string")) + else: + block_id_value = block_id_value_raw + + 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_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": + violations.append(Violation(path=key, reason="unknown field")) + if violations: + raise ValidationError(violations) + return PutBlockOutput( + block_id=block_id_value, + revision=revision_value, + ) + + @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..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 @@ -2,71 +2,190 @@ 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_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_value_raw = raw["id"] + if not isinstance(id_value_raw, str): + violations.append(Violation(path="id", reason="expected string")) + else: + id_value = id_value_raw + + 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_value_raw = raw["name"] + if not isinstance(name_value_raw, str): + violations.append(Violation(path="name", reason="expected string")) + else: + name_value = name_value_raw + + children_value: list[Category] | None = None + if "children" in raw: + 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_value_raw, list): + violations.append( + Violation(path="children", reason="expected array") + ) + else: + children_value_list: list[Category] = [] + for children_value_index, children_value_element in enumerate( + typing.cast("list[typing.Any]", children_value_raw) + ): + children_value_item_path = f"children[{children_value_index}]" + children_value_item: Category = typing.cast("typing.Any", None) + try: + children_value_item = ( + _CategoryTransferTypeConverter().from_transfer_type( + children_value_element, Category + ) + ) + except ValidationError as error: + _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": + violations.append(Violation(path=key, reason="unknown field")) + if violations: + raise ValidationError(violations) + return Category( + 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: + 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 + + +@_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_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_value_raw = raw["swatches"] + if not isinstance(swatches_value_raw, list): + violations.append(Violation(path="swatches", reason="expected array")) + else: + swatches_value_list: list[str] = [] + for swatches_value_index, swatches_value_element in enumerate( + typing.cast("list[typing.Any]", swatches_value_raw) + ): + 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_value_item_path, reason="expected string" + ) + ) + else: + 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": + violations.append(Violation(path=key, reason="unknown field")) + if violations: + raise ValidationError(violations) + return Palette( + swatches=swatches_value, + ) + + @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/__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/showcase/_definitions.py b/advanced/samples/python/json_schema/api/showcase/_definitions.py index 00b1bd60..3bf7f712 100644 --- a/advanced/samples/python/json_schema/api/showcase/_definitions.py +++ b/advanced/samples/python/json_schema/api/showcase/_definitions.py @@ -4,143 +4,183 @@ 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_date_time", + "_check_duration", + "_check_time", + "_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( @@ -154,6 +194,13 @@ def validate(value: list[typing.Any]) -> list[typing.Any]: 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: @@ -173,60 +220,199 @@ 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 _parse_date_time(value: object) -> object: - if not isinstance(value, str): - return value - 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}") +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" - return datetime.datetime.fromisoformat(normalized) + 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=_temporal_reason("date-time", value)) + ) + return None + return datetime.datetime.fromisoformat(_temporal_isoformat(value)) -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=_temporal_reason("date", 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}") - normalized = value.upper() - if normalized.endswith("Z"): - normalized = normalized[:-1] + "+00:00" - return datetime.time.fromisoformat(normalized) + violations.append(Violation(path=path, reason=_temporal_reason("time", value))) + return None + return datetime.time.fromisoformat(_temporal_isoformat(value)) -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=_temporal_reason("duration", value)) + ) + return None total = 0 number = "" for char in value[2:]: 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: - raise ValueError(f"must be a valid duration, got {value!r}") + 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 "" @@ -280,39 +466,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 +485,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..5aca212d 100644 --- a/advanced/samples/python/json_schema/api/showcase/models.py +++ b/advanced/samples/python/json_schema/api/showcase/models.py @@ -2,234 +2,551 @@ 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.""" +_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) - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - strict=True, populate_by_name=True, extra="allow" - ) - street: str = pydantic.Field() +_ADDRESS_DECLARED: frozenset[str] = frozenset({"street", "city", "zip"}) - city: str | None = pydantic.Field(default=None) - zip: SpecInt | None = pydantic.Field(default=None) +_CIRCLE_DECLARED: frozenset[str] = frozenset({"kind", "radius"}) - _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) - - @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). - """ +_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"}) + - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - strict=True, populate_by_name=True, extra="allow" +_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_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_value_raw = raw["street"] + if not isinstance(street_value_raw, str): + violations.append(Violation(path="street", reason="expected string")) + else: + street_value = street_value_raw + + city_value: str | None = None + if "city" in raw: + 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_value_raw, str): + violations.append(Violation(path="city", reason="expected string")) + else: + city_value = city_value_raw + + zip_value: int | None = None + if "zip" in raw: + zip_value_raw = raw["zip"] + if zip_value_raw is None: + violations.append( + Violation(path="zip", reason="explicit null not allowed") + ) + else: + 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: + if key not in _ADDRESS_DECLARED: + additional_properties[key] = raw[key] + if violations: + raise ValidationError(violations) + return Address( + street=street_value, + city=city_value, + zip=zip_value, + 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"], - ) - ) - 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), + +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(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(raw) > 3: + violations.append( + Violation( + path="", reason=f"must have at most 3 properties, got {len(raw)}" ) ) - for key in extra: + for key in raw: 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)}", + ) + ) + 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(out) > 3: + violations.append( + Violation( + path="", reason=f"must have at most 3 properties, got {len(out)}" + ) ) - return self + for key in out: + if len(key) > 8: + 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 - @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() - } +@_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). + """ -class Choices(pydantic.BaseModel): + 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 + ) + 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: + violations: list[Violation] = [] + out: dict[str, typing.Any] = {} + for key, entry in value.additional_properties.items(): + try: + out[key] = _choices_value_to_transfer_type(entry) + except ValidationError as error: + _collect(violations, key, error) + if violations: + raise ValidationError(violations) + 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"], + +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_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_value_raw = raw["kind"] + if not isinstance(kind_value_raw, str): + violations.append(Violation(path="kind", reason="expected string")) + elif kind_value_raw not in ("circle",): + violations.append(Violation(path="kind", reason='must equal "circle"')) + else: + kind_value = kind_value_raw + + 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_value_raw = raw["radius"] + if not ( + not isinstance(radius_value_raw, bool) + and isinstance(radius_value_raw, (int, float)) + ): + violations.append(Violation(path="radius", reason="expected number")) + else: + 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}", ) ) - 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() - } + 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_value, + radius=radius_value, + 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 + 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 + if violations: + raise ValidationError(violations) + return out -class Circle(pydantic.BaseModel): +@_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_value: str | None = None + if "email" in raw: + 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_value_raw, str): + violations.append(Violation(path="email", reason="expected string")) + else: + email_value = email_value_raw + + shipping_street_value: str | None = None + if "shippingStreet" in raw: + 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_value_raw, str): + violations.append( + Violation(path="shippingStreet", reason="expected string") + ) + else: + shipping_street_value = shipping_street_value_raw + + shipping_zip_value: str | None = None + if "shippingZip" in raw: + 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_value_raw, str): + violations.append( + Violation(path="shippingZip", reason="expected string") + ) + else: + shipping_zip_value = shipping_zip_value_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_value, + shipping_street=shipping_street_value, + shipping_zip=shipping_zip_value, + 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 +556,2270 @@ 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 - - _OPTIONAL_NON_NULLABLE_FIELDS: typing.ClassVar[frozenset[str]] = frozenset( - {"email", "shippingStreet", "shippingZip", "shipping_street", "shipping_zip"} - ) + if violations: + raise ValidationError(violations) + return out - @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 - - @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 {})) + if violations: + raise ValidationError(violations) + return out -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_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_value_raw = raw["kind"] + if not isinstance(kind_value_raw, str): + violations.append(Violation(path="kind", reason="expected string")) + elif kind_value_raw not in ("link",): + violations.append(Violation(path="kind", reason='must equal "link"')) + else: + kind_value = kind_value_raw + + 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_value_raw = raw["href"] + if not isinstance(href_value_raw, str): + violations.append(Violation(path="href", reason="expected string")) + else: + 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_value_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_value, + href=href_value, + 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() - } + out["href"] = value.href + for key, entry in value.additional_properties.items(): + out[key] = entry + if violations: + raise ValidationError(violations) + return out -class LinkNote(pydantic.BaseModel): +@_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}" ) ) - 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: _NICKNAMES_MEMBER.dump_python(value, mode="json", by_alias=True) - for key, value in typing.cast( - dict[str, object], self.model_extra or {} - ).items() - } + 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 -class Quotas(pydantic.BaseModel): +@_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. """ - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - strict=True, populate_by_name=True, extra="allow" - ) + 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_value: str | None = None + if "theme" in raw: + 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_value_raw, str): + violations.append(Violation(path="theme", reason="expected string")) + else: + theme_value = theme_value_raw + + font_size_value: int | None = None + if "fontSize" in raw: + 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_value_parsed = _parse_spec_integer( + font_size_value_raw, "fontSize", violations + ) + 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": + violations.append(Violation(path=key, reason="unknown field")) + if violations: + raise ValidationError(violations) + return Settings( + theme=theme_value, + font_size=font_size_value, + ) + + @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.""" - @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()): + 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_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_value_raw = raw["kind"] + if not isinstance(kind_value_raw, str): + violations.append(Violation(path="kind", reason="expected string")) + elif kind_value_raw not in ("showcase",): + violations.append( + Violation(path="kind", reason='must equal "showcase"') + ) + else: + kind_value = kind_value_raw + + 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_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 not in (1,): + violations.append(Violation(path="revision", reason="must equal 1")) + else: + revision_value = revision_value_parsed + + 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_value_raw = raw["enabled"] + if not isinstance(enabled_value_raw, bool): + violations.append(Violation(path="enabled", reason="expected boolean")) + elif enabled_value_raw not in (True,): + violations.append(Violation(path="enabled", reason="must equal true")) + else: + enabled_value = enabled_value_raw + + 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_value_raw = raw["status"] + if not isinstance(status_value_raw, str): + violations.append(Violation(path="status", reason="expected string")) + elif status_value_raw not in ("active", "inactive", "pending"): + violations.append( + Violation( + path="status", + reason=f'must be one of ["active", "inactive", "pending"], got {_quote(status_value_raw)}', + ) + ) + else: + status_value = status_value_raw + + 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_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 not in (1, 2, 3): + violations.append( + Violation( + path="tier", + reason=f"must be one of [1, 2, 3], got {_quote(tier_value_parsed)}", + ) + ) + else: + tier_value = tier_value_parsed + + 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_value_raw = raw["scale"] + if not ( + not isinstance(scale_value_raw, bool) + and isinstance(scale_value_raw, (int, float)) + ): + violations.append(Violation(path="scale", reason="expected number")) + elif scale_value_raw not in (1.5, 2.5): + violations.append( + Violation( + path="scale", + reason=f"must be one of [1.5, 2.5], got {_quote(scale_value_raw)}", + ) + ) + else: + scale_value = scale_value_raw + + 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_value_raw = raw["name"] + if not isinstance(name_value_raw, str): + violations.append(Violation(path="name", reason="expected string")) + else: + 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_value_raw)}", + ) + ) + if len(name_value_raw) > 64: + violations.append( + Violation( + path="name", + reason=f"must have length <= 64, got {len(name_value_raw)}", + ) + ) + + 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_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_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_value_raw = raw["active"] + if not isinstance(active_value_raw, bool): + violations.append(Violation(path="active", reason="expected boolean")) + else: + active_value = active_value_raw + + nickname_value: str | None = None + if "nickname" in raw: + 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_value_raw, str): + violations.append( + Violation(path="nickname", reason="expected string") + ) + else: + 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_value_raw)}", + ) + ) + + code_value: str | None = None + if "code" in raw: + 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_value_raw, str): + violations.append(Violation(path="code", reason="expected string")) + else: + 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_value_raw)}", + ) + ) + if len(code_value_raw) > 5: + violations.append( + Violation( + path="code", + reason=f"must have length <= 5, got {len(code_value_raw)}", + ) + ) + + sku_value: str | None = None + if "sku" in raw: + 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_value_raw, str): + violations.append(Violation(path="sku", reason="expected string")) + else: + 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_value_raw)}", + ) + ) + + phrase_value: str | None = None + if "phrase" in raw: + 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_value_raw, str): + violations.append( + Violation(path="phrase", reason="expected string") + ) + else: + 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_value_raw)}", + ) + ) + + request_id_value: str | None = None + if "requestId" in raw: + 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_value_raw, str): + violations.append( + Violation(path="requestId", reason="expected string") + ) + else: + 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_value_raw)}", + ) + ) + + contact_email_value: str | None = None + if "contactEmail" in raw: + 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_value_raw, str): + violations.append( + Violation(path="contactEmail", reason="expected string") + ) + else: + contact_email_value = contact_email_value_raw + if ( + 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_value_raw)}", + ) + ) + + host_value: str | None = None + if "host" in raw: + 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_value_raw, str): + violations.append(Violation(path="host", reason="expected string")) + else: + host_value = host_value_raw + if ( + 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_value_raw)}", + ) + ) + + homepage_value: str | None = None + if "homepage" in raw: + 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_value_raw, str): + violations.append( + Violation(path="homepage", reason="expected string") + ) + else: + 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_value_raw)}", + ) + ) + + gateway_value: str | None = None + if "gateway" in raw: + 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_value_raw, str): + violations.append( + Violation(path="gateway", reason="expected string") + ) + else: + 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_value_raw)}", + ) + ) + + blob_value: bytes | None = None + if "blob" in raw: + 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_value_raw, str): + violations.append(Violation(path="blob", reason="expected string")) + else: + blob_value_parsed = _parse_base64( + blob_value_raw, "blob", violations + ) + if blob_value_parsed is not None: + blob_value = blob_value_parsed + + url_blob_value: bytes | None = None + if "urlBlob" in raw: + 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_value_raw, str): + violations.append( + Violation(path="urlBlob", reason="expected string") + ) + else: + url_blob_value_parsed = _parse_base64url( + url_blob_value_raw, "urlBlob", violations + ) + if url_blob_value_parsed is not None: + url_blob_value = url_blob_value_parsed + + retries_value: int | None = None + if "retries" in raw: + retries_value_raw = raw["retries"] + if retries_value_raw is None: + violations.append( + Violation(path="retries", reason="explicit null not allowed") + ) + else: + retries_value_parsed = _parse_spec_integer( + retries_value_raw, "retries", violations + ) + if retries_value_parsed is not None: + retries_value = retries_value_parsed + + verbose_value: bool | None = None + if "verbose" in raw: + 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_value_raw, bool): + violations.append( + Violation(path="verbose", reason="expected boolean") + ) + else: + verbose_value = verbose_value_raw + + greeting_value: str | None = None + if "greeting" in raw: + 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_value_raw, str): + violations.append( + Violation(path="greeting", reason="expected string") + ) + else: + greeting_value = greeting_value_raw + + debug_value: bool | None = None + if "debug" in raw: + 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_value_raw, bool): + violations.append( + Violation(path="debug", reason="expected boolean") + ) + else: + debug_value = debug_value_raw + + legacy_id_py_value: str | None = None + if "legacyId" in raw: + 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_value_raw, str): + violations.append( + Violation(path="legacyId", reason="expected string") + ) + else: + legacy_id_py_value = legacy_id_py_value_raw + + middle_name_value: str | None = None + if "middleName" in raw: + middle_name_value_raw = raw["middleName"] + if middle_name_value_raw is None: + middle_name_value = None + else: + if not isinstance(middle_name_value_raw, str): + violations.append( + Violation(path="middleName", reason="expected string") + ) + else: + middle_name_value = middle_name_value_raw + + category_value: str | None = None + if "category" not in raw: + violations.append(Violation(path="category", reason="required")) + else: + category_value_raw = raw["category"] + if category_value_raw is None: + category_value = None + else: + if not isinstance(category_value_raw, str): + violations.append( + Violation(path="category", reason="expected string") + ) + else: + category_value = category_value_raw + + priority_value: int | None = None + if "priority" in raw: + priority_value_raw = raw["priority"] + if priority_value_raw is None: + violations.append( + Violation(path="priority", reason="explicit null not allowed") + ) + else: + priority_value_parsed = _parse_spec_integer( + priority_value_raw, "priority", violations + ) + 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_value}", + ) + ) + if priority_value > 10: + violations.append( + Violation( + path="priority", + reason=f"must be <= 10, got {priority_value}", + ) + ) + + level_value: int | None = None + if "level" in raw: + level_value_raw = raw["level"] + if level_value_raw is None: + violations.append( + Violation(path="level", reason="explicit null not allowed") + ) + else: + 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_value}" + ) + ) + + ratio_value: float | None = None + if "ratio" in raw: + 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_value_raw, bool) + and isinstance(ratio_value_raw, (int, float)) + ): + violations.append(Violation(path="ratio", reason="expected number")) + else: + ratio_value = ratio_value_raw + if not ( + -1.7976931348623157e308 + <= ratio_value_raw + <= 1.7976931348623157e308 + ): + violations.append( + Violation( + path="ratio", + 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_value: int | None = None + if "step" in raw: + step_value_raw = raw["step"] + if step_value_raw is None: + violations.append( + Violation(path="step", reason="explicit null not allowed") + ) + else: + 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_value}", + ) + ) + + tags_value: list[str] | None = None + if "tags" in raw: + 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_value_raw, list): + violations.append(Violation(path="tags", reason="expected array")) + else: + tags_value_list: list[str] = [] + for tags_value_index, tags_value_element in enumerate( + typing.cast("list[typing.Any]", tags_value_raw) + ): + 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_value_item_path, reason="expected string" + ) + ) + else: + 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_value_list)}", + ) + ) + if len(tags_value_list) > 5: + violations.append( + Violation( + path="tags", + reason=f"must have at most 5 items, got {len(tags_value_list)}", + ) + ) + tags_value = tags_value_list + + aliases_value: list[str] | None = None + if "aliases" in raw: + 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_value_raw, list): + violations.append( + Violation(path="aliases", reason="expected array") + ) + else: + aliases_value_list: list[str] = [] + for aliases_value_index, aliases_value_element in enumerate( + typing.cast("list[typing.Any]", aliases_value_raw) + ): + 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_value_item_path, + reason="expected string", + ) + ) + else: + 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_value: list[str] | None = None + if "roles" in raw: + 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_value_raw, list): + violations.append(Violation(path="roles", reason="expected array")) + else: + roles_value_list: list[str] = [] + for roles_value_index, roles_value_element in enumerate( + typing.cast("list[typing.Any]", roles_value_raw) + ): + 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_value_item_path, reason="expected string" + ) + ) + else: + roles_value_item = roles_value_element + roles_value_list.append(roles_value_item) + _check_contains( + roles_value_list, + lambda element: element in ("admin",), + 1, + 2, + True, + "roles", + violations, + ) + roles_value = roles_value_list + + id_or_name_value: str | int | None = None + if "idOrName" in raw: + 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_value_parsed = _showcase_id_or_name_from_transfer_type( + id_or_name_value_raw, "idOrName", violations + ) + if id_or_name_value_parsed is not None: + id_or_name_value = id_or_name_value_parsed + + mode_value: typing.Literal["auto", "manual"] | int | None = None + if "mode" in raw: + mode_value_raw = raw["mode"] + if mode_value_raw is None: + violations.append( + Violation(path="mode", reason="explicit null not allowed") + ) + else: + mode_value_parsed = _showcase_mode_from_transfer_type( + mode_value_raw, "mode", violations + ) + if mode_value_parsed is not None: + mode_value = mode_value_parsed + + payload_value: dict[str, typing.Any] | str | None = None + if "payload" in raw: + payload_value_raw = raw["payload"] + if payload_value_raw is None: + violations.append( + Violation(path="payload", reason="explicit null not allowed") + ) + else: + payload_value_parsed = _showcase_payload_from_transfer_type( + payload_value_raw, "payload", violations + ) + if payload_value_parsed is not None: + payload_value = payload_value_parsed + + detail_value: ShowcaseDetailObject | str | None = None + if "detail" in raw: + detail_value_raw = raw["detail"] + if detail_value_raw is None: + violations.append( + Violation(path="detail", reason="explicit null not allowed") + ) + else: + detail_value_parsed = _showcase_detail_from_transfer_type( + detail_value_raw, "detail", violations + ) + if detail_value_parsed is not None: + detail_value = detail_value_parsed + + shape_or_name_value: Circle | Square | str | None = None + if "shapeOrName" in raw: + 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_value_parsed = _showcase_shape_or_name_from_transfer_type( + shape_or_name_value_raw, "shapeOrName", violations + ) + if shape_or_name_value_parsed is not None: + shape_or_name_value = shape_or_name_value_parsed + + measurements_value: list[float] | str | None = None + if "measurements" in raw: + measurements_value_raw = raw["measurements"] + if measurements_value_raw is None: + violations.append( + Violation(path="measurements", reason="explicit null not allowed") + ) + else: + measurements_value_parsed = _showcase_measurements_from_transfer_type( + measurements_value_raw, "measurements", violations + ) + if measurements_value_parsed is not None: + measurements_value = measurements_value_parsed + + shapes_value: list[Shape] | None = None + if "shapes" in raw: + 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_value_raw, list): + violations.append(Violation(path="shapes", reason="expected array")) + else: + shapes_value_list: list[Shape] = [] + for shapes_value_index, shapes_value_element in enumerate( + typing.cast("list[typing.Any]", shapes_value_raw) + ): + 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_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_value: list[ShowcaseSegmentsItem] | None = None + if "segments" in raw: + 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_value_raw, list): + violations.append( + Violation(path="segments", reason="expected array") + ) + else: + segments_value_list: list[ShowcaseSegmentsItem] = [] + for segments_value_index, segments_value_element in enumerate( + typing.cast("list[typing.Any]", segments_value_raw) + ): + segments_value_item_path = f"segments[{segments_value_index}]" + segments_value_item: ShowcaseSegmentsItem = typing.cast( + "typing.Any", None + ) + segments_value_item_parsed = ( + _showcase_segments_item_from_transfer_type( + segments_value_element, + segments_value_item_path, + violations, + ) + ) + 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_value: list[str | None] | None = None + if "slots" in raw: + 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_value_raw, list): + violations.append(Violation(path="slots", reason="expected array")) + else: + slots_value_list: list[str | None] = [] + for slots_value_index, slots_value_element in enumerate( + typing.cast("list[typing.Any]", slots_value_raw) + ): + 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_value_element, str): + violations.append( + Violation( + path=slots_value_item_path, + reason="expected string", + ) + ) + else: + slots_value_item = slots_value_element + slots_value_list.append(slots_value_item) + slots_value = slots_value_list + + grid_value: list[list[int]] | None = None + if "grid" in raw: + 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_value_raw, list): + violations.append(Violation(path="grid", reason="expected array")) + else: + grid_value_list: list[list[int]] = [] + for grid_value_index, grid_value_element in enumerate( + typing.cast("list[typing.Any]", grid_value_raw) + ): + 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_value_item_path, reason="expected array" + ) + ) + else: + 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_value_item_item_path = ( + f"{grid_value_item_path}[{grid_value_item_index}]" + ) + grid_value_item_item: int = typing.cast( + "typing.Any", 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_value_raw = raw["location"] + if location_value_raw is None: + violations.append( + Violation(path="location", reason="explicit null not allowed") + ) + else: + try: + location_value = ( + _ShowcaseLocationTransferTypeConverter().from_transfer_type( + location_value_raw, ShowcaseLocation + ) + ) + except ValidationError as error: + _collect(violations, "location", error) + + audit_value: ShowcaseAudit | None = None + if "audit" in raw: + audit_value_raw = raw["audit"] + if audit_value_raw is None: + audit_value = None + else: + try: + audit_value = ( + _ShowcaseAuditTransferTypeConverter().from_transfer_type( + audit_value_raw, ShowcaseAudit + ) + ) + except ValidationError as error: + _collect(violations, "audit", error) + + rows_value: list[ShowcaseRowsItem] | None = None + if "rows" in raw: + 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_value_raw, list): + violations.append(Violation(path="rows", reason="expected array")) + else: + rows_value_list: list[ShowcaseRowsItem] = [] + for rows_value_index, rows_value_element in enumerate( + typing.cast("list[typing.Any]", rows_value_raw) + ): + rows_value_item_path = f"rows[{rows_value_index}]" + rows_value_item: ShowcaseRowsItem = typing.cast( + "typing.Any", None + ) + try: + rows_value_item = _ShowcaseRowsItemTransferTypeConverter().from_transfer_type( + rows_value_element, ShowcaseRowsItem + ) + except ValidationError as error: + _collect(violations, rows_value_item_path, error) + rows_value_list.append(rows_value_item) + rows_value = rows_value_list + + ledger_py_value: ShowcaseLedger | None = None + if "ledger" in raw: + 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_value = ( + _ShowcaseLedgerTransferTypeConverter().from_transfer_type( + ledger_py_value_raw, ShowcaseLedger + ) + ) + except ValidationError as error: + _collect(violations, "ledger", error) + + metadata_value: ShowcaseMetadata | None = None + if "metadata" in raw: + metadata_value_raw = raw["metadata"] + if metadata_value_raw is None: + violations.append( + Violation(path="metadata", reason="explicit null not allowed") + ) + else: + try: + metadata_value = ( + _ShowcaseMetadataTransferTypeConverter().from_transfer_type( + metadata_value_raw, ShowcaseMetadata + ) + ) + except ValidationError as error: + _collect(violations, "metadata", error) + + quotas_value: Quotas | None = None + if "quotas" in raw: + quotas_value_raw = raw["quotas"] + if quotas_value_raw is None: + violations.append( + Violation(path="quotas", reason="explicit null not allowed") + ) + else: + try: + quotas_value = _QuotasTransferTypeConverter().from_transfer_type( + quotas_value_raw, Quotas + ) + except ValidationError as error: + _collect(violations, "quotas", error) + + tokens_value: Tokens | None = None + if "tokens" in raw: + tokens_value_raw = raw["tokens"] + if tokens_value_raw is None: + violations.append( + Violation(path="tokens", reason="explicit null not allowed") + ) + else: + try: + tokens_value = _TokensTransferTypeConverter().from_transfer_type( + tokens_value_raw, Tokens + ) + except ValidationError as error: + _collect(violations, "tokens", error) + + nicknames_value: Nicknames | None = None + if "nicknames" in raw: + nicknames_value_raw = raw["nicknames"] + if nicknames_value_raw is None: + violations.append( + Violation(path="nicknames", reason="explicit null not allowed") + ) + else: + try: + nicknames_value = ( + _NicknamesTransferTypeConverter().from_transfer_type( + nicknames_value_raw, Nicknames + ) + ) + except ValidationError as error: + _collect(violations, "nicknames", error) + + choices_value: Choices | None = None + if "choices" in raw: + choices_value_raw = raw["choices"] + if choices_value_raw is None: + violations.append( + Violation(path="choices", reason="explicit null not allowed") + ) + else: + try: + choices_value = _ChoicesTransferTypeConverter().from_transfer_type( + choices_value_raw, Choices + ) + except ValidationError as error: + _collect(violations, "choices", error) + + extras_value: Extras | None = None + if "extras" in raw: + extras_value_raw = raw["extras"] + if extras_value_raw is None: + violations.append( + Violation(path="extras", reason="explicit null not allowed") + ) + else: + try: + extras_value = _ExtrasTransferTypeConverter().from_transfer_type( + extras_value_raw, Extras + ) + except ValidationError as error: + _collect(violations, "extras", error) + + shape_value: Shape | None = None + if "shape" in raw: + shape_value_raw = raw["shape"] + if shape_value_raw is None: + violations.append( + Violation(path="shape", reason="explicit null not allowed") + ) + else: + 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_value: Note | None = None + if "note" in raw: + note_value_raw = raw["note"] + if note_value_raw is None: + violations.append( + Violation(path="note", reason="explicit null not allowed") + ) + else: + 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_value: Address | None = None + if "address" in raw: + address_value_raw = raw["address"] + if address_value_raw is None: + violations.append( + Violation(path="address", reason="explicit null not allowed") + ) + else: + try: + address_value = _AddressTransferTypeConverter().from_transfer_type( + address_value_raw, Address + ) + except ValidationError as error: + _collect(violations, "address", error) + + labels_value: Labels | None = None + if "labels" in raw: + labels_value_raw = raw["labels"] + if labels_value_raw is None: + violations.append( + Violation(path="labels", reason="explicit null not allowed") + ) + else: + try: + labels_value = _LabelsTransferTypeConverter().from_transfer_type( + labels_value_raw, Labels + ) + except ValidationError as error: + _collect(violations, "labels", error) + + settings_value: Settings | None = None + if "settings" in raw: + settings_value_raw = raw["settings"] + if settings_value_raw is None: + violations.append( + Violation(path="settings", reason="explicit null not allowed") + ) + else: + try: + settings_value = ( + _SettingsTransferTypeConverter().from_transfer_type( + settings_value_raw, Settings + ) + ) + except ValidationError as error: + _collect(violations, "settings", error) + + attributes_value: Attributes | None = None + if "attributes" in raw: + attributes_value_raw = raw["attributes"] + if attributes_value_raw is None: + violations.append( + Violation(path="attributes", reason="explicit null not allowed") + ) + else: + try: + attributes_value = ( + _AttributesTransferTypeConverter().from_transfer_type( + attributes_value_raw, Attributes + ) + ) + except ValidationError as error: + _collect(violations, "attributes", error) + + contact_value: ContactPy | None = None + if "contact" in raw: + contact_value_raw = raw["contact"] + if contact_value_raw is None: + violations.append( + Violation(path="contact", reason="explicit null not allowed") + ) + else: + try: + contact_value = ( + _ContactPyTransferTypeConverter().from_transfer_type( + contact_value_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_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 + 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 not (-1.7976931348623157e308 <= value.ratio <= 1.7976931348623157e308): + violations.append( + Violation( + path="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: + 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 in ("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}", + ) + ) + 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): + 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 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}") + ) + 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: + 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: + 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: + violations.append( + Violation( + path="measurements", + reason=f"must have at least 1 items, got {len(value.measurements)}", + ) + ) + _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)}", + ) + ) + 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: + 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: + 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: + try: + out["location"] = ( + _ShowcaseLocationTransferTypeConverter().to_transfer_type( + value.location + ) + ) + except ValidationError as error: + _collect(violations, "location", error) + if value.audit is not None: + try: + out["audit"] = _ShowcaseAuditTransferTypeConverter().to_transfer_type( + value.audit + ) + except ValidationError as error: + _collect(violations, "audit", error) + if value.rows is not None: + 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: + try: + out["ledger"] = _ShowcaseLedgerTransferTypeConverter().to_transfer_type( + value.ledger_py + ) + except ValidationError as error: + _collect(violations, "ledger", error) + if value.metadata is not None: + try: + out["metadata"] = ( + _ShowcaseMetadataTransferTypeConverter().to_transfer_type( + value.metadata + ) + ) + except ValidationError as error: + _collect(violations, "metadata", error) + if value.quotas is not None: + try: + out["quotas"] = _QuotasTransferTypeConverter().to_transfer_type( + value.quotas + ) + except ValidationError as error: + _collect(violations, "quotas", error) + if value.tokens is not None: + try: + out["tokens"] = _TokensTransferTypeConverter().to_transfer_type( + value.tokens + ) + except ValidationError as error: + _collect(violations, "tokens", error) + if value.nicknames is not None: + try: + out["nicknames"] = _NicknamesTransferTypeConverter().to_transfer_type( + value.nicknames + ) + except ValidationError as error: + _collect(violations, "nicknames", error) + if value.choices is not None: 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 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: _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) + out["choices"] = _ChoicesTransferTypeConverter().to_transfer_type( + value.choices + ) + except ValidationError as error: + _collect(violations, "choices", error) + if value.extras is not None: + try: + out["extras"] = _ExtrasTransferTypeConverter().to_transfer_type( + value.extras + ) + except ValidationError as error: + _collect(violations, "extras", error) + if value.shape is not None: + try: + out["shape"] = _shape_to_transfer_type(value.shape) + except ValidationError as error: + _collect(violations, "shape", error) + if value.note is not None: + try: + out["note"] = _note_to_transfer_type(value.note) + except ValidationError as error: + _collect(violations, "note", error) + if value.address is not None: + try: + out["address"] = _AddressTransferTypeConverter().to_transfer_type( + value.address + ) + except ValidationError as error: + _collect(violations, "address", error) + if value.labels is not None: + try: + out["labels"] = _LabelsTransferTypeConverter().to_transfer_type( + value.labels + ) + except ValidationError as error: + _collect(violations, "labels", error) + if value.settings is not None: + try: + out["settings"] = _SettingsTransferTypeConverter().to_transfer_type( + value.settings + ) + except ValidationError as error: + _collect(violations, "settings", error) + if value.attributes is not None: + try: + out["attributes"] = _AttributesTransferTypeConverter().to_transfer_type( + value.attributes + ) + except ValidationError as error: + _collect(violations, "attributes", error) + if value.contact is not None: + try: + out["contact"] = _ContactPyTransferTypeConverter().to_transfer_type( + value.contact + ) + except ValidationError as error: + _collect(violations, "contact", error) + if violations: + raise ValidationError(violations) + return out -class Showcase(pydantic.BaseModel): +@_transfer_type_convertible(_ShowcaseTransferTypeConverter) +@dataclasses.dataclass(slots=True, kw_only=True, init=False) +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 +2827,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 +2838,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 +2849,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 = dataclasses.field(default=None, repr=False) """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 = dataclasses.field(default=None, repr=False) """Greeting Optional string with a schema default, surfaced on read. """ - debug: bool = pydantic.Field(default=False) + _debug: bool | None = dataclasses.field(default=None, repr=False) """Debug flag Optional boolean with a schema default. """ @@ -788,7 +2948,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 +2958,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 +2994,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 +3009,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 +3017,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 +3028,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 +3038,452 @@ 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 + + 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: 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 + 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: int) -> None: + self._retries = value + + @retries.deleter + def retries(self) -> None: + self._retries = None + + @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: str) -> None: + self._greeting = value + + @greeting.deleter + def greeting(self) -> None: + self._greeting = None + + @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: bool) -> None: + self._debug = value + + @debug.deleter + def debug(self) -> None: + self._debug = 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_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_value_raw = raw["by"] + if not isinstance(by_value_raw, str): + violations.append(Violation(path="by", reason="expected string")) + else: + 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_value_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_value, + 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 - @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) +@_transfer_type_convertible(_ShowcaseAuditTransferTypeConverter) +@dataclasses.dataclass(slots=True, kw_only=True) +class ShowcaseAudit: + by: str -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_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_value_raw = raw["code"] + if not isinstance(code_value_raw, str): + violations.append(Violation(path="code", reason="expected string")) + else: + 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_value_raw)}", + ) + ) + hint_value: str | None = None + if "hint" in raw: + 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_value_raw, str): + violations.append(Violation(path="hint", reason="expected string")) + else: + hint_value = hint_value_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_value, + hint=hint_value, + 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: + violations: list[Violation] = [] + out: dict[str, typing.Any] = {} + for key, entry in value.additional_properties.items(): + try: + out[key] = _ShowcaseLedgerValueTransferTypeConverter().to_transfer_type( + entry + ) + except ValidationError as error: + _collect(violations, key, error) + if violations: + raise ValidationError(violations) + 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 +3492,165 @@ 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_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_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_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_LEDGER_VALUE_DECLARED: + additional_properties[key] = raw[key] + if violations: + raise ValidationError(violations) + return ShowcaseLedgerValue( + amount=amount_value, + 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 + out["amount"] = value.amount + 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 { - 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" - ) - amount: SpecInt = pydantic.Field(ge=0) +@_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 + ) - @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_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_value_raw = raw["city"] + if not isinstance(city_value_raw, str): + violations.append(Violation(path="city", reason="expected string")) + else: + 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_value_raw)}", + ) + ) -class ShowcaseLocation(pydantic.BaseModel): + geo_value: ShowcaseLocationGeo | None = None + if "geo" in raw: + geo_value_raw = raw["geo"] + if geo_value_raw is None: + violations.append( + Violation(path="geo", reason="explicit null not allowed") + ) + else: + try: + geo_value = ( + _ShowcaseLocationGeoTransferTypeConverter().from_transfer_type( + geo_value_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_value, + geo=geo_value, + 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: + 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: + raise ValidationError(violations) + return out + + +@_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 +3659,678 @@ 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 - city: str = pydantic.Field(min_length=1) + geo: ShowcaseLocationGeo | None = None - geo: ShowcaseLocationGeo | None = pydantic.Field(default=None) + additional_properties: dict[str, typing.Any] = dataclasses.field( + default_factory=dict + ) - _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) +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_value: float | None = None + if "lat" in raw: + 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_value_raw, bool) + and isinstance(lat_value_raw, (int, float)) + ): + violations.append(Violation(path="lat", reason="expected number")) + else: + 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}", + ) + ) - @pydantic.model_serializer(mode="wrap") - def _serialize( - self, - handler: typing.Callable[[pydantic.BaseModel], typing.Any], - ) -> dict[str, object]: - return _emit_set_fields(self, handler) + lon_value: float | None = None + if "lon" in raw: + 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_value_raw, bool) + and isinstance(lon_value_raw, (int, float)) + ): + violations.append(Violation(path="lon", reason="expected number")) + else: + 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: + if key not in _SHOWCASE_LOCATION_GEO_DECLARED: + additional_properties[key] = raw[key] + if violations: + raise ValidationError(violations) + return ShowcaseLocationGeo( + 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 -class ShowcaseLocationGeo(pydantic.BaseModel): - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - strict=True, populate_by_name=True, extra="allow" - ) - lat: float | None = pydantic.Field(default=None) +@_transfer_type_convertible(_ShowcaseLocationGeoTransferTypeConverter) +@dataclasses.dataclass(slots=True, kw_only=True) +class ShowcaseLocationGeo: + lat: float | None = None - lon: float | None = pydantic.Field(default=None) + lon: float | None = None - _OPTIONAL_NON_NULLABLE_FIELDS: typing.ClassVar[frozenset[str]] = frozenset( - {"lat", "lon"} + 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), + +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_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_value_raw = raw["cell"] + if not isinstance(cell_value_raw, str): + violations.append(Violation(path="cell", reason="expected string")) + else: + 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_value_raw)}", + ) + ) + + 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_value, + 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)}" ) ) - if errors: - raise pydantic.ValidationError.from_exception_data( - title=type(self).__name__, line_errors=errors - ) - return self + 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 dict(typing.cast(dict[str, object], self.model_extra or {})) +@_transfer_type_convertible(_ShowcaseRowsItemTransferTypeConverter) +@dataclasses.dataclass(slots=True, kw_only=True) +class ShowcaseRowsItem: + cell: str -class ShowcaseRowsItem(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 ) - cell: 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 _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_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_value_raw = raw["id"] + if not isinstance(id_value_raw, str): + violations.append(Violation(path="id", reason="expected string")) + else: + id_value = id_value_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_value, + ) + + @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_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_value_raw = raw["kind"] + if not isinstance(kind_value_raw, str): + violations.append(Violation(path="kind", reason="expected string")) + elif kind_value_raw not in ("square",): + violations.append(Violation(path="kind", reason='must equal "square"')) + else: + kind_value = kind_value_raw + + 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_value_raw = raw["side"] + if not ( + not isinstance(side_value_raw, bool) + and isinstance(side_value_raw, (int, float)) + ): + violations.append(Violation(path="side", reason="expected number")) + else: + 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}", + ) + ) -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] = {} + for key in raw: + if key not in _SQUARE_DECLARED: + additional_properties[key] = raw[key] + if violations: + raise ValidationError(violations) + return Square( + kind=kind_value, + side=side_value, + 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 + 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 + if violations: + raise ValidationError(violations) + return out - 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) +@_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_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_value_raw = raw["kind"] + if not isinstance(kind_value_raw, str): + violations.append(Violation(path="kind", reason="expected string")) + elif kind_value_raw not in ("text",): + violations.append(Violation(path="kind", reason='must equal "text"')) + else: + kind_value = kind_value_raw + + 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_value_raw = raw["body"] + if not isinstance(body_value_raw, str): + violations.append(Violation(path="body", reason="expected string")) + else: + 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_value_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_value, + body=body_value, + 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_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_value_raw = raw["id"] + if not isinstance(id_value_raw, str): + violations.append(Violation(path="id", reason="expected string")) + else: + id_value = id_value_raw + + kind_value: str | None = None + if "kind" in raw: + 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_value_raw, str): + violations.append(Violation(path="kind", reason="expected string")) + else: + kind_value = kind_value_raw + + 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_value_raw = raw["name"] + if not isinstance(name_value_raw, str): + violations.append(Violation(path="name", reason="expected string")) + else: + name_value = name_value_raw + + size_value: int | None = None + if "size" in raw: + size_value_raw = raw["size"] + if size_value_raw is None: + violations.append( + Violation(path="size", reason="explicit null not allowed") + ) + else: + 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_value}" + ) + ) + if size_value > 20: + violations.append( + Violation( + path="size", reason=f"must be <= 20, got {size_value}" + ) ) - ) - 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() - } + 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_value, + kind=kind_value, + name=name_value, + size=size_value, + 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 -class Widget(pydantic.BaseModel): +@_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,75 +4338,490 @@ 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_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_value_raw = raw["id"] + if not isinstance(id_value_raw, str): + violations.append(Violation(path="id", reason="expected string")) + else: + id_value = id_value_raw + + kind_value: str | None = None + if "kind" in raw: + 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_value_raw, str): + violations.append(Violation(path="kind", reason="expected string")) + else: + kind_value = kind_value_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_value, + kind=kind_value, + 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 ) - id: str = pydantic.Field() - kind: str | None = pydantic.Field(default=None) +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: + 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) + + +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: + 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) + + +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: + 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) + + +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 + - _OPTIONAL_NON_NULLABLE_FIELDS: typing.ClassVar[frozenset[str]] = frozenset({"kind"}) +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}")) + 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 + + +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 + + +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 + + +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 - @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_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): + 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_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_list)}", + ) + ) + _check_unique_items(items_list, path, violations) + return items_list + 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 +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--name` override (two or more inline object branches cannot derive @@ -1648,48 +4829,13 @@ def _serialize( 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 = ( - 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/__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/advanced/samples/python/json_schema/api/temporal/_definitions.py b/advanced/samples/python/json_schema/api/temporal/_definitions.py index 00b1bd60..3bf7f712 100644 --- a/advanced/samples/python/json_schema/api/temporal/_definitions.py +++ b/advanced/samples/python/json_schema/api/temporal/_definitions.py @@ -4,143 +4,183 @@ 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_date_time", + "_check_duration", + "_check_time", + "_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( @@ -154,6 +194,13 @@ def validate(value: list[typing.Any]) -> list[typing.Any]: 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: @@ -173,60 +220,199 @@ 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 _parse_date_time(value: object) -> object: - if not isinstance(value, str): - return value - 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}") +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" - return datetime.datetime.fromisoformat(normalized) + 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=_temporal_reason("date-time", value)) + ) + return None + return datetime.datetime.fromisoformat(_temporal_isoformat(value)) -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=_temporal_reason("date", 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}") - normalized = value.upper() - if normalized.endswith("Z"): - normalized = normalized[:-1] + "+00:00" - return datetime.time.fromisoformat(normalized) + violations.append(Violation(path=path, reason=_temporal_reason("time", value))) + return None + return datetime.time.fromisoformat(_temporal_isoformat(value)) -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=_temporal_reason("duration", value)) + ) + return None total = 0 number = "" for char in value[2:]: 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: - raise ValueError(f"must be a valid duration, got {value!r}") + 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 "" @@ -280,39 +466,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 +485,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..442114a3 100644 --- a/advanced/samples/python/json_schema/api/temporal/models.py +++ b/advanced/samples/python/json_schema/api/temporal/models.py @@ -2,20 +2,270 @@ 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, + _check_date_time, + _check_duration, + _check_time, + _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_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_value_raw = raw["createdAt"] + if not isinstance(created_at_value_raw, str): + violations.append(Violation(path="createdAt", reason="expected string")) + else: + created_at_value_parsed = _parse_date_time( + created_at_value_raw, "createdAt", violations + ) + if created_at_value_parsed is not None: + created_at_value = created_at_value_parsed + + 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_value_raw = raw["birthday"] + if not isinstance(birthday_value_raw, str): + violations.append(Violation(path="birthday", reason="expected string")) + else: + birthday_value_parsed = _parse_date( + birthday_value_raw, "birthday", violations + ) + if birthday_value_parsed is not None: + birthday_value = birthday_value_parsed + + 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_value_raw = raw["alarm"] + if not isinstance(alarm_value_raw, str): + violations.append(Violation(path="alarm", reason="expected string")) + else: + alarm_value_parsed = _parse_time(alarm_value_raw, "alarm", violations) + if alarm_value_parsed is not None: + alarm_value = alarm_value_parsed + + 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_value_raw = raw["timeout"] + if not isinstance(timeout_value_raw, str): + violations.append(Violation(path="timeout", reason="expected string")) + else: + timeout_value_parsed = _parse_duration( + timeout_value_raw, "timeout", violations + ) + if timeout_value_parsed is not None: + timeout_value = timeout_value_parsed + + updated_at_value: datetime.datetime | None = None + if "updatedAt" in raw: + 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_value_raw, str): + violations.append( + Violation(path="updatedAt", reason="expected string") + ) + else: + updated_at_value_parsed = _parse_date_time( + updated_at_value_raw, "updatedAt", violations + ) + if updated_at_value_parsed is not None: + updated_at_value = updated_at_value_parsed + + expires_on_value: datetime.date | None = None + if "expiresOn" in raw: + 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_value_raw, str): + violations.append( + Violation(path="expiresOn", reason="expected string") + ) + else: + expires_on_value_parsed = _parse_date( + expires_on_value_raw, "expiresOn", violations + ) + if expires_on_value_parsed is not None: + expires_on_value = expires_on_value_parsed + + reminder_value: datetime.time | None = None + if "reminder" in raw: + 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_value_raw, str): + violations.append( + Violation(path="reminder", reason="expected string") + ) + else: + reminder_value_parsed = _parse_time( + reminder_value_raw, "reminder", violations + ) + if reminder_value_parsed is not None: + reminder_value = reminder_value_parsed + + retry_delay_value: datetime.timedelta | None = None + if "retryDelay" in raw: + 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_value_raw, str): + violations.append( + Violation(path="retryDelay", reason="expected string") + ) + else: + retry_delay_value_parsed = _parse_duration( + retry_delay_value_raw, "retryDelay", violations + ) + if retry_delay_value_parsed is not None: + retry_delay_value = retry_delay_value_parsed + + deleted_at_value: datetime.datetime | None = None + if "deletedAt" in raw: + deleted_at_value_raw = raw["deletedAt"] + if deleted_at_value_raw is None: + deleted_at_value = None + else: + if not isinstance(deleted_at_value_raw, str): + violations.append( + Violation(path="deletedAt", reason="expected string") + ) + else: + deleted_at_value_parsed = _parse_date_time( + deleted_at_value_raw, "deletedAt", violations + ) + if deleted_at_value_parsed is not None: + deleted_at_value = deleted_at_value_parsed + + archived_on_value: datetime.date | None = None + if "archivedOn" in raw: + archived_on_value_raw = raw["archivedOn"] + if archived_on_value_raw is None: + archived_on_value = None + else: + if not isinstance(archived_on_value_raw, str): + violations.append( + Violation(path="archivedOn", reason="expected string") + ) + else: + archived_on_value_parsed = _parse_date( + archived_on_value_raw, "archivedOn", violations + ) + if archived_on_value_parsed is not None: + archived_on_value = archived_on_value_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_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) + 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 +273,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/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/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/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/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/chat/_definitions.py b/samples/python/chat/_definitions.py index 00b1bd60..3bf7f712 100644 --- a/samples/python/chat/_definitions.py +++ b/samples/python/chat/_definitions.py @@ -4,143 +4,183 @@ 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_date_time", + "_check_duration", + "_check_time", + "_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( @@ -154,6 +194,13 @@ def validate(value: list[typing.Any]) -> list[typing.Any]: 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: @@ -173,60 +220,199 @@ 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 _parse_date_time(value: object) -> object: - if not isinstance(value, str): - return value - 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}") +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" - return datetime.datetime.fromisoformat(normalized) + 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=_temporal_reason("date-time", value)) + ) + return None + return datetime.datetime.fromisoformat(_temporal_isoformat(value)) -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=_temporal_reason("date", 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}") - normalized = value.upper() - if normalized.endswith("Z"): - normalized = normalized[:-1] + "+00:00" - return datetime.time.fromisoformat(normalized) + violations.append(Violation(path=path, reason=_temporal_reason("time", value))) + return None + return datetime.time.fromisoformat(_temporal_isoformat(value)) -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=_temporal_reason("duration", value)) + ) + return None total = 0 number = "" for char in value[2:]: 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: - raise ValueError(f"must be a valid duration, got {value!r}") + 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 "" @@ -280,39 +466,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 +485,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..7a10a675 100644 --- a/samples/python/chat/models.py +++ b/samples/python/chat/models.py @@ -2,206 +2,522 @@ 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" - ) - - 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) - - -class Labels(pydantic.BaseModel): - """Arbitrary string key/value labels.""" +_ROOM_DECLARED: frozenset[str] = frozenset( + {"roomId", "displayName", "topic", "members", "labels"} +) - 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"], - ) - ) - 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), +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_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_value_raw = raw["roomId"] + if not isinstance(room_id_value_raw, str): + violations.append(Violation(path="roomId", reason="expected string")) + else: + room_id_value = room_id_value_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_value, + ) + + @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)}" ) ) - 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 { - 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() - } +@_transfer_type_convertible(_LabelsTransferTypeConverter) +@dataclasses.dataclass(slots=True, kw_only=True) +class Labels: + """Arbitrary string key/value labels.""" -class Message(pydantic.BaseModel): + 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_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_value_raw = raw["kind"] + if not isinstance(kind_value_raw, str): + violations.append(Violation(path="kind", reason="expected string")) + elif kind_value_raw not in ("text",): + violations.append(Violation(path="kind", reason='must equal "text"')) + else: + kind_value = kind_value_raw + + 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_value_raw = raw["body"] + if not isinstance(body_value_raw, str): + violations.append(Violation(path="body", reason="expected string")) + else: + body_value = body_value_raw + + reply_to_id_value: str | None = None + if "replyToId" in raw: + 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_value_raw, str): + violations.append( + Violation(path="replyToId", reason="expected string") + ) + else: + reply_to_id_value = reply_to_id_value_raw + + priority_value: int | None = None + if "priority" in raw: + priority_value_raw = raw["priority"] + if priority_value_raw is None: + violations.append( + Violation(path="priority", reason="explicit null not allowed") + ) + else: + priority_value_parsed = _parse_spec_integer( + priority_value_raw, "priority", violations + ) + if priority_value_parsed is not None: + priority_value = priority_value_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_value, + body=body_value, + reply_to_id=reply_to_id_value, + priority=priority_value, + ) + + @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, init=False) +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 = dataclasses.field(default=None, repr=False) """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( + def __init__( self, - handler: typing.Callable[[pydantic.BaseModel], typing.Any], - ) -> dict[str, object]: - return _emit_set_fields(self, handler) - - -class Room(pydantic.BaseModel): + *, + kind: typing.Literal["text"] = "text", + body: str, + reply_to_id: str | None = None, + priority: int | None = None, + _priority: int | None = 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: int) -> None: + self._priority = value + + @priority.deleter + def priority(self) -> None: + self._priority = None + + +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_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_value_raw = raw["roomId"] + if not isinstance(room_id_value_raw, str): + violations.append(Violation(path="roomId", reason="expected string")) + else: + room_id_value = room_id_value_raw + + 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_value_raw = raw["displayName"] + if not isinstance(display_name_value_raw, str): + violations.append( + Violation(path="displayName", reason="expected string") + ) + else: + display_name_value = display_name_value_raw + + topic_value: str | None = None + if "topic" not in raw: + violations.append(Violation(path="topic", reason="required")) + else: + topic_value_raw = raw["topic"] + if topic_value_raw is None: + topic_value = None + else: + if not isinstance(topic_value_raw, str): + violations.append(Violation(path="topic", reason="expected string")) + else: + topic_value = topic_value_raw + + members_value: list[str] | None = None + if "members" in raw: + 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_value_raw, list): + violations.append( + Violation(path="members", reason="expected array") + ) + else: + members_value_list: list[str] = [] + for members_value_index, members_value_element in enumerate( + typing.cast("list[typing.Any]", members_value_raw) + ): + 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_value_item_path, + reason="expected string", + ) + ) + else: + members_value_item = members_value_element + members_value_list.append(members_value_item) + members_value = members_value_list + + labels_value: Labels | None = None + if "labels" in raw: + labels_value_raw = raw["labels"] + if labels_value_raw is None: + violations.append( + Violation(path="labels", reason="explicit null not allowed") + ) + else: + try: + labels_value = _LabelsTransferTypeConverter().from_transfer_type( + labels_value_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_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 + out["topic"] = value.topic + if value.members is not None: + out["members"] = value.members + if value.labels is not None: + 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 + + +@_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 = pydantic.Field(alias="roomId") + room_id: str - display_name: str = pydantic.Field(alias="displayName") + display_name: str - topic: str | None = pydantic.Field() + topic: str | None """Room topic; may be explicitly cleared to null.""" - members: list[str] | None = pydantic.Field(default=None) - - labels: Labels | None = pydantic.Field(default=None) + members: list[str] | None = None - _OPTIONAL_NON_NULLABLE_FIELDS: typing.ClassVar[frozenset[str]] = frozenset( - {"labels", "members"} - ) - - @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): - """Request to post a message.""" + labels: Labels | None = None - 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 ) - 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") +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_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_value_raw = raw["roomId"] + if not isinstance(room_id_value_raw, str): + violations.append(Violation(path="roomId", reason="expected string")) + else: + room_id_value = room_id_value_raw + + 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_value_raw = raw["message"] + try: + message_value = _MessageTransferTypeConverter().from_transfer_type( + message_value_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_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 + try: + out["message"] = _MessageTransferTypeConverter().to_transfer_type( + value.message + ) + except ValidationError as error: + _collect(violations, "message", error) + 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(_SendMessageInputTransferTypeConverter) +@dataclasses.dataclass(slots=True, kw_only=True) +class SendMessageInput: + """Request to post a message.""" -_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_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_value_raw = raw["messageId"] + if not isinstance(message_id_value_raw, str): + violations.append(Violation(path="messageId", reason="expected string")) + else: + message_id_value = message_id_value_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_value, + ) + + @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/__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/kb/_definitions.py b/samples/python/kb/_definitions.py index 00b1bd60..3bf7f712 100644 --- a/samples/python/kb/_definitions.py +++ b/samples/python/kb/_definitions.py @@ -4,143 +4,183 @@ 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_date_time", + "_check_duration", + "_check_time", + "_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( @@ -154,6 +194,13 @@ def validate(value: list[typing.Any]) -> list[typing.Any]: 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: @@ -173,60 +220,199 @@ 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 _parse_date_time(value: object) -> object: - if not isinstance(value, str): - return value - 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}") +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" - return datetime.datetime.fromisoformat(normalized) + 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=_temporal_reason("date-time", value)) + ) + return None + return datetime.datetime.fromisoformat(_temporal_isoformat(value)) -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=_temporal_reason("date", 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}") - normalized = value.upper() - if normalized.endswith("Z"): - normalized = normalized[:-1] + "+00:00" - return datetime.time.fromisoformat(normalized) + violations.append(Violation(path=path, reason=_temporal_reason("time", value))) + return None + return datetime.time.fromisoformat(_temporal_isoformat(value)) -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=_temporal_reason("duration", value)) + ) + return None total = 0 number = "" for char in value[2:]: 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: - raise ValueError(f"must be a valid duration, got {value!r}") + 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 "" @@ -280,39 +466,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 +485,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..0d5b9f43 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,287 @@ 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_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_value_raw = raw["blockId"] + if not isinstance(block_id_value_raw, str): + violations.append(Violation(path="blockId", reason="expected string")) + else: + block_id_value = block_id_value_raw + + 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_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_value}" + ) + ) + + text_value: str | None = None + if "text" in raw: + 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_value_raw, str): + violations.append(Violation(path="text", reason="expected string")) + else: + text_value = text_value_raw + + style_value: BlockStyle | None = None + if "style" in raw: + style_value_raw = raw["style"] + if style_value_raw is None: + violations.append( + Violation(path="style", reason="explicit null not allowed") + ) + else: + try: + style_value = getattr( + BlockStyle, "__temporal_transfer_type_converter" + ).from_transfer_type(style_value_raw, BlockStyle) + except ValidationError as error: + _collect(violations, "style", error) + + page_value: Page | None = None + if "page" in raw: + page_value_raw = raw["page"] + if page_value_raw is None: + page_value = None + else: + try: + page_value = _PageTransferTypeConverter().from_transfer_type( + page_value_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_value, + order=order_value, + text=text_value, + style=style_value, + page=page_value, + ) + + @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: + 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: + try: + out["page"] = _PageTransferTypeConverter().to_transfer_type(value.page) + except ValidationError as error: + _collect(violations, "page", error) + 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_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_value_raw = raw["pageId"] + if not isinstance(page_id_value_raw, str): + violations.append(Violation(path="pageId", reason="expected string")) + else: + page_id_value = page_id_value_raw + + 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_value_raw = raw["title"] + if not isinstance(title_value_raw, str): + violations.append(Violation(path="title", reason="expected string")) + else: + title_value = title_value_raw + + 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_value_raw = raw["meta"] + try: + meta_value = getattr( + PageMeta, "__temporal_transfer_type_converter" + ).from_transfer_type(meta_value_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_value: list[Block] | None = None + if "blocks" in raw: + 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_value_raw, list): + violations.append(Violation(path="blocks", reason="expected array")) + else: + blocks_value_list: list[Block] = [] + for blocks_value_index, blocks_value_element in enumerate( + typing.cast("list[typing.Any]", blocks_value_raw) + ): + blocks_value_item_path = f"blocks[{blocks_value_index}]" + blocks_value_item: Block = typing.cast("typing.Any", None) + try: + blocks_value_item = ( + _BlockTransferTypeConverter().from_transfer_type( + blocks_value_element, Block + ) + ) + except ValidationError as error: + _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": + violations.append(Violation(path=key, reason="unknown field")) + if violations: + raise ValidationError(violations) + return Page( + page_id=page_id_value, + title=title_value, + meta=meta_value, + blocks=blocks_value, + ) -class Page(pydantic.BaseModel): + @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 + 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: + 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 + + +@_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..5d368d93 100644 --- a/samples/python/kb/content/block/models.py +++ b/samples/python/kb/content/block/models.py @@ -2,43 +2,97 @@ 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_value: bool | None = None + if "bold" in raw: + 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_value_raw, bool): + violations.append(Violation(path="bold", reason="expected boolean")) + else: + bold_value = bold_value_raw + + indent_value: int | None = None + if "indent" in raw: + indent_value_raw = raw["indent"] + if indent_value_raw is None: + violations.append( + Violation(path="indent", reason="explicit null not allowed") + ) + else: + 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_value}", + ) + ) + + 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_value, + indent=indent_value, + ) + + @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..5719f97f 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_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_value_raw = raw["author"] + if not isinstance(author_value_raw, str): + violations.append(Violation(path="author", reason="expected string")) + else: + author_value = author_value_raw + + word_count_value: int | None = None + if "wordCount" in raw: + 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_value_parsed = _parse_spec_integer( + word_count_value_raw, "wordCount", violations + ) + 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": + violations.append(Violation(path=key, reason="unknown field")) + if violations: + raise ValidationError(violations) + return PageMeta( + author=author_value, + word_count=word_count_value, + ) + + @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..3a3a2b70 100644 --- a/samples/python/kb/kb/models.py +++ b/samples/python/kb/kb/models.py @@ -2,57 +2,161 @@ 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_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_value_raw = raw["rootId"] + if not isinstance(root_id_value_raw, str): + violations.append(Violation(path="rootId", reason="expected string")) + else: + root_id_value = root_id_value_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_value, + ) + + @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_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_value_raw = raw["pageId"] + if not isinstance(page_id_value_raw, str): + violations.append(Violation(path="pageId", reason="expected string")) + else: + page_id_value = page_id_value_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_value, + ) + + @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_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_value_raw = raw["blockId"] + if not isinstance(block_id_value_raw, str): + violations.append(Violation(path="blockId", reason="expected string")) + else: + block_id_value = block_id_value_raw + + 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_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": + violations.append(Violation(path=key, reason="unknown field")) + if violations: + raise ValidationError(violations) + return PutBlockOutput( + block_id=block_id_value, + revision=revision_value, + ) + + @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..56213f62 100644 --- a/samples/python/kb/tree/category/models.py +++ b/samples/python/kb/tree/category/models.py @@ -2,71 +2,190 @@ 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_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_value_raw = raw["id"] + if not isinstance(id_value_raw, str): + violations.append(Violation(path="id", reason="expected string")) + else: + id_value = id_value_raw + + 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_value_raw = raw["name"] + if not isinstance(name_value_raw, str): + violations.append(Violation(path="name", reason="expected string")) + else: + name_value = name_value_raw + + children_value: list[Category] | None = None + if "children" in raw: + 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_value_raw, list): + violations.append( + Violation(path="children", reason="expected array") + ) + else: + children_value_list: list[Category] = [] + for children_value_index, children_value_element in enumerate( + typing.cast("list[typing.Any]", children_value_raw) + ): + children_value_item_path = f"children[{children_value_index}]" + children_value_item: Category = typing.cast("typing.Any", None) + try: + children_value_item = ( + _CategoryTransferTypeConverter().from_transfer_type( + children_value_element, Category + ) + ) + except ValidationError as error: + _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": + violations.append(Violation(path=key, reason="unknown field")) + if violations: + raise ValidationError(violations) + return Category( + 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: + 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 + + +@_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_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_value_raw = raw["swatches"] + if not isinstance(swatches_value_raw, list): + violations.append(Violation(path="swatches", reason="expected array")) + else: + swatches_value_list: list[str] = [] + for swatches_value_index, swatches_value_element in enumerate( + typing.cast("list[typing.Any]", swatches_value_raw) + ): + 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_value_item_path, reason="expected string" + ) + ) + else: + 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": + violations.append(Violation(path=key, reason="unknown field")) + if violations: + raise ValidationError(violations) + return Palette( + swatches=swatches_value, + ) + + @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/__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/showcase/_definitions.py b/samples/python/showcase/_definitions.py index 00b1bd60..3bf7f712 100644 --- a/samples/python/showcase/_definitions.py +++ b/samples/python/showcase/_definitions.py @@ -4,143 +4,183 @@ 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_date_time", + "_check_duration", + "_check_time", + "_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( @@ -154,6 +194,13 @@ def validate(value: list[typing.Any]) -> list[typing.Any]: 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: @@ -173,60 +220,199 @@ 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 _parse_date_time(value: object) -> object: - if not isinstance(value, str): - return value - 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}") +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" - return datetime.datetime.fromisoformat(normalized) + 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=_temporal_reason("date-time", value)) + ) + return None + return datetime.datetime.fromisoformat(_temporal_isoformat(value)) -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=_temporal_reason("date", 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}") - normalized = value.upper() - if normalized.endswith("Z"): - normalized = normalized[:-1] + "+00:00" - return datetime.time.fromisoformat(normalized) + violations.append(Violation(path=path, reason=_temporal_reason("time", value))) + return None + return datetime.time.fromisoformat(_temporal_isoformat(value)) -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=_temporal_reason("duration", value)) + ) + return None total = 0 number = "" for char in value[2:]: 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: - raise ValueError(f"must be a valid duration, got {value!r}") + 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 "" @@ -280,39 +466,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 +485,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..5aca212d 100644 --- a/samples/python/showcase/models.py +++ b/samples/python/showcase/models.py @@ -2,234 +2,551 @@ 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.""" +_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) - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - strict=True, populate_by_name=True, extra="allow" - ) - street: str = pydantic.Field() +_ADDRESS_DECLARED: frozenset[str] = frozenset({"street", "city", "zip"}) - city: str | None = pydantic.Field(default=None) - zip: SpecInt | None = pydantic.Field(default=None) +_CIRCLE_DECLARED: frozenset[str] = frozenset({"kind", "radius"}) - _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) - - @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). - """ +_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"}) + - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - strict=True, populate_by_name=True, extra="allow" +_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_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_value_raw = raw["street"] + if not isinstance(street_value_raw, str): + violations.append(Violation(path="street", reason="expected string")) + else: + street_value = street_value_raw + + city_value: str | None = None + if "city" in raw: + 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_value_raw, str): + violations.append(Violation(path="city", reason="expected string")) + else: + city_value = city_value_raw + + zip_value: int | None = None + if "zip" in raw: + zip_value_raw = raw["zip"] + if zip_value_raw is None: + violations.append( + Violation(path="zip", reason="explicit null not allowed") + ) + else: + 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: + if key not in _ADDRESS_DECLARED: + additional_properties[key] = raw[key] + if violations: + raise ValidationError(violations) + return Address( + street=street_value, + city=city_value, + zip=zip_value, + 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"], - ) - ) - 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), + +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(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(raw) > 3: + violations.append( + Violation( + path="", reason=f"must have at most 3 properties, got {len(raw)}" ) ) - for key in extra: + for key in raw: 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)}", + ) + ) + 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(out) > 3: + violations.append( + Violation( + path="", reason=f"must have at most 3 properties, got {len(out)}" + ) ) - return self + for key in out: + if len(key) > 8: + 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 - @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() - } +@_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). + """ -class Choices(pydantic.BaseModel): + 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 + ) + 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: + violations: list[Violation] = [] + out: dict[str, typing.Any] = {} + for key, entry in value.additional_properties.items(): + try: + out[key] = _choices_value_to_transfer_type(entry) + except ValidationError as error: + _collect(violations, key, error) + if violations: + raise ValidationError(violations) + 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"], + +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_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_value_raw = raw["kind"] + if not isinstance(kind_value_raw, str): + violations.append(Violation(path="kind", reason="expected string")) + elif kind_value_raw not in ("circle",): + violations.append(Violation(path="kind", reason='must equal "circle"')) + else: + kind_value = kind_value_raw + + 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_value_raw = raw["radius"] + if not ( + not isinstance(radius_value_raw, bool) + and isinstance(radius_value_raw, (int, float)) + ): + violations.append(Violation(path="radius", reason="expected number")) + else: + 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}", ) ) - 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() - } + 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_value, + radius=radius_value, + 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 + 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 + if violations: + raise ValidationError(violations) + return out -class Circle(pydantic.BaseModel): +@_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_value: str | None = None + if "email" in raw: + 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_value_raw, str): + violations.append(Violation(path="email", reason="expected string")) + else: + email_value = email_value_raw + + shipping_street_value: str | None = None + if "shippingStreet" in raw: + 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_value_raw, str): + violations.append( + Violation(path="shippingStreet", reason="expected string") + ) + else: + shipping_street_value = shipping_street_value_raw + + shipping_zip_value: str | None = None + if "shippingZip" in raw: + 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_value_raw, str): + violations.append( + Violation(path="shippingZip", reason="expected string") + ) + else: + shipping_zip_value = shipping_zip_value_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_value, + shipping_street=shipping_street_value, + shipping_zip=shipping_zip_value, + 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 +556,2270 @@ 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 - - _OPTIONAL_NON_NULLABLE_FIELDS: typing.ClassVar[frozenset[str]] = frozenset( - {"email", "shippingStreet", "shippingZip", "shipping_street", "shipping_zip"} - ) + if violations: + raise ValidationError(violations) + return out - @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 - - @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 {})) + if violations: + raise ValidationError(violations) + return out -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_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_value_raw = raw["kind"] + if not isinstance(kind_value_raw, str): + violations.append(Violation(path="kind", reason="expected string")) + elif kind_value_raw not in ("link",): + violations.append(Violation(path="kind", reason='must equal "link"')) + else: + kind_value = kind_value_raw + + 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_value_raw = raw["href"] + if not isinstance(href_value_raw, str): + violations.append(Violation(path="href", reason="expected string")) + else: + 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_value_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_value, + href=href_value, + 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() - } + out["href"] = value.href + for key, entry in value.additional_properties.items(): + out[key] = entry + if violations: + raise ValidationError(violations) + return out -class LinkNote(pydantic.BaseModel): +@_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}" ) ) - 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: _NICKNAMES_MEMBER.dump_python(value, mode="json", by_alias=True) - for key, value in typing.cast( - dict[str, object], self.model_extra or {} - ).items() - } + 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 -class Quotas(pydantic.BaseModel): +@_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. """ - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - strict=True, populate_by_name=True, extra="allow" - ) + 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_value: str | None = None + if "theme" in raw: + 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_value_raw, str): + violations.append(Violation(path="theme", reason="expected string")) + else: + theme_value = theme_value_raw + + font_size_value: int | None = None + if "fontSize" in raw: + 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_value_parsed = _parse_spec_integer( + font_size_value_raw, "fontSize", violations + ) + 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": + violations.append(Violation(path=key, reason="unknown field")) + if violations: + raise ValidationError(violations) + return Settings( + theme=theme_value, + font_size=font_size_value, + ) + + @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.""" - @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()): + 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_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_value_raw = raw["kind"] + if not isinstance(kind_value_raw, str): + violations.append(Violation(path="kind", reason="expected string")) + elif kind_value_raw not in ("showcase",): + violations.append( + Violation(path="kind", reason='must equal "showcase"') + ) + else: + kind_value = kind_value_raw + + 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_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 not in (1,): + violations.append(Violation(path="revision", reason="must equal 1")) + else: + revision_value = revision_value_parsed + + 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_value_raw = raw["enabled"] + if not isinstance(enabled_value_raw, bool): + violations.append(Violation(path="enabled", reason="expected boolean")) + elif enabled_value_raw not in (True,): + violations.append(Violation(path="enabled", reason="must equal true")) + else: + enabled_value = enabled_value_raw + + 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_value_raw = raw["status"] + if not isinstance(status_value_raw, str): + violations.append(Violation(path="status", reason="expected string")) + elif status_value_raw not in ("active", "inactive", "pending"): + violations.append( + Violation( + path="status", + reason=f'must be one of ["active", "inactive", "pending"], got {_quote(status_value_raw)}', + ) + ) + else: + status_value = status_value_raw + + 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_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 not in (1, 2, 3): + violations.append( + Violation( + path="tier", + reason=f"must be one of [1, 2, 3], got {_quote(tier_value_parsed)}", + ) + ) + else: + tier_value = tier_value_parsed + + 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_value_raw = raw["scale"] + if not ( + not isinstance(scale_value_raw, bool) + and isinstance(scale_value_raw, (int, float)) + ): + violations.append(Violation(path="scale", reason="expected number")) + elif scale_value_raw not in (1.5, 2.5): + violations.append( + Violation( + path="scale", + reason=f"must be one of [1.5, 2.5], got {_quote(scale_value_raw)}", + ) + ) + else: + scale_value = scale_value_raw + + 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_value_raw = raw["name"] + if not isinstance(name_value_raw, str): + violations.append(Violation(path="name", reason="expected string")) + else: + 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_value_raw)}", + ) + ) + if len(name_value_raw) > 64: + violations.append( + Violation( + path="name", + reason=f"must have length <= 64, got {len(name_value_raw)}", + ) + ) + + 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_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_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_value_raw = raw["active"] + if not isinstance(active_value_raw, bool): + violations.append(Violation(path="active", reason="expected boolean")) + else: + active_value = active_value_raw + + nickname_value: str | None = None + if "nickname" in raw: + 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_value_raw, str): + violations.append( + Violation(path="nickname", reason="expected string") + ) + else: + 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_value_raw)}", + ) + ) + + code_value: str | None = None + if "code" in raw: + 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_value_raw, str): + violations.append(Violation(path="code", reason="expected string")) + else: + 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_value_raw)}", + ) + ) + if len(code_value_raw) > 5: + violations.append( + Violation( + path="code", + reason=f"must have length <= 5, got {len(code_value_raw)}", + ) + ) + + sku_value: str | None = None + if "sku" in raw: + 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_value_raw, str): + violations.append(Violation(path="sku", reason="expected string")) + else: + 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_value_raw)}", + ) + ) + + phrase_value: str | None = None + if "phrase" in raw: + 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_value_raw, str): + violations.append( + Violation(path="phrase", reason="expected string") + ) + else: + 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_value_raw)}", + ) + ) + + request_id_value: str | None = None + if "requestId" in raw: + 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_value_raw, str): + violations.append( + Violation(path="requestId", reason="expected string") + ) + else: + 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_value_raw)}", + ) + ) + + contact_email_value: str | None = None + if "contactEmail" in raw: + 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_value_raw, str): + violations.append( + Violation(path="contactEmail", reason="expected string") + ) + else: + contact_email_value = contact_email_value_raw + if ( + 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_value_raw)}", + ) + ) + + host_value: str | None = None + if "host" in raw: + 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_value_raw, str): + violations.append(Violation(path="host", reason="expected string")) + else: + host_value = host_value_raw + if ( + 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_value_raw)}", + ) + ) + + homepage_value: str | None = None + if "homepage" in raw: + 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_value_raw, str): + violations.append( + Violation(path="homepage", reason="expected string") + ) + else: + 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_value_raw)}", + ) + ) + + gateway_value: str | None = None + if "gateway" in raw: + 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_value_raw, str): + violations.append( + Violation(path="gateway", reason="expected string") + ) + else: + 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_value_raw)}", + ) + ) + + blob_value: bytes | None = None + if "blob" in raw: + 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_value_raw, str): + violations.append(Violation(path="blob", reason="expected string")) + else: + blob_value_parsed = _parse_base64( + blob_value_raw, "blob", violations + ) + if blob_value_parsed is not None: + blob_value = blob_value_parsed + + url_blob_value: bytes | None = None + if "urlBlob" in raw: + 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_value_raw, str): + violations.append( + Violation(path="urlBlob", reason="expected string") + ) + else: + url_blob_value_parsed = _parse_base64url( + url_blob_value_raw, "urlBlob", violations + ) + if url_blob_value_parsed is not None: + url_blob_value = url_blob_value_parsed + + retries_value: int | None = None + if "retries" in raw: + retries_value_raw = raw["retries"] + if retries_value_raw is None: + violations.append( + Violation(path="retries", reason="explicit null not allowed") + ) + else: + retries_value_parsed = _parse_spec_integer( + retries_value_raw, "retries", violations + ) + if retries_value_parsed is not None: + retries_value = retries_value_parsed + + verbose_value: bool | None = None + if "verbose" in raw: + 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_value_raw, bool): + violations.append( + Violation(path="verbose", reason="expected boolean") + ) + else: + verbose_value = verbose_value_raw + + greeting_value: str | None = None + if "greeting" in raw: + 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_value_raw, str): + violations.append( + Violation(path="greeting", reason="expected string") + ) + else: + greeting_value = greeting_value_raw + + debug_value: bool | None = None + if "debug" in raw: + 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_value_raw, bool): + violations.append( + Violation(path="debug", reason="expected boolean") + ) + else: + debug_value = debug_value_raw + + legacy_id_py_value: str | None = None + if "legacyId" in raw: + 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_value_raw, str): + violations.append( + Violation(path="legacyId", reason="expected string") + ) + else: + legacy_id_py_value = legacy_id_py_value_raw + + middle_name_value: str | None = None + if "middleName" in raw: + middle_name_value_raw = raw["middleName"] + if middle_name_value_raw is None: + middle_name_value = None + else: + if not isinstance(middle_name_value_raw, str): + violations.append( + Violation(path="middleName", reason="expected string") + ) + else: + middle_name_value = middle_name_value_raw + + category_value: str | None = None + if "category" not in raw: + violations.append(Violation(path="category", reason="required")) + else: + category_value_raw = raw["category"] + if category_value_raw is None: + category_value = None + else: + if not isinstance(category_value_raw, str): + violations.append( + Violation(path="category", reason="expected string") + ) + else: + category_value = category_value_raw + + priority_value: int | None = None + if "priority" in raw: + priority_value_raw = raw["priority"] + if priority_value_raw is None: + violations.append( + Violation(path="priority", reason="explicit null not allowed") + ) + else: + priority_value_parsed = _parse_spec_integer( + priority_value_raw, "priority", violations + ) + 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_value}", + ) + ) + if priority_value > 10: + violations.append( + Violation( + path="priority", + reason=f"must be <= 10, got {priority_value}", + ) + ) + + level_value: int | None = None + if "level" in raw: + level_value_raw = raw["level"] + if level_value_raw is None: + violations.append( + Violation(path="level", reason="explicit null not allowed") + ) + else: + 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_value}" + ) + ) + + ratio_value: float | None = None + if "ratio" in raw: + 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_value_raw, bool) + and isinstance(ratio_value_raw, (int, float)) + ): + violations.append(Violation(path="ratio", reason="expected number")) + else: + ratio_value = ratio_value_raw + if not ( + -1.7976931348623157e308 + <= ratio_value_raw + <= 1.7976931348623157e308 + ): + violations.append( + Violation( + path="ratio", + 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_value: int | None = None + if "step" in raw: + step_value_raw = raw["step"] + if step_value_raw is None: + violations.append( + Violation(path="step", reason="explicit null not allowed") + ) + else: + 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_value}", + ) + ) + + tags_value: list[str] | None = None + if "tags" in raw: + 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_value_raw, list): + violations.append(Violation(path="tags", reason="expected array")) + else: + tags_value_list: list[str] = [] + for tags_value_index, tags_value_element in enumerate( + typing.cast("list[typing.Any]", tags_value_raw) + ): + 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_value_item_path, reason="expected string" + ) + ) + else: + 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_value_list)}", + ) + ) + if len(tags_value_list) > 5: + violations.append( + Violation( + path="tags", + reason=f"must have at most 5 items, got {len(tags_value_list)}", + ) + ) + tags_value = tags_value_list + + aliases_value: list[str] | None = None + if "aliases" in raw: + 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_value_raw, list): + violations.append( + Violation(path="aliases", reason="expected array") + ) + else: + aliases_value_list: list[str] = [] + for aliases_value_index, aliases_value_element in enumerate( + typing.cast("list[typing.Any]", aliases_value_raw) + ): + 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_value_item_path, + reason="expected string", + ) + ) + else: + 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_value: list[str] | None = None + if "roles" in raw: + 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_value_raw, list): + violations.append(Violation(path="roles", reason="expected array")) + else: + roles_value_list: list[str] = [] + for roles_value_index, roles_value_element in enumerate( + typing.cast("list[typing.Any]", roles_value_raw) + ): + 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_value_item_path, reason="expected string" + ) + ) + else: + roles_value_item = roles_value_element + roles_value_list.append(roles_value_item) + _check_contains( + roles_value_list, + lambda element: element in ("admin",), + 1, + 2, + True, + "roles", + violations, + ) + roles_value = roles_value_list + + id_or_name_value: str | int | None = None + if "idOrName" in raw: + 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_value_parsed = _showcase_id_or_name_from_transfer_type( + id_or_name_value_raw, "idOrName", violations + ) + if id_or_name_value_parsed is not None: + id_or_name_value = id_or_name_value_parsed + + mode_value: typing.Literal["auto", "manual"] | int | None = None + if "mode" in raw: + mode_value_raw = raw["mode"] + if mode_value_raw is None: + violations.append( + Violation(path="mode", reason="explicit null not allowed") + ) + else: + mode_value_parsed = _showcase_mode_from_transfer_type( + mode_value_raw, "mode", violations + ) + if mode_value_parsed is not None: + mode_value = mode_value_parsed + + payload_value: dict[str, typing.Any] | str | None = None + if "payload" in raw: + payload_value_raw = raw["payload"] + if payload_value_raw is None: + violations.append( + Violation(path="payload", reason="explicit null not allowed") + ) + else: + payload_value_parsed = _showcase_payload_from_transfer_type( + payload_value_raw, "payload", violations + ) + if payload_value_parsed is not None: + payload_value = payload_value_parsed + + detail_value: ShowcaseDetailObject | str | None = None + if "detail" in raw: + detail_value_raw = raw["detail"] + if detail_value_raw is None: + violations.append( + Violation(path="detail", reason="explicit null not allowed") + ) + else: + detail_value_parsed = _showcase_detail_from_transfer_type( + detail_value_raw, "detail", violations + ) + if detail_value_parsed is not None: + detail_value = detail_value_parsed + + shape_or_name_value: Circle | Square | str | None = None + if "shapeOrName" in raw: + 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_value_parsed = _showcase_shape_or_name_from_transfer_type( + shape_or_name_value_raw, "shapeOrName", violations + ) + if shape_or_name_value_parsed is not None: + shape_or_name_value = shape_or_name_value_parsed + + measurements_value: list[float] | str | None = None + if "measurements" in raw: + measurements_value_raw = raw["measurements"] + if measurements_value_raw is None: + violations.append( + Violation(path="measurements", reason="explicit null not allowed") + ) + else: + measurements_value_parsed = _showcase_measurements_from_transfer_type( + measurements_value_raw, "measurements", violations + ) + if measurements_value_parsed is not None: + measurements_value = measurements_value_parsed + + shapes_value: list[Shape] | None = None + if "shapes" in raw: + 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_value_raw, list): + violations.append(Violation(path="shapes", reason="expected array")) + else: + shapes_value_list: list[Shape] = [] + for shapes_value_index, shapes_value_element in enumerate( + typing.cast("list[typing.Any]", shapes_value_raw) + ): + 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_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_value: list[ShowcaseSegmentsItem] | None = None + if "segments" in raw: + 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_value_raw, list): + violations.append( + Violation(path="segments", reason="expected array") + ) + else: + segments_value_list: list[ShowcaseSegmentsItem] = [] + for segments_value_index, segments_value_element in enumerate( + typing.cast("list[typing.Any]", segments_value_raw) + ): + segments_value_item_path = f"segments[{segments_value_index}]" + segments_value_item: ShowcaseSegmentsItem = typing.cast( + "typing.Any", None + ) + segments_value_item_parsed = ( + _showcase_segments_item_from_transfer_type( + segments_value_element, + segments_value_item_path, + violations, + ) + ) + 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_value: list[str | None] | None = None + if "slots" in raw: + 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_value_raw, list): + violations.append(Violation(path="slots", reason="expected array")) + else: + slots_value_list: list[str | None] = [] + for slots_value_index, slots_value_element in enumerate( + typing.cast("list[typing.Any]", slots_value_raw) + ): + 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_value_element, str): + violations.append( + Violation( + path=slots_value_item_path, + reason="expected string", + ) + ) + else: + slots_value_item = slots_value_element + slots_value_list.append(slots_value_item) + slots_value = slots_value_list + + grid_value: list[list[int]] | None = None + if "grid" in raw: + 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_value_raw, list): + violations.append(Violation(path="grid", reason="expected array")) + else: + grid_value_list: list[list[int]] = [] + for grid_value_index, grid_value_element in enumerate( + typing.cast("list[typing.Any]", grid_value_raw) + ): + 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_value_item_path, reason="expected array" + ) + ) + else: + 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_value_item_item_path = ( + f"{grid_value_item_path}[{grid_value_item_index}]" + ) + grid_value_item_item: int = typing.cast( + "typing.Any", 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_value_raw = raw["location"] + if location_value_raw is None: + violations.append( + Violation(path="location", reason="explicit null not allowed") + ) + else: + try: + location_value = ( + _ShowcaseLocationTransferTypeConverter().from_transfer_type( + location_value_raw, ShowcaseLocation + ) + ) + except ValidationError as error: + _collect(violations, "location", error) + + audit_value: ShowcaseAudit | None = None + if "audit" in raw: + audit_value_raw = raw["audit"] + if audit_value_raw is None: + audit_value = None + else: + try: + audit_value = ( + _ShowcaseAuditTransferTypeConverter().from_transfer_type( + audit_value_raw, ShowcaseAudit + ) + ) + except ValidationError as error: + _collect(violations, "audit", error) + + rows_value: list[ShowcaseRowsItem] | None = None + if "rows" in raw: + 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_value_raw, list): + violations.append(Violation(path="rows", reason="expected array")) + else: + rows_value_list: list[ShowcaseRowsItem] = [] + for rows_value_index, rows_value_element in enumerate( + typing.cast("list[typing.Any]", rows_value_raw) + ): + rows_value_item_path = f"rows[{rows_value_index}]" + rows_value_item: ShowcaseRowsItem = typing.cast( + "typing.Any", None + ) + try: + rows_value_item = _ShowcaseRowsItemTransferTypeConverter().from_transfer_type( + rows_value_element, ShowcaseRowsItem + ) + except ValidationError as error: + _collect(violations, rows_value_item_path, error) + rows_value_list.append(rows_value_item) + rows_value = rows_value_list + + ledger_py_value: ShowcaseLedger | None = None + if "ledger" in raw: + 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_value = ( + _ShowcaseLedgerTransferTypeConverter().from_transfer_type( + ledger_py_value_raw, ShowcaseLedger + ) + ) + except ValidationError as error: + _collect(violations, "ledger", error) + + metadata_value: ShowcaseMetadata | None = None + if "metadata" in raw: + metadata_value_raw = raw["metadata"] + if metadata_value_raw is None: + violations.append( + Violation(path="metadata", reason="explicit null not allowed") + ) + else: + try: + metadata_value = ( + _ShowcaseMetadataTransferTypeConverter().from_transfer_type( + metadata_value_raw, ShowcaseMetadata + ) + ) + except ValidationError as error: + _collect(violations, "metadata", error) + + quotas_value: Quotas | None = None + if "quotas" in raw: + quotas_value_raw = raw["quotas"] + if quotas_value_raw is None: + violations.append( + Violation(path="quotas", reason="explicit null not allowed") + ) + else: + try: + quotas_value = _QuotasTransferTypeConverter().from_transfer_type( + quotas_value_raw, Quotas + ) + except ValidationError as error: + _collect(violations, "quotas", error) + + tokens_value: Tokens | None = None + if "tokens" in raw: + tokens_value_raw = raw["tokens"] + if tokens_value_raw is None: + violations.append( + Violation(path="tokens", reason="explicit null not allowed") + ) + else: + try: + tokens_value = _TokensTransferTypeConverter().from_transfer_type( + tokens_value_raw, Tokens + ) + except ValidationError as error: + _collect(violations, "tokens", error) + + nicknames_value: Nicknames | None = None + if "nicknames" in raw: + nicknames_value_raw = raw["nicknames"] + if nicknames_value_raw is None: + violations.append( + Violation(path="nicknames", reason="explicit null not allowed") + ) + else: + try: + nicknames_value = ( + _NicknamesTransferTypeConverter().from_transfer_type( + nicknames_value_raw, Nicknames + ) + ) + except ValidationError as error: + _collect(violations, "nicknames", error) + + choices_value: Choices | None = None + if "choices" in raw: + choices_value_raw = raw["choices"] + if choices_value_raw is None: + violations.append( + Violation(path="choices", reason="explicit null not allowed") + ) + else: + try: + choices_value = _ChoicesTransferTypeConverter().from_transfer_type( + choices_value_raw, Choices + ) + except ValidationError as error: + _collect(violations, "choices", error) + + extras_value: Extras | None = None + if "extras" in raw: + extras_value_raw = raw["extras"] + if extras_value_raw is None: + violations.append( + Violation(path="extras", reason="explicit null not allowed") + ) + else: + try: + extras_value = _ExtrasTransferTypeConverter().from_transfer_type( + extras_value_raw, Extras + ) + except ValidationError as error: + _collect(violations, "extras", error) + + shape_value: Shape | None = None + if "shape" in raw: + shape_value_raw = raw["shape"] + if shape_value_raw is None: + violations.append( + Violation(path="shape", reason="explicit null not allowed") + ) + else: + 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_value: Note | None = None + if "note" in raw: + note_value_raw = raw["note"] + if note_value_raw is None: + violations.append( + Violation(path="note", reason="explicit null not allowed") + ) + else: + 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_value: Address | None = None + if "address" in raw: + address_value_raw = raw["address"] + if address_value_raw is None: + violations.append( + Violation(path="address", reason="explicit null not allowed") + ) + else: + try: + address_value = _AddressTransferTypeConverter().from_transfer_type( + address_value_raw, Address + ) + except ValidationError as error: + _collect(violations, "address", error) + + labels_value: Labels | None = None + if "labels" in raw: + labels_value_raw = raw["labels"] + if labels_value_raw is None: + violations.append( + Violation(path="labels", reason="explicit null not allowed") + ) + else: + try: + labels_value = _LabelsTransferTypeConverter().from_transfer_type( + labels_value_raw, Labels + ) + except ValidationError as error: + _collect(violations, "labels", error) + + settings_value: Settings | None = None + if "settings" in raw: + settings_value_raw = raw["settings"] + if settings_value_raw is None: + violations.append( + Violation(path="settings", reason="explicit null not allowed") + ) + else: + try: + settings_value = ( + _SettingsTransferTypeConverter().from_transfer_type( + settings_value_raw, Settings + ) + ) + except ValidationError as error: + _collect(violations, "settings", error) + + attributes_value: Attributes | None = None + if "attributes" in raw: + attributes_value_raw = raw["attributes"] + if attributes_value_raw is None: + violations.append( + Violation(path="attributes", reason="explicit null not allowed") + ) + else: + try: + attributes_value = ( + _AttributesTransferTypeConverter().from_transfer_type( + attributes_value_raw, Attributes + ) + ) + except ValidationError as error: + _collect(violations, "attributes", error) + + contact_value: ContactPy | None = None + if "contact" in raw: + contact_value_raw = raw["contact"] + if contact_value_raw is None: + violations.append( + Violation(path="contact", reason="explicit null not allowed") + ) + else: + try: + contact_value = ( + _ContactPyTransferTypeConverter().from_transfer_type( + contact_value_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_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 + 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 not (-1.7976931348623157e308 <= value.ratio <= 1.7976931348623157e308): + violations.append( + Violation( + path="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: + 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 in ("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}", + ) + ) + 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): + 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 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}") + ) + 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: + 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: + 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: + violations.append( + Violation( + path="measurements", + reason=f"must have at least 1 items, got {len(value.measurements)}", + ) + ) + _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)}", + ) + ) + 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: + 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: + 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: + try: + out["location"] = ( + _ShowcaseLocationTransferTypeConverter().to_transfer_type( + value.location + ) + ) + except ValidationError as error: + _collect(violations, "location", error) + if value.audit is not None: + try: + out["audit"] = _ShowcaseAuditTransferTypeConverter().to_transfer_type( + value.audit + ) + except ValidationError as error: + _collect(violations, "audit", error) + if value.rows is not None: + 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: + try: + out["ledger"] = _ShowcaseLedgerTransferTypeConverter().to_transfer_type( + value.ledger_py + ) + except ValidationError as error: + _collect(violations, "ledger", error) + if value.metadata is not None: + try: + out["metadata"] = ( + _ShowcaseMetadataTransferTypeConverter().to_transfer_type( + value.metadata + ) + ) + except ValidationError as error: + _collect(violations, "metadata", error) + if value.quotas is not None: + try: + out["quotas"] = _QuotasTransferTypeConverter().to_transfer_type( + value.quotas + ) + except ValidationError as error: + _collect(violations, "quotas", error) + if value.tokens is not None: + try: + out["tokens"] = _TokensTransferTypeConverter().to_transfer_type( + value.tokens + ) + except ValidationError as error: + _collect(violations, "tokens", error) + if value.nicknames is not None: + try: + out["nicknames"] = _NicknamesTransferTypeConverter().to_transfer_type( + value.nicknames + ) + except ValidationError as error: + _collect(violations, "nicknames", error) + if value.choices is not None: 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 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: _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) + out["choices"] = _ChoicesTransferTypeConverter().to_transfer_type( + value.choices + ) + except ValidationError as error: + _collect(violations, "choices", error) + if value.extras is not None: + try: + out["extras"] = _ExtrasTransferTypeConverter().to_transfer_type( + value.extras + ) + except ValidationError as error: + _collect(violations, "extras", error) + if value.shape is not None: + try: + out["shape"] = _shape_to_transfer_type(value.shape) + except ValidationError as error: + _collect(violations, "shape", error) + if value.note is not None: + try: + out["note"] = _note_to_transfer_type(value.note) + except ValidationError as error: + _collect(violations, "note", error) + if value.address is not None: + try: + out["address"] = _AddressTransferTypeConverter().to_transfer_type( + value.address + ) + except ValidationError as error: + _collect(violations, "address", error) + if value.labels is not None: + try: + out["labels"] = _LabelsTransferTypeConverter().to_transfer_type( + value.labels + ) + except ValidationError as error: + _collect(violations, "labels", error) + if value.settings is not None: + try: + out["settings"] = _SettingsTransferTypeConverter().to_transfer_type( + value.settings + ) + except ValidationError as error: + _collect(violations, "settings", error) + if value.attributes is not None: + try: + out["attributes"] = _AttributesTransferTypeConverter().to_transfer_type( + value.attributes + ) + except ValidationError as error: + _collect(violations, "attributes", error) + if value.contact is not None: + try: + out["contact"] = _ContactPyTransferTypeConverter().to_transfer_type( + value.contact + ) + except ValidationError as error: + _collect(violations, "contact", error) + if violations: + raise ValidationError(violations) + return out -class Showcase(pydantic.BaseModel): +@_transfer_type_convertible(_ShowcaseTransferTypeConverter) +@dataclasses.dataclass(slots=True, kw_only=True, init=False) +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 +2827,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 +2838,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 +2849,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 = dataclasses.field(default=None, repr=False) """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 = dataclasses.field(default=None, repr=False) """Greeting Optional string with a schema default, surfaced on read. """ - debug: bool = pydantic.Field(default=False) + _debug: bool | None = dataclasses.field(default=None, repr=False) """Debug flag Optional boolean with a schema default. """ @@ -788,7 +2948,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 +2958,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 +2994,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 +3009,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 +3017,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 +3028,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 +3038,452 @@ 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 + + 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: 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 + 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: int) -> None: + self._retries = value + + @retries.deleter + def retries(self) -> None: + self._retries = None + + @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: str) -> None: + self._greeting = value + + @greeting.deleter + def greeting(self) -> None: + self._greeting = None + + @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: bool) -> None: + self._debug = value + + @debug.deleter + def debug(self) -> None: + self._debug = 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_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_value_raw = raw["by"] + if not isinstance(by_value_raw, str): + violations.append(Violation(path="by", reason="expected string")) + else: + 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_value_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_value, + 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 - @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) +@_transfer_type_convertible(_ShowcaseAuditTransferTypeConverter) +@dataclasses.dataclass(slots=True, kw_only=True) +class ShowcaseAudit: + by: str -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_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_value_raw = raw["code"] + if not isinstance(code_value_raw, str): + violations.append(Violation(path="code", reason="expected string")) + else: + 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_value_raw)}", + ) + ) + hint_value: str | None = None + if "hint" in raw: + 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_value_raw, str): + violations.append(Violation(path="hint", reason="expected string")) + else: + hint_value = hint_value_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_value, + hint=hint_value, + 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: + violations: list[Violation] = [] + out: dict[str, typing.Any] = {} + for key, entry in value.additional_properties.items(): + try: + out[key] = _ShowcaseLedgerValueTransferTypeConverter().to_transfer_type( + entry + ) + except ValidationError as error: + _collect(violations, key, error) + if violations: + raise ValidationError(violations) + 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 +3492,165 @@ 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_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_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_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_LEDGER_VALUE_DECLARED: + additional_properties[key] = raw[key] + if violations: + raise ValidationError(violations) + return ShowcaseLedgerValue( + amount=amount_value, + 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 + out["amount"] = value.amount + 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 { - 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" - ) - amount: SpecInt = pydantic.Field(ge=0) +@_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 + ) - @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_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_value_raw = raw["city"] + if not isinstance(city_value_raw, str): + violations.append(Violation(path="city", reason="expected string")) + else: + 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_value_raw)}", + ) + ) -class ShowcaseLocation(pydantic.BaseModel): + geo_value: ShowcaseLocationGeo | None = None + if "geo" in raw: + geo_value_raw = raw["geo"] + if geo_value_raw is None: + violations.append( + Violation(path="geo", reason="explicit null not allowed") + ) + else: + try: + geo_value = ( + _ShowcaseLocationGeoTransferTypeConverter().from_transfer_type( + geo_value_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_value, + geo=geo_value, + 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: + 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: + raise ValidationError(violations) + return out + + +@_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 +3659,678 @@ 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 - city: str = pydantic.Field(min_length=1) + geo: ShowcaseLocationGeo | None = None - geo: ShowcaseLocationGeo | None = pydantic.Field(default=None) + additional_properties: dict[str, typing.Any] = dataclasses.field( + default_factory=dict + ) - _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) +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_value: float | None = None + if "lat" in raw: + 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_value_raw, bool) + and isinstance(lat_value_raw, (int, float)) + ): + violations.append(Violation(path="lat", reason="expected number")) + else: + 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}", + ) + ) - @pydantic.model_serializer(mode="wrap") - def _serialize( - self, - handler: typing.Callable[[pydantic.BaseModel], typing.Any], - ) -> dict[str, object]: - return _emit_set_fields(self, handler) + lon_value: float | None = None + if "lon" in raw: + 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_value_raw, bool) + and isinstance(lon_value_raw, (int, float)) + ): + violations.append(Violation(path="lon", reason="expected number")) + else: + 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: + if key not in _SHOWCASE_LOCATION_GEO_DECLARED: + additional_properties[key] = raw[key] + if violations: + raise ValidationError(violations) + return ShowcaseLocationGeo( + 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 -class ShowcaseLocationGeo(pydantic.BaseModel): - model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict( - strict=True, populate_by_name=True, extra="allow" - ) - lat: float | None = pydantic.Field(default=None) +@_transfer_type_convertible(_ShowcaseLocationGeoTransferTypeConverter) +@dataclasses.dataclass(slots=True, kw_only=True) +class ShowcaseLocationGeo: + lat: float | None = None - lon: float | None = pydantic.Field(default=None) + lon: float | None = None - _OPTIONAL_NON_NULLABLE_FIELDS: typing.ClassVar[frozenset[str]] = frozenset( - {"lat", "lon"} + 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), + +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_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_value_raw = raw["cell"] + if not isinstance(cell_value_raw, str): + violations.append(Violation(path="cell", reason="expected string")) + else: + 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_value_raw)}", + ) + ) + + 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_value, + 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)}" ) ) - if errors: - raise pydantic.ValidationError.from_exception_data( - title=type(self).__name__, line_errors=errors - ) - return self + 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 dict(typing.cast(dict[str, object], self.model_extra or {})) +@_transfer_type_convertible(_ShowcaseRowsItemTransferTypeConverter) +@dataclasses.dataclass(slots=True, kw_only=True) +class ShowcaseRowsItem: + cell: str -class ShowcaseRowsItem(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 ) - cell: 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 _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_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_value_raw = raw["id"] + if not isinstance(id_value_raw, str): + violations.append(Violation(path="id", reason="expected string")) + else: + id_value = id_value_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_value, + ) + + @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_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_value_raw = raw["kind"] + if not isinstance(kind_value_raw, str): + violations.append(Violation(path="kind", reason="expected string")) + elif kind_value_raw not in ("square",): + violations.append(Violation(path="kind", reason='must equal "square"')) + else: + kind_value = kind_value_raw + + 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_value_raw = raw["side"] + if not ( + not isinstance(side_value_raw, bool) + and isinstance(side_value_raw, (int, float)) + ): + violations.append(Violation(path="side", reason="expected number")) + else: + 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}", + ) + ) -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] = {} + for key in raw: + if key not in _SQUARE_DECLARED: + additional_properties[key] = raw[key] + if violations: + raise ValidationError(violations) + return Square( + kind=kind_value, + side=side_value, + 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 + 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 + if violations: + raise ValidationError(violations) + return out - 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) +@_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_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_value_raw = raw["kind"] + if not isinstance(kind_value_raw, str): + violations.append(Violation(path="kind", reason="expected string")) + elif kind_value_raw not in ("text",): + violations.append(Violation(path="kind", reason='must equal "text"')) + else: + kind_value = kind_value_raw + + 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_value_raw = raw["body"] + if not isinstance(body_value_raw, str): + violations.append(Violation(path="body", reason="expected string")) + else: + 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_value_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_value, + body=body_value, + 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_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_value_raw = raw["id"] + if not isinstance(id_value_raw, str): + violations.append(Violation(path="id", reason="expected string")) + else: + id_value = id_value_raw + + kind_value: str | None = None + if "kind" in raw: + 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_value_raw, str): + violations.append(Violation(path="kind", reason="expected string")) + else: + kind_value = kind_value_raw + + 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_value_raw = raw["name"] + if not isinstance(name_value_raw, str): + violations.append(Violation(path="name", reason="expected string")) + else: + name_value = name_value_raw + + size_value: int | None = None + if "size" in raw: + size_value_raw = raw["size"] + if size_value_raw is None: + violations.append( + Violation(path="size", reason="explicit null not allowed") + ) + else: + 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_value}" + ) + ) + if size_value > 20: + violations.append( + Violation( + path="size", reason=f"must be <= 20, got {size_value}" + ) ) - ) - 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() - } + 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_value, + kind=kind_value, + name=name_value, + size=size_value, + 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 -class Widget(pydantic.BaseModel): +@_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,75 +4338,490 @@ 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_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_value_raw = raw["id"] + if not isinstance(id_value_raw, str): + violations.append(Violation(path="id", reason="expected string")) + else: + id_value = id_value_raw + + kind_value: str | None = None + if "kind" in raw: + 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_value_raw, str): + violations.append(Violation(path="kind", reason="expected string")) + else: + kind_value = kind_value_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_value, + kind=kind_value, + 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 ) - id: str = pydantic.Field() - kind: str | None = pydantic.Field(default=None) +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: + 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) + + +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: + 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) + + +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: + 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) + + +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 + - _OPTIONAL_NON_NULLABLE_FIELDS: typing.ClassVar[frozenset[str]] = frozenset({"kind"}) +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}")) + 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 + + +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 + + +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 + + +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 - @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_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): + 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_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_list)}", + ) + ) + _check_unique_items(items_list, path, violations) + return items_list + 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 +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--name` override (two or more inline object branches cannot derive @@ -1648,48 +4829,13 @@ def _serialize( 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 = ( - 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/__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/temporal/_definitions.py b/samples/python/temporal/_definitions.py index 00b1bd60..3bf7f712 100644 --- a/samples/python/temporal/_definitions.py +++ b/samples/python/temporal/_definitions.py @@ -4,143 +4,183 @@ 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_date_time", + "_check_duration", + "_check_time", + "_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( @@ -154,6 +194,13 @@ def validate(value: list[typing.Any]) -> list[typing.Any]: 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: @@ -173,60 +220,199 @@ 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 _parse_date_time(value: object) -> object: - if not isinstance(value, str): - return value - 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}") +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" - return datetime.datetime.fromisoformat(normalized) + 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=_temporal_reason("date-time", value)) + ) + return None + return datetime.datetime.fromisoformat(_temporal_isoformat(value)) -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=_temporal_reason("date", 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}") - normalized = value.upper() - if normalized.endswith("Z"): - normalized = normalized[:-1] + "+00:00" - return datetime.time.fromisoformat(normalized) + violations.append(Violation(path=path, reason=_temporal_reason("time", value))) + return None + return datetime.time.fromisoformat(_temporal_isoformat(value)) -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=_temporal_reason("duration", value)) + ) + return None total = 0 number = "" for char in value[2:]: 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: - raise ValueError(f"must be a valid duration, got {value!r}") + 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 "" @@ -280,39 +466,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 +485,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..442114a3 100644 --- a/samples/python/temporal/models.py +++ b/samples/python/temporal/models.py @@ -2,20 +2,270 @@ 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, + _check_date_time, + _check_duration, + _check_time, + _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_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_value_raw = raw["createdAt"] + if not isinstance(created_at_value_raw, str): + violations.append(Violation(path="createdAt", reason="expected string")) + else: + created_at_value_parsed = _parse_date_time( + created_at_value_raw, "createdAt", violations + ) + if created_at_value_parsed is not None: + created_at_value = created_at_value_parsed + + 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_value_raw = raw["birthday"] + if not isinstance(birthday_value_raw, str): + violations.append(Violation(path="birthday", reason="expected string")) + else: + birthday_value_parsed = _parse_date( + birthday_value_raw, "birthday", violations + ) + if birthday_value_parsed is not None: + birthday_value = birthday_value_parsed + + 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_value_raw = raw["alarm"] + if not isinstance(alarm_value_raw, str): + violations.append(Violation(path="alarm", reason="expected string")) + else: + alarm_value_parsed = _parse_time(alarm_value_raw, "alarm", violations) + if alarm_value_parsed is not None: + alarm_value = alarm_value_parsed + + 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_value_raw = raw["timeout"] + if not isinstance(timeout_value_raw, str): + violations.append(Violation(path="timeout", reason="expected string")) + else: + timeout_value_parsed = _parse_duration( + timeout_value_raw, "timeout", violations + ) + if timeout_value_parsed is not None: + timeout_value = timeout_value_parsed + + updated_at_value: datetime.datetime | None = None + if "updatedAt" in raw: + 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_value_raw, str): + violations.append( + Violation(path="updatedAt", reason="expected string") + ) + else: + updated_at_value_parsed = _parse_date_time( + updated_at_value_raw, "updatedAt", violations + ) + if updated_at_value_parsed is not None: + updated_at_value = updated_at_value_parsed + + expires_on_value: datetime.date | None = None + if "expiresOn" in raw: + 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_value_raw, str): + violations.append( + Violation(path="expiresOn", reason="expected string") + ) + else: + expires_on_value_parsed = _parse_date( + expires_on_value_raw, "expiresOn", violations + ) + if expires_on_value_parsed is not None: + expires_on_value = expires_on_value_parsed + + reminder_value: datetime.time | None = None + if "reminder" in raw: + 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_value_raw, str): + violations.append( + Violation(path="reminder", reason="expected string") + ) + else: + reminder_value_parsed = _parse_time( + reminder_value_raw, "reminder", violations + ) + if reminder_value_parsed is not None: + reminder_value = reminder_value_parsed + + retry_delay_value: datetime.timedelta | None = None + if "retryDelay" in raw: + 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_value_raw, str): + violations.append( + Violation(path="retryDelay", reason="expected string") + ) + else: + retry_delay_value_parsed = _parse_duration( + retry_delay_value_raw, "retryDelay", violations + ) + if retry_delay_value_parsed is not None: + retry_delay_value = retry_delay_value_parsed + + deleted_at_value: datetime.datetime | None = None + if "deletedAt" in raw: + deleted_at_value_raw = raw["deletedAt"] + if deleted_at_value_raw is None: + deleted_at_value = None + else: + if not isinstance(deleted_at_value_raw, str): + violations.append( + Violation(path="deletedAt", reason="expected string") + ) + else: + deleted_at_value_parsed = _parse_date_time( + deleted_at_value_raw, "deletedAt", violations + ) + if deleted_at_value_parsed is not None: + deleted_at_value = deleted_at_value_parsed + + archived_on_value: datetime.date | None = None + if "archivedOn" in raw: + archived_on_value_raw = raw["archivedOn"] + if archived_on_value_raw is None: + archived_on_value = None + else: + if not isinstance(archived_on_value_raw, str): + violations.append( + Violation(path="archivedOn", reason="expected string") + ) + else: + archived_on_value_parsed = _parse_date( + archived_on_value_raw, "archivedOn", violations + ) + if archived_on_value_parsed is not None: + archived_on_value = archived_on_value_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_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) + 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 +273,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..6267c064 --- /dev/null +++ b/samples/python/tests/json_converter_helper.py @@ -0,0 +1,239 @@ +"""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). + +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 + +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" + +#: 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: 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, ...]] = { + ("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.""" + 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_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 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: + """Deserialize a canonical wire fixture into ``cls`` via the default converter.""" + 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. + + 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..0450c7e3 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, @@ -13,122 +8,251 @@ Room, SendMessageInput, SendMessageOutput, + ValidationError, ) +from tests.json_converter_helper import ( + canonical_json_bytes, + converter_for, + encode_bytes, + load_fixture, + roundtrip_fixture, + violation_pairs, +) -WIRE_FIXTURE_DIR = Path(__file__).resolve().parents[2] / "wire" / "json_schema" / "chat" +SUITE = "chat" -def load_fixture(name: str) -> object: - return json.loads((WIRE_FIXTURE_DIR / name).read_text(encoding="utf-8")) +def expect_roundtrip(name: str, model_type: type[typing.Any]) -> typing.Any: + """Decode a fixture through the default converter, re-encode, compare **bytes**. + 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. + """ + return roundtrip_fixture(model_type, SUITE, name) -def fixture_bytes(name: str) -> bytes: - return (WIRE_FIXTURE_DIR / name).read_bytes() +def test_optional_non_nullable_members_reject_explicit_null() -> None: + converter = converter_for(Room) -def roundtrip_fixture(name: str, model_type: type[typing.Any]) -> typing.Any: - payload = Payload( - metadata={"encoding": b"json/plain"}, - data=fixture_bytes(name), + # `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: + # 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 == 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 + # keeping `default` off the dataclass field, so it is asserted on bytes. + assert encode_bytes(unset) == canonical_json_bytes( + {"kind": "text", "body": "hello"} ) - 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 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_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) == { + assert converter.to_transfer_type(Message(body="hello", priority=7)) == { "kind": "text", "body": "hello", - "priority": 0, + "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} + ) + # Explicitly assigning the schema default still marks the property present. + unset.priority = 0 + assert converter.to_transfer_type(unset)["priority"] == 0 + # 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 + # default — it is the only admissible value, not a suggestion. + assert unset.kind == "text" + + +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 == 0 + assert "priority" not in converter_for(Message).to_transfer_type(message) -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_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, ) - - assert room.model_dump(by_alias=True) == { + # 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 + # 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, roundtrip_fixture("message-full.json", Message)) + full_message = typing.cast( + Message, + expect_roundtrip("message-full.json", Message), + ) 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..6b2a212d 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,121 @@ from kb import GetPageInput from kb import Page from kb import PutBlockOutput +from kb import ValidationError -WIRE_FIXTURE_DIR = Path(__file__).resolve().parents[2] / "wire" / "json_schema" / "kb" +from tests.json_converter_helper import ( + canonical_fixture_bytes, + canonical_json_bytes, + converter_for, + decode_fixture, + encode_bytes, + load_fixture, + roundtrip_fixture, + violation_pairs, +) +SUITE = "kb" -def load_fixture(name: str) -> object: - return json.loads((WIRE_FIXTURE_DIR / name).read_text(encoding="utf-8")) +def expect_roundtrip(name: str, model_type: type[typing.Any]) -> typing.Any: + """Decode a fixture through the default converter, re-encode, compare **bytes**.""" + return roundtrip_fixture(model_type, SUITE, name) -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) - 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 + # 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" + 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 + + # 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"] = [ + { + 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_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: + # 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..d04ae5c9 100644 --- a/samples/python/tests/test_showcase.py +++ b/samples/python/tests/test_showcase.py @@ -1,15 +1,13 @@ +import dataclasses 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 showcase import ( Address, Attributes, + Choices, Circle, ContactPy, Extras, @@ -19,168 +17,249 @@ Showcase, ShowcaseDetailObject, ShowcaseLedgerValue, + ShowcaseLocation, + ShowcaseRowsItem, Square, TextNote, + ValidationError, + Violation, Widget, ) - -WIRE_FIXTURE_DIR = ( - Path(__file__).resolve().parents[2] / "wire" / "json_schema" / "showcase" +from tests.json_converter_helper import ( + canonical_json_bytes, + converter_for, + decode_fixture, + encode_bytes, + roundtrip_fixture, + violation_pairs, ) +SUITE = "showcase" -def load_fixture(name: str) -> object: - return json.loads((WIRE_FIXTURE_DIR / name).read_text(encoding="utf-8")) +def test_validation_types_are_exported_from_the_package() -> None: + violation = Violation(path="name", reason="required") + error = ValidationError([violation]) + assert error.violations == [violation] -def fixture_bytes(name: str) -> bytes: - return (WIRE_FIXTURE_DIR / name).read_bytes() +# 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 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 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 expect_roundtrip(name: str, model_type: type[typing.Any]) -> typing.Any: + """Decode a fixture through the *default* data converter, re-encode, compare **bytes**. + + 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. + """ + return roundtrip_fixture(model_type, SUITE, name) + + +def expect_showcase(name: str) -> Showcase: + return typing.cast(Showcase, expect_roundtrip(name, Showcase)) + + +def parse(raw: dict[str, typing.Any]) -> Showcase: + return converter_for(Showcase).from_transfer_type(raw, Showcase) + + +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 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. + 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. + # 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.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", - } + 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 + # 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"), + ] + + # 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) + + # A closed object rejects an unknown member. + assert parse_violations({**BASE, "nope": 1}) == [("nope", "unknown field")] + + # 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_labels_typed_map_and_settings_closed_object() -> None: - labels = Labels.model_validate({"env": "prod", "team": "core"}) - assert labels.model_dump() == {"env": "prod", "team": "core"} + 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): - _ = Labels.model_validate({"env": 42}) + 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): - _ = Settings.model_validate({"theme": "dark", "unknown": 1}) + 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_temporal_pydantic_converter() -> ( - None -): - minimal = typing.cast( - Showcase, roundtrip_fixture("showcase-minimal.json", Showcase) - ) +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" + # Scalar defaults materialize through properties while their unset private + # values remain omitted on re-serialize (byte-identity asserted above). 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)) + + full = expect_showcase("showcase-full.json") assert full.retries == 5 assert full.middle_name == "Q" assert full.tags == ["a", "b"] @@ -188,300 +267,333 @@ 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") 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) + 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 below `exclusiveMinimum`. - with pytest.raises(ValidationError) as excinfo: - _ = Showcase.model_validate({**base, "level": 0}) - assert "greater than 0" in str(excinfo.value) - # 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) +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") + ] + # 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} + ) - # 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_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") + ] -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) - ) - assert strings.code == "a😀b" - assert strings.nickname == "buddy" + # 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}") + ] - base = { - "kind": "showcase", - "name": "w", - "count": 1, - "active": True, - "category": "tools", - "status": "active", - "tier": 1, - "scale": 1.5, - } + # 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") + ] - # 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) + # 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}) - # 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) +def test_string_length_constraints_roundtrip_and_reject() -> None: + # 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" - # 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) + 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") + ] - # 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) +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. - # 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) + 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: - # 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 +602,349 @@ 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, 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_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") + ] - # A value admitted by neither branch is rejected. - with pytest.raises(ValidationError): - _ = Showcase.model_validate({**base, "measurements": True}) + # 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 `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 +954,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 +965,175 @@ 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) + 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. + 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, "rows": [{"cell": "ok"}, {}]}) - assert "rows.1.cell" in str(excinfo.value) - # A nested array reports the failing element at its own two-dimensional index. +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: - _ = Showcase.model_validate({**base, "grid": [[1], [2, 1.5]]}) - assert "grid.1.1" in str(excinfo.value) + _ = converter_for(Attributes).to_transfer_type( + Attributes(additional_properties={}) + ) + assert violation_pairs(excinfo.value) == [ + ("", "must have at least 1 properties, got 0") + ] - # 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) + _ = 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', + ) + ] - with pytest.raises(ValidationError) as excinfo: - _ = Showcase.model_validate({**base, "tokens": {"primary": "AB"}}) - assert "primary" in str(excinfo.value) +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: - _ = Showcase.model_validate({**base, "nicknames": {"tiny": "a"}}) - assert "tiny" in str(excinfo.value) + _ = 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 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}} - ) - assert "at most 3 properties" in str(excinfo.value) + # 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 4292c6e8..abfc1485 100644 --- a/samples/python/tests/test_temporal.py +++ b/samples/python/tests/test_temporal.py @@ -1,46 +1,76 @@ +import dataclasses 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 +from temporal import Temporal, ValidationError +from temporal._definitions import ( + _TEMPORAL_FRACTION_DIGITS, + _TEMPORAL_MAX_DURATION_SECONDS, + _temporal_isoformat, +) -WIRE_FIXTURE_DIR = ( - Path(__file__).resolve().parents[2] / "wire" / "json_schema" / "temporal" +from tests.json_converter_helper import ( + canonical_json_bytes, + converter_for, + decode_fixture, + encode_bytes, + load_fixture, + roundtrip_fixture, + violation_pairs, ) +SUITE = "temporal" -def load_fixture(name: str) -> object: - return json.loads((WIRE_FIXTURE_DIR / name).read_text(encoding="utf-8")) +#: 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 fixture_bytes(name: str) -> bytes: - return (WIRE_FIXTURE_DIR / name).read_bytes() +def decode(name: str) -> Temporal: + return decode_fixture(Temporal, SUITE, name) -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 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 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) +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("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) @@ -48,46 +78,301 @@ def test_temporal_roundtrip_full() -> None: def test_temporal_roundtrip_minimal() -> None: - assert encode(decode("temporal-minimal.json")) == load_fixture( - "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() -> 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; 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. + assert parse() == parse(deletedAt=None, archivedOn=None) + + +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: + assert parse_violations(nope=1) == [("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: - 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(Exception): - _ = Temporal.model_validate(base) +def test_temporal_materialized_narrowing_rejects( + field: str, value: str, format_name: str +) -> None: + # The reason names the format and the offending value, rendered in its JSON + # form exactly as Go and TypeScript render it. + 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/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/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..cbe572e3 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(name: string, converter: TransferTypeConverter): 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 { + showcaseTransferTypeConverter.fromTransferType(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/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/PRINCIPLES.md b/specs/json-schema/PRINCIPLES.md index 11bb2928..f6a7496b 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,19 @@ 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]]. +Python-specific P15 rule: a default-bearing property `field` synthesizes the +private `_` 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 `_: 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. 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 `_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 @@ -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/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..020a0412 100644 --- a/specs/json-schema/features/default.md +++ b/specs/json-schema/features/default.md @@ -7,8 +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 `OrDefault()` accessor in Go and native language -mechanisms elsewhere. +generated `OrDefault()` accessor in Go, native getters in Java and +Python, and a generated `DEFAULT_` constant in TypeScript. ## Spec summary @@ -45,7 +45,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 +64,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,37 +117,43 @@ 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` / 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 | private `_: 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) 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 | `_` 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 TypeScript constant is named `DEFAULT_`, or +**`DEFAULT__`** 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 `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 -both are named off the **emitted** member identifier rather than the JSON +synthesized `OrDefault` / `DEFAULT_` / `_` 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_`, and an override that moved @@ -153,13 +162,13 @@ 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 through its getter; Go does so via the generated +`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. ## Validator mapping @@ -168,12 +177,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 +192,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` 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: @@ -225,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_` 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); a Python sibling explicitly renamed to `_nickname` (private-backing clash) | ### Runtime fixtures (validator / adapters) @@ -248,10 +256,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 @@ -292,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 `OrDefault` / `DEFAULT_` names (P15). + the synthesized `OrDefault` / `DEFAULT_` / `_` names (P15). 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..9dc67d6c 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,20 +128,31 @@ 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 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 @@ -156,7 +167,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..ec24b5c7 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 @@ -662,30 +702,54 @@ violation path (`idOrName`, `shapes[1]`, `choices.primary`): | Language | Where a non-object branch's constraints live | |---|---| | 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. | +| 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 `__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. The fallthrough is safe by **precondition**: +`__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: ` 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..5cc1d891 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; default-bearing property over private storage | 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. @@ -162,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 `OrDefault()` -accessor and TS `DEFAULT_` constant ([[default]]), the [[enum]] +accessor, TS `DEFAULT_` constant, and Python `_` 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 `OrDefault()` accessor and TS `DEFAULT_` -constant ([[default]]), the Go closed-value type and Java value class +with it — the Go `OrDefault()` accessor, TS `DEFAULT_` +constant, Python `_` 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 @@ -204,9 +206,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 +239,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 +312,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 +322,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..2ce1e037 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'` | `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 | | `"null"` | `raw == nil` / `bytes.Equal(raw, []byte("null"))` | `v === null` | `v is None` | `v == null` | Strategy per language: @@ -148,18 +149,31 @@ 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`. 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 + `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..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,35 +105,46 @@ 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 - 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 @@ -190,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): @@ -212,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. @@ -254,12 +269,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 +293,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 @@ -289,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. diff --git a/specs/json-schema/nullability.md b/specs/json-schema/nullability.md index c01fd464..9a4dff87 100644 --- a/specs/json-schema/nullability.md +++ b/specs/json-schema/nullability.md @@ -133,34 +133,37 @@ 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 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 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 +223,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 +265,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 +313,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 +332,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 +345,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 +411,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..a387375c 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,9 @@ use crate::planning::{PlannedFamily, PlannedJsonType, PlannedSpec}; use crate::spec::{ApiSpecBranch, ApiSpecNode}; use crate::spec::{ExternalTypeSpec, ModulePath, RecordSpec}; -#[derive(Debug, Deserialize, Default)] +const JSON_PUBLIC_RUNTIME_NAMES: &[&str] = &["ValidationError", "Violation"]; + +#[derive(Debug, Clone, Deserialize, Default)] struct Schema { #[serde(rename = "$ref")] reference: Option<String>, @@ -95,51 +97,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 +111,119 @@ 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. +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 (`_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 +/// 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. +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. `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(), + member_ident.to_snake_case() + ) +} + +pub(crate) fn union_parse_fn(base: &str) -> String { + format!("_{base}_from_transfer_type") +} + +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`. +pub(crate) 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() } @@ -227,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>> { @@ -576,100 +653,84 @@ 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 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)?; + 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); + 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 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()); } + + // 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, + root_package_imports: BTreeMap::new(), exported_names: json_models .iter() .map(|model| model.model_name.clone()) @@ -678,112 +739,78 @@ 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_date_time", + "_check_duration", + "_check_time", + "_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 +819,51 @@ 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_date_time", + "_check_duration", + "_check_time", + "_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 +872,92 @@ 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, 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; 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!( @@ -874,6 +980,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: @@ -893,58 +1006,199 @@ 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 _parse_date_time(value: object) -> object: - if not isinstance(value, str): - return value - 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}") +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" - return datetime.datetime.fromisoformat(normalized) + 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=_temporal_reason("date-time", value)) + ) + return None + return datetime.datetime.fromisoformat(_temporal_isoformat(value)) -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=_temporal_reason("date", 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}") - normalized = value.upper() - if normalized.endswith("Z"): - normalized = normalized[:-1] + "+00:00" - return datetime.time.fromisoformat(normalized) + violations.append(Violation(path=path, reason=_temporal_reason("time", value))) + return None + return datetime.time.fromisoformat(_temporal_isoformat(value)) -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=_temporal_reason("duration", value)) + ) + return None total = 0 number = "" for char in value[2:]: 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: - raise ValueError(f"must be a valid duration, got {value!r}") + 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 "" @@ -996,37 +1250,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 +1278,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 +1291,3306 @@ 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. +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); + 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", - }; - // 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", + 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"); +} + +/// 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, + path_expr: &str, + schema: &Schema, + 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(&mut body, &body_indent, &condition, path_expr, &reason); + }; + 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( + 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}}}\""), + ); + } + 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}}}\""), + ); + } + 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}}}\""), + ); + } + 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, - " ", - 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=", + indent, + &format!("not (-{PY_BINARY64_MAX} <= {value_expr} <= {PY_BINARY64_MAX})"), + path_expr, + &format!("f\"must be a finite number, got {{{value_expr}}}\""), ); - output.push_str(&python_string_literal(extra)); - output.push_str(")\n"); + if !body.is_empty() { + output.push_str(indent); + output.push_str("else:\n"); + output.push_str(&body); + } +} - 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(()); +/// 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); + } +} - let Some(properties) = &schema.properties else { - return Ok(()); +/// 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; }; - if properties.is_empty() { - return Ok(()); + 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 + ), + ); +} - 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)]" +/// 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 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 {}", + 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`]. +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; + } + // 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. + 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() + } + // 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() + || schema.exclusive_maximum.is_some() + || schema.multiple_of.is_some() } - if let Some(values) = &property.enum_values { - enum_fields.push((json_name.clone(), field_name.clone(), values.clone())); + Some("array") => { + schema.min_items.is_some() + || schema.max_items.is_some() + || schema.unique_items == Some(true) + || schema.contains.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()); - } + _ => 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 and +/// 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. `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, +) -> 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, models, value_expr, path_expr, indent); + } + // An inline sum type narrows to the branch it holds and runs that branch's + // 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, models)? { + render_py_union_value_checks(output, &union, models, value_expr, path_expr, indent)?; + } + 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 = + 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", + } +} + +/// 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 { + 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", + } +} + +/// 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. 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. + (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() + } + } + // 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, + )?; + "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"); +} + +/// 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), 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, +) -> 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, + models, + 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); + } + + // 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. 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() + .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(()) +} + +/// Emits the dispatch of a union's `_<base>_to_transfer_type`. Unlike the parse +/// 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); + 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)?; + render_union_serialize_function(output, &base, &model.model_name, &union, models)?; + } + 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, &property.py_member_name(json_name)); + let member_type = annotation(property)?; + render_union_parse_function(output, &base, &member_type, &union)?; + // 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)?; + } + } + } + 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", " ") +} + +/// 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], +) -> Result<()> { + push_section(output); + output.push_str(&format!( + "def {}(value: {member_type}) -> typing.Any:\n", + union_serialize_fn(base) + )); + 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(()) +} + +// --------------------------------------------------------------------------- +// 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) + )); + 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. 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"); + 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)]" + ); + } + let storage_name = if property.default.is_some() { + format!("_{field_name}") + } else { + field_name.clone() + }; + output.push_str(" "); + output.push_str(&storage_name); + output.push_str(": "); + if property.default.is_some() { + 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 + // schema `default`, being a suggestion, is not. + if required.contains(json_name) { + output.push_str(&member_type); + } else { + output.push_str(&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)); + } 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 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(&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", + 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: {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(()) +} + +/// 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 { + // 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"); + } + 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. 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"); + + 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, models, "entry", "key", " ")?; + } + 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, ""); + } + 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 = 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); + // 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, 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, + &property.py_member_name(json_name) + )) + )), + _ => None, + }; + 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")); + " " + } else { + "" + }; + render_py_serialize_property_check( + output, json_name, property, models, guarded, indent, + )?; + match inline_union { + // 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, + ), + None => render_py_serialize_value( + output, + emitted, + PySerializeSink::Assign(&target), + &value_expr, + &path_expr, + indent, + &field_name, + )?, + } + } + } + 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_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, ""); + 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(()) +} + +/// 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`). +/// +/// 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; + } + 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. `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. +/// +/// 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, + property: &Schema, + models: &[&PlannedJsonType], + 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; + let body_indent = if guard_null { + format!("{indent} ") + } else { + indent.to_string() + }; + let mut body = String::new(); + render_py_field_checks( + &mut body, + property, + models, + &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, + models: &[&PlannedJsonType], + 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, + models, + 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<()> { + // 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!("{slot}_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, "", &slot, &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, &slot, &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, &slot, &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, &slot, &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, &property.py_member_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(()); + } + + // 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 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(()); + } + + 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, + } +} + +/// 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`. +#[allow(clippy::too_many_arguments)] +fn render_py_closed_value_parser( + output: &mut String, + values: &[Value], + compare_exprs: &[String], + raw_expr: &str, + target: &str, + path_expr: &str, + indent: &str, + reason: &str, +) { + let keyword = 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) + )); + "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 = format!("{compared} not in {}", py_value_tuple(compare_exprs)); + 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" + )); + output.push_str(indent); + output.push_str("else:\n"); + output.push_str(indent); + output.push_str(&format!(" {target} = {assignment}\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<()> { + 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"); + 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()); + + let body = body.to_string(); + 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 { + // 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, + &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)?; + Ok(list_local) +} + +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 +4629,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,312 +4643,27 @@ 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`. 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"); @@ -1885,319 +4698,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 +4768,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 +4782,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 +4813,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 +4835,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 +4902,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/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")); diff --git a/src/generator/python.rs b/src/generator/python.rs index a668beae..4b6307f6 100644 --- a/src/generator/python.rs +++ b/src/generator/python.rs @@ -30,6 +30,13 @@ 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."; +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>, support: &crate::SupportFiles, @@ -55,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( @@ -63,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) } @@ -76,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())?; @@ -89,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, @@ -105,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) => { @@ -115,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(()) } @@ -159,11 +192,16 @@ 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; + if !root_package_imports.is_empty() { + render_root_package_imports(&mut contents, root_package_imports); + 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 +227,8 @@ 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); + 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 { @@ -541,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 @@ -591,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> { @@ -662,9 +706,7 @@ impl<'a> ApiPlanner<'a> { .cloned(), ); let package_model_names = package_model_names.into_iter().collect::<Vec<_>>(); - insert_generated_file( - &mut files, - "__init__.py", + let render_init = |root_package_imports: &RootPackageImports| { if mode == GenerationMode::NativeApi { render_package_init( services, @@ -674,11 +716,19 @@ impl<'a> ApiPlanner<'a> { &support_names, self.api_plan, self.model_hoists, + root_package_imports, ) } else { - render_definitions_only_package_init(services, &model_names) - }, - )?; + 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", @@ -2157,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, }) @@ -2770,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 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, } @@ -2797,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); } } @@ -3219,6 +3273,7 @@ pub(in crate::generator) fn enum_default_expr( fn render_definitions_only_package_init( services: &[RenderedService<'_>], model_names: &[String], + root_package_imports: &RootPackageImports, ) -> String { let mut output = String::new(); render_generated_file_header(&mut output); @@ -3227,6 +3282,7 @@ fn render_definitions_only_package_init( .iter() .map(|service| service.name.to_string()) .collect::<Vec<_>>(); + render_root_package_imports(&mut output, root_package_imports); if !model_names.is_empty() { render_named_python_import(&mut output, ".models", model_names); } @@ -3235,7 +3291,12 @@ 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 root_package_export_names(root_package_imports) + .iter() + .map(String::as_str) + .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 +3489,25 @@ pub(in crate::generator) fn render_named_python_import( render_named_python_import_with_indent(output, module, 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( output: &mut String, module: &str, @@ -4526,6 +4606,7 @@ fn render_package_init( support_names: &[String], _api_plan: &PlannedSpec, _model_hoists: Option<&PythonModelHoists>, + root_package_imports: &RootPackageImports, ) -> String { let operation_function_names = services .iter() @@ -4550,6 +4631,7 @@ fn render_package_init( let mut output = String::new(); render_generated_file_header(&mut output); output.push('\n'); + render_root_package_imports(&mut output, root_package_imports); if !model_names.is_empty() { render_named_python_import(&mut output, ".models", model_names); } @@ -4596,6 +4678,11 @@ fn render_package_init( render_named_python_import(&mut output, "._support", &operation_registry_support_names); } output.push_str("\n__all__ = [\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(" "); output.push_str(&python_string_literal(name)); @@ -7372,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; @@ -7425,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) diff --git a/src/parser/json_schema.rs b/src/parser/json_schema.rs index 4d140f8c..9e4d83b0 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__" ) } @@ -5861,14 +5878,24 @@ 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). + // 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 + // 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)?; } + // 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) @@ -5894,28 +5921,18 @@ 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 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. /// (`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"], _ => &[], } @@ -6043,9 +6060,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(()); @@ -6084,6 +6101,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 @@ -6117,9 +6153,9 @@ fn validate_member_scope(language: Language, model_full_name: &str, schema: &Sch } /// 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) +/// 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 +6164,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, @@ -6144,7 +6181,7 @@ fn collect_ts_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() }) @@ -6164,7 +6201,7 @@ fn collect_ts_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}") @@ -6175,7 +6212,7 @@ fn collect_ts_default_constants( ) }; top.insert( - Language::TypeScript, + language, ident, format!("`{}.{json_name}` DEFAULT_ constant", model.full_name), )?; @@ -6245,6 +6282,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 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 +/// 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 @@ -9658,6 +9886,290 @@ properties: parse_for(Language::Python, input).expect("Python has no OrDefault accessor"); } + #[test] + 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::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: + 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" } +"##; + 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] + 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 @@ -9919,6 +10431,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 }"); @@ -10273,9 +10815,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 +10829,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 +10860,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] @@ -10376,11 +10924,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] @@ -10437,10 +10993,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 +11010,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..cfcc188b 100644 --- a/tests/generate_python.rs +++ b/tests/generate_python.rs @@ -86,6 +86,263 @@ 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`), +/// 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") +"#; + +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" +del unset.salutation +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")) } @@ -196,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() @@ -351,6 +623,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) @@ -385,31 +679,63 @@ 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"); - // 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 default-bearing property materializes on read while its private + // 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:")); + 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")); // `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")); } @@ -427,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(); } } @@ -577,6 +907,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(); @@ -858,8 +1220,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 +1246,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 +1255,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 +1282,93 @@ 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 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 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 +1392,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")); @@ -1052,7 +1482,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 [ @@ -1191,7 +1621,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}" ); @@ -1200,3 +1630,144 @@ 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(); +} + +#[test] +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"); + 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: 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.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("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")); + + assert_python_script_succeeds( + PYTHON_DATACLASS_DEFAULT_RUNTIME_CHECK, + &[temp_dir.to_str().unwrap(), "default_package"], + ); + fs::remove_dir_all(temp_dir).unwrap(); +}