Coverage for src/loman/serialization/profile.py: 100%
46 statements
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-07 00:32 +0000
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-07 00:32 +0000
1"""Serialization profiles: how much of a saved computation is readable text.
3A profile and a container are independent choices. The profile decides how a
4*value* is encoded --- inline JSON numbers, or bytes written out of line. The
5container decides where those bytes *land* --- one text document, a zip, or a
6directory. Collapsing the two into a single setting looks tidier and immediately
7fails on real cases: a readable manifest inside a zip is genuinely useful, and
8``dumps()`` returning a string can only ever be readable-plus-single-document.
10Only one combination is impossible: the efficient profile has nowhere to put
11blobs in a single JSON document, and base64-inlining them would inflate by a
12third and force a read-all. That case raises and points at ``container="zip"``.
13"""
15from __future__ import annotations
17import fnmatch
18from typing import Any
20import attrs
22from .compression import DEFAULT_COMPRESSION
24# Below this, a separate container member --- its own entry, filename and seek
25# --- costs more than the JSON it saves, and keeping small values inline is what
26# preserves "open the manifest and read it". 8 KiB is about a 1024-element
27# float64 array.
28DEFAULT_INLINE_MAX_BYTES = 8 * 1024
31@attrs.frozen
32class SerializationProfile:
33 """How values are encoded for one save.
35 :ivar name: Identifier recorded in the manifest.
36 :ivar inline_max_bytes: Values estimated at or below this many bytes stay
37 inline. ``None`` keeps everything inline, whatever the container.
38 :ivar overrides: Selector-to-settings map, letting one save treat some nodes
39 differently. A selector is a node-key glob (``"market_data/**"``) or a
40 tag (``"tag:raw"``).
41 """
43 name: str
44 inline_max_bytes: int | None = None
45 #: ``"none"``, or a codec and optional level such as ``"zstd:1"`` or
46 #: ``"zlib:6"``. Named, never inferred: a saved file should compress the way
47 #: you asked, not the way something guessed. Whichever of the compressed and
48 #: raw payloads is smaller is what gets stored.
49 compression: str = "none"
50 dedupe: str = "identity"
51 checksums: bool = False
52 frame_encoding: str = "npy"
53 overrides: dict[str, dict[str, Any]] = attrs.field(factory=dict)
55 def wants_blob(self, nbytes: int | None) -> bool:
56 """Return whether a value of *nbytes* should be written out of line.
58 A transformer that cannot estimate its size passes ``None`` and is taken
59 at its word that the value is worth storing out of line.
60 """
61 if self.inline_max_bytes is None:
62 return False
63 if nbytes is None:
64 return True
65 return nbytes > self.inline_max_bytes
67 def settings_for(self, node: str | None, tags: frozenset[str] = frozenset()) -> dict[str, Any]:
68 """Return the override settings that apply to *node*.
70 Later matches win, so a more specific selector listed after a general one
71 takes precedence --- the order the overrides were written in.
72 """
73 settings: dict[str, Any] = {}
74 for selector, values in self.overrides.items():
75 if _selector_matches(selector, node, tags):
76 settings.update(values)
77 return settings
80def _selector_matches(selector: str, node: str | None, tags: frozenset[str]) -> bool:
81 """Return whether *selector* applies to a node with *tags*."""
82 if selector.startswith("tag:"):
83 return selector[4:] in tags
84 if node is None:
85 return False
86 # fnmatch treats "*" as matching separators too, so "a/**" and "a/*" both
87 # match nested keys. Node keys are shallow in practice; exactness here would
88 # mean a path-glob implementation for no observed benefit.
89 return fnmatch.fnmatchcase(node, selector)
92#: Everything inline: the file is JSON you can open and read end to end.
93READABLE = SerializationProfile(name="readable", inline_max_bytes=None)
95#: Large values out of line as binary blobs; small ones stay inline, so the
96#: manifest still describes every value's shape without decoding anything.
97#: Compressed with zstd at level 1, which on measured data gives around 9x on a
98#: realistic price series and rejects incompressible data at about 1 GB/s. A
99#: blob that does not shrink is stored raw, so the cost of the attempt is
100#: bounded and nothing is ever stored larger than it started.
101EFFICIENT = SerializationProfile(
102 name="efficient",
103 inline_max_bytes=DEFAULT_INLINE_MAX_BYTES,
104 compression=DEFAULT_COMPRESSION,
105)
107_BY_NAME = {p.name: p for p in (READABLE, EFFICIENT)}
110def resolve_profile(profile: str | SerializationProfile | None) -> SerializationProfile:
111 """Return a :class:`SerializationProfile` from a name, an instance or ``None``."""
112 if profile is None:
113 return EFFICIENT
114 if isinstance(profile, SerializationProfile):
115 return profile
116 try:
117 return _BY_NAME[profile]
118 except KeyError:
119 msg = f"Unknown profile {profile!r}; expected one of {sorted(_BY_NAME)} or a SerializationProfile"
120 raise ValueError(msg) from None