Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions rust-bindings/src/model/profile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -238,6 +239,7 @@ impl From<PyModelExtension> for ModelExtension {
PyModelExtension::REDACTED_ENV_VARS => ModelExtension::RedactedEnvVars,
PyModelExtension::FEATURE_BUNDLE_1 => ModelExtension::FeatureBundle1,
PyModelExtension::EXPR => ModelExtension::Expr,
PyModelExtension::WRAP_ACTIONS => ModelExtension::WrapActions,
}
}
}
Expand All @@ -249,6 +251,20 @@ impl From<ModelExtension> 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<PyModelExtension> 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,
}
Expand Down
1 change: 1 addition & 0 deletions src/openjd/_openjd_rs.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -3138,6 +3138,7 @@ class ModelExtension(enum.Enum):
REDACTED_ENV_VARS = ...
FEATURE_BUNDLE_1 = ...
EXPR = ...
WRAP_ACTIONS = ...

@property
def name(self) -> builtins.str:
Expand Down
1 change: 1 addition & 0 deletions test/openjd/model_v1/test_pickle.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
],
Expand Down
47 changes: 47 additions & 0 deletions test/openjd/model_v1/test_version_enums.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<ModelExtension> 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
Loading