Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 5 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@
<img src="https://github.com/Apakottur/shpyx/blob/main/shpyx.png?raw=true" />
</p>

[![PyPI](https://img.shields.io/pypi/v/shpyx?logo=pypi&logoColor=white&style=for-the-badge)](https://pypi.org/project/shpyx/)
[![Downloads](https://img.shields.io/pypi/dm/shpyx?logo=pypi&logoColor=white&style=for-the-badge)](https://pypi.org/project/shpyx/)
[![Python](https://img.shields.io/pypi/pyversions/shpyx?logo=pypi&logoColor=white&style=for-the-badge)](https://pypi.org/project/shpyx/)
[![image](https://img.shields.io/pypi/v/shpyx.svg)](https://pypi.python.org/pypi/shpyx)
[![image](https://img.shields.io/pypi/l/shpyx.svg)](https://github.com/Apakottur/shpyx/blob/main/LICENSE)
[![image](https://img.shields.io/pypi/pyversions/shpyx.svg)](https://pypi.python.org/pypi/shpyx)

**shpyx** is a simple, lightweight and typed library for running shell commands in Python.

Expand Down Expand Up @@ -207,12 +207,7 @@ ty check --config-file linters/ty.toml src tests

### Releasing

The package version is derived from the git tag (via `hatch-vcs`), so there is nothing to bump in
`pyproject.toml`. To release a new version, push a `v`-prefixed tag from `main`:
To release a new version, run the interactive script:
```shell
git tag v0.0.37
git push origin v0.0.37
./scripts/release.py
```

This triggers the `Release` workflow, which builds the package, publishes it to PyPI using Trusted
Publishing (OIDC, no stored token), and creates a GitHub Release with auto-generated notes.
1 change: 1 addition & 0 deletions linters/cspell/words.txt
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,4 @@ anyio
multibyte
haia
softprops
httpx
3 changes: 3 additions & 0 deletions linters/ruff.toml
Original file line number Diff line number Diff line change
Expand Up @@ -126,3 +126,6 @@ ban-relative-imports = "all"
"tests/*" = [
"S101", # Use of `assert` detected
]
"scripts/*" = [
"T201", # `print` found
]
7 changes: 7 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ requires = ["hatchling", "hatch-vcs"]

[dependency-groups]
dev = [
"httpx==0.28.1",
"mypy==2.2.0",
"pre-commit==4.6.0",
"pytest-cov==7.1.0",
Expand All @@ -29,6 +30,12 @@ classifiers = [
"Topic :: Software Development :: Libraries :: Python Modules",
"License :: OSI Approved :: MIT License",
"Operating System :: OS Independent",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
]

[project.urls]
Expand Down
90 changes: 90 additions & 0 deletions scripts/release.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
#!/usr/bin/env python
"""
Create a new shpyx release.
"""

import sys

import httpx

import shpyx

_MAIN_BRANCH = "main"


def _abort(message: str) -> None:
print(f"\n❌ {message}")
sys.exit(1)


def _confirm(question: str) -> None:
user_input = input(f"{question} [y/N] ").strip().lower()
if user_input != "y":
_abort("Aborted by user.")


def main() -> None:
# Verify we are on a clean, up-to-date `main` before tagging.
branch = shpyx.run("git rev-parse --abbrev-ref HEAD").stdout.strip()
if branch != _MAIN_BRANCH:
_abort(f"Must be on the '{_MAIN_BRANCH}' branch, but currently on '{branch}'.")
if shpyx.run("git status --porcelain").stdout.strip():
_abort("Working tree is not clean. Commit or stash your changes first.")

# Fetch and fast-forward to origin/main.
print("Fetching from origin...")
shpyx.run("git pull", log_output=True)

# Derive the GitHub "owner/repo" slug (and PyPI package name) from the origin remote,
# supporting both SSH (git@github.com:owner/repo.git) and HTTPS URLs.
remote_url = shpyx.run("git remote get-url origin").stdout.strip()
slug = remote_url.removesuffix(".git").split("github.com")[-1].strip(":/")
if slug.count("/") != 1:
_abort(f"Cannot parse a GitHub 'owner/repo' slug from origin URL {remote_url!r}.")
package_name = slug.split("/")[1]

# Look up the latest published version on PyPI.
pypi_response = httpx.get(f"https://pypi.org/pypi/{package_name}/json")
pypi_response.raise_for_status()
version = pypi_response.json()["info"]["version"]
parts = version.split(".")
if len(parts) != 3 or not all(part.isdigit() for part in parts):
_abort(f"Cannot parse PyPI version {version!r} as 'major.minor.patch'.")
major, minor, patch = (int(part) for part in parts)

# Let the user pick the next version.
bumps = {
"1": ("patch", f"{major}.{minor}.{patch + 1}"),
"2": ("minor", f"{major}.{minor + 1}.0"),
"3": ("major", f"{major + 1}.0.0"),
}
print(f"\nLatest version on PyPI: {major}.{minor}.{patch}")
print("Select the next version:")
for key, (name, next_version) in bumps.items():
print(f" {key}) {name:<5} -> {next_version}")
while (choice := input("Choice [1/2/3]: ").strip()) not in bumps:
print("Invalid choice, please enter 1, 2 or 3.")
tag = f"v{bumps[choice][1]}"

# Handle a pre-existing tag (e.g. from a release run that failed after tagging).
local = shpyx.run(f"git tag --list {tag}").stdout.strip()
remote = shpyx.run(f"git ls-remote --tags origin {tag}").stdout.strip()
if local or remote:
print(f"\n⚠️ Tag {tag} already exists (a previous release may have failed).")
_confirm(f"Delete the existing {tag} and recreate it?")
# Local delete may fail if the tag only exists on the remote; ignore that.
shpyx.run(f"git tag --delete {tag}", verify_return_code=False)
shpyx.run(f"git push --delete origin {tag}", verify_return_code=False)

# Create and push the tag.
_confirm(f"\nCreate and push tag {tag} to trigger the release?")
shpyx.run(f"git tag {tag}")
shpyx.run(f"git push origin {tag}")

# Print the release URL.
print(f"\n✅ Pushed {tag}. The Release workflow is now running:")
print(f" https://github.com/{slug}/actions/workflows/release.yml")


if __name__ == "__main__":
main()
71 changes: 71 additions & 0 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading