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.
Files changed (66) hide show
  1. afterpython/__init__.py +13 -0
  2. afterpython/_io/toml.py +105 -0
  3. afterpython/_io/yaml.py +26 -0
  4. afterpython/_paths.py +39 -0
  5. afterpython/_typing.py +4 -0
  6. afterpython/builders/__init__.py +17 -0
  7. afterpython/builders/content_json.py +131 -0
  8. afterpython/builders/index_md.py +94 -0
  9. afterpython/builders/jupyter_notebook.py +293 -0
  10. afterpython/builders/markdown.py +271 -0
  11. afterpython/builders/metadata.py +50 -0
  12. afterpython/cli/__init__.py +3 -0
  13. afterpython/cli/commands/add.py +67 -0
  14. afterpython/cli/commands/build.py +260 -0
  15. afterpython/cli/commands/bump.py +118 -0
  16. afterpython/cli/commands/check.py +36 -0
  17. afterpython/cli/commands/clean.py +85 -0
  18. afterpython/cli/commands/commit.py +52 -0
  19. afterpython/cli/commands/commitizen.py +32 -0
  20. afterpython/cli/commands/dev.py +213 -0
  21. afterpython/cli/commands/format.py +28 -0
  22. afterpython/cli/commands/init.py +107 -0
  23. afterpython/cli/commands/init_branch_rules.py +9 -0
  24. afterpython/cli/commands/install.py +28 -0
  25. afterpython/cli/commands/lock.py +23 -0
  26. afterpython/cli/commands/pre_commit.py +66 -0
  27. afterpython/cli/commands/preview.py +22 -0
  28. afterpython/cli/commands/release.py +67 -0
  29. afterpython/cli/commands/remove.py +67 -0
  30. afterpython/cli/commands/start.py +144 -0
  31. afterpython/cli/commands/sync.py +152 -0
  32. afterpython/cli/commands/update.py +176 -0
  33. afterpython/cli/main.py +132 -0
  34. afterpython/const.py +4 -0
  35. afterpython/main.py +25 -0
  36. afterpython/pcu.py +166 -0
  37. afterpython/templates/ci-workflow-template.yml +197 -0
  38. afterpython/templates/cz-template.toml +7 -0
  39. afterpython/templates/dependabot-template.yml +30 -0
  40. afterpython/templates/deploy-workflow-template.yml +88 -0
  41. afterpython/templates/pre-commit-config-template.yaml +48 -0
  42. afterpython/templates/release-workflow-template.yml +56 -0
  43. afterpython/templates/ruff-template.toml +32 -0
  44. afterpython/tools/_afterpython.py +128 -0
  45. afterpython/tools/_git.py +137 -0
  46. afterpython/tools/branch_rules.py +106 -0
  47. afterpython/tools/commitizen.py +13 -0
  48. afterpython/tools/github_actions.py +48 -0
  49. afterpython/tools/myst.py +208 -0
  50. afterpython/tools/pre_commit.py +37 -0
  51. afterpython/tools/pyproject.py +110 -0
  52. afterpython/utils.py +299 -0
  53. afterpython/version.py +39 -0
  54. afterpython-0.1.0.dist-info/METADATA +131 -0
  55. afterpython-0.1.0.dist-info/RECORD +57 -0
  56. afterpython-0.1.0.dist-info/WHEEL +4 -0
  57. afterpython-0.1.0.dist-info/entry_points.txt +4 -0
  58. afterpython/README.py +0 -158
  59. afterpython/images/favicon.ico +0 -0
  60. afterpython/images/logo-trace.svg +0 -3
  61. afterpython/images/logo.png +0 -0
  62. afterpython/images/logo.svg +0 -2
  63. afterpython-0.0.1.dev2.dist-info/LICENSE +0 -201
  64. afterpython-0.0.1.dev2.dist-info/METADATA +0 -20
  65. afterpython-0.0.1.dev2.dist-info/RECORD +0 -9
  66. 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()