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.
- panel_live_server/__init__.py +14 -0
- panel_live_server/app.py +125 -0
- panel_live_server/cli.py +280 -0
- panel_live_server/client.py +177 -0
- panel_live_server/config.py +94 -0
- panel_live_server/database.py +614 -0
- panel_live_server/endpoints.py +310 -0
- panel_live_server/manager.py +351 -0
- panel_live_server/pages/__init__.py +8 -0
- panel_live_server/pages/add_page.py +200 -0
- panel_live_server/pages/admin_page.py +155 -0
- panel_live_server/pages/feed_page.py +204 -0
- panel_live_server/pages/view_page.py +216 -0
- panel_live_server/py.typed +0 -0
- panel_live_server/screenshot.py +133 -0
- panel_live_server/server.py +871 -0
- panel_live_server/templates/show.html +551 -0
- panel_live_server/ui.py +17 -0
- panel_live_server/utils.py +443 -0
- panel_live_server/validation.py +303 -0
- panel_live_server-0.1.0.dist-info/METADATA +168 -0
- panel_live_server-0.1.0.dist-info/RECORD +25 -0
- panel_live_server-0.1.0.dist-info/WHEEL +4 -0
- panel_live_server-0.1.0.dist-info/entry_points.txt +3 -0
- panel_live_server-0.1.0.dist-info/licenses/LICENSE.txt +30 -0
|
@@ -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
|
+
]
|
panel_live_server/app.py
ADDED
|
@@ -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)
|
panel_live_server/cli.py
ADDED
|
@@ -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
|