easybind 0.2.5__tar.gz → 0.2.8__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.
Files changed (38) hide show
  1. {easybind-0.2.5 → easybind-0.2.8}/PKG-INFO +7 -2
  2. {easybind-0.2.5 → easybind-0.2.8}/README.md +5 -1
  3. {easybind-0.2.5 → easybind-0.2.8}/pyproject.toml +1 -1
  4. {easybind-0.2.5 → easybind-0.2.8}/src/easybind/devtools/__init__.py +8 -0
  5. easybind-0.2.8/src/easybind/devtools/pin_pyproject.py +454 -0
  6. easybind-0.2.5/src/easybind/devtools/pin_pyproject.py +0 -251
  7. {easybind-0.2.5 → easybind-0.2.8}/.clangd +0 -0
  8. {easybind-0.2.5 → easybind-0.2.8}/.github/workflows/publish.yml +0 -0
  9. {easybind-0.2.5 → easybind-0.2.8}/.gitignore +0 -0
  10. {easybind-0.2.5 → easybind-0.2.8}/.vscode/tasks.json +0 -0
  11. {easybind-0.2.5 → easybind-0.2.8}/CMakeLists.txt +0 -0
  12. {easybind-0.2.5 → easybind-0.2.8}/LICENSE +0 -0
  13. {easybind-0.2.5 → easybind-0.2.8}/NOTICE +0 -0
  14. {easybind-0.2.5 → easybind-0.2.8}/RELEASING.md +0 -0
  15. {easybind-0.2.5 → easybind-0.2.8}/cmake/easybind_dependencies.cmake +0 -0
  16. {easybind-0.2.5 → easybind-0.2.8}/cmake/easybind_pip.cmake +0 -0
  17. {easybind-0.2.5 → easybind-0.2.8}/pyrightconfig.json +0 -0
  18. {easybind-0.2.5 → easybind-0.2.8}/scripts/clangd-update.sh +0 -0
  19. {easybind-0.2.5 → easybind-0.2.8}/scripts/release_tag.py +0 -0
  20. {easybind-0.2.5 → easybind-0.2.8}/scripts/wait_pypi_release.py +0 -0
  21. {easybind-0.2.5 → easybind-0.2.8}/src/easybind/__init__.cpp +0 -0
  22. {easybind-0.2.5 → easybind-0.2.8}/src/easybind/bind.hpp +0 -0
  23. {easybind-0.2.5 → easybind-0.2.8}/src/easybind/devtools/release_helpers.py +0 -0
  24. {easybind-0.2.5 → easybind-0.2.8}/src/easybind/devtools/release_tag.py +0 -0
  25. {easybind-0.2.5 → easybind-0.2.8}/src/easybind/devtools/wait_pypi.py +0 -0
  26. {easybind-0.2.5 → easybind-0.2.8}/src/easybind/export.hpp +0 -0
  27. {easybind-0.2.5 → easybind-0.2.8}/src/easybind/module/__init__.cpp +0 -0
  28. {easybind-0.2.5 → easybind-0.2.8}/src/easybind/module/node.cpp +0 -0
  29. {easybind-0.2.5 → easybind-0.2.8}/src/easybind/module/node.hpp +0 -0
  30. {easybind-0.2.5 → easybind-0.2.8}/src/easybind/module/node__init__.cpp +0 -0
  31. {easybind-0.2.5 → easybind-0.2.8}/src/easybind/module/ns_module.hpp +0 -0
  32. {easybind-0.2.5 → easybind-0.2.8}/src/easybind/py.typed +0 -0
  33. {easybind-0.2.5 → easybind-0.2.8}/src/easybind/sample/__init__.cpp +0 -0
  34. {easybind-0.2.5 → easybind-0.2.8}/src/easybind/sample/sample.cpp +0 -0
  35. {easybind-0.2.5 → easybind-0.2.8}/src/easybind/sample/sample.hpp +0 -0
  36. {easybind-0.2.5 → easybind-0.2.8}/src/easybind/sample/test1.py +0 -0
  37. {easybind-0.2.5 → easybind-0.2.8}/src/easybind/sample/test2/__init__.py +0 -0
  38. {easybind-0.2.5 → easybind-0.2.8}/tests/test_sample.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: easybind
3
- Version: 0.2.5
3
+ Version: 0.2.8
4
4
  Summary: Self-registering nanobind helpers (import as easybind)
5
5
  Keywords: easybind,nanobind,bindings,pymergetic
6
6
  Author-Email: PymergeticOS Maintainers <raudzus@pymergetic.com>
@@ -21,6 +21,7 @@ Project-URL: Repository, https://github.com/pymergetic/easybind
21
21
  Project-URL: Issues, https://github.com/pymergetic/easybind/issues
22
22
  Requires-Python: >=3.11
23
23
  Requires-Dist: nanobind~=2.12.0
24
+ Requires-Dist: packaging>=23
24
25
  Provides-Extra: dev
25
26
  Requires-Dist: pytest>=7.0.0; extra == "dev"
26
27
  Requires-Dist: build>=1.0.0; extra == "dev"
@@ -90,17 +91,21 @@ Use **`easybind.devtools`** or the **`easybind-pin-pyproject`** CLI to rewrite e
90
91
  - **cppdantic** pinning **easybind** in several tables.
91
92
  - A future project pinning **cppdantic** the same way: pass **`--distribution cppdantic`**.
92
93
 
93
- **CLI** (defaults: **`distribution=easybind`**, version = latest on PyPI for that distribution; run from the tree that contains **`pyproject.toml`**):
94
+ **CLI** (defaults: **`distribution=easybind`**, version = latest on PyPI; run from the tree that contains **`pyproject.toml`**):
94
95
 
95
96
  ```bash
96
97
  easybind-pin-pyproject --dry-run
97
98
  easybind-pin-pyproject
99
+ easybind-pin-pyproject --from-github # GitHub OWNER/REPO from PyPI metadata for --distribution
100
+ easybind-pin-pyproject --from-github ORG/REPO # override when PyPI has no GitHub URL
98
101
  easybind-pin-pyproject --installed
99
102
  easybind-pin-pyproject --version 0.2.3
100
103
  easybind-pin-pyproject --distribution cppdantic
101
104
  easybind-pin-pyproject --pyproject /path/to/pyproject.toml
102
105
  ```
103
106
 
107
+ Use **`GITHUB_TOKEN`** for private GitHub repos or higher API rate limits.
108
+
104
109
  **Other devtools CLIs** (after **`pip install`** / **`uv pip install -e .`**, or run **`scripts/…`** shims from a git checkout):
105
110
 
106
111
  ```bash
@@ -61,17 +61,21 @@ Use **`easybind.devtools`** or the **`easybind-pin-pyproject`** CLI to rewrite e
61
61
  - **cppdantic** pinning **easybind** in several tables.
62
62
  - A future project pinning **cppdantic** the same way: pass **`--distribution cppdantic`**.
63
63
 
64
- **CLI** (defaults: **`distribution=easybind`**, version = latest on PyPI for that distribution; run from the tree that contains **`pyproject.toml`**):
64
+ **CLI** (defaults: **`distribution=easybind`**, version = latest on PyPI; run from the tree that contains **`pyproject.toml`**):
65
65
 
66
66
  ```bash
67
67
  easybind-pin-pyproject --dry-run
68
68
  easybind-pin-pyproject
69
+ easybind-pin-pyproject --from-github # GitHub OWNER/REPO from PyPI metadata for --distribution
70
+ easybind-pin-pyproject --from-github ORG/REPO # override when PyPI has no GitHub URL
69
71
  easybind-pin-pyproject --installed
70
72
  easybind-pin-pyproject --version 0.2.3
71
73
  easybind-pin-pyproject --distribution cppdantic
72
74
  easybind-pin-pyproject --pyproject /path/to/pyproject.toml
73
75
  ```
74
76
 
77
+ Use **`GITHUB_TOKEN`** for private GitHub repos or higher API rate limits.
78
+
75
79
  **Other devtools CLIs** (after **`pip install`** / **`uv pip install -e .`**, or run **`scripts/…`** shims from a git checkout):
76
80
 
77
81
  ```bash
@@ -8,7 +8,7 @@ dynamic = ["version"]
8
8
  description = "Self-registering nanobind helpers (import as easybind)"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
11
- dependencies = ["nanobind~=2.12.0"]
11
+ dependencies = ["nanobind~=2.12.0", "packaging>=23"]
12
12
  license = "Apache-2.0"
13
13
  license-files = ["LICENSE", "NOTICE"]
14
14
  authors = [
@@ -6,8 +6,12 @@ from easybind.devtools.pin_pyproject import (
6
6
  bump_easybind_compatible_pins,
7
7
  bump_easybind_compatible_pins_in_file,
8
8
  compatible_pin_versions,
9
+ compatible_release_pin_from_installed_version,
10
+ fetch_pypi_project_json,
9
11
  fetch_pypi_version,
12
+ github_owner_repo_from_pypi_distribution,
10
13
  installed_distribution_version,
14
+ latest_release_version_from_github,
11
15
  pypi_release_exists,
12
16
  single_compatible_pin_version,
13
17
  wait_pypi_for_compatible_pin,
@@ -25,9 +29,13 @@ __all__ = [
25
29
  "bump_easybind_compatible_pins",
26
30
  "bump_easybind_compatible_pins_in_file",
27
31
  "compatible_pin_versions",
32
+ "compatible_release_pin_from_installed_version",
28
33
  "ensure_clean_worktree",
34
+ "fetch_pypi_project_json",
29
35
  "fetch_pypi_version",
36
+ "github_owner_repo_from_pypi_distribution",
30
37
  "installed_distribution_version",
38
+ "latest_release_version_from_github",
31
39
  "latest_v_tag",
32
40
  "next_v_tag",
33
41
  "pypi_release_exists",
@@ -0,0 +1,454 @@
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
+ # PEP 440 version suitable for ``~=`` RHS in typical pyproject pins (numeric release).
16
+ _VERSION_RE = re.compile(r"^[0-9]+(?:\.[0-9]+)*$")
17
+
18
+ # Release tags: ``vMAJOR.MINOR.PATCH`` (GitHub / git tag style).
19
+ _V_SEMVER_TAG = re.compile(r"^v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$")
20
+
21
+
22
+ def _semver_tuple(version: str) -> tuple[int, ...]:
23
+ """Numeric tuple for comparing ``X.Y.Z`` style pins."""
24
+ return tuple(int(p) for p in version.split("."))
25
+
26
+ # Reject pathological / mistaken distribution strings (full re.escape covers the rest).
27
+ _DIST_OK = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9._-]*$")
28
+
29
+ # ``https://github.com/OWNER/REPO`` (and ``…/tree/…``, ``.git``, …).
30
+ _GITHUB_OWNER_REPO = re.compile(
31
+ r"https?://github\.com/([^/]+)/([^/#?]+)",
32
+ re.IGNORECASE,
33
+ )
34
+
35
+
36
+ def _pin_pattern(distribution: str) -> re.Pattern[str]:
37
+ if not _DIST_OK.match(distribution):
38
+ raise ValueError(
39
+ f"invalid distribution name for pin replacement: {distribution!r} "
40
+ "(expected a PyPI-style name, e.g. easybind, cppdantic, scikit-build-core)"
41
+ )
42
+ return re.compile(rf"({re.escape(distribution)}~=)([0-9]+(?:\.[0-9]+)*)")
43
+
44
+
45
+ def fetch_pypi_project_json(distribution: str, *, timeout_s: float = 30.0) -> dict:
46
+ """Return the JSON object from ``https://pypi.org/pypi/{distribution}/json``."""
47
+ url = f"https://pypi.org/pypi/{distribution}/json"
48
+ try:
49
+ with urlopen(url, timeout=timeout_s) as r:
50
+ return json.load(r)
51
+ except HTTPError as e:
52
+ if e.code == 404:
53
+ raise ValueError(f"no PyPI project named {distribution!r}") from e
54
+ raise
55
+
56
+
57
+ def fetch_pypi_version(package: str = "easybind", *, timeout_s: float = 30.0) -> str:
58
+ """Return ``info.version`` from ``https://pypi.org/pypi/{package}/json``."""
59
+ data = fetch_pypi_project_json(package, timeout_s=timeout_s)
60
+ return str(data["info"]["version"])
61
+
62
+
63
+ def github_owner_repo_from_pypi_distribution(distribution: str, *, timeout_s: float = 30.0) -> str:
64
+ """Return ``OWNER/REPO`` by scanning PyPI ``home_page`` and ``project_urls`` for ``github.com``.
65
+
66
+ Works for any PyPI name whose metadata includes a ``github.com/OWNER/REPO`` link.
67
+ If nothing matches, raises ``ValueError`` — then pass ``--from-github OWNER/REPO`` explicitly.
68
+ """
69
+ data = fetch_pypi_project_json(distribution, timeout_s=timeout_s)
70
+ info = data.get("info") or {}
71
+ urls: list[str] = []
72
+ pu = info.get("project_urls")
73
+ if isinstance(pu, dict):
74
+ for key in ("Source", "Repository", "Homepage", "Code"):
75
+ v = pu.get(key)
76
+ if isinstance(v, str) and v.strip():
77
+ urls.append(v.strip())
78
+ for v in pu.values():
79
+ if isinstance(v, str) and v.strip() and v.strip() not in urls:
80
+ urls.append(v.strip())
81
+ hp = info.get("home_page")
82
+ if isinstance(hp, str) and hp.strip():
83
+ urls.append(hp.strip())
84
+
85
+ for u in urls:
86
+ m = _GITHUB_OWNER_REPO.search(u)
87
+ if m:
88
+ repo = m.group(2).removesuffix(".git")
89
+ return f"{m.group(1)}/{repo}"
90
+
91
+ raise ValueError(
92
+ f"no github.com URL in PyPI metadata for {distribution!r}; "
93
+ "set project URLs on PyPI or pass --from-github OWNER/REPO"
94
+ )
95
+
96
+
97
+ def pypi_release_exists(package: str, version: str, *, timeout_s: float = 15.0) -> bool:
98
+ """Return True if ``https://pypi.org/pypi/{package}/{version}/json`` exists (release uploaded)."""
99
+ url = f"https://pypi.org/pypi/{package}/{version}/json"
100
+ try:
101
+ with urlopen(url, timeout=timeout_s) as r:
102
+ return getattr(r, "status", 200) == 200
103
+ except HTTPError as e:
104
+ if e.code == 404:
105
+ return False
106
+ raise
107
+
108
+
109
+ def compatible_pin_versions(pyproject_toml: str, distribution: str) -> list[str]:
110
+ """Return every version string from ``{distribution}~=VERSION`` pins (order preserved)."""
111
+ pat = _pin_pattern(distribution)
112
+ return [m.group(2) for m in pat.finditer(pyproject_toml)]
113
+
114
+
115
+ def single_compatible_pin_version(
116
+ pyproject_toml: str,
117
+ distribution: str,
118
+ *,
119
+ empty_pins_suffix: str = "",
120
+ ) -> str:
121
+ """Return the version string if all ``{distribution}~=…`` pins agree; else raise ``ValueError``."""
122
+ vers = compatible_pin_versions(pyproject_toml, distribution)
123
+ if not vers:
124
+ raise ValueError(
125
+ f"no `{distribution}~=...` pins in pyproject.toml{empty_pins_suffix}"
126
+ )
127
+ uniq = set(vers)
128
+ if len(uniq) != 1:
129
+ raise ValueError(
130
+ f"{distribution}~= pins disagree: {sorted(uniq)!r}; fix pyproject.toml first"
131
+ )
132
+ return vers[0]
133
+
134
+
135
+ def wait_pypi_for_compatible_pin(
136
+ pyproject_toml: str,
137
+ distribution: str,
138
+ *,
139
+ timeout_s: float = 1800.0,
140
+ interval_s: float = 30.0,
141
+ verbose: bool = False,
142
+ ) -> tuple[str, int]:
143
+ """Poll PyPI until ``pypi_release_exists(distribution, version)`` for the pin.
144
+
145
+ Uses ``single_compatible_pin_version`` on *pyproject_toml*. Returns
146
+ ``(resolved_version, attempt_count)``. Raises ``TimeoutError`` if the release
147
+ never appears within *timeout_s*. With *verbose*, log progress to stdout.
148
+ """
149
+ version = single_compatible_pin_version(pyproject_toml, distribution)
150
+ if verbose:
151
+ print(
152
+ f"waiting for {distribution}=={version} on PyPI "
153
+ f"(timeout {timeout_s:.0f}s, interval {interval_s:.0f}s)...",
154
+ flush=True,
155
+ )
156
+ deadline = time.monotonic() + timeout_s
157
+ attempt = 0
158
+ while True:
159
+ attempt += 1
160
+ if pypi_release_exists(distribution, version):
161
+ return version, attempt
162
+ if time.monotonic() >= deadline:
163
+ raise TimeoutError(
164
+ f"timed out waiting for {distribution}=={version} on PyPI "
165
+ f"after {timeout_s}s ({attempt} attempts)"
166
+ )
167
+ if verbose:
168
+ remaining = int(max(0, deadline - time.monotonic()))
169
+ print(
170
+ f"attempt {attempt}: not yet (retry in {interval_s:.0f}s, ~{remaining}s left)...",
171
+ flush=True,
172
+ )
173
+ time.sleep(interval_s)
174
+
175
+
176
+ def installed_distribution_version(package: str = "easybind") -> str:
177
+ """Return ``importlib.metadata.version(package)`` for the active environment."""
178
+ from importlib.metadata import version
179
+
180
+ return version(package)
181
+
182
+
183
+ def compatible_release_pin_from_installed_version(pep440: str) -> str:
184
+ """Map an installed PEP 440 version (e.g. setuptools-scm ``0.2.7.post1.dev0``) to ``MAJOR.MINOR.PATCH`` for ``~=`` pins."""
185
+ from packaging.version import Version
186
+
187
+ v = Version(pep440)
188
+ rel = list(v.release)
189
+ if not rel:
190
+ raise ValueError(f"no release segment in {pep440!r}")
191
+ while len(rel) < 3:
192
+ rel.append(0)
193
+ return f"{rel[0]}.{rel[1]}.{rel[2]}"
194
+
195
+
196
+ def _github_request_json(url: str, *, token: str | None, timeout_s: float) -> object:
197
+ req = Request(url, headers={"Accept": "application/vnd.github+json"})
198
+ if token:
199
+ req.add_header("Authorization", f"Bearer {token}")
200
+ with urlopen(req, timeout=timeout_s) as r:
201
+ return json.load(r)
202
+
203
+
204
+ def latest_release_version_from_github(
205
+ owner_repo: str,
206
+ *,
207
+ token: str | None = None,
208
+ timeout_s: float = 30.0,
209
+ ) -> str:
210
+ """Return the highest ``vMAJOR.MINOR.PATCH`` tag on GitHub as ``X.Y.Z`` (no local git clone).
211
+
212
+ Uses ``GET /repos/{owner}/{repo}/tags`` (paginated). Works when the tag exists on GitHub but
213
+ PyPI has not published the wheel yet. *owner_repo* is ``OWNER/REPO``.
214
+
215
+ Set ``GITHUB_TOKEN`` or ``GH_TOKEN`` (or pass *token*) for private repos or higher rate limits.
216
+ """
217
+ s = owner_repo.strip().strip("/")
218
+ parts = s.split("/")
219
+ if len(parts) != 2 or not parts[0] or not parts[1]:
220
+ raise ValueError(f"expected OWNER/REPO, got {owner_repo!r}")
221
+
222
+ owner, repo = parts[0], parts[1]
223
+ tok = token if token is not None else os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN")
224
+
225
+ best: tuple[int, int, int] | None = None
226
+ best_ver: str | None = None
227
+ page = 1
228
+ per_page = 100
229
+ while page <= 100:
230
+ url = f"https://api.github.com/repos/{owner}/{repo}/tags?per_page={per_page}&page={page}"
231
+ try:
232
+ data = _github_request_json(url, token=tok, timeout_s=timeout_s)
233
+ except HTTPError as e:
234
+ detail = ""
235
+ try:
236
+ detail = e.read().decode("utf-8", errors="replace")[:500]
237
+ except Exception:
238
+ pass
239
+ raise ValueError(
240
+ f"GitHub API HTTP {e.code} for {owner}/{repo} (set GITHUB_TOKEN for private repos): {detail}"
241
+ ) from e
242
+ if not isinstance(data, list) or len(data) == 0:
243
+ break
244
+ for item in data:
245
+ if not isinstance(item, dict):
246
+ continue
247
+ name = item.get("name")
248
+ if not isinstance(name, str):
249
+ continue
250
+ m = _V_SEMVER_TAG.fullmatch(name.strip())
251
+ if not m:
252
+ continue
253
+ t = (int(m.group(1)), int(m.group(2)), int(m.group(3)))
254
+ if best is None or t > best:
255
+ best = t
256
+ best_ver = f"{m.group(1)}.{m.group(2)}.{m.group(3)}"
257
+ if len(data) < per_page:
258
+ break
259
+ page += 1
260
+
261
+ if best_ver is None:
262
+ raise ValueError(f"no vMAJOR.MINOR.PATCH tags found for github.com/{owner}/{repo}")
263
+ return best_ver
264
+
265
+
266
+ def bump_compatible_pins(pyproject_toml: str, distribution: str, version: str) -> tuple[str, int]:
267
+ """Replace each ``{distribution}~=X.Y.Z`` with ``{distribution}~={version}``.
268
+
269
+ Returns ``(new_text, replacement_count)``.
270
+ """
271
+ if not _VERSION_RE.match(version):
272
+ raise ValueError(f"invalid PEP 440 version for pin: {version!r}")
273
+
274
+ pat = _pin_pattern(distribution)
275
+
276
+ def repl(m: re.Match[str]) -> str:
277
+ return f"{m.group(1)}{version}"
278
+
279
+ new_text, n = pat.subn(repl, pyproject_toml)
280
+ return new_text, n
281
+
282
+
283
+ def bump_compatible_pins_in_file(
284
+ pyproject: Path | str,
285
+ distribution: str,
286
+ version: str,
287
+ *,
288
+ dry_run: bool = False,
289
+ ) -> int:
290
+ """Read ``pyproject.toml``, apply :func:`bump_compatible_pins`, write back unless ``dry_run``.
291
+
292
+ Returns the number of replacements. Raises ``ValueError`` if no matching pin exists.
293
+ """
294
+ path = Path(pyproject)
295
+ text = path.read_text(encoding="utf-8")
296
+ new_text, n = bump_compatible_pins(text, distribution, version)
297
+ if n == 0:
298
+ raise ValueError(f"no `{distribution}~=...` pin found in {path}")
299
+ if new_text == text:
300
+ return n
301
+ if not dry_run:
302
+ path.write_text(new_text, encoding="utf-8")
303
+ return n
304
+
305
+
306
+ def bump_easybind_compatible_pins(pyproject_toml: str, version: str) -> tuple[str, int]:
307
+ """Same as :func:`bump_compatible_pins` with ``distribution=\"easybind\"``."""
308
+ return bump_compatible_pins(pyproject_toml, "easybind", version)
309
+
310
+
311
+ def bump_easybind_compatible_pins_in_file(
312
+ pyproject: Path | str,
313
+ version: str,
314
+ *,
315
+ dry_run: bool = False,
316
+ ) -> int:
317
+ """Same as :func:`bump_compatible_pins_in_file` with ``distribution=\"easybind\"``."""
318
+ return bump_compatible_pins_in_file(pyproject, "easybind", version, dry_run=dry_run)
319
+
320
+
321
+ def main(argv: list[str] | None = None) -> int:
322
+ """CLI: ``easybind-pin-pyproject`` (see ``--help``)."""
323
+ ap = argparse.ArgumentParser(
324
+ description=(
325
+ "Set every {distribution}~= pin in pyproject.toml "
326
+ "(default distribution: easybind). "
327
+ "Default version is latest on PyPI (can lag tags). "
328
+ "--from-github uses the latest vX.Y.Z GitHub tag for that distribution's repo "
329
+ "(OWNER/REPO from PyPI project URLs if you omit it). "
330
+ "--version / --installed are explicit overrides."
331
+ )
332
+ )
333
+ ap.add_argument(
334
+ "--distribution",
335
+ "-d",
336
+ default="easybind",
337
+ metavar="NAME",
338
+ help="PyPI distribution name to match in pins (default: easybind), e.g. cppdantic, easybind",
339
+ )
340
+ ap.add_argument(
341
+ "--pyproject",
342
+ type=Path,
343
+ default=None,
344
+ help="path to pyproject.toml (default: ./pyproject.toml under cwd)",
345
+ )
346
+ ap.add_argument(
347
+ "--version",
348
+ metavar="X.Y.Z",
349
+ default=None,
350
+ help="pin to this exact release (use when PyPI does not list it yet, e.g. CI still publishing)",
351
+ )
352
+ ap.add_argument(
353
+ "--installed",
354
+ action="store_true",
355
+ help="use installed version for --distribution, normalized to X.Y.Z (dev/post/local stripped)",
356
+ )
357
+ ap.add_argument(
358
+ "--from-github",
359
+ nargs="?",
360
+ const="",
361
+ default=None,
362
+ metavar="OWNER/REPO",
363
+ help=(
364
+ "pin to highest vMAJOR.MINOR.PATCH tag on GitHub. "
365
+ "If OWNER/REPO is omitted, read github.com/OWNER/REPO from PyPI metadata for --distribution. "
366
+ "Set GITHUB_TOKEN for private repos."
367
+ ),
368
+ )
369
+ ap.add_argument("--dry-run", action="store_true", help="do not write the file")
370
+ ns = ap.parse_args(argv)
371
+
372
+ nsrc = sum(
373
+ 1
374
+ for x in (ns.version is not None, ns.installed, ns.from_github is not None)
375
+ if x
376
+ )
377
+ if nsrc > 1:
378
+ print("error: use at most one of --version, --installed, or --from-github", file=sys.stderr)
379
+ return 2
380
+
381
+ pyproject = ns.pyproject if ns.pyproject is not None else Path.cwd() / "pyproject.toml"
382
+ if not pyproject.is_file():
383
+ print(f"error: {pyproject} not found", file=sys.stderr)
384
+ return 2
385
+
386
+ dist = ns.distribution.strip()
387
+ if not dist:
388
+ print("error: empty --distribution", file=sys.stderr)
389
+ return 2
390
+
391
+ ver_source: str
392
+ if ns.installed:
393
+ raw_installed = installed_distribution_version(dist)
394
+ try:
395
+ ver = compatible_release_pin_from_installed_version(raw_installed)
396
+ except ValueError as e:
397
+ print(f"error: {e}", file=sys.stderr)
398
+ return 1
399
+ if raw_installed != ver:
400
+ print(
401
+ f"note: installed {dist}=={raw_installed!r} -> pin ~={ver} (PEP 440 release triple)",
402
+ file=sys.stderr,
403
+ )
404
+ ver_source = "installed"
405
+ elif ns.from_github is not None:
406
+ raw = ns.from_github.strip()
407
+ try:
408
+ if not raw:
409
+ owner_repo = github_owner_repo_from_pypi_distribution(dist)
410
+ else:
411
+ owner_repo = raw
412
+ ver = latest_release_version_from_github(owner_repo)
413
+ except ValueError as e:
414
+ print(f"error: {e}", file=sys.stderr)
415
+ return 1
416
+ ver_source = "github"
417
+ elif ns.version:
418
+ ver = ns.version.strip()
419
+ ver_source = "explicit"
420
+ else:
421
+ ver = fetch_pypi_version(dist)
422
+ ver_source = "pypi"
423
+
424
+ if not _VERSION_RE.match(ver):
425
+ print(f"error: bad version string: {ver!r}", file=sys.stderr)
426
+ return 2
427
+
428
+ try:
429
+ n = bump_compatible_pins_in_file(pyproject, dist, ver, dry_run=ns.dry_run)
430
+ except ValueError as e:
431
+ print(f"error: {e}", file=sys.stderr)
432
+ return 1
433
+
434
+ action = "would update" if ns.dry_run else "updated"
435
+ print(f"{action} {n} {dist}~= pin(s) to ~={ver} in {pyproject}")
436
+
437
+ # PyPI / venv often lag a v* tag on GitHub.
438
+ if ns.dry_run and ver_source in ("pypi", "installed"):
439
+ try:
440
+ or_ = github_owner_repo_from_pypi_distribution(dist)
441
+ gh_ver = latest_release_version_from_github(or_)
442
+ if _semver_tuple(gh_ver) > _semver_tuple(ver):
443
+ print(
444
+ f"hint: github.com/{or_} has v{gh_ver} but {ver_source} gave {ver}. "
445
+ "Re-run with: easybind-pin-pyproject --dry-run --from-github",
446
+ )
447
+ except ValueError:
448
+ pass
449
+
450
+ return 0
451
+
452
+
453
+ if __name__ == "__main__":
454
+ raise SystemExit(main())
@@ -1,251 +0,0 @@
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 re
8
- import sys
9
- import time
10
- from pathlib import Path
11
- from urllib.error import HTTPError
12
- from urllib.request import urlopen
13
-
14
- # PEP 440 version suitable for ``~=`` RHS in typical pyproject pins (numeric release).
15
- _VERSION_RE = re.compile(r"^[0-9]+(?:\.[0-9]+)*$")
16
-
17
- # Reject pathological / mistaken distribution strings (full re.escape covers the rest).
18
- _DIST_OK = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9._-]*$")
19
-
20
-
21
- def _pin_pattern(distribution: str) -> re.Pattern[str]:
22
- if not _DIST_OK.match(distribution):
23
- raise ValueError(
24
- f"invalid distribution name for pin replacement: {distribution!r} "
25
- "(expected a PyPI-style name, e.g. easybind, cppdantic, scikit-build-core)"
26
- )
27
- return re.compile(rf"({re.escape(distribution)}~=)([0-9]+(?:\.[0-9]+)*)")
28
-
29
-
30
- def fetch_pypi_version(package: str = "easybind", *, timeout_s: float = 30.0) -> str:
31
- """Return ``info.version`` from ``https://pypi.org/pypi/{package}/json``."""
32
- url = f"https://pypi.org/pypi/{package}/json"
33
- with urlopen(url, timeout=timeout_s) as r:
34
- data = json.load(r)
35
- return str(data["info"]["version"])
36
-
37
-
38
- def pypi_release_exists(package: str, version: str, *, timeout_s: float = 15.0) -> bool:
39
- """Return True if ``https://pypi.org/pypi/{package}/{version}/json`` exists (release uploaded)."""
40
- url = f"https://pypi.org/pypi/{package}/{version}/json"
41
- try:
42
- with urlopen(url, timeout=timeout_s) as r:
43
- return getattr(r, "status", 200) == 200
44
- except HTTPError as e:
45
- if e.code == 404:
46
- return False
47
- raise
48
-
49
-
50
- def compatible_pin_versions(pyproject_toml: str, distribution: str) -> list[str]:
51
- """Return every version string from ``{distribution}~=VERSION`` pins (order preserved)."""
52
- pat = _pin_pattern(distribution)
53
- return [m.group(2) for m in pat.finditer(pyproject_toml)]
54
-
55
-
56
- def single_compatible_pin_version(
57
- pyproject_toml: str,
58
- distribution: str,
59
- *,
60
- empty_pins_suffix: str = "",
61
- ) -> str:
62
- """Return the version string if all ``{distribution}~=…`` pins agree; else raise ``ValueError``."""
63
- vers = compatible_pin_versions(pyproject_toml, distribution)
64
- if not vers:
65
- raise ValueError(
66
- f"no `{distribution}~=...` pins in pyproject.toml{empty_pins_suffix}"
67
- )
68
- uniq = set(vers)
69
- if len(uniq) != 1:
70
- raise ValueError(
71
- f"{distribution}~= pins disagree: {sorted(uniq)!r}; fix pyproject.toml first"
72
- )
73
- return vers[0]
74
-
75
-
76
- def wait_pypi_for_compatible_pin(
77
- pyproject_toml: str,
78
- distribution: str,
79
- *,
80
- timeout_s: float = 1800.0,
81
- interval_s: float = 30.0,
82
- verbose: bool = False,
83
- ) -> tuple[str, int]:
84
- """Poll PyPI until ``pypi_release_exists(distribution, version)`` for the pin.
85
-
86
- Uses ``single_compatible_pin_version`` on *pyproject_toml*. Returns
87
- ``(resolved_version, attempt_count)``. Raises ``TimeoutError`` if the release
88
- never appears within *timeout_s*. With *verbose*, log progress to stdout.
89
- """
90
- version = single_compatible_pin_version(pyproject_toml, distribution)
91
- if verbose:
92
- print(
93
- f"waiting for {distribution}=={version} on PyPI "
94
- f"(timeout {timeout_s:.0f}s, interval {interval_s:.0f}s)...",
95
- flush=True,
96
- )
97
- deadline = time.monotonic() + timeout_s
98
- attempt = 0
99
- while True:
100
- attempt += 1
101
- if pypi_release_exists(distribution, version):
102
- return version, attempt
103
- if time.monotonic() >= deadline:
104
- raise TimeoutError(
105
- f"timed out waiting for {distribution}=={version} on PyPI "
106
- f"after {timeout_s}s ({attempt} attempts)"
107
- )
108
- if verbose:
109
- remaining = int(max(0, deadline - time.monotonic()))
110
- print(
111
- f"attempt {attempt}: not yet (retry in {interval_s:.0f}s, ~{remaining}s left)...",
112
- flush=True,
113
- )
114
- time.sleep(interval_s)
115
-
116
-
117
- def installed_distribution_version(package: str = "easybind") -> str:
118
- """Return ``importlib.metadata.version(package)`` for the active environment."""
119
- from importlib.metadata import version
120
-
121
- return version(package)
122
-
123
-
124
- def bump_compatible_pins(pyproject_toml: str, distribution: str, version: str) -> tuple[str, int]:
125
- """Replace each ``{distribution}~=X.Y.Z`` with ``{distribution}~={version}``.
126
-
127
- Returns ``(new_text, replacement_count)``.
128
- """
129
- if not _VERSION_RE.match(version):
130
- raise ValueError(f"invalid PEP 440 version for pin: {version!r}")
131
-
132
- pat = _pin_pattern(distribution)
133
-
134
- def repl(m: re.Match[str]) -> str:
135
- return f"{m.group(1)}{version}"
136
-
137
- new_text, n = pat.subn(repl, pyproject_toml)
138
- return new_text, n
139
-
140
-
141
- def bump_compatible_pins_in_file(
142
- pyproject: Path | str,
143
- distribution: str,
144
- version: str,
145
- *,
146
- dry_run: bool = False,
147
- ) -> int:
148
- """Read ``pyproject.toml``, apply :func:`bump_compatible_pins`, write back unless ``dry_run``.
149
-
150
- Returns the number of replacements. Raises ``ValueError`` if no matching pin exists.
151
- """
152
- path = Path(pyproject)
153
- text = path.read_text(encoding="utf-8")
154
- new_text, n = bump_compatible_pins(text, distribution, version)
155
- if n == 0:
156
- raise ValueError(f"no `{distribution}~=...` pin found in {path}")
157
- if new_text == text:
158
- return n
159
- if not dry_run:
160
- path.write_text(new_text, encoding="utf-8")
161
- return n
162
-
163
-
164
- def bump_easybind_compatible_pins(pyproject_toml: str, version: str) -> tuple[str, int]:
165
- """Same as :func:`bump_compatible_pins` with ``distribution=\"easybind\"``."""
166
- return bump_compatible_pins(pyproject_toml, "easybind", version)
167
-
168
-
169
- def bump_easybind_compatible_pins_in_file(
170
- pyproject: Path | str,
171
- version: str,
172
- *,
173
- dry_run: bool = False,
174
- ) -> int:
175
- """Same as :func:`bump_compatible_pins_in_file` with ``distribution=\"easybind\"``."""
176
- return bump_compatible_pins_in_file(pyproject, "easybind", version, dry_run=dry_run)
177
-
178
-
179
- def main(argv: list[str] | None = None) -> int:
180
- """CLI: ``easybind-pin-pyproject`` (see ``--help``)."""
181
- ap = argparse.ArgumentParser(
182
- description=(
183
- "Set every {distribution}~= pin in pyproject.toml "
184
- "(default distribution: easybind; default version: latest on PyPI)."
185
- )
186
- )
187
- ap.add_argument(
188
- "--distribution",
189
- "-d",
190
- default="easybind",
191
- metavar="NAME",
192
- help="PyPI distribution name to match in pins (default: easybind), e.g. cppdantic, easybind",
193
- )
194
- ap.add_argument(
195
- "--pyproject",
196
- type=Path,
197
- default=None,
198
- help="path to pyproject.toml (default: ./pyproject.toml under cwd)",
199
- )
200
- ap.add_argument(
201
- "--version",
202
- metavar="X.Y.Z",
203
- default=None,
204
- help="pin to this version (default: PyPI latest for --distribution, unless --installed)",
205
- )
206
- ap.add_argument(
207
- "--installed",
208
- action="store_true",
209
- help="use the version of --distribution installed in the current environment",
210
- )
211
- ap.add_argument("--dry-run", action="store_true", help="do not write the file")
212
- ns = ap.parse_args(argv)
213
-
214
- if ns.version and ns.installed:
215
- print("error: use only one of --version or --installed", file=sys.stderr)
216
- return 2
217
-
218
- pyproject = ns.pyproject if ns.pyproject is not None else Path.cwd() / "pyproject.toml"
219
- if not pyproject.is_file():
220
- print(f"error: {pyproject} not found", file=sys.stderr)
221
- return 2
222
-
223
- dist = ns.distribution.strip()
224
- if not dist:
225
- print("error: empty --distribution", file=sys.stderr)
226
- return 2
227
-
228
- if ns.installed:
229
- ver = installed_distribution_version(dist)
230
- elif ns.version:
231
- ver = ns.version.strip()
232
- else:
233
- ver = fetch_pypi_version(dist)
234
-
235
- if not _VERSION_RE.match(ver):
236
- print(f"error: bad version string: {ver!r}", file=sys.stderr)
237
- return 2
238
-
239
- try:
240
- n = bump_compatible_pins_in_file(pyproject, dist, ver, dry_run=ns.dry_run)
241
- except ValueError as e:
242
- print(f"error: {e}", file=sys.stderr)
243
- return 1
244
-
245
- action = "would update" if ns.dry_run else "updated"
246
- print(f"{action} {n} {dist}~= pin(s) to ~={ver} in {pyproject}")
247
- return 0
248
-
249
-
250
- if __name__ == "__main__":
251
- raise SystemExit(main())
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes