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.
- {makejinja-2.2.0 → makejinja-2.3.1}/PKG-INFO +11 -9
- {makejinja-2.2.0 → makejinja-2.3.1}/README.md +9 -8
- {makejinja-2.2.0 → makejinja-2.3.1}/makejinja/__init__.py +2 -0
- makejinja-2.3.1/makejinja/app.py +360 -0
- {makejinja-2.2.0 → makejinja-2.3.1}/makejinja/cli.py +2 -2
- {makejinja-2.2.0 → makejinja-2.3.1}/makejinja/config.py +55 -15
- makejinja-2.3.1/makejinja/loader.py +59 -0
- {makejinja-2.2.0 → makejinja-2.3.1}/pyproject.toml +5 -3
- makejinja-2.2.0/makejinja/app.py +0 -224
- makejinja-2.2.0/makejinja/loader.py +0 -54
- {makejinja-2.2.0 → makejinja-2.3.1}/LICENSE +0 -0
- {makejinja-2.2.0 → makejinja-2.3.1}/makejinja/__main__.py +0 -0
- {makejinja-2.2.0 → makejinja-2.3.1}/makejinja/py.typed +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: makejinja
|
|
3
|
-
Version: 2.
|
|
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="
|
|
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="
|
|
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](
|
|
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](
|
|
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
|
|
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 --
|
|
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
|
|
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="
|
|
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="
|
|
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](
|
|
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](
|
|
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
|
|
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 --
|
|
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
|
|
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`.
|
|
@@ -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
|
-
|
|
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:
|
|
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:
|
|
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,
|
|
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={
|
|
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
|
|
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.
|
|
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
|
|
63
|
+
addopts = "--cov makejinja --cov-report term-missing"
|
|
63
64
|
|
|
64
65
|
[tool.ruff]
|
|
65
66
|
target-version = "py311"
|
|
66
|
-
select = ["
|
|
67
|
+
select = ["E", "W", "F", "B", "UP"]
|
|
68
|
+
ignore = ["E501"]
|
|
67
69
|
|
|
68
70
|
[tool.ruff.pydocstyle]
|
|
69
71
|
convention = "google"
|
makejinja-2.2.0/makejinja/app.py
DELETED
|
@@ -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
|
|
File without changes
|