Skip to content

Commit f9e26de

Browse files
authored
feat(version): add project version management tool (MODFLOW-ORG#284)
Support version string substitutions in - version.txt (conventional source of truth) - pixi.toml - meson.build As well as arbitrary substitutions into other files. This feels like reinventing the wheel, but we can't use setuptools_scm or versioneer with Fortran projects, AFAIK, only Python projects. This will at least prevent proliferation of version update scripts
1 parent 18af51a commit f9e26de

5 files changed

Lines changed: 636 additions & 1 deletion

File tree

autotest/test_version.py

Lines changed: 271 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,271 @@
1+
import sys
2+
3+
import pytest
4+
5+
from modflow_devtools.version import get_version, set_version, update_file
6+
7+
# ---------------------------------------------------------------------------
8+
# Fixtures
9+
# ---------------------------------------------------------------------------
10+
11+
12+
@pytest.fixture
13+
def project_dir(tmp_path):
14+
"""A minimal project directory with version.txt, meson.build, pixi.toml."""
15+
(tmp_path / "version.txt").write_text("1.0.0")
16+
(tmp_path / "meson.build").write_text(
17+
"project(\n 'testproj',\n version: '1.0.0',\n meson_version: '>= 1.0',\n)\n"
18+
)
19+
(tmp_path / "pixi.toml").write_text(
20+
'[project]\nname = "testproj"\nversion = "1.0.0"\n\n[dependencies]\n'
21+
)
22+
return tmp_path
23+
24+
25+
@pytest.fixture
26+
def fortran_file(tmp_path):
27+
"""A file with a Fortran-style version string."""
28+
path = tmp_path / "src" / "prog.f"
29+
path.parent.mkdir()
30+
path.write_text(" PARAMETER (VERSION='1.0.0 01/01/2020')\n")
31+
return path
32+
33+
34+
# ---------------------------------------------------------------------------
35+
# Unit tests: get_version
36+
# ---------------------------------------------------------------------------
37+
38+
39+
class TestGetVersion:
40+
def test_reads_version(self, project_dir):
41+
assert get_version(project_dir) == "1.0.0"
42+
43+
def test_missing_version_txt(self, tmp_path):
44+
with pytest.raises(FileNotFoundError):
45+
get_version(tmp_path)
46+
47+
def test_strips_whitespace(self, tmp_path):
48+
(tmp_path / "version.txt").write_text(" 2.3.4\n")
49+
assert get_version(tmp_path) == "2.3.4"
50+
51+
52+
# ---------------------------------------------------------------------------
53+
# Unit tests: _update_version_txt, _update_meson_build, _update_pixi_toml
54+
# (tested indirectly through set_version)
55+
# ---------------------------------------------------------------------------
56+
57+
58+
class TestSetVersion:
59+
def test_updates_all_three_files(self, project_dir):
60+
set_version("2.0.0", project_dir)
61+
assert (project_dir / "version.txt").read_text() == "2.0.0"
62+
assert "version: '2.0.0'" in (project_dir / "meson.build").read_text()
63+
assert 'version = "2.0.0"' in (project_dir / "pixi.toml").read_text()
64+
65+
def test_does_not_modify_meson_version_line(self, project_dir):
66+
set_version("2.0.0", project_dir)
67+
meson = (project_dir / "meson.build").read_text()
68+
assert "meson_version: '>= 1.0'" in meson
69+
70+
def test_invalid_version_raises(self, project_dir):
71+
with pytest.raises(ValueError, match="Invalid version"):
72+
set_version("not-a-version", project_dir)
73+
74+
def test_missing_version_txt_raises(self, tmp_path):
75+
# No version.txt in tmp_path
76+
(tmp_path / "meson.build").write_text("project(\n version: '1.0.0',\n)\n")
77+
(tmp_path / "pixi.toml").write_text('[project]\nversion = "1.0.0"\n')
78+
with pytest.raises(FileNotFoundError):
79+
set_version("2.0.0", tmp_path)
80+
81+
def test_missing_meson_build_warns(self, tmp_path, capsys):
82+
(tmp_path / "version.txt").write_text("1.0.0")
83+
(tmp_path / "pixi.toml").write_text('[project]\nversion = "1.0.0"\n')
84+
set_version("2.0.0", tmp_path)
85+
assert "meson.build" in capsys.readouterr().err
86+
87+
def test_missing_pixi_toml_warns(self, tmp_path, capsys):
88+
(tmp_path / "version.txt").write_text("1.0.0")
89+
(tmp_path / "meson.build").write_text("project(\n version: '1.0.0',\n)\n")
90+
set_version("2.0.0", tmp_path)
91+
assert "pixi.toml" in capsys.readouterr().err
92+
93+
94+
# ---------------------------------------------------------------------------
95+
# Unit tests: dry_run
96+
# ---------------------------------------------------------------------------
97+
98+
99+
class TestDryRun:
100+
def test_no_files_modified(self, project_dir, capsys):
101+
set_version("9.9.9", project_dir, dry_run=True)
102+
assert (project_dir / "version.txt").read_text() == "1.0.0"
103+
assert "version: '1.0.0'" in (project_dir / "meson.build").read_text()
104+
assert 'version = "1.0.0"' in (project_dir / "pixi.toml").read_text()
105+
106+
def test_prints_expected_changes(self, project_dir, capsys):
107+
set_version("9.9.9", project_dir, dry_run=True)
108+
out = capsys.readouterr().out
109+
assert "9.9.9" in out
110+
assert "1.0.0" in out
111+
112+
113+
# ---------------------------------------------------------------------------
114+
# Unit tests: update_file
115+
# ---------------------------------------------------------------------------
116+
117+
118+
class TestUpdateFile:
119+
def test_fortran_parameter_style(self, fortran_file):
120+
pattern = r"PARAMETER \(VERSION='([^']+)'\)"
121+
fmt = "PARAMETER (VERSION='{version} 06/25/2013')"
122+
update_file(fortran_file, pattern, fmt, "2.0.0")
123+
assert "PARAMETER (VERSION='2.0.0 06/25/2013')" in fortran_file.read_text()
124+
125+
def test_provisional_suffix_preserved(self, tmp_path):
126+
f = tmp_path / "prog.f90"
127+
f.write_text(" version = '7.2.001 PROVISIONAL'\n")
128+
pattern = r"version = '([^']+)'"
129+
fmt = "version = '{version} PROVISIONAL'"
130+
update_file(f, pattern, fmt, "7.2.002")
131+
assert "version = '7.2.002 PROVISIONAL'" in f.read_text()
132+
133+
def test_dry_run_no_modification(self, fortran_file, capsys):
134+
pattern = r"PARAMETER \(VERSION='([^']+)'\)"
135+
fmt = "PARAMETER (VERSION='{version}')"
136+
original = fortran_file.read_text()
137+
update_file(fortran_file, pattern, fmt, "9.9.9", dry_run=True)
138+
assert fortran_file.read_text() == original
139+
140+
def test_missing_file_raises(self, tmp_path):
141+
with pytest.raises(FileNotFoundError):
142+
update_file(
143+
tmp_path / "nonexistent.f", r"VERSION='([^']+)'", "VERSION='{version}'", "1.0.0"
144+
)
145+
146+
def test_no_capture_group_raises(self, fortran_file):
147+
with pytest.raises(ValueError, match="exactly one capture group"):
148+
update_file(
149+
fortran_file,
150+
r"PARAMETER \(VERSION='[^']+'\)",
151+
"PARAMETER (VERSION='{version}')",
152+
"1.0.0",
153+
)
154+
155+
def test_multiple_capture_groups_raises(self, fortran_file):
156+
with pytest.raises(ValueError, match="exactly one capture group"):
157+
update_file(fortran_file, r"(PARAMETER) \(VERSION='([^']+)'\)", "{version}", "1.0.0")
158+
159+
def test_format_missing_version_placeholder_raises(self, fortran_file):
160+
with pytest.raises(ValueError, match="must contain"):
161+
update_file(
162+
fortran_file,
163+
r"PARAMETER \(VERSION='([^']+)'\)",
164+
"PARAMETER (VERSION='hardcoded')",
165+
"1.0.0",
166+
)
167+
168+
def test_pattern_not_found_warns(self, tmp_path, capsys):
169+
f = tmp_path / "file.f"
170+
f.write_text("no version here\n")
171+
update_file(f, r"VERSION='([^']+)'", "VERSION='{version}'", "1.0.0")
172+
assert "not found" in capsys.readouterr().err
173+
174+
175+
# ---------------------------------------------------------------------------
176+
# Integration tests: set_version with --file/--pattern/--format
177+
# ---------------------------------------------------------------------------
178+
179+
180+
class TestSetVersionWithFile:
181+
def test_updates_all_files_including_fortran(self, project_dir, fortran_file):
182+
pattern = r"PARAMETER \(VERSION='([^']+)'\)"
183+
fmt = "PARAMETER (VERSION='{version}')"
184+
set_version("2.0.0", project_dir, file=fortran_file, pattern=pattern, fmt=fmt)
185+
assert (project_dir / "version.txt").read_text() == "2.0.0"
186+
assert "VERSION='2.0.0'" in fortran_file.read_text()
187+
188+
def test_file_without_pattern_raises(self, project_dir, fortran_file):
189+
with pytest.raises(ValueError, match="--file requires"):
190+
set_version("2.0.0", project_dir, file=fortran_file, pattern=None, fmt=None)
191+
192+
193+
# ---------------------------------------------------------------------------
194+
# Integration tests: CLI via __main__
195+
# ---------------------------------------------------------------------------
196+
197+
198+
class TestCLI:
199+
def _run(self, monkeypatch, capsys, *argv):
200+
from modflow_devtools.version.__main__ import main
201+
202+
monkeypatch.setattr(sys, "argv", ["mf version", *argv])
203+
try:
204+
main()
205+
except SystemExit as e:
206+
return e.code, capsys.readouterr()
207+
return 0, capsys.readouterr()
208+
209+
def test_get(self, project_dir, monkeypatch, capsys):
210+
code, captured = self._run(monkeypatch, capsys, "get", "--root", str(project_dir))
211+
assert code == 0
212+
assert captured.out.strip() == "1.0.0"
213+
214+
def test_get_root_option(self, project_dir, monkeypatch, capsys):
215+
code, captured = self._run(monkeypatch, capsys, "get", "--root", str(project_dir))
216+
assert code == 0
217+
assert "1.0.0" in captured.out
218+
219+
def test_get_missing_version_txt(self, tmp_path, monkeypatch, capsys):
220+
code, captured = self._run(monkeypatch, capsys, "get", "--root", str(tmp_path))
221+
assert code == 1
222+
assert "Error" in captured.err
223+
224+
def test_set(self, project_dir, monkeypatch, capsys):
225+
code, _ = self._run(monkeypatch, capsys, "set", "2.0.0", "--root", str(project_dir))
226+
assert code == 0
227+
assert (project_dir / "version.txt").read_text() == "2.0.0"
228+
229+
def test_set_dry_run(self, project_dir, monkeypatch, capsys):
230+
code, captured = self._run(
231+
monkeypatch, capsys, "set", "9.9.9", "--root", str(project_dir), "--dry-run"
232+
)
233+
assert code == 0
234+
assert (project_dir / "version.txt").read_text() == "1.0.0"
235+
assert "9.9.9" in captured.out
236+
237+
def test_set_with_file(self, project_dir, fortran_file, monkeypatch, capsys):
238+
code, _ = self._run(
239+
monkeypatch,
240+
capsys,
241+
"set",
242+
"2.0.0",
243+
"--root",
244+
str(project_dir),
245+
"--file",
246+
str(fortran_file),
247+
"--pattern",
248+
r"PARAMETER \(VERSION='([^']+)'\)",
249+
"--format",
250+
"PARAMETER (VERSION='{version}')",
251+
)
252+
assert code == 0
253+
assert "VERSION='2.0.0'" in fortran_file.read_text()
254+
255+
def test_set_file_missing_pattern_errors(self, project_dir, fortran_file, monkeypatch, capsys):
256+
code, _ = self._run(
257+
monkeypatch,
258+
capsys,
259+
"set",
260+
"2.0.0",
261+
"--root",
262+
str(project_dir),
263+
"--file",
264+
str(fortran_file),
265+
# --pattern and --format omitted
266+
)
267+
assert code != 0
268+
269+
def test_no_command_exits(self, monkeypatch, capsys):
270+
code, _ = self._run(monkeypatch, capsys)
271+
assert code == 1

modflow_devtools/cli.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
mf programs install <program>
1212
mf programs uninstall <program>
1313
mf programs history
14+
mf version get
15+
mf version set <version>
1416
"""
1517

1618
import argparse
@@ -31,6 +33,9 @@ def main():
3133
# Programs subcommand
3234
subparsers.add_parser("programs", help="Manage MODFLOW program registries")
3335

36+
# Version subcommand
37+
subparsers.add_parser("version", help="Manage project versions")
38+
3439
# Parse only the first level to determine which submodule to invoke
3540
args, remaining = parser.parse_known_args()
3641

@@ -50,6 +55,11 @@ def main():
5055

5156
sys.argv = ["mf programs", *remaining]
5257
programs_main()
58+
elif args.subcommand == "version":
59+
from modflow_devtools.version.__main__ import main as version_main
60+
61+
sys.argv = ["mf version", *remaining]
62+
version_main()
5363

5464

5565
if __name__ == "__main__":

0 commit comments

Comments
 (0)