@trillboards/edge-sdk 0.2.130 → 0.2.132
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.
- package/deploy/detector/pyproject.toml +11 -1
- package/deploy/detector/stamp_version.py +238 -0
- package/deploy/detector/tests/test_server.py +17 -0
- package/deploy/detector/tests/test_version_stamp.py +282 -0
- package/deploy/detector/trillboards_detector/__init__.py +6 -1
- package/deploy/detector/trillboards_detector/_version.py +17 -0
- package/deploy/detector/trillboards_detector/cli.py +11 -1
- package/deploy/detector/trillboards_detector/server.py +10 -0
- package/deploy/macos/scripts/setup-detector.sh +66 -1
- package/deploy/systemd/setup-detector.sh +66 -1
- package/deploy/windows/setup-detector.ps1 +72 -1
- package/dist/CloudSensingManager.d.ts.map +1 -1
- package/dist/CloudSensingManager.js +28 -7
- package/dist/CloudSensingManager.js.map +1 -1
- package/dist/DurableVlmCandidateSpool.d.ts.map +1 -1
- package/dist/DurableVlmCandidateSpool.js +5 -1
- package/dist/DurableVlmCandidateSpool.js.map +1 -1
- package/dist/EdgeAgent.js +1 -1
- package/dist/config.d.ts +5 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js.map +1 -1
- package/dist/diagnose.d.ts +1 -1
- package/dist/diagnose.js +1 -1
- package/package.json +1 -1
|
@@ -1,6 +1,16 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "trillboards-detector"
|
|
3
|
-
|
|
3
|
+
# GENERATED — `stamp_version.py --write` owns this line; `--check` fails CI when
|
|
4
|
+
# it is stale. The `+src.<hex>` local segment is a fingerprint of the bytes this
|
|
5
|
+
# package ships, and it lives HERE rather than in a hatch dynamic-version hook
|
|
6
|
+
# because uv reads the version out of this file to decide whether its cached
|
|
7
|
+
# wheel still describes the directory. A dynamic version would be read only by
|
|
8
|
+
# BUILDING, which is the step a cache hit skips, so it could never invalidate
|
|
9
|
+
# its own cache. Measured on uv 0.9.2 (2026-09-04): with the version held
|
|
10
|
+
# constant, a `labels.py`-only change survives `uv tool install` AND
|
|
11
|
+
# `uv tool install --force`; move the version and a bare `uv tool install`
|
|
12
|
+
# reinstalls. See `stamp_version.py`.
|
|
13
|
+
version = "0.2.0+src.2c3db7881945"
|
|
4
14
|
description = "On-device object-detector service for the Trillboards BYO-CCTV connector (loopback HTTP, ONNX Runtime)."
|
|
5
15
|
requires-python = ">=3.10"
|
|
6
16
|
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Stamp `trillboards-detector`'s version from the bytes it ships.
|
|
3
|
+
|
|
4
|
+
WHY THIS EXISTS — A VERSION THAT DOES NOT MOVE IS A LIE THE INSTALLER BELIEVES
|
|
5
|
+
=============================================================================
|
|
6
|
+
|
|
7
|
+
`pyproject.toml` said `version = "0.2.0"` for the whole life of this package.
|
|
8
|
+
Measured on 2026-09-04 across the seven commits that touched
|
|
9
|
+
`trillboards_detector/labels.py`, the model vocabulary went from **16,129 bytes
|
|
10
|
+
to 37,321** — one model id to eight — and the version string never moved once.
|
|
11
|
+
|
|
12
|
+
That is not cosmetic, because uv decides whether to rebuild a LOCAL directory
|
|
13
|
+
from its cached wheel, and the cache is not keyed on the package sources.
|
|
14
|
+
Measured on uv 0.9.2, changing ONLY `trillboards_detector/labels.py` while
|
|
15
|
+
`pyproject.toml` stays untouched:
|
|
16
|
+
|
|
17
|
+
uv tool install <dir> -> "Audited 1 package" STALE
|
|
18
|
+
uv tool install --force <dir> -> venv rebuilt, STALE
|
|
19
|
+
uv tool install --force --refresh-package <name> -> fresh
|
|
20
|
+
(version string moved in pyproject.toml) -> fresh, with no flags
|
|
21
|
+
|
|
22
|
+
**So `--force` is not enough, and all three shipped installers used exactly
|
|
23
|
+
`--force`.** A box therefore keeps an obsolete CLASS VOCABULARY for ever: a
|
|
24
|
+
granted specialist downloads, sits on disk, and `POST /detect` answers
|
|
25
|
+
400 "requested classes not in this box's vocabulary" while `/health` says
|
|
26
|
+
`ready` — the failure written up in
|
|
27
|
+
`.claude/lessons/2026-09-02-a-granted-model-is-not-a-served-model.md`, where the
|
|
28
|
+
run measured the VLM under a specialist's label.
|
|
29
|
+
|
|
30
|
+
WHY DERIVED AND NOT HAND-BUMPED
|
|
31
|
+
===============================
|
|
32
|
+
|
|
33
|
+
A hand-bumped version is the same trap one release later: the seven commits
|
|
34
|
+
above each had an author who could have bumped it and none did, because nothing
|
|
35
|
+
asked them to and nothing checked. So the version's local segment is a
|
|
36
|
+
FINGERPRINT OF THE SHIPPED BYTES. Editing any shipped file moves it; forgetting
|
|
37
|
+
is not an available move, because CI recomputes it (`--check`).
|
|
38
|
+
|
|
39
|
+
The base version stays hand-editable in `_version.py` — it is the human release
|
|
40
|
+
line, and a release IS a judgement. What is mechanical is only the claim "these
|
|
41
|
+
are different bytes", which is not a judgement at all.
|
|
42
|
+
|
|
43
|
+
WHAT IS IN THE FINGERPRINT, AND WHY EACH PART
|
|
44
|
+
=============================================
|
|
45
|
+
|
|
46
|
+
* every `*.py` under `trillboards_detector/`, EXCEPT `_version.py` — the code
|
|
47
|
+
that is actually installed. `_version.py` is excluded because including the
|
|
48
|
+
stamp in its own input has no fixed point.
|
|
49
|
+
* `pyproject.toml` with the `version = "..."` line normalised out, for the same
|
|
50
|
+
reason, and INCLUDED at all because the pinned dependency set
|
|
51
|
+
(`onnxruntime==1.29.0`) is part of what this tool IS: a box on a different ORT
|
|
52
|
+
is a box with different kernels, which is the whole reason those pins carry
|
|
53
|
+
the docblock they carry.
|
|
54
|
+
|
|
55
|
+
Line endings are normalised to `\n` before hashing. A Windows checkout with
|
|
56
|
+
`core.autocrlf=true` would otherwise fingerprint differently from the Linux one
|
|
57
|
+
that built it, and the stamp would flap per platform rather than per change.
|
|
58
|
+
|
|
59
|
+
USAGE
|
|
60
|
+
python3 stamp_version.py --check # exit 1 on drift (CI)
|
|
61
|
+
python3 stamp_version.py --write # re-stamp after editing the package
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
from __future__ import annotations
|
|
65
|
+
|
|
66
|
+
import argparse
|
|
67
|
+
import hashlib
|
|
68
|
+
import re
|
|
69
|
+
import sys
|
|
70
|
+
from pathlib import Path
|
|
71
|
+
|
|
72
|
+
HERE = Path(__file__).resolve().parent
|
|
73
|
+
PACKAGE_DIR = HERE / "trillboards_detector"
|
|
74
|
+
PYPROJECT = HERE / "pyproject.toml"
|
|
75
|
+
VERSION_FILE = PACKAGE_DIR / "_version.py"
|
|
76
|
+
|
|
77
|
+
#: How many hex characters of the digest ride in the local version segment. 12
|
|
78
|
+
#: is git's abbreviated-sha length for the same reason: long enough that a
|
|
79
|
+
#: collision is not a thing that happens to a repository, short enough to read
|
|
80
|
+
#: off a `uv tool list` line.
|
|
81
|
+
FINGERPRINT_CHARS = 12
|
|
82
|
+
|
|
83
|
+
#: `version = "..."`, exactly as `[project]` spells it.
|
|
84
|
+
VERSION_LINE_RE = re.compile(r'^version\s*=\s*".*"\s*$', re.MULTILINE)
|
|
85
|
+
|
|
86
|
+
VERSION_FILE_TEMPLATE = '''\
|
|
87
|
+
"""GENERATED FILE — do not hand-edit.
|
|
88
|
+
|
|
89
|
+
`VERSION`'s local segment is a fingerprint of the bytes this package ships, so
|
|
90
|
+
that an installer can never mistake new code for code it already has. See
|
|
91
|
+
`stamp_version.py` for the measurement that made this necessary.
|
|
92
|
+
|
|
93
|
+
Re-stamp after editing anything under `trillboards_detector/`:
|
|
94
|
+
|
|
95
|
+
python3 trillboards-edge-sdk/packages/edge-sdk/deploy/detector/stamp_version.py --write
|
|
96
|
+
|
|
97
|
+
`BASE_VERSION` is the hand-editable release line and is the ONLY field here a
|
|
98
|
+
human sets.
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
BASE_VERSION = "{base}"
|
|
102
|
+
SOURCE_FINGERPRINT = "{fingerprint}"
|
|
103
|
+
VERSION = "{version}"
|
|
104
|
+
'''
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _normalise(raw: bytes) -> bytes:
|
|
108
|
+
"""CRLF/CR -> LF, so the stamp is a property of the change and not the checkout."""
|
|
109
|
+
return raw.replace(b"\r\n", b"\n").replace(b"\r", b"\n")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _fingerprint_inputs() -> "list[tuple[str, bytes]]":
|
|
113
|
+
"""(label, bytes) pairs, in a stable order, for everything the stamp covers."""
|
|
114
|
+
inputs: "list[tuple[str, bytes]]" = []
|
|
115
|
+
for path in sorted(PACKAGE_DIR.rglob("*.py"), key=lambda p: p.relative_to(HERE).as_posix()):
|
|
116
|
+
if path == VERSION_FILE:
|
|
117
|
+
continue
|
|
118
|
+
inputs.append((path.relative_to(HERE).as_posix(), _normalise(path.read_bytes())))
|
|
119
|
+
pyproject = _normalise(PYPROJECT.read_bytes()).decode("utf-8")
|
|
120
|
+
# The version line is normalised out: it is the OUTPUT of this function and
|
|
121
|
+
# cannot also be an input to it.
|
|
122
|
+
pyproject = VERSION_LINE_RE.sub('version = "<STAMPED>"', pyproject)
|
|
123
|
+
inputs.append(("pyproject.toml", pyproject.encode("utf-8")))
|
|
124
|
+
return inputs
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def compute_fingerprint() -> str:
|
|
128
|
+
digest = hashlib.sha256()
|
|
129
|
+
for label, blob in _fingerprint_inputs():
|
|
130
|
+
# Length-delimited so that concatenation is unambiguous: without it,
|
|
131
|
+
# moving bytes from the end of one file to the start of the next leaves
|
|
132
|
+
# the digest unchanged.
|
|
133
|
+
digest.update(f"{label}\0{len(blob)}\0".encode("utf-8"))
|
|
134
|
+
digest.update(blob)
|
|
135
|
+
return digest.hexdigest()[:FINGERPRINT_CHARS]
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def read_base_version() -> str:
|
|
139
|
+
text = VERSION_FILE.read_text(encoding="utf-8")
|
|
140
|
+
match = re.search(r'^BASE_VERSION\s*=\s*"([^"]+)"\s*$', text, re.MULTILINE)
|
|
141
|
+
if not match:
|
|
142
|
+
raise SystemExit(f"{VERSION_FILE} has no BASE_VERSION assignment")
|
|
143
|
+
return match.group(1)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def expected_version() -> "tuple[str, str, str]":
|
|
147
|
+
"""(base, fingerprint, full version) that the tree SHOULD carry right now."""
|
|
148
|
+
base = read_base_version()
|
|
149
|
+
fingerprint = compute_fingerprint()
|
|
150
|
+
return base, fingerprint, f"{base}+src.{fingerprint}"
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def read_stamped_version() -> str:
|
|
154
|
+
text = VERSION_FILE.read_text(encoding="utf-8")
|
|
155
|
+
match = re.search(r'^VERSION\s*=\s*"([^"]+)"\s*$', text, re.MULTILINE)
|
|
156
|
+
if not match:
|
|
157
|
+
raise SystemExit(f"{VERSION_FILE} has no VERSION assignment")
|
|
158
|
+
return match.group(1)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def read_pyproject_version() -> str:
|
|
162
|
+
text = PYPROJECT.read_text(encoding="utf-8")
|
|
163
|
+
match = re.search(r'^version\s*=\s*"([^"]+)"\s*$', text, re.MULTILINE)
|
|
164
|
+
if not match:
|
|
165
|
+
raise SystemExit(f"{PYPROJECT} has no [project] version assignment")
|
|
166
|
+
return match.group(1)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def write_stamp() -> "tuple[str, bool]":
|
|
170
|
+
base, fingerprint, version = expected_version()
|
|
171
|
+
changed = False
|
|
172
|
+
|
|
173
|
+
if not VERSION_FILE.exists() or read_stamped_version() != version:
|
|
174
|
+
VERSION_FILE.write_text(
|
|
175
|
+
VERSION_FILE_TEMPLATE.format(base=base, fingerprint=fingerprint, version=version),
|
|
176
|
+
encoding="utf-8",
|
|
177
|
+
)
|
|
178
|
+
changed = True
|
|
179
|
+
|
|
180
|
+
# pyproject.toml carries the SAME literal, and that is load-bearing rather
|
|
181
|
+
# than a duplicate: uv reads the version out of THIS file to decide whether
|
|
182
|
+
# its cached wheel still describes the directory. A dynamic version sourced
|
|
183
|
+
# from `_version.py` would be read only by BUILDING, which is the step a
|
|
184
|
+
# cache hit skips — so it could never invalidate its own cache.
|
|
185
|
+
pyproject_text = PYPROJECT.read_text(encoding="utf-8")
|
|
186
|
+
stamped = VERSION_LINE_RE.sub(f'version = "{version}"', pyproject_text, count=1)
|
|
187
|
+
if stamped != pyproject_text:
|
|
188
|
+
PYPROJECT.write_text(stamped, encoding="utf-8")
|
|
189
|
+
changed = True
|
|
190
|
+
|
|
191
|
+
return version, changed
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def check_stamp() -> int:
|
|
195
|
+
_, _, want = expected_version()
|
|
196
|
+
problems = []
|
|
197
|
+
have_version_file = read_stamped_version()
|
|
198
|
+
have_pyproject = read_pyproject_version()
|
|
199
|
+
if have_version_file != want:
|
|
200
|
+
problems.append(f" {VERSION_FILE.name}: VERSION is {have_version_file!r}, expected {want!r}")
|
|
201
|
+
if have_pyproject != want:
|
|
202
|
+
problems.append(f" pyproject.toml: version is {have_pyproject!r}, expected {want!r}")
|
|
203
|
+
if not problems:
|
|
204
|
+
print(f"detector-version-stamp OK — {want}")
|
|
205
|
+
return 0
|
|
206
|
+
print("detector-version-stamp DRIFT — the shipped bytes changed but the version did not.", file=sys.stderr)
|
|
207
|
+
for line in problems:
|
|
208
|
+
print(line, file=sys.stderr)
|
|
209
|
+
print(
|
|
210
|
+
"\nA version that does not move lets `uv tool install` keep an OBSOLETE class\n"
|
|
211
|
+
"vocabulary on a fielded box while /health reports `ready` and /detect answers\n"
|
|
212
|
+
"400 for the granted specialist's classes. Re-stamp:\n"
|
|
213
|
+
" python3 trillboards-edge-sdk/packages/edge-sdk/deploy/detector/stamp_version.py --write",
|
|
214
|
+
file=sys.stderr,
|
|
215
|
+
)
|
|
216
|
+
return 1
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def main(argv: "list[str] | None" = None) -> int:
|
|
220
|
+
parser = argparse.ArgumentParser(prog="stamp_version.py", description=__doc__)
|
|
221
|
+
mode = parser.add_mutually_exclusive_group(required=True)
|
|
222
|
+
mode.add_argument("--check", action="store_true", help="exit 1 if the stamp is stale")
|
|
223
|
+
mode.add_argument("--write", action="store_true", help="re-stamp _version.py and pyproject.toml")
|
|
224
|
+
mode.add_argument("--print", action="store_true", help="print the version the tree should carry")
|
|
225
|
+
args = parser.parse_args(argv)
|
|
226
|
+
|
|
227
|
+
if args.check:
|
|
228
|
+
return check_stamp()
|
|
229
|
+
if args.print:
|
|
230
|
+
print(expected_version()[2])
|
|
231
|
+
return 0
|
|
232
|
+
version, changed = write_stamp()
|
|
233
|
+
print(f"{'stamped' if changed else 'already stamped'} {version}")
|
|
234
|
+
return 0
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
if __name__ == "__main__":
|
|
238
|
+
raise SystemExit(main())
|
|
@@ -217,6 +217,23 @@ class TestHealth(ServerCase):
|
|
|
217
217
|
status, _ = self.request(f"{url}/health", method="POST", body={})
|
|
218
218
|
self.assertEqual(status, 405)
|
|
219
219
|
|
|
220
|
+
def test_health_names_the_build_that_is_answering(self):
|
|
221
|
+
"""WHICH BUILD, on the wire, on both the healthy and the unhealthy reply.
|
|
222
|
+
|
|
223
|
+
Two boxes running different vocabularies are byte-identical on every
|
|
224
|
+
other field here — `state: ready`, the pinned model, the model path — so
|
|
225
|
+
before this there was nothing an operator or a rig script could read to
|
|
226
|
+
tell a current box from one whose install silently no-opped. The 503
|
|
227
|
+
reply carries it too: a box that is stale AND has no artefact yet is
|
|
228
|
+
exactly when knowing the build matters most.
|
|
229
|
+
"""
|
|
230
|
+
from trillboards_detector._version import VERSION
|
|
231
|
+
|
|
232
|
+
_, ready = self.request(f"{self.serve(FakeLoader())}/health")
|
|
233
|
+
self.assertEqual(ready["version"], VERSION)
|
|
234
|
+
_, down = self.request(f"{self.serve(FakeLoader(state='unavailable'))}/health")
|
|
235
|
+
self.assertEqual(down["version"], VERSION)
|
|
236
|
+
|
|
220
237
|
|
|
221
238
|
class TestHealthReportsResidency(ServerCase):
|
|
222
239
|
"""`/health` must say WHICH models are resident, and still load nothing.
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
"""The tool's version must move when the bytes it ships move.
|
|
2
|
+
|
|
3
|
+
WHY THIS IS A TEST AND NOT A CONVENTION
|
|
4
|
+
=======================================
|
|
5
|
+
|
|
6
|
+
`pyproject.toml` said `version = "0.2.0"` from the day this package was created.
|
|
7
|
+
Across the seven commits that touched `trillboards_detector/labels.py` the model
|
|
8
|
+
vocabulary went 16,129 -> 37,321 bytes, one model id to eight, and the version
|
|
9
|
+
never moved once. Every one of those commits had an author who could have bumped
|
|
10
|
+
it; nothing asked, so nobody did.
|
|
11
|
+
|
|
12
|
+
That is not bookkeeping. Measured on uv 0.9.2, with the version held constant, a
|
|
13
|
+
change to a module under `trillboards_detector/` survives BOTH
|
|
14
|
+
`uv tool install` and `uv tool install --force` — uv's local-directory build
|
|
15
|
+
cache is not keyed on the package sources — and both print
|
|
16
|
+
"Installed 1 executable". A box therefore keeps an obsolete class VOCABULARY,
|
|
17
|
+
a granted specialist answers 400, and `/health` says `ready` throughout
|
|
18
|
+
(`.claude/lessons/2026-09-02-a-granted-model-is-not-a-served-model.md`).
|
|
19
|
+
|
|
20
|
+
So the version's local segment is DERIVED from the shipped bytes, and this file
|
|
21
|
+
is what makes forgetting impossible.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import importlib.util
|
|
27
|
+
import re
|
|
28
|
+
import shutil
|
|
29
|
+
import subprocess
|
|
30
|
+
import sys
|
|
31
|
+
import tempfile
|
|
32
|
+
import unittest
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
|
|
35
|
+
DETECTOR_DIR = Path(__file__).resolve().parents[1]
|
|
36
|
+
STAMPER = DETECTOR_DIR / "stamp_version.py"
|
|
37
|
+
PYPROJECT = DETECTOR_DIR / "pyproject.toml"
|
|
38
|
+
PACKAGE_DIR = DETECTOR_DIR / "trillboards_detector"
|
|
39
|
+
|
|
40
|
+
sys.path.insert(0, str(DETECTOR_DIR))
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _load_stamper(root: Path):
|
|
44
|
+
"""Import `stamp_version` bound to `root` rather than to the real tree.
|
|
45
|
+
|
|
46
|
+
Loaded as a FRESH module object per root so the mutation test below cannot
|
|
47
|
+
be answered by a cached module still pointing at the committed sources —
|
|
48
|
+
which is the same class of staleness this whole file exists to catch, and it
|
|
49
|
+
would make the non-vacuity check vacuous.
|
|
50
|
+
"""
|
|
51
|
+
spec = importlib.util.spec_from_file_location(f"stamp_version_{abs(hash(str(root)))}", root / "stamp_version.py")
|
|
52
|
+
module = importlib.util.module_from_spec(spec)
|
|
53
|
+
assert spec.loader is not None
|
|
54
|
+
spec.loader.exec_module(module)
|
|
55
|
+
return module
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class TestVersionStamp(unittest.TestCase):
|
|
59
|
+
def test_the_committed_stamp_matches_the_committed_bytes(self) -> None:
|
|
60
|
+
"""The check CI runs. Fails loudly with the one command that fixes it."""
|
|
61
|
+
result = subprocess.run(
|
|
62
|
+
[sys.executable, str(STAMPER), "--check"],
|
|
63
|
+
capture_output=True,
|
|
64
|
+
text=True,
|
|
65
|
+
)
|
|
66
|
+
self.assertEqual(
|
|
67
|
+
result.returncode,
|
|
68
|
+
0,
|
|
69
|
+
"the detector's version stamp is stale:\n"
|
|
70
|
+
f"{result.stdout}{result.stderr}",
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
def test_pyproject_and_the_module_carry_the_same_version(self) -> None:
|
|
74
|
+
"""Two writers, and they must never disagree.
|
|
75
|
+
|
|
76
|
+
`pyproject.toml` carries the literal because uv reads the version FROM
|
|
77
|
+
THIS FILE to decide whether its cached wheel still describes the
|
|
78
|
+
directory — a hatch dynamic version would be read only by BUILDING,
|
|
79
|
+
which is the step a cache hit skips, so it could never invalidate its own
|
|
80
|
+
cache. `_version.py` carries it because the running service has to be
|
|
81
|
+
able to say what it is without importing package metadata. Both are
|
|
82
|
+
written by one generator; this asserts the generator is the only writer.
|
|
83
|
+
"""
|
|
84
|
+
stamper = _load_stamper(DETECTOR_DIR)
|
|
85
|
+
self.assertEqual(stamper.read_pyproject_version(), stamper.read_stamped_version())
|
|
86
|
+
|
|
87
|
+
def test_the_package_exports_the_stamped_version(self) -> None:
|
|
88
|
+
import trillboards_detector
|
|
89
|
+
from trillboards_detector import _version
|
|
90
|
+
|
|
91
|
+
self.assertEqual(trillboards_detector.__version__, _version.VERSION)
|
|
92
|
+
self.assertEqual(_version.VERSION, f"{_version.BASE_VERSION}+src.{_version.SOURCE_FINGERPRINT}")
|
|
93
|
+
|
|
94
|
+
def test_the_version_carries_a_source_fingerprint(self) -> None:
|
|
95
|
+
"""`0.2.0` and `0.2.0` were two different builds. `+src.<hex>` cannot be."""
|
|
96
|
+
from trillboards_detector import _version
|
|
97
|
+
|
|
98
|
+
self.assertRegex(_version.VERSION, r"^\d+\.\d+\.\d+\+src\.[0-9a-f]{12}$")
|
|
99
|
+
|
|
100
|
+
def test_editing_a_shipped_module_moves_the_fingerprint(self) -> None:
|
|
101
|
+
"""NON-VACUITY. A check that cannot go red is not a check.
|
|
102
|
+
|
|
103
|
+
Copies the package to a temp tree, appends one line to the file whose
|
|
104
|
+
growth started all this, and asserts the derived version changes. This is
|
|
105
|
+
the property the whole design rests on, asserted rather than assumed.
|
|
106
|
+
"""
|
|
107
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
108
|
+
root = Path(tmp) / "detector"
|
|
109
|
+
shutil.copytree(
|
|
110
|
+
DETECTOR_DIR,
|
|
111
|
+
root,
|
|
112
|
+
ignore=shutil.ignore_patterns("__pycache__", "bench", "tests", "*.pyc"),
|
|
113
|
+
)
|
|
114
|
+
before = _load_stamper(root).expected_version()[2]
|
|
115
|
+
|
|
116
|
+
labels = root / "trillboards_detector" / "labels.py"
|
|
117
|
+
labels.write_text(labels.read_text() + "\n# one more model id\n", encoding="utf-8")
|
|
118
|
+
after = _load_stamper(root).expected_version()[2]
|
|
119
|
+
|
|
120
|
+
self.assertNotEqual(before, after, "a shipped-module edit left the version unchanged")
|
|
121
|
+
|
|
122
|
+
def test_the_fingerprint_ignores_line_endings(self) -> None:
|
|
123
|
+
"""A Windows checkout must not stamp differently from the Linux one.
|
|
124
|
+
|
|
125
|
+
`core.autocrlf=true` rewrites every checked-out `.py`. Without
|
|
126
|
+
normalisation the stamp would flap per PLATFORM rather than per change,
|
|
127
|
+
and a CI check on one OS would fail a tree that is correct on the other.
|
|
128
|
+
"""
|
|
129
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
130
|
+
root = Path(tmp) / "detector"
|
|
131
|
+
shutil.copytree(
|
|
132
|
+
DETECTOR_DIR,
|
|
133
|
+
root,
|
|
134
|
+
ignore=shutil.ignore_patterns("__pycache__", "bench", "tests", "*.pyc"),
|
|
135
|
+
)
|
|
136
|
+
before = _load_stamper(root).expected_version()[2]
|
|
137
|
+
for path in list((root / "trillboards_detector").rglob("*.py")) + [root / "pyproject.toml"]:
|
|
138
|
+
path.write_bytes(path.read_bytes().replace(b"\n", b"\r\n"))
|
|
139
|
+
after = _load_stamper(root).expected_version()[2]
|
|
140
|
+
self.assertEqual(before, after, "CRLF alone moved the fingerprint")
|
|
141
|
+
|
|
142
|
+
def test_the_stamper_is_idempotent(self) -> None:
|
|
143
|
+
"""`--write` on a stamped tree changes nothing, so it is safe in a loop."""
|
|
144
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
145
|
+
root = Path(tmp) / "detector"
|
|
146
|
+
shutil.copytree(
|
|
147
|
+
DETECTOR_DIR,
|
|
148
|
+
root,
|
|
149
|
+
ignore=shutil.ignore_patterns("__pycache__", "bench", "tests", "*.pyc"),
|
|
150
|
+
)
|
|
151
|
+
stamper = _load_stamper(root)
|
|
152
|
+
first_version, first_changed = stamper.write_stamp()
|
|
153
|
+
second_version, second_changed = stamper.write_stamp()
|
|
154
|
+
self.assertFalse(first_changed, "the committed tree was not already stamped")
|
|
155
|
+
self.assertFalse(second_changed)
|
|
156
|
+
self.assertEqual(first_version, second_version)
|
|
157
|
+
|
|
158
|
+
def test_the_version_line_is_excluded_from_its_own_input(self) -> None:
|
|
159
|
+
"""Otherwise the stamp has no fixed point and `--write` never converges."""
|
|
160
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
161
|
+
root = Path(tmp) / "detector"
|
|
162
|
+
shutil.copytree(
|
|
163
|
+
DETECTOR_DIR,
|
|
164
|
+
root,
|
|
165
|
+
ignore=shutil.ignore_patterns("__pycache__", "bench", "tests", "*.pyc"),
|
|
166
|
+
)
|
|
167
|
+
stamper = _load_stamper(root)
|
|
168
|
+
before = stamper.compute_fingerprint()
|
|
169
|
+
pyproject = root / "pyproject.toml"
|
|
170
|
+
pyproject.write_text(
|
|
171
|
+
re.sub(
|
|
172
|
+
r'^version\s*=\s*".*"\s*$',
|
|
173
|
+
'version = "9.9.9+src.deadbeefcafe"',
|
|
174
|
+
pyproject.read_text(encoding="utf-8"),
|
|
175
|
+
count=1,
|
|
176
|
+
flags=re.MULTILINE,
|
|
177
|
+
),
|
|
178
|
+
encoding="utf-8",
|
|
179
|
+
)
|
|
180
|
+
self.assertEqual(before, stamper.compute_fingerprint())
|
|
181
|
+
|
|
182
|
+
def test_a_dependency_pin_change_moves_the_fingerprint(self) -> None:
|
|
183
|
+
"""The pinned runtime is part of what this tool IS.
|
|
184
|
+
|
|
185
|
+
`docs/runbooks/cctv-detector-tier.md` §2 picks this detector on a
|
|
186
|
+
measured ~63 ms and says ORT's kernel fusion, thread heuristics and SIMD
|
|
187
|
+
dispatch move between minor releases — so a box on a different
|
|
188
|
+
onnxruntime is a box the measurement does not describe, and an installer
|
|
189
|
+
must not mistake it for one it already has.
|
|
190
|
+
"""
|
|
191
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
192
|
+
root = Path(tmp) / "detector"
|
|
193
|
+
shutil.copytree(
|
|
194
|
+
DETECTOR_DIR,
|
|
195
|
+
root,
|
|
196
|
+
ignore=shutil.ignore_patterns("__pycache__", "bench", "tests", "*.pyc"),
|
|
197
|
+
)
|
|
198
|
+
before = _load_stamper(root).expected_version()[2]
|
|
199
|
+
pyproject = root / "pyproject.toml"
|
|
200
|
+
pyproject.write_text(
|
|
201
|
+
pyproject.read_text(encoding="utf-8").replace(
|
|
202
|
+
'"onnxruntime==1.29.0"', '"onnxruntime==1.30.0"'
|
|
203
|
+
),
|
|
204
|
+
encoding="utf-8",
|
|
205
|
+
)
|
|
206
|
+
self.assertNotEqual(before, _load_stamper(root).expected_version()[2])
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
class TestInstallersCannotAcceptAStaleInstall(unittest.TestCase):
|
|
210
|
+
"""`--force` is not enough, and all three installers used exactly `--force`.
|
|
211
|
+
|
|
212
|
+
Measured on uv 0.9.2 (2026-09-04), local-directory source, `labels.py`-only
|
|
213
|
+
change with `pyproject.toml` untouched:
|
|
214
|
+
|
|
215
|
+
uv tool install <dir> STALE ("Audited 1 package")
|
|
216
|
+
uv tool install --force <dir> STALE (cached wheel)
|
|
217
|
+
uv tool install --force --refresh-package <name> fresh
|
|
218
|
+
|
|
219
|
+
So the flag is load-bearing, and so is asking the installed binary what it
|
|
220
|
+
is afterwards. Asserted on the shipped text of all three provisioners
|
|
221
|
+
because there is no runner on which all three can be executed.
|
|
222
|
+
"""
|
|
223
|
+
|
|
224
|
+
INSTALLERS = (
|
|
225
|
+
DETECTOR_DIR.parent / "macos" / "scripts" / "setup-detector.sh",
|
|
226
|
+
DETECTOR_DIR.parent / "systemd" / "setup-detector.sh",
|
|
227
|
+
DETECTOR_DIR.parent / "windows" / "setup-detector.ps1",
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
@staticmethod
|
|
231
|
+
def _install_invocations(text: str) -> "list[str]":
|
|
232
|
+
"""Lines that actually RUN the install, not lines that mention it.
|
|
233
|
+
|
|
234
|
+
Keyed on `--managed-python`, which every real invocation carries and no
|
|
235
|
+
prose line does. A looser rule (any line containing "tool install")
|
|
236
|
+
matches a log message and a PowerShell block comment, and a rule that
|
|
237
|
+
matched nothing would pass vacuously — hence the count assertion at the
|
|
238
|
+
call site.
|
|
239
|
+
"""
|
|
240
|
+
return [line for line in text.splitlines() if "tool install" in line and "--managed-python" in line]
|
|
241
|
+
|
|
242
|
+
def test_every_installer_busts_the_package_cache(self) -> None:
|
|
243
|
+
for path in self.INSTALLERS:
|
|
244
|
+
with self.subTest(installer=path.name + f" ({path.parent.name})"):
|
|
245
|
+
text = path.read_text(encoding="utf-8")
|
|
246
|
+
install_lines = self._install_invocations(text)
|
|
247
|
+
# TWO: the ordinary install, and the uninstall-and-retry repair
|
|
248
|
+
# rung. A file that grew a third would be installing somewhere
|
|
249
|
+
# this test never looked.
|
|
250
|
+
self.assertEqual(
|
|
251
|
+
len(install_lines),
|
|
252
|
+
2,
|
|
253
|
+
f"{path} runs {len(install_lines)} `uv tool install` invocations, expected 2",
|
|
254
|
+
)
|
|
255
|
+
for line in install_lines:
|
|
256
|
+
self.assertIn(
|
|
257
|
+
"--refresh-package trillboards-detector",
|
|
258
|
+
line,
|
|
259
|
+
f"{path} installs without busting uv's cached wheel: {line.strip()}",
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
def test_every_installer_verifies_what_it_installed(self) -> None:
|
|
263
|
+
for path in self.INSTALLERS:
|
|
264
|
+
with self.subTest(installer=path.name + f" ({path.parent.name})"):
|
|
265
|
+
text = path.read_text(encoding="utf-8")
|
|
266
|
+
self.assertIn("--version", text, f"{path} never asks the binary what it is")
|
|
267
|
+
self.assertIn(
|
|
268
|
+
"is STALE after a full reinstall",
|
|
269
|
+
text,
|
|
270
|
+
f"{path} has no loud outcome for an install that did not take",
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
def test_no_installer_uses_the_blunt_refresh(self) -> None:
|
|
274
|
+
"""`--refresh` re-downloads the pinned ~250 MB onnxruntime wheel every run."""
|
|
275
|
+
for path in self.INSTALLERS:
|
|
276
|
+
with self.subTest(installer=path.name + f" ({path.parent.name})"):
|
|
277
|
+
for line in self._install_invocations(path.read_text(encoding="utf-8")):
|
|
278
|
+
self.assertNotRegex(line, r"--refresh(?!-package)\b")
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
if __name__ == "__main__":
|
|
282
|
+
unittest.main()
|
|
@@ -5,4 +5,9 @@ Its own uv tool, its own venv, its own OS-supervised loopback service on
|
|
|
5
5
|
`docs/runbooks/cctv-detector-tier.md` for what it runs and why that model.
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
|
-
__version__
|
|
8
|
+
from ._version import VERSION as __version__ # noqa: F401 (re-exported)
|
|
9
|
+
|
|
10
|
+
# THE VERSION IS DERIVED FROM THE BYTES, NOT TYPED. Its local segment is a
|
|
11
|
+
# fingerprint of everything this package ships, because a version that does not
|
|
12
|
+
# move lets `uv tool install` keep an obsolete class VOCABULARY on a fielded box
|
|
13
|
+
# while `/health` still says `ready`. `stamp_version.py` carries the measurement.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""GENERATED FILE — do not hand-edit.
|
|
2
|
+
|
|
3
|
+
`VERSION`'s local segment is a fingerprint of the bytes this package ships, so
|
|
4
|
+
that an installer can never mistake new code for code it already has. See
|
|
5
|
+
`stamp_version.py` for the measurement that made this necessary.
|
|
6
|
+
|
|
7
|
+
Re-stamp after editing anything under `trillboards_detector/`:
|
|
8
|
+
|
|
9
|
+
python3 trillboards-edge-sdk/packages/edge-sdk/deploy/detector/stamp_version.py --write
|
|
10
|
+
|
|
11
|
+
`BASE_VERSION` is the hand-editable release line and is the ONLY field here a
|
|
12
|
+
human sets.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
BASE_VERSION = "0.2.0"
|
|
16
|
+
SOURCE_FINGERPRINT = "2c3db7881945"
|
|
17
|
+
VERSION = "0.2.0+src.2c3db7881945"
|
|
@@ -17,6 +17,7 @@ import sys
|
|
|
17
17
|
import threading
|
|
18
18
|
from pathlib import Path
|
|
19
19
|
|
|
20
|
+
from ._version import VERSION
|
|
20
21
|
from .loader import DEFAULT_POLL_INTERVAL_S, DetectorLoader
|
|
21
22
|
from .modelpath import DEFAULT_MODEL_NAME, resolve_model_path, resolve_ram_budget_mb
|
|
22
23
|
from .server import DEFAULT_HOST, DEFAULT_PORT, serve
|
|
@@ -60,6 +61,14 @@ def _configure_logging(log_file: "str | None", verbose: bool) -> None:
|
|
|
60
61
|
|
|
61
62
|
def build_parser() -> argparse.ArgumentParser:
|
|
62
63
|
p = argparse.ArgumentParser(prog="trillboards-detector")
|
|
64
|
+
# `--version` IS THE INSTALLER'S ONLY WAY TO ASK WHAT IT ACTUALLY INSTALLED,
|
|
65
|
+
# and it exists because `uv tool install` reports success on work it did not
|
|
66
|
+
# do. Measured on uv 0.9.2: changing only `labels.py` and re-running the
|
|
67
|
+
# install — even with `--force` — leaves the OLD wheel in place and prints
|
|
68
|
+
# "Installed 1 executable". The three setup-detector scripts therefore run
|
|
69
|
+
# this and compare it against the staged source's version; see
|
|
70
|
+
# `stamp_version.py` for why the version can be trusted to have moved.
|
|
71
|
+
p.add_argument("--version", action="version", version=f"trillboards-detector {VERSION}")
|
|
63
72
|
sub = p.add_subparsers(dest="command")
|
|
64
73
|
s = sub.add_parser("serve", help="run the loopback detector service")
|
|
65
74
|
s.add_argument("--host", default=DEFAULT_HOST)
|
|
@@ -132,7 +141,8 @@ def main(argv: "list[str] | None" = None) -> int:
|
|
|
132
141
|
)
|
|
133
142
|
|
|
134
143
|
LOG.info(
|
|
135
|
-
"[detector] starting on %s:%s, pinned model %s at %s, ceiling %s",
|
|
144
|
+
"[detector] %s starting on %s:%s, pinned model %s at %s, ceiling %s",
|
|
145
|
+
VERSION,
|
|
136
146
|
args.host,
|
|
137
147
|
args.port,
|
|
138
148
|
args.model_name,
|
|
@@ -32,6 +32,7 @@ import sys as _sys
|
|
|
32
32
|
import threading
|
|
33
33
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
34
34
|
|
|
35
|
+
from ._version import VERSION
|
|
35
36
|
from .errors import DetectorNotReady, UnknownClasses
|
|
36
37
|
|
|
37
38
|
#: Loopback only. litert-lm owns 9379; this is a SECOND service, not a route on
|
|
@@ -264,6 +265,15 @@ class DetectorHandler(BaseHTTPRequestHandler):
|
|
|
264
265
|
info = loader.info()
|
|
265
266
|
ready = info.get("state") == "ready"
|
|
266
267
|
info["status"] = "ok" if ready else info.get("state", "unavailable")
|
|
268
|
+
# WHICH BUILD IS ANSWERING. A box that kept an obsolete build keeps an
|
|
269
|
+
# obsolete class VOCABULARY with it, and every other field here is
|
|
270
|
+
# identical on both — `state: ready`, the pinned model, the model path —
|
|
271
|
+
# so before this there was nothing on the wire that could tell a current
|
|
272
|
+
# box from a stale one. The 2026-09-02 rig lost a run to exactly that:
|
|
273
|
+
# a granted specialist downloaded, `/health` said `ready`, and `/detect`
|
|
274
|
+
# answered 400 for its classes. Reported on the LOADER's payload rather
|
|
275
|
+
# than only in a log because the reader that needs it is a script.
|
|
276
|
+
info["version"] = VERSION
|
|
267
277
|
self._json(200 if ready else 503, info)
|
|
268
278
|
|
|
269
279
|
# ── /detect ──────────────────────────────────────────────────────────────
|