claude-ctx 0.5.0__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 (69) hide show
  1. claude_ctx-0.5.0/LICENSE +21 -0
  2. claude_ctx-0.5.0/PKG-INFO +83 -0
  3. claude_ctx-0.5.0/README.md +56 -0
  4. claude_ctx-0.5.0/pyproject.toml +133 -0
  5. claude_ctx-0.5.0/setup.cfg +4 -0
  6. claude_ctx-0.5.0/src/_file_lock.py +94 -0
  7. claude_ctx-0.5.0/src/_fs_utils.py +104 -0
  8. claude_ctx-0.5.0/src/agent_add.py +229 -0
  9. claude_ctx-0.5.0/src/backup_config.py +295 -0
  10. claude_ctx-0.5.0/src/backup_mirror.py +981 -0
  11. claude_ctx-0.5.0/src/backup_retention.py +138 -0
  12. claude_ctx-0.5.0/src/backup_watchdog.py +175 -0
  13. claude_ctx-0.5.0/src/batch_convert.py +536 -0
  14. claude_ctx-0.5.0/src/behavior_miner.py +496 -0
  15. claude_ctx-0.5.0/src/catalog_builder.py +226 -0
  16. claude_ctx-0.5.0/src/change_detector.py +255 -0
  17. claude_ctx-0.5.0/src/claude_ctx.egg-info/PKG-INFO +83 -0
  18. claude_ctx-0.5.0/src/claude_ctx.egg-info/SOURCES.txt +67 -0
  19. claude_ctx-0.5.0/src/claude_ctx.egg-info/dependency_links.txt +1 -0
  20. claude_ctx-0.5.0/src/claude_ctx.egg-info/entry_points.txt +11 -0
  21. claude_ctx-0.5.0/src/claude_ctx.egg-info/requires.txt +14 -0
  22. claude_ctx-0.5.0/src/claude_ctx.egg-info/top_level.txt +58 -0
  23. claude_ctx-0.5.0/src/context_monitor.py +370 -0
  24. claude_ctx-0.5.0/src/corpus_cache.py +301 -0
  25. claude_ctx-0.5.0/src/cosine_ranker.py +180 -0
  26. claude_ctx-0.5.0/src/council_runner.py +359 -0
  27. claude_ctx-0.5.0/src/ctx_audit_log.py +249 -0
  28. claude_ctx-0.5.0/src/ctx_config.py +220 -0
  29. claude_ctx-0.5.0/src/ctx_init.py +233 -0
  30. claude_ctx-0.5.0/src/ctx_lifecycle.py +1067 -0
  31. claude_ctx-0.5.0/src/ctx_monitor.py +1283 -0
  32. claude_ctx-0.5.0/src/embedding_backend.py +231 -0
  33. claude_ctx-0.5.0/src/flatten_agents.py +125 -0
  34. claude_ctx-0.5.0/src/import_strix_skills.py +187 -0
  35. claude_ctx-0.5.0/src/inject_hooks.py +241 -0
  36. claude_ctx-0.5.0/src/intake_gate.py +378 -0
  37. claude_ctx-0.5.0/src/intake_pipeline.py +157 -0
  38. claude_ctx-0.5.0/src/intent_interview.py +705 -0
  39. claude_ctx-0.5.0/src/kpi_dashboard.py +563 -0
  40. claude_ctx-0.5.0/src/link_conversions.py +424 -0
  41. claude_ctx-0.5.0/src/memory_anchor.py +361 -0
  42. claude_ctx-0.5.0/src/quality_signals.py +333 -0
  43. claude_ctx-0.5.0/src/resolve_graph.py +222 -0
  44. claude_ctx-0.5.0/src/resolve_skills.py +519 -0
  45. claude_ctx-0.5.0/src/scan_repo.py +570 -0
  46. claude_ctx-0.5.0/src/skill_add.py +448 -0
  47. claude_ctx-0.5.0/src/skill_add_detector.py +235 -0
  48. claude_ctx-0.5.0/src/skill_category.py +366 -0
  49. claude_ctx-0.5.0/src/skill_health.py +573 -0
  50. claude_ctx-0.5.0/src/skill_loader.py +214 -0
  51. claude_ctx-0.5.0/src/skill_quality.py +1241 -0
  52. claude_ctx-0.5.0/src/skill_suggest.py +131 -0
  53. claude_ctx-0.5.0/src/skill_telemetry.py +305 -0
  54. claude_ctx-0.5.0/src/skill_unload.py +334 -0
  55. claude_ctx-0.5.0/src/toolbox.py +390 -0
  56. claude_ctx-0.5.0/src/toolbox_config.py +321 -0
  57. claude_ctx-0.5.0/src/toolbox_hooks.py +217 -0
  58. claude_ctx-0.5.0/src/toolbox_verdict.py +542 -0
  59. claude_ctx-0.5.0/src/update_repo_stats.py +247 -0
  60. claude_ctx-0.5.0/src/usage_tracker.py +353 -0
  61. claude_ctx-0.5.0/src/versions_catalog.py +216 -0
  62. claude_ctx-0.5.0/src/wiki_batch_entities.py +341 -0
  63. claude_ctx-0.5.0/src/wiki_graphify.py +394 -0
  64. claude_ctx-0.5.0/src/wiki_lint.py +385 -0
  65. claude_ctx-0.5.0/src/wiki_orchestrator.py +622 -0
  66. claude_ctx-0.5.0/src/wiki_query.py +400 -0
  67. claude_ctx-0.5.0/src/wiki_sync.py +401 -0
  68. claude_ctx-0.5.0/src/wiki_utils.py +93 -0
  69. claude_ctx-0.5.0/src/wiki_visualize.py +739 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Steve Solun
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,83 @@
1
+ Metadata-Version: 2.4
2
+ Name: claude-ctx
3
+ Version: 0.5.0
4
+ Summary: Skill and agent recommendation system for Claude Code — knowledge graph, wiki, and intake quality gates
5
+ Author: Steve Solun
6
+ License: MIT
7
+ Project-URL: Homepage, https://stevesolun.github.io/ctx/
8
+ Project-URL: Repository, https://github.com/stevesolun/ctx
9
+ Project-URL: Documentation, https://stevesolun.github.io/ctx/
10
+ Project-URL: Changelog, https://github.com/stevesolun/ctx/blob/main/CHANGELOG.md
11
+ Requires-Python: >=3.11
12
+ Description-Content-Type: text/markdown
13
+ License-File: LICENSE
14
+ Requires-Dist: networkx<4,>=3
15
+ Requires-Dist: numpy<3,>=1.24
16
+ Requires-Dist: plotly<7,>=5
17
+ Requires-Dist: pyyaml<7,>=6
18
+ Provides-Extra: dev
19
+ Requires-Dist: pytest>=8; extra == "dev"
20
+ Requires-Dist: pytest-cov>=5; extra == "dev"
21
+ Requires-Dist: mypy>=1.8; extra == "dev"
22
+ Requires-Dist: ruff>=0.4; extra == "dev"
23
+ Provides-Extra: embeddings
24
+ Requires-Dist: sentence-transformers<4,>=2; extra == "embeddings"
25
+ Requires-Dist: torch<3,>=2; extra == "embeddings"
26
+ Dynamic: license-file
27
+
28
+ # ctx — Skill & Agent Recommendation for Claude Code
29
+
30
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
31
+ [![Python 3.11+](https://img.shields.io/badge/Python-3.11+-green.svg)](https://python.org)
32
+ [![PyPI](https://img.shields.io/pypi/v/claude-ctx.svg)](https://pypi.org/project/claude-ctx/)
33
+ [![Tests](https://img.shields.io/badge/Tests-1363_passing-brightgreen.svg)](#)
34
+ [![Graph](https://img.shields.io/badge/Graph-2,211_nodes_/_642K_edges-red.svg)](graph/)
35
+ [![Docs](https://img.shields.io/badge/docs-MkDocs_Material-blue.svg)](https://stevesolun.github.io/ctx/)
36
+
37
+ Watches what you develop, walks a knowledge graph of **1,769 skills and 443 agents** (2,212 nodes, 885 edges, 865 communities), and recommends the right ones on the fly — you decide what to load and unload. Powered by a Karpathy LLM wiki with persistent memory that gets smarter every session.
38
+
39
+ ## Why it exists
40
+
41
+ - **Discovery** — with 1,700+ skills and 400+ agents, you can't possibly know which exist or which apply to your current repo.
42
+ - **Context budget** — loading everything wastes tokens and degrades quality. You need the right 10–15 per session.
43
+ - **Skill rot** — skills you installed months ago and never used are cluttering context. Stale ones should be flagged automatically.
44
+
45
+ ## Install
46
+
47
+ ```bash
48
+ pip install claude-ctx
49
+ ctx-init --hooks # one-shot setup: directories, hooks, starter toolboxes
50
+ ```
51
+
52
+ Optional extras: `pip install "claude-ctx[embeddings]"` for the semantic backend, `pip install "claude-ctx[dev]"` for the test toolchain.
53
+
54
+ ### Pre-built knowledge graph (optional)
55
+
56
+ A pre-built knowledge graph of 2,211 nodes and 642K edges ships as a tarball. Extract to get a ready-to-use `~/.claude/skill-wiki/`:
57
+
58
+ ```bash
59
+ # after `git clone` — or download graph/wiki-graph.tar.gz from the GitHub release
60
+ mkdir -p ~/.claude/skill-wiki
61
+ tar xzf graph/wiki-graph.tar.gz -C ~/.claude/skill-wiki/
62
+ ```
63
+
64
+ ## Use
65
+
66
+ After install, the `ctx` hooks integrate automatically with Claude Code's `PostToolUse` + `Stop` events. Typical flow:
67
+
68
+ ```bash
69
+ ctx-scan-repo --repo . # scan current repo, surface recommended skills/agents
70
+ ctx-skill-quality list # four-signal quality score for every skill
71
+ ctx-skill-quality explain python-patterns # drill into a single skill
72
+ ctx-skill-health dashboard # structural health + drift detection
73
+ ctx-toolbox run --event pre-commit # run a council on the current diff
74
+ ctx-monitor serve # local dashboard: http://127.0.0.1:8765/
75
+ ```
76
+
77
+ The **`ctx-monitor`** dashboard shows currently loaded skills with load/unload buttons, a cytoscape graph view (`/graph?slug=…`), the LLM-wiki entity browser (`/wiki/<slug>`), a filterable skills grid, a session timeline, an audit log viewer, and a live SSE event stream.
78
+
79
+ Full docs, architecture, and every module: **<https://stevesolun.github.io/ctx/>**
80
+
81
+ ## License
82
+
83
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,56 @@
1
+ # ctx — Skill & Agent Recommendation for Claude Code
2
+
3
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
4
+ [![Python 3.11+](https://img.shields.io/badge/Python-3.11+-green.svg)](https://python.org)
5
+ [![PyPI](https://img.shields.io/pypi/v/claude-ctx.svg)](https://pypi.org/project/claude-ctx/)
6
+ [![Tests](https://img.shields.io/badge/Tests-1363_passing-brightgreen.svg)](#)
7
+ [![Graph](https://img.shields.io/badge/Graph-2,211_nodes_/_642K_edges-red.svg)](graph/)
8
+ [![Docs](https://img.shields.io/badge/docs-MkDocs_Material-blue.svg)](https://stevesolun.github.io/ctx/)
9
+
10
+ Watches what you develop, walks a knowledge graph of **1,769 skills and 443 agents** (2,212 nodes, 885 edges, 865 communities), and recommends the right ones on the fly — you decide what to load and unload. Powered by a Karpathy LLM wiki with persistent memory that gets smarter every session.
11
+
12
+ ## Why it exists
13
+
14
+ - **Discovery** — with 1,700+ skills and 400+ agents, you can't possibly know which exist or which apply to your current repo.
15
+ - **Context budget** — loading everything wastes tokens and degrades quality. You need the right 10–15 per session.
16
+ - **Skill rot** — skills you installed months ago and never used are cluttering context. Stale ones should be flagged automatically.
17
+
18
+ ## Install
19
+
20
+ ```bash
21
+ pip install claude-ctx
22
+ ctx-init --hooks # one-shot setup: directories, hooks, starter toolboxes
23
+ ```
24
+
25
+ Optional extras: `pip install "claude-ctx[embeddings]"` for the semantic backend, `pip install "claude-ctx[dev]"` for the test toolchain.
26
+
27
+ ### Pre-built knowledge graph (optional)
28
+
29
+ A pre-built knowledge graph of 2,211 nodes and 642K edges ships as a tarball. Extract to get a ready-to-use `~/.claude/skill-wiki/`:
30
+
31
+ ```bash
32
+ # after `git clone` — or download graph/wiki-graph.tar.gz from the GitHub release
33
+ mkdir -p ~/.claude/skill-wiki
34
+ tar xzf graph/wiki-graph.tar.gz -C ~/.claude/skill-wiki/
35
+ ```
36
+
37
+ ## Use
38
+
39
+ After install, the `ctx` hooks integrate automatically with Claude Code's `PostToolUse` + `Stop` events. Typical flow:
40
+
41
+ ```bash
42
+ ctx-scan-repo --repo . # scan current repo, surface recommended skills/agents
43
+ ctx-skill-quality list # four-signal quality score for every skill
44
+ ctx-skill-quality explain python-patterns # drill into a single skill
45
+ ctx-skill-health dashboard # structural health + drift detection
46
+ ctx-toolbox run --event pre-commit # run a council on the current diff
47
+ ctx-monitor serve # local dashboard: http://127.0.0.1:8765/
48
+ ```
49
+
50
+ The **`ctx-monitor`** dashboard shows currently loaded skills with load/unload buttons, a cytoscape graph view (`/graph?slug=…`), the LLM-wiki entity browser (`/wiki/<slug>`), a filterable skills grid, a session timeline, an audit log viewer, and a live SSE event stream.
51
+
52
+ Full docs, architecture, and every module: **<https://stevesolun.github.io/ctx/>**
53
+
54
+ ## License
55
+
56
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,133 @@
1
+ [build-system]
2
+ requires = ["setuptools>=42"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "claude-ctx"
7
+ version = "0.5.0"
8
+ description = "Skill and agent recommendation system for Claude Code — knowledge graph, wiki, and intake quality gates"
9
+ authors = [{ name = "Steve Solun" }]
10
+ license = { text = "MIT" }
11
+ readme = "README.md"
12
+ requires-python = ">=3.11"
13
+
14
+ dependencies = [
15
+ "networkx>=3,<4",
16
+ "numpy>=1.24,<3",
17
+ "plotly>=5,<7",
18
+ "pyyaml>=6,<7",
19
+ ]
20
+
21
+ [project.urls]
22
+ Homepage = "https://stevesolun.github.io/ctx/"
23
+ Repository = "https://github.com/stevesolun/ctx"
24
+ Documentation = "https://stevesolun.github.io/ctx/"
25
+ Changelog = "https://github.com/stevesolun/ctx/blob/main/CHANGELOG.md"
26
+
27
+ [project.scripts]
28
+ ctx-init = "ctx_init:main"
29
+ ctx-install-hooks = "inject_hooks:main"
30
+ ctx-monitor = "ctx_monitor:main"
31
+ ctx-scan-repo = "scan_repo:main"
32
+ ctx-skill-quality = "skill_quality:main"
33
+ ctx-skill-health = "skill_health:main"
34
+ ctx-toolbox = "toolbox:main"
35
+ ctx-lifecycle = "ctx_lifecycle:main"
36
+ ctx-skill-add = "skill_add:main"
37
+ ctx-wiki-graphify = "wiki_graphify:main"
38
+
39
+ [project.optional-dependencies]
40
+ dev = [
41
+ "pytest>=8",
42
+ "pytest-cov>=5",
43
+ "mypy>=1.8",
44
+ "ruff>=0.4",
45
+ ]
46
+ embeddings = [
47
+ "sentence-transformers>=2,<4",
48
+ "torch>=2,<3",
49
+ ]
50
+
51
+ [tool.setuptools]
52
+ # src/ holds ~55 flat top-level modules (scan_repo.py, skill_quality.py, …),
53
+ # not a proper package tree. `packages.find` only discovers directories with
54
+ # __init__.py, so the earlier config packaged only `tests/` and shipped a
55
+ # broken wheel. List every runtime module explicitly via py-modules and tell
56
+ # setuptools to look for them under src/.
57
+ package-dir = {"" = "src"}
58
+ py-modules = [
59
+ "_file_lock",
60
+ "_fs_utils",
61
+ "agent_add",
62
+ "backup_config",
63
+ "backup_mirror",
64
+ "backup_retention",
65
+ "backup_watchdog",
66
+ "batch_convert",
67
+ "behavior_miner",
68
+ "catalog_builder",
69
+ "change_detector",
70
+ "context_monitor",
71
+ "corpus_cache",
72
+ "cosine_ranker",
73
+ "council_runner",
74
+ "ctx_audit_log",
75
+ "ctx_config",
76
+ "ctx_init",
77
+ "ctx_lifecycle",
78
+ "ctx_monitor",
79
+ "embedding_backend",
80
+ "flatten_agents",
81
+ "import_strix_skills",
82
+ "inject_hooks",
83
+ "intake_gate",
84
+ "intake_pipeline",
85
+ "intent_interview",
86
+ "kpi_dashboard",
87
+ "link_conversions",
88
+ "memory_anchor",
89
+ "quality_signals",
90
+ "resolve_graph",
91
+ "resolve_skills",
92
+ "scan_repo",
93
+ "skill_add",
94
+ "skill_add_detector",
95
+ "skill_category",
96
+ "skill_health",
97
+ "skill_loader",
98
+ "skill_quality",
99
+ "skill_suggest",
100
+ "skill_telemetry",
101
+ "skill_unload",
102
+ "toolbox",
103
+ "toolbox_config",
104
+ "toolbox_hooks",
105
+ "toolbox_verdict",
106
+ "update_repo_stats",
107
+ "usage_tracker",
108
+ "versions_catalog",
109
+ "wiki_batch_entities",
110
+ "wiki_graphify",
111
+ "wiki_lint",
112
+ "wiki_orchestrator",
113
+ "wiki_query",
114
+ "wiki_sync",
115
+ "wiki_utils",
116
+ "wiki_visualize",
117
+ ]
118
+ # Do NOT ship the test suite to PyPI. The previous build inadvertently
119
+ # bundled src/tests/ because it was the only directory with __init__.py.
120
+ packages = []
121
+
122
+ [tool.setuptools.package-data]
123
+ "*" = ["config.json", "skill-registry.json"]
124
+
125
+ [tool.pytest.ini_options]
126
+ testpaths = ["src/tests"]
127
+ markers = [
128
+ "integration: marks tests that require external models or network (deselect with -m 'not integration')",
129
+ ]
130
+
131
+ [tool.ruff]
132
+ line-length = 100
133
+ target-version = "py311"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,94 @@
1
+ """
2
+ _file_lock.py -- Cross-platform advisory file lock.
3
+
4
+ Used by the toolbox and skill-health modules to serialize read-modify-write
5
+ cycles on shared config/manifest files (e.g. ~/.claude/skill-manifest.json,
6
+ ~/.claude/toolbox-runs/<hash>.verdict.json) so that concurrent agent sessions
7
+ do not clobber each other's writes.
8
+
9
+ Usage:
10
+
11
+ with file_lock(manifest_path):
12
+ data = json.loads(manifest_path.read_text())
13
+ data["load"].append(...)
14
+ manifest_path.write_text(json.dumps(data))
15
+
16
+ The lock is advisory -- it only blocks other callers that also use ``file_lock``.
17
+ It does not protect against processes that ignore locking.
18
+
19
+ On POSIX we use fcntl.flock (whole-file exclusive). On Windows we use
20
+ msvcrt.locking on the companion .lock file so we don't hold a handle to the
21
+ file the caller is about to replace.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import os
27
+ import sys
28
+ from contextlib import contextmanager
29
+ from pathlib import Path
30
+ from typing import Iterator
31
+
32
+ if sys.platform == "win32":
33
+ import msvcrt # type: ignore[import-not-found]
34
+ else:
35
+ import fcntl # type: ignore[import-not-found]
36
+
37
+
38
+ @contextmanager
39
+ def file_lock(target: Path, timeout: float = 10.0) -> Iterator[None]:
40
+ """Acquire an exclusive advisory lock on ``target``.
41
+
42
+ Creates ``target.with_suffix(target.suffix + '.lock')`` as the lock
43
+ file so we don't hold a handle to the target itself (which callers
44
+ typically replace via ``os.replace``). The lock file is left on disk
45
+ after release -- it's cheap and avoids a race where two processes
46
+ both try to create-and-lock at once.
47
+
48
+ Raises TimeoutError if the lock cannot be acquired within ``timeout``.
49
+ """
50
+ target = Path(target)
51
+ target.parent.mkdir(parents=True, exist_ok=True)
52
+ lock_path = target.with_suffix(target.suffix + ".lock")
53
+ lock_path.touch(exist_ok=True)
54
+
55
+ fd = os.open(str(lock_path), os.O_RDWR)
56
+ try:
57
+ _acquire(fd, timeout)
58
+ try:
59
+ yield
60
+ finally:
61
+ _release(fd)
62
+ finally:
63
+ os.close(fd)
64
+
65
+
66
+ def _acquire(fd: int, timeout: float) -> None:
67
+ import time
68
+ deadline = time.monotonic() + max(0.0, timeout)
69
+ while True:
70
+ try:
71
+ if sys.platform == "win32":
72
+ # Lock 1 byte at offset 0 non-blockingly.
73
+ msvcrt.locking(fd, msvcrt.LK_NBLCK, 1)
74
+ else:
75
+ fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
76
+ return
77
+ except (OSError, BlockingIOError):
78
+ if time.monotonic() >= deadline:
79
+ raise TimeoutError("file_lock: timed out acquiring lock")
80
+ time.sleep(0.05)
81
+
82
+
83
+ def _release(fd: int) -> None:
84
+ try:
85
+ if sys.platform == "win32":
86
+ try:
87
+ os.lseek(fd, 0, os.SEEK_SET)
88
+ msvcrt.locking(fd, msvcrt.LK_UNLCK, 1)
89
+ except OSError:
90
+ pass
91
+ else:
92
+ fcntl.flock(fd, fcntl.LOCK_UN)
93
+ except OSError:
94
+ pass
@@ -0,0 +1,104 @@
1
+ """
2
+ _fs_utils.py -- Shared atomic file-write helpers for the ctx project.
3
+
4
+ Why this exists: 14 modules independently implemented nearly identical
5
+ ``_atomic_write`` / ``_atomic_write_text`` private functions, leading to
6
+ subtle divergences (missing Windows retry, missing parent-dir creation,
7
+ predictable temp names). This module provides a single hardened
8
+ implementation that all of them delegate to.
9
+
10
+ The ``atomic_write_*`` family writes via a temp file in the same directory
11
+ as the target, then calls ``os.replace()`` which is atomic on POSIX and
12
+ best-effort atomic on Windows. On Windows, ``os.replace()`` raises
13
+ ``PermissionError`` if the destination is held open; we retry 3 times with
14
+ a 50 ms sleep between attempts before re-raising.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import os
21
+ import tempfile
22
+ import time
23
+ from pathlib import Path
24
+ from typing import Any
25
+
26
+ __all__ = ["atomic_write_text", "atomic_write_bytes", "atomic_write_json"]
27
+
28
+
29
+ def atomic_write_text(path: Path, text: str, encoding: str = "utf-8") -> None:
30
+ """Write *text* to *path* atomically.
31
+
32
+ Uses a temp file in the same directory so that the final ``os.replace``
33
+ stays on the same filesystem (avoids cross-device rename failures).
34
+ Creates parent directories if they are missing.
35
+ """
36
+ path.parent.mkdir(parents=True, exist_ok=True)
37
+ fd, tmp = tempfile.mkstemp(prefix=path.name + ".", dir=str(path.parent))
38
+ try:
39
+ with os.fdopen(fd, "w", encoding=encoding) as fh:
40
+ fh.write(text)
41
+ _replace_with_retry(tmp, path)
42
+ except Exception:
43
+ _unlink_silent(tmp)
44
+ raise
45
+
46
+
47
+ def atomic_write_bytes(path: Path, data: bytes) -> None:
48
+ """Write raw *data* to *path* atomically.
49
+
50
+ Same temp-file-in-same-dir + ``os.replace`` strategy as
51
+ :func:`atomic_write_text`. Creates parent directories if missing.
52
+ """
53
+ path.parent.mkdir(parents=True, exist_ok=True)
54
+ fd, tmp = tempfile.mkstemp(prefix=path.name + ".", dir=str(path.parent))
55
+ try:
56
+ with os.fdopen(fd, "wb") as fh:
57
+ fh.write(data)
58
+ _replace_with_retry(tmp, path)
59
+ except Exception:
60
+ _unlink_silent(tmp)
61
+ raise
62
+
63
+
64
+ def atomic_write_json(path: Path, obj: Any, indent: int | None = 2) -> None:
65
+ """Serialise *obj* as JSON and write to *path* atomically.
66
+
67
+ Produces a trailing newline for clean diffs. Uses UTF-8 encoding.
68
+ Creates parent directories if missing.
69
+ """
70
+ atomic_write_text(path, json.dumps(obj, indent=indent) + "\n", encoding="utf-8")
71
+
72
+
73
+ # ── Internal helpers ──────────────────────────────────────────────────────────
74
+
75
+
76
+ def _replace_with_retry(src: str, dst: Path, *, attempts: int = 10, delay: float = 0.05) -> None:
77
+ """Call ``os.replace(src, dst)``, retrying on ``PermissionError``.
78
+
79
+ On POSIX, ``os.replace`` is a single atomic syscall. On Windows it can
80
+ raise ``PermissionError`` when another process or thread holds the
81
+ destination open — or even just a transient AV/indexer read handle.
82
+ We retry *attempts* times, sleeping *delay* seconds between each try.
83
+
84
+ Defaults (10 * 50ms = 500ms max) were tuned after CI flakes under
85
+ 8-thread concurrent writes on windows-latest; 3 * 50ms was not enough
86
+ under load. 500ms total is still fast for interactive work.
87
+ """
88
+ last_exc: Exception | None = None
89
+ for _ in range(attempts):
90
+ try:
91
+ os.replace(src, dst)
92
+ return
93
+ except PermissionError as exc:
94
+ last_exc = exc
95
+ time.sleep(delay)
96
+ raise last_exc # type: ignore[misc]
97
+
98
+
99
+ def _unlink_silent(path: str) -> None:
100
+ """Delete *path* without raising if it is already gone."""
101
+ try:
102
+ os.unlink(path)
103
+ except OSError:
104
+ pass