From c6ce1a5ebb755264bf33dd38f04ca4d8ce298f65 Mon Sep 17 00:00:00 2001 From: JJ Kavelaars Date: Sat, 3 Oct 2026 18:29:12 -0700 Subject: [PATCH 1/2] Stop tracking generated __version__.py. Co-authored-by: Cursor --- src/mp_ephem/__version__.py | 1 - 1 file changed, 1 deletion(-) delete mode 100644 src/mp_ephem/__version__.py diff --git a/src/mp_ephem/__version__.py b/src/mp_ephem/__version__.py deleted file mode 100644 index ef3d3f2..0000000 --- a/src/mp_ephem/__version__.py +++ /dev/null @@ -1 +0,0 @@ -version = "0.14.0" From 33964dd66abdfb4f8c12e62bfc64e096b654fc91 Mon Sep 17 00:00:00 2001 From: JJ Kavelaars Date: Sat, 3 Oct 2026 18:30:19 -0700 Subject: [PATCH 2/2] Derive YYYY.MAJOR.FIX versions from git tags and automate PyPI releases. Co-authored-by: Cursor --- .github/scripts/next_version.py | 64 ++++++++++++++++++++++++++++++ .github/workflows/pypi-publish.yml | 56 +++++++++++++++++++++++++- .github/workflows/python-app.yml | 2 + .gitignore | 5 +++ README | 19 ++++++++- pyproject.toml | 12 ++++++ setup.py | 3 -- src/mp_ephem/__init__.py | 4 ++ test/test_next_version.py | 29 ++++++++++++++ 9 files changed, 188 insertions(+), 6 deletions(-) create mode 100644 .github/scripts/next_version.py create mode 100644 pyproject.toml create mode 100644 test/test_next_version.py diff --git a/.github/scripts/next_version.py b/.github/scripts/next_version.py new file mode 100644 index 0000000..2f730e2 --- /dev/null +++ b/.github/scripts/next_version.py @@ -0,0 +1,64 @@ +"""Compute the next calendar version (YYYY.MAJOR.FIX) from git release tags. + +Release tags look like ``v2026.1.0``. The first release of a calendar year is +``YYYY.1.0`` whatever the release type; after that a ``major`` release bumps +MAJOR and resets FIX, and a ``bugfix`` release bumps FIX. + +Usage:: + + python .github/scripts/next_version.py {major,bugfix} + +Prints the new version (without the leading ``v``). Exits non-zero if HEAD +already carries a release tag, so the same commit is not released twice. +""" +import argparse +import datetime +import re +import subprocess +import sys + +TAG_PATTERN = re.compile(r"^v(\d{4})\.(\d+)\.(\d+)$") + + +def parse_tag(tag): + match = TAG_PATTERN.match(tag.strip()) + if match is None: + return None + return tuple(int(part) for part in match.groups()) + + +def next_version(tags, release_type, year): + releases = [v for v in (parse_tag(t) for t in tags) if v is not None] + latest = max(releases) if releases else None + if latest is None or latest[0] < year: + return year, 1, 0 + if latest[0] > year: + raise ValueError("latest release v{}.{}.{} is dated after {}".format(*latest, year)) + _, major, fix = latest + if release_type == "major": + return year, major + 1, 0 + if release_type == "bugfix": + return year, major, fix + 1 + raise ValueError("release type must be 'major' or 'bugfix', not {!r}".format(release_type)) + + +def git_lines(*args): + output = subprocess.run(["git", *args], check=True, capture_output=True, text=True).stdout + return [line for line in output.splitlines() if line.strip()] + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("release_type", choices=["major", "bugfix"]) + args = parser.parse_args(argv) + + head_releases = [t for t in git_lines("tag", "--points-at", "HEAD") if parse_tag(t)] + if head_releases: + sys.exit("HEAD is already released as {}".format(", ".join(head_releases))) + + year = datetime.datetime.now(datetime.timezone.utc).year + print("{}.{}.{}".format(*next_version(git_lines("tag", "--list"), args.release_type, year))) + + +if __name__ == "__main__": + main() diff --git a/.github/workflows/pypi-publish.yml b/.github/workflows/pypi-publish.yml index e5cb88f..1807d08 100644 --- a/.github/workflows/pypi-publish.yml +++ b/.github/workflows/pypi-publish.yml @@ -1,22 +1,49 @@ -# Build and upload the package to PyPI. Run manually from the Actions tab. +# Release to PyPI. Run manually from the Actions tab on master and choose the release type. +# Versions are YYYY.MAJOR.FIX; the next one is computed from the v* release tags, +# and the tag plus a GitHub release are created only after the PyPI upload succeeds. +# The file name is registered as a PyPI trusted publisher: do not rename it. name: Upload Package to PyPI on: workflow_dispatch: + inputs: + release_type: + description: "major (new features or changed results) or bugfix" + required: true + type: choice + options: + - bugfix + - major + +concurrency: + group: pypi-release jobs: build: name: Build distribution + if: github.ref == 'refs/heads/master' runs-on: ubuntu-latest + outputs: + version: ${{ steps.version.outputs.version }} steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: "3.x" + - name: Compute next version + id: version + run: | + version=$(python3 .github/scripts/next_version.py "${{ inputs.release_type }}") + echo "version=$version" >> "$GITHUB_OUTPUT" + echo "Releasing $version" >> "$GITHUB_STEP_SUMMARY" - name: Install pypa/build run: python3 -m pip install build --user - name: Build for upload + env: + SETUPTOOLS_SCM_PRETEND_VERSION: ${{ steps.version.outputs.version }} run: python3 -m build --sdist - name: Store the distribution packages uses: actions/upload-artifact@v4 @@ -42,3 +69,28 @@ jobs: path: dist/ - name: Publish to PyPI uses: pypa/gh-action-pypi-publish@release/v1 + + tag-release: + name: Tag and create GitHub release + needs: + - build + - publish-to-pypi + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + - name: Download all the dists + uses: actions/download-artifact@v4 + with: + name: python-package-distributions + path: dist/ + - name: Create tag and release + env: + GH_TOKEN: ${{ github.token }} + VERSION: ${{ needs.build.outputs.version }} + run: | + gh release create "v$VERSION" dist/* \ + --target "$GITHUB_SHA" \ + --title "mp_ephem $VERSION" \ + --generate-notes diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index 9053fbf..aa7d184 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -19,6 +19,8 @@ jobs: steps: - uses: actions/checkout@v3 + with: + fetch-depth: 0 - name: Set up Python 3.9 uses: actions/setup-python@v3 with: diff --git a/.gitignore b/.gitignore index afe4041..a0d7c2e 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,8 @@ MANIFEST venv .DS_Store .eggs + +# written by setuptools_scm at build time +src/mp_ephem/__version__.py +*.egg-info +*.so diff --git a/README b/README index 37de01f..d8f231f 100644 --- a/README +++ b/README @@ -7,7 +7,24 @@ Started working on this on 10=APR=2012 INSTALL -python setup.py install +pip install mp_ephem (from PyPI) +pip install . (from a source checkout) + +VERSIONS + +Releases are numbered YYYY.MAJOR.FIX, e.g. 2026.2.1: + + YYYY year of the release, so the age of a version is obvious + MAJOR increments for releases with new features or changed results + FIX increments for bug-fix releases + +The first release of each year is YYYY.1.0. The version comes from the git +tag (v2026.2.1); nothing in the source needs editing. Builds between releases +get development versions such as 2026.2.2.dev3+g1a2b3c4. + +To release, run the "Upload Package to PyPI" workflow on master from the +GitHub Actions tab and choose "major" or "bugfix". It computes the next +version, uploads to PyPI, then tags the commit and creates a GitHub release. PYTHON USAGE: diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..83f7546 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,12 @@ +[build-system] +requires = ["setuptools>=64", "setuptools_scm>=8"] +build-backend = "setuptools.build_meta" + +# Version is YYYY.MAJOR.FIX, taken from release tags like v2026.1.0. +# Untagged builds get a development version such as 2026.1.1.dev3+g1234abc. +[tool.setuptools_scm] +version_file = "src/mp_ephem/__version__.py" +tag_regex = '^v(?P\d{4}\.\d+\.\d+)$' + +[tool.setuptools_scm.scm.git] +describe_command = ["git", "describe", "--dirty", "--tags", "--long", "--match", "v[0-9][0-9][0-9][0-9].*"] diff --git a/setup.py b/setup.py index 512734f..6a74e81 100644 --- a/setup.py +++ b/setup.py @@ -2,15 +2,12 @@ import sys from setuptools import setup, find_packages, Extension -version = "0.14.0" - dependencies = ['astropy', 'pyerfa', 'numpy', 'requests'] sources = [x for x in glob.glob('orbfit/*.c')] headers = [x for x in glob.glob('orbfit/*.h')] setup(name='mp_ephem', - version=version, url='http://github.com/OSSOS/liborbfit', author='''JJ Kavelaars (jjk@uvic.ca), Michele Bannister (micheleb@uvic.ca)''', maintainer='M Bannister and JJ Kavelaars', diff --git a/src/mp_ephem/__init__.py b/src/mp_ephem/__init__.py index da78064..deedf97 100644 --- a/src/mp_ephem/__init__.py +++ b/src/mp_ephem/__init__.py @@ -1,3 +1,7 @@ +try: + from .__version__ import version as __version__ +except ImportError: # source tree that has never been built + __version__ = "unknown" from . import time_mpc from .bk_orbit import BKOrbit from .bk_orbit import BKOrbitError diff --git a/test/test_next_version.py b/test/test_next_version.py new file mode 100644 index 0000000..5d5dd6a --- /dev/null +++ b/test/test_next_version.py @@ -0,0 +1,29 @@ +import importlib.util +import os +import unittest + +_SCRIPT = os.path.join(os.path.dirname(__file__), os.pardir, '.github', 'scripts', 'next_version.py') +_spec = importlib.util.spec_from_file_location('next_version', _SCRIPT) +next_version = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(next_version) + + +class NextVersionTest(unittest.TestCase): + + def test_first_release_ignores_legacy_tags(self): + self.assertEqual(next_version.next_version(['V12.0', '0.14.0'], 'major', 2026), (2026, 1, 0)) + self.assertEqual(next_version.next_version([], 'bugfix', 2026), (2026, 1, 0)) + + def test_major_and_bugfix_within_a_year(self): + tags = ['v2026.1.0', 'v2026.2.0', 'v2026.2.3', 'v2026.10.0'] + self.assertEqual(next_version.next_version(tags, 'major', 2026), (2026, 11, 0)) + self.assertEqual(next_version.next_version(tags, 'bugfix', 2026), (2026, 10, 1)) + + def test_new_year_restarts_numbering(self): + tags = ['v2026.4.2'] + self.assertEqual(next_version.next_version(tags, 'major', 2027), (2027, 1, 0)) + self.assertEqual(next_version.next_version(tags, 'bugfix', 2027), (2027, 1, 0)) + + def test_rejects_release_dated_in_the_future(self): + with self.assertRaises(ValueError): + next_version.next_version(['v2027.1.0'], 'bugfix', 2026)