aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJoseph Hunkeler <jhunkeler@gmail.com>2024-09-13 09:58:17 -0400
committerJoseph Hunkeler <jhunkeler@gmail.com>2024-09-18 23:06:08 -0400
commit8f17199d16bcdb29516d34514f95d1a117f6bd26 (patch)
tree15b96d2d2ab577472fcf392aea7cc4f15f8de360
parentb7251ce3bf65bcbec7ecbb98a0eb0b3c9abde507 (diff)
downloadstasis-8f17199d16bcdb29516d34514f95d1a117f6bd26.tar.gz
Implement multiprocessing pool(s)
* Adds --cpu-limit and --parallel-fail-fast arguments * Adds disable, parallel, and setup_script keys to [test] blocks
-rw-r--r--README.md55
-rw-r--r--include/delivery.h3
-rw-r--r--include/template_func_proto.h1
-rw-r--r--src/delivery.c187
-rw-r--r--src/globals.c28
-rw-r--r--src/stasis_main.c27
-rw-r--r--src/template_func_proto.c37
7 files changed, 252 insertions, 86 deletions
diff --git a/README.md b/README.md
index 586237a..b39cbe1 100644
--- a/README.md
+++ b/README.md
@@ -117,6 +117,8 @@ Create some test cases for packages.
[test:our_cool_program]
version = 1.2.3
repository = https://github.com/org/our_cool_program
+script_setup =
+ pip install -e '.[test]'
script =
pytest -fEsx \
--basetemp="{{ func:basetemp_dir() }}" \
@@ -126,6 +128,8 @@ script =
[test:our_other_cool_program]
version = 4.5.6
repository = https://github.com/org/our_other_cool_program
+script_setup =
+ pip install -e '.[test]'
script =
pytest -fEsx \
--basetemp="{{ func:basetemp_dir() }}" \
@@ -143,22 +147,24 @@ stasis mydelivery.ini
## Command Line Options
-| Long Option | Short Option | Purpose |
-|:--------------------|:------------:|:---------------------------------------------------------------|
-| --help | -h | Display usage statement |
-| --version | -V | Display program version |
-| --continue-on-error | -C | Allow tests to fail |
-| --config ARG | -c ARG | Read STASIS configuration file |
-| --python ARG | -p ARG | Override version of Python in configuration |
-| --verbose | -v | Increase output verbosity |
-| --unbuffered | -U | Disable line buffering |
-| --update-base | n/a | Update conda installation prior to STATIS environment creation |
-| --overwrite | n/a | Overwrite an existing release |
-| --no-docker | n/a | Do not build docker images |
-| --no-artifactory | n/a | Do not upload artifacts to Artifactory |
-| --no-testing | n/a | Do not execute test scripts |
-| --no-rewrite | n/a | Do not rewrite paths and URLs in output files |
-| DELIVERY_FILE | n/a | STASIS delivery file |
+| Long Option | Short Option | Purpose |
+|:---------------------|:------------:|:---------------------------------------------------------------|
+| --help | -h | Display usage statement |
+| --version | -V | Display program version |
+| --continue-on-error | -C | Allow tests to fail |
+| --config ARG | -c ARG | Read STASIS configuration file |
+| --cpu-limit ARG | -l ARG | Number of processes to spawn concurrently (default: cpus - 1) |
+| --python ARG | -p ARG | Override version of Python in configuration |
+| --verbose | -v | Increase output verbosity |
+| --unbuffered | -U | Disable line buffering |
+| --update-base | n/a | Update conda installation prior to STATIS environment creation |
+| --parallel-fail-fast | n/a | On test error, terminate all concurrent tasks |
+| --overwrite | n/a | Overwrite an existing release |
+| --no-docker | n/a | Do not build docker images |
+| --no-artifactory | n/a | Do not upload artifacts to Artifactory |
+| --no-testing | n/a | Do not execute test scripts |
+| --no-rewrite | n/a | Do not rewrite paths and URLs in output files |
+| DELIVERY_FILE | n/a | STASIS delivery file |
## Environment variables
@@ -259,13 +265,16 @@ Environment variables exported are _global_ to all programs executed by stasis.
Sections starting with `test:` will be used during the testing phase of the stasis pipeline. Where the value of `name` following the colon is an arbitrary value, and only used for reporting which test-run is executing. Section names must be unique.
-| Key | Type | Purpose | Required |
-|--------------|--------|-------------------------------------------------------|----------|
-| build_recipe | String | Git repository path to package's conda recipe | N |
-| repository | String | Git repository path or URL to clone | Y |
-| version | String | Git commit or tag to check out | Y |
-| runtime | List | Export environment variables specific to test context | Y |
-| script | List | Body of a shell script that will execute the tests | Y |
+| Key | Type | Purpose | Required |
+|--------------|---------|-------------------------------------------------------------|----------|
+| disable | Boolean | Disable `script` execution (`script_setup` always executes) | N |
+| parallel | Boolean | Execute test block in parallel (default) or sequentially | N |
+| build_recipe | String | Git repository path to package's conda recipe | N |
+| repository | String | Git repository path or URL to clone | Y |
+| version | String | Git commit or tag to check out | Y |
+| runtime | List | Export environment variables specific to test context | Y |
+| script_setup | List | Body of a shell script that will install dependencies | N |
+| script | List | Body of a shell script that will execute the tests | Y |
### deploy:artifactory:_name_
diff --git a/include/delivery.h b/include/delivery.h
index 067cd0b..6dd6cc4 100644
--- a/include/delivery.h
+++ b/include/delivery.h
@@ -149,7 +149,10 @@ struct Delivery {
char *name; ///< Name of package
char *version; ///< Version of package
char *repository; ///< Git repository of package
+ char *script_setup; ///< Commands to execute before the main script
char *script; ///< Commands to execute
+ bool disable; ///< Toggle a test block
+ bool parallel; ///< Toggle parallel or serial execution
char *build_recipe; ///< Conda recipe to build (optional)
char *repository_info_ref; ///< Git commit hash
char *repository_info_tag; ///< Git tag (first parent)
diff --git a/include/template_func_proto.h b/include/template_func_proto.h
index 7778a11..0c8bbb7 100644
--- a/include/template_func_proto.h
+++ b/include/template_func_proto.h
@@ -7,5 +7,6 @@ int get_github_release_notes_tplfunc_entrypoint(void *frame, void *data_out);
int get_github_release_notes_auto_tplfunc_entrypoint(void *frame, void *data_out);
int get_junitxml_file_entrypoint(void *frame, void *data_out);
int get_basetemp_dir_entrypoint(void *frame, void *data_out);
+int tox_run_entrypoint(void *frame, void *data_out);
#endif //TEMPLATE_FUNC_PROTO_H \ No newline at end of file
diff --git a/src/delivery.c b/src/delivery.c
index 3e99aad..2718c08 100644
--- a/src/delivery.c
+++ b/src/delivery.c
@@ -2,6 +2,7 @@
#include <fnmatch.h>
#include "core.h"
+#include "multiprocessing.h"
extern struct STASIS_GLOBAL globals;
@@ -560,7 +561,13 @@ static int populate_delivery_ini(struct Delivery *ctx, int render_mode) {
test->version = ini_getval_str(ini, section_name, "version", render_mode, &err);
test->repository = ini_getval_str(ini, section_name, "repository", render_mode, &err);
+ test->script_setup = ini_getval_str(ini, section_name, "script_setup", INI_READ_RAW, &err);
test->script = ini_getval_str(ini, section_name, "script", INI_READ_RAW, &err);
+ test->disable = ini_getval_bool(ini, section_name, "disable", render_mode, &err);
+ test->parallel = ini_getval_bool(ini, section_name, "parallel", render_mode, &err);
+ if (err) {
+ test->parallel = true;
+ }
test->repository_remove_tags = ini_getval_strlist(ini, section_name, "repository_remove_tags", LINE_SEP, render_mode, &err);
test->build_recipe = ini_getval_str(ini, section_name, "build_recipe", render_mode, &err);
test->runtime.environ = ini_getval_strlist(ini, section_name, "runtime", LINE_SEP, render_mode, &err);
@@ -1702,6 +1709,9 @@ int delivery_index_conda_artifacts(struct Delivery *ctx) {
}
void delivery_tests_run(struct Delivery *ctx) {
+ struct MultiProcessingPool *pool_parallel;
+ struct MultiProcessingPool *pool_serial;
+ struct MultiProcessingPool *pool_setup;
struct Process proc;
memset(&proc, 0, sizeof(proc));
@@ -1715,20 +1725,40 @@ void delivery_tests_run(struct Delivery *ctx) {
if (!ctx->tests[0].name) {
msg(STASIS_MSG_WARN | STASIS_MSG_L2, "no tests are defined!\n");
} else {
+ pool_parallel = mp_pool_init("parallel", ctx->storage.tmpdir);
+ if (!pool_parallel) {
+ perror("mp_pool_init/parallel");
+ exit(1);
+ }
+
+ pool_serial = mp_pool_init("serial", ctx->storage.tmpdir);
+ if (!pool_serial) {
+ perror("mp_pool_init/serial");
+ exit(1);
+ }
+
+ pool_setup = mp_pool_init("setup", ctx->storage.tmpdir);
+ if (!pool_setup) {
+ perror("mp_pool_init/setup");
+ exit(1);
+ }
+
+ const char *runner_cmd_fmt = "set -e -x\n%s\n";
for (size_t i = 0; i < sizeof(ctx->tests) / sizeof(ctx->tests[0]); i++) {
- if (!ctx->tests[i].name && !ctx->tests[i].repository && !ctx->tests[i].script) {
+ struct Test *test = &ctx->tests[i];
+ if (!test->name && !test->repository && !test->script) {
// skip unused test records
continue;
}
- msg(STASIS_MSG_L2, "Executing tests for %s %s\n", ctx->tests[i].name, ctx->tests[i].version);
- if (!ctx->tests[i].script || !strlen(ctx->tests[i].script)) {
+ msg(STASIS_MSG_L2, "Executing tests for %s %s\n", test->name, test->version);
+ if (!test->script || !strlen(test->script)) {
msg(STASIS_MSG_WARN | STASIS_MSG_L3, "Nothing to do. To fix, declare a 'script' in section: [test:%s]\n",
- ctx->tests[i].name);
+ test->name);
continue;
}
char destdir[PATH_MAX];
- sprintf(destdir, "%s/%s", ctx->storage.build_sources_dir, path_basename(ctx->tests[i].repository));
+ sprintf(destdir, "%s/%s", ctx->storage.build_sources_dir, path_basename(test->repository));
if (!access(destdir, F_OK)) {
msg(STASIS_MSG_L3, "Purging repository %s\n", destdir);
@@ -1736,44 +1766,31 @@ void delivery_tests_run(struct Delivery *ctx) {
COE_CHECK_ABORT(1, "Unable to remove repository\n");
}
}
- msg(STASIS_MSG_L3, "Cloning repository %s\n", ctx->tests[i].repository);
- if (!git_clone(&proc, ctx->tests[i].repository, destdir, ctx->tests[i].version)) {
- ctx->tests[i].repository_info_tag = strdup(git_describe(destdir));
- ctx->tests[i].repository_info_ref = strdup(git_rev_parse(destdir, "HEAD"));
+ msg(STASIS_MSG_L3, "Cloning repository %s\n", test->repository);
+ if (!git_clone(&proc, test->repository, destdir, test->version)) {
+ test->repository_info_tag = strdup(git_describe(destdir));
+ test->repository_info_ref = strdup(git_rev_parse(destdir, "HEAD"));
} else {
COE_CHECK_ABORT(1, "Unable to clone repository\n");
}
- if (ctx->tests[i].repository_remove_tags && strlist_count(ctx->tests[i].repository_remove_tags)) {
- filter_repo_tags(destdir, ctx->tests[i].repository_remove_tags);
+ if (test->repository_remove_tags && strlist_count(test->repository_remove_tags)) {
+ filter_repo_tags(destdir, test->repository_remove_tags);
}
if (pushd(destdir)) {
COE_CHECK_ABORT(1, "Unable to enter repository directory\n");
} else {
-#if 1
- int status;
- char *cmd = calloc(strlen(ctx->tests[i].script) + STASIS_BUFSIZ, sizeof(*cmd));
+ char *cmd = calloc(strlen(test->script) + STASIS_BUFSIZ, sizeof(*cmd));
+ if (!cmd) {
+ SYSERROR("Unable to allocate test script buffer: %s", strerror(errno));
+ exit(1);
+ }
- msg(STASIS_MSG_L3, "Testing %s\n", ctx->tests[i].name);
+ msg(STASIS_MSG_L3, "Testing %s\n", test->name);
memset(&proc, 0, sizeof(proc));
- // Apply workaround for tox positional arguments
- char *toxconf = NULL;
- if (!access("tox.ini", F_OK)) {
- if (!fix_tox_conf("tox.ini", &toxconf)) {
- msg(STASIS_MSG_L3, "Fixing tox positional arguments\n");
- if (!globals.workaround.tox_posargs) {
- globals.workaround.tox_posargs = calloc(PATH_MAX, sizeof(*globals.workaround.tox_posargs));
- } else {
- memset(globals.workaround.tox_posargs, 0, PATH_MAX);
- }
- snprintf(globals.workaround.tox_posargs, PATH_MAX - 1, "-c %s --root .", toxconf);
- }
- }
-
- // enable trace mode before executing each test script
- strcpy(cmd, ctx->tests[i].script);
+ strcpy(cmd, test->script);
char *cmd_rendered = tpl_render(cmd);
if (cmd_rendered) {
if (strcmp(cmd_rendered, cmd) != 0) {
@@ -1786,36 +1803,110 @@ void delivery_tests_run(struct Delivery *ctx) {
exit(1);
}
- puts(cmd);
+ if (test->disable) {
+ msg(STASIS_MSG_L2, "Script execution disabled by configuration\n", test->name);
+ guard_free(cmd);
+ continue;
+ }
+
char runner_cmd[0xFFFF] = {0};
- sprintf(runner_cmd, "set +x\nsource %s/etc/profile.d/conda.sh\nsource %s/etc/profile.d/mamba.sh\n\nmamba activate ${CONDA_DEFAULT_ENV}\n\n%s\n",
- ctx->storage.conda_install_prefix,
- ctx->storage.conda_install_prefix,
- cmd);
- status = shell(&proc, runner_cmd);
- if (status) {
- msg(STASIS_MSG_ERROR, "Script failure: %s\n%s\n\nExit code: %d\n", ctx->tests[i].name, ctx->tests[i].script, status);
+ char pool_name[100] = "parallel";
+ struct MultiProcessingTask *task = NULL;
+ struct MultiProcessingPool *pool = pool_parallel;
+ if (!test->parallel) {
+ pool = pool_serial;
+ memset(pool_name, 0, sizeof(pool_name));
+ strcpy(pool_name, "serial");
+ }
+
+ sprintf(runner_cmd, runner_cmd_fmt, cmd);
+ task = mp_task(pool, test->name, runner_cmd);
+ if (!task) {
+ SYSERROR("Failed to add task to %s pool: %s", pool_name, runner_cmd);
popd();
- guard_free(cmd);
if (!globals.continue_on_error) {
tpl_free();
delivery_free(ctx);
globals_free();
}
- COE_CHECK_ABORT(1, "Test failure");
+ exit(1);
}
guard_free(cmd);
+ popd();
+
+ }
+ }
- if (toxconf) {
- remove(toxconf);
- guard_free(toxconf);
+ // Configure "script_setup" tasks
+ // Directories should exist now, so no need to go through initializing everything all over again.
+ for (size_t i = 0; i < sizeof(ctx->tests) / sizeof(ctx->tests[0]); i++) {
+ struct Test *test = &ctx->tests[i];
+ if (test->script_setup) {
+ char destdir[PATH_MAX];
+ sprintf(destdir, "%s/%s", ctx->storage.build_sources_dir, path_basename(test->repository));
+ if (access(destdir, F_OK)) {
+ SYSERROR("%s: %s", destdir, strerror(errno));
+ exit(1);
+ }
+ if (!pushd(destdir)) {
+ char *cmd = calloc(strlen(test->script_setup) + STASIS_BUFSIZ, sizeof(*cmd));
+ if (!cmd) {
+ SYSERROR("Unable to allocate test script_setup buffer: %s", strerror(errno));
+ exit(1);
+ }
+
+ strcpy(cmd, test->script_setup);
+ char *cmd_rendered = tpl_render(cmd);
+ if (cmd_rendered) {
+ if (strcmp(cmd_rendered, cmd) != 0) {
+ strcpy(cmd, cmd_rendered);
+ cmd[strlen(cmd_rendered) ? strlen(cmd_rendered) - 1 : 0] = 0;
+ }
+ guard_free(cmd_rendered);
+ } else {
+ SYSERROR("An error occurred while rendering the following:\n%s", cmd);
+ exit(1);
+ }
+
+ struct MultiProcessingPool *pool = pool_setup;
+ struct MultiProcessingTask *task = NULL;
+ char runner_cmd[0xFFFF] = {0};
+ sprintf(runner_cmd, runner_cmd_fmt, cmd);
+
+ task = mp_task(pool, test->name, runner_cmd);
+ if (!task) {
+ SYSERROR("Failed to add task %s to setup pool: %s", test->name, runner_cmd);
+ popd();
+ if (!globals.continue_on_error) {
+ tpl_free();
+ delivery_free(ctx);
+ globals_free();
+ }
+ exit(1);
+ }
+ guard_free(cmd);
+ popd();
}
- popd();
-#else
- msg(STASIS_MSG_WARNING | STASIS_MSG_L3, "TESTING DISABLED BY CODE!\n");
-#endif
}
}
+
+ size_t opt_flags = 0;
+ opt_flags |= globals.parallel_fail_fast;
+
+ if (pool_setup->num_used) {
+ COE_CHECK_ABORT(mp_pool_join(pool_setup, 1, opt_flags) != 0, "Failure in setup task pool");
+ mp_pool_free(&pool_setup);
+ }
+
+ if (pool_parallel->num_used) {
+ COE_CHECK_ABORT(mp_pool_join(pool_parallel, globals.cpu_limit, opt_flags) != 0, "Failure in parallel task pool");
+ mp_pool_free(&pool_parallel);
+ }
+
+ if (pool_serial->num_used) {
+ COE_CHECK_ABORT(mp_pool_join(pool_serial, 1, opt_flags) != 0, "Failure in serial task pool");
+ mp_pool_free(&pool_serial);
+ }
}
}
diff --git a/src/globals.c b/src/globals.c
index 1e27959..667809b 100644
--- a/src/globals.c
+++ b/src/globals.c
@@ -25,19 +25,20 @@ const char *BANNER =
"Association of Universities for Research in Astronomy (AURA)\n";
struct STASIS_GLOBAL globals = {
- .verbose = false,
- .continue_on_error = false,
- .always_update_base_environment = false,
- .conda_fresh_start = true,
- .conda_install_prefix = NULL,
- .conda_packages = NULL,
- .pip_packages = NULL,
- .tmpdir = NULL,
- .enable_docker = true,
- .enable_artifactory = true,
- .enable_artifactory_build_info = true,
- .enable_testing = true,
- .enable_rewrite_spec_stage_2 = true,
+ .verbose = false, ///< Toggle verbose mode
+ .continue_on_error = false, ///< Do not stop program on error
+ .always_update_base_environment = false, ///< Run "conda update --all" after installing Conda
+ .conda_fresh_start = true, ///< Remove/reinstall Conda at startup
+ .conda_install_prefix = NULL, ///< Path to install Conda
+ .conda_packages = NULL, ///< Conda packages to install
+ .pip_packages = NULL, ///< Python packages to install
+ .tmpdir = NULL, ///< Path to store temporary data
+ .enable_docker = true, ///< Toggle docker usage
+ .enable_artifactory = true, ///< Toggle artifactory server usage
+ .enable_artifactory_build_info = true, ///< Toggle build-info uploads
+ .enable_testing = true, ///< Toggle [test] block "script" execution. "script_setup" always executes.
+ .enable_rewrite_spec_stage_2 = true, ///< Leave template stings in output files
+ .parallel_fail_fast = false, ///< Kill ALL multiprocessing tasks immediately on error
};
void globals_free() {
@@ -55,7 +56,6 @@ void globals_free() {
guard_free(globals.jfrog.jfrog_artifactory_base_url);
guard_free(globals.jfrog.jfrog_artifactory_product);
guard_free(globals.jfrog.remote_filename);
- guard_free(globals.workaround.tox_posargs);
guard_free(globals.workaround.conda_reactivate);
if (globals.envctl) {
envctl_free(&globals.envctl);
diff --git a/src/stasis_main.c b/src/stasis_main.c
index 7ea465c..164a9ca 100644
--- a/src/stasis_main.c
+++ b/src/stasis_main.c
@@ -12,15 +12,18 @@
#define OPT_NO_TESTING 1004
#define OPT_OVERWRITE 1005
#define OPT_NO_REWRITE_SPEC_STAGE_2 1006
+#define OPT_PARALLEL_FAIL_FAST 1007
static struct option long_options[] = {
{"help", no_argument, 0, 'h'},
{"version", no_argument, 0, 'V'},
{"continue-on-error", no_argument, 0, 'C'},
{"config", required_argument, 0, 'c'},
+ {"cpu-limit", required_argument, 0, 'l'},
{"python", required_argument, 0, 'p'},
{"verbose", no_argument, 0, 'v'},
{"unbuffered", no_argument, 0, 'U'},
{"update-base", no_argument, 0, OPT_ALWAYS_UPDATE_BASE},
+ {"parallel-fail-fast", no_argument, 0, OPT_PARALLEL_FAIL_FAST},
{"overwrite", no_argument, 0, OPT_OVERWRITE},
{"no-docker", no_argument, 0, OPT_NO_DOCKER},
{"no-artifactory", no_argument, 0, OPT_NO_ARTIFACTORY},
@@ -35,10 +38,12 @@ const char *long_options_help[] = {
"Display program version",
"Allow tests to fail",
"Read configuration file",
+ "Number of processes to spawn concurrently (default: cpus - 1)",
"Override version of Python in configuration",
"Increase output verbosity",
"Disable line buffering",
"Update conda installation prior to STASIS environment creation",
+ "On test error, terminate all concurrent tasks",
"Overwrite an existing release",
"Do not build docker images",
"Do not upload artifacts to Artifactory",
@@ -201,6 +206,13 @@ static void check_requirements(struct Delivery *ctx) {
}
int main(int argc, char *argv[]) {
+ /*
+ extern int exmain(int argc, char *argv[]);
+ exmain(argc, argv);
+ printf("ending program\n");
+ exit(0);
+ */
+
struct Delivery ctx;
struct Process proc = {
.f_stdout = "",
@@ -214,6 +226,10 @@ int main(int argc, char *argv[]) {
char installer_url[PATH_MAX];
char python_override_version[STASIS_NAME_MAX];
int user_disabled_docker = false;
+ globals.cpu_limit = get_cpu_count();
+ if (globals.cpu_limit > 1) {
+ globals.cpu_limit--;
+ }
memset(env_name, 0, sizeof(env_name));
memset(env_name_testing, 0, sizeof(env_name_testing));
@@ -241,9 +257,18 @@ int main(int argc, char *argv[]) {
case 'p':
strcpy(python_override_version, optarg);
break;
+ case 'l':
+ globals.cpu_limit = strtol(optarg, NULL, 10);
+ if (globals.cpu_limit < 1) {
+ globals.cpu_limit = 1;
+ }
+ break;
case OPT_ALWAYS_UPDATE_BASE:
globals.always_update_base_environment = true;
break;
+ case OPT_PARALLEL_FAIL_FAST:
+ globals.parallel_fail_fast = true;
+ break;
case 'U':
setenv("PYTHONUNBUFFERED", "1", 1);
fflush(stdout);
@@ -327,7 +352,6 @@ int main(int argc, char *argv[]) {
tpl_register("deploy.jfrog.repo", &globals.jfrog.repo);
tpl_register("deploy.jfrog.url", &globals.jfrog.url);
tpl_register("deploy.docker.registry", &ctx.deploy.docker.registry);
- tpl_register("workaround.tox_posargs", &globals.workaround.tox_posargs);
tpl_register("workaround.conda_reactivate", &globals.workaround.conda_reactivate);
// Expose function(s) to the template engine
@@ -336,6 +360,7 @@ int main(int argc, char *argv[]) {
tpl_register_func("get_github_release_notes_auto", &get_github_release_notes_auto_tplfunc_entrypoint, 1, &ctx);
tpl_register_func("junitxml_file", &get_junitxml_file_entrypoint, 1, &ctx);
tpl_register_func("basetemp_dir", &get_basetemp_dir_entrypoint, 1, &ctx);
+ tpl_register_func("tox_run", &tox_run_entrypoint, 2, &ctx);
// Set up PREFIX/etc directory information
// The user may manipulate the base directory path with STASIS_SYSCONFDIR
diff --git a/src/template_func_proto.c b/src/template_func_proto.c
index 3cf66e4..ebb595e 100644
--- a/src/template_func_proto.c
+++ b/src/template_func_proto.c
@@ -109,4 +109,41 @@ int get_basetemp_dir_entrypoint(void *frame, void *data_out) {
sprintf(*output, "%s/truth-%s-%s", ctx->storage.tmpdir, name, ctx->info.release_name);
return result;
+}
+
+int tox_run_entrypoint(void *frame, void *data_out) {
+ char **output = (char **) data_out;
+ struct tplfunc_frame *f = (struct tplfunc_frame *) frame;
+ const struct Delivery *ctx = (const struct Delivery *) f->data_in;
+
+ // Apply workaround for tox positional arguments
+ char *toxconf = NULL;
+ if (!access("tox.ini", F_OK)) {
+ if (!fix_tox_conf("tox.ini", &toxconf)) {
+ msg(STASIS_MSG_L3, "Fixing tox positional arguments\n");
+ *output = calloc(STASIS_BUFSIZ, sizeof(**output));
+ if (!*output) {
+ return -1;
+ }
+ char *basetemp_path = NULL;
+ if (get_basetemp_dir_entrypoint(f, &basetemp_path)) {
+ return -2;
+ }
+ char *jxml_path = NULL;
+ if (get_junitxml_file_entrypoint(f, &jxml_path)) {
+ return -3;
+ }
+ const char *tox_target = f->argv[0].t_char_ptr;
+ const char *pytest_args = f->argv[1].t_char_ptr;
+ if (isempty(toxconf) || !strcmp(toxconf, "/")) {
+ SYSERROR("Unsafe toxconf path: '%s'", toxconf);
+ return -4;
+ }
+ snprintf(*output, STASIS_BUFSIZ - 1, "\npip install tox && (tox -e py%s%s -c %s --root . -- --basetemp=\"%s\" --junitxml=\"%s\" %s ; rm -f '%s')\n", ctx->meta.python_compact, tox_target, toxconf, basetemp_path, jxml_path, pytest_args ? pytest_args : "", toxconf);
+
+ guard_free(jxml_path);
+ guard_free(basetemp_path);
+ }
+ }
+ return 0;
} \ No newline at end of file