diff --git a/rust-bindings/src/model/profile.rs b/rust-bindings/src/model/profile.rs index a369dc7..5946e9d 100644 --- a/rust-bindings/src/model/profile.rs +++ b/rust-bindings/src/model/profile.rs @@ -184,6 +184,7 @@ pub(crate) enum PyModelExtension { REDACTED_ENV_VARS = 1, FEATURE_BUNDLE_1 = 2, EXPR = 3, + WRAP_ACTIONS = 4, } #[cfg_attr(feature = "stub-gen", gen_stub_pymethods)] @@ -238,6 +239,7 @@ impl From for ModelExtension { PyModelExtension::REDACTED_ENV_VARS => ModelExtension::RedactedEnvVars, PyModelExtension::FEATURE_BUNDLE_1 => ModelExtension::FeatureBundle1, PyModelExtension::EXPR => ModelExtension::Expr, + PyModelExtension::WRAP_ACTIONS => ModelExtension::WrapActions, } } } @@ -249,6 +251,20 @@ impl From for PyModelExtension { ModelExtension::RedactedEnvVars => PyModelExtension::REDACTED_ENV_VARS, ModelExtension::FeatureBundle1 => PyModelExtension::FEATURE_BUNDLE_1, ModelExtension::Expr => PyModelExtension::EXPR, + ModelExtension::WrapActions => PyModelExtension::WRAP_ACTIONS, + // `ModelExtension` is `#[non_exhaustive]`, so downstream + // crates cannot write an exhaustive match. This arm is + // mandatory, and the compiler will not warn when the Rust + // crate gains a variant that has no arm above. The fallback + // therefore MISREPRESENTS any such variant as `EXPR`. + // + // A new upstream variant requires three edits: a new + // `PyModelExtension` member, an arm here, and an arm in + // `From for ModelExtension`. The guard + // against forgetting is + // `test/openjd/model_v1/test_version_enums.py::TestModelExtension`, + // which fails when this enum drifts from + // `openjd.model.v2023_09.ExtensionName`. #[allow(unreachable_patterns)] _ => PyModelExtension::EXPR, } diff --git a/src/openjd/_openjd_rs.pyi b/src/openjd/_openjd_rs.pyi index 48c7354..36947fd 100644 --- a/src/openjd/_openjd_rs.pyi +++ b/src/openjd/_openjd_rs.pyi @@ -3138,6 +3138,7 @@ class ModelExtension(enum.Enum): REDACTED_ENV_VARS = ... FEATURE_BUNDLE_1 = ... EXPR = ... + WRAP_ACTIONS = ... @property def name(self) -> builtins.str: diff --git a/test/openjd/model_v1/test_pickle.py b/test/openjd/model_v1/test_pickle.py index 4c77274..3c85309 100644 --- a/test/openjd/model_v1/test_pickle.py +++ b/test/openjd/model_v1/test_pickle.py @@ -52,6 +52,7 @@ "openjd._openjd_rs.ModelExtension.REDACTED_ENV_VARS", "openjd._openjd_rs.ModelExtension.FEATURE_BUNDLE_1", "openjd._openjd_rs.ModelExtension.EXPR", + "openjd._openjd_rs.ModelExtension.WRAP_ACTIONS", "openjd._openjd_rs.TemplateSpecificationVersion.JOBTEMPLATE_v2023_09", "openjd._openjd_rs.TemplateSpecificationVersion.ENVIRONMENT_v2023_09", ], diff --git a/test/openjd/model_v1/test_version_enums.py b/test/openjd/model_v1/test_version_enums.py index bf31d32..945c641 100644 --- a/test/openjd/model_v1/test_version_enums.py +++ b/test/openjd/model_v1/test_version_enums.py @@ -3,6 +3,8 @@ import pytest from openjd.model._v1 import TemplateSpecificationVersion, decode_job_template +from openjd.model._v1.types import ModelExtension +from openjd.model.v2023_09 import ExtensionName # All known variants. Add new ones here as the spec evolves; the test # ensures every variant is classified as exactly job-template OR @@ -56,3 +58,48 @@ def test_template_specification_version_returned_from_decode(self) -> None: # like behaviour without being a str subclass). assert t.specification_version == "jobtemplate-2023-09" assert t.specification_version.value == "jobtemplate-2023-09" + + +def _rust_extension_members() -> set[str]: + # `ModelExtension` is a PyO3 pyclass enum, not a real `enum.Enum`, so + # it is not iterable. Discover members by identity instead of relying + # on the enum protocol or a hardcoded list. + return { + name + for name in dir(ModelExtension) + if isinstance(getattr(ModelExtension, name), ModelExtension) + } + + +class TestModelExtension: + """Drift guard for the `ModelExtension` PyO3 binding. + + `openjd_model::types::ModelExtension` is `#[non_exhaustive]`, so + `From for PyModelExtension` must carry a catch-all + arm and the compiler cannot warn when the Rust crate gains a variant + the binding does not map. These tests are that warning. + + Regression: `WRAP_ACTIONS` landed in the Rust crate six days after + the binding was written; the binding was never updated, so the + catch-all silently mapped it to `EXPR`. A profile built with + `WRAP_ACTIONS` therefore enabled `EXPR` instead, and templates using + `onWrapEnvEnter`/`onWrapTaskRun`/`onWrapEnvExit` were rejected with + an error that did not name the real cause. + """ + + def test_binding_members_match_python_extension_names(self) -> None: + # The binding enum and the Python model layer describe the same + # set of 2023-09 extensions. Divergence in either direction is a + # bug: a missing member means the catch-all aliases it to the + # wrong extension; an extra member means Python cannot name it. + assert _rust_extension_members() == {e.value for e in ExtensionName} + + @pytest.mark.parametrize("name", [e.value for e in ExtensionName]) + def test_name_round_trips_to_its_own_member(self, name: str) -> None: + # Catches aliasing directly, independent of member-set equality: + # every spec name must parse back to the member of the same name, + # never to a different extension. + parsed = ModelExtension.from_str(name) + assert parsed is not None, f"{name} is not recognized by the Rust binding" + assert parsed.name == name + assert parsed.as_str() == name