afterpython 0.1.0__py3-none-any.whl → 0.1.0.dev6__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 (55) hide show
  1. afterpython/{templates/pre-commit-config-template.yaml → .pre-commit-config.yaml} +1 -1
  2. afterpython/_io/toml.py +3 -5
  3. afterpython/_paths.py +1 -1
  4. afterpython/_typing.py +2 -1
  5. afterpython/builders/__init__.py +3 -12
  6. afterpython/builders/metadata.py +3 -7
  7. afterpython/builders/molab.py +99 -0
  8. afterpython/cli/__init__.py +1 -0
  9. afterpython/cli/commands/build.py +68 -97
  10. afterpython/cli/commands/bump.py +0 -30
  11. afterpython/cli/commands/check.py +1 -1
  12. afterpython/cli/commands/clean.py +3 -4
  13. afterpython/cli/commands/commit.py +3 -21
  14. afterpython/cli/commands/commitizen.py +1 -1
  15. afterpython/cli/commands/dev.py +9 -13
  16. afterpython/cli/commands/format.py +1 -1
  17. afterpython/cli/commands/init.py +39 -48
  18. afterpython/cli/commands/install.py +2 -8
  19. afterpython/cli/commands/pre_commit.py +1 -1
  20. afterpython/cli/commands/preview.py +1 -1
  21. afterpython/cli/commands/start.py +4 -7
  22. afterpython/cli/commands/sync.py +21 -53
  23. afterpython/cli/commands/update.py +8 -31
  24. afterpython/cli/main.py +14 -75
  25. afterpython/const.py +1 -0
  26. afterpython/cz.toml +27 -0
  27. afterpython/pcu.py +1 -3
  28. afterpython/templates/deploy-workflow-template.yml +5 -5
  29. afterpython/templates/release-workflow-template.yml +3 -3
  30. afterpython/templates/ruff-template.toml +1 -1
  31. afterpython/tools/_afterpython.py +10 -73
  32. afterpython/tools/_git.py +2 -28
  33. afterpython/tools/commitizen.py +45 -5
  34. afterpython/tools/github_actions.py +53 -23
  35. afterpython/tools/myst.py +5 -46
  36. afterpython/tools/pre_commit.py +31 -6
  37. afterpython/tools/pyproject.py +2 -40
  38. afterpython/utils.py +0 -82
  39. {afterpython-0.1.0.dist-info → afterpython-0.1.0.dev6.dist-info}/METADATA +10 -34
  40. afterpython-0.1.0.dev6.dist-info/RECORD +47 -0
  41. {afterpython-0.1.0.dist-info → afterpython-0.1.0.dev6.dist-info}/WHEEL +1 -1
  42. afterpython/builders/content_json.py +0 -131
  43. afterpython/builders/index_md.py +0 -94
  44. afterpython/builders/jupyter_notebook.py +0 -293
  45. afterpython/builders/markdown.py +0 -271
  46. afterpython/cli/commands/add.py +0 -67
  47. afterpython/cli/commands/init_branch_rules.py +0 -9
  48. afterpython/cli/commands/lock.py +0 -23
  49. afterpython/cli/commands/remove.py +0 -67
  50. afterpython/templates/ci-workflow-template.yml +0 -197
  51. afterpython/templates/cz-template.toml +0 -7
  52. afterpython/templates/dependabot-template.yml +0 -30
  53. afterpython/tools/branch_rules.py +0 -106
  54. afterpython-0.1.0.dist-info/RECORD +0 -57
  55. {afterpython-0.1.0.dist-info → afterpython-0.1.0.dev6.dist-info}/entry_points.txt +0 -0
@@ -18,7 +18,7 @@ ignore = [
18
18
  ]
19
19
  # extended rules:
20
20
  extend-select = [
21
- "I", # isort - import sorting
21
+ # "I", # isort - import sorting
22
22
  "UP", # pyupgrade - upgrade syntax for newer Python
23
23
  "B", # flake8-bugbear - find likely bugs
24
24
  "SIM", # flake8-simplify - simplify code
@@ -1,71 +1,13 @@
1
1
  from __future__ import annotations
2
-
3
2
  from typing import TYPE_CHECKING
4
3
 
5
4
  if TYPE_CHECKING:
6
- from pathlib import Path
7
-
8
5
  from tomlkit.toml_document import TOMLDocument
9
6
 
10
- from afterpython._typing import tContentType
11
-
12
7
  import tomlkit
13
8
 
14
9
  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 ""
10
+ from afterpython._io.toml import read_toml, write_toml, _to_tomlkit
69
11
 
70
12
 
71
13
  def read_afterpython() -> TOMLDocument:
@@ -112,17 +54,12 @@ def init_afterpython():
112
54
  return
113
55
  afterpython_toml_path.touch()
114
56
  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)
57
+ update_afterpython(
58
+ {
59
+ "company": {
60
+ "name": "",
61
+ "url": "",
62
+ },
63
+ "website": {"url": ""},
64
+ }
65
+ )
afterpython/tools/_git.py CHANGED
@@ -58,7 +58,7 @@ def get_github_url() -> str | None:
58
58
  return None
59
59
 
60
60
 
61
- def is_gh_authenticated():
61
+ def setup_github_auth():
62
62
  """Guide user through GitHub authentication."""
63
63
  if not has_gh():
64
64
  print("""
@@ -89,6 +89,7 @@ Please authenticate with GitHub:
89
89
  """)
90
90
  return False
91
91
 
92
+ print("✓ GitHub authentication verified")
92
93
  return True
93
94
 
94
95
 
@@ -106,32 +107,5 @@ def get_github_username() -> str | None:
106
107
  return result.stdout.strip()
107
108
 
108
109
 
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
110
  # TODO: use gh's token for pygithub to get repo issues
137
111
  # def get_repo_issues(owner: str, repo: str) -> list[dict]:
@@ -1,13 +1,53 @@
1
- import shutil
2
-
3
1
  import afterpython as ap
4
2
 
5
3
 
4
+ commitizen_default = {
5
+ "tool": {
6
+ "commitizen": {
7
+ "name": "cz_conventional_commits",
8
+ "tag_format": "v$version",
9
+ "version_scheme": "pep440",
10
+ "version_provider": "uv",
11
+ "update_changelog_on_bump": False,
12
+ "major_version_zero": True,
13
+ }
14
+ }
15
+ }
16
+
17
+
18
+ commitizen_pre_commit_hook = {
19
+ "repos": [
20
+ {
21
+ "repo": "https://github.com/commitizen-tools/commitizen",
22
+ "rev": "v4.10.0",
23
+ "hooks": [
24
+ {
25
+ "id": "commitizen",
26
+ "stages": ["commit-msg"],
27
+ },
28
+ {
29
+ "id": "commitizen-branch",
30
+ "stages": ["pre-push"],
31
+ },
32
+ ],
33
+ },
34
+ ]
35
+ }
36
+
37
+
6
38
  def init_commitizen():
7
- commitizen_toml_path = ap.paths.afterpython_path / "cz.toml"
39
+ from afterpython.tools.pre_commit import update_pre_commit
40
+ from afterpython._io.toml import write_toml
41
+
42
+ afterpython_path = ap.paths.afterpython_path
43
+ commitizen_toml_path = afterpython_path / "cz.toml"
8
44
  if commitizen_toml_path.exists():
9
45
  print(f"Commitizen configuration file {commitizen_toml_path} already exists")
10
46
  return
11
- cz_template_path = ap.paths.templates_path / "cz-template.toml"
12
- shutil.copy(cz_template_path, commitizen_toml_path)
47
+
48
+ # Create commitizen configuration with default values
49
+ write_toml(commitizen_toml_path, commitizen_default)
13
50
  print(f"Created {commitizen_toml_path}")
51
+
52
+ # add commitizen hook to .pre-commit-config.yaml
53
+ update_pre_commit(commitizen_pre_commit_hook)
@@ -1,48 +1,78 @@
1
1
  import shutil
2
- from pathlib import Path
3
2
 
4
3
  import click
5
4
 
6
5
  import afterpython as ap
7
6
 
8
7
 
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")
8
+ def init_release_workflow():
9
+ """Initialize GitHub Actions release workflow.
10
+
11
+ Creates .github/workflows/release.yml in the user's project root.
12
+ This workflow triggers on version tags and:
13
+ - Builds the package with uv
14
+ - Publishes to PyPI using trusted publishing
15
+ - Creates GitHub releases (with pre-release detection)
16
+ """
17
+ user_path = ap.paths.user_path
18
+ workflow_dir = user_path / ".github" / "workflows"
19
+ workflow_path = workflow_dir / "release.yml"
20
+
21
+ if workflow_path.exists():
22
+ click.echo(f"GitHub Actions release workflow {workflow_path} already exists")
13
23
  return
14
24
 
15
- # Create parent directory if it doesn't exist
16
- target_path.parent.mkdir(parents=True, exist_ok=True)
25
+ # Create .github/workflows directory if it doesn't exist
26
+ workflow_dir.mkdir(parents=True, exist_ok=True)
17
27
 
18
28
  # Copy template from package
19
- template_path = ap.paths.templates_path / template_name
29
+ template_path = ap.paths.templates_path / "release-workflow-template.yml"
20
30
  if not template_path.exists():
21
31
  raise FileNotFoundError(
22
32
  f"Template file not found: {template_path}\n"
23
33
  "This might indicate a corrupted installation. Please reinstall afterpython."
24
34
  )
25
35
 
26
- shutil.copy(template_path, target_path)
27
- print(f"Created {target_path}")
36
+ shutil.copy(template_path, workflow_path)
37
+ click.echo(f"Created {workflow_path}")
38
+ click.echo(
39
+ "\n📋 Next steps to enable releases:\n"
40
+ " 1. Enable PyPI trusted publishing:\n"
41
+ " - Go to https://pypi.org/manage/account/publishing/\n"
42
+ " - Add your repository and workflow file: release.yml\n"
43
+ " 2. Push tags to trigger releases:\n"
44
+ " - Use 'ap bump --release' for stable releases (auto-pushes tag)\n"
45
+ " - Use 'ap bump --pre' then 'ap release' for pre-releases\n"
46
+ )
28
47
 
29
48
 
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", "")
49
+ def init_deploy_workflow():
50
+ """Initialize GitHub Actions deployment workflow for GitHub Pages.
34
51
 
52
+ Creates .github/workflows/deploy.yml in the user's project root.
53
+ This workflow triggers on pushes to main branch and:
54
+ - Builds the project website with 'ap build'
55
+ - Deploys to GitHub Pages
56
+ - Allows manual deployment via workflow_dispatch
57
+ """
35
58
  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"
59
+ workflow_dir = user_path / ".github" / "workflows"
60
+ workflow_path = workflow_dir / "deploy.yml"
38
61
 
39
- _copy_github_template(template_name, workflow_path)
62
+ if workflow_path.exists():
63
+ click.echo(f"GitHub Actions deploy workflow {workflow_path} already exists")
64
+ return
40
65
 
66
+ # Create .github/workflows directory if it doesn't exist
67
+ workflow_dir.mkdir(parents=True, exist_ok=True)
41
68
 
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"
69
+ # Copy template from package
70
+ template_path = ap.paths.templates_path / "deploy-workflow-template.yml"
71
+ if not template_path.exists():
72
+ raise FileNotFoundError(
73
+ f"Template file not found: {template_path}\n"
74
+ "This might indicate a corrupted installation. Please reinstall afterpython."
75
+ )
47
76
 
48
- _copy_github_template(template_name, dependabot_path)
77
+ shutil.copy(template_path, workflow_path)
78
+ click.echo(f"Created {workflow_path}")
afterpython/tools/myst.py CHANGED
@@ -1,17 +1,15 @@
1
1
  from __future__ import annotations
2
-
3
2
  from typing import TYPE_CHECKING
4
3
 
5
4
  if TYPE_CHECKING:
6
5
  from pathlib import Path
7
-
8
- from afterpython._typing import NodeEnv, tContentType
6
+ from afterpython._typing import tContentType, NodeEnv
9
7
 
10
8
  import subprocess
11
9
 
12
10
  import afterpython as ap
13
- from afterpython._io.yaml import read_yaml, write_yaml
14
11
  from afterpython.utils import deep_merge
12
+ from afterpython._io.yaml import write_yaml, read_yaml
15
13
 
16
14
 
17
15
  def update_authors_yml(data_update: dict):
@@ -103,48 +101,6 @@ def update_myst_yml(data_update: dict, path: Path, add_comments: bool = False):
103
101
  write_yaml(file_path, existing_data)
104
102
 
105
103
 
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
104
  def _write_welcome_file(content_type: tContentType):
149
105
  welcome_file = ap.paths.afterpython_path / content_type / "index.md"
150
106
  if welcome_file.exists():
@@ -198,6 +154,9 @@ def init_myst():
198
154
  },
199
155
  "site": {
200
156
  "options": {
157
+ "favicon": "../static/favicon.ico",
158
+ "logo": "../static/logo.svg",
159
+ "logo_dark": "../static/logo.svg",
201
160
  "analytics_google": "{{ GOOGLE_ANALYTICS_ID }}",
202
161
  # "twitter": "",
203
162
  },
@@ -1,8 +1,36 @@
1
- import shutil
2
1
  import subprocess
3
2
 
4
3
  import afterpython as ap
5
- from afterpython._io.yaml import read_yaml, write_yaml
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
+ }
6
34
 
7
35
 
8
36
  def install_pre_commit():
@@ -29,9 +57,6 @@ def init_pre_commit():
29
57
  if pre_commit_path.exists():
30
58
  print(f".pre-commit-config.yaml already exists at {pre_commit_path}")
31
59
  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)
60
+ write_yaml(pre_commit_path, pre_commit_default)
36
61
  print(f"Created {pre_commit_path}")
37
62
  install_pre_commit()
@@ -1,17 +1,15 @@
1
1
  from __future__ import annotations
2
-
3
2
  from typing import TYPE_CHECKING
4
3
 
5
4
  if TYPE_CHECKING:
6
5
  from tomlkit.toml_document import TOMLDocument
7
6
 
8
7
  import asyncio
9
- from pathlib import Path
10
8
 
11
9
  from pyproject_metadata import StandardMetadata
12
10
 
13
11
  import afterpython as ap
14
- from afterpython._io.toml import _to_tomlkit, read_toml, write_toml
12
+ from afterpython._io.toml import read_toml, write_toml, _to_tomlkit
15
13
 
16
14
 
17
15
  def read_pyproject() -> TOMLDocument:
@@ -28,50 +26,14 @@ def read_metadata() -> StandardMetadata:
28
26
  return StandardMetadata.from_pyproject(read_pyproject())
29
27
 
30
28
 
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
29
  def init_pyproject():
67
30
  """Initialize pyproject.toml with sensible defaults
68
31
  - add [build-system] section with uv build backend (same as `uv init --package`)
69
32
  - add [project.urls] section with homepage, repository, and documentation URLs
70
33
  """
71
34
  import httpx
72
-
73
- from afterpython.tools._git import get_git_user_config, get_github_url
74
35
  from afterpython.utils import fetch_pypi_json
36
+ from afterpython.tools._git import get_git_user_config, get_github_url
75
37
 
76
38
  build_backend = "uv_build"
77
39
 
afterpython/utils.py CHANGED
@@ -1,12 +1,9 @@
1
1
  from __future__ import annotations
2
-
3
2
  from typing import TYPE_CHECKING
4
3
 
5
4
  if TYPE_CHECKING:
6
5
  from pathlib import Path
7
-
8
6
  from httpx import AsyncClient
9
-
10
7
  from afterpython._typing import NodeEnv
11
8
 
12
9
  import os
@@ -23,7 +20,6 @@ def find_node_env() -> NodeEnv:
23
20
  If no, install the Node.js version specified in NODEENV_VERSION
24
21
  """
25
22
  from mystmd_py.nodeenv import find_any_node
26
-
27
23
  from afterpython.const import NODEENV_VERSION
28
24
 
29
25
  # from mystmd_py.main import ensure_valid_version
@@ -39,11 +35,6 @@ def has_uv() -> bool:
39
35
  return shutil.which("uv") is not None
40
36
 
41
37
 
42
- def has_pixi() -> bool:
43
- """Check if pixi is installed"""
44
- return shutil.which("pixi") is not None
45
-
46
-
47
38
  def has_gh() -> bool:
48
39
  """Check if gh is installed"""
49
40
  return shutil.which("gh") is not None
@@ -224,76 +215,3 @@ def handle_passthrough_help(
224
215
  subprocess.run([*underlying_command, "--help"])
225
216
 
226
217
  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