makejinja 2.2.0__tar.gz → 2.3.1__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: makejinja
3
- Version: 2.2.0
3
+ Version: 2.3.1
4
4
  Summary: makejinja is a CLI tool and Python library to automatically generate files from Jinja2 templates. Use it to easily generate complex Home Assistant dashboards!
5
5
  Home-page: https://mirkolenz.github.io/makejinja/
6
6
  License: MIT
@@ -28,6 +28,7 @@ Classifier: Topic :: System :: Systems Administration
28
28
  Classifier: Topic :: Text Processing :: Markup
29
29
  Classifier: Topic :: Utilities
30
30
  Classifier: Typing :: Typed
31
+ Requires-Dist: immutables (>=0.20,<0.21)
31
32
  Requires-Dist: jinja2 (>=3.1,<4.0)
32
33
  Requires-Dist: pyyaml (>=6.0,<7.0)
33
34
  Requires-Dist: rich-click (>=1.7,<2.0)
@@ -38,11 +39,12 @@ Description-Content-Type: text/markdown
38
39
 
39
40
  <!-- markdownlint-disable MD033 MD041 -->
40
41
  <p align="center">
41
- <img width="256px" src="./assets/logo.png" />
42
+ <img width="256px" src="https://raw.githubusercontent.com/mirkolenz/makejinja/main/assets/logo.png" />
42
43
  </p>
43
44
  <p align="center">
45
+ <a href="https://pypi.org/project/makejinja/">PyPI</a> |
44
46
  <a href="https://mirkolenz.github.io/makejinja">Docs</a> |
45
- <a href="./tests/data">Example</a> |
47
+ <a href="https://github.com/mirkolenz/makejinja/tree/main/tests/data">Example</a> |
46
48
  <a href="https://jinja.palletsprojects.com/en/3.1.x/templates">Templating</a>
47
49
  </p>
48
50
  <p align="center">
@@ -59,7 +61,7 @@ This allows you to load variables from external files or create repeating patter
59
61
  It is conceptually similar to [gomplate](https://github.com/hairyhenderson/gomplate), but is built on Python and Jinja instead of Go.
60
62
  A use case for this tool is generating config files for [Home Assistant](https://www.home-assistant.io/):
61
63
  Using the same language that the built-in templates use, you can greatly simplify your configuration.
62
- An [example for Home Assistant](./tests/data) can be found in the tests directory.
64
+ An [example for Home Assistant](https://github.com/mirkolenz/makejinja/tree/main/tests/data) can be found in the tests directory.
63
65
 
64
66
  ## Features
65
67
 
@@ -70,7 +72,7 @@ An [example for Home Assistant](./tests/data) can be found in the tests director
70
72
  - Tailor the whitespace behavior to your needs.
71
73
  - Use custom delimiters for Jinja blocks/comments/variables.
72
74
  - Modify _all_ init options for the Jinja environment.
73
- - Write custom **Python loaders** that implement a subset of our fully typed [abstract loader class](./makejinja/loader.py)
75
+ - Write custom **Python loaders** that implement a subset of our fully typed [abstract loader class](https://mirkolenz.github.io/makejinja/makejinja/loader.html#AbstractLoader)
74
76
 
75
77
  ## Installation
76
78
 
@@ -90,25 +92,25 @@ Thus, we advise leveraging [`pipx`](https://github.com/pypa/pipx) instead:
90
92
 
91
93
  You can then directly invoke the app as follows:
92
94
 
93
- `makejinja --input ./data/input --output ./data/output`
95
+ `makejinja -i ./input -o ./output`
94
96
 
95
97
  ### Nix
96
98
 
97
99
  If you use the `nix` package manager, you can add this repository as an input to your flake and use `makejinja.packages.${system}.default`.
98
100
  You can also run it directly
99
101
 
100
- `nix run github:mirkolenz/makejinja -- --input ./data/input --output ./data/output`
102
+ `nix run github:mirkolenz/makejinja -- -i ./input -o ./output`
101
103
 
102
104
  ### Docker
103
105
 
104
106
  We automatically publish an image at `ghcr.io/mirkolenz/makejinja`.
105
107
  To use it, mount a folder to the container and pass the options as the command.
106
108
 
107
- `docker run --rm -v $(pwd)/data:/data ghcr.io/mirkolenz/makejinja:latest --input /data/input --output /data/output`
109
+ `docker run --rm -v $(pwd)/data:/data ghcr.io/mirkolenz/makejinja:latest -i /data/input -o /data/output`
108
110
 
109
111
  ## Usage in Terminal / Command Line
110
112
 
111
113
  In its default configuration, makejinja searches the input folder recursively for files ending in `.jinja`.
112
114
  It then renders these files and writes them to the output folder, preserving the directory structure.
113
- Our [documentation](https://mirkolenz.github.io/makejinja/cli.html) contains a detailed description of all options and can also be accessed via `makejinja --help`.
115
+ Our [documentation](https://mirkolenz.github.io/makejinja/makejinja/cli.html) contains a detailed description of all options and can also be accessed via `makejinja --help`.
114
116
 
@@ -1,10 +1,11 @@
1
1
  <!-- markdownlint-disable MD033 MD041 -->
2
2
  <p align="center">
3
- <img width="256px" src="./assets/logo.png" />
3
+ <img width="256px" src="https://raw.githubusercontent.com/mirkolenz/makejinja/main/assets/logo.png" />
4
4
  </p>
5
5
  <p align="center">
6
+ <a href="https://pypi.org/project/makejinja/">PyPI</a> |
6
7
  <a href="https://mirkolenz.github.io/makejinja">Docs</a> |
7
- <a href="./tests/data">Example</a> |
8
+ <a href="https://github.com/mirkolenz/makejinja/tree/main/tests/data">Example</a> |
8
9
  <a href="https://jinja.palletsprojects.com/en/3.1.x/templates">Templating</a>
9
10
  </p>
10
11
  <p align="center">
@@ -21,7 +22,7 @@ This allows you to load variables from external files or create repeating patter
21
22
  It is conceptually similar to [gomplate](https://github.com/hairyhenderson/gomplate), but is built on Python and Jinja instead of Go.
22
23
  A use case for this tool is generating config files for [Home Assistant](https://www.home-assistant.io/):
23
24
  Using the same language that the built-in templates use, you can greatly simplify your configuration.
24
- An [example for Home Assistant](./tests/data) can be found in the tests directory.
25
+ An [example for Home Assistant](https://github.com/mirkolenz/makejinja/tree/main/tests/data) can be found in the tests directory.
25
26
 
26
27
  ## Features
27
28
 
@@ -32,7 +33,7 @@ An [example for Home Assistant](./tests/data) can be found in the tests director
32
33
  - Tailor the whitespace behavior to your needs.
33
34
  - Use custom delimiters for Jinja blocks/comments/variables.
34
35
  - Modify _all_ init options for the Jinja environment.
35
- - Write custom **Python loaders** that implement a subset of our fully typed [abstract loader class](./makejinja/loader.py)
36
+ - Write custom **Python loaders** that implement a subset of our fully typed [abstract loader class](https://mirkolenz.github.io/makejinja/makejinja/loader.html#AbstractLoader)
36
37
 
37
38
  ## Installation
38
39
 
@@ -52,24 +53,24 @@ Thus, we advise leveraging [`pipx`](https://github.com/pypa/pipx) instead:
52
53
 
53
54
  You can then directly invoke the app as follows:
54
55
 
55
- `makejinja --input ./data/input --output ./data/output`
56
+ `makejinja -i ./input -o ./output`
56
57
 
57
58
  ### Nix
58
59
 
59
60
  If you use the `nix` package manager, you can add this repository as an input to your flake and use `makejinja.packages.${system}.default`.
60
61
  You can also run it directly
61
62
 
62
- `nix run github:mirkolenz/makejinja -- --input ./data/input --output ./data/output`
63
+ `nix run github:mirkolenz/makejinja -- -i ./input -o ./output`
63
64
 
64
65
  ### Docker
65
66
 
66
67
  We automatically publish an image at `ghcr.io/mirkolenz/makejinja`.
67
68
  To use it, mount a folder to the container and pass the options as the command.
68
69
 
69
- `docker run --rm -v $(pwd)/data:/data ghcr.io/mirkolenz/makejinja:latest --input /data/input --output /data/output`
70
+ `docker run --rm -v $(pwd)/data:/data ghcr.io/mirkolenz/makejinja:latest -i /data/input -o /data/output`
70
71
 
71
72
  ## Usage in Terminal / Command Line
72
73
 
73
74
  In its default configuration, makejinja searches the input folder recursively for files ending in `.jinja`.
74
75
  It then renders these files and writes them to the output folder, preserving the directory structure.
75
- Our [documentation](https://mirkolenz.github.io/makejinja/cli.html) contains a detailed description of all options and can also be accessed via `makejinja --help`.
76
+ Our [documentation](https://mirkolenz.github.io/makejinja/makejinja/cli.html) contains a detailed description of all options and can also be accessed via `makejinja --help`.
@@ -1,4 +1,6 @@
1
1
  """
2
+ ![Screencast demo](../assets/demo.gif)
3
+
2
4
  .. include:: ../README.md
3
5
 
4
6
  ## Usage as a Library
@@ -0,0 +1,360 @@
1
+ import json
2
+ import os
3
+ import shutil
4
+ import sys
5
+ import tomllib
6
+ from collections import abc
7
+ from inspect import signature
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ import yaml
12
+ from jinja2 import BaseLoader, ChoiceLoader, DictLoader, Environment, FileSystemLoader
13
+ from jinja2.environment import load_extensions
14
+ from jinja2.utils import import_string
15
+ from rich import print
16
+
17
+ from makejinja.config import Config
18
+ from makejinja.loader import AbstractLoader, Data, MutableData
19
+
20
+ __all__ = ["makejinja"]
21
+
22
+
23
+ def makejinja(config: Config):
24
+ """makejinja can be used to automatically generate files from [Jinja templates](https://jinja.palletsprojects.com/en/3.1.x/templates/)."""
25
+
26
+ for path in config.import_paths:
27
+ sys.path.append(str(path.resolve()))
28
+
29
+ data = load_data(config)
30
+
31
+ if config.output.is_dir() and config.clean:
32
+ if not config.quiet:
33
+ print(f"Remove output '{config.output}'")
34
+
35
+ shutil.rmtree(config.output)
36
+
37
+ if not single_input_output_file(config):
38
+ config.output.mkdir(exist_ok=True, parents=True)
39
+
40
+ env = init_jinja_env(config, data)
41
+
42
+ for loader in config.loaders:
43
+ process_loader(loader, env, data)
44
+
45
+ # Save rendered files to avoid duplicate work
46
+ # Even if two files are in two separate folders, they will have the same template name (i.e., relative path)
47
+ # and thus only the first one will be rendered every time
48
+ rendered_files: set[Path] = set()
49
+
50
+ # Save rendered folders to later copy metadata
51
+ rendered_folders: dict[Path, Path] = {}
52
+
53
+ for user_input_path in config.inputs:
54
+ if user_input_path.is_file():
55
+ handle_input_file(user_input_path, config, env, rendered_files)
56
+ elif user_input_path.is_dir():
57
+ handle_input_folder(
58
+ user_input_path, config, env, rendered_files, rendered_folders
59
+ )
60
+
61
+ # The metadata has to be copied after all files are rendered
62
+ # Otherwise the mtime will be updated
63
+ if config.copy_metadata:
64
+ for output_path, input_path in rendered_folders.items():
65
+ if not config.quiet:
66
+ print(f"Copy metadata '{input_path}' -> '{output_path}'")
67
+
68
+ shutil.copystat(input_path, output_path)
69
+
70
+
71
+ def single_input_output_file(config: Config) -> bool:
72
+ """Check if the user provided a single input and a single output"""
73
+ return (
74
+ len(config.inputs) == 1
75
+ and config.inputs[0].is_file()
76
+ and (config.output.suffix != "" or config.output.is_file())
77
+ and not config.output.is_dir()
78
+ )
79
+
80
+
81
+ def handle_input_file(
82
+ input_path: Path,
83
+ config: Config,
84
+ env: Environment,
85
+ rendered_files: abc.MutableSet[Path],
86
+ ) -> None:
87
+ relative_path = Path(input_path.name)
88
+ output_path = generate_output_path(config, relative_path)
89
+
90
+ if output_path not in rendered_files:
91
+ render_path(
92
+ input_path,
93
+ str(relative_path),
94
+ output_path,
95
+ config,
96
+ env,
97
+ enforce_jinja_suffix=False,
98
+ )
99
+
100
+ rendered_files.add(output_path)
101
+
102
+
103
+ def handle_input_folder(
104
+ user_input_path: Path,
105
+ config: Config,
106
+ env: Environment,
107
+ rendered_files: abc.MutableSet[Path],
108
+ rendered_folders: abc.MutableMapping[Path, Path],
109
+ ) -> None:
110
+ input_paths = (
111
+ input_path
112
+ for include_pattern in config.include_patterns
113
+ for input_path in sorted(user_input_path.glob(include_pattern))
114
+ )
115
+
116
+ for input_path in input_paths:
117
+ relative_path = input_path.relative_to(user_input_path)
118
+ output_path = generate_output_path(config, relative_path)
119
+
120
+ if any(input_path.match(x) for x in config.exclude_patterns):
121
+ if not config.quiet:
122
+ print(f"Skip excluded '{input_path}'")
123
+
124
+ elif input_path.is_file() and output_path not in rendered_files:
125
+ render_path(
126
+ input_path,
127
+ str(relative_path),
128
+ output_path,
129
+ config,
130
+ env,
131
+ enforce_jinja_suffix=True,
132
+ )
133
+ rendered_files.add(output_path)
134
+
135
+ elif input_path.is_dir() and output_path not in rendered_folders:
136
+ if not config.quiet:
137
+ print(f"Create folder '{input_path}' -> '{output_path}'")
138
+
139
+ output_path.mkdir()
140
+ rendered_folders[output_path] = input_path
141
+
142
+
143
+ def generate_output_path(config: Config, relative_path: Path) -> Path:
144
+ if single_input_output_file(config):
145
+ return config.output
146
+
147
+ output_file = config.output / relative_path
148
+
149
+ if relative_path.suffix == config.jinja_suffix and not config.keep_jinja_suffix:
150
+ output_file = output_file.with_suffix("")
151
+
152
+ return output_file
153
+
154
+
155
+ def init_jinja_env(
156
+ config: Config,
157
+ data: Data,
158
+ ) -> Environment:
159
+ # If the user-provided ordering of the inputs shall be respected, use this snippet
160
+ # loaders: list[BaseLoader] = []
161
+ # for path in config.inputs:
162
+ # if path.is_dir():
163
+ # loaders.append(FileSystemLoader(path))
164
+ # elif path.is_file():
165
+ # loaders.append(DictLoader({path.name: path.read_text()}))
166
+
167
+ file_loader = DictLoader(
168
+ {path.name: path.read_text() for path in config.inputs if path.is_file()}
169
+ )
170
+ folder_loader = FileSystemLoader([path for path in config.inputs if path.is_dir()])
171
+ loaders: list[BaseLoader] = [file_loader, folder_loader]
172
+
173
+ env = Environment(
174
+ loader=ChoiceLoader(loaders),
175
+ extensions=config.extensions,
176
+ block_start_string=config.delimiter.block_start,
177
+ block_end_string=config.delimiter.block_end,
178
+ variable_start_string=config.delimiter.variable_start,
179
+ variable_end_string=config.delimiter.variable_end,
180
+ comment_start_string=config.delimiter.comment_start,
181
+ comment_end_string=config.delimiter.comment_end,
182
+ line_statement_prefix=config.prefix.line_statement,
183
+ line_comment_prefix=config.prefix.line_comment,
184
+ trim_blocks=config.whitespace.trim_blocks,
185
+ lstrip_blocks=config.whitespace.lstrip_blocks,
186
+ newline_sequence=config.whitespace.newline_sequence, # type: ignore
187
+ keep_trailing_newline=config.whitespace.keep_trailing_newline,
188
+ optimized=config.internal.optimized,
189
+ undefined=config.undefined.value,
190
+ finalize=None,
191
+ autoescape=config.internal.autoescape,
192
+ cache_size=config.internal.cache_size,
193
+ auto_reload=config.internal.auto_reload,
194
+ bytecode_cache=None,
195
+ enable_async=config.internal.enable_async,
196
+ )
197
+
198
+ env.globals.update(data)
199
+ env.globals["env"] = os.environ
200
+
201
+ return env
202
+
203
+
204
+ def from_yaml(path: Path) -> dict[str, Any]:
205
+ data = {}
206
+
207
+ with path.open("rb") as fp:
208
+ for doc in yaml.safe_load_all(fp):
209
+ data |= doc
210
+
211
+ return data
212
+
213
+
214
+ def from_toml(path: Path) -> dict[str, Any]:
215
+ with path.open("rb") as fp:
216
+ return tomllib.load(fp)
217
+
218
+
219
+ def from_json(path: Path) -> dict[str, Any]:
220
+ with path.open("rb") as fp:
221
+ return json.load(fp)
222
+
223
+
224
+ DATA_LOADERS: dict[str, abc.Callable[[Path], dict[str, Any]]] = {
225
+ ".yaml": from_yaml,
226
+ ".yml": from_yaml,
227
+ ".toml": from_toml,
228
+ ".json": from_json,
229
+ }
230
+
231
+
232
+ def collect_files(paths: abc.Iterable[Path], pattern: str = "**/*") -> list[Path]:
233
+ files = []
234
+
235
+ for path in paths:
236
+ if path.is_dir():
237
+ files.extend(
238
+ file
239
+ for file in sorted(path.glob(pattern))
240
+ if not file.name.startswith(".") and file.is_file()
241
+ )
242
+ elif path.is_file():
243
+ files.append(path)
244
+
245
+ return files
246
+
247
+
248
+ def dict_nested_set(data: MutableData, dotted_key: str, value: Any) -> None:
249
+ """Given `foo`, 'key1.key2.key3', 'something', set foo['key1']['key2']['key3'] = 'something'
250
+
251
+ Source: https://stackoverflow.com/a/57561744
252
+ """
253
+
254
+ # Start off pointing at the original dictionary that was passed in.
255
+ here = data
256
+
257
+ # Turn the string of key names into a list of strings.
258
+ keys = dotted_key.split(".")
259
+
260
+ # For every key *before* the last one, we concentrate on navigating through the dictionary.
261
+ for key in keys[:-1]:
262
+ # Try to find here[key]. If it doesn't exist, create it with an empty dictionary. Then,
263
+ # update our `here` pointer to refer to the thing we just found (or created).
264
+ here = here.setdefault(key, {})
265
+
266
+ # Finally, set the final key to the given value
267
+ here[keys[-1]] = value
268
+
269
+
270
+ def load_data(config: Config) -> dict[str, Any]:
271
+ data: dict[str, Any] = {}
272
+
273
+ for path in collect_files(config.data):
274
+ if loader := DATA_LOADERS.get(path.suffix):
275
+ if not config.quiet:
276
+ print(f"Load data '{path}'")
277
+
278
+ data |= loader(path)
279
+ else:
280
+ if not config.quiet:
281
+ print(f"Skip unsupported data '{path}'")
282
+
283
+ for key, value in config.data_vars:
284
+ dict_nested_set(data, key, value)
285
+
286
+ return data
287
+
288
+
289
+ def process_loader(loader_name: str, env: Environment, data: Data):
290
+ cls: type[AbstractLoader] = import_string(loader_name)
291
+ sig_params = signature(cls).parameters
292
+ params: dict[str, Any] = {}
293
+
294
+ if sig_params.get("env"):
295
+ params["env"] = env
296
+ if sig_params.get("environment"):
297
+ params["environment"] = env
298
+ if sig_params.get("data"):
299
+ params["data"] = data
300
+
301
+ loader = cls(**params)
302
+
303
+ if hasattr(loader, "globals"):
304
+ env.globals.update({func.__name__: func for func in loader.globals()})
305
+
306
+ if hasattr(loader, "functions"):
307
+ env.globals.update({func.__name__: func for func in loader.functions()})
308
+
309
+ if hasattr(loader, "data"):
310
+ env.globals.update(loader.data())
311
+
312
+ if hasattr(loader, "extensions"):
313
+ load_extensions(env, loader.extensions())
314
+
315
+ if hasattr(loader, "filters"):
316
+ env.filters.update({func.__name__: func for func in loader.filters()})
317
+
318
+ if hasattr(loader, "tests"):
319
+ env.tests.update({func.__name__: func for func in loader.tests()})
320
+
321
+ if hasattr(loader, "policies"):
322
+ env.policies.update(loader.policies())
323
+
324
+
325
+ def render_path(
326
+ input: Path,
327
+ template_name: str,
328
+ output: Path,
329
+ config: Config,
330
+ env: Environment,
331
+ enforce_jinja_suffix: bool,
332
+ ) -> None:
333
+ if output.exists() and not config.force:
334
+ if not config.quiet:
335
+ print(f"Skip existing '{output}'")
336
+
337
+ elif input.suffix == config.jinja_suffix or not enforce_jinja_suffix:
338
+ template = env.get_template(template_name)
339
+ rendered = template.render()
340
+
341
+ # Write the rendered template if it has content
342
+ # Prevents empty macro definitions
343
+ if rendered.strip() == "" and not config.keep_empty:
344
+ if not config.quiet:
345
+ print(f"Skip empty '{input}'")
346
+ else:
347
+ if not config.quiet:
348
+ print(f"Render file '{input}' -> '{output}'")
349
+
350
+ with output.open("w") as fp:
351
+ fp.write(rendered)
352
+
353
+ if config.copy_metadata:
354
+ shutil.copystat(input, output)
355
+
356
+ else:
357
+ if not config.quiet:
358
+ print(f"Copy file '{input}' -> '{output}'")
359
+
360
+ shutil.copy2(input, output)
@@ -21,8 +21,8 @@ _ts_loaders = ts.default_loaders(
21
21
  )
22
22
 
23
23
 
24
- @click.command("makejinja")
25
- @click.version_option()
24
+ @click.command("makejinja", context_settings={"help_option_names": ("--help", "-h")})
25
+ @click.version_option(None, "--version", "-v")
26
26
  @ts.click_options(Config, _ts_loaders)
27
27
  def makejinja_cli(config: Config):
28
28
  """makejinja can be used to automatically generate files from [Jinja templates](https://jinja.palletsprojects.com/en/3.1.x/templates/).
@@ -1,7 +1,8 @@
1
- import typing as t
1
+ from collections import abc
2
2
  from enum import Enum
3
3
  from pathlib import Path
4
4
 
5
+ import immutables
5
6
  import rich_click as click
6
7
  import typed_settings as ts
7
8
  from jinja2 import (
@@ -26,6 +27,7 @@ from jinja2.defaults import (
26
27
  )
27
28
 
28
29
  __all__ = ["Config", "Delimiter", "Internal", "Prefix", "Whitespace", "Undefined"]
30
+ frozendict = immutables.Map
29
31
 
30
32
 
31
33
  class Undefined(Enum):
@@ -62,14 +64,14 @@ class Delimiter:
62
64
 
63
65
  @ts.settings(frozen=True)
64
66
  class Prefix:
65
- line_statement: t.Optional[str] = ts.option(
67
+ line_statement: str | None = ts.option(
66
68
  default=LINE_STATEMENT_PREFIX,
67
69
  help=(
68
70
  "If given and a string, this will be used as prefix for line based"
69
71
  " statements."
70
72
  ),
71
73
  )
72
- line_comment: t.Optional[str] = ts.option(
74
+ line_comment: str | None = ts.option(
73
75
  default=LINE_COMMENT_PREFIX,
74
76
  help=(
75
77
  "If given and a string, this will be used as prefix for line based"
@@ -164,19 +166,21 @@ class Whitespace:
164
166
  class Config:
165
167
  inputs: tuple[Path, ...] = ts.option(
166
168
  click={
167
- "type": click.Path(exists=True, file_okay=False, path_type=Path),
168
- "param_decls": "--input",
169
- "required": True,
169
+ "type": click.Path(exists=True, path_type=Path),
170
+ "param_decls": ("--input", "-i"),
170
171
  },
171
172
  help="""
172
- Path to a folder containing template files.
173
+ Path to a folder containing template files or a single template file.
173
174
  It is passed to Jinja's [FileSystemLoader](https://jinja.palletsprojects.com/en/3.1.x/api/#jinja2.FileSystemLoader) when creating the environment.
174
175
  **Note:** This option may be passed multiple times to pass a list of values.
175
176
  If a template exists in multiple inputs, the last value with be used.
176
177
  """,
177
178
  )
178
179
  output: Path = ts.option(
179
- click={"type": click.Path(file_okay=False, writable=True, path_type=Path)},
180
+ click={
181
+ "type": click.Path(path_type=Path),
182
+ "param_decls": ("--output", "-o"),
183
+ },
180
184
  help="""
181
185
  Path to a folder where the rendered templates are stored.
182
186
  makejinja preserves the relative paths in the process, meaning that you can even use it on nested directories.
@@ -184,7 +188,7 @@ class Config:
184
188
  )
185
189
  include_patterns: tuple[str, ...] = ts.option(
186
190
  default=("**/*",),
187
- click={"param_decls": "--include-pattern"},
191
+ click={"param_decls": ("--include-pattern", "--include", "-I")},
188
192
  help="""
189
193
  Glob patterns to search for files in `inputs`.
190
194
  Accepts all pattern supported by [`fnmatch`](https://docs.python.org/3/library/fnmatch.html#module-fnmatch).
@@ -195,7 +199,7 @@ class Config:
195
199
  )
196
200
  exclude_patterns: tuple[str, ...] = ts.option(
197
201
  default=tuple(),
198
- click={"param_decls": "--exclude-pattern"},
202
+ click={"param_decls": ("--exclude-pattern", "--exclude", "-E")},
199
203
  help="""
200
204
  Glob patterns pattern to exclude files matched.
201
205
  Applied against files discovered through `include_patterns`.
@@ -207,6 +211,7 @@ class Config:
207
211
  help="""
208
212
  File ending of Jinja template files.
209
213
  All files with this suffix in `inputs` matched by `pattern` are passed to the Jinja renderer.
214
+ This suffix is not enforced for individual files passed to `inputs`.
210
215
  **Note:** Should be provided *with* the leading dot.
211
216
  """,
212
217
  )
@@ -228,7 +233,7 @@ class Config:
228
233
  )
229
234
  copy_metadata: bool = ts.option(
230
235
  default=False,
231
- click={"param_decls": "--copy-metadata"},
236
+ click={"param_decls": ("--copy-metadata", "-m")},
232
237
  help="""
233
238
  Copy the file metadata (e.g., created/modified/permissions) from the input file using `shutil.copystat`
234
239
  """,
@@ -237,20 +242,33 @@ class Config:
237
242
  default=tuple(),
238
243
  click={
239
244
  "type": click.Path(exists=True, path_type=Path),
240
- "param_decls": "--data",
245
+ "param_decls": ("--data", "-d"),
241
246
  },
242
247
  help="""
243
248
  Load variables from yaml/yml/toml/json files for use in your Jinja templates.
244
- The defintions are passed to Jinja's `render` function.
249
+ The defintions are passed to Jinja as globals.
245
250
  Can either be a file or a folder containg files.
246
251
  **Note:** This option may be passed multiple times to pass a list of values.
247
252
  If multiple files are supplied, beware that previous declarations will be overwritten by newer ones.
248
253
  """,
249
254
  )
255
+ data_vars: abc.Mapping[str, str] = ts.option(
256
+ default=frozendict(),
257
+ click={
258
+ "param_decls": ("--data-var", "-D"),
259
+ "help": """
260
+ Load variables from the command line for use in your Jinja templates.
261
+ The definitions are applied after loading the data from files.
262
+ When using dotted keys (e.g., `foo.bar=42`), the value is converted to a nested dictionary.
263
+ Consequently, you can override values loaded from files.
264
+ **Note:** This option may be passed multiple times.
265
+ """,
266
+ },
267
+ )
250
268
  loaders: tuple[str, ...] = ts.option(
251
269
  default=tuple(),
252
270
  click={
253
- "param_decls": "--loader",
271
+ "param_decls": ("--loader", "-l"),
254
272
  },
255
273
  help="""
256
274
  Use custom Python code to adjust the used Jinja environment to your needs.
@@ -278,7 +296,7 @@ class Config:
278
296
  )
279
297
  extensions: tuple[str, ...] = ts.option(
280
298
  default=tuple(),
281
- click={"param_decls": "--extension"},
299
+ click={"param_decls": ("--extension", "-e")},
282
300
  help="""
283
301
  List of Jinja extensions to use as strings of import paths.
284
302
  An overview of the built-in ones can be found on the [project website](https://jinja.palletsprojects.com/en/3.1.x/extensions/).
@@ -295,6 +313,27 @@ class Config:
295
313
  """
296
314
  ),
297
315
  )
316
+ clean: bool = ts.option(
317
+ default=False,
318
+ click={"param_decls": ("--clean", "-c")},
319
+ help="""
320
+ Whether to remove the output folder if it exists.
321
+ """,
322
+ )
323
+ force: bool = ts.option(
324
+ default=False,
325
+ click={"param_decls": ("--force", "-f")},
326
+ help="""
327
+ Whether to overwrite existing files in the output folder.
328
+ """,
329
+ )
330
+ quiet: bool = ts.option(
331
+ default=False,
332
+ click={"param_decls": ("--quiet", "-q")},
333
+ help="""
334
+ Print no information about the rendering process.
335
+ """,
336
+ )
298
337
  delimiter: Delimiter = Delimiter()
299
338
  prefix: Prefix = Prefix()
300
339
  whitespace: Whitespace = Whitespace()
@@ -320,6 +359,7 @@ OPTION_GROUPS = {
320
359
  "name": "Jinja Environment",
321
360
  "options": [
322
361
  "--data",
362
+ "--data-var",
323
363
  "--loader",
324
364
  "--import-path",
325
365
  "--extension",
@@ -0,0 +1,59 @@
1
+ from collections import abc
2
+ from typing import Any
3
+
4
+ from jinja2 import Environment
5
+ from jinja2.ext import Extension
6
+
7
+ __all__ = (
8
+ "AbstractLoader",
9
+ "Environment",
10
+ "Extension",
11
+ "Extensions",
12
+ "Filter",
13
+ "Filters",
14
+ "Function",
15
+ "Functions",
16
+ "Test",
17
+ "Tests",
18
+ "Policies",
19
+ "MutableData",
20
+ "Data",
21
+ )
22
+
23
+ Extensions = abc.Sequence[type[Extension]]
24
+ Filter = abc.Callable[[Any], Any]
25
+ Filters = abc.Sequence[Filter]
26
+ Function = abc.Callable[..., Any]
27
+ Functions = abc.Sequence[Function]
28
+ Test = abc.Callable[..., Any]
29
+ Tests = abc.Sequence[Test]
30
+ Policies = abc.Mapping[str, Any]
31
+ MutableData = abc.MutableMapping[str, Any]
32
+ Data = abc.Mapping[str, Any]
33
+
34
+
35
+ class AbstractLoader:
36
+ def __init__(self, *, env: Environment, data: Data) -> None:
37
+ pass
38
+
39
+ def functions(self) -> Functions:
40
+ return []
41
+
42
+ def data(self) -> Data:
43
+ return {}
44
+
45
+ def filters(self) -> Filters:
46
+ return []
47
+
48
+ def tests(self) -> Tests:
49
+ return []
50
+
51
+ def policies(self) -> Policies:
52
+ return {}
53
+
54
+ def extensions(self) -> Extensions:
55
+ return []
56
+
57
+ # Deprecated: Use functions() and data() instead
58
+ def globals(self) -> Functions:
59
+ return []
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "makejinja"
3
- version = "2.2.0"
3
+ version = "2.3.1"
4
4
  description = "makejinja is a CLI tool and Python library to automatically generate files from Jinja2 templates. Use it to easily generate complex Home Assistant dashboards!"
5
5
  authors = ["Mirko Lenz <mirko@mirkolenz.com>"]
6
6
  license = "MIT"
@@ -50,6 +50,7 @@ jinja2 = "^3.1"
50
50
  pyyaml = "^6.0"
51
51
  rich-click = "^1.7"
52
52
  typed-settings = { version = "^23.1", extras = ["attrs", "cattrs", "click"] }
53
+ immutables = "^0.20"
53
54
 
54
55
  [tool.poetry.group.dev.dependencies]
55
56
  pytest = "^7.4"
@@ -59,11 +60,12 @@ pytest-cov = "^4.1"
59
60
  pdoc = "^14.1"
60
61
 
61
62
  [tool.pytest.ini_options]
62
- addopts = "--cov makejinja --cov-report term-missing -vv"
63
+ addopts = "--cov makejinja --cov-report term-missing"
63
64
 
64
65
  [tool.ruff]
65
66
  target-version = "py311"
66
- select = ["B", "E", "F", "W"]
67
+ select = ["E", "W", "F", "B", "UP"]
68
+ ignore = ["E501"]
67
69
 
68
70
  [tool.ruff.pydocstyle]
69
71
  convention = "google"
@@ -1,224 +0,0 @@
1
- import itertools
2
- import json
3
- import shutil
4
- import sys
5
- import tomllib
6
- import typing as t
7
- from inspect import signature
8
- from pathlib import Path
9
-
10
- import yaml
11
- from jinja2 import Environment, FileSystemLoader
12
- from jinja2.environment import load_extensions
13
- from jinja2.utils import import_string
14
- from rich import print
15
-
16
- from makejinja.config import Config
17
- from makejinja.loader import AbstractLoader
18
-
19
- __all__ = ["makejinja"]
20
-
21
-
22
- def makejinja(config: Config):
23
- """makejinja can be used to automatically generate files from [Jinja templates](https://jinja.palletsprojects.com/en/3.1.x/templates/)."""
24
-
25
- for path in config.import_paths:
26
- sys.path.append(str(path.resolve()))
27
-
28
- data = load_data(config)
29
-
30
- if config.output.is_dir():
31
- print(f"Remove output '{config.output}'")
32
- shutil.rmtree(config.output)
33
-
34
- config.output.mkdir()
35
-
36
- env = init_jinja_env(config)
37
-
38
- for loader in config.loaders:
39
- process_loader(loader, env, data)
40
-
41
- rendered_files: set[Path] = set()
42
- rendered_folders: dict[Path, Path] = {}
43
-
44
- for input_dir, include_pattern in itertools.product(
45
- config.inputs, config.include_patterns
46
- ):
47
- for input_path in sorted(input_dir.glob(include_pattern)):
48
- relative_path = input_path.relative_to(input_dir)
49
- output_path = generate_output_path(config, relative_path)
50
-
51
- if any(input_path.match(x) for x in config.exclude_patterns):
52
- print(f"Skip excluded '{input_path}'")
53
-
54
- elif input_path.is_file() and output_path not in rendered_files:
55
- render_path(input_path, relative_path, output_path, config, env, data)
56
- rendered_files.add(output_path)
57
-
58
- elif input_path.is_dir() and output_path not in rendered_folders:
59
- print(f"Create folder '{input_path}' -> '{output_path}'")
60
- output_path.mkdir()
61
- rendered_folders[output_path] = input_path
62
-
63
- # The metadata has to be copied after all files are rendered
64
- # Otherwise the mtime will be updated
65
- if config.copy_metadata:
66
- for output_path, input_path in rendered_folders.items():
67
- print(f"Copy metadata '{input_path}' -> '{output_path}'")
68
- shutil.copystat(input_path, output_path)
69
-
70
-
71
- def generate_output_path(config: Config, relative_path: Path) -> Path:
72
- output_file = config.output / relative_path
73
-
74
- if relative_path.suffix == config.jinja_suffix and not config.keep_jinja_suffix:
75
- output_file = output_file.with_suffix("")
76
-
77
- return output_file
78
-
79
-
80
- def init_jinja_env(config: Config) -> Environment:
81
- return Environment(
82
- loader=FileSystemLoader(config.inputs),
83
- extensions=config.extensions,
84
- block_start_string=config.delimiter.block_start,
85
- block_end_string=config.delimiter.block_end,
86
- variable_start_string=config.delimiter.variable_start,
87
- variable_end_string=config.delimiter.variable_end,
88
- comment_start_string=config.delimiter.comment_start,
89
- comment_end_string=config.delimiter.comment_end,
90
- line_statement_prefix=config.prefix.line_statement,
91
- line_comment_prefix=config.prefix.line_comment,
92
- trim_blocks=config.whitespace.trim_blocks,
93
- lstrip_blocks=config.whitespace.lstrip_blocks,
94
- newline_sequence=config.whitespace.newline_sequence, # type: ignore
95
- keep_trailing_newline=config.whitespace.keep_trailing_newline,
96
- optimized=config.internal.optimized,
97
- undefined=config.undefined.value,
98
- finalize=None,
99
- autoescape=config.internal.autoescape,
100
- cache_size=config.internal.cache_size,
101
- auto_reload=config.internal.auto_reload,
102
- bytecode_cache=None,
103
- enable_async=config.internal.enable_async,
104
- )
105
-
106
-
107
- def from_yaml(path: Path) -> dict[str, t.Any]:
108
- data = {}
109
-
110
- with path.open("rb") as fp:
111
- for doc in yaml.safe_load_all(fp):
112
- data |= doc
113
-
114
- return data
115
-
116
-
117
- def from_toml(path: Path) -> dict[str, t.Any]:
118
- with path.open("rb") as fp:
119
- return tomllib.load(fp)
120
-
121
-
122
- def from_json(path: Path) -> dict[str, t.Any]:
123
- with path.open("rb") as fp:
124
- return json.load(fp)
125
-
126
-
127
- DATA_LOADERS: dict[str, t.Callable[[Path], dict[str, t.Any]]] = {
128
- ".yaml": from_yaml,
129
- ".yml": from_yaml,
130
- ".toml": from_toml,
131
- ".json": from_json,
132
- }
133
-
134
-
135
- def collect_files(paths: t.Iterable[Path], pattern: str = "**/*") -> t.List[Path]:
136
- files = []
137
-
138
- for path in paths:
139
- if path.is_dir():
140
- files.extend(
141
- file
142
- for file in sorted(path.glob(pattern))
143
- if not file.name.startswith(".") and file.is_file()
144
- )
145
- elif path.is_file():
146
- files.append(path)
147
-
148
- return files
149
-
150
-
151
- def load_data(config: Config) -> dict[str, t.Any]:
152
- data: dict[str, t.Any] = {}
153
-
154
- for path in collect_files(config.data):
155
- if loader := DATA_LOADERS.get(path.suffix):
156
- data |= loader(path)
157
- else:
158
- print(f"Skip unsupported data '{path}'")
159
-
160
- return data
161
-
162
-
163
- def process_loader(
164
- loader_name: str, env: Environment, data: t.MutableMapping[str, t.Any]
165
- ):
166
- cls: t.Type[AbstractLoader] = import_string(loader_name)
167
- sig_params = signature(cls).parameters
168
- params: dict[str, t.Any] = {}
169
-
170
- if sig_params.get("env"):
171
- params["env"] = env
172
- if sig_params.get("environment"):
173
- params["environment"] = env
174
- if sig_params.get("data"):
175
- params["data"] = data
176
-
177
- loader = cls(**params)
178
-
179
- if hasattr(loader, "extensions"):
180
- load_extensions(env, loader.extensions())
181
-
182
- if hasattr(loader, "globals"):
183
- env.globals.update({func.__name__: func for func in loader.globals()})
184
-
185
- if hasattr(loader, "filters"):
186
- env.filters.update({func.__name__: func for func in loader.filters()})
187
-
188
- if hasattr(loader, "tests"):
189
- env.tests.update({func.__name__: func for func in loader.tests()})
190
-
191
- if hasattr(loader, "policies"):
192
- env.policies.update(loader.policies())
193
-
194
- if hasattr(loader, "data"):
195
- data.update(loader.data())
196
-
197
-
198
- def render_path(
199
- input: Path,
200
- relative_path: Path,
201
- output: Path,
202
- config: Config,
203
- env: Environment,
204
- data: dict[str, t.Any],
205
- ) -> None:
206
- if input.suffix == config.jinja_suffix:
207
- template = env.get_template(str(relative_path))
208
- rendered = template.render(data)
209
-
210
- # Write the rendered template if it has content
211
- # Prevents empty macro definitions
212
- if rendered.strip() == "" and not config.keep_empty:
213
- print(f"Skip empty '{input}'")
214
- else:
215
- print(f"Render file '{input}' -> '{output}'")
216
- with output.open("w") as fp:
217
- fp.write(rendered)
218
-
219
- if config.copy_metadata:
220
- shutil.copystat(input, output)
221
-
222
- else:
223
- print(f"Copy file '{input}' -> '{output}'")
224
- shutil.copy2(input, output)
@@ -1,54 +0,0 @@
1
- import typing as t
2
-
3
- from jinja2 import Environment
4
- from jinja2.ext import Extension
5
-
6
- __all__ = [
7
- "AbstractLoader",
8
- "Environment",
9
- "Extension",
10
- "Extensions",
11
- "Filter",
12
- "Filters",
13
- "Global",
14
- "Globals",
15
- "Test",
16
- "Tests",
17
- "Policies",
18
- "MutableData",
19
- "Data",
20
- ]
21
-
22
- Extensions = t.Sequence[t.Type[Extension]]
23
- Filter = t.Callable[[t.Any], t.Any]
24
- Filters = t.Sequence[Filter]
25
- Global = t.Callable[..., t.Any]
26
- Globals = t.Sequence[Global]
27
- Test = t.Callable[..., t.Any]
28
- Tests = t.Sequence[Test]
29
- Policies = t.Mapping[str, t.Any]
30
- MutableData = t.MutableMapping[str, t.Any]
31
- Data = t.Mapping[str, t.Any]
32
-
33
-
34
- class AbstractLoader:
35
- def __init__(self, *, env: Environment, data: MutableData) -> None:
36
- pass
37
-
38
- def filters(self) -> Filters:
39
- return []
40
-
41
- def globals(self) -> Globals:
42
- return []
43
-
44
- def tests(self) -> Tests:
45
- return []
46
-
47
- def policies(self) -> Policies:
48
- return {}
49
-
50
- def data(self) -> Data:
51
- return {}
52
-
53
- def extensions(self) -> Extensions:
54
- return []
File without changes
File without changes