/ mlflow / entities / webhook.py
webhook.py
  1  from enum import Enum
  2  from typing import Literal, TypeAlias
  3  
  4  from typing_extensions import Self
  5  
  6  from mlflow.exceptions import MlflowException
  7  from mlflow.protos.webhooks_pb2 import Webhook as ProtoWebhook
  8  from mlflow.protos.webhooks_pb2 import WebhookAction as ProtoWebhookAction
  9  from mlflow.protos.webhooks_pb2 import WebhookEntity as ProtoWebhookEntity
 10  from mlflow.protos.webhooks_pb2 import WebhookEvent as ProtoWebhookEvent
 11  from mlflow.protos.webhooks_pb2 import WebhookStatus as ProtoWebhookStatus
 12  from mlflow.protos.webhooks_pb2 import WebhookTestResult as ProtoWebhookTestResult
 13  from mlflow.utils.workspace_utils import resolve_entity_workspace_name
 14  
 15  
 16  class WebhookStatus(str, Enum):
 17      ACTIVE = "ACTIVE"
 18      DISABLED = "DISABLED"
 19  
 20      def __str__(self) -> str:
 21          return self.value
 22  
 23      @classmethod
 24      def from_proto(cls, proto: int) -> Self:
 25          proto_name = ProtoWebhookStatus.Name(proto)
 26          try:
 27              return cls(proto_name)
 28          except ValueError:
 29              raise ValueError(f"Unknown proto status: {proto_name}")
 30  
 31      def to_proto(self) -> int:
 32          return ProtoWebhookStatus.Value(self.value)
 33  
 34      def is_active(self) -> bool:
 35          return self == WebhookStatus.ACTIVE
 36  
 37  
 38  class WebhookEntity(str, Enum):
 39      REGISTERED_MODEL = "registered_model"
 40      MODEL_VERSION = "model_version"
 41      MODEL_VERSION_TAG = "model_version_tag"
 42      MODEL_VERSION_ALIAS = "model_version_alias"
 43      PROMPT = "prompt"
 44      PROMPT_VERSION = "prompt_version"
 45      PROMPT_TAG = "prompt_tag"
 46      PROMPT_VERSION_TAG = "prompt_version_tag"
 47      PROMPT_ALIAS = "prompt_alias"
 48      BUDGET_POLICY = "budget_policy"
 49  
 50      def __str__(self) -> str:
 51          return self.value
 52  
 53      @classmethod
 54      def from_proto(cls, proto: int) -> Self:
 55          proto_name = ProtoWebhookEntity.Name(proto)
 56          entity_value = proto_name.lower()
 57          return cls(entity_value)
 58  
 59      def to_proto(self) -> int:
 60          proto_name = self.value.upper()
 61          return ProtoWebhookEntity.Value(proto_name)
 62  
 63  
 64  class WebhookAction(str, Enum):
 65      CREATED = "created"
 66      UPDATED = "updated"
 67      DELETED = "deleted"
 68      SET = "set"
 69      EXCEEDED = "exceeded"
 70  
 71      def __str__(self) -> str:
 72          return self.value
 73  
 74      @classmethod
 75      def from_proto(cls, proto: int) -> Self:
 76          proto_name = ProtoWebhookAction.Name(proto)
 77          # Convert UPPER_CASE to lowercase
 78          action_value = proto_name.lower()
 79          try:
 80              return cls(action_value)
 81          except ValueError:
 82              raise ValueError(f"Unknown proto action: {proto_name}")
 83  
 84      def to_proto(self) -> int:
 85          # Convert lowercase to UPPER_CASE
 86          proto_name = self.value.upper()
 87          return ProtoWebhookAction.Value(proto_name)
 88  
 89  
 90  WebhookEventStr: TypeAlias = Literal[
 91      "registered_model.created",
 92      "model_version.created",
 93      "model_version_tag.set",
 94      "model_version_tag.deleted",
 95      "model_version_alias.created",
 96      "model_version_alias.deleted",
 97      "prompt.created",
 98      "prompt_version.created",
 99      "prompt_tag.set",
100      "prompt_tag.deleted",
101      "prompt_version_tag.set",
102      "prompt_version_tag.deleted",
103      "prompt_alias.created",
104      "prompt_alias.deleted",
105      "budget_policy.exceeded",
106  ]
107  
108  # Valid actions for each entity type
109  VALID_ENTITY_ACTIONS: dict[WebhookEntity, set[WebhookAction]] = {
110      WebhookEntity.REGISTERED_MODEL: {
111          WebhookAction.CREATED,
112      },
113      WebhookEntity.MODEL_VERSION: {
114          WebhookAction.CREATED,
115      },
116      WebhookEntity.MODEL_VERSION_TAG: {
117          WebhookAction.SET,
118          WebhookAction.DELETED,
119      },
120      WebhookEntity.MODEL_VERSION_ALIAS: {
121          WebhookAction.CREATED,
122          WebhookAction.DELETED,
123      },
124      WebhookEntity.PROMPT: {
125          WebhookAction.CREATED,
126      },
127      WebhookEntity.PROMPT_VERSION: {
128          WebhookAction.CREATED,
129      },
130      WebhookEntity.PROMPT_TAG: {
131          WebhookAction.SET,
132          WebhookAction.DELETED,
133      },
134      WebhookEntity.PROMPT_VERSION_TAG: {
135          WebhookAction.SET,
136          WebhookAction.DELETED,
137      },
138      WebhookEntity.PROMPT_ALIAS: {
139          WebhookAction.CREATED,
140          WebhookAction.DELETED,
141      },
142      WebhookEntity.BUDGET_POLICY: {
143          WebhookAction.EXCEEDED,
144      },
145  }
146  
147  
148  class WebhookEvent:
149      """
150      Represents a webhook event with a resource and action.
151      """
152  
153      def __init__(
154          self,
155          entity: str | WebhookEntity,
156          action: str | WebhookAction,
157      ):
158          """
159          Initialize a WebhookEvent.
160  
161          Args:
162              entity: The entity type (string or WebhookEntity enum)
163              action: The action type (string or WebhookAction enum)
164  
165          Raises:
166              MlflowException: If the entity/action combination is invalid
167          """
168          self._entity = WebhookEntity(entity) if isinstance(entity, str) else entity
169          self._action = WebhookAction(action) if isinstance(action, str) else action
170  
171          # Validate entity/action combination
172          if not self._is_valid_combination(self._entity, self._action):
173              valid_actions = VALID_ENTITY_ACTIONS.get(self._entity, set())
174              raise MlflowException.invalid_parameter_value(
175                  f"Invalid action '{self._action}' for entity '{self._entity}'. "
176                  f"Valid actions are: {sorted([a.value for a in valid_actions])}"
177              )
178  
179      @property
180      def entity(self) -> WebhookEntity:
181          return self._entity
182  
183      @property
184      def action(self) -> WebhookAction:
185          return self._action
186  
187      @staticmethod
188      def _is_valid_combination(entity: WebhookEntity, action: WebhookAction) -> bool:
189          """
190          Check if an entity/action combination is valid.
191  
192          Args:
193              entity: The webhook entity
194              action: The webhook action
195  
196          Returns:
197              True if the combination is valid, False otherwise
198          """
199          valid_actions = VALID_ENTITY_ACTIONS.get(entity, set())
200          return action in valid_actions
201  
202      @classmethod
203      def from_proto(cls, proto: ProtoWebhookEvent) -> Self:
204          return cls(
205              entity=WebhookEntity.from_proto(proto.entity),
206              action=WebhookAction.from_proto(proto.action),
207          )
208  
209      @classmethod
210      def from_str(cls, event_str: WebhookEventStr) -> Self:
211          """
212          Create a WebhookEvent from a dot-separated string representation.
213  
214          Args:
215              event_str: Valid webhook event string (e.g., "registered_model.created")
216  
217          Returns:
218              A WebhookEvent instance
219          """
220          match event_str.split("."):
221              case [entity_str, action_str]:
222                  try:
223                      entity = WebhookEntity(entity_str)
224                      action = WebhookAction(action_str)
225                      return cls(entity=entity, action=action)
226                  except ValueError as e:
227                      raise MlflowException.invalid_parameter_value(
228                          f"Invalid entity or action in event string: {event_str}. Error: {e}"
229                      )
230              case _:
231                  raise MlflowException.invalid_parameter_value(
232                      f"Invalid event string format: {event_str}. "
233                      "Expected format: 'entity.action' (e.g., 'registered_model.created')"
234                  )
235  
236      def to_proto(self) -> ProtoWebhookEvent:
237          event = ProtoWebhookEvent()
238          event.entity = self.entity.to_proto()
239          event.action = self.action.to_proto()
240          return event
241  
242      def __str__(self) -> str:
243          return f"{self.entity.value}.{self.action.value}"
244  
245      def __eq__(self, other: object) -> bool:
246          if not isinstance(other, WebhookEvent):
247              return False
248          return self.entity == other.entity and self.action == other.action
249  
250      def __hash__(self) -> int:
251          return hash((self.entity, self.action))
252  
253      def __repr__(self) -> str:
254          return f"WebhookEvent(entity={self.entity}, action={self.action})"
255  
256  
257  class Webhook:
258      """
259      MLflow entity for Webhook.
260      """
261  
262      def __init__(
263          self,
264          webhook_id: str,
265          name: str,
266          url: str,
267          events: list[WebhookEvent],
268          creation_timestamp: int,
269          last_updated_timestamp: int,
270          description: str | None = None,
271          status: str | WebhookStatus = WebhookStatus.ACTIVE,
272          secret: str | None = None,
273          workspace: str | None = None,
274      ):
275          """
276          Initialize a Webhook entity.
277  
278          Args:
279              webhook_id: Unique webhook identifier
280              name: Human-readable webhook name
281              url: Webhook endpoint URL
282              events: List of WebhookEvent objects that trigger this webhook
283              creation_timestamp: Creation timestamp in milliseconds since Unix epoch
284              last_updated_timestamp: Last update timestamp in milliseconds since Unix epoch
285              description: Optional webhook description
286              status: Webhook status (ACTIVE or DISABLED)
287              secret: Optional secret key for HMAC signature verification
288              workspace: Workspace the webhook belongs to
289          """
290          super().__init__()
291          self._webhook_id = webhook_id
292          self._name = name
293          self._url = url
294          if not events:
295              raise MlflowException.invalid_parameter_value("Webhook events cannot be empty")
296          self._events = events
297          self._description = description
298          self._status = WebhookStatus(status) if isinstance(status, str) else status
299          self._secret = secret
300          self._creation_timestamp = creation_timestamp
301          self._last_updated_timestamp = last_updated_timestamp
302          self._workspace = resolve_entity_workspace_name(workspace)
303  
304      @property
305      def webhook_id(self) -> str:
306          return self._webhook_id
307  
308      @property
309      def name(self) -> str:
310          return self._name
311  
312      @property
313      def url(self) -> str:
314          return self._url
315  
316      @property
317      def events(self) -> list[WebhookEvent]:
318          return self._events
319  
320      @property
321      def description(self) -> str | None:
322          return self._description
323  
324      @property
325      def status(self) -> WebhookStatus:
326          return self._status
327  
328      @property
329      def secret(self) -> str | None:
330          return self._secret
331  
332      @property
333      def creation_timestamp(self) -> int:
334          return self._creation_timestamp
335  
336      @property
337      def last_updated_timestamp(self) -> int:
338          return self._last_updated_timestamp
339  
340      @property
341      def workspace(self) -> str:
342          return self._workspace
343  
344      @classmethod
345      def from_proto(cls, proto: ProtoWebhook) -> Self:
346          return cls(
347              webhook_id=proto.webhook_id,
348              name=proto.name,
349              url=proto.url,
350              events=[WebhookEvent.from_proto(e) for e in proto.events],
351              description=proto.description or None,
352              status=WebhookStatus.from_proto(proto.status),
353              creation_timestamp=proto.creation_timestamp,
354              last_updated_timestamp=proto.last_updated_timestamp,
355          )
356  
357      def to_proto(self):
358          webhook = ProtoWebhook()
359          webhook.webhook_id = self.webhook_id
360          webhook.name = self.name
361          webhook.url = self.url
362          webhook.events.extend([event.to_proto() for event in self.events])
363          if self.description:
364              webhook.description = self.description
365          webhook.status = self.status.to_proto()
366          webhook.creation_timestamp = self.creation_timestamp
367          webhook.last_updated_timestamp = self.last_updated_timestamp
368          return webhook
369  
370      def __repr__(self) -> str:
371          return (
372              f"Webhook("
373              f"webhook_id='{self.webhook_id}', "
374              f"name='{self.name}', "
375              f"url='{self.url}', "
376              f"status='{self.status}', "
377              f"workspace='{self.workspace}', "
378              f"events={self.events}, "
379              f"creation_timestamp={self.creation_timestamp}, "
380              f"last_updated_timestamp={self.last_updated_timestamp}"
381              f")"
382          )
383  
384  
385  class WebhookTestResult:
386      """
387      MLflow entity for WebhookTestResult.
388      """
389  
390      def __init__(
391          self,
392          success: bool,
393          response_status: int | None = None,
394          response_body: str | None = None,
395          error_message: str | None = None,
396      ):
397          """
398          Initialize a WebhookTestResult entity.
399  
400          Args:
401              success: Whether the test succeeded
402              response_status: HTTP response status code if available
403              response_body: Response body if available
404              error_message: Error message if test failed
405          """
406          self._success = success
407          self._response_status = response_status
408          self._response_body = response_body
409          self._error_message = error_message
410  
411      @property
412      def success(self) -> bool:
413          return self._success
414  
415      @property
416      def response_status(self) -> int | None:
417          return self._response_status
418  
419      @property
420      def response_body(self) -> str | None:
421          return self._response_body
422  
423      @property
424      def error_message(self) -> str | None:
425          return self._error_message
426  
427      @classmethod
428      def from_proto(cls, proto: ProtoWebhookTestResult) -> Self:
429          return cls(
430              success=proto.success,
431              response_status=proto.response_status or None,
432              response_body=proto.response_body or None,
433              error_message=proto.error_message or None,
434          )
435  
436      def to_proto(self) -> ProtoWebhookTestResult:
437          return ProtoWebhookTestResult(
438              success=self.success,
439              response_status=self.response_status,
440              response_body=self.response_body,
441              error_message=self.error_message,
442          )
443  
444      def __repr__(self) -> str:
445          return (
446              f"WebhookTestResult("
447              f"success={self.success!r}, "
448              f"response_status={self.response_status!r}, "
449              f"response_body={self.response_body!r}, "
450              f"error_message={self.error_message!r}"
451              f")"
452          )