From b845d70944863c11c308b50669cbdf99d037eeb3 Mon Sep 17 00:00:00 2001 From: Daniel Neilson <53624638+ddneilson@users.noreply.github.com> Date: Wed, 24 Jan 2024 15:35:13 -0600 Subject: [PATCH] feat: support environment templates (#30) Environment Templates are a part of the spec that allows an Environment to be defined outside of a Job Template, but still be applied when running Job(s) created from the Job Template. The CLI doesn't support these yet. This commit teaches the CLI about environment templates: 1. In the 'check' command, to verify a template syntax; 2. In the 'schema' command, to generate json schema for EnvironmentTemplates; and 3. In the 'run' command, to apply Environments when running a Job locally. The implementation of this also applies a few small cleanups and refactors to make the implementation more straightforward. Signed-off-by: Daniel Neilson <53624638+ddneilson@users.noreply.github.com> --- README.md | 49 ++- pyproject.toml | 24 +- src/openjd/cli/_check/_check_command.py | 36 +- src/openjd/cli/_common/__init__.py | 15 +- src/openjd/cli/_common/_validation_utils.py | 73 ++-- .../_run/_local_session/_session_manager.py | 9 + src/openjd/cli/_run/_run_command.py | 52 ++- src/openjd/cli/_schema/_schema_command.py | 17 +- test/openjd/cli/__init__.py | 9 +- test/openjd/cli/conftest.py | 4 +- test/openjd/cli/test_check_command.py | 20 - test/openjd/cli/test_common.py | 155 ++++--- test/openjd/cli/test_run_command.py | 386 ++++++++++++++---- test/openjd/cli/test_schema_command.py | 22 + 14 files changed, 645 insertions(+), 226 deletions(-) diff --git a/README.md b/README.md index 8578421..4d5516b 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,38 @@ -## Open Job Description CLI +## Open Job Description - CLI -This CLI enables interactive support for writing Job templates using the Open Job Description specification. +Open Job Description (OpenJD) is a flexible open specification for defining render jobs which are portable +between studios and render solutions. This package provides a command-line interface that can be used +to: Verifiy that OpenJD templates are syntactically correct; Run OpenJD jobs locally; and more. + +For more information about Open Job Description and our goals with it, please see the +Open Job Description [Wiki on GitHub](https://github.com/OpenJobDescription/openjd-specifications/wiki). + +## Compatibility + +This library requires: + +1. Python 3.9 or higher; +2. Linux, MacOS, or Windows operating system; +3. On Linux/MacOS: + * `sudo` +4. On Windows: + * PowerShell 7+ + +## Versioning + +This package's version follows [Semantic Versioning 2.0](https://semver.org/). + +1. The MAJOR version is currently 0. +2. The MINOR version is incremented when backwards incompatible changes are introduced to the public API. +3. The PATCH version is incremented when bug fixes or backwards compatible changes are introduced to the public API. ## Commands +### Getting Help + +The main `openjd` command and all subcommands support a `--help` option to display +information on how to use the command. + ### `check` Reports any syntax errors that appear in the schema of a Job Template file. @@ -11,12 +40,12 @@ Reports any syntax errors that appear in the schema of a Job Template file. |Name|Type|Required|Description|Example| |---|---|---|---|---| -|`path`|path|yes|A path leading to a Job template file, or a bundle containing a `.template.json`/`.template.yaml`/`.template.yml` file.|`/path/to/job.template.json`| +|`path`|path|yes|A path leading to a Job or Environment template file.|`/path/to/template.json`| |`--output`|string|no|How to display the results of the command. Allowed values are `human-readable` (default), `json`, and `yaml`.|`--output json`, `--output yaml`| #### Example ```sh -$ openjd-cli check /path/to/job.template.json +$ openjd check /path/to/job.template.json Template at '/path/to/job.template.json' passes validation checks! ``` @@ -28,7 +57,7 @@ Displays summary information about a sample Job or a Step therein. The user may |Name|Type|Required|Description|Example| |---|---|---|---|---| -|`path`|path|yes|A path leading to a Job template file, or a bundle containing a `.template.json`/`.template.yaml`/`.template.yml` file.|`/path/to/job.template.json`| +|`path`|path|yes|A path leading to a Job template file.|`/path/to/job.template.json`| |`--job-param`, `-p`|string, path|no|A key-value pair representing a parameter in the template and the value to use for it, provided as a string or a path to a JSON/YAML document prefixed with 'file://'. Can be specified multiple times.|`--job-param MyParam=5`, `-p file://parameter_file.json`| |`--step-name`|string|no|The name of the Step to summarize.|`--step-name Step1`| |`--output`|string|no|How to display the results of the command. Allowed values are `human-readable` (default), `json`, and `yaml`.|`--output json`, `--output yaml`| @@ -63,14 +92,20 @@ Total environments: 0 ``` ### `run` -Runs a sample Session on the user’s local machine. The user should provide the Step to run Tasks from, along with any desired Job or Task parameters. + +Given a Job Template, Job Parameters, and optional Environment Templates this will run a set of the Tasks +from the constructed Job locally within an OpenJD Sesssion. + +Please see [How Jobs Are Run](https://github.com/OpenJobDescription/openjd-specifications/wiki/How-Jobs-Are-Run) for +details on how Open Job Description's Jobs are run within Sessions. #### Arguments |Name|Type|Required|Description|Example| |---|---|---|---|---| -|`path`|path|yes|A path leading to a Job template file, or a bundle containing a `.template.json`/`.template.yaml`/`.template.yml` file.|`/path/to/job.template.json`| +|`path`|path|yes|A path leading to a Job template file.|`/path/to/job.template.json`| |`--step-name`|string|yes|The name of the Step to run in a local Session.|`--step-name Step1`| +|`--environment`|paths|no|Path to a file containing Environment Template definitions. Can be provided multiple times.|`--environment /path/to/env.template1.json --environment /path/to/env.template2.yaml`| |`--job-param`, `-p`|string, path|no|A key-value pair representing a parameter in the template and the value to use for it, provided as a string or a path to a JSON/YAML document prefixed with 'file://'. Can be specified multiple times.|`--job-param MyParam=5`, `-p file://parameter_file.json`| |`--task-params`, `-tp`|string, path|no|A list of key-value pairs representing a Task parameter set for the Step, provided as a string or a path to a JSON/YAML document prefixed with 'file://'. If present, the Session will run one Task per parameter set supplied with `--task-params`. Can be specified multiple times.|`--task-params PingCount=20 PingDelay=30`, `-tp file://parameter_set_file.json`| |`--maximum-tasks`|integer|no|A maximum number of Tasks to run from this Step. Unless present, the Session will run all Tasks defined in the Step's parameter space, or one Task per `--task-params` argument.|`--maximum-tasks 5`| diff --git a/pyproject.toml b/pyproject.toml index 4a79702..d887df8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,9 +8,26 @@ dynamic = ["version"] readme = "README.md" license = "Apache-2.0" requires-python = ">=3.9" - +description = "Provides a command-line interface for working with Open Job Description templates." +# https://pypi.org/classifiers/ +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Programming Language :: Python", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Operating System :: POSIX :: Linux", + "Operating System :: Microsoft :: Windows", + "Operating System :: MacOS", + "License :: OSI Approved :: Apache Software License", + "Intended Audience :: Developers", + "Intended Audience :: End Users/Desktop" +] dependencies = [ - "openjd-sessions == 0.3.*" + "openjd-sessions == 0.3.*", + "openjd-model == 0.2.*" ] [project.scripts] @@ -142,3 +159,6 @@ source = [ [tool.coverage.report] show_missing = true fail_under = 92 +omit= [ + "src/openjd/cli/_version.py" +] diff --git a/src/openjd/cli/_check/_check_command.py b/src/openjd/cli/_check/_check_command.py index 7a0a7e2..6a37643 100644 --- a/src/openjd/cli/_check/_check_command.py +++ b/src/openjd/cli/_check/_check_command.py @@ -1,6 +1,12 @@ # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. from argparse import Namespace +from openjd.model import ( + DecodeValidationError, + SchemaVersion, + decode_job_template, + decode_environment_template, +) from .._common import read_template, OpenJDCliResult, print_cli_result @@ -11,10 +17,36 @@ def do_check(args: Namespace) -> OpenJDCliResult: try: # Raises: RuntimeError - filepath, _ = read_template(args) + template_object = read_template(args.path) + + # Raises: KeyError + document_version = template_object["specificationVersion"] + + # Raises: ValueError + template_version = SchemaVersion(document_version) + + # Raises: DecodeValidationError + if SchemaVersion.is_job_template(template_version): + decode_job_template(template=template_object) + elif SchemaVersion.is_environment_template(template_version): + decode_environment_template(template=template_object) + else: + return OpenJDCliResult( + status="error", + message=f"Unknown template 'specificationVersion' ({document_version}).", + ) + + except KeyError: + return OpenJDCliResult( + status="error", message="ERROR: Missing field 'specificationVersion'" + ) except RuntimeError as exc: return OpenJDCliResult(status="error", message=f"ERROR: {str(exc)}") + except DecodeValidationError as exc: + return OpenJDCliResult( + status="error", message=f"ERROR: '{str(args.path)}' failed checks: {str(exc)}" + ) return OpenJDCliResult( - status="success", message=f"Template at '{str(filepath)}' passes validation checks." + status="success", message=f"Template at '{str(args.path)}' passes validation checks." ) diff --git a/src/openjd/cli/_common/__init__.py b/src/openjd/cli/_common/__init__.py index d46bcae..d0058e8 100644 --- a/src/openjd/cli/_common/__init__.py +++ b/src/openjd/cli/_common/__init__.py @@ -14,7 +14,12 @@ get_job_params, get_task_params, ) -from ._validation_utils import get_doc_type, read_template +from ._validation_utils import ( + get_doc_type, + read_template, + read_job_template, + read_environment_template, +) from openjd.model import DecodeValidationError, Job __all__ = [ @@ -22,6 +27,8 @@ "get_job_params", "get_task_params", "read_template", + "read_job_template", + "read_environment_template", "validate_task_parameters", ] @@ -61,7 +68,7 @@ def add_common_arguments( "path", type=Path, action="store", - help="The path to the template file or Job Bundle to use.", + help="The path to the template file.", ) if CommonArgument.JOB_PARAMS in common_arg_options: parser.add_argument( @@ -101,12 +108,12 @@ def add(self, name: str, description: str, **kwargs) -> ArgumentParser: def generate_job(args: Namespace) -> Job: try: # Raises: RuntimeError, DecodeValidationError - template_file, template = read_template(args) + template = read_job_template(args.path) # Raises: RuntimeError return job_from_template( template, args.job_params if args.job_params else None, - Path(os.path.abspath(template_file.parent)), + Path(os.path.abspath(args.path.parent)), Path(os.getcwd()), ) except RuntimeError as rte: diff --git a/src/openjd/cli/_common/_validation_utils.py b/src/openjd/cli/_common/_validation_utils.py index 3ffafc6..0be1f02 100644 --- a/src/openjd/cli/_common/_validation_utils.py +++ b/src/openjd/cli/_common/_validation_utils.py @@ -1,14 +1,16 @@ # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -from argparse import Namespace +from typing import Any from pathlib import Path from openjd.model import ( DecodeValidationError, DocumentType, + EnvironmentTemplate, JobTemplate, - decode_template, document_string_to_object, + decode_environment_template, + decode_job_template, ) @@ -20,49 +22,60 @@ def get_doc_type(filepath: Path) -> DocumentType: raise RuntimeError(f"'{str(filepath)}' is not JSON or YAML.") -def _find_template_in_directory(bundle_dir: Path) -> Path: - """Search a directory for a Job Template file, - stopping at the first instance of a `template.json` or `template.yaml` file in the top level.""" - for file in bundle_dir.glob("*template.*"): - if file.is_file() and file.suffix.lower() in (".json", ".yaml", ".yml"): - return file - - raise RuntimeError( - f"Couldn't find 'template.json' or 'template.yaml' in the folder '{str(bundle_dir)}'." - ) - - -def read_template(args: Namespace) -> tuple[Path, JobTemplate]: +def read_template(template_file: Path) -> dict[str, Any]: """Open a JSON or YAML-formatted file and attempt to parse it into a JobTemplate object. Raises a RuntimeError if the file doesn't exist or can't be opened, and raises a DecodeValidationError if its contents can't be parsed into a valid JobTemplate. """ - if not args.path.exists(): - raise RuntimeError(f"'{str(args.path)}' does not exist.") + if not template_file.exists(): + raise RuntimeError(f"'{str(template_file)}' does not exist.") - if args.path.is_file(): + if template_file.is_file(): # Raises: RuntimeError - filepath = args.path - filetype = get_doc_type(filepath) - elif args.path.is_dir(): - # Raises: RuntimeError - filepath = _find_template_in_directory(args.path) - filetype = get_doc_type(filepath) + filetype = get_doc_type(template_file) else: - raise RuntimeError(f"'{str(args.path)}' is not a file or directory.") + raise RuntimeError(f"'{str(template_file)}' is not a file.") try: - template_string = filepath.read_text(encoding="utf-8") + template_string = template_file.read_text(encoding="utf-8") except OSError as exc: - raise RuntimeError(f"Could not open file '{str(filepath)}': {str(exc)}") + raise RuntimeError(f"Could not open file '{str(template_file)}': {str(exc)}") try: + # Raises: DecodeValidationError template_object = document_string_to_object( document=template_string, document_type=filetype ) - template = decode_template(template=template_object) except DecodeValidationError as exc: - raise RuntimeError(f"'{str(filepath)}' failed checks: {str(exc)}") + raise RuntimeError(f"'{str(template_file)}' failed checks: {str(exc)}") + + return template_object + + +def read_job_template(template_file: Path) -> JobTemplate: + """Open a JSON or YAML-formatted file and attempt to parse it into a JobTemplate object. + Raises a RuntimeError if the file doesn't exist or can't be opened, and raises a + DecodeValidationError if its contents can't be parsed into a valid JobTemplate. + """ + # Raises RuntimeError + template_object = read_template(template_file) + + # Raises: DecodeValidationError + template = decode_job_template(template=template_object) + + return template + + +def read_environment_template(template_file: Path) -> EnvironmentTemplate: + """Open a JSON or YAML-formatted file and attempt to parse it into an EnvironmentTemplate object. + Raises a RuntimeError if the file doesn't exist or can't be opened, and raises a + DecodeValidationError if its contents can't be parsed into a valid EnvironmentTemplate. + """ + # Raises RuntimeError + template_object = read_template(template_file) + + # Raises: DecodeValidationError + template = decode_environment_template(template=template_object) - return filepath, template + return template diff --git a/src/openjd/cli/_run/_local_session/_session_manager.py b/src/openjd/cli/_run/_local_session/_session_manager.py index e26c113..e838aaf 100644 --- a/src/openjd/cli/_run/_local_session/_session_manager.py +++ b/src/openjd/cli/_run/_local_session/_session_manager.py @@ -10,6 +10,7 @@ from ._actions import EnterEnvironmentAction, ExitEnvironmentAction, RunTaskAction, SessionAction from ._logs import LocalSessionLogHandler, LogEntry from openjd.model import ( + EnvironmentTemplate, Job, ParameterValue, ParameterValueType, @@ -49,6 +50,7 @@ class LocalSession: _current_action: Optional[SessionAction] _action_ended: Event _path_mapping_rules: Optional[list[PathMappingRule]] + _environments: Optional[list[EnvironmentTemplate]] _log_handler: LocalSessionLogHandler _cleanup_called: bool @@ -58,6 +60,7 @@ def __init__( job: Job, session_id: str, path_mapping_rules: Optional[list[PathMappingRule]] = None, + environments: Optional[list[EnvironmentTemplate]] = None, should_print_logs: bool = True, ): self.session_id = session_id @@ -65,6 +68,7 @@ def __init__( self._action_ended = Event() self._job = job self._path_mapping_rules = path_mapping_rules + self._environments = environments # Evaluate Job parameters, if applicable if job.parameters: @@ -145,6 +149,11 @@ def initialize( self.ended.clear() session_environment_ids: list[str] = [] + # Enqueue "Enter Environment" actions for the given environments + if self._environments: + envs = [environ.environment for environ in self._environments] + session_environment_ids += self._add_environments(envs) + # Enqueue "Enter Environment" actions for root level environments if self._job.jobEnvironments: session_environment_ids += self._add_environments(self._job.jobEnvironments) diff --git a/src/openjd/cli/_run/_run_command.py b/src/openjd/cli/_run/_run_command.py index 90b643e..c7a047d 100644 --- a/src/openjd/cli/_run/_run_command.py +++ b/src/openjd/cli/_run/_run_command.py @@ -12,8 +12,9 @@ generate_job, get_task_params, print_cli_result, + read_environment_template, ) -from openjd.model import Job, Step +from openjd.model import DecodeValidationError, EnvironmentTemplate, Job, Step from openjd.sessions import PathMappingRule @@ -83,6 +84,15 @@ def add_run_arguments(run_parser: ArgumentParser): + "the 'pathmapping-1.0' schema. Can either be supplied as a string or as a path to a JSON/YAML document, " + "prefixed with 'file://'.", ) + run_parser.add_argument( + "--environment", + "--env", + dest="environments", + action="append", + type=str, + metavar=" [] ...", + help="Apply the given environments to the Session in the order given.", + ) def _collect_required_steps(step_map: dict[str, Step], step: Step) -> list[Step]: @@ -128,6 +138,7 @@ def _run_local_session( step: Step, maximum_tasks: int = -1, task_parameter_values: list[dict] = [], + environments: Optional[list[EnvironmentTemplate]] = None, path_mapping_rules: Optional[list[PathMappingRule]], should_run_dependencies: bool = False, should_print_logs: bool = True, @@ -148,6 +159,7 @@ def _run_local_session( job=job, session_id="sample_session", path_mapping_rules=path_mapping_rules, + environments=environments, should_print_logs=should_print_logs, ) as session: session.initialize( @@ -194,16 +206,16 @@ def do_run(args: Namespace) -> OpenJDCliResult: sets in sequence. """ - try: - # Raises: RuntimeError - sample_job = generate_job(args) - - task_params: list[dict] = [] - if args.task_params: - task_params = get_task_params(args.task_params) - - except RuntimeError as rte: - return OpenJDCliResult(status="error", message=str(rte)) + environments: list[EnvironmentTemplate] = [] + if args.environments: + for env in args.environments: + filename = Path(env).expanduser() + try: + # Raises: RuntimeError, DecodeValidationError + template = read_environment_template(filename) + environments.append(template) + except (RuntimeError, DecodeValidationError) as e: + return OpenJDCliResult(status="error", message=str(e)) path_mapping_rules: Optional[list[PathMappingRule]] = None if args.path_mapping_rules: @@ -214,18 +226,29 @@ def do_run(args: Namespace) -> OpenJDCliResult: else: parsed_rules = json.loads(args.path_mapping_rules) if parsed_rules.get("version", None) != "pathmapping-1.0": - raise OpenJDCliResult( + return OpenJDCliResult( status="error", message="Path mapping rules must have a 'version' value of 'pathmapping-1.0'", ) if not isinstance(parsed_rules.get("path_mapping_rules", None), list): - raise OpenJDCliResult( + return OpenJDCliResult( status="error", - message="Path mapping rules must contain a list named 'path_mapping_rules'", + message="Path mapping rules must contain a list named 'path_mapping_rules'", ) rules_list = parsed_rules.get("path_mapping_rules") path_mapping_rules = [PathMappingRule.from_dict(rule) for rule in rules_list] + try: + # Raises: RuntimeError + sample_job = generate_job(args) + + task_params: list[dict] = [] + if args.task_params: + task_params = get_task_params(args.task_params) + + except RuntimeError as rte: + return OpenJDCliResult(status="error", message=str(rte)) + # Map Step names to Step objects so they can be easily accessed step_map = {step.name: step for step in sample_job.steps} @@ -236,6 +259,7 @@ def do_run(args: Namespace) -> OpenJDCliResult: step=step_map[args.step], task_parameter_values=task_params, maximum_tasks=args.maximum_tasks, + environments=environments, path_mapping_rules=path_mapping_rules, should_run_dependencies=(args.run_dependencies), should_print_logs=(args.output == "human-readable"), diff --git a/src/openjd/cli/_schema/_schema_command.py b/src/openjd/cli/_schema/_schema_command.py index eeb9cb8..574c5e5 100644 --- a/src/openjd/cli/_schema/_schema_command.py +++ b/src/openjd/cli/_schema/_schema_command.py @@ -2,18 +2,24 @@ from argparse import ArgumentParser, Namespace import json +from typing import Union from .._common import OpenJDCliResult, print_cli_result -from ...model import SchemaVersion +from openjd.model import SchemaVersion, JobTemplate, EnvironmentTemplate def add_schema_arguments(schema_parser: ArgumentParser) -> None: + allowed_values = [ + v.value + for v in SchemaVersion + if SchemaVersion.is_job_template(v) or SchemaVersion.is_environment_template(v) + ] schema_parser.add_argument( "--version", action="store", type=SchemaVersion, required=True, - help="The specification version to return a JSON schema document for.", + help=f"The specification version to return a JSON schema document for. Allowed values: {', '.join(allowed_values)}", ) @@ -39,8 +45,11 @@ def do_get_schema(args: Namespace) -> OpenJDCliResult: Job templates against. """ + Template: Union[type[JobTemplate], type[EnvironmentTemplate]] if args.version == SchemaVersion.v2023_09: - from ...model.v2023_09 import JobTemplate + from openjd.model.v2023_09 import JobTemplate as Template + elif args.version == SchemaVersion.ENVIRONMENT_v2023_09: + from openjd.model.v2023_09 import EnvironmentTemplate as Template else: return OpenJDCliResult( status="error", message=f"ERROR: Cannot generate schema for version '{args.version}'." @@ -52,7 +61,7 @@ def do_get_schema(args: Namespace) -> OpenJDCliResult: # The `schema` attribute will have to be updated if/when Pydantic # is updated to v2. # (AFAIK it can be replaced with `model_json_schema()`.) - schema_doc = JobTemplate.schema() + schema_doc = Template.schema() _process_regex(schema_doc) except Exception as e: return OpenJDCliResult(status="error", message=f"ERROR generating schema: {str(e)}") diff --git a/test/openjd/cli/__init__.py b/test/openjd/cli/__init__.py index 619ef2b..d1cc794 100644 --- a/test/openjd/cli/__init__.py +++ b/test/openjd/cli/__init__.py @@ -16,7 +16,14 @@ # Basic step; uses Job parameters and has an environment "name": "NormalStep", "script": {"actions": {"onRun": {"command": "echo", "args": ["{{Param.Message}}"]}}}, - "stepEnvironments": [{"name": "env1", "variables": {"var1": "val1"}}], + "stepEnvironments": [ + { + "name": "env1", + "script": { + "actions": {"onEnter": {"command": "echo", "args": ["EnteringEnv"]}} + }, + } + ], }, { # Step that will wait for one minute before completing its Task diff --git a/test/openjd/cli/conftest.py b/test/openjd/cli/conftest.py index 0bfcd9f..5d72a77 100644 --- a/test/openjd/cli/conftest.py +++ b/test/openjd/cli/conftest.py @@ -9,7 +9,7 @@ from . import MOCK_TEMPLATE, SampleSteps from openjd.cli._common._job_from_template import job_from_template from openjd.cli._run._local_session._session_manager import LocalSession -from openjd.model import decode_template +from openjd.model import decode_job_template @pytest.fixture(scope="function", params=[[], ["Message='A new message!'"]]) @@ -27,7 +27,7 @@ def sample_job_and_dirs(request): os.makedirs(template_dir) os.makedirs(current_working_dir) - template = decode_template(template=MOCK_TEMPLATE) + template = decode_job_template(template=MOCK_TEMPLATE) yield ( job_from_template( template=template, diff --git a/test/openjd/cli/test_check_command.py b/test/openjd/cli/test_check_command.py index 35c51c2..93be6d3 100644 --- a/test/openjd/cli/test_check_command.py +++ b/test/openjd/cli/test_check_command.py @@ -36,26 +36,6 @@ def test_do_check_file_success(tempfile_extension: str, doc_serializer: Callable Path(temp_template.name).unlink() -def test_do_check_bundle_success(): - """ - The CLI should be able to find a template JSON/YAML document in a provided directory - """ - temp_template = None - - with tempfile.TemporaryDirectory() as temp_bundle: - with tempfile.NamedTemporaryFile( - mode="w+t", - suffix=".template.json", - encoding="utf8", - delete=False, - dir=temp_bundle, - ) as temp_template: - json.dump(MOCK_TEMPLATE, temp_template.file) - - mock_args = Namespace(path=Path(temp_bundle), output="human-readable") - do_check(mock_args) - - def test_do_check_file_error(): """ Raise a SystemExit on an error diff --git a/test/openjd/cli/test_common.py b/test/openjd/cli/test_common.py index 48dca3d..e38ed75 100644 --- a/test/openjd/cli/test_common.py +++ b/test/openjd/cli/test_common.py @@ -21,9 +21,12 @@ get_job_params, get_task_params, read_template, + read_job_template, + read_environment_template, ) from openjd.cli._common._job_from_template import job_from_template from openjd.model import ( + DecodeValidationError, decode_template, ) @@ -43,25 +46,6 @@ def template_dir_and_cwd(): yield (template_dir, current_working_dir) -@patch("openjd.cli._common._validation_utils.decode_template") -def test_decode_template_called(mock_decode_template: Mock): - """ - Tests that calls to `read_template` will call `decode_template` with the appropriately decoded dictionary - """ - temp_template = None - - with tempfile.NamedTemporaryFile( - mode="w+t", suffix=".template.json", encoding="utf8", delete=False - ) as temp_template: - json.dump(MOCK_TEMPLATE, temp_template.file) - - mock_decode_template.assert_not_called() - read_template(Namespace(path=Path(temp_template.name), output="human-readable")) - mock_decode_template.assert_called_once_with(template=MOCK_TEMPLATE) - - Path(temp_template.name).unlink() - - @pytest.mark.parametrize( "tempfile_extension,doc_serializer", [ @@ -81,13 +65,11 @@ def test_read_template_success(tempfile_extension: str, doc_serializer: Callable ) as temp_template: doc_serializer(MOCK_TEMPLATE, temp_template) - mock_args = Namespace(path=Path(temp_template.name), output="human-readable") - new_path, decoded_template = read_template(mock_args) - assert new_path == Path(temp_template.name) - assert decoded_template.name == MOCK_TEMPLATE["name"] - assert len(decoded_template.steps) == len(MOCK_TEMPLATE["steps"]) + template_filename = Path(temp_template.name) + result = read_template(template_filename) + assert result == MOCK_TEMPLATE - Path(temp_template.name).unlink() + template_filename.unlink() @pytest.mark.parametrize( @@ -99,8 +81,8 @@ def test_read_template_success(tempfile_extension: str, doc_serializer: Callable pytest.param( True, False, - "'some-file.json' is not a file or directory.", - id="Path is not a file or directory", + "'some-file.json' is not a file.", + id="Path is not a file", ), pytest.param( True, @@ -116,13 +98,13 @@ def test_read_template_fileerror( """ Tests that `read_template` raises a RuntimeError when unable to open a file """ - mock_args = Namespace(path=Path("some-file.json"), output="human-readable") + args = Path("some-file.json") with ( pytest.raises(RuntimeError) as rte, patch.object(Path, "exists", Mock(return_value=mock_exists_response)), patch.object(Path, "is_file", Mock(return_value=mock_is_file_response)), ): - read_template(mock_args) + read_template(args) assert str(rte.value).startswith(expected_error) @@ -132,49 +114,54 @@ def test_read_template_fileerror( [ pytest.param( ".template.json", - '{ "name": "something" }', + '{ "specificationVersion": "jobtemplate-2023-09" }', id="JSON missing field", ), - pytest.param( - ".template.json", - '{ "specificationVersion": "jobtemplate-2023-09", "name": 5, "steps": [{"name": "step1", "script": {"actions": {"onRun": {"command": "something"}}}}]}', - id="JSON type error", - ), - pytest.param( - ".template.json", - '{ "specificationVersion": "jobtemplate-2023-09", "name": "{{{{{a name}", "steps": [{"name": "step1", "script": {"actions": {"onRun": {"command": \'echo "Hello, world!"\'}}}}],}', - id="Unparsable JSON brackets", - ), - pytest.param( - ".template.json", - '{ "specificationVersion": "jobtemplate-2023-09template-2023-09", "steps": "not a real step"} ', - id="Multiple JSON errors", - ), pytest.param( ".template.yaml", - 'specificationVersion: "jobtemplate-2023-09"\nname: "something"', + 'specificationVersion: "jobtemplate-2023-09"\n', id="YAML missing field", ), + ], +) +def test_read_job_template_parsingerror(tempfile_extension: str, file_contents: str): + """ + Tests that `read_job_template` raises a DecodeValidationError when provided a JSON/YAML body with schema errors + """ + temp_template = None + + with tempfile.NamedTemporaryFile( + mode="w+t", suffix=tempfile_extension, encoding="utf8", delete=False + ) as temp_template: + temp_template.write(file_contents) + + mock_args = Path(temp_template.name) + with pytest.raises(DecodeValidationError) as re: + read_job_template(mock_args) + + assert "validation errors for JobTemplate" in str(re.value) + + Path(temp_template.name).unlink() + + +@pytest.mark.parametrize( + "tempfile_extension,file_contents", + [ pytest.param( - ".template.yaml", - 'specificationVersion: "jobtemplate-2023-09"\nname: "something"\nsteps: "not a real step"', - id="YAML type error", - ), - pytest.param( - ".template.yaml", - 'specificationVersion\nname: "something"\nsteps: "not a real step"', - id="Badly-formatted YAML", + ".template.json", + '{ "specificationVersion": "environment-2023-09" }', + id="JSON missing field", ), pytest.param( ".template.yaml", - 'specificationVersion: "jobtemplate-2023-09"\nname:\nsteps: "not a real step"', - id="Multiple YAML errors", + 'specificationVersion: "environment-2023-09"\n', + id="YAML missing field", ), ], ) -def test_read_template_parsingerror(tempfile_extension: str, file_contents: str): +def test_read_environment_template_parsingerror(tempfile_extension: str, file_contents: str): """ - Tests that `read_template` raises a RuntimeError that when provided a JSON/YAML body with schema errors + Tests that `read_environment_template` raises a DecodeValidationError when provided a JSON/YAML body with schema errors """ temp_template = None @@ -183,11 +170,11 @@ def test_read_template_parsingerror(tempfile_extension: str, file_contents: str) ) as temp_template: temp_template.write(file_contents) - mock_args = Namespace(path=Path(temp_template.name), output="human-readable") - with pytest.raises(RuntimeError) as re: - read_template(mock_args) + mock_args = Path(temp_template.name) + with pytest.raises(DecodeValidationError) as re: + read_environment_template(mock_args) - assert str(re.value).startswith(f"'{temp_template.name}' failed checks:") + assert "validation errors for EnvironmentTemplate" in str(re.value) Path(temp_template.name).unlink() @@ -589,3 +576,47 @@ def test_generate_job_success( ) Path(temp_template.name).unlink() + + +@pytest.mark.parametrize( + "template_dict, param_list, expected_error", + [ + pytest.param( + { + "specificationVersion": "jobtemplate-2023-09", + "name": "Test", + "parameterDefinitions": [{"name": "Foo", "type": "INT"}], + "steps": [{"name": "Test", "script": {"actions": {"onRun": {"command": "test"}}}}], + }, + ["Foo=blah"], + "ERROR generating Job", + id="RuntimeError when parameters fail validation", + ), + pytest.param( + { + "specificationVersion": "jobtemplate-2023-09", + }, + [], + "ERROR validating template", + id="DecodeValidation converted to RuntimeError when template fails parsing", + ), + ], +) +def test_generate_job_raises( + template_dict: dict, param_list: list[str], expected_error: str +) -> None: + """Test that generate_job() raises the expected exceptions.""" + + temp_template = None + + with tempfile.NamedTemporaryFile( + mode="w+t", suffix=".template.json", encoding="utf8", delete=False + ) as temp_template: + json.dump(template_dict, temp_template.file) + + args = Namespace(path=Path(temp_template.name), job_params=param_list, output="human-readable") + + with pytest.raises(RuntimeError) as excinfo: + generate_job(args) + + assert expected_error in str(excinfo.value) diff --git a/test/openjd/cli/test_run_command.py b/test/openjd/cli/test_run_command.py index 0f36a83..f145244 100644 --- a/test/openjd/cli/test_run_command.py +++ b/test/openjd/cli/test_run_command.py @@ -2,14 +2,16 @@ from argparse import Namespace import json -from pathlib import Path, PurePath, PureWindowsPath, PurePosixPath +from pathlib import Path, PureWindowsPath, PurePosixPath import tempfile +import re +import os +from typing import Any import pytest -from unittest.mock import ANY, Mock, patch +from unittest.mock import Mock, patch from . import MOCK_TEMPLATE, SampleSteps -from openjd.cli._run import _run_command from openjd.cli._run._run_command import ( OpenJDRunResult, do_run, @@ -19,56 +21,278 @@ from openjd.sessions import PathMappingRule, PathFormat, Session +TEST_RUN_JOB_TEMPLATE_BASIC = { + "specificationVersion": "jobtemplate-2023-09", + "name": "Job", + "parameterDefinitions": [{"name": "J", "type": "STRING"}], + "jobEnvironments": [ + { + "name": "J1", + "script": { + "actions": { + "onEnter": {"command": "echo", "args": ["J1 Enter"]}, + "onExit": {"command": "echo", "args": ["J1 Exit"]}, + } + }, + }, + { + "name": "J2", + "script": { + "actions": { + "onEnter": {"command": "echo", "args": ["J2 Enter"]}, + "onExit": {"command": "echo", "args": ["J2 Exit"]}, + } + }, + }, + ], + "steps": [ + { + "name": "First", + "parameterSpace": { + "taskParameterDefinitions": [ + {"name": "Foo", "type": "INT", "range": "1"}, + {"name": "Bar", "type": "STRING", "range": ["Bar1", "Bar2"]}, + ] + }, + "stepEnvironments": [ + { + "name": "FirstS", + "script": { + "actions": { + "onEnter": {"command": "echo", "args": ["FirstS Enter"]}, + "onExit": {"command": "echo", "args": ["FirstS Exit"]}, + } + }, + }, + ], + "script": { + "actions": { + "onRun": { + "command": "echo", + "args": [ + "J={{Param.J}} Foo={{Task.Param.Foo}}. Bar={{Task.Param.Bar}}", + ], + } + } + }, + } + ], +} + +TEST_RUN_JOB_TEMPLATE_DEPENDENCY = { + "specificationVersion": "jobtemplate-2023-09", + "name": "Job", + "parameterDefinitions": [{"name": "J", "type": "STRING"}], + "jobEnvironments": [ + { + "name": "J1", + "script": { + "actions": { + "onEnter": {"command": "echo", "args": ["J1 Enter"]}, + "onExit": {"command": "echo", "args": ["J1 Exit"]}, + } + }, + }, + ], + "steps": [ + { + "name": "First", + "parameterSpace": { + "taskParameterDefinitions": [ + {"name": "Foo", "type": "INT", "range": "1"}, + {"name": "Bar", "type": "STRING", "range": ["Bar1", "Bar2"]}, + ] + }, + "script": { + "actions": { + "onRun": { + "command": "echo", + "args": [ + "J={{Param.J}} Foo={{Task.Param.Foo}}. Bar={{Task.Param.Bar}}", + ], + } + } + }, + }, + { + "name": "Second", + "dependencies": [{"dependsOn": "First"}], + "parameterSpace": { + "taskParameterDefinitions": [ + {"name": "Fuz", "type": "INT", "range": "1-2"}, + ] + }, + "script": { + "actions": { + "onRun": { + "command": "echo", + "args": [ + "J={{Param.J}} Fuz={{Task.Param.Fuz}}.", + ], + } + } + }, + }, + ], +} + +TEST_RUN_ENV_TEMPLATE_1 = { + "specificationVersion": "environment-2023-09", + "environment": { + "name": "Env1", + "script": { + "actions": { + "onEnter": {"command": "echo", "args": ["Env1 Enter"]}, + "onExit": {"command": "echo", "args": ["Env1 Exit"]}, + } + }, + }, +} + +TEST_RUN_ENV_TEMPLATE_2 = { + "specificationVersion": "environment-2023-09", + "environment": { + "name": "Env2", + "script": { + "actions": { + "onEnter": {"command": "echo", "args": ["Env2 Enter"]}, + "onExit": {"command": "echo", "args": ["Env2 Exit"]}, + } + }, + }, +} + + @pytest.mark.parametrize( - "step_name,task_params,should_run_dependencies", + "job_template,env_templates,step_name,task_params,run_dependencies,expected_output,expected_not_in_output", [ - pytest.param("BareStep", [], False, id="Basic step"), - pytest.param("NormalStep", [], False, id="Step with extra environments"), - pytest.param("DependentStep", [], False, id="Exclude dependencies"), - pytest.param("DependentStep", [], True, id="Include dependencies"), - pytest.param("TaskParamStep", [], False, id="Step with Task parameters"), pytest.param( - "TaskParamStep", - [["TaskNumber=1 TaskMessage=Hello!"]], - False, - id="Custom Task parameters", + TEST_RUN_JOB_TEMPLATE_BASIC, + [], # Env Templates + "First", # step name + [], # Task params + True, # run_dependencies + re.compile( + r"J1 Enter.*J2 Enter.*FirstS Enter.*J=Jvalue.*Foo=1. Bar=Bar1.*Foo=1. Bar=Bar2.*FirstS Exit.*J2 Exit.*J1 Exit" + ), + "", + id="RunFirstStep", ), pytest.param( - "TaskParamStep", - [['TaskNumber=1 TaskMessage="Hello, world!"']], - False, - id="Custom Task parameters with commas", + TEST_RUN_JOB_TEMPLATE_BASIC, + [], # Env Templates + "First", # step name + [["Foo=1 Bar=Bar1"]], # Task params + True, # run_dependencies + re.compile( + r"J1 Enter.*J2 Enter.*FirstS Enter.*J=Jvalue.*Foo=1. Bar=Bar1.*FirstS Exit.*J2 Exit.*J1 Exit" + ), + "Foo=1. Bar=Bar2", + id="RunSelectTask", + ), + pytest.param( + TEST_RUN_JOB_TEMPLATE_DEPENDENCY, + [], # Env Templates + "Second", # step name + [], # Task params + True, # run_dependencies + re.compile( + r"J1 Enter.*J=Jvalue.*Foo=1. Bar=Bar1.*Foo=1. Bar=Bar2.*J=Jvalue Fuz=1.*J=Jvalue Fuz=2.*J1 Exit" + ), + "", + id="RunSecondStepWithDep", + ), + pytest.param( + TEST_RUN_JOB_TEMPLATE_DEPENDENCY, + [], # Env Templates + "Second", # step name + [], # Task params + False, # run_dependencies + re.compile(r"J1 Enter.*J=Jvalue Fuz=1.*J=Jvalue Fuz=2.*J1 Exit"), + "Foo=1. Bar=Bar1", + id="RunSecondStepNoDep", + ), + pytest.param( + TEST_RUN_JOB_TEMPLATE_BASIC, + [TEST_RUN_ENV_TEMPLATE_1], # Env Templates + "First", # step name + [], # Task params + True, # run_dependencies + re.compile( + r"Env1 Enter.*J1 Enter.*J2 Enter.*FirstS Enter.*J=Jvalue.*Foo=1. Bar=Bar1.*Foo=1. Bar=Bar2.*FirstS Exit.*J2 Exit.*J1 Exit.*Env1 Exit" + ), + "", + id="WithOneEnv", + ), + pytest.param( + TEST_RUN_JOB_TEMPLATE_BASIC, + [TEST_RUN_ENV_TEMPLATE_1, TEST_RUN_ENV_TEMPLATE_2], # Env Templates + "First", # step name + [], # Task params + True, # run_dependencies + re.compile( + r"Env1 Enter.*Env2 Enter.*J1 Enter.*J2 Enter.*FirstS Enter.*J=Jvalue.*Foo=1. Bar=Bar1.*Foo=1. Bar=Bar2.*FirstS Exit.*J2 Exit.*J1 Exit.*Env2 Exit.*Env1 Exit" + ), + "", + id="WithTwoEnvs", ), ], ) def test_do_run_success( + job_template: dict[str, Any], + env_templates: list[dict[str, Any]], step_name: str, - task_params: list[str], - should_run_dependencies: bool, -): - """ - Test that the `run` command succeeds with various argument options. - """ - temp_template = None + task_params: list[list[str]], + run_dependencies: bool, + expected_output: re.Pattern[str], + expected_not_in_output: str, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that the 'run' command correctly runs templates and obtains the expected results.""" - with tempfile.NamedTemporaryFile( - mode="w+t", suffix=".template.json", encoding="utf8", delete=False - ) as temp_template: - json.dump(MOCK_TEMPLATE, temp_template.file) + files_created: list[Path] = [] + try: + # GIVEN + with tempfile.NamedTemporaryFile( + mode="w+t", suffix=".template.json", encoding="utf8", delete=False + ) as job_template_file: + json.dump(job_template, job_template_file.file) + files_created.append(Path(job_template_file.name)) - mock_args = Namespace( - path=Path(temp_template.name), - step=step_name, - job_params=None, - task_params=task_params, - maximum_tasks=-1, - run_dependencies=should_run_dependencies, - path_mapping_rules=None, - output="human-readable", - ) - do_run(mock_args) + environments_files: list[str] = [] + for e in env_templates: + with tempfile.NamedTemporaryFile( + mode="w+t", suffix=".env.template.json", encoding="utf8", delete=False + ) as file: + json.dump(e, file.file) + files_created.append(Path(file.name)) + environments_files.append(file.name) - Path(temp_template.name).unlink() + args = Namespace( + path=Path(job_template_file.name), + step=step_name, + job_params=["J=Jvalue"], + task_params=task_params, + maximum_tasks=-1, + run_dependencies=run_dependencies, + path_mapping_rules=None, + environments=environments_files, + output="human-readable", + ) + + # WHEN + do_run(args) + + # THEN + assert not any( + os.linesep in m for m in caplog.messages + ), "paranoia; Windows is acting weird" + assert expected_output.search("".join(m.strip() for m in caplog.messages)) + if expected_not_in_output: + assert expected_not_in_output not in caplog.text + finally: + for f in files_created: + f.unlink() def test_do_run_error(): @@ -82,33 +306,41 @@ def test_do_run_error(): task_params=None, run_dependencies=False, path_mapping_rules=None, + environments=[], output="human-readable", ) with pytest.raises(SystemExit): do_run(mock_args) -def test_do_run_path_mapping_rules(): +def test_do_run_path_mapping_rules(caplog: pytest.LogCaptureFixture): """ Test that the `run` command exits on any error (e.g., a non-existent template file). """ + # GIVEN + job_template = { + "specificationVersion": "jobtemplate-2023-09", + "name": "Job", + "parameterDefinitions": [{"name": "TestPath", "type": "PATH"}], + "steps": [ + { + "name": "TestStep", + "script": { + "actions": {"onRun": {"command": "echo", "args": ["Mapped:{{Param.TestPath}}"]}} + }, + } + ], + } path_mapping_rules = { "version": "pathmapping-1.0", "path_mapping_rules": [ { - "source_path_format": "WINDOWS", - "source_path": r"C:\test", + "source_path_format": "POSIX" if os.name == "posix" else "WINDOWS", + "source_path": r"/home/test" if os.name == "posix" else r"C:\test", "destination_path": "/mnt/test", } ], } - expected_path_mapping_rules = [ - PathMappingRule( - source_path_format=PathFormat.WINDOWS, - source_path=PureWindowsPath(r"C:\test"), - destination_path=PurePath("/mnt/test"), - ) - ] try: # Set up a rules file and a job template file @@ -121,34 +353,31 @@ def test_do_run_path_mapping_rules(): with tempfile.NamedTemporaryFile( mode="w+t", suffix=".template.json", encoding="utf8", delete=False ) as temp_template: - json.dump(MOCK_TEMPLATE, temp_template.file) - - # Patch out _run_local_session so we can check how it gets called - with patch.object(_run_command, "_run_local_session") as mock_run_local_session: - # Call the CLI run command, using the temp files we created - mock_args = Namespace( - path=Path(temp_template.name), - step="NormalStep", - job_params=None, - task_params=None, - run_dependencies=False, - output="human-readable", - path_mapping_rules="file://" + temp_rules.name, - maximum_tasks=1, - ) - do_run(mock_args) - - # Confirm _run_local_session gets called with the correct path mapping rules - mock_run_local_session.assert_called_once_with( - job=ANY, - step=ANY, - step_map=ANY, - maximum_tasks=1, - task_parameter_values=ANY, - path_mapping_rules=expected_path_mapping_rules, - should_run_dependencies=False, - should_print_logs=True, - ) + json.dump(job_template, temp_template.file) + + run_args = Namespace( + path=Path(temp_template.name), + step="TestStep", + job_params=[r"TestPath=/home/test" if os.name == "posix" else r"TestPath=c:\test"], + task_params=None, + run_dependencies=False, + output="human-readable", + path_mapping_rules="file://" + temp_rules.name, + environments=[], + maximum_tasks=1, + ) + + # WHEN + do_run(run_args) + + # THEN + assert not any( + os.linesep in m for m in caplog.messages + ), "paranoia; Windows is acting weird." + if os.name == "posix": + assert any("Mapped:/mnt/test" in m for m in caplog.messages) + else: + assert any(r"Mapped:\\mnt\\test" in m for m in caplog.messages) finally: if temp_rules: Path(temp_rules.name).unlink() @@ -177,6 +406,7 @@ def test_do_run_nonexistent_step(capsys: pytest.CaptureFixture): maximum_tasks=-1, run_dependencies=False, path_mapping_rules=None, + environments=[], output="human-readable", ) with pytest.raises(SystemExit): diff --git a/test/openjd/cli/test_schema_command.py b/test/openjd/cli/test_schema_command.py index 57f7a0d..e722877 100644 --- a/test/openjd/cli/test_schema_command.py +++ b/test/openjd/cli/test_schema_command.py @@ -77,6 +77,28 @@ def test_do_get_schema_success(capsys: pytest.CaptureFixture): assert "steps" in model_json["properties"] +@pytest.mark.usefixtures("capsys") +def test_do_get_schema_success_environment(capsys: pytest.CaptureFixture): + """ + Test that the `schema` command returns a correctly-formed + JSON body with specific Environment template attributes. + """ + with patch( + "openjd.cli._schema._schema_command._process_regex", new=Mock(side_effect=_process_regex) + ) as patched_process_regex: + do_get_schema( + Namespace(version=SchemaVersion.ENVIRONMENT_v2023_09, output="human-readable") + ) + patched_process_regex.assert_called() + + model_output = capsys.readouterr().out + model_json = json.loads(model_output) + + assert model_json is not None + assert model_json["title"] == "EnvironmentTemplate" + assert "specificationVersion" in model_json["properties"] + + @pytest.mark.usefixtures("capsys") def test_do_get_schema_incorrect_version(capsys: pytest.CaptureFixture): """