pymergetic-common 0.0.1__py3-none-any.whl
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.
- pymergetic/__init__.py +3 -0
- pymergetic/common/__init__.py +5 -0
- pymergetic/common/__project__.py +18 -0
- pymergetic/common/devtools/__init__.py +75 -0
- pymergetic/common/devtools/pin_pyproject.py +511 -0
- pymergetic/common/devtools/project_paths.py +102 -0
- pymergetic/common/devtools/release_helpers.py +154 -0
- pymergetic/common/devtools/release_tag.py +152 -0
- pymergetic/common/devtools/wait_pypi.py +85 -0
- pymergetic/common/header/__init__.py +20 -0
- pymergetic/common/header/decorators.py +17 -0
- pymergetic/common/header/exceptions.py +42 -0
- pymergetic/common/header/resolve.py +66 -0
- pymergetic/common/header/types.py +127 -0
- pymergetic/common/sysinfo/__init__.py +21 -0
- pymergetic_common-0.0.1.dist-info/METADATA +127 -0
- pymergetic_common-0.0.1.dist-info/RECORD +20 -0
- pymergetic_common-0.0.1.dist-info/WHEEL +5 -0
- pymergetic_common-0.0.1.dist-info/entry_points.txt +4 -0
- pymergetic_common-0.0.1.dist-info/top_level.txt +1 -0
pymergetic/__init__.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
__package_name__ = "pymergetic-common"
|
|
2
|
+
__package_module__ = "pymergetic.common"
|
|
3
|
+
|
|
4
|
+
try:
|
|
5
|
+
from importlib.metadata import version as _pkg_version
|
|
6
|
+
|
|
7
|
+
__version__ = _pkg_version(__package_name__)
|
|
8
|
+
except Exception:
|
|
9
|
+
try:
|
|
10
|
+
from setuptools_scm import get_version as _get_version # type: ignore
|
|
11
|
+
|
|
12
|
+
__version__ = _get_version(
|
|
13
|
+
root="../..", relative_to=__file__, local_scheme="no-local-version"
|
|
14
|
+
)
|
|
15
|
+
except Exception:
|
|
16
|
+
__version__ = "0.0.0"
|
|
17
|
+
|
|
18
|
+
__all__ = ["__package_name__", "__package_module__", "__version__"]
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Developer helpers (PyPI version lookup, bumping ``{dist}~=…`` pins, release tags)."""
|
|
2
|
+
|
|
3
|
+
from pymergetic.common.devtools.pin_pyproject import (
|
|
4
|
+
bump_common_compatible_pins,
|
|
5
|
+
bump_common_compatible_pins_in_file,
|
|
6
|
+
bump_compatible_pins,
|
|
7
|
+
bump_compatible_pins_in_file,
|
|
8
|
+
bump_easybind_compatible_pins,
|
|
9
|
+
bump_easybind_compatible_pins_in_file,
|
|
10
|
+
compatible_pin_versions,
|
|
11
|
+
compatible_release_pin_from_installed_version,
|
|
12
|
+
fetch_pypi_project_json,
|
|
13
|
+
fetch_pypi_version,
|
|
14
|
+
github_owner_repo_from_pypi_distribution,
|
|
15
|
+
installed_distribution_version,
|
|
16
|
+
latest_release_version_from_github,
|
|
17
|
+
pypi_release_exists,
|
|
18
|
+
single_compatible_pin_version,
|
|
19
|
+
wait_pypi_for_compatible_pin,
|
|
20
|
+
)
|
|
21
|
+
from pymergetic.common.devtools.project_paths import (
|
|
22
|
+
compatible_pin_base_distributions,
|
|
23
|
+
find_git_root,
|
|
24
|
+
project_distribution,
|
|
25
|
+
pyproject_path,
|
|
26
|
+
resolve_pin_distribution,
|
|
27
|
+
resolve_project_root,
|
|
28
|
+
resolve_pyproject,
|
|
29
|
+
resolve_wait_distribution,
|
|
30
|
+
)
|
|
31
|
+
from pymergetic.common.devtools.release_helpers import (
|
|
32
|
+
PYPROJECT_AUTO_COMMIT_MSG,
|
|
33
|
+
dirty_paths,
|
|
34
|
+
ensure_clean_worktree,
|
|
35
|
+
latest_v_tag,
|
|
36
|
+
next_v_tag,
|
|
37
|
+
prepare_worktree_for_tag,
|
|
38
|
+
project_name_from_pyproject,
|
|
39
|
+
tag_push_commands,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
__all__ = [
|
|
43
|
+
"bump_common_compatible_pins",
|
|
44
|
+
"bump_common_compatible_pins_in_file",
|
|
45
|
+
"bump_compatible_pins",
|
|
46
|
+
"bump_compatible_pins_in_file",
|
|
47
|
+
"bump_easybind_compatible_pins",
|
|
48
|
+
"bump_easybind_compatible_pins_in_file",
|
|
49
|
+
"compatible_pin_base_distributions",
|
|
50
|
+
"compatible_pin_versions",
|
|
51
|
+
"compatible_release_pin_from_installed_version",
|
|
52
|
+
"dirty_paths",
|
|
53
|
+
"ensure_clean_worktree",
|
|
54
|
+
"fetch_pypi_project_json",
|
|
55
|
+
"fetch_pypi_version",
|
|
56
|
+
"find_git_root",
|
|
57
|
+
"github_owner_repo_from_pypi_distribution",
|
|
58
|
+
"installed_distribution_version",
|
|
59
|
+
"latest_release_version_from_github",
|
|
60
|
+
"latest_v_tag",
|
|
61
|
+
"next_v_tag",
|
|
62
|
+
"prepare_worktree_for_tag",
|
|
63
|
+
"project_distribution",
|
|
64
|
+
"project_name_from_pyproject",
|
|
65
|
+
"pyproject_path",
|
|
66
|
+
"PYPROJECT_AUTO_COMMIT_MSG",
|
|
67
|
+
"pypi_release_exists",
|
|
68
|
+
"resolve_pin_distribution",
|
|
69
|
+
"resolve_project_root",
|
|
70
|
+
"resolve_pyproject",
|
|
71
|
+
"resolve_wait_distribution",
|
|
72
|
+
"single_compatible_pin_version",
|
|
73
|
+
"tag_push_commands",
|
|
74
|
+
"wait_pypi_for_compatible_pin",
|
|
75
|
+
]
|
|
@@ -0,0 +1,511 @@
|
|
|
1
|
+
"""Bump ``{distribution}~=…`` compatible-release pins in a ``pyproject.toml`` string or file."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import sys
|
|
10
|
+
import time
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from urllib.error import HTTPError
|
|
13
|
+
from urllib.request import Request, urlopen
|
|
14
|
+
|
|
15
|
+
from pymergetic.common.devtools.project_paths import (
|
|
16
|
+
resolve_pin_distribution,
|
|
17
|
+
resolve_project_root,
|
|
18
|
+
resolve_pyproject,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
# PEP 440 version suitable for ``~=`` RHS in typical pyproject pins (numeric release).
|
|
22
|
+
_VERSION_RE = re.compile(r"^[0-9]+(?:\.[0-9]+)*$")
|
|
23
|
+
|
|
24
|
+
# Release tags: ``vMAJOR.MINOR.PATCH`` (GitHub / git tag style).
|
|
25
|
+
_V_SEMVER_TAG = re.compile(r"^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _semver_tuple(version: str) -> tuple[int, ...]:
|
|
29
|
+
"""Numeric tuple for comparing ``X.Y.Z`` style pins."""
|
|
30
|
+
return tuple(int(p) for p in version.split("."))
|
|
31
|
+
|
|
32
|
+
# Reject pathological / mistaken distribution strings (full re.escape covers the rest).
|
|
33
|
+
_DIST_OK = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9._-]*$")
|
|
34
|
+
|
|
35
|
+
# ``https://github.com/OWNER/REPO`` (and ``…/tree/…``, ``.git``, …).
|
|
36
|
+
_GITHUB_OWNER_REPO = re.compile(
|
|
37
|
+
r"https?://github\.com/([^/]+)/([^/#?]+)",
|
|
38
|
+
re.IGNORECASE,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _pin_pattern(distribution: str) -> re.Pattern[str]:
|
|
43
|
+
if not _DIST_OK.match(distribution):
|
|
44
|
+
raise ValueError(
|
|
45
|
+
f"invalid distribution name for pin replacement: {distribution!r} "
|
|
46
|
+
"(expected a PyPI-style name, e.g. pymergetic-easybind, cppdantic, scikit-build-core)"
|
|
47
|
+
)
|
|
48
|
+
base = re.escape(distribution)
|
|
49
|
+
return re.compile(rf"({base}(?:\[[^\]]+\])?~=)([0-9]+(?:\.[0-9]+)*)")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def fetch_pypi_project_json(distribution: str, *, timeout_s: float = 30.0) -> dict:
|
|
53
|
+
"""Return the JSON object from ``https://pypi.org/pypi/{distribution}/json``."""
|
|
54
|
+
url = f"https://pypi.org/pypi/{distribution}/json"
|
|
55
|
+
try:
|
|
56
|
+
with urlopen(url, timeout=timeout_s) as r:
|
|
57
|
+
return json.load(r)
|
|
58
|
+
except HTTPError as e:
|
|
59
|
+
if e.code == 404:
|
|
60
|
+
raise ValueError(f"no PyPI project named {distribution!r}") from e
|
|
61
|
+
raise
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def fetch_pypi_version(package: str = "pymergetic-common", *, timeout_s: float = 30.0) -> str:
|
|
65
|
+
"""Return ``info.version`` from ``https://pypi.org/pypi/{package}/json``."""
|
|
66
|
+
data = fetch_pypi_project_json(package, timeout_s=timeout_s)
|
|
67
|
+
return str(data["info"]["version"])
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def github_owner_repo_from_pypi_distribution(distribution: str, *, timeout_s: float = 30.0) -> str:
|
|
71
|
+
"""Return ``OWNER/REPO`` by scanning PyPI ``home_page`` and ``project_urls`` for ``github.com``.
|
|
72
|
+
|
|
73
|
+
Works for any PyPI name whose metadata includes a ``github.com/OWNER/REPO`` link.
|
|
74
|
+
If nothing matches, raises ``ValueError`` — then pass ``--from-github OWNER/REPO`` explicitly.
|
|
75
|
+
"""
|
|
76
|
+
data = fetch_pypi_project_json(distribution, timeout_s=timeout_s)
|
|
77
|
+
info = data.get("info") or {}
|
|
78
|
+
urls: list[str] = []
|
|
79
|
+
pu = info.get("project_urls")
|
|
80
|
+
if isinstance(pu, dict):
|
|
81
|
+
for key in ("Source", "Repository", "Homepage", "Code"):
|
|
82
|
+
v = pu.get(key)
|
|
83
|
+
if isinstance(v, str) and v.strip():
|
|
84
|
+
urls.append(v.strip())
|
|
85
|
+
for v in pu.values():
|
|
86
|
+
if isinstance(v, str) and v.strip() and v.strip() not in urls:
|
|
87
|
+
urls.append(v.strip())
|
|
88
|
+
hp = info.get("home_page")
|
|
89
|
+
if isinstance(hp, str) and hp.strip():
|
|
90
|
+
urls.append(hp.strip())
|
|
91
|
+
|
|
92
|
+
for u in urls:
|
|
93
|
+
m = _GITHUB_OWNER_REPO.search(u)
|
|
94
|
+
if m:
|
|
95
|
+
repo = m.group(2).removesuffix(".git")
|
|
96
|
+
return f"{m.group(1)}/{repo}"
|
|
97
|
+
|
|
98
|
+
raise ValueError(
|
|
99
|
+
f"no github.com URL in PyPI metadata for {distribution!r}; "
|
|
100
|
+
"set project URLs on PyPI or pass --from-github OWNER/REPO"
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def pypi_release_exists(package: str, version: str, *, timeout_s: float = 15.0) -> bool:
|
|
105
|
+
"""Return True if ``https://pypi.org/pypi/{package}/{version}/json`` exists (release uploaded)."""
|
|
106
|
+
url = f"https://pypi.org/pypi/{package}/{version}/json"
|
|
107
|
+
try:
|
|
108
|
+
with urlopen(url, timeout=timeout_s) as r:
|
|
109
|
+
return getattr(r, "status", 200) == 200
|
|
110
|
+
except HTTPError as e:
|
|
111
|
+
if e.code == 404:
|
|
112
|
+
return False
|
|
113
|
+
raise
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def compatible_pin_versions(pyproject_toml: str, distribution: str) -> list[str]:
|
|
117
|
+
"""Return every version string from ``{distribution}~=VERSION`` pins (order preserved)."""
|
|
118
|
+
pat = _pin_pattern(distribution)
|
|
119
|
+
return [m.group(2) for m in pat.finditer(pyproject_toml)]
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def single_compatible_pin_version(
|
|
123
|
+
pyproject_toml: str,
|
|
124
|
+
distribution: str,
|
|
125
|
+
*,
|
|
126
|
+
empty_pins_suffix: str = "",
|
|
127
|
+
) -> str:
|
|
128
|
+
"""Return the version string if all ``{distribution}~=…`` pins agree; else raise ``ValueError``."""
|
|
129
|
+
vers = compatible_pin_versions(pyproject_toml, distribution)
|
|
130
|
+
if not vers:
|
|
131
|
+
raise ValueError(
|
|
132
|
+
f"no `{distribution}~=...` pins in pyproject.toml{empty_pins_suffix}"
|
|
133
|
+
)
|
|
134
|
+
uniq = set(vers)
|
|
135
|
+
if len(uniq) != 1:
|
|
136
|
+
raise ValueError(
|
|
137
|
+
f"{distribution}~= pins disagree: {sorted(uniq)!r}; fix pyproject.toml first"
|
|
138
|
+
)
|
|
139
|
+
return vers[0]
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def wait_pypi_for_compatible_pin(
|
|
143
|
+
pyproject_toml: str,
|
|
144
|
+
distribution: str,
|
|
145
|
+
*,
|
|
146
|
+
timeout_s: float = 1800.0,
|
|
147
|
+
interval_s: float = 30.0,
|
|
148
|
+
verbose: bool = False,
|
|
149
|
+
) -> tuple[str, int]:
|
|
150
|
+
"""Poll PyPI until ``pypi_release_exists(distribution, version)`` for the pin.
|
|
151
|
+
|
|
152
|
+
Uses ``single_compatible_pin_version`` on *pyproject_toml*. Returns
|
|
153
|
+
``(resolved_version, attempt_count)``. Raises ``TimeoutError`` if the release
|
|
154
|
+
never appears within *timeout_s*. With *verbose*, log progress to stdout.
|
|
155
|
+
"""
|
|
156
|
+
version = single_compatible_pin_version(pyproject_toml, distribution)
|
|
157
|
+
if verbose:
|
|
158
|
+
print(
|
|
159
|
+
f"waiting for {distribution}=={version} on PyPI "
|
|
160
|
+
f"(timeout {timeout_s:.0f}s, interval {interval_s:.0f}s)...",
|
|
161
|
+
flush=True,
|
|
162
|
+
)
|
|
163
|
+
deadline = time.monotonic() + timeout_s
|
|
164
|
+
attempt = 0
|
|
165
|
+
while True:
|
|
166
|
+
attempt += 1
|
|
167
|
+
if pypi_release_exists(distribution, version):
|
|
168
|
+
return version, attempt
|
|
169
|
+
if time.monotonic() >= deadline:
|
|
170
|
+
raise TimeoutError(
|
|
171
|
+
f"timed out waiting for {distribution}=={version} on PyPI "
|
|
172
|
+
f"after {timeout_s}s ({attempt} attempts)"
|
|
173
|
+
)
|
|
174
|
+
if verbose:
|
|
175
|
+
remaining = int(max(0, deadline - time.monotonic()))
|
|
176
|
+
print(
|
|
177
|
+
f"attempt {attempt}: not yet (retry in {interval_s:.0f}s, ~{remaining}s left)...",
|
|
178
|
+
flush=True,
|
|
179
|
+
)
|
|
180
|
+
time.sleep(interval_s)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def installed_distribution_version(package: str = "pymergetic-common") -> str:
|
|
184
|
+
"""Return ``importlib.metadata.version(package)`` for the active environment."""
|
|
185
|
+
from importlib.metadata import version
|
|
186
|
+
|
|
187
|
+
return version(package)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def compatible_release_pin_from_installed_version(pep440: str) -> str:
|
|
191
|
+
"""Map an installed PEP 440 version (e.g. setuptools-scm ``0.2.7.post1.dev0``) to ``MAJOR.MINOR.PATCH`` for ``~=`` pins."""
|
|
192
|
+
from packaging.version import Version
|
|
193
|
+
|
|
194
|
+
v = Version(pep440)
|
|
195
|
+
rel = list(v.release)
|
|
196
|
+
if not rel:
|
|
197
|
+
raise ValueError(f"no release segment in {pep440!r}")
|
|
198
|
+
while len(rel) < 3:
|
|
199
|
+
rel.append(0)
|
|
200
|
+
return f"{rel[0]}.{rel[1]}.{rel[2]}"
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _github_request_json(url: str, *, token: str | None, timeout_s: float) -> object:
|
|
204
|
+
req = Request(url, headers={"Accept": "application/vnd.github+json"})
|
|
205
|
+
if token:
|
|
206
|
+
req.add_header("Authorization", f"Bearer {token}")
|
|
207
|
+
with urlopen(req, timeout=timeout_s) as r:
|
|
208
|
+
return json.load(r)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def latest_release_version_from_github(
|
|
212
|
+
owner_repo: str,
|
|
213
|
+
*,
|
|
214
|
+
token: str | None = None,
|
|
215
|
+
timeout_s: float = 30.0,
|
|
216
|
+
) -> str:
|
|
217
|
+
"""Return the highest ``vMAJOR.MINOR.PATCH`` tag on GitHub as ``X.Y.Z`` (no local git clone).
|
|
218
|
+
|
|
219
|
+
Uses ``GET /repos/{owner}/{repo}/tags`` (paginated). Works when the tag exists on GitHub but
|
|
220
|
+
PyPI has not published the wheel yet. *owner_repo* is ``OWNER/REPO``.
|
|
221
|
+
|
|
222
|
+
The tags endpoint can lag a few seconds right after you push a new tag (eventual consistency /
|
|
223
|
+
caching). If the version looks stale, wait and call again.
|
|
224
|
+
|
|
225
|
+
Set ``GITHUB_TOKEN`` or ``GH_TOKEN`` (or pass *token*) for private repos or higher rate limits.
|
|
226
|
+
"""
|
|
227
|
+
s = owner_repo.strip().strip("/")
|
|
228
|
+
parts = s.split("/")
|
|
229
|
+
if len(parts) != 2 or not parts[0] or not parts[1]:
|
|
230
|
+
raise ValueError(f"expected OWNER/REPO, got {owner_repo!r}")
|
|
231
|
+
|
|
232
|
+
owner, repo = parts[0], parts[1]
|
|
233
|
+
tok = token if token is not None else os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN")
|
|
234
|
+
|
|
235
|
+
best: tuple[int, int, int] | None = None
|
|
236
|
+
best_ver: str | None = None
|
|
237
|
+
page = 1
|
|
238
|
+
per_page = 100
|
|
239
|
+
while page <= 100:
|
|
240
|
+
url = f"https://api.github.com/repos/{owner}/{repo}/tags?per_page={per_page}&page={page}"
|
|
241
|
+
try:
|
|
242
|
+
data = _github_request_json(url, token=tok, timeout_s=timeout_s)
|
|
243
|
+
except HTTPError as e:
|
|
244
|
+
detail = ""
|
|
245
|
+
try:
|
|
246
|
+
detail = e.read().decode("utf-8", errors="replace")[:500]
|
|
247
|
+
except Exception:
|
|
248
|
+
pass
|
|
249
|
+
raise ValueError(
|
|
250
|
+
f"GitHub API HTTP {e.code} for {owner}/{repo} (set GITHUB_TOKEN for private repos): {detail}"
|
|
251
|
+
) from e
|
|
252
|
+
if not isinstance(data, list) or len(data) == 0:
|
|
253
|
+
break
|
|
254
|
+
for item in data:
|
|
255
|
+
if not isinstance(item, dict):
|
|
256
|
+
continue
|
|
257
|
+
name = item.get("name")
|
|
258
|
+
if not isinstance(name, str):
|
|
259
|
+
continue
|
|
260
|
+
m = _V_SEMVER_TAG.fullmatch(name.strip())
|
|
261
|
+
if not m:
|
|
262
|
+
continue
|
|
263
|
+
t = (int(m.group(1)), int(m.group(2)), int(m.group(3)))
|
|
264
|
+
if best is None or t > best:
|
|
265
|
+
best = t
|
|
266
|
+
best_ver = f"{m.group(1)}.{m.group(2)}.{m.group(3)}"
|
|
267
|
+
if len(data) < per_page:
|
|
268
|
+
break
|
|
269
|
+
page += 1
|
|
270
|
+
|
|
271
|
+
if best_ver is None:
|
|
272
|
+
raise ValueError(f"no vMAJOR.MINOR.PATCH tags found for github.com/{owner}/{repo}")
|
|
273
|
+
return best_ver
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def bump_compatible_pins(pyproject_toml: str, distribution: str, version: str) -> tuple[str, int]:
|
|
277
|
+
"""Replace each ``{distribution}~=X.Y.Z`` with ``{distribution}~={version}``.
|
|
278
|
+
|
|
279
|
+
Returns ``(new_text, replacement_count)``.
|
|
280
|
+
"""
|
|
281
|
+
if not _VERSION_RE.match(version):
|
|
282
|
+
raise ValueError(f"invalid PEP 440 version for pin: {version!r}")
|
|
283
|
+
|
|
284
|
+
pat = _pin_pattern(distribution)
|
|
285
|
+
|
|
286
|
+
def repl(m: re.Match[str]) -> str:
|
|
287
|
+
return f"{m.group(1)}{version}"
|
|
288
|
+
|
|
289
|
+
new_text, n = pat.subn(repl, pyproject_toml)
|
|
290
|
+
return new_text, n
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def bump_compatible_pins_in_file(
|
|
294
|
+
pyproject: Path | str,
|
|
295
|
+
distribution: str,
|
|
296
|
+
version: str,
|
|
297
|
+
*,
|
|
298
|
+
dry_run: bool = False,
|
|
299
|
+
) -> int:
|
|
300
|
+
"""Read ``pyproject.toml``, apply :func:`bump_compatible_pins`, write back unless ``dry_run``.
|
|
301
|
+
|
|
302
|
+
Returns the number of replacements. Raises ``ValueError`` if no matching pin exists.
|
|
303
|
+
"""
|
|
304
|
+
path = Path(pyproject)
|
|
305
|
+
text = path.read_text(encoding="utf-8")
|
|
306
|
+
new_text, n = bump_compatible_pins(text, distribution, version)
|
|
307
|
+
if n == 0:
|
|
308
|
+
raise ValueError(f"no `{distribution}~=...` pin found in {path}")
|
|
309
|
+
if new_text == text:
|
|
310
|
+
return n
|
|
311
|
+
if not dry_run:
|
|
312
|
+
path.write_text(new_text, encoding="utf-8")
|
|
313
|
+
return n
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def bump_common_compatible_pins(pyproject_toml: str, version: str) -> tuple[str, int]:
|
|
317
|
+
"""Same as :func:`bump_compatible_pins` with ``distribution=\"pymergetic-common\"``."""
|
|
318
|
+
return bump_compatible_pins(pyproject_toml, "pymergetic-common", version)
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def bump_common_compatible_pins_in_file(
|
|
322
|
+
pyproject: Path | str,
|
|
323
|
+
version: str,
|
|
324
|
+
*,
|
|
325
|
+
dry_run: bool = False,
|
|
326
|
+
) -> int:
|
|
327
|
+
"""Same as :func:`bump_compatible_pins_in_file` with ``distribution=\"pymergetic-common\"``."""
|
|
328
|
+
return bump_compatible_pins_in_file(pyproject, "pymergetic-common", version, dry_run=dry_run)
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def bump_easybind_compatible_pins(pyproject_toml: str, version: str) -> tuple[str, int]:
|
|
332
|
+
"""Same as :func:`bump_compatible_pins` with ``distribution=\"pymergetic-easybind\"``."""
|
|
333
|
+
return bump_compatible_pins(pyproject_toml, "pymergetic-easybind", version)
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def bump_easybind_compatible_pins_in_file(
|
|
337
|
+
pyproject: Path | str,
|
|
338
|
+
version: str,
|
|
339
|
+
*,
|
|
340
|
+
dry_run: bool = False,
|
|
341
|
+
) -> int:
|
|
342
|
+
"""Same as :func:`bump_compatible_pins_in_file` with ``distribution=\"pymergetic-easybind\"``."""
|
|
343
|
+
return bump_compatible_pins_in_file(pyproject, "pymergetic-easybind", version, dry_run=dry_run)
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def main(argv: list[str] | None = None) -> int:
|
|
347
|
+
"""CLI: ``pymergetic-pin-pyproject`` (see ``--help``)."""
|
|
348
|
+
ap = argparse.ArgumentParser(
|
|
349
|
+
description=(
|
|
350
|
+
"Set every {distribution}~= pin in pyproject.toml. "
|
|
351
|
+
"Default version is the highest vX.Y.Z tag on GitHub for that distribution's repo "
|
|
352
|
+
"(github.com/OWNER/REPO is read from PyPI project URLs; override with --from-github ORG/REPO). "
|
|
353
|
+
"The GitHub tags API can lag briefly after a new tag push. "
|
|
354
|
+
"Use --from-pypi for PyPI's published latest instead. "
|
|
355
|
+
"--version / --installed are explicit overrides.\n\n"
|
|
356
|
+
"``--distribution`` is optional when the project has exactly one external "
|
|
357
|
+
"compatible-release pin (not ``[project].name``)."
|
|
358
|
+
)
|
|
359
|
+
)
|
|
360
|
+
ap.add_argument(
|
|
361
|
+
"--project-root",
|
|
362
|
+
type=Path,
|
|
363
|
+
default=None,
|
|
364
|
+
help="package directory containing pyproject.toml (relative or absolute; default: cwd)",
|
|
365
|
+
)
|
|
366
|
+
ap.add_argument(
|
|
367
|
+
"--distribution",
|
|
368
|
+
"-d",
|
|
369
|
+
default=None,
|
|
370
|
+
metavar="NAME",
|
|
371
|
+
help="PyPI distribution whose ~= pins to bump (default: infer from pyproject.toml)",
|
|
372
|
+
)
|
|
373
|
+
ap.add_argument(
|
|
374
|
+
"--pyproject",
|
|
375
|
+
type=Path,
|
|
376
|
+
default=None,
|
|
377
|
+
help="path to pyproject.toml or its parent directory (overrides --project-root)",
|
|
378
|
+
)
|
|
379
|
+
ap.add_argument(
|
|
380
|
+
"--version",
|
|
381
|
+
metavar="X.Y.Z",
|
|
382
|
+
default=None,
|
|
383
|
+
help="pin to this exact release (e.g. CI still publishing or tags API stale)",
|
|
384
|
+
)
|
|
385
|
+
ap.add_argument(
|
|
386
|
+
"--installed",
|
|
387
|
+
action="store_true",
|
|
388
|
+
help="use installed version for --distribution, normalized to X.Y.Z (dev/post/local stripped)",
|
|
389
|
+
)
|
|
390
|
+
ap.add_argument(
|
|
391
|
+
"--from-pypi",
|
|
392
|
+
action="store_true",
|
|
393
|
+
help=(
|
|
394
|
+
"use latest published version from PyPI (pypi.org/.../json info.version) instead of "
|
|
395
|
+
"the default (latest GitHub v* tag)"
|
|
396
|
+
),
|
|
397
|
+
)
|
|
398
|
+
ap.add_argument(
|
|
399
|
+
"--from-github",
|
|
400
|
+
nargs="?",
|
|
401
|
+
const="",
|
|
402
|
+
default=None,
|
|
403
|
+
metavar="OWNER/REPO",
|
|
404
|
+
help=(
|
|
405
|
+
"same default as no flag: pin to highest vMAJOR.MINOR.PATCH tag on GitHub. "
|
|
406
|
+
"If you pass OWNER/REPO, use that repo instead of discovering it from PyPI metadata. "
|
|
407
|
+
"The tags list can lag a few seconds after you push a new tag; re-run if the pin looks stale. "
|
|
408
|
+
"Set GITHUB_TOKEN for private repos."
|
|
409
|
+
),
|
|
410
|
+
)
|
|
411
|
+
ap.add_argument("--dry-run", action="store_true", help="do not write the file")
|
|
412
|
+
ns = ap.parse_args(argv)
|
|
413
|
+
|
|
414
|
+
nsrc = sum(
|
|
415
|
+
1
|
|
416
|
+
for x in (
|
|
417
|
+
ns.version is not None,
|
|
418
|
+
ns.installed,
|
|
419
|
+
ns.from_pypi,
|
|
420
|
+
ns.from_github is not None,
|
|
421
|
+
)
|
|
422
|
+
if x
|
|
423
|
+
)
|
|
424
|
+
if nsrc > 1:
|
|
425
|
+
print(
|
|
426
|
+
"error: use at most one of --version, --installed, --from-pypi, or --from-github",
|
|
427
|
+
file=sys.stderr,
|
|
428
|
+
)
|
|
429
|
+
return 2
|
|
430
|
+
|
|
431
|
+
pyproject = resolve_pyproject(project_root=resolve_project_root(ns.project_root), pyproject=ns.pyproject)
|
|
432
|
+
if not pyproject.is_file():
|
|
433
|
+
print(f"error: {pyproject} not found", file=sys.stderr)
|
|
434
|
+
return 2
|
|
435
|
+
|
|
436
|
+
text = pyproject.read_text(encoding="utf-8")
|
|
437
|
+
try:
|
|
438
|
+
dist = resolve_pin_distribution(text, pyproject.parent, ns.distribution)
|
|
439
|
+
except ValueError as e:
|
|
440
|
+
print(f"error: {e}", file=sys.stderr)
|
|
441
|
+
return 2
|
|
442
|
+
|
|
443
|
+
ver_source: str
|
|
444
|
+
if ns.installed:
|
|
445
|
+
raw_installed = installed_distribution_version(dist)
|
|
446
|
+
try:
|
|
447
|
+
ver = compatible_release_pin_from_installed_version(raw_installed)
|
|
448
|
+
except ValueError as e:
|
|
449
|
+
print(f"error: {e}", file=sys.stderr)
|
|
450
|
+
return 1
|
|
451
|
+
if raw_installed != ver:
|
|
452
|
+
print(
|
|
453
|
+
f"note: installed {dist}=={raw_installed!r} -> pin ~={ver} (PEP 440 release triple)",
|
|
454
|
+
file=sys.stderr,
|
|
455
|
+
)
|
|
456
|
+
ver_source = "installed"
|
|
457
|
+
elif ns.from_pypi:
|
|
458
|
+
try:
|
|
459
|
+
ver = fetch_pypi_version(dist)
|
|
460
|
+
except ValueError as e:
|
|
461
|
+
print(f"error: {e}", file=sys.stderr)
|
|
462
|
+
return 1
|
|
463
|
+
ver_source = "pypi"
|
|
464
|
+
elif ns.version is not None:
|
|
465
|
+
ver = ns.version.strip()
|
|
466
|
+
ver_source = "explicit"
|
|
467
|
+
else:
|
|
468
|
+
# Default and --from-github [OWNER/REPO]: latest v* tag on GitHub.
|
|
469
|
+
raw = ns.from_github.strip() if ns.from_github is not None else ""
|
|
470
|
+
try:
|
|
471
|
+
if not raw:
|
|
472
|
+
owner_repo = github_owner_repo_from_pypi_distribution(dist)
|
|
473
|
+
else:
|
|
474
|
+
owner_repo = raw
|
|
475
|
+
ver = latest_release_version_from_github(owner_repo)
|
|
476
|
+
except ValueError as e:
|
|
477
|
+
print(f"error: {e}", file=sys.stderr)
|
|
478
|
+
return 1
|
|
479
|
+
ver_source = "github"
|
|
480
|
+
|
|
481
|
+
if not _VERSION_RE.match(ver):
|
|
482
|
+
print(f"error: bad version string: {ver!r}", file=sys.stderr)
|
|
483
|
+
return 2
|
|
484
|
+
|
|
485
|
+
try:
|
|
486
|
+
n = bump_compatible_pins_in_file(pyproject, dist, ver, dry_run=ns.dry_run)
|
|
487
|
+
except ValueError as e:
|
|
488
|
+
print(f"error: {e}", file=sys.stderr)
|
|
489
|
+
return 1
|
|
490
|
+
|
|
491
|
+
action = "would update" if ns.dry_run else "updated"
|
|
492
|
+
print(f"{action} {n} {dist}~= pin(s) to ~={ver} in {pyproject}")
|
|
493
|
+
|
|
494
|
+
# PyPI / venv often lag a v* tag on GitHub (default pin source is GitHub).
|
|
495
|
+
if ns.dry_run and ver_source in ("pypi", "installed"):
|
|
496
|
+
try:
|
|
497
|
+
or_ = github_owner_repo_from_pypi_distribution(dist)
|
|
498
|
+
gh_ver = latest_release_version_from_github(or_)
|
|
499
|
+
if _semver_tuple(gh_ver) > _semver_tuple(ver):
|
|
500
|
+
print(
|
|
501
|
+
f"hint: github.com/{or_} has v{gh_ver} but {ver_source} gave {ver}. "
|
|
502
|
+
f"Default pinning follows GitHub tags; run: pymergetic-pin-pyproject --project-root {pyproject.parent} --dry-run",
|
|
503
|
+
)
|
|
504
|
+
except ValueError:
|
|
505
|
+
pass
|
|
506
|
+
|
|
507
|
+
return 0
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
if __name__ == "__main__":
|
|
511
|
+
raise SystemExit(main())
|