pyuncommonkit 0.1.0__tar.gz
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.
- pyuncommonkit-0.1.0/LICENSE +21 -0
- pyuncommonkit-0.1.0/PKG-INFO +200 -0
- pyuncommonkit-0.1.0/README.md +165 -0
- pyuncommonkit-0.1.0/pyproject.toml +59 -0
- pyuncommonkit-0.1.0/pyproject.toml.orig +61 -0
- pyuncommonkit-0.1.0/src/commonkit/__init__.py +19 -0
- pyuncommonkit-0.1.0/src/commonkit/cache.py +125 -0
- pyuncommonkit-0.1.0/src/commonkit/keys.py +229 -0
- pyuncommonkit-0.1.0/src/commonkit/read.py +49 -0
- pyuncommonkit-0.1.0/src/commonkit/shared.py +227 -0
- pyuncommonkit-0.1.0/src/commonkit/store.py +963 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Sean D. True
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pyuncommonkit
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Named shared-memory segments for numpy arrays: publish once, attach from any process.
|
|
5
|
+
Keywords: numpy,npy,mmap,shared-memory,cache
|
|
6
|
+
Author: Sean D. True
|
|
7
|
+
Author-email: Sean D. True <sean.true@gmail.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Intended Audience :: Science/Research
|
|
13
|
+
Classifier: Operating System :: MacOS
|
|
14
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
21
|
+
Classifier: Topic :: Scientific/Engineering
|
|
22
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
23
|
+
Classifier: Topic :: System :: Filesystems
|
|
24
|
+
Requires-Dist: numpy>=1.26
|
|
25
|
+
Requires-Dist: pytest>=8 ; extra == 'dev'
|
|
26
|
+
Requires-Dist: ruff>=0.6 ; extra == 'dev'
|
|
27
|
+
Requires-Python: >=3.11
|
|
28
|
+
Project-URL: Homepage, https://github.com/seantrue/pycommonkit
|
|
29
|
+
Project-URL: Repository, https://github.com/seantrue/pycommonkit
|
|
30
|
+
Project-URL: Issues, https://github.com/seantrue/pycommonkit/issues
|
|
31
|
+
Project-URL: Contract, https://github.com/seantrue/pycommonkit/blob/main/docs/contract.md
|
|
32
|
+
Project-URL: Rust crate, https://crates.io/crates/commonkit
|
|
33
|
+
Provides-Extra: dev
|
|
34
|
+
Description-Content-Type: text/markdown
|
|
35
|
+
|
|
36
|
+
<p align="center">
|
|
37
|
+
<img src="https://raw.githubusercontent.com/seantrue/pycommonkit/main/docs/images/commons-banner.jpg"
|
|
38
|
+
alt="Illustrated aerial view of farmland: square fields and center-pivot circles on a shared road grid"
|
|
39
|
+
width="100%">
|
|
40
|
+
</p>
|
|
41
|
+
|
|
42
|
+
# pycommonkit
|
|
43
|
+
|
|
44
|
+
Named shared-memory segments for numpy arrays. One process decodes and
|
|
45
|
+
publishes; every other process attaches the same physical pages.
|
|
46
|
+
|
|
47
|
+
Installed as **`pyuncommonkit`**, imported as **`commonkit`** (the PyPI name `commonkit` belongs
|
|
48
|
+
to an unrelated project, and PyPI refused `pycommonkit` as too similar to it). A Rust crate, `commonkit`, implementing the same on-disk contract is
|
|
49
|
+
in this repository under `rust/` (`docs/rust-crate-plan.md`): keys, segments, the store and the
|
|
50
|
+
reaper are done, and a cross-language conformance suite runs both on one tree.
|
|
51
|
+
|
|
52
|
+
The name is for Fortran's **named `COMMON` block**. `COMMON /GRID/ X, Y` in one
|
|
53
|
+
subroutine and the same name in another, compiled separately, give both the same
|
|
54
|
+
storage: the linker joins them by the name alone, and no unit owns the block. The
|
|
55
|
+
name is the whole interface. A commonkit key is the same idea: any process that
|
|
56
|
+
can spell the key reaches the same bytes, without knowing who wrote them.
|
|
57
|
+
|
|
58
|
+
Where the analogy stops is where commonkit had to be stricter:
|
|
59
|
+
|
|
60
|
+
* **Across processes, not within one program.** A `COMMON` block is shared by the
|
|
61
|
+
program units of one executable; a segment is shared by separate processes.
|
|
62
|
+
* **The layout travels with the data.** Each Fortran unit declares its own view
|
|
63
|
+
of a block, and a unit that declares it differently silently reads other
|
|
64
|
+
variables' bits. A segment carries its dtype, shape and order in its `.npy`
|
|
65
|
+
header, so every reader sees the same layout, and publishing a different array
|
|
66
|
+
under an existing key is refused loudly (`KeyCollision`).
|
|
67
|
+
* **Written once, then read-only.** Any unit may assign into a `COMMON` block at
|
|
68
|
+
any time. A segment is written once, whole, and readers map it read-only.
|
|
69
|
+
* **It may not be there.** A `COMMON` block exists for the whole run. A segment
|
|
70
|
+
exists only until the reaper needs the space, which is why every caller must be
|
|
71
|
+
able to recompute it (below).
|
|
72
|
+
|
|
73
|
+
## The contract
|
|
74
|
+
|
|
75
|
+
`docs/contract.md` (version 1) is the authority on the on-disk format: keys, layout, the `.npy`
|
|
76
|
+
segment, publishing, the write-once rules, touch, the reaper, policy and counters. Python is the
|
|
77
|
+
reference implementation and `conformance/vectors/` decides any disagreement
|
|
78
|
+
(`conformance/generate.py` regenerates the fixtures byte for byte). Both the Python package and
|
|
79
|
+
the Rust crate pass every vector.
|
|
80
|
+
|
|
81
|
+
## Status
|
|
82
|
+
|
|
83
|
+
See `docs/STATUS.md`. Nothing is published to PyPI or crates.io yet.
|
|
84
|
+
|
|
85
|
+
## What it is
|
|
86
|
+
|
|
87
|
+
* `SharedArrayStore` — publish/attach `.npy` segments under a root directory,
|
|
88
|
+
with admission gates, a TTL reaper and a byte budget.
|
|
89
|
+
* `KeyScheme` — the addressing vocabulary for one *domain*. A scheme fixes the
|
|
90
|
+
domain, a version, the legal forms and the legal variants, and turns
|
|
91
|
+
`(digest, variant, form)` into both a key and a relative path.
|
|
92
|
+
* `shared_store()` — the ONE store per host that every user shares. Its byte budget, TTL,
|
|
93
|
+
free-space floor, fsync and republish verification come only from the tree's `.policy.json`
|
|
94
|
+
(which wins), then `COMMONKIT_STORE_*` environment variables, then the defaults (macOS:
|
|
95
|
+
`~/Library/Caches/commonkit/store`, 2 GB, 900 s, 8 GB free), because the reaping process applies
|
|
96
|
+
its own policy to the whole tree; the root comes from the environment or the default. A caller
|
|
97
|
+
chooses only `mode` and the smallest array worth storing.
|
|
98
|
+
* `TieredPixelCache` — an in-process L1 memo in front of a store, so a loop
|
|
99
|
+
that looks up the same digest repeatedly pays one attach rather than one per
|
|
100
|
+
call.
|
|
101
|
+
|
|
102
|
+
## A cache, not a store of record
|
|
103
|
+
|
|
104
|
+
**A missing segment is never an error, only a slower path.** The store promises
|
|
105
|
+
that a key which is present names the right bytes. It never promises that a key
|
|
106
|
+
is present. So every caller must be able to compute the value again when
|
|
107
|
+
`attach` returns `None`, and must treat a `publish` that returns `False` as
|
|
108
|
+
normal.
|
|
109
|
+
|
|
110
|
+
A segment can be absent at any moment, for reasons the caller doesn't control:
|
|
111
|
+
|
|
112
|
+
* **Its publish was declined.** Under pressure the store refuses rather than
|
|
113
|
+
blocks: the array was smaller than worth storing or larger than allowed, the
|
|
114
|
+
volume was below its free-space floor, or the tree was over its byte budget.
|
|
115
|
+
A decline is how the store survives back-pressure, not a fault.
|
|
116
|
+
* **The reaper removed it.** Segments are evicted when they expire, least
|
|
117
|
+
recently used first to get back under the byte budget, and to restore the
|
|
118
|
+
free-space floor. The reaper doesn't know who might still want a segment, and
|
|
119
|
+
doesn't ask: no reference counts, no coordination between processes.
|
|
120
|
+
* **It was torn.** A file shorter than its header promises, after a crash with
|
|
121
|
+
`fsync` off (the default) or damage from outside the contract, is a miss.
|
|
122
|
+
* **The store is disabled.** The mode is `off`, or the root can't be created or
|
|
123
|
+
written: every attach misses and every publish declines.
|
|
124
|
+
* **The tree is gone.** On Linux it lives in `/dev/shm` and does not survive a
|
|
125
|
+
reboot; any root can be cleared; another machine has its own tree.
|
|
126
|
+
|
|
127
|
+
That one rule is what makes the rest cheap. The reaper can unlink freely,
|
|
128
|
+
publish never blocks, writes need no durability, and a process that fails
|
|
129
|
+
costs the others only time: the next reader recomputes.
|
|
130
|
+
|
|
131
|
+
**What does raise.** Exactly two things, both bugs at the call site rather than
|
|
132
|
+
conditions of the store: building a key that isn't valid for its scheme
|
|
133
|
+
(`ValueError`), and publishing a *different* array under a key that already
|
|
134
|
+
holds one (`KeyCollision`, a `ValueError` subclass), which means a key scheme or
|
|
135
|
+
a digest is broken. Everything else (an absent root, a full filesystem, a
|
|
136
|
+
reaped or torn segment, a hostile `.usage.json` or `.policy.json`) returns
|
|
137
|
+
`None` or `False` and ticks a counter. The constructor never raises: an
|
|
138
|
+
unusable root leaves the store `disabled` and every method a no-op.
|
|
139
|
+
|
|
140
|
+
**Replacing a value under the same key.** A segment is written once: publishing
|
|
141
|
+
the same array again only refreshes it, and a different array raises
|
|
142
|
+
`KeyCollision`. A caller that must replace a value whose key hasn't changed (a
|
|
143
|
+
recompute after a fix the key doesn't capture) calls `remove(key)` first, then
|
|
144
|
+
publishes. `remove` only unlinks, so a reader still holding the old segment keeps
|
|
145
|
+
its bytes; there is no in-use check.
|
|
146
|
+
|
|
147
|
+
**A value that can't be recomputed doesn't belong here alone.** Keep its
|
|
148
|
+
durable copy somewhere you own and use the store to share it fast. (A pinned
|
|
149
|
+
subtree the reaper skips is a possible later addition; it isn't built.)
|
|
150
|
+
|
|
151
|
+
**Callers never need `.usage.json`.** It is the reaper's record of how much the
|
|
152
|
+
tree holds, read only by the byte-budget gate. When it is missing or stale,
|
|
153
|
+
usage is unknown, that gate passes, and the free-space floor still applies;
|
|
154
|
+
the next complete reap writes a fresh one.
|
|
155
|
+
|
|
156
|
+
## Keys
|
|
157
|
+
|
|
158
|
+
{domain}:{version}:{form}:{digest}:{variant}
|
|
159
|
+
|
|
160
|
+
and the path it names:
|
|
161
|
+
|
|
162
|
+
{root}/{form}/{digest[:2]}/{digest}.{variant}.npy
|
|
163
|
+
|
|
164
|
+
Two-level sharding, so no directory holds a million entries. The `variant` axis
|
|
165
|
+
is whatever distinguishes two renderings of the same source: for images it is
|
|
166
|
+
orientation (`o0`, `o1`, `oa`), for audio it would be sample rate (`sr16000`).
|
|
167
|
+
Schemes are registered by domain so `parse()` and `relpath()` can dispatch on a
|
|
168
|
+
key alone, which is what lets one store hold several domains.
|
|
169
|
+
|
|
170
|
+
## Tests
|
|
171
|
+
|
|
172
|
+
uv run --extra dev pytest -q # includes the conformance vectors
|
|
173
|
+
|
|
174
|
+
The suite addresses audio and video rather than images, to show the package is
|
|
175
|
+
not pixel-specific. Two modules run end to end against real media and skip
|
|
176
|
+
themselves when their tool is not on `PATH`:
|
|
177
|
+
|
|
178
|
+
* `tests/test_sox.py` — `sox` synthesizes a sine wave and dumps it as raw
|
|
179
|
+
int16 (and float32) samples. The samples are published, attached back as a
|
|
180
|
+
read-only `np.memmap`, attached again from a separate process, and stored at
|
|
181
|
+
two sample rates as two variants (`sr16000`, `sr44100`) of one digest.
|
|
182
|
+
* `tests/test_ffmpeg.py` — `ffmpeg` renders its `testsrc` pattern to
|
|
183
|
+
`rawvideo` frames, stored as one `(T, H, W, C)` array. The same checks, with
|
|
184
|
+
the frame rate as the variant (`fr10`, `fr25`), plus a clip over
|
|
185
|
+
`max_array_bytes` being declined whole rather than truncated.
|
|
186
|
+
|
|
187
|
+
Both use their tool's built-in generators, so no media files are checked in.
|
|
188
|
+
Both also exercise per-form dtypes: `dtypes` maps a *form* to the dtype it
|
|
189
|
+
requires (`{"wav16k": "int16"}`), and a float dump is refused under the
|
|
190
|
+
integer form and accepted under the float one. A form's name is only a label —
|
|
191
|
+
the store enforces a dtype only for forms listed in `dtypes`.
|
|
192
|
+
|
|
193
|
+
## Origin
|
|
194
|
+
|
|
195
|
+
Lifted on 2026-10-27 from `seantrue/commonkit` (`main` at `4dbfc7d`, plus the contract and
|
|
196
|
+
vectors from its `rust-crate-plan` branch at `2b6bcf9` and after), with a fresh history. That
|
|
197
|
+
package was extracted from the pixel-cache layer of a private image-corpus
|
|
198
|
+
application, where it ran in production first. The measurements in the module
|
|
199
|
+
docstrings were taken there; the framework-coupled half (the settings wiring
|
|
200
|
+
and the decode entry point) deliberately stayed behind.
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="https://raw.githubusercontent.com/seantrue/pycommonkit/main/docs/images/commons-banner.jpg"
|
|
3
|
+
alt="Illustrated aerial view of farmland: square fields and center-pivot circles on a shared road grid"
|
|
4
|
+
width="100%">
|
|
5
|
+
</p>
|
|
6
|
+
|
|
7
|
+
# pycommonkit
|
|
8
|
+
|
|
9
|
+
Named shared-memory segments for numpy arrays. One process decodes and
|
|
10
|
+
publishes; every other process attaches the same physical pages.
|
|
11
|
+
|
|
12
|
+
Installed as **`pyuncommonkit`**, imported as **`commonkit`** (the PyPI name `commonkit` belongs
|
|
13
|
+
to an unrelated project, and PyPI refused `pycommonkit` as too similar to it). A Rust crate, `commonkit`, implementing the same on-disk contract is
|
|
14
|
+
in this repository under `rust/` (`docs/rust-crate-plan.md`): keys, segments, the store and the
|
|
15
|
+
reaper are done, and a cross-language conformance suite runs both on one tree.
|
|
16
|
+
|
|
17
|
+
The name is for Fortran's **named `COMMON` block**. `COMMON /GRID/ X, Y` in one
|
|
18
|
+
subroutine and the same name in another, compiled separately, give both the same
|
|
19
|
+
storage: the linker joins them by the name alone, and no unit owns the block. The
|
|
20
|
+
name is the whole interface. A commonkit key is the same idea: any process that
|
|
21
|
+
can spell the key reaches the same bytes, without knowing who wrote them.
|
|
22
|
+
|
|
23
|
+
Where the analogy stops is where commonkit had to be stricter:
|
|
24
|
+
|
|
25
|
+
* **Across processes, not within one program.** A `COMMON` block is shared by the
|
|
26
|
+
program units of one executable; a segment is shared by separate processes.
|
|
27
|
+
* **The layout travels with the data.** Each Fortran unit declares its own view
|
|
28
|
+
of a block, and a unit that declares it differently silently reads other
|
|
29
|
+
variables' bits. A segment carries its dtype, shape and order in its `.npy`
|
|
30
|
+
header, so every reader sees the same layout, and publishing a different array
|
|
31
|
+
under an existing key is refused loudly (`KeyCollision`).
|
|
32
|
+
* **Written once, then read-only.** Any unit may assign into a `COMMON` block at
|
|
33
|
+
any time. A segment is written once, whole, and readers map it read-only.
|
|
34
|
+
* **It may not be there.** A `COMMON` block exists for the whole run. A segment
|
|
35
|
+
exists only until the reaper needs the space, which is why every caller must be
|
|
36
|
+
able to recompute it (below).
|
|
37
|
+
|
|
38
|
+
## The contract
|
|
39
|
+
|
|
40
|
+
`docs/contract.md` (version 1) is the authority on the on-disk format: keys, layout, the `.npy`
|
|
41
|
+
segment, publishing, the write-once rules, touch, the reaper, policy and counters. Python is the
|
|
42
|
+
reference implementation and `conformance/vectors/` decides any disagreement
|
|
43
|
+
(`conformance/generate.py` regenerates the fixtures byte for byte). Both the Python package and
|
|
44
|
+
the Rust crate pass every vector.
|
|
45
|
+
|
|
46
|
+
## Status
|
|
47
|
+
|
|
48
|
+
See `docs/STATUS.md`. Nothing is published to PyPI or crates.io yet.
|
|
49
|
+
|
|
50
|
+
## What it is
|
|
51
|
+
|
|
52
|
+
* `SharedArrayStore` — publish/attach `.npy` segments under a root directory,
|
|
53
|
+
with admission gates, a TTL reaper and a byte budget.
|
|
54
|
+
* `KeyScheme` — the addressing vocabulary for one *domain*. A scheme fixes the
|
|
55
|
+
domain, a version, the legal forms and the legal variants, and turns
|
|
56
|
+
`(digest, variant, form)` into both a key and a relative path.
|
|
57
|
+
* `shared_store()` — the ONE store per host that every user shares. Its byte budget, TTL,
|
|
58
|
+
free-space floor, fsync and republish verification come only from the tree's `.policy.json`
|
|
59
|
+
(which wins), then `COMMONKIT_STORE_*` environment variables, then the defaults (macOS:
|
|
60
|
+
`~/Library/Caches/commonkit/store`, 2 GB, 900 s, 8 GB free), because the reaping process applies
|
|
61
|
+
its own policy to the whole tree; the root comes from the environment or the default. A caller
|
|
62
|
+
chooses only `mode` and the smallest array worth storing.
|
|
63
|
+
* `TieredPixelCache` — an in-process L1 memo in front of a store, so a loop
|
|
64
|
+
that looks up the same digest repeatedly pays one attach rather than one per
|
|
65
|
+
call.
|
|
66
|
+
|
|
67
|
+
## A cache, not a store of record
|
|
68
|
+
|
|
69
|
+
**A missing segment is never an error, only a slower path.** The store promises
|
|
70
|
+
that a key which is present names the right bytes. It never promises that a key
|
|
71
|
+
is present. So every caller must be able to compute the value again when
|
|
72
|
+
`attach` returns `None`, and must treat a `publish` that returns `False` as
|
|
73
|
+
normal.
|
|
74
|
+
|
|
75
|
+
A segment can be absent at any moment, for reasons the caller doesn't control:
|
|
76
|
+
|
|
77
|
+
* **Its publish was declined.** Under pressure the store refuses rather than
|
|
78
|
+
blocks: the array was smaller than worth storing or larger than allowed, the
|
|
79
|
+
volume was below its free-space floor, or the tree was over its byte budget.
|
|
80
|
+
A decline is how the store survives back-pressure, not a fault.
|
|
81
|
+
* **The reaper removed it.** Segments are evicted when they expire, least
|
|
82
|
+
recently used first to get back under the byte budget, and to restore the
|
|
83
|
+
free-space floor. The reaper doesn't know who might still want a segment, and
|
|
84
|
+
doesn't ask: no reference counts, no coordination between processes.
|
|
85
|
+
* **It was torn.** A file shorter than its header promises, after a crash with
|
|
86
|
+
`fsync` off (the default) or damage from outside the contract, is a miss.
|
|
87
|
+
* **The store is disabled.** The mode is `off`, or the root can't be created or
|
|
88
|
+
written: every attach misses and every publish declines.
|
|
89
|
+
* **The tree is gone.** On Linux it lives in `/dev/shm` and does not survive a
|
|
90
|
+
reboot; any root can be cleared; another machine has its own tree.
|
|
91
|
+
|
|
92
|
+
That one rule is what makes the rest cheap. The reaper can unlink freely,
|
|
93
|
+
publish never blocks, writes need no durability, and a process that fails
|
|
94
|
+
costs the others only time: the next reader recomputes.
|
|
95
|
+
|
|
96
|
+
**What does raise.** Exactly two things, both bugs at the call site rather than
|
|
97
|
+
conditions of the store: building a key that isn't valid for its scheme
|
|
98
|
+
(`ValueError`), and publishing a *different* array under a key that already
|
|
99
|
+
holds one (`KeyCollision`, a `ValueError` subclass), which means a key scheme or
|
|
100
|
+
a digest is broken. Everything else (an absent root, a full filesystem, a
|
|
101
|
+
reaped or torn segment, a hostile `.usage.json` or `.policy.json`) returns
|
|
102
|
+
`None` or `False` and ticks a counter. The constructor never raises: an
|
|
103
|
+
unusable root leaves the store `disabled` and every method a no-op.
|
|
104
|
+
|
|
105
|
+
**Replacing a value under the same key.** A segment is written once: publishing
|
|
106
|
+
the same array again only refreshes it, and a different array raises
|
|
107
|
+
`KeyCollision`. A caller that must replace a value whose key hasn't changed (a
|
|
108
|
+
recompute after a fix the key doesn't capture) calls `remove(key)` first, then
|
|
109
|
+
publishes. `remove` only unlinks, so a reader still holding the old segment keeps
|
|
110
|
+
its bytes; there is no in-use check.
|
|
111
|
+
|
|
112
|
+
**A value that can't be recomputed doesn't belong here alone.** Keep its
|
|
113
|
+
durable copy somewhere you own and use the store to share it fast. (A pinned
|
|
114
|
+
subtree the reaper skips is a possible later addition; it isn't built.)
|
|
115
|
+
|
|
116
|
+
**Callers never need `.usage.json`.** It is the reaper's record of how much the
|
|
117
|
+
tree holds, read only by the byte-budget gate. When it is missing or stale,
|
|
118
|
+
usage is unknown, that gate passes, and the free-space floor still applies;
|
|
119
|
+
the next complete reap writes a fresh one.
|
|
120
|
+
|
|
121
|
+
## Keys
|
|
122
|
+
|
|
123
|
+
{domain}:{version}:{form}:{digest}:{variant}
|
|
124
|
+
|
|
125
|
+
and the path it names:
|
|
126
|
+
|
|
127
|
+
{root}/{form}/{digest[:2]}/{digest}.{variant}.npy
|
|
128
|
+
|
|
129
|
+
Two-level sharding, so no directory holds a million entries. The `variant` axis
|
|
130
|
+
is whatever distinguishes two renderings of the same source: for images it is
|
|
131
|
+
orientation (`o0`, `o1`, `oa`), for audio it would be sample rate (`sr16000`).
|
|
132
|
+
Schemes are registered by domain so `parse()` and `relpath()` can dispatch on a
|
|
133
|
+
key alone, which is what lets one store hold several domains.
|
|
134
|
+
|
|
135
|
+
## Tests
|
|
136
|
+
|
|
137
|
+
uv run --extra dev pytest -q # includes the conformance vectors
|
|
138
|
+
|
|
139
|
+
The suite addresses audio and video rather than images, to show the package is
|
|
140
|
+
not pixel-specific. Two modules run end to end against real media and skip
|
|
141
|
+
themselves when their tool is not on `PATH`:
|
|
142
|
+
|
|
143
|
+
* `tests/test_sox.py` — `sox` synthesizes a sine wave and dumps it as raw
|
|
144
|
+
int16 (and float32) samples. The samples are published, attached back as a
|
|
145
|
+
read-only `np.memmap`, attached again from a separate process, and stored at
|
|
146
|
+
two sample rates as two variants (`sr16000`, `sr44100`) of one digest.
|
|
147
|
+
* `tests/test_ffmpeg.py` — `ffmpeg` renders its `testsrc` pattern to
|
|
148
|
+
`rawvideo` frames, stored as one `(T, H, W, C)` array. The same checks, with
|
|
149
|
+
the frame rate as the variant (`fr10`, `fr25`), plus a clip over
|
|
150
|
+
`max_array_bytes` being declined whole rather than truncated.
|
|
151
|
+
|
|
152
|
+
Both use their tool's built-in generators, so no media files are checked in.
|
|
153
|
+
Both also exercise per-form dtypes: `dtypes` maps a *form* to the dtype it
|
|
154
|
+
requires (`{"wav16k": "int16"}`), and a float dump is refused under the
|
|
155
|
+
integer form and accepted under the float one. A form's name is only a label —
|
|
156
|
+
the store enforces a dtype only for forms listed in `dtypes`.
|
|
157
|
+
|
|
158
|
+
## Origin
|
|
159
|
+
|
|
160
|
+
Lifted on 2026-10-27 from `seantrue/commonkit` (`main` at `4dbfc7d`, plus the contract and
|
|
161
|
+
vectors from its `rust-crate-plan` branch at `2b6bcf9` and after), with a fresh history. That
|
|
162
|
+
package was extracted from the pixel-cache layer of a private image-corpus
|
|
163
|
+
application, where it ran in production first. The measurements in the module
|
|
164
|
+
docstrings were taken there; the framework-coupled half (the settings wiring
|
|
165
|
+
and the decode entry point) deliberately stayed behind.
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "pyuncommonkit"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Named shared-memory segments for numpy arrays: publish once, attach from any process."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
requires-python = ">=3.11"
|
|
9
|
+
dependencies = ["numpy>=1.26"]
|
|
10
|
+
keywords = [
|
|
11
|
+
"numpy",
|
|
12
|
+
"npy",
|
|
13
|
+
"mmap",
|
|
14
|
+
"shared-memory",
|
|
15
|
+
"cache",
|
|
16
|
+
]
|
|
17
|
+
classifiers = [
|
|
18
|
+
"Development Status :: 4 - Beta",
|
|
19
|
+
"Intended Audience :: Developers",
|
|
20
|
+
"Intended Audience :: Science/Research",
|
|
21
|
+
"Operating System :: MacOS",
|
|
22
|
+
"Operating System :: POSIX :: Linux",
|
|
23
|
+
"Programming Language :: Python :: 3",
|
|
24
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
25
|
+
"Programming Language :: Python :: 3.11",
|
|
26
|
+
"Programming Language :: Python :: 3.12",
|
|
27
|
+
"Programming Language :: Python :: 3.13",
|
|
28
|
+
"Programming Language :: Python :: 3.14",
|
|
29
|
+
"Topic :: Scientific/Engineering",
|
|
30
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
31
|
+
"Topic :: System :: Filesystems",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
[[project.authors]]
|
|
35
|
+
name = "Sean D. True"
|
|
36
|
+
email = "sean.true@gmail.com"
|
|
37
|
+
|
|
38
|
+
[project.urls]
|
|
39
|
+
Homepage = "https://github.com/seantrue/pycommonkit"
|
|
40
|
+
Repository = "https://github.com/seantrue/pycommonkit"
|
|
41
|
+
Issues = "https://github.com/seantrue/pycommonkit/issues"
|
|
42
|
+
Contract = "https://github.com/seantrue/pycommonkit/blob/main/docs/contract.md"
|
|
43
|
+
"Rust crate" = "https://crates.io/crates/commonkit"
|
|
44
|
+
|
|
45
|
+
[project.optional-dependencies]
|
|
46
|
+
dev = [
|
|
47
|
+
"pytest>=8",
|
|
48
|
+
"ruff>=0.6",
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
[build-system]
|
|
52
|
+
requires = ["uv_build>=0.11.21,<0.12.0"]
|
|
53
|
+
build-backend = "uv_build"
|
|
54
|
+
|
|
55
|
+
[tool.uv.build-backend]
|
|
56
|
+
module-name = "commonkit"
|
|
57
|
+
|
|
58
|
+
[tool.ruff]
|
|
59
|
+
line-length = 100
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "pyuncommonkit"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Named shared-memory segments for numpy arrays: publish once, attach from any process."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [
|
|
7
|
+
{ name = "Sean D. True", email = "sean.true@gmail.com" }
|
|
8
|
+
]
|
|
9
|
+
license = "MIT"
|
|
10
|
+
license-files = ["LICENSE"]
|
|
11
|
+
# 3.11, NOT 3.12: the model venvs this exists to serve are python 3.11, and
|
|
12
|
+
# being importable there is the whole point of the package.
|
|
13
|
+
requires-python = ">=3.11"
|
|
14
|
+
dependencies = [
|
|
15
|
+
"numpy>=1.26",
|
|
16
|
+
]
|
|
17
|
+
keywords = ["numpy", "npy", "mmap", "shared-memory", "cache"]
|
|
18
|
+
# No License :: classifier: PEP 639's `license` expression replaces it. Linux and macOS only:
|
|
19
|
+
# Windows is supported in the contract (§12) but has no Python default root or CI yet.
|
|
20
|
+
classifiers = [
|
|
21
|
+
"Development Status :: 4 - Beta",
|
|
22
|
+
"Intended Audience :: Developers",
|
|
23
|
+
"Intended Audience :: Science/Research",
|
|
24
|
+
"Operating System :: MacOS",
|
|
25
|
+
"Operating System :: POSIX :: Linux",
|
|
26
|
+
"Programming Language :: Python :: 3",
|
|
27
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
28
|
+
"Programming Language :: Python :: 3.11",
|
|
29
|
+
"Programming Language :: Python :: 3.12",
|
|
30
|
+
"Programming Language :: Python :: 3.13",
|
|
31
|
+
"Programming Language :: Python :: 3.14",
|
|
32
|
+
"Topic :: Scientific/Engineering",
|
|
33
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
34
|
+
"Topic :: System :: Filesystems",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
[project.urls]
|
|
38
|
+
Homepage = "https://github.com/seantrue/pycommonkit"
|
|
39
|
+
Repository = "https://github.com/seantrue/pycommonkit"
|
|
40
|
+
Issues = "https://github.com/seantrue/pycommonkit/issues"
|
|
41
|
+
Contract = "https://github.com/seantrue/pycommonkit/blob/main/docs/contract.md"
|
|
42
|
+
"Rust crate" = "https://crates.io/crates/commonkit"
|
|
43
|
+
|
|
44
|
+
[project.optional-dependencies]
|
|
45
|
+
dev = [
|
|
46
|
+
"pytest>=8",
|
|
47
|
+
"ruff>=0.6",
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
[build-system]
|
|
51
|
+
requires = ["uv_build>=0.11.21,<0.12.0"]
|
|
52
|
+
build-backend = "uv_build"
|
|
53
|
+
|
|
54
|
+
[tool.uv.build-backend]
|
|
55
|
+
# Published as pyuncommonkit, imported as commonkit, so no consumer's code changes; only its
|
|
56
|
+
# dependency line does. PyPI `commonkit` belongs to an unrelated project, and PyPI refused
|
|
57
|
+
# `pycommonkit` as too similar to it (2026-10-28).
|
|
58
|
+
module-name = "commonkit"
|
|
59
|
+
|
|
60
|
+
[tool.ruff]
|
|
61
|
+
line-length = 100
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Named shared-memory segments for numpy arrays.
|
|
2
|
+
|
|
3
|
+
The name is for Fortran's ``COMMON`` block: a named region several separately
|
|
4
|
+
compiled programs agree to share. The name is the whole interface, and the
|
|
5
|
+
segment outlives whoever wrote it.
|
|
6
|
+
|
|
7
|
+
``store`` is the mechanism and carries the measured reasons behind it, ``keys``
|
|
8
|
+
the addressing, ``cache`` the in-process memo that keeps a tight loop from
|
|
9
|
+
paying an attach per call.
|
|
10
|
+
"""
|
|
11
|
+
from .cache import TieredPixelCache
|
|
12
|
+
from .keys import KeyScheme, parse, register, relpath, scheme_for
|
|
13
|
+
from .shared import shared_store
|
|
14
|
+
from .store import KeyCollision, SharedArrayStore
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"KeyCollision", "KeyScheme", "SharedArrayStore", "TieredPixelCache",
|
|
18
|
+
"parse", "register", "relpath", "scheme_for", "shared_store",
|
|
19
|
+
]
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Two-tier pixel cache: an in-process memo over the shared store.
|
|
2
|
+
|
|
3
|
+
Why two tiers and not a straight replacement, in one measurement:
|
|
4
|
+
|
|
5
|
+
The consumer's face-crop path calls its pixel accessor **twice per face**
|
|
6
|
+
-- once for the base dimensions, once for the crop. An 18-face page is
|
|
7
|
+
therefore 36 lookups. Today that is one 38 ms decode plus 35 free in-process
|
|
8
|
+
hits. Against a shared store alone it would be 36 attaches at ~3.3 ms each =
|
|
9
|
+
119 ms, **three times slower than doing nothing at all**. Attaching is cheap, but
|
|
10
|
+
it is not free, and a loop that attaches per crop is the one access pattern that
|
|
11
|
+
turns a cache into a pessimization.
|
|
12
|
+
|
|
13
|
+
So L1 memoises the ATTACHED MAPPING, not a second copy of the pixels. The first
|
|
14
|
+
lookup attaches; every later lookup in that process is a dict hit. The pixels
|
|
15
|
+
still live once in shared memory and are still shared across processes -- L1 adds
|
|
16
|
+
no bytes, it removes syscalls.
|
|
17
|
+
|
|
18
|
+
Tier order: L1 in-process memo -> L2 shared store -> decode.
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from collections import Counter
|
|
23
|
+
|
|
24
|
+
import numpy as np
|
|
25
|
+
|
|
26
|
+
from .store import KeyCollision
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class TieredPixelCache:
|
|
30
|
+
"""Satisfies the consumer's ``PixelCache`` protocol.
|
|
31
|
+
|
|
32
|
+
``l1`` is anything with ``get``/``set_array`` (the original consumer
|
|
33
|
+
passes an LRU array cache); ``l2`` is a
|
|
34
|
+
:class:`~commonkit.store.SharedArrayStore`. Either may be None,
|
|
35
|
+
which degrades to the other -- and with both None this is a no-op cache,
|
|
36
|
+
leaving its caller exactly as it would be with no cache at all.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __init__(self, l1=None, l2=None):
|
|
40
|
+
self.l1 = l1
|
|
41
|
+
self.l2 = l2
|
|
42
|
+
self.stats: Counter = Counter()
|
|
43
|
+
|
|
44
|
+
# ``_base_array`` checks for this and prefers it over the bytes path.
|
|
45
|
+
def get(self, key: str):
|
|
46
|
+
"""The PixelCache protocol: the array, or None."""
|
|
47
|
+
return self.get_with_source(key)[0]
|
|
48
|
+
|
|
49
|
+
def get_with_source(self, key: str):
|
|
50
|
+
"""``(array, source)`` -- which TIER answered, or ``(None, "decode")``.
|
|
51
|
+
|
|
52
|
+
The return channel this class used to lack, and the lack of which was a
|
|
53
|
+
real defect rather than an inelegance. ``entry.decoded`` could not ask
|
|
54
|
+
what happened, so it inferred it from counter DELTAS -- reading
|
|
55
|
+
``shm_hit`` off this object, which deliberately does not tick it because
|
|
56
|
+
the store already does. The inference silently defaulted to "decode",
|
|
57
|
+
so every cross-process attach was reported as a decode.
|
|
58
|
+
|
|
59
|
+
Answering directly deletes the inference instead of correcting it. It is
|
|
60
|
+
also the only thread-safe shape: this cache is a module-global shared
|
|
61
|
+
across a node's CONCURRENCY threads, so recording "what I did last" on
|
|
62
|
+
``self`` would be a race between them.
|
|
63
|
+
"""
|
|
64
|
+
if self.l1 is not None:
|
|
65
|
+
arr = self.l1.get(key)
|
|
66
|
+
if arr is not None:
|
|
67
|
+
self.stats["l1_hit"] += 1
|
|
68
|
+
return arr, "l1"
|
|
69
|
+
if self.l2 is not None:
|
|
70
|
+
arr = self.l2.attach(key)
|
|
71
|
+
if arr is not None:
|
|
72
|
+
# The store counts the hit (and the publish, below) itself.
|
|
73
|
+
# Counting it here too doubled both in drain_stats, which
|
|
74
|
+
# sums the tier's counters with the store's.
|
|
75
|
+
# Promote so a crop loop pays one attach, not one per crop.
|
|
76
|
+
if self.l1 is not None:
|
|
77
|
+
try:
|
|
78
|
+
self.l1.set_array(key, arr)
|
|
79
|
+
except Exception: # noqa: BLE001, S110 -- an L1 that can't take the array costs a later attach, never an error
|
|
80
|
+
pass
|
|
81
|
+
return arr, "shm"
|
|
82
|
+
self.stats["miss"] += 1
|
|
83
|
+
return None, "decode"
|
|
84
|
+
|
|
85
|
+
def set_array(self, key: str, arr) -> None:
|
|
86
|
+
"""Called by ``_base_array`` after a decode. Raises only :class:`KeyCollision`."""
|
|
87
|
+
if self.l1 is not None:
|
|
88
|
+
try:
|
|
89
|
+
self.l1.set_array(key, arr)
|
|
90
|
+
except Exception: # noqa: BLE001, S110 -- an L1 that can't take the array costs a later decode, never an error
|
|
91
|
+
pass
|
|
92
|
+
if self.l2 is not None:
|
|
93
|
+
try:
|
|
94
|
+
if not self.l2.publish(key, arr):
|
|
95
|
+
self.stats["shm_declined"] += 1
|
|
96
|
+
except KeyCollision:
|
|
97
|
+
# The one exception that must reach the caller (contract §6.4): a
|
|
98
|
+
# broken key scheme hidden here would serve two contents under one key.
|
|
99
|
+
raise
|
|
100
|
+
except Exception: # noqa: BLE001
|
|
101
|
+
self.stats["shm_publish_failed"] += 1
|
|
102
|
+
|
|
103
|
+
def set(self, key: str, value: bytes) -> None:
|
|
104
|
+
"""Bytes path, for protocol compliance. ``_base_array`` prefers
|
|
105
|
+
``set_array`` and will not normally reach this."""
|
|
106
|
+
try:
|
|
107
|
+
import io
|
|
108
|
+
arr = np.load(io.BytesIO(value), allow_pickle=False)
|
|
109
|
+
except Exception: # noqa: BLE001 -- undecodable bytes are a miss: the cache never raises
|
|
110
|
+
return
|
|
111
|
+
self.set_array(key, arr) # outside the try, so a KeyCollision gets out
|
|
112
|
+
|
|
113
|
+
def drain_stats(self) -> Counter:
|
|
114
|
+
"""Take and reset counters, including the store's own."""
|
|
115
|
+
out = Counter(self.stats)
|
|
116
|
+
self.stats.clear()
|
|
117
|
+
if self.l2 is not None:
|
|
118
|
+
try:
|
|
119
|
+
out.update(self.l2.drain_stats())
|
|
120
|
+
except Exception: # noqa: BLE001, S110 -- a store whose stats fail still lets the tier's own counters out
|
|
121
|
+
pass
|
|
122
|
+
return out
|
|
123
|
+
|
|
124
|
+
def __repr__(self) -> str:
|
|
125
|
+
return f"<TieredPixelCache l1={self.l1!r} l2={self.l2!r}>"
|