Passed
Push — develop ( 63be29...c81ce7 )
by Shalom
01:25
created

cli.TestInjiCmd.test_multiple_templates()   A

Complexity

Conditions 1

Size

Total Lines 13
Code Lines 9

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 9
dl 0
loc 13
rs 9.95
c 0
b 0
f 0
cc 1
nop 1
1
#!/usr/bin/python3
2
3
# -*- coding: utf-8 -*-
4
5
import atexit
6
import json
7
import os
8
from   os.path import abspath, dirname, exists, join
9
import pytest
10
import re
11
import shutil
12
import signal
13
import subprocess
14
import sys
15
import tempfile
16
import textwrap
17
import unittest
18
19
import inji
20
21
# The location of the inji CLI entry point
22
injicmd = inji.cli_location if exists(inji.cli_location) else 'inji'
23
24
def check_output(*args, **kwargs):
25
  os.environ['PYTHONUNBUFFERED'] = "1"
26
  return subprocess.check_output( [*args], **kwargs ).decode('utf-8')
27
28
def run_negative_test( command=[ injicmd ],
29
                        exit_code=2,
30
                        errors=None,
31
                        stderr=subprocess.STDOUT,
32
                        input=None
33
                      ):
34
  """ Run an inji command checking for the provided exit code
35
      and strings in stderr
36
  """
37
  class NegativeTestException(Exception): pass
38
  with pytest.raises(NegativeTestException) as e_info:
39
    try:
40
      check_output(*command, stderr=stderr, input=input)
41
    except subprocess.CalledProcessError as exc:
42
      msg = 'exit_code:{} output:{}'.format(exc.returncode, exc.output)
43
      raise NegativeTestException(msg) from exc
44
  e = str(e_info)
45
  assert f"exit_code:{exit_code} " in e
46
  for string in errors:
47
    assert re.search(f'{string}', e)
48
  return True
49
50
def file_from_text(*args, **kwargs):
51
  """ Write args to a tempfile and return the filename """
52
53
  fqdir=kwargs.get('dir', tempfile.tempdir)
54
55
  if kwargs.get('name') is None:
56
    _, filename = tempfile.mkstemp(text=True, dir=fqdir)
57
  else:
58
    filename = join(fqdir, kwargs.get('name'))
59
60
  atexit.register(os.remove, filename)
61
  with open(filename, "a+") as f:
62
    f.write('\n'.join(args))
63
64
  return abspath(filename)
65
66
class TestFixtureHelloWorld(unittest.TestCase):
67
68
  def test_hello_world(self):
69
    assert check_output('/bin/echo', 'hello', 'world') == "hello world\n"
70
71
  def test_check_output(self):
72
    out = check_output( 'sed', 's/foo/bar/', input=b'foo' )
73
    assert "bar" in out
74
75
class TestInjiCmd(unittest.TestCase):
76
77
  def test_help(self):
78
    """Test help message is emitted"""
79
    assert re.search('usage: inji', check_output(injicmd, '-h'))
80
81
  def test_stdin(self):
82
    """Templates should be read in from STDIN (-) by default"""
83
    assert check_output( injicmd,
84
                          input=b"{% set foo='world!' %}Hola {{ foo }}"
85
      ) == "Hola world!\n"
86
87
  def test_stdin_empty_input(self):
88
    """Empty template string should return a newline"""
89
    assert check_output( injicmd, input=b"" ) == '\n'
90
91
  def test_json_config_args(self):
92
    """Config passed as JSON string"""
93
    assert check_output(
94
        injicmd, '-j', '{"foo": "world!"}',
95
          input=b"Hola {{ foo }}"
96
      ) == "Hola world!\n"
97
98
  def test_invalid_json_config_args(self):
99
    """Empty json config args should cause an error"""
100
    input_cases = [ '', '}{', '{@}' ] # invalid JSON inputs
101
    for json in input_cases:
102
      run_negative_test(
103
        command=[ injicmd, '-j', json ],
104
        errors=[ 'Error parsing JSON config:' ]
105
      )
106
107
  def test_kv_config_args(self):
108
    """ Config passed as KV strings """
109
    assert check_output(
110
        injicmd,
111
          '-k', 'bar=bar',
112
          '-k', 'foo=bar',     # should keep bar
113
          '-k', 'foo=world!',  # valid, last declaration wins
114
          '-k', 'moo=',        # valid, sets an empty moo
115
          input=b"Hola {{ foo }}{{ moo }}{{ bar }}"
116
      ) == "Hola world!bar\n"
117
118
  def test_invalid_kv_config_args(self):
119
    """Invalid KV config args should cause an error"""
120
    input_cases = [ '', '=', '=baz' ] # invalid KV inputs
121
    for kv in input_cases:
122
      run_negative_test(
123
        command=[ injicmd, '-k', kv ],
124
        errors=[ 'Invalid key found parsing' ]
125
      )
126
127
  def test_12factor_config_sourcing(self):
128
    """Test config sourcing precendence should adhere to 12-factor app expectations"""
129
    tmpdir = tempfile.mkdtemp(prefix='param-')
130
    atexit.register(shutil.rmtree, tmpdir)
131
132
    for item in ['dev', 'prod', 'stage']:
133
      cdir = os.path.join(tmpdir, item)
134
      os.mkdir(cdir)
135
      for file in 'a.yml', 'b.yaml', 'c.yaml':
136
        fqn = os.path.join(cdir, file)
137
        file_from_text(f"zoo: {file}\nitem: {item}", dir=cdir, name=fqn)
138
139
    cfg_file = file_from_text(
140
        f"zar: bella\nzoo: cfg",
141
        dir=tmpdir, name='inji.yml' )
142
143
    param_file = file_from_text(
144
        f"zar: zella\nzoo: zorg",
145
        dir=tmpdir, name='vars.yaml' )
146
147
    # needed to source the default config from ./inji.y?ml
148
    OLDPWD=os.getcwd()
149
    os.chdir(tmpdir)
150
151
    # test we are able to recursively source params
152
    # but also source from the default (low-precedence) config file relative to PWD
153
    assert re.search('Hola \w+ from bella',
154
      check_output(
155
        injicmd,
156
          '-o', f"{tmpdir}/dev",
157
          input=b"Hola {{ item }} from {{ zar }}"
158
      )
159
    )
160
161
    # dev/c.yaml should be last file sourced
162
    assert check_output(
163
        injicmd, '-o', f"{tmpdir}/dev",
164
          input=b"Hola {{ zoo }}"
165
      ) == "Hola c.yaml\n"
166
167
    # prod/ should be the last overlay sourced
168
    assert check_output(
169
        injicmd,
170
          '-o', f"{tmpdir}/stage",
171
          '-o', f"{tmpdir}/dev",
172
          '-o', f"{tmpdir}/prod",
173
          input=b"Hola {{ item }}"
174
      ) == "Hola prod\n"
175
176
    # named config file trumps overlays, arg position is irrelevant
177
    assert check_output(
178
        injicmd,
179
          '-o', f"{tmpdir}/stage",
180
          '-o', f"{tmpdir}/prod",
181
          '-v', param_file,
182
          '-o', f"{tmpdir}/dev",
183
          input=b"Hola {{ zar }} from {{ zoo }}"
184
      ) == "Hola zella from zorg\n"
185
186
    # env vars trump named config files
187
    os.environ['zoo']='env'
188
    assert check_output(
189
        injicmd,
190
          '-o', f"{tmpdir}/stage",
191
          '-o', f"{tmpdir}/prod",
192
          '-v', param_file,
193
          '-o', f"{tmpdir}/dev",
194
          input=b"Hola {{ zar }} from {{ zoo }}"
195
      ) == "Hola zella from env\n"
196
197
    # cli params passed in as JSON strings at the CLI trump all files
198
    assert check_output(
199
        injicmd,
200
          '-o', f"{tmpdir}/stage",
201
          '-j', '{"zoo": "world!"}',
202
          '-o', f"{tmpdir}/prod",
203
          '-v', param_file,
204
          '-o', f"{tmpdir}/dev",
205
          input=b"Hola {{ zar }} from {{ zoo }}"
206
      ) == "Hola zella from world!\n"
207
208
    # cli params passed in as KV pairs take ultimate precendence
209
    # Q: why do KV pairs take precendece over JSON?
210
    # A: the use-case is overriding particular values from a JSON blurb sourced
211
    #    from a file or external system.
212
    assert check_output(
213
        injicmd,
214
          '-k', 'zar=della',
215
          '-o', f"{tmpdir}/stage",
216
          '-j', '{"zoo": "world!"}',
217
          '-o', f"{tmpdir}/prod",
218
          '-v', param_file,
219
          '-o', f"{tmpdir}/dev",
220
          input=b"Hola {{ zar }} from {{ zoo }}"
221
      ) == "Hola della from world!\n"
222
223
    # except when params are defined in the templates themselves, off course!
224
    assert check_output(
225
        injicmd,
226
          '-k', 'zar=della',
227
          '-o', f"{tmpdir}/stage",
228
          '-j', '{"zoo": "mars"}',
229
          '-o', f"{tmpdir}/prod",
230
          '-v', param_file,
231
          '-o', f"{tmpdir}/dev",
232
          input=b"{% set zar='quux' %}Hola {{ zar }} from {{ zoo }}"
233
      ) == "Hola quux from mars\n"
234
235
    os.environ.pop('zoo')
236
    os.chdir(OLDPWD)
237
238
  def test_undefined_variables(self):
239
    """ Undefined variables should cause a failure """
240
    run_negative_test(
241
      input=b"{% set foo='world!' %}Hola {{ bar }}",
242
      exit_code=1,
243
      errors=[
244
        'UndefinedError',
245
        'variable \W{4}bar\W{4} is undefined in template'
246
      ]
247
    )
248
249
  def test_keep_undefined_var(self):
250
    """Undefined variable placeholders in keep mode should be kept"""
251
    assert check_output( injicmd, '-s', 'keep',
252
                          input=b"[Hola {{ foo }}]"
253
      ) == '[Hola {{ foo }}]\n'
254
255
  def test_empty_undefined_var(self):
256
    """Undefined variables in empty mode should leave spaces behind placeholders"""
257
    assert check_output( injicmd, '-s', 'empty',
258
                          input=b"[Hola {{ foo }}]"
259
      ) == '[Hola ]\n'
260
261
  def test_template_render_with_envvars(self):
262
    """Environment variables should be referenceable as parameters"""
263
    template = file_from_text("Hola {{ foo }}")
264
    os.environ['foo'] = 'world!'
265
    assert check_output( injicmd, template ) == 'Hola world!\n'
266
    os.environ.pop('foo')
267
268
  def test_template_render_with_internal_vars(self):
269
    """
270
    Jinja template should render correctly
271
    referencing those variables set in the templates themselves
272
    """
273
    template = file_from_text("{% set foo='world!' %}Hola {{ foo }}")
274
    assert check_output( injicmd, template ) == 'Hola world!\n'
275
276
  def test_template_missing(self):
277
    """ Missing template files should cause an error """
278
    run_negative_test(
279
      command=[ injicmd, 'nonexistent-template.j2' ],
280
      errors=[
281
        'nonexistent-template.j2.. does not exist'
282
      ]
283
    )
284
285
  def test_template_directory(self):
286
    """ Using a directory as a template source should cause an error"""
287
    run_negative_test(
288
      command=[ injicmd, '/' ],
289
      errors=[
290
        'error: argument',
291
        'path ../.. is not a file',
292
      ]
293
    )
294
295
  def test_template_render_with_varsfile(self):
296
    """Params from params files should be rendered on the template output"""
297
    template = file_from_text("Hola {{ foo }}")
298
    varsfile = file_from_text("foo: world!")
299
    assert check_output(
300
            injicmd, template, '-v', varsfile
301
        ) == 'Hola world!\n'
302
303
  def test_multiple_templates(self):
304
    """ Multiple template should all be rendered to STDOUT """
305
    # Documentation note:
306
    # Positional arguments must be adjacent to one another.
307
    # i.e. -k foo=bar t1 -k bar=foo t2  # is unsupported by argsparse
308
    assert check_output(
309
            injicmd,
310
              file_from_text("t1: {{ k1 }}{{ quuz }}"),
311
              file_from_text("t2: {{ k2 }}{{ moo }}"),
312
              '-k', 'k1=foo',
313
              '-k', 'k2=bar',
314
              '-v', file_from_text("k2: bar\nmoo: quux\nquuz: grault")
315
          ) == "t1: foograult\nt2: barquux\n"
316
317
  def test_multiple_templates_alongside_stdin(self):
318
    """ The use of STDIN with multiple templates should only render STDIN """
319
    # This is an edge-case that perhaps users would not (should not) likely do
320
    # but it doesn't make sense to mix STDIN with multiple named template files
321
    # (or does it?). For now, we favour only using STDIN in this case.
322
    assert check_output(
323
            injicmd,
324
              file_from_text("t1"),    # named template should be ignored
325
              '/dev/stdin',            # STDIN should be used
326
              file_from_text("t2"),    # named template should be ignored
327
              input=b"crash override"  # This is what renders
328
          ) == "crash override\n"
329
330
  def test_template_render_with_multiple_varsfiles(self):
331
    """Params from multiple files should be merged before rendering"""
332
    template = file_from_text("Hola {{ foo }}, Hello {{ bar }}, t{{ t }}")
333
    varsfile1 = file_from_text("foo: world!\nt: quux")
334
    varsfile2 = file_from_text("bar: metaverse\nt: moocow")
335
    assert check_output(
336
          injicmd, template,
337
                '-v', varsfile1,
338
                '-v', varsfile2
339
      ) == 'Hola world!, Hello metaverse, tmoocow\n'
340
341
  def test_error_with_empty_varsfile(self):
342
    """ An empty vars file is an error, we ought to fail early """
343
    """
344
    There may be a case for allowing this to just be a warning and so
345
    we may change this behaviour in the future. For now, it definitely
346
    is something we should fail-early on.
347
    """
348
    assert run_negative_test(
349
      command=[
350
        injicmd, file_from_text("Hola {{ foo }}"),
351
              '-v', file_from_text('')
352
      ],
353
      exit_code=1,
354
      errors=[
355
        'TypeError: .* contains no data'
356
      ]
357
    )
358
359
  def test_error_with_malformed_varsfile(self):
360
    """ An invalid varsfile is a fail-early error """
361
    run_negative_test(
362
      command=[
363
        injicmd, file_from_text("Hola {{ foo }}"),
364
              '-v', file_from_text('@')
365
      ],
366
      exit_code=1,
367
      errors=[
368
        'cannot start any token'
369
      ]
370
    )
371
372
  def test_filters_format_dict(self):
373
    """ Test the use of the format_dict filter """
374
    os.environ['USE_ANSIBLE_SUPPORT'] = '1'
375
    assert check_output(
376
        injicmd,
377
          '-k', 'url=https://google.com:443/webhp?q=foo+bar',
378
          file_from_text("""{{
379
            url | urlsplit |
380
              format_dict('scheme={scheme} hostname={hostname} path={path}')
381
            }}"""),
382
          ) == "scheme=https hostname=google.com path=/webhp\n"
383
    os.environ.pop('USE_ANSIBLE_SUPPORT')
384
385
  def test_tests_is_prime(self):
386
    """ Test the use of the is_prime test """
387
    assert check_output(
388
        injicmd, file_from_text("""{{ 2 is is_prime }}"""),
389
      ) == "True\n"
390
    assert check_output(
391
        injicmd, file_from_text("""{{ 3 is is_prime }}"""),
392
      ) == "True\n"
393
    assert check_output(
394
        injicmd, file_from_text("""{{ 42 is is_prime }}"""),
395
      ) == "False\n"
396
397
  def test_globals_markdown(self):
398
    """ Test that markdown content can be loaded """
399
    md_file = file_from_text(textwrap.dedent('''\
400
    # Heading
401
402
    Paragraph
403
404
    - List item
405
406
    ```python
407
    print(42)
408
    ```
409
410
    ~~~~{.python hl_lines="1 3"}
411
    print(42)
412
    ~~~~
413
    '''))
414
    output = check_output(
415
        injicmd, file_from_text("""{{{{ markdown('{}') }}}}""".format(md_file)),
416
    )
417
    print(output)
418
    assert re.search( 'h1.*>Heading', output )
419
    assert re.search( 'p>Paragraph', output )
420
    assert re.search( 'li>List item', output )
421
    assert re.search( 'pre><code class="python">print\(42\)', output )
422
423
  def test_globals_markdown_extensions(self):
424
    """ Test that markdown content can be loaded """
425
    md_file = file_from_text(textwrap.dedent('''\
426
    ~~~~{.python hl_lines="1 3"}
427
    v=42
428
    s=str(v)
429
    print(s)
430
    ~~~~
431
    '''))
432
    output = check_output(
433
        injicmd,
434
        file_from_text("""
435
          {{{{ markdown('{}', extensions=['codehilite', 'extra']) }}}}
436
          """.format(md_file)),
437
    )
438
    assert re.search( 'class="codehilite".*42', output )
439
440
  def test_globals_run(self):
441
    """ Test the use of the run global function """
442
    assert re.search( '^\.$',
443
      check_output(
444
        injicmd, file_from_text("""{{ run('ls -d') }}"""),
445
      )
446
    )
447
448
  def test_globals_strftime(self):
449
    """ Test the use of the strftime global function """
450
    assert re.search( '^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}',
451
      check_output(
452
        injicmd, file_from_text("""{{ date | strftime("%FT%T") }}"""),
453
      )
454
    )
455
456
  def test_sigint(self):
457
    """ Test that ctrl-c's are caught properly """
458
    with subprocess.Popen([injicmd]) as proc:
459
      proc.send_signal(signal.SIGINT)
460
      proc.wait(3)
461
      # exit status -N to indicate killed by signal N
462
      assert proc.returncode == -1 * signal.SIGINT # SIGINT == 2
463
464
if __name__ == '__main__':
465
  TestInjiCmd().test_globals_markdown_extensions()
466
467