Passed
Push — develop ( 57b1f3...45f623 )
by Shalom
01:32
created

TestInjiCmd.test_template_render_with_multiple_varsfiles()   A

Complexity

Conditions 1

Size

Total Lines 10
Code Lines 9

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 9
nop 1
dl 0
loc 10
rs 9.95
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 run(cmd, *args, **kwargs):
24
  os.environ['PYTHONUNBUFFERED'] = "1"
25
  proc = subprocess.Popen( [cmd, *args],
26
    stdout = subprocess.PIPE,
27
    stderr = subprocess.PIPE,
28
  )
29
  stdout, stderr = proc.communicate(**kwargs)
30
  return proc.returncode, stdout.decode('utf-8'), stderr.decode('utf-8')
31
32
def check_output(*args, **kwargs):
33
  return subprocess.check_output( [*args], **kwargs ).decode('utf-8')
34
35
def file_from_text(*args, **kwargs):
36
  """ Write args to a tempfile and return the filename """
37
38
  fqdir=kwargs.get('dir', tempfile.tempdir)
39
40
  if kwargs.get('name') is None:
41
    _, filename = tempfile.mkstemp( text=True, dir=fqdir)
42
  else:
43
    filename = join(fqdir, kwargs.get('name'))
44
45
  atexit.register(os.remove, filename)
46
  with open(filename, "a+") as f:
47
    f.write('\n'.join(args))
48
49
  return abspath(filename)
50
51
class TestFixtureHelloWorld(unittest.TestCase):
52
53
  def test_hello_world(self):
54
    code, out, err = run('/bin/echo', 'hello', 'world')
55
    assert 'hello' in out
56
    assert 'world' in out
57
    assert '' in err
58
    assert code == 0
59
60
  def test_check_output(self):
61
    out = check_output( 'sed', 's/foo/bar/', input=b'foo' )
62
    assert "bar" in out
63
64
class TestInjiCmd(unittest.TestCase):
65
66
  def test_help(self):
67
    """Test help message is emitted"""
68
    code, out, err = run(inji, '-h')
69
    assert code == 0
70
    assert 'usage:' in out
71
    assert '' == err
72
73
  def test_stdin(self):
74
    """Templates should be read in from STDIN (-) by default"""
75
    assert "Hola world!\n" == \
76
      check_output( inji,
77
                    input=b"{% set foo='world!' %}Hola {{ foo }}"
78
      )
79
80
  def test_stdin_empty_input(self):
81
    """Empty template string should return a newline"""
82
    assert '\n' == check_output( inji, input=b"" )
83
84
  def test_json_config_args(self):
85
    """Config passed as JSON string"""
86
    assert "Hola world!\n" == \
87
      check_output(
88
        inji, '-c', '{"foo": "world!"}',
89
          input=b"Hola {{ foo }}"
90
      )
91
92
  def test_invalid_json_config_args(self):
93
    """Empty json config args should cause an error"""
94
    class InvalidLambdaException(Exception): pass
95
    input_cases = [ '', '}{', '{@}', '{"foo": tru}' ] # invalid JSON inputs
96
    for cfg in input_cases:
97
      with pytest.raises(InvalidLambdaException) as e_info:
98
        try:
99
          check_output( inji, '-c', '',
100
                        stderr=subprocess.STDOUT,
101
          )
102
        except subprocess.CalledProcessError as exc:
103
          msg = 'exit_code:{} output:{}'.format(exc.returncode, exc.output)
104
          raise InvalidLambdaException(msg) from exc
105
      e = str(e_info)
106
      assert re.search("--json: invalid <lambda> value: ", e)
107
      assert "exit_code:2 " in e
108
109
  def test_12factor_config_sourcing(self):
110
    """Test config sourcing precendence should adhere to 12-factor app expectations"""
111
    tmpdir = tempfile.mkdtemp(prefix='param-')
112
    atexit.register(shutil.rmtree, tmpdir)
113
114
    for item in ['dev', 'prod', 'stage']:
115
      cdir = os.path.join(tmpdir, item)
116
      os.mkdir(cdir)
117
      for file in 'a.yml', 'b.yaml', 'c.yaml':
118
        fqn = os.path.join(cdir, file)
119
        file_from_text(f"zoo: {file}\nitem: {item}", dir=cdir, name=fqn)
120
121
    cfg_file = file_from_text(
122
        f"zar: bella\nzoo: cfg",
123
        dir=tmpdir, name='inji.yml' )
124
125
    param_file = file_from_text(
126
        f"zar: zella\nzoo: zorg",
127
        dir=tmpdir, name='vars.yaml' )
128
129
    # needed to source lowest precedence config from ./inji.y?ml
130
    OLDPWD=os.getcwd()
131
    os.chdir(tmpdir)
132
133
    # test we are able to recursively source params
134
    # but also source from the p5 config file relative to PWD
135
    assert re.search('Hola \w+ from bella',
136
      check_output(
137
        inji,
138
          '-o', f"{tmpdir}/dev",
139
          input=b"Hola {{ item }} from {{ zar }}"
140
      )
141
    )
142
143
    # dev/c.yaml should be last file sourced
144
    assert "Hola c.yaml\n" == \
145
      check_output(
146
        inji, '-o', f"{tmpdir}/dev",
147
          input=b"Hola {{ zoo }}"
148
      )
149
150
    # prod/ should be the last overlay sourced
151
    assert "Hola prod\n" == \
152
      check_output(
153
        inji,
154
          '-o', f"{tmpdir}/stage",
155
          '-o', f"{tmpdir}/dev",
156
          '-o', f"{tmpdir}/prod",
157
          input=b"Hola {{ item }}"
158
      )
159
160
    # precedence 4
161
    # named config file trumps overlays, arg position is irrelevant
162
    assert "Hola zella from zorg\n" == \
163
      check_output(
164
        inji,
165
          '-o', f"{tmpdir}/stage",
166
          '-o', f"{tmpdir}/prod",
167
          '-v', param_file,
168
          '-o', f"{tmpdir}/dev",
169
          input=b"Hola {{ zar }} from {{ zoo }}"
170
      )
171
172
    # precedence 3
173
    # env vars trump named config files
174
    os.environ['zoo']='env'
175
    assert "Hola zella from env\n" == \
176
      check_output(
177
        inji,
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
      )
184
185
    # precedence 2
186
    # cli params passed in as JSON take ultimate precendence
187
    assert "Hola zella from world!\n" == \
188
      check_output(
189
        inji,
190
          '-o', f"{tmpdir}/stage",
191
          '-c', '{"zoo": "world!"}',
192
          '-o', f"{tmpdir}/prod",
193
          '-v', param_file,
194
          '-o', f"{tmpdir}/dev",
195
          input=b"Hola {{ zar }} from {{ zoo }}"
196
      )
197
198
    # precedence 1
199
    # except when params are defined in the templates themselves, off course!
200
    assert "Hola quux from mars\n" == \
201
      check_output(
202
        inji,
203
          '-o', f"{tmpdir}/stage",
204
          '-c', '{"zoo": "mars"}',
205
          '-o', f"{tmpdir}/prod",
206
          '-v', param_file,
207
          '-o', f"{tmpdir}/dev",
208
          input=b"{% set zar='quux' %}Hola {{ zar }} from {{ zoo }}"
209
      )
210
211
    os.environ.pop('zoo')
212
    os.chdir(OLDPWD)
213
214
  def test_strict_undefined_var(self):
215
    """Undefined variables should cause a failure"""
216
    class BarUndefinedException(Exception): pass
217
    with pytest.raises(BarUndefinedException) as e_info:
218
      try:
219
        check_output( inji,
220
                      input=b"{% set foo='world!' %}Hola {{ bar }}",
221
                      stderr=subprocess.STDOUT,
222
        )
223
      except subprocess.CalledProcessError as exc:
224
        msg = 'exit_code:{} output:{}'.format(exc.returncode, exc.output)
225
        raise BarUndefinedException(msg) from exc
226
    e = str(e_info)
227
    assert "exit_code:1 " in e
228
    assert re.search('jinja2.exceptions.UndefinedError.+bar.+is undefined', e)
229
230
  def test_keep_undefined_var(self):
231
    """Undefined variables in keep mode should be kept"""
232
    assert '[Hola {{ foo }}]\n' == \
233
      check_output( inji, '-s', 'keep',
234
                      input=b"[Hola {{ foo }}]"
235
      )
236
237
  def test_empty_undefined_var(self):
238
    """Undefined variables in empty mode should leave spaces behind placeholders"""
239
    assert '[Hola ]\n' == \
240
      check_output( inji, '-s', 'empty',
241
                      input=b"[Hola {{ foo }}]"
242
      )
243
244
  def test_template_render_with_envvars(self):
245
    """Environment variables should be referenceable as parameters"""
246
    template = file_from_text("Hola {{ foo }}")
247
    os.environ['foo'] = 'world!'
248
    assert 'Hola world!\n' == \
249
      check_output( inji, '-t', template )
250
    os.environ.pop('foo')
251
252
  def test_template_render_with_internal_vars(self):
253
    """
254
    Jinja template should render correctly
255
    referencing those variables set in the templates themselves
256
    """
257
    template = file_from_text("{% set foo='world!' %}Hola {{ foo }}")
258
    assert 'Hola world!\n' == \
259
      check_output( inji, '-t', template )
260
261
  def test_template_missing(self):
262
    """ Missing template files should cause an error"""
263
    class TemplateFileMissingException(Exception): pass
264
    with pytest.raises(TemplateFileMissingException) as e_info:
265
      try:
266
        check_output( inji, '-t', 'nonexistent-template.j2',
267
                      stderr=subprocess.STDOUT,
268
        )
269
      except subprocess.CalledProcessError as exc:
270
        msg = 'exit_code:{} output:{}'.format(exc.returncode, exc.output)
271
        raise TemplateFileMissingException(msg) from exc
272
    e = str(e_info)
273
    assert "exit_code:2 " in e
274
    assert re.search('nonexistent-template.j2.. does not exist', e)
275
276
  def test_template_directory(self):
277
    """ Using a directory as a template source should cause an error"""
278
    class TemplateAsDirectoryException(Exception): pass
279
    with pytest.raises(TemplateAsDirectoryException) as e_info:
280
      try:
281
        check_output( inji, '-t', '/',
282
                      stderr=subprocess.STDOUT,
283
        )
284
      except subprocess.CalledProcessError as exc:
285
        msg = 'exit_code:{} output:{}'.format(exc.returncode, exc.output)
286
        raise TemplateAsDirectoryException(msg) from exc
287
    e = str(e_info)
288
    assert "exit_code:2 " in e
289
    assert re.search('/.. is not a file', e)
290
291
  def test_template_render_with_varsfile(self):
292
    """Params from params files should be rendered on the template output"""
293
    template = file_from_text("Hola {{ foo }}")
294
    varsfile = file_from_text("foo: world!")
295
    assert 'Hola world!\n' == \
296
      check_output( inji, '-t', template, '-v', varsfile )
297
298
  def test_template_render_with_multiple_varsfiles(self):
299
    """Params from multiple files should be merged before rendering"""
300
    template = file_from_text("Hola {{ foo }}, Hello {{ bar }}, t{{ t }}")
301
    varsfile1 = file_from_text("foo: world!\nt: quux")
302
    varsfile2 = file_from_text("bar: metaverse\nt: moocow")
303
    assert 'Hola world!, Hello metaverse, tmoocow\n' == \
304
      check_output(
305
          inji, '-t', template,
306
                '-v', varsfile1,
307
                '-v', varsfile2
308
      )
309
310
  def test_error_with_empty_varsfile(self):
311
    """ An empty vars file is an error, we ought to fail early """
312
    """
313
    There may be a case for allowing this to just be a warning and so
314
    we may change this behaviour in the future. For now, it definitely
315
    is something we should fail-early on.
316
    """
317
    class EmptyVarsFileException(Exception): pass
318
    with pytest.raises(EmptyVarsFileException) as e_info:
319
      try:
320
        template = file_from_text("Hola {{ foo }}, Hello {{ bar }}")
321
        varsfile = file_from_text('')
322
        check_output( inji, '-t', template, '-v', varsfile,
323
                      stderr=subprocess.STDOUT,
324
        )
325
      except subprocess.CalledProcessError as exc:
326
        msg = 'exit_code:{} output:{}'.format(exc.returncode, exc.output)
327
        raise EmptyVarsFileException(msg) from exc
328
    e = str(e_info)
329
    assert re.search('TypeError: .* contains no data', e)
330
    assert "exit_code:1 " in e
331
332
  def test_error_with_malformed_varsfile(self):
333
    """ An invalid varsfile is a fail-early error """
334
    class MalformedVarsFileException(Exception): pass
335
    with pytest.raises(MalformedVarsFileException) as e_info:
336
      try:
337
        varsfile = file_from_text('@')
338
        check_output( inji, '-v', varsfile,
339
                      input=b"Hola {{ foo }}, Hello {{ bar }}",
340
                      stderr=subprocess.STDOUT
341
        )
342
      except subprocess.CalledProcessError as exc:
343
        msg = 'exit_code:{} output:{}'.format(exc.returncode, exc.output)
344
        raise MalformedVarsFileException(msg) from exc
345
    e = str(e_info)
346
    assert re.search('cannot start any token', e)
347
    assert "exit_code:1 " in e
348
349
if __name__ == '__main__':
350
  TestInjiCmd().test_empty_json_config_args()
351
352