Skip to content

Commit f893927

Browse files
2.0.2: 3.14 support
1 parent 08c53f3 commit f893927

File tree

8 files changed

+411
-4
lines changed

8 files changed

+411
-4
lines changed

.github/workflows/build.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ jobs:
1616

1717
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
1818
with:
19-
python-version: '3.13'
19+
python-version: '3.14'
2020

2121
- name: Install uv and pipx
2222
run: |

.github/workflows/publish_to_pypi.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ jobs:
1919

2020
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
2121
with:
22-
python-version: '3.13'
22+
python-version: '3.14'
2323

2424
- name: Install uv and pipx
2525
run: |

CHANGELOG.md

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,24 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [2.1.0] - 2025-10-06
9+
10+
### Added
11+
12+
- Checks pypi to see if current version is published. Don't bump if unpublished. Otherwise, if you run this on every
13+
time you run your Makefile or Justfile, you'll have version number gaps
14+
15+
## [2.0.1] - 2025-10-05
16+
17+
### Fixed
18+
19+
- Fixed missing dependency in pyproject.toml
20+
821
## [2.0.0] - 2025-10-05
922

1023
### Added
1124

12-
- New commands, 100% code rewrite, same general design goals.
25+
- New commands, 100% code rewrite, same general design goals.
1326

1427
### Changed
1528

jiggle_version/__main__.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,11 @@
4141
from jiggle_version.discover import find_source_files
4242
from jiggle_version.parsers.ast_parser import parse_python_module, parse_setup_py
4343
from jiggle_version.parsers.config_parser import parse_pyproject_toml, parse_setup_cfg
44+
from jiggle_version.pypi import (
45+
UnpublishedVersionError,
46+
check_pypi_publication,
47+
get_package_name,
48+
)
4449
from jiggle_version.update import (
4550
update_pyproject_toml,
4651
update_python_file,
@@ -83,6 +88,7 @@ class CustomFormatter(RichHelpFormatter):
8388
VERSION_DISAGREEMENT = 102
8489
DIRTY_GIT_REPO = 103
8590
NO_CONFIG_FOUND = 104
91+
PYPI_CHECK_FAILED = 105
8692

8793

8894
def handle_check(args: argparse.Namespace) -> int:
@@ -279,6 +285,40 @@ def handle_bump(args: argparse.Namespace) -> int:
279285
print(f"❌ Error bumping version: {e}", file=sys.stderr)
280286
return VERSION_BUMP_ERROR
281287

288+
# --- 2.5. PyPI Publication Pre-flight Check ---
289+
if not args.no_check_pypi and not args.dry_run:
290+
try:
291+
package_name = get_package_name(project_root)
292+
if package_name:
293+
print("\nConducting PyPI publication check…")
294+
check_pypi_publication(
295+
package_name=package_name,
296+
current_version=current_version,
297+
new_version=target_version,
298+
# >>> PASS THE CONFIG PATH
299+
config_path=digest_path,
300+
)
301+
else:
302+
LOGGER.info("Skipping PyPI check: no package name in pyproject.toml.")
303+
print(
304+
"\n⚪ Skipping PyPI check: could not find [project].name in pyproject.toml."
305+
)
306+
except UnpublishedVersionError as e:
307+
LOGGER.error("PyPI pre-flight check failed: %s", e)
308+
print(f"\n{e}", file=sys.stderr)
309+
# >>> MORE HELPFUL HINT
310+
print(
311+
"Hint: If this is a private package, use --no-check-pypi to bypass this check.",
312+
file=sys.stderr,
313+
)
314+
return PYPI_CHECK_FAILED
315+
except Exception as e:
316+
# Catch other potential errors like network issues
317+
LOGGER.warning(
318+
"PyPI check could not complete: %s", e, exc_info=args.verbose > 1
319+
)
320+
print(f"\n🟡 Warning: Could not complete PyPI check: {e}", file=sys.stderr)
321+
282322
# --- 3. Write changes ---
283323
if args.dry_run:
284324
print("\n--dry-run enabled, no files will be changed.")
@@ -506,6 +546,13 @@ def build_bump_subparser(
506546
help="Write even on disagreement.",
507547
)
508548

549+
p.add_argument(
550+
"--no-check-pypi",
551+
action="store_true",
552+
default=False,
553+
help="Disable the pre-flight check against pypi.org.",
554+
)
555+
509556
# Autogit group (all optional; config may override later)
510557
g = p.add_argument_group("autogit options")
511558
g.add_argument(

jiggle_version/pypi.py

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
# jiggle_version/pypi.py
2+
"""
3+
Implements a pre-flight check against PyPI to prevent bumping an unpublished version.
4+
"""
5+
from __future__ import annotations
6+
7+
import sys
8+
from datetime import datetime, timedelta, timezone
9+
from pathlib import Path
10+
11+
import requests
12+
import tomlkit
13+
from packaging.version import Version
14+
15+
# Handle Python < 3.11 needing tomli
16+
if sys.version_info < (3, 11):
17+
import tomli as tomllib
18+
else:
19+
import tomllib
20+
21+
22+
# --- Custom Exception ---
23+
class UnpublishedVersionError(Exception):
24+
"""Raised when attempting to bump a version that is unpublished on PyPI."""
25+
26+
27+
# --- Caching Configuration ---
28+
CACHE_TTL = timedelta(days=1)
29+
30+
31+
def get_package_name(project_root: Path) -> str | None:
32+
"""
33+
Finds the package name from pyproject.toml [project].name.
34+
"""
35+
pyproject_path = project_root / "pyproject.toml"
36+
if not pyproject_path.is_file():
37+
return None
38+
try:
39+
config = tomllib.loads(pyproject_path.read_text(encoding="utf-8"))
40+
return config.get("project", {}).get("name")
41+
except tomllib.TOMLDecodeError:
42+
return None
43+
44+
45+
def get_latest_published_version(package_name: str, config_path: Path) -> str | None:
46+
"""
47+
Fetches the latest published version of a package from PyPI, caching the
48+
result in the project's .jiggle_version.config file.
49+
"""
50+
# --- Read from TOML cache ---
51+
doc = (
52+
tomlkit.parse(config_path.read_text(encoding="utf-8"))
53+
if config_path.is_file()
54+
else tomlkit.document()
55+
)
56+
jiggle_tool_config = doc.get("tool", {}).get("jiggle_version", {})
57+
pypi_cache = jiggle_tool_config.get("pypi_cache", {})
58+
last_checked_str = pypi_cache.get("timestamp")
59+
60+
if last_checked_str:
61+
last_checked = datetime.fromisoformat(last_checked_str)
62+
if datetime.now(timezone.utc) - last_checked < CACHE_TTL:
63+
print(
64+
f" (from cache created at {last_checked.strftime('%Y-%m-%d %H:%M')})"
65+
)
66+
return pypi_cache.get("latest_version")
67+
68+
# --- Fetch from PyPI ---
69+
print(" (querying pypi.org...)")
70+
url = f"https://pypi.org/pypi/{package_name}/json"
71+
latest_version = None
72+
try:
73+
response = requests.get(url, timeout=10)
74+
if response.status_code == 404:
75+
latest_version = None # Package not on PyPI at all
76+
elif response.status_code == 200:
77+
data = response.json()
78+
latest_version = data.get("info", {}).get("version")
79+
# On other errors, we return None to skip the check gracefully
80+
81+
# --- Update TOML cache ---
82+
cache_table = tomlkit.table()
83+
cache_table.add("timestamp", datetime.now(timezone.utc).isoformat())
84+
cache_table.add("latest_version", latest_version)
85+
86+
if "tool" not in doc:
87+
doc.add("tool", tomlkit.table())
88+
if "jiggle_version" not in doc.get("tool", {}): # type: ignore
89+
doc["tool"].add("jiggle_version", tomlkit.table()) # type: ignore
90+
91+
doc["tool"]["jiggle_version"]["pypi_cache"] = cache_table # type: ignore
92+
config_path.write_text(tomlkit.dumps(doc), encoding="utf-8")
93+
94+
except requests.RequestException:
95+
# Network error, skip the check
96+
pass
97+
98+
return latest_version
99+
100+
101+
def check_pypi_publication(
102+
package_name: str, current_version: str, new_version: str, config_path: Path
103+
) -> None:
104+
"""
105+
Checks if the current version is published and allows bumping under specific rules.
106+
"""
107+
latest_published_str = get_latest_published_version(package_name, config_path)
108+
109+
if not latest_published_str:
110+
# NEW BEHAVIOR: If the package has never been published, block the bump.
111+
raise UnpublishedVersionError(
112+
f"Package '{package_name}' is not on PyPI. Publish the initial version first."
113+
)
114+
115+
current_v = Version(current_version)
116+
published_v = Version(latest_published_str)
117+
new_v = Version(new_version)
118+
119+
if current_v > published_v:
120+
if new_v > current_v:
121+
print(
122+
f"🟡 Current version '{current_v}' is unpublished (PyPI has '{published_v}'). "
123+
f"Allowing bump to '{new_v}'."
124+
)
125+
return
126+
raise UnpublishedVersionError(
127+
f"Current version '{current_v}' is not published on PyPI (latest is '{published_v}').\n"
128+
"Cannot perform a redundant bump."
129+
)
130+
elif new_v > published_v:
131+
print(f"✅ PyPI version is '{published_v}'. Bump to '{new_v}' is allowed.")
132+
return
133+
else:
134+
raise UnpublishedVersionError(
135+
f"New version '{new_v}' is not greater than the latest published version on PyPI ('{published_v}')."
136+
)

pyproject.toml

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,28 @@ authors = [{ name = "Matthew Martin", email = "matthewdeanmartin@gmail.com" }]
77
keywords = ["version", "version-numbers"]
88
license = "MIT"
99
requires-python = ">=3.8"
10+
classifiers = [
11+
"Development Status :: 3 - Alpha",
12+
"Programming Language :: Python",
13+
"Programming Language :: Python :: 3.10",
14+
"Programming Language :: Python :: 3.11",
15+
"Programming Language :: Python :: 3.12",
16+
"Programming Language :: Python :: 3.13",
17+
"Programming Language :: Python :: 3.14",
18+
"Programming Language :: Python :: 3.15",
19+
"Programming Language :: Python :: 3.8",
20+
"Programming Language :: Python :: 3.9",
21+
]
1022

1123

1224
dependencies = [
1325
"rich-argparse",
1426
"tomli; python_version < '3.11'",
1527
"pathspec>=0.12",
1628
# "importlib-resources; python_version < '3.9'",
17-
"tomlkit"
29+
"tomlkit",
30+
"requests",
31+
"packaging"
1832
]
1933

2034
[dependency-groups]
@@ -39,6 +53,7 @@ dev = [
3953
# mpy
4054
"mypy; python_version >= '3.8'",
4155
"types-toml; python_version >= '3.8'",
56+
"types-requests",
4257

4358
# reports
4459

test/test_main.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@ def test_bump_dry_run_patch_from_cli(
116116
"--scheme",
117117
"pep440",
118118
"--dry-run",
119+
"--no-check-pypi",
119120
]
120121
)
121122
assert rc == 0
@@ -149,6 +150,7 @@ def test_bump_uses_config_increment_when_not_given(
149150
str(root / "pyproject.toml"),
150151
"bump",
151152
"--dry-run",
153+
"--no-check-pypi",
152154
]
153155
)
154156
assert rc == 0
@@ -184,6 +186,7 @@ def test_bump_force_write_allows_disagreement(
184186
"pep440",
185187
"--dry-run",
186188
"--force-write",
189+
"--no-check-pypi",
187190
]
188191
)
189192
# Without --force-write the code returns 2, so ensure it now proceeds.

0 commit comments

Comments
 (0)