From b10adb30ad73af1b8fc66138d7c15d18e82689ad Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 07:51:01 +0000 Subject: [PATCH] 0.4.0 pre-release: quote_blocks without deepcopy; LegaldownDeprecationWarning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit quote_blocks deep-copied the cached reading of a quote, which recursed once per nesting level and raised RecursionError for a list nested about 62 deep inside a quote — input the parser accepts (MAX_LIST_DEPTH 64). Found by PactTrack's migration to 0.4.0. It now reads the quote again, uncached (_read_quote_content), so the blocks are still the caller's to change and the cache is untouched; drafting_note_blocks builds on it. tests/test_deep_nesting.py runs every public path (parse, validate with and without final, serialize, the dict round trip, layout, line_of, body_layout, iter_document_directives, find_markers, block_fragments, the quote readers, code_content, template assembly) on lists, quotes and their mixes at and past the parser's limits. Every deprecation legaldown raises is now a LegaldownDeprecationWarning (a DeprecationWarning subclass, exported from legaldown), so one filter catches them all; the README says how (warnings.simplefilter, or pytest filterwarnings — Python's -W cannot name a package's category). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013WmBAc5T7UCKVdpUmg9qxz --- README.md | 9 +- src/legaldown/__init__.py | 5 + src/legaldown/_deprecation.py | 14 +++ src/legaldown/assembly.py | 7 +- src/legaldown/parser.py | 16 ++- src/legaldown/serializer.py | 3 +- src/legaldown/validator/core.py | 5 +- src/legaldown/validator/templates.py | 2 +- tests/test_deep_nesting.py | 147 +++++++++++++++++++++++++++ tests/test_public_api.py | 51 ++++++++++ 10 files changed, 246 insertions(+), 13 deletions(-) create mode 100644 src/legaldown/_deprecation.py create mode 100644 tests/test_deep_nesting.py diff --git a/README.md b/README.md index 50caf76..5abc1d3 100644 --- a/README.md +++ b/README.md @@ -265,7 +265,12 @@ is the same function for files on disk, and the one `parse_template` takes as `r > `validate_document` is now `validate`, `serialize_document` is now `serialize` (text) or `save` > (file), and the importer callbacks `import_definitions=` and `import_attachment_definitions=` are > replaced by `resolve=`. They still work and raise a `DeprecationWarning`; they are deprecated -> since 0.4.0 and will be removed in 0.5.0. +> since 0.4.0 and will be removed in 0.5.0. Every deprecation warning legaldown raises is a +> `legaldown.LegaldownDeprecationWarning` (a `DeprecationWarning` subclass), so one filter catches +> them all: `warnings.simplefilter("error", legaldown.LegaldownDeprecationWarning)` in code, or +> `filterwarnings = ["error::legaldown.LegaldownDeprecationWarning"]` in pytest's configuration +> (Python's own `-W` option and `PYTHONWARNINGS` ignore a category from a package, as they read it +> before the package can be imported). > > **Changed in 0.4.0:** `ValidationResult` is a plain value of two parts, `diagnostics` and `index`. > `errors`, `warnings` and `infos` are read-only lists taken from `diagnostics` (changing them @@ -709,7 +714,7 @@ checks and what it does not. > **Deprecated:** `assemble(text, answers, load_file=)`, `template_questions` and `needed_questions` > are replaced by `Template` and `Form`. They still work, unchanged (but for `Question`, below, and > that a `load_file=` loader is never asked for a path that is absolute or leads out of the template's -> directory, §2.3), and raise a `DeprecationWarning`; they are deprecated since 0.4.0 and will be removed in 0.5.0. Two things +> directory, §2.3), and raise a `LegaldownDeprecationWarning`; they are deprecated since 0.4.0 and will be removed in 0.5.0. Two things > differ in the new API: it asks the questions of an included fragment where its `{{include:}}` is > (the functions ask them after the body), and it refuses a template that has Errors in the > validator's template rules (the functions assemble it). `legaldown assemble` follows the new API, diff --git a/src/legaldown/__init__.py b/src/legaldown/__init__.py index c2c349c..57bf883 100644 --- a/src/legaldown/__init__.py +++ b/src/legaldown/__init__.py @@ -17,6 +17,9 @@ """ from __future__ import annotations +# The category of every deprecation warning legaldown raises +from ._deprecation import LegaldownDeprecationWarning + # Assembly (§15.7): a named capability (§17.6) from .assembly import AssemblyError, AssemblyResult, Question, assemble, needed_questions, template_questions from .files import LoadFile, file_loader @@ -168,4 +171,6 @@ # Deprecated importer callbacks "DefinitionsImporter", # deprecated since 0.4.0, removed in 0.5.0: use ``validate(resolve=)`` "AttachmentDefinitionsImporter", # deprecated since 0.4.0, removed in 0.5.0: use ``validate(resolve=)`` + # What each deprecated name above warns with when used: one category to filter + "LegaldownDeprecationWarning", ] diff --git a/src/legaldown/_deprecation.py b/src/legaldown/_deprecation.py new file mode 100644 index 0000000..2774ba3 --- /dev/null +++ b/src/legaldown/_deprecation.py @@ -0,0 +1,14 @@ +"""The warning category of legaldown's deprecations.""" +from __future__ import annotations + + +class LegaldownDeprecationWarning(DeprecationWarning): + """A deprecated legaldown API was used: it is removed in the next minor + release. Every deprecation warning legaldown raises is of this category, + so one filter covers them all; a ``DeprecationWarning`` filter still + matches them too.""" + + +# Named where it is imported from, ``legaldown.LegaldownDeprecationWarning`` +# (a warnings filter written as a qualified name, a pickled warning). +LegaldownDeprecationWarning.__module__ = "legaldown" diff --git a/src/legaldown/assembly.py b/src/legaldown/assembly.py index ac20a2d..70bb8a9 100644 --- a/src/legaldown/assembly.py +++ b/src/legaldown/assembly.py @@ -39,6 +39,7 @@ from functools import cache from typing import Any +from ._deprecation import LegaldownDeprecationWarning from .definitions import list_fragments from .directives import PLACEHOLDER_TYPE_PARAMS, Directive, format_value, lex from .files import LoadFile, relative_path @@ -1785,7 +1786,7 @@ def assemble( warnings.warn( "legaldown.assemble() is deprecated since 0.4.0 and will be removed in 0.5.0; " "use legaldown.load_template() or parse_template(), then .form(answers).assemble()", - DeprecationWarning, + LegaldownDeprecationWarning, stacklevel=2, ) try: @@ -1845,7 +1846,7 @@ def template_questions( warnings.warn( "legaldown.template_questions() is deprecated since 0.4.0 and will be removed in 0.5.0; " "use legaldown.load_template() or parse_template(), then .questions", - DeprecationWarning, + LegaldownDeprecationWarning, stacklevel=2, ) return _questions(_read(template, load_file)) @@ -1869,7 +1870,7 @@ def needed_questions( warnings.warn( "legaldown.needed_questions() is deprecated since 0.4.0 and will be removed in 0.5.0; " "use legaldown.load_template() or parse_template(), then .form(answers).questions", - DeprecationWarning, + LegaldownDeprecationWarning, stacklevel=2, ) t = _read(template, load_file) diff --git a/src/legaldown/parser.py b/src/legaldown/parser.py index af63305..cf0dd11 100644 --- a/src/legaldown/parser.py +++ b/src/legaldown/parser.py @@ -9,7 +9,6 @@ from __future__ import annotations import bisect -import copy import os import re import warnings @@ -21,6 +20,7 @@ import yaml +from ._deprecation import LegaldownDeprecationWarning from .definitions import DefinitionAnchor, block_fragments, find_definition_anchors from .directives import Directive, lex from .markdown import ( @@ -1357,6 +1357,12 @@ def quote_content(text: str, depth: int = 0) -> tuple[tuple[Block, ...], tuple[_ how many list items and quotes the content is in (``_parse_list``). Cached, as the validator reads a quote's blocks more than once: the blocks are shared, not to be changed.""" + return _read_quote_content(text, depth) + + +def _read_quote_content(text: str, depth: int = 0) -> tuple[tuple[Block, ...], tuple[_BlockSpan, ...]]: + """``quote_content`` uncached: blocks of its own each time, which no + other caller holds.""" layout = _Layout() blocks, _sections = _parse_body(text.split("\n"), layout, headings=False, depth=depth) return tuple(blocks), tuple(layout.preamble) @@ -1364,7 +1370,7 @@ def quote_content(text: str, depth: int = 0) -> tuple[tuple[Block, ...], tuple[_ def quote_blocks(block: Block, *, depth: int = 0) -> list[Block]: """The blocks the block quote *block* holds, as the validator reads them - (``quote_content``): each a fresh copy, free to change, since the + (``quote_content``): each block read afresh, free to change, since the validator keeps its own reading of a quote's content. A heading in one is a ``heading`` block, not a section, and no directive is lifted into block fields (§4.1). *depth*: how many list items and quotes *block* is @@ -1376,7 +1382,9 @@ def quote_blocks(block: Block, *, depth: int = 0) -> list[Block]: raise ValueError(f"not a block quote: {block.kind}") if depth >= MAX_QUOTE_DEPTH: return [Block(kind="paragraph", text=block.text)] if block.text.strip() else [] - return copy.deepcopy(list(quote_content(block.text, depth + 1)[0])) + # Read again rather than deep-copied from the cache: a copy recurses once + # per nesting level, and fails on a list nested near MAX_LIST_DEPTH. + return list(_read_quote_content(block.text, depth + 1)[0]) def parse_item_content(text: str) -> list[Block]: @@ -1469,7 +1477,7 @@ def parse_document(source: str, *, filename: str = "") -> Document: warnings.warn( "legaldown.parse_document() is deprecated since 0.4.0 and will be removed in 0.5.0; " "use legaldown.parse() for a string, or legaldown.load() for a file", - DeprecationWarning, + LegaldownDeprecationWarning, stacklevel=2, ) return parse(source, filename=filename) diff --git a/src/legaldown/serializer.py b/src/legaldown/serializer.py index 43d2929..0b823f7 100644 --- a/src/legaldown/serializer.py +++ b/src/legaldown/serializer.py @@ -13,6 +13,7 @@ import yaml +from ._deprecation import LegaldownDeprecationWarning from .directives import format_value from .files import write_atomically from .markdown import ( @@ -640,7 +641,7 @@ def serialize_document(document: Document) -> str: warnings.warn( "legaldown.serialize_document() is deprecated since 0.4.0 and will be removed in 0.5.0; " "use legaldown.serialize()", - DeprecationWarning, + LegaldownDeprecationWarning, stacklevel=2, ) return serialize(document) diff --git a/src/legaldown/validator/core.py b/src/legaldown/validator/core.py index b4b7538..8ae7f10 100644 --- a/src/legaldown/validator/core.py +++ b/src/legaldown/validator/core.py @@ -14,6 +14,7 @@ from functools import cache, partial from typing import Any +from .._deprecation import LegaldownDeprecationWarning from ..directives import ( DIRECTIVE_PARAMS, KNOWN_DIRECTIVES, @@ -636,7 +637,7 @@ def validate_document( warnings.warn( "legaldown.validate_document() is deprecated since 0.4.0 and will be removed in 0.5.0; " "use legaldown.validate()", - DeprecationWarning, + LegaldownDeprecationWarning, stacklevel=2, ) resolve = _resolver(document, None, import_definitions, import_attachment_definitions, stacklevel=3) @@ -659,7 +660,7 @@ def _resolver( warnings.warn( "import_definitions and import_attachment_definitions are deprecated since 0.4.0 " "and will be removed in 0.5.0; use validate(resolve=), which reads the files itself", - DeprecationWarning, + LegaldownDeprecationWarning, stacklevel=stacklevel, ) return resolve diff --git a/src/legaldown/validator/templates.py b/src/legaldown/validator/templates.py index 53d1c96..128bf2d 100644 --- a/src/legaldown/validator/templates.py +++ b/src/legaldown/validator/templates.py @@ -300,7 +300,7 @@ def is_drafting_note(quote: Block) -> bool: def drafting_note_blocks(quote: Block, *, depth: int = 0) -> list[Block]: """The blocks of drafting note *quote* without its ``[!DRAFTING]`` marker - line (§15.6), as ``quote_blocks`` reads the note's content: fresh copies. + line (§15.6), as ``quote_blocks`` reads the note's content: fresh blocks. The marker starts the first paragraph or heading, and is cut from it, the block going when nothing else is in it; where it does not (a marker line indented as code), the note is what is written after its first diff --git a/tests/test_deep_nesting.py b/tests/test_deep_nesting.py new file mode 100644 index 0000000..eaf7b6a --- /dev/null +++ b/tests/test_deep_nesting.py @@ -0,0 +1,147 @@ +"""Deep nesting up to the parser's limits, and past them: lists nested +``MAX_LIST_DEPTH`` deep, quotes nested ``MAX_QUOTE_DEPTH`` deep, and the two +in each other. Each public path reads such a document without a +``RecursionError``: past the limits the parser reads the rest as text, so no +document nests deeper than that, but a path that recurses more per level than +the parser does can still run out of stack below them.""" +from __future__ import annotations + +import pytest + +from legaldown import Document, parse, parse_template, serialize, validate +from legaldown.grammar import MAX_LIST_DEPTH, MAX_QUOTE_DEPTH +from legaldown.parser import quote_content +from legaldown.syntax import ( + block_fragments, + body_layout, + code_content, + drafting_note_blocks, + find_markers, + is_drafting_note, + iter_document_directives, + quote_blocks, +) + +_FRONT = "---\ntitle: T\nquestions:\n extras:\n type: boolean\n---\n\n# A {#a}\n\n" + +#: What the innermost level holds: placeholders, a choice, fenced code. +_INNER = ['x {{placeholder: who}} {{choose: extras, true="a", false="b"}}', "", "```", "{{placeholder: c}}", "```"] + + +def _nest(levels: str) -> str: + """The body nesting ``_INNER`` in *levels*, outermost first: ``L`` a list + item, ``Q`` a block quote, ``D`` a drafting note.""" + lines = list(_INNER) + for kind in reversed(levels): + if kind == "L": + lines = ["- " + lines[0]] + [f" {line}" if line else "" for line in lines[1:]] + else: + lines = [f"> {line}" if line else ">" for line in lines] + if kind == "D": + lines.insert(0, "> [!DRAFTING]") + return "\n".join(lines) + + +_L, _Q = MAX_LIST_DEPTH, MAX_QUOTE_DEPTH +SHAPES = { + "lists at the limit": "L" * _L, + "lists past the limit": "L" * (_L + 6), + "quotes at the limit": "Q" * _Q, + "quotes past the limit": "Q" * (_Q + 4), + "lists below the limit in a quote": "Q" + "L" * (_L - 2), + "lists past the limit in a quote": "Q" + "L" * (_L + 6), + "lists past the limit in quotes": "Q" * (_Q - 1) + "L" * (_L + 6), + "quotes past the limit in lists": "L" * (_L - 1) + "Q" * (_Q + 4), + "quotes in lists in quotes": "L" * (_L - 4) + "Q" * _Q + "L" * 10, + "quotes and lists alternating": "QL" * (_Q + 4), + "lists and quotes alternating": "LQ" * (_Q + 4), + "lists in a drafting note": "D" + "L" * (_L + 6), + "lists in a drafting note in lists": "L" * 10 + "D" + "L" * (_L - 4), + "drafting notes in lists": "DL" * (_Q + 4), +} + + +@pytest.fixture(params=list(SHAPES), scope="module") +def source(request) -> str: + return _FRONT + _nest(SHAPES[request.param]) + "\n" + + +def _quotes(blocks, depth: int, found: list) -> list: + for block in blocks: + if block.kind == "quote": + found.append((block, depth)) + for item in block.items or []: + _quotes(item.blocks, depth + 1, found) + return found + + +def test_the_reported_quote_holds_its_nested_lists_as_fresh_blocks(): + body = "\n".join("> " + " " * i + "- x" for i in range(62)) + [quote] = parse(_FRONT + body + "\n").sections[0].blocks + blocks = quote_blocks(quote) + cached = list(quote_content(quote.text, 1)[0]) + assert blocks == cached + assert all(fresh is not shared for fresh, shared in zip(blocks, cached, strict=True)) + depth, block = 0, blocks[0] + while block.kind == "unordered_list": + depth += 1 + block = block.items[0].blocks[-1] + assert depth == 62 + blocks[0].items.clear() # the caller may change them; the cache keeps its reading + assert quote_content(quote.text, 1)[0][0].items != [] + assert quote_blocks(quote) == cached + + +def test_a_drafting_note_holding_lists_past_the_limit_has_fresh_blocks(): + [note] = parse(_FRONT + _nest("D" + "L" * (_L + 6)) + "\n").sections[0].blocks + assert is_drafting_note(note) + blocks = drafting_note_blocks(note) + assert [block.kind for block in blocks] == ["unordered_list"] + blocks[0].items.clear() + assert drafting_note_blocks(note)[0].items != [] + + +def test_parse_validate_and_serialize(source): + document = parse(source) + validate(document) + validate(parse(source), final=True) + text = serialize(document) + assert serialize(parse(text)) == text + + +def test_the_dict_round_trip(source): + document = parse(source) + assert Document.from_dict(document.to_dict()) == document + + +def test_where_the_parts_are(source): + document = parse(source) + assert document.layout() is not None + assert document.line_of(0, 0) == 10 + assert body_layout(source.split("---\n\n", 1)[1]) is not None + + +def test_what_the_text_holds(source): + document = parse(source) + assert list(iter_document_directives(document)) != [] + find_markers(document) + for block in document.sections[0].blocks: + block_fragments(block) + + +def test_each_quote_read_into_blocks(source): + pending = _quotes(parse(source).sections[0].blocks, 0, []) + while pending: + quote, depth = pending.pop() + blocks = drafting_note_blocks(quote, depth=depth) if is_drafting_note(quote) else quote_blocks(quote, depth=depth) + _quotes(blocks, depth + 1, pending) + for block in blocks: + if block.kind == "code": + code_content(block) + + +@pytest.mark.parametrize("answers", [{"extras": True, "who": "Ann", "c": "C"}, {"extras": False}]) +def test_assembly(source, answers): + result = parse_template(source).form(answers).assemble() + if result.ok: + parse(result.output) diff --git a/tests/test_public_api.py b/tests/test_public_api.py index 3da3443..13a36a1 100644 --- a/tests/test_public_api.py +++ b/tests/test_public_api.py @@ -64,6 +64,7 @@ def test_every_public_name_is_importable(module): "Document", "Metadata", "Section", "Block", "ListItem", "Side", "Party", "Representative", "CustomField", "Amends", "Attachment", "DefinitionsImporter", "AttachmentDefinitionsImporter", # deprecated + "LegaldownDeprecationWarning", # what the deprecated names warn with ]) #: What PactTrack and legaldown-render import from ``legaldown`` itself. @@ -550,6 +551,56 @@ def test_validate_document_is_a_deprecated_alias_of_validate(): assert record[0].filename == __file__ +_TEMPLATE_TEXT = "---\ntitle: T\n---\n\n# A {#a}\n\nHi {{placeholder: who}}.\n" + +#: Every deprecated entry point, called: (name, call). +_DEPRECATED_CALLS = [ + ("parse_document", lambda: legaldown.parse_document(_SOURCE)), + ("serialize_document", lambda: legaldown.serialize_document(parse(_SOURCE))), + ("validate_document", lambda: legaldown.validate_document(parse(_SOURCE))), + ("validator.validate_document", lambda: legaldown.validator.validate_document(parse(_SOURCE))), + ("import_definitions", lambda: validate(parse(_SOURCE), import_definitions=lambda *_: {})), + ("import_attachment_definitions", lambda: validate(parse(_SOURCE), import_attachment_definitions=lambda _f: {})), + ("validate_document importer", lambda: legaldown.validate_document(parse(_SOURCE), import_definitions=lambda *_: {})), + ("assemble", lambda: legaldown.assemble(_TEMPLATE_TEXT, {"who": "Ann"})), + ("template_questions", lambda: legaldown.template_questions(_TEMPLATE_TEXT)), + ("needed_questions", lambda: legaldown.needed_questions(_TEMPLATE_TEXT, {})), +] + + +@pytest.mark.parametrize("call", [call for _name, call in _DEPRECATED_CALLS], ids=[n for n, _c in _DEPRECATED_CALLS]) +def test_every_deprecation_warns_with_legaldowns_own_category(call): + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + call() + assert caught, "no warning" + assert {type(w.message) for w in caught} == {legaldown.LegaldownDeprecationWarning} + assert all("removed in 0.5.0" in str(w.message) and w.filename == __file__ for w in caught) + + +@pytest.mark.parametrize("call", [call for _name, call in _DEPRECATED_CALLS], ids=[n for n, _c in _DEPRECATED_CALLS]) +def test_one_filter_turns_every_deprecation_into_an_error(call): + with warnings.catch_warnings(): + warnings.simplefilter("error", legaldown.LegaldownDeprecationWarning) + with pytest.raises(legaldown.LegaldownDeprecationWarning): + call() + with warnings.catch_warnings(): + warnings.simplefilter("error", DeprecationWarning) # a filter on the base category still matches + with pytest.raises(DeprecationWarning): + call() + + +def test_the_deprecation_category_is_a_deprecation_warning_named_from_legaldown(): + category = legaldown.LegaldownDeprecationWarning + assert issubclass(category, DeprecationWarning) and category is not DeprecationWarning + assert f"{category.__module__}.{category.__qualname__}" == "legaldown.LegaldownDeprecationWarning" + assert "removed in the next minor release" in " ".join(category.__doc__.split()) + with warnings.catch_warnings(): + warnings.simplefilter("error") + legaldown.validate(parse(_SOURCE)) # the current API does not warn + legaldown.parse_template(_TEMPLATE_TEXT).form({"who": "Ann"}).assemble() + + def test_the_messages_by_severity_are_the_diagnostics_own(): result = validate(parse(_FRONTMATTER + "# A\n\nSee {{ref: nowhere}}.\n")) assert result.errors == [d.message for d in result.diagnostics if d.level == "error"] != []