From bd09c14217716aa90e45941418965d208f7ee5fe Mon Sep 17 00:00:00 2001 From: Matthew Sisserson Date: Wed, 28 Feb 2024 11:07:04 -0500 Subject: [PATCH 1/6] Added spec validations in Stack and Component pydantic models. Added check for mismatch between stack and component provider to yaml_utils.py --- src/mlstacks/constants.py | 59 +++++++++++++++++ src/mlstacks/enums.py | 19 ++++++ src/mlstacks/models/component.py | 66 +++++++++++++++++-- src/mlstacks/models/stack.py | 6 +- .../terraform-aws.tf | 13 ++-- src/mlstacks/utils/model_utils.py | 35 +++++++++- src/mlstacks/utils/yaml_utils.py | 21 +++++- 7 files changed, 203 insertions(+), 16 deletions(-) diff --git a/src/mlstacks/constants.py b/src/mlstacks/constants.py index 1216c784..5647f3a6 100644 --- a/src/mlstacks/constants.py +++ b/src/mlstacks/constants.py @@ -39,6 +39,51 @@ "model_deployer": ["seldon"], "step_operator": ["sagemaker", "vertex"], } +ALLOWED_COMPONENT_TYPES = { + "aws": { + "artifact_store": ["s3"], + "container_registry": ["aws"], + "experiment_tracker": ["mlflow"], + "orchestrator": [ + "kubeflow", + "kubernetes", + "sagemaker", + "skypilot", + "tekton", + ], + "mlops_platform": ["zenml"], + "model_deployer": ["seldon"], + "step_operator": ["sagemaker"], + }, + "gcp": { + "artifact_store": ["gcp"], + "container_registry": ["gcp"], + "experiment_tracker": ["mlflow"], + "orchestrator": [ + "kubeflow", + "kubernetes", + "skypilot", + "tekton", + "vertex", + ], + "mlops_platform": ["zenml"], + "model_deployer": ["seldon"], + "step_operator": ["vertex"], + }, + "k3d": { + "artifact_store": ["minio"], + "container_registry": ["default"], + "experiment_tracker": ["mlflow"], + "orchestrator": [ + "kubeflow", + "kubernetes", + "sagemaker", + "tekton", + ], + "mlops_platform": ["zenml"], + "model_deployer": ["seldon"], + }, +} PERMITTED_NAME_REGEX = r"^[a-zA-Z0-9][a-zA-Z0-9_-]*$" ANALYTICS_OPT_IN_ENV_VARIABLE = "MLSTACKS_ANALYTICS_OPT_IN" @@ -49,5 +94,19 @@ "contain alphanumeric characters, underscores, and hyphens " "thereafter." ) +INVALID_COMPONENT_TYPE_ERROR_MESSAGE = ( + "Artifact Store, Container Registry, Experiment Tracker, Orchestrator," + "MLOps Platform, and Model Deployer may be used with aws, gcp, and k3d" + "providers. Step Operator may only be used with aws and gcp." +) +INVALID_COMPONENT_FLAVOR_ERROR_MESSAGE = ( + "Only certain flavors are allowed for a given provider-component type" + "combination. For more information, consult the tables for your specified" + "provider at the MLStacks documentation:" + "https://mlstacks.zenml.io/stacks/stack-specification." +) +STACK_COMPONENT_PROVIDER_MISMATCH_ERROR_MESSAGE = ( + "Stack provider and component provider mismatch." +) DEFAULT_REMOTE_STATE_BUCKET_NAME = "zenml-mlstacks-remote-state" TERRAFORM_CONFIG_BUCKET_REPLACEMENT_STRING = "BUCKETNAMEREPLACEME" diff --git a/src/mlstacks/enums.py b/src/mlstacks/enums.py index 45bbada8..788b6aab 100644 --- a/src/mlstacks/enums.py +++ b/src/mlstacks/enums.py @@ -77,3 +77,22 @@ class AnalyticsEventsEnum(str, Enum): MLSTACKS_SOURCE = "MLStacks Source" MLSTACKS_EXCEPTION = "MLStacks Exception" MLSTACKS_VERSION = "MLStacks Version" + + +class SpecTypeEnum(str, Enum): + """Spec type enum.""" + + STACK = "stack" + COMPONENT = "component" + + +class StackSpecVersionEnum(int, Enum): + """Spec version enum.""" + + ONE = 1 + + +class ComponentSpecVersionEnum(int, Enum): + """Spec version enum.""" + + ONE = 1 diff --git a/src/mlstacks/models/component.py b/src/mlstacks/models/component.py index 31a1db4a..7f0a042f 100644 --- a/src/mlstacks/models/component.py +++ b/src/mlstacks/models/component.py @@ -16,13 +16,23 @@ from pydantic import BaseModel, validator -from mlstacks.constants import INVALID_NAME_ERROR_MESSAGE +from mlstacks.constants import ( + INVALID_COMPONENT_FLAVOR_ERROR_MESSAGE, + INVALID_COMPONENT_TYPE_ERROR_MESSAGE, + INVALID_NAME_ERROR_MESSAGE, +) from mlstacks.enums import ( ComponentFlavorEnum, + ComponentSpecVersionEnum, ComponentTypeEnum, ProviderEnum, + SpecTypeEnum, +) +from mlstacks.utils.model_utils import ( + is_valid_component_flavor, + is_valid_component_type, + is_valid_name, ) -from mlstacks.utils.model_utils import is_valid_name class ComponentMetadata(BaseModel): @@ -49,16 +59,16 @@ class Component(BaseModel): metadata: The metadata of the component. """ - spec_version: int = 1 - spec_type: str = "component" + spec_version: ComponentSpecVersionEnum = 1 + spec_type: SpecTypeEnum = "component" name: str + provider: ProviderEnum component_type: ComponentTypeEnum component_flavor: ComponentFlavorEnum - provider: ProviderEnum metadata: Optional[ComponentMetadata] = None @validator("name") - def validate_name(cls, name: str) -> str: # noqa: N805 + def validate_name(self, cls, name: str) -> str: # noqa: N805 """Validate the name. Name must start with an alphanumeric character and can only contain @@ -78,3 +88,47 @@ def validate_name(cls, name: str) -> str: # noqa: N805 if not is_valid_name(name): raise ValueError(INVALID_NAME_ERROR_MESSAGE) return name + + @validator("component_type") + def validate_component_type(self, cls, component_type: str, values: dict) -> str: + """Validate the component type. + + Artifact Store, Container Registry, Experiment Tracker, Orchestrator, + MLOps Platform, and Model Deployer may be used with aws, gcp, and k3d + providers. Step Operator may only be used with aws and gcp. + + Args: + component_type: The component type. + values: The previously validated component specs. + + Returns: + The validated component type. + + Raises: + ValueError: If the component type is invalid. + """ + if not is_valid_component_type(component_type, values["provider"]): + raise ValueError(INVALID_COMPONENT_TYPE_ERROR_MESSAGE) + return component_type + + @validator("component_flavor") + def validate_component_flavor( + self, cls, component_flavor: str, values: dict + ) -> str: + """Validate the component flavor. + + Only certain flavors are allowed for a given provider-component type combination. For more information, consult the tables for your specified provider at the MLStacks documentation: https://mlstacks.zenml.io/stacks/stack-specification. + + Args: + component_flavor: The component flavor. + values: The previously validated component specs. + + Returns: + The validated component flavor. + + Raises: + ValueError: If the component flavor is invalid. + """ + if not is_valid_component_flavor(component_flavor, values): + raise ValueError(INVALID_COMPONENT_FLAVOR_ERROR_MESSAGE) + return component_flavor diff --git a/src/mlstacks/models/stack.py b/src/mlstacks/models/stack.py index 1afebce5..4e28f367 100644 --- a/src/mlstacks/models/stack.py +++ b/src/mlstacks/models/stack.py @@ -19,6 +19,8 @@ from mlstacks.enums import ( DeploymentMethodEnum, ProviderEnum, + SpecTypeEnum, + StackSpecVersionEnum, ) from mlstacks.models.component import Component from mlstacks.utils.model_utils import is_valid_name @@ -38,8 +40,8 @@ class Stack(BaseModel): components: The components of the stack. """ - spec_version: int = 1 - spec_type: str = "stack" + spec_version: StackSpecVersionEnum = 1 + spec_type: SpecTypeEnum = "stack" name: str provider: ProviderEnum default_region: Optional[str] diff --git a/src/mlstacks/terraform/remote-state-terraform-config/terraform-aws.tf b/src/mlstacks/terraform/remote-state-terraform-config/terraform-aws.tf index 61e0b73a..f662ccbe 100644 --- a/src/mlstacks/terraform/remote-state-terraform-config/terraform-aws.tf +++ b/src/mlstacks/terraform/remote-state-terraform-config/terraform-aws.tf @@ -1,8 +1,13 @@ # defining the providers for the recipe module terraform { required_providers { - google = { - source = "hashicorp/google" + aws = { + source = "hashicorp/aws" + } + + random = { + source = "hashicorp/random" + version = "3.1.0" } local = { @@ -40,6 +45,6 @@ terraform { required_version = ">= 0.14.8" } -provider "google" { - project = var.project_id +provider "aws" { + region = var.region } diff --git a/src/mlstacks/utils/model_utils.py b/src/mlstacks/utils/model_utils.py index 286382e3..7e821a28 100644 --- a/src/mlstacks/utils/model_utils.py +++ b/src/mlstacks/utils/model_utils.py @@ -14,7 +14,7 @@ import re -from mlstacks.constants import PERMITTED_NAME_REGEX +from mlstacks.constants import ALLOWED_COMPONENT_TYPES, PERMITTED_NAME_REGEX def is_valid_name(name: str) -> bool: @@ -29,3 +29,36 @@ def is_valid_name(name: str) -> bool: True if the name is valid, False otherwise. """ return re.match(PERMITTED_NAME_REGEX, name) is not None + + +def is_valid_component_type(component_type: str, provider: str) -> bool: + """Check if the component type is valid. + + Used for components. + + Args: + component_type: The component type. + provider: The provider. + + Returns: + True if the component type is valid, False otherwise. + """ + return component_type in ALLOWED_COMPONENT_TYPES[provider].keys() + + +def is_valid_component_flavor(component_flavor: str, specs: dict) -> bool: + """Check if the component flavor is valid. + + Used for components. + + Args: + component_flavor: The component flavor. + specs: The previously validated component specs. + + Returns: + True if the component flavor is valid, False otherwise. + """ + return ( + component_flavor + in ALLOWED_COMPONENT_TYPES[specs["provider"]][specs["component_type"]] + ) diff --git a/src/mlstacks/utils/yaml_utils.py b/src/mlstacks/utils/yaml_utils.py index 422e5cdf..dd66289e 100644 --- a/src/mlstacks/utils/yaml_utils.py +++ b/src/mlstacks/utils/yaml_utils.py @@ -16,6 +16,7 @@ import yaml +from mlstacks.constants import STACK_COMPONENT_PROVIDER_MISMATCH_ERROR_MESSAGE from mlstacks.models.component import ( Component, ComponentMetadata, @@ -58,8 +59,15 @@ def load_component_yaml(path: str) -> Component: Returns: The component model. """ - with open(path) as file: - component_data = yaml.safe_load(file) + + try: + with open(path) as file: + component_data = yaml.safe_load(file) + except FileNotFoundError as exc: + # Not sure what to do here, as I'd like to specify the path that caused the problem, but I don't think that's possible while using a constant for the error message. + raise FileNotFoundError( + f'Component file at "{path}" specified in the stack spec file could not be found.' + ) from exc if component_data.get("metadata") is None: component_data["metadata"] = {} @@ -95,7 +103,8 @@ def load_stack_yaml(path: str) -> Stack: if component_data is None: component_data = [] - return Stack( + + stack = Stack( spec_version=stack_data.get("spec_version"), spec_type=stack_data.get("spec_type"), name=stack_data.get("name"), @@ -107,3 +116,9 @@ def load_stack_yaml(path: str) -> Stack: load_component_yaml(component) for component in component_data ], ) + + for component in stack.components: + if component.provider != stack.provider: + raise ValueError(STACK_COMPONENT_PROVIDER_MISMATCH_ERROR_MESSAGE) + + return stack From 53852d33292d3efc800023cbf504921625d5ba8c Mon Sep 17 00:00:00 2001 From: Matthew Sisserson Date: Tue, 5 Mar 2024 08:52:42 -0500 Subject: [PATCH 2/6] Working out testing changes --- src/mlstacks/constants.py | 11 +-- src/mlstacks/models/component.py | 13 ++-- src/mlstacks/utils/model_utils.py | 16 ++++- tests/unit/models/test_component.py | 11 ++- tests/unit/utils/test_terraform_utils.py | 14 +++- tests/unit/utils/test_zenml_utils.py | 87 ++++++++++++++++-------- 6 files changed, 107 insertions(+), 45 deletions(-) diff --git a/src/mlstacks/constants.py b/src/mlstacks/constants.py index 5647f3a6..fa90dd32 100644 --- a/src/mlstacks/constants.py +++ b/src/mlstacks/constants.py @@ -55,6 +55,7 @@ "model_deployer": ["seldon"], "step_operator": ["sagemaker"], }, + "azure": {}, "gcp": { "artifact_store": ["gcp"], "container_registry": ["gcp"], @@ -95,14 +96,14 @@ "thereafter." ) INVALID_COMPONENT_TYPE_ERROR_MESSAGE = ( - "Artifact Store, Container Registry, Experiment Tracker, Orchestrator," - "MLOps Platform, and Model Deployer may be used with aws, gcp, and k3d" + "Artifact Store, Container Registry, Experiment Tracker, Orchestrator, " + "MLOps Platform, and Model Deployer may be used with aws, gcp, and k3d " "providers. Step Operator may only be used with aws and gcp." ) INVALID_COMPONENT_FLAVOR_ERROR_MESSAGE = ( - "Only certain flavors are allowed for a given provider-component type" - "combination. For more information, consult the tables for your specified" - "provider at the MLStacks documentation:" + "Only certain flavors are allowed for a given provider-component type " + "combination. For more information, consult the tables for your specified " + "provider at the MLStacks documentation: " "https://mlstacks.zenml.io/stacks/stack-specification." ) STACK_COMPONENT_PROVIDER_MISMATCH_ERROR_MESSAGE = ( diff --git a/src/mlstacks/models/component.py b/src/mlstacks/models/component.py index 7f0a042f..fb3fc6c2 100644 --- a/src/mlstacks/models/component.py +++ b/src/mlstacks/models/component.py @@ -68,7 +68,7 @@ class Component(BaseModel): metadata: Optional[ComponentMetadata] = None @validator("name") - def validate_name(self, cls, name: str) -> str: # noqa: N805 + def validate_name(cls, name: str) -> str: # noqa: N805 """Validate the name. Name must start with an alphanumeric character and can only contain @@ -90,7 +90,9 @@ def validate_name(self, cls, name: str) -> str: # noqa: N805 return name @validator("component_type") - def validate_component_type(self, cls, component_type: str, values: dict) -> str: + def validate_component_type( + cls, component_type: str, values: dict + ) -> str: """Validate the component type. Artifact Store, Container Registry, Experiment Tracker, Orchestrator, @@ -113,11 +115,14 @@ def validate_component_type(self, cls, component_type: str, values: dict) -> str @validator("component_flavor") def validate_component_flavor( - self, cls, component_flavor: str, values: dict + cls, component_flavor: str, values: dict ) -> str: """Validate the component flavor. - Only certain flavors are allowed for a given provider-component type combination. For more information, consult the tables for your specified provider at the MLStacks documentation: https://mlstacks.zenml.io/stacks/stack-specification. + Only certain flavors are allowed for a given provider-component + type combination. For more information, consult the tables for + your specified provider at the MLStacks documentation: + https://mlstacks.zenml.io/stacks/stack-specification. Args: component_flavor: The component flavor. diff --git a/src/mlstacks/utils/model_utils.py b/src/mlstacks/utils/model_utils.py index 7e821a28..8f9465b5 100644 --- a/src/mlstacks/utils/model_utils.py +++ b/src/mlstacks/utils/model_utils.py @@ -58,7 +58,19 @@ def is_valid_component_flavor(component_flavor: str, specs: dict) -> bool: Returns: True if the component flavor is valid, False otherwise. """ + print("----------------------") + print(f"component_flavor: {component_flavor}") + print(f"specs: {specs}") + try: + t = component_flavor in ALLOWED_COMPONENT_TYPES[specs["provider"]][specs["component_type"]] + except: + return False + print(f"result: {t}") + print("----------------------") + + return ( - component_flavor - in ALLOWED_COMPONENT_TYPES[specs["provider"]][specs["component_type"]] + t + # component_flavor + # in ALLOWED_COMPONENT_TYPES[specs["provider"]][specs["component_type"]] ) diff --git a/tests/unit/models/test_component.py b/tests/unit/models/test_component.py index 3dec23fd..a835b7af 100644 --- a/tests/unit/models/test_component.py +++ b/tests/unit/models/test_component.py @@ -15,7 +15,11 @@ from hypothesis import strategies as st from mlstacks.constants import PERMITTED_NAME_REGEX -from mlstacks.enums import ComponentFlavorEnum, ComponentTypeEnum +from mlstacks.enums import ( + ComponentFlavorEnum, + ComponentTypeEnum, + ProviderEnum, +) from mlstacks.models.component import Component, ComponentMetadata @@ -29,13 +33,18 @@ def test_component_metadata(instance): @given(st.builds(Component, name=st.from_regex(PERMITTED_NAME_REGEX))) def test_component(instance): + print(f"instance: {instance}") assert isinstance(instance.spec_version, int) assert isinstance(instance.spec_type, str) assert isinstance(instance.name, str) assert instance.name is not None assert instance.spec_version is not None assert instance.spec_type is not None + print("!!!!!!!!!!!!!!!!!!!!!!!!") + print("just prior to component type test") assert isinstance(instance.component_type, ComponentTypeEnum) + print("just after component type test") + print("!!!!!!!!!!!!!!!!!!!!!!!!") assert isinstance(instance.component_flavor, ComponentFlavorEnum) assert isinstance(instance.provider, str) assert instance.provider is not None diff --git a/tests/unit/utils/test_terraform_utils.py b/tests/unit/utils/test_terraform_utils.py index 5448d2b9..5eab0e3a 100644 --- a/tests/unit/utils/test_terraform_utils.py +++ b/tests/unit/utils/test_terraform_utils.py @@ -111,11 +111,14 @@ def test_enable_key_function_handles_components_without_flavors( """ comp_flavor = "s3" comp_type = "artifact_store" + comp_provider = "aws" c = Component( name=dummy_name, component_flavor=comp_flavor, component_type=comp_type, - provider=random.choice(list(ProviderEnum)).value, + # provider=random.choice(list(ProviderEnum)).value, + # Not sure why the above line was used when only "aws" is valid here + provider=comp_provider, ) key = _compose_enable_key(c) assert key == "enable_artifact_store" @@ -125,12 +128,14 @@ def test_component_variable_parsing_works(): """Tests that the component variable parsing works.""" metadata = ComponentMetadata() component_flavor = "zenml" + random_test = random.choice(list(ProviderEnum)).value + print(f"variable parsing: {random_test}") components = [ Component( name="test", component_flavor=component_flavor, component_type="mlops_platform", - provider=random.choice(list(ProviderEnum)).value, + provider=random_test, spec_type="component", spec_version=1, metadata=metadata, @@ -146,12 +151,14 @@ def test_component_var_parsing_works_for_env_vars(): """Tests that the component variable parsing works.""" env_vars = {"ARIA_KEY": "blupus"} metadata = ComponentMetadata(environment_variables=env_vars) + random_test = random.choice(list(ProviderEnum)).value + print(f"env vars: {random_test}") components = [ Component( name="test", component_flavor="zenml", component_type="mlops_platform", - provider=random.choice(list(ProviderEnum)).value, + provider=random_test, metadata=metadata, ) ] @@ -166,6 +173,7 @@ def test_component_var_parsing_works_for_env_vars(): def test_tf_variable_parsing_from_stack_works(): """Tests that the Terraform variables extraction (from a stack) works.""" provider = random.choice(list(ProviderEnum)).value + print(f"parsing_from_stack: {provider}") component_flavor = "zenml" metadata = ComponentMetadata() components = [ diff --git a/tests/unit/utils/test_zenml_utils.py b/tests/unit/utils/test_zenml_utils.py index 7958914f..2fcc6b71 100644 --- a/tests/unit/utils/test_zenml_utils.py +++ b/tests/unit/utils/test_zenml_utils.py @@ -12,6 +12,8 @@ # permissions and limitations under the License. """Tests for utilities for mlstacks-ZenML interaction.""" +import pydantic + from mlstacks.models.component import Component from mlstacks.models.stack import Stack from mlstacks.utils.zenml_utils import has_valid_flavor_combinations @@ -44,40 +46,65 @@ def test_flavor_combination_validator_fails_aws_gcp(): Tests a known failure case. (AWS Stack with a GCP artifact store.) """ - valid_stack = Stack( - name="aria-stack", - provider="aws", - components=[], - ) - invalid_component = Component( - name="blupus-component", - component_type="artifact_store", - component_flavor="gcp", - provider=valid_stack.provider, - ) - assert not has_valid_flavor_combinations( - stack=valid_stack, - components=[invalid_component], - ) + # valid_stack = Stack( + # name="aria-stack", + # provider="aws", + # components=[], + # ) + # invalid_component = Component( + # name="blupus-component", + # component_type="artifact_store", + # component_flavor="gcp", + # provider=valid_stack.provider, + # ) + # assert not has_valid_flavor_combinations( + # stack=valid_stack, + # components=[invalid_component], + # ) + + valid = True + try: + Component( + name="blupus-component", + component_type="artifact_store", + component_flavor="gcp", + provider="aws", + ) + except pydantic.error_wrappers.ValidationError: + valid = False + assert not valid def test_flavor_combination_validator_fails_k3d_s3(): """Checks that the flavor combination validator fails. Tests a known failure case. (K3D Stack with a S3 artifact store.) """ - valid_stack = Stack( - name="aria-stack", - provider="k3d", - components=[], - ) - invalid_component = Component( - name="blupus-component", - component_type="artifact_store", - component_flavor="s3", - provider=valid_stack.provider, - ) - assert not has_valid_flavor_combinations( - stack=valid_stack, - components=[invalid_component], - ) + # valid_stack = Stack( + # name="aria-stack", + # provider="k3d", + # components=[], + # ) + # invalid_component = Component( + # name="blupus-component", + # component_type="artifact_store", + # component_flavor="s3", + # provider="k3d", + # ) + # assert not has_valid_flavor_combinations( + # stack=valid_stack, + # components=[invalid_component], + # ) + + valid = True + try: + Component( + name="blupus-component", + component_type="artifact_store", + component_flavor="s3", + provider="k3d", + ) + except pydantic.error_wrappers.ValidationError: + valid = False + + assert not valid From 1a8ef94c31f3a479d58077e60cba23b4674e4e66 Mon Sep 17 00:00:00 2001 From: Matthew Sisserson Date: Tue, 5 Mar 2024 10:40:41 -0500 Subject: [PATCH 3/6] Changed tests and added test_utils --- src/mlstacks/enums.py | 3 ++ src/mlstacks/utils/test_utils.py | 9 ++++++ tests/unit/models/test_component.py | 36 ++++++++++++++++++++++-- tests/unit/utils/test_terraform_utils.py | 20 +++++++++++-- 4 files changed, 63 insertions(+), 5 deletions(-) create mode 100644 src/mlstacks/utils/test_utils.py diff --git a/src/mlstacks/enums.py b/src/mlstacks/enums.py index 788b6aab..6d400507 100644 --- a/src/mlstacks/enums.py +++ b/src/mlstacks/enums.py @@ -49,6 +49,9 @@ class ComponentFlavorEnum(str, Enum): TEKTON = "tekton" VERTEX = "vertex" ZENML = "zenml" + DEFAULT = "default" + + class DeploymentMethodEnum(str, Enum): diff --git a/src/mlstacks/utils/test_utils.py b/src/mlstacks/utils/test_utils.py new file mode 100644 index 00000000..94785f85 --- /dev/null +++ b/src/mlstacks/utils/test_utils.py @@ -0,0 +1,9 @@ +from typing import List +from mlstacks.enums import ProviderEnum + + +def get_allowed_providers() -> List[str]: + # Filter out AZURE + excluded_providers = ["azure"] + allowed_providers = [provider.value for provider in ProviderEnum if provider.value not in excluded_providers] + return allowed_providers diff --git a/tests/unit/models/test_component.py b/tests/unit/models/test_component.py index a835b7af..0c59e8b6 100644 --- a/tests/unit/models/test_component.py +++ b/tests/unit/models/test_component.py @@ -13,8 +13,10 @@ from hypothesis import given from hypothesis import strategies as st +from hypothesis.strategies import composite +from hypothesis import assume -from mlstacks.constants import PERMITTED_NAME_REGEX +from mlstacks.constants import PERMITTED_NAME_REGEX, ALLOWED_COMPONENT_TYPES from mlstacks.enums import ( ComponentFlavorEnum, ComponentTypeEnum, @@ -23,6 +25,35 @@ from mlstacks.models.component import Component, ComponentMetadata +@composite +def valid_components(draw): + # Drawing a valid provider enum member directly + provider = draw(st.sampled_from([provider for provider in ProviderEnum])) + + # component_types and component_flavors are mappings to strings, + # and model or validation layer handles string to enum conversion: + component_types = list(ALLOWED_COMPONENT_TYPES[provider.value].keys()) + assume(component_types) + component_type = draw(st.sampled_from(component_types)) + + component_flavors = ALLOWED_COMPONENT_TYPES[provider.value][component_type] + assume(component_flavors) + + component_flavor_str = draw(st.sampled_from(component_flavors)) + component_flavor_enum = ComponentFlavorEnum(component_flavor_str) # Convert string to enum + + # Constructing the Component instance with valid fields + return Component( + name=draw(st.from_regex(PERMITTED_NAME_REGEX)), + provider=provider.value, + component_type=component_type, + component_flavor=component_flavor_enum, + spec_version=1, + spec_type="component", + metadata=None + ) + + @given(st.builds(ComponentMetadata)) def test_component_metadata(instance): assert instance.config is None or isinstance(instance.config, dict) @@ -31,7 +62,8 @@ def test_component_metadata(instance): ) -@given(st.builds(Component, name=st.from_regex(PERMITTED_NAME_REGEX))) +# @given(st.builds(Component, name=st.from_regex(PERMITTED_NAME_REGEX), provider=st.sampled_from(["aws", "gcp", "k3d"]))) +@given(valid_components()) def test_component(instance): print(f"instance: {instance}") assert isinstance(instance.spec_version, int) diff --git a/tests/unit/utils/test_terraform_utils.py b/tests/unit/utils/test_terraform_utils.py index 5eab0e3a..e60f08f6 100644 --- a/tests/unit/utils/test_terraform_utils.py +++ b/tests/unit/utils/test_terraform_utils.py @@ -36,6 +36,7 @@ remote_state_bucket_exists, tf_definitions_present, ) +from mlstacks.utils.test_utils import get_allowed_providers EXISTING_S3_BUCKET_URL = "s3://public-flavor-logos" EXISTING_S3_BUCKET_REGION = "eu-central-1" @@ -128,7 +129,11 @@ def test_component_variable_parsing_works(): """Tests that the component variable parsing works.""" metadata = ComponentMetadata() component_flavor = "zenml" - random_test = random.choice(list(ProviderEnum)).value + + # random_test = random.choice(list(ProviderEnum)).value + allowed_providers = get_allowed_providers() + random_test = random.choice(allowed_providers) + print(f"variable parsing: {random_test}") components = [ Component( @@ -151,7 +156,13 @@ def test_component_var_parsing_works_for_env_vars(): """Tests that the component variable parsing works.""" env_vars = {"ARIA_KEY": "blupus"} metadata = ComponentMetadata(environment_variables=env_vars) - random_test = random.choice(list(ProviderEnum)).value + + + # EXCLUDE AZURE + allowed_providers = get_allowed_providers() + random_test = random.choice(allowed_providers) + # random_test = random.choice(list(ProviderEnum)).value + print(f"env vars: {random_test}") components = [ Component( @@ -172,7 +183,10 @@ def test_component_var_parsing_works_for_env_vars(): def test_tf_variable_parsing_from_stack_works(): """Tests that the Terraform variables extraction (from a stack) works.""" - provider = random.choice(list(ProviderEnum)).value + # provider = random.choice(list(ProviderEnum)).value + allowed_providers = get_allowed_providers() + provider = random.choice(allowed_providers) + print(f"parsing_from_stack: {provider}") component_flavor = "zenml" metadata = ComponentMetadata() From 01e1ff1dfba30221702bcd6233a2e1199a20363f Mon Sep 17 00:00:00 2001 From: Matthew Sisserson Date: Wed, 6 Mar 2024 11:09:52 -0500 Subject: [PATCH 4/6] Made changes according to formatter and linter. --- src/mlstacks/constants.py | 4 +++- src/mlstacks/enums.py | 2 -- src/mlstacks/models/component.py | 16 ++++++++----- src/mlstacks/models/stack.py | 6 ++--- src/mlstacks/utils/model_utils.py | 29 ++++++++++++------------ src/mlstacks/utils/test_utils.py | 29 ++++++++++++++++++++++-- src/mlstacks/utils/yaml_utils.py | 11 +++++---- tests/unit/models/test_component.py | 17 +++++++------- tests/unit/utils/test_terraform_utils.py | 6 +---- tests/unit/utils/test_zenml_utils.py | 1 + 10 files changed, 74 insertions(+), 47 deletions(-) diff --git a/src/mlstacks/constants.py b/src/mlstacks/constants.py index fa90dd32..3b88745b 100644 --- a/src/mlstacks/constants.py +++ b/src/mlstacks/constants.py @@ -12,6 +12,8 @@ # permissions and limitations under the License. """MLStacks constants.""" +from typing import Dict, List + MLSTACKS_PACKAGE_NAME = "mlstacks" MLSTACKS_INITIALIZATION_FILE_FLAG = "IGNORE_ME" MLSTACKS_STACK_COMPONENT_FLAGS = [ @@ -39,7 +41,7 @@ "model_deployer": ["seldon"], "step_operator": ["sagemaker", "vertex"], } -ALLOWED_COMPONENT_TYPES = { +ALLOWED_COMPONENT_TYPES: Dict[str, Dict[str, List[str]]] = { "aws": { "artifact_store": ["s3"], "container_registry": ["aws"], diff --git a/src/mlstacks/enums.py b/src/mlstacks/enums.py index 6d400507..122e2806 100644 --- a/src/mlstacks/enums.py +++ b/src/mlstacks/enums.py @@ -51,8 +51,6 @@ class ComponentFlavorEnum(str, Enum): ZENML = "zenml" DEFAULT = "default" - - class DeploymentMethodEnum(str, Enum): """Deployment method enum.""" diff --git a/src/mlstacks/models/component.py b/src/mlstacks/models/component.py index fb3fc6c2..4eed4395 100644 --- a/src/mlstacks/models/component.py +++ b/src/mlstacks/models/component.py @@ -12,7 +12,7 @@ # permissions and limitations under the License. """Component model.""" -from typing import Dict, Optional +from typing import Any, Dict, Optional from pydantic import BaseModel, validator @@ -59,8 +59,8 @@ class Component(BaseModel): metadata: The metadata of the component. """ - spec_version: ComponentSpecVersionEnum = 1 - spec_type: SpecTypeEnum = "component" + spec_version: ComponentSpecVersionEnum = ComponentSpecVersionEnum.ONE + spec_type: SpecTypeEnum = SpecTypeEnum.COMPONENT name: str provider: ProviderEnum component_type: ComponentTypeEnum @@ -68,7 +68,7 @@ class Component(BaseModel): metadata: Optional[ComponentMetadata] = None @validator("name") - def validate_name(cls, name: str) -> str: # noqa: N805 + def validate_name(cls, name: str) -> str: # noqa """Validate the name. Name must start with an alphanumeric character and can only contain @@ -91,7 +91,9 @@ def validate_name(cls, name: str) -> str: # noqa: N805 @validator("component_type") def validate_component_type( - cls, component_type: str, values: dict + cls, # noqa + component_type: str, + values: Dict[str, Any], ) -> str: """Validate the component type. @@ -115,7 +117,9 @@ def validate_component_type( @validator("component_flavor") def validate_component_flavor( - cls, component_flavor: str, values: dict + cls, # noqa + component_flavor: str, + values: Dict[str, Any], ) -> str: """Validate the component flavor. diff --git a/src/mlstacks/models/stack.py b/src/mlstacks/models/stack.py index 4e28f367..b32bdba4 100644 --- a/src/mlstacks/models/stack.py +++ b/src/mlstacks/models/stack.py @@ -40,8 +40,8 @@ class Stack(BaseModel): components: The components of the stack. """ - spec_version: StackSpecVersionEnum = 1 - spec_type: SpecTypeEnum = "stack" + spec_version: StackSpecVersionEnum = StackSpecVersionEnum.ONE + spec_type: SpecTypeEnum = SpecTypeEnum.STACK name: str provider: ProviderEnum default_region: Optional[str] @@ -52,7 +52,7 @@ class Stack(BaseModel): components: List[Component] = [] @validator("name") - def validate_name(cls, name: str) -> str: # noqa: N805 + def validate_name(cls, name: str) -> str: # noqa """Validate the name. Name must start with an alphanumeric character and can only contain diff --git a/src/mlstacks/utils/model_utils.py b/src/mlstacks/utils/model_utils.py index 8f9465b5..3737e056 100644 --- a/src/mlstacks/utils/model_utils.py +++ b/src/mlstacks/utils/model_utils.py @@ -13,6 +13,7 @@ """Util functions for Pydantic models and validation.""" import re +from typing import Any, Dict from mlstacks.constants import ALLOWED_COMPONENT_TYPES, PERMITTED_NAME_REGEX @@ -43,10 +44,13 @@ def is_valid_component_type(component_type: str, provider: str) -> bool: Returns: True if the component type is valid, False otherwise. """ - return component_type in ALLOWED_COMPONENT_TYPES[provider].keys() + allowed_types = list(ALLOWED_COMPONENT_TYPES[provider].keys()) + return component_type in allowed_types -def is_valid_component_flavor(component_flavor: str, specs: dict) -> bool: +def is_valid_component_flavor( + component_flavor: str, specs: Dict[str, Any] +) -> bool: """Check if the component flavor is valid. Used for components. @@ -58,19 +62,14 @@ def is_valid_component_flavor(component_flavor: str, specs: dict) -> bool: Returns: True if the component flavor is valid, False otherwise. """ - print("----------------------") - print(f"component_flavor: {component_flavor}") - print(f"specs: {specs}") try: - t = component_flavor in ALLOWED_COMPONENT_TYPES[specs["provider"]][specs["component_type"]] - except: + is_valid = ( + component_flavor + in ALLOWED_COMPONENT_TYPES[specs["provider"]][ + specs["component_type"] + ] + ) + except ValueError: return False - print(f"result: {t}") - print("----------------------") - - return ( - t - # component_flavor - # in ALLOWED_COMPONENT_TYPES[specs["provider"]][specs["component_type"]] - ) + return is_valid diff --git a/src/mlstacks/utils/test_utils.py b/src/mlstacks/utils/test_utils.py index 94785f85..ea493ab7 100644 --- a/src/mlstacks/utils/test_utils.py +++ b/src/mlstacks/utils/test_utils.py @@ -1,9 +1,34 @@ +# Copyright (c) ZenML GmbH 2023. All Rights Reserved. +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at: +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express +# or implied. See the License for the specific language governing +# permissions and limitations under the License. +"""Util functions for tests.""" + from typing import List + from mlstacks.enums import ProviderEnum def get_allowed_providers() -> List[str]: + """Filter out unimplemented providers. + + Used for component and stack testing. + + Returns: + A list of implemented providers + """ # Filter out AZURE excluded_providers = ["azure"] - allowed_providers = [provider.value for provider in ProviderEnum if provider.value not in excluded_providers] - return allowed_providers + return [ + provider.value + for provider in ProviderEnum + if provider.value not in excluded_providers + ] diff --git a/src/mlstacks/utils/yaml_utils.py b/src/mlstacks/utils/yaml_utils.py index dd66289e..cd1cc5ca 100644 --- a/src/mlstacks/utils/yaml_utils.py +++ b/src/mlstacks/utils/yaml_utils.py @@ -59,15 +59,16 @@ def load_component_yaml(path: str) -> Component: Returns: The component model. """ - try: with open(path) as file: component_data = yaml.safe_load(file) except FileNotFoundError as exc: - # Not sure what to do here, as I'd like to specify the path that caused the problem, but I don't think that's possible while using a constant for the error message. - raise FileNotFoundError( - f'Component file at "{path}" specified in the stack spec file could not be found.' - ) from exc + # Not sure what to do here, as I'd like to specify the path that + # caused the problem, but I don't think that's possible while using + # a constant for the error message. + error_message = f"""Component file at "{path}" specified in + the stack spec file could not be found.""" + raise FileNotFoundError(error_message) from exc if component_data.get("metadata") is None: component_data["metadata"] = {} diff --git a/tests/unit/models/test_component.py b/tests/unit/models/test_component.py index 0c59e8b6..0cd3731d 100644 --- a/tests/unit/models/test_component.py +++ b/tests/unit/models/test_component.py @@ -11,16 +11,15 @@ # or implied. See the License for the specific language governing # permissions and limitations under the License. -from hypothesis import given +from hypothesis import assume, given from hypothesis import strategies as st from hypothesis.strategies import composite -from hypothesis import assume -from mlstacks.constants import PERMITTED_NAME_REGEX, ALLOWED_COMPONENT_TYPES +from mlstacks.constants import ALLOWED_COMPONENT_TYPES, PERMITTED_NAME_REGEX from mlstacks.enums import ( - ComponentFlavorEnum, - ComponentTypeEnum, - ProviderEnum, + ComponentFlavorEnum, + ComponentTypeEnum, + ProviderEnum, ) from mlstacks.models.component import Component, ComponentMetadata @@ -40,7 +39,9 @@ def valid_components(draw): assume(component_flavors) component_flavor_str = draw(st.sampled_from(component_flavors)) - component_flavor_enum = ComponentFlavorEnum(component_flavor_str) # Convert string to enum + component_flavor_enum = ComponentFlavorEnum( + component_flavor_str + ) # Convert string to enum # Constructing the Component instance with valid fields return Component( @@ -50,7 +51,7 @@ def valid_components(draw): component_flavor=component_flavor_enum, spec_version=1, spec_type="component", - metadata=None + metadata=None, ) diff --git a/tests/unit/utils/test_terraform_utils.py b/tests/unit/utils/test_terraform_utils.py index e60f08f6..34377165 100644 --- a/tests/unit/utils/test_terraform_utils.py +++ b/tests/unit/utils/test_terraform_utils.py @@ -129,12 +129,11 @@ def test_component_variable_parsing_works(): """Tests that the component variable parsing works.""" metadata = ComponentMetadata() component_flavor = "zenml" - + # random_test = random.choice(list(ProviderEnum)).value allowed_providers = get_allowed_providers() random_test = random.choice(allowed_providers) - print(f"variable parsing: {random_test}") components = [ Component( name="test", @@ -157,13 +156,11 @@ def test_component_var_parsing_works_for_env_vars(): env_vars = {"ARIA_KEY": "blupus"} metadata = ComponentMetadata(environment_variables=env_vars) - # EXCLUDE AZURE allowed_providers = get_allowed_providers() random_test = random.choice(allowed_providers) # random_test = random.choice(list(ProviderEnum)).value - print(f"env vars: {random_test}") components = [ Component( name="test", @@ -187,7 +184,6 @@ def test_tf_variable_parsing_from_stack_works(): allowed_providers = get_allowed_providers() provider = random.choice(allowed_providers) - print(f"parsing_from_stack: {provider}") component_flavor = "zenml" metadata = ComponentMetadata() components = [ diff --git a/tests/unit/utils/test_zenml_utils.py b/tests/unit/utils/test_zenml_utils.py index 2fcc6b71..3274d119 100644 --- a/tests/unit/utils/test_zenml_utils.py +++ b/tests/unit/utils/test_zenml_utils.py @@ -75,6 +75,7 @@ def test_flavor_combination_validator_fails_aws_gcp(): assert not valid + def test_flavor_combination_validator_fails_k3d_s3(): """Checks that the flavor combination validator fails. From 8f937d92b9f94cafa7f3094ef9c5e6deb7517b8d Mon Sep 17 00:00:00 2001 From: Matthew Sisserson Date: Wed, 27 Mar 2024 09:10:50 -0400 Subject: [PATCH 5/6] Made final changes for pull request. Got rid of comments and print calls. --- src/mlstacks/utils/model_utils.py | 2 +- src/mlstacks/utils/yaml_utils.py | 6 ++ {src/mlstacks/utils => tests}/test_utils.py | 2 +- tests/unit/models/test_component.py | 5 -- tests/unit/utils/test_terraform_utils.py | 7 +- tests/unit/utils/test_zenml_utils.py | 87 +++++++-------------- 6 files changed, 39 insertions(+), 70 deletions(-) rename {src/mlstacks/utils => tests}/test_utils.py (94%) diff --git a/src/mlstacks/utils/model_utils.py b/src/mlstacks/utils/model_utils.py index 3737e056..e42c23d5 100644 --- a/src/mlstacks/utils/model_utils.py +++ b/src/mlstacks/utils/model_utils.py @@ -69,7 +69,7 @@ def is_valid_component_flavor( specs["component_type"] ] ) - except ValueError: + except KeyError: return False return is_valid diff --git a/src/mlstacks/utils/yaml_utils.py b/src/mlstacks/utils/yaml_utils.py index cd1cc5ca..200a3132 100644 --- a/src/mlstacks/utils/yaml_utils.py +++ b/src/mlstacks/utils/yaml_utils.py @@ -58,6 +58,9 @@ def load_component_yaml(path: str) -> Component: Returns: The component model. + + Raises: + FileNotFoundError: If the file is not found. """ try: with open(path) as file: @@ -97,6 +100,9 @@ def load_stack_yaml(path: str) -> Stack: Returns: The stack model. + + Raises: + ValueError: If the stack and component have different providers """ with open(path) as yaml_file: stack_data = yaml.safe_load(yaml_file) diff --git a/src/mlstacks/utils/test_utils.py b/tests/test_utils.py similarity index 94% rename from src/mlstacks/utils/test_utils.py rename to tests/test_utils.py index ea493ab7..6d675268 100644 --- a/src/mlstacks/utils/test_utils.py +++ b/tests/test_utils.py @@ -1,4 +1,4 @@ -# Copyright (c) ZenML GmbH 2023. All Rights Reserved. +# Copyright (c) ZenML GmbH 2024. All Rights Reserved. # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at: diff --git a/tests/unit/models/test_component.py b/tests/unit/models/test_component.py index 0cd3731d..ce8f0b1d 100644 --- a/tests/unit/models/test_component.py +++ b/tests/unit/models/test_component.py @@ -63,7 +63,6 @@ def test_component_metadata(instance): ) -# @given(st.builds(Component, name=st.from_regex(PERMITTED_NAME_REGEX), provider=st.sampled_from(["aws", "gcp", "k3d"]))) @given(valid_components()) def test_component(instance): print(f"instance: {instance}") @@ -73,11 +72,7 @@ def test_component(instance): assert instance.name is not None assert instance.spec_version is not None assert instance.spec_type is not None - print("!!!!!!!!!!!!!!!!!!!!!!!!") - print("just prior to component type test") assert isinstance(instance.component_type, ComponentTypeEnum) - print("just after component type test") - print("!!!!!!!!!!!!!!!!!!!!!!!!") assert isinstance(instance.component_flavor, ComponentFlavorEnum) assert isinstance(instance.provider, str) assert instance.provider is not None diff --git a/tests/unit/utils/test_terraform_utils.py b/tests/unit/utils/test_terraform_utils.py index 34377165..69abd74d 100644 --- a/tests/unit/utils/test_terraform_utils.py +++ b/tests/unit/utils/test_terraform_utils.py @@ -36,7 +36,7 @@ remote_state_bucket_exists, tf_definitions_present, ) -from mlstacks.utils.test_utils import get_allowed_providers +from tests.test_utils import get_allowed_providers EXISTING_S3_BUCKET_URL = "s3://public-flavor-logos" EXISTING_S3_BUCKET_REGION = "eu-central-1" @@ -117,8 +117,6 @@ def test_enable_key_function_handles_components_without_flavors( name=dummy_name, component_flavor=comp_flavor, component_type=comp_type, - # provider=random.choice(list(ProviderEnum)).value, - # Not sure why the above line was used when only "aws" is valid here provider=comp_provider, ) key = _compose_enable_key(c) @@ -130,7 +128,6 @@ def test_component_variable_parsing_works(): metadata = ComponentMetadata() component_flavor = "zenml" - # random_test = random.choice(list(ProviderEnum)).value allowed_providers = get_allowed_providers() random_test = random.choice(allowed_providers) @@ -159,7 +156,6 @@ def test_component_var_parsing_works_for_env_vars(): # EXCLUDE AZURE allowed_providers = get_allowed_providers() random_test = random.choice(allowed_providers) - # random_test = random.choice(list(ProviderEnum)).value components = [ Component( @@ -180,7 +176,6 @@ def test_component_var_parsing_works_for_env_vars(): def test_tf_variable_parsing_from_stack_works(): """Tests that the Terraform variables extraction (from a stack) works.""" - # provider = random.choice(list(ProviderEnum)).value allowed_providers = get_allowed_providers() provider = random.choice(allowed_providers) diff --git a/tests/unit/utils/test_zenml_utils.py b/tests/unit/utils/test_zenml_utils.py index 3274d119..cc72771c 100644 --- a/tests/unit/utils/test_zenml_utils.py +++ b/tests/unit/utils/test_zenml_utils.py @@ -12,7 +12,6 @@ # permissions and limitations under the License. """Tests for utilities for mlstacks-ZenML interaction.""" -import pydantic from mlstacks.models.component import Component from mlstacks.models.stack import Stack @@ -46,34 +45,21 @@ def test_flavor_combination_validator_fails_aws_gcp(): Tests a known failure case. (AWS Stack with a GCP artifact store.) """ - # valid_stack = Stack( - # name="aria-stack", - # provider="aws", - # components=[], - # ) - # invalid_component = Component( - # name="blupus-component", - # component_type="artifact_store", - # component_flavor="gcp", - # provider=valid_stack.provider, - # ) - # assert not has_valid_flavor_combinations( - # stack=valid_stack, - # components=[invalid_component], - # ) - - valid = True - try: - Component( - name="blupus-component", - component_type="artifact_store", - component_flavor="gcp", - provider="aws", - ) - except pydantic.error_wrappers.ValidationError: - valid = False - - assert not valid + valid_stack = Stack( + name="aria-stack", + provider="aws", + components=[], + ) + invalid_component = Component( + name="blupus-component", + component_type="artifact_store", + component_flavor="gcp", + provider="gcp", + ) + assert not has_valid_flavor_combinations( + stack=valid_stack, + components=[invalid_component], + ) def test_flavor_combination_validator_fails_k3d_s3(): @@ -81,31 +67,18 @@ def test_flavor_combination_validator_fails_k3d_s3(): Tests a known failure case. (K3D Stack with a S3 artifact store.) """ - # valid_stack = Stack( - # name="aria-stack", - # provider="k3d", - # components=[], - # ) - # invalid_component = Component( - # name="blupus-component", - # component_type="artifact_store", - # component_flavor="s3", - # provider="k3d", - # ) - # assert not has_valid_flavor_combinations( - # stack=valid_stack, - # components=[invalid_component], - # ) - - valid = True - try: - Component( - name="blupus-component", - component_type="artifact_store", - component_flavor="s3", - provider="k3d", - ) - except pydantic.error_wrappers.ValidationError: - valid = False - - assert not valid + valid_stack = Stack( + name="aria-stack", + provider="k3d", + components=[], + ) + invalid_component = Component( + name="blupus-component", + component_type="artifact_store", + component_flavor="s3", + provider="aws", + ) + assert not has_valid_flavor_combinations( + stack=valid_stack, + components=[invalid_component], + ) From 511367e960337369b3cddbd29bb9134c29582183 Mon Sep 17 00:00:00 2001 From: Matthew Sisserson Date: Tue, 2 Apr 2024 11:03:25 -0400 Subject: [PATCH 6/6] Removed a comment in yaml_utils.py --- src/mlstacks/utils/yaml_utils.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/mlstacks/utils/yaml_utils.py b/src/mlstacks/utils/yaml_utils.py index 200a3132..0ef0734a 100644 --- a/src/mlstacks/utils/yaml_utils.py +++ b/src/mlstacks/utils/yaml_utils.py @@ -66,9 +66,6 @@ def load_component_yaml(path: str) -> Component: with open(path) as file: component_data = yaml.safe_load(file) except FileNotFoundError as exc: - # Not sure what to do here, as I'd like to specify the path that - # caused the problem, but I don't think that's possible while using - # a constant for the error message. error_message = f"""Component file at "{path}" specified in the stack spec file could not be found.""" raise FileNotFoundError(error_message) from exc