afterpython 0.0.1.dev2__py3-none-any.whl → 0.1.0.dev5__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/.pre-commit-config.yaml +48 -0
- afterpython/__init__.py +13 -0
- afterpython/_io/toml.py +103 -0
- afterpython/_io/yaml.py +26 -0
- afterpython/_paths.py +39 -0
- afterpython/_typing.py +5 -0
- afterpython/builders/__init__.py +8 -0
- afterpython/builders/metadata.py +46 -0
- afterpython/builders/molab.py +99 -0
- afterpython/cli/__init__.py +4 -0
- afterpython/cli/commands/build.py +190 -0
- afterpython/cli/commands/bump.py +88 -0
- afterpython/cli/commands/check.py +36 -0
- afterpython/cli/commands/clean.py +84 -0
- afterpython/cli/commands/commit.py +34 -0
- afterpython/cli/commands/commitizen.py +32 -0
- afterpython/cli/commands/dev.py +146 -0
- afterpython/cli/commands/format.py +28 -0
- afterpython/cli/commands/init.py +98 -0
- afterpython/cli/commands/install.py +22 -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/start.py +140 -0
- afterpython/cli/commands/sync.py +108 -0
- afterpython/cli/commands/update.py +153 -0
- afterpython/cli/main.py +71 -0
- afterpython/const.py +5 -0
- afterpython/cz.toml +27 -0
- afterpython/main.py +25 -0
- afterpython/pcu.py +164 -0
- afterpython/templates/deploy-workflow-template.yml +90 -0
- afterpython/templates/release-workflow-template.yml +56 -0
- afterpython/templates/ruff-template.toml +32 -0
- afterpython/tools/_afterpython.py +65 -0
- afterpython/tools/_git.py +111 -0
- afterpython/tools/commitizen.py +53 -0
- afterpython/tools/github_actions.py +78 -0
- afterpython/tools/myst.py +164 -0
- afterpython/tools/pre_commit.py +62 -0
- afterpython/tools/pyproject.py +72 -0
- afterpython/utils.py +215 -0
- afterpython/version.py +39 -0
- afterpython-0.1.0.dev5.dist-info/METADATA +105 -0
- afterpython-0.1.0.dev5.dist-info/RECORD +47 -0
- afterpython-0.1.0.dev5.dist-info/WHEEL +4 -0
- afterpython-0.1.0.dev5.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,164 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from typing import TYPE_CHECKING
|
|
3
|
+
|
|
4
|
+
if TYPE_CHECKING:
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from afterpython._typing import tContentType, NodeEnv
|
|
7
|
+
|
|
8
|
+
import subprocess
|
|
9
|
+
|
|
10
|
+
import afterpython as ap
|
|
11
|
+
from afterpython.utils import deep_merge
|
|
12
|
+
from afterpython._io.yaml import write_yaml, read_yaml
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def update_authors_yml(data_update: dict):
|
|
16
|
+
"""Update authors.yml while preserving order and formatting"""
|
|
17
|
+
file_path = ap.paths.afterpython_path / "authors.yml"
|
|
18
|
+
|
|
19
|
+
if not file_path.exists():
|
|
20
|
+
write_yaml(file_path, data_update)
|
|
21
|
+
return
|
|
22
|
+
|
|
23
|
+
existing_data = read_yaml(file_path) or {}
|
|
24
|
+
|
|
25
|
+
# merge authors, keep the author with more fields (e.g. github, x, etc.) to avoid duplication
|
|
26
|
+
merged_authors = {}
|
|
27
|
+
for author in (
|
|
28
|
+
existing_data["project"]["contributors"]
|
|
29
|
+
+ data_update["project"]["contributors"]
|
|
30
|
+
):
|
|
31
|
+
author_id = author["id"]
|
|
32
|
+
if author_id not in merged_authors:
|
|
33
|
+
merged_authors[author_id] = author
|
|
34
|
+
else:
|
|
35
|
+
# keep the author with more fields (e.g. github, x, etc.)
|
|
36
|
+
if len(author.keys()) > len(merged_authors[author_id].keys()):
|
|
37
|
+
merged_authors[author_id] = author
|
|
38
|
+
existing_data["project"]["contributors"] = list(merged_authors.values())
|
|
39
|
+
|
|
40
|
+
# set comments for project section for convenience
|
|
41
|
+
if "project" in existing_data and not existing_data["project"].ca.items.get(
|
|
42
|
+
"contributors"
|
|
43
|
+
):
|
|
44
|
+
existing_data["project"].yaml_set_comment_before_after_key(
|
|
45
|
+
"contributors",
|
|
46
|
+
before="See more at: https://mystmd.org/guide/frontmatter#frontmatter-authors",
|
|
47
|
+
)
|
|
48
|
+
write_yaml(file_path, existing_data)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def update_myst_yml(data_update: dict, path: Path):
|
|
52
|
+
"""Update myst.yml while preserving order and formatting
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
data_update: dict of data to update
|
|
56
|
+
path: path to the myst.yml file, e.g. doc/, blog/, tutorial/, example/, guide/
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
file_path = path / "myst.yml"
|
|
60
|
+
|
|
61
|
+
if not file_path.exists():
|
|
62
|
+
raise FileNotFoundError(
|
|
63
|
+
f"myst.yml not found at {file_path}, did you forget to run `ap init` or `myst init`?"
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
existing_data = read_yaml(file_path) or {}
|
|
67
|
+
existing_data = deep_merge(existing_data, data_update)
|
|
68
|
+
|
|
69
|
+
# set comments for project section for convenience
|
|
70
|
+
if "project" in existing_data:
|
|
71
|
+
if not existing_data["project"].ca.items.get("id"):
|
|
72
|
+
existing_data["project"].yaml_set_comment_before_after_key(
|
|
73
|
+
"id",
|
|
74
|
+
before="See how to create Table of Contents at: https://mystmd.org/guide/table-of-contents",
|
|
75
|
+
)
|
|
76
|
+
if not existing_data["project"].ca.items.get("authors"):
|
|
77
|
+
existing_data["project"].yaml_set_comment_before_after_key(
|
|
78
|
+
"authors",
|
|
79
|
+
before="See more authors' fields at: https://mystmd.org/guide/frontmatter#frontmatter-authors",
|
|
80
|
+
)
|
|
81
|
+
if not existing_data["project"].ca.items.get("venue"):
|
|
82
|
+
existing_data["project"].yaml_set_comment_before_after_key(
|
|
83
|
+
"venue",
|
|
84
|
+
before="See more venue's fields at: https://mystmd.org/guide/frontmatter#venue",
|
|
85
|
+
)
|
|
86
|
+
if "site" in existing_data:
|
|
87
|
+
if not existing_data["site"].ca.items.get("options"):
|
|
88
|
+
existing_data["site"].yaml_set_comment_before_after_key(
|
|
89
|
+
"options",
|
|
90
|
+
before="See options at: https://mystmd.org/guide/website-templates#site-options",
|
|
91
|
+
)
|
|
92
|
+
if not existing_data["site"].ca.items.get("actions"):
|
|
93
|
+
existing_data["site"].yaml_set_comment_before_after_key(
|
|
94
|
+
"actions",
|
|
95
|
+
before="See web layout at: https://mystmd.org/guide/website-navigation",
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
write_yaml(file_path, existing_data)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _write_welcome_file(content_type: tContentType):
|
|
102
|
+
welcome_file = ap.paths.afterpython_path / content_type / "index.md"
|
|
103
|
+
if welcome_file.exists():
|
|
104
|
+
return
|
|
105
|
+
welcome_content = f"""# Welcome to AfterPython
|
|
106
|
+
|
|
107
|
+
Welcome to your project's {content_type}! This is a starter page to help you get started.
|
|
108
|
+
|
|
109
|
+
## Getting Started
|
|
110
|
+
|
|
111
|
+
Replace this placeholder content with your own. Here's what you can do:
|
|
112
|
+
|
|
113
|
+
- Creating new `.md` or `.ipynb` files in the `afterpython/{content_type}/` directory
|
|
114
|
+
- Writing in MyST Markdown format
|
|
115
|
+
- Adding images to the `afterpython/static/` directory and referencing them
|
|
116
|
+
|
|
117
|
+
## Resources
|
|
118
|
+
|
|
119
|
+
- [AfterPython's Project Website](https://ap.afterpython.org)
|
|
120
|
+
- [MyST Markdown Guide](https://mystmd.org)
|
|
121
|
+
|
|
122
|
+
Start building your amazing project! 🚀
|
|
123
|
+
"""
|
|
124
|
+
welcome_file.write_text(welcome_content)
|
|
125
|
+
return welcome_file
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def init_myst():
|
|
129
|
+
"""
|
|
130
|
+
Initialize MyST Markdown (mystmd) and myst.yml files in
|
|
131
|
+
doc/, blog/, tutorial/, example/, guide/ directories with sensible defaults
|
|
132
|
+
"""
|
|
133
|
+
from afterpython.const import CONTENT_TYPES
|
|
134
|
+
from afterpython.utils import find_node_env
|
|
135
|
+
|
|
136
|
+
# find any existing node.js version and use it, if no, install the Node.js version specified in NODEENV_VERSION
|
|
137
|
+
node_env: NodeEnv = find_node_env()
|
|
138
|
+
subprocess.run(["npm", "install", "-g", "pnpm"], env=node_env, check=True)
|
|
139
|
+
for content_type in CONTENT_TYPES:
|
|
140
|
+
path = ap.paths.afterpython_path / content_type
|
|
141
|
+
print(f"Initializing MyST Markdown (mystmd) in {path.name}/ directory ...")
|
|
142
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
143
|
+
subprocess.run(["myst", "init"], cwd=path, input="n\n", text=True, env=node_env)
|
|
144
|
+
myst_yml_defaults = {
|
|
145
|
+
"extends": "../authors.yml",
|
|
146
|
+
"project": {
|
|
147
|
+
"license": "CC-BY-4.0",
|
|
148
|
+
"subject": content_type.capitalize()
|
|
149
|
+
if content_type != "doc"
|
|
150
|
+
else "Documentation",
|
|
151
|
+
},
|
|
152
|
+
"site": {
|
|
153
|
+
"options": {
|
|
154
|
+
"favicon": "../static/favicon.ico",
|
|
155
|
+
"logo": "../static/logo.svg",
|
|
156
|
+
"logo_dark": "../static/logo.svg",
|
|
157
|
+
"analytics_google": "{{ GOOGLE_ANALYTICS_ID }}",
|
|
158
|
+
# "twitter": "",
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
}
|
|
162
|
+
update_myst_yml(myst_yml_defaults, path)
|
|
163
|
+
_write_welcome_file(content_type)
|
|
164
|
+
subprocess.run(["ap", "sync"])
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import subprocess
|
|
2
|
+
|
|
3
|
+
import afterpython as ap
|
|
4
|
+
|
|
5
|
+
from afterpython._io.yaml import write_yaml, read_yaml
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
pre_commit_default = {
|
|
9
|
+
# default is only ["pre-commit"], without changing the default,
|
|
10
|
+
# we need to explicitly pass in --hook-type whenever we run "pre-commit install --hook-type ..."
|
|
11
|
+
"default_install_hook_types": ["pre-commit", "commit-msg", "pre-push"],
|
|
12
|
+
"exclude": "^afterpython/_website/", # do not check the project website template
|
|
13
|
+
"repos": [
|
|
14
|
+
{
|
|
15
|
+
"repo": "https://github.com/pre-commit/pre-commit-hooks",
|
|
16
|
+
"rev": "v6.0.0",
|
|
17
|
+
"hooks": [
|
|
18
|
+
{"id": "trailing-whitespace", "stages": ["pre-commit"]},
|
|
19
|
+
{"id": "end-of-file-fixer", "stages": ["pre-commit"]},
|
|
20
|
+
{"id": "check-yaml", "stages": ["pre-commit"]},
|
|
21
|
+
{"id": "check-toml", "stages": ["pre-commit"]},
|
|
22
|
+
{"id": "check-added-large-files", "stages": ["pre-commit"]},
|
|
23
|
+
],
|
|
24
|
+
},
|
|
25
|
+
# {
|
|
26
|
+
# "repo": "https://github.com/astral-sh/uv-pre-commit",
|
|
27
|
+
# "rev": "0.9.8",
|
|
28
|
+
# "hooks": [
|
|
29
|
+
# {"id": "uv-lock"}, # Update the uv lockfile
|
|
30
|
+
# ]
|
|
31
|
+
# },
|
|
32
|
+
],
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def install_pre_commit():
|
|
37
|
+
# installed in .git/hooks
|
|
38
|
+
subprocess.run(["ap", "pre-commit", "install", "--install-hooks"], check=True)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def update_pre_commit(data_update: dict):
|
|
42
|
+
from afterpython.utils import deep_merge
|
|
43
|
+
|
|
44
|
+
pre_commit_path = ap.paths.afterpython_path / ".pre-commit-config.yaml"
|
|
45
|
+
if not pre_commit_path.exists():
|
|
46
|
+
raise FileNotFoundError(
|
|
47
|
+
f".pre-commit-config.yaml not found at {pre_commit_path}"
|
|
48
|
+
)
|
|
49
|
+
existing_data = read_yaml(pre_commit_path)
|
|
50
|
+
existing_data = deep_merge(existing_data, data_update)
|
|
51
|
+
write_yaml(pre_commit_path, existing_data)
|
|
52
|
+
install_pre_commit()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def init_pre_commit():
|
|
56
|
+
pre_commit_path = ap.paths.afterpython_path / ".pre-commit-config.yaml"
|
|
57
|
+
if pre_commit_path.exists():
|
|
58
|
+
print(f".pre-commit-config.yaml already exists at {pre_commit_path}")
|
|
59
|
+
return
|
|
60
|
+
write_yaml(pre_commit_path, pre_commit_default)
|
|
61
|
+
print(f"Created {pre_commit_path}")
|
|
62
|
+
install_pre_commit()
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from typing import TYPE_CHECKING
|
|
3
|
+
|
|
4
|
+
if TYPE_CHECKING:
|
|
5
|
+
from tomlkit.toml_document import TOMLDocument
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
|
|
9
|
+
from pyproject_metadata import StandardMetadata
|
|
10
|
+
|
|
11
|
+
import afterpython as ap
|
|
12
|
+
from afterpython._io.toml import read_toml, write_toml, _to_tomlkit
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def read_pyproject() -> TOMLDocument:
|
|
16
|
+
"""Read pyproject.toml"""
|
|
17
|
+
return read_toml(ap.paths.pyproject_path)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def write_pyproject(data: TOMLDocument):
|
|
21
|
+
write_toml(ap.paths.pyproject_path, data)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def read_metadata() -> StandardMetadata:
|
|
25
|
+
"""Read metadata from pyproject.toml"""
|
|
26
|
+
return StandardMetadata.from_pyproject(read_pyproject())
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def init_pyproject():
|
|
30
|
+
"""Initialize pyproject.toml with sensible defaults
|
|
31
|
+
- add [build-system] section with uv build backend (same as `uv init --package`)
|
|
32
|
+
- add [project.urls] section with homepage, repository, and documentation URLs
|
|
33
|
+
"""
|
|
34
|
+
import httpx
|
|
35
|
+
from afterpython.utils import fetch_pypi_json
|
|
36
|
+
from afterpython.tools._git import get_git_user_config, get_github_url
|
|
37
|
+
|
|
38
|
+
build_backend = "uv_build"
|
|
39
|
+
|
|
40
|
+
async def fetch_build_backend_version() -> str | None:
|
|
41
|
+
"""Fetch the latest version of build backend package from PyPI."""
|
|
42
|
+
async with httpx.AsyncClient() as client:
|
|
43
|
+
data = await fetch_pypi_json(client, build_backend)
|
|
44
|
+
return data["info"]["version"] if data else None
|
|
45
|
+
|
|
46
|
+
data: TOMLDocument = read_pyproject()
|
|
47
|
+
is_updated = False
|
|
48
|
+
|
|
49
|
+
if "build-system" not in data:
|
|
50
|
+
uv_build_version = asyncio.run(fetch_build_backend_version())
|
|
51
|
+
if uv_build_version:
|
|
52
|
+
data["build-system"] = {
|
|
53
|
+
"requires": [f"{build_backend}>={uv_build_version}"],
|
|
54
|
+
"build-backend": build_backend,
|
|
55
|
+
}
|
|
56
|
+
is_updated = True
|
|
57
|
+
|
|
58
|
+
if "project" in data:
|
|
59
|
+
if "urls" not in data["project"]:
|
|
60
|
+
data["project"]["urls"] = {
|
|
61
|
+
"homepage": "",
|
|
62
|
+
"repository": get_github_url() or "",
|
|
63
|
+
"documentation": "",
|
|
64
|
+
}
|
|
65
|
+
is_updated = True
|
|
66
|
+
if "authors" not in data["project"]:
|
|
67
|
+
# convert git user config to tomlkit object
|
|
68
|
+
data["project"]["authors"] = _to_tomlkit([get_git_user_config()])
|
|
69
|
+
is_updated = True
|
|
70
|
+
|
|
71
|
+
if is_updated:
|
|
72
|
+
write_pyproject(data)
|
afterpython/utils.py
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from typing import TYPE_CHECKING
|
|
3
|
+
|
|
4
|
+
if TYPE_CHECKING:
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from httpx import AsyncClient
|
|
7
|
+
from afterpython._typing import NodeEnv
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
import shutil
|
|
12
|
+
import subprocess
|
|
13
|
+
|
|
14
|
+
import click
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def find_node_env() -> NodeEnv:
|
|
18
|
+
"""
|
|
19
|
+
Find if there is an installed Node.js version, if yes, use it
|
|
20
|
+
If no, install the Node.js version specified in NODEENV_VERSION
|
|
21
|
+
"""
|
|
22
|
+
from mystmd_py.nodeenv import find_any_node
|
|
23
|
+
from afterpython.const import NODEENV_VERSION
|
|
24
|
+
|
|
25
|
+
# from mystmd_py.main import ensure_valid_version
|
|
26
|
+
# Use mystmd's own node-finding logic
|
|
27
|
+
binary_path = os.environ.get("PATH", os.defpath)
|
|
28
|
+
_node_path, os_path = find_any_node(binary_path, nodeenv_version=NODEENV_VERSION)
|
|
29
|
+
node_env: NodeEnv = {**os.environ, "PATH": os_path}
|
|
30
|
+
return node_env
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def has_uv() -> bool:
|
|
34
|
+
"""Check if uv is installed"""
|
|
35
|
+
return shutil.which("uv") is not None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def has_gh() -> bool:
|
|
39
|
+
"""Check if gh is installed"""
|
|
40
|
+
return shutil.which("gh") is not None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
async def fetch_pypi_json(client: AsyncClient, package_name: str) -> dict | None:
|
|
44
|
+
url = f"https://pypi.org/pypi/{package_name}/json"
|
|
45
|
+
try:
|
|
46
|
+
response = await client.get(url)
|
|
47
|
+
response.raise_for_status()
|
|
48
|
+
data = response.json()
|
|
49
|
+
return data
|
|
50
|
+
except Exception as e:
|
|
51
|
+
# Return None if package doesn't exist or network fails
|
|
52
|
+
print(f"Warning: Could not fetch PyPI JSON for {package_name}: {e}")
|
|
53
|
+
return None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# VIBE-CODED
|
|
57
|
+
def detect_license_from_file(file_path: str) -> str:
|
|
58
|
+
"""Extract license name from LICENSE file using regex."""
|
|
59
|
+
with open(file_path, encoding="utf-8") as f:
|
|
60
|
+
content = f.read()
|
|
61
|
+
|
|
62
|
+
# Common license patterns
|
|
63
|
+
patterns = {
|
|
64
|
+
"Apache-2.0": r"Apache License\s+Version 2\.0",
|
|
65
|
+
"MIT": r"MIT License|Permission is hereby granted, free of charge",
|
|
66
|
+
"GPL-3.0": r"GNU GENERAL PUBLIC LICENSE\s+Version 3",
|
|
67
|
+
"GPL-2.0": r"GNU GENERAL PUBLIC LICENSE\s+Version 2",
|
|
68
|
+
"BSD-3-Clause": r"BSD 3-Clause License|Redistribution and use in source and binary forms",
|
|
69
|
+
"BSD-2-Clause": r"BSD 2-Clause License",
|
|
70
|
+
"ISC": r"ISC License",
|
|
71
|
+
"LGPL-3.0": r"GNU LESSER GENERAL PUBLIC LICENSE\s+Version 3",
|
|
72
|
+
"MPL-2.0": r"Mozilla Public License Version 2\.0",
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
# Check first 500 chars for license header
|
|
76
|
+
header = content[:500]
|
|
77
|
+
|
|
78
|
+
for license_id, pattern in patterns.items():
|
|
79
|
+
if re.search(pattern, header, re.IGNORECASE):
|
|
80
|
+
return license_id
|
|
81
|
+
|
|
82
|
+
return ""
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def deep_merge(base: dict, updates: dict) -> dict:
|
|
86
|
+
"""Deep merge updates into base, preserving structure and metadata.
|
|
87
|
+
|
|
88
|
+
Works with:
|
|
89
|
+
- Regular Python dicts
|
|
90
|
+
- tomlkit TOMLDocument/Table objects
|
|
91
|
+
- ruamel.yaml CommentedMap objects
|
|
92
|
+
|
|
93
|
+
Behavior:
|
|
94
|
+
- Dicts are merged recursively
|
|
95
|
+
- Lists are extended (concatenated)
|
|
96
|
+
- Other types are overwritten
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
base: The base dictionary to merge into (modified in-place)
|
|
100
|
+
updates: The updates to apply
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
The merged base dictionary (same object, modified in-place)
|
|
104
|
+
"""
|
|
105
|
+
for key, value in updates.items():
|
|
106
|
+
if key in base:
|
|
107
|
+
if isinstance(base[key], dict) and isinstance(value, dict):
|
|
108
|
+
# Recursively merge dicts
|
|
109
|
+
deep_merge(base[key], value)
|
|
110
|
+
elif isinstance(base[key], list) and isinstance(value, list):
|
|
111
|
+
# Extend lists, but only add items that don't already exist
|
|
112
|
+
for item in value:
|
|
113
|
+
if item not in base[key]:
|
|
114
|
+
base[key].append(item)
|
|
115
|
+
else:
|
|
116
|
+
# Overwrite for other types
|
|
117
|
+
base[key] = value
|
|
118
|
+
else:
|
|
119
|
+
base[key] = value
|
|
120
|
+
|
|
121
|
+
return base
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def convert_author_name_to_id(name: str) -> str:
|
|
125
|
+
"""Convert author name to ID
|
|
126
|
+
|
|
127
|
+
Args:
|
|
128
|
+
name: The author name
|
|
129
|
+
|
|
130
|
+
Returns:
|
|
131
|
+
The author ID
|
|
132
|
+
|
|
133
|
+
Examples:
|
|
134
|
+
- "Stephen Yau" -> "stephen_yau"
|
|
135
|
+
- "John Doe" -> "john_doe"
|
|
136
|
+
- "Jane Smith" -> "jane_smith"
|
|
137
|
+
"""
|
|
138
|
+
return name.replace(" ", "_").lower()
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def find_available_port(
|
|
142
|
+
start_port: int = 3000, max_port: int = 3100, host: str = "localhost"
|
|
143
|
+
) -> int:
|
|
144
|
+
"""
|
|
145
|
+
Find a TCP port with no listener on `host`, starting from start_port.
|
|
146
|
+
"""
|
|
147
|
+
import socket
|
|
148
|
+
|
|
149
|
+
def is_port_in_use(
|
|
150
|
+
port: int, host: str = "localhost", timeout: float = 0.2
|
|
151
|
+
) -> bool:
|
|
152
|
+
"""
|
|
153
|
+
Return True if *any* TCP listener is active on the given host:port.
|
|
154
|
+
This works across IPv4/IPv6 by using getaddrinfo().
|
|
155
|
+
"""
|
|
156
|
+
try:
|
|
157
|
+
for family, socktype, proto, _canonname, sockaddr in socket.getaddrinfo(
|
|
158
|
+
host, port, type=socket.SOCK_STREAM
|
|
159
|
+
):
|
|
160
|
+
with socket.socket(family, socktype, proto) as s:
|
|
161
|
+
s.settimeout(timeout)
|
|
162
|
+
if s.connect_ex(sockaddr) == 0:
|
|
163
|
+
return True
|
|
164
|
+
except socket.gaierror:
|
|
165
|
+
# host couldn't be resolved; treat as no listener
|
|
166
|
+
return False
|
|
167
|
+
return False
|
|
168
|
+
|
|
169
|
+
for port in range(start_port, max_port + 1):
|
|
170
|
+
if not is_port_in_use(port, host=host):
|
|
171
|
+
return port
|
|
172
|
+
raise RuntimeError(
|
|
173
|
+
f"No free ports available in range {start_port}-{max_port} for host={host!r}"
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def has_content_for_myst(content_path: Path) -> bool:
|
|
178
|
+
"""Check if a content directory has any content files"""
|
|
179
|
+
return any(
|
|
180
|
+
content_path.glob("*.md")
|
|
181
|
+
or content_path.glob("*.ipynb")
|
|
182
|
+
or content_path.glob("*.tex")
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def handle_passthrough_help(
|
|
187
|
+
ctx: click.Context,
|
|
188
|
+
underlying_command: list[str],
|
|
189
|
+
show_underlying: bool = True,
|
|
190
|
+
help_flags: tuple[str, ...] = ("--help", "-h"),
|
|
191
|
+
) -> None:
|
|
192
|
+
"""Handle --help for commands that pass through to underlying tools.
|
|
193
|
+
|
|
194
|
+
Shows both Click's help (custom options) and the underlying tool's help.
|
|
195
|
+
|
|
196
|
+
Args:
|
|
197
|
+
ctx: Click context
|
|
198
|
+
underlying_command: Command to show help for (e.g., ["cz", "bump"])
|
|
199
|
+
show_underlying: Whether to show underlying tool's help (default: True)
|
|
200
|
+
help_flags: Flags that trigger help display (default: ("--help", "-h"))
|
|
201
|
+
Use ("--help",) if -h is reserved by the underlying tool
|
|
202
|
+
"""
|
|
203
|
+
# Check if any help flag is present
|
|
204
|
+
if any(flag in ctx.args for flag in help_flags):
|
|
205
|
+
# Show Click's help first (our custom options)
|
|
206
|
+
click.echo(ctx.get_help())
|
|
207
|
+
|
|
208
|
+
# Then show underlying tool's help if requested
|
|
209
|
+
if show_underlying:
|
|
210
|
+
click.echo("\n" + "=" * 60)
|
|
211
|
+
click.echo(f"Additional options from '{' '.join(underlying_command)}':")
|
|
212
|
+
click.echo("=" * 60 + "\n")
|
|
213
|
+
subprocess.run([*underlying_command, "--help"])
|
|
214
|
+
|
|
215
|
+
ctx.exit(0)
|
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,105 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: afterpython
|
|
3
|
+
Version: 0.1.0.dev5
|
|
4
|
+
Summary: CLI tool for AfterPython
|
|
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: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
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: pygithub>=2.8.1
|
|
24
|
+
Requires-Dist: python-dotenv>=1.2.1
|
|
25
|
+
Requires-Dist: pyproject-metadata>=0.9.1
|
|
26
|
+
Requires-Dist: mystmd>=1.6.6
|
|
27
|
+
Requires-Dist: pdoc>=16.0.0
|
|
28
|
+
Requires-Dist: ruff>=0.14.5
|
|
29
|
+
Requires-Dist: pre-commit>=4.4.0
|
|
30
|
+
Requires-Dist: commitizen>=4.10.0
|
|
31
|
+
Requires-Python: >=3.11
|
|
32
|
+
Project-URL: documentation, https://ap.afterpython.org/doc
|
|
33
|
+
Project-URL: homepage, https://afterpython.org
|
|
34
|
+
Project-URL: repository, https://github.com/AfterPythonOrg/afterpython
|
|
35
|
+
Description-Content-Type: text/markdown
|
|
36
|
+
|
|
37
|
+
# AfterPython: Python Package Maintenance Toolkit and Project Website Generator
|
|
38
|
+
|
|
39
|
+
[](https://afterpython.org)
|
|
40
|
+
[](https://pixi.sh)
|
|
41
|
+

|
|
42
|
+
[](https://pypi.org/project/afterpython)
|
|
43
|
+

|
|
44
|
+
[](https://github.com/AfterPythonOrg/afterpython/discussions)
|
|
45
|
+
[](https://deepwiki.com/AfterPythonOrg/afterpython)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
[MyST]: https://mystmd.org
|
|
49
|
+
[MyST Markdown]: https://mystmd.org/spec/
|
|
50
|
+
[Jupyter Notebook]: https://jupyter.org
|
|
51
|
+
[pre-commit]: https://pre-commit.com
|
|
52
|
+
[pagefind]: https://pagefind.app
|
|
53
|
+
[SemVer]: https://semver.org
|
|
54
|
+
[pdoc]: https://pdoc.dev/
|
|
55
|
+
[GitHub Actions]: https://github.com/features/actions
|
|
56
|
+
[PyTorch]: https://pytorch.org
|
|
57
|
+
[Scikit-Learn]: https://scikit-learn.org
|
|
58
|
+
[WebLLM]: https://webllm.mlc.ai/
|
|
59
|
+
[project-website-template]: https://github.com/AfterPythonOrg/project-website-template
|
|
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
|
+
- [ ] Export content as PDF — for example, combine all blog posts into a single PDF file
|
|
81
|
+
- [ ] **⚡ Full-text search** across **ALL** your content in your website — docs, blogs, tutorials, everything
|
|
82
|
+
- [ ] **🤖 Embedded AI Chatbot** that answers questions directly using an in-browser LLM — at no cost
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
## Installation
|
|
86
|
+
```bash
|
|
87
|
+
# install afterpython as a dev dependency
|
|
88
|
+
uv add --dev afterpython
|
|
89
|
+
# initialize afterpython
|
|
90
|
+
ap init
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
## Tech Stack
|
|
95
|
+
- [MyST]
|
|
96
|
+
- [project-website-template]
|
|
97
|
+
- [pre-commit]
|
|
98
|
+
- [GitHub Actions]
|
|
99
|
+
- [pdoc]
|
|
100
|
+
- [uv]
|
|
101
|
+
- [ruff]
|
|
102
|
+
- [pagefind]
|
|
103
|
+
- [WebLLM]
|
|
104
|
+
<!-- - ty -->
|
|
105
|
+
<!-- - [pixi] -->
|