boost-skill-cli 1.0.52__tar.gz → 1.0.53__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 (113) hide show
  1. {boost_skill_cli-1.0.52 → boost_skill_cli-1.0.53}/PKG-INFO +1 -1
  2. {boost_skill_cli-1.0.52 → boost_skill_cli-1.0.53}/boost_cli/_version.py +2 -2
  3. {boost_skill_cli-1.0.52 → boost_skill_cli-1.0.53}/boost_cli/core/config.py +3 -2
  4. {boost_skill_cli-1.0.52 → boost_skill_cli-1.0.53}/boost_cli/core/journal.py +3 -1
  5. {boost_skill_cli-1.0.52 → boost_skill_cli-1.0.53}/boost_cli/core/lockfile.py +27 -2
  6. {boost_skill_cli-1.0.52 → boost_skill_cli-1.0.53}/boost_cli/core/util.py +32 -0
  7. {boost_skill_cli-1.0.52 → boost_skill_cli-1.0.53}/boost_skill_cli.egg-info/PKG-INFO +1 -1
  8. {boost_skill_cli-1.0.52 → boost_skill_cli-1.0.53}/tests/unit/test_lockfile.py +25 -0
  9. {boost_skill_cli-1.0.52 → boost_skill_cli-1.0.53}/tests/unit/test_util.py +44 -0
  10. {boost_skill_cli-1.0.52 → boost_skill_cli-1.0.53}/.gitignore +0 -0
  11. {boost_skill_cli-1.0.52 → boost_skill_cli-1.0.53}/CLAUDE.md +0 -0
  12. {boost_skill_cli-1.0.52 → boost_skill_cli-1.0.53}/CONTRIBUTING.md +0 -0
  13. {boost_skill_cli-1.0.52 → boost_skill_cli-1.0.53}/LICENSE +0 -0
  14. {boost_skill_cli-1.0.52 → boost_skill_cli-1.0.53}/MANIFEST.in +0 -0
  15. {boost_skill_cli-1.0.52 → boost_skill_cli-1.0.53}/Makefile +0 -0
  16. {boost_skill_cli-1.0.52 → boost_skill_cli-1.0.53}/README.md +0 -0
  17. {boost_skill_cli-1.0.52 → boost_skill_cli-1.0.53}/SECURITY.md +0 -0
  18. {boost_skill_cli-1.0.52 → boost_skill_cli-1.0.53}/boost +0 -0
  19. {boost_skill_cli-1.0.52 → boost_skill_cli-1.0.53}/boost_cli/__init__.py +0 -0
  20. {boost_skill_cli-1.0.52 → boost_skill_cli-1.0.53}/boost_cli/__main__.py +0 -0
  21. {boost_skill_cli-1.0.52 → boost_skill_cli-1.0.53}/boost_cli/cli.py +0 -0
  22. {boost_skill_cli-1.0.52 → boost_skill_cli-1.0.53}/boost_cli/cliparse.py +0 -0
  23. {boost_skill_cli-1.0.52 → boost_skill_cli-1.0.53}/boost_cli/commands/__init__.py +0 -0
  24. {boost_skill_cli-1.0.52 → boost_skill_cli-1.0.53}/boost_cli/commands/bmad.py +0 -0
  25. {boost_skill_cli-1.0.52 → boost_skill_cli-1.0.53}/boost_cli/commands/configuration.py +0 -0
  26. {boost_skill_cli-1.0.52 → boost_skill_cli-1.0.53}/boost_cli/commands/discovery.py +0 -0
  27. {boost_skill_cli-1.0.52 → boost_skill_cli-1.0.53}/boost_cli/commands/hooks.py +0 -0
  28. {boost_skill_cli-1.0.52 → boost_skill_cli-1.0.53}/boost_cli/commands/info.py +0 -0
  29. {boost_skill_cli-1.0.52 → boost_skill_cli-1.0.53}/boost_cli/commands/intelligence.py +0 -0
  30. {boost_skill_cli-1.0.52 → boost_skill_cli-1.0.53}/boost_cli/commands/pkg.py +0 -0
  31. {boost_skill_cli-1.0.52 → boost_skill_cli-1.0.53}/boost_cli/commands/quality.py +0 -0
  32. {boost_skill_cli-1.0.52 → boost_skill_cli-1.0.53}/boost_cli/commands/taps.py +0 -0
  33. {boost_skill_cli-1.0.52 → boost_skill_cli-1.0.53}/boost_cli/commands/team.py +0 -0
  34. {boost_skill_cli-1.0.52 → boost_skill_cli-1.0.53}/boost_cli/core/__init__.py +0 -0
  35. {boost_skill_cli-1.0.52 → boost_skill_cli-1.0.53}/boost_cli/core/agents.py +0 -0
  36. {boost_skill_cli-1.0.52 → boost_skill_cli-1.0.53}/boost_cli/core/ai.py +0 -0
  37. {boost_skill_cli-1.0.52 → boost_skill_cli-1.0.53}/boost_cli/core/catalog.py +0 -0
  38. {boost_skill_cli-1.0.52 → boost_skill_cli-1.0.53}/boost_cli/core/claude_settings.py +0 -0
  39. {boost_skill_cli-1.0.52 → boost_skill_cli-1.0.53}/boost_cli/core/dense.py +0 -0
  40. {boost_skill_cli-1.0.52 → boost_skill_cli-1.0.53}/boost_cli/core/embed.py +0 -0
  41. {boost_skill_cli-1.0.52 → boost_skill_cli-1.0.53}/boost_cli/core/frontmatter.py +0 -0
  42. {boost_skill_cli-1.0.52 → boost_skill_cli-1.0.53}/boost_cli/core/gitutil.py +0 -0
  43. {boost_skill_cli-1.0.52 → boost_skill_cli-1.0.53}/boost_cli/core/logs.py +0 -0
  44. {boost_skill_cli-1.0.52 → boost_skill_cli-1.0.53}/boost_cli/core/mcp.py +0 -0
  45. {boost_skill_cli-1.0.52 → boost_skill_cli-1.0.53}/boost_cli/core/output.py +0 -0
  46. {boost_skill_cli-1.0.52 → boost_skill_cli-1.0.53}/boost_cli/core/paths.py +0 -0
  47. {boost_skill_cli-1.0.52 → boost_skill_cli-1.0.53}/boost_cli/core/policy.py +0 -0
  48. {boost_skill_cli-1.0.52 → boost_skill_cli-1.0.53}/boost_cli/core/rag.py +0 -0
  49. {boost_skill_cli-1.0.52 → boost_skill_cli-1.0.53}/boost_cli/core/registry.py +0 -0
  50. {boost_skill_cli-1.0.52 → boost_skill_cli-1.0.53}/boost_cli/core/store.py +0 -0
  51. {boost_skill_cli-1.0.52 → boost_skill_cli-1.0.53}/boost_cli/data/registries.json +0 -0
  52. {boost_skill_cli-1.0.52 → boost_skill_cli-1.0.53}/boost_cli/errors.py +0 -0
  53. {boost_skill_cli-1.0.52 → boost_skill_cli-1.0.53}/boost_cli/spin.py +0 -0
  54. {boost_skill_cli-1.0.52 → boost_skill_cli-1.0.53}/boost_skill_cli.egg-info/SOURCES.txt +0 -0
  55. {boost_skill_cli-1.0.52 → boost_skill_cli-1.0.53}/boost_skill_cli.egg-info/dependency_links.txt +0 -0
  56. {boost_skill_cli-1.0.52 → boost_skill_cli-1.0.53}/boost_skill_cli.egg-info/entry_points.txt +0 -0
  57. {boost_skill_cli-1.0.52 → boost_skill_cli-1.0.53}/boost_skill_cli.egg-info/requires.txt +0 -0
  58. {boost_skill_cli-1.0.52 → boost_skill_cli-1.0.53}/boost_skill_cli.egg-info/top_level.txt +0 -0
  59. {boost_skill_cli-1.0.52 → boost_skill_cli-1.0.53}/docs/.claude/settings.local.json +0 -0
  60. {boost_skill_cli-1.0.52 → boost_skill_cli-1.0.53}/docs/DEBUGGING.md +0 -0
  61. {boost_skill_cli-1.0.52 → boost_skill_cli-1.0.53}/docs/demo.gif +0 -0
  62. {boost_skill_cli-1.0.52 → boost_skill_cli-1.0.53}/docs/demo.tape +0 -0
  63. {boost_skill_cli-1.0.52 → boost_skill_cli-1.0.53}/docs/design-roadmap.html +0 -0
  64. {boost_skill_cli-1.0.52 → boost_skill_cli-1.0.53}/docs/index.html +0 -0
  65. {boost_skill_cli-1.0.52 → boost_skill_cli-1.0.53}/docs/rag-architecture.md +0 -0
  66. {boost_skill_cli-1.0.52 → boost_skill_cli-1.0.53}/docs/roadmap.html +0 -0
  67. {boost_skill_cli-1.0.52 → boost_skill_cli-1.0.53}/index.html +0 -0
  68. {boost_skill_cli-1.0.52 → boost_skill_cli-1.0.53}/pyproject.toml +0 -0
  69. {boost_skill_cli-1.0.52 → boost_skill_cli-1.0.53}/scripts/build_registries.py +0 -0
  70. {boost_skill_cli-1.0.52 → boost_skill_cli-1.0.53}/scripts/mutation_gate.py +0 -0
  71. {boost_skill_cli-1.0.52 → boost_skill_cli-1.0.53}/setup.cfg +0 -0
  72. {boost_skill_cli-1.0.52 → boost_skill_cli-1.0.53}/style/README.md +0 -0
  73. {boost_skill_cli-1.0.52 → boost_skill_cli-1.0.53}/style/boost.css +0 -0
  74. {boost_skill_cli-1.0.52 → boost_skill_cli-1.0.53}/style/boost.js +0 -0
  75. {boost_skill_cli-1.0.52 → boost_skill_cli-1.0.53}/style/demo.html +0 -0
  76. {boost_skill_cli-1.0.52 → boost_skill_cli-1.0.53}/tests/conftest.py +0 -0
  77. {boost_skill_cli-1.0.52 → boost_skill_cli-1.0.53}/tests/functional/test_cli_bmad.py +0 -0
  78. {boost_skill_cli-1.0.52 → boost_skill_cli-1.0.53}/tests/functional/test_cli_configuration.py +0 -0
  79. {boost_skill_cli-1.0.52 → boost_skill_cli-1.0.53}/tests/functional/test_cli_discovery.py +0 -0
  80. {boost_skill_cli-1.0.52 → boost_skill_cli-1.0.53}/tests/functional/test_cli_hooks.py +0 -0
  81. {boost_skill_cli-1.0.52 → boost_skill_cli-1.0.53}/tests/functional/test_cli_info.py +0 -0
  82. {boost_skill_cli-1.0.52 → boost_skill_cli-1.0.53}/tests/functional/test_cli_intelligence.py +0 -0
  83. {boost_skill_cli-1.0.52 → boost_skill_cli-1.0.53}/tests/functional/test_cli_pkg.py +0 -0
  84. {boost_skill_cli-1.0.52 → boost_skill_cli-1.0.53}/tests/functional/test_cli_quality.py +0 -0
  85. {boost_skill_cli-1.0.52 → boost_skill_cli-1.0.53}/tests/functional/test_cli_taps.py +0 -0
  86. {boost_skill_cli-1.0.52 → boost_skill_cli-1.0.53}/tests/functional/test_cli_team.py +0 -0
  87. {boost_skill_cli-1.0.52 → boost_skill_cli-1.0.53}/tests/functional/test_everyday_loop.py +0 -0
  88. {boost_skill_cli-1.0.52 → boost_skill_cli-1.0.53}/tests/make_fixture.py +0 -0
  89. {boost_skill_cli-1.0.52 → boost_skill_cli-1.0.53}/tests/smoke.sh +0 -0
  90. {boost_skill_cli-1.0.52 → boost_skill_cli-1.0.53}/tests/unit/test_agents.py +0 -0
  91. {boost_skill_cli-1.0.52 → boost_skill_cli-1.0.53}/tests/unit/test_ai.py +0 -0
  92. {boost_skill_cli-1.0.52 → boost_skill_cli-1.0.53}/tests/unit/test_catalog.py +0 -0
  93. {boost_skill_cli-1.0.52 → boost_skill_cli-1.0.53}/tests/unit/test_claude_settings.py +0 -0
  94. {boost_skill_cli-1.0.52 → boost_skill_cli-1.0.53}/tests/unit/test_cliparse.py +0 -0
  95. {boost_skill_cli-1.0.52 → boost_skill_cli-1.0.53}/tests/unit/test_config.py +0 -0
  96. {boost_skill_cli-1.0.52 → boost_skill_cli-1.0.53}/tests/unit/test_dense.py +0 -0
  97. {boost_skill_cli-1.0.52 → boost_skill_cli-1.0.53}/tests/unit/test_embed.py +0 -0
  98. {boost_skill_cli-1.0.52 → boost_skill_cli-1.0.53}/tests/unit/test_errors_and_cli_table.py +0 -0
  99. {boost_skill_cli-1.0.52 → boost_skill_cli-1.0.53}/tests/unit/test_frontmatter.py +0 -0
  100. {boost_skill_cli-1.0.52 → boost_skill_cli-1.0.53}/tests/unit/test_gitutil.py +0 -0
  101. {boost_skill_cli-1.0.52 → boost_skill_cli-1.0.53}/tests/unit/test_journal.py +0 -0
  102. {boost_skill_cli-1.0.52 → boost_skill_cli-1.0.53}/tests/unit/test_logs.py +0 -0
  103. {boost_skill_cli-1.0.52 → boost_skill_cli-1.0.53}/tests/unit/test_mcp.py +0 -0
  104. {boost_skill_cli-1.0.52 → boost_skill_cli-1.0.53}/tests/unit/test_mutation_hardening.py +0 -0
  105. {boost_skill_cli-1.0.52 → boost_skill_cli-1.0.53}/tests/unit/test_output.py +0 -0
  106. {boost_skill_cli-1.0.52 → boost_skill_cli-1.0.53}/tests/unit/test_paths.py +0 -0
  107. {boost_skill_cli-1.0.52 → boost_skill_cli-1.0.53}/tests/unit/test_policy.py +0 -0
  108. {boost_skill_cli-1.0.52 → boost_skill_cli-1.0.53}/tests/unit/test_rag.py +0 -0
  109. {boost_skill_cli-1.0.52 → boost_skill_cli-1.0.53}/tests/unit/test_registry.py +0 -0
  110. {boost_skill_cli-1.0.52 → boost_skill_cli-1.0.53}/tests/unit/test_spin.py +0 -0
  111. {boost_skill_cli-1.0.52 → boost_skill_cli-1.0.53}/tests/unit/test_store.py +0 -0
  112. {boost_skill_cli-1.0.52 → boost_skill_cli-1.0.53}/tests/unit/test_token_parity.py +0 -0
  113. {boost_skill_cli-1.0.52 → boost_skill_cli-1.0.53}/tests/unit/test_version.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: boost-skill-cli
3
- Version: 1.0.52
3
+ Version: 1.0.53
4
4
  Summary: boost — Homebrew for AI coding skills
5
5
  Author: Jonathan Reyes
6
6
  License: GNU GENERAL PUBLIC LICENSE
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '1.0.52'
22
- __version_tuple__ = version_tuple = (1, 0, 52)
21
+ __version__ = version = '1.0.53'
22
+ __version_tuple__ = version_tuple = (1, 0, 53)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -5,7 +5,7 @@ import json
5
5
  from copy import deepcopy
6
6
  from typing import Any
7
7
 
8
- from . import paths
8
+ from . import paths, util
9
9
 
10
10
  DEFAULTS = {
11
11
  "agents": {
@@ -90,7 +90,8 @@ def load() -> dict:
90
90
 
91
91
  def save(cfg: dict) -> None:
92
92
  paths.ensure_dirs()
93
- paths.config_path().write_text(json.dumps(cfg, indent=2, sort_keys=False) + "\n")
93
+ util.atomic_write_text(
94
+ paths.config_path(), json.dumps(cfg, indent=2, sort_keys=False) + "\n")
94
95
 
95
96
 
96
97
  def get(dotted: str, default=None):
@@ -67,4 +67,6 @@ def _maybe_rotate() -> None:
67
67
  except OSError:
68
68
  return
69
69
  if len(lines) > ROTATE_AT:
70
- p.write_text("\n".join(lines[-ROTATE_KEEP:]) + "\n")
70
+ # Atomic swap: a bare write_text here races concurrent log() appends and
71
+ # can truncate the feed mid-line if interrupted.
72
+ util.atomic_write_text(p, "\n".join(lines[-ROTATE_KEEP:]) + "\n")
@@ -26,16 +26,41 @@ def _skeleton() -> dict:
26
26
  def read() -> dict:
27
27
  p = paths.lockfile_path()
28
28
  if not p.exists():
29
- return _skeleton()
29
+ return _skeleton() # empty: never installed anything yet
30
30
  try:
31
31
  lock = json.loads(p.read_text())
32
32
  except (json.JSONDecodeError, OSError):
33
+ # Corrupt (present but unparseable) is NOT the same as empty: returning
34
+ # a bare skeleton here would let the next write() overwrite the only
35
+ # record of every prior install. Preserve the bytes for recovery and
36
+ # surface it loudly before falling back to the skeleton.
37
+ _preserve_corrupt(p)
33
38
  return _skeleton()
34
39
  lock.setdefault("version", SCHEMA_VERSION)
35
40
  lock.setdefault("skills", {})
36
41
  return lock
37
42
 
38
43
 
44
+ def _preserve_corrupt(p) -> None:
45
+ """Copy an unparseable lock file aside as ``<lock>.corrupt`` and warn.
46
+
47
+ Best effort: a read must still succeed (returning the skeleton) even if the
48
+ sidecar cannot be written, so failures here are swallowed after logging.
49
+ """
50
+ try:
51
+ backup = p.with_name(p.name + ".corrupt")
52
+ shutil.copy(p, backup)
53
+ except OSError:
54
+ backup = None
55
+ try:
56
+ from . import logs
57
+ logs.get_logger().warning(
58
+ "lock file %s is corrupt; preserved %s and continuing with an "
59
+ "empty lock", p, backup or "(backup failed)")
60
+ except Exception:
61
+ pass
62
+
63
+
39
64
  def write(lock: dict) -> None:
40
65
  paths.ensure_dirs()
41
66
  p = paths.lockfile_path()
@@ -52,7 +77,7 @@ def write(lock: dict) -> None:
52
77
  _prune_history()
53
78
  lock["version"] = SCHEMA_VERSION
54
79
  lock["updated"] = util.now_iso()
55
- p.write_text(json.dumps(lock, indent=2, sort_keys=True) + "\n")
80
+ util.atomic_write_text(p, json.dumps(lock, indent=2, sort_keys=True) + "\n")
56
81
 
57
82
 
58
83
  def _history_files() -> List:
@@ -2,7 +2,9 @@
2
2
  from __future__ import annotations
3
3
 
4
4
  import hashlib
5
+ import os
5
6
  import re
7
+ import tempfile
6
8
  from datetime import datetime, timezone
7
9
  from pathlib import Path
8
10
  from typing import List, Tuple
@@ -10,6 +12,36 @@ from typing import List, Tuple
10
12
  IGNORED = {".git", "__pycache__", ".DS_Store"}
11
13
 
12
14
 
15
+ def atomic_write_text(path: Path, text: str, encoding: str = "utf-8") -> None:
16
+ """Write ``text`` to ``path`` so a reader never sees a partial file.
17
+
18
+ The bytes go to a temp file in the *same* directory (so the rename stays on
19
+ one filesystem and is atomic), get flushed and fsynced to disk, then swap
20
+ into place with ``os.replace``. An interrupted or concurrent write can no
21
+ longer truncate the target: the previous file survives intact until the
22
+ complete new one atomically replaces it. This is the corruption-safe
23
+ substitute for a bare ``Path.write_text`` on any file boost must not lose —
24
+ the lock file, the config, the pulse journal.
25
+ """
26
+ path = Path(path)
27
+ path.parent.mkdir(parents=True, exist_ok=True)
28
+ fd, tmp = tempfile.mkstemp(dir=str(path.parent),
29
+ prefix="." + path.name + ".", suffix=".tmp")
30
+ try:
31
+ with os.fdopen(fd, "w", encoding=encoding) as f:
32
+ f.write(text)
33
+ f.flush()
34
+ os.fsync(f.fileno())
35
+ os.replace(tmp, str(path))
36
+ except BaseException:
37
+ # Never leave a temp turd behind, and never mask the original error.
38
+ try:
39
+ os.unlink(tmp)
40
+ except OSError:
41
+ pass
42
+ raise
43
+
44
+
13
45
  def now_iso() -> str:
14
46
  return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
15
47
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: boost-skill-cli
3
- Version: 1.0.52
3
+ Version: 1.0.53
4
4
  Summary: boost — Homebrew for AI coding skills
5
5
  Author: Jonathan Reyes
6
6
  License: GNU GENERAL PUBLIC LICENSE
@@ -35,6 +35,31 @@ class TestRead:
35
35
  assert lock["version"] == 3
36
36
  assert lock["skills"] == {}
37
37
 
38
+ def test_corrupt_file_preserved_as_sidecar(self, sandbox):
39
+ paths.ensure_dirs()
40
+ p = paths.lockfile_path()
41
+ p.write_text("{definitely not json")
42
+ lockfile.read()
43
+ backup = p.with_name(p.name + ".corrupt")
44
+ assert backup.exists()
45
+ assert backup.read_text() == "{definitely not json"
46
+
47
+ def test_missing_file_leaves_no_sidecar(self, sandbox):
48
+ lockfile.read()
49
+ p = paths.lockfile_path()
50
+ assert not p.with_name(p.name + ".corrupt").exists()
51
+
52
+ def test_corrupt_read_does_not_lose_prior_records(self, sandbox):
53
+ # A corrupt lock followed by a new install must not silently erase the
54
+ # only surviving copy of the earlier records — they live on in .corrupt.
55
+ paths.ensure_dirs()
56
+ p = paths.lockfile_path()
57
+ p.write_text('{"skills": {"old": {"version": "1.0"}}, TRAILING GARBAGE')
58
+ lockfile.set_skill("new", {"version": "2.0"})
59
+ assert set(lockfile.installed()) == {"new"} # skeleton took over
60
+ backup = p.with_name(p.name + ".corrupt")
61
+ assert '"old"' in backup.read_text() # but old bytes survive
62
+
38
63
  def test_missing_keys_defaulted(self, sandbox):
39
64
  paths.ensure_dirs()
40
65
  paths.lockfile_path().write_text('{"skills": {"a": {}}}')
@@ -364,3 +364,47 @@ class TestScoreSkill:
364
364
  assert len(text) == 48_000
365
365
  d = make_skill(tmp_path, text)
366
366
  assert util.score_skill(d) == (95, [])
367
+
368
+
369
+ class TestAtomicWriteText:
370
+ def test_writes_content(self, tmp_path):
371
+ p = tmp_path / "f.txt"
372
+ util.atomic_write_text(p, "hello")
373
+ assert p.read_text() == "hello"
374
+
375
+ def test_overwrites_existing(self, tmp_path):
376
+ p = tmp_path / "f.txt"
377
+ p.write_text("old")
378
+ util.atomic_write_text(p, "new")
379
+ assert p.read_text() == "new"
380
+
381
+ def test_creates_missing_parents(self, tmp_path):
382
+ p = tmp_path / "a" / "b" / "f.txt"
383
+ util.atomic_write_text(p, "deep")
384
+ assert p.read_text() == "deep"
385
+
386
+ def test_leaves_no_temp_files(self, tmp_path):
387
+ util.atomic_write_text(tmp_path / "f.txt", "x")
388
+ leftovers = [q.name for q in tmp_path.iterdir() if q.name != "f.txt"]
389
+ assert leftovers == []
390
+
391
+ def test_honours_encoding(self, tmp_path):
392
+ p = tmp_path / "f.txt"
393
+ util.atomic_write_text(p, "café", encoding="utf-8")
394
+ assert p.read_bytes() == "café".encode("utf-8")
395
+
396
+ def test_replace_failure_cleans_temp_and_raises(self, tmp_path, monkeypatch):
397
+ import os as _os
398
+ p = tmp_path / "f.txt"
399
+ p.write_text("original")
400
+
401
+ def boom(_src, _dst):
402
+ raise OSError("replace failed")
403
+
404
+ monkeypatch.setattr(_os, "replace", boom)
405
+ with pytest.raises(OSError, match="replace failed"):
406
+ util.atomic_write_text(p, "new")
407
+ # original untouched, and no temp turd left behind
408
+ assert p.read_text() == "original"
409
+ leftovers = [q.name for q in tmp_path.iterdir() if q.name != "f.txt"]
410
+ assert leftovers == []
File without changes