templify-cli 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.
templify/writers.py ADDED
@@ -0,0 +1,312 @@
1
+ """Template writers: render a template with data and save it, picked by file extension."""
2
+
3
+ import json
4
+ import re
5
+ from collections.abc import Callable, Iterator
6
+ from pathlib import Path
7
+ from typing import Any, Protocol
8
+ from uuid import uuid4
9
+
10
+ from docxtpl import DocxTemplate
11
+ from jinja2 import Environment, FileSystemLoader
12
+
13
+ from .models import Data, DataItem
14
+
15
+ type Context = dict[str, Any]
16
+ """The variables a template is rendered with."""
17
+
18
+ ADDITIONAL_CONTEXT = {
19
+ "new_line": "\n",
20
+ "tab": "\t",
21
+ "page_break": "\f",
22
+ }
23
+ """Variables available in every template, they win over data columns with the same name."""
24
+
25
+ # -----------------------
26
+ # writer protocol
27
+ # -----------------------
28
+
29
+
30
+ class Writer(Protocol):
31
+ """Renders a template with a context and saves the result to a file.
32
+
33
+ A writer is built from its template path (see `WriterFactory`),
34
+ so any class with this `write` method and an `__init__(template_path)` is a writer.
35
+ """
36
+
37
+ def write(self, context: Context, filepath: Path) -> None:
38
+ """Render the template with `context` and save the result to `filepath`."""
39
+ ...
40
+
41
+
42
+ type WriterFactory = Callable[[Path], Writer]
43
+ """Builds a writer from a template path, usually the writer class itself."""
44
+
45
+
46
+ class InvalidOutputError(Exception):
47
+ """Raised when a rendered template is not valid for its format, like broken JSON."""
48
+
49
+
50
+ # -----------------------
51
+ # registry
52
+ # -----------------------
53
+
54
+
55
+ class TemplateExtensionNotSupportedError(Exception):
56
+ """Raised when no writer is registered for a template file extension."""
57
+
58
+ def __init__(self, extension: str) -> None:
59
+ message = f"Extension '{extension}' is not supported for being a template file"
60
+ super().__init__(message)
61
+
62
+
63
+ class WriterRegistry:
64
+ """Maps template file extensions (case-insensitive, without the dot) to writers.
65
+
66
+ Example:
67
+ ```python
68
+ writers = WriterRegistry()
69
+
70
+
71
+ @writers.writer("txt", "md")
72
+ class TextWriter: ...
73
+
74
+
75
+ writer = writers.create(Path("letter.md")) # a TextWriter
76
+ ```
77
+
78
+ """
79
+
80
+ def __init__(self) -> None:
81
+ self._factories: dict[str, WriterFactory] = {}
82
+
83
+ def __contains__(self, extension: str) -> bool:
84
+ """Whether a writer is registered for `extension`."""
85
+ return extension.lower() in self._factories
86
+
87
+ def __getitem__(self, extension: str) -> WriterFactory:
88
+ """Return the writer factory registered for `extension`.
89
+
90
+ Raises:
91
+ TemplateExtensionNotSupportedError: No writer is registered for `extension`.
92
+
93
+ """
94
+ if extension not in self:
95
+ raise TemplateExtensionNotSupportedError(extension)
96
+ return self._factories[extension.lower()]
97
+
98
+ @property
99
+ def extensions(self) -> list[str]:
100
+ """The supported extensions, sorted."""
101
+ return sorted(self._factories)
102
+
103
+ def writer[T: WriterFactory](self, *extensions: str) -> Callable[[T], T]:
104
+ """Register the decorated class (or factory) as the writer for `extensions`."""
105
+
106
+ def decorator(factory: T) -> T:
107
+ for extension in extensions:
108
+ self.add_writer(factory, extension)
109
+ return factory
110
+
111
+ return decorator
112
+
113
+ def add_writer(self, factory: WriterFactory, extension: str) -> None:
114
+ """Register `factory` as the writer for `extension`, replacing any existing one."""
115
+ self._factories[extension.lower()] = factory
116
+
117
+ def create(self, template_path: Path) -> Writer:
118
+ """Build the writer matching the extension of `template_path`.
119
+
120
+ Raises:
121
+ TemplateExtensionNotSupportedError: No writer is registered for the extension.
122
+
123
+ """
124
+ return self[template_path.suffix.lstrip(".")](template_path)
125
+
126
+
127
+ writers = WriterRegistry()
128
+ """The default registry, used by the CLI `--template` option."""
129
+
130
+ # -----------------------
131
+ # concrete writers
132
+ # -----------------------
133
+
134
+
135
+ @writers.writer("docx")
136
+ class DocxWriter:
137
+ """Writes Word documents using docxtpl (Jinja2 tags inside a `.docx` file)."""
138
+
139
+ def __init__(self, template_path: Path) -> None:
140
+ """Load the template.
141
+
142
+ Raises:
143
+ docx.opc.exceptions.PackageNotFoundError: The file is not a valid `.docx`.
144
+
145
+ """
146
+ self.template = DocxTemplate(template_path)
147
+ self.template.init_docx() # fail fast on a corrupted file, docxtpl loads lazily
148
+
149
+ def write(self, context: Context, filepath: Path) -> None:
150
+ """Render the template with `context` and save the result to `filepath`.
151
+
152
+ Raises:
153
+ jinja2.TemplateError: The template has a syntax error, or failed to render.
154
+
155
+ """
156
+ self.template.render(context)
157
+ self.template.save(filepath)
158
+
159
+
160
+ @writers.writer("txt", "md", "html", "htm")
161
+ class TextWriter:
162
+ """Writes plain text files using Jinja2, saved as UTF-8.
163
+
164
+ Templates can `{% include %}` other files from the template's folder.
165
+ Output is not HTML-escaped.
166
+ """
167
+
168
+ def __init__(self, template_path: Path) -> None:
169
+ """Load the template.
170
+
171
+ Raises:
172
+ jinja2.TemplateSyntaxError: The template has a syntax error.
173
+
174
+ """
175
+ env = Environment( # noqa: S701 - output is not only html, escaping would corrupt txt/json
176
+ loader=FileSystemLoader(template_path.parent),
177
+ keep_trailing_newline=True,
178
+ )
179
+ self.template = env.get_template(template_path.name)
180
+
181
+ def render(self, context: Context) -> str:
182
+ """Render the template with `context` and return the text."""
183
+ return self.template.render(context)
184
+
185
+ def write(self, context: Context, filepath: Path) -> None:
186
+ """Render the template with `context` and save the result to `filepath`."""
187
+ filepath.write_text(self.render(context), encoding="utf-8", newline="")
188
+
189
+
190
+ @writers.writer("json")
191
+ class JsonWriter(TextWriter):
192
+ """Same as `TextWriter`, but refuses to save output that is not valid JSON."""
193
+
194
+ def render(self, context: Context) -> str:
195
+ """Render the template with `context` and return the text.
196
+
197
+ Raises:
198
+ InvalidOutputError: The rendered text is not valid JSON.
199
+
200
+ """
201
+ content = super().render(context)
202
+
203
+ try:
204
+ json.loads(content)
205
+ except json.JSONDecodeError as e:
206
+ message = f"Rendered output is not valid JSON: {e}"
207
+ raise InvalidOutputError(message) from e
208
+
209
+ return content
210
+
211
+
212
+ # -----------------------
213
+ # write modes
214
+ # -----------------------
215
+
216
+
217
+ def write_single( # noqa: PLR0913
218
+ writer: Writer,
219
+ data: Data,
220
+ *,
221
+ output_dir: Path,
222
+ filename: str,
223
+ extension: str,
224
+ data_variable: str = "data",
225
+ ) -> Path:
226
+ """Render all the data into one file, the template loops over `data_variable`.
227
+
228
+ Args:
229
+ writer: Renders and saves the file.
230
+ data: The records, available in the template as `data_variable`.
231
+ output_dir: Where to save the file, created if missing.
232
+ filename: The file name without extension.
233
+ extension: The file extension, without the dot.
234
+ data_variable: The template variable holding `data`.
235
+
236
+ Returns:
237
+ The saved file path. A random suffix is added if the file already exists.
238
+
239
+ """
240
+ filepath = _generate_filepath(filename, output_dir, extension)
241
+ writer.write({data_variable: data, **ADDITIONAL_CONTEXT}, filepath)
242
+ return filepath
243
+
244
+
245
+ def write_multiple( # noqa: PLR0913
246
+ writer: Writer,
247
+ data: Data,
248
+ *,
249
+ output_dir: Path,
250
+ extension: str,
251
+ filename_key: str | None = None,
252
+ include_index: bool = False,
253
+ ) -> Iterator[Path]:
254
+ """Render one file per record, yielding each saved file path.
255
+
256
+ Files are written lazily, one per iteration, so callers can report progress.
257
+
258
+ Args:
259
+ writer: Renders and saves each file.
260
+ data: The records, each record's columns are the template variables.
261
+ output_dir: Where to save the files, created if missing.
262
+ extension: The files extension, without the dot.
263
+ filename_key: The column naming each file, `template-<n>` when missing or empty.
264
+ include_index: Prefix each filename with the record number: `1 - Rama`.
265
+
266
+ Yields:
267
+ Each saved file path. A random suffix is added if the file already exists.
268
+
269
+ """
270
+ for idx, item in enumerate(data, start=1):
271
+ filename = _get_filename(item, idx, filename_key, include_index=include_index)
272
+ filepath = _generate_filepath(filename, output_dir, extension)
273
+ writer.write({**item, **ADDITIONAL_CONTEXT}, filepath)
274
+ yield filepath
275
+
276
+
277
+ # -----------------------
278
+ # utils
279
+ # -----------------------
280
+
281
+ _INVALID_FILENAME_CHARS = re.compile(r'[<>:"/\\|?*\x00-\x1f]')
282
+
283
+
284
+ def _sanitize_filename(filename: str) -> str:
285
+ return _INVALID_FILENAME_CHARS.sub("_", filename).strip(" .")
286
+
287
+
288
+ def _get_filename(
289
+ item: DataItem,
290
+ idx: int,
291
+ filename_key: str | None = None,
292
+ *,
293
+ include_index: bool = False,
294
+ ) -> str:
295
+ value = item.get(filename_key) if filename_key else None
296
+ filename = _sanitize_filename(str(value)) if value is not None else ""
297
+
298
+ if not filename:
299
+ return f"template-{idx}"
300
+
301
+ return f"{idx} - {filename}" if include_index else filename
302
+
303
+
304
+ def _generate_filepath(filename: str, output_dir: Path, extension: str) -> Path:
305
+ output_dir.mkdir(parents=True, exist_ok=True)
306
+
307
+ filepath = output_dir / f"{filename}.{extension}"
308
+
309
+ while filepath.exists():
310
+ filepath = output_dir / f"{filename}-{uuid4().hex[:8]}.{extension}"
311
+
312
+ return filepath