makejinja 2.8.3__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,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: makejinja
3
- Version: 2.8.3
3
+ Version: 2.9.0
4
4
  Summary: Generate entire directory structures using Jinja templates with support for external data and custom plugins
5
5
  Keywords: cli,dashboard,generator,hassio,home-assistant,jinja2,library,lovelace,template,tool
6
6
  Author: Mirko Lenz
@@ -77,7 +77,7 @@ Our comprehensive [Home Assistant example](https://github.com/mirkolenz/makejinj
77
77
 
78
78
  ## Key Features
79
79
 
80
- - **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.
81
81
  - **Custom Template Delimiters**: Configure Jinja delimiters (e.g., `<% %>` instead of `{{ }}`) to avoid conflicts with target file formats like Home Assistant, Kubernetes, or Terraform.
82
82
  - **Flexible Directory Processing**: Process multiple input directories with complex nested structures, preserving hierarchy while applying powerful template transformations.
83
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.
@@ -36,7 +36,7 @@ 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
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.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "makejinja"
3
- version = "2.8.3"
3
+ version = "2.9.0"
4
4
  description = "Generate entire directory structures using Jinja templates with support for external data and custom plugins"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.13,<4"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "makejinja"
3
- version = "2.8.3"
3
+ version = "2.9.0"
4
4
  description = "Generate entire directory structures using Jinja templates with support for external data and custom plugins"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.13,<4"
@@ -14,8 +14,9 @@ import rich_click as click
14
14
  import yaml
15
15
  from jinja2 import BaseLoader, ChoiceLoader, DictLoader, Environment, FileSystemLoader
16
16
  from jinja2.utils import import_string
17
+ from typed_settings.dict_utils import set_path
17
18
 
18
- from makejinja.config import Config
19
+ from makejinja.config import Config, DataNamespace
19
20
  from makejinja.plugin import Data, Functions, MutableData, PathFilter, Plugin
20
21
 
21
22
  __all__ = ["makejinja"]
@@ -288,57 +289,89 @@ DATA_LOADERS: dict[str, abc.Callable[[Path], dict[str, Any]]] = {
288
289
  }
289
290
 
290
291
 
291
- def collect_files(paths: abc.Iterable[Path], pattern: str = "**/*") -> list[Path]:
292
- 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."""
293
294
 
294
295
  for path in paths:
295
296
  if path.is_dir():
296
- files.extend(
297
- file
298
- for file in sorted(path.glob(pattern))
297
+ yield from (
298
+ (file, file.relative_to(path))
299
+ for file in sorted(path.glob("**/*"))
299
300
  if not file.name.startswith(".") and file.is_file()
300
301
  )
302
+
301
303
  elif path.is_file():
302
- files.append(path)
304
+ yield path, Path(path.name)
303
305
 
304
- return files
305
306
 
307
+ def to_identifier(name: str) -> str:
308
+ """Replace all characters that are invalid in a Python identifier with underscores.
306
309
 
307
- def dict_nested_set(data: MutableData, dotted_key: str, value: Any) -> None:
308
- """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.
309
311
 
310
- Source: https://stackoverflow.com/a/57561744
312
+ >>> to_identifier("my-hosting")
313
+ 'my_hosting'
314
+ >>> to_identifier("2nd")
315
+ '_2nd'
311
316
  """
312
317
 
313
- # Start off pointing at the original dictionary that was passed in.
314
- 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 ()
332
+
333
+ case DataNamespace.stem:
334
+ parts = (relative_path.stem,)
335
+
336
+ case DataNamespace.path:
337
+ parts = relative_path.with_suffix("").parts
315
338
 
316
- # Turn the string of key names into a list of strings.
317
- keys = dotted_key.split(".")
339
+ return tuple(to_identifier(part) for part in parts)
318
340
 
319
- # For every key *before* the last one, we concentrate on navigating through the dictionary.
320
- for key in keys[:-1]:
321
- # Try to find here[key]. If it doesn't exist, create it with an empty dictionary. Then,
322
- # update our `here` pointer to refer to the thing we just found (or created).
323
- here = here.setdefault(key, {})
324
341
 
325
- # Finally, set the final key to the given value
326
- here[keys[-1]] = value
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
327
355
 
328
356
 
329
357
  def load_data(config: Config) -> dict[str, Any]:
330
358
  data: dict[str, Any] = {}
331
359
 
332
- for path in collect_files(config.data):
360
+ for path, relative_path in collect_files(config.data):
333
361
  if loader := DATA_LOADERS.get(path.suffix):
334
- 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))
335
369
 
336
- data |= loader(path)
337
370
  else:
338
371
  log(f"Skip unsupported data '{path}'", config)
339
372
 
340
373
  for key, value in config.data_vars.items():
341
- dict_nested_set(data, key, value)
374
+ set_path(data, key, value)
342
375
 
343
376
  return data
344
377
 
@@ -23,7 +23,10 @@ _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)
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)
27
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
 
@@ -1,5 +1,5 @@
1
1
  from collections import abc
2
- from enum import Enum
2
+ from enum import Enum, auto
3
3
  from pathlib import Path
4
4
  from typing import Literal
5
5
 
@@ -25,7 +25,15 @@ from jinja2.defaults import (
25
25
  )
26
26
  from rich_click.utils import OptionGroupDict
27
27
 
28
- __all__ = ["Config", "Delimiter", "Internal", "Prefix", "Undefined", "Whitespace"]
28
+ __all__ = [
29
+ "Config",
30
+ "DataNamespace",
31
+ "Delimiter",
32
+ "Internal",
33
+ "Prefix",
34
+ "Undefined",
35
+ "Whitespace",
36
+ ]
29
37
 
30
38
 
31
39
  class Undefined(Enum):
@@ -37,6 +45,14 @@ class Undefined(Enum):
37
45
  strict = StrictUndefined
38
46
 
39
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
+
40
56
  def _exclude_patterns_validator(instance, attribute, value) -> None:
41
57
  if any("**" in pattern for pattern in value):
42
58
  # todo: for next major release, raise ValueError instead of printing a warning
@@ -264,6 +280,19 @@ class Config:
264
280
  If multiple files are supplied, beware that previous declarations will be overwritten by newer ones.
265
281
  """,
266
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
+ )
267
296
  data_vars: abc.Mapping[str, str] = ts.option(
268
297
  default=frozendict(),
269
298
  click={
@@ -405,6 +434,7 @@ OPTION_GROUPS: dict[str, list[OptionGroupDict]] = {
405
434
  "name": "Jinja Environment",
406
435
  "options": [
407
436
  "--data",
437
+ "--data-namespace",
408
438
  "--data-var",
409
439
  "--file-data",
410
440
  "--plugin",