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 +20 -0
- flechtwerk/attribute/__init__.py +50 -0
- flechtwerk/attribute/attribute.py +173 -0
- flechtwerk/attribute/codec.py +31 -0
- flechtwerk/attribute/codecs.py +140 -0
- flechtwerk/attribute/record.py +265 -0
- flechtwerk/configs.py +156 -0
- flechtwerk/extractor.py +311 -0
- flechtwerk/kafka.py +212 -0
- flechtwerk/metrics.py +192 -0
- flechtwerk/module.py +466 -0
- flechtwerk/mqtt.py +486 -0
- flechtwerk/observer.py +105 -0
- flechtwerk/py.typed +0 -0
- flechtwerk/state.py +249 -0
- flechtwerk/testing.py +371 -0
- flechtwerk/transformer.py +460 -0
- flechtwerk/types.py +72 -0
- flechtwerk-0.1.0.dist-info/METADATA +292 -0
- flechtwerk-0.1.0.dist-info/RECORD +22 -0
- flechtwerk-0.1.0.dist-info/WHEEL +4 -0
- flechtwerk-0.1.0.dist-info/licenses/LICENSE +21 -0
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)
|