martinpdes-maintenance 0.2.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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Martin Poinsinet de Sivry-Houle
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,119 @@
1
+ Metadata-Version: 2.4
2
+ Name: martinpdes-maintenance
3
+ Version: 0.2.0
4
+ Summary: Shared release preparation and metadata checks for scientific Python projects
5
+ License-Expression: MIT
6
+ Requires-Python: >=3.10
7
+ Description-Content-Type: text/markdown
8
+ License-File: LICENSE
9
+ Requires-Dist: tomli>=2; python_version < "3.11"
10
+ Provides-Extra: testing
11
+ Requires-Dist: pytest<10,>=8; extra == "testing"
12
+ Requires-Dist: setuptools-scm<10,>=8; extra == "testing"
13
+ Provides-Extra: scm
14
+ Requires-Dist: setuptools-scm<10,>=8; extra == "scm"
15
+ Provides-Extra: dev
16
+ Requires-Dist: pytest<10,>=8; extra == "dev"
17
+ Requires-Dist: ruff==0.17.2; extra == "dev"
18
+ Requires-Dist: build<2,>=1; extra == "dev"
19
+ Requires-Dist: twine<7,>=6; extra == "dev"
20
+ Dynamic: license-file
21
+
22
+ # martinpdes-maintenance
23
+
24
+ Shared release preparation for the MartinPdeS scientific Python ecosystem.
25
+ The distribution is `martinpdes-maintenance`; its command is `mps-maintenance`.
26
+ Python 3.10 and newer are supported. The Python package lives directly in
27
+ `martinpdes_maintenance/`, following the flat layout used by MartinPdeS-docs.
28
+
29
+ ## Install
30
+
31
+ From a source checkout:
32
+
33
+ ```sh
34
+ python -m pip install -e ".[dev,scm]"
35
+ ```
36
+
37
+ This repository has not yet been published on PyPI. After publication, consumers
38
+ can pin a reviewed version in their release development extra. The tool is not
39
+ a runtime dependency of scientific packages.
40
+
41
+ ## Configure a consuming project
42
+
43
+ Add explicit settings to the consumer's `pyproject.toml`:
44
+
45
+ ```toml
46
+ [tool.mps-maintenance]
47
+ version-file = "DeepPeak/_version.py"
48
+ version-method = "setuptools-scm"
49
+ conda-recipe = "conda.recipe/meta.yaml"
50
+ ```
51
+
52
+ Use `version-method = "literal"` (the default) to update a simple
53
+ `__version__ = version = "X.Y.Z"` assignment directly. SCM mode requires this
54
+ tool's `scm` extra and generates the file using setuptools-scm. Optional settings:
55
+
56
+ ```toml
57
+ zenodo = ".zenodo.json"
58
+ citation = "CITATION.cff"
59
+ ```
60
+
61
+ All configured files must already exist inside the repository. Unknown settings
62
+ are errors. Project versions must be static in `[project].version`; Conda recipes
63
+ must contain one literal `version:` field. Jinja-templated recipe versions and
64
+ prerelease creation are not supported in this initial implementation. Citation
65
+ updates currently change the version only, preserving existing date behavior.
66
+
67
+ ## Commands
68
+
69
+ Run from the consumer repository root, or supply `--root /path/to/repository`
70
+ before the subcommand:
71
+
72
+ ```sh
73
+ mps-maintenance check
74
+ mps-maintenance check --version v1.2.3
75
+ mps-maintenance next patch
76
+ mps-maintenance tag v1.2.4 --dry-run
77
+ ```
78
+
79
+ `next` selects the highest stable `vMAJOR.MINOR.PATCH` tag available locally,
80
+ including tags not reachable from HEAD. It ignores prerelease and unrelated tags;
81
+ fetch tags first when remote releases may be missing. It fails if no stable tag exists.
82
+
83
+ `tag` requires a clean, full Git checkout and an unused stable tag. It calculates
84
+ all replacements before writing, prints a diff, validates updated versions,
85
+ stages the release files, commits, then creates an annotated tag. `--dry-run`
86
+ prints the plan without writing repository files, staging, committing, or tagging.
87
+ Zenodo publication dates default to the local calendar date; `--date YYYY-MM-DD`
88
+ sets a reproducible date explicitly.
89
+
90
+ To actually create the release commit and tag, omit `--dry-run`. Nothing is
91
+ pushed. Existing Makefile push behavior should remain in the consumer repository.
92
+ A commit or tag failure leaves state for inspection: use `git status` and
93
+ `git log -1`, fix the cause, and recover explicitly. A tag failure can leave a
94
+ successful release commit. Do not blindly rerun or reset unrelated work.
95
+
96
+ ## Migration
97
+
98
+ 1. Install a reviewed tool revision in the release environment.
99
+ 2. Configure every file the old scripts check or update.
100
+ 3. Compare `check`, `next`, and dry-run output with the old scripts.
101
+ 4. Replace old script bodies with thin CLI wrappers, preserving Makefile behavior.
102
+ 5. Verify in a disposable clone before removing compatibility wrappers.
103
+
104
+ DeepPeak uses SCM generation; PyMieSim and PackLab additionally update Zenodo;
105
+ BornSim uses literal generation plus Zenodo and Citation metadata. No consumer
106
+ repositories are migrated automatically.
107
+
108
+ ## Development
109
+
110
+ ```sh
111
+ python -m pytest
112
+ python -m ruff check .
113
+ python -m ruff format --check .
114
+ python -m build
115
+ python -m twine check dist/*
116
+ ```
117
+
118
+ Tests create local temporary Git repositories and never push or publish.
119
+ See CONTRIBUTING.md for release bootstrapping.
@@ -0,0 +1,98 @@
1
+ # martinpdes-maintenance
2
+
3
+ Shared release preparation for the MartinPdeS scientific Python ecosystem.
4
+ The distribution is `martinpdes-maintenance`; its command is `mps-maintenance`.
5
+ Python 3.10 and newer are supported. The Python package lives directly in
6
+ `martinpdes_maintenance/`, following the flat layout used by MartinPdeS-docs.
7
+
8
+ ## Install
9
+
10
+ From a source checkout:
11
+
12
+ ```sh
13
+ python -m pip install -e ".[dev,scm]"
14
+ ```
15
+
16
+ This repository has not yet been published on PyPI. After publication, consumers
17
+ can pin a reviewed version in their release development extra. The tool is not
18
+ a runtime dependency of scientific packages.
19
+
20
+ ## Configure a consuming project
21
+
22
+ Add explicit settings to the consumer's `pyproject.toml`:
23
+
24
+ ```toml
25
+ [tool.mps-maintenance]
26
+ version-file = "DeepPeak/_version.py"
27
+ version-method = "setuptools-scm"
28
+ conda-recipe = "conda.recipe/meta.yaml"
29
+ ```
30
+
31
+ Use `version-method = "literal"` (the default) to update a simple
32
+ `__version__ = version = "X.Y.Z"` assignment directly. SCM mode requires this
33
+ tool's `scm` extra and generates the file using setuptools-scm. Optional settings:
34
+
35
+ ```toml
36
+ zenodo = ".zenodo.json"
37
+ citation = "CITATION.cff"
38
+ ```
39
+
40
+ All configured files must already exist inside the repository. Unknown settings
41
+ are errors. Project versions must be static in `[project].version`; Conda recipes
42
+ must contain one literal `version:` field. Jinja-templated recipe versions and
43
+ prerelease creation are not supported in this initial implementation. Citation
44
+ updates currently change the version only, preserving existing date behavior.
45
+
46
+ ## Commands
47
+
48
+ Run from the consumer repository root, or supply `--root /path/to/repository`
49
+ before the subcommand:
50
+
51
+ ```sh
52
+ mps-maintenance check
53
+ mps-maintenance check --version v1.2.3
54
+ mps-maintenance next patch
55
+ mps-maintenance tag v1.2.4 --dry-run
56
+ ```
57
+
58
+ `next` selects the highest stable `vMAJOR.MINOR.PATCH` tag available locally,
59
+ including tags not reachable from HEAD. It ignores prerelease and unrelated tags;
60
+ fetch tags first when remote releases may be missing. It fails if no stable tag exists.
61
+
62
+ `tag` requires a clean, full Git checkout and an unused stable tag. It calculates
63
+ all replacements before writing, prints a diff, validates updated versions,
64
+ stages the release files, commits, then creates an annotated tag. `--dry-run`
65
+ prints the plan without writing repository files, staging, committing, or tagging.
66
+ Zenodo publication dates default to the local calendar date; `--date YYYY-MM-DD`
67
+ sets a reproducible date explicitly.
68
+
69
+ To actually create the release commit and tag, omit `--dry-run`. Nothing is
70
+ pushed. Existing Makefile push behavior should remain in the consumer repository.
71
+ A commit or tag failure leaves state for inspection: use `git status` and
72
+ `git log -1`, fix the cause, and recover explicitly. A tag failure can leave a
73
+ successful release commit. Do not blindly rerun or reset unrelated work.
74
+
75
+ ## Migration
76
+
77
+ 1. Install a reviewed tool revision in the release environment.
78
+ 2. Configure every file the old scripts check or update.
79
+ 3. Compare `check`, `next`, and dry-run output with the old scripts.
80
+ 4. Replace old script bodies with thin CLI wrappers, preserving Makefile behavior.
81
+ 5. Verify in a disposable clone before removing compatibility wrappers.
82
+
83
+ DeepPeak uses SCM generation; PyMieSim and PackLab additionally update Zenodo;
84
+ BornSim uses literal generation plus Zenodo and Citation metadata. No consumer
85
+ repositories are migrated automatically.
86
+
87
+ ## Development
88
+
89
+ ```sh
90
+ python -m pytest
91
+ python -m ruff check .
92
+ python -m ruff format --check .
93
+ python -m build
94
+ python -m twine check dist/*
95
+ ```
96
+
97
+ Tests create local temporary Git repositories and never push or publish.
98
+ See CONTRIBUTING.md for release bootstrapping.
@@ -0,0 +1 @@
1
+ """Shared, explicitly configured repository maintenance tools."""
@@ -0,0 +1,223 @@
1
+ """Command-line release operations; publishing remains the caller's responsibility."""
2
+
3
+ import argparse
4
+ import difflib
5
+ import json
6
+ import os
7
+ import re
8
+ import subprocess
9
+ import sys
10
+ import tempfile
11
+ from datetime import date
12
+ from pathlib import Path
13
+
14
+ try:
15
+ import tomllib
16
+ except ModuleNotFoundError:
17
+ import tomli as tomllib
18
+
19
+ TAG = re.compile(r"v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)")
20
+
21
+
22
+ def git(root, *args):
23
+ """Run Git in the explicit repository and return its output."""
24
+ return subprocess.run(
25
+ ["git", "-C", str(root), *args], check=True, capture_output=True, text=True
26
+ ).stdout.strip()
27
+
28
+
29
+ def load(root):
30
+ """Load project metadata and validate explicitly configured release paths."""
31
+ project = tomllib.loads((root / "pyproject.toml").read_text())
32
+ config = project.get("tool", {}).get("mps-maintenance", {})
33
+ allowed = {"version-file", "version-method", "conda-recipe", "zenodo", "citation"}
34
+ if not config or set(config) - allowed:
35
+ raise ValueError("Configure [tool.mps-maintenance]; unknown settings are not allowed")
36
+ if config.get("version-method", "literal") not in {"literal", "setuptools-scm"}:
37
+ raise ValueError("version-method must be literal or setuptools-scm")
38
+ for key in allowed - {"version-method"}:
39
+ if key not in config:
40
+ continue
41
+ value = config[key]
42
+ if not isinstance(value, str) or not value:
43
+ raise ValueError(f"{key} must be a nonempty relative path")
44
+ path = root / value
45
+ if Path(value).is_absolute() or not path.resolve().is_relative_to(root):
46
+ raise ValueError(f"{key} must stay within the repository")
47
+ if not path.is_file() or path.is_symlink():
48
+ raise ValueError(f"{key} must name an existing regular file")
49
+ if "version-method" in config and "version-file" not in config:
50
+ raise ValueError("version-method requires version-file")
51
+ return project, config
52
+
53
+
54
+ def replace(text, pattern, replacement):
55
+ """Require exactly one matching metadata field."""
56
+ if len(list(re.finditer(pattern, text, re.MULTILINE))) != 1:
57
+ raise ValueError(f"Expected exactly one field matching {pattern}")
58
+ return re.sub(pattern, replacement, text, flags=re.MULTILINE)
59
+
60
+
61
+ def fields(root, config):
62
+ """Return configured version fields and their regular expressions."""
63
+ result = {"pyproject.toml": r'(?m)(^version\s*=\s*)["\']([^"\']+)["\']'}
64
+ for key, pattern in {
65
+ "version-file": r'(^__version__\s*=\s*version\s*=\s*)["\']([^"\']+)["\']',
66
+ "conda-recipe": r'(^[ \t]*version:[ \t]*)["\']?([^\s"\'#]+)["\']?',
67
+ "citation": r'(^version:[ \t]*)["\']?([^\s"\'#]+)["\']?',
68
+ }.items():
69
+ if key in config:
70
+ result[config[key]] = pattern
71
+ return result
72
+
73
+
74
+ def versions(root, config):
75
+ """Read every configured version, failing on missing or ambiguous fields."""
76
+ result = {}
77
+ for path, pattern in fields(root, config).items():
78
+ if path == "pyproject.toml":
79
+ result[path] = str(tomllib.loads((root / path).read_text())["project"]["version"])
80
+ continue
81
+ matches = list(re.finditer(pattern, (root / path).read_text(), re.MULTILINE))
82
+ if len(matches) != 1:
83
+ raise ValueError(f"Expected exactly one version in {path}")
84
+ result[path] = matches[0].group(2)
85
+ if "zenodo" in config:
86
+ result[config["zenodo"]] = json.loads((root / config["zenodo"]).read_text())["version"]
87
+ return result
88
+
89
+
90
+ def plan(root, config, version, release_date):
91
+ """Prepare all new file contents before writing any release metadata."""
92
+ versions(root, config)
93
+ updates = {}
94
+ for path, pattern in fields(root, config).items():
95
+ text = (root / path).read_text()
96
+ if path == "pyproject.toml":
97
+ # Restrict replacement to the project table, never a tool's version setting.
98
+ table = re.search(r"(?ms)^\[project\][ \t]*\n.*?(?=^\[|\Z)", text)
99
+ if table is None:
100
+ raise ValueError("Missing [project] table")
101
+ section = replace(table[0], pattern, lambda m: m[1] + json.dumps(version))
102
+ updates[path] = text[: table.start()] + section + text[table.end() :]
103
+ else:
104
+ updates[path] = replace(text, pattern, lambda m: m[1] + json.dumps(version))
105
+ if "zenodo" in config:
106
+ path = config["zenodo"]
107
+ data = json.loads((root / path).read_text())
108
+ data.update(version=version, publication_date=release_date)
109
+ updates[path] = json.dumps(data, indent=2) + "\n"
110
+ if config.get("version-method") == "setuptools-scm":
111
+ # Generate into a temporary destination, keeping planning read-only for the repository.
112
+ with tempfile.TemporaryDirectory() as directory:
113
+ destination = Path(directory) / "_version.py"
114
+ subprocess.run(
115
+ [
116
+ sys.executable,
117
+ "-c",
118
+ (
119
+ "from setuptools_scm import get_version; "
120
+ "import sys; get_version(root=sys.argv[1], write_to=sys.argv[2], "
121
+ "version_scheme='only-version', local_scheme='no-local-version')"
122
+ ),
123
+ str(Path(directory).resolve()),
124
+ str(destination.resolve()),
125
+ ],
126
+ check=True,
127
+ capture_output=True,
128
+ text=True,
129
+ env={**os.environ, "SETUPTOOLS_SCM_PRETEND_VERSION": version},
130
+ )
131
+ updates[config["version-file"]] = destination.read_text()
132
+ return updates
133
+
134
+
135
+ def main(argv=None):
136
+ """Execute a release command and return a shell exit status."""
137
+ parser = argparse.ArgumentParser(description=__doc__)
138
+ parser.add_argument("--root", type=Path, default=Path.cwd())
139
+ commands = parser.add_subparsers(dest="command", required=True)
140
+ commands.add_parser("next").add_argument("kind", choices=["major", "minor", "patch"])
141
+ check = commands.add_parser("check")
142
+ check.add_argument("--version")
143
+ tag = commands.add_parser("tag")
144
+ tag.add_argument("tag")
145
+ tag.add_argument("--dry-run", action="store_true")
146
+ tag.add_argument("--date", default=date.today().isoformat())
147
+ args = parser.parse_args(argv)
148
+ root = args.root.resolve()
149
+ try:
150
+ if Path(git(root, "rev-parse", "--show-toplevel")).resolve() != root:
151
+ raise ValueError("--root must be the Git repository root")
152
+ if args.command == "next":
153
+ tags = [
154
+ tuple(map(int, m.groups()))
155
+ for t in git(root, "tag", "--list").splitlines()
156
+ if (m := TAG.fullmatch(t))
157
+ ]
158
+ if not tags:
159
+ raise ValueError("No stable semantic-version tag found")
160
+ numbers = list(max(tags))
161
+ index = ["major", "minor", "patch"].index(args.kind)
162
+ numbers[index] += 1
163
+ numbers[index + 1 :] = [0] * (2 - index)
164
+ print("v" + ".".join(map(str, numbers)))
165
+ return 0
166
+ _, config = load(root)
167
+ if args.command == "check":
168
+ declared = versions(root, config)
169
+ expected = (args.version or declared["pyproject.toml"]).removeprefix("v")
170
+ for path, value in declared.items():
171
+ print(f"{'OK' if value == expected else 'MISMATCH'} {path}: {value}")
172
+ return int(any(value != expected for value in declared.values()))
173
+ match = TAG.fullmatch(args.tag)
174
+ if not match:
175
+ raise ValueError("Tag must use vMAJOR.MINOR.PATCH")
176
+ date.fromisoformat(args.date)
177
+ if git(root, "status", "--porcelain", "--untracked-files=all"):
178
+ raise ValueError("Working tree must be clean")
179
+ if git(root, "tag", "--list", args.tag):
180
+ raise ValueError("Tag already exists")
181
+ if git(root, "rev-parse", "--is-shallow-repository") == "true":
182
+ raise ValueError("Release creation requires a full clone")
183
+ updates = plan(root, config, args.tag[1:], args.date)
184
+ for path, text in updates.items():
185
+ print(
186
+ "".join(
187
+ difflib.unified_diff(
188
+ (root / path).read_text().splitlines(True),
189
+ text.splitlines(True),
190
+ fromfile=path,
191
+ tofile=path,
192
+ )
193
+ ),
194
+ end="",
195
+ )
196
+ if args.dry_run:
197
+ return 0
198
+ originals = {path: (root / path).read_bytes() for path in updates}
199
+ try:
200
+ for path, text in updates.items():
201
+ (root / path).write_text(text)
202
+ if any(v != args.tag[1:] for v in versions(root, config).values()):
203
+ raise ValueError("Updated metadata failed validation")
204
+ except Exception:
205
+ for path, content in originals.items():
206
+ (root / path).write_bytes(content)
207
+ raise
208
+ git(root, "add", "--", *updates)
209
+ git(root, "commit", "-m", f"Release {args.tag}")
210
+ git(root, "tag", "-a", args.tag, "-m", f"Release {args.tag}")
211
+ print(f"Created {args.tag}; nothing was pushed.")
212
+ return 0
213
+ except (ValueError, KeyError, OSError, subprocess.CalledProcessError) as error:
214
+ print(f"Release operation failed: {error}", file=sys.stderr)
215
+ print(
216
+ "Inspect git status and git log before retrying a failed tag operation.",
217
+ file=sys.stderr,
218
+ )
219
+ return 1
220
+
221
+
222
+ if __name__ == "__main__":
223
+ raise SystemExit(main())
@@ -0,0 +1,119 @@
1
+ Metadata-Version: 2.4
2
+ Name: martinpdes-maintenance
3
+ Version: 0.2.0
4
+ Summary: Shared release preparation and metadata checks for scientific Python projects
5
+ License-Expression: MIT
6
+ Requires-Python: >=3.10
7
+ Description-Content-Type: text/markdown
8
+ License-File: LICENSE
9
+ Requires-Dist: tomli>=2; python_version < "3.11"
10
+ Provides-Extra: testing
11
+ Requires-Dist: pytest<10,>=8; extra == "testing"
12
+ Requires-Dist: setuptools-scm<10,>=8; extra == "testing"
13
+ Provides-Extra: scm
14
+ Requires-Dist: setuptools-scm<10,>=8; extra == "scm"
15
+ Provides-Extra: dev
16
+ Requires-Dist: pytest<10,>=8; extra == "dev"
17
+ Requires-Dist: ruff==0.17.2; extra == "dev"
18
+ Requires-Dist: build<2,>=1; extra == "dev"
19
+ Requires-Dist: twine<7,>=6; extra == "dev"
20
+ Dynamic: license-file
21
+
22
+ # martinpdes-maintenance
23
+
24
+ Shared release preparation for the MartinPdeS scientific Python ecosystem.
25
+ The distribution is `martinpdes-maintenance`; its command is `mps-maintenance`.
26
+ Python 3.10 and newer are supported. The Python package lives directly in
27
+ `martinpdes_maintenance/`, following the flat layout used by MartinPdeS-docs.
28
+
29
+ ## Install
30
+
31
+ From a source checkout:
32
+
33
+ ```sh
34
+ python -m pip install -e ".[dev,scm]"
35
+ ```
36
+
37
+ This repository has not yet been published on PyPI. After publication, consumers
38
+ can pin a reviewed version in their release development extra. The tool is not
39
+ a runtime dependency of scientific packages.
40
+
41
+ ## Configure a consuming project
42
+
43
+ Add explicit settings to the consumer's `pyproject.toml`:
44
+
45
+ ```toml
46
+ [tool.mps-maintenance]
47
+ version-file = "DeepPeak/_version.py"
48
+ version-method = "setuptools-scm"
49
+ conda-recipe = "conda.recipe/meta.yaml"
50
+ ```
51
+
52
+ Use `version-method = "literal"` (the default) to update a simple
53
+ `__version__ = version = "X.Y.Z"` assignment directly. SCM mode requires this
54
+ tool's `scm` extra and generates the file using setuptools-scm. Optional settings:
55
+
56
+ ```toml
57
+ zenodo = ".zenodo.json"
58
+ citation = "CITATION.cff"
59
+ ```
60
+
61
+ All configured files must already exist inside the repository. Unknown settings
62
+ are errors. Project versions must be static in `[project].version`; Conda recipes
63
+ must contain one literal `version:` field. Jinja-templated recipe versions and
64
+ prerelease creation are not supported in this initial implementation. Citation
65
+ updates currently change the version only, preserving existing date behavior.
66
+
67
+ ## Commands
68
+
69
+ Run from the consumer repository root, or supply `--root /path/to/repository`
70
+ before the subcommand:
71
+
72
+ ```sh
73
+ mps-maintenance check
74
+ mps-maintenance check --version v1.2.3
75
+ mps-maintenance next patch
76
+ mps-maintenance tag v1.2.4 --dry-run
77
+ ```
78
+
79
+ `next` selects the highest stable `vMAJOR.MINOR.PATCH` tag available locally,
80
+ including tags not reachable from HEAD. It ignores prerelease and unrelated tags;
81
+ fetch tags first when remote releases may be missing. It fails if no stable tag exists.
82
+
83
+ `tag` requires a clean, full Git checkout and an unused stable tag. It calculates
84
+ all replacements before writing, prints a diff, validates updated versions,
85
+ stages the release files, commits, then creates an annotated tag. `--dry-run`
86
+ prints the plan without writing repository files, staging, committing, or tagging.
87
+ Zenodo publication dates default to the local calendar date; `--date YYYY-MM-DD`
88
+ sets a reproducible date explicitly.
89
+
90
+ To actually create the release commit and tag, omit `--dry-run`. Nothing is
91
+ pushed. Existing Makefile push behavior should remain in the consumer repository.
92
+ A commit or tag failure leaves state for inspection: use `git status` and
93
+ `git log -1`, fix the cause, and recover explicitly. A tag failure can leave a
94
+ successful release commit. Do not blindly rerun or reset unrelated work.
95
+
96
+ ## Migration
97
+
98
+ 1. Install a reviewed tool revision in the release environment.
99
+ 2. Configure every file the old scripts check or update.
100
+ 3. Compare `check`, `next`, and dry-run output with the old scripts.
101
+ 4. Replace old script bodies with thin CLI wrappers, preserving Makefile behavior.
102
+ 5. Verify in a disposable clone before removing compatibility wrappers.
103
+
104
+ DeepPeak uses SCM generation; PyMieSim and PackLab additionally update Zenodo;
105
+ BornSim uses literal generation plus Zenodo and Citation metadata. No consumer
106
+ repositories are migrated automatically.
107
+
108
+ ## Development
109
+
110
+ ```sh
111
+ python -m pytest
112
+ python -m ruff check .
113
+ python -m ruff format --check .
114
+ python -m build
115
+ python -m twine check dist/*
116
+ ```
117
+
118
+ Tests create local temporary Git repositories and never push or publish.
119
+ See CONTRIBUTING.md for release bootstrapping.
@@ -0,0 +1,12 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ martinpdes_maintenance/__init__.py
5
+ martinpdes_maintenance/cli.py
6
+ martinpdes_maintenance.egg-info/PKG-INFO
7
+ martinpdes_maintenance.egg-info/SOURCES.txt
8
+ martinpdes_maintenance.egg-info/dependency_links.txt
9
+ martinpdes_maintenance.egg-info/entry_points.txt
10
+ martinpdes_maintenance.egg-info/requires.txt
11
+ martinpdes_maintenance.egg-info/top_level.txt
12
+ tests/test_cli.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ mps-maintenance = martinpdes_maintenance.cli:main
@@ -0,0 +1,16 @@
1
+
2
+ [:python_version < "3.11"]
3
+ tomli>=2
4
+
5
+ [dev]
6
+ pytest<10,>=8
7
+ ruff==0.17.2
8
+ build<2,>=1
9
+ twine<7,>=6
10
+
11
+ [scm]
12
+ setuptools-scm<10,>=8
13
+
14
+ [testing]
15
+ pytest<10,>=8
16
+ setuptools-scm<10,>=8
@@ -0,0 +1 @@
1
+ martinpdes_maintenance
@@ -0,0 +1,32 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "martinpdes-maintenance"
7
+ version = "0.2.0"
8
+ description = "Shared release preparation and metadata checks for scientific Python projects"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ dependencies = ["tomli>=2; python_version < '3.11'"]
14
+
15
+ [project.optional-dependencies]
16
+ testing = ["pytest>=8,<10", "setuptools-scm>=8,<10"]
17
+ scm = ["setuptools-scm>=8,<10"]
18
+ dev = ["pytest>=8,<10", "ruff==0.17.2", "build>=1,<2", "twine>=6,<7"]
19
+
20
+ [project.scripts]
21
+ mps-maintenance = "martinpdes_maintenance.cli:main"
22
+
23
+ [tool.setuptools.packages.find]
24
+ where = ["."]
25
+ include = ["martinpdes_maintenance", "martinpdes_maintenance.*"]
26
+
27
+ [tool.ruff]
28
+ target-version = "py310"
29
+ line-length = 100
30
+
31
+ [tool.pytest.ini_options]
32
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,133 @@
1
+ """Exercise release behavior in isolated Git repositories."""
2
+
3
+ import json
4
+ import subprocess
5
+
6
+ import pytest
7
+
8
+ from martinpdes_maintenance.cli import main
9
+
10
+
11
+ def git(root, *args):
12
+ return subprocess.run(
13
+ ["git", "-C", str(root), *args], check=True, capture_output=True, text=True
14
+ ).stdout.strip()
15
+
16
+
17
+ @pytest.fixture
18
+ def repo(tmp_path):
19
+ git(tmp_path, "init")
20
+ git(tmp_path, "config", "user.name", "Test")
21
+ git(tmp_path, "config", "user.email", "test@example.invalid")
22
+ (tmp_path / "pyproject.toml").write_text("""[project]
23
+ name = "example"
24
+ version = "1.2.3"
25
+ [tool.mps-maintenance]
26
+ version-file = "_version.py"
27
+ conda-recipe = "meta.yaml"
28
+ zenodo = ".zenodo.json"
29
+ citation = "CITATION.cff"
30
+ """)
31
+ (tmp_path / "_version.py").write_text('__version__ = version = "1.2.3"\n')
32
+ (tmp_path / "meta.yaml").write_text("package:\n name: example\n version: 1.2.3 # keep\n")
33
+ (tmp_path / "CITATION.cff").write_text('version: "1.2.3"\ntitle: Example\n')
34
+ (tmp_path / ".zenodo.json").write_text(json.dumps({"version": "1.2.3", "title": "Keep"}))
35
+ git(tmp_path, "add", ".")
36
+ git(tmp_path, "commit", "-m", "Initial")
37
+ git(tmp_path, "tag", "v1.2.3")
38
+ return tmp_path
39
+
40
+
41
+ def call(repo, *args):
42
+ return main(["--root", str(repo), *args])
43
+
44
+
45
+ def test_next_ignores_nonstable_tags(repo, capsys):
46
+ git(repo, "tag", "v9.0.0rc1")
47
+ git(repo, "tag", "v2.0.0")
48
+ assert call(repo, "next", "minor") == 0
49
+ assert capsys.readouterr().out.strip() == "v2.1.0"
50
+
51
+
52
+ def test_check_reports_mismatch(repo, capsys):
53
+ assert call(repo, "check") == 0
54
+ (repo / "_version.py").write_text('__version__ = version = "0.0.1"\n')
55
+ assert call(repo, "check") == 1
56
+ assert "MISMATCH" in capsys.readouterr().out
57
+
58
+
59
+ def test_dry_run_is_read_only(repo):
60
+ before = {p.name: p.read_bytes() for p in repo.iterdir() if p.is_file()}
61
+ head = git(repo, "rev-parse", "HEAD")
62
+ assert call(repo, "tag", "v1.2.4", "--dry-run") == 0
63
+ assert before == {p.name: p.read_bytes() for p in repo.iterdir() if p.is_file()}
64
+ assert git(repo, "rev-parse", "HEAD") == head
65
+ assert not git(repo, "tag", "--list", "v1.2.4")
66
+
67
+
68
+ def test_release_updates_all_metadata_and_annotates_tag(repo):
69
+ assert call(repo, "tag", "v1.2.4", "--date", "2026-10-24") == 0
70
+ assert call(repo, "check", "--version", "v1.2.4") == 0
71
+ assert git(repo, "status", "--porcelain") == ""
72
+ assert git(repo, "cat-file", "-t", "refs/tags/v1.2.4") == "tag"
73
+ metadata = json.loads((repo / ".zenodo.json").read_text())
74
+ assert metadata == {"version": "1.2.4", "title": "Keep", "publication_date": "2026-10-24"}
75
+ assert "# keep" in (repo / "meta.yaml").read_text()
76
+
77
+
78
+ @pytest.mark.parametrize("reason", ["dirty", "existing", "invalid", "malformed", "escape"])
79
+ def test_rejected_operations_do_not_change_head(repo, reason):
80
+ tag = "v1.2.4"
81
+ if reason == "dirty":
82
+ (repo / "unrelated").write_text("preserve")
83
+ elif reason == "existing":
84
+ tag = "v1.2.3"
85
+ elif reason == "invalid":
86
+ tag = "v01.2.4"
87
+ elif reason == "malformed":
88
+ (repo / "meta.yaml").write_text("not a recipe")
89
+ git(repo, "add", ".")
90
+ git(repo, "commit", "-m", "Malformed fixture")
91
+ else:
92
+ p = repo / "pyproject.toml"
93
+ p.write_text(p.read_text().replace('"_version.py"', '"../outside.py"'))
94
+ git(repo, "add", ".")
95
+ git(repo, "commit", "-m", "Invalid configuration")
96
+ head = git(repo, "rev-parse", "HEAD")
97
+ assert call(repo, "tag", tag) == 1
98
+ assert git(repo, "rev-parse", "HEAD") == head
99
+
100
+
101
+ def test_scm_generation(repo):
102
+ pytest.importorskip("setuptools_scm")
103
+ p = repo / "pyproject.toml"
104
+ p.write_text(p.read_text() + 'version-method = "setuptools-scm"\n')
105
+ git(repo, "add", ".")
106
+ git(repo, "commit", "-m", "SCM configuration")
107
+ assert call(repo, "tag", "v1.2.4") == 0
108
+ assert call(repo, "check") == 0
109
+ assert "__version_tuple__" in (repo / "_version.py").read_text()
110
+
111
+
112
+ def test_literal_project_without_optional_metadata(repo):
113
+ p = repo / "pyproject.toml"
114
+ p.write_text(
115
+ '[project]\nname="example"\nversion="1.2.3"\n'
116
+ '[tool.mps-maintenance]\nversion-file="_version.py"\n'
117
+ )
118
+ git(repo, "add", ".")
119
+ git(repo, "commit", "-m", "Minimal configuration")
120
+ assert call(repo, "tag", "v1.2.4") == 0
121
+ assert json.loads((repo / ".zenodo.json").read_text())["version"] == "1.2.3"
122
+
123
+
124
+ def test_failed_commit_leaves_metadata_for_recovery(repo):
125
+ hook = repo / ".git" / "hooks" / "pre-commit"
126
+ hook.write_text("#!/bin/sh\nexit 1\n")
127
+ hook.chmod(0o755)
128
+ head = git(repo, "rev-parse", "HEAD")
129
+ assert call(repo, "tag", "v1.2.4") == 1
130
+ assert git(repo, "rev-parse", "HEAD") == head
131
+ assert git(repo, "tag", "--list", "v1.2.4") == ""
132
+ assert call(repo, "check", "--version", "v1.2.4") == 0
133
+ assert git(repo, "diff", "--cached", "--name-only")