diff --git a/CLAUDE.md b/CLAUDE.md index e78ea3c..fa8a7f1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -3599,3 +3599,57 @@ is not up yet and a shop PC showing a stopped engine for five minutes after install looks broken. `tests/test_model_download.py` asserts them, and the rewritten fetch was checked against the real URL: 232,589 bytes, sha256 identical to the model already on disk. + +## The engine stopped updating, and said "installed" every time + +The SSL fix above was released and **did not reach the machine it was written +for**. Its log said so, one line above the tick: + +``` +behavision is already installed with the same version as the provided wheel. +Use --force-reinstall to force an installation of the wheel. + [ok] Engine and dependencies installed +``` + +and the traceback that followed still pointed at `model_assets.py", line 59, +in _fetch / urllib.request.urlretrieve` — code the fix had deleted. + +Two frozen literals caused it: `version = "1.1.0"` in `pyproject.toml` and +`__version__ = "1.0.0"` in `behavision/__init__.py`. They disagreed with each +other and neither tracked a release, so **every release built +`behavision-1.1.0-py3-none-any.whl`** and `pip install --upgrade` on a machine +that already had 1.1.0 is a no-op. The comment beside that call even claimed +the opposite: *"`--upgrade` so re-running after a new release replaces the +engine rather than leaving the old one in place and reporting success."* + +The shape of the damage is what makes it bad. The Go binaries — the app, the +agent, the setup tool — are rebuilt every release and updated normally. So a +shop PC ran a current app supervising an engine several releases old, and +nothing anywhere said which: `/api/health` reported the model, the paths, the +cameras and the gallery, and **no version at all**. + +Three changes, and the second is the one that does not depend on the first +being remembered: + +- **One version, in the package**, read by `pyproject.toml` through + `[tool.setuptools.dynamic]`. Two literals in two files is how they came to + disagree. In a checkout it reads `0.0.0+dev`, because a plausible-looking + number on a developer's `/api/health` would be worse than none. +- **`pip install --force-reinstall --no-deps `, after the ordinary + `--upgrade`.** `--upgrade` settles the dependencies; the second call + guarantees our own code is the code in the folder. `--no-deps` is what keeps + it cheap — forcing the dependencies too would re-download ~300 MB of numpy, + OpenCV and onnxruntime on every run. A rebuild at an unchanged version is the + ordinary case while developing, so this must not rely on the version moving. +- **`release.sh` stamps the tag**: `v0.5.6-demo` → `0.5.6+demo`, valid PEP 440 + (a local segment takes alphanumerics and dots, never hyphens). Into a copy of + the line, reverted in a `trap`, so the tree is never left dirty. + +`/api/health` reports `version` now. Without it there is no way to tell a shop +PC three releases behind from a current one, which is precisely how this +survived several releases. + +Reproduced end to end before fixing, against real wheels on Python 3.12: two +builds of the same version, `--upgrade` leaves the old code in place and prints +the same sentence the colleague's Mac printed, `--force-reinstall --no-deps` +replaces it. diff --git a/agent/cmd/behavision-setup/main.go b/agent/cmd/behavision-setup/main.go index 4253fc1..d34539a 100644 --- a/agent/cmd/behavision-setup/main.go +++ b/agent/cmd/behavision-setup/main.go @@ -539,7 +539,30 @@ func pipInstall(vpy, src string) error { // 'behavision.egg-info': Read-only file system". Falling back to source // copies it somewhere writable first, for the same reason. if wheels, _ := filepath.Glob(filepath.Join(src, "behavision-*.whl")); len(wheels) > 0 { - return stream(exec.Command(vpy, "-m", "pip", "install", "--upgrade", wheels[0]), + // TWO calls, and the second is the one that matters. + // + // `--upgrade` alone is not an upgrade when the version has not moved: + // pip skips the wheel and says so, one line above this program + // printing "[ok] Engine and dependencies installed". The wheel version + // was a frozen literal for several releases, so every engine fix in + // them silently failed to reach any machine that had run setup once - + // while the Go binaries beside it, rebuilt every release, updated + // normally. Half the product current, half of it months old, and + // nothing saying which. + // + // release.sh stamps the tag into the version now, so the versions do + // differ. This does not rely on that: a rebuild at the same version is + // the ordinary case while developing, and "installed" has to mean the + // code in this folder either way. + if err := stream(exec.Command(vpy, "-m", "pip", "install", "--upgrade", wheels[0]), + "installing the engine"); err != nil { + return err + } + // --no-deps so this is our own package only: the call above has + // already settled the dependencies, and forcing those too would + // re-download ~300 MB of numpy, OpenCV and onnxruntime every run. + return stream(exec.Command(vpy, "-m", "pip", "install", + "--force-reinstall", "--no-deps", wheels[0]), "installing the engine") } tmp, err := os.MkdirTemp("", "behavision-src-") diff --git a/behavision/__init__.py b/behavision/__init__.py index 9cd9def..a1613a1 100644 --- a/behavision/__init__.py +++ b/behavision/__init__.py @@ -4,4 +4,15 @@ Pipeline: capture -> detect (YuNet) -> track (IoU) -> align + encode (ArcFace ONNX) -> match / auto-enroll (FAISS + SQLite) -> events + API. """ -__version__ = "1.0.0" +# Stamped by release.sh from the git tag, into a copy of this line, so a +# release always produces a wheel nobody has installed before. It was a frozen +# "1.0.0" here and a frozen "1.1.0" in pyproject.toml - two literals that +# disagreed with each other and tracked nothing - which is how every engine fix +# for several releases silently failed to reach a machine that had run setup +# once. pip skips a wheel whose version is already installed and says so, one +# line above setup printing "[ok] Engine and dependencies installed". +# +# In a checkout it stays obviously a checkout: "0.0.0+dev" on /api/health is +# the truth about a developer's machine, and a plausible-looking number there +# would be worse than none. +__version__ = "0.0.0+dev" diff --git a/behavision/api.py b/behavision/api.py index d547178..86f3e00 100644 --- a/behavision/api.py +++ b/behavision/api.py @@ -17,6 +17,7 @@ from fastapi.responses import HTMLResponse, Response, StreamingResponse from fastapi.security import HTTPBasic, HTTPBasicCredentials from pydantic import BaseModel, ValidationError +from . import __version__ from .config import ApiSection, CameraConfig, CameraTuning from .commission import CommissionRun from .events import Event @@ -142,7 +143,7 @@ def _reencode(jpeg: bytes, width: int, quality: int) -> "bytes | None": def create_app(engine: Engine) -> FastAPI: - app = FastAPI(title="Behavision", version="1.0.0", + app = FastAPI(title="Behavision", version=__version__, dependencies=_auth_dependencies(engine.cfg.api)) def worker_or_404(camera_id: str): @@ -172,6 +173,10 @@ def create_app(engine: Engine) -> FastAPI: # are up, and the shop recognises nobody it already knows. stranded = engine.gallery.health["stranded"] return {"status": "ok" if engine.started_at else "starting", + # Which build is actually running. Without it there was no way + # to tell a shop PC three releases behind from a current one - + # which is precisely how a silent install failure survived. + "version": __version__, "recognition_model": engine.encoder.model_name, "gallery_unreadable_embeddings": stranded, # "where is my database" must be answerable from the API: the diff --git a/pyproject.toml b/pyproject.toml index 7cc1720..5ee3058 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "behavision" -version = "1.1.0" +dynamic = ["version"] description = "Production face recognition over RTSP" requires-python = ">=3.10" dependencies = [ @@ -32,3 +32,9 @@ behavision = ["static/*"] [tool.pytest.ini_options] testpaths = ["tests"] + +# One version for the package and the wheel. Two literals in two files is how +# they came to disagree (1.0.0 here, 1.1.0 there) and how neither tracked a +# release. +[tool.setuptools.dynamic] +version = {attr = "behavision.__version__"} diff --git a/release.sh b/release.sh index 5fb1d1e..f4cfd31 100755 --- a/release.sh +++ b/release.sh @@ -50,7 +50,28 @@ step "2. Agent and setup tool" step "3. Engine source and wheel" # The wheel is built with the checkout's own interpreter; requires-python is a # statement about the SHOP PC, which setup enforces when it finds Python there. +# Stamp the tag into the wheel version. Without this every release built +# behavision-1.1.0-py3-none-any.whl, and `pip install --upgrade` on a machine +# that already had 1.1.0 is a no-op - so an engine fix reached nobody who had +# ever run setup, while the Go binaries beside it updated normally. Nothing +# reported a version either, so there was no way to tell a shop PC three +# releases behind from a current one. +# +# v0.5.6-demo -> 0.5.6+demo, which is valid PEP 440: a local segment takes +# alphanumerics and dots, never hyphens. +TMPVER=$(mktemp) +PEP440=$(printf '%s' "${TAG#v}" | sed 's/-/+/; s/[^0-9A-Za-z.+]/./g') +trap 'git checkout -- behavision/__init__.py 2>/dev/null || true; rm -f "$TMPVER"' EXIT +# A temp file rather than `sed -i`, whose argument handling differs between +# BSD and GNU - this script is run from a Mac today and that is not a reason +# to plant a portability trap in a release path. +sed "s/^__version__ = .*/__version__ = \"$PEP440\"/" behavision/__init__.py > "$TMPVER" +cat "$TMPVER" > behavision/__init__.py .venv/bin/python -m pip wheel --no-deps --ignore-requires-python -q -w "$STAGE/engine-src" . 2>&1 | grep -v "DEPRECATION\|WARNING: Ignoring" || true +git checkout -- behavision/__init__.py +rm -f "$TMPVER" +trap - EXIT +echo " engine wheel version $PEP440" ls "$STAGE"/engine-src/behavision-*.whl >/dev/null || { echo "wheel was not built" >&2; exit 1; } cp pyproject.toml requirements.txt "$STAGE/engine-src/" mkdir -p "$STAGE/engine-src/config" && cp config/default.yaml "$STAGE/engine-src/config/"