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,128 @@
|
|
|
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 tomlkit.toml_document import TOMLDocument
|
|
9
|
+
|
|
10
|
+
from afterpython._typing import tContentType
|
|
11
|
+
|
|
12
|
+
import tomlkit
|
|
13
|
+
|
|
14
|
+
import afterpython as ap
|
|
15
|
+
from afterpython._io.toml import _to_tomlkit, read_toml, write_toml
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def get_default_thumbnail(
|
|
19
|
+
content_type: tContentType, file_path: Path | None = None
|
|
20
|
+
) -> str:
|
|
21
|
+
"""Get the default thumbnail value for a content type.
|
|
22
|
+
|
|
23
|
+
Reads default thumbnail configuration from afterpython.toml:
|
|
24
|
+
- Content type specific thumbnail: [website.{content_type}.thumbnail]
|
|
25
|
+
- Website-wide default thumbnail: [website.thumbnail]
|
|
26
|
+
- Falls back to empty string if no default is configured
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
content_type: The content type (blog, tutorial, etc.)
|
|
30
|
+
file_path: Optional path to the file needing the thumbnail (for calculating relative path)
|
|
31
|
+
|
|
32
|
+
Returns:
|
|
33
|
+
The thumbnail value to use (e.g., "./static/image.png", "../static/image.png", or "")
|
|
34
|
+
"""
|
|
35
|
+
from afterpython._io.toml import _from_tomlkit
|
|
36
|
+
from afterpython.utils import get_relative_static_prefix, normalize_static_path
|
|
37
|
+
|
|
38
|
+
afterpython = read_afterpython()
|
|
39
|
+
try:
|
|
40
|
+
# website_default_thumbnail = normalize_static_path(
|
|
41
|
+
# str(_from_tomlkit(afterpython.get("website", {})).get("thumbnail", ""))
|
|
42
|
+
# )
|
|
43
|
+
content_type_default_thumbnail = normalize_static_path(
|
|
44
|
+
str(
|
|
45
|
+
_from_tomlkit(afterpython.get("website", {}))
|
|
46
|
+
.get(content_type, {})
|
|
47
|
+
.get("thumbnail", "")
|
|
48
|
+
)
|
|
49
|
+
)
|
|
50
|
+
except ValueError as e:
|
|
51
|
+
print(f"Error in reading afterpython.toml for {content_type=}: {e}")
|
|
52
|
+
return "null"
|
|
53
|
+
|
|
54
|
+
# Determine the static prefix based on file location
|
|
55
|
+
if file_path is not None:
|
|
56
|
+
content_type_path = ap.paths.afterpython_path / content_type.lower()
|
|
57
|
+
static_prefix = get_relative_static_prefix(file_path, content_type_path)
|
|
58
|
+
else:
|
|
59
|
+
static_prefix = "./" # Default fallback
|
|
60
|
+
|
|
61
|
+
if content_type_default_thumbnail:
|
|
62
|
+
return f"{static_prefix}static{content_type_default_thumbnail}"
|
|
63
|
+
# use "thumbnail" set in afterpython.toml [website]
|
|
64
|
+
# elif website_default_thumbnail:
|
|
65
|
+
# Website-wide static is one level up from content_type folder
|
|
66
|
+
# return f"{static_prefix}../static{website_default_thumbnail}"
|
|
67
|
+
else:
|
|
68
|
+
return ""
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def read_afterpython() -> TOMLDocument:
|
|
72
|
+
"""Read afterpython.toml"""
|
|
73
|
+
return read_toml(ap.paths.afterpython_path / "afterpython.toml")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def update_afterpython(data_update: dict):
|
|
77
|
+
"""Update afterpython.toml
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
data_update: dict of data to update
|
|
81
|
+
"""
|
|
82
|
+
from afterpython.utils import deep_merge
|
|
83
|
+
|
|
84
|
+
afterpython_toml_path = ap.paths.afterpython_path / "afterpython.toml"
|
|
85
|
+
|
|
86
|
+
# read existing data
|
|
87
|
+
if not afterpython_toml_path.exists():
|
|
88
|
+
afterpython_toml_path.touch()
|
|
89
|
+
existing_data = tomlkit.document()
|
|
90
|
+
else:
|
|
91
|
+
with open(afterpython_toml_path, "rb") as f:
|
|
92
|
+
existing_data = tomlkit.parse(f.read())
|
|
93
|
+
if existing_data is None:
|
|
94
|
+
existing_data = tomlkit.document()
|
|
95
|
+
|
|
96
|
+
# convert and update existing data
|
|
97
|
+
# Convert to tomlkit objects to use "array of inline tables" format
|
|
98
|
+
# e.g. authors = [{name = "..."}] instead of [[docs.authors]] (array of tables)
|
|
99
|
+
converted_data = _to_tomlkit(data_update)
|
|
100
|
+
|
|
101
|
+
existing_data = deep_merge(existing_data, converted_data)
|
|
102
|
+
|
|
103
|
+
# write updated data
|
|
104
|
+
write_toml(afterpython_toml_path, existing_data)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def init_afterpython():
|
|
108
|
+
"""Initialize afterpython.toml"""
|
|
109
|
+
afterpython_toml_path = ap.paths.afterpython_path / "afterpython.toml"
|
|
110
|
+
if afterpython_toml_path.exists():
|
|
111
|
+
print(f"afterpython.toml already exists at {afterpython_toml_path}")
|
|
112
|
+
return
|
|
113
|
+
afterpython_toml_path.touch()
|
|
114
|
+
print(f"Created {afterpython_toml_path}")
|
|
115
|
+
default_data = {
|
|
116
|
+
"company": {
|
|
117
|
+
"name": "",
|
|
118
|
+
"url": "",
|
|
119
|
+
},
|
|
120
|
+
"website": {
|
|
121
|
+
"url": "",
|
|
122
|
+
"favicon": "favicon.svg",
|
|
123
|
+
"logo": "logo.svg",
|
|
124
|
+
"logo_dark": "logo.svg",
|
|
125
|
+
"thumbnail": "thumbnail.png",
|
|
126
|
+
},
|
|
127
|
+
}
|
|
128
|
+
update_afterpython(default_data)
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# VIBE-CODED
|
|
2
|
+
import re
|
|
3
|
+
import subprocess
|
|
4
|
+
|
|
5
|
+
from git import Repo
|
|
6
|
+
|
|
7
|
+
from afterpython.utils import has_gh
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def get_git_user_config() -> dict | None:
|
|
11
|
+
"""Get Git user configuration (name and email)."""
|
|
12
|
+
try:
|
|
13
|
+
# Get the repo
|
|
14
|
+
repo = Repo(search_parent_directories=True)
|
|
15
|
+
|
|
16
|
+
# Access git config
|
|
17
|
+
reader = repo.config_reader()
|
|
18
|
+
|
|
19
|
+
# Get user name and email
|
|
20
|
+
name = reader.get_value("user", "name", default=None)
|
|
21
|
+
email = reader.get_value("user", "email", default=None)
|
|
22
|
+
|
|
23
|
+
if not name or not email:
|
|
24
|
+
return None
|
|
25
|
+
|
|
26
|
+
return {"name": name, "email": email}
|
|
27
|
+
except Exception:
|
|
28
|
+
return None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def get_github_url() -> str | None:
|
|
32
|
+
"""Get GitHub repository URL from git remote origin."""
|
|
33
|
+
try:
|
|
34
|
+
# Get the repo
|
|
35
|
+
repo = Repo(search_parent_directories=True)
|
|
36
|
+
|
|
37
|
+
# Get origin remote URL
|
|
38
|
+
if "origin" not in repo.remotes:
|
|
39
|
+
return None
|
|
40
|
+
|
|
41
|
+
remote_url = repo.remotes.origin.url
|
|
42
|
+
# Verify it's a GitHub URL
|
|
43
|
+
if "github.com" not in remote_url:
|
|
44
|
+
return None
|
|
45
|
+
|
|
46
|
+
# Convert SSH format to HTTPS format
|
|
47
|
+
# git@github.com:user/repo.git -> https://github.com/user/repo
|
|
48
|
+
if remote_url.startswith("git@github.com:"):
|
|
49
|
+
remote_url = remote_url.replace("git@github.com:", "https://github.com/")
|
|
50
|
+
|
|
51
|
+
# Remove .git suffix if present
|
|
52
|
+
remote_url = re.sub(r"\.git$", "", remote_url)
|
|
53
|
+
|
|
54
|
+
return remote_url
|
|
55
|
+
|
|
56
|
+
except (ImportError, Exception):
|
|
57
|
+
# GitPython not installed or not in a git repo
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def is_gh_authenticated():
|
|
62
|
+
"""Guide user through GitHub authentication."""
|
|
63
|
+
if not has_gh():
|
|
64
|
+
print("""
|
|
65
|
+
╭─────────────────────────────────────────╮
|
|
66
|
+
│ GitHub CLI Required │
|
|
67
|
+
╰─────────────────────────────────────────╯
|
|
68
|
+
|
|
69
|
+
Install it:
|
|
70
|
+
• macOS: brew install gh
|
|
71
|
+
• Linux: https://github.com/cli/cli/releases
|
|
72
|
+
• Windows: https://cli.github.com/
|
|
73
|
+
|
|
74
|
+
""")
|
|
75
|
+
return False
|
|
76
|
+
|
|
77
|
+
result = subprocess.run(["gh", "auth", "status"], capture_output=True, check=False)
|
|
78
|
+
|
|
79
|
+
if result.returncode != 0:
|
|
80
|
+
print("""
|
|
81
|
+
╭─────────────────────────────────────────╮
|
|
82
|
+
│ GitHub Authentication Required │
|
|
83
|
+
╰─────────────────────────────────────────╯
|
|
84
|
+
|
|
85
|
+
Please authenticate with GitHub:
|
|
86
|
+
|
|
87
|
+
gh auth login
|
|
88
|
+
|
|
89
|
+
""")
|
|
90
|
+
return False
|
|
91
|
+
|
|
92
|
+
return True
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def get_github_username() -> str | None:
|
|
96
|
+
"""Get authenticated username via gh CLI."""
|
|
97
|
+
if not has_gh():
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
result = subprocess.run(
|
|
101
|
+
["gh", "api", "user", "--jq", ".login"],
|
|
102
|
+
capture_output=True,
|
|
103
|
+
text=True,
|
|
104
|
+
check=True,
|
|
105
|
+
)
|
|
106
|
+
return result.stdout.strip()
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def get_current_user_id() -> int | None:
|
|
110
|
+
"""
|
|
111
|
+
Get the GitHub user ID of the currently authenticated user.
|
|
112
|
+
|
|
113
|
+
Returns:
|
|
114
|
+
User ID or None if error
|
|
115
|
+
"""
|
|
116
|
+
if not is_gh_authenticated():
|
|
117
|
+
return None
|
|
118
|
+
|
|
119
|
+
result = subprocess.run(
|
|
120
|
+
["gh", "api", "user", "--jq", ".id"],
|
|
121
|
+
capture_output=True,
|
|
122
|
+
text=True,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
if result.returncode != 0:
|
|
126
|
+
print(f"Error fetching user ID: {result.stderr}")
|
|
127
|
+
return None
|
|
128
|
+
|
|
129
|
+
try:
|
|
130
|
+
return int(result.stdout.strip())
|
|
131
|
+
except ValueError:
|
|
132
|
+
print("Error: Failed to parse user ID")
|
|
133
|
+
return None
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
# TODO: use gh's token for pygithub to get repo issues
|
|
137
|
+
# def get_repo_issues(owner: str, repo: str) -> list[dict]:
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Branch protection rules for AfterPython projects.
|
|
3
|
+
Uses gh CLI to create GitHub rulesets.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import subprocess
|
|
8
|
+
from copy import deepcopy
|
|
9
|
+
|
|
10
|
+
from afterpython.tools._git import is_gh_authenticated
|
|
11
|
+
|
|
12
|
+
DEFAULT_RULESET = {
|
|
13
|
+
"name": "afterpython-default", # The ruleset identifier in GitHub
|
|
14
|
+
"target": "branch", # This ruleset applies to branches (not tags)
|
|
15
|
+
"enforcement": "active", # Rules are enforced (vs "disabled" or "evaluate")
|
|
16
|
+
"conditions": { # Which branches this ruleset applies to
|
|
17
|
+
"ref_name": {
|
|
18
|
+
"include": ["refs/heads/main"], # Only protect the 'main' branch
|
|
19
|
+
"exclude": [], # No exclusions
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
"bypass_actors": [
|
|
23
|
+
{
|
|
24
|
+
"actor_id": 5, # Repository admin role
|
|
25
|
+
"actor_type": "RepositoryRole",
|
|
26
|
+
"bypass_mode": "always",
|
|
27
|
+
}
|
|
28
|
+
],
|
|
29
|
+
"rules": [ # Array of protection rules
|
|
30
|
+
# Rule 1: No Force Pushes
|
|
31
|
+
{"type": "non_fast_forward"}, # Prevents git push --force
|
|
32
|
+
# Rule 2: No Branch Deletion
|
|
33
|
+
{"type": "deletion"}, # Prevents branch from being deleted
|
|
34
|
+
# Rule 3: CI Status Checks
|
|
35
|
+
{
|
|
36
|
+
"type": "required_status_checks",
|
|
37
|
+
"parameters": {
|
|
38
|
+
"required_status_checks": [
|
|
39
|
+
{"context": "lint"},
|
|
40
|
+
{"context": "test"},
|
|
41
|
+
{"context": "build"},
|
|
42
|
+
],
|
|
43
|
+
# The PR branch must be up to date with the base branch (main) before merging
|
|
44
|
+
"strict_required_status_checks_policy": True,
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
],
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def list_rulesets() -> list[dict] | None:
|
|
52
|
+
"""
|
|
53
|
+
List existing rulesets for the current repository.
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
List of rulesets or None if error
|
|
57
|
+
"""
|
|
58
|
+
if not is_gh_authenticated():
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
result = subprocess.run(
|
|
62
|
+
["gh", "api", "repos/{owner}/{repo}/rulesets"],
|
|
63
|
+
capture_output=True,
|
|
64
|
+
text=True,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
if result.returncode != 0:
|
|
68
|
+
print(f"Error listing rulesets: {result.stderr}")
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
try:
|
|
72
|
+
return json.loads(result.stdout)
|
|
73
|
+
except json.JSONDecodeError:
|
|
74
|
+
print("Error: Failed to parse rulesets JSON")
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def create_default_branch_rules(branch: str = "main") -> bool:
|
|
79
|
+
if not is_gh_authenticated():
|
|
80
|
+
return False
|
|
81
|
+
|
|
82
|
+
# Check if ruleset already exists
|
|
83
|
+
existing_rulesets = list_rulesets()
|
|
84
|
+
if existing_rulesets:
|
|
85
|
+
print(f"Ruleset '{DEFAULT_RULESET['name']}' already exists.")
|
|
86
|
+
return False
|
|
87
|
+
|
|
88
|
+
# Build payload
|
|
89
|
+
payload = deepcopy(DEFAULT_RULESET)
|
|
90
|
+
if branch != "main":
|
|
91
|
+
payload["conditions"]["ref_name"]["include"] = [f"refs/heads/{branch}"]
|
|
92
|
+
|
|
93
|
+
# Create ruleset
|
|
94
|
+
result = subprocess.run(
|
|
95
|
+
["gh", "api", "-X", "POST", "repos/{owner}/{repo}/rulesets", "--input", "-"],
|
|
96
|
+
input=json.dumps(payload),
|
|
97
|
+
capture_output=True,
|
|
98
|
+
text=True,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
if result.returncode != 0:
|
|
102
|
+
print(f"Error creating ruleset: {result.stderr}")
|
|
103
|
+
return False
|
|
104
|
+
|
|
105
|
+
print(f"✓ Created branch ruleset '{DEFAULT_RULESET['name']}' for {branch=}")
|
|
106
|
+
return True
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import shutil
|
|
2
|
+
|
|
3
|
+
import afterpython as ap
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def init_commitizen():
|
|
7
|
+
commitizen_toml_path = ap.paths.afterpython_path / "cz.toml"
|
|
8
|
+
if commitizen_toml_path.exists():
|
|
9
|
+
print(f"Commitizen configuration file {commitizen_toml_path} already exists")
|
|
10
|
+
return
|
|
11
|
+
cz_template_path = ap.paths.templates_path / "cz-template.toml"
|
|
12
|
+
shutil.copy(cz_template_path, commitizen_toml_path)
|
|
13
|
+
print(f"Created {commitizen_toml_path}")
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import shutil
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
import click
|
|
5
|
+
|
|
6
|
+
import afterpython as ap
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _copy_github_template(template_name: str, target_path: Path):
|
|
10
|
+
"""Helper to copy GitHub-related templates"""
|
|
11
|
+
if target_path.exists():
|
|
12
|
+
click.echo(f"{target_path} already exists")
|
|
13
|
+
return
|
|
14
|
+
|
|
15
|
+
# Create parent directory if it doesn't exist
|
|
16
|
+
target_path.parent.mkdir(parents=True, exist_ok=True)
|
|
17
|
+
|
|
18
|
+
# Copy template from package
|
|
19
|
+
template_path = ap.paths.templates_path / template_name
|
|
20
|
+
if not template_path.exists():
|
|
21
|
+
raise FileNotFoundError(
|
|
22
|
+
f"Template file not found: {template_path}\n"
|
|
23
|
+
"This might indicate a corrupted installation. Please reinstall afterpython."
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
shutil.copy(template_path, target_path)
|
|
27
|
+
print(f"Created {target_path}")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def create_workflow(workflow_name: str):
|
|
31
|
+
"""Create a GitHub Actions workflow from template"""
|
|
32
|
+
if ".yml" in workflow_name:
|
|
33
|
+
workflow_name = workflow_name.replace(".yml", "")
|
|
34
|
+
|
|
35
|
+
user_path = ap.paths.user_path
|
|
36
|
+
workflow_path = user_path / ".github" / "workflows" / f"{workflow_name}.yml"
|
|
37
|
+
template_name = f"{workflow_name}-workflow-template.yml"
|
|
38
|
+
|
|
39
|
+
_copy_github_template(template_name, workflow_path)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def create_dependabot():
|
|
43
|
+
"""Create Dependabot configuration for GitHub Actions updates"""
|
|
44
|
+
user_path = ap.paths.user_path
|
|
45
|
+
dependabot_path = user_path / ".github" / "dependabot.yml"
|
|
46
|
+
template_name = "dependabot-template.yml"
|
|
47
|
+
|
|
48
|
+
_copy_github_template(template_name, dependabot_path)
|
|
@@ -0,0 +1,208 @@
|
|
|
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 afterpython._typing import NodeEnv, tContentType
|
|
9
|
+
|
|
10
|
+
import subprocess
|
|
11
|
+
|
|
12
|
+
import afterpython as ap
|
|
13
|
+
from afterpython._io.yaml import read_yaml, write_yaml
|
|
14
|
+
from afterpython.utils import deep_merge
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def update_authors_yml(data_update: dict):
|
|
18
|
+
"""Update authors.yml while preserving order and formatting"""
|
|
19
|
+
file_path = ap.paths.afterpython_path / "authors.yml"
|
|
20
|
+
|
|
21
|
+
if not file_path.exists():
|
|
22
|
+
write_yaml(file_path, data_update)
|
|
23
|
+
return
|
|
24
|
+
|
|
25
|
+
existing_data = read_yaml(file_path) or {}
|
|
26
|
+
|
|
27
|
+
# merge authors, keep the author with more fields (e.g. github, x, etc.) to avoid duplication
|
|
28
|
+
merged_authors = {}
|
|
29
|
+
for author in (
|
|
30
|
+
existing_data["project"]["contributors"]
|
|
31
|
+
+ data_update["project"]["contributors"]
|
|
32
|
+
):
|
|
33
|
+
author_id = author["id"]
|
|
34
|
+
if author_id not in merged_authors:
|
|
35
|
+
merged_authors[author_id] = author
|
|
36
|
+
else:
|
|
37
|
+
# keep the author with more fields (e.g. github, x, etc.)
|
|
38
|
+
if len(author.keys()) > len(merged_authors[author_id].keys()):
|
|
39
|
+
merged_authors[author_id] = author
|
|
40
|
+
existing_data["project"]["contributors"] = list(merged_authors.values())
|
|
41
|
+
|
|
42
|
+
# set comments for project section for convenience
|
|
43
|
+
if "project" in existing_data and not existing_data["project"].ca.items.get(
|
|
44
|
+
"contributors"
|
|
45
|
+
):
|
|
46
|
+
existing_data["project"].yaml_set_comment_before_after_key(
|
|
47
|
+
"contributors",
|
|
48
|
+
before="See more at: https://mystmd.org/guide/frontmatter#frontmatter-authors",
|
|
49
|
+
)
|
|
50
|
+
write_yaml(file_path, existing_data)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def update_myst_yml(data_update: dict, path: Path, add_comments: bool = False):
|
|
54
|
+
"""Update myst.yml while preserving order and formatting
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
data_update: dict of data to update
|
|
58
|
+
path: path to the myst.yml file, e.g. doc/, blog/, tutorial/, example/, guide/
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
file_path = path / "myst.yml"
|
|
62
|
+
|
|
63
|
+
if not file_path.exists():
|
|
64
|
+
raise FileNotFoundError(
|
|
65
|
+
f"myst.yml not found at {file_path}, did you forget to run `ap init` or `myst init`?"
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
existing_data = read_yaml(file_path) or {}
|
|
69
|
+
existing_data = deep_merge(existing_data, data_update, extend_lists=False)
|
|
70
|
+
|
|
71
|
+
# NOTE: this checking existing_data["key1"].ca.items.get("key2") is buggy
|
|
72
|
+
# only add_comments=True during init to avoid adding duplicate comments
|
|
73
|
+
if add_comments:
|
|
74
|
+
# set comments for project section for convenience
|
|
75
|
+
if "project" in existing_data:
|
|
76
|
+
if not existing_data["project"].ca.items.get("id"):
|
|
77
|
+
existing_data["project"].yaml_set_comment_before_after_key(
|
|
78
|
+
"id",
|
|
79
|
+
before="See how to create Table of Contents at: https://mystmd.org/guide/table-of-contents",
|
|
80
|
+
)
|
|
81
|
+
if not existing_data["project"].ca.items.get("authors"):
|
|
82
|
+
existing_data["project"].yaml_set_comment_before_after_key(
|
|
83
|
+
"authors",
|
|
84
|
+
before="See more authors' fields at: https://mystmd.org/guide/frontmatter#frontmatter-authors",
|
|
85
|
+
)
|
|
86
|
+
if not existing_data["project"].ca.items.get("venue"):
|
|
87
|
+
existing_data["project"].yaml_set_comment_before_after_key(
|
|
88
|
+
"venue",
|
|
89
|
+
before="See more venue's fields at: https://mystmd.org/guide/frontmatter#venue",
|
|
90
|
+
)
|
|
91
|
+
if "site" in existing_data:
|
|
92
|
+
if not existing_data["site"].ca.items.get("options"):
|
|
93
|
+
existing_data["site"].yaml_set_comment_before_after_key(
|
|
94
|
+
"options",
|
|
95
|
+
before="See options at: https://mystmd.org/guide/website-templates#site-options",
|
|
96
|
+
)
|
|
97
|
+
if not existing_data["site"].ca.items.get("actions"):
|
|
98
|
+
existing_data["site"].yaml_set_comment_before_after_key(
|
|
99
|
+
"actions",
|
|
100
|
+
before="See web layout at: https://mystmd.org/guide/website-navigation",
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
write_yaml(file_path, existing_data)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _write_index_file(content_type: tContentType):
|
|
107
|
+
"""Create a placeholder index.md file for MyST to prevent first file from becoming index.
|
|
108
|
+
|
|
109
|
+
MyST treats the first file in TOC as index. This placeholder ensures all actual
|
|
110
|
+
content files get proper slugs. The generated index.html is deleted post-build
|
|
111
|
+
since SvelteKit owns the landing page route.
|
|
112
|
+
|
|
113
|
+
Note: This only creates the file. TOC modification is handled by the build process.
|
|
114
|
+
|
|
115
|
+
Raises:
|
|
116
|
+
FileExistsError: If index.md already exists (users shouldn't create this file).
|
|
117
|
+
"""
|
|
118
|
+
if content_type == "doc":
|
|
119
|
+
return # Doc doesn't need a placeholder index.md
|
|
120
|
+
|
|
121
|
+
content_path = ap.paths.afterpython_path / content_type
|
|
122
|
+
index_file = content_path / "index.md"
|
|
123
|
+
|
|
124
|
+
# Check if user created an index.md file
|
|
125
|
+
if index_file.exists():
|
|
126
|
+
raise FileExistsError(
|
|
127
|
+
f"\n"
|
|
128
|
+
f"Found existing 'index.md' in afterpython/{content_type}/\n"
|
|
129
|
+
f"\n"
|
|
130
|
+
f"The 'index.md' file is reserved for internal use by AfterPython.\n"
|
|
131
|
+
f"MyST treats the first file in TOC as index, which would conflict with\n"
|
|
132
|
+
f"SvelteKit (project-website-template)'s landing page route (/{content_type}).\n"
|
|
133
|
+
f"\n"
|
|
134
|
+
f"Please rename your file to something else (e.g., '{content_type}_intro.md')\n"
|
|
135
|
+
f"and update the reference in afterpython/{content_type}/myst.yml"
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
index_content = f"""---
|
|
139
|
+
title: ← {content_type.capitalize()}
|
|
140
|
+
---
|
|
141
|
+
|
|
142
|
+
This is a placeholder index page. The actual {content_type} landing page is rendered by SvelteKit.
|
|
143
|
+
"""
|
|
144
|
+
index_file.write_text(index_content)
|
|
145
|
+
return index_file
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _write_welcome_file(content_type: tContentType):
|
|
149
|
+
welcome_file = ap.paths.afterpython_path / content_type / "index.md"
|
|
150
|
+
if welcome_file.exists():
|
|
151
|
+
return
|
|
152
|
+
welcome_content = f"""# Welcome to AfterPython
|
|
153
|
+
|
|
154
|
+
Welcome to your project's {content_type}! This is a starter page to help you get started.
|
|
155
|
+
|
|
156
|
+
## Getting Started
|
|
157
|
+
|
|
158
|
+
Replace this placeholder content with your own. Here's what you can do:
|
|
159
|
+
|
|
160
|
+
- Creating new `.md` or `.ipynb` files in the `afterpython/{content_type}/` directory
|
|
161
|
+
- Writing in MyST Markdown format
|
|
162
|
+
- Adding images to the `afterpython/static/` directory and referencing them
|
|
163
|
+
|
|
164
|
+
## Resources
|
|
165
|
+
|
|
166
|
+
- [AfterPython's Project Website](https://afterpython.afterpython.org)
|
|
167
|
+
- [MyST Markdown Guide](https://mystmd.org)
|
|
168
|
+
|
|
169
|
+
Start building your amazing project! 🚀
|
|
170
|
+
"""
|
|
171
|
+
welcome_file.write_text(welcome_content)
|
|
172
|
+
return welcome_file
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def init_myst():
|
|
176
|
+
"""
|
|
177
|
+
Initialize MyST Markdown (mystmd) and myst.yml files in
|
|
178
|
+
doc/, blog/, tutorial/, example/, guide/ directories with sensible defaults
|
|
179
|
+
"""
|
|
180
|
+
from afterpython.const import CONTENT_TYPES
|
|
181
|
+
from afterpython.utils import find_node_env
|
|
182
|
+
|
|
183
|
+
# find any existing node.js version and use it, if no, install the Node.js version specified in NODEENV_VERSION
|
|
184
|
+
node_env: NodeEnv = find_node_env()
|
|
185
|
+
subprocess.run(["npm", "install", "-g", "pnpm"], env=node_env, check=True)
|
|
186
|
+
for content_type in CONTENT_TYPES:
|
|
187
|
+
path = ap.paths.afterpython_path / content_type
|
|
188
|
+
print(f"Initializing MyST Markdown (mystmd) in {path.name}/ directory ...")
|
|
189
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
190
|
+
subprocess.run(["myst", "init"], cwd=path, input="n\n", text=True, env=node_env)
|
|
191
|
+
myst_yml_defaults = {
|
|
192
|
+
"extends": "../authors.yml",
|
|
193
|
+
"project": {
|
|
194
|
+
"license": "CC-BY-4.0",
|
|
195
|
+
"subject": content_type.capitalize()
|
|
196
|
+
if content_type != "doc"
|
|
197
|
+
else "Documentation",
|
|
198
|
+
},
|
|
199
|
+
"site": {
|
|
200
|
+
"options": {
|
|
201
|
+
"analytics_google": "{{ GOOGLE_ANALYTICS_ID }}",
|
|
202
|
+
# "twitter": "",
|
|
203
|
+
},
|
|
204
|
+
},
|
|
205
|
+
}
|
|
206
|
+
update_myst_yml(myst_yml_defaults, path, add_comments=True)
|
|
207
|
+
_write_welcome_file(content_type)
|
|
208
|
+
subprocess.run(["ap", "sync"])
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import shutil
|
|
2
|
+
import subprocess
|
|
3
|
+
|
|
4
|
+
import afterpython as ap
|
|
5
|
+
from afterpython._io.yaml import read_yaml, write_yaml
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def install_pre_commit():
|
|
9
|
+
# installed in .git/hooks
|
|
10
|
+
subprocess.run(["ap", "pre-commit", "install", "--install-hooks"], check=True)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def update_pre_commit(data_update: dict):
|
|
14
|
+
from afterpython.utils import deep_merge
|
|
15
|
+
|
|
16
|
+
pre_commit_path = ap.paths.afterpython_path / ".pre-commit-config.yaml"
|
|
17
|
+
if not pre_commit_path.exists():
|
|
18
|
+
raise FileNotFoundError(
|
|
19
|
+
f".pre-commit-config.yaml not found at {pre_commit_path}"
|
|
20
|
+
)
|
|
21
|
+
existing_data = read_yaml(pre_commit_path)
|
|
22
|
+
existing_data = deep_merge(existing_data, data_update)
|
|
23
|
+
write_yaml(pre_commit_path, existing_data)
|
|
24
|
+
install_pre_commit()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def init_pre_commit():
|
|
28
|
+
pre_commit_path = ap.paths.afterpython_path / ".pre-commit-config.yaml"
|
|
29
|
+
if pre_commit_path.exists():
|
|
30
|
+
print(f".pre-commit-config.yaml already exists at {pre_commit_path}")
|
|
31
|
+
return
|
|
32
|
+
pre_commit_template_path = (
|
|
33
|
+
ap.paths.templates_path / "pre-commit-config-template.yaml"
|
|
34
|
+
)
|
|
35
|
+
shutil.copy(pre_commit_template_path, pre_commit_path)
|
|
36
|
+
print(f"Created {pre_commit_path}")
|
|
37
|
+
install_pre_commit()
|