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 +8 -0
- metaflow/cli_components/run_cmds.py +2 -2
- metaflow/cmd/main_cli.py +1 -1
- metaflow/metadata_provider/metadata.py +35 -0
- metaflow/metaflow_config.py +6 -0
- metaflow/metaflow_environment.py +6 -1
- metaflow/metaflow_git.py +115 -0
- metaflow/metaflow_version.py +2 -2
- metaflow/plugins/__init__.py +1 -0
- metaflow/plugins/argo/argo_workflows.py +32 -6
- metaflow/plugins/argo/argo_workflows_cli.py +11 -0
- metaflow/plugins/aws/aws_client.py +4 -3
- metaflow/plugins/datatools/s3/s3.py +46 -44
- metaflow/plugins/datatools/s3/s3op.py +133 -63
- metaflow/plugins/uv/__init__.py +0 -0
- metaflow/plugins/uv/bootstrap.py +100 -0
- metaflow/plugins/uv/uv_environment.py +70 -0
- metaflow/version.py +1 -1
- {metaflow-2.15.7.data → metaflow-2.15.8.data}/data/share/metaflow/devtools/Makefile +2 -0
- {metaflow-2.15.7.dist-info → metaflow-2.15.8.dist-info}/METADATA +2 -2
- {metaflow-2.15.7.dist-info → metaflow-2.15.8.dist-info}/RECORD +27 -23
- {metaflow-2.15.7.data → metaflow-2.15.8.data}/data/share/metaflow/devtools/Tiltfile +0 -0
- {metaflow-2.15.7.data → metaflow-2.15.8.data}/data/share/metaflow/devtools/pick_services.sh +0 -0
- {metaflow-2.15.7.dist-info → metaflow-2.15.8.dist-info}/WHEEL +0 -0
- {metaflow-2.15.7.dist-info → metaflow-2.15.8.dist-info}/entry_points.txt +0 -0
- {metaflow-2.15.7.dist-info → metaflow-2.15.8.dist-info}/licenses/LICENSE +0 -0
- {metaflow-2.15.7.dist-info → metaflow-2.15.8.dist-info}/top_level.txt +0 -0
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
|
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=
|
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
|
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
|
|
metaflow/metaflow_config.py
CHANGED
@@ -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
|
|
metaflow/metaflow_environment.py
CHANGED
@@ -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:
|
metaflow/metaflow_git.py
ADDED
@@ -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
|
metaflow/metaflow_version.py
CHANGED
@@ -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 = []
|
metaflow/plugins/__init__.py
CHANGED
@@ -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
|
-
|
2556
|
-
|
2557
|
-
|
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
|
-
|
2607
|
-
|
2608
|
-
|
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
|
-
#
|
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 =
|
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(
|
1394
|
-
|
1395
|
-
|
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
|
1484
|
-
#
|
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
|
-
#
|
1492
|
-
#
|
1493
|
-
#
|
1494
|
-
#
|
1495
|
-
#
|
1496
|
-
#
|
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
|
-
|
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
|
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
|
1723
|
-
|
1724
|
-
|
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
|
-
|
225
|
-
|
226
|
-
|
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
|
-
|
236
|
-
|
237
|
-
|
238
|
-
|
239
|
-
|
240
|
-
|
241
|
-
|
242
|
-
|
243
|
-
|
244
|
-
|
245
|
-
|
246
|
-
|
247
|
-
|
248
|
-
|
249
|
-
|
250
|
-
|
251
|
-
|
252
|
-
|
253
|
-
|
254
|
-
|
255
|
-
|
256
|
-
|
257
|
-
|
258
|
-
|
259
|
-
|
260
|
-
|
261
|
-
|
262
|
-
|
263
|
-
|
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
|
-
|
267
|
-
|
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
|
-
|
270
|
-
|
271
|
-
|
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
|
-
|
320
|
-
|
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
|
-
|
332
|
-
|
346
|
+
except S3UploadFailedError as e:
|
347
|
+
err = convert_to_client_error(e)
|
348
|
+
handle_client_error(err, idx, result_file)
|
333
349
|
continue
|
334
|
-
|
335
|
-
|
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
|
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
|
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
|
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
|
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.
|
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.
|
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.
|
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=
|
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=
|
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=
|
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=
|
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=
|
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=
|
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=
|
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=
|
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=
|
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=
|
213
|
-
metaflow/plugins/argo/argo_workflows_cli.py,sha256=
|
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=
|
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=
|
285
|
-
metaflow/plugins/datatools/s3/s3op.py,sha256=
|
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.
|
393
|
-
metaflow-2.15.
|
394
|
-
metaflow-2.15.
|
395
|
-
metaflow-2.15.
|
396
|
-
metaflow-2.15.
|
397
|
-
metaflow-2.15.
|
398
|
-
metaflow-2.15.
|
399
|
-
metaflow-2.15.
|
400
|
-
metaflow-2.15.
|
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,,
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|