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,5 @@
1
+ Metadata-Version: 2.4
2
+ Name: cli_release-me
3
+ Version: 1.1.3
4
+ Summary: CLI tool for creating git-tagged versions of Python packages where the version has to be specified exactly once.
5
+ Author-email: Thomas Bauwens <thomas.bauwens@kuleuven.be>
@@ -0,0 +1,2 @@
1
+ # ReleaseMe
2
+ Tool for releasing Python packages automatically.
@@ -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()