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.
- htmlgraph/.htmlgraph/.session-warning-state.json +6 -0
- htmlgraph/.htmlgraph/agents.json +72 -0
- htmlgraph/.htmlgraph/htmlgraph.db +0 -0
- htmlgraph/__init__.py +51 -1
- htmlgraph/__init__.pyi +123 -0
- htmlgraph/agent_detection.py +26 -10
- htmlgraph/agent_registry.py +2 -1
- htmlgraph/analytics/__init__.py +8 -1
- htmlgraph/analytics/cli.py +86 -20
- htmlgraph/analytics/cost_analyzer.py +391 -0
- htmlgraph/analytics/cost_monitor.py +664 -0
- htmlgraph/analytics/cost_reporter.py +675 -0
- htmlgraph/analytics/cross_session.py +617 -0
- htmlgraph/analytics/dependency.py +10 -6
- htmlgraph/analytics/pattern_learning.py +771 -0
- htmlgraph/analytics/session_graph.py +707 -0
- htmlgraph/analytics/strategic/__init__.py +80 -0
- htmlgraph/analytics/strategic/cost_optimizer.py +611 -0
- htmlgraph/analytics/strategic/pattern_detector.py +876 -0
- htmlgraph/analytics/strategic/preference_manager.py +709 -0
- htmlgraph/analytics/strategic/suggestion_engine.py +747 -0
- htmlgraph/analytics/work_type.py +67 -27
- htmlgraph/analytics_index.py +53 -20
- htmlgraph/api/__init__.py +3 -0
- htmlgraph/api/cost_alerts_websocket.py +416 -0
- htmlgraph/api/main.py +2498 -0
- htmlgraph/api/static/htmx.min.js +1 -0
- htmlgraph/api/static/style-redesign.css +1344 -0
- htmlgraph/api/static/style.css +1079 -0
- htmlgraph/api/templates/dashboard-redesign.html +1366 -0
- htmlgraph/api/templates/dashboard.html +794 -0
- htmlgraph/api/templates/partials/activity-feed-hierarchical.html +326 -0
- htmlgraph/api/templates/partials/activity-feed.html +1100 -0
- htmlgraph/api/templates/partials/agents-redesign.html +317 -0
- htmlgraph/api/templates/partials/agents.html +317 -0
- htmlgraph/api/templates/partials/event-traces.html +373 -0
- htmlgraph/api/templates/partials/features-kanban-redesign.html +509 -0
- htmlgraph/api/templates/partials/features.html +578 -0
- htmlgraph/api/templates/partials/metrics-redesign.html +346 -0
- htmlgraph/api/templates/partials/metrics.html +346 -0
- htmlgraph/api/templates/partials/orchestration-redesign.html +443 -0
- htmlgraph/api/templates/partials/orchestration.html +198 -0
- htmlgraph/api/templates/partials/spawners.html +375 -0
- htmlgraph/api/templates/partials/work-items.html +613 -0
- htmlgraph/api/websocket.py +538 -0
- htmlgraph/archive/__init__.py +24 -0
- htmlgraph/archive/bloom.py +234 -0
- htmlgraph/archive/fts.py +297 -0
- htmlgraph/archive/manager.py +583 -0
- htmlgraph/archive/search.py +244 -0
- htmlgraph/atomic_ops.py +560 -0
- htmlgraph/attribute_index.py +2 -1
- htmlgraph/bounded_paths.py +539 -0
- htmlgraph/builders/base.py +57 -2
- htmlgraph/builders/bug.py +19 -3
- htmlgraph/builders/chore.py +19 -3
- htmlgraph/builders/epic.py +19 -3
- htmlgraph/builders/feature.py +27 -3
- htmlgraph/builders/insight.py +2 -1
- htmlgraph/builders/metric.py +2 -1
- htmlgraph/builders/pattern.py +2 -1
- htmlgraph/builders/phase.py +19 -3
- htmlgraph/builders/spike.py +29 -3
- htmlgraph/builders/track.py +42 -1
- htmlgraph/cigs/__init__.py +81 -0
- htmlgraph/cigs/autonomy.py +385 -0
- htmlgraph/cigs/cost.py +475 -0
- htmlgraph/cigs/messages_basic.py +472 -0
- htmlgraph/cigs/messaging.py +365 -0
- htmlgraph/cigs/models.py +771 -0
- htmlgraph/cigs/pattern_storage.py +427 -0
- htmlgraph/cigs/patterns.py +503 -0
- htmlgraph/cigs/posttool_analyzer.py +234 -0
- htmlgraph/cigs/reporter.py +818 -0
- htmlgraph/cigs/tracker.py +317 -0
- htmlgraph/cli/.htmlgraph/.session-warning-state.json +6 -0
- htmlgraph/cli/.htmlgraph/agents.json +72 -0
- htmlgraph/cli/.htmlgraph/htmlgraph.db +0 -0
- htmlgraph/cli/__init__.py +42 -0
- htmlgraph/cli/__main__.py +6 -0
- htmlgraph/cli/analytics.py +1424 -0
- htmlgraph/cli/base.py +685 -0
- htmlgraph/cli/constants.py +206 -0
- htmlgraph/cli/core.py +954 -0
- htmlgraph/cli/main.py +147 -0
- htmlgraph/cli/models.py +475 -0
- htmlgraph/cli/templates/__init__.py +1 -0
- htmlgraph/cli/templates/cost_dashboard.py +399 -0
- htmlgraph/cli/work/__init__.py +239 -0
- htmlgraph/cli/work/browse.py +115 -0
- htmlgraph/cli/work/features.py +568 -0
- htmlgraph/cli/work/orchestration.py +676 -0
- htmlgraph/cli/work/report.py +728 -0
- htmlgraph/cli/work/sessions.py +466 -0
- htmlgraph/cli/work/snapshot.py +559 -0
- htmlgraph/cli/work/tracks.py +486 -0
- htmlgraph/cli_commands/__init__.py +1 -0
- htmlgraph/cli_commands/feature.py +195 -0
- htmlgraph/cli_framework.py +115 -0
- htmlgraph/collections/__init__.py +2 -0
- htmlgraph/collections/base.py +197 -14
- htmlgraph/collections/bug.py +2 -1
- htmlgraph/collections/chore.py +2 -1
- htmlgraph/collections/epic.py +2 -1
- htmlgraph/collections/feature.py +2 -1
- htmlgraph/collections/insight.py +2 -1
- htmlgraph/collections/metric.py +2 -1
- htmlgraph/collections/pattern.py +2 -1
- htmlgraph/collections/phase.py +2 -1
- htmlgraph/collections/session.py +194 -0
- htmlgraph/collections/spike.py +13 -2
- htmlgraph/collections/task_delegation.py +241 -0
- htmlgraph/collections/todo.py +14 -1
- htmlgraph/collections/traces.py +487 -0
- htmlgraph/config/cost_models.json +56 -0
- htmlgraph/config.py +190 -0
- htmlgraph/context_analytics.py +2 -1
- htmlgraph/converter.py +116 -7
- htmlgraph/cost_analysis/__init__.py +5 -0
- htmlgraph/cost_analysis/analyzer.py +438 -0
- htmlgraph/dashboard.html +2246 -248
- htmlgraph/dashboard.html.backup +6592 -0
- htmlgraph/dashboard.html.bak +7181 -0
- htmlgraph/dashboard.html.bak2 +7231 -0
- htmlgraph/dashboard.html.bak3 +7232 -0
- htmlgraph/db/__init__.py +38 -0
- htmlgraph/db/queries.py +790 -0
- htmlgraph/db/schema.py +1788 -0
- htmlgraph/decorators.py +317 -0
- htmlgraph/dependency_models.py +2 -1
- htmlgraph/deploy.py +26 -27
- htmlgraph/docs/API_REFERENCE.md +841 -0
- htmlgraph/docs/HTTP_API.md +750 -0
- htmlgraph/docs/INTEGRATION_GUIDE.md +752 -0
- htmlgraph/docs/ORCHESTRATION_PATTERNS.md +717 -0
- htmlgraph/docs/README.md +532 -0
- htmlgraph/docs/__init__.py +77 -0
- htmlgraph/docs/docs_version.py +55 -0
- htmlgraph/docs/metadata.py +93 -0
- htmlgraph/docs/migrations.py +232 -0
- htmlgraph/docs/template_engine.py +143 -0
- htmlgraph/docs/templates/_sections/cli_reference.md.j2 +52 -0
- htmlgraph/docs/templates/_sections/core_concepts.md.j2 +29 -0
- htmlgraph/docs/templates/_sections/sdk_basics.md.j2 +69 -0
- htmlgraph/docs/templates/base_agents.md.j2 +78 -0
- htmlgraph/docs/templates/example_user_override.md.j2 +47 -0
- htmlgraph/docs/version_check.py +163 -0
- htmlgraph/edge_index.py +2 -1
- htmlgraph/error_handler.py +544 -0
- htmlgraph/event_log.py +86 -37
- htmlgraph/event_migration.py +2 -1
- htmlgraph/file_watcher.py +12 -8
- htmlgraph/find_api.py +2 -1
- htmlgraph/git_events.py +67 -9
- htmlgraph/hooks/.htmlgraph/.session-warning-state.json +6 -0
- htmlgraph/hooks/.htmlgraph/agents.json +72 -0
- htmlgraph/hooks/.htmlgraph/index.sqlite +0 -0
- htmlgraph/hooks/__init__.py +8 -0
- htmlgraph/hooks/bootstrap.py +169 -0
- htmlgraph/hooks/cigs_pretool_enforcer.py +354 -0
- htmlgraph/hooks/concurrent_sessions.py +208 -0
- htmlgraph/hooks/context.py +350 -0
- htmlgraph/hooks/drift_handler.py +525 -0
- htmlgraph/hooks/event_tracker.py +790 -99
- htmlgraph/hooks/git_commands.py +175 -0
- htmlgraph/hooks/installer.py +5 -1
- htmlgraph/hooks/orchestrator.py +327 -76
- htmlgraph/hooks/orchestrator_reflector.py +31 -4
- htmlgraph/hooks/post_tool_use_failure.py +32 -7
- htmlgraph/hooks/post_tool_use_handler.py +257 -0
- htmlgraph/hooks/posttooluse.py +92 -19
- htmlgraph/hooks/pretooluse.py +527 -7
- htmlgraph/hooks/prompt_analyzer.py +637 -0
- htmlgraph/hooks/session_handler.py +668 -0
- htmlgraph/hooks/session_summary.py +395 -0
- htmlgraph/hooks/state_manager.py +504 -0
- htmlgraph/hooks/subagent_detection.py +202 -0
- htmlgraph/hooks/subagent_stop.py +369 -0
- htmlgraph/hooks/task_enforcer.py +99 -4
- htmlgraph/hooks/validator.py +212 -91
- htmlgraph/ids.py +2 -1
- htmlgraph/learning.py +125 -100
- htmlgraph/mcp_server.py +2 -1
- htmlgraph/models.py +217 -18
- htmlgraph/operations/README.md +62 -0
- htmlgraph/operations/__init__.py +79 -0
- htmlgraph/operations/analytics.py +339 -0
- htmlgraph/operations/bootstrap.py +289 -0
- htmlgraph/operations/events.py +244 -0
- htmlgraph/operations/fastapi_server.py +231 -0
- htmlgraph/operations/hooks.py +350 -0
- htmlgraph/operations/initialization.py +597 -0
- htmlgraph/operations/initialization.py.backup +228 -0
- htmlgraph/operations/server.py +303 -0
- htmlgraph/orchestration/__init__.py +58 -0
- htmlgraph/orchestration/claude_launcher.py +179 -0
- htmlgraph/orchestration/command_builder.py +72 -0
- htmlgraph/orchestration/headless_spawner.py +281 -0
- htmlgraph/orchestration/live_events.py +377 -0
- htmlgraph/orchestration/model_selection.py +327 -0
- htmlgraph/orchestration/plugin_manager.py +140 -0
- htmlgraph/orchestration/prompts.py +137 -0
- htmlgraph/orchestration/spawner_event_tracker.py +383 -0
- htmlgraph/orchestration/spawners/__init__.py +16 -0
- htmlgraph/orchestration/spawners/base.py +194 -0
- htmlgraph/orchestration/spawners/claude.py +173 -0
- htmlgraph/orchestration/spawners/codex.py +435 -0
- htmlgraph/orchestration/spawners/copilot.py +294 -0
- htmlgraph/orchestration/spawners/gemini.py +471 -0
- htmlgraph/orchestration/subprocess_runner.py +36 -0
- htmlgraph/{orchestration.py → orchestration/task_coordination.py} +16 -8
- htmlgraph/orchestration.md +563 -0
- htmlgraph/orchestrator-system-prompt-optimized.txt +863 -0
- htmlgraph/orchestrator.py +2 -1
- htmlgraph/orchestrator_config.py +357 -0
- htmlgraph/orchestrator_mode.py +115 -4
- htmlgraph/parallel.py +2 -1
- htmlgraph/parser.py +86 -6
- htmlgraph/path_query.py +608 -0
- htmlgraph/pattern_matcher.py +636 -0
- htmlgraph/pydantic_models.py +476 -0
- htmlgraph/quality_gates.py +350 -0
- htmlgraph/query_builder.py +2 -1
- htmlgraph/query_composer.py +509 -0
- htmlgraph/reflection.py +443 -0
- htmlgraph/refs.py +344 -0
- htmlgraph/repo_hash.py +512 -0
- htmlgraph/repositories/__init__.py +292 -0
- htmlgraph/repositories/analytics_repository.py +455 -0
- htmlgraph/repositories/analytics_repository_standard.py +628 -0
- htmlgraph/repositories/feature_repository.py +581 -0
- htmlgraph/repositories/feature_repository_htmlfile.py +668 -0
- htmlgraph/repositories/feature_repository_memory.py +607 -0
- htmlgraph/repositories/feature_repository_sqlite.py +858 -0
- htmlgraph/repositories/filter_service.py +620 -0
- htmlgraph/repositories/filter_service_standard.py +445 -0
- htmlgraph/repositories/shared_cache.py +621 -0
- htmlgraph/repositories/shared_cache_memory.py +395 -0
- htmlgraph/repositories/track_repository.py +552 -0
- htmlgraph/repositories/track_repository_htmlfile.py +619 -0
- htmlgraph/repositories/track_repository_memory.py +508 -0
- htmlgraph/repositories/track_repository_sqlite.py +711 -0
- htmlgraph/sdk/__init__.py +398 -0
- htmlgraph/sdk/__init__.pyi +14 -0
- htmlgraph/sdk/analytics/__init__.py +19 -0
- htmlgraph/sdk/analytics/engine.py +155 -0
- htmlgraph/sdk/analytics/helpers.py +178 -0
- htmlgraph/sdk/analytics/registry.py +109 -0
- htmlgraph/sdk/base.py +484 -0
- htmlgraph/sdk/constants.py +216 -0
- htmlgraph/sdk/core.pyi +308 -0
- htmlgraph/sdk/discovery.py +120 -0
- htmlgraph/sdk/help/__init__.py +12 -0
- htmlgraph/sdk/help/mixin.py +699 -0
- htmlgraph/sdk/mixins/__init__.py +15 -0
- htmlgraph/sdk/mixins/attribution.py +113 -0
- htmlgraph/sdk/mixins/mixin.py +410 -0
- htmlgraph/sdk/operations/__init__.py +12 -0
- htmlgraph/sdk/operations/mixin.py +427 -0
- htmlgraph/sdk/orchestration/__init__.py +17 -0
- htmlgraph/sdk/orchestration/coordinator.py +203 -0
- htmlgraph/sdk/orchestration/spawner.py +204 -0
- htmlgraph/sdk/planning/__init__.py +19 -0
- htmlgraph/sdk/planning/bottlenecks.py +93 -0
- htmlgraph/sdk/planning/mixin.py +211 -0
- htmlgraph/sdk/planning/parallel.py +186 -0
- htmlgraph/sdk/planning/queue.py +210 -0
- htmlgraph/sdk/planning/recommendations.py +87 -0
- htmlgraph/sdk/planning/smart_planning.py +319 -0
- htmlgraph/sdk/session/__init__.py +19 -0
- htmlgraph/sdk/session/continuity.py +57 -0
- htmlgraph/sdk/session/handoff.py +110 -0
- htmlgraph/sdk/session/info.py +309 -0
- htmlgraph/sdk/session/manager.py +103 -0
- htmlgraph/sdk/strategic/__init__.py +26 -0
- htmlgraph/sdk/strategic/mixin.py +563 -0
- htmlgraph/server.py +295 -107
- htmlgraph/session_hooks.py +300 -0
- htmlgraph/session_manager.py +285 -3
- htmlgraph/session_registry.py +587 -0
- htmlgraph/session_state.py +436 -0
- htmlgraph/session_warning.py +2 -1
- htmlgraph/sessions/__init__.py +23 -0
- htmlgraph/sessions/handoff.py +756 -0
- htmlgraph/system_prompts.py +450 -0
- htmlgraph/templates/orchestration-view.html +350 -0
- htmlgraph/track_builder.py +33 -1
- htmlgraph/track_manager.py +38 -0
- htmlgraph/transcript.py +18 -5
- htmlgraph/validation.py +115 -0
- htmlgraph/watch.py +2 -1
- htmlgraph/work_type_utils.py +2 -1
- {htmlgraph-0.20.1.data → htmlgraph-0.27.5.data}/data/htmlgraph/dashboard.html +2246 -248
- {htmlgraph-0.20.1.dist-info → htmlgraph-0.27.5.dist-info}/METADATA +95 -64
- htmlgraph-0.27.5.dist-info/RECORD +337 -0
- {htmlgraph-0.20.1.dist-info → htmlgraph-0.27.5.dist-info}/entry_points.txt +1 -1
- htmlgraph/cli.py +0 -4839
- htmlgraph/sdk.py +0 -2359
- htmlgraph-0.20.1.dist-info/RECORD +0 -118
- {htmlgraph-0.20.1.data → htmlgraph-0.27.5.data}/data/htmlgraph/styles.css +0 -0
- {htmlgraph-0.20.1.data → htmlgraph-0.27.5.data}/data/htmlgraph/templates/AGENTS.md.template +0 -0
- {htmlgraph-0.20.1.data → htmlgraph-0.27.5.data}/data/htmlgraph/templates/CLAUDE.md.template +0 -0
- {htmlgraph-0.20.1.data → htmlgraph-0.27.5.data}/data/htmlgraph/templates/GEMINI.md.template +0 -0
- {htmlgraph-0.20.1.dist-info → htmlgraph-0.27.5.dist-info}/WHEEL +0 -0
htmlgraph/atomic_ops.py
ADDED
|
@@ -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
|
htmlgraph/attribute_index.py
CHANGED
|
@@ -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
|