diff --git a/README.md b/README.md index faceebb..d48a064 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,9 @@

-[![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. @@ -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. diff --git a/linters/cspell/words.txt b/linters/cspell/words.txt index 502735f..4f5818a 100644 --- a/linters/cspell/words.txt +++ b/linters/cspell/words.txt @@ -34,3 +34,4 @@ anyio multibyte haia softprops +httpx diff --git a/linters/ruff.toml b/linters/ruff.toml index 8d998f9..6a41689 100644 --- a/linters/ruff.toml +++ b/linters/ruff.toml @@ -126,3 +126,6 @@ ban-relative-imports = "all" "tests/*" = [ "S101", # Use of `assert` detected ] +"scripts/*" = [ + "T201", # `print` found +] diff --git a/pyproject.toml b/pyproject.toml index 4501afc..5179895 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", @@ -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] diff --git a/scripts/release.py b/scripts/release.py new file mode 100755 index 0000000..b6f5bad --- /dev/null +++ b/scripts/release.py @@ -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() diff --git a/uv.lock b/uv.lock index af28884..85ec3ad 100644 --- a/uv.lock +++ b/uv.lock @@ -6,6 +6,20 @@ resolution-markers = [ "python_full_version < '3.15'", ] +[[package]] +name = "anyio" +version = "4.14.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3b/72/5562aabb8dd7181e8e860622a38bea08d17842b99ecd4c91f84ac95251b0/anyio-4.14.1.tar.gz", hash = "sha256:8d648a3544c1a700e3ff78615cd679e4c5c3f149904287e73687b2596963629e", size = 254831, upload-time = "2026-06-24T20:56:06.017Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b0/7b/90df4a0a816d98d6ea26f559d87836d494a2cf1fcf063be67df50a7bcc30/anyio-4.14.1-py3-none-any.whl", hash = "sha256:4e5533c5b8ff0a24f5d7a176cbe6877129cd183893f66b537f8f227d10527d72", size = 124875, upload-time = "2026-06-24T20:56:04.413Z" }, +] + [[package]] name = "ast-serialize" version = "0.6.0" @@ -47,6 +61,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/66/40/c53deb2cd0c9b0fb636d24d9f40924cf2e65028e6b20b10cd5c1eeb2c730/ast_serialize-0.6.0-cp39-abi3-win_arm64.whl", hash = "sha256:ccd132fe8db56f61fe743b1f644d01b8d65b83248a8da506f3132bda86d6ed5e", size = 1072965, upload-time = "2026-06-30T20:02:54.097Z" }, ] +[[package]] +name = "certifi" +version = "2026.6.17" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", size = 134594, upload-time = "2026-06-17T10:31:07.894Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" }, +] + [[package]] name = "cfgv" version = "3.5.0" @@ -198,6 +221,43 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/60/02/be4a57b60c7149b55b9e3b3c13f609cd8eb5307c751f22bd8fb8d262e75b/filelock-3.29.7-py3-none-any.whl", hash = "sha256:987db6f789a3a2a59f55081801b2b3697cb97e2a736b5f1a9e99b559285fbc51", size = 46036, upload-time = "2026-07-08T05:46:57.53Z" }, ] +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + [[package]] name = "identify" version = "2.6.19" @@ -207,6 +267,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/94/84/d9273cd09688070a6523c4aee4663a8538721b2b755c4962aafae0011e72/identify-2.6.19-py2.py3-none-any.whl", hash = "sha256:20e6a87f786f768c092a721ad107fc9df0eb89347be9396cadf3f4abbd1fb78a", size = 99397, upload-time = "2026-04-17T18:39:49.221Z" }, ] +[[package]] +name = "idna" +version = "3.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, +] + [[package]] name = "iniconfig" version = "2.3.0" @@ -594,6 +663,7 @@ source = { editable = "." } [package.dev-dependencies] dev = [ + { name = "httpx" }, { name = "mypy" }, { name = "pre-commit" }, { name = "pytest" }, @@ -607,6 +677,7 @@ dev = [ [package.metadata.requires-dev] dev = [ + { name = "httpx", specifier = "==0.28.1" }, { name = "mypy", specifier = "==2.2.0" }, { name = "pre-commit", specifier = "==4.6.0" }, { name = "pytest", specifier = "==9.1.1" },