Coverage for ml_workbench/mlflow.py: 28%
85 statements
« prev ^ index » next coverage.py v7.11.0, created at 2025-12-28 16:32 +0200
« prev ^ index » next coverage.py v7.11.0, created at 2025-12-28 16:32 +0200
1from __future__ import annotations
3import os
4from collections.abc import Mapping
5from dataclasses import dataclass
6from typing import Any
8from .config import YamlConfig
11@dataclass(frozen=True)
12class MlflowSpec:
13 enabled: bool
14 type: str # Always "local" or "databricks", inferred from MLFLOW_TRACKING_URI if not specified
15 experiment_name_prefix: str
16 tags: dict[str, Any]
19class Mlflow:
20 """Encapsulates MLflow configuration from the YAML config.
22 Expected YAML structure:
24 mlflow:
25 enabled: true # optional, defaults to True
26 type: "local" # optional, "local" or "databricks", inferred from MLFLOW_TRACKING_URI if not specified
27 experiment_name_prefix: "/Shared/" # optional, defaults to ""
28 tags: # optional, defaults to {}
29 environment: "development"
30 data_version: "v1"
32 If the `mlflow` section is not present in the config, default values are used:
33 - enabled: True
34 - type: inferred from MLFLOW_TRACKING_URI env var (if starts with "databricks" -> "databricks", else "local")
35 - experiment_name_prefix: ""
36 - tags: {}
37 """
39 def __init__(self, config: YamlConfig) -> None:
40 self.config = config
42 mlflow_section = self._get_mlflow_section(config)
44 # Extract values with defaults
45 enabled = mlflow_section.get("enabled", True)
46 if not isinstance(enabled, bool):
47 raise ValueError("mlflow.enabled must be a boolean")
49 mlflow_type = mlflow_section.get("type")
50 if mlflow_type is not None and not isinstance(mlflow_type, str):
51 raise ValueError("mlflow.type must be a string or None")
52 # Validate type if provided
53 if mlflow_type is not None:
54 mlflow_type_lower = mlflow_type.lower()
55 if mlflow_type_lower not in ("local", "databricks"):
56 raise ValueError(
57 f"mlflow.type must be 'local' or 'databricks', got '{mlflow_type}'"
58 )
59 mlflow_type = mlflow_type_lower
60 else:
61 # If type not specified, infer from MLFLOW_TRACKING_URI environment variable
62 tracking_uri = os.environ.get("MLFLOW_TRACKING_URI", "")
63 if tracking_uri.startswith("databricks"):
64 mlflow_type = "databricks"
65 else:
66 mlflow_type = "local"
68 experiment_name_prefix = mlflow_section.get("experiment_name_prefix", "")
69 if not isinstance(experiment_name_prefix, str):
70 raise ValueError("mlflow.experiment_name_prefix must be a string")
72 tags = mlflow_section.get("tags", {})
73 if not isinstance(tags, Mapping):
74 raise ValueError("mlflow.tags must be a mapping")
76 self.spec = MlflowSpec(
77 enabled=enabled,
78 type=mlflow_type,
79 experiment_name_prefix=experiment_name_prefix,
80 tags=dict(tags),
81 )
83 @staticmethod
84 def _get_mlflow_section(config: YamlConfig) -> Mapping[str, Any]:
85 """Get mlflow section from config, returning empty dict if not present."""
86 mlflow = config._data.get("mlflow") # type: ignore[attr-defined]
87 if mlflow is None:
88 return {}
89 if not isinstance(mlflow, Mapping):
90 raise ValueError("'mlflow' section must be a mapping")
91 return mlflow
93 def to_dict(self) -> dict[str, Any]:
94 """Return dictionary representation of MLflow configuration."""
95 return {
96 "enabled": self.spec.enabled,
97 "type": self.spec.type,
98 "experiment_name_prefix": self.spec.experiment_name_prefix,
99 "tags": dict(self.spec.tags),
100 }
102 def is_enabled(self) -> bool:
103 """Check if MLflow tracking is enabled.
105 Returns
106 -------
107 bool
108 True if MLflow tracking is enabled, False otherwise
109 """
110 return self.spec.enabled
112 def get_type(self) -> str:
113 """Get the MLflow tracking type.
115 Returns
116 -------
117 str
118 The tracking type: "local" or "databricks"
119 """
120 return self.spec.type
122 def get_tags(self) -> dict[str, Any]:
123 """Get all tags.
125 Returns
126 -------
127 dict[str, Any]
128 All tags
129 """
130 return self.spec.tags
132 def get_name(self, experiment_name: str) -> str:
133 """Get the full experiment name by combining experiment name prefix with experiment name.
135 Parameters
136 ----------
137 experiment_name : str
138 The experiment name
140 Returns
141 -------
142 str
143 The full experiment name (prefix + experiment_name)
144 """
145 prefix = self.spec.experiment_name_prefix
146 # Ensure prefix ends with / if it's not empty and doesn't already end with /
147 if prefix and not prefix.endswith("/"):
148 prefix = prefix + "/"
149 # Remove leading / from experiment_name if prefix already has it
150 if prefix.startswith("/") and experiment_name.startswith("/"):
151 experiment_name = experiment_name.lstrip("/")
152 return prefix + experiment_name
154 @classmethod
155 def verify_config(cls, config: YamlConfig) -> None:
156 """Validate that mlflow section is well-formed if present.
158 Checks
159 ------
160 - mlflow section, if present, is a mapping
161 - enabled is a boolean (if provided)
162 - type is "local" or "databricks" (if provided)
163 - experiment_name_prefix is a string (if provided)
164 - tags is a mapping (if provided)
166 Notes
167 -----
168 If mlflow section is not present, validation passes (defaults will be used).
169 """
170 mlflow = config._data.get("mlflow") # type: ignore[attr-defined]
171 if mlflow is None:
172 return # Section not present, defaults will be used
174 if not isinstance(mlflow, Mapping):
175 raise ValueError("'mlflow' section must be a mapping")
177 # Validate enabled if present
178 enabled = mlflow.get("enabled")
179 if enabled is not None and not isinstance(enabled, bool):
180 raise ValueError("mlflow.enabled must be a boolean")
182 # Validate type if present
183 mlflow_type = mlflow.get("type")
184 if mlflow_type is not None:
185 if not isinstance(mlflow_type, str):
186 raise ValueError("mlflow.type must be a string")
187 mlflow_type_lower = mlflow_type.lower()
188 if mlflow_type_lower not in ("local", "databricks"):
189 raise ValueError(
190 f"mlflow.type must be 'local' or 'databricks', got '{mlflow_type}'"
191 )
193 # Validate experiment_name_prefix if present
194 experiment_name_prefix = mlflow.get("experiment_name_prefix")
195 if experiment_name_prefix is not None and not isinstance(
196 experiment_name_prefix, str
197 ):
198 raise ValueError("mlflow.experiment_name_prefix must be a string")
200 # Validate tags if present
201 tags = mlflow.get("tags")
202 if tags is not None and not isinstance(tags, Mapping):
203 raise ValueError("mlflow.tags must be a mapping")
206__all__ = ["Mlflow", "MlflowSpec"]