afterpython 0.0.1.dev2__py3-none-any.whl → 0.1.0__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/__init__.py +13 -0
- afterpython/_io/toml.py +105 -0
- afterpython/_io/yaml.py +26 -0
- afterpython/_paths.py +39 -0
- afterpython/_typing.py +4 -0
- afterpython/builders/__init__.py +17 -0
- afterpython/builders/content_json.py +131 -0
- afterpython/builders/index_md.py +94 -0
- afterpython/builders/jupyter_notebook.py +293 -0
- afterpython/builders/markdown.py +271 -0
- afterpython/builders/metadata.py +50 -0
- afterpython/cli/__init__.py +3 -0
- afterpython/cli/commands/add.py +67 -0
- afterpython/cli/commands/build.py +260 -0
- afterpython/cli/commands/bump.py +118 -0
- afterpython/cli/commands/check.py +36 -0
- afterpython/cli/commands/clean.py +85 -0
- afterpython/cli/commands/commit.py +52 -0
- afterpython/cli/commands/commitizen.py +32 -0
- afterpython/cli/commands/dev.py +213 -0
- afterpython/cli/commands/format.py +28 -0
- afterpython/cli/commands/init.py +107 -0
- afterpython/cli/commands/init_branch_rules.py +9 -0
- afterpython/cli/commands/install.py +28 -0
- afterpython/cli/commands/lock.py +23 -0
- afterpython/cli/commands/pre_commit.py +66 -0
- afterpython/cli/commands/preview.py +22 -0
- afterpython/cli/commands/release.py +67 -0
- afterpython/cli/commands/remove.py +67 -0
- afterpython/cli/commands/start.py +144 -0
- afterpython/cli/commands/sync.py +152 -0
- afterpython/cli/commands/update.py +176 -0
- afterpython/cli/main.py +132 -0
- afterpython/const.py +4 -0
- afterpython/main.py +25 -0
- afterpython/pcu.py +166 -0
- afterpython/templates/ci-workflow-template.yml +197 -0
- afterpython/templates/cz-template.toml +7 -0
- afterpython/templates/dependabot-template.yml +30 -0
- afterpython/templates/deploy-workflow-template.yml +88 -0
- afterpython/templates/pre-commit-config-template.yaml +48 -0
- afterpython/templates/release-workflow-template.yml +56 -0
- afterpython/templates/ruff-template.toml +32 -0
- afterpython/tools/_afterpython.py +128 -0
- afterpython/tools/_git.py +137 -0
- afterpython/tools/branch_rules.py +106 -0
- afterpython/tools/commitizen.py +13 -0
- afterpython/tools/github_actions.py +48 -0
- afterpython/tools/myst.py +208 -0
- afterpython/tools/pre_commit.py +37 -0
- afterpython/tools/pyproject.py +110 -0
- afterpython/utils.py +299 -0
- afterpython/version.py +39 -0
- afterpython-0.1.0.dist-info/METADATA +131 -0
- afterpython-0.1.0.dist-info/RECORD +57 -0
- afterpython-0.1.0.dist-info/WHEEL +4 -0
- afterpython-0.1.0.dist-info/entry_points.txt +4 -0
- afterpython/README.py +0 -158
- afterpython/images/favicon.ico +0 -0
- afterpython/images/logo-trace.svg +0 -3
- afterpython/images/logo.png +0 -0
- afterpython/images/logo.svg +0 -2
- afterpython-0.0.1.dev2.dist-info/LICENSE +0 -201
- afterpython-0.0.1.dev2.dist-info/METADATA +0 -20
- afterpython-0.0.1.dev2.dist-info/RECORD +0 -9
- afterpython-0.0.1.dev2.dist-info/WHEEL +0 -4
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
if TYPE_CHECKING:
|
|
6
|
+
from tomlkit.toml_document import TOMLDocument
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from pyproject_metadata import StandardMetadata
|
|
12
|
+
|
|
13
|
+
import afterpython as ap
|
|
14
|
+
from afterpython._io.toml import _to_tomlkit, read_toml, write_toml
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def read_pyproject() -> TOMLDocument:
|
|
18
|
+
"""Read pyproject.toml"""
|
|
19
|
+
return read_toml(ap.paths.pyproject_path)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def write_pyproject(data: TOMLDocument):
|
|
23
|
+
write_toml(ap.paths.pyproject_path, data)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def read_metadata() -> StandardMetadata:
|
|
27
|
+
"""Read metadata from pyproject.toml"""
|
|
28
|
+
return StandardMetadata.from_pyproject(read_pyproject())
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def find_package_directory() -> Path:
|
|
32
|
+
"""Find the user's package directory.
|
|
33
|
+
|
|
34
|
+
Supports both common Python project layouts:
|
|
35
|
+
- src layout: user_project/src/package_name/__init__.py
|
|
36
|
+
- flat layout: user_project/package_name/__init__.py
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
Path to the package directory containing __init__.py
|
|
40
|
+
|
|
41
|
+
Raises:
|
|
42
|
+
FileNotFoundError: If package directory cannot be found in either layout
|
|
43
|
+
"""
|
|
44
|
+
metadata = read_metadata()
|
|
45
|
+
package_name = metadata.name
|
|
46
|
+
project_root = ap.paths.user_path
|
|
47
|
+
|
|
48
|
+
# Try src layout first (recommended layout)
|
|
49
|
+
src_layout_dir = project_root / "src" / package_name
|
|
50
|
+
if (src_layout_dir / "__init__.py").exists():
|
|
51
|
+
return src_layout_dir
|
|
52
|
+
|
|
53
|
+
# Try flat layout
|
|
54
|
+
flat_layout_dir = project_root / package_name
|
|
55
|
+
if (flat_layout_dir / "__init__.py").exists():
|
|
56
|
+
return flat_layout_dir
|
|
57
|
+
|
|
58
|
+
# Package not found in either location
|
|
59
|
+
raise FileNotFoundError(
|
|
60
|
+
f"Could not find package '{package_name}' in either src layout "
|
|
61
|
+
f"({src_layout_dir}) or flat layout ({flat_layout_dir}). "
|
|
62
|
+
f"Expected to find __init__.py in one of these locations."
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def init_pyproject():
|
|
67
|
+
"""Initialize pyproject.toml with sensible defaults
|
|
68
|
+
- add [build-system] section with uv build backend (same as `uv init --package`)
|
|
69
|
+
- add [project.urls] section with homepage, repository, and documentation URLs
|
|
70
|
+
"""
|
|
71
|
+
import httpx
|
|
72
|
+
|
|
73
|
+
from afterpython.tools._git import get_git_user_config, get_github_url
|
|
74
|
+
from afterpython.utils import fetch_pypi_json
|
|
75
|
+
|
|
76
|
+
build_backend = "uv_build"
|
|
77
|
+
|
|
78
|
+
async def fetch_build_backend_version() -> str | None:
|
|
79
|
+
"""Fetch the latest version of build backend package from PyPI."""
|
|
80
|
+
async with httpx.AsyncClient() as client:
|
|
81
|
+
data = await fetch_pypi_json(client, build_backend)
|
|
82
|
+
return data["info"]["version"] if data else None
|
|
83
|
+
|
|
84
|
+
data: TOMLDocument = read_pyproject()
|
|
85
|
+
is_updated = False
|
|
86
|
+
|
|
87
|
+
if "build-system" not in data:
|
|
88
|
+
uv_build_version = asyncio.run(fetch_build_backend_version())
|
|
89
|
+
if uv_build_version:
|
|
90
|
+
data["build-system"] = {
|
|
91
|
+
"requires": [f"{build_backend}>={uv_build_version}"],
|
|
92
|
+
"build-backend": build_backend,
|
|
93
|
+
}
|
|
94
|
+
is_updated = True
|
|
95
|
+
|
|
96
|
+
if "project" in data:
|
|
97
|
+
if "urls" not in data["project"]:
|
|
98
|
+
data["project"]["urls"] = {
|
|
99
|
+
"homepage": "",
|
|
100
|
+
"repository": get_github_url() or "",
|
|
101
|
+
"documentation": "",
|
|
102
|
+
}
|
|
103
|
+
is_updated = True
|
|
104
|
+
if "authors" not in data["project"]:
|
|
105
|
+
# convert git user config to tomlkit object
|
|
106
|
+
data["project"]["authors"] = _to_tomlkit([get_git_user_config()])
|
|
107
|
+
is_updated = True
|
|
108
|
+
|
|
109
|
+
if is_updated:
|
|
110
|
+
write_pyproject(data)
|
afterpython/utils.py
ADDED
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
if TYPE_CHECKING:
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from httpx import AsyncClient
|
|
9
|
+
|
|
10
|
+
from afterpython._typing import NodeEnv
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
import re
|
|
14
|
+
import shutil
|
|
15
|
+
import subprocess
|
|
16
|
+
|
|
17
|
+
import click
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def find_node_env() -> NodeEnv:
|
|
21
|
+
"""
|
|
22
|
+
Find if there is an installed Node.js version, if yes, use it
|
|
23
|
+
If no, install the Node.js version specified in NODEENV_VERSION
|
|
24
|
+
"""
|
|
25
|
+
from mystmd_py.nodeenv import find_any_node
|
|
26
|
+
|
|
27
|
+
from afterpython.const import NODEENV_VERSION
|
|
28
|
+
|
|
29
|
+
# from mystmd_py.main import ensure_valid_version
|
|
30
|
+
# Use mystmd's own node-finding logic
|
|
31
|
+
binary_path = os.environ.get("PATH", os.defpath)
|
|
32
|
+
_node_path, os_path = find_any_node(binary_path, nodeenv_version=NODEENV_VERSION)
|
|
33
|
+
node_env: NodeEnv = {**os.environ, "PATH": os_path}
|
|
34
|
+
return node_env
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def has_uv() -> bool:
|
|
38
|
+
"""Check if uv is installed"""
|
|
39
|
+
return shutil.which("uv") is not None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def has_pixi() -> bool:
|
|
43
|
+
"""Check if pixi is installed"""
|
|
44
|
+
return shutil.which("pixi") is not None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def has_gh() -> bool:
|
|
48
|
+
"""Check if gh is installed"""
|
|
49
|
+
return shutil.which("gh") is not None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
async def fetch_pypi_json(client: AsyncClient, package_name: str) -> dict | None:
|
|
53
|
+
url = f"https://pypi.org/pypi/{package_name}/json"
|
|
54
|
+
try:
|
|
55
|
+
response = await client.get(url)
|
|
56
|
+
response.raise_for_status()
|
|
57
|
+
data = response.json()
|
|
58
|
+
return data
|
|
59
|
+
except Exception as e:
|
|
60
|
+
# Return None if package doesn't exist or network fails
|
|
61
|
+
print(f"Warning: Could not fetch PyPI JSON for {package_name}: {e}")
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# VIBE-CODED
|
|
66
|
+
def detect_license_from_file(file_path: str) -> str:
|
|
67
|
+
"""Extract license name from LICENSE file using regex."""
|
|
68
|
+
with open(file_path, encoding="utf-8") as f:
|
|
69
|
+
content = f.read()
|
|
70
|
+
|
|
71
|
+
# Common license patterns
|
|
72
|
+
patterns = {
|
|
73
|
+
"Apache-2.0": r"Apache License\s+Version 2\.0",
|
|
74
|
+
"MIT": r"MIT License|Permission is hereby granted, free of charge",
|
|
75
|
+
"GPL-3.0": r"GNU GENERAL PUBLIC LICENSE\s+Version 3",
|
|
76
|
+
"GPL-2.0": r"GNU GENERAL PUBLIC LICENSE\s+Version 2",
|
|
77
|
+
"BSD-3-Clause": r"BSD 3-Clause License|Redistribution and use in source and binary forms",
|
|
78
|
+
"BSD-2-Clause": r"BSD 2-Clause License",
|
|
79
|
+
"ISC": r"ISC License",
|
|
80
|
+
"LGPL-3.0": r"GNU LESSER GENERAL PUBLIC LICENSE\s+Version 3",
|
|
81
|
+
"MPL-2.0": r"Mozilla Public License Version 2\.0",
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
# Check first 500 chars for license header
|
|
85
|
+
header = content[:500]
|
|
86
|
+
|
|
87
|
+
for license_id, pattern in patterns.items():
|
|
88
|
+
if re.search(pattern, header, re.IGNORECASE):
|
|
89
|
+
return license_id
|
|
90
|
+
|
|
91
|
+
return ""
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def deep_merge(base: dict, updates: dict, extend_lists: bool = True) -> dict:
|
|
95
|
+
"""Deep merge updates into base, preserving structure and metadata.
|
|
96
|
+
|
|
97
|
+
Works with:
|
|
98
|
+
- Regular Python dicts
|
|
99
|
+
- tomlkit TOMLDocument/Table objects
|
|
100
|
+
- ruamel.yaml CommentedMap objects
|
|
101
|
+
|
|
102
|
+
Behavior:
|
|
103
|
+
- Dicts are merged recursively
|
|
104
|
+
- Lists are extended (concatenated)
|
|
105
|
+
- Other types are overwritten
|
|
106
|
+
|
|
107
|
+
Args:
|
|
108
|
+
base: The base dictionary to merge into (modified in-place)
|
|
109
|
+
updates: The updates to apply
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
The merged base dictionary (same object, modified in-place)
|
|
113
|
+
"""
|
|
114
|
+
for key, value in updates.items():
|
|
115
|
+
if key in base:
|
|
116
|
+
if isinstance(base[key], dict) and isinstance(value, dict):
|
|
117
|
+
# Recursively merge dicts
|
|
118
|
+
deep_merge(base[key], value, extend_lists=extend_lists)
|
|
119
|
+
elif (
|
|
120
|
+
isinstance(base[key], list) and isinstance(value, list) and extend_lists
|
|
121
|
+
):
|
|
122
|
+
# Extend lists, but only add items that don't already exist
|
|
123
|
+
for item in value:
|
|
124
|
+
if item not in base[key]:
|
|
125
|
+
base[key].append(item)
|
|
126
|
+
else:
|
|
127
|
+
# Overwrite for other types
|
|
128
|
+
base[key] = value
|
|
129
|
+
else:
|
|
130
|
+
base[key] = value
|
|
131
|
+
|
|
132
|
+
return base
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def convert_author_name_to_id(name: str) -> str:
|
|
136
|
+
"""Convert author name to ID
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
name: The author name
|
|
140
|
+
|
|
141
|
+
Returns:
|
|
142
|
+
The author ID
|
|
143
|
+
|
|
144
|
+
Examples:
|
|
145
|
+
- "Stephen Yau" -> "stephen_yau"
|
|
146
|
+
- "John Doe" -> "john_doe"
|
|
147
|
+
- "Jane Smith" -> "jane_smith"
|
|
148
|
+
"""
|
|
149
|
+
return name.replace(" ", "_").lower()
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def find_available_port(
|
|
153
|
+
start_port: int = 3000, max_port: int = 3100, host: str = "localhost"
|
|
154
|
+
) -> int:
|
|
155
|
+
"""
|
|
156
|
+
Find a TCP port with no listener on `host`, starting from start_port.
|
|
157
|
+
"""
|
|
158
|
+
import socket
|
|
159
|
+
|
|
160
|
+
def is_port_in_use(
|
|
161
|
+
port: int, host: str = "localhost", timeout: float = 0.2
|
|
162
|
+
) -> bool:
|
|
163
|
+
"""
|
|
164
|
+
Return True if *any* TCP listener is active on the given host:port.
|
|
165
|
+
This works across IPv4/IPv6 by using getaddrinfo().
|
|
166
|
+
"""
|
|
167
|
+
try:
|
|
168
|
+
for family, socktype, proto, _canonname, sockaddr in socket.getaddrinfo(
|
|
169
|
+
host, port, type=socket.SOCK_STREAM
|
|
170
|
+
):
|
|
171
|
+
with socket.socket(family, socktype, proto) as s:
|
|
172
|
+
s.settimeout(timeout)
|
|
173
|
+
if s.connect_ex(sockaddr) == 0:
|
|
174
|
+
return True
|
|
175
|
+
except socket.gaierror:
|
|
176
|
+
# host couldn't be resolved; treat as no listener
|
|
177
|
+
return False
|
|
178
|
+
return False
|
|
179
|
+
|
|
180
|
+
for port in range(start_port, max_port + 1):
|
|
181
|
+
if not is_port_in_use(port, host=host):
|
|
182
|
+
return port
|
|
183
|
+
raise RuntimeError(
|
|
184
|
+
f"No free ports available in range {start_port}-{max_port} for host={host!r}"
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def has_content_for_myst(content_path: Path) -> bool:
|
|
189
|
+
"""Check if a content directory has any content files"""
|
|
190
|
+
return any(
|
|
191
|
+
content_path.glob("*.md")
|
|
192
|
+
or content_path.glob("*.ipynb")
|
|
193
|
+
or content_path.glob("*.tex")
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def handle_passthrough_help(
|
|
198
|
+
ctx: click.Context,
|
|
199
|
+
underlying_command: list[str],
|
|
200
|
+
show_underlying: bool = True,
|
|
201
|
+
help_flags: tuple[str, ...] = ("--help", "-h"),
|
|
202
|
+
) -> None:
|
|
203
|
+
"""Handle --help for commands that pass through to underlying tools.
|
|
204
|
+
|
|
205
|
+
Shows both Click's help (custom options) and the underlying tool's help.
|
|
206
|
+
|
|
207
|
+
Args:
|
|
208
|
+
ctx: Click context
|
|
209
|
+
underlying_command: Command to show help for (e.g., ["cz", "bump"])
|
|
210
|
+
show_underlying: Whether to show underlying tool's help (default: True)
|
|
211
|
+
help_flags: Flags that trigger help display (default: ("--help", "-h"))
|
|
212
|
+
Use ("--help",) if -h is reserved by the underlying tool
|
|
213
|
+
"""
|
|
214
|
+
# Check if any help flag is present
|
|
215
|
+
if any(flag in ctx.args for flag in help_flags):
|
|
216
|
+
# Show Click's help first (our custom options)
|
|
217
|
+
click.echo(ctx.get_help())
|
|
218
|
+
|
|
219
|
+
# Then show underlying tool's help if requested
|
|
220
|
+
if show_underlying:
|
|
221
|
+
click.echo("\n" + "=" * 60)
|
|
222
|
+
click.echo(f"Additional options from '{' '.join(underlying_command)}':")
|
|
223
|
+
click.echo("=" * 60 + "\n")
|
|
224
|
+
subprocess.run([*underlying_command, "--help"])
|
|
225
|
+
|
|
226
|
+
ctx.exit(0)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def normalize_static_path(path: str) -> str:
|
|
230
|
+
"""
|
|
231
|
+
Normalize paths for static assets.
|
|
232
|
+
|
|
233
|
+
Only allows two formats:
|
|
234
|
+
1. Pure filename/path without ./ or ../ (e.g., "logo.svg", "somewhere/logo.svg")
|
|
235
|
+
2. Absolute path starting with / (e.g., "/somewhere/logo.svg")
|
|
236
|
+
|
|
237
|
+
Args:
|
|
238
|
+
path: User-provided path from afterpython.toml
|
|
239
|
+
|
|
240
|
+
Returns:
|
|
241
|
+
Normalized path
|
|
242
|
+
|
|
243
|
+
Raises:
|
|
244
|
+
ValueError: If path contains ./ or ../ or other invalid patterns
|
|
245
|
+
|
|
246
|
+
Examples:
|
|
247
|
+
>>> normalize_static_path("logo.svg")
|
|
248
|
+
'logo.svg'
|
|
249
|
+
>>> normalize_static_path("somewhere/logo.svg")
|
|
250
|
+
'/somewhere/logo.svg'
|
|
251
|
+
>>> normalize_static_path("/somewhere/logo.svg")
|
|
252
|
+
'/somewhere/logo.svg'
|
|
253
|
+
>>> normalize_static_path("./logo.svg") # Raises ValueError
|
|
254
|
+
>>> normalize_static_path("../logo.svg") # Raises ValueError
|
|
255
|
+
>>> normalize_static_path("static/logo.svg") # Raises ValueError
|
|
256
|
+
"""
|
|
257
|
+
if not path:
|
|
258
|
+
return ""
|
|
259
|
+
|
|
260
|
+
# Reject paths with ./ or ../
|
|
261
|
+
if "./" in path or "../" in path or path.startswith(".") or path.startswith(".."):
|
|
262
|
+
raise ValueError(
|
|
263
|
+
f"Invalid path '{path}': paths cannot contain './' or '../'. "
|
|
264
|
+
f"Use either a pure filename (e.g., 'logo.svg') or absolute path (e.g., '/somewhere/logo.svg')"
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
# Reject paths with 'static' in them
|
|
268
|
+
if "static" in path.lower():
|
|
269
|
+
raise ValueError(
|
|
270
|
+
f"Invalid path '{path}': do not include 'static' in the path. "
|
|
271
|
+
f"Files are assumed to be in the static/ directory."
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
# If it starts with /, it's absolute - keep as is
|
|
275
|
+
if not path.startswith("/"):
|
|
276
|
+
return "/" + path
|
|
277
|
+
else:
|
|
278
|
+
return path
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def get_relative_static_prefix(file_path: Path, content_type_path: Path) -> str:
|
|
282
|
+
"""Calculate the relative path prefix from a file to its content type's static/ folder.
|
|
283
|
+
|
|
284
|
+
Args:
|
|
285
|
+
file_path: Path to the content file (e.g., blog/layer/blog5.ipynb)
|
|
286
|
+
content_type_path: Path to the content type folder (e.g., blog/)
|
|
287
|
+
|
|
288
|
+
Returns:
|
|
289
|
+
Relative prefix to reach static/ (e.g., "./" or "../" or "../../")
|
|
290
|
+
"""
|
|
291
|
+
# Get the depth: how many directories deep the file is from content_type_path
|
|
292
|
+
relative = file_path.parent.relative_to(content_type_path)
|
|
293
|
+
depth = len(relative.parts)
|
|
294
|
+
|
|
295
|
+
# Build the correct prefix
|
|
296
|
+
if depth == 0:
|
|
297
|
+
return "./" # File is directly in content_type_path
|
|
298
|
+
else:
|
|
299
|
+
return "../" * depth
|
afterpython/version.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from packaging.version import Version as BaseVersion
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class Version(BaseVersion):
|
|
5
|
+
def next_breaking(self, level: str = "auto") -> "Version":
|
|
6
|
+
"""Get the next version that would break compatibility.
|
|
7
|
+
|
|
8
|
+
Args:
|
|
9
|
+
level: "major", "minor", or "auto" (detect from current version)
|
|
10
|
+
- If major == 0: breaks on minor (0.x API is unstable)
|
|
11
|
+
- If major >= 1: breaks on major (semver stable)
|
|
12
|
+
|
|
13
|
+
Examples:
|
|
14
|
+
Version("0.9.7").next_breaking() -> Version("0.10.0")
|
|
15
|
+
Version("1.2.3").next_breaking() -> Version("2.0.0")
|
|
16
|
+
Version("1.2.3").next_breaking("minor") -> Version("1.3.0")
|
|
17
|
+
"""
|
|
18
|
+
if level == "auto":
|
|
19
|
+
# Follow semver convention: 0.x.x breaks on minor, 1.x.x+ breaks on major
|
|
20
|
+
level = "minor" if self.major == 0 else "major"
|
|
21
|
+
|
|
22
|
+
if level == "major":
|
|
23
|
+
return Version(f"{self.major + 1}.0.0")
|
|
24
|
+
elif level == "minor":
|
|
25
|
+
return Version(f"{self.major}.{self.minor + 1}.0")
|
|
26
|
+
else:
|
|
27
|
+
raise ValueError(f"Invalid level: {level}. Use 'major', 'minor', or 'auto'")
|
|
28
|
+
|
|
29
|
+
def bump_major(self) -> "Version":
|
|
30
|
+
"""Bump major version. Example: 1.2.3 -> 2.0.0"""
|
|
31
|
+
return Version(f"{self.major + 1}.0.0")
|
|
32
|
+
|
|
33
|
+
def bump_minor(self) -> "Version":
|
|
34
|
+
"""Bump minor version. Example: 0.9.7 -> 0.10.0"""
|
|
35
|
+
return Version(f"{self.major}.{self.minor + 1}.0")
|
|
36
|
+
|
|
37
|
+
def bump_patch(self) -> "Version":
|
|
38
|
+
"""Bump patch version. Example: 0.9.7 -> 0.9.8"""
|
|
39
|
+
return Version(f"{self.major}.{self.minor}.{self.micro + 1}")
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: afterpython
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: CLI tool to streamline Python package maintenance and generate a dedicated project website
|
|
5
|
+
Keywords: package-maintenance,project-website,static-site-generator,developer-tools,documentation,cli-tool,ci/cd,mystmd,semantic-release,python-packaging
|
|
6
|
+
Author: Stephen Yau
|
|
7
|
+
Author-email: Stephen Yau <softwareentrepreneer+afterpython@gmail.com>
|
|
8
|
+
License-Expression: Apache-2.0
|
|
9
|
+
Classifier: Operating System :: OS Independent
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Topic :: Software Development :: Build Tools
|
|
12
|
+
Classifier: Topic :: Software Development :: Documentation
|
|
13
|
+
Classifier: Typing :: Typed
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Requires-Dist: click>=8.3.1
|
|
18
|
+
Requires-Dist: trogon>=0.6.0
|
|
19
|
+
Requires-Dist: httpx>=0.28.1
|
|
20
|
+
Requires-Dist: tomlkit>=0.13.3
|
|
21
|
+
Requires-Dist: ruamel-yaml>=0.18.16
|
|
22
|
+
Requires-Dist: gitpython>=3.1.45
|
|
23
|
+
Requires-Dist: python-dotenv>=1.2.1
|
|
24
|
+
Requires-Dist: pyproject-metadata>=0.10.0
|
|
25
|
+
Requires-Dist: mystmd>=1.7.0
|
|
26
|
+
Requires-Dist: pdoc>=16.0.0
|
|
27
|
+
Requires-Dist: ruff>=0.14.8
|
|
28
|
+
Requires-Dist: pre-commit>=4.5.0
|
|
29
|
+
Requires-Dist: commitizen>=4.10.0
|
|
30
|
+
Requires-Python: >=3.11
|
|
31
|
+
Project-URL: documentation, https://afterpython.afterpython.org/doc
|
|
32
|
+
Project-URL: homepage, https://afterpython.afterpython.org
|
|
33
|
+
Project-URL: repository, https://github.com/AfterPythonOrg/afterpython
|
|
34
|
+
Description-Content-Type: text/markdown
|
|
35
|
+
|
|
36
|
+
# AfterPython: Python Package Maintenance Toolkit and Project Website Generator
|
|
37
|
+
|
|
38
|
+
[](https://afterpython.org)
|
|
39
|
+

|
|
40
|
+
[](https://pypi.org/project/afterpython)
|
|
41
|
+

|
|
42
|
+
[](https://github.com/AfterPythonOrg/afterpython/discussions)
|
|
43
|
+
[](https://deepwiki.com/AfterPythonOrg/afterpython)
|
|
44
|
+
<!-- [](https://pixi.sh) -->
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
[MyST]: https://mystmd.org
|
|
48
|
+
[MyST Markdown]: https://mystmd.org/spec/
|
|
49
|
+
[Jupyter Notebook]: https://jupyter.org
|
|
50
|
+
[pre-commit]: https://pre-commit.com
|
|
51
|
+
[pagefind]: https://pagefind.app
|
|
52
|
+
[SemVer]: https://semver.org
|
|
53
|
+
[pdoc]: https://pdoc.dev/
|
|
54
|
+
[GitHub Actions]: https://github.com/features/actions
|
|
55
|
+
[PyTorch]: https://pytorch.org
|
|
56
|
+
[Scikit-Learn]: https://scikit-learn.org
|
|
57
|
+
[WebLLM]: https://webllm.mlc.ai/
|
|
58
|
+
[project-website-template]: https://github.com/AfterPythonOrg/project-website-template
|
|
59
|
+
[commitizen]: https://github.com/commitizen-tools/commitizen
|
|
60
|
+
[uv]: https://docs.astral.sh/uv/
|
|
61
|
+
[ruff]: https://docs.astral.sh/ruff/
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
## Problem
|
|
65
|
+
Going from **writing Python code to publishing and maintaining a package** is **time-consuming**.
|
|
66
|
+
First-time maintainers must learn multiple tools and concepts, e.g. [MyST], [SemVer], CI/CD ([pre-commit] hooks, GitHub workflows), and they often spending hours just to deploy a documentation site whereas well-resourced projects like [PyTorch] and [Scikit-Learn] have **dedicated websites** that serve as their project hubs.
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
## Solution
|
|
70
|
+
`afterpython` **automates** the tedious parts of Python package maintenance and generates a **project website** for **building community** and **hosting content** such as documentation, **blog posts**, tutorials, examples and more — empowering more developers to write packages with ease.
|
|
71
|
+
|
|
72
|
+
---
|
|
73
|
+
`afterpython` is a CLI tool that **abstracts away the complexity** of **content writing, website deployment, and package release/maintenance** by providing an opinionated set of modern tools — so you don’t have to spend time selecting or learning anything beyond the basics.
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
## Core Features
|
|
77
|
+
- [x] Write content directly in [MyST Markdown] or [Jupyter Notebook]
|
|
78
|
+
- [x] Go from writing to **website deployment in minutes** — no need to learn any of the underlying tools
|
|
79
|
+
- [x] Centralize all your content in a modern, **unified project website** — from documentation to blog posts
|
|
80
|
+
- [x] Zero-config orchestration — Pre-configured modern tooling with sane defaults (see [Tech Stack](#tech-stack)), so you can start maintaining packages immediately **without learning each tool**
|
|
81
|
+
- [ ] Export content as PDF — for example, combine all blog posts into a single PDF file
|
|
82
|
+
- [ ] **⚡ Full-text search** across **ALL** your content in your website — docs, blogs, tutorials, everything
|
|
83
|
+
- [ ] **🤖 Embedded AI Chatbot** that answers questions directly using an in-browser LLM — at no cost
|
|
84
|
+
|
|
85
|
+
---
|
|
86
|
+
## Project Website
|
|
87
|
+
> The project website for `afterpython` is created using `afterpython` itself. See the [**website**](https://afterpython.afterpython.org).
|
|
88
|
+
<picture>
|
|
89
|
+
<source media="(prefers-color-scheme: dark)" srcset="afterpython/static/website-dark.png">
|
|
90
|
+
<source media="(prefers-color-scheme: light)" srcset="afterpython/static/website-light.png">
|
|
91
|
+
<img alt="Project Website" src="afterpython/static/website-light.png">
|
|
92
|
+
</picture>
|
|
93
|
+
|
|
94
|
+
You can create your own website too and deploy it to GitHub Pages in **less than a minute**! See [Quickstart](https://afterpython.afterpython.org/doc/quickstart).
|
|
95
|
+
|
|
96
|
+
---
|
|
97
|
+
## Installation
|
|
98
|
+
```bash
|
|
99
|
+
# install afterpython as a dev dependency
|
|
100
|
+
uv add --dev afterpython
|
|
101
|
+
|
|
102
|
+
# initialize afterpython
|
|
103
|
+
ap init
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
---
|
|
108
|
+
## CLI Commands
|
|
109
|
+
```bash
|
|
110
|
+
# show all commands
|
|
111
|
+
ap --help
|
|
112
|
+
|
|
113
|
+
# or use terminal UI (TUI)
|
|
114
|
+
ap tui
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
---
|
|
119
|
+
## Tech Stack
|
|
120
|
+
- [MyST]
|
|
121
|
+
- [project-website-template]
|
|
122
|
+
- [pre-commit]
|
|
123
|
+
- [GitHub Actions]
|
|
124
|
+
- [pdoc]
|
|
125
|
+
- [commitizen]
|
|
126
|
+
- [uv]
|
|
127
|
+
- [ruff]
|
|
128
|
+
- [pagefind]
|
|
129
|
+
- [WebLLM]
|
|
130
|
+
<!-- - ty -->
|
|
131
|
+
<!-- - [pixi] -->
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
afterpython/__init__.py,sha256=aNochs72gU2NR1cBzIkk3NxSsdJ7ceHluI5gdYwaFpI,289
|
|
2
|
+
afterpython/_io/toml.py,sha256=dvmq6n4MhlJ02u_YjfC3mOnCWatfCn_J1lugavigoJw,3410
|
|
3
|
+
afterpython/_io/yaml.py,sha256=raksve_gaIFJim8lQrq-kQHkRjTloed7CT2FY0oUJSg,654
|
|
4
|
+
afterpython/_paths.py,sha256=FTwQ7necKYuRosCyMZPfv1eqaEmGIsyqd3yXpm-WQTA,1584
|
|
5
|
+
afterpython/_typing.py,sha256=FbphSGoujPuTfCZqt0HaKQrBgzhRpCOEAEQU6Cbl7oA,145
|
|
6
|
+
afterpython/builders/__init__.py,sha256=YMfYfsbSJNMjqfq9GRsQj8ayWUhtZKpvrc0oFUASICo,575
|
|
7
|
+
afterpython/builders/content_json.py,sha256=-LTkeA8oA6XEudc2b1H7U0-UwcJlHn7xgICeweMYrG0,4805
|
|
8
|
+
afterpython/builders/index_md.py,sha256=SDVHnVftL4D1AA8jw2YvRdTem_Ho8v8DcrORVjVtW8A,3762
|
|
9
|
+
afterpython/builders/jupyter_notebook.py,sha256=2H5Qv_IIkmOud-GYZPYjB0E30fVZubpUUQ5gFrtGSqU,10140
|
|
10
|
+
afterpython/builders/markdown.py,sha256=0ExfRqvOTWsgUkAVaGGNkiNDtNedRr7JR1yTGxpbsmA,8736
|
|
11
|
+
afterpython/builders/metadata.py,sha256=--0zWaI0ZI1wKeezcyqSUy-h1IuLqnI33ateoAHIaBU,1337
|
|
12
|
+
afterpython/cli/__init__.py,sha256=G4AB-rllhmr7tURFc5cXJfkjZZ2bXtMBg3oy4zUGEFw,84
|
|
13
|
+
afterpython/cli/commands/add.py,sha256=Ppp4msdvOwN9_-fdG5-b0-IV9p0Eef9CiIEU3srUjYk,1792
|
|
14
|
+
afterpython/cli/commands/build.py,sha256=Vvyd7AkL2Tw3vEDhi4bEOWD9hdtxon7SgAzXUeh5D8w,9050
|
|
15
|
+
afterpython/cli/commands/bump.py,sha256=2MZBQ1kxjr0bWhA--_xHwdwjsUlCyrXPn3pJ5NDZO4k,4088
|
|
16
|
+
afterpython/cli/commands/check.py,sha256=lrPaVThAwxxnczu-VOZT7dd7aKjstnMQfX9UpZY3Lxs,1058
|
|
17
|
+
afterpython/cli/commands/clean.py,sha256=havP43ZC1AKFzvscsRMF_to6TvOwruxGA5qzr6dMHvg,2529
|
|
18
|
+
afterpython/cli/commands/commit.py,sha256=9QOKyt4tfTJzZEdHEPAdpUc4stYMSvG3-31uADuBxn4,1420
|
|
19
|
+
afterpython/cli/commands/commitizen.py,sha256=H3jewz4VkZi5FYTWw3USVM3XosRx12kPShOQGxD3LNM,842
|
|
20
|
+
afterpython/cli/commands/dev.py,sha256=YIJECfroGRQnuMWWRC4W_DMA0eJoQssufj8WBDjQVOM,6796
|
|
21
|
+
afterpython/cli/commands/format.py,sha256=VFwCG9KDTF6j4Y_Ewp4_RpS8uNBQNOAj9HU_G97my5A,710
|
|
22
|
+
afterpython/cli/commands/init.py,sha256=UiE8QIhDV9NqoqU2EU_FMAlJ2Y18lFF0x2JmcyiAQxc,3044
|
|
23
|
+
afterpython/cli/commands/init_branch_rules.py,sha256=fzAm8D5qbW3u4bEMpqPuRgO-0akuCGG7pu09JlKhYn4,243
|
|
24
|
+
afterpython/cli/commands/install.py,sha256=B8JuQmtU_4EZpHct2J5j30BGLkGmvwKvU26upbmabvQ,750
|
|
25
|
+
afterpython/cli/commands/lock.py,sha256=Kppu_0tnqdbP6Gbulxf123f-cFzTGIw39si2O7rDo3Q,603
|
|
26
|
+
afterpython/cli/commands/pre_commit.py,sha256=QIuzTZEfw4uPMqmQT8YmyD0yewBcwjLvcnbVjRbonQE,1978
|
|
27
|
+
afterpython/cli/commands/preview.py,sha256=sBwe46H0V-U7I4d0-u-nXmPBWGmkjKhkUTbqV927zRM,593
|
|
28
|
+
afterpython/cli/commands/release.py,sha256=6SDEnPtuCL0_XUqWUlvRSKxAv011QqOD0kc5pKu7378,2479
|
|
29
|
+
afterpython/cli/commands/remove.py,sha256=Fl79JSI9IdHyzYflocXxcxgBkBPkNFAW98Yuijly26g,1812
|
|
30
|
+
afterpython/cli/commands/start.py,sha256=q4rUkIllOO8wJQ9ABtsx-dqkW1yC0vltAtSkiHN_GH4,4002
|
|
31
|
+
afterpython/cli/commands/sync.py,sha256=u9L3aq04M5VHh0FByDSpmBD7F1RYCNsIgvZFifnRZ6A,6042
|
|
32
|
+
afterpython/cli/commands/update.py,sha256=4h_zKp72e4RmXUKHyeP6kfHqr4V3IugSOnxa-E1F55I,6312
|
|
33
|
+
afterpython/cli/main.py,sha256=m3BJ5rOlllUyOngD2HF2fosDLrduREzAQKpsTqcYPZc,4956
|
|
34
|
+
afterpython/const.py,sha256=B_yUBmHPyu98aOibwt7idWbhKNBBed0TeYXNBd8tPTk,157
|
|
35
|
+
afterpython/main.py,sha256=w0z6mTib3u4p2HxMrk-tIJlZtur7uRaqsbE0ewlYlSs,490
|
|
36
|
+
afterpython/pcu.py,sha256=EoEhSzFZ49b5qc9KH2RLxIHSzY8YFM5uhtw07chcrr8,6093
|
|
37
|
+
afterpython/templates/ci-workflow-template.yml,sha256=w0BiV9F_y9YNTd6mTD7xRlJgwRQw61AinhiACXlEQqk,5933
|
|
38
|
+
afterpython/templates/cz-template.toml,sha256=2qwbQLb0nbZaA63oWXFFzvzPNBsFZHATL1C2rdNdjjs,185
|
|
39
|
+
afterpython/templates/dependabot-template.yml,sha256=CBQEgh8EsXw2X7IqzGtFI7lVT9liF6PW4DvGLB0X2LY,1136
|
|
40
|
+
afterpython/templates/deploy-workflow-template.yml,sha256=2W4K3lKMYjSTPrlXIsMweBzqP9IoLtZXAAcUu5FA5NA,2365
|
|
41
|
+
afterpython/templates/pre-commit-config-template.yaml,sha256=u_JF4YHGHIWxLdcX5BS__VsVgOwwVQQsSZGPNR2Yyf0,915
|
|
42
|
+
afterpython/templates/release-workflow-template.yml,sha256=3YBkgrqmi--uznslpMxPz7Vj7L4_NPlhDxgwYBt-Ui8,1606
|
|
43
|
+
afterpython/templates/ruff-template.toml,sha256=lNCd4EthoLScr4cyVEgGUH4xK5CfIX3f9I3zkJHAYgg,1008
|
|
44
|
+
afterpython/tools/_afterpython.py,sha256=G1075u8VT6s-TjEcGajqEYS3pZoW0sGyJM7DJohMOPE,4244
|
|
45
|
+
afterpython/tools/_git.py,sha256=aO-qDEQ4OevD_B74DYKK4nQKYeiaS1BXmYc8paJRfbQ,3772
|
|
46
|
+
afterpython/tools/branch_rules.py,sha256=ciQ2H2YBnoCZu_plyVeIHzf2FcAILRFhpjzOR2_IfJg,3159
|
|
47
|
+
afterpython/tools/commitizen.py,sha256=ZO1qHmzUU0myd85tncv76WtIR7DB_AoQGSUvaf78Ohg,438
|
|
48
|
+
afterpython/tools/github_actions.py,sha256=zVmg9aqUg-EO0CnbRHl9i8TmFtXpt7nd0VjzckqqE30,1527
|
|
49
|
+
afterpython/tools/myst.py,sha256=2VO-gD25PbGSD8-v1sLI1v3Q9loahRd3ednKrW4_Utc,8113
|
|
50
|
+
afterpython/tools/pre_commit.py,sha256=oNsrUkPI13rSSBSyhyTTKPnQGhDxLIPYhxu6kL0fDmo,1206
|
|
51
|
+
afterpython/tools/pyproject.py,sha256=kb7BDn24yH_xS2F5_L0dK-GL4KgOJS-MIIUQhHnLorI,3507
|
|
52
|
+
afterpython/utils.py,sha256=s1FIJP6FvpAcK8SujB8glFbiKx-ZCltywh5ihaOhL0U,9516
|
|
53
|
+
afterpython/version.py,sha256=hihRV1S-gc1m46OaXD32ikkEkqzs-Gtq7AcAqOQV-wY,1603
|
|
54
|
+
afterpython-0.1.0.dist-info/WHEEL,sha256=93kfTGt3a0Dykt_T-gsjtyS5_p8F_d6CE1NwmBOirzo,79
|
|
55
|
+
afterpython-0.1.0.dist-info/entry_points.txt,sha256=roa0PDgBueM1JPbRG0G2OSxzaN394OUL_oS9iEwVLBc,80
|
|
56
|
+
afterpython-0.1.0.dist-info/METADATA,sha256=pld74AzH2JQEKTgHbrVpJ0BJUNk2k7HAzumazs-83bo,5779
|
|
57
|
+
afterpython-0.1.0.dist-info/RECORD,,
|