Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add CLI tests #2

Merged
merged 2 commits into from
Jul 12, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ BuildFolder := ./build
SourceFiles := setup.py ./tpl/*.py

test:
-flake8 tpl/
#pytest
-flake8 tpl/ tests/
pytest ./tests

all: test $(DistFolder)/tpl docker

Expand Down
236 changes: 236 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,236 @@
import os
from pathlib import Path
from subprocess import run, PIPE, CompletedProcess
from collections import defaultdict
import json


import pytest
import yaml


# For common exit codes see `man 3 sysexits`


EXECUTION_TIMEOUT = 2 # seconds


class CLI:
"""Helper class to ease testing of CLI commands"""
def __init__(self, executable_list, tmpdir, print_debug_output=True):
self._executable = executable_list
self.tmpdir = tmpdir
self._tmpfile_auto_increment = defaultdict(int)
# print stdout/err and exit code so that in case of errors we can see
# what happened
self._print_debug_output = print_debug_output

def __call__(self, *args, stdin="", env={}, encoding="UTF-8") -> CompletedProcess:
# patch PATH into env if not already set
env.setdefault("PATH", os.environ["PATH"])
result = run(
["tpl", *[str(arg) for arg in args]],
timeout=EXECUTION_TIMEOUT,
stdout=PIPE,
stderr=PIPE,
input=str(stdin),
encoding=encoding,
env=env,
cwd=self.tmpdir
)

if self._print_debug_output:
self.print_debug_info_for_call(result)
return result

def _print_stream_output(self, call_result: CompletedProcess, stream_name: str):
stream = getattr(call_result, stream_name.lower())
name = stream_name.upper()

print(f"{name}:", end="")
if len(stream) == 0:
print(" (stream is empty)")
elif stream == "\n":
print(" (stream is empty, containts only one newline)")
elif stream[-1] != "\n":
print(" (does not end in newline")
else:
print()

print("-"*24)

print(stream, end="")

# if it doesn't end in a newline add one so the seperation doesn't start
# directly after the output
if len(stream) > 0 and stream[-1] != "\n":
print()

print("="*24)

def print_debug_info_for_call(self, call_result: CompletedProcess):
print(f"Command: {call_result.args}")
print(f"Return code: {call_result.returncode}")

self._print_stream_output(call_result, "stdout")
self._print_stream_output(call_result, "stderr")

print("Folder hierarchy:")
print(self.folder_tree())

def folder_tree(self, path=None):
if path is None:
path = self.tmpdir
path = Path(path)
return "./\n" + "\n".join(self._folder_structure_recursive(path))

def _folder_structure_recursive(self, path: Path):
for item in path.iterdir():
yield f"|-- {item.name}"
if item.is_dir():
for line in self._folder_structure_recursive(item):
yield f"| {line}"

def _normalize_filename(self, name):
allowed_chars = (
"abcdefghijklmnopqrstuvwxyz"
"ABCDEFGHIJKLMNOPQRSTUVWXYZ"
"01234567890"
"-_."
)
return "".join([c for c in str(name) if c in allowed_chars][:32])

def unique_file(self, name="") -> Path:
"""Generate a unique filename that can be used in the tmpdir"""
normalized = self._normalize_filename(name)

index = str(self._tmpfile_auto_increment[normalized])
self._tmpfile_auto_increment[normalized] += 1

filename = index + "-" + normalized
if len(normalized) == 0:
filename = index

return Path(self.tmpdir, filename)

def path_for_content(self, file_content, encoding="UTF-8", name="") -> Path:
if name == "":
name = file_content # use the first few characters to form a name
file_path = self.unique_file(name)
with file_path.open("wb") as file:
file.write(str(file_content).encode(encoding))
return file_path

def path_for_json(self, content: dict, encoding="UTF-8", name="") -> Path:
if name == "":
name = "json-data"
return self.path_for_content(json.dumps(content), encoding, name)

def path_for_yaml(self, content: dict, encoding="UTF-8", name="") -> Path:
if name == "":
name = "yaml-data"
return self.path_for_content(
yaml.dump(content, default_flow_style=False),
encoding,
name
)


@pytest.fixture
def cli(tmpdir):
yield CLI("tpl", tmpdir)


def test_source_environment(cli):
p = cli("-e", cli.path_for_content("{{FOO}}"), env={"FOO": "bar"})
assert p.stdout == "bar\n"


def test_unicode_var(cli):
p = cli("-e", cli.path_for_content("{{FOO}}"), env={"FOO": "🐍"})
assert p.stdout == "🐍\n"


def test_shadowing_json_env(cli):
p = cli(
"--json", cli.path_for_json({"FOO": "json"}),
"-e",
cli.path_for_content("{{FOO}}"),
env={"FOO": "env"}
)
assert p.stdout == "env\n"


def test_shadowing_yaml_env(cli):
p = cli(
"--yaml", cli.path_for_yaml({"FOO": "yaml"}),
"-e",
cli.path_for_content("{{FOO}}"),
env={"FOO": "env"}
)
assert p.stdout == "env\n"


def test_yaml_flow_style(cli):
p = cli(
"--yaml", cli.path_for_content('{"FOO": "yaml"}'),
cli.path_for_content("{{FOO}}")
)
assert p.stdout == "yaml\n"


def test_environment_by_default(cli):
p = cli(
cli.path_for_content("{{FOO}}"),
env={"FOO": "bar"}
)
assert p.stdout == "bar\n"


def test_corrupt_yaml(cli):
p = cli(
"--yaml", cli.path_for_content('{"FOO": "not properly closed'),
cli.path_for_content("{{FOO}}")
)
assert p.returncode == 1


def test_corrupt_json(cli):
p = cli(
"--json", cli.path_for_content('{"FOO": "not properly closed'),
cli.path_for_content("{{FOO}}")
)
assert p.returncode == 1


def test_usage_and_error_without_arguments(cli):
p = cli()
assert p.returncode == 64 # EX_USAGE
assert p.stderr.startswith("No template")
assert "Usage" in p.stderr


def test_help_on_h(cli):
p = cli("-h")
assert p.returncode == 0
assert "Usage:" in p.stderr
assert "Options:" in p.stderr


def test_help_on_help(cli):
p = cli("--help")
assert p.returncode == 0
assert "Usage:" in p.stderr
assert "Options:" in p.stderr


def test_version_on_v(cli):
p = cli("-v")
assert p.returncode == 0
assert "tpl - " in p.stdout


def test_version_on_version(cli):
p = cli("--version")
assert p.returncode == 0
assert "tpl - " in p.stdout
10 changes: 6 additions & 4 deletions tpl/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ def print_usage():
print("""Usage:
tpl [options] <template_file>
tpl --help
tpl --version""")
tpl --version""", file=sys.stderr)


def print_help():
Expand All @@ -87,13 +87,15 @@ def print_help():
Options:
-e, --environment Use all environment variables as data
--json=<file> Load JSON data from a file or STDIN
--yaml=<file> Load YAML data from a file or STDIN""")
--yaml=<file> Load YAML data from a file or STDIN""", file=sys.stderr)


def print_version():
# Although help and usage appear on STDERR, the version goes to STDOUT.
# This is the same way that `less` does it under macOS, even though thats
# probably not a good reason.
from .__version__ import __version__
executable = sys.argv[0]
print(f"{executable} - {__version__}")
print(f"tpl - {__version__}")


def parse_input_options(type, file):
Expand Down