diff --git a/rust-bindings/src/expr/symbol_table.rs b/rust-bindings/src/expr/symbol_table.rs index f2fa3570..521dd258 100644 --- a/rust-bindings/src/expr/symbol_table.rs +++ b/rust-bindings/src/expr/symbol_table.rs @@ -269,6 +269,45 @@ impl PySerializedSymbolTable { } } + /// Build a ``SerializedSymbolTable`` from its JSON transport + /// text, as produced by ``to_json_str``. + /// + /// This is the inverse of ``to_json_str`` and exists for callers + /// that carry the transport form across a process or service + /// boundary — a scheduler persisting the table produced by + /// ``create_job`` and later handing it to a worker, for instance. + /// Prefer ``from_symtab`` when the source is an in-memory + /// ``SymbolTable``. + /// + /// Raises ``ValueError`` if the text is not valid JSON. Note that + /// the *contents* are validated lazily: a well-formed JSON + /// document whose entries are not valid symbol table entries is + /// accepted here and rejected by ``to_symtab``. + #[classmethod] + fn from_json_str(_cls: &Bound<'_, pyo3::types::PyType>, json: &str) -> PyResult { + let inner = openjd_expr::SerializedSymbolTable::from_json_str(json).map_err(|e| { + pyo3::exceptions::PyValueError::new_err(format!( + "Failed to parse SerializedSymbolTable JSON: {e}" + )) + })?; + Ok(Self { inner }) + } + + /// Serialize to the JSON transport text: an array of + /// ``{"name", "type", "value"}`` objects in canonical + /// (lexicographic) path order. + /// + /// Use this to move a table across a process or service boundary; + /// pair it with ``from_json_str`` to reconstruct. The result is + /// stable for a given table, so it is safe to store or compare. + fn to_json_str(&self) -> PyResult { + serde_json::to_string(&self.inner).map_err(|e| { + pyo3::exceptions::PyValueError::new_err(format!( + "Failed to serialize SerializedSymbolTable: {e}" + )) + }) + } + /// Deserialize this serialized symbol table into a full /// ``SymbolTable`` suitable for inspection or modification. /// ``path_format`` controls how PATH-typed values are diff --git a/src/openjd/_openjd_rs.pyi b/src/openjd/_openjd_rs.pyi index 36947fdb..205166a2 100644 --- a/src/openjd/_openjd_rs.pyi +++ b/src/openjd/_openjd_rs.pyi @@ -2032,6 +2032,36 @@ class SerializedSymbolTable: use this classmethod to convert. """ + @classmethod + def from_json_str(cls, json: builtins.str) -> SerializedSymbolTable: + r""" + Build a ``SerializedSymbolTable`` from its JSON transport + text, as produced by ``to_json_str``. + + This is the inverse of ``to_json_str`` and exists for callers + that carry the transport form across a process or service + boundary — a scheduler persisting the table produced by + ``create_job`` and later handing it to a worker, for instance. + Prefer ``from_symtab`` when the source is an in-memory + ``SymbolTable``. + + Raises ``ValueError`` if the text is not valid JSON. Note that + the *contents* are validated lazily: a well-formed JSON + document whose entries are not valid symbol table entries is + accepted here and rejected by ``to_symtab``. + """ + + def to_json_str(self) -> builtins.str: + r""" + Serialize to the JSON transport text: an array of + ``{"name", "type", "value"}`` objects in canonical + (lexicographic) path order. + + Use this to move a table across a process or service boundary; + pair it with ``from_json_str`` to reconstruct. The result is + stable for a given table, so it is safe to store or compare. + """ + def to_symtab(self, *, path_format: typing.Optional[PathFormat] = None) -> SymbolTable: r""" Deserialize this serialized symbol table into a full diff --git a/src/openjd/model/__init__.py b/src/openjd/model/__init__.py index 5e76c1c5..4776e5fb 100644 --- a/src/openjd/model/__init__.py +++ b/src/openjd/model/__init__.py @@ -1,7 +1,12 @@ # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. from ._capabilities import validate_attribute_capability_name, validate_amount_capability_name -from ._create_job import create_job, preprocess_job_parameters +from ._create_job import ( + JobWithSymbolTables, + create_job, + create_job_with_symbol_tables, + preprocess_job_parameters, +) from ._errors import ( CompatibilityError, DecodeValidationError, @@ -51,6 +56,8 @@ __all__ = ( "create_job", + "create_job_with_symbol_tables", + "JobWithSymbolTables", "decode_template", "decode_environment_template", "decode_job_template", diff --git a/src/openjd/model/_create_job.py b/src/openjd/model/_create_job.py index 99bc5ff2..d85f6b04 100644 --- a/src/openjd/model/_create_job.py +++ b/src/openjd/model/_create_job.py @@ -1,15 +1,22 @@ # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. import json +from dataclasses import dataclass from os.path import normpath from pathlib import Path -from typing import Any, Optional, cast +from typing import TYPE_CHECKING, Any, Optional, cast from pydantic import ValidationError from ._errors import CompatibilityError, DecodeValidationError from ._format_strings import FormatStringError from ._symbol_table import SymbolTable + +if TYPE_CHECKING: + # Type-only: importing openjd.expr loads the Rust bindings, and importing this + # module must not. The runtime imports live in _serialize_symbol_table, which + # only the opt-in create_job_with_symbol_tables path reaches. + from openjd.expr import SerializedSymbolTable from ._internal import instantiate_model from ._merge_job_parameter import merge_job_parameter_definitions from ._types import ( @@ -26,7 +33,28 @@ ) from ._convert_pydantic_error import pydantic_validationerrors_to_str -__all__ = ("preprocess_job_parameters",) +__all__ = ("preprocess_job_parameters", "create_job_with_symbol_tables", "JobWithSymbolTables") + + +@dataclass(frozen=True) +class JobWithSymbolTables: + """A created job together with the symbol tables it was instantiated with. + + Returned by :func:`create_job_with_symbol_tables`. The tables are in the + ``SerializedSymbolTable`` transport form, ready to persist or send to the host + that will run the job's sessions. + """ + + job: Job + """The created job — identical to what ``create_job`` returns.""" + + job_symbol_table: "SerializedSymbolTable" + """Job scope: ``Param.*``, ``RawParam.*``, and ``Job.Name`` under EXPR.""" + + step_symbol_tables: dict[str, "SerializedSymbolTable"] + """Step scope keyed by step name: job scope plus ``Step.Name`` and the step's + evaluated template-scope ``let`` bindings.""" + # The original scalar job-parameter type names whose values are carried as # strings through preprocessing. EXPR-extension types (BOOL, RANGE_EXPR, and @@ -386,12 +414,12 @@ def preprocess_job_parameters( # ======================================================================= -def create_job( +def _create_job_and_symbol_table( *, job_template: JobTemplate, job_parameter_values: JobParameterValues, environment_templates: Optional[list[EnvironmentTemplate]] = None, -) -> Job: +) -> tuple[Job, SymbolTable]: """This function will create a job from a given Job Template and set of values for Job Parameters. Minimally, values must be provided for Job Parameters that do not have default values defined in the template. @@ -409,7 +437,8 @@ def create_job( DecodeValidationError Returns: - Job: The job generated. + tuple[Job, SymbolTable]: The job generated, and the job-scope symbol table + it was instantiated with. """ # Raises: ValueError @@ -489,4 +518,112 @@ def create_job( pydantic_validationerrors_to_str(job_template.__class__, exc.errors()) ) - return cast(Job, job) + return cast(Job, job), symtab + + +def create_job( + *, + job_template: JobTemplate, + job_parameter_values: JobParameterValues, + environment_templates: Optional[list[EnvironmentTemplate]] = None, +) -> Job: + """Create a job from a Job Template and a set of Job Parameter values. + + See :func:`create_job_with_symbol_tables` when you also need the resolved + symbol tables — for instance to transport them to a host that will run the + job's sessions. + + Raises: + DecodeValidationError + + Returns: + Job: The job generated. + """ + job, _symtab = _create_job_and_symbol_table( + job_template=job_template, + job_parameter_values=job_parameter_values, + environment_templates=environment_templates, + ) + return job + + +def create_job_with_symbol_tables( + *, + job_template: JobTemplate, + job_parameter_values: JobParameterValues, + environment_templates: Optional[list[EnvironmentTemplate]] = None, +) -> JobWithSymbolTables: + """Create a job, and return the resolved symbol tables alongside it. + + Same job as :func:`create_job`. The difference is that the symbol tables + built during instantiation are returned instead of discarded, in the + ``SerializedSymbolTable`` transport form, so a caller can persist them and + hand them to a session on another host. + + This mirrors ``openjd-rs``, whose ``create_job`` attaches the step-scope + table to each ``Step`` as ``resolved_symtab``. The tables are returned + separately here rather than added to the ``Job`` model so that the model's + serialized form does not change. + + Scopes, matching the sessions API: + + * ``job_symbol_table`` — job scope: ``Param.*``, ``RawParam.*`` and, with the + EXPR extension, ``Job.Name``. Use it for job and queue environments. + * ``step_symbol_tables`` — job scope plus ``Step.Name`` and the step's + evaluated template-scope ``let`` bindings, keyed by step name. Use each for + that step and its step environments. + + Script-scope ``let`` bindings are deliberately absent: they resolve at + session time, so the table carries the symbols they reference rather than + their results. + + Unlike ``openjd-rs``, the tables are not filtered down to the symbols a step + actually references. They are a superset, which is valid input wherever a + filtered table is — a session layers its own scopes on top either way. + + Raises: + DecodeValidationError + + Returns: + JobWithSymbolTables: The job and its resolved symbol tables. + """ + job, symtab = _create_job_and_symbol_table( + job_template=job_template, + job_parameter_values=job_parameter_values, + environment_templates=environment_templates, + ) + + step_tables: dict[str, SerializedSymbolTable] = {} + for step_template in getattr(job_template, "steps", None) or []: + # Reuse the model's own per-step hook — the one instantiate_model calls — + # so the returned table is the step scope the job was instantiated with + # rather than a reimplementation of it. It depends only on the step's + # name, its `let` bindings and the job-scope table, so re-invoking it + # here is deterministic. + extends_symtab = step_template._job_creation_metadata.extends_symtab + step_symtab = extends_symtab(step_template, symtab) if extends_symtab else symtab + step_tables[str(step_template.name)] = _serialize_symbol_table(step_symtab) + + return JobWithSymbolTables( + job=job, + job_symbol_table=_serialize_symbol_table(symtab), + step_symbol_tables=step_tables, + ) + + +def _serialize_symbol_table(symtab: SymbolTable) -> "SerializedSymbolTable": + """Convert a job-model symbol table into the EXPR transport form. + + Goes through ``symtab_to_expr_values`` so the typed coercion is the engine's + own, then through ``SerializedSymbolTable.from_symtab`` — the same serializer + openjd-rs uses — so the bytes match what the Rust implementation produces for + an equivalent table. + """ + # Imported here rather than at module scope: both of these load the Rust + # bindings, and importing openjd.model must not. + from openjd.expr import SerializedSymbolTable + + from ._format_strings._expr_support import symtab_to_expr_values + + engine_symtab = symtab_to_expr_values(symtab, types=symtab.expr_types) + return SerializedSymbolTable.from_symtab(engine_symtab) diff --git a/test/openjd/expr/test_symbol_table.py b/test/openjd/expr/test_symbol_table.py index 95b06552..e894dd52 100644 --- a/test/openjd/expr/test_symbol_table.py +++ b/test/openjd/expr/test_symbol_table.py @@ -1,5 +1,7 @@ # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +import json +import pickle from pathlib import Path from typing import cast @@ -7,7 +9,7 @@ import sys -from openjd.expr import ExprValue, SymbolTable, TypeCode +from openjd.expr import ExprValue, SerializedSymbolTable, SymbolTable, TypeCode from openjd.expr import PathFormat HOST_PATH_FORMAT = PathFormat.WINDOWS if sys.platform == "win32" else PathFormat.POSIX @@ -254,3 +256,125 @@ def test_contains_namespace(self) -> None: assert "Param" in st assert "Param.X" in st assert "Other" not in st + + +class TestSerializedSymbolTableJson: + """``to_json_str`` / ``from_json_str`` are the supported way to move a + serialized symbol table across a process or service boundary.""" + + def test_round_trip_preserves_values(self) -> None: + # GIVEN + symtab = SymbolTable( + { + "Job.Name": "my-job", + "Step.Name": "render", + "Param.Count": 42, + "Param.Scale": 1.5, + "Param.Debug": True, + } + ) + serialized = SerializedSymbolTable.from_symtab(symtab) + + # WHEN + json_text = serialized.to_json_str() + restored = SerializedSymbolTable.from_json_str(json_text).to_symtab() + + # THEN + assert restored["Job.Name"] == ExprValue("my-job") + assert restored["Step.Name"] == ExprValue("render") + assert restored["Param.Count"] == ExprValue(42) + assert restored["Param.Scale"] == ExprValue(1.5) + assert restored["Param.Debug"] == ExprValue(True) + + def test_transport_shape(self) -> None: + """The transport form is an array of {name, type, value} objects in + canonical path order, with scalars carried as strings.""" + # GIVEN + symtab = SymbolTable({"Job.Name": "my-job", "Param.Count": 42}) + + # WHEN + entries = json.loads(SerializedSymbolTable.from_symtab(symtab).to_json_str()) + + # THEN + assert entries == [ + {"name": "Job.Name", "type": "string", "value": "my-job"}, + {"name": "Param.Count", "type": "int", "value": "42"}, + ] + + def test_to_json_str_is_stable(self) -> None: + # GIVEN + symtab = SymbolTable({"Param.B": 2, "Param.A": 1}) + + # WHEN + first = SerializedSymbolTable.from_symtab(symtab).to_json_str() + second = SerializedSymbolTable.from_symtab(symtab).to_json_str() + + # THEN + assert first == second + + def test_empty_table_round_trips(self) -> None: + # WHEN + json_text = SerializedSymbolTable.from_symtab(SymbolTable()).to_json_str() + + # THEN + assert json_text == "[]" + assert SerializedSymbolTable.from_json_str(json_text).to_symtab().symbols == set() + + def test_from_json_str_accepts_hand_built_transport(self) -> None: + """A caller that builds the transport form itself, rather than going + through ``from_symtab``, gets the same result.""" + # GIVEN + hand_built = '[{"name": "Job.Name", "type": "string", "value": "hand-built"}]' + + # WHEN + symtab = SerializedSymbolTable.from_json_str(hand_built).to_symtab() + + # THEN + assert symtab["Job.Name"] == ExprValue("hand-built") + + def test_from_json_str_rejects_malformed_json(self) -> None: + # WHEN / THEN + with pytest.raises(ValueError, match="Failed to parse SerializedSymbolTable JSON"): + SerializedSymbolTable.from_json_str("not json at all") + + def test_from_json_str_defers_content_validation_to_to_symtab(self) -> None: + """Well-formed JSON that is not a valid table is accepted by + ``from_json_str`` and rejected by ``to_symtab``.""" + # GIVEN + well_formed_but_wrong = '{"not": "an array"}' + + # WHEN + serialized = SerializedSymbolTable.from_json_str(well_formed_but_wrong) + + # THEN + with pytest.raises(ValueError, match="expected JSON array"): + serialized.to_symtab() + + def test_json_round_trip_matches_pickle_round_trip(self) -> None: + """The JSON form carries the same content as the pickle form, which + round-trips through the same transport text.""" + # GIVEN + symtab = SymbolTable({"Job.Name": "my-job", "Param.Count": 42}) + serialized = SerializedSymbolTable.from_symtab(symtab) + + # WHEN + via_json = SerializedSymbolTable.from_json_str(serialized.to_json_str()).to_symtab() + via_pickle = pickle.loads(pickle.dumps(serialized)).to_symtab() + + # THEN + assert via_json.symbols == via_pickle.symbols + for name in via_json.symbols: + assert via_json[name] == via_pickle[name] + + def test_path_values_round_trip_with_host_format(self) -> None: + # GIVEN + symtab = SymbolTable({"RawParam.Scene": "/proj/scene.blend"}) + json_text = SerializedSymbolTable.from_symtab(symtab).to_json_str() + + # WHEN + restored = SerializedSymbolTable.from_json_str(json_text).to_symtab( + path_format=HOST_PATH_FORMAT + ) + + # THEN + assert "RawParam.Scene" in restored diff --git a/test/openjd/model_v0/test_create_job.py b/test/openjd/model_v0/test_create_job.py index b7a63936..a7945c4b 100644 --- a/test/openjd/model_v0/test_create_job.py +++ b/test/openjd/model_v0/test_create_job.py @@ -1,5 +1,6 @@ # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +import json import os import tempfile import pytest @@ -818,3 +819,252 @@ def test_uneven_parameter_space_association(self) -> None: "1 validation errors for JobTemplate\nsteps[0] -> parameterSpace -> combination:\n\tAssociative expressions must have arguments with identical ranges. Expression (A, B) has argument lengths (10, 2)." in str(excinfo.value) ) + + +class TestCreateJobWithSymbolTables: + """``create_job_with_symbol_tables`` returns the tables that ``create_job`` + discards, in the transport form openjd-rs uses.""" + + EXPR_TEMPLATE: dict[str, Any] = { + "specificationVersion": "jobtemplate-2023-09", + "extensions": ["EXPR"], + "name": "my-job", + "parameterDefinitions": [ + {"name": "Count", "type": "INT", "default": 21}, + {"name": "Tag", "type": "STRING", "default": "abc"}, + {"name": "Scene", "type": "PATH", "default": "scene.blend"}, + ], + "steps": [ + { + "name": "render", + "let": ["twice = Param.Count * 2", "tag = Job.Name + '-' + Step.Name"], + "script": { + "let": ["sess = Job.Name + '!'"], + "actions": { + "onRun": { + "command": "echo", + "args": ["{{ twice }}", "{{ tag }}", "{{ sess }}"], + } + }, + }, + }, + { + "name": "publish", + "script": {"actions": {"onRun": {"command": "echo", "args": ["{{ Param.Tag }}"]}}}, + }, + ], + } + + @staticmethod + def _entries(serialized: Any) -> dict[str, tuple[str, Any]]: + """Decode the transport form into {name: (type, value)}.""" + _helper, (text,) = serialized.__reduce__() + return {e["name"]: (e["type"], e["value"]) for e in json.loads(text)} + + def _create(self) -> Any: + from openjd.model import create_job_with_symbol_tables + + job_template = decode_job_template( + template=self.EXPR_TEMPLATE, supported_extensions=["EXPR"] + ) + parameter_values = preprocess_job_parameters( + job_template=job_template, + job_parameter_values={}, + job_template_dir=Path(), + current_working_dir=Path(), + allow_job_template_dir_walk_up=True, + ) + return create_job_with_symbol_tables( + job_template=job_template, job_parameter_values=parameter_values + ) + + def test_returns_the_same_job_as_create_job(self) -> None: + # GIVEN + job_template = decode_job_template( + template=self.EXPR_TEMPLATE, supported_extensions=["EXPR"] + ) + parameter_values = preprocess_job_parameters( + job_template=job_template, + job_parameter_values={}, + job_template_dir=Path(), + current_working_dir=Path(), + allow_job_template_dir_walk_up=True, + ) + + # WHEN + plain = create_job(job_template=job_template, job_parameter_values=parameter_values) + result = self._create() + + # THEN + assert result.job == plain + + def test_job_scope_table(self) -> None: + # WHEN + entries = self._entries(self._create().job_symbol_table) + + # THEN + assert entries["Job.Name"] == ("string", "my-job") + assert entries["Param.Count"] == ("int", "21") + assert entries["Param.Tag"] == ("string", "abc") + # PATH parameters carry RawParam only at template scope: the mapped + # Param.* value only exists at session scope. + assert entries["RawParam.Scene"] == ("string", "scene.blend") + assert "Param.Scene" not in entries + # Job scope has no step in it + assert "Step.Name" not in entries + + def test_step_scope_tables_are_keyed_by_step_name(self) -> None: + # WHEN + tables = self._create().step_symbol_tables + + # THEN + assert set(tables) == {"render", "publish"} + + def test_step_scope_adds_step_name_and_template_let_results(self) -> None: + # WHEN + entries = self._entries(self._create().step_symbol_tables["render"]) + + # THEN + assert entries["Step.Name"] == ("string", "render") + # Template-scope `let` results are resolved and frozen into the table + assert entries["twice"] == ("int", "42") + assert entries["tag"] == ("string", "my-job-render") + + def test_script_scope_let_is_not_in_the_table(self) -> None: + """Script-scope `let` resolves at session time, so only the symbols it + references travel — not its results.""" + # WHEN + entries = self._entries(self._create().step_symbol_tables["render"]) + + # THEN + assert "sess" not in entries + assert "Job.Name" in entries + + def test_each_step_gets_its_own_scope(self) -> None: + # WHEN + tables = self._create().step_symbol_tables + + # THEN + assert self._entries(tables["publish"])["Step.Name"] == ("string", "publish") + # `render`'s let bindings do not leak into `publish` + assert "twice" not in self._entries(tables["publish"]) + + def test_non_expr_template_has_no_job_name(self) -> None: + from openjd.model import create_job_with_symbol_tables + + # GIVEN + template = { + "specificationVersion": "jobtemplate-2023-09", + "name": "plain", + "parameterDefinitions": [{"name": "Tag", "type": "STRING", "default": "x"}], + "steps": [{"name": "s", "script": {"actions": {"onRun": {"command": "echo"}}}}], + } + job_template = decode_job_template(template=template) + parameter_values = preprocess_job_parameters( + job_template=job_template, + job_parameter_values={}, + job_template_dir=Path(), + current_working_dir=Path(), + allow_job_template_dir_walk_up=True, + ) + + # WHEN + result = create_job_with_symbol_tables( + job_template=job_template, job_parameter_values=parameter_values + ) + + # THEN — Job.Name is EXPR-gated + assert "Job.Name" not in self._entries(result.job_symbol_table) + assert self._entries(result.job_symbol_table)["Param.Tag"] == ("string", "x") + + def test_matches_the_rust_implementation(self) -> None: + """Every symbol openjd-rs' create_job puts in Step.resolved_symtab appears + here with an identical type and value. + + This is the property that lets a v0 producer and a Rust consumer share the + channel. The table here is a superset: openjd-rs filters its table down to + the symbols the step references, which is a payload optimization rather + than a semantic difference. + """ + from openjd._openjd_rs import create_job as rs_create_job + from openjd._openjd_rs import decode_job_template as rs_decode + + # GIVEN + rs_job = rs_create_job( + job_template=rs_decode(self.EXPR_TEMPLATE, supported_extensions=["EXPR"]), + job_parameter_values={}, + ) + v0_tables = self._create().step_symbol_tables + + # WHEN / THEN + compared = 0 + for rs_step in rs_job.steps: + if rs_step.resolved_symtab is None: + continue + rust_entries = self._entries(rs_step.resolved_symtab) + v0_entries = self._entries(v0_tables[str(rs_step.name)]) + for name, rust_value in rust_entries.items(): + assert ( + name in v0_entries + ), f"{name} missing from the v0 table for step {rs_step.name}" + assert v0_entries[name] == rust_value, f"{name} differs for step {rs_step.name}" + compared += 1 + # Guard against the assertions above passing vacuously + assert compared > 0 + + def test_queue_environment_parameters_reach_the_tables(self) -> None: + """Parameters contributed by an external environment template — a queue + environment, in service terms — must appear in the returned tables. + + Note the job template itself cannot *reference* such a parameter: the + caller merges the environment's parameterDefinitions into the template + before decode. What this pins is that the values reach the symbol tables, + which is this API's part of that flow. + """ + from openjd.model import create_job_with_symbol_tables + + # GIVEN + job_template = decode_job_template( + template={ + "specificationVersion": "jobtemplate-2023-09", + "extensions": ["EXPR"], + "name": "queue-env-job", + "steps": [ + { + "name": "render", + "script": {"actions": {"onRun": {"command": "echo", "args": ["hi"]}}}, + } + ], + }, + supported_extensions=["EXPR"], + ) + env_template = decode_environment_template( + template=dict( + specificationVersion="environment-2023-09", + environment=minimal_environment_2023_09, + parameterDefinitions=[{"name": "Bar", "type": "STRING", "default": "fromQueueEnv"}], + ) + ) + parameter_values = preprocess_job_parameters( + job_template=job_template, + job_parameter_values={}, + job_template_dir=Path(), + current_working_dir=Path(), + allow_job_template_dir_walk_up=True, + environment_templates=[env_template], + ) + + # WHEN + result = create_job_with_symbol_tables( + job_template=job_template, + job_parameter_values=parameter_values, + environment_templates=[env_template], + ) + + # THEN — the queue environment's parameter is in job scope, and inherited + # by step scope + assert self._entries(result.job_symbol_table)["Param.Bar"] == ("string", "fromQueueEnv") + assert self._entries(result.step_symbol_tables["render"])["Param.Bar"] == ( + "string", + "fromQueueEnv", + )