Files
mlflow--mlflow/dev/pyproject.py
T
2026-07-13 13:22:34 +08:00

520 lines
20 KiB
Python

from __future__ import annotations
import re
import subprocess
import sys
from collections import Counter
from enum import Enum
from pathlib import Path
from typing import Any, cast
import toml
import yaml
from packaging.version import Version
from pydantic import BaseModel, Field, RootModel
class PackageType(Enum):
SKINNY = "skinny"
RELEASE = "release"
DEV = "dev"
TRACING = "tracing"
def description(self) -> str:
WARNING = "# Auto-generated by dev/pyproject.py. Do not edit manually."
if self is PackageType.TRACING:
return f"""{WARNING}
# This file defines the package metadata of `mlflow-tracing`.
"""
if self is PackageType.SKINNY:
return f"""{WARNING}
# This file defines the package metadata of `mlflow-skinny`.
"""
if self is PackageType.RELEASE:
return f"""{WARNING}
# This file defines the package metadata of `mlflow`. `mlflow-skinny` and `mlflow-tracing`
# are included in the requirements to prevent a version mismatch between `mlflow` and those
# child packages. This file will replace `pyproject.toml` when releasing a new version.
"""
if self is PackageType.DEV:
return f"""{WARNING}
# This file defines the package metadata of `mlflow` **during development**. To install `mlflow`
# from the source code, `mlflow-skinny` and `mlflow-tracing` are NOT included in the requirements.
# This file will be replaced by `pyproject.release.toml` when releasing a new version.
"""
raise ValueError(f"Unreachable: {self}")
SEPARATOR = """
# Package metadata: can't be updated manually, use dev/pyproject.py
# -----------------------------------------------------------------
# Dev tool settings: can be updated manually
"""
SKINNY_README = """
<!-- Autogenerated by dev/pyproject.py. Do not edit manually. -->
📣 This is the `mlflow-skinny` package, a lightweight MLflow package without SQL storage, server, UI, or data science dependencies.
Additional dependencies can be installed to leverage the full feature set of MLflow. For example:
- To use the `mlflow.sklearn` component of MLflow Models, install `scikit-learn`, `numpy` and `pandas`.
- To use SQL-based metadata storage, install `sqlalchemy`, `alembic`, and `sqlparse`.
- To use serving-based features, install `flask` and `pandas`.
**Note:** When using `mlflow-skinny`, set the tracking URI to your remote MLflow server:
```bash
export MLFLOW_TRACKING_URI="http://your-mlflow-server:5000"
```
---
<br>
<br>
""" # noqa: E501
# Tracing SDK should only include the minimum set of MLflow modules
# to minimize the size of the package.
TRACING_INCLUDE_FILES = [
"mlflow",
# Flavors that we support auto tracing
"mlflow.agno*",
"mlflow.anthropic*",
"mlflow.autogen*",
"mlflow.bedrock*",
"mlflow.crewai*",
"mlflow.dspy*",
"mlflow.gemini*",
"mlflow.groq*",
"mlflow.langchain*",
"mlflow.litellm*",
"mlflow.llama_index*",
"mlflow.mistral*",
"mlflow.openai*",
"mlflow.strands*",
"mlflow.haystack*",
# Other necessary modules
"mlflow.azure*",
"mlflow.entities*",
"mlflow.environment_variables",
"mlflow.exceptions",
"mlflow.legacy_databricks_cli*",
"mlflow.prompt*",
"mlflow.protos*",
"mlflow.pydantic_ai*",
"mlflow.smolagents*",
"mlflow.store*",
"mlflow.telemetry*",
"mlflow.tracing*",
"mlflow.tracking*",
"mlflow.types*",
"mlflow.utils*",
"mlflow.version",
]
TRACING_EXCLUDE_FILES = [
# Large proto files that are not needed in the package
"mlflow/protos/databricks_artifacts_pb2.py",
"mlflow/protos/databricks_filesystem_service_pb2.py",
"mlflow/protos/databricks_uc_registry_messages_pb2.py",
"mlflow/protos/databricks_uc_registry_service_pb2.py",
"mlflow/protos/model_registry_pb2.py",
"mlflow/protos/unity_catalog_oss_messages_pb2.py",
"mlflow/protos/unity_catalog_oss_service_pb2.py",
# Test files
"tests",
"tests.*",
]
def find_duplicates(seq: list[str]) -> list[str]:
counted = Counter(seq)
return [item for item, count in counted.items() if count > 1]
def write_file_if_changed(file_path: Path, new_content: str) -> None:
if file_path.exists():
existing_content = file_path.read_text()
if existing_content == new_content:
print(f"No changes in {file_path}, skipping write.")
return
print(f"Writing changes to {file_path}.")
file_path.write_text(new_content)
def format_content_with_taplo(content: str) -> str:
return (
subprocess.check_output(
["bin/taplo", "fmt", "-"],
input=content,
text=True,
).strip()
+ "\n"
)
def write_toml_file_if_changed(
file_path: Path, description: str, toml_data: dict[str, Any]
) -> None:
"""
Write a TOML file with description only if content has changed.
Formats content with taplo before comparison.
"""
new_content = description + "\n" + toml.dumps(toml_data)
formatted_content = format_content_with_taplo(new_content)
write_file_if_changed(file_path, formatted_content)
class PackageRequirement(BaseModel):
pip_release: str = Field(..., description="The pip package name")
max_major_version: int = Field(..., description="Maximum major version allowed")
minimum: str | None = Field(None, description="Minimum version required")
unsupported: list[str] | None = Field(None, description="List of unsupported versions")
markers: str | None = Field(
None, description="Environment markers for conditional installation"
)
extras: list[str] | None = Field(None, description="Package extras to install")
freeze: bool | None = Field(None, description="Whether to freeze this package version")
RequirementsYaml = RootModel[dict[str, PackageRequirement]]
def generate_requirements_from_yaml(requirements_yaml: RequirementsYaml) -> list[str]:
"""Generate pip requirement strings from validated YAML specification."""
requirement_strs: list[str] = []
for package_entry in requirements_yaml.root.values():
pip_release = package_entry.pip_release
version_specs: list[str] = []
extras = f"[{','.join(package_entry.extras)}]" if package_entry.extras else ""
max_major_version = package_entry.max_major_version
version_specs.append(f"<{max_major_version + 1}")
if package_entry.minimum:
version_specs.append(f">={package_entry.minimum}")
if package_entry.unsupported:
version_specs.extend(f"!={version}" for version in package_entry.unsupported)
markers = f"; {package_entry.markers}" if package_entry.markers else ""
requirement_str = f"{pip_release}{extras}{','.join(version_specs)}{markers}"
requirement_strs.append(requirement_str)
requirement_strs.sort()
return requirement_strs
def read_requirements_yaml(yaml_path: Path) -> list[str]:
"""Read and parse a YAML requirements file into pip requirement strings."""
with yaml_path.open() as f:
requirements_data = yaml.safe_load(f)
return generate_requirements_from_yaml(RequirementsYaml(requirements_data))
def read_package_versions_yml() -> dict[str, Any]:
with open("mlflow/ml-package-versions.yml") as f:
return cast(dict[str, Any], yaml.safe_load(f))
def build(package_type: PackageType) -> None:
requirements_dir = Path("requirements")
tracing_requirements = read_requirements_yaml(requirements_dir / "tracing-requirements.yaml")
skinny_requirements = read_requirements_yaml(requirements_dir / "skinny-requirements.yaml")
_check_skinny_tracing_mismatch(
skinny_reqs=skinny_requirements, tracing_reqs=tracing_requirements
)
core_requirements = read_requirements_yaml(requirements_dir / "core-requirements.yaml")
gateways_requirements = read_requirements_yaml(requirements_dir / "gateway-requirements.yaml")
genai_requirements = read_requirements_yaml(requirements_dir / "genai-requirements.yaml")
version_match = re.search(
r'^VERSION = "([a-z0-9\.]+)"$', Path("mlflow", "version.py").read_text(), re.MULTILINE
)
if version_match is None:
raise ValueError(
'Could not find VERSION in mlflow/version.py. Expected format: VERSION = "x.y.z"'
)
package_version = version_match.group(1)
python_version = Path(".python-version").read_text().strip()
versions_yaml = read_package_versions_yml()
langchain_requirements = [
"langchain>={},<={}".format(
max(
Version(versions_yaml["langchain"]["autologging"]["minimum"]),
Version(versions_yaml["langchain"]["models"]["minimum"]),
),
min(
Version(versions_yaml["langchain"]["autologging"]["maximum"]),
Version(versions_yaml["langchain"]["models"]["maximum"]),
),
)
]
match package_type:
case PackageType.TRACING:
dependencies = sorted(tracing_requirements)
case PackageType.SKINNY:
dependencies = sorted(skinny_requirements)
case PackageType.RELEASE:
dependencies = [
f"mlflow-skinny=={package_version}",
f"mlflow-tracing=={package_version}",
] + sorted(core_requirements)
case PackageType.DEV:
# skinny_requirements is an exact superset of tracing_requirements
# (validated above), so we don't need to include both below.
dependencies = sorted(core_requirements + skinny_requirements)
case _:
raise ValueError(f"Unreachable: {package_type}")
if dep_duplicates := find_duplicates(dependencies):
raise RuntimeError(f"Duplicated dependencies are found: {dep_duplicates}")
match package_type:
case PackageType.TRACING:
package_name = "mlflow-tracing"
case PackageType.SKINNY:
package_name = "mlflow-skinny"
case _:
package_name = "mlflow"
description = (
"MLflow is an open source platform for the complete machine learning lifecycle"
if package_type != PackageType.TRACING
else (
"MLflow Tracing SDK is an open-source, lightweight Python package that only "
"includes the minimum set of dependencies and functionality to instrument "
"your code/models/agents with MLflow Tracing."
)
)
data = {
"build-system": {
"requires": ["setuptools<=82.0.1"],
"build-backend": "setuptools.build_meta",
},
"project": {
"name": package_name,
"version": package_version,
"maintainers": [
{"name": "Databricks", "email": "mlflow-oss-maintainers@googlegroups.com"}
],
"description": description,
"readme": "README_SKINNY.md" if package_type == PackageType.SKINNY else "README.md",
"license": {
"file": "LICENSE.txt",
},
"keywords": ["mlflow", "ai", "databricks"],
"classifiers": [
"Development Status :: 5 - Production/Stable",
"Intended Audience :: Developers",
"Intended Audience :: End Users/Desktop",
"Intended Audience :: Science/Research",
"Intended Audience :: Information Technology",
"Topic :: Scientific/Engineering :: Artificial Intelligence",
"Topic :: Software Development :: Libraries :: Python Modules",
"License :: OSI Approved :: Apache Software License",
"Operating System :: OS Independent",
f"Programming Language :: Python :: {python_version}",
],
"requires-python": f">={python_version}",
"dependencies": dependencies,
"optional-dependencies": {
"extras": [
# Required to log artifacts and models to HDFS artifact locations
"pyarrow",
# Required to sign outgoing request with SigV4 signature
"requests-auth-aws-sigv4",
# Required to log artifacts and models to AWS S3 artifact locations
"boto3",
"botocore",
# Required to log artifacts and models to GCS artifact locations
"google-cloud-storage>=1.30.0",
"azureml-core>=1.2.0",
# Required to log artifacts to SFTP artifact locations
"pysftp",
# Required by the mlflow.projects module, when running projects against
# a remote Kubernetes cluster
"kubernetes",
# Required for exporting metrics from the MLflow server to Prometheus
# as part of the MLflow server monitoring add-on
"prometheus-flask-exporter",
],
"db": [
# Required to use MySQL, PostgreSQL, or SQL Server as the backend store
"PyMySQL",
"psycopg2-binary",
"pymssql",
],
"databricks": [
# Required to write model artifacts to unity catalog locations
"azure-storage-file-datalake>12",
"google-cloud-storage>=1.30.0",
"boto3>1",
"botocore",
"databricks-agents>=1.2.0,<2.0",
],
"gateway": gateways_requirements,
"genai": genai_requirements,
# click 8.3.0 causes MLflow MCP server to fail: https://github.com/mlflow/mlflow/issues/18747
"mcp": ["fastmcp<4,>=2.7.0", "click!=8.3.0"],
"azure": [
# Required to log artifacts and models to Azure Blob Storage
"azure-storage-blob>=12",
"azure-identity>=1.6.1",
],
"sqlserver": ["mlflow-dbstore"],
"aliyun-oss": ["aliyunstoreplugin"],
"jfrog": ["mlflow-jfrog-plugin"],
"kubernetes": ["kubernetes"],
"langchain": langchain_requirements,
"auth": ["Flask-WTF<2"],
}
# Tracing SDK does not support extras
if package_type != PackageType.TRACING
else None,
"urls": {
"homepage": "https://mlflow.org",
"issues": "https://github.com/mlflow/mlflow/issues",
"documentation": "https://mlflow.org/docs/latest",
"repository": "https://github.com/mlflow/mlflow",
},
"scripts": {
"mlflow": "mlflow.cli:cli",
}
if package_type != PackageType.TRACING
else None,
"entry-points": {
"mlflow.app": {
"basic-auth": "mlflow.server.auth:create_app",
},
"mlflow.app.client": {
"basic-auth": "mlflow.server.auth.client:AuthServiceClient",
},
"mlflow.deployments": {
"databricks": "mlflow.deployments.databricks",
"http": "mlflow.deployments.mlflow",
"https": "mlflow.deployments.mlflow",
"openai": "mlflow.deployments.openai",
},
}
if package_type != PackageType.TRACING
else None,
},
"tool": {
"setuptools": {
"packages": {
"find": {
"where": ["."],
"include": ["mlflow", "mlflow.*"]
if package_type != PackageType.TRACING
else TRACING_INCLUDE_FILES,
"exclude": ["tests", "tests.*"]
if package_type != PackageType.TRACING
else TRACING_EXCLUDE_FILES,
"namespaces": False,
}
},
"package-data": _get_package_data(package_type),
}
},
}
if package_type == PackageType.TRACING:
out_path = Path("libs/tracing/pyproject.toml")
write_toml_file_if_changed(out_path, package_type.description(), data)
elif package_type == PackageType.SKINNY:
out_path = Path("libs/skinny/pyproject.toml")
write_toml_file_if_changed(out_path, package_type.description(), data)
skinny_readme_path = Path("libs/skinny/README_SKINNY.md")
new_readme_content = SKINNY_README.lstrip() + Path("README.md").read_text()
write_file_if_changed(skinny_readme_path, new_readme_content)
for f in ["LICENSE.txt", "MANIFEST.in", "mlflow"]:
symlink = Path("libs/skinny", f)
if symlink.exists():
symlink.unlink()
target = Path("../..", f)
symlink.symlink_to(target, target_is_directory=target.is_dir())
elif package_type == PackageType.RELEASE:
out_path = Path(f"pyproject.{package_type.value}.toml")
write_toml_file_if_changed(out_path, package_type.description(), data)
else:
out_path = Path("pyproject.toml")
original_manual_content = out_path.read_text().split(SEPARATOR)[1]
generated_part = package_type.description() + "\n" + toml.dumps(data)
formatted_generated_part = format_content_with_taplo(generated_part)
formatted_full_content = formatted_generated_part + SEPARATOR + original_manual_content
write_file_if_changed(out_path, formatted_full_content)
subprocess.check_call(["uv", "lock"])
def _get_package_data(package_type: PackageType) -> dict[str, list[str]] | None:
if package_type == PackageType.TRACING:
return None
package_data = {
"mlflow": [
"store/db_migrations/alembic.ini",
"temporary_db_migrations_for_pre_1_users/alembic.ini",
"pyspark/ml/log_model_allowlist.txt",
"server/auth/basic_auth.ini",
"server/auth/db/migrations/alembic.ini",
"server/uvicorn_log_config.yaml",
"models/notebook_resources/**/*",
"ai_commands/**/*.md",
"assistant/skills/**/*",
"agent/setup/templates/**/*.md",
]
}
if package_type != PackageType.SKINNY:
package_data["mlflow"] += [
"models/container/**/*",
"server/js/build/**/*",
"utils/model_catalog/*.json",
]
return package_data
def _check_skinny_tracing_mismatch(*, skinny_reqs: list[str], tracing_reqs: list[str]) -> None:
"""
Check if the tracing requirements are a subset of the skinny requirements.
NB: We don't make mlflow-tracing as a hard dependency of mlflow-skinny because
it will complicate the package management (need another .release.toml file
that is dependent by pyproject.release.toml)
"""
if diff := set(tracing_reqs) - set(skinny_reqs):
raise RuntimeError(
"Tracing requirements must be a subset of skinny requirements. "
"Please check the requirements/skinny-requirements.yaml and "
"requirements/tracing-requirements.yaml files.\n"
f"Diff: {diff}"
)
def main() -> None:
if not Path("bin/taplo").exists():
print(
"taplo is required to generate pyproject.toml. "
"Please run 'python bin/install.py' to install it."
)
sys.exit(1)
for package_type in PackageType:
build(package_type)
if __name__ == "__main__":
main()