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/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """templify: fill document templates (docx, txt, md, html, json) with data (xlsx, json)."""
@@ -0,0 +1 @@
1
+ """The templify command line interface."""
@@ -0,0 +1 @@
1
+ """Typer dependencies shared by the commands, injected with typer-di."""
@@ -0,0 +1,9 @@
1
+ """The rich console used for command output."""
2
+
3
+ from typing import Annotated
4
+
5
+ from rich.console import Console
6
+ from typer_di import Depends
7
+
8
+ ConsoleDI = Annotated[Console, Depends(lambda: Console(color_system="truecolor"))]
9
+ """Injects a truecolor rich console."""
@@ -0,0 +1,196 @@
1
+ """The data pipeline options: `--data`, `--order`, `--filter` and `--group-by`."""
2
+
3
+ import json
4
+ from pathlib import Path
5
+ from typing import Annotated
6
+ from zipfile import BadZipFile
7
+
8
+ import typer
9
+ from pyspecification import (
10
+ ArgumentError,
11
+ ExpressionWrapperDict,
12
+ Predicate,
13
+ PredicateCompiler,
14
+ PredicateDict,
15
+ RuleDoesNotExistError,
16
+ RuleKeyDoesNotExistError,
17
+ )
18
+ from typer_di import Depends
19
+
20
+ from templify.filter_rules import rules
21
+ from templify.models import Data
22
+ from templify.readers import readers
23
+ from templify.transformers import OrderByKey, filter_data, group_by_data, order_by_data
24
+
25
+ # -----------------------
26
+ # utils
27
+ # -----------------------
28
+
29
+
30
+ def _extract_extension(file: Path) -> str:
31
+ return file.suffix.lstrip(".")
32
+
33
+
34
+ # -----------------------
35
+ # read data
36
+ # -----------------------
37
+
38
+
39
+ def _check_if_extension_is_supported(file: Path) -> Path:
40
+ extension = _extract_extension(file)
41
+
42
+ if extension not in readers:
43
+ message = f"Extension {extension} is not supported"
44
+ raise typer.BadParameter(message)
45
+
46
+ return file
47
+
48
+
49
+ def _read(
50
+ file: Annotated[
51
+ Path,
52
+ typer.Option(
53
+ "-d",
54
+ "--data",
55
+ help="Path to the data file",
56
+ exists=True,
57
+ file_okay=True,
58
+ dir_okay=False,
59
+ resolve_path=True,
60
+ callback=_check_if_extension_is_supported,
61
+ ),
62
+ ],
63
+ ) -> Data:
64
+ try:
65
+ return readers[_extract_extension(file)](file)
66
+ except (ValueError, BadZipFile) as e:
67
+ msg = f"Could not read data file '{file.name}': {e}"
68
+ raise typer.BadParameter(msg) from e
69
+
70
+
71
+ # -----------------------
72
+ # order data
73
+ # -----------------------
74
+
75
+
76
+ def _get_order_by_keys(
77
+ keys: Annotated[
78
+ list[str],
79
+ typer.Option(
80
+ "--order",
81
+ help="Columns to order by",
82
+ default_factory=list,
83
+ rich_help_panel="Data Options",
84
+ ),
85
+ ],
86
+ ) -> list[OrderByKey]:
87
+ return [OrderByKey(key) for key in keys]
88
+
89
+
90
+ def _order(
91
+ data: Annotated[Data, Depends(_read)],
92
+ keys: Annotated[list[OrderByKey], Depends(_get_order_by_keys)],
93
+ ) -> Data:
94
+ try:
95
+ return order_by_data(data, keys) if keys else data
96
+ except KeyError as e:
97
+ raise typer.BadParameter(str(e)) from e
98
+
99
+
100
+ # -----------------------
101
+ # filter data
102
+ # -----------------------
103
+
104
+
105
+ def _get_compiler() -> PredicateCompiler:
106
+ return PredicateCompiler(
107
+ rules.rules,
108
+ lambda schema: Predicate(
109
+ lambda _: schema["operator"] == "all",
110
+ operator="logical",
111
+ ),
112
+ )
113
+
114
+
115
+ def _get_filter_schema_dict(
116
+ json_string: Annotated[
117
+ str | None,
118
+ typer.Option(
119
+ "--filter",
120
+ help="JSON string containing the filter schema",
121
+ rich_help_panel="Data Options",
122
+ ),
123
+ ] = None,
124
+ ) -> ExpressionWrapperDict | PredicateDict | None:
125
+ if json_string is None:
126
+ return None
127
+
128
+ try:
129
+ return json.loads(json_string)
130
+ except json.JSONDecodeError as e:
131
+ raise typer.BadParameter(str(e)) from e
132
+
133
+
134
+ def _filter(
135
+ data: Annotated[Data, Depends(_order)],
136
+ compiler: Annotated[PredicateCompiler, Depends(_get_compiler)],
137
+ schema: Annotated[
138
+ ExpressionWrapperDict | PredicateDict | None, Depends(_get_filter_schema_dict)
139
+ ],
140
+ ) -> Data:
141
+ if schema is None:
142
+ return data
143
+
144
+ try:
145
+ return filter_data(data, compiler, schema)
146
+ except (RuleKeyDoesNotExistError, RuleDoesNotExistError, ArgumentError) as e:
147
+ raise typer.BadParameter(str(e)) from e
148
+ except (TypeError, ValueError, AttributeError) as e:
149
+ msg = f"Filter failed on a data value (empty cell or wrong type?): {e}"
150
+ raise typer.BadParameter(msg) from e
151
+
152
+
153
+ # -----------------------
154
+ # group data
155
+ # -----------------------
156
+
157
+
158
+ def _group(
159
+ data: Annotated[Data, Depends(_filter)],
160
+ group_keys: Annotated[
161
+ list[str],
162
+ typer.Option(
163
+ "--group-by",
164
+ help="Columns to group by, each group holds its rows under --group-items",
165
+ default_factory=list,
166
+ rich_help_panel="Data Options",
167
+ ),
168
+ ],
169
+ group_items_key: Annotated[
170
+ str,
171
+ typer.Option(
172
+ "--group-items",
173
+ help="Name of the variable that holds each group rows",
174
+ rich_help_panel="Data Options",
175
+ ),
176
+ ] = "items",
177
+ ) -> Data:
178
+ if not group_keys:
179
+ return data
180
+
181
+ try:
182
+ return group_by_data(data, group_keys, group_items_key)
183
+ except (KeyError, ValueError) as e:
184
+ raise typer.BadParameter(str(e)) from e
185
+ except TypeError as e:
186
+ msg = f"Can not group by unhashable values (like lists or objects): {e}"
187
+ raise typer.BadParameter(msg) from e
188
+
189
+
190
+ # -----------------------
191
+ # DI
192
+ # -----------------------
193
+
194
+
195
+ DataDI = Annotated[Data, Depends(_group)]
196
+ """Injects the records read from `--data`, then ordered, filtered and grouped."""
@@ -0,0 +1,96 @@
1
+ """The template and output options: `--template` and `--output`."""
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+ from typing import Annotated
6
+
7
+ import typer
8
+ from jinja2 import TemplateError
9
+ from typer_di import Depends
10
+
11
+ from templify.writers import Writer, writers
12
+
13
+ # -----------------------
14
+ # template
15
+ # -----------------------
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class Template:
20
+ """A template file and the writer chosen from its extension."""
21
+
22
+ path: Path
23
+ writer: Writer
24
+
25
+ @property
26
+ def extension(self) -> str:
27
+ """The template extension, lowercase and without the dot: `docx`."""
28
+ return self.path.suffix.lstrip(".").lower()
29
+
30
+
31
+ def _check_if_extension_is_supported(file: Path) -> Path:
32
+ extension = file.suffix.lstrip(".")
33
+
34
+ if extension not in writers:
35
+ supported = ", ".join(writers.extensions)
36
+ message = f"Template extension '{extension}' is not supported, supported: {supported}"
37
+ raise typer.BadParameter(message)
38
+
39
+ return file
40
+
41
+
42
+ def _get_template(
43
+ template_path: Annotated[
44
+ Path,
45
+ typer.Option(
46
+ "-t",
47
+ "--template",
48
+ help=f"Path to the template file ({', '.join(writers.extensions)})",
49
+ exists=True,
50
+ file_okay=True,
51
+ dir_okay=False,
52
+ resolve_path=True,
53
+ callback=_check_if_extension_is_supported,
54
+ ),
55
+ ],
56
+ ) -> Template:
57
+ try:
58
+ return Template(path=template_path, writer=writers.create(template_path))
59
+ except TemplateError as e:
60
+ msg = f"Template Error: {e}"
61
+ raise typer.BadParameter(msg) from e
62
+ except Exception as e:
63
+ msg = f"Could not load template '{template_path.name}': {e}"
64
+ raise typer.BadParameter(msg) from e
65
+
66
+
67
+ # -----------------------
68
+ # output
69
+ # -----------------------
70
+
71
+
72
+ def _get_output_dir(
73
+ output_path: Annotated[
74
+ Path,
75
+ typer.Option(
76
+ "-o",
77
+ "--output",
78
+ help="Path to the output directory",
79
+ file_okay=False,
80
+ dir_okay=True,
81
+ resolve_path=True,
82
+ ),
83
+ ],
84
+ ) -> Path:
85
+ return output_path
86
+
87
+
88
+ # -----------------------
89
+ # DI
90
+ # -----------------------
91
+
92
+ TemplateDI = Annotated[Template, Depends(_get_template)]
93
+ """Injects the `--template` file with the writer matching its extension."""
94
+
95
+ OutputDirDI = Annotated[Path, Depends(_get_output_dir)]
96
+ """Injects the `--output` directory."""
templify/cli/help.py ADDED
@@ -0,0 +1,61 @@
1
+ """Long help texts of the commands."""
2
+
3
+ SINGLE_TEMPLATE_HELP_TEXT = (
4
+ "Generate single template file (docx, txt, md, html, json, ...)\n"
5
+ "\n"
6
+ "Usage:\n"
7
+ "\n"
8
+ f"| {'name':<10} | {'age':<4}\n"
9
+ "-------------------\n"
10
+ f"| {'Abdullah':<10} | {'32':<4}\n"
11
+ f"| {'Rawaa':<10} | {'33':<4}\n"
12
+ f"| {'Rama':<10} | {'25':<4}\n"
13
+ "\n"
14
+ "Template:\n"
15
+ "\n"
16
+ "{% for person in data %}\n"
17
+ "Hello my name is {{ person['name'] }}, my age is {{ person['age'] }}\n"
18
+ "{{ page_break }}\n"
19
+ "{% endfor %}\n"
20
+ "\n"
21
+ "Command:\n"
22
+ "\n"
23
+ "templify s \\ \n"
24
+ "-t <your_path_to_template_file> \\ \n"
25
+ "-d <your_path_to_data_file> \\ \n"
26
+ "-o <your_path_to_output_directory> \n"
27
+ "\n"
28
+ "it will generate a single file with three pages like this:\n"
29
+ "\n"
30
+ "Page 1: Hello my name is Abdullah, my age is 32\n"
31
+ "Page 2: Hello my name is Rawaa, my age is 33\n"
32
+ "Page 3: Hello my name is Rama, my age is 25\n"
33
+ )
34
+
35
+ MULTIPLE_TEMPLATES_HELP_TEXT = (
36
+ "Generate multiple template files (docx, txt, md, html, json, ...)\n"
37
+ "\n"
38
+ "Usage:\n"
39
+ "\n"
40
+ f"| {'name':<10} | {'age':<4}\n"
41
+ "-------------------\n"
42
+ f"| {'Abdullah':<10} | {'32':<4}\n"
43
+ f"| {'Rawaa':<10} | {'33':<4}\n"
44
+ f"| {'Rama':<10} | {'25':<4}\n"
45
+ "\n"
46
+ "Template: Hello my name is {{ name }}, my age is {{ age }}\n"
47
+ "\n"
48
+ "Command:\n"
49
+ "\n"
50
+ "templify m \\ \n"
51
+ "-t <your_path_to_template_file> \\ \n"
52
+ "-d <your_path_to_data_file> \\ \n"
53
+ "-o <your_path_to_output_directory> \\ \n"
54
+ "-k name --index\n"
55
+ "\n"
56
+ "it will generate a multiple files like this:\n"
57
+ "\n"
58
+ "1 - Abdullah.docx `Hello my name is Abdullah, my age is 32`\n"
59
+ "2 - Rawaa.docx `Hello my name is Rawaa, my age is 33`\n"
60
+ "3 - Rama.docx `Hello my name is Rama, my age is 25`\n"
61
+ )
templify/cli/main.py ADDED
@@ -0,0 +1,165 @@
1
+ # ruff: noqa: PLR0913
2
+ """The templify command line application."""
3
+
4
+ from typing import Annotated, Any
5
+
6
+ import typer
7
+ from jinja2 import TemplateError
8
+ from pyspecification import get_expression_json_schema
9
+ from rich.markup import escape
10
+ from rich.progress import track
11
+ from rich.table import Table
12
+ from typer_di import TyperDI
13
+ from typer_swissknife import command
14
+
15
+ from templify.cli.dependencies.console import ConsoleDI
16
+ from templify.cli.dependencies.data import DataDI
17
+ from templify.cli.dependencies.template import OutputDirDI, TemplateDI
18
+ from templify.filter_rules import rules
19
+ from templify.writers import InvalidOutputError, write_multiple, write_single
20
+
21
+ from .help import MULTIPLE_TEMPLATES_HELP_TEXT, SINGLE_TEMPLATE_HELP_TEXT
22
+
23
+ app = TyperDI(
24
+ name="templify",
25
+ help="A CLI application for templating documents",
26
+ no_args_is_help=True,
27
+ )
28
+
29
+
30
+ @command(
31
+ app,
32
+ name="filters-json-schema",
33
+ aliases=("filters-schema", "fjs"),
34
+ help="List the filter rules, their arguments and description",
35
+ )
36
+ def print_filter_schema(console: ConsoleDI) -> None:
37
+ """List the filter rules, their arguments and description."""
38
+ table = Table("Rule", "Arguments", "Description", box=None, header_style="bold")
39
+ expressions = get_expression_json_schema(rules.rules)["$defs"]["expression"]["oneOf"]
40
+
41
+ for expression in expressions:
42
+ if "name" not in expression.get("properties", {}):
43
+ continue # the all / any wrapper, not a rule
44
+
45
+ properties = expression["properties"]
46
+ arguments = properties["kwargs"]["properties"].items()
47
+ table.add_row(
48
+ properties["name"]["const"],
49
+ escape(", ".join(f"{name}: {_type_name(schema)}" for name, schema in arguments)),
50
+ expression.get("description", ""),
51
+ )
52
+
53
+ console.print(table)
54
+
55
+
56
+ def _type_name(schema: dict[str, Any]) -> str:
57
+ """Return a short type name for a json schema: `string`, `date`, `list[string]`, ..."""
58
+ if schema.get("type") == "array":
59
+ return f"list[{_type_name(schema.get('items', {}))}]"
60
+
61
+ return schema.get("format") or schema.get("type") or "any"
62
+
63
+
64
+ @command(
65
+ app,
66
+ name="single-template",
67
+ aliases=("single", "s"),
68
+ no_args_is_help=True,
69
+ help=SINGLE_TEMPLATE_HELP_TEXT,
70
+ )
71
+ def generate_single_file(
72
+ console: ConsoleDI,
73
+ data: DataDI,
74
+ template: TemplateDI,
75
+ output_dir: OutputDirDI,
76
+ filename: Annotated[
77
+ str | None,
78
+ typer.Option(
79
+ "-f",
80
+ "--filename",
81
+ help="Output filename without extension [default: template filename]",
82
+ ),
83
+ ] = None,
84
+ data_variable: Annotated[
85
+ str,
86
+ typer.Option(
87
+ "-v",
88
+ "--data-variable",
89
+ help="Name of the variable that holds the data inside the template",
90
+ ),
91
+ ] = "data",
92
+ ) -> None:
93
+ """Generate a single file from all the records."""
94
+ try:
95
+ filepath = write_single(
96
+ template.writer,
97
+ data,
98
+ output_dir=output_dir,
99
+ filename=filename or template.path.stem,
100
+ extension=template.extension,
101
+ data_variable=data_variable,
102
+ )
103
+ except (TemplateError, InvalidOutputError) as e:
104
+ msg = f"Template Error: {e}"
105
+ raise typer.BadParameter(msg) from e
106
+ else:
107
+ console.print(f"[green]File generated successfully:[/] {filepath}")
108
+
109
+
110
+ @command(
111
+ app,
112
+ name="multiple-templates",
113
+ aliases=("multiple", "m"),
114
+ no_args_is_help=True,
115
+ help=MULTIPLE_TEMPLATES_HELP_TEXT,
116
+ )
117
+ def generate_multiple_files(
118
+ console: ConsoleDI,
119
+ data: DataDI,
120
+ template: TemplateDI,
121
+ output_dir: OutputDirDI,
122
+ filename_key: Annotated[
123
+ str | None,
124
+ typer.Option(
125
+ "-k",
126
+ "--filename-key",
127
+ help="Column used to name each generated file [default: template-<index>]",
128
+ ),
129
+ ] = None,
130
+ include_index: Annotated[ # noqa: FBT002
131
+ bool,
132
+ typer.Option(
133
+ "-i",
134
+ "--index",
135
+ help="Prefix each filename with its row index",
136
+ ),
137
+ ] = False,
138
+ ) -> None:
139
+ """Generate one file per record (or per group)."""
140
+ files = write_multiple(
141
+ template.writer,
142
+ data,
143
+ output_dir=output_dir,
144
+ extension=template.extension,
145
+ filename_key=filename_key,
146
+ include_index=include_index,
147
+ )
148
+
149
+ try:
150
+ for _ in track(files, description="Generating", total=len(data), console=console):
151
+ pass
152
+ except (TemplateError, InvalidOutputError) as e:
153
+ msg = f"Template Error: {e}"
154
+ raise typer.BadParameter(msg) from e
155
+ else:
156
+ console.print(f"[green]{len(data)} files generated successfully in:[/] {output_dir}")
157
+
158
+
159
+ def main() -> None:
160
+ """Run the templify command line application."""
161
+ app()
162
+
163
+
164
+ if __name__ == "__main__":
165
+ main()