framesig 0.1.0__tar.gz → 0.1.2__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.
Files changed (34) hide show
  1. {framesig-0.1.0/src/framesig.egg-info → framesig-0.1.2}/PKG-INFO +13 -5
  2. {framesig-0.1.0 → framesig-0.1.2}/README.md +10 -2
  3. {framesig-0.1.0 → framesig-0.1.2}/pyproject.toml +6 -3
  4. {framesig-0.1.0 → framesig-0.1.2}/src/framesig/__init__.py +1 -1
  5. {framesig-0.1.0 → framesig-0.1.2}/src/framesig/cache.py +51 -8
  6. {framesig-0.1.0 → framesig-0.1.2/src/framesig.egg-info}/PKG-INFO +13 -5
  7. {framesig-0.1.0 → framesig-0.1.2}/src/framesig.egg-info/SOURCES.txt +1 -0
  8. framesig-0.1.2/src/framesig.egg-info/requires.txt +6 -0
  9. framesig-0.1.2/tests/test_cache_resilience.py +114 -0
  10. {framesig-0.1.0 → framesig-0.1.2}/tests/test_end_to_end.py +29 -0
  11. framesig-0.1.0/src/framesig.egg-info/requires.txt +0 -6
  12. {framesig-0.1.0 → framesig-0.1.2}/LICENSE +0 -0
  13. {framesig-0.1.0 → framesig-0.1.2}/setup.cfg +0 -0
  14. {framesig-0.1.0 → framesig-0.1.2}/src/framesig/_coerce.py +0 -0
  15. {framesig-0.1.0 → framesig-0.1.2}/src/framesig/cli.py +0 -0
  16. {framesig-0.1.0 → framesig-0.1.2}/src/framesig/config.py +0 -0
  17. {framesig-0.1.0 → framesig-0.1.2}/src/framesig/detectors.py +0 -0
  18. {framesig-0.1.0 → framesig-0.1.2}/src/framesig/errors.py +0 -0
  19. {framesig-0.1.0 → framesig-0.1.2}/src/framesig/events.py +0 -0
  20. {framesig-0.1.0 → framesig-0.1.2}/src/framesig/regions.py +0 -0
  21. {framesig-0.1.0 → framesig-0.1.2}/src/framesig/scanner.py +0 -0
  22. {framesig-0.1.0 → framesig-0.1.2}/src/framesig/videogen.py +0 -0
  23. {framesig-0.1.0 → framesig-0.1.2}/src/framesig/viz.py +0 -0
  24. {framesig-0.1.0 → framesig-0.1.2}/src/framesig.egg-info/dependency_links.txt +0 -0
  25. {framesig-0.1.0 → framesig-0.1.2}/src/framesig.egg-info/entry_points.txt +0 -0
  26. {framesig-0.1.0 → framesig-0.1.2}/src/framesig.egg-info/top_level.txt +0 -0
  27. {framesig-0.1.0 → framesig-0.1.2}/tests/test_cache.py +0 -0
  28. {framesig-0.1.0 → framesig-0.1.2}/tests/test_cli.py +0 -0
  29. {framesig-0.1.0 → framesig-0.1.2}/tests/test_config.py +0 -0
  30. {framesig-0.1.0 → framesig-0.1.2}/tests/test_detectors.py +0 -0
  31. {framesig-0.1.0 → framesig-0.1.2}/tests/test_events.py +0 -0
  32. {framesig-0.1.0 → framesig-0.1.2}/tests/test_regions.py +0 -0
  33. {framesig-0.1.0 → framesig-0.1.2}/tests/test_scanner.py +0 -0
  34. {framesig-0.1.0 → framesig-0.1.2}/tests/test_videogen.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: framesig
3
- Version: 0.1.0
3
+ Version: 0.1.2
4
4
  Summary: Detect on-screen events in any video by pixel signature — declarative YAML, cached scores, game-agnostic.
5
5
  Author-email: Fernando Aporta Franco <ferinazumaDEV@users.noreply.github.com>
6
6
  License: MIT
@@ -17,14 +17,16 @@ Requires-Python: >=3.9
17
17
  Description-Content-Type: text/markdown
18
18
  License-File: LICENSE
19
19
  Requires-Dist: numpy<3,>=1.24
20
- Requires-Dist: opencv-python-headless<5,>=4.8
20
+ Requires-Dist: opencv-python-headless<6,>=4.8
21
21
  Requires-Dist: PyYAML<7,>=6
22
22
  Provides-Extra: dev
23
- Requires-Dist: pytest<9,>=7; extra == "dev"
23
+ Requires-Dist: pytest<10,>=7; extra == "dev"
24
24
  Dynamic: license-file
25
25
 
26
26
  # framesig
27
27
 
28
+ [English](README.md) · **Español**: [README.es.md](README.es.md)
29
+
28
30
  **Find *when* something happens on screen — in any video, from any game or source — by its pixel signature.**
29
31
 
30
32
  ![Python](https://img.shields.io/badge/python-3.9%2B-blue) ![License: MIT](https://img.shields.io/badge/license-MIT-green) ![Tests](https://img.shields.io/badge/tests-88%20passing-brightgreen)
@@ -60,6 +62,12 @@ framesig doesn't know what a "kill" or a "death screen" looks like — and it do
60
62
 
61
63
  framesig needs only `numpy`, `opencv-python-headless` and `PyYAML`. (`ffmpeg` on your `PATH` is optional — it's used only to *generate* the sample clip in `gen-sample` / `demo`; scanning real videos does not need it.)
62
64
 
65
+ ```bash
66
+ pip install framesig
67
+ ```
68
+
69
+ Or from a clone, to hack on it:
70
+
63
71
  ```bash
64
72
  git clone https://github.com/ferinazumaDEV/framesig
65
73
  cd framesig
@@ -245,8 +253,8 @@ The suite includes an end-to-end test that renders the synthetic clip with ffmpe
245
253
  framesig is one of a set of small, dependency-light tools I build and maintain in the open — focused utilities that each do one job well and turn messy input into clean, structured output. If framesig fits into your pipeline, these siblings share the same engineering-first philosophy:
246
254
 
247
255
  - [The GEO Handbook](https://github.com/ferinazumaDEV/generative-engine-optimization-handbook) — the open reference on getting content cited by AI answer engines (ChatGPT, Perplexity, Google AI Overviews, Gemini, Copilot).
248
- - [typedout](https://github.com/ferinazumaDEV/typedout) — reliable structured output from any LLM: schema-validated JSON with tolerant repair and retries.
249
- - [politeclient](https://github.com/ferinazumaDEV/politeclient) — a polite, bulletproof HTTP client for Python: retries with backoff, per-host rate-limiting, caching, pagination.
256
+ - [typedout](https://github.com/ferinazumaDEV/typedout) — reliable structured output from OpenAI and Anthropic, with a provider interface for others: schema-validated JSON with tolerant repair and retries.
257
+ - [politeclient](https://github.com/ferinazumaDEV/politeclient) — a careful, well-behaved HTTP client for Python: retries with backoff, per-host rate-limiting, caching, pagination.
250
258
  - [scaffld](https://github.com/ferinazumaDEV/scaffld) — scaffold fully-wired Python projects (tests, CI, pre-commit, license) from templates, with a TUI.
251
259
  - Hub & writing: [zentimes.es](https://zentimes.es).
252
260
 
@@ -1,5 +1,7 @@
1
1
  # framesig
2
2
 
3
+ [English](README.md) · **Español**: [README.es.md](README.es.md)
4
+
3
5
  **Find *when* something happens on screen — in any video, from any game or source — by its pixel signature.**
4
6
 
5
7
  ![Python](https://img.shields.io/badge/python-3.9%2B-blue) ![License: MIT](https://img.shields.io/badge/license-MIT-green) ![Tests](https://img.shields.io/badge/tests-88%20passing-brightgreen)
@@ -35,6 +37,12 @@ framesig doesn't know what a "kill" or a "death screen" looks like — and it do
35
37
 
36
38
  framesig needs only `numpy`, `opencv-python-headless` and `PyYAML`. (`ffmpeg` on your `PATH` is optional — it's used only to *generate* the sample clip in `gen-sample` / `demo`; scanning real videos does not need it.)
37
39
 
40
+ ```bash
41
+ pip install framesig
42
+ ```
43
+
44
+ Or from a clone, to hack on it:
45
+
38
46
  ```bash
39
47
  git clone https://github.com/ferinazumaDEV/framesig
40
48
  cd framesig
@@ -220,8 +228,8 @@ The suite includes an end-to-end test that renders the synthetic clip with ffmpe
220
228
  framesig is one of a set of small, dependency-light tools I build and maintain in the open — focused utilities that each do one job well and turn messy input into clean, structured output. If framesig fits into your pipeline, these siblings share the same engineering-first philosophy:
221
229
 
222
230
  - [The GEO Handbook](https://github.com/ferinazumaDEV/generative-engine-optimization-handbook) — the open reference on getting content cited by AI answer engines (ChatGPT, Perplexity, Google AI Overviews, Gemini, Copilot).
223
- - [typedout](https://github.com/ferinazumaDEV/typedout) — reliable structured output from any LLM: schema-validated JSON with tolerant repair and retries.
224
- - [politeclient](https://github.com/ferinazumaDEV/politeclient) — a polite, bulletproof HTTP client for Python: retries with backoff, per-host rate-limiting, caching, pagination.
231
+ - [typedout](https://github.com/ferinazumaDEV/typedout) — reliable structured output from OpenAI and Anthropic, with a provider interface for others: schema-validated JSON with tolerant repair and retries.
232
+ - [politeclient](https://github.com/ferinazumaDEV/politeclient) — a careful, well-behaved HTTP client for Python: retries with backoff, per-host rate-limiting, caching, pagination.
225
233
  - [scaffld](https://github.com/ferinazumaDEV/scaffld) — scaffold fully-wired Python projects (tests, CI, pre-commit, license) from templates, with a TUI.
226
234
  - Hub & writing: [zentimes.es](https://zentimes.es).
227
235
 
@@ -4,9 +4,12 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "framesig"
7
- version = "0.1.0"
7
+ version = "0.1.2"
8
8
  description = "Detect on-screen events in any video by pixel signature — declarative YAML, cached scores, game-agnostic."
9
9
  readme = "README.md"
10
+ # Version policy: this floor is a minimum, not a recommendation, and everything below 3.11 is
11
+ # compatibility rather than support. 0.2.0 raises the floor to 3.11 (3.10 reaches end of life in
12
+ # October 2026). Nothing changes within 0.1.x.
10
13
  requires-python = ">=3.9"
11
14
  license = { text = "MIT" }
12
15
  authors = [{ name = "Fernando Aporta Franco", email = "ferinazumaDEV@users.noreply.github.com" }]
@@ -28,12 +31,12 @@ classifiers = [
28
31
  ]
29
32
  dependencies = [
30
33
  "numpy>=1.24,<3",
31
- "opencv-python-headless>=4.8,<5",
34
+ "opencv-python-headless>=4.8,<6",
32
35
  "PyYAML>=6,<7",
33
36
  ]
34
37
 
35
38
  [project.optional-dependencies]
36
- dev = ["pytest>=7,<9"]
39
+ dev = ["pytest>=7,<10"]
37
40
 
38
41
  [project.scripts]
39
42
  framesig = "framesig.cli:main"
@@ -41,7 +41,7 @@ from .regions import Region
41
41
  from .scanner import ScanResult, detect_all, scan_video
42
42
  from .videogen import GroundTruth, SampleVideo, generate_sample_video
43
43
 
44
- __version__ = "0.1.0"
44
+ __version__ = "0.1.2"
45
45
 
46
46
  __all__ = [
47
47
  "__version__",
@@ -17,11 +17,19 @@ from __future__ import annotations
17
17
 
18
18
  import hashlib
19
19
  import json
20
+ import os
21
+ import tempfile
20
22
  from pathlib import Path
21
23
  from typing import Any
22
24
 
23
25
  CACHE_VERSION = 2
24
26
 
27
+ #: Keys :meth:`ScanResult.from_payload` reads. A file missing any of them is not
28
+ #: a usable cache entry, however well-formed its JSON is -- validating only the
29
+ #: version used to let ``{"version": 2}`` through as a hit, and the consumer
30
+ #: then died on a KeyError far from the actual problem.
31
+ _REQUIRED_KEYS = ("video", "meta", "timestamps", "scores")
32
+
25
33
 
26
34
  def video_fingerprint(path: str | Path) -> dict[str, Any]:
27
35
  """Cheap identity for a video file: absolute path, byte size and mtime."""
@@ -57,29 +65,64 @@ class ScoreCache:
57
65
  """Return the cached payload for ``key``, or ``None`` on a miss.
58
66
 
59
67
  A corrupt or unreadable cache file is treated as a miss rather than an
60
- error, so a bad cache can never break a scan.
68
+ error, so a bad cache can never break a scan. "Corrupt" covers bytes
69
+ that are not UTF-8, text that is not JSON, JSON that is not an object,
70
+ the wrong cache version, and an object that does not carry what the
71
+ consumer will ask for. Any of those means: recompute.
61
72
  """
62
73
  path = self._path_for(key)
63
- if not path.exists():
74
+ try:
75
+ raw = path.read_bytes()
76
+ except OSError:
64
77
  return None
65
78
  try:
66
- payload = json.loads(path.read_text(encoding="utf-8"))
67
- except (OSError, json.JSONDecodeError):
79
+ payload = json.loads(raw.decode("utf-8"))
80
+ # UnicodeDecodeError is a ValueError, not an OSError, so catching
81
+ # (OSError, JSONDecodeError) missed it entirely: a cache file holding
82
+ # arbitrary bytes -- a truncated write, a file from another tool, a disk
83
+ # error -- raised out of load() and killed the scan it was meant to
84
+ # protect. JSONDecodeError is itself a ValueError, so one clause covers
85
+ # both without widening to bare Exception.
86
+ except ValueError:
68
87
  return None
69
88
  if not isinstance(payload, dict) or payload.get("version") != CACHE_VERSION:
70
89
  return None
90
+ if not all(key_name in payload for key_name in _REQUIRED_KEYS):
91
+ return None
92
+ if not isinstance(payload.get("scores"), dict):
93
+ return None
71
94
  return payload
72
95
 
73
96
  def store(self, key: str, payload: dict[str, Any]) -> Path:
74
97
  """Write ``payload`` under ``key`` and return the file path.
75
98
 
76
99
  The write is atomic (temp file + replace) so a crash mid-write cannot
77
- leave a half-written cache behind.
100
+ leave a half-written cache behind, and each write uses a temporary of
101
+ its own so two processes sharing a cache directory cannot collide.
102
+
103
+ Concurrency beyond that is last-writer-wins. Two writers producing
104
+ scores for the same key produce the same scores -- the key is a digest
105
+ of the video and the score-relevant config -- so which one lands does
106
+ not change the answer.
78
107
  """
79
108
  self.directory.mkdir(parents=True, exist_ok=True)
80
109
  payload = {**payload, "version": CACHE_VERSION}
81
110
  path = self._path_for(key)
82
- tmp = path.with_suffix(".json.tmp")
83
- tmp.write_text(json.dumps(payload), encoding="utf-8")
84
- tmp.replace(path)
111
+
112
+ # A shared name like scores_<key>.json.tmp meant two writers used the
113
+ # same temporary: the first replace() moved it away and the second
114
+ # failed with FileNotFoundError on a path it had legitimately written.
115
+ # mkstemp gives each writer its own, in the destination directory so
116
+ # os.replace stays a rename within one filesystem, and therefore atomic.
117
+ handle, tmp_name = tempfile.mkstemp(
118
+ dir=self.directory, prefix=f"scores_{key}.", suffix=".json.tmp"
119
+ )
120
+ tmp = Path(tmp_name)
121
+ try:
122
+ with os.fdopen(handle, "w", encoding="utf-8") as fh:
123
+ json.dump(payload, fh)
124
+ os.replace(tmp, path)
125
+ except BaseException:
126
+ tmp.unlink(missing_ok=True)
127
+ raise
85
128
  return path
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: framesig
3
- Version: 0.1.0
3
+ Version: 0.1.2
4
4
  Summary: Detect on-screen events in any video by pixel signature — declarative YAML, cached scores, game-agnostic.
5
5
  Author-email: Fernando Aporta Franco <ferinazumaDEV@users.noreply.github.com>
6
6
  License: MIT
@@ -17,14 +17,16 @@ Requires-Python: >=3.9
17
17
  Description-Content-Type: text/markdown
18
18
  License-File: LICENSE
19
19
  Requires-Dist: numpy<3,>=1.24
20
- Requires-Dist: opencv-python-headless<5,>=4.8
20
+ Requires-Dist: opencv-python-headless<6,>=4.8
21
21
  Requires-Dist: PyYAML<7,>=6
22
22
  Provides-Extra: dev
23
- Requires-Dist: pytest<9,>=7; extra == "dev"
23
+ Requires-Dist: pytest<10,>=7; extra == "dev"
24
24
  Dynamic: license-file
25
25
 
26
26
  # framesig
27
27
 
28
+ [English](README.md) · **Español**: [README.es.md](README.es.md)
29
+
28
30
  **Find *when* something happens on screen — in any video, from any game or source — by its pixel signature.**
29
31
 
30
32
  ![Python](https://img.shields.io/badge/python-3.9%2B-blue) ![License: MIT](https://img.shields.io/badge/license-MIT-green) ![Tests](https://img.shields.io/badge/tests-88%20passing-brightgreen)
@@ -60,6 +62,12 @@ framesig doesn't know what a "kill" or a "death screen" looks like — and it do
60
62
 
61
63
  framesig needs only `numpy`, `opencv-python-headless` and `PyYAML`. (`ffmpeg` on your `PATH` is optional — it's used only to *generate* the sample clip in `gen-sample` / `demo`; scanning real videos does not need it.)
62
64
 
65
+ ```bash
66
+ pip install framesig
67
+ ```
68
+
69
+ Or from a clone, to hack on it:
70
+
63
71
  ```bash
64
72
  git clone https://github.com/ferinazumaDEV/framesig
65
73
  cd framesig
@@ -245,8 +253,8 @@ The suite includes an end-to-end test that renders the synthetic clip with ffmpe
245
253
  framesig is one of a set of small, dependency-light tools I build and maintain in the open — focused utilities that each do one job well and turn messy input into clean, structured output. If framesig fits into your pipeline, these siblings share the same engineering-first philosophy:
246
254
 
247
255
  - [The GEO Handbook](https://github.com/ferinazumaDEV/generative-engine-optimization-handbook) — the open reference on getting content cited by AI answer engines (ChatGPT, Perplexity, Google AI Overviews, Gemini, Copilot).
248
- - [typedout](https://github.com/ferinazumaDEV/typedout) — reliable structured output from any LLM: schema-validated JSON with tolerant repair and retries.
249
- - [politeclient](https://github.com/ferinazumaDEV/politeclient) — a polite, bulletproof HTTP client for Python: retries with backoff, per-host rate-limiting, caching, pagination.
256
+ - [typedout](https://github.com/ferinazumaDEV/typedout) — reliable structured output from OpenAI and Anthropic, with a provider interface for others: schema-validated JSON with tolerant repair and retries.
257
+ - [politeclient](https://github.com/ferinazumaDEV/politeclient) — a careful, well-behaved HTTP client for Python: retries with backoff, per-host rate-limiting, caching, pagination.
250
258
  - [scaffld](https://github.com/ferinazumaDEV/scaffld) — scaffold fully-wired Python projects (tests, CI, pre-commit, license) from templates, with a TUI.
251
259
  - Hub & writing: [zentimes.es](https://zentimes.es).
252
260
 
@@ -20,6 +20,7 @@ src/framesig.egg-info/entry_points.txt
20
20
  src/framesig.egg-info/requires.txt
21
21
  src/framesig.egg-info/top_level.txt
22
22
  tests/test_cache.py
23
+ tests/test_cache_resilience.py
23
24
  tests/test_cli.py
24
25
  tests/test_config.py
25
26
  tests/test_detectors.py
@@ -0,0 +1,6 @@
1
+ numpy<3,>=1.24
2
+ opencv-python-headless<6,>=4.8
3
+ PyYAML<7,>=6
4
+
5
+ [dev]
6
+ pytest<10,>=7
@@ -0,0 +1,114 @@
1
+ """A bad cache must never break a scan, and two writers must not collide.
2
+
3
+ Audit findings F04 and F05.
4
+
5
+ F04: ``ScoreCache.load`` documented that a corrupt or unreadable file is a miss,
6
+ but caught only ``(OSError, json.JSONDecodeError)``. ``UnicodeDecodeError`` is a
7
+ ``ValueError``, so a cache file holding arbitrary bytes raised straight out of
8
+ ``load()`` and killed the scan the cache exists to speed up. Validation was also
9
+ thin enough that ``{"version": 2}`` counted as a hit, and the consumer then died
10
+ on a ``KeyError`` far from the real problem.
11
+
12
+ F05: every write of a key used the same temporary name, so two writers sharing a
13
+ cache directory raced: the first ``replace()`` moved the shared temporary away
14
+ and the second failed with ``FileNotFoundError`` on a file it had written.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import threading
21
+
22
+ import pytest
23
+
24
+ from framesig.cache import CACHE_VERSION, ScoreCache
25
+
26
+ GOOD = {
27
+ "video": "sample.mp4",
28
+ "meta": {"duration": 1.0},
29
+ "timestamps": [0.0, 0.1],
30
+ "scores": {"flash": [0.0, 1.0]},
31
+ }
32
+
33
+ CORRUPT = [
34
+ pytest.param(b"\xff\xfe", id="not-utf8"),
35
+ pytest.param(b"\x00\x01\x02\x03", id="binary-garbage"),
36
+ pytest.param(b'{"version": 2, "scores"', id="truncated-json"),
37
+ pytest.param(b"[1, 2, 3]", id="json-but-not-an-object"),
38
+ pytest.param(b'{"version": 99, "video": "v", "meta": {}, "timestamps": [], "scores": {}}',
39
+ id="wrong-version"),
40
+ pytest.param(b'{"version": 2}', id="no-payload-keys"),
41
+ pytest.param(b'{"version": 2, "video": "v", "meta": {}, "timestamps": []}',
42
+ id="missing-scores"),
43
+ pytest.param(b'{"version": 2, "video": "v", "meta": {}, "timestamps": [], "scores": []}',
44
+ id="scores-not-an-object"),
45
+ pytest.param(b"", id="empty-file"),
46
+ ]
47
+
48
+
49
+ @pytest.mark.parametrize("contents", CORRUPT)
50
+ def test_corrupt_cache_is_a_miss_not_an_exception(tmp_path, contents):
51
+ (tmp_path / "scores_bad.json").write_bytes(contents)
52
+ assert ScoreCache(tmp_path).load("bad") is None
53
+
54
+
55
+ def test_a_good_entry_still_loads(tmp_path):
56
+ """The validation must not be so strict that nothing survives it."""
57
+ cache = ScoreCache(tmp_path)
58
+ cache.store("ok", GOOD)
59
+ loaded = cache.load("ok")
60
+ assert loaded is not None
61
+ assert loaded["scores"] == GOOD["scores"]
62
+ assert loaded["version"] == CACHE_VERSION
63
+
64
+
65
+ def test_missing_file_is_a_miss(tmp_path):
66
+ assert ScoreCache(tmp_path).load("never-written") is None
67
+
68
+
69
+ def test_two_writers_on_one_key_do_not_collide(tmp_path):
70
+ """F05: a barrier after the temporary write forces the interleaving."""
71
+ cache = ScoreCache(tmp_path)
72
+ barrier = threading.Barrier(2, timeout=10)
73
+ errors: list[BaseException] = []
74
+
75
+ original_replace = __import__("os").replace
76
+
77
+ def slow_replace(src, dst):
78
+ barrier.wait() # both writers hold a temporary at this point
79
+ return original_replace(src, dst)
80
+
81
+ def writer(index):
82
+ try:
83
+ payload = {**GOOD, "scores": {"flash": [float(index)]}}
84
+ cache.store("shared", payload)
85
+ except BaseException as exc: # noqa: BLE001 - the test is about what escapes
86
+ errors.append(exc)
87
+
88
+ import framesig.cache as cache_module
89
+ monkeypatched = getattr(cache_module, "os")
90
+ real = monkeypatched.replace
91
+ monkeypatched.replace = slow_replace
92
+ try:
93
+ threads = [threading.Thread(target=writer, args=(i,)) for i in range(2)]
94
+ for thread in threads:
95
+ thread.start()
96
+ for thread in threads:
97
+ thread.join(timeout=15)
98
+ finally:
99
+ monkeypatched.replace = real
100
+
101
+ assert not errors, f"a writer raised: {errors!r}"
102
+
103
+ # Whichever landed, the file must be complete and valid -- not half-written.
104
+ written = json.loads((tmp_path / "scores_shared.json").read_text(encoding="utf-8"))
105
+ assert written["version"] == CACHE_VERSION
106
+ assert written["scores"]["flash"] in ([0.0], [1.0])
107
+ assert cache.load("shared") is not None
108
+
109
+
110
+ def test_no_temporary_files_are_left_behind(tmp_path):
111
+ cache = ScoreCache(tmp_path)
112
+ for i in range(5):
113
+ cache.store(f"key{i}", GOOD)
114
+ assert list(tmp_path.glob("*.tmp")) == []
@@ -6,6 +6,7 @@ that framesig recovers exactly the events baked into it, at the right times.
6
6
 
7
7
  from __future__ import annotations
8
8
 
9
+ import json
9
10
  from pathlib import Path
10
11
 
11
12
  import pytest
@@ -97,3 +98,31 @@ def test_detect_all_passes_the_scan_sample_period(sample_video, config, tmp_path
97
98
  (cut,) = detect_all(config, result)["scene_cut"]
98
99
  assert cut.samples == 1
99
100
  assert cut.duration == pytest.approx(result.meta["sample_period"])
101
+
102
+
103
+ def test_scan_recomputes_when_the_cache_file_is_corrupt(sample_video, config, tmp_path):
104
+ """Audit finding F04, the half the unit tests do not cover.
105
+
106
+ tests/test_cache_resilience.py proves ScoreCache.load() treats corrupt
107
+ bytes and useless payloads as a miss. This proves the *consumer* survives
108
+ them: a scan over a poisoned cache must recompute, return the same scores as
109
+ a clean scan, and leave a valid cache file behind -- not raise, and not
110
+ report from_cache=True for data it could not have read.
111
+ """
112
+ first = scan_video(sample_video.path, config, cache_dir=str(tmp_path))
113
+ assert first.from_cache is False
114
+ cache_files = list(Path(tmp_path).glob("scores_*.json"))
115
+ assert len(cache_files) == 1, cache_files
116
+ cache_file = cache_files[0]
117
+
118
+ for garbage in (b"\xff\xfe", b'{"version": 2}', b'{"version": 2, "scores"'):
119
+ cache_file.write_bytes(garbage)
120
+ again = scan_video(sample_video.path, config, cache_dir=str(tmp_path))
121
+ assert again.from_cache is False, f"served a hit from {garbage!r}"
122
+ assert again.timestamps == first.timestamps
123
+ assert again.scores == first.scores
124
+ rewritten = json.loads(cache_file.read_text(encoding="utf-8"))
125
+ assert rewritten["version"] == 2 and rewritten["scores"] == first.scores
126
+
127
+ # And the repaired cache is a real hit again.
128
+ assert scan_video(sample_video.path, config, cache_dir=str(tmp_path)).from_cache is True
@@ -1,6 +0,0 @@
1
- numpy<3,>=1.24
2
- opencv-python-headless<5,>=4.8
3
- PyYAML<7,>=6
4
-
5
- [dev]
6
- pytest<9,>=7
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes