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.
- jinja2_cli-1.0.0.dist-info/METADATA +115 -0
- jinja2_cli-1.0.0.dist-info/RECORD +6 -0
- jinja2_cli-1.0.0.dist-info/WHEEL +4 -0
- jinja2_cli-1.0.0.dist-info/entry_points.txt +3 -0
- jinja2cli/__init__.py +17 -0
- jinja2cli/cli.py +870 -0
|
@@ -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
|
+
[](https://pypi.org/project/jinja2-cli/)
|
|
108
|
+
[](https://formulae.brew.sh/formula/jinja2-cli)
|
|
109
|
+
[](https://search.nixos.org/packages?query=jinja2-cli)
|
|
110
|
+
[](https://aur.archlinux.org/packages/jinja2-cli)
|
|
111
|
+
[](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,,
|
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()
|