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.
- claude_ctx-0.5.0/LICENSE +21 -0
- claude_ctx-0.5.0/PKG-INFO +83 -0
- claude_ctx-0.5.0/README.md +56 -0
- claude_ctx-0.5.0/pyproject.toml +133 -0
- claude_ctx-0.5.0/setup.cfg +4 -0
- claude_ctx-0.5.0/src/_file_lock.py +94 -0
- claude_ctx-0.5.0/src/_fs_utils.py +104 -0
- claude_ctx-0.5.0/src/agent_add.py +229 -0
- claude_ctx-0.5.0/src/backup_config.py +295 -0
- claude_ctx-0.5.0/src/backup_mirror.py +981 -0
- claude_ctx-0.5.0/src/backup_retention.py +138 -0
- claude_ctx-0.5.0/src/backup_watchdog.py +175 -0
- claude_ctx-0.5.0/src/batch_convert.py +536 -0
- claude_ctx-0.5.0/src/behavior_miner.py +496 -0
- claude_ctx-0.5.0/src/catalog_builder.py +226 -0
- claude_ctx-0.5.0/src/change_detector.py +255 -0
- claude_ctx-0.5.0/src/claude_ctx.egg-info/PKG-INFO +83 -0
- claude_ctx-0.5.0/src/claude_ctx.egg-info/SOURCES.txt +67 -0
- claude_ctx-0.5.0/src/claude_ctx.egg-info/dependency_links.txt +1 -0
- claude_ctx-0.5.0/src/claude_ctx.egg-info/entry_points.txt +11 -0
- claude_ctx-0.5.0/src/claude_ctx.egg-info/requires.txt +14 -0
- claude_ctx-0.5.0/src/claude_ctx.egg-info/top_level.txt +58 -0
- claude_ctx-0.5.0/src/context_monitor.py +370 -0
- claude_ctx-0.5.0/src/corpus_cache.py +301 -0
- claude_ctx-0.5.0/src/cosine_ranker.py +180 -0
- claude_ctx-0.5.0/src/council_runner.py +359 -0
- claude_ctx-0.5.0/src/ctx_audit_log.py +249 -0
- claude_ctx-0.5.0/src/ctx_config.py +220 -0
- claude_ctx-0.5.0/src/ctx_init.py +233 -0
- claude_ctx-0.5.0/src/ctx_lifecycle.py +1067 -0
- claude_ctx-0.5.0/src/ctx_monitor.py +1283 -0
- claude_ctx-0.5.0/src/embedding_backend.py +231 -0
- claude_ctx-0.5.0/src/flatten_agents.py +125 -0
- claude_ctx-0.5.0/src/import_strix_skills.py +187 -0
- claude_ctx-0.5.0/src/inject_hooks.py +241 -0
- claude_ctx-0.5.0/src/intake_gate.py +378 -0
- claude_ctx-0.5.0/src/intake_pipeline.py +157 -0
- claude_ctx-0.5.0/src/intent_interview.py +705 -0
- claude_ctx-0.5.0/src/kpi_dashboard.py +563 -0
- claude_ctx-0.5.0/src/link_conversions.py +424 -0
- claude_ctx-0.5.0/src/memory_anchor.py +361 -0
- claude_ctx-0.5.0/src/quality_signals.py +333 -0
- claude_ctx-0.5.0/src/resolve_graph.py +222 -0
- claude_ctx-0.5.0/src/resolve_skills.py +519 -0
- claude_ctx-0.5.0/src/scan_repo.py +570 -0
- claude_ctx-0.5.0/src/skill_add.py +448 -0
- claude_ctx-0.5.0/src/skill_add_detector.py +235 -0
- claude_ctx-0.5.0/src/skill_category.py +366 -0
- claude_ctx-0.5.0/src/skill_health.py +573 -0
- claude_ctx-0.5.0/src/skill_loader.py +214 -0
- claude_ctx-0.5.0/src/skill_quality.py +1241 -0
- claude_ctx-0.5.0/src/skill_suggest.py +131 -0
- claude_ctx-0.5.0/src/skill_telemetry.py +305 -0
- claude_ctx-0.5.0/src/skill_unload.py +334 -0
- claude_ctx-0.5.0/src/toolbox.py +390 -0
- claude_ctx-0.5.0/src/toolbox_config.py +321 -0
- claude_ctx-0.5.0/src/toolbox_hooks.py +217 -0
- claude_ctx-0.5.0/src/toolbox_verdict.py +542 -0
- claude_ctx-0.5.0/src/update_repo_stats.py +247 -0
- claude_ctx-0.5.0/src/usage_tracker.py +353 -0
- claude_ctx-0.5.0/src/versions_catalog.py +216 -0
- claude_ctx-0.5.0/src/wiki_batch_entities.py +341 -0
- claude_ctx-0.5.0/src/wiki_graphify.py +394 -0
- claude_ctx-0.5.0/src/wiki_lint.py +385 -0
- claude_ctx-0.5.0/src/wiki_orchestrator.py +622 -0
- claude_ctx-0.5.0/src/wiki_query.py +400 -0
- claude_ctx-0.5.0/src/wiki_sync.py +401 -0
- claude_ctx-0.5.0/src/wiki_utils.py +93 -0
- claude_ctx-0.5.0/src/wiki_visualize.py +739 -0
claude_ctx-0.5.0/LICENSE
ADDED
|
@@ -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)
|
|
31
|
+
[](https://python.org)
|
|
32
|
+
[](https://pypi.org/project/claude-ctx/)
|
|
33
|
+
[](#)
|
|
34
|
+
[](graph/)
|
|
35
|
+
[](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)
|
|
4
|
+
[](https://python.org)
|
|
5
|
+
[](https://pypi.org/project/claude-ctx/)
|
|
6
|
+
[](#)
|
|
7
|
+
[](graph/)
|
|
8
|
+
[](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,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
|