diff --git a/.github/workflows/verify_and_publish.yml b/.github/workflows/verify_and_publish.yml index 8a281d55..1fd71427 100644 --- a/.github/workflows/verify_and_publish.yml +++ b/.github/workflows/verify_and_publish.yml @@ -159,7 +159,7 @@ jobs: coverage run \ $(which cijoe) --monitor -l \ --config cijoe-example-${{ matrix.usage_example }}/cijoe-config.toml \ - --workflow cijoe-example-${{ matrix.usage_example }}/cijoe-workflow.yaml + cijoe-example-${{ matrix.usage_example }}/cijoe-workflow.yaml - name: Coverage report run: | diff --git a/.gitignore b/.gitignore index 95a6ec47..d4d1fb64 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ .coverage cijoe-output* +cijoe-archive* tags selftest_results *.egg-info diff --git a/docs/source/050_usage_help.out b/docs/source/050_usage_help.out index 9f6beeff..e2011a13 100644 --- a/docs/source/050_usage_help.out +++ b/docs/source/050_usage_help.out @@ -1,35 +1,36 @@ -usage: cijoe [-h] [--config CONFIG] [--workflow WORKFLOW] [--output OUTPUT] - [--log-level] [--monitor] [--no-report] [--skip-report] - [--tag TAG] [--archive] [--produce-report] [--integrity-check] +usage: cijoe [-h] [--config CONFIG] [--output OUTPUT] [--log-level] + [--monitor] [--no-report] [--skip-report] [--tag TAG] + [--archive] [--produce-report] [--integrity-check] [--resources] [--example [EXAMPLE]] [--version] - [step ...] + [target] [step ...] options: -h, --help show this help message and exit -workflow: - Run workflow at '-w', using config at '-c', and output at '-o' +run: + Options for running a workflow script. - step Given a workflow; one or more workflow steps to run. - Else; one or more cijoe Python scripts to run. - (default: None) + target A cijoe workflow or script to run. + step Given a workflow, the steps of the workflow it + should run. If none are given, all steps are + run. --config CONFIG, -c CONFIG - Path to the Configuration file. (default: cijoe- - config.toml) - --workflow WORKFLOW, -w WORKFLOW - Path to workflow file. (default: cijoe-workflow.yaml) + Path a Configuration file. Multiple can be + specified. (default: cijoe-config.toml) + (default: []) --output OUTPUT, -o OUTPUT - Path to output directory. (default: /cijoe/docs/cijoe- - output) - --log-level, -l Increase log-level. Provide '-l' for info and '-ll' - for debug. (default: None) + Path to output directory. (default: + /cijoe/docs/cijoe-output) + --log-level, -l Increase log-level. Provide '-l' for info and + '-ll' for debug. (default: None) --monitor, -m Dump command output to stdout (default: False) - --no-report, -n Skip the producing, and opening, a report at the end - of the workflow-run (default: False) - --skip-report, -s Skip the report opening at the end of the workflow-run - (default: True) - --tag TAG, -t TAG Tags to identify a workflow-run. This will be prefixed - while storing in archive (default: None) + --no-report, -n Skip the producing, and opening, a report at the + end of the workflow-run (default: False) + --skip-report, -s Skip the report opening at the end of the + workflow-run (default: True) + --tag TAG, -t TAG Tags to identify a workflow-run. This will be + prefixed while storing in archive (default: + None) utilities: Workflow, and workflow-related utilities @@ -39,13 +40,13 @@ utilities: --produce-report, -p Produce report, and open it, for output at '-o / --output' and exit. (default: None) --integrity-check, -i - Check integrity of workflow at '-w / --workflow' and - exit. (default: False) - --resources, -r List collected resources and exit. (default: False) + Check integrity of workflow given as positional + argument and exit. (default: False) + --resources, -r List collected resources and exit. (default: + False) --example [EXAMPLE], -e [EXAMPLE] - Emits the given example. When no example is given, - then it prints a list of available examples. (default: - None) + Emits the given example. When no example is + given, then it prints a list of available + examples. (default: None) --version, -v Print the version number of 'cijoe' and exit. - (default: False) - + (default: False) \ No newline at end of file diff --git a/docs/source/usage/index.rst b/docs/source/usage/index.rst index 6c61d500..4fad8d95 100644 --- a/docs/source/usage/index.rst +++ b/docs/source/usage/index.rst @@ -61,10 +61,10 @@ Which yields the following output: Search Paths ============ -The :ref:`sec-usage-cli` for the positional argument, and config-files -(``--c / --config``) and workflows (``-w / --workflow``) by default search for files -named ``cijoe-workflow.yaml`` and ``cijoe-config.toml``, respectfully. These files -are searched for, in order, in the following locations: +The :ref:`sec-usage-cli` for the positional target argument, and for config-files +(``--c / --config``) by default search for files named ``cijoe-workflow.yaml`` +and ``cijoe-config.toml``, respectfully. These files are searched for, in order, +in the following locations: ``$PWD`` In your current working directory @@ -179,7 +179,7 @@ as an artifact. run: | $(which cijoe) --monitor -l \ --config ./example/cijoe-config.toml \ - --workflow ./example/cijoe-workflow.yaml + ./example/cijoe-workflow.yaml - name: Upload report if: always() diff --git a/src/cijoe/cli/cli.py b/src/cijoe/cli/cli.py index 1ec32e8d..a3c15ab6 100644 --- a/src/cijoe/cli/cli.py +++ b/src/cijoe/cli/cli.py @@ -459,45 +459,29 @@ def create_adhoc_workflow(args): def parse_args(): """Parse command-line interface.""" - parent_dirs = set() - is_workflow = False - - for i, argv in enumerate(sys.argv): - if i == 0 or sys.argv[i - 1].startswith("-"): - continue - if argv.endswith(".py"): - path = Path(argv).resolve() - parent_dirs.add(path.parent) - sys.argv[i] = path.stem - break - if argv.endswith(".yaml"): - is_workflow = True - sys.argv.insert(i, "--workflow") - sys.argv.insert(i, "workflow_path") - break - - resource_scripts = get_resources(list(parent_dirs))["scripts"] - + # A parent parser is added without help text to allow for parsing part of the + # arguments and evaluating them before proceeding with parsing the rest parent_parser = argparse.ArgumentParser(add_help=False) run_group = parent_parser.add_argument_group( "run", "Options for running a workflow script." ) - + run_group.add_argument( + "target", + nargs="?", + default=None, + help="A cijoe workflow or script to run.", + ) run_group.add_argument( "step", nargs="*", default=[], help="Given a workflow, the steps of the workflow it should run. If none are given, all steps are run.", ) - run_group.add_argument( "--workflow", "-w", - type=Path, - default=Path( - os.environ.get("CIJOE_DEFAULT_WORKFLOW", DEFAULT_WORKFLOW_FILENAME) - ), + default=None, help=argparse.SUPPRESS, ) run_group.add_argument( @@ -597,61 +581,79 @@ def parse_args(): help="Print the version number of 'cijoe' and exit.", ) + args, _ = parent_parser.parse_known_args() + + # A parser inheriting from the parent_parser is added to allow for printing + # the help text if --help argument is given. parser = argparse.ArgumentParser( prog=Path(sys.argv[0]).stem, formatter_class=argparse.ArgumentDefaultsHelpFormatter, parents=[parent_parser], ) - subparsers = parser.add_subparsers() - - # Create subparser for workflows - subparsers.add_parser( - "workflow_path", parents=[parent_parser], help="Path to a cijoe workflow file." - ) - subparsers.add_parser("script_path", help="Path to a cijoe script.") - subparsers.add_parser( - "script_name", - help="Name of a cijoe script. You can see all reachable cijoe scripts with command 'cijoe -r'.", - ) - - # Create subparser for scripts - # Adding the subparser requires loading the whole script, so - # we only add the subparser to the script given in the arguments - if not is_workflow: - for i, argv in enumerate(sys.argv): - if i == 0 or argv.startswith("-") or sys.argv[i - 1].startswith("-"): - continue - - # The first positional argument is the script identifier - ident = argv - - script = resource_scripts.get(ident, None) - if not script: - log.error(f"Invalid target({ident})") - return errno.EINVAL, None - script.load() - help_text = ( - next(line for line in script.docs.splitlines() if line) - if script.docs - else "" - ) - subparser = subparsers.add_parser( - ident, - parents=[parent_parser], - help=help_text, - epilog=script.docs, - formatter_class=argparse.RawTextHelpFormatter, - ) - subparser.add_argument( - "--script-name", default=ident, help=argparse.SUPPRESS - ) - if script.argparser_func: - script.argparser_func(subparser) - - break - - args = parser.parse_args() + # For backwards compatibility: allow the --workflow argument. + # If the --workflow argument is used, and a positional argument is given, the + # parser will interpret it as a `target`, but we assume it to be a step + # identifier. + if args.workflow: + log.warning( + "The -w / --workflow argument is deprecated" + "please specify the workflow as a positional argument instead." + ) + args = parser.parse_intermixed_args() + if args.target: + args.step = [args.target] + args.step + args.workflow = Path(args.workflow) + + # If the target ends with .yaml, we run the workflow at the given path, and + # assume that the remaining positional args are step identifiers of the + # given workflow. + # note: the `parse_intermixed_args` allow for multiple groups of positional + # arguments, i.e. both the workflow path and step identifiers. + elif not args.target or args.target.endswith(".yaml"): + args = parser.parse_intermixed_args() + workflow = args.target or os.environ.get( + "CIJOE_DEFAULT_WORKFLOW", DEFAULT_WORKFLOW_FILENAME + ) + setattr(args, "workflow", Path(workflow)) + + # Else, we assume the target is either a cijoe script identifier or path + else: + ident = args.target + parent_dirs = [] + + # If direct path to script is given, the parent directories must be + # included in the resource-collection + if ident.endswith(".py"): + path = Path(ident) + parent_dirs.append(path.parent) + ident = path.stem + + resource_scripts = get_resources(parent_dirs)["scripts"] + + # Load the script to get the name and arguments for the script, so + # arguments can be parsed and/or added to the help text + script = resource_scripts.get(ident, None) + if not script: + log.error(f"Invalid target({ident})") + return errno.EINVAL, None + script.load() + + help_text = ( + next(line for line in script.docs.splitlines() if line) + if script.docs + else "" + ) + script_group = parser.add_argument_group( + help_text, "Options specific for the given script" + ) + if script.argparser_func: + script.argparser_func(script_group) + + args = parser.parse_args() + + setattr(args, "script_name", ident) + args.step = [] levels = [log.ERROR, log.INFO, log.DEBUG] log.basicConfig( diff --git a/tests/core/test_argparse.py b/tests/core/test_argparse.py new file mode 100644 index 00000000..e360f31b --- /dev/null +++ b/tests/core/test_argparse.py @@ -0,0 +1,215 @@ +import copy +import os +import sys +from argparse import Namespace +from pathlib import Path + +from cijoe.cli.cli import DEFAULT_CONFIG_FILENAME, DEFAULT_WORKFLOW_FILENAME, parse_args + +TEMPLATE_SCRIPT = """def main(args, cijoe): + return 0 +""" + + +def test_run_group(): + """ + Base test to check all arguments are set + """ + + config = "config.toml" + output = "output" + tag = "tag" + loglevel = "ll" + example = "example" + + test_args = [ + "cijoe", + "-c", + config, + "-o", + output, + f"-{loglevel}", + "--monitor", + "--no-report", + "--skip-report", + "--tag", + tag, + "--archive", + "--produce-report", + "--integrity-check", + "--resources", + "--example", + example, + "--version", + ] + sys.argv = test_args + err, args = parse_args() + assert not err + assert args.config[0].name == config + assert args.output.name == output + assert len(args.log_level) == len(loglevel) + assert args.monitor + assert args.no_report + assert not args.skip_report + assert args.tag == [tag] + assert args.archive + assert args.produce_report + assert args.integrity_check + assert args.resources + assert args.example == example + assert args.version + + +def test_target_workflow(): + workflow = "workflow.yaml" + + test_args = ["cijoe", workflow] + sys.argv = test_args + err, args = parse_args() + assert not err + assert args.workflow.name == workflow + + +def test_target_workflow_steps(): + workflow = "workflow.yaml" + steps = ["step1", "step2", "step3"] + + test_args = ["cijoe", workflow, *steps] + sys.argv = test_args + err, args = parse_args() + assert not err + assert args.workflow.name == workflow + assert len(args.step) == len(steps) + assert all(a == b for a, b in zip(args.step, steps)) + + +def test_target_core_script(): + script = "core.example_script_default" + + test_args = ["cijoe", script] + sys.argv = test_args + err, args = parse_args() + assert not err + assert args.script_name == script + + +def test_target_core_script_args(): + script = "core.example_script_default" + + test_args = ["cijoe", script, "--repeat", "10"] + sys.argv = test_args + err, args = parse_args() + assert not err + assert args.script_name == script + assert args.repeat == 10 + + +def test_target_path_script(tmp_path): + script = "test" + script_path = (tmp_path / f"{script}.py").resolve() + + script_path.write_text(TEMPLATE_SCRIPT) + + test_args = ["cijoe", str(script_path)] + sys.argv = test_args + err, args = parse_args() + assert not err + assert args.script_name == script + + +def test_target_path_script_args_fail(tmp_path): + """ + If given a wrong argument, the argument parser should fail + """ + + script = "test" + script_path = (tmp_path / f"{script}.py").resolve() + + script_path.write_text(TEMPLATE_SCRIPT) + + test_args = ["cijoe", str(script_path), "--repeat", "10"] + sys.argv = test_args + + try: + _ = parse_args() + assert False + except SystemExit: + assert True + + +def test_target_path_script_step(tmp_path): + """ + If given additional steps when running a script, the argument parser remove + the steps + """ + + steps = ["step1", "step2", "step3"] + + script = "test" + script_path = (tmp_path / f"{script}.py").resolve() + + script_path.write_text(TEMPLATE_SCRIPT) + + test_args = ["cijoe", str(script_path), *steps] + sys.argv = test_args + + err, args = parse_args() + assert not err + assert args.script_name == script + assert not args.step + + +def test_workflow_argument(): + """ + For backwards compatibility, the -w / --workflow argument should still work + """ + + workflow = "workflow.yaml" + + test_args = ["cijoe", "-w", workflow] + sys.argv = test_args + err, args = parse_args() + assert not err + assert args.workflow.name == workflow + + +def test_workflow_argument_steps(): + """ + For backwards compatibility, the -w / --workflow argument should still work + """ + + workflow = "workflow.yaml" + steps = ["step1", "step2", "step3"] + + test_args = ["cijoe", "-w", workflow, *steps] + sys.argv = test_args + err, args = parse_args() + assert not err + assert args.workflow.name == workflow + assert len(args.step) == len(steps) + assert all(a == b for a, b in zip(args.step, steps)) + + +def test_mixed_order(): + config = "config.toml" + workflow = "workflow.yaml" + steps = ["step1", "step2", "step3"] + + test_args = ["cijoe", "-c", config, "--monitor", workflow, *steps, "-l"] + sys.argv = test_args + err, args = parse_args() + assert not err + assert args.workflow.name == workflow + assert args.config[0].name == config + assert len(args.step) == len(steps) + assert all(a == b for a, b in zip(args.step, steps)) + + +def test_defaults(): + test_args = ["cijoe"] + sys.argv = test_args + err, args = parse_args() + assert not err + assert args.workflow.name == os.environ.get( + "CIJOE_DEFAULT_WORKFLOW", DEFAULT_WORKFLOW_FILENAME + )