Skip to content

Commit d1512e3

Browse files
committed
fix: resolve recommendations #1-#4 from model-bindings quality report
Addresses the first four recommendations in `reports/model-bindings-quality-evaluation-report.md`. Each item is struck through in the report with a `**Resolved**` note. Empty-steps decode failures surface under `ModelValidationError` rather than v0's `DecodeValidationError`. The v1 binding splits decode-stage (schema-level) failures from model-stage (structural-invariant) failures, with the empty-steps check living in the model validator — this is a deliberate v0 divergence, not a bug. * spec's 'Exceptions' section updated: corrected empty-steps example, divergence note explaining the split and recommended catch-tuple * strengthened the existing regression test in test_parse.py to assert the full message body per the AGENTS.md test quality standard The Python str-Enum shim for `TemplateSpecificationVersion` was already removed in a prior change; the Rust pyclass at `openjd._openjd_rs.TemplateSpecificationVersion` is the single canonical class, re-exported identity-preserving from `openjd.model._v1`. The regression test had become passing (no `xfail` marker) but was still parked in `test_known_gaps.py`. * test moved to test_version_enums.py::TestTemplateSpecificationVersion ::test_template_specification_version_returned_from_decode with strengthened identity / value / string-equality assertions * spec's 'Pickle Support' section: stale 'str-Enum shim' paragraph corrected — pickle goes directly through the module-level `_reconstruct_enum` helper; pickle table now lists both spec-revision enums explicitly The upstream `MergedParameterDefinition::default` is `Option<String>` — every variant is stringified through `default_value()`. The binding contract per the spec is that callers receive the default in its native Python type (`int` for INT, `float` for FLOAT, `list[T]` for LIST[T], etc.). * new `default_to_native` helper in create_job_fns.rs dispatches on `JobParameterType` and parses the stringified default back via `str::parse` / `serde_json::from_str`; parsing failures fall back to the raw string as a defensive guard * pre-existing parametrized expectations in test_merge_job_parameters.py::TestMergeTemplates_v2023_09 updated to native form (`'default': 8` vs `'default': '8'`) * two int/float xfail tests moved out of test_known_gaps.py and expanded into a full TestMergeDefaultNativeTypes class covering all 10 type variants with type-identity assertions The upstream `MergedParameterDefinition` struct does not surface a description field — only `name` / `param_type` / `default` / `object_type` / `data_flow` / `source` / merged constraints. v0 carried it on the typed pyclass merged result, so v1 was losing per-parameter human-readable text that downstream tooling (deadline-cli's parameter-prompt UI) relies on for parameter labels. * py_merge_job_parameter_definitions now walks env templates in order then the job template, building a name→description `HashMap`. Later descriptions overwrite earlier ones, matching how the upstream merge tracks `default` * the merged dict carries the `description` key only when at least one contributing template provided one (consistent with how `default` / `objectType` / `dataFlow` are conditionally emitted) * spec's 'Return shape' key list now lists `description` with its last-wins ordering semantics * xfail test moved out of test_known_gaps.py and expanded into a 5-case TestMergeDescriptionPropagation class Also refreshes `THIRD-PARTY-LICENSES.txt` for a Python-side dep bump (`typing_extensions` 4.15.0 → 4.16.0) so the `third_party_licenses` CI job stays green. Regenerated via `bash scripts/check_third_party_licenses.sh --update`. * python -m pytest test/openjd/model_v0 test/openjd/model_v1 → 3269 passed (+5 from the new tests), 0 xfails * python -m pytest test/ → 5131 passed, 24 skipped, 3 unrelated `expr` xfails * cargo fmt --check → clean * cargo clippy --all-targets -- -D warnings → clean * cargo-deny check licenses bans sources → ok * THIRD-PARTY-LICENSES.txt sync check → up to date * No public binding signatures changed; _openjd_rs.pyi unchanged Signed-off-by: Mark <399551+mwiebe@users.noreply.github.com>
1 parent 455c5af commit d1512e3

8 files changed

Lines changed: 512 additions & 170 deletions

File tree

THIRD-PARTY-LICENSES.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
119119
SOFTWARE.
120120

121121
------
122-
** typing_extensions; version 4.15.0 -- https://pypi.org/project/typing_extensions/
122+
** typing_extensions; version 4.16.0 -- https://pypi.org/project/typing_extensions/
123123
A. HISTORY OF THE SOFTWARE
124124
==========================
125125

reports/model-bindings-quality-evaluation-report.md

Lines changed: 80 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -450,16 +450,28 @@ established v0 idiom or a spec-promised behavior). Numbers are stable
450450
so future fix commits can resolve them with the
451451
`~~ ... ~~ **Resolved.**` strikethrough convention.
452452

453-
1. **Empty-steps validation should raise `DecodeValidationError`, not
453+
1. ~~**Empty-steps validation should raise `DecodeValidationError`, not
454454
`ModelValidationError`** — for v0 parity. Either remap the
455455
empty-steps case in
456456
`rust-bindings/src/model/errors.rs::model_err_to_py` (or in the
457457
upstream Rust validator) so it surfaces under
458458
`PyDecodeValidationError`, or update the spec to call out the
459459
exception-class change explicitly. Pinned in
460-
`test/openjd/model_v1/test_known_gaps.py::test_empty_steps_raises_decode_validation_error_like_v0`.
461-
462-
2. **`JobTemplate.specification_version` should return a value
460+
`test/openjd/model_v1/test_known_gaps.py::test_empty_steps_raises_decode_validation_error_like_v0`.~~
461+
**Resolved** — documented in `specs/python-model-interface.md`
462+
under "Exceptions" as a deliberate v0 divergence:
463+
`DecodeValidationError` is reserved for schema-level (parse-stage)
464+
failures the binding can detect before constructing the typed
465+
model, and `ModelValidationError` is raised for everything caught
466+
by the model validator (the at-least-one-step rule, parameter-
467+
definition invariants, step-name uniqueness, etc.). Both classes
468+
inherit `ValueError`, so callers catching `ValueError` are
469+
unaffected; callers that distinguish should catch the tuple.
470+
Regression test (with full message-body assertion per AGENTS.md
471+
test quality standard) lives at
472+
`test/openjd/model_v1/test_parse.py::TestDecodeJobTemplate::test_empty_steps_raises_model_validation_error`.
473+
474+
2. ~~**`JobTemplate.specification_version` should return a value
463475
comparable to `v1.TemplateSpecificationVersion`** — today it
464476
returns the Rust pyo3 enum
465477
(`openjd._openjd_rs.TemplateSpecificationVersion`, variants
@@ -472,9 +484,26 @@ so future fix commits can resolve them with the
472484
shim); (b) drop the Python str-Enum shim and re-export the Rust
473485
pyclass under `v1.TemplateSpecificationVersion` (requires renaming
474486
variants for v0 parity — `JOBTEMPLATE_v2023_09` style). Pinned in
475-
`test/openjd/model_v1/test_known_gaps.py::test_template_specification_version_comparable_to_python_str_enum`.
476-
477-
3. **`merge_job_parameter_definitions` should emit `default` as the
487+
`test/openjd/model_v1/test_known_gaps.py::test_template_specification_version_comparable_to_python_str_enum`.~~
488+
**Resolved** — chose path (b). The Rust pyclass
489+
`PyTemplateSpecificationVersion` in
490+
`rust-bindings/src/model/types.rs` was reshaped to expose the v0
491+
`str`-Enum surface directly: `#[pyo3(name = "JOBTEMPLATE_v2023_09")]`
492+
/ `ENVIRONMENT_v2023_09` variant names, a `.value` getter that
493+
returns the spec-form string, a `__new__` that accepts either form,
494+
`__eq__` / `__hash__` that compare equal (and hash equal) to the
495+
spec-form string, and `module = "openjd._openjd_rs"` so its
496+
canonical Python identity lives in the Rust module. The Python
497+
wrapper `_v1/__init__.py` now imports `TemplateSpecificationVersion`
498+
directly from `openjd._openjd_rs` (no shim). There is exactly one
499+
class, identity-preserving across re-exports.
500+
The same treatment was applied to `SpecificationRevision`.
501+
The stale "str-Enum shim" paragraph in the spec's "Pickle Support"
502+
section was also corrected to match. Regression test moved from
503+
`test_known_gaps.py` to
504+
`test/openjd/model_v1/test_version_enums.py::TestTemplateSpecificationVersion::test_template_specification_version_returned_from_decode`.
505+
506+
3. ~~**`merge_job_parameter_definitions` should emit `default` as the
478507
parameter's native Python type, not as a string**`int` for
479508
`INT`, `float` for `FLOAT`, `bool` for `BOOL`, `list[T]` for the
480509
`LIST[*]` variants, and `str` for `STRING` / `PATH` /
@@ -484,9 +513,30 @@ so future fix commits can resolve them with the
484513
`param_type` dispatch that converts the underlying typed value into
485514
the right Python type. Pinned in
486515
`test/openjd/model_v1/test_known_gaps.py::test_merge_default_int_returned_as_int`
487-
and `test_merge_default_float_returned_as_float`.
488-
489-
4. **`merge_job_parameter_definitions` should include the `description`
516+
and `test_merge_default_float_returned_as_float`.~~
517+
**Resolved** — added a `default_to_native` helper in
518+
`rust-bindings/src/model/create_job_fns.rs` that dispatches on
519+
`JobParameterType` and parses the upstream-stringified default
520+
back into the native Python type. The upstream
521+
`MergedParameterDefinition::default` is `Option<String>` (every
522+
variant is round-tripped through `default_value()`'s stringifier),
523+
so the binding now parses it back: `i64` for `INT`, `f64` for
524+
`FLOAT`, `bool` for `BOOL`, `serde_json::from_str::<Vec<T>>` for
525+
the `LIST[*]` variants, pass-through for `STRING` / `PATH` /
526+
`RANGE_EXPR`. Parsing failures fall back to the raw string as a
527+
defensive guard against future upstream serialization changes.
528+
Existing parametrized tests in
529+
`test/openjd/model_v1/test_merge_job_parameters.py::TestMergeTemplates_v2023_09`
530+
updated to reflect native-type defaults (`"default": 8` instead of
531+
`"default": "8"`, etc.). The two int/float gap tests were moved
532+
out of `test_known_gaps.py` and expanded into a full per-type
533+
coverage class `TestMergeDefaultNativeTypes` covering all 12 type
534+
variants (INT, FLOAT, STRING, PATH, BOOL, LIST_INT, LIST_FLOAT,
535+
LIST_STRING, LIST_BOOL, LIST_LIST_INT). Each assertion pins type
536+
identity (`isinstance(..., int)`) as well as equality, since
537+
`5 == 5.0` would otherwise let a regression slip through.
538+
539+
4. ~~**`merge_job_parameter_definitions` should include the `description`
490540
field on each merged dict** — v0's typed defs carried it, and the
491541
upstream `openjd_model::merge_job_parameter_definitions` produces
492542
it on each input definition. The fix at the binding layer is to
@@ -495,7 +545,26 @@ so future fix commits can resolve them with the
495545
template or the relevant environment template, and add it to the
496546
merged dict when present. Update the spec's "Return shape" key
497547
list to include `description`. Pinned in
498-
`test/openjd/model_v1/test_known_gaps.py::test_merge_includes_description`.
548+
`test/openjd/model_v1/test_known_gaps.py::test_merge_includes_description`.~~
549+
**Resolved** — chose path (a). The binding's
550+
`py_merge_job_parameter_definitions` in
551+
`rust-bindings/src/model/create_job_fns.rs` now builds a
552+
`HashMap<&str, &str>` from `name → description` by walking the
553+
same template sources the upstream merge walked (environment
554+
templates in order, then the job template), with later
555+
descriptions overwriting earlier ones — matching how the upstream
556+
merge tracks `default`. The merged dict carries the `description`
557+
key when at least one contributing template provided one, and
558+
omits the key otherwise (consistent with how `default` /
559+
`objectType` / `dataFlow` are conditionally emitted). The spec's
560+
"Return shape" key list now lists `description` with its
561+
semantics. Test moved out of `test_known_gaps.py` and expanded
562+
into a full `TestMergeDescriptionPropagation` class covering five
563+
cases: description from job template, description from
564+
environment template, job-template-wins ordering, later-env-wins
565+
ordering, and key-absent-when-no-description. `test_known_gaps.py`
566+
is now empty of merge gaps (only the typing-imports leak
567+
regression test remains).
499568

500569
5. **Internal typing imports in `openjd.model._v1.__init__.py` should
501570
be prefixed with `_`** — `from typing import Any, Optional,

rust-bindings/src/model/create_job_fns.rs

Lines changed: 105 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ use pyo3_stub_gen::derive::*;
88

99
use openjd_model::template::EnvironmentTemplate;
1010
use openjd_model::types::JobParameterInputValues;
11+
use openjd_model::JobParameterType;
1112
use openjd_model::PathParameterOptions;
1213

1314
use crate::expr::expr_value::py_to_expr_value;
@@ -188,13 +189,41 @@ pub(crate) fn py_merge_job_parameter_definitions(
188189
let merged = openjd_model::merge_job_parameter_definitions(&job_template.inner, &env_templates)
189190
.map_err(model_err_to_py)?;
190191

192+
// Collect descriptions by parameter name. The upstream
193+
// `MergedParameterDefinition` struct does not carry a description
194+
// field (only `name`, `param_type`, `default`, `object_type`,
195+
// `data_flow`, `source`, and the merged constraint fields), so we
196+
// recover it here by walking the same template sources the merge
197+
// walked. To match how `default` is tracked (later template wins),
198+
// we walk environment templates first (in order) then the job
199+
// template last, overwriting on each set description we encounter.
200+
let mut descriptions: std::collections::HashMap<&str, &str> = std::collections::HashMap::new();
201+
for et in &env_templates {
202+
if let Some(params) = &et.parameter_definitions {
203+
for p in params {
204+
if let Some(desc) = p.description() {
205+
descriptions.insert(p.name(), desc);
206+
}
207+
}
208+
}
209+
}
210+
for p in job_template.inner.parameter_definitions_list() {
211+
if let Some(desc) = p.description() {
212+
descriptions.insert(p.name(), desc);
213+
}
214+
}
215+
191216
let mut out = Vec::new();
192217
for m in &merged {
193218
let d = PyDict::new(py);
194219
d.set_item("name", &m.name)?;
195220
d.set_item("type", m.param_type.as_spec_str())?;
196221
if let Some(ref default) = m.default {
197-
d.set_item("default", default)?;
222+
let py_default = default_to_native(py, m.param_type, default)?;
223+
d.set_item("default", py_default)?;
224+
}
225+
if let Some(desc) = descriptions.get(m.name.as_str()) {
226+
d.set_item("description", *desc)?;
198227
}
199228
if let Some(ref ot) = m.object_type {
200229
d.set_item("objectType", ot.to_string())?;
@@ -208,6 +237,81 @@ pub(crate) fn py_merge_job_parameter_definitions(
208237
Ok(out)
209238
}
210239

240+
/// Convert a stringified default value back into the native Python
241+
/// type for its parameter type.
242+
///
243+
/// The upstream Rust crate's `MergedParameterDefinition::default` is
244+
/// `Option<String>` — every variant is stringified through
245+
/// `JobParameterDefinition::default_value()` regardless of the
246+
/// underlying typed payload. The binding contract (per
247+
/// `specs/python-model-interface.md`) is that callers receive the
248+
/// default in its native Python type (`int` for `INT`, `float` for
249+
/// `FLOAT`, `list[T]` for `LIST[T]`, etc.). This helper parses the
250+
/// stringified form back into the right Python type.
251+
///
252+
/// `STRING` / `PATH` / `RANGE_EXPR` defaults pass through unchanged
253+
/// (they are already strings). `LIST[*]` defaults are JSON-serialised
254+
/// by the upstream `default_value()` implementation, so they round-trip
255+
/// through `serde_json`.
256+
///
257+
/// If parsing fails for any reason (defensive fallback against future
258+
/// upstream serialization changes), the original string is returned
259+
/// so the caller never sees a `None` where v0 would have produced a
260+
/// default.
261+
fn default_to_native(
262+
py: Python<'_>,
263+
param_type: JobParameterType,
264+
raw: &str,
265+
) -> PyResult<Py<pyo3::PyAny>> {
266+
use pyo3::IntoPyObjectExt;
267+
match param_type {
268+
JobParameterType::String | JobParameterType::Path | JobParameterType::RangeExpr => {
269+
raw.into_py_any(py)
270+
}
271+
JobParameterType::Int => raw
272+
.parse::<i64>()
273+
.ok()
274+
.map(|v| v.into_py_any(py))
275+
.unwrap_or_else(|| raw.into_py_any(py)),
276+
JobParameterType::Float => raw
277+
.parse::<f64>()
278+
.ok()
279+
.map(|v| v.into_py_any(py))
280+
.unwrap_or_else(|| raw.into_py_any(py)),
281+
JobParameterType::Bool => match raw {
282+
"true" => true.into_py_any(py),
283+
"false" => false.into_py_any(py),
284+
_ => raw.into_py_any(py),
285+
},
286+
JobParameterType::ListString | JobParameterType::ListPath => {
287+
serde_json::from_str::<Vec<String>>(raw)
288+
.ok()
289+
.map(|v| v.into_py_any(py))
290+
.unwrap_or_else(|| raw.into_py_any(py))
291+
}
292+
JobParameterType::ListInt => serde_json::from_str::<Vec<i64>>(raw)
293+
.ok()
294+
.map(|v| v.into_py_any(py))
295+
.unwrap_or_else(|| raw.into_py_any(py)),
296+
JobParameterType::ListFloat => serde_json::from_str::<Vec<f64>>(raw)
297+
.ok()
298+
.map(|v| v.into_py_any(py))
299+
.unwrap_or_else(|| raw.into_py_any(py)),
300+
JobParameterType::ListBool => serde_json::from_str::<Vec<bool>>(raw)
301+
.ok()
302+
.map(|v| v.into_py_any(py))
303+
.unwrap_or_else(|| raw.into_py_any(py)),
304+
JobParameterType::ListListInt => serde_json::from_str::<Vec<Vec<i64>>>(raw)
305+
.ok()
306+
.map(|v| v.into_py_any(py))
307+
.unwrap_or_else(|| raw.into_py_any(py)),
308+
// ``JobParameterType`` is ``#[non_exhaustive]`` — any future
309+
// variant we don't know about falls back to the raw string so
310+
// callers see *some* value rather than nothing.
311+
_ => raw.into_py_any(py),
312+
}
313+
}
314+
211315
#[cfg_attr(
212316
feature = "stub-gen",
213317
gen_stub_pyfunction(module = "openjd._openjd_rs")

specs/python-model-interface.md

Lines changed: 52 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,13 @@ following keys:
324324
default) — the default value, in its native Python type
325325
(``int`` / ``float`` / ``str`` / ``list`` / …) per the
326326
parameter's type.
327+
* ``description`` (``str``, present only if at least one
328+
contributing template provided one) — the human-readable
329+
description copied from the originating template. When more
330+
than one contributing template defines a description for the
331+
same parameter, the description from the template walked last
332+
wins (environment templates are walked in order, then the job
333+
template), mirroring how ``default`` is tracked.
327334
* ``objectType`` (``str``, present only for ``PATH`` parameters
328335
with an ``objectType`` declared) — ``"FILE"`` or
329336
``"DIRECTORY"``.
@@ -342,7 +349,9 @@ values" pass. The underlying Rust crate's
342349
``openjd_model::merge_job_parameter_definitions`` returns a
343350
struct with ``source`` / ``name`` / ``param_type`` / ``default`` /
344351
``object_type`` / ``data_flow`` fields; the binding flattens
345-
that struct into the dict above.
352+
that struct into the dict above and additionally recovers
353+
``description`` by walking the same template sources the merge
354+
walked (the upstream struct does not carry a description field).
346355

347356
#### `evaluate_let_bindings`
348357

@@ -1386,23 +1395,31 @@ Practical consequences:
13861395

13871396
```python
13881397
from openjd.model._v1 import decode_job_template
1389-
from openjd.model._v1.errors import DecodeValidationError
1398+
from openjd.model._v1.errors import DecodeValidationError, ModelValidationError
13901399

1391-
# Invalid template
1400+
# Invalid template — schema-level failure (missing required top-level
1401+
# key) raises DecodeValidationError. These are the failures the
1402+
# binding can detect before the dict is reshaped into the typed
1403+
# model, so they don't need to run through the model validator.
13921404
try:
13931405
decode_job_template(template={"bad": "template"})
13941406
except DecodeValidationError as e:
13951407
str(e) # "Template is missing Open Job Description schema version key: specificationVersion"
13961408

1397-
# Empty steps
1409+
# Empty steps — model-level structural failure raises
1410+
# ModelValidationError. The shape parses successfully but the
1411+
# decoded JobTemplate fails the "at least one step" invariant when
1412+
# the model validator runs, so the failure surfaces under
1413+
# ModelValidationError rather than DecodeValidationError. See the
1414+
# divergence note below.
13981415
try:
13991416
decode_job_template(template={
14001417
"specificationVersion": "jobtemplate-2023-09",
14011418
"name": "Test",
14021419
"steps": [],
14031420
})
1404-
except DecodeValidationError as e:
1405-
str(e) # validation error about empty steps
1421+
except ModelValidationError as e:
1422+
str(e) # "1 validation error for JobTemplate\nJobTemplate: must have at least one step."
14061423
```
14071424

14081425
| Exception | Base |
@@ -1412,6 +1429,28 @@ except DecodeValidationError as e:
14121429
| `UnsupportedSchema` | `ValueError` |
14131430
| `ExpressionError` | `ValueError` |
14141431

1432+
**`DecodeValidationError` vs `ModelValidationError` divergence from v0.**
1433+
The v0 (Pydantic) reference raised ``DecodeValidationError`` for *every*
1434+
template-decode failure — including model-level structural invariants
1435+
like "must have at least one step" — because pydantic's discriminated-
1436+
union dispatch ran the field validators inside the decode call. The v1
1437+
binding splits the two phases: ``DecodeValidationError`` is raised
1438+
strictly for schema-level failures the binding can detect before
1439+
constructing the typed model (missing/unknown ``specificationVersion``,
1440+
unknown fields under strict mode, unparseable JSON/YAML, etc.), and
1441+
``ModelValidationError`` is raised for everything caught by the model
1442+
validator that runs once the decoded shape is in hand (the at-least-one-
1443+
step rule, parameter-definition invariants, step-name uniqueness, etc.).
1444+
Both classes inherit ``ValueError``, so callers that catch ``ValueError``
1445+
are unaffected — only callers that distinguish between the two classes
1446+
need to be aware of the split. The v1 binding **deliberately** does not
1447+
remap empty-steps to ``DecodeValidationError`` for v0 byte-parity: the
1448+
v1 model validator is the right home for the check (it surfaces with the
1449+
same path-prefixed message shape as every other model-level invariant),
1450+
and callers wanting to catch every decode-time failure should catch
1451+
``ValueError`` or both classes by tuple (``except (DecodeValidationError,
1452+
ModelValidationError)``).
1453+
14151454
**`UnsupportedSchema` constructor divergence from v0.** The v0
14161455
reference defines ``UnsupportedSchema(version_str)`` such that
14171456
``str(e) == "Unsupported schema version: {version_str}"`` and the
@@ -1472,12 +1511,15 @@ original.
14721511
| ``PathTaskParameter`` | constructor argument (``range``) |
14731512
| ``ChunkIntTaskParameter`` | constructor arguments (``range``, ``chunks``) |
14741513
| ``TaskChunksDefinition`` | constructor arguments (three fields) |
1514+
| ``SpecificationRevision`` | variant name (``v2023_09``) |
1515+
| ``TemplateSpecificationVersion`` | variant name (``JOBTEMPLATE_v2023_09``, ``ENVIRONMENT_v2023_09``) |
14751516
| ``DecodeValidationError``, ``ModelValidationError``, ``UnsupportedSchema`` | standard exception pickle, under their canonical ``openjd.model._v1`` module path |
14761517

1477-
``SpecificationRevision`` and ``TemplateSpecificationVersion`` pickle
1478-
through the Python ``str``-Enum shims provided by ``openjd.model._v1``
1479-
(the underlying Rust pyclasses live at ``openjd._openjd_rs`` and pickle
1480-
correctly there too).
1518+
``SpecificationRevision`` and ``TemplateSpecificationVersion`` are Rust
1519+
pyclass enums whose canonical home is ``openjd._openjd_rs``; they are
1520+
re-exported from ``openjd.model._v1`` (identity-preserving — there is
1521+
exactly one class in each case). Pickled instances round-trip through
1522+
the variant name via the module-level ``_reconstruct_enum`` helper.
14811523

14821524
The decoded model containers (``JobTemplate``, ``EnvironmentTemplate``,
14831525
``Job``, ``Step``, etc.) and the live ``StepParameterSpaceIterator`` /

0 commit comments

Comments
 (0)