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/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 @@
|
|
|
1
|
+
pdit
|