cli_release-me 1.1.3__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,44 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags:
|
|
6
|
+
- 'v*'
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
publish:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
|
|
12
|
+
steps:
|
|
13
|
+
- name: Checkout code
|
|
14
|
+
uses: actions/checkout@v4
|
|
15
|
+
|
|
16
|
+
- name: Set up Python
|
|
17
|
+
uses: actions/setup-python@v5
|
|
18
|
+
with:
|
|
19
|
+
python-version: '3.13' # Match your project requirements
|
|
20
|
+
|
|
21
|
+
- name: Install Hatch
|
|
22
|
+
run: pip install hatch
|
|
23
|
+
|
|
24
|
+
- name: Check version matches tag
|
|
25
|
+
run: |
|
|
26
|
+
TAG_VERSION=${GITHUB_REF#refs/*/}
|
|
27
|
+
PROJECT_VERSION=$(hatch version)
|
|
28
|
+
|
|
29
|
+
echo "Git tag version: $TAG_VERSION"
|
|
30
|
+
echo "Project version: $PROJECT_VERSION"
|
|
31
|
+
|
|
32
|
+
if [ "$TAG_VERSION" != "$PROJECT_VERSION" ]; then
|
|
33
|
+
echo "❌ Version mismatch between tag ($TAG_VERSION) and pyproject.toml ($PROJECT_VERSION)"
|
|
34
|
+
exit 1
|
|
35
|
+
fi
|
|
36
|
+
|
|
37
|
+
- name: Publish to PyPI
|
|
38
|
+
env:
|
|
39
|
+
HATCH_INDEX_USER: __token__
|
|
40
|
+
HATCH_INDEX_AUTH: ${{ secrets.PYPI_API_TOKEN }}
|
|
41
|
+
run: |
|
|
42
|
+
hatch build
|
|
43
|
+
hatch publish
|
|
44
|
+
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "cli_release-me" # This is actually the name of the package on PyPI, whereas the name we import is the name of the folder containing our package.
|
|
3
|
+
version = "v1.1.3"
|
|
4
|
+
description = "CLI tool for creating git-tagged versions of Python packages where the version has to be specified exactly once."
|
|
5
|
+
authors = [{name = "Thomas Bauwens", email = "thomas.bauwens@kuleuven.be"}]
|
|
6
|
+
dependencies = []
|
|
7
|
+
|
|
8
|
+
[project.scripts]
|
|
9
|
+
releaseme = "releaseme._cli:_main"
|
|
10
|
+
|
|
11
|
+
[build-system]
|
|
12
|
+
requires = ["hatchling"] # Better than setuptools for separating distribution name from package name.
|
|
13
|
+
build-backend = "hatchling.build"
|
|
14
|
+
|
|
15
|
+
[tool.hatch.build.targets.wheel]
|
|
16
|
+
packages = ["./releaseme"] # This is the path where the code is, which also determines by what name you import it (theoretically).
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "v1.1.3"
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def _main():
|
|
5
|
+
import argparse
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import sys
|
|
9
|
+
import tomllib
|
|
10
|
+
import subprocess
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Optional
|
|
13
|
+
|
|
14
|
+
print() # Newline
|
|
15
|
+
|
|
16
|
+
# Define arguments. They are only parsed after we print the current version.
|
|
17
|
+
parser = argparse.ArgumentParser(description="Push a new tagged version of a Python package.")
|
|
18
|
+
parser.add_argument("version", type=str, help="New version number.")
|
|
19
|
+
parser.add_argument("--runtime_variable_path", type=Path, help="Path to the file where the version is defined in a variable.")
|
|
20
|
+
parser.add_argument("--runtime_variable_name", type=str, help="Name of the variable whose value should be set to the current version.", default="__version__")
|
|
21
|
+
|
|
22
|
+
# Sanity check: are we even in a Python package tracked by Git?
|
|
23
|
+
PATH_GIT = Path(".git")
|
|
24
|
+
PATH_TOML = Path("pyproject.toml")
|
|
25
|
+
if not PATH_GIT.exists() or not PATH_TOML.exists():
|
|
26
|
+
print("❌ This does not look like a Python project root.")
|
|
27
|
+
sys.exit(1)
|
|
28
|
+
|
|
29
|
+
# Inspect the package for its name and version.
|
|
30
|
+
# - The TOML definitely exists. Question is whether it is correctly formed.
|
|
31
|
+
def get_distribution_name() -> str:
|
|
32
|
+
with open(PATH_TOML, "rb") as handle:
|
|
33
|
+
try:
|
|
34
|
+
return tomllib.load(handle)["project"]["name"]
|
|
35
|
+
except:
|
|
36
|
+
print("❌ Missing project name in TOML.")
|
|
37
|
+
sys.exit(1)
|
|
38
|
+
|
|
39
|
+
DISTRIBUTION_NAME = get_distribution_name()
|
|
40
|
+
print(f"✅ Identified distribution: {DISTRIBUTION_NAME}")
|
|
41
|
+
|
|
42
|
+
# - And even with a project name, can we find the source code?
|
|
43
|
+
def get_package_path() -> Path:
|
|
44
|
+
with open(PATH_TOML, "rb") as handle:
|
|
45
|
+
try: # This is most specific and hence has precedent.
|
|
46
|
+
package = Path(tomllib.load(handle)["tool.hatch.build.targets.wheel"]["packages"][0])
|
|
47
|
+
except:
|
|
48
|
+
# If there is a ./src/, it is always investigated.
|
|
49
|
+
parent_of_package = Path("./src/")
|
|
50
|
+
if not parent_of_package.is_dir():
|
|
51
|
+
parent_of_package = parent_of_package.parent
|
|
52
|
+
|
|
53
|
+
# Now, if there is a folder here with the same name as the distribution, that has to be it.
|
|
54
|
+
_, subfolders, _ = next(os.walk(parent_of_package))
|
|
55
|
+
subfolders = [f for f in subfolders if not f.startswith(".") and not f.startswith("_") and not f.endswith(".egg-info")]
|
|
56
|
+
|
|
57
|
+
if DISTRIBUTION_NAME in subfolders:
|
|
58
|
+
package = parent_of_package / DISTRIBUTION_NAME
|
|
59
|
+
# Or, if there is only one subfolder, that's likely it.
|
|
60
|
+
elif len(subfolders) == 1:
|
|
61
|
+
package = parent_of_package / subfolders[0]
|
|
62
|
+
else:
|
|
63
|
+
print("❌ Could not find package name.")
|
|
64
|
+
sys.exit(1)
|
|
65
|
+
|
|
66
|
+
# Verify that this folder contains an __init__.py as a sanity check that it is actually a Python module.
|
|
67
|
+
if not (package / "__init__.py").is_file():
|
|
68
|
+
print(f"❌ Missing __init__.py in supposed package root {package.as_posix()}!")
|
|
69
|
+
sys.exit(1)
|
|
70
|
+
|
|
71
|
+
return package
|
|
72
|
+
|
|
73
|
+
def get_package_name() -> str:
|
|
74
|
+
return get_package_path().name
|
|
75
|
+
|
|
76
|
+
PACKAGE_NAME = get_package_name()
|
|
77
|
+
print(f"✅ Identified package: {PACKAGE_NAME}")
|
|
78
|
+
|
|
79
|
+
# - Can we find the old and new tags?
|
|
80
|
+
def get_last_version_tag() -> Optional[str]:
|
|
81
|
+
try:
|
|
82
|
+
return subprocess.check_output(["git", "describe", "--tags", "--abbrev=0"], text=True).strip()
|
|
83
|
+
except subprocess.CalledProcessError:
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
def is_numeric_version_tag(version: str) -> bool:
|
|
87
|
+
return re.match(r"^v?[0-9.]+$", version) is not None
|
|
88
|
+
|
|
89
|
+
def is_version_lower(v1: str, v2: str):
|
|
90
|
+
return tuple(int(p) for p in v1.removeprefix("v").split(".")) < tuple(int(p) for p in v2.removeprefix("v").split("."))
|
|
91
|
+
|
|
92
|
+
CURRENT_VERSION = get_last_version_tag()
|
|
93
|
+
if CURRENT_VERSION is not None:
|
|
94
|
+
print(f"✅ Identified current version: {CURRENT_VERSION}")
|
|
95
|
+
|
|
96
|
+
args = parser.parse_args()
|
|
97
|
+
NEW_VERSION = args.version.strip()
|
|
98
|
+
if CURRENT_VERSION is not None:
|
|
99
|
+
if is_numeric_version_tag(CURRENT_VERSION) and is_numeric_version_tag(NEW_VERSION): # These checks are immune to a 'v' prefix.
|
|
100
|
+
if is_version_lower(NEW_VERSION, CURRENT_VERSION): # Idem.
|
|
101
|
+
print(f"❌ Cannot use new version {NEW_VERSION} since it is lower than the current version.")
|
|
102
|
+
sys.exit(1)
|
|
103
|
+
if CURRENT_VERSION.startswith("v") and not NEW_VERSION.startswith("v"):
|
|
104
|
+
NEW_VERSION = "v" + NEW_VERSION
|
|
105
|
+
else:
|
|
106
|
+
if is_numeric_version_tag(NEW_VERSION) and not NEW_VERSION.startswith("v"):
|
|
107
|
+
NEW_VERSION = "v" + NEW_VERSION
|
|
108
|
+
|
|
109
|
+
# Summarise the commits since the last tag.
|
|
110
|
+
def generate_release_notes(from_tag: Optional[str]):
|
|
111
|
+
if not from_tag:
|
|
112
|
+
print("⚠️ No previous tag found, listing all commits")
|
|
113
|
+
range_spec = "--all"
|
|
114
|
+
else:
|
|
115
|
+
range_spec = f"{from_tag}..HEAD"
|
|
116
|
+
|
|
117
|
+
sep = "<<END>>"
|
|
118
|
+
log = subprocess.check_output(["git", "log", range_spec, f"--pretty=format:%B{sep}"], text=True).strip()
|
|
119
|
+
if not log:
|
|
120
|
+
print(f"❌ No changes were made since the last version ({CURRENT_VERSION})!")
|
|
121
|
+
sys.exit(1)
|
|
122
|
+
|
|
123
|
+
commit_titles = [s.strip().split("\n")[0] for s in log.split(sep)]
|
|
124
|
+
return "".join("- " + title + "\n"
|
|
125
|
+
for title in commit_titles if title)
|
|
126
|
+
|
|
127
|
+
def quote(s: str) -> str:
|
|
128
|
+
return "\n".join(" | " + line for line in [""] + s.strip().split("\n") + [""])
|
|
129
|
+
|
|
130
|
+
notes = generate_release_notes(CURRENT_VERSION)
|
|
131
|
+
print(f"✅ Generated release notes since {CURRENT_VERSION or 'initial commit'}:")
|
|
132
|
+
print(quote(notes))
|
|
133
|
+
|
|
134
|
+
# Update all mentions of the version in the project files.
|
|
135
|
+
def update_pyproject(version: str):
|
|
136
|
+
content = PATH_TOML.read_text()
|
|
137
|
+
new_content = re.sub(r"""version\s*=\s*["'][0-9a-zA-Z.\-+]+["']""", f'version = "{version}"', content)
|
|
138
|
+
PATH_TOML.write_text(new_content)
|
|
139
|
+
print(f"✅ Updated pyproject.toml to version {version}")
|
|
140
|
+
|
|
141
|
+
PATH_VARIABLE = args.runtime_variable_path or get_package_path() / "__init__.py"
|
|
142
|
+
def update_variable(version: str):
|
|
143
|
+
if not PATH_VARIABLE.exists():
|
|
144
|
+
print(f"⚠️ {PATH_VARIABLE.name} not found; skipping {args.runtime_variable_name} update")
|
|
145
|
+
return
|
|
146
|
+
content = PATH_VARIABLE.read_text()
|
|
147
|
+
new_content = re.sub(re.escape(args.runtime_variable_name) + r"""\s*=\s*["'][0-9a-zA-Z.\-+]+["']""",
|
|
148
|
+
f'{args.runtime_variable_name} = "{version}"', content)
|
|
149
|
+
PATH_VARIABLE.write_text(new_content)
|
|
150
|
+
print(f"✅ Updated {PATH_VARIABLE.name} to version {version}")
|
|
151
|
+
|
|
152
|
+
if input(f"⚠️ Please confirm that you want to release the above details as follows:\n 📦 Package: {PACKAGE_NAME}\n ⏳ Version: {NEW_VERSION}\n 🌐 PyPI: {DISTRIBUTION_NAME}\n([y]/n) ").lower() == "n":
|
|
153
|
+
print(f"❌ User abort.")
|
|
154
|
+
sys.exit(1)
|
|
155
|
+
|
|
156
|
+
update_pyproject(NEW_VERSION)
|
|
157
|
+
update_variable(NEW_VERSION)
|
|
158
|
+
|
|
159
|
+
# Save changes with Git.
|
|
160
|
+
def git_commit_tag_push(version: str, notes: str):
|
|
161
|
+
try:
|
|
162
|
+
print(quote(
|
|
163
|
+
subprocess.check_output(["git", "add", "pyproject.toml", PATH_VARIABLE.as_posix()], text=True) + \
|
|
164
|
+
subprocess.check_output(["git", "commit", "-m", f"🔖 Release {version}\n\n{notes}"], text=True) + \
|
|
165
|
+
subprocess.check_output(["git", "push"], text=True)
|
|
166
|
+
))
|
|
167
|
+
print(quote(
|
|
168
|
+
subprocess.check_output(["git", "tag", "-a", f"{version}", "-m", f"Release {version}\n\n{notes}"], text=True) + \
|
|
169
|
+
subprocess.check_output(["git", "push", "origin", f"{version}"], text=True)
|
|
170
|
+
))
|
|
171
|
+
except:
|
|
172
|
+
print(f"❌ Failed to save to Git.")
|
|
173
|
+
raise
|
|
174
|
+
print(f"✅ Committed, tagged, and pushed version {version} with release notes.")
|
|
175
|
+
|
|
176
|
+
git_commit_tag_push(NEW_VERSION, notes)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
if __name__ == "__main__": # Run from command line.
|
|
180
|
+
_main()
|