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.
- magpy_lite-0.1.0/.github/scripts/roll_changelog.py +99 -0
- magpy_lite-0.1.0/.github/workflows/ci.yml +48 -0
- magpy_lite-0.1.0/.github/workflows/publish.yml +80 -0
- magpy_lite-0.1.0/.github/workflows/release.yml +234 -0
- magpy_lite-0.1.0/.gitignore +17 -0
- magpy_lite-0.1.0/.python-version +1 -0
- magpy_lite-0.1.0/CHANGELOG.md +14 -0
- magpy_lite-0.1.0/CITATION.cff +12 -0
- magpy_lite-0.1.0/CLAUDE.md +55 -0
- magpy_lite-0.1.0/LICENSE +21 -0
- magpy_lite-0.1.0/Makefile +73 -0
- magpy_lite-0.1.0/PKG-INFO +81 -0
- magpy_lite-0.1.0/README.md +49 -0
- magpy_lite-0.1.0/pyproject.toml +100 -0
- magpy_lite-0.1.0/src/magpy_lite/__init__.py +10 -0
- magpy_lite-0.1.0/src/magpy_lite/__main__.py +5 -0
- magpy_lite-0.1.0/src/magpy_lite/app.py +85 -0
- magpy_lite-0.1.0/tests/test_app.py +24 -0
- magpy_lite-0.1.0/tests/test_import.py +5 -0
- magpy_lite-0.1.0/uv.lock +460 -0
|
@@ -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 @@
|
|
|
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.
|
magpy_lite-0.1.0/LICENSE
ADDED
|
@@ -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 {} +
|