From e409b8a26e33d6024c4be984b9b0491069400ae7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 07:04:25 +0000 Subject: [PATCH 1/4] Adopt legaldown-validator 0.4.0's public API; remove validator_bridge.py - Depend on legaldown-validator>=0.4.0,<0.5. - parse_document/validate_document -> parse/validate; assemble() -> parse_template(source).form(answers).assemble(). - ValidationResult fields -> result.index (sections, placed_markers, is_template, *_lookup). - Quote content from legaldown.syntax.quote_blocks, a drafting note's from drafting_note_blocks (replaces _without_drafting_marker and the past-depth branch), code blocks from code_content (replaces _code_block), the answers file from legaldown.load_answers (replaces cli._read_answers). - Lexer, directives and definition helpers from legaldown.syntax; grammar constants, value checks and conditions from legaldown.grammar, including VALID_PLACEHOLDER_TYPES and DECISION_QUESTION_TYPES for the resolver's own copies. - validator_bridge.py is deleted: nothing beyond the public API is used. - Tests fail on a legaldown DeprecationWarning (pytest filterwarnings). - Docs: architecture, roadmap U2/U3, CONFORMANCE, notes on ADR 0002 and 0007. Output is byte-identical (golden tests, and every specification example and fixture under each built-in style), except that a quote nested past the validator's quote depth that holds only a comment no longer renders an empty

, as at every shallower depth. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013WmBAc5T7UCKVdpUmg9qxz --- CONFORMANCE.md | 2 +- docs/architecture.md | 45 +++++----- docs/decisions/0002-one-parser.md | 3 + .../0007-one-parser-validator-model.md | 6 ++ docs/roadmap.md | 4 +- pyproject.toml | 7 +- src/legaldown_render/api.py | 11 +-- src/legaldown_render/build.py | 87 +++++-------------- src/legaldown_render/cli.py | 15 ++-- src/legaldown_render/resolve/resolver.py | 32 +++---- src/legaldown_render/tree.py | 4 +- src/legaldown_render/validator_bridge.py | 28 ------ tests/test_render.py | 36 ++++++-- 13 files changed, 126 insertions(+), 154 deletions(-) delete mode 100644 src/legaldown_render/validator_bridge.py diff --git a/CONFORMANCE.md b/CONFORMANCE.md index e1c369f..41fa580 100644 --- a/CONFORMANCE.md +++ b/CONFORMANCE.md @@ -1,7 +1,7 @@ # Conformance `legaldown-render` 0.2.0 targets **Level 2 — Rendering** of the LegalDown specification **0.2** -(§17.3). Core parsing and validation come from `legaldown-validator` 0.3.0, which claims Level 1 +(§17.3). Core parsing and validation come from `legaldown-validator` 0.4, which claims Level 1 — Core. Its own [CONFORMANCE.md](https://github.com/ForLegalAI/legaldown-validator/blob/main/CONFORMANCE.md) lists the Core rules it covers. diff --git a/docs/architecture.md b/docs/architecture.md index 0d227ee..45fa014 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -13,12 +13,12 @@ This page describes how the renderer is built. The decisions behind it are in │ normalize_source(): strip BOM, unify line endings ▼ ┌─────────────────────────┐ - │ 2. Assemble (answers) │ only with an answers set: legaldown-validator's assemble() turns - └───────────┬─────────────┘ the template into its assembled document, which the rest renders + │ 2. Assemble (answers) │ only with an answers set: legaldown-validator's Template and Form + └───────────┬─────────────┘ turn the template into its assembled document, which the rest renders ▼ ┌─────────────────────────┐ - │ 1. Parse & validate │ legaldown-validator: Document, ValidationResult (Core diagnostics, - └───────────┬─────────────┘ resolved identifiers, party/side/definition/attachment lookups) + │ 1. Parse & validate │ legaldown-validator: Document, ValidationResult (Core diagnostics; + └───────────┬─────────────┘ its index: identifiers, markers, party/side/definition/attachment lookups) ▼ ┌─────────────────────────┐ │ 3. Build build.py │ source + validator → render tree (source inlines) @@ -38,8 +38,8 @@ This page describes how the renderer is built. The decisions behind it are in Stage 2, **assembly** with an answers set (§15.7), runs only when the job has answers, before the document is parsed for rendering. A template rendered with answers is assembled first and the assembled document rendered; without answers, it renders as its **template view** (§15.8). -Assembly is the core package's (`legaldown.assemble`), exact to the byte as §15.7.2 requires; the -renderer only decides when to refuse: +Assembly is the core package's (`legaldown.parse_template(source).form(answers).assemble()`), +exact to the byte as §15.7.2 requires; the renderer only decides when to refuse: - the template has Errors: §15.7.2 defines assembly only for a template without them - the answers do (`answer-invalid`, `answer-missing`) @@ -55,11 +55,11 @@ semantic settings, and only stage 5 knows about file formats. ### 1. Parse and validate -`legaldown.parse_document` and `legaldown.validate_document` provide: +`legaldown.parse` and `legaldown.validate` provide: - the **metadata**: title, sides and parties, attachments, questions, language - the **section identifiers** exactly as the validator resolved them: explicit `{#id}`, or - generated by §5.3 with §5.5 collision handling. These come from `ValidationResult.sections`, + generated by §5.3 with §5.5 collision handling. These come from `result.index.sections`, so the renderer can never compute a different anchor than the one the validator checked - **lookups** for definitions, parties, sides, and attachments, already reduced to display names (§3.6) @@ -76,16 +76,17 @@ never decides a structural or LegalDown question itself: | Question | Answered by | |---|---| -| Sections, headings, identifiers | `Document.sections`, `ValidationResult.sections` | -| Blocks: paragraphs, lists and items, quotes, tables, code, rules | `Document` blocks. List items hold blocks (nested lists, code, quotes, tables), and a quote's content is the validator's `quote_content()` | -| Where a marker (`{#id when=…}`) is placed, and what it means | `ValidationResult.placed_markers`: each marker's block, field, offset, identifier, and condition | -| Whether the document is a template | `ValidationResult.is_template` | -| Whether a quote is a drafting note | The validator's `is_drafting_note()` | +| Sections, headings, identifiers | `Document.sections`, `result.index.sections` | +| Blocks: paragraphs, lists and items, quotes, tables, code, rules | `Document` blocks. List items hold blocks (nested lists, code, quotes, tables), a quote's content is the validator's `quote_blocks()`, and a code block's is `code_content()` | +| Where a marker (`{#id when=…}`) is placed, and what it means | `result.index.placed_markers`: each marker's block, field, offset, identifier, and condition | +| Whether the document is a template | `result.index.is_template` | +| Whether a quote is a drafting note, and its content | The validator's `is_drafting_note()` and `drafting_note_blocks()` | | Which list item a marker belongs to | `PlacedMarker.item`: the item's number among the list's items, in pre-order | | A lifted definition, `{{ref:}}` or `{{term:}}` block's source | The validator's `render_block()` | -The few imports beyond the validator's public API (quote content, fence helpers, the -answers-file reader) are all in `validator_bridge.py`, the list for roadmap item U3. +These are all the validator's public API: the model and the result from `legaldown`, the +reading of source text from `legaldown.syntax`, and the language's constants and rules from +`legaldown.grammar`. Within one block's text, **markdown-it-py parses inline Markdown only**: emphasis, links, code spans, inline HTML. Directives are protected first by **sentinels**. Every directive, and every @@ -116,7 +117,7 @@ Guessing at lost structure was tried, and it traded each gap for new bugs. 1. **Survey.** It collects which definitions exist, whether template constructs are used, and which placeholder ids are used with conflicting types (§10.7). -2. **Structure.** Section numbers are the validator's own (`ValidationResult.sections`), so a +2. **Structure.** Section numbers are the validator's own (`result.index.sections`), so a rendered number always matches the validator's: **alternatives** share a number (§15.8), and a skipped heading level counts as 1. The style only formats them, the n-th part of a number with the n-th level format. For paragraphs and list items, **alternatives** follow the @@ -198,18 +199,18 @@ src/legaldown_render/ | Dependency | Why | |---|---| -| `legaldown-validator>=0.3.0,<0.4` | The only LegalDown parser; Core validation | +| `legaldown-validator>=0.4.0,<0.5` | The only LegalDown parser; Core validation | | `markdown-it-py` | Inline Markdown inside one block's text (ADR 0007) | | `babel` | CLDR locale data (ADR 0005) | | `pyyaml` | Style templates | All of them are pure Python. The DOCX and PDF writers will bring their dependencies in as extras. -The renderer uses the validator's public API (`legaldown`, `legaldown.validator`), except for -the few names in `validator_bridge.py`: `quote_content` and `MAX_QUOTE_DEPTH`, the fence -helpers, and the answers-file reader. The pin to one minor version exists because of them. -Asking the validator to export them publicly is roadmap item U3 -([validator#93](https://github.com/ForLegalAI/legaldown-validator/issues/93)). +The renderer uses only the validator's public API: `legaldown`, and the tooling modules +`legaldown.syntax` and `legaldown.grammar` (roadmap item U3, done in validator 0.4.0). Before 1.0 +a minor release of the validator may change that API, so the dependency is pinned to one minor +version, and the tests fail on any of its `DeprecationWarning`s (`filterwarnings` in +`pyproject.toml`), since the next minor release removes what is deprecated. ## Errors and diagnostics diff --git a/docs/decisions/0002-one-parser.md b/docs/decisions/0002-one-parser.md index 1ce1c80..c67f380 100644 --- a/docs/decisions/0002-one-parser.md +++ b/docs/decisions/0002-one-parser.md @@ -49,3 +49,6 @@ cannot carry everything a renderer needs: - The renderer depends on markdown-it-py. - The outline check turns any disagreement between the two parsers into a loud bug report. - Some renderer features wait for upstream changes. That is deliberate. +- Since validator 0.4.0, the directive grammar and the other readings of source text come from its + supported tooling modules, `legaldown.syntax` and `legaldown.grammar`. `legaldown.directives` + is internal there. diff --git a/docs/decisions/0007-one-parser-validator-model.md b/docs/decisions/0007-one-parser-validator-model.md index 1463f4b..ddbd536 100644 --- a/docs/decisions/0007-one-parser-validator-model.md +++ b/docs/decisions/0007-one-parser-validator-model.md @@ -64,3 +64,9 @@ module, `validator_bridge.py`. That module is the list for roadmap item U3. the builder turns its listed items and their depths into nested lists, and "2.1(b)(i)" replaced "2.1(c)". - The build stage is smaller: about 100 lines of position rules and special cases are gone. +- The bridge is gone since validator 0.4.0, which made all of it public (roadmap U3). The + result's decisions moved to `result.index` (`placed_markers`, `is_template`, `sections`). A + quote's content comes from `legaldown.syntax.quote_blocks`, and a drafting note's from + `drafting_note_blocks`, which replaced the builder's own removal of the `[!DRAFTING]` marker + (validator #88). A code block's content comes from `code_content`, and an answers file is read by + `legaldown.load_answers`. `validator_bridge.py` was deleted. diff --git a/docs/roadmap.md b/docs/roadmap.md index 04468b4..09c0ff2 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -11,8 +11,8 @@ here. | # | Where | Change | Status | |---|---|---|---| | U1 | validator | **Keep nested list structure** in the model ([#14](https://github.com/ForLegalAI/legaldown-validator/issues/14)) | Done in [validator#61](https://github.com/ForLegalAI/legaldown-validator/pull/61); the renderer nests lists from it (v0.2) | -| U2 | validator | **Source positions** (line numbers) on sections, blocks, and diagnostics ([#27](https://github.com/ForLegalAI/legaldown-validator/issues/27)) | Done for the validator's diagnostics in 0.3.0 ([validator#85](https://github.com/ForLegalAI/legaldown-validator/pull/85)); the CLI prints them. Still open: a block's line, for the renderer's own diagnostics ([#93](https://github.com/ForLegalAI/legaldown-validator/issues/93)) | -| U3 | validator | **Public API** ([#26](https://github.com/ForLegalAI/legaldown-validator/issues/26)) for what the renderer imports from submodules, and the validator's own **placed markers** and **template decision** | Mostly done in 0.3.0 ([validator#90](https://github.com/ForLegalAI/legaldown-validator/pull/90)): the renderer reads `placed_markers` and `is_template` from the result. Left in `validator_bridge.py`: `quote_content`, the fence helpers, the answers-file reader ([#93](https://github.com/ForLegalAI/legaldown-validator/issues/93)), and a drafting note's content ([#88](https://github.com/ForLegalAI/legaldown-validator/issues/88)). Until then, the dependency is pinned to one minor version | +| U2 | validator | **Source positions** (line numbers) on sections, blocks, and diagnostics ([#27](https://github.com/ForLegalAI/legaldown-validator/issues/27)) | Done for the validator's diagnostics in 0.3.0 ([validator#85](https://github.com/ForLegalAI/legaldown-validator/pull/85)); the CLI prints them. 0.4.0 makes a block's and a list item's line public (`Document.line_of`, `Document.layout()`). Still open: the line of a directive within a block, which the renderer's own diagnostics are about ([#93](https://github.com/ForLegalAI/legaldown-validator/issues/93)) | +| U3 | validator | **Public API** ([#26](https://github.com/ForLegalAI/legaldown-validator/issues/26)) for what the renderer imports from submodules, and the validator's own **placed markers** and **template decision** | Done. Mostly in 0.3.0 ([validator#90](https://github.com/ForLegalAI/legaldown-validator/pull/90)): `placed_markers` and `is_template` on the result. The rest in 0.4.0: quote and drafting-note content, code content, and the answers-file reader ([#93](https://github.com/ForLegalAI/legaldown-validator/issues/93), [#88](https://github.com/ForLegalAI/legaldown-validator/issues/88)), in the tooling modules `legaldown.syntax` and `legaldown.grammar`. The renderer adopted it and dropped `validator_bridge.py`. The dependency stays pinned to one minor version, since before 1.0 a minor release may change the API | | U1b | validator | **Parser gaps** the renderer inherits. Fixed in validator `main`: list markers (#21), tables (#22, #44), comments and HTML blocks (#23, #59), the signature-block cutoff (#24), empty comments (#28), indented code (#9, #41), empty list items (#46), content after a nested list (#64), nested marker changes (#65), headings in quotes and items (#78), raw-html (#40). hard breaks (#25). Still open: link reference definitions ([#92](https://github.com/ForLegalAI/legaldown-validator/issues/92)) | Mostly done; adopted with 0.3.0. Listed in `CONFORMANCE.md` | | U4a | validator | **Specification 0.2** (templates, §15) | Done in 0.2.0 | | U4b | validator | Template **assembly** with an answers set (§15.7, [#30](https://github.com/ForLegalAI/legaldown-validator/issues/30)) | Done in [validator#55](https://github.com/ForLegalAI/legaldown-validator/pull/55); the renderer uses it (v0.2) | diff --git a/pyproject.toml b/pyproject.toml index 6394fe0..bad173b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,7 +31,7 @@ classifiers = [ dependencies = [ # The only LegalDown parser (docs/decisions/0002). A minor bump may change # the model, so the range is pinned to one minor version. - "legaldown-validator>=0.3.0,<0.4", + "legaldown-validator>=0.4.0,<0.5", # Inline Markdown (emphasis, links, code spans) within the validator's # blocks, parsed in inline mode only (docs/decisions/0007). "markdown-it-py>=3.0,<5", @@ -76,3 +76,8 @@ pythonpath = ["src"] markers = [ "conformance: renders the LegalDown specification's examples and fixtures (needs a checkout of the specification)", ] +# A deprecated legaldown-validator API fails the tests: the next minor +# release removes it, and the dependency range admits only one minor version. +filterwarnings = [ + 'error:(legaldown\.|import_definitions):DeprecationWarning', +] diff --git a/src/legaldown_render/api.py b/src/legaldown_render/api.py index e58ac79..9defbf5 100644 --- a/src/legaldown_render/api.py +++ b/src/legaldown_render/api.py @@ -18,7 +18,7 @@ from typing import Any import yaml -from legaldown import AssemblyError, Diagnostic, Document, ValidationResult, assemble, parse_document, validate_document +from legaldown import AssemblyError, Diagnostic, Document, ValidationResult, parse, parse_template, validate from .build import build_tree, normalize_source from .errors import DocumentError, RenderRefused @@ -104,7 +104,7 @@ def render(source: str, options: RenderOptions | None = None, /, **settings: Any if options.answers is not None: source, assembled = _assemble(source, options.answers) document = _parse(source) - result = validate_document(document, final=options.final) + result = validate(document, final=options.final) diagnostics = assembled + list(result.diagnostics) if options.strict and any(d.level == "error" for d in diagnostics): raise RenderRefused(diagnostics) @@ -131,13 +131,14 @@ def _assemble(source: str, answers: Mapping[str, Any]) -> tuple[str, list[Diagno no output, or the answers do (``answer-invalid``, ``answer-missing``), or the template needs files a renderer below Full does not read (includes, LegalDown attachments, translations; §17.6).""" - findings = validate_document(_parse(source)).diagnostics + findings = validate(_parse(source)).diagnostics errors = sum(1 for d in findings if d.level == "error") if errors: raise RenderRefused(findings, f"Assembly refused: the template has {errors} error(s), " "and only a template without errors can be assembled (§15.7.2).") try: - result = assemble(source, answers) + # No resolve=: a template that needs other files is refused (§17.6). + result = parse_template(source).form(answers).assemble() except AssemblyError as error: raise DocumentError(f"The template cannot be assembled: {error}") from error if not result.ok: @@ -150,7 +151,7 @@ def _assemble(source: str, answers: Mapping[str, Any]) -> tuple[str, list[Diagno def _parse(source: str) -> Document: try: - return parse_document(source) + return parse(source) except (ValueError, yaml.YAMLError) as error: raise DocumentError(f"The document cannot be read: {error}") from error diff --git a/src/legaldown_render/build.py b/src/legaldown_render/build.py index 3a90891..7c8ae00 100644 --- a/src/legaldown_render/build.py +++ b/src/legaldown_render/build.py @@ -3,8 +3,8 @@ There is one parser: ``legaldown-validator``'s. Its ``Document`` gives the sections, their blocks, and each block's text, and its ``ValidationResult`` -says where markers are placed and whether the document is a template -(``placed_markers``, ``is_template``). The builder never decides a +index says where markers are placed and whether the document is a template +(``result.index.placed_markers``, ``result.index.is_template``). The builder never decides a structural or LegalDown question itself. Within one block's text it still needs inline Markdown — emphasis, links, @@ -18,7 +18,8 @@ Lists and quotes are built as the validator's model holds them: list items hold blocks, nested lists included, and a quote's content is the blocks the -validator reads in it (ForLegalAI/legaldown-validator#71, #72). +validator reads in it (``legaldown.syntax.quote_blocks``, +ForLegalAI/legaldown-validator#71, #72). """ from __future__ import annotations @@ -31,15 +32,16 @@ from urllib.parse import unquote from legaldown import Block as ModelBlock -from legaldown import ( +from legaldown import Document, PlacedMarker, ValidationResult +from legaldown.syntax import ( Directive, - Document, - PlacedMarker, - ValidationResult, + code_content, + drafting_note_blocks, find_definition_anchors, is_drafting_note, lex, list_items, + quote_blocks, render_block, ) from markdown_it import MarkdownIt @@ -70,7 +72,6 @@ Table, Text, ) -from .validator_bridge import FENCE_OPEN_RE, MAX_QUOTE_DEPTH, closes_fence, dedent, indent_width, quote_content _OPEN, _CLOSE = "\ue000", "\ue001" # Leads the sentinel of source that renders nothing (a {{def:}}). It is @@ -89,9 +90,6 @@ #: before resolving or writing it. (The validator caps lists at 64 levels #: and quotes at 16 itself.) MAX_NESTING = 100 -# A drafting note's first line (§15.6). Which quotes are drafting notes is -# the validator's decision (is_drafting_note); this text is taken off. -DRAFTING_MARKER = "[!DRAFTING]" def normalize_source(source: str) -> str: @@ -177,7 +175,7 @@ def __init__(self, document: Document, result: ValidationResult) -> None: # The markers the validator placed, by (section index or None for # the preamble, block index). self.placed: dict[tuple[int | None, int], list[PlacedMarker]] = {} - for marker in result.placed_markers: + for marker in result.index.placed_markers: self.placed.setdefault((marker.section, marker.block), []).append(marker) # Each text is lexed once in a render. self.lex = cache(lex) @@ -373,7 +371,8 @@ def block(self, block: ModelBlock, placed: list[PlacedMarker], *, top_level: boo case "quote": return self.quote(block) case "code": - return _code_block(block.text) + code = code_content(block) + return CodeBlock(code.text, code.info) case "table": width = len(block.headers) # Every row as wide as the header: short rows padded, extra @@ -436,28 +435,19 @@ def _check_depth(self, more: int) -> None: def quote(self, block: ModelBlock) -> Block: """A block quote, its content the blocks the validator reads in it - (``quote_content``). Past the validator's quote depth, it reads the - quote's text as one, and so does the builder. A drafting note is - decided by the validator's own test (§15.6); its ``[!DRAFTING]`` - marker, which starts its first paragraph or heading in the - validator's reading, is not shown.""" + (``quote_blocks``). Past the validator's quote depth, it reads the + quote's text as one paragraph, and so does the builder. A drafting + note is decided by the validator's own test (§15.6), and its content + is the validator's reading without the ``[!DRAFTING]`` marker + (``drafting_note_blocks``).""" drafting = is_drafting_note(block) self._check_depth(1) + # The quotes and list items the quote is in, before entering it. + read = drafting_note_blocks if drafting else quote_blocks + children = read(block, depth=self.depth) self.depth += 1 try: - if self.depth <= MAX_QUOTE_DEPTH: - children = list(quote_content(block.text, self.depth)[0]) - if drafting: - unmarked = _without_drafting_marker(children) - # When the marker does not start the first paragraph or - # heading (an indented marker line reads as code), the - # note is the text after its marker line. - children = unmarked if unmarked is not None else list( - quote_content(block.text.partition("\n")[2], self.depth)[0]) - blocks = self.blocks(children, None, markers=False) - else: - text = block.text.partition("\n")[2] if drafting else block.text - blocks = (Paragraph(self.text(text)),) if text.strip() else () + blocks = self.blocks(children, None, markers=False) finally: self.depth -= 1 return DraftingNote(blocks) if drafting else Quote(blocks) @@ -470,7 +460,7 @@ def tree(self) -> RenderTree: Section( level=section.level, title=self.text(section.title), - identifier=self.result.sections[index].identifier, + identifier=self.result.index.sections[index].identifier, condition=section.condition, blocks=self.blocks(section.blocks, index), ) @@ -483,24 +473,10 @@ def tree(self) -> RenderTree: language=self.language, preamble=self.blocks(document.preamble, None), sections=sections, - is_template=self.result.is_template, + is_template=self.result.index.is_template, ) -def _without_drafting_marker(children: list[ModelBlock]) -> list[ModelBlock] | None: - """A drafting note's blocks, as the validator reads them, without the - ``[!DRAFTING]`` marker that starts the first of them (its first line is - the marker, §15.6); a block that held only the marker goes. None when - the marker does not start a first paragraph or heading. To be replaced by - the validator's own reading (ForLegalAI/legaldown-validator#88).""" - first = children[0] if children else None - if first is None or first.kind not in ("paragraph", "heading") \ - or not first.text.upper().startswith(DRAFTING_MARKER): - return None - rest = first.text[len(DRAFTING_MARKER):].lstrip() - return ([replace(first, text=rest)] if rest else []) + children[1:] - - def _strip_markers(block: ModelBlock, placed: list[PlacedMarker]) -> tuple[ModelBlock, str, str]: """*block* without its placed marker, and the marker's identifier and condition. The validator says which field holds the marker.""" @@ -530,23 +506,6 @@ def _paragraph_source(block: ModelBlock) -> str: return block.text if block.kind == "paragraph" else render_block(block) -def _code_block(text: str) -> CodeBlock: - """A code block from the validator's model, read by the validator's - CommonMark rules: an indented block loses four columns from each line; - a fenced one loses its fences, and from each line as much indentation - as its opening fence had.""" - lines = text.split("\n") - opening = FENCE_OPEN_RE.match(lines[0]) if lines else None - if opening is None: - return CodeBlock("\n".join(dedent(line, 4) for line in lines) + "\n") - body = lines[1:] - if body and closes_fence(body[-1], opening.group("fence")): - body = body[:-1] - indent = indent_width(lines[0]) - body = [dedent(line, indent) for line in body] - return CodeBlock("\n".join(body) + ("\n" if body else ""), lines[0][opening.end():].strip()) - - def _trim(inlines: tuple[Inline, ...]) -> tuple[Inline, ...]: """*inlines* without spacing at either end (left where a comment or a marker was removed).""" diff --git a/src/legaldown_render/cli.py b/src/legaldown_render/cli.py index 1931fdd..d7e3eab 100644 --- a/src/legaldown_render/cli.py +++ b/src/legaldown_render/cli.py @@ -12,11 +12,12 @@ from pathlib import Path from typing import Any +from legaldown import AnswersError, load_answers + from . import __version__ from .api import RenderOptions, render from .errors import DocumentError, InternalError, RenderRefused from .style import StyleError, builtin_styles, dump_style, load_style, parse_override -from .validator_bridge import read_answers from .writers import FORMATS, format_for_path @@ -92,10 +93,14 @@ def main(argv: list[str] | None = None) -> int: answers = None if args.answers: - # Read as legaldown-validator's `legaldown assemble` reads it (§15.7.1). - answers, failure = read_answers(Path(args.answers)) - if failure: - print(f"legaldown-render: {failure}", file=sys.stderr) + # Read as legaldown-validator reads an answers set (§15.7.1). + try: + answers = load_answers(args.answers) + except OSError as error: + print(f"legaldown-render: cannot read {Path(args.answers)}: {error}", file=sys.stderr) + return 1 + except AnswersError as error: + print(f"legaldown-render: {error}", file=sys.stderr) return 1 options = RenderOptions(format=output_format, style=args.style, overrides=overrides, diff --git a/src/legaldown_render/resolve/resolver.py b/src/legaldown_render/resolve/resolver.py index 105903f..cd44dc4 100644 --- a/src/legaldown_render/resolve/resolver.py +++ b/src/legaldown_render/resolve/resolver.py @@ -24,11 +24,13 @@ from dataclasses import dataclass, replace from typing import Any, NamedTuple -from legaldown import Diagnostic, Directive, Document, ValidationResult, slugify_identifier -from legaldown.validator import ( +from legaldown import Diagnostic, Document, ValidationResult +from legaldown.grammar import ( ALWAYS, + DECISION_QUESTION_TYPES, IDENTIFIER_RE, KNOWN_CURRENCIES, + VALID_PLACEHOLDER_TYPES, Presence, condition_problem, exclusive, @@ -36,7 +38,9 @@ is_valid_iso_date, is_valid_money_amount, parse_condition, + slugify_identifier, ) +from legaldown.syntax import Directive from ..build import plain_inlines from ..style import Style, effective_labels @@ -86,9 +90,6 @@ #: The attachments heading's anchor, with a colon for the same reason. ATTACHMENTS_ANCHOR = "ld:attachments" -_VALUE_TYPES = ("text", "date", "money", "duration") -_DECISION_TYPES = ("boolean", "choice") - #: Markers for a directive that breaks the §11.2 grammar: it carries no #: arguments, so its type's marker is shown without a value. _MALFORMED = { @@ -268,7 +269,7 @@ def _register(self, identifier: str, target: _Target) -> None: def _number_sections(self) -> list[Section]: """Number the sections, and record each one's presence (§15.3). - The numbers are the validator's own (``ValidationResult.sections``), + The numbers are the validator's own (``result.index.sections``), so a rendered number and a reference to it always agree with the validator: alternatives share a number (§15.8), and a skipped heading level counts as 1. The style only formats them, the n-th @@ -279,7 +280,7 @@ def _number_sections(self) -> list[Section]: levels = heading_levels(self.style.numbering) presences: dict[int, Presence] = {} # level -> presence of the open section out: list[Section] = [] - for section, indexed in zip(self.tree.sections, self.result.sections, strict=True): + for section, indexed in zip(self.tree.sections, self.result.index.sections, strict=True): # The heading's own level, clamped to 1-5 (§4.1), decides which # sections enclose it, and so its presence (§15.3). level = min(max(section.level, 1), 5) @@ -576,7 +577,7 @@ def _ambiguity_message(self, target_id: str, target: _Target, clashes: list[_Slo "numbering so that they differ.") def _term(self, directive: Directive, definition_id: str) -> Inline: - term = self.result.definition_lookup.get(definition_id) + term = self.result.index.definition_lookup.get(definition_id) if term is None: return FailureMarker(f"[UNDEFINED: {definition_id}]") text = directive.params.get("label") or self._plain_value(term) @@ -598,10 +599,10 @@ def _money(self, directive: Directive, amount: str) -> Inline: return Value("money", self.formatter.money(amount, currency)) def _party(self, directive: Directive, name: str) -> Inline: - return self._named("party", name, self.result.party_lookup, directive) + return self._named("party", name, self.result.index.party_lookup, directive) def _side(self, directive: Directive, name: str) -> Inline: - return self._named("side", name, self.result.side_lookup, directive) + return self._named("side", name, self.result.index.side_lookup, directive) def _named(self, kind: str, name: str, lookup: dict[str, str], directive: Directive) -> Inline: """``{{party:}}`` and ``{{side:}}`` (§13.5): failure markers first — a @@ -640,9 +641,10 @@ def _placeholder(self, directive: Directive, placeholder_id: str) -> Inline: return invalid placeholder_type = self._placeholder_type(directive) declared = self.questions.get(placeholder_id) - if isinstance(declared, dict) and declared.get("type") in _DECISION_TYPES: + declared_type = declared.get("type") if isinstance(declared, dict) else None + if isinstance(declared_type, str) and declared_type in DECISION_QUESTION_TYPES: return invalid # a decision question's id is not a blank (§15.2) - if placeholder_type not in _VALUE_TYPES or placeholder_id in self.inconsistent_placeholders: + if placeholder_type not in VALID_PLACEHOLDER_TYPES or placeholder_id in self.inconsistent_placeholders: return invalid currency = directive.params.get("currency") if placeholder_type == "money" and currency is not None and currency not in KNOWN_CURRENCIES: @@ -656,7 +658,7 @@ def _placeholder(self, directive: Directive, placeholder_id: str) -> Inline: return Blank(placeholder_id, self.style.placeholders.blank, prompt) def _attach(self, directive: Directive, attachment_id: str) -> Inline: - title = self.result.attachment_lookup.get(attachment_id) + title = self.result.index.attachment_lookup.get(attachment_id) if title is None: return FailureMarker(f"[UNKNOWN ATTACHMENT: {attachment_id}]") anchor = self.attachment_anchors.get(attachment_id) @@ -745,7 +747,7 @@ def _sides(self) -> tuple[SideInfo, ...]: name=self._frontmatter(party.legal_name or party.label or party.name), details=tuple(details), )) - label = self.result.side_lookup.get(side.name) or side.label or side.name + label = self.result.index.side_lookup.get(side.name) or side.label or side.name sides.append(SideInfo(self._frontmatter(label), tuple(parties))) return tuple(sides) @@ -773,7 +775,7 @@ def _signatures(self) -> tuple[SignatureParty, ...]: return () blocks: list[SignatureParty] = [] for side in self.metadata.sides: - label = self._frontmatter(self.result.side_lookup.get(side.name) or side.label or side.name) + label = self._frontmatter(self.result.index.side_lookup.get(side.name) or side.label or side.name) for party in side.parties: # Where signature blocks are generated, legal_name MUST appear (§3.6). blocks.append(SignatureParty( diff --git a/src/legaldown_render/tree.py b/src/legaldown_render/tree.py index 6e15f78..edce701 100644 --- a/src/legaldown_render/tree.py +++ b/src/legaldown_render/tree.py @@ -15,7 +15,7 @@ from collections.abc import Callable, Iterator from dataclasses import dataclass, field, replace -from legaldown import Directive +from legaldown.syntax import Directive from .errors import InternalError @@ -75,7 +75,7 @@ class Image: @dataclass(frozen=True, slots=True) class DirectiveSource: - """A directive as lexed by ``legaldown.directives``, not yet resolved.""" + """A directive as lexed by ``legaldown.syntax``, not yet resolved.""" directive: Directive diff --git a/src/legaldown_render/validator_bridge.py b/src/legaldown_render/validator_bridge.py deleted file mode 100644 index 9032d89..0000000 --- a/src/legaldown_render/validator_bridge.py +++ /dev/null @@ -1,28 +0,0 @@ -"""Everything the renderer takes from ``legaldown-validator`` beyond its -public API, in one place. - -The renderer never re-derives a LegalDown rule (docs/decisions/0002): where -the validator has decided something, the renderer asks it. Since -legaldown-validator 0.3.0 nearly all of it is public — where markers are -placed and whether a document is a template come with the -``ValidationResult`` (``placed_markers``, ``is_template``), and the model -helpers are exported from ``legaldown``. What is left here is how the -validator reads a block quote's content and a fenced code block, and how -its CLI reads an answers file. The dependency stays pinned to one minor -version while this list is not empty. -""" -from __future__ import annotations - -from legaldown.cli import _read_answers as read_answers -from legaldown.markdown import FENCE_OPEN_RE, closes_fence, dedent, indent_width -from legaldown.parser import MAX_QUOTE_DEPTH, quote_content - -__all__ = [ - "FENCE_OPEN_RE", - "MAX_QUOTE_DEPTH", - "closes_fence", - "dedent", - "indent_width", - "quote_content", - "read_answers", -] diff --git a/tests/test_render.py b/tests/test_render.py index 14ecc0e..d3c8f7b 100644 --- a/tests/test_render.py +++ b/tests/test_render.py @@ -285,9 +285,9 @@ def test_contents_follow_the_numbering_depth_not_the_heading_level() -> None: "# A\n\n###### B\n\n# C\n", ]) def test_section_numbers_are_the_validators(body: str) -> None: - from legaldown import parse_document, validate_document + from legaldown import parse, validate result = render(FRONT + body, format="text") - expected = [entry.number for entry in validate_document(parse_document(FRONT + body)).sections] + expected = [entry.number for entry in validate(parse(FRONT + body)).index.sections] assert [section.designation for section in result.tree.sections] == expected @@ -543,11 +543,13 @@ def test_item_opening_with_a_nested_list_keeps_its_label_first() -> None: def test_an_unresolved_node_is_an_internal_error() -> None: from dataclasses import replace + from legaldown.syntax import iter_directives + from legaldown_render import InternalError from legaldown_render.tree import DirectiveSource, Paragraph, assert_resolved result = render(FRONT + "# A\n\n{{party: acme}}\n", format="text") - directive = next(iter(__import__("legaldown").iter_directives("{{party: acme}}"))) + directive = next(iter(iter_directives("{{party: acme}}"))) section = replace(result.tree.sections[0], blocks=(Paragraph((DirectiveSource(directive),)),)) with pytest.raises(InternalError): assert_resolved(replace(result.tree, sections=(section,))) @@ -664,12 +666,12 @@ def test_a_shared_number_excludes_every_unit_holding_it() -> None: def test_section_numbers_agree_with_the_validator() -> None: - from legaldown import parse_document, validate_document + from legaldown import parse, validate source = FRONT.replace("title: T", THREE_WAY) + ( "# A {#x when=q:a}\n\n# B\n\n# C {#x when=q:b}\n\n# D {#d when=q:a}\n\n# E {#d when=q:b}\n") result = render(source, format="text") - expected = [entry.number for entry in validate_document(parse_document(source)).sections] + expected = [entry.number for entry in validate(parse(source)).index.sections] assert [section.designation for section in result.tree.sections] == expected == ["1", "2", "3", "4", "4"] @@ -704,11 +706,11 @@ def test_hidden_lead_character_in_the_source_is_plain_text() -> None: def test_level_six_headings_are_numbered_as_the_validator_numbers_them() -> None: - from legaldown import parse_document, validate_document + from legaldown import parse, validate source = FRONT + "# A\n\n## B\n\n### C\n\n#### D\n\n##### E\n\n###### F\n" result = render(source, format="text") - expected = [entry.number for entry in validate_document(parse_document(source)).sections] + expected = [entry.number for entry in validate(parse(source)).index.sections] assert [section.designation for section in result.tree.sections] == expected @@ -933,6 +935,22 @@ def test_deep_lists_and_quotes_render_as_the_validator_caps_them(output_format: assert "deepest" in render(FRONT + "# A\n\n" + marker * 2000 + "deepest\n", format=output_format).output +@pytest.mark.parametrize("depth", [16, 17]) +def test_a_quote_past_the_validators_depth_reads_as_its_text(depth: int) -> None: + # Past MAX_QUOTE_DEPTH the validator reads a quote as one paragraph of its + # text (legaldown.syntax.quote_blocks); a drafting note's text is what + # follows its marker line. Either side of the limit, a quote that holds + # only a comment renders no paragraph. + from legaldown.grammar import MAX_QUOTE_DEPTH + + assert depth in (MAX_QUOTE_DEPTH, MAX_QUOTE_DEPTH + 1) + nested = "> " * (depth - 1) + note = text(f"# A\n\n{nested}> [!DRAFTING]\n{nested}> Ask *the* client.\n") + assert "Ask the client." in note and "[!DRAFTING]" not in note + for body in (f"{nested}> \n", f"{nested}> [!DRAFTING]\n{nested}> \n"): + assert '

' not in html("# A\n\n" + body) + + def test_lists_and_inline_formatting_count_together() -> None: from legaldown_render.build import MAX_NESTING lists = "".join(" " * depth + f"- l{depth}\n" for depth in range(60)) @@ -983,9 +1001,9 @@ def test_a_level_six_heading_nests_where_its_number_puts_it() -> None: def _validator_rules(source: str) -> set[str]: - from legaldown import parse_document, validate_document + from legaldown import parse, validate - return {d.rule for d in validate_document(parse_document(source)).diagnostics} + return {d.rule for d in validate(parse(source)).diagnostics} def test_a_signature_block_heading_is_an_ordinary_section() -> None: From 54596c30f56e878e6f961ecbe23d0f32affe58db Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 07:06:14 +0000 Subject: [PATCH 2/4] CI: install legaldown-validator main before the renderer in the validator-main job The job installed the renderer first, which resolves legaldown-validator>=0.4.0,<0.5 from PyPI, and only then swapped in the validator from main. Before a validator release is on PyPI that first step fails, which is exactly when this job is meant to tell us something. Installing the validator from main first satisfies the range. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013WmBAc5T7UCKVdpUmg9qxz --- .github/workflows/ci.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8de7216..5e1d979 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -73,6 +73,8 @@ jobs: with: python-version: "3.12" cache: pip + # The validator from main first, so the renderer's own range for it is + # satisfied before a release of that version is on PyPI. + - run: pip install "legaldown-validator @ git+https://github.com/ForLegalAI/legaldown-validator@main" - run: pip install -e ".[dev]" - - run: pip install --force-reinstall --no-deps "legaldown-validator @ git+https://github.com/ForLegalAI/legaldown-validator@main" - run: pytest -q From 9b655e41e1b56f45677cdffe674398e6c3917cf2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 08:17:12 +0000 Subject: [PATCH 3/4] Docs for legaldown-validator 0.4.0; one deprecation category in pytest - pyproject.toml: filterwarnings is error::legaldown.LegaldownDeprecationWarning, the one category every validator deprecation now uses, in place of the message regex. Checked: parse_document, validate_document, serialize_document, assemble, template_questions and import_definitions= each fail a test under it. - architecture.md: the filter as it now is; the renderer's diagnostics lack a line because they are about a directive within a block (a block's line is public since 0.4.0, Document.line_of); labels.py has six languages. - CONFORMANCE.md: validator 0.4 is the current dependency; the released legaldown-render 0.2.0 is built on 0.3.0. - PUBLISHING.md: release after the validator's minor release is on PyPI; the tests fail on its deprecations. - concepts.md: validator diagnostics carry a line (since 0.3.0). - ADR 0002, 0007: date the 0.4.0 notes. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013WmBAc5T7UCKVdpUmg9qxz --- .github/PUBLISHING.md | 6 +++++- CONFORMANCE.md | 6 +++--- docs/architecture.md | 11 +++++++---- docs/concepts.md | 2 +- docs/decisions/0002-one-parser.md | 6 +++--- docs/decisions/0007-one-parser-validator-model.md | 12 ++++++------ pyproject.toml | 3 ++- 7 files changed, 27 insertions(+), 19 deletions(-) diff --git a/.github/PUBLISHING.md b/.github/PUBLISHING.md index aa0adc4..c292794 100644 --- a/.github/PUBLISHING.md +++ b/.github/PUBLISHING.md @@ -77,5 +77,9 @@ markdown-it-py) come from PyPI. - The distribution is named `legaldown-render`; the import name is `legaldown_render` (`pip install legaldown-render` → `import legaldown_render`). - A release of `legaldown-validator` with a new minor version needs a renderer release too: the - dependency is pinned to one minor version (`pyproject.toml`). + dependency is pinned to one minor version (`pyproject.toml`). Publish it once that validator + release is on PyPI, since until then nothing can satisfy the range (the `validator-main` CI job + installs the validator from `main` first for this reason). The tests fail on any + `legaldown.LegaldownDeprecationWarning`, so a release never calls what the validator's next + minor version removes. - The package ships a `py.typed` marker, so type checkers use its annotations directly. diff --git a/CONFORMANCE.md b/CONFORMANCE.md index 41fa580..4ac5edc 100644 --- a/CONFORMANCE.md +++ b/CONFORMANCE.md @@ -1,8 +1,8 @@ # Conformance -`legaldown-render` 0.2.0 targets **Level 2 — Rendering** of the LegalDown specification **0.2** -(§17.3). Core parsing and validation come from `legaldown-validator` 0.4, which claims Level 1 -— Core. Its own [CONFORMANCE.md](https://github.com/ForLegalAI/legaldown-validator/blob/main/CONFORMANCE.md) +`legaldown-render` targets **Level 2 — Rendering** of the LegalDown specification **0.2** +(§17.3), as 0.2.0 did. Core parsing and validation come from `legaldown-validator` 0.4 (0.3.0 up +to `legaldown-render` 0.2.0), which claims Level 1 — Core. Its own [CONFORMANCE.md](https://github.com/ForLegalAI/legaldown-validator/blob/main/CONFORMANCE.md) lists the Core rules it covers. It is checked against the specification's own examples and fixtures corpus (`tests/conformance/`): diff --git a/docs/architecture.md b/docs/architecture.md index 45fa014..48fc3c4 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -190,7 +190,7 @@ src/legaldown_render/ ├── style/ │ ├── model.py the style dataclasses (every field has a default) │ ├── loader.py layering, extends, overrides, validation -│ ├── labels.py built-in labels per language (en, cs) +│ ├── labels.py built-in labels per language (en, cs, de, fr, pl, sk) │ └── builtin/ default.yaml, continental.yaml, outline.yaml └── writers/ html.py, text.py ``` @@ -209,8 +209,10 @@ All of them are pure Python. The DOCX and PDF writers will bring their dependenc The renderer uses only the validator's public API: `legaldown`, and the tooling modules `legaldown.syntax` and `legaldown.grammar` (roadmap item U3, done in validator 0.4.0). Before 1.0 a minor release of the validator may change that API, so the dependency is pinned to one minor -version, and the tests fail on any of its `DeprecationWarning`s (`filterwarnings` in -`pyproject.toml`), since the next minor release removes what is deprecated. +version, and the tests fail on any of its deprecation warnings, since the next minor release +removes what is deprecated. The validator raises every one of them as a +`legaldown.LegaldownDeprecationWarning`, the one category `filterwarnings` in `pyproject.toml` +turns into an error. ## Errors and diagnostics @@ -224,7 +226,8 @@ Diagnostics reuse `legaldown.Diagnostic`. The renderer adds rules only it can ev `ref-not-enumerated`, a specification rule id, plus renderer-specific ids prefixed `render-`: `render-not-processed`, `render-locale-fallback`, and `render-ref-ambiguous`. The validator's diagnostics carry their line (§16.9), and the CLI prints it as `file:line:`. The renderer's own -have none yet, because a block's line is not public in the validator (U2). +have none yet: they are about a directive within a block, and the validator makes only a block's +line public (`Document.line_of`, U2). ## Security diff --git a/docs/concepts.md b/docs/concepts.md index e3492ee..e3d98dd 100644 --- a/docs/concepts.md +++ b/docs/concepts.md @@ -103,5 +103,5 @@ style template. | **Numbering scope** | A region that is numbered independently: the main body, or each attachment when the style template restarts numbering (§13.8) | | **Anchor** | A link target in the output. Section, item, and paragraph anchors share one namespace in the source (§5.6). The renderer maps them, together with definition anchors, into the output's single anchor space without collisions, by prefixing definition anchors with `def:`, a character no identifier can contain | | **Failure marker** | The visible bracketed text the specification requires in place of something that did not resolve, for example `[BROKEN REF: id]` or `[INVALID DATE: value]` | -| **Diagnostic** | A finding with a stable rule id, a severity, and a message (source lines will follow once the validator provides them). It uses the same type and rule ids as the validator, plus the rules only a renderer can evaluate, such as `ref-not-enumerated` | +| **Diagnostic** | A finding with a stable rule id, a severity, and a message. The validator's carry their source line; the renderer's own do not yet. It uses the same type and rule ids as the validator, plus the rules only a renderer can evaluate, such as `ref-not-enumerated` | | **Template view** | How a template is rendered without answers: conditional units marked, every `{{choose:}}` phrase shown, drafting notes styled distinctly (§15.8) | diff --git a/docs/decisions/0002-one-parser.md b/docs/decisions/0002-one-parser.md index c67f380..b156459 100644 --- a/docs/decisions/0002-one-parser.md +++ b/docs/decisions/0002-one-parser.md @@ -49,6 +49,6 @@ cannot carry everything a renderer needs: - The renderer depends on markdown-it-py. - The outline check turns any disagreement between the two parsers into a loud bug report. - Some renderer features wait for upstream changes. That is deliberate. -- Since validator 0.4.0, the directive grammar and the other readings of source text come from its - supported tooling modules, `legaldown.syntax` and `legaldown.grammar`. `legaldown.directives` - is internal there. +- 2026-10-03: since validator 0.4.0, the directive grammar and the other readings of source + text come from its supported tooling modules, `legaldown.syntax` and `legaldown.grammar`. + `legaldown.directives` is internal there. diff --git a/docs/decisions/0007-one-parser-validator-model.md b/docs/decisions/0007-one-parser-validator-model.md index ddbd536..2794e0f 100644 --- a/docs/decisions/0007-one-parser-validator-model.md +++ b/docs/decisions/0007-one-parser-validator-model.md @@ -64,9 +64,9 @@ module, `validator_bridge.py`. That module is the list for roadmap item U3. the builder turns its listed items and their depths into nested lists, and "2.1(b)(i)" replaced "2.1(c)". - The build stage is smaller: about 100 lines of position rules and special cases are gone. -- The bridge is gone since validator 0.4.0, which made all of it public (roadmap U3). The - result's decisions moved to `result.index` (`placed_markers`, `is_template`, `sections`). A - quote's content comes from `legaldown.syntax.quote_blocks`, and a drafting note's from - `drafting_note_blocks`, which replaced the builder's own removal of the `[!DRAFTING]` marker - (validator #88). A code block's content comes from `code_content`, and an answers file is read by - `legaldown.load_answers`. `validator_bridge.py` was deleted. +- 2026-10-03: the bridge is gone since validator 0.4.0, which made all of it public (roadmap + U3). The result's decisions moved to `result.index` (`placed_markers`, `is_template`, + `sections`). A quote's content comes from `legaldown.syntax.quote_blocks`, and a drafting + note's from `drafting_note_blocks`, which replaced the builder's own removal of the + `[!DRAFTING]` marker (validator #88). A code block's content comes from `code_content`, and an + answers file is read by `legaldown.load_answers`. `validator_bridge.py` was deleted. diff --git a/pyproject.toml b/pyproject.toml index bad173b..8ea3cbe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -78,6 +78,7 @@ markers = [ ] # A deprecated legaldown-validator API fails the tests: the next minor # release removes it, and the dependency range admits only one minor version. +# Every deprecation the validator raises is of this one category. filterwarnings = [ - 'error:(legaldown\.|import_definitions):DeprecationWarning', + "error::legaldown.LegaldownDeprecationWarning", ] From 90fccc0e82b21eeb8278144c726f9d6de70313a8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 09:52:01 +0000 Subject: [PATCH 4/4] CI: test against legaldown-validator main even outside the range Install the renderer first and force-reinstall the validator from main last, so the validator-main job keeps testing main once its version leaves the renderer's range (pip would otherwise replace it with the PyPI release). legaldown-validator 0.4.0 is on PyPI, so the range installs on its own again. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Q3fBapGwQ2J2vLzpDyjB5t --- .github/PUBLISHING.md | 6 +++--- .github/workflows/ci.yml | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/PUBLISHING.md b/.github/PUBLISHING.md index c292794..150d8e1 100644 --- a/.github/PUBLISHING.md +++ b/.github/PUBLISHING.md @@ -79,7 +79,7 @@ markdown-it-py) come from PyPI. - A release of `legaldown-validator` with a new minor version needs a renderer release too: the dependency is pinned to one minor version (`pyproject.toml`). Publish it once that validator release is on PyPI, since until then nothing can satisfy the range (the `validator-main` CI job - installs the validator from `main` first for this reason). The tests fail on any - `legaldown.LegaldownDeprecationWarning`, so a release never calls what the validator's next - minor version removes. + overrides the installed validator with `main`, so it tests main regardless of the range). The + tests fail on any `legaldown.LegaldownDeprecationWarning`, so a release never calls what the + validator's next minor version removes. - The package ships a `py.typed` marker, so type checkers use its annotations directly. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5e1d979..6567e7a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -73,8 +73,8 @@ jobs: with: python-version: "3.12" cache: pip - # The validator from main first, so the renderer's own range for it is - # satisfied before a release of that version is on PyPI. - - run: pip install "legaldown-validator @ git+https://github.com/ForLegalAI/legaldown-validator@main" - run: pip install -e ".[dev]" + # Force-reinstalled last, with --no-deps, so main is tested even when its + # version is outside the renderer's range. + - run: pip install --force-reinstall --no-deps "legaldown-validator @ git+https://github.com/ForLegalAI/legaldown-validator@main" - run: pytest -q