1717from typing import TYPE_CHECKING , Any , Callable , Optional , Type , Union
1818
1919from openjd .model import (
20+ FormatStringError ,
2021 JobParameterValues ,
2122 ParameterValue ,
2223 ParameterValueType ,
4849from ._subprocess import LoggingSubprocess
4950from ._tempdir import TempDir , custom_gettempdir
5051from ._types import (
52+ ENV_ACTION_DEFAULT_NOTIFY_PERIOD_SECONDS ,
53+ TASK_RUN_DEFAULT_NOTIFY_PERIOD_SECONDS ,
5154 ActionState ,
5255 EnvironmentIdentifier ,
5356 EnvironmentModel ,
@@ -136,6 +139,11 @@ class SimplifiedEnvironmentVariableChanges:
136139
137140 def __init__ (self , initial_variables : Union [dict [str , str ], "EnvironmentVariableObject" ]):
138141 self ._to_set : dict [str , Optional [str ]]
142+ # Names of variables that were set/unset via openjd_env stdout
143+ # messages (RFC 0008: only these are surfaced through
144+ # ``WrappedAction.Environment``; the initial ``variables:`` map
145+ # seed is intentionally excluded, matching the Rust runtime).
146+ self ._openjd_env_names : set [str ] = set ()
139147
140148 if is_windows ():
141149 self ._to_set = {}
@@ -147,12 +155,14 @@ def __init__(self, initial_variables: Union[dict[str, str], "EnvironmentVariable
147155 def simplify_ordered_changes (self , changes : list [EnvironmentVariableChange ]) -> None :
148156 """Apply a given list of sets and unsets to the current state in order"""
149157 for change in changes :
158+ name = change .name .upper () if is_windows () else change .name
150159 if isinstance (change , EnvironmentVariableSetChange ):
151- self ._to_set [change . name . upper () if is_windows () else change . name ] = change .value
160+ self ._to_set [name ] = change .value
152161 elif isinstance (change , EnvironmentVariableUnsetChange ):
153- self ._to_set [change . name . upper () if is_windows () else change . name ] = None
162+ self ._to_set [name ] = None
154163 else :
155164 raise ValueError ("Unknown type of environment variable change." )
165+ self ._openjd_env_names .add (name )
156166
157167 def apply_to_environment (self , env_vars : dict [str , Optional [str ]]) -> None :
158168 """Modify a given dictionary of environment variables to reflect the changes"""
@@ -723,7 +733,19 @@ def enter_environment(
723733 wrap_env = None
724734
725735 if wrap_env is not None :
726- self ._inject_wrapped_env_symbols (symtab , environment , on_enter_action )
736+ try :
737+ self ._inject_wrapped_env_symbols (symtab , environment , on_enter_action )
738+ except (FormatStringError , ValueError ) as e :
739+ # e.g. the wrapped onEnter references {{Env.File.*}} (embedded
740+ # files are not materialized on the wrap path yet). Fail the
741+ # action through the normal failure path — the environment
742+ # stays in the entered list, exactly as when enter() itself
743+ # fails, so the caller's cleanup exits it as usual.
744+ self ._fail_action_before_start (
745+ f"Failed to resolve the wrapped onEnter action of "
746+ f"{ environment .name } for { wrap_env .name } 's onWrapEnvEnter: { e } "
747+ )
748+ return identifier
727749 self ._runner = EnvironmentScriptRunner (
728750 logger = self ._logger ,
729751 user = self ._user ,
@@ -835,7 +857,18 @@ def exit_environment(
835857 )
836858
837859 if wrap_env is not None :
838- self ._inject_wrapped_env_symbols (symtab , environment , on_exit_action )
860+ try :
861+ self ._inject_wrapped_env_symbols (symtab , environment , on_exit_action )
862+ except (FormatStringError , ValueError ) as e :
863+ # Mirror of the onWrapEnvEnter injection-failure handling:
864+ # fail the action through the normal failure path. The
865+ # environment was already removed from tracking above,
866+ # matching how a failed exit() behaves.
867+ self ._fail_action_before_start (
868+ f"Failed to resolve the wrapped onExit action of "
869+ f"{ environment .name } for { wrap_env .name } 's onWrapEnvExit: { e } "
870+ )
871+ return
839872 self ._runner = EnvironmentScriptRunner (
840873 logger = self ._logger ,
841874 user = self ._user ,
@@ -918,7 +951,28 @@ def run_task(
918951 # wrap action instead of the step script's onRun (RFC 0008).
919952 wrap_env = self ._find_wrap_environment (hook = "onWrapTaskRun" )
920953 if wrap_env is not None :
921- self ._inject_wrapped_task_symbols (symtab , step_script , step_name or "" )
954+ if step_name is None :
955+ # RFC 0008: without a step name, {{WrappedStep.Name}}
956+ # renders as the empty string in the wrap script. Callers
957+ # predating the step_name kwarg won't pass it; make the
958+ # gap visible rather than silently rendering empty.
959+ self ._logger .warning (
960+ "A wrap environment is active but run_task() was not given a "
961+ "step_name; WrappedStep.Name will render as an empty string."
962+ )
963+ try :
964+ self ._inject_wrapped_task_symbols (symtab , step_script , step_name or "" )
965+ except (FormatStringError , ValueError ) as e :
966+ # e.g. the wrapped onRun references {{Task.File.*}} (embedded
967+ # files are not materialized on the wrap path yet), or a
968+ # FEATURE_BUNDLE_1 timeout/notifyPeriod format string did not
969+ # resolve to an integer. Fail the action through the normal
970+ # failure path rather than raising out of the public API.
971+ self ._fail_action_before_start (
972+ f"Failed to resolve the wrapped Task action for { wrap_env .name } 's "
973+ f"onWrapTaskRun: { e } "
974+ )
975+ return
922976
923977 self ._runner = EnvironmentScriptRunner (
924978 logger = self ._logger ,
@@ -1227,21 +1281,27 @@ def _environment_defines_any_wrap_hook(self, env: EnvironmentModel) -> bool:
12271281 if env .script is None :
12281282 return False
12291283 return any (
1230- hasattr (env .script .actions , name )
1231- and getattr (env .script .actions , name ) is not None
1284+ hasattr (env .script .actions , name ) and getattr (env .script .actions , name ) is not None
12321285 for name in self ._WRAP_HOOK_NAMES
12331286 )
12341287
12351288 def _collect_session_env_list (self ) -> list [str ]:
12361289 """Collect all ``openjd_env``-defined variables across the active
12371290 environment stack as ``["KEY=value", ...]`` for
1238- ``WrappedAction.Environment``."""
1291+ ``WrappedAction.Environment``.
1292+
1293+ RFC 0008 defines this variable as carrying only ``openjd_env``
1294+ definitions from the current session; an environment's declarative
1295+ ``variables:`` map (and host-inherited variables) are intentionally
1296+ excluded, matching the Rust runtime."""
12391297 env_list : list [str ] = []
12401298 for env_id in self ._environments_entered :
12411299 if env_id in self ._created_env_vars :
12421300 changes = self ._created_env_vars [env_id ]
1301+ # Iterate _to_set (insertion-ordered) rather than the name
1302+ # set so the list order is deterministic.
12431303 for key , value in changes ._to_set .items ():
1244- if value is not None :
1304+ if key in changes . _openjd_env_names and value is not None :
12451305 env_list .append (f"{ key } ={ value } " )
12461306 return env_list
12471307
@@ -1262,11 +1322,12 @@ def _inject_wrapped_cancelation_symbols(
12621322 action's ``<Cancelation>`` (RFC 0008 follow-up,
12631323 openjd-specifications#148).
12641324
1265- ``Mode`` is ``"TERMINATE"``, ``"NOTIFY_THEN_TERMINATE"``, or the
1266- empty string when the wrapped action defines no ``<Cancelation>`` —
1267- the empty case is deliberately distinct from an explicit
1268- ``TERMINATE`` so wrap scripts can tell "author declared TERMINATE"
1269- apart from "author declared nothing".
1325+ ``Mode`` is typed ``string?``: ``"TERMINATE"``,
1326+ ``"NOTIFY_THEN_TERMINATE"``, or ``None`` (rendering as
1327+ ``null``/empty in format strings) when the wrapped action defines
1328+ no ``<Cancelation>`` — the null case is deliberately distinct from
1329+ an explicit ``TERMINATE`` so wrap scripts can tell "author declared
1330+ TERMINATE" apart from "author declared nothing".
12701331
12711332 ``NotifyPeriodInSeconds`` is typed ``int?``: the effective grace
12721333 period when the mode is ``NOTIFY_THEN_TERMINATE``, applying the
@@ -1278,10 +1339,10 @@ def _inject_wrapped_cancelation_symbols(
12781339 applicable" is not conflated with a zero-length notify period.
12791340 """
12801341 cancelation = getattr (action , "cancelation" , None )
1281- mode : str
1342+ mode : Optional [ str ]
12821343 notify_period : Optional [int ]
12831344 if cancelation is None :
1284- mode = ""
1345+ mode = None
12851346 notify_period = None
12861347 elif cancelation .mode == CancelationMode_2023_09 .TERMINATE :
12871348 mode = CancelationMode_2023_09 .TERMINATE .value
@@ -1290,7 +1351,11 @@ def _inject_wrapped_cancelation_symbols(
12901351 mode = CancelationMode_2023_09 .NOTIFY_THEN_TERMINATE .value
12911352 period = getattr (cancelation , "notifyPeriodInSeconds" , None )
12921353 if period is None :
1293- notify_period = 120 if is_task_run else 30
1354+ notify_period = (
1355+ TASK_RUN_DEFAULT_NOTIFY_PERIOD_SECONDS
1356+ if is_task_run
1357+ else ENV_ACTION_DEFAULT_NOTIFY_PERIOD_SECONDS
1358+ )
12941359 elif isinstance (period , int ):
12951360 notify_period = period
12961361 else :
@@ -1308,17 +1373,11 @@ def _inject_wrapped_env_symbols(
13081373 """Populate ``WrappedAction.*`` and ``WrappedEnv.Name`` for
13091374 ``onWrapEnvEnter`` / ``onWrapEnvExit`` scripts (RFC 0008)."""
13101375 command = inner_action .command .resolve (symtab = symtab )
1311- args = (
1312- [a .resolve (symtab = symtab ) for a in inner_action .args ]
1313- if inner_action .args
1314- else []
1315- )
1376+ args = [a .resolve (symtab = symtab ) for a in inner_action .args ] if inner_action .args else []
13161377 symtab ["WrappedAction.Command" ] = command
13171378 symtab ["WrappedAction.Args" ] = args
13181379 symtab ["WrappedAction.Environment" ] = self ._collect_session_env_list ()
1319- symtab ["WrappedAction.Timeout" ] = self ._resolve_action_timeout (
1320- inner_action , symtab
1321- )
1380+ symtab ["WrappedAction.Timeout" ] = self ._resolve_action_timeout (inner_action , symtab )
13221381 self ._inject_wrapped_cancelation_symbols (symtab , inner_action , is_task_run = False )
13231382 symtab ["WrappedEnv.Name" ] = environment .name
13241383
@@ -1339,9 +1398,7 @@ def _inject_wrapped_task_symbols(
13391398
13401399 symtab ["WrappedAction.Command" ] = on_run .command .resolve (symtab = symtab )
13411400 symtab ["WrappedAction.Args" ] = (
1342- [arg .resolve (symtab = symtab ) for arg in on_run .args ]
1343- if on_run .args
1344- else []
1401+ [arg .resolve (symtab = symtab ) for arg in on_run .args ] if on_run .args else []
13451402 )
13461403 symtab ["WrappedAction.Environment" ] = self ._collect_session_env_list ()
13471404 symtab ["WrappedAction.Timeout" ] = self ._resolve_action_timeout (on_run , symtab )
@@ -1526,6 +1583,26 @@ def _action_log_filter_callback(
15261583 assert action_status is not None
15271584 self ._callback (self ._session_id , action_status )
15281585
1586+ def _fail_action_before_start (self , message : str ) -> None :
1587+ """Mark the pending action as FAILED before any runner/subprocess
1588+ exists (RFC 0008: e.g. when resolving the wrapped action's format
1589+ strings for ``WrappedAction.*`` injection fails).
1590+
1591+ Mirrors the failure branch of :meth:`_action_callback` — the
1592+ session transitions to READY_ENDING so the caller can exit the
1593+ entered environments — but does not require ``self._runner``.
1594+ """
1595+ self ._logger .error (message )
1596+ self ._action_fail_message = message
1597+ self ._action_exit_code = None
1598+ self ._action_state = ActionState .FAILED
1599+ self ._state = SessionState .READY_ENDING
1600+ if self ._callback :
1601+ action_status = self .action_status
1602+ # for the type checker
1603+ assert action_status is not None
1604+ self ._callback (self ._session_id , action_status )
1605+
15291606 def _action_callback (self , state : ActionState ) -> None :
15301607 """This callback is invoked:
15311608 1. When the Action process is successfully started, by the same thread that is running the
0 commit comments