Passed
Push — develop ( 5b93c7...dc653f )
by Shalom
02:40
created

cli.TestInjiCmd.test_12factor_config_sourcing()   B

Complexity

Conditions 3

Size

Total Lines 110
Code Lines 81

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 3
eloc 81
nop 1
dl 0
loc 110
rs 7.6218
c 0
b 0
f 0

How to fix   Long Method   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

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