diff --git a/pyproject.toml b/pyproject.toml index dae4f853..5d4c447a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,7 +31,11 @@ classifiers = [ "Topic :: Software Development :: Libraries" ] dependencies = [ - "openjd-model >= 0.9,< 0.10", + # Requires the openjd-model release that ships the EXPR + WRAP_ACTIONS + # bindings (build_symbol_table, job_parameter_type_expr_spec) and the + # wrap-hook model. Those are added by feat: commits on top of model 0.10.0, + # which the model's conventional-commits release tooling bumps to 0.11.0. + "openjd-model >= 0.11,< 0.12", "pywin32 >= 307; platform_system == 'Windows'", "psutil >= 5.9,< 7.3; platform_system == 'Windows'", ] diff --git a/src/openjd/sessions/_runner_env_script.py b/src/openjd/sessions/_runner_env_script.py index 2ce1962c..bd92081b 100644 --- a/src/openjd/sessions/_runner_env_script.py +++ b/src/openjd/sessions/_runner_env_script.py @@ -142,6 +142,31 @@ def _run_env_action( self._action = action self._run_action(self._action, symtab, default_timeout=default_timeout) + def run_wrap_action( + self, + action: ActionModel, + *, + default_timeout: Optional[timedelta] = None, + ) -> None: + """Run a caller-supplied wrap action (RFC 0008) against this runner's + prepared symbol table. + + This is the low-level entry point used by the Session's WRAP_ACTIONS + dispatch: the Session chooses the action (an outer environment's + ``onWrapEnvEnter`` / ``onWrapEnvExit``) and seeds the symbol table with + ``WrappedAction.*`` / ``WrappedEnv.*`` before calling. + + Unlike :meth:`enter` / :meth:`exit`, this does NOT materialize any + embedded files or evaluate let bindings — the caller is responsible for + preparing the symbol table. This mirrors the Rust + ``EnvironmentScriptRunner::run_wrap_action`` first-cut behavior. + """ + if self.state != ScriptRunnerState.READY: + raise RuntimeError("This cannot be used to run a second subprocess.") + log_subsection_banner(self._logger, "Phase: Setup") + self._action = action + self._run_action(action, self._symtab, default_timeout=default_timeout) + def enter(self) -> None: """Run the Environment's onEnter action.""" if self.state != ScriptRunnerState.READY: diff --git a/src/openjd/sessions/_runner_step_script.py b/src/openjd/sessions/_runner_step_script.py index a4d2e654..bf9dd730 100644 --- a/src/openjd/sessions/_runner_step_script.py +++ b/src/openjd/sessions/_runner_step_script.py @@ -21,7 +21,7 @@ TerminateCancelMethod, ) from ._session_user import SessionUser -from ._types import ActionState, StepScriptModel +from ._types import ActionModel, ActionState, StepScriptModel __all__ = ("StepScriptRunner",) @@ -95,6 +95,29 @@ def __init__( if not isinstance(self._script, StepScript_2023_09): raise NotImplementedError("Unknown model type") + def run_wrap_action( + self, + action: ActionModel, + *, + default_timeout: Optional[timedelta] = None, + ) -> None: + """Run a caller-supplied wrap action (RFC 0008) against this runner's + prepared symbol table. + + Used by the Session's WRAP_ACTIONS dispatch when an active wrap + environment defines ``onWrapTaskRun``: the Session seeds the symbol + table with ``WrappedAction.*`` / ``WrappedStep.Name`` and supplies the + wrap action here, which runs instead of the step's own ``onRun``. + + Unlike :meth:`run`, this does NOT materialize the step's embedded files + or evaluate let bindings — the caller prepares the symbol table. This + mirrors the Rust ``run_wrap_action`` first-cut behavior. + """ + if self.state != ScriptRunnerState.READY: + raise RuntimeError("This cannot be used to run a second subprocess.") + log_subsection_banner(self._logger, "Phase: Setup") + self._run_action(action, self._symtab, default_timeout=default_timeout) + def run(self) -> None: """Run the Step Script's onRun Action.""" if self.state != ScriptRunnerState.READY: diff --git a/src/openjd/sessions/_session.py b/src/openjd/sessions/_session.py index dbb8af49..b9d4a712 100644 --- a/src/openjd/sessions/_session.py +++ b/src/openjd/sessions/_session.py @@ -32,6 +32,7 @@ CancelationMethodTerminate as CancelationMethodTerminate_2023_09, CancelationMode as CancelationMode_2023_09, CommandString as CommandString_2023_09, + ModelParsingContext as ModelParsingContext_2023_09, StepActions as StepActions_2023_09, StepScript as StepScript_2023_09, ValueReferenceConstants as ValueReferenceConstants_2023_09, @@ -48,6 +49,7 @@ from ._subprocess import LoggingSubprocess from ._tempdir import TempDir, custom_gettempdir from ._types import ( + ActionModel, ActionState, EnvironmentIdentifier, EnvironmentModel, @@ -651,6 +653,16 @@ def enter_environment( symtab = self._symbol_table(environment.revision) + # EXPR `let` bindings (RFC 0007) on the environment script are evaluated + # into the symbol table before resolving environment variables or the + # action, so both can reference them. + env_script = environment.script + self._apply_let_bindings( + symtab, + getattr(env_script, "let", None) if env_script is not None else None, + environment.revision, + ) + if environment.variables is not None: # We must process the current environment's variables # before we call _evaluate_current_session_env_vars() @@ -682,6 +694,24 @@ def enter_environment( self._materialize_path_mapping(environment.revision, action_env_vars, symtab) + # RFC 0008 WRAP_ACTIONS: if an outer active wrap environment (excluding + # this one, which is never wrapped by its own hooks) defines + # onWrapEnvEnter and this environment has an onEnter, the wrap action + # runs in place of the inner onEnter. We seed WrappedAction.* / + # WrappedEnv.Name from the inner onEnter before running the wrap action. + wrap_action: Optional[ActionModel] = None + if environment.script is not None and environment.script.actions.onEnter is not None: + wrap_env = self._wrap_env_excluding(identifier) + if wrap_env is not None and wrap_env.script is not None: + on_wrap_enter = wrap_env.script.actions.onWrapEnvEnter + if on_wrap_enter is not None: + self._seed_wrapped_action_symbols( + symtab, + inner_action=environment.script.actions.onEnter, + wrapped_env_name=environment.name, + ) + wrap_action = on_wrap_enter + # Sets the subprocess running. # Returns immediately after it has started, or is running self._action_state = ActionState.RUNNING @@ -701,7 +731,10 @@ def enter_environment( symtab=symtab, session_files_directory=self.files_directory, ) - self._runner.enter() + if wrap_action is not None: + self._runner.run_wrap_action(wrap_action) + else: + self._runner.enter() return identifier @@ -766,6 +799,33 @@ def exit_environment( symtab = self._symbol_table(environment.revision) self._materialize_path_mapping(environment.revision, action_env_vars, symtab) + + # EXPR `let` bindings (RFC 0007) on the environment script are evaluated + # into the symbol table before resolving the onExit / wrap action. + exit_env_script = environment.script + self._apply_let_bindings( + symtab, + getattr(exit_env_script, "let", None) if exit_env_script is not None else None, + environment.revision, + ) + + # RFC 0008 WRAP_ACTIONS: the environment being exited has already been + # popped above, so _active_wrap_env() returns the remaining outer wrap + # environment (if any). If it defines onWrapEnvExit and this environment + # has an onExit, the wrap action runs in place of the inner onExit. + wrap_action: Optional[ActionModel] = None + if environment.script is not None and environment.script.actions.onExit is not None: + wrap_env = self._active_wrap_env() + if wrap_env is not None and wrap_env.script is not None: + on_wrap_exit = wrap_env.script.actions.onWrapEnvExit + if on_wrap_exit is not None: + self._seed_wrapped_action_symbols( + symtab, + inner_action=environment.script.actions.onExit, + wrapped_env_name=environment.name, + ) + wrap_action = on_wrap_exit + # Sets the subprocess running. # Returns immediately after it has started, or is running self._action_state = ActionState.RUNNING @@ -785,7 +845,10 @@ def exit_environment( symtab=symtab, session_files_directory=self.files_directory, ) - self._runner.exit() + if wrap_action is not None: + self._runner.run_wrap_action(wrap_action) + else: + self._runner.exit() def run_task( self, @@ -794,6 +857,7 @@ def run_task( task_parameter_values: TaskParameterSet, os_env_vars: Optional[dict[str, str]] = None, log_task_banner: bool = True, + step_name: Optional[str] = None, ) -> None: """Run a Task within the Session. This method is non-blocking; it will exit when the subprocess is either confirmed to have @@ -834,6 +898,28 @@ def run_task( symtab = self._symbol_table(step_script.revision, task_parameter_values) action_env_vars = self._evaluate_current_session_env_vars(os_env_vars) self._materialize_path_mapping(step_script.revision, action_env_vars, symtab) + + # EXPR `let` bindings (RFC 0007) on the step script are evaluated into + # the symbol table before resolving the action, so the action's + # `{{ }}` expressions (and any wrap-action seeding below) can reference + # them. + self._apply_let_bindings(symtab, getattr(step_script, "let", None), step_script.revision) + + # RFC 0008 WRAP_ACTIONS: if an active wrap environment defines + # onWrapTaskRun, the wrap action runs in place of the step's onRun. We + # seed WrappedAction.* / WrappedStep.Name from the step's onRun first. + wrap_action: Optional[ActionModel] = None + wrap_env = self._active_wrap_env() + if wrap_env is not None and wrap_env.script is not None: + on_wrap_task = wrap_env.script.actions.onWrapTaskRun + if on_wrap_task is not None: + self._seed_wrapped_action_symbols( + symtab, + inner_action=step_script.actions.onRun, + wrapped_step_name=step_name if step_name is not None else "", + ) + wrap_action = on_wrap_task + self._runner = StepScriptRunner( logger=self._logger, user=self._user, @@ -852,7 +938,10 @@ def run_task( # Note: This may fail immediately (e.g. if we cannot write embedded files to disk), # so it's important to set the action_state to RUNNING before calling run(), rather # than after -- run() itself may end up setting the action state to FAILED. - self._runner.run() + if wrap_action is not None: + self._runner.run_wrap_action(wrap_action) + else: + self._runner.run() def _run_task_without_session_env( self, @@ -1092,6 +1181,59 @@ def processed_parameter_value(param: ParameterValue) -> str: else: raise NotImplementedError(f"Schema version {str(version.value)} is not supported.") + def _apply_let_bindings( + self, + symtab: SymbolTable, + let_bindings: Optional[list[str]], + version: SpecificationRevision, + *, + path_format: Optional[Any] = None, + ) -> None: + """Evaluate EXPR ``let`` bindings (RFC 0007) and add them to ``symtab``. + + ``let_bindings`` is the script's/environment's ``let`` field: an ordered + list of ``"name = expression"`` strings. Each RHS is an EXPR expression + evaluated against the symbol table built so far (so later bindings can + reference earlier ones), and its native typed result (int/list/str/...) is + stored under the bound name so the action's own ``{{ }}`` expressions can + reference it. Bindings are only present when the template declared the + EXPR extension, so EXPR parsing is forced for the RHS regardless of the + session's configured extension set. + + First-cut limitation (mirrors the wrap-action seeding): bindings are + resolved before the runner materializes embedded files, so a binding RHS + cannot reference ``Env.File.*`` / ``Task.File.*``. + """ + if not let_bindings: + return + if version != SpecificationRevision.v2023_09: + raise NotImplementedError(f"Schema version {str(version.value)} is not supported.") + + # `let` only appears under the EXPR extension; force it on for parsing + # the RHS even if the Session was constructed without it in its set. + context = ModelParsingContext_2023_09( + supported_extensions=list(set(self.get_enabled_extensions()) | {"EXPR"}) + ) + context.extensions = set(self.get_enabled_extensions()) | {"EXPR"} + + for binding in let_bindings: + name, sep, rhs = binding.partition("=") + name = name.strip() + rhs = rhs.strip() + if not sep or not name or not rhs: + # Malformed bindings are rejected by the model's `let` validator + # at decode time; skip defensively here. + continue + # Parse the RHS as a standalone EXPR expression and evaluate it + # against the current symbol table to obtain its native typed value. + arg = ArgString_2023_09("{{ " + rhs + " }}", context=context) + expressions = arg.expressions + if not expressions or expressions[0].expression is None: + continue + symtab[name] = expressions[0].expression.evaluate( + symtab=symtab, path_format=path_format + ) + def _openjd_session_root_dir(self) -> Path: """ Returns (and creates if necessary) the top-level directory where Open Job Description step session @@ -1316,3 +1458,128 @@ def _evaluate_current_session_env_vars( if identifier in self._created_env_vars: self._created_env_vars[identifier].apply_to_environment(result) return result + + # ========================= + # WRAP_ACTIONS (RFC 0008) + + @staticmethod + def _env_has_any_wrap_hook(environment: EnvironmentModel) -> bool: + """Return True iff the environment defines any RFC 0008 wrap hook + (onWrapEnvEnter / onWrapTaskRun / onWrapEnvExit). + """ + script = environment.script + if script is None: + return False + actions = script.actions + return ( + actions.onWrapEnvEnter is not None + or actions.onWrapTaskRun is not None + or actions.onWrapEnvExit is not None + ) + + def _active_wrap_env(self) -> Optional[EnvironmentModel]: + """Return the innermost currently-active environment that defines any + wrap hook, or None. + + The model validator rejects templates with more than one wrap layer in + a single session, so this is effectively "the wrap environment" at + runtime. The innermost-wins traversal mirrors openjd-rs and keeps + dispatch deterministic. + """ + for identifier in reversed(self._environments_entered): + environment = self._environments.get(identifier) + if environment is not None and self._env_has_any_wrap_hook(environment): + return environment + return None + + def _wrap_env_excluding(self, self_id: EnvironmentIdentifier) -> Optional[EnvironmentModel]: + """Like :meth:`_active_wrap_env`, but excludes ``self_id``. + + An environment's own lifecycle actions (onEnter/onExit) are never + wrapped by its own wrap hooks (RFC 0008). This is what the onEnter + dispatch needs. + """ + for identifier in reversed(self._environments_entered): + if identifier == self_id: + continue + environment = self._environments.get(identifier) + if environment is not None and self._env_has_any_wrap_hook(environment): + return environment + return None + + def _openjd_defined_env(self) -> dict[str, str]: + """The cumulative OpenJD-defined environment variables (Environment + "variables" declarations plus openjd_env/openjd_unset_env directives) + across all currently-entered environments. + + Host-inherited variables are intentionally excluded, per RFC 0008 for + WrappedAction.Environment. + """ + env: dict[str, Optional[str]] = {} + for identifier in self._environments_entered: + changes = self._created_env_vars.get(identifier) + if changes is not None: + changes.apply_to_environment(env) + return {name: value for name, value in env.items() if value is not None} + + def _seed_wrapped_action_symbols( + self, + symtab: SymbolTable, + *, + inner_action: ActionModel, + wrapped_env_name: Optional[str] = None, + wrapped_step_name: Optional[str] = None, + ) -> None: + """Seed the RFC 0008 ``WrappedAction.*`` symbols (and the companion + ``WrappedEnv.Name`` / ``WrappedStep.Name``) onto ``symtab`` from the + wrapped (inner) action. + + The wrapped action's command/args/timeout are resolved through the same + Rust-backed EXPR engine the rest of the model uses, and the resulting + typed values are recorded in ``symtab.expr_types`` so the engine coerces + them correctly when the wrap action's own format strings reference them. + + First-cut limitation (consistent with openjd-rs): the inner action's + command/args are resolved against the symbol table built so far, which + does not yet include the wrapped environment's embedded files + (``Env.File.*``) — those are materialized inside the runner. + """ + command = inner_action.command.resolve(symtab=symtab) + args: list[str] = [] + if inner_action.args is not None: + for arg in inner_action.args: + value = arg.resolve(symtab=symtab) + if value is None: + continue + if isinstance(value, (list, tuple)): + args.extend(str(element) for element in value) + else: + args.append(str(value)) + + wrapped_env = [f"{name}={value}" for name, value in self._openjd_defined_env().items()] + + # WrappedAction.Timeout carries the ORIGINAL wrapped action's timeout in + # seconds (RFC 0008); 0 means unset. + timeout = int(inner_action.timeout) if inner_action.timeout else 0 + + symtab["WrappedAction.Command"] = command + symtab["WrappedAction.Args"] = args + symtab["WrappedAction.Environment"] = wrapped_env + symtab["WrappedAction.Timeout"] = timeout + + # Record EXPR types so the (Rust) build_symbol_table coercion produces + # the right typed values for these symbols. + expr_types: dict[str, str] = dict(getattr(symtab, "expr_types", None) or {}) + expr_types["WrappedAction.Command"] = "STRING" + expr_types["WrappedAction.Args"] = "LIST[STRING]" + expr_types["WrappedAction.Environment"] = "LIST[STRING]" + expr_types["WrappedAction.Timeout"] = "INT" + + if wrapped_env_name is not None: + symtab["WrappedEnv.Name"] = wrapped_env_name + expr_types["WrappedEnv.Name"] = "STRING" + if wrapped_step_name is not None: + symtab["WrappedStep.Name"] = wrapped_step_name + expr_types["WrappedStep.Name"] = "STRING" + + symtab.expr_types = expr_types # type: ignore[attr-defined] diff --git a/test/openjd/sessions/test_let_bindings.py b/test/openjd/sessions/test_let_bindings.py new file mode 100644 index 00000000..3b800fb2 --- /dev/null +++ b/test/openjd/sessions/test_let_bindings.py @@ -0,0 +1,95 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + +"""End-to-end tests for EXPR ``let`` binding resolution (RFC 0007) in the +pure-Python (v0) Session. + +These prove that ``let`` bindings declared on a step script / environment script +are evaluated at run time and made available to the action's ``{{ }}`` +expressions. The RHS of each binding is an EXPR expression evaluated by the Rust +engine through the model's bindings; chained bindings (one referencing an +earlier one) resolve in order. +""" + +import time +from pathlib import Path + +from openjd.model import parse_model +from openjd.model.v2023_09 import ( + Environment as Environment_2023_09, + StepScript as StepScript_2023_09, +) +from openjd.sessions import Session, SessionState + +_EXTS = ["EXPR"] + +# Inline python that writes argv[2] to the file at argv[1]. +_WRITE = "import sys\nopen(sys.argv[1], 'w').write(sys.argv[2])" + + +def _wait_until_ready(session: Session) -> None: + deadline = time.monotonic() + 30 + while session.state == SessionState.RUNNING: + if time.monotonic() > deadline: # pragma: no cover + raise AssertionError("Timed out waiting for action to finish") + time.sleep(0.05) + + +def _onrun(python_exe: str, outfile: Path, content: str) -> dict: + return {"command": python_exe, "args": ["-c", _WRITE, outfile.as_posix(), content]} + + +def _step_with_let(python_exe: str, out: Path, *, let: list, content: str) -> StepScript_2023_09: + return parse_model( + model=StepScript_2023_09, + obj={"let": let, "actions": {"onRun": _onrun(python_exe, out / "out.txt", content)}}, + supported_extensions=_EXTS, + ) + + +class TestStepLetRuntime: + def test_literal_let_resolves(self, session_id: str, python_exe: str, tmp_path: Path) -> None: + # GIVEN a step whose let binding computes a value referenced by onRun. + step = _step_with_let( + python_exe, tmp_path, let=["doubled = 21 * 2"], content="{{ doubled }}" + ) + + # WHEN the task runs + with Session(session_id=session_id, job_parameter_values={}) as session: + session.run_task(step_script=step, task_parameter_values={}) + _wait_until_ready(session) + + # THEN the let value resolved at run time + assert (tmp_path / "out.txt").read_text() == "42" + + def test_chained_let_resolves_in_order( + self, session_id: str, python_exe: str, tmp_path: Path + ) -> None: + # A later binding references an earlier one (RFC 0007 chaining). + step = _step_with_let(python_exe, tmp_path, let=["a = 2", "b = a + 3"], content="{{ b }}") + with Session(session_id=session_id, job_parameter_values={}) as session: + session.run_task(step_script=step, task_parameter_values={}) + _wait_until_ready(session) + assert (tmp_path / "out.txt").read_text() == "5" + + +class TestEnvLetRuntime: + def test_env_script_let_resolves_in_on_enter( + self, session_id: str, python_exe: str, tmp_path: Path + ) -> None: + env = parse_model( + model=Environment_2023_09, + obj={ + "name": "E", + "script": { + "let": ["greeting = 'hello'"], + "actions": { + "onEnter": _onrun(python_exe, tmp_path / "out.txt", "{{ greeting }}") + }, + }, + }, + supported_extensions=_EXTS, + ) + with Session(session_id=session_id, job_parameter_values={}) as session: + session.enter_environment(environment=env) + _wait_until_ready(session) + assert (tmp_path / "out.txt").read_text() == "hello" diff --git a/test/openjd/sessions/test_wrap_actions.py b/test/openjd/sessions/test_wrap_actions.py new file mode 100644 index 00000000..5c7a5029 --- /dev/null +++ b/test/openjd/sessions/test_wrap_actions.py @@ -0,0 +1,177 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + +"""End-to-end tests for the RFC 0008 WRAP_ACTIONS runtime in the pure-Python +(v0) Session. + +These exercise the three wrap-hook dispatch sites (onWrapEnvEnter / +onWrapTaskRun / onWrapEnvExit). Expression resolution of the seeded +``WrappedAction.*`` / ``WrappedEnv.Name`` / ``WrappedStep.Name`` symbols is +handled by the Rust EXPR engine through the model's existing bindings. +""" + +import time +from pathlib import Path + +from openjd.model import parse_model +from openjd.model.v2023_09 import ( + Environment as Environment_2023_09, + StepScript as StepScript_2023_09, +) +from openjd.sessions import ActionState, Session, SessionState + +_EXTS = ["EXPR", "WRAP_ACTIONS"] + +# Inline python that writes argv[2] to the file at argv[1]. +_WRITE = "import sys\nopen(sys.argv[1], 'w').write(sys.argv[2])" + + +def _wait_until_ready(session: Session) -> None: + """Block until the current action finishes.""" + deadline = time.monotonic() + 30 + while session.state == SessionState.RUNNING: + if time.monotonic() > deadline: # pragma: no cover + raise AssertionError("Timed out waiting for action to finish") + time.sleep(0.05) + + +def _action(python_exe: str, outfile: Path, content: str) -> dict: + return { + "command": python_exe, + "args": ["-c", _WRITE, outfile.as_posix(), content], + } + + +def _wrap_environment(python_exe: str, out: Path) -> Environment_2023_09: + """A wrap environment defining its own onEnter/onExit plus all three wrap + hooks. The wrap hooks reference the seeded WrappedAction.* / WrappedEnv.* / + WrappedStep.* symbols so we can prove they resolved through EXPR. + """ + env_dict = { + "name": "WrapEnv", + "script": { + "actions": { + "onEnter": _action(python_exe, out / "wrap_own_enter.txt", "own-enter"), + "onExit": _action(python_exe, out / "wrap_own_exit.txt", "own-exit"), + "onWrapEnvEnter": _action( + python_exe, + out / "wrap_enter.txt", + "cmd={{ WrappedAction.Command }} env={{ WrappedEnv.Name }} " + "timeout={{ WrappedAction.Timeout }}", + ), + "onWrapTaskRun": _action( + python_exe, + out / "wrap_task.txt", + "cmd={{ WrappedAction.Command }} step={{ WrappedStep.Name }}", + ), + "onWrapEnvExit": _action( + python_exe, + out / "wrap_exit.txt", + "env={{ WrappedEnv.Name }}", + ), + } + }, + } + return parse_model(model=Environment_2023_09, obj=env_dict, supported_extensions=_EXTS) + + +def _inner_environment(python_exe: str, out: Path) -> Environment_2023_09: + env_dict = { + "name": "InnerEnv", + "script": { + "actions": { + "onEnter": _action(python_exe, out / "inner_enter.txt", "inner-enter"), + "onExit": _action(python_exe, out / "inner_exit.txt", "inner-exit"), + } + }, + } + return parse_model(model=Environment_2023_09, obj=env_dict, supported_extensions=_EXTS) + + +def _step_script(python_exe: str, out: Path) -> StepScript_2023_09: + step_dict = { + "actions": {"onRun": _action(python_exe, out / "task_run.txt", "task-run")}, + } + return parse_model(model=StepScript_2023_09, obj=step_dict, supported_extensions=_EXTS) + + +class TestWrapActions: + def test_wrap_dispatch_end_to_end( + self, session_id: str, python_exe: str, tmp_path: Path + ) -> None: + # GIVEN + wrap_env = _wrap_environment(python_exe, tmp_path) + inner_env = _inner_environment(python_exe, tmp_path) + step = _step_script(python_exe, tmp_path) + + with Session(session_id=session_id, job_parameter_values={}) as session: + # WHEN: enter the wrap env. Its own onEnter runs (not self-wrapped). + session.enter_environment(environment=wrap_env) + _wait_until_ready(session) + assert session.state == SessionState.READY + + # WHEN: enter the inner env. onWrapEnvEnter runs in place of onEnter. + session.enter_environment(environment=inner_env) + _wait_until_ready(session) + assert session.state == SessionState.READY + + # WHEN: run a task. onWrapTaskRun runs in place of onRun. + session.run_task(step_script=step, task_parameter_values={}, step_name="MyStep") + _wait_until_ready(session) + assert session.state == SessionState.READY + + # WHEN: exit the inner env. onWrapEnvExit runs in place of onExit. + inner_id = session.environments_entered[-1] + session.exit_environment(identifier=inner_id) + _wait_until_ready(session) + + # WHEN: exit the wrap env. Its own onExit runs (not self-wrapped). + wrap_id = session.environments_entered[-1] + session.exit_environment(identifier=wrap_id) + _wait_until_ready(session) + + # THEN: the wrap env's own lifecycle actions ran (never self-wrapped). + assert (tmp_path / "wrap_own_enter.txt").read_text() == "own-enter" + assert (tmp_path / "wrap_own_exit.txt").read_text() == "own-exit" + + # THEN: the inner env/task lifecycle actions were REPLACED by the + # wrap actions — the inner ones never ran. + assert not (tmp_path / "inner_enter.txt").exists() + assert not (tmp_path / "task_run.txt").exists() + assert not (tmp_path / "inner_exit.txt").exists() + + # THEN: the wrap actions ran, and the seeded WrappedAction.* / + # WrappedEnv.Name / WrappedStep.Name resolved through EXPR. + enter_out = (tmp_path / "wrap_enter.txt").read_text() + assert f"cmd={python_exe}" in enter_out + assert "env=InnerEnv" in enter_out + assert "timeout=0" in enter_out + + task_out = (tmp_path / "wrap_task.txt").read_text() + assert f"cmd={python_exe}" in task_out + assert "step=MyStep" in task_out + + assert (tmp_path / "wrap_exit.txt").read_text() == "env=InnerEnv" + + def test_no_wrap_when_no_wrap_env( + self, session_id: str, python_exe: str, tmp_path: Path + ) -> None: + # GIVEN: only a plain (non-wrap) environment and a task. + inner_env = _inner_environment(python_exe, tmp_path) + step = _step_script(python_exe, tmp_path) + + with Session(session_id=session_id, job_parameter_values={}) as session: + # WHEN + session.enter_environment(environment=inner_env) + _wait_until_ready(session) + session.run_task(step_script=step, task_parameter_values={}) + _wait_until_ready(session) + inner_id = session.environments_entered[-1] + session.exit_environment(identifier=inner_id) + _wait_until_ready(session) + + # THEN: with no active wrap env, the ordinary actions run unchanged. + assert (tmp_path / "inner_enter.txt").read_text() == "inner-enter" + assert (tmp_path / "task_run.txt").read_text() == "task-run" + assert (tmp_path / "inner_exit.txt").read_text() == "inner-exit" + assert session.action_status is not None + assert session.action_status.state == ActionState.SUCCESS