afterpython 0.0.1.dev2__py3-none-any.whl → 0.1.0.dev4__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.
Files changed (56) hide show
  1. afterpython/.pre-commit-config.yaml +48 -0
  2. afterpython/__init__.py +13 -0
  3. afterpython/_io/toml.py +103 -0
  4. afterpython/_io/yaml.py +26 -0
  5. afterpython/_paths.py +39 -0
  6. afterpython/_typing.py +5 -0
  7. afterpython/builders/__init__.py +8 -0
  8. afterpython/builders/metadata.py +46 -0
  9. afterpython/builders/molab.py +99 -0
  10. afterpython/cli/__init__.py +4 -0
  11. afterpython/cli/commands/build.py +187 -0
  12. afterpython/cli/commands/bump.py +88 -0
  13. afterpython/cli/commands/check.py +36 -0
  14. afterpython/cli/commands/clean.py +84 -0
  15. afterpython/cli/commands/commit.py +34 -0
  16. afterpython/cli/commands/commitizen.py +32 -0
  17. afterpython/cli/commands/dev.py +146 -0
  18. afterpython/cli/commands/format.py +28 -0
  19. afterpython/cli/commands/init.py +98 -0
  20. afterpython/cli/commands/install.py +22 -0
  21. afterpython/cli/commands/pre_commit.py +66 -0
  22. afterpython/cli/commands/preview.py +22 -0
  23. afterpython/cli/commands/release.py +67 -0
  24. afterpython/cli/commands/start.py +140 -0
  25. afterpython/cli/commands/sync.py +108 -0
  26. afterpython/cli/commands/update.py +153 -0
  27. afterpython/cli/main.py +71 -0
  28. afterpython/const.py +5 -0
  29. afterpython/cz.toml +27 -0
  30. afterpython/main.py +25 -0
  31. afterpython/pcu.py +164 -0
  32. afterpython/templates/deploy-workflow-template.yml +71 -0
  33. afterpython/templates/release-workflow-template.yml +56 -0
  34. afterpython/templates/ruff-template.toml +32 -0
  35. afterpython/tools/_afterpython.py +65 -0
  36. afterpython/tools/_git.py +111 -0
  37. afterpython/tools/commitizen.py +53 -0
  38. afterpython/tools/github_actions.py +78 -0
  39. afterpython/tools/myst.py +164 -0
  40. afterpython/tools/pre_commit.py +62 -0
  41. afterpython/tools/pyproject.py +72 -0
  42. afterpython/utils.py +215 -0
  43. afterpython/version.py +39 -0
  44. afterpython-0.1.0.dev4.dist-info/METADATA +105 -0
  45. afterpython-0.1.0.dev4.dist-info/RECORD +47 -0
  46. afterpython-0.1.0.dev4.dist-info/WHEEL +4 -0
  47. afterpython-0.1.0.dev4.dist-info/entry_points.txt +4 -0
  48. afterpython/README.py +0 -158
  49. afterpython/images/favicon.ico +0 -0
  50. afterpython/images/logo-trace.svg +0 -3
  51. afterpython/images/logo.png +0 -0
  52. afterpython/images/logo.svg +0 -2
  53. afterpython-0.0.1.dev2.dist-info/LICENSE +0 -201
  54. afterpython-0.0.1.dev2.dist-info/METADATA +0 -20
  55. afterpython-0.0.1.dev2.dist-info/RECORD +0 -9
  56. afterpython-0.0.1.dev2.dist-info/WHEEL +0 -4
@@ -0,0 +1,48 @@
1
+ default_install_hook_types:
2
+ - pre-commit
3
+ - commit-msg
4
+ - pre-push
5
+ exclude: ^afterpython/_website/
6
+ repos:
7
+ - repo: https://github.com/pre-commit/pre-commit-hooks
8
+ rev: v6.0.0
9
+ hooks:
10
+ - id: trailing-whitespace
11
+ stages:
12
+ - pre-commit
13
+ - id: end-of-file-fixer
14
+ stages:
15
+ - pre-commit
16
+ - id: check-yaml
17
+ stages:
18
+ - pre-commit
19
+ - id: check-toml
20
+ stages:
21
+ - pre-commit
22
+ - id: check-added-large-files
23
+ stages:
24
+ - pre-commit
25
+ - repo: https://github.com/astral-sh/ruff-pre-commit
26
+ rev: v0.14.5
27
+ hooks:
28
+ - id: ruff-check
29
+ stages:
30
+ - pre-commit
31
+ args:
32
+ - --config
33
+ - ./afterpython/ruff.toml
34
+ - id: ruff-format
35
+ stages:
36
+ - pre-commit
37
+ args:
38
+ - --config
39
+ - ./afterpython/ruff.toml
40
+ - repo: https://github.com/commitizen-tools/commitizen
41
+ rev: v4.10.0
42
+ hooks:
43
+ - id: commitizen
44
+ stages:
45
+ - commit-msg
46
+ - id: commitizen-branch
47
+ stages:
48
+ - pre-push
@@ -0,0 +1,13 @@
1
+ # import builtins
2
+ # from pprint import pprint
3
+ from importlib.metadata import version
4
+
5
+ from afterpython._paths import Paths
6
+
7
+ # make pprint available in the global namespace
8
+ # builtins.pprint = pprint
9
+
10
+ paths = Paths()
11
+
12
+ __version__ = version("afterpython")
13
+ __all__ = ("__version__", "paths")
@@ -0,0 +1,103 @@
1
+ from __future__ import annotations
2
+ from typing import TYPE_CHECKING
3
+
4
+ if TYPE_CHECKING:
5
+ from tomlkit.toml_document import TOMLDocument
6
+ from pathlib import Path
7
+
8
+ import tomlkit
9
+
10
+
11
+ # VIBE-CODED
12
+ def _from_tomlkit(value):
13
+ """Recursively convert tomlkit objects to plain Python data structures.
14
+
15
+ This ensures compatibility with other libraries (like ruamel.yaml):
16
+ - tomlkit containers → plain Python containers
17
+ - tomlkit primitives → plain Python primitives
18
+ """
19
+ from tomlkit.items import Array, InlineTable, Table, AoT
20
+
21
+ if value is None:
22
+ return None
23
+
24
+ # Check containers first (these are the problematic ones)
25
+ if isinstance(value, (Table, InlineTable)):
26
+ # tomlkit dict-like → plain dict
27
+ return {k: _from_tomlkit(v) for k, v in value.items()}
28
+ elif isinstance(value, (Array, AoT)):
29
+ # tomlkit array → plain list
30
+ return [_from_tomlkit(item) for item in value]
31
+ elif isinstance(value, dict):
32
+ # Already a dict, but recurse to handle nested tomlkit objects
33
+ return {k: _from_tomlkit(v) for k, v in value.items()}
34
+ elif isinstance(value, list):
35
+ # Already a list, but recurse to handle nested tomlkit objects
36
+ return [_from_tomlkit(item) for item in value]
37
+
38
+ # Primitives - tomlkit primitives inherit from Python types
39
+ # Just convert explicitly to be safe
40
+ elif isinstance(value, bool): # MUST check bool before int!
41
+ return bool(value)
42
+ elif isinstance(value, int):
43
+ return int(value)
44
+ elif isinstance(value, float):
45
+ return float(value)
46
+ elif isinstance(value, str):
47
+ return str(value)
48
+ else:
49
+ # datetime, date, time, or already plain Python type
50
+ return value
51
+
52
+
53
+ # VIBE-CODED
54
+ def _to_tomlkit(value):
55
+ """Recursively convert Python data structures to tomlkit objects.
56
+
57
+ This ensures proper TOML formatting:
58
+ - List of dicts → array of inline tables: [{key = val}]
59
+ - Nested dicts → nested tables with proper structure
60
+ """
61
+ from tomlkit import inline_table, array
62
+
63
+ if value is None:
64
+ return None
65
+
66
+ if isinstance(value, dict):
67
+ # Plain dict → recursively convert values
68
+ return {k: _to_tomlkit(v) for k, v in value.items()}
69
+ elif isinstance(value, list):
70
+ if not value:
71
+ # Empty list → empty tomlkit array
72
+ return array()
73
+
74
+ # Check if ALL items are dicts (should be inline tables)
75
+ if all(isinstance(item, dict) for item in value):
76
+ arr = array()
77
+ for item in value:
78
+ tbl = inline_table()
79
+ # Recursively convert the dict values
80
+ tbl.update({k: _to_tomlkit(v) for k, v in item.items()})
81
+ arr.append(tbl)
82
+ return arr
83
+ else:
84
+ # Mixed or non-dict items → regular array
85
+ arr = array()
86
+ for item in value:
87
+ arr.append(_to_tomlkit(item))
88
+ return arr
89
+ else:
90
+ # Primitives (str, int, float, bool, datetime, etc.)
91
+ # tomlkit handles these natively
92
+ return value
93
+
94
+
95
+ def read_toml(file_path: Path) -> TOMLDocument:
96
+ with open(file_path, "rb") as f:
97
+ data: TOMLDocument = tomlkit.parse(f.read())
98
+ return data
99
+
100
+
101
+ def write_toml(file_path: Path, data: TOMLDocument | dict):
102
+ with open(file_path, "w") as f:
103
+ f.write(tomlkit.dumps(data))
@@ -0,0 +1,26 @@
1
+ from pathlib import Path
2
+
3
+ from ruamel.yaml import YAML
4
+ from ruamel.yaml.comments import CommentedMap
5
+
6
+
7
+ def _get_yaml() -> YAML:
8
+ """Get configured YAML instance that preserves order, comments, and formatting"""
9
+ yaml = YAML()
10
+ yaml.preserve_quotes = True
11
+ yaml.default_flow_style = False
12
+ yaml.width = 4096 # Prevent line wrapping
13
+ return yaml
14
+
15
+
16
+ def read_yaml(file_path: Path) -> CommentedMap:
17
+ yaml = _get_yaml()
18
+ with open(file_path) as f:
19
+ return yaml.load(f)
20
+
21
+
22
+ def write_yaml(file_path: Path, data: dict):
23
+ """Write YAML data"""
24
+ yaml = _get_yaml()
25
+ with open(file_path, "w") as f:
26
+ yaml.dump(data, f)
afterpython/_paths.py ADDED
@@ -0,0 +1,39 @@
1
+ from pathlib import Path
2
+ from dataclasses import dataclass, field
3
+
4
+
5
+ @dataclass
6
+ class Paths:
7
+ package_path: Path = field(init=False)
8
+ templates_path: Path = field(init=False)
9
+ user_path: Path = field(init=False)
10
+ pyproject_path: Path = field(init=False)
11
+ afterpython_path: Path = field(init=False)
12
+ website_path: Path = field(init=False)
13
+ build_path: Path = field(init=False)
14
+ static_path: Path = field(init=False)
15
+
16
+ def __post_init__(self):
17
+ # package path is the path to the afterpython package in user's site-packages
18
+ self.package_path = Path(__file__).resolve().parents[0]
19
+ self.templates_path = self.package_path / "templates"
20
+ self.user_path = self._find_project_root()
21
+ self.pyproject_path = self.user_path / "pyproject.toml"
22
+ self.afterpython_path = self.user_path / "afterpython"
23
+ self.website_path = self.afterpython_path / "_website"
24
+ self.build_path = self.afterpython_path / "_build"
25
+ self.static_path = self.afterpython_path / "static"
26
+
27
+ def _find_project_root(self) -> Path:
28
+ """Find the project root by looking for pyproject.toml in current or parent directories."""
29
+ current = Path.cwd()
30
+
31
+ # Check current directory and all parents
32
+ for path in [current, *current.parents]:
33
+ if (path / "pyproject.toml").exists():
34
+ return path
35
+
36
+ # If no pyproject.toml found, raise an error like uv does
37
+ raise FileNotFoundError(
38
+ "No pyproject.toml found in current directory or any parent directory"
39
+ )
afterpython/_typing.py ADDED
@@ -0,0 +1,5 @@
1
+ from typing import TypeAlias, Literal
2
+
3
+
4
+ NodeEnv: TypeAlias = dict[str, str]
5
+ tContentType = Literal["doc", "blog", "tutorial", "example", "guide"]
@@ -0,0 +1,8 @@
1
+ from afterpython.builders.metadata import build_metadata
2
+ from afterpython.builders.molab import add_molab_badge_to_jupyter_notebooks
3
+
4
+
5
+ __all__ = (
6
+ "add_molab_badge_to_jupyter_notebooks",
7
+ "build_metadata",
8
+ )
@@ -0,0 +1,46 @@
1
+ from __future__ import annotations
2
+ from typing import TYPE_CHECKING
3
+
4
+ if TYPE_CHECKING:
5
+ from pyproject_metadata import StandardMetadata
6
+
7
+ import json
8
+
9
+ import afterpython as ap
10
+
11
+
12
+ build_path = ap.paths.build_path
13
+
14
+
15
+ def convert_paths():
16
+ """
17
+ Convert paths in "description" field (README.md) in metadata.json to use the new paths in the build output.
18
+ e.g. convert ./afterpython/static/image.png to static/image.png
19
+ """
20
+ # Read metadata.json
21
+ with open(build_path / "metadata.json") as f:
22
+ metadata = json.load(f)
23
+ markdown_text = metadata["description"]
24
+
25
+ # Replace with the correct paths
26
+ updated_markdown = markdown_text.replace("./afterpython/static/", "/")
27
+
28
+ # Write back to metadata.json
29
+ metadata["description"] = updated_markdown
30
+ with open(build_path / "metadata.json", "w") as f:
31
+ json.dump(metadata, f, indent=2)
32
+
33
+ print("Completed path conversion in metadata.json")
34
+
35
+
36
+ def build_metadata():
37
+ """Build metadata.json using pyproject.toml"""
38
+ from afterpython.tools.pyproject import read_metadata
39
+
40
+ metadata: StandardMetadata = read_metadata()
41
+
42
+ # Write to metadata.json
43
+ with open(build_path / "metadata.json", "w") as f:
44
+ json.dump(metadata.as_json(), f, indent=2)
45
+
46
+ convert_paths()
@@ -0,0 +1,99 @@
1
+ from __future__ import annotations
2
+ from typing import TYPE_CHECKING
3
+
4
+ if TYPE_CHECKING:
5
+ from afterpython._typing import tContentType
6
+
7
+ import json
8
+ from pathlib import Path
9
+
10
+ from pyproject_metadata import StandardMetadata
11
+
12
+ import afterpython as ap
13
+ from afterpython.tools.pyproject import read_metadata
14
+ from afterpython.const import CONTENT_TYPES
15
+
16
+
17
+ def _get_molab_badge() -> str:
18
+ return "https://marimo.io/molab-shield.svg"
19
+
20
+
21
+ def _create_molab_url(github_url: str, content_path: Path):
22
+ """Create a molab URL for a given content type and notebook path.
23
+ Args:
24
+ github_url: str, e.g. "https://github.com/AfterPythonOrg/afterpython"
25
+ content_path: str, e.g. "tutorial/test.ipynb"
26
+ """
27
+ github_url = github_url.replace("https://github.com/", "github/")
28
+ return f"https://molab.marimo.io/{github_url}/blob/main/afterpython/{content_path.as_posix()}"
29
+
30
+
31
+ def add_molab_badge_to_jupyter_notebooks(content_type: tContentType):
32
+ """Add a badge markdown cell to the top of Jupyter notebooks in the given content type directory, e.g. tutorial/, blog/, etc.
33
+ # NOTE
34
+ Note that the jupyter notebooks need to exist in the github repository first.
35
+ If you have renamed a notebook, you need to push the changes to the github repository first for the badge to work.
36
+ """
37
+ assert content_type.lower() in CONTENT_TYPES, (
38
+ f"Invalid content type: {content_type}"
39
+ )
40
+ metadata: StandardMetadata = read_metadata()
41
+
42
+ if "repository" not in metadata.urls:
43
+ return "Repository URL not found in [project.urls] in pyproject.toml, cannot add molab badge"
44
+ else:
45
+ github_url = metadata.urls["repository"]
46
+
47
+ path = ap.paths.afterpython_path / content_type.lower()
48
+
49
+ # iterate over all files in the path
50
+ for notebook_path in path.rglob("*.ipynb"):
51
+ # Skip files in _build directory
52
+ if "_build" in notebook_path.parts:
53
+ continue
54
+
55
+ # Get path relative to afterpython/ (includes tutorial/ in the path)
56
+ content_path = notebook_path.relative_to(ap.paths.afterpython_path)
57
+ molab_url = _create_molab_url(github_url, content_path)
58
+ badge_md = f"[![Open in molab]({_get_molab_badge()})]({molab_url})"
59
+
60
+ # Read the notebook
61
+ try:
62
+ # Read the notebook
63
+ with open(notebook_path) as f:
64
+ notebook = json.load(f)
65
+ except json.JSONDecodeError:
66
+ print(f"āœ— Skipping {content_path} - invalid or empty JSON")
67
+ continue
68
+ except Exception as e:
69
+ print(f"āœ— Error reading {content_path}: {e}")
70
+ continue
71
+
72
+ # Validate notebook structure
73
+ if "cells" not in notebook:
74
+ print(f"āœ— Skipping {content_path} - not a valid Jupyter notebook")
75
+ continue
76
+
77
+ # Create a markdown cell with the badge
78
+ badge_cell = {
79
+ "cell_type": "markdown",
80
+ "id": "molab-badge-cell", # Unique ID for the badge cell
81
+ "metadata": {
82
+ "tags": ["molab", "hide-input"] # Optional: use MyST tags
83
+ },
84
+ "source": [badge_md],
85
+ }
86
+
87
+ # Check if first cell is already a badge cell (avoid duplicates)
88
+ if notebook["cells"] and notebook["cells"][0].get("id") == "molab-badge-cell":
89
+ # Update existing badge
90
+ notebook["cells"][0] = badge_cell
91
+ print(f"āœ“ Updated molab badge in: {content_path}")
92
+ else:
93
+ # Insert at the beginning
94
+ notebook["cells"].insert(0, badge_cell)
95
+ print(f"āœ“ Added molab badge to: {content_path}")
96
+
97
+ # Write back
98
+ with open(notebook_path, "w") as f:
99
+ json.dump(notebook, f, indent=1)
@@ -0,0 +1,4 @@
1
+ from afterpython.cli.main import afterpython_group
2
+
3
+
4
+ __all__ = ["afterpython_group"]
@@ -0,0 +1,187 @@
1
+ from __future__ import annotations
2
+ from typing import TYPE_CHECKING
3
+ from collections.abc import Callable
4
+
5
+ if TYPE_CHECKING:
6
+ from afterpython._typing import NodeEnv
7
+ from pathlib import Path
8
+
9
+ import os
10
+ import shutil
11
+ import subprocess
12
+
13
+ import click
14
+ from click.exceptions import Exit
15
+
16
+ import afterpython as ap
17
+ from afterpython.utils import find_node_env
18
+ from afterpython.const import CONTENT_TYPES
19
+ from afterpython.builders import (
20
+ build_metadata,
21
+ add_molab_badge_to_jupyter_notebooks,
22
+ )
23
+
24
+
25
+ def prebuild():
26
+ def _check_initialized():
27
+ # Check if 'ap init' has been run
28
+ afterpython_toml = ap.paths.afterpython_path / "afterpython.toml"
29
+ if not afterpython_toml.exists():
30
+ raise click.ClickException(
31
+ "AfterPython is not initialized!\n"
32
+ "Run 'ap init' first to set up your project."
33
+ )
34
+
35
+ def _clean_build_directory():
36
+ print("Cleaning up build directory...")
37
+ build_path = ap.paths.build_path
38
+ if build_path.exists():
39
+ shutil.rmtree(build_path)
40
+ build_path.mkdir(parents=True, exist_ok=True)
41
+
42
+ _check_initialized()
43
+ _clean_build_directory()
44
+
45
+ if os.getenv("AP_MOLAB_BADGE", "0") == "1":
46
+ for content_type in CONTENT_TYPES:
47
+ add_molab_badge_to_jupyter_notebooks(content_type)
48
+
49
+
50
+ def postbuild():
51
+ def _move_files(
52
+ source: Path,
53
+ destination: Path,
54
+ is_copy: bool = True,
55
+ ignore_copy: Callable[[str, list[str]], set[str]] | None = None,
56
+ ):
57
+ """
58
+ Move or copy files from source to destination.
59
+
60
+ Args:
61
+ source: Source path
62
+ destination: Destination path
63
+ is_copy: If True, copy files (merge with existing). If False, move files (replace destination)
64
+ ignore_copy: Optional ignore function for copytree, takes (directory, contents) and returns set of names to ignore
65
+ """
66
+ if not source.exists():
67
+ return
68
+ if is_copy:
69
+ shutil.copytree(source, destination, dirs_exist_ok=True, ignore=ignore_copy)
70
+ print(f"Copied: {source} to {destination}")
71
+ else:
72
+ # Remove destination if it exists (move will fail otherwise)
73
+ if destination.exists():
74
+ shutil.rmtree(destination)
75
+ shutil.move(str(source), str(destination))
76
+ print(f"Moved: {source} to {destination}")
77
+
78
+ website_static = ap.paths.website_path / "static"
79
+ website_static.mkdir(parents=True, exist_ok=True)
80
+
81
+ for content_type in CONTENT_TYPES:
82
+ myst_build = ap.paths.afterpython_path / content_type / "_build"
83
+ afterpython_content_build = ap.paths.build_path / content_type
84
+ # Move myst builds from afterpython/{content_type}/_build to afterpython/_build/{content_type}
85
+ _move_files(myst_build, afterpython_content_build, is_copy=False)
86
+ # Copy afterpython/_build/{content_type}/html to afterpython/_website/static/{content_type}
87
+ _move_files(
88
+ afterpython_content_build / "html",
89
+ website_static / content_type,
90
+ is_copy=True,
91
+ )
92
+
93
+ # Copy all files from afterpython/_build to afterpython/_website/static/
94
+ _move_files(
95
+ ap.paths.build_path,
96
+ website_static,
97
+ is_copy=True,
98
+ # ignore all content builds e.g. doc/, blog/, only their html/ files will be copied
99
+ ignore_copy=lambda dir, contents: [
100
+ name for name in contents if name in CONTENT_TYPES
101
+ ],
102
+ )
103
+
104
+ # Copy all static files from afterpython/static/ to afterpython/_website/static/
105
+ _move_files(ap.paths.static_path, website_static, is_copy=True)
106
+
107
+
108
+ @click.command(
109
+ add_help_option=False, # disable click's --help option so that ap build --help can work
110
+ context_settings=dict(
111
+ ignore_unknown_options=True,
112
+ allow_extra_args=True,
113
+ ),
114
+ )
115
+ @click.pass_context
116
+ @click.option(
117
+ "--dev",
118
+ is_flag=True,
119
+ hidden=True, # Internal flag used by `ap dev`, not exposed to users
120
+ help="Development build - only build metadata for the landing page, skip content and production builds",
121
+ )
122
+ @click.option(
123
+ "--execute", is_flag=True, help="Execute Jupyter notebooks for all content types"
124
+ )
125
+ def build(ctx, dev: bool, execute: bool):
126
+ """Build the project website and all contents for production.
127
+
128
+ This command builds MyST content (doc/blog/tutorial/example/guide) and the SvelteKit website.
129
+
130
+ Any extra arguments are passed to the 'myst build --html' command for each content type.
131
+ See "myst build --help" for more details.
132
+
133
+ Use --execute to execute Jupyter notebooks for all content types.
134
+ """
135
+ from afterpython.utils import has_content_for_myst
136
+ from afterpython.utils import handle_passthrough_help
137
+
138
+ # Show both our options and myst's help and exit
139
+ handle_passthrough_help(
140
+ ctx,
141
+ ["myst", "build"],
142
+ show_underlying=True,
143
+ )
144
+
145
+ paths = ctx.obj["paths"]
146
+ prebuild()
147
+
148
+ click.echo("Building metadata.json...")
149
+ build_metadata()
150
+
151
+ # myst's production build
152
+ if not dev:
153
+ node_env: NodeEnv = find_node_env()
154
+ for content_type in CONTENT_TYPES:
155
+ content_path = paths.afterpython_path / content_type
156
+ if not has_content_for_myst(content_path):
157
+ click.echo(f"Skipping {content_type}/ (no content files found)")
158
+ continue
159
+
160
+ click.echo(f"Building {content_type}/...")
161
+ # NOTE: needs to set BASE_URL so that the project website can link to the content pages correctly at e.g. localhost:5173/doc
162
+ build_env = {**node_env, "BASE_URL": f"/{content_type}"}
163
+ result = subprocess.run(
164
+ [
165
+ "myst",
166
+ "build",
167
+ "--html",
168
+ *(["--execute"] if execute else []),
169
+ *ctx.args,
170
+ ],
171
+ cwd=content_path,
172
+ env=build_env,
173
+ check=False,
174
+ )
175
+ if result.returncode != 0:
176
+ raise Exit(result.returncode)
177
+
178
+ postbuild()
179
+
180
+ # website's production build
181
+ if not dev:
182
+ click.echo("Building project website...")
183
+ result = subprocess.run(
184
+ ["pnpm", "build"], cwd=paths.website_path, env=node_env, check=False
185
+ )
186
+ if result.returncode != 0:
187
+ raise Exit(result.returncode)
@@ -0,0 +1,88 @@
1
+ import subprocess
2
+
3
+ import click
4
+
5
+
6
+ @click.command(
7
+ add_help_option=False, # disable click's --help option so that cz --help can work
8
+ context_settings=dict(
9
+ ignore_unknown_options=True,
10
+ allow_extra_args=True,
11
+ ),
12
+ )
13
+ @click.pass_context
14
+ @click.option(
15
+ "--pre",
16
+ is_flag=True,
17
+ help="bump to pre-release version (e.g., dev3 -> rc0, a1 -> a2)",
18
+ )
19
+ @click.option(
20
+ "--release", is_flag=True, help="bump to stable release (uses cz bump default)"
21
+ )
22
+ def bump(ctx, release: bool, pre: bool):
23
+ """Bump project version with granular control.
24
+
25
+ By default, stays within current release phase:
26
+ - Dev releases (e.g., 0.1.0.dev3) -> increment dev number (0.1.0.dev4)
27
+ - Pre-releases (e.g., 0.1.0a1) -> increment pre-release (0.1.0a2)
28
+
29
+ Use --pre to transition from dev to pre-release, or pre-release to next pre-release.
30
+ Use --release to bump to next stable version (cz bump default behavior).
31
+ """
32
+ from afterpython.tools.pyproject import read_metadata
33
+ from afterpython.utils import handle_passthrough_help
34
+
35
+ # Show both our options and commitizen's help and exit
36
+ handle_passthrough_help(
37
+ ctx,
38
+ ["cz", "bump"],
39
+ show_underlying=True,
40
+ )
41
+
42
+ if release and pre:
43
+ raise click.ClickException("Only one of --release or --pre can be specified")
44
+
45
+ # bump using cz bump's default behavior
46
+ if release:
47
+ subprocess.run(["ap", "cz", "bump", *ctx.args])
48
+
49
+ # Skip tag push if this is a dry run
50
+ if "--dry-run" in ctx.args:
51
+ return
52
+
53
+ # Get the new version after bump
54
+ metadata = read_metadata()
55
+ new_version = metadata.version
56
+ if new_version is None:
57
+ raise click.ClickException("Unable to read version after bump")
58
+
59
+ tag = f"v{new_version}"
60
+
61
+ # Auto-push the specific tag to trigger release workflow
62
+ click.echo(f"\nšŸš€ Pushing tag {tag} to trigger release workflow...")
63
+ subprocess.run(["git", "push", "origin", tag])
64
+ else:
65
+ metadata = read_metadata()
66
+ version = metadata.version
67
+
68
+ if version is None:
69
+ raise click.ClickException("Unable to read version from pyproject.toml")
70
+
71
+ args = ctx.args # default: pass through all extra args
72
+
73
+ # by default, bump dev-release version
74
+ if version.is_devrelease and not pre:
75
+ devrelease_number = int(version.dev)
76
+ if "--devrelease" not in ctx.args:
77
+ args = ["--devrelease", str(devrelease_number + 1), *ctx.args]
78
+ # exclude e.g. 0.1.0.dev1 which is both dev-release and pre-release (with version.pre = None)
79
+ elif version.is_prerelease:
80
+ prerelease_type = version.pre[0] if version.pre is not None else "rc"
81
+ if prerelease_type == "a":
82
+ prerelease_type = "alpha"
83
+ elif prerelease_type == "b":
84
+ prerelease_type = "beta"
85
+ if "--prerelease" not in ctx.args:
86
+ args = ["--prerelease", prerelease_type, *ctx.args]
87
+
88
+ subprocess.run(["ap", "cz", "bump", *args])