summaryrefslogtreecommitdiff
path: root/share
diff options
context:
space:
mode:
authorDaniel Silverstone <dsilvers@digital-scurf.org>2021-01-09 16:15:35 +0000
committerDaniel Silverstone <dsilvers@digital-scurf.org>2021-01-10 09:49:25 +0000
commitfcdbd77cc2b909b8a1d0fc8d2e6343bbc278470c (patch)
tree05420ead169a05ae3a2b0a7c4564e230596f1fb0 /share
parente9b941e5218e5e4293bb915b13f831baba246a89 (diff)
downloadsubplot-fcdbd77cc2b909b8a1d0fc8d2e6343bbc278470c.tar.gz
resource: Switch from 'templates' to 'share'
In a general sense, we will want to have more than just template files as resources. This shifts from the concept that the only thing resource-wise that subplot has is templates, to a more general shared resources concept without a default path beyond CWD. Signed-off-by: Daniel Silverstone <dsilvers@digital-scurf.org>
Diffstat (limited to 'share')
-rw-r--r--share/templates/bash/assert.sh19
-rw-r--r--share/templates/bash/cap.sh17
-rw-r--r--share/templates/bash/ctx.sh17
-rw-r--r--share/templates/bash/dict.sh50
-rw-r--r--share/templates/bash/files.sh22
-rw-r--r--share/templates/bash/template.sh.tera163
-rw-r--r--share/templates/bash/template.yaml8
-rw-r--r--share/templates/python/asserts.py23
-rw-r--r--share/templates/python/context.py95
-rw-r--r--share/templates/python/context_tests.py156
-rw-r--r--share/templates/python/encoding.py12
-rw-r--r--share/templates/python/encoding_tests.py19
-rw-r--r--share/templates/python/files.py23
-rw-r--r--share/templates/python/main.py97
-rw-r--r--share/templates/python/scenarios.py97
-rw-r--r--share/templates/python/template.py.tera78
-rw-r--r--share/templates/python/template.yaml9
-rw-r--r--share/templates/rust/template.rs.tera70
-rw-r--r--share/templates/rust/template.yaml2
19 files changed, 977 insertions, 0 deletions
diff --git a/share/templates/bash/assert.sh b/share/templates/bash/assert.sh
new file mode 100644
index 0000000..43bb11b
--- /dev/null
+++ b/share/templates/bash/assert.sh
@@ -0,0 +1,19 @@
+#!/bin/bash
+
+# Check two values for equality and give error if they are not equal
+assert_eq() {
+ if ! diff -u <(echo "$1") <(echo "$2")
+ then
+ echo "expected values to be identical, but they're not"
+ exit 1
+ fi
+}
+
+# Check first value contains second value.
+assert_contains() {
+ if ! echo "$1" | grep -F "$2" > /dev/null
+ then
+ echo "expected first value to contain second value"
+ exit 1
+ fi
+}
diff --git a/share/templates/bash/cap.sh b/share/templates/bash/cap.sh
new file mode 100644
index 0000000..8ea35d8
--- /dev/null
+++ b/share/templates/bash/cap.sh
@@ -0,0 +1,17 @@
+#!/bin/bash
+
+# Store step captures for calling the corresponding functions.
+
+cap_new() {
+ dict_new _cap
+}
+
+cap_set()
+{
+ dict_set _cap "$1" "$2"
+}
+
+cap_get()
+{
+ dict_get _cap "$1"
+}
diff --git a/share/templates/bash/ctx.sh b/share/templates/bash/ctx.sh
new file mode 100644
index 0000000..c9401c6
--- /dev/null
+++ b/share/templates/bash/ctx.sh
@@ -0,0 +1,17 @@
+#!/bin/bash
+
+# A context abstraction using dictionaries.
+
+ctx_new() {
+ dict_new _ctx
+}
+
+ctx_set()
+{
+ dict_set _ctx "$1" "$2"
+}
+
+ctx_get()
+{
+ dict_get _ctx "$1"
+}
diff --git a/share/templates/bash/dict.sh b/share/templates/bash/dict.sh
new file mode 100644
index 0000000..aea5b96
--- /dev/null
+++ b/share/templates/bash/dict.sh
@@ -0,0 +1,50 @@
+#!/bin/bash
+
+# Simple dictionary abstraction. All values are stored in files so
+# they can more easily be inspected.
+
+dict_new() {
+ local name="$1"
+ rm -rf "$name"
+ mkdir "$name"
+}
+
+dict_has() {
+ local name="$1"
+ local key="$2"
+ local f="$name/$key"
+ test -e "$f"
+}
+
+dict_get() {
+ local name="$1"
+ local key="$2"
+ local f="$name/$key"
+ cat "$f"
+}
+
+dict_get_default() {
+ local name="$1"
+ local key="$2"
+ local default="$3"
+ local f="$name/$key"
+ if [ -e "$f" ]
+ then
+ cat "$f"
+ else
+ echo "$default"
+ fi
+}
+
+dict_set() {
+ local name="$1"
+ local key="$2"
+ local value="$3"
+ local f="$name/$key"
+ echo "$value" > "$f"
+}
+
+dict_keys() {
+ local name="$1"
+ ls -1 "$name"
+}
diff --git a/share/templates/bash/files.sh b/share/templates/bash/files.sh
new file mode 100644
index 0000000..50c935d
--- /dev/null
+++ b/share/templates/bash/files.sh
@@ -0,0 +1,22 @@
+#!/bin/bash
+
+# Store files embedded in the markdown input.
+
+files_new() {
+ dict_new _files
+}
+
+files_set() {
+ dict_set _files "$1" "$2"
+}
+
+files_get() {
+ dict_get _files "$1"
+}
+
+
+# Decode a base64 encoded string.
+
+decode_base64() {
+ echo "$1" | base64 -d
+}
diff --git a/share/templates/bash/template.sh.tera b/share/templates/bash/template.sh.tera
new file mode 100644
index 0000000..5e92371
--- /dev/null
+++ b/share/templates/bash/template.sh.tera
@@ -0,0 +1,163 @@
+#!/usr/bin/env bash
+
+set -eu -o pipefail
+
+#############################################################################
+# Functions that implement steps.
+
+{% for func in functions %}
+#----------------------------------------------------------------------------
+# This code comes from: {{ func.source }}
+
+{{ func.code }}
+{% endfor %}
+
+
+#############################################################################
+# Scaffolding for generated test program.
+
+{% include "dict.sh" %}
+{% include "ctx.sh" %}
+{% include "cap.sh" %}
+{% include "files.sh" %}
+{% include "assert.sh" %}
+
+# Remember where we started from. The step functions may need to refer
+# to files there.
+srcdir="$(pwd)"
+echo "srcdir $srcdir"
+
+# Create a new temporary directory and chdir there. This allows step
+# functions to create new files in the current working directory
+# without having to be so careful.
+_datadir="$(mktemp -d)"
+echo "datadir $_datadir"
+cd "$_datadir"
+
+
+# Store test data files that were embedded in the source document.
+# Base64 encoding is used to allow arbitrary data.
+
+files_new
+{% for file in files %}
+# {{ file.filename }}
+filename="$(decode_base64 '{{ file.filename | base64 }}')"
+contents="$(decode_base64 '{{ file.contents | base64 }}')"
+files_set "$filename" "$contents"
+{% endfor %}
+
+
+#############################################################################
+# Code to implement the scenarios.
+
+{% for scenario in scenarios %}
+######################################
+# Scenario: {{ scenario.title }}
+scenario_{{ loop.index }}() {
+ local title scendir step name text ret cleanups steps
+ declare -a cleanups
+ declare -a steps
+
+ title="$(decode_base64 '{{ scenario.title | base64 }}')"
+ echo "scenario: $title"
+
+ scendir="$(mktemp -d -p "$_datadir")"
+ cd "$scendir"
+ export HOME="$scendir"
+ export TMPDIR="$scendir"
+
+ ctx_new
+ cleanups[0]=''
+ steps[0]=''
+ ret=0
+
+ {% for step in scenario.steps %}
+ if [ "$ret" = 0 ]
+ then
+ # Step: {{ step.text }}
+ step="{{ step.kind | lower }} $(decode_base64 '{{ step.text | base64 }}')"
+ echo " step: $step"
+
+ cap_new
+ {% for part in step.parts %}{% if part.CapturedText is defined -%}
+ name="$(decode_base64 '{{ part.CapturedText.name | base64 }}')"
+ text="$(decode_base64 '{{ part.CapturedText.text | base64 }}')"
+ cap_set "$name" "$text"
+ {% endif -%}
+ {% endfor -%}
+ if {{ step.function }}
+ then
+ cleanup='{{ step.cleanup }}'
+ if [ "$cleanup" != "" ]
+ then
+ {% raw %}
+ i=${#cleanups}
+ cleanups[$i]="$cleanup"
+ steps[$i]="$step"
+ {% endraw %}
+ fi
+ else
+ ret=$?
+ fi
+ fi
+ {% endfor %}
+
+ {% raw %}
+ echo "${!cleanups[*]}" | tr ' ' '\n' | tac | while read i
+ do
+ step="${steps[$i]}"
+ func="${cleanups[$i]}"
+ echo " cleanup: $step"
+ $func
+ done
+ {% endraw %}
+
+ return $ret
+}
+{% endfor %}
+
+#############################################################################
+# Make the environment minimal.
+
+# Write to stdout the names of all environment variables, one per
+# line. Handle also cases where the value of an environment variable
+# contains newlines.
+envnames()
+{
+ env -0 | xargs -0 -n1 -i'{}' sh -c "printf '%s\n' '{}' | head -n1 | sed 's/=.*//'"
+}
+
+# Unset all environment variables. At the beginning of each scenario,
+# some additional ones will be set to the per-scenario directory.
+unset $(envnames)
+export PATH=/bin:/usr/bin
+export SHELL=/bin/sh
+
+
+#############################################################################
+# Run the scenarios.
+
+if [ "$#" = 0 ]
+then {% for scenario in scenarios %}
+ scenario_{{ loop.index }}{% endfor %}
+else
+
+
+ for pattern in "$@"
+ do
+ pattern="$(echo "$pattern" | tr A-Z a-z)"
+{% for scenario in scenarios %}
+ if echo "{{ scenario.title | lower }}" | grep -F -e "$pattern" > /dev/null
+ then
+ scenario_{{ loop.index }}
+ fi
+{% endfor %}
+ done
+fi
+
+
+#############################################################################
+# Clean up temporary directory and report success.
+
+rm -rf "$_datadir"
+echo "OK, all scenarios finished successfully"
diff --git a/share/templates/bash/template.yaml b/share/templates/bash/template.yaml
new file mode 100644
index 0000000..01269dd
--- /dev/null
+++ b/share/templates/bash/template.yaml
@@ -0,0 +1,8 @@
+template: template.sh.tera
+run: bash
+helpers:
+ - assert.sh
+ - cap.sh
+ - ctx.sh
+ - dict.sh
+ - files.sh
diff --git a/share/templates/python/asserts.py b/share/templates/python/asserts.py
new file mode 100644
index 0000000..c898454
--- /dev/null
+++ b/share/templates/python/asserts.py
@@ -0,0 +1,23 @@
+# Check two values for equality and give error if they are not equal
+def assert_eq(a, b):
+ assert a == b, "expected %r == %r" % (a, b)
+
+
+# Check two values for inequality and give error if they are equal
+def assert_ne(a, b):
+ assert a != b, "expected %r != %r" % (a, b)
+
+
+# Check that two dict values are equal.
+def assert_dict_eq(a, b):
+ assert isinstance(a, dict)
+ assert isinstance(b, dict)
+ for key in a:
+ assert key in b, f"exected {key} in both dicts"
+ av = a[key]
+ bv = b[key]
+ assert_eq(type(av), type(bv))
+ if isinstance(av, list):
+ assert_eq(list(sorted(av)), list(sorted(bv)))
+ for key in b:
+ assert key in a, f"exected {key} in both dicts"
diff --git a/share/templates/python/context.py b/share/templates/python/context.py
new file mode 100644
index 0000000..d61316e
--- /dev/null
+++ b/share/templates/python/context.py
@@ -0,0 +1,95 @@
+import logging
+import re
+
+
+# Store context between steps.
+class Context:
+ def __init__(self):
+ self._vars = {}
+ self._ns = {}
+
+ def as_dict(self):
+ return dict(self._vars)
+
+ def get(self, key, default=None):
+ return self._vars.get(key, default)
+
+ def __getitem__(self, key):
+ return self._vars[key]
+
+ def __setitem__(self, key, value):
+ logging.debug("Context: key {!r} set to {!r}".format(key, value))
+ self._vars[key] = value
+
+ def __contains__(self, key):
+ return key in self._vars
+
+ def __delitem__(self, key):
+ del self._vars[key]
+
+ def __repr__(self):
+ return repr({"vars": self._vars, "namespaces": self._ns})
+
+ def declare(self, name):
+ if name not in self._ns:
+ self._ns[name] = NameSpace(name)
+ logging.debug(f"Context: declared {name}")
+ return self._ns[name]
+
+ def remember_value(self, name, value):
+ ns = self.declare("_values")
+ if name in ns:
+ raise KeyError(name)
+ ns[name] = value
+
+ def recall_value(self, name):
+ ns = self.declare("_values")
+ if name not in ns:
+ raise KeyError(name)
+ return ns[name]
+
+ def expand_values(self, pattern):
+ parts = []
+ while pattern:
+ m = re.search(r"(?<!\$)\$\{(?P<name>\S*)\}", pattern)
+ if not m:
+ parts.append(pattern)
+ break
+ name = m.group("name")
+ if not name:
+ raise KeyError("empty name in expansion")
+ value = self.recall_value(name)
+ parts.append(value)
+ pattern = pattern[m.end() :]
+ return "".join(parts)
+
+
+class NameSpace:
+ def __init__(self, name):
+ self.name = name
+ self._dict = {}
+
+ def as_dict(self):
+ return dict(self._dict)
+
+ def get(self, key, default=None):
+ if key not in self._dict:
+ if default is None:
+ return None
+ self._dict[key] = default
+ return self._dict[key]
+
+ def __setitem__(self, key, value):
+ self._dict[key] = value
+
+ def __getitem__(self, key):
+ return self._dict[key]
+
+ def __contains__(self, key):
+ return key in self._dict
+
+ def __delitem__(self, key):
+ del self._dict[key]
+
+ def __repr__(self):
+ return repr(self._dict)
diff --git a/share/templates/python/context_tests.py b/share/templates/python/context_tests.py
new file mode 100644
index 0000000..c91350e
--- /dev/null
+++ b/share/templates/python/context_tests.py
@@ -0,0 +1,156 @@
+import unittest
+
+from context import Context
+
+
+class ContextTests(unittest.TestCase):
+ def test_converts_to_empty_dict_initially(self):
+ ctx = Context()
+ self.assertEqual(ctx.as_dict(), {})
+
+ def test_set_item(self):
+ ctx = Context()
+ ctx["foo"] = "bar"
+ self.assertEqual(ctx["foo"], "bar")
+
+ def test_does_not_contain_item(self):
+ ctx = Context()
+ self.assertFalse("foo" in ctx)
+
+ def test_no_longer_contains_item(self):
+ ctx = Context()
+ ctx["foo"] = "bar"
+ del ctx["foo"]
+ self.assertFalse("foo" in ctx)
+
+ def test_contains_item(self):
+ ctx = Context()
+ ctx["foo"] = "bar"
+ self.assertTrue("foo" in ctx)
+
+ def test_get_returns_default_if_item_does_not_exist(self):
+ ctx = Context()
+ self.assertEqual(ctx.get("foo"), None)
+
+ def test_get_returns_specified_default_if_item_does_not_exist(self):
+ ctx = Context()
+ self.assertEqual(ctx.get("foo", "bar"), "bar")
+
+ def test_get_returns_value_if_item_exists(self):
+ ctx = Context()
+ ctx["foo"] = "bar"
+ self.assertEqual(ctx.get("foo", "yo"), "bar")
+
+ def test_reprs_itself_when_empty(self):
+ ctx = Context()
+ self.assertFalse("foo" in repr(ctx))
+
+ def test_reprs_itself_when_not_empty(self):
+ ctx = Context()
+ ctx["foo"] = "bar"
+ self.assertTrue("foo" in repr(ctx))
+ self.assertTrue("bar" in repr(ctx))
+
+
+class ContextMemoryTests(unittest.TestCase):
+ def test_recall_raises_exception_for_unremembered_value(self):
+ ctx = Context()
+ with self.assertRaises(KeyError):
+ ctx.recall_value("foo")
+
+ def test_recall_returns_remembered_value(self):
+ ctx = Context()
+ ctx.remember_value("foo", "bar")
+ self.assertEqual(ctx.recall_value("foo"), "bar")
+
+ def test_remember_raises_exception_for_previously_remembered(self):
+ ctx = Context()
+ ctx.remember_value("foo", "bar")
+ with self.assertRaises(KeyError):
+ ctx.remember_value("foo", "bar")
+
+ def test_expand_returns_pattern_without_values_as_is(self):
+ ctx = Context()
+ self.assertEqual(ctx.expand_values("foo"), "foo")
+
+ def test_expand_allows_double_dollar_escapes(self):
+ ctx = Context()
+ self.assertEqual(ctx.expand_values("$${foo}"), "$${foo}")
+
+ def test_expand_raises_exception_for_empty_name_expansion_as_is(self):
+ ctx = Context()
+ with self.assertRaises(KeyError):
+ ctx.expand_values("${}")
+
+ def test_expand_raises_error_for_unrememebered_values(self):
+ ctx = Context()
+ with self.assertRaises(KeyError):
+ ctx.expand_values("${foo}")
+
+ def test_expands_rememebered_values(self):
+ ctx = Context()
+ ctx.remember_value("foo", "bar")
+ self.assertEqual(ctx.expand_values("${foo}"), "bar")
+
+
+class ContextNamepaceTests(unittest.TestCase):
+ def test_explicit_namespaces_are_empty_dicts_initially(self):
+ ctx = Context()
+ ns = ctx.declare("foo")
+ self.assertEqual(ns.as_dict(), {})
+
+ def test_declaring_explicit_namespaces_is_idempotent(self):
+ ctx = Context()
+ ns1 = ctx.declare("foo")
+ ns2 = ctx.declare("foo")
+ self.assertEqual(id(ns1), id(ns2))
+
+ def test_knows_their_name(self):
+ ctx = Context()
+ ns = ctx.declare("foo")
+ self.assertEqual(ns.name, "foo")
+
+ def test_sets_key(self):
+ ctx = Context()
+ ns = ctx.declare("foo")
+ ns["bar"] = "yo"
+ self.assertEqual(ns["bar"], "yo")
+
+ def test_gets(self):
+ ctx = Context()
+ ns = ctx.declare("foo")
+ ns["bar"] = "yo"
+ self.assertEqual(ns.get("bar", "argh"), "yo")
+
+ def test_get_without_default_doesnt_set(self):
+ ctx = Context()
+ ns = ctx.declare("foo")
+ ns.get("bar")
+ self.assertFalse("bar" in ns)
+
+ def test_gets_with_default_sets_as_well(self):
+ ctx = Context()
+ ns = ctx.declare("foo")
+ self.assertEqual(ns.get("bar", "yo"), "yo")
+ self.assertEqual(ns["bar"], "yo")
+
+ def test_does_not_contain_key(self):
+ ctx = Context()
+ ns = ctx.declare("foo")
+ self.assertFalse("bar" in ns)
+
+ def test_contains_key(self):
+ ctx = Context()
+ ns = ctx.declare("foo")
+ ns["bar"] = "yo"
+ self.assertTrue("bar" in ns)
+
+ def test_deletes(self):
+ ctx = Context()
+ ns = ctx.declare("foo")
+ ns["bar"] = "yo"
+ del ns["bar"]
+ self.assertFalse("bar" in ns)
+
+
+unittest.main()
diff --git a/share/templates/python/encoding.py b/share/templates/python/encoding.py
new file mode 100644
index 0000000..1efb95e
--- /dev/null
+++ b/share/templates/python/encoding.py
@@ -0,0 +1,12 @@
+# Decode a base64 encoded string. Result is binary or unicode string.
+
+
+import base64
+
+
+def decode_bytes(s):
+ return base64.b64decode(s)
+
+
+def decode_str(s):
+ return base64.b64decode(s).decode()
diff --git a/share/templates/python/encoding_tests.py b/share/templates/python/encoding_tests.py
new file mode 100644
index 0000000..4167aa4
--- /dev/null
+++ b/share/templates/python/encoding_tests.py
@@ -0,0 +1,19 @@
+import base64
+import unittest
+
+import encoding
+
+
+class EncodingTests(unittest.TestCase):
+ def test_str_roundtrip(self):
+ original = "foo\nbar\0"
+ encoded = base64.b64encode(original.encode())
+ self.assertEqual(encoding.decode_str(encoded), original)
+
+ def test_bytes_roundtrip(self):
+ original = b"foo\nbar\0"
+ encoded = base64.b64encode(original)
+ self.assertEqual(encoding.decode_bytes(encoded), original)
+
+
+unittest.main()
diff --git a/share/templates/python/files.py b/share/templates/python/files.py
new file mode 100644
index 0000000..6346172
--- /dev/null
+++ b/share/templates/python/files.py
@@ -0,0 +1,23 @@
+# Retrieve an embedded test data file using filename.
+
+
+class Files:
+ def __init__(self):
+ self._files = {}
+
+ def set(self, filename, content):
+ self._files[filename] = content
+
+ def get(self, filename):
+ return self._files[filename]
+
+
+_files = Files()
+
+
+def store_file(filename, content):
+ _files.set(filename, content)
+
+
+def get_file(filename):
+ return _files.get(filename)
diff --git a/share/templates/python/main.py b/share/templates/python/main.py
new file mode 100644
index 0000000..87e2782
--- /dev/null
+++ b/share/templates/python/main.py
@@ -0,0 +1,97 @@
+import argparse
+import logging
+import os
+import random
+import shutil
+import tarfile
+import tempfile
+
+
+# Remember where we started from. The step functions may need to refer
+# to files there.
+srcdir = os.getcwd()
+print("srcdir", srcdir)
+
+# Create a new temporary directory and chdir there. This allows step
+# functions to create new files in the current working directory
+# without having to be so careful.
+_datadir = tempfile.mkdtemp()
+print("datadir", _datadir)
+os.chdir(_datadir)
+
+
+def parse_command_line():
+ p = argparse.ArgumentParser()
+ p.add_argument("--log")
+ p.add_argument("--env", action="append", default=[])
+ p.add_argument("--save-on-failure")
+ p.add_argument("patterns", nargs="*")
+ return p.parse_args()
+
+
+def setup_logging(args):
+ if args.log:
+ fmt = "%(asctime)s %(levelname)s %(message)s"
+ datefmt = "%Y-%m-%d %H:%M:%S"
+ formatter = logging.Formatter(fmt, datefmt)
+
+ filename = os.path.abspath(os.path.join(srcdir, args.log))
+ handler = logging.FileHandler(filename)
+ handler.setFormatter(formatter)
+ else:
+ handler = logging.NullHandler()
+
+ logger = logging.getLogger()
+ logger.addHandler(handler)
+ logger.setLevel(logging.DEBUG)
+
+
+def save_directory(dirname, tarname):
+ print("tarname", tarname)
+ logging.info("Saving {} to {}".format(dirname, tarname))
+ tar = tarfile.open(tarname, "w")
+ tar.add(dirname, arcname="datadir")
+ tar.close()
+
+
+def main(scenarios):
+ args = parse_command_line()
+ setup_logging(args)
+ logging.info("Test program starts")
+
+ logging.info("patterns: {}".format(args.patterns))
+ if len(args.patterns) == 0:
+ logging.info("Executing all scenarios")
+ todo = list(scenarios)
+ random.shuffle(todo)
+ else:
+ logging.info("Executing requested scenarios only: {}".format(args.patterns))
+ patterns = [arg.lower() for arg in args.patterns]
+ todo = [
+ scen
+ for scen in scenarios
+ if any(pattern in scen.get_title().lower() for pattern in patterns)
+ ]
+
+ extra_env = {}
+ for env in args.env:
+ (name, value) = env.split("=", 1)
+ extra_env[name] = value
+ logging.debug(f"args.env: {args.env}")
+ logging.debug(f"env vars from command line; {extra_env}")
+
+ try:
+ for scen in todo:
+ scen.run(_datadir, extra_env)
+ except Exception as e:
+ logging.error(str(e), exc_info=True)
+ if args.save_on_failure:
+ print(args.save_on_failure)
+ filename = os.path.abspath(os.path.join(srcdir, args.save_on_failure))
+ print(filename)
+ save_directory(_datadir, filename)
+ raise
+
+ shutil.rmtree(_datadir)
+ print("OK, all scenarios finished successfully")
+ logging.info("OK, all scenarios finished successfully")
diff --git a/share/templates/python/scenarios.py b/share/templates/python/scenarios.py
new file mode 100644
index 0000000..e2703df
--- /dev/null
+++ b/share/templates/python/scenarios.py
@@ -0,0 +1,97 @@
+import logging
+import os
+import tempfile
+
+
+#############################################################################
+# Code to implement the scenarios.
+
+
+class Step:
+ def __init__(self):
+ self._kind = None
+ self._text = None
+ self._args = {}
+ self._function = None
+ self._cleanup = None
+
+ def set_kind(self, kind):
+ self._kind = kind
+
+ def set_text(self, text):
+ self._text = text
+
+ def set_arg(self, name, value):
+ self._args[name] = value
+
+ def set_function(self, function):
+ self._function = function
+
+ def set_cleanup(self, cleanup):
+ self._cleanup = cleanup
+
+ def do(self, ctx):
+ print(" step: {} {}".format(self._kind, self._text))
+ logging.info(" step: {} {}".format(self._kind, self._text))
+ self._function(ctx, **self._args)
+
+ def cleanup(self, ctx):
+ if self._cleanup:
+ print(" cleanup: {} {}".format(self._kind, self._text))
+ logging.info(" cleanup: {} {}".format(self._kind, self._text))
+ self._cleanup(ctx)
+ else:
+ logging.info(" no cleanup defined: {} {}".format(self._kind, self._text))
+
+
+class Scenario:
+ def __init__(self, ctx):
+ self._title = None
+ self._steps = []
+ self._ctx = ctx
+
+ def get_title(self):
+ return self._title
+
+ def set_title(self, title):
+ self._title = title
+
+ def append_step(self, step):
+ self._steps.append(step)
+
+ def run(self, datadir, extra_env):
+ print("scenario: {}".format(self._title))
+ logging.info("Scenario: {}".format(self._title))
+ logging.info("extra environment variables: {}".format(extra_env))
+
+ scendir = tempfile.mkdtemp(dir=datadir)
+ os.chdir(scendir)
+ self._set_environment_variables_to(scendir, extra_env)
+
+ done = []
+ ctx = self._ctx
+ try:
+ for step in self._steps:
+ step.do(ctx)
+ done.append(step)
+ except Exception as e:
+ logging.error(str(e), exc_info=True)
+ for step in reversed(done):
+ step.cleanup(ctx)
+ raise
+ for step in reversed(done):
+ step.cleanup(ctx)
+
+ def _set_environment_variables_to(self, scendir, extra_env):
+ minimal = {
+ "PATH": "/bin:/usr/bin",
+ "SHELL": "/bin/sh",
+ "HOME": scendir,
+ "TMPDIR": scendir,
+ }
+
+ os.environ.clear()
+ os.environ.update(minimal)
+ os.environ.update(extra_env)
+ logging.debug(f"extra_env: {dict(extra_env)!r}")
+ logging.debug(f"os.environ: {dict(os.environ)!r}")
diff --git a/share/templates/python/template.py.tera b/share/templates/python/template.py.tera
new file mode 100644
index 0000000..aa97cf0
--- /dev/null
+++ b/share/templates/python/template.py.tera
@@ -0,0 +1,78 @@
+#############################################################################
+# Functions that implement steps.
+
+{% for func in functions %}
+#----------------------------------------------------------------------------
+# This code comes from: {{ func.source }}
+
+{{ func.code }}
+{% endfor %}
+
+
+#############################################################################
+# Scaffolding for generated test program.
+
+{% include "context.py" %}
+{% include "encoding.py" %}
+{% include "files.py" %}
+{% include "asserts.py" %}
+{% include "scenarios.py" %}
+{% include "main.py" %}
+
+
+#############################################################################
+# Test data files that were embedded in the source document. Base64
+# encoding is used to allow arbitrary data.
+
+{% for file in files %}
+# {{ file.filename }}
+filename = decode_str('{{ file.filename | base64 }}')
+contents = decode_bytes('{{ file.contents | base64 }}')
+store_file(filename, contents)
+{% endfor %}
+
+
+
+#############################################################################
+# Classes for individual scenarios.
+
+{% for scenario in scenarios %}
+#----------------------------------------------------------------------------
+# Scenario: {{ scenario.title }}
+class Scenario_{{ loop.index }}():
+ def __init__(self):
+ ctx = Context()
+ self._scenario = Scenario(ctx)
+ self._scenario.set_title(decode_str('{{ scenario.title | base64 }}'))
+ {% for step in scenario.steps %}
+ # Step: {{ step.text }}
+ step = Step()
+ step.set_kind('{{ step.kind | lower }}')
+ step.set_text(decode_str('{{ step.text | base64 }}'))
+ step.set_function({{ step.function }})
+ if '{{ step.cleanup }}':
+ step.set_cleanup({{ step.cleanup }})
+ self._scenario.append_step(step)
+ {% for part in step.parts %}{% if part.CapturedText is defined -%}
+ name = decode_str('{{ part.CapturedText.name | base64 }}')
+ text = decode_str('{{ part.CapturedText.text | base64 }}')
+ step.set_arg(name, text)
+ {% endif -%}
+ {% endfor -%}
+ {% endfor %}
+
+ def get_title(self):
+ return self._scenario.get_title()
+
+ def run(self, datadir, extra_env):
+ self._scenario.run(datadir, extra_env)
+{% endfor %}
+
+_scenarios = { {% for scenario in scenarios %}
+ Scenario_{{ loop.index }}(),{% endfor %}
+}
+
+
+#############################################################################
+# Call main function and clean up.
+main(_scenarios)
diff --git a/share/templates/python/template.yaml b/share/templates/python/template.yaml
new file mode 100644
index 0000000..73f2510
--- /dev/null
+++ b/share/templates/python/template.yaml
@@ -0,0 +1,9 @@
+template: template.py.tera
+helpers:
+ - context.py
+ - encoding.py
+ - files.py
+ - asserts.py
+ - scenarios.py
+ - main.py
+run: python3
diff --git a/share/templates/rust/template.rs.tera b/share/templates/rust/template.rs.tera
new file mode 100644
index 0000000..c972d37
--- /dev/null
+++ b/share/templates/rust/template.rs.tera
@@ -0,0 +1,70 @@
+use subplotlib::prelude::*;
+
+{% for func in functions %}
+
+// --------------------------------
+// This came from {{ func.source }}
+
+{{ func.code }}
+
+{% endfor %}
+
+// --------------------------------
+
+lazy_static! {
+ static ref SUBPLOT_EMBEDDED_FILES: Vec<SubplotDataFile> = vec![
+{% for file in files %}
+ SubplotDataFile::new("{{ file.filename | base64 }}",
+ "{{ file.contents | base64 }}"),
+{% endfor %}
+ ];
+}
+
+{% for scenario in scenarios %}
+
+// ---------------------------------
+
+// {{ scenario.title | commentsafe }}
+#[test]
+fn {{ scenario.title | nameslug }}() {
+ let mut scenario = Scenario::new(&base64_decode("{{scenario.title | base64}}"));
+ {% for step in scenario.steps %}
+ let step = {{step.function}}::Builder::default()
+ {% for part in step.parts %}{% if part.CapturedText is defined -%}
+ {%- set name = part.CapturedText.name -%}
+ {%- set text = part.CapturedText.text -%}
+ {%- set type = step.types[name] | default(value='text') -%}
+ .{{name}}(
+ {% if type in ['number', 'int', 'uint'] %}{{text}}
+ {%- elif type in ['text', 'word']%}
+ // "{{text | commentsafe }}"
+ &base64_decode("{{text | base64}}"
+ )
+ {%- elif type in ['file'] %}
+ {
+ use std::path::PathBuf;
+ // {{ text | commentsafe }}
+ let target_name: PathBuf = base64_decode("{{ text | base64 }}").into();
+ SUBPLOT_EMBEDDED_FILES
+ .iter()
+ .find(|df| df.name() == target_name)
+ .expect("Unable to find file at runtime")
+ .clone()
+ }
+ {%- else %} /* WOAH unknown type {{step.types[name]}} */ {{text}}
+ {%- endif %}
+ )
+ {% endif -%}
+ {% endfor -%}
+ .build();
+ {%- if step.cleanup %}
+ let cleanup = {{step.cleanup}}::Builder::default().build();
+ scenario.add_step(step, Some(cleanup));
+ {%- else %}
+ scenario.add_step(step, None);
+ {%- endif %}
+ {% endfor %}
+
+ scenario.run().unwrap();
+}
+{% endfor %}
diff --git a/share/templates/rust/template.yaml b/share/templates/rust/template.yaml
new file mode 100644
index 0000000..110f5df
--- /dev/null
+++ b/share/templates/rust/template.yaml
@@ -0,0 +1,2 @@
+template: template.rs.tera
+run: cargo test