jinja2-cli 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,115 @@
1
+ Metadata-Version: 2.3
2
+ Name: jinja2-cli
3
+ Version: 1.0.0
4
+ Summary: The CLI interface to Jinja2
5
+ Author: Matt Robenolt
6
+ Author-email: Matt Robenolt <m@robenolt.com>
7
+ License: Copyright (c) 2017-2026, Matt Robenolt
8
+ All rights reserved.
9
+
10
+ Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
11
+
12
+ * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
13
+ * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
14
+
15
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3 :: Only
18
+ Classifier: Programming Language :: Python :: 3.8
19
+ Classifier: Programming Language :: Python :: 3.9
20
+ Classifier: Programming Language :: Python :: 3.10
21
+ Classifier: Programming Language :: Python :: 3.11
22
+ Classifier: Programming Language :: Python :: 3.12
23
+ Classifier: License :: OSI Approved :: BSD License
24
+ Classifier: Operating System :: OS Independent
25
+ Requires-Dist: jinja2>=3.1
26
+ Requires-Dist: hjson ; extra == 'hjson'
27
+ Requires-Dist: json5 ; extra == 'json5'
28
+ Requires-Dist: tomli ; python_full_version < '3.11' and extra == 'toml'
29
+ Requires-Dist: xmltodict ; extra == 'xml'
30
+ Requires-Dist: pyyaml ; extra == 'yaml'
31
+ Requires-Python: >=3.8
32
+ Project-URL: Homepage, https://github.com/mattrobenolt/jinja2-cli
33
+ Project-URL: Issues, https://github.com/mattrobenolt/jinja2-cli/issues
34
+ Provides-Extra: hjson
35
+ Provides-Extra: json5
36
+ Provides-Extra: toml
37
+ Provides-Extra: xml
38
+ Provides-Extra: yaml
39
+ Description-Content-Type: text/markdown
40
+
41
+ # $ jinja2
42
+
43
+ The CLI for [Jinja2](https://jinja.palletsprojects.com/).
44
+
45
+ ```
46
+ $ jinja2 template.j2 data.json
47
+ $ curl -s http://api.example.com | jinja2 template.j2
48
+ ```
49
+
50
+ ## Install
51
+ ```
52
+ $ uv tool install jinja2-cli
53
+ $ pip install jinja2-cli
54
+ ```
55
+
56
+ ## Formats
57
+ Built-in: JSON, INI, ENV, querystring, TOML (Python 3.11+)
58
+
59
+ Optional formats via extras:
60
+ ```
61
+ $ pip install jinja2-cli[yaml]
62
+ $ pip install jinja2-cli[xml]
63
+ $ pip install jinja2-cli[hjson]
64
+ $ pip install jinja2-cli[json5]
65
+ ```
66
+
67
+ ## Features
68
+ - Read data from files or stdin
69
+ - Define variables inline with `-D key=value`
70
+ - Custom Jinja2 extensions
71
+ - **Import custom filters** - see [Custom Filters](#custom-filters) below
72
+ - Full control over Jinja2 environment options
73
+
74
+ Run `jinja2 --help` for all options, or see [docs/](docs/) for full documentation.
75
+
76
+ ## Custom Filters
77
+
78
+ Extend Jinja2 with your own filters or use Ansible's extensive filter library:
79
+
80
+ ```bash
81
+ # Use custom filters
82
+ $ jinja2 template.j2 data.json -F myfilters
83
+
84
+ # Use Ansible filters
85
+ $ jinja2 template.j2 data.json -F ansible.plugins.filter.core
86
+ ```
87
+
88
+ Example filter module:
89
+ ```python
90
+ # myfilters.py
91
+ def reverse(s):
92
+ return s[::-1]
93
+
94
+ def shout(s):
95
+ return s.upper() + "!"
96
+ ```
97
+
98
+ See [docs/filters.md](docs/filters.md) for complete documentation and examples.
99
+
100
+ ## Used by
101
+ - [Dangerzone](https://github.com/freedomofpress/dangerzone) by Freedom of the Press Foundation
102
+ - [Elastic](https://github.com/elastic/logstash-docker) Docker images (Logstash, Kibana, Beats)
103
+ - [ScyllaDB](https://github.com/scylladb/scylla-machine-image) CloudFormation templates
104
+ - [800+ more](https://github.com/mattrobenolt/jinja2-cli/network/dependents) on GitHub
105
+
106
+ ## Available in
107
+ [![PyPI](https://img.shields.io/pypi/v/jinja2-cli)](https://pypi.org/project/jinja2-cli/)
108
+ [![Homebrew](https://img.shields.io/homebrew/v/jinja2-cli)](https://formulae.brew.sh/formula/jinja2-cli)
109
+ [![nixpkgs](https://img.shields.io/badge/nixpkgs-jinja2--cli-blue)](https://search.nixos.org/packages?query=jinja2-cli)
110
+ [![AUR](https://img.shields.io/aur/version/jinja2-cli)](https://aur.archlinux.org/packages/jinja2-cli)
111
+ [![Alpine](https://img.shields.io/badge/Alpine-jinja2--cli-0D597F?logo=alpinelinux&logoColor=fff)](https://pkgs.alpinelinux.org/package/edge/community/x86_64/jinja2-cli)
112
+
113
+ ## Learn more
114
+ - [Jinja2 as a Command Line Application](https://thejeshgn.com/2021/12/07/jinja2-command-line-application/)
115
+ - [Combining jinja2-cli with jq and environment variables](https://www.zufallsheld.de/2025/06/30/templating-jinja-cli)
@@ -0,0 +1,6 @@
1
+ jinja2cli/__init__.py,sha256=LUCoYQracozRP2uFVwGXbvBa_m6TwpkSiP1aLIoBXuY,297
2
+ jinja2cli/cli.py,sha256=sLA6GX7nORmY1hqTl2uP1InWtUV0LKmtrl2NVFeHwkA,26285
3
+ jinja2_cli-1.0.0.dist-info/WHEEL,sha256=KSLUh82mDPEPk0Bx0ScXlWL64bc8KmzIPNcpQZFV-6E,79
4
+ jinja2_cli-1.0.0.dist-info/entry_points.txt,sha256=cdD0DR2ndXe1hxMczmSXvtbBJWf5BguAGgGXEXJoxqA,43
5
+ jinja2_cli-1.0.0.dist-info/METADATA,sha256=SecLn5HY6VNGZt3p0YgaTvYtpRQhawjgmafVw3AVWus,5050
6
+ jinja2_cli-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.9.22
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ jinja2 = jinja2cli:main
3
+
jinja2cli/__init__.py ADDED
@@ -0,0 +1,17 @@
1
+ """
2
+ jinja2-cli
3
+ ==========
4
+
5
+ License: BSD, see LICENSE for more details.
6
+ """
7
+
8
+ __author__ = "Matt Robenolt"
9
+
10
+ from importlib.metadata import PackageNotFoundError, version
11
+
12
+ try:
13
+ __version__ = version("jinja2-cli")
14
+ except PackageNotFoundError:
15
+ __version__ = "dev"
16
+
17
+ from .cli import main # NOQA
jinja2cli/cli.py ADDED
@@ -0,0 +1,870 @@
1
+ """
2
+ jinja2-cli
3
+ ==========
4
+
5
+ License: BSD, see LICENSE for more details.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import importlib
12
+ import importlib.util
13
+ import os
14
+ import sys
15
+ from collections.abc import Iterable, Iterator, Sequence
16
+ from types import ModuleType
17
+ from typing import IO, Any, Callable, Tuple, Type, Union
18
+
19
+
20
+ class InvalidDataFormat(Exception):
21
+ pass
22
+
23
+
24
+ class InvalidUsage(Exception):
25
+ pass
26
+
27
+
28
+ class InvalidInputData(Exception):
29
+ pass
30
+
31
+
32
+ class MalformedJSON(InvalidInputData):
33
+ pass
34
+
35
+
36
+ class MalformedINI(InvalidInputData):
37
+ pass
38
+
39
+
40
+ class MalformedYAML(InvalidInputData):
41
+ pass
42
+
43
+
44
+ class MalformedQuerystring(InvalidInputData):
45
+ pass
46
+
47
+
48
+ class MalformedToml(InvalidDataFormat):
49
+ pass
50
+
51
+
52
+ class MalformedXML(InvalidDataFormat):
53
+ pass
54
+
55
+
56
+ class MalformedEnv(InvalidDataFormat):
57
+ pass
58
+
59
+
60
+ class MalformedHJSON(InvalidDataFormat):
61
+ pass
62
+
63
+
64
+ class MalformedJSON5(InvalidDataFormat):
65
+ pass
66
+
67
+
68
+ ParserFn = Callable[[str], Any]
69
+ FormatLoadResult = Tuple[ParserFn, Type[Exception], Type[Exception]]
70
+ ExtensionSpec = Union[str, ModuleType, Type[Any]]
71
+
72
+
73
+ def get_format(fmt: str) -> FormatLoadResult:
74
+ try:
75
+ return formats[fmt]()
76
+ except ModuleNotFoundError:
77
+ raise InvalidDataFormat(fmt)
78
+
79
+
80
+ def has_format(fmt: str) -> bool:
81
+ try:
82
+ get_format(fmt)
83
+ return True
84
+ except InvalidDataFormat:
85
+ return False
86
+
87
+
88
+ def get_available_formats() -> Iterator[str]:
89
+ for fmt in formats.keys():
90
+ if has_format(fmt):
91
+ yield fmt
92
+ yield "auto"
93
+
94
+
95
+ def load_json() -> FormatLoadResult:
96
+ import json
97
+
98
+ return json.loads, json.JSONDecodeError, MalformedJSON
99
+
100
+
101
+ def load_ini() -> FormatLoadResult:
102
+ import configparser
103
+
104
+ def _parse_ini(data: str) -> dict:
105
+ from io import StringIO
106
+
107
+ class MyConfigParser(configparser.ConfigParser):
108
+ def as_dict(self) -> dict:
109
+ return {section: dict(self.items(section, raw=True)) for section in self.sections()}
110
+
111
+ p = MyConfigParser()
112
+ p.read_file(StringIO(data))
113
+ return p.as_dict()
114
+
115
+ return _parse_ini, configparser.Error, MalformedINI
116
+
117
+
118
+ def load_yaml() -> FormatLoadResult:
119
+ from yaml import YAMLError, load
120
+
121
+ try:
122
+ from yaml import CSafeLoader as SafeLoader
123
+ except ImportError:
124
+ from yaml import SafeLoader
125
+
126
+ def yaml_loader(stream: str) -> Any:
127
+ return load(stream, Loader=SafeLoader)
128
+
129
+ return yaml_loader, YAMLError, MalformedYAML
130
+
131
+
132
+ def load_querystring() -> FormatLoadResult:
133
+ from urllib.parse import parse_qs
134
+
135
+ def _parse_qs(data: str) -> dict:
136
+ """Extend urlparse to allow objects in dot syntax.
137
+
138
+ >>> _parse_qs('user.first_name=Matt&user.last_name=Robenolt')
139
+ {'user': {'first_name': 'Matt', 'last_name': 'Robenolt'}}
140
+ """
141
+ dict_ = {}
142
+ for k, v in parse_qs(data).items():
143
+ v = list(map(lambda x: x.strip(), v))
144
+ v = v[0] if len(v) == 1 else v
145
+ if "." in k:
146
+ pieces = k.split(".")
147
+ cur = dict_
148
+ for idx, piece in enumerate(pieces):
149
+ if piece not in cur:
150
+ cur[piece] = {}
151
+ if idx == len(pieces) - 1:
152
+ cur[piece] = v
153
+ cur = cur[piece]
154
+ else:
155
+ dict_[k] = v
156
+ return dict_
157
+
158
+ return _parse_qs, Exception, MalformedQuerystring
159
+
160
+
161
+ def load_toml() -> FormatLoadResult:
162
+ try:
163
+ import tomllib # type: ignore[unresolved-import]
164
+ except ModuleNotFoundError:
165
+ import tomli as tomllib # type: ignore[unresolved-import]
166
+
167
+ return tomllib.loads, Exception, MalformedToml
168
+
169
+
170
+ def load_xml() -> FormatLoadResult:
171
+ from xml.parsers import expat
172
+
173
+ import xmltodict
174
+
175
+ return xmltodict.parse, expat.ExpatError, MalformedXML
176
+
177
+
178
+ def parse_env(data: str) -> dict:
179
+ """
180
+ Parse an envfile format of key=value pairs that are newline separated.
181
+ Supports quoted values with escape sequences.
182
+ """
183
+ dict_ = {}
184
+ for line in data.splitlines():
185
+ line = line.lstrip()
186
+ # ignore empty or commented lines
187
+ if not line or line[:1] == "#":
188
+ continue
189
+ k, v = line.split("=", 1)
190
+
191
+ # Handle quoted values
192
+ if v and v[0] in ('"', "'"):
193
+ quote = v[0]
194
+ if len(v) > 1 and v[-1] == quote:
195
+ # Remove surrounding quotes
196
+ v = v[1:-1]
197
+ # Decode escape sequences for double-quoted values
198
+ if quote == '"':
199
+ v = v.encode().decode("unicode-escape")
200
+
201
+ dict_[k] = v
202
+ return dict_
203
+
204
+
205
+ def load_env() -> FormatLoadResult:
206
+ return parse_env, Exception, MalformedEnv
207
+
208
+
209
+ def load_hjson() -> FormatLoadResult:
210
+ import hjson
211
+
212
+ return hjson.loads, Exception, MalformedHJSON
213
+
214
+
215
+ def load_json5() -> FormatLoadResult:
216
+ import json5
217
+
218
+ return json5.loads, Exception, MalformedJSON5
219
+
220
+
221
+ # Global list of available format parsers on your system
222
+ # mapped to the callable/Exception to parse a string into a dict
223
+ formats = {
224
+ "json": load_json,
225
+ "ini": load_ini,
226
+ "yaml": load_yaml,
227
+ "yml": load_yaml,
228
+ "querystring": load_querystring,
229
+ "toml": load_toml,
230
+ "xml": load_xml,
231
+ "env": load_env,
232
+ "hjson": load_hjson,
233
+ "json5": load_json5,
234
+ }
235
+
236
+
237
+ def discover_filters(filter_path: str, base_dir: str | None = None) -> dict[str, Callable]:
238
+ import inspect
239
+
240
+ discovered_filters: dict[str, Callable] = {}
241
+ module = None
242
+ object_name = None
243
+
244
+ # Try importing the full path as a module first
245
+ try:
246
+ if importlib.util.find_spec(filter_path) is not None:
247
+ module = importlib.import_module(filter_path)
248
+ except (ModuleNotFoundError, ValueError):
249
+ pass
250
+
251
+ if module is None:
252
+ # Not a module, try splitting into module.object
253
+ module_name, object_name = split_extension_path(filter_path)
254
+ try:
255
+ if importlib.util.find_spec(module_name) is not None:
256
+ module = importlib.import_module(module_name)
257
+ except (ModuleNotFoundError, ValueError):
258
+ pass
259
+
260
+ if module is None and base_dir:
261
+ module = load_local_module(module_name, base_dir)
262
+
263
+ if module is None:
264
+ raise ModuleNotFoundError(f"Cannot import filter module from {filter_path!r}")
265
+
266
+ if object_name:
267
+ # Import specific object from module
268
+ try:
269
+ filter_fn = getattr(module, object_name)
270
+ except AttributeError as exc:
271
+ raise ModuleNotFoundError(f"Cannot import {object_name!r} from module") from exc
272
+
273
+ # Check if it's a class with a filters() method (e.g., Ansible FilterModule)
274
+ if inspect.isclass(filter_fn):
275
+ if hasattr(filter_fn, "filters"):
276
+ instance = filter_fn()
277
+ if callable(instance.filters):
278
+ result = instance.filters()
279
+ if isinstance(result, dict):
280
+ discovered_filters.update(result)
281
+ return discovered_filters
282
+
283
+ # If it's a callable, use it as a filter with its function name
284
+ if callable(filter_fn):
285
+ # If it returns a dict, merge all filters, otherwise use function name
286
+ if object_name in ("filters", "get_filters", "load_filters") or object_name.startswith(
287
+ "load_"
288
+ ):
289
+ # Convention: these return dict of filters
290
+ result = filter_fn()
291
+ if isinstance(result, dict):
292
+ discovered_filters.update(result)
293
+ else:
294
+ discovered_filters[filter_fn.__name__] = filter_fn
295
+ else:
296
+ discovered_filters[filter_fn.__name__] = filter_fn
297
+ elif isinstance(filter_fn, dict):
298
+ # If it's already a dict, merge it
299
+ discovered_filters.update(filter_fn)
300
+ else:
301
+ # No specific object, look for common patterns
302
+ # Check for FilterModule class (Ansible pattern)
303
+ if hasattr(module, "FilterModule") and inspect.isclass(module.FilterModule):
304
+ if hasattr(module.FilterModule, "filters"):
305
+ instance = module.FilterModule()
306
+ if callable(instance.filters):
307
+ result = instance.filters()
308
+ if isinstance(result, dict):
309
+ discovered_filters.update(result)
310
+ elif hasattr(module, "filters") and isinstance(module.filters, dict):
311
+ discovered_filters.update(module.filters)
312
+ elif hasattr(module, "filters") and callable(module.filters):
313
+ result = module.filters()
314
+ if isinstance(result, dict):
315
+ discovered_filters.update(result)
316
+ else:
317
+ # Auto-discover all public callables in the module
318
+ for name, obj in inspect.getmembers(module):
319
+ if not name.startswith("_") and callable(obj) and inspect.isfunction(obj):
320
+ discovered_filters[name] = obj
321
+
322
+ return discovered_filters
323
+
324
+
325
+ def render(
326
+ template_path: str | None,
327
+ data: dict,
328
+ extensions: list[ExtensionSpec],
329
+ filters: list[str] | None = None,
330
+ strict: bool = False,
331
+ trim_blocks: bool = False,
332
+ lstrip_blocks: bool = False,
333
+ autoescape: bool = False,
334
+ variable_start_string: str | None = None,
335
+ variable_end_string: str | None = None,
336
+ block_start_string: str | None = None,
337
+ block_end_string: str | None = None,
338
+ comment_start_string: str | None = None,
339
+ comment_end_string: str | None = None,
340
+ line_statement_prefix: str | None = None,
341
+ line_comment_prefix: str | None = None,
342
+ newline_sequence: str | None = None,
343
+ search_paths: list[str] | None = None,
344
+ template_string: str | None = None,
345
+ base_dir: str | None = None,
346
+ ) -> str:
347
+ from jinja2 import (
348
+ Environment,
349
+ FileSystemLoader,
350
+ StrictUndefined,
351
+ UndefinedError,
352
+ )
353
+
354
+ env_kwargs: dict = {
355
+ "extensions": extensions,
356
+ "keep_trailing_newline": True,
357
+ "trim_blocks": trim_blocks,
358
+ "lstrip_blocks": lstrip_blocks,
359
+ }
360
+
361
+ # Only use FileSystemLoader when we have a template path (not streaming)
362
+ if template_path is not None:
363
+ template_dir = os.path.dirname(template_path) or "."
364
+ paths = [template_dir] + (search_paths or [])
365
+ env_kwargs["loader"] = FileSystemLoader(paths)
366
+
367
+ if autoescape:
368
+ env_kwargs["autoescape"] = True
369
+ if variable_start_string is not None:
370
+ env_kwargs["variable_start_string"] = variable_start_string
371
+ if variable_end_string is not None:
372
+ env_kwargs["variable_end_string"] = variable_end_string
373
+ if block_start_string is not None:
374
+ env_kwargs["block_start_string"] = block_start_string
375
+ if block_end_string is not None:
376
+ env_kwargs["block_end_string"] = block_end_string
377
+ if comment_start_string is not None:
378
+ env_kwargs["comment_start_string"] = comment_start_string
379
+ if comment_end_string is not None:
380
+ env_kwargs["comment_end_string"] = comment_end_string
381
+ if line_statement_prefix is not None:
382
+ env_kwargs["line_statement_prefix"] = line_statement_prefix
383
+ if line_comment_prefix is not None:
384
+ env_kwargs["line_comment_prefix"] = line_comment_prefix
385
+ if newline_sequence is not None:
386
+ env_kwargs["newline_sequence"] = newline_sequence
387
+
388
+ env = Environment(**env_kwargs)
389
+ if strict:
390
+ env.undefined = StrictUndefined
391
+
392
+ # Load custom filters
393
+ if filters:
394
+ filter_base_dir = base_dir or os.getcwd()
395
+ for filter_path in filters:
396
+ discovered = discover_filters(filter_path, filter_base_dir)
397
+ env.filters.update(discovered)
398
+
399
+ # Add environ global
400
+ def _environ(key: str):
401
+ value = os.environ.get(key)
402
+ if value is None and strict:
403
+ raise UndefinedError(f"environment variable '{key}' is not defined")
404
+ return value
405
+
406
+ env.globals["environ"] = _environ
407
+ env.globals["get_context"] = lambda: data
408
+
409
+ if template_string is not None:
410
+ return env.from_string(template_string).render(data)
411
+ assert template_path is not None
412
+ return env.get_template(os.path.basename(template_path)).render(data)
413
+
414
+
415
+ def split_extension_path(extension: str) -> tuple[str, str | None]:
416
+ if ":" in extension:
417
+ module_name, object_name = extension.split(":", 1)
418
+ return module_name, object_name or None
419
+ module_name, _, object_name = extension.rpartition(".")
420
+ if module_name:
421
+ return module_name, object_name or None
422
+ return extension, None
423
+
424
+
425
+ def load_local_module(module_name: str, base_dir: str) -> ModuleType | None:
426
+ module_path = os.path.join(base_dir, *module_name.split("."))
427
+ for candidate in (f"{module_path}.py", os.path.join(module_path, "__init__.py")):
428
+ if not os.path.isfile(candidate):
429
+ continue
430
+ spec = importlib.util.spec_from_file_location(module_name, candidate)
431
+ if spec is None or spec.loader is None:
432
+ return None
433
+ module = importlib.util.module_from_spec(spec)
434
+ sys.modules[module_name] = module
435
+ spec.loader.exec_module(module)
436
+ return module
437
+ return None
438
+
439
+
440
+ def resolve_extension(extension: ExtensionSpec, base_dir: str) -> ExtensionSpec:
441
+ if not isinstance(extension, str):
442
+ return extension
443
+ if extension.startswith("jinja2.ext."):
444
+ return extension
445
+ module_name, object_name = split_extension_path(extension)
446
+ if object_name:
447
+ if importlib.util.find_spec(module_name) is not None:
448
+ module = importlib.import_module(module_name)
449
+ else:
450
+ module = load_local_module(module_name, base_dir)
451
+ if module is None:
452
+ raise ModuleNotFoundError(f"Cannot import {module_name!r}")
453
+ try:
454
+ return getattr(module, object_name)
455
+ except AttributeError as exc:
456
+ raise ModuleNotFoundError(
457
+ f"Cannot import {object_name!r} from {module_name!r}"
458
+ ) from exc
459
+ if importlib.util.find_spec(module_name) is not None:
460
+ return extension
461
+ module = load_local_module(module_name, base_dir)
462
+ if module is None:
463
+ return extension
464
+ return module
465
+
466
+
467
+ def cli(opts: argparse.Namespace, args: Sequence[str]) -> int:
468
+ template_string: str | None = None
469
+ template_path: str | None = None
470
+
471
+ if opts.stream:
472
+ # Stream mode: read template from stdin, all args are data files
473
+ template_string = sys.stdin.read()
474
+ data_files = args
475
+ else:
476
+ # Normal mode: first arg is template, rest are data files
477
+ template_path_arg = args[0]
478
+ data_files = args[1:]
479
+ template_path = os.path.abspath(template_path_arg)
480
+
481
+ data: dict = {}
482
+
483
+ # Determine if we're reading from stdin or files
484
+ if not data_files:
485
+ # No data files specified
486
+ if opts.stream:
487
+ # In stream mode, stdin is used for template, so no data
488
+ data_files = []
489
+ else:
490
+ # Normal mode, read data from stdin
491
+ data_files = ["-"]
492
+
493
+ # Check for invalid mixing of stdin and files
494
+ has_stdin = any(f in ("-", "") for f in data_files)
495
+ if has_stdin and len(data_files) > 1:
496
+ raise InvalidUsage("cannot mix stdin (-) with file arguments")
497
+
498
+ # Load and merge multiple data files
499
+ for data_file in data_files:
500
+ format = opts.format
501
+ data_content = ""
502
+
503
+ if data_file in ("-", ""):
504
+ if data_file == "-" or (data_file == "" and not sys.stdin.isatty()):
505
+ data_content = sys.stdin.read()
506
+ if format == "auto":
507
+ # default to yaml first if available since yaml
508
+ # is a superset of json
509
+ if has_format("yaml"):
510
+ format = "yaml"
511
+ else:
512
+ format = "json"
513
+ else:
514
+ path = os.path.join(os.getcwd(), os.path.expanduser(data_file))
515
+ if format == "auto":
516
+ ext = os.path.splitext(path)[1][1:]
517
+ if has_format(ext):
518
+ format = ext
519
+ else:
520
+ raise InvalidDataFormat(ext)
521
+
522
+ with open(path) as fp:
523
+ data_content = fp.read()
524
+
525
+ if data_content:
526
+ try:
527
+ fn, except_exc, raise_exc = get_format(format)
528
+ except InvalidDataFormat:
529
+ if format in ("yml", "yaml"):
530
+ raise InvalidDataFormat(f"{format}: install pyyaml to fix")
531
+ if format == "toml":
532
+ raise InvalidDataFormat("toml: install tomli to fix")
533
+ if format == "xml":
534
+ raise InvalidDataFormat("xml: install xmltodict to fix")
535
+ if format == "hjson":
536
+ raise InvalidDataFormat("hjson: install hjson to fix")
537
+ if format == "json5":
538
+ raise InvalidDataFormat("json5: install json5 to fix")
539
+ raise
540
+ try:
541
+ parsed = fn(data_content) or {}
542
+ deep_merge(data, parsed)
543
+ except except_exc:
544
+ raise raise_exc(f"{data_content[:60]} ...")
545
+
546
+ extensions = []
547
+ for ext in opts.extensions:
548
+ # Allow shorthand and assume if it's not a module
549
+ # path, it's probably trying to use builtin from jinja2
550
+ if "." not in ext and ":" not in ext:
551
+ ext = f"jinja2.ext.{ext}"
552
+ extensions.append(resolve_extension(ext, os.getcwd()))
553
+
554
+ # Use only a specific section if needed
555
+ if opts.section:
556
+ section = opts.section
557
+ if section in data:
558
+ data = data[section]
559
+ else:
560
+ raise InvalidUsage(f"unknown section: {section}")
561
+
562
+ deep_merge(data, parse_kv_string(opts.D or []))
563
+
564
+ rendered = render(
565
+ template_path,
566
+ data,
567
+ extensions,
568
+ filters=opts.filters,
569
+ strict=opts.strict,
570
+ trim_blocks=opts.trim_blocks,
571
+ lstrip_blocks=opts.lstrip_blocks,
572
+ autoescape=opts.autoescape,
573
+ variable_start_string=opts.variable_start,
574
+ variable_end_string=opts.variable_end,
575
+ block_start_string=opts.block_start,
576
+ block_end_string=opts.block_end,
577
+ comment_start_string=opts.comment_start,
578
+ comment_end_string=opts.comment_end,
579
+ line_statement_prefix=opts.line_statement_prefix,
580
+ line_comment_prefix=opts.line_comment_prefix,
581
+ newline_sequence=opts.newline_sequence,
582
+ search_paths=opts.search_paths,
583
+ template_string=template_string,
584
+ )
585
+
586
+ if opts.outfile is None:
587
+ out = sys.stdout
588
+ else:
589
+ out = open(opts.outfile, "w")
590
+
591
+ out.write(rendered)
592
+ out.flush()
593
+ return 0
594
+
595
+
596
+ def deep_merge(target: dict, source: dict) -> dict:
597
+ for key, value in source.items():
598
+ if key in target and isinstance(target[key], dict) and isinstance(value, dict):
599
+ deep_merge(target[key], value)
600
+ else:
601
+ target[key] = value
602
+ return target
603
+
604
+
605
+ def parse_kv_string(pairs: Iterable[str]) -> dict:
606
+ dict_ = {}
607
+ for pair in pairs:
608
+ try:
609
+ k, v = pair.split("=", 1)
610
+ except ValueError:
611
+ k, v = pair, None
612
+
613
+ # Support dot notation for nested dicts
614
+ if "." in k:
615
+ keys = k.split(".")
616
+ current = dict_
617
+ for key in keys[:-1]:
618
+ if key not in current:
619
+ current[key] = {}
620
+ current = current[key]
621
+ current[keys[-1]] = v
622
+ else:
623
+ dict_[k] = v
624
+ return dict_
625
+
626
+
627
+ FORMAT_HELP_SENTINEL = "__JINJA2CLI_FORMAT_HELP__"
628
+
629
+
630
+ class ArgumentParser(argparse.ArgumentParser):
631
+ def format_help(self) -> str:
632
+ help_text = super().format_help()
633
+ help_text = help_text.replace(
634
+ FORMAT_HELP_SENTINEL,
635
+ "format of input variables: " + ", ".join(sorted(list(get_available_formats()))),
636
+ )
637
+ return help_text
638
+
639
+
640
+ class VersionAction(argparse.Action):
641
+ def __call__(
642
+ self,
643
+ parser: argparse.ArgumentParser,
644
+ namespace: argparse.Namespace,
645
+ values: Any,
646
+ option_string: str | None = None,
647
+ ) -> None:
648
+ from jinja2 import __version__ as jinja_version
649
+
650
+ from jinja2cli import __version__
651
+
652
+ parser.exit(message=f"jinja2-cli v{__version__}\n - Jinja2 v{jinja_version}\n")
653
+
654
+
655
+ def run() -> int:
656
+ parser = ArgumentParser(usage="%(prog)s [options] <input template> <input data>")
657
+ parser.add_argument(
658
+ "--version",
659
+ action=VersionAction,
660
+ nargs=0,
661
+ help="show program's version number and exit",
662
+ )
663
+ parser.add_argument(
664
+ "-f",
665
+ "--format",
666
+ help=FORMAT_HELP_SENTINEL,
667
+ dest="format",
668
+ default="auto",
669
+ )
670
+ parser.add_argument(
671
+ "-e",
672
+ "--extension",
673
+ help="extra jinja2 extensions to load",
674
+ dest="extensions",
675
+ action="append",
676
+ default=["do", "loopcontrols"],
677
+ )
678
+ parser.add_argument(
679
+ "-F",
680
+ "--filter",
681
+ help="extra jinja2 filters to load (e.g., mymodule.myfilter)",
682
+ dest="filters",
683
+ action="append",
684
+ default=[],
685
+ )
686
+ parser.add_argument(
687
+ "-D",
688
+ dest="D",
689
+ help="Define template variable in the form of key=value",
690
+ action="append",
691
+ metavar="key=value",
692
+ )
693
+ parser.add_argument(
694
+ "-I",
695
+ "--include",
696
+ help="Add directory to template search path",
697
+ dest="search_paths",
698
+ action="append",
699
+ default=[],
700
+ metavar="DIR",
701
+ )
702
+ parser.add_argument(
703
+ "-s",
704
+ "--section",
705
+ help="Use only this section from the configuration",
706
+ dest="section",
707
+ )
708
+ parser.add_argument(
709
+ "--strict",
710
+ help="Disallow undefined variables to be used within the template",
711
+ dest="strict",
712
+ action="store_true",
713
+ )
714
+ parser.add_argument(
715
+ "-o",
716
+ "--outfile",
717
+ help="File to use for output. Default is stdout.",
718
+ dest="outfile",
719
+ metavar="FILE",
720
+ )
721
+ parser.add_argument(
722
+ "--trim-blocks",
723
+ help="Trim first newline after a block",
724
+ dest="trim_blocks",
725
+ action="store_true",
726
+ )
727
+ parser.add_argument(
728
+ "--lstrip-blocks",
729
+ help="Strip leading spaces and tabs from block start",
730
+ dest="lstrip_blocks",
731
+ action="store_true",
732
+ )
733
+ parser.add_argument(
734
+ "--autoescape",
735
+ help="Enable autoescape",
736
+ dest="autoescape",
737
+ action="store_true",
738
+ )
739
+ parser.add_argument(
740
+ "--variable-start",
741
+ help="Variable start string",
742
+ dest="variable_start",
743
+ )
744
+ parser.add_argument(
745
+ "--variable-end",
746
+ help="Variable end string",
747
+ dest="variable_end",
748
+ )
749
+ parser.add_argument(
750
+ "--block-start",
751
+ help="Block start string",
752
+ dest="block_start",
753
+ )
754
+ parser.add_argument(
755
+ "--block-end",
756
+ help="Block end string",
757
+ dest="block_end",
758
+ )
759
+ parser.add_argument(
760
+ "--comment-start",
761
+ help="Comment start string",
762
+ dest="comment_start",
763
+ )
764
+ parser.add_argument(
765
+ "--comment-end",
766
+ help="Comment end string",
767
+ dest="comment_end",
768
+ )
769
+ parser.add_argument(
770
+ "--line-statement-prefix",
771
+ help="Line statement prefix",
772
+ dest="line_statement_prefix",
773
+ )
774
+ parser.add_argument(
775
+ "--line-comment-prefix",
776
+ help="Line comment prefix",
777
+ dest="line_comment_prefix",
778
+ )
779
+ parser.add_argument(
780
+ "--newline-sequence",
781
+ help=r'Newline sequence (e.g., "\n" or "\r\n")',
782
+ dest="newline_sequence",
783
+ )
784
+ parser.add_argument(
785
+ "-S",
786
+ "--stream",
787
+ help="Read template from stdin (no template file argument)",
788
+ action="store_true",
789
+ dest="stream",
790
+ )
791
+ parser.add_argument("template", nargs="?", help=argparse.SUPPRESS)
792
+ parser.add_argument("data", nargs="*", help=argparse.SUPPRESS)
793
+ opts = parser.parse_args()
794
+ args = [opts.template] + list(opts.data) if opts.template else []
795
+
796
+ opts.extensions = set(opts.extensions)
797
+
798
+ if not opts.stream:
799
+ if len(args) == 0:
800
+ parser.print_help()
801
+ return 1
802
+
803
+ # Without the second argv, assume they maybe want to read from stdin
804
+ if len(args) == 1:
805
+ args.append("")
806
+
807
+ if opts.format not in formats and opts.format != "auto":
808
+ raise InvalidDataFormat(opts.format)
809
+
810
+ return cli(opts, args)
811
+
812
+
813
+ # borrowed from https://github.com/python/cpython/blob/3.14/Lib/_colorize.py#L274
814
+ def can_colorize(*, file: IO[str] | IO[bytes] | None = None) -> bool:
815
+ def _safe_getenv(k: str, fallback: str | None = None) -> str | None:
816
+ """Exception-safe environment retrieval. See gh-128636."""
817
+ try:
818
+ return os.environ.get(k, fallback)
819
+ except Exception:
820
+ return fallback
821
+
822
+ if file is None:
823
+ file = sys.stdout
824
+
825
+ if not sys.flags.ignore_environment:
826
+ if _safe_getenv("PYTHON_COLORS") == "0":
827
+ return False
828
+ if _safe_getenv("PYTHON_COLORS") == "1":
829
+ return True
830
+ if _safe_getenv("NO_COLOR"):
831
+ return False
832
+ if _safe_getenv("FORCE_COLOR"):
833
+ return True
834
+ if _safe_getenv("TERM") == "dumb":
835
+ return False
836
+
837
+ if not hasattr(file, "fileno"):
838
+ return False
839
+
840
+ if sys.platform == "win32":
841
+ try:
842
+ import nt
843
+
844
+ if not nt._supports_virtual_terminal():
845
+ return False
846
+ except (ImportError, AttributeError):
847
+ return False
848
+
849
+ try:
850
+ return os.isatty(file.fileno())
851
+ except OSError:
852
+ return hasattr(file, "isatty") and file.isatty()
853
+
854
+
855
+ def main() -> None:
856
+ try:
857
+ raise SystemExit(run())
858
+ except KeyboardInterrupt:
859
+ raise SystemExit(130)
860
+ except Exception as e:
861
+ file = sys.stderr
862
+ if can_colorize(file=file):
863
+ print(f"\x1b[1;35m{type(e).__name__}\x1b[0m: \x1b[35m{e}\x1b[0m", file=file)
864
+ else:
865
+ print(f"{type(e).__name__}: {e}", file=file)
866
+ raise SystemExit(1)
867
+
868
+
869
+ if __name__ == "__main__":
870
+ main()