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,13 @@
1
+ # import builtins
2
+ # from pprint import pprint
3
+ from importlib.metadata import version
4
+
5
+ from afterpython._paths import Paths
6
+
7
+ # make pprint available in the global namespace
8
+ # builtins.pprint = pprint
9
+
10
+ paths = Paths()
11
+
12
+ __version__ = version("afterpython")
13
+ __all__ = ("__version__", "paths")
@@ -0,0 +1,105 @@
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
+ import tomlkit
11
+
12
+
13
+ # VIBE-CODED
14
+ def _from_tomlkit(value):
15
+ """Recursively convert tomlkit objects to plain Python data structures.
16
+
17
+ This ensures compatibility with other libraries (like ruamel.yaml):
18
+ - tomlkit containers → plain Python containers
19
+ - tomlkit primitives → plain Python primitives
20
+ """
21
+ from tomlkit.items import AoT, Array, InlineTable, Table
22
+
23
+ if value is None:
24
+ return None
25
+
26
+ # Check containers first (these are the problematic ones)
27
+ if isinstance(value, (Table, InlineTable)):
28
+ # tomlkit dict-like → plain dict
29
+ return {k: _from_tomlkit(v) for k, v in value.items()}
30
+ elif isinstance(value, (Array, AoT)):
31
+ # tomlkit array → plain list
32
+ return [_from_tomlkit(item) for item in value]
33
+ elif isinstance(value, dict):
34
+ # Already a dict, but recurse to handle nested tomlkit objects
35
+ return {k: _from_tomlkit(v) for k, v in value.items()}
36
+ elif isinstance(value, list):
37
+ # Already a list, but recurse to handle nested tomlkit objects
38
+ return [_from_tomlkit(item) for item in value]
39
+
40
+ # Primitives - tomlkit primitives inherit from Python types
41
+ # Just convert explicitly to be safe
42
+ elif isinstance(value, bool): # MUST check bool before int!
43
+ return bool(value)
44
+ elif isinstance(value, int):
45
+ return int(value)
46
+ elif isinstance(value, float):
47
+ return float(value)
48
+ elif isinstance(value, str):
49
+ return str(value)
50
+ else:
51
+ # datetime, date, time, or already plain Python type
52
+ return value
53
+
54
+
55
+ # VIBE-CODED
56
+ def _to_tomlkit(value):
57
+ """Recursively convert Python data structures to tomlkit objects.
58
+
59
+ This ensures proper TOML formatting:
60
+ - List of dicts → array of inline tables: [{key = val}]
61
+ - Nested dicts → nested tables with proper structure
62
+ """
63
+ from tomlkit import array, inline_table
64
+
65
+ if value is None:
66
+ return None
67
+
68
+ if isinstance(value, dict):
69
+ # Plain dict → recursively convert values
70
+ return {k: _to_tomlkit(v) for k, v in value.items()}
71
+ elif isinstance(value, list):
72
+ if not value:
73
+ # Empty list → empty tomlkit array
74
+ return array()
75
+
76
+ # Check if ALL items are dicts (should be inline tables)
77
+ if all(isinstance(item, dict) for item in value):
78
+ arr = array()
79
+ for item in value:
80
+ tbl = inline_table()
81
+ # Recursively convert the dict values
82
+ tbl.update({k: _to_tomlkit(v) for k, v in item.items()})
83
+ arr.append(tbl)
84
+ return arr
85
+ else:
86
+ # Mixed or non-dict items → regular array
87
+ arr = array()
88
+ for item in value:
89
+ arr.append(_to_tomlkit(item))
90
+ return arr
91
+ else:
92
+ # Primitives (str, int, float, bool, datetime, etc.)
93
+ # tomlkit handles these natively
94
+ return value
95
+
96
+
97
+ def read_toml(file_path: Path) -> TOMLDocument:
98
+ with open(file_path, "rb") as f:
99
+ data: TOMLDocument = tomlkit.parse(f.read())
100
+ return data
101
+
102
+
103
+ def write_toml(file_path: Path, data: TOMLDocument | dict):
104
+ with open(file_path, "w") as f:
105
+ f.write(tomlkit.dumps(data))
@@ -0,0 +1,26 @@
1
+ from pathlib import Path
2
+
3
+ from ruamel.yaml import YAML
4
+ from ruamel.yaml.comments import CommentedMap
5
+
6
+
7
+ def _get_yaml() -> YAML:
8
+ """Get configured YAML instance that preserves order, comments, and formatting"""
9
+ yaml = YAML()
10
+ yaml.preserve_quotes = True
11
+ yaml.default_flow_style = False
12
+ yaml.width = 4096 # Prevent line wrapping
13
+ return yaml
14
+
15
+
16
+ def read_yaml(file_path: Path) -> CommentedMap:
17
+ yaml = _get_yaml()
18
+ with open(file_path) as f:
19
+ return yaml.load(f)
20
+
21
+
22
+ def write_yaml(file_path: Path, data: dict):
23
+ """Write YAML data"""
24
+ yaml = _get_yaml()
25
+ with open(file_path, "w") as f:
26
+ yaml.dump(data, f)
afterpython/_paths.py ADDED
@@ -0,0 +1,39 @@
1
+ from dataclasses import dataclass, field
2
+ from pathlib import Path
3
+
4
+
5
+ @dataclass
6
+ class Paths:
7
+ package_path: Path = field(init=False)
8
+ templates_path: Path = field(init=False)
9
+ user_path: Path = field(init=False)
10
+ pyproject_path: Path = field(init=False)
11
+ afterpython_path: Path = field(init=False)
12
+ website_path: Path = field(init=False)
13
+ build_path: Path = field(init=False)
14
+ static_path: Path = field(init=False)
15
+
16
+ def __post_init__(self):
17
+ # package path is the path to the afterpython package in user's site-packages
18
+ self.package_path = Path(__file__).resolve().parents[0]
19
+ self.templates_path = self.package_path / "templates"
20
+ self.user_path = self._find_project_root()
21
+ self.pyproject_path = self.user_path / "pyproject.toml"
22
+ self.afterpython_path = self.user_path / "afterpython"
23
+ self.website_path = self.afterpython_path / "_website"
24
+ self.build_path = self.afterpython_path / "_build"
25
+ self.static_path = self.afterpython_path / "static"
26
+
27
+ def _find_project_root(self) -> Path:
28
+ """Find the project root by looking for pyproject.toml in current or parent directories."""
29
+ current = Path.cwd()
30
+
31
+ # Check current directory and all parents
32
+ for path in [current, *current.parents]:
33
+ if (path / "pyproject.toml").exists():
34
+ return path
35
+
36
+ # If no pyproject.toml found, raise an error like uv does
37
+ raise FileNotFoundError(
38
+ "No pyproject.toml found in current directory or any parent directory"
39
+ )
afterpython/_typing.py ADDED
@@ -0,0 +1,4 @@
1
+ from typing import Literal, TypeAlias
2
+
3
+ NodeEnv: TypeAlias = dict[str, str]
4
+ tContentType = Literal["doc", "blog", "tutorial", "example", "guide"]
@@ -0,0 +1,17 @@
1
+ from afterpython.builders.content_json import build_content_json
2
+ from afterpython.builders.index_md import (
3
+ create_placeholder_index_md_files,
4
+ delete_placeholder_index_md_files,
5
+ )
6
+ from afterpython.builders.jupyter_notebook import build_jupyter_notebooks
7
+ from afterpython.builders.markdown import build_markdown
8
+ from afterpython.builders.metadata import build_metadata
9
+
10
+ __all__ = (
11
+ "build_content_json",
12
+ "build_jupyter_notebooks",
13
+ "build_markdown",
14
+ "build_metadata",
15
+ "create_placeholder_index_md_files",
16
+ "delete_placeholder_index_md_files",
17
+ )
@@ -0,0 +1,131 @@
1
+ """
2
+ Build {content_type}.json for non-doc content type (blog, tutorial, example, guide)
3
+
4
+ Combines all individual content JSON files from MyST build output into a single
5
+ manifest file for SvelteKit to consume (e.g., blog.json, tutorial.json).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import TYPE_CHECKING
11
+
12
+ if TYPE_CHECKING:
13
+ from afterpython._typing import tContentType
14
+
15
+ import json
16
+ from pathlib import Path
17
+
18
+ import click
19
+
20
+ import afterpython as ap
21
+ from afterpython.const import CONTENT_TYPES
22
+
23
+
24
+ def standardize_data(content_type: tContentType, content: dict) -> dict:
25
+ """
26
+ Standardize the content dictionary to a single level.
27
+ """
28
+ from afterpython.tools._afterpython import read_afterpython
29
+
30
+ afterpython = read_afterpython()
31
+ website = afterpython.get("website", {})
32
+ content_type_config = website.get(content_type, {})
33
+ featured_post = content_type_config.get("featured_post", "")
34
+
35
+ frontmatter = content.get("frontmatter", {})
36
+ # e.g. featured_post = "blog4.md" or "blog4"
37
+ is_featured = content["slug"] == featured_post or content["location"].endswith(
38
+ featured_post
39
+ )
40
+ return {
41
+ "version": content["version"],
42
+ "slug": content["slug"],
43
+ "location": content["location"],
44
+ "featured": is_featured,
45
+ # frontmatter fields
46
+ "title": frontmatter.get("title", ""),
47
+ "description": frontmatter.get("description", ""),
48
+ "tags": frontmatter.get("tags", []),
49
+ "date": frontmatter.get("date", ""),
50
+ "thumbnail": frontmatter.get("thumbnail", ""),
51
+ "thumbnailOptimized": frontmatter.get("thumbnailOptimized", ""),
52
+ "authors": frontmatter.get("authors", []),
53
+ }
54
+
55
+
56
+ def convert_paths(content_type: tContentType, output_file: Path):
57
+ """
58
+ Convert paths (generated by MyST build) in the content_json file to use the new paths in the build output.
59
+ e.g. convert /blog_thumbnail-08d4f63a34eb7b55b6a00c25218c3295.png to /blog/build/blog_thumbnail-08d4f63a34eb7b55b6a00c25218c3295.png
60
+ """
61
+ with open(output_file, encoding="utf-8") as f:
62
+ content = json.load(f)
63
+
64
+ for item in content:
65
+ if not item.get("thumbnailOptimized"):
66
+ item["thumbnailOptimized"] = item["thumbnail"]
67
+ item["thumbnail"] = f"/{content_type}/build" + item["thumbnail"]
68
+ item["thumbnailOptimized"] = (
69
+ f"/{content_type}/build" + item["thumbnailOptimized"]
70
+ )
71
+
72
+ with open(output_file, "w", encoding="utf-8") as f:
73
+ json.dump(content, f, indent=2, ensure_ascii=False)
74
+
75
+ click.echo(f"Completed path conversion in {output_file}")
76
+
77
+
78
+ def build_content_json():
79
+ """Build {content_type}.json manifest for non-doc content types.
80
+
81
+ Reads all JSON files from afterpython/_build/{content_type}/site/content/
82
+ (excluding index.json) and combines them into a single manifest file at
83
+ afterpython/_build/{content_type}.json.
84
+
85
+ This manifest is used by SvelteKit landing pages to list all content items.
86
+
87
+ Args:
88
+ content_type: Content type (blog, tutorial, example, guide)
89
+ """
90
+ for content_type in CONTENT_TYPES:
91
+ if content_type == "doc":
92
+ continue # Doc doesn't need a combined manifest
93
+
94
+ content_path = ap.paths.afterpython_path / content_type
95
+ content_dir = content_path / "_build" / "site" / "content"
96
+ myst_yml_path = content_path / "myst.yml"
97
+ if (
98
+ not content_path.exists()
99
+ or not myst_yml_path.exists()
100
+ or not content_dir.exists()
101
+ ):
102
+ click.echo(f"Skipping {content_type}.json (missing files)")
103
+ continue
104
+
105
+ click.echo(f"Building {content_type}.json...")
106
+
107
+ # Collect all JSON files except index.json, which is created by a placeholder index.md file
108
+ content_items = []
109
+ for json_file in content_dir.glob("*.json"):
110
+ if json_file.stem == "index":
111
+ continue # Skip index.json
112
+
113
+ try:
114
+ with open(json_file, encoding="utf-8") as f:
115
+ data = json.load(f)
116
+
117
+ # Preserve all data from MyST JSON files
118
+ # The project-website-template will parse the fields it needs
119
+ content_items.append(standardize_data(content_type, data))
120
+ except (json.JSONDecodeError, KeyError) as e:
121
+ click.echo(f"Warning: Failed to parse {json_file}: {e}")
122
+ continue
123
+
124
+ # Write combined manifest
125
+ output_file = ap.paths.build_path / f"{content_type}.json"
126
+ with open(output_file, "w", encoding="utf-8") as f:
127
+ json.dump(content_items, f, indent=2, ensure_ascii=False)
128
+
129
+ click.echo(f"Generated {output_file} with {len(content_items)} items")
130
+
131
+ convert_paths(content_type, output_file)
@@ -0,0 +1,94 @@
1
+ import click
2
+
3
+ import afterpython as ap
4
+ from afterpython.const import CONTENT_TYPES
5
+
6
+
7
+ def create_placeholder_index_md_files():
8
+ """Create placeholder index.md files for non-doc content types.
9
+
10
+ Without these placeholders, MyST treats the first file in TOC as index,
11
+ causing it to use routes like /blog instead of /blog/blog1. These routes
12
+ are reserved for SvelteKit landing pages. Placeholders ensure all actual
13
+ content files get proper slugs and are deleted post-build.
14
+ """
15
+ from afterpython._io.yaml import read_yaml, write_yaml
16
+ from afterpython.tools.myst import _write_index_file
17
+
18
+ for content_type in CONTENT_TYPES:
19
+ if content_type == "doc":
20
+ continue # Doc doesn't need a placeholder index.md
21
+
22
+ content_path = ap.paths.afterpython_path / content_type
23
+ if not content_path.exists():
24
+ click.echo(
25
+ f"No content found in {content_path}, skip creating placeholder index.md"
26
+ )
27
+ continue
28
+
29
+ myst_yml_path = content_path / "myst.yml"
30
+ if not myst_yml_path.exists():
31
+ click.echo(
32
+ f"No myst.yml found in {content_path}, skip creating placeholder index.md"
33
+ )
34
+ continue
35
+
36
+ content_path = ap.paths.afterpython_path / content_type
37
+ myst_yml_path = content_path / "myst.yml"
38
+
39
+ # Prepend index.md to TOC in myst.yml
40
+ if myst_yml_path.exists():
41
+ # Create placeholder index.md file
42
+ _write_index_file(content_type)
43
+ myst_data = read_yaml(myst_yml_path) or {}
44
+ toc = myst_data.get("project", {}).get("toc", [])
45
+
46
+ # Check if index.md is already first in TOC
47
+ if not toc or toc[0].get("file") != "index.md":
48
+ # Prepend index.md to TOC in myst.yml
49
+ new_toc = [{"file": "index.md"}, *toc]
50
+ if "project" not in myst_data:
51
+ myst_data["project"] = {}
52
+ myst_data["project"]["toc"] = new_toc
53
+ write_yaml(myst_yml_path, myst_data)
54
+ click.echo(f"Added index.md to TOC in {myst_yml_path}")
55
+
56
+
57
+ def delete_placeholder_index_md_files():
58
+ """Delete placeholder index.md and built index.html for non-doc content types.
59
+
60
+ These files were created pre-build to ensure proper slug generation. Now that
61
+ MyST has built the content with correct slugs, we delete them so SvelteKit
62
+ can own the landing page routes (/blog, /tutorial, etc.) in the project website.
63
+ """
64
+ from afterpython._io.yaml import read_yaml, write_yaml
65
+
66
+ for content_type in CONTENT_TYPES:
67
+ if content_type == "doc":
68
+ continue # Doc doesn't need a placeholder index.md
69
+
70
+ content_path = ap.paths.afterpython_path / content_type
71
+ myst_yml_path = content_path / "myst.yml"
72
+ index_md = content_path / "index.md"
73
+
74
+ # Delete index.md from source
75
+ if index_md.exists():
76
+ index_md.unlink()
77
+ click.echo(f"Deleted: {index_md}")
78
+
79
+ # Delete index.html from build output
80
+ index_html = content_path / "_build" / "html" / "index.html"
81
+ if index_html.exists():
82
+ index_html.unlink()
83
+ click.echo(f"Deleted: {index_html}")
84
+
85
+ # Remove index.md from TOC in myst.yml
86
+ if myst_yml_path.exists():
87
+ myst_data = read_yaml(myst_yml_path) or {}
88
+ toc = myst_data.get("project", {}).get("toc", [])
89
+
90
+ # Remove index.md from TOC if it's the first entry
91
+ if toc and toc[0].get("file") == "index.md":
92
+ myst_data["project"]["toc"] = toc[1:]
93
+ write_yaml(myst_yml_path, myst_data)
94
+ click.echo(f"Removed index.md from TOC in {myst_yml_path}")