htmlgraph 0.20.1__py3-none-any.whl → 0.27.5__py3-none-any.whl

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 (304) hide show
  1. htmlgraph/.htmlgraph/.session-warning-state.json +6 -0
  2. htmlgraph/.htmlgraph/agents.json +72 -0
  3. htmlgraph/.htmlgraph/htmlgraph.db +0 -0
  4. htmlgraph/__init__.py +51 -1
  5. htmlgraph/__init__.pyi +123 -0
  6. htmlgraph/agent_detection.py +26 -10
  7. htmlgraph/agent_registry.py +2 -1
  8. htmlgraph/analytics/__init__.py +8 -1
  9. htmlgraph/analytics/cli.py +86 -20
  10. htmlgraph/analytics/cost_analyzer.py +391 -0
  11. htmlgraph/analytics/cost_monitor.py +664 -0
  12. htmlgraph/analytics/cost_reporter.py +675 -0
  13. htmlgraph/analytics/cross_session.py +617 -0
  14. htmlgraph/analytics/dependency.py +10 -6
  15. htmlgraph/analytics/pattern_learning.py +771 -0
  16. htmlgraph/analytics/session_graph.py +707 -0
  17. htmlgraph/analytics/strategic/__init__.py +80 -0
  18. htmlgraph/analytics/strategic/cost_optimizer.py +611 -0
  19. htmlgraph/analytics/strategic/pattern_detector.py +876 -0
  20. htmlgraph/analytics/strategic/preference_manager.py +709 -0
  21. htmlgraph/analytics/strategic/suggestion_engine.py +747 -0
  22. htmlgraph/analytics/work_type.py +67 -27
  23. htmlgraph/analytics_index.py +53 -20
  24. htmlgraph/api/__init__.py +3 -0
  25. htmlgraph/api/cost_alerts_websocket.py +416 -0
  26. htmlgraph/api/main.py +2498 -0
  27. htmlgraph/api/static/htmx.min.js +1 -0
  28. htmlgraph/api/static/style-redesign.css +1344 -0
  29. htmlgraph/api/static/style.css +1079 -0
  30. htmlgraph/api/templates/dashboard-redesign.html +1366 -0
  31. htmlgraph/api/templates/dashboard.html +794 -0
  32. htmlgraph/api/templates/partials/activity-feed-hierarchical.html +326 -0
  33. htmlgraph/api/templates/partials/activity-feed.html +1100 -0
  34. htmlgraph/api/templates/partials/agents-redesign.html +317 -0
  35. htmlgraph/api/templates/partials/agents.html +317 -0
  36. htmlgraph/api/templates/partials/event-traces.html +373 -0
  37. htmlgraph/api/templates/partials/features-kanban-redesign.html +509 -0
  38. htmlgraph/api/templates/partials/features.html +578 -0
  39. htmlgraph/api/templates/partials/metrics-redesign.html +346 -0
  40. htmlgraph/api/templates/partials/metrics.html +346 -0
  41. htmlgraph/api/templates/partials/orchestration-redesign.html +443 -0
  42. htmlgraph/api/templates/partials/orchestration.html +198 -0
  43. htmlgraph/api/templates/partials/spawners.html +375 -0
  44. htmlgraph/api/templates/partials/work-items.html +613 -0
  45. htmlgraph/api/websocket.py +538 -0
  46. htmlgraph/archive/__init__.py +24 -0
  47. htmlgraph/archive/bloom.py +234 -0
  48. htmlgraph/archive/fts.py +297 -0
  49. htmlgraph/archive/manager.py +583 -0
  50. htmlgraph/archive/search.py +244 -0
  51. htmlgraph/atomic_ops.py +560 -0
  52. htmlgraph/attribute_index.py +2 -1
  53. htmlgraph/bounded_paths.py +539 -0
  54. htmlgraph/builders/base.py +57 -2
  55. htmlgraph/builders/bug.py +19 -3
  56. htmlgraph/builders/chore.py +19 -3
  57. htmlgraph/builders/epic.py +19 -3
  58. htmlgraph/builders/feature.py +27 -3
  59. htmlgraph/builders/insight.py +2 -1
  60. htmlgraph/builders/metric.py +2 -1
  61. htmlgraph/builders/pattern.py +2 -1
  62. htmlgraph/builders/phase.py +19 -3
  63. htmlgraph/builders/spike.py +29 -3
  64. htmlgraph/builders/track.py +42 -1
  65. htmlgraph/cigs/__init__.py +81 -0
  66. htmlgraph/cigs/autonomy.py +385 -0
  67. htmlgraph/cigs/cost.py +475 -0
  68. htmlgraph/cigs/messages_basic.py +472 -0
  69. htmlgraph/cigs/messaging.py +365 -0
  70. htmlgraph/cigs/models.py +771 -0
  71. htmlgraph/cigs/pattern_storage.py +427 -0
  72. htmlgraph/cigs/patterns.py +503 -0
  73. htmlgraph/cigs/posttool_analyzer.py +234 -0
  74. htmlgraph/cigs/reporter.py +818 -0
  75. htmlgraph/cigs/tracker.py +317 -0
  76. htmlgraph/cli/.htmlgraph/.session-warning-state.json +6 -0
  77. htmlgraph/cli/.htmlgraph/agents.json +72 -0
  78. htmlgraph/cli/.htmlgraph/htmlgraph.db +0 -0
  79. htmlgraph/cli/__init__.py +42 -0
  80. htmlgraph/cli/__main__.py +6 -0
  81. htmlgraph/cli/analytics.py +1424 -0
  82. htmlgraph/cli/base.py +685 -0
  83. htmlgraph/cli/constants.py +206 -0
  84. htmlgraph/cli/core.py +954 -0
  85. htmlgraph/cli/main.py +147 -0
  86. htmlgraph/cli/models.py +475 -0
  87. htmlgraph/cli/templates/__init__.py +1 -0
  88. htmlgraph/cli/templates/cost_dashboard.py +399 -0
  89. htmlgraph/cli/work/__init__.py +239 -0
  90. htmlgraph/cli/work/browse.py +115 -0
  91. htmlgraph/cli/work/features.py +568 -0
  92. htmlgraph/cli/work/orchestration.py +676 -0
  93. htmlgraph/cli/work/report.py +728 -0
  94. htmlgraph/cli/work/sessions.py +466 -0
  95. htmlgraph/cli/work/snapshot.py +559 -0
  96. htmlgraph/cli/work/tracks.py +486 -0
  97. htmlgraph/cli_commands/__init__.py +1 -0
  98. htmlgraph/cli_commands/feature.py +195 -0
  99. htmlgraph/cli_framework.py +115 -0
  100. htmlgraph/collections/__init__.py +2 -0
  101. htmlgraph/collections/base.py +197 -14
  102. htmlgraph/collections/bug.py +2 -1
  103. htmlgraph/collections/chore.py +2 -1
  104. htmlgraph/collections/epic.py +2 -1
  105. htmlgraph/collections/feature.py +2 -1
  106. htmlgraph/collections/insight.py +2 -1
  107. htmlgraph/collections/metric.py +2 -1
  108. htmlgraph/collections/pattern.py +2 -1
  109. htmlgraph/collections/phase.py +2 -1
  110. htmlgraph/collections/session.py +194 -0
  111. htmlgraph/collections/spike.py +13 -2
  112. htmlgraph/collections/task_delegation.py +241 -0
  113. htmlgraph/collections/todo.py +14 -1
  114. htmlgraph/collections/traces.py +487 -0
  115. htmlgraph/config/cost_models.json +56 -0
  116. htmlgraph/config.py +190 -0
  117. htmlgraph/context_analytics.py +2 -1
  118. htmlgraph/converter.py +116 -7
  119. htmlgraph/cost_analysis/__init__.py +5 -0
  120. htmlgraph/cost_analysis/analyzer.py +438 -0
  121. htmlgraph/dashboard.html +2246 -248
  122. htmlgraph/dashboard.html.backup +6592 -0
  123. htmlgraph/dashboard.html.bak +7181 -0
  124. htmlgraph/dashboard.html.bak2 +7231 -0
  125. htmlgraph/dashboard.html.bak3 +7232 -0
  126. htmlgraph/db/__init__.py +38 -0
  127. htmlgraph/db/queries.py +790 -0
  128. htmlgraph/db/schema.py +1788 -0
  129. htmlgraph/decorators.py +317 -0
  130. htmlgraph/dependency_models.py +2 -1
  131. htmlgraph/deploy.py +26 -27
  132. htmlgraph/docs/API_REFERENCE.md +841 -0
  133. htmlgraph/docs/HTTP_API.md +750 -0
  134. htmlgraph/docs/INTEGRATION_GUIDE.md +752 -0
  135. htmlgraph/docs/ORCHESTRATION_PATTERNS.md +717 -0
  136. htmlgraph/docs/README.md +532 -0
  137. htmlgraph/docs/__init__.py +77 -0
  138. htmlgraph/docs/docs_version.py +55 -0
  139. htmlgraph/docs/metadata.py +93 -0
  140. htmlgraph/docs/migrations.py +232 -0
  141. htmlgraph/docs/template_engine.py +143 -0
  142. htmlgraph/docs/templates/_sections/cli_reference.md.j2 +52 -0
  143. htmlgraph/docs/templates/_sections/core_concepts.md.j2 +29 -0
  144. htmlgraph/docs/templates/_sections/sdk_basics.md.j2 +69 -0
  145. htmlgraph/docs/templates/base_agents.md.j2 +78 -0
  146. htmlgraph/docs/templates/example_user_override.md.j2 +47 -0
  147. htmlgraph/docs/version_check.py +163 -0
  148. htmlgraph/edge_index.py +2 -1
  149. htmlgraph/error_handler.py +544 -0
  150. htmlgraph/event_log.py +86 -37
  151. htmlgraph/event_migration.py +2 -1
  152. htmlgraph/file_watcher.py +12 -8
  153. htmlgraph/find_api.py +2 -1
  154. htmlgraph/git_events.py +67 -9
  155. htmlgraph/hooks/.htmlgraph/.session-warning-state.json +6 -0
  156. htmlgraph/hooks/.htmlgraph/agents.json +72 -0
  157. htmlgraph/hooks/.htmlgraph/index.sqlite +0 -0
  158. htmlgraph/hooks/__init__.py +8 -0
  159. htmlgraph/hooks/bootstrap.py +169 -0
  160. htmlgraph/hooks/cigs_pretool_enforcer.py +354 -0
  161. htmlgraph/hooks/concurrent_sessions.py +208 -0
  162. htmlgraph/hooks/context.py +350 -0
  163. htmlgraph/hooks/drift_handler.py +525 -0
  164. htmlgraph/hooks/event_tracker.py +790 -99
  165. htmlgraph/hooks/git_commands.py +175 -0
  166. htmlgraph/hooks/installer.py +5 -1
  167. htmlgraph/hooks/orchestrator.py +327 -76
  168. htmlgraph/hooks/orchestrator_reflector.py +31 -4
  169. htmlgraph/hooks/post_tool_use_failure.py +32 -7
  170. htmlgraph/hooks/post_tool_use_handler.py +257 -0
  171. htmlgraph/hooks/posttooluse.py +92 -19
  172. htmlgraph/hooks/pretooluse.py +527 -7
  173. htmlgraph/hooks/prompt_analyzer.py +637 -0
  174. htmlgraph/hooks/session_handler.py +668 -0
  175. htmlgraph/hooks/session_summary.py +395 -0
  176. htmlgraph/hooks/state_manager.py +504 -0
  177. htmlgraph/hooks/subagent_detection.py +202 -0
  178. htmlgraph/hooks/subagent_stop.py +369 -0
  179. htmlgraph/hooks/task_enforcer.py +99 -4
  180. htmlgraph/hooks/validator.py +212 -91
  181. htmlgraph/ids.py +2 -1
  182. htmlgraph/learning.py +125 -100
  183. htmlgraph/mcp_server.py +2 -1
  184. htmlgraph/models.py +217 -18
  185. htmlgraph/operations/README.md +62 -0
  186. htmlgraph/operations/__init__.py +79 -0
  187. htmlgraph/operations/analytics.py +339 -0
  188. htmlgraph/operations/bootstrap.py +289 -0
  189. htmlgraph/operations/events.py +244 -0
  190. htmlgraph/operations/fastapi_server.py +231 -0
  191. htmlgraph/operations/hooks.py +350 -0
  192. htmlgraph/operations/initialization.py +597 -0
  193. htmlgraph/operations/initialization.py.backup +228 -0
  194. htmlgraph/operations/server.py +303 -0
  195. htmlgraph/orchestration/__init__.py +58 -0
  196. htmlgraph/orchestration/claude_launcher.py +179 -0
  197. htmlgraph/orchestration/command_builder.py +72 -0
  198. htmlgraph/orchestration/headless_spawner.py +281 -0
  199. htmlgraph/orchestration/live_events.py +377 -0
  200. htmlgraph/orchestration/model_selection.py +327 -0
  201. htmlgraph/orchestration/plugin_manager.py +140 -0
  202. htmlgraph/orchestration/prompts.py +137 -0
  203. htmlgraph/orchestration/spawner_event_tracker.py +383 -0
  204. htmlgraph/orchestration/spawners/__init__.py +16 -0
  205. htmlgraph/orchestration/spawners/base.py +194 -0
  206. htmlgraph/orchestration/spawners/claude.py +173 -0
  207. htmlgraph/orchestration/spawners/codex.py +435 -0
  208. htmlgraph/orchestration/spawners/copilot.py +294 -0
  209. htmlgraph/orchestration/spawners/gemini.py +471 -0
  210. htmlgraph/orchestration/subprocess_runner.py +36 -0
  211. htmlgraph/{orchestration.py → orchestration/task_coordination.py} +16 -8
  212. htmlgraph/orchestration.md +563 -0
  213. htmlgraph/orchestrator-system-prompt-optimized.txt +863 -0
  214. htmlgraph/orchestrator.py +2 -1
  215. htmlgraph/orchestrator_config.py +357 -0
  216. htmlgraph/orchestrator_mode.py +115 -4
  217. htmlgraph/parallel.py +2 -1
  218. htmlgraph/parser.py +86 -6
  219. htmlgraph/path_query.py +608 -0
  220. htmlgraph/pattern_matcher.py +636 -0
  221. htmlgraph/pydantic_models.py +476 -0
  222. htmlgraph/quality_gates.py +350 -0
  223. htmlgraph/query_builder.py +2 -1
  224. htmlgraph/query_composer.py +509 -0
  225. htmlgraph/reflection.py +443 -0
  226. htmlgraph/refs.py +344 -0
  227. htmlgraph/repo_hash.py +512 -0
  228. htmlgraph/repositories/__init__.py +292 -0
  229. htmlgraph/repositories/analytics_repository.py +455 -0
  230. htmlgraph/repositories/analytics_repository_standard.py +628 -0
  231. htmlgraph/repositories/feature_repository.py +581 -0
  232. htmlgraph/repositories/feature_repository_htmlfile.py +668 -0
  233. htmlgraph/repositories/feature_repository_memory.py +607 -0
  234. htmlgraph/repositories/feature_repository_sqlite.py +858 -0
  235. htmlgraph/repositories/filter_service.py +620 -0
  236. htmlgraph/repositories/filter_service_standard.py +445 -0
  237. htmlgraph/repositories/shared_cache.py +621 -0
  238. htmlgraph/repositories/shared_cache_memory.py +395 -0
  239. htmlgraph/repositories/track_repository.py +552 -0
  240. htmlgraph/repositories/track_repository_htmlfile.py +619 -0
  241. htmlgraph/repositories/track_repository_memory.py +508 -0
  242. htmlgraph/repositories/track_repository_sqlite.py +711 -0
  243. htmlgraph/sdk/__init__.py +398 -0
  244. htmlgraph/sdk/__init__.pyi +14 -0
  245. htmlgraph/sdk/analytics/__init__.py +19 -0
  246. htmlgraph/sdk/analytics/engine.py +155 -0
  247. htmlgraph/sdk/analytics/helpers.py +178 -0
  248. htmlgraph/sdk/analytics/registry.py +109 -0
  249. htmlgraph/sdk/base.py +484 -0
  250. htmlgraph/sdk/constants.py +216 -0
  251. htmlgraph/sdk/core.pyi +308 -0
  252. htmlgraph/sdk/discovery.py +120 -0
  253. htmlgraph/sdk/help/__init__.py +12 -0
  254. htmlgraph/sdk/help/mixin.py +699 -0
  255. htmlgraph/sdk/mixins/__init__.py +15 -0
  256. htmlgraph/sdk/mixins/attribution.py +113 -0
  257. htmlgraph/sdk/mixins/mixin.py +410 -0
  258. htmlgraph/sdk/operations/__init__.py +12 -0
  259. htmlgraph/sdk/operations/mixin.py +427 -0
  260. htmlgraph/sdk/orchestration/__init__.py +17 -0
  261. htmlgraph/sdk/orchestration/coordinator.py +203 -0
  262. htmlgraph/sdk/orchestration/spawner.py +204 -0
  263. htmlgraph/sdk/planning/__init__.py +19 -0
  264. htmlgraph/sdk/planning/bottlenecks.py +93 -0
  265. htmlgraph/sdk/planning/mixin.py +211 -0
  266. htmlgraph/sdk/planning/parallel.py +186 -0
  267. htmlgraph/sdk/planning/queue.py +210 -0
  268. htmlgraph/sdk/planning/recommendations.py +87 -0
  269. htmlgraph/sdk/planning/smart_planning.py +319 -0
  270. htmlgraph/sdk/session/__init__.py +19 -0
  271. htmlgraph/sdk/session/continuity.py +57 -0
  272. htmlgraph/sdk/session/handoff.py +110 -0
  273. htmlgraph/sdk/session/info.py +309 -0
  274. htmlgraph/sdk/session/manager.py +103 -0
  275. htmlgraph/sdk/strategic/__init__.py +26 -0
  276. htmlgraph/sdk/strategic/mixin.py +563 -0
  277. htmlgraph/server.py +295 -107
  278. htmlgraph/session_hooks.py +300 -0
  279. htmlgraph/session_manager.py +285 -3
  280. htmlgraph/session_registry.py +587 -0
  281. htmlgraph/session_state.py +436 -0
  282. htmlgraph/session_warning.py +2 -1
  283. htmlgraph/sessions/__init__.py +23 -0
  284. htmlgraph/sessions/handoff.py +756 -0
  285. htmlgraph/system_prompts.py +450 -0
  286. htmlgraph/templates/orchestration-view.html +350 -0
  287. htmlgraph/track_builder.py +33 -1
  288. htmlgraph/track_manager.py +38 -0
  289. htmlgraph/transcript.py +18 -5
  290. htmlgraph/validation.py +115 -0
  291. htmlgraph/watch.py +2 -1
  292. htmlgraph/work_type_utils.py +2 -1
  293. {htmlgraph-0.20.1.data → htmlgraph-0.27.5.data}/data/htmlgraph/dashboard.html +2246 -248
  294. {htmlgraph-0.20.1.dist-info → htmlgraph-0.27.5.dist-info}/METADATA +95 -64
  295. htmlgraph-0.27.5.dist-info/RECORD +337 -0
  296. {htmlgraph-0.20.1.dist-info → htmlgraph-0.27.5.dist-info}/entry_points.txt +1 -1
  297. htmlgraph/cli.py +0 -4839
  298. htmlgraph/sdk.py +0 -2359
  299. htmlgraph-0.20.1.dist-info/RECORD +0 -118
  300. {htmlgraph-0.20.1.data → htmlgraph-0.27.5.data}/data/htmlgraph/styles.css +0 -0
  301. {htmlgraph-0.20.1.data → htmlgraph-0.27.5.data}/data/htmlgraph/templates/AGENTS.md.template +0 -0
  302. {htmlgraph-0.20.1.data → htmlgraph-0.27.5.data}/data/htmlgraph/templates/CLAUDE.md.template +0 -0
  303. {htmlgraph-0.20.1.data → htmlgraph-0.27.5.data}/data/htmlgraph/templates/GEMINI.md.template +0 -0
  304. {htmlgraph-0.20.1.dist-info → htmlgraph-0.27.5.dist-info}/WHEEL +0 -0
@@ -0,0 +1,560 @@
1
+ """
2
+ Atomic file operations with crash-safe handling.
3
+
4
+ Provides atomic file write operations that prevent partial file corruption
5
+ through a temp-file-and-rename pattern. Crash-safe without requiring locks
6
+ or external dependencies.
7
+
8
+ Key Features:
9
+ - Atomic writes via temp file + rename pattern
10
+ - Platform-aware (Windows, macOS, Linux)
11
+ - No external dependencies (stdlib only: os, pathlib, tempfile)
12
+ - Retry logic for concurrent access
13
+ - Orphaned temp file cleanup
14
+ - Type hints and comprehensive docstrings
15
+
16
+ Architecture:
17
+ - AtomicFileWriter: Context manager for streaming writes
18
+ - DirectoryLocker: Lightweight coordination via marker files
19
+ - atomic_rename: Platform-aware rename operation
20
+ - safe_temp_file: Create unique temp file paths
21
+ - cleanup_orphaned_temp_files: Cleanup crashed writes
22
+
23
+ Usage:
24
+ # Method 1: Context manager (streaming writes)
25
+ from htmlgraph.atomic_ops import AtomicFileWriter
26
+
27
+ with AtomicFileWriter(Path("target.txt")) as f:
28
+ f.write("content")
29
+ # File is committed atomically when context exits
30
+
31
+ # Method 2: Simple atomic write
32
+ from htmlgraph.atomic_ops import AtomicFileWriter
33
+ AtomicFileWriter.atomic_write(Path("target.txt"), "content")
34
+
35
+ # Method 3: Atomic JSON write
36
+ from htmlgraph.atomic_ops import AtomicFileWriter
37
+ AtomicFileWriter.atomic_json_write(Path("data.json"), {"key": "value"})
38
+
39
+ Crash Safety:
40
+ - Write to temp file first (original untouched)
41
+ - If crash occurs: temp file remains, target unmodified
42
+ - On recovery: cleanup_orphaned_temp_files() removes orphaned files
43
+ - Result: No partial or corrupted files ever written to target
44
+ """
45
+
46
+ import json
47
+ import logging
48
+ import os
49
+ import platform
50
+ import tempfile
51
+ import time
52
+ from pathlib import Path
53
+ from typing import TextIO
54
+
55
+ logger = logging.getLogger(__name__)
56
+
57
+
58
+ class AtomicFileWriter:
59
+ """
60
+ Context manager for atomic file writes with crash safety.
61
+
62
+ Uses temp file + atomic rename pattern to ensure that writes are
63
+ all-or-nothing: either the entire file is written, or the original
64
+ file remains unchanged.
65
+
66
+ Attributes:
67
+ target_path: Final file location
68
+ temp_file: Temporary file handle
69
+ temp_path: Path to temporary file
70
+ """
71
+
72
+ def __init__(self, target_path: Path, temp_dir: Path | None = None) -> None:
73
+ """
74
+ Initialize atomic writer for target file.
75
+
76
+ Args:
77
+ target_path: Final file location (after atomic rename)
78
+ temp_dir: Directory for temp file (default: same as target_path)
79
+
80
+ Raises:
81
+ ValueError: If target_path is empty or None
82
+ """
83
+ if not target_path:
84
+ raise ValueError("target_path cannot be None or empty")
85
+
86
+ self.target_path = Path(target_path)
87
+ self.temp_dir = Path(temp_dir) if temp_dir else self.target_path.parent
88
+ self.temp_file: TextIO | None = None
89
+ self.temp_path: Path | None = None
90
+
91
+ def __enter__(self) -> TextIO:
92
+ """
93
+ Create and open temporary file for writing.
94
+
95
+ Creates a unique temp file in the same directory as target_path
96
+ (or in temp_dir if specified). This ensures the temp file is on
97
+ the same filesystem for atomic rename.
98
+
99
+ Returns:
100
+ File handle for writing (buffered text mode)
101
+
102
+ Raises:
103
+ OSError: If temp file creation fails (disk full, permissions, etc.)
104
+ """
105
+ try:
106
+ # Create parent directories if they don't exist
107
+ self.temp_dir.mkdir(parents=True, exist_ok=True)
108
+
109
+ # Create temp file in same directory as target (same filesystem)
110
+ # This is critical for os.rename() atomicity on the same filesystem
111
+ temp_fd, temp_path_str = tempfile.mkstemp(
112
+ dir=str(self.temp_dir), prefix=".tmp-", suffix=".tmp"
113
+ )
114
+ self.temp_path = Path(temp_path_str)
115
+
116
+ # Convert file descriptor to file object
117
+ self.temp_file = os.fdopen(temp_fd, "w", encoding="utf-8")
118
+ return self.temp_file
119
+
120
+ except OSError as e:
121
+ logger.error(f"Failed to create temp file in {self.temp_dir}: {e}")
122
+ raise
123
+
124
+ def __exit__(
125
+ self,
126
+ exc_type: type[BaseException] | None,
127
+ exc_val: BaseException | None,
128
+ exc_tb: object,
129
+ ) -> None:
130
+ """
131
+ Commit write (atomic rename) or rollback on error.
132
+
133
+ If no exception occurred: performs atomic rename of temp file to target.
134
+ If exception occurred: deletes temp file and re-raises exception.
135
+
136
+ Args:
137
+ exc_type: Exception type if exception occurred
138
+ exc_val: Exception value if exception occurred
139
+ exc_tb: Exception traceback if exception occurred
140
+
141
+ Raises:
142
+ OSError: If atomic rename fails after successful write
143
+ (Re-raises any exception from the with block)
144
+ """
145
+ if self.temp_file:
146
+ try:
147
+ self.temp_file.close()
148
+ except OSError as e:
149
+ logger.error(f"Failed to close temp file {self.temp_path}: {e}")
150
+ # Try to cleanup temp file before re-raising
151
+ if self.temp_path and self.temp_path.exists():
152
+ try:
153
+ self.temp_path.unlink()
154
+ except OSError:
155
+ pass
156
+ raise
157
+
158
+ # If exception occurred during write, delete temp file and re-raise
159
+ if exc_type is not None:
160
+ if self.temp_path and self.temp_path.exists():
161
+ try:
162
+ self.temp_path.unlink()
163
+ logger.debug(f"Rolled back temp file {self.temp_path}")
164
+ except OSError as e:
165
+ logger.warning(f"Failed to cleanup temp file {self.temp_path}: {e}")
166
+ # Don't suppress the exception
167
+ return
168
+
169
+ # No exception: commit via atomic rename
170
+ if self.temp_path:
171
+ try:
172
+ atomic_rename(self.temp_path, self.target_path)
173
+ logger.debug(f"Atomically committed {self.target_path}")
174
+ except OSError as e:
175
+ # Rename failed - cleanup temp and raise
176
+ try:
177
+ self.temp_path.unlink()
178
+ except OSError:
179
+ pass
180
+ logger.error(
181
+ f"Failed to rename {self.temp_path} to {self.target_path}: {e}"
182
+ )
183
+ raise
184
+
185
+ @staticmethod
186
+ def atomic_write(path: Path, content: str, encoding: str = "utf-8") -> None:
187
+ """
188
+ Simple atomic write without context manager.
189
+
190
+ Convenience method for one-shot atomic writes. Equivalent to:
191
+ with AtomicFileWriter(path) as f:
192
+ f.write(content)
193
+
194
+ Args:
195
+ path: Target file path
196
+ content: Text content to write
197
+ encoding: Text encoding (default: utf-8)
198
+
199
+ Raises:
200
+ OSError: If write or rename fails
201
+ ValueError: If path is invalid
202
+ """
203
+ writer = AtomicFileWriter(path)
204
+ with writer as f:
205
+ f.write(content)
206
+
207
+ @staticmethod
208
+ def atomic_json_write(path: Path, data: dict[str, object], indent: int = 2) -> None:
209
+ """
210
+ Atomic JSON write with formatting.
211
+
212
+ Convenience method for atomic JSON writes with pretty-printing.
213
+ Ensures JSON file is never partially written or corrupted.
214
+
215
+ Args:
216
+ path: Target JSON file path
217
+ data: Dictionary/object to write as JSON
218
+ indent: JSON indentation level (default: 2 for readability)
219
+
220
+ Raises:
221
+ OSError: If write or rename fails
222
+ TypeError: If data is not JSON serializable
223
+ ValueError: If path is invalid
224
+ """
225
+ writer = AtomicFileWriter(path)
226
+ with writer as f:
227
+ json.dump(data, f, indent=indent, ensure_ascii=False)
228
+ f.write("\n") # Add trailing newline for text files
229
+
230
+ @staticmethod
231
+ def safe_read_with_retry(
232
+ path: Path, max_retries: int = 3, retry_delay: float = 0.1
233
+ ) -> str:
234
+ """
235
+ Read file with retry on concurrent access.
236
+
237
+ Handles transient failures (file being written by another process)
238
+ by retrying with exponential backoff. Useful when reading files
239
+ that may be updated concurrently.
240
+
241
+ Args:
242
+ path: File to read
243
+ max_retries: Maximum number of retry attempts (default: 3)
244
+ retry_delay: Delay in seconds between retries (default: 0.1)
245
+
246
+ Returns:
247
+ File contents as string
248
+
249
+ Raises:
250
+ FileNotFoundError: If file doesn't exist and all retries exhausted
251
+ OSError: If read failed after all retries
252
+ """
253
+ last_error: OSError | None = None
254
+
255
+ for attempt in range(max_retries):
256
+ try:
257
+ with open(path, encoding="utf-8") as f:
258
+ return f.read()
259
+ except OSError as e:
260
+ last_error = e
261
+ if attempt < max_retries - 1:
262
+ wait_time = retry_delay * (2**attempt) # Exponential backoff
263
+ logger.debug(
264
+ f"Read retry {attempt + 1}/{max_retries} for {path} "
265
+ f"(waiting {wait_time:.2f}s): {e}"
266
+ )
267
+ time.sleep(wait_time)
268
+
269
+ # All retries exhausted
270
+ logger.error(f"Failed to read {path} after {max_retries} retries: {last_error}")
271
+ raise last_error if last_error else FileNotFoundError(f"Cannot read {path}")
272
+
273
+
274
+ class DirectoryLocker:
275
+ """
276
+ Lightweight directory-level coordination for concurrent writes.
277
+
278
+ Uses marker files (not OS-level locks) to coordinate access between
279
+ multiple processes. Supports shared locks (multiple readers) and
280
+ exclusive locks (single writer).
281
+
282
+ Marker Files:
283
+ - .lock-shared-{pid}: Process holding shared lock
284
+ - .lock-exclusive-{pid}: Process holding exclusive lock
285
+
286
+ Attributes:
287
+ lock_dir: Directory containing lock marker files
288
+ pid: Current process ID
289
+ """
290
+
291
+ def __init__(self, lock_dir: Path) -> None:
292
+ """
293
+ Initialize lock directory.
294
+
295
+ Args:
296
+ lock_dir: Directory where lock marker files are stored
297
+
298
+ Raises:
299
+ OSError: If lock directory cannot be created
300
+ """
301
+ self.lock_dir = Path(lock_dir)
302
+ self.pid = os.getpid()
303
+ self.shared_lock_path: Path | None = None
304
+ self.exclusive_lock_path: Path | None = None
305
+
306
+ # Create lock directory
307
+ try:
308
+ self.lock_dir.mkdir(parents=True, exist_ok=True)
309
+ except OSError as e:
310
+ logger.error(f"Failed to create lock directory {lock_dir}: {e}")
311
+ raise
312
+
313
+ def acquire_shared_lock(self, timeout: float = 5.0) -> bool:
314
+ """
315
+ Acquire shared lock (multiple readers allowed).
316
+
317
+ Shared locks allow multiple processes to hold the lock simultaneously.
318
+ Useful for coordinating read-heavy operations.
319
+
320
+ Args:
321
+ timeout: Max seconds to wait for lock (default: 5.0)
322
+
323
+ Returns:
324
+ True if lock acquired, False if timeout exceeded
325
+
326
+ Raises:
327
+ OSError: If lock file creation fails
328
+ """
329
+ self.shared_lock_path = self.lock_dir / f".lock-shared-{self.pid}"
330
+ deadline = time.time() + timeout
331
+
332
+ while time.time() < deadline:
333
+ try:
334
+ # Try to create lock file (atomic)
335
+ self.shared_lock_path.touch(exist_ok=True)
336
+ logger.debug(f"Acquired shared lock: {self.shared_lock_path}")
337
+ return True
338
+ except OSError as e:
339
+ logger.warning(f"Failed to acquire shared lock: {e}")
340
+ time.sleep(0.01)
341
+
342
+ logger.error(f"Timeout acquiring shared lock after {timeout} seconds")
343
+ return False
344
+
345
+ def acquire_exclusive_lock(self, timeout: float = 5.0) -> bool:
346
+ """
347
+ Acquire exclusive lock (single writer only).
348
+
349
+ Exclusive locks prevent other processes from writing. Useful for
350
+ coordinating write operations on shared resources.
351
+
352
+ Args:
353
+ timeout: Max seconds to wait for lock (default: 5.0)
354
+
355
+ Returns:
356
+ True if lock acquired, False if timeout exceeded
357
+
358
+ Raises:
359
+ OSError: If lock file creation fails
360
+ """
361
+ self.exclusive_lock_path = self.lock_dir / f".lock-exclusive-{self.pid}"
362
+ deadline = time.time() + timeout
363
+
364
+ while time.time() < deadline:
365
+ try:
366
+ # Check if any exclusive locks exist
367
+ exclusive_locks = list(self.lock_dir.glob(".lock-exclusive-*"))
368
+ if exclusive_locks:
369
+ time.sleep(0.01)
370
+ continue
371
+
372
+ # Try to create lock file
373
+ self.exclusive_lock_path.touch(exist_ok=True)
374
+ logger.debug(f"Acquired exclusive lock: {self.exclusive_lock_path}")
375
+ return True
376
+ except OSError as e:
377
+ logger.warning(f"Failed to acquire exclusive lock: {e}")
378
+ time.sleep(0.01)
379
+
380
+ logger.error(f"Timeout acquiring exclusive lock after {timeout} seconds")
381
+ return False
382
+
383
+ def release_lock(self) -> None:
384
+ """
385
+ Release lock (both shared and exclusive).
386
+
387
+ Safe to call even if no lock is held. Cleans up marker files.
388
+
389
+ Raises:
390
+ OSError: If lock file deletion fails (continues anyway)
391
+ """
392
+ for lock_path in [self.shared_lock_path, self.exclusive_lock_path]:
393
+ if lock_path and lock_path.exists():
394
+ try:
395
+ lock_path.unlink()
396
+ logger.debug(f"Released lock: {lock_path}")
397
+ except OSError as e:
398
+ logger.warning(f"Failed to release lock {lock_path}: {e}")
399
+
400
+
401
+ def atomic_rename(src: Path, dst: Path) -> None:
402
+ """
403
+ Platform-aware atomic rename operation.
404
+
405
+ Handles platform differences:
406
+ - Linux/macOS: os.rename() is atomic by default
407
+ - Windows: os.replace() is atomic on Windows 7+
408
+ - All platforms: Overwrites existing destination
409
+
410
+ Args:
411
+ src: Source file path
412
+ dst: Destination file path
413
+
414
+ Raises:
415
+ OSError: If rename fails (file doesn't exist, permissions, etc.)
416
+ ValueError: If source and destination are the same
417
+ """
418
+ src = Path(src)
419
+ dst = Path(dst)
420
+
421
+ if src == dst:
422
+ raise ValueError("Source and destination paths are identical")
423
+
424
+ if not src.exists():
425
+ raise FileNotFoundError(f"Source file does not exist: {src}")
426
+
427
+ try:
428
+ # Ensure parent directory exists
429
+ dst.parent.mkdir(parents=True, exist_ok=True)
430
+
431
+ # Platform-aware atomic rename
432
+ if platform.system() == "Windows":
433
+ # Windows: os.replace() is atomic (overwrites existing)
434
+ os.replace(str(src), str(dst))
435
+ else:
436
+ # Linux/macOS: os.rename() is atomic (overwrites existing)
437
+ os.rename(str(src), str(dst))
438
+
439
+ logger.debug(f"Atomic rename: {src} -> {dst}")
440
+
441
+ except OSError as e:
442
+ logger.error(f"Failed to rename {src} to {dst}: {e}")
443
+ raise
444
+
445
+
446
+ def safe_temp_file(base_dir: Path, prefix: str = "tmp") -> Path:
447
+ """
448
+ Create unique temp file path (doesn't create file).
449
+
450
+ Returns a unique path for a temp file without actually creating it.
451
+ Useful for planning where to write a temp file before opening.
452
+
453
+ Args:
454
+ base_dir: Directory to create temp file in
455
+ prefix: Temp file prefix (default: "tmp")
456
+
457
+ Returns:
458
+ Path object (file not created)
459
+
460
+ Raises:
461
+ OSError: If base_dir cannot be accessed
462
+ """
463
+ base_dir = Path(base_dir)
464
+ base_dir.mkdir(parents=True, exist_ok=True)
465
+
466
+ # Generate unique filename using timestamp + random
467
+ import random
468
+ import string
469
+
470
+ timestamp = int(time.time() * 1000000) # Microsecond precision
471
+ random_suffix = "".join(random.choices(string.ascii_lowercase + string.digits, k=8))
472
+ filename = f".{prefix}-{timestamp}-{random_suffix}.tmp"
473
+
474
+ return base_dir / filename
475
+
476
+
477
+ def cleanup_orphaned_temp_files(base_dir: Path, age_hours: float = 24) -> int:
478
+ """
479
+ Remove temp files older than age_hours.
480
+
481
+ Cleans up orphaned temp files left from crashed writes. Temp files
482
+ matching pattern ".tmp-*" older than age_hours are deleted.
483
+
484
+ Args:
485
+ base_dir: Directory to scan for orphaned temp files
486
+ age_hours: Age threshold in hours (default: 24)
487
+
488
+ Returns:
489
+ Number of temp files deleted
490
+
491
+ Raises:
492
+ OSError: If base_dir doesn't exist or cannot be accessed
493
+ """
494
+ base_dir = Path(base_dir)
495
+ if not base_dir.exists():
496
+ logger.debug(f"Cleanup directory does not exist: {base_dir}")
497
+ return 0
498
+
499
+ deleted_count = 0
500
+ age_seconds = age_hours * 3600
501
+ current_time = time.time()
502
+
503
+ try:
504
+ for temp_file in base_dir.glob(".tmp-*"):
505
+ try:
506
+ # Check file age
507
+ file_time = temp_file.stat().st_mtime
508
+ file_age = current_time - file_time
509
+
510
+ if file_age > age_seconds:
511
+ temp_file.unlink()
512
+ deleted_count += 1
513
+ logger.debug(f"Deleted orphaned temp file: {temp_file}")
514
+ except (OSError, FileNotFoundError) as e:
515
+ # File may be in use or deleted by another process
516
+ logger.debug(f"Failed to cleanup {temp_file}: {e}")
517
+ continue
518
+
519
+ except OSError as e:
520
+ logger.error(f"Failed to scan {base_dir} for orphaned temp files: {e}")
521
+ raise
522
+
523
+ if deleted_count > 0:
524
+ logger.info(f"Cleaned up {deleted_count} orphaned temp files from {base_dir}")
525
+
526
+ return deleted_count
527
+
528
+
529
+ def validate_atomic_write(path: Path) -> bool:
530
+ """
531
+ Verify file was written atomically (complete, not partial).
532
+
533
+ Checks that a file exists and is readable. A complete atomic write
534
+ will have a valid, readable file. Partial writes or corrupted files
535
+ will fail to read.
536
+
537
+ Args:
538
+ path: File to validate
539
+
540
+ Returns:
541
+ True if file exists and is readable, False otherwise
542
+ """
543
+ path = Path(path)
544
+
545
+ if not path.exists():
546
+ logger.debug(f"File does not exist: {path}")
547
+ return False
548
+
549
+ if not path.is_file():
550
+ logger.debug(f"Path is not a file: {path}")
551
+ return False
552
+
553
+ try:
554
+ # Try to read file to verify it's not corrupted
555
+ with open(path, encoding="utf-8") as f:
556
+ f.read()
557
+ return True
558
+ except (OSError, UnicodeDecodeError) as e:
559
+ logger.error(f"File is corrupted or unreadable: {path}: {e}")
560
+ return False
@@ -1,3 +1,5 @@
1
+ from __future__ import annotations
2
+
1
3
  """
2
4
  Attribute Index for O(1) attribute-based lookups.
3
5
 
@@ -11,7 +13,6 @@ Without this index, finding nodes by attribute requires scanning
11
13
  all nodes in the graph - O(n) complexity.
12
14
  """
13
15
 
14
- from __future__ import annotations
15
16
 
16
17
  from collections import defaultdict
17
18
  from dataclasses import dataclass, field