Skip to content

Commit 93cd405

Browse files
marcelsafinCopilot
andcommitted
fix: invalidate external step caches and restore backup preimages
Invalidate source-derived Python caches outside custom packages as well as in-package caches. Include extension configuration backup paths in existing component snapshots, preserving prior files, directories and absence. Cover equal-size/equal-mtime reloads, cache deletion failures, nine real rollback histories and backup I/O failures. Assisted-by: GitHub Copilot (model: GPT-6 Astra, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent dbbc499 commit 93cd405

5 files changed

Lines changed: 160 additions & 2 deletions

File tree

docs/reference/bundles.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ specify bundle update [<bundle_id>]
5959

6060
Re-resolves a bundle and **refreshes** its components through each primitive's update path, bringing already-installed components up to the bundle's newly pinned versions while preserving primitive-level overrides (such as preset priority). Provide a bundle id, or use `--all` to update everything installed.
6161

62-
Before refreshing or removing an owned component, the bundler snapshots its installed files and registry metadata. If a component operation or provenance write fails, it attempts to restore those local snapshots, including disabled state, user configuration, extension hook settings, and generated command files and skill resources for current and previously active integrations, without downloading an older version. Previously absent outputs are also restored to absence. Custom steps are restored before dependent workflows. Recovery is best-effort and reports incomplete restoration; these temporary snapshots cover failures during the command, not process crashes or unrelated project files.
62+
Before refreshing or removing an owned component, the bundler snapshots its installed files and registry metadata. If a component operation or provenance write fails, it attempts to restore those local snapshots, including disabled state, user configuration, extension hook settings and pre-existing configuration backups, and generated command files and skill resources for current and previously active integrations, without downloading an older version. Previously absent outputs and configuration backups are also restored to absence. Custom steps are restored before dependent workflows. Recovery is best-effort and reports incomplete restoration; these temporary snapshots cover failures during the command, not process crashes or unrelated project files.
6363

6464
> **Pin enforcement is install-time only.** Idempotency checks are id-based, not version-aware: a component that is already present is skipped during `install` without comparing its on-disk version to the manifest pin. Version pins are therefore guaranteed to be applied only when the bundler actually installs a component for the first time or refreshes it. Run `specify bundle update` to re-apply every owned component at its pinned version.
6565

src/specify_cli/bundler/services/artifacts.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
"""Component-scoped preimages of generated integration outputs."""
1+
"""Component-scoped preimages of manager-generated outputs and backups."""
22
from __future__ import annotations
33

44
import os
@@ -84,6 +84,7 @@ def snapshot_generated_artifacts(
8484
)
8585
active_skills = manager._resolve_agent_skills_dir(active) if active else None
8686
else:
87+
paths.add(manager.extensions_dir / ".backup" / snapshot.component.id)
8788
paths.update(manager._find_extension_skill_dirs(
8889
skills, snapshot.component.id, create_skills_dir=False
8990
))

src/specify_cli/workflows/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,10 @@ def _load_custom_steps(project_root: Path, registry: dict[str, StepBase]) -> Non
189189
raise OSError(f"Refusing symlinked bytecode cache: {cache_dir}")
190190
if cache_dir.is_dir():
191191
_shutil.rmtree(cache_dir)
192+
# PYTHONPYCACHEPREFIX can place caches outside the step package.
193+
for source_file in step_dir.rglob("*.py"):
194+
cache_file = Path(_importlib_util.cache_from_source(str(source_file)))
195+
cache_file.unlink(missing_ok=True)
192196
_importlib.invalidate_caches()
193197

194198
# Treat the step directory as a proper package so that relative

tests/integration/test_bundler_state_rollback.py

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,102 @@ def fail_save(*_args):
137137
assert HookExecutor(project).get_project_config()["hooks"] == original_hooks
138138

139139

140+
@pytest.mark.parametrize("installed_components", ["extensions"], indirect=True)
141+
@pytest.mark.parametrize("backup_state", ["absent", "empty", "contents"])
142+
@pytest.mark.parametrize("operation", ["refresh", "drop", "remove"])
143+
def test_save_failure_restores_extension_backup_preimage(
144+
installed_components, monkeypatch, backup_state, operation,
145+
):
146+
from specify_cli import _assets
147+
148+
project, _, manager_type, installer, plan, metadata = installed_components
149+
backup_root = project / ".specify/extensions/.backup"
150+
if backup_state != "absent":
151+
owned_backup = backup_root / "owned"
152+
owned_backup.mkdir(parents=True)
153+
unrelated = backup_root / "unrelated"
154+
unrelated.mkdir()
155+
(unrelated / "saved-config.yml").write_text("unrelated\n", encoding="utf-8")
156+
if backup_state == "contents":
157+
(owned_backup / "owned-config.yml").write_text(
158+
"setting: previous backup\n", encoding="utf-8"
159+
)
160+
(owned_backup / "notes").mkdir()
161+
(owned_backup / "notes/context.txt").write_text(
162+
"retained backup context\n", encoding="utf-8"
163+
)
164+
165+
def backup_tree():
166+
if not backup_root.exists():
167+
return None
168+
return {
169+
str(path.relative_to(backup_root)): path.read_bytes() if path.is_file() else None
170+
for path in backup_root.rglob("*")
171+
}
172+
173+
before = backup_tree()
174+
original_record = records_path(project).read_bytes()
175+
source = _assets._locate_bundled_extension("owned")
176+
(source / "replacement-config.yml").write_text("new defaults\n", encoding="utf-8")
177+
178+
def fail_save(*_args):
179+
raise OSError("provenance write refused")
180+
181+
monkeypatch.setattr("specify_cli.bundler.services.installer.save_records", fail_save)
182+
with pytest.raises(BundlerError, match="provenance write refused"):
183+
if operation == "remove":
184+
remove_bundle(project, "demo-bundle", installer)
185+
else:
186+
install_bundle(
187+
project,
188+
plan(["owned", "keeper"] if operation == "refresh" else ["keeper"]),
189+
installer, refresh=True,
190+
)
191+
assert backup_tree() == before
192+
assert manager_type(project).registry.get("owned") == metadata
193+
assert records_path(project).read_bytes() == original_record
194+
assert not (project / ".specify/extensions/owned/replacement-config.yml").exists()
195+
196+
197+
@pytest.mark.parametrize("installed_components", ["extensions"], indirect=True)
198+
@pytest.mark.parametrize("failure", ["snapshot", "restore"])
199+
def test_extension_backup_io_failure_is_reported(
200+
installed_components, monkeypatch, failure,
201+
):
202+
import shutil
203+
from pathlib import Path
204+
205+
project, _, manager_type, installer, _, metadata = installed_components
206+
backup = project / ".specify/extensions/.backup/owned"
207+
backup.mkdir(parents=True)
208+
original = backup / "owned-config.yml"
209+
original.write_text("prior backup\n", encoding="utf-8")
210+
original_record = records_path(project).read_bytes()
211+
copy_tree = shutil.copytree
212+
213+
def fail_copy(source, destination, *args, **kwargs):
214+
attempted = source if failure == "snapshot" else destination
215+
if Path(attempted) == backup:
216+
raise PermissionError("backup I/O denied")
217+
return copy_tree(source, destination, *args, **kwargs)
218+
219+
def fail_save(*_args):
220+
raise OSError("provenance write refused")
221+
222+
monkeypatch.setattr("specify_cli.bundler.services.artifacts.shutil.copytree", fail_copy)
223+
if failure == "restore":
224+
monkeypatch.setattr("specify_cli.bundler.services.installer.save_records", fail_save)
225+
message = "backup I/O denied" if failure == "snapshot" else "Rollback was incomplete"
226+
with pytest.raises(BundlerError, match=message):
227+
remove_bundle(project, "demo-bundle", installer)
228+
assert manager_type(project).registry.get("owned") == metadata
229+
assert records_path(project).read_bytes() == original_record
230+
if failure == "snapshot":
231+
assert original.read_text(encoding="utf-8") == "prior backup\n"
232+
else:
233+
assert not backup.exists()
234+
235+
140236
@pytest.mark.parametrize("kind", ["steps", "workflows"])
141237
def test_dropped_component_restores_local_payload_and_exact_registry(
142238
tmp_path, monkeypatch, kind

tests/workflows/test_registry_isolation.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,3 +198,60 @@ def test_symlinked_cache_is_not_used_when_it_cannot_be_invalidated(projects):
198198
assert load_custom_steps(project_a) == []
199199
assert "shared" not in STEP_REGISTRY
200200
assert external.is_dir()
201+
202+
203+
@pytest.fixture
204+
def external_bytecode_cache(projects, monkeypatch):
205+
import sys
206+
207+
project_a, _ = projects
208+
prefix = project_a.parent / "bytecode"
209+
monkeypatch.setattr(sys, "pycache_prefix", str(prefix))
210+
package = project_a / ".specify/workflows/steps/shared"
211+
assert load_custom_steps(project_a) == ["shared"]
212+
assert STEP_REGISTRY["shared"].execute({}, None).output["project"] == "project-a"
213+
for name in ("__init__.py", "helper.py"):
214+
cache = Path(importlib.util.cache_from_source(str(package / name)))
215+
assert cache.is_relative_to(prefix)
216+
assert cache.is_file()
217+
assert not (package / "__pycache__").exists()
218+
return project_a, package, prefix
219+
220+
221+
def test_external_caches_are_invalidated_for_package_and_delayed_imports(
222+
external_bytecode_cache,
223+
):
224+
project, package, prefix = external_bytecode_cache
225+
unrelated = prefix / "unrelated.pyc"
226+
unrelated.write_bytes(b"unrelated cache")
227+
for name in ("__init__.py", "helper.py"):
228+
source = package / name
229+
stat = source.stat()
230+
original = source.read_text(encoding="utf-8")
231+
updated = original.replace("project-a", "project-b")
232+
assert updated != original and len(updated) == len(original)
233+
source.write_text(updated, encoding="utf-8")
234+
os.utime(source, ns=(stat.st_atime_ns, stat.st_mtime_ns))
235+
236+
assert load_custom_steps(project) == ["shared"]
237+
assert STEP_REGISTRY["shared"].project_marker == "project-b"
238+
assert STEP_REGISTRY["shared"].execute({}, None).output["project"] == "project-b"
239+
assert unrelated.read_bytes() == b"unrelated cache"
240+
241+
242+
@pytest.mark.parametrize("source_name", ["__init__.py", "helper.py"])
243+
def test_failed_external_cache_deletion_skips_package(
244+
external_bytecode_cache, monkeypatch, source_name,
245+
):
246+
project, package, _ = external_bytecode_cache
247+
cache = Path(importlib.util.cache_from_source(str(package / source_name)))
248+
unlink = Path.unlink
249+
250+
def deny_cache_removal(path, *args, **kwargs):
251+
if path == cache:
252+
raise PermissionError("external cache deletion denied")
253+
return unlink(path, *args, **kwargs)
254+
255+
monkeypatch.setattr(Path, "unlink", deny_cache_removal)
256+
assert load_custom_steps(project) == []
257+
assert "shared" not in STEP_REGISTRY

0 commit comments

Comments
 (0)