Passed
Push — develop ( 503712...bde8f9 )
by Shalom
02:13
created

cli.TestInjiCmd.test_json_config_args()   A

Complexity

Conditions 1

Size

Total Lines 6
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Importance

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