superqode 0.1.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.
- superqode/__init__.py +33 -0
- superqode/acp/__init__.py +23 -0
- superqode/acp/client.py +913 -0
- superqode/acp/permission_screen.py +457 -0
- superqode/acp/types.py +480 -0
- superqode/acp_discovery.py +856 -0
- superqode/agent/__init__.py +22 -0
- superqode/agent/edit_strategies.py +334 -0
- superqode/agent/loop.py +892 -0
- superqode/agent/qe_report_templates.py +39 -0
- superqode/agent/system_prompts.py +353 -0
- superqode/agent_output.py +721 -0
- superqode/agent_stream.py +953 -0
- superqode/agents/__init__.py +59 -0
- superqode/agents/acp_registry.py +305 -0
- superqode/agents/client.py +249 -0
- superqode/agents/data/augmentcode.com.toml +51 -0
- superqode/agents/data/cagent.dev.toml +51 -0
- superqode/agents/data/claude.com.toml +60 -0
- superqode/agents/data/codeassistant.dev.toml +51 -0
- superqode/agents/data/codex.openai.com.toml +57 -0
- superqode/agents/data/fastagent.ai.toml +66 -0
- superqode/agents/data/geminicli.com.toml +77 -0
- superqode/agents/data/goose.block.xyz.toml +54 -0
- superqode/agents/data/junie.jetbrains.com.toml +56 -0
- superqode/agents/data/kimi.moonshot.cn.toml +57 -0
- superqode/agents/data/llmlingagent.dev.toml +51 -0
- superqode/agents/data/molt.bot.toml +49 -0
- superqode/agents/data/opencode.ai.toml +60 -0
- superqode/agents/data/stakpak.dev.toml +51 -0
- superqode/agents/data/vtcode.dev.toml +51 -0
- superqode/agents/discovery.py +266 -0
- superqode/agents/messaging.py +160 -0
- superqode/agents/persona.py +166 -0
- superqode/agents/registry.py +421 -0
- superqode/agents/schema.py +72 -0
- superqode/agents/unified.py +367 -0
- superqode/app/__init__.py +111 -0
- superqode/app/constants.py +314 -0
- superqode/app/css.py +366 -0
- superqode/app/models.py +118 -0
- superqode/app/suggester.py +125 -0
- superqode/app/widgets.py +1591 -0
- superqode/app_enhanced.py +399 -0
- superqode/app_main.py +17187 -0
- superqode/approval.py +312 -0
- superqode/atomic.py +296 -0
- superqode/commands/__init__.py +1 -0
- superqode/commands/acp.py +965 -0
- superqode/commands/agents.py +180 -0
- superqode/commands/auth.py +278 -0
- superqode/commands/config.py +374 -0
- superqode/commands/init.py +826 -0
- superqode/commands/providers.py +819 -0
- superqode/commands/qe.py +1145 -0
- superqode/commands/roles.py +380 -0
- superqode/commands/serve.py +172 -0
- superqode/commands/suggestions.py +127 -0
- superqode/commands/superqe.py +460 -0
- superqode/config/__init__.py +51 -0
- superqode/config/loader.py +812 -0
- superqode/config/schema.py +498 -0
- superqode/core/__init__.py +111 -0
- superqode/core/roles.py +281 -0
- superqode/danger.py +386 -0
- superqode/data/superqode-template.yaml +1522 -0
- superqode/design_system.py +1080 -0
- superqode/dialogs/__init__.py +6 -0
- superqode/dialogs/base.py +39 -0
- superqode/dialogs/model.py +130 -0
- superqode/dialogs/provider.py +870 -0
- superqode/diff_view.py +919 -0
- superqode/enterprise.py +21 -0
- superqode/evaluation/__init__.py +25 -0
- superqode/evaluation/adapters.py +93 -0
- superqode/evaluation/behaviors.py +89 -0
- superqode/evaluation/engine.py +209 -0
- superqode/evaluation/scenarios.py +96 -0
- superqode/execution/__init__.py +36 -0
- superqode/execution/linter.py +538 -0
- superqode/execution/modes.py +347 -0
- superqode/execution/resolver.py +283 -0
- superqode/execution/runner.py +642 -0
- superqode/file_explorer.py +811 -0
- superqode/file_viewer.py +471 -0
- superqode/flash.py +183 -0
- superqode/guidance/__init__.py +58 -0
- superqode/guidance/config.py +203 -0
- superqode/guidance/prompts.py +71 -0
- superqode/harness/__init__.py +54 -0
- superqode/harness/accelerator.py +291 -0
- superqode/harness/config.py +319 -0
- superqode/harness/validator.py +147 -0
- superqode/history.py +279 -0
- superqode/integrations/superopt_runner.py +124 -0
- superqode/logging/__init__.py +49 -0
- superqode/logging/adapters.py +219 -0
- superqode/logging/formatter.py +923 -0
- superqode/logging/integration.py +341 -0
- superqode/logging/sinks.py +170 -0
- superqode/logging/unified_log.py +417 -0
- superqode/lsp/__init__.py +26 -0
- superqode/lsp/client.py +544 -0
- superqode/main.py +1069 -0
- superqode/mcp/__init__.py +89 -0
- superqode/mcp/auth_storage.py +380 -0
- superqode/mcp/client.py +1236 -0
- superqode/mcp/config.py +319 -0
- superqode/mcp/integration.py +337 -0
- superqode/mcp/oauth.py +436 -0
- superqode/mcp/oauth_callback.py +385 -0
- superqode/mcp/types.py +290 -0
- superqode/memory/__init__.py +31 -0
- superqode/memory/feedback.py +342 -0
- superqode/memory/store.py +522 -0
- superqode/notifications.py +369 -0
- superqode/optimization/__init__.py +5 -0
- superqode/optimization/config.py +33 -0
- superqode/permissions/__init__.py +25 -0
- superqode/permissions/rules.py +488 -0
- superqode/plan.py +323 -0
- superqode/providers/__init__.py +33 -0
- superqode/providers/gateway/__init__.py +165 -0
- superqode/providers/gateway/base.py +228 -0
- superqode/providers/gateway/litellm_gateway.py +1170 -0
- superqode/providers/gateway/openresponses_gateway.py +436 -0
- superqode/providers/health.py +297 -0
- superqode/providers/huggingface/__init__.py +74 -0
- superqode/providers/huggingface/downloader.py +472 -0
- superqode/providers/huggingface/endpoints.py +442 -0
- superqode/providers/huggingface/hub.py +531 -0
- superqode/providers/huggingface/inference.py +394 -0
- superqode/providers/huggingface/transformers_runner.py +516 -0
- superqode/providers/local/__init__.py +100 -0
- superqode/providers/local/base.py +438 -0
- superqode/providers/local/discovery.py +418 -0
- superqode/providers/local/lmstudio.py +256 -0
- superqode/providers/local/mlx.py +457 -0
- superqode/providers/local/ollama.py +486 -0
- superqode/providers/local/sglang.py +268 -0
- superqode/providers/local/tgi.py +260 -0
- superqode/providers/local/tool_support.py +477 -0
- superqode/providers/local/vllm.py +258 -0
- superqode/providers/manager.py +1338 -0
- superqode/providers/models.py +1016 -0
- superqode/providers/models_dev.py +578 -0
- superqode/providers/openresponses/__init__.py +87 -0
- superqode/providers/openresponses/converters/__init__.py +17 -0
- superqode/providers/openresponses/converters/messages.py +343 -0
- superqode/providers/openresponses/converters/tools.py +268 -0
- superqode/providers/openresponses/schema/__init__.py +56 -0
- superqode/providers/openresponses/schema/models.py +585 -0
- superqode/providers/openresponses/streaming/__init__.py +5 -0
- superqode/providers/openresponses/streaming/parser.py +338 -0
- superqode/providers/openresponses/tools/__init__.py +21 -0
- superqode/providers/openresponses/tools/apply_patch.py +352 -0
- superqode/providers/openresponses/tools/code_interpreter.py +290 -0
- superqode/providers/openresponses/tools/file_search.py +333 -0
- superqode/providers/openresponses/tools/mcp_adapter.py +252 -0
- superqode/providers/registry.py +716 -0
- superqode/providers/usage.py +332 -0
- superqode/pure_mode.py +384 -0
- superqode/qr/__init__.py +23 -0
- superqode/qr/dashboard.py +781 -0
- superqode/qr/generator.py +1018 -0
- superqode/qr/templates.py +135 -0
- superqode/safety/__init__.py +41 -0
- superqode/safety/sandbox.py +413 -0
- superqode/safety/warnings.py +256 -0
- superqode/server/__init__.py +33 -0
- superqode/server/lsp_server.py +775 -0
- superqode/server/web.py +250 -0
- superqode/session/__init__.py +25 -0
- superqode/session/persistence.py +580 -0
- superqode/session/sharing.py +477 -0
- superqode/session.py +475 -0
- superqode/sidebar.py +2991 -0
- superqode/stream_view.py +648 -0
- superqode/styles/__init__.py +3 -0
- superqode/superqe/__init__.py +184 -0
- superqode/superqe/acp_runner.py +1064 -0
- superqode/superqe/constitution/__init__.py +62 -0
- superqode/superqe/constitution/evaluator.py +308 -0
- superqode/superqe/constitution/loader.py +432 -0
- superqode/superqe/constitution/schema.py +250 -0
- superqode/superqe/events.py +591 -0
- superqode/superqe/frameworks/__init__.py +65 -0
- superqode/superqe/frameworks/base.py +234 -0
- superqode/superqe/frameworks/e2e.py +263 -0
- superqode/superqe/frameworks/executor.py +237 -0
- superqode/superqe/frameworks/javascript.py +409 -0
- superqode/superqe/frameworks/python.py +373 -0
- superqode/superqe/frameworks/registry.py +92 -0
- superqode/superqe/mcp_tools/__init__.py +47 -0
- superqode/superqe/mcp_tools/core_tools.py +418 -0
- superqode/superqe/mcp_tools/registry.py +230 -0
- superqode/superqe/mcp_tools/testing_tools.py +167 -0
- superqode/superqe/noise.py +89 -0
- superqode/superqe/orchestrator.py +778 -0
- superqode/superqe/roles.py +609 -0
- superqode/superqe/session.py +713 -0
- superqode/superqe/skills/__init__.py +57 -0
- superqode/superqe/skills/base.py +106 -0
- superqode/superqe/skills/core_skills.py +899 -0
- superqode/superqe/skills/registry.py +90 -0
- superqode/superqe/verifier.py +101 -0
- superqode/superqe_cli.py +76 -0
- superqode/tool_call.py +358 -0
- superqode/tools/__init__.py +93 -0
- superqode/tools/agent_tools.py +496 -0
- superqode/tools/base.py +324 -0
- superqode/tools/batch_tool.py +133 -0
- superqode/tools/diagnostics.py +311 -0
- superqode/tools/edit_tools.py +653 -0
- superqode/tools/enhanced_base.py +515 -0
- superqode/tools/file_tools.py +269 -0
- superqode/tools/file_tracking.py +45 -0
- superqode/tools/lsp_tools.py +610 -0
- superqode/tools/network_tools.py +350 -0
- superqode/tools/permissions.py +400 -0
- superqode/tools/question_tool.py +324 -0
- superqode/tools/search_tools.py +598 -0
- superqode/tools/shell_tools.py +259 -0
- superqode/tools/todo_tools.py +121 -0
- superqode/tools/validation.py +80 -0
- superqode/tools/web_tools.py +639 -0
- superqode/tui.py +1152 -0
- superqode/tui_integration.py +875 -0
- superqode/tui_widgets/__init__.py +27 -0
- superqode/tui_widgets/widgets/__init__.py +18 -0
- superqode/tui_widgets/widgets/progress.py +185 -0
- superqode/tui_widgets/widgets/tool_display.py +188 -0
- superqode/undo_manager.py +574 -0
- superqode/utils/__init__.py +5 -0
- superqode/utils/error_handling.py +323 -0
- superqode/utils/fuzzy.py +257 -0
- superqode/widgets/__init__.py +477 -0
- superqode/widgets/agent_collab.py +390 -0
- superqode/widgets/agent_store.py +936 -0
- superqode/widgets/agent_switcher.py +395 -0
- superqode/widgets/animation_manager.py +284 -0
- superqode/widgets/code_context.py +356 -0
- superqode/widgets/command_palette.py +412 -0
- superqode/widgets/connection_status.py +537 -0
- superqode/widgets/conversation_history.py +470 -0
- superqode/widgets/diff_indicator.py +155 -0
- superqode/widgets/enhanced_status_bar.py +385 -0
- superqode/widgets/enhanced_toast.py +476 -0
- superqode/widgets/file_browser.py +809 -0
- superqode/widgets/file_reference.py +585 -0
- superqode/widgets/issue_timeline.py +340 -0
- superqode/widgets/leader_key.py +264 -0
- superqode/widgets/mode_switcher.py +445 -0
- superqode/widgets/model_picker.py +234 -0
- superqode/widgets/permission_preview.py +1205 -0
- superqode/widgets/prompt.py +358 -0
- superqode/widgets/provider_connect.py +725 -0
- superqode/widgets/pty_shell.py +587 -0
- superqode/widgets/qe_dashboard.py +321 -0
- superqode/widgets/resizable_sidebar.py +377 -0
- superqode/widgets/response_changes.py +218 -0
- superqode/widgets/response_display.py +528 -0
- superqode/widgets/rich_tool_display.py +613 -0
- superqode/widgets/sidebar_panels.py +1180 -0
- superqode/widgets/slash_complete.py +356 -0
- superqode/widgets/split_view.py +612 -0
- superqode/widgets/status_bar.py +273 -0
- superqode/widgets/superqode_display.py +786 -0
- superqode/widgets/thinking_display.py +815 -0
- superqode/widgets/throbber.py +87 -0
- superqode/widgets/toast.py +206 -0
- superqode/widgets/unified_output.py +1073 -0
- superqode/workspace/__init__.py +75 -0
- superqode/workspace/artifacts.py +472 -0
- superqode/workspace/coordinator.py +353 -0
- superqode/workspace/diff_tracker.py +429 -0
- superqode/workspace/git_guard.py +373 -0
- superqode/workspace/git_snapshot.py +526 -0
- superqode/workspace/manager.py +750 -0
- superqode/workspace/snapshot.py +357 -0
- superqode/workspace/watcher.py +535 -0
- superqode/workspace/worktree.py +440 -0
- superqode-0.1.5.dist-info/METADATA +204 -0
- superqode-0.1.5.dist-info/RECORD +288 -0
- superqode-0.1.5.dist-info/WHEEL +5 -0
- superqode-0.1.5.dist-info/entry_points.txt +3 -0
- superqode-0.1.5.dist-info/licenses/LICENSE +648 -0
- superqode-0.1.5.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,535 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Directory Watcher - Real-time File Change Detection.
|
|
3
|
+
|
|
4
|
+
Uses watchdog for efficient file system monitoring.
|
|
5
|
+
Tracks file changes in real-time for:
|
|
6
|
+
- Live diff updates in TUI
|
|
7
|
+
- Automatic snapshot triggers
|
|
8
|
+
- Change notifications to agents
|
|
9
|
+
- Optimized for SuperQode's QE workflow
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
import fnmatch
|
|
16
|
+
import os
|
|
17
|
+
import threading
|
|
18
|
+
import time
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
from datetime import datetime
|
|
21
|
+
from enum import Enum
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Callable, Dict, List, Optional, Set
|
|
24
|
+
from weakref import WeakSet
|
|
25
|
+
|
|
26
|
+
try:
|
|
27
|
+
from watchdog.observers import Observer
|
|
28
|
+
from watchdog.events import (
|
|
29
|
+
FileSystemEventHandler,
|
|
30
|
+
FileCreatedEvent,
|
|
31
|
+
FileModifiedEvent,
|
|
32
|
+
FileDeletedEvent,
|
|
33
|
+
FileMovedEvent,
|
|
34
|
+
DirCreatedEvent,
|
|
35
|
+
DirDeletedEvent,
|
|
36
|
+
DirMovedEvent,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
WATCHDOG_AVAILABLE = True
|
|
40
|
+
except ImportError:
|
|
41
|
+
WATCHDOG_AVAILABLE = False
|
|
42
|
+
Observer = None
|
|
43
|
+
FileSystemEventHandler = object
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class ChangeType(Enum):
|
|
47
|
+
"""Type of file system change."""
|
|
48
|
+
|
|
49
|
+
CREATED = "created"
|
|
50
|
+
MODIFIED = "modified"
|
|
51
|
+
DELETED = "deleted"
|
|
52
|
+
MOVED = "moved"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass
|
|
56
|
+
class FileChange:
|
|
57
|
+
"""Represents a file system change event."""
|
|
58
|
+
|
|
59
|
+
path: Path
|
|
60
|
+
change_type: ChangeType
|
|
61
|
+
timestamp: datetime = field(default_factory=datetime.now)
|
|
62
|
+
is_directory: bool = False
|
|
63
|
+
old_path: Optional[Path] = None # For moves/renames
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def relative_path(self) -> str:
|
|
67
|
+
"""Get path as string."""
|
|
68
|
+
return str(self.path)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass
|
|
72
|
+
class WatcherConfig:
|
|
73
|
+
"""Configuration for the directory watcher."""
|
|
74
|
+
|
|
75
|
+
# Patterns to ignore (glob format)
|
|
76
|
+
ignore_patterns: List[str] = field(
|
|
77
|
+
default_factory=lambda: [
|
|
78
|
+
"*.pyc",
|
|
79
|
+
"__pycache__",
|
|
80
|
+
".git",
|
|
81
|
+
".git/*",
|
|
82
|
+
"node_modules",
|
|
83
|
+
"node_modules/*",
|
|
84
|
+
".superqode",
|
|
85
|
+
".superqode/*",
|
|
86
|
+
"*.swp",
|
|
87
|
+
"*.swo",
|
|
88
|
+
"*~",
|
|
89
|
+
".DS_Store",
|
|
90
|
+
"Thumbs.db",
|
|
91
|
+
"*.log",
|
|
92
|
+
"*.tmp",
|
|
93
|
+
]
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
# File extensions to watch (empty = all)
|
|
97
|
+
watch_extensions: List[str] = field(default_factory=list)
|
|
98
|
+
|
|
99
|
+
# Debounce interval (seconds) - combine rapid changes
|
|
100
|
+
debounce_interval: float = 0.5
|
|
101
|
+
|
|
102
|
+
# Maximum events to buffer
|
|
103
|
+
max_buffer_size: int = 1000
|
|
104
|
+
|
|
105
|
+
# Watch subdirectories
|
|
106
|
+
recursive: bool = True
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
# Type alias for change callbacks
|
|
110
|
+
ChangeCallback = Callable[[FileChange], None]
|
|
111
|
+
# Async callbacks should be standard async callables taking a FileChange
|
|
112
|
+
AsyncChangeCallback = Callable[
|
|
113
|
+
[FileChange], "asyncio.Future | asyncio.Task | asyncio.coroutines.CoroutineType"
|
|
114
|
+
]
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class _WatchdogHandler(FileSystemEventHandler):
|
|
118
|
+
"""Internal handler for watchdog events."""
|
|
119
|
+
|
|
120
|
+
def __init__(self, watcher: "DirectoryWatcher"):
|
|
121
|
+
super().__init__()
|
|
122
|
+
self.watcher = watcher
|
|
123
|
+
|
|
124
|
+
def _should_ignore(self, path: str) -> bool:
|
|
125
|
+
"""Check if path should be ignored."""
|
|
126
|
+
for pattern in self.watcher.config.ignore_patterns:
|
|
127
|
+
if fnmatch.fnmatch(path, pattern) or fnmatch.fnmatch(os.path.basename(path), pattern):
|
|
128
|
+
return True
|
|
129
|
+
return False
|
|
130
|
+
|
|
131
|
+
def _should_watch_extension(self, path: str) -> bool:
|
|
132
|
+
"""Check if file extension should be watched."""
|
|
133
|
+
if not self.watcher.config.watch_extensions:
|
|
134
|
+
return True
|
|
135
|
+
|
|
136
|
+
_, ext = os.path.splitext(path)
|
|
137
|
+
return ext.lower() in self.watcher.config.watch_extensions
|
|
138
|
+
|
|
139
|
+
def _process_event(
|
|
140
|
+
self, path: str, change_type: ChangeType, is_dir: bool = False, old_path: str = None
|
|
141
|
+
):
|
|
142
|
+
"""Process a file system event."""
|
|
143
|
+
if self._should_ignore(path):
|
|
144
|
+
return
|
|
145
|
+
|
|
146
|
+
if not is_dir and not self._should_watch_extension(path):
|
|
147
|
+
return
|
|
148
|
+
|
|
149
|
+
change = FileChange(
|
|
150
|
+
path=Path(path),
|
|
151
|
+
change_type=change_type,
|
|
152
|
+
is_directory=is_dir,
|
|
153
|
+
old_path=Path(old_path) if old_path else None,
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
self.watcher._handle_change(change)
|
|
157
|
+
|
|
158
|
+
def on_created(self, event):
|
|
159
|
+
is_dir = isinstance(event, DirCreatedEvent)
|
|
160
|
+
self._process_event(event.src_path, ChangeType.CREATED, is_dir)
|
|
161
|
+
|
|
162
|
+
def on_modified(self, event):
|
|
163
|
+
if isinstance(event, (DirCreatedEvent, DirModifiedEvent, DirDeletedEvent)):
|
|
164
|
+
return # Ignore directory modifications
|
|
165
|
+
self._process_event(event.src_path, ChangeType.MODIFIED)
|
|
166
|
+
|
|
167
|
+
def on_deleted(self, event):
|
|
168
|
+
is_dir = isinstance(event, DirDeletedEvent)
|
|
169
|
+
self._process_event(event.src_path, ChangeType.DELETED, is_dir)
|
|
170
|
+
|
|
171
|
+
def on_moved(self, event):
|
|
172
|
+
is_dir = isinstance(event, DirMovedEvent)
|
|
173
|
+
self._process_event(event.dest_path, ChangeType.MOVED, is_dir, event.src_path)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class DirectoryWatcher:
|
|
177
|
+
"""
|
|
178
|
+
Real-time directory watcher using watchdog.
|
|
179
|
+
|
|
180
|
+
Monitors a directory for file changes and notifies registered callbacks.
|
|
181
|
+
Includes debouncing to handle rapid successive changes.
|
|
182
|
+
|
|
183
|
+
Usage:
|
|
184
|
+
watcher = DirectoryWatcher(project_root)
|
|
185
|
+
|
|
186
|
+
@watcher.on_change
|
|
187
|
+
def handle_change(change: FileChange):
|
|
188
|
+
print(f"{change.change_type}: {change.path}")
|
|
189
|
+
|
|
190
|
+
watcher.start()
|
|
191
|
+
# ... files are monitored ...
|
|
192
|
+
watcher.stop()
|
|
193
|
+
|
|
194
|
+
Async usage:
|
|
195
|
+
async def watch_files():
|
|
196
|
+
async for change in watcher.async_changes():
|
|
197
|
+
print(f"{change.change_type}: {change.path}")
|
|
198
|
+
"""
|
|
199
|
+
|
|
200
|
+
def __init__(
|
|
201
|
+
self,
|
|
202
|
+
root_path: Path,
|
|
203
|
+
config: Optional[WatcherConfig] = None,
|
|
204
|
+
):
|
|
205
|
+
if not WATCHDOG_AVAILABLE:
|
|
206
|
+
raise ImportError(
|
|
207
|
+
"watchdog is required for directory watching. Install with: pip install watchdog"
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
self.root_path = Path(root_path).resolve()
|
|
211
|
+
self.config = config or WatcherConfig()
|
|
212
|
+
|
|
213
|
+
# State
|
|
214
|
+
self._observer: Optional[Observer] = None
|
|
215
|
+
self._running = False
|
|
216
|
+
self._callbacks: Set[ChangeCallback] = set()
|
|
217
|
+
self._async_callbacks: Set[AsyncChangeCallback] = set()
|
|
218
|
+
|
|
219
|
+
# Debouncing
|
|
220
|
+
self._pending_changes: Dict[str, FileChange] = {}
|
|
221
|
+
self._debounce_timer: Optional[threading.Timer] = None
|
|
222
|
+
self._debounce_lock = threading.Lock()
|
|
223
|
+
|
|
224
|
+
# Async event queue
|
|
225
|
+
self._async_queue: Optional[asyncio.Queue] = None
|
|
226
|
+
self._loop: Optional[asyncio.AbstractEventLoop] = None
|
|
227
|
+
|
|
228
|
+
# Change buffer (for polling mode)
|
|
229
|
+
self._change_buffer: List[FileChange] = []
|
|
230
|
+
self._buffer_lock = threading.Lock()
|
|
231
|
+
|
|
232
|
+
@property
|
|
233
|
+
def is_running(self) -> bool:
|
|
234
|
+
"""Check if watcher is running."""
|
|
235
|
+
return self._running
|
|
236
|
+
|
|
237
|
+
def on_change(self, callback: ChangeCallback) -> ChangeCallback:
|
|
238
|
+
"""Decorator to register a change callback."""
|
|
239
|
+
self._callbacks.add(callback)
|
|
240
|
+
return callback
|
|
241
|
+
|
|
242
|
+
def on_change_async(self, callback: AsyncChangeCallback) -> AsyncChangeCallback:
|
|
243
|
+
"""Decorator to register an async change callback."""
|
|
244
|
+
self._async_callbacks.add(callback)
|
|
245
|
+
return callback
|
|
246
|
+
|
|
247
|
+
def remove_callback(self, callback: ChangeCallback) -> None:
|
|
248
|
+
"""Remove a registered callback."""
|
|
249
|
+
self._callbacks.discard(callback)
|
|
250
|
+
self._async_callbacks.discard(callback)
|
|
251
|
+
|
|
252
|
+
def _handle_change(self, change: FileChange) -> None:
|
|
253
|
+
"""Handle a change event (with debouncing)."""
|
|
254
|
+
path_key = str(change.path)
|
|
255
|
+
|
|
256
|
+
with self._debounce_lock:
|
|
257
|
+
# Update or add the pending change
|
|
258
|
+
existing = self._pending_changes.get(path_key)
|
|
259
|
+
|
|
260
|
+
if existing:
|
|
261
|
+
# Merge changes (e.g., create + modify = create)
|
|
262
|
+
if (
|
|
263
|
+
existing.change_type == ChangeType.CREATED
|
|
264
|
+
and change.change_type == ChangeType.MODIFIED
|
|
265
|
+
):
|
|
266
|
+
change = existing # Keep as created
|
|
267
|
+
elif (
|
|
268
|
+
existing.change_type == ChangeType.CREATED
|
|
269
|
+
and change.change_type == ChangeType.DELETED
|
|
270
|
+
):
|
|
271
|
+
# Created then deleted = no change
|
|
272
|
+
del self._pending_changes[path_key]
|
|
273
|
+
return
|
|
274
|
+
|
|
275
|
+
self._pending_changes[path_key] = change
|
|
276
|
+
|
|
277
|
+
# Reset debounce timer
|
|
278
|
+
if self._debounce_timer:
|
|
279
|
+
self._debounce_timer.cancel()
|
|
280
|
+
|
|
281
|
+
self._debounce_timer = threading.Timer(
|
|
282
|
+
self.config.debounce_interval,
|
|
283
|
+
self._flush_changes,
|
|
284
|
+
)
|
|
285
|
+
self._debounce_timer.start()
|
|
286
|
+
|
|
287
|
+
def _flush_changes(self) -> None:
|
|
288
|
+
"""Flush pending changes to callbacks."""
|
|
289
|
+
with self._debounce_lock:
|
|
290
|
+
changes = list(self._pending_changes.values())
|
|
291
|
+
self._pending_changes.clear()
|
|
292
|
+
|
|
293
|
+
for change in changes:
|
|
294
|
+
self._dispatch_change(change)
|
|
295
|
+
|
|
296
|
+
def _dispatch_change(self, change: FileChange) -> None:
|
|
297
|
+
"""Dispatch a change to all callbacks."""
|
|
298
|
+
# Add to buffer
|
|
299
|
+
with self._buffer_lock:
|
|
300
|
+
self._change_buffer.append(change)
|
|
301
|
+
# Limit buffer size
|
|
302
|
+
if len(self._change_buffer) > self.config.max_buffer_size:
|
|
303
|
+
self._change_buffer = self._change_buffer[-self.config.max_buffer_size :]
|
|
304
|
+
|
|
305
|
+
# Sync callbacks
|
|
306
|
+
for callback in self._callbacks:
|
|
307
|
+
try:
|
|
308
|
+
callback(change)
|
|
309
|
+
except Exception:
|
|
310
|
+
pass # Don't let one callback break others
|
|
311
|
+
|
|
312
|
+
# Async queue
|
|
313
|
+
if self._async_queue and self._loop:
|
|
314
|
+
try:
|
|
315
|
+
self._loop.call_soon_threadsafe(
|
|
316
|
+
self._async_queue.put_nowait,
|
|
317
|
+
change,
|
|
318
|
+
)
|
|
319
|
+
except Exception:
|
|
320
|
+
pass
|
|
321
|
+
|
|
322
|
+
# Async callbacks
|
|
323
|
+
for callback in self._async_callbacks:
|
|
324
|
+
if self._loop:
|
|
325
|
+
try:
|
|
326
|
+
asyncio.run_coroutine_threadsafe(callback(change), self._loop)
|
|
327
|
+
except Exception:
|
|
328
|
+
pass
|
|
329
|
+
|
|
330
|
+
def start(self) -> None:
|
|
331
|
+
"""Start watching the directory."""
|
|
332
|
+
if self._running:
|
|
333
|
+
return
|
|
334
|
+
|
|
335
|
+
self._observer = Observer()
|
|
336
|
+
handler = _WatchdogHandler(self)
|
|
337
|
+
|
|
338
|
+
self._observer.schedule(
|
|
339
|
+
handler,
|
|
340
|
+
str(self.root_path),
|
|
341
|
+
recursive=self.config.recursive,
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
self._observer.start()
|
|
345
|
+
self._running = True
|
|
346
|
+
|
|
347
|
+
def stop(self) -> None:
|
|
348
|
+
"""Stop watching the directory."""
|
|
349
|
+
if not self._running:
|
|
350
|
+
return
|
|
351
|
+
|
|
352
|
+
if self._debounce_timer:
|
|
353
|
+
self._debounce_timer.cancel()
|
|
354
|
+
self._flush_changes() # Flush any pending changes
|
|
355
|
+
|
|
356
|
+
if self._observer:
|
|
357
|
+
self._observer.stop()
|
|
358
|
+
self._observer.join(timeout=5.0)
|
|
359
|
+
self._observer = None
|
|
360
|
+
|
|
361
|
+
self._running = False
|
|
362
|
+
|
|
363
|
+
def get_recent_changes(self, count: int = 100) -> List[FileChange]:
|
|
364
|
+
"""Get recent changes from the buffer."""
|
|
365
|
+
with self._buffer_lock:
|
|
366
|
+
return self._change_buffer[-count:]
|
|
367
|
+
|
|
368
|
+
def clear_buffer(self) -> None:
|
|
369
|
+
"""Clear the change buffer."""
|
|
370
|
+
with self._buffer_lock:
|
|
371
|
+
self._change_buffer.clear()
|
|
372
|
+
|
|
373
|
+
async def async_changes(self) -> asyncio.AsyncIterator[FileChange]:
|
|
374
|
+
"""Async iterator for file changes.
|
|
375
|
+
|
|
376
|
+
Usage:
|
|
377
|
+
async for change in watcher.async_changes():
|
|
378
|
+
handle_change(change)
|
|
379
|
+
"""
|
|
380
|
+
self._loop = asyncio.get_event_loop()
|
|
381
|
+
self._async_queue = asyncio.Queue()
|
|
382
|
+
|
|
383
|
+
try:
|
|
384
|
+
while self._running:
|
|
385
|
+
try:
|
|
386
|
+
change = await asyncio.wait_for(
|
|
387
|
+
self._async_queue.get(),
|
|
388
|
+
timeout=1.0,
|
|
389
|
+
)
|
|
390
|
+
yield change
|
|
391
|
+
except asyncio.TimeoutError:
|
|
392
|
+
continue
|
|
393
|
+
finally:
|
|
394
|
+
self._async_queue = None
|
|
395
|
+
self._loop = None
|
|
396
|
+
|
|
397
|
+
def __enter__(self) -> "DirectoryWatcher":
|
|
398
|
+
self.start()
|
|
399
|
+
return self
|
|
400
|
+
|
|
401
|
+
def __exit__(self, *args) -> None:
|
|
402
|
+
self.stop()
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
class PollingWatcher:
|
|
406
|
+
"""
|
|
407
|
+
Fallback directory watcher using polling.
|
|
408
|
+
|
|
409
|
+
Used when watchdog is not available. Less efficient but works everywhere.
|
|
410
|
+
"""
|
|
411
|
+
|
|
412
|
+
def __init__(
|
|
413
|
+
self,
|
|
414
|
+
root_path: Path,
|
|
415
|
+
poll_interval: float = 1.0,
|
|
416
|
+
config: Optional[WatcherConfig] = None,
|
|
417
|
+
):
|
|
418
|
+
self.root_path = Path(root_path).resolve()
|
|
419
|
+
self.poll_interval = poll_interval
|
|
420
|
+
self.config = config or WatcherConfig()
|
|
421
|
+
|
|
422
|
+
self._running = False
|
|
423
|
+
self._thread: Optional[threading.Thread] = None
|
|
424
|
+
self._file_mtimes: Dict[str, float] = {}
|
|
425
|
+
self._callbacks: Set[ChangeCallback] = set()
|
|
426
|
+
|
|
427
|
+
def on_change(self, callback: ChangeCallback) -> ChangeCallback:
|
|
428
|
+
"""Register a change callback."""
|
|
429
|
+
self._callbacks.add(callback)
|
|
430
|
+
return callback
|
|
431
|
+
|
|
432
|
+
def _should_ignore(self, path: str) -> bool:
|
|
433
|
+
"""Check if path should be ignored."""
|
|
434
|
+
for pattern in self.config.ignore_patterns:
|
|
435
|
+
if fnmatch.fnmatch(path, pattern) or fnmatch.fnmatch(os.path.basename(path), pattern):
|
|
436
|
+
return True
|
|
437
|
+
return False
|
|
438
|
+
|
|
439
|
+
def _scan_directory(self) -> Dict[str, float]:
|
|
440
|
+
"""Scan directory and get file modification times."""
|
|
441
|
+
mtimes = {}
|
|
442
|
+
|
|
443
|
+
for root, dirs, files in os.walk(self.root_path):
|
|
444
|
+
# Filter ignored directories
|
|
445
|
+
dirs[:] = [d for d in dirs if not self._should_ignore(d)]
|
|
446
|
+
|
|
447
|
+
for file in files:
|
|
448
|
+
file_path = os.path.join(root, file)
|
|
449
|
+
if self._should_ignore(file_path):
|
|
450
|
+
continue
|
|
451
|
+
|
|
452
|
+
try:
|
|
453
|
+
mtimes[file_path] = os.path.getmtime(file_path)
|
|
454
|
+
except OSError:
|
|
455
|
+
continue
|
|
456
|
+
|
|
457
|
+
return mtimes
|
|
458
|
+
|
|
459
|
+
def _poll_loop(self) -> None:
|
|
460
|
+
"""Main polling loop."""
|
|
461
|
+
while self._running:
|
|
462
|
+
current_mtimes = self._scan_directory()
|
|
463
|
+
|
|
464
|
+
# Check for changes
|
|
465
|
+
current_files = set(current_mtimes.keys())
|
|
466
|
+
previous_files = set(self._file_mtimes.keys())
|
|
467
|
+
|
|
468
|
+
# New files
|
|
469
|
+
for path in current_files - previous_files:
|
|
470
|
+
change = FileChange(path=Path(path), change_type=ChangeType.CREATED)
|
|
471
|
+
for callback in self._callbacks:
|
|
472
|
+
try:
|
|
473
|
+
callback(change)
|
|
474
|
+
except Exception:
|
|
475
|
+
pass
|
|
476
|
+
|
|
477
|
+
# Deleted files
|
|
478
|
+
for path in previous_files - current_files:
|
|
479
|
+
change = FileChange(path=Path(path), change_type=ChangeType.DELETED)
|
|
480
|
+
for callback in self._callbacks:
|
|
481
|
+
try:
|
|
482
|
+
callback(change)
|
|
483
|
+
except Exception:
|
|
484
|
+
pass
|
|
485
|
+
|
|
486
|
+
# Modified files
|
|
487
|
+
for path in current_files & previous_files:
|
|
488
|
+
if current_mtimes[path] != self._file_mtimes[path]:
|
|
489
|
+
change = FileChange(path=Path(path), change_type=ChangeType.MODIFIED)
|
|
490
|
+
for callback in self._callbacks:
|
|
491
|
+
try:
|
|
492
|
+
callback(change)
|
|
493
|
+
except Exception:
|
|
494
|
+
pass
|
|
495
|
+
|
|
496
|
+
self._file_mtimes = current_mtimes
|
|
497
|
+
time.sleep(self.poll_interval)
|
|
498
|
+
|
|
499
|
+
def start(self) -> None:
|
|
500
|
+
"""Start polling."""
|
|
501
|
+
if self._running:
|
|
502
|
+
return
|
|
503
|
+
|
|
504
|
+
self._file_mtimes = self._scan_directory()
|
|
505
|
+
self._running = True
|
|
506
|
+
self._thread = threading.Thread(target=self._poll_loop, daemon=True)
|
|
507
|
+
self._thread.start()
|
|
508
|
+
|
|
509
|
+
def stop(self) -> None:
|
|
510
|
+
"""Stop polling."""
|
|
511
|
+
self._running = False
|
|
512
|
+
if self._thread:
|
|
513
|
+
self._thread.join(timeout=5.0)
|
|
514
|
+
self._thread = None
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def create_watcher(
|
|
518
|
+
root_path: Path,
|
|
519
|
+
config: Optional[WatcherConfig] = None,
|
|
520
|
+
use_polling: bool = False,
|
|
521
|
+
) -> DirectoryWatcher | PollingWatcher:
|
|
522
|
+
"""Create the appropriate watcher for the platform.
|
|
523
|
+
|
|
524
|
+
Args:
|
|
525
|
+
root_path: Directory to watch
|
|
526
|
+
config: Watcher configuration
|
|
527
|
+
use_polling: Force polling mode (default: auto-detect)
|
|
528
|
+
|
|
529
|
+
Returns:
|
|
530
|
+
DirectoryWatcher or PollingWatcher
|
|
531
|
+
"""
|
|
532
|
+
if use_polling or not WATCHDOG_AVAILABLE:
|
|
533
|
+
return PollingWatcher(root_path, config=config)
|
|
534
|
+
|
|
535
|
+
return DirectoryWatcher(root_path, config)
|