diff --git a/autotest/test_version.py b/autotest/test_version.py new file mode 100644 index 00000000..3e59b355 --- /dev/null +++ b/autotest/test_version.py @@ -0,0 +1,271 @@ +import sys + +import pytest + +from modflow_devtools.version import get_version, set_version, update_file + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def project_dir(tmp_path): + """A minimal project directory with version.txt, meson.build, pixi.toml.""" + (tmp_path / "version.txt").write_text("1.0.0") + (tmp_path / "meson.build").write_text( + "project(\n 'testproj',\n version: '1.0.0',\n meson_version: '>= 1.0',\n)\n" + ) + (tmp_path / "pixi.toml").write_text( + '[project]\nname = "testproj"\nversion = "1.0.0"\n\n[dependencies]\n' + ) + return tmp_path + + +@pytest.fixture +def fortran_file(tmp_path): + """A file with a Fortran-style version string.""" + path = tmp_path / "src" / "prog.f" + path.parent.mkdir() + path.write_text(" PARAMETER (VERSION='1.0.0 01/01/2020')\n") + return path + + +# --------------------------------------------------------------------------- +# Unit tests: get_version +# --------------------------------------------------------------------------- + + +class TestGetVersion: + def test_reads_version(self, project_dir): + assert get_version(project_dir) == "1.0.0" + + def test_missing_version_txt(self, tmp_path): + with pytest.raises(FileNotFoundError): + get_version(tmp_path) + + def test_strips_whitespace(self, tmp_path): + (tmp_path / "version.txt").write_text(" 2.3.4\n") + assert get_version(tmp_path) == "2.3.4" + + +# --------------------------------------------------------------------------- +# Unit tests: _update_version_txt, _update_meson_build, _update_pixi_toml +# (tested indirectly through set_version) +# --------------------------------------------------------------------------- + + +class TestSetVersion: + def test_updates_all_three_files(self, project_dir): + set_version("2.0.0", project_dir) + assert (project_dir / "version.txt").read_text() == "2.0.0" + assert "version: '2.0.0'" in (project_dir / "meson.build").read_text() + assert 'version = "2.0.0"' in (project_dir / "pixi.toml").read_text() + + def test_does_not_modify_meson_version_line(self, project_dir): + set_version("2.0.0", project_dir) + meson = (project_dir / "meson.build").read_text() + assert "meson_version: '>= 1.0'" in meson + + def test_invalid_version_raises(self, project_dir): + with pytest.raises(ValueError, match="Invalid version"): + set_version("not-a-version", project_dir) + + def test_missing_version_txt_raises(self, tmp_path): + # No version.txt in tmp_path + (tmp_path / "meson.build").write_text("project(\n version: '1.0.0',\n)\n") + (tmp_path / "pixi.toml").write_text('[project]\nversion = "1.0.0"\n') + with pytest.raises(FileNotFoundError): + set_version("2.0.0", tmp_path) + + def test_missing_meson_build_warns(self, tmp_path, capsys): + (tmp_path / "version.txt").write_text("1.0.0") + (tmp_path / "pixi.toml").write_text('[project]\nversion = "1.0.0"\n') + set_version("2.0.0", tmp_path) + assert "meson.build" in capsys.readouterr().err + + def test_missing_pixi_toml_warns(self, tmp_path, capsys): + (tmp_path / "version.txt").write_text("1.0.0") + (tmp_path / "meson.build").write_text("project(\n version: '1.0.0',\n)\n") + set_version("2.0.0", tmp_path) + assert "pixi.toml" in capsys.readouterr().err + + +# --------------------------------------------------------------------------- +# Unit tests: dry_run +# --------------------------------------------------------------------------- + + +class TestDryRun: + def test_no_files_modified(self, project_dir, capsys): + set_version("9.9.9", project_dir, dry_run=True) + assert (project_dir / "version.txt").read_text() == "1.0.0" + assert "version: '1.0.0'" in (project_dir / "meson.build").read_text() + assert 'version = "1.0.0"' in (project_dir / "pixi.toml").read_text() + + def test_prints_expected_changes(self, project_dir, capsys): + set_version("9.9.9", project_dir, dry_run=True) + out = capsys.readouterr().out + assert "9.9.9" in out + assert "1.0.0" in out + + +# --------------------------------------------------------------------------- +# Unit tests: update_file +# --------------------------------------------------------------------------- + + +class TestUpdateFile: + def test_fortran_parameter_style(self, fortran_file): + pattern = r"PARAMETER \(VERSION='([^']+)'\)" + fmt = "PARAMETER (VERSION='{version} 06/25/2013')" + update_file(fortran_file, pattern, fmt, "2.0.0") + assert "PARAMETER (VERSION='2.0.0 06/25/2013')" in fortran_file.read_text() + + def test_provisional_suffix_preserved(self, tmp_path): + f = tmp_path / "prog.f90" + f.write_text(" version = '7.2.001 PROVISIONAL'\n") + pattern = r"version = '([^']+)'" + fmt = "version = '{version} PROVISIONAL'" + update_file(f, pattern, fmt, "7.2.002") + assert "version = '7.2.002 PROVISIONAL'" in f.read_text() + + def test_dry_run_no_modification(self, fortran_file, capsys): + pattern = r"PARAMETER \(VERSION='([^']+)'\)" + fmt = "PARAMETER (VERSION='{version}')" + original = fortran_file.read_text() + update_file(fortran_file, pattern, fmt, "9.9.9", dry_run=True) + assert fortran_file.read_text() == original + + def test_missing_file_raises(self, tmp_path): + with pytest.raises(FileNotFoundError): + update_file( + tmp_path / "nonexistent.f", r"VERSION='([^']+)'", "VERSION='{version}'", "1.0.0" + ) + + def test_no_capture_group_raises(self, fortran_file): + with pytest.raises(ValueError, match="exactly one capture group"): + update_file( + fortran_file, + r"PARAMETER \(VERSION='[^']+'\)", + "PARAMETER (VERSION='{version}')", + "1.0.0", + ) + + def test_multiple_capture_groups_raises(self, fortran_file): + with pytest.raises(ValueError, match="exactly one capture group"): + update_file(fortran_file, r"(PARAMETER) \(VERSION='([^']+)'\)", "{version}", "1.0.0") + + def test_format_missing_version_placeholder_raises(self, fortran_file): + with pytest.raises(ValueError, match="must contain"): + update_file( + fortran_file, + r"PARAMETER \(VERSION='([^']+)'\)", + "PARAMETER (VERSION='hardcoded')", + "1.0.0", + ) + + def test_pattern_not_found_warns(self, tmp_path, capsys): + f = tmp_path / "file.f" + f.write_text("no version here\n") + update_file(f, r"VERSION='([^']+)'", "VERSION='{version}'", "1.0.0") + assert "not found" in capsys.readouterr().err + + +# --------------------------------------------------------------------------- +# Integration tests: set_version with --file/--pattern/--format +# --------------------------------------------------------------------------- + + +class TestSetVersionWithFile: + def test_updates_all_files_including_fortran(self, project_dir, fortran_file): + pattern = r"PARAMETER \(VERSION='([^']+)'\)" + fmt = "PARAMETER (VERSION='{version}')" + set_version("2.0.0", project_dir, file=fortran_file, pattern=pattern, fmt=fmt) + assert (project_dir / "version.txt").read_text() == "2.0.0" + assert "VERSION='2.0.0'" in fortran_file.read_text() + + def test_file_without_pattern_raises(self, project_dir, fortran_file): + with pytest.raises(ValueError, match="--file requires"): + set_version("2.0.0", project_dir, file=fortran_file, pattern=None, fmt=None) + + +# --------------------------------------------------------------------------- +# Integration tests: CLI via __main__ +# --------------------------------------------------------------------------- + + +class TestCLI: + def _run(self, monkeypatch, capsys, *argv): + from modflow_devtools.version.__main__ import main + + monkeypatch.setattr(sys, "argv", ["mf version", *argv]) + try: + main() + except SystemExit as e: + return e.code, capsys.readouterr() + return 0, capsys.readouterr() + + def test_get(self, project_dir, monkeypatch, capsys): + code, captured = self._run(monkeypatch, capsys, "get", "--root", str(project_dir)) + assert code == 0 + assert captured.out.strip() == "1.0.0" + + def test_get_root_option(self, project_dir, monkeypatch, capsys): + code, captured = self._run(monkeypatch, capsys, "get", "--root", str(project_dir)) + assert code == 0 + assert "1.0.0" in captured.out + + def test_get_missing_version_txt(self, tmp_path, monkeypatch, capsys): + code, captured = self._run(monkeypatch, capsys, "get", "--root", str(tmp_path)) + assert code == 1 + assert "Error" in captured.err + + def test_set(self, project_dir, monkeypatch, capsys): + code, _ = self._run(monkeypatch, capsys, "set", "2.0.0", "--root", str(project_dir)) + assert code == 0 + assert (project_dir / "version.txt").read_text() == "2.0.0" + + def test_set_dry_run(self, project_dir, monkeypatch, capsys): + code, captured = self._run( + monkeypatch, capsys, "set", "9.9.9", "--root", str(project_dir), "--dry-run" + ) + assert code == 0 + assert (project_dir / "version.txt").read_text() == "1.0.0" + assert "9.9.9" in captured.out + + def test_set_with_file(self, project_dir, fortran_file, monkeypatch, capsys): + code, _ = self._run( + monkeypatch, + capsys, + "set", + "2.0.0", + "--root", + str(project_dir), + "--file", + str(fortran_file), + "--pattern", + r"PARAMETER \(VERSION='([^']+)'\)", + "--format", + "PARAMETER (VERSION='{version}')", + ) + assert code == 0 + assert "VERSION='2.0.0'" in fortran_file.read_text() + + def test_set_file_missing_pattern_errors(self, project_dir, fortran_file, monkeypatch, capsys): + code, _ = self._run( + monkeypatch, + capsys, + "set", + "2.0.0", + "--root", + str(project_dir), + "--file", + str(fortran_file), + # --pattern and --format omitted + ) + assert code != 0 + + def test_no_command_exits(self, monkeypatch, capsys): + code, _ = self._run(monkeypatch, capsys) + assert code == 1 diff --git a/modflow_devtools/cli.py b/modflow_devtools/cli.py index 75f0555f..b4c746e3 100644 --- a/modflow_devtools/cli.py +++ b/modflow_devtools/cli.py @@ -11,6 +11,8 @@ mf programs install mf programs uninstall mf programs history + mf version get + mf version set """ import argparse @@ -31,6 +33,9 @@ def main(): # Programs subcommand subparsers.add_parser("programs", help="Manage MODFLOW program registries") + # Version subcommand + subparsers.add_parser("version", help="Manage project versions") + # Parse only the first level to determine which submodule to invoke args, remaining = parser.parse_known_args() @@ -50,6 +55,11 @@ def main(): sys.argv = ["mf programs", *remaining] programs_main() + elif args.subcommand == "version": + from modflow_devtools.version.__main__ import main as version_main + + sys.argv = ["mf version", *remaining] + version_main() if __name__ == "__main__": diff --git a/modflow_devtools/version/__init__.py b/modflow_devtools/version/__init__.py new file mode 100644 index 00000000..e2f563e4 --- /dev/null +++ b/modflow_devtools/version/__init__.py @@ -0,0 +1,232 @@ +""" +Version management API. + +Provides functions to read and update version strings in version.txt, +meson.build, and pixi.toml. Also supports updating version strings in +arbitrary files via regex pattern and format string. +""" + +import re +import sys +from pathlib import Path + + +def get_version(root: Path) -> str: + """ + Read the current version from version.txt. + + Parameters + ---------- + root : Path + Project root directory containing version.txt. + + Returns + ------- + str + Version string. + + Raises + ------ + FileNotFoundError + If version.txt does not exist. + """ + path = root / "version.txt" + if not path.exists(): + raise FileNotFoundError(f"version.txt not found in {root}") + return path.read_text().strip() + + +def _update_version_txt(root: Path, version: str, dry_run: bool = False) -> None: + path = root / "version.txt" + if not path.exists(): + raise FileNotFoundError(f"version.txt not found in {root}") + old = path.read_text().strip() + if dry_run: + print(f" version.txt: {old} -> {version}") + return + path.write_text(version) + print(f"Updated version.txt: {old} -> {version}") + + +def _update_meson_build(root: Path, version: str, dry_run: bool = False) -> None: + path = root / "meson.build" + if not path.exists(): + print(f"Warning: meson.build not found in {root}, skipping", file=sys.stderr) + return + + lines = path.read_text().splitlines(keepends=True) + new_lines = [] + old_version = None + for line in lines: + if "version:" in line and "meson_version:" not in line: + m = re.search(r"version:\s*'([^']*)'", line) + if m: + old_version = m.group(1) + line = line[: m.start(1)] + version + line[m.end(1) :] + new_lines.append(line) + + if old_version is None: + print("Warning: version field not found in meson.build, skipping", file=sys.stderr) + return + + if dry_run: + print(f" meson.build: {old_version} -> {version}") + return + + path.write_text("".join(new_lines)) + print(f"Updated meson.build: {old_version} -> {version}") + + +def _update_pixi_toml(root: Path, version: str, dry_run: bool = False) -> None: + path = root / "pixi.toml" + if not path.exists(): + print(f"Warning: pixi.toml not found in {root}, skipping", file=sys.stderr) + return + + text = path.read_text() + pattern = re.compile(r'^(version\s*=\s*")[^"]*(")', re.MULTILINE) + m = pattern.search(text) + if not m: + print("Warning: version field not found in pixi.toml, skipping", file=sys.stderr) + return + + old_m = re.search(r'^version\s*=\s*"([^"]*)"', text, re.MULTILINE) + old_version = old_m.group(1) if old_m else "?" + + new_text = pattern.sub(rf"\g<1>{version}\g<2>", text) + + if dry_run: + print(f" pixi.toml: {old_version} -> {version}") + return + + path.write_text(new_text) + print(f"Updated pixi.toml: {old_version} -> {version}") + + +def update_file( + path: Path, + pattern: str, + fmt: str, + version: str, + dry_run: bool = False, +) -> None: + """ + Update a version string in an arbitrary file using a regex pattern and + a format string. + + Parameters + ---------- + path : Path + Path to the file to update. + pattern : str + Regular expression with exactly one capture group matching the + current version string within the line. + fmt : str + Python format string for the replacement, must contain ``{version}``. + Replaces the entire regex match (not just the captured group). + version : str + New version string. + dry_run : bool + If True, print what would change without modifying the file. + + Raises + ------ + FileNotFoundError + If the file does not exist. + ValueError + If the pattern does not have exactly one capture group, or the + format string does not contain ``{version}``. + """ + if not path.exists(): + raise FileNotFoundError(f"File not found: {path}") + + compiled = re.compile(pattern) + + if compiled.groups != 1: + raise ValueError( + f"Pattern must have exactly one capture group, got {compiled.groups}: {pattern!r}" + ) + if "{version}" not in fmt: + raise ValueError(f"Format string must contain {{version}}: {fmt!r}") + + text = path.read_text() + m = compiled.search(text) + if not m: + print(f"Warning: pattern not found in {path.name}, skipping", file=sys.stderr) + return + + old_version = m.group(1) + replacement = fmt.format(version=version) + new_text = compiled.sub(replacement, text) + + if dry_run: + print(f" {path.name}: {old_version} -> {version}") + return + + path.write_text(new_text) + print(f"Updated {path.name}: {old_version} -> {version}") + + +def set_version( + version: str, + root: Path, + dry_run: bool = False, + file: Path | None = None, + pattern: str | None = None, + fmt: str | None = None, +) -> None: + """ + Set the version in version.txt, meson.build, and pixi.toml. + + Optionally also update an additional file using a regex pattern and + format string (e.g. a Fortran source file). + + Parameters + ---------- + version : str + New version string. Must be a valid PEP 440 version. + root : Path + Project root directory. + dry_run : bool + If True, print what would change without modifying any files. + file : Path, optional + Additional file to update. + pattern : str, optional + Regex pattern for the additional file (required if file is given). + fmt : str, optional + Format string for the additional file replacement (required if file is given). + + Raises + ------ + ValueError + If the version string is not valid, or if file is given without + pattern/fmt, or if the pattern/fmt fail validation. + FileNotFoundError + If version.txt does not exist in root, or if the additional file + does not exist. + """ + from packaging.version import InvalidVersion, Version + + try: + Version(version) + except InvalidVersion as e: + raise ValueError(f"Invalid version {version!r}: {e}") from e + + if dry_run: + print("Dry run - no files will be modified:") + + _update_version_txt(root, version, dry_run) + _update_meson_build(root, version, dry_run) + _update_pixi_toml(root, version, dry_run) + + if file is not None: + if pattern is None or fmt is None: + raise ValueError("--file requires both --pattern and --format") + update_file(file, pattern, fmt, version, dry_run) + + +__all__ = [ + "get_version", + "set_version", + "update_file", +] diff --git a/modflow_devtools/version/__main__.py b/modflow_devtools/version/__main__.py new file mode 100644 index 00000000..71b1fce7 --- /dev/null +++ b/modflow_devtools/version/__main__.py @@ -0,0 +1,111 @@ +""" +Command-line interface for the Version API. + +Commands: + get Show the current version from version.txt + set Set the version in version.txt, meson.build, and pixi.toml +""" + +import argparse +import sys +from pathlib import Path + +from . import get_version, set_version + + +def cmd_get(args): + """Get command handler.""" + root = Path(args.root) if args.root else Path.cwd() + try: + print(get_version(root)) + except FileNotFoundError as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) + + +def cmd_set(args): + """Set command handler.""" + root = Path(args.root) if args.root else Path.cwd() + file = (root / args.file) if args.file else None + try: + set_version( + version=args.version, + root=root, + dry_run=args.dry_run, + file=file, + pattern=args.pattern, + fmt=args.format, + ) + except (ValueError, FileNotFoundError) as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) + + +def main(): + """Main CLI entry point.""" + parser = argparse.ArgumentParser( + prog="mf version", + description="Manage project versions", + ) + subparsers = parser.add_subparsers(dest="command", help="Available commands") + + # get command + get_parser = subparsers.add_parser("get", help="Show the current version") + get_parser.add_argument( + "--root", + help="Project root directory (default: current working directory)", + ) + + # set command + set_parser = subparsers.add_parser("set", help="Set the version") + set_parser.add_argument("version", help="Version string (PEP 440)") + set_parser.add_argument( + "--root", + help="Project root directory (default: current working directory)", + ) + set_parser.add_argument( + "--dry-run", + action="store_true", + help="Show what would be changed without modifying any files", + ) + set_parser.add_argument( + "--file", + help="Additional file to update (relative to --root)", + ) + set_parser.add_argument( + "--pattern", + help=( + "Regex with one capture group matching the current version string " + "(required with --file)" + ), + ) + set_parser.add_argument( + "--format", + dest="format", + help=( + "Format string for the replacement with a {version} placeholder (required with --file)" + ), + ) + + args = parser.parse_args() + + if not args.command: + parser.print_help() + sys.exit(1) + + if args.command == "set": + file_args = [args.file, args.pattern, args.format] + if any(file_args) and not all(file_args): + parser.error("--file, --pattern, and --format must all be provided together") + + if args.command == "get": + cmd_get(args) + elif args.command == "set": + cmd_set(args) + else: + parser.print_help() + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml index 459e5f51..05080d32 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -69,6 +69,11 @@ docs = [ "sphinx-rtd-theme", "myst-parser" ] +version = [ + "packaging", + "tomli", + "tomli-w" +] dfn = [ "boltons", "tomli", @@ -82,7 +87,7 @@ models = [ "tomli", "tomli-w" ] -dev = ["modflow-devtools[lint,test,docs,dfn,models]"] +dev = ["modflow-devtools[lint,test,docs,version,dfn,models]"] [dependency-groups] build = [ @@ -115,6 +120,11 @@ docs = [ "sphinx-rtd-theme", "myst-parser" ] +version = [ + "packaging", + "tomli", + "tomli-w" +] dfn = [ "boltons", "tomli", @@ -133,6 +143,7 @@ dev = [ {include-group = "lint"}, {include-group = "test"}, {include-group = "docs"}, + {include-group = "version"}, {include-group = "dfn"}, {include-group = "models"}, ]