From 068c9623696eb572a1b0f7f1aabdcdb7c881cc69 Mon Sep 17 00:00:00 2001 From: wpbonelli Date: Sun, 6 Sep 2026 19:44:08 -0400 Subject: [PATCH 1/3] docs: add DEVELOPER.md, fold in the publishing guide Move the release procedure from guide-to-publish.md into a new DEVELOPER.md that also covers development setup, linting and testing, matching the layout of modflow-devtools. Includes the conda-forge step and the trusted-publisher rename caveat. Link it from the README. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01UVbcrMW2Ax1LwR9bskJdeu --- guide-to-publish.md => DEVELOPER.md | 108 ++++++++++++++++++++++++++-- README.md | 6 ++ 2 files changed, 108 insertions(+), 6 deletions(-) rename guide-to-publish.md => DEVELOPER.md (58%) diff --git a/guide-to-publish.md b/DEVELOPER.md similarity index 58% rename from guide-to-publish.md rename to DEVELOPER.md index 73d16c6..a2cce75 100644 --- a/guide-to-publish.md +++ b/DEVELOPER.md @@ -1,4 +1,100 @@ -# How to publish a release +# Developing `modflowapi` + +This document explains how to set up a development environment, run the tests, and cut a release. +Conventions used in the project are noted along the way. + + + + + +- [Requirements](#requirements) +- [Installation](#installation) +- [Linting](#linting) +- [Testing](#testing) + - [MODFLOW executables and example models](#modflow-executables-and-example-models) + - [Running the tests](#running-the-tests) +- [Releasing](#releasing) + - [1. Start the release](#1-start-the-release) + - [2. Review and approve](#2-review-and-approve) + - [3. Automatic steps](#3-automatic-steps) + - [4. conda-forge](#4-conda-forge) + - [Changelog conventions](#changelog-conventions) + + + +## Requirements + +Python 3.11+. Like the other MODFLOW Python projects, `modflowapi` aims to support recent Python +versions, loosely following [SPEC 0](https://scientific-python.org/specs/spec-0000/#support-window). + +## Installation + +Fork and clone the repository, then install the project in editable mode with the development +dependencies: + +```shell +pip install -e . --group dev +``` + +Developers who use `uv` can sync the project instead: + +```shell +uv sync --all-extras +``` + +The `dev` group includes the `test` and `lint` groups. + +## Linting + +Linting and formatting use [`ruff`](https://docs.astral.sh/ruff/), and spelling is checked with +[`codespell`](https://github.com/codespell-project/codespell): + +```shell +ruff check . +ruff format . +codespell +``` + +These are also run in CI and must pass. + +## Testing + +The tests use [`pytest`](https://docs.pytest.org/) with +[`pytest-xdist`](https://pytest-xdist.readthedocs.io/) and fixtures from +[`modflow-devtools`](https://github.com/MODFLOW-ORG/modflow-devtools). They live in `autotest/` and +are run from that directory. + +### MODFLOW executables and example models + +The tests need the MODFLOW 6 executables, including the `libmf6` shared library, either on the +`PATH` or installed into `autotest/`. They also need the MODFLOW 6 example models. Both can be +fetched with the `mf` command that ships with `modflow-devtools`: + +```shell +mf programs install libmf6 --bindir autotest +mf sync +``` + +FloPy's [`get-modflow`](https://github.com/modflowpy/flopy) utility is an alternative way to install +the executables. See [`.github/workflows/ci.yml`](.github/workflows/ci.yml) for exactly what CI +installs, including the nightly build used for the example-model tests. + +### Running the tests + +From `autotest/`, run in parallel with verbose output: + +```shell +pytest -v -n auto +``` + +Two markers select subsets of the suite (see [`autotest/pytest.ini`](autotest/pytest.ini)): + +- `mf6`: tests that run the MODFLOW 6 example models +- `extensions`: tests for the `modflowapi` extensions + +For instance, to skip the example-model tests: `pytest -v -n auto -m "not mf6"`. + +## Releasing Releases are automated by [`.github/workflows/release.yml`](.github/workflows/release.yml). Publishing to PyPI uses [trusted publishing](https://docs.pypi.org/trusted-publishers/), so no @@ -12,7 +108,7 @@ API token is needed, but the repository must have a `release` environment config > eighteen months until the next release. After any such rename, update the publisher at > https://pypi.org/manage/project/modflowapi/settings/publishing/ to match. -## 1. Start the release +### 1. Start the release From the [Actions tab](https://github.com/MODFLOW-ORG/modflowapi/actions/workflows/release.yml), select **Run workflow** and fill in the form: @@ -36,12 +132,12 @@ into `main`. A release can alternatively be started by pushing a release branch named `v..`. -## 2. Review and approve +### 2. Review and approve Review the release pull request, in particular `HISTORY.md`. Mark it ready for review and merge it into `main`. Merge rather than squash, to preserve the commit history. -## 3. Automatic steps +### 3. Automatic steps Merging the release pull request into `main` triggers jobs that: @@ -52,7 +148,7 @@ Merging the release pull request into `main` triggers jobs that: Merge the reset pull request to finish the release. -## 4. conda-forge +### 4. conda-forge A few hours after the upload to PyPI, a bot opens a version pull request on the [feedstock](https://github.com/conda-forge/modflowapi-feedstock). To start it immediately instead, @@ -69,7 +165,7 @@ Merging the feedstock pull request builds and uploads the package. It does not a until the channel index is regenerated, which takes up to about an hour; the package is visible on anaconda.org before then. -## Changelog conventions +### Changelog conventions Release notes are generated from commit messages, so commits merged to `develop` must follow the [conventional commits](https://www.conventionalcommits.org/) format (`feat:`, `fix:`, `refactor:`, diff --git a/README.md b/README.md index 8e05d20..2022c6c 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ An extension to [xmipy](https://pypi.org/project/xmipy/) for the [MODFLOW API](h - [Introduction](#introduction) - [Installation](#installation) - [Documentation](#documentation) +- [Contributing](#contributing) - [Citation](#citation) @@ -61,6 +62,11 @@ Examples using `modflowapi` and its extensions can be found in the [Quickstart]( For more info on MODFLOW 6 see [the USGS overview](https://water.usgs.gov/ogw/modflow/). +## Contributing + +See [DEVELOPER.md](DEVELOPER.md) for how to set up a development environment, run the tests, and +cut a release. + ## Citation Hughes, Joseph D., Russcher, M. J., Langevin, C. D., Morway, E. D. and McDonald, R. R., 2022, The MODFLOW Application Programming Interface for simulationcontrol and software interoperability: Environmental Modelling & Software, v. 148, p. 105257, [doi:10.1016/j.envsoft.2021.105257](https://doi.org/10.1016/j.envsoft.2021.105257). From 94be8834fadb6d7acd6a7212772d5e95198f0be8 Mon Sep 17 00:00:00 2001 From: wpbonelli Date: Mon, 7 Sep 2026 09:03:02 -0400 Subject: [PATCH 2/3] ci(release): simplify update_version.py Drop the tag-relative --bump {major,minor,patch,dev}. It shelled out to git tag and kept a second source of truth alongside version.txt that could disagree with it, and the 'dev' choice was a --bump value that ignored tags entirely. The development version on develop already names the next release, so --release derives it by stripping the development suffix (1.2.0.dev0 -> 1.2.0). Rename --get to --dry-run, move the "Updated ..." lines to stderr, and print the resolved version on stdout whether or not it was written. The develop reset previously ran the script twice -- once to compute the next version, once to write it -- in an order that silently double-bumped if reversed; `next=$(update_version.py --post-release)` now does both. Rename --next-dev to --post-release: it pairs with --release, and "next dev" read like incrementing the .devN counter. The dispatch form loses the bump input; the common case needs no input at all, and the version input still overrides. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01UVbcrMW2Ax1LwR9bskJdeu --- .github/workflows/release.yml | 17 ++---- DEVELOPER.md | 12 ++--- scripts/update_version.py | 99 +++++++++++------------------------ 3 files changed, 39 insertions(+), 89 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ba84b6d..b5c2972 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,18 +8,8 @@ on: required: true type: string default: 'develop' - bump: - description: 'Version increment relative to the latest release tag. Ignored if a version number is given below.' - required: true - type: choice - default: 'minor' - options: - - minor - - patch - - major - - dev version: - description: 'Version number to release, e.g. 0.3.0. Overrides the increment selected above.' + description: "Version number to release, e.g. 0.3.0. Defaults to version.txt with its development suffix removed." required: false type: string run_tests: @@ -86,7 +76,7 @@ jobs: if [[ -n "${{ inputs.version }}" ]]; then ver="${{ inputs.version }}" else - ver=$(uv run scripts/update_version.py --get --bump "${{ inputs.bump }}") + ver=$(uv run scripts/update_version.py --release --dry-run) fi else # release branch name is the version number, prefixed with 'v' @@ -332,8 +322,7 @@ jobs: ver="${{ needs.release.outputs.version }}" branch="post-release-$ver-reset" - next=$(uv run scripts/update_version.py --get --next-dev) - uv run scripts/update_version.py -v "$next" + next=$(uv run scripts/update_version.py --post-release) git config core.sharedRepository true git config user.name "github-actions[bot]" diff --git a/DEVELOPER.md b/DEVELOPER.md index a2cce75..b13dcc1 100644 --- a/DEVELOPER.md +++ b/DEVELOPER.md @@ -116,19 +116,19 @@ select **Run workflow** and fill in the form: | Input | Description | |:--|:--| | `branch` | Branch to release from. Defaults to `develop`. | -| `bump` | Version increment relative to the latest release tag: `minor` (default), `patch`, `major`, or `dev` to release the current development version as-is. | -| `version` | Explicit version number, e.g. `0.3.0`. Overrides `bump`. | +| `version` | Explicit version number, e.g. `0.3.0`. Defaults to the version in `version.txt` with its `.dev` suffix removed. | | `run_tests` | Run the test suite before drafting the release. Defaults to true. | This can also be done from the command line, for instance: ```shell -gh workflow run release.yml -f branch=develop -f bump=minor +gh workflow run release.yml -f branch=develop ``` -The workflow creates a `v` release branch, updates the version number, regenerates the -changelog with [git-cliff](https://git-cliff.org/), runs the tests, and opens a draft pull request -into `main`. +The release version is normally the development version already set in `version.txt` (e.g. +`1.2.0.dev0` releases as `1.2.0`); pass `version` only to release something else. The workflow +creates a `v` release branch, updates the version number, regenerates the changelog with +[git-cliff](https://git-cliff.org/), runs the tests, and opens a draft pull request into `main`. A release can alternatively be started by pushing a release branch named `v..`. diff --git a/scripts/update_version.py b/scripts/update_version.py index df753d6..57722df 100644 --- a/scripts/update_version.py +++ b/scripts/update_version.py @@ -1,12 +1,12 @@ import argparse -import subprocess +import sys import textwrap from datetime import datetime from os.path import basename from pathlib import Path from filelock import FileLock -from packaging.version import InvalidVersion, Version +from packaging.version import Version _project_name = "modflowapi" _project_root_path = Path(__file__).parent.parent @@ -14,54 +14,20 @@ _version_py_path = _project_root_path / "modflowapi" / "version.py" _citation_cff_path = _project_root_path / "CITATION.cff" -_initial_version = Version("0.0.1") _current_version = Version(_version_txt_path.read_text().strip()) def log_update(path, version: Version): - print(f"Updated {path} with version {version}") + print(f"Updated {path} with version {version}", file=sys.stderr) -def latest_release() -> Version: - """Version of the most recent release tag, or the initial version if there is none.""" - try: - tags = subprocess.run( - ["git", "tag", "--list", "--sort=-v:refname"], - cwd=_project_root_path, - capture_output=True, - text=True, - check=True, - ).stdout.split() - except (subprocess.CalledProcessError, FileNotFoundError): - tags = [] - for tag in tags: - try: - return Version(tag) - except InvalidVersion: - continue - return _initial_version - - -def bump_version(bump: str) -> Version: - """Next release version, relative to the latest release tag. - - The 'dev' increment releases the current development version as-is, - with any development segment (e.g. '.dev0') stripped. - """ - if bump == "dev": - return Version(_current_version.base_version) - latest = latest_release() - if bump == "major": - return Version(f"{latest.major + 1}.0.0") - elif bump == "minor": - return Version(f"{latest.major}.{latest.minor + 1}.0") - elif bump == "patch": - return Version(f"{latest.major}.{latest.minor}.{latest.micro + 1}") - raise ValueError(f"Unsupported version increment: {bump}") - - -def next_dev_version() -> Version: - """Next development version, incrementing the minor version number.""" +def release_version() -> Version: + """The current development version with any development segment (e.g. '.dev0') removed.""" + return Version(_current_version.base_version) + + +def post_release_version() -> Version: + """Development version for the next cycle: minor incremented, '.dev0' suffix.""" version = Version(_current_version.base_version) return Version(f"{version.major}.{version.minor + 1}.0.dev0") @@ -115,52 +81,47 @@ def update_version(timestamp: datetime = datetime.now(), version: Version = None formatter_class=argparse.RawDescriptionHelpFormatter, epilog=textwrap.dedent( """\ - Update version information in version.txt in the project root, - as well as several other files in the repository. If neither - --version nor --bump nor --next-dev is provided, the version - number will not be changed. A file lock is held to synchronize - file access. The version tag must be standard + Update version information in version.txt in the project root, as + well as several other files in the repository, and print the new + version. If none of --version, --release or --post-release is + provided, the version number is not changed. A file lock is held to + synchronize file access. The version tag must be standard '..' format for semantic versioning. """ ), ) parser.add_argument("-v", "--version", required=False, help="Specify the release version") parser.add_argument( - "-b", - "--bump", + "-r", + "--release", required=False, - choices=["major", "minor", "patch", "dev"], - help=( - "Compute the release version by incrementing the latest release tag. " - "'dev' releases the current development version as-is" - ), + action="store_true", + help="Use the current development version with its development segment (e.g. '.dev0') removed", ) parser.add_argument( - "-n", - "--next-dev", + "-p", + "--post-release", required=False, action="store_true", - help="Compute the next development version, incrementing the minor version number", + help="Use the development version for the next cycle: the minor version incremented, with a '.dev0' suffix", ) parser.add_argument( - "-g", - "--get", + "--dry-run", required=False, action="store_true", - help="Print the version number, no updates (defaults false)", + help="Print the version that would be written, and exit without writing", ) args = parser.parse_args() - if args.next_dev: - version = next_dev_version() - elif args.bump: - version = bump_version(args.bump) + if args.post_release: + version = post_release_version() + elif args.release: + version = release_version() elif args.version: version = Version(args.version) else: version = _current_version - if args.get: - print(version) - else: + if not args.dry_run: update_version(timestamp=datetime.now(), version=version) + print(version) From 5a85776782349f78032d8e68b7520f83623443fc Mon Sep 17 00:00:00 2001 From: wpbonelli Date: Tue, 8 Sep 2026 06:59:43 -0400 Subject: [PATCH 3/3] trim dev docs --- DEVELOPER.md | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/DEVELOPER.md b/DEVELOPER.md index b13dcc1..21aca6d 100644 --- a/DEVELOPER.md +++ b/DEVELOPER.md @@ -68,7 +68,7 @@ are run from that directory. The tests need the MODFLOW 6 executables, including the `libmf6` shared library, either on the `PATH` or installed into `autotest/`. They also need the MODFLOW 6 example models. Both can be -fetched with the `mf` command that ships with `modflow-devtools`: +fetched with the `mf` command from `modflow-devtools`: ```shell mf programs install libmf6 --bindir autotest @@ -103,10 +103,8 @@ API token is needed, but the repository must have a `release` environment config > [!IMPORTANT] > PyPI matches a trusted publisher on the organisation name, the repository name, the workflow > filename and the environment name. Renaming any of them silently invalidates the publisher, and -> nothing reports it until the next release fails with `invalid-publisher`. This happened when the -> organisation was renamed from `MODFLOW-USGS` to `MODFLOW-ORG`, and went unnoticed for the -> eighteen months until the next release. After any such rename, update the publisher at -> https://pypi.org/manage/project/modflowapi/settings/publishing/ to match. +> nothing reports it until the next release fails with `invalid-publisher`. After any such rename, +> update the publisher at https://pypi.org/manage/project/modflowapi/settings/publishing/ to match. ### 1. Start the release @@ -157,9 +155,7 @@ open an issue there titled `@conda-forge-admin, please update version`. > [!IMPORTANT] > The bot updates the version number and the checksum, and nothing else. **Check the recipe's > `host` and `run` requirements against the dependencies the release actually declares**, which are -> the `Requires-Dist` lines of the sdist on PyPI. Both releases so far needed this by hand: 1.0.0 -> because the build backend had moved from setuptools to hatchling, and 1.0.1 because the pandas -> lower bound had been raised. A maintainer can push the correction to the bot's branch. +> the `Requires-Dist` lines of the sdist on PyPI. A maintainer can push corrections to the bot's branch. Merging the feedstock pull request builds and uploads the package. It does not appear to a solver until the channel index is regenerated, which takes up to about an hour; the package is visible on @@ -177,6 +173,5 @@ from. [`.github/workflows/pull_request.yml`](.github/workflows/pull_request.yml) that is not a conventional commit header, but it cannot tell whether the type is the right one: a user facing change titled `chore:` still passes the check and is still dropped from the notes. -Read the generated changelog on the release pull request before merging it. Anything missing is -added there, into the section for the version being cut, not to `develop`; the section does not -exist until the release workflow generates it. +Read the generated changelog on the release pull request before merging it, and make any necessary +edits to the section for the version being cut.