templify-cli 0.1.0__tar.gz → 0.3.0__tar.gz

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.
Files changed (24) hide show
  1. {templify_cli-0.1.0 → templify_cli-0.3.0}/PKG-INFO +996 -945
  2. {templify_cli-0.1.0 → templify_cli-0.3.0}/README.md +993 -943
  3. {templify_cli-0.1.0 → templify_cli-0.3.0}/pyproject.toml +114 -3
  4. templify_cli-0.3.0/pyproject.toml.orig +115 -0
  5. templify_cli-0.3.0/src/templify/cli/__main__.py +4 -0
  6. templify_cli-0.3.0/src/templify/cli/cli.py +172 -0
  7. templify_cli-0.3.0/src/templify/cli/dependencies/data.py +304 -0
  8. {templify_cli-0.1.0 → templify_cli-0.3.0}/src/templify/cli/dependencies/template.py +87 -96
  9. templify_cli-0.3.0/src/templify/cli/help.py +32 -0
  10. {templify_cli-0.1.0 → templify_cli-0.3.0}/src/templify/filter_rules.py +382 -366
  11. {templify_cli-0.1.0 → templify_cli-0.3.0}/src/templify/readers.py +47 -23
  12. {templify_cli-0.1.0 → templify_cli-0.3.0}/src/templify/writers.py +314 -312
  13. templify_cli-0.1.0/pyproject.toml.orig +0 -80
  14. templify_cli-0.1.0/src/templify/cli/dependencies/data.py +0 -196
  15. templify_cli-0.1.0/src/templify/cli/help.py +0 -61
  16. templify_cli-0.1.0/src/templify/cli/main.py +0 -165
  17. {templify_cli-0.1.0 → templify_cli-0.3.0}/LICENSE +0 -0
  18. {templify_cli-0.1.0 → templify_cli-0.3.0}/src/templify/__init__.py +0 -0
  19. {templify_cli-0.1.0 → templify_cli-0.3.0}/src/templify/cli/__init__.py +0 -0
  20. {templify_cli-0.1.0 → templify_cli-0.3.0}/src/templify/cli/dependencies/__init__.py +0 -0
  21. {templify_cli-0.1.0 → templify_cli-0.3.0}/src/templify/cli/dependencies/console.py +0 -0
  22. {templify_cli-0.1.0 → templify_cli-0.3.0}/src/templify/models.py +0 -0
  23. {templify_cli-0.1.0 → templify_cli-0.3.0}/src/templify/py.typed +0 -0
  24. {templify_cli-0.1.0 → templify_cli-0.3.0}/src/templify/transformers.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: templify-cli
3
- Version: 0.1.0
3
+ Version: 0.3.0
4
4
  Summary: Fill Word, text, Markdown, HTML and JSON templates with Excel or JSON data, from the command line.
5
5
  Keywords: template,templating,jinja2,docx,word,excel,mail-merge,document-generation,cli
6
6
  Author: aaltatan
@@ -23,8 +23,9 @@ Classifier: Typing :: Typed
23
23
  Requires-Dist: docxtpl>=0.20.2
24
24
  Requires-Dist: jinja2>=3.1
25
25
  Requires-Dist: openpyxl>=3.1.5
26
- Requires-Dist: pyspecification>=3.0.0
26
+ Requires-Dist: pyspecification>=4.0.0
27
27
  Requires-Dist: rich>=13.0
28
+ Requires-Dist: schemify-cli>=0.1.0
28
29
  Requires-Dist: typer>=0.27.2
29
30
  Requires-Dist: typer-di>=0.1.5
30
31
  Requires-Dist: typer-swissknife>=0.1.0
@@ -35,946 +36,996 @@ Project-URL: Repository, https://github.com/aaltatan/templify
35
36
  Project-URL: Issues, https://github.com/aaltatan/templify/issues
36
37
  Description-Content-Type: text/markdown
37
38
 
38
- # templify
39
-
40
- A command line tool that fills document templates with data.
41
-
42
- Point it at a **template** (Word, text, Markdown, HTML or JSON) and a **data file** (Excel or JSON). templify renders one file per row, or one file with every row in it. Before rendering, you can **order**, **filter** and **group** the data.
43
-
44
- ```bash
45
- templify m -t offer.docx -d employees.xlsx -o offers -k name
46
- ```
47
-
48
- ```text
49
- offers/
50
- ├── Abdullah.docx
51
- ├── Rawaa.docx
52
- ├── Rama.docx
53
- └── Omar.docx
54
- ```
55
-
56
- ---
57
-
58
- ## Table of contents
59
-
60
- - [Features](#features)
61
- - [Installation](#installation)
62
- - [Quick start](#quick-start)
63
- - [How it works](#how-it-works)
64
- - [Commands](#commands)
65
- - [`multiple-templates` (`m`)](#multiple-templates-m)
66
- - [`single-template` (`s`)](#single-template-s)
67
- - [`filters-json-schema` (`fjs`)](#filters-json-schema-fjs)
68
- - [Data sources](#data-sources)
69
- - [Templates](#templates)
70
- - [Supported formats](#supported-formats)
71
- - [Jinja2 in 60 seconds](#jinja2-in-60-seconds)
72
- - [Built-in variables](#built-in-variables)
73
- - [Word (`.docx`) templates](#word-docx-templates)
74
- - [JSON templates](#json-templates)
75
- - [HTML templates](#html-templates)
76
- - [Output files](#output-files)
77
- - [Ordering](#ordering)
78
- - [Filtering](#filtering)
79
- - [Filter schema](#filter-schema)
80
- - [Filter rules reference](#filter-rules-reference)
81
- - [Grouping](#grouping)
82
- - [Cookbook](#cookbook)
83
- - [Errors](#errors)
84
- - [Extending templify](#extending-templify)
85
- - [Development](#development)
86
-
87
- ---
88
-
89
- ## Features
90
-
91
- - **Many template formats**: `.docx`, `.txt`, `.md`, `.html`, `.htm` and `.json`. The writer is picked automatically from the template's extension.
92
- - **Many data sources**: Excel (`.xlsx`) and JSON (`.json`).
93
- - **Two output modes**: one file per row (`multiple-templates`), or every row in one file (`single-template`).
94
- - **Ordering** by any number of columns, ascending or descending.
95
- - **Filtering** with a JSON filter language: 50+ rules for strings, numbers, decimals, dates, datetimes and UUIDs. Rules combine with `all` / `any`, nest, and can be inverted.
96
- - **Grouping** by one or more columns, e.g. one file per department listing its employees.
97
- - **Safe output**: existing files are never overwritten, invalid filename characters are replaced, and missing folders are created.
98
- - **Validated JSON output**: a JSON template that renders invalid JSON is rejected before anything is saved.
99
- - **Clear errors** for bad templates, bad data, unknown columns and bad filters, instead of Python tracebacks.
100
- - **Extensible**: add a new data reader, template writer or filter rule with one decorator.
101
-
102
- ## Installation
103
-
104
- Requires **Python 3.12+**.
105
-
106
- Install it as a global command from PyPI with [uv](https://docs.astral.sh/uv/):
107
-
108
- ```bash
109
- uv tool install templify-cli
110
- ```
111
-
112
- or with [pipx](https://pipx.pypa.io/):
113
-
114
- ```bash
115
- pipx install templify-cli
116
- ```
117
-
118
- Or run it from a clone without installing:
119
-
120
- ```bash
121
- uv sync
122
- ```
123
-
124
- ```bash
125
- uv run templify --help
126
- ```
127
-
128
- ## Quick start
129
-
130
- **1. Prepare your data**, e.g. `employees.xlsx`. The first row holds the column names:
131
-
132
- | name | department | city | salary | hired | email |
133
- | -------- | ---------- | -------- | -----: | ---------- | -------------------- |
134
- | Abdullah | Sales | Homs | 1200 | 2021-04-01 | abdullah@example.com |
135
- | Rawaa | IT | Damascus | 1800 | 2023-09-15 | rawaa@example.com |
136
- | Rama | IT | Homs | 1500 | 2024-02-10 | rama@example.com |
137
- | Omar | Sales | Aleppo | 1100 | 2024-11-03 | |
138
-
139
- **2. Write a template** that uses the column names as variables, e.g. `welcome.txt`:
140
-
141
- ```jinja
142
- Hello {{ name }},
143
- You joined {{ department }} on {{ hired.strftime("%d/%m/%Y") }}.
144
- ```
145
-
146
- **3. Run templify:**
147
-
148
- ```bash
149
- templify m -t welcome.txt -d employees.xlsx -o welcome -k name
150
- ```
151
-
152
- `welcome/Rama.txt`:
153
-
154
- ```text
155
- Hello Rama,
156
- You joined IT on 10/02/2024.
157
- ```
158
-
159
- > The same data file is used in every example below.
160
-
161
- ## How it works
162
-
163
- Every command runs the same pipeline:
164
-
165
- ```text
166
- read data ──► order ──► filter ──► group ──► render template ──► save file(s)
167
- (-d) (--order) (--filter) (--group-by) (-t) (-o)
168
- ```
169
-
170
- 1. **Read**: the data file becomes a list of rows, each row a set of `column: value` pairs.
171
- 2. **Order**: rows are sorted by `--order` columns (optional).
172
- 3. **Filter**: rows not matching `--filter` are dropped (optional).
173
- 4. **Group**: rows are grouped by `--group-by` columns (optional).
174
- 5. **Render**: the writer chosen from the template extension renders the rows.
175
- 6. **Save**:
176
- - `multiple-templates` saves one file per row, or per group when grouping.
177
- - `single-template` saves one file containing every row.
178
-
179
- ## Commands
180
-
181
- ```bash
182
- templify --help
183
- ```
184
-
185
- | Command | Aliases | What it does |
186
- | --------------------- | --------------------- | -------------------------------------------- |
187
- | `multiple-templates` | `multiple`, `m` | Render **one file per row** (or per group) |
188
- | `single-template` | `single`, `s` | Render **one file** with all the rows |
189
- | `filters-json-schema` | `filters-schema`, `fjs` | List the filter rules and their arguments |
190
-
191
- ### `multiple-templates` (`m`)
192
-
193
- Renders the template once **per row**. Each row's columns are the template variables: `{{ name }}`, `{{ salary }}`, ...
194
-
195
- ```bash
196
- templify m -t <template> -d <data> -o <output-dir> [OPTIONS]
197
- ```
198
-
199
- | Option | Short | Description | Default |
200
- | ------------------ | ----- | -------------------------------------------------------------------- | ---------------- |
201
- | `--template` | `-t` | Template file. **Required.** | |
202
- | `--data` | `-d` | Data file. **Required.** | |
203
- | `--output` | `-o` | Output directory, created if missing. **Required.** | |
204
- | `--filename-key` | `-k` | Column whose value names each file | `template-<n>` |
205
- | `--index` | `-i` | Prefix each filename with its row number: `1 - Rama.docx` | off |
206
- | `--order` | | Column to order by, `-column` for descending. Repeatable | |
207
- | `--filter` | | JSON [filter schema](#filter-schema) | |
208
- | `--group-by` | | Column to [group](#grouping) by. Repeatable | |
209
- | `--group-items` | | Variable name holding each group's rows | `items` |
210
-
211
- Example:
212
-
213
- ```bash
214
- templify m -t offer.docx -d employees.xlsx -o offers -k name --index
215
- ```
216
-
217
- ```text
218
- offers/
219
- ├── 1 - Abdullah.docx
220
- ├── 2 - Rawaa.docx
221
- ├── 3 - Rama.docx
222
- └── 4 - Omar.docx
223
- ```
224
-
225
- ### `single-template` (`s`)
226
-
227
- Renders the template **once**, with every row in a list variable (`data` by default). The template loops over it.
228
-
229
- ```bash
230
- templify s -t <template> -d <data> -o <output-dir> [OPTIONS]
231
- ```
232
-
233
- | Option | Short | Description | Default |
234
- | ----------------- | ----- | ---------------------------------------------------------- | ---------------------- |
235
- | `--template` | `-t` | Template file. **Required.** | |
236
- | `--data` | `-d` | Data file. **Required.** | |
237
- | `--output` | `-o` | Output directory, created if missing. **Required.** | |
238
- | `--filename` | `-f` | Output filename **without** extension | the template's name |
239
- | `--data-variable` | `-v` | Name of the list variable inside the template | `data` |
240
- | `--order` | | Column to order by, `-column` for descending. Repeatable | |
241
- | `--filter` | | JSON [filter schema](#filter-schema) | |
242
- | `--group-by` | | Column to [group](#grouping) by. Repeatable | |
243
- | `--group-items` | | Variable name holding each group's rows | `items` |
244
-
245
- Example with `team.md`:
246
-
247
- ```jinja
248
- # Team report
249
-
250
- | # | Name | Department | Salary |
251
- |---|------|------------|-------:|
252
- {% for e in data -%}
253
- | {{ loop.index }} | {{ e.name }} | {{ e.department }} | {{ "{:,}".format(e.salary) }} |
254
- {% endfor %}
255
- **Total salaries:** {{ data | sum(attribute="salary") }}
256
- ```
257
-
258
- ```bash
259
- templify s -t team.md -d employees.xlsx -o reports --order -salary
260
- ```
261
-
262
- `reports/team.md`:
263
-
264
- ```markdown
265
- # Team report
266
-
267
- | # | Name | Department | Salary |
268
- |---|------|------------|-------:|
269
- | 1 | Rawaa | IT | 1,800 |
270
- | 2 | Rama | IT | 1,500 |
271
- | 3 | Abdullah | Sales | 1,200 |
272
- | 4 | Omar | Sales | 1,100 |
273
-
274
- **Total salaries:** 5600
275
- ```
276
-
277
- Rename the output and the loop variable:
278
-
279
- ```bash
280
- templify s -t team.md -d employees.xlsx -o reports -f "2024 team" -v employees
281
- ```
282
-
283
- The template then loops with `{% for e in employees %}`, and the file is saved as `reports/2024 team.md`.
284
-
285
- ### `filters-json-schema` (`fjs`)
286
-
287
- Lists every filter rule you can use in `--filter`, with its arguments (in `args` order) and description:
288
-
289
- ```bash
290
- templify fjs
291
- ```
292
-
293
- ```text
294
- Rule Arguments Description
295
- is_true key: string Keep records where `key` is `true`.
296
- is_null key: string Keep records where `key` is empty (`null`).
297
- int__eq key: string, value: integer Keep records where `key` equals `value`.
298
- ...
299
- string__regex key: string, pattern: string Keep records where `key` matches the regular expression `pattern`.
300
- string__is_in key: string, value: list[string] Keep records where `key` is one of the `value` list.
301
- ...
302
- date__ge key: string, value: date Keep records where `key` is greater than or equal to `value`, comparing the day only.
303
- ...
304
- ```
305
-
306
- The argument names are also the `kwargs` names: `{"key": "salary", "value": 1400}`.
307
-
308
- ## Data sources
309
-
310
- The reader is chosen from the data file extension, ignoring case.
311
-
312
- | Extension | Format |
313
- | --------- | --------------------------------------------------------------------------- |
314
- | `.xlsx` | Excel workbook: the **active sheet**, first row = column names |
315
- | `.json` | A JSON **list of objects**: `[{"name": "Rama", ...}, ...]` |
316
-
317
- **Excel notes**
318
-
319
- - The first row holds the column names, and each following row is one record.
320
- - Fully blank rows are skipped.
321
- - Formula cells give their **last calculated value**, not the formula text. A file created by a script and never opened in Excel has no calculated values yet, so those cells come back empty.
322
- - Values keep their type: numbers stay numbers, dates become Python `datetime`s (so `{{ hired.strftime("%Y") }}` works), and empty cells become `None`.
323
-
324
- **JSON notes**
325
-
326
- - Must be a list of objects. An object, a list of numbers and so on are rejected with a clear error.
327
- - Read as UTF-8, so Arabic and other non-Latin text works.
328
- - JSON has no date type, so dates are strings (`"2024-02-10"`). The [`date__*` filter rules](#dates) understand both forms.
329
-
330
- `employees.json`:
331
-
332
- ```json
333
- [
334
- {
335
- "name": "Abdullah",
336
- "department": "Sales",
337
- "city": "Homs",
338
- "salary": 1200,
339
- "hired": "2021-04-01",
340
- "email": "abdullah@example.com"
341
- }
342
- ]
343
- ```
344
-
345
- ## Templates
346
-
347
- ### Supported formats
348
-
349
- The writer is chosen from the **template extension**, ignoring case. The output file gets the same extension.
350
-
351
- | Extension | Writer | Engine |
352
- | ----------------------------- | ------------ | ------------------------------------------------ |
353
- | `.docx` | `DocxWriter` | [docxtpl](https://docxtpl.readthedocs.io/) (Jinja2 inside Word) |
354
- | `.txt`, `.md`, `.html`, `.htm` | `TextWriter` | [Jinja2](https://jinja.palletsprojects.com/) |
355
- | `.json` | `JsonWriter` | Jinja2 + validates that the output is JSON |
356
-
357
- Any other extension is rejected, and the error lists the supported ones.
358
-
359
- ### Jinja2 in 60 seconds
360
-
361
- All templates use [Jinja2](https://jinja.palletsprojects.com/en/stable/templates/) syntax:
362
-
363
- | Syntax | Meaning |
364
- | ------------------------------------------ | ---------------------------------------- |
365
- | `{{ name }}` | Print a variable |
366
- | `{{ e.name }}` or `{{ e["name"] }}` | Print a field of a row |
367
- | `{% for e in data %} ... {% endfor %}` | Loop |
368
- | `{{ loop.index }}` | 1-based loop counter |
369
- | `{% if salary > 1400 %} ... {% endif %}` | Condition |
370
- | `{{ name \| upper }}` | Filter: `upper`, `lower`, `title`, `length`, `sum`, `default`, `tojson`, ... |
371
- | `{{ email or "no email" }}` | Fallback for empty values |
372
- | `{{ hired.strftime("%d/%m/%Y") }}` | Format an Excel date |
373
- | `{{ "{:,.2f}".format(salary) }}` | Format a number: `1,200.00` |
374
- | `{# comment #}` | Comment, not rendered |
375
- | `{%- ... -%}` | Trim the whitespace around a tag |
376
- | `{% include "header.txt" %}` | Include another file from the template's folder (text formats) |
377
-
378
- A variable that doesn't exist renders as an empty string instead of failing.
379
-
380
- ### Built-in variables
381
-
382
- These are always available, in both modes:
383
-
384
- | Variable | Value | In `.docx` it becomes |
385
- | ---------------- | ------ | --------------------- |
386
- | `{{ new_line }}` | `\n` | a line break |
387
- | `{{ tab }}` | `\t` | a tab |
388
- | `{{ page_break }}` | `\f` | a page break |
389
-
390
- > If your data has a column with one of these names, the built-in value wins.
391
-
392
- ### Word (`.docx`) templates
393
-
394
- Write Jinja2 tags directly in the Word document. Keep each tag in the **same formatting** (don't bold half of `{{ name }}`), otherwise Word splits it into pieces and Jinja can't read it.
395
-
396
- `offer.docx`:
397
-
398
- ```text
399
- Dear {{ name }},
400
- Welcome to the {{ department }} team in {{ city }}. Your salary is {{ salary }}$.
401
- ```
402
-
403
- docxtpl adds special tags for Word structure:
404
-
405
- | Tag | Use |
406
- | ----------------------------------- | ------------------------------------------------------- |
407
- | `{%p for e in data %}` ... `{%p endfor %}` | Loop over **paragraphs**: each tag sits on its own paragraph |
408
- | `{%tr for e in data %}` ... `{%tr endfor %}` | Loop over **table rows**: one row per record |
409
- | `{%tc ... %}` | Loop over table cells |
410
- | `{%r ... %}` | Tag applied to a run |
411
-
412
- **One Word file, one page per employee** (`all-offers.docx`, three paragraphs):
413
-
414
- ```text
415
- {%p for e in data %}
416
- {{ e.name }} - {{ e.department }}{{ page_break }}
417
- {%p endfor %}
418
- ```
419
-
420
- ```bash
421
- templify s -t all-offers.docx -d employees.xlsx -o offers
422
- ```
423
-
424
- **A Word table with one row per employee**: create a 2-row table. Put `{%tr for e in data %}` and `{%tr endfor %}` in rows of their own, around a row containing `{{ e.name }}`, `{{ e.salary }}`, ...
425
-
426
- See the [docxtpl documentation](https://docxtpl.readthedocs.io/) for images, rich text and more.
427
-
428
- ### JSON templates
429
-
430
- A `.json` template is rendered like text, then **validated**. If the result isn't valid JSON, nothing is saved and you get an error that points at the problem.
431
-
432
- Always write values through the `tojson` filter. It adds quotes and escapes characters like `"` for you, and turns `None` into `null` and `True` into `true`.
433
-
434
- `profile.json`:
435
-
436
- ```jinja
437
- {
438
- "name": {{ name | tojson }},
439
- "department": {{ department | tojson }},
440
- "email": {{ email | tojson }},
441
- "senior": {{ (salary > 1400) | tojson }}
442
- }
443
- ```
444
-
445
- ```bash
446
- templify m -t profile.json -d employees.json -o profiles -k name
447
- ```
448
-
449
- `profiles/Omar.json`:
450
-
451
- ```json
452
- {
453
- "name": "Omar",
454
- "department": "Sales",
455
- "email": null,
456
- "senior": false
457
- }
458
- ```
459
-
460
- Dump the whole (ordered / filtered / grouped) dataset:
461
-
462
- ```jinja
463
- {{ data | tojson(indent=2) }}
464
- ```
465
-
466
- ```bash
467
- templify s -t all.json -d employees.xlsx -o export -f employees --order name
468
- ```
469
-
470
- > `tojson` sorts object keys alphabetically.
471
-
472
- **Why use `tojson`?** With `"name": "{{ name }}"`, a name like `Ra"ma` produces broken JSON:
473
-
474
- ```text
475
- Invalid value: Template Error: Rendered output is not valid JSON: Expecting ',' delimiter: line 1 column 14 (char 13)
476
- ```
477
-
478
- ### HTML templates
479
-
480
- `department.html`:
481
-
482
- ```jinja
483
- <!DOCTYPE html>
484
- <html>
485
- <body>
486
- <h1>{{ department }} department</h1>
487
- <ul>
488
- {%- for e in items %}
489
- <li>{{ e.name }} ({{ e.city }})</li>
490
- {%- endfor %}
491
- </ul>
492
- <p>{{ items | length }} employees</p>
493
- </body>
494
- </html>
495
- ```
496
-
497
- ```bash
498
- templify m -t department.html -d employees.xlsx -o departments --group-by department -k department
499
- ```
500
-
501
- > Values are **not** HTML-escaped, so `<b>` in your data stays bold. If the data isn't trusted, escape it with `{{ value | e }}`.
502
-
503
- ## Output files
504
-
505
- | Situation | Result |
506
- | ------------------------------------------- | --------------------------------------------------- |
507
- | No `-k` | `template-1.txt`, `template-2.txt`, ... |
508
- | `-k name` | `Rama.txt` |
509
- | `-k name -i` | `3 - Rama.txt` |
510
- | The `-k` column is empty or missing for a row | That row falls back to `template-<n>.txt` |
511
- | The value has invalid characters: `a/b:c?` | They become `_`: `a_b_c_.txt` |
512
- | The value is a number: `-k id` | `42.txt` |
513
- | The file already exists | A short random suffix is added: `Rama-3f9a1c2e.txt`. **Nothing is ever overwritten** |
514
- | The output folder doesn't exist | It is created, including parent folders |
515
- | `single-template` without `-f` | Named after the template: `team.md` → `team.md` |
516
-
517
- Output files are written as **UTF-8**, with the template's line endings kept as they are.
518
-
519
- ## Ordering
520
-
521
- `--order <column>` sorts ascending, and `--order -<column>` sorts descending. Repeat it to sort by several columns. The first one has the highest priority.
522
-
523
- ```bash
524
- # highest salary first
525
- templify s -t names.txt -d employees.xlsx -o out --order -salary
526
-
527
- # by department A→Z, then by salary high→low inside each department
528
- templify s -t names.txt -d employees.xlsx -o out --order department --order -salary
529
- ```
530
-
531
- ```text
532
- Rawaa Rama Abdullah Omar
533
- ```
534
-
535
- Ordering by a column that doesn't exist fails with the list of available columns.
536
-
537
- ## Filtering
538
-
539
- `--filter` takes a JSON string that describes which rows to **keep**.
540
-
541
- > **Quoting on the command line**
542
- >
543
- > - **bash / zsh / PowerShell 7.3+**: wrap the JSON in single quotes: `--filter '{"name": ...}'`
544
- > - **cmd.exe**: escape the inner quotes: `--filter "{\"name\": ...}"`
545
-
546
- ### Filter schema
547
-
548
- **A single rule** (a *predicate*) has exactly four keys:
549
-
550
- ```json
551
- {
552
- "name": "string__eq",
553
- "args": ["department", "IT"],
554
- "kwargs": {},
555
- "inverse": false
556
- }
557
- ```
558
-
559
- | Key | Meaning |
560
- | --------- | ----------------------------------------------------------------------------------- |
561
- | `name` | Rule name, see the [rules reference](#filter-rules-reference) |
562
- | `args` | Positional arguments: **the column first**, then the value(s) |
563
- | `kwargs` | The same arguments by name, e.g. `{"key": "salary", "value": 1400}`. Use `{}` otherwise |
564
- | `inverse` | `true` negates the rule: "NOT equal", "does NOT contain", ... |
565
-
566
- ```bash
567
- # IT only
568
- templify s -t names.txt -d employees.xlsx -o out \
569
- --filter '{"name": "string__eq", "args": ["department", "IT"], "kwargs": {}, "inverse": false}'
570
- # → Rawaa Rama
571
-
572
- # everyone except IT
573
- templify s -t names.txt -d employees.xlsx -o out \
574
- --filter '{"name": "string__eq", "args": ["department", "IT"], "kwargs": {}, "inverse": true}'
575
- # → Abdullah Omar
576
-
577
- # same rule, arguments by name
578
- templify s -t names.txt -d employees.xlsx -o out \
579
- --filter '{"name": "int__gt", "args": [], "kwargs": {"key": "salary", "value": 1400}, "inverse": false}'
580
- # → Rawaa Rama
581
- ```
582
-
583
- **Combine rules** with an *expression*: exactly two keys, `operator` and `expressions`.
584
-
585
- | `operator` | Keeps a row when |
586
- | ---------- | --------------------------------- |
587
- | `all` | **every** expression matches (AND) |
588
- | `any` | **at least one** matches (OR) |
589
-
590
- ```bash
591
- # in Homs AND earning at least 1300
592
- templify s -t names.txt -d employees.xlsx -o out --filter '{
593
- "operator": "all",
594
- "expressions": [
595
- {"name": "string__eq", "args": ["city", "Homs"], "kwargs": {}, "inverse": false},
596
- {"name": "int__ge", "args": ["salary", 1300], "kwargs": {}, "inverse": false}
597
- ]
598
- }'
599
- # → Rama
600
-
601
- # in Aleppo OR earning more than 1700
602
- templify s -t names.txt -d employees.xlsx -o out --filter '{
603
- "operator": "any",
604
- "expressions": [
605
- {"name": "string__eq", "args": ["city", "Aleppo"], "kwargs": {}, "inverse": false},
606
- {"name": "int__gt", "args": ["salary", 1700], "kwargs": {}, "inverse": false}
607
- ]
608
- }'
609
- # → Rawaa Omar
610
- ```
611
-
612
- Expressions **nest** to any depth:
613
-
614
- ```bash
615
- # IT AND (in Damascus OR hired since 2024)
616
- templify s -t names.txt -d employees.xlsx -o out --filter '{
617
- "operator": "all",
618
- "expressions": [
619
- {"name": "string__eq", "args": ["department", "IT"], "kwargs": {}, "inverse": false},
620
- {
621
- "operator": "any",
622
- "expressions": [
623
- {"name": "string__eq", "args": ["city", "Damascus"], "kwargs": {}, "inverse": false},
624
- {"name": "date__ge", "args": ["hired", "2024-01-01"], "kwargs": {}, "inverse": false}
625
- ]
626
- }
627
- ]
628
- }'
629
- # → Rawaa Rama
630
- ```
631
-
632
- > **Empty cells**: a comparison rule on an empty cell (e.g. `string__endswith` on a missing email) stops with
633
- > `Filter failed on a data value (empty cell or wrong type?)`. Skip empty cells first by adding
634
- > `{"name": "is_null", "args": ["email"], "kwargs": {}, "inverse": true}` to an `all` expression.
635
-
636
- ### Filter rules reference
637
-
638
- The first argument is always the **column**. `value` is converted to the rule's type, so `"5"` works for `int__eq`.
639
-
640
- #### Boolean & null
641
-
642
- | Rule | Arguments | Keeps rows where |
643
- | --------- | ---------- | -------------------------- |
644
- | `is_true` | `[column]` | the value is `true` |
645
- | `is_null` | `[column]` | the value is empty / `null` |
646
-
647
- #### Numbers: `int__*`, `float__*`, `decimal__*`
648
-
649
- | Suffix | Arguments | Keeps rows where |
650
- | ------ | ----------------- | ---------------- |
651
- | `eq` | `[column, value]` | `column == value` |
652
- | `gt` | `[column, value]` | `column > value` |
653
- | `ge` | `[column, value]` | `column >= value` |
654
- | `lt` | `[column, value]` | `column < value` |
655
- | `le` | `[column, value]` | `column <= value` |
656
-
657
- For example: `int__ge`, `float__lt` and `decimal__eq`. For `decimal__*`, the value can be a string (`"10.50"`), an integer, or a float rounded to 2 decimals.
658
-
659
- #### Strings: `string__*`
660
-
661
- | Rule | Arguments | Keeps rows where |
662
- | --------------------- | ------------------ | ---------------------------------------- |
663
- | `string__eq` | `[column, text]` | equals `text` |
664
- | `string__contains` | `[column, text]` | contains `text` |
665
- | `string__icontains` | `[column, text]` | contains `text`, ignoring case |
666
- | `string__startswith` | `[column, text]` | starts with `text` |
667
- | `string__istartswith` | `[column, text]` | starts with `text`, ignoring case |
668
- | `string__endswith` | `[column, text]` | ends with `text` |
669
- | `string__iendswith` | `[column, text]` | ends with `text`, ignoring case |
670
- | `string__is_in` | `[column, [a, b]]` | is one of the list |
671
- | `string__iis_in` | `[column, [a, b]]` | is one of the list, ignoring case |
672
- | `string__regex` | `[column, pattern]` | matches the [regular expression](https://docs.python.org/3/library/re.html) |
673
- | `string__length_eq` | `[column, n]` | length `== n` (also `_gt`, `_ge`, `_lt`, `_le`) |
674
-
675
- ```bash
676
- # lives in Homs or Aleppo
677
- --filter '{"name": "string__is_in", "args": ["city", ["Homs", "Aleppo"]], "kwargs": {}, "inverse": false}'
678
- # → Abdullah Rama Omar
679
-
680
- # name starts with "Ra"
681
- --filter '{"name": "string__regex", "args": ["name", "^Ra"], "kwargs": {}, "inverse": false}'
682
- # → Rawaa Rama
683
- ```
684
-
685
- #### Dates: `date__*` <a id="dates"></a>
686
-
687
- `date__eq`, `date__gt`, `date__ge`, `date__lt`, `date__le`, with arguments `[column, "YYYY-MM-DD"]`.
688
-
689
- They compare the **day only** and accept every form a date comes in:
690
-
691
- - Excel date cells, where the time part is ignored
692
- - ISO strings from JSON: `"2024-02-10"` or `"2024-02-10T08:30:00"`
693
-
694
- ```bash
695
- # hired in 2024 or later (Excel dates)
696
- templify s -t names.txt -d employees.xlsx -o out \
697
- --filter '{"name": "date__ge", "args": ["hired", "2024-01-01"], "kwargs": {}, "inverse": false}'
698
- # → Rama Omar
699
-
700
- # hired before 2024 (JSON string dates)
701
- templify s -t names.txt -d employees.json -o out \
702
- --filter '{"name": "date__lt", "args": ["hired", "2024-01-01"], "kwargs": {}, "inverse": false}'
703
- # → Abdullah Rawaa
704
-
705
- # hired during 2024: combine two rules
706
- --filter '{"operator": "all", "expressions": [
707
- {"name": "date__ge", "args": ["hired", "2024-01-01"], "kwargs": {}, "inverse": false},
708
- {"name": "date__le", "args": ["hired", "2024-12-31"], "kwargs": {}, "inverse": false}
709
- ]}'
710
- ```
711
-
712
- #### Datetimes: `datetime__*`
713
-
714
- `datetime__eq`, `datetime__gt`, `datetime__ge`, `datetime__lt`, `datetime__le`, with arguments `[column, "YYYY-MM-DDTHH:MM:SS"]`. They compare down to the second. The column must hold real datetimes, i.e. Excel date cells. For JSON string dates, use `date__*`.
715
-
716
- #### UUIDs: `uuid__*`
717
-
718
- `uuid__eq`, `uuid__gt`, `uuid__ge`, `uuid__lt`, `uuid__le`, with arguments `[column, "uuid-string"]`.
719
-
720
- ## Grouping
721
-
722
- `--group-by <column>` merges rows that share the same value into **one group**. Each group is a new row holding:
723
-
724
- - the group-by column(s), with the shared value
725
- - `items` (rename it with `--group-items`), the list of the original rows in that group
726
-
727
- ```text
728
- rows: groups (--group-by department):
729
- {name: Abdullah, department: Sales} {department: Sales, items: [Abdullah, Omar]}
730
- {name: Rawaa, department: IT} ──► {department: IT, items: [Rawaa, Rama]}
731
- {name: Rama, department: IT}
732
- {name: Omar, department: Sales}
733
- ```
734
-
735
- Groups appear in the order their first row appears. Grouping runs **after** ordering and filtering, so `--order` also sorts the rows inside every group, and `--filter` decides which rows get grouped.
736
-
737
- **One file per group**, with `multiple-templates`: `-k` can name files after the group column.
738
-
739
- ```bash
740
- templify m -t department.html -d employees.xlsx -o departments --group-by department -k department
741
- ```
742
-
743
- ```text
744
- departments/
745
- ├── Sales.html
746
- └── IT.html
747
- ```
748
-
749
- **One report of all groups**, with `single-template`: loop over groups, then over each group's rows.
750
-
751
- `by-city.txt`:
752
-
753
- ```jinja
754
- {% for group in data -%}
755
- {{ group.city }} / {{ group.department }}:
756
- {%- for e in group.people %} {{ e.name }}{% endfor %}
757
- {% endfor %}
758
- ```
759
-
760
- ```bash
761
- templify s -t by-city.txt -d employees.xlsx -o out \
762
- --order city --group-by city --group-by department --group-items people
763
- ```
764
-
765
- ```text
766
- Aleppo / Sales: Omar
767
- Damascus / IT: Rawaa
768
- Homs / Sales: Abdullah
769
- Homs / IT: Rama
770
- ```
771
-
772
- Useful group-level expressions:
773
-
774
- ```jinja
775
- {{ items | length }} {# rows in the group #}
776
- {{ items | sum(attribute="salary") }} {# total of a column #}
777
- {{ items | map(attribute="name") | join(", ") }} {# "Rawaa, Rama" #}
778
- {{ (items | sum(attribute="salary")) / (items | length) }} {# average #}
779
- ```
780
-
781
- **Grouping errors**
782
-
783
- | Problem | Message |
784
- | -------------------------------------------------------- | --------------------------------------------------- |
785
- | The column doesn't exist | `Key 'country' not found in data, Available keys: ...` |
786
- | `--group-items` equals a group-by column | `Items key 'city' can not be one of the group by keys` |
787
- | The column holds lists or objects (JSON) | `Can not group by unhashable values ...` |
788
-
789
- ## Cookbook
790
-
791
- **Offer letters in Word, one per employee, numbered:**
792
-
793
- ```bash
794
- templify m -t offer.docx -d employees.xlsx -o offers -k name -i
795
- ```
796
-
797
- **Only employees hired this year, newest first:**
798
-
799
- ```bash
800
- templify m -t offer.docx -d employees.xlsx -o new-hires -k name --order -hired \
801
- --filter '{"name": "date__ge", "args": ["hired", "2024-01-01"], "kwargs": {}, "inverse": false}'
802
- ```
803
-
804
- **Emails only for people who have an email address:**
805
-
806
- ```bash
807
- templify m -t email.txt -d employees.xlsx -o emails -k email \
808
- --filter '{"name": "is_null", "args": ["email"], "kwargs": {}, "inverse": true}'
809
- ```
810
-
811
- **A Markdown salary report per department:**
812
-
813
- `department.md`:
814
-
815
- ```jinja
816
- # {{ department }}
817
-
818
- {% for e in items -%}
819
- - {{ e.name }}: {{ e.salary }}
820
- {% endfor %}
821
- Total: {{ items | sum(attribute="salary") }}
822
- ```
823
-
824
- ```bash
825
- templify m -t department.md -d employees.xlsx -o reports -k department --group-by department --order -salary
826
- ```
827
-
828
- **Convert Excel to JSON:**
829
-
830
- ```bash
831
- templify s -t all.json -d employees.xlsx -o export -f employees
832
- ```
833
-
834
- with `all.json` containing `{{ data | tojson(indent=2) }}`.
835
-
836
- **One JSON file per record, for an API import:**
837
-
838
- ```bash
839
- templify m -t profile.json -d employees.xlsx -o api -k email
840
- ```
841
-
842
- **A single printable Word file, one page per employee:**
843
-
844
- ```bash
845
- templify s -t all-offers.docx -d employees.xlsx -o print --order name
846
- ```
847
-
848
- **Name files with two columns**: templify names files from one column, so add a combined column to your data (an Excel formula like `=A2&" - "&B2` works once the file is saved in Excel), then use `-k full_title`.
849
-
850
- ## Errors
851
-
852
- templify reports problems as short messages and exits with code `2`:
853
-
854
- | Problem | Example message |
855
- | ------------------------------------------------ | ---------------------------------------------------------------------- |
856
- | Unsupported template extension | `Template extension 'pdf' is not supported, supported: docx, htm, ...` |
857
- | Unsupported data extension | `Extension csv is not supported` |
858
- | File doesn't exist / is a folder | `File '...' does not exist.` |
859
- | Jinja syntax error in the template | `Template Error: unexpected '}'` |
860
- | Corrupted `.docx` template | `Could not load template 't.docx': ...` |
861
- | Corrupted `.xlsx` / JSON not a list of objects | `Could not read data file 'data.json': JSON data must be a list of objects` |
862
- | JSON template rendered invalid JSON | `Template Error: Rendered output is not valid JSON: ...` |
863
- | `--order` / `--group-by` column doesn't exist | `Key 'salary' not found in data, Available keys: name, age` |
864
- | `--filter` isn't valid JSON | `Expecting property name enclosed in double quotes: ...` |
865
- | Unknown filter rule | `Rule 'nope' does not exist. Available rules: is_true, is_null, ...` |
866
- | Invalid value for a rule | `Argument 'value' with value 'soon' failed to process, ...` |
867
- | Filter hit an empty cell or wrong type | `Filter failed on a data value (empty cell or wrong type?): ...` |
868
-
869
- ## Extending templify
870
-
871
- Each extension point is a registry filled with a decorator. Add your code, and the CLI picks it up automatically, including `--help` and the error messages.
872
-
873
- ### A new data source
874
-
875
- `src/templify/readers.py`: a function that takes a path and returns a list of dicts.
876
-
877
- ```python
878
- import csv
879
-
880
-
881
- @readers.reader("csv")
882
- def read_csv(filepath: Path) -> Data:
883
- with filepath.open(encoding="utf-8", newline="") as f:
884
- return list(csv.DictReader(f))
885
- ```
886
-
887
- ### A new template format
888
-
889
- `src/templify/writers.py`: a class built from the template path that implements the `Writer` protocol, i.e. a `write(context, filepath)` method.
890
-
891
- ```python
892
- class Writer(Protocol):
893
- def write(self, context: Context, filepath: Path) -> None: ...
894
- ```
895
-
896
- Text-based formats can simply be added to `TextWriter`:
897
-
898
- ```python
899
- @writers.writer("txt", "md", "html", "htm", "xml", "csv")
900
- class TextWriter: ...
901
- ```
902
-
903
- A format with its own engine gets its own class:
904
-
905
- ```python
906
- @writers.writer("pptx")
907
- class PptxWriter:
908
- def __init__(self, template_path: Path) -> None:
909
- self.template_path = template_path
910
-
911
- def write(self, context: Context, filepath: Path) -> None:
912
- ... # render context into the template and save to filepath
913
- ```
914
-
915
- A format that needs validation can extend `TextWriter` and override `render`, the way `JsonWriter` does.
916
-
917
- ### A new filter rule
918
-
919
- `src/templify/filter_rules.py`: the first two parameters are the row and the column. `processors` converts the values coming from the filter JSON.
920
-
921
- ```python
922
- @rules.rule(processors=float)
923
- def float__between(d: dict[str, Any], key: str, low: float, high: float) -> bool:
924
- return low <= d[key] <= high
925
- ```
926
-
927
- ```bash
928
- --filter '{"name": "float__between", "args": ["salary", 1200, 1600], "kwargs": {}, "inverse": false}'
929
- ```
930
-
931
- ## Development
932
-
933
- ```bash
934
- uv sync
935
- ```
936
-
937
- Run the tests. Coverage (lines and branches) is printed after every run:
938
-
939
- ```bash
940
- uv run pytest
941
- ```
942
-
943
- Lint and format:
944
-
945
- ```bash
946
- uvx ruff check src tests
947
- ```
948
-
949
- ```bash
950
- uvx ruff format src tests
951
- ```
952
-
953
- ### Project structure
954
-
955
- ```text
956
- src/templify/
957
- ├── models.py # Data / DataItem types
958
- ├── readers.py # data sources: ReaderRegistry, read_excel, read_json
959
- ├── writers.py # Writer protocol, WriterRegistry, Docx/Text/Json writers,
960
- │ # write_single / write_multiple, file naming
961
- ├── transformers.py # order_by_data, filter_data, group_by_data
962
- ├── filter_rules.py # every --filter rule
963
- └── cli/
964
- ├── main.py # the commands
965
- ├── help.py # long --help texts
966
- └── dependencies/
967
- ├── data.py # -d, --order, --filter, --group-by pipeline
968
- ├── template.py # -t (picks the writer) and -o
969
- └── console.py # rich console
970
- tests/
971
- ├── test_readers.py
972
- ├── test_writers.py
973
- ├── test_transformers.py
974
- ├── test_filter_rules.py
975
- └── test_cli.py # end-to-end user scenarios
976
- ```
977
-
978
- ## License
979
-
980
- templify is free software, released under the [GNU General Public License v3.0 or later](https://www.gnu.org/licenses/gpl-3.0.html) (see `LICENSE`).
39
+ # templify
40
+
41
+ A command line tool that fills document templates with data.
42
+
43
+ Point it at a **template** (Word, text, Markdown, HTML or JSON) and a **data source**: an Excel or JSON file, or a JSON Schema that templify asks you to fill. templify renders one file per row, one file with every row in it, or one file from a single object. Before rendering, you can **order**, **filter** and **group** the data.
44
+
45
+ ```bash
46
+ templify render --each -t offer.docx -d employees.xlsx -o offers -n name
47
+ ```
48
+
49
+ ```text
50
+ offers/
51
+ ├── Abdullah.docx
52
+ ├── Rawaa.docx
53
+ ├── Rama.docx
54
+ └── Omar.docx
55
+ ```
56
+
57
+ ---
58
+
59
+ ## Table of contents
60
+
61
+ - [Features](#features)
62
+ - [Installation](#installation)
63
+ - [Quick start](#quick-start)
64
+ - [How it works](#how-it-works)
65
+ - [Commands](#commands)
66
+ - [`render` (`r`)](#render-r)
67
+ - [`filters` (`f`)](#filters-f)
68
+ - [Upgrading from 0.2](#upgrading-from-02)
69
+ - [Data sources](#data-sources)
70
+ - [Templates](#templates)
71
+ - [Supported formats](#supported-formats)
72
+ - [Jinja2 in 60 seconds](#jinja2-in-60-seconds)
73
+ - [Built-in variables](#built-in-variables)
74
+ - [Word (`.docx`) templates](#word-docx-templates)
75
+ - [JSON templates](#json-templates)
76
+ - [HTML templates](#html-templates)
77
+ - [Output files](#output-files)
78
+ - [Ordering](#ordering)
79
+ - [Filtering](#filtering)
80
+ - [Filter schema](#filter-schema)
81
+ - [Filter rules reference](#filter-rules-reference)
82
+ - [Grouping](#grouping)
83
+ - [Cookbook](#cookbook)
84
+ - [Errors](#errors)
85
+ - [Extending templify](#extending-templify)
86
+ - [Development](#development)
87
+
88
+ ---
89
+
90
+ ## Features
91
+
92
+ - **Many template formats**: `.docx`, `.txt`, `.md`, `.html`, `.htm` and `.json`. The writer is picked automatically from the template's extension.
93
+ - **Many data sources**: Excel (`.xlsx`), JSON (`.json`), or answers asked from a JSON Schema (`-s`).
94
+ - **One command, three modes**: every row in one file (`render`), one file per row (`render --each`), or one object in one file (`render --object`).
95
+ - **Ordering** by any number of columns, ascending or descending.
96
+ - **Filtering** with a JSON filter language: 50+ rules for strings, numbers, decimals, dates, datetimes and UUIDs. Rules combine with `all` / `any`, nest, and can be inverted.
97
+ - **Grouping** by one or more columns, e.g. one file per department listing its employees.
98
+ - **Safe output**: existing files are never overwritten, invalid filename characters are replaced, and missing folders are created.
99
+ - **Validated JSON output**: a JSON template that renders invalid JSON is rejected before anything is saved.
100
+ - **Clear errors** for bad templates, bad data, unknown columns and bad filters, instead of Python tracebacks.
101
+ - **Extensible**: add a new data reader, template writer or filter rule with one decorator.
102
+
103
+ ## Installation
104
+
105
+ Requires **Python 3.12+**.
106
+
107
+ Install it as a global command from PyPI with [uv](https://docs.astral.sh/uv/):
108
+
109
+ ```bash
110
+ uv tool install templify-cli
111
+ ```
112
+
113
+ or with [pipx](https://pipx.pypa.io/):
114
+
115
+ ```bash
116
+ pipx install templify-cli
117
+ ```
118
+
119
+ Or run it from a clone without installing:
120
+
121
+ ```bash
122
+ uv sync
123
+ ```
124
+
125
+ ```bash
126
+ uv run templify --help
127
+ ```
128
+
129
+ ## Quick start
130
+
131
+ **1. Prepare your data**, e.g. `employees.xlsx`. The first row holds the column names:
132
+
133
+ | name | department | city | salary | hired | email |
134
+ | -------- | ---------- | -------- | -----: | ---------- | -------------------- |
135
+ | Abdullah | Sales | Homs | 1200 | 2021-04-01 | abdullah@example.com |
136
+ | Rawaa | IT | Damascus | 1800 | 2023-09-15 | rawaa@example.com |
137
+ | Rama | IT | Homs | 1500 | 2024-02-10 | rama@example.com |
138
+ | Omar | Sales | Aleppo | 1100 | 2024-11-03 | |
139
+
140
+ **2. Write a template** that uses the column names as variables, e.g. `welcome.txt`:
141
+
142
+ ```jinja
143
+ Hello {{ name }},
144
+ You joined {{ department }} on {{ hired.strftime("%d/%m/%Y") }}.
145
+ ```
146
+
147
+ **3. Run templify:**
148
+
149
+ ```bash
150
+ templify render --each -t welcome.txt -d employees.xlsx -o welcome -n name
151
+ ```
152
+
153
+ `welcome/Rama.txt`:
154
+
155
+ ```text
156
+ Hello Rama,
157
+ You joined IT on 10/02/2024.
158
+ ```
159
+
160
+ > The same data file is used in every example below.
161
+
162
+ ## How it works
163
+
164
+ `render` runs one pipeline:
165
+
166
+ ```text
167
+ read data ──► order ──► filter ──► group ──► render template ──► save file(s)
168
+ (-d / -s) (--order) (--filter) (--group-by) (-t) (-o)
169
+ ```
170
+
171
+ 1. **Read**: the [data source](#data-sources) becomes a list of rows, each row a set of `column: value` pairs.
172
+ 2. **Order**: rows are sorted by `--order` columns (optional).
173
+ 3. **Filter**: rows not matching `--filter` are dropped (optional).
174
+ 4. **Group**: rows are grouped by `--group-by` columns (optional).
175
+ 5. **Render**: the writer chosen from the template extension renders the rows.
176
+ 6. **Save**:
177
+ - by default, one file containing every row.
178
+ - with `--each`, one file per row, or per group when grouping.
179
+
180
+ With `--object`, the data is a **single object** instead of a list, so steps 2 to 4 don't apply and that object is rendered into one file.
181
+
182
+ ## Commands
183
+
184
+ ```bash
185
+ templify --help
186
+ ```
187
+
188
+ | Command | Alias | What it does |
189
+ | --------- | ----- | --------------------------------------------------- |
190
+ | `render` | `r` | Render a template with data, in one of three modes |
191
+ | `filters` | `f` | List the filter rules and their arguments |
192
+
193
+ ### `render` (`r`)
194
+
195
+ ```bash
196
+ templify render -t <template> (-d <data> | -s <schema.json>) [--each | --object] [-o <output-dir>] [OPTIONS]
197
+ ```
198
+
199
+ The mode decides what the template sees and how many files are written:
200
+
201
+ | Mode | Data | Writes | The template sees |
202
+ | ----------- | ------------------- | ------------------------------- | ------------------------------------------------------- |
203
+ | *(default)* | a list of rows | **one file** with all the rows | the rows as a list, `objects`: `{% for e in objects %}` |
204
+ | `--each` | a list of rows | **one file per row** (or group) | each row's columns: `{{ name }}`, `{{ salary }}` |
205
+ | `--object` | a **single object** | **one file** | the object as `object`: `{{ object.name }}` |
206
+
207
+ | Option | Short | Modes | Description | Default |
208
+ | ------------- | ----- | ------------------- | --------------------------------------------------------------- | -------------------- |
209
+ | `--template` | `-t` | all | Template file. **Required.** | |
210
+ | `--data` | `-d` | all | Data file. Exactly one of `-d` / `-s` is **required** | |
211
+ | `--schema` | `-s` | all | JSON Schema file, [ask for the data](#data-sources) matching it | |
212
+ | `--output` | `-o` | all | Output directory, created if missing | current directory |
213
+ | `--each` | | | One file per row (or per group) | off |
214
+ | `--object` | | | The data is a single object | off |
215
+ | `--filename` | `-f` | default, `--object` | Output filename **without** extension | the template's name |
216
+ | `--var` | `-v` | default, `--object` | Name of the variable holding the data | `objects` / `object` |
217
+ | `--name-by` | `-n` | `--each` | Column whose value names each file | `template-<n>` |
218
+ | `--numbered` | | `--each` | Prefix each filename with its row number: `1 - Rama.docx` | off |
219
+ | `--order` | | default, `--each` | Column to [order](#ordering) by, `-column` for descending. Repeatable | |
220
+ | `--filter` | | default, `--each` | JSON [filter schema](#filter-schema) | |
221
+ | `--group-by` | | default, `--each` | Column to [group](#grouping) by. Repeatable | |
222
+ | `--items-var` | | default, `--each` | Variable name holding each group's rows | `items` |
223
+
224
+ An option used in a mode that doesn't take it is an error, never silently ignored:
225
+ `--name-by only works with --each`.
226
+
227
+ #### All rows in one file (default)
228
+
229
+ `team.md`:
230
+
231
+ ```jinja
232
+ # Team report
233
+
234
+ | # | Name | Department | Salary |
235
+ |---|------|------------|-------:|
236
+ {% for e in objects -%}
237
+ | {{ loop.index }} | {{ e.name }} | {{ e.department }} | {{ "{:,}".format(e.salary) }} |
238
+ {% endfor %}
239
+ **Total salaries:** {{ objects | sum(attribute="salary") }}
240
+ ```
241
+
242
+ ```bash
243
+ templify render -t team.md -d employees.xlsx -o reports --order -salary
244
+ ```
245
+
246
+ `reports/team.md`:
247
+
248
+ ```markdown
249
+ # Team report
250
+
251
+ | # | Name | Department | Salary |
252
+ |---|------|------------|-------:|
253
+ | 1 | Rawaa | IT | 1,800 |
254
+ | 2 | Rama | IT | 1,500 |
255
+ | 3 | Abdullah | Sales | 1,200 |
256
+ | 4 | Omar | Sales | 1,100 |
257
+
258
+ **Total salaries:** 5600
259
+ ```
260
+
261
+ Rename the output and the loop variable:
262
+
263
+ ```bash
264
+ templify render -t team.md -d employees.xlsx -o reports -f "2024 team" -v employees
265
+ ```
266
+
267
+ The template then loops with `{% for e in employees %}`, and the file is saved as `reports/2024 team.md`.
268
+
269
+ #### One file per row (`--each`)
270
+
271
+ ```bash
272
+ templify render -t offer.docx -d employees.xlsx -o offers --each -n name --numbered
273
+ ```
274
+
275
+ ```text
276
+ offers/
277
+ ├── 1 - Abdullah.docx
278
+ ├── 2 - Rawaa.docx
279
+ ├── 3 - Rama.docx
280
+ └── 4 - Omar.docx
281
+ ```
282
+
283
+ ![templify render --each](https://raw.githubusercontent.com/aaltatan/templify/main/docs/demo/each.gif)
284
+
285
+ #### One object (`--object`)
286
+
287
+ ```bash
288
+ templify render -t letter.docx -d rama.json --object # Hello {{ object.name }}
289
+ templify render -t letter.docx -s person.schema.json --object # asks for the fields first
290
+ ```
291
+
292
+ With `-s`, [schemify](https://github.com/aaltatan/schemify-cli) asks for the fields:
293
+
294
+ ![templify render --object](https://raw.githubusercontent.com/aaltatan/templify/main/docs/demo/schema.gif)
295
+
296
+ ### `filters` (`f`)
297
+
298
+ Lists every filter rule you can use in `--filter`, with its arguments (in `args` order) and description:
299
+
300
+ ```bash
301
+ templify filters
302
+ ```
303
+
304
+ ```text
305
+ Rule Arguments Description
306
+ is_true key: string Keep records where `key` is `true`.
307
+ is_null key: string Keep records where `key` is empty (`null`).
308
+ int__eq key: string, value: integer Keep records where `key` equals `value`.
309
+ ...
310
+ string__regex key: string, pattern: string Keep records where `key` matches the regular expression `pattern`.
311
+ string__is_in key: string, value: list[string] Keep records where `key` is one of the `value` list.
312
+ ...
313
+ date__ge key: string, value: date Keep records where `key` is greater than or equal to `value`, comparing the day only.
314
+ ...
315
+ ```
316
+
317
+ The argument names are also the `kwargs` names: `{"key": "salary", "value": 1400}`.
318
+
319
+ ### Upgrading from 0.2
320
+
321
+ | 0.2 | 0.3 |
322
+ | ---------------------------------------- | ----------------------------------------- |
323
+ | `templify single-template` / `s` | `templify render` |
324
+ | `templify multiple-templates` / `m` | `templify render --each` |
325
+ | `templify schema-template` / `js` | `templify render --object -s ...` |
326
+ | `templify schema-list-template` / `jsl` | `templify render -s ...` |
327
+ | `templify filters-json-schema` / `fjs` | `templify filters` |
328
+ | `-k` / `--filename-key` | `-n` / `--name-by` |
329
+ | `-i` / `--index` | `--numbered` |
330
+ | `--group-items` | `--items-var` |
331
+ | `-v` / `--data-variable`, default `data` | `-v` / `--var`, default `objects` |
332
+ | `-o` required | `-o` optional, default current directory |
333
+ | Template errors exit with code 2 | exit with code 1 (usage errors stay 2) |
334
+
335
+ ## Data sources
336
+
337
+ `render` takes **exactly one** data source:
338
+
339
+ - `-d` / `--data`: a data file. The reader is chosen from its extension, ignoring case.
340
+ - `-s` / `--schema`: a JSON Schema file. [schemify](https://github.com/aaltatan/schemify-cli) asks for the data matching it: a list (keep answering until you say **No**), or one object with `--object`.
341
+
342
+ ![templify render -s](https://raw.githubusercontent.com/aaltatan/templify/main/docs/demo/schema-list.gif)
343
+
344
+ Data files for the default mode and `--each` hold a **list of objects**:
345
+
346
+ | Extension | Format |
347
+ | --------- | --------------------------------------------------------------------------- |
348
+ | `.xlsx` | Excel workbook: the **active sheet**, first row = column names |
349
+ | `.json` | A JSON **list of objects**: `[{"name": "Rama", ...}, ...]` |
350
+
351
+ Data files for `--object` hold a **single object**:
352
+
353
+ | Extension | Format |
354
+ | --------- | --------------------------------------------------------------------------- |
355
+ | `.json` | A JSON **object**: `{"name": "Rama", ...}` |
356
+
357
+ **Excel notes**
358
+
359
+ - The first row holds the column names, and each following row is one record.
360
+ - Fully blank rows are skipped.
361
+ - Formula cells give their **last calculated value**, not the formula text. A file created by a script and never opened in Excel has no calculated values yet, so those cells come back empty.
362
+ - Values keep their type: numbers stay numbers, dates become Python `datetime`s (so `{{ hired.strftime("%Y") }}` works), and empty cells become `None`.
363
+
364
+ **JSON notes**
365
+
366
+ - Must be a list of objects. An object, a list of numbers and so on are rejected with a clear error.
367
+ - Read as UTF-8, so Arabic and other non-Latin text works.
368
+ - JSON has no date type, so dates are strings (`"2024-02-10"`). The [`date__*` filter rules](#dates) understand both forms.
369
+
370
+ `employees.json`:
371
+
372
+ ```json
373
+ [
374
+ {
375
+ "name": "Abdullah",
376
+ "department": "Sales",
377
+ "city": "Homs",
378
+ "salary": 1200,
379
+ "hired": "2021-04-01",
380
+ "email": "abdullah@example.com"
381
+ }
382
+ ]
383
+ ```
384
+
385
+ ## Templates
386
+
387
+ ### Supported formats
388
+
389
+ The writer is chosen from the **template extension**, ignoring case. The output file gets the same extension.
390
+
391
+ | Extension | Writer | Engine |
392
+ | ----------------------------- | ------------ | ------------------------------------------------ |
393
+ | `.docx` | `DocxWriter` | [docxtpl](https://docxtpl.readthedocs.io/) (Jinja2 inside Word) |
394
+ | `.txt`, `.md`, `.html`, `.htm` | `TextWriter` | [Jinja2](https://jinja.palletsprojects.com/) |
395
+ | `.json` | `JsonWriter` | Jinja2 + validates that the output is JSON |
396
+
397
+ Any other extension is rejected, and the error lists the supported ones.
398
+
399
+ ### Jinja2 in 60 seconds
400
+
401
+ All templates use [Jinja2](https://jinja.palletsprojects.com/en/stable/templates/) syntax:
402
+
403
+ | Syntax | Meaning |
404
+ | ------------------------------------------ | ---------------------------------------- |
405
+ | `{{ name }}` | Print a variable |
406
+ | `{{ e.name }}` or `{{ e["name"] }}` | Print a field of a row |
407
+ | `{% for e in objects %} ... {% endfor %}` | Loop |
408
+ | `{{ loop.index }}` | 1-based loop counter |
409
+ | `{% if salary > 1400 %} ... {% endif %}` | Condition |
410
+ | `{{ name \| upper }}` | Filter: `upper`, `lower`, `title`, `length`, `sum`, `default`, `tojson`, ... |
411
+ | `{{ email or "no email" }}` | Fallback for empty values |
412
+ | `{{ hired.strftime("%d/%m/%Y") }}` | Format an Excel date |
413
+ | `{{ "{:,.2f}".format(salary) }}` | Format a number: `1,200.00` |
414
+ | `{# comment #}` | Comment, not rendered |
415
+ | `{%- ... -%}` | Trim the whitespace around a tag |
416
+ | `{% include "header.txt" %}` | Include another file from the template's folder (text formats) |
417
+
418
+ A variable that doesn't exist renders as an empty string instead of failing.
419
+
420
+ ### Built-in variables
421
+
422
+ These are always available, in both modes:
423
+
424
+ | Variable | Value | In `.docx` it becomes |
425
+ | ---------------- | ------ | --------------------- |
426
+ | `{{ new_line }}` | `\n` | a line break |
427
+ | `{{ tab }}` | `\t` | a tab |
428
+ | `{{ page_break }}` | `\f` | a page break |
429
+
430
+ > If your data has a column with one of these names, the built-in value wins.
431
+
432
+ ### Word (`.docx`) templates
433
+
434
+ Write Jinja2 tags directly in the Word document. Keep each tag in the **same formatting** (don't bold half of `{{ name }}`), otherwise Word splits it into pieces and Jinja can't read it.
435
+
436
+ `offer.docx`:
437
+
438
+ ```text
439
+ Dear {{ name }},
440
+ Welcome to the {{ department }} team in {{ city }}. Your salary is {{ salary }}$.
441
+ ```
442
+
443
+ docxtpl adds special tags for Word structure:
444
+
445
+ | Tag | Use |
446
+ | ----------------------------------- | ------------------------------------------------------- |
447
+ | `{%p for e in objects %}` ... `{%p endfor %}` | Loop over **paragraphs**: each tag sits on its own paragraph |
448
+ | `{%tr for e in objects %}` ... `{%tr endfor %}` | Loop over **table rows**: one row per record |
449
+ | `{%tc ... %}` | Loop over table cells |
450
+ | `{%r ... %}` | Tag applied to a run |
451
+
452
+ **One Word file, one page per employee** (`all-offers.docx`, three paragraphs):
453
+
454
+ ```text
455
+ {%p for e in objects %}
456
+ {{ e.name }} - {{ e.department }}{{ page_break }}
457
+ {%p endfor %}
458
+ ```
459
+
460
+ ```bash
461
+ templify render -t all-offers.docx -d employees.xlsx -o offers
462
+ ```
463
+
464
+ **A Word table with one row per employee**: create a 2-row table. Put `{%tr for e in objects %}` and `{%tr endfor %}` in rows of their own, around a row containing `{{ e.name }}`, `{{ e.salary }}`, ...
465
+
466
+ See the [docxtpl documentation](https://docxtpl.readthedocs.io/) for images, rich text and more.
467
+
468
+ ### JSON templates
469
+
470
+ A `.json` template is rendered like text, then **validated**. If the result isn't valid JSON, nothing is saved and you get an error that points at the problem.
471
+
472
+ Always write values through the `tojson` filter. It adds quotes and escapes characters like `"` for you, and turns `None` into `null` and `True` into `true`.
473
+
474
+ `profile.json`:
475
+
476
+ ```jinja
477
+ {
478
+ "name": {{ name | tojson }},
479
+ "department": {{ department | tojson }},
480
+ "email": {{ email | tojson }},
481
+ "senior": {{ (salary > 1400) | tojson }}
482
+ }
483
+ ```
484
+
485
+ ```bash
486
+ templify render --each -t profile.json -d employees.json -o profiles -n name
487
+ ```
488
+
489
+ `profiles/Omar.json`:
490
+
491
+ ```json
492
+ {
493
+ "name": "Omar",
494
+ "department": "Sales",
495
+ "email": null,
496
+ "senior": false
497
+ }
498
+ ```
499
+
500
+ Dump the whole (ordered / filtered / grouped) dataset:
501
+
502
+ ```jinja
503
+ {{ objects | tojson(indent=2) }}
504
+ ```
505
+
506
+ ```bash
507
+ templify render -t all.json -d employees.xlsx -o export -f employees --order name
508
+ ```
509
+
510
+ > `tojson` sorts object keys alphabetically.
511
+
512
+ **Why use `tojson`?** With `"name": "{{ name }}"`, a name like `Ra"ma` produces broken JSON:
513
+
514
+ ```text
515
+ Invalid value: Template Error: Rendered output is not valid JSON: Expecting ',' delimiter: line 1 column 14 (char 13)
516
+ ```
517
+
518
+ ### HTML templates
519
+
520
+ `department.html`:
521
+
522
+ ```jinja
523
+ <!DOCTYPE html>
524
+ <html>
525
+ <body>
526
+ <h1>{{ department }} department</h1>
527
+ <ul>
528
+ {%- for e in items %}
529
+ <li>{{ e.name }} ({{ e.city }})</li>
530
+ {%- endfor %}
531
+ </ul>
532
+ <p>{{ items | length }} employees</p>
533
+ </body>
534
+ </html>
535
+ ```
536
+
537
+ ```bash
538
+ templify render --each -t department.html -d employees.xlsx -o departments --group-by department -n department
539
+ ```
540
+
541
+ > Values are **not** HTML-escaped, so `<b>` in your data stays bold. If the data isn't trusted, escape it with `{{ value | e }}`.
542
+
543
+ ## Output files
544
+
545
+ | Situation | Result |
546
+ | ------------------------------------------- | --------------------------------------------------- |
547
+ | No `-n` | `template-1.txt`, `template-2.txt`, ... |
548
+ | `-k name` | `Rama.txt` |
549
+ | `-k name -i` | `3 - Rama.txt` |
550
+ | The `-n` column is empty or missing for a row | That row falls back to `template-<n>.txt` |
551
+ | The value has invalid characters: `a/b:c?` | They become `_`: `a_b_c_.txt` |
552
+ | The value is a number: `-k id` | `42.txt` |
553
+ | The file already exists | A short random suffix is added: `Rama-3f9a1c2e.txt`. **Nothing is ever overwritten** |
554
+ | The output folder doesn't exist | It is created, including parent folders |
555
+ | `render` without `-f` | Named after the template: `team.md` → `team.md` |
556
+
557
+ Output files are written as **UTF-8**, with the template's line endings kept as they are.
558
+
559
+ ## Ordering
560
+
561
+ `--order <column>` sorts ascending, and `--order -<column>` sorts descending. Repeat it to sort by several columns. The first one has the highest priority.
562
+
563
+ ```bash
564
+ # highest salary first
565
+ templify render -t names.txt -d employees.xlsx -o out --order -salary
566
+
567
+ # by department A→Z, then by salary high→low inside each department
568
+ templify render -t names.txt -d employees.xlsx -o out --order department --order -salary
569
+ ```
570
+
571
+ ```text
572
+ Rawaa Rama Abdullah Omar
573
+ ```
574
+
575
+ Ordering by a column that doesn't exist fails with the list of available columns.
576
+
577
+ ## Filtering
578
+
579
+ `--filter` takes a JSON string that describes which rows to **keep**.
580
+
581
+ > **Quoting on the command line**
582
+ >
583
+ > - **bash / zsh / PowerShell 7.3+**: wrap the JSON in single quotes: `--filter '{"name": ...}'`
584
+ > - **cmd.exe**: escape the inner quotes: `--filter "{\"name\": ...}"`
585
+
586
+ ### Filter schema
587
+
588
+ **A single rule** (a *predicate*) has exactly four keys:
589
+
590
+ ```json
591
+ {
592
+ "name": "string__eq",
593
+ "args": ["department", "IT"],
594
+ "kwargs": {},
595
+ "inverse": false
596
+ }
597
+ ```
598
+
599
+ | Key | Meaning |
600
+ | --------- | ----------------------------------------------------------------------------------- |
601
+ | `name` | Rule name, see the [rules reference](#filter-rules-reference) |
602
+ | `args` | Positional arguments: **the column first**, then the value(s) |
603
+ | `kwargs` | The same arguments by name, e.g. `{"key": "salary", "value": 1400}`. Use `{}` otherwise |
604
+ | `inverse` | `true` negates the rule: "NOT equal", "does NOT contain", ... |
605
+
606
+ ```bash
607
+ # IT only
608
+ templify render -t names.txt -d employees.xlsx -o out \
609
+ --filter '{"name": "string__eq", "args": ["department", "IT"], "kwargs": {}, "inverse": false}'
610
+ # → Rawaa Rama
611
+
612
+ # everyone except IT
613
+ templify render -t names.txt -d employees.xlsx -o out \
614
+ --filter '{"name": "string__eq", "args": ["department", "IT"], "kwargs": {}, "inverse": true}'
615
+ # → Abdullah Omar
616
+
617
+ # same rule, arguments by name
618
+ templify render -t names.txt -d employees.xlsx -o out \
619
+ --filter '{"name": "int__gt", "args": [], "kwargs": {"key": "salary", "value": 1400}, "inverse": false}'
620
+ # → Rawaa Rama
621
+ ```
622
+
623
+ **Combine rules** with an *expression*: exactly two keys, `operator` and `expressions`.
624
+
625
+ | `operator` | Keeps a row when |
626
+ | ---------- | --------------------------------- |
627
+ | `all` | **every** expression matches (AND) |
628
+ | `any` | **at least one** matches (OR) |
629
+
630
+ ```bash
631
+ # in Homs AND earning at least 1300
632
+ templify render -t names.txt -d employees.xlsx -o out --filter '{
633
+ "operator": "all",
634
+ "expressions": [
635
+ {"name": "string__eq", "args": ["city", "Homs"], "kwargs": {}, "inverse": false},
636
+ {"name": "int__ge", "args": ["salary", 1300], "kwargs": {}, "inverse": false}
637
+ ]
638
+ }'
639
+ # → Rama
640
+
641
+ # in Aleppo OR earning more than 1700
642
+ templify render -t names.txt -d employees.xlsx -o out --filter '{
643
+ "operator": "any",
644
+ "expressions": [
645
+ {"name": "string__eq", "args": ["city", "Aleppo"], "kwargs": {}, "inverse": false},
646
+ {"name": "int__gt", "args": ["salary", 1700], "kwargs": {}, "inverse": false}
647
+ ]
648
+ }'
649
+ # → Rawaa Omar
650
+ ```
651
+
652
+ Expressions **nest** to any depth:
653
+
654
+ ```bash
655
+ # IT AND (in Damascus OR hired since 2024)
656
+ templify render -t names.txt -d employees.xlsx -o out --filter '{
657
+ "operator": "all",
658
+ "expressions": [
659
+ {"name": "string__eq", "args": ["department", "IT"], "kwargs": {}, "inverse": false},
660
+ {
661
+ "operator": "any",
662
+ "expressions": [
663
+ {"name": "string__eq", "args": ["city", "Damascus"], "kwargs": {}, "inverse": false},
664
+ {"name": "date__ge", "args": ["hired", "2024-01-01"], "kwargs": {}, "inverse": false}
665
+ ]
666
+ }
667
+ ]
668
+ }'
669
+ # → Rawaa Rama
670
+ ```
671
+
672
+ > **Empty cells**: a comparison rule on an empty cell (e.g. `string__endswith` on a missing email) stops with
673
+ > `Filter failed on a data value (empty cell or wrong type?)`. Skip empty cells first by adding
674
+ > `{"name": "is_null", "args": ["email"], "kwargs": {}, "inverse": true}` to an `all` expression.
675
+
676
+ ### Filter rules reference
677
+
678
+ The first argument is always the **column**. `value` is converted to the rule's type, so `"5"` works for `int__eq`.
679
+
680
+ #### Boolean & null
681
+
682
+ | Rule | Arguments | Keeps rows where |
683
+ | --------- | ---------- | -------------------------- |
684
+ | `is_true` | `[column]` | the value is `true` |
685
+ | `is_null` | `[column]` | the value is empty / `null` |
686
+
687
+ #### Numbers: `int__*`, `float__*`, `decimal__*`
688
+
689
+ | Suffix | Arguments | Keeps rows where |
690
+ | ------ | ----------------- | ---------------- |
691
+ | `eq` | `[column, value]` | `column == value` |
692
+ | `gt` | `[column, value]` | `column > value` |
693
+ | `ge` | `[column, value]` | `column >= value` |
694
+ | `lt` | `[column, value]` | `column < value` |
695
+ | `le` | `[column, value]` | `column <= value` |
696
+
697
+ For example: `int__ge`, `float__lt` and `decimal__eq`. For `decimal__*`, the value can be a string (`"10.50"`), an integer, or a float rounded to 2 decimals.
698
+
699
+ #### Strings: `string__*`
700
+
701
+ | Rule | Arguments | Keeps rows where |
702
+ | --------------------- | ------------------ | ---------------------------------------- |
703
+ | `string__eq` | `[column, text]` | equals `text` |
704
+ | `string__contains` | `[column, text]` | contains `text` |
705
+ | `string__icontains` | `[column, text]` | contains `text`, ignoring case |
706
+ | `string__startswith` | `[column, text]` | starts with `text` |
707
+ | `string__istartswith` | `[column, text]` | starts with `text`, ignoring case |
708
+ | `string__endswith` | `[column, text]` | ends with `text` |
709
+ | `string__iendswith` | `[column, text]` | ends with `text`, ignoring case |
710
+ | `string__is_in` | `[column, [a, b]]` | is one of the list |
711
+ | `string__iis_in` | `[column, [a, b]]` | is one of the list, ignoring case |
712
+ | `string__regex` | `[column, pattern]` | matches the [regular expression](https://docs.python.org/3/library/re.html) |
713
+ | `string__length_eq` | `[column, n]` | length `== n` (also `_gt`, `_ge`, `_lt`, `_le`) |
714
+
715
+ ```bash
716
+ # lives in Homs or Aleppo
717
+ --filter '{"name": "string__is_in", "args": ["city", ["Homs", "Aleppo"]], "kwargs": {}, "inverse": false}'
718
+ # → Abdullah Rama Omar
719
+
720
+ # name starts with "Ra"
721
+ --filter '{"name": "string__regex", "args": ["name", "^Ra"], "kwargs": {}, "inverse": false}'
722
+ # → Rawaa Rama
723
+ ```
724
+
725
+ #### Dates: `date__*` <a id="dates"></a>
726
+
727
+ `date__eq`, `date__gt`, `date__ge`, `date__lt`, `date__le`, with arguments `[column, "YYYY-MM-DD"]`.
728
+
729
+ They compare the **day only** and accept every form a date comes in:
730
+
731
+ - Excel date cells, where the time part is ignored
732
+ - ISO strings from JSON: `"2024-02-10"` or `"2024-02-10T08:30:00"`
733
+
734
+ ```bash
735
+ # hired in 2024 or later (Excel dates)
736
+ templify render -t names.txt -d employees.xlsx -o out \
737
+ --filter '{"name": "date__ge", "args": ["hired", "2024-01-01"], "kwargs": {}, "inverse": false}'
738
+ # → Rama Omar
739
+
740
+ # hired before 2024 (JSON string dates)
741
+ templify render -t names.txt -d employees.json -o out \
742
+ --filter '{"name": "date__lt", "args": ["hired", "2024-01-01"], "kwargs": {}, "inverse": false}'
743
+ # → Abdullah Rawaa
744
+
745
+ # hired during 2024: combine two rules
746
+ --filter '{"operator": "all", "expressions": [
747
+ {"name": "date__ge", "args": ["hired", "2024-01-01"], "kwargs": {}, "inverse": false},
748
+ {"name": "date__le", "args": ["hired", "2024-12-31"], "kwargs": {}, "inverse": false}
749
+ ]}'
750
+ ```
751
+
752
+ #### Datetimes: `datetime__*`
753
+
754
+ `datetime__eq`, `datetime__gt`, `datetime__ge`, `datetime__lt`, `datetime__le`, with arguments `[column, "YYYY-MM-DDTHH:MM:SS"]`. They compare down to the second. The column must hold real datetimes, i.e. Excel date cells. For JSON string dates, use `date__*`.
755
+
756
+ #### UUIDs: `uuid__*`
757
+
758
+ `uuid__eq`, `uuid__gt`, `uuid__ge`, `uuid__lt`, `uuid__le`, with arguments `[column, "uuid-string"]`.
759
+
760
+ ## Grouping
761
+
762
+ `--group-by <column>` merges rows that share the same value into **one group**. Each group is a new row holding:
763
+
764
+ - the group-by column(s), with the shared value
765
+ - `items` (rename it with `--items-var`), the list of the original rows in that group
766
+
767
+ ```text
768
+ rows: groups (--group-by department):
769
+ {name: Abdullah, department: Sales} {department: Sales, items: [Abdullah, Omar]}
770
+ {name: Rawaa, department: IT} ──► {department: IT, items: [Rawaa, Rama]}
771
+ {name: Rama, department: IT}
772
+ {name: Omar, department: Sales}
773
+ ```
774
+
775
+ Groups appear in the order their first row appears. Grouping runs **after** ordering and filtering, so `--order` also sorts the rows inside every group, and `--filter` decides which rows get grouped.
776
+
777
+ **One file per group**, with `render --each`: `-n` can name files after the group column.
778
+
779
+ ```bash
780
+ templify render --each -t department.html -d employees.xlsx -o departments --group-by department -n department
781
+ ```
782
+
783
+ ```text
784
+ departments/
785
+ ├── Sales.html
786
+ └── IT.html
787
+ ```
788
+
789
+ **One report of all groups**, with `render`: loop over groups, then over each group's rows.
790
+
791
+ `by-city.txt`:
792
+
793
+ ```jinja
794
+ {% for group in objects -%}
795
+ {{ group.city }} / {{ group.department }}:
796
+ {%- for e in group.people %} {{ e.name }}{% endfor %}
797
+ {% endfor %}
798
+ ```
799
+
800
+ ```bash
801
+ templify render -t by-city.txt -d employees.xlsx -o out \
802
+ --order city --group-by city --group-by department --items-var people
803
+ ```
804
+
805
+ ```text
806
+ Aleppo / Sales: Omar
807
+ Damascus / IT: Rawaa
808
+ Homs / Sales: Abdullah
809
+ Homs / IT: Rama
810
+ ```
811
+
812
+ Useful group-level expressions:
813
+
814
+ ```jinja
815
+ {{ items | length }} {# rows in the group #}
816
+ {{ items | sum(attribute="salary") }} {# total of a column #}
817
+ {{ items | map(attribute="name") | join(", ") }} {# "Rawaa, Rama" #}
818
+ {{ (items | sum(attribute="salary")) / (items | length) }} {# average #}
819
+ ```
820
+
821
+ **Grouping errors**
822
+
823
+ | Problem | Message |
824
+ | -------------------------------------------------------- | --------------------------------------------------- |
825
+ | The column doesn't exist | `Key 'country' not found in data, Available keys: ...` |
826
+ | `--items-var` equals a group-by column | `Items key 'city' can not be one of the group by keys` |
827
+ | The column holds lists or objects (JSON) | `Can not group by unhashable values ...` |
828
+
829
+ ## Cookbook
830
+
831
+ **Offer letters in Word, one per employee, numbered:**
832
+
833
+ ```bash
834
+ templify render --each -t offer.docx -d employees.xlsx -o offers -n name --numbered
835
+ ```
836
+
837
+ **Only employees hired this year, newest first:**
838
+
839
+ ```bash
840
+ templify render --each -t offer.docx -d employees.xlsx -o new-hires -n name --order -hired \
841
+ --filter '{"name": "date__ge", "args": ["hired", "2024-01-01"], "kwargs": {}, "inverse": false}'
842
+ ```
843
+
844
+ **Emails only for people who have an email address:**
845
+
846
+ ```bash
847
+ templify render --each -t email.txt -d employees.xlsx -o emails -n email \
848
+ --filter '{"name": "is_null", "args": ["email"], "kwargs": {}, "inverse": true}'
849
+ ```
850
+
851
+ **A Markdown salary report per department:**
852
+
853
+ `department.md`:
854
+
855
+ ```jinja
856
+ # {{ department }}
857
+
858
+ {% for e in items -%}
859
+ - {{ e.name }}: {{ e.salary }}
860
+ {% endfor %}
861
+ Total: {{ items | sum(attribute="salary") }}
862
+ ```
863
+
864
+ ```bash
865
+ templify render --each -t department.md -d employees.xlsx -o reports -n department --group-by department --order -salary
866
+ ```
867
+
868
+ **Convert Excel to JSON:**
869
+
870
+ ```bash
871
+ templify render -t all.json -d employees.xlsx -o export -f employees
872
+ ```
873
+
874
+ with `all.json` containing `{{ objects | tojson(indent=2) }}`.
875
+
876
+ **One JSON file per record, for an API import:**
877
+
878
+ ```bash
879
+ templify render --each -t profile.json -d employees.xlsx -o api -n email
880
+ ```
881
+
882
+ **A single printable Word file, one page per employee:**
883
+
884
+ ```bash
885
+ templify render -t all-offers.docx -d employees.xlsx -o print --order name
886
+ ```
887
+
888
+ **Name files with two columns**: templify names files from one column, so add a combined column to your data (an Excel formula like `=A2&" - "&B2` works once the file is saved in Excel), then use `-n full_title`.
889
+
890
+ ## Errors
891
+
892
+ templify reports problems as short messages. Bad input (options, data, template files) exits with code `2`; a template that fails while rendering exits with code `1`:
893
+
894
+ | Problem | Example message |
895
+ | ------------------------------------------------ | ---------------------------------------------------------------------- |
896
+ | Unsupported template extension | `Template extension 'pdf' is not supported, supported: docx, htm, ...` |
897
+ | Unsupported data extension | `Extension 'csv' is not supported for being a data file` |
898
+ | Both or neither of `-d` / `-s` | `Give exactly one data source: -d / --data or -s / --schema` |
899
+ | Option used in the wrong mode | `--name-by only works with --each` |
900
+ | `--each` with `--object` | `--each and --object can not be used together` |
901
+ | File doesn't exist / is a folder | `File '...' does not exist.` |
902
+ | Jinja syntax error in the template | `Template Error: unexpected '}'` |
903
+ | Corrupted `.docx` template | `Could not load template 't.docx': ...` |
904
+ | Corrupted `.xlsx` / JSON not a list of objects | `Could not read data file 'data.json': JSON data must be a list of objects` |
905
+ | JSON template rendered invalid JSON | `Template Error: Rendered output is not valid JSON: ...` |
906
+ | `--order` / `--group-by` column doesn't exist | `Key 'salary' not found in data, Available keys: name, age` |
907
+ | `--filter` isn't valid JSON | `Expecting property name enclosed in double quotes: ...` |
908
+ | Unknown filter rule | `Rule 'nope' does not exist. Available rules: is_true, is_null, ...` |
909
+ | Invalid value for a rule | `Argument 'value' with value 'soon' failed to process, ...` |
910
+ | Filter hit an empty cell or wrong type | `Filter failed on a data value (empty cell or wrong type?): ...` |
911
+
912
+ ## Extending templify
913
+
914
+ Each extension point is a registry filled with a decorator. Add your code, and the CLI picks it up automatically, including `--help` and the error messages.
915
+
916
+ ### A new data source
917
+
918
+ `src/templify/readers.py`: a function that takes a path and returns a list of dicts, registered on `readers`. A reader for `render --object` returns one dict and is registered on `object_readers`.
919
+
920
+ ```python
921
+ import csv
922
+
923
+
924
+ @readers.reader("csv")
925
+ def read_csv(filepath: Path) -> Data:
926
+ with filepath.open(encoding="utf-8", newline="") as f:
927
+ return list(csv.DictReader(f))
928
+ ```
929
+
930
+ ### A new template format
931
+
932
+ `src/templify/writers.py`: a class built from the template path that implements the `Writer` protocol, i.e. a `write(context, filepath)` method.
933
+
934
+ ```python
935
+ class Writer(Protocol):
936
+ def write(self, context: Context, filepath: Path) -> None: ...
937
+ ```
938
+
939
+ Text-based formats can simply be added to `TextWriter`:
940
+
941
+ ```python
942
+ @writers.writer("txt", "md", "html", "htm", "xml", "csv")
943
+ class TextWriter: ...
944
+ ```
945
+
946
+ A format with its own engine gets its own class:
947
+
948
+ ```python
949
+ @writers.writer("pptx")
950
+ class PptxWriter:
951
+ def __init__(self, template_path: Path) -> None:
952
+ self.template_path = template_path
953
+
954
+ def write(
955
+ self, context: Context, filepath: Path
956
+ ) -> None: ... # render context into the template and save to filepath
957
+ ```
958
+
959
+ A format that needs validation can extend `TextWriter` and override `render`, the way `JsonWriter` does.
960
+
961
+ ### A new filter rule
962
+
963
+ `src/templify/filter_rules.py`: the first two parameters are the row and the column. A `Process` marker on a parameter converts the value coming from the filter JSON.
964
+
965
+ ```python
966
+ @rules.rule()
967
+ def float__between(d: dict[str, Any], key: str, low: FloatValue, high: FloatValue) -> bool:
968
+ return low <= d[key] <= high
969
+ ```
970
+
971
+ ```bash
972
+ --filter '{"name": "float__between", "args": ["salary", 1200, 1600], "kwargs": {}, "inverse": false}'
973
+ ```
974
+
975
+ ## Development
976
+
977
+ ```bash
978
+ uv sync
979
+ ```
980
+
981
+ Run the tests. Coverage (lines and branches) is printed after every run:
982
+
983
+ ```bash
984
+ uv run pytest
985
+ ```
986
+
987
+ Lint and format:
988
+
989
+ ```bash
990
+ uvx ruff check src tests
991
+ ```
992
+
993
+ ```bash
994
+ uvx ruff format src tests
995
+ ```
996
+
997
+ The demo GIFs are recorded with [VHS](https://github.com/charmbracelet/vhs) (`scoop install vhs`
998
+ or `brew install vhs`) and PowerShell (`pwsh`), from the repository root:
999
+
1000
+ ```bash
1001
+ vhs docs/demo/each.tape && vhs docs/demo/schema.tape && vhs docs/demo/schema-list.tape
1002
+ ```
1003
+
1004
+ ### Project structure
1005
+
1006
+ ```text
1007
+ src/templify/
1008
+ ├── models.py # Data / DataItem types
1009
+ ├── readers.py # data sources: readers / object_readers, read_excel, read_json
1010
+ ├── writers.py # Writer protocol, WriterRegistry, Docx/Text/Json writers,
1011
+ │ # write_single / write_multiple, file naming
1012
+ ├── transformers.py # order_by_data, filter_data, group_by_data
1013
+ ├── filter_rules.py # every --filter rule
1014
+ └── cli/
1015
+ ├── main.py # the commands
1016
+ ├── help.py # long --help texts
1017
+ └── dependencies/
1018
+ ├── data.py # -d, --order, --filter, --group-by pipeline
1019
+ ├── template.py # -t (picks the writer) and -o
1020
+ └── console.py # rich console
1021
+ tests/
1022
+ ├── test_readers.py
1023
+ ├── test_writers.py
1024
+ ├── test_transformers.py
1025
+ ├── test_filter_rules.py
1026
+ └── test_cli.py # end-to-end user scenarios
1027
+ ```
1028
+
1029
+ ## License
1030
+
1031
+ templify is free software, released under the [GNU General Public License v3.0 or later](https://www.gnu.org/licenses/gpl-3.0.html) (see `LICENSE`).