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.
- martinpdes_maintenance-0.2.0/LICENSE +21 -0
- martinpdes_maintenance-0.2.0/PKG-INFO +119 -0
- martinpdes_maintenance-0.2.0/README.md +98 -0
- martinpdes_maintenance-0.2.0/martinpdes_maintenance/__init__.py +1 -0
- martinpdes_maintenance-0.2.0/martinpdes_maintenance/cli.py +223 -0
- martinpdes_maintenance-0.2.0/martinpdes_maintenance.egg-info/PKG-INFO +119 -0
- martinpdes_maintenance-0.2.0/martinpdes_maintenance.egg-info/SOURCES.txt +12 -0
- martinpdes_maintenance-0.2.0/martinpdes_maintenance.egg-info/dependency_links.txt +1 -0
- martinpdes_maintenance-0.2.0/martinpdes_maintenance.egg-info/entry_points.txt +2 -0
- martinpdes_maintenance-0.2.0/martinpdes_maintenance.egg-info/requires.txt +16 -0
- martinpdes_maintenance-0.2.0/martinpdes_maintenance.egg-info/top_level.txt +1 -0
- martinpdes_maintenance-0.2.0/pyproject.toml +32 -0
- martinpdes_maintenance-0.2.0/setup.cfg +4 -0
- martinpdes_maintenance-0.2.0/tests/test_cli.py +133 -0
|
@@ -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 @@
|
|
|
1
|
+
|
|
@@ -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,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")
|