From 3091dbda810f85c38844803ae1e81277104a6480 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Contreras=20Guill=C3=A9n?= Date: Sun, 7 Jun 2026 13:38:40 +0200 Subject: [PATCH 1/2] docs(book): Phase 0 implementation plan (build pipeline + Ch.1 vertical slice) --- .../2026-06-07-pyfly-by-example-phase0.md | 1149 +++++++++++++++++ 1 file changed, 1149 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-07-pyfly-by-example-phase0.md diff --git a/docs/superpowers/plans/2026-06-07-pyfly-by-example-phase0.md b/docs/superpowers/plans/2026-06-07-pyfly-by-example-phase0.md new file mode 100644 index 00000000..13b240c7 --- /dev/null +++ b/docs/superpowers/plans/2026-06-07-pyfly-by-example-phase0.md @@ -0,0 +1,1149 @@ +# PyFly by Example — Phase 0 (Build Pipeline + Vertical Slice) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Stand up the complete book build system and produce a polished, illustrated **EPUB + PDF** containing the front matter and a fully written, illustrated **Chapter 1** — the vertical slice that locks voice, formatting, and the toolchain. + +**Architecture:** Markdown manuscript → custom `markdown` pipeline (Pygments listings + admonition callouts + inlined SVG figures) → HTML, then two renderers: a stdlib `zipfile` **EPUB3** assembler (embeds SVG, raster cover) and **WeasyPrint** for the print-style **PDF**, both driven by one `book.yaml` and sharing one CSS theme. All illustrations are hand-authored SVG using the official PyFly logo and the "Sparky" mascot. + +**Tech Stack:** Python 3.12 (`book/.venv`), `markdown` 3.9, `pygments` 2.20, `weasyprint` 66, `cairosvg` 2.8.2, `Pillow` 11.3; Homebrew `pango/cairo/gdk-pixbuf`; build runs with `DYLD_FALLBACK_LIBRARY_PATH=/opt/homebrew/lib`. + +**Spec:** `docs/superpowers/specs/2026-06-07-pyfly-by-example-book-design.md` + +--- + +## File Structure (created in this phase) + +``` +book/ + book.yaml # metadata + ordered manifest of front/chapters/back + build/ + run.sh # wrapper: exports DYLD path, calls build.py via book/.venv + md.py # markdown -> html (listings, figures, callouts) + epub.py # EPUB3 assembler (stdlib zipfile) + pdf.py # WeasyPrint HTML/CSS -> PDF + gen_cover.py # compose cover.svg (logo embedded) + rasterize cover.png + verify_code.py # extract python listings, syntax+import check vs pyfly + build.py # orchestrator + theme/ + tokens.css # palette variables + book.css # shared screen/EPUB styles (Modern) + print.css # @page, running heads, page numbers (PDF only) + pygments.css # light code-highlight theme + art/ + logo/pyfly-logo.png # copied from repo assets/ (source of truth) + cover.svg # generated by gen_cover.py + cover.png # generated by gen_cover.py (EPUB cover) + mascot/sparky.svg # Sparky badge (reusable) + openers/ch01.svg # Chapter 1 opener illustration + figures/01-choice.svg # "infinite choice vs cohesion" + figures/01-layers.svg # the four module layers + manuscript/ + 00-front/ + 00-title.md 00-copyright.md 00-preface.md 00-conventions.md 00-meet-sparky.md + 01-why-pyfly.md + dist/ # build output (git-ignored): pyfly-by-example.{epub,pdf} +tests/book/ + test_md.py # custom markdown directives + test_epub.py # EPUB structure validity + test_verify_code.py # code verifier +``` + +**Conventions for all tasks:** run build commands through the wrapper so libs load: +`bash book/build/run.sh` (full build) or, for tests, +`DYLD_FALLBACK_LIBRARY_PATH="$(brew --prefix)/lib" book/.venv/bin/python -m pytest tests/book -q`. + +--- + +## Task 1: Project metadata + run wrapper + +**Files:** +- Create: `book/book.yaml` +- Create: `book/build/run.sh` + +- [ ] **Step 1: Write `book/book.yaml`** + +```yaml +title: "PyFly by Example" +subtitle: "Event-Driven Python Microservices with the Firefly Framework" +author: "Firefly Software Foundation" +publisher: "Firefly Software Foundation" +language: "en" +identifier: "urn:uuid:7c1b1f2e-0a9e-4e3a-9d2a-pyfly000book" +rights: "Copyright (c) 2026 Firefly Software Foundation. Licensed under Apache-2.0." +cover_svg: "art/cover.svg" +cover_png: "art/cover.png" +# Trim size for the PDF (US Royal-ish, Packt-style) +trim_width: "7.5in" +trim_height: "9.25in" +# Ordered manifest. Only files that exist are built (others are skipped with a warning), +# so this same manifest grows as later phases add chapters. +front: + - {id: title, file: 00-front/00-title.md, nav: false} + - {id: copyright, file: 00-front/00-copyright.md, nav: false} + - {id: preface, file: 00-front/00-preface.md, title: "Preface"} + - {id: conventions, file: 00-front/00-conventions.md, title: "Conventions"} + - {id: sparky, file: 00-front/00-meet-sparky.md, title: "Meet Sparky"} +parts: + - title: "Part I — Foundations" + chapters: + - {id: ch01, file: 01-why-pyfly.md, num: 1, title: "Why PyFly?", opener: art/openers/ch01.svg} +back: [] +``` + +- [ ] **Step 2: Write `book/build/run.sh`** + +```bash +#!/usr/bin/env bash +# Build wrapper: ensures Homebrew's cairo/pango are loadable, then runs the build +# inside the isolated 3.12 venv. Pass-through args go to build.py (e.g. --epub-only). +set -euo pipefail +BOOK_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +BREW_PREFIX="$(brew --prefix 2>/dev/null || echo /opt/homebrew)" +export DYLD_FALLBACK_LIBRARY_PATH="${BREW_PREFIX}/lib:/usr/local/lib:${DYLD_FALLBACK_LIBRARY_PATH:-}" +exec "${BOOK_DIR}/.venv/bin/python" "${BOOK_DIR}/build/build.py" "$@" +``` + +- [ ] **Step 3: Make it executable** + +Run: `chmod +x book/build/run.sh` +Expected: no output, exit 0. + +- [ ] **Step 4: Commit** + +```bash +git add book/book.yaml book/build/run.sh +git commit -m "build(book): metadata manifest + DYLD-aware run wrapper" +``` + +--- + +## Task 2: Theme CSS (tokens, book, print, pygments) + +**Files:** +- Create: `book/theme/tokens.css`, `book/theme/book.css`, `book/theme/print.css`, `book/theme/pygments.css` + +- [ ] **Step 1: Write `book/theme/tokens.css`** + +```css +:root{ + --green:#43b02a; --green-bright:#5fd13a; --lime:#86dd4c; --lime-soft:#a7e76a; + --green-deep:#2c8a1c; --green-ink:#1f5e16; --amber:#ffc24b; --amber-soft:#ffd86b; + --ink:#1c2420; --ink-soft:#33402e; --muted:#7a8472; --page:#fffef9; + --code-bg:#f7faf3; --code-border:#dde6d3; --rule:#e7ece1; + --note:#1f6fd6; --note-bg:#eef6ff; --tip:#2f8f3f; --warn:#c2410c; --warn-bg:#fff4ec; +} +``` + +- [ ] **Step 2: Write `book/theme/book.css`** (Modern interior — shared by EPUB + PDF) + +```css +@import "tokens.css"; +@import "pygments.css"; +body{ color:var(--ink); background:var(--page); + font-family:-apple-system,"Segoe UI",Inter,Roboto,Helvetica,Arial,sans-serif; + font-size:1rem; line-height:1.72; margin:0; } +.chapter{ padding:0 0 2rem; } +h1,h2,h3{ color:var(--ink); line-height:1.25; font-weight:800; } +h1.chtitle{ font-size:2rem; margin:.2rem 0 1rem; color:#15351a; } +h2{ font-size:1.25rem; font-weight:700; margin:1.6rem 0 .5rem; } +h3{ font-size:1.05rem; font-weight:700; margin:1.2rem 0 .4rem; } +p{ margin:0 0 .8rem; } +a{ color:var(--green-deep); } +code{ background:#eef3e8; color:var(--green-deep); padding:1px 5px; border-radius:4px; + font-family:"SF Mono","JetBrains Mono",Menlo,Consolas,monospace; font-size:.86em; } +.eyebrow{ color:var(--green); font-weight:700; letter-spacing:2px; font-size:.72rem; + text-transform:uppercase; } +.opener{ display:flex; align-items:center; gap:1rem; margin:.4rem 0 1rem; } +.opener .badge{ flex:none; width:64px; height:64px; } +.opener-illus{ width:100%; height:auto; margin:.6rem 0 1.2rem; } + +/* listings */ +.listing{ margin:1rem 0; } +.filetab{ display:inline-block; background:#eef3e8; color:#3f6b34; + font:600 .72rem/1 "SF Mono",Menlo,monospace; padding:.4rem .6rem; + border:1px solid var(--code-border); border-bottom:none; border-radius:7px 7px 0 0; } +pre.code{ margin:0; background:var(--code-bg); border:1px solid var(--code-border); + border-radius:0 7px 7px 7px; padding:.8rem .9rem; overflow-x:auto; + font-family:"SF Mono","JetBrains Mono",Menlo,Consolas,monospace; font-size:.8rem; line-height:1.6; } +.lcap{ font-size:.74rem; color:var(--muted); margin-top:.4rem; font-style:italic; } + +/* figures */ +figure.fig{ margin:1.2rem 0; text-align:center; } +figure.fig svg{ max-width:100%; height:auto; } +figcaption{ font-size:.76rem; color:var(--muted); margin-top:.5rem; } +figcaption b{ color:#3f6b34; } + +/* callouts (admonition extension output) */ +.admonition{ border-radius:11px; padding:.7rem .9rem; margin:1rem 0; font-size:.92rem; + border:1px solid var(--rule); background:#f6f8f1; } +.admonition-title{ font-weight:700; font-size:.72rem; letter-spacing:1px; text-transform:uppercase; + margin:0 0 .3rem; } +.admonition.note{ background:var(--note-bg); border-color:#cfe3fb; } +.admonition.note>.admonition-title{ color:var(--note); } +.admonition.tip{ background:#f0faef; border-color:#cdeccd; } +.admonition.tip>.admonition-title{ color:var(--tip); } +.admonition.warning{ background:var(--warn-bg); border-color:#fcd9c2; } +.admonition.warning>.admonition-title{ color:var(--warn); } +.admonition.spring{ background:#f3fae9; border-color:#d4e9bd; } +.admonition.spring>.admonition-title{ color:var(--green-deep); } +.admonition.spring>.admonition-title::before{ content:"\1F33F "; } + +/* recap / exercises */ +.recap{ background:#f3fae9; border:1px solid #d4e9bd; border-radius:11px; padding:.9rem 1rem; margin:1.4rem 0; } +.recap h3,.exercises h3{ margin-top:0; color:var(--green-deep); } +``` + +- [ ] **Step 3: Write `book/theme/pygments.css`** (generate from Pygments, then hand-tune) + +Run to generate a base, then we override key tokens: +```bash +book/.venv/bin/python -c "from pygments.formatters import HtmlFormatter; print(HtmlFormatter().get_style_defs('.code'))" > book/theme/pygments.css +``` +Then append these print-safe overrides to `book/theme/pygments.css`: +```css +.code .k,.code .kn,.code .kd{ color:#cf222e; } /* keyword */ +.code .s,.code .s1,.code .s2,.code .sb{ color:#0a3069; } /* string */ +.code .c,.code .c1,.code .cm{ color:#6e7781; font-style:italic; } /* comment */ +.code .nf,.code .fm{ color:#8250df; } /* function */ +.code .nd{ color:#953800; } /* decorator */ +.code .mi,.code .mf{ color:#0550ae; } /* number */ +.code .nb,.code .bp{ color:#116329; } /* builtin */ +.code .nc,.code .nn{ color:#0b3d2e; font-weight:600; } /* class/namespace */ +``` + +- [ ] **Step 4: Write `book/theme/print.css`** (PDF page setup only) + +```css +@page{ + size: 7.5in 9.25in; + margin: 0.85in 0.8in 0.9in; + @bottom-center{ content: counter(page); font-family:-apple-system,sans-serif; + font-size:9px; color:#9aa6a0; } + @top-center{ content: string(runhead); font-family:-apple-system,sans-serif; + font-size:8.5px; letter-spacing:1.5px; text-transform:uppercase; color:#a7b0a0; } +} +@page :first{ @top-center{ content: none; } @bottom-center{ content: none; } } +h1.chtitle{ string-set: runhead content(text); break-before: page; } +.cover-page{ break-after: page; text-align:center; } +.cover-page img{ width:100%; height:auto; } +.listing,figure.fig,.admonition{ break-inside: avoid; } +h2,h3{ break-after: avoid; } +``` + +- [ ] **Step 5: Commit** + +```bash +git add book/theme/ +git commit -m "style(book): Modern theme — tokens, book, print, pygments" +``` + +--- + +## Task 3: Markdown pipeline `md.py` (TDD) + +**Files:** +- Create: `book/build/md.py` +- Test: `tests/book/test_md.py` + +- [ ] **Step 1: Write the failing tests** + +```python +# tests/book/test_md.py +from pathlib import Path +import sys +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "book" / "build")) +from md import render_markdown # noqa: E402 + +def test_listing_renders_filetab_caption_and_highlight(tmp_path): + src = ( + "::: listing wallet/app.py | Listing 1.1 — hello\n" + "from pyfly.core import pyfly_application\n" + "@pyfly_application(name=\"x\")\n" + "class App: ...\n" + ":::\n" + ) + html = render_markdown(src, tmp_path) + assert 'class="filetab">wallet/app.py<' in html + assert "Listing 1.1" in html + assert 'class="listing"' in html + assert "') + html = render_markdown("::: figure f.svg | Figure 1.1 — demo\n", tmp_path) + assert " HTML for *PyFly by Example*. + +Custom block directives on top of python-markdown: + ::: figure | (single line; inlines the SVG) + ::: listing | (block; code lines until a lone ':::') + + ::: +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'
{d.xhtml_body}
\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''' + + + + + + + + + + + + + FIREFLY SOFTWARE FOUNDATION + + + by Example + Event-Driven Python Microservices + with the Firefly Framework + A HANDS-ON, PROJECT-DRIVEN GUIDE +''' + +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. From 3bc88944255de3a3dcdda3f1b0b5a027d8646ef0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20Contreras=20Guill=C3=A9n?= Date: Sun, 7 Jun 2026 13:40:08 +0200 Subject: [PATCH 2/2] feat(context): injectable ApplicationEventPublisher + arbitrary domain events + bump v26.06.41 - ApplicationEventPublisher is now an injectable singleton bean (Spring ApplicationEventPublisher); inject it + await publisher.publish(event) to fire events into the context bus. - The event bus + @app_event_listener accept ANY object (not just ApplicationEvent subclasses); a listener whose param type matches (isinstance) receives it. Untyped/Any param still falls back to the catch-all ApplicationEvent (note: typing.Any is a class in 3.11+, excluded explicitly). Tests: tests/context/test_event_publisher.py (2). Gates: mypy --strict (619), ruff + format, full suite 3822 passed. --- CHANGELOG.md | 14 +++++ README.md | 2 +- pyproject.toml | 2 +- src/pyfly/__init__.py | 2 +- src/pyfly/context/__init__.py | 2 + src/pyfly/context/application_context.py | 15 +++-- src/pyfly/context/events.py | 40 ++++++++++--- tests/context/test_event_publisher.py | 71 ++++++++++++++++++++++++ uv.lock | 2 +- 9 files changed, 135 insertions(+), 15 deletions(-) create mode 100644 tests/context/test_event_publisher.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 41f0bd6b..2d659e64 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,20 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). --- +## v26.06.41 (2026-06-07) + +### Added (context — injectable ApplicationEventPublisher + arbitrary domain events) + +- **`ApplicationEventPublisher`** is now an injectable singleton bean (Spring's + `ApplicationEventPublisher`): inject it into any bean and `await publisher.publish(event)` + to fire events into the context event bus. +- **Arbitrary domain events** — the event bus and `@app_event_listener` no longer require + events to subclass `ApplicationEvent`; any object can be published, and a listener whose + parameter type matches (by `isinstance`) receives it (an untyped/`Any` parameter still + falls back to the catch-all `ApplicationEvent`). + +`ApplicationEventPublisher` is exported from `pyfly.context`. + ## v26.06.40 (2026-06-07) ### Added (context — more @ConditionalOn* conditions) diff --git a/README.md b/README.md index 805b081c..5be5d28e 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Firefly Framework Python 3.12+ License: Apache 2.0 - Version: 26.06.40 + Version: 26.06.41 Type Checked: mypy strict Code Style: Ruff Async First 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" },