Skip to content

Commit 8eae8ae

Browse files
authored
feat: Implement WRAP_ACTIONS with EXPR binding to RS (#285)
* feat: Implement WRAP_ACTIONS with EXPR binding to RS Signed-off-by: David Leong <116610336+leongdl@users.noreply.github.com> * feat: Pass conformance test Signed-off-by: David Leong <116610336+leongdl@users.noreply.github.com> * feat: add build_symbol_table binding for typed EXPR symbol tables Add a build_symbol_table #[pyfunction] that constructs a typed openjd-expr SymbolTable from a flat dotted-key value map plus an optional per-key EXPR type-spec map. String values are coerced to their declared type via ExprValue::from_str_coerce and dotted keys are nested into subtables. This moves the typed symbol-table construction next to the engine (previously a string-coercion + nesting dance in the Python _expr_support bridge), keeping value typing consistent with evaluation and giving correct native-aggregate inference. The OpenJD-type -> EXPR-type-spec mapping stays on the Python side and is passed in as resolved spec strings. Signed-off-by: David Leong <116610336+leongdl@users.noreply.github.com> * feat: expr and wrap_actions pr comments Signed-off-by: David Leong <116610336+leongdl@users.noreply.github.com> * feat: v0 parity for RFC 0007 typed params and RFC 0008 wrap-action rules Close the remaining pure-Python v0 vs Rust gaps for the EXPR / WRAP_ACTIONS extensions in the default model path: - RFC 0007: v0 create_job can now instantiate jobs that declare BOOL, RANGE_EXPR, and LIST[*] parameters. Their values are carried natively (lists/bools) through preprocessing into the JobParameter, and the typed EXPR symbol table (build_symbol_table) coerces them, rather than the string-only handling used by the original scalar types. ParameterValueType, ParameterValue.value, and JobParameter.value are extended accordingly, and the parameter merge skips the legacy scalar-only constraint merging (and the EXPR-gated re-parse) for the new types. - RFC 0008 single-wrap-layer: JobTemplate now rejects more than one wrap-defining environment reachable in any session stack (jobEnvironments plus one step's stepEnvironments), mirroring openjd-rs wrap_actions.rs. - RFC 0008 wrapped-variable scoping: EnvironmentActions now rejects WrappedAction.* outside the wrap hooks, WrappedEnv.* outside the env enter/exit hooks, and WrappedStep.* outside the task-run hook. Runtime action-wrapping (RFC 0008 execution) lives in openjd-sessions-for-python and is tracked as a separate change. Signed-off-by: David Leong <116610336+leongdl@users.noreply.github.com> * feat: reuse Rust via bindings to simplify the EXPR model glue Replace hand-maintained Python logic in the v0 model's EXPR glue with thin calls into the Rust bindings, so the behaviour is single-sourced in the openjd crates: - job_parameter_type_expr_spec: maps an OpenJD job-parameter type name (case-insensitive) to its EXPR type spec by reusing the crate's JobParameterType::from_spec_str + expr_type(). Removes the hand-maintained _OPENJD_TYPE_TO_EXPR_TYPE table and the recursive LIST[...] unwrap from _expr_support.py; the OpenJD-type -> EXPR-type contract (incl. nested LIST[LIST[INT]]) now lives only in the openjd-model crate. RANGE_EXPR now maps to the engine's range_expr type. - ParsedExpression.typecheck: type-checks an expression against typed (unresolved) symbol placeholders without extracting a result. Lets validate_typed_expression drop its brittle error-message sniffing ("unresolved" / "Cannot extract value") — a well-typed but runtime-dependent expression now simply type-checks Ok in Rust instead of surfacing a boundary error the Python had to recognise by string. Signed-off-by: David Leong <116610336+leongdl@users.noreply.github.com> * feat: scope-check timeout format strings and cover RANGE_EXPR typed validation RFC 0008 wrapped-variable scoping: _action_referenced_namespaces now also inspects an Action's timeout field, which is a FormatString under the FEATURE_BUNDLE_1 extension. Previously only command/args were scanned, so a WrappedAction.*/WrappedEnv.*/WrappedStep.* reference smuggled into a timeout expression bypassed the per-hook scope rule. Non-FormatString timeouts (plain ints) are skipped by the existing isinstance guard. RFC 0007 RANGE_EXPR: add tests pinning the typed-validation behaviour now that the OpenJD-type -> EXPR-type mapping resolves RANGE_EXPR to the engine's range_expr type (previously None, which fell back to name-only validation). Valid range_expr ops (subscript, len, list) type-check; genuine type errors (arithmetic, invalid methods) are now caught at decode time. Covered at the ExprNode seam and end-to-end through decode_job_template. Signed-off-by: David Leong <116610336+leongdl@users.noreply.github.com> * feat: harden EXPR/WRAP_ACTIONS v0 model — spec string coercion, value constraints, lazy import Address six review findings on the EXPR (RFC 0005-0007) / WRAP_ACTIONS (RFC 0008) support in the pure-Python (v0) model: - EXPR results interpolated into format strings now use the engine's RFC 0005 string coercion (true/false, double-quoted list items, preserved Decimal trailing zeros; null -> empty string) instead of Python str() of the native value, which emitted Python reprs (True/None/['a', 'b']). Adds evaluate_to_str() to the node/expression seam; evaluate() still returns the native value. - create_job/preprocess now enforce item/length/range constraints on user-supplied LIST[*] and RANGE_EXPR values (previously only the template default was validated at decode time). - Merged EXPR defaults are re-validated against the surviving definition's constraints before model_copy (which skips validators), so a default carried over from another source can no longer bypass them. - SymbolTable.expr_types is now a first-class field copied by __init__ and union(), instead of a dynamic attribute that derived/unioned tables dropped. - The EXPR_EXTENSION gate constant moved to _parser so importing the parser no longer eagerly loads the Rust expr surface on the non-EXPR path. - Collapsed the eight near-identical JobList*/RangeExpr definitions onto a shared base, and single-sourced the triplicated task-parameter range gate. Adds 27 regression unit tests covering each fix. Signed-off-by: David Leong <116610336+leongdl@users.noreply.github.com> * test: cover create_job instantiation of WRAP_ACTIONS templates Signed-off-by: David Leong <116610336+leongdl@users.noreply.github.com> --------- Signed-off-by: David Leong <116610336+leongdl@users.noreply.github.com>
1 parent 8f1f9f1 commit 8eae8ae

30 files changed

Lines changed: 3206 additions & 94 deletions

rust-bindings/src/expr/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,5 +26,5 @@ pub(crate) use path_mapping::PyPathMappingRule;
2626
pub(crate) use profile::{PyExprExtension, PyExprProfile, PyExprRevision, PyHostContext};
2727
pub(crate) use range_expr::{PyIntRange, PyRangeExpr};
2828
pub(crate) use symbol_table::{
29-
_reconstruct_serialized_symtab, PySerializedSymbolTable, PySymbolTable,
29+
_reconstruct_serialized_symtab, build_symbol_table, PySerializedSymbolTable, PySymbolTable,
3030
};

rust-bindings/src/expr/parsed_expression.rs

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,37 @@ impl PyParsedExpression {
9292
Ok(PyExprValue { inner: value })
9393
}
9494

95+
/// Type-check the expression against a symbol table of (typically
96+
/// unresolved) typed placeholders, without extracting a concrete value.
97+
///
98+
/// Succeeds when the expression is well-typed for the given symbol types —
99+
/// including when the result is an unresolved value that merely depends on
100+
/// a runtime symbol — and raises only on a genuine type/evaluation error.
101+
/// Unlike :meth:`evaluate`, it discards the result, so it never raises the
102+
/// "cannot extract value from unresolved" boundary error; callers no longer
103+
/// need to sniff the error message to tell a real type error from a
104+
/// runtime-dependent one.
105+
#[pyo3(signature = (*, values=None, profile=None))]
106+
fn typecheck(
107+
&self,
108+
values: Option<&Bound<'_, pyo3::PyAny>>,
109+
profile: Option<&PyExprProfile>,
110+
) -> PyResult<()> {
111+
let symtab;
112+
let symtab_refs: Vec<&SymbolTable> = if let Some(v) = values {
113+
symtab = extract_symtab(v)?;
114+
vec![&symtab]
115+
} else {
116+
vec![]
117+
};
118+
let lib = profile_for_call(profile);
119+
self.inner
120+
.with_library(&lib)
121+
.evaluate(&symtab_refs)
122+
.map_err(expr_err_to_py)?;
123+
Ok(())
124+
}
125+
95126
/// Evaluate the expression and return an :class:`EvalResult` with the
96127
/// resulting value alongside the per-call resource-usage metrics
97128
/// (``peak_memory`` in bytes, ``operation_count``).

rust-bindings/src/expr/symbol_table.rs

Lines changed: 81 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,17 @@
22
// SPDX-License-Identifier: Apache-2.0
33

44
use pyo3::prelude::*;
5-
use pyo3::types::PyDict;
5+
use pyo3::types::{PyDict, PyString};
66
#[cfg(feature = "stub-gen")]
77
use pyo3_stub_gen::derive::*;
88

9+
use openjd_expr::path_mapping::PathFormat;
910
use openjd_expr::symbol_table::SymbolTable;
11+
use openjd_expr::types::ExprType;
12+
use openjd_expr::value::ExprValue;
1013

1114
use crate::expr::expr_value::{py_to_expr_value, PyExprValue};
15+
use crate::expr::path_format::PyPathFormat;
1216

1317
#[cfg_attr(feature = "stub-gen", gen_stub_pyclass(module = "openjd._openjd_rs"))]
1418
#[pyclass(module = "openjd.expr", name = "SymbolTable", from_py_object)]
@@ -324,3 +328,79 @@ pub(crate) fn _reconstruct_serialized_symtab(json: &str) -> PyResult<PySerialize
324328
})?;
325329
Ok(PySerializedSymbolTable { inner })
326330
}
331+
332+
// ── Typed symbol-table builder ─────────────────────────────────────
333+
//
334+
// Bridges the pure-Python (v0) model's flat dotted-key symbol table into
335+
// a typed `SymbolTable` the engine can evaluate against. This replaces the
336+
// former Python `symtab_to_expr_values`/`_to_expr_value` coercion so the
337+
// string→typed-value coercion lives next to the engine (PR #285 review,
338+
// C3/C5). The OpenJD-type → EXPR-type-spec mapping stays in Python; this
339+
// function takes the resolved EXPR type spec strings (e.g. "int",
340+
// "list[int]", "path") so the engine owns only the coercion + nesting.
341+
342+
/// Coerce a single Python value to an `ExprValue`, optionally toward a known
343+
/// target `ExprType`. String values are coerced via `from_str_coerce` (so a
344+
/// stored ``"10"`` of type INT becomes a real integer); other native values
345+
/// are built then coerced. With no target the value's native type is
346+
/// inferred. Mirrors the former Python ``_expr_support._to_expr_value``.
347+
fn coerce_symbol_value(
348+
value: &Bound<'_, pyo3::PyAny>,
349+
target: Option<&ExprType>,
350+
pf: PathFormat,
351+
) -> PyResult<ExprValue> {
352+
let Some(target) = target else {
353+
// No confident type — let the engine infer from the native value.
354+
return py_to_expr_value(value);
355+
};
356+
if let Ok(s) = value.cast::<PyString>() {
357+
return ExprValue::from_str_coerce(&s.to_cow()?, target, pf)
358+
.map_err(pyo3::exceptions::PyValueError::new_err);
359+
}
360+
py_to_expr_value(value)?
361+
.coerce(target, pf)
362+
.map_err(pyo3::exceptions::PyValueError::new_err)
363+
}
364+
365+
/// Build a typed :class:`SymbolTable` from a flat dotted-key value map and an
366+
/// optional per-key EXPR type-spec map, coercing string values to their
367+
/// declared type and nesting dotted keys (``"Param.Frame"``) into subtables.
368+
///
369+
/// ``values`` maps dotted symbol names to their (typically string) values, as
370+
/// the v0 model stores them. ``types`` maps the same dotted names to EXPR
371+
/// type spec strings (``"int"``, ``"list[int]"``, ``"path"``, …); names absent
372+
/// from ``types`` are inferred from the value. ``path_format`` controls how
373+
/// PATH-typed values are interpreted (defaults to the host OS).
374+
#[cfg_attr(
375+
feature = "stub-gen",
376+
gen_stub_pyfunction(module = "openjd._openjd_rs")
377+
)]
378+
#[pyfunction]
379+
#[pyo3(signature = (values, types=None, *, path_format=None))]
380+
pub(crate) fn build_symbol_table(
381+
values: &Bound<'_, PyDict>,
382+
types: Option<&Bound<'_, PyDict>>,
383+
path_format: Option<PyPathFormat>,
384+
) -> PyResult<PySymbolTable> {
385+
let pf = path_format
386+
.map(PathFormat::from)
387+
.unwrap_or_else(PathFormat::host);
388+
let mut st = SymbolTable::new();
389+
for (key, value) in values.iter() {
390+
let dotted: String = key.extract()?;
391+
let target: Option<ExprType> = match types {
392+
Some(t) => match t.get_item(dotted.as_str())? {
393+
Some(spec_obj) => {
394+
let spec: String = spec_obj.extract()?;
395+
Some(ExprType::parse(&spec).map_err(pyo3::exceptions::PyValueError::new_err)?)
396+
}
397+
None => None,
398+
},
399+
None => None,
400+
};
401+
let ev = coerce_symbol_value(&value, target.as_ref(), pf)?;
402+
st.set(&dotted, ev)
403+
.map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?;
404+
}
405+
Ok(PySymbolTable { inner: st })
406+
}

rust-bindings/src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ fn openjd_rs(m: &Bound<'_, PyModule>) -> PyResult<()> {
8181
m.add_function(wrap_pyfunction!(evaluate_expression, m)?)?;
8282
m.add_function(wrap_pyfunction!(parse_expression, m)?)?;
8383
m.add_function(wrap_pyfunction!(escape_format_string, m)?)?;
84+
m.add_function(wrap_pyfunction!(build_symbol_table, m)?)?;
8485
m.add_function(wrap_pyfunction!(_reconstruct_expr_value, m)?)?;
8586
m.add_function(wrap_pyfunction!(_reconstruct_serialized_symtab, m)?)?;
8687

@@ -231,6 +232,7 @@ fn openjd_rs(m: &Bound<'_, PyModule>) -> PyResult<()> {
231232
m.add_function(wrap_pyfunction!(validate_attribute_capability_name, m)?)?;
232233
m.add_function(wrap_pyfunction!(standard_amount_capability_names, m)?)?;
233234
m.add_function(wrap_pyfunction!(standard_attribute_capability_names, m)?)?;
235+
m.add_function(wrap_pyfunction!(job_parameter_type_expr_spec, m)?)?;
234236
m.add_function(wrap_pyfunction!(standard_attribute_capabilities, m)?)?;
235237

236238
register_renamed_exception(

rust-bindings/src/model/mod.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,8 +72,8 @@ pub(crate) use template_types::{
7272
PyStepScript as PyTemplateStepScript, PyStepTemplate,
7373
};
7474
pub(crate) use types::{
75-
PyDocumentType, PyJobParameterType, PyJobParameterValue, PyTaskParameterType,
76-
PyTaskParameterValue, PyTemplateSpecificationVersion,
75+
job_parameter_type_expr_spec, PyDocumentType, PyJobParameterType, PyJobParameterValue,
76+
PyTaskParameterType, PyTaskParameterValue, PyTemplateSpecificationVersion,
7777
};
7878
pub(crate) use user_interfaces::{
7979
PyBoolUserInterface, PyFileFilter, PyFloatUserInterface, PyHiddenOnlyUserInterface,

rust-bindings/src/model/types.rs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,22 @@ impl PyJobParameterType {
304304
}
305305
}
306306

307+
/// Map an OpenJD job-parameter type spec name (e.g. ``"INT"``, ``"LIST[INT]"``,
308+
/// ``"RANGE_EXPR"``; case-insensitive) to its EXPR type spec string
309+
/// (``"int"``, ``"list[int]"``, ``"range_expr"``), or ``None`` when the name is
310+
/// not a recognized job-parameter type. Single-sources both the
311+
/// (case-insensitive) type-name parsing and the OpenJD-type -> EXPR-type
312+
/// mapping in the Rust ``openjd-model`` crate so the Python model does not
313+
/// hand-maintain a parallel table.
314+
#[cfg_attr(
315+
feature = "stub-gen",
316+
gen_stub_pyfunction(module = "openjd._openjd_rs")
317+
)]
318+
#[pyfunction]
319+
pub(crate) fn job_parameter_type_expr_spec(type_name: &str) -> Option<String> {
320+
JobParameterType::from_spec_str(type_name.trim()).map(|t| t.expr_type().to_string())
321+
}
322+
307323
impl From<PyJobParameterType> for JobParameterType {
308324
fn from(v: PyJobParameterType) -> Self {
309325
match v {

src/openjd/_openjd_rs.pyi

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1780,6 +1780,12 @@ class ParsedExpression:
17801780
@property
17811781
def expr(self) -> builtins.str: ...
17821782
def __repr__(self) -> builtins.str: ...
1783+
def typecheck(
1784+
self,
1785+
*,
1786+
values: typing.Optional[typing.Any] = None,
1787+
profile: typing.Optional[ExprProfile] = None,
1788+
) -> None: ...
17831789
def evaluate(
17841790
self,
17851791
*,
@@ -3567,6 +3573,15 @@ def deserialize_step(step_dict: dict) -> Step:
35673573
"""
35683574

35693575
def escape_format_string(value: builtins.str) -> builtins.str: ...
3576+
def job_parameter_type_expr_spec(
3577+
type_name: builtins.str,
3578+
) -> typing.Optional[builtins.str]: ...
3579+
def build_symbol_table(
3580+
values: builtins.dict,
3581+
types: typing.Optional[builtins.dict] = None,
3582+
*,
3583+
path_format: typing.Optional[PathFormat] = None,
3584+
) -> SymbolTable: ...
35703585
def evaluate_expression(
35713586
expr: builtins.str,
35723587
*,

src/openjd/model/_create_job.py

Lines changed: 44 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,12 @@
2626

2727
__all__ = ("preprocess_job_parameters",)
2828

29+
# The original scalar job-parameter type names whose values are carried as
30+
# strings through preprocessing. EXPR-extension types (BOOL, RANGE_EXPR, and
31+
# the LIST[*] variants) are carried natively instead so the typed EXPR symbol
32+
# table can coerce them.
33+
_LEGACY_SCALAR_TYPE_NAMES = frozenset({"STRING", "INT", "FLOAT", "PATH"})
34+
2935

3036
# =======================================================================
3137
# ================ Preprocessing Job Parameters =========================
@@ -70,8 +76,18 @@ def _collect_defaults_2023_09(
7076
return_value: JobParameterValues = dict[str, ParameterValue]()
7177
# Collect defaults
7278
for param in job_parameter_definitions:
79+
is_legacy_scalar = param.type.name in _LEGACY_SCALAR_TYPE_NAMES
7380
if param.name not in job_parameter_values:
7481
if param.default is not None:
82+
if not is_legacy_scalar:
83+
# EXPR types (BOOL / RANGE_EXPR / LIST[*]): carry the native
84+
# default through so the typed symbol-table builder can
85+
# coerce it. The PATH-relative-default handling below only
86+
# applies to the scalar PATH type.
87+
return_value[param.name] = ParameterValue(
88+
type=ParameterValueType(param.type), value=param.default
89+
)
90+
continue
7591
default = str(param.default)
7692
# Make PATH defaults relative to job_template_dir, and
7793
# enforce the `allow_job_template_dir_walk_up` parameter request.
@@ -104,6 +120,12 @@ def _collect_defaults_2023_09(
104120
else:
105121
# Check the parameter against the constraints
106122
value = job_parameter_values[param.name]
123+
if not is_legacy_scalar:
124+
# EXPR types: carry the provided native value through.
125+
return_value[param.name] = ParameterValue(
126+
type=ParameterValueType(param.type), value=value
127+
)
128+
continue
107129
# Join any provided relative PATH parameter value with the current_working_directory (except the empty value "")
108130
if param.type.name == "PATH" and value != "" and not Path(value).is_absolute():
109131
value = str(current_working_dir / value)
@@ -123,8 +145,16 @@ def _check_2023_09(
123145
for param in job_parameter_definitions:
124146
if param.name in job_parameter_values:
125147
param_value = job_parameter_values[param.name]
148+
# The EXPR-extension LIST[*]/RANGE_EXPR definitions don't implement
149+
# _check_constraints (BOOL and the original scalars do). Their
150+
# template defaults are validated at decode time, and their values
151+
# are type-checked when coerced into the typed EXPR symbol table, so
152+
# skip the create-time constraint check when it isn't available.
153+
check_constraints = getattr(param, "_check_constraints", None)
154+
if check_constraints is None:
155+
continue
126156
try:
127-
param._check_constraints(param_value.value)
157+
check_constraints(param_value.value)
128158
except ValueError as err:
129159
errors.append(str(err))
130160

@@ -312,14 +342,21 @@ def create_job(
312342
if job_template.specificationVersion == TemplateSpecificationVersion.JOBTEMPLATE_v2023_09:
313343
from .v2023_09 import ValueReferenceConstants as ValueReferenceConstants_2023_09
314344

345+
# EXPR-extension typed params (BOOL / RANGE_EXPR / LIST[*]) carry native
346+
# values; record their OpenJD type so the typed symbol-table builder
347+
# coerces them to the right ExprType during expression evaluation. The
348+
# original scalar types keep their existing string-based handling.
349+
expr_types: dict[str, str] = {}
315350
for name, param in all_job_parameter_values.items():
351+
prefix = ValueReferenceConstants_2023_09.JOB_PARAMETER_PREFIX.value
352+
raw_prefix = ValueReferenceConstants_2023_09.JOB_PARAMETER_RAWPREFIX.value
316353
if param.type != "PATH":
317-
symtab[f"{ValueReferenceConstants_2023_09.JOB_PARAMETER_PREFIX.value}.{name}"] = (
318-
all_job_parameter_values[name].value
319-
)
320-
symtab[f"{ValueReferenceConstants_2023_09.JOB_PARAMETER_RAWPREFIX.value}.{name}"] = (
321-
all_job_parameter_values[name].value
322-
)
354+
symtab[f"{prefix}.{name}"] = all_job_parameter_values[name].value
355+
symtab[f"{raw_prefix}.{name}"] = all_job_parameter_values[name].value
356+
if param.type.name not in _LEGACY_SCALAR_TYPE_NAMES:
357+
expr_types[f"{prefix}.{name}"] = param.type.value
358+
expr_types[f"{raw_prefix}.{name}"] = param.type.value
359+
symtab.expr_types.update(expr_types)
323360
else:
324361
raise NotImplementedError(
325362
f"Spec version {job_template.specificationVersion} not implemented."

0 commit comments

Comments
 (0)