openplot 1.0.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.
- openplot/__init__.py +3 -0
- openplot/api/__init__.py +1 -0
- openplot/api/schemas.py +132 -0
- openplot/cli.py +139 -0
- openplot/desktop.py +437 -0
- openplot/domain/__init__.py +1 -0
- openplot/domain/annotations.py +45 -0
- openplot/domain/regions.py +52 -0
- openplot/executor.py +726 -0
- openplot/feedback.py +141 -0
- openplot/mcp_server.py +353 -0
- openplot/models.py +408 -0
- openplot/release_versioning.py +305 -0
- openplot/server.py +11120 -0
- openplot/services/__init__.py +1 -0
- openplot/services/naming.py +75 -0
- openplot/static/assets/MarkdownRenderer-DqMDEmDw.js +2 -0
- openplot/static/assets/geist-cyrillic-wght-normal-CHSlOQsW.woff2 +0 -0
- openplot/static/assets/geist-latin-ext-wght-normal-DMtmJ5ZE.woff2 +0 -0
- openplot/static/assets/geist-latin-wght-normal-Dm3htQBi.woff2 +0 -0
- openplot/static/assets/iconify-xdk4cov8.js +1 -0
- openplot/static/assets/index-9qrwxLoh.css +1 -0
- openplot/static/assets/index-DhwX5yIi.js +19 -0
- openplot/static/assets/lucide-DCaNFHhh.js +1 -0
- openplot/static/assets/markdown-BgYhP8km.js +29 -0
- openplot/static/assets/openplot-DYqSIMi_.png +0 -0
- openplot/static/assets/react-vendor-C4hNv3Lv.js +9 -0
- openplot/static/assets/ui-DjPaK12C.js +12 -0
- openplot/static/index.html +17 -0
- openplot/static/vite.svg +1 -0
- openplot-1.0.0.dist-info/METADATA +332 -0
- openplot-1.0.0.dist-info/RECORD +34 -0
- openplot-1.0.0.dist-info/WHEEL +4 -0
- openplot-1.0.0.dist-info/entry_points.txt +4 -0
openplot/__init__.py
ADDED
openplot/api/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""API helpers and schemas for OpenPlot."""
|
openplot/api/schemas.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""Typed request payloads for the OpenPlot FastAPI server."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Literal
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, ConfigDict
|
|
8
|
+
|
|
9
|
+
from ..models import FixRunner
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class PlotModePathSuggestionsRequest(BaseModel):
|
|
13
|
+
selection_type: Literal["data", "script"] = "data"
|
|
14
|
+
query: str = ""
|
|
15
|
+
workspace_id: str | None = None
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class PlotModeSelectPathsRequest(BaseModel):
|
|
19
|
+
selection_type: Literal["data", "script"]
|
|
20
|
+
paths: list[str]
|
|
21
|
+
workspace_id: str | None = None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class PlotModeChatRequest(BaseModel):
|
|
25
|
+
message: str = ""
|
|
26
|
+
workspace_id: str | None = None
|
|
27
|
+
runner: FixRunner | None = None
|
|
28
|
+
model: str | None = None
|
|
29
|
+
variant: str | None = None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class PlotModeSettingsRequest(BaseModel):
|
|
33
|
+
execution_mode: Literal["quick", "autonomous"]
|
|
34
|
+
workspace_id: str | None = None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class PlotModeQuestionAnswerItemRequest(BaseModel):
|
|
38
|
+
question_id: str
|
|
39
|
+
option_ids: list[str] = []
|
|
40
|
+
text: str | None = None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class PlotModeQuestionAnswerRequest(BaseModel):
|
|
44
|
+
question_set_id: str
|
|
45
|
+
answers: list[PlotModeQuestionAnswerItemRequest] = []
|
|
46
|
+
workspace_id: str | None = None
|
|
47
|
+
runner: FixRunner | None = None
|
|
48
|
+
model: str | None = None
|
|
49
|
+
variant: str | None = None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class PlotModeTabularHintRegionRequest(BaseModel):
|
|
53
|
+
sheet_id: str
|
|
54
|
+
row_start: int
|
|
55
|
+
row_end: int
|
|
56
|
+
col_start: int
|
|
57
|
+
col_end: int
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class PlotModeTabularHintRequest(BaseModel):
|
|
61
|
+
selector_id: str
|
|
62
|
+
regions: list[PlotModeTabularHintRegionRequest] = []
|
|
63
|
+
workspace_id: str | None = None
|
|
64
|
+
sheet_id: str | None = None
|
|
65
|
+
row_start: int | None = None
|
|
66
|
+
row_end: int | None = None
|
|
67
|
+
col_start: int | None = None
|
|
68
|
+
col_end: int | None = None
|
|
69
|
+
note: str | None = None
|
|
70
|
+
runner: FixRunner | None = None
|
|
71
|
+
model: str | None = None
|
|
72
|
+
variant: str | None = None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class PlotModeFinalizeRequest(BaseModel):
|
|
76
|
+
model_config = ConfigDict(extra="allow")
|
|
77
|
+
workspace_id: str | None = None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class RenameSessionRequest(BaseModel):
|
|
81
|
+
workspace_name: str | None = None
|
|
82
|
+
name: str | None = None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class RenameBranchRequest(BaseModel):
|
|
86
|
+
name: str | None = None
|
|
87
|
+
branch_name: str | None = None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class PreferencesRequest(BaseModel):
|
|
91
|
+
fix_runner: FixRunner | None = None
|
|
92
|
+
fix_model: str | None = None
|
|
93
|
+
fix_variant: str | None = None
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class PythonInterpreterRequest(BaseModel):
|
|
97
|
+
mode: Literal["builtin", "manual", "auto"] = "builtin"
|
|
98
|
+
path: str | None = None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class RunnerInstallRequest(BaseModel):
|
|
102
|
+
runner: FixRunner
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class RunnerAuthLaunchRequest(BaseModel):
|
|
106
|
+
runner: FixRunner
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class OpenExternalUrlRequest(BaseModel):
|
|
110
|
+
url: str
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class StartFixJobRequest(BaseModel):
|
|
114
|
+
session_id: str | None = None
|
|
115
|
+
runner: FixRunner | None = None
|
|
116
|
+
model: str = ""
|
|
117
|
+
variant: str | None = None
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class CheckoutVersionRequest(BaseModel):
|
|
121
|
+
version_id: str = ""
|
|
122
|
+
branch_id: str | None = None
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class AnnotationUpdateRequest(BaseModel):
|
|
126
|
+
feedback: str | None = None
|
|
127
|
+
status: str | None = None
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class SubmitScriptRequest(BaseModel):
|
|
131
|
+
code: str = ""
|
|
132
|
+
annotation_id: str | None = None
|
openplot/cli.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""CLI entry point for OpenPlot."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
import webbrowser
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
import uvicorn
|
|
11
|
+
|
|
12
|
+
DEFAULT_SERVE_PORT = 17623
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@click.group()
|
|
16
|
+
@click.version_option(package_name="openplot")
|
|
17
|
+
def main() -> None:
|
|
18
|
+
"""The agentic plotting "IDE" built for everyone."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@main.command()
|
|
22
|
+
@click.argument("file", required=False)
|
|
23
|
+
@click.option(
|
|
24
|
+
"--port",
|
|
25
|
+
"-p",
|
|
26
|
+
default=DEFAULT_SERVE_PORT,
|
|
27
|
+
help=(f"Port to serve on (default: {DEFAULT_SERVE_PORT}; use 0 to auto-pick)."),
|
|
28
|
+
)
|
|
29
|
+
@click.option("--host", "-h", default="127.0.0.1", help="Host to bind to.")
|
|
30
|
+
@click.option("--no-browser", is_flag=True, help="Don't auto-open the browser.")
|
|
31
|
+
def serve(file: str | None, port: int, host: str, no_browser: bool) -> None:
|
|
32
|
+
"""Start the OpenPlot server for FILE or by restoring a workspace.
|
|
33
|
+
|
|
34
|
+
FILE can be a Python script (.py).
|
|
35
|
+
If FILE is omitted, OpenPlot restores the most recently updated workspace.
|
|
36
|
+
If no workspace exists, OpenPlot starts in plot mode.
|
|
37
|
+
"""
|
|
38
|
+
from .server import (
|
|
39
|
+
create_app,
|
|
40
|
+
init_session_from_script,
|
|
41
|
+
set_workspace_dir,
|
|
42
|
+
write_port_file,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
if file is not None:
|
|
46
|
+
file_path = Path(file).expanduser().resolve()
|
|
47
|
+
if not file_path.exists():
|
|
48
|
+
click.secho(f"Error: File not found: {file_path}", fg="red")
|
|
49
|
+
sys.exit(1)
|
|
50
|
+
|
|
51
|
+
ext = file_path.suffix.lower()
|
|
52
|
+
if ext != ".py":
|
|
53
|
+
click.secho(
|
|
54
|
+
f"Error: OpenPlot serve only accepts Python scripts (.py), got: {file_path.name}",
|
|
55
|
+
fg="red",
|
|
56
|
+
)
|
|
57
|
+
sys.exit(1)
|
|
58
|
+
|
|
59
|
+
click.echo(f"Executing {file_path.name} ...")
|
|
60
|
+
result = init_session_from_script(file_path)
|
|
61
|
+
if not result.success:
|
|
62
|
+
click.secho(f"Error: {result.error}", fg="red")
|
|
63
|
+
if result.stderr:
|
|
64
|
+
click.echo(result.stderr)
|
|
65
|
+
sys.exit(1)
|
|
66
|
+
click.echo(f"Detected output: {result.plot_path} ({result.plot_type})")
|
|
67
|
+
else:
|
|
68
|
+
set_workspace_dir(Path.cwd())
|
|
69
|
+
click.echo(
|
|
70
|
+
"Starting OpenPlot. The web UI will restore the most recently updated workspace or start a new plot workspace."
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
# Pick a free port if 0.
|
|
74
|
+
if port == 0:
|
|
75
|
+
import socket
|
|
76
|
+
|
|
77
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
78
|
+
s.bind(("", 0))
|
|
79
|
+
port = s.getsockname()[1]
|
|
80
|
+
|
|
81
|
+
write_port_file(port)
|
|
82
|
+
|
|
83
|
+
url = f"http://{host}:{port}"
|
|
84
|
+
click.echo(f"OpenPlot server running at {url}")
|
|
85
|
+
|
|
86
|
+
if not no_browser:
|
|
87
|
+
# Delay browser open slightly so the server is ready.
|
|
88
|
+
import threading
|
|
89
|
+
|
|
90
|
+
threading.Timer(1.0, lambda: webbrowser.open(url)).start()
|
|
91
|
+
|
|
92
|
+
app = create_app()
|
|
93
|
+
uvicorn.run(app, host=host, port=port, log_level="warning")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@main.command()
|
|
97
|
+
@click.argument("file", required=False)
|
|
98
|
+
@click.option(
|
|
99
|
+
"--port",
|
|
100
|
+
"-p",
|
|
101
|
+
default=DEFAULT_SERVE_PORT,
|
|
102
|
+
help=(f"Port to serve on (default: {DEFAULT_SERVE_PORT}; use 0 to auto-pick)."),
|
|
103
|
+
)
|
|
104
|
+
@click.option("--host", "-h", default="127.0.0.1", help="Host to bind to.")
|
|
105
|
+
def desktop(file: str | None, port: int, host: str) -> None:
|
|
106
|
+
"""Launch OpenPlot in a native desktop window."""
|
|
107
|
+
from .desktop import launch_desktop
|
|
108
|
+
|
|
109
|
+
launch_desktop(file, host=host, port=port)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@main.command()
|
|
113
|
+
@click.option(
|
|
114
|
+
"--server-url",
|
|
115
|
+
default=None,
|
|
116
|
+
help=(
|
|
117
|
+
"OpenPlot backend URL to proxy (default: auto-discover from "
|
|
118
|
+
"OPENPLOT_SERVER_URL or ~/.openplot/port)."
|
|
119
|
+
),
|
|
120
|
+
)
|
|
121
|
+
def mcp(server_url: str | None) -> None:
|
|
122
|
+
"""Launch the MCP stdio server (for agent integration).
|
|
123
|
+
|
|
124
|
+
This connects to a running OpenPlot server to proxy tool calls.
|
|
125
|
+
"""
|
|
126
|
+
from .mcp_server import BackendError, discover_server_url, run_mcp_stdio
|
|
127
|
+
|
|
128
|
+
try:
|
|
129
|
+
resolved_url = discover_server_url(server_url)
|
|
130
|
+
except BackendError as exc:
|
|
131
|
+
click.secho(f"Error: {exc}", fg="red", err=True)
|
|
132
|
+
sys.exit(1)
|
|
133
|
+
|
|
134
|
+
click.echo(f"Starting OpenPlot MCP server (backend: {resolved_url})", err=True)
|
|
135
|
+
run_mcp_stdio(resolved_url)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
if __name__ == "__main__":
|
|
139
|
+
main()
|
openplot/desktop.py
ADDED
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
"""Desktop launcher for OpenPlot."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import atexit
|
|
6
|
+
import importlib
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import socket
|
|
10
|
+
import sys
|
|
11
|
+
import threading
|
|
12
|
+
import time
|
|
13
|
+
from contextlib import suppress
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
import click
|
|
17
|
+
import uvicorn
|
|
18
|
+
|
|
19
|
+
DEFAULT_DESKTOP_PORT = 17623
|
|
20
|
+
_ALLOWED_SUFFIXES = {".py", ".svg", ".png", ".jpg", ".jpeg", ".pdf"}
|
|
21
|
+
_DESKTOP_FILE_DROP_EVENT = "openplot-desktop-file-drop"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _strip_macos_process_serial_arg() -> None:
|
|
25
|
+
"""Remove Finder's process serial argument before Click parses argv."""
|
|
26
|
+
if sys.platform != "darwin":
|
|
27
|
+
return
|
|
28
|
+
if len(sys.argv) < 2:
|
|
29
|
+
return
|
|
30
|
+
if sys.argv[1].startswith("-psn_"):
|
|
31
|
+
del sys.argv[1]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _pick_free_port() -> int:
|
|
35
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
|
36
|
+
sock.bind(("", 0))
|
|
37
|
+
return int(sock.getsockname()[1])
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _configure_linux_qt_runtime() -> None:
|
|
41
|
+
if not sys.platform.startswith("linux"):
|
|
42
|
+
return
|
|
43
|
+
|
|
44
|
+
os.environ.setdefault("PYWEBVIEW_GUI", "qt")
|
|
45
|
+
os.environ.setdefault("QT_API", "pyqt6")
|
|
46
|
+
os.environ.setdefault("QT_QPA_PLATFORM", "xcb")
|
|
47
|
+
os.environ.setdefault("QT_OPENGL", "software")
|
|
48
|
+
os.environ.setdefault("QT_QUICK_BACKEND", "software")
|
|
49
|
+
os.environ.setdefault("LIBGL_ALWAYS_SOFTWARE", "1")
|
|
50
|
+
os.environ.setdefault("QTWEBENGINE_DISABLE_SANDBOX", "1")
|
|
51
|
+
|
|
52
|
+
chromium_default = (
|
|
53
|
+
"--disable-gpu --disable-gpu-compositing --disable-features=Vulkan --no-sandbox"
|
|
54
|
+
)
|
|
55
|
+
existing_chromium_flags = os.environ.get("QTWEBENGINE_CHROMIUM_FLAGS", "").strip()
|
|
56
|
+
if existing_chromium_flags:
|
|
57
|
+
os.environ["QTWEBENGINE_CHROMIUM_FLAGS"] = (
|
|
58
|
+
f"{existing_chromium_flags} {chromium_default}"
|
|
59
|
+
)
|
|
60
|
+
else:
|
|
61
|
+
os.environ["QTWEBENGINE_CHROMIUM_FLAGS"] = chromium_default
|
|
62
|
+
|
|
63
|
+
appdir = os.environ.get("APPDIR", "").strip()
|
|
64
|
+
if appdir:
|
|
65
|
+
bundled_xkb = Path(appdir) / "usr" / "share" / "X11" / "xkb"
|
|
66
|
+
if bundled_xkb.is_dir():
|
|
67
|
+
os.environ.setdefault("XKB_CONFIG_ROOT", str(bundled_xkb))
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _resolve_input_file(raw_file: str | None) -> Path | None:
|
|
71
|
+
if not raw_file:
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
path = Path(raw_file).expanduser().resolve()
|
|
75
|
+
|
|
76
|
+
if not path.exists():
|
|
77
|
+
raise click.ClickException(f"File not found: {path}")
|
|
78
|
+
|
|
79
|
+
ext = path.suffix.lower()
|
|
80
|
+
if ext not in _ALLOWED_SUFFIXES:
|
|
81
|
+
supported = ", ".join(sorted(_ALLOWED_SUFFIXES))
|
|
82
|
+
raise click.ClickException(
|
|
83
|
+
f"Unsupported file type '{ext}'. Expected one of: {supported}"
|
|
84
|
+
)
|
|
85
|
+
return path
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _desktop_file_drop_script(paths: list[str]) -> str:
|
|
89
|
+
payload = json.dumps({"paths": paths})
|
|
90
|
+
return (
|
|
91
|
+
"window.dispatchEvent("
|
|
92
|
+
f"new CustomEvent({_DESKTOP_FILE_DROP_EVENT!r}, {{ detail: {payload} }})"
|
|
93
|
+
");"
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _desktop_dropped_file_paths(event: object) -> list[str]:
|
|
98
|
+
if not isinstance(event, dict):
|
|
99
|
+
return []
|
|
100
|
+
|
|
101
|
+
data_transfer = event.get("dataTransfer")
|
|
102
|
+
if not isinstance(data_transfer, dict):
|
|
103
|
+
return []
|
|
104
|
+
|
|
105
|
+
raw_files = data_transfer.get("files")
|
|
106
|
+
if not isinstance(raw_files, list):
|
|
107
|
+
return []
|
|
108
|
+
|
|
109
|
+
paths: list[str] = []
|
|
110
|
+
seen: set[str] = set()
|
|
111
|
+
for item in raw_files:
|
|
112
|
+
if not isinstance(item, dict):
|
|
113
|
+
continue
|
|
114
|
+
raw_path = item.get("pywebviewFullPath")
|
|
115
|
+
if not isinstance(raw_path, str):
|
|
116
|
+
continue
|
|
117
|
+
normalized = raw_path.strip()
|
|
118
|
+
if not normalized or normalized in seen:
|
|
119
|
+
continue
|
|
120
|
+
seen.add(normalized)
|
|
121
|
+
paths.append(normalized)
|
|
122
|
+
return paths
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _bind_macos_file_drop_bridge(window: object) -> None:
|
|
126
|
+
if sys.platform != "darwin":
|
|
127
|
+
return
|
|
128
|
+
|
|
129
|
+
try:
|
|
130
|
+
from webview.dom import DOMEventHandler
|
|
131
|
+
except ImportError:
|
|
132
|
+
return
|
|
133
|
+
|
|
134
|
+
document = getattr(getattr(window, "dom", None), "document", None)
|
|
135
|
+
if document is None:
|
|
136
|
+
return
|
|
137
|
+
|
|
138
|
+
def _ignore_drag(_event: object) -> None:
|
|
139
|
+
return
|
|
140
|
+
|
|
141
|
+
def _on_drop(event: object) -> None:
|
|
142
|
+
paths = _desktop_dropped_file_paths(event)
|
|
143
|
+
if not paths:
|
|
144
|
+
return
|
|
145
|
+
evaluate_js = getattr(window, "evaluate_js", None)
|
|
146
|
+
if not callable(evaluate_js):
|
|
147
|
+
return
|
|
148
|
+
with suppress(Exception):
|
|
149
|
+
evaluate_js(_desktop_file_drop_script(paths))
|
|
150
|
+
|
|
151
|
+
document.events.dragenter += DOMEventHandler(_ignore_drag, True, True)
|
|
152
|
+
document.events.dragover += DOMEventHandler(_ignore_drag, True, True, debounce=500)
|
|
153
|
+
document.events.drop += DOMEventHandler(_on_drop, True, True)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _restore_stdio_for_windowed_app() -> None:
|
|
157
|
+
"""Restore sys.stdin/stdout/stderr from OS handles.
|
|
158
|
+
|
|
159
|
+
PyInstaller windowed apps (console=False) set these to None, but when a
|
|
160
|
+
parent process spawns us with pipes the OS-level handles are valid.
|
|
161
|
+
Re-wrapping them lets stdio-based transports (MCP, click.echo) work.
|
|
162
|
+
"""
|
|
163
|
+
if sys.platform == "win32":
|
|
164
|
+
_restore_stdio_win32()
|
|
165
|
+
else:
|
|
166
|
+
_restore_stdio_posix()
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _restore_stdio_posix() -> None:
|
|
170
|
+
for fd, mode, attr in ((0, "r", "stdin"), (1, "w", "stdout"), (2, "w", "stderr")):
|
|
171
|
+
if getattr(sys, attr) is None:
|
|
172
|
+
try:
|
|
173
|
+
setattr(sys, attr, os.fdopen(fd, mode, closefd=False))
|
|
174
|
+
except OSError:
|
|
175
|
+
pass
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _restore_stdio_win32() -> None:
|
|
179
|
+
"""Restore stdio on Windows using the Win32 standard handles.
|
|
180
|
+
|
|
181
|
+
In a GUI-subsystem process (console=False) the C-runtime file descriptors
|
|
182
|
+
0/1/2 may not be mapped, but the Win32 standard handles that the parent
|
|
183
|
+
provided via STARTUPINFO *are* present. We use GetStdHandle -> msvcrt
|
|
184
|
+
open_osfhandle -> os.fdopen to reconnect them.
|
|
185
|
+
"""
|
|
186
|
+
try:
|
|
187
|
+
import ctypes
|
|
188
|
+
import msvcrt
|
|
189
|
+
except ImportError:
|
|
190
|
+
return
|
|
191
|
+
|
|
192
|
+
kernel32 = ctypes.windll.kernel32 # type: ignore[attr-defined]
|
|
193
|
+
INVALID_HANDLE_VALUE = ctypes.c_void_p(-1).value
|
|
194
|
+
|
|
195
|
+
STD_HANDLES = (
|
|
196
|
+
(-10, 0, "r", "stdin"), # STD_INPUT_HANDLE
|
|
197
|
+
(-11, 1, "w", "stdout"), # STD_OUTPUT_HANDLE
|
|
198
|
+
(-12, 2, "w", "stderr"), # STD_ERROR_HANDLE
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
for std_id, fallback_fd, mode, attr in STD_HANDLES:
|
|
202
|
+
if getattr(sys, attr) is not None:
|
|
203
|
+
continue
|
|
204
|
+
|
|
205
|
+
handle = kernel32.GetStdHandle(std_id)
|
|
206
|
+
if handle in (0, INVALID_HANDLE_VALUE, None):
|
|
207
|
+
continue
|
|
208
|
+
|
|
209
|
+
try:
|
|
210
|
+
fd = msvcrt.open_osfhandle(handle, 0)
|
|
211
|
+
except OSError:
|
|
212
|
+
fd = fallback_fd
|
|
213
|
+
|
|
214
|
+
try:
|
|
215
|
+
setattr(sys, attr, os.fdopen(fd, mode, closefd=False))
|
|
216
|
+
except OSError:
|
|
217
|
+
pass
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _run_internal_script_execution(
|
|
221
|
+
*,
|
|
222
|
+
script_path: str,
|
|
223
|
+
work_dir: str | None,
|
|
224
|
+
capture_dir: str | None,
|
|
225
|
+
) -> int:
|
|
226
|
+
from openplot.executor import execute_script_inline
|
|
227
|
+
|
|
228
|
+
_restore_stdio_for_windowed_app()
|
|
229
|
+
|
|
230
|
+
result = execute_script_inline(
|
|
231
|
+
script_path,
|
|
232
|
+
work_dir=work_dir,
|
|
233
|
+
capture_dir=capture_dir,
|
|
234
|
+
)
|
|
235
|
+
click.echo(
|
|
236
|
+
json.dumps(
|
|
237
|
+
{
|
|
238
|
+
"type": "openplot_internal_script_result",
|
|
239
|
+
"success": result.success,
|
|
240
|
+
"plot_path": result.plot_path,
|
|
241
|
+
"plot_type": result.plot_type,
|
|
242
|
+
"stdout": result.stdout,
|
|
243
|
+
"stderr": result.stderr,
|
|
244
|
+
"returncode": result.returncode,
|
|
245
|
+
"duration_s": result.duration_s,
|
|
246
|
+
"error": result.error,
|
|
247
|
+
}
|
|
248
|
+
)
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
if result.returncode != 0:
|
|
252
|
+
return result.returncode
|
|
253
|
+
return 0
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _run_internal_mcp_stdio(server_url: str | None) -> int:
|
|
257
|
+
from openplot.mcp_server import BackendError, discover_server_url, run_mcp_stdio
|
|
258
|
+
|
|
259
|
+
_restore_stdio_for_windowed_app()
|
|
260
|
+
|
|
261
|
+
try:
|
|
262
|
+
resolved_url = discover_server_url(server_url)
|
|
263
|
+
except BackendError as exc:
|
|
264
|
+
click.echo(f"Error: {exc}", err=True)
|
|
265
|
+
return 1
|
|
266
|
+
|
|
267
|
+
run_mcp_stdio(resolved_url)
|
|
268
|
+
return 0
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def launch_desktop(
|
|
272
|
+
file: str | None,
|
|
273
|
+
*,
|
|
274
|
+
host: str = "127.0.0.1",
|
|
275
|
+
port: int = DEFAULT_DESKTOP_PORT,
|
|
276
|
+
) -> None:
|
|
277
|
+
"""Launch OpenPlot in a native webview window."""
|
|
278
|
+
file_path = _resolve_input_file(file)
|
|
279
|
+
if file_path is not None and file_path.suffix.lower() != ".py":
|
|
280
|
+
raise click.ClickException(
|
|
281
|
+
f"OpenPlot desktop only accepts Python scripts (.py), got: {file_path.name}"
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
_configure_linux_qt_runtime()
|
|
285
|
+
|
|
286
|
+
try:
|
|
287
|
+
webview = importlib.import_module("webview")
|
|
288
|
+
except ModuleNotFoundError as exc:
|
|
289
|
+
raise click.ClickException(
|
|
290
|
+
"Desktop mode requires 'pywebview'. Install with: uv sync --extra desktop"
|
|
291
|
+
) from exc
|
|
292
|
+
|
|
293
|
+
from openplot.server import (
|
|
294
|
+
create_app,
|
|
295
|
+
init_session_from_script,
|
|
296
|
+
set_workspace_dir,
|
|
297
|
+
write_port_file,
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
if file_path is None:
|
|
301
|
+
set_workspace_dir(Path.home())
|
|
302
|
+
else:
|
|
303
|
+
set_workspace_dir(file_path.parent)
|
|
304
|
+
|
|
305
|
+
click.echo(f"Executing {file_path.name} ...")
|
|
306
|
+
result = init_session_from_script(file_path)
|
|
307
|
+
if not result.success:
|
|
308
|
+
details = result.error or "Failed to execute script"
|
|
309
|
+
if result.stderr:
|
|
310
|
+
details = f"{details}\n{result.stderr.strip()}"
|
|
311
|
+
raise click.ClickException(details)
|
|
312
|
+
|
|
313
|
+
if port == 0:
|
|
314
|
+
port = _pick_free_port()
|
|
315
|
+
|
|
316
|
+
write_port_file(port)
|
|
317
|
+
url = f"http://{host}:{port}"
|
|
318
|
+
|
|
319
|
+
app = create_app()
|
|
320
|
+
config = uvicorn.Config(app, host=host, port=port, log_level="warning")
|
|
321
|
+
uvicorn_server = uvicorn.Server(config)
|
|
322
|
+
server_thread = threading.Thread(
|
|
323
|
+
target=uvicorn_server.run,
|
|
324
|
+
name="openplot-uvicorn",
|
|
325
|
+
daemon=True,
|
|
326
|
+
)
|
|
327
|
+
server_thread.start()
|
|
328
|
+
|
|
329
|
+
started_deadline = time.monotonic() + 15.0
|
|
330
|
+
while not uvicorn_server.started and server_thread.is_alive():
|
|
331
|
+
if time.monotonic() >= started_deadline:
|
|
332
|
+
break
|
|
333
|
+
time.sleep(0.05)
|
|
334
|
+
|
|
335
|
+
shutdown_once = threading.Event()
|
|
336
|
+
|
|
337
|
+
def _shutdown_server() -> None:
|
|
338
|
+
if shutdown_once.is_set():
|
|
339
|
+
return
|
|
340
|
+
shutdown_once.set()
|
|
341
|
+
uvicorn_server.should_exit = True
|
|
342
|
+
server_thread.join(timeout=8.0)
|
|
343
|
+
|
|
344
|
+
if not uvicorn_server.started:
|
|
345
|
+
_shutdown_server()
|
|
346
|
+
raise click.ClickException("Failed to start local OpenPlot backend")
|
|
347
|
+
|
|
348
|
+
click.echo(f"OpenPlot desktop running at {url}")
|
|
349
|
+
|
|
350
|
+
atexit.register(_shutdown_server)
|
|
351
|
+
|
|
352
|
+
window = webview.create_window(
|
|
353
|
+
"OpenPlot",
|
|
354
|
+
url=url,
|
|
355
|
+
width=1440,
|
|
356
|
+
height=920,
|
|
357
|
+
min_size=(980, 700),
|
|
358
|
+
zoomable=True,
|
|
359
|
+
)
|
|
360
|
+
window.events.closed += _shutdown_server
|
|
361
|
+
|
|
362
|
+
try:
|
|
363
|
+
webview.start(_bind_macos_file_drop_bridge, window, debug=False)
|
|
364
|
+
finally:
|
|
365
|
+
_shutdown_server()
|
|
366
|
+
with suppress(Exception):
|
|
367
|
+
atexit.unregister(_shutdown_server)
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
@click.command()
|
|
371
|
+
@click.argument("file", required=False)
|
|
372
|
+
@click.option(
|
|
373
|
+
"--port",
|
|
374
|
+
"-p",
|
|
375
|
+
default=DEFAULT_DESKTOP_PORT,
|
|
376
|
+
help=(f"Port to serve on (default: {DEFAULT_DESKTOP_PORT}; use 0 to auto-pick)."),
|
|
377
|
+
)
|
|
378
|
+
@click.option("--host", "-h", default="127.0.0.1", help="Host to bind to.")
|
|
379
|
+
@click.option(
|
|
380
|
+
"--internal-execute-script",
|
|
381
|
+
"internal_execute_script",
|
|
382
|
+
type=click.Path(path_type=str),
|
|
383
|
+
hidden=True,
|
|
384
|
+
)
|
|
385
|
+
@click.option(
|
|
386
|
+
"--internal-work-dir",
|
|
387
|
+
"internal_work_dir",
|
|
388
|
+
type=click.Path(path_type=str),
|
|
389
|
+
hidden=True,
|
|
390
|
+
)
|
|
391
|
+
@click.option(
|
|
392
|
+
"--internal-capture-dir",
|
|
393
|
+
"internal_capture_dir",
|
|
394
|
+
type=click.Path(path_type=str),
|
|
395
|
+
hidden=True,
|
|
396
|
+
)
|
|
397
|
+
@click.option(
|
|
398
|
+
"--internal-run-mcp",
|
|
399
|
+
"internal_run_mcp",
|
|
400
|
+
is_flag=True,
|
|
401
|
+
hidden=True,
|
|
402
|
+
)
|
|
403
|
+
@click.option(
|
|
404
|
+
"--internal-mcp-server-url",
|
|
405
|
+
"internal_mcp_server_url",
|
|
406
|
+
type=str,
|
|
407
|
+
hidden=True,
|
|
408
|
+
)
|
|
409
|
+
def main(
|
|
410
|
+
file: str | None,
|
|
411
|
+
port: int,
|
|
412
|
+
host: str,
|
|
413
|
+
internal_execute_script: str | None,
|
|
414
|
+
internal_work_dir: str | None,
|
|
415
|
+
internal_capture_dir: str | None,
|
|
416
|
+
internal_run_mcp: bool,
|
|
417
|
+
internal_mcp_server_url: str | None,
|
|
418
|
+
) -> None:
|
|
419
|
+
"""Launch OpenPlot in a native desktop window."""
|
|
420
|
+
if internal_execute_script is not None:
|
|
421
|
+
raise SystemExit(
|
|
422
|
+
_run_internal_script_execution(
|
|
423
|
+
script_path=internal_execute_script,
|
|
424
|
+
work_dir=internal_work_dir,
|
|
425
|
+
capture_dir=internal_capture_dir,
|
|
426
|
+
)
|
|
427
|
+
)
|
|
428
|
+
|
|
429
|
+
if internal_run_mcp:
|
|
430
|
+
raise SystemExit(_run_internal_mcp_stdio(internal_mcp_server_url))
|
|
431
|
+
|
|
432
|
+
launch_desktop(file, host=host, port=port)
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
if __name__ == "__main__":
|
|
436
|
+
_strip_macos_process_serial_arg()
|
|
437
|
+
main()
|