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.
Files changed (288) hide show
  1. superqode/__init__.py +33 -0
  2. superqode/acp/__init__.py +23 -0
  3. superqode/acp/client.py +913 -0
  4. superqode/acp/permission_screen.py +457 -0
  5. superqode/acp/types.py +480 -0
  6. superqode/acp_discovery.py +856 -0
  7. superqode/agent/__init__.py +22 -0
  8. superqode/agent/edit_strategies.py +334 -0
  9. superqode/agent/loop.py +892 -0
  10. superqode/agent/qe_report_templates.py +39 -0
  11. superqode/agent/system_prompts.py +353 -0
  12. superqode/agent_output.py +721 -0
  13. superqode/agent_stream.py +953 -0
  14. superqode/agents/__init__.py +59 -0
  15. superqode/agents/acp_registry.py +305 -0
  16. superqode/agents/client.py +249 -0
  17. superqode/agents/data/augmentcode.com.toml +51 -0
  18. superqode/agents/data/cagent.dev.toml +51 -0
  19. superqode/agents/data/claude.com.toml +60 -0
  20. superqode/agents/data/codeassistant.dev.toml +51 -0
  21. superqode/agents/data/codex.openai.com.toml +57 -0
  22. superqode/agents/data/fastagent.ai.toml +66 -0
  23. superqode/agents/data/geminicli.com.toml +77 -0
  24. superqode/agents/data/goose.block.xyz.toml +54 -0
  25. superqode/agents/data/junie.jetbrains.com.toml +56 -0
  26. superqode/agents/data/kimi.moonshot.cn.toml +57 -0
  27. superqode/agents/data/llmlingagent.dev.toml +51 -0
  28. superqode/agents/data/molt.bot.toml +49 -0
  29. superqode/agents/data/opencode.ai.toml +60 -0
  30. superqode/agents/data/stakpak.dev.toml +51 -0
  31. superqode/agents/data/vtcode.dev.toml +51 -0
  32. superqode/agents/discovery.py +266 -0
  33. superqode/agents/messaging.py +160 -0
  34. superqode/agents/persona.py +166 -0
  35. superqode/agents/registry.py +421 -0
  36. superqode/agents/schema.py +72 -0
  37. superqode/agents/unified.py +367 -0
  38. superqode/app/__init__.py +111 -0
  39. superqode/app/constants.py +314 -0
  40. superqode/app/css.py +366 -0
  41. superqode/app/models.py +118 -0
  42. superqode/app/suggester.py +125 -0
  43. superqode/app/widgets.py +1591 -0
  44. superqode/app_enhanced.py +399 -0
  45. superqode/app_main.py +17187 -0
  46. superqode/approval.py +312 -0
  47. superqode/atomic.py +296 -0
  48. superqode/commands/__init__.py +1 -0
  49. superqode/commands/acp.py +965 -0
  50. superqode/commands/agents.py +180 -0
  51. superqode/commands/auth.py +278 -0
  52. superqode/commands/config.py +374 -0
  53. superqode/commands/init.py +826 -0
  54. superqode/commands/providers.py +819 -0
  55. superqode/commands/qe.py +1145 -0
  56. superqode/commands/roles.py +380 -0
  57. superqode/commands/serve.py +172 -0
  58. superqode/commands/suggestions.py +127 -0
  59. superqode/commands/superqe.py +460 -0
  60. superqode/config/__init__.py +51 -0
  61. superqode/config/loader.py +812 -0
  62. superqode/config/schema.py +498 -0
  63. superqode/core/__init__.py +111 -0
  64. superqode/core/roles.py +281 -0
  65. superqode/danger.py +386 -0
  66. superqode/data/superqode-template.yaml +1522 -0
  67. superqode/design_system.py +1080 -0
  68. superqode/dialogs/__init__.py +6 -0
  69. superqode/dialogs/base.py +39 -0
  70. superqode/dialogs/model.py +130 -0
  71. superqode/dialogs/provider.py +870 -0
  72. superqode/diff_view.py +919 -0
  73. superqode/enterprise.py +21 -0
  74. superqode/evaluation/__init__.py +25 -0
  75. superqode/evaluation/adapters.py +93 -0
  76. superqode/evaluation/behaviors.py +89 -0
  77. superqode/evaluation/engine.py +209 -0
  78. superqode/evaluation/scenarios.py +96 -0
  79. superqode/execution/__init__.py +36 -0
  80. superqode/execution/linter.py +538 -0
  81. superqode/execution/modes.py +347 -0
  82. superqode/execution/resolver.py +283 -0
  83. superqode/execution/runner.py +642 -0
  84. superqode/file_explorer.py +811 -0
  85. superqode/file_viewer.py +471 -0
  86. superqode/flash.py +183 -0
  87. superqode/guidance/__init__.py +58 -0
  88. superqode/guidance/config.py +203 -0
  89. superqode/guidance/prompts.py +71 -0
  90. superqode/harness/__init__.py +54 -0
  91. superqode/harness/accelerator.py +291 -0
  92. superqode/harness/config.py +319 -0
  93. superqode/harness/validator.py +147 -0
  94. superqode/history.py +279 -0
  95. superqode/integrations/superopt_runner.py +124 -0
  96. superqode/logging/__init__.py +49 -0
  97. superqode/logging/adapters.py +219 -0
  98. superqode/logging/formatter.py +923 -0
  99. superqode/logging/integration.py +341 -0
  100. superqode/logging/sinks.py +170 -0
  101. superqode/logging/unified_log.py +417 -0
  102. superqode/lsp/__init__.py +26 -0
  103. superqode/lsp/client.py +544 -0
  104. superqode/main.py +1069 -0
  105. superqode/mcp/__init__.py +89 -0
  106. superqode/mcp/auth_storage.py +380 -0
  107. superqode/mcp/client.py +1236 -0
  108. superqode/mcp/config.py +319 -0
  109. superqode/mcp/integration.py +337 -0
  110. superqode/mcp/oauth.py +436 -0
  111. superqode/mcp/oauth_callback.py +385 -0
  112. superqode/mcp/types.py +290 -0
  113. superqode/memory/__init__.py +31 -0
  114. superqode/memory/feedback.py +342 -0
  115. superqode/memory/store.py +522 -0
  116. superqode/notifications.py +369 -0
  117. superqode/optimization/__init__.py +5 -0
  118. superqode/optimization/config.py +33 -0
  119. superqode/permissions/__init__.py +25 -0
  120. superqode/permissions/rules.py +488 -0
  121. superqode/plan.py +323 -0
  122. superqode/providers/__init__.py +33 -0
  123. superqode/providers/gateway/__init__.py +165 -0
  124. superqode/providers/gateway/base.py +228 -0
  125. superqode/providers/gateway/litellm_gateway.py +1170 -0
  126. superqode/providers/gateway/openresponses_gateway.py +436 -0
  127. superqode/providers/health.py +297 -0
  128. superqode/providers/huggingface/__init__.py +74 -0
  129. superqode/providers/huggingface/downloader.py +472 -0
  130. superqode/providers/huggingface/endpoints.py +442 -0
  131. superqode/providers/huggingface/hub.py +531 -0
  132. superqode/providers/huggingface/inference.py +394 -0
  133. superqode/providers/huggingface/transformers_runner.py +516 -0
  134. superqode/providers/local/__init__.py +100 -0
  135. superqode/providers/local/base.py +438 -0
  136. superqode/providers/local/discovery.py +418 -0
  137. superqode/providers/local/lmstudio.py +256 -0
  138. superqode/providers/local/mlx.py +457 -0
  139. superqode/providers/local/ollama.py +486 -0
  140. superqode/providers/local/sglang.py +268 -0
  141. superqode/providers/local/tgi.py +260 -0
  142. superqode/providers/local/tool_support.py +477 -0
  143. superqode/providers/local/vllm.py +258 -0
  144. superqode/providers/manager.py +1338 -0
  145. superqode/providers/models.py +1016 -0
  146. superqode/providers/models_dev.py +578 -0
  147. superqode/providers/openresponses/__init__.py +87 -0
  148. superqode/providers/openresponses/converters/__init__.py +17 -0
  149. superqode/providers/openresponses/converters/messages.py +343 -0
  150. superqode/providers/openresponses/converters/tools.py +268 -0
  151. superqode/providers/openresponses/schema/__init__.py +56 -0
  152. superqode/providers/openresponses/schema/models.py +585 -0
  153. superqode/providers/openresponses/streaming/__init__.py +5 -0
  154. superqode/providers/openresponses/streaming/parser.py +338 -0
  155. superqode/providers/openresponses/tools/__init__.py +21 -0
  156. superqode/providers/openresponses/tools/apply_patch.py +352 -0
  157. superqode/providers/openresponses/tools/code_interpreter.py +290 -0
  158. superqode/providers/openresponses/tools/file_search.py +333 -0
  159. superqode/providers/openresponses/tools/mcp_adapter.py +252 -0
  160. superqode/providers/registry.py +716 -0
  161. superqode/providers/usage.py +332 -0
  162. superqode/pure_mode.py +384 -0
  163. superqode/qr/__init__.py +23 -0
  164. superqode/qr/dashboard.py +781 -0
  165. superqode/qr/generator.py +1018 -0
  166. superqode/qr/templates.py +135 -0
  167. superqode/safety/__init__.py +41 -0
  168. superqode/safety/sandbox.py +413 -0
  169. superqode/safety/warnings.py +256 -0
  170. superqode/server/__init__.py +33 -0
  171. superqode/server/lsp_server.py +775 -0
  172. superqode/server/web.py +250 -0
  173. superqode/session/__init__.py +25 -0
  174. superqode/session/persistence.py +580 -0
  175. superqode/session/sharing.py +477 -0
  176. superqode/session.py +475 -0
  177. superqode/sidebar.py +2991 -0
  178. superqode/stream_view.py +648 -0
  179. superqode/styles/__init__.py +3 -0
  180. superqode/superqe/__init__.py +184 -0
  181. superqode/superqe/acp_runner.py +1064 -0
  182. superqode/superqe/constitution/__init__.py +62 -0
  183. superqode/superqe/constitution/evaluator.py +308 -0
  184. superqode/superqe/constitution/loader.py +432 -0
  185. superqode/superqe/constitution/schema.py +250 -0
  186. superqode/superqe/events.py +591 -0
  187. superqode/superqe/frameworks/__init__.py +65 -0
  188. superqode/superqe/frameworks/base.py +234 -0
  189. superqode/superqe/frameworks/e2e.py +263 -0
  190. superqode/superqe/frameworks/executor.py +237 -0
  191. superqode/superqe/frameworks/javascript.py +409 -0
  192. superqode/superqe/frameworks/python.py +373 -0
  193. superqode/superqe/frameworks/registry.py +92 -0
  194. superqode/superqe/mcp_tools/__init__.py +47 -0
  195. superqode/superqe/mcp_tools/core_tools.py +418 -0
  196. superqode/superqe/mcp_tools/registry.py +230 -0
  197. superqode/superqe/mcp_tools/testing_tools.py +167 -0
  198. superqode/superqe/noise.py +89 -0
  199. superqode/superqe/orchestrator.py +778 -0
  200. superqode/superqe/roles.py +609 -0
  201. superqode/superqe/session.py +713 -0
  202. superqode/superqe/skills/__init__.py +57 -0
  203. superqode/superqe/skills/base.py +106 -0
  204. superqode/superqe/skills/core_skills.py +899 -0
  205. superqode/superqe/skills/registry.py +90 -0
  206. superqode/superqe/verifier.py +101 -0
  207. superqode/superqe_cli.py +76 -0
  208. superqode/tool_call.py +358 -0
  209. superqode/tools/__init__.py +93 -0
  210. superqode/tools/agent_tools.py +496 -0
  211. superqode/tools/base.py +324 -0
  212. superqode/tools/batch_tool.py +133 -0
  213. superqode/tools/diagnostics.py +311 -0
  214. superqode/tools/edit_tools.py +653 -0
  215. superqode/tools/enhanced_base.py +515 -0
  216. superqode/tools/file_tools.py +269 -0
  217. superqode/tools/file_tracking.py +45 -0
  218. superqode/tools/lsp_tools.py +610 -0
  219. superqode/tools/network_tools.py +350 -0
  220. superqode/tools/permissions.py +400 -0
  221. superqode/tools/question_tool.py +324 -0
  222. superqode/tools/search_tools.py +598 -0
  223. superqode/tools/shell_tools.py +259 -0
  224. superqode/tools/todo_tools.py +121 -0
  225. superqode/tools/validation.py +80 -0
  226. superqode/tools/web_tools.py +639 -0
  227. superqode/tui.py +1152 -0
  228. superqode/tui_integration.py +875 -0
  229. superqode/tui_widgets/__init__.py +27 -0
  230. superqode/tui_widgets/widgets/__init__.py +18 -0
  231. superqode/tui_widgets/widgets/progress.py +185 -0
  232. superqode/tui_widgets/widgets/tool_display.py +188 -0
  233. superqode/undo_manager.py +574 -0
  234. superqode/utils/__init__.py +5 -0
  235. superqode/utils/error_handling.py +323 -0
  236. superqode/utils/fuzzy.py +257 -0
  237. superqode/widgets/__init__.py +477 -0
  238. superqode/widgets/agent_collab.py +390 -0
  239. superqode/widgets/agent_store.py +936 -0
  240. superqode/widgets/agent_switcher.py +395 -0
  241. superqode/widgets/animation_manager.py +284 -0
  242. superqode/widgets/code_context.py +356 -0
  243. superqode/widgets/command_palette.py +412 -0
  244. superqode/widgets/connection_status.py +537 -0
  245. superqode/widgets/conversation_history.py +470 -0
  246. superqode/widgets/diff_indicator.py +155 -0
  247. superqode/widgets/enhanced_status_bar.py +385 -0
  248. superqode/widgets/enhanced_toast.py +476 -0
  249. superqode/widgets/file_browser.py +809 -0
  250. superqode/widgets/file_reference.py +585 -0
  251. superqode/widgets/issue_timeline.py +340 -0
  252. superqode/widgets/leader_key.py +264 -0
  253. superqode/widgets/mode_switcher.py +445 -0
  254. superqode/widgets/model_picker.py +234 -0
  255. superqode/widgets/permission_preview.py +1205 -0
  256. superqode/widgets/prompt.py +358 -0
  257. superqode/widgets/provider_connect.py +725 -0
  258. superqode/widgets/pty_shell.py +587 -0
  259. superqode/widgets/qe_dashboard.py +321 -0
  260. superqode/widgets/resizable_sidebar.py +377 -0
  261. superqode/widgets/response_changes.py +218 -0
  262. superqode/widgets/response_display.py +528 -0
  263. superqode/widgets/rich_tool_display.py +613 -0
  264. superqode/widgets/sidebar_panels.py +1180 -0
  265. superqode/widgets/slash_complete.py +356 -0
  266. superqode/widgets/split_view.py +612 -0
  267. superqode/widgets/status_bar.py +273 -0
  268. superqode/widgets/superqode_display.py +786 -0
  269. superqode/widgets/thinking_display.py +815 -0
  270. superqode/widgets/throbber.py +87 -0
  271. superqode/widgets/toast.py +206 -0
  272. superqode/widgets/unified_output.py +1073 -0
  273. superqode/workspace/__init__.py +75 -0
  274. superqode/workspace/artifacts.py +472 -0
  275. superqode/workspace/coordinator.py +353 -0
  276. superqode/workspace/diff_tracker.py +429 -0
  277. superqode/workspace/git_guard.py +373 -0
  278. superqode/workspace/git_snapshot.py +526 -0
  279. superqode/workspace/manager.py +750 -0
  280. superqode/workspace/snapshot.py +357 -0
  281. superqode/workspace/watcher.py +535 -0
  282. superqode/workspace/worktree.py +440 -0
  283. superqode-0.1.5.dist-info/METADATA +204 -0
  284. superqode-0.1.5.dist-info/RECORD +288 -0
  285. superqode-0.1.5.dist-info/WHEEL +5 -0
  286. superqode-0.1.5.dist-info/entry_points.txt +3 -0
  287. superqode-0.1.5.dist-info/licenses/LICENSE +648 -0
  288. 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)