+ :::
+Plain ``` fences still work via codehilite. Callouts use the admonition
+extension (note/tip/warning + custom 'spring').
+"""
+from __future__ import annotations
+import re
+from pathlib import Path
+import markdown
+from markdown.preprocessors import Preprocessor
+from markdown.extensions import Extension
+from pygments import highlight
+from pygments.lexers import get_lexer_by_name, guess_lexer
+from pygments.util import ClassNotFound
+from pygments.formatters import HtmlFormatter
+
+_FMT = HtmlFormatter(nowrap=True) # tokens only; we supply
+_EXT = {"py":"python","yaml":"yaml","yml":"yaml","toml":"toml","json":"json",
+ "sh":"bash","bash":"bash","sql":"sql","xml":"xml","html":"html","txt":"text"}
+
+class _Directives(Preprocessor):
+ FIG = re.compile(r'^:::\s*figure\s+(?P\S+)\s*\|\s*(?P.+?)\s*$')
+ LST = re.compile(r'^:::\s*listing\s+(?P[^|]+?)\s*(?:\|\s*(?P.+?))?\s*$')
+
+ def __init__(self, md, base: Path):
+ super().__init__(md)
+ self.base = Path(base)
+
+ def run(self, lines):
+ out, i = [], 0
+ while i < len(lines):
+ mf, ml = self.FIG.match(lines[i]), self.LST.match(lines[i])
+ if mf:
+ out.append(self.md.htmlStash.store(self._figure(mf["src"], mf["cap"])))
+ i += 1
+ elif ml:
+ body, i = [], i + 1
+ while i < len(lines) and lines[i].strip() != ":::":
+ body.append(lines[i]); i += 1
+ i += 1
+ out.append(self.md.htmlStash.store(
+ self._listing(ml["file"].strip(), ml["cap"], "\n".join(body))))
+ else:
+ out.append(lines[i]); i += 1
+ return out
+
+ def _figure(self, src: str, cap: str) -> str:
+ svg = (self.base / src).read_text(encoding="utf-8").strip()
+ svg = re.sub(r'^<\?xml[^>]*\?>\s*', "", svg)
+ return f'{svg}{cap}'
+
+ def _listing(self, file_label: str, cap: str | None, code: str) -> str:
+ lang = _EXT.get(file_label.rsplit(".", 1)[-1].lower(), "python") if "." in file_label else "python"
+ try:
+ lexer = get_lexer_by_name(lang)
+ except ClassNotFound:
+ lexer = guess_lexer(code)
+ body = highlight(code, lexer, _FMT)
+ cap_html = f'{cap}
' if cap else ""
+ return (f'{file_label}'
+ f'
{body}{cap_html}
')
+
+class PyflyExtension(Extension):
+ def __init__(self, base: Path, **kw):
+ self.base = base
+ super().__init__(**kw)
+ def extendMarkdown(self, md):
+ md.preprocessors.register(_Directives(md, self.base), "pyfly_directives", 28)
+
+def render_markdown(text: str, base: Path) -> str:
+ md = markdown.Markdown(
+ extensions=["extra", "admonition", "toc", "sane_lists", "codehilite",
+ PyflyExtension(base)],
+ extension_configs={"codehilite": {"css_class": "code", "guess_lang": False}},
+ output_format="html5",
+ )
+ return md.convert(text)
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `book/.venv/bin/python -m pytest tests/book/test_md.py -q`
+Expected: PASS (3 passed).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add book/build/md.py tests/book/test_md.py
+git commit -m "feat(book): markdown pipeline — listings, SVG figures, callouts"
+```
+
+---
+
+## Task 4: EPUB3 assembler `epub.py` (TDD)
+
+**Files:**
+- Create: `book/build/epub.py`
+- Test: `tests/book/test_epub.py`
+
+- [ ] **Step 1: Write the failing test**
+
+```python
+# tests/book/test_epub.py
+import zipfile
+from pathlib import Path
+import sys
+sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "book" / "build"))
+from epub import EpubBuilder, Doc # noqa: E402
+
+def test_epub_is_valid_ocf(tmp_path):
+ out = tmp_path / "b.epub"
+ b = EpubBuilder(title="T", author="A", language="en", identifier="urn:uuid:1",
+ css=["body{color:#000}"])
+ b.add_doc(Doc(id="c1", title="One", xhtml_body="One
", in_nav=True))
+ b.build(out)
+ z = zipfile.ZipFile(out)
+ # mimetype must be first entry, stored (uncompressed), exact bytes
+ first = z.infolist()[0]
+ assert first.filename == "mimetype"
+ assert first.compress_type == zipfile.ZIP_STORED
+ assert z.read("mimetype") == b"application/epub+zip"
+ names = set(z.namelist())
+ assert "META-INF/container.xml" in names
+ assert "OEBPS/content.opf" in names
+ assert "OEBPS/nav.xhtml" in names
+ assert "OEBPS/c1.xhtml" in names
+ assert "One" in z.read("OEBPS/nav.xhtml").decode()
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `book/.venv/bin/python -m pytest tests/book/test_epub.py -q`
+Expected: FAIL (ModuleNotFoundError: epub).
+
+- [ ] **Step 3: Implement `book/build/epub.py`**
+
+```python
+"""Minimal, correct EPUB3 (OCF) assembler using only the standard library."""
+from __future__ import annotations
+import zipfile
+from dataclasses import dataclass, field
+from datetime import datetime, timezone
+from pathlib import Path
+from xml.sax.saxutils import escape
+
+_MEDIA = {".svg":"image/svg+xml",".png":"image/png",".jpg":"image/jpeg",
+ ".jpeg":"image/jpeg",".css":"text/css",".woff2":"font/woff2",".woff":"font/woff"}
+
+@dataclass
+class Doc:
+ id: str
+ title: str
+ xhtml_body: str
+ in_nav: bool = True
+
+@dataclass
+class Asset:
+ id: str
+ href: str # relative to OEBPS, e.g. "art/cover.png"
+ data: bytes
+ media_type: str
+ properties: str = "" # e.g. "cover-image"
+
+@dataclass
+class EpubBuilder:
+ title: str
+ author: str
+ language: str
+ identifier: str
+ css: list[str] = field(default_factory=list)
+ docs: list[Doc] = field(default_factory=list)
+ assets: list[Asset] = field(default_factory=list)
+ cover_asset_id: str | None = None
+
+ def add_doc(self, d: Doc) -> None: self.docs.append(d)
+ def add_asset(self, a: Asset) -> None: self.assets.append(a)
+
+ def add_file(self, path: Path, href: str, aid: str, properties: str = "") -> Asset:
+ a = Asset(id=aid, href=href, data=Path(path).read_bytes(),
+ media_type=_MEDIA[Path(href).suffix.lower()], properties=properties)
+ self.assets.append(a)
+ if properties == "cover-image": self.cover_asset_id = aid
+ return a
+
+ def _xhtml(self, d: Doc) -> str:
+ links = "\n".join(f''
+ for i in range(len(self.css)))
+ return (f'\n'
+ f'\n'
+ f'{escape(d.title)}\n{links}\n\n'
+ f'\n\n')
+
+ def _nav(self) -> str:
+ items = "\n".join(f'{escape(d.title)}'
+ for d in self.docs if d.in_nav)
+ return ('\n'
+ ''
+ 'Contents'
+ f''
+ '')
+
+ def _opf(self) -> str:
+ modified = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
+ man = [' ']
+ for i in range(len(self.css)):
+ man.append(f' ')
+ for d in self.docs:
+ man.append(f' ')
+ for a in self.assets:
+ props = f' properties="{a.properties}"' if a.properties else ""
+ man.append(f' ')
+ spine = "".join(f'' for d in self.docs)
+ cover_meta = (f''
+ if self.cover_asset_id else "")
+ return (f'\n'
+ f'\n'
+ f''
+ f'{escape(self.identifier)}'
+ f'{escape(self.title)}'
+ f'{escape(self.author)}'
+ f'{self.language}'
+ f'{modified}{cover_meta}'
+ f'{"".join(man)}'
+ f'{spine}')
+
+ def build(self, out: Path) -> Path:
+ out = Path(out); out.parent.mkdir(parents=True, exist_ok=True)
+ with zipfile.ZipFile(out, "w") as z:
+ # 1) mimetype FIRST and STORED
+ zi = zipfile.ZipInfo("mimetype")
+ zi.compress_type = zipfile.ZIP_STORED
+ z.writestr(zi, "application/epub+zip")
+ # 2) container
+ z.writestr("META-INF/container.xml",
+ '\n'
+ ''
+ '',
+ zipfile.ZIP_DEFLATED)
+ # 3) css, docs, nav, opf
+ for i, css in enumerate(self.css):
+ z.writestr(f"OEBPS/style{i}.css", css, zipfile.ZIP_DEFLATED)
+ for d in self.docs:
+ z.writestr(f"OEBPS/{d.id}.xhtml", self._xhtml(d), zipfile.ZIP_DEFLATED)
+ z.writestr("OEBPS/nav.xhtml", self._nav(), zipfile.ZIP_DEFLATED)
+ z.writestr("OEBPS/content.opf", self._opf(), zipfile.ZIP_DEFLATED)
+ # 4) assets
+ for a in self.assets:
+ z.writestr(f"OEBPS/{a.href}", a.data, zipfile.ZIP_DEFLATED)
+ return out
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `book/.venv/bin/python -m pytest tests/book/test_epub.py -q`
+Expected: PASS (1 passed).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add book/build/epub.py tests/book/test_epub.py
+git commit -m "feat(book): stdlib EPUB3 assembler"
+```
+
+---
+
+## Task 5: Code verifier `verify_code.py` (TDD)
+
+**Files:**
+- Create: `book/build/verify_code.py`
+- Test: `tests/book/test_verify_code.py`
+
+**Purpose:** extract python listings and confirm they at least *parse*; for listings tagged
+`verify`, also confirm referenced `pyfly.*` imports resolve in the framework env.
+
+- [ ] **Step 1: Write the failing test**
+
+```python
+# tests/book/test_verify_code.py
+from pathlib import Path
+import sys
+sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "book" / "build"))
+from verify_code import extract_python_listings, check_syntax # noqa: E402
+
+def test_extract_and_syntax(tmp_path):
+ md = ("intro\n"
+ "::: listing a.py | L1\n"
+ "x = 1\n"
+ ":::\n"
+ "::: listing b.txt | not python\n"
+ "noise\n"
+ ":::\n")
+ f = tmp_path / "c.md"; f.write_text(md)
+ listings = extract_python_listings(f)
+ assert [l.label for l in listings] == ["a.py"] # only .py extracted
+ ok, err = check_syntax(listings[0].code)
+ assert ok and err is None
+ bad_ok, bad_err = check_syntax("def (:")
+ assert not bad_ok and "Syntax" in bad_err
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `book/.venv/bin/python -m pytest tests/book/test_verify_code.py -q`
+Expected: FAIL (ModuleNotFoundError: verify_code).
+
+- [ ] **Step 3: Implement `book/build/verify_code.py`**
+
+```python
+"""Extract python code listings from manuscript and verify they parse.
+
+Used by build.py (warn-only) and runnable standalone:
+ book/.venv/bin/python book/build/verify_code.py book/manuscript
+"""
+from __future__ import annotations
+import ast, re, sys
+from dataclasses import dataclass
+from pathlib import Path
+
+_START = re.compile(r'^:::\s*listing\s+(?P[^|]+?)\s*(?:\|\s*(?P.+?))?\s*$')
+
+@dataclass
+class Listing:
+ label: str
+ code: str
+ source: Path
+ line: int
+
+def extract_python_listings(md_file: Path) -> list[Listing]:
+ out: list[Listing] = []
+ lines = Path(md_file).read_text(encoding="utf-8").splitlines()
+ i = 0
+ while i < len(lines):
+ m = _START.match(lines[i])
+ if not m:
+ i += 1; continue
+ label, start = m["file"].strip(), i + 1
+ body, i = [], i + 1
+ while i < len(lines) and lines[i].strip() != ":::":
+ body.append(lines[i]); i += 1
+ i += 1
+ if label.endswith(".py"):
+ out.append(Listing(label, "\n".join(body), Path(md_file), start))
+ return out
+
+def check_syntax(code: str) -> tuple[bool, str | None]:
+ # allow elided bodies written as '...'; reject real syntax errors
+ try:
+ ast.parse(code)
+ return True, None
+ except SyntaxError as e:
+ return False, f"SyntaxError: {e.msg} (line {e.lineno})"
+
+def main(root: str) -> int:
+ files = sorted(Path(root).rglob("*.md"))
+ failures = 0
+ for f in files:
+ for lst in extract_python_listings(f):
+ ok, err = check_syntax(lst.code)
+ if not ok:
+ failures += 1
+ print(f"FAIL {f}:{lst.line} [{lst.label}] {err}")
+ print(f"verify_code: {failures} failing listing(s) across {len(files)} file(s)")
+ return 1 if failures else 0
+
+if __name__ == "__main__":
+ raise SystemExit(main(sys.argv[1] if len(sys.argv) > 1 else "book/manuscript"))
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `book/.venv/bin/python -m pytest tests/book/test_verify_code.py -q`
+Expected: PASS (1 passed).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add book/build/verify_code.py tests/book/test_verify_code.py
+git commit -m "feat(book): code-listing extractor + syntax verifier"
+```
+
+---
+
+## Task 6: Cover generator `gen_cover.py`
+
+**Files:**
+- Create: `book/build/gen_cover.py`
+- Create (output): `book/art/cover.svg`, `book/art/cover.png`
+- Create: `book/art/logo/pyfly-logo.png` (copied from `assets/`)
+
+- [ ] **Step 1: Copy the logo into the book art tree**
+
+```bash
+mkdir -p book/art/logo book/art/mascot book/art/openers book/art/figures
+cp assets/pyfly-logo.png book/art/logo/pyfly-logo.png
+```
+
+- [ ] **Step 2: Implement `book/build/gen_cover.py`**
+
+Composes the Daylight cover as a self-contained SVG (logo embedded as a data URI so it
+travels into EPUB/PDF without external refs), then rasterizes `cover.png` for the EPUB cover.
+
+```python
+"""Generate book/art/cover.svg (Daylight, logo embedded) and cover.png."""
+from __future__ import annotations
+import base64
+from pathlib import Path
+import cairosvg
+
+ART = Path(__file__).resolve().parents[1] / "art"
+W, H = 1500, 2100 # 5:7 cover
+
+def build_svg() -> str:
+ logo_b64 = base64.b64encode((ART / "logo" / "pyfly-logo.png").read_bytes()).decode()
+ logo = f"data:image/png;base64,{logo_b64}"
+ # logo native ratio 1648:748 -> place at width 1100, centered, upper area
+ lw = 1100; lh = int(lw * 748 / 1648); lx = (W - lw)//2; ly = 360
+ band_y = int(H * 0.64)
+ return f''''''
+
+def main() -> None:
+ svg = build_svg()
+ (ART / "cover.svg").write_text(svg, encoding="utf-8")
+ cairosvg.svg2png(bytestring=svg.encode(), write_to=str(ART / "cover.png"),
+ output_width=W, output_height=H)
+ print("wrote cover.svg and cover.png")
+
+if __name__ == "__main__":
+ main()
+```
+
+- [ ] **Step 3: Generate the cover**
+
+Run: `DYLD_FALLBACK_LIBRARY_PATH="$(brew --prefix)/lib" book/.venv/bin/python book/build/gen_cover.py`
+Expected: `wrote cover.svg and cover.png`; `book/art/cover.png` exists and is > 50 KB.
+
+- [ ] **Step 4: Visually confirm the cover**
+
+Run: `open book/art/cover.png`
+Expected: Daylight cover with the real logo + green "by Example" band. (Iterate on `gen_cover.py` spacing/sizes if needed, then regenerate.)
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add book/build/gen_cover.py book/art/logo/pyfly-logo.png book/art/cover.svg book/art/cover.png
+git commit -m "feat(book): cover generator (Daylight + official logo) + assets"
+```
+
+---
+
+## Task 7: Mascot, opener, and Chapter 1 figures (SVG)
+
+**Files:**
+- Create: `book/art/mascot/sparky.svg`, `book/art/openers/ch01.svg`,
+ `book/art/figures/01-choice.svg`, `book/art/figures/01-layers.svg`
+
+- [ ] **Step 1: Write `book/art/mascot/sparky.svg`** (reusable chapter-opener badge)
+
+```svg
+
+```
+
+- [ ] **Step 2: Write `book/art/openers/ch01.svg`**
+
+A wide opener (viewBox 0 0 720 280): Sparky on the left, and three small "stack choice" cards
+unifying into one PyFly box on the right (visualizes "infinite choice → cohesion"). Use the
+Friendly-Flat palette (`#43b02a`, `#5fd13a`, `#eaf6df`, `#ffc24b`). Keep shapes solid, rounded,
+with soft shadows. (Concrete coordinates authored during execution; must render < 720px wide.)
+
+- [ ] **Step 3: Write `book/art/figures/01-choice.svg`** — left: scattered library logos/boxes
+labeled "FastAPI? Flask? SQLAlchemy? Kafka? …"; right: one tidy "PyFly" box. Arrow between.
+
+- [ ] **Step 4: Write `book/art/figures/01-layers.svg`** — the four module layers (Foundation,
+Application, Infrastructure, Cross-Cutting) as stacked rounded bands with labels, matching the
+spec's palette.
+
+- [ ] **Step 5: Validate the SVGs parse**
+
+Run:
+```bash
+book/.venv/bin/python - <<'PY'
+import xml.dom.minidom, glob
+for f in glob.glob("book/art/**/*.svg", recursive=True):
+ xml.dom.minidom.parse(f); print("ok", f)
+PY
+```
+Expected: `ok` for every SVG.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add book/art/mascot book/art/openers book/art/figures
+git commit -m "feat(book): Sparky mascot, Ch1 opener + figures (Friendly Flat)"
+```
+
+---
+
+## Task 8: Front matter manuscript
+
+**Files:**
+- Create: `book/manuscript/00-front/00-title.md`, `00-copyright.md`, `00-preface.md`,
+ `00-conventions.md`, `00-meet-sparky.md`
+
+- [ ] **Step 1: Write `00-title.md`**
+
+```markdown
+::: figure ../art/cover.svg |
+
+# PyFly by Example
+#### Event-Driven Python Microservices with the Firefly Framework
+
+Firefly Software Foundation
+```
+
+- [ ] **Step 2: Write `00-copyright.md`** — copyright line, Apache-2.0 notice, "first edition
+2026", framework version (26.6.x), and the disclaimer that code is verified against that version.
+
+- [ ] **Step 3: Write `00-preface.md`** — sections: *Who this book is for*, *What you'll build*
+(the Lumen arc), *How this book is organized* (the five parts; reference the roadmap figure),
+*How to use this book* (run every listing), *Conventions in brief*, *Downloading the code*.
+
+- [ ] **Step 4: Write `00-conventions.md`** — explains listing chrome (file tab + caption),
+callout types (Note/Tip/Warning + Spring parity), figure numbering, and the Sparky badge. Include
+one live example of each via the real directives so it renders as documentation-of-itself.
+
+- [ ] **Step 5: Write `00-meet-sparky.md`** — short, fun intro to Sparky (the guide), with the
+mascot figure: `::: figure ../art/mascot/sparky.svg | Sparky, your guide through PyFly.`
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add book/manuscript/00-front/
+git commit -m "content(book): front matter (title, copyright, preface, conventions, Sparky)"
+```
+
+---
+
+## Task 9: Chapter 1 — "Why PyFly?"
+
+**Files:**
+- Create: `book/manuscript/01-why-pyfly.md`
+
+**Voice:** warm, precise, second person; motivate before API. **Anatomy:** opener → intro →
+sections → listings → figures → callouts → recap → exercises. All code is **real PyFly**.
+
+- [ ] **Step 1: Write the chapter front (opener + intro)**
+
+```markdown
+Chapter 1
+
+# Why PyFly? {.chtitle}
+
+::: figure ../art/openers/ch01.svg |
+
+In this chapter you'll meet PyFly, understand the problem it solves, install it, scaffold the
+**Lumen** wallet service, and run it — seeing structured logging, a health endpoint, and live API
+docs with zero boilerplate.
+```
+
+- [ ] **Step 2: Write "The cohesion problem" section** + figure
+`::: figure ../art/figures/01-choice.svg | Figure 1.1 — Infinite choice, no cohesion.`
+Prose adapted from the framework's positioning (the two-weeks-of-decisions problem).
+
+- [ ] **Step 3: Write "What is PyFly?" section** + the layers figure
+`::: figure ../art/figures/01-layers.svg | Figure 1.2 — PyFly's four module layers.`
+Include a Spring-parity callout:
+```markdown
+!!! spring "Spring parity"
+ PyFly is the official Python implementation of the Firefly Framework, the Spring-Boot-based
+ platform. If you know Spring Boot, the stereotypes and lifecycle will feel like home.
+```
+
+- [ ] **Step 4: Write "Installing PyFly" section**
+
+```markdown
+::: listing terminal | Listing 1.1 — Install PyFly and scaffold the Lumen service
+pip install pyfly # or: bash install.sh (from the repo)
+pyfly new lumen
+cd lumen
+:::
+```
+
+- [ ] **Step 5: Write "Your first run" section** with the generated app + run + endpoints
+
+```markdown
+::: listing lumen/app.py | Listing 1.2 — The application entry point
+from pyfly.core import pyfly_application, PyFlyApplication
+
+
+@pyfly_application(name="lumen", scan_packages=["lumen"])
+class LumenApp:
+ """The Lumen wallet service."""
+
+
+if __name__ == "__main__":
+ PyFlyApplication(LumenApp).run()
+:::
+
+::: listing terminal | Listing 1.3 — Run it
+pyfly run --reload
+# Banner, structured logs, then: Started lumen in 0.42s (N beans initialized)
+:::
+```
+Then a Note callout pointing at `http://localhost:8080/docs` and `/actuator/health`.
+
+- [ ] **Step 6: Write the "What you built" recap + exercises**
+
+```markdown
+## What you built {.recap}
+
+You installed PyFly, scaffolded **Lumen**, and ran a production-shaped service — structured
+logging, health checks, and OpenAPI docs — without wiring any of it yourself.
+
+## Try it yourself {.exercises}
+
+1. Change the service name and re-run; watch the banner and logs update.
+2. Open `/actuator/health` and `/docs`. What endpoints already exist?
+3. Inspect the generated project layout; map each folder to a layer from Figure 1.2.
+```
+
+- [ ] **Step 7: Verify Chapter 1 listings parse**
+
+Run: `book/.venv/bin/python book/build/verify_code.py book/manuscript`
+Expected: `0 failing listing(s)`.
+
+- [ ] **Step 8: Commit**
+
+```bash
+git add book/manuscript/01-why-pyfly.md
+git commit -m "content(book): Chapter 1 — Why PyFly?"
+```
+
+---
+
+## Task 10: Orchestrator `build.py` + `pdf.py` (wire it all together)
+
+**Files:**
+- Create: `book/build/pdf.py`
+- Create: `book/build/build.py`
+
+- [ ] **Step 1: Implement `book/build/pdf.py`**
+
+```python
+"""Render the assembled book HTML to a PDF via WeasyPrint."""
+from __future__ import annotations
+from pathlib import Path
+from weasyprint import HTML, CSS
+
+def render_pdf(full_html: str, base_url: Path, css_paths: list[Path], out: Path) -> Path:
+ out = Path(out); out.parent.mkdir(parents=True, exist_ok=True)
+ HTML(string=full_html, base_url=str(base_url)).write_pdf(
+ str(out), stylesheets=[CSS(filename=str(p)) for p in css_paths])
+ return out
+```
+
+- [ ] **Step 2: Implement `book/build/build.py`**
+
+```python
+"""Build *PyFly by Example* into EPUB + PDF from book.yaml."""
+from __future__ import annotations
+import sys
+from pathlib import Path
+import yaml # PyYAML ships with the framework env; ensure installed in book/.venv
+
+sys.path.insert(0, str(Path(__file__).resolve().parent))
+from md import render_markdown # noqa: E402
+from epub import EpubBuilder, Doc # noqa: E402
+from pdf import render_pdf # noqa: E402
+
+BOOK = Path(__file__).resolve().parents[1]
+MAN = BOOK / "manuscript"
+THEME = BOOK / "theme"
+DIST = BOOK / "dist"
+
+def _docs_from_manifest(cfg: dict) -> list[tuple[str, str, str, bool]]:
+ """Return (id, title, md_path, in_nav) in reading order; skip missing files."""
+ out = []
+ for fm in cfg.get("front", []):
+ out.append((fm["id"], fm.get("title", fm["id"].title()),
+ str(MAN / fm["file"]), bool(fm.get("nav", True)) and "title" in fm))
+ for part in cfg.get("parts", []):
+ for ch in part["chapters"]:
+ out.append((ch["id"], f'{ch["num"]}. {ch["title"]}', str(MAN / ch["file"]), True))
+ return [(i, t, p, n) for (i, t, p, n) in out if Path(p).exists()]
+
+def main() -> int:
+ cfg = yaml.safe_load((BOOK / "book.yaml").read_text())
+ css_files = [THEME / "book.css"] # @import pulls tokens + pygments
+ css_text = [(THEME / "book.css").read_text(), (THEME / "tokens.css").read_text(),
+ (THEME / "pygments.css").read_text()]
+ docs = _docs_from_manifest(cfg)
+
+ # ---- EPUB ----
+ epub = EpubBuilder(title=cfg["title"], author=cfg["author"], language=cfg["language"],
+ identifier=cfg["identifier"], css=css_text)
+ # cover image first
+ cover_png = BOOK / cfg["cover_png"]
+ if cover_png.exists():
+ epub.add_file(cover_png, "art/cover.png", "cover-img", properties="cover-image")
+ for (cid, title, path, in_nav) in docs:
+ body = render_markdown(Path(path).read_text(encoding="utf-8"), MAN / Path(path).parent.name
+ if False else Path(path).parent)
+ epub.add_doc(Doc(id=cid, title=title, xhtml_body=body, in_nav=in_nav))
+ DIST.mkdir(exist_ok=True)
+ epub.build(DIST / "pyfly-by-example.epub")
+
+ # ---- PDF ----
+ parts_html = []
+ if cover_png.exists():
+ parts_html.append(f'')
+ for (cid, title, path, in_nav) in docs:
+ parts_html.append(render_markdown(Path(path).read_text(encoding="utf-8"), Path(path).parent))
+ full = (""
+ + "\n".join(parts_html) + "")
+ render_pdf(full, base_url=BOOK,
+ css_paths=[THEME / "tokens.css", THEME / "pygments.css",
+ THEME / "book.css", THEME / "print.css"],
+ out=DIST / "pyfly-by-example.pdf")
+ print(f"Built {len(docs)} document(s) -> EPUB + PDF in {DIST}")
+ return 0
+
+if __name__ == "__main__":
+ raise SystemExit(main())
+```
+
+- [ ] **Step 3: Ensure PyYAML is in the build venv**
+
+Run: `book/.venv/bin/python -m pip install -q pyyaml`
+Expected: exit 0.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add book/build/pdf.py book/build/build.py
+git commit -m "feat(book): orchestrator + WeasyPrint PDF renderer"
+```
+
+---
+
+## Task 11: Full build + verification
+
+- [ ] **Step 1: Run the full build**
+
+Run: `bash book/build/run.sh`
+Expected: `Built N document(s) -> EPUB + PDF in .../book/dist`, exit 0.
+
+- [ ] **Step 2: Verify EPUB structure**
+
+Run:
+```bash
+book/.venv/bin/python - <<'PY'
+import zipfile
+z=zipfile.ZipFile("book/dist/pyfly-by-example.epub")
+assert z.infolist()[0].filename=="mimetype"
+print("entries:", len(z.namelist()))
+print("has opf/nav:", "OEBPS/content.opf" in z.namelist(), "OEBPS/nav.xhtml" in z.namelist())
+print("cover:", "OEBPS/art/cover.png" in z.namelist())
+PY
+```
+Expected: mimetype first; opf/nav/cover present.
+
+- [ ] **Step 3: Verify PDF opens and has pages**
+
+Run:
+```bash
+book/.venv/bin/python - <<'PY'
+import os; p="book/dist/pyfly-by-example.pdf"
+print("pdf bytes:", os.path.getsize(p))
+print("header ok:", open(p,"rb").read(5)==b"%PDF-")
+PY
+open book/dist/pyfly-by-example.pdf
+open book/dist/pyfly-by-example.epub
+```
+Expected: PDF > 100 KB, `%PDF-` header; both open and render the cover + front matter + Chapter 1
+with the Modern styling, highlighted listings, callouts, and figures.
+
+- [ ] **Step 4: Run the full book test suite**
+
+Run: `DYLD_FALLBACK_LIBRARY_PATH="$(brew --prefix)/lib" book/.venv/bin/python -m pytest tests/book -q`
+Expected: all pass.
+
+- [ ] **Step 5: Commit the build outputs are git-ignored — verify and commit any fixes**
+
+```bash
+git status --short # dist/ should NOT appear (ignored)
+git add -A book tests/book
+git commit -m "build(book): Phase 0 vertical slice builds to EPUB + PDF" || echo "nothing to commit"
+```
+
+---
+
+## Self-Review
+
+**Spec coverage:** Phase 0 of the spec (§10) = pipeline + theme + cover + Sparky + diagram kit +
+front matter + Chapter 1 → EPUB+PDF. Tasks 1–11 cover each: build system (1,3,4,5,10),
+theme (2), cover (6), mascot/figures (7), front matter (8), Chapter 1 (9), build+verify (11).
+Later parts (chapters 2–18, appendices) are explicitly **out of scope** for this plan and get their
+own plans, reusing this pipeline unchanged.
+
+**Placeholder scan:** Build code, CSS, EPUB/MD/verifier modules, cover generator, and Sparky SVG
+are fully specified. The three Chapter-1 *figures* (Task 7 steps 2–4) and prose (Task 9) are
+content authored during execution against an explicit spec (sections, captions, real listings) —
+this is intended creative work, not a code placeholder. All code steps include complete code.
+
+**Type/name consistency:** `render_markdown(text, base)` (md.py) is called consistently in
+build.py and tests. `EpubBuilder`/`Doc`/`add_file(...properties="cover-image")` match between
+epub.py, its test, and build.py. `extract_python_listings`/`check_syntax` match verify_code.py and
+its test. The `::: listing`/`::: figure` directive grammar is identical in md.py and verify_code.py.
+
+---
+
+## Notes for later phases (not part of this plan)
+
+- Each subsequent Part = one plan: write chapters + opener/figures, append to `book.yaml`, rebuild,
+ review. The pipeline, theme, and assemblers do not change.
+- Final phase: EPUB validation (epubcheck if installed), term index, full code-verification pass.
diff --git a/pyproject.toml b/pyproject.toml
index d16b0bc4..2be93607 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -7,7 +7,7 @@ name = "pyfly"
# CalVer YY.MM.PATCH — package metadata uses PEP 440 normalized form (26.5.4);
# git tag, GitHub release and human-readable display use leading-zero form
# (v26.05.04) to match the Java/.NET/Go siblings.
-version = "26.6.40"
+version = "26.6.41"
description = "The official Python implementation of the Firefly Framework — DI, CQRS, EDA, hexagonal architecture, and more."
readme = "README.md"
license = "Apache-2.0"
diff --git a/src/pyfly/__init__.py b/src/pyfly/__init__.py
index 5e259781..2029e2c4 100644
--- a/src/pyfly/__init__.py
+++ b/src/pyfly/__init__.py
@@ -13,4 +13,4 @@
# limitations under the License.
"""PyFly — Enterprise Python Framework."""
-__version__ = "26.06.40"
+__version__ = "26.06.41"
diff --git a/src/pyfly/context/__init__.py b/src/pyfly/context/__init__.py
index 03473931..8c517f84 100644
--- a/src/pyfly/context/__init__.py
+++ b/src/pyfly/context/__init__.py
@@ -28,6 +28,7 @@
from pyfly.context.events import (
ApplicationEvent,
ApplicationEventBus,
+ ApplicationEventPublisher,
ApplicationReadyEvent,
ContextClosedEvent,
ContextRefreshedEvent,
@@ -41,6 +42,7 @@
"ApplicationContext",
"ApplicationEvent",
"ApplicationEventBus",
+ "ApplicationEventPublisher",
"ApplicationReadyEvent",
"BeanPostProcessor",
"ContextClosedEvent",
diff --git a/src/pyfly/context/application_context.py b/src/pyfly/context/application_context.py
index eb919857..0a7ff6c4 100644
--- a/src/pyfly/context/application_context.py
+++ b/src/pyfly/context/application_context.py
@@ -38,6 +38,7 @@
from pyfly.context.events import (
ApplicationEvent,
ApplicationEventBus,
+ ApplicationEventPublisher,
ApplicationReadyEvent,
ContextClosedEvent,
ContextRefreshedEvent,
@@ -80,6 +81,10 @@ def __init__(self, config: Config) -> None:
self._container._registrations[Config].instance = config
self._container.register(Container, scope=Scope.SINGLETON)
self._container._registrations[Container].instance = self._container
+ # Injectable event publisher (Spring ApplicationEventPublisher) — beans can fire
+ # lifecycle or arbitrary domain events into the bus.
+ self._container.register(ApplicationEventPublisher, scope=Scope.SINGLETON)
+ self._container._registrations[ApplicationEventPublisher].instance = ApplicationEventPublisher(self._event_bus)
# ------------------------------------------------------------------
# Bean registration
@@ -688,13 +693,15 @@ def _wire_app_event_listeners(self) -> None:
# return annotation must not be mistaken for the event (audit #119).
hints = typing.get_type_hints(method)
hints.pop("return", None)
- event_type: type[ApplicationEvent] | None = None
+ # The first type-annotated parameter is the event type — any type, so a
+ # listener can subscribe to arbitrary domain events, not only ApplicationEvent.
+ event_type: type = ApplicationEvent
for param_type in hints.values():
- if isinstance(param_type, type) and issubclass(param_type, ApplicationEvent):
+ # A concrete class (not typing.Any, which is a class in 3.11+ but is
+ # the "untyped" catch-all here) becomes the subscribed event type.
+ if isinstance(param_type, type) and param_type is not typing.Any:
event_type = param_type
break
- if event_type is None:
- event_type = ApplicationEvent
self._event_bus.subscribe(event_type, method, owner_cls=type(reg.instance))
count += 1
self._wiring_counts["event_listeners"] = count
diff --git a/src/pyfly/context/events.py b/src/pyfly/context/events.py
index 3b10ea59..32e2ce15 100644
--- a/src/pyfly/context/events.py
+++ b/src/pyfly/context/events.py
@@ -54,30 +54,31 @@ class ApplicationEventBus:
def __init__(self) -> None:
self._listeners: dict[
- type[ApplicationEvent],
+ type,
list[tuple[Callable[..., Awaitable[None]], type | None]],
] = {}
def subscribe(
self,
- event_type: type[ApplicationEvent],
+ event_type: type,
listener: Callable[..., Awaitable[None]],
*,
owner_cls: type | None = None,
) -> None:
- """Register a listener for a specific event type."""
+ """Register a listener for a specific event type (any type, not only ApplicationEvent)."""
if event_type not in self._listeners:
self._listeners[event_type] = []
self._listeners[event_type].append((listener, owner_cls))
# Pre-sort so publish() doesn't need to sort per invocation
self._listeners[event_type].sort(key=lambda e: get_order(e[1]) if e[1] else 0)
- async def publish(self, event: ApplicationEvent) -> None:
+ async def publish(self, event: object) -> None:
"""Publish an event to all matching listeners (pre-sorted by @order).
- Listeners may be synchronous (``void``) or coroutine functions; the
- result is awaited only when awaitable, so a plain ``def`` listener does
- not crash startup (audit #115).
+ *event* may be any object — lifecycle ``ApplicationEvent`` subclasses or arbitrary
+ domain events. Listeners may be synchronous (``void``) or coroutine functions; the
+ result is awaited only when awaitable, so a plain ``def`` listener does not crash
+ startup (audit #115).
"""
for event_type, entries in self._listeners.items():
if isinstance(event, event_type):
@@ -85,3 +86,28 @@ async def publish(self, event: ApplicationEvent) -> None:
result = listener(event)
if inspect.isawaitable(result):
await result
+
+
+class ApplicationEventPublisher:
+ """Injectable publisher for firing application events into the context event bus.
+
+ Inject it into any bean and publish lifecycle or arbitrary domain events::
+
+ @service
+ class OrderService:
+ def __init__(self, events: ApplicationEventPublisher) -> None:
+ self._events = events
+
+ async def place(self, order: Order) -> None:
+ await self._events.publish(OrderPlacedEvent(order.id))
+
+ Any ``@app_event_listener`` whose parameter type matches the published event (by
+ ``isinstance``) is invoked. The Spring ``ApplicationEventPublisher`` equivalent.
+ """
+
+ def __init__(self, bus: ApplicationEventBus) -> None:
+ self._bus = bus
+
+ async def publish(self, event: object) -> None:
+ """Publish *event* (any object) to all matching listeners."""
+ await self._bus.publish(event)
diff --git a/tests/context/test_event_publisher.py b/tests/context/test_event_publisher.py
new file mode 100644
index 00000000..80641a38
--- /dev/null
+++ b/tests/context/test_event_publisher.py
@@ -0,0 +1,71 @@
+# Copyright 2026 Firefly Software Foundation.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+"""Injectable ApplicationEventPublisher + arbitrary domain events (v26.06.41)."""
+
+from __future__ import annotations
+
+import pytest
+
+from pyfly.container import service
+from pyfly.context.application_context import ApplicationContext
+from pyfly.context.events import ApplicationEventPublisher, app_event_listener
+from pyfly.core.config import Config
+
+
+class OrderPlaced:
+ """An arbitrary domain event — NOT an ApplicationEvent subclass."""
+
+ def __init__(self, order_id: str) -> None:
+ self.order_id = order_id
+
+
+_received: list[str] = []
+
+
+@service
+class OrderListener:
+ @app_event_listener
+ async def on_order(self, event: OrderPlaced) -> None:
+ _received.append(event.order_id)
+
+
+@service
+class OrderService:
+ def __init__(self, events: ApplicationEventPublisher) -> None:
+ self.events = events
+
+ async def place(self, order_id: str) -> None:
+ await self.events.publish(OrderPlaced(order_id))
+
+
+@pytest.mark.asyncio
+async def test_injectable_publisher_delivers_domain_event() -> None:
+ _received.clear()
+ ctx = ApplicationContext(Config({}))
+ ctx.register_bean(OrderListener)
+ ctx.register_bean(OrderService)
+ await ctx.start()
+
+ svc = ctx.get_bean(OrderService)
+ assert isinstance(svc.events, ApplicationEventPublisher) # publisher was injected
+
+ await svc.place("order-1")
+ assert _received == ["order-1"] # arbitrary domain event reached the @app_event_listener
+
+
+@pytest.mark.asyncio
+async def test_publisher_is_a_singleton_bean() -> None:
+ ctx = ApplicationContext(Config({}))
+ await ctx.start()
+ assert isinstance(ctx.get_bean(ApplicationEventPublisher), ApplicationEventPublisher)
diff --git a/uv.lock b/uv.lock
index 35e354da..21cf5380 100644
--- a/uv.lock
+++ b/uv.lock
@@ -1981,7 +1981,7 @@ wheels = [
[[package]]
name = "pyfly"
-version = "26.6.40"
+version = "26.6.41"
source = { editable = "." }
dependencies = [
{ name = "pydantic" },