mooring 0.1.0__tar.gz
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-0.1.0/.github/workflows/release.yml +54 -0
- mooring-0.1.0/.gitignore +8 -0
- mooring-0.1.0/PKG-INFO +162 -0
- mooring-0.1.0/README.md +144 -0
- mooring-0.1.0/pyproject.toml +51 -0
- mooring-0.1.0/src/mooring/__init__.py +3 -0
- mooring-0.1.0/src/mooring/auth.py +197 -0
- mooring-0.1.0/src/mooring/cli.py +285 -0
- mooring-0.1.0/src/mooring/config.py +72 -0
- mooring-0.1.0/src/mooring/config_default.toml +18 -0
- mooring-0.1.0/src/mooring/editor.py +115 -0
- mooring-0.1.0/src/mooring/github.py +182 -0
- mooring-0.1.0/src/mooring/gitsha.py +39 -0
- mooring-0.1.0/src/mooring/hub/__init__.py +0 -0
- mooring-0.1.0/src/mooring/hub/server.py +253 -0
- mooring-0.1.0/src/mooring/hub/static/app.js +190 -0
- mooring-0.1.0/src/mooring/hub/static/index.html +74 -0
- mooring-0.1.0/src/mooring/hub/static/style.css +127 -0
- mooring-0.1.0/src/mooring/manifest.py +54 -0
- mooring-0.1.0/src/mooring/notebook_template.py +55 -0
- mooring-0.1.0/src/mooring/paths.py +26 -0
- mooring-0.1.0/src/mooring/sync.py +379 -0
- mooring-0.1.0/tests/manual_editor_check.py +42 -0
- mooring-0.1.0/tests/test_auth.py +71 -0
- mooring-0.1.0/tests/test_config.py +37 -0
- mooring-0.1.0/tests/test_github.py +108 -0
- mooring-0.1.0/tests/test_gitsha.py +25 -0
- mooring-0.1.0/tests/test_hub.py +54 -0
- mooring-0.1.0/tests/test_manifest.py +18 -0
- mooring-0.1.0/tests/test_sync.py +302 -0
- mooring-0.1.0/uv.lock +907 -0
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
name: release
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags: ["v*"]
|
|
6
|
+
|
|
7
|
+
permissions:
|
|
8
|
+
contents: write
|
|
9
|
+
|
|
10
|
+
jobs:
|
|
11
|
+
build:
|
|
12
|
+
runs-on: windows-latest
|
|
13
|
+
steps:
|
|
14
|
+
- uses: actions/checkout@v4
|
|
15
|
+
|
|
16
|
+
- name: Install uv
|
|
17
|
+
uses: astral-sh/setup-uv@v5
|
|
18
|
+
|
|
19
|
+
- name: Install Pythons (3.12 app target, 3.13 for moonlit)
|
|
20
|
+
run: |
|
|
21
|
+
uv python install 3.12
|
|
22
|
+
uv python install 3.13
|
|
23
|
+
|
|
24
|
+
- name: Sync dependencies
|
|
25
|
+
run: uv sync
|
|
26
|
+
|
|
27
|
+
- name: Lint
|
|
28
|
+
run: uv run ruff check src tests
|
|
29
|
+
|
|
30
|
+
- name: Test
|
|
31
|
+
run: uv run pytest -q
|
|
32
|
+
|
|
33
|
+
- name: Build artifacts
|
|
34
|
+
run: |
|
|
35
|
+
New-Item -ItemType Directory -Force dist | Out-Null
|
|
36
|
+
uvx --python 3.13 moonlit build -e mooring.cli:main -o dist/mooring.pyz --python-version 3.12
|
|
37
|
+
uvx --python 3.13 moonlit build -e mooring.cli:main -o dist/mooring.exe --windows-exe --python-version 3.12
|
|
38
|
+
|
|
39
|
+
- name: Smoke test artifact without git on PATH
|
|
40
|
+
# Strip PATH to the Python directory alone: proves the artifact works
|
|
41
|
+
# on a machine that has Python but no git or other dev tooling.
|
|
42
|
+
run: |
|
|
43
|
+
$py = (uv python find 3.12)
|
|
44
|
+
$env:PATH = (Split-Path $py)
|
|
45
|
+
& $py dist/mooring.pyz selftest
|
|
46
|
+
if ($LASTEXITCODE -ne 0) { exit 1 }
|
|
47
|
+
|
|
48
|
+
- name: Upload release assets
|
|
49
|
+
uses: softprops/action-gh-release@v2
|
|
50
|
+
with:
|
|
51
|
+
files: |
|
|
52
|
+
dist/mooring.pyz
|
|
53
|
+
dist/mooring.exe
|
|
54
|
+
generate_release_notes: true
|
mooring-0.1.0/.gitignore
ADDED
mooring-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mooring
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Git-free marimo notebook sharing via GitHub
|
|
5
|
+
Requires-Python: <3.13,>=3.12
|
|
6
|
+
Requires-Dist: altair
|
|
7
|
+
Requires-Dist: fastexcel
|
|
8
|
+
Requires-Dist: keyring
|
|
9
|
+
Requires-Dist: marimo>=0.13
|
|
10
|
+
Requires-Dist: openpyxl
|
|
11
|
+
Requires-Dist: platformdirs
|
|
12
|
+
Requires-Dist: plotly
|
|
13
|
+
Requires-Dist: polars
|
|
14
|
+
Requires-Dist: requests
|
|
15
|
+
Requires-Dist: starlette
|
|
16
|
+
Requires-Dist: uvicorn
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# ⚓ mooring
|
|
20
|
+
|
|
21
|
+
Git-free [marimo](https://marimo.io) notebook sharing via GitHub.
|
|
22
|
+
|
|
23
|
+
Mooring is a single-file app (`mooring.pyz` / `mooring.exe`) that lets a team
|
|
24
|
+
of data analysts pull, edit, and push marimo notebooks stored in a shared
|
|
25
|
+
GitHub repo — **without git installed on their machines**. All sync happens
|
|
26
|
+
over the GitHub REST API; the only requirement on an analyst's machine is
|
|
27
|
+
Python 3.12.
|
|
28
|
+
|
|
29
|
+
Double-clicking the app opens a local browser **hub**: log in to GitHub with
|
|
30
|
+
a one-time device code, see every team notebook with its sync status, pull
|
|
31
|
+
the latest, open notebooks in the bundled marimo editor, and push your
|
|
32
|
+
changes back — one commit per file, with conflicts detected and resolved
|
|
33
|
+
per file (never silently overwritten).
|
|
34
|
+
|
|
35
|
+
Built with [moonlit](https://github.com/openafterhours/moonlit).
|
|
36
|
+
|
|
37
|
+
---
|
|
38
|
+
|
|
39
|
+
## How it works
|
|
40
|
+
|
|
41
|
+
- **One shared team repo** (e.g. `your-org/notebooks`) holds `notebooks/`
|
|
42
|
+
and `data/` folders. Everyone pulls from and pushes to it.
|
|
43
|
+
- **No git anywhere.** Pull walks the repo tree via the GitHub Git Data API
|
|
44
|
+
and downloads only changed blobs; push uses the Contents API with the
|
|
45
|
+
file's last-known SHA, so GitHub itself rejects writes that would clobber
|
|
46
|
+
someone else's change.
|
|
47
|
+
- **Three-way change detection.** Mooring computes git blob SHAs locally and
|
|
48
|
+
keeps a manifest of what was last synced, so it always knows whether a file
|
|
49
|
+
is modified locally, changed remotely, or conflicted.
|
|
50
|
+
- **Conflicts are explicit.** Pull never overwrites local edits; push blocks
|
|
51
|
+
conflicted files. The hub offers per-file resolution: *Use remote*,
|
|
52
|
+
*Keep both*, or *Push as copy* (publishes your version under
|
|
53
|
+
`name-<your-github-login>.py`).
|
|
54
|
+
- **Frozen package stack.** Notebooks can import anything bundled into the
|
|
55
|
+
artifact: `polars`, `altair`, `plotly`, `openpyxl`, `fastexcel`,
|
|
56
|
+
`requests` (plus the standard library). There is no pip at runtime.
|
|
57
|
+
|
|
58
|
+
## For analysts: install & use
|
|
59
|
+
|
|
60
|
+
1. Install [Python 3.12](https://www.python.org/downloads/) (tick
|
|
61
|
+
*"Add python.exe to PATH"*).
|
|
62
|
+
2. Get `mooring.exe` (or `mooring.pyz`) from your admin and put it anywhere,
|
|
63
|
+
e.g. your Desktop.
|
|
64
|
+
3. Run it (`mooring.exe`, or `python mooring.pyz`). Your browser opens the hub.
|
|
65
|
+
4. Click **Log in with GitHub**, enter the code shown at
|
|
66
|
+
[github.com/login/device](https://github.com/login/device).
|
|
67
|
+
5. **Pull** to fetch the team's notebooks, **Open** to edit one in marimo,
|
|
68
|
+
**New notebook** to start fresh, **Push** to share your work.
|
|
69
|
+
|
|
70
|
+
Notebooks live in `Documents\mooring\<repo>\notebooks\`; data files your
|
|
71
|
+
notebooks read go in `...\<repo>\data\` and sync the same way.
|
|
72
|
+
|
|
73
|
+
The first launch takes a minute while the app unpacks itself to a local
|
|
74
|
+
cache; later launches are fast.
|
|
75
|
+
|
|
76
|
+
### CLI (optional)
|
|
77
|
+
|
|
78
|
+
Everything the hub does is also available from a terminal:
|
|
79
|
+
|
|
80
|
+
```
|
|
81
|
+
python mooring.pyz login | logout | whoami
|
|
82
|
+
python mooring.pyz status
|
|
83
|
+
python mooring.pyz pull [--theirs | --keep-both]
|
|
84
|
+
python mooring.pyz push [paths...] [-m "message"]
|
|
85
|
+
python mooring.pyz open notebooks/sales.py
|
|
86
|
+
python mooring.pyz new sales-analysis
|
|
87
|
+
python mooring.pyz selftest
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## For admins: set up a team
|
|
91
|
+
|
|
92
|
+
1. **Create the shared repo**, e.g. `your-org/notebooks`, with empty
|
|
93
|
+
`notebooks/` and `data/` folders. Don't enable git-LFS (the API would
|
|
94
|
+
deliver pointer files).
|
|
95
|
+
2. **Register a GitHub OAuth app** (Settings → Developer settings → OAuth
|
|
96
|
+
apps → New). Any homepage/callback URL works; then **enable Device Flow**
|
|
97
|
+
on the app. Copy the client id — there is no secret to manage.
|
|
98
|
+
- If the repo lives in an org with third-party-app restrictions, an org
|
|
99
|
+
owner must approve the OAuth app.
|
|
100
|
+
3. **Bake the config**: edit `src/mooring/config_default.toml` with the
|
|
101
|
+
client id, owner, repo, and branch.
|
|
102
|
+
4. **Build** (requires [uv](https://docs.astral.sh/uv/)):
|
|
103
|
+
|
|
104
|
+
```
|
|
105
|
+
uv sync
|
|
106
|
+
uv run pytest
|
|
107
|
+
uvx --python 3.13 moonlit build -e mooring.cli:main -o dist/mooring.pyz --python-version 3.12
|
|
108
|
+
uvx --python 3.13 moonlit build -e mooring.cli:main -o dist/mooring.exe --windows-exe --python-version 3.12
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
For machines with **no Python at all**, build a folder bundle with
|
|
112
|
+
embedded CPython instead:
|
|
113
|
+
|
|
114
|
+
```
|
|
115
|
+
uvx --python 3.13 moonlit build -e mooring.cli:main -o dist/mooring-bundle --bundle-python --python-version 3.12
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
5. **Distribute** the artifact (file share, email, GitHub release — the
|
|
119
|
+
`.github/workflows/release.yml` workflow builds and attaches artifacts on
|
|
120
|
+
every `v*` tag).
|
|
121
|
+
|
|
122
|
+
Users without a baked config get a one-time setup form in the hub instead;
|
|
123
|
+
their values are stored in `%APPDATA%\mooring\config.toml`.
|
|
124
|
+
|
|
125
|
+
### Changing the bundled notebook packages
|
|
126
|
+
|
|
127
|
+
Edit `dependencies` in `pyproject.toml`, `uv sync`, rebuild, redistribute.
|
|
128
|
+
Notebooks can only import what's frozen into the artifact.
|
|
129
|
+
|
|
130
|
+
## Development
|
|
131
|
+
|
|
132
|
+
```
|
|
133
|
+
uv sync # install everything
|
|
134
|
+
uv run pytest # unit tests (no network needed)
|
|
135
|
+
uv run ruff check src tests # lint
|
|
136
|
+
uv run mooring hub # run the hub from source
|
|
137
|
+
uv run python tests/manual_editor_check.py # editor-subprocess smoke test
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
Layout: `src/mooring/` — `cli.py` (entry point; also sets PYTHONPATH so the
|
|
141
|
+
marimo subprocess works from inside the packaged artifact), `auth.py` (device
|
|
142
|
+
flow + keyring), `github.py` (REST client), `gitsha.py`/`manifest.py`/`sync.py`
|
|
143
|
+
(the three-way sync engine), `editor.py` (marimo subprocess manager),
|
|
144
|
+
`hub/` (Starlette app + static frontend).
|
|
145
|
+
|
|
146
|
+
To integration-test sync against a real repo, set `MOORING_TOKEN` (a PAT
|
|
147
|
+
works) plus `MOORING_OWNER`/`MOORING_REPO`/`MOORING_CLIENT_ID` and use the
|
|
148
|
+
CLI against a scratch repository.
|
|
149
|
+
|
|
150
|
+
## Constraints & notes
|
|
151
|
+
|
|
152
|
+
- **Python version is pinned.** A `.pyz`/`.exe` built for 3.12 needs the
|
|
153
|
+
user to have Python 3.12.x; moonlit shows a clear error otherwise. The
|
|
154
|
+
`--bundle-python` build escapes this entirely.
|
|
155
|
+
- **File sizes**: pushes warn at 10 MB and refuse at 45 MB per file (GitHub
|
|
156
|
+
Contents API limit). Keep big datasets out of the repo.
|
|
157
|
+
- **Tokens** are stored in the OS credential store (Windows Credential
|
|
158
|
+
Manager); `repo`-scoped OAuth tokens grant access to all repos the user
|
|
159
|
+
can reach — use a dedicated machine account org if that's a concern.
|
|
160
|
+
- **Artifact size** is ~110 MB (marimo + polars + plotly + altair). First
|
|
161
|
+
run extracts to `%LOCALAPPDATA%\moonlit\`; old versions' caches can be
|
|
162
|
+
deleted freely.
|
mooring-0.1.0/README.md
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# ⚓ mooring
|
|
2
|
+
|
|
3
|
+
Git-free [marimo](https://marimo.io) notebook sharing via GitHub.
|
|
4
|
+
|
|
5
|
+
Mooring is a single-file app (`mooring.pyz` / `mooring.exe`) that lets a team
|
|
6
|
+
of data analysts pull, edit, and push marimo notebooks stored in a shared
|
|
7
|
+
GitHub repo — **without git installed on their machines**. All sync happens
|
|
8
|
+
over the GitHub REST API; the only requirement on an analyst's machine is
|
|
9
|
+
Python 3.12.
|
|
10
|
+
|
|
11
|
+
Double-clicking the app opens a local browser **hub**: log in to GitHub with
|
|
12
|
+
a one-time device code, see every team notebook with its sync status, pull
|
|
13
|
+
the latest, open notebooks in the bundled marimo editor, and push your
|
|
14
|
+
changes back — one commit per file, with conflicts detected and resolved
|
|
15
|
+
per file (never silently overwritten).
|
|
16
|
+
|
|
17
|
+
Built with [moonlit](https://github.com/openafterhours/moonlit).
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## How it works
|
|
22
|
+
|
|
23
|
+
- **One shared team repo** (e.g. `your-org/notebooks`) holds `notebooks/`
|
|
24
|
+
and `data/` folders. Everyone pulls from and pushes to it.
|
|
25
|
+
- **No git anywhere.** Pull walks the repo tree via the GitHub Git Data API
|
|
26
|
+
and downloads only changed blobs; push uses the Contents API with the
|
|
27
|
+
file's last-known SHA, so GitHub itself rejects writes that would clobber
|
|
28
|
+
someone else's change.
|
|
29
|
+
- **Three-way change detection.** Mooring computes git blob SHAs locally and
|
|
30
|
+
keeps a manifest of what was last synced, so it always knows whether a file
|
|
31
|
+
is modified locally, changed remotely, or conflicted.
|
|
32
|
+
- **Conflicts are explicit.** Pull never overwrites local edits; push blocks
|
|
33
|
+
conflicted files. The hub offers per-file resolution: *Use remote*,
|
|
34
|
+
*Keep both*, or *Push as copy* (publishes your version under
|
|
35
|
+
`name-<your-github-login>.py`).
|
|
36
|
+
- **Frozen package stack.** Notebooks can import anything bundled into the
|
|
37
|
+
artifact: `polars`, `altair`, `plotly`, `openpyxl`, `fastexcel`,
|
|
38
|
+
`requests` (plus the standard library). There is no pip at runtime.
|
|
39
|
+
|
|
40
|
+
## For analysts: install & use
|
|
41
|
+
|
|
42
|
+
1. Install [Python 3.12](https://www.python.org/downloads/) (tick
|
|
43
|
+
*"Add python.exe to PATH"*).
|
|
44
|
+
2. Get `mooring.exe` (or `mooring.pyz`) from your admin and put it anywhere,
|
|
45
|
+
e.g. your Desktop.
|
|
46
|
+
3. Run it (`mooring.exe`, or `python mooring.pyz`). Your browser opens the hub.
|
|
47
|
+
4. Click **Log in with GitHub**, enter the code shown at
|
|
48
|
+
[github.com/login/device](https://github.com/login/device).
|
|
49
|
+
5. **Pull** to fetch the team's notebooks, **Open** to edit one in marimo,
|
|
50
|
+
**New notebook** to start fresh, **Push** to share your work.
|
|
51
|
+
|
|
52
|
+
Notebooks live in `Documents\mooring\<repo>\notebooks\`; data files your
|
|
53
|
+
notebooks read go in `...\<repo>\data\` and sync the same way.
|
|
54
|
+
|
|
55
|
+
The first launch takes a minute while the app unpacks itself to a local
|
|
56
|
+
cache; later launches are fast.
|
|
57
|
+
|
|
58
|
+
### CLI (optional)
|
|
59
|
+
|
|
60
|
+
Everything the hub does is also available from a terminal:
|
|
61
|
+
|
|
62
|
+
```
|
|
63
|
+
python mooring.pyz login | logout | whoami
|
|
64
|
+
python mooring.pyz status
|
|
65
|
+
python mooring.pyz pull [--theirs | --keep-both]
|
|
66
|
+
python mooring.pyz push [paths...] [-m "message"]
|
|
67
|
+
python mooring.pyz open notebooks/sales.py
|
|
68
|
+
python mooring.pyz new sales-analysis
|
|
69
|
+
python mooring.pyz selftest
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## For admins: set up a team
|
|
73
|
+
|
|
74
|
+
1. **Create the shared repo**, e.g. `your-org/notebooks`, with empty
|
|
75
|
+
`notebooks/` and `data/` folders. Don't enable git-LFS (the API would
|
|
76
|
+
deliver pointer files).
|
|
77
|
+
2. **Register a GitHub OAuth app** (Settings → Developer settings → OAuth
|
|
78
|
+
apps → New). Any homepage/callback URL works; then **enable Device Flow**
|
|
79
|
+
on the app. Copy the client id — there is no secret to manage.
|
|
80
|
+
- If the repo lives in an org with third-party-app restrictions, an org
|
|
81
|
+
owner must approve the OAuth app.
|
|
82
|
+
3. **Bake the config**: edit `src/mooring/config_default.toml` with the
|
|
83
|
+
client id, owner, repo, and branch.
|
|
84
|
+
4. **Build** (requires [uv](https://docs.astral.sh/uv/)):
|
|
85
|
+
|
|
86
|
+
```
|
|
87
|
+
uv sync
|
|
88
|
+
uv run pytest
|
|
89
|
+
uvx --python 3.13 moonlit build -e mooring.cli:main -o dist/mooring.pyz --python-version 3.12
|
|
90
|
+
uvx --python 3.13 moonlit build -e mooring.cli:main -o dist/mooring.exe --windows-exe --python-version 3.12
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
For machines with **no Python at all**, build a folder bundle with
|
|
94
|
+
embedded CPython instead:
|
|
95
|
+
|
|
96
|
+
```
|
|
97
|
+
uvx --python 3.13 moonlit build -e mooring.cli:main -o dist/mooring-bundle --bundle-python --python-version 3.12
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
5. **Distribute** the artifact (file share, email, GitHub release — the
|
|
101
|
+
`.github/workflows/release.yml` workflow builds and attaches artifacts on
|
|
102
|
+
every `v*` tag).
|
|
103
|
+
|
|
104
|
+
Users without a baked config get a one-time setup form in the hub instead;
|
|
105
|
+
their values are stored in `%APPDATA%\mooring\config.toml`.
|
|
106
|
+
|
|
107
|
+
### Changing the bundled notebook packages
|
|
108
|
+
|
|
109
|
+
Edit `dependencies` in `pyproject.toml`, `uv sync`, rebuild, redistribute.
|
|
110
|
+
Notebooks can only import what's frozen into the artifact.
|
|
111
|
+
|
|
112
|
+
## Development
|
|
113
|
+
|
|
114
|
+
```
|
|
115
|
+
uv sync # install everything
|
|
116
|
+
uv run pytest # unit tests (no network needed)
|
|
117
|
+
uv run ruff check src tests # lint
|
|
118
|
+
uv run mooring hub # run the hub from source
|
|
119
|
+
uv run python tests/manual_editor_check.py # editor-subprocess smoke test
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Layout: `src/mooring/` — `cli.py` (entry point; also sets PYTHONPATH so the
|
|
123
|
+
marimo subprocess works from inside the packaged artifact), `auth.py` (device
|
|
124
|
+
flow + keyring), `github.py` (REST client), `gitsha.py`/`manifest.py`/`sync.py`
|
|
125
|
+
(the three-way sync engine), `editor.py` (marimo subprocess manager),
|
|
126
|
+
`hub/` (Starlette app + static frontend).
|
|
127
|
+
|
|
128
|
+
To integration-test sync against a real repo, set `MOORING_TOKEN` (a PAT
|
|
129
|
+
works) plus `MOORING_OWNER`/`MOORING_REPO`/`MOORING_CLIENT_ID` and use the
|
|
130
|
+
CLI against a scratch repository.
|
|
131
|
+
|
|
132
|
+
## Constraints & notes
|
|
133
|
+
|
|
134
|
+
- **Python version is pinned.** A `.pyz`/`.exe` built for 3.12 needs the
|
|
135
|
+
user to have Python 3.12.x; moonlit shows a clear error otherwise. The
|
|
136
|
+
`--bundle-python` build escapes this entirely.
|
|
137
|
+
- **File sizes**: pushes warn at 10 MB and refuse at 45 MB per file (GitHub
|
|
138
|
+
Contents API limit). Keep big datasets out of the repo.
|
|
139
|
+
- **Tokens** are stored in the OS credential store (Windows Credential
|
|
140
|
+
Manager); `repo`-scoped OAuth tokens grant access to all repos the user
|
|
141
|
+
can reach — use a dedicated machine account org if that's a concern.
|
|
142
|
+
- **Artifact size** is ~110 MB (marimo + polars + plotly + altair). First
|
|
143
|
+
run extracts to `%LOCALAPPDATA%\moonlit\`; old versions' caches can be
|
|
144
|
+
deleted freely.
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "mooring"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Git-free marimo notebook sharing via GitHub"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.12,<3.13"
|
|
7
|
+
dependencies = [
|
|
8
|
+
"marimo>=0.13",
|
|
9
|
+
"starlette",
|
|
10
|
+
"uvicorn",
|
|
11
|
+
"requests",
|
|
12
|
+
"keyring",
|
|
13
|
+
"platformdirs",
|
|
14
|
+
# notebook stack frozen into the distributed artifact
|
|
15
|
+
"polars",
|
|
16
|
+
"altair",
|
|
17
|
+
"plotly",
|
|
18
|
+
"openpyxl",
|
|
19
|
+
"fastexcel",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
[project.scripts]
|
|
23
|
+
mooring = "mooring.cli:main"
|
|
24
|
+
|
|
25
|
+
# moonlit (the .pyz builder) is NOT a dev dependency: it needs Python >=3.13
|
|
26
|
+
# while this project targets the team's 3.12. Invoke it isolated instead:
|
|
27
|
+
# uvx --python 3.13 moonlit build ...
|
|
28
|
+
[dependency-groups]
|
|
29
|
+
dev = [
|
|
30
|
+
"pytest",
|
|
31
|
+
"responses",
|
|
32
|
+
"ruff",
|
|
33
|
+
# starlette.testclient backend. starlette now prefers `httpx2` (the
|
|
34
|
+
# Pydantic-stewarded httpx successor); plain httpx still works behind a
|
|
35
|
+
# deprecation warning and is kept here as the conservative choice.
|
|
36
|
+
"httpx",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
[build-system]
|
|
40
|
+
requires = ["hatchling"]
|
|
41
|
+
build-backend = "hatchling.build"
|
|
42
|
+
|
|
43
|
+
[tool.hatch.build.targets.wheel]
|
|
44
|
+
packages = ["src/mooring"]
|
|
45
|
+
|
|
46
|
+
[tool.pytest.ini_options]
|
|
47
|
+
testpaths = ["tests"]
|
|
48
|
+
|
|
49
|
+
[tool.ruff]
|
|
50
|
+
line-length = 100
|
|
51
|
+
src = ["src", "tests"]
|
|
@@ -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)
|