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/server.py ADDED
@@ -0,0 +1,410 @@
1
+ """
2
+ FastAPI server for local Python code execution.
3
+
4
+ Provides HTTP endpoints for:
5
+ - Listing Python files
6
+ - Saving files to disk
7
+ - Serving static frontend files
8
+
9
+ And a WebSocket endpoint for:
10
+ - Executing Python scripts
11
+ - Watching a script file for changes
12
+ """
13
+
14
+ import asyncio
15
+ import os
16
+ import threading
17
+ from contextlib import asynccontextmanager
18
+ from dataclasses import asdict, dataclass, field
19
+ from pathlib import Path
20
+ from typing import List, Optional
21
+ from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect
22
+ from fastapi.middleware.cors import CORSMiddleware
23
+ from fastapi.staticfiles import StaticFiles
24
+ from fastapi.responses import FileResponse, JSONResponse
25
+ from pydantic import BaseModel
26
+
27
+ from .ipython_executor import IPythonExecutor
28
+ from .file_watcher import FileWatcher
29
+
30
+ # Global shutdown event for WebSocket connections (threading.Event works across threads)
31
+ shutdown_event = threading.Event()
32
+
33
+
34
+ @dataclass
35
+ class Session:
36
+ """Represents a session with an IPython executor and state tracking."""
37
+ executor: IPythonExecutor
38
+ is_executing: bool = False
39
+ lock: asyncio.Lock = field(default_factory=asyncio.Lock)
40
+ file_watcher_task: Optional[asyncio.Task] = None
41
+ watcher_stop_event: Optional[threading.Event] = None
42
+
43
+
44
+ # Session registry: maps session_id -> Session
45
+ _sessions: dict[str, Session] = {}
46
+ _sessions_lock = threading.Lock() # Thread-safe session creation/deletion
47
+
48
+
49
+ def get_or_create_session(session_id: str) -> Session:
50
+ """Get existing session or create a new one (thread-safe).
51
+
52
+ The kernel starts immediately in the background so it's ready when the user
53
+ executes code. File watching and other operations don't wait for the kernel.
54
+ """
55
+ with _sessions_lock:
56
+ if session_id not in _sessions:
57
+ executor = IPythonExecutor()
58
+ executor.start() # Start kernel in background immediately
59
+ _sessions[session_id] = Session(executor=executor)
60
+ return _sessions[session_id]
61
+
62
+
63
+ async def delete_session(session_id: str) -> None:
64
+ """Delete a session if it exists, shutting down its kernel."""
65
+ session = None
66
+ with _sessions_lock:
67
+ if session_id in _sessions:
68
+ session = _sessions.pop(session_id)
69
+
70
+ # Do async cleanup outside the lock to avoid blocking other connections
71
+ if session:
72
+ if session.watcher_stop_event:
73
+ session.watcher_stop_event.set()
74
+ if session.file_watcher_task:
75
+ session.file_watcher_task.cancel()
76
+ await session.executor.shutdown()
77
+
78
+
79
+ async def shutdown_all_sessions() -> None:
80
+ """Shutdown all active sessions. Called on server shutdown."""
81
+ for session_id in list(_sessions.keys()):
82
+ await delete_session(session_id)
83
+
84
+
85
+ def is_error_result(result: dict) -> bool:
86
+ """Check if an execution result represents an error."""
87
+ return any(item["type"] == "error" for item in result.get("output", []))
88
+
89
+
90
+ def signal_shutdown():
91
+ """Signal WebSocket connections to close and cleanup. Called by cli.py before server shutdown."""
92
+ shutdown_event.set()
93
+ # Run async shutdown in a new event loop if called from sync context
94
+ try:
95
+ loop = asyncio.get_running_loop()
96
+ # If there's a running loop, schedule it
97
+ asyncio.create_task(shutdown_all_sessions())
98
+ except RuntimeError:
99
+ # No running loop, create one
100
+ asyncio.run(shutdown_all_sessions())
101
+
102
+
103
+ class SaveFileRequest(BaseModel):
104
+ """Request to save a file."""
105
+ path: str
106
+ content: str
107
+
108
+
109
+ @asynccontextmanager
110
+ async def lifespan(app: FastAPI):
111
+ """Manage app lifecycle - cleanup on shutdown."""
112
+ yield
113
+ # Signal all connections to close and shutdown kernels
114
+ shutdown_event.set()
115
+ await shutdown_all_sessions()
116
+
117
+
118
+ # FastAPI app
119
+ app = FastAPI(
120
+ title="pdit Python Backend",
121
+ description="Local Python execution server for pdit",
122
+ version="0.1.0",
123
+ lifespan=lifespan
124
+ )
125
+
126
+
127
+ @app.middleware("http")
128
+ async def require_token(request: Request, call_next):
129
+ """Require a token for API routes when configured."""
130
+ token = os.environ.get("PDIT_TOKEN")
131
+ if token and request.url.path.startswith("/api") and request.method != "OPTIONS":
132
+ provided = request.headers.get("X-PDIT-Token") or request.query_params.get("token")
133
+ if provided != token:
134
+ return JSONResponse(status_code=401, content={"detail": "Unauthorized"})
135
+ return await call_next(request)
136
+
137
+ # Configure CORS origins based on server port
138
+ # Environment variable is set by cli.py before server starts
139
+ port = os.environ.get("PDIT_PORT", "8888")
140
+
141
+ # Allow localhost on the selected port (handles both localhost and 127.0.0.1)
142
+ allowed_origins = [
143
+ f"http://localhost:{port}",
144
+ f"http://127.0.0.1:{port}",
145
+ ]
146
+
147
+ # Enable CORS for browser access
148
+ app.add_middleware(
149
+ CORSMiddleware,
150
+ allow_origins=allowed_origins,
151
+ allow_credentials=True,
152
+ allow_methods=["*"],
153
+ allow_headers=["*"],
154
+ )
155
+
156
+
157
+ # WebSocket endpoint for unified session communication
158
+ @app.websocket("/ws/session")
159
+ async def websocket_session(websocket: WebSocket, sessionId: str, token: Optional[str] = None):
160
+ """Unified WebSocket endpoint for file watching and code execution.
161
+
162
+ The WebSocket connection lifecycle is tied to the session - when the connection
163
+ closes, the session is cleaned up.
164
+
165
+ Query Parameters:
166
+ sessionId: Unique session identifier
167
+ token: Optional authentication token (required if PDIT_TOKEN env var is set)
168
+
169
+ Message Protocol (Client -> Server):
170
+ {"type": "watch", "path": "/absolute/path.py"}
171
+ {"type": "execute", "script": "...", "lineRange?": {"from": N, "to": N}, "scriptName?": "...", "reset?": false}
172
+ {"type": "interrupt"}
173
+ {"type": "reset"}
174
+
175
+ Message Protocol (Server -> Client):
176
+ File events: {"type": "initial/fileChanged/fileDeleted", "path": "...", "content": "...", "timestamp": N}
177
+ Execution: {"type": "expressions/result/cancelled/complete/busy", ...}
178
+ Errors: {"type": "error", "message": "..."}
179
+ """
180
+ # Validate token if configured
181
+ expected_token = os.environ.get("PDIT_TOKEN")
182
+ if expected_token and token != expected_token:
183
+ await websocket.close(code=4001, reason="Unauthorized")
184
+ return
185
+
186
+ await websocket.accept()
187
+
188
+ session = get_or_create_session(sessionId)
189
+ execute_task: Optional[asyncio.Task] = None
190
+
191
+ try:
192
+ while True:
193
+ # Check for server shutdown
194
+ if shutdown_event.is_set():
195
+ break
196
+
197
+ data = await websocket.receive_json()
198
+ msg_type = data.get("type")
199
+
200
+ if msg_type == "watch":
201
+ await _handle_ws_watch(websocket, session, data.get("path", ""))
202
+
203
+ elif msg_type == "execute":
204
+ # Run execution in background so we can process interrupt/reset
205
+ if execute_task is not None and not execute_task.done():
206
+ # Already executing - send busy
207
+ await websocket.send_json({"type": "busy"})
208
+ else:
209
+ execute_task = asyncio.create_task(
210
+ _handle_ws_execute(websocket, session, data)
211
+ )
212
+
213
+ elif msg_type == "interrupt":
214
+ await session.executor.interrupt()
215
+
216
+ elif msg_type == "reset":
217
+ await session.executor.reset()
218
+
219
+ except WebSocketDisconnect:
220
+ pass
221
+ except Exception as e:
222
+ try:
223
+ await websocket.send_json({"type": "error", "message": str(e)})
224
+ except Exception:
225
+ pass
226
+ finally:
227
+ # Cancel any running execution task
228
+ if execute_task is not None and not execute_task.done():
229
+ execute_task.cancel()
230
+ try:
231
+ await execute_task
232
+ except asyncio.CancelledError:
233
+ pass
234
+ # Clean up session when WebSocket closes
235
+ await delete_session(sessionId)
236
+
237
+
238
+ async def _handle_ws_watch(websocket: WebSocket, session: Session, path: str) -> None:
239
+ """Handle file watch request over WebSocket."""
240
+ # Stop existing watcher if any
241
+ if session.watcher_stop_event:
242
+ session.watcher_stop_event.set()
243
+ if session.file_watcher_task:
244
+ session.file_watcher_task.cancel()
245
+ try:
246
+ await session.file_watcher_task
247
+ except asyncio.CancelledError:
248
+ pass
249
+
250
+ # Create new watcher
251
+ session.watcher_stop_event = threading.Event()
252
+ watcher = FileWatcher(path, stop_event=session.watcher_stop_event)
253
+
254
+ async def watch_loop():
255
+ try:
256
+ async for event in watcher.watch_with_initial():
257
+ if shutdown_event.is_set():
258
+ break
259
+ await websocket.send_json(asdict(event))
260
+ if event.type in ("fileDeleted", "error"):
261
+ break
262
+ except asyncio.CancelledError:
263
+ pass
264
+ except Exception:
265
+ pass # Connection may have closed
266
+
267
+ session.file_watcher_task = asyncio.create_task(watch_loop())
268
+
269
+
270
+ async def _handle_ws_execute(websocket: WebSocket, session: Session, data: dict) -> None:
271
+ """Handle code execution request over WebSocket with busy detection."""
272
+ # Check if already executing
273
+ async with session.lock:
274
+ if session.is_executing:
275
+ await websocket.send_json({"type": "busy"})
276
+ return
277
+ session.is_executing = True
278
+
279
+ try:
280
+ script = data.get("script", "")
281
+ script_name = data.get("scriptName")
282
+ line_range = None
283
+ if lr := data.get("lineRange"):
284
+ line_range = (lr["from"], lr["to"])
285
+
286
+ if data.get("reset"):
287
+ await session.executor.reset()
288
+
289
+ # Track expressions for cancelled handling
290
+ expressions: list[dict] = []
291
+ executed_count = 0
292
+
293
+ async for event in session.executor.execute_script(script, line_range, script_name):
294
+ # Add type field to result messages (executor yields without type)
295
+ if "output" in event and "type" not in event:
296
+ event = {"type": "result", **event}
297
+
298
+ # Send event to client
299
+ await websocket.send_json(event)
300
+
301
+ # Track expressions for cancelled handling
302
+ if event.get("type") == "expressions":
303
+ expressions = event["expressions"]
304
+ executed_count = 0
305
+ elif event.get("type") == "result":
306
+ executed_count += 1
307
+ if is_error_result(event):
308
+ remaining = expressions[executed_count:]
309
+ if remaining:
310
+ await websocket.send_json({"type": "cancelled", "expressions": remaining})
311
+ await websocket.send_json({"type": "complete"})
312
+ return
313
+
314
+ await websocket.send_json({"type": "complete"})
315
+
316
+ except WebSocketDisconnect:
317
+ # Client disconnected, interrupt execution
318
+ await session.executor.interrupt()
319
+ raise
320
+ except Exception as e:
321
+ await websocket.send_json({"type": "error", "message": str(e)})
322
+ finally:
323
+ async with session.lock:
324
+ session.is_executing = False
325
+
326
+
327
+ class ListFilesResponse(BaseModel):
328
+ """Response from listing files."""
329
+ files: List[str]
330
+
331
+
332
+ @app.get("/api/list-files", response_model=ListFilesResponse)
333
+ async def list_files():
334
+ """List all Python files in the current working directory.
335
+
336
+ Returns:
337
+ List of relative paths to .py files
338
+
339
+ Note:
340
+ Excludes hidden directories and common virtual environment directories.
341
+ """
342
+ import fnmatch
343
+
344
+ cwd = Path.cwd()
345
+ py_files: list[str] = []
346
+
347
+ # Directories to skip
348
+ skip_dirs = {".git", ".venv", "venv", "__pycache__", "node_modules", ".tox", ".mypy_cache", ".pytest_cache", "dist", "build", "*.egg-info"}
349
+
350
+ for root, dirs, files in os.walk(cwd):
351
+ # Filter out hidden and virtual environment directories
352
+ dirs[:] = [d for d in dirs if not d.startswith('.') and d not in skip_dirs and not any(fnmatch.fnmatch(d, pattern) for pattern in skip_dirs)]
353
+
354
+ for file in files:
355
+ if file.endswith('.py'):
356
+ full_path = Path(root) / file
357
+ relative_path = full_path.relative_to(cwd)
358
+ py_files.append(str(relative_path))
359
+
360
+ # Sort by filename (not path) for better UX
361
+ py_files.sort(key=lambda p: Path(p).name.lower())
362
+
363
+ return ListFilesResponse(files=py_files)
364
+
365
+
366
+ @app.post("/api/save-file")
367
+ async def save_file(request: SaveFileRequest):
368
+ """Save a file to the filesystem.
369
+
370
+ Args:
371
+ request: File path and content to save
372
+
373
+ Returns:
374
+ Status OK if successful
375
+
376
+ Raises:
377
+ HTTPException: If file cannot be written
378
+
379
+ Note:
380
+ Security consideration: This endpoint allows writing to any path
381
+ the server has access to. Path validation should be added.
382
+ """
383
+ try:
384
+ file_path = Path(request.path)
385
+ file_path.write_text(request.content)
386
+ return {"status": "ok"}
387
+ except Exception as e:
388
+ raise HTTPException(status_code=500, detail=f"Error saving file: {str(e)}")
389
+
390
+
391
+ # Static file serving for frontend
392
+ # Get path to _static directory inside the package
393
+ STATIC_DIR = Path(__file__).parent / "_static"
394
+
395
+ if STATIC_DIR.exists():
396
+ # Mount assets directory for JS/CSS files
397
+ assets_dir = STATIC_DIR / "assets"
398
+ if assets_dir.exists():
399
+ app.mount("/assets", StaticFiles(directory=str(assets_dir)), name="assets")
400
+
401
+ # Serve index.html for all unmatched routes (SPA routing)
402
+ @app.get("/{full_path:path}")
403
+ async def serve_frontend(full_path: str):
404
+ """Serve frontend for all non-API routes."""
405
+ # If path starts with /api, this won't match (API routes take precedence)
406
+ # Serve index.html for SPA routing
407
+ index_file = STATIC_DIR / "index.html"
408
+ if index_file.exists():
409
+ return FileResponse(index_file)
410
+ return ""
@@ -0,0 +1,155 @@
1
+ Metadata-Version: 2.4
2
+ Name: pdit
3
+ Version: 0.1.0
4
+ Summary: Interactive Python code editor with inline execution results
5
+ Author: Harry Vangberg
6
+ License: Private
7
+ Project-URL: Homepage, https://github.com/vangberg/pdit
8
+ Project-URL: Repository, https://github.com/vangberg/pdit
9
+ Project-URL: Issues, https://github.com/vangberg/pdit/issues
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.8
14
+ Classifier: Programming Language :: Python :: 3.9
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Requires-Python: <3.14,>=3.9
19
+ Description-Content-Type: text/markdown
20
+ Requires-Dist: fastapi>=0.104.0
21
+ Requires-Dist: uvicorn[standard]>=0.24.0
22
+ Requires-Dist: typer>=0.9.0
23
+ Requires-Dist: aiofiles>=23.0.0
24
+ Requires-Dist: watchfiles>=0.20.0
25
+ Requires-Dist: jupyter_client>=8.0.0
26
+ Requires-Dist: ipykernel>=6.29.0
27
+ Requires-Dist: itables>=2.6.1
28
+ Requires-Dist: ipython>=8.18.1
29
+ Provides-Extra: examples
30
+ Requires-Dist: great-tables>=0.20.0; extra == "examples"
31
+ Requires-Dist: matplotlib>=3.7.5; extra == "examples"
32
+ Requires-Dist: pandas>=2.0.3; extra == "examples"
33
+ Requires-Dist: polars>=1.8.2; extra == "examples"
34
+
35
+ # pdit
36
+
37
+ Output-focused Python editor.
38
+
39
+ pdit lets you write regular Python files and see execution results inline, like a notebook but without cells. Edit in your browser or your favorite editor.
40
+
41
+ ## Quick Start
42
+
43
+ ```bash
44
+ pip install pdit
45
+ pdit script.py
46
+ ```
47
+
48
+ ## Features
49
+
50
+ - **Output-focused** - Results appear inline next to the code that generated them
51
+ - **Just Python scripts** - No notebooks, no cells, no special format. Work with plain `.py` files
52
+ - **File watching** - Changes to the file on disk automatically reload in the editor
53
+ - **Auto-run** - Execute code automatically when the file changes
54
+ - **Coding agents** - Perfect companion for Claude Code, Cursor, and other AI coding tools that edit files
55
+
56
+ ### Rich Output
57
+
58
+ - **IPython display** - Rich outputs via [IPython.display](https://ipython.readthedocs.io/en/latest/api/generated/IPython.display.html)
59
+ - **Matplotlib** - Inline plot rendering
60
+ - **Interactive DataFrames** - Sortable, searchable tables
61
+ - **Markdown** - Format text output with Markdown
62
+
63
+ ## Output
64
+
65
+ ### Markdown
66
+
67
+ Top-level string output renders as Markdown, so headings, lists, and emphasis display cleanly.
68
+
69
+ ### HTML
70
+
71
+ Rich HTML output is supported for objects that implement `_repr_html_()`; see note: [IPython.display.display](https://ipython.readthedocs.io/en/latest/api/generated/IPython.display.html#IPython.display.display).
72
+
73
+ ### IPython display
74
+
75
+ IPython display objects render inline; see [IPython.display](https://ipython.readthedocs.io/en/latest/api/generated/IPython.display.html) for details.
76
+
77
+ ### DataFrames
78
+
79
+ Pandas and Polars DataFrames render as interactive tables automatically.
80
+
81
+ ### Plots
82
+
83
+ Matplotlib figures display inline. Call `plt.show()`.
84
+
85
+ ## Installation
86
+
87
+ For development installs or running from source, use [uv](https://github.com/astral-sh/uv).
88
+
89
+ ```bash
90
+ # Install from dist branch (recommended, includes pre-built assets)
91
+ uv add git+https://github.com/vangberg/pdit@dist
92
+
93
+ # Or use directly with uvx
94
+ uvx --from git+https://github.com/vangberg/pdit@dist pdit script.py
95
+
96
+ # From cloned repo (for development)
97
+ git clone git@github.com:vangberg/pdit.git
98
+ cd pdit
99
+ uv pip install -e .
100
+ uv run pdit script.py
101
+ ```
102
+
103
+ ## Usage
104
+
105
+ Start pdit with a Python file:
106
+
107
+ ```bash
108
+ pdit script.py
109
+ ```
110
+
111
+ This will:
112
+ 1. Start the local server on port 8888
113
+ 2. Open your browser automatically
114
+ 3. Load the script file in the editor
115
+
116
+ If you're running from source, use:
117
+
118
+ ```bash
119
+ uv run pdit script.py
120
+ ```
121
+
122
+ ### Options
123
+
124
+ ```bash
125
+ pdit [OPTIONS] [SCRIPT]
126
+
127
+ Options:
128
+ --port INTEGER Port to run server on (default: 8888)
129
+ --host TEXT Host to bind to (default: 127.0.0.1)
130
+ --no-browser Don't open browser automatically
131
+ --help Show help message
132
+ ```
133
+
134
+ ### Examples
135
+
136
+ ```bash
137
+ # Start with script
138
+ pdit analysis.py
139
+
140
+ # Custom port
141
+ pdit --port 9000 script.py
142
+
143
+ # Start without opening browser
144
+ pdit --no-browser script.py
145
+
146
+ # Just start the editor (no script)
147
+ pdit
148
+ ```
149
+ ## Development
150
+
151
+ See [DEVELOPMENT.md](DEVELOPMENT.md) for development setup and testing.
152
+
153
+ ## License
154
+
155
+ Private
@@ -0,0 +1,15 @@
1
+ pdit/__init__.py,sha256=QpiMnN_x5G3QaeWMXQlCRUtCo35IEYZLPj-cz87WG_g,185
2
+ pdit/cli.py,sha256=LQyHfBec2tyB-j0afDrZUW9k9OdGfLbbjtq-SYt_zRk,7708
3
+ pdit/exporter.py,sha256=i2emLqRQNPW92vFBTamLZyOhUTDU45_0QnOWG7PcIu8,2811
4
+ pdit/file_watcher.py,sha256=1WlqKWjLCdgMZLdN836MEeWTisGCtZmvgRdOFN-jns8,5200
5
+ pdit/ipython_executor.py,sha256=L2ci871Lyy2hcfxiEyNNiIIfiKBpVv6WaXvYM-jfiHE,13317
6
+ pdit/server.py,sha256=URabdsLxiLUipkl8PTc5jgDjMlQV0v8QJTMnNNy0XKs,13941
7
+ pdit/_static/export.html,sha256=nCo3c6UIZ_djV1hH_Zx9HCsMcubYGEnT33Joc7OVg4A,331509
8
+ pdit/_static/index.html,sha256=zGgZzENG7QVdL9r-1wnEkgNE05BLagWak1LD7m-hczo,450
9
+ pdit/_static/assets/index-BkEyY6gm.js,sha256=NVl_Txa-hhLk_vG27s4_qIpOVNYv5AiI0FNzzbME6Rc,814761
10
+ pdit/_static/assets/index-DxOOJTA1.css,sha256=9T_1k8PnqINanHaLnwLaQbR9xG4IzTAVpPSYgpHia_o,22150
11
+ pdit-0.1.0.dist-info/METADATA,sha256=9NePu5cjJ-aKjnh0xlUotim04BjcZEYCg-HdrwMdKZY,4337
12
+ pdit-0.1.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
13
+ pdit-0.1.0.dist-info/entry_points.txt,sha256=n9I4wGD7M0NQrPWJcTWOsJAzeS6bQJbTnUP8C3Sl-iY,39
14
+ pdit-0.1.0.dist-info/top_level.txt,sha256=wk6vel1ecJS4EZZ3U6Xue9OwDq-Tw8Pbvq_TRQz9eIQ,5
15
+ pdit-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.9.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ pdit = pdit.cli:main
@@ -0,0 +1 @@
1
+ pdit