afterpython 0.3.19__py3-none-any.whl → 0.4.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.
- afterpython/_typing.py +2 -2
- afterpython/builders/__init__.py +2 -0
- afterpython/builders/build_pdoc.py +7 -0
- afterpython/builders/comments_json.py +107 -0
- afterpython/builders/marimo_notebook.py +7 -0
- afterpython/cli/commands/build.py +5 -3
- afterpython/cli/commands/bump.py +299 -84
- afterpython/cli/commands/changelog.py +45 -0
- afterpython/cli/commands/check.py +20 -13
- afterpython/cli/commands/commit_msg.py +115 -0
- afterpython/cli/commands/init.py +286 -41
- afterpython/cli/commands/{commitizen.py → lint.py} +7 -10
- afterpython/cli/commands/{pre_commit.py → prek.py} +8 -8
- afterpython/cli/commands/release.py +39 -10
- afterpython/cli/commands/start.py +6 -4
- afterpython/cli/commands/update.py +8 -7
- afterpython/cli/main.py +9 -9
- afterpython/const.py +1 -1
- afterpython/pcu.py +45 -40
- afterpython/templates/afterpython-template.toml +39 -0
- afterpython/templates/ci-workflow-template.yml +108 -77
- afterpython/templates/cliff-template.toml +111 -0
- afterpython/templates/deploy-workflow-template.yml +14 -18
- afterpython/templates/pdoc/custom.css +37 -0
- afterpython/templates/pdoc/frame.html.jinja2 +15 -1
- afterpython/templates/pdoc/syntax-highlighting.css +155 -0
- afterpython/templates/pdoc/theme.css +44 -0
- afterpython/templates/pre-commit-config-template.yaml +36 -17
- afterpython/templates/release-workflow-template.yml +61 -10
- afterpython/templates/ruff-template.toml +8 -1
- afterpython/templates/ty-template.toml +19 -0
- afterpython/tools/_afterpython.py +31 -21
- afterpython/tools/_git.py +56 -0
- afterpython/tools/branch_rules.py +3 -1
- afterpython/tools/myst.py +36 -19
- afterpython/tools/prek.py +94 -0
- afterpython/utils.py +1 -1
- {afterpython-0.3.19.dist-info → afterpython-0.4.1.dist-info}/METADATA +24 -20
- afterpython-0.4.1.dist-info/RECORD +71 -0
- {afterpython-0.3.19.dist-info → afterpython-0.4.1.dist-info}/WHEEL +1 -1
- afterpython/cli/commands/commit.py +0 -52
- afterpython/templates/cz-template.toml +0 -7
- afterpython/tools/commitizen.py +0 -13
- afterpython/tools/pre_commit.py +0 -37
- afterpython-0.3.19.dist-info/RECORD +0 -65
- {afterpython-0.3.19.dist-info → afterpython-0.4.1.dist-info}/entry_points.txt +0 -0
afterpython/_typing.py
CHANGED
afterpython/builders/__init__.py
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
from afterpython.builders.build_pdoc import build_pdoc
|
|
2
|
+
from afterpython.builders.comments_json import build_comments_json
|
|
2
3
|
from afterpython.builders.content_json import build_content_json
|
|
3
4
|
from afterpython.builders.faq_json import build_faq_json
|
|
4
5
|
from afterpython.builders.index_md import (
|
|
@@ -13,6 +14,7 @@ from afterpython.builders.metadata import build_metadata
|
|
|
13
14
|
from afterpython.builders.url_md import build_url_md
|
|
14
15
|
|
|
15
16
|
__all__ = (
|
|
17
|
+
"build_comments_json",
|
|
16
18
|
"build_content_json",
|
|
17
19
|
"build_faq_json",
|
|
18
20
|
"build_jupyter_notebooks",
|
|
@@ -19,9 +19,15 @@ def build_pdoc(base_path: str) -> None:
|
|
|
19
19
|
project logo appears in the top-left of every API page and links
|
|
20
20
|
back to the project website root (matches MyST's UX).
|
|
21
21
|
"""
|
|
22
|
+
from afterpython._io.toml import _from_tomlkit
|
|
23
|
+
from afterpython.tools._afterpython import read_afterpython
|
|
24
|
+
|
|
22
25
|
package_dir = find_package_directory()
|
|
23
26
|
output_dir = ap.paths.build_path / "api_reference"
|
|
24
27
|
template_dir = ap.paths.templates_path / "pdoc"
|
|
28
|
+
# e.g. "google"; unset leaves it to pdoc (a module's __docformat__, else restructuredtext)
|
|
29
|
+
website = _from_tomlkit(read_afterpython().get("website", {}))
|
|
30
|
+
docformat = website.get("api_reference_docformat")
|
|
25
31
|
|
|
26
32
|
click.echo("Building api_reference/...")
|
|
27
33
|
|
|
@@ -45,6 +51,7 @@ def build_pdoc(base_path: str) -> None:
|
|
|
45
51
|
f"{base_path}/",
|
|
46
52
|
"--template-directory",
|
|
47
53
|
str(template_dir),
|
|
54
|
+
*(["--docformat", str(docformat)] if docformat else []),
|
|
48
55
|
str(package_dir),
|
|
49
56
|
],
|
|
50
57
|
env=env,
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
|
|
6
|
+
import click
|
|
7
|
+
|
|
8
|
+
import afterpython as ap
|
|
9
|
+
|
|
10
|
+
GISCUS_API = "https://giscus.app/api/discussions/categories"
|
|
11
|
+
DEFAULT_CATEGORY = "Announcements"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _read_comments_config() -> dict:
|
|
15
|
+
from afterpython._io.toml import _from_tomlkit
|
|
16
|
+
from afterpython.tools._afterpython import read_afterpython
|
|
17
|
+
|
|
18
|
+
website = _from_tomlkit(read_afterpython().get("website", {}))
|
|
19
|
+
return dict(website.get("comments", {}))
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _get_github_repo() -> str | None:
|
|
23
|
+
"""Return "owner/repo" from pyproject.toml's repository URL, or the git remote."""
|
|
24
|
+
from afterpython.tools._git import get_github_url
|
|
25
|
+
from afterpython.tools.pyproject import read_metadata
|
|
26
|
+
|
|
27
|
+
url = str(read_metadata().urls.get("repository", "")) or get_github_url() or ""
|
|
28
|
+
match = re.search(r"github\.com[/:]([^/]+)/([^/]+?)(?:\.git)?/?$", url)
|
|
29
|
+
return f"{match[1]}/{match[2]}" if match else None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _fetch_giscus_categories(repo: str) -> dict:
|
|
33
|
+
"""Fetch the repo and category IDs giscus needs. Raises click.ClickException on failure."""
|
|
34
|
+
import httpx2
|
|
35
|
+
|
|
36
|
+
try:
|
|
37
|
+
response = httpx2.get(GISCUS_API, params={"repo": repo}, timeout=10.0)
|
|
38
|
+
except httpx2.HTTPError as e:
|
|
39
|
+
raise click.ClickException(f"could not reach giscus.app ({e})") from e
|
|
40
|
+
try:
|
|
41
|
+
data = response.json()
|
|
42
|
+
except ValueError:
|
|
43
|
+
data = {}
|
|
44
|
+
if response.status_code != 200 or "repositoryId" not in data:
|
|
45
|
+
error = data.get("error") or f"HTTP {response.status_code}"
|
|
46
|
+
raise click.ClickException(error)
|
|
47
|
+
return data
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def build_comments_json():
|
|
51
|
+
"""Build comments.json for the giscus comment section on content pages.
|
|
52
|
+
|
|
53
|
+
Reads [website.comments] in afterpython.toml. The website template only
|
|
54
|
+
shows comments when comments.json exists, so a disabled or failed setup
|
|
55
|
+
just leaves it out. Never fails the build.
|
|
56
|
+
"""
|
|
57
|
+
stale = ap.paths.website_path / "static" / "comments.json"
|
|
58
|
+
stale.unlink(missing_ok=True)
|
|
59
|
+
|
|
60
|
+
config = _read_comments_config()
|
|
61
|
+
if not config.get("enabled", False):
|
|
62
|
+
click.echo("Comments are disabled, skip building comments.json")
|
|
63
|
+
return
|
|
64
|
+
|
|
65
|
+
def warn(reason: str):
|
|
66
|
+
click.secho(f"Warning: skip comments, {reason}", fg="yellow")
|
|
67
|
+
|
|
68
|
+
repo = _get_github_repo()
|
|
69
|
+
if not repo:
|
|
70
|
+
warn(
|
|
71
|
+
"no GitHub repository found "
|
|
72
|
+
"(set [project.urls] repository in pyproject.toml)"
|
|
73
|
+
)
|
|
74
|
+
return
|
|
75
|
+
|
|
76
|
+
category = str(config.get("category") or DEFAULT_CATEGORY)
|
|
77
|
+
try:
|
|
78
|
+
data = _fetch_giscus_categories(repo)
|
|
79
|
+
except click.ClickException as e:
|
|
80
|
+
warn(
|
|
81
|
+
f"giscus is not ready for {repo}: {e.message}\n"
|
|
82
|
+
f" 1. Enable Discussions: https://github.com/{repo}/settings\n"
|
|
83
|
+
" 2. Install the giscus app: https://github.com/apps/giscus\n"
|
|
84
|
+
" Or set [website.comments] enabled = false in afterpython.toml"
|
|
85
|
+
)
|
|
86
|
+
return
|
|
87
|
+
|
|
88
|
+
category_id = next(
|
|
89
|
+
(c["id"] for c in data.get("categories", []) if c["name"] == category), None
|
|
90
|
+
)
|
|
91
|
+
if not category_id:
|
|
92
|
+
names = ", ".join(c["name"] for c in data.get("categories", []))
|
|
93
|
+
warn(
|
|
94
|
+
f'Discussions category "{category}" not found in {repo} '
|
|
95
|
+
f"(available: {names or 'none'})"
|
|
96
|
+
)
|
|
97
|
+
return
|
|
98
|
+
|
|
99
|
+
click.echo(f"Building comments.json ({repo}, {category})...")
|
|
100
|
+
comments = {
|
|
101
|
+
"repo": repo,
|
|
102
|
+
"repoId": data["repositoryId"],
|
|
103
|
+
"category": category,
|
|
104
|
+
"categoryId": category_id,
|
|
105
|
+
}
|
|
106
|
+
with open(ap.paths.build_path / "comments.json", "w") as f:
|
|
107
|
+
json.dump(comments, f, indent=2)
|
|
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import os
|
|
4
4
|
import re
|
|
5
|
+
import shutil
|
|
5
6
|
import subprocess
|
|
6
7
|
from pathlib import Path
|
|
7
8
|
from typing import Literal
|
|
@@ -48,6 +49,12 @@ def _export_marimo(
|
|
|
48
49
|
execute: bool = False,
|
|
49
50
|
):
|
|
50
51
|
"""Run `marimo export` to produce HTML at `output_html`."""
|
|
52
|
+
# marimo is an optional dependency (the `marimo` extra)
|
|
53
|
+
if shutil.which("marimo") is None:
|
|
54
|
+
raise click.ClickException(
|
|
55
|
+
f"marimo is required to build {source.name} (a marimo notebook), "
|
|
56
|
+
'install it with: uv add --dev "afterpython[marimo]"'
|
|
57
|
+
)
|
|
51
58
|
output_html.parent.mkdir(parents=True, exist_ok=True)
|
|
52
59
|
subcommand = "html-wasm" if mode == "wasm" else "html"
|
|
53
60
|
cmd = [
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
from collections.abc import Callable
|
|
3
|
+
from collections.abc import Callable, Iterable
|
|
4
4
|
from typing import TYPE_CHECKING
|
|
5
5
|
|
|
6
6
|
if TYPE_CHECKING:
|
|
@@ -17,6 +17,7 @@ from click.exceptions import Exit
|
|
|
17
17
|
|
|
18
18
|
import afterpython as ap
|
|
19
19
|
from afterpython.builders import (
|
|
20
|
+
build_comments_json,
|
|
20
21
|
build_content_json,
|
|
21
22
|
build_faq_json,
|
|
22
23
|
build_jupyter_notebooks,
|
|
@@ -137,6 +138,7 @@ def prebuild():
|
|
|
137
138
|
create_placeholder_index_md_files()
|
|
138
139
|
build_metadata()
|
|
139
140
|
build_faq_json()
|
|
141
|
+
build_comments_json()
|
|
140
142
|
build_marimo_readme(mode=_get_readme_py_mode())
|
|
141
143
|
build_markdown()
|
|
142
144
|
build_jupyter_notebooks()
|
|
@@ -147,7 +149,7 @@ def postbuild(dev_build: bool = False):
|
|
|
147
149
|
source: Path,
|
|
148
150
|
destination: Path,
|
|
149
151
|
is_copy: bool = True,
|
|
150
|
-
ignore_copy: Callable[[str, list[str]],
|
|
152
|
+
ignore_copy: Callable[[str, list[str]], Iterable[str]] | None = None,
|
|
151
153
|
):
|
|
152
154
|
"""
|
|
153
155
|
Move or copy files from source to destination.
|
|
@@ -156,7 +158,7 @@ def postbuild(dev_build: bool = False):
|
|
|
156
158
|
source: Source path
|
|
157
159
|
destination: Destination path
|
|
158
160
|
is_copy: If True, copy files (merge with existing). If False, move files (replace destination)
|
|
159
|
-
ignore_copy: Optional ignore function for copytree, takes (directory, contents) and returns
|
|
161
|
+
ignore_copy: Optional ignore function for copytree, takes (directory, contents) and returns the names to ignore
|
|
160
162
|
"""
|
|
161
163
|
if not source.exists():
|
|
162
164
|
return
|
afterpython/cli/commands/bump.py
CHANGED
|
@@ -1,118 +1,333 @@
|
|
|
1
|
+
import os
|
|
1
2
|
import subprocess
|
|
3
|
+
import sys
|
|
2
4
|
|
|
3
5
|
import click
|
|
4
|
-
from
|
|
6
|
+
from packaging.version import Version
|
|
5
7
|
|
|
6
|
-
|
|
8
|
+
# uv's --bump names for PEP 440 pre-release markers
|
|
9
|
+
PRE_KINDS = {"a": "alpha", "b": "beta", "rc": "rc"}
|
|
10
|
+
CHANGELOG = "CHANGELOG.md"
|
|
11
|
+
# git-cliff ignores pre-release and dev tags (e.g. v0.4.0rc1, v0.4.0.dev2),
|
|
12
|
+
# so a stable release's section has every commit since the previous stable release
|
|
13
|
+
PRE_RELEASE_TAGS = "(a|b|rc|dev)[0-9]+"
|
|
7
14
|
|
|
8
15
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
16
|
+
def _uv_version_args(
|
|
17
|
+
version: Version, release: bool, pre: bool, level: str
|
|
18
|
+
) -> list[str]:
|
|
19
|
+
"""Arguments for `uv version`, either `--bump ...` or an exact version"""
|
|
20
|
+
is_stable = not version.is_prerelease # dev versions count as pre-releases too
|
|
21
|
+
if not is_stable and level != "patch":
|
|
22
|
+
raise click.ClickException(
|
|
23
|
+
f"--{level} only applies to stable versions, current version is {version}"
|
|
24
|
+
)
|
|
25
|
+
if release:
|
|
26
|
+
# e.g. 0.1.0rc0 -> 0.1.0, 0.3.19 -> 0.3.20
|
|
27
|
+
return ["--bump", "stable" if not is_stable else level]
|
|
28
|
+
if is_stable:
|
|
29
|
+
# e.g. 0.3.19 -> 0.3.20 (or 0.3.20rc0 with --pre)
|
|
30
|
+
return ["--bump", level, *(["--bump", "rc=0"] if pre else [])]
|
|
31
|
+
if version.is_devrelease:
|
|
32
|
+
if not pre:
|
|
33
|
+
# e.g. 0.1.0.dev3 -> 0.1.0.dev4
|
|
34
|
+
return ["--bump", "dev"]
|
|
35
|
+
if version.pre is None:
|
|
36
|
+
# e.g. 0.1.0.dev3 -> 0.1.0rc0
|
|
37
|
+
return ["--bump", "rc=0"]
|
|
38
|
+
# e.g. 0.1.0rc0.dev2 -> 0.1.0rc0 (uv's --bump rc would skip to rc1)
|
|
39
|
+
kind, number = version.pre
|
|
40
|
+
return [str(Version(f"{version.base_version}{kind}{number}"))]
|
|
41
|
+
# e.g. 0.1.0a1 -> 0.1.0a2, 0.1.0rc0 -> 0.1.0rc1
|
|
42
|
+
assert version.pre is not None # not stable and not dev, so it's a pre-release
|
|
43
|
+
return ["--bump", PRE_KINDS[version.pre[0]]]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _git(*args: str) -> subprocess.CompletedProcess:
|
|
47
|
+
import afterpython as ap
|
|
48
|
+
|
|
49
|
+
return subprocess.run(
|
|
50
|
+
["git", *args],
|
|
51
|
+
cwd=ap.paths.user_path,
|
|
52
|
+
capture_output=True,
|
|
53
|
+
text=True,
|
|
54
|
+
check=False,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _restore(files: list[str], new_files: list[str]):
|
|
59
|
+
"""Undo the version bump in the working tree and index"""
|
|
60
|
+
import afterpython as ap
|
|
61
|
+
|
|
62
|
+
_git("restore", "--source=HEAD", "--staged", "--worktree", "--", *files)
|
|
63
|
+
for file in new_files:
|
|
64
|
+
# --force, a hook (e.g. end-of-file-fixer) may have changed the file after it was staged
|
|
65
|
+
_git("rm", "--cached", "--force", "--quiet", "--ignore-unmatch", "--", file)
|
|
66
|
+
(ap.paths.user_path / file).unlink(missing_ok=True)
|
|
67
|
+
click.echo(f"↩️ Restored {', '.join(files + new_files)}", err=True)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _write_changelog(tag: str):
|
|
71
|
+
"""Write the release section for tag to CHANGELOG.md with git-cliff"""
|
|
72
|
+
import afterpython as ap
|
|
73
|
+
|
|
74
|
+
cmd = [
|
|
75
|
+
"git-cliff",
|
|
76
|
+
"--config",
|
|
77
|
+
str(ap.paths.afterpython_path / "cliff.toml"),
|
|
78
|
+
"--tag",
|
|
79
|
+
tag,
|
|
80
|
+
"--ignore-tags",
|
|
81
|
+
PRE_RELEASE_TAGS,
|
|
82
|
+
]
|
|
83
|
+
if (ap.paths.user_path / CHANGELOG).exists():
|
|
84
|
+
# only add the new section on top, keeping manual edits to older ones;
|
|
85
|
+
# a range from the previous stable tag, since --unreleased would drop
|
|
86
|
+
# the commits before an ignored pre-release tag (e.g. v0.4.1rc0)
|
|
87
|
+
previous = _git(
|
|
88
|
+
"describe",
|
|
89
|
+
"--tags",
|
|
90
|
+
"--abbrev=0",
|
|
91
|
+
"--match",
|
|
92
|
+
"v*",
|
|
93
|
+
*(f"--exclude=*{kind}[0-9]*" for kind in ("a", "b", "rc", "dev")),
|
|
94
|
+
).stdout.strip()
|
|
95
|
+
cmd += [f"{previous}..HEAD" if previous else "--unreleased"]
|
|
96
|
+
cmd += ["--prepend", CHANGELOG]
|
|
97
|
+
else:
|
|
98
|
+
# git-cliff can't prepend to a missing file, so create it from the full history
|
|
99
|
+
cmd += ["--output", CHANGELOG]
|
|
100
|
+
result = subprocess.run(
|
|
101
|
+
cmd, cwd=ap.paths.user_path, capture_output=True, text=True, check=False
|
|
102
|
+
)
|
|
103
|
+
if result.returncode != 0:
|
|
104
|
+
raise click.ClickException(f"git-cliff failed:\n{result.stderr.strip()}")
|
|
105
|
+
# the blank line after each release also ends up at the end of the file,
|
|
106
|
+
# end it with a single newline as end-of-file-fixer wants
|
|
107
|
+
path = ap.paths.user_path / CHANGELOG
|
|
108
|
+
path.write_text(path.read_text().rstrip("\n") + "\n")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _is_interactive() -> bool:
|
|
112
|
+
return sys.stdin.isatty() and sys.stdout.isatty()
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _edit_changelog():
|
|
116
|
+
"""Open CHANGELOG.md in git's editor, the bump continues once it's closed"""
|
|
117
|
+
import afterpython as ap
|
|
118
|
+
|
|
119
|
+
result = _git("var", "GIT_EDITOR")
|
|
120
|
+
if result.returncode != 0:
|
|
121
|
+
raise click.ClickException(
|
|
122
|
+
f"Failed to find git's editor:\n{result.stderr.strip()}"
|
|
123
|
+
)
|
|
124
|
+
editor = result.stdout.strip()
|
|
125
|
+
click.echo(
|
|
126
|
+
f"✏️ Opening {CHANGELOG} in {editor}, save and close it to commit the bump "
|
|
127
|
+
"(to abort, press Ctrl+C or exit the editor with an error, e.g. :cq in vim)"
|
|
128
|
+
)
|
|
129
|
+
# raises ClickException if the editor exits with an error, which rolls back the bump
|
|
130
|
+
click.edit(filename=str(ap.paths.user_path / CHANGELOG), editor=editor)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@click.command()
|
|
17
134
|
@click.option(
|
|
18
135
|
"--pre",
|
|
19
136
|
is_flag=True,
|
|
20
|
-
help="bump to pre-release version (e.g., dev3 ->
|
|
137
|
+
help="bump to pre-release version (e.g., 0.1.0.dev3 -> 0.1.0rc0, 0.1.0a1 -> 0.1.0a2)",
|
|
138
|
+
)
|
|
139
|
+
@click.option(
|
|
140
|
+
"--release",
|
|
141
|
+
is_flag=True,
|
|
142
|
+
help="bump to stable version (e.g., 0.1.0rc0 -> 0.1.0, 0.3.19 -> 0.3.20) and push it with its tag to trigger the release workflow (default branch only)",
|
|
21
143
|
)
|
|
22
144
|
@click.option(
|
|
23
|
-
"--
|
|
145
|
+
"--minor",
|
|
146
|
+
is_flag=True,
|
|
147
|
+
help="bump the minor version instead of patch (stable versions only, e.g., 0.3.19 -> 0.4.0)",
|
|
148
|
+
)
|
|
149
|
+
@click.option(
|
|
150
|
+
"--major",
|
|
151
|
+
is_flag=True,
|
|
152
|
+
help="bump the major version instead of patch (stable versions only, e.g., 0.3.19 -> 1.0.0)",
|
|
153
|
+
)
|
|
154
|
+
@click.option(
|
|
155
|
+
"--no-edit",
|
|
156
|
+
is_flag=True,
|
|
157
|
+
help="don't open CHANGELOG.md in git's editor before committing a stable bump",
|
|
24
158
|
)
|
|
25
|
-
|
|
26
|
-
"""
|
|
159
|
+
@click.option(
|
|
160
|
+
"--dry-run", is_flag=True, help="show the new version without changing anything"
|
|
161
|
+
)
|
|
162
|
+
def bump(
|
|
163
|
+
release: bool, pre: bool, minor: bool, major: bool, no_edit: bool, dry_run: bool
|
|
164
|
+
):
|
|
165
|
+
"""Bump project version, commit it and tag it.
|
|
27
166
|
|
|
167
|
+
\b
|
|
28
168
|
By default, stays within current release phase:
|
|
29
169
|
- Dev releases (e.g., 0.1.0.dev3) -> increment dev number (0.1.0.dev4)
|
|
30
170
|
- Pre-releases (e.g., 0.1.0a1) -> increment pre-release (0.1.0a2)
|
|
171
|
+
- Stable releases (e.g., 0.3.19) -> increment patch (0.3.20), or --minor/--major
|
|
31
172
|
|
|
32
173
|
Use --pre to transition from dev to pre-release, or pre-release to next pre-release.
|
|
33
|
-
|
|
174
|
+
Stable bumps also add the new release section to CHANGELOG.md with git-cliff
|
|
175
|
+
(if afterpython/cliff.toml exists, see `ap init cliff`) and open it in git's editor,
|
|
176
|
+
the bump is committed once you close it (use --no-edit to skip).
|
|
177
|
+
Use --release to bump to a stable version and push it with its tag (triggers the release workflow),
|
|
178
|
+
only allowed on the default branch (e.g. main).
|
|
34
179
|
"""
|
|
180
|
+
import afterpython as ap
|
|
181
|
+
from afterpython.tools._git import check_release_branch
|
|
35
182
|
from afterpython.tools.pyproject import read_metadata
|
|
36
|
-
from afterpython.utils import
|
|
37
|
-
|
|
38
|
-
# Show both our options and commitizen's help and exit
|
|
39
|
-
handle_passthrough_help(
|
|
40
|
-
ctx,
|
|
41
|
-
["cz", "bump"],
|
|
42
|
-
show_underlying=True,
|
|
43
|
-
)
|
|
183
|
+
from afterpython.utils import has_pixi
|
|
44
184
|
|
|
45
185
|
if release and pre:
|
|
46
186
|
raise click.ClickException("Only one of --release or --pre can be specified")
|
|
187
|
+
if minor and major:
|
|
188
|
+
raise click.ClickException("Only one of --minor or --major can be specified")
|
|
189
|
+
level = "minor" if minor else "major" if major else "patch"
|
|
47
190
|
|
|
48
|
-
#
|
|
191
|
+
# before changing anything, so a wrong branch doesn't leave a bump behind
|
|
49
192
|
if release:
|
|
50
|
-
|
|
193
|
+
check_release_branch()
|
|
51
194
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
195
|
+
version = read_metadata().version
|
|
196
|
+
if version is None:
|
|
197
|
+
raise click.ClickException("Unable to read version from pyproject.toml")
|
|
198
|
+
uv_args = _uv_version_args(version, release, pre, level)
|
|
55
199
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
200
|
+
user_path = ap.paths.user_path
|
|
201
|
+
# without uv.lock, --frozen stops uv from creating one; with it, uv updates it
|
|
202
|
+
has_uv_lock = (user_path / "uv.lock").exists()
|
|
203
|
+
uv_lock_arg = "--no-sync" if has_uv_lock else "--frozen"
|
|
204
|
+
result = subprocess.run(
|
|
205
|
+
["uv", "version", *uv_args, "--dry-run", "--short", "--frozen"],
|
|
206
|
+
cwd=user_path,
|
|
207
|
+
capture_output=True,
|
|
208
|
+
text=True,
|
|
209
|
+
check=False,
|
|
210
|
+
)
|
|
211
|
+
if result.returncode != 0:
|
|
212
|
+
raise click.ClickException(result.stderr.strip())
|
|
213
|
+
new_version = result.stdout.strip()
|
|
214
|
+
tag = f"v{new_version}"
|
|
61
215
|
|
|
62
|
-
|
|
216
|
+
uses_pixi = (user_path / "pixi.lock").exists()
|
|
217
|
+
if uses_pixi and not has_pixi():
|
|
218
|
+
click.echo("⚠️ pixi.lock found but pixi is not installed, skip updating it")
|
|
219
|
+
uses_pixi = False
|
|
220
|
+
candidates = [
|
|
221
|
+
"pyproject.toml",
|
|
222
|
+
*(["uv.lock"] if has_uv_lock else []),
|
|
223
|
+
*(["pixi.lock"] if uses_pixi else []),
|
|
224
|
+
]
|
|
225
|
+
# only stable bumps write the changelog, and only when afterpython/cliff.toml was set up
|
|
226
|
+
writes_changelog = (
|
|
227
|
+
not Version(new_version).is_prerelease
|
|
228
|
+
and (ap.paths.afterpython_path / "cliff.toml").exists()
|
|
229
|
+
)
|
|
230
|
+
if writes_changelog:
|
|
231
|
+
candidates.append(CHANGELOG)
|
|
232
|
+
# no editor in CI or tests
|
|
233
|
+
edits_changelog = writes_changelog and not no_edit and _is_interactive()
|
|
234
|
+
files = _git("ls-files", "--", *candidates).stdout.split()
|
|
235
|
+
if "pyproject.toml" not in files:
|
|
236
|
+
raise click.ClickException("pyproject.toml is not tracked by git")
|
|
237
|
+
# CHANGELOG.md created by this bump, removed again if the bump fails
|
|
238
|
+
new_files: list[str] = []
|
|
239
|
+
if writes_changelog and CHANGELOG not in files:
|
|
240
|
+
if (user_path / CHANGELOG).exists():
|
|
241
|
+
raise click.ClickException(
|
|
242
|
+
f"{CHANGELOG} exists but is not tracked by git, commit or remove it first"
|
|
243
|
+
)
|
|
244
|
+
new_files.append(CHANGELOG)
|
|
63
245
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
result = subprocess.run(
|
|
246
|
+
# the bump commit only contains the version change
|
|
247
|
+
dirty = _git("status", "--porcelain", "--", *files).stdout.strip()
|
|
248
|
+
if dirty:
|
|
249
|
+
raise click.ClickException(
|
|
250
|
+
f"Commit or stash your changes to these files first:\n{dirty}"
|
|
251
|
+
)
|
|
252
|
+
if _git("rev-parse", "--quiet", "--verify", f"refs/tags/{tag}").returncode == 0:
|
|
253
|
+
raise click.ClickException(f"Tag {tag} already exists")
|
|
254
|
+
# otherwise uv would re-lock other packages into the bump commit
|
|
255
|
+
if has_uv_lock and (
|
|
256
|
+
subprocess.run(
|
|
257
|
+
["uv", "lock", "--check"], cwd=user_path, capture_output=True, check=False
|
|
258
|
+
).returncode
|
|
259
|
+
!= 0
|
|
260
|
+
):
|
|
261
|
+
raise click.ClickException(
|
|
262
|
+
"uv.lock is out of date, run `uv lock` and commit it first"
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
if dry_run:
|
|
266
|
+
click.echo(f"{version} → {new_version}")
|
|
267
|
+
if writes_changelog:
|
|
268
|
+
click.echo(f"Would write the {new_version} section to {CHANGELOG}")
|
|
269
|
+
if edits_changelog:
|
|
270
|
+
click.echo(f"Would open {CHANGELOG} in git's editor before committing")
|
|
271
|
+
click.echo(f"Would commit {', '.join(files + new_files)} and create tag {tag}")
|
|
272
|
+
if release:
|
|
273
|
+
click.echo(f"Would push the current branch and tag {tag} to origin")
|
|
274
|
+
return
|
|
275
|
+
|
|
276
|
+
try:
|
|
277
|
+
result = subprocess.run(
|
|
278
|
+
["uv", "version", *uv_args, uv_lock_arg], cwd=user_path, check=False
|
|
279
|
+
)
|
|
96
280
|
if result.returncode != 0:
|
|
97
|
-
raise
|
|
281
|
+
raise click.ClickException("uv version failed")
|
|
282
|
+
if read_metadata().version != Version(new_version):
|
|
283
|
+
raise click.ClickException(
|
|
284
|
+
f"Expected version {new_version} in pyproject.toml after the bump"
|
|
285
|
+
)
|
|
286
|
+
# refresh the pixi env; older pixi versions also record the editable version in pixi.lock
|
|
287
|
+
if uses_pixi:
|
|
288
|
+
result = subprocess.run(["pixi", "install"], cwd=user_path, check=False)
|
|
289
|
+
if result.returncode != 0:
|
|
290
|
+
raise click.ClickException("pixi install failed")
|
|
291
|
+
if writes_changelog:
|
|
292
|
+
_write_changelog(tag)
|
|
293
|
+
click.echo(f"📝 Wrote the {new_version} section to {CHANGELOG}")
|
|
294
|
+
if edits_changelog:
|
|
295
|
+
_edit_changelog()
|
|
296
|
+
if new_files:
|
|
297
|
+
# `git commit -- <files>` only takes files git already knows about
|
|
298
|
+
result = _git("add", "--", *new_files)
|
|
299
|
+
if result.returncode != 0:
|
|
300
|
+
raise click.ClickException(f"git add failed:\n{result.stderr.strip()}")
|
|
98
301
|
|
|
99
|
-
#
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
302
|
+
# skip the commit message check, "bump" is not one of the commit types
|
|
303
|
+
env = os.environ.copy()
|
|
304
|
+
env["SKIP"] = ",".join(filter(None, [env.get("SKIP"), "ap-commit"]))
|
|
305
|
+
message = f"bump: version {version} → {new_version}"
|
|
306
|
+
# `git commit -- <files>` commits only these files, leaving anything else staged as is
|
|
307
|
+
result = subprocess.run(
|
|
308
|
+
["git", "commit", "-m", message, "--", *files, *new_files],
|
|
309
|
+
cwd=user_path,
|
|
310
|
+
env=env,
|
|
104
311
|
check=False,
|
|
105
312
|
)
|
|
106
|
-
if
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
313
|
+
if result.returncode != 0:
|
|
314
|
+
raise click.ClickException("git commit failed")
|
|
315
|
+
except (click.ClickException, KeyboardInterrupt):
|
|
316
|
+
_restore(files, new_files)
|
|
317
|
+
raise
|
|
318
|
+
|
|
319
|
+
result = _git("tag", tag)
|
|
320
|
+
if result.returncode != 0:
|
|
321
|
+
raise click.ClickException(
|
|
322
|
+
f"Committed the bump but failed to create tag {tag}:\n{result.stderr.strip()}"
|
|
323
|
+
)
|
|
324
|
+
click.echo(f"✅ Bumped {version} → {new_version} and tagged {tag}")
|
|
325
|
+
|
|
326
|
+
if release:
|
|
327
|
+
# ap release pushes the branch and the tag, which triggers the release workflow
|
|
328
|
+
click.echo()
|
|
329
|
+
result = subprocess.run(["ap", "release"], cwd=user_path, check=False)
|
|
330
|
+
if result.returncode != 0:
|
|
331
|
+
raise click.ClickException(
|
|
332
|
+
f"Bumped and tagged {tag} but didn't push it, retry with: ap release"
|
|
117
333
|
)
|
|
118
|
-
click.echo("✓ Committed pixi.lock update")
|