Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 19 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -225,7 +225,7 @@ off.
Three functions cover the common path:

```python
from legaldown import load, validate, serialize_document
from legaldown import load, serialize, validate

document = load("contract.lgd")
result = validate(document)
Expand All @@ -234,7 +234,7 @@ for diagnostic in result.diagnostics:
print(diagnostic.level, diagnostic.rule, diagnostic.message)

if result.is_valid: # no Error-level diagnostics
print(serialize_document(document))
print(serialize(document)) # the source text; save(document) writes it to the file
```

`load(path)` takes a `str` or `os.PathLike`, reads the file as UTF-8 and returns the `Document`.
Expand Down Expand Up @@ -262,9 +262,10 @@ file's text (or `None`), to read them from elsewhere — a database, an upload.
is the same function for files on disk, and the one `parse_template` takes as `resolve=` (`LoadFile`).

> **Deprecated:** `parse_document` is now `parse` (string; same arguments) or `load` (file),
> `validate_document` is now `validate`, 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.
> `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.
>
> **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
Expand Down Expand Up @@ -317,15 +318,25 @@ the validator's own helpers ([Tooling API](#tooling-api)).
`Side`, `Party`, `Attachment` — that you can inspect, edit, and write back out:

```python
from legaldown import load, serialize_document
from legaldown import load, save

document = load("contract.lgd")
document.metadata.governing_law = "Czech Republic"

with open("contract.lgd", "w", encoding="utf-8") as handle:
handle.write(serialize_document(document))
save(document) # to document.path, where load found it
```

`save(document, path=None)` mirrors `load`: it writes the document to `path` (a `str` or
`os.PathLike`), or to `document.path` when it has none to be given, and returns the absolute path.
A document from `parse(text)` or built in code has no path, so `save` raises `ValueError` until you
pass one. The write is atomic — the file is either as it was or the new text, never half written —
a symbolic link is written through, and a file keeps its permissions. Missing directories on the way
are created. The text is UTF-8 with LF line endings and no byte-order mark, so a file that was read
with CRLF line endings or a byte-order mark is written back without them. Afterwards `document.path`
is the path written, as after `load`: that is where `validate` looks for the files the document
refers to (and `document.filename`, which diagnostics name, is its name). `serialize(document)` is the same text as a `str`, the inverse of `parse`, for when the
file is not what you want to write (an HTTP response, a database).

Content before the first heading — typically the sentence identifying the parties — is the
document's preamble (§4.4): it is unnumbered, so it lives in `document.preamble` rather than in
`sections`. `document.iter_blocks()` walks every body block, preamble first.
Expand Down
10 changes: 6 additions & 4 deletions src/legaldown/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,14 @@

Quick start::

from legaldown import load, serialize_document, validate
from legaldown import load, serialize, validate

doc = load("contract.lgd")
result = validate(doc)
for diagnostic in result.diagnostics:
print(diagnostic.level, diagnostic.rule, diagnostic.message)
if result.is_valid:
print(serialize_document(doc))
print(serialize(doc)) # save(doc) writes it back to the file
"""
from __future__ import annotations

Expand Down Expand Up @@ -78,7 +78,7 @@

# Parser & serializer
from .parser import FrontmatterError, collect_source_directives, load, parse, parse_document
from .serializer import render_block, render_item, serialize_document
from .serializer import render_block, render_item, save, serialize, serialize_document

# The LegalDown specification version this implementation targets.
from .specification import SPEC_VERSION
Expand Down Expand Up @@ -125,7 +125,9 @@
"parse",
"parse_document", # deprecated since 0.4.0, removed in 0.5.0: use ``parse`` or ``load``
"FrontmatterError",
"serialize_document",
"save",
"serialize",
"serialize_document", # deprecated since 0.4.0, removed in 0.5.0: use ``serialize``
"validate",
"validate_document", # deprecated since 0.4.0, removed in 0.5.0: use ``validate``
# Assembly (§15.7, §17.6)
Expand Down
32 changes: 2 additions & 30 deletions src/legaldown/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,20 +15,17 @@
from __future__ import annotations

import argparse
import contextlib
import json
import os
import stat
import sys
import tempfile
from pathlib import Path
from typing import Any

import yaml

from . import SPEC_VERSION, __version__
from .assembly import AssemblyResult, frontmatter_diagnostic
from .files import within
from .files import within, write_atomically
from .parser import FrontmatterError, load
from .template import AnswersError, Form, Template, load_answers, load_template
from .validator import validate
Expand Down Expand Up @@ -176,31 +173,6 @@ def _write_stdout(text: str, errors: str = "strict") -> None:
sys.stdout.write(text)


def _write_atomically(path: Path, text: str) -> None:
"""*text* to *path*: all of it, or the file as it was — written beside it and
moved into place, since the file may be the answers a person typed. A symbolic
link is written through, and the file keeps its permissions (a new one gets
those a file made now would have)."""
target = path.resolve()
target.parent.mkdir(parents=True, exist_ok=True)
if target.exists():
mode = stat.S_IMODE(target.stat().st_mode)
else:
mask = os.umask(0)
os.umask(mask)
mode = 0o666 & ~mask
descriptor, name = tempfile.mkstemp(dir=target.parent, prefix=f".{target.name}.", suffix=".tmp")
try:
with os.fdopen(descriptor, "w", encoding="utf-8", newline="") as handle:
handle.write(text)
os.chmod(name, mode)
os.replace(name, target)
except BaseException:
with contextlib.suppress(OSError):
os.unlink(name)
raise


def _write(path: Path, text: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("w", encoding="utf-8", newline="") as handle:
Expand Down Expand Up @@ -296,7 +268,7 @@ def _save_answers(path: Path, answers: dict) -> str | None:
try:
text = yaml.safe_dump(answers, sort_keys=False, allow_unicode=True, default_flow_style=False)
text.encode("utf-8")
_write_atomically(path, text)
write_atomically(path, text)
except (OSError, ValueError, RuntimeError, yaml.YAMLError) as exc: # a UnicodeEncodeError is a ValueError; a link loop a RuntimeError
return f"cannot write the answers to {path}: {exc}"
return None
Expand Down
2 changes: 1 addition & 1 deletion src/legaldown/definitions.py
Original file line number Diff line number Diff line change
Expand Up @@ -311,7 +311,7 @@ def _own_fragments(block: Block) -> list[tuple[str, bool]]:
):
# Raw HTML and indented code: no directive or marker is recognized
# (§11.4). A source block is not read at all: its text is read once
# written and parsed (``serialize_document``). A fenced block's text
# written and parsed (``serialize``). A fenced block's text
# is read with its fence blanked (below): text a model puts after its
# closing fence is checked -- which needs
# at least the fence's own line and one more, so a fence with nothing
Expand Down
29 changes: 29 additions & 0 deletions src/legaldown/files.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,11 @@
"""
from __future__ import annotations

import contextlib
import os
import posixpath
import stat
import tempfile
from collections.abc import Callable
from pathlib import Path

Expand Down Expand Up @@ -61,3 +64,29 @@ def within(base: Path, relative: str) -> Path | None:
except (OSError, ValueError, RuntimeError):
return None
return target if target.is_relative_to(root) else None


def write_atomically(path: Path, text: str) -> None:
"""*text* to *path* as UTF-8: all of it, or the file as it was — written
beside it and moved into place, since the file may be a document or the
answers a person wrote. A symbolic link is written through, the file keeps
its permissions (a new one gets those a file made now would have), and
missing directories on the way are created."""
target = path.resolve()
target.parent.mkdir(parents=True, exist_ok=True)
if target.exists():
mode = stat.S_IMODE(target.stat().st_mode)
else:
mask = os.umask(0)
os.umask(mask)
mode = 0o666 & ~mask
descriptor, name = tempfile.mkstemp(dir=target.parent, prefix=f".{target.name}.", suffix=".tmp")
try:
with os.fdopen(descriptor, "w", encoding="utf-8", newline="") as handle:
handle.write(text)
os.chmod(name, mode)
os.replace(name, target)
except BaseException:
with contextlib.suppress(OSError):
os.unlink(name)
raise
2 changes: 1 addition & 1 deletion src/legaldown/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -271,7 +271,7 @@ def iter_blocks(self) -> Iterator[tuple[Section | None, int, Block]]:
# not numbered and without an identifier. Its text is one line.
"heading": {"kind": "heading", "text": "", "level": 1},
# Markdown source a caller built, such as an editor's row: written as it
# stands (``serialize_document``), never produced by the parser and not
# stands (``serialize``), never produced by the parser and not
# read by the validator. Read back, it is the blocks it holds.
"source": {"kind": "source", "text": ""},
}
Expand Down
57 changes: 56 additions & 1 deletion src/legaldown/serializer.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,16 @@
"""
from __future__ import annotations

import os
import re
import warnings
from pathlib import Path
from typing import Any

import yaml

from .directives import format_value
from .files import write_atomically
from .markdown import (
FENCE_OPEN_RE,
close_fences,
Expand Down Expand Up @@ -555,9 +559,14 @@ def ignore_aliases(self, data: Any) -> bool:

# ── Public API ────────────────────────────────────────────────────

def serialize_document(document: Document) -> str:
def serialize(document: Document) -> str:
"""Serialize a Document object to LegalDown (.legal.md) source text.

The inverse of ``parse``: what it returns parses back as the document
(what the document says, not the source's own details such as line
endings or a byte-order mark). The text is LF-terminated and always ends
with a line break; ``save`` is the call for a file.

A document parsed without frontmatter (§3.1) is written without it, as
long as its metadata is still empty; a thematic break opening it is then
written ``***``, which cannot open frontmatter."""
Expand Down Expand Up @@ -591,6 +600,52 @@ def serialize_document(document: Document) -> str:
return text.rstrip("\n") + "\n"


def save(document: Document, path: str | os.PathLike[str] | None = None) -> Path:
"""Write *document* as LegalDown source to *path*, or, without one, to the
file it came from (``Document.path``).

The call for a document that lives in a file, as ``load`` is for reading
it: it writes ``serialize(document)`` as UTF-8 with LF line endings and no
byte-order mark, so a file that was read with CRLF line endings or a
byte-order mark is written back without them. The write is atomic: the
file is either as it was or the new text, never half written, since the
text goes to a temporary file beside it and is moved into place. A
symbolic link is written through (the link stays one, its target changes);
the file keeps its permissions, and a new one gets those a file made now
would have. Directories that are missing on the way to *path* are created.

The document now lives at *path*, as after ``load``: ``Document.path`` is
set to its absolute path (symbolic links are not followed), which is where
``validate`` looks for the files it refers to (§7.5, §12.4), and
``Document.filename`` to its name, which diagnostics name. That path is
returned.

Raises ``ValueError`` when both *path* and ``Document.path`` are None (a
document parsed from text or built in code has no path; pass one), and
``OSError`` when the file cannot be written; the document's path and
filename are then unchanged.
"""
target = document.path if path is None else path
if target is None:
raise ValueError("a document parsed from text or built in code has no path; pass one")
file = Path(target).absolute()
write_atomically(file, serialize(document))
document.path = file
document.filename = file.name
return file


def serialize_document(document: Document) -> str:
"""Deprecated since 0.4.0, removed in 0.5.0. Same as ``serialize``."""
warnings.warn(
"legaldown.serialize_document() is deprecated since 0.4.0 and will be removed in 0.5.0; "
"use legaldown.serialize()",
DeprecationWarning,
stacklevel=2,
)
return serialize(document)




def render_item(item: ListItem) -> str:
Expand Down
4 changes: 2 additions & 2 deletions tests/conformance/test_legaldown_fixtures.py
Original file line number Diff line number Diff line change
Expand Up @@ -249,14 +249,14 @@ def test_a_fixture_keeps_its_diagnostics_when_written_back(path: Path):
holds, an entry not yet complete included (#83)."""
from collections import Counter

from legaldown import serialize_document
from legaldown import serialize
from legaldown.parser import FrontmatterError

try:
document = parse(_read(path), filename=path.name)
except FrontmatterError:
pytest.skip("unreadable frontmatter: nothing to write back")
again = parse(serialize_document(document), filename=path.name)
again = parse(serialize(document), filename=path.name)
# How the source writes its YAML is not the model's (block style when
# written back).
assert replace(again.metadata, not_line_editable=[]) == replace(document.metadata, not_line_editable=[])
Expand Down
6 changes: 3 additions & 3 deletions tests/test_conditions.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

import pytest

from legaldown import serialize_document
from legaldown import serialize
from legaldown.markers import Marker, parse_marker, split_heading
from legaldown.parser import parse
from legaldown.validator import validate
Expand Down Expand Up @@ -77,7 +77,7 @@ def test_a_conditional_heading_round_trips_and_its_identifier_is_generated():
document = _parse("# Services {when=vat}\n\nText.")
section = document.sections[0]
assert (section.title, section.identifier, section.condition) == ("Services", "", "vat")
assert "\n# Services {when=vat}\n" in serialize_document(document)
assert "\n# Services {when=vat}\n" in serialize(document)
result = validate(document)
assert result.index.sections[0].identifier == "services"
assert document.sections[0].identifier == "" # validation leaves the document as it was
Expand Down Expand Up @@ -389,7 +389,7 @@ def test_a_comment_may_follow_a_heading_marker():
document = _parse("# Termination {when=vat} <!-- optional -->\n\nText.")
[section] = document.sections
assert (section.title, section.condition) == ("Termination <!-- optional -->", "vat")
assert parse(serialize_document(document)).sections[0].condition == "vat"
assert parse(serialize(document)).sections[0].condition == "vat"


def test_a_dotted_path_is_not_a_reference():
Expand Down
14 changes: 14 additions & 0 deletions tests/test_public_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,20 @@ def test_parse_document_is_a_deprecated_alias_of_parse():
assert record[0].filename == __file__ # the caller is named, not the shim


def test_serialize_document_is_a_deprecated_alias_of_serialize():
document = parse(_SOURCE)
with pytest.warns(DeprecationWarning, match=r"removed in 0\.5\.0") as record:
text = legaldown.serialize_document(document)
assert text == legaldown.serialize(document)
assert record[0].filename == __file__ # the caller is named, not the shim


def test_writing_mirrors_reading():
for name in ("load", "parse", "save", "serialize"):
assert name in legaldown.__all__
assert "write_atomically" not in legaldown.__all__


def test_a_symbolic_link_is_named_as_it_was_given(tmp_path):
target = tmp_path / "v3-final.lgd"
target.write_text(_SOURCE, encoding="utf-8")
Expand Down
Loading
Loading