metaflow 2.15.7__py2.py3-none-any.whl → 2.15.8__py2.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.
metaflow/cli.py CHANGED
@@ -17,6 +17,7 @@ from .flowspec import _FlowState
17
17
  from .graph import FlowGraph
18
18
  from .metaflow_config import (
19
19
  DEFAULT_DATASTORE,
20
+ DEFAULT_DECOSPECS,
20
21
  DEFAULT_ENVIRONMENT,
21
22
  DEFAULT_EVENT_LOGGER,
22
23
  DEFAULT_METADATA,
@@ -509,9 +510,16 @@ def start(
509
510
  ):
510
511
  # run/resume are special cases because they can add more decorators with --with,
511
512
  # so they have to take care of themselves.
513
+
512
514
  all_decospecs = ctx.obj.tl_decospecs + list(
513
515
  ctx.obj.environment.decospecs() or []
514
516
  )
517
+
518
+ # We add the default decospecs for everything except init and step since in those
519
+ # cases, the decospecs will already have been handled by either a run/resume
520
+ # or a scheduler setting them up in their own way.
521
+ if ctx.saved_args[0] not in ("step", "init"):
522
+ all_decospecs += DEFAULT_DECOSPECS.split()
515
523
  if all_decospecs:
516
524
  decorators._attach_decorators(ctx.obj.flow, all_decospecs)
517
525
  decorators._init(ctx.obj.flow)
@@ -71,7 +71,7 @@ def write_file(file_path, content):
71
71
  f.write(str(content))
72
72
 
73
73
 
74
- def config_merge_cb(ctx, param, value):
74
+ def config_callback(ctx, param, value):
75
75
  # Callback to:
76
76
  # - read the Click auto_envvar variable from both the
77
77
  # environment AND the configuration
@@ -127,7 +127,7 @@ def common_run_options(func):
127
127
  help="Add a decorator to all steps. You can specify this "
128
128
  "option multiple times to attach multiple decorators "
129
129
  "in steps.",
130
- callback=config_merge_cb,
130
+ callback=config_callback,
131
131
  )
132
132
  @click.option(
133
133
  "--run-id-file",
metaflow/cmd/main_cli.py CHANGED
@@ -94,7 +94,7 @@ def start(ctx):
94
94
  echo("(%s)\n" % version, fg="magenta", bold=False)
95
95
 
96
96
  if ctx.invoked_subcommand is None:
97
- echo("More data science, less engineering\n", fg="magenta")
97
+ echo("More AI, less engineering\n", fg="magenta")
98
98
 
99
99
  lnk_sz = max(len(lnk) for lnk in CONTACT_INFO.values()) + 1
100
100
  for what, lnk in CONTACT_INFO.items():
@@ -630,6 +630,20 @@ class MetadataProvider(object):
630
630
  sys_info["r_version"] = env["r_version_code"]
631
631
  return sys_info
632
632
 
633
+ def _get_git_info_as_dict(self):
634
+ git_info = {}
635
+ env = self._environment.get_environment_info()
636
+ for key in [
637
+ "repo_url",
638
+ "branch_name",
639
+ "commit_sha",
640
+ "has_uncommitted_changes",
641
+ ]:
642
+ if key in env and env[key]:
643
+ git_info[key] = env[key]
644
+
645
+ return git_info
646
+
633
647
  def _get_system_tags(self):
634
648
  """Convert system info dictionary into a list of system tags"""
635
649
  return [
@@ -670,6 +684,27 @@ class MetadataProvider(object):
670
684
  tags=["attempt_id:{0}".format(attempt)],
671
685
  )
672
686
  )
687
+ # Add script name as metadata
688
+ script_name = self._environment.get_environment_info()["script"]
689
+ metadata.append(
690
+ MetaDatum(
691
+ field="script-name",
692
+ value=script_name,
693
+ type="script-name",
694
+ tags=["attempt_id:{0}".format(attempt)],
695
+ )
696
+ )
697
+ # And add git metadata
698
+ git_info = self._get_git_info_as_dict()
699
+ if git_info:
700
+ metadata.append(
701
+ MetaDatum(
702
+ field="git-info",
703
+ value=json.dumps(git_info),
704
+ type="git-info",
705
+ tags=["attempt_id:{0}".format(attempt)],
706
+ )
707
+ )
673
708
  if metadata:
674
709
  self.register_metadata(run_id, step_name, task_id, metadata)
675
710
 
@@ -109,6 +109,12 @@ S3_WORKER_COUNT = from_conf("S3_WORKER_COUNT", 64)
109
109
  # top-level retries)
110
110
  S3_TRANSIENT_RETRY_COUNT = from_conf("S3_TRANSIENT_RETRY_COUNT", 20)
111
111
 
112
+ # S3 retry configuration used in the aws client
113
+ # Use the adaptive retry strategy by default
114
+ S3_CLIENT_RETRY_CONFIG = from_conf(
115
+ "S3_CLIENT_RETRY_CONFIG", {"max_attempts": 10, "mode": "adaptive"}
116
+ )
117
+
112
118
  # Threshold to start printing warnings for an AWS retry
113
119
  RETRY_WARNING_THRESHOLD = 3
114
120
 
@@ -4,6 +4,7 @@ import sys
4
4
 
5
5
  from .util import get_username
6
6
  from . import metaflow_version
7
+ from . import metaflow_git
7
8
  from metaflow.exception import MetaflowException
8
9
  from metaflow.extension_support import dump_module_info
9
10
  from metaflow.mflog import BASH_MFLOG, BASH_FLUSH_LOGS
@@ -197,6 +198,10 @@ class MetaflowEnvironment(object):
197
198
  "python_version_code": "%d.%d.%d" % sys.version_info[:3],
198
199
  "metaflow_version": metaflow_version.get_version(),
199
200
  "script": os.path.basename(os.path.abspath(sys.argv[0])),
201
+ # Add git info
202
+ **metaflow_git.get_repository_info(
203
+ path=os.path.dirname(os.path.abspath(sys.argv[0]))
204
+ ),
200
205
  }
201
206
  if R.use_r():
202
207
  env["metaflow_r_version"] = R.metaflow_r_version()
@@ -206,7 +211,7 @@ class MetaflowEnvironment(object):
206
211
  # Information about extension modules (to load them in the proper order)
207
212
  ext_key, ext_val = dump_module_info()
208
213
  env[ext_key] = ext_val
209
- return env
214
+ return {k: v for k, v in env.items() if v is not None and v != ""}
210
215
 
211
216
  def executable(self, step_name, default=None):
212
217
  if default is not None:
@@ -0,0 +1,115 @@
1
+ #!/usr/bin/env python
2
+ """Get git repository information for the package
3
+
4
+ Functions to retrieve git repository details like URL, branch name,
5
+ and commit SHA for Metaflow code provenance tracking.
6
+ """
7
+
8
+ import os
9
+ import subprocess
10
+ from typing import Dict, List, Optional, Tuple, Union
11
+
12
+ # Cache for git information to avoid repeated subprocess calls
13
+ _git_info_cache = None
14
+
15
+ __all__ = ("get_repository_info",)
16
+
17
+
18
+ def _call_git(
19
+ args: List[str], path=Union[str, os.PathLike]
20
+ ) -> Tuple[Optional[str], Optional[int], bool]:
21
+ """
22
+ Call git with provided args.
23
+
24
+ Returns
25
+ -------
26
+ tuple : Tuple containing
27
+ (stdout, exitcode, failure) of the call
28
+ """
29
+ try:
30
+ result = subprocess.run(
31
+ ["git", *args],
32
+ cwd=path,
33
+ capture_output=True,
34
+ text=True,
35
+ check=False,
36
+ )
37
+ return result.stdout.strip(), result.returncode, False
38
+ except (OSError, subprocess.SubprocessError):
39
+ # Covers subprocess timeouts and other errors which would not lead to an exit code
40
+ return None, None, True
41
+
42
+
43
+ def _get_repo_url(path: Union[str, os.PathLike]) -> Optional[str]:
44
+ """Get the repository URL from git config"""
45
+ stdout, returncode, _failed = _call_git(
46
+ ["config", "--get", "remote.origin.url"], path
47
+ )
48
+ if returncode == 0:
49
+ url = stdout
50
+ # Convert SSH URLs to HTTPS for clickable links
51
+ if url.startswith("git@"):
52
+ parts = url.split(":", 1)
53
+ if len(parts) == 2:
54
+ domain = parts[0].replace("git@", "")
55
+ repo_path = parts[1]
56
+ url = f"https://{domain}/{repo_path}"
57
+ return url
58
+ return None
59
+
60
+
61
+ def _get_branch_name(path: Union[str, os.PathLike]) -> Optional[str]:
62
+ """Get the current git branch name"""
63
+ stdout, returncode, _failed = _call_git(["rev-parse", "--abbrev-ref", "HEAD"], path)
64
+ return stdout if returncode == 0 else None
65
+
66
+
67
+ def _get_commit_sha(path: Union[str, os.PathLike]) -> Optional[str]:
68
+ """Get the current git commit SHA"""
69
+ stdout, returncode, _failed = _call_git(["rev-parse", "HEAD"], path)
70
+ return stdout if returncode == 0 else None
71
+
72
+
73
+ def _is_in_git_repo(path: Union[str, os.PathLike]) -> bool:
74
+ """Check if we're currently in a git repository"""
75
+ stdout, returncode, _failed = _call_git(
76
+ ["rev-parse", "--is-inside-work-tree"], path
77
+ )
78
+ return returncode == 0 and stdout == "true"
79
+
80
+
81
+ def _has_uncommitted_changes(path: Union[str, os.PathLike]) -> Optional[bool]:
82
+ """Check if the git repository has uncommitted changes"""
83
+ _stdout, returncode, failed = _call_git(
84
+ ["diff-index", "--quiet", "HEAD", "--"], path
85
+ )
86
+ if failed:
87
+ return None
88
+ return returncode != 0
89
+
90
+
91
+ def get_repository_info(path: Union[str, os.PathLike]) -> Dict[str, Union[str, bool]]:
92
+ """Get git repository information for a path
93
+
94
+ Returns:
95
+ dict: Dictionary containing:
96
+ repo_url: Repository URL (converted to HTTPS if from SSH)
97
+ branch_name: Current branch name
98
+ commit_sha: Current commit SHA
99
+ has_uncommitted_changes: Boolean indicating if there are uncommitted changes
100
+ """
101
+ global _git_info_cache
102
+
103
+ if _git_info_cache is not None:
104
+ return _git_info_cache
105
+
106
+ _git_info_cache = {}
107
+ if _is_in_git_repo(path):
108
+ _git_info_cache = {
109
+ "repo_url": _get_repo_url(path),
110
+ "branch_name": _get_branch_name(path),
111
+ "commit_sha": _get_commit_sha(path),
112
+ "has_uncommitted_changes": _has_uncommitted_changes(path),
113
+ }
114
+
115
+ return _git_info_cache
@@ -27,11 +27,11 @@ if name == "nt":
27
27
  """find the path to the git executable on Windows"""
28
28
  # first see if git is in the path
29
29
  try:
30
- check_output(["where", "/Q", "git"])
30
+ subprocess.check_output(["where", "/Q", "git"])
31
31
  # if this command succeeded, git is in the path
32
32
  return "git"
33
33
  # catch the exception thrown if git was not found
34
- except CalledProcessError:
34
+ except subprocess.CalledProcessError:
35
35
  pass
36
36
  # There are several locations where git.exe may be hiding
37
37
  possible_locations = []
@@ -75,6 +75,7 @@ FLOW_DECORATORS_DESC = [
75
75
  ENVIRONMENTS_DESC = [
76
76
  ("conda", ".pypi.conda_environment.CondaEnvironment"),
77
77
  ("pypi", ".pypi.pypi_environment.PyPIEnvironment"),
78
+ ("uv", ".uv.uv_environment.UVEnvironment"),
78
79
  ]
79
80
 
80
81
  # Add metadata providers here
@@ -7,6 +7,7 @@ import sys
7
7
  from collections import defaultdict
8
8
  from hashlib import sha1
9
9
  from math import inf
10
+ from typing import List
10
11
 
11
12
  from metaflow import JSONType, current
12
13
  from metaflow.decorators import flow_decorators
@@ -110,6 +111,7 @@ class ArgoWorkflows(object):
110
111
  notify_pager_duty_integration_key=None,
111
112
  notify_incident_io_api_key=None,
112
113
  incident_io_alert_source_config_id=None,
114
+ incident_io_metadata: List[str] = None,
113
115
  enable_heartbeat_daemon=True,
114
116
  enable_error_msg_capture=False,
115
117
  ):
@@ -161,6 +163,9 @@ class ArgoWorkflows(object):
161
163
  self.notify_pager_duty_integration_key = notify_pager_duty_integration_key
162
164
  self.notify_incident_io_api_key = notify_incident_io_api_key
163
165
  self.incident_io_alert_source_config_id = incident_io_alert_source_config_id
166
+ self.incident_io_metadata = self.parse_incident_io_metadata(
167
+ incident_io_metadata
168
+ )
164
169
  self.enable_heartbeat_daemon = enable_heartbeat_daemon
165
170
  self.enable_error_msg_capture = enable_error_msg_capture
166
171
  self.parameters = self._process_parameters()
@@ -287,6 +292,21 @@ class ArgoWorkflows(object):
287
292
 
288
293
  return True
289
294
 
295
+ @staticmethod
296
+ def parse_incident_io_metadata(metadata: List[str] = None):
297
+ "parse key value pairs into a dict for incident.io metadata if given"
298
+ parsed_metadata = None
299
+ if metadata is not None:
300
+ parsed_metadata = {}
301
+ for kv in metadata:
302
+ key, value = kv.split("=", 1)
303
+ if key in parsed_metadata:
304
+ raise MetaflowException(
305
+ "Incident.io Metadata *%s* provided multiple times" % key
306
+ )
307
+ parsed_metadata[key] = value
308
+ return parsed_metadata
309
+
290
310
  @classmethod
291
311
  def trigger(cls, name, parameters=None):
292
312
  if parameters is None:
@@ -2552,9 +2572,12 @@ class ArgoWorkflows(object):
2552
2572
  else None
2553
2573
  ),
2554
2574
  "metadata": {
2555
- "run_status": "failed",
2556
- "flow_name": self.flow.name,
2557
- "run_id": "argo-{{workflow.name}}",
2575
+ **(self.incident_io_metadata or {}),
2576
+ **{
2577
+ "run_status": "failed",
2578
+ "flow_name": self.flow.name,
2579
+ "run_id": "argo-{{workflow.name}}",
2580
+ },
2558
2581
  },
2559
2582
  }
2560
2583
  )
@@ -2603,9 +2626,12 @@ class ArgoWorkflows(object):
2603
2626
  else None
2604
2627
  ),
2605
2628
  "metadata": {
2606
- "run_status": "succeeded",
2607
- "flow_name": self.flow.name,
2608
- "run_id": "argo-{{workflow.name}}",
2629
+ **(self.incident_io_metadata or {}),
2630
+ **{
2631
+ "run_status": "succeeded",
2632
+ "flow_name": self.flow.name,
2633
+ "run_id": "argo-{{workflow.name}}",
2634
+ },
2609
2635
  },
2610
2636
  }
2611
2637
  )
@@ -187,6 +187,13 @@ def argo_workflows(obj, name=None):
187
187
  default=None,
188
188
  help="Incident.io Alert source config ID. Example '01GW2G3V0S59R238FAHPDS1R66'",
189
189
  )
190
+ @click.option(
191
+ "--incident-io-metadata",
192
+ default=None,
193
+ type=str,
194
+ multiple=True,
195
+ help="Incident.io Alert Custom Metadata field in the form of Key=Value",
196
+ )
190
197
  @click.option(
191
198
  "--enable-heartbeat-daemon/--no-enable-heartbeat-daemon",
192
199
  default=False,
@@ -226,6 +233,7 @@ def create(
226
233
  notify_pager_duty_integration_key=None,
227
234
  notify_incident_io_api_key=None,
228
235
  incident_io_alert_source_config_id=None,
236
+ incident_io_metadata=None,
229
237
  enable_heartbeat_daemon=True,
230
238
  deployer_attribute_file=None,
231
239
  enable_error_msg_capture=False,
@@ -283,6 +291,7 @@ def create(
283
291
  notify_pager_duty_integration_key,
284
292
  notify_incident_io_api_key,
285
293
  incident_io_alert_source_config_id,
294
+ incident_io_metadata,
286
295
  enable_heartbeat_daemon,
287
296
  enable_error_msg_capture,
288
297
  )
@@ -459,6 +468,7 @@ def make_flow(
459
468
  notify_pager_duty_integration_key,
460
469
  notify_incident_io_api_key,
461
470
  incident_io_alert_source_config_id,
471
+ incident_io_metadata,
462
472
  enable_heartbeat_daemon,
463
473
  enable_error_msg_capture,
464
474
  ):
@@ -538,6 +548,7 @@ def make_flow(
538
548
  notify_pager_duty_integration_key=notify_pager_duty_integration_key,
539
549
  notify_incident_io_api_key=notify_incident_io_api_key,
540
550
  incident_io_alert_source_config_id=incident_io_alert_source_config_id,
551
+ incident_io_metadata=incident_io_metadata,
541
552
  enable_heartbeat_daemon=enable_heartbeat_daemon,
542
553
  enable_error_msg_capture=enable_error_msg_capture,
543
554
  )
@@ -14,6 +14,7 @@ class Boto3ClientProvider(object):
14
14
  AWS_SANDBOX_ENABLED,
15
15
  AWS_SANDBOX_STS_ENDPOINT_URL,
16
16
  AWS_SANDBOX_API_KEY,
17
+ S3_CLIENT_RETRY_CONFIG,
17
18
  )
18
19
 
19
20
  if session_vars is None:
@@ -37,10 +38,10 @@ class Boto3ClientProvider(object):
37
38
  if module == "s3" and (
38
39
  "config" not in client_params or client_params["config"].retries is None
39
40
  ):
40
- # Use the adaptive retry strategy by default -- do not set anything if
41
- # the user has already set something
41
+ # do not set anything if the user has already set something
42
42
  config = client_params.get("config", Config())
43
- config.retries = {"max_attempts": 10, "mode": "adaptive"}
43
+ config.retries = S3_CLIENT_RETRY_CONFIG
44
+ client_params["config"] = config
44
45
 
45
46
  if AWS_SANDBOX_ENABLED:
46
47
  # role is ignored in the sandbox
@@ -18,6 +18,7 @@ from metaflow.metaflow_config import (
18
18
  S3_RETRY_COUNT,
19
19
  S3_TRANSIENT_RETRY_COUNT,
20
20
  S3_SERVER_SIDE_ENCRYPTION,
21
+ S3_WORKER_COUNT,
21
22
  TEMPDIR,
22
23
  )
23
24
  from metaflow.util import (
@@ -1390,9 +1391,31 @@ class S3(object):
1390
1391
  )
1391
1392
 
1392
1393
  # add some jitter to make sure retries are not synchronized
1393
- def _jitter_sleep(self, trynum, multiplier=2):
1394
- interval = multiplier**trynum + random.randint(0, 10)
1395
- time.sleep(interval)
1394
+ def _jitter_sleep(
1395
+ self, trynum: int, base: int = 2, cap: int = 360, jitter: float = 0.1
1396
+ ) -> None:
1397
+ """
1398
+ Sleep for an exponentially increasing interval with added jitter.
1399
+
1400
+ Parameters
1401
+ ----------
1402
+ trynum: The current retry attempt number.
1403
+ base: The base multiplier for the exponential backoff.
1404
+ cap: The maximum interval to sleep.
1405
+ jitter: The maximum jitter percentage to add to the interval.
1406
+ """
1407
+ # Calculate the exponential backoff interval
1408
+ interval = min(cap, base**trynum)
1409
+
1410
+ # Add random jitter
1411
+ jitter_value = interval * jitter * random.uniform(-1, 1)
1412
+ interval_with_jitter = interval + jitter_value
1413
+
1414
+ # Ensure the interval is not negative
1415
+ interval_with_jitter = max(0, interval_with_jitter)
1416
+
1417
+ # Sleep for the calculated interval
1418
+ time.sleep(interval_with_jitter)
1396
1419
 
1397
1420
  # NOTE: re: _read_many_files and _put_many_files
1398
1421
  # All file IO is through binary files - we write bytes, we read
@@ -1480,20 +1503,17 @@ class S3(object):
1480
1503
  # - a known transient failure (SlowDown for example) in which case we will
1481
1504
  # retry *only* the inputs that have this transient failure.
1482
1505
  # - an unknown failure (something went wrong but we cannot say if it was
1483
- # a known permanent failure or something else). In this case, we retry
1484
- # the operation completely.
1485
- #
1486
- # There are therefore two retry counts:
1487
- # - the transient failure retry count: how many times do we try on known
1488
- # transient errors
1489
- # - the top-level retry count: how many times do we try on unknown failures
1506
+ # a known permanent failure or something else). In this case, we assume
1507
+ # it's a transient failure and retry only those inputs (same as above).
1490
1508
  #
1491
- # Note that, if the operation runs out of transient failure retries, it will
1492
- # count as an "unknown" failure (ie: it will be retried according to the
1493
- # outer top-level retry count). In other words, you can potentially have
1494
- # transient_retry_count * retry_count tries).
1495
- # Finally, if on transient failures, we make NO progress (ie: no input is
1496
- # successfully processed), that counts as an "unknown" failure.
1509
+ # NOTES(npow): 2025-05-13
1510
+ # Previously, this code would also retry the fatal failures, including no_progress
1511
+ # and unknown failures, from the beginning. This is not ideal because:
1512
+ # 1. Fatal errors are not supposed to be retried.
1513
+ # 2. Retrying from the beginning does not improve the situation, and is
1514
+ # wasteful since we have already uploaded some files.
1515
+ # 3. The number of transient errors is far more than fatal errors, so we
1516
+ # can be optimistic and assume the unknown errors are transient.
1497
1517
  cmdline = [sys.executable, os.path.abspath(s3op.__file__), mode]
1498
1518
  recursive_get = False
1499
1519
  for key, value in options.items():
@@ -1528,7 +1548,6 @@ class S3(object):
1528
1548
  # Otherwise, we cap the failure rate at 90%
1529
1549
  return min(90, self._s3_inject_failures)
1530
1550
 
1531
- retry_count = 0 # Number of retries (excluding transient failures)
1532
1551
  transient_retry_count = 0 # Number of transient retries (per top-level retry)
1533
1552
  inject_failures = _inject_failure_rate()
1534
1553
  out_lines = [] # List to contain the lines returned by _s3op_with_retries
@@ -1595,7 +1614,12 @@ class S3(object):
1595
1614
  # things, this will shrink more and more until we are doing a
1596
1615
  # single operation at a time. If things start going better, it
1597
1616
  # will increase by 20% every round.
1598
- max_count = min(int(last_ok_count * 1.2), len(pending_retries))
1617
+ #
1618
+ # If we made no progress (last_ok_count == 0) we retry at most
1619
+ # 2*S3_WORKER_COUNT from whatever is left in `pending_retries`
1620
+ max_count = min(
1621
+ int(last_ok_count * 1.2), len(pending_retries)
1622
+ ) or min(2 * S3_WORKER_COUNT, len(pending_retries))
1599
1623
  tmp_input.writelines(pending_retries[:max_count])
1600
1624
  tmp_input.flush()
1601
1625
  debug.s3client_exec(
@@ -1712,38 +1736,16 @@ class S3(object):
1712
1736
  _update_out_lines(out_lines, ok_lines, resize=loop_count == 0)
1713
1737
  return 0, 0, inject_failures, err_out
1714
1738
 
1715
- while retry_count <= S3_RETRY_COUNT:
1739
+ while transient_retry_count <= S3_TRANSIENT_RETRY_COUNT:
1716
1740
  (
1717
1741
  last_ok_count,
1718
1742
  last_retry_count,
1719
1743
  inject_failures,
1720
1744
  err_out,
1721
1745
  ) = try_s3_op(last_ok_count, pending_retries, out_lines, inject_failures)
1722
- if err_out or (
1723
- last_retry_count != 0
1724
- and (
1725
- last_ok_count == 0
1726
- or transient_retry_count > S3_TRANSIENT_RETRY_COUNT
1727
- )
1728
- ):
1729
- # We had a fatal failure (err_out is not None)
1730
- # or we made no progress (last_ok_count is 0)
1731
- # or we are out of transient retries
1732
- # so we will restart from scratch (being very conservative)
1733
- retry_count += 1
1734
- err_msg = err_out
1735
- if err_msg is None and last_ok_count == 0:
1736
- err_msg = "No progress"
1737
- if err_msg is None:
1738
- err_msg = "Too many transient errors"
1739
- print(
1740
- "S3 non-transient error (attempt #%d): %s" % (retry_count, err_msg)
1741
- )
1742
- _reset()
1743
- if retry_count <= S3_RETRY_COUNT:
1744
- self._jitter_sleep(retry_count)
1745
- continue
1746
- elif last_retry_count != 0:
1746
+ if err_out:
1747
+ break
1748
+ if last_retry_count != 0:
1747
1749
  # During our last try, we did not manage to process everything we wanted
1748
1750
  # due to a transient failure so we try again.
1749
1751
  transient_retry_count += 1
@@ -15,7 +15,10 @@ from tempfile import NamedTemporaryFile
15
15
  from multiprocessing import Process, Queue
16
16
  from itertools import starmap, chain, islice
17
17
 
18
+ from boto3.exceptions import RetriesExceededError, S3UploadFailedError
18
19
  from boto3.s3.transfer import TransferConfig
20
+ from botocore.config import Config
21
+ from botocore.exceptions import ClientError, SSLError
19
22
 
20
23
  try:
21
24
  # python2
@@ -46,13 +49,21 @@ from metaflow.plugins.datatools.s3.s3util import (
46
49
  import metaflow.tracing as tracing
47
50
  from metaflow.metaflow_config import (
48
51
  S3_WORKER_COUNT,
52
+ S3_CLIENT_RETRY_CONFIG,
49
53
  )
50
54
 
51
55
  DOWNLOAD_FILE_THRESHOLD = 2 * TransferConfig().multipart_threshold
52
56
  DOWNLOAD_MAX_CHUNK = 2 * 1024 * 1024 * 1024 - 1
53
57
 
58
+ DEFAULT_S3_CLIENT_PARAMS = {"config": Config(retries=S3_CLIENT_RETRY_CONFIG)}
54
59
  RANGE_MATCH = re.compile(r"bytes (?P<start>[0-9]+)-(?P<end>[0-9]+)/(?P<total>[0-9]+)")
55
60
 
61
+ # from botocore ClientError MSG_TEMPLATE:
62
+ # https://github.com/boto/botocore/blob/68ca78f3097906c9231840a49931ef4382c41eea/botocore/exceptions.py#L521
63
+ BOTOCORE_MSG_TEMPLATE_MATCH = re.compile(
64
+ r"An error occurred \((\w+)\) when calling the (\w+) operation.*: (.+)"
65
+ )
66
+
56
67
  S3Config = namedtuple("S3Config", "role session_vars client_params")
57
68
 
58
69
 
@@ -147,6 +158,7 @@ def normalize_client_error(err):
147
158
  "LimitExceededException",
148
159
  "RequestThrottled",
149
160
  "EC2ThrottledException",
161
+ "InternalError",
150
162
  ):
151
163
  return 503
152
164
  return error_code
@@ -221,54 +233,57 @@ def worker(result_file_name, queue, mode, s3config):
221
233
  elif mode == "download":
222
234
  tmp = NamedTemporaryFile(dir=".", mode="wb", delete=False)
223
235
  try:
224
- if url.range:
225
- resp = s3.get_object(
226
- Bucket=url.bucket, Key=url.path, Range=url.range
227
- )
228
- range_result = resp["ContentRange"]
229
- range_result_match = RANGE_MATCH.match(range_result)
230
- if range_result_match is None:
231
- raise RuntimeError(
232
- "Wrong format for ContentRange: %s"
233
- % str(range_result)
236
+ try:
237
+ if url.range:
238
+ resp = s3.get_object(
239
+ Bucket=url.bucket, Key=url.path, Range=url.range
234
240
  )
235
- range_result = {
236
- x: int(range_result_match.group(x))
237
- for x in ["total", "start", "end"]
238
- }
239
- else:
240
- resp = s3.get_object(Bucket=url.bucket, Key=url.path)
241
- range_result = None
242
- sz = resp["ContentLength"]
243
- if range_result is None:
244
- range_result = {"total": sz, "start": 0, "end": sz - 1}
245
- if not url.range and sz > DOWNLOAD_FILE_THRESHOLD:
246
- # In this case, it is more efficient to use download_file as it
247
- # will download multiple parts in parallel (it does it after
248
- # multipart_threshold)
249
- s3.download_file(url.bucket, url.path, tmp.name)
250
- else:
251
- read_in_chunks(tmp, resp["Body"], sz, DOWNLOAD_MAX_CHUNK)
252
- tmp.close()
253
- os.rename(tmp.name, url.local)
254
- except client_error as err:
255
- tmp.close()
256
- os.unlink(tmp.name)
257
- error_code = normalize_client_error(err)
258
- if error_code == 404:
259
- result_file.write("%d %d\n" % (idx, -ERROR_URL_NOT_FOUND))
260
- continue
261
- elif error_code == 403:
262
- result_file.write(
263
- "%d %d\n" % (idx, -ERROR_URL_ACCESS_DENIED)
264
- )
241
+ range_result = resp["ContentRange"]
242
+ range_result_match = RANGE_MATCH.match(range_result)
243
+ if range_result_match is None:
244
+ raise RuntimeError(
245
+ "Wrong format for ContentRange: %s"
246
+ % str(range_result)
247
+ )
248
+ range_result = {
249
+ x: int(range_result_match.group(x))
250
+ for x in ["total", "start", "end"]
251
+ }
252
+ else:
253
+ resp = s3.get_object(Bucket=url.bucket, Key=url.path)
254
+ range_result = None
255
+ sz = resp["ContentLength"]
256
+ if range_result is None:
257
+ range_result = {"total": sz, "start": 0, "end": sz - 1}
258
+ if not url.range and sz > DOWNLOAD_FILE_THRESHOLD:
259
+ # In this case, it is more efficient to use download_file as it
260
+ # will download multiple parts in parallel (it does it after
261
+ # multipart_threshold)
262
+ s3.download_file(url.bucket, url.path, tmp.name)
263
+ else:
264
+ read_in_chunks(
265
+ tmp, resp["Body"], sz, DOWNLOAD_MAX_CHUNK
266
+ )
267
+ tmp.close()
268
+ os.rename(tmp.name, url.local)
269
+ except client_error as err:
270
+ tmp.close()
271
+ os.unlink(tmp.name)
272
+ handle_client_error(err, idx, result_file)
265
273
  continue
266
- elif error_code == 503:
267
- result_file.write("%d %d\n" % (idx, -ERROR_TRANSIENT))
274
+ except RetriesExceededError as e:
275
+ tmp.close()
276
+ os.unlink(tmp.name)
277
+ err = convert_to_client_error(e)
278
+ handle_client_error(err, idx, result_file)
268
279
  continue
269
- else:
270
- raise
271
- # TODO specific error message for out of disk space
280
+ except (SSLError, Exception) as e:
281
+ tmp.close()
282
+ os.unlink(tmp.name)
283
+ # assume anything else is transient
284
+ result_file.write("%d %d\n" % (idx, -ERROR_TRANSIENT))
285
+ result_file.flush()
286
+ continue
272
287
  # If we need the metadata, get it and write it out
273
288
  if pre_op_info:
274
289
  with open("%s_meta" % url.local, mode="w") as f:
@@ -316,28 +331,67 @@ def worker(result_file_name, queue, mode, s3config):
316
331
  if url.encryption is not None:
317
332
  extra["ServerSideEncryption"] = url.encryption
318
333
  try:
319
- s3.upload_file(
320
- url.local, url.bucket, url.path, ExtraArgs=extra
321
- )
322
- # We indicate that the file was uploaded
323
- result_file.write("%d %d\n" % (idx, 0))
324
- except client_error as err:
325
- error_code = normalize_client_error(err)
326
- if error_code == 403:
327
- result_file.write(
328
- "%d %d\n" % (idx, -ERROR_URL_ACCESS_DENIED)
334
+ try:
335
+ s3.upload_file(
336
+ url.local, url.bucket, url.path, ExtraArgs=extra
329
337
  )
338
+ # We indicate that the file was uploaded
339
+ result_file.write("%d %d\n" % (idx, 0))
340
+ except client_error as err:
341
+ # Shouldn't get here, but just in case.
342
+ # Internally, botocore catches ClientError and returns a S3UploadFailedError.
343
+ # See https://github.com/boto/boto3/blob/develop/boto3/s3/transfer.py#L377
344
+ handle_client_error(err, idx, result_file)
330
345
  continue
331
- elif error_code == 503:
332
- result_file.write("%d %d\n" % (idx, -ERROR_TRANSIENT))
346
+ except S3UploadFailedError as e:
347
+ err = convert_to_client_error(e)
348
+ handle_client_error(err, idx, result_file)
333
349
  continue
334
- else:
335
- raise
350
+ except (SSLError, Exception) as e:
351
+ # assume anything else is transient
352
+ result_file.write("%d %d\n" % (idx, -ERROR_TRANSIENT))
353
+ result_file.flush()
354
+ continue
336
355
  except:
337
356
  traceback.print_exc()
357
+ result_file.flush()
338
358
  sys.exit(ERROR_WORKER_EXCEPTION)
339
359
 
340
360
 
361
+ def convert_to_client_error(e):
362
+ match = BOTOCORE_MSG_TEMPLATE_MATCH.search(str(e))
363
+ if not match:
364
+ raise e
365
+ error_code = match.group(1)
366
+ operation_name = match.group(2)
367
+ error_message = match.group(3)
368
+ response = {
369
+ "Error": {
370
+ "Code": error_code,
371
+ "Message": error_message,
372
+ }
373
+ }
374
+ return ClientError(response, operation_name)
375
+
376
+
377
+ def handle_client_error(err, idx, result_file):
378
+ error_code = normalize_client_error(err)
379
+ if error_code == 404:
380
+ result_file.write("%d %d\n" % (idx, -ERROR_URL_NOT_FOUND))
381
+ result_file.flush()
382
+ elif error_code == 403:
383
+ result_file.write("%d %d\n" % (idx, -ERROR_URL_ACCESS_DENIED))
384
+ result_file.flush()
385
+ elif error_code == 503:
386
+ result_file.write("%d %d\n" % (idx, -ERROR_TRANSIENT))
387
+ result_file.flush()
388
+ else:
389
+ # optimistically assume it is a transient error
390
+ result_file.write("%d %d\n" % (idx, -ERROR_TRANSIENT))
391
+ result_file.flush()
392
+ # TODO specific error message for out of disk space
393
+
394
+
341
395
  def start_workers(mode, urls, num_workers, inject_failure, s3config):
342
396
  # We start the minimum of len(urls) or num_workers to avoid starting
343
397
  # workers that will definitely do nothing
@@ -381,6 +435,22 @@ def start_workers(mode, urls, num_workers, inject_failure, s3config):
381
435
  if proc.exitcode is not None:
382
436
  if proc.exitcode != 0:
383
437
  msg = "Worker process failed (exit code %d)" % proc.exitcode
438
+
439
+ # IMPORTANT: if this process has put items on a queue, then it will not terminate
440
+ # until all buffered items have been flushed to the pipe, causing a deadlock.
441
+ # `cancel_join_thread()` allows it to exit without flushing the queue.
442
+ # Without this line, the parent process would hang indefinitely when a subprocess
443
+ # did not exit cleanly in the case of unhandled exceptions.
444
+ #
445
+ # The error situation is:
446
+ # 1. this process puts stuff in queue
447
+ # 2. subprocess dies so doesn't consume its end-of-queue marker (the None)
448
+ # 3. other subprocesses consume all useful bits AND their end-of-queue marker
449
+ # 4. one marker is left and not consumed
450
+ # 5. this process cannot shut down until the queue is empty.
451
+ # 6. it will never be empty because all subprocesses (workers) have died.
452
+ queue.cancel_join_thread()
453
+
384
454
  exit(msg, proc.exitcode)
385
455
  # Read the output file if all went well
386
456
  with open(out_path, "r") as out_file:
@@ -745,7 +815,7 @@ def lst(
745
815
  s3config = S3Config(
746
816
  s3role,
747
817
  json.loads(s3sessionvars) if s3sessionvars else None,
748
- json.loads(s3clientparams) if s3clientparams else None,
818
+ json.loads(s3clientparams) if s3clientparams else DEFAULT_S3_CLIENT_PARAMS,
749
819
  )
750
820
 
751
821
  urllist = []
@@ -878,7 +948,7 @@ def put(
878
948
  s3config = S3Config(
879
949
  s3role,
880
950
  json.loads(s3sessionvars) if s3sessionvars else None,
881
- json.loads(s3clientparams) if s3clientparams else None,
951
+ json.loads(s3clientparams) if s3clientparams else DEFAULT_S3_CLIENT_PARAMS,
882
952
  )
883
953
 
884
954
  urls = list(starmap(_make_url, _files()))
@@ -1025,7 +1095,7 @@ def get(
1025
1095
  s3config = S3Config(
1026
1096
  s3role,
1027
1097
  json.loads(s3sessionvars) if s3sessionvars else None,
1028
- json.loads(s3clientparams) if s3clientparams else None,
1098
+ json.loads(s3clientparams) if s3clientparams else DEFAULT_S3_CLIENT_PARAMS,
1029
1099
  )
1030
1100
 
1031
1101
  # Construct a list of URL (prefix) objects
@@ -1172,7 +1242,7 @@ def info(
1172
1242
  s3config = S3Config(
1173
1243
  s3role,
1174
1244
  json.loads(s3sessionvars) if s3sessionvars else None,
1175
- json.loads(s3clientparams) if s3clientparams else None,
1245
+ json.loads(s3clientparams) if s3clientparams else DEFAULT_S3_CLIENT_PARAMS,
1176
1246
  )
1177
1247
 
1178
1248
  # Construct a list of URL (prefix) objects
File without changes
@@ -0,0 +1,100 @@
1
+ import os
2
+ import subprocess
3
+ import sys
4
+ import time
5
+
6
+ from metaflow.util import which
7
+ from metaflow.metaflow_config import get_pinned_conda_libs
8
+ from urllib.request import Request, urlopen
9
+ from urllib.error import URLError
10
+
11
+ # TODO: support version/platform/architecture selection.
12
+ UV_URL = "https://github.com/astral-sh/uv/releases/download/0.6.11/uv-x86_64-unknown-linux-gnu.tar.gz"
13
+
14
+ if __name__ == "__main__":
15
+
16
+ def run_cmd(cmd, stdin_str=None):
17
+ result = subprocess.run(
18
+ cmd,
19
+ shell=True,
20
+ input=stdin_str,
21
+ stdout=subprocess.PIPE,
22
+ stderr=subprocess.PIPE,
23
+ text=True,
24
+ )
25
+ if result.returncode != 0:
26
+ print(f"Bootstrap failed while executing: {cmd}")
27
+ print("Stdout:", result.stdout)
28
+ print("Stderr:", result.stderr)
29
+ sys.exit(1)
30
+
31
+ def install_uv():
32
+ import tarfile
33
+
34
+ uv_install_path = os.path.join(os.getcwd(), "uv_install")
35
+ if which("uv"):
36
+ return
37
+
38
+ print("Installing uv...")
39
+
40
+ # Prepare directory once
41
+ os.makedirs(uv_install_path, exist_ok=True)
42
+
43
+ # Download and decompress in one go
44
+ headers = {
45
+ "Accept-Encoding": "gzip, deflate, br",
46
+ "Connection": "keep-alive",
47
+ "User-Agent": "python-urllib",
48
+ }
49
+
50
+ def _tar_filter(member: tarfile.TarInfo, path):
51
+ if os.path.basename(member.name) != "uv":
52
+ return None # skip
53
+ member.path = os.path.basename(member.path)
54
+ return member
55
+
56
+ max_retries = 3
57
+ for attempt in range(max_retries):
58
+ try:
59
+ req = Request(UV_URL, headers=headers)
60
+ with urlopen(req) as response:
61
+ with tarfile.open(fileobj=response, mode="r:gz") as tar:
62
+ tar.extractall(uv_install_path, filter=_tar_filter)
63
+ break
64
+ except (URLError, IOError) as e:
65
+ if attempt == max_retries - 1:
66
+ raise Exception(
67
+ f"Failed to download UV after {max_retries} attempts: {e}"
68
+ )
69
+ time.sleep(2**attempt)
70
+
71
+ # Update PATH only once at the end
72
+ os.environ["PATH"] += os.pathsep + uv_install_path
73
+
74
+ def get_dependencies(datastore_type):
75
+ # return required dependencies for Metaflow that must be added to the UV environment.
76
+ pinned = get_pinned_conda_libs(None, datastore_type)
77
+
78
+ # return only dependency names instead of pinned versions
79
+ return pinned.keys()
80
+
81
+ def sync_uv_project(datastore_type):
82
+ print("Syncing uv project...")
83
+ dependencies = " ".join(get_dependencies(datastore_type))
84
+ cmd = f"""set -e;
85
+ uv sync --frozen --no-install-package metaflow;
86
+ uv pip install {dependencies} --strict
87
+ """
88
+ run_cmd(cmd)
89
+
90
+ if len(sys.argv) != 2:
91
+ print("Usage: bootstrap.py <datastore_type>")
92
+ sys.exit(1)
93
+
94
+ try:
95
+ datastore_type = sys.argv[1]
96
+ install_uv()
97
+ sync_uv_project(datastore_type)
98
+ except Exception as e:
99
+ print(f"Error: {str(e)}", file=sys.stderr)
100
+ sys.exit(1)
@@ -0,0 +1,70 @@
1
+ import os
2
+
3
+ from metaflow.exception import MetaflowException
4
+ from metaflow.metaflow_environment import MetaflowEnvironment
5
+
6
+
7
+ class UVException(MetaflowException):
8
+ headline = "uv error"
9
+
10
+
11
+ class UVEnvironment(MetaflowEnvironment):
12
+ TYPE = "uv"
13
+
14
+ def __init__(self, flow):
15
+ self.flow = flow
16
+
17
+ def validate_environment(self, logger, datastore_type):
18
+ self.datastore_type = datastore_type
19
+ self.logger = logger
20
+
21
+ def init_environment(self, echo, only_steps=None):
22
+ self.logger("Bootstrapping uv...")
23
+
24
+ def executable(self, step_name, default=None):
25
+ return "uv run python"
26
+
27
+ def add_to_package(self):
28
+ # NOTE: We treat uv.lock and pyproject.toml as regular project assets and ship these along user code as part of the code package
29
+ # These are the minimal required files to reproduce the UV environment on the remote platform.
30
+ def _find(filename):
31
+ current_dir = os.getcwd()
32
+ while True:
33
+ file_path = os.path.join(current_dir, filename)
34
+ if os.path.isfile(file_path):
35
+ return file_path
36
+ parent_dir = os.path.dirname(current_dir)
37
+ if parent_dir == current_dir: # Reached root
38
+ raise UVException(
39
+ f"Could not find {filename} in current directory or any parent directory"
40
+ )
41
+ current_dir = parent_dir
42
+
43
+ pyproject_path = _find("pyproject.toml")
44
+ uv_lock_path = _find("uv.lock")
45
+ files = [
46
+ (uv_lock_path, "uv.lock"),
47
+ (pyproject_path, "pyproject.toml"),
48
+ ]
49
+ return files
50
+
51
+ def pylint_config(self):
52
+ config = super().pylint_config()
53
+ # Disable (import-error) in pylint
54
+ config.append("--disable=F0401")
55
+ return config
56
+
57
+ def bootstrap_commands(self, step_name, datastore_type):
58
+ return [
59
+ "echo 'Bootstrapping uv project...'",
60
+ "flush_mflogs",
61
+ # We have to prevent the tracing module from loading, as the bootstrapping process
62
+ # uses the internal S3 client which would fail to import tracing due to the required
63
+ # dependencies being bundled into the conda environment, which is yet to be
64
+ # initialized at this point.
65
+ 'DISABLE_TRACING=True python -m metaflow.plugins.uv.bootstrap "%s"'
66
+ % datastore_type,
67
+ "echo 'uv project bootstrapped.'",
68
+ "flush_mflogs",
69
+ "export PATH=$PATH:$(pwd)/uv_install",
70
+ ]
metaflow/version.py CHANGED
@@ -1 +1 @@
1
- metaflow_version = "2.15.7"
1
+ metaflow_version = "2.15.8"
@@ -260,6 +260,7 @@ shell: setup-tilt
260
260
  env METAFLOW_HOME="$(DEVTOOLS_DIR)" \
261
261
  METAFLOW_PROFILE=local \
262
262
  AWS_CONFIG_FILE="$(DEVTOOLS_DIR)/aws_config" \
263
+ AWS_SHARED_CREDENTIALS_FILE= \
263
264
  "$$user_shell" -i; \
264
265
  else \
265
266
  env METAFLOW_HOME="$(DEVTOOLS_DIR)" \
@@ -301,6 +302,7 @@ create-dev-shell: setup-tilt
301
302
  echo " env METAFLOW_HOME=\"$(DEVTOOLS_DIR)\" \\" >> $$SHELL_PATH && \
302
303
  echo " METAFLOW_PROFILE=local \\" >> $$SHELL_PATH && \
303
304
  echo " AWS_CONFIG_FILE=\"$(DEVTOOLS_DIR)/aws_config\" \\" >> $$SHELL_PATH && \
305
+ echo " AWS_SHARED_CREDENTIALS_FILE= \\" >> $$SHELL_PATH && \
304
306
  echo " \"\$$user_shell\" -i" >> $$SHELL_PATH && \
305
307
  echo "else" >> $$SHELL_PATH && \
306
308
  echo " env METAFLOW_HOME=\"$(DEVTOOLS_DIR)\" \\" >> $$SHELL_PATH && \
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: metaflow
3
- Version: 2.15.7
3
+ Version: 2.15.8
4
4
  Summary: Metaflow: More AI and ML, Less Engineering
5
5
  Author: Metaflow Developers
6
6
  Author-email: help@metaflow.org
@@ -26,7 +26,7 @@ License-File: LICENSE
26
26
  Requires-Dist: requests
27
27
  Requires-Dist: boto3
28
28
  Provides-Extra: stubs
29
- Requires-Dist: metaflow-stubs==2.15.7; extra == "stubs"
29
+ Requires-Dist: metaflow-stubs==2.15.8; extra == "stubs"
30
30
  Dynamic: author
31
31
  Dynamic: author-email
32
32
  Dynamic: classifier
@@ -1,7 +1,7 @@
1
1
  metaflow/R.py,sha256=CqVfIatvmjciuICNnoyyNGrwE7Va9iXfLdFbQa52hwA,3958
2
2
  metaflow/__init__.py,sha256=Cg9wItncyCn8jqALP5rIJ1RcpTEPp--allUctGKMTtI,5937
3
3
  metaflow/cards.py,sha256=IbRmredvmFEU0V6JL7DR8wCESwVmmZJubr6x24bo7U4,442
4
- metaflow/cli.py,sha256=RU-yXpT-Lfl3xGyFNtL742e9KEqcRxEnQ-4mwXrXhvo,20928
4
+ metaflow/cli.py,sha256=XqGP8mNObMO0lOJmWtQvFFWUEKWMN_3-cGkvci2n6e4,21296
5
5
  metaflow/cli_args.py,sha256=hDsdWdRmfXYifVGq6b6FDfgoWxtIG2nr_lU6EBV0Pnk,3584
6
6
  metaflow/clone_util.py,sha256=LSuVbFpPUh92UW32DBcnZbL0FFw-4w3CLa0tpEbCkzk,2066
7
7
  metaflow/cmd_with_io.py,sha256=kl53HkAIyv0ecpItv08wZYczv7u3msD1VCcciqigqf0,588
@@ -16,12 +16,13 @@ metaflow/includefile.py,sha256=kWKDSlzVcRVNGG9PV5eB3o2ynrzqhVsfaLtkqjshn7Q,20948
16
16
  metaflow/info_file.py,sha256=wtf2_F0M6dgiUu74AFImM8lfy5RrUw5Yj7Rgs2swKRY,686
17
17
  metaflow/integrations.py,sha256=LlsaoePRg03DjENnmLxZDYto3NwWc9z_PtU6nJxLldg,1480
18
18
  metaflow/lint.py,sha256=x4p6tnRzYqNNniCGXyrUW0WuYfTUgnaOMRivxvnxask,11661
19
- metaflow/metaflow_config.py,sha256=oLbF4ZOfdejRBbemL_9NmFo2G2iAdTuUgbd7vNxV2lg,23567
19
+ metaflow/metaflow_config.py,sha256=IXTmqV2Ny1SuW18v7t6_DYzL8eWqLG3dkm1ohf8rPCM,23770
20
20
  metaflow/metaflow_config_funcs.py,sha256=5GlvoafV6SxykwfL8D12WXSfwjBN_NsyuKE_Q3gjGVE,6738
21
21
  metaflow/metaflow_current.py,sha256=pfkXmkyHeMJhxIs6HBJNBEaBDpcl5kz9Wx5mW6F_3qo,7164
22
- metaflow/metaflow_environment.py,sha256=e5BOkA7VdpjseI4HUkm_pR74NVJRNADL20LIQL4W1vU,8139
22
+ metaflow/metaflow_environment.py,sha256=CWG90qpfz9iJ6hHhFlAmMVNALn2v_5eTVk3mFbQR4Pw,8379
23
+ metaflow/metaflow_git.py,sha256=Pb_VtvQzcjpuuM7UfC2u5kz85EbPMUfspl2UrPWBQMM,3647
23
24
  metaflow/metaflow_profile.py,sha256=jKPEW-hmAQO-htSxb9hXaeloLacAh41A35rMZH6G8pA,418
24
- metaflow/metaflow_version.py,sha256=duhIzfKZtcxMVMs2uiBqBvUarSHJqyWDwMhaBOQd_g0,7491
25
+ metaflow/metaflow_version.py,sha256=QNN-8z1JX-Gby0dtXqjogQmsvbE8KDRMNHzQEdjI6qw,7513
25
26
  metaflow/monitor.py,sha256=T0NMaBPvXynlJAO_avKtk8OIIRMyEuMAyF8bIp79aZU,5323
26
27
  metaflow/multicore_utils.py,sha256=yEo5T6Gemn4_vl8b6IOz7fsTUYtEyqa3AaKZgJY96Wc,4974
27
28
  metaflow/package.py,sha256=yfwVMVB1mD-Sw94KwXNK3N-26YHoKMn6btrcgd67Izs,7845
@@ -36,7 +37,7 @@ metaflow/tuple_util.py,sha256=_G5YIEhuugwJ_f6rrZoelMFak3DqAR2tt_5CapS1XTY,830
36
37
  metaflow/unbounded_foreach.py,sha256=p184WMbrMJ3xKYHwewj27ZhRUsSj_kw1jlye5gA9xJk,387
37
38
  metaflow/util.py,sha256=mJBkV5tShIyCsLDeM1zygQGeciQVMrVPm_qI8Oi33G0,14656
38
39
  metaflow/vendor.py,sha256=LZgXrh7ZSDmD32D1T5jj3OKKpXIqqxKzdMAOc5V0SD4,5162
39
- metaflow/version.py,sha256=mmpM7KoxX80ooct0ho2OKpy5xnA-HgJXYdJElu0ET2M,28
40
+ metaflow/version.py,sha256=8XQw9A4nDWkRa2GDc805yvkVhCHDDAZj-jh2ZCyveec,28
40
41
  metaflow/_vendor/__init__.py,sha256=y_CiwUD3l4eAKvTVDZeqgVujMy31cAM1qjAB-HfI-9s,353
41
42
  metaflow/_vendor/typing_extensions.py,sha256=q9zxWa6p6CzF1zZvSkygSlklduHf_b3K7MCxGz7MJRc,134519
42
43
  metaflow/_vendor/zipp.py,sha256=ajztOH-9I7KA_4wqDYygtHa6xUBVZgFpmZ8FE74HHHI,8425
@@ -139,7 +140,7 @@ metaflow/_vendor/v3_7/typeguard/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NM
139
140
  metaflow/cli_components/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
140
141
  metaflow/cli_components/dump_cmd.py,sha256=SZEX51BWNd1o3H2uHDkYA8KRvou5X8g5rTwpdu5vnNQ,2704
141
142
  metaflow/cli_components/init_cmd.py,sha256=Er-BO59UEUV1HIsg81bRtZWT2D2IZNMp93l-AoZLCls,1519
142
- metaflow/cli_components/run_cmds.py,sha256=gAJeiuFt5y1IcVclwH6TV9p-JCy6-rDtEKUj2ioMO0s,11304
143
+ metaflow/cli_components/run_cmds.py,sha256=F12_xYqVdkL2doP2ADI3cA41Izce5_zr1khJCLl77YA,11304
143
144
  metaflow/cli_components/step_cmd.py,sha256=zGJgTv7wxrv34nWDi__CHaC2eS6kItR95EdVGJX803w,4766
144
145
  metaflow/cli_components/utils.py,sha256=gpoDociadjnJD7MuiJup_MDR02ZJjjleejr0jPBu29c,6057
145
146
  metaflow/client/__init__.py,sha256=1GtQB4Y_CBkzaxg32L1syNQSlfj762wmLrfrDxGi1b8,226
@@ -147,7 +148,7 @@ metaflow/client/core.py,sha256=Cca6HbK-UBO72aELfFJxsl85ylYHHlCAd-uJP-lEepQ,83689
147
148
  metaflow/client/filecache.py,sha256=Wy0yhhCqC1JZgebqi7z52GCwXYnkAqMZHTtxThvwBgM,15229
148
149
  metaflow/cmd/__init__.py,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
149
150
  metaflow/cmd/configure_cmd.py,sha256=o-DKnUf2FBo_HiMVyoyzQaGBSMtpbEPEdFTQZ0hkU-k,33396
150
- metaflow/cmd/main_cli.py,sha256=LSehmMjkWojAN1XTtqW6S51ZpGNAdW4_VK5S7qH8-Ts,2982
151
+ metaflow/cmd/main_cli.py,sha256=iKptsPcys0OKileZdE8Pp0_y8t7pm94SL3b870Qhk30,2972
151
152
  metaflow/cmd/make_wrapper.py,sha256=N8L4u8QZAryH0sAjRsdEqG-gTj2S4LUsfDizOemrTR0,1604
152
153
  metaflow/cmd/tutorials_cmd.py,sha256=8FdlKkicTOhCIDKcBR5b0Oz6giDvS-EMY3o9skIrRqw,5156
153
154
  metaflow/cmd/util.py,sha256=jS_0rUjOnGGzPT65fzRLdGjrYAOOLA4jU2S0HJLV0oc,406
@@ -170,14 +171,14 @@ metaflow/extension_support/integrations.py,sha256=AWAh-AZ-vo9IxuAVEjGw3s8p_NMm2D
170
171
  metaflow/extension_support/plugins.py,sha256=gl7NbIJLJyLTb5LELsj1D9paQip6t6Lqz6Rhmvqvyrw,11286
171
172
  metaflow/metadata_provider/__init__.py,sha256=FZNSnz26VB_m18DQG8mup6-Gfl7r1U6lRMljJBp3VAM,64
172
173
  metaflow/metadata_provider/heartbeat.py,sha256=42mQo6wOHdFuaCh426uV6Kn8swe7e5I3gqA_G7cI_LA,3127
173
- metaflow/metadata_provider/metadata.py,sha256=meO4Fhxu7tbMUGwasYb9_AtL06fwrrXKKjIK7KRWZDs,27093
174
+ metaflow/metadata_provider/metadata.py,sha256=SZIOUKqMilar-Mrj_vMvLgLeEH7kOUChsK_z51iis44,28182
174
175
  metaflow/metadata_provider/util.py,sha256=lYoQKbqoTM1iZChgyVWN-gX-HyM9tt9bXEMJexY9XmM,1723
175
176
  metaflow/mflog/__init__.py,sha256=TkR9ny_JYvNCWJTdLiHsbLSLc9cUvzAzpDuHLdG8nkA,6020
176
177
  metaflow/mflog/mflog.py,sha256=VebXxqitOtNAs7VJixnNfziO_i_urG7bsJ5JiB5IXgY,4370
177
178
  metaflow/mflog/save_logs.py,sha256=4p1OwozsHJBslOzAf0wUq2XPMNpEOZWM68MgWzh_jJY,2330
178
179
  metaflow/mflog/save_logs_periodically.py,sha256=2Uvk9hi-zlCqXxOQoXmmjH1SCugfw6eG6w70WgfI-ho,1256
179
180
  metaflow/mflog/tee.py,sha256=wTER15qeHuiRpCkOqo-bd-r3Gj-EVlf3IvWRCA4beW4,887
180
- metaflow/plugins/__init__.py,sha256=siqE9Zj_b9zKgMhll3f5L2m1gcAKxp_e4qMRTGJ65xY,8460
181
+ metaflow/plugins/__init__.py,sha256=td6LLqHTvumEJMAZc8VxE2McBtiRokryphoG4WtvAzU,8508
181
182
  metaflow/plugins/catch_decorator.py,sha256=UOM2taN_OL2RPpuJhwEOA9ZALm0-hHD0XS2Hn2GUev0,4061
182
183
  metaflow/plugins/debug_logger.py,sha256=mcF5HYzJ0NQmqCMjyVUk3iAP-heroHRIiVWQC6Ha2-I,879
183
184
  metaflow/plugins/debug_monitor.py,sha256=Md5X_sDOSssN9pt2D8YcaIjTK5JaQD55UAYTcF6xYF0,1099
@@ -209,8 +210,8 @@ metaflow/plugins/airflow/sensors/s3_sensor.py,sha256=iDReG-7FKnumrtQg-HY6cCUAAqN
209
210
  metaflow/plugins/argo/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
210
211
  metaflow/plugins/argo/argo_client.py,sha256=A1kI9rjVjCadDsBscZ2Wk8xRBI6GNgWV6SU7TyrdfrI,16530
211
212
  metaflow/plugins/argo/argo_events.py,sha256=_C1KWztVqgi3zuH57pInaE9OzABc2NnncC-zdwOMZ-w,5909
212
- metaflow/plugins/argo/argo_workflows.py,sha256=JEgXzfL_4MMTZsxtO6ydMgza_DI-LirDaprS_LTASOY,185013
213
- metaflow/plugins/argo/argo_workflows_cli.py,sha256=27eLtcp5N5plapP-uIJqR41B0zDfXOV39AGM0nchymo,37952
213
+ metaflow/plugins/argo/argo_workflows.py,sha256=nF42vquNFcWBv4_XdCC8l5a1agFxGgVFZYkatOYzOQA,186081
214
+ metaflow/plugins/argo/argo_workflows_cli.py,sha256=Ka-ea4x19E6DrYgGm5ZwormxEbTGdun8fyHl-mh0tfc,38265
214
215
  metaflow/plugins/argo/argo_workflows_decorator.py,sha256=ogCSBmwsC2C3eusydrgjuAJd4qK18f1sI4jJwA4Fd-o,7800
215
216
  metaflow/plugins/argo/argo_workflows_deployer.py,sha256=6kHxEnYXJwzNCM9swI8-0AckxtPWqwhZLerYkX8fxUM,4444
216
217
  metaflow/plugins/argo/argo_workflows_deployer_objects.py,sha256=lRRHUcpiyJZFltthxZoIp7aJWwy7pcdhaRm0etKN9es,14182
@@ -218,7 +219,7 @@ metaflow/plugins/argo/capture_error.py,sha256=Ys9dscGrTpW-ZCirLBU0gD9qBM0BjxyxGl
218
219
  metaflow/plugins/argo/generate_input_paths.py,sha256=loYsI6RFX9LlFsHb7Fe-mzlTTtRdySoOu7sYDy-uXK0,881
219
220
  metaflow/plugins/argo/jobset_input_paths.py,sha256=-h0E_e0w6FMiBUod9Rf_XOSCtZv_C0exacw4q1SfIfg,501
220
221
  metaflow/plugins/aws/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
221
- metaflow/plugins/aws/aws_client.py,sha256=mO8UD6pxFaOnxDb3hTP3HB7Gqb_ZxoR-76LT683WHvI,4036
222
+ metaflow/plugins/aws/aws_client.py,sha256=lMIGE06LNeOfqbEXVhXKU8kx3gTFwu14Si5n4y1W9KM,4039
222
223
  metaflow/plugins/aws/aws_utils.py,sha256=kNd61C54Y3WxrL7KSjoKydRjBQ1p3exc9QXux-jZyDE,7510
223
224
  metaflow/plugins/aws/batch/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
224
225
  metaflow/plugins/aws/batch/batch.py,sha256=e9ssahWM18GnipPK2sqYB-ztx9w7Eoo7YtWyEtufYxs,17787
@@ -281,8 +282,8 @@ metaflow/plugins/datastores/s3_storage.py,sha256=CZdNqaKtxDXQbEg2YHyphph3hWcLIE5
281
282
  metaflow/plugins/datatools/__init__.py,sha256=ge4L16OBQLy2J_MMvoHg3lMfdm-MluQgRWoyZ5GCRnk,1267
282
283
  metaflow/plugins/datatools/local.py,sha256=FJvMOBcjdyhSPHmdLocBSiIT0rmKkKBmsaclxH75x08,4233
283
284
  metaflow/plugins/datatools/s3/__init__.py,sha256=14tr9fPjN3ULW5IOfKHeG7Uhjmgm7LMtQHfz1SFv-h8,248
284
- metaflow/plugins/datatools/s3/s3.py,sha256=dSUEf3v_BVyvYXlehWy04xcBtNDhKKR5Gnn7oXwagaw,67037
285
- metaflow/plugins/datatools/s3/s3op.py,sha256=XR5E2likcFx7OLk33Kh4FCJVl_eSWrPdO_2hD9F9uoc,43410
285
+ metaflow/plugins/datatools/s3/s3.py,sha256=ThISU4XjXhDqAtfgH_PgtNp0OxF_bhzCgNozYIeMa7g,66993
286
+ metaflow/plugins/datatools/s3/s3op.py,sha256=I7XkDJvVvvLt8SmXNSZjSNONIa2m3QlT5-ZL8g6erno,46936
286
287
  metaflow/plugins/datatools/s3/s3tail.py,sha256=boQjQGQMI-bvTqcMP2y7uSlSYLcvWOy7J3ZUaF78NAA,2597
287
288
  metaflow/plugins/datatools/s3/s3util.py,sha256=FgRgaVmEq7-i2dV7q8XK5w5PfFt-xJjZa8WrK8IJfdI,3769
288
289
  metaflow/plugins/env_escape/__init__.py,sha256=tGNUZnmPvk52eNs__VK443b3CZ7ogEFTT-s9_n_HF8Q,8837
@@ -340,6 +341,9 @@ metaflow/plugins/pypi/utils.py,sha256=W8OhDrhz7YIE-2MSSN5Rj7AmESwsN5YDmdnro152J4
340
341
  metaflow/plugins/secrets/__init__.py,sha256=mhJaN2eMS_ZZVewAMR2E-JdP5i0t3v9e6Dcwd-WpruE,310
341
342
  metaflow/plugins/secrets/inline_secrets_provider.py,sha256=EChmoBGA1i7qM3jtYwPpLZDBybXLergiDlN63E0u3x8,294
342
343
  metaflow/plugins/secrets/secrets_decorator.py,sha256=s-sFzPWOjahhpr5fMj-ZEaHkDYAPTO0isYXGvaUwlG8,11273
344
+ metaflow/plugins/uv/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
345
+ metaflow/plugins/uv/bootstrap.py,sha256=Yp_G3dHhXX5GwmpAASdZr28fDu69pucakST954Vmvp4,3243
346
+ metaflow/plugins/uv/uv_environment.py,sha256=yKoC4t_TrVoN2tH6pV_XOpH3QvObmkRurM_I85Fa-x8,2577
343
347
  metaflow/runner/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
344
348
  metaflow/runner/click_api.py,sha256=nAT1mSV40YKiHTyTr4RDZBCGgGFV6K0TXCTL4xtUOAc,23492
345
349
  metaflow/runner/deployer.py,sha256=6ixXonNyLB1dfTNl1HVGVT5M8CybXDUa3oFTabn9Sp8,9099
@@ -389,12 +393,12 @@ metaflow/user_configs/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3h
389
393
  metaflow/user_configs/config_decorators.py,sha256=qCKVAvd0NKgaCxQ2OThes5-DYHXq6A1HqURubYNeFdw,20481
390
394
  metaflow/user_configs/config_options.py,sha256=m6jccSpzI4qUJ7vyYkYBIf8G3V0Caunxg_k7zg4Zlqg,21067
391
395
  metaflow/user_configs/config_parameters.py,sha256=oeJGVKu1ao_YQX6Lg6P2FEv5k5-_F4sARLlVpTW9ezM,15502
392
- metaflow-2.15.7.data/data/share/metaflow/devtools/Makefile,sha256=kZJDrvY2qRfqkue3mBecZutaGs35zsZR_vNu8WOBcto,13632
393
- metaflow-2.15.7.data/data/share/metaflow/devtools/Tiltfile,sha256=P5_rn_F3xYLN1_cEAQ9mNeS22HG2rb8beKIz2RIK6fU,20634
394
- metaflow-2.15.7.data/data/share/metaflow/devtools/pick_services.sh,sha256=DCnrMXwtApfx3B4S-YiZESMyAFHbXa3VuNL0MxPLyiE,2196
395
- metaflow-2.15.7.dist-info/licenses/LICENSE,sha256=nl_Lt5v9VvJ-5lWJDT4ddKAG-VZ-2IaLmbzpgYDz2hU,11343
396
- metaflow-2.15.7.dist-info/METADATA,sha256=4s0oOBFv9UjhBzCjGFY3m1XOmzEi_14ELD7rYRHN8_4,6740
397
- metaflow-2.15.7.dist-info/WHEEL,sha256=MAQBAzGbXNI3bUmkDsiV_duv8i-gcdnLzw7cfUFwqhU,109
398
- metaflow-2.15.7.dist-info/entry_points.txt,sha256=RvEq8VFlgGe_FfqGOZi0D7ze1hLD0pAtXeNyGfzc_Yc,103
399
- metaflow-2.15.7.dist-info/top_level.txt,sha256=v1pDHoWaSaKeuc5fKTRSfsXCKSdW1zvNVmvA-i0if3o,9
400
- metaflow-2.15.7.dist-info/RECORD,,
396
+ metaflow-2.15.8.data/data/share/metaflow/devtools/Makefile,sha256=5n89OGIC_kE4wxtEI66VCucN-b-1w5bqvGeZYmeRGz8,13737
397
+ metaflow-2.15.8.data/data/share/metaflow/devtools/Tiltfile,sha256=P5_rn_F3xYLN1_cEAQ9mNeS22HG2rb8beKIz2RIK6fU,20634
398
+ metaflow-2.15.8.data/data/share/metaflow/devtools/pick_services.sh,sha256=DCnrMXwtApfx3B4S-YiZESMyAFHbXa3VuNL0MxPLyiE,2196
399
+ metaflow-2.15.8.dist-info/licenses/LICENSE,sha256=nl_Lt5v9VvJ-5lWJDT4ddKAG-VZ-2IaLmbzpgYDz2hU,11343
400
+ metaflow-2.15.8.dist-info/METADATA,sha256=ySz1B2qdeOjZ2eLXO35Za8JOhmgsxBP747BNFBLbBn8,6740
401
+ metaflow-2.15.8.dist-info/WHEEL,sha256=MAQBAzGbXNI3bUmkDsiV_duv8i-gcdnLzw7cfUFwqhU,109
402
+ metaflow-2.15.8.dist-info/entry_points.txt,sha256=RvEq8VFlgGe_FfqGOZi0D7ze1hLD0pAtXeNyGfzc_Yc,103
403
+ metaflow-2.15.8.dist-info/top_level.txt,sha256=v1pDHoWaSaKeuc5fKTRSfsXCKSdW1zvNVmvA-i0if3o,9
404
+ metaflow-2.15.8.dist-info/RECORD,,