diff --git a/docs/docs/in_depth/mloda-api.md b/docs/docs/in_depth/mloda-api.md index dfde9693c..a06585b18 100644 --- a/docs/docs/in_depth/mloda-api.md +++ b/docs/docs/in_depth/mloda-api.md @@ -97,34 +97,19 @@ from mloda.steward import ( ##### resolve_feature -Resolve a feature name to its matching FeatureGroup class. This is useful for debugging feature resolution or understanding which FeatureGroup handles a specific feature. +Resolve a single feature name (or a `Feature`) to its matching FeatureGroup without running the request, reporting failures in `result.error` instead of raising. It takes `feature` (`str | Feature`) positionally plus keyword-only `options`, `plugin_collector`, `feature_group`, `links`, `data_access_collection`, and `compute_frameworks`, and returns a `ResolvedFeature` (8 fields, including `candidates`, `error`, `supported_compute_frameworks`, `subtype`). ``` python from mloda.steward import resolve_feature -# Successful resolution result = resolve_feature("my_feature_name") if result.feature_group: print(f"Resolved to: {result.feature_group.__name__}") else: print(f"Error: {result.error}") - -# Access all matching candidates (before subclass filtering) -print(f"Candidates: {[fg.__name__ for fg in result.candidates]}") ``` -**Parameters:** - -- **feature_name** (`str`): The name of the feature to resolve. -- **options** (`Options | None`, keyword-only): Options used for matching and for the compute framework capability split. Defaults to empty `Options`. Required to resolve FeatureGroups that gate matching on an option. -- **plugin_collector** (`PluginCollector | None`, keyword-only): Restricts the FeatureGroups considered, and threads its `allow_redefinition` flag into deduplication. - -**Returns:** `ResolvedFeature` dataclass with fields: - -- **feature_name** (`str`): The input feature name. -- **feature_group** (`Type[FeatureGroup] | None`): The resolved FeatureGroup class, or None if resolution failed. -- **candidates** (`List[Type[FeatureGroup]]`): All FeatureGroups that matched before subclass filtering. -- **error** (`str | None`): Error message if resolution failed (no match or multiple conflicts). +See [Discover Plugins](discover-plugins.md#resolving-feature-names) for the full signature, every `ResolvedFeature` field, and worked examples (options-gated groups, scoping, broken framework declarations). `resolve_feature` is exported from `mloda.provider`, `mloda.user`, and `mloda.steward`; the `ResolvedFeature` return type is exported from `mloda.steward`. ##### explain and resolved_plan @@ -180,6 +165,41 @@ for step in mloda.explain(["sales__mean_aggr"], compute_frameworks=["PandasDataF print(step.requested_feature_names, step.injected_feature_names) ``` +##### diagnose and resolution_report + +`diagnose` and `resolution_report()` are the non-raising counterparts to `explain` and `resolved_plan()`: where the plan-based pair returns `PlanStep` records, these return the resolution facts a failing request would otherwise raise. + +`mlodaAPI.diagnose(features, ...)` runs the whole-request preflight without raising and returns a single `ResolutionDiagnosis`. It takes the same arguments as `explain` (every parameter after `features` is keyword-only). On success its `records` equal `session.resolution_report()` and `complete` is `True`; on a resolution failure it carries the records resolved before the failing feature plus `feature_name`, `failed_result`, and `message`; on an environment or config failure (redefinition conflict, framework-declaration error, compute-framework pin) it returns `records=[]`, `complete=False`, and only `message`. + +`session.resolution_report()` returns the `list[ResolutionRecord]` captured while `prepare()` planned the request, one per feature, available before or after `run()`. + +``` python +from mloda.user import mlodaAPI + +diagnosis = mlodaAPI.diagnose(["sales__mean_aggr"], compute_frameworks=["PandasDataFrame"]) +if diagnosis.complete: + for record in diagnosis.records: + print(record.feature_name, record.requested) +else: + print(diagnosis.feature_name, diagnosis.message) +``` + +When the same request is run rather than diagnosed, the failure raises `FeatureResolutionError` (below). + +##### Resolution result types + +Import the typed resolution surfaces from `mloda.provider` (also re-exported from `mloda.user` and `mloda.steward`): + +``` python +from mloda.provider import FeatureResolutionError, ResolutionDiagnosis, ResolutionRecord +``` + +- **`FeatureResolutionError`** (a `ValueError` subclass): raised during planning (`mlodaAPI(...)` / `prepare()` / `run_all()`) when a feature does not resolve to exactly one FeatureGroup. Attributes: `feature_name` (`str`), `result` (`EvaluationResult`, the captured per-candidate elimination facts), `partial_records` (`tuple[ResolutionRecord, ...]`, features resolved before the failure, capped at the last 1000). Because it subclasses `ValueError`, existing `except ValueError` handlers keep working; catch `FeatureResolutionError` to read the attributes. See [Feature Group Resolution Errors](troubleshooting/feature-group-resolution-errors.md). +- **`ResolutionDiagnosis`** (frozen dataclass): the return value of `diagnose`, never raised. Fields: `records` (`list[ResolutionRecord]`), `complete` (`bool`), `feature_name` (`str | None`), `failed_result` (`EvaluationResult | None`), `message` (`str | None`). +- **`ResolutionRecord`** (frozen dataclass): one per feature, returned inside `resolution_report()`, `ResolutionDiagnosis.records`, and `FeatureResolutionError.partial_records`; never raised. Fields: `feature_name` (`str`), `requested` (`bool`), `result` (`EvaluationResult`). + +`EvaluationResult` is the captured matcher outcome carried by the fields above; it lives in `mloda.core.prepare.identify_feature_group` and is not part of the public `__init__` exports. + ##### get_feature_group_docs Get documentation for feature groups with optional filtering. diff --git a/docs/docs/in_depth/troubleshooting/feature-group-resolution-errors.md b/docs/docs/in_depth/troubleshooting/feature-group-resolution-errors.md index 4a1054933..72241bf82 100644 --- a/docs/docs/in_depth/troubleshooting/feature-group-resolution-errors.md +++ b/docs/docs/in_depth/troubleshooting/feature-group-resolution-errors.md @@ -27,17 +27,45 @@ Pass a `Feature`, `options`, `plugin_collector`, `links`, frameworks needs this to mirror) to reproduce the run you are debugging. See [Discover Plugins](../discover-plugins.md) for the full signature. +## Catching resolution failures + +Inside a run, a feature that does not resolve to exactly one FeatureGroup raises +`FeatureResolutionError` during planning (the `mloda.run_all(...)` / +`mloda.prepare(...)` call, before any compute runs). It is a `ValueError` +subclass, so existing `except ValueError` handlers keep working, but you can now +catch it specifically and inspect why it failed: + +``` python +from mloda.provider import FeatureResolutionError +from mloda.user import mloda + +try: + mloda.run_all(["my_feature"]) +except FeatureResolutionError as err: + print(err.feature_name) # the feature that failed to resolve + print(err) # the human-readable message a run prints + for record in err.partial_records: + print(record.feature_name, record.requested) # features resolved before the failure + # err.result carries the per-candidate elimination facts (an EvaluationResult) +``` + +To inspect a request without catching an exception, use the non-raising +surfaces: `resolve_feature` for a single name (above), or `mlodaAPI.diagnose(...)`, +which returns a `ResolutionDiagnosis` for the whole request. See +[mlodaAPI](../mloda-api.md#diagnose-and-resolution_report) for both, plus the +fields on `FeatureResolutionError`, `ResolutionDiagnosis`, and `ResolutionRecord`. + ## Multiple Feature Groups Error ### The Problem ``` -ValueError: Multiple feature groups found for feature '': +FeatureResolutionError: Multiple feature groups found for feature '': - FeatureGroupA (...) [domain: ...] - FeatureGroupB (...) [domain: ...] ``` -This error occurs when multiple distinct feature groups claim they can handle the same feature. Each feature must resolve to exactly one feature group to prevent conflicts. +This error occurs when multiple distinct feature groups claim they can handle the same feature. Each feature must resolve to exactly one feature group to prevent conflicts. It is raised as `FeatureResolutionError` (a `ValueError` subclass) during planning; see [Catching resolution failures](#catching-resolution-failures) to inspect it. If you previously hit this in a notebook because of a redefined feature group (re-running a cell that defines `class MyFG(FeatureGroup): ...`), that case is now auto-deduplicated. If this error still appears, it points to a real conflict between two distinct classes (different `(module, qualname)`). diff --git a/mloda/core/abstract_plugins/components/feature_chainer/feature_chain_parser.py b/mloda/core/abstract_plugins/components/feature_chainer/feature_chain_parser.py index 06537df14..84c8e72f1 100644 --- a/mloda/core/abstract_plugins/components/feature_chainer/feature_chain_parser.py +++ b/mloda/core/abstract_plugins/components/feature_chainer/feature_chain_parser.py @@ -187,21 +187,6 @@ def parse_feature_name( return None, None return cls._legacy_operation_config(parsed), parsed.source_feature - @classmethod - def _match_pattern_based_feature( - cls, - feature_name: str | FeatureName, - prefix_patterns: list[Any], - pattern: str = CHAIN_SEPARATOR, - ) -> bool: - """Internal method for matching pattern-based features - used by match_configuration_feature_chain_parser.""" - _feature_name: FeatureName = FeatureName(feature_name) if isinstance(feature_name, str) else feature_name - - has_prefix_configuration, source_feature = cls.parse_feature_name(_feature_name, prefix_patterns, pattern) - if has_prefix_configuration is None or source_feature is None: - return False - return True - @classmethod def _can_skip_required_check(cls, spec: PropertySpec) -> bool: """Check if the base parser should treat this property as optional. @@ -498,8 +483,9 @@ def name_path_presence_rejection_reason( ) -> str | None: """The reason a name-path candidate was rejected for missing presence (#769); None when nothing is missing. - Diagnostic-only: mirrors _check_name_path_required_presence so the resolution-failure - report explains the same non-match the matcher produced. + Supported diagnostic seam, paired with ``_strict_validation_rejection_reason``: + mirrors _check_name_path_required_presence so the resolution-failure report explains the + same non-match the matcher produced. """ missing = cls._name_path_missing_required_keys(effective_options, property_mapping) if not missing: diff --git a/mloda/core/abstract_plugins/components/feature_chainer/feature_chain_parser_mixin.py b/mloda/core/abstract_plugins/components/feature_chainer/feature_chain_parser_mixin.py index 6c1a1779c..1ecdc0ece 100644 --- a/mloda/core/abstract_plugins/components/feature_chainer/feature_chain_parser_mixin.py +++ b/mloda/core/abstract_plugins/components/feature_chainer/feature_chain_parser_mixin.py @@ -313,8 +313,9 @@ def _strict_validation_rejection_reason(cls, feature_name: str | FeatureName, op """Return the rejection message that match_feature_group_criteria discards, if any. The engine no longer calls this: it renders the reasons the first match pass recorded via - ``record_match_rejection``. This stays a compatibility facade for direct diagnostic calls - and must keep producing the same messages the match pass records. + ``record_match_rejection``. This is a supported diagnostic seam: a stable, + overridable hook for reproducing a single group's value-rejection reason outside a run. It + must keep producing the same messages the match pass records. Reports two kinds of value rejection, both gated on the feature group OTHERWISE matching the feature: diff --git a/mloda/core/abstract_plugins/components/options.py b/mloda/core/abstract_plugins/components/options.py index ed35297c4..3bdfcf59a 100644 --- a/mloda/core/abstract_plugins/components/options.py +++ b/mloda/core/abstract_plugins/components/options.py @@ -151,15 +151,6 @@ def __init__( OptionsValidator.validate_no_duplicate_keys(self.group, self.context) OptionsValidator.validate_propagate_keys_in_context(self.propagate_context_keys, self.context) - def add(self, key: str, value: Any) -> None: - """ - Legacy method for backward compatibility. - Adds to group to maintain existing behavior during migration. - - Possibility that we keep this as default method for adding options in the future. - """ - self.add_to_group(key, value) - def add_to_group(self, key: str, value: Any) -> None: """Add parameter to group (affects Feature Group resolution/splitting).""" OptionsValidator.validate_can_add_to_group(key, value, self.group, self.context) diff --git a/mloda/core/abstract_plugins/components/utils.py b/mloda/core/abstract_plugins/components/utils.py index 23fe4367a..eaab1068c 100644 --- a/mloda/core/abstract_plugins/components/utils.py +++ b/mloda/core/abstract_plugins/components/utils.py @@ -49,13 +49,11 @@ def as_str(value: Any) -> str: return value -def get_all_subclasses(cls: Any, log_n_subclasses: int = 0) -> set[type[Any]]: +def get_all_subclasses(cls: Any) -> set[type[Any]]: all_subclasses = set() for subclass in cls.__subclasses__(): all_subclasses.add(subclass) all_subclasses.update(get_all_subclasses(subclass)) - if log_n_subclasses > 0: - logger.debug(f"Abstractclass: {type(cls)}. Subclasses: {list(all_subclasses)[log_n_subclasses]}.") return all_subclasses diff --git a/mloda/core/abstract_plugins/feature_group.py b/mloda/core/abstract_plugins/feature_group.py index 1b5a61ce0..6e6a218cd 100644 --- a/mloda/core/abstract_plugins/feature_group.py +++ b/mloda/core/abstract_plugins/feature_group.py @@ -3,7 +3,7 @@ import inspect import logging from collections.abc import Collection, Mapping -from typing import Any, ClassVar, Callable, Iterable, Optional, final +from typing import Any, ClassVar, Callable, Optional, final from abc import ABC from mloda.core.abstract_plugins.components.base_artifact import BaseArtifact @@ -14,6 +14,7 @@ from mloda.core.abstract_plugins.components.base_feature_group_version import BaseFeatureGroupVersion from mloda.core.abstract_plugins.components.feature_chainer.feature_chain_parser import ( CHAIN_SEPARATOR, + COLUMN_SEPARATOR, FeatureChainParser, option_key_is_present, ) @@ -469,7 +470,7 @@ def get_column_base_feature(column_name: str) -> str: Returns the original name if no ~N suffix exists. """ - return column_name.split("~")[0] + return column_name.split(COLUMN_SEPARATOR)[0] @staticmethod def expand_feature_columns(feature_name: str, num_columns: int) -> list[str]: @@ -841,15 +842,3 @@ class MyMatchData(FeatureGroup, MatchData): def format_feature_group_class(fg_class: type[FeatureGroup]) -> str: """Format a single FeatureGroup class for error messages.""" return f"{fg_class.__name__} ({fg_class.__module__})" - - -def format_feature_group_classes(feature_groups: Iterable[type[FeatureGroup]], include_domain: bool = False) -> str: - """Format FeatureGroup classes for error messages.""" - lines = [] - for fg_class in feature_groups: - line = f" - {fg_class.__name__} ({fg_class.__module__})" - if include_domain: - domain = fg_class.get_domain() - line += f" [domain: {domain.name}]" - lines.append(line) - return "\n".join(lines) diff --git a/mloda/core/api/request.py b/mloda/core/api/request.py index 210686385..4f3cf37da 100644 --- a/mloda/core/api/request.py +++ b/mloda/core/api/request.py @@ -530,11 +530,13 @@ def _setup_engine_runner( return runner def get_result(self) -> list[Any]: + """Return the computed run results; raises if no run function has executed yet.""" if self.runner is None: raise ValueError("You need to run any run function beforehand.") return self.runner.get_result() def get_artifacts(self) -> dict[str, Any]: + """Return the artifacts produced by the run; raises if no run function has executed yet.""" if self.runner is None: raise ValueError("You need to run any run function beforehand.") return self.runner.get_artifacts() diff --git a/tests/test_core/test_abstract_plugins/test_feature_group/test_format_feature_group_class.py b/tests/test_core/test_abstract_plugins/test_feature_group/test_format_feature_group_class.py new file mode 100644 index 000000000..faaa040de --- /dev/null +++ b/tests/test_core/test_abstract_plugins/test_feature_group/test_format_feature_group_class.py @@ -0,0 +1,55 @@ +"""Tests for the format_feature_group_class (singular) helper, which stays live in +production (engine.py, execution_plan.py, feature_group_step.py) after the plural +format_feature_group_classes was removed.""" + +from typing import Optional + +from mloda.core.abstract_plugins.components.domain import Domain +from mloda.core.abstract_plugins.components.feature_name import FeatureName +from mloda.core.abstract_plugins.feature_group import FeatureGroup, format_feature_group_class +from mloda.user import Feature, Options + + +class SampleFeatureGroupAlpha(FeatureGroup): + """A test feature group for formatting tests.""" + + def input_features(self, options: Options, feature_name: FeatureName) -> Optional[set[Feature]]: + return None + + +class SampleFeatureGroupWithDomain(FeatureGroup): + """A test feature group with a custom domain.""" + + @classmethod + def get_domain(cls) -> Domain: + return Domain("sales") + + def input_features(self, options: Options, feature_name: FeatureName) -> Optional[set[Feature]]: + return None + + +class TestFormatFeatureGroupClass: + """Direct unit tests for the format_feature_group_class (singular) helper.""" + + def test_returns_class_name_and_module(self) -> None: + """format_feature_group_class returns the ClassName (module.path) format.""" + result = format_feature_group_class(SampleFeatureGroupAlpha) + + assert "SampleFeatureGroupAlpha" in result + assert SampleFeatureGroupAlpha.__module__ in result + + def test_output_format_structure(self) -> None: + """The exact structure is ClassName (module.path).""" + result = format_feature_group_class(SampleFeatureGroupAlpha) + + assert result.count("(") == 1 + assert result.count(")") == 1 + assert result.startswith("SampleFeatureGroupAlpha") + assert result.endswith(")") + + def test_with_feature_group_with_domain(self) -> None: + """Formatting works for a FeatureGroup that declares a custom domain.""" + result = format_feature_group_class(SampleFeatureGroupWithDomain) + + assert "SampleFeatureGroupWithDomain" in result + assert SampleFeatureGroupWithDomain.__module__ in result diff --git a/tests/test_core/test_abstract_plugins/test_feature_group/test_format_feature_group_classes.py b/tests/test_core/test_abstract_plugins/test_feature_group/test_format_feature_group_classes.py deleted file mode 100644 index aefe5a956..000000000 --- a/tests/test_core/test_abstract_plugins/test_feature_group/test_format_feature_group_classes.py +++ /dev/null @@ -1,206 +0,0 @@ -"""Tests for format_feature_group_classes and format_feature_group_class utility functions.""" - -from typing import Generator, Optional - - -from mloda.core.abstract_plugins.components.domain import Domain -from mloda.core.abstract_plugins.components.feature_name import FeatureName -from mloda.core.abstract_plugins.feature_group import ( - FeatureGroup, - format_feature_group_class, - format_feature_group_classes, -) -from mloda.user import Feature, Options - - -class SampleFeatureGroupAlpha(FeatureGroup): - """A test feature group for formatting tests.""" - - def input_features(self, options: Options, feature_name: FeatureName) -> Optional[set[Feature]]: - return None - - -class SampleFeatureGroupBeta(FeatureGroup): - """Another test feature group for formatting tests.""" - - def input_features(self, options: Options, feature_name: FeatureName) -> Optional[set[Feature]]: - return None - - -class SampleFeatureGroupWithDomain(FeatureGroup): - """A test feature group with a custom domain.""" - - @classmethod - def get_domain(cls) -> Domain: - return Domain("sales") - - def input_features(self, options: Options, feature_name: FeatureName) -> Optional[set[Feature]]: - return None - - -class SampleFeatureGroupFinance(FeatureGroup): - """A test feature group with finance domain.""" - - @classmethod - def get_domain(cls) -> Domain: - return Domain("finance") - - def input_features(self, options: Options, feature_name: FeatureName) -> Optional[set[Feature]]: - return None - - -class TestFormatFeatureGroupClassesBasic: - """Tests for basic formatting of FeatureGroup classes.""" - - def test_formats_single_class_with_name_and_module(self) -> None: - """Test that a single FeatureGroup class is formatted with name and module. - - Expected output format: - - SampleFeatureGroupAlpha (tests.test_core.test_abstract_plugins.test_feature_group.test_format_feature_group_classes) - """ - result = format_feature_group_classes([SampleFeatureGroupAlpha]) - - assert "SampleFeatureGroupAlpha" in result - assert "test_format_feature_group_classes" in result - assert result.startswith(" - ") - - def test_formats_multiple_classes_newline_separated(self) -> None: - """Test that multiple FeatureGroup classes are formatted as newline-separated list. - - Expected output format: - - SampleFeatureGroupAlpha (module.path) - - SampleFeatureGroupBeta (module.path) - """ - result = format_feature_group_classes([SampleFeatureGroupAlpha, SampleFeatureGroupBeta]) - - lines = result.split("\n") - assert len(lines) == 2 - - assert "SampleFeatureGroupAlpha" in lines[0] - assert "SampleFeatureGroupBeta" in lines[1] - - for line in lines: - assert line.startswith(" - ") - - def test_empty_iterable_returns_empty_string(self) -> None: - """Test that an empty iterable returns an empty string.""" - result = format_feature_group_classes([]) - - assert result == "" - - def test_single_class_format_structure(self) -> None: - """Test the exact structure of the output for a single class. - - The output should follow the pattern: - - ClassName (module.path) - """ - result = format_feature_group_classes([SampleFeatureGroupAlpha]) - - assert result.count("(") == 1 - assert result.count(")") == 1 - assert " - " in result - - -class TestFormatFeatureGroupClassesWithDomain: - """Tests for formatting FeatureGroup classes with domain information.""" - - def test_include_domain_shows_domain_info(self) -> None: - """Test that include_domain=True shows domain information. - - Expected output format: - - SampleFeatureGroupWithDomain (module.path) [domain: sales] - """ - result = format_feature_group_classes([SampleFeatureGroupWithDomain], include_domain=True) - - assert "SampleFeatureGroupWithDomain" in result - assert "[domain: sales]" in result - - def test_include_domain_false_hides_domain_info(self) -> None: - """Test that include_domain=False (default) does not show domain information.""" - result = format_feature_group_classes([SampleFeatureGroupWithDomain], include_domain=False) - - assert "SampleFeatureGroupWithDomain" in result - assert "[domain:" not in result - assert "sales" not in result - - def test_default_domain_shown_when_include_domain_true(self) -> None: - """Test that default domain is shown when include_domain=True.""" - result = format_feature_group_classes([SampleFeatureGroupAlpha], include_domain=True) - - assert "SampleFeatureGroupAlpha" in result - assert "[domain: default_domain]" in result - - def test_multiple_classes_with_different_domains(self) -> None: - """Test formatting multiple classes with different domains. - - Expected output: - - SampleFeatureGroupWithDomain (module.path) [domain: sales] - - SampleFeatureGroupFinance (module.path) [domain: finance] - """ - result = format_feature_group_classes( - [SampleFeatureGroupWithDomain, SampleFeatureGroupFinance], include_domain=True - ) - - assert "[domain: sales]" in result - assert "[domain: finance]" in result - - lines = result.strip().split("\n") - assert len(lines) == 2 - - -class TestFormatFeatureGroupClassesEdgeCases: - """Tests for edge cases in formatting FeatureGroup classes.""" - - def test_accepts_generator(self) -> None: - """Test that the function accepts a generator (Iterable, not just list).""" - - def class_generator() -> Generator[type[FeatureGroup], None, None]: - yield SampleFeatureGroupAlpha - yield SampleFeatureGroupBeta - - result = format_feature_group_classes(class_generator()) - - assert "SampleFeatureGroupAlpha" in result - assert "SampleFeatureGroupBeta" in result - - def test_accepts_set(self) -> None: - """Test that the function accepts a set of classes.""" - classes = {SampleFeatureGroupAlpha, SampleFeatureGroupBeta} - result = format_feature_group_classes(classes) - - assert "SampleFeatureGroupAlpha" in result - assert "SampleFeatureGroupBeta" in result - - def test_accepts_tuple(self) -> None: - """Test that the function accepts a tuple of classes.""" - classes = (SampleFeatureGroupAlpha,) - result = format_feature_group_classes(classes) - - assert "SampleFeatureGroupAlpha" in result - - -class TestFormatFeatureGroupClass: - """Tests for format_feature_group_class (singular) helper function.""" - - def test_returns_class_name_and_module(self) -> None: - """Test that format_feature_group_class returns ClassName (module.path) format.""" - result = format_feature_group_class(SampleFeatureGroupAlpha) - - assert "SampleFeatureGroupAlpha" in result - assert "test_format_feature_group_classes" in result - - def test_output_format_structure(self) -> None: - """Test the exact format structure: ClassName (module.path).""" - result = format_feature_group_class(SampleFeatureGroupAlpha) - - assert result.count("(") == 1 - assert result.count(")") == 1 - assert result.startswith("SampleFeatureGroupAlpha") - assert result.endswith(")") - - def test_with_feature_group_with_domain(self) -> None: - """Test formatting works with a FeatureGroup that has a custom domain.""" - result = format_feature_group_class(SampleFeatureGroupWithDomain) - - assert "SampleFeatureGroupWithDomain" in result - assert "test_format_feature_group_classes" in result diff --git a/tests/test_core/test_prepare/test_identify_feature_group_error_message.py b/tests/test_core/test_prepare/test_identify_feature_group_error_message.py index 5ad7919d8..89e14f463 100644 --- a/tests/test_core/test_prepare/test_identify_feature_group_error_message.py +++ b/tests/test_core/test_prepare/test_identify_feature_group_error_message.py @@ -1,7 +1,8 @@ """Tests for error message formatting in identify_feature_group.py. -This test verifies that error messages use formatted output from -format_feature_group_classes instead of raw dict/class representation. +This test verifies that the multiple-feature-group error message uses the +formatted candidate output from _render_multiple (naming each class as +"ClassName (module.path)") instead of raw dict/class representation. """ from typing import Optional