afterpython 0.3.13__tar.gz → 0.3.14__tar.gz

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 (64) hide show
  1. {afterpython-0.3.13 → afterpython-0.3.14}/PKG-INFO +1 -1
  2. {afterpython-0.3.13 → afterpython-0.3.14}/pyproject.toml +1 -1
  3. {afterpython-0.3.13 → afterpython-0.3.14}/src/afterpython/builders/content_json.py +20 -12
  4. {afterpython-0.3.13 → afterpython-0.3.14}/src/afterpython/cli/commands/dev.py +108 -12
  5. {afterpython-0.3.13 → afterpython-0.3.14}/src/afterpython/cli/commands/sync.py +10 -5
  6. {afterpython-0.3.13 → afterpython-0.3.14}/README.md +0 -0
  7. {afterpython-0.3.13 → afterpython-0.3.14}/src/afterpython/__init__.py +0 -0
  8. {afterpython-0.3.13 → afterpython-0.3.14}/src/afterpython/_io/toml.py +0 -0
  9. {afterpython-0.3.13 → afterpython-0.3.14}/src/afterpython/_io/yaml.py +0 -0
  10. {afterpython-0.3.13 → afterpython-0.3.14}/src/afterpython/_paths.py +0 -0
  11. {afterpython-0.3.13 → afterpython-0.3.14}/src/afterpython/_typing.py +0 -0
  12. {afterpython-0.3.13 → afterpython-0.3.14}/src/afterpython/builders/__init__.py +0 -0
  13. {afterpython-0.3.13 → afterpython-0.3.14}/src/afterpython/builders/build_pdoc.py +0 -0
  14. {afterpython-0.3.13 → afterpython-0.3.14}/src/afterpython/builders/faq_json.py +0 -0
  15. {afterpython-0.3.13 → afterpython-0.3.14}/src/afterpython/builders/index_md.py +0 -0
  16. {afterpython-0.3.13 → afterpython-0.3.14}/src/afterpython/builders/jupyter_notebook.py +0 -0
  17. {afterpython-0.3.13 → afterpython-0.3.14}/src/afterpython/builders/llms_txt.py +0 -0
  18. {afterpython-0.3.13 → afterpython-0.3.14}/src/afterpython/builders/marimo_notebook.py +0 -0
  19. {afterpython-0.3.13 → afterpython-0.3.14}/src/afterpython/builders/markdown.py +0 -0
  20. {afterpython-0.3.13 → afterpython-0.3.14}/src/afterpython/builders/metadata.py +0 -0
  21. {afterpython-0.3.13 → afterpython-0.3.14}/src/afterpython/builders/url_md.py +0 -0
  22. {afterpython-0.3.13 → afterpython-0.3.14}/src/afterpython/cli/__init__.py +0 -0
  23. {afterpython-0.3.13 → afterpython-0.3.14}/src/afterpython/cli/commands/add.py +0 -0
  24. {afterpython-0.3.13 → afterpython-0.3.14}/src/afterpython/cli/commands/build.py +0 -0
  25. {afterpython-0.3.13 → afterpython-0.3.14}/src/afterpython/cli/commands/bump.py +0 -0
  26. {afterpython-0.3.13 → afterpython-0.3.14}/src/afterpython/cli/commands/check.py +0 -0
  27. {afterpython-0.3.13 → afterpython-0.3.14}/src/afterpython/cli/commands/clean.py +0 -0
  28. {afterpython-0.3.13 → afterpython-0.3.14}/src/afterpython/cli/commands/commit.py +0 -0
  29. {afterpython-0.3.13 → afterpython-0.3.14}/src/afterpython/cli/commands/commitizen.py +0 -0
  30. {afterpython-0.3.13 → afterpython-0.3.14}/src/afterpython/cli/commands/format.py +0 -0
  31. {afterpython-0.3.13 → afterpython-0.3.14}/src/afterpython/cli/commands/init.py +0 -0
  32. {afterpython-0.3.13 → afterpython-0.3.14}/src/afterpython/cli/commands/init_branch_rules.py +0 -0
  33. {afterpython-0.3.13 → afterpython-0.3.14}/src/afterpython/cli/commands/install.py +0 -0
  34. {afterpython-0.3.13 → afterpython-0.3.14}/src/afterpython/cli/commands/lock.py +0 -0
  35. {afterpython-0.3.13 → afterpython-0.3.14}/src/afterpython/cli/commands/pre_commit.py +0 -0
  36. {afterpython-0.3.13 → afterpython-0.3.14}/src/afterpython/cli/commands/preview.py +0 -0
  37. {afterpython-0.3.13 → afterpython-0.3.14}/src/afterpython/cli/commands/release.py +0 -0
  38. {afterpython-0.3.13 → afterpython-0.3.14}/src/afterpython/cli/commands/remove.py +0 -0
  39. {afterpython-0.3.13 → afterpython-0.3.14}/src/afterpython/cli/commands/serve.py +0 -0
  40. {afterpython-0.3.13 → afterpython-0.3.14}/src/afterpython/cli/commands/start.py +0 -0
  41. {afterpython-0.3.13 → afterpython-0.3.14}/src/afterpython/cli/commands/update.py +0 -0
  42. {afterpython-0.3.13 → afterpython-0.3.14}/src/afterpython/cli/main.py +0 -0
  43. {afterpython-0.3.13 → afterpython-0.3.14}/src/afterpython/const.py +0 -0
  44. {afterpython-0.3.13 → afterpython-0.3.14}/src/afterpython/main.py +0 -0
  45. {afterpython-0.3.13 → afterpython-0.3.14}/src/afterpython/pcu.py +0 -0
  46. {afterpython-0.3.13 → afterpython-0.3.14}/src/afterpython/py.typed +0 -0
  47. {afterpython-0.3.13 → afterpython-0.3.14}/src/afterpython/templates/ci-workflow-template.yml +0 -0
  48. {afterpython-0.3.13 → afterpython-0.3.14}/src/afterpython/templates/cz-template.toml +0 -0
  49. {afterpython-0.3.13 → afterpython-0.3.14}/src/afterpython/templates/dependabot-template.yml +0 -0
  50. {afterpython-0.3.13 → afterpython-0.3.14}/src/afterpython/templates/deploy-workflow-template.yml +0 -0
  51. {afterpython-0.3.13 → afterpython-0.3.14}/src/afterpython/templates/pdoc/frame.html.jinja2 +0 -0
  52. {afterpython-0.3.13 → afterpython-0.3.14}/src/afterpython/templates/pre-commit-config-template.yaml +0 -0
  53. {afterpython-0.3.13 → afterpython-0.3.14}/src/afterpython/templates/release-workflow-template.yml +0 -0
  54. {afterpython-0.3.13 → afterpython-0.3.14}/src/afterpython/templates/ruff-template.toml +0 -0
  55. {afterpython-0.3.13 → afterpython-0.3.14}/src/afterpython/tools/_afterpython.py +0 -0
  56. {afterpython-0.3.13 → afterpython-0.3.14}/src/afterpython/tools/_git.py +0 -0
  57. {afterpython-0.3.13 → afterpython-0.3.14}/src/afterpython/tools/branch_rules.py +0 -0
  58. {afterpython-0.3.13 → afterpython-0.3.14}/src/afterpython/tools/commitizen.py +0 -0
  59. {afterpython-0.3.13 → afterpython-0.3.14}/src/afterpython/tools/github_actions.py +0 -0
  60. {afterpython-0.3.13 → afterpython-0.3.14}/src/afterpython/tools/myst.py +0 -0
  61. {afterpython-0.3.13 → afterpython-0.3.14}/src/afterpython/tools/pre_commit.py +0 -0
  62. {afterpython-0.3.13 → afterpython-0.3.14}/src/afterpython/tools/pyproject.py +0 -0
  63. {afterpython-0.3.13 → afterpython-0.3.14}/src/afterpython/utils.py +0 -0
  64. {afterpython-0.3.13 → afterpython-0.3.14}/src/afterpython/version.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: afterpython
3
- Version: 0.3.13
3
+ Version: 0.3.14
4
4
  Summary: CLI tool to streamline Python package maintenance and generate a dedicated project website
5
5
  Keywords: package-maintenance,project-website,static-site-generator,developer-tools,documentation,cli-tool,ci/cd,mystmd,semantic-release,python-packaging
6
6
  Author: Stephen Yau
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "afterpython"
3
- version = "0.3.13"
3
+ version = "0.3.14"
4
4
  description = "CLI tool to streamline Python package maintenance and generate a dedicated project website"
5
5
  license = "Apache-2.0"
6
6
  authors = [
@@ -37,19 +37,23 @@ def standardize_data(content_type: tContentType, content: dict) -> dict:
37
37
  is_featured = content["slug"] == featured_post or content["location"].endswith(
38
38
  featured_post
39
39
  )
40
+ # `frontmatter.get(key, default)` only falls back when the key is missing —
41
+ # but YAML parses `thumbnail:` (empty value) as None, which then propagates
42
+ # into convert_paths() and crashes the string concatenation. Coerce here
43
+ # so downstream sees "" / [] consistently.
40
44
  return {
41
45
  "version": content["version"],
42
46
  "slug": content["slug"],
43
47
  "location": content["location"],
44
48
  "featured": is_featured,
45
49
  # 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", []),
50
+ "title": frontmatter.get("title") or "",
51
+ "description": frontmatter.get("description") or "",
52
+ "tags": frontmatter.get("tags") or [],
53
+ "date": frontmatter.get("date") or "",
54
+ "thumbnail": frontmatter.get("thumbnail") or "",
55
+ "thumbnailOptimized": frontmatter.get("thumbnailOptimized") or "",
56
+ "authors": frontmatter.get("authors") or [],
53
57
  }
54
58
 
55
59
 
@@ -63,11 +67,15 @@ def convert_paths(content_type: tContentType, output_file: Path):
63
67
 
64
68
  for item in content:
65
69
  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
- )
70
+ item["thumbnailOptimized"] = item.get("thumbnail") or ""
71
+ # Skip path prefixing when there's no thumbnail — prepending to "" would
72
+ # produce a bogus "/blog/build" URL that 404s in the frontend.
73
+ if item.get("thumbnail"):
74
+ item["thumbnail"] = f"/{content_type}/build" + item["thumbnail"]
75
+ if item.get("thumbnailOptimized"):
76
+ item["thumbnailOptimized"] = (
77
+ f"/{content_type}/build" + item["thumbnailOptimized"]
78
+ )
71
79
 
72
80
  with open(output_file, "w", encoding="utf-8") as f:
73
81
  json.dump(content, f, indent=2, ensure_ascii=False)
@@ -5,7 +5,12 @@ from typing import TYPE_CHECKING
5
5
  if TYPE_CHECKING:
6
6
  from afterpython._typing import NodeEnv
7
7
 
8
+ import contextlib
9
+ import os
10
+ import queue
11
+ import signal
8
12
  import subprocess
13
+ import threading
9
14
  import time
10
15
 
11
16
  import click
@@ -16,6 +21,76 @@ from afterpython.const import CONTENT_TYPES
16
21
  from afterpython.utils import find_available_port, find_node_env
17
22
 
18
23
 
24
+ def _stream_process_output(
25
+ proc: subprocess.Popen,
26
+ output_queue: queue.Queue[str | None],
27
+ ready_event: threading.Event,
28
+ ):
29
+ """Forward process output to the terminal and queue startup lines."""
30
+ if proc.stdout is None:
31
+ if not ready_event.is_set():
32
+ output_queue.put(None)
33
+ return
34
+
35
+ for line in proc.stdout:
36
+ click.echo(line, nl=False)
37
+ if not ready_event.is_set():
38
+ output_queue.put(line)
39
+
40
+ if not ready_event.is_set():
41
+ output_queue.put(None)
42
+
43
+
44
+ def _wait_for_myst_server(
45
+ proc: subprocess.Popen,
46
+ content_type: str,
47
+ port: int,
48
+ output_queue: queue.Queue[str | None],
49
+ ready_event: threading.Event,
50
+ timeout: int = 300,
51
+ ):
52
+ """Wait until MyST reports that its dev server has started."""
53
+ deadline = time.monotonic() + timeout
54
+ ready_markers = (
55
+ f"Server started on port {port}",
56
+ f"http://localhost:{port}",
57
+ f"http://127.0.0.1:{port}",
58
+ )
59
+
60
+ try:
61
+ while time.monotonic() < deadline:
62
+ returncode = proc.poll()
63
+ if returncode is not None and output_queue.empty():
64
+ if returncode == 0:
65
+ click.echo(
66
+ f"MyST {content_type} server exited before becoming ready"
67
+ )
68
+ return
69
+ raise Exit(returncode)
70
+
71
+ try:
72
+ line = output_queue.get(timeout=0.5)
73
+ except queue.Empty:
74
+ continue
75
+
76
+ if line is None:
77
+ returncode = proc.poll()
78
+ if returncode not in (None, 0):
79
+ raise Exit(returncode)
80
+ click.echo(f"MyST {content_type} server exited before becoming ready")
81
+ return
82
+
83
+ if any(marker in line for marker in ready_markers):
84
+ click.echo(f"MyST {content_type} server is ready")
85
+ return
86
+
87
+ raise click.ClickException(
88
+ f"Timed out waiting for MyST {content_type} server on port {port}"
89
+ )
90
+ finally:
91
+ ready_event.set()
92
+
93
+
19
94
  @click.command(
20
95
  add_help_option=False, # disable click's --help option so that ap dev --help can work
21
96
  context_settings=dict(
@@ -116,15 +191,26 @@ def dev(
116
191
  paths = ctx.obj["paths"]
117
192
 
118
193
  def cleanup_processes():
119
- """Clean up all MyST server processes"""
194
+ """Clean up all MyST server processes.
195
+
196
+ Each spawned 'ap {content_type}' is its own session leader (start_new_session=True
197
+ below), so its grandchild `myst start` shares the same process group. proc.terminate()
198
+ would only SIGTERM the outer Python wrapper — which is blocked in subprocess.run and
199
+ doesn't forward the signal — leaving myst orphaned. Signal the whole group instead.
200
+ """
120
201
  click.echo("\nShutting down MyST servers...")
121
202
  for proc in myst_processes:
122
203
  try:
123
- proc.terminate()
204
+ pgid = os.getpgid(proc.pid)
205
+ except ProcessLookupError:
206
+ continue
207
+ try:
208
+ os.killpg(pgid, signal.SIGTERM)
124
209
  proc.wait(timeout=5)
125
210
  except subprocess.TimeoutExpired:
126
- proc.kill()
127
- except Exception:
211
+ with contextlib.suppress(ProcessLookupError):
212
+ os.killpg(pgid, signal.SIGKILL)
213
+ except ProcessLookupError:
128
214
  pass
129
215
 
130
216
  # Determine which content types to run
@@ -175,6 +261,8 @@ def dev(
175
261
  f"PUBLIC_{content_type.upper()}_URL=http://localhost:{myst_port}\n"
176
262
  )
177
263
 
264
+ output_queue: queue.Queue[str | None] = queue.Queue()
265
+ ready_event = threading.Event()
178
266
  myst_process = subprocess.Popen(
179
267
  [
180
268
  "ap",
@@ -184,15 +272,23 @@ def dev(
184
272
  *(["--execute"] if execute else []),
185
273
  *ctx.args,
186
274
  ],
187
- # stdout=subprocess.DEVNULL, # Suppress output (optional)
188
- # stderr=subprocess.DEVNULL, # Suppress errors (optional)
275
+ # New session so cleanup_processes can SIGTERM the whole group and
276
+ # take the grandchild `myst start` down with the wrapper.
277
+ start_new_session=True,
278
+ stdout=subprocess.PIPE,
279
+ stderr=subprocess.STDOUT,
280
+ text=True,
281
+ bufsize=1,
189
282
  )
190
283
  myst_processes.append(myst_process)
191
-
192
- # NOTE: MyST internally uses additional ports beyond the one specified by --port.
193
- # Without this delay, multiple MyST servers may attempt to bind to the same internal port,
194
- # causing "address already in use" errors.
195
- time.sleep(3)
284
+ threading.Thread(
285
+ target=_stream_process_output,
286
+ args=(myst_process, output_queue, ready_event),
287
+ daemon=True,
288
+ ).start()
289
+ _wait_for_myst_server(
290
+ myst_process, content_type, myst_port, output_queue, ready_event
291
+ )
196
292
 
197
293
  postbuild(dev_build=True)
198
294
 
@@ -208,7 +304,7 @@ def dev(
208
304
  click.echo(
209
305
  "Skipping website dev server (--no-website flag). Run 'pnpm dev' manually in afterpython/_website/ with your custom options."
210
306
  )
211
- if enabled_content_types:
307
+ if myst_processes:
212
308
  # Keep the process running to maintain MyST servers
213
309
  click.echo("Press Ctrl+C to stop MyST servers...")
214
310
  while True:
@@ -1,4 +1,5 @@
1
1
  from datetime import datetime
2
+ from pathlib import PurePosixPath
2
3
 
3
4
  import click
4
5
  from pyproject_metadata import StandardMetadata
@@ -8,6 +9,13 @@ import afterpython as ap
8
9
  from afterpython.utils import convert_author_name_to_id, normalize_static_path
9
10
 
10
11
 
12
+ def _myst_favicon_path(path: str) -> str:
13
+ """Convert a website favicon path to MyST's required .ico favicon path."""
14
+ if not path:
15
+ return ""
16
+ return str(PurePosixPath(path).with_suffix(".ico"))
17
+
18
+
11
19
  def _sync_authors_yml(authors: list[tuple[str, str | None]]):
12
20
  """Sync authors.yml with authors in pyproject.toml"""
13
21
  from afterpython._io.yaml import read_yaml
@@ -58,10 +66,7 @@ def sync():
58
66
  website_favicon = normalize_static_path(
59
67
  str(_from_tomlkit(afterpython.get("website", {})).get("favicon", ""))
60
68
  )
61
- if website_favicon and not website_favicon.lower().endswith(".ico"):
62
- raise ValueError(
63
- f"Invalid favicon '{website_favicon}': mystmd only supports .ico files."
64
- )
69
+ myst_favicon = _myst_favicon_path(website_favicon)
65
70
  website_logo = normalize_static_path(
66
71
  str(_from_tomlkit(afterpython.get("website", {})).get("logo", ""))
67
72
  )
@@ -133,7 +138,7 @@ def sync():
133
138
  "site": {
134
139
  "title": title,
135
140
  "options": {
136
- "favicon": "../static" + website_favicon if website_favicon else "",
141
+ "favicon": "../static" + myst_favicon if myst_favicon else "",
137
142
  "logo": "../static" + website_logo if website_logo else "",
138
143
  "logo_dark": "../static" + website_logo_dark
139
144
  if website_logo_dark
File without changes