Completed
Branch develop (c191b3)
by Shalom
01:34
created

varsfile()   A

Complexity

Conditions 3

Size

Total Lines 16
Code Lines 14

Duplication

Lines 16
Ratio 100 %

Importance

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