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,293 @@
1
+ import json
2
+ import os
3
+ from pathlib import Path
4
+
5
+ import click
6
+
7
+ import afterpython as ap
8
+ from afterpython.const import CONTENT_TYPES
9
+ from afterpython.tools.pyproject import read_metadata
10
+
11
+
12
+ def _get_molab_badge() -> str:
13
+ return "https://marimo.io/molab-shield.svg"
14
+
15
+
16
+ def _create_molab_url(github_url: str, content_path: Path):
17
+ """Create a molab URL for a given content type and notebook path.
18
+ Args:
19
+ github_url: str, e.g. "https://github.com/AfterPythonOrg/afterpython"
20
+ content_path: str, e.g. "tutorial/test.ipynb"
21
+ """
22
+ github_url = github_url.replace("https://github.com/", "github/")
23
+ return f"https://molab.marimo.io/{github_url}/blob/main/afterpython/{content_path.as_posix()}"
24
+
25
+
26
+ def _read_notebook(notebook_path: Path, content_path: Path) -> dict | None:
27
+ """Read and validate a Jupyter notebook.
28
+
29
+ Returns:
30
+ Notebook dict if valid, None if invalid or error occurred
31
+ """
32
+ try:
33
+ with open(notebook_path) as f:
34
+ notebook = json.load(f)
35
+ except json.JSONDecodeError:
36
+ click.echo(f"✗ Skipping {content_path} - invalid or empty JSON")
37
+ return None
38
+ except Exception as e:
39
+ click.echo(f"✗ Error reading {content_path}: {e}")
40
+ return None
41
+
42
+ # Validate notebook structure
43
+ if "cells" not in notebook:
44
+ click.echo(f"✗ Skipping {content_path} - not a valid Jupyter notebook")
45
+ return None
46
+
47
+ return notebook
48
+
49
+
50
+ def _write_notebook(notebook: dict, notebook_path: Path):
51
+ """Write a notebook dict to file."""
52
+ with open(notebook_path, "w") as f:
53
+ json.dump(notebook, f, indent=1)
54
+
55
+
56
+ def _has_frontmatter(notebook: dict) -> bool:
57
+ """Check if a notebook has frontmatter in the first cell."""
58
+ if not notebook["cells"]:
59
+ return False
60
+
61
+ first_cell = notebook["cells"][0]
62
+ return (
63
+ first_cell.get("cell_type") == "markdown"
64
+ and first_cell.get("source")
65
+ and isinstance(first_cell["source"], list)
66
+ and len(first_cell["source"]) > 0
67
+ and first_cell["source"][0].strip().startswith("---")
68
+ )
69
+
70
+
71
+ def _read_frontmatter(notebook: dict) -> str | None:
72
+ """Read frontmatter content from a notebook.
73
+
74
+ Returns:
75
+ Frontmatter content as a string if it exists, None otherwise
76
+ """
77
+ if not _has_frontmatter(notebook):
78
+ return None
79
+
80
+ first_cell = notebook["cells"][0]
81
+ return "".join(first_cell["source"])
82
+
83
+
84
+ def _write_frontmatter(notebook: dict, content: str):
85
+ """Write frontmatter content to a notebook cell.
86
+
87
+ Low-level function that handles cell manipulation.
88
+ If frontmatter cell exists, updates it. If not, creates a new one.
89
+
90
+ Args:
91
+ notebook: The notebook dict to modify
92
+ content: The complete frontmatter content as a string (should include --- delimiters)
93
+ """
94
+ # Convert content string to list of lines
95
+ source_lines = [
96
+ line if line.endswith("\n") else line + "\n" for line in content.split("\n")
97
+ ]
98
+ # Remove the last empty line if the content ends with \n
99
+ if source_lines and source_lines[-1] == "\n":
100
+ source_lines.pop()
101
+
102
+ if _has_frontmatter(notebook):
103
+ # Update existing frontmatter cell
104
+ notebook["cells"][0]["source"] = source_lines
105
+ else:
106
+ # Create new frontmatter cell
107
+ frontmatter_cell = {
108
+ "cell_type": "markdown",
109
+ "id": "frontmatter-cell",
110
+ "metadata": {},
111
+ "source": source_lines,
112
+ }
113
+ notebook["cells"].insert(0, frontmatter_cell)
114
+
115
+
116
+ def _add_fields_to_frontmatter(notebook: dict, fields: dict[str, str]):
117
+ """Add or update fields in frontmatter.
118
+
119
+ High-level function that handles frontmatter field manipulation.
120
+ If frontmatter exists, adds new fields to it. If not, creates new frontmatter with those fields.
121
+
122
+ Args:
123
+ notebook: The notebook dict to modify
124
+ fields: A dict of fields to add/update (e.g., {"thumbnail": "null"})
125
+ """
126
+ existing_content = _read_frontmatter(notebook)
127
+
128
+ if existing_content:
129
+ # Parse existing frontmatter and add new fields
130
+ lines = existing_content.split("\n")
131
+
132
+ # Find the closing --- of frontmatter
133
+ closing_index = -1
134
+ for i in range(1, len(lines)):
135
+ if lines[i].strip().startswith("---"):
136
+ closing_index = i
137
+ break
138
+
139
+ if closing_index > 0:
140
+ # Insert new fields before the closing ---
141
+ for key, value in fields.items():
142
+ # Check if field already exists
143
+ if not any(line.strip().startswith(f"{key}:") for line in lines):
144
+ lines.insert(closing_index, f"{key}: {value}")
145
+ closing_index += 1 # Adjust index for next insertion
146
+
147
+ new_content = "\n".join(lines)
148
+ _write_frontmatter(notebook, new_content)
149
+ else:
150
+ # Create new frontmatter with the provided fields
151
+ field_lines = [f"{key}: {value}" for key, value in fields.items()]
152
+ new_content = "---\n" + "\n".join(field_lines) + "\n---"
153
+ _write_frontmatter(notebook, new_content)
154
+
155
+
156
+ def _add_thumbnail_to_notebook(
157
+ notebook: dict, thumbnail: str, content_path: Path
158
+ ) -> bool:
159
+ """Add thumbnail field to a single notebook's frontmatter.
160
+
161
+ Args:
162
+ notebook: The notebook dict to modify
163
+ thumbnail: The thumbnail value to add
164
+ content_path: Path for logging purposes
165
+
166
+ Returns:
167
+ True if thumbnail was added, False if it already exists
168
+ """
169
+ # Read frontmatter content
170
+ frontmatter_content = _read_frontmatter(notebook)
171
+
172
+ # Check if thumbnail is already set in frontmatter
173
+ if frontmatter_content and "thumbnail:" in frontmatter_content:
174
+ return False
175
+
176
+ # Add thumbnail using the fields parameter
177
+ _add_fields_to_frontmatter(notebook, fields={"thumbnail": thumbnail})
178
+
179
+ if frontmatter_content:
180
+ click.echo(
181
+ f" ↳ Added 'thumbnail: {thumbnail}' to frontmatter in: {content_path}"
182
+ )
183
+ else:
184
+ click.echo(
185
+ f" ↳ Created frontmatter with 'thumbnail: {thumbnail}' in: {content_path}"
186
+ )
187
+
188
+ return True
189
+
190
+
191
+ def _add_molab_badge_to_notebook(notebook: dict, github_url: str, content_path: Path):
192
+ """Add a molab badge markdown cell to a single notebook.
193
+
194
+ Args:
195
+ notebook: The notebook dict to modify
196
+ github_url: The GitHub repository URL
197
+ content_path: Path relative to afterpython/ for generating the molab URL
198
+
199
+ Note:
200
+ The jupyter notebook needs to exist in the github repository first.
201
+ If you have renamed a notebook, you need to push the changes to the github repository first for the badge to work.
202
+ """
203
+ molab_url = _create_molab_url(github_url, content_path)
204
+ badge_md = f"[![Open in molab]({_get_molab_badge()})]({molab_url})"
205
+
206
+ # Create a markdown cell with the badge
207
+ badge_cell = {
208
+ "cell_type": "markdown",
209
+ "id": "molab-badge-cell", # Unique ID for the badge cell
210
+ "metadata": {
211
+ "tags": ["molab", "hide-input"] # Optional: use MyST tags
212
+ },
213
+ "source": [badge_md],
214
+ }
215
+
216
+ # Determine insertion position (after frontmatter if it exists)
217
+ insert_position = 1 if _has_frontmatter(notebook) else 0
218
+
219
+ # Check if badge already exists at the expected position
220
+ if (
221
+ len(notebook["cells"]) > insert_position
222
+ and notebook["cells"][insert_position].get("id") == "molab-badge-cell"
223
+ ):
224
+ # Update existing badge
225
+ notebook["cells"][insert_position] = badge_cell
226
+ click.echo(f"✓ Updated molab badge in: {content_path}")
227
+ else:
228
+ # Insert at the correct position
229
+ notebook["cells"].insert(insert_position, badge_cell)
230
+ click.echo(f"✓ Added molab badge to: {content_path}")
231
+
232
+
233
+ def build_jupyter_notebooks():
234
+ """Build Jupyter notebooks for all content types"""
235
+ from afterpython.tools._afterpython import get_default_thumbnail
236
+
237
+ # Check if molab badge feature is enabled
238
+ add_molab_badge = os.getenv("AP_MOLAB_BADGE", "1") == "1"
239
+
240
+ # Get GitHub URL once if needed for molab badges
241
+ github_url = None
242
+ if add_molab_badge:
243
+ metadata = read_metadata()
244
+ if "repository" in metadata.urls:
245
+ github_url = metadata.urls["repository"]
246
+ else:
247
+ click.echo(
248
+ "⚠ Repository URL not found in [project.urls] in pyproject.toml, skipping molab badge"
249
+ )
250
+ add_molab_badge = False
251
+
252
+ for content_type in CONTENT_TYPES:
253
+ click.echo(f"Building Jupyter notebooks for {content_type}/...")
254
+
255
+ path = ap.paths.afterpython_path / content_type.lower()
256
+
257
+ # Iterate over all .ipynb files once
258
+ for notebook_path in path.rglob("*.ipynb"):
259
+ # Skip files in _build directory
260
+ if "_build" in notebook_path.parts:
261
+ continue
262
+
263
+ # Get path relative to afterpython/
264
+ content_path = notebook_path.relative_to(ap.paths.afterpython_path)
265
+
266
+ # Get default thumbnail for this content type
267
+ # explicitly set to "null" if no default thumbnail is set to avoid using the first image (very likely to be "open in molab" badge image) as thumbnail
268
+ default_thumbnail = (
269
+ get_default_thumbnail(content_type, notebook_path) or "null"
270
+ )
271
+
272
+ # Read the notebook
273
+ notebook = _read_notebook(notebook_path, content_path)
274
+ if notebook is None:
275
+ continue
276
+
277
+ # Track if notebook was modified
278
+ modified = False
279
+
280
+ # Add molab badge if enabled
281
+ if add_molab_badge and github_url:
282
+ _add_molab_badge_to_notebook(notebook, github_url, content_path)
283
+ modified = True
284
+
285
+ # Add thumbnail to frontmatter
286
+ if content_type != "doc" and _add_thumbnail_to_notebook(
287
+ notebook, default_thumbnail, content_path
288
+ ):
289
+ modified = True
290
+
291
+ # Write back if modified
292
+ if modified:
293
+ _write_notebook(notebook, notebook_path)
@@ -0,0 +1,271 @@
1
+ from pathlib import Path
2
+
3
+ import click
4
+
5
+ import afterpython as ap
6
+ from afterpython.const import CONTENT_TYPES
7
+
8
+
9
+ def _read_markdown(markdown_path: Path, content_path: Path) -> str | None:
10
+ """Read and validate a markdown file.
11
+
12
+ Args:
13
+ markdown_path: Absolute path to the markdown file
14
+ content_path: Path relative to afterpython/ for logging
15
+
16
+ Returns:
17
+ Markdown content as a string if valid, None if invalid or error occurred
18
+ """
19
+ try:
20
+ with open(markdown_path, encoding="utf-8") as f:
21
+ content = f.read()
22
+ return content
23
+ except FileNotFoundError:
24
+ click.echo(f"✗ Skipping {content_path} - file not found")
25
+ return None
26
+ except Exception as e:
27
+ click.echo(f"✗ Error reading {content_path}: {e}")
28
+ return None
29
+
30
+
31
+ def _write_markdown(content: str, markdown_path: Path):
32
+ """Write markdown content to file.
33
+
34
+ Args:
35
+ content: The markdown content to write
36
+ markdown_path: Absolute path to the markdown file
37
+ """
38
+ with open(markdown_path, "w", encoding="utf-8") as f:
39
+ f.write(content)
40
+
41
+
42
+ def _has_frontmatter(content: str) -> bool:
43
+ """Check if markdown content has frontmatter.
44
+
45
+ Args:
46
+ content: The markdown file content as a string
47
+
48
+ Returns:
49
+ True if the content starts with ---, False otherwise
50
+ """
51
+ return content.strip().startswith("---")
52
+
53
+
54
+ def _read_frontmatter(content: str) -> str | None:
55
+ """Read frontmatter content from markdown.
56
+
57
+ Args:
58
+ content: The markdown file content as a string
59
+
60
+ Returns:
61
+ Frontmatter content as a string (including --- delimiters) if it exists, None otherwise
62
+ """
63
+ if not _has_frontmatter(content):
64
+ return None
65
+
66
+ lines = content.split("\n")
67
+
68
+ # Find the closing --- of frontmatter
69
+ closing_index = -1
70
+ for i in range(1, len(lines)):
71
+ if lines[i].strip() == "---":
72
+ closing_index = i
73
+ break
74
+
75
+ if closing_index == -1:
76
+ return None
77
+
78
+ # Return frontmatter including both --- delimiters
79
+ return "\n".join(lines[: closing_index + 1])
80
+
81
+
82
+ def _write_frontmatter(content: str, frontmatter: str) -> str:
83
+ """Write frontmatter content to markdown.
84
+
85
+ Low-level function that handles frontmatter replacement.
86
+ If frontmatter exists in content, replaces it. If not, prepends it.
87
+
88
+ Args:
89
+ content: The markdown file content as a string
90
+ frontmatter: The complete frontmatter content as a string (should include --- delimiters)
91
+
92
+ Returns:
93
+ The updated markdown content with the new frontmatter
94
+ """
95
+ if _has_frontmatter(content):
96
+ # Replace existing frontmatter
97
+ lines = content.split("\n")
98
+
99
+ # Find the closing --- of existing frontmatter
100
+ closing_index = -1
101
+ for i in range(1, len(lines)):
102
+ if lines[i].strip() == "---":
103
+ closing_index = i
104
+ break
105
+
106
+ if closing_index > -1:
107
+ # Remove old frontmatter and prepend new one
108
+ body = "\n".join(lines[closing_index + 1 :])
109
+ return frontmatter + "\n" + body
110
+ else:
111
+ # Malformed frontmatter, just prepend new frontmatter
112
+ return frontmatter + "\n" + content
113
+ else:
114
+ # No frontmatter exists, prepend it
115
+ return frontmatter + "\n" + content
116
+
117
+
118
+ def _ensure_blank_line_after_frontmatter(content: str) -> str:
119
+ """Ensure there's a blank line between frontmatter and content.
120
+
121
+ Args:
122
+ content: The markdown content
123
+
124
+ Returns:
125
+ Content with blank line after frontmatter if frontmatter exists
126
+ """
127
+ if not _has_frontmatter(content):
128
+ return content
129
+
130
+ lines = content.split("\n")
131
+
132
+ # Find the closing --- of frontmatter
133
+ closing_index = -1
134
+ for i in range(1, len(lines)):
135
+ if lines[i].strip() == "---":
136
+ closing_index = i
137
+ break
138
+
139
+ if closing_index == -1:
140
+ return content
141
+
142
+ # Check if there's already a blank line after the closing ---, and if the next line is not empty, insert a blank line
143
+ if closing_index + 1 < len(lines) and lines[closing_index + 1].strip() != "":
144
+ lines.insert(closing_index + 1, "")
145
+
146
+ return "\n".join(lines)
147
+
148
+
149
+ def _add_fields_to_frontmatter(content: str, fields: dict[str, str]) -> str:
150
+ """Add or update fields in frontmatter.
151
+
152
+ High-level function that handles frontmatter field manipulation.
153
+ If frontmatter exists, adds new fields to it. If not, creates new frontmatter with those fields.
154
+
155
+ Args:
156
+ content: The markdown file content as a string
157
+ fields: A dict of fields to add/update (e.g., {"thumbnail": "null"})
158
+
159
+ Returns:
160
+ The updated markdown content with the new fields in frontmatter
161
+ """
162
+ existing_frontmatter = _read_frontmatter(content)
163
+
164
+ if existing_frontmatter:
165
+ # Parse existing frontmatter and add new fields
166
+ lines = existing_frontmatter.split("\n")
167
+
168
+ # Find the closing --- of frontmatter
169
+ closing_index = -1
170
+ for i in range(1, len(lines)):
171
+ if lines[i].strip() == "---":
172
+ closing_index = i
173
+ break
174
+
175
+ if closing_index > 0:
176
+ # Insert new fields before the closing ---
177
+ for key, value in fields.items():
178
+ # Check if field already exists
179
+ if not any(line.strip().startswith(f"{key}:") for line in lines):
180
+ lines.insert(closing_index, f"{key}: {value}")
181
+ closing_index += 1 # Adjust index for next insertion
182
+
183
+ new_frontmatter = "\n".join(lines)
184
+ return _write_frontmatter(content, new_frontmatter)
185
+ else:
186
+ # Create new frontmatter with the provided fields
187
+ field_lines = [f"{key}: {value}" for key, value in fields.items()]
188
+ new_frontmatter = "---\n" + "\n".join(field_lines) + "\n---"
189
+ return _write_frontmatter(content, new_frontmatter)
190
+
191
+ return content
192
+
193
+
194
+ def _add_thumbnail_to_markdown(
195
+ content: str, thumbnail: str, content_path: Path
196
+ ) -> tuple[str, bool]:
197
+ """Add thumbnail field to a markdown file's frontmatter.
198
+
199
+ Args:
200
+ content: The markdown content
201
+ thumbnail: The thumbnail value to add
202
+ content_path: Path for logging purposes
203
+
204
+ Returns:
205
+ Tuple of (modified_content, was_modified)
206
+ """
207
+ # Check if thumbnail is already set in frontmatter
208
+ frontmatter_content = _read_frontmatter(content)
209
+ if frontmatter_content and "thumbnail:" in frontmatter_content:
210
+ return content, False
211
+
212
+ # Add thumbnail using the fields parameter
213
+ modified_content = _add_fields_to_frontmatter(
214
+ content, fields={"thumbnail": thumbnail}
215
+ )
216
+
217
+ if frontmatter_content:
218
+ click.echo(
219
+ f" ↳ Added 'thumbnail: {thumbnail}' to frontmatter in: {content_path}"
220
+ )
221
+ else:
222
+ click.echo(
223
+ f" ↳ Created frontmatter with 'thumbnail: {thumbnail}' in: {content_path}"
224
+ )
225
+
226
+ return modified_content, True
227
+
228
+
229
+ def build_markdown():
230
+ """Build markdown files for all content types"""
231
+ from afterpython.tools._afterpython import get_default_thumbnail
232
+
233
+ for content_type in CONTENT_TYPES:
234
+ # Skip doc content type - docs don't need default thumbnails
235
+ if content_type == "doc":
236
+ continue
237
+
238
+ click.echo(f"Building markdown files for {content_type}/...")
239
+
240
+ path = ap.paths.afterpython_path / content_type.lower()
241
+
242
+ # Iterate over all .md files
243
+ for markdown_path in path.rglob("*.md"):
244
+ # Skip files in _build directory
245
+ if "_build" in markdown_path.parts:
246
+ continue
247
+
248
+ # Get path relative to afterpython/
249
+ content_path = markdown_path.relative_to(ap.paths.afterpython_path)
250
+
251
+ # Get default thumbnail for this content type
252
+ default_thumbnail = get_default_thumbnail(content_type, markdown_path)
253
+
254
+ # Read the markdown file
255
+ content = _read_markdown(markdown_path, content_path)
256
+ if content is None:
257
+ continue
258
+
259
+ # Ensure blank line after frontmatter (fixes existing files)
260
+ original_content = content
261
+ content = _ensure_blank_line_after_frontmatter(content)
262
+ blank_line_added = content != original_content
263
+
264
+ # Add thumbnail to frontmatter
265
+ modified_content, was_modified = _add_thumbnail_to_markdown(
266
+ content, default_thumbnail, content_path
267
+ )
268
+
269
+ # Write back if modified (either blank line or thumbnail)
270
+ if was_modified or blank_line_added:
271
+ _write_markdown(modified_content, markdown_path)
@@ -0,0 +1,50 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ if TYPE_CHECKING:
6
+ from pyproject_metadata import StandardMetadata
7
+
8
+ import json
9
+
10
+ import click
11
+
12
+ import afterpython as ap
13
+
14
+ build_path = ap.paths.build_path
15
+
16
+
17
+ def convert_paths():
18
+ """
19
+ Convert paths in "description" field (README.md) in metadata.json to use the new paths in the build output.
20
+ e.g. convert ./afterpython/static/image.png to static/image.png
21
+ """
22
+ # Read metadata.json
23
+ with open(build_path / "metadata.json") as f:
24
+ metadata = json.load(f)
25
+ markdown_text = metadata["description"]
26
+
27
+ # Replace with the correct paths
28
+ updated_markdown = markdown_text.replace("afterpython/static/", "/")
29
+
30
+ # Write back to metadata.json
31
+ metadata["description"] = updated_markdown
32
+ with open(build_path / "metadata.json", "w") as f:
33
+ json.dump(metadata, f, indent=2)
34
+
35
+ click.echo("Completed path conversion in metadata.json")
36
+
37
+
38
+ def build_metadata():
39
+ """Build metadata.json using pyproject.toml"""
40
+ from afterpython.tools.pyproject import read_metadata
41
+
42
+ click.echo("Building metadata.json...")
43
+
44
+ metadata: StandardMetadata = read_metadata()
45
+
46
+ # Write to metadata.json
47
+ with open(build_path / "metadata.json", "w") as f:
48
+ json.dump(metadata.as_json(), f, indent=2)
49
+
50
+ convert_paths()
@@ -0,0 +1,3 @@
1
+ from afterpython.cli.main import afterpython_group
2
+
3
+ __all__ = ["afterpython_group"]
@@ -0,0 +1,67 @@
1
+ import subprocess
2
+
3
+ import click
4
+ from click.exceptions import Exit
5
+
6
+ from afterpython.utils import has_pixi, has_uv
7
+
8
+
9
+ @click.command()
10
+ @click.option(
11
+ "--optional",
12
+ type=str,
13
+ default=None,
14
+ required=False,
15
+ help="Add to an optional dependency group (pixi: mapped to 'optional' feature)",
16
+ )
17
+ @click.option(
18
+ "--group",
19
+ type=str,
20
+ default=None,
21
+ required=False,
22
+ help="Add to a dependency group (pixi: mapped to same-named feature)",
23
+ )
24
+ @click.argument("lib", type=str, required=True)
25
+ def add(optional: str | None, group: str | None, lib: str):
26
+ """Add a new dependency to the project (manages both uv and pixi if present)"""
27
+ if not has_uv():
28
+ click.echo("uv not found. Please install uv first.")
29
+ return
30
+
31
+ if optional and group:
32
+ click.echo("Error: Cannot specify both --optional and --group")
33
+ raise Exit(1)
34
+
35
+ result = subprocess.run(
36
+ [
37
+ "uv",
38
+ "add",
39
+ *(["--optional", optional] if optional else []),
40
+ *(["--group", group] if group else []),
41
+ lib,
42
+ ],
43
+ check=False,
44
+ )
45
+ if result.returncode != 0:
46
+ raise Exit(result.returncode)
47
+
48
+ if has_pixi():
49
+ # NOTE: pixi doesn't support --optional, so we need to use --feature instead
50
+ if optional:
51
+ feature_name = "optional"
52
+ elif group:
53
+ feature_name = group
54
+ else:
55
+ feature_name = None
56
+ result = subprocess.run(
57
+ [
58
+ "pixi",
59
+ "add",
60
+ "--pypi",
61
+ *(["--feature", feature_name] if feature_name else []),
62
+ lib,
63
+ ],
64
+ check=False,
65
+ )
66
+ if result.returncode != 0:
67
+ raise Exit(result.returncode)