makejinja 2.8.2__tar.gz → 2.9.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,23 +1,22 @@
1
- Metadata-Version: 2.3
1
+ Metadata-Version: 2.4
2
2
  Name: makejinja
3
- Version: 2.8.2
3
+ Version: 2.9.0
4
4
  Summary: Generate entire directory structures using Jinja templates with support for external data and custom plugins
5
- Keywords: jinja2,home-assistant,hassio,dashboard,lovelace,template,generator,cli,tool,library
5
+ Keywords: cli,dashboard,generator,hassio,home-assistant,jinja2,library,lovelace,template,tool
6
6
  Author: Mirko Lenz
7
7
  Author-email: Mirko Lenz <mirko@mirkolenz.com>
8
+ License-Expression: MIT
8
9
  Classifier: Development Status :: 5 - Production/Stable
9
10
  Classifier: Environment :: Console
10
11
  Classifier: Framework :: Pytest
11
12
  Classifier: Intended Audience :: Developers
12
13
  Classifier: Intended Audience :: End Users/Desktop
13
14
  Classifier: Intended Audience :: System Administrators
14
- Classifier: License :: OSI Approved :: MIT License
15
15
  Classifier: Natural Language :: English
16
16
  Classifier: Operating System :: OS Independent
17
- Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3
18
18
  Classifier: Programming Language :: Python :: 3.13
19
19
  Classifier: Programming Language :: Python :: 3.14
20
- Classifier: Programming Language :: Python :: 3
21
20
  Classifier: Topic :: File Formats
22
21
  Classifier: Topic :: Home Automation
23
22
  Classifier: Topic :: Software Development :: Code Generators
@@ -31,8 +30,8 @@ Requires-Dist: frozendict>=2,<3
31
30
  Requires-Dist: jinja2>=3,<4
32
31
  Requires-Dist: pyyaml>=6,<7
33
32
  Requires-Dist: rich-click>=1,<2
34
- Requires-Dist: typed-settings[attrs,cattrs,click]>=23,<26
35
- Requires-Python: >=3.12, <4
33
+ Requires-Dist: typed-settings[attrs,cattrs,click]>=25,<27
34
+ Requires-Python: >=3.13, <4
36
35
  Project-URL: Changelog, https://github.com/mirkolenz/makejinja/releases
37
36
  Project-URL: Documentation, https://mirkolenz.github.io/makejinja/makejinja/cli.html
38
37
  Project-URL: Homepage, https://mirkolenz.github.io/makejinja/
@@ -78,10 +77,10 @@ Our comprehensive [Home Assistant example](https://github.com/mirkolenz/makejinj
78
77
 
79
78
  ## Key Features
80
79
 
81
- - **Multi-Source Data Integration**: Load variables from YAML, TOML, and Python files, with support for file-specific data sources and runtime variable injection.
80
+ - **Multi-Source Data Integration**: Load variables from YAML, TOML, and Python files, with support for file-specific data sources, per-file namespacing, and runtime variable injection.
82
81
  - **Custom Template Delimiters**: Configure Jinja delimiters (e.g., `<% %>` instead of `{{ }}`) to avoid conflicts with target file formats like Home Assistant, Kubernetes, or Terraform.
83
82
  - **Flexible Directory Processing**: Process multiple input directories with complex nested structures, preserving hierarchy while applying powerful template transformations.
84
- - **Extensible Plugin System**: Create custom [plugins](https://mirkolenz.github.io/makejinja/makejinja/plugin.html#Plugin) with filters, functions, and path filtering logic for specialized requirements.
83
+ - **Extensible Plugin System**: Create custom [plugins](https://mirkolenz.github.io/makejinja/makejinja/plugin.html#Plugin) with filters, functions, data, tests, policies, extensions, and path filtering logic for specialized requirements.
85
84
  - **Production-Ready**: Comprehensive CLI interface, configuration file support, and Python library API for seamless workflow integration.
86
85
 
87
86
  ## Use Cases
@@ -36,10 +36,10 @@ Our comprehensive [Home Assistant example](https://github.com/mirkolenz/makejinj
36
36
 
37
37
  ## Key Features
38
38
 
39
- - **Multi-Source Data Integration**: Load variables from YAML, TOML, and Python files, with support for file-specific data sources and runtime variable injection.
39
+ - **Multi-Source Data Integration**: Load variables from YAML, TOML, and Python files, with support for file-specific data sources, per-file namespacing, and runtime variable injection.
40
40
  - **Custom Template Delimiters**: Configure Jinja delimiters (e.g., `<% %>` instead of `{{ }}`) to avoid conflicts with target file formats like Home Assistant, Kubernetes, or Terraform.
41
41
  - **Flexible Directory Processing**: Process multiple input directories with complex nested structures, preserving hierarchy while applying powerful template transformations.
42
- - **Extensible Plugin System**: Create custom [plugins](https://mirkolenz.github.io/makejinja/makejinja/plugin.html#Plugin) with filters, functions, and path filtering logic for specialized requirements.
42
+ - **Extensible Plugin System**: Create custom [plugins](https://mirkolenz.github.io/makejinja/makejinja/plugin.html#Plugin) with filters, functions, data, tests, policies, extensions, and path filtering logic for specialized requirements.
43
43
  - **Production-Ready**: Comprehensive CLI interface, configuration file support, and Python library API for seamless workflow integration.
44
44
 
45
45
  ## Use Cases
@@ -1,20 +1,21 @@
1
1
  [project]
2
2
  name = "makejinja"
3
- version = "2.8.2"
3
+ version = "2.9.0"
4
4
  description = "Generate entire directory structures using Jinja templates with support for external data and custom plugins"
5
- authors = [{ name = "Mirko Lenz", email = "mirko@mirkolenz.com" }]
6
5
  readme = "README.md"
6
+ requires-python = ">=3.13,<4"
7
+ license = "MIT"
7
8
  keywords = [
8
- "jinja2",
9
- "home-assistant",
10
- "hassio",
9
+ "cli",
11
10
  "dashboard",
11
+ "generator",
12
+ "hassio",
13
+ "home-assistant",
14
+ "jinja2",
15
+ "library",
12
16
  "lovelace",
13
17
  "template",
14
- "generator",
15
- "cli",
16
18
  "tool",
17
- "library",
18
19
  ]
19
20
  classifiers = [
20
21
  "Development Status :: 5 - Production/Stable",
@@ -23,13 +24,11 @@ classifiers = [
23
24
  "Intended Audience :: Developers",
24
25
  "Intended Audience :: End Users/Desktop",
25
26
  "Intended Audience :: System Administrators",
26
- "License :: OSI Approved :: MIT License",
27
27
  "Natural Language :: English",
28
28
  "Operating System :: OS Independent",
29
- "Programming Language :: Python :: 3.12",
29
+ "Programming Language :: Python :: 3",
30
30
  "Programming Language :: Python :: 3.13",
31
31
  "Programming Language :: Python :: 3.14",
32
- "Programming Language :: Python :: 3",
33
32
  "Topic :: File Formats",
34
33
  "Topic :: Home Automation",
35
34
  "Topic :: Software Development :: Code Generators",
@@ -40,38 +39,50 @@ classifiers = [
40
39
  "Topic :: Utilities",
41
40
  "Typing :: Typed",
42
41
  ]
43
- requires-python = ">=3.12,<4"
44
42
  dependencies = [
45
43
  "frozendict>=2,<3",
46
44
  "jinja2>=3,<4",
47
45
  "pyyaml>=6,<7",
48
46
  "rich-click>=1,<2",
49
- "typed-settings[attrs,cattrs,click]>=23,<26",
47
+ "typed-settings[attrs,cattrs,click]>=25,<27",
50
48
  ]
51
49
 
50
+ [[project.authors]]
51
+ name = "Mirko Lenz"
52
+ email = "mirko@mirkolenz.com"
53
+
52
54
  [project.urls]
53
- Repository = "https://github.com/mirkolenz/makejinja"
54
- Homepage = "https://mirkolenz.github.io/makejinja/"
55
+ Changelog = "https://github.com/mirkolenz/makejinja/releases"
55
56
  Documentation = "https://mirkolenz.github.io/makejinja/makejinja/cli.html"
57
+ Homepage = "https://mirkolenz.github.io/makejinja/"
56
58
  Issues = "https://github.com/mirkolenz/makejinja/issues"
57
- Changelog = "https://github.com/mirkolenz/makejinja/releases"
59
+ Repository = "https://github.com/mirkolenz/makejinja"
58
60
 
59
61
  [project.scripts]
60
62
  makejinja = "makejinja.cli:makejinja_cli"
61
63
 
62
64
  [dependency-groups]
63
- test = ["pytest>=9,<10", "pytest-cov>=7,<8"]
64
65
  docs = ["pdoc>=16,<17"]
66
+ test = [
67
+ "pytest>=9,<10",
68
+ "pytest-cov>=7,<8",
69
+ ]
65
70
 
66
71
  [build-system]
67
- requires = ["uv-build>=0.9,<1"]
72
+ requires = ["uv-build>=0.12,<1"]
68
73
  build-backend = "uv_build"
69
74
 
70
- [tool.uv]
71
- default-groups = ["test", "docs"]
72
-
73
75
  [tool.pytest]
74
- addopts = ["--cov=makejinja", "--cov-report=term-missing"]
76
+ addopts = [
77
+ "--cov=makejinja",
78
+ "--cov-report=term-missing",
79
+ ]
75
80
 
76
81
  [tool.ruff.lint.pydocstyle]
77
82
  convention = "google"
83
+
84
+ [tool.uv]
85
+ default-groups = [
86
+ "test",
87
+ "docs",
88
+ ]
@@ -0,0 +1,76 @@
1
+ [project]
2
+ name = "makejinja"
3
+ version = "2.9.0"
4
+ description = "Generate entire directory structures using Jinja templates with support for external data and custom plugins"
5
+ readme = "README.md"
6
+ requires-python = ">=3.13,<4"
7
+ license = "MIT"
8
+ authors = [{ name = "Mirko Lenz", email = "mirko@mirkolenz.com" }]
9
+ keywords = [
10
+ "cli",
11
+ "dashboard",
12
+ "generator",
13
+ "hassio",
14
+ "home-assistant",
15
+ "jinja2",
16
+ "library",
17
+ "lovelace",
18
+ "template",
19
+ "tool",
20
+ ]
21
+ classifiers = [
22
+ "Development Status :: 5 - Production/Stable",
23
+ "Environment :: Console",
24
+ "Framework :: Pytest",
25
+ "Intended Audience :: Developers",
26
+ "Intended Audience :: End Users/Desktop",
27
+ "Intended Audience :: System Administrators",
28
+ "Natural Language :: English",
29
+ "Operating System :: OS Independent",
30
+ "Programming Language :: Python :: 3",
31
+ "Programming Language :: Python :: 3.13",
32
+ "Programming Language :: Python :: 3.14",
33
+ "Topic :: File Formats",
34
+ "Topic :: Home Automation",
35
+ "Topic :: Software Development :: Code Generators",
36
+ "Topic :: Software Development :: Libraries :: Python Modules",
37
+ "Topic :: System :: Software Distribution",
38
+ "Topic :: System :: Systems Administration",
39
+ "Topic :: Text Processing :: Markup",
40
+ "Topic :: Utilities",
41
+ "Typing :: Typed",
42
+ ]
43
+ dependencies = [
44
+ "frozendict>=2,<3",
45
+ "jinja2>=3,<4",
46
+ "pyyaml>=6,<7",
47
+ "rich-click>=1,<2",
48
+ "typed-settings[attrs,cattrs,click]>=25,<27",
49
+ ]
50
+
51
+ [project.urls]
52
+ Changelog = "https://github.com/mirkolenz/makejinja/releases"
53
+ Documentation = "https://mirkolenz.github.io/makejinja/makejinja/cli.html"
54
+ Homepage = "https://mirkolenz.github.io/makejinja/"
55
+ Issues = "https://github.com/mirkolenz/makejinja/issues"
56
+ Repository = "https://github.com/mirkolenz/makejinja"
57
+
58
+ [project.scripts]
59
+ makejinja = "makejinja.cli:makejinja_cli"
60
+
61
+ [dependency-groups]
62
+ docs = ["pdoc>=16,<17"]
63
+ test = ["pytest>=9,<10", "pytest-cov>=7,<8"]
64
+
65
+ [build-system]
66
+ requires = ["uv-build>=0.12,<1"]
67
+ build-backend = "uv_build"
68
+
69
+ [tool.pytest]
70
+ addopts = ["--cov=makejinja", "--cov-report=term-missing"]
71
+
72
+ [tool.ruff.lint.pydocstyle]
73
+ convention = "google"
74
+
75
+ [tool.uv]
76
+ default-groups = ["test", "docs"]
@@ -16,4 +16,4 @@ from .app import makejinja
16
16
 
17
17
  loader = plugin
18
18
 
19
- __all__ = ["makejinja", "config", "plugin", "loader", "cli"]
19
+ __all__ = ["cli", "config", "loader", "makejinja", "plugin"]
@@ -8,16 +8,16 @@ import tomllib
8
8
  from collections import abc
9
9
  from inspect import signature
10
10
  from pathlib import Path
11
- from typing import Any
11
+ from typing import Any, cast
12
12
 
13
13
  import rich_click as click
14
14
  import yaml
15
15
  from jinja2 import BaseLoader, ChoiceLoader, DictLoader, Environment, FileSystemLoader
16
- from jinja2.environment import load_extensions
17
16
  from jinja2.utils import import_string
17
+ from typed_settings.dict_utils import set_path
18
18
 
19
- from makejinja.config import Config
20
- from makejinja.plugin import Data, MutableData, PathFilter, Plugin
19
+ from makejinja.config import Config, DataNamespace
20
+ from makejinja.plugin import Data, Functions, MutableData, PathFilter, Plugin
21
21
 
22
22
  __all__ = ["makejinja"]
23
23
 
@@ -244,7 +244,7 @@ def init_jinja_env(
244
244
  )
245
245
 
246
246
  env.globals.update(data)
247
- env.globals["env"] = os.environ
247
+ cast(dict[str, Any], env.globals)["env"] = os.environ
248
248
 
249
249
  return env
250
250
 
@@ -266,14 +266,7 @@ def from_yaml(path: Path) -> dict[str, Any]:
266
266
 
267
267
  def from_toml(path: Path) -> dict[str, Any]:
268
268
  with path.open("rb") as fp:
269
- data = tomllib.load(fp)
270
-
271
- if isinstance(data, abc.Mapping):
272
- return dict(data)
273
-
274
- raise TypeError(
275
- f"Expected TOML documents in '{path}' to be mappings but found {type(data).__name__}"
276
- )
269
+ return tomllib.load(fp)
277
270
 
278
271
 
279
272
  def from_json(path: Path) -> dict[str, Any]:
@@ -281,7 +274,7 @@ def from_json(path: Path) -> dict[str, Any]:
281
274
  data = json.load(fp)
282
275
 
283
276
  if isinstance(data, abc.Mapping):
284
- return data
277
+ return dict(data)
285
278
 
286
279
  raise TypeError(
287
280
  f"Expected JSON documents in '{path}' to be mappings but found {type(data).__name__}"
@@ -296,57 +289,89 @@ DATA_LOADERS: dict[str, abc.Callable[[Path], dict[str, Any]]] = {
296
289
  }
297
290
 
298
291
 
299
- def collect_files(paths: abc.Iterable[Path], pattern: str = "**/*") -> list[Path]:
300
- files = []
292
+ def collect_files(paths: abc.Iterable[Path]) -> abc.Iterator[tuple[Path, Path]]:
293
+ """Find all files in the given paths, yielding them together with their relative path."""
301
294
 
302
295
  for path in paths:
303
296
  if path.is_dir():
304
- files.extend(
305
- file
306
- for file in sorted(path.glob(pattern))
297
+ yield from (
298
+ (file, file.relative_to(path))
299
+ for file in sorted(path.glob("**/*"))
307
300
  if not file.name.startswith(".") and file.is_file()
308
301
  )
302
+
309
303
  elif path.is_file():
310
- files.append(path)
304
+ yield path, Path(path.name)
311
305
 
312
- return files
313
306
 
307
+ def to_identifier(name: str) -> str:
308
+ """Replace all characters that are invalid in a Python identifier with underscores.
314
309
 
315
- def dict_nested_set(data: MutableData, dotted_key: str, value: Any) -> None:
316
- """Given `foo`, 'key1.key2.key3', 'something', set foo['key1']['key2']['key3'] = 'something'
310
+ Jinja looks up globals by name, so only identifiers are reachable from templates.
317
311
 
318
- Source: https://stackoverflow.com/a/57561744
312
+ >>> to_identifier("my-hosting")
313
+ 'my_hosting'
314
+ >>> to_identifier("2nd")
315
+ '_2nd'
319
316
  """
320
317
 
321
- # Start off pointing at the original dictionary that was passed in.
322
- here = data
318
+ # The `_` prefix isolates the rules for the first character, leaving a continuation check
319
+ identifier = "".join(char if f"_{char}".isidentifier() else "_" for char in name)
320
+
321
+ return identifier if identifier.isidentifier() else f"_{identifier}"
322
+
323
+
324
+ def namespace_keys(
325
+ relative_path: Path, data_namespace: DataNamespace
326
+ ) -> tuple[str, ...]:
327
+ """Determine the nested keys under which the contents of a data file are stored."""
328
+
329
+ match data_namespace:
330
+ case DataNamespace.flat:
331
+ return ()
323
332
 
324
- # Turn the string of key names into a list of strings.
325
- keys = dotted_key.split(".")
333
+ case DataNamespace.stem:
334
+ parts = (relative_path.stem,)
326
335
 
327
- # For every key *before* the last one, we concentrate on navigating through the dictionary.
328
- for key in keys[:-1]:
329
- # Try to find here[key]. If it doesn't exist, create it with an empty dictionary. Then,
330
- # update our `here` pointer to refer to the thing we just found (or created).
331
- here = here.setdefault(key, {})
336
+ case DataNamespace.path:
337
+ parts = relative_path.with_suffix("").parts
332
338
 
333
- # Finally, set the final key to the given value
334
- here[keys[-1]] = value
339
+ return tuple(to_identifier(part) for part in parts)
340
+
341
+
342
+ def dict_nested_setdefault(data: MutableData, keys: abc.Iterable[str]) -> MutableData:
343
+ """Traverse nested dicts along `keys`, inserting empty dicts for missing entries.
344
+
345
+ >>> data = {}
346
+ >>> dict_nested_setdefault(data, ["key1", "key2"])["key3"] = "value"
347
+ >>> data
348
+ {'key1': {'key2': {'key3': 'value'}}}
349
+ """
350
+
351
+ for key in keys:
352
+ data = data.setdefault(key, {})
353
+
354
+ return data
335
355
 
336
356
 
337
357
  def load_data(config: Config) -> dict[str, Any]:
338
358
  data: dict[str, Any] = {}
339
359
 
340
- for path in collect_files(config.data):
360
+ for path, relative_path in collect_files(config.data):
341
361
  if loader := DATA_LOADERS.get(path.suffix):
342
- log(f"Load data '{path}'", config)
362
+ keys = namespace_keys(relative_path, config.data_namespace)
363
+ dotted_keys = ".".join(keys)
364
+ suffix = f" as '{dotted_keys}'" if keys else ""
365
+
366
+ log(f"Load data '{path}'{suffix}", config)
367
+
368
+ dict_nested_setdefault(data, keys).update(loader(path))
343
369
 
344
- data |= loader(path)
345
370
  else:
346
371
  log(f"Skip unsupported data '{path}'", config)
347
372
 
348
373
  for key, value in config.data_vars.items():
349
- dict_nested_set(data, key, value)
374
+ set_path(data, key, value)
350
375
 
351
376
  return data
352
377
 
@@ -371,6 +396,10 @@ def load_file_data(template_name: str, config: Config) -> dict[str, Any]:
371
396
  return file_data
372
397
 
373
398
 
399
+ def _named_functions(functions: Functions) -> dict[str, Any]:
400
+ return {cast(Any, func).__name__: func for func in functions}
401
+
402
+
374
403
  def load_plugin(
375
404
  plugin_name: str, env: Environment, data: Data, config: Config
376
405
  ) -> Plugin:
@@ -390,22 +419,23 @@ def load_plugin(
390
419
  plugin = cls(**params)
391
420
 
392
421
  if hasattr(plugin, "globals"):
393
- env.globals.update({func.__name__: func for func in plugin.globals()})
422
+ env.globals.update(_named_functions(plugin.globals()))
394
423
 
395
424
  if hasattr(plugin, "functions"):
396
- env.globals.update({func.__name__: func for func in plugin.functions()})
425
+ env.globals.update(_named_functions(plugin.functions()))
397
426
 
398
427
  if hasattr(plugin, "data"):
399
428
  env.globals.update(plugin.data())
400
429
 
401
430
  if hasattr(plugin, "extensions"):
402
- load_extensions(env, plugin.extensions())
431
+ for extension in plugin.extensions():
432
+ env.add_extension(extension)
403
433
 
404
434
  if hasattr(plugin, "filters"):
405
- env.filters.update({func.__name__: func for func in plugin.filters()})
435
+ env.filters.update(_named_functions(plugin.filters()))
406
436
 
407
437
  if hasattr(plugin, "tests"):
408
- env.tests.update({func.__name__: func for func in plugin.tests()})
438
+ env.tests.update(_named_functions(plugin.tests()))
409
439
 
410
440
  if hasattr(plugin, "policies"):
411
441
  env.policies.update(plugin.policies())
@@ -13,7 +13,7 @@ from .app import makejinja
13
13
 
14
14
  __all__: list[str] = []
15
15
 
16
- click.rich_click.USE_MARKDOWN = True
16
+ click.rich_click.TEXT_MARKUP = "markdown"
17
17
  click.rich_click.OPTION_GROUPS = OPTION_GROUPS
18
18
 
19
19
  _ts_loaders = ts.default_loaders(
@@ -23,8 +23,11 @@ _ts_loaders = ts.default_loaders(
23
23
 
24
24
  @click.command("makejinja", context_settings={"help_option_names": ("--help", "-h")})
25
25
  @click.version_option(None, "--version", "-v")
26
- @ts.click_options(Config, _ts_loaders)
27
- def makejinja_cli(config: Config):
26
+ # Without reloading, `makejinja.toml` is resolved against the working directory at import
27
+ # time. Collection options stay bound to it either way, as typed-settings injects their
28
+ # import-time defaults as if they were passed on the command line.
29
+ @ts.click_options(Config, _ts_loaders, reload_settings_on_invoke=True)
30
+ def makejinja_cli(config: Config) -> None:
28
31
  """makejinja can be used to automatically generate files from [Jinja templates](https://jinja.palletsprojects.com/en/3.1.x/templates/).
29
32
 
30
33
  Instead of passing CLI options, you can also write them to a file called `makejinja.toml` in your working directory.
@@ -1,6 +1,7 @@
1
1
  from collections import abc
2
- from enum import Enum
2
+ from enum import Enum, auto
3
3
  from pathlib import Path
4
+ from typing import Literal
4
5
 
5
6
  import rich_click as click
6
7
  import typed_settings as ts
@@ -24,7 +25,15 @@ from jinja2.defaults import (
24
25
  )
25
26
  from rich_click.utils import OptionGroupDict
26
27
 
27
- __all__ = ["Config", "Delimiter", "Internal", "Prefix", "Whitespace", "Undefined"]
28
+ __all__ = [
29
+ "Config",
30
+ "DataNamespace",
31
+ "Delimiter",
32
+ "Internal",
33
+ "Prefix",
34
+ "Undefined",
35
+ "Whitespace",
36
+ ]
28
37
 
29
38
 
30
39
  class Undefined(Enum):
@@ -36,6 +45,14 @@ class Undefined(Enum):
36
45
  strict = StrictUndefined
37
46
 
38
47
 
48
+ class DataNamespace(Enum):
49
+ """Where the contents of a data file are placed in the global namespace."""
50
+
51
+ flat = auto()
52
+ stem = auto()
53
+ path = auto()
54
+
55
+
39
56
  def _exclude_patterns_validator(instance, attribute, value) -> None:
40
57
  if any("**" in pattern for pattern in value):
41
58
  # todo: for next major release, raise ValueError instead of printing a warning
@@ -152,7 +169,7 @@ class Whitespace:
152
169
  If this is set to `True`, leading spaces and tabs are stripped from the start of a line to a block.
153
170
  """,
154
171
  )
155
- newline_sequence: str = ts.option(
172
+ newline_sequence: Literal["\n", "\r\n", "\r"] = ts.option(
156
173
  default=NEWLINE_SEQUENCE,
157
174
  click={"param_decls": "--newline-sequence"},
158
175
  help="""
@@ -206,7 +223,7 @@ class Config:
206
223
  """,
207
224
  )
208
225
  exclude_patterns: tuple[str, ...] = ts.option(
209
- default=tuple(),
226
+ default=(),
210
227
  click={"param_decls": ("--exclude-pattern", "--exclude", "-E")},
211
228
  validator=_exclude_patterns_validator,
212
229
  help="""
@@ -250,7 +267,7 @@ class Config:
250
267
  """,
251
268
  )
252
269
  data: tuple[Path, ...] = ts.option(
253
- default=tuple(),
270
+ default=(),
254
271
  click={
255
272
  "type": click.Path(exists=True, path_type=Path),
256
273
  "param_decls": ("--data", "-d"),
@@ -263,6 +280,19 @@ class Config:
263
280
  If multiple files are supplied, beware that previous declarations will be overwritten by newer ones.
264
281
  """,
265
282
  )
283
+ data_namespace: DataNamespace = ts.option(
284
+ default=DataNamespace.flat,
285
+ help="""
286
+ Determine where the contents of each `data` file are placed in the Jinja globals.
287
+ With `flat`, all files are merged into a single namespace, so identical keys are overwritten by files loaded later.
288
+ With `stem`, each file is nested under its name without the suffix, making `data/hosting.toml` available as `{{ hosting.provider }}`.
289
+ With `path`, each file is nested under its path relative to the `data` entry it was found in,
290
+ making `data/catalogs/hosting.toml` available as `{{ catalogs.hosting.provider }}` when passing `-d data`.
291
+ A `data` entry pointing to a file instead of a directory always uses its stem.
292
+ **Note:** Characters that are invalid in a Python identifier are replaced with `_` to keep the keys reachable in templates,
293
+ so `my-hosting.toml` is available as `{{ my_hosting.provider }}`.
294
+ """,
295
+ )
266
296
  data_vars: abc.Mapping[str, str] = ts.option(
267
297
  default=frozendict(),
268
298
  click={
@@ -290,7 +320,7 @@ class Config:
290
320
  },
291
321
  )
292
322
  loaders: tuple[str, ...] = ts.option(
293
- default=tuple(),
323
+ default=(),
294
324
  click={
295
325
  "param_decls": ("--loader", "-l"),
296
326
  "hidden": True,
@@ -298,17 +328,18 @@ class Config:
298
328
  help="Deprecated, use `--plugin` instead.",
299
329
  )
300
330
  plugins: tuple[str, ...] = ts.option(
301
- default=tuple(),
331
+ default=(),
302
332
  click={
303
333
  "param_decls": ("--plugin", "-p"),
304
334
  },
305
335
  help="""
306
336
  Use custom Python code to adjust the used Jinja environment to your needs.
307
337
  The specified Python file should export a **class** containing a subset of the following functions:
308
- `filters`, `globals`, `data`, and `extensions`.
309
- In addition, you may add an `__init__` function that receives two positional arguments:
310
- the created Jinja environment and the data parsed from the files supplied to makejinja's `data` option.
311
- This allows you to apply arbitrary logic to makejinja.
338
+ `filters`, `functions`, `data`, `tests`, `policies`, `extensions`, and `path_filters`.
339
+ The `globals` method is deprecated, use `functions` and `data` instead.
340
+ An optional `__init__` method can receive any subset of the keyword arguments `env` (or `environment`), `data`, and `config`.
341
+ These provide the Jinja environment, loaded global data, and makejinja configuration.
342
+ Before templates are loaded, the constructor can configure environment features such as `finalize`, custom loaders, and bytecode caching.
312
343
  An import path can be specified either in dotted notation (`your.custom.Plugin`)
313
344
  or with a colon as object delimiter (`your.custom:Plugin`).
314
345
  **Note:** This option may be passed multiple times to pass a list of values.
@@ -327,7 +358,7 @@ class Config:
327
358
  """,
328
359
  )
329
360
  extensions: tuple[str, ...] = ts.option(
330
- default=tuple(),
361
+ default=(),
331
362
  click={"param_decls": ("--extension", "-e")},
332
363
  help="""
333
364
  List of Jinja extensions to use as strings of import paths.
@@ -346,13 +377,13 @@ class Config:
346
377
  ),
347
378
  )
348
379
  exec_pre: tuple[str, ...] = ts.option(
349
- default=tuple(),
380
+ default=(),
350
381
  help="""
351
382
  Shell commands to execute before rendering.
352
383
  """,
353
384
  )
354
385
  exec_post: tuple[str, ...] = ts.option(
355
- default=tuple(),
386
+ default=(),
356
387
  help="""
357
388
  Shell commands to execute after rendering.
358
389
  """,
@@ -403,6 +434,7 @@ OPTION_GROUPS: dict[str, list[OptionGroupDict]] = {
403
434
  "name": "Jinja Environment",
404
435
  "options": [
405
436
  "--data",
437
+ "--data-namespace",
406
438
  "--data-var",
407
439
  "--file-data",
408
440
  "--plugin",
@@ -0,0 +1,76 @@
1
+ from collections import abc
2
+ from pathlib import Path
3
+ from typing import Any, Protocol
4
+
5
+ from jinja2 import Environment
6
+ from jinja2.ext import Extension
7
+
8
+ from makejinja.config import Config
9
+
10
+ __all__ = ["Plugin"]
11
+
12
+
13
+ type Extensions = abc.Sequence[type[Extension]]
14
+ type Filter = abc.Callable[..., Any]
15
+ type Filters = abc.Sequence[Filter]
16
+ type Function = abc.Callable[..., Any]
17
+ type Functions = abc.Sequence[Function]
18
+ type Test = abc.Callable[..., Any]
19
+ type Tests = abc.Sequence[Test]
20
+ type Policies = abc.Mapping[str, Any]
21
+ type MutableData = abc.MutableMapping[str, Any]
22
+ type Data = abc.Mapping[str, Any]
23
+ type PathFilter = abc.Callable[[Path], bool]
24
+ type PathFilters = abc.Sequence[PathFilter]
25
+
26
+
27
+ class Plugin(Protocol):
28
+ """Extend makejinja by implementing any subset of the plugin methods.
29
+
30
+ The optional constructor receives supported parameters by keyword.
31
+ It may request `env` (or `environment`), `data`, and `config`.
32
+ Configure the environment before loading any templates, including changes to
33
+ `finalize`, custom loaders, or bytecode caching.
34
+ """
35
+
36
+ def __init__(self, *, env: Environment, data: Data, config: Config) -> None:
37
+ pass
38
+
39
+ def functions(self) -> Functions:
40
+ """Return global template callables registered by their function names."""
41
+ return []
42
+
43
+ def data(self) -> Data:
44
+ """Return global template values that override existing values."""
45
+ return {}
46
+
47
+ def filters(self) -> Filters:
48
+ """Return template filters registered by their function names."""
49
+ return []
50
+
51
+ def tests(self) -> Tests:
52
+ """Return template tests registered by their function names."""
53
+ return []
54
+
55
+ def policies(self) -> Policies:
56
+ """Return overrides for Jinja environment policies."""
57
+ return {}
58
+
59
+ def extensions(self) -> Extensions:
60
+ """Return Jinja extension classes to register on the environment."""
61
+ return []
62
+
63
+ def path_filters(self) -> PathFilters:
64
+ """Return predicates that exclude discovered paths when they return false."""
65
+ return []
66
+
67
+ def globals(self) -> Functions:
68
+ """Return global callables.
69
+
70
+ Deprecated:
71
+ Use `functions()` and `data()` instead.
72
+ """
73
+ return []
74
+
75
+
76
+ AbstractLoader = Plugin
@@ -1,58 +0,0 @@
1
- from collections import abc
2
- from pathlib import Path
3
- from typing import Any, Protocol
4
-
5
- from jinja2 import Environment
6
- from jinja2.ext import Extension
7
-
8
- from makejinja.config import Config
9
-
10
- __all__ = ["Plugin"]
11
-
12
- Extensions = abc.Sequence[type[Extension]]
13
- Filter = abc.Callable[[Any], Any]
14
- Filters = abc.Sequence[Filter]
15
- Function = abc.Callable[..., Any]
16
- Functions = abc.Sequence[Function]
17
- Test = abc.Callable[..., Any]
18
- Tests = abc.Sequence[Test]
19
- Policies = abc.Mapping[str, Any]
20
- MutableData = abc.MutableMapping[str, Any]
21
- Data = abc.Mapping[str, Any]
22
- PathFilter = abc.Callable[[Path], bool]
23
- PathFilters = abc.Sequence[PathFilter]
24
-
25
-
26
- class Plugin(Protocol):
27
- """Extend the functionality of makejinja with a plugin implementing a subset of this protocol."""
28
-
29
- def __init__(self, *, env: Environment, data: Data, config: Config) -> None:
30
- pass
31
-
32
- def functions(self) -> Functions:
33
- return []
34
-
35
- def data(self) -> Data:
36
- return {}
37
-
38
- def filters(self) -> Filters:
39
- return []
40
-
41
- def tests(self) -> Tests:
42
- return []
43
-
44
- def policies(self) -> Policies:
45
- return {}
46
-
47
- def extensions(self) -> Extensions:
48
- return []
49
-
50
- def path_filters(self) -> PathFilters:
51
- return []
52
-
53
- # Deprecated: Use functions() and data() instead
54
- def globals(self) -> Functions:
55
- return []
56
-
57
-
58
- AbstractLoader = Plugin