mlrun 1.8.0rc1__py3-none-any.whl → 1.8.0rc3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of mlrun might be problematic. Click here for more details.

Files changed (83) hide show
  1. mlrun/__init__.py +5 -7
  2. mlrun/__main__.py +1 -1
  3. mlrun/artifacts/__init__.py +1 -0
  4. mlrun/artifacts/document.py +313 -0
  5. mlrun/artifacts/manager.py +2 -0
  6. mlrun/common/formatters/project.py +9 -0
  7. mlrun/common/schemas/__init__.py +4 -0
  8. mlrun/common/schemas/alert.py +31 -18
  9. mlrun/common/schemas/api_gateway.py +3 -3
  10. mlrun/common/schemas/artifact.py +7 -7
  11. mlrun/common/schemas/auth.py +6 -4
  12. mlrun/common/schemas/background_task.py +7 -7
  13. mlrun/common/schemas/client_spec.py +2 -2
  14. mlrun/common/schemas/clusterization_spec.py +2 -2
  15. mlrun/common/schemas/common.py +5 -5
  16. mlrun/common/schemas/constants.py +15 -0
  17. mlrun/common/schemas/datastore_profile.py +1 -1
  18. mlrun/common/schemas/feature_store.py +9 -9
  19. mlrun/common/schemas/frontend_spec.py +4 -4
  20. mlrun/common/schemas/function.py +10 -10
  21. mlrun/common/schemas/hub.py +1 -1
  22. mlrun/common/schemas/k8s.py +3 -3
  23. mlrun/common/schemas/memory_reports.py +3 -3
  24. mlrun/common/schemas/model_monitoring/grafana.py +1 -1
  25. mlrun/common/schemas/model_monitoring/model_endpoint_v2.py +1 -1
  26. mlrun/common/schemas/model_monitoring/model_endpoints.py +1 -1
  27. mlrun/common/schemas/notification.py +18 -3
  28. mlrun/common/schemas/object.py +1 -1
  29. mlrun/common/schemas/pagination.py +4 -4
  30. mlrun/common/schemas/partition.py +16 -1
  31. mlrun/common/schemas/pipeline.py +2 -2
  32. mlrun/common/schemas/project.py +22 -17
  33. mlrun/common/schemas/runs.py +2 -2
  34. mlrun/common/schemas/runtime_resource.py +5 -5
  35. mlrun/common/schemas/schedule.py +1 -1
  36. mlrun/common/schemas/secret.py +1 -1
  37. mlrun/common/schemas/tag.py +3 -3
  38. mlrun/common/schemas/workflow.py +5 -5
  39. mlrun/config.py +23 -1
  40. mlrun/datastore/datastore_profile.py +38 -19
  41. mlrun/datastore/vectorstore.py +186 -0
  42. mlrun/db/base.py +58 -6
  43. mlrun/db/httpdb.py +267 -15
  44. mlrun/db/nopdb.py +44 -5
  45. mlrun/execution.py +47 -1
  46. mlrun/model.py +2 -2
  47. mlrun/model_monitoring/applications/results.py +2 -2
  48. mlrun/model_monitoring/db/tsdb/base.py +2 -2
  49. mlrun/model_monitoring/db/tsdb/tdengine/schemas.py +37 -13
  50. mlrun/model_monitoring/db/tsdb/tdengine/tdengine_connector.py +32 -40
  51. mlrun/model_monitoring/helpers.py +4 -10
  52. mlrun/model_monitoring/stream_processing.py +14 -11
  53. mlrun/platforms/__init__.py +44 -13
  54. mlrun/projects/__init__.py +6 -1
  55. mlrun/projects/pipelines.py +184 -55
  56. mlrun/projects/project.py +309 -33
  57. mlrun/run.py +4 -1
  58. mlrun/runtimes/base.py +2 -1
  59. mlrun/runtimes/mounts.py +572 -0
  60. mlrun/runtimes/nuclio/function.py +1 -2
  61. mlrun/runtimes/pod.py +82 -18
  62. mlrun/runtimes/remotesparkjob.py +1 -1
  63. mlrun/runtimes/sparkjob/spark3job.py +1 -1
  64. mlrun/utils/clones.py +1 -1
  65. mlrun/utils/helpers.py +12 -2
  66. mlrun/utils/logger.py +2 -2
  67. mlrun/utils/notifications/notification/__init__.py +22 -19
  68. mlrun/utils/notifications/notification/base.py +12 -12
  69. mlrun/utils/notifications/notification/console.py +6 -6
  70. mlrun/utils/notifications/notification/git.py +6 -6
  71. mlrun/utils/notifications/notification/ipython.py +6 -6
  72. mlrun/utils/notifications/notification/mail.py +149 -0
  73. mlrun/utils/notifications/notification/slack.py +6 -6
  74. mlrun/utils/notifications/notification/webhook.py +6 -6
  75. mlrun/utils/notifications/notification_pusher.py +20 -12
  76. mlrun/utils/regex.py +2 -0
  77. mlrun/utils/version/version.json +2 -2
  78. {mlrun-1.8.0rc1.dist-info → mlrun-1.8.0rc3.dist-info}/METADATA +190 -186
  79. {mlrun-1.8.0rc1.dist-info → mlrun-1.8.0rc3.dist-info}/RECORD +83 -79
  80. {mlrun-1.8.0rc1.dist-info → mlrun-1.8.0rc3.dist-info}/WHEEL +1 -1
  81. {mlrun-1.8.0rc1.dist-info → mlrun-1.8.0rc3.dist-info}/LICENSE +0 -0
  82. {mlrun-1.8.0rc1.dist-info → mlrun-1.8.0rc3.dist-info}/entry_points.txt +0 -0
  83. {mlrun-1.8.0rc1.dist-info → mlrun-1.8.0rc3.dist-info}/top_level.txt +0 -0
mlrun/runtimes/pod.py CHANGED
@@ -17,21 +17,22 @@ import os
17
17
  import re
18
18
  import time
19
19
  import typing
20
+ from collections.abc import Iterable
20
21
  from enum import Enum
21
22
 
22
23
  import dotenv
23
24
  import kubernetes.client as k8s_client
25
+ from kubernetes.client import V1Volume, V1VolumeMount
24
26
 
25
27
  import mlrun.common.constants
26
28
  import mlrun.errors
29
+ import mlrun.runtimes.mounts
27
30
  import mlrun.utils.regex
28
- import mlrun_pipelines.mounts
29
31
  from mlrun.common.schemas import (
30
32
  NodeSelectorOperator,
31
33
  PreemptionModes,
32
34
  SecurityContextEnrichmentModes,
33
35
  )
34
- from mlrun_pipelines.mixins import KfpAdapterMixin
35
36
 
36
37
  from ..config import config as mlconf
37
38
  from ..k8s_utils import (
@@ -367,6 +368,35 @@ class KubeResourceSpec(FunctionSpec):
367
368
  + f"service accounts {allowed_service_accounts}"
368
369
  )
369
370
 
371
+ def with_volumes(
372
+ self,
373
+ volumes: typing.Union[list[dict], dict, V1Volume],
374
+ ) -> "KubeResourceSpec":
375
+ """Add volumes to the volumes dictionary, only used as part of the mlrun_pipelines mount functions."""
376
+ if isinstance(volumes, dict):
377
+ set_named_item(self._volumes, volumes)
378
+ elif isinstance(volumes, Iterable):
379
+ for volume in volumes:
380
+ set_named_item(self._volumes, volume)
381
+ else:
382
+ set_named_item(self._volumes, volumes)
383
+ return self
384
+
385
+ def with_volume_mounts(
386
+ self,
387
+ volume_mounts: typing.Union[list[dict], dict, V1VolumeMount],
388
+ ) -> "KubeResourceSpec":
389
+ """Add volume mounts to the volume mounts dictionary,
390
+ only used as part of the mlrun_pipelines mount functions."""
391
+ if isinstance(volume_mounts, dict):
392
+ self._set_volume_mount(volume_mounts)
393
+ elif isinstance(volume_mounts, Iterable):
394
+ for volume_mount in volume_mounts:
395
+ self._set_volume_mount(volume_mount)
396
+ else:
397
+ self._set_volume_mount(volume_mounts)
398
+ return self
399
+
370
400
  def _set_volume_mount(
371
401
  self, volume_mount, volume_mounts_field_name="_volume_mounts"
372
402
  ):
@@ -942,12 +972,12 @@ class AutoMountType(str, Enum):
942
972
  @classmethod
943
973
  def all_mount_modifiers(cls):
944
974
  return [
945
- mlrun_pipelines.mounts.v3io_cred.__name__,
946
- mlrun_pipelines.mounts.mount_v3io.__name__,
947
- mlrun_pipelines.mounts.mount_pvc.__name__,
948
- mlrun_pipelines.mounts.auto_mount.__name__,
949
- mlrun_pipelines.mounts.mount_s3.__name__,
950
- mlrun_pipelines.mounts.set_env_variables.__name__,
975
+ mlrun.runtimes.mounts.v3io_cred.__name__,
976
+ mlrun.runtimes.mounts.mount_v3io.__name__,
977
+ mlrun.runtimes.mounts.mount_pvc.__name__,
978
+ mlrun.runtimes.mounts.auto_mount.__name__,
979
+ mlrun.runtimes.mounts.mount_s3.__name__,
980
+ mlrun.runtimes.mounts.set_env_variables.__name__,
951
981
  ]
952
982
 
953
983
  @classmethod
@@ -964,27 +994,27 @@ class AutoMountType(str, Enum):
964
994
  def _get_auto_modifier():
965
995
  # If we're running on Iguazio - use v3io_cred
966
996
  if mlconf.igz_version != "":
967
- return mlrun_pipelines.mounts.v3io_cred
997
+ return mlrun.runtimes.mounts.v3io_cred
968
998
  # Else, either pvc mount if it's configured or do nothing otherwise
969
999
  pvc_configured = (
970
1000
  "MLRUN_PVC_MOUNT" in os.environ
971
1001
  or "pvc_name" in mlconf.get_storage_auto_mount_params()
972
1002
  )
973
- return mlrun_pipelines.mounts.mount_pvc if pvc_configured else None
1003
+ return mlrun.runtimes.mounts.mount_pvc if pvc_configured else None
974
1004
 
975
1005
  def get_modifier(self):
976
1006
  return {
977
1007
  AutoMountType.none: None,
978
- AutoMountType.v3io_credentials: mlrun_pipelines.mounts.v3io_cred,
979
- AutoMountType.v3io_fuse: mlrun_pipelines.mounts.mount_v3io,
980
- AutoMountType.pvc: mlrun_pipelines.mounts.mount_pvc,
1008
+ AutoMountType.v3io_credentials: mlrun.runtimes.mounts.v3io_cred,
1009
+ AutoMountType.v3io_fuse: mlrun.runtimes.mounts.mount_v3io,
1010
+ AutoMountType.pvc: mlrun.runtimes.mounts.mount_pvc,
981
1011
  AutoMountType.auto: self._get_auto_modifier(),
982
- AutoMountType.s3: mlrun_pipelines.mounts.mount_s3,
983
- AutoMountType.env: mlrun_pipelines.mounts.set_env_variables,
1012
+ AutoMountType.s3: mlrun.runtimes.mounts.mount_s3,
1013
+ AutoMountType.env: mlrun.runtimes.mounts.set_env_variables,
984
1014
  }[self]
985
1015
 
986
1016
 
987
- class KubeResource(BaseRuntime, KfpAdapterMixin):
1017
+ class KubeResource(BaseRuntime):
988
1018
  """
989
1019
  A parent class for runtimes that generate k8s resources when executing.
990
1020
  """
@@ -1026,7 +1056,8 @@ class KubeResource(BaseRuntime, KfpAdapterMixin):
1026
1056
 
1027
1057
  def get_env(self, name, default=None):
1028
1058
  """Get the pod environment variable for the given name, if not found return the default
1029
- If it's a scalar value, will return it, if the value is from source, return the k8s struct (V1EnvVarSource)"""
1059
+ If it's a scalar value, will return it, if the value is from source, return the k8s struct (V1EnvVarSource)
1060
+ """
1030
1061
  for env_var in self.spec.env:
1031
1062
  if get_item_name(env_var) == name:
1032
1063
  # valueFrom is a workaround for now, for some reason the envs aren't getting sanitized
@@ -1080,7 +1111,10 @@ class KubeResource(BaseRuntime, KfpAdapterMixin):
1080
1111
  else:
1081
1112
  raise mlrun.errors.MLRunNotFoundError(f"{file_path} does not exist")
1082
1113
  for name, value in env_vars.items():
1083
- self.set_env(name, value)
1114
+ if isinstance(value, dict) and "valueFrom" in value:
1115
+ self.set_env(name, value_from=value["valueFrom"])
1116
+ else:
1117
+ self.set_env(name, value)
1084
1118
  return self
1085
1119
 
1086
1120
  def set_image_pull_configuration(
@@ -1270,6 +1304,36 @@ class KubeResource(BaseRuntime, KfpAdapterMixin):
1270
1304
  )
1271
1305
  self.spec.security_context = security_context
1272
1306
 
1307
+ def apply(
1308
+ self,
1309
+ modifier: typing.Callable[["KubeResource"], "KubeResource"],
1310
+ ) -> "KubeResource":
1311
+ """
1312
+ Apply a modifier to the runtime which is used to change the runtimes k8s object's spec.
1313
+ All modifiers accept Kube, apply some changes on its spec and return it so modifiers can be chained
1314
+ one after the other.
1315
+
1316
+ :param modifier: a modifier callable object
1317
+ :return: the runtime (self) after the modifications
1318
+ """
1319
+ modifier(self)
1320
+ if AutoMountType.is_auto_modifier(modifier):
1321
+ self.spec.disable_auto_mount = True
1322
+
1323
+ api_client = k8s_client.ApiClient()
1324
+ if self.spec.env:
1325
+ for index, env in enumerate(
1326
+ api_client.sanitize_for_serialization(self.spec.env)
1327
+ ):
1328
+ self.spec.env[index] = env
1329
+
1330
+ if self.spec.volumes and self.spec.volume_mounts:
1331
+ vols = api_client.sanitize_for_serialization(self.spec.volumes)
1332
+ mounts = api_client.sanitize_for_serialization(self.spec.volume_mounts)
1333
+ self.spec.update_vols_and_mounts(vols, mounts)
1334
+
1335
+ return self
1336
+
1273
1337
  def list_valid_priority_class_names(self):
1274
1338
  return mlconf.get_valid_function_priority_class_names()
1275
1339
 
@@ -19,7 +19,7 @@ import kubernetes.client
19
19
 
20
20
  import mlrun.errors
21
21
  from mlrun.config import config
22
- from mlrun_pipelines.mounts import mount_v3io, mount_v3iod
22
+ from mlrun.runtimes.mounts import mount_v3io, mount_v3iod
23
23
 
24
24
  from .kubejob import KubejobRuntime
25
25
  from .pod import KubeResourceSpec
@@ -20,7 +20,7 @@ import mlrun.errors
20
20
  import mlrun.k8s_utils
21
21
  import mlrun.runtimes.pod
22
22
  from mlrun.config import config
23
- from mlrun_pipelines.mounts import mount_v3io, mount_v3iod
23
+ from mlrun.runtimes.mounts import mount_v3io, mount_v3iod
24
24
 
25
25
  from ...execution import MLClientCtx
26
26
  from ...model import RunObject
mlrun/utils/clones.py CHANGED
@@ -122,7 +122,7 @@ def add_credentials_git_remote_url(url: str, secrets=None) -> tuple[str, bool]:
122
122
  username, password = get_git_username_password_from_token(token)
123
123
 
124
124
  if username:
125
- return f"https://{username}:{password}@{url_obj.hostname}{url_obj.path}", True
125
+ return f"https://{username}:{password}@{url_obj.netloc}{url_obj.path}", True
126
126
  return url, False
127
127
 
128
128
 
mlrun/utils/helpers.py CHANGED
@@ -1265,14 +1265,24 @@ def datetime_to_iso(time_obj: Optional[datetime]) -> Optional[str]:
1265
1265
  return time_obj.isoformat()
1266
1266
 
1267
1267
 
1268
- def enrich_datetime_with_tz_info(timestamp_string):
1268
+ def enrich_datetime_with_tz_info(timestamp_string) -> Optional[datetime]:
1269
1269
  if not timestamp_string:
1270
1270
  return timestamp_string
1271
1271
 
1272
1272
  if timestamp_string and not mlrun.utils.helpers.has_timezone(timestamp_string):
1273
1273
  timestamp_string += datetime.now(timezone.utc).astimezone().strftime("%z")
1274
1274
 
1275
- return datetime.strptime(timestamp_string, "%Y-%m-%d %H:%M:%S.%f%z")
1275
+ for _format in [
1276
+ # e.g: 2021-08-25 12:00:00.000Z
1277
+ "%Y-%m-%d %H:%M:%S.%f%z",
1278
+ # e.g: 2024-11-11 07:44:56+0000
1279
+ "%Y-%m-%d %H:%M:%S%z",
1280
+ ]:
1281
+ try:
1282
+ return datetime.strptime(timestamp_string, _format)
1283
+ except ValueError as exc:
1284
+ last_exc = exc
1285
+ raise last_exc
1276
1286
 
1277
1287
 
1278
1288
  def has_timezone(timestamp):
mlrun/utils/logger.py CHANGED
@@ -24,7 +24,7 @@ from traceback import format_exception
24
24
  from typing import IO, Optional, Union
25
25
 
26
26
  import orjson
27
- import pydantic
27
+ import pydantic.v1
28
28
 
29
29
  from mlrun import errors
30
30
  from mlrun.config import config
@@ -33,7 +33,7 @@ from mlrun.config import config
33
33
  class _BaseFormatter(logging.Formatter):
34
34
  def _json_dump(self, json_object):
35
35
  def default(obj):
36
- if isinstance(obj, pydantic.BaseModel):
36
+ if isinstance(obj, pydantic.v1.BaseModel):
37
37
  return obj.dict()
38
38
 
39
39
  # EAFP all the way.
@@ -14,30 +14,32 @@
14
14
 
15
15
  import enum
16
16
 
17
- from mlrun.common.schemas.notification import NotificationKind
18
-
19
- from .base import NotificationBase
20
- from .console import ConsoleNotification
21
- from .git import GitNotification
22
- from .ipython import IPythonNotification
23
- from .slack import SlackNotification
24
- from .webhook import WebhookNotification
17
+ import mlrun.common.schemas.notification as notifications
18
+ import mlrun.utils.notifications.notification.base as base
19
+ import mlrun.utils.notifications.notification.console as console
20
+ import mlrun.utils.notifications.notification.git as git
21
+ import mlrun.utils.notifications.notification.ipython as ipython
22
+ import mlrun.utils.notifications.notification.mail as mail
23
+ import mlrun.utils.notifications.notification.slack as slack
24
+ import mlrun.utils.notifications.notification.webhook as webhook
25
25
 
26
26
 
27
27
  class NotificationTypes(str, enum.Enum):
28
- console = NotificationKind.console.value
29
- git = NotificationKind.git.value
30
- ipython = NotificationKind.ipython.value
31
- slack = NotificationKind.slack.value
32
- webhook = NotificationKind.webhook.value
28
+ console = notifications.NotificationKind.console.value
29
+ git = notifications.NotificationKind.git.value
30
+ ipython = notifications.NotificationKind.ipython.value
31
+ slack = notifications.NotificationKind.slack.value
32
+ mail = notifications.NotificationKind.mail.value
33
+ webhook = notifications.NotificationKind.webhook.value
33
34
 
34
- def get_notification(self) -> type[NotificationBase]:
35
+ def get_notification(self) -> type[base.NotificationBase]:
35
36
  return {
36
- self.console: ConsoleNotification,
37
- self.git: GitNotification,
38
- self.ipython: IPythonNotification,
39
- self.slack: SlackNotification,
40
- self.webhook: WebhookNotification,
37
+ self.console: console.ConsoleNotification,
38
+ self.git: git.GitNotification,
39
+ self.ipython: ipython.IPythonNotification,
40
+ self.slack: slack.SlackNotification,
41
+ self.mail: mail.MailNotification,
42
+ self.webhook: webhook.WebhookNotification,
41
43
  }.get(self)
42
44
 
43
45
  def inverse_dependencies(self) -> list[str]:
@@ -64,5 +66,6 @@ class NotificationTypes(str, enum.Enum):
64
66
  cls.git,
65
67
  cls.ipython,
66
68
  cls.slack,
69
+ cls.mail,
67
70
  cls.webhook,
68
71
  ]
@@ -53,13 +53,13 @@ class NotificationBase:
53
53
  def push(
54
54
  self,
55
55
  message: str,
56
- severity: typing.Union[
57
- mlrun.common.schemas.NotificationSeverity, str
56
+ severity: typing.Optional[
57
+ typing.Union[mlrun.common.schemas.NotificationSeverity, str]
58
58
  ] = mlrun.common.schemas.NotificationSeverity.INFO,
59
- runs: typing.Union[mlrun.lists.RunList, list] = None,
60
- custom_html: typing.Optional[str] = None,
61
- alert: mlrun.common.schemas.AlertConfig = None,
62
- event_data: mlrun.common.schemas.Event = None,
59
+ runs: typing.Optional[typing.Union[mlrun.lists.RunList, list]] = None,
60
+ custom_html: typing.Optional[typing.Optional[str]] = None,
61
+ alert: typing.Optional[mlrun.common.schemas.AlertConfig] = None,
62
+ event_data: typing.Optional[mlrun.common.schemas.Event] = None,
63
63
  ):
64
64
  raise NotImplementedError()
65
65
 
@@ -81,13 +81,13 @@ class NotificationBase:
81
81
  def _get_html(
82
82
  self,
83
83
  message: str,
84
- severity: typing.Union[
85
- mlrun.common.schemas.NotificationSeverity, str
84
+ severity: typing.Optional[
85
+ typing.Union[mlrun.common.schemas.NotificationSeverity, str]
86
86
  ] = mlrun.common.schemas.NotificationSeverity.INFO,
87
- runs: typing.Union[mlrun.lists.RunList, list] = None,
88
- custom_html: typing.Optional[str] = None,
89
- alert: mlrun.common.schemas.AlertConfig = None,
90
- event_data: mlrun.common.schemas.Event = None,
87
+ runs: typing.Optional[typing.Union[mlrun.lists.RunList, list]] = None,
88
+ custom_html: typing.Optional[typing.Optional[str]] = None,
89
+ alert: typing.Optional[mlrun.common.schemas.AlertConfig] = None,
90
+ event_data: typing.Optional[mlrun.common.schemas.Event] = None,
91
91
  ) -> str:
92
92
  if custom_html:
93
93
  return custom_html
@@ -31,13 +31,13 @@ class ConsoleNotification(NotificationBase):
31
31
  def push(
32
32
  self,
33
33
  message: str,
34
- severity: typing.Union[
35
- mlrun.common.schemas.NotificationSeverity, str
34
+ severity: typing.Optional[
35
+ typing.Union[mlrun.common.schemas.NotificationSeverity, str]
36
36
  ] = mlrun.common.schemas.NotificationSeverity.INFO,
37
- runs: typing.Union[mlrun.lists.RunList, list] = None,
38
- custom_html: typing.Optional[str] = None,
39
- alert: mlrun.common.schemas.AlertConfig = None,
40
- event_data: mlrun.common.schemas.Event = None,
37
+ runs: typing.Optional[typing.Union[mlrun.lists.RunList, list]] = None,
38
+ custom_html: typing.Optional[typing.Optional[str]] = None,
39
+ alert: typing.Optional[mlrun.common.schemas.AlertConfig] = None,
40
+ event_data: typing.Optional[mlrun.common.schemas.Event] = None,
41
41
  ):
42
42
  severity = self._resolve_severity(severity)
43
43
  print(f"[{severity}] {message}")
@@ -54,13 +54,13 @@ class GitNotification(NotificationBase):
54
54
  async def push(
55
55
  self,
56
56
  message: str,
57
- severity: typing.Union[
58
- mlrun.common.schemas.NotificationSeverity, str
57
+ severity: typing.Optional[
58
+ typing.Union[mlrun.common.schemas.NotificationSeverity, str]
59
59
  ] = mlrun.common.schemas.NotificationSeverity.INFO,
60
- runs: typing.Union[mlrun.lists.RunList, list] = None,
61
- custom_html: typing.Optional[str] = None,
62
- alert: mlrun.common.schemas.AlertConfig = None,
63
- event_data: mlrun.common.schemas.Event = None,
60
+ runs: typing.Optional[typing.Union[mlrun.lists.RunList, list]] = None,
61
+ custom_html: typing.Optional[typing.Optional[str]] = None,
62
+ alert: typing.Optional[mlrun.common.schemas.AlertConfig] = None,
63
+ event_data: typing.Optional[mlrun.common.schemas.Event] = None,
64
64
  ):
65
65
  git_repo = self.params.get("repo", None)
66
66
  git_issue = self.params.get("issue", None)
@@ -49,13 +49,13 @@ class IPythonNotification(NotificationBase):
49
49
  def push(
50
50
  self,
51
51
  message: str,
52
- severity: typing.Union[
53
- mlrun.common.schemas.NotificationSeverity, str
52
+ severity: typing.Optional[
53
+ typing.Union[mlrun.common.schemas.NotificationSeverity, str]
54
54
  ] = mlrun.common.schemas.NotificationSeverity.INFO,
55
- runs: typing.Union[mlrun.lists.RunList, list] = None,
56
- custom_html: typing.Optional[str] = None,
57
- alert: mlrun.common.schemas.AlertConfig = None,
58
- event_data: mlrun.common.schemas.Event = None,
55
+ runs: typing.Optional[typing.Union[mlrun.lists.RunList, list]] = None,
56
+ custom_html: typing.Optional[typing.Optional[str]] = None,
57
+ alert: typing.Optional[mlrun.common.schemas.AlertConfig] = None,
58
+ event_data: typing.Optional[mlrun.common.schemas.Event] = None,
59
59
  ):
60
60
  if not self._ipython:
61
61
  mlrun.utils.helpers.logger.debug(
@@ -0,0 +1,149 @@
1
+ # Copyright 2023 Iguazio
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+ import re
15
+ import typing
16
+ from email.message import EmailMessage
17
+
18
+ import aiosmtplib
19
+
20
+ import mlrun.common.schemas
21
+ import mlrun.lists
22
+ import mlrun.utils.helpers
23
+ import mlrun.utils.notifications.notification.base as base
24
+ import mlrun.utils.regex
25
+
26
+ DEFAULT_SMTP_PORT = 587
27
+
28
+
29
+ class MailNotification(base.NotificationBase):
30
+ """
31
+ API/Client notification for sending run statuses as a mail message
32
+ """
33
+
34
+ boolean_params = ["use_tls", "start_tls", "validate_certs"]
35
+
36
+ required_params = [
37
+ "server_host",
38
+ "server_port",
39
+ "sender_address",
40
+ "username",
41
+ "password",
42
+ "email_addresses",
43
+ ] + boolean_params
44
+
45
+ @classmethod
46
+ def validate_params(cls, params):
47
+ for required_param in cls.required_params:
48
+ if required_param not in params:
49
+ raise ValueError(
50
+ f"Parameter '{required_param}' is required for MailNotification"
51
+ )
52
+
53
+ for boolean_param in cls.boolean_params:
54
+ if not isinstance(params.get(boolean_param, None), bool):
55
+ raise ValueError(
56
+ f"Parameter '{boolean_param}' must be a boolean for MailNotification"
57
+ )
58
+
59
+ cls._validate_emails(params)
60
+
61
+ async def push(
62
+ self,
63
+ message: str,
64
+ severity: typing.Optional[
65
+ typing.Union[mlrun.common.schemas.NotificationSeverity, str]
66
+ ] = mlrun.common.schemas.NotificationSeverity.INFO,
67
+ runs: typing.Optional[typing.Union[mlrun.lists.RunList, list]] = None,
68
+ custom_html: typing.Optional[typing.Optional[str]] = None,
69
+ alert: typing.Optional[mlrun.common.schemas.AlertConfig] = None,
70
+ event_data: typing.Optional[mlrun.common.schemas.Event] = None,
71
+ ):
72
+ self.params.setdefault("subject", f"[{severity}] {message}")
73
+ self.params.setdefault("body", message)
74
+ await self._send_email(**self.params)
75
+
76
+ @classmethod
77
+ def enrich_default_params(
78
+ cls, params: dict, default_params: typing.Optional[dict] = None
79
+ ) -> dict:
80
+ params = super().enrich_default_params(params, default_params)
81
+ params.setdefault("use_tls", True)
82
+ params.setdefault("start_tls", False)
83
+ params.setdefault("validate_certs", True)
84
+ params.setdefault("server_port", DEFAULT_SMTP_PORT)
85
+
86
+ default_mail_address = params.pop("default_email_addresses", "")
87
+ email_addresses = params.get("email_addresses", default_mail_address)
88
+ if isinstance(email_addresses, list):
89
+ email_addresses = ",".join(email_addresses)
90
+ params["email_addresses"] = email_addresses
91
+
92
+ return params
93
+
94
+ @classmethod
95
+ def _validate_emails(cls, params):
96
+ cls._validate_email_address(params["sender_address"])
97
+
98
+ if not isinstance(params["email_addresses"], (str, list)):
99
+ raise ValueError(
100
+ "Parameter 'email_addresses' must be a string or a list of strings"
101
+ )
102
+
103
+ email_addresses = params["email_addresses"]
104
+ if isinstance(email_addresses, str):
105
+ email_addresses = email_addresses.split(",")
106
+ for email_address in email_addresses:
107
+ cls._validate_email_address(email_address)
108
+
109
+ @classmethod
110
+ def _validate_email_address(cls, email_address):
111
+ if not isinstance(email_address, str):
112
+ raise ValueError(f"Email address '{email_address}' must be a string")
113
+
114
+ if not re.match(mlrun.utils.regex.mail_regex, email_address):
115
+ raise ValueError(f"Invalid email address '{email_address}'")
116
+
117
+ @staticmethod
118
+ async def _send_email(
119
+ email_addresses: str,
120
+ sender_address: str,
121
+ server_host: str,
122
+ server_port: int,
123
+ username: str,
124
+ password: str,
125
+ use_tls: bool,
126
+ start_tls: bool,
127
+ validate_certs: bool,
128
+ subject: str,
129
+ body: str,
130
+ **kwargs,
131
+ ):
132
+ # Create the email message
133
+ message = EmailMessage()
134
+ message["From"] = sender_address
135
+ message["To"] = email_addresses
136
+ message["Subject"] = subject
137
+ message.set_content(body)
138
+
139
+ # Send the email
140
+ await aiosmtplib.send(
141
+ message,
142
+ hostname=server_host,
143
+ port=server_port,
144
+ username=username,
145
+ password=password,
146
+ use_tls=use_tls,
147
+ validate_certs=validate_certs,
148
+ start_tls=start_tls,
149
+ )
@@ -46,13 +46,13 @@ class SlackNotification(NotificationBase):
46
46
  async def push(
47
47
  self,
48
48
  message: str,
49
- severity: typing.Union[
50
- mlrun.common.schemas.NotificationSeverity, str
49
+ severity: typing.Optional[
50
+ typing.Union[mlrun.common.schemas.NotificationSeverity, str]
51
51
  ] = mlrun.common.schemas.NotificationSeverity.INFO,
52
- runs: typing.Union[mlrun.lists.RunList, list] = None,
53
- custom_html: typing.Optional[str] = None,
54
- alert: mlrun.common.schemas.AlertConfig = None,
55
- event_data: mlrun.common.schemas.Event = None,
52
+ runs: typing.Optional[typing.Union[mlrun.lists.RunList, list]] = None,
53
+ custom_html: typing.Optional[typing.Optional[str]] = None,
54
+ alert: typing.Optional[mlrun.common.schemas.AlertConfig] = None,
55
+ event_data: typing.Optional[mlrun.common.schemas.Event] = None,
56
56
  ):
57
57
  webhook = self.params.get("webhook", None) or mlrun.get_secret_or_env(
58
58
  "SLACK_WEBHOOK"
@@ -37,13 +37,13 @@ class WebhookNotification(NotificationBase):
37
37
  async def push(
38
38
  self,
39
39
  message: str,
40
- severity: typing.Union[
41
- mlrun.common.schemas.NotificationSeverity, str
40
+ severity: typing.Optional[
41
+ typing.Union[mlrun.common.schemas.NotificationSeverity, str]
42
42
  ] = mlrun.common.schemas.NotificationSeverity.INFO,
43
- runs: typing.Union[mlrun.lists.RunList, list] = None,
44
- custom_html: typing.Optional[str] = None,
45
- alert: mlrun.common.schemas.AlertConfig = None,
46
- event_data: mlrun.common.schemas.Event = None,
43
+ runs: typing.Optional[typing.Union[mlrun.lists.RunList, list]] = None,
44
+ custom_html: typing.Optional[typing.Optional[str]] = None,
45
+ alert: typing.Optional[mlrun.common.schemas.AlertConfig] = None,
46
+ event_data: typing.Optional[mlrun.common.schemas.Event] = None,
47
47
  ):
48
48
  url = self.params.get("url", None)
49
49
  method = self.params.get("method", "post").lower()