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.
@@ -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