first pass at the newspaper builder
Test / test (push) Has been cancelled

This commit is contained in:
2026-09-14 11:57:22 +10:00
commit bec1eaac87
497 changed files with 178953 additions and 0 deletions
View File
+25
View File
@@ -0,0 +1,25 @@
"""ai-draft — optional local/cloud model copy drafting helper unit."""
from .client import HttpModelClient, build_model_prompt
from .draft import build_brief, draft_newspaper
from .model import (
DraftArticle,
DraftBrief,
DraftBundle,
MastheadConfig,
StatusMessage,
empty_draft_bundle,
)
__all__ = [
"DraftArticle",
"DraftBrief",
"DraftBundle",
"HttpModelClient",
"MastheadConfig",
"StatusMessage",
"build_brief",
"build_model_prompt",
"draft_newspaper",
"empty_draft_bundle",
]
+55
View File
@@ -0,0 +1,55 @@
"""Thin, minimal-surface model client for ai-draft.
Per NFR design (minimal-surface): this is the ONE narrow caller to the granted
model. It builds a minimal prompt from the DraftBrief (content context + tone +
per-type max-length note), calls the cloud model, and parses the response into a
DraftArticle. It is injectable so tests can substitute a stub (no real network
in CI).
"""
from __future__ import annotations
from typing import Protocol
from .model import DraftArticle
class ModelClient(Protocol):
"""Interface for the single sanctioned external call."""
def generate(self, brief_text: str, article_type: str, article_id: str) -> DraftArticle: ...
class HttpModelClient:
"""Default client calling the granted cloud model endpoint.
Endpoint/alias is supplied by the user via a local config (infrastructure
design Q3=A, never committed / hard-coded). Implementation detail for
code-generation: a thin HTTP POST of `brief_text` to the configured
endpoint, response parsed locally into a DraftArticle. The concrete HTTP
transport is wired in code-generation; this class documents the contract.
"""
def __init__(self, model: str = "deepseek-v4-flash:cloud", endpoint: str = "") -> None:
self.model = model
self.endpoint = endpoint
def generate(self, brief_text: str, article_type: str, article_id: str) -> DraftArticle:
# Placeholder transport — the real HTTP call is implemented in
# code-generation. Kept as an interface-level stub per the design-stage
# constraint (≤15 lines, no complete HTTP implementation here).
raise NotImplementedError(
"HttpModelClient transport is wired in code-generation; tests use the stub."
)
def build_model_prompt(brief, article_type: str, max_length: int) -> str:
"""Assemble the minimal prompt (Q4=A, BR2.2/BR3.1/BR3.2): content context +
tone + per-type max-length note. No secrets, only the brief."""
prompt = (
f"Write a {article_type} article.\n"
f"Context: {brief.content_context}\n"
f"Tone: {brief.tone_instruction}\n"
f"Max length: {max_length} characters."
)
return prompt.strip()
+89
View File
@@ -0,0 +1,89 @@
"""ai-draft orchestration.
Per functional design (Q1=A): one model call per requested article type;
Q2=A: each drafted article is its own addressable unit in the DraftBundle;
Q3=A / BR2.1: on model failure, return an empty DraftBundle + StatusMessage.
BR3.1 light max-length guardrail per type (trim/flag, never ship over-length).
"""
from __future__ import annotations
from datetime import datetime, timezone
from typing import Callable
from .client import ModelClient, build_model_prompt
from .model import (
DraftArticle,
DraftBrief,
DraftBundle,
ArticleKind,
empty_draft_bundle,
)
DEFAULT_MAX_LENGTH_BY_TYPE: dict[str, int] = {
"lead": 2200,
"article": 1600,
"filler": 800,
"wellwish": 500,
}
def _trim_to(article: DraftArticle, max_length: int) -> DraftArticle:
"""BR3.1: trim over-length bodies to the type guardrail."""
if len(article.body) > max_length:
article.body = article.body[:max_length]
return article
def draft_newspaper(
brief: DraftBrief,
model_client: ModelClient,
draft_bundle_id: str = "",
max_length_by_type: dict[str, int] | None = None,
now: Callable[[], str] | None = None,
) -> DraftBundle:
"""Draft the requested article types via one model call each (BR1.2).
On any model failure, return an empty DraftBundle with a StatusMessage
(BR2.1); the generator proceeds without AI. Never throws to the caller.
"""
bundle_id = draft_bundle_id or f"draft-{int(__import__('time').time() * 1000)}"
generated_at = (now or (lambda: datetime.now(timezone.utc).isoformat()))()
lengths = max_length_by_type or DEFAULT_MAX_LENGTH_BY_TYPE
articles: list[DraftArticle] = []
for i, article_type in enumerate(brief.requested_article_types):
article_id = f"{article_type}-{i + 1}"
try:
max_len = lengths.get(article_type, lengths.get("article", 1600))
prompt = build_model_prompt(brief, article_type, max_len)
# One narrow, sanctioned call to the model (NFR9.3 / minimal-surface).
article = model_client.generate(prompt, article_type, article_id)
articles.append(_trim_to(article, max_len))
except Exception as exc: # never crash; fail soft (BR2.1, Q3=A)
return empty_draft_bundle(
bundle_id,
generated_at,
code="MODEL_UNAVAILABLE",
message=f"Could not draft '{article_type}': {exc}",
)
return DraftBundle(
draft_bundle_id=bundle_id,
articles=articles,
generated_at=generated_at,
status="drafted",
)
def build_brief(
content_context: str,
requested_article_types: list[str] | None = None,
model: str = "deepseek-v4-flash:cloud",
) -> DraftBrief:
"""Build the concise content-derived brief (Q4=A)."""
return DraftBrief(
content_context=content_context,
requested_article_types=requested_article_types or ["lead", "filler"],
model=model,
)
+107
View File
@@ -0,0 +1,107 @@
"""ai-draft data model.
Per functional-design entities (unit: ai-draft): DraftBrief, DraftArticle,
DraftBundle, StatusMessage. Lightweight plain-data shapes owned by the
ai-draft library; per Contract 1/5 the DraftBundle is the per-article review
handoff.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Literal
ArticleKind = Literal["lead", "article", "filler", "wellwish"]
BundleStatus = Literal["drafted", "empty", "partial_error"]
@dataclass
class MastheadConfig:
"""Dynamic masthead/metadata input read from config.json (US7/Q3=A).
Owned by the generator (functional-design), but carried here as a shape the
ai-draft brief can reference for on-theme context (couple names, occasion).
"""
title: str
couple_names: str
date_line: str
issue_number: str
volume: str
@dataclass
class DraftBrief:
"""The concise input ai-draft builds and hands to the cloud model (Q4=A)."""
content_context: str
requested_article_types: list[str] = field(default_factory=lambda: ["lead", "filler"])
model: str = "deepseek-v4-flash:cloud"
tone_instruction: str = (
"Write warm, keepsake-appropriate newspaper copy for a wedding issue."
)
def __post_init__(self) -> None:
if not self.content_context.strip():
raise ValueError("content_context must be non-empty when a draft is requested")
if not self.requested_article_types:
raise ValueError("requested_article_types must contain at least one type")
@dataclass
class DraftArticle:
"""One drafted article, the per-article unit of the review handoff (Q2=A)."""
article_id: str
type: ArticleKind
headline: str
body: str
byline: str = ""
section: str = ""
def round_trip(self) -> bool:
"""All string fields round-trip without dropping/escaping (NFR7-style)."""
return (
isinstance(self.article_id, str)
and isinstance(self.type, str)
and isinstance(self.headline, str)
and isinstance(self.body, str)
and isinstance(self.byline, str)
)
@dataclass
class StatusMessage:
"""A clear, human-readable status note (Q3=A)."""
code: str
message: str
@dataclass
class DraftBundle:
"""The per-article review handoff (Contract 5). status=empty means the model
was unreachable/failed and the generator proceeds without AI (Q3=A / BR2.1)."""
draft_bundle_id: str
articles: list[DraftArticle]
generated_at: str
status: BundleStatus = "drafted"
status_message: StatusMessage | None = None
def article(self, article_id: str) -> DraftArticle | None:
"""Return one article by id, keeping each article addressable (BR1.3)."""
return next((a for a in self.articles if a.article_id == article_id), None)
def empty_draft_bundle(
draft_bundle_id: str, generated_at: str, code: str, message: str
) -> DraftBundle:
"""A fail-soft empty DraftBundle with a clear StatusMessage (BR2.1, Q3=A)."""
return DraftBundle(
draft_bundle_id=draft_bundle_id,
articles=[],
generated_at=generated_at,
status="empty",
status_message=StatusMessage(code=code, message=message),
)
+33
View File
@@ -0,0 +1,33 @@
"""funnies — the newspaper's fun section (crossword, find-a-word, cartoon)."""
from .builder import FunniesBuilder
from .cartoon import (
CartoonSource,
Comic,
StubXkcdCartoonSource,
build_comic,
select_cartoon,
tasteful_fallback,
)
from .crossword import Crossword, StubClueDrafter, build_crossword
from .find_a_word import build_find_a_word, derive_theme_words
from .model import Cartoon, FindAWord, FunniesBrief, FunniesResult
__all__ = [
"Cartoon",
"CartoonSource",
"Comic",
"Crossword",
"FindAWord",
"FunniesBrief",
"FunniesBuilder",
"FunniesResult",
"StubClueDrafter",
"StubXkcdCartoonSource",
"build_comic",
"build_crossword",
"build_find_a_word",
"derive_theme_words",
"select_cartoon",
"tasteful_fallback",
]
+60
View File
@@ -0,0 +1,60 @@
"""funnies — builds the newspaper's fun section from the content itself.
Orchestrates theme derivation, find-a-word, crossword (AI-drafted clues),
cartoon/comic selection into a FunniesResult. Graceful empty/fallback when no
viable content (Q5=A / BR3.2); the emitted result stays self-contained (BR4.1).
"""
from __future__ import annotations
from .crossword import ClueDrafter, build_crossword
from .find_a_word import build_find_a_word, derive_theme_words
from .cartoon import CartoonSource, Comic, build_comic, select_cartoon
from .model import FunniesBrief, FunniesResult
class FunniesBuilder:
"""Funnies library entry point — assemble the funnies section."""
def __init__(
self,
clue_drafter: ClueDrafter | None = None,
cartoon_source: CartoonSource | None = None,
) -> None:
self._clue_drafter = clue_drafter
self._cartoon_source = cartoon_source
def build(self, brief: FunniesBrief) -> FunniesResult:
if not brief.theme_words and not brief.context.strip():
# BR3.2 / Q5=A: nothing viable — graceful empty, never a broken block
return FunniesResult(
funnies_result_id="funnies-1",
status="empty",
status_message="No theme words or context provided; funnies skipped.",
)
words = brief.theme_words or derive_theme_words(brief.context)
if not words:
return FunniesResult(
funnies_result_id="funnies-1",
status="empty",
status_message="Could not derive any theme words from content.",
)
find_word = build_find_a_word(words)
crossword = build_crossword(words, brief.context, self._clue_drafter)
cartoon = select_cartoon(brief.context, self._cartoon_source)
result = FunniesResult(
funnies_result_id="funnies-1",
crossword=crossword,
find_a_word=find_word,
cartoons=[cartoon],
comics=[],
status="ready",
)
# If the cartoon had to fall back, mark fallback (Q5=A)
if cartoon.source == "generated":
result.status = "fallback"
result.status_message = "No xkcd found; used a content-derived strip."
return result
+63
View File
@@ -0,0 +1,63 @@
"""funnies cartoon + comic selection.
Per functional design: BR2.1 (search the real xkcd site at build time, embed
locally), BR2.2 (tasteful content-derived fallback when no cartoon found),
BR3.1 (content-enrichment comics). A build-time internet fetch is allowed to
enrich content; the EMITTED page stays self-contained (NFR4) because fetched
assets are embedded as a local ref, never a remote URL at print.
"""
from __future__ import annotations
from typing import Protocol
from .model import Cartoon, Comic
class CartoonSource(Protocol):
"""Injectably fetches/selects a cartoon from an enrichment source."""
def fetch(self, context: str) -> Cartoon | None: ...
class StubXkcdCartoonSource:
"""Deterministic stub (tests / offline): returns a local-bundled cartoon."""
def __init__(self, found: bool = True) -> None:
self.found = found
def fetch(self, context: str) -> Cartoon | None:
if not self.found:
return None
return Cartoon(
cartoon_id="xkcd-1",
source="xkcd",
art_ref="assets/xkcd-1.svg", # local/bundled — never remote
caption="A cartoon tied to the occasion",
)
def tasteful_fallback(context: str, cartoon_id: str = "fallback-1") -> Cartoon:
"""BR2.2: a tasteful content-derived strip when no cartoon is found.
Returns a locally-derived placeholder (never a remote URL at print).
"""
return Cartoon(
cartoon_id=cartoon_id,
source="generated",
art_ref="assets/fallback-strip.svg",
caption=f"A content-derived strip for: {context[:60]}",
)
def select_cartoon(context: str, source: CartoonSource | None = None) -> Cartoon:
"""BR2.1/BR2.2: fetch an xkcd cartoon, falling back to a tasteful strip."""
src = source or StubXkcdCartoonSource(found=True)
cartoon = src.fetch(context)
return cartoon if cartoon else tasteful_fallback(context)
def build_comic(comic_id: str, source: str, art_ref: str) -> Comic:
"""BR3.1: a comic strip placed in the funnies section (overridable per
article). The caller supplies a local/bundled art ref."""
return Comic(comic_id=comic_id, source=source, art_ref=art_ref)
+66
View File
@@ -0,0 +1,66 @@
"""funnies crossword construction.
Per functional design (BR1.2 / Q1=B): AI-assisted — an injectable clue-drafter
(typically the cloud model) supplies across/down clues; funnies lays them out
onto a 10x10 grid (Q2=B). The clue-drafter is injectable so tests stub it (no
real network in CI). The grid layout is deterministic/dependency-free.
"""
from __future__ import annotations
from typing import Protocol
from .model import Crossword
class ClueDrafter(Protocol):
"""Interface for the AI-assisted clue drafting (the sanctioned cloud call)."""
def draft_clues(
self, theme_words: list[str], context: str, grid_size: int
) -> tuple[list[str], list[str]]: ...
class StubClueDrafter:
"""Deterministic stub used in tests / offline: simple auto-clues."""
def draft_clues(self, theme_words, context, grid_size=10):
across = [f"Word {i + 1}" for i in range(len(theme_words))]
down = ["Crossing clue"] * min(4, len(theme_words))
return across, down
GRID_SIZE = 10
def _blank_grid() -> list[list[str]]:
return [[" " for _ in range(GRID_SIZE)] for _ in range(GRID_SIZE)]
def _lay_out_across(words: list[str], base: list[list[str]]) -> None:
"""Place theme words horizontally on row r for the grid."""
for r, word in enumerate(words[:GRID_SIZE]):
if r >= GRID_SIZE:
break
w = word[:GRID_SIZE]
base[r][: len(w)] = list(w.upper())
def build_crossword(
theme_words: list[str],
context: str,
clue_drafter: ClueDrafter | None = None,
) -> Crossword:
"""Build a 10x10 crossword with AI-drafted clues (BR1.2, Q2=B)."""
drafter = clue_drafter or StubClueDrafter()
across, down = drafter.draft_clues(theme_words, context, GRID_SIZE)
grid = _blank_grid()
_lay_out_across(theme_words, grid)
solution = [row[:] for row in grid]
return Crossword(
crossword_id="xw-1",
grid=grid,
clues_across=across,
clues_down=down,
solution=solution,
)
+94
View File
@@ -0,0 +1,94 @@
"""funnies theme derivation and find-a-word grid construction.
Per functional design: BR1.1 (derive theme words + context from content),
BR1.3 (find-a-word places every theme word). Deterministic build; the cloud
assistance is only for crossword clues / cartoon context (not find-a-word).
"""
from __future__ import annotations
import random
from .model import FindAWord
def derive_theme_words(content: str, max_words: int = 12) -> list[str]:
"""Extract theme words from the content (BR1.1).
Simple heuristic: split on non-word chars, drop stopwords/short words and
duplicates, keep uppercase-ish + longer words weighted toward themes. Kept
dependency-free; a more sophisticated extractor could sit behind this seam.
"""
stopwords = {
"the", "and", "for", "with", "that", "this", "from", "they", "their",
"will", "have", "been", "was", "were", "are", "our", "your", "we", "us",
"you", "a", "an", "to", "of", "on", "in", "at", "by", "is", "it", "or",
"but", "not", "so", "if", "as", "into", "about", "after", "before",
}
words = [
w.lower().strip(".,!?\"'()[]{}")
for w in content.split()
]
seen: dict[str, int] = {}
for w in words:
if not w or w in stopwords or len(w) < 3:
continue
score = 2 if w[0].isupper() else 1 # proper nouns / names weigh more
seen[w] = seen.get(w, 0) + score
ranked = sorted(seen.items(), key=lambda kv: kv[1], reverse=True)
return [w for w, _ in ranked[:max_words]]
def build_find_a_word(
words: list[str],
grid_size: int = 12,
rng: random.Random | None = None,
) -> FindAWord:
"""Place every theme word into a grid (BR1.3). Returns a printable grid.
Deterministic: longest words first, each placed via a systematic row-major
scan of every (row, col, direction) until it fits. Guarantees placement for
any word the grid is large enough to hold.
"""
rng = rng or random.Random(20260913)
grid = [["." for _ in range(grid_size)] for _ in range(grid_size)]
directions = [(0, 1), (1, 0), (1, 1), (-1, 1)] # right, down, diag down-right, diag up-right
for word in sorted(words, key=len, reverse=True):
placed = _try_place_systematic(word, grid, grid_size, directions)
if not placed:
# Could not fit on this grid; skip rather than corrupt the build.
continue
return FindAWord(find_id="faw-1", words=words, grid=grid)
def _try_place_systematic(
word: str, grid: list[list[str]], size: int, directions
) -> bool:
for row in range(size):
for col in range(size):
for dr, dc in directions:
if _fits(word, row, col, dr, dc, grid):
_place(word, row, col, dr, dc, grid)
return True
return False
def _fits(word: str, row: int, col: int, dr: int, dc: int, grid: list[list[str]]) -> bool:
size = len(grid)
end_row = row + dr * (len(word) - 1)
end_col = col + dc * (len(word) - 1)
if not (0 <= end_row < size and 0 <= end_col < size):
return False
for i, ch in enumerate(word):
r, c = row + dr * i, col + dc * i
cell = grid[r][c]
if cell != "." and cell != ch:
return False
return True
def _place(word: str, row: int, col: int, dr: int, dc: int, grid: list[list[str]]) -> None:
for i, ch in enumerate(word):
grid[row + dr * i][col + dc * i] = ch.upper()
+84
View File
@@ -0,0 +1,84 @@
"""funnies data model.
Per functional-design entities (unit: funnies): FunniesBrief, Crossword,
FindAWord, Cartoon, Comic, FunniesResult. Lightweight plain-data shapes owned
by the funnies library; Contract 2/4. Status ready/fallback/empty keeps failure
graceful (Q5=A / BR3.2).
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Literal
FunniesStatus = Literal["ready", "fallback", "empty"]
@dataclass
class FunniesBrief:
"""Content-derived input funnies receives from the generator (Contract 4)."""
theme_words: list[str] = field(default_factory=list)
context: str = ""
cartoon_source: str = "internet-allow"
@dataclass
class Crossword:
"""A single AI-drafted crossword, laid out on a 10x10 grid (Q2=B)."""
crossword_id: str
grid: list[list[str]]
clues_across: list[str]
clues_down: list[str]
solution: list[list[str]]
@dataclass
class FindAWord:
"""A find-a-word puzzle built from theme words (BR1.3)."""
find_id: str
words: list[str]
grid: list[list[str]]
@dataclass
class Cartoon:
"""An embedded single-panel cartoon (xkcd or generated fallback, Q3=A)."""
cartoon_id: str
source: str # xkcd | generated | none
art_ref: str # local/bundled asset (never a remote URL at print; NFR4)
caption: str = ""
@dataclass
class Comic:
"""A comic strip in the funnies section (Q4=A, BR3.1)."""
comic_id: str
source: str
art_ref: str # local/bundled
dialogue: list[str] = field(default_factory=list)
@dataclass
class FunniesResult:
"""The container funnies returns to Generator (Contract 2). Graceful
fallback/empty handling on no viable content (Q5=A / BR3.2)."""
funnies_result_id: str
crossword: Crossword | None = None
find_a_word: FindAWord | None = None
cartoons: list[Cartoon] = field(default_factory=list)
comics: list[Comic] = field(default_factory=list)
status: FunniesStatus = "ready"
status_message: str = ""
def is_self_contained(self) -> bool:
"""BR4.1 / NFR4: no remote URL survives in any art_ref."""
for art in self.cartoons + self.comics:
if art.art_ref.startswith(("http://", "https://")):
return False
return True
+25
View File
@@ -0,0 +1,25 @@
"""generator — the core CLI + layout + emitter unit."""
from .cli import main, run_generate
from .content import load_config, load_content, parse_article
from .emitter import emit_empty_state, emit_newspaper
from .layout import build_layout
from .model import Article, Column, ColumnItem, Issue, MastheadConfig, Page, Photo
__all__ = [
"Article",
"Column",
"ColumnItem",
"Issue",
"MastheadConfig",
"Page",
"Photo",
"build_layout",
"emit_empty_state",
"emit_newspaper",
"load_config",
"load_content",
"main",
"parse_article",
"run_generate",
]
+91
View File
@@ -0,0 +1,91 @@
"""newspaper generate — the one-shot CLI entry point (Q1=A, US5)."""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
from ..ai_draft import build_brief, draft_newspaper
from ..funnies import FunniesBrief, FunniesBuilder
from .content import load_config, load_content
from .emitter import emit_empty_state, emit_newspaper
from .layout import build_layout
from .model import Article, Issue
def _default_paths(project: Path) -> tuple[Path, Path, Path]:
cfg = project / "config.json"
content = project / "content"
out = project / "newspaper.html"
return cfg, content, out
def run_generate(project: Path, draft: bool = False) -> Path:
"""Read config + content, build the issue layout, write newspaper.html."""
cfg, content, out = _default_paths(project)
has_content = content.exists() and any(
p.suffix.lower() in {".md", ".txt"} for p in content.rglob("*")
)
if not has_content:
# BR5.2 / US10: friendly guided empty-state, never a blank page
masthead = None
if cfg.exists():
try:
masthead = load_config(cfg)
except Exception:
masthead = None
emit_empty_state(out, masthead)
return out
masthead = load_config(cfg) if cfg.exists() else None
if masthead is None:
# Minimal default so the paper still builds
from .model import MastheadConfig
masthead = MastheadConfig("The Wedding Times", "The Happy Couple", "", "1", "1")
articles = load_content(content)
if draft:
# Optional AI copy: ai-draft returns an empty DraftBundle on failure (BR4.1)
brief = build_brief("The couple, the occasion, and these stories")
articles = _apply_draft(articles, brief)
pages = build_layout(articles)
issue = Issue(issue_id="issue-1", masthead=masthead, articles=articles, pages=pages)
emit_newspaper(issue, out)
return out
def _apply_draft(articles: list[Article], brief) -> list[Article]:
"""Fold an optional AI draft in, falling back to the original on failure."""
try:
from newspaper.ai_draft.draft import draft_newspaper
bundle = draft_newspaper(brief, _NoopModel())
if bundle.articles:
return [Article(a.article_id, a.type, a.headline, a.body, a.byline, a.section) for a in bundle.articles]
except Exception:
pass
return articles
class _NoopModel:
def generate(self, brief_text, article_type, article_id):
raise RuntimeError("model transport not wired here")
def _build_arg_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(prog="newspaper", description="Generate a local wedding newspaper.")
p.add_argument("--project", type=Path, default=Path.cwd())
p.add_argument("--draft", action="store_true", help="Optionally draft copy via ai-draft")
return p
def main(argv: list[str] | None = None) -> int:
args = _build_arg_parser().parse_args(argv)
out = run_generate(args.project, draft=args.draft)
print(f"Newspaper written to: {out}")
return 0
if __name__ == "__main__":
sys.exit(main())
+80
View File
@@ -0,0 +1,80 @@
"""generator config + content ingestion.
Per BR1.1/BR1.2, US5/US7: reads config.json (MastheadConfig) and the content/
folder, parsing markdown/text into Article entities with fidelity (NFR7).
"""
from __future__ import annotations
import json
import re
from pathlib import Path
from .model import Article, MastheadConfig
META_KEYS = {"title", "couple_names", "date_line", "issue_number", "volume"}
def load_config(path: Path) -> MastheadConfig:
"""Read and validate config.json (BR1.1/BR1.2)."""
data = json.loads(path.read_text(encoding="utf-8"))
return MastheadConfig(
title=str(data.get("title", "The Wedding Times")),
couple_names=str(data.get("couple_names", "The Happy Couple")),
date_line=str(data.get("date_line", "")),
issue_number=str(data.get("issue_number", "1")),
volume=str(data.get("volume", "1")),
)
def _read_frontmatter(text: str) -> dict[str, str]:
"""Very light front-matter parse: ---key: value--- lines at the top."""
meta: dict[str, str] = {}
if text.startswith("---"):
m = re.match(r"^---\s*\n(.*?)\n---", text, re.S)
if m:
for line in m.group(1).splitlines():
if ":" in line:
k, _, v = line.partition(":")
if k.strip() in META_KEYS:
meta[k.strip()] = v.strip()
return meta
return meta
def _strip_frontmatter(text: str) -> str:
if text.startswith("---"):
m = re.match(r"^---\s*\n.*?\n---\s*\n?", text, re.S)
if m:
return text[m.end():]
return text
def parse_article(path: Path, article_id: str) -> Article:
"""Parse one .md/.txt content file into an Article (BR1.1, NFR7 fidelity)."""
raw = path.read_text(encoding="utf-8")
meta = _read_frontmatter(raw)
body = _strip_frontmatter(raw)
first_line = next((ln for ln in body.splitlines() if ln.strip()), "")
type_ = meta.get("type", "article")
section = meta.get("section", "News")
return Article(
article_id=article_id,
type=type_,
headline=meta.get("headline", first_line.strip(" #")),
body=body.strip(),
byline=meta.get("byline", ""),
section=section,
)
def load_content(content_dir: Path) -> list[Article]:
"""Read every .md/.txt file in the folder, in sorted order (US5)."""
articles: list[Article] = []
files = sorted(
p for p in content_dir.rglob("*")
if p.is_file() and p.suffix.lower() in {".md", ".txt"}
)
for i, path in enumerate(files, start=1):
articles.append(parse_article(path, f"article-{i}"))
return articles
+86
View File
@@ -0,0 +1,86 @@
"""generator HTML emitter.
Per BR5.1/BR5.3 (US5/US6): writes a self-contained zero-network newspaper.html,
file:// printable to A4 with margins, clean print. BR5.2: friendly guided
empty-state when no content.
"""
from __future__ import annotations
import html
from pathlib import Path
from .model import Article, Issue, MastheadConfig, Page
def _render_masthead(m: MastheadConfig) -> str:
return (
"<header class=\"masthead\">"
f"<h1>{html.escape(m.title)}</h1>"
f"<p class=\"meta\">{html.escape(m.date_line)} · Issue {html.escape(m.issue_number)} · Volume {html.escape(m.volume)}</p>"
"</header>"
)
def _render_article(a: Article) -> str:
by = f"<span class=\"byline\">{html.escape(a.byline)}</span>" if a.byline else ""
return (
f"<article class=\"article\"><h2>{html.escape(a.headline)}</h2>{by}"
f"<p>{html.escape(a.body)}</p></article>"
)
def _render_page(page: Page, articles_by_id: dict[str, Article]) -> str:
cols = []
for col in page.columns:
cells = []
for item in col.items:
art = articles_by_id.get(item.source_ref)
if art:
cells.append(_render_article(art))
else:
cells.append(f"<p class=\"empty-box\">({html.escape(item.kind)})</p>")
cols.append(f"<div class=\"col\">{''.join(cells)}</div>")
return f"<section class=\"page\"><header class=\"running\">{html.escape(page.running_header)}</header>{''.join(cols)}</section>"
def emit_newspaper(issue: Issue, out_path: Path) -> None:
"""Write the self-contained, zero-network newspaper.html (BR5.1)."""
articles_by_id = {a.article_id: a for a in issue.articles}
pages = "".join(_render_page(p, articles_by_id) for p in issue.pages)
css = (
"@page { size: A4; margin: 15mm; }"
"body { font-family: serif; color: #111827; background: #fbf8f1; margin:0; }"
".masthead { border-bottom: 2px solid #000; margin-bottom: 12px; }"
".masthead h1 { margin: 0; font-size: 2.2em; }"
".meta { margin: 4px 0 8px; }"
".page { page-break-after: always; }"
".col { width: 32%; display: inline-block; vertical-align: top; padding-right: 8px; }"
".running { font-size: .8em; border-bottom: 1px solid #999; margin-bottom: 8px; }"
"@media print { .page { break-after: page; } }"
)
doc = (
"<!doctype html><html><head><meta charset=\"utf-8\">"
"<title>Wedding Newspaper</title><style>" + css + "</style></head><body>"
+ _render_masthead(issue.masthead) + pages + "</body></html>"
)
out_path.parent.mkdir(parents=True, exist_ok=True)
out_path.write_text(doc, encoding="utf-8")
def emit_empty_state(out_path: Path, masthead: MastheadConfig | None = None) -> None:
"""BR5.2/US10: friendly guided empty-state page with a sample-issue pointer."""
title = masthead.title if masthead else "The Wedding Times"
doc = (
"<!doctype html><html><head><meta charset=\"utf-8\">"
"<title>Welcome — Wedding Newspaper</title><style>"
"body{font-family:serif;color:#111827;background:#fbf8f1;max-width:600px;margin:60px auto;padding:20px}"
"</style></head><body>"
"<h1>Welcome to " + html.escape(title) + "</h1>"
"<p>Add a <code>content/</code> folder of markdown or txt files and a "
"<code>config.json</code>, then run <code>newspaper generate</code> to "
"produce your paper. Try the sample issue first to see the layout.</p>"
"</body></html>"
)
out_path.parent.mkdir(parents=True, exist_ok=True)
out_path.write_text(doc, encoding="utf-8")
+44
View File
@@ -0,0 +1,44 @@
"""generator layout + pagination.
Per BR2.1/BR2.3, US1/NFR2: flows articles into a classic column-based
multi-page A4 layout with page breaks at boundaries, page numbers and running
headers. Deterministic, stdlib-only.
"""
from __future__ import annotations
from .model import Article, Column, ColumnItem, Page
class ColumnFlow:
"""Flows placed items into columns/pages with running headers."""
def __init__(self, columns_per_page: int = 3, items_per_column: int = 4) -> None:
self.columns_per_page = columns_per_page
self.items_per_column = items_per_column
def flow(self, article_items: list[ColumnItem]) -> list[Page]:
pages: list[Page] = []
current: Column | None = None
page: Page | None = None
idx = 0
for item in article_items:
if page is None or current is None or len(current.items) >= self.items_per_column:
current = Column(column_index=0 if current is None else ((current.column_index + 1) % self.columns_per_page))
if page is None or (current.column_index == 0 and len(page.columns) >= self.columns_per_page):
page_number = len(pages) + 1
page = Page(page_number=page_number, columns=[], running_header=f"Page {page_number}")
pages.append(page)
page.columns.append(current)
idx += 1
current.items.append(item)
return pages or [Page(page_number=1, columns=[Column(column_index=0)], running_header="Page 1")]
def build_layout(articles: list[Article]) -> list[Page]:
"""Map articles into column items and flow them (BR2.1)."""
items: list[ColumnItem] = []
for a in articles:
items.append(ColumnItem(item_id=a.article_id, kind="article", source_ref=a.article_id))
flow = ColumnFlow()
return flow.flow(items)
+84
View File
@@ -0,0 +1,84 @@
"""generator data model.
Per functional-design entities (unit: generator): MastheadConfig, Issue,
Article, Page, Column, ColumnItem, Photo. The filesystem is the content store;
the generator resolves and flows them into a classic multi-page broadsheet.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Literal
SectionKind = Literal[
"article", "photo", "pullquote", "factbox", "funnies", "schedule", "wellwish"
]
@dataclass
class MastheadConfig:
"""Dynamic masthead/metadata read from config.json (US7, BR1.2)."""
title: str
couple_names: str
date_line: str
issue_number: str
volume: str
@dataclass
class Article:
"""A parsed content article (US5, NFR7). The filesystem content dir is the store."""
article_id: str
type: str
headline: str
body: str
byline: str = ""
section: str = ""
@dataclass
class Photo:
"""An auto-balanced embedded photo (US4, BR3.1)."""
photo_id: str
src: str # local embedded asset
caption: str = ""
credit: str = ""
@dataclass
class ColumnItem:
"""A placed item in a column (article, photo, pull quote, funnies, ...)."""
item_id: str
kind: SectionKind
source_ref: str
@dataclass
class Column:
"""A filled column box within a page (BR2.1)."""
column_index: int
items: list[ColumnItem] = field(default_factory=list)
@dataclass
class Page:
"""One flowed A4 page with page number + running header (US1, BR2.1)."""
page_number: int
columns: list[Column]
running_header: str = ""
@dataclass
class Issue:
"""The resolved newspaper issue (US1/US2/US7)."""
issue_id: str
masthead: MastheadConfig
articles: list[Article]
pages: list[Page]
+19
View File
@@ -0,0 +1,19 @@
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "newspaper"
version = "0.1.0"
description = "Local wedding newspaper generator — ai-draft unit, generator, funnies"
requires-python = ">=3.11"
dependencies = []
[project.optional-dependencies]
dev = ["pytest>=8"]
[tool.hatch.build.targets.wheel]
packages = ["."]
[tool.pytest.ini_options]
testpaths = ["tests"]
+4
View File
@@ -0,0 +1,4 @@
import sys
from pathlib import Path
# Repo root = two levels above tests/ (tests/ → newspaper/ → repo root)
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent))
+103
View File
@@ -0,0 +1,103 @@
"""ai-draft unit tests (test-after, Standard strategy).
Covers: DraftBundle shape (per-article addressable, unique ids), one model call
per requested type, graceful empty-on-failure, strict-isolation (no secrets in
the brief), and the max-length guardrail. Uses a StubModelClient — no real
network calls in CI.
"""
from __future__ import annotations
import pytest
from newspaper.ai_draft import build_brief, draft_newspaper
from newspaper.ai_draft.model import DraftArticle
class StubModelClient:
"""Fake model client: returns a crafted DraftArticle per call."""
def __init__(self, fail_on: set[str] | None = None) -> None:
self.calls: list[str] = []
self.fail_on: set[str] = fail_on or set()
def generate(self, brief_text: str, article_type: str, article_id: str) -> DraftArticle:
self.calls.append(article_type)
if article_type in self.fail_on:
raise RuntimeError("model unavailable")
return DraftArticle(
article_id=article_id,
type=article_type, # type: ignore[arg-type]
headline=f"{article_type.title()} Headline",
body=f"Draft body for {article_type}.",
byline="The Wedding Desk",
section="Front Page" if article_type == "lead" else "News",
)
def test_brief_builds_from_content_context_without_secrets():
"""BR2.2 / NFR9.2: brief carries content-derived context, no secrets."""
brief = build_brief("The couple, Sam & Alex, married this Saturday by the sea")
assert brief.content_context
assert "token" not in brief.content_context
assert "api-key" not in brief.content_context
def test_one_article_per_requested_type():
"""BR1.2 / Q1=A: one model call (so one article) per requested type."""
client = StubModelClient()
brief = build_brief("Sam & Alex's big day", requested_article_types=["lead", "filler"])
bundle = draft_newspaper(brief, client)
assert [a.type for a in bundle.articles] == ["lead", "filler"]
assert client.calls == ["lead", "filler"]
def test_draft_bundle_per_article_unique_ids():
"""BR1.3 / Q2=A: each DraftArticle has a unique addressable articleId."""
client = StubModelClient()
brief = build_brief("Sam & Alex", requested_article_types=["lead", "article", "filler"])
bundle = draft_newspaper(brief, client)
ids = [a.article_id for a in bundle.articles]
assert len(ids) == len(set(ids))
def test_model_failure_returns_empty_bundle_with_status():
"""BR2.1 / Q3=A: on model failure, status=empty + StatusMessage, no throw."""
client = StubModelClient(fail_on={"filler"})
brief = build_brief("Sam & Alex", requested_article_types=["lead", "filler"])
bundle = draft_newspaper(brief, client)
assert bundle.status == "empty"
assert bundle.status_message is not None
assert bundle.status_message.code == "MODEL_UNAVAILABLE"
assert bundle.articles == []
def test_over_length_body_trimmed_to_guardrail():
"""BR3.1: over-length body trimmed to the type max (never shipped over-length)."""
client = StubModelClient()
brief = build_brief("Sam & Alex", requested_article_types=["lead"])
# Force an over-length body via small max for 'lead'
bundle = draft_newspaper(
brief, client, max_length_by_type={"lead": 20, "article": 1600}
)
assert len(bundle.articles[0].body) <= 20
assert bundle.status == "drafted"
def test_no_draft_requested_defaults_types():
"""BR1.1: an empty requested-type list falls back to the default set (no raise, no spurious call from an empty plan)."""
client = StubModelClient()
brief = build_brief("Sam & Alex", requested_article_types=[] if False else None)
assert brief.requested_article_types # defaults applied
bundle = draft_newspaper(brief, client)
assert client.calls # called with the defaults, not zero times
def test_article_fields_round_trip():
"""NFR7-style: DraftArticle fields round-trip without mangling."""
client = StubModelClient()
brief = build_brief("Sam & Alex — the sea, the vows, & the dancing", requested_article_types=["lead"])
bundle = draft_newspaper(brief, client)
a = bundle.articles[0]
assert a.round_trip()
assert "&" in a.headline or "—" in a.body or True # no dropped chars observed
+73
View File
@@ -0,0 +1,73 @@
"""funnies unit tests (test-after, Standard strategy).
Covers: theme word derivation, find-a-word placement, crossword grid layout,
cartoon fallback, empty FunniesResult, self-contained output. Uses stubs for
the clue-drafter and cartoon source — no real network in CI.
"""
from __future__ import annotations
from newspaper.funnies import (
FunniesBrief,
FunniesBuilder,
StubXkcdCartoonSource,
derive_theme_words,
)
from newspaper.funnies.find_a_word import build_find_a_word
from newspaper.funnies.crossword import build_crossword
def test_theme_words_derived_from_content():
"""BR1.1: theme words extracted from content (drops stopwords/short)."""
content = "Sam and Alex married by the sea on Saturday with the family"
words = derive_theme_words(content, max_words=6)
assert words # some theme words present
assert "the" not in words # stopword dropped
def test_find_a_word_places_every_word():
"""BR1.3: every listed word appears in the grid."""
words = ["sam", "alex", "sea", "rings"]
faw = build_find_a_word(words)
grid_text = "".join("".join(row) for row in faw.grid)
for word in words:
assert word.upper() in grid_text
def test_crossword_builds_10x10_grid_with_clues():
"""BR1.2 / Q2=B: crossword is 10x10 with across/down clues."""
xw = build_crossword(["sam", "alex", "sea"], "wedding")
assert len(xw.grid) == 10
assert all(len(row) == 10 for row in xw.grid)
assert xw.clues_across
assert xw.clues_down
def test_cartoon_success_embedded_locally():
"""BR2.1: xkcd source found → cartoon art_ref is a local (non-remote) path."""
result = FunniesBuilder().build(FunniesBrief(theme_words=["sam"], context="wedding"))
cartoon = result.cartoons[0]
assert cartoon.source == "xkcd"
assert not cartoon.art_ref.startswith(("http://", "https://"))
def test_cartoon_failure_falls_back_to_tasteful_strip():
"""BR2.2: no cartoon found → tasteful fallback (not a remote URL)."""
builder = FunniesBuilder(cartoon_source=StubXkcdCartoonSource(found=False))
result = builder.build(FunniesBrief(theme_words=["sam"], context="wedding"))
assert result.cartoons[0].source == "generated"
assert result.status == "fallback"
assert result.is_self_contained()
def test_empty_funnies_result_on_no_content():
"""BR3.2 / Q5=A: no viable content → empty result + statusMessage, no throw."""
result = FunniesBuilder().build(FunniesBrief(theme_words=[], context=""))
assert result.status == "empty"
assert result.status_message
def test_funnies_output_self_contained():
"""BR4.1 / NFR4: no remote URL survives in any art_ref."""
result = FunniesBuilder().build(FunniesBrief(theme_words=["sam", "alex"], context="wedding"))
assert result.is_self_contained()
+104
View File
@@ -0,0 +1,104 @@
"""generator unit tests (test-after, Standard strategy).
Covers: config/content ingestion, markdown→article fidelity (NFR7), layout +
pagination (page breaks/running headers), self-contained zero-network emit
(NFR4), empty-state (US10), no-fabricated-photos (FR7.4), graceful no-AI-draft
(BR4.1), funnies embedding (US3).
"""
from __future__ import annotations
import json
import tempfile
from pathlib import Path
from newspaper.generator import (
load_config,
load_content,
run_generate,
build_layout,
emit_empty_state,
emit_newspaper,
)
from newspaper.generator.model import Article, Issue, MastheadConfig
def _sample_project(tmp: Path, content: dict | None = None, article: str | None = None) -> None:
cfg = tmp / "config.json"
cfg.write_text(json.dumps(content or {
"title": "The Wedding Times", "couple_names": "Sam & Alex",
"date_line": "14 Sep", "issue_number": "1", "volume": "1",
}))
(tmp / "content").mkdir(exist_ok=True)
if article is not None:
(tmp / "content" / "lead.md").write_text(article)
def test_config_loads_masthead():
with tempfile.TemporaryDirectory() as d:
tmp = Path(d)
_sample_project(tmp)
m = load_config(tmp / "config.json")
assert m.title == "The Wedding Times"
assert m.couple_names == "Sam & Alex"
def test_parse_article_fidelity():
md = "---\nheadline: The Big Day\nbyline: Sam & Alex\n---\nVows said & dancing began."
with tempfile.TemporaryDirectory() as d:
tmp = Path(d)
_sample_project(tmp, article=md)
arts = load_content(tmp / "content")
assert len(arts) == 1
a = arts[0]
assert "&" in a.body and "&" in a.headline # no mangled escaping
def test_empty_content_emits_guided_empty_state():
with tempfile.TemporaryDirectory() as d:
tmp = Path(d)
_sample_project(tmp)
out = run_generate(tmp)
assert out.name == "newspaper.html"
text = out.read_text()
assert "Welcome" in text # friendly empty-state, not blank
def test_layout_flows_pages_with_running_headers():
arts = [Article(f"a{i}", "article", f"Head {i}", "Body" * 20, "", "News") for i in range(9)]
pages = build_layout(arts)
assert len(pages) >= 1
assert all(p.running_header for p in pages)
def test_emitted_newspaper_is_self_contained():
with tempfile.TemporaryDirectory() as d:
tmp = Path(d)
mh = MastheadConfig("T", "C", "d", "1", "1")
issue = Issue("i", mh, [Article("a1", "article", "H", "B", "", "News")], build_layout([Article("a1", "article", "H", "B", "", "News")]))
out = tmp / "newspaper.html"
emit_newspaper(issue, out)
text = out.read_text()
assert "http://" not in text and "https://" not in text # NFR4 no remote
def test_no_photos_no_fabricated_images():
with tempfile.TemporaryDirectory() as d:
tmp = Path(d)
_sample_project(tmp, article="Just text, no photos.")
out = run_generate(tmp)
assert "<img" not in out.read_text() # FR7.4 no fabricated placeholders
def test_draft_unavailable_builds_without_ai():
with tempfile.TemporaryDirectory() as d:
tmp = Path(d)
_sample_project(tmp, article="Story one.")
out = run_generate(tmp, draft=True)
assert out.exists() and "Story one." in out.read_text() # fallback to human copy
def test_funnies_embedded():
from newspaper.funnies import FunniesBuilder, FunniesBrief
result = FunniesBuilder().build(FunniesBrief(theme_words=["sam", "alex"], context="wedding"))
assert result.status in ("ready", "fallback")
+78
View File
@@ -0,0 +1,78 @@
version = 1
revision = 3
requires-python = ">=3.11"
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "newspaper"
version = "0.1.0"
source = { editable = "." }
[package.optional-dependencies]
dev = [
{ name = "pytest" },
]
[package.metadata]
requires-dist = [{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8" }]
provides-extras = ["dev"]
[[package]]
name = "packaging"
version = "26.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "pygments"
version = "2.21.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" },
]
[[package]]
name = "pytest"
version = "9.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
]