matview-cache 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.
@@ -0,0 +1,159 @@
1
+ """Codecs encode a Python object to bytes and back, and optionally split it into parts.
2
+
3
+ Codecs are named, and the name is recorded per recipe, so decoding is a lookup and
4
+ never an inference.
5
+
6
+ **The byte-canonical requirement.** Because fingerprints key on content digests, a
7
+ codec must produce identical bytes for identical input, on every machine, every time.
8
+ A codec that fails this defeats early cutoff silently: nothing errors, the cascade
9
+ simply never stops.
10
+
11
+ Concrete rules, from the spec:
12
+
13
+ - Parquet is disqualified out of the box. It stamps a creation timestamp and writer
14
+ version into the file, so two encodings of the same table differ. Arrow IPC with
15
+ compression off, fixed schema field ordering and no custom metadata is the safe default.
16
+ - No codec may embed a timestamp, hostname, PID, or library version.
17
+ - Compression, if ever added, must be at a pinned level with a pinned implementation,
18
+ or it silently breaks the guarantee across environments.
19
+
20
+ Serialization is never by Python object graph, only by an explicit format. That rules
21
+ out the obvious shortcut for arbitrary objects, and it is what keeps the cache readable
22
+ from other languages and safe to share.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ from typing import Final, Protocol, runtime_checkable
28
+
29
+ from matview.errors import CodecError, CodecUnavailable
30
+
31
+ __all__ = [
32
+ "CODEC_NAMES",
33
+ "BaseCodec",
34
+ "Codec",
35
+ "CodecError",
36
+ "CodecUnavailable",
37
+ "codec_is_known",
38
+ "get_codec",
39
+ "register_codec",
40
+ ]
41
+
42
+
43
+ @runtime_checkable
44
+ class Codec(Protocol):
45
+ """Encode a value to bytes and back."""
46
+
47
+ name: str
48
+
49
+ def encode(self, value: object) -> bytes | dict[str, bytes]:
50
+ """Return bytes for one unpartitioned artifact, or a dict of `part -> bytes`."""
51
+ ...
52
+
53
+ def decode(self, data: bytes) -> object:
54
+ """Decode one artifact's bytes."""
55
+ ...
56
+
57
+ def decode_parts(self, parts: dict[str, bytes]) -> object:
58
+ """Reassemble a partitioned value from every part, in sorted-label order.
59
+
60
+ Only partitioning codecs implement this. See DECISIONS.md D11.
61
+ """
62
+ ...
63
+
64
+
65
+ class BaseCodec:
66
+ """Shared behaviour. A codec that never partitions inherits the right refusal."""
67
+
68
+ name: str = ""
69
+
70
+ def encode(self, value: object) -> bytes | dict[str, bytes]: # pragma: no cover - abstract
71
+ raise NotImplementedError
72
+
73
+ def decode(self, data: bytes) -> object: # pragma: no cover - abstract
74
+ raise NotImplementedError
75
+
76
+ def decode_parts(self, parts: dict[str, bytes]) -> object:
77
+ raise CodecError(
78
+ f"codec {self.name!r} does not partition, so it cannot reassemble "
79
+ f"{len(parts)} parts. A partitioned recipe needs a partitioning codec."
80
+ )
81
+
82
+
83
+ #: Every codec name this build knows about, whether or not its extra is installed.
84
+ CODEC_NAMES: Final = frozenset(
85
+ {
86
+ "json",
87
+ "arrow",
88
+ "arrow-pandas",
89
+ "arrow-series",
90
+ "arrow-year",
91
+ "arrow-year-pandas",
92
+ "npy",
93
+ }
94
+ )
95
+
96
+ _BUILTIN_MODULES: Final = {
97
+ "json": "matview.codecs._json",
98
+ "arrow": "matview.codecs._arrow",
99
+ "arrow-series": "matview.codecs._arrow",
100
+ "arrow-pandas": "matview.codecs._arrow_pandas",
101
+ "arrow-year": "matview.codecs._arrow",
102
+ "arrow-year-pandas": "matview.codecs._arrow_pandas",
103
+ "npy": "matview.codecs._npy",
104
+ }
105
+
106
+ #: Which optional extra installs each codec. Not derivable from the name: `arrow-pandas`
107
+ #: shares the Arrow encoding but needs pandas rather than polars.
108
+ _EXTRAS: Final = {
109
+ "json": "json",
110
+ "arrow": "arrow",
111
+ "arrow-series": "arrow",
112
+ "arrow-pandas": "pandas",
113
+ "arrow-year": "arrow",
114
+ "arrow-year-pandas": "pandas",
115
+ "npy": "npy",
116
+ }
117
+
118
+ _REGISTRY: dict[str, Codec] = {}
119
+
120
+
121
+ def register_codec(codec: Codec) -> None:
122
+ """Register a codec under its own name. Re-registering the same name replaces it."""
123
+ if not codec.name:
124
+ raise CodecError("a codec must have a name")
125
+ _REGISTRY[codec.name] = codec
126
+
127
+
128
+ def get_codec(name: str) -> Codec:
129
+ """Look up a codec, importing its optional extra on first use."""
130
+ if name in _REGISTRY:
131
+ return _REGISTRY[name]
132
+
133
+ module = _BUILTIN_MODULES.get(name)
134
+ if module is None:
135
+ known = ", ".join(sorted(set(_REGISTRY) | CODEC_NAMES))
136
+ raise CodecError(f"unknown codec {name!r}; known codecs: {known}")
137
+
138
+ import importlib
139
+
140
+ try:
141
+ importlib.import_module(module)
142
+ except ImportError as exc:
143
+ extra = _EXTRAS.get(name, name)
144
+ raise CodecUnavailable(
145
+ f"codec {name!r} needs its optional extra: pip install 'matview-cache[{extra}]' ({exc})"
146
+ ) from exc
147
+
148
+ if name not in _REGISTRY: # pragma: no cover - a builtin module that failed to register
149
+ raise CodecError(f"module {module} did not register codec {name!r}")
150
+ return _REGISTRY[name]
151
+
152
+
153
+ def codec_is_known(name: str) -> bool:
154
+ """Whether a codec name resolves, without importing its extra.
155
+
156
+ Used at registration so a typo fails at import time rather than at the first
157
+ compute, while an uninstalled extra does not stop a recipe being registered.
158
+ """
159
+ return name in _REGISTRY or name in _BUILTIN_MODULES
@@ -0,0 +1,104 @@
1
+ """Arrow IPC codecs for polars.
2
+
3
+ The encoding itself lives in `_arrow_ipc`, which imports only pyarrow, so what is stored
4
+ does not depend on which dataframe library produced it. Parquet is disqualified out of
5
+ the box: it stamps a creation timestamp and writer version into the file, so two
6
+ encodings of the same table differ and every downstream fingerprint changes for no reason.
7
+
8
+ Two names share one encoding, because the spec is explicit that "the codec is a property
9
+ of the recipe, so decoding is a lookup and never an inference":
10
+
11
+ - `arrow` decodes to a DataFrame.
12
+ - `arrow-series` decodes to a Series, for a recipe whose output genuinely is one.
13
+
14
+ Both accept either on encode. Inspecting the table to guess which the caller wanted would
15
+ be exactly the inference the spec rules out. See DECISIONS.md D14.
16
+
17
+ polars is imported at module level on purpose. This module is itself imported lazily, only
18
+ when a recipe asks for one of these codecs, and that import failing is how `get_codec`
19
+ learns to raise CodecUnavailable with an install hint.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from typing import Any
25
+
26
+ import polars as pl
27
+
28
+ from matview.codecs import BaseCodec, register_codec
29
+ from matview.codecs._arrow_ipc import canonical_table, encode_table, read_table
30
+ from matview.codecs._arrow_parts import combine_years, slice_by_year
31
+ from matview.errors import CodecError
32
+
33
+ __all__ = ["ArrowCodec", "ArrowSeriesCodec", "ArrowYearCodec"]
34
+
35
+
36
+ def _to_table(value: object) -> Any:
37
+ if isinstance(value, pl.DataFrame):
38
+ frame = value
39
+ elif isinstance(value, pl.Series):
40
+ # A Series becomes a one-column table under its own name, or "" when unnamed.
41
+ frame = value.to_frame()
42
+ else:
43
+ raise CodecError(
44
+ f"the arrow codecs encode a polars DataFrame or Series, got "
45
+ f"{type(value).__module__}.{type(value).__qualname__}"
46
+ )
47
+ return canonical_table(frame.to_arrow())
48
+
49
+
50
+ class ArrowCodec(BaseCodec):
51
+ name = "arrow"
52
+
53
+ def encode(self, value: object) -> bytes | dict[str, bytes]:
54
+ return encode_table(_to_table(value))
55
+
56
+ def decode(self, data: bytes) -> object:
57
+ return pl.from_arrow(read_table(data))
58
+
59
+
60
+ class ArrowSeriesCodec(ArrowCodec):
61
+ name = "arrow-series"
62
+
63
+ def decode(self, data: bytes) -> object:
64
+ frame: Any = super().decode(data)
65
+ if len(frame.columns) != 1:
66
+ raise CodecError(
67
+ f"codec 'arrow-series' expects one column, found {len(frame.columns)}: "
68
+ f"{', '.join(frame.columns)}. A multi-column value wants codec 'arrow'."
69
+ )
70
+ return frame.to_series()
71
+
72
+
73
+ class ArrowYearCodec(ArrowCodec):
74
+ """`arrow` bytes, split into one artifact per calendar year.
75
+
76
+ Registered as `arrow-year`, partitioning on a column named `date`. For any other
77
+ column name, register your own instance:
78
+
79
+ register_codec(ArrowYearCodec(name="bars-year", column="day"))
80
+
81
+ An **empty** frame has no years, so it stores as a single unpartitioned artifact
82
+ rather than as zero parts. An empty series is a value, not a miss (rule 9), and zero
83
+ parts would store nothing at all. See DECISIONS.md D43.
84
+ """
85
+
86
+ def __init__(self, name: str = "arrow-year", column: str = "date") -> None:
87
+ self.name = name
88
+ self.column = column
89
+
90
+ def encode(self, value: object) -> bytes | dict[str, bytes]:
91
+ table = _to_table(value)
92
+ if table.num_rows == 0:
93
+ return encode_table(table)
94
+ return {
95
+ label: encode_table(part) for label, part in slice_by_year(table, self.column).items()
96
+ }
97
+
98
+ def decode_parts(self, parts: dict[str, bytes]) -> object:
99
+ return pl.from_arrow(combine_years({label: read_table(d) for label, d in parts.items()}))
100
+
101
+
102
+ register_codec(ArrowCodec())
103
+ register_codec(ArrowSeriesCodec())
104
+ register_codec(ArrowYearCodec())
@@ -0,0 +1,89 @@
1
+ """The canonical Arrow IPC encoding, shared by every Arrow-backed codec.
2
+
3
+ Library-agnostic on purpose: it imports pyarrow and nothing else, so a consumer who
4
+ works in pandas does not have to install polars to read its own cache, and the bytes are
5
+ defined here rather than by whichever dataframe library happened to produce the table.
6
+
7
+ That last point is the whole reason this module exists separately. Two things had to be
8
+ taken out of the producing library's hands:
9
+
10
+ - **The writer.** polars' own `write_ipc_stream` is not byte-stable across polars
11
+ releases (see DECISIONS.md D29), so the stream is written through pyarrow under
12
+ explicit options.
13
+ - **The offset width.** `string`/`large_string`, `binary`/`large_binary` and
14
+ `list`/`large_list` differ only in whether offsets are 32- or 64-bit. It carries no
15
+ semantic difference, and each library picks a side: polars produces `large_string`,
16
+ pandas 2 produces `string`, pandas 3 produces `large_string`. Left alone, the same
17
+ logical table gets different digests depending on its producer and on a minor version
18
+ of it. Normalizing to the large variant pins the choice to matview. See DECISIONS.md D39.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import io
24
+ from typing import Any
25
+
26
+ import pyarrow as pa
27
+
28
+ from matview.errors import CodecError
29
+
30
+ __all__ = ["canonical_table", "encode_table", "read_table", "widen", "write_options"]
31
+
32
+
33
+ def write_options() -> Any:
34
+ """Explicit and pinned, so no pyarrow default can drift into the bytes."""
35
+ return pa.ipc.IpcWriteOptions(
36
+ compression=None,
37
+ metadata_version=pa.ipc.MetadataVersion.V5,
38
+ allow_64bit=False,
39
+ use_threads=False,
40
+ )
41
+
42
+
43
+ def widen(kind: Any) -> Any:
44
+ """Normalize a type's offset width, recursively.
45
+
46
+ Only the 32/64-bit offset pairs are touched, because only they are a pure encoding
47
+ choice. Notably a dictionary type's index width is *not* normalized: it varies with
48
+ cardinality, so it is data, not an encoding detail, and forcing one width would make
49
+ a categorical column larger than it needs to be. A recipe whose output includes a
50
+ categorical should cast it explicitly if it wants a digest independent of its
51
+ producer.
52
+ """
53
+ if pa.types.is_string(kind):
54
+ return pa.large_string()
55
+ if pa.types.is_binary(kind):
56
+ return pa.large_binary()
57
+ if pa.types.is_list(kind) or pa.types.is_large_list(kind):
58
+ return pa.large_list(widen(kind.value_type))
59
+ if pa.types.is_struct(kind):
60
+ return pa.struct([pa.field(f.name, widen(f.type), f.nullable) for f in kind])
61
+ if pa.types.is_map(kind):
62
+ return pa.map_(widen(kind.key_type), widen(kind.item_type))
63
+ return kind
64
+
65
+
66
+ def canonical_table(table: Any) -> Any:
67
+ """Put a table into the one form matview stores.
68
+
69
+ No schema or field metadata — polars and pandas both record their own there, and it
70
+ would differ between producers of identical data. Offset widths normalized. One
71
+ record batch, so that how a table happened to be assembled cannot leak into the bytes.
72
+ """
73
+ fields = [pa.field(f.name, widen(f.type), f.nullable) for f in table.schema]
74
+ return table.cast(pa.schema(fields)).combine_chunks()
75
+
76
+
77
+ def encode_table(table: Any) -> bytes:
78
+ sink = io.BytesIO()
79
+ with pa.ipc.new_stream(sink, table.schema, options=write_options()) as writer:
80
+ writer.write_table(table)
81
+ return sink.getvalue()
82
+
83
+
84
+ def read_table(data: bytes) -> Any:
85
+ try:
86
+ with pa.ipc.open_stream(io.BytesIO(data)) as reader:
87
+ return reader.read_all()
88
+ except Exception as exc:
89
+ raise CodecError(f"stored bytes are not an Arrow IPC stream: {exc}") from exc
@@ -0,0 +1,76 @@
1
+ """The `arrow-pandas` codec: the same bytes as `arrow`, decoded to a pandas DataFrame.
2
+
3
+ A consumer working in pandas should not have to install polars to read its own cache, so
4
+ this module imports pandas and pyarrow and not polars. The encoding is shared, and
5
+ because offset widths are normalized in `_arrow_ipc`, a table stored here is
6
+ byte-identical to the same table stored by the `arrow` codec — verified across pandas 2.2
7
+ and 3.0, which disagree about how they map strings.
8
+
9
+ The index is deliberately not preserved. A pandas index is presentation rather than data,
10
+ and `preserve_index=True` would both add a column and write pandas metadata into the
11
+ schema, which would make the digest depend on the producing library after all.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from typing import Any
17
+
18
+ import pandas as pd
19
+ import pyarrow as pa
20
+
21
+ from matview.codecs import BaseCodec, register_codec
22
+ from matview.codecs._arrow_ipc import canonical_table, encode_table, read_table
23
+ from matview.codecs._arrow_parts import combine_years, slice_by_year
24
+ from matview.errors import CodecError
25
+
26
+ __all__ = ["ArrowPandasCodec", "ArrowYearPandasCodec"]
27
+
28
+
29
+ class ArrowPandasCodec(BaseCodec):
30
+ name = "arrow-pandas"
31
+
32
+ def _to_table(self, value: object) -> Any:
33
+ if isinstance(value, pd.Series):
34
+ # A Series becomes a one-column table under its own name, as in the polars codec.
35
+ value = value.to_frame(name=value.name if value.name is not None else "")
36
+ if not isinstance(value, pd.DataFrame):
37
+ raise CodecError(
38
+ f"the {self.name} codec encodes a pandas DataFrame or Series, got "
39
+ f"{type(value).__module__}.{type(value).__qualname__}"
40
+ )
41
+ return canonical_table(pa.Table.from_pandas(value, preserve_index=False))
42
+
43
+ def encode(self, value: object) -> bytes | dict[str, bytes]:
44
+ return encode_table(self._to_table(value))
45
+
46
+ def decode(self, data: bytes) -> object:
47
+ table: Any = read_table(data)
48
+ return table.to_pandas()
49
+
50
+
51
+ class ArrowYearPandasCodec(ArrowPandasCodec):
52
+ """`arrow-year` bytes, decoded to a pandas DataFrame.
53
+
54
+ Byte-identical to `arrow-year`, like the pair these build on, so a year-partitioned
55
+ cache is not partitioned by dataframe library either.
56
+ """
57
+
58
+ def __init__(self, name: str = "arrow-year-pandas", column: str = "date") -> None:
59
+ self.name = name
60
+ self.column = column
61
+
62
+ def encode(self, value: object) -> bytes | dict[str, bytes]:
63
+ table = self._to_table(value)
64
+ if table.num_rows == 0:
65
+ return encode_table(table)
66
+ return {
67
+ label: encode_table(part) for label, part in slice_by_year(table, self.column).items()
68
+ }
69
+
70
+ def decode_parts(self, parts: dict[str, bytes]) -> object:
71
+ table = combine_years({label: read_table(data) for label, data in parts.items()})
72
+ return table.to_pandas()
73
+
74
+
75
+ register_codec(ArrowPandasCodec())
76
+ register_codec(ArrowYearPandasCodec())
@@ -0,0 +1,99 @@
1
+ """Calendar-year slicing for the Arrow codecs. Library-agnostic: pyarrow only.
2
+
3
+ The financial case wants calendar-year partitions, and the reason is write amplification
4
+ rather than anything about SQLite. With roughly 5,000 symbols and 50 indicators, a
5
+ whole-series encoding is around 40 KB, so a daily append rewrites all of it — on the order
6
+ of 10 GB per refresh against a remote store. Yearly parts rewrite only the current year,
7
+ cutting that to roughly 500 MB. Monthly would cut it further but multiplies row count by
8
+ 240 and makes range reads stitch many pieces, which is why this is years and not months.
9
+
10
+ A closed year is then immutable, and matview needs nothing extra for that to pay off: a
11
+ recipe reading one part records a dep edge on *that part alone*, so a write to the current
12
+ year does not invalidate anything built from a closed one.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from typing import Any
18
+
19
+ import pyarrow as pa
20
+ import pyarrow.compute as pc
21
+
22
+ from matview.codecs._arrow_ipc import canonical_table
23
+ from matview.errors import CodecError
24
+
25
+ __all__ = ["combine_years", "slice_by_year", "year_labels"]
26
+
27
+ #: Zero-padded, so that sorting labels as text agrees with sorting years as numbers.
28
+ #: The core treats `part` as an opaque string and the fingerprint sorts dependencies by
29
+ #: UTF-16 code unit, under which "999" sorts after "1000". Padding removes the question.
30
+ _LABEL = "{:04d}"
31
+
32
+
33
+ def _partition_values(table: Any, column: str) -> Any:
34
+ """Validate the partitioning column and return its values, read in UTC.
35
+
36
+ Timezone-aware timestamps are read **in UTC**. This is not a detail: the same instant
37
+ is 2023 in UTC and 2024 in Asia/Tokyo, so without a fixed rule a row near a year
38
+ boundary would land in a different part depending on a column's timezone metadata, and
39
+ its digest would move with it. A `Date` column has no such ambiguity.
40
+ """
41
+ if column not in table.schema.names:
42
+ raise CodecError(
43
+ f"the year codecs partition on a column named {column!r}, which this value does "
44
+ f"not have. Columns present: {', '.join(table.schema.names) or '(none)'}. Register "
45
+ f"your own instance with column=... if it is called something else."
46
+ )
47
+
48
+ values = table[column]
49
+ kind = values.type
50
+ if pa.types.is_timestamp(kind):
51
+ if kind.tz is not None:
52
+ # Reinterpreting as naive reads the stored instant, which Arrow keeps in UTC.
53
+ values = values.cast(pa.timestamp(kind.unit))
54
+ elif not pa.types.is_date(kind):
55
+ raise CodecError(
56
+ f"column {column!r} is {kind}, which has no calendar year. The year codecs "
57
+ f"partition on a date or timestamp column."
58
+ )
59
+
60
+ if values.null_count:
61
+ raise CodecError(
62
+ f"column {column!r} has {values.null_count} null value(s), which belong to no "
63
+ f"year. Drop or fill them before storing: silently bucketing them would lose "
64
+ f"rows on the next read."
65
+ )
66
+ return values
67
+
68
+
69
+ def year_labels(table: Any, column: str) -> list[str]:
70
+ """The part labels this table would produce, without slicing it."""
71
+ years = pc.year(_partition_values(table, column))
72
+ return sorted({_LABEL.format(year) for year in years.to_pylist()})
73
+
74
+
75
+ def slice_by_year(table: Any, column: str) -> dict[str, Any]:
76
+ """Split a table into one canonical table per calendar year.
77
+
78
+ Rows are sorted by the partitioning column first, so that a frame which is merely
79
+ reordered encodes to the same bytes rather than churning every fingerprint above it.
80
+ Arrow's sort is stable, so rows sharing a date keep their input order — which makes
81
+ this fully canonical for the one-row-per-date case the design has, and no more than
82
+ that. A recipe with several rows per date and no meaningful order of its own should
83
+ sort them itself.
84
+ """
85
+ # Validated before sorting, not after: sort_by on a column that is absent or of the
86
+ # wrong type raises a raw Arrow error, and a codec should say which column it wanted
87
+ # and how to change it.
88
+ _partition_values(table, column)
89
+ ordered = table.sort_by([(column, "ascending")])
90
+ years = pc.year(_partition_values(ordered, column))
91
+ return {
92
+ _LABEL.format(year): canonical_table(ordered.filter(pc.equal(years, year)))
93
+ for year in sorted(set(years.to_pylist()))
94
+ }
95
+
96
+
97
+ def combine_years(parts: dict[str, Any]) -> Any:
98
+ """Reassemble the parts in label order, which is year order because labels are padded."""
99
+ return pa.concat_tables([parts[label] for label in sorted(parts)])
@@ -0,0 +1,36 @@
1
+ """The `json` codec: JSON Canonicalization Scheme in, plain `json.loads` out.
2
+
3
+ The spec asks this codec to sort keys and pin float formatting. JCS does both, is
4
+ already a dependency for key hashing, and is specified well enough that another
5
+ language produces the same bytes, so it is one canonicalization to get right instead
6
+ of two. See DECISIONS.md D22.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+
13
+ from matview.canon import jcs
14
+ from matview.codecs import BaseCodec, register_codec
15
+ from matview.errors import CodecError
16
+
17
+ __all__ = ["JsonCodec"]
18
+
19
+
20
+ class JsonCodec(BaseCodec):
21
+ name = "json"
22
+
23
+ def encode(self, value: object) -> bytes | dict[str, bytes]:
24
+ try:
25
+ return jcs(value)
26
+ except Exception as exc:
27
+ raise CodecError(f"value is not JSON-canonicalizable: {exc}") from exc
28
+
29
+ def decode(self, data: bytes) -> object:
30
+ try:
31
+ return json.loads(data.decode("utf-8"))
32
+ except Exception as exc:
33
+ raise CodecError(f"stored bytes are not valid JSON: {exc}") from exc
34
+
35
+
36
+ register_codec(JsonCodec())
matview/codecs/_npy.py ADDED
@@ -0,0 +1,53 @@
1
+ """The `npy` codec: NumPy's own array format.
2
+
3
+ The .npy header records dtype, shape and element order and nothing else — no timestamp,
4
+ hostname, PID or library version — so the bytes are stable across machines and releases.
5
+
6
+ Object arrays are refused rather than serialized. NumPy would fall back to serializing
7
+ the Python object graph for them, which would make the cache Python-only and unsafe to
8
+ share, and would foreclose the whole foreign-recipe design. Every call here passes
9
+ `allow_pickle=False`, on write and on read.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import io
15
+ from typing import Any
16
+
17
+ import numpy as np
18
+
19
+ from matview.codecs import BaseCodec, register_codec
20
+ from matview.errors import CodecError
21
+
22
+ # numpy is imported at module level on purpose; see the note in _arrow.py.
23
+
24
+ __all__ = ["NpyCodec"]
25
+
26
+
27
+ class NpyCodec(BaseCodec):
28
+ name = "npy"
29
+
30
+ def encode(self, value: object) -> bytes | dict[str, bytes]:
31
+ if not isinstance(value, np.ndarray):
32
+ raise CodecError(
33
+ f"the npy codec encodes a numpy ndarray, got "
34
+ f"{type(value).__module__}.{type(value).__qualname__}"
35
+ )
36
+ if value.dtype.hasobject:
37
+ raise CodecError(
38
+ "the npy codec refuses object arrays: storing one would mean serializing "
39
+ "a Python object graph, which is neither portable nor safe to share"
40
+ )
41
+ buffer = io.BytesIO()
42
+ np.save(buffer, value, allow_pickle=False)
43
+ return buffer.getvalue()
44
+
45
+ def decode(self, data: bytes) -> object:
46
+ try:
47
+ array: Any = np.load(io.BytesIO(data), allow_pickle=False)
48
+ except Exception as exc:
49
+ raise CodecError(f"stored bytes are not a valid .npy payload: {exc}") from exc
50
+ return array
51
+
52
+
53
+ register_codec(NpyCodec())