Skip to content

Instantly share code, notes, and snippets.

@walkerh
Created February 21, 2024 02:08
Show Gist options
  • Save walkerh/b3a8a1c27744e602579958bf337babc5 to your computer and use it in GitHub Desktop.
Save walkerh/b3a8a1c27744e602579958bf337babc5 to your computer and use it in GitHub Desktop.
pytest bash functions demo
func() {
echo hello
echo MARKER
echo -- $@
var1=$1
var2=$2
}
[pytest]
addopts = --tb=no
testpaths = test*
addict==2.4.0
iniconfig==2.0.0
packaging==23.2
pluggy==1.4.0
pytest==8.0.1
from base64 import b64decode
from dataclasses import dataclass
from pprint import pprint
from subprocess import PIPE, run
from addict import Dict
UTF8_SLASH_MODE = dict(encoding="utf-8", errors="backslashreplace")
PIPE_OUTPUTS = dict(stdout=PIPE, stderr=PIPE)
RUN_KWARGS = UTF8_SLASH_MODE | PIPE_OUTPUTS
@dataclass(frozen=True)
class BashFunctionCallResult:
returncode: int
stderr: str
function_output: str
namespace: Dict[str, str]
def call_bash_function(source_file, function, exports, *args) -> BashFunctionCallResult:
"""
Calls a bash function during construction and stores the results as members:
returncode, stderr, function_output, and namespace. The standard output from
the function goes into function_output. All variables named in exports
(a string or list) are exported from bash into namespace.
"""
command_list = build_command_list(source_file, function, exports, args)
result = run(["bash"], input=command_list, **RUN_KWARGS)
return convert_completed_process(result)
def build_command_list(source_file, function, exports, args):
commands = (
start_bash_session(source_file)
+ [
call_bash_function_command(function, args),
]
+ build_export_commands(exports)
)
# ^^^ For each variable we want to export from bash, serialize the value
# using base64 to prevent whitespace issues.
command_list = "\n".join(commands)
return command_list
def start_bash_session(source_file):
return [
"set -Eeuo pipefail",
f"source {source_file}",
]
def call_bash_function_command(function, args):
argstr = " ".join([f"'{arg}'" for arg in args])
return f"{function} {argstr}"
def build_export_commands(exports):
if isinstance(exports, str):
exports = exports.split()
return ["echo MARKER"] + [
f'echo -n {name}=; echo -n "${name}" | base64' for name in exports
]
def convert_completed_process(result):
lines = result.stdout.splitlines()
# Use the LAST occurence of a line that is just MARKER to separate
# function output from exported variables:
marker_index = [i for i, s in enumerate(lines) if s == "MARKER"][-1]
function_output = "\n".join(lines[:marker_index])
var_data = lines[marker_index + 1 :]
namespace = {
k: b64decode(v).decode()
for k, v in (record.split("=", 1) for record in var_data)
}
returncode = result.returncode
stderr = result.stderr
return BashFunctionCallResult(returncode, stderr, function_output, namespace)
def test_a():
result = call_bash_function("lib.sh", "func", ["var1", "var2"], "foo\nbar", "spam")
assert result.stderr == ""
assert result.function_output == "hello\nMARKER\n-- foo bar spam"
assert result.namespace == {"var1": "foo\nbar", "var2": "spam"}
assert result.returncode == 0
def test_b():
result = call_bash_function("lib.sh", "func", "var1 var2", "foo\nbar", "spam")
assert result.stderr == ""
assert result.function_output == "hello\nMARKER\n-- foo bar spam"
assert result.namespace == {"var1": "foo\nbar", "var2": "spam"}
assert result.returncode == 0
def test_c():
result = call_bash_function("lib.sh", "func", "var2", "foo\nbar", "spam")
assert result.stderr == ""
assert result.function_output == "hello\nMARKER\n-- foo bar spam"
assert result.namespace == {"var2": "spam"}
assert result.returncode == 0
def test_d():
result = call_bash_function(
"lib.sh", "func", "var1 var2 var2", "foo\nbar", "sp''am", "eggs"
)
assert result.stderr == ""
assert result.function_output == "hello\nMARKER\n-- foo bar spam eggs"
assert result.namespace == {"var1": "foo\nbar", "var2": "spam"}
assert result.returncode == 0
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment