pulse-data 0.2.2__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.
@@ -0,0 +1,19 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later OR LicenseRef-Inventzia-Commercial
2
+ # Copyright (c) 2013-2026 Magrino Bini, Paola Apruzzese, Inventzia Science and Technology Ltd.
3
+ #
4
+ # This file is part of pulse-data.
5
+ #
6
+ # pulse-data is dual-licensed:
7
+ # - Under the GNU Affero General Public License v3.0 or later (see LICENSE-AGPL-3.0).
8
+ # - Under a commercial license (see LICENSE-COMMERCIAL.txt).
9
+ # Contact operations@inventzia.com.
10
+ """pulse-data: the Datum routing contract and generated event schemas.
11
+
12
+ See the :mod:`inventzia.pulse.data.datum` subpackage for the ``Datum`` protocol
13
+ and JSON codecs, and ``inventzia.pulse.data.schemas`` for the generated models.
14
+
15
+ ``inventzia`` and ``inventzia.pulse`` are deliberately PEP 420 namespace packages
16
+ (no ``__init__.py``) so pulse-data and pulse-beacon can share the
17
+ ``inventzia.pulse.*`` prefix and install side by side; from here inward the
18
+ hand-written packages are regular packages with deliberate exports.
19
+ """
@@ -0,0 +1,29 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later OR LicenseRef-Inventzia-Commercial
2
+ # Copyright (c) 2013-2026 Magrino Bini, Paola Apruzzese, Inventzia Science and Technology Ltd.
3
+ #
4
+ # This file is part of pulse-data.
5
+ #
6
+ # pulse-data is dual-licensed:
7
+ # - Under the GNU Affero General Public License v3.0 or later (see LICENSE-AGPL-3.0).
8
+ # - Under a commercial license (see LICENSE-COMMERCIAL.txt).
9
+ # Contact operations@inventzia.com.
10
+ """The Datum routing contract and its JSON / tagged-JSON codecs.
11
+
12
+ from inventzia.pulse.data.datum import Datum, to_tagged_json, from_tagged_json
13
+ """
14
+
15
+ from inventzia.pulse.data.datum.datum import Datum
16
+ from inventzia.pulse.data.datum.codec import (
17
+ from_json,
18
+ from_tagged_json,
19
+ to_json,
20
+ to_tagged_json,
21
+ )
22
+
23
+ __all__ = [
24
+ "Datum",
25
+ "to_json",
26
+ "from_json",
27
+ "to_tagged_json",
28
+ "from_tagged_json",
29
+ ]
@@ -0,0 +1,94 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later OR LicenseRef-Inventzia-Commercial
2
+ # Copyright (c) 2013-2026 Magrino Bini, Paola Apruzzese, Inventzia Science and Technology Ltd.
3
+ #
4
+ # This file is part of pulse-data.
5
+ #
6
+ # pulse-data is dual-licensed:
7
+ # - Under the GNU Affero General Public License v3.0 or later (see LICENSE-AGPL-3.0).
8
+ # - Under a commercial license (see LICENSE-COMMERCIAL.txt).
9
+ # Contact operations@inventzia.com.
10
+ """
11
+ The Python counterpart of the Java ``DatumCodec``.
12
+
13
+ pulse-data owns how a :class:`~inventzia.pulse.data.datum.datum.Datum` becomes
14
+ JSON in *both* languages, so a value produced in one is consumed verbatim in the
15
+ other. Pydantic does the field-level (de)serialisation; this module adds the two
16
+ forms the transport layer uses:
17
+
18
+ * **type-directed** — :func:`to_json` / :func:`from_json`, when the caller knows
19
+ the concrete model class (e.g. it knows the topic's payload type);
20
+ * **self-describing (tagged)** — :func:`to_tagged_json` / :func:`from_tagged_json`,
21
+ which embed the ``TYPE_ID`` in a small envelope so a receiver can recover the
22
+ type from the message itself. Required wherever the type is not known ahead of
23
+ time: the in-process cross-language bridge, and later the socket/ZMQ transport.
24
+ The type is resolved through the composite
25
+ :mod:`inventzia.pulse.data.datum.registry` (the mirror of Java's
26
+ ``DatumTypeRegistry``). The tagged functions default to the process-wide registry;
27
+ pass ``registry=`` (or use :class:`TaggedCodec`) to bind an isolated one in tests.
28
+
29
+ The tagged envelope is identical to the Java side::
30
+
31
+ {"typeId": "<TYPE_ID>", "payload": { ...fields... }}
32
+ """
33
+
34
+ import json
35
+ from typing import Optional, TypeVar
36
+
37
+ from inventzia.pulse.data.datum.registry import Registry, default_registry
38
+
39
+ _FIELD_TYPE_ID = "typeId"
40
+ _FIELD_PAYLOAD = "payload"
41
+
42
+ T = TypeVar("T")
43
+
44
+
45
+ def to_json(datum) -> str:
46
+ """Serialise a datum to a single-line JSON string (flat, field names = wire names).
47
+
48
+ ``exclude_none=True`` omits absent optional fields, matching the Java codec's
49
+ ``@JsonInclude(NON_NULL)`` so the two languages produce identical envelopes.
50
+ """
51
+ return datum.model_dump_json(by_alias=True, exclude_none=True)
52
+
53
+
54
+ def from_json(json_str: str, model_class: type[T]) -> T:
55
+ """Deserialise a JSON string into the given model class."""
56
+ return model_class.model_validate_json(json_str)
57
+
58
+
59
+ def to_tagged_json(datum, registry: Optional[Registry] = None) -> str:
60
+ """Serialise a datum to the self-describing envelope ``{"typeId", "payload"}``.
61
+
62
+ ``registry`` defaults to the process-wide composite registry; pass an isolated one
63
+ (from ``build_registry``) to encode against a custom type universe.
64
+ """
65
+ reg = registry if registry is not None else default_registry()
66
+ payload = json.loads(datum.model_dump_json(by_alias=True, exclude_none=True))
67
+ return json.dumps({_FIELD_TYPE_ID: reg.type_id_of(datum), _FIELD_PAYLOAD: payload})
68
+
69
+
70
+ def from_tagged_json(json_str: str, registry: Optional[Registry] = None):
71
+ """Deserialise a tagged envelope, recovering the concrete type from its ``typeId``.
72
+
73
+ ``registry`` defaults to the process-wide composite registry.
74
+ """
75
+ reg = registry if registry is not None else default_registry()
76
+ envelope = json.loads(json_str)
77
+ type_id = envelope.get(_FIELD_TYPE_ID)
78
+ if not isinstance(type_id, str):
79
+ raise ValueError(f"Tagged JSON missing textual {_FIELD_TYPE_ID!r}: {json_str}")
80
+ model_class = reg.class_for(type_id)
81
+ return model_class.model_validate(envelope.get(_FIELD_PAYLOAD))
82
+
83
+
84
+ class TaggedCodec:
85
+ """A tagged codec bound to a specific :class:`Registry` (for tests / isolated universes)."""
86
+
87
+ def __init__(self, registry: Registry):
88
+ self._registry = registry
89
+
90
+ def to_tagged_json(self, datum) -> str:
91
+ return to_tagged_json(datum, self._registry)
92
+
93
+ def from_tagged_json(self, json_str: str):
94
+ return from_tagged_json(json_str, self._registry)
@@ -0,0 +1,50 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later OR LicenseRef-Inventzia-Commercial
2
+ # Copyright (c) 2013-2026 Magrino Bini, Paola Apruzzese, Inventzia Science and Technology Ltd.
3
+ #
4
+ # This file is part of pulse-data.
5
+ #
6
+ # pulse-data is dual-licensed:
7
+ # - Under the GNU Affero General Public License v3.0 or later (see LICENSE-AGPL-3.0).
8
+ # - Under a commercial license (see LICENSE-COMMERCIAL.txt).
9
+ # Contact operations@inventzia.com.
10
+ """
11
+ Routing contract for all data types transported on the pulse-beacon bus.
12
+
13
+ Python equivalent of the Java ``Datum`` interface. Uses structural typing
14
+ (``Protocol``) so generated Pydantic models satisfy the contract without
15
+ explicit inheritance — they simply need ``datum_key`` and ``datum_time``
16
+ properties.
17
+ """
18
+
19
+ from typing import Protocol, runtime_checkable
20
+
21
+
22
+ @runtime_checkable
23
+ class Datum(Protocol):
24
+ """
25
+ Every generated schema class satisfies this protocol by exposing
26
+ ``datum_key`` and ``datum_time`` properties whose implementations
27
+ are generated from the ``x-datum-key`` and ``x-datum-time`` YAML
28
+ annotations on the source schema.
29
+
30
+ In Python the contract is structural: no explicit ``implements`` is
31
+ needed. The pulse-beacon Python infrastructure reads these properties
32
+ by duck typing.
33
+ """
34
+
35
+ @property
36
+ def datum_key(self) -> str:
37
+ """
38
+ The routing key for this datum instance — e.g. an instrument
39
+ symbol, session ID, or tenant. Never ``None``; return an empty
40
+ string when no key applies.
41
+ """
42
+ ...
43
+
44
+ @property
45
+ def datum_time(self) -> int:
46
+ """
47
+ The logical time of this datum in epoch milliseconds — when the
48
+ underlying event occurred, not when it was published or received.
49
+ """
50
+ ...
@@ -0,0 +1,67 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later OR LicenseRef-Inventzia-Commercial
2
+ # Copyright (c) 2013-2026 Magrino Bini, Paola Apruzzese, Inventzia Science and Technology Ltd.
3
+ #
4
+ # This file is part of pulse-data.
5
+ #
6
+ # pulse-data is dual-licensed:
7
+ # - Under the GNU Affero General Public License v3.0 or later (see LICENSE-AGPL-3.0).
8
+ # - Under a commercial license (see LICENSE-COMMERCIAL.txt).
9
+ # Contact operations@inventzia.com.
10
+ """The datum-type service-provider interface (SPI).
11
+
12
+ Each package that contributes routed :class:`Datum` types (pulse-data itself, and
13
+ independent extensions such as an adapter) publishes a :class:`DatumTypeProvider`.
14
+ A provider is pure declarative metadata plus binding descriptors, discovered once and
15
+ composed into an immutable registry (see :mod:`inventzia.pulse.data.datum.registry`).
16
+ Provider construction and loading must have no network, authentication, or gateway
17
+ side effects.
18
+
19
+ Extensions register a provider through the ``inventzia.pulse.datum_types`` packaging
20
+ entry-point group, whose value is a public zero-argument provider class; the registry
21
+ instantiates it exactly once. The core provider is generated and seeded directly, not
22
+ discovered.
23
+ """
24
+
25
+ from dataclasses import dataclass
26
+ from typing import Optional, Protocol, runtime_checkable
27
+
28
+ #: The SPI contract version this pulse-data implements. Providers must match it exactly.
29
+ SPI_VERSION = 1
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class DatumTypeBinding:
34
+ """One contributed type: its ``TYPE_ID``, ``TYPE_VERSION``, and model class.
35
+
36
+ Built from the class's own constants (``Model.TYPE_ID`` / ``Model.TYPE_VERSION``),
37
+ so nothing is duplicated. Mirrors the Java ``DatumTypeBinding`` record.
38
+ """
39
+
40
+ type_id: str
41
+ type_version: int
42
+ datum_class: type
43
+
44
+
45
+ @runtime_checkable
46
+ class DatumTypeProvider(Protocol):
47
+ """Declares a set of :class:`Datum` types contributed by one package."""
48
+
49
+ def provider_id(self) -> str:
50
+ """Reverse-DNS namespace root; every contributed ``TYPE_ID`` starts with it + '.'."""
51
+ ...
52
+
53
+ def spi_version(self) -> int:
54
+ """The SPI contract version this provider was built against."""
55
+ ...
56
+
57
+ def package_version(self) -> str:
58
+ """The contributing distribution's version (informational; baked at build time)."""
59
+ ...
60
+
61
+ def bindings(self) -> "list[DatumTypeBinding]":
62
+ """The type bindings this provider contributes."""
63
+ ...
64
+
65
+ def manifest(self) -> Optional[str]:
66
+ """Reserved for Phase 2 (schema-manifest fingerprinting). ``None`` in Phase 1."""
67
+ ...
@@ -0,0 +1,269 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later OR LicenseRef-Inventzia-Commercial
2
+ # Copyright (c) 2013-2026 Magrino Bini, Paola Apruzzese, Inventzia Science and Technology Ltd.
3
+ #
4
+ # This file is part of pulse-data.
5
+ #
6
+ # pulse-data is dual-licensed:
7
+ # - Under the GNU Affero General Public License v3.0 or later (see LICENSE-AGPL-3.0).
8
+ # - Under a commercial license (see LICENSE-COMMERCIAL.txt).
9
+ # Contact operations@inventzia.com.
10
+ """The composite datum-type registry: TYPE_ID <-> model class.
11
+
12
+ Built once from the core provider (seeded directly) plus discovered extension
13
+ providers, validated, then frozen. The tagged codec resolves types through it. This is
14
+ hand-written infrastructure; the per-provider bindings are generated. Mirror of the Java
15
+ ``DatumTypeRegistry``.
16
+ """
17
+
18
+ import hashlib
19
+ import re
20
+ import threading
21
+ from dataclasses import dataclass
22
+ from importlib.metadata import entry_points
23
+
24
+ import pydantic
25
+
26
+ from inventzia.pulse.data.datum.provider import SPI_VERSION, DatumTypeBinding, DatumTypeProvider
27
+
28
+ _ENTRY_POINT_GROUP = "inventzia.pulse.datum_types"
29
+ _ID_RE = re.compile(r"[A-Za-z0-9_]+(\.[A-Za-z0-9_]+)+") # reverse-DNS-ish: dotted segments
30
+ _HEX64 = re.compile(r"[0-9a-f]{64}")
31
+ _MANIFEST_FORMAT = "pdm1"
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class ProviderInfo:
36
+ """Retained, immutable metadata for one contributing provider (for diagnostics / audit)."""
37
+
38
+ provider_id: str
39
+ package_version: str
40
+ manifest: "str | None" # canonical pdm1 string, or None (unverifiable)
41
+ entries: "tuple | None" # ((type_id, type_version, fingerprint), ...) or None
42
+
43
+
44
+ class Registry:
45
+ """An immutable TYPE_ID <-> class registry. Build via :func:`build_registry`."""
46
+
47
+ __slots__ = ("_by_id", "_by_class", "_providers")
48
+
49
+ def __init__(self, by_id: dict, by_class: dict, providers: tuple):
50
+ self._by_id = by_id
51
+ self._by_class = by_class
52
+ self._providers = providers
53
+
54
+ def providers(self) -> tuple:
55
+ """Immutable :class:`ProviderInfo` snapshot for every contributing provider, in order."""
56
+ return self._providers
57
+
58
+ def unverifiable_providers(self) -> tuple:
59
+ """Provider IDs with no manifest (predating Phase 2); a fingerprint cannot include them."""
60
+ return tuple(pi.provider_id for pi in self._providers if pi.manifest is None)
61
+
62
+ def fingerprint(self) -> "str | None":
63
+ """SHA-256 hex of the providers' manifests (sorted by provider_id), or None if any is absent.
64
+
65
+ None means at least one provider is unverifiable (see :meth:`unverifiable_providers`);
66
+ the cross-language bridge treats that as fail-closed.
67
+ """
68
+ if any(pi.manifest is None for pi in self._providers):
69
+ return None
70
+ joined = "\n".join(pi.manifest for pi in sorted(self._providers, key=lambda pi: pi.provider_id))
71
+ return hashlib.sha256(joined.encode("utf-8")).hexdigest()
72
+
73
+ def class_for(self, type_id: str) -> type:
74
+ """Return the model class registered for a TYPE_ID."""
75
+ try:
76
+ return self._by_id[type_id]
77
+ except KeyError:
78
+ raise KeyError(f"Unknown TYPE_ID: {type_id!r}") from None
79
+
80
+ def type_id_of(self, datum) -> str:
81
+ """Return the TYPE_ID of a datum, verified against the registry.
82
+
83
+ Encoding must not emit a tagged envelope for a class that is not the registered
84
+ binding for its declared TYPE_ID, or a receiver could get a typeId no runtime can
85
+ decode. Mirrors the Java ``DatumTypeRegistry.typeIdOf`` check.
86
+ """
87
+ cls = type(datum)
88
+ type_id = self._by_class.get(cls)
89
+ if type_id is None:
90
+ raise KeyError(f"Unregistered datum type: {cls.__name__}")
91
+ return type_id
92
+
93
+
94
+ def _fail(msg: str) -> "None":
95
+ raise ValueError(f"invalid datum-type registry: {msg}")
96
+
97
+
98
+ def _validate_class(provider_id: str, b: DatumTypeBinding) -> None:
99
+ cls = b.datum_class
100
+ if cls is None:
101
+ _fail(f"provider '{provider_id}' has a binding with no class")
102
+ if not isinstance(cls, type) or not issubclass(cls, pydantic.BaseModel):
103
+ _fail(f"provider '{provider_id}' type '{b.type_id}': class {cls!r} is not a pydantic BaseModel "
104
+ "(the codec calls model_validate_json / model_dump_json)")
105
+ for prop in ("datum_key", "datum_time"):
106
+ if not hasattr(cls, prop):
107
+ _fail(f"provider '{provider_id}' type '{b.type_id}': class {cls.__name__} lacks '{prop}'")
108
+ cls_type_id = getattr(cls, "TYPE_ID", None)
109
+ if cls_type_id != b.type_id:
110
+ _fail(f"provider '{provider_id}': binding type_id '{b.type_id}' does not match "
111
+ f"{cls.__name__}.TYPE_ID {cls_type_id!r} (descriptor drift)")
112
+ tv = b.type_version
113
+ if type(tv) is not int or tv <= 0:
114
+ _fail(f"provider '{provider_id}' type '{b.type_id}': TYPE_VERSION must be a positive int, got {tv!r}")
115
+ cls_type_version = getattr(cls, "TYPE_VERSION", None)
116
+ if cls_type_version != tv:
117
+ _fail(f"provider '{provider_id}' type '{b.type_id}': binding type_version {tv} does not match "
118
+ f"{cls.__name__}.TYPE_VERSION {cls_type_version!r} (descriptor drift)")
119
+
120
+
121
+ def _parse_manifest(provider, bindings) -> ProviderInfo:
122
+ """Parse and validate a provider's baked manifest against its bindings (see the SPI spec)."""
123
+ pid = provider.provider_id()
124
+ m = provider.manifest()
125
+ if m is None:
126
+ return ProviderInfo(pid, provider.package_version(), None, None)
127
+ if "\n" in m or "\r" in m:
128
+ _fail(f"provider '{pid}' manifest contains a newline")
129
+ parts = m.split("|")
130
+ if len(parts) != 3 or parts[0] != _MANIFEST_FORMAT:
131
+ _fail(f"provider '{pid}' manifest is not a {_MANIFEST_FORMAT} manifest")
132
+ _, embedded_pid, body = parts
133
+ if embedded_pid != pid:
134
+ _fail(f"provider '{pid}' manifest embeds provider id '{embedded_pid}'")
135
+
136
+ entries = []
137
+ type_ids = []
138
+ if body:
139
+ for entry in body.split(";"):
140
+ fields = entry.split(":")
141
+ if len(fields) != 3:
142
+ _fail(f"provider '{pid}' manifest has a malformed entry '{entry}'")
143
+ tid, ver_s, fp = fields
144
+ if not _HEX64.fullmatch(fp):
145
+ _fail(f"provider '{pid}' type '{tid}': fingerprint is not 64 lowercase hex")
146
+ try:
147
+ ver = int(ver_s)
148
+ except ValueError:
149
+ _fail(f"provider '{pid}' type '{tid}': non-integer version '{ver_s}'")
150
+ entries.append((tid, ver, fp))
151
+ type_ids.append(tid)
152
+ if type_ids != sorted(type_ids):
153
+ _fail(f"provider '{pid}' manifest entries are not sorted by TYPE_ID")
154
+ if len(set(type_ids)) != len(type_ids):
155
+ _fail(f"provider '{pid}' manifest has duplicate entries")
156
+
157
+ binding_ver = {b.type_id: b.type_version for b in bindings}
158
+ entry_ver = {tid: ver for (tid, ver, _) in entries}
159
+ if set(binding_ver) != set(entry_ver):
160
+ _fail(f"provider '{pid}' manifest does not match its bindings one-to-one")
161
+ for tid, ver in entry_ver.items():
162
+ if binding_ver[tid] != ver:
163
+ _fail(f"provider '{pid}' type '{tid}': manifest version {ver} != binding version {binding_ver[tid]}")
164
+ return ProviderInfo(pid, provider.package_version(), m, tuple(entries))
165
+
166
+
167
+ def build_registry(providers) -> Registry:
168
+ """Validate ``providers`` and build an immutable composite registry.
169
+
170
+ ``providers`` is an ordered iterable of :class:`DatumTypeProvider` (core first). The
171
+ validation rejects, with the offending provider/type named: null providers or bindings;
172
+ empty or malformed provider / type IDs; blank package versions; an SPI version mismatch;
173
+ an empty provider; a class that is not a valid pydantic datum; TYPE_VERSION that is not a
174
+ positive int; a TYPE_ID outside ``provider_id + '.'``; a duplicate class within a provider;
175
+ a duplicate TYPE_ID across providers; and a class bound to more than one TYPE_ID.
176
+ """
177
+ by_id: dict = {}
178
+ by_class: dict = {}
179
+ seen_provider_ids: set = set()
180
+ provider_infos: list = []
181
+
182
+ for provider in providers:
183
+ if provider is None:
184
+ _fail("a provider is None")
185
+ pid = provider.provider_id()
186
+ if not pid or not pid.strip() or not _ID_RE.fullmatch(pid):
187
+ _fail(f"provider has an empty or malformed provider_id: {pid!r}")
188
+ if pid in seen_provider_ids:
189
+ _fail(f"duplicate provider_id '{pid}'")
190
+ seen_provider_ids.add(pid)
191
+ if provider.spi_version() != SPI_VERSION:
192
+ _fail(f"provider '{pid}' targets SPI version {provider.spi_version()}, "
193
+ f"this pulse-data supports {SPI_VERSION}")
194
+ pv = provider.package_version()
195
+ if not pv or not pv.strip():
196
+ _fail(f"provider '{pid}' has a missing or blank package_version")
197
+
198
+ bindings = provider.bindings()
199
+ if bindings is None:
200
+ _fail(f"provider '{pid}' returned no bindings collection")
201
+ bindings = sorted(bindings, key=lambda b: (b.type_id or ""))
202
+ if not bindings:
203
+ _fail(f"provider '{pid}' contributes no datum types")
204
+
205
+ provider_classes: set = set()
206
+ for b in bindings:
207
+ if b is None:
208
+ _fail(f"provider '{pid}' has a None binding")
209
+ if not b.type_id or not b.type_id.strip():
210
+ _fail(f"provider '{pid}' has an empty or malformed type_id")
211
+ if not b.type_id.startswith(pid + "."):
212
+ _fail(f"provider '{pid}' type '{b.type_id}' is not under its namespace '{pid}.'")
213
+ _validate_class(pid, b)
214
+ if b.datum_class in provider_classes:
215
+ _fail(f"provider '{pid}' binds class {b.datum_class.__name__} more than once")
216
+ provider_classes.add(b.datum_class)
217
+ if b.type_id in by_id:
218
+ _fail(f"duplicate TYPE_ID '{b.type_id}' (provider '{pid}' conflicts with an earlier provider)")
219
+ if b.datum_class in by_class:
220
+ _fail(f"class {b.datum_class.__name__} is bound to more than one TYPE_ID")
221
+ by_id[b.type_id] = b.datum_class
222
+ by_class[b.datum_class] = b.type_id
223
+
224
+ provider_infos.append(_parse_manifest(provider, bindings))
225
+
226
+ return Registry(by_id, by_class, tuple(provider_infos))
227
+
228
+
229
+ def _discover_providers() -> list:
230
+ """Seed the core provider directly, then discover extension providers.
231
+
232
+ Core is instantiated directly (never via discovery), so a source-tree or mis-packaged
233
+ install cannot lose its core types and core-only parity is guaranteed. Extensions are
234
+ loaded from the entry-point group and sorted by provider_id for determinism.
235
+ """
236
+ from inventzia.pulse.data.schemas.provider import CoreDatumTypeProvider
237
+
238
+ providers = [CoreDatumTypeProvider()]
239
+ extensions = []
240
+ for ep in entry_points(group=_ENTRY_POINT_GROUP):
241
+ provider_cls = ep.load() # entry point resolves to the class, not an instance
242
+ extensions.append(provider_cls())
243
+ extensions.sort(key=lambda p: p.provider_id())
244
+ providers.extend(extensions)
245
+ return providers
246
+
247
+
248
+ _default_lock = threading.Lock()
249
+ _default_registry: "Registry | None" = None
250
+
251
+
252
+ def default_registry() -> Registry:
253
+ """The process-wide composite registry, built once (lazily) and frozen."""
254
+ global _default_registry
255
+ if _default_registry is None:
256
+ with _default_lock:
257
+ if _default_registry is None:
258
+ _default_registry = build_registry(_discover_providers())
259
+ return _default_registry
260
+
261
+
262
+ def class_for(type_id: str) -> type:
263
+ """Facade over :func:`default_registry`: TYPE_ID -> model class."""
264
+ return default_registry().class_for(type_id)
265
+
266
+
267
+ def type_id_of(datum) -> str:
268
+ """Facade over :func:`default_registry`: verified TYPE_ID of a datum."""
269
+ return default_registry().type_id_of(datum)
@@ -0,0 +1,56 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later OR LicenseRef-Inventzia-Commercial
2
+ # Copyright (c) 2013-2026 Magrino Bini, Paola Apruzzese, Inventzia Science and Technology Ltd.
3
+ #
4
+ # This file is part of pulse-data.
5
+ #
6
+ # pulse-data is dual-licensed:
7
+ # - Under the GNU Affero General Public License v3.0 or later (see LICENSE-AGPL-3.0).
8
+ # - Under a commercial license (see LICENSE-COMMERCIAL.txt).
9
+ # Contact operations@inventzia.com.
10
+ #
11
+ # THIS FILE IS GENERATED. DO NOT EDIT MANUALLY.
12
+ # Source: schemas_yaml/common/vector_value.yaml
13
+ # Regenerate: python schemas/schemas-generators/generate_python.py
14
+
15
+ from __future__ import annotations
16
+ from decimal import Decimal
17
+ from pydantic import BaseModel, ConfigDict, Field, model_validator
18
+ from typing import ClassVar, Optional
19
+
20
+
21
+ class VectorValue(BaseModel):
22
+ """
23
+ A generic timestamped vector of scalar observations, optionally labelled. A scalar value is simply the length-1 case. Suited to indicators that emit M components per timestamp (e.g. MACD -> [macd, signal, histogram]).
24
+ """
25
+
26
+ model_config = ConfigDict(extra="ignore", frozen=True)
27
+
28
+ TYPE_ID: ClassVar[str] = "com.inventzia.pulse.data.schemas.common.VectorValue"
29
+ TYPE_VERSION: ClassVar[int] = 1
30
+
31
+ key: str
32
+ """The series or observation-source identifier"""
33
+ time: int
34
+ """Epoch milliseconds of the observation"""
35
+ values: tuple[Decimal, ...]
36
+ """The M scalar observations (length 1 for a scalar value)"""
37
+
38
+ value_ids: Optional[tuple[str, ...]] = Field(None, alias="valueIds")
39
+ """Optional labels, parallel to values (positional if absent)"""
40
+
41
+ @model_validator(mode="after")
42
+ def _check_parallel_lengths(self):
43
+ if self.value_ids is not None and len(self.value_ids) != len(self.values):
44
+ raise ValueError(
45
+ f"value_ids length ({len(self.value_ids)}) must equal values length ({len(self.values)})")
46
+ return self
47
+
48
+ # -- Datum protocol ---------------------------------------------------
49
+
50
+ @property
51
+ def datum_key(self) -> str:
52
+ return self.key
53
+
54
+ @property
55
+ def datum_time(self) -> int:
56
+ return self.time
@@ -0,0 +1,70 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later OR LicenseRef-Inventzia-Commercial
2
+ # Copyright (c) 2013-2026 Magrino Bini, Paola Apruzzese, Inventzia Science and Technology Ltd.
3
+ #
4
+ # This file is part of pulse-data.
5
+ #
6
+ # pulse-data is dual-licensed:
7
+ # - Under the GNU Affero General Public License v3.0 or later (see LICENSE-AGPL-3.0).
8
+ # - Under a commercial license (see LICENSE-COMMERCIAL.txt).
9
+ # Contact operations@inventzia.com.
10
+ #
11
+ # THIS FILE IS GENERATED. DO NOT EDIT MANUALLY.
12
+ # Source: schemas_yaml/marketdata/cdf_bar.yaml
13
+ # Regenerate: python schemas/schemas-generators/generate_python.py
14
+
15
+ from __future__ import annotations
16
+ from datetime import date
17
+ from decimal import Decimal
18
+ from pydantic import AwareDatetime, BaseModel, ConfigDict, Field
19
+ from typing import ClassVar, Optional
20
+
21
+
22
+ class CdfBar(BaseModel):
23
+ """
24
+ Common data format (CDF) market data bar.
25
+ """
26
+
27
+ model_config = ConfigDict(extra="ignore", frozen=True)
28
+
29
+ TYPE_ID: ClassVar[str] = "com.inventzia.pulse.data.schemas.marketdata.CdfBar"
30
+ TYPE_VERSION: ClassVar[int] = 1
31
+
32
+ symb: str
33
+ """Instrument symbol or identifier"""
34
+ timestamp: int
35
+ """Epoch milliseconds representing the bar open time"""
36
+ op: Decimal
37
+ """Open price"""
38
+ hi: Decimal
39
+ """High price"""
40
+ lo: Decimal
41
+ """Low price"""
42
+ cl: Decimal
43
+ """Close price"""
44
+ vlm: Decimal
45
+ """Volume traded during this bar"""
46
+ datetime: AwareDatetime
47
+ """ISO 8601 datetime of the bar open time"""
48
+ date: date
49
+ """Trading date for the bar"""
50
+
51
+ vwap: Optional[Decimal] = None
52
+ """Volume-weighted average price (optional)"""
53
+ count: Optional[int] = None
54
+ """Number of trades aggregated in this bar (optional)"""
55
+ expiry: Optional[str] = None
56
+ """Option or futures expiry (optional)"""
57
+ strike: Optional[Decimal] = None
58
+ """Option strike price (optional)"""
59
+ sym_exp: Optional[str] = Field(None, alias="symExp")
60
+ """Symbol + expiry composite identifier (optional)"""
61
+
62
+ # -- Datum protocol ---------------------------------------------------
63
+
64
+ @property
65
+ def datum_key(self) -> str:
66
+ return self.symb
67
+
68
+ @property
69
+ def datum_time(self) -> int:
70
+ return self.timestamp