mlrun 1.10.0rc16__py3-none-any.whl → 1.10.1rc4__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 (101) hide show
  1. mlrun/__init__.py +22 -2
  2. mlrun/artifacts/document.py +6 -1
  3. mlrun/artifacts/llm_prompt.py +21 -15
  4. mlrun/artifacts/model.py +3 -3
  5. mlrun/common/constants.py +9 -0
  6. mlrun/common/formatters/artifact.py +1 -0
  7. mlrun/common/model_monitoring/helpers.py +86 -0
  8. mlrun/common/schemas/__init__.py +2 -0
  9. mlrun/common/schemas/auth.py +2 -0
  10. mlrun/common/schemas/function.py +10 -0
  11. mlrun/common/schemas/hub.py +30 -18
  12. mlrun/common/schemas/model_monitoring/__init__.py +2 -0
  13. mlrun/common/schemas/model_monitoring/constants.py +30 -6
  14. mlrun/common/schemas/model_monitoring/functions.py +13 -4
  15. mlrun/common/schemas/model_monitoring/model_endpoints.py +11 -0
  16. mlrun/common/schemas/pipeline.py +1 -1
  17. mlrun/common/schemas/serving.py +3 -0
  18. mlrun/common/schemas/workflow.py +1 -0
  19. mlrun/common/secrets.py +22 -1
  20. mlrun/config.py +34 -21
  21. mlrun/datastore/__init__.py +11 -3
  22. mlrun/datastore/azure_blob.py +162 -47
  23. mlrun/datastore/base.py +265 -7
  24. mlrun/datastore/datastore.py +10 -5
  25. mlrun/datastore/datastore_profile.py +61 -5
  26. mlrun/datastore/model_provider/huggingface_provider.py +367 -0
  27. mlrun/datastore/model_provider/mock_model_provider.py +87 -0
  28. mlrun/datastore/model_provider/model_provider.py +211 -74
  29. mlrun/datastore/model_provider/openai_provider.py +243 -71
  30. mlrun/datastore/s3.py +24 -2
  31. mlrun/datastore/store_resources.py +4 -4
  32. mlrun/datastore/storeytargets.py +2 -3
  33. mlrun/datastore/utils.py +15 -3
  34. mlrun/db/base.py +27 -19
  35. mlrun/db/httpdb.py +57 -48
  36. mlrun/db/nopdb.py +25 -10
  37. mlrun/execution.py +55 -13
  38. mlrun/hub/__init__.py +15 -0
  39. mlrun/hub/module.py +181 -0
  40. mlrun/k8s_utils.py +105 -16
  41. mlrun/launcher/base.py +13 -6
  42. mlrun/launcher/local.py +2 -0
  43. mlrun/model.py +9 -3
  44. mlrun/model_monitoring/api.py +66 -27
  45. mlrun/model_monitoring/applications/__init__.py +1 -1
  46. mlrun/model_monitoring/applications/base.py +388 -138
  47. mlrun/model_monitoring/applications/context.py +2 -4
  48. mlrun/model_monitoring/applications/results.py +4 -7
  49. mlrun/model_monitoring/controller.py +239 -101
  50. mlrun/model_monitoring/db/_schedules.py +36 -13
  51. mlrun/model_monitoring/db/_stats.py +4 -3
  52. mlrun/model_monitoring/db/tsdb/base.py +29 -9
  53. mlrun/model_monitoring/db/tsdb/tdengine/schemas.py +4 -5
  54. mlrun/model_monitoring/db/tsdb/tdengine/tdengine_connector.py +154 -50
  55. mlrun/model_monitoring/db/tsdb/tdengine/writer_graph_steps.py +51 -0
  56. mlrun/model_monitoring/db/tsdb/v3io/stream_graph_steps.py +17 -4
  57. mlrun/model_monitoring/db/tsdb/v3io/v3io_connector.py +245 -51
  58. mlrun/model_monitoring/helpers.py +28 -5
  59. mlrun/model_monitoring/stream_processing.py +45 -14
  60. mlrun/model_monitoring/writer.py +220 -1
  61. mlrun/platforms/__init__.py +3 -2
  62. mlrun/platforms/iguazio.py +7 -3
  63. mlrun/projects/operations.py +16 -11
  64. mlrun/projects/pipelines.py +2 -2
  65. mlrun/projects/project.py +157 -69
  66. mlrun/run.py +97 -20
  67. mlrun/runtimes/__init__.py +18 -0
  68. mlrun/runtimes/base.py +14 -6
  69. mlrun/runtimes/daskjob.py +1 -0
  70. mlrun/runtimes/local.py +5 -2
  71. mlrun/runtimes/mounts.py +20 -2
  72. mlrun/runtimes/nuclio/__init__.py +1 -0
  73. mlrun/runtimes/nuclio/application/application.py +147 -17
  74. mlrun/runtimes/nuclio/function.py +72 -27
  75. mlrun/runtimes/nuclio/serving.py +102 -20
  76. mlrun/runtimes/pod.py +213 -21
  77. mlrun/runtimes/utils.py +49 -9
  78. mlrun/secrets.py +54 -13
  79. mlrun/serving/remote.py +79 -6
  80. mlrun/serving/routers.py +23 -41
  81. mlrun/serving/server.py +230 -40
  82. mlrun/serving/states.py +605 -232
  83. mlrun/serving/steps.py +62 -0
  84. mlrun/serving/system_steps.py +136 -81
  85. mlrun/serving/v2_serving.py +9 -10
  86. mlrun/utils/helpers.py +215 -83
  87. mlrun/utils/logger.py +3 -1
  88. mlrun/utils/notifications/notification/base.py +18 -0
  89. mlrun/utils/notifications/notification/git.py +2 -4
  90. mlrun/utils/notifications/notification/mail.py +38 -15
  91. mlrun/utils/notifications/notification/slack.py +2 -4
  92. mlrun/utils/notifications/notification/webhook.py +2 -5
  93. mlrun/utils/notifications/notification_pusher.py +1 -1
  94. mlrun/utils/version/version.json +2 -2
  95. {mlrun-1.10.0rc16.dist-info → mlrun-1.10.1rc4.dist-info}/METADATA +51 -50
  96. {mlrun-1.10.0rc16.dist-info → mlrun-1.10.1rc4.dist-info}/RECORD +100 -95
  97. mlrun/api/schemas/__init__.py +0 -259
  98. {mlrun-1.10.0rc16.dist-info → mlrun-1.10.1rc4.dist-info}/WHEEL +0 -0
  99. {mlrun-1.10.0rc16.dist-info → mlrun-1.10.1rc4.dist-info}/entry_points.txt +0 -0
  100. {mlrun-1.10.0rc16.dist-info → mlrun-1.10.1rc4.dist-info}/licenses/LICENSE +0 -0
  101. {mlrun-1.10.0rc16.dist-info → mlrun-1.10.1rc4.dist-info}/top_level.txt +0 -0
mlrun/utils/helpers.py CHANGED
@@ -15,13 +15,13 @@
15
15
  import asyncio
16
16
  import base64
17
17
  import enum
18
- import functools
19
18
  import gzip
20
19
  import hashlib
21
20
  import inspect
22
21
  import itertools
23
22
  import json
24
23
  import os
24
+ import pathlib
25
25
  import re
26
26
  import string
27
27
  import sys
@@ -46,6 +46,8 @@ import pytz
46
46
  import semver
47
47
  import yaml
48
48
  from dateutil import parser
49
+ from packaging.requirements import Requirement
50
+ from packaging.utils import canonicalize_name
49
51
  from pandas import Timedelta, Timestamp
50
52
  from yaml.representer import RepresenterError
51
53
 
@@ -62,6 +64,7 @@ import mlrun_pipelines.models
62
64
  import mlrun_pipelines.utils
63
65
  from mlrun.common.constants import MYSQL_MEDIUMBLOB_SIZE_BYTES
64
66
  from mlrun.common.schemas import ArtifactCategories
67
+ from mlrun.common.schemas.hub import HubSourceType
65
68
  from mlrun.config import config
66
69
  from mlrun_pipelines.models import PipelineRun
67
70
 
@@ -250,6 +253,40 @@ def verify_field_regex(
250
253
  return False
251
254
 
252
255
 
256
+ def validate_function_name(name: str) -> None:
257
+ """
258
+ Validate that a function name conforms to Kubernetes DNS-1123 label requirements.
259
+
260
+ Function names for Kubernetes resources must:
261
+ - Be lowercase alphanumeric characters or '-'
262
+ - Start and end with an alphanumeric character
263
+ - Be at most 63 characters long
264
+
265
+ This validation should be called AFTER normalize_name() has been applied.
266
+
267
+ Refer to https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#dns-label-names
268
+
269
+ :param name: The function name to validate (after normalization)
270
+ :raises MLRunInvalidArgumentError: If the function name is invalid for Kubernetes
271
+ """
272
+ if not name:
273
+ return
274
+
275
+ verify_field_regex(
276
+ "function.metadata.name",
277
+ name,
278
+ mlrun.utils.regex.dns_1123_label,
279
+ raise_on_failure=True,
280
+ log_message=(
281
+ f"Function name '{name}' is invalid. "
282
+ "Kubernetes function names must be DNS-1123 labels: "
283
+ "lowercase alphanumeric characters or '-', "
284
+ "starting and ending with an alphanumeric character, "
285
+ "and at most 63 characters long."
286
+ ),
287
+ )
288
+
289
+
253
290
  def validate_builder_source(
254
291
  source: str, pull_at_runtime: bool = False, workdir: Optional[str] = None
255
292
  ):
@@ -464,21 +501,49 @@ def to_date_str(d):
464
501
  return ""
465
502
 
466
503
 
467
- def normalize_name(name: str, verbose: bool = True):
504
+ def normalize_name(name: str):
468
505
  # TODO: Must match
469
506
  # [a-z0-9]([-a-z0-9]*[a-z0-9])?(\\.[a-z0-9]([-a-z0-9]*[a-z0-9])?
470
507
  name = re.sub(r"\s+", "-", name)
471
508
  if "_" in name:
472
- if verbose:
473
- warnings.warn(
474
- "Names with underscore '_' are about to be deprecated, use dashes '-' instead. "
475
- f"Replacing '{name}' underscores with dashes.",
476
- FutureWarning,
477
- )
478
509
  name = name.replace("_", "-")
479
510
  return name.lower()
480
511
 
481
512
 
513
+ def ensure_batch_job_suffix(
514
+ function_name: typing.Optional[str],
515
+ ) -> tuple[typing.Optional[str], bool, str]:
516
+ """
517
+ Ensure that a function name has the batch job suffix appended to prevent database collision.
518
+
519
+ This helper is used by to_job() methods in runtimes that convert online functions (serving, local)
520
+ to batch processing jobs. The suffix prevents the job from overwriting the original function in
521
+ the database when both are stored with the same (project, name) key.
522
+
523
+ :param function_name: The original function name (can be None or empty string)
524
+
525
+ :return: A tuple of (modified_name, was_renamed, suffix) where:
526
+ - modified_name: The function name with the batch suffix (if not already present),
527
+ or empty string if input was empty
528
+ - was_renamed: True if the suffix was added, False if it was already present or if name was empty/None
529
+ - suffix: The suffix value that was used (or would have been used)
530
+
531
+ """
532
+ suffix = mlrun_constants.RESERVED_BATCH_JOB_SUFFIX
533
+
534
+ # Handle None or empty string
535
+ if not function_name:
536
+ return function_name, False, suffix
537
+
538
+ if not function_name.endswith(suffix):
539
+ return (
540
+ f"{function_name}{suffix}",
541
+ True,
542
+ suffix,
543
+ )
544
+ return function_name, False, suffix
545
+
546
+
482
547
  class LogBatchWriter:
483
548
  def __init__(self, func, batch=16, maxtime=5):
484
549
  self.batch = batch
@@ -508,9 +573,14 @@ def get_in(obj, keys, default=None):
508
573
  if isinstance(keys, str):
509
574
  keys = keys.split(".")
510
575
  for key in keys:
511
- if not obj or key not in obj:
576
+ if obj is None:
512
577
  return default
513
- obj = obj[key]
578
+ if isinstance(obj, dict):
579
+ if key not in obj:
580
+ return default
581
+ obj = obj[key]
582
+ else:
583
+ obj = getattr(obj, key, default)
514
584
  return obj
515
585
 
516
586
 
@@ -800,20 +870,35 @@ def remove_tag_from_artifact_uri(uri: str) -> Optional[str]:
800
870
  "store://key:tag" => "store://key"
801
871
  "store://models/remote-model-project/my_model#0@tree" => unchanged (no tag)
802
872
  """
803
- return re.sub(r"(?<=/[^/:]\+):[^@^:\s]+(?=(@|\^|$))", "", uri)
873
+ add_store = False
874
+ if mlrun.datastore.is_store_uri(uri):
875
+ uri = uri.removeprefix(DB_SCHEMA + "://")
876
+ add_store = True
877
+ uri = re.sub(r"(#[^:@\s]*)?:[^@^:\s]+(?=(@|\^|$))", lambda m: m.group(1) or "", uri)
878
+ return uri if not add_store else DB_SCHEMA + "://" + uri
879
+
804
880
 
881
+ def check_if_hub_uri(uri: str) -> bool:
882
+ return uri.startswith(hub_prefix)
805
883
 
806
- def extend_hub_uri_if_needed(uri) -> tuple[str, bool]:
884
+
885
+ def extend_hub_uri_if_needed(
886
+ uri: str,
887
+ asset_type: HubSourceType = HubSourceType.functions,
888
+ file: str = "function.yaml",
889
+ ) -> tuple[str, bool]:
807
890
  """
808
- Retrieve the full uri of the item's yaml in the hub.
891
+ Retrieve the full uri of an object in the hub.
809
892
 
810
893
  :param uri: structure: "hub://[<source>/]<item-name>[:<tag>]"
894
+ :param asset_type: The type of the hub item (functions, modules, etc.)
895
+ :param file: The file name inside the hub item directory (default: function.yaml)
811
896
 
812
897
  :return: A tuple of:
813
898
  [0] = Extended URI of item
814
899
  [1] = Is hub item (bool)
815
900
  """
816
- is_hub_uri = uri.startswith(hub_prefix)
901
+ is_hub_uri = check_if_hub_uri(uri)
817
902
  if not is_hub_uri:
818
903
  return uri, is_hub_uri
819
904
 
@@ -830,10 +915,10 @@ def extend_hub_uri_if_needed(uri) -> tuple[str, bool]:
830
915
  raise mlrun.errors.MLRunInvalidArgumentError(
831
916
  "Invalid character '/' in function name or source name"
832
917
  ) from exc
833
- name = normalize_name(name=name, verbose=False)
918
+ name = normalize_name(name=name)
834
919
  if not source_name:
835
920
  # Searching item in all sources
836
- sources = db.list_hub_sources(item_name=name, tag=tag)
921
+ sources = db.list_hub_sources(item_name=name, tag=tag, item_type=asset_type)
837
922
  if not sources:
838
923
  raise mlrun.errors.MLRunNotFoundError(
839
924
  f"Item={name}, tag={tag} not found in any hub source"
@@ -843,10 +928,10 @@ def extend_hub_uri_if_needed(uri) -> tuple[str, bool]:
843
928
  else:
844
929
  # Specific source is given
845
930
  indexed_source = db.get_hub_source(source_name)
846
- # hub function directory name are with underscores instead of hyphens
931
+ # hub directories name are with underscores instead of hyphens
847
932
  name = name.replace("-", "_")
848
- function_suffix = f"{name}/{tag}/src/function.yaml"
849
- return indexed_source.source.get_full_uri(function_suffix), is_hub_uri
933
+ suffix = f"{name}/{tag}/src/{file}"
934
+ return indexed_source.source.get_full_uri(suffix, asset_type), is_hub_uri
850
935
 
851
936
 
852
937
  def gen_md_table(header, rows=None):
@@ -914,10 +999,20 @@ def enrich_image_url(
914
999
  mlrun_version = config.images_tag or client_version or server_version
915
1000
  tag = mlrun_version or ""
916
1001
 
917
- # TODO: Remove condition when mlrun/mlrun-kfp image is also supported
918
- if "mlrun-kfp" not in image_url:
1002
+ # starting mlrun 1.10.0-rc0 we want to enrich the kfp image with the python version
1003
+ # e.g for 1.9 we have a single mlrun-kfp image that supports only python 3.9
1004
+ enrich_kfp_python_version = (
1005
+ "mlrun-kfp" in image_url
1006
+ and mlrun_version
1007
+ and semver.VersionInfo.is_valid(mlrun_version)
1008
+ and semver.VersionInfo.parse(mlrun_version)
1009
+ >= semver.VersionInfo.parse("1.10.0-rc0")
1010
+ )
1011
+
1012
+ if "mlrun-kfp" not in image_url or enrich_kfp_python_version:
919
1013
  tag += resolve_image_tag_suffix(
920
- mlrun_version=mlrun_version, python_version=client_python_version
1014
+ mlrun_version=mlrun_version,
1015
+ python_version=client_python_version,
921
1016
  )
922
1017
 
923
1018
  # it's an mlrun image if the repository is mlrun
@@ -930,8 +1025,10 @@ def enrich_image_url(
930
1025
  # use the tag from image URL if available, else fallback to the given tag
931
1026
  tag = image_tag or tag
932
1027
  if tag:
1028
+ # Remove '-pyXY' suffix if present, since the compatibility check expects a valid semver string
1029
+ tag_for_compatibility = re.sub(r"-py\d+$", "", tag)
933
1030
  if mlrun.utils.helpers.validate_component_version_compatibility(
934
- "mlrun-client", "1.10.0-rc0", mlrun_client_version=tag
1031
+ "mlrun-client", "1.10.0-rc0", mlrun_client_version=tag_for_compatibility
935
1032
  ):
936
1033
  warnings.warn(
937
1034
  "'mlrun/ml-base' image is deprecated in 1.10.0 and will be removed in 1.12.0, "
@@ -943,8 +1040,15 @@ def enrich_image_url(
943
1040
  else:
944
1041
  image_url = "mlrun/mlrun"
945
1042
 
946
- if is_mlrun_image and tag and ":" not in image_url:
947
- image_url = f"{image_url}:{tag}"
1043
+ if is_mlrun_image and tag:
1044
+ if ":" not in image_url:
1045
+ image_url = f"{image_url}:{tag}"
1046
+ elif enrich_kfp_python_version:
1047
+ # For mlrun-kfp >= 1.10.0-rc0, append python suffix to existing tag
1048
+ python_suffix = resolve_image_tag_suffix(
1049
+ mlrun_version, client_python_version
1050
+ )
1051
+ image_url = f"{image_url}{python_suffix}" if python_suffix else image_url
948
1052
 
949
1053
  registry = (
950
1054
  config.images_registry if is_mlrun_image else config.vendor_images_registry
@@ -1214,55 +1318,6 @@ def get_workflow_url(
1214
1318
  return url
1215
1319
 
1216
1320
 
1217
- def get_kfp_list_runs_filter(
1218
- project_name: Optional[str] = None,
1219
- end_date: Optional[str] = None,
1220
- start_date: Optional[str] = None,
1221
- ) -> str:
1222
- """
1223
- Generates a filter for listing Kubeflow Pipelines (KFP) runs.
1224
-
1225
- :param project_name: The name of the project. If "*", it won't filter by project.
1226
- :param end_date: The latest creation date for filtering runs (ISO 8601 format).
1227
- :param start_date: The earliest creation date for filtering runs (ISO 8601 format).
1228
- :return: A JSON-formatted filter string for KFP.
1229
- """
1230
-
1231
- # KFP filter operation codes
1232
- kfp_less_than_or_equal_op = 7 # '<='
1233
- kfp_greater_than_or_equal_op = 5 # '>='
1234
- kfp_substring_op = 9 # Substring match
1235
-
1236
- filters = {"predicates": []}
1237
-
1238
- if end_date:
1239
- filters["predicates"].append(
1240
- {
1241
- "key": "created_at",
1242
- "op": kfp_less_than_or_equal_op,
1243
- "timestamp_value": end_date,
1244
- }
1245
- )
1246
-
1247
- if project_name and project_name != "*":
1248
- filters["predicates"].append(
1249
- {
1250
- "key": "name",
1251
- "op": kfp_substring_op,
1252
- "string_value": project_name,
1253
- }
1254
- )
1255
- if start_date:
1256
- filters["predicates"].append(
1257
- {
1258
- "key": "created_at",
1259
- "op": kfp_greater_than_or_equal_op,
1260
- "timestamp_value": start_date,
1261
- }
1262
- )
1263
- return json.dumps(filters)
1264
-
1265
-
1266
1321
  def validate_and_convert_date(date_input: str) -> str:
1267
1322
  """
1268
1323
  Converts any recognizable date string into a standardized RFC 3339 format.
@@ -1860,10 +1915,7 @@ async def run_in_threadpool(func, *args, **kwargs):
1860
1915
  Run a sync-function in the loop default thread pool executor pool and await its result.
1861
1916
  Note that this function is not suitable for CPU-bound tasks, as it will block the event loop.
1862
1917
  """
1863
- loop = asyncio.get_running_loop()
1864
- if kwargs:
1865
- func = functools.partial(func, **kwargs)
1866
- return await loop.run_in_executor(None, func, *args)
1918
+ return await asyncio.to_thread(func, *args, **kwargs)
1867
1919
 
1868
1920
 
1869
1921
  def is_explicit_ack_supported(context):
@@ -2404,9 +2456,7 @@ def split_path(path: str) -> typing.Union[str, list[str], None]:
2404
2456
  return path
2405
2457
 
2406
2458
 
2407
- def get_data_from_path(
2408
- path: typing.Union[str, list[str], None], data: dict
2409
- ) -> dict[str, Any]:
2459
+ def get_data_from_path(path: typing.Union[str, list[str], None], data: dict) -> Any:
2410
2460
  if isinstance(path, str):
2411
2461
  output_data = data.get(path)
2412
2462
  elif isinstance(path, list):
@@ -2419,6 +2469,88 @@ def get_data_from_path(
2419
2469
  raise mlrun.errors.MLRunInvalidArgumentError(
2420
2470
  "Expected path be of type str or list of str or None"
2421
2471
  )
2422
- if isinstance(output_data, (int, float)):
2423
- output_data = [output_data]
2424
2472
  return output_data
2473
+
2474
+
2475
+ def is_valid_port(port: int, raise_on_error: bool = False) -> bool:
2476
+ if not port:
2477
+ return False
2478
+ if 0 <= port <= 65535:
2479
+ return True
2480
+ if raise_on_error:
2481
+ raise ValueError("Port must be in the range 0–65535")
2482
+ return False
2483
+
2484
+
2485
+ def set_data_by_path(
2486
+ path: typing.Union[str, list[str], None], data: dict, value
2487
+ ) -> None:
2488
+ if path is None:
2489
+ if not isinstance(value, dict):
2490
+ raise ValueError("When path is None, value must be a dictionary.")
2491
+ data.update(value)
2492
+
2493
+ elif isinstance(path, str):
2494
+ data[path] = value
2495
+
2496
+ elif isinstance(path, list):
2497
+ current = data
2498
+ for key in path[:-1]:
2499
+ if key not in current or not isinstance(current[key], dict):
2500
+ current[key] = {}
2501
+ current = current[key]
2502
+ current[path[-1]] = value
2503
+ else:
2504
+ raise mlrun.errors.MLRunInvalidArgumentError(
2505
+ "Expected path to be of type str or list of str"
2506
+ )
2507
+
2508
+
2509
+ def _normalize_requirements(reqs: typing.Union[str, list[str], None]) -> list[str]:
2510
+ if reqs is None:
2511
+ return []
2512
+ if isinstance(reqs, str):
2513
+ s = reqs.strip()
2514
+ return [s] if s else []
2515
+ return [s.strip() for s in reqs if s and s.strip()]
2516
+
2517
+
2518
+ def merge_requirements(
2519
+ reqs_priority: typing.Union[str, list[str], None],
2520
+ reqs_secondary: typing.Union[str, list[str], None],
2521
+ ) -> list[str]:
2522
+ """
2523
+ Merge two requirement collections into a union. If the same package
2524
+ appears in both, the specifier from reqs_priority wins.
2525
+
2526
+ Args:
2527
+ reqs_priority: str | list[str] | None (priority input)
2528
+ reqs_secondary: str | list[str] | None
2529
+
2530
+ Returns:
2531
+ list[str]: pip-style requirements.
2532
+ """
2533
+ merged: dict[str, Requirement] = {}
2534
+
2535
+ for r in _normalize_requirements(reqs_secondary) + _normalize_requirements(
2536
+ reqs_priority
2537
+ ):
2538
+ req = Requirement(r)
2539
+ merged[canonicalize_name(req.name)] = req
2540
+
2541
+ return [str(req) for req in merged.values()]
2542
+
2543
+
2544
+ def get_source_and_working_dir_paths(source_file_path) -> (pathlib.Path, pathlib.Path):
2545
+ source_file_path_object = pathlib.Path(source_file_path).resolve()
2546
+ working_dir_path_object = pathlib.Path(".").resolve()
2547
+ return source_file_path_object, working_dir_path_object
2548
+
2549
+
2550
+ def get_relative_module_name_from_path(
2551
+ source_file_path_object, working_dir_path_object
2552
+ ) -> str:
2553
+ relative_path_to_source_file = source_file_path_object.relative_to(
2554
+ working_dir_path_object
2555
+ )
2556
+ return ".".join(relative_path_to_source_file.with_suffix("").parts)
mlrun/utils/logger.py CHANGED
@@ -393,12 +393,14 @@ def resolve_formatter_by_kind(
393
393
 
394
394
 
395
395
  def create_test_logger(name: str = "mlrun", stream: IO[str] = stdout) -> Logger:
396
- return create_logger(
396
+ logger = create_logger(
397
397
  level="debug",
398
398
  formatter_kind=FormatterKinds.HUMAN_EXTENDED.name,
399
399
  name=name,
400
400
  stream=stream,
401
401
  )
402
+ logger._logger.propagate = True # pass records up to pytest’s handler
403
+ return logger
402
404
 
403
405
 
404
406
  def create_logger(
@@ -15,11 +15,29 @@
15
15
  import asyncio
16
16
  import typing
17
17
  from copy import deepcopy
18
+ from typing import Optional
19
+
20
+ import aiohttp
18
21
 
19
22
  import mlrun.common.schemas
20
23
  import mlrun.lists
21
24
 
22
25
 
26
+ class TimedHTTPClient:
27
+ def __init__(self, timeout: Optional[float] = 30.0):
28
+ """
29
+ HTTP client wrapper with built-in timeout.
30
+
31
+ Args:
32
+ timeout: Request timeout in seconds (default: 30.0)
33
+ """
34
+ self.timeout = aiohttp.ClientTimeout(total=timeout)
35
+
36
+ def session(self, **kwargs) -> aiohttp.ClientSession:
37
+ """Create a new ClientSession with the configured timeout and additional parameters."""
38
+ return aiohttp.ClientSession(timeout=self.timeout, **kwargs)
39
+
40
+
23
41
  class NotificationBase:
24
42
  def __init__(
25
43
  self,
@@ -16,13 +16,11 @@ import json
16
16
  import os
17
17
  import typing
18
18
 
19
- import aiohttp
20
-
21
19
  import mlrun.common.schemas
22
20
  import mlrun.errors
23
21
  import mlrun.lists
24
22
 
25
- from .base import NotificationBase
23
+ from .base import NotificationBase, TimedHTTPClient
26
24
 
27
25
 
28
26
  class GitNotification(NotificationBase):
@@ -148,7 +146,7 @@ class GitNotification(NotificationBase):
148
146
  }
149
147
  url = f"https://{server}/repos/{repo}/issues/{issue}/comments"
150
148
 
151
- async with aiohttp.ClientSession() as session:
149
+ async with TimedHTTPClient().session() as session:
152
150
  resp = await session.post(url, headers=headers, json={"body": message})
153
151
  if not resp.ok:
154
152
  resp_text = await resp.text()
@@ -34,17 +34,18 @@ class MailNotification(base.NotificationBase):
34
34
 
35
35
  boolean_params = ["use_tls", "start_tls", "validate_certs"]
36
36
 
37
+ optional_auth_params = ["username", "password"]
38
+
37
39
  required_params = [
38
40
  "server_host",
39
41
  "server_port",
40
42
  "sender_address",
41
- "username",
42
- "password",
43
43
  "email_addresses",
44
44
  ] + boolean_params
45
45
 
46
46
  @classmethod
47
47
  def validate_params(cls, params):
48
+ cls._enrich_params(params)
48
49
  for required_param in cls.required_params:
49
50
  if required_param not in params:
50
51
  raise ValueError(
@@ -57,6 +58,13 @@ class MailNotification(base.NotificationBase):
57
58
  f"Parameter '{boolean_param}' must be a boolean for MailNotification"
58
59
  )
59
60
 
61
+ # Allow no auth, username only, or username + password
62
+ # Some SMTP servers allow username without password
63
+ if params["password"] and not params["username"]:
64
+ raise ValueError(
65
+ "Parameter 'username' is required when 'password' is provided for MailNotification"
66
+ )
67
+
60
68
  cls._validate_emails(params)
61
69
 
62
70
  async def push(
@@ -78,6 +86,8 @@ class MailNotification(base.NotificationBase):
78
86
  )
79
87
  self.params["body"] = runs_html
80
88
 
89
+ self._enrich_params(self.params)
90
+
81
91
  if message_body_override:
82
92
  self.params["body"] = message_body_override.replace(
83
93
  "{{ runs }}", runs_html
@@ -147,8 +157,8 @@ class MailNotification(base.NotificationBase):
147
157
  sender_address: str,
148
158
  server_host: str,
149
159
  server_port: int,
150
- username: str,
151
- password: str,
160
+ username: typing.Optional[str],
161
+ password: typing.Optional[str],
152
162
  use_tls: bool,
153
163
  start_tls: bool,
154
164
  validate_certs: bool,
@@ -163,14 +173,27 @@ class MailNotification(base.NotificationBase):
163
173
  message["Subject"] = subject
164
174
  message.attach(MIMEText(body, "html"))
165
175
 
166
- # Send the email
167
- await aiosmtplib.send(
168
- message,
169
- hostname=server_host,
170
- port=server_port,
171
- username=username,
172
- password=password,
173
- use_tls=use_tls,
174
- validate_certs=validate_certs,
175
- start_tls=start_tls,
176
- )
176
+ send_kwargs = {
177
+ "hostname": server_host,
178
+ "port": server_port,
179
+ "use_tls": use_tls,
180
+ "validate_certs": validate_certs,
181
+ "start_tls": start_tls,
182
+ }
183
+
184
+ # Only include auth parameters when provided to avoid forcing SMTP AUTH
185
+ if username is not None:
186
+ send_kwargs["username"] = username
187
+ if password is not None:
188
+ send_kwargs["password"] = password
189
+
190
+ await aiosmtplib.send(message, **send_kwargs)
191
+
192
+ @staticmethod
193
+ def _enrich_params(params):
194
+ # if username/password are not provided or empty strings, set them to None.
195
+ # this ensures consistent behavior in _send_email and avoids
196
+ # forcing SMTP auth when the server does not require authentication.
197
+ for param in ["username", "password"]:
198
+ if param not in params or not params[param]:
199
+ params[param] = None
@@ -14,14 +14,12 @@
14
14
 
15
15
  import typing
16
16
 
17
- import aiohttp
18
-
19
17
  import mlrun.common.runtimes.constants as runtimes_constants
20
18
  import mlrun.common.schemas
21
19
  import mlrun.lists
22
20
  import mlrun.utils.helpers
23
21
 
24
- from .base import NotificationBase
22
+ from .base import NotificationBase, TimedHTTPClient
25
23
 
26
24
 
27
25
  class SlackNotification(NotificationBase):
@@ -67,7 +65,7 @@ class SlackNotification(NotificationBase):
67
65
 
68
66
  data = self._generate_slack_data(message, severity, runs, alert, event_data)
69
67
 
70
- async with aiohttp.ClientSession() as session:
68
+ async with TimedHTTPClient().session() as session:
71
69
  async with session.post(webhook, json=data) as response:
72
70
  response.raise_for_status()
73
71
 
@@ -15,14 +15,13 @@
15
15
  import re
16
16
  import typing
17
17
 
18
- import aiohttp
19
18
  import orjson
20
19
 
21
20
  import mlrun.common.schemas
22
21
  import mlrun.lists
23
22
  import mlrun.utils.helpers
24
23
 
25
- from .base import NotificationBase
24
+ from .base import NotificationBase, TimedHTTPClient
26
25
 
27
26
 
28
27
  class WebhookNotification(NotificationBase):
@@ -87,9 +86,7 @@ class WebhookNotification(NotificationBase):
87
86
  # we automatically handle it as `ssl=None` for their convenience.
88
87
  verify_ssl = verify_ssl and None if url.startswith("https") else None
89
88
 
90
- async with aiohttp.ClientSession(
91
- json_serialize=self._encoder,
92
- ) as session:
89
+ async with TimedHTTPClient().session(json_serialize=self._encoder) as session:
93
90
  response = await getattr(session, method)(
94
91
  url,
95
92
  headers=headers,
@@ -308,7 +308,7 @@ class NotificationPusher(_NotificationPusherBase):
308
308
  and retry_count >= max_retries
309
309
  ):
310
310
  message += (
311
- "\nRetry limit reached run has failed after all retry attempts."
311
+ "\nRetry limit reached - run has failed after all retry attempts."
312
312
  )
313
313
 
314
314
  severity = (
@@ -1,4 +1,4 @@
1
1
  {
2
- "git_commit": "78045e1e85e7c81eee93682240c4ebe7b22fa67c",
3
- "version": "1.10.0-rc16"
2
+ "git_commit": "d48a638b75d73b43de9731dc297755e2c0c86084",
3
+ "version": "1.10.1-rc4"
4
4
  }