import importlib.metadata import os from unittest import mock import pytest import yaml from mlflow.exceptions import MlflowException from mlflow.utils.environment import ( _contains_mlflow_requirement, _deduplicate_requirements, _get_pip_deps, _get_pip_requirement_specifier, _is_mlflow_requirement, _is_pip_deps, _mlflow_conda_env, _overwrite_pip_deps, _parse_pip_requirements, _process_conda_env, _process_pip_requirements, _remove_incompatible_requirements, _validate_env_arguments, _validate_version_constraints, infer_pip_requirements, ) from tests.helper_functions import _mlflow_major_version_string @pytest.fixture def conda_env_path(tmp_path): return os.path.join(tmp_path, "conda_env.yaml") def test_mlflow_conda_env_returns_none_when_output_path_is_specified(conda_env_path): env_creation_output = _mlflow_conda_env( path=conda_env_path, additional_conda_deps=["conda-dep-1=0.0.1", "conda-dep-2"], additional_pip_deps=["pip-dep-1", "pip-dep2==0.1.0"], ) assert env_creation_output is None def test_mlflow_conda_env_returns_expected_env_dict_when_output_path_is_not_specified(): conda_deps = ["conda-dep-1=0.0.1", "conda-dep-2"] env = _mlflow_conda_env(path=None, additional_conda_deps=conda_deps) for conda_dep in conda_deps: assert conda_dep in env["dependencies"] @pytest.mark.parametrize("conda_deps", [["conda-dep-1=0.0.1", "conda-dep-2"], None]) def test_mlflow_conda_env_includes_pip_dependencies_but_pip_is_not_specified(conda_deps): additional_pip_deps = ["pip-dep==0.0.1"] env = _mlflow_conda_env( path=None, additional_conda_deps=conda_deps, additional_pip_deps=additional_pip_deps ) if conda_deps is not None: for conda_dep in conda_deps: assert conda_dep in env["dependencies"] pip_version = importlib.metadata.version("pip") assert f"pip<={pip_version}" in env["dependencies"] @pytest.mark.parametrize("pip_specification", ["pip", "pip==20.0.02"]) def test_mlflow_conda_env_includes_pip_dependencies_and_pip_is_specified(pip_specification): conda_deps = ["conda-dep-1=0.0.1", "conda-dep-2", pip_specification] additional_pip_deps = ["pip-dep==0.0.1"] env = _mlflow_conda_env( path=None, additional_conda_deps=conda_deps, additional_pip_deps=additional_pip_deps ) for conda_dep in conda_deps: assert conda_dep in env["dependencies"] assert pip_specification in env["dependencies"] def test_is_pip_deps(): assert _is_pip_deps({"pip": ["a"]}) assert not _is_pip_deps({"ipi": ["a"]}) assert not _is_pip_deps("") assert not _is_pip_deps([]) def test_overwrite_pip_deps(): # dependencies field doesn't exist name_and_channels = {"name": "env", "channels": ["conda-forge"]} expected = {**name_and_channels, "dependencies": [{"pip": ["scipy"]}]} assert _overwrite_pip_deps(name_and_channels, ["scipy"]) == expected # dependencies field doesn't contain pip dependencies conda_env = {**name_and_channels, "dependencies": ["pip"]} expected = {**name_and_channels, "dependencies": ["pip", {"pip": ["scipy"]}]} assert _overwrite_pip_deps(conda_env, ["scipy"]) == expected # dependencies field contains pip dependencies conda_env = {**name_and_channels, "dependencies": ["pip", {"pip": ["numpy"]}, "pandas"]} expected = {**name_and_channels, "dependencies": ["pip", {"pip": ["scipy"]}, "pandas"]} assert _overwrite_pip_deps(conda_env, ["scipy"]) == expected def test_parse_pip_requirements(tmp_path): assert _parse_pip_requirements(None) == ([], []) assert _parse_pip_requirements([]) == ([], []) # Without version specifiers assert _parse_pip_requirements(["a", "b"]) == (["a", "b"], []) # With version specifiers assert _parse_pip_requirements(["a==0.0", "b>1.1"]) == (["a==0.0", "b>1.1"], []) # Environment marker (https://www.python.org/dev/peps/pep-0508/#environment-markers) assert _parse_pip_requirements(['a; python_version < "3.8"']) == ( ['a; python_version < "3.8"'], [], ) # GitHub URI mlflow_repo_uri = "git+https://github.com/mlflow/mlflow.git" assert _parse_pip_requirements([mlflow_repo_uri]) == ([mlflow_repo_uri], []) # Local file fake_whl = tmp_path.joinpath("fake.whl") fake_whl.write_text("") assert _parse_pip_requirements([str(fake_whl)]) == ([str(fake_whl)], []) def test_parse_pip_requirements_with_relative_requirements_files(tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) f1 = tmp_path.joinpath("requirements1.txt") f1.write_text("b") assert _parse_pip_requirements(f1.name) == (["b"], []) assert _parse_pip_requirements(["a", f"-r {f1.name}"]) == (["a", "b"], []) f2 = tmp_path.joinpath("requirements2.txt") f3 = tmp_path.joinpath("requirements3.txt") f2.write_text(f"b\n-r {f3.name}") f3.write_text("c") assert _parse_pip_requirements(f2.name) == (["b", "c"], []) assert _parse_pip_requirements(["a", f"-r {f2.name}"]) == (["a", "b", "c"], []) def test_parse_pip_requirements_with_absolute_requirements_files(tmp_path): f1 = tmp_path.joinpath("requirements1.txt") f1.write_text("b") assert _parse_pip_requirements(str(f1)) == (["b"], []) assert _parse_pip_requirements(["a", f"-r {f1}"]) == (["a", "b"], []) f2 = tmp_path.joinpath("requirements2.txt") f3 = tmp_path.joinpath("requirements3.txt") f2.write_text(f"b\n-r {f3}") f3.write_text("c") assert _parse_pip_requirements(str(f2)) == (["b", "c"], []) assert _parse_pip_requirements(["a", f"-r {f2}"]) == (["a", "b", "c"], []) def test_parse_pip_requirements_with_constraints_files(tmp_path): con_file = tmp_path.joinpath("constraints.txt") con_file.write_text("b") assert _parse_pip_requirements(["a", f"-c {con_file}"]) == (["a"], ["b"]) req_file = tmp_path.joinpath("requirements.txt") req_file.write_text(f"-c {con_file}\n") assert _parse_pip_requirements(["a", f"-r {req_file}"]) == (["a"], ["b"]) def test_parse_pip_requirements_ignores_comments_and_blank_lines(tmp_path): reqs = [ "# comment", "a # inline comment", # blank lines "", " ", ] f = tmp_path.joinpath("requirements.txt") f.write_text("\n".join(reqs)) assert _parse_pip_requirements(reqs) == (["a"], []) assert _parse_pip_requirements(str(f)) == (["a"], []) def test_parse_pip_requirements_removes_temporary_requirements_file(): assert _parse_pip_requirements(["a"]) == (["a"], []) assert all(not x.endswith(".tmp.requirements.txt") for x in os.listdir()) with pytest.raises(FileNotFoundError, match="No such file or directory"): _parse_pip_requirements(["a", "-r does_not_exist.txt"]) # Ensure the temporary requirements file has been removed even when parsing fails assert all(not x.endswith(".tmp.requirements.txt") for x in os.listdir()) @pytest.mark.parametrize("invalid_argument", [0, True, [0]]) def test_parse_pip_requirements_with_invalid_argument_types(invalid_argument): with pytest.raises(TypeError, match="`pip_requirements` must be either a string path"): _parse_pip_requirements(invalid_argument) def test_validate_env_arguments(): _validate_env_arguments(pip_requirements=None, extra_pip_requirements=None, conda_env=None) match = "Only one of `conda_env`, `pip_requirements`, and `extra_pip_requirements`" with pytest.raises(ValueError, match=match): _validate_env_arguments(conda_env={}, pip_requirements=[], extra_pip_requirements=None) with pytest.raises(ValueError, match=match): _validate_env_arguments(conda_env={}, pip_requirements=None, extra_pip_requirements=[]) with pytest.raises(ValueError, match=match): _validate_env_arguments(conda_env=None, pip_requirements=[], extra_pip_requirements=[]) with pytest.raises(ValueError, match=match): _validate_env_arguments(conda_env={}, pip_requirements=[], extra_pip_requirements=[]) def test_is_mlflow_requirement(): assert _is_mlflow_requirement("mlflow") assert _is_mlflow_requirement("MLFLOW") assert _is_mlflow_requirement("MLflow") assert _is_mlflow_requirement("mlflow==1.2.3") assert _is_mlflow_requirement("mlflow < 1.2.3") assert _is_mlflow_requirement("mlflow; python_version < '3.8'") assert _is_mlflow_requirement("mlflow @ https://github.com/mlflow/mlflow.git") assert _is_mlflow_requirement("mlflow @ file:///path/to/mlflow") assert _is_mlflow_requirement("mlflow-skinny==1.2.3") assert not _is_mlflow_requirement("foo") # Ensure packages that look like mlflow are NOT considered as mlflow. assert not _is_mlflow_requirement("mlflow-foo") assert not _is_mlflow_requirement("mlflow_foo") def test_contains_mlflow_requirement(): assert _contains_mlflow_requirement(["mlflow"]) assert _contains_mlflow_requirement(["mlflow==1.2.3"]) assert _contains_mlflow_requirement(["mlflow", "foo"]) assert _contains_mlflow_requirement(["mlflow-skinny"]) assert not _contains_mlflow_requirement([]) assert not _contains_mlflow_requirement(["foo"]) def test_get_pip_requirement_specifier(): assert _get_pip_requirement_specifier("") == "" assert _get_pip_requirement_specifier(" ") == " " assert _get_pip_requirement_specifier("mlflow") == "mlflow" assert _get_pip_requirement_specifier("mlflow==1.2.3") == "mlflow==1.2.3" assert _get_pip_requirement_specifier("-r reqs.txt") == "" assert _get_pip_requirement_specifier(" -r reqs.txt") == " " assert _get_pip_requirement_specifier("mlflow==1.2.3 --hash=foo") == "mlflow==1.2.3" assert _get_pip_requirement_specifier("mlflow==1.2.3 --hash=foo") == "mlflow==1.2.3 " assert _get_pip_requirement_specifier("mlflow-skinny==1.2 --foo=bar") == "mlflow-skinny==1.2" def test_process_pip_requirements(tmp_path): expected_mlflow_ver = _mlflow_major_version_string() conda_env, reqs, cons = _process_pip_requirements(["a"]) assert _get_pip_deps(conda_env) == [expected_mlflow_ver, "a"] assert reqs == [expected_mlflow_ver, "a"] assert cons == [] conda_env, reqs, cons = _process_pip_requirements(["a"], pip_requirements=["b"]) assert _get_pip_deps(conda_env) == [expected_mlflow_ver, "b"] assert reqs == [expected_mlflow_ver, "b"] assert cons == [] # Ensure a requirement for mlflow is preserved conda_env, reqs, cons = _process_pip_requirements(["a"], pip_requirements=["mlflow==1.2.3"]) assert _get_pip_deps(conda_env) == ["mlflow==1.2.3"] assert reqs == ["mlflow==1.2.3"] assert cons == [] # Ensure a requirement for mlflow is preserved when package hashes are specified hash1 = "sha256:963c22532e82a93450674ab97d62f9e528ed0906b580fadb7c003e696197557c" hash2 = "sha256:b15ff0c7e5e64f864a0b40c99b9a582227315eca2065d9f831db9aeb8f24637b" conda_env, reqs, cons = _process_pip_requirements( ["a"], pip_requirements=[f"mlflow==1.20.2 --hash={hash1} --hash={hash2}"], ) assert _get_pip_deps(conda_env) == [f"mlflow==1.20.2 --hash={hash1} --hash={hash2}"] assert reqs == [f"mlflow==1.20.2 --hash={hash1} --hash={hash2}"] assert cons == [] conda_env, reqs, cons = _process_pip_requirements(["a"], extra_pip_requirements=["b"]) assert _get_pip_deps(conda_env) == [expected_mlflow_ver, "a", "b"] assert reqs == [expected_mlflow_ver, "a", "b"] assert cons == [] con_file = tmp_path.joinpath("constraints.txt") con_file.write_text("c") conda_env, reqs, cons = _process_pip_requirements( ["a"], pip_requirements=["b", f"-c {con_file}"] ) assert _get_pip_deps(conda_env) == [expected_mlflow_ver, "b", "-c constraints.txt"] assert reqs == [expected_mlflow_ver, "b", "-c constraints.txt"] assert cons == ["c"] conda_env, reqs, cons = _process_pip_requirements(["a"], extra_pip_requirements=["a[extras]"]) assert _get_pip_deps(conda_env) == [expected_mlflow_ver, "a[extras]"] assert reqs == [expected_mlflow_ver, "a[extras]"] assert cons == [] conda_env, reqs, cons = _process_pip_requirements( ["mlflow==1.2.3", "b[extra1]", "a==1.2.3"], extra_pip_requirements=["b[extra2]", "a[extras]"], ) assert _get_pip_deps(conda_env) == ["mlflow==1.2.3", "b[extra1,extra2]", "a[extras]==1.2.3"] assert reqs == ["mlflow==1.2.3", "b[extra1,extra2]", "a[extras]==1.2.3"] assert cons == [] def test_process_conda_env(tmp_path): def make_conda_env(pip_deps): return { "name": "mlflow-env", "channels": ["conda-forge"], "dependencies": ["python=3.8.15", "pip", {"pip": pip_deps}], } expected_mlflow_ver = _mlflow_major_version_string() conda_env, reqs, cons = _process_conda_env(make_conda_env(["a"])) assert _get_pip_deps(conda_env) == [expected_mlflow_ver, "a"] assert reqs == [expected_mlflow_ver, "a"] assert cons == [] conda_env_file = tmp_path.joinpath("conda_env.yaml") conda_env_file.write_text(yaml.dump(make_conda_env(["a"]))) conda_env, reqs, cons = _process_conda_env(str(conda_env_file)) assert _get_pip_deps(conda_env) == [expected_mlflow_ver, "a"] assert reqs == [expected_mlflow_ver, "a"] assert cons == [] # Ensure a requirement for mlflow is preserved conda_env, reqs, cons = _process_conda_env(make_conda_env(["mlflow==1.2.3"])) assert _get_pip_deps(conda_env) == ["mlflow==1.2.3"] assert reqs == ["mlflow==1.2.3"] assert cons == [] con_file = tmp_path.joinpath("constraints.txt") con_file.write_text("c") conda_env, reqs, cons = _process_conda_env(make_conda_env(["a", f"-c {con_file}"])) assert _get_pip_deps(conda_env) == [expected_mlflow_ver, "a", "-c constraints.txt"] assert reqs == [expected_mlflow_ver, "a", "-c constraints.txt"] assert cons == ["c"] # NB: mlflow-skinny is not automatically attached to any model. If specified, it is # up to the user to pin a version. conda_env, reqs, cons = _process_conda_env(make_conda_env(["mlflow-skinny", "a", "b"])) assert _get_pip_deps(conda_env) == ["mlflow-skinny", "a", "b"] assert reqs == ["mlflow-skinny", "a", "b"] assert cons == [] with pytest.raises(TypeError, match=r"Expected .+, but got `int`"): _process_conda_env(0) @pytest.mark.parametrize( ("env_var", "fallbacks", "should_raise"), [ # 1&2. If env var is True, always throw an exception from inference error (True, ["sklearn"], True), (True, None, True), # 3. If env var is False but fallback is provided, should not throw an exception (False, ["sklearn"], False), # 4. If fallback is not provided, should throw an exception (False, None, True), ], ) def test_infer_requirements_error_handling(env_var, fallbacks, should_raise, monkeypatch): monkeypatch.setenv("MLFLOW_REQUIREMENTS_INFERENCE_RAISE_ERRORS", str(env_var)) # Disable UV auto-detect to ensure model-based inference is used monkeypatch.setenv("MLFLOW_UV_AUTO_DETECT", "false") call_args = ("path/to/model", "sklearn", fallbacks) with mock.patch( "mlflow.utils.requirements_utils._capture_imported_modules", side_effect=MlflowException("Failed to capture imported modules"), ): if should_raise: with pytest.raises(MlflowException, match="Failed to capture imported module"): infer_pip_requirements(*call_args) else: # Should just pass with warning with mock.patch("mlflow.utils.environment._logger.warning") as warning_mock: infer_pip_requirements(*call_args) warning_mock.assert_called_once() warning_text = warning_mock.call_args[0][0] assert "Encountered an unexpected error while inferring" in warning_text @pytest.mark.parametrize( ("input_requirements", "expected"), [ # Simple cases (["scikit-learn>1", "pandas"], ["scikit-learn>1", "pandas"]), # Duplicates without extras, preserving version restrictions (["packageA", "packageA==1.0"], ["packageA==1.0"]), # Duplicates with extras (["packageA", "packageA[extras]"], ["packageA[extras]"]), # Mixed cases ( ["packageA", "packageB", "packageA[extras]", "packageC<=2.0"], ["packageA[extras]", "packageB", "packageC<=2.0"], ), # Mixed versions and extras (["markdown>=3.5.1", "markdown[extras]", "markdown<4"], ["markdown[extras]<4,>=3.5.1"]), # Overlapping extras ( ["packageZ[extra1]", "packageZ[extra2]", "packageZ"], ["packageZ[extra1,extra2]"], ), # No version on extras with final version on non-extras ( ["markdown[extra1]", "markdown[extra2]", "markdown>3", "markdown<4"], ["markdown[extra1,extra2]<4,>3"], ), # Version constraints with extras (["markdown>1.0", "markdown[extras]<4"], ["markdown[extras]<4,>1.0"]), # Verify duplicate specifiers are not preserved ( ["markdown==3.5.1", "markdown[extras]==3.5.1", "markdown[extras]"], ["markdown[extras]==3.5.1"], ), # Verify duplicate extras are not preserved (["markdown[extras]", "markdown", "markdown[extras]"], ["markdown[extras]"]), # Marker-differentiated versions should be kept separate ( [ "numpy==2.2.6 ; python_full_version < '3.11'", "numpy==2.4.2 ; python_full_version >= '3.11'", ], [ 'numpy==2.2.6; python_full_version < "3.11"', 'numpy==2.4.2; python_full_version >= "3.11"', ], ), # Same marker should still merge ( [ "numpy>=1.0 ; python_version >= '3.10'", "numpy<2.0 ; python_version >= '3.10'", ], ['numpy<2.0,>=1.0; python_version >= "3.10"'], ), # Local version label on second entry - prefer non-local (PyPI-installable) (["torch==2.7.1", "torch==2.7.1+cu128"], ["torch==2.7.1"]), # Local version label on first entry - prefer non-local (PyPI-installable) (["torch==2.7.1+cu128", "torch==2.7.1"], ["torch==2.7.1"]), # Both have the same local label - should deduplicate normally (["torch==2.7.1+cu128", "torch==2.7.1+cu128"], ["torch==2.7.1+cu128"]), ], ) def test_deduplicate_requirements_resolve_correctly(input_requirements, expected): assert _deduplicate_requirements(input_requirements) == expected @pytest.mark.parametrize( "input_requirements", [ # Non-inclusive range with precise specifier ["scikit-learn==1.1", "scikit-learn<1"], # Incompatible ranges with extras ["markdown[extras]==3.5.1", "markdown<3.4"], # Invalid ranges ["markdown<3", "markdown>3"], # Conflicting versions ["markdown==3.0", "markdown==3.5"], # Differing local labels are a real conflict and should not be silently dropped ["torch==2.7.1+cu128", "torch==2.7.1+cpu"], ], ) def test_invalid_requirements_raise(input_requirements): with pytest.raises( MlflowException, match="The specified requirements versions are incompatible" ): _deduplicate_requirements(input_requirements) def test_pip_requirements_validation_skipped_when_env_var_set(monkeypatch): monkeypatch.setenv("MLFLOW_SKIP_PIP_REQUIREMENTS_CHECK", "true") # _validate_version_constraints should return early without calling pip _validate_version_constraints(["markdown<2.0", "markdown>3.0"]) # _deduplicate_requirements with incompatible constraints should not raise result = _deduplicate_requirements(["markdown<3", "markdown>3"]) assert result == ["markdown<3,>3"] @pytest.mark.parametrize( ("input_requirements", "expected"), [ (["databricks-connect", "pyspark", "pyspark-connect"], ["databricks-connect"]), (["databricks-connect==1.15.0", "pyspark==3.0.0"], ["databricks-connect==1.15.0"]), (["databricks-connect==1.15.0", "pyspark-connect"], ["databricks-connect==1.15.0"]), (["pyspark==3.0.0", "pyspark-connect"], ["pyspark==3.0.0", "pyspark-connect"]), ( ["pyspark==3.0.0", "pyspark-connect==1.0.0"], ["pyspark==3.0.0", "pyspark-connect==1.0.0"], ), ], ) def test_remove_incompatible_requirements(input_requirements, expected): assert _remove_incompatible_requirements(input_requirements) == expected