legaldown-render 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,49 @@
1
+ """legaldown-render — the reference renderer for LegalDown.
2
+
3
+ Render a LegalDown document to HTML or plain text. Section numbers,
4
+ cross-references, defined terms, and formatted values are generated at render
5
+ time, under a style template (§13.7)::
6
+
7
+ from legaldown_render import render
8
+
9
+ result = render(open("contract.lgd").read(), format="html", style="default")
10
+ for diagnostic in result.diagnostics:
11
+ print(diagnostic.level, diagnostic.rule, diagnostic.message)
12
+ open("contract.html", "w").write(result.output)
13
+
14
+ Parsing and Core validation come from legaldown-validator; this package
15
+ claims the Rendering conformance level (§17.3). Exact coverage:
16
+ https://github.com/ForLegalAI/legaldown-render/blob/main/CONFORMANCE.md
17
+ """
18
+ from __future__ import annotations
19
+
20
+ from .api import RenderOptions, RenderResult, render, render_file
21
+ from .errors import DocumentError, InternalError, RenderError, RenderRefused
22
+ from .style import Style, StyleError, builtin_styles, dump_style, load_style
23
+
24
+ __version__ = "0.2.0"
25
+
26
+ #: The LegalDown specification version this renderer targets.
27
+ SPEC_VERSION = "0.2"
28
+
29
+ #: Conformance level per specification §17.
30
+ CONFORMANCE_LEVEL = "rendering"
31
+
32
+ __all__ = [
33
+ "__version__",
34
+ "SPEC_VERSION",
35
+ "CONFORMANCE_LEVEL",
36
+ "render",
37
+ "render_file",
38
+ "RenderOptions",
39
+ "RenderResult",
40
+ "Style",
41
+ "StyleError",
42
+ "load_style",
43
+ "dump_style",
44
+ "builtin_styles",
45
+ "RenderError",
46
+ "DocumentError",
47
+ "RenderRefused",
48
+ "InternalError",
49
+ ]
@@ -0,0 +1,187 @@
1
+ """The rendering pipeline and its public API (docs/architecture.md).
2
+
3
+ Preferences are split in two, on purpose:
4
+
5
+ * the **style template** says how documents look — numbering, enumeration,
6
+ locale, labels, typography. It is a reusable YAML file (``--style``), with
7
+ single values overridable per job (``--set key=value``, ``overrides``);
8
+ * **render options** say what this one job does — the output format, strict
9
+ mode, the final check, the answers to a template, a full HTML page or a
10
+ fragment. They never change how a document
11
+ looks, so they are not part of the style.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ from collections.abc import Mapping
16
+ from dataclasses import dataclass, field
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+ import yaml
21
+ from legaldown import AssemblyError, Diagnostic, Document, ValidationResult, assemble, parse_document, validate_document
22
+
23
+ from .build import build_tree, normalize_source
24
+ from .errors import DocumentError, RenderRefused
25
+ from .resolve import Formatter, parse_locale, resolve_tree
26
+ from .style import Style, StyleError, load_style
27
+ from .tree import RenderTree, assert_resolved
28
+ from .writers import FORMATS, HtmlWriter, TextWriter
29
+
30
+
31
+ @dataclass(frozen=True, slots=True)
32
+ class RenderOptions:
33
+ """Settings for one render job."""
34
+
35
+ #: ``html`` or ``text``.
36
+ format: str = "html"
37
+ #: A built-in style name, a path to a YAML style template, or a Style.
38
+ style: str | Path | Style | None = None
39
+ #: Per-job style overrides by dotted key, e.g. ``{"numbering.scheme": "none"}``.
40
+ overrides: Mapping[str, Any] = field(default_factory=dict)
41
+ #: Shorthand for ``overrides={"locale": ...}``: the formatting locale.
42
+ locale: str | None = None
43
+ #: Refuse to render (raise RenderRefused) when the document has errors,
44
+ #: instead of rendering them as visible failure markers.
45
+ strict: bool = False
46
+ #: The final check (§15.9): the document is meant for signature, so a
47
+ #: remaining blank (``placeholder-unfilled``) or template construct
48
+ #: (``template-construct-present``) is an Error. With ``strict``, such a
49
+ #: document is refused.
50
+ final: bool = False
51
+ #: An answers set (§15.7.1): question id to answer, as YAML or JSON
52
+ #: would load it. With answers, a template is assembled first and the
53
+ #: assembled document is rendered (§15.8); without, a template renders
54
+ #: as its template view.
55
+ answers: Mapping[str, Any] | None = None
56
+ #: HTML only: a complete page (True) or just the ``<article>`` (False).
57
+ standalone: bool = True
58
+
59
+
60
+ @dataclass(frozen=True, slots=True)
61
+ class RenderResult:
62
+ """What a render produced."""
63
+
64
+ output: str
65
+ #: Core (validator) and Rendering diagnostics, each with its stable rule id.
66
+ diagnostics: list[Diagnostic]
67
+ format: str
68
+ #: The resolved tree, for tools that want more than the output text.
69
+ tree: RenderTree
70
+ style: Style
71
+
72
+ @property
73
+ def ok(self) -> bool:
74
+ """True when the document has no errors."""
75
+ return not any(diagnostic.level == "error" for diagnostic in self.diagnostics)
76
+
77
+
78
+ def render(source: str, options: RenderOptions | None = None, /, **settings: Any) -> RenderResult:
79
+ """Render LegalDown *source*.
80
+
81
+ Settings are a :class:`RenderOptions` or the same fields as keywords::
82
+
83
+ render(text, format="html", style="continental", locale="cs-CZ")
84
+
85
+ Problems in the document never raise: they render as failure markers and
86
+ are listed in ``diagnostics``. Raises :class:`StyleError` for an invalid
87
+ style or setting, :class:`DocumentError` for a document that cannot be
88
+ read, and :class:`RenderRefused` in strict mode when the document has
89
+ errors, or when a template cannot be assembled with ``answers``.
90
+ """
91
+ if options is None:
92
+ options = RenderOptions(**settings)
93
+ elif settings:
94
+ raise TypeError("pass either a RenderOptions or keyword settings, not both")
95
+ if options.format not in FORMATS:
96
+ raise ValueError(f"unknown output format '{options.format}' (known: {', '.join(FORMATS)})")
97
+ overrides = dict(options.overrides)
98
+ if options.locale:
99
+ overrides["locale"] = options.locale
100
+ style = load_style(options.style, overrides=overrides)
101
+
102
+ source = normalize_source(source)
103
+ assembled: list[Diagnostic] = []
104
+ if options.answers is not None:
105
+ source, assembled = _assemble(source, options.answers)
106
+ document = _parse(source)
107
+ result = validate_document(document, final=options.final)
108
+ diagnostics = assembled + list(result.diagnostics)
109
+ if options.strict and any(d.level == "error" for d in diagnostics):
110
+ raise RenderRefused(diagnostics)
111
+
112
+ tree, extra = _resolve(document, result, style)
113
+ diagnostics += [d for d in extra if d not in diagnostics]
114
+ if options.strict and any(d.level == "error" for d in diagnostics):
115
+ raise RenderRefused(diagnostics)
116
+
117
+ assert_resolved(tree)
118
+ output = _writer(options, style).write(tree)
119
+ return RenderResult(output, diagnostics, options.format, tree, style)
120
+
121
+
122
+ def render_file(path: str | Path, options: RenderOptions | None = None, /, **settings: Any) -> RenderResult:
123
+ """Render the LegalDown file at *path* (see :func:`render`)."""
124
+ return render(Path(path).read_text(encoding="utf-8"), options, **settings)
125
+
126
+
127
+ def _assemble(source: str, answers: Mapping[str, Any]) -> tuple[str, list[Diagnostic]]:
128
+ """*source* assembled with *answers* (§15.7), and the assembly's
129
+ Warnings (such as ``answer-unknown``). Raises RenderRefused when it
130
+ cannot be assembled: the template has Errors, for which §15.7.2 defines
131
+ no output, or the answers do (``answer-invalid``, ``answer-missing``),
132
+ or the template needs files a renderer below Full does not read
133
+ (includes, LegalDown attachments, translations; §17.6)."""
134
+ findings = validate_document(_parse(source)).diagnostics
135
+ errors = sum(1 for d in findings if d.level == "error")
136
+ if errors:
137
+ raise RenderRefused(findings, f"Assembly refused: the template has {errors} error(s), "
138
+ "and only a template without errors can be assembled (§15.7.2).")
139
+ try:
140
+ result = assemble(source, answers)
141
+ except AssemblyError as error:
142
+ raise DocumentError(f"The template cannot be assembled: {error}") from error
143
+ if not result.ok:
144
+ errors = [d for d in result.diagnostics if d.level == "error"]
145
+ raise RenderRefused(result.diagnostics, f"Assembly refused: {len(errors)} error(s) in the answers or "
146
+ "the template (§15.7).")
147
+ # The source was normalized, and assembly keeps its line breaks.
148
+ return result.output, list(result.diagnostics)
149
+
150
+
151
+ def _parse(source: str) -> Document:
152
+ try:
153
+ return parse_document(source)
154
+ except (ValueError, yaml.YAMLError) as error:
155
+ raise DocumentError(f"The document cannot be read: {error}") from error
156
+
157
+
158
+ def _resolve(document: Document, result: ValidationResult, style: Style) -> tuple[RenderTree, list[Diagnostic]]:
159
+ diagnostics: list[Diagnostic] = []
160
+ # Raw HTML is never emitted; the validator reports it (raw-html, §8.7).
161
+ tree = build_tree(document, result)
162
+ locale = parse_locale(style.locale) if style.locale else None
163
+ if style.locale and locale is None:
164
+ raise StyleError("setting", [f"locale: '{style.locale}' is not a known locale (e.g. en-US, cs-CZ)"])
165
+ if locale is None:
166
+ # No locale set: the document's language is the hint (§10.1).
167
+ locale = parse_locale(tree.language)
168
+ if locale is None:
169
+ diagnostics.append(Diagnostic(
170
+ rule="render-locale-fallback",
171
+ level="info",
172
+ message=f"No formatting locale is set and the document language '{tree.language}' is not a "
173
+ f"known locale; values are formatted for 'en'. Set one with --locale or the style's "
174
+ f"'locale'.",
175
+ ))
176
+ locale = parse_locale("en")
177
+ assert locale is not None
178
+ resolved, found = resolve_tree(tree, document, result, style, Formatter(locale, style.values))
179
+ return resolved, diagnostics + found
180
+
181
+
182
+ def _writer(options: RenderOptions, style: Style) -> HtmlWriter | TextWriter:
183
+ if options.format == "text":
184
+ return TextWriter()
185
+ from . import __version__
186
+
187
+ return HtmlWriter(style, standalone=options.standalone, generator=f"legaldown-render {__version__}")