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.
@@ -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,5 @@
1
+ from .metadata import PDFMetadata
2
+ from .options import PDFOptions
3
+ from .rendering import render_pdf
4
+
5
+ __all__ = ["PDFOptions", "PDFMetadata", "render_pdf"]
@@ -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,4 @@
1
+ from .environment import TypedVariableEnvironment
2
+ from .rendering import new_template_environment
3
+
4
+ __all__ = ["new_template_environment", "TypedVariableEnvironment"]
@@ -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]