magpy-lite 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,99 @@
1
+ """Move CHANGELOG.md's [Unreleased] entries under a new version heading.
2
+
3
+ Usage: roll_changelog.py CHANGELOG TAG PREVIOUS_TAG DATE NOTES_OUT [TAG_ALREADY_AT_HEAD]
4
+ roll_changelog.py --notes CHANGELOG TAG NOTES_OUT
5
+
6
+ Called by release.yml. Writes the release notes to NOTES_OUT and edits the
7
+ changelog in place; touches neither when there is nothing to release:
8
+
9
+ - [Unreleased] has entries: they become ``## [X.Y.Z] - DATE`` and an empty
10
+ [Unreleased] is left above them.
11
+ - [Unreleased] is empty but the commit already shipped as another tag (a manual
12
+ minor/major bump after the automatic patch): a short ``## [X.Y.Z]`` entry
13
+ points at that release, whose entries become the notes.
14
+
15
+ Keep a Changelog link references at the bottom are updated when present.
16
+
17
+ With --notes, CHANGELOG has already been rolled for TAG (a re-run resuming a
18
+ release whose changelog commit reached main): only the notes are written, the
19
+ same ones the roll produced. Exits 1 if CHANGELOG has no section for TAG.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import re
25
+ import sys
26
+ from pathlib import Path
27
+
28
+ HEADING = re.compile(r"^## \[(?P<name>[^\]]+)\]", re.MULTILINE)
29
+ LINKS = re.compile(r"^\[[^\]]+\]: ", re.MULTILINE)
30
+
31
+
32
+ def section(text: str, name: str) -> tuple[int, int, int] | None:
33
+ """(heading start, body start, body end) of the ``## [name]`` section."""
34
+ for m in HEADING.finditer(text):
35
+ if m.group("name") == name:
36
+ body = text.index("\n", m.start()) + 1
37
+ nxt = HEADING.search(text, body)
38
+ links = LINKS.search(text, body)
39
+ ends = [x.start() for x in (nxt, links) if x] + [len(text)]
40
+ return m.start(), body, min(ends)
41
+ return None
42
+
43
+
44
+ def update_links(text: str, version: str, base: str) -> str:
45
+ """Point [Unreleased] at the new tag and add a link for the new version."""
46
+ m = re.search(r"^\[Unreleased\]: (?P<repo>\S+)/compare/\S+\.\.\.HEAD$", text, re.M)
47
+ if not m:
48
+ return text
49
+ repo, tag = m.group("repo"), f"v{version}"
50
+ link = f"{repo}/compare/{base}...{tag}" if base else f"{repo}/releases/tag/{tag}"
51
+ new = f"[Unreleased]: {repo}/compare/{tag}...HEAD\n[{version}]: {link}"
52
+ return text[: m.start()] + new + text[m.end() :]
53
+
54
+
55
+ SAME_CODE = re.compile(r"^Same code as (?P<tag>v\S+), released as \S+\.")
56
+
57
+
58
+ def rolled_notes(path: str, tag: str, notes_out: str) -> None:
59
+ text = Path(path).read_text()
60
+ rolled = section(text, tag.removeprefix("v"))
61
+ if rolled is None:
62
+ sys.exit(f"{path} has no section for {tag}")
63
+ notes = text[rolled[1] : rolled[2]].strip()
64
+ if (m := SAME_CODE.match(notes)) and (shipped := section(text, m["tag"].removeprefix("v"))):
65
+ notes = f"{notes}\n\n{text[shipped[1] : shipped[2]].strip()}"
66
+ Path(notes_out).write_text(notes + "\n")
67
+
68
+
69
+ def main(path: str, tag: str, previous: str, date: str, notes_out: str, at_head: str = "") -> None:
70
+ file = Path(path)
71
+ text = file.read_text()
72
+ version = tag.removeprefix("v")
73
+ unreleased = section(text, "Unreleased")
74
+ if unreleased is None:
75
+ return
76
+ _, body_start, body_end = unreleased
77
+ body = text[body_start:body_end].strip()
78
+ base = previous
79
+ if body:
80
+ notes = body
81
+ rolled = f"\n## [{version}] - {date}\n\n{body}\n\n"
82
+ elif at_head and (shipped := section(text, at_head.removeprefix("v"))):
83
+ shipped_body = text[shipped[1] : shipped[2]].strip()
84
+ base = at_head
85
+ notes = f"Same code as {at_head}, released as {tag}.\n\n{shipped_body}".strip()
86
+ rolled = f"\n## [{version}] - {date}\n\nSame code as {at_head}, released as {tag}.\n\n"
87
+ else:
88
+ return
89
+
90
+ text = text[:body_start] + rolled + text[body_end:].lstrip("\n")
91
+ file.write_text(update_links(text, version, base))
92
+ Path(notes_out).write_text(notes + "\n")
93
+
94
+
95
+ if __name__ == "__main__":
96
+ if sys.argv[1:2] == ["--notes"]:
97
+ rolled_notes(*sys.argv[2:])
98
+ else:
99
+ main(*sys.argv[1:])
@@ -0,0 +1,48 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ branches: [main]
8
+ workflow_dispatch:
9
+
10
+ permissions:
11
+ contents: read
12
+
13
+ concurrency:
14
+ # PR runs supersede each other. Every other run gets its own group, so each
15
+ # push to main finishes CI and can be released.
16
+ group: ci-${{ github.event_name == 'pull_request' && github.ref || github.run_id }}
17
+ cancel-in-progress: ${{ github.event_name == 'pull_request' }}
18
+
19
+ jobs:
20
+ test:
21
+ name: test (py${{ matrix.python-version }})
22
+ runs-on: ubuntu-latest
23
+ strategy:
24
+ fail-fast: false
25
+ matrix:
26
+ python-version: ["3.10", "3.11", "3.12", "3.13"]
27
+ steps:
28
+ - uses: actions/checkout@v4
29
+ with:
30
+ fetch-depth: 0 # full history + tags for hatch-vcs versioning
31
+
32
+ - name: Set up uv
33
+ uses: astral-sh/setup-uv@v5
34
+ with:
35
+ python-version: ${{ matrix.python-version }}
36
+ enable-cache: true
37
+
38
+ - name: Install
39
+ run: uv sync --extra dev
40
+
41
+ - name: Lint
42
+ run: uv run ruff check src tests
43
+
44
+ - name: Format check
45
+ run: uv run ruff format --check src tests
46
+
47
+ - name: Test
48
+ run: uv run pytest
@@ -0,0 +1,80 @@
1
+ name: Publish
2
+
3
+ # Publishes to PyPI when a GitHub Release is published, or when release.yml
4
+ # starts it on a new tag. It only runs on a vX.Y.Z tag.
5
+ #
6
+ # Uses PyPI Trusted Publishing (OIDC) — no API token/secret required. Before the
7
+ # first release, configure a Trusted Publisher on PyPI for this project:
8
+ # PyPI → project "magpy-lite" → Settings → Publishing → add a GitHub publisher with
9
+ # owner=jmcmeen, repo=magpy-lite, workflow=publish.yml, environment=pypi.
10
+ # Also create a GitHub environment named "pypi" (Settings → Environments).
11
+
12
+ on:
13
+ release:
14
+ types: [published]
15
+ workflow_dispatch:
16
+
17
+ permissions:
18
+ contents: read
19
+
20
+ jobs:
21
+ build:
22
+ name: Build distributions
23
+ runs-on: ubuntu-latest
24
+ steps:
25
+ - uses: actions/checkout@v4
26
+ with:
27
+ fetch-depth: 0 # full history + tags for hatch-vcs versioning
28
+
29
+ - name: Set up uv
30
+ uses: astral-sh/setup-uv@v5
31
+ with:
32
+ python-version: "3.12"
33
+
34
+ - name: Require a release tag on main
35
+ run: |
36
+ # Run by hand on a branch, this would build and upload a dev version;
37
+ # on a tag off main, code that never went through main and its CI.
38
+ if [ "$GITHUB_REF_TYPE" != tag ] || ! [[ "$GITHUB_REF_NAME" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
39
+ echo "::error::Publish only runs on a vX.Y.Z tag, not $GITHUB_REF_TYPE '$GITHUB_REF_NAME'."
40
+ exit 1
41
+ fi
42
+ git fetch -q origin +refs/heads/main:refs/remotes/origin/main
43
+ if ! git merge-base --is-ancestor "$GITHUB_SHA" origin/main; then
44
+ echo "::error::$GITHUB_REF_NAME isn't on main; only release tags from main are published."
45
+ exit 1
46
+ fi
47
+
48
+ - name: Build sdist + wheel
49
+ run: |
50
+ # Version from the tag this run is on, not `git describe`: when two
51
+ # tags share a commit, describe can pick the older one.
52
+ export SETUPTOOLS_SCM_PRETEND_VERSION="${GITHUB_REF_NAME#v}"
53
+ uv build
54
+
55
+ - name: Upload artifacts
56
+ uses: actions/upload-artifact@v4
57
+ with:
58
+ name: dist
59
+ path: dist/
60
+
61
+ publish:
62
+ name: Publish to PyPI
63
+ needs: build
64
+ runs-on: ubuntu-latest
65
+ environment:
66
+ name: pypi
67
+ url: https://pypi.org/p/magpy-lite
68
+ permissions:
69
+ id-token: write # required for trusted publishing
70
+ steps:
71
+ - name: Download built distributions
72
+ uses: actions/download-artifact@v4
73
+ with:
74
+ name: dist
75
+ path: dist/
76
+
77
+ - name: Publish
78
+ uses: pypa/gh-action-pypi-publish@release/v1
79
+ with:
80
+ skip-existing: true # a retried release re-dispatches this
@@ -0,0 +1,234 @@
1
+ name: Release
2
+
3
+ # Cuts a release when a merge to main passes CI: the latest vX.Y.Z tag gets its
4
+ # patch number bumped (v0.1.0 if there are no tags yet). Merges that land while
5
+ # a release is running can share the next release (see below), so a release is
6
+ # per merge in the usual case, not guaranteed for each one.
7
+ # Python packages take their version from that tag via hatch-vcs; a repo whose
8
+ # version lives in files (e.g. Cargo.toml) provides .github/scripts/
9
+ # set_version.sh, which is run with the new version (X.Y.Z). CHANGELOG.md's
10
+ # [Unreleased] entries move under the new version in a github-actions[bot]
11
+ # commit on main, along with any set_version.sh edits; that commit is what gets
12
+ # tagged, and the entries become the release notes. If main has moved on by
13
+ # then, nothing is pushed and this merge isn't released on its own: the release
14
+ # for the newer merge includes it (a manual minor/major run says to run again).
15
+ #
16
+ # For a minor or major release, run this workflow by hand on main ("Run
17
+ # workflow", or `gh workflow run release.yml -f bump=minor`). It releases the
18
+ # head of main with that part bumped; the next merge continues from it. If the
19
+ # merge was already released as a patch, the commit carries both tags. It
20
+ # refuses to run until CI has passed on that commit. If a release is already
21
+ # running and a merge's release queues behind your run, GitHub cancels your
22
+ # pending run in its favour -- Actions shows it as cancelled; run it again.
23
+ #
24
+ # Releasing runs are serialized, and GitHub keeps only the newest pending one,
25
+ # so merges that land close together can share a release. The lock is on the
26
+ # job, so skipped runs (failed CI, dispatch off main) never take that slot.
27
+ # A commit that is already inside the latest release is skipped, so an old run
28
+ # finishing late never gets a newer version than the code after it. Re-running
29
+ # a run whose release exists only re-dispatches publish.yml (which skips files
30
+ # already on PyPI).
31
+
32
+ on:
33
+ workflow_run:
34
+ workflows: [CI]
35
+ types: [completed]
36
+ branches: [main]
37
+ workflow_dispatch:
38
+ inputs:
39
+ bump:
40
+ description: Version part to bump
41
+ type: choice
42
+ options: [minor, major, patch]
43
+ default: minor
44
+
45
+ permissions:
46
+ contents: write # changelog commit, tag + release
47
+ actions: write # dispatch publish.yml
48
+
49
+ jobs:
50
+ release:
51
+ name: Tag and release
52
+ if: >-
53
+ (github.event_name == 'workflow_run'
54
+ && github.event.workflow_run.conclusion == 'success'
55
+ && github.event.workflow_run.event == 'push')
56
+ || (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main')
57
+ runs-on: ubuntu-latest
58
+ concurrency:
59
+ group: release
60
+ cancel-in-progress: false
61
+ env:
62
+ SHA: ${{ github.event.workflow_run.head_sha || github.sha }}
63
+ BUMP: ${{ inputs.bump || 'patch' }}
64
+ GH_TOKEN: ${{ github.token }}
65
+ BOT_EMAIL: 41898282+github-actions[bot]@users.noreply.github.com
66
+ steps:
67
+ - uses: actions/checkout@v4
68
+ with:
69
+ ref: ${{ github.event.workflow_run.head_sha || github.sha }}
70
+ fetch-depth: 0 # all tags
71
+
72
+ - name: Require passing CI
73
+ if: github.event_name == 'workflow_dispatch'
74
+ run: |
75
+ # A release commit never runs CI (bot pushes don't trigger it), so
76
+ # check the commit it was made on.
77
+ tested=$SHA
78
+ if git rev-parse -q --verify HEAD^ > /dev/null &&
79
+ [ "$(git log -1 --format=%ae HEAD)" = "$BOT_EMAIL" ] &&
80
+ git log -1 --format=%s HEAD | grep -q '^Update changelog for '; then
81
+ tested=$(git rev-parse HEAD^)
82
+ fi
83
+ passed=$(gh run list --workflow ci.yml --commit "$tested" --event push \
84
+ --status success --json databaseId --jq length)
85
+ if [ "$passed" -eq 0 ]; then
86
+ echo "::error::CI hasn't passed on $tested yet; wait for it, then run this again."
87
+ exit 1
88
+ fi
89
+
90
+ - name: Compute version
91
+ id: version
92
+ run: |
93
+ semver='^v[0-9]+\.[0-9]+\.[0-9]+$'
94
+ bump() {
95
+ IFS=. read -r major minor patch <<< "${1#v}"
96
+ case "$BUMP" in
97
+ major) echo "v$((major + 1)).0.0" ;;
98
+ minor) echo "v$major.$((minor + 1)).0" ;;
99
+ *) echo "v$major.$minor.$((patch + 1))" ;;
100
+ esac
101
+ }
102
+ first() { if [ "$BUMP" = major ]; then echo v1.0.0; else echo v0.1.0; fi; }
103
+ # This commit's releases, oldest first: tags on it, or on a release
104
+ # commit the bot made on top of it.
105
+ ours=$(git tag --points-at HEAD | grep -E "$semver" || true)
106
+ for t in $(git tag --contains HEAD | grep -E "$semver" || true); do
107
+ if [ "$(git rev-parse "$t^{commit}^")" = "$(git rev-parse HEAD)" ] &&
108
+ [ "$(git log -1 --format=%ae "$t")" = "$BOT_EMAIL" ] &&
109
+ git log -1 --format=%s "$t" | grep -q '^Update changelog for '; then
110
+ ours="$ours $t"
111
+ fi
112
+ done
113
+ ours=$(printf '%s\n' $ours | grep . | sort -uV || true)
114
+ newest=$(printf '%s\n' $ours | tail -1)
115
+ if [ "$BUMP" = patch ]; then
116
+ # A patch run on a released commit retries its newest release.
117
+ retry=$newest
118
+ else
119
+ # A minor or major run retries only its own bump: a tag of this
120
+ # commit that is exactly that bump of the release before it, or the
121
+ # first release when there is none before it. (Asking for the same
122
+ # bump again on the same commit counts as a retry: releasing
123
+ # identical code under two new minor versions is never wanted.)
124
+ retry=
125
+ merged=$(git tag --merged HEAD | grep -E "$semver" || true)
126
+ for t in $ours; do
127
+ base=$(printf '%s\n' $merged "$t" | sort -uV | grep -B1 -xF "$t" | grep -vxF "$t" || true)
128
+ if { [ -n "$base" ] && [ "$(bump "$base")" = "$t" ]; } ||
129
+ { [ -z "$base" ] && [ "$t" = "$(first)" ]; }; then
130
+ retry=$t
131
+ fi
132
+ done
133
+ fi
134
+ if [ -n "$retry" ]; then
135
+ echo "$SHA is already $retry; retrying its release and publish."
136
+ echo "tag=$retry" >> "$GITHUB_OUTPUT"
137
+ exit 0
138
+ fi
139
+ latest=$(git tag --list 'v*' | grep -E "$semver" | sort -V | tail -1 || true)
140
+ if [ -z "$latest" ]; then
141
+ next=$(first)
142
+ elif git merge-base --is-ancestor HEAD "$latest" &&
143
+ ! printf '%s\n' $ours | grep -qxF "$latest"; then
144
+ echo "$SHA is already included in $latest; nothing to do."
145
+ exit 0
146
+ else
147
+ next=$(bump "$latest")
148
+ fi
149
+ echo "Releasing $next ($BUMP bump; previous: ${latest:-none})"
150
+ {
151
+ echo "tag=$next"
152
+ echo "new=true"
153
+ echo "previous=$latest"
154
+ echo "at_head=$newest"
155
+ } >> "$GITHUB_OUTPUT"
156
+
157
+ - name: Roll changelog and set version
158
+ id: changelog
159
+ if: steps.version.outputs.new == 'true'
160
+ env:
161
+ TAG: ${{ steps.version.outputs.tag }}
162
+ PREVIOUS: ${{ steps.version.outputs.previous }}
163
+ AT_HEAD: ${{ steps.version.outputs.at_head }}
164
+ run: |
165
+ notes="$RUNNER_TEMP/notes.md"
166
+ # A re-run after this changelog commit reached main but the release
167
+ # wasn't created: release that commit, with the notes it rolled,
168
+ # rather than rolling $SHA again (that push would be rejected).
169
+ git fetch -q origin +refs/heads/main:refs/remotes/origin/main
170
+ rolled=$(git log --format='%H%x09%P%x09%ae%x09%s' "$SHA..origin/main" |
171
+ awk -F '\t' -v sha="$SHA" -v bot="$BOT_EMAIL" -v subject="Update changelog for $TAG" \
172
+ '$2 == sha && $3 == bot && $4 == subject { print $1 }')
173
+ if [ -n "$rolled" ]; then
174
+ echo "Release commit for $TAG is already on main ($rolled); releasing that."
175
+ echo "sha=$rolled" >> "$GITHUB_OUTPUT"
176
+ if git show "$rolled:CHANGELOG.md" > "$RUNNER_TEMP/CHANGELOG.md" 2> /dev/null &&
177
+ python3 .github/scripts/roll_changelog.py --notes "$RUNNER_TEMP/CHANGELOG.md" "$TAG" "$notes"; then
178
+ echo "notes=$notes" >> "$GITHUB_OUTPUT"
179
+ fi
180
+ exit 0
181
+ fi
182
+ if [ -f CHANGELOG.md ]; then
183
+ python3 .github/scripts/roll_changelog.py CHANGELOG.md "$TAG" "$PREVIOUS" \
184
+ "$(date -u +%Y-%m-%d)" "$notes" "$AT_HEAD"
185
+ fi
186
+ if [ -f .github/scripts/set_version.sh ]; then
187
+ bash .github/scripts/set_version.sh "${TAG#v}"
188
+ fi
189
+ # Stage everything, so files the hook creates or stages are included.
190
+ git add -A
191
+ if git diff --cached --quiet; then
192
+ echo "Nothing under [Unreleased] and no version files; using generated notes."
193
+ exit 0
194
+ fi
195
+ git config user.name "github-actions[bot]"
196
+ git config user.email "$BOT_EMAIL"
197
+ git commit -qm "Update changelog for $TAG"
198
+ # Fast-forward only: if main moved past $SHA, leave it alone.
199
+ if git push origin HEAD:refs/heads/main; then
200
+ echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
201
+ if [ -s "$notes" ]; then
202
+ echo "notes=$notes" >> "$GITHUB_OUTPUT"
203
+ else
204
+ echo "Nothing under [Unreleased]; using generated notes."
205
+ fi
206
+ exit 0
207
+ fi
208
+ git fetch -q origin +refs/heads/main:refs/remotes/origin/main
209
+ if [ "$(git rev-parse origin/main)" = "$SHA" ]; then
210
+ echo "::error::Pushing the release commit for $TAG failed, and main hasn't moved."
211
+ exit 1
212
+ fi
213
+ # Releasing $SHA as is would leave its entries under [Unreleased]
214
+ # (they'd appear in the next release too) and miss the version files.
215
+ echo "::notice::main moved past $SHA; not releasing $TAG here. The release for the newer merge includes this one (for a manual minor/major run, run it again)."
216
+ echo "skip=true" >> "$GITHUB_OUTPUT"
217
+
218
+ - name: Create release
219
+ if: steps.version.outputs.tag != '' && steps.changelog.outputs.skip != 'true'
220
+ env:
221
+ TAG: ${{ steps.version.outputs.tag }}
222
+ TARGET: ${{ steps.changelog.outputs.sha || env.SHA }}
223
+ NOTES: ${{ steps.changelog.outputs.notes }}
224
+ run: |
225
+ if ! gh release view "$TAG" > /dev/null 2>&1; then
226
+ if [ -n "$NOTES" ]; then notes=(--notes-file "$NOTES"); else notes=(--generate-notes); fi
227
+ gh release create "$TAG" --target "$TARGET" --title "$TAG" "${notes[@]}"
228
+ fi
229
+ # A release made with GITHUB_TOKEN doesn't fire publish.yml's release
230
+ # trigger, so start it directly on the new tag. Repos with nothing to
231
+ # publish have no publish.yml.
232
+ if [ -f .github/workflows/publish.yml ]; then
233
+ gh workflow run publish.yml --ref "$TAG"
234
+ fi
@@ -0,0 +1,17 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ build/
5
+ dist/
6
+
7
+ .venv/
8
+ .env
9
+
10
+ .pytest_cache/
11
+ .ruff_cache/
12
+ .coverage
13
+ htmlcov/
14
+
15
+ .DS_Store
16
+ .claude/settings.local.json
17
+ .claude/*.local.json
@@ -0,0 +1 @@
1
+ 3.13
@@ -0,0 +1,14 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here. The format is based on
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to
5
+ [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
+
7
+ ## [Unreleased]
8
+
9
+ ## [0.1.0] - 2026-10-17
10
+
11
+ ### Added
12
+
13
+ - Placeholder Textual app (`magpy-lite`, `python -m magpy_lite`) announcing the project.
14
+
@@ -0,0 +1,12 @@
1
+ # Citation metadata (Citation File Format 1.2.0 — https://citation-file-format.github.io).
2
+ # `version`/`date-released` are omitted so this file never drifts from the actual release.
3
+ cff-version: 1.2.0
4
+ title: "magpy-lite: Terminal interface for the bioamla bioacoustics toolkit"
5
+ message: "If you use this software, please cite it using this metadata."
6
+ type: software
7
+ authors:
8
+ - family-names: McMeen
9
+ given-names: John
10
+ email: johnmcmeen@gmail.com
11
+ repository-code: "https://github.com/jmcmeen/magpy-lite"
12
+ license: MIT
@@ -0,0 +1,55 @@
1
+ # CLAUDE.md
2
+
3
+ Guidance for Claude Code when working in this repository.
4
+
5
+ ## What this is
6
+
7
+ magpy-lite is a **Textual TUI front end for the `bioamla` library**, the
8
+ terminal sibling of MagPy (the PyQt6 GUI). bioamla owns the bioacoustics
9
+ domain; magpy-lite owns the terminal UI and the mutable state a functional
10
+ library can't hold. If you need domain functionality, add it to bioamla.
11
+
12
+ **0.1.0 is a placeholder release**: one "coming soon" screen, no bioamla
13
+ dependency yet. Features are planned as magpy and bioamla mature; don't invent
14
+ screens that haven't been asked for.
15
+
16
+ ## Workflow
17
+
18
+ - **The user handles all commits.** Make changes in the working tree and stop
19
+ there. Do not run `git commit`, and do not offer to, unless asked. Branching
20
+ and pushing likewise only on request. Work happens on `dev`; `main` releases.
21
+ - Tooling (`Makefile`, CI, release workflow, ruff config) follows the laborae
22
+ workspace template; `make consistency` at the workspace root checks it. Don't
23
+ edit `.github/workflows/release.yml` or `.github/scripts/roll_changelog.py`
24
+ here; change the template instead.
25
+
26
+ ## Commands
27
+
28
+ The project uses **`uv`**; the `Makefile` wraps the common tasks:
29
+
30
+ ```sh
31
+ make install # uv sync --extra dev
32
+ make run # launch the TUI (magpy-lite -> magpy_lite.app:main)
33
+ make test # uv run pytest
34
+ make lint # ruff check src tests
35
+ make fmt # ruff format + ruff check --fix
36
+ make check # lint + format check + test
37
+ ```
38
+
39
+ `uv run python -m magpy_lite` also launches it. Python is `>=3.10`.
40
+
41
+ ## Layout
42
+
43
+ - `src/magpy_lite/app.py` — the `App` subclass and the `main()` entry point.
44
+ - `src/magpy_lite/__main__.py` — `python -m` shim.
45
+ - Tests drive the app headlessly with Textual's `App.run_test()` inside
46
+ `asyncio.run()`; there is no pytest-asyncio dependency. Tests must never
47
+ block on real terminal input.
48
+
49
+ ## Rules
50
+
51
+ - Keep runtime dependencies minimal: `textual` now, `bioamla` once a screen
52
+ calls into it. Check whether a package already arrives transitively before
53
+ declaring it.
54
+ - Once bioamla is wired in, keep its imports in one layer (as MagPy does with
55
+ `services/`) so widgets never import bioamla directly.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 John McMeen
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,73 @@
1
+ # Makefile for magpy-lite development tasks.
2
+ # Dependency resolution and environment management use uv (https://docs.astral.sh/uv/).
3
+
4
+ .DEFAULT_GOAL := help
5
+
6
+ UV ?= uv
7
+ SRC := src
8
+ TESTS := tests
9
+
10
+ .PHONY: help install dev sync lock upgrade shell \
11
+ run \
12
+ test test-fast cov \
13
+ lint fmt fmt-check check \
14
+ clean
15
+
16
+ help: ## Show this help
17
+ @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) \
18
+ | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-14s\033[0m %s\n", $$1, $$2}'
19
+
20
+ ## --- Environment & dependencies (uv) ---
21
+
22
+ install: ## Create venv and install the project with dev tooling
23
+ $(UV) sync --extra dev
24
+
25
+ dev: install ## Alias for install
26
+
27
+ sync: ## Sync the environment to the lockfile (incl. dev tooling)
28
+ $(UV) sync --extra dev
29
+
30
+ lock: ## Resolve and write uv.lock
31
+ $(UV) lock
32
+
33
+ upgrade: ## Upgrade locked dependencies to latest allowed versions
34
+ $(UV) lock --upgrade
35
+
36
+ shell: ## Spawn a subshell with the project venv activated
37
+ $(UV) run $${SHELL:-bash}
38
+
39
+ ## --- Running ---
40
+
41
+ run: ## Launch the TUI
42
+ $(UV) run magpy-lite
43
+
44
+ ## --- Testing ---
45
+
46
+ test: ## Run the full test suite
47
+ $(UV) run --extra dev pytest
48
+
49
+ test-fast: ## Run tests, skipping slow/integration markers
50
+ $(UV) run --extra dev pytest -m "not slow and not integration"
51
+
52
+ cov: ## Run tests with coverage report
53
+ $(UV) run --extra dev pytest --cov --cov-report=term-missing
54
+
55
+ ## --- Linting & formatting (ruff) ---
56
+
57
+ lint: ## Lint with ruff
58
+ $(UV) run --extra dev ruff check $(SRC) $(TESTS)
59
+
60
+ fmt: ## Auto-format and apply lint fixes
61
+ $(UV) run --extra dev ruff format $(SRC) $(TESTS)
62
+ $(UV) run --extra dev ruff check --fix $(SRC) $(TESTS)
63
+
64
+ fmt-check: ## Check formatting without modifying files
65
+ $(UV) run --extra dev ruff format --check $(SRC) $(TESTS)
66
+
67
+ check: lint fmt-check test ## Run lint, format check, and tests
68
+
69
+ ## --- Housekeeping ---
70
+
71
+ clean: ## Remove caches and test/coverage build artifacts
72
+ rm -rf .pytest_cache .ruff_cache .coverage htmlcov dist
73
+ find . -name .venv -prune -o -type d -name __pycache__ -prune -exec rm -rf {} +