From 4c89001f5cf0b2b2b6152bd06e66c70274520155 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 04:31:34 +0000 Subject: [PATCH 1/3] Public API for renderers: placed markers, the template decision, helpers (#26) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ValidationResult.is_template and .placed_markers hand back the two decisions validate_document makes together (§15.1, §5.7, §15.3). Each PlacedMarker says where the marker is (section, block, fragment, offset, the list item it marks) and what applies (identifier, condition), with its line. is_template, is_drafting_note, the lexer and the fragment helpers are exported from legaldown; the condition helpers from legaldown.validator. The README states what the public API is. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012pWb8FGHgaM3ZWbzsPiKsk --- README.md | 19 ++++ src/legaldown/__init__.py | 21 +++- src/legaldown/directives.py | 2 +- src/legaldown/validator/__init__.py | 14 ++- src/legaldown/validator/conditions.py | 4 +- src/legaldown/validator/core.py | 62 +++++++++-- src/legaldown/validator/result.py | 38 +++++++ src/legaldown/validator/templates.py | 3 +- tests/test_public_api.py | 146 ++++++++++++++++++++++++++ 9 files changed, 293 insertions(+), 16 deletions(-) create mode 100644 tests/test_public_api.py diff --git a/README.md b/README.md index 09feb02..bd4160a 100644 --- a/README.md +++ b/README.md @@ -191,6 +191,12 @@ if result.is_valid: # no Error-level diagnostics frontmatter cannot be read — the `frontmatter-invalid-yaml` rule, which `validate_document`, given a parsed document, cannot report. +The public API is what the `legaldown` and `legaldown.validator` packages export (their +`__all__`). Changes to it are listed in the notes of each +[GitHub release](https://github.com/ForLegalAI/legaldown-validator/releases); before 1.0 a +minor release may change it, a patch release does not. Other modules are internal and may +change in any release. + ### Working with the result Validating a document builds the indices the checks need — section numbers, resolved definitions, @@ -206,6 +212,19 @@ so a renderer or a UI can reuse the work instead of re-deriving it: | `sections`, `section_lookup` | Numbered section index; resolves `{{ref:}}` targets. Numbers count from the shallowest heading level, and a level a heading skips counts as 1 (`#`, `###`, `##` → 1, 1.1.1, 1.2), so no two sections share a number except alternatives and what they contain (§15.8) | | `definition_lookup`, `party_lookup`, `side_lookup`, `attachment_lookup` | Resolved display text | | `inline_dates`, `inline_money`, `inline_durations`, `inline_fields`, `inline_placeholders` | Field-spec values found in the body | +| `is_template` | Whether the document is a template (§15.1): it declares `questions`, carries a condition, or holds a `{{choose:}}` | +| `placed_markers` | The markers in body text that apply (§5.7, §15.3), in document order: `PlacedMarker(section, block, fragment, offset, source, identifier, condition, item, include_only, line)` — in fragment `fragment` of `block_fragments(block)`, at `offset`; `item` is the list item it marks (numbered as `list_fragments` numbers them), and `identifier` is `""` where it does not apply (an include-only paragraph, §12.2) | + +A renderer builds from these decisions rather than re-deriving them, with the helpers the +validator reads the document with: + +| Helper | What it gives | +|---|---| +| `lex(text)` → `Lexed` | The directives in inline text (§11.4), and a `view` of it with comments and code spans blanked; `is_escaped(text, offset)` | +| `block_fragments(block)`, `list_fragments(block)`, `list_items(block)` | The texts of a block that hold directives and markers, in the order `PlacedMarker.fragment` counts them; a list's, with the items each is in | +| `is_template(document)`, `is_drafting_note(block)` | The template decision without validating (§15.1); whether a quote block is a drafting note (§15.6) | +| `legaldown.validator`: `parse_condition` → `Condition`, `condition_problem`, `exclusive`, `Presence`, `ALWAYS` | Conditions (§15.3, §15.4): parse one, tell why one is invalid, tell whether two units (each the set of conditions it appears under, `Presence`) can never appear together | +| `legaldown.validator`: `is_valid_iso_date`, `is_valid_money_amount`, `is_positive_numeric`, `IDENTIFIER_RE`, `KNOWN_CURRENCIES` | Value checks (§3.10, §10) | ### Reading and editing the document model diff --git a/src/legaldown/__init__.py b/src/legaldown/__init__.py index 519913b..6857aa9 100644 --- a/src/legaldown/__init__.py +++ b/src/legaldown/__init__.py @@ -33,16 +33,21 @@ DELIMITER_PAIRS, DefinitionAnchor, DefinitionRef, + block_fragments, collect_definitions, definition_lookup, find_definition_anchors, id_term, + list_fragments, ) from .directives import ( DIRECTIVE_PARAMS, KNOWN_DIRECTIVES, Directive, + Lexed, + is_escaped, iter_directives, + lex, ) from .models import ( BLOCK_DEFAULTS, @@ -62,6 +67,7 @@ document_to_dict, empty_document, item_text, + list_items, metadata_from_dict, party_from_dict, section_from_dict, @@ -80,11 +86,14 @@ AttachmentDefinitionsImporter, DefinitionsImporter, Diagnostic, + PlacedMarker, SectionIndexEntry, ValidationResult, + is_template, slugify_identifier, validate_document, ) +from .validator.templates import is_drafting_note __version__ = "0.2.0" @@ -126,8 +135,13 @@ "DELIMITER_PAIRS", "find_definition_anchors", "DefinitionAnchor", - # Directives (§11) + # Directives (§11): the lexer, and where text holding them is "iter_directives", + "lex", + "Lexed", + "is_escaped", + "block_fragments", + "list_fragments", "Directive", "DIRECTIVE_PARAMS", "KNOWN_DIRECTIVES", @@ -138,6 +152,10 @@ "ValidationResult", "SectionIndexEntry", "Diagnostic", + "PlacedMarker", + # What validate_document decides, on its own (§15.1, §15.6) + "is_template", + "is_drafting_note", # Document model "Document", "Metadata", @@ -145,6 +163,7 @@ "Block", "ListItem", "item_text", + "list_items", "Side", "Party", "Representative", diff --git a/src/legaldown/directives.py b/src/legaldown/directives.py index 7006456..d67da06 100644 --- a/src/legaldown/directives.py +++ b/src/legaldown/directives.py @@ -316,7 +316,7 @@ def lex(text: str) -> Lexed: *text* is inline text, such as a paragraph's: fenced code is not looked for. Text that can hold one (a code block's, a block quote's) is passed - through ``blank_fenced_code`` first, as block structure precedes inline + through ``blank_fenced_code`` first — ``block_fragments`` gives it so — as block structure precedes inline structure. It is read once, left to right, taking whichever of a directive, a comment, or a code span opens first. A directive is lexed from the source as written and consumes its own text, so a quoted diff --git a/src/legaldown/validator/__init__.py b/src/legaldown/validator/__init__.py index f457286..878e18f 100644 --- a/src/legaldown/validator/__init__.py +++ b/src/legaldown/validator/__init__.py @@ -1,7 +1,8 @@ """legaldown.validator — Document validation and structural analysis.""" from __future__ import annotations -from .core import AttachmentDefinitionsImporter, DefinitionsImporter, validate_document +from .conditions import ALWAYS, Condition, Presence, condition_problem, exclusive, parse_condition +from .core import AttachmentDefinitionsImporter, DefinitionsImporter, is_template, validate_document from .helpers import ( format_section_number, is_positive_numeric, @@ -18,17 +19,26 @@ VALID_DURATION_UNITS, VALID_PLACEHOLDER_TYPES, ) -from .result import Diagnostic, SectionIndexEntry, ValidationResult +from .result import Diagnostic, PlacedMarker, SectionIndexEntry, ValidationResult __all__ = [ # Core "validate_document", + "is_template", "DefinitionsImporter", "AttachmentDefinitionsImporter", # Result types "ValidationResult", "SectionIndexEntry", "Diagnostic", + "PlacedMarker", + # Conditions (§15.3, §15.4): a condition's presence, a set of them + "parse_condition", + "Condition", + "condition_problem", + "exclusive", + "Presence", + "ALWAYS", # Helpers "slugify_identifier", "format_section_number", diff --git a/src/legaldown/validator/conditions.py b/src/legaldown/validator/conditions.py index 25f11e4..d71bf82 100644 --- a/src/legaldown/validator/conditions.py +++ b/src/legaldown/validator/conditions.py @@ -90,7 +90,9 @@ def satisfiable(presence: Iterable[Condition], questions: Any) -> bool: def exclusive(first: Presence, second: Presence, questions: Any) -> bool: - """True if two units can never appear together (§15.4).""" + """True if two units, appearing under the conditions *first* and + *second* (their ``Presence``), can never appear together for any answers + to the document's *questions* (§15.4).""" return not satisfiable(first | second, questions) diff --git a/src/legaldown/validator/core.py b/src/legaldown/validator/core.py index e17add7..c9093b3 100644 --- a/src/legaldown/validator/core.py +++ b/src/legaldown/validator/core.py @@ -25,7 +25,7 @@ mask_directives, ) from ..markdown import HTML_COMMENT_RE, INLINE_HTML_RE, is_comment_only -from ..models import Amends, Document +from ..models import LIST_KINDS, Amends, Document from ..positions import Locator from ..specification import SPEC_VERSION, parse_version from .conditions import ALWAYS, Presence, always_covered, condition_problem, exclusive, parse_condition, satisfiable @@ -47,7 +47,7 @@ VALID_DURATION_UNITS, VALID_PLACEHOLDER_TYPES, ) -from .result import Line, SectionIndexEntry, ValidationResult +from .result import Line, PlacedMarker, SectionIndexEntry, ValidationResult from .templates import ( BRACE_STRAY, DECISION_QUESTION_TYPES, @@ -525,15 +525,21 @@ def _check_never_true( ) -def is_template( - document: Document, - markers: list[FoundMarker] | None = None, - lex_fragment: Callable[[str], Lexed] = lex, -) -> bool: +def is_template(document: Document) -> bool: """True if *document* is a template (§15.1): it declares ``questions``, carries a condition — on a section, an attachment, or a placed marker — - or contains a ``{{choose:}}``, wherever it is. *markers* are - ``find_markers(document, lex_fragment)`` when the caller has them.""" + or contains a ``{{choose:}}``, wherever it is. ``validate_document`` + reports it too (``ValidationResult.is_template``).""" + return _is_template(document, None, lex) + + +def _is_template( + document: Document, + markers: list[FoundMarker] | None, + lex_fragment: Callable[[str], Lexed], +) -> bool: + """``is_template``; *markers* are ``find_markers(document, + lex_fragment)`` when the caller has them.""" from ..definitions import text_fragments # see the import note in validate_document meta = document.metadata @@ -649,7 +655,7 @@ def named(text: str, name: str) -> list[Directive]: # Markers are found first: only a template gives a preamble paragraph's # condition its place (§5.7). markers = find_markers(document, lex_fragment) - template = is_template(document, markers, lex_fragment) + template = _is_template(document, markers, lex_fragment) units = Units(document, markers, questions, template=template) body_directives = { directive.name @@ -1547,6 +1553,42 @@ def definition_line(ref: Any) -> int | None: line=definition_line(ref), ) + # What a renderer builds from: the template decision and the markers + # that apply, as every check above read them. + result.is_template = template + result.placed_markers = _placed_markers(document, markers, template, marker_line) + if document.filename: result.diagnostics = [replace(d, file=document.filename) for d in result.diagnostics] return result + + +def _placed_markers( + document: Document, + markers: list[FoundMarker], + template: bool, + line: Callable[[FoundMarker], int | None], +) -> list[PlacedMarker]: + """The markers of *markers* that apply (``PlacedMarker``).""" + from ..definitions import list_fragments # see the import note in validate_document + + placed = [] + for found in markers: + if found.marker is None or not found.placed(template): + continue + blocks = document.preamble if found.section is None else document.sections[found.section].blocks + block = blocks[found.block] + items = list_fragments(block)[found.fragment][2] if block.kind in LIST_KINDS else () + placed.append(PlacedMarker( + section=found.section, + block=found.block, + fragment=found.fragment, + offset=found.offset, + source=found.source, + identifier="" if found.include_only else found.marker.identifier, + condition=found.marker.condition, + item=items[-1] if items else None, + include_only=found.include_only, + line=line(found), + )) + return placed diff --git a/src/legaldown/validator/result.py b/src/legaldown/validator/result.py index 1434f30..136ac61 100644 --- a/src/legaldown/validator/result.py +++ b/src/legaldown/validator/result.py @@ -41,6 +41,38 @@ class Diagnostic: file: str = "" +@dataclass(slots=True, frozen=True) +class PlacedMarker: + """A marker in body text that is in a marker position and applies in + its document (§5.7, §15.3), as ``validate_document`` places it — the + marker of a top-level paragraph or of a list item's first paragraph. A + section's own marker is its ``Section.identifier`` and ``condition``. + + It is in fragment *fragment* of ``block_fragments(block)``, block + *block* of the preamble (*section* None) or of section *section*, at + *offset* in that fragment's text: ``text[offset:offset + len(source)]`` + is *source*. A fragment holds at most one placed marker, which ends it. + In a list, *item* is the list item it marks, numbered in document order + among all the list's items, nested ones included, as ``list_fragments`` + numbers them; None elsewhere. + + *identifier* is the ``#id`` that applies: ``""`` for the marker of a + paragraph holding only an ``{{include:}}``, whose ``#id`` is ignored + (§12.2, *include_only*). *line* is its line (§16.9), None for a document + built in code or changed since it was parsed.""" + + section: int | None + block: int + fragment: int + offset: int + source: str + identifier: str + condition: str + item: int | None = None + include_only: bool = False + line: int | None = None + + #: A diagnostic's line (from 1), or a function giving it, called only when a #: diagnostic is recorded at it: finding a line costs more than knowing where. Line = int | None | Callable[[], "int | None"] @@ -70,6 +102,12 @@ class ValidationResult: inline_durations: list[tuple[str, str]] = field(default_factory=list) inline_fields: list[tuple[str, str]] = field(default_factory=list) inline_placeholders: list[tuple[str, str]] = field(default_factory=list) + #: Whether the document is a template (§15.1): it declares questions, + #: carries a condition, or holds a ``{{choose:}}``. + is_template: bool = False + #: The markers in body text that apply (``PlacedMarker``), in document + #: order. + placed_markers: list[PlacedMarker] = field(default_factory=list) #: The lines diagnostics are recorded at by default (``at``), innermost last. _lines: list[Line] = field(default_factory=list, repr=False, compare=False) diff --git a/src/legaldown/validator/templates.py b/src/legaldown/validator/templates.py index 91fa8b5..7aeeac3 100644 --- a/src/legaldown/validator/templates.py +++ b/src/legaldown/validator/templates.py @@ -290,7 +290,8 @@ def is_unrecognized_alert(self) -> bool: def is_drafting_note(quote: Block) -> bool: - """True if block *quote* is a drafting note (``Quote.is_drafting_note``).""" + """True if block *quote* is a drafting note: its first line is exactly + ``[!DRAFTING]``, letters in any case (§15.6).""" return Quote(quote.text.split("\n", 1)[0].strip(), range(0)).is_drafting_note diff --git a/tests/test_public_api.py b/tests/test_public_api.py new file mode 100644 index 0000000..370c589 --- /dev/null +++ b/tests/test_public_api.py @@ -0,0 +1,146 @@ +"""The public API (#26): what ``validate_document`` decides for a renderer — +the template decision and the markers that apply — and the helpers a +renderer builds with, importable from ``legaldown`` and ``legaldown.validator``.""" +from __future__ import annotations + +import os +from pathlib import Path + +import pytest + +import legaldown +import legaldown.validator +from legaldown import ( + PlacedMarker, + ValidationResult, + block_fragments, + document_from_dict, + is_drafting_note, + is_template, + list_fragments, + parse_document, + validate_document, +) +from legaldown.directives import lex +from legaldown.validator.units import find_markers + +_FRONTMATTER = "---\ntitle: T\n---\n\n" + + +def _markers(body: str) -> list[PlacedMarker]: + return validate_document(parse_document(_FRONTMATTER + body)).placed_markers + + +@pytest.mark.parametrize("module", [legaldown, legaldown.validator]) +def test_every_public_name_is_importable(module): + assert [name for name in module.__all__ if not hasattr(module, name)] == [] + + +def test_the_renderers_names_are_public(): + for name in ("PlacedMarker", "is_template", "is_drafting_note", "lex", "Lexed", "is_escaped", + "block_fragments", "list_fragments", "list_items"): + assert name in legaldown.__all__ + for name in ("parse_condition", "Condition", "condition_problem", "exclusive", "Presence", "ALWAYS", "is_template", + "PlacedMarker", "IDENTIFIER_RE", "KNOWN_CURRENCIES", "is_valid_iso_date", + "is_valid_money_amount", "is_positive_numeric"): + assert name in legaldown.validator.__all__ + + +def test_a_result_built_directly_has_neither_decision(): + result = ValidationResult() + assert (result.is_template, result.placed_markers) == (False, []) + + +def test_a_marker_ending_a_paragraph_over_lines(): + [marker] = _markers("# A\n\nText\nmore. {#t}\n") + assert (marker.section, marker.block, marker.fragment, marker.item) == (0, 0, 0, None) + assert (marker.source, marker.identifier, marker.condition, marker.line) == ("{#t}", "t", "", 8) + assert "Text\nmore. {#t}"[marker.offset:] == "{#t}" + + +def test_a_list_items_marker_names_its_item(): + """An item's fragments are its blocks': the item a marker marks is not + its fragment's index.""" + body = "# A\n\n- first {#a}\n\n second para\n- second {#b}\n - nested\n more {#c when=x}\n" + markers = _markers(body) + assert [(m.identifier, m.fragment, m.item) for m in markers] == [("a", 0, 0), ("b", 2, 1), ("c", 3, 2)] + assert markers[2].condition == "x" + [block] = parse_document(_FRONTMATTER + body).sections[0].blocks + assert [list_fragments(block)[m.fragment][2][-1] for m in markers] == [0, 1, 2] + + +def test_an_include_only_paragraphs_identifier_does_not_apply(): + [marker] = _markers("# A\n\n{{include: x.lgd}} {#i when=b}\n") + assert (marker.identifier, marker.condition, marker.include_only) == ("", "b", True) + + +def test_a_preamble_condition_applies_only_in_a_template(): + assert _markers("Intro {when=a}\n\n# A\n\nText.\n") == [] + result = validate_document(parse_document(_FRONTMATTER + "Intro {when=a}\n\n# A {when=b}\n\nText.\n")) + assert result.is_template + assert [(m.section, m.condition) for m in result.placed_markers] == [(None, "a")] + + +def test_the_offset_is_the_markers_not_a_copy_in_a_comment(): + [marker] = _markers("# A\n\nText {#t} \n") + assert marker.offset == 5 + + +@pytest.mark.parametrize("body", [ + "# A\n\n| a |\n|---|\n| x {#t} |\n", + "# A\n\n> quoted {#t}\n", + "# A\n\n> > nested {#t}\n", + "# A\n\nText {#t} more.\n", + "# A\n\n- a\n\n second {#t}\n", +]) +def test_a_marker_out_of_place_is_not_placed(body): + assert _markers(body) == [] + + +def test_a_document_built_in_code_has_no_lines(): + document = document_from_dict({"sections": [{"title": "A", "blocks": [{"kind": "paragraph", "text": "Text {#t}"}]}]}) + [marker] = validate_document(document).placed_markers + assert (marker.identifier, marker.line) == ("t", None) + + +def test_is_template_on_its_own(): + assert not is_template(parse_document(_FRONTMATTER + "# A\n\nText.\n")) + assert is_template(parse_document(_FRONTMATTER + "# A\n\nPick {{choose: q}}.\n")) + + +def test_is_drafting_note(): + [note, quote] = parse_document(_FRONTMATTER + "# A\n\n> [!drafting]\n> Check.\n\n> Plain.\n").sections[0].blocks + assert is_drafting_note(note) and not is_drafting_note(quote) + + +def _corpus(): + root = os.environ.get("LEGALDOWN_FIXTURES_DIR", "") + if not root or not Path(root).is_dir(): + return [] + spec = Path(root).parent + return [pytest.param(path, id=path.relative_to(spec).as_posix()) for path in sorted(spec.rglob("*.lgd"))] + + +@pytest.mark.parametrize("path", _corpus()) +def test_the_decisions_are_the_validators_own(path: Path): + """Over the specification's documents: the template decision is + ``is_template``, the placed markers are the validator's placed findings, + each where it says, at most one in a fragment.""" + try: + document = parse_document(path.read_text(encoding="utf-8")) + except legaldown.FrontmatterError: + pytest.skip("unreadable frontmatter") + result = validate_document(document) + assert result.is_template == is_template(document) + found = [f for f in find_markers(document, lex) if f.marker is not None and f.placed(result.is_template)] + assert [(m.section, m.block, m.fragment, m.offset, m.source) for m in result.placed_markers] == [ + (f.section, f.block, f.fragment, f.offset, f.source) for f in found + ] + places = [(m.section, m.block, m.fragment) for m in result.placed_markers] + assert len(places) == len(set(places)) + lines = path.read_text(encoding="utf-8").split("\n") + for marker in result.placed_markers: + blocks = document.preamble if marker.section is None else document.sections[marker.section].blocks + text = block_fragments(blocks[marker.block])[marker.fragment][0] + assert text[marker.offset:marker.offset + len(marker.source)] == marker.source + assert marker.source in lines[marker.line - 1] From f5d8c2477dfe111c276f3dca6bc25f6ce7606c32 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 04:38:22 +0000 Subject: [PATCH 2/3] Walk each list once for its placed markers; name a marker's field _placed_markers read a list's fragments once per marker, quadratic in a long list. PlacedMarker says which field of its block holds it (text, or a lifted ref's or term's suffix), and takes keyword arguments only. is_drafting_note is exported from legaldown.validator too. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012pWb8FGHgaM3ZWbzsPiKsk --- README.md | 9 ++++--- src/legaldown/__init__.py | 2 +- src/legaldown/directives.py | 7 ++--- src/legaldown/validator/__init__.py | 2 ++ src/legaldown/validator/core.py | 9 ++++++- src/legaldown/validator/result.py | 12 ++++++--- tests/test_public_api.py | 41 +++++++++++++++++++++++++++++ 7 files changed, 69 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index bd4160a..0ecf182 100644 --- a/README.md +++ b/README.md @@ -195,7 +195,8 @@ The public API is what the `legaldown` and `legaldown.validator` packages export `__all__`). Changes to it are listed in the notes of each [GitHub release](https://github.com/ForLegalAI/legaldown-validator/releases); before 1.0 a minor release may change it, a patch release does not. Other modules are internal and may -change in any release. +change in any release; constants still only there are to be made public +([#34](https://github.com/ForLegalAI/legaldown-validator/issues/34)). ### Working with the result @@ -213,7 +214,7 @@ so a renderer or a UI can reuse the work instead of re-deriving it: | `definition_lookup`, `party_lookup`, `side_lookup`, `attachment_lookup` | Resolved display text | | `inline_dates`, `inline_money`, `inline_durations`, `inline_fields`, `inline_placeholders` | Field-spec values found in the body | | `is_template` | Whether the document is a template (§15.1): it declares `questions`, carries a condition, or holds a `{{choose:}}` | -| `placed_markers` | The markers in body text that apply (§5.7, §15.3), in document order: `PlacedMarker(section, block, fragment, offset, source, identifier, condition, item, include_only, line)` — in fragment `fragment` of `block_fragments(block)`, at `offset`; `item` is the list item it marks (numbered as `list_fragments` numbers them), and `identifier` is `""` where it does not apply (an include-only paragraph, §12.2) | +| `placed_markers` | The markers in body text that apply (§5.7, §15.3), in document order: `PlacedMarker(section, block, fragment, offset, source, identifier, condition, field, item, include_only, line)` — in fragment `fragment` of `block_fragments(block)`, at `offset`, which is the block's `field` (`text`, or `suffix` after a lifted `{{ref:}}`/`{{term:}}`); `item` is the list item it marks, counted over all the list's items, nested and empty ones included, as `list_fragments` counts them; `identifier` is `""` where it does not apply (an include-only paragraph, §12.2) | A renderer builds from these decisions rather than re-deriving them, with the helpers the validator reads the document with: @@ -221,9 +222,9 @@ validator reads the document with: | Helper | What it gives | |---|---| | `lex(text)` → `Lexed` | The directives in inline text (§11.4), and a `view` of it with comments and code spans blanked; `is_escaped(text, offset)` | -| `block_fragments(block)`, `list_fragments(block)`, `list_items(block)` | The texts of a block that hold directives and markers, in the order `PlacedMarker.fragment` counts them; a list's, with the items each is in | +| `block_fragments(block)`, `list_fragments(block)`, `list_items(block)` | The texts of a block that hold directives and markers, in the order `PlacedMarker.fragment` counts them; the same for a list, with the items each is in; a list's items, as `ListItem`s | | `is_template(document)`, `is_drafting_note(block)` | The template decision without validating (§15.1); whether a quote block is a drafting note (§15.6) | -| `legaldown.validator`: `parse_condition` → `Condition`, `condition_problem`, `exclusive`, `Presence`, `ALWAYS` | Conditions (§15.3, §15.4): parse one, tell why one is invalid, tell whether two units (each the set of conditions it appears under, `Presence`) can never appear together | +| `legaldown.validator`: `parse_condition` → `Condition`, `condition_problem`, `exclusive`, `Presence`, `ALWAYS` | Conditions (§15.3, §15.4): parse one, tell why one is invalid, tell whether two units (each the set of conditions it appears under, `Presence`) can never appear together, given the document's `questions` | | `legaldown.validator`: `is_valid_iso_date`, `is_valid_money_amount`, `is_positive_numeric`, `IDENTIFIER_RE`, `KNOWN_CURRENCIES` | Value checks (§3.10, §10) | ### Reading and editing the document model diff --git a/src/legaldown/__init__.py b/src/legaldown/__init__.py index 6857aa9..5b6c0e0 100644 --- a/src/legaldown/__init__.py +++ b/src/legaldown/__init__.py @@ -89,11 +89,11 @@ PlacedMarker, SectionIndexEntry, ValidationResult, + is_drafting_note, is_template, slugify_identifier, validate_document, ) -from .validator.templates import is_drafting_note __version__ = "0.2.0" diff --git a/src/legaldown/directives.py b/src/legaldown/directives.py index d67da06..54780e6 100644 --- a/src/legaldown/directives.py +++ b/src/legaldown/directives.py @@ -315,9 +315,10 @@ def lex(text: str) -> Lexed: """Lex *text* for directives, outside literal regions (§11.4). *text* is inline text, such as a paragraph's: fenced code is not looked - for. Text that can hold one (a code block's, a block quote's) is passed - through ``blank_fenced_code`` first — ``block_fragments`` gives it so — as block structure precedes inline - structure. It is read once, left to right, taking whichever + for. Text that can hold it (a code block's, a block quote's) has it + blanked first, as block structure precedes inline structure + (``blank_fenced_code``; ``block_fragments`` gives such text so). It is + read once, left to right, taking whichever of a directive, a comment, or a code span opens first. A directive is lexed from the source as written and consumes its own text, so a quoted value may hold backticks or ``