Skip to content

Commit 3b63395

Browse files
committed
Add verify to check cached models against their recorded digests
1 parent 268a9ab commit 3b63395

4 files changed

Lines changed: 42 additions & 2 deletions

File tree

python/DOC.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ A warm cache can be read-only. Concurrent cold starts on one model are safe: a m
9393
**Command line**
9494

9595
```bash
96-
uvx mujoco-menagerie names | info NAME | path NAME [ENTRY] | view NAME [ENTRY] | prefetch [NAME...] | prune
96+
uvx mujoco-menagerie names | info NAME | path NAME [ENTRY] | view NAME [ENTRY] | prefetch [NAME...] | verify [NAME...] | prune
9797
```
9898

9999
**Speed**, laptop, local mirror

python/src/mujoco_menagerie/__main__.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,10 @@ def main(argv: list[str] | None = None) -> None:
3939
sub.add_parser(
4040
'prune', help='delete cached models this version no longer references'
4141
)
42+
sub.add_parser(
43+
'verify',
44+
help='check cached models against the digests recorded at download',
45+
).add_argument('names', nargs='*')
4246
a = p.parse_args(argv)
4347
try:
4448
if a.cmd == 'names':
@@ -61,6 +65,16 @@ def main(argv: list[str] | None = None) -> None:
6165
mm.prefetch(a.names)
6266
elif a.cmd == 'prune':
6367
print(*mm.prune(), sep='\n')
68+
elif a.cmd == 'verify':
69+
cache = mm.Cache()
70+
bad = {
71+
r.name: cache.verify(r)
72+
for r in map(mm.get, a.names or mm.names())
73+
if cache.is_cached(r)
74+
}
75+
for name, files in bad.items():
76+
print(name, 'ok' if not files else 'changed: ' + ' '.join(files))
77+
sys.exit(any(bad.values()))
6478
except mm.MenagerieError as e:
6579
sys.exit(f'error: {e}')
6680

python/src/mujoco_menagerie/_cache.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,14 @@ def _publish(
139139
tree = stage / robot.name
140140
if not tree.is_dir():
141141
raise DownloadError(f'{robot.asset} has no top-level {robot.name}/')
142-
(tree / SENTINEL).write_text(json.dumps({'oid': robot.oid}))
142+
files = {
143+
p.relative_to(tree).as_posix(): sha256_file(p)
144+
for p in sorted(tree.rglob('*'))
145+
if p.is_file()
146+
}
147+
(tree / SENTINEL).write_text(
148+
json.dumps({'oid': robot.oid, 'files': files})
149+
)
143150
_fsync_tree(tree)
144151
target.parent.mkdir(parents=True, exist_ok=True)
145152
with contextlib.suppress(OSError): # lost a race to another process
@@ -150,6 +157,16 @@ def _publish(
150157
finally:
151158
shutil.rmtree(stage, ignore_errors=True)
152159

160+
def verify(self, robot: Robot) -> list[str]:
161+
"""Files missing or changed since the model was published."""
162+
target = self.model_path(robot)
163+
files = json.loads((target / SENTINEL).read_text())['files']
164+
return [
165+
f
166+
for f, h in files.items()
167+
if not (target / f).is_file() or sha256_file(target / f) != h
168+
]
169+
153170
def prune(self, keep: Iterable[Robot]) -> list[pathlib.Path]:
154171
# Like uninstalling a package: not safe for models other processes are using.
155172
keep = {self.model_path(r).name for r in keep}

python/tests/test_cache.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,15 @@ def test_environment_supplies_defaults(monkeypatch, tmp_path):
133133
assert Cache(base_url='file:///x').base_url == 'file:///x'
134134

135135

136+
def test_verify_detects_changed_files(robot, base_url, cache_dir):
137+
cache = Cache(cache_dir, base_url)
138+
path = cache.resolve(robot)
139+
assert cache.verify(robot) == []
140+
(path / 'fake.xml').write_text('<mujoco/>')
141+
(path / 'assets/cube.obj').unlink()
142+
assert cache.verify(robot) == ['assets/cube.obj', 'fake.xml']
143+
144+
136145
def test_versions_coexist_and_prune(fake_model, archives, base_url, cache_dir):
137146
v1 = make_robot(fake_model, archives, oid='1' * 40)
138147
(fake_model / 'README.md').write_text('# v2\n')

0 commit comments

Comments
 (0)