Skip to content

Commit 741476c

Browse files
authored
Merge pull request #86 from OpenFUSIONToolkit/feature/pypi-packaging
Packaging for non-developer users (pip/uvx installable, GUI bundled)
2 parents c17c18f + e151671 commit 741476c

23 files changed

Lines changed: 1114 additions & 88 deletions

File tree

.github/workflows/ci.yml

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -36,31 +36,39 @@ jobs:
3636
- '.github/workflows/ci.yml'
3737
3838
python:
39-
name: Python
39+
name: Python (${{ matrix.python }})
4040
needs: changes
4141
if: ${{ needs.changes.outputs.python == 'true' }}
4242
runs-on: ubuntu-latest
43+
strategy:
44+
fail-fast: false
45+
matrix:
46+
# 3.12 is the supported floor (pyproject requires-python); 3.14 is the dev pin.
47+
python: ["3.12", "3.14"]
4348
steps:
4449
- uses: actions/checkout@v4
4550

46-
- name: Set up uv (Python 3.14 from .python-version)
51+
- name: Set up uv
4752
uses: astral-sh/setup-uv@v5
4853
with:
4954
enable-cache: true
5055

5156
- name: Install project + deps
52-
run: uv sync --extra service
57+
run: uv sync --python ${{ matrix.python }}
5358

59+
# ruff lint/format are Python-version-agnostic; run once, on the floor.
5460
- name: Lint (ruff)
61+
if: ${{ matrix.python == '3.12' }}
5562
run: uvx ruff check .
5663

5764
- name: Format check (ruff format)
65+
if: ${{ matrix.python == '3.12' }}
5866
run: uvx ruff format --check .
5967

6068
- name: Tests (pytest; tolerate "no tests collected" while skeletal)
6169
run: |
6270
set +e
63-
uv run --with pytest pytest -q
71+
uv run --python ${{ matrix.python }} --with pytest pytest -q
6472
code=$?
6573
set -e
6674
# exit 5 = no tests collected yet (skeleton); treat as success
@@ -80,7 +88,7 @@ jobs:
8088
enable-cache: true
8189

8290
- name: Install project + deps
83-
run: uv sync --extra service
91+
run: uv sync
8492

8593
- name: Typecheck (ty)
8694
run: uv run ty check src/magnetics

.github/workflows/release.yml

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
name: Release
2+
3+
# Build the wheel + sdist (GUI bundled), smoke-test the install, and publish to
4+
# PyPI on a version tag. `workflow_dispatch` runs the build + smoke test only
5+
# (no publish) so the pipeline can be exercised before a real release.
6+
#
7+
# Publishing uses PyPI Trusted Publishing (OIDC) — no API token. See
8+
# docs/RELEASING.md for the one-time publisher setup.
9+
on:
10+
push:
11+
tags: ["v*"]
12+
workflow_dispatch: {}
13+
14+
jobs:
15+
build:
16+
name: Build + smoke test
17+
runs-on: ubuntu-latest
18+
steps:
19+
- uses: actions/checkout@v4
20+
21+
- uses: actions/setup-node@v4
22+
with:
23+
node-version: "22"
24+
cache: npm
25+
cache-dependency-path: gui/web/package-lock.json
26+
27+
- uses: astral-sh/setup-uv@v5
28+
with:
29+
enable-cache: true
30+
31+
- name: Check tag matches project version
32+
if: startsWith(github.ref, 'refs/tags/v')
33+
run: |
34+
ver="$(uv version --short)"
35+
if [ "v$ver" != "$GITHUB_REF_NAME" ]; then
36+
echo "::error::tag $GITHUB_REF_NAME does not match project version v$ver"
37+
exit 1
38+
fi
39+
40+
# Build GUI → stage → sdist+wheel → verify bundle → smoke-test the wheel.
41+
# Same script developers run locally, so the two can't drift.
42+
- name: Build + smoke test the distribution
43+
run: scripts/build-dist.sh --smoke
44+
45+
- uses: actions/upload-artifact@v4
46+
with:
47+
name: dist
48+
path: dist/
49+
50+
publish:
51+
name: Publish to PyPI
52+
if: startsWith(github.ref, 'refs/tags/v')
53+
needs: build
54+
runs-on: ubuntu-latest
55+
environment: pypi
56+
permissions:
57+
id-token: write # trusted publishing (OIDC)
58+
contents: write # create the GitHub Release
59+
steps:
60+
- uses: actions/download-artifact@v4
61+
with:
62+
name: dist
63+
path: dist/
64+
65+
- name: Publish to PyPI
66+
uses: pypa/gh-action-pypi-publish@release/v1
67+
68+
- name: Create GitHub Release
69+
env:
70+
GH_TOKEN: ${{ github.token }}
71+
run: gh release create "$GITHUB_REF_NAME" dist/* --repo "$GITHUB_REPOSITORY" --generate-notes

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,7 @@ frontend (its `dist/` is staged into `service/webapp/` for the wheel).
139139
- **Verify GUI behavior changes with Playwright.** When a change affects the frontend's *behavior*
140140
(control state/defaults, data flow, what renders, error handling) — not just styling — drive the
141141
running app with Playwright and assert the actual DOM/behavior, rather than relying on `tsc` or
142-
unit tests alone. Start the app (`./run.sh`), then script the interaction (e.g. select a device,
142+
unit tests alone. Start the app (`./run-dev.sh`), then script the interaction (e.g. select a device,
143143
read back the control values / node responses). This is how the NSTX device-aware `PullControl`
144144
and the live pull were validated.
145145
- **Run the CI gates locally before committing frontend/typing changes:** `uv run ty check src/magnetics`

README.md

Lines changed: 78 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,94 @@
1-
# magnetics (analysis)
1+
# magnetics
22

3-
Device-agnostic Python library for 3D magnetic-sensor analysis of tokamak MHD
4-
instabilities. See `../docs/VISION.md` for context.
3+
Device-agnostic Python library **and** GUI for 3D magnetic-sensor analysis of
4+
tokamak MHD instabilities — quasi-stationary (locked) modes and rapidly-rotating
5+
modes. See [`docs/VISION.md`](docs/VISION.md) for context.
56

6-
## Setup
7+
## Quick start (no dev setup)
8+
9+
The GUI ships inside the package — no Node or build toolchain needed.
10+
11+
```sh
12+
uvx magnetics # zero-install: uv provisions Python + runs it
13+
# or
14+
pip install magnetics # into an existing environment
15+
magnetics # start the app, open it in your browser
16+
```
17+
18+
`magnetics` starts the service and opens the GUI in your browser. Useful flags:
19+
20+
```sh
21+
magnetics --port 8000 # fixed port (default: first free port from 8000)
22+
magnetics --no-browser # headless (e.g. on a server)
23+
magnetics --data-dir PATH # where shot data lives (see below)
24+
```
25+
26+
Two more console scripts come with the install:
27+
28+
```sh
29+
magnetics-fetch --shot 184927 # pull a shot to the local data dir
30+
magnetics-service # start just the API/service (honors HOST/PORT)
31+
```
32+
33+
### Where shot data lives
34+
35+
Fetched shots are cached persistently so you don't re-pull them. The location is,
36+
in order:
37+
38+
1. `$MAGNETICS_DATA_DIR` (or `--data-dir`) if set — **the** knob to relocate it;
39+
2. the repo's `data/` dir when run from a source checkout;
40+
3. otherwise a per-user data dir (`~/.local/share/magnetics` on Linux,
41+
`~/Library/Application Support/magnetics` on macOS).
42+
43+
**On a cluster,** set `MAGNETICS_DATA_DIR` to scratch/project space (home dirs are
44+
usually quota'd and shot files are large) — e.g. in your shell profile:
45+
46+
```sh
47+
export MAGNETICS_DATA_DIR=$SCRATCH/magnetics
48+
```
49+
50+
## Development
51+
52+
The Python project is the repo root (a uv project); the React GUI is in `gui/web/`.
753

854
```sh
955
uv sync --group dev
1056
uv run nbstripout --install # strip notebook outputs on commit (run once per clone)
57+
./run-dev.sh # live: FastAPI service + GUI dev server (hot reload)
58+
./run-dev.sh static # GUI only, static mock fixtures (offline frontend work)
59+
./run-dev.sh --prod # build the GUI and serve it on one port (preview the wheel)
60+
61+
scripts/build-dist.sh # build the distributable wheel + sdist (GUI bundled) into dist/
62+
scripts/build-dist.sh --smoke # …and smoke-test the wheel in a clean venv
1163
```
1264

13-
## Layout
65+
### Running a built wheel in isolation
66+
67+
To try a freshly built wheel the way an end user would — in a throwaway
68+
environment that touches neither your `.venv` nor the repo's `data/` — run it with
69+
`uvx --from` (the installed package lives in uv's cache, so it uses the per-user
70+
data dir, not this checkout):
71+
72+
```sh
73+
uvx --from ./dist/magnetics-*.whl magnetics
74+
uvx --from ./dist/magnetics-*.whl magnetics --data-dir "$(mktemp -d)" # touch nothing persistent
75+
uvx --reinstall --from ./dist/magnetics-*.whl magnetics # after a rebuild at the same version
76+
```
77+
78+
### Layout
1479

1580
- `src/magnetics/core/` — pure, device-agnostic analysis (e.g. `spectral.py`).
16-
- `tests/` — pytest suite; fixtures in `tests/fixtures/`.
81+
- `src/magnetics/service/` — FastAPI app; the built GUI is bundled at `service/webapp/`.
82+
- `gui/web/` — React + Vite + TypeScript frontend.
83+
- `tests/` — pytest suite (synthetic fixtures; no real tokamak data committed).
1784
- `examples/` — runnable Jupyter notebooks.
1885

19-
## Common commands
86+
### Common commands
2087

2188
```sh
22-
uv run pytest # run tests
89+
uv run pytest # run the Python tests
2390
uv run jupyter lab examples/ # open the example notebooks
91+
cd gui/web && npm test # run the frontend tests
2492
```
93+
94+
Releasing to PyPI is documented in [`docs/RELEASING.md`](docs/RELEASING.md).

docs/RELEASING.md

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
# Releasing `magnetics` to PyPI
2+
3+
The [`Release` workflow](../.github/workflows/release.yml) builds the wheel + sdist
4+
(with the GUI bundled), smoke-tests the install in a clean Python 3.12 venv, and
5+
publishes to PyPI on a version tag. Publishing uses **PyPI Trusted Publishing**
6+
(OIDC) — there is no API token to manage.
7+
8+
## One-time setup (before the first release)
9+
10+
1. **Register the pending publisher on PyPI.** Log in to <https://pypi.org> as an
11+
owner of the project (or as whoever will own it), go to
12+
**Your projects → (project) → Publishing**, or for a brand-new name use
13+
**Account → Publishing → Add a pending publisher**, and enter:
14+
- **PyPI project name:** `magnetics`
15+
- **Owner:** `OpenFUSIONToolkit`
16+
- **Repository name:** `Magnetics`
17+
- **Workflow name:** `release.yml`
18+
- **Environment name:** `pypi`
19+
20+
The project name `magnetics` is currently unclaimed; the pending publisher
21+
reserves it and lets the first tag push create the project.
22+
23+
2. **Create the `pypi` GitHub environment.** In the repo, **Settings →
24+
Environments → New environment → `pypi`**. Optionally add required reviewers so
25+
a human must approve each publish. The environment name must match the workflow
26+
(`environment: pypi`) and the pending publisher above.
27+
28+
> The pending publisher **must exist before the first tag push**, or the publish
29+
> step fails auth. That failure is harmless — finish the setup and re-run the job.
30+
31+
## Cutting a release
32+
33+
Follows the repo's GitFlow-lite (see `CLAUDE.md`). Versioning is SemVer; the tag
34+
must match `version` in `pyproject.toml` (the workflow enforces this).
35+
36+
1. On a branch off `develop`, bump `version` in `pyproject.toml` (and keep
37+
`src/magnetics/__init__.py:__version__` in step). Open a PR into `develop`.
38+
2. Open a release PR `develop``main` and merge it.
39+
3. Tag the merge commit on `main` and push the tag:
40+
```bash
41+
git checkout main && git pull
42+
git tag vX.Y.Z
43+
git push origin vX.Y.Z
44+
```
45+
4. The `Release` workflow builds, smoke-tests, publishes to PyPI, and creates a
46+
GitHub Release with the artifacts attached.
47+
48+
## Dry run
49+
50+
Trigger the workflow manually (**Actions → Release → Run workflow**, or
51+
`gh workflow run release.yml`). `workflow_dispatch` runs the **build + smoke test
52+
only** and skips publishing, so you can validate the pipeline (GUI bundling, wheel
53+
install, launcher boot) without cutting a release.
54+
55+
## What "installed" looks like for users
56+
57+
```bash
58+
uvx magnetics # zero-install, ephemeral (uv provisions Python)
59+
# or
60+
pip install magnetics && magnetics # into an existing environment
61+
```
62+
63+
Both start the service and open the bundled GUI in a browser. Shot data is written
64+
to a per-user data directory (or `$MAGNETICS_DATA_DIR`); see the README.

gui/web/src/components/tabs/QuasiStationaryTab.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
55
import type Plotly from "plotly.js-dist-min";
66
import { useStore } from "../../store";
7-
import { apiBase, startFetch } from "../../lib/api";
7+
import { apiBase, startFetch, usingLiveBackend } from "../../lib/api";
88
import { useNode } from "../../lib/useNode";
99
import NodeView from "../../lib/NodeView";
1010
import Plot from "../../lib/Plot";
@@ -386,7 +386,7 @@ export default function QuasiStationaryTab({ machine }: { machine: string }) {
386386
const plotCustomSignals = useCallback(() => {
387387
const names = customText.split(/[\s,]+/).map(s => s.trim()).filter(Boolean);
388388
if (!names.length || names.some(n => !POINTNAME_RE.test(n))) return;
389-
if (!apiBase()) { setCustomMsg("✗ no live backend configured — set VITE_API_BASE to fetch data"); return; }
389+
if (!usingLiveBackend()) { setCustomMsg("✗ no live backend configured — run the packaged app or set VITE_API_BASE"); return; }
390390
if (credsMissing) { setCustomMsg(`✗ ${CREDS_HINT}`); return; }
391391
setCustomBusy(true); setCustomFrac(0); setCustomMsg("fetching…");
392392
void (async () => {

0 commit comments

Comments
 (0)