pdfrender 0.1.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.
- pdf_render/__init__.py +25 -0
- pdf_render/data/__init__.py +5 -0
- pdf_render/data/accessor.py +161 -0
- pdf_render/data/importer.py +209 -0
- pdf_render/data/model_field_typing.py +159 -0
- pdf_render/import_functions.py +43 -0
- pdf_render/md/__init__.py +3 -0
- pdf_render/md/extensions/__init__.py +13 -0
- pdf_render/md/extensions/columns.py +47 -0
- pdf_render/md/extensions/flex_wrap.py +52 -0
- pdf_render/md/extensions/muted_text.py +42 -0
- pdf_render/md/extensions/nerdfont_glyph.py +37 -0
- pdf_render/md/extensions/page_break.py +39 -0
- pdf_render/md/rendering.py +29 -0
- pdf_render/pdf/__init__.py +5 -0
- pdf_render/pdf/document_template.py +45 -0
- pdf_render/pdf/metadata.py +38 -0
- pdf_render/pdf/options.py +64 -0
- pdf_render/pdf/rendering.py +40 -0
- pdf_render/templates/__init__.py +4 -0
- pdf_render/templates/environment.py +158 -0
- pdf_render/templates/filters/__init__.py +9 -0
- pdf_render/templates/filters/format_filters.py +34 -0
- pdf_render/templates/filters/getter_filters.py +45 -0
- pdf_render/templates/rendering.py +75 -0
- pdf_render/templates/tests/__init__.py +0 -0
- pdf_render/user_input/__init__.py +3 -0
- pdf_render/user_input/undefined_variable_form.py +321 -0
- pdfrender-0.1.0.dist-info/METADATA +96 -0
- pdfrender-0.1.0.dist-info/RECORD +34 -0
- pdfrender-0.1.0.dist-info/WHEEL +5 -0
- pdfrender-0.1.0.dist-info/entry_points.txt +2 -0
- pdfrender-0.1.0.dist-info/licenses/LICENSE +21 -0
- pdfrender-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
from xml.etree import ElementTree
|
|
2
|
+
|
|
3
|
+
from markdown import Extension
|
|
4
|
+
from markdown.inlinepatterns import InlineProcessor
|
|
5
|
+
|
|
6
|
+
# Pattern to match %( content )%
|
|
7
|
+
FLEX_WRAP_RE = r"%\(\s*(.*?)\s*\)%"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class FlexWrapInlineProcessor(InlineProcessor):
|
|
11
|
+
def __init__(self, pattern, md=None):
|
|
12
|
+
super().__init__(pattern, md)
|
|
13
|
+
|
|
14
|
+
def handleMatch(self, match, data):
|
|
15
|
+
# Extract the content between %( and )%
|
|
16
|
+
content = match.group(1)
|
|
17
|
+
|
|
18
|
+
# Split on || to get individual items
|
|
19
|
+
items = [item.strip() for item in content.split("||")]
|
|
20
|
+
|
|
21
|
+
# Create the container div
|
|
22
|
+
container = ElementTree.Element("div")
|
|
23
|
+
container.set("class", "flex-wrap")
|
|
24
|
+
|
|
25
|
+
# Create each item as a bubble
|
|
26
|
+
for item_text in items:
|
|
27
|
+
if item_text: # Only create bubbles for non-empty items
|
|
28
|
+
bubble = ElementTree.SubElement(container, "div")
|
|
29
|
+
bubble.set("class", "bubble")
|
|
30
|
+
bubble.text = item_text
|
|
31
|
+
|
|
32
|
+
return container, match.start(0), match.end(0)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class FlexWrapExtension(Extension):
|
|
36
|
+
"""Markdown extension for the `%( item1 || item2 || ... )%` inline syntax.
|
|
37
|
+
|
|
38
|
+
Renders the given `||`-separated items as a `<div class="flex-wrap">`
|
|
39
|
+
containing one `<div class="bubble">` per non-empty item, for wrapping
|
|
40
|
+
lists of tags/badges.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def extendMarkdown(self, md):
|
|
44
|
+
processor = FlexWrapInlineProcessor(FLEX_WRAP_RE, md)
|
|
45
|
+
|
|
46
|
+
# Add with priority to ensure it runs before other inline processors
|
|
47
|
+
md.inlinePatterns.register(processor, "flex_wrap", 175)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# noinspection PyPep8Naming
|
|
51
|
+
def makeExtension(**kwargs):
|
|
52
|
+
return FlexWrapExtension(**kwargs)
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import xml.etree.ElementTree as ElementTree
|
|
2
|
+
|
|
3
|
+
from markdown import Extension
|
|
4
|
+
from markdown.inlinepatterns import InlineProcessor
|
|
5
|
+
|
|
6
|
+
# Pattern to match %% content %%
|
|
7
|
+
MUTED_TEXT_RE = r"%m\s*(.*?)\s*m%"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class MutedTextInlineProcessor(InlineProcessor):
|
|
11
|
+
def __init__(self, pattern, md=None):
|
|
12
|
+
super().__init__(pattern, md)
|
|
13
|
+
|
|
14
|
+
def handleMatch(self, match, data):
|
|
15
|
+
# Extract the content between %m and m%
|
|
16
|
+
content = match.group(1)
|
|
17
|
+
|
|
18
|
+
# Create the container div
|
|
19
|
+
container = ElementTree.Element("span")
|
|
20
|
+
container.set("class", "muted-text")
|
|
21
|
+
container.text = content.strip()
|
|
22
|
+
|
|
23
|
+
return container, match.start(0), match.end(0)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class MutedTextExtension(Extension):
|
|
27
|
+
"""Markdown extension for the `%m text m%` inline syntax.
|
|
28
|
+
|
|
29
|
+
Renders the enclosed text as a `<span class="muted-text">`, for
|
|
30
|
+
de-emphasized inline text.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def extendMarkdown(self, md):
|
|
34
|
+
processor = MutedTextInlineProcessor(MUTED_TEXT_RE, md)
|
|
35
|
+
|
|
36
|
+
# Add with priority to ensure it runs before other inline processors
|
|
37
|
+
md.inlinePatterns.register(processor, "muted_text", 175)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# noinspection PyPep8Naming
|
|
41
|
+
def makeExtension(**kwargs):
|
|
42
|
+
return MutedTextExtension(**kwargs)
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
from xml.etree import ElementTree
|
|
2
|
+
|
|
3
|
+
from markdown import Extension
|
|
4
|
+
from markdown.inlinepatterns import InlineProcessor
|
|
5
|
+
|
|
6
|
+
GLYPH_RE = r"%\+(\S)\+%"
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class NerdfontGlyphInlineProcessor(InlineProcessor):
|
|
10
|
+
def __init__(self, pattern, md=None):
|
|
11
|
+
super().__init__(pattern, md)
|
|
12
|
+
|
|
13
|
+
def handleMatch(self, match, data):
|
|
14
|
+
|
|
15
|
+
container = ElementTree.Element("span")
|
|
16
|
+
container.set("class", "nf")
|
|
17
|
+
container.text = match.group(1)
|
|
18
|
+
|
|
19
|
+
return container, match.start(0), match.end(0)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class NerdfontGlyphExtension(Extension):
|
|
23
|
+
"""Markdown extension for the `%+<glyph>+%` inline syntax.
|
|
24
|
+
|
|
25
|
+
Renders the single enclosed character as a `<span class="nf">`, for
|
|
26
|
+
embedding a Nerd Font icon glyph inline.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def extendMarkdown(self, md):
|
|
30
|
+
|
|
31
|
+
processor = NerdfontGlyphInlineProcessor(GLYPH_RE, md)
|
|
32
|
+
md.inlinePatterns.register(processor, "nerdfont_glyph", 175)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# noinspection PyPep8Naming
|
|
36
|
+
def makeExtension(**kwargs):
|
|
37
|
+
return NerdfontGlyphExtension(**kwargs)
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from xml.etree import ElementTree
|
|
2
|
+
|
|
3
|
+
from markdown import Extension
|
|
4
|
+
from markdown.inlinepatterns import InlineProcessor
|
|
5
|
+
|
|
6
|
+
# Pattern to match %( content )%
|
|
7
|
+
PAGE_BREAK_RE = r"%:pg:%"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class PageBreakInlineProcessor(InlineProcessor):
|
|
11
|
+
def __init__(self, pattern, md=None):
|
|
12
|
+
super().__init__(pattern, md)
|
|
13
|
+
|
|
14
|
+
def handleMatch(self, match, data):
|
|
15
|
+
|
|
16
|
+
container = ElementTree.Element("div")
|
|
17
|
+
container.set("class", "page")
|
|
18
|
+
|
|
19
|
+
return container, match.start(0), match.end(0)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class PageBreakExtension(Extension):
|
|
23
|
+
"""Markdown extension for the `%:pg:%` inline syntax.
|
|
24
|
+
|
|
25
|
+
Renders an empty `<div class="page">`, intended to be styled with a CSS
|
|
26
|
+
page-break rule when rendering to PDF.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def extendMarkdown(self, md):
|
|
30
|
+
|
|
31
|
+
processor = PageBreakInlineProcessor(PAGE_BREAK_RE, md)
|
|
32
|
+
|
|
33
|
+
# Add with priority to ensure it runs before other inline processors
|
|
34
|
+
md.inlinePatterns.register(processor, "page_break", 175)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# noinspection PyPep8Naming
|
|
38
|
+
def makeExtension(**kwargs):
|
|
39
|
+
return PageBreakExtension(**kwargs)
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from markdown import Markdown
|
|
2
|
+
|
|
3
|
+
from pdf_render.import_functions import import_submodules
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def create_markdown_renderer(*markdown_extensions_modules: str) -> Markdown:
|
|
7
|
+
"""Build a `markdown.Markdown` renderer with pdf_render's extensions enabled.
|
|
8
|
+
|
|
9
|
+
Discovers and registers every extension module under
|
|
10
|
+
`pdf_render.md.extensions` and `pymdownx` (any submodule exposing a
|
|
11
|
+
`makeExtension` callable), plus any additional extension modules passed
|
|
12
|
+
in.
|
|
13
|
+
|
|
14
|
+
Args:
|
|
15
|
+
*markdown_extensions_modules: Additional dotted module paths to
|
|
16
|
+
search for extensions exposing a `makeExtension` callable.
|
|
17
|
+
|
|
18
|
+
Returns:
|
|
19
|
+
A configured `markdown.Markdown` instance.
|
|
20
|
+
"""
|
|
21
|
+
markdown_extensions = import_submodules("pdf_render.md.extensions", has_attr_filter="makeExtension")
|
|
22
|
+
markdown_extensions.update(import_submodules("pymdownx", has_attr_filter="makeExtension"))
|
|
23
|
+
for extension_dir in markdown_extensions_modules:
|
|
24
|
+
if not isinstance(extension_dir, str):
|
|
25
|
+
continue
|
|
26
|
+
|
|
27
|
+
markdown_extensions.update(import_submodules(extension_dir, has_attr_filter="makeExtension"))
|
|
28
|
+
|
|
29
|
+
return Markdown(extensions=markdown_extensions)
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
from jinja2 import Environment
|
|
4
|
+
|
|
5
|
+
from pdf_render.pdf.metadata import PDFMetadata
|
|
6
|
+
|
|
7
|
+
BASE_HTML_TEMPLATE = """
|
|
8
|
+
<!DOCTYPE html>
|
|
9
|
+
<html lang="{{ title }}">
|
|
10
|
+
<head>
|
|
11
|
+
<title>{{ title }}</title>
|
|
12
|
+
<meta http-equiv="Content-type" content="text/html;charset=UTF-8">
|
|
13
|
+
{% for author in authors %}
|
|
14
|
+
<meta name="author" content="{{ author }}">
|
|
15
|
+
{% endfor %}
|
|
16
|
+
<meta name="generator" content="{{ generator }}">
|
|
17
|
+
<meta name="keyword" content="{{ keywords | join(",") }}">
|
|
18
|
+
<meta name="dcterms.created" content="{{ created.strftime('%Y-%m-%d') }}">
|
|
19
|
+
<meta name="dcterms.modified" content="{{ modified.strftime('%Y-%m-%d') }}">
|
|
20
|
+
<meta name="description" content="{{ description }}">
|
|
21
|
+
{% for key in custom %}
|
|
22
|
+
<meta name="{{ key }}" content="{{ custom[key] }}">
|
|
23
|
+
{% endfor %}
|
|
24
|
+
<style>
|
|
25
|
+
{{ css }}
|
|
26
|
+
</style>
|
|
27
|
+
</head>
|
|
28
|
+
<body>
|
|
29
|
+
<div class="markdown-container">
|
|
30
|
+
{{ html }}
|
|
31
|
+
</div>
|
|
32
|
+
</body>
|
|
33
|
+
</html>
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def finalize(value: Any):
|
|
38
|
+
return value if value is not None else ""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def render_document_html(html: str, css: str, metadata: PDFMetadata):
|
|
42
|
+
jinja2_env = Environment(finalize=finalize)
|
|
43
|
+
template_globals = {"html": html, "css": css, **metadata.model_dump()}
|
|
44
|
+
|
|
45
|
+
return jinja2_env.from_string(BASE_HTML_TEMPLATE, globals=template_globals).render()
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
from pydantic import BaseModel, Field, field_validator
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class PDFMetadata(BaseModel):
|
|
8
|
+
"""Document metadata to embed in a rendered PDF.
|
|
9
|
+
|
|
10
|
+
Attributes:
|
|
11
|
+
title: The document title.
|
|
12
|
+
description: The document description/subject.
|
|
13
|
+
generator: The name of the tool that generated the document.
|
|
14
|
+
language: The document's language tag (e.g. "en-us").
|
|
15
|
+
keywords: Keywords describing the document. Commas are stripped from
|
|
16
|
+
each entry, since WeasyPrint uses commas to separate keywords.
|
|
17
|
+
authors: The document's author names.
|
|
18
|
+
created: The document's creation timestamp.
|
|
19
|
+
modified: The document's last-modified timestamp.
|
|
20
|
+
custom: Additional custom metadata fields.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
title: str | None = None
|
|
24
|
+
description: str | None = None
|
|
25
|
+
generator: str | None = None
|
|
26
|
+
language: str | None = "en-us"
|
|
27
|
+
keywords: list[str] | None = []
|
|
28
|
+
authors: list[str] | None = []
|
|
29
|
+
created: datetime | None = Field(default_factory=datetime.now)
|
|
30
|
+
modified: datetime | None = Field(default_factory=datetime.now)
|
|
31
|
+
custom: dict | None = {}
|
|
32
|
+
|
|
33
|
+
@field_validator("keywords")
|
|
34
|
+
@classmethod
|
|
35
|
+
def sanitize_for_commas(cls, value: Any):
|
|
36
|
+
if isinstance(value, list):
|
|
37
|
+
return [list_item.replace(",", "") for list_item in value]
|
|
38
|
+
return value
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, ConfigDict, field_validator
|
|
4
|
+
from weasyprint import CSS, Attachment
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class PDFOptions(BaseModel):
|
|
8
|
+
"""Typed wrapper around WeasyPrint's PDF rendering options.
|
|
9
|
+
|
|
10
|
+
Mirrors the keyword arguments accepted by `weasyprint.HTML.render`/
|
|
11
|
+
`weasyprint.Document.write_pdf` (see `weasyprint.DEFAULT_OPTIONS`), so an
|
|
12
|
+
instance can be passed straight through via
|
|
13
|
+
`model_dump(exclude_none=True)`.
|
|
14
|
+
|
|
15
|
+
Attributes:
|
|
16
|
+
stylesheets: Additional CSS to apply, as `weasyprint.CSS` objects or paths/URLs.
|
|
17
|
+
attachments: Files to embed in the PDF as attachments.
|
|
18
|
+
attachment_relationships: The PDF/A-3 AFRelationship value for attachments.
|
|
19
|
+
pdf_identifier: The document's PDF identifier.
|
|
20
|
+
pdf_variant: The target PDF variant (e.g. "pdf/a-3b", "pdf/ua-1").
|
|
21
|
+
pdf_version: The target PDF version (e.g. "1.7").
|
|
22
|
+
pdf_forms: Whether to keep interactive form fields in the output.
|
|
23
|
+
pdf_tags: Whether to include structure tags for accessibility.
|
|
24
|
+
uncompressed_pdf: Whether to disable PDF stream compression.
|
|
25
|
+
xmp_metadata: Whether to include an XMP metadata stream.
|
|
26
|
+
custom_metadata: Whether to include WeasyPrint's custom PDF metadata.
|
|
27
|
+
presentational_hints: Whether to honor CSS presentational hints.
|
|
28
|
+
output_intent: An ICC output intent identifier.
|
|
29
|
+
optimize_images: Whether to re-encode images to reduce PDF size.
|
|
30
|
+
jpeg_quality: JPEG re-encoding quality, clamped to 1-100.
|
|
31
|
+
dpi: Maximum image resolution, in dots per inch.
|
|
32
|
+
full_fonts: Whether to embed full font files instead of subsets.
|
|
33
|
+
hinting: Whether to keep font hinting instructions.
|
|
34
|
+
cache: A dict or path used to cache fetched resources across renders.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
# weasprint.__init__.DEFAULT_OPTIONS
|
|
38
|
+
model_config = ConfigDict(arbitrary_types_allowed=True)
|
|
39
|
+
stylesheets: list[CSS | str] | None = None
|
|
40
|
+
attachments: list[Attachment | str] | None = None
|
|
41
|
+
attachment_relationships: str | None = None
|
|
42
|
+
pdf_identifier: bytes | None = None
|
|
43
|
+
pdf_variant: str | None = None
|
|
44
|
+
pdf_version: str | None = None
|
|
45
|
+
pdf_forms: bool | None = None
|
|
46
|
+
pdf_tags: bool = False
|
|
47
|
+
uncompressed_pdf: bool = False
|
|
48
|
+
xmp_metadata: bool | None = None
|
|
49
|
+
custom_metadata: bool = False
|
|
50
|
+
presentational_hints: bool | None = None
|
|
51
|
+
output_intent: str | None = None
|
|
52
|
+
optimize_images: bool = False
|
|
53
|
+
jpeg_quality: int | None = None
|
|
54
|
+
dpi: int | None = None
|
|
55
|
+
full_fonts: bool = False
|
|
56
|
+
hinting: bool = False
|
|
57
|
+
cache: dict[str, Any] | str | None = None
|
|
58
|
+
|
|
59
|
+
@field_validator("jpeg_quality")
|
|
60
|
+
@classmethod
|
|
61
|
+
def clamp_jpeg_quality(cls, value: Any):
|
|
62
|
+
if isinstance(value, int) and not (1 <= value <= 100):
|
|
63
|
+
raise ValueError("jpeg_quality must be within range of 1-100")
|
|
64
|
+
return value
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
from weasyprint import HTML
|
|
4
|
+
from weasyprint.text.fonts import FontConfiguration
|
|
5
|
+
|
|
6
|
+
from pdf_render.pdf.document_template import render_document_html
|
|
7
|
+
from pdf_render.pdf.metadata import PDFMetadata
|
|
8
|
+
from pdf_render.pdf.options import PDFOptions
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def render_pdf(
|
|
12
|
+
out_file: Path,
|
|
13
|
+
html: str,
|
|
14
|
+
css: str,
|
|
15
|
+
pdf_metadata: PDFMetadata = None,
|
|
16
|
+
pdf_options: PDFOptions = None,
|
|
17
|
+
):
|
|
18
|
+
"""Render HTML/CSS to a PDF file using WeasyPrint.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
out_file: The path to write the rendered PDF to. Also used as the
|
|
22
|
+
base URL for resolving relative resources in `html`, and as the
|
|
23
|
+
default document title when `pdf_metadata` is not given.
|
|
24
|
+
html: The document body as an HTML string.
|
|
25
|
+
css: Additional CSS to apply to the document.
|
|
26
|
+
pdf_metadata: Metadata to embed in the PDF. Defaults to a
|
|
27
|
+
`PDFMetadata` titled after `out_file`'s filename.
|
|
28
|
+
pdf_options: WeasyPrint rendering options. Defaults to `PDFOptions()`.
|
|
29
|
+
"""
|
|
30
|
+
pdf_metadata = pdf_metadata or PDFMetadata(title=out_file.stem)
|
|
31
|
+
pdf_options = pdf_options or PDFOptions()
|
|
32
|
+
pdf_options.custom_metadata = bool(pdf_metadata.custom)
|
|
33
|
+
|
|
34
|
+
font_config: FontConfiguration = FontConfiguration()
|
|
35
|
+
|
|
36
|
+
document_html = render_document_html(html=html, css=css, metadata=pdf_metadata)
|
|
37
|
+
options = pdf_options.model_dump(exclude_none=True)
|
|
38
|
+
html = HTML(string=document_html, base_url=out_file)
|
|
39
|
+
pdf_document = html.render(font_config=font_config, **options)
|
|
40
|
+
pdf_document.write_pdf(target=out_file, **options)
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import builtins
|
|
2
|
+
import logging
|
|
3
|
+
import re
|
|
4
|
+
from typing import Any, MutableMapping, Optional, Type, Union
|
|
5
|
+
|
|
6
|
+
from click import Choice
|
|
7
|
+
from jinja2 import FileSystemLoader, TemplateSyntaxError
|
|
8
|
+
from jinja2.environment import Environment, Template
|
|
9
|
+
from jinja2.meta import find_undeclared_variables
|
|
10
|
+
from jinja2.runtime import StrictUndefined
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class TypedVariableEnvironment(Environment):
|
|
16
|
+
"""A `jinja2.Environment` that pre-processes templates to support typed variables.
|
|
17
|
+
|
|
18
|
+
Allows template authors to specify variable types using PEP 484
|
|
19
|
+
annotation syntax. Typed variables can be useful when prompting users for
|
|
20
|
+
the value of undefined variables.
|
|
21
|
+
|
|
22
|
+
Variables won't appear in `undeclared_variables` until the template is
|
|
23
|
+
read via `get_template()`. Resist the urge to call `get_template()` and
|
|
24
|
+
`Template.render()` back-to-back in the same loop; instead:
|
|
25
|
+
|
|
26
|
+
1. Iterate through `list_templates()`, calling `get_template()` on each
|
|
27
|
+
item and storing the resulting `Template` in a list.
|
|
28
|
+
2. Use the keys/values of `undeclared_variables` to prompt the user for
|
|
29
|
+
missing values.
|
|
30
|
+
3. Update `globals` with the user's inputs.
|
|
31
|
+
4. Validate the values in `globals` against the types recorded in
|
|
32
|
+
`type_registry`.
|
|
33
|
+
5. Finally, render the list of `Template` objects.
|
|
34
|
+
|
|
35
|
+
Attributes:
|
|
36
|
+
type_registry: Maps every variable name found while pre-processing a
|
|
37
|
+
template to its declared (or inferred) type. Populated during
|
|
38
|
+
`preprocess()`; a variable with no explicit type annotation
|
|
39
|
+
defaults to `str`.
|
|
40
|
+
undeclared_variables: The subset of `type_registry` whose variables
|
|
41
|
+
have no defined value yet, populated by `update_undeclared_variables()`.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
loader: FileSystemLoader
|
|
45
|
+
|
|
46
|
+
def __init__(self, *args, **kwargs):
|
|
47
|
+
super().__init__(*args, **kwargs, undefined=StrictUndefined)
|
|
48
|
+
"""
|
|
49
|
+
StrictUndefined is required as jinja2.meta.find_undeclared_variables will not detect any
|
|
50
|
+
undeclared variables when the default of jinja2.runtime.Undefined is used.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
self.type_registry: dict[str, type | object] = {}
|
|
54
|
+
"""
|
|
55
|
+
Used to assign types to keys in self.undeclared_variables.
|
|
56
|
+
Is populated during pre-process, marking a variable as the found type. Type defaults to str if none is found.
|
|
57
|
+
Should contain every variable.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
self.undeclared_variables: dict[str, type] = {}
|
|
61
|
+
"""
|
|
62
|
+
The keys/values from self.type_registry who have no defined value.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
def preprocess(self, source, name=None, filename=None):
|
|
66
|
+
"""
|
|
67
|
+
- Extracts the PEP 484 type from the template variable tag, if it exists.
|
|
68
|
+
- The variable name and type are stored in a dict 'Environment.type_registry'.
|
|
69
|
+
- If no type annotation is found, the variable will be mapped to type 'str'.
|
|
70
|
+
- The type annotation (: $type_name) is removed from the template source before handing it back
|
|
71
|
+
to the parent class jinja2.environment.Environment's 'preprocess' method.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
# In case it's not '{{' and '}}', we need to escape them because they probably still contain braces
|
|
75
|
+
start_string = "".join(["\\" + char for char in self.variable_start_string])
|
|
76
|
+
end_string = "".join(["\\" + char for char in self.variable_end_string])
|
|
77
|
+
var_pattern = r"(\w+)"
|
|
78
|
+
type_pattern = r"(\w+)"
|
|
79
|
+
type_args_pattern = r"([\(\[]\[.+\][\)\]])"
|
|
80
|
+
match_pattern = (
|
|
81
|
+
start_string
|
|
82
|
+
+ r"\s*"
|
|
83
|
+
+ var_pattern
|
|
84
|
+
+ r"\s*:\s*"
|
|
85
|
+
+ type_pattern
|
|
86
|
+
+ r"\s*(?:"
|
|
87
|
+
+ type_args_pattern
|
|
88
|
+
+ r")?\s*"
|
|
89
|
+
+ end_string
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
for match in re.finditer(match_pattern, source):
|
|
93
|
+
var_name, var_type_str, var_type_args = match.groups()
|
|
94
|
+
var_type_str = var_type_str if var_name is not None else "str"
|
|
95
|
+
|
|
96
|
+
if "Choice" == var_type_str.strip():
|
|
97
|
+
choice_args = var_type_args.lstrip("(").rstrip(")")
|
|
98
|
+
self.type_registry[var_name] = Choice(eval(choice_args))
|
|
99
|
+
|
|
100
|
+
continue
|
|
101
|
+
|
|
102
|
+
var_type = getattr(builtins, var_type_str) if hasattr(builtins, var_type_str) else str
|
|
103
|
+
self.type_registry[var_name] = var_type
|
|
104
|
+
|
|
105
|
+
clean_source = re.sub(
|
|
106
|
+
match_pattern,
|
|
107
|
+
rf"{self.variable_start_string} \1 {self.variable_end_string}",
|
|
108
|
+
source,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
return super().preprocess(clean_source, name, filename)
|
|
112
|
+
|
|
113
|
+
def update_undeclared_variables(self, source: str):
|
|
114
|
+
ast = self.parse(source)
|
|
115
|
+
undeclared_variables = list(find_undeclared_variables(ast))
|
|
116
|
+
if undeclared_variables:
|
|
117
|
+
input_variables = {var_name: self.type_registry.get(var_name, str) for var_name in undeclared_variables}
|
|
118
|
+
self.undeclared_variables.update(input_variables)
|
|
119
|
+
|
|
120
|
+
def get_template(
|
|
121
|
+
self,
|
|
122
|
+
name: Union[str, "Template"],
|
|
123
|
+
parent: Optional[str] = None,
|
|
124
|
+
_globals: Optional[MutableMapping[str, Any]] = None,
|
|
125
|
+
) -> "Template":
|
|
126
|
+
"""
|
|
127
|
+
Based on jinja2.Environment's original get_template method, adding the call to update_undeclared_variables.
|
|
128
|
+
"""
|
|
129
|
+
if isinstance(name, Template):
|
|
130
|
+
return name
|
|
131
|
+
|
|
132
|
+
if parent is not None:
|
|
133
|
+
name: str = self.join_path(name, parent)
|
|
134
|
+
|
|
135
|
+
source, _, _ = self.loader.get_source(self, name)
|
|
136
|
+
|
|
137
|
+
try:
|
|
138
|
+
self.update_undeclared_variables(source)
|
|
139
|
+
except TemplateSyntaxError as e:
|
|
140
|
+
logger.error(f"Syntax error at: {name}:{e.lineno}")
|
|
141
|
+
raise e
|
|
142
|
+
|
|
143
|
+
template = self._load_template(name, _globals)
|
|
144
|
+
return template
|
|
145
|
+
|
|
146
|
+
def from_string(
|
|
147
|
+
self,
|
|
148
|
+
source: Union[str],
|
|
149
|
+
_globals: Optional[MutableMapping[str, Any]] = None,
|
|
150
|
+
template_class: Optional[Type[Template]] = None,
|
|
151
|
+
) -> "Template":
|
|
152
|
+
"""
|
|
153
|
+
Based on jinja2.Environment's original from_string method, adding the call to update_undeclared_variables.
|
|
154
|
+
"""
|
|
155
|
+
self.update_undeclared_variables(source)
|
|
156
|
+
gs = self.make_globals(_globals)
|
|
157
|
+
cls = template_class or self.template_class
|
|
158
|
+
return cls.from_code(self, self.compile(source), gs, None)
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
from .format_filters import jinja_filter_month_year_fmt, jinja_filter_phone_num_fmt
|
|
2
|
+
from .getter_filters import jinja_filter_get_list_item_by_attr, jinja_filter_with_item_attr_first
|
|
3
|
+
|
|
4
|
+
__all__ = [
|
|
5
|
+
"jinja_filter_month_year_fmt",
|
|
6
|
+
"jinja_filter_phone_num_fmt",
|
|
7
|
+
"jinja_filter_with_item_attr_first",
|
|
8
|
+
"jinja_filter_get_list_item_by_attr",
|
|
9
|
+
]
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import datetime
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def jinja_filter_phone_num_fmt(phone_num: str):
|
|
5
|
+
"""Format an 11-digit US/Canadian phone number string.
|
|
6
|
+
|
|
7
|
+
Args:
|
|
8
|
+
phone_num: A string of digits, assumed to be a valid 11-digit
|
|
9
|
+
US/Canadian phone number (country code + area code + number).
|
|
10
|
+
Returned unchanged if it isn't exactly 11 digits.
|
|
11
|
+
|
|
12
|
+
Returns:
|
|
13
|
+
The formatted string, e.g. "1-800-222-2222".
|
|
14
|
+
"""
|
|
15
|
+
phone_num = str(phone_num)
|
|
16
|
+
if not phone_num.isdigit():
|
|
17
|
+
return phone_num
|
|
18
|
+
|
|
19
|
+
if len(phone_num) != 11:
|
|
20
|
+
return phone_num
|
|
21
|
+
|
|
22
|
+
return f"{phone_num[0]}-{phone_num[1:4]}-{phone_num[4:7]}-{phone_num[7:]}"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def jinja_filter_month_year_fmt(_datetime: datetime.datetime):
|
|
26
|
+
"""Format a datetime as its full month name and year.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
_datetime: The datetime to format.
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
The formatted string, e.g. "January 2024".
|
|
33
|
+
"""
|
|
34
|
+
return _datetime.strftime("%B %Y")
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
def jinja_filter_get_list_item_by_attr(list_in: list, attr: str, attr_value: str):
|
|
2
|
+
"""Return the first item in a list whose attribute matches a value.
|
|
3
|
+
|
|
4
|
+
Similar to Jinja2's built-in `selectattr` filter, but returns only the
|
|
5
|
+
first match instead of an iterable of all matches.
|
|
6
|
+
|
|
7
|
+
Args:
|
|
8
|
+
list_in: The list of objects to search.
|
|
9
|
+
attr: The attribute name to compare.
|
|
10
|
+
attr_value: The value `attr` must equal.
|
|
11
|
+
|
|
12
|
+
Returns:
|
|
13
|
+
The first matching item, or `""` if none match.
|
|
14
|
+
"""
|
|
15
|
+
for item in list_in:
|
|
16
|
+
if getattr(item, attr) == attr_value:
|
|
17
|
+
return item
|
|
18
|
+
return ""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def jinja_filter_with_item_attr_first(list_in: list, attr: str, attr_values: str | list[str]):
|
|
22
|
+
"""Reorder a list so items matching given attribute value(s) come first.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
list_in: The list of objects to reorder.
|
|
26
|
+
attr: The attribute name to compare.
|
|
27
|
+
attr_values: One or more values of `attr` to move to the front, in
|
|
28
|
+
priority order. If neither a `str` nor a `list`, `list_in` is
|
|
29
|
+
returned unchanged.
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
A new list with matching items first (in `attr_values` priority
|
|
33
|
+
order), followed by the remaining items in their original order.
|
|
34
|
+
"""
|
|
35
|
+
if not isinstance(attr_values, str) and not isinstance(attr_values, list):
|
|
36
|
+
return list_in
|
|
37
|
+
|
|
38
|
+
if isinstance(attr_values, str):
|
|
39
|
+
attr_values = [attr_values]
|
|
40
|
+
|
|
41
|
+
result_list = []
|
|
42
|
+
for attr_value in attr_values:
|
|
43
|
+
result_list += [item for item in list_in if getattr(item, attr) == attr_value]
|
|
44
|
+
|
|
45
|
+
return result_list + [item for item in list_in if item not in result_list]
|