mooring 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.
mooring/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Mooring: git-free marimo notebook sharing via GitHub."""
2
+
3
+ __version__ = "0.1.0"
mooring/auth.py ADDED
@@ -0,0 +1,197 @@
1
+ """GitHub OAuth Device Flow and token storage.
2
+
3
+ Device flow needs only a public client_id (no secret): the app shows a short
4
+ code, the user enters it at https://github.com/login/device, and we poll for
5
+ the resulting token. Tokens are stored in the OS credential store via keyring
6
+ (Windows Credential Manager / macOS Keychain), with a plaintext-file fallback,
7
+ and MOORING_TOKEN overrides everything for CI and tests.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import os
13
+ import stat
14
+ import time
15
+ from collections.abc import Mapping
16
+ from dataclasses import dataclass
17
+
18
+ import requests
19
+
20
+ from mooring import paths
21
+
22
+ DEVICE_CODE_URL = "https://github.com/login/device/code"
23
+ TOKEN_URL = "https://github.com/login/oauth/access_token"
24
+ SCOPE = "repo"
25
+ KEYRING_SERVICE = "mooring-github"
26
+ KEYRING_USER = "github-token"
27
+ TOKEN_FILE_NAME = "token"
28
+
29
+
30
+ class AuthError(Exception):
31
+ pass
32
+
33
+
34
+ @dataclass
35
+ class DeviceCode:
36
+ device_code: str
37
+ user_code: str
38
+ verification_uri: str
39
+ interval: int
40
+ expires_in: int
41
+
42
+
43
+ @dataclass
44
+ class PollResult:
45
+ """One poll attempt: exactly one of token/pending is set; pending carries
46
+ the interval to wait before the next attempt."""
47
+
48
+ token: str | None = None
49
+ interval: int = 5
50
+
51
+ @property
52
+ def pending(self) -> bool:
53
+ return self.token is None
54
+
55
+
56
+ def start_device_flow(client_id: str, session: requests.Session | None = None) -> DeviceCode:
57
+ http = session or requests
58
+ resp = http.post(
59
+ DEVICE_CODE_URL,
60
+ data={"client_id": client_id, "scope": SCOPE},
61
+ headers={"Accept": "application/json"},
62
+ timeout=30,
63
+ )
64
+ resp.raise_for_status()
65
+ data = resp.json()
66
+ if "device_code" not in data:
67
+ raise AuthError(f"GitHub rejected the device-flow request: {data}")
68
+ return DeviceCode(
69
+ device_code=data["device_code"],
70
+ user_code=data["user_code"],
71
+ verification_uri=data["verification_uri"],
72
+ interval=int(data.get("interval", 5)),
73
+ expires_in=int(data.get("expires_in", 900)),
74
+ )
75
+
76
+
77
+ def poll_once(
78
+ client_id: str,
79
+ device: DeviceCode,
80
+ interval: int | None = None,
81
+ session: requests.Session | None = None,
82
+ ) -> PollResult:
83
+ """Single token-poll attempt. Raises AuthError on terminal failures."""
84
+ http = session or requests
85
+ current = interval if interval is not None else device.interval
86
+ resp = http.post(
87
+ TOKEN_URL,
88
+ data={
89
+ "client_id": client_id,
90
+ "device_code": device.device_code,
91
+ "grant_type": "urn:ietf:params:oauth:grant-type:device_code",
92
+ },
93
+ headers={"Accept": "application/json"},
94
+ timeout=30,
95
+ )
96
+ resp.raise_for_status()
97
+ data = resp.json()
98
+ if "access_token" in data:
99
+ return PollResult(token=data["access_token"])
100
+ error = data.get("error", "")
101
+ if error == "authorization_pending":
102
+ return PollResult(interval=current)
103
+ if error == "slow_down":
104
+ return PollResult(interval=int(data.get("interval", current + 5)))
105
+ if error == "expired_token":
106
+ raise AuthError("The login code expired. Start the login again.")
107
+ if error == "access_denied":
108
+ raise AuthError("Login was cancelled on github.com.")
109
+ raise AuthError(f"GitHub login failed: {data.get('error_description', error or data)}")
110
+
111
+
112
+ def poll_for_token(
113
+ client_id: str,
114
+ device: DeviceCode,
115
+ session: requests.Session | None = None,
116
+ sleep=time.sleep,
117
+ clock=time.monotonic,
118
+ ) -> str:
119
+ """Blocking poll loop used by the CLI; the hub polls via poll_once instead."""
120
+ deadline = clock() + device.expires_in
121
+ interval = device.interval
122
+ while True:
123
+ if clock() >= deadline:
124
+ raise AuthError("The login code expired. Start the login again.")
125
+ result = poll_once(client_id, device, interval=interval, session=session)
126
+ if result.token:
127
+ return result.token
128
+ interval = result.interval
129
+ sleep(interval)
130
+
131
+
132
+ def _token_file() -> "os.PathLike[str]":
133
+ return paths.user_config_dir() / TOKEN_FILE_NAME
134
+
135
+
136
+ def _keyring():
137
+ try:
138
+ import keyring
139
+ import keyring.errors # noqa: F401
140
+
141
+ if keyring.get_keyring() is None:
142
+ return None
143
+ return keyring
144
+ except Exception: # pragma: no cover - environment-dependent
145
+ return None
146
+
147
+
148
+ def save_token(token: str) -> None:
149
+ kr = _keyring()
150
+ if kr is not None:
151
+ try:
152
+ kr.set_password(KEYRING_SERVICE, KEYRING_USER, token)
153
+ return
154
+ except Exception: # pragma: no cover - backend-dependent
155
+ pass
156
+ path = _token_file()
157
+ path.parent.mkdir(parents=True, exist_ok=True)
158
+ path.write_text(token, "utf-8")
159
+ try:
160
+ os.chmod(path, stat.S_IRUSR | stat.S_IWUSR)
161
+ except OSError: # pragma: no cover - chmod is best-effort on Windows
162
+ pass
163
+ print(
164
+ "Warning: no OS credential store available; "
165
+ f"token saved as plain text at {path}."
166
+ )
167
+
168
+
169
+ def get_token(env: Mapping[str, str] | None = None) -> str | None:
170
+ env = os.environ if env is None else env
171
+ if env.get("MOORING_TOKEN"):
172
+ return env["MOORING_TOKEN"]
173
+ kr = _keyring()
174
+ if kr is not None:
175
+ try:
176
+ token = kr.get_password(KEYRING_SERVICE, KEYRING_USER)
177
+ if token:
178
+ return token
179
+ except Exception: # pragma: no cover - backend-dependent
180
+ pass
181
+ path = _token_file()
182
+ if os.path.isfile(path):
183
+ text = open(path, encoding="utf-8").read().strip()
184
+ return text or None
185
+ return None
186
+
187
+
188
+ def delete_token() -> None:
189
+ kr = _keyring()
190
+ if kr is not None:
191
+ try:
192
+ kr.delete_password(KEYRING_SERVICE, KEYRING_USER)
193
+ except Exception: # pragma: no cover - includes PasswordDeleteError
194
+ pass
195
+ path = _token_file()
196
+ if os.path.isfile(path):
197
+ os.remove(path)
mooring/cli.py ADDED
@@ -0,0 +1,285 @@
1
+ """Command-line entry point for mooring."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import importlib.util
7
+ import os
8
+ import sys
9
+ from pathlib import Path
10
+
11
+ from mooring import __version__, config, paths
12
+
13
+ SELFTEST_PACKAGES = (
14
+ "marimo",
15
+ "polars",
16
+ "altair",
17
+ "plotly",
18
+ "openpyxl",
19
+ "fastexcel",
20
+ "requests",
21
+ "keyring",
22
+ "starlette",
23
+ "uvicorn",
24
+ "platformdirs",
25
+ )
26
+
27
+
28
+ def _ensure_child_pythonpath() -> None:
29
+ """Expose bundled packages to child processes (the marimo server and its kernels).
30
+
31
+ moonlit activates its extracted site-packages via site.addsitedir(), which
32
+ subprocesses do not inherit; PYTHONPATH does.
33
+ """
34
+ spec = importlib.util.find_spec("marimo")
35
+ if spec is None or not spec.origin:
36
+ return
37
+ site_dir = str(Path(spec.origin).resolve().parents[1])
38
+ parts = [p for p in os.environ.get("PYTHONPATH", "").split(os.pathsep) if p]
39
+ if site_dir not in parts:
40
+ os.environ["PYTHONPATH"] = os.pathsep.join([site_dir, *parts])
41
+
42
+
43
+ def _build_parser() -> argparse.ArgumentParser:
44
+ parser = argparse.ArgumentParser(
45
+ prog="mooring",
46
+ description="Share marimo notebooks via GitHub without git. "
47
+ "Run with no arguments to open the browser hub.",
48
+ )
49
+ parser.add_argument("--version", action="version", version=f"mooring {__version__}")
50
+ sub = parser.add_subparsers(dest="command")
51
+
52
+ hub = sub.add_parser("hub", help="open the browser hub (default)")
53
+ hub.add_argument("--no-browser", action="store_true", help="don't open a browser tab")
54
+ hub.add_argument("--port", type=int, default=None, help="fixed port for the hub server")
55
+
56
+ sub.add_parser("login", help="log in to GitHub via device flow")
57
+ sub.add_parser("logout", help="forget the stored GitHub token")
58
+ sub.add_parser("whoami", help="show the logged-in GitHub user")
59
+ sub.add_parser("status", help="show sync status of workspace files")
60
+
61
+ pull = sub.add_parser("pull", help="download changes from the team repo")
62
+ pull_grp = pull.add_mutually_exclusive_group()
63
+ pull_grp.add_argument(
64
+ "--theirs", action="store_true", help="overwrite local edits with remote versions"
65
+ )
66
+ pull_grp.add_argument(
67
+ "--keep-both",
68
+ action="store_true",
69
+ help="keep local edits and save remote versions as copies",
70
+ )
71
+
72
+ push = sub.add_parser("push", help="upload local changes to the team repo")
73
+ push.add_argument("paths", nargs="*", help="specific files to push (default: all changes)")
74
+ push.add_argument("-m", "--message", default=None, help="commit message")
75
+
76
+ open_cmd = sub.add_parser("open", help="open a notebook in the marimo editor")
77
+ open_cmd.add_argument("path", help="workspace-relative notebook path")
78
+
79
+ new = sub.add_parser("new", help="create a new notebook and open it")
80
+ new.add_argument("name", help="notebook name (e.g. sales-analysis)")
81
+
82
+ sub.add_parser("selftest", help="verify the bundled environment")
83
+ sub.add_parser("version", help="print the version")
84
+ return parser
85
+
86
+
87
+ def _print_paths(cfg: config.Config) -> None:
88
+ print(f" config file : {paths.user_config_file()}")
89
+ print(f" workspace : {cfg.workspace()}")
90
+ print(f" logs : {paths.user_log_dir()}")
91
+
92
+
93
+ def cmd_selftest(cfg: config.Config) -> int:
94
+ import importlib.metadata
95
+
96
+ print(f"mooring {__version__} (python {sys.version.split()[0]}, {sys.executable})")
97
+ failures = []
98
+ for name in SELFTEST_PACKAGES:
99
+ try:
100
+ importlib.import_module(name)
101
+ version = importlib.metadata.version(name)
102
+ print(f" ok {name} {version}")
103
+ except Exception as exc: # noqa: BLE001 - report and continue
104
+ failures.append(name)
105
+ print(f" FAIL {name}: {exc}")
106
+ _print_paths(cfg)
107
+ print(f" PYTHONPATH : {os.environ.get('PYTHONPATH', '(not set)')}")
108
+ if cfg.is_configured:
109
+ print(f" team repo : {cfg.repo_slug} (branch {cfg.branch})")
110
+ else:
111
+ print(" team repo : not configured")
112
+ if failures:
113
+ print(f"selftest FAILED: {', '.join(failures)}")
114
+ return 1
115
+ print("selftest OK")
116
+ return 0
117
+
118
+
119
+ def _require_token() -> str:
120
+ from mooring import auth
121
+
122
+ token = auth.get_token()
123
+ if not token:
124
+ sys.exit("Not logged in. Run `mooring login` first.")
125
+ return token
126
+
127
+
128
+ def _client(cfg: config.Config):
129
+ from mooring.github import GitHubClient
130
+
131
+ if not cfg.is_configured:
132
+ sys.exit(
133
+ "No team repo configured. Set [github] owner/repo/client_id in "
134
+ f"{paths.user_config_file()} (or run the hub for guided setup)."
135
+ )
136
+ return GitHubClient(_require_token(), cfg.owner, cfg.repo)
137
+
138
+
139
+ def cmd_login(cfg: config.Config) -> int:
140
+ from mooring import auth
141
+
142
+ if not cfg.client_id:
143
+ sys.exit(
144
+ "No OAuth client_id configured. Set [github] client_id in "
145
+ f"{paths.user_config_file()}."
146
+ )
147
+ device = auth.start_device_flow(cfg.client_id)
148
+ print(f"Open {device.verification_uri} and enter code: {device.user_code}")
149
+ print("Waiting for authorization...")
150
+ token = auth.poll_for_token(cfg.client_id, device)
151
+ auth.save_token(token)
152
+ from mooring.github import GitHubClient
153
+
154
+ user = GitHubClient(token, cfg.owner, cfg.repo).get_user()
155
+ print(f"Logged in as {user['login']}.")
156
+ return 0
157
+
158
+
159
+ def cmd_logout() -> int:
160
+ from mooring import auth
161
+
162
+ auth.delete_token()
163
+ print("Logged out.")
164
+ return 0
165
+
166
+
167
+ def cmd_whoami(cfg: config.Config) -> int:
168
+ from mooring.github import GitHubClient
169
+
170
+ user = GitHubClient(_require_token(), cfg.owner, cfg.repo).get_user()
171
+ print(user["login"])
172
+ return 0
173
+
174
+
175
+ def cmd_status(cfg: config.Config) -> int:
176
+ from mooring import sync
177
+
178
+ report = sync.status(_client(cfg), cfg)
179
+ if not report.files:
180
+ print("Workspace empty and no remote files. Try `mooring new <name>`.")
181
+ return 0
182
+ width = max(len(f.path) for f in report.files)
183
+ for f in report.files:
184
+ print(f" {f.path:<{width}} {f.state.value}")
185
+ print(report.summary())
186
+ return 0
187
+
188
+
189
+ def cmd_pull(cfg: config.Config, theirs: bool, keep_both: bool) -> int:
190
+ from mooring import sync
191
+
192
+ strategy = (
193
+ sync.ConflictStrategy.THEIRS
194
+ if theirs
195
+ else sync.ConflictStrategy.KEEP_BOTH
196
+ if keep_both
197
+ else sync.ConflictStrategy.SKIP
198
+ )
199
+ result = sync.pull(_client(cfg), cfg, strategy=strategy)
200
+ for line in result.lines:
201
+ print(f" {line}")
202
+ print(result.summary())
203
+ return 0 if not result.skipped_conflicts else 1
204
+
205
+
206
+ def cmd_push(cfg: config.Config, only_paths: list[str], message: str | None) -> int:
207
+ from mooring import sync
208
+
209
+ result = sync.push(_client(cfg), cfg, paths=only_paths or None, message=message)
210
+ for line in result.lines:
211
+ print(f" {line}")
212
+ print(result.summary())
213
+ return 0 if not result.blocked_conflicts else 1
214
+
215
+
216
+ def cmd_open(cfg: config.Config, rel_path: str) -> int:
217
+ import webbrowser
218
+
219
+ from mooring.editor import EditorServer
220
+
221
+ workspace = cfg.workspace()
222
+ target = workspace / rel_path
223
+ if not target.is_file():
224
+ sys.exit(f"No such notebook: {target}")
225
+ server = EditorServer(workspace)
226
+ server.ensure_started()
227
+ url = server.url_for(rel_path)
228
+ print(f"Editor running at {url} (Ctrl+C to stop)")
229
+ webbrowser.open(url)
230
+ try:
231
+ server.wait()
232
+ except KeyboardInterrupt:
233
+ server.shutdown()
234
+ return 0
235
+
236
+
237
+ def cmd_new(cfg: config.Config, name: str) -> int:
238
+ from mooring import notebook_template
239
+
240
+ workspace = cfg.workspace()
241
+ rel_path = notebook_template.create(workspace, name)
242
+ print(f"Created {rel_path}")
243
+ return cmd_open(cfg, rel_path)
244
+
245
+
246
+ def main(argv: list[str] | None = None) -> int:
247
+ _ensure_child_pythonpath()
248
+ parser = _build_parser()
249
+ args = parser.parse_args(argv)
250
+ command = args.command or "hub"
251
+ cfg = config.load_config()
252
+
253
+ if command == "version":
254
+ print(f"mooring {__version__}")
255
+ return 0
256
+ if command == "selftest":
257
+ return cmd_selftest(cfg)
258
+ if command == "hub":
259
+ from mooring.hub.server import run_hub
260
+
261
+ no_browser = getattr(args, "no_browser", False)
262
+ port = getattr(args, "port", None)
263
+ return run_hub(cfg, open_browser=not no_browser, port=port)
264
+ if command == "login":
265
+ return cmd_login(cfg)
266
+ if command == "logout":
267
+ return cmd_logout()
268
+ if command == "whoami":
269
+ return cmd_whoami(cfg)
270
+ if command == "status":
271
+ return cmd_status(cfg)
272
+ if command == "pull":
273
+ return cmd_pull(cfg, args.theirs, args.keep_both)
274
+ if command == "push":
275
+ return cmd_push(cfg, args.paths, args.message)
276
+ if command == "open":
277
+ return cmd_open(cfg, args.path)
278
+ if command == "new":
279
+ return cmd_new(cfg, args.name)
280
+ parser.error(f"unknown command {command!r}")
281
+ return 2
282
+
283
+
284
+ if __name__ == "__main__":
285
+ sys.exit(main())
mooring/config.py ADDED
@@ -0,0 +1,72 @@
1
+ """Layered configuration: packaged defaults <- user config file <- environment."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import tomllib
7
+ from collections.abc import Mapping
8
+ from dataclasses import dataclass
9
+ from importlib import resources
10
+ from pathlib import Path
11
+
12
+ from mooring import paths
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class Config:
17
+ client_id: str = ""
18
+ owner: str = ""
19
+ repo: str = ""
20
+ branch: str = "main"
21
+ folders: tuple[str, ...] = ("notebooks", "data")
22
+ warn_file_mb: int = 10
23
+ max_file_mb: int = 45
24
+ workspace_path: str = ""
25
+
26
+ @property
27
+ def repo_slug(self) -> str:
28
+ return f"{self.owner}/{self.repo}"
29
+
30
+ @property
31
+ def is_configured(self) -> bool:
32
+ return bool(self.client_id and self.owner and self.repo)
33
+
34
+ def workspace(self) -> Path:
35
+ if self.workspace_path:
36
+ return Path(self.workspace_path).expanduser()
37
+ return paths.default_workspace(self.repo or "workspace")
38
+
39
+
40
+ def _merge(base: dict, override: dict) -> dict:
41
+ out = dict(base)
42
+ for key, value in override.items():
43
+ if isinstance(value, dict) and isinstance(out.get(key), dict):
44
+ out[key] = _merge(out[key], value)
45
+ else:
46
+ out[key] = value
47
+ return out
48
+
49
+
50
+ def load_config(
51
+ user_config_path: Path | None = None,
52
+ env: Mapping[str, str] | None = None,
53
+ ) -> Config:
54
+ env = os.environ if env is None else env
55
+ default_text = resources.files("mooring").joinpath("config_default.toml").read_text("utf-8")
56
+ data = tomllib.loads(default_text)
57
+ path = user_config_path if user_config_path is not None else paths.user_config_file()
58
+ if path.is_file():
59
+ data = _merge(data, tomllib.loads(path.read_text("utf-8")))
60
+ gh = data.get("github", {})
61
+ sync = data.get("sync", {})
62
+ ws = data.get("workspace", {})
63
+ return Config(
64
+ client_id=env.get("MOORING_CLIENT_ID", gh.get("client_id", "")),
65
+ owner=env.get("MOORING_OWNER", gh.get("owner", "")),
66
+ repo=env.get("MOORING_REPO", gh.get("repo", "")),
67
+ branch=env.get("MOORING_BRANCH", gh.get("branch", "main")),
68
+ folders=tuple(sync.get("folders", ("notebooks", "data"))),
69
+ warn_file_mb=int(sync.get("warn_file_mb", 10)),
70
+ max_file_mb=int(sync.get("max_file_mb", 45)),
71
+ workspace_path=env.get("MOORING_WORKSPACE", ws.get("path", "")),
72
+ )
@@ -0,0 +1,18 @@
1
+ # Default configuration baked into the distributed artifact.
2
+ # An admin edits this file before building, so teammates receive a
3
+ # pre-configured app. Users can override any value in
4
+ # %APPDATA%\mooring\config.toml or via MOORING_* environment variables.
5
+
6
+ [github]
7
+ client_id = "" # OAuth app client id (device flow enabled); public, no secret
8
+ owner = "" # GitHub org or user that owns the shared notebooks repo
9
+ repo = "" # name of the shared notebooks repo
10
+ branch = "main"
11
+
12
+ [sync]
13
+ folders = ["notebooks", "data"]
14
+ warn_file_mb = 10
15
+ max_file_mb = 45
16
+
17
+ [workspace]
18
+ path = "" # empty = ~/Documents/mooring/<repo>
mooring/editor.py ADDED
@@ -0,0 +1,115 @@
1
+ """Manage the marimo editor as a subprocess.
2
+
3
+ marimo has no programmatic edit-mode API (only run mode), so we spawn
4
+ `python -m marimo edit <workspace>` as a single directory-mode server and
5
+ open individual notebooks via its `?file=` URL parameter. cli.main() puts the
6
+ bundled site-packages on PYTHONPATH before anything runs, so this subprocess
7
+ — and the kernel processes marimo itself spawns — can import everything even
8
+ when mooring runs from a moonlit-extracted zipapp.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import secrets
14
+ import socket
15
+ import subprocess
16
+ import sys
17
+ import time
18
+ import urllib.error
19
+ import urllib.parse
20
+ import urllib.request
21
+ from pathlib import Path
22
+
23
+ STARTUP_TIMEOUT = 30.0
24
+
25
+
26
+ class EditorError(Exception):
27
+ pass
28
+
29
+
30
+ def _free_port() -> int:
31
+ with socket.socket() as sock:
32
+ sock.bind(("127.0.0.1", 0))
33
+ return sock.getsockname()[1]
34
+
35
+
36
+ class EditorServer:
37
+ def __init__(self, workspace: Path) -> None:
38
+ self.workspace = workspace
39
+ self.port: int | None = None
40
+ self.token = secrets.token_urlsafe(16)
41
+ self._proc: subprocess.Popen | None = None
42
+
43
+ @property
44
+ def running(self) -> bool:
45
+ return self._proc is not None and self._proc.poll() is None
46
+
47
+ def ensure_started(self) -> None:
48
+ if self.running:
49
+ return
50
+ self.workspace.mkdir(parents=True, exist_ok=True)
51
+ self.port = _free_port()
52
+ cmd = [
53
+ sys.executable,
54
+ "-m",
55
+ "marimo",
56
+ "edit",
57
+ str(self.workspace),
58
+ "--headless",
59
+ "--host",
60
+ "127.0.0.1",
61
+ "--port",
62
+ str(self.port),
63
+ "--token-password",
64
+ self.token,
65
+ "--skip-update-check",
66
+ ]
67
+ self._proc = subprocess.Popen(cmd, cwd=str(self.workspace))
68
+ self._wait_ready()
69
+
70
+ def _wait_ready(self) -> None:
71
+ deadline = time.monotonic() + STARTUP_TIMEOUT
72
+ url = f"http://127.0.0.1:{self.port}/"
73
+ while time.monotonic() < deadline:
74
+ if self._proc is not None and self._proc.poll() is not None:
75
+ raise EditorError(
76
+ f"marimo exited during startup (code {self._proc.returncode})."
77
+ )
78
+ try:
79
+ urllib.request.urlopen(url, timeout=1) # noqa: S310 - localhost only
80
+ return
81
+ except urllib.error.HTTPError:
82
+ return # any HTTP response (401 included) means the server is up
83
+ except (urllib.error.URLError, OSError, TimeoutError):
84
+ time.sleep(0.25)
85
+ raise EditorError("marimo did not become ready in time.")
86
+
87
+ def url_for(self, rel_path: str) -> str:
88
+ if not self.running:
89
+ raise EditorError("Editor is not running.")
90
+ query = urllib.parse.urlencode(
91
+ {"file": rel_path.replace("\\", "/"), "access_token": self.token}
92
+ )
93
+ return f"http://127.0.0.1:{self.port}/?{query}"
94
+
95
+ def wait(self) -> None:
96
+ if self._proc is not None:
97
+ self._proc.wait()
98
+
99
+ def shutdown(self) -> None:
100
+ if not self.running:
101
+ return
102
+ proc = self._proc
103
+ if sys.platform == "win32":
104
+ # TerminateProcess would orphan marimo's kernel children; kill the tree.
105
+ subprocess.run(
106
+ ["taskkill", "/F", "/T", "/PID", str(proc.pid)],
107
+ capture_output=True,
108
+ check=False,
109
+ )
110
+ else:
111
+ proc.terminate()
112
+ try:
113
+ proc.wait(timeout=10)
114
+ except subprocess.TimeoutExpired:
115
+ proc.kill()