fora-protocol 1.0.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
wire/base.py ADDED
@@ -0,0 +1,106 @@
1
+ """Base class for every generated model.
2
+
3
+ All generated models inherit this, so it is the single place to configure model-wide
4
+ behavior and the one point an application extends to add or relax behavior across every
5
+ model at once. Hand-written; not regenerated.
6
+
7
+ Because every generated model — nested ones included — inherits this class, a validator
8
+ here runs at every depth of a message for free. That is why the wire-naming refusal below
9
+ needs no walk, while its TypeScript twin in ``gen/ts/wire/base.ts`` has to recurse itself.
10
+ """
11
+ from typing import Any
12
+
13
+ from pydantic import BaseModel, ConfigDict, model_validator
14
+ from pydantic_core import PydanticCustomError
15
+
16
+ from wire.names import snake_from_json_name
17
+ from wire.unique import UNIQUE_ITEM_FIELDS
18
+
19
+ #: Error ``type`` on the refusal below, so a caller can recognise it in
20
+ #: ``ValidationError.errors()`` without matching on a message string.
21
+ JSON_NAME_ALIAS_ERROR = "fora_json_name_alias"
22
+
23
+
24
+ class WireModel(BaseModel):
25
+ # Forward-compatible: fields from a newer protocol version are ignored, not
26
+ # rejected. A consumer that wants strictness sets extra="forbid" in its own
27
+ # subclass.
28
+ model_config = ConfigDict(extra="ignore", populate_by_name=True)
29
+
30
+ @model_validator(mode="before")
31
+ @classmethod
32
+ def _refuse_json_name_alias(cls, data: Any) -> Any:
33
+ """Refuse a message whose field names are protojson's lowerCamelCase alias.
34
+
35
+ The FORA wire is snake_case proto-JSON and the camelCase ``json_name`` alias is out
36
+ of contract, so a conformant peer serves snake_case — connect-go does that only
37
+ when a codec with UseProtoNames is registered, which a FORA deployment does and a
38
+ stock connect-go server does not. ``extra="ignore"`` above means such an answer
39
+ would otherwise validate SUCCESSFULLY into a message with every multiword field
40
+ missing, and nothing anywhere would say so.
41
+
42
+ Depth is the point, and the money verb is why: a stock connect-go server omits
43
+ unset fields, so a TransactionResponse arrives as ``{ver, items}`` — every root key
44
+ a single word, identical in both spellings — while ``transaction_id`` sits one
45
+ level down in TransactionResultItem and is dropped. That reads as a purchase that
46
+ succeeded with no transaction id and no delivery URL, severing the dispute chain at
47
+ its first link. Inheritance covers the depth: TransactionResultItem is a WireModel
48
+ too, so it checks itself.
49
+
50
+ Open maps are out of reach by construction rather than by a hold-back list. A
51
+ ``dict[str, Any]`` field is validated as data, not as a model, so its keys — which
52
+ are caller-chosen — never reach this method.
53
+
54
+ A value that is not a mapping is left alone: this also runs when a model is built
55
+ from Python objects rather than parsed from the wire, and there the field names are
56
+ already the field names.
57
+ """
58
+ if not isinstance(data, dict):
59
+ return data
60
+ declared = cls.model_fields
61
+ for key in data:
62
+ if not isinstance(key, str) or key in declared:
63
+ continue
64
+ name = snake_from_json_name(key)
65
+ if name != key and name in declared:
66
+ raise PydanticCustomError(
67
+ JSON_NAME_ALIAS_ERROR,
68
+ "peer answered with the lowerCamelCase json_name alias ({key}); the FORA "
69
+ "wire is snake_case proto-JSON, so its answer cannot be read without "
70
+ "silently dropping every multiword field",
71
+ {"key": key},
72
+ )
73
+ return data
74
+
75
+ @model_validator(mode="after")
76
+ def _reject_duplicate_items(self) -> "WireModel":
77
+ # (buf.validate.field).repeated.unique. datamodel-code-generator drops JSON
78
+ # Schema's `uniqueItems` for pydantic v2, so this rule cannot ride into models.py
79
+ # the way every other field rule does; it arrives via the generated wire/unique.py
80
+ # instead. Zod gets the same rule inline, from `uniqueItems` in the JSON Schema.
81
+ # No rule is restated here: the field list is derived from Go protovalidate.
82
+ #
83
+ # Walk the MRO, NOT just type(self).__name__: the manifest is keyed by the
84
+ # generated message class name, and a consumer SUBCLASS (the documented extension
85
+ # seam above) has a different __name__. Field-annotation rules (constr/Field
86
+ # bounds, patterns) inherit structurally across the MRO; this out-of-band rule
87
+ # must walk the base classes itself or it silently no-ops on subclasses.
88
+ # WireModel/BaseModel/object are not manifest keys, so they resolve to () — inert.
89
+ fields: set[str] = set()
90
+ for klass in type(self).__mro__:
91
+ fields.update(UNIQUE_ITEM_FIELDS.get(klass.__name__, ()))
92
+ for name in fields:
93
+ items = getattr(self, name, None)
94
+ if items is not None and len(items) != len(set(items)):
95
+ raise ValueError(f"{name}: repeated value must contain unique items")
96
+ return self
97
+
98
+ def model_dump(self, **kwargs: Any) -> dict[str, Any]:
99
+ # Proto-JSON omits unset optional fields; default to the same so parse → dump
100
+ # round-trips match the wire. Pass exclude_none=False to include nulls.
101
+ kwargs.setdefault("exclude_none", True)
102
+ return super().model_dump(**kwargs)
103
+
104
+ def model_dump_json(self, **kwargs: Any) -> str:
105
+ kwargs.setdefault("exclude_none", True)
106
+ return super().model_dump_json(**kwargs)