├── .gitignore ├── .travis.yml ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.md ├── jinja2cli ├── __init__.py └── cli.py ├── samples ├── environ.jinja2 ├── sample.jinja2 ├── sample.json └── sample.sh ├── setup.cfg ├── setup.py └── tests ├── __init__.py ├── files └── template.j2 ├── test_jinja2cli.py └── test_parse_qs.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[cod] 2 | 3 | # C extensions 4 | *.so 5 | 6 | # Packages 7 | *.egg 8 | *.egg-info 9 | dist 10 | build 11 | eggs 12 | parts 13 | bin 14 | var 15 | sdist 16 | develop-eggs 17 | .installed.cfg 18 | lib 19 | lib64 20 | 21 | # Installer logs 22 | pip-log.txt 23 | 24 | # Unit test / coverage reports 25 | .coverage 26 | .tox 27 | nosetests.xml 28 | .cache 29 | 30 | #Translations 31 | *.mo 32 | 33 | #Mr Developer 34 | .mr.developer.cfg 35 | 36 | .pytest_cache 37 | dist/ 38 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: python 3 | dist: xenial 4 | python: 5 | - '2.7' 6 | - '3.4' 7 | - '3.5' 8 | - '3.6' 9 | - '3.7' 10 | 11 | install: pip install -e .[yaml,toml,tests,xml,hjson,json5] 12 | 13 | script: 14 | - pytest -v 15 | - flake8 jinja2cli tests 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017, Matt Robenolt 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 5 | 6 | * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 7 | * 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. 8 | 9 | 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. 10 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md 2 | include LICENSE 3 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | test: 2 | pytest -v 3 | 4 | publish: clean 5 | python setup.py sdist bdist_wheel 6 | twine upload -s dist/* 7 | 8 | clean: 9 | rm -rf *.egg-info *.egg dist build .pytest_cache 10 | 11 | .PHONY: test publish clean 12 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # $ jinja2 2 | A CLI interface to Jinja2 3 | ``` 4 | $ jinja2 helloworld.tmpl data.json --format=json 5 | $ cat data.json | jinja2 helloworld.tmpl 6 | $ curl -s http://httpbin.org/ip | jinja2 helloip.tmpl 7 | $ curl -s http://httpbin.org/ip | jinja2 helloip.tmpl > helloip.html 8 | ``` 9 | 10 | ## Install 11 | `$ pip install jinja2-cli` 12 | 13 | ## Usage 14 | ``` 15 | Usage: jinja2 [options] 16 | 17 | Options: 18 | --version show program's version number and exit 19 | -h, --help show this help message and exit 20 | --format=FORMAT format of input variables: auto, ini, json, 21 | querystring, yaml, yml 22 | -e EXTENSIONS, --extension=EXTENSIONS 23 | extra jinja2 extensions to load 24 | -D key=value Define template variable in the form of key=value 25 | -s SECTION, --section=SECTION 26 | Use only this section from the configuration 27 | --strict Disallow undefined variables to be used within the 28 | template 29 | ``` 30 | 31 | ## Optional YAML support 32 | If `PyYAML` is present, you can use YAML as an input data source. 33 | 34 | `$ pip install jinja2-cli[yaml]` 35 | 36 | ## Optional TOML support 37 | If `toml` is present, you can use TOML as an input data source. 38 | 39 | `$ pip install jinja2-cli[toml]` 40 | 41 | ## Optional XML support 42 | If `xmltodict` is present, you can use XML as an input data source. 43 | 44 | `$ pip install jinja2-cli[xml]` 45 | 46 | ## Optional HJSON support 47 | If `hjson` is present, you can use HJSON as an input data source. 48 | 49 | `$ pip install jinja2-cli[hjson]` 50 | 51 | ## Optional JSON5 support 52 | If `json5` is present, you can use JSON5 as an input data source. 53 | 54 | `$ pip install jinja2-cli[json5]` 55 | 56 | ## TODO 57 | * Variable inheritance and overrides 58 | * Tests! 59 | -------------------------------------------------------------------------------- /jinja2cli/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | jinja2-cli 3 | ========== 4 | 5 | License: BSD, see LICENSE for more details. 6 | """ 7 | 8 | __author__ = "Matt Robenolt" 9 | __version__ = "0.8.2" 10 | 11 | from .cli import main # NOQA 12 | -------------------------------------------------------------------------------- /jinja2cli/cli.py: -------------------------------------------------------------------------------- 1 | """ 2 | jinja2-cli 3 | ========== 4 | 5 | License: BSD, see LICENSE for more details. 6 | """ 7 | 8 | import warnings 9 | 10 | warnings.filterwarnings("ignore") 11 | 12 | import os 13 | import sys 14 | from optparse import Option, OptionParser 15 | 16 | sys.path.insert(0, os.getcwd()) 17 | 18 | PY3 = sys.version_info[0] == 3 19 | 20 | if PY3: 21 | text_type = str 22 | bytes_type = bytes 23 | else: 24 | text_type = unicode # NOQA 25 | bytes_type = str # NOQA 26 | 27 | 28 | def force_text(data): 29 | if isinstance(data, text_type): 30 | return data 31 | if isinstance(data, bytes_type): 32 | return data.decode("utf8") 33 | return data 34 | 35 | 36 | class InvalidDataFormat(Exception): 37 | pass 38 | 39 | 40 | class InvalidInputData(Exception): 41 | pass 42 | 43 | 44 | class MalformedJSON(InvalidInputData): 45 | pass 46 | 47 | 48 | class MalformedINI(InvalidInputData): 49 | pass 50 | 51 | 52 | class MalformedYAML(InvalidInputData): 53 | pass 54 | 55 | 56 | class MalformedQuerystring(InvalidInputData): 57 | pass 58 | 59 | 60 | class MalformedToml(InvalidDataFormat): 61 | pass 62 | 63 | 64 | class MalformedXML(InvalidDataFormat): 65 | pass 66 | 67 | 68 | class MalformedEnv(InvalidDataFormat): 69 | pass 70 | 71 | 72 | class MalformedHJSON(InvalidDataFormat): 73 | pass 74 | 75 | 76 | class MalformedJSON5(InvalidDataFormat): 77 | pass 78 | 79 | 80 | def get_format(fmt): 81 | try: 82 | return formats[fmt]() 83 | except ImportError: 84 | raise InvalidDataFormat(fmt) 85 | 86 | 87 | def has_format(fmt): 88 | try: 89 | get_format(fmt) 90 | return True 91 | except InvalidDataFormat: 92 | return False 93 | 94 | 95 | def get_available_formats(): 96 | for fmt in formats.keys(): 97 | if has_format(fmt): 98 | yield fmt 99 | yield "auto" 100 | 101 | 102 | def _load_json(): 103 | try: 104 | import json 105 | 106 | return json.loads, ValueError, MalformedJSON 107 | except ImportError: 108 | import simplejson 109 | 110 | return simplejson.loads, simplejson.decoder.JSONDecodeError, MalformedJSON 111 | 112 | 113 | def _load_ini(): 114 | try: 115 | import ConfigParser 116 | except ImportError: 117 | import configparser as ConfigParser 118 | 119 | def _parse_ini(data): 120 | try: 121 | from StringIO import StringIO 122 | except ImportError: 123 | from io import StringIO 124 | 125 | class MyConfigParser(ConfigParser.ConfigParser): 126 | def as_dict(self): 127 | d = dict(self._sections) 128 | for k in d: 129 | d[k] = dict(self._defaults, **d[k]) 130 | d[k].pop("__name__", None) 131 | return d 132 | 133 | p = MyConfigParser() 134 | try: 135 | reader = p.readfp 136 | except AttributeError: 137 | reader = p.read_file 138 | reader(StringIO(data)) 139 | return p.as_dict() 140 | 141 | return _parse_ini, ConfigParser.Error, MalformedINI 142 | 143 | 144 | def _load_yaml(): 145 | from yaml import load, YAMLError 146 | 147 | try: 148 | from yaml import CSafeLoader as SafeLoader 149 | except ImportError: 150 | from yaml import SafeLoader 151 | 152 | def yaml_loader(stream): 153 | return load(stream, Loader=SafeLoader) 154 | 155 | return yaml_loader, YAMLError, MalformedYAML 156 | 157 | 158 | def _load_querystring(): 159 | try: 160 | import urlparse 161 | except ImportError: 162 | import urllib.parse as urlparse 163 | 164 | def _parse_qs(data): 165 | """Extend urlparse to allow objects in dot syntax. 166 | 167 | >>> _parse_qs('user.first_name=Matt&user.last_name=Robenolt') 168 | {'user': {'first_name': 'Matt', 'last_name': 'Robenolt'}} 169 | """ 170 | dict_ = {} 171 | for k, v in urlparse.parse_qs(data).items(): 172 | v = list(map(lambda x: x.strip(), v)) 173 | v = v[0] if len(v) == 1 else v 174 | if "." in k: 175 | pieces = k.split(".") 176 | cur = dict_ 177 | for idx, piece in enumerate(pieces): 178 | if piece not in cur: 179 | cur[piece] = {} 180 | if idx == len(pieces) - 1: 181 | cur[piece] = v 182 | cur = cur[piece] 183 | else: 184 | dict_[force_text(k)] = force_text(v) 185 | return dict_ 186 | 187 | return _parse_qs, Exception, MalformedQuerystring 188 | 189 | 190 | def _load_toml(): 191 | import toml 192 | 193 | return toml.loads, Exception, MalformedToml 194 | 195 | 196 | def _load_xml(): 197 | import xml 198 | import xmltodict 199 | 200 | return xmltodict.parse, xml.parsers.expat.ExpatError, MalformedXML 201 | 202 | 203 | def _load_env(): 204 | def _parse_env(data): 205 | """ 206 | Parse an envfile format of key=value pairs that are newline separated 207 | """ 208 | dict_ = {} 209 | for line in data.splitlines(): 210 | line = line.lstrip() 211 | # ignore empty or commented lines 212 | if not line or line[:1] == "#": 213 | continue 214 | k, v = line.split("=", 1) 215 | dict_[force_text(k)] = force_text(v) 216 | return dict_ 217 | 218 | return _parse_env, Exception, MalformedEnv 219 | 220 | 221 | def _load_hjson(): 222 | import hjson 223 | 224 | return hjson.loads, Exception, MalformedHJSON 225 | 226 | 227 | def _load_json5(): 228 | import json5 229 | 230 | return json5.loads, Exception, MalformedJSON5 231 | 232 | 233 | # Global list of available format parsers on your system 234 | # mapped to the callable/Exception to parse a string into a dict 235 | formats = { 236 | "json": _load_json, 237 | "ini": _load_ini, 238 | "yaml": _load_yaml, 239 | "yml": _load_yaml, 240 | "querystring": _load_querystring, 241 | "toml": _load_toml, 242 | "xml": _load_xml, 243 | "env": _load_env, 244 | "hjson": _load_hjson, 245 | "json5": _load_json5, 246 | } 247 | 248 | 249 | def render(template_path, data, extensions, strict=False): 250 | from jinja2 import ( 251 | __version__ as jinja_version, 252 | Environment, 253 | FileSystemLoader, 254 | StrictUndefined, 255 | ) 256 | 257 | # Starting with jinja2 3.1, `with_` and `autoescape` are no longer 258 | # able to be imported, but since they were default, let's stub them back 259 | # in implicitly for older versions. 260 | # We also don't track any lower bounds on jinja2 as a dependency, so 261 | # it's not easily safe to know it's included by default either. 262 | if tuple(jinja_version.split(".", 2)) < ("3", "1"): 263 | for ext in "with_", "autoescape": 264 | ext = "jinja2.ext." + ext 265 | if ext not in extensions: 266 | extensions.append(ext) 267 | 268 | env = Environment( 269 | loader=FileSystemLoader(os.path.dirname(template_path)), 270 | extensions=extensions, 271 | keep_trailing_newline=True, 272 | ) 273 | if strict: 274 | env.undefined = StrictUndefined 275 | 276 | # Add environ global 277 | env.globals["environ"] = lambda key: force_text(os.environ.get(key)) 278 | env.globals["get_context"] = lambda: data 279 | 280 | return env.get_template(os.path.basename(template_path)).render(data) 281 | 282 | 283 | def is_fd_alive(fd): 284 | if os.name == "nt": 285 | return not os.isatty(fd.fileno()) 286 | import select 287 | 288 | return bool(select.select([fd], [], [], 0)[0]) 289 | 290 | 291 | def cli(opts, args): 292 | template_path, data = args 293 | format = opts.format 294 | if data in ("-", ""): 295 | if data == "-" or (data == "" and is_fd_alive(sys.stdin)): 296 | data = sys.stdin.read() 297 | if format == "auto": 298 | # default to yaml first if available since yaml 299 | # is a superset of json 300 | if has_format("yaml"): 301 | format = "yaml" 302 | else: 303 | format = "json" 304 | else: 305 | path = os.path.join(os.getcwd(), os.path.expanduser(data)) 306 | if format == "auto": 307 | ext = os.path.splitext(path)[1][1:] 308 | if has_format(ext): 309 | format = ext 310 | else: 311 | raise InvalidDataFormat(ext) 312 | 313 | with open(path) as fp: 314 | data = fp.read() 315 | 316 | template_path = os.path.abspath(template_path) 317 | 318 | if data: 319 | try: 320 | fn, except_exc, raise_exc = get_format(format) 321 | except InvalidDataFormat: 322 | if format in ("yml", "yaml"): 323 | raise InvalidDataFormat("%s: install pyyaml to fix" % format) 324 | if format == "toml": 325 | raise InvalidDataFormat("toml: install toml to fix") 326 | if format == "xml": 327 | raise InvalidDataFormat("xml: install xmltodict to fix") 328 | if format == "hjson": 329 | raise InvalidDataFormat("hjson: install hjson to fix") 330 | if format == "json5": 331 | raise InvalidDataFormat("json5: install json5 to fix") 332 | raise 333 | try: 334 | data = fn(data) or {} 335 | except except_exc: 336 | raise raise_exc("%s ..." % data[:60]) 337 | else: 338 | data = {} 339 | 340 | extensions = [] 341 | for ext in opts.extensions: 342 | # Allow shorthand and assume if it's not a module 343 | # path, it's probably trying to use builtin from jinja2 344 | if "." not in ext: 345 | ext = "jinja2.ext." + ext 346 | extensions.append(ext) 347 | 348 | # Use only a specific section if needed 349 | if opts.section: 350 | section = opts.section 351 | if section in data: 352 | data = data[section] 353 | else: 354 | sys.stderr.write("ERROR: unknown section. Exiting.") 355 | return 1 356 | 357 | data.update(parse_kv_string(opts.D or [])) 358 | 359 | if opts.outfile is None: 360 | out = sys.stdout 361 | else: 362 | out = open(opts.outfile, "w") 363 | 364 | if not PY3: 365 | import codecs 366 | 367 | out = codecs.getwriter("utf8")(out) 368 | 369 | out.write(render(template_path, data, extensions, opts.strict)) 370 | out.flush() 371 | return 0 372 | 373 | 374 | def parse_kv_string(pairs): 375 | dict_ = {} 376 | for pair in pairs: 377 | pair = force_text(pair) 378 | try: 379 | k, v = pair.split("=", 1) 380 | except ValueError: 381 | k, v = pair, None 382 | dict_[k] = v 383 | return dict_ 384 | 385 | 386 | class LazyHelpOption(Option): 387 | "An Option class that resolves help from a callable" 388 | 389 | def __setattr__(self, attr, value): 390 | if attr == "help": 391 | attr = "_help" 392 | self.__dict__[attr] = value 393 | 394 | @property 395 | def help(self): 396 | h = self._help 397 | if callable(h): 398 | h = h() 399 | # Cache on the class to get rid of the @property 400 | self.help = h 401 | return h 402 | 403 | 404 | class LazyOptionParser(OptionParser): 405 | def __init__(self, **kwargs): 406 | # Fake a version so we can lazy load it later. 407 | # This is due to internals of OptionParser, but it's 408 | # fine 409 | kwargs["version"] = 1 410 | kwargs["option_class"] = LazyHelpOption 411 | OptionParser.__init__(self, **kwargs) 412 | 413 | def get_version(self): 414 | from jinja2 import __version__ as jinja_version 415 | from jinja2cli import __version__ 416 | 417 | return "jinja2-cli v%s\n - Jinja2 v%s" % (__version__, jinja_version) 418 | 419 | 420 | def main(): 421 | parser = LazyOptionParser( 422 | usage="usage: %prog [options] " 423 | ) 424 | parser.add_option( 425 | "-f", 426 | "--format", 427 | help=lambda: "format of input variables: %s" 428 | % ", ".join(sorted(list(get_available_formats()))), 429 | dest="format", 430 | action="store", 431 | default="auto", 432 | ) 433 | parser.add_option( 434 | "-e", 435 | "--extension", 436 | help="extra jinja2 extensions to load", 437 | dest="extensions", 438 | action="append", 439 | default=["do", "loopcontrols"], 440 | ) 441 | parser.add_option( 442 | "-D", 443 | help="Define template variable in the form of key=value", 444 | action="append", 445 | metavar="key=value", 446 | ) 447 | parser.add_option( 448 | "-s", 449 | "--section", 450 | help="Use only this section from the configuration", 451 | dest="section", 452 | action="store", 453 | ) 454 | parser.add_option( 455 | "--strict", 456 | help="Disallow undefined variables to be used within the template", 457 | dest="strict", 458 | action="store_true", 459 | ) 460 | parser.add_option( 461 | "-o", 462 | "--outfile", 463 | help="File to use for output. Default is stdout.", 464 | dest="outfile", 465 | metavar="FILE", 466 | action="store", 467 | ) 468 | opts, args = parser.parse_args() 469 | 470 | # Dedupe list 471 | opts.extensions = set(opts.extensions) 472 | 473 | if len(args) == 0: 474 | parser.print_help() 475 | sys.exit(1) 476 | 477 | # Without the second argv, assume they maybe want to read from stdin 478 | if len(args) == 1: 479 | args.append("") 480 | 481 | if opts.format not in formats and opts.format != "auto": 482 | raise InvalidDataFormat(opts.format) 483 | 484 | sys.exit(cli(opts, args)) 485 | 486 | 487 | if __name__ == "__main__": 488 | main() 489 | -------------------------------------------------------------------------------- /samples/environ.jinja2: -------------------------------------------------------------------------------- 1 | jinja2-cli makes environment variables via a jinja global variable. 2 | 3 | To get an environment variable's value, subscript environ like this: 4 | 5 | PATH is {{ environ('PATH') }} 6 | USER is {{ environ('USER') }} 7 | USERNAME is {{ environ('USERNAME') }} 8 | 9 | Alas, you can't loop through them; environ is not iterable. 10 | -------------------------------------------------------------------------------- /samples/sample.jinja2: -------------------------------------------------------------------------------- 1 | 2 | 3 | {% if foo is defined -%} 4 | 5 | {% endif -%} 6 | 7 | {% if bar is defined -%} 8 |