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.
@@ -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
@@ -0,0 +1,8 @@
1
+ __pycache__/
2
+ *.pyc
3
+ .venv/
4
+ dist/
5
+ .pytest_cache/
6
+ .ruff_cache/
7
+ *.egg-info/
8
+ .idea
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.
@@ -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,3 @@
1
+ """Mooring: git-free marimo notebook sharing via GitHub."""
2
+
3
+ __version__ = "0.1.0"
@@ -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)