Fable 5 Analysis · Issue #758 · rendercv/rendercv · GitHub
Skip to content

Fable 5 Analysis #758

Description

@sinaatalay

RenderCV Architecture Review — through the lens of the Development Philosophy

Scope: all 8.6k lines of src/, 10.4k of tests/, CI, scripts, toolchain. Five parallel deep-review agents + my own verification of every high-severity claim. Everything marked [verified] I reproduced by running code; everything with a file:line I read myself.

One framing correction first: the philosophy's Section 3 ("crush and burn") carries a pre-launch caveat — and RenderCV is launched (v2.8, PyPI, users, rendercv.com). So the compatibility boundary is the YAML contract + CLI surface. Everything behind that boundary — Python internals, module layout, the render pipeline — has no external consumers except your own web app, and can still be rebuilt freely. The findings below stay inside that boundary.

Verdict

The skeleton is genuinely good: one pipeline (YAML → pydantic → jinja → typst), one entry path (build_rendercv_dictionary_and_model), themes-as-data, disciplined typing culture, strong test infrastructure. The disease is in the middle of the pipeline: between the validated model and the Typst string there is no engineered representation — only a cascade of regex/string rewrites executed on a mutated copy of the validated model. Nearly every confirmed bug, every ty: ignore, every fallback, and a year of fix-commits trace back to that one missing piece of engineering. This is the Section 4 story told by your own git log: "Fix placeholder removal eating provided placeholders", "Fix cross-line emphasis interference" (#685), "Fix orphaned connector words", "Only catch full words in bold_keywords", "Fix multiline summary issues", "Don't fail on empty links" — ~12 commits, each locally reasonable, each patching one manifestation of the same absent structure.

Bugs found during this review (all in current main)

  1. [verified] A section named interests crashes error reporting. pydantic_error_handling.py:24-32 filters pydantic union-branch names out of error locations by substring ("int", "str", "list", …). Any user key containing those substrings — interests, paintings, internships, distributed_systems — is silently dropped from the location path, and the coordinate lookup then dies with a raw KeyError: 0 instead of showing the validation table. The machinery built to display expected errors crashes with an impossible one (Section 5 inverted).
  2. [verified] Typo'd keys in entries are accepted silently. compnay: TYPO-VALUE validates without complaint. Entries use extra="allow" (base.py:17-26, entries/bases/entry.py) — and the docstring states the reason: the renderer needs to setattr template-generated fields onto validated models. The renderer's scratchpad design bought its convenience by disabling typo detection in the most-edited region of every user's YAML — the exact feature the docs advertise with the strict-validation GIF.
  3. [verified] Escaping placeholder corruption. markdown_parser.py:104-141 swaps Typst commands for RENDERCVTYPSTCOMMANDORMATH{i} and swaps back with str.replace. Index 1 is a string-prefix of index 10: with ≥11 commands/math in one text node, output is corrupted (#cmd1(x)0 instead of #cmd10(x)). Commit 2df9d226 fixed this same prefix-collision class in another placeholder system two weeks ago — the class keeps reappearing because the mechanism (flat-string sentinels) stays.
  4. [verified] Empty markdown links fabricate https://example.com. markdown_parser.py:49. A user writing [text]() ships a CV linking to example.com, silently. History: 21543131 first made this raise — then 2e329bd4 "Don't fail on empty links" replaced the error with fabrication. A designed rejection was downgraded to a silent fallback (Section 2's exact definition).
  5. Watch mode renders stale design/locale/settings. render_command.py:210-228 reads overlay file contents once at startup; the watch lambda closes over that arguments dict; run_rendercv re-reads only the main YAML (run_rendercv.py:140). So the watcher watches design.yaml, triggers on its change, and re-renders with the old design. (Code-verified; the closure is unambiguous.)
  6. [verified] Test fixture type-dispatch is a shadowing tautology. tests/renderer/conftest.py:331-332 — the loop variable field_type shadows the parameter, so if field_type is field_type always matches the first dict entry and returns "https://example.com" for any unmatched type; the raise below is unreachable.
  7. Dead Hypothesis profiles. tests/conftest.py:6-8 registers ci (200 examples) and dev (30), but nothing ever selects them — no HYPOTHESIS_PROFILE in test.yaml, no addopts. CI silently runs default (100). Speculative config that lies to the reader (Section 2).
  8. Latent: explicit False overrides are dropped. rendercv_model_builder.py:149-151 merges CLI overrides with if value:, collapsing False into "unset" while the TypedDict promises bool | None. Today's CLI can't send False, so it's dormant — but the type contract lies to library callers (the web app).

The structural findings (the missing engineering, per Section 4)

1. There is no render IR — content is rewritten as flat strings, six times

Invariant: generated Typst is well-formed and faithfully represents user content; every placeholder is resolved exactly once.
What happens: a string passes through keyword-bolding (regex) → per-line markdown parsing (markdown_to_typst, line-by-line specifically to patch cross-line interference, markdown_parser.py:158-192) → escaping with sentinel round-trips → locale phrase str.replace (entry_templates_from_input.py:141-146) → placeholder substitution → placeholder-cleanup regexes. Bugs #3 and #4 live here, plus the #OpenToWork ambiguity [verified]: prose hashtags pass through as Typst commands and fail compilation with an error pointing at generated code, not the user's YAML — a direct consequence of having two ways to express formatting (markdown AND raw-Typst passthrough) on one undifferentiated string (Section 1).
The engineering: parse each content field once into a tree (the markdown library already gives you an ElementTree — you discard it back to a string). Keyword bolding, phrase expansion, and placeholder substitution become node operations on that tree; Typst/Markdown/HTML emission happens once at the end, escaping exactly once per text node at emission. Sentinels, round-trips, per-line parsing, and cleanup regexes all cease to exist rather than being fixed. Encode "resolved" in types: Template and RenderedText as distinct types, where only RenderedText can be emitted — the "unresolved placeholder" bug becomes uncompilable (Section 6).

2. The validated model doubles as a mutable render scratchpad

Invariant: a validated RenderCVModel is the user's input — one type, one meaning.
What happens: process_model (model_processor.py:80) deep-copies the model, then mutates it; schema models carry renderer-only private attrs (_plain_name, _connections, _top_note, _footer); entry_templates_from_input.py:219-224 setattrs arbitrary string-keyed render outputs onto entries; process_fields:187-189 str()s any non-string field; Entry = EntryModel | str forces getattr(..., None)-plus-RenderCVInternalError duck typing (:150-196) and three ty: ignores; empty string is a sentinel for "absent" (:132-134). And the price of it all is Bug #2: extra="allow" on entries so the grafting works.
The engineering: a separate frozen render IR — RenderSection/RenderEntry dataclasses built by a pure function schema model → IR. Schema models become frozen with extra="forbid" everywhere; typo detection returns; the deep-copy, the private attrs, the setattr grafting, the Entry | str ignores, and the double role all disappear. TextEntry becomes a real type instead of str. Templates receive the IR (typed context), not the schema model plus untyped **kwargs (templater.py:209-215).

3. Error machinery is string surgery over pydantic internals

Invariant: RenderCV classifies errors by what they are (structured type + location), never by prose.
What happens: error_dictionary.yaml maps by substring-matching pydantic's English messages (pydantic_error_handling.py:89-92) — silent decay on any pydantic upgrade; unwanted_texts message replaces (:50-51); the substring location filter that causes Bug #1; hardcoded end_date/current_date special cases (:69-87); and design.py:37-55 routes built-in-vs-custom themes by parsing error["ctx"]["discriminator"] == "'theme'" — with embedded quote characters — from a caught ValidationError. That is a fallback keyed on a third-party error's internal formatting (Sections 2, 5, 6 at once).
The engineering: (a) key friendly messages on error["type"] — pydantic's stable, documented contract — never on msg; (b) resolve locations structurally: a loc element is a union-branch artifact iff it is not a key/index present in the CommentedMap at that level — walk both together, no denylist; (c) theme routing becomes a look-before-leap decision: if design["theme"] in available_themes: validate built-in else load custom — the discriminator-parsing fallback is deleted. Note design.py currently raises through three mechanisms (PydanticCustomError, RenderCVInternalError, bare ValueError at :135) — one function, three error protocols.

4. Types are traded away at the theme/locale/section boundary

All 20 ty: ignores in src/ cluster on one pattern: types constructed at import time — reduce(or_, ...) unions (built_in_design.py:43, locale.py:44, section.py:43), pydantic.create_model section models (section.py:116-121), the variant generator, FontFamily Literal[*tuple(...)]. Worse, Design (design.py:152-155) is annotated as BuiltInDesign while its validator returns arbitrary custom-theme classes — the type is theater (Section 6's "worst position: looks safe"). And RenderCommand types placeholder templates (OUTPUT_FOLDER/NAME_..._CV.typ) as pathlib.Path — a value that is not a path claiming to be one.
The engineering: the section models are a closed set — eight entry types that change only with a code change — so write the eight SectionWith*Entries classes statically; create_model buys nothing there. For themes/locales, keep YAML as the source of truth but generate the Python variant modules as a build step (just update-schema already establishes this exact committed-generated-artifact pattern, CI-enforced by test_generated_files.py). The type checker then sees real classes, IDEs autocomplete themes, and the runtime reduce magic goes away. Annotate Design honestly as ClassicTheme (every theme, custom ones included, is structurally a ClassicTheme).

5. Control flow runs through the UI; there is no library boundary

run_rendercv lives in cli/ and demands a concrete ProgressPanel; ProgressPanel.print_user_error/print_validation_errors (a display component) raise typer.Exit(code=1) (progress_panel.py:135,169); the watcher then suppresses typer.Exit to survive (watcher.py:29,61). Display, error policy, and process termination are braided across three layers. Meanwhile rendercv/__init__.py exports nothing — the web app must import deep internals with no defined contract (Section 7) — and __init__.py:7 runs warnings.filterwarnings("ignore", module="pydantic"), globally muting pydantic warnings in every host application. entry_point.py:16 catches any ImportError and prescribes "reinstall with [full]" — misdiagnosing genuine import bugs as installation errors (Section 5: a fallback masking which path failed).
The engineering: a rendercv.render(inputs) -> RenderResult facade in the package root; it raises typed exceptions and reports steps through a small Protocol (two methods). The CLI is then: parse args → gather inputs → call facade → catch {RenderCVUserError, RenderCVUserValidationError} in one place (error_handler.py already exists for exactly this) → panel displays → Exit(1). The panel never raises; the watcher needs no suppression; watch mode calls "gather inputs" per render — which also deletes Bug #5 by construction rather than patching it. Photo downloading (model_processor.py:24-60 — network I/O, disk writes, and mutation of the original model, invoked from inside the templater, with a silent reuse-if-file-exists cache) moves to this input-gathering stage where I/O belongs.

Toolchain (Section 8)

Mostly exemplary — justfile + uv --frozen everywhere, CI calling the same recipes, generated artifacts (schema.json, examples, SKILL.md) CI-enforced against drift, the offline-wheel and pyodide tests, test_tests.py enforcing the tests-mirror-src convention. Remaining violations:

  • Two formatters: black (with unstable string_processing) runs locally in just format (justfile:9, with || true), but prek/CI runs only ruff-format. One axis, two tools, only one enforced. Pick ruff-format, delete black — or if black's string-splitting is the requirement, put black in prek and drop ruff-format. One must go.
  • .pre-commit-config.yaml pins ruff v0.15.7 and ty>=0.0.24 independently of the same pins in pyproject.toml dev deps — two version sources per tool.
  • test_generated_files.py:32-34 skips schema freshness on Windows ("generation differs") — a nondeterminism accepted as expected instead of engineered away.
  • Entry test data duplicated between tests/schema/models/cv/conftest.py and tests/renderer/conftest.py's return_value_for_field (which also has Bug #6).
  • Fix or delete the Hypothesis profiles (Bug 3rd party fonts are missing licenses #7): one line in test.yaml (HYPOTHESIS_PROFILE: ci) or remove the dead registrations.

What already embodies the philosophy (keep and defend)

Themes as YAML overrides on a single ClassicTheme model — one theme structure, N default-sets — is opinionation done right. One pipeline entry point with composable stages (rendercv_model_builder.py). The exception taxonomy (exception.py) maps exactly onto Section 5's expected/impossible split, and run_rendercv deliberately has no catch-all — real bugs propagate. Aggregated validation errors with YAML coordinates is a genuinely engineered UX. Reference-file testing with --update-testdata, 3×3 CI matrix, merged coverage with a 90% floor. Docstrings with "Why" sections. validation_context.py is small and clean. The #706 keyword-bolding fix added Hypothesis property tests — the right instinct; the properties now need a structure worth proving.

Priority order

  1. Now (small, structural, user-facing): Bug Requests instead of urllib, commit random pick of headers #1 (structural loc resolution, not a bigger denylist), Bug Remove 3rd party dependencies as source #8 (is not None), Bug Allow {start|end}_date to be less precise than a specific day #4 (empty link → validation error again), Bugs #6/3rd party fonts are missing licenses #7 in tests.
  2. The big one — split schema from render IR (finding 2): restores typo detection (extra="forbid"), deletes the mutation/deep-copy/setattr layer, and is the prerequisite for finding 1.
  3. Rebuild inline content on a tree (finding 1): kills the escaping/sentinel/per-line bug class that generates most of your fix traffic, including Bug Raise error only if status code is 404 #3.
  4. Library facade + display-only panel + per-render input gathering (finding 5): unblocks clean web-app usage and deletes Bug When a end or start date is just YYYY, date comparisons fails #5.
  5. Error machinery on type-keyed structure; explicit theme routing (finding 3).
  6. Codegen themes/locales, static section models (finding 4): drives ty: ignore count from 20 toward ~0.
  7. One formatter; toolchain dedup (Section 8 items).

Items 2+3 are one coherent re-engineering of the pipeline's middle — by Section 3 (still valid for internals), that rebuild is the expected mode, not an emergency. The YAML contract, themes, and CLI don't change; the reference-file test suite you already have is precisely the harness that makes this rebuild safe.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions