panel-live-server 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
+ """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,125 @@
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
+ from urllib.parse import urlparse
9
+
10
+ from panel_live_server.config import get_config
11
+ from panel_live_server.endpoints import EmbedEndpoint
12
+ from panel_live_server.endpoints import HealthEndpoint
13
+ from panel_live_server.endpoints import ScreenshotEndpoint
14
+ from panel_live_server.endpoints import SnippetEndpoint
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ def _display_url(address: str, port: int, endpoint: str) -> str:
20
+ """Generate the display server URL, externalizing when config.external_url is set."""
21
+ external_url = get_config().external_url
22
+ if external_url:
23
+ return f"{external_url.rstrip('/')}/{endpoint}"
24
+ return f"http://{address}:{port}/{endpoint}"
25
+
26
+
27
+ def _api_url(address: str, port: int, endpoint: str) -> str:
28
+ """Generate the API URL for a given endpoint."""
29
+ return f"http://{address}:{port}{endpoint}"
30
+
31
+
32
+ def _build_websocket_origins(address: str, port: int) -> list[str]:
33
+ """Build a targeted websocket origin allowlist for Bokeh/Panel.
34
+
35
+ Bokeh expects origins as host[:port] values. We include local defaults,
36
+ the configured bind address, and (when configured) the externally reachable
37
+ host from ``external_url``.
38
+ """
39
+ origins: set[str] = {
40
+ f"localhost:{port}",
41
+ f"127.0.0.1:{port}",
42
+ }
43
+
44
+ # Add the configured bind address when it is a concrete host.
45
+ if address and address not in {"0.0.0.0", "::"}:
46
+ origins.add(f"{address}:{port}")
47
+
48
+ external_url = get_config().external_url
49
+ if external_url:
50
+ parsed = urlparse(external_url)
51
+ if parsed.hostname:
52
+ if parsed.port:
53
+ origins.add(f"{parsed.hostname}:{parsed.port}")
54
+ else:
55
+ origins.add(parsed.hostname)
56
+ if parsed.scheme == "https":
57
+ origins.add(f"{parsed.hostname}:443")
58
+ elif parsed.scheme == "http":
59
+ origins.add(f"{parsed.hostname}:80")
60
+
61
+ return sorted(origins)
62
+
63
+
64
+ def main(address: str = "localhost", port: int = 5077, show: bool = True) -> None:
65
+ """Start the Panel server."""
66
+ import panel as pn
67
+
68
+ from panel_live_server.database import get_db
69
+ from panel_live_server.pages import add_page
70
+ from panel_live_server.pages import admin_page
71
+ from panel_live_server.pages import feed_page
72
+ from panel_live_server.pages import view_page
73
+
74
+ # Initialize the database
75
+ _ = get_db()
76
+
77
+ # Configure Panel defaults
78
+ pn.template.FastListTemplate.param.main_layout.default = None
79
+ pn.pane.Markdown.param.disable_anchors.default = True
80
+
81
+ # Initialize views cache for feed page
82
+ pn.state.cache["views"] = {}
83
+
84
+ # Configure pages
85
+ pages = {
86
+ "/view": view_page,
87
+ "/feed": feed_page,
88
+ "/admin": admin_page,
89
+ "/add": add_page,
90
+ }
91
+
92
+ # Configure extra patterns for Tornado handlers (REST API endpoints)
93
+ extra_patterns = [
94
+ (r"/api/snippet", SnippetEndpoint),
95
+ (r"/api/embed", EmbedEndpoint),
96
+ (r"/api/screenshot", ScreenshotEndpoint),
97
+ (r"/api/health", HealthEndpoint),
98
+ ]
99
+
100
+ # Log startup information
101
+ logger.info(f"Starting Panel Live Server at http://{address}:{port}")
102
+ logger.info(f" Feed: {_display_url(address, port, 'feed')}")
103
+ logger.info(f" Add: {_display_url(address, port, 'add')}")
104
+ logger.info(f" Admin: {_display_url(address, port, 'admin')}")
105
+ logger.info(f" Health: {_api_url(address, port, '/api/health')}")
106
+
107
+ # Start server
108
+ pn.serve(
109
+ pages,
110
+ port=port,
111
+ address=address,
112
+ show=show,
113
+ title="Panel Live Server",
114
+ extra_patterns=extra_patterns,
115
+ websocket_origin=_build_websocket_origins(address=address, port=port),
116
+ )
117
+
118
+
119
+ if __name__ == "__main__":
120
+ # Read config from env vars when run as subprocess
121
+ from panel_live_server.config import reset_config
122
+
123
+ reset_config()
124
+ config = get_config()
125
+ main(address=config.host, port=config.port, show=False)
@@ -0,0 +1,280 @@
1
+ """CLI for Panel Live Server."""
2
+
3
+ import logging
4
+ import os
5
+ import sys
6
+ from typing import Annotated
7
+
8
+ # On Windows, conda/pixi environments require Library/bin and DLLs on PATH so
9
+ # that native extensions (numpy, panel, etc.) can find their DLLs at import
10
+ # time. MCP clients that launch pls directly (not via `pixi run`) don't
11
+ # activate the environment, so we fix it up here before any heavy imports.
12
+ if sys.platform == "win32":
13
+ from panel_live_server.utils import prepend_env_dll_paths
14
+
15
+ prepend_env_dll_paths(os.environ)
16
+
17
+ import typer
18
+
19
+ from panel_live_server import __version__
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+
24
+ def version_callback(value: bool) -> None:
25
+ """Print version and exit."""
26
+ if value:
27
+ typer.echo(f"panel-live-server {__version__}")
28
+ raise typer.Exit()
29
+
30
+
31
+ app = typer.Typer(
32
+ name="pls",
33
+ help="Panel Live Server - Execute and visualize Python code snippets.",
34
+ add_completion=False,
35
+ )
36
+
37
+ list_app = typer.Typer(help="List resources (packages, etc.).")
38
+ app.add_typer(list_app, name="list")
39
+
40
+
41
+ @app.callback(invoke_without_command=True)
42
+ def main_callback(
43
+ ctx: typer.Context,
44
+ version: Annotated[
45
+ bool,
46
+ typer.Option("--version", "-V", callback=version_callback, is_eager=True, help="Show version and exit."),
47
+ ] = False,
48
+ ) -> None:
49
+ """Panel Live Server - Execute and visualize Python code snippets."""
50
+ if ctx.invoked_subcommand is None:
51
+ typer.echo(ctx.get_help())
52
+
53
+
54
+ @app.command()
55
+ def serve(
56
+ port: int = typer.Option(
57
+ 5077,
58
+ "--port",
59
+ "-p",
60
+ help="Port number to run the Panel server on.",
61
+ envvar="PANEL_LIVE_SERVER_PORT",
62
+ show_default=True,
63
+ ),
64
+ host: str = typer.Option(
65
+ "localhost",
66
+ "--host",
67
+ "-H",
68
+ help="Host address to bind to.",
69
+ envvar="PANEL_LIVE_SERVER_HOST",
70
+ show_default=True,
71
+ ),
72
+ db_path: str | None = typer.Option(
73
+ None,
74
+ "--db-path",
75
+ help="Path to the SQLite database file.",
76
+ envvar="PANEL_LIVE_SERVER_DB_PATH",
77
+ ),
78
+ show: bool = typer.Option(
79
+ False,
80
+ "--show",
81
+ help="Open the server in a browser after starting.",
82
+ ),
83
+ verbose: bool = typer.Option(
84
+ False,
85
+ "--verbose",
86
+ "-v",
87
+ help="Enable verbose logging.",
88
+ ),
89
+ ) -> None:
90
+ """Start the Panel Live Server directly.
91
+
92
+ The server provides a web interface for executing Python code snippets
93
+ and visualizing the results. Visit http://<host>:<port>/feed to see
94
+ visualizations as they are created.
95
+
96
+ Note: if you are also running `pls mcp`, both commands use the same Panel
97
+ server port (PANEL_LIVE_SERVER_PORT). Run only one at a time unless you
98
+ configure different ports.
99
+ """
100
+ import os
101
+
102
+ if verbose:
103
+ logging.basicConfig(level=logging.DEBUG)
104
+ else:
105
+ logging.basicConfig(level=logging.INFO)
106
+
107
+ # Set env vars before config is loaded so get_config() picks them up
108
+ os.environ["PANEL_LIVE_SERVER_PORT"] = str(port)
109
+ os.environ["PANEL_LIVE_SERVER_HOST"] = host
110
+ if db_path:
111
+ os.environ["PANEL_LIVE_SERVER_DB_PATH"] = db_path
112
+
113
+ # Reset the cached config singleton so it re-reads the env vars we just set
114
+ from panel_live_server.config import reset_config
115
+
116
+ reset_config()
117
+
118
+ from panel_live_server.app import main as app_main
119
+
120
+ try:
121
+ app_main(address=host, port=port, show=show)
122
+ except OSError as exc:
123
+ import errno
124
+
125
+ import requests
126
+
127
+ if exc.errno != errno.EADDRINUSE:
128
+ raise
129
+ url = f"http://{host}:{port}/api/health"
130
+ try:
131
+ resp = requests.get(url, timeout=2)
132
+ if resp.status_code == 200:
133
+ typer.echo(f"Panel Live Server is already running at http://{host}:{port}")
134
+ typer.echo(" Run `pls status` for details.")
135
+ raise typer.Exit(0)
136
+ except requests.ConnectionError:
137
+ pass
138
+ typer.echo(f"Error: port {port} is already in use by another process.", err=True)
139
+ typer.echo(f" Try: pls serve --port {port + 1}", err=True)
140
+ raise typer.Exit(1) from None
141
+
142
+
143
+ @app.command()
144
+ def mcp(
145
+ transport: str = typer.Option(
146
+ "stdio",
147
+ "--transport",
148
+ "-t",
149
+ help="MCP transport: stdio, http, or sse.",
150
+ envvar="PANEL_LIVE_SERVER_TRANSPORT",
151
+ ),
152
+ host: str = typer.Option(
153
+ "127.0.0.1",
154
+ "--host",
155
+ help="Host for HTTP/SSE transport.",
156
+ envvar="PANEL_LIVE_SERVER_MCP_HOST",
157
+ ),
158
+ port: int = typer.Option(
159
+ 8001,
160
+ "--port",
161
+ "-p",
162
+ help="Port for HTTP/SSE transport.",
163
+ envvar="PANEL_LIVE_SERVER_MCP_PORT",
164
+ ),
165
+ verbose: bool = typer.Option(
166
+ False,
167
+ "--verbose",
168
+ "-v",
169
+ help="Enable verbose logging.",
170
+ ),
171
+ ) -> None:
172
+ """Start as an MCP server for AI assistants.
173
+
174
+ The MCP server exposes the `show` tool for executing and displaying
175
+ Python visualizations. A Panel visualization server starts automatically
176
+ on port 5077 (PANEL_LIVE_SERVER_PORT) — visit that address in a browser
177
+ to watch visualizations appear in real time.
178
+
179
+ Note: the --port flag here controls the MCP HTTP/SSE listener, NOT the
180
+ Panel visualization server port. For stdio transport, --port is unused.
181
+ """
182
+ if verbose:
183
+ logging.basicConfig(level=logging.DEBUG)
184
+ else:
185
+ logging.basicConfig(level=logging.INFO)
186
+
187
+ from panel_live_server.server import mcp as mcp_server
188
+
189
+ if transport == "stdio":
190
+ mcp_server.run(transport="stdio")
191
+ elif transport == "http":
192
+ mcp_server.run(transport="streamable-http", host=host, port=port)
193
+ elif transport == "sse":
194
+ mcp_server.run(transport="sse", host=host, port=port)
195
+ else:
196
+ typer.echo(f"Unknown transport: {transport!r}. Choose from: stdio, http, sse.")
197
+ raise typer.Exit(1)
198
+
199
+
200
+ @app.command()
201
+ def status(
202
+ port: int = typer.Option(
203
+ 5077,
204
+ "--port",
205
+ "-p",
206
+ help="Port to check.",
207
+ envvar="PANEL_LIVE_SERVER_PORT",
208
+ show_default=True,
209
+ ),
210
+ host: str = typer.Option(
211
+ "localhost",
212
+ "--host",
213
+ "-H",
214
+ help="Host to check.",
215
+ envvar="PANEL_LIVE_SERVER_HOST",
216
+ show_default=True,
217
+ ),
218
+ ) -> None:
219
+ """Check whether the Panel server is running.
220
+
221
+ Queries the health endpoint and reports the server status.
222
+ """
223
+ import requests
224
+
225
+ url = f"http://{host}:{port}/api/health"
226
+ try:
227
+ resp = requests.get(url, timeout=3)
228
+ if resp.status_code == 200:
229
+ data = resp.json()
230
+ typer.echo(f"Running http://{host}:{port}/feed (healthy at {data.get('timestamp', '?')})")
231
+ else:
232
+ typer.echo(f"Unhealthy http://{host}:{port} (status {resp.status_code})")
233
+ raise typer.Exit(1)
234
+ except requests.ConnectionError:
235
+ typer.echo(f"Not running (nothing on {host}:{port})")
236
+ raise typer.Exit(1) from None
237
+ except requests.Timeout:
238
+ typer.echo(f"Timeout (no response from {host}:{port} within 3 s)")
239
+ raise typer.Exit(1) from None
240
+
241
+
242
+ @list_app.command(name="packages")
243
+ def list_packages(
244
+ filter: str = typer.Argument(
245
+ "",
246
+ help="Optional substring to filter package names (case-insensitive).",
247
+ show_default=False,
248
+ ),
249
+ ) -> None:
250
+ """List all Python packages installed in the current environment.
251
+
252
+ Optionally filter by a substring, e.g. ``pls list packages panel`` to show
253
+ only packages whose name contains "panel".
254
+ """
255
+ from importlib.metadata import distributions
256
+
257
+ pkgs = sorted(
258
+ ((dist.metadata["Name"], dist.metadata["Version"]) for dist in distributions()),
259
+ key=lambda t: t[0].lower().replace("-", "_"),
260
+ )
261
+
262
+ if filter:
263
+ pkgs = [(name, ver) for name, ver in pkgs if filter.lower() in name.lower()]
264
+
265
+ if not pkgs:
266
+ typer.echo("No packages found.")
267
+ return
268
+
269
+ name_width = max(len(name) for name, _ in pkgs)
270
+ for name, version in pkgs:
271
+ typer.echo(f"{name:<{name_width}} {version}")
272
+
273
+
274
+ def main() -> None:
275
+ """Entry point for the pls command."""
276
+ app()
277
+
278
+
279
+ if __name__ == "__main__":
280
+ main()
@@ -0,0 +1,177 @@
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 = "inline", validated: bool = False) -> 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 ("inline" or "server")
66
+ validated : bool, optional
67
+ When True, signals that the code was already validated and executed
68
+ by the MCP ``show`` tool, so the server can skip its redundant
69
+ storage-time validation and execution.
70
+
71
+ Returns
72
+ -------
73
+ dict
74
+ Server response containing either:
75
+ - Success: {"url": str, "id": str, ...}
76
+ - Error: {"error": str, "message": str, "traceback": str}
77
+
78
+ Raises
79
+ ------
80
+ RuntimeError
81
+ If HTTP request fails
82
+ """
83
+ try:
84
+ response = self.session.post(
85
+ f"{self.base_url}/api/snippet",
86
+ json={
87
+ "code": code,
88
+ "name": name,
89
+ "description": description,
90
+ "method": method,
91
+ "validated": validated,
92
+ },
93
+ timeout=self.timeout,
94
+ )
95
+
96
+ response.raise_for_status()
97
+ return response.json()
98
+
99
+ except requests.RequestException as e:
100
+ logger.exception(f"Error creating visualization: {e}")
101
+ raise RuntimeError(f"Failed to create visualization: {e}") from e
102
+
103
+ def get_embed_html(self, snippet_id: str) -> str | None:
104
+ """Fetch self-contained static HTML for an inline-method snippet.
105
+
106
+ Returns ``None`` on failure so the caller can degrade gracefully.
107
+ """
108
+ try:
109
+ response = self.session.get(
110
+ f"{self.base_url}/api/embed",
111
+ params={"id": snippet_id},
112
+ timeout=self.timeout,
113
+ )
114
+ if response.status_code != 200:
115
+ logger.warning("Embed render failed (HTTP %s) for snippet %s", response.status_code, snippet_id)
116
+ return None
117
+ if "text/html" not in response.headers.get("Content-Type", ""):
118
+ logger.warning("Embed render returned non-HTML content-type for snippet %s", snippet_id)
119
+ return None
120
+ return response.text
121
+ except requests.RequestException as e:
122
+ logger.warning("Embed render request error for snippet %s: %s", snippet_id, e)
123
+ return None
124
+
125
+ def get_screenshot(
126
+ self,
127
+ snippet_id: str,
128
+ width: int | None = None,
129
+ height: int | None = None,
130
+ full_page: bool = False,
131
+ ) -> tuple[bytes | None, str | None]:
132
+ """Fetch a PNG screenshot of a snippet's rendered ``/view`` page.
133
+
134
+ Returns
135
+ -------
136
+ tuple[bytes | None, str | None]
137
+ ``(png_bytes, None)`` on success, or ``(None, error_message)`` on failure.
138
+ """
139
+ params: dict[str, str | int] = {"id": snippet_id, "full_page": str(full_page).lower()}
140
+ if width:
141
+ params["width"] = width
142
+ if height:
143
+ params["height"] = height
144
+
145
+ try:
146
+ response = self.session.get(
147
+ f"{self.base_url}/api/screenshot",
148
+ params=params,
149
+ timeout=max(self.timeout, 60),
150
+ )
151
+ except requests.RequestException as e:
152
+ logger.warning("Screenshot request error for snippet %s: %s", snippet_id, e)
153
+ return None, f"Screenshot request failed: {e}"
154
+
155
+ if response.status_code == 200 and "image/png" in response.headers.get("Content-Type", ""):
156
+ return response.content, None
157
+
158
+ try:
159
+ body = response.json()
160
+ message = body.get("message") or body.get("error") or response.text
161
+ except ValueError:
162
+ message = response.text or f"HTTP {response.status_code}"
163
+ logger.warning("Screenshot failed (HTTP %s) for snippet %s: %s", response.status_code, snippet_id, message)
164
+ return None, message
165
+
166
+ def close(self) -> None:
167
+ """Close the HTTP session and cleanup resources."""
168
+ if self.session:
169
+ self.session.close()
170
+
171
+ def __enter__(self):
172
+ """Context manager entry."""
173
+ return self
174
+
175
+ def __exit__(self, exc_type, exc_val, exc_tb):
176
+ """Context manager exit."""
177
+ self.close()
@@ -0,0 +1,94 @@
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
+ def _resolve_external_url(port: int) -> str:
18
+ """Resolve the external URL for the Panel server.
19
+
20
+ Checks in priority order:
21
+ 1. ``PANEL_LIVE_SERVER_EXTERNAL_URL`` — explicit override (port-inclusive).
22
+ 2. ``JUPYTERHUB_HOST`` + ``JUPYTERHUB_SERVICE_PREFIX`` — JupyterHub with jupyter-server-proxy.
23
+ Note: ``JUPYTERHUB_SERVICE_PREFIX`` is set automatically by JupyterHub, but ``JUPYTERHUB_HOST`` is
24
+ only set automatically in subdomain routing mode and must be supplied manually in path-based routing.
25
+ 3. ``CODESPACE_NAME`` — GitHub Codespaces port-forwarding URL.
26
+ 4. ``""`` — local; callers fall back to ``http://localhost:{port}``.
27
+ """
28
+ if explicit := os.getenv("PANEL_LIVE_SERVER_EXTERNAL_URL", ""):
29
+ return explicit.rstrip("/")
30
+
31
+ hub_host = os.getenv("JUPYTERHUB_HOST", "")
32
+ hub_prefix = os.getenv("JUPYTERHUB_SERVICE_PREFIX", "")
33
+ if hub_host and hub_prefix:
34
+ if not hub_host.startswith(("http://", "https://")):
35
+ hub_host = f"https://{hub_host}"
36
+ return f"{hub_host.rstrip('/')}{hub_prefix}proxy/{port}"
37
+
38
+ if codespace := os.getenv("CODESPACE_NAME", ""):
39
+ domain = os.getenv("GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN") or "app.github.dev"
40
+ return f"https://{codespace}-{port}.{domain}"
41
+
42
+ return ""
43
+
44
+
45
+ class Config(BaseModel):
46
+ """Panel Live Server configuration."""
47
+
48
+ port: int = Field(default=5077, description="Port for the Panel server")
49
+ host: str = Field(default="localhost", description="Host address for the Panel server")
50
+ max_restarts: int = Field(default=3, description="Maximum number of restart attempts")
51
+ db_path: Path = Field(
52
+ default_factory=lambda: _default_user_dir() / "snippets" / "snippets.db",
53
+ description="Path to SQLite database for snippets",
54
+ )
55
+ external_url: str = Field(
56
+ default="",
57
+ description=(
58
+ "Externally reachable base URL for the Panel server (port-inclusive). "
59
+ "Auto-detected from JUPYTERHUB_HOST + JUPYTERHUB_SERVICE_PREFIX (JupyterHub) "
60
+ "or CODESPACE_NAME (GitHub Codespaces) if not set explicitly via PANEL_LIVE_SERVER_EXTERNAL_URL."
61
+ ),
62
+ )
63
+ screenshot_width: int = Field(default=1200, description="Viewport width (px) for screenshot capture")
64
+ screenshot_height: int = Field(default=800, description="Viewport height (px) for screenshot capture")
65
+ screenshot_settle_ms: int = Field(default=1200, description="Delay (ms) after content mounts before capturing, to let Bokeh finish drawing")
66
+ screenshot_timeout_ms: int = Field(default=30000, description="Max time (ms) to wait for the page to load before capturing")
67
+
68
+
69
+ _config: Config | None = None
70
+
71
+
72
+ def get_config() -> Config:
73
+ """Get or create the config instance."""
74
+ global _config
75
+ if _config is None:
76
+ port = int(os.getenv("PANEL_LIVE_SERVER_PORT", "5077"))
77
+ _config = Config(
78
+ port=port,
79
+ host=os.getenv("PANEL_LIVE_SERVER_HOST", "localhost"),
80
+ max_restarts=int(os.getenv("PANEL_LIVE_SERVER_MAX_RESTARTS", "3")),
81
+ db_path=Path(os.getenv("PANEL_LIVE_SERVER_DB_PATH", str(_default_user_dir() / "snippets" / "snippets.db"))),
82
+ external_url=_resolve_external_url(port),
83
+ screenshot_width=int(os.getenv("PANEL_LIVE_SERVER_SCREENSHOT_WIDTH", "1200")),
84
+ screenshot_height=int(os.getenv("PANEL_LIVE_SERVER_SCREENSHOT_HEIGHT", "800")),
85
+ screenshot_settle_ms=int(os.getenv("PANEL_LIVE_SERVER_SCREENSHOT_SETTLE_MS", "1200")),
86
+ screenshot_timeout_ms=int(os.getenv("PANEL_LIVE_SERVER_SCREENSHOT_TIMEOUT_MS", "30000")),
87
+ )
88
+ return _config
89
+
90
+
91
+ def reset_config() -> None:
92
+ """Reset config (for testing)."""
93
+ global _config
94
+ _config = None