cli.TestInjiCmd.test_globals_markdown()   A
last analyzed

Complexity

Conditions 1

Size

Total Lines 25
Code Lines 9

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 9
dl 0
loc 25
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_get_and_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_filters_get_json(self):
386
    """ Test the use of the GET filter to return a python object """
387
    assert re.search( 'myscheme://.*/GET', check_output(
388
        injicmd,
389
          '-k', 'url=https://httpbin.org/anything',
390
          file_from_text("""{{
391
            GET(url) | format_dict('myscheme://{origin}/{method}')
392
          }}"""),
393
        )
394
    )
395
396
  def test_filters_get_text(self):
397
    """ Test the use of the GET filter to return text """
398
    assert re.search( 'Simple webservice echo test', check_output(
399
        injicmd,
400
          '-k', 'url=http://scooterlabs.com/echo',
401
          file_from_text("""{{
402
            GET(url)
403
          }}"""),
404
        )
405
    )
406
407
  def test_tests_is_prime(self):
408
    """ Test the use of the is_prime test """
409
    assert check_output(
410
        injicmd, file_from_text("""{{ 2 is is_prime }}"""),
411
      ) == "True\n"
412
    assert check_output(
413
        injicmd, file_from_text("""{{ 3 is is_prime }}"""),
414
      ) == "True\n"
415
    assert check_output(
416
        injicmd, file_from_text("""{{ 42 is is_prime }}"""),
417
      ) == "False\n"
418
419
  def test_globals_markdown(self):
420
    """ Test that markdown content can be loaded """
421
    md_file = file_from_text(textwrap.dedent('''\
422
    # Heading
423
424
    Paragraph
425
426
    - List item
427
428
    ```python
429
    print(42)
430
    ```
431
432
    ~~~~{.python hl_lines="1 3"}
433
    print(42)
434
    ~~~~
435
    '''))
436
    output = check_output(
437
        injicmd, file_from_text("""{{{{ markdown('{}') }}}}""".format(md_file)),
438
    )
439
    print(output)
440
    assert re.search( 'h1.*>Heading', output )
441
    assert re.search( 'p>Paragraph', output )
442
    assert re.search( 'li>List item', output )
443
    assert re.search( 'pre><code class="python">print\(42\)', output )
444
445
  def test_globals_markdown_extensions(self):
446
    """ Test that markdown content can be loaded """
447
    md_file = file_from_text(textwrap.dedent('''\
448
    ~~~~{.python hl_lines="1 3"}
449
    v=42
450
    s=str(v)
451
    print(s)
452
    ~~~~
453
    '''))
454
    output = check_output(
455
        injicmd,
456
        file_from_text("""
457
          {{{{ markdown('{}', extensions=['codehilite', 'extra']) }}}}
458
          """.format(md_file)),
459
    )
460
    assert re.search( 'class="codehilite".*42', output )
461
462
  def test_globals_run(self):
463
    """ Test the use of the run global function """
464
    assert re.search( '^\.$',
465
      check_output(
466
        injicmd, file_from_text("""{{ run('ls -d') }}"""),
467
      )
468
    )
469
470
  def test_globals_whatismyip(self):
471
    """ Test the use of the whatismyip global function """
472
    assert re.search( '^\d+\.\d+\.\d+\.\d+$',
473
      check_output(
474
        injicmd, file_from_text("""{{ whatismyip() }}"""),
475
      )
476
    )
477
478
  def test_globals_ip_api(self):
479
    """ Test the use of the ip_api global function """
480
    assert re.search( '^\d+\.\d+\.\d+\.\d+$',
481
      check_output(
482
        injicmd, file_from_text("""{{ ip_api('query') }}"""),
483
      )
484
    )
485
486
  def test_globals_strftime(self):
487
    """ Test the use of the strftime global function """
488
    assert re.search( '^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}',
489
      check_output(
490
        injicmd, file_from_text("""{{ date | strftime("%FT%T") }}"""),
491
      )
492
    )
493
494
  def test_sigint(self):
495
    """ Test that ctrl-c's are caught properly """
496
    with subprocess.Popen([injicmd]) as proc:
497
      proc.send_signal(signal.SIGINT)
498
      proc.wait(3)
499
      # exit status -N to indicate killed by signal N
500
      assert proc.returncode == -1 * signal.SIGINT # SIGINT == 2
501
502
if __name__ == '__main__':
503
  TestInjiCmd().test_globals_markdown_extensions()
504