panel-live-server 0.1.0a2__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
+ """Panel Live Server - Execute and visualize Python code snippets."""
2
+
3
+ import importlib.metadata
4
+ import warnings
5
+
6
+ try:
7
+ __version__ = importlib.metadata.version("panel-live-server")
8
+ except importlib.metadata.PackageNotFoundError as e: # pragma: no cover
9
+ warnings.warn(f"Could not determine version of panel-live-server\n{e!s}", stacklevel=2)
10
+ __version__ = "unknown"
11
+
12
+ __all__: list[str] = [
13
+ "__version__",
14
+ ]
@@ -0,0 +1,89 @@
1
+ """Panel server for code visualization.
2
+
3
+ This module implements a Panel web server that executes Python code
4
+ and displays the results through various endpoints.
5
+ """
6
+
7
+ import logging
8
+ import os
9
+
10
+ from panel_live_server.endpoints import HealthEndpoint
11
+ from panel_live_server.endpoints import SnippetEndpoint
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ def _display_url(address: str, port: int, endpoint: str) -> str:
17
+ """Generate the display server URL."""
18
+ proxy_url = os.getenv("JUPYTER_SERVER_PROXY_URL", None)
19
+ if proxy_url:
20
+ proxy_url = proxy_url.rstrip("/")
21
+ return f"{proxy_url}/{port}/{endpoint}"
22
+ return f"http://{address}:{port}/{endpoint}"
23
+
24
+
25
+ def _api_url(address: str, port: int, endpoint: str) -> str:
26
+ """Generate the API URL for a given endpoint."""
27
+ return f"http://{address}:{port}{endpoint}"
28
+
29
+
30
+ def main(address: str = "localhost", port: int = 5077, show: bool = True) -> None:
31
+ """Start the Panel server."""
32
+ import panel as pn
33
+
34
+ from panel_live_server.database import get_db
35
+ from panel_live_server.pages import add_page
36
+ from panel_live_server.pages import admin_page
37
+ from panel_live_server.pages import feed_page
38
+ from panel_live_server.pages import view_page
39
+
40
+ # Initialize the database
41
+ _ = get_db()
42
+
43
+ # Configure Panel defaults
44
+ pn.template.FastListTemplate.param.main_layout.default = None
45
+ pn.pane.Markdown.param.disable_anchors.default = True
46
+
47
+ # Initialize views cache for feed page
48
+ pn.state.cache["views"] = {}
49
+
50
+ # Configure pages
51
+ pages = {
52
+ "/view": view_page,
53
+ "/feed": feed_page,
54
+ "/admin": admin_page,
55
+ "/add": add_page,
56
+ }
57
+
58
+ # Configure extra patterns for Tornado handlers (REST API endpoints)
59
+ extra_patterns = [
60
+ (r"/api/snippet", SnippetEndpoint),
61
+ (r"/api/health", HealthEndpoint),
62
+ ]
63
+
64
+ # Log startup information
65
+ logger.info(f"Starting Panel Live Server at http://{address}:{port}")
66
+ logger.info(f" Feed: {_display_url(address, port, 'feed')}")
67
+ logger.info(f" Add: {_display_url(address, port, 'add')}")
68
+ logger.info(f" Admin: {_display_url(address, port, 'admin')}")
69
+ logger.info(f" Health: {_api_url(address, port, '/api/health')}")
70
+
71
+ # Start server
72
+ pn.serve(
73
+ pages,
74
+ port=port,
75
+ address=address,
76
+ show=show,
77
+ title="Panel Live Server",
78
+ extra_patterns=extra_patterns,
79
+ )
80
+
81
+
82
+ if __name__ == "__main__":
83
+ # Read config from env vars when run as subprocess
84
+ from panel_live_server.config import get_config
85
+ from panel_live_server.config import reset_config
86
+
87
+ reset_config()
88
+ config = get_config()
89
+ main(address=config.host, port=config.port, show=False)
@@ -0,0 +1,269 @@
1
+ """CLI for Panel Live Server."""
2
+
3
+ import logging
4
+ from typing import Annotated
5
+
6
+ import typer
7
+
8
+ from panel_live_server import __version__
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ def version_callback(value: bool) -> None:
14
+ """Print version and exit."""
15
+ if value:
16
+ typer.echo(f"panel-live-server {__version__}")
17
+ raise typer.Exit()
18
+
19
+
20
+ app = typer.Typer(
21
+ name="pls",
22
+ help="Panel Live Server - Execute and visualize Python code snippets.",
23
+ add_completion=False,
24
+ )
25
+
26
+ list_app = typer.Typer(help="List resources (packages, etc.).")
27
+ app.add_typer(list_app, name="list")
28
+
29
+
30
+ @app.callback(invoke_without_command=True)
31
+ def main_callback(
32
+ ctx: typer.Context,
33
+ version: Annotated[
34
+ bool,
35
+ typer.Option("--version", "-V", callback=version_callback, is_eager=True, help="Show version and exit."),
36
+ ] = False,
37
+ ) -> None:
38
+ """Panel Live Server - Execute and visualize Python code snippets."""
39
+ if ctx.invoked_subcommand is None:
40
+ typer.echo(ctx.get_help())
41
+
42
+
43
+ @app.command()
44
+ def serve(
45
+ port: int = typer.Option(
46
+ 5077,
47
+ "--port",
48
+ "-p",
49
+ help="Port number to run the Panel server on.",
50
+ envvar="PANEL_LIVE_SERVER_PORT",
51
+ show_default=True,
52
+ ),
53
+ host: str = typer.Option(
54
+ "localhost",
55
+ "--host",
56
+ "-H",
57
+ help="Host address to bind to.",
58
+ envvar="PANEL_LIVE_SERVER_HOST",
59
+ show_default=True,
60
+ ),
61
+ db_path: str | None = typer.Option(
62
+ None,
63
+ "--db-path",
64
+ help="Path to the SQLite database file.",
65
+ envvar="PANEL_LIVE_SERVER_DB_PATH",
66
+ ),
67
+ show: bool = typer.Option(
68
+ False,
69
+ "--show",
70
+ help="Open the server in a browser after starting.",
71
+ ),
72
+ verbose: bool = typer.Option(
73
+ False,
74
+ "--verbose",
75
+ "-v",
76
+ help="Enable verbose logging.",
77
+ ),
78
+ ) -> None:
79
+ """Start the Panel Live Server directly.
80
+
81
+ The server provides a web interface for executing Python code snippets
82
+ and visualizing the results. Visit http://<host>:<port>/feed to see
83
+ visualizations as they are created.
84
+
85
+ Note: if you are also running `pls mcp`, both commands use the same Panel
86
+ server port (PANEL_LIVE_SERVER_PORT). Run only one at a time unless you
87
+ configure different ports.
88
+ """
89
+ import os
90
+
91
+ if verbose:
92
+ logging.basicConfig(level=logging.DEBUG)
93
+ else:
94
+ logging.basicConfig(level=logging.INFO)
95
+
96
+ # Set env vars before config is loaded so get_config() picks them up
97
+ os.environ["PANEL_LIVE_SERVER_PORT"] = str(port)
98
+ os.environ["PANEL_LIVE_SERVER_HOST"] = host
99
+ if db_path:
100
+ os.environ["PANEL_LIVE_SERVER_DB_PATH"] = db_path
101
+
102
+ # Reset the cached config singleton so it re-reads the env vars we just set
103
+ from panel_live_server.config import reset_config
104
+
105
+ reset_config()
106
+
107
+ from panel_live_server.app import main as app_main
108
+
109
+ try:
110
+ app_main(address=host, port=port, show=show)
111
+ except OSError as exc:
112
+ import errno
113
+
114
+ import requests
115
+
116
+ if exc.errno != errno.EADDRINUSE:
117
+ raise
118
+ url = f"http://{host}:{port}/api/health"
119
+ try:
120
+ resp = requests.get(url, timeout=2)
121
+ if resp.status_code == 200:
122
+ typer.echo(f"Panel Live Server is already running at http://{host}:{port}")
123
+ typer.echo(" Run `pls status` for details.")
124
+ raise typer.Exit(0)
125
+ except requests.ConnectionError:
126
+ pass
127
+ typer.echo(f"Error: port {port} is already in use by another process.", err=True)
128
+ typer.echo(f" Try: pls serve --port {port + 1}", err=True)
129
+ raise typer.Exit(1) from None
130
+
131
+
132
+ @app.command()
133
+ def mcp(
134
+ transport: str = typer.Option(
135
+ "stdio",
136
+ "--transport",
137
+ "-t",
138
+ help="MCP transport: stdio, http, or sse.",
139
+ envvar="PANEL_LIVE_SERVER_TRANSPORT",
140
+ ),
141
+ host: str = typer.Option(
142
+ "127.0.0.1",
143
+ "--host",
144
+ help="Host for HTTP/SSE transport.",
145
+ envvar="PANEL_LIVE_SERVER_MCP_HOST",
146
+ ),
147
+ port: int = typer.Option(
148
+ 8001,
149
+ "--port",
150
+ "-p",
151
+ help="Port for HTTP/SSE transport.",
152
+ envvar="PANEL_LIVE_SERVER_MCP_PORT",
153
+ ),
154
+ verbose: bool = typer.Option(
155
+ False,
156
+ "--verbose",
157
+ "-v",
158
+ help="Enable verbose logging.",
159
+ ),
160
+ ) -> None:
161
+ """Start as an MCP server for AI assistants.
162
+
163
+ The MCP server exposes the `show` tool for executing and displaying
164
+ Python visualizations. A Panel visualization server starts automatically
165
+ on port 5077 (PANEL_LIVE_SERVER_PORT) — visit that address in a browser
166
+ to watch visualizations appear in real time.
167
+
168
+ Note: the --port flag here controls the MCP HTTP/SSE listener, NOT the
169
+ Panel visualization server port. For stdio transport, --port is unused.
170
+ """
171
+ if verbose:
172
+ logging.basicConfig(level=logging.DEBUG)
173
+ else:
174
+ logging.basicConfig(level=logging.INFO)
175
+
176
+ from panel_live_server.server import mcp as mcp_server
177
+
178
+ if transport == "stdio":
179
+ mcp_server.run(transport="stdio")
180
+ elif transport == "http":
181
+ mcp_server.run(transport="streamable-http", host=host, port=port)
182
+ elif transport == "sse":
183
+ mcp_server.run(transport="sse", host=host, port=port)
184
+ else:
185
+ typer.echo(f"Unknown transport: {transport!r}. Choose from: stdio, http, sse.")
186
+ raise typer.Exit(1)
187
+
188
+
189
+ @app.command()
190
+ def status(
191
+ port: int = typer.Option(
192
+ 5077,
193
+ "--port",
194
+ "-p",
195
+ help="Port to check.",
196
+ envvar="PANEL_LIVE_SERVER_PORT",
197
+ show_default=True,
198
+ ),
199
+ host: str = typer.Option(
200
+ "localhost",
201
+ "--host",
202
+ "-H",
203
+ help="Host to check.",
204
+ envvar="PANEL_LIVE_SERVER_HOST",
205
+ show_default=True,
206
+ ),
207
+ ) -> None:
208
+ """Check whether the Panel server is running.
209
+
210
+ Queries the health endpoint and reports the server status.
211
+ """
212
+ import requests
213
+
214
+ url = f"http://{host}:{port}/api/health"
215
+ try:
216
+ resp = requests.get(url, timeout=3)
217
+ if resp.status_code == 200:
218
+ data = resp.json()
219
+ typer.echo(f"Running http://{host}:{port}/feed (healthy at {data.get('timestamp', '?')})")
220
+ else:
221
+ typer.echo(f"Unhealthy http://{host}:{port} (status {resp.status_code})")
222
+ raise typer.Exit(1)
223
+ except requests.ConnectionError:
224
+ typer.echo(f"Not running (nothing on {host}:{port})")
225
+ raise typer.Exit(1) from None
226
+ except requests.Timeout:
227
+ typer.echo(f"Timeout (no response from {host}:{port} within 3 s)")
228
+ raise typer.Exit(1) from None
229
+
230
+
231
+ @list_app.command(name="packages")
232
+ def list_packages(
233
+ filter: str = typer.Argument(
234
+ "",
235
+ help="Optional substring to filter package names (case-insensitive).",
236
+ show_default=False,
237
+ ),
238
+ ) -> None:
239
+ """List all Python packages installed in the current environment.
240
+
241
+ Optionally filter by a substring, e.g. ``pls list packages panel`` to show
242
+ only packages whose name contains "panel".
243
+ """
244
+ from importlib.metadata import distributions
245
+
246
+ pkgs = sorted(
247
+ ((dist.metadata["Name"], dist.metadata["Version"]) for dist in distributions()),
248
+ key=lambda t: t[0].lower().replace("-", "_"),
249
+ )
250
+
251
+ if filter:
252
+ pkgs = [(name, ver) for name, ver in pkgs if filter.lower() in name.lower()]
253
+
254
+ if not pkgs:
255
+ typer.echo("No packages found.")
256
+ return
257
+
258
+ name_width = max(len(name) for name, _ in pkgs)
259
+ for name, version in pkgs:
260
+ typer.echo(f"{name:<{name_width}} {version}")
261
+
262
+
263
+ def main() -> None:
264
+ """Entry point for the pls command."""
265
+ app()
266
+
267
+
268
+ if __name__ == "__main__":
269
+ main()
@@ -0,0 +1,109 @@
1
+ """HTTP client for Display Server REST API.
2
+
3
+ This module provides a client for interacting with the Panel Display Server
4
+ via its REST API. The client can be used with either a locally-managed subprocess
5
+ or a remote server instance.
6
+ """
7
+
8
+ import logging
9
+
10
+ import requests # type: ignore[import-untyped]
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ class DisplayClient:
16
+ """HTTP client for Display Server REST API.
17
+
18
+ This client handles all HTTP communication with the Panel Display Server,
19
+ including health checks and snippet creation. It uses a persistent session
20
+ for connection pooling.
21
+ """
22
+
23
+ def __init__(self, base_url: str, timeout: int = 30):
24
+ """Initialize the Display Client.
25
+
26
+ Parameters
27
+ ----------
28
+ base_url : str
29
+ Base URL of the Display Server (e.g., "http://localhost:5077")
30
+ timeout : int
31
+ Request timeout in seconds
32
+ """
33
+ self.base_url = base_url.rstrip("/")
34
+ self.timeout = timeout
35
+ self.session = requests.Session()
36
+
37
+ def is_healthy(self) -> bool:
38
+ """Check if Display Server is healthy.
39
+
40
+ Returns
41
+ -------
42
+ bool
43
+ True if server responds to health check, False otherwise
44
+ """
45
+ try:
46
+ response = self.session.get(f"{self.base_url}/api/health", timeout=2)
47
+ return response.status_code == 200
48
+ except requests.RequestException:
49
+ return False
50
+
51
+ def create_snippet(self, code: str, name: str = "", description: str = "", method: str = "jupyter") -> dict:
52
+ """Create a visualization snippet on the Display Server.
53
+
54
+ Sends Python code to the server for execution and rendering.
55
+
56
+ Parameters
57
+ ----------
58
+ code : str
59
+ Python code to execute
60
+ name : str, optional
61
+ Name for the visualization
62
+ description : str, optional
63
+ Description of the visualization
64
+ method : str, optional
65
+ Execution method ("jupyter" or "panel")
66
+
67
+ Returns
68
+ -------
69
+ dict
70
+ Server response containing either:
71
+ - Success: {"url": str, "id": str, ...}
72
+ - Error: {"error": str, "message": str, "traceback": str}
73
+
74
+ Raises
75
+ ------
76
+ RuntimeError
77
+ If HTTP request fails
78
+ """
79
+ try:
80
+ response = self.session.post(
81
+ f"{self.base_url}/api/snippet",
82
+ json={
83
+ "code": code,
84
+ "name": name,
85
+ "description": description,
86
+ "method": method,
87
+ },
88
+ timeout=self.timeout,
89
+ )
90
+
91
+ response.raise_for_status()
92
+ return response.json()
93
+
94
+ except requests.RequestException as e:
95
+ logger.exception(f"Error creating visualization: {e}")
96
+ raise RuntimeError(f"Failed to create visualization: {e}") from e
97
+
98
+ def close(self) -> None:
99
+ """Close the HTTP session and cleanup resources."""
100
+ if self.session:
101
+ self.session.close()
102
+
103
+ def __enter__(self):
104
+ """Context manager entry."""
105
+ return self
106
+
107
+ def __exit__(self, exc_type, exc_val, exc_tb):
108
+ """Context manager exit."""
109
+ self.close()
@@ -0,0 +1,50 @@
1
+ """Configuration for Panel Live Server."""
2
+
3
+ import logging
4
+ import os
5
+ from pathlib import Path
6
+
7
+ from pydantic import BaseModel
8
+ from pydantic import Field
9
+
10
+ logger = logging.getLogger("panel_live_server")
11
+
12
+
13
+ def _default_user_dir() -> Path:
14
+ return Path(os.getenv("PANEL_LIVE_SERVER_USER_DIR", "~/.panel-live-server")).expanduser()
15
+
16
+
17
+ class Config(BaseModel):
18
+ """Panel Live Server configuration."""
19
+
20
+ port: int = Field(default=5077, description="Port for the Panel server")
21
+ host: str = Field(default="localhost", description="Host address for the Panel server")
22
+ max_restarts: int = Field(default=3, description="Maximum number of restart attempts")
23
+ db_path: Path = Field(
24
+ default_factory=lambda: _default_user_dir() / "snippets" / "snippets.db",
25
+ description="Path to SQLite database for snippets",
26
+ )
27
+ jupyter_server_proxy_url: str = Field(default="", description="Jupyter server proxy URL")
28
+
29
+
30
+ _config: Config | None = None
31
+
32
+
33
+ def get_config() -> Config:
34
+ """Get or create the config instance."""
35
+ global _config
36
+ if _config is None:
37
+ _config = Config(
38
+ port=int(os.getenv("PANEL_LIVE_SERVER_PORT", "5077")),
39
+ host=os.getenv("PANEL_LIVE_SERVER_HOST", "localhost"),
40
+ max_restarts=int(os.getenv("PANEL_LIVE_SERVER_MAX_RESTARTS", "3")),
41
+ db_path=Path(os.getenv("PANEL_LIVE_SERVER_DB_PATH", str(_default_user_dir() / "snippets" / "snippets.db"))),
42
+ jupyter_server_proxy_url=os.getenv("JUPYTER_SERVER_PROXY_URL", ""),
43
+ )
44
+ return _config
45
+
46
+
47
+ def reset_config() -> None:
48
+ """Reset config (for testing)."""
49
+ global _config
50
+ _config = None