From f6be8cd157b5223ebe8c39178c493a839ace88a5 Mon Sep 17 00:00:00 2001 From: Oscar Levin Date: Sat, 27 Jun 2026 13:22:49 -0600 Subject: [PATCH 1/6] clean up leftover claude mess --- .../2026-06-17-pretext-validate-command.md | 440 ------------------ ...6-06-17-pretext-validate-command-design.md | 126 ----- 2 files changed, 566 deletions(-) delete mode 100644 docs/superpowers/plans/2026-06-17-pretext-validate-command.md delete mode 100644 docs/superpowers/specs/2026-06-17-pretext-validate-command-design.md diff --git a/docs/superpowers/plans/2026-06-17-pretext-validate-command.md b/docs/superpowers/plans/2026-06-17-pretext-validate-command.md deleted file mode 100644 index a36fbb5a..00000000 --- a/docs/superpowers/plans/2026-06-17-pretext-validate-command.md +++ /dev/null @@ -1,440 +0,0 @@ -# `pretext validate` Command 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:** Add a `pretext validate [TARGET] [--dev]` command that validates a target's assembled source against the PreTeXt RelaxNG schema and exits non-zero when the document is invalid. - -**Architecture:** Refactor schema validation in `pretext/utils.py` into composable engine helpers (`_validate_with_lxml`, existing `_validate_with_jing`) behind an orchestrator `run_schema_validation(etree, schema_file, order)`. The existing build-time `xml_validates_against_schema` becomes a thin wrapper (lxml-first, warn-only, unchanged behavior). A new Click command in `pretext/cli.py` calls the orchestrator jing-first, prints errors, and sets the process exit code. - -**Tech Stack:** Python, Click, lxml, pytest, pytest-console-scripts (`script_runner`). - -## Global Constraints - -- Do **not** change build-time validation behavior or ordering: `xml_validates_against_schema` stays lxml-first and warn-only. -- The three existing validation tests in `tests/test_utils.py` (`test_xml_validates_against_schema_jing_fallback_success`, `..._invalid`, `..._jing_unavailable`) must continue to pass unchanged. -- Engine functions are resolved by bare name inside `run_schema_validation` (call-time module-global lookup) so `monkeypatch.setattr(utils, "_validate_with_jing", ...)` works. -- Exit codes: valid → `0`; invalid → `1`; could-not-validate → `2`. Failure is signaled with `ctx.exit(...)` (raises `SystemExit`, which bypasses `nice_errors`' `except Exception`). -- Schemas live at `resources.resource_base_path() / "core" / "schema" / {pretext.rng | pretext-dev.rng}`. -- `pretext/utils.py` already imports: `from lxml import etree as ET`, `from lxml.etree import _Element`, `from typing import Optional`, `import typing as t`, `from . import ... resources`, and defines `log`. -- `pretext/cli.py` already imports: `utils`, `resources`, `click`, `sys`, `Optional`, `Path`, and defines `log`, `nice_errors`, `CONTEXT_SETTINGS`, `main`. - ---- - -### Task 1: Add lxml engine, orchestrator, and schema-path helpers in `utils.py` - -**Files:** -- Modify: `pretext/utils.py` (add helpers near the existing `_validate_with_jing`, which ends around line 294) -- Test: `tests/test_utils.py` - -**Interfaces:** -- Consumes: existing `_validate_with_jing(etree: _Element, schema_file: Path) -> Optional[tuple[bool, str]]` (returns `None` when jing is unavailable, `(True, "")` when valid, `(False, error_text)` when invalid). -- Produces: - - `_validate_with_lxml(etree: _Element, schema_file: Path) -> Optional[tuple[bool, str]]` — `None` when lxml cannot compile the schema (`RelaxNGParseError`), else `(is_valid, error_text)`. - - `run_schema_validation(etree: _Element, schema_file: Path, order: t.Sequence[str] = ("lxml", "jing")) -> tuple[Optional[bool], str]` — tries each named engine in `order`; returns the first engine result that is not `None`; returns `(None, )` if every engine is unavailable. - - `schema_path(dev: bool = False) -> Path` — returns the stable or dev schema file path. - -- [ ] **Step 1: Write the failing tests** - -Add to `tests/test_utils.py` (the file already imports `from lxml import etree as ET` and `from pretext import utils` on this branch): - -```python -def test_validate_with_lxml_valid_and_invalid(tmp_path: Path) -> None: - schema_file = tmp_path / "mini.rng" - schema_file.write_text( - '' - "" - ) - - ok = utils._validate_with_lxml(ET.fromstring(""), schema_file) - assert ok == (True, "") - - bad = utils._validate_with_lxml(ET.fromstring(""), schema_file) - assert bad is not None - assert bad[0] is False - assert bad[1] != "" - - -def test_validate_with_lxml_uncompilable_schema_returns_none( - monkeypatch: pytest.MonkeyPatch, tmp_path: Path -) -> None: - def _raise(*args: object, **kwargs: object) -> object: - raise ET.RelaxNGParseError("boom") - - monkeypatch.setattr(utils.ET, "RelaxNG", _raise) - assert utils._validate_with_lxml(ET.fromstring(""), tmp_path / "x.rng") is None - - -def test_run_schema_validation_uses_first_available_engine( - monkeypatch: pytest.MonkeyPatch, tmp_path: Path -) -> None: - monkeypatch.setattr(utils, "_validate_with_jing", lambda *a: (True, "")) - monkeypatch.setattr(utils, "_validate_with_lxml", lambda *a: (False, "lxml says no")) - - # jing first wins - assert utils.run_schema_validation( - ET.fromstring(""), tmp_path / "s.rng", order=("jing", "lxml") - ) == (True, "") - # lxml first wins - assert utils.run_schema_validation( - ET.fromstring(""), tmp_path / "s.rng", order=("lxml", "jing") - ) == (False, "lxml says no") - - -def test_run_schema_validation_skips_unavailable_engine( - monkeypatch: pytest.MonkeyPatch, tmp_path: Path -) -> None: - monkeypatch.setattr(utils, "_validate_with_jing", lambda *a: None) - monkeypatch.setattr(utils, "_validate_with_lxml", lambda *a: (True, "")) - assert utils.run_schema_validation( - ET.fromstring(""), tmp_path / "s.rng", order=("jing", "lxml") - ) == (True, "") - - -def test_run_schema_validation_all_unavailable_returns_none( - monkeypatch: pytest.MonkeyPatch, tmp_path: Path -) -> None: - monkeypatch.setattr(utils, "_validate_with_jing", lambda *a: None) - monkeypatch.setattr(utils, "_validate_with_lxml", lambda *a: None) - result = utils.run_schema_validation( - ET.fromstring(""), tmp_path / "s.rng", order=("jing", "lxml") - ) - assert result[0] is None - assert "could not be completed" in result[1] - - -def test_schema_path_selects_stable_or_dev() -> None: - assert utils.schema_path(dev=False).name == "pretext.rng" - assert utils.schema_path(dev=True).name == "pretext-dev.rng" - assert utils.schema_path().name == "pretext.rng" -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `python -m pytest tests/test_utils.py -k "validate_with_lxml or run_schema_validation or schema_path" -v` -Expected: FAIL with `AttributeError: module 'pretext.utils' has no attribute '_validate_with_lxml'` (and similar for the other new names). - -- [ ] **Step 3: Implement the helpers** - -In `pretext/utils.py`, immediately after the existing `_validate_with_jing` function (it ends near line 294, just before the `# boilerplate to prevent overzealous caching` comment), add: - -```python -def _validate_with_lxml( - etree: _Element, schema_file: Path -) -> Optional[tuple[bool, str]]: - # Returns None when lxml cannot compile the schema (the known - # "no define for ref" bug on some libxml2 builds), so the caller can - # fall back to another engine. - try: - relaxng = ET.RelaxNG(file=str(schema_file)) - except ET.RelaxNGParseError: - log.debug( - "lxml could not compile the RelaxNG schema; trying the next validator." - ) - return None - try: - relaxng.assertValid(etree) - return True, "" - except ET.DocumentInvalid as err: - return False, str(err.error_log) - - -def run_schema_validation( - etree: _Element, - schema_file: Path, - order: t.Sequence[str] = ("lxml", "jing"), -) -> tuple[Optional[bool], str]: - # Engines are looked up by name here (not captured at import time) so tests - # can monkeypatch `utils._validate_with_jing` / `utils._validate_with_lxml`. - engines = { - "lxml": _validate_with_lxml, - "jing": _validate_with_jing, - } - for engine_name in order: - result = engines[engine_name](etree, schema_file) - if result is not None: - return result - return None, ( - "Schema validation could not be completed: no validator was available " - "(jing is not installed and lxml could not compile the schema)." - ) - - -def schema_path(dev: bool = False) -> Path: - schema_name = "pretext-dev.rng" if dev else "pretext.rng" - return resources.resource_base_path() / "core" / "schema" / schema_name -``` - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `python -m pytest tests/test_utils.py -k "validate_with_lxml or run_schema_validation or schema_path" -v` -Expected: PASS (6 tests). - -- [ ] **Step 5: Commit** - -```bash -git add pretext/utils.py tests/test_utils.py -git commit -m "Add lxml engine, orchestrator, and schema-path helpers for validation" -``` - ---- - -### Task 2: Refactor `xml_validates_against_schema` to use the orchestrator - -**Files:** -- Modify: `pretext/utils.py:210-263` (the `xml_validates_against_schema` function body) -- Test: `tests/test_utils.py` (existing three tests are the safety net — no new tests) - -**Interfaces:** -- Consumes: `run_schema_validation(...)`, `schema_path(...)` from Task 1. -- Produces: `xml_validates_against_schema(etree: _Element) -> bool` — same signature and observable build-time behavior as before (lxml-first, warn-only, writes `.error_schema.log` on failure). - -- [ ] **Step 1: Confirm the existing tests currently pass (baseline)** - -Run: `python -m pytest tests/test_utils.py -k "xml_validates_against_schema" -v` -Expected: PASS (3 tests). This is the refactor safety net. - -- [ ] **Step 2: Replace the function body** - -In `pretext/utils.py`, replace the entire existing `xml_validates_against_schema` function (from `def xml_validates_against_schema(etree: _Element) -> bool:` through its final `return True`, currently lines ~210-263) with: - -```python -def xml_validates_against_schema(etree: _Element) -> bool: - schemarngfile = schema_path() - log.debug(f"Validating PreTeXt source against schema {schemarngfile}") - # Build-time validation stays lxml-first (fast) and warn-only. - is_valid, error_text = run_schema_validation( - etree, schemarngfile, order=("lxml", "jing") - ) - if is_valid: - log.info("PreTeXt source passed schema validation.") - return True - if is_valid is None: - log.warning(error_text + " Continuing with build.") - else: - log.debug( - "PreTeXt document did not pass schema validation; unexpected output " - "may result. See .error_schema.log for hints. Continuing with build." - ) - with open(".error_schema.log", "w") as error_log_file: - error_log_file.write(error_text) - return False -``` - -- [ ] **Step 3: Run the existing validation tests to verify they still pass** - -Run: `python -m pytest tests/test_utils.py -k "xml_validates_against_schema" -v` -Expected: PASS (3 tests) — `..._jing_fallback_success` (returns True, no log file), `..._jing_fallback_invalid` (log contains "schema validation error via jing"), `..._jing_unavailable` (log file exists). - -- [ ] **Step 4: Run the full utils test module** - -Run: `python -m pytest tests/test_utils.py -v` -Expected: PASS (all, including the 6 from Task 1). - -- [ ] **Step 5: Commit** - -```bash -git add pretext/utils.py -git commit -m "Refactor build-time schema validation to use shared orchestrator" -``` - ---- - -### Task 3: Add the `pretext validate` CLI command - -**Files:** -- Modify: `pretext/cli.py` (add a new command after the `build` command, which ends near line 730) -- Test: `tests/test_cli.py` - -**Interfaces:** -- Consumes: `utils.run_schema_validation(...)`, `utils.schema_path(...)` from Task 1; `ctx.obj["project"]` (set in `main`); `project.get_target(name)`; `target.source_element()` (assembles source, returns `_Element`). -- Produces: a Click command `validate` registered on `main`, invokable as `pretext validate [TARGET] [--dev]`. - -- [ ] **Step 1: Write the failing tests** - -Add to `tests/test_cli.py` (it already imports `Path`, `script_runner: ScriptRunner`, `PTX_CMD`). Add `from lxml import etree as ET` and `from pretext import utils` to its imports, then append: - -```python -def _validator_available() -> bool: - # True if either lxml or jing can run against the bundled schema. - result = utils.run_schema_validation( - ET.fromstring(""), utils.schema_path(), order=("lxml", "jing") - ) - return result[0] is not None - - -def _make_project(tmp_path: Path, script_runner: ScriptRunner) -> Path: - assert script_runner.run([PTX_CMD, "new"], cwd=tmp_path).success - return tmp_path / "new-pretext-project" - - -def test_validate_invalid_source_is_nonzero( - tmp_path: Path, script_runner: ScriptRunner -) -> None: - project = _make_project(tmp_path, script_runner) - main_src = project / "source" / "main.ptx" - main_src.write_text('\n\n') - ret = script_runner.run([PTX_CMD, "validate"], cwd=project) - assert ret.returncode != 0 - - -def test_validate_malformed_xml_is_nonzero( - tmp_path: Path, script_runner: ScriptRunner -) -> None: - project = _make_project(tmp_path, script_runner) - main_src = project / "source" / "main.ptx" - main_src.write_text("\n") # not well-formed - ret = script_runner.run([PTX_CMD, "validate"], cwd=project) - assert ret.returncode != 0 - - -@pytest.mark.skipif( - not _validator_available(), reason="no RelaxNG validator (lxml/jing) available" -) -def test_validate_valid_project_is_zero( - tmp_path: Path, script_runner: ScriptRunner -) -> None: - project = _make_project(tmp_path, script_runner) - ret = script_runner.run([PTX_CMD, "validate"], cwd=project) - assert ret.returncode == 0 - - -@pytest.mark.skipif( - not _validator_available(), reason="no RelaxNG validator (lxml/jing) available" -) -def test_validate_dev_schema_runs( - tmp_path: Path, script_runner: ScriptRunner -) -> None: - project = _make_project(tmp_path, script_runner) - ret = script_runner.run([PTX_CMD, "validate", "--dev"], cwd=project) - assert ret.returncode == 0 -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `python -m pytest tests/test_cli.py -k "validate" -v` -Expected: FAIL — `pretext validate` is not yet a command, so the runs exit non-zero with a "No such command 'validate'" usage error. (The two `..._nonzero` tests may *accidentally* pass for the wrong reason; that is fine — they will pass for the right reason after Step 3. The `..._is_zero` test will FAIL, proving the command is missing.) - -- [ ] **Step 3: Implement the command** - -In `pretext/cli.py`, add the following after the `build` command function (after its body ends near line 730, before the `@main.command(...)` for `generate`): - -```python -@main.command( - short_help="Validate source against the PreTeXt schema", - context_settings=CONTEXT_SETTINGS, -) -@click.argument("target_name", required=False, metavar="target") -@click.option( - "--dev", - is_flag=True, - help="Validate against the development schema (pretext-dev.rng) instead of the " - "stable schema, allowing experimental elements.", -) -@click.pass_context -@nice_errors -def validate(ctx: click.Context, target_name: Optional[str], dev: bool) -> None: - """ - Validate the source of TARGET against the PreTeXt RelaxNG schema. - - Reports schema errors and exits with a non-zero status when the document is - invalid, so it can gate CI or pre-commit checks. Without TARGET, the first - target in project.ptx is used. Exit codes: 0 = valid, 1 = invalid, 2 = - validation could not be performed (no validator available). - """ - project = ctx.obj["project"] - target = project.get_target(target_name) - - # Assemble the source (resolves xinclude); surfaces syntax/xinclude errors. - try: - etree = target.source_element() - except Exception as e: - log.error(f"Could not assemble source for validation: {e}") - ctx.exit(1) - - schema_file = utils.schema_path(dev) - log.info(f"Validating source against schema {schema_file.name}.") - is_valid, error_text = utils.run_schema_validation( - etree, schema_file, order=("jing", "lxml") - ) - - if is_valid: - log.info(f"PreTeXt source passed schema validation ({schema_file.name}).") - return - if is_valid is None: - log.error(error_text) - ctx.exit(2) - - with open(".error_schema.log", "w") as error_log_file: - error_log_file.write(error_text) - log.error("PreTeXt source did NOT pass schema validation:") - log.error(error_text) - log.error("See .error_schema.log for the full report.") - ctx.exit(1) -``` - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `python -m pytest tests/test_cli.py -k "validate" -v` -Expected: PASS (the two `..._nonzero` tests pass; the two skipif tests pass when a validator is available, else skip). - -- [ ] **Step 5: Confirm the command is wired up** - -Run: `pretext validate -h` -Expected: Help text for the validate command, showing the `--dev` option and the `[target]` argument. - -- [ ] **Step 6: Commit** - -```bash -git add pretext/cli.py tests/test_cli.py -git commit -m "Add pretext validate command" -``` - ---- - -### Task 4: Document the command in the changelog - -**Files:** -- Modify: `CHANGELOG.md` - -**Interfaces:** none. - -- [ ] **Step 1: Add a changelog entry** - -Open `CHANGELOG.md`, find the top-most "unreleased"/in-progress section (matching the existing format used by recent entries), and add a bullet: - -```markdown -- Added a `pretext validate` command that checks a target's source against the - RelaxNG schema and exits non-zero on failure (use `--dev` for the development - schema). Tries `jing` first, then falls back to lxml's built-in validator. -``` - -- [ ] **Step 2: Verify the changelog format matches surrounding entries** - -Run: `git diff CHANGELOG.md` -Expected: a single added bullet under the current unreleased section, consistent indentation/style with neighbors. - -- [ ] **Step 3: Commit** - -```bash -git add CHANGELOG.md -git commit -m "Document pretext validate command in changelog" -``` - ---- - -## Self-Review - -**1. Spec coverage:** -- Command surface `pretext validate [TARGET]`, single-target default, assembled source → Task 3. ✓ -- `--dev` flag selecting `pretext-dev.rng` → `schema_path` (Task 1) + flag wiring (Task 3) + test (Task 3). ✓ -- Engine jing-first/lxml-backup → Task 3 calls orchestrator `order=("jing","lxml")`. ✓ -- Exit codes 0/1/2 + `ctx.exit` to bypass `nice_errors` → Task 3 + Global Constraints. ✓ -- `.error_schema.log` written on invalid → Task 3. ✓ -- utils refactor (shared engines, unchanged build path) → Tasks 1 & 2. ✓ -- Build stays lxml-first/warn-only/unchanged → Task 2 + Global Constraints, guarded by existing tests. ✓ -- Testing: orchestrator unit tests (Task 1), build-path regression (Task 2), CLI exit-code/`--dev` tests (Task 3). ✓ -- Phase-2 wasm direction → documented in spec; intentionally out of scope of this plan. ✓ - -**2. Placeholder scan:** No TBD/TODO/"add error handling"/"similar to" — every code and test step contains complete content. ✓ - -**3. Type consistency:** `_validate_with_lxml`, `_validate_with_jing`, and the orchestrator all share `(_Element, Path) -> Optional[tuple[bool, str]]`; orchestrator returns `tuple[Optional[bool], str]`; `schema_path(dev: bool) -> Path`; command consumes these exact names. Build wrapper keeps `(_Element) -> bool`. Consistent across tasks. ✓ diff --git a/docs/superpowers/specs/2026-06-17-pretext-validate-command-design.md b/docs/superpowers/specs/2026-06-17-pretext-validate-command-design.md deleted file mode 100644 index 0d98e888..00000000 --- a/docs/superpowers/specs/2026-06-17-pretext-validate-command-design.md +++ /dev/null @@ -1,126 +0,0 @@ -# Design: `pretext validate` command - -**Date:** 2026-06-17 -**Status:** Approved (pending spec review) -**Branch:** `validate` - -## Summary - -Add a standalone `pretext validate` command that checks a target's assembled -PreTeXt source against the RelaxNG schema, reports errors clearly, and **exits -non-zero on failure** so it can gate CI and pre-commit workflows. - -This is distinct from the existing build-time validation, which only *warns and -continues* (the return value of `utils.xml_validates_against_schema` is ignored -at `project/__init__.py:742`). - -## Motivation - -- Authors and CI want a way to confirm a document is schema-valid *without* - running a full build. -- Build-time validation is intentionally lenient (warn-only) and lxml-first for - speed. A dedicated command can afford to be stricter and more correct. - -## Command surface - -``` -pretext validate [TARGET] [--dev] -``` - -- New `@main.command` in `pretext/cli.py`, mirroring the `build` command: - `@click.argument("target_name", required=False)`, `@click.pass_context`, - `@nice_errors`. -- Resolves the project from `ctx.obj["project"]` and the target via - `project.get_target(target_name)`. -- **Single-target default**: with no `TARGET`, validates the first target's - source (consistent with `build`/`generate`/`view`). -- Calls `target.source_element()` to **assemble** the source. This resolves - XInclude and surfaces XML-syntax/XInclude errors using existing messaging - before schema validation runs. - -### `--dev` flag - -- Default: validate against the **stable** schema `core/schema/pretext.rng`. -- `--dev`: validate against the **development** schema - `core/schema/pretext-dev.rng` (permits experimental elements not yet in the - stable release). Both files already ship in the bundled core. -- No custom-`--schema`-path flag for now (YAGNI; trivial to add later, since - schema selection is already a parameter of the engine helpers). - -## Validation engine: jing-first, lxml-backup - -For the standalone command, try **jing first** (correct on the full schema), -fall back to **lxml's** built-in RelaxNG if jing is not installed. - -Build-time validation is **unchanged**: it stays lxml-first and warn-only so it -is not slowed down. - -### Outcomes and exit codes - -| Outcome | Behavior | Exit code | -|---|---|---| -| Valid | Success message | `0` | -| Invalid | Print errors to terminal (stderr) + write `.error_schema.log` | `1` | -| Could not validate (jing absent **and** lxml hits the "no define for ref" compile bug) | Clear message that validity could not be confirmed | `2` | - -A distinct exit code for "could not validate" prevents CI from going silently -green when no engine could actually run. - -### Exit-code gotcha - -`nice_errors` (`cli.py:40`) catches every `Exception`, logs it, and returns -normally — yielding exit code `0`. To make the non-zero contract work, the -command signals failure with `ctx.exit(1)` / `sys.exit(...)`, which raise -`SystemExit` (a `BaseException`, not `Exception`); these slip past the -`except Exception` and let Click set the process exit code. - -## `utils` refactor (shared engine, unchanged build path) - -Goal: share engine logic without changing build-time behavior or speed. - -- Factor the two engines into peer helpers, each returning `(is_valid, error_text)` - or `None` (meaning "this engine could not run"): - - `_validate_with_jing(etree, schema_file)` — already exists. - - `_validate_with_lxml(etree, schema_file)` — new; returns `None` on - `RelaxNGParseError` (the compile bug) so the orchestrator can fall back. -- Add an orchestrator: - `run_schema_validation(etree, schema_file, order) -> tuple[Optional[bool], str]` - where `order` is the engine sequence to try; result `None` means "could not - validate with any engine". -- Keep `xml_validates_against_schema(etree)` for the **build path**: a thin - wrapper that calls the orchestrator with `order=[lxml, jing]`, stays warn-only, - and returns `bool`. Build behavior and ordering are preserved. -- The new command calls the orchestrator with `order=[jing, lxml]`, resolves the - schema file from `--dev`, and acts on the `(Optional[bool], error_text)` result - to print errors and set the exit code. - -## Testing - -- Unit tests in `tests/test_utils.py` for the orchestrator (extending the - existing monkeypatch-based tests on this branch): - - jing-first ordering when both engines are available, - - fallback to lxml when jing returns `None`, - - `None` result when both engines are unavailable. -- CLI test: `pretext validate` exits `0` on a known-valid sample and non-zero on - a known-invalid sample; `--dev` selects the dev schema file. - -## Future direction (phase 2, out of scope here) - -Replace the Java dependency with a correct, pure-install engine: - -- pretext-cli's **release CI** checks out `siefkenj/relaxng_validator_wasm` and - builds Python wheels via `maturin` for each platform/Python version, then - vendors or depends on them. -- The orchestrator gains a `wasm` engine that goes to the **front** of `order` - for the validate command, with jing/lxml remaining as fallbacks. -- Note: building maturin/PyO3 wheels requires a Rust toolchain at *build* time; - this is a CI/release-pipeline step, not a build-at-runtime step on the user's - machine. The validator is not yet published to PyPI, which is why phase 1 ships - on jing + lxml. - -## Out of scope - -- Schematron / `pretext-validation-plus.xsl` semantic validation. -- Custom arbitrary `--schema PATH` flag. -- Validating all targets at once (single-target default; could be revisited). -- Changing build-time validation behavior. From eb296103ba7a5d392f7c4c9990ff6823dfc5e743 Mon Sep 17 00:00:00 2001 From: Oscar Levin Date: Sat, 27 Jun 2026 16:09:29 -0600 Subject: [PATCH 2/6] Add support for experimental xsl-fop builds --- CHANGELOG.md | 8 ++++- pretext/__init__.py | 2 +- pretext/project/__init__.py | 62 ++++++++++++++++++++++++++----------- pretext/project/xml.py | 7 +++++ 4 files changed, 59 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 14a9dc0c..02d04068 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,11 +9,17 @@ Instructions: Add a subsection under `[Unreleased]` for additions, fixes, change ## [Unreleased] +### Added + +- Reader options menu for HTML output, in place of light/dark toggle. +- Additional accessibility improvements. +- Support for testing experimental PDF-FO method for generating PDFs. This is an experimental method that uses Apache FOP to generate PDFs from XSL-FO. It is not yet fully supported, but can be enabled by setting `pdf-method="pdf-fo"` on a target in the manifest. + ## [2.42.0] - 2026-06-18 Includes updates to core through commit: [afb99f7](https://github.com/PreTeXtBook/pretext/commit/afb99f78226d4631e3fe87f5a0d4d41ff753e26f) -# Added +### Added - You can now specify the debug level *after* the command (e.g., `pretext -v debug build` can now also be entered as `pretext build -v debug`). - Temporary directories created by core pretext will now be cleaned up at all debug levels. To save them for debugging, use the `--save-tmp-dirs` flag, as in `pretext --save-tmp-dirs build`. diff --git a/pretext/__init__.py b/pretext/__init__.py index 3db5fa21..634a0bae 100644 --- a/pretext/__init__.py +++ b/pretext/__init__.py @@ -19,7 +19,7 @@ VERSION = get_version("pretext", Path(__file__).parent.parent) -CORE_COMMIT = "afb99f78226d4631e3fe87f5a0d4d41ff753e26f" +CORE_COMMIT = "73e61ec2c3f91468a80a12ce249fa69a734e6d26" def activate() -> None: diff --git a/pretext/project/__init__.py b/pretext/project/__init__.py index 776788e5..df9146fe 100644 --- a/pretext/project/__init__.py +++ b/pretext/project/__init__.py @@ -29,7 +29,7 @@ ) import pydantic_xml as pxml from pydantic_xml.element.element import SearchMode -from .xml import Executables, LegacyProject, LatexEngine +from .xml import Executables, LegacyProject, LatexEngine, PdfMethod from . import generate from .. import constants from .. import core @@ -143,9 +143,11 @@ class Target(pxml.BaseXmlModel, tag="target", search_mode=SearchMode.UNORDERED): _source_element_with_ids: t.Optional[ET._Element] = None # A path to the publication file for this target, relative to the project's `publication` path. This is mostly validated by `post_validate`. publication: Path = pxml.attr(default=None) - latex_engine: LatexEngine = pxml.attr( - name="latex-engine", default=LatexEngine.XELATEX - ) + # `pdf_method` is the canonical PDF engine setting. + # `latex_engine` is retained only for backwards compatibility and will be + # mapped into `pdf_method` when `pdf_method` is not explicitly provided. + pdf_method: PdfMethod = pxml.attr(name="pdf-method", default=None) + latex_engine: t.Optional[LatexEngine] = pxml.attr(name="latex-engine", default=None) # Flag to indicate whether to include LaTeX source files in the output directory when building a PDF target. latex_source: t.Optional[str] = pxml.attr(name="latex-source", default=None) @@ -156,6 +158,19 @@ def latex_source_validator(cls, v: t.Optional[str]) -> bool: return False return v.lower() != "no" + @model_validator(mode="after") + def pdf_method_validator(self) -> "Target": + if self.pdf_method is None: + if self.latex_engine is not None: + self.pdf_method = PdfMethod(self.latex_engine.value) + else: + self.pdf_method = PdfMethod.XELATEX + if self.format == Format.LATEX and self.pdf_method == PdfMethod.PDF_FO: + raise ValueError( + "The LaTeX format does not support pdf-method='pdf-fo'. Please use a standard LaTeX PDF method instead." + ) + return self + braille_mode: BrailleMode = pxml.attr( name="braille-mode", default=BrailleMode.EMBOSS ) @@ -832,19 +847,30 @@ def build( ) log.debug(e, exc_info=True) elif self.format == Format.PDF: - # Include latex source files if requested. - if self.latex_source: - latex = True - core.pdf( - xml=self.source_abspath(), - pub_file=self.publication_abspath().as_posix(), - stringparams=stringparams_copy, - extra_xsl=custom_xsl, - out_file=out_file, - dest_dir=self.output_dir_abspath().as_posix(), - method=self.latex_engine, - outputs="all" if latex else "pdf-only", - ) + if self.pdf_method == PdfMethod.PDF_FO: + # Experimental support for the new PDF-FO method. + core.pdf_fo( + xml=self.source_abspath(), + pub_file=self.publication_abspath().as_posix(), + stringparams=stringparams_copy, + out_file=out_file, + dest_dir=self.output_dir_abspath().as_posix(), + ) + else: + # Otherwise we use the standard latex methods. + # Include latex source files if requested. + if self.latex_source: + latex = True + core.pdf( + xml=self.source_abspath(), + pub_file=self.publication_abspath().as_posix(), + stringparams=stringparams_copy, + extra_xsl=custom_xsl, + out_file=out_file, + dest_dir=self.output_dir_abspath().as_posix(), + method=self.pdf_method, + outputs="all" if latex else "pdf-only", + ) elif self.format == Format.LATEX: core.pdf( xml=self.source_abspath(), @@ -853,7 +879,7 @@ def build( extra_xsl=custom_xsl, out_file=out_file, dest_dir=self.output_dir_abspath().as_posix(), - method=self.latex_engine, + method=self.pdf_method, outputs="prebuild", ) elif self.format == Format.EPUB: diff --git a/pretext/project/xml.py b/pretext/project/xml.py index 8b3e2a7b..9b48f737 100644 --- a/pretext/project/xml.py +++ b/pretext/project/xml.py @@ -24,6 +24,7 @@ class Executables(pxml.BaseXmlModel, tag="executables"): node: str = pxml.attr(default="node") liblouis: str = pxml.attr(default="file2brl") perl: str = pxml.attr(default="perl") + fop: str = pxml.attr(default="fop") class LegacyFormat(str, Enum): @@ -45,6 +46,12 @@ class LatexEngine(str, Enum): LATEX = "latex" PDFLATEX = "pdflatex" +class PdfMethod(str, Enum): + XELATEX = "xelatex" + LATEX = "latex" + PDFLATEX = "pdflatex" + LUALATEX = "lualatex" + PDF_FO = "pdf-fo" class LegacyStringParam(pxml.BaseXmlModel): model_config = ConfigDict() From 0df844d9b65e0d92510c8ef1183bf1ada68007d1 Mon Sep 17 00:00:00 2001 From: Oscar Levin Date: Sat, 27 Jun 2026 16:23:15 -0600 Subject: [PATCH 3/6] format --- pretext/project/xml.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pretext/project/xml.py b/pretext/project/xml.py index 9b48f737..59ff55ac 100644 --- a/pretext/project/xml.py +++ b/pretext/project/xml.py @@ -46,6 +46,7 @@ class LatexEngine(str, Enum): LATEX = "latex" PDFLATEX = "pdflatex" + class PdfMethod(str, Enum): XELATEX = "xelatex" LATEX = "latex" @@ -53,6 +54,7 @@ class PdfMethod(str, Enum): LUALATEX = "lualatex" PDF_FO = "pdf-fo" + class LegacyStringParam(pxml.BaseXmlModel): model_config = ConfigDict() key: str = pxml.attr() From 6da60bfa2c2f21c698c22543bc739ce12cf0f8d6 Mon Sep 17 00:00:00 2001 From: Oscar Levin Date: Sun, 28 Jun 2026 12:34:42 -0600 Subject: [PATCH 4/6] fix broken tests to use new pdf_method --- tests/test_project.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/test_project.py b/tests/test_project.py index d586c279..a74fa39c 100644 --- a/tests/test_project.py +++ b/tests/test_project.py @@ -225,7 +225,7 @@ def test_manifest_legacy() -> None: "publication", "publication.ptx" ) assert t_html.output_dir_abspath() == project.abspath() / Path("output", "html") - assert t_html.latex_engine == "xelatex" + assert t_html.pdf_method == "xelatex" assert t_html.stringparams == {"one": "uno", "two": "dos"} t_latex = project.get_target("latex") @@ -233,14 +233,14 @@ def test_manifest_legacy() -> None: assert t_latex.source == Path("source", "main.ptx") assert t_latex.publication == Path("publication", "publication.ptx") assert t_latex.output_dir == Path("output", "latex") - assert t_latex.latex_engine == "xelatex" + assert t_latex.pdf_method == "xelatex" t_pdf = project.get_target("pdf") assert t_pdf.format == "pdf" assert t_pdf.source == Path("source", "main.ptx") assert t_pdf.publication == Path("publication", "publication.ptx") assert t_pdf.output_dir == Path("output", "pdf") - assert t_pdf.latex_engine == "pdflatex" + assert t_pdf.pdf_method == "pdflatex" assert not project.has_target("foo") @@ -265,7 +265,7 @@ def test_manifest_legacy_wrong() -> None: "publication", "publication.ptx" ) assert t_html.output_dir_abspath() == project.abspath() / Path("output", "html") - assert t_html.latex_engine == "xelatex" + assert t_html.pdf_method == "xelatex" assert t_html.stringparams == {"one": "uno", "two": "dos"} t_latex = project.get_target("latex") @@ -273,14 +273,14 @@ def test_manifest_legacy_wrong() -> None: assert t_latex.source == Path("source", "main.ptx") assert t_latex.publication == Path("publication", "publication.ptx") assert t_latex.output_dir == Path("output", "latex") - assert t_latex.latex_engine == "xelatex" + assert t_latex.pdf_method == "xelatex" t_pdf = project.get_target("pdf") assert t_pdf.format == "pdf" assert t_pdf.source == Path("source", "main.ptx") assert t_pdf.publication == Path("publication", "publication.ptx") assert t_pdf.output_dir == Path("output", "pdf") - assert t_pdf.latex_engine == "pdflatex" + assert t_pdf.pdf_method == "pdflatex" assert not project.has_target("foo") From 752ca07d2d5d002363924b9456eb0db483c14f3a Mon Sep 17 00:00:00 2001 From: Oscar Levin Date: Sun, 28 Jun 2026 12:42:44 -0600 Subject: [PATCH 5/6] fix broken tests to use new pdf_method --- tests/test_project.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_project.py b/tests/test_project.py index a74fa39c..a1bac0b6 100644 --- a/tests/test_project.py +++ b/tests/test_project.py @@ -54,7 +54,7 @@ def test_defaults(tmp_path: Path) -> None: assert target.output_dir == Path(name) assert target.deploy_dir is None assert target.xsl is None - assert target.latex_engine == pr.LatexEngine.XELATEX + assert target.pdf_method == pr.PdfMethod.XELATEX assert target.stringparams == {} # Default asy_method should be "local" assert project.asy_method == pr.AsyMethod.LOCAL From 0229b3a499aa7297a8e44d3667034409c0fce300 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 28 Jun 2026 19:01:09 +0000 Subject: [PATCH 6/6] test: make view port assertion resilient --- tests/test_cli.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/tests/test_cli.py b/tests/test_cli.py index 3cb008c8..da894c4c 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -9,8 +9,7 @@ import requests import pretext from lxml import etree as ET # noqa: N812 -from pretext import constants -from pretext import utils +from pretext import constants, server, utils from typing import cast, Generator import pytest from pytest_console_scripts import ScriptRunner @@ -460,9 +459,17 @@ def test_view(tmp_path: Path, script_runner: ScriptRunner) -> None: assert script_runner.run([PTX_CMD, "-v", "debug", "new", "-d", "1"]).success os.chdir(Path("1")) assert script_runner.run([PTX_CMD, "-v", "debug", "build"]).success - port = random.randint(10_000, 65_536) + port = random.randint(10_000, 65_535) with pretext_view("-p", f"{port}"): - r = requests.get(f"http://localhost:{port}") + project_hash = utils.hash_path(Path.cwd().resolve()) + running_server = None + for _ in range(50): + running_server = server.active_server_for_path_hash(project_hash) + if running_server is not None and running_server.is_active_server(): + break + time.sleep(0.1) + assert running_server is not None + r = requests.get(f"http://localhost:{running_server.port}") assert r.status_code == 200 assert script_runner.run([PTX_CMD, "-v", "debug", "view", "-s"]).success