Passed
Push — develop ( fcb976...eb8254 )
by Shalom
02:53
created

cli.TestInjiCmd.test_undefined_variables()   A

Complexity

Conditions 1

Size

Total Lines 8
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 7
nop 1
dl 0
loc 8
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 subprocess
13
import sys
14
import tempfile
15
import unittest
16
17
sys.path.insert(0, join(dirname(abspath(__file__)), '../..'))
18
sys.path.insert(0, join(dirname(abspath(__file__)), '../../inji'))
19
20
import inji
21
inji = abspath(join(sys.path[0], 'inji'))
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=[ inji ],
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(inji, '-h'))
79
80
  def test_stdin(self):
81
    """Templates should be read in from STDIN (-) by default"""
82
    assert check_output( inji,
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 '\n' == check_output( inji, input=b"" )
89
90
  def test_json_config_args(self):
91
    """Config passed as JSON string"""
92
    assert check_output(
93
        inji, '-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=[ inji, '-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
        inji,
110
          '-k', 'foo=bar',
111
          '-k', 'foo=world!',  # valid, last declaration wins
112
          input=b"Hola {{ foo }}"
113
      ) == "Hola world!\n"
114
    assert check_output(
115
        inji, '-k', 'foo=',    # valid, sets foo to be empty
116
          input=b"Hola {{ foo }}"
117
      ) ==  "Hola \n"
118
119
  def test_invalid_kv_config_args(self):
120
    """Invalid KV config args should cause an error"""
121
    input_cases = [ '', '=', '=baz' ] # invalid KV inputs
122
    for kv in input_cases:
123
      run_negative_test(
124
        command=[ inji, '-k', kv ],
125
        errors=[ 'Invalid key found parsing' ]
126
      )
127
128
  def test_12factor_config_sourcing(self):
129
    """Test config sourcing precendence should adhere to 12-factor app expectations"""
130
    tmpdir = tempfile.mkdtemp(prefix='param-')
131
    atexit.register(shutil.rmtree, tmpdir)
132
133
    for item in ['dev', 'prod', 'stage']:
134
      cdir = os.path.join(tmpdir, item)
135
      os.mkdir(cdir)
136
      for file in 'a.yml', 'b.yaml', 'c.yaml':
137
        fqn = os.path.join(cdir, file)
138
        file_from_text(f"zoo: {file}\nitem: {item}", dir=cdir, name=fqn)
139
140
    cfg_file = file_from_text(
141
        f"zar: bella\nzoo: cfg",
142
        dir=tmpdir, name='inji.yml' )
143
144
    param_file = file_from_text(
145
        f"zar: zella\nzoo: zorg",
146
        dir=tmpdir, name='vars.yaml' )
147
148
    # needed to source lowest precedence config from ./inji.y?ml
149
    OLDPWD=os.getcwd()
150
    os.chdir(tmpdir)
151
152
    # test we are able to recursively source params
153
    # but also source from the p5 config file relative to PWD
154
    assert re.search('Hola \w+ from bella',
155
      check_output(
156
        inji,
157
          '-o', f"{tmpdir}/dev",
158
          input=b"Hola {{ item }} from {{ zar }}"
159
      )
160
    )
161
162
    # dev/c.yaml should be last file sourced
163
    assert check_output(
164
        inji, '-o', f"{tmpdir}/dev",
165
          input=b"Hola {{ zoo }}"
166
      ) == "Hola c.yaml\n"
167
168
    # prod/ should be the last overlay sourced
169
    assert check_output(
170
        inji,
171
          '-o', f"{tmpdir}/stage",
172
          '-o', f"{tmpdir}/dev",
173
          '-o', f"{tmpdir}/prod",
174
          input=b"Hola {{ item }}"
175
      ) == "Hola prod\n"
176
177
    # precedence 4
178
    # named config file trumps overlays, arg position is irrelevant
179
    assert check_output(
180
        inji,
181
          '-o', f"{tmpdir}/stage",
182
          '-o', f"{tmpdir}/prod",
183
          '-v', param_file,
184
          '-o', f"{tmpdir}/dev",
185
          input=b"Hola {{ zar }} from {{ zoo }}"
186
      ) == "Hola zella from zorg\n"
187
188
    # precedence 3
189
    # env vars trump named config files
190
    os.environ['zoo']='env'
191
    assert check_output(
192
        inji,
193
          '-o', f"{tmpdir}/stage",
194
          '-o', f"{tmpdir}/prod",
195
          '-v', param_file,
196
          '-o', f"{tmpdir}/dev",
197
          input=b"Hola {{ zar }} from {{ zoo }}"
198
      ) == "Hola zella from env\n"
199
200
    # precedence 2
201
    # cli params passed in as JSON take ultimate precendence
202
    assert check_output(
203
        inji,
204
          '-o', f"{tmpdir}/stage",
205
          '-c', '{"zoo": "world!"}',
206
          '-o', f"{tmpdir}/prod",
207
          '-v', param_file,
208
          '-o', f"{tmpdir}/dev",
209
          input=b"Hola {{ zar }} from {{ zoo }}"
210
      ) == "Hola zella from world!\n"
211
212
    # precedence 1
213
    # except when params are defined in the templates themselves, off course!
214
    assert check_output(
215
        inji,
216
          '-o', f"{tmpdir}/stage",
217
          '-c', '{"zoo": "mars"}',
218
          '-o', f"{tmpdir}/prod",
219
          '-v', param_file,
220
          '-o', f"{tmpdir}/dev",
221
          input=b"{% set zar='quux' %}Hola {{ zar }} from {{ zoo }}"
222
      ) == "Hola quux from mars\n"
223
224
    os.environ.pop('zoo')
225
    os.chdir(OLDPWD)
226
227
  def test_undefined_variables(self):
228
    """ Undefined variables should cause a failure """
229
    run_negative_test(
230
      input=b"{% set foo='world!' %}Hola {{ bar }}",
231
      exit_code=1,
232
      errors=[
233
        'UndefinedError',
234
        'variable \W{4}bar\W{4} is undefined in template'
235
      ]
236
    )
237
238
  def test_keep_undefined_var(self):
239
    """Undefined variable placeholders in keep mode should be kept"""
240
    assert check_output( inji, '-s', 'keep',
241
                          input=b"[Hola {{ foo }}]"
242
      ) == '[Hola {{ foo }}]\n'
243
244
  def test_empty_undefined_var(self):
245
    """Undefined variables in empty mode should leave spaces behind placeholders"""
246
    assert check_output( inji, '-s', 'empty',
247
                          input=b"[Hola {{ foo }}]"
248
      ) == '[Hola ]\n'
249
250
  def test_template_render_with_envvars(self):
251
    """Environment variables should be referenceable as parameters"""
252
    template = file_from_text("Hola {{ foo }}")
253
    os.environ['foo'] = 'world!'
254
    assert check_output( inji, '-t', template ) == 'Hola world!\n'
255
    os.environ.pop('foo')
256
257
  def test_template_render_with_internal_vars(self):
258
    """
259
    Jinja template should render correctly
260
    referencing those variables set in the templates themselves
261
    """
262
    template = file_from_text("{% set foo='world!' %}Hola {{ foo }}")
263
    assert check_output( inji, '-t', template ) == 'Hola world!\n'
264
265
  def test_template_missing(self):
266
    """ Missing template files should cause an error """
267
    run_negative_test(
268
      command=[ inji, '-t', 'nonexistent-template.j2' ],
269
      errors=[
270
        'nonexistent-template.j2.. does not exist'
271
      ]
272
    )
273
274
  def test_template_directory(self):
275
    """ Using a directory as a template source should cause an error"""
276
    run_negative_test(
277
      command=[ inji, '-t', '/' ],
278
      errors=[
279
        'error: argument',
280
        'path ../.. is not a file',
281
      ]
282
    )
283
284
  def test_template_render_with_varsfile(self):
285
    """Params from params files should be rendered on the template output"""
286
    template = file_from_text("Hola {{ foo }}")
287
    varsfile = file_from_text("foo: world!")
288
    assert check_output(
289
            inji, '-t', template, '-v', varsfile
290
        ) == 'Hola world!\n'
291
292
  def test_template_render_with_multiple_varsfiles(self):
293
    """Params from multiple files should be merged before rendering"""
294
    template = file_from_text("Hola {{ foo }}, Hello {{ bar }}, t{{ t }}")
295
    varsfile1 = file_from_text("foo: world!\nt: quux")
296
    varsfile2 = file_from_text("bar: metaverse\nt: moocow")
297
    assert check_output(
298
          inji, '-t', template,
299
                '-v', varsfile1,
300
                '-v', varsfile2
301
      ) == 'Hola world!, Hello metaverse, tmoocow\n'
302
303
  def test_error_with_empty_varsfile(self):
304
    """ An empty vars file is an error, we ought to fail early """
305
    """
306
    There may be a case for allowing this to just be a warning and so
307
    we may change this behaviour in the future. For now, it definitely
308
    is something we should fail-early on.
309
    """
310
    assert run_negative_test(
311
      command=[
312
        inji, '-t', file_from_text("Hola {{ foo }}"),
313
              '-v', file_from_text('')
314
      ],
315
      exit_code=1,
316
      errors=[
317
        'TypeError: .* contains no data'
318
      ]
319
    )
320
321
  def test_error_with_malformed_varsfile(self):
322
    """ An invalid varsfile is a fail-early error """
323
    run_negative_test(
324
      command=[
325
        inji, '-t', file_from_text("Hola {{ foo }}"),
326
              '-v', file_from_text('@')
327
      ],
328
      exit_code=1,
329
      errors=[
330
        'cannot start any token'
331
      ]
332
    )
333
334
if __name__ == '__main__':
335
  TestInjiCmd().test_empty_json_config_args()
336
337