pdit 0.1.0__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.
- pdit/__init__.py +11 -0
- pdit/_static/assets/index-BkEyY6gm.js +135 -0
- pdit/_static/assets/index-DxOOJTA1.css +1 -0
- pdit/_static/export.html +74 -0
- pdit/_static/index.html +14 -0
- pdit/cli.py +250 -0
- pdit/exporter.py +90 -0
- pdit/file_watcher.py +162 -0
- pdit/ipython_executor.py +350 -0
- pdit/server.py +410 -0
- pdit-0.1.0.dist-info/METADATA +155 -0
- pdit-0.1.0.dist-info/RECORD +15 -0
- pdit-0.1.0.dist-info/WHEEL +5 -0
- pdit-0.1.0.dist-info/entry_points.txt +2 -0
- pdit-0.1.0.dist-info/top_level.txt +1 -0
pdit/_static/index.html
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
|
6
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
7
|
+
<title>pdit</title>
|
|
8
|
+
<script type="module" crossorigin src="/assets/index-BkEyY6gm.js"></script>
|
|
9
|
+
<link rel="stylesheet" crossorigin href="/assets/index-DxOOJTA1.css">
|
|
10
|
+
</head>
|
|
11
|
+
<body>
|
|
12
|
+
<div id="app"></div>
|
|
13
|
+
</body>
|
|
14
|
+
</html>
|
pdit/cli.py
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Command-line interface for pdit.
|
|
3
|
+
|
|
4
|
+
Provides the `pdit` command to start the server and open the web interface.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import contextlib
|
|
8
|
+
import signal
|
|
9
|
+
import socket
|
|
10
|
+
import sys
|
|
11
|
+
import time
|
|
12
|
+
import threading
|
|
13
|
+
import webbrowser
|
|
14
|
+
import secrets
|
|
15
|
+
import urllib.parse
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Optional
|
|
18
|
+
|
|
19
|
+
import typer
|
|
20
|
+
from typing_extensions import Annotated
|
|
21
|
+
import uvicorn
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# Flag for graceful shutdown on SIGTERM
|
|
25
|
+
_shutdown_requested = False
|
|
26
|
+
|
|
27
|
+
app = typer.Typer(add_completion=False)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def find_available_port(start_port=8888, max_tries=100):
|
|
31
|
+
"""Find an available port starting from start_port.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
start_port: Port to start searching from
|
|
35
|
+
max_tries: Maximum number of ports to try
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
Available port number
|
|
39
|
+
|
|
40
|
+
Raises:
|
|
41
|
+
RuntimeError: If no available port found within max_tries
|
|
42
|
+
"""
|
|
43
|
+
for port in range(start_port, start_port + max_tries):
|
|
44
|
+
try:
|
|
45
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
46
|
+
# Allow reuse of ports in TIME_WAIT state
|
|
47
|
+
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
48
|
+
s.bind(("127.0.0.1", port))
|
|
49
|
+
return port
|
|
50
|
+
except OSError:
|
|
51
|
+
continue
|
|
52
|
+
raise RuntimeError(f"Could not find available port in range {start_port}-{start_port + max_tries}")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class Server(uvicorn.Server):
|
|
56
|
+
"""Custom Server class that can run in a background thread."""
|
|
57
|
+
|
|
58
|
+
def install_signal_handlers(self):
|
|
59
|
+
"""Disable signal handlers for threading compatibility."""
|
|
60
|
+
pass
|
|
61
|
+
|
|
62
|
+
@contextlib.contextmanager
|
|
63
|
+
def run_in_thread(self):
|
|
64
|
+
"""Run server in background thread, wait for startup."""
|
|
65
|
+
thread = threading.Thread(target=self.run, daemon=True)
|
|
66
|
+
thread.start()
|
|
67
|
+
try:
|
|
68
|
+
# Wait for server to be ready
|
|
69
|
+
while not self.started:
|
|
70
|
+
time.sleep(1e-3)
|
|
71
|
+
yield
|
|
72
|
+
finally:
|
|
73
|
+
# Signal WebSocket connections to close before shutting down server
|
|
74
|
+
from .server import signal_shutdown
|
|
75
|
+
signal_shutdown()
|
|
76
|
+
|
|
77
|
+
# Give connections a moment to close
|
|
78
|
+
time.sleep(0.2)
|
|
79
|
+
|
|
80
|
+
# Clean shutdown
|
|
81
|
+
self.should_exit = True
|
|
82
|
+
thread.join(timeout=3.0)
|
|
83
|
+
if thread.is_alive():
|
|
84
|
+
# Force exit if shutdown takes too long
|
|
85
|
+
import sys
|
|
86
|
+
sys.exit(1)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def start(
|
|
90
|
+
script: Optional[Path] = None,
|
|
91
|
+
port: Optional[int] = None,
|
|
92
|
+
host: str = "127.0.0.1",
|
|
93
|
+
no_browser: bool = False,
|
|
94
|
+
no_token_auth: bool = False,
|
|
95
|
+
):
|
|
96
|
+
"""Start the pdit server with optional script."""
|
|
97
|
+
|
|
98
|
+
# Check if frontend is built
|
|
99
|
+
static_dir = Path(__file__).parent / "_static"
|
|
100
|
+
if not static_dir.exists() or not (static_dir / "index.html").exists():
|
|
101
|
+
typer.echo("Warning: Frontend build not found at pdit/_static/", err=True)
|
|
102
|
+
typer.echo("The server will start but the web interface won't be available.", err=True)
|
|
103
|
+
typer.echo("Run './scripts/build-frontend.sh' to build and copy the frontend.", err=True)
|
|
104
|
+
typer.echo()
|
|
105
|
+
|
|
106
|
+
# Use script path as-is (relative to current directory)
|
|
107
|
+
script_path = None
|
|
108
|
+
if script:
|
|
109
|
+
script_path = str(script)
|
|
110
|
+
|
|
111
|
+
# Determine port to use
|
|
112
|
+
if port is None:
|
|
113
|
+
# No port specified: find available port starting from 8888
|
|
114
|
+
actual_port = find_available_port(start_port=8888)
|
|
115
|
+
if actual_port != 8888:
|
|
116
|
+
typer.echo(f"Port 8888 is already in use, using port {actual_port} instead")
|
|
117
|
+
else:
|
|
118
|
+
# Port explicitly specified: use it or fail
|
|
119
|
+
try:
|
|
120
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
121
|
+
# Allow reuse of ports in TIME_WAIT state
|
|
122
|
+
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
123
|
+
s.bind((host, port))
|
|
124
|
+
actual_port = port
|
|
125
|
+
except OSError:
|
|
126
|
+
typer.echo(f"Error: Port {port} is already in use", err=True)
|
|
127
|
+
sys.exit(1)
|
|
128
|
+
|
|
129
|
+
# Pass port/token to server via environment variables for CORS and auth
|
|
130
|
+
import os
|
|
131
|
+
os.environ["PDIT_PORT"] = str(actual_port)
|
|
132
|
+
token = None
|
|
133
|
+
if no_token_auth:
|
|
134
|
+
os.environ.pop("PDIT_TOKEN", None)
|
|
135
|
+
else:
|
|
136
|
+
token = os.environ.get("PDIT_TOKEN")
|
|
137
|
+
if not token:
|
|
138
|
+
token = secrets.token_urlsafe(24)
|
|
139
|
+
os.environ["PDIT_TOKEN"] = token
|
|
140
|
+
|
|
141
|
+
# Build URL with token and optional script
|
|
142
|
+
url = f"http://{host}:{actual_port}"
|
|
143
|
+
params = {}
|
|
144
|
+
if script_path:
|
|
145
|
+
params["script"] = script_path
|
|
146
|
+
if token:
|
|
147
|
+
params["token"] = token
|
|
148
|
+
if params:
|
|
149
|
+
url = f"{url}?{urllib.parse.urlencode(params)}"
|
|
150
|
+
|
|
151
|
+
typer.echo(f"Starting pdit server on {host}:{actual_port}")
|
|
152
|
+
|
|
153
|
+
# Configure and create server
|
|
154
|
+
config = uvicorn.Config(
|
|
155
|
+
"pdit.server:app",
|
|
156
|
+
host=host,
|
|
157
|
+
port=actual_port,
|
|
158
|
+
log_level="info"
|
|
159
|
+
)
|
|
160
|
+
server = Server(config=config)
|
|
161
|
+
|
|
162
|
+
# Run server in thread, open browser when ready
|
|
163
|
+
with server.run_in_thread():
|
|
164
|
+
# Server is guaranteed to be ready here
|
|
165
|
+
if not no_browser:
|
|
166
|
+
webbrowser.open(url)
|
|
167
|
+
typer.echo(f"Opening browser to {url}")
|
|
168
|
+
|
|
169
|
+
# Set up SIGTERM handler for graceful shutdown
|
|
170
|
+
def handle_sigterm(signum, frame):
|
|
171
|
+
global _shutdown_requested
|
|
172
|
+
_shutdown_requested = True
|
|
173
|
+
|
|
174
|
+
signal.signal(signal.SIGTERM, handle_sigterm)
|
|
175
|
+
|
|
176
|
+
# Keep server running
|
|
177
|
+
try:
|
|
178
|
+
while not _shutdown_requested:
|
|
179
|
+
time.sleep(0.1) # Check more frequently for shutdown
|
|
180
|
+
typer.echo("\nShutting down...")
|
|
181
|
+
except KeyboardInterrupt:
|
|
182
|
+
typer.echo("\nShutting down...")
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
@app.command()
|
|
186
|
+
def main_command(
|
|
187
|
+
script: Annotated[
|
|
188
|
+
Optional[Path],
|
|
189
|
+
typer.Argument(help="Python script file to open", exists=True, dir_okay=False)
|
|
190
|
+
] = None,
|
|
191
|
+
export: Annotated[
|
|
192
|
+
bool,
|
|
193
|
+
typer.Option("--export", "-e", help="Export script to self-contained HTML file")
|
|
194
|
+
] = False,
|
|
195
|
+
output: Annotated[
|
|
196
|
+
Optional[Path],
|
|
197
|
+
typer.Option("-o", "--output", help="Output file for export (default: script.html)")
|
|
198
|
+
] = None,
|
|
199
|
+
stdout: Annotated[
|
|
200
|
+
bool,
|
|
201
|
+
typer.Option("--stdout", help="Write export to stdout instead of file")
|
|
202
|
+
] = False,
|
|
203
|
+
port: Annotated[
|
|
204
|
+
Optional[int],
|
|
205
|
+
typer.Option(help="Port to run server on (default: 8888, or next available)")
|
|
206
|
+
] = None,
|
|
207
|
+
host: Annotated[
|
|
208
|
+
str,
|
|
209
|
+
typer.Option(help="Host to bind to")
|
|
210
|
+
] = "127.0.0.1",
|
|
211
|
+
no_browser: Annotated[
|
|
212
|
+
bool,
|
|
213
|
+
typer.Option("--no-browser", help="Don't open browser automatically")
|
|
214
|
+
] = False,
|
|
215
|
+
no_token_auth: Annotated[
|
|
216
|
+
bool,
|
|
217
|
+
typer.Option("--no-token-auth", help="Disable token authentication for API access")
|
|
218
|
+
] = False,
|
|
219
|
+
):
|
|
220
|
+
"""Start the pdit server, or export a script to HTML with --export."""
|
|
221
|
+
if export:
|
|
222
|
+
if not script:
|
|
223
|
+
typer.echo("Error: script is required for --export", err=True)
|
|
224
|
+
raise typer.Exit(1)
|
|
225
|
+
|
|
226
|
+
from .exporter import export_script
|
|
227
|
+
|
|
228
|
+
try:
|
|
229
|
+
html_output = export_script(script)
|
|
230
|
+
except FileNotFoundError as e:
|
|
231
|
+
typer.echo(f"Error: {e}", err=True)
|
|
232
|
+
raise typer.Exit(1)
|
|
233
|
+
|
|
234
|
+
if stdout:
|
|
235
|
+
typer.echo(html_output)
|
|
236
|
+
else:
|
|
237
|
+
output_path = output if output else script.with_suffix('.html')
|
|
238
|
+
output_path.write_text(html_output)
|
|
239
|
+
typer.echo(f"Exported to {output_path}")
|
|
240
|
+
else:
|
|
241
|
+
start(script, port, host, no_browser, no_token_auth)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def main():
|
|
245
|
+
"""Entry point for the CLI."""
|
|
246
|
+
app()
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
if __name__ == "__main__":
|
|
250
|
+
app()
|
pdit/exporter.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Export functionality for pdit scripts."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from .ipython_executor import IPythonExecutor
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def execute_script(script_content: str, script_name: str) -> list[dict[str, Any]]:
|
|
11
|
+
"""Execute a script and return expressions in frontend format.
|
|
12
|
+
|
|
13
|
+
Args:
|
|
14
|
+
script_content: The Python source code to execute
|
|
15
|
+
script_name: Name of the script (for error messages)
|
|
16
|
+
|
|
17
|
+
Returns:
|
|
18
|
+
List of expression dicts ready for frontend consumption
|
|
19
|
+
"""
|
|
20
|
+
executor = IPythonExecutor()
|
|
21
|
+
expressions = []
|
|
22
|
+
expression_id = 0
|
|
23
|
+
|
|
24
|
+
try:
|
|
25
|
+
for event in executor.execute_script(script_content, script_name=script_name):
|
|
26
|
+
# Skip the expressions list event
|
|
27
|
+
if event.get("type") == "expressions":
|
|
28
|
+
continue
|
|
29
|
+
# Result events have output field
|
|
30
|
+
if "output" in event:
|
|
31
|
+
expressions.append({
|
|
32
|
+
"id": expression_id,
|
|
33
|
+
"lineStart": event["lineStart"],
|
|
34
|
+
"lineEnd": event["lineEnd"],
|
|
35
|
+
"state": "done",
|
|
36
|
+
"result": {
|
|
37
|
+
"output": event["output"],
|
|
38
|
+
"isInvisible": event["isInvisible"]
|
|
39
|
+
}
|
|
40
|
+
})
|
|
41
|
+
expression_id += 1
|
|
42
|
+
finally:
|
|
43
|
+
executor.shutdown()
|
|
44
|
+
|
|
45
|
+
return expressions
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def generate_html(script_content: str, expressions: list[dict[str, Any]]) -> str:
|
|
49
|
+
"""Generate self-contained HTML from script and execution results.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
script_content: The original Python source code
|
|
53
|
+
expressions: List of expression results from execute_script()
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
Complete HTML string ready to write to file
|
|
57
|
+
|
|
58
|
+
Raises:
|
|
59
|
+
FileNotFoundError: If export.html template is missing
|
|
60
|
+
"""
|
|
61
|
+
static_dir = Path(__file__).parent / "_static"
|
|
62
|
+
export_html_path = static_dir / "export.html"
|
|
63
|
+
|
|
64
|
+
if not export_html_path.exists():
|
|
65
|
+
raise FileNotFoundError("export.html not found. Run './scripts/build-frontend.sh' first.")
|
|
66
|
+
|
|
67
|
+
template = export_html_path.read_text()
|
|
68
|
+
|
|
69
|
+
response_data = {
|
|
70
|
+
"code": script_content,
|
|
71
|
+
"expressions": expressions
|
|
72
|
+
}
|
|
73
|
+
json_data = json.dumps(response_data).replace("<", "\\u003c")
|
|
74
|
+
injection_script = f'<script>window.__pdit_response__ = {json_data};</script>'
|
|
75
|
+
|
|
76
|
+
return template.replace('</head>', f'{injection_script}\n</head>')
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def export_script(script_path: Path) -> str:
|
|
80
|
+
"""Execute a script and generate HTML export.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
script_path: Path to the Python script
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
Complete HTML string
|
|
87
|
+
"""
|
|
88
|
+
script_content = script_path.read_text()
|
|
89
|
+
expressions = execute_script(script_content, script_path.name)
|
|
90
|
+
return generate_html(script_content, expressions)
|
pdit/file_watcher.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""
|
|
2
|
+
File watching functionality using watchfiles.
|
|
3
|
+
|
|
4
|
+
Provides a FileWatcher class that monitors a single file for changes
|
|
5
|
+
and streams events through an async queue.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import threading
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import AsyncGenerator, Optional, Union
|
|
12
|
+
from watchfiles import awatch
|
|
13
|
+
import time
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class FileEvent:
|
|
18
|
+
"""Base class for file watcher events."""
|
|
19
|
+
path: str
|
|
20
|
+
timestamp: int
|
|
21
|
+
type: str = field(init=False)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class InitialFileEvent(FileEvent):
|
|
26
|
+
"""Initial file content event."""
|
|
27
|
+
content: str
|
|
28
|
+
type: str = field(default="initial", init=False)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class FileChangedEvent(FileEvent):
|
|
33
|
+
"""File modification event."""
|
|
34
|
+
content: str
|
|
35
|
+
type: str = field(default="fileChanged", init=False)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class FileDeletedEvent(FileEvent):
|
|
40
|
+
"""File deletion event."""
|
|
41
|
+
type: str = field(default="fileDeleted", init=False)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class FileErrorEvent(FileEvent):
|
|
46
|
+
"""File error event."""
|
|
47
|
+
message: str
|
|
48
|
+
type: str = field(default="error", init=False)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class FileWatcher:
|
|
52
|
+
"""Watch a single file for changes and stream events.
|
|
53
|
+
|
|
54
|
+
Uses watchfiles (Rust-based) for fast, async file watching.
|
|
55
|
+
|
|
56
|
+
Usage:
|
|
57
|
+
watcher = FileWatcher("/path/to/file.py")
|
|
58
|
+
|
|
59
|
+
async for event in watcher.watch_with_initial():
|
|
60
|
+
if isinstance(event, InitialFileEvent):
|
|
61
|
+
print(f"Initial: {event.content}")
|
|
62
|
+
elif isinstance(event, FileChangedEvent):
|
|
63
|
+
print(f"Changed: {event.content}")
|
|
64
|
+
elif isinstance(event, FileDeletedEvent):
|
|
65
|
+
print("Deleted")
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
def __init__(self, file_path: str, stop_event: Optional[threading.Event] = None):
|
|
69
|
+
"""Initialize file watcher.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
file_path: Absolute path to file to watch
|
|
73
|
+
stop_event: Optional threading.Event to signal watcher to stop
|
|
74
|
+
"""
|
|
75
|
+
self.file_path = Path(file_path).resolve()
|
|
76
|
+
self.stop_event = stop_event
|
|
77
|
+
|
|
78
|
+
async def watch_with_initial(
|
|
79
|
+
self
|
|
80
|
+
) -> AsyncGenerator[Union[InitialFileEvent, FileChangedEvent, FileDeletedEvent, FileErrorEvent], None]:
|
|
81
|
+
"""Watch file with initial content event.
|
|
82
|
+
|
|
83
|
+
Encapsulates all domain logic: file validation, reading, timestamps, error handling.
|
|
84
|
+
|
|
85
|
+
Yields:
|
|
86
|
+
FileEvent subclasses with all domain data
|
|
87
|
+
|
|
88
|
+
Example:
|
|
89
|
+
watcher = FileWatcher("/path/to/file.py")
|
|
90
|
+
async for event in watcher.watch_with_initial():
|
|
91
|
+
if isinstance(event, InitialFileEvent):
|
|
92
|
+
print(f"Initial: {event.content}")
|
|
93
|
+
elif isinstance(event, FileChangedEvent):
|
|
94
|
+
print(f"Changed: {event.content}")
|
|
95
|
+
"""
|
|
96
|
+
# Validate file exists
|
|
97
|
+
if not self.file_path.exists():
|
|
98
|
+
yield FileErrorEvent(
|
|
99
|
+
path=str(self.file_path),
|
|
100
|
+
message=f"File not found: {self.file_path}",
|
|
101
|
+
timestamp=int(time.time())
|
|
102
|
+
)
|
|
103
|
+
return
|
|
104
|
+
|
|
105
|
+
# Read and yield initial content
|
|
106
|
+
try:
|
|
107
|
+
content = self.file_path.read_text()
|
|
108
|
+
timestamp = int(time.time())
|
|
109
|
+
|
|
110
|
+
yield InitialFileEvent(
|
|
111
|
+
path=str(self.file_path),
|
|
112
|
+
content=content,
|
|
113
|
+
timestamp=timestamp
|
|
114
|
+
)
|
|
115
|
+
except Exception as e:
|
|
116
|
+
yield FileErrorEvent(
|
|
117
|
+
path=str(self.file_path),
|
|
118
|
+
message=f"Error reading file: {str(e)}",
|
|
119
|
+
timestamp=int(time.time())
|
|
120
|
+
)
|
|
121
|
+
return
|
|
122
|
+
|
|
123
|
+
# Watch for changes in parent directory to detect deletion
|
|
124
|
+
watch_path = self.file_path.parent
|
|
125
|
+
|
|
126
|
+
# Use rust_timeout to make awatch check stop_event frequently (100ms)
|
|
127
|
+
# This ensures quick response to server shutdown
|
|
128
|
+
async for changes in awatch(watch_path, stop_event=self.stop_event, rust_timeout=100):
|
|
129
|
+
for change_type, changed_path in changes:
|
|
130
|
+
changed_path = Path(changed_path).resolve()
|
|
131
|
+
|
|
132
|
+
# Skip events for other files
|
|
133
|
+
if changed_path != self.file_path:
|
|
134
|
+
continue
|
|
135
|
+
|
|
136
|
+
from watchfiles import Change
|
|
137
|
+
|
|
138
|
+
# Handle file deletion
|
|
139
|
+
if change_type == Change.deleted:
|
|
140
|
+
yield FileDeletedEvent(
|
|
141
|
+
path=str(self.file_path),
|
|
142
|
+
timestamp=int(time.time())
|
|
143
|
+
)
|
|
144
|
+
return
|
|
145
|
+
|
|
146
|
+
# Handle file modification (Change.added or Change.modified)
|
|
147
|
+
try:
|
|
148
|
+
content = self.file_path.read_text()
|
|
149
|
+
timestamp = int(time.time())
|
|
150
|
+
|
|
151
|
+
yield FileChangedEvent(
|
|
152
|
+
path=str(self.file_path),
|
|
153
|
+
content=content,
|
|
154
|
+
timestamp=timestamp
|
|
155
|
+
)
|
|
156
|
+
except Exception as e:
|
|
157
|
+
yield FileErrorEvent(
|
|
158
|
+
path=str(self.file_path),
|
|
159
|
+
message=f"Error reading file: {str(e)}",
|
|
160
|
+
timestamp=int(time.time())
|
|
161
|
+
)
|
|
162
|
+
return
|