flechtwerk 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
flechtwerk/__init__.py ADDED
@@ -0,0 +1,20 @@
1
+ """Flechtwerk — async stream processing framework for Kafka."""
2
+ from .configs import ConfigStore
3
+ from .extractor import Extractor
4
+ from .module import CompressionType, Flechtwerk, MqttBrokerConfig
5
+ from .transformer import Transformer
6
+ from .types import Config, Event, IncomingMessage, Message, State
7
+
8
+ __all__ = [
9
+ "CompressionType",
10
+ "Config",
11
+ "ConfigStore",
12
+ "Event",
13
+ "Extractor",
14
+ "Flechtwerk",
15
+ "IncomingMessage",
16
+ "Message",
17
+ "MqttBrokerConfig",
18
+ "State",
19
+ "Transformer",
20
+ ]
@@ -0,0 +1,50 @@
1
+ """Type-safe handles on dict keys, paired with explicit encode/decode codecs."""
2
+ from .attribute import (
3
+ Attribute,
4
+ MissingAttributeError,
5
+ RawDict,
6
+ )
7
+ from .codec import Codec, Decoder, Encoder
8
+ from .codecs import (
9
+ BOOL,
10
+ DATE,
11
+ DATETIME,
12
+ DICT,
13
+ FLOAT,
14
+ INT,
15
+ LIST,
16
+ SET,
17
+ STR,
18
+ TIME,
19
+ TUPLE,
20
+ )
21
+ from .record import (
22
+ ANY,
23
+ RECORD,
24
+ Record,
25
+ record_codec,
26
+ )
27
+
28
+ __all__ = [
29
+ "ANY",
30
+ "Attribute",
31
+ "BOOL",
32
+ "Codec",
33
+ "DATE",
34
+ "DATETIME",
35
+ "DICT",
36
+ "Decoder",
37
+ "Encoder",
38
+ "FLOAT",
39
+ "INT",
40
+ "LIST",
41
+ "MissingAttributeError",
42
+ "RawDict",
43
+ "RECORD",
44
+ "Record",
45
+ "SET",
46
+ "STR",
47
+ "TIME",
48
+ "TUPLE",
49
+ "record_codec",
50
+ ]
@@ -0,0 +1,173 @@
1
+ """Type-safe handles on keys in a dict, with a paired encode/decode codec.
2
+
3
+ `Attribute(name, codec)` declares a required field; `Attribute(name, codec,
4
+ optional=True)` declares one that may be absent or `None`. The keyword-only
5
+ `optional` flag drives exactly one write-side guard: a required attribute
6
+ rejects `None` writes (so a missing value can't land silently as JSON
7
+ `null`), an optional attribute stores `None` as `null`. Read semantics are
8
+ identical for both kinds and are carried by the *method*, not the flag —
9
+ `Record[attr]` reads-or-raises (returns `V`), `Record.get` / `Record.pop`
10
+ tolerate absence (return `V | None`). The `required` property is the
11
+ inverse of `optional`, for the few sites that branch on mandatory-ness
12
+ (e.g. a validation layer's missing-field check).
13
+
14
+ Each `Attribute` carries a `Codec[V]` that drives both the static type
15
+ parameter and the runtime encode/decode. The type checker infers the
16
+ `[V]` from the codec — `Attribute("name", STR)` produces an
17
+ `Attribute[str]` without an explicit subscript. Built-in codecs are
18
+ exported from `flechtwerk.attribute` (`STR`, `INT`, `DATETIME`, `RECORD`,
19
+ `LIST(RECORD)`, …).
20
+ """
21
+ from typing import Any
22
+
23
+ from .codec import Codec
24
+ from .codecs import IDENTITY
25
+
26
+ type RawDict = dict[str, Any]
27
+ """Wire-form JSON-native dict — the underlying storage of `Record.raw` and the
28
+ argument type for every `Attribute` dict-op (`read_from`, `write_to`, …)."""
29
+
30
+
31
+ class MissingAttributeError(KeyError):
32
+ """Raised by `Attribute.read_from` when the value is absent or `None`."""
33
+
34
+
35
+ class Attribute[V]:
36
+ """A typed handle on one key in a `RawDict`, paired with an encode/decode codec.
37
+
38
+ `optional=False` (the default) declares a required field whose
39
+ `write_to` rejects `None`; `optional=True` allows `None` (stored as
40
+ JSON `null`). The read methods are kind-agnostic — which presence
41
+ semantic applies is chosen by the caller's method (`[]` vs
42
+ `.get` / `.pop`), not by this flag.
43
+
44
+ Public attributes:
45
+
46
+ - `name`: the wire-level dict key this attribute reads/writes.
47
+ - `codec`: the `Codec[V]` driving encode/decode. Exposed so callers can
48
+ compose codecs (e.g. `LIST(some_attr.codec)` lifts an attribute's
49
+ codec into a list-of-V codec). Direct encode/decode access goes
50
+ through `attr.codec.encode` / `attr.codec.decode`; the Attribute
51
+ itself only surfaces dict-operating methods (`read_from`,
52
+ `write_to`, `present_in`, `delete_from`, `get_from`, `pop_from`).
53
+ - `optional`: whether `None` / absence is permitted on write.
54
+ """
55
+
56
+ def __init__(self, name: str, codec: Codec[V], *, optional: bool = False) -> None:
57
+ self.name = name
58
+ self.codec = codec
59
+ self.optional = optional
60
+
61
+ def __init_subclass__(cls, **kwargs: Any) -> None:
62
+ """Reject subclasses outside this module — the Attribute hierarchy is sealed."""
63
+ super().__init_subclass__(**kwargs)
64
+ if cls.__module__ != Attribute.__module__:
65
+ raise TypeError(f"{cls.__qualname__} cannot extend the sealed Attribute hierarchy")
66
+
67
+ @property
68
+ def required(self) -> bool:
69
+ """Whether this attribute must be present and non-`None` — the inverse of `optional`."""
70
+ return not self.optional
71
+
72
+ def read_from(self, raw: RawDict) -> V:
73
+ """Look up this attribute in `raw` and return the decoded value.
74
+
75
+ Raises `MissingAttributeError` if the key is absent or the stored
76
+ value is `None`. This is kind-agnostic — a `[]` read raises on
77
+ absence regardless of `optional`; absence-tolerance lives in
78
+ `Record.get` / `Record.pop`.
79
+ """
80
+ # Null ≡ missing is duplicated in get_from and pop_from — change all in lockstep.
81
+ v = raw.get(self.name)
82
+ if v is None:
83
+ raise MissingAttributeError(f"attribute {self!r} is missing")
84
+ return self.codec.decode(v)
85
+
86
+ def write_to(self, raw: RawDict, value: V | None) -> None:
87
+ """Encode `value` and store it under this attribute's name in `raw`.
88
+
89
+ A required attribute (`optional=False`) rejects `None` at the
90
+ write-site so it can't land silently as JSON `null`; an optional
91
+ attribute stores `None` as `null`. The `not self.optional` check
92
+ rides the `None` branch only, so the common non-`None` write pays
93
+ nothing for it.
94
+ """
95
+ if value is None:
96
+ if not self.optional:
97
+ raise ValueError(f"cannot assign None to required {self!r}")
98
+ raw[self.name] = None
99
+ else:
100
+ raw[self.name] = self.codec.encode(value)
101
+
102
+ def present_in(self, raw: RawDict) -> bool:
103
+ """Whether this attribute is present in `raw` (key exists, value may be `None`)."""
104
+ return self.name in raw
105
+
106
+ def delete_from(self, raw: RawDict) -> None:
107
+ """Remove this attribute's entry from `raw`. Raises `KeyError` if absent."""
108
+ del raw[self.name]
109
+
110
+ def get_from(self, raw: RawDict, default: V | None = None) -> V | None:
111
+ """Return the decoded value, or `default` if missing or `None`."""
112
+ v = raw.get(self.name)
113
+ return self.codec.decode(v) if v is not None else default
114
+
115
+ def pop_from(self, raw: RawDict, *default: V) -> V | None:
116
+ """Remove and return the decoded value; raise `KeyError` if missing and no default.
117
+
118
+ A stored `None` is returned as `None` (no decode), and the key is
119
+ removed — mirroring `dict.pop` semantics for `None` writes.
120
+ """
121
+ if not self.present_in(raw):
122
+ if default:
123
+ return default[0]
124
+ raise KeyError(self)
125
+ v = raw.get(self.name)
126
+ if v is not None:
127
+ v = self.codec.decode(v)
128
+ self.delete_from(raw)
129
+ return v
130
+
131
+ # Identity is (type, name) — never the codec or `optional`. The name is the
132
+ # wire key this handle addresses, so two handles for the same slot are equal.
133
+ # The codec is excluded on purpose: codecs compare by object identity, so a
134
+ # composite codec like LIST(STR) is rebuilt per call and would make
135
+ # "same field" attributes unequal. `type` is in the key so a ViewAttribute
136
+ # stays distinct from a plain Attribute of the same name — the dict-spread
137
+ # override relies on both write_to calls running, the typed one landing last.
138
+ def __eq__(self, other: object) -> bool:
139
+ return type(other) is type(self) and other.name == self.name # type: ignore[attr-defined]
140
+
141
+ def __hash__(self) -> int:
142
+ return hash((type(self), self.name))
143
+
144
+ def __repr__(self) -> str:
145
+ kind = ", optional=True" if self.optional else ""
146
+ return f"{type(self).__name__}({self.name!r}{kind})"
147
+
148
+
149
+ class ViewAttribute(Attribute[Any]):
150
+ """Synthesized view onto a `Record` key, produced by `Record.keys()`.
151
+
152
+ Overrides `read_from` (raw passthrough, None-tolerant) and `write_to`
153
+ (identity store, no encoding) — the spread roundtrip must list
154
+ every key including stored JSON "null", and the value coming back
155
+ in is already in wire form. The same dispatch protocol as every other
156
+ Attribute, just with no-op codec behavior.
157
+
158
+ Public class but deliberately not re-exported from `flechtwerk.attribute`:
159
+ application code constructs handles via `Attribute(...)`; `ViewAttribute`
160
+ is a framework-internal mechanism that powers `Record`'s dict-spread
161
+ support. Reaching it requires the fully qualified import, which serves
162
+ as the "you know what you're doing" signal in lieu of the leading
163
+ underscore.
164
+ """
165
+
166
+ def __init__(self, name: str):
167
+ super().__init__(name, IDENTITY)
168
+
169
+ def read_from(self, raw: RawDict) -> Any:
170
+ return raw[self.name]
171
+
172
+ def write_to(self, raw: RawDict, value: Any) -> None:
173
+ raw[self.name] = value
@@ -0,0 +1,31 @@
1
+ """The `Codec[V]` dataclass — paired encode/decode for a Python value of type `V`.
2
+
3
+ `Attribute[V]` is constructed with a `Codec[V]`. Both directions are required:
4
+ the codec is the single source of truth for how a `V` round-trips through
5
+ JSON-native form. The `[V]` type parameter on `Attribute` is inferred from
6
+ the codec by the type checker — there's no runtime type introspection.
7
+
8
+ Built-in codec constants (`STR`, `INT`, `DATETIME`, `RECORD`, …) live in
9
+ `codecs.py`.
10
+ """
11
+ from collections.abc import Callable
12
+ from dataclasses import dataclass
13
+ from typing import Any
14
+
15
+ type Decoder[V] = Callable[[Any], V]
16
+ """A function that decodes a JSON-native wire value to a Python value of type `V`."""
17
+
18
+ type Encoder[V] = Callable[[V], Any]
19
+ """A function that encodes a Python value of type `V` to a JSON-native wire value."""
20
+
21
+
22
+ @dataclass(frozen=True, slots=True)
23
+ class Codec[V]:
24
+ """A pair of `(decode, encode)` callables for a value of type `V`.
25
+
26
+ Both directions are required — there is no fallback registry. The value
27
+ type `V` is the single source of truth: pass a `Codec[V]` to an
28
+ `Attribute` and the type checker infers the `Attribute[V]` parameter.
29
+ """
30
+ decode: Decoder[V]
31
+ encode: Encoder[V]
@@ -0,0 +1,140 @@
1
+ """Built-in codec atoms and constructors.
2
+
3
+ The catalogue is composable: atoms (`STR`, `INT`, `BOOL`, `DATE`, `FLOAT`,
4
+ `DATETIME`, `TIME`) are fixed leaves; constructors (`LIST`, `SET`, `TUPLE`,
5
+ `DICT`) take an inner codec and return a parameterized container codec.
6
+ Element validation is uniform — `LIST(STR).encode([1, 2, 3])` rejects
7
+ non-strings (each element runs through `STR.encode`).
8
+
9
+ `RECORD` and `ANY` live in `record.py` because they need the `Record`
10
+ class and the recursive `_encode_any` walker, respectively.
11
+
12
+ Naming convention: all atoms and constructors use uppercase identifiers
13
+ matching the ALL_CAPS style of the typed-attribute call sites.
14
+ """
15
+ from datetime import date, datetime, time
16
+ from typing import Any, Final
17
+
18
+ from .codec import Codec, Decoder
19
+
20
+
21
+ def _encode_datetime(dt: datetime) -> str:
22
+ """Encoder for `DATETIME` — lossless `isoformat` defaults, `Z` for UTC.
23
+
24
+ Default `isoformat` precision (full microseconds when nonzero, no
25
+ fraction when zero) keeps the encoder lossless — a fixed `timespec`
26
+ would silently truncate sub-millisecond values and break round-trips.
27
+
28
+ The `Z` suffix replaces `+00:00` only when the zone *is* UTC
29
+ (`tzname() == "UTC"` — matches `timezone.utc`, `ZoneInfo("UTC")` and
30
+ plain `timezone(timedelta(0))`). A zone that merely coincides with UTC
31
+ (e.g. `Europe/London` in winter, `tzname() == "GMT"`) keeps its
32
+ `+00:00` offset — `Z` asserts UTC, not a zero offset.
33
+ """
34
+ encoded = dt.isoformat()
35
+ return encoded.replace("+00:00", "Z") if dt.tzname() == "UTC" else encoded
36
+
37
+
38
+ def _validate[T](t: type[T]) -> Decoder[T]:
39
+ """Codec helper that asserts `type(x) is t`.
40
+
41
+ Exact-type check, not `isinstance` — this matters for the int/bool split
42
+ (`bool` is a subclass of `int`, but `INT.encode(True)` should reject).
43
+ Statically enforced by the `Codec[T]` parameter at the call site; the
44
+ runtime `assert` is a safety net that disappears under `python -O`.
45
+ """
46
+
47
+ def check(x: Any) -> T:
48
+ assert type(x) is t, f"expected {t.__name__}, got {type(x).__name__}: {x!r}"
49
+ return x
50
+
51
+ return check
52
+
53
+
54
+ # --- atoms ---
55
+
56
+
57
+ STR: Final = Codec[str](_validate(str), _validate(str))
58
+ INT: Final = Codec[int](_validate(int), _validate(int))
59
+ BOOL: Final = Codec[bool](_validate(bool), _validate(bool))
60
+ DATE: Final = Codec[date](date.fromisoformat, date.isoformat)
61
+ FLOAT: Final = Codec[float](_validate(float), _validate(float))
62
+ DATETIME: Final = Codec[datetime](datetime.fromisoformat, _encode_datetime)
63
+ TIME: Final = Codec[time](time.fromisoformat, time.isoformat)
64
+
65
+
66
+ # --- constructors ---
67
+
68
+
69
+ def LIST[V](inner: Codec[V]) -> Codec[list[V]]:
70
+ """Codec for `list[V]` — runs `inner` over each element."""
71
+ return Codec(
72
+ decode=lambda lst: [inner.decode(x) for x in lst],
73
+ encode=lambda lst: [inner.encode(x) for x in lst],
74
+ )
75
+
76
+
77
+ def SET[V](inner: Codec[V]) -> Codec[set[V]]:
78
+ """Codec for `set[V]` — runs `inner` over each element.
79
+
80
+ Encode emits a sorted list (deterministic wire form for diff stability).
81
+ Sort is on raw values, then each is encoded — preserves the natural
82
+ ordering of the source type. V must be hashable (a Python set
83
+ requirement) and the raw values must be comparable to each other.
84
+ """
85
+ return Codec(
86
+ decode=lambda items: {inner.decode(x) for x in items},
87
+ encode=lambda s: [inner.encode(x) for x in sorted(s)],
88
+ )
89
+
90
+
91
+ def TUPLE[V](inner: Codec[V]) -> Codec[tuple[V, ...]]:
92
+ """Codec for `tuple[V, ...]` — runs `inner` over each element, preserves order."""
93
+ return Codec(
94
+ decode=lambda items: tuple(inner.decode(x) for x in items),
95
+ encode=lambda t: [inner.encode(x) for x in t],
96
+ )
97
+
98
+
99
+ def DICT[V](inner: Codec[V]) -> Codec[dict[str, V]]:
100
+ """Codec for `dict[str, V]` — runs `inner` over each value.
101
+
102
+ Keys must be exactly `str` — enforced statically by the
103
+ `dict[str, V]` parameter and asserted at runtime via exact-type
104
+ check (same discipline as `_validate`). Str subclasses (e.g.
105
+ `StrEnum` members) are rejected — accepting them silently would
106
+ lose type information at the boundary.
107
+ """
108
+
109
+ def encode(d: dict[str, V]) -> dict[str, Any]:
110
+ for k in d:
111
+ assert type(k) is str, f"DICT key must be str, got {type(k).__name__}: {k!r}"
112
+ return {k: inner.encode(v) for k, v in d.items()}
113
+
114
+ return Codec(
115
+ decode=lambda d: {k: inner.decode(v) for k, v in d.items()},
116
+ encode=encode,
117
+ )
118
+
119
+
120
+ IDENTITY: Final = Codec[Any](decode=lambda x: x, encode=lambda x: x)
121
+ """Identity codec — used by `ViewAttribute` where the value is already in wire form.
122
+
123
+ Deliberately not re-exported from `flechtwerk.attribute`: an application
124
+ that wants pass-through behavior should declare a real codec for the
125
+ underlying type rather than reach for `IDENTITY`. Available via the
126
+ fully-qualified import for the framework internals that need it.
127
+ """
128
+
129
+ __all__ = [
130
+ "BOOL",
131
+ "DATETIME",
132
+ "DICT",
133
+ "FLOAT",
134
+ "INT",
135
+ "LIST",
136
+ "SET",
137
+ "STR",
138
+ "TIME",
139
+ "TUPLE",
140
+ ]
@@ -0,0 +1,265 @@
1
+ """A typed dict wrapper, keyed exclusively by `Attribute` objects.
2
+
3
+ `Record` wraps an underlying `dict[str, Any]` (exposed as `raw`) whose
4
+ string keys serialize directly to JSON. All public access uses
5
+ `Attribute` instances:
6
+
7
+ event[CHANNEL_ID] # decoded V, or raise if absent/null
8
+ event[CHANNEL_ID] = value # dispatches via attr.write_to
9
+ event.get(STATUS, default) # decoded V or default
10
+ event.pop(LAST_TIME, default) # decoded V or default
11
+ del event[STATUS] # removes the entry
12
+ STATUS in event # presence check
13
+
14
+ Indexing with a string raises `TypeError`. The *method* encodes the
15
+ presence intent — `[]` asserts the value is present (returns `V`),
16
+ `.get()` / `.pop()` tolerate absence (return `V | None`) — independent
17
+ of whether the attribute was declared `optional`.
18
+
19
+ Iteration (`iter(record)`, `for attr in record`) yields the same
20
+ synthesized `ViewAttribute` handles that `keys()` produces — aligning
21
+ with the standard `Mapping` convention that `iter(d) == iter(d.keys())`.
22
+ Use `record.raw` directly if you need the wire-form key strings.
23
+
24
+ Dict-spread is supported (`Record({**other_record, NEW_ATTR: value})`):
25
+ ``keys()`` yields `ViewAttribute` handles that override `read_from`
26
+ (raw passthrough, None-tolerant) and `write_to` (identity store) so the
27
+ spread roundtrip flows through the standard Record paths without any
28
+ special-casing in Record itself.
29
+ """
30
+ from collections.abc import Iterable, Iterator
31
+ from copy import deepcopy
32
+ from datetime import date, datetime, time
33
+ from typing import Any, Final, Self, overload
34
+
35
+ from .attribute import (
36
+ Attribute,
37
+ RawDict,
38
+ ViewAttribute,
39
+ )
40
+ from .codec import Codec
41
+ from .codecs import DATE, DATETIME, DICT, LIST, SET, TIME, TUPLE
42
+
43
+
44
+ def _encode_any(v: Any) -> Any:
45
+ """Recursively encode any value to JSON-native form via isinstance dispatch.
46
+
47
+ Walks dicts/lists/sets/tuples through their `(ANY)` codecs; converts
48
+ `datetime` to ISO 8601, `date` to ISO 8601 (`YYYY-MM-DD`), `time` to
49
+ ISO 8601 (`HH:MM:SS[.ffffff]`), `Record` to a shallow copy of its
50
+ `raw`. The JSON-native primitives
51
+ (`str`, `int`, `float`, `bool`, `None`) pass through unchanged.
52
+ Raises `TypeError` on any other type — silent passthrough would let
53
+ non-JSON-native values land in `Record.raw` and crash later in
54
+ `json.dumps`.
55
+
56
+ This is the implementation of `ANY.encode` and the runtime-dispatch
57
+ layer that container codecs delegate into when their inner is `ANY`.
58
+ """
59
+ if v is None:
60
+ return v
61
+ if isinstance(v, (str, int, float)): # bool ⊂ int — passes through as bool
62
+ return v
63
+ if isinstance(v, datetime):
64
+ return DATETIME.encode(v)
65
+ if isinstance(v, date): # datetime ⊂ date — must come after the datetime check
66
+ return DATE.encode(v)
67
+ if isinstance(v, time):
68
+ return TIME.encode(v)
69
+ if isinstance(v, Record):
70
+ return RECORD.encode(v)
71
+ if isinstance(v, dict):
72
+ return _DICT_OF_ANY.encode(v)
73
+ if isinstance(v, list):
74
+ return _LIST_OF_ANY.encode(v)
75
+ if isinstance(v, set):
76
+ return _SET_OF_ANY.encode(v)
77
+ if isinstance(v, tuple):
78
+ return _TUPLE_OF_ANY.encode(v)
79
+ raise TypeError(f"no encoder for {type(v).__name__}: {v!r}")
80
+
81
+
82
+ class Record:
83
+ """Wrapper around a `RawDict` with `Attribute`-only access."""
84
+
85
+ raw: RawDict
86
+
87
+ def __init__(self, source: Record | dict[Attribute, Any] | None = None, /) -> None:
88
+ self.raw = {}
89
+ if source is None:
90
+ return
91
+ if isinstance(source, Record):
92
+ self.raw = source.raw.copy()
93
+ return
94
+ for k, v in source.items():
95
+ k.write_to(self.raw, v)
96
+
97
+ @classmethod
98
+ def wrap(cls, source: RawDict, /) -> Self:
99
+ self = cls()
100
+ for k, v in source.items():
101
+ self.raw[k] = _encode_any(v)
102
+ return self
103
+
104
+ def __reduce__(self) -> tuple:
105
+ # Pickle reconstructs via (cls.wrap, (raw,)), running back through the
106
+ # wire-format path. copy.copy/deepcopy do not ride this — they use the
107
+ # dedicated __copy__/__deepcopy__ below.
108
+ return self.__class__.wrap, (self.raw,)
109
+
110
+ # --- indexing ---
111
+
112
+ def __getitem__[V](self, attr: Attribute[V]) -> V:
113
+ return attr.read_from(self.raw)
114
+
115
+ def __setitem__[V](self, attr: Attribute[V], value: V | None) -> None:
116
+ attr.write_to(self.raw, value)
117
+
118
+ def __delitem__(self, attr: Attribute) -> None:
119
+ """Remove the key from the underlying dict. Raises `KeyError` if absent."""
120
+ attr.delete_from(self.raw)
121
+
122
+ def __contains__(self, attr: Attribute) -> bool:
123
+ """Check key presence — kind-agnostic, since a question neither decodes
124
+ a value nor mutates the absence contract."""
125
+ return attr.present_in(self.raw)
126
+
127
+ # --- container protocol ---
128
+
129
+ def __len__(self) -> int:
130
+ return len(self.raw)
131
+
132
+ def __iter__(self) -> Iterator[Attribute[Any]]:
133
+ """Yield a `ViewAttribute` per key in `raw`, lazily.
134
+
135
+ The primary iteration protocol — `keys()` materializes this for
136
+ the dict-spread path. Aligns with Python's dict idiom where
137
+ `__iter__` is the fundamental lazy walk and `keys()` is the
138
+ view-returning convenience.
139
+ """
140
+ for name in self.raw:
141
+ yield ViewAttribute(name)
142
+
143
+ def keys(self) -> Iterable[Attribute[Any]]:
144
+ """Materialize a list of `ViewAttribute` handles from `__iter__`.
145
+
146
+ Enables dict-spread: ``Record({**other, NEW_ATTR: value})`` calls
147
+ ``other.keys()`` and then ``other[view_attr]`` for each — both
148
+ landing on the view's overridden read/write methods. The constructor
149
+ stores the values as-is via ``ViewAttribute.write_to``.
150
+ """
151
+ return list(self)
152
+
153
+ def __bool__(self) -> bool:
154
+ return bool(self.raw)
155
+
156
+ # --- equality, repr, copy, hash ---
157
+
158
+ def __eq__(self, other: object) -> bool:
159
+ return type(other) is type(self) and self.raw == other.raw # type: ignore[attr-defined]
160
+
161
+ # Defining `__eq__` implicitly sets `__hash__ = None`, marking the class
162
+ # unhashable — no need to spell it out.
163
+
164
+ def __repr__(self) -> str:
165
+ return f"{type(self).__name__}({self.raw!r})"
166
+
167
+ def __copy__(self) -> Record:
168
+ return type(self)(self)
169
+
170
+ def __deepcopy__(self, memo: dict) -> Record:
171
+ new = type(self)()
172
+ new.raw = deepcopy(self.raw, memo)
173
+ return new
174
+
175
+ # --- Pythonic helpers ---
176
+
177
+ @overload
178
+ def get[V](self, attr: Attribute[V]) -> V | None:
179
+ ...
180
+
181
+ @overload
182
+ def get[V](self, attr: Attribute[V], default: V) -> V:
183
+ ...
184
+
185
+ def get[V](self, attr: Attribute[V], default: V | None = None) -> V | None:
186
+ """Return the decoded value, or `default` if missing or `None`."""
187
+ return attr.get_from(self.raw, default)
188
+
189
+ def pop[V](self, attr: Attribute[V], /, *default: V) -> V | None:
190
+ """Remove and return the decoded value; raise KeyError if missing and no default given.
191
+
192
+ A stored `None` is returned as `None` (no decode) and the key is removed —
193
+ mirroring `dict.pop` semantics and matching how an optional attribute's
194
+ `write_to` allows `None` writes.
195
+ """
196
+ return attr.pop_from(self.raw, *default)
197
+
198
+ def coalesce[V](self, *attrs: Attribute[V]) -> V | None:
199
+ """Return the first non-`None` decoded value among the given attributes.
200
+
201
+ Equivalent to a chain of `.get()` falls-through: returns
202
+ `.get(attrs[0])` if present, else `.get(attrs[1])`, ..., else
203
+ `None`. Useful for wire formats where the same logical field
204
+ appears under several names (e.g. pagination variants like
205
+ `pages` vs `total_page` vs `total_pages`).
206
+ """
207
+ for attr in attrs:
208
+ v = attr.get_from(self.raw)
209
+ if v is not None:
210
+ return v
211
+ return None
212
+
213
+ def update(self, other: Record) -> None:
214
+ """Merge another `Record` into this one."""
215
+ self.raw.update(other.raw)
216
+
217
+
218
+ def record_codec[T: Record](cls: type[T]) -> Codec[T]:
219
+ """Build a `Codec[T]` for a `Record` subclass `T`.
220
+
221
+ Decode wraps the raw dict in `cls`; encode returns a shallow copy of
222
+ `.raw`. Top-level isolation is preserved (the new owner gets its own
223
+ dict to mutate via `__setitem__`); nested dicts/lists are shared,
224
+ matching the framework's "treat `.raw` as immutable from outside"
225
+ contract.
226
+ """
227
+ return Codec(
228
+ decode=cls.wrap,
229
+ encode=lambda r: r.raw.copy(),
230
+ )
231
+
232
+
233
+ RECORD: Final = record_codec(Record)
234
+ """Codec for an untyped `Record` value.
235
+
236
+ Use as `Attribute("data", RECORD)` for fields whose value is a
237
+ plain `Record`. For `Record` subclasses, build a per-subclass codec via
238
+ `record_codec(cls)` (or use the constants exported from `flechtwerk.types`
239
+ for `Config`, `Event`, `State`).
240
+ """
241
+
242
+ ANY: Final = Codec[Any](
243
+ decode=lambda x: x,
244
+ encode=_encode_any,
245
+ )
246
+ """The universal escape-hatch codec for `Any`-typed values.
247
+
248
+ Decode is identity (the wire value passes through unchanged — JSON load
249
+ already produced JSON-native shape). Encode runs the recursive
250
+ `_encode_any` walker, which dispatches on runtime type and handles
251
+ `Record`, `dict`, `list`, `set`, `tuple`, `datetime`, `date`, `time`,
252
+ and the JSON-native primitives.
253
+
254
+ Compose with the container constructors for typed-but-heterogeneous
255
+ fields: `LIST(DICT(ANY))` for `list[dict[str, Any]]`, `DICT(ANY)` for
256
+ `dict[str, Any]`, etc.
257
+ """
258
+
259
+ # Pre-built bare-Any container codecs, used by `_encode_any`'s isinstance
260
+ # dispatch. Building these once at module load time avoids reconstructing a
261
+ # fresh `Codec` per recursive call.
262
+ _DICT_OF_ANY: Final = DICT(ANY)
263
+ _LIST_OF_ANY: Final = LIST(ANY)
264
+ _SET_OF_ANY: Final = SET(ANY)
265
+ _TUPLE_OF_ANY: Final = TUPLE(ANY)