diff --git a/.github/workflows/master.yml b/.github/workflows/master.yml index 1b7154f17a3e2..0d86bc963a674 100644 --- a/.github/workflows/master.yml +++ b/.github/workflows/master.yml @@ -41,7 +41,10 @@ jobs: run: | source ./dev/install-common-deps.sh pip install -r requirements/lint-requirements.txt - - name: Run tests + - name: Test custom pylint-plugins + run : | + pytest tests/pylint_plugins + - name: Run lint checks run: | ./dev/lint.sh r: diff --git a/mlflow/entities/run_info.py b/mlflow/entities/run_info.py index b35a4dbc7ee12..3b7d03a9ded05 100644 --- a/mlflow/entities/run_info.py +++ b/mlflow/entities/run_info.py @@ -4,19 +4,22 @@ from mlflow.exceptions import MlflowException from mlflow.protos.service_pb2 import RunInfo as ProtoRunInfo +from mlflow.protos.databricks_pb2 import INVALID_PARAMETER_VALUE def check_run_is_active(run_info): if run_info.lifecycle_stage != LifecycleStage.ACTIVE: raise MlflowException( - "The run {} must be in 'active' lifecycle_stage.".format(run_info.run_id) + "The run {} must be in 'active' lifecycle_stage.".format(run_info.run_id), + error_code=INVALID_PARAMETER_VALUE, ) def check_run_is_deleted(run_info): if run_info.lifecycle_stage != LifecycleStage.DELETED: raise MlflowException( - "The run {} must be in 'deleted' lifecycle_stage.".format(run_info.run_id) + "The run {} must be in 'deleted' lifecycle_stage.".format(run_info.run_id), + error_code=INVALID_PARAMETER_VALUE, ) diff --git a/mlflow/utils/uri.py b/mlflow/utils/uri.py index 55e346d1144f9..b4354673c7e6e 100644 --- a/mlflow/utils/uri.py +++ b/mlflow/utils/uri.py @@ -258,7 +258,7 @@ def is_databricks_model_registry_artifacts_uri(artifact_uri): def construct_run_url(hostname, experiment_id, run_id, workspace_id=None): if not hostname or not experiment_id or not run_id: raise MlflowException( - "Hostname, experiment ID, and run ID are all required to construct" "a run URL" + "Hostname, experiment ID, and run ID are all required to construct a run URL" ) prefix = hostname if workspace_id and workspace_id != "0": diff --git a/pylint_plugins/__init__.py b/pylint_plugins/__init__.py new file mode 100644 index 0000000000000..7d24df68fc9bb --- /dev/null +++ b/pylint_plugins/__init__.py @@ -0,0 +1,5 @@ +from .pytest_raises_without_match import PytestRaisesWithoutMatch + + +def register(linter): + linter.register_checker(PytestRaisesWithoutMatch(linter)) diff --git a/pylint_plugins/pytest_raises_without_match/README.md b/pylint_plugins/pytest_raises_without_match/README.md new file mode 100644 index 0000000000000..387871a8299ab --- /dev/null +++ b/pylint_plugins/pytest_raises_without_match/README.md @@ -0,0 +1,52 @@ +# `pytest-raises-without-match` + +This custom pylint rule disallows calling `pytest.raises` without a `match` argument +to avoid capturing unintended exceptions and eliminate false-positive tests. + +## Example + +Suppose we want to test this function throws `Exception("bar")` when `condition2` is satisfied. + +```python +def func(): + if condition1: + raise Exception("foo") + + if condition2: + raise Exception("bar") +``` + +### Bad + +```python +def test_func(): + with pytest.raises(Exception): + func() +``` + +- This test passes when `condition1` is unintentionally satisfied. +- Future code readers will struggle to identify which exception `pytest.raises` should match. + +### Good + +```python +def test_func(): + with pytest.raises(Exception, match="bar"): + func() +``` + +- This test fails when `condition1` is unintentionally satisfied. +- Future code readers can quickly identify which exception `pytest.raises` should match by searching `bar`. + +## How to disable this rule + +```python +def test_func(): + with pytest.raises(Exception): # pylint: disable=pytest-raises-without-match + func() +``` + +## References + +- https://docs.pytest.org/en/latest/how-to/assert.html#assertions-about-expected-exceptions +- https://docs.pytest.org/en/latest/reference/reference.html#pytest.raises diff --git a/pylint_plugins/pytest_raises_without_match/__init__.py b/pylint_plugins/pytest_raises_without_match/__init__.py new file mode 100644 index 0000000000000..21f0ff92a50aa --- /dev/null +++ b/pylint_plugins/pytest_raises_without_match/__init__.py @@ -0,0 +1,38 @@ +import astroid +from pylint.interfaces import IAstroidChecker +from pylint.checkers import BaseChecker + + +class PytestRaisesWithoutMatch(BaseChecker): + __implements__ = IAstroidChecker + + name = "pytest-raises-without-match" + msgs = { + "W0001": ( + "`pytest.raises` must be called with `match` argument` ", + name, + "Use `pytest.raises(, match=...)`", + ), + } + priority = -1 + + @staticmethod + def _is_pytest_raises_call(node: astroid.Call): + if not isinstance(node.func, astroid.Attribute) or not isinstance( + node.func.expr, astroid.Name + ): + return False + return node.func.expr.name == "pytest" and node.func.attrname == "raises" + + @staticmethod + def _called_with_match(node: astroid.Call): + # Note `match` is a keyword-only argument: + # https://docs.pytest.org/en/latest/reference/reference.html#pytest.raises + return any(k.arg == "match" for k in node.keywords) + + def visit_call(self, node: astroid.Call): + if not PytestRaisesWithoutMatch._is_pytest_raises_call(node): + return + + if not PytestRaisesWithoutMatch._called_with_match(node): + self.add_message(self.name, node=node) diff --git a/pylintrc b/pylintrc index cb8bc449d36e2..2c83f53f8bf50 100644 --- a/pylintrc +++ b/pylintrc @@ -33,7 +33,7 @@ jobs=0 # List of plugins (as comma separated values of python modules names) to load, # usually to register additional checkers. -load-plugins= +load-plugins=pylint_plugins # Pickle collected data for later comparisons. persistent=yes diff --git a/tests/autologging/test_autologging_behaviors_unit.py b/tests/autologging/test_autologging_behaviors_unit.py index d545fc1d3a617..199ff5f9b3f38 100644 --- a/tests/autologging/test_autologging_behaviors_unit.py +++ b/tests/autologging/test_autologging_behaviors_unit.py @@ -224,7 +224,7 @@ def parallel_fn(): time.sleep(np.random.random()) patch_destination.fn() - with pytest.raises(Exception): + with pytest.raises(Exception, match="enablement error"): test_autolog(silent=True) with pytest.warns(None): diff --git a/tests/autologging/test_autologging_client.py b/tests/autologging/test_autologging_client.py index ea1ab706c4974..18a656a12c46d 100644 --- a/tests/autologging/test_autologging_client.py +++ b/tests/autologging/test_autologging_client.py @@ -233,7 +233,7 @@ def test_client_correctly_operates_as_context_manager_for_synchronous_flush(): assert run_tags_1 == tags_to_log exc_to_raise = Exception("test exception") - with pytest.raises(Exception) as raised_exc_info: + with pytest.raises(Exception, match=str(exc_to_raise)) as raised_exc_info: with mlflow.start_run(), MlflowAutologgingQueueingClient() as client: run_id_2 = mlflow.active_run().info.run_id client.log_params(run_id_2, params_to_log) @@ -264,7 +264,7 @@ def test_logging_failures_are_handled_as_expected(): client.log_metrics(run_id=pending_run_id, metrics={"a": 1}) client.set_terminated(run_id=pending_run_id, status="KILLED") - with pytest.raises(MlflowException) as exc: + with pytest.raises(MlflowException, match="Batch logging failed!") as exc: client.flush() runs = mlflow.search_runs(experiment_ids=[experiment_id], output_format="list") diff --git a/tests/autologging/test_autologging_safety_unit.py b/tests/autologging/test_autologging_safety_unit.py index b886f1202a84b..ef9be122fa1ce 100644 --- a/tests/autologging/test_autologging_safety_unit.py +++ b/tests/autologging/test_autologging_safety_unit.py @@ -282,7 +282,7 @@ def patch_impl(original, *args, **kwargs): safe_patch(test_autologging_integration, patch_destination, "fn", patch_impl) - with pytest.raises(Exception) as exc: + with pytest.raises(Exception, match=str(exc_to_throw)) as exc: patch_destination.fn() assert exc.value == exc_to_throw @@ -319,7 +319,7 @@ def patch_impl(original, *args, **kwargs): raise exc_to_throw safe_patch(test_autologging_integration, patch_destination, "fn", patch_impl) - with pytest.raises(Exception) as exc: + with pytest.raises(Exception, match=str(exc_to_throw)) as exc: patch_destination.fn() assert exc.value == exc_to_throw @@ -860,7 +860,7 @@ def non_throwing_function(): def throwing_function(): raise exc_to_throw - with pytest.raises(Exception) as exc: + with pytest.raises(Exception, match=str(exc_to_throw)) as exc: throwing_function() assert exc.value == exc_to_throw @@ -913,7 +913,7 @@ class ThrowingClass(baseclass, metaclass=metaclass): def function(self): raise exc_to_throw - with pytest.raises(Exception) as exc: + with pytest.raises(Exception, match=str(exc_to_throw)) as exc: ThrowingClass().function() assert exc.value == exc_to_throw @@ -998,14 +998,14 @@ def patch_function(original, *args, **kwargs): patch_function = with_managed_run("test_integration", patch_function) - with pytest.raises(Exception): + with pytest.raises(Exception, match="bad implementation"): patch_function(lambda: "foo") assert patch_function_active_run is not None status1 = client.get_run(patch_function_active_run.info.run_id).info.status assert RunStatus.from_string(status1) == RunStatus.FAILED - with mlflow.start_run() as active_run, pytest.raises(Exception): + with mlflow.start_run() as active_run, pytest.raises(Exception, match="bad implementation"): patch_function(lambda: "foo") assert patch_function_active_run == active_run # `with_managed_run` should not terminate a preexisting MLflow run, @@ -1053,14 +1053,14 @@ def _on_exception(self, exception): TestPatch = with_managed_run("test_integration", TestPatch) - with pytest.raises(Exception): + with pytest.raises(Exception, match="bad implementation"): TestPatch.call(lambda: "foo") assert patch_function_active_run is not None status1 = client.get_run(patch_function_active_run.info.run_id).info.status assert RunStatus.from_string(status1) == RunStatus.FAILED - with mlflow.start_run() as active_run, pytest.raises(Exception): + with mlflow.start_run() as active_run, pytest.raises(Exception, match="bad implementation"): TestPatch.call(lambda: "foo") assert patch_function_active_run == active_run # `with_managed_run` should not terminate a preexisting MLflow run, @@ -1108,7 +1108,7 @@ def original(): "test_integration", lambda original, *args, **kwargs: original(*args, **kwargs) ) - with pytest.raises(KeyboardInterrupt): + with pytest.raises(KeyboardInterrupt, match=""): patch_function_1(original) assert not mlflow.active_run() @@ -1124,7 +1124,7 @@ def _on_exception(self, exception): patch_function_2 = with_managed_run("test_integration", PatchFunction2) - with pytest.raises(KeyboardInterrupt): + with pytest.raises(KeyboardInterrupt, match=""): patch_function_2.call(original) @@ -1418,8 +1418,8 @@ def patch_fn(original): # If use safe_patch to patch, exception would not come from original fn and so would be logged patch_destination.fn = patch_fn - with pytest.raises(Exception): - patch_destination.fn() + with pytest.raises(Exception, match="Exception that should stop autologging session"): + patch_destination.fn(lambda: None) assert _AutologgingSessionManager.active_session() is None @@ -1573,7 +1573,7 @@ def _predict(self, X, a, b): @property def predict(self): if not self._has_predict: - raise AttributeError() + raise AttributeError("does not have predict") return self._predict class ExtendedEstimator(BaseEstimator): @@ -1624,7 +1624,7 @@ def autolog(disable=False, exclusive=False, silent=False): # pylint: disable=un bad_estimator = EstimatorCls(has_predict=False) assert not hasattr(bad_estimator, "predict") - with pytest.raises(AttributeError): + with pytest.raises(AttributeError, match="does not have predict"): bad_estimator.predict(X=1, a=2, b=3) autolog(disable=True) diff --git a/tests/autologging/test_autologging_utils.py b/tests/autologging/test_autologging_utils.py index 8782940ab6c06..f520045b7d7fc 100644 --- a/tests/autologging/test_autologging_utils.py +++ b/tests/autologging/test_autologging_utils.py @@ -911,9 +911,9 @@ def f4(self, *args, **kwargs): assert 3 == get_instance_method_first_arg_value(Test.f1, [3], {"cd2": 4}) assert 3 == get_instance_method_first_arg_value(Test.f1, [], {"ab1": 3, "cd2": 4}) assert 3 == get_instance_method_first_arg_value(Test.f2, [3, 4], {}) - with pytest.raises(AssertionError): + with pytest.raises(AssertionError, match=""): get_instance_method_first_arg_value(Test.f3, [], {"ab1": 3, "cd2": 4}) - with pytest.raises(AssertionError): + with pytest.raises(AssertionError, match=""): get_instance_method_first_arg_value(Test.f4, [], {"ab1": 3, "cd2": 4}) diff --git a/tests/azureml/test_deploy.py b/tests/azureml/test_deploy.py index 34cc607997581..28bedf8e30b37 100644 --- a/tests/azureml/test_deploy.py +++ b/tests/azureml/test_deploy.py @@ -287,7 +287,9 @@ def test_deploy_throws_exception_if_model_does_not_contain_pyfunc_flavor(sklearn del model_config.flavors[pyfunc.FLAVOR_NAME] model_config.save(model_config_path) - with AzureMLMocks(), pytest.raises(MlflowException) as exc: + with AzureMLMocks(), pytest.raises( + MlflowException, match="does not contain the `python_function` flavor" + ) as exc: workspace = get_azure_workspace() mlflow.azureml.deploy(model_uri=model_path, workspace=workspace) assert exc.error_code == INVALID_PARAMETER_VALUE @@ -304,7 +306,7 @@ def test_deploy_throws_exception_if_model_python_version_is_less_than_three( model_config.flavors[pyfunc.FLAVOR_NAME][pyfunc.PY_VERSION] = "2.7.6" model_config.save(model_config_path) - with AzureMLMocks(), pytest.raises(MlflowException) as exc: + with AzureMLMocks(), pytest.raises(MlflowException, match="Python 3 and above") as exc: workspace = get_azure_workspace() mlflow.azureml.deploy(model_uri=model_path, workspace=workspace) assert exc.error_code == INVALID_PARAMETER_VALUE diff --git a/tests/azureml/test_image_creation.py b/tests/azureml/test_image_creation.py index 87886a9db279c..7ba33af37f49d 100644 --- a/tests/azureml/test_image_creation.py +++ b/tests/azureml/test_image_creation.py @@ -417,7 +417,9 @@ def test_build_image_throws_exception_if_model_does_not_contain_pyfunc_flavor( del model_config.flavors[pyfunc.FLAVOR_NAME] model_config.save(model_config_path) - with AzureMLMocks(), pytest.raises(MlflowException) as exc: + with AzureMLMocks(), pytest.raises( + MlflowException, match="does not contain the `python_function` flavor" + ) as exc: workspace = get_azure_workspace() mlflow.azureml.build_image(model_uri=model_path, workspace=workspace) assert exc.error_code == INVALID_PARAMETER_VALUE @@ -434,7 +436,7 @@ def test_build_image_throws_exception_if_model_python_version_is_less_than_three model_config.flavors[pyfunc.FLAVOR_NAME][pyfunc.PY_VERSION] = "2.7.6" model_config.save(model_config_path) - with AzureMLMocks(), pytest.raises(MlflowException) as exc: + with AzureMLMocks(), pytest.raises(MlflowException, match="Python 3 and above") as exc: workspace = get_azure_workspace() mlflow.azureml.build_image(model_uri=model_path, workspace=workspace) assert exc.error_code == INVALID_PARAMETER_VALUE diff --git a/tests/data/test_data.py b/tests/data/test_data.py index d98ce75ccfc31..6a010f677e922 100644 --- a/tests/data/test_data.py +++ b/tests/data/test_data.py @@ -50,7 +50,7 @@ def test_download_uri(): # Verify exceptions are thrown when downloading from unsupported/invalid URIs invalid_prefixes = ["file://", "/tmp"] for prefix in invalid_prefixes: - with temp_directory() as dst_dir, pytest.raises(DownloadException): + with temp_directory() as dst_dir, pytest.raises(DownloadException, match="`uri` must be"): download_uri( uri=os.path.join(prefix, "some/path"), output_path=os.path.join(dst_dir, "tmp-file") ) diff --git a/tests/deployments/test_deployments.py b/tests/deployments/test_deployments.py index e4b75753c27ec..366d4254c44d1 100644 --- a/tests/deployments/test_deployments.py +++ b/tests/deployments/test_deployments.py @@ -45,7 +45,9 @@ def test_get_success(): def test_wrong_target_name(): - with pytest.raises(MlflowException): + with pytest.raises( + MlflowException, match='No plugin found for managing model deployments to "wrong_target"' + ): deployments.get_deploy_client("wrong_target") @@ -56,7 +58,7 @@ class DummyPlugin: dummy_plugin = DummyPlugin() plugin_manager = DeploymentPlugins() plugin_manager.registry["dummy"] = dummy_plugin - with pytest.raises(MlflowException): + with pytest.raises(MlflowException, match="Plugin registered for the target dummy"): plugin_manager["dummy"] # pylint: disable=pointless-statement @@ -64,7 +66,7 @@ def test_plugin_raising_error(): client = deployments.get_deploy_client(f_target) # special case to raise error os.environ["raiseError"] = "True" - with pytest.raises(RuntimeError): + with pytest.raises(RuntimeError, match="Error requested"): client.list_deployments() os.environ["raiseError"] = "False" diff --git a/tests/entities/test_run.py b/tests/entities/test_run.py index b4be65b77b306..090fc22dfe3dc 100644 --- a/tests/entities/test_run.py +++ b/tests/entities/test_run.py @@ -95,6 +95,6 @@ def test_string_repr(self): def test_creating_run_with_absent_info_throws_exception(self): run_data = TestRunData._create()[0] - with pytest.raises(MlflowException) as no_info_exc: + with pytest.raises(MlflowException, match="run_info cannot be None") as no_info_exc: Run(None, run_data) assert "run_info cannot be None" in str(no_info_exc) diff --git a/tests/helper_functions.py b/tests/helper_functions.py index c15fe34262dbb..edbd614d3e99a 100644 --- a/tests/helper_functions.py +++ b/tests/helper_functions.py @@ -445,3 +445,11 @@ def mock_method_chain(mock_obj, methods, return_value=None, side_effect=None): def multi_context(*cms): with ExitStack() as stack: yield list(map(stack.enter_context, cms)) + + +class StartsWithMatcher: + def __init__(self, prefix): + self.prefix = prefix + + def __eq__(self, other): + return isinstance(other, str) and other.startswith(self.prefix) diff --git a/tests/keras/test_keras_model_export.py b/tests/keras/test_keras_model_export.py index 57a633dda8f49..58382662b2ff3 100644 --- a/tests/keras/test_keras_model_export.py +++ b/tests/keras/test_keras_model_export.py @@ -229,7 +229,7 @@ def _import_module(name, **kwargs): import_module_mock.side_effect = _import_module x = MyModel("x123") path0 = os.path.join(model_path, "0") - with pytest.raises(MlflowException): + with pytest.raises(MlflowException, match="Unable to infer keras module from the model"): mlflow.keras.save_model(x, path0) mlflow.keras.save_model(x, path0, keras_module=FakeKerasModule, save_format="h5") y = mlflow.keras.load_model(path0) @@ -240,7 +240,9 @@ def _import_module(name, **kwargs): assert x == z # Tests model log with mlflow.start_run() as active_run: - with pytest.raises(MlflowException): + with pytest.raises( + MlflowException, match="Unable to infer keras module from the model" + ): mlflow.keras.log_model(x, "model0") mlflow.keras.log_model(x, "model0", keras_module=FakeKerasModule, save_format="h5") a = mlflow.keras.load_model("runs:/{}/model0".format(active_run.info.run_id)) @@ -371,7 +373,7 @@ def __call__(self): mlflow.keras.save_model(custom_model, model_path, custom_objects=incorrect_custom_objects) model_loaded = mlflow.keras.load_model(model_path, custom_objects=correct_custom_objects) assert model_loaded is not None - with pytest.raises(TypeError): + with pytest.raises(TypeError, match=r".+"): model_loaded = mlflow.keras.load_model(model_path) diff --git a/tests/mleap/test_mleap_model_export.py b/tests/mleap/test_mleap_model_export.py index 86f0ce7fecc5f..e4da9050c0096 100644 --- a/tests/mleap/test_mleap_model_export.py +++ b/tests/mleap/test_mleap_model_export.py @@ -4,6 +4,7 @@ from packaging.version import Version import numpy as np +import pandas as pd import pyspark from pyspark.ml.pipeline import Pipeline from pyspark.ml.wrapper import JavaModel @@ -124,7 +125,10 @@ def _transform(self, dataset): unsupported_pipeline = Pipeline(stages=[CustomTransformer()]) unsupported_model = unsupported_pipeline.fit(spark_model_iris.spark_df) - with pytest.raises(mlflow.mleap.MLeapSerializationException): + with pytest.raises( + mlflow.mleap.MLeapSerializationException, + match="MLeap encountered an error while serializing the model", + ): mlflow.mleap.save_model( spark_model=unsupported_model, path=model_path, sample_input=spark_model_iris.spark_df ) @@ -174,3 +178,14 @@ def test_spark_module_model_save_with_relative_path_and_valid_sample_input_produ assert os.path.exists(config_path) config = Model.load(config_path) assert mlflow.mleap.FLAVOR_NAME in config.flavors + + +@pytest.mark.large +def test_mleap_module_model_save_with_invalid_sample_input_type_raises_exception( + spark_model_iris, model_path +): + with pytest.raises(Exception, match="must be a PySpark dataframe"): + invalid_input = pd.DataFrame() + mlflow.spark.save_model( + spark_model=spark_model_iris.model, path=model_path, sample_input=invalid_input + ) diff --git a/tests/models/test_model_input_examples.py b/tests/models/test_model_input_examples.py index 4900e64e102ca..7641b83a28af5 100644 --- a/tests/models/test_model_input_examples.py +++ b/tests/models/test_model_input_examples.py @@ -130,7 +130,7 @@ def test_input_examples(pandas_df_with_all_types, dict_of_ndarrays): # pass multidimensional array as a list example = np.array([[1, 2, 3]]) - with pytest.raises(TensorsNotSupportedException): + with pytest.raises(TensorsNotSupportedException, match=r"Row '0' has shape \(1, 3\)"): _Example([example, example]) # pass dict with scalars diff --git a/tests/projects/test_databricks.py b/tests/projects/test_databricks.py index 2ba7fdfdda4a7..366ba356bc4cc 100644 --- a/tests/projects/test_databricks.py +++ b/tests/projects/test_databricks.py @@ -108,7 +108,7 @@ def dbfs_mocks(dbfs_path_exists_mock, upload_to_dbfs_mock): # pylint: disable=u @pytest.fixture() -def before_run_validations_mock(): # pylint: disable=unused-argument +def before_run_validations_mock(): with mock.patch("mlflow.projects.databricks.before_run_validations"): yield @@ -206,14 +206,13 @@ def test_dbfs_path_exists_error_response_handling(response_mock): http_request_mock.return_value = response_mock # then _dbfs_path_exists should return a MlflowException - with pytest.raises(MlflowException): + with pytest.raises(MlflowException, match="API request to check existence of file at DBFS"): job_runner._dbfs_path_exists("some/path") def test_run_databricks_validations( tmpdir, - cluster_spec_mock, # pylint: disable=unused-argument - tracking_uri_mock, + cluster_spec_mock, dbfs_mocks, set_tag_mock, ): # pylint: disable=unused-argument @@ -226,8 +225,8 @@ def test_run_databricks_validations( "mlflow.projects.databricks.DatabricksJobRunner._databricks_api_request" ) as db_api_req_mock: # Test bad tracking URI - tracking_uri_mock.return_value = tmpdir.strpath - with pytest.raises(ExecutionException): + mlflow.set_tracking_uri(tmpdir.strpath) + with pytest.raises(ExecutionException, match="MLflow tracking URI must be of"): run_databricks_project(cluster_spec_mock, synchronous=True) assert db_api_req_mock.call_count == 0 db_api_req_mock.reset_mock() @@ -235,9 +234,11 @@ def test_run_databricks_validations( assert ( len(mlflow_service.list_run_infos(experiment_id=FileStore.DEFAULT_EXPERIMENT_ID)) == 0 ) - tracking_uri_mock.return_value = "http://" + mlflow.set_tracking_uri("databricks") # Test misspecified parameters - with pytest.raises(ExecutionException): + with pytest.raises( + ExecutionException, match="No value given for missing parameters: 'name'" + ): mlflow.projects.run( TEST_PROJECT_DIR, backend="databricks", @@ -247,7 +248,7 @@ def test_run_databricks_validations( assert db_api_req_mock.call_count == 0 db_api_req_mock.reset_mock() # Test bad cluster spec - with pytest.raises(ExecutionException): + with pytest.raises(ExecutionException, match="Backend spec must be provided"): mlflow.projects.run( TEST_PROJECT_DIR, backend="databricks", synchronous=True, backend_config=None ) @@ -380,9 +381,10 @@ def test_run_databricks_throws_exception_when_spec_uses_existing_cluster(): existing_cluster_spec = { "existing_cluster_id": "1000-123456-clust1", } - with pytest.raises(MlflowException) as exc: + with pytest.raises( + MlflowException, match="execution against existing clusters is not currently supported" + ) as exc: run_databricks_project(cluster_spec=existing_cluster_spec) - assert "execution against existing clusters is not currently supported" in exc.value.message assert exc.value.error_code == ErrorCode.Name(INVALID_PARAMETER_VALUE) @@ -407,7 +409,7 @@ def test_run_databricks_cancel( assert runs_cancel_mock.call_count == 1 # Test that we raise an exception when a blocking Databricks run fails runs_get_mock.return_value = mock_runs_get_result(succeeded=False) - with pytest.raises(mlflow.projects.ExecutionException): + with pytest.raises(mlflow.projects.ExecutionException, match=r"Run \(ID '.+'\) failed"): run_databricks_project(cluster_spec_mock, synchronous=True) @@ -473,7 +475,9 @@ def test_run_databricks_failed(_): text = '{"error_code": "RESOURCE_DOES_NOT_EXIST", "message": "Node type not supported"}' m.return_value = mock.Mock(text=text, status_code=400) runner = DatabricksJobRunner(construct_db_uri_from_profile("profile")) - with pytest.raises(MlflowException): + with pytest.raises( + MlflowException, match="RESOURCE_DOES_NOT_EXIST: Node type not supported" + ): runner._run_shell_command_job("/project", "command", {}, {}) diff --git a/tests/projects/test_docker_projects.py b/tests/projects/test_docker_projects.py index 813546ac6455b..d696261820ef9 100644 --- a/tests/projects/test_docker_projects.py +++ b/tests/projects/test_docker_projects.py @@ -132,8 +132,8 @@ def test_docker_project_tracking_uri_propagation( def test_docker_uri_mode_validation(docker_example_base_image): # pylint: disable=unused-argument - with pytest.raises(ExecutionException): - mlflow.projects.run(TEST_DOCKER_PROJECT_DIR, backend="databricks") + with pytest.raises(ExecutionException, match="When running on Databricks"): + mlflow.projects.run(TEST_DOCKER_PROJECT_DIR, backend="databricks", backend_config={}) @mock.patch("mlflow.projects.docker._get_git_commit") @@ -162,7 +162,7 @@ def test_docker_invalid_project_backend_local(): work_dir = "./examples/docker" project = _project_spec.load_project(work_dir) project.name = None - with pytest.raises(ExecutionException): + with pytest.raises(ExecutionException, match="Project name in MLProject must be specified"): mlflow.projects.docker.validate_docker_env(project) @@ -253,7 +253,7 @@ def test_docker_user_specified_env_vars(volumes, environment, expected, os_envir if "should_crash" in expected: expected.remove("should_crash") - with pytest.raises(MlflowException): + with pytest.raises(MlflowException, match="This project expects"): with mock.patch.dict("os.environ", os_environ): _get_docker_command(image, active_run, None, volumes, environment) else: diff --git a/tests/projects/test_entry_point.py b/tests/projects/test_entry_point.py index 35f1245731141..b0ec95b91a7fd 100644 --- a/tests/projects/test_entry_point.py +++ b/tests/projects/test_entry_point.py @@ -30,7 +30,9 @@ def test_entry_point_compute_params(): assert params == {"name": "friend", "greeting": "hello"} assert extra_params == {} # Raise exception on missing required parameter - with pytest.raises(ExecutionException): + with pytest.raises( + ExecutionException, match="No value given for missing parameters: 'name'" + ): entry_point.compute_parameters({}, storage_dir) @@ -44,7 +46,9 @@ def test_entry_point_compute_command(): storage_dir = tmp.path() command = entry_point.compute_command({"name": "friend", "excitement": 10}, storage_dir) assert command == "python greeter.py hi friend --excitement 10" - with pytest.raises(ExecutionException): + with pytest.raises( + ExecutionException, match="No value given for missing parameters: 'name'" + ): entry_point.compute_command({}, storage_dir) # Test shell escaping name_value = "friend; echo 'hi'" @@ -81,7 +85,7 @@ def test_path_parameter(): # Verify that we raise an exception when passing a non-existent local file to a # parameter of type "path" - with TempDir() as tmp, pytest.raises(ExecutionException): + with TempDir() as tmp, pytest.raises(ExecutionException, match="no such file or directory"): dst_dir = tmp.path() entry_point.compute_parameters( user_parameters={"path": os.path.join(dst_dir, "some/nonexistent/file")}, @@ -116,7 +120,7 @@ def test_uri_parameter(): ) assert download_uri_mock.call_count == 0 # Test that we raise an exception if a local path is passed to a parameter of type URI - with pytest.raises(ExecutionException): + with pytest.raises(ExecutionException, match="Expected URI for parameter uri"): entry_point.compute_command(user_parameters={"uri": dst_dir}, storage_dir=dst_dir) @@ -130,11 +134,11 @@ def test_params(): entry_point = EntryPoint("entry_point_name", defaults, "command_name script.py") user1 = {} - with pytest.raises(ExecutionException): + with pytest.raises(ExecutionException, match="No value given for missing parameters"): entry_point._validate_parameters(user1) user_2 = {"beta": 0.004} - with pytest.raises(ExecutionException): + with pytest.raises(ExecutionException, match="No value given for missing parameters"): entry_point._validate_parameters(user_2) user_3 = {"alpha": 0.004, "gamma": 0.89} diff --git a/tests/projects/test_kubernetes.py b/tests/projects/test_kubernetes.py index 4d4014be11646..2054767b39b2b 100644 --- a/tests/projects/test_kubernetes.py +++ b/tests/projects/test_kubernetes.py @@ -232,7 +232,10 @@ def test_push_image_to_registry(): def test_push_image_to_registry_handling_errors(): image_uri = "dockerhub_account/mlflow-kubernetes-example" - with pytest.raises(ExecutionException): + with pytest.raises( + ExecutionException, + match="Error while pushing to docker registry: An image does not exist locally", + ): kb.push_image_to_registry(image_uri) diff --git a/tests/projects/test_project_spec.py b/tests/projects/test_project_spec.py index 023b1f832da48..44ccb58d084a5 100644 --- a/tests/projects/test_project_spec.py +++ b/tests/projects/test_project_spec.py @@ -35,7 +35,7 @@ def test_project_get_unspecified_entry_point(): assert entry_point.name == "my_script.sh" assert entry_point.command == "%s my_script.sh" % os.environ.get("SHELL", "bash") assert entry_point.parameters == {} - with pytest.raises(ExecutionException): + with pytest.raises(ExecutionException, match="Could not find my_program.scala"): project.get_entry_point("my_program.scala") @@ -111,6 +111,6 @@ def test_load_docker_project(tmpdir): ) def test_load_invalid_project(tmpdir, invalid_project_contents, expected_error_msg): tmpdir.join("MLproject").write(invalid_project_contents) - with pytest.raises(ExecutionException) as e: + with pytest.raises(ExecutionException, match=expected_error_msg) as e: _project_spec.load_project(tmpdir.strpath) assert expected_error_msg in str(e.value) diff --git a/tests/projects/test_projects.py b/tests/projects/test_projects.py index 988b3f8bea42e..d20bf4dd78be6 100644 --- a/tests/projects/test_projects.py +++ b/tests/projects/test_projects.py @@ -80,7 +80,9 @@ def test_resolve_experiment_id_should_not_allow_both_name_and_id_in_use(): def test_invalid_run_mode(): """Verify that we raise an exception given an invalid run mode""" - with pytest.raises(ExecutionException): + with pytest.raises( + ExecutionException, match="Got unsupported execution mode some unsupported mode" + ): mlflow.projects.run(uri=TEST_PROJECT_DIR, backend="some unsupported mode") @@ -88,8 +90,8 @@ def test_invalid_run_mode(): def test_use_conda(): """Verify that we correctly handle the `use_conda` argument.""" # Verify we throw an exception when conda is unavailable - with mock.patch.dict("os.environ", {}, clear=True): - with pytest.raises(ExecutionException): + with mock.patch("mlflow.utils.process.exec_cmd", side_effect=EnvironmentError): + with pytest.raises(ExecutionException, match="Could not find Conda executable"): mlflow.projects.run(TEST_PROJECT_DIR, use_conda=True) @@ -355,7 +357,10 @@ def exec_cmd_mock_raise(cmd, *args, **kwargs): # pylint: disable=unused-argumen # Simulate a non-working or non-existent mamba with mock.patch("mlflow.utils.process.exec_cmd", side_effect=exec_cmd_mock_raise): - with pytest.raises(ExecutionException): + with pytest.raises( + ExecutionException, + match="You have set the env variable MLFLOW_CONDA_CREATE_ENV_CMD", + ): mlflow.utils.conda.get_or_create_conda_env(conda_env_path) @@ -398,21 +403,62 @@ def test_parse_kubernetes_config(): assert kube_config["kube-job-template"] == yaml_obj -def test_parse_kubernetes_config_without_context(): - kubernetes_config = { - "repository-uri": "dockerhub_account/mlflow-kubernetes-example", - "kube-job-template-path": "kubernetes_job_template.yaml", - } - with pytest.raises(ExecutionException): +@pytest.fixture +def mock_kubernetes_job_template(tmpdir): + tmp_path = tmpdir.join("kubernetes_job_template.yaml") + tmp_path.write( + """ +apiVersion: batch/v1 +kind: Job +metadata: + name: "{replaced with MLflow Project name}" + namespace: mlflow +spec: + ttlSecondsAfterFinished: 100 + backoffLimit: 0 + template: + spec: + containers: + - name: "{replaced with MLflow Project name}" + image: "{replaced with URI of Docker image created during Project execution}" + command: ["{replaced with MLflow Project entry point command}"] + resources: + limits: + memory: 512Mi + requests: + memory: 256Mi + restartPolicy: Never +""".lstrip() + ) + return tmp_path.strpath + + +class StartsWithMatcher: + def __init__(self, prefix): + self.prefix = prefix + + def __eq__(self, other): + return isinstance(other, str) and other.startswith(self.prefix) + + +def test_parse_kubernetes_config_without_context(mock_kubernetes_job_template): + with mock.patch("mlflow.projects._logger.debug") as mock_debug: + kubernetes_config = { + "repository-uri": "dockerhub_account/mlflow-kubernetes-example", + "kube-job-template-path": mock_kubernetes_job_template, + } _parse_kubernetes_config(kubernetes_config) + mock_debug.assert_called_once_with( + StartsWithMatcher("Could not find kube-context in backend_config") + ) -def test_parse_kubernetes_config_without_image_uri(): +def test_parse_kubernetes_config_without_image_uri(mock_kubernetes_job_template): kubernetes_config = { "kube-context": "docker-for-desktop", - "kube-job-template-path": "kubernetes_job_template.yaml", + "kube-job-template-path": mock_kubernetes_job_template, } - with pytest.raises(ExecutionException): + with pytest.raises(ExecutionException, match="Could not find 'repository-uri'"): _parse_kubernetes_config(kubernetes_config) @@ -422,7 +468,7 @@ def test_parse_kubernetes_config_invalid_template_job_file(): "repository-uri": "username/mlflow-kubernetes-example", "kube-job-template-path": "file_not_found.yaml", } - with pytest.raises(ExecutionException): + with pytest.raises(ExecutionException, match="Could not find 'kube-job-template-path'"): _parse_kubernetes_config(kubernetes_config) diff --git a/tests/projects/test_utils.py b/tests/projects/test_utils.py index f6d41e7b5fc67..c9163f5e9ed67 100644 --- a/tests/projects/test_utils.py +++ b/tests/projects/test_utils.py @@ -113,11 +113,11 @@ def test_fetching_non_existing_version_fails(local_git_repo, local_git_repo_uri) def test_fetch_project_validations(local_git_repo_uri): # Verify that runs fail if given incorrect subdirectories via the `#` character. for base_uri in [TEST_PROJECT_DIR, local_git_repo_uri]: - with pytest.raises(ExecutionException): + with pytest.raises(ExecutionException, match="Could not find subdirectory fake"): _fetch_project(uri=_build_uri(base_uri, "fake")) # Passing `version` raises an exception for local projects - with pytest.raises(ExecutionException): + with pytest.raises(ExecutionException, match="Setting a version is only supported"): _fetch_project(uri=TEST_PROJECT_DIR, version="version") @@ -145,7 +145,7 @@ def test_parse_subdirectory(): # Make sure periods are restricted in Git repo subdirectory paths. period_fail_uri = GIT_PROJECT_URI + "#.." - with pytest.raises(ExecutionException): + with pytest.raises(ExecutionException, match=r"'\.' is not allowed"): _parse_subdirectory(period_fail_uri) diff --git a/tests/pyfunc/test_model_export_with_class_and_artifacts.py b/tests/pyfunc/test_model_export_with_class_and_artifacts.py index 88ef636bd0a66..a783e98dabac4 100644 --- a/tests/pyfunc/test_model_export_with_class_and_artifacts.py +++ b/tests/pyfunc/test_model_export_with_class_and_artifacts.py @@ -863,87 +863,96 @@ def test_save_model_with_no_artifacts_does_not_produce_artifacts_dir(model_path) @pytest.mark.large def test_save_model_with_python_model_argument_of_invalid_type_raises_exeption(tmpdir): - with pytest.raises(MlflowException) as exc_info: + match = "python_model` must be a subclass of `PythonModel`" + with pytest.raises(MlflowException, match=match): mlflow.pyfunc.save_model( path=os.path.join(str(tmpdir), "model1"), python_model="not the right type" ) - assert "python_model` must be a subclass of `PythonModel`" in str(exc_info) - with pytest.raises(MlflowException) as exc_info: + with pytest.raises(MlflowException, match=match): mlflow.pyfunc.save_model( path=os.path.join(str(tmpdir), "model2"), python_model="not the right type" ) - assert "python_model` must be a subclass of `PythonModel`" in str(exc_info) @pytest.mark.large def test_save_model_with_unsupported_argument_combinations_throws_exception(model_path): - with pytest.raises(MlflowException) as exc_info: + with pytest.raises( + MlflowException, match="Either `loader_module` or `python_model` must be specified" + ) as exc_info: mlflow.pyfunc.save_model( path=model_path, artifacts={"artifact": "/path/to/artifact"}, python_model=None ) - assert "Either `loader_module` or `python_model` must be specified" in str(exc_info) python_model = ModuleScopedSklearnModel(predict_fn=None) loader_module = __name__ - with pytest.raises(MlflowException) as exc_info: + with pytest.raises( + MlflowException, match="The following sets of parameters cannot be specified together" + ) as exc_info: mlflow.pyfunc.save_model( path=model_path, python_model=python_model, loader_module=loader_module ) - assert "The following sets of parameters cannot be specified together" in str(exc_info) assert str(python_model) in str(exc_info) assert str(loader_module) in str(exc_info) - with pytest.raises(MlflowException) as exc_info: + with pytest.raises( + MlflowException, match="The following sets of parameters cannot be specified together" + ) as exc_info: mlflow.pyfunc.save_model( path=model_path, python_model=python_model, data_path="/path/to/data", artifacts={"artifact": "/path/to/artifact"}, ) - assert "The following sets of parameters cannot be specified together" in str(exc_info) - with pytest.raises(MlflowException) as exc_info: + with pytest.raises( + MlflowException, match="Either `loader_module` or `python_model` must be specified" + ): mlflow.pyfunc.save_model(path=model_path, python_model=None, loader_module=None) - assert "Either `loader_module` or `python_model` must be specified" in str(exc_info) @pytest.mark.large def test_log_model_with_unsupported_argument_combinations_throws_exception(): - with mlflow.start_run(), pytest.raises(MlflowException) as exc_info: + match = ( + "Either `loader_module` or `python_model` must be specified. A `loader_module` " + "should be a python module. A `python_model` should be a subclass of " + "PythonModel" + ) + with mlflow.start_run(), pytest.raises(MlflowException, match=match): mlflow.pyfunc.log_model( artifact_path="pyfunc_model", artifacts={"artifact": "/path/to/artifact"}, python_model=None, ) - assert ( - "Either `loader_module` or `python_model` must be specified. A `loader_module` " - "should be a python module. A `python_model` should be a subclass of " - "PythonModel" in str(exc_info) - ) python_model = ModuleScopedSklearnModel(predict_fn=None) loader_module = __name__ - with mlflow.start_run(), pytest.raises(MlflowException) as exc_info: + with mlflow.start_run(), pytest.raises( + MlflowException, + match="The following sets of parameters cannot be specified together", + ) as exc_info: mlflow.pyfunc.log_model( - artifact_path="pyfunc_model", python_model=python_model, loader_module=loader_module + artifact_path="pyfunc_model", + python_model=python_model, + loader_module=loader_module, ) - assert "The following sets of parameters cannot be specified together" in str(exc_info) assert str(python_model) in str(exc_info) assert str(loader_module) in str(exc_info) - with mlflow.start_run(), pytest.raises(MlflowException) as exc_info: + with mlflow.start_run(), pytest.raises( + MlflowException, match="The following sets of parameters cannot be specified together" + ) as exc_info: mlflow.pyfunc.log_model( artifact_path="pyfunc_model", python_model=python_model, data_path="/path/to/data", artifacts={"artifact1": "/path/to/artifact"}, ) - assert "The following sets of parameters cannot be specified together" in str(exc_info) - with mlflow.start_run(), pytest.raises(MlflowException) as exc_info: + with mlflow.start_run(), pytest.raises( + MlflowException, match="Either `loader_module` or `python_model` must be specified" + ): mlflow.pyfunc.log_model(artifact_path="pyfunc_model", python_model=None, loader_module=None) - assert "Either `loader_module` or `python_model` must be specified" in str(exc_info) @pytest.mark.large diff --git a/tests/pyfunc/test_model_export_with_loader_module_and_data_path.py b/tests/pyfunc/test_model_export_with_loader_module_and_data_path.py index bd511ea0d9e11..8b4f395096bc8 100644 --- a/tests/pyfunc/test_model_export_with_loader_module_and_data_path.py +++ b/tests/pyfunc/test_model_export_with_loader_module_and_data_path.py @@ -1,6 +1,7 @@ import os import pickle import yaml +import re import numpy as np import pandas as pd @@ -165,9 +166,9 @@ def test_column_schema_enforcement(): pdf["d"] = pdf["d"].astype(np.float64) pdf["h"] = pdf["h"].astype(np.datetime64) # test that missing column raises - with pytest.raises(MlflowException) as ex: + match_missing_inputs = "Model is missing inputs" + with pytest.raises(MlflowException, match=match_missing_inputs): res = pyfunc_model.predict(pdf[["b", "d", "a", "e", "g", "f", "h"]]) - assert "Model is missing inputs" in str(ex) # test that extra column is ignored pdf["x"] = 1 @@ -188,9 +189,9 @@ def test_column_schema_enforcement(): # Test conversions # 1. long -> integer raises pdf["a"] = pdf["a"].astype(np.int64) - with pytest.raises(MlflowException) as ex: + match_incompatible_inputs = "Incompatible input types" + with pytest.raises(MlflowException, match=match_incompatible_inputs): pyfunc_model.predict(pdf) - assert "Incompatible input types" in str(ex) pdf["a"] = pdf["a"].astype(np.int32) # 2. integer -> long works pdf["b"] = pdf["b"].astype(np.int32) @@ -208,35 +209,30 @@ def test_column_schema_enforcement(): # 4. unsigned int -> int raises pdf["a"] = pdf["a"].astype(np.uint32) - with pytest.raises(MlflowException) as ex: + with pytest.raises(MlflowException, match=match_incompatible_inputs): pyfunc_model.predict(pdf) - assert "Incompatible input types" in str(ex) pdf["a"] = pdf["a"].astype(np.int32) # 5. double -> float raises pdf["c"] = pdf["c"].astype(np.float64) - with pytest.raises(MlflowException) as ex: + with pytest.raises(MlflowException, match=match_incompatible_inputs): pyfunc_model.predict(pdf) - assert "Incompatible input types" in str(ex) pdf["c"] = pdf["c"].astype(np.float32) # 6. float -> double works, double -> float does not pdf["d"] = pdf["d"].astype(np.float32) res = pyfunc_model.predict(pdf) assert res.dtypes.to_dict() == expected_types - assert "Incompatible input types" in str(ex) pdf["d"] = pdf["d"].astype(np.float64) pdf["c"] = pdf["c"].astype(np.float64) - with pytest.raises(MlflowException) as ex: + with pytest.raises(MlflowException, match=match_incompatible_inputs): pyfunc_model.predict(pdf) - assert "Incompatible input types" in str(ex) pdf["c"] = pdf["c"].astype(np.float32) # 7. int -> float raises pdf["c"] = pdf["c"].astype(np.int32) - with pytest.raises(MlflowException) as ex: + with pytest.raises(MlflowException, match=match_incompatible_inputs): pyfunc_model.predict(pdf) - assert "Incompatible input types" in str(ex) pdf["c"] = pdf["c"].astype(np.float32) # 8. int -> double works @@ -247,33 +243,28 @@ def test_column_schema_enforcement(): # 9. long -> double raises pdf["d"] = pdf["d"].astype(np.int64) - with pytest.raises(MlflowException) as ex: + with pytest.raises(MlflowException, match=match_incompatible_inputs): pyfunc_model.predict(pdf) - assert "Incompatible input types" in str(ex) pdf["d"] = pdf["d"].astype(np.float64) # 10. any float -> any int raises pdf["a"] = pdf["a"].astype(np.float32) - with pytest.raises(MlflowException) as ex: + with pytest.raises(MlflowException, match=match_incompatible_inputs): pyfunc_model.predict(pdf) - assert "Incompatible input types" in str(ex) # 10. any float -> any int raises pdf["a"] = pdf["a"].astype(np.float64) - with pytest.raises(MlflowException) as ex: + with pytest.raises(MlflowException, match=match_incompatible_inputs): pyfunc_model.predict(pdf) - assert "Incompatible input types" in str(ex) pdf["a"] = pdf["a"].astype(np.int32) pdf["b"] = pdf["b"].astype(np.float64) - with pytest.raises(MlflowException) as ex: + with pytest.raises(MlflowException, match=match_incompatible_inputs): pyfunc_model.predict(pdf) - assert "Incompatible input types" in str(ex) pdf["b"] = pdf["b"].astype(np.int64) pdf["b"] = pdf["b"].astype(np.float64) - with pytest.raises(MlflowException) as ex: + with pytest.raises(MlflowException, match=match_incompatible_inputs): pyfunc_model.predict(pdf) pdf["b"] = pdf["b"].astype(np.int64) - assert "Incompatible input types" in str(ex) # 11. objects work pdf["b"] = pdf["b"].astype(np.object) @@ -291,9 +282,8 @@ def test_column_schema_enforcement(): pdf["h"] = pdf["h"].astype("datetime64[s]") # 13. np.ndarrays can be converted to dataframe but have no columns - with pytest.raises(MlflowException) as ex: + with pytest.raises(MlflowException, match=match_missing_inputs): pyfunc_model.predict(pdf.values) - assert "Model is missing inputs" in str(ex) # 14. dictionaries of str -> list/nparray work arr = np.array([1, 2, 3]) @@ -321,9 +311,8 @@ def test_column_schema_enforcement(): "f": [[bytes(0), bytes(1), bytes(1)]], "h": [np.array(["2020-01-01", "2020-02-02", "2020-03-03"], dtype=np.datetime64)], } - with pytest.raises(MlflowException) as ex: + with pytest.raises(MlflowException, match=match_incompatible_inputs): pyfunc_model.predict(d) - assert "Incompatible input types" in str(ex) # 16. conversion to dataframe fails d = { @@ -331,11 +320,11 @@ def test_column_schema_enforcement(): "b": [1, 2], "c": [1, 2, 3], } - with pytest.raises(MlflowException) as ex: + with pytest.raises( + MlflowException, + match="This model contains a column-based signature, which suggests a DataFrame input.", + ): pyfunc_model.predict(d) - assert "This model contains a column-based signature, which suggests a DataFrame input." in str( - ex - ) def _compare_exact_tensor_dict_input(d1, d2): @@ -364,9 +353,8 @@ def test_tensor_multi_named_schema_enforcement(): # test that missing column raises inp1 = {k: v for k, v in inp.items()} - with pytest.raises(MlflowException) as ex: + with pytest.raises(MlflowException, match="Model is missing inputs"): pyfunc_model.predict(inp1.pop("b")) - assert "Model is missing inputs" in str(ex) # test that extra column is ignored inp2 = {k: v for k, v in inp.items()} @@ -394,9 +382,10 @@ def test_tensor_multi_named_schema_enforcement(): # test that type casting is not supported inp4 = {k: v for k, v in inp.items()} inp4["a"] = inp4["a"].astype(np.int32) - with pytest.raises(MlflowException) as ex: + with pytest.raises( + MlflowException, match="dtype of input int32 does not match expected dtype uint64" + ): pyfunc_model.predict(inp4) - assert "dtype of input int32 does not match expected dtype uint64" in str(ex) # test wrong shape inp5 = { @@ -404,9 +393,11 @@ def test_tensor_multi_named_schema_enforcement(): "b": np.array([[0, 0], [1, 1]], dtype=np.short), "c": np.array([[[0, 0]]], dtype=np.float32), } - with pytest.raises(MlflowException) as ex: + with pytest.raises( + MlflowException, + match=re.escape("Shape of input (1, 4) does not match expected shape (-1, 5)"), + ): pyfunc_model.predict(inp5) - assert "Shape of input (1, 4) does not match expected shape (-1, 5)" in str(ex) # test non-dictionary input inp6 = [ @@ -414,35 +405,39 @@ def test_tensor_multi_named_schema_enforcement(): np.array([[0, 0], [1, 1]], dtype=np.short), np.array([[[0, 0]]], dtype=np.float32), ] - with pytest.raises(MlflowException) as ex: + with pytest.raises( + MlflowException, match=re.escape("Model is missing inputs ['a', 'b', 'c'].") + ): pyfunc_model.predict(inp6) - assert "Model is missing inputs ['a', 'b', 'c']." in str(ex) # test empty ndarray does not work inp7 = {k: v for k, v in inp.items()} inp7["a"] = np.array([]) - with pytest.raises(MlflowException) as ex: + with pytest.raises( + MlflowException, match=re.escape("Shape of input (0,) does not match expected shape") + ): pyfunc_model.predict(inp7) - assert "Shape of input (0,) does not match expected shape" in str(ex) # test dictionary of str -> list does not work inp8 = {k: list(v) for k, v in inp.items()} - with pytest.raises(MlflowException) as ex: + match = ( + r"This model contains a tensor-based model signature with input names.+" + r"suggests a dictionary input mapping input name to a numpy array, but a dict" + r" with value type was found" + ) + with pytest.raises(MlflowException, match=match): pyfunc_model.predict(inp8) - assert "This model contains a tensor-based model signature with input names" in str(ex) - assert ( - "suggests a dictionary input mapping input name to a numpy array, but a dict" - " with value type was found" - ) in str(ex) # test dataframe input fails at shape enforcement pdf = pd.DataFrame(data=[[1, 2, 3]], columns=["a", "b", "c"]) pdf["a"] = pdf["a"].astype(np.uint64) pdf["b"] = pdf["b"].astype(np.short) pdf["c"] = pdf["c"].astype(np.float32) - with pytest.raises(MlflowException) as ex: + with pytest.raises( + MlflowException, + match=re.escape("Shape of input (1,) does not match expected shape (-1, 5)"), + ): pyfunc_model.predict(pdf) - assert "Shape of input (1,) does not match expected shape (-1, 5)" in str(ex) def test_schema_enforcement_single_named_tensor_schema(): @@ -469,9 +464,8 @@ def test_schema_enforcement_single_named_tensor_schema(): assert expected_types == actual_types # test list does not work - with pytest.raises(MlflowException) as ex: + with pytest.raises(MlflowException, match="Model is missing inputs"): pyfunc_model.predict([[0, 0], [1, 1]]) - assert "Model is missing inputs ['a']" in str(ex) def test_schema_enforcement_named_tensor_schema_1d(): @@ -510,20 +504,18 @@ def test_missing_value_hint_is_displayed_when_it_should(): m.signature = ModelSignature(inputs=input_schema) pyfunc_model = PyFuncModel(model_meta=m, model_impl=TestModel()) pdf = pd.DataFrame(data=[[1], [None]], columns=["a"]) - with pytest.raises(MlflowException) as ex: + match = "Incompatible input types" + with pytest.raises(MlflowException, match=match) as ex: pyfunc_model.predict(pdf) hint = "Hint: the type mismatch is likely caused by missing values." - assert "Incompatible input types" in str(ex.value.message) assert hint in str(ex.value.message) pdf = pd.DataFrame(data=[[1.5], [None]], columns=["a"]) - with pytest.raises(MlflowException) as ex: + with pytest.raises(MlflowException, match=match) as ex: pyfunc_model.predict(pdf) - assert "Incompatible input types" in str(ex) assert hint not in str(ex.value.message) pdf = pd.DataFrame(data=[[1], [2]], columns=["a"], dtype=np.float64) - with pytest.raises(MlflowException) as ex: + with pytest.raises(MlflowException, match=match) as ex: pyfunc_model.predict(pdf) - assert "Incompatible input types" in str(ex.value.message) assert hint not in str(ex.value.message) @@ -548,19 +540,16 @@ def test_column_schema_enforcement_no_col_names(): assert pyfunc_model.predict(pdf).equals(pdf) # Must provide the right number of arguments - with pytest.raises(MlflowException) as ex: + with pytest.raises(MlflowException, match="the provided value only has 2 inputs."): pyfunc_model.predict([[1.0, 2.0]]) - assert "the provided value only has 2 inputs." in str(ex) # Must provide the right types - with pytest.raises(MlflowException) as ex: + with pytest.raises(MlflowException, match="Can not safely convert int64 to float64"): pyfunc_model.predict([[1, 2, 3]]) - assert "Can not safely convert int64 to float64" in str(ex) # Can only provide data type that can be converted to dataframe... - with pytest.raises(MlflowException) as ex: + with pytest.raises(MlflowException, match="Expected input to be DataFrame or list. Found: set"): pyfunc_model.predict(set([1, 2, 3])) - assert "Expected input to be DataFrame or list. Found: set" in str(ex) # 9. dictionaries of str -> list/nparray work d = {"a": [1.0], "b": [2.0], "c": [3.0]} @@ -581,33 +570,41 @@ def test_tensor_schema_enforcement_no_col_names(): assert np.array_equal(pyfunc_model.predict(pd.DataFrame(test_data)), test_data) # Can not call with a list - with pytest.raises(MlflowException) as ex: + with pytest.raises( + MlflowException, + match="This model contains a tensor-based model signature with no input names", + ): pyfunc_model.predict([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]]) - assert "This model contains a tensor-based model signature with no input names" in str(ex) # Can not call with a dict - with pytest.raises(MlflowException) as ex: + with pytest.raises( + MlflowException, + match="This model contains a tensor-based model signature with no input names", + ): pyfunc_model.predict({"blah": test_data}) - assert "This model contains a tensor-based model signature with no input names" in str(ex) # Can not call with a np.ndarray of a wrong shape - with pytest.raises(MlflowException) as ex: + with pytest.raises( + MlflowException, + match=re.escape("Shape of input (2, 2) does not match expected shape (-1, 3)"), + ): pyfunc_model.predict(np.array([[1.0, 2.0], [4.0, 5.0]])) - assert "Shape of input (2, 2) does not match expected shape (-1, 3)" in str(ex) # Can not call with a np.ndarray of a wrong type - with pytest.raises(MlflowException) as ex: + with pytest.raises( + MlflowException, match="dtype of input uint32 does not match expected dtype float32" + ): pyfunc_model.predict(test_data.astype(np.uint32)) - assert "dtype of input uint32 does not match expected dtype float32" in str(ex) # Can call with a np.ndarray with more elements along variable axis test_data2 = np.array([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0], [7.0, 8.0, 9.0]], dtype=np.float32) assert np.array_equal(pyfunc_model.predict(test_data2), test_data2) # Can not call with an empty ndarray - with pytest.raises(MlflowException) as ex: + with pytest.raises( + MlflowException, match=re.escape("Shape of input () does not match expected shape (-1, 3)") + ): pyfunc_model.predict(np.ndarray([])) - assert "Shape of input () does not match expected shape (-1, 3)" in str(ex) @pytest.mark.large @@ -672,16 +669,18 @@ def test_model_log_load_no_active_run(sklearn_knn_model, iris_data, tmpdir): @pytest.mark.large def test_save_model_with_unsupported_argument_combinations_throws_exception(model_path): - with pytest.raises(MlflowException) as exc_info: + with pytest.raises( + MlflowException, match="Either `loader_module` or `python_model` must be specified" + ): mlflow.pyfunc.save_model(path=model_path, data_path="/path/to/data") - assert "Either `loader_module` or `python_model` must be specified" in str(exc_info) @pytest.mark.large def test_log_model_with_unsupported_argument_combinations_throws_exception(): - with mlflow.start_run(), pytest.raises(MlflowException) as exc_info: + with mlflow.start_run(), pytest.raises( + MlflowException, match="Either `loader_module` or `python_model` must be specified" + ): mlflow.pyfunc.log_model(artifact_path="pyfunc_model", data_path="/path/to/data") - assert "Either `loader_module` or `python_model` must be specified" in str(exc_info) @pytest.mark.large diff --git a/tests/pyfunc/test_scoring_server.py b/tests/pyfunc/test_scoring_server.py index 740d0cc04e8b5..8ac6e865034c2 100644 --- a/tests/pyfunc/test_scoring_server.py +++ b/tests/pyfunc/test_scoring_server.py @@ -506,20 +506,14 @@ def test_infer_and_parse_json_input(): assert (result == np.array(arr)).all() # input is unrecognized JSON input - with pytest.raises(MlflowException) as ex: + match = "Failed to parse input from JSON. Ensure that input is a valid JSON list or dictionary." + with pytest.raises(MlflowException, match=match): pyfunc_scoring_server.infer_and_parse_json_input(json.dumps('"just a string"')) - assert ( - "Failed to parse input from JSON. Ensure that input is a valid JSON" - " list or dictionary." in str(ex) - ) # input is not json str - with pytest.raises(MlflowException) as ex: + match = "Failed to parse input from JSON. Ensure that input is a valid JSON formatted string." + with pytest.raises(MlflowException, match=match): pyfunc_scoring_server.infer_and_parse_json_input("(not a json string)") - assert ( - "Failed to parse input from JSON. Ensure that input is a valid JSON" - " formatted string." in str(ex) - ) @pytest.mark.large diff --git a/tests/pyfunc/test_spark.py b/tests/pyfunc/test_spark.py index c863937c523b2..0e82c69680a84 100644 --- a/tests/pyfunc/test_spark.py +++ b/tests/pyfunc/test_spark.py @@ -176,7 +176,7 @@ def predict(self, context, model_input): udf = mlflow.pyfunc.spark_udf( spark, "runs:/{}/model".format(run.info.run_id), result_type=ArrayType(StringType()) ) - with pytest.raises(pyspark.sql.utils.PythonException): + with pytest.raises(pyspark.sql.utils.PythonException, match=r".+"): res = good_data.withColumn("res", udf()).select("res").toPandas() @@ -199,7 +199,7 @@ def predict(self, context, model_input): columns=["a", "b", "c", "d"], data={"a": [1], "b": [2], "c": [3], "d": [4]} ) ) - with pytest.raises(pyspark.sql.utils.PythonException): + with pytest.raises(pyspark.sql.utils.PythonException, match=r".+"): res = data.withColumn("res1", udf("a", "b")).select("res1").toPandas() res = data.withColumn("res2", udf("a", "b", "c")).select("res2").toPandas() diff --git a/tests/pylint_plugins/test_pytest_raises_without_match.py b/tests/pylint_plugins/test_pytest_raises_without_match.py new file mode 100644 index 0000000000000..64421783adcbe --- /dev/null +++ b/tests/pylint_plugins/test_pytest_raises_without_match.py @@ -0,0 +1,98 @@ +import pytest + +from tests.helper_functions import _is_importable + +pytestmark = pytest.mark.skipif( + not _is_importable("pylint"), reason="pylint is required to run tests in this module" +) + + +@pytest.fixture(scope="module") +def test_case(): + # Ref: https://pylint.pycqa.org/en/latest/how_tos/custom_checkers.html#testing-a-checker + import pylint.testutils + from pylint_plugins import PytestRaisesWithoutMatch + + class TestPytestRaisesWithoutMatch(pylint.testutils.CheckerTestCase): + CHECKER_CLASS = PytestRaisesWithoutMatch + + test_case = TestPytestRaisesWithoutMatch() + test_case.setup_method() + return test_case + + +def create_message(msg_id, node): + import pylint.testutils + + return pylint.testutils.Message(msg_id=msg_id, node=node) + + +def extract_node(code): + import astroid + + return astroid.extract_node(code) + + +def iter_bad_cases(): + # Single context manager + root_node = extract_node( + """ +with pytest.raises(Exception): + raise Exception("failed") +""" + ) + yield root_node, root_node.items[0][0] + + # Multiple context managers + root_node = extract_node( + """ +with context_manager, pytest.raises(Exception): + raise Exception("failed") +""" + ) + yield root_node, root_node.items[1][0] + + # Without `with` + root_node = extract_node( + """ +pytest.raises(Exception) +""" + ) + yield root_node, root_node + + +def test_bad_cases(test_case): + for root_node, error_node in iter_bad_cases(): + with test_case.assertAddsMessages(create_message(test_case.CHECKER_CLASS.name, error_node)): + test_case.walk(root_node) + + +def iter_good_cases(): + # Single context manager + yield extract_node( + """ +with pytest.raises(Exception, match="failed"): + raise Exception("failed") +""" + ) + + # Multiple context managers + yield extract_node( + """ +with context_manager, pytest.raises(Exception, match="failed"): + raise Exception("failed") +""" + ) + + # Without `with` + yield extract_node( + """ +pytest.raises(Exception, match="failed") +""" + ) + + +def test_good_cases(test_case): + for root_node in iter_good_cases(): + with test_case.assertNoMessages(): + test_case.walk(root_node) diff --git a/tests/pylint_plugins/utils.py b/tests/pylint_plugins/utils.py new file mode 100644 index 0000000000000..df9df062689d5 --- /dev/null +++ b/tests/pylint_plugins/utils.py @@ -0,0 +1,11 @@ +import subprocess +import re + + +def get_pylint_msg_ids(): + from pylint.constants import MSG_TYPES + + res = subprocess.run(["pylint", "--list-msgs"], stdout=subprocess.PIPE, check=True) + stdout = res.stdout.decode("utf-8") + letters = "".join(MSG_TYPES.keys()) + return set(re.findall(fr"\(([{letters}][0-9]{{4}})\)", stdout)) diff --git a/tests/pytorch/test_pytorch_model_export.py b/tests/pytorch/test_pytorch_model_export.py index 9ca25a6b1a99a..4a5718ed794ab 100644 --- a/tests/pytorch/test_pytorch_model_export.py +++ b/tests/pytorch/test_pytorch_model_export.py @@ -6,6 +6,7 @@ import json import logging import pickle +import re from unittest import mock import pytest @@ -265,14 +266,14 @@ def test_log_model_no_registered_model_name(module_scoped_subclassed_model): def test_raise_exception(sequential_model): with TempDir(chdr=True, remove_on_exit=True) as tmp: path = tmp.path("model") - with pytest.raises(IOError): + with pytest.raises(IOError, match="No such file or directory"): mlflow.pytorch.load_model(path) - with pytest.raises(TypeError): + with pytest.raises(TypeError, match="Argument 'pytorch_model' should be a torch.nn.Module"): mlflow.pytorch.save_model([1, 2, 3], path) mlflow.pytorch.save_model(sequential_model, path) - with pytest.raises(RuntimeError): + with pytest.raises(RuntimeError, match=f"Path '{os.path.abspath(path)}' already exists"): mlflow.pytorch.save_model(sequential_model, path) from mlflow import sklearn @@ -284,7 +285,7 @@ def test_raise_exception(sequential_model): pickle.dump(knn, f) path = tmp.path("knn") sklearn.save_model(knn, path=path) - with pytest.raises(MlflowException): + with pytest.raises(MlflowException, match='Model does not have the "pytorch" flavor'): mlflow.pytorch.load_model(path) @@ -325,14 +326,14 @@ def test_pyfunc_model_works_with_np_input_type( np.testing.assert_array_almost_equal(np_result[:, 0], sequential_predicted, decimal=4) # predict does not work with lists - with pytest.raises(TypeError) as exc_info: + with pytest.raises( + TypeError, match="The PyTorch flavor does not support List or Dict input types" + ): pyfunc_loaded.predict([1, 2, 3, 4]) - assert "The PyTorch flavor does not support List or Dict input types" in str(exc_info) # predict does not work with scalars - with pytest.raises(TypeError) as exc_info: + with pytest.raises(TypeError, match="Input data should be pandas.DataFrame or numpy.ndarray"): pyfunc_loaded.predict(4) - assert "Input data should be pandas.DataFrame or numpy.ndarray" in str(exc_info) @pytest.mark.large @@ -596,11 +597,10 @@ def test_save_model_with_wrong_codepaths_fails_correctly( module_scoped_subclassed_model, model_path, data ): # pylint: disable=unused-argument - with pytest.raises(TypeError) as exc_info: + with pytest.raises(TypeError, match="Argument code_paths should be a list, not "): mlflow.pytorch.save_model( path=model_path, pytorch_model=module_scoped_subclassed_model, code_paths="some string" ) - assert "Argument code_paths should be a list, not {}".format(type("")) in str(exc_info.value) assert not os.path.exists(model_path) @@ -858,12 +858,12 @@ def test_load_model_raises_exception_when_pickle_module_cannot_be_imported( ) as f: f.write(bad_pickle_module_name) - with pytest.raises(MlflowException) as exc_info: + with pytest.raises( + MlflowException, + match=r"Failed to import the pickle module.+" + re.escape(bad_pickle_module_name), + ): mlflow.pytorch.load_model(model_uri=model_path) - assert "Failed to import the pickle module" in str(exc_info) - assert bad_pickle_module_name in str(exc_info) - @pytest.mark.large def test_pyfunc_serve_and_score(data): @@ -995,11 +995,11 @@ def test_requirements_file_save_model(create_requirements_file, sequential_model @pytest.mark.parametrize("scripted_model", [True, False]) def test_log_model_invalid_requirement_file_path(sequential_model): - with mlflow.start_run(), pytest.raises(FileNotFoundError): + with mlflow.start_run(), pytest.raises(FileNotFoundError, match="non_existing_file.txt"): mlflow.pytorch.log_model( pytorch_model=sequential_model, artifact_path="models", - requirements_file="inexistent_file.txt", + requirements_file="non_existing_file.txt", ) @@ -1011,7 +1011,7 @@ def test_log_model_invalid_requirement_file_type(sequential_model): mlflow.pytorch.log_model( pytorch_model=sequential_model, artifact_path="models", - requirements_file=["inexistent_file.txt"], + requirements_file=["non_existing_file.txt"], ) @@ -1088,11 +1088,11 @@ def test_extra_files_save_model(create_extra_files, sequential_model): @pytest.mark.parametrize("scripted_model", [True, False]) def test_log_model_invalid_extra_file_path(sequential_model): - with mlflow.start_run(), pytest.raises(FileNotFoundError): + with mlflow.start_run(), pytest.raises(FileNotFoundError, match="non_existing_file.txt"): mlflow.pytorch.log_model( pytorch_model=sequential_model, artifact_path="models", - extra_files=["inexistent_file.txt"], + extra_files=["non_existing_file.txt"], ) @@ -1104,7 +1104,7 @@ def test_log_model_invalid_extra_file_type(sequential_model): mlflow.pytorch.log_model( pytorch_model=sequential_model, artifact_path="models", - extra_files="inexistent_file.txt", + extra_files="non_existing_file.txt", ) diff --git a/tests/sagemaker/mock/test_sagemaker_service_mock.py b/tests/sagemaker/mock/test_sagemaker_service_mock.py index 2c74c6ce4f8db..75dddc9766a1f 100644 --- a/tests/sagemaker/mock/test_sagemaker_service_mock.py +++ b/tests/sagemaker/mock/test_sagemaker_service_mock.py @@ -63,7 +63,7 @@ def test_creating_model_with_name_already_in_use_raises_exception(sagemaker_clie create_sagemaker_model(sagemaker_client=sagemaker_client, model_name=model_name) - with pytest.raises(ValueError): + with pytest.raises(ValueError, match="Attempted to create a model"): create_sagemaker_model(sagemaker_client=sagemaker_client, model_name=model_name) @@ -110,7 +110,7 @@ def test_describe_model_response_contains_expected_attributes(sagemaker_client): @mock_sagemaker def test_describe_model_throws_exception_for_nonexistent_model(sagemaker_client): - with pytest.raises(ValueError): + with pytest.raises(ValueError, match="Attempted to describe a model"): sagemaker_client.describe_model(ModelName="nonexistent-model") @@ -176,7 +176,7 @@ def test_creating_endpoint_config_with_name_already_in_use_raises_exception(sage model_name=model_name, ) - with pytest.raises(ValueError): + with pytest.raises(ValueError, match="Attempted to create an endpoint configuration"): create_endpoint_config( sagemaker_client=sagemaker_client, endpoint_config_name=endpoint_config_name, @@ -241,7 +241,7 @@ def test_describe_endpoint_config_response_contains_expected_attributes(sagemake @mock_sagemaker def test_describe_endpoint_config_throws_exception_for_nonexistent_config(sagemaker_client): - with pytest.raises(ValueError): + with pytest.raises(ValueError, match="Attempted to describe an endpoint config"): sagemaker_client.describe_endpoint_config(EndpointConfigName="nonexistent-config") @@ -337,7 +337,7 @@ def test_creating_endpoint_with_name_already_in_use_raises_exception(sagemaker_c Tags=[{"Key": "Some Key", "Value": "Some Value"}], ) - with pytest.raises(ValueError): + with pytest.raises(ValueError, match="Attempted to create an endpoint"): sagemaker_client.create_endpoint( EndpointConfigName=endpoint_config_name, EndpointName=endpoint_name, @@ -411,7 +411,7 @@ def test_describe_endpoint_response_contains_expected_attributes(sagemaker_clien @mock_sagemaker def test_describe_endpoint_throws_exception_for_nonexistent_endpoint(sagemaker_client): - with pytest.raises(ValueError): + with pytest.raises(ValueError, match="Attempted to describe an endpoint"): sagemaker_client.describe_endpoint(EndpointName="nonexistent-endpoint") @@ -498,7 +498,7 @@ def test_update_endpoint_with_nonexistent_config_throws_exception(sagemaker_clie EndpointName=endpoint_name, ) - with pytest.raises(ValueError): + with pytest.raises(ValueError, match="Attempted to update an endpoint"): sagemaker_client.update_endpoint( EndpointName=endpoint_name, EndpointConfigName="nonexistent-config" ) @@ -587,7 +587,7 @@ def test_creating_transform_job_with_name_already_in_use_raises_exception(sagema Tags=[{"Key": "Some Key", "Value": "Some Value"}], ) - with pytest.raises(ValueError): + with pytest.raises(ValueError, match="Attempted to create a transform job"): sagemaker_client.create_transform_job( TransformJobName=job_name, ModelName=model_name, @@ -670,5 +670,5 @@ def test_describe_transform_job_response_contains_expected_attributes(sagemaker_ @mock_sagemaker def test_describe_transform_job_throws_exception_for_nonexistent_transform_job(sagemaker_client): - with pytest.raises(ValueError): + with pytest.raises(ValueError, match="Attempted to describe a transform job"): sagemaker_client.describe_transform_job(TransformJobName="nonexistent-job") diff --git a/tests/sagemaker/test_batch_deployment.py b/tests/sagemaker/test_batch_deployment.py index 16e2300855f64..10902c3ac881b 100644 --- a/tests/sagemaker/test_batch_deployment.py +++ b/tests/sagemaker/test_batch_deployment.py @@ -94,7 +94,8 @@ def mock_wrapper(*args, **kwargs): @pytest.mark.large def test_batch_deployment_with_unsupported_flavor_raises_exception(pretrained_model): unsupported_flavor = "this is not a valid flavor" - with pytest.raises(MlflowException) as exc: + match = "The specified flavor: `this is not a valid flavor` is not supported for deployment" + with pytest.raises(MlflowException, match=match) as exc: mfs.deploy_transform_job( job_name="bad_flavor", model_uri=pretrained_model.model_uri, @@ -111,7 +112,10 @@ def test_batch_deployment_with_unsupported_flavor_raises_exception(pretrained_mo @pytest.mark.large def test_batch_deployment_with_missing_flavor_raises_exception(pretrained_model): missing_flavor = "mleap" - with pytest.raises(MlflowException) as exc: + with pytest.raises( + MlflowException, + match="The specified model does not contain the specified deployment flavor", + ) as exc: mfs.deploy_transform_job( job_name="missing-flavor", model_uri=pretrained_model.model_uri, @@ -133,7 +137,8 @@ def test_batch_deployment_of_model_with_no_supported_flavors_raises_exception(pr del model_config.flavors[mlflow.pyfunc.FLAVOR_NAME] model_config.save(path=model_config_path) - with pytest.raises(MlflowException) as exc: + match = "The specified model does not contain any of the supported flavors for deployment" + with pytest.raises(MlflowException, match=match) as exc: mfs.deploy_transform_job( job_name="missing-flavor", model_uri=logged_model_path, @@ -151,7 +156,7 @@ def test_batch_deployment_of_model_with_no_supported_flavors_raises_exception(pr def test_deploy_sagemaker_transform_job_in_asynchronous_mode_without_archiving_throws_exception( pretrained_model, ): - with pytest.raises(MlflowException) as exc: + with pytest.raises(MlflowException, match="Resources must be archived") as exc: mfs.deploy_transform_job( job_name="test-job", model_uri=pretrained_model.model_uri, @@ -163,7 +168,6 @@ def test_deploy_sagemaker_transform_job_in_asynchronous_mode_without_archiving_t synchronous=False, ) - assert "Resources must be archived" in exc.value.message assert exc.value.error_code == ErrorCode.Name(INVALID_PARAMETER_VALUE) @@ -351,7 +355,9 @@ def test_deploying_sagemaker_transform_job_with_preexisting_name_in_create_mode_ s3_output_path="Some Output Path", ) - with pytest.raises(MlflowException) as exc: + with pytest.raises( + MlflowException, match="a batch transform job with the same name already exists" + ) as exc: mfs.deploy_transform_job( job_name=job_name, model_uri=pretrained_model.model_uri, @@ -361,7 +367,6 @@ def test_deploying_sagemaker_transform_job_with_preexisting_name_in_create_mode_ s3_output_path="Some Output Path", ) - assert "a batch transform job with the same name already exists" in exc.value.message assert exc.value.error_code == ErrorCode.Name(INVALID_PARAMETER_VALUE) @@ -453,7 +458,7 @@ def fail_transform_job_creations(self, operation_name, operation_kwargs): with mock.patch( "botocore.client.BaseClient._make_api_call", new=fail_transform_job_creations - ), pytest.raises(MlflowException) as exc: + ), pytest.raises(MlflowException, match="batch transform job failed") as exc: mfs.deploy_transform_job( job_name="test-job", model_uri=pretrained_model.model_uri, @@ -463,7 +468,6 @@ def fail_transform_job_creations(self, operation_name, operation_kwargs): s3_output_path="Some Output Path", ) - assert "batch transform job failed" in exc.value.message assert exc.value.error_code == ErrorCode.Name(INTERNAL_ERROR) @@ -482,14 +486,13 @@ def test_attempting_to_terminate_in_asynchronous_mode_without_archiving_throws_e s3_output_path="Some Output Path", ) - with pytest.raises(MlflowException) as exc: + with pytest.raises(MlflowException, match="Resources must be archived") as exc: mfs.terminate_transform_job( job_name=job_name, archive=False, synchronous=False, ) - assert "Resources must be archived" in exc.value.message assert exc.value.error_code == ErrorCode.Name(INVALID_PARAMETER_VALUE) diff --git a/tests/sagemaker/test_deployment.py b/tests/sagemaker/test_deployment.py index 705bff2255a77..1c310c178edaf 100644 --- a/tests/sagemaker/test_deployment.py +++ b/tests/sagemaker/test_deployment.py @@ -112,18 +112,17 @@ def test_assume_role_and_get_credentials(): @mock_sagemaker_aws_services def test_deployment_with_non_existent_assume_role_arn_raises_exception(pretrained_model): - with pytest.raises(botocore.exceptions.ClientError) as exc: + match = ( + r"An error occurred \(NoSuchEntity\) when calling the GetRole " + r"operation: Role non-existent-role-arn not found" + ) + with pytest.raises(botocore.exceptions.ClientError, match=match): mfs.deploy( app_name="bad_assume_role_arn", model_uri=pretrained_model.model_uri, assume_role_arn="arn:aws:iam::123456789012:role/non-existent-role-arn", ) - assert ( - str(exc.value) == "An error occurred (NoSuchEntity) when calling the GetRole " - "operation: Role non-existent-role-arn not found" - ) - @pytest.mark.large @mock_sagemaker_aws_services @@ -142,7 +141,8 @@ def test_deployment_with_assume_role_arn(pretrained_model, sagemaker_client): @pytest.mark.large def test_deployment_with_unsupported_flavor_raises_exception(pretrained_model): unsupported_flavor = "this is not a valid flavor" - with pytest.raises(MlflowException) as exc: + match = "The specified flavor: `this is not a valid flavor` is not supported for deployment" + with pytest.raises(MlflowException, match=match) as exc: mfs.deploy( app_name="bad_flavor", model_uri=pretrained_model.model_uri, flavor=unsupported_flavor ) @@ -153,7 +153,8 @@ def test_deployment_with_unsupported_flavor_raises_exception(pretrained_model): @pytest.mark.large def test_deployment_with_missing_flavor_raises_exception(pretrained_model): missing_flavor = "mleap" - with pytest.raises(MlflowException) as exc: + match = "The specified model does not contain the specified deployment flavor" + with pytest.raises(MlflowException, match=match) as exc: mfs.deploy( app_name="missing-flavor", model_uri=pretrained_model.model_uri, flavor=missing_flavor ) @@ -169,7 +170,8 @@ def test_deployment_of_model_with_no_supported_flavors_raises_exception(pretrain del model_config.flavors[mlflow.pyfunc.FLAVOR_NAME] model_config.save(path=model_config_path) - with pytest.raises(MlflowException) as exc: + match = "The specified model does not contain any of the supported flavors for deployment" + with pytest.raises(MlflowException, match=match) as exc: mfs.deploy(app_name="missing-flavor", model_uri=logged_model_path, flavor=None) assert exc.value.error_code == ErrorCode.Name(RESOURCE_DOES_NOT_EXIST) @@ -201,7 +203,7 @@ def test_get_preferred_deployment_flavor_obtains_valid_flavor_from_model(pretrai def test_attempting_to_deploy_in_asynchronous_mode_without_archiving_throws_exception( pretrained_model, ): - with pytest.raises(MlflowException) as exc: + with pytest.raises(MlflowException, match="Resources must be archived") as exc: mfs.deploy( app_name="test-app", model_uri=pretrained_model.model_uri, @@ -210,7 +212,6 @@ def test_attempting_to_deploy_in_asynchronous_mode_without_archiving_throws_exce synchronous=False, ) - assert "Resources must be archived" in exc.value.message assert exc.value.error_code == ErrorCode.Name(INVALID_PARAMETER_VALUE) @@ -413,12 +414,13 @@ def test_deploying_application_with_preexisting_name_in_create_mode_throws_excep app_name=app_name, model_uri=pretrained_model.model_uri, mode=mfs.DEPLOYMENT_MODE_CREATE ) - with pytest.raises(MlflowException) as exc: + with pytest.raises( + MlflowException, match="an application with the same name already exists" + ) as exc: mfs.deploy( app_name=app_name, model_uri=pretrained_model.model_uri, mode=mfs.DEPLOYMENT_MODE_CREATE ) - assert "an application with the same name already exists" in exc.value.message assert exc.value.error_code == ErrorCode.Name(INVALID_PARAMETER_VALUE) @@ -537,14 +539,13 @@ def fail_endpoint_creations(self, operation_name, operation_kwargs): with mock.patch( "botocore.client.BaseClient._make_api_call", new=fail_endpoint_creations - ), pytest.raises(MlflowException) as exc: + ), pytest.raises(MlflowException, match="deployment operation failed") as exc: mfs.deploy( app_name="test-app", model_uri=pretrained_model.model_uri, mode=mfs.DEPLOYMENT_MODE_CREATE, ) - assert "deployment operation failed" in exc.value.message assert exc.value.error_code == ErrorCode.Name(INTERNAL_ERROR) @@ -674,14 +675,12 @@ def fail_endpoint_updates(self, operation_name, operation_kwargs): with mock.patch( "botocore.client.BaseClient._make_api_call", new=fail_endpoint_updates - ), pytest.raises(MlflowException) as exc: + ), pytest.raises(MlflowException, match="deployment operation failed") as exc: mfs.deploy( app_name="test-app", model_uri=pretrained_model.model_uri, mode=mfs.DEPLOYMENT_MODE_REPLACE, ) - - assert "deployment operation failed" in exc.value.message assert exc.value.error_code == ErrorCode.Name(INTERNAL_ERROR) diff --git a/tests/sklearn/test_sklearn_model_export.py b/tests/sklearn/test_sklearn_model_export.py index d7deb6ebe428c..0e9836a4f167a 100644 --- a/tests/sklearn/test_sklearn_model_export.py +++ b/tests/sklearn/test_sklearn_model_export.py @@ -1,7 +1,5 @@ -import sys from unittest import mock import os -import pickle import pytest import yaml from collections import namedtuple @@ -232,11 +230,7 @@ def test_custom_transformer_can_be_saved_and_loaded_with_cloudpickle_format( # current test module, we expect pickle to fail when attempting to serialize it. In contrast, # we expect cloudpickle to successfully locate the transformer definition and serialize the # model successfully. - if sys.version_info >= (3, 0): - expect_exception_context = pytest.raises(AttributeError) - else: - expect_exception_context = pytest.raises(pickle.PicklingError) - with expect_exception_context: + with pytest.raises(AttributeError, match="Can't pickle local object"): pickle_format_model_path = os.path.join(str(tmpdir), "pickle_model") mlflow.sklearn.save_model( sk_model=custom_transformer_model, @@ -429,7 +423,7 @@ def test_model_log_persists_requirements_in_mlflow_model_directory( def test_model_save_throws_exception_if_serialization_format_is_unrecognized( sklearn_knn_model, model_path ): - with pytest.raises(MlflowException) as exc: + with pytest.raises(MlflowException, match="Unrecognized serialization format") as exc: mlflow.sklearn.save_model( sk_model=sklearn_knn_model.model, path=model_path, diff --git a/tests/spacy/test_spacy_model_export.py b/tests/spacy/test_spacy_model_export.py index a409f597b6e13..87315395c9068 100644 --- a/tests/spacy/test_spacy_model_export.py +++ b/tests/spacy/test_spacy_model_export.py @@ -136,7 +136,7 @@ def test_predict_df_with_wrong_shape(spacy_model_with_data, model_path): # Concatenating with itself to duplicate column and mess up input shape # then asserting n MlflowException is raised - with pytest.raises(MlflowException): + with pytest.raises(MlflowException, match="Shape of input dataframe must be"): pyfunc_loaded.predict( pd.concat( [spacy_model_with_data.inference_data, spacy_model_with_data.inference_data], axis=1 diff --git a/tests/spark/autologging/datasource/test_spark_datasource_autologging_missing_jar.py b/tests/spark/autologging/datasource/test_spark_datasource_autologging_missing_jar.py index 89b6bcd2c63be..95127b6d38f78 100644 --- a/tests/spark/autologging/datasource/test_spark_datasource_autologging_missing_jar.py +++ b/tests/spark/autologging/datasource/test_spark_datasource_autologging_missing_jar.py @@ -11,8 +11,7 @@ def test_enabling_autologging_throws_for_missing_jar(): # pylint: disable=unused-argument spark_session = _get_or_create_spark_session(jars="") try: - with pytest.raises(MlflowException) as exc: + with pytest.raises(MlflowException, match="ensure you have the mlflow-spark JAR attached"): mlflow.spark.autolog() - assert "ensure you have the mlflow-spark JAR attached" in exc.value.message finally: spark_session.stop() diff --git a/tests/spark/autologging/datasource/test_spark_datasource_autologging_unit.py b/tests/spark/autologging/datasource/test_spark_datasource_autologging_unit.py index ca91309a9c2f9..f97f6c45cd8c5 100644 --- a/tests/spark/autologging/datasource/test_spark_datasource_autologging_unit.py +++ b/tests/spark/autologging/datasource/test_spark_datasource_autologging_unit.py @@ -50,6 +50,7 @@ def test_enabling_autologging_throws_for_wrong_spark_version( with mock.patch("mlflow._spark_autologging._get_spark_major_version") as get_version_mock: get_version_mock.return_value = 2 - with pytest.raises(MlflowException) as exc: + with pytest.raises( + MlflowException, match="Spark autologging unsupported for Spark versions < 3" + ): mlflow.spark.autolog() - assert "Spark autologging unsupported for Spark versions < 3" in exc.value.message diff --git a/tests/spark/test_spark_model_export.py b/tests/spark/test_spark_model_export.py index 3b2ab941e5c70..8b4d95edb8407 100644 --- a/tests/spark/test_spark_model_export.py +++ b/tests/spark/test_spark_model_export.py @@ -259,11 +259,10 @@ def test_estimator_model_export(spark_model_estimator, model_path, spark_custom_ @pytest.mark.large def test_transformer_model_export(spark_model_transformer, model_path, spark_custom_env): - with pytest.raises(MlflowException) as e: + with pytest.raises(MlflowException, match="Cannot serialize this model"): sparkm.save_model( spark_model_transformer.model, path=model_path, conda_env=spark_custom_env ) - assert "Cannot serialize this model" in e.value.message @pytest.mark.large @@ -378,9 +377,8 @@ def test_sparkml_estimator_model_log( @pytest.mark.large def test_sparkml_model_log_invalid_args(spark_model_transformer, model_path): # pylint: disable=unused-argument - with pytest.raises(MlflowException) as e: + with pytest.raises(MlflowException, match="Cannot serialize this model"): sparkm.log_model(spark_model=spark_model_transformer.model, artifact_path="model0") - assert "Cannot serialize this model" in e.value.message @pytest.mark.large @@ -648,23 +646,12 @@ def _transform(self, dataset): unsupported_pipeline = Pipeline(stages=[CustomTransformer()]) unsupported_model = unsupported_pipeline.fit(spark_model_iris.spark_df) - with pytest.raises(ValueError): + with pytest.raises(ValueError, match="CustomTransformer"): sparkm.save_model( spark_model=unsupported_model, path=model_path, sample_input=spark_model_iris.spark_df ) -@pytest.mark.large -def test_mleap_module_model_save_with_invalid_sample_input_type_raises_exception( - spark_model_iris, model_path -): - with pytest.raises(Exception): - invalid_input = pd.DataFrame() - sparkm.save_model( - spark_model=spark_model_iris.model, path=model_path, sample_input=invalid_input - ) - - def test_shutil_copytree_without_file_permissions(tmpdir): src_dir = tmpdir.mkdir("src-dir") dst_dir = tmpdir.mkdir("dst-dir") diff --git a/tests/statsmodels/test_statsmodels_autolog.py b/tests/statsmodels/test_statsmodels_autolog.py index 339e2dbc00c8c..ffd0a28b23142 100644 --- a/tests/statsmodels/test_statsmodels_autolog.py +++ b/tests/statsmodels/test_statsmodels_autolog.py @@ -149,7 +149,8 @@ def as_text(self): def test_statsmodels_autolog_works_after_exception(): mlflow.statsmodels.autolog() # We first fit a model known to raise an exception - pytest.raises(Exception, failing_logit_model) + with pytest.raises(Exception, match=r".+"): + failing_logit_model() # and then fit another one that should go well model_with_results = ols_model() diff --git a/tests/store/artifact/test_azure_blob_artifact_repo.py b/tests/store/artifact/test_azure_blob_artifact_repo.py index be6b3a1166b82..0d3f15eaa0bd1 100644 --- a/tests/store/artifact/test_azure_blob_artifact_repo.py +++ b/tests/store/artifact/test_azure_blob_artifact_repo.py @@ -358,7 +358,7 @@ def get_mock_listing(*args, **kwargs): mock_client.get_container_client().walk_blobs.side_effect = get_mock_listing - with pytest.raises(MlflowException) as exc: + with pytest.raises( + MlflowException, match="Azure blob does not begin with the specified artifact path" + ): repo.download_artifacts("") - - assert "Azure blob does not begin with the specified artifact path" in str(exc) diff --git a/tests/store/artifact/test_databricks_artifact_repo.py b/tests/store/artifact/test_databricks_artifact_repo.py index e0e1d1ae5e003..097a7cf75fbb4 100644 --- a/tests/store/artifact/test_databricks_artifact_repo.py +++ b/tests/store/artifact/test_databricks_artifact_repo.py @@ -90,11 +90,11 @@ def test_init_validation_and_cleaning(self): assert repo.run_id == MOCK_RUN_ID assert repo.run_relative_artifact_repo_root_path == "" - with pytest.raises(MlflowException): + with pytest.raises(MlflowException, match="DBFS URI must be of the form dbfs"): DatabricksArtifactRepository("s3://test") - with pytest.raises(MlflowException): + with pytest.raises(MlflowException, match="Artifact URI incorrect"): DatabricksArtifactRepository("dbfs:/databricks/mlflow/EXP/RUN/artifact") - with pytest.raises(MlflowException): + with pytest.raises(MlflowException, match="DBFS URI must be of the form dbfs"): DatabricksArtifactRepository( "dbfs://scope:key@notdatabricks/databricks/mlflow-tracking/experiment/1/run/2" ) @@ -270,7 +270,7 @@ def test_log_artifact_azure_blob_client_sas_error(self, databricks_artifact_repo ) write_credential_infos_mock.return_value = [mock_credential_info] mock_create_blob_client.side_effect = MlflowException("MOCK ERROR") - with pytest.raises(MlflowException): + with pytest.raises(MlflowException, match=r".+"): databricks_artifact_repo.log_artifact(test_file.strpath) write_credential_infos_mock.assert_called_with(run_id=MOCK_RUN_ID, paths=ANY) @@ -336,7 +336,7 @@ def test_log_artifact_aws_presigned_url_error(self, databricks_artifact_repo, te ) write_credential_infos_mock.return_value = [mock_credential_info] request_mock.side_effect = MlflowException("MOCK ERROR") - with pytest.raises(MlflowException): + with pytest.raises(MlflowException, match="MOCK ERROR"): databricks_artifact_repo.log_artifact(test_file.strpath) write_credential_infos_mock.assert_called_with(run_id=MOCK_RUN_ID, paths=ANY) @@ -402,7 +402,7 @@ def test_log_artifact_gcp_presigned_url_error(self, databricks_artifact_repo, te ) write_credential_infos_mock.return_value = [mock_credential_info] request_mock.side_effect = MlflowException("MOCK ERROR") - with pytest.raises(MlflowException): + with pytest.raises(MlflowException, match="MOCK ERROR"): databricks_artifact_repo.log_artifact(test_file.strpath) write_credential_infos_mock.assert_called_with(run_id=MOCK_RUN_ID, paths=ANY) @@ -868,7 +868,7 @@ def test_databricks_download_file_get_request_fail(self, databricks_artifact_rep read_credential_infos_mock.return_value = [mock_credential_info] get_list_mock.return_value = [] request_mock.return_value = MlflowException("MOCK ERROR") - with pytest.raises(MlflowException): + with pytest.raises(MlflowException, match=r".+"): databricks_artifact_repo.download_artifacts(test_file.strpath) read_credential_infos_mock.assert_called_with( run_id=MOCK_RUN_ID, paths=[test_file.strpath] @@ -1008,7 +1008,12 @@ def test_download_artifacts_provides_failure_info(self, databricks_artifact_repo MlflowException("MOCK ERROR 2"), ] - with pytest.raises(MlflowException) as exc: + match = ( + r"The following failures occurred while downloading one or more artifacts.+" + r"MOCK ERROR 1.+" + r"MOCK ERROR 2" + ) + with pytest.raises(MlflowException, match=match) as exc: databricks_artifact_repo.download_artifacts("test_path") err_msg = str(exc.value) @@ -1043,8 +1048,12 @@ def test_log_artifacts_provides_failure_info(self, databricks_artifact_repo, tmp MlflowException("MOCK ERROR 1"), MlflowException("MOCK ERROR 2"), ] - - with pytest.raises(MlflowException) as exc: + match = ( + r"The following failures occurred while uploading one or more artifacts.+" + r"MOCK ERROR 1.+" + r"MOCK ERROR 2" + ) + with pytest.raises(MlflowException, match=match) as exc: databricks_artifact_repo.log_artifacts(str(tmpdir), "test_artifacts") err_msg = str(exc.value) diff --git a/tests/store/artifact/test_databricks_models_artifact_repo.py b/tests/store/artifact/test_databricks_models_artifact_repo.py index 6876fedff6191..bcce2d170044b 100644 --- a/tests/store/artifact/test_databricks_models_artifact_repo.py +++ b/tests/store/artifact/test_databricks_models_artifact_repo.py @@ -77,7 +77,10 @@ def test_init_with_stage_uri_containing_profile(self, stage_uri_with_profile): ], ) def test_init_with_invalid_artifact_uris(self, invalid_artifact_uri): - with pytest.raises(MlflowException): + with pytest.raises( + MlflowException, + match="A valid databricks profile is required to instantiate this repository", + ): DatabricksModelsArtifactRepository(invalid_artifact_uri) def test_init_with_version_uri_and_profile_is_inferred(self): @@ -132,7 +135,10 @@ def test_init_with_valid_uri_but_no_profile(self, valid_profileless_artifact_uri "mlflow.store.artifact.utils.models.mlflow.get_registry_uri", return_value=None, ): - with pytest.raises(MlflowException): + with pytest.raises( + MlflowException, + match="A valid databricks profile is required to instantiate this repository", + ): DatabricksModelsArtifactRepository(valid_profileless_artifact_uri) def test_list_artifacts(self, databricks_model_artifact_repo): @@ -207,17 +213,24 @@ def test_download_file_get_request_fail(self, databricks_model_artifact_repo): DATABRICKS_MODEL_ARTIFACT_REPOSITORY + "._call_endpoint" ) as call_endpoint_mock: call_endpoint_mock.side_effect = MlflowException("MOCK ERROR") - with pytest.raises(MlflowException): + with pytest.raises(MlflowException, match=r".+"): databricks_model_artifact_repo.download_artifacts("Something") def test_log_artifact_fail(self, databricks_model_artifact_repo): - with pytest.raises(MlflowException): + with pytest.raises( + MlflowException, match="This repository does not support logging artifacts" + ): databricks_model_artifact_repo.log_artifact("Some file") def test_log_artifacts_fail(self, databricks_model_artifact_repo): - with pytest.raises(MlflowException): + with pytest.raises( + MlflowException, match="This repository does not support logging artifacts" + ): databricks_model_artifact_repo.log_artifacts("Some dir") def test_delete_artifacts_fail(self, databricks_model_artifact_repo): - with pytest.raises(NotImplementedError): + with pytest.raises( + NotImplementedError, + match="This artifact repository does not support deleting artifacts", + ): databricks_model_artifact_repo.delete_artifacts() diff --git a/tests/store/artifact/test_dbfs_artifact_repo.py b/tests/store/artifact/test_dbfs_artifact_repo.py index 3d4412bab94e2..cdeb3b766c124 100644 --- a/tests/store/artifact/test_dbfs_artifact_repo.py +++ b/tests/store/artifact/test_dbfs_artifact_repo.py @@ -80,5 +80,5 @@ def test_dbfs_artifact_repo_factory_acled_paths(artifact_uri): "artifact_uri", [("notdbfs:/path"), ("dbfs://some:where@notdatabricks/path")] ) def test_dbfs_artifact_repo_factory_errors(artifact_uri): - with pytest.raises(MlflowException): + with pytest.raises(MlflowException, match="DBFS URI must be of the form dbfs"): dbfs_artifact_repo_factory(artifact_uri) diff --git a/tests/store/artifact/test_dbfs_rest_artifact_repo.py b/tests/store/artifact/test_dbfs_rest_artifact_repo.py index 44d28283509e9..78663a22950c5 100644 --- a/tests/store/artifact/test_dbfs_rest_artifact_repo.py +++ b/tests/store/artifact/test_dbfs_rest_artifact_repo.py @@ -72,9 +72,10 @@ def test_init_validation_and_cleaning(self): get_creds_mock.return_value = lambda: MlflowHostCreds("http://host") repo = get_artifact_repository("dbfs:/test/") assert repo.artifact_uri == "dbfs:/test" - with pytest.raises(MlflowException): + match = "DBFS URI must be of the form dbfs:/" + with pytest.raises(MlflowException, match=match): DbfsRestArtifactRepository("s3://test") - with pytest.raises(MlflowException): + with pytest.raises(MlflowException, match=match): DbfsRestArtifactRepository("dbfs://profile@notdatabricks/test/") def test_init_get_host_creds_with_databricks_profile_uri(self): @@ -140,7 +141,7 @@ def my_http_request(host_creds, **kwargs): # pylint: disable=unused-argument def test_log_artifact_error(self, dbfs_artifact_repo, test_file): with mock.patch("mlflow.utils.rest_utils.http_request") as http_request_mock: http_request_mock.return_value = Mock(status_code=409, text="") - with pytest.raises(MlflowException): + with pytest.raises(MlflowException, match=r"API request to endpoint .+ failed"): dbfs_artifact_repo.log_artifact(test_file.strpath) @pytest.mark.parametrize( @@ -180,7 +181,7 @@ def my_http_request(host_creds, **kwargs): # pylint: disable=unused-argument def test_log_artifacts_error(self, dbfs_artifact_repo, test_dir): with mock.patch("mlflow.utils.rest_utils.http_request") as http_request_mock: http_request_mock.return_value = Mock(status_code=409, text="") - with pytest.raises(MlflowException): + with pytest.raises(MlflowException, match=r"API request to endpoint .+ failed"): dbfs_artifact_repo.log_artifacts(test_dir.strpath) @pytest.mark.parametrize( @@ -262,7 +263,7 @@ def test_download_artifacts(self, dbfs_artifact_repo): def test_get_host_creds_from_default_store_file_store(): with mock.patch("mlflow.tracking._tracking_service.utils._get_store") as get_store_mock: get_store_mock.return_value = FileStore() - with pytest.raises(MlflowException): + with pytest.raises(MlflowException, match="Failed to get credentials for DBFS"): _get_host_creds_from_default_store() diff --git a/tests/store/artifact/test_hdfs_artifact_repo.py b/tests/store/artifact/test_hdfs_artifact_repo.py index 4e3436787f351..cd7363e20cd28 100644 --- a/tests/store/artifact/test_hdfs_artifact_repo.py +++ b/tests/store/artifact/test_hdfs_artifact_repo.py @@ -199,7 +199,7 @@ def test_parse_extra_conf(): } assert _parse_extra_conf(None) is None - with pytest.raises(Exception): + with pytest.raises(ValueError, match="not enough values to unpack "): _parse_extra_conf("missing_equals_sign") diff --git a/tests/store/artifact/test_local_artifact_repo.py b/tests/store/artifact/test_local_artifact_repo.py index 5bac211869912..54d97e01925e6 100644 --- a/tests/store/artifact/test_local_artifact_repo.py +++ b/tests/store/artifact/test_local_artifact_repo.py @@ -141,9 +141,8 @@ def test_artifacts_are_logged_to_and_downloaded_from_repo_subdirectory_successfu def test_log_artifact_throws_exception_for_invalid_artifact_paths(local_artifact_repo): with TempDir() as local_dir: for bad_artifact_path in ["/", "//", "/tmp", "/bad_path", ".", "../terrible_path"]: - with pytest.raises(MlflowException) as exc_info: + with pytest.raises(MlflowException, match="Invalid artifact path"): local_artifact_repo.log_artifact(local_dir.path(), bad_artifact_path) - assert "Invalid artifact path" in str(exc_info) def test_logging_directory_of_artifacts_produces_expected_repo_contents(local_artifact_repo): diff --git a/tests/store/artifact/test_runs_artifact_repo.py b/tests/store/artifact/test_runs_artifact_repo.py index 57d2f3e687f47..e56dc6c58479f 100644 --- a/tests/store/artifact/test_runs_artifact_repo.py +++ b/tests/store/artifact/test_runs_artifact_repo.py @@ -36,7 +36,7 @@ def test_parse_runs_uri_valid_input(uri, expected_run_id, expected_artifact_path ], ) def test_parse_runs_uri_invalid_input(uri): - with pytest.raises(MlflowException): + with pytest.raises(MlflowException, match="Not a proper runs"): RunsArtifactRepository.parse_runs_uri(uri) diff --git a/tests/store/artifact/test_s3_artifact_repo.py b/tests/store/artifact/test_s3_artifact_repo.py index fab28309d83b3..f043159fef238 100644 --- a/tests/store/artifact/test_s3_artifact_repo.py +++ b/tests/store/artifact/test_s3_artifact_repo.py @@ -1,6 +1,7 @@ import os import posixpath import tarfile +import json from datetime import datetime import pytest @@ -276,7 +277,7 @@ def test_get_s3_file_upload_extra_args_invalid_json(): "MLFLOW_S3_UPLOAD_EXTRA_ARGS", '"ServerSideEncryption": "aws:kms", "SSEKMSKeyId": "123456"}' ) - with pytest.raises(ValueError): + with pytest.raises(json.decoder.JSONDecodeError, match=r".+"): S3ArtifactRepository.get_s3_file_upload_extra_args() diff --git a/tests/store/artifact/test_sftp_artifact_repo.py b/tests/store/artifact/test_sftp_artifact_repo.py index aa488e46c53a4..1bf02100e18a3 100644 --- a/tests/store/artifact/test_sftp_artifact_repo.py +++ b/tests/store/artifact/test_sftp_artifact_repo.py @@ -18,7 +18,7 @@ def sftp_mock(): def test_artifact_uri_factory(): from paramiko.ssh_exception import SSHException - with pytest.raises(SSHException): + with pytest.raises(SSHException, match="No hostkey for host test_sftp found"): get_artifact_repository("sftp://user:pass@test_sftp:123/some/path") diff --git a/tests/store/artifact/utils/test_model_utils.py b/tests/store/artifact/utils/test_model_utils.py index 496aaaadd2319..deee4487df35a 100644 --- a/tests/store/artifact/utils/test_model_utils.py +++ b/tests/store/artifact/utils/test_model_utils.py @@ -47,5 +47,5 @@ def test_parse_models_uri_with_stage(uri, expected_name, expected_stage): ], ) def test_parse_models_uri_invalid_input(uri): - with pytest.raises(MlflowException): + with pytest.raises(MlflowException, match="Not a proper models"): _parse_model_uri(uri) diff --git a/tests/store/tracking/test_file_store.py b/tests/store/tracking/test_file_store.py index a784004cca46c..4cc948062fc4b 100644 --- a/tests/store/tracking/test_file_store.py +++ b/tests/store/tracking/test_file_store.py @@ -352,7 +352,9 @@ def test_rename_experiment(self): # Ensure that we cannot rename deleted experiments. fs.delete_experiment(exp_id) - with pytest.raises(Exception) as e: + with pytest.raises( + Exception, match="Cannot rename experiment in non-active lifecycle stage" + ) as e: fs.rename_experiment(exp_id, exp_name) assert "non-active lifecycle" in str(e.value) self.assertEqual(fs.get_experiment(exp_id).name, new_name) @@ -449,7 +451,7 @@ def test_create_run_in_deleted_experiment(self): exp_id = self.experiments[random_int(0, len(self.experiments) - 1)] # delete it fs.delete_experiment(exp_id) - with pytest.raises(Exception): + with pytest.raises(Exception, match="Could not create run under non-active experiment"): fs.create_run(exp_id, "user", 0, []) def test_create_run_returns_expected_run_data(self): @@ -795,7 +797,7 @@ def test_set_experiment_tags(self): assert experiment.tags["multiline_tag"] == "value2\nvalue2\nvalue2" # test cannot set tags on deleted experiments fs.delete_experiment(exp_id) - with pytest.raises(MlflowException): + with pytest.raises(MlflowException, match="must be in the 'active'lifecycle_stage"): fs.set_experiment_tag(exp_id, ExperimentTag("should", "notset")) def test_set_tags(self): @@ -831,16 +833,16 @@ def test_delete_tags(self): new_tags = fs.get_run(run_id).data.tags assert "tag0" not in new_tags.keys() # test that you cannot delete tags that don't exist. - with pytest.raises(MlflowException): + with pytest.raises(MlflowException, match="No tag with name"): fs.delete_tag(run_id, "fakeTag") # test that you cannot delete tags for nonexistent runs - with pytest.raises(MlflowException): + with pytest.raises(MlflowException, match=r"Run .+ not found"): fs.delete_tag("random_id", "tag0") fs = FileStore(self.test_root) fs.delete_run(run_id) # test that you cannot delete tags for deleted runs. assert fs.get_run(run_id).info.lifecycle_stage == LifecycleStage.DELETED - with pytest.raises(MlflowException): + with pytest.raises(MlflowException, match="must be in 'active' lifecycle_stage"): fs.delete_tag(run_id, "tag0") def test_unicode_tag(self): @@ -871,11 +873,12 @@ def test_set_deleted_run(self): fs.delete_run(run_id) assert fs.get_run(run_id).info.lifecycle_stage == LifecycleStage.DELETED - with pytest.raises(MlflowException): + match = "must be in 'active' lifecycle_stage" + with pytest.raises(MlflowException, match=match): fs.set_tag(run_id, RunTag("a", "b")) - with pytest.raises(MlflowException): + with pytest.raises(MlflowException, match=match): fs.log_metric(run_id, Metric("a", 0.0, timestamp=0, step=0)) - with pytest.raises(MlflowException): + with pytest.raises(MlflowException, match=match): fs.log_param(run_id, Param("a", "b")) def test_default_experiment_initialization(self): @@ -895,7 +898,7 @@ def test_malformed_experiment(self): # delete metadata file. path = os.path.join(self.test_root, str(exp_0.experiment_id), "meta.yaml") os.remove(path) - with pytest.raises(MissingConfigException) as e: + with pytest.raises(MissingConfigException, match="does not exist") as e: fs.get_experiment(FileStore.DEFAULT_EXPERIMENT_ID) assert e.message.contains("does not exist") @@ -913,9 +916,8 @@ def test_malformed_run(self): bad_run_id = self.exp_data[exp_0.experiment_id]["runs"][0] path = os.path.join(self.test_root, str(exp_0.experiment_id), str(bad_run_id), "meta.yaml") os.remove(path) - with pytest.raises(MissingConfigException) as e: + with pytest.raises(MissingConfigException, match="does not exist"): fs.get_run(bad_run_id) - assert e.message.contains("does not exist") valid_runs = self._search(fs, exp_0.experiment_id) assert len(valid_runs) == len(all_runs) - 1 @@ -937,13 +939,11 @@ def test_mismatching_experiment_id(self): path_new = os.path.join(self.test_root, str(target)) os.rename(path_orig, path_new) - with pytest.raises(MlflowException) as e: + with pytest.raises(MlflowException, match="Could not find experiment with ID"): fs.get_experiment(FileStore.DEFAULT_EXPERIMENT_ID) - assert e.message.contains("Could not find experiment with ID") - with pytest.raises(MlflowException) as e: + with pytest.raises(MlflowException, match="does not exist"): fs.get_experiment(target) - assert e.message.contains("does not exist") assert len(fs.list_experiments(ViewType.ALL)) == experiments - 1 def test_bad_experiment_id_recorded_for_run(self): @@ -961,9 +961,8 @@ def test_bad_experiment_id_recorded_for_run(self): experiment_data["experiment_id"] = 1 write_yaml(path, "meta.yaml", experiment_data, True) - with pytest.raises(MlflowException) as e: + with pytest.raises(MlflowException, match="metadata is in invalid state"): fs.get_run(bad_run_id) - assert e.message.contains("not found") valid_runs = self._search(fs, exp_0.experiment_id) assert len(valid_runs) == len(all_runs) - 1 diff --git a/tests/store/tracking/test_rest_store.py b/tests/store/tracking/test_rest_store.py index dec7eed6ce730..be25422014f46 100644 --- a/tests/store/tracking/test_rest_store.py +++ b/tests/store/tracking/test_rest_store.py @@ -56,7 +56,7 @@ class MyCoolException(Exception): class CustomErrorHandlingRestStore(RestStore): def _call_endpoint(self, api, json_body): - raise MyCoolException() + raise MyCoolException("cool") def mock_http_request(): @@ -98,9 +98,8 @@ def test_failed_http_request(self, request): request.return_value = response store = RestStore(lambda: MlflowHostCreds("https://hello")) - with pytest.raises(MlflowException) as cm: + with pytest.raises(MlflowException, match="RESOURCE_DOES_NOT_EXIST: No experiment"): store.list_experiments() - assert "RESOURCE_DOES_NOT_EXIST: No experiment" in str(cm.value) @mock.patch("requests.Session.request") def test_failed_http_request_custom_handler(self, request): @@ -110,7 +109,7 @@ def test_failed_http_request_custom_handler(self, request): request.return_value = response store = CustomErrorHandlingRestStore(lambda: MlflowHostCreds("https://hello")) - with pytest.raises(MyCoolException): + with pytest.raises(MyCoolException, match="cool"): store.list_experiments() @mock.patch("requests.Session.request") @@ -411,7 +410,7 @@ def rate_limit_response_fn(*args, **kwargs): ) mock_http.side_effect = rate_limit_response_fn - with pytest.raises(MlflowException) as exc_info: + with pytest.raises(MlflowException, match="Hit rate limit") as exc_info: store.get_experiment_by_name("imspamming") assert exc_info.value.error_code == ErrorCode.Name(REQUEST_LIMIT_EXCEEDED) assert mock_http.call_count == 1 @@ -428,10 +427,9 @@ def rate_limit_response_fn(*args, **kwargs): raise MlflowException("Some internal error!", INTERNAL_ERROR) mock_http.side_effect = rate_limit_response_fn - with pytest.raises(MlflowException) as exc_info: + with pytest.raises(MlflowException, match="Some internal error!") as exc_info: store.get_experiment_by_name("abc") assert exc_info.value.error_code == ErrorCode.Name(INTERNAL_ERROR) - assert exc_info.value.message == "Some internal error!" expected_message0 = GetExperimentByName(experiment_name="abc") self._verify_requests( mock_http, diff --git a/tests/store/tracking/test_sqlalchemy_store.py b/tests/store/tracking/test_sqlalchemy_store.py index 99ac52bbc868f..a161abf1d5752 100644 --- a/tests/store/tracking/test_sqlalchemy_store.py +++ b/tests/store/tracking/test_sqlalchemy_store.py @@ -813,14 +813,14 @@ def test_set_experiment_tag(self): self.assertTrue(experiment.tags["multiline tag"] == "value2\nvalue2\nvalue2") # test cannot set tags that are too long longTag = entities.ExperimentTag("longTagKey", "a" * 5001) - with pytest.raises(MlflowException): + with pytest.raises(MlflowException, match="exceeded length limit of 5000"): self.store.set_experiment_tag(exp_id, longTag) # test can set tags that are somewhat long longTag = entities.ExperimentTag("longTagKey", "a" * 4999) self.store.set_experiment_tag(exp_id, longTag) # test cannot set tags on deleted experiments self.store.delete_experiment(exp_id) - with pytest.raises(MlflowException): + with pytest.raises(MlflowException, match="must be in the 'active' state"): self.store.set_experiment_tag(exp_id, entities.ExperimentTag("should", "notset")) def test_set_tag(self): @@ -835,7 +835,7 @@ def test_set_tag(self): # Overwriting tags is allowed self.store.set_tag(run.info.run_id, new_tag) # test setting tags that are too long fails. - with pytest.raises(MlflowException): + with pytest.raises(MlflowException, match="exceeded length limit of 5000"): self.store.set_tag(run.info.run_id, entities.RunTag("longTagKey", "a" * 5001)) # test can set tags that are somewhat long self.store.set_tag(run.info.run_id, entities.RunTag("longTagKey", "a" * 4999)) @@ -866,14 +866,14 @@ def test_delete_tag(self): self.assertTrue(k0 not in run.data.tags) self.assertTrue(k0 in run2.data.tags) # test that you cannot delete tags that don't exist. - with pytest.raises(MlflowException): + with pytest.raises(MlflowException, match="No tag with name"): self.store.delete_tag(run.info.run_id, "fakeTag") # test that you cannot delete tags for nonexistent runs - with pytest.raises(MlflowException): + with pytest.raises(MlflowException, match="Run with id=randomRunId not found"): self.store.delete_tag("randomRunId", k0) # test that you cannot delete tags for deleted runs. self.store.delete_run(run.info.run_id) - with pytest.raises(MlflowException): + with pytest.raises(MlflowException, match="must be in the 'active' state"): self.store.delete_tag(run.info.run_id, k1) def test_get_metric_history(self): diff --git a/tests/store/tracking/test_sqlalchemy_store_schema.py b/tests/store/tracking/test_sqlalchemy_store_schema.py index e68ec3035e1ec..b3b416a3e326e 100644 --- a/tests/store/tracking/test_sqlalchemy_store_schema.py +++ b/tests/store/tracking/test_sqlalchemy_store_schema.py @@ -97,9 +97,8 @@ def test_sqlalchemy_store_detects_schema_mismatch( tmpdir, db_url ): # pylint: disable=unused-argument def _assert_invalid_schema(engine): - with pytest.raises(MlflowException) as ex: + with pytest.raises(MlflowException, match="Detected out-of-date database schema."): _verify_schema(engine) - assert ex.message.contains("Detected out-of-date database schema.") # Initialize an empty database & verify that we detect a schema mismatch engine = sqlalchemy.create_engine(db_url) diff --git a/tests/tensorflow/test_tensorflow2_model_export.py b/tests/tensorflow/test_tensorflow2_model_export.py index 45e5a575f17b7..abcecd5a45f86 100644 --- a/tests/tensorflow/test_tensorflow2_model_export.py +++ b/tests/tensorflow/test_tensorflow2_model_export.py @@ -316,7 +316,7 @@ def test_schema_and_examples_are_save_correctly(saved_tf_iris_model): def test_save_model_with_invalid_path_signature_def_or_metagraph_tags_throws_exception( saved_tf_iris_model, model_path ): - with pytest.raises(IOError): + with pytest.raises(IOError, match=r".+"): mlflow.tensorflow.save_model( tf_saved_model_dir="not_a_valid_tf_model_dir", tf_meta_graph_tags=saved_tf_iris_model.meta_graph_tags, @@ -324,7 +324,7 @@ def test_save_model_with_invalid_path_signature_def_or_metagraph_tags_throws_exc path=model_path, ) - with pytest.raises(RuntimeError): + with pytest.raises(RuntimeError, match=r".+"): mlflow.tensorflow.save_model( tf_saved_model_dir=saved_tf_iris_model.path, tf_meta_graph_tags=["bad tags"], @@ -332,7 +332,7 @@ def test_save_model_with_invalid_path_signature_def_or_metagraph_tags_throws_exc path=model_path, ) - with pytest.raises(MlflowException): + with pytest.raises(MlflowException, match="Could not find signature def key bad signature"): mlflow.tensorflow.save_model( tf_saved_model_dir=saved_tf_iris_model.path, tf_meta_graph_tags=saved_tf_iris_model.meta_graph_tags, @@ -340,7 +340,7 @@ def test_save_model_with_invalid_path_signature_def_or_metagraph_tags_throws_exc path=model_path, ) - with pytest.raises(IOError): + with pytest.raises(IOError, match=r".+"): mlflow.tensorflow.save_model( tf_saved_model_dir="bad path", tf_meta_graph_tags="bad tags", @@ -369,7 +369,7 @@ def test_load_model_loads_artifacts_from_specified_model_directory(saved_tf_iris def test_log_model_with_non_keyword_args_fails(saved_tf_iris_model): artifact_path = "model" with mlflow.start_run(): - with pytest.raises(TypeError): + with pytest.raises(TypeError, match="only takes keyword arguments"): mlflow.tensorflow.log_model( saved_tf_iris_model.path, saved_tf_iris_model.meta_graph_tags, @@ -690,7 +690,7 @@ def test_iris_data_model_can_be_loaded_and_evaluated_as_pyfunc(saved_tf_iris_mod inp_list = [] for df_col_name in list(saved_tf_iris_model.inference_df): inp_list.append(saved_tf_iris_model.inference_df[df_col_name].values) - with pytest.raises(TypeError): + with pytest.raises(TypeError, match="Only dict and DataFrame input types are supported"): results = pyfunc_wrapper.predict(inp_list) @@ -730,7 +730,7 @@ def test_categorical_model_can_be_loaded_and_evaluated_as_pyfunc( inp_list = [] for df_col_name in list(saved_tf_categorical_model.inference_df): inp_list.append(saved_tf_categorical_model.inference_df[df_col_name].values) - with pytest.raises(TypeError): + with pytest.raises(TypeError, match="Only dict and DataFrame input types are supported"): results = pyfunc_wrapper.predict(inp_list) diff --git a/tests/test_cli.py b/tests/test_cli.py index e5275d9ec8e5a..676d9dcc3610d 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -156,7 +156,7 @@ def test_mlflow_gc_sqlite(sqlite_store): subprocess.check_output(["mlflow", "gc", "--backend-store-uri", sqlite_store[1]]) runs = store.search_runs(experiment_ids=["0"], filter_string="", run_view_type=ViewType.ALL) assert len(runs) == 0 - with pytest.raises(MlflowException): + with pytest.raises(MlflowException, match=r"Run .+ not found"): store.get_run(run.info.run_uuid) @@ -167,7 +167,7 @@ def test_mlflow_gc_file_store(file_store): subprocess.check_output(["mlflow", "gc", "--backend-store-uri", file_store[1]]) runs = store.search_runs(experiment_ids=["0"], filter_string="", run_view_type=ViewType.ALL) assert len(runs) == 0 - with pytest.raises(MlflowException): + with pytest.raises(MlflowException, match=r"Run .+ not found"): store.get_run(run.info.run_uuid) @@ -180,14 +180,14 @@ def test_mlflow_gc_file_store_passing_explicit_run_ids(file_store): ) runs = store.search_runs(experiment_ids=["0"], filter_string="", run_view_type=ViewType.ALL) assert len(runs) == 0 - with pytest.raises(MlflowException): + with pytest.raises(MlflowException, match=r"Run .+ not found"): store.get_run(run.info.run_uuid) def test_mlflow_gc_not_deleted_run(file_store): store = file_store[0] run = _create_run_in_store(store) - with pytest.raises(subprocess.CalledProcessError): + with pytest.raises(subprocess.CalledProcessError, match=r".+"): subprocess.check_output( ["mlflow", "gc", "--backend-store-uri", file_store[1], "--run-ids", run.info.run_uuid] ) diff --git a/tests/test_skinny_client_omits_data_science_libs.py b/tests/test_skinny_client_omits_data_science_libs.py index 43df518fdbc92..ae0b0dc7330cf 100644 --- a/tests/test_skinny_client_omits_data_science_libs.py +++ b/tests/test_skinny_client_omits_data_science_libs.py @@ -13,7 +13,7 @@ def test_fails_import_flask(): assert mlflow is not None - with pytest.raises(ImportError): + with pytest.raises(ImportError, match="flask"): import flask assert flask is not None @@ -24,7 +24,7 @@ def test_fails_import_pandas(): assert mlflow is not None - with pytest.raises(ImportError): + with pytest.raises(ImportError, match="pandas"): import pandas assert pandas is not None @@ -35,7 +35,7 @@ def test_fails_import_numpy(): assert mlflow is not None - with pytest.raises(ImportError): + with pytest.raises(ImportError, match="numpy"): import numpy assert numpy is not None diff --git a/tests/test_skinny_client_omits_sql_libs.py b/tests/test_skinny_client_omits_sql_libs.py index 1f4783ccebd5b..af688cfccd37c 100644 --- a/tests/test_skinny_client_omits_sql_libs.py +++ b/tests/test_skinny_client_omits_sql_libs.py @@ -10,7 +10,7 @@ def test_fails_import_sqlalchemy(): assert mlflow is not None # pylint or flake8 disabling is not working - with pytest.raises(ImportError): + with pytest.raises(ImportError, match="sqlalchemy"): import sqlalchemy # pylint: disable=unused-import assert sqlalchemy is not None # pylint or flake8 disabling is not working diff --git a/tests/tracking/_model_registry/test_model_registry_client.py b/tests/tracking/_model_registry/test_model_registry_client.py index 1b2bb30bc0369..f67aa80ae12a7 100644 --- a/tests/tracking/_model_registry/test_model_registry_client.py +++ b/tests/tracking/_model_registry/test_model_registry_client.py @@ -99,7 +99,7 @@ def test_rename_registered_model(mock_store): def test_update_registered_model_validation_errors_on_empty_new_name(mock_store): # pylint: disable=unused-argument - with pytest.raises(MlflowException): + with pytest.raises(MlflowException, match="The name must not be an empty string"): newModelRegistryClient().rename_registered_model("Model 1", " ") @@ -208,7 +208,7 @@ def test_create_model_version_when_wait_exceeds_time(mock_store): mock_store.create_model_version.return_value = mv mock_store.get_model_version.return_value = mv - with pytest.raises(MlflowException): + with pytest.raises(MlflowException, match="Exceeded max wait time"): newModelRegistryClient().create_model_version( name, "uri:/source", "run123", await_creation_for=1 ) @@ -318,7 +318,7 @@ def test_transition_model_version_stage(mock_store): def test_transition_model_version_stage_validation_errors(mock_store): # pylint: disable=unused-argument - with pytest.raises(MlflowException): + with pytest.raises(MlflowException, match="The stage must not be an empty string"): newModelRegistryClient().transition_model_version_stage("Model 1", "12", stage=" ") diff --git a/tests/tracking/_model_registry/test_model_registry_fluent.py b/tests/tracking/_model_registry/test_model_registry_fluent.py index c413cc3a23ec4..82322affed414 100644 --- a/tests/tracking/_model_registry/test_model_registry_fluent.py +++ b/tests/tracking/_model_registry/test_model_registry_fluent.py @@ -25,9 +25,11 @@ def test_register_model_raises_exception_with_unsupported_registry_store(): old_registry_uri = get_registry_uri() try: set_registry_uri(tmp.path()) - with pytest.raises(MlflowException) as exc: + with pytest.raises( + MlflowException, match="Model Registry features are not supported" + ) as exc: register_model(model_uri="runs:/1234/some_model", name="testmodel") - assert exc.value.error_code == ErrorCode.Name(FEATURE_DISABLED) + assert exc.value.error_code == ErrorCode.Name(FEATURE_DISABLED) finally: set_registry_uri(old_registry_uri) @@ -104,7 +106,7 @@ def test_register_model_with_unexpected_mlflow_exception_in_create_registered_mo "create_registered_model", side_effect=MlflowException("Dunno", INTERNAL_ERROR), ) - with create_model_patch, pytest.raises(MlflowException): + with create_model_patch, pytest.raises(MlflowException, match="Dunno"): register_model("s3:/some/path/to/model", "Model 1") MlflowClient.create_registered_model.assert_called_once_with("Model 1") @@ -113,6 +115,6 @@ def test_register_model_with_unexpected_exception_in_create_registered_model(): create_model_patch = mock.patch.object( MlflowClient, "create_registered_model", side_effect=Exception("Dunno") ) - with create_model_patch, pytest.raises(Exception): + with create_model_patch, pytest.raises(Exception, match="Dunno"): register_model("s3:/some/path/to/model", "Model 1") MlflowClient.create_registered_model.assert_called_once_with("Model 1") diff --git a/tests/tracking/_model_registry/test_utils.py b/tests/tracking/_model_registry/test_utils.py index 73ab9b20f8895..bf54ff188b73c 100644 --- a/tests/tracking/_model_registry/test_utils.py +++ b/tests/tracking/_model_registry/test_utils.py @@ -4,12 +4,12 @@ import pytest from unittest import mock -from mlflow.exceptions import MlflowException from mlflow.store.db.db_types import DATABASE_ENGINES from mlflow.store.model_registry.sqlalchemy_store import SqlAlchemyStore from mlflow.store.model_registry.rest_store import RestStore from mlflow.tracking._model_registry.utils import _get_store, get_registry_uri, set_registry_uri from mlflow.tracking._tracking_service.utils import _TRACKING_URI_ENV_VAR +from mlflow.tracking.registry import UnsupportedModelRegistryStoreURIException # Disable mocking tracking URI here, as we want to test setting the tracking URI via @@ -98,12 +98,14 @@ def test_get_store_sqlalchemy_store(db_type): def test_get_store_bad_uris(bad_uri): env = {_TRACKING_URI_ENV_VAR: bad_uri} - with mock.patch.dict(os.environ, env), pytest.raises(MlflowException): + with mock.patch.dict(os.environ, env), pytest.raises( + UnsupportedModelRegistryStoreURIException, + match="Model registry functionality is unavailable", + ): _get_store() def test_get_store_caches_on_store_uri(tmpdir): - help(tmpdir) store_uri_1 = "sqlite:///" + tmpdir.join("store1.db").strpath store_uri_2 = "sqlite:///" + tmpdir.join("store2.db").strpath diff --git a/tests/tracking/_tracking_service/test_utils.py b/tests/tracking/_tracking_service/test_utils.py index bda83661cd26e..407e1699abb08 100644 --- a/tests/tracking/_tracking_service/test_utils.py +++ b/tests/tracking/_tracking_service/test_utils.py @@ -7,6 +7,7 @@ import pytest import mlflow +from mlflow.exceptions import MlflowException from mlflow.store.db.db_types import DATABASE_ENGINES from mlflow.store.tracking.file_store import FileStore from mlflow.store.tracking.rest_store import RestStore @@ -192,9 +193,8 @@ def test_get_store_databricks_profile(): with mock.patch.dict(os.environ, env): store = _get_store() assert isinstance(store, RestStore) - with pytest.raises(Exception) as e_info: + with pytest.raises(MlflowException, match="mycoolprofile"): store.get_host_creds() - assert "mycoolprofile" in str(e_info.value) def test_get_store_caches_on_store_uri_and_artifact_uri(tmpdir): @@ -323,7 +323,10 @@ def test_get_store_for_unregistered_scheme(): tracking_store = TrackingStoreRegistry() - with pytest.raises(UnsupportedModelRegistryStoreURIException): + with pytest.raises( + UnsupportedModelRegistryStoreURIException, + match="Model registry functionality is unavailable", + ): tracking_store.get_store("unknown-scheme://") diff --git a/tests/tracking/fluent/test_fluent.py b/tests/tracking/fluent/test_fluent.py index cbd71035ce135..9e4cf7dc55bf2 100644 --- a/tests/tracking/fluent/test_fluent.py +++ b/tests/tracking/fluent/test_fluent.py @@ -525,7 +525,7 @@ def test_start_run_with_parent(): def test_start_run_with_parent_non_nested(): with mock.patch("mlflow.tracking.fluent._active_run_stack", [mock.Mock()]): - with pytest.raises(Exception): + with pytest.raises(Exception, match=r"Run with UUID .+ is already active"): start_run() @@ -572,7 +572,9 @@ def test_start_run_existing_run_from_environment_with_set_environment( env_patch = mock.patch.dict("os.environ", {_RUN_ID_ENV_VAR: run_id}) with env_patch, mock.patch.object(MlflowClient, "get_run", return_value=mock_run): - with pytest.raises(MlflowException): + with pytest.raises( + MlflowException, match="active run ID does not match environment run ID" + ): set_experiment("test-run") start_run() @@ -583,8 +585,9 @@ def test_start_run_existing_run_deleted(empty_active_run_stack): # pylint: disa run_id = uuid.uuid4().hex + match = f"Cannot start run with ID {run_id} because it is in the deleted state" with mock.patch.object(MlflowClient, "get_run", return_value=mock_run): - with pytest.raises(MlflowException): + with pytest.raises(MlflowException, match=match): start_run(run_id) @@ -882,13 +885,12 @@ def test_delete_tag(): """ mlflow.set_tag("a", "b") run = MlflowClient().get_run(mlflow.active_run().info.run_id) - print(run.info.run_id) assert "a" in run.data.tags mlflow.delete_tag("a") run = MlflowClient().get_run(mlflow.active_run().info.run_id) assert "a" not in run.data.tags - with pytest.raises(MlflowException): + with pytest.raises(MlflowException, match="No tag with name"): mlflow.delete_tag("a") - with pytest.raises(MlflowException): + with pytest.raises(MlflowException, match="No tag with name"): mlflow.delete_tag("b") mlflow.end_run() diff --git a/tests/tracking/test_client.py b/tests/tracking/test_client.py index 7d5956b2669c2..3ef868dee89d6 100644 --- a/tests/tracking/test_client.py +++ b/tests/tracking/test_client.py @@ -224,7 +224,9 @@ def test_client_registry_operations_raise_exception_with_unsupported_registry_st lambda: client.get_model_version("test", 1), ] for func in expected_failure_functions: - with pytest.raises(MlflowException) as exc: + with pytest.raises( + MlflowException, match="Model Registry features are not supported" + ) as exc: func() assert exc.value.error_code == ErrorCode.Name(FEATURE_DISABLED) @@ -431,9 +433,8 @@ def test_create_model_version_non_ready_model(mock_registry_store): run_id=run_id, status=ModelVersionStatus.to_string(ModelVersionStatus.FAILED_REGISTRATION), ) - with pytest.raises(MlflowException) as exc: + with pytest.raises(MlflowException, match="Model version creation failed for model name"): client.create_model_version("name", "source") - assert "Model version creation failed for model name" in exc.value def test_create_model_version_run_link_with_configured_profile(mock_registry_store): diff --git a/tests/tracking/test_model_registry.py b/tests/tracking/test_model_registry.py index fc18e3bf12b61..f30e15a20ab8c 100644 --- a/tests/tracking/test_model_registry.py +++ b/tests/tracking/test_model_registry.py @@ -225,7 +225,9 @@ def test_update_registered_model_flow(mlflow_client, backend_store_uri): assert_is_between(start_time_1, end_time_1, registered_model_detailed_1.last_updated_timestamp) # update with no args is an error - with pytest.raises(MlflowException): + with pytest.raises( + MlflowException, match="Attempting to update registered model with no new field values" + ): mlflow_client.update_registered_model(name=name, description=None) # update name @@ -233,7 +235,7 @@ def test_update_registered_model_flow(mlflow_client, backend_store_uri): start_time_2 = now() mlflow_client.rename_registered_model(name=name, new_name=new_name) end_time_2 = now() - with pytest.raises(MlflowException): + with pytest.raises(MlflowException, match="Registered Model with name=UpdateRMTest not found"): mlflow_client.get_registered_model(name) registered_model_detailed_2 = mlflow_client.get_registered_model(new_name) assert registered_model_detailed_2.name == new_name @@ -277,7 +279,9 @@ def test_update_registered_model_flow(mlflow_client, backend_store_uri): # old named models are not accessible for old_name in [previous_name, name, new_name]: - with pytest.raises(MlflowException): + with pytest.raises( + MlflowException, match=r"Registered Model with name=UpdateRMTest( \d)? not found" + ): mlflow_client.get_registered_model(old_name) @@ -294,17 +298,17 @@ def test_delete_registered_model_flow(mlflow_client, backend_store_uri): assert [name] == [rm.name for rm in mlflow_client.list_registered_models() if rm.name == name] # cannot create a model with same name - with pytest.raises(MlflowException): + with pytest.raises(MlflowException, match=r"Registered Model .+ already exists"): mlflow_client.create_registered_model(name) mlflow_client.delete_registered_model(name) # cannot get a deleted model - with pytest.raises(MlflowException): + with pytest.raises(MlflowException, match=r"Registered Model .+ not found"): mlflow_client.get_registered_model(name) # cannot update a deleted model - with pytest.raises(MlflowException): + with pytest.raises(MlflowException, match=r"Registered Model .+ not found"): mlflow_client.rename_registered_model(name=name, new_name="something else") # list does not include deleted model @@ -381,9 +385,10 @@ def test_get_model_version(mlflow_client, backend_store_uri): assert model_version.name == name assert model_version.version == "1" - with pytest.raises(MlflowException) as ex: + with pytest.raises( + MlflowException, match="INVALID_PARAMETER_VALUE: Model version must be an integer" + ): mlflow_client.get_model_version(name=name, version="something not correct") - assert "INVALID_PARAMETER_VALUE: Model version must be an integer" in str(ex.value) def test_update_model_version_flow(mlflow_client, backend_store_uri): @@ -538,13 +543,13 @@ def test_delete_model_version_flow(mlflow_client, backend_store_uri): assert_is_between(start_time_2, end_time_2, rmd3.last_updated_timestamp) # cannot get a deleted model version - with pytest.raises(MlflowException): + with pytest.raises(MlflowException, match=r"Model Version .+ not found"): mlflow_client.delete_model_version(name, "1") # cannot update a deleted model version - with pytest.raises(MlflowException): + with pytest.raises(MlflowException, match=r"Model Version .+ not found"): mlflow_client.update_model_version(name=name, version=1, description="Test model") - with pytest.raises(MlflowException): + with pytest.raises(MlflowException, match=r"Model Version .+ not found"): mlflow_client.transition_model_version_stage(name=name, version=1, stage="Staging") mlflow_client.delete_model_version(name, 3) diff --git a/tests/tracking/test_rest_tracking.py b/tests/tracking/test_rest_tracking.py index 26338a5d2d468..263e13229c1b9 100644 --- a/tests/tracking/test_rest_tracking.py +++ b/tests/tracking/test_rest_tracking.py @@ -335,12 +335,12 @@ def test_delete_tag(mlflow_client, backend_store_uri): mlflow_client.delete_tag(run_id, "taggity") run = mlflow_client.get_run(run_id) assert "taggity" not in run.data.tags - with pytest.raises(MlflowException): + with pytest.raises(MlflowException, match=r"Run .+ not found"): mlflow_client.delete_tag("fake_run_id", "taggity") - with pytest.raises(MlflowException): + with pytest.raises(MlflowException, match="No tag with name: fakeTag"): mlflow_client.delete_tag(run_id, "fakeTag") mlflow_client.delete_run(run_id) - with pytest.raises(MlflowException): + with pytest.raises(MlflowException, match=f"The run {run_id} must be in"): mlflow_client.delete_tag(run_id, "taggity") diff --git a/tests/tracking/test_tracking.py b/tests/tracking/test_tracking.py index 0683513ca8fec..ac84ca36a0f5b 100644 --- a/tests/tracking/test_tracking.py +++ b/tests/tracking/test_tracking.py @@ -7,6 +7,7 @@ import tempfile import time import yaml +import re import pytest from unittest import mock @@ -32,13 +33,10 @@ def test_create_experiment(): - with pytest.raises(TypeError): - mlflow.create_experiment() # pylint: disable=no-value-for-parameter - - with pytest.raises(Exception): + with pytest.raises(MlflowException, match="Invalid experiment name"): mlflow.create_experiment(None) - with pytest.raises(Exception): + with pytest.raises(MlflowException, match="Invalid experiment name"): mlflow.create_experiment("") exp_id = mlflow.create_experiment("Some random experiment name %d" % random.randint(1, 1e6)) @@ -49,31 +47,30 @@ def test_create_experiment_with_duplicate_name(): name = "popular_name" exp_id = mlflow.create_experiment(name) - with pytest.raises(MlflowException): + with pytest.raises(MlflowException, match=re.escape(f"Experiment(name={name}) already exists")): mlflow.create_experiment(name) tracking.MlflowClient().delete_experiment(exp_id) - with pytest.raises(MlflowException): + with pytest.raises(MlflowException, match=re.escape(f"Experiment(name={name}) already exists")): mlflow.create_experiment(name) def test_create_experiments_with_bad_names(): # None for name - with pytest.raises(MlflowException) as e: + with pytest.raises(MlflowException, match="Invalid experiment name: 'None'"): mlflow.create_experiment(None) - assert e.message.contains("Invalid experiment name: 'None'") # empty string name - with pytest.raises(MlflowException) as e: + with pytest.raises(MlflowException, match="Invalid experiment name: ''"): mlflow.create_experiment("") - assert e.message.contains("Invalid experiment name: ''") @pytest.mark.parametrize("name", [123, 0, -1.2, [], ["A"], {1: 2}]) def test_create_experiments_with_bad_name_types(name): - with pytest.raises(MlflowException) as e: + with pytest.raises( + MlflowException, match=re.escape(f"Invalid experiment name: {name}. Expects a string.") + ): mlflow.create_experiment(name) - assert e.message.contains("Invalid experiment name: %s. Expects a string." % name) @pytest.mark.usefixtures("reset_active_experiment") @@ -101,7 +98,7 @@ def test_set_experiment_by_id(): assert run.info.experiment_id == exp_id nonexistent_id = "-1337" - with pytest.raises(MlflowException) as exc: + with pytest.raises(MlflowException, match="No Experiment with id=-1337 exists") as exc: mlflow.set_experiment(experiment_id=nonexistent_id) assert exc.value.error_code == ErrorCode.Name(RESOURCE_DOES_NOT_EXIST) with start_run() as run: @@ -255,7 +252,7 @@ def test_start_run_context_manager(): assert finished_run.info.status == RunStatus.to_string(RunStatus.FINISHED) # Launch a separate run that fails, verify the run status is FAILED and the run UUID is # different - with pytest.raises(Exception): + with pytest.raises(Exception, match="Failing run!"): with start_run() as second_run: second_run_id = second_run.info.run_id raise Exception("Failing run!") @@ -453,7 +450,7 @@ def test_set_tags(): def test_log_metric_validation(): with start_run() as active_run: run_id = active_run.info.run_id - with pytest.raises(MlflowException) as e: + with pytest.raises(MlflowException, match="Got invalid value apple for metric") as e: mlflow.log_metric("name_1", "apple") assert e.value.error_code == ErrorCode.Name(INVALID_PARAMETER_VALUE) finished_run = tracking.MlflowClient().get_run(run_id) @@ -509,28 +506,33 @@ def test_log_batch_duplicate_entries_raises(): def test_log_batch_validates_entity_names_and_values(): - bad_kwargs = { - "metrics": [ - [Metric(key="../bad/metric/name", value=0.3, timestamp=3, step=0)], - [Metric(key="ok-name", value="non-numerical-value", timestamp=3, step=0)], - [Metric(key="ok-name", value=0.3, timestamp="non-numerical-timestamp", step=0)], - ], - "params": [[Param(key="../bad/param/name", value="my-val")]], - "tags": [[Param(key="../bad/tag/name", value="my-val")]], - } with start_run() as active_run: - for kwarg, bad_values in bad_kwargs.items(): - for bad_kwarg_value in bad_values: - final_kwargs = { - "run_id": active_run.info.run_id, - "metrics": [], - "params": [], - "tags": [], - } - final_kwargs[kwarg] = bad_kwarg_value - with pytest.raises(MlflowException) as e: - tracking.MlflowClient().log_batch(**final_kwargs) - assert e.value.error_code == ErrorCode.Name(INVALID_PARAMETER_VALUE) + run_id = active_run.info.run_id + + metrics = [Metric(key="../bad/metric/name", value=0.3, timestamp=3, step=0)] + with pytest.raises(MlflowException, match="Invalid metric name") as e: + tracking.MlflowClient().log_batch(run_id, metrics=metrics) + assert e.value.error_code == ErrorCode.Name(INVALID_PARAMETER_VALUE) + + metrics = [Metric(key="ok-name", value="non-numerical-value", timestamp=3, step=0)] + with pytest.raises(MlflowException, match="Got invalid value") as e: + tracking.MlflowClient().log_batch(run_id, metrics=metrics) + assert e.value.error_code == ErrorCode.Name(INVALID_PARAMETER_VALUE) + + metrics = [Metric(key="ok-name", value=0.3, timestamp="non-numerical-timestamp", step=0)] + with pytest.raises(MlflowException, match="Got invalid timestamp") as e: + tracking.MlflowClient().log_batch(run_id, metrics=metrics) + assert e.value.error_code == ErrorCode.Name(INVALID_PARAMETER_VALUE) + + params = [Param(key="../bad/param/name", value="my-val")] + with pytest.raises(MlflowException, match="Invalid parameter name") as e: + tracking.MlflowClient().log_batch(run_id, params=params) + assert e.value.error_code == ErrorCode.Name(INVALID_PARAMETER_VALUE) + + tags = [Param(key="../bad/tag/name", value="my-val")] + with pytest.raises(MlflowException, match="Invalid tag name") as e: + tracking.MlflowClient().log_batch(run_id, tags=tags) + assert e.value.error_code == ErrorCode.Name(INVALID_PARAMETER_VALUE) def test_log_artifact_with_dirs(tmpdir): diff --git a/tests/types/test_schema.py b/tests/types/test_schema.py index 6682c482c4e74..03590ce4271c9 100644 --- a/tests/types/test_schema.py +++ b/tests/types/test_schema.py @@ -19,9 +19,8 @@ def test_col_spec(): b1 = ColSpec(DataType.string, "b") assert b1 != a1 assert a1 == a2 - with pytest.raises(MlflowException) as ex: + with pytest.raises(MlflowException, match="Unsupported type 'unsupported'"): ColSpec("unsupported") - assert "Unsupported type 'unsupported'" in ex.value.message a4 = ColSpec(**a1.to_dict()) assert a4 == a1 assert ColSpec(**json.loads(json.dumps(a1.to_dict()))) == a1 @@ -41,15 +40,15 @@ def test_tensor_spec(): assert a1 != a4 b1 = TensorSpec(np.dtype("float64"), (-1, 3, 3), "b") assert b1 != a1 - with pytest.raises(TypeError) as ex1: + with pytest.raises(TypeError, match="Expected `type` to be instance"): TensorSpec("Unsupported", (-1, 3, 3), "a") - assert "Expected `type` to be instance" in str(ex1.value) - with pytest.raises(TypeError) as ex2: + with pytest.raises(TypeError, match="Expected `shape` to be instance"): TensorSpec(np.dtype("float64"), np.array([-1, 2, 3]), "b") - assert "Expected `shape` to be instance" in str(ex2.value) - with pytest.raises(MlflowException) as ex3: + with pytest.raises( + MlflowException, + match="MLflow does not support size information in flexible numpy data types", + ): TensorSpec(np.dtype("