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 +3 -0
- mooring/auth.py +197 -0
- mooring/cli.py +285 -0
- mooring/config.py +72 -0
- mooring/config_default.toml +18 -0
- mooring/editor.py +115 -0
- mooring/github.py +182 -0
- mooring/gitsha.py +39 -0
- mooring/hub/__init__.py +0 -0
- mooring/hub/server.py +253 -0
- mooring/hub/static/app.js +190 -0
- mooring/hub/static/index.html +74 -0
- mooring/hub/static/style.css +127 -0
- mooring/manifest.py +54 -0
- mooring/notebook_template.py +55 -0
- mooring/paths.py +26 -0
- mooring/sync.py +379 -0
- mooring-0.1.0.dist-info/METADATA +162 -0
- mooring-0.1.0.dist-info/RECORD +21 -0
- mooring-0.1.0.dist-info/WHEEL +4 -0
- mooring-0.1.0.dist-info/entry_points.txt +2 -0
mooring/__init__.py
ADDED
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()
|