mlcflow 1.2.6__tar.gz → 1.2.8__tar.gz
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.
- {mlcflow-1.2.6 → mlcflow-1.2.8}/PKG-INFO +1 -1
- {mlcflow-1.2.6 → mlcflow-1.2.8}/mlc/action.py +8 -3
- {mlcflow-1.2.6 → mlcflow-1.2.8}/mlc/main.py +2 -2
- {mlcflow-1.2.6 → mlcflow-1.2.8}/mlc/repo_action.py +220 -33
- {mlcflow-1.2.6 → mlcflow-1.2.8}/mlc/script_action.py +2 -1
- {mlcflow-1.2.6 → mlcflow-1.2.8}/mlcflow.egg-info/PKG-INFO +1 -1
- {mlcflow-1.2.6 → mlcflow-1.2.8}/mlcflow.egg-info/SOURCES.txt +3 -1
- {mlcflow-1.2.6 → mlcflow-1.2.8}/pyproject.toml +1 -1
- mlcflow-1.2.8/tests/test_mlcflow_unix_installer.py +137 -0
- mlcflow-1.2.8/tests/test_repo_action_extra_git_args.py +20 -0
- mlcflow-1.2.8/tests/test_script_action_apptainer.py +54 -0
- mlcflow-1.2.8/tests/test_unix_installer_venv.py +224 -0
- mlcflow-1.2.6/tests/test_mlcflow_unix_installer.py +0 -83
- mlcflow-1.2.6/tests/test_script_action_apptainer.py +0 -29
- {mlcflow-1.2.6 → mlcflow-1.2.8}/LICENSE.md +0 -0
- {mlcflow-1.2.6 → mlcflow-1.2.8}/README.md +0 -0
- {mlcflow-1.2.6 → mlcflow-1.2.8}/mlc/__init__.py +0 -0
- {mlcflow-1.2.6 → mlcflow-1.2.8}/mlc/__main__.py +0 -0
- {mlcflow-1.2.6 → mlcflow-1.2.8}/mlc/action_factory.py +0 -0
- {mlcflow-1.2.6 → mlcflow-1.2.8}/mlc/cache_action.py +0 -0
- {mlcflow-1.2.6 → mlcflow-1.2.8}/mlc/cfg_action.py +0 -0
- {mlcflow-1.2.6 → mlcflow-1.2.8}/mlc/error_codes.py +0 -0
- {mlcflow-1.2.6 → mlcflow-1.2.8}/mlc/experiment_action.py +0 -0
- {mlcflow-1.2.6 → mlcflow-1.2.8}/mlc/index.py +0 -0
- {mlcflow-1.2.6 → mlcflow-1.2.8}/mlc/item.py +0 -0
- {mlcflow-1.2.6 → mlcflow-1.2.8}/mlc/logger.py +0 -0
- {mlcflow-1.2.6 → mlcflow-1.2.8}/mlc/meta_schema.py +0 -0
- {mlcflow-1.2.6 → mlcflow-1.2.8}/mlc/repo.py +0 -0
- {mlcflow-1.2.6 → mlcflow-1.2.8}/mlc/utils.py +0 -0
- {mlcflow-1.2.6 → mlcflow-1.2.8}/mlcflow.egg-info/dependency_links.txt +0 -0
- {mlcflow-1.2.6 → mlcflow-1.2.8}/mlcflow.egg-info/entry_points.txt +0 -0
- {mlcflow-1.2.6 → mlcflow-1.2.8}/mlcflow.egg-info/requires.txt +0 -0
- {mlcflow-1.2.6 → mlcflow-1.2.8}/mlcflow.egg-info/top_level.txt +0 -0
- {mlcflow-1.2.6 → mlcflow-1.2.8}/setup.cfg +0 -0
- {mlcflow-1.2.6 → mlcflow-1.2.8}/tests/test_action_invalid_meta_entries.py +0 -0
- {mlcflow-1.2.6 → mlcflow-1.2.8}/tests/test_cache_mark_tmp.py +0 -0
|
@@ -5,6 +5,7 @@ import yaml
|
|
|
5
5
|
import logging
|
|
6
6
|
import re
|
|
7
7
|
import shutil
|
|
8
|
+
import sys
|
|
8
9
|
from pathlib import Path
|
|
9
10
|
|
|
10
11
|
from .logger import logger, setup_logging
|
|
@@ -483,6 +484,7 @@ class Action:
|
|
|
483
484
|
target_name = i.get('target_name', i.get('target', "cache"))
|
|
484
485
|
i['target_name'] = target_name
|
|
485
486
|
ii = i.copy()
|
|
487
|
+
quiet = ii.get('quiet', sys.stdin.isatty())
|
|
486
488
|
|
|
487
489
|
if i.get('search_tags'):
|
|
488
490
|
ii['tags'] = ",".join(i['search_tags'])
|
|
@@ -504,9 +506,12 @@ class Action:
|
|
|
504
506
|
|
|
505
507
|
new_tags = set(search_tags)
|
|
506
508
|
if len(found_items) > 1:
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
509
|
+
if quiet:
|
|
510
|
+
user_input = 'yes'
|
|
511
|
+
else:
|
|
512
|
+
# Step 3: Ask user for confirmation if multiple items are found
|
|
513
|
+
user_input = input(
|
|
514
|
+
f"{len(found_items)} items found. Do you want to update all? (yes/no): ").strip().lower()
|
|
510
515
|
if user_input not in ['yes', 'y']:
|
|
511
516
|
return {'return': 0,
|
|
512
517
|
'message': 'Update operation canceled by the user.'}
|
|
@@ -576,7 +576,7 @@ def main():
|
|
|
576
576
|
else:
|
|
577
577
|
pre_args.target, pre_args.action = pre_args.action, None
|
|
578
578
|
actions = get_action(pre_args.target, default_parent)
|
|
579
|
-
help_text += actions.__doc__
|
|
579
|
+
help_text += actions.__doc__ or ""
|
|
580
580
|
# iterate through every method
|
|
581
581
|
for method_name, method in inspect.getmembers(
|
|
582
582
|
actions.__class__, inspect.isfunction):
|
|
@@ -588,7 +588,7 @@ def main():
|
|
|
588
588
|
action_name = pre_args.action.replace("-", "_")
|
|
589
589
|
try:
|
|
590
590
|
method = getattr(actions, action_name)
|
|
591
|
-
help_text += actions.__doc__
|
|
591
|
+
help_text += actions.__doc__ or ""
|
|
592
592
|
if method.__doc__:
|
|
593
593
|
help_text += method.__doc__
|
|
594
594
|
except AttributeError:
|
|
@@ -40,11 +40,164 @@ class RepoAction(Action):
|
|
|
40
40
|
|
|
41
41
|
"""
|
|
42
42
|
|
|
43
|
+
# These phrases come from git's current stderr output and may vary by
|
|
44
|
+
# version or locale, so keep the matching logic narrowly scoped.
|
|
45
|
+
GIT_MISSING_BRANCH_PHRASE = "did not match any file(s) known to git"
|
|
46
|
+
GIT_FAST_FORWARD_FAILURE_PHRASE = "not possible to fast-forward"
|
|
47
|
+
|
|
43
48
|
def __init__(self, parent=None):
|
|
44
49
|
# super().__init__(parent)
|
|
45
50
|
self.parent = parent
|
|
46
51
|
self.__dict__.update(vars(parent))
|
|
47
52
|
|
|
53
|
+
def _build_pull_command(self, repo_path, branch=None, clone_depth=None,
|
|
54
|
+
fast_forward_only=False):
|
|
55
|
+
pull_command = ['git', '-C', repo_path, 'pull']
|
|
56
|
+
if fast_forward_only:
|
|
57
|
+
pull_command.append('--ff-only')
|
|
58
|
+
if clone_depth is not None:
|
|
59
|
+
if self._is_shallow_repo(repo_path):
|
|
60
|
+
pull_command.extend(['--depth', str(clone_depth)])
|
|
61
|
+
else:
|
|
62
|
+
logger.warning(
|
|
63
|
+
f"--depth/--shallow ignored for pull on non-shallow repo {repo_path}. "
|
|
64
|
+
"Re-clone with --shallow to create a shallow copy."
|
|
65
|
+
)
|
|
66
|
+
# Preserve the historical existing-repo behavior of a plain
|
|
67
|
+
# `git pull` by default. The branch argument is only forwarded to
|
|
68
|
+
# `git pull` when the internal strict path explicitly opts into
|
|
69
|
+
# fast-forward-only updates.
|
|
70
|
+
if branch and fast_forward_only:
|
|
71
|
+
pull_command.extend(['origin', branch])
|
|
72
|
+
return pull_command
|
|
73
|
+
|
|
74
|
+
def _checkout_pull_branch(self, repo_path, branch):
|
|
75
|
+
"""Ensure *branch* is checked out locally before a strict pull.
|
|
76
|
+
|
|
77
|
+
First tries to checkout an existing local branch. If that fails, fetches
|
|
78
|
+
the branch from origin and creates a new tracking branch. Raises
|
|
79
|
+
RuntimeError with contextual guidance when the branch cannot be prepared.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
repo_path: Local repository path.
|
|
83
|
+
branch: Branch name to prepare before pulling.
|
|
84
|
+
|
|
85
|
+
Raises:
|
|
86
|
+
RuntimeError: If the branch cannot be fetched or checked out.
|
|
87
|
+
"""
|
|
88
|
+
try:
|
|
89
|
+
subprocess.run(
|
|
90
|
+
['git', '-C', repo_path, 'checkout', branch],
|
|
91
|
+
capture_output=True,
|
|
92
|
+
text=True,
|
|
93
|
+
check=True)
|
|
94
|
+
except subprocess.CalledProcessError as checkout_error:
|
|
95
|
+
checkout_error_text = self._subprocess_error_message(
|
|
96
|
+
checkout_error)
|
|
97
|
+
lowered_checkout_error = checkout_error_text.lower()
|
|
98
|
+
# Git uses the same exit code for many checkout failures, so we
|
|
99
|
+
# only fall back to fetch+track when stderr includes both
|
|
100
|
+
# "pathspec" and Git's usual missing-ref phrase
|
|
101
|
+
# "did not match any file(s) known to git".
|
|
102
|
+
missing_local_branch = (
|
|
103
|
+
"pathspec" in lowered_checkout_error
|
|
104
|
+
and self.GIT_MISSING_BRANCH_PHRASE in lowered_checkout_error
|
|
105
|
+
)
|
|
106
|
+
if not missing_local_branch:
|
|
107
|
+
raise RuntimeError(
|
|
108
|
+
f"Cannot switch to branch '{branch}' in {repo_path}: "
|
|
109
|
+
f"{checkout_error_text}"
|
|
110
|
+
) from checkout_error
|
|
111
|
+
try:
|
|
112
|
+
subprocess.run(
|
|
113
|
+
['git', '-C', repo_path, 'fetch', 'origin', branch],
|
|
114
|
+
capture_output=True,
|
|
115
|
+
text=True,
|
|
116
|
+
check=True)
|
|
117
|
+
except subprocess.CalledProcessError as fetch_error:
|
|
118
|
+
raise RuntimeError(
|
|
119
|
+
f"Failed to fetch branch '{branch}' in {repo_path}: "
|
|
120
|
+
f"{self._subprocess_error_message(fetch_error)}. "
|
|
121
|
+
"Ensure the branch exists on origin and that the repository is reachable."
|
|
122
|
+
) from fetch_error
|
|
123
|
+
|
|
124
|
+
try:
|
|
125
|
+
subprocess.run(
|
|
126
|
+
['git', '-C', repo_path, 'checkout', '-b',
|
|
127
|
+
branch, '--track', f'origin/{branch}'],
|
|
128
|
+
capture_output=True,
|
|
129
|
+
text=True,
|
|
130
|
+
check=True)
|
|
131
|
+
except subprocess.CalledProcessError as tracking_error:
|
|
132
|
+
raise RuntimeError(
|
|
133
|
+
f"Initial checkout of '{branch}' failed in {repo_path}. "
|
|
134
|
+
"After fetching from origin, creating a tracking branch also failed. "
|
|
135
|
+
f"Initial error: {checkout_error_text}. "
|
|
136
|
+
f"Tracking branch error: {self._subprocess_error_message(tracking_error)}. "
|
|
137
|
+
"Check that the branch name is correct and that your local checkout can track origin."
|
|
138
|
+
) from tracking_error
|
|
139
|
+
|
|
140
|
+
@staticmethod
|
|
141
|
+
def _format_stash_restore_guidance(repo_path):
|
|
142
|
+
return (
|
|
143
|
+
f"Local changes remain in stash. Please run `git -C {repo_path} stash apply` "
|
|
144
|
+
"after resolving pull issues."
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
def _format_pull_error(self, repo_path, error_message, stash_created=False,
|
|
148
|
+
force=False, failure_phase="git pull"):
|
|
149
|
+
"""Format a pull failure message.
|
|
150
|
+
|
|
151
|
+
Args:
|
|
152
|
+
repo_path: Repository path being updated.
|
|
153
|
+
error_message: Original stderr/stdout-derived git failure text.
|
|
154
|
+
stash_created: Whether local changes were stashed for a force pull.
|
|
155
|
+
force: Whether the failure happened on a force-pull path.
|
|
156
|
+
failure_phase: Phase label such as ``git pull`` or
|
|
157
|
+
``branch checkout``.
|
|
158
|
+
|
|
159
|
+
Returns:
|
|
160
|
+
A user-facing error string with optional recovery guidance.
|
|
161
|
+
"""
|
|
162
|
+
prefix = "Force pull failed" if force else "Pull failed"
|
|
163
|
+
if failure_phase:
|
|
164
|
+
prefix = f"{prefix} during {failure_phase}"
|
|
165
|
+
|
|
166
|
+
resolution = ""
|
|
167
|
+
lowered_error = error_message.lower()
|
|
168
|
+
# Git reports ff-only failures via stderr text instead of a distinct
|
|
169
|
+
# exit code, so use this case-insensitive substring from the standard
|
|
170
|
+
# "Not possible to fast-forward, aborting." message to add guidance.
|
|
171
|
+
if self.GIT_FAST_FORWARD_FAILURE_PHRASE in lowered_error:
|
|
172
|
+
resolution = (
|
|
173
|
+
" Check whether the local and remote branches have diverged "
|
|
174
|
+
"and reconcile them manually before retrying."
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
if stash_created:
|
|
178
|
+
return (
|
|
179
|
+
f"{prefix} for {repo_path}. "
|
|
180
|
+
f"{self._format_stash_restore_guidance(repo_path)} "
|
|
181
|
+
f"Details: {error_message}{resolution}"
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
return f"{prefix} for {repo_path}: {error_message}{resolution}"
|
|
185
|
+
|
|
186
|
+
@staticmethod
|
|
187
|
+
def _subprocess_error_message(error):
|
|
188
|
+
"""Return stderr, then stdout, then str(error), whichever is populated first."""
|
|
189
|
+
return (error.stderr or error.stdout or str(error)).strip()
|
|
190
|
+
|
|
191
|
+
@staticmethod
|
|
192
|
+
def _validate_extra_git_args(extra_args):
|
|
193
|
+
disallowed_pattern = re.compile(r"[\r\n]")
|
|
194
|
+
for arg in extra_args:
|
|
195
|
+
if disallowed_pattern.search(arg):
|
|
196
|
+
return (
|
|
197
|
+
"--extra_git_args may not include carriage returns or newlines."
|
|
198
|
+
)
|
|
199
|
+
return None
|
|
200
|
+
|
|
48
201
|
def add(self, run_args):
|
|
49
202
|
"""
|
|
50
203
|
####################################################################################################################
|
|
@@ -337,7 +490,7 @@ class RepoAction(Action):
|
|
|
337
490
|
|
|
338
491
|
def pull_repo(self, repo_url, branch=None, checkout=None, tag=None,
|
|
339
492
|
pat=None, ssh=None, ignore_on_conflict=False, repo_path=None, force=False,
|
|
340
|
-
shallow=False, depth=None, extra_git_args=None):
|
|
493
|
+
shallow=False, depth=None, extra_git_args=None, fast_forward_only=False):
|
|
341
494
|
|
|
342
495
|
# Determine the checkout path from environment or default
|
|
343
496
|
repo_base_path = self.repos_path # either the value will be from 'MLC_REPOS'
|
|
@@ -380,7 +533,7 @@ class RepoAction(Action):
|
|
|
380
533
|
clone_depth = int(depth)
|
|
381
534
|
except (TypeError, ValueError):
|
|
382
535
|
return {
|
|
383
|
-
"return": 1, "error": f"Invalid value for --depth: {depth
|
|
536
|
+
"return": 1, "error": f"Invalid value for --depth: {depth}. Must be a positive integer."}
|
|
384
537
|
if clone_depth < 1:
|
|
385
538
|
return {
|
|
386
539
|
"return": 1, "error": f"Invalid value for --depth: {clone_depth}. Must be a positive integer."}
|
|
@@ -391,9 +544,23 @@ class RepoAction(Action):
|
|
|
391
544
|
extra_args = []
|
|
392
545
|
if extra_git_args:
|
|
393
546
|
if isinstance(extra_git_args, list):
|
|
547
|
+
if not all(isinstance(arg, str) for arg in extra_git_args):
|
|
548
|
+
return {
|
|
549
|
+
"return": 1,
|
|
550
|
+
"error": "--extra_git_args list entries must all be strings."
|
|
551
|
+
}
|
|
394
552
|
extra_args = extra_git_args
|
|
553
|
+
elif isinstance(extra_git_args, str):
|
|
554
|
+
extra_args = shlex.split(extra_git_args)
|
|
395
555
|
else:
|
|
396
|
-
|
|
556
|
+
return {
|
|
557
|
+
"return": 1,
|
|
558
|
+
"error": "--extra_git_args must be provided as a string or list of strings."
|
|
559
|
+
}
|
|
560
|
+
extra_git_args_error = self._validate_extra_git_args(
|
|
561
|
+
extra_args)
|
|
562
|
+
if extra_git_args_error:
|
|
563
|
+
return {"return": 1, "error": extra_git_args_error}
|
|
397
564
|
|
|
398
565
|
# If the directory doesn't exist, clone it
|
|
399
566
|
if not os.path.exists(repo_path):
|
|
@@ -468,30 +635,33 @@ class RepoAction(Action):
|
|
|
468
635
|
logger.info(
|
|
469
636
|
"Pulling latest changes...")
|
|
470
637
|
try:
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
if self._is_shallow_repo(repo_path):
|
|
474
|
-
pull_command += ['--depth', str(clone_depth)]
|
|
475
|
-
else:
|
|
476
|
-
logger.warning(
|
|
477
|
-
f"--depth/--shallow ignored for pull on non-shallow repo {repo_path}. "
|
|
478
|
-
"Re-clone with --shallow to create a shallow copy."
|
|
479
|
-
)
|
|
638
|
+
if fast_forward_only and branch:
|
|
639
|
+
self._checkout_pull_branch(repo_path, branch)
|
|
480
640
|
subprocess.run(
|
|
481
|
-
|
|
641
|
+
self._build_pull_command(
|
|
642
|
+
repo_path, branch, clone_depth, fast_forward_only),
|
|
482
643
|
capture_output=True,
|
|
483
644
|
text=True,
|
|
484
645
|
check=True)
|
|
646
|
+
except RuntimeError as e:
|
|
647
|
+
return {
|
|
648
|
+
"return": 1,
|
|
649
|
+
"error": self._format_pull_error(
|
|
650
|
+
repo_path,
|
|
651
|
+
str(e),
|
|
652
|
+
stash_created=stash_created,
|
|
653
|
+
force=True,
|
|
654
|
+
failure_phase="branch checkout")
|
|
655
|
+
}
|
|
485
656
|
except subprocess.CalledProcessError as e:
|
|
486
|
-
pull_error =
|
|
487
|
-
if stash_created:
|
|
488
|
-
return {
|
|
489
|
-
"return": 1,
|
|
490
|
-
"error": f"Force pull failed during git pull for {repo_path}. Local changes remain in stash. Please run `git -C {repo_path} stash apply` after resolving pull issues. Details: {pull_error}"
|
|
491
|
-
}
|
|
657
|
+
pull_error = self._subprocess_error_message(e)
|
|
492
658
|
return {
|
|
493
659
|
"return": 1,
|
|
494
|
-
"error":
|
|
660
|
+
"error": self._format_pull_error(
|
|
661
|
+
repo_path,
|
|
662
|
+
pull_error,
|
|
663
|
+
stash_created=stash_created,
|
|
664
|
+
force=True)
|
|
495
665
|
}
|
|
496
666
|
logger.info("Repository successfully pulled.")
|
|
497
667
|
|
|
@@ -536,16 +706,28 @@ class RepoAction(Action):
|
|
|
536
706
|
else:
|
|
537
707
|
logger.info(
|
|
538
708
|
"No local changes detected. Pulling latest changes...")
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
)
|
|
548
|
-
|
|
709
|
+
try:
|
|
710
|
+
if fast_forward_only and branch:
|
|
711
|
+
self._checkout_pull_branch(repo_path, branch)
|
|
712
|
+
subprocess.run(
|
|
713
|
+
self._build_pull_command(
|
|
714
|
+
repo_path, branch, clone_depth, fast_forward_only),
|
|
715
|
+
capture_output=True,
|
|
716
|
+
text=True,
|
|
717
|
+
check=True)
|
|
718
|
+
except RuntimeError as e:
|
|
719
|
+
return {
|
|
720
|
+
"return": 1,
|
|
721
|
+
"error": self._format_pull_error(
|
|
722
|
+
repo_path, str(e), failure_phase="branch checkout")
|
|
723
|
+
}
|
|
724
|
+
except subprocess.CalledProcessError as e:
|
|
725
|
+
pull_error = self._subprocess_error_message(e)
|
|
726
|
+
return {
|
|
727
|
+
"return": 1,
|
|
728
|
+
"error": self._format_pull_error(
|
|
729
|
+
repo_path, pull_error)
|
|
730
|
+
}
|
|
549
731
|
logger.info("Repository successfully pulled.")
|
|
550
732
|
|
|
551
733
|
if tag:
|
|
@@ -585,6 +767,8 @@ class RepoAction(Action):
|
|
|
585
767
|
|
|
586
768
|
return {"return": 0}
|
|
587
769
|
|
|
770
|
+
except RuntimeError as e:
|
|
771
|
+
return {'return': 1, 'error': str(e)}
|
|
588
772
|
except subprocess.CalledProcessError as e:
|
|
589
773
|
return {'return': 1, 'error': f"Git command failed: {e}"}
|
|
590
774
|
except Exception as e:
|
|
@@ -610,7 +794,7 @@ class RepoAction(Action):
|
|
|
610
794
|
|
|
611
795
|
|
|
612
796
|
- `--checkout <commit_sha>`: Checks out a specific commit after cloning (applicable when the repository exists locally).
|
|
613
|
-
- `--branch <branch_name>`: Checks out a specific branch
|
|
797
|
+
- `--branch <branch_name>`: Checks out a specific branch while cloning a new repository. When strict fast-forward-only pulls are requested for an existing checkout, it also selects the local branch to switch to before pulling `origin/<branch>`.
|
|
614
798
|
- `--tag <release_tag>`: Checks out a particular release tag.
|
|
615
799
|
- `--pat <access_token>` or `--ssh`: Clones a private repository using a personal access token or SSH.
|
|
616
800
|
- `--force`: For existing repositories with local tracked changes, stashes changes before pull and reapplies them after pull.
|
|
@@ -651,7 +835,8 @@ class RepoAction(Action):
|
|
|
651
835
|
'force'),
|
|
652
836
|
shallow=run_args.get('shallow', False),
|
|
653
837
|
depth=run_args.get('depth'),
|
|
654
|
-
extra_git_args=run_args.get('extra_git_args')
|
|
838
|
+
extra_git_args=run_args.get('extra_git_args'),
|
|
839
|
+
fast_forward_only=run_args.get('fast_forward_only', False))
|
|
655
840
|
if res['return'] > 0:
|
|
656
841
|
return res
|
|
657
842
|
else:
|
|
@@ -666,6 +851,7 @@ class RepoAction(Action):
|
|
|
666
851
|
shallow = run_args.get('shallow', False)
|
|
667
852
|
depth = run_args.get('depth')
|
|
668
853
|
extra_git_args = run_args.get('extra_git_args')
|
|
854
|
+
fast_forward_only = run_args.get('fast_forward_only', False)
|
|
669
855
|
|
|
670
856
|
if sum(bool(var) for var in [branch, checkout, tag]) > 1:
|
|
671
857
|
return {
|
|
@@ -682,7 +868,8 @@ class RepoAction(Action):
|
|
|
682
868
|
force=force,
|
|
683
869
|
shallow=shallow,
|
|
684
870
|
depth=depth,
|
|
685
|
-
extra_git_args=extra_git_args
|
|
871
|
+
extra_git_args=extra_git_args,
|
|
872
|
+
fast_forward_only=fast_forward_only)
|
|
686
873
|
if res['return'] > 0:
|
|
687
874
|
return res
|
|
688
875
|
|
|
@@ -27,4 +27,6 @@ mlcflow.egg-info/top_level.txt
|
|
|
27
27
|
tests/test_action_invalid_meta_entries.py
|
|
28
28
|
tests/test_cache_mark_tmp.py
|
|
29
29
|
tests/test_mlcflow_unix_installer.py
|
|
30
|
-
tests/
|
|
30
|
+
tests/test_repo_action_extra_git_args.py
|
|
31
|
+
tests/test_script_action_apptainer.py
|
|
32
|
+
tests/test_unix_installer_venv.py
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import platform
|
|
2
|
+
import subprocess
|
|
3
|
+
import sys
|
|
4
|
+
import tempfile
|
|
5
|
+
import textwrap
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
REPO_ROOT = Path(__file__).resolve().parents[1]
|
|
10
|
+
INSTALLER_SCRIPT = REPO_ROOT / "docs" / "install" / "mlcflow_unix_installer.sh"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _resolve_venv_dir(tmp_path, setup_snippet):
|
|
14
|
+
default_dir = tmp_path / "mlcflow"
|
|
15
|
+
marker = "__RESULT__"
|
|
16
|
+
command = textwrap.dedent(
|
|
17
|
+
f"""
|
|
18
|
+
set -euo pipefail
|
|
19
|
+
source "{INSTALLER_SCRIPT}"
|
|
20
|
+
DEFAULT_VENV_DIR="{default_dir}"
|
|
21
|
+
VENV_DIR="$DEFAULT_VENV_DIR"
|
|
22
|
+
PY_MAJOR_MINOR="$(python3 -c 'import sys; print("{{}}.{{}}".format(*sys.version_info[:2]))')"
|
|
23
|
+
PY_ARCH="$(python3 -c 'import platform; print(platform.machine())')"
|
|
24
|
+
{setup_snippet}
|
|
25
|
+
resolve_default_venv_dir
|
|
26
|
+
echo "{marker}$VENV_DIR"
|
|
27
|
+
"""
|
|
28
|
+
)
|
|
29
|
+
result = subprocess.run(
|
|
30
|
+
["bash", "-lc", command],
|
|
31
|
+
text=True,
|
|
32
|
+
capture_output=True,
|
|
33
|
+
check=True,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
for line in reversed(result.stdout.splitlines()):
|
|
37
|
+
if line.startswith(marker):
|
|
38
|
+
return line[len(marker):]
|
|
39
|
+
|
|
40
|
+
raise AssertionError(
|
|
41
|
+
f"Could not parse resolved venv path from output:\n{result.stdout}"
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_resolve_default_venv_dir_prefers_compatible_default(tmp_path):
|
|
46
|
+
setup_snippet = """
|
|
47
|
+
python3 -m venv "$DEFAULT_VENV_DIR"
|
|
48
|
+
"""
|
|
49
|
+
resolved = _resolve_venv_dir(tmp_path, setup_snippet)
|
|
50
|
+
assert resolved == str(tmp_path / "mlcflow")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_resolve_default_venv_dir_uses_suffix_for_incompatible_default(
|
|
54
|
+
tmp_path):
|
|
55
|
+
setup_snippet = """
|
|
56
|
+
mkdir -p "$DEFAULT_VENV_DIR"
|
|
57
|
+
"""
|
|
58
|
+
resolved = _resolve_venv_dir(tmp_path, setup_snippet)
|
|
59
|
+
assert resolved.startswith(str(tmp_path / "mlcflow_"))
|
|
60
|
+
assert "_py" in resolved
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_resolve_default_venv_dir_removes_stale_shared_env_when_default_incompatible(
|
|
64
|
+
tmp_path,
|
|
65
|
+
):
|
|
66
|
+
"""Broken suffixed envs should be removed so setup_venv recreates them."""
|
|
67
|
+
setup_snippet = """
|
|
68
|
+
mkdir -p "$DEFAULT_VENV_DIR"
|
|
69
|
+
SHARED_VENV_DIR="${DEFAULT_VENV_DIR}_${PY_ARCH}_py${PY_MAJOR_MINOR}"
|
|
70
|
+
mkdir -p "$SHARED_VENV_DIR"
|
|
71
|
+
"""
|
|
72
|
+
resolved = _resolve_venv_dir(tmp_path, setup_snippet)
|
|
73
|
+
expected_suffix = (
|
|
74
|
+
f"{tmp_path / 'mlcflow'}_{platform.machine()}"
|
|
75
|
+
f"_py{sys.version_info[0]}.{sys.version_info[1]}"
|
|
76
|
+
)
|
|
77
|
+
assert resolved == expected_suffix
|
|
78
|
+
assert not Path(expected_suffix).exists()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_resolve_default_venv_dir_reuses_compatible_suffixed_env(tmp_path):
|
|
82
|
+
setup_snippet = """
|
|
83
|
+
SHARED_VENV_DIR="${DEFAULT_VENV_DIR}_${PY_ARCH}_py${PY_MAJOR_MINOR}"
|
|
84
|
+
python3 -m venv "$SHARED_VENV_DIR"
|
|
85
|
+
"""
|
|
86
|
+
resolved = _resolve_venv_dir(tmp_path, setup_snippet)
|
|
87
|
+
expected = (
|
|
88
|
+
f"{tmp_path / 'mlcflow'}_{platform.machine()}"
|
|
89
|
+
f"_py{sys.version_info[0]}.{sys.version_info[1]}"
|
|
90
|
+
)
|
|
91
|
+
assert resolved == expected
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_get_venv_suffix_and_compatibility_signature_use_consistent_python_values():
|
|
95
|
+
with tempfile.TemporaryDirectory() as temp_dir:
|
|
96
|
+
shim_path = Path(temp_dir) / "python-shim"
|
|
97
|
+
shim_path.write_text(
|
|
98
|
+
textwrap.dedent(
|
|
99
|
+
"""\
|
|
100
|
+
#!/usr/bin/env python3
|
|
101
|
+
import os
|
|
102
|
+
import platform
|
|
103
|
+
import sys
|
|
104
|
+
|
|
105
|
+
machine = os.environ["FAKE_MACHINE"]
|
|
106
|
+
major = int(os.environ.get("FAKE_PY_MAJOR", "3"))
|
|
107
|
+
minor = int(os.environ.get("FAKE_PY_MINOR", "12"))
|
|
108
|
+
code = sys.argv[2]
|
|
109
|
+
|
|
110
|
+
platform.machine = lambda: machine
|
|
111
|
+
sys.version_info = (major, minor, 0, "final", 0)
|
|
112
|
+
exec(code, {"__name__": "__main__"})
|
|
113
|
+
"""
|
|
114
|
+
),
|
|
115
|
+
encoding="utf-8",
|
|
116
|
+
)
|
|
117
|
+
shim_path.chmod(0o755)
|
|
118
|
+
|
|
119
|
+
command = textwrap.dedent(
|
|
120
|
+
f"""
|
|
121
|
+
set -euo pipefail
|
|
122
|
+
source "{INSTALLER_SCRIPT}"
|
|
123
|
+
PYTHON_CMD="{shim_path}"
|
|
124
|
+
export FAKE_MACHINE=arm64
|
|
125
|
+
export FAKE_PY_MAJOR=3
|
|
126
|
+
export FAKE_PY_MINOR=12
|
|
127
|
+
printf '__RESULT__:%s:%s\\n' "$(get_venv_suffix)" "$(get_python_compatibility_signature "$PYTHON_CMD")"
|
|
128
|
+
"""
|
|
129
|
+
)
|
|
130
|
+
result = subprocess.run(
|
|
131
|
+
["bash", "-lc", command],
|
|
132
|
+
text=True,
|
|
133
|
+
capture_output=True,
|
|
134
|
+
check=True,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
assert "__RESULT__:_arm64_py3.12:arm64|3.12" in result.stdout
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from mlc.repo_action import RepoAction
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def test_validate_extra_git_args_allows_literal_shell_characters():
|
|
5
|
+
assert RepoAction._validate_extra_git_args(
|
|
6
|
+
[
|
|
7
|
+
"refs/pull/$PR/head",
|
|
8
|
+
"http.extraHeader=Authorization: ******",
|
|
9
|
+
"name-with|pipe",
|
|
10
|
+
"`literal-backticks`",
|
|
11
|
+
]
|
|
12
|
+
) is None
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_validate_extra_git_args_rejects_line_breaks():
|
|
16
|
+
error = RepoAction._validate_extra_git_args(["line1\nline2"])
|
|
17
|
+
|
|
18
|
+
assert error == (
|
|
19
|
+
"--extra_git_args may not include carriage returns or newlines."
|
|
20
|
+
)
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import tempfile
|
|
2
|
+
import unittest
|
|
3
|
+
from unittest.mock import patch
|
|
4
|
+
|
|
5
|
+
from mlc.script_action import ScriptAction
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class _Parent:
|
|
9
|
+
def __init__(self, repos_path=None):
|
|
10
|
+
self.repos_path = repos_path
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ScriptActionApptainerTest(unittest.TestCase):
|
|
14
|
+
def test_apptainer_delegates_to_script_module(self):
|
|
15
|
+
action = ScriptAction(_Parent(tempfile.gettempdir()))
|
|
16
|
+
run_args = {"tags": "detect,os"}
|
|
17
|
+
expected = {"return": 0}
|
|
18
|
+
|
|
19
|
+
with patch.object(
|
|
20
|
+
ScriptAction,
|
|
21
|
+
"call_script_module_function",
|
|
22
|
+
return_value=expected) as call_script_module_function:
|
|
23
|
+
result = action.apptainer(run_args)
|
|
24
|
+
|
|
25
|
+
self.assertEqual(result, expected)
|
|
26
|
+
call_script_module_function.assert_called_once_with(
|
|
27
|
+
"apptainer", run_args)
|
|
28
|
+
|
|
29
|
+
def test_auto_pull_uses_fast_forward_only_for_mlperf_automations(self):
|
|
30
|
+
with tempfile.TemporaryDirectory() as repos_path:
|
|
31
|
+
action = ScriptAction(_Parent(repos_path))
|
|
32
|
+
|
|
33
|
+
with patch.object(
|
|
34
|
+
ScriptAction,
|
|
35
|
+
"find_target_folder",
|
|
36
|
+
return_value=None), \
|
|
37
|
+
patch.object(
|
|
38
|
+
ScriptAction,
|
|
39
|
+
"access",
|
|
40
|
+
return_value={"return": 1, "error": "pull failed"}) as access:
|
|
41
|
+
result = action.call_script_module_function("run", {})
|
|
42
|
+
|
|
43
|
+
self.assertEqual(result["return"], 1)
|
|
44
|
+
access.assert_called_once_with({
|
|
45
|
+
"automation": "repo",
|
|
46
|
+
"action": "pull",
|
|
47
|
+
"repo": "mlcommons@mlperf-automations",
|
|
48
|
+
"branch": "dev",
|
|
49
|
+
"fast_forward_only": True
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
if __name__ == "__main__":
|
|
54
|
+
unittest.main()
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import platform
|
|
3
|
+
import shlex
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
import tempfile
|
|
7
|
+
import textwrap
|
|
8
|
+
import unittest
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
12
|
+
INSTALLER_PATH = os.path.join(
|
|
13
|
+
REPO_ROOT,
|
|
14
|
+
"docs",
|
|
15
|
+
"install",
|
|
16
|
+
"mlcflow_unix_installer.sh")
|
|
17
|
+
# Keep this in sync with docs/install/mlcflow_unix_installer.sh:
|
|
18
|
+
# get_venv_suffix() and get_python_compatibility_signature().
|
|
19
|
+
COMPATIBILITY_SIGNATURE_CODE = (
|
|
20
|
+
'import platform, sys; '
|
|
21
|
+
'print("{}|{}.{}".format(platform.machine(), sys.version_info[0], sys.version_info[1]))'
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class UnixInstallerVenvTest(unittest.TestCase):
|
|
26
|
+
def setUp(self):
|
|
27
|
+
self.temp_dir = tempfile.TemporaryDirectory()
|
|
28
|
+
self.addCleanup(self.temp_dir.cleanup)
|
|
29
|
+
|
|
30
|
+
def _expected_suffix(self):
|
|
31
|
+
return (
|
|
32
|
+
f"_{platform.machine()}"
|
|
33
|
+
f"_py{sys.version_info[0]}.{sys.version_info[1]}"
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
def _run_setup_venv(self, venv_dir):
|
|
37
|
+
command = """
|
|
38
|
+
set -euo pipefail
|
|
39
|
+
source {installer}
|
|
40
|
+
VENV_DIR={venv_dir}
|
|
41
|
+
setup_venv
|
|
42
|
+
printf '__RESULT__:%s:%s\\n' "$VENV_DIR" "${{VIRTUAL_ENV:-}}"
|
|
43
|
+
""".format(
|
|
44
|
+
installer=shlex.quote(INSTALLER_PATH),
|
|
45
|
+
venv_dir=shlex.quote(venv_dir),
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
completed = subprocess.run(
|
|
49
|
+
["bash", "-lc", command],
|
|
50
|
+
cwd=self.temp_dir.name,
|
|
51
|
+
capture_output=True,
|
|
52
|
+
text=True,
|
|
53
|
+
check=False,
|
|
54
|
+
)
|
|
55
|
+
self.assertEqual(completed.returncode, 0, msg=completed.stderr)
|
|
56
|
+
|
|
57
|
+
result_lines = [
|
|
58
|
+
line for line in completed.stdout.splitlines()
|
|
59
|
+
if line.startswith("__RESULT__:")
|
|
60
|
+
]
|
|
61
|
+
self.assertTrue(result_lines, msg=completed.stdout)
|
|
62
|
+
_, resolved_path, activated_path = result_lines[-1].split(":", 2)
|
|
63
|
+
return resolved_path, activated_path, completed.stdout
|
|
64
|
+
|
|
65
|
+
def _compatibility_signature(self, python_executable):
|
|
66
|
+
completed = subprocess.run(
|
|
67
|
+
[
|
|
68
|
+
python_executable,
|
|
69
|
+
"-c",
|
|
70
|
+
COMPATIBILITY_SIGNATURE_CODE,
|
|
71
|
+
],
|
|
72
|
+
capture_output=True,
|
|
73
|
+
text=True,
|
|
74
|
+
check=True,
|
|
75
|
+
)
|
|
76
|
+
return completed.stdout.strip()
|
|
77
|
+
|
|
78
|
+
def _write_installer_with_stubbed_main_dependencies(self):
|
|
79
|
+
integration_installer_path = os.path.join(
|
|
80
|
+
self.temp_dir.name, "mlcflow_unix_installer_integration_test.sh"
|
|
81
|
+
)
|
|
82
|
+
stubbed_functions = textwrap.dedent("""
|
|
83
|
+
detect_os() {
|
|
84
|
+
OS_ID="ubuntu"
|
|
85
|
+
OS_VERSION="test"
|
|
86
|
+
PKG_MANAGER="apt"
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
check_missing_dependencies() {
|
|
90
|
+
MISSING_DEPS=()
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
ensure_python() {
|
|
94
|
+
:
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
install_mlcflow() {
|
|
98
|
+
:
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
prompt_repo_details() {
|
|
102
|
+
:
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
pull_repo() {
|
|
106
|
+
:
|
|
107
|
+
}
|
|
108
|
+
""").strip()
|
|
109
|
+
with open(INSTALLER_PATH, "r", encoding="utf-8") as installer_file:
|
|
110
|
+
installer_contents = installer_file.read().rstrip()
|
|
111
|
+
main_guard = textwrap.dedent(
|
|
112
|
+
"""
|
|
113
|
+
if [[ "${BASH_SOURCE[0]:-$0}" == "$0" ]]; then
|
|
114
|
+
main
|
|
115
|
+
fi
|
|
116
|
+
"""
|
|
117
|
+
).strip()
|
|
118
|
+
replacement = (
|
|
119
|
+
stubbed_functions
|
|
120
|
+
+ "\n\n"
|
|
121
|
+
+ main_guard
|
|
122
|
+
)
|
|
123
|
+
self.assertIn(main_guard, installer_contents)
|
|
124
|
+
modified_contents = installer_contents.replace(main_guard, replacement)
|
|
125
|
+
self.assertIn(replacement, modified_contents)
|
|
126
|
+
with open(integration_installer_path, "w", encoding="utf-8") as installer_file:
|
|
127
|
+
installer_file.write(modified_contents)
|
|
128
|
+
return integration_installer_path
|
|
129
|
+
|
|
130
|
+
def test_installer_main_runs_setup_venv_with_stubbed_dependencies(self):
|
|
131
|
+
venv_dir = os.path.join(self.temp_dir.name, "mlcflow")
|
|
132
|
+
os.makedirs(venv_dir, exist_ok=True)
|
|
133
|
+
expected_path = venv_dir + self._expected_suffix()
|
|
134
|
+
installer_path = self._write_installer_with_stubbed_main_dependencies()
|
|
135
|
+
|
|
136
|
+
completed = subprocess.run(
|
|
137
|
+
["bash", installer_path, "--yes", "--venv-dir", venv_dir],
|
|
138
|
+
cwd=self.temp_dir.name,
|
|
139
|
+
capture_output=True,
|
|
140
|
+
text=True,
|
|
141
|
+
check=False,
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
self.assertEqual(completed.returncode, 0, msg=completed.stderr)
|
|
145
|
+
self.assertIn("Installation completed successfully.", completed.stdout)
|
|
146
|
+
self.assertIn(expected_path, completed.stdout)
|
|
147
|
+
self.assertTrue(
|
|
148
|
+
os.path.exists(
|
|
149
|
+
os.path.join(
|
|
150
|
+
expected_path,
|
|
151
|
+
"bin",
|
|
152
|
+
"python")))
|
|
153
|
+
self.assertEqual(
|
|
154
|
+
self._compatibility_signature(
|
|
155
|
+
os.path.join(expected_path, "bin", "python")),
|
|
156
|
+
self._compatibility_signature(sys.executable),
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
def test_setup_venv_creates_requested_path_from_scratch(self):
|
|
160
|
+
venv_dir = os.path.join(self.temp_dir.name, "mlcflow")
|
|
161
|
+
|
|
162
|
+
resolved_path, activated_path, stdout = self._run_setup_venv(venv_dir)
|
|
163
|
+
|
|
164
|
+
self.assertEqual(resolved_path, venv_dir)
|
|
165
|
+
self.assertEqual(activated_path, venv_dir)
|
|
166
|
+
self.assertIn("Setting up virtual environment at", stdout)
|
|
167
|
+
self.assertTrue(
|
|
168
|
+
os.path.exists(
|
|
169
|
+
os.path.join(
|
|
170
|
+
venv_dir,
|
|
171
|
+
"bin",
|
|
172
|
+
"python")))
|
|
173
|
+
self.assertEqual(
|
|
174
|
+
self._compatibility_signature(
|
|
175
|
+
os.path.join(venv_dir, "bin", "python")),
|
|
176
|
+
self._compatibility_signature(sys.executable),
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
def test_setup_venv_reuses_compatible_existing_venv(self):
|
|
180
|
+
venv_dir = os.path.join(self.temp_dir.name, "mlcflow")
|
|
181
|
+
subprocess.run(
|
|
182
|
+
[sys.executable, "-m", "venv", venv_dir],
|
|
183
|
+
check=True,
|
|
184
|
+
cwd=self.temp_dir.name,
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
resolved_path, activated_path, stdout = self._run_setup_venv(venv_dir)
|
|
188
|
+
|
|
189
|
+
self.assertEqual(resolved_path, venv_dir)
|
|
190
|
+
self.assertEqual(activated_path, venv_dir)
|
|
191
|
+
self.assertIn("Reusing existing virtual environment.", stdout)
|
|
192
|
+
self.assertEqual(
|
|
193
|
+
self._compatibility_signature(
|
|
194
|
+
os.path.join(venv_dir, "bin", "python")),
|
|
195
|
+
self._compatibility_signature(sys.executable),
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
def test_setup_venv_uses_arch_and_python_suffix_for_incompatible_dir(self):
|
|
199
|
+
venv_dir = os.path.join(self.temp_dir.name, "mlcflow")
|
|
200
|
+
os.makedirs(venv_dir, exist_ok=True)
|
|
201
|
+
expected_path = venv_dir + self._expected_suffix()
|
|
202
|
+
|
|
203
|
+
resolved_path, activated_path, stdout = self._run_setup_venv(venv_dir)
|
|
204
|
+
|
|
205
|
+
self.assertEqual(resolved_path, expected_path)
|
|
206
|
+
self.assertEqual(activated_path, expected_path)
|
|
207
|
+
self.assertIn(
|
|
208
|
+
"is incompatible with current Python/platform. Using",
|
|
209
|
+
stdout)
|
|
210
|
+
self.assertTrue(
|
|
211
|
+
os.path.exists(
|
|
212
|
+
os.path.join(
|
|
213
|
+
expected_path,
|
|
214
|
+
"bin",
|
|
215
|
+
"python")))
|
|
216
|
+
self.assertEqual(
|
|
217
|
+
self._compatibility_signature(
|
|
218
|
+
os.path.join(expected_path, "bin", "python")),
|
|
219
|
+
self._compatibility_signature(sys.executable),
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
if __name__ == "__main__":
|
|
224
|
+
unittest.main()
|
|
@@ -1,83 +0,0 @@
|
|
|
1
|
-
import subprocess
|
|
2
|
-
import platform
|
|
3
|
-
import sys
|
|
4
|
-
from pathlib import Path
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
REPO_ROOT = Path(__file__).resolve().parents[1]
|
|
8
|
-
INSTALLER_SCRIPT = REPO_ROOT / "docs" / "install" / "mlcflow_unix_installer.sh"
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
def _resolve_venv_dir(tmp_path, setup_snippet):
|
|
12
|
-
default_dir = tmp_path / "mlcflow"
|
|
13
|
-
marker = "__RESULT__"
|
|
14
|
-
command = f"""
|
|
15
|
-
set -euo pipefail
|
|
16
|
-
source "{INSTALLER_SCRIPT}"
|
|
17
|
-
DEFAULT_VENV_DIR="{default_dir}"
|
|
18
|
-
VENV_DIR="$DEFAULT_VENV_DIR"
|
|
19
|
-
PY_MAJOR_MINOR="$(python3 -c 'import sys; print("{{}}.{{}}".format(*sys.version_info[:2]))')"
|
|
20
|
-
PY_ARCH="$(python3 -c 'import platform; print(platform.machine())')"
|
|
21
|
-
{setup_snippet}
|
|
22
|
-
resolve_default_venv_dir
|
|
23
|
-
echo "{marker}$VENV_DIR"
|
|
24
|
-
"""
|
|
25
|
-
result = subprocess.run(
|
|
26
|
-
["bash", "-lc", command],
|
|
27
|
-
text=True,
|
|
28
|
-
capture_output=True,
|
|
29
|
-
check=True,
|
|
30
|
-
)
|
|
31
|
-
|
|
32
|
-
for line in reversed(result.stdout.splitlines()):
|
|
33
|
-
if line.startswith(marker):
|
|
34
|
-
return line[len(marker):]
|
|
35
|
-
|
|
36
|
-
raise AssertionError(
|
|
37
|
-
f"Could not parse resolved venv path from output:\n{result.stdout}")
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
def test_resolve_default_venv_dir_prefers_compatible_default(tmp_path):
|
|
41
|
-
setup_snippet = """
|
|
42
|
-
python3 -m venv "$DEFAULT_VENV_DIR"
|
|
43
|
-
"""
|
|
44
|
-
resolved = _resolve_venv_dir(tmp_path, setup_snippet)
|
|
45
|
-
assert resolved == str(tmp_path / "mlcflow")
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
def test_resolve_default_venv_dir_uses_suffix_for_incompatible_default(
|
|
49
|
-
tmp_path):
|
|
50
|
-
setup_snippet = """
|
|
51
|
-
mkdir -p "$DEFAULT_VENV_DIR"
|
|
52
|
-
"""
|
|
53
|
-
resolved = _resolve_venv_dir(tmp_path, setup_snippet)
|
|
54
|
-
assert resolved.startswith(str(tmp_path / "mlcflow_"))
|
|
55
|
-
assert "_py" in resolved
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
def test_resolve_default_venv_dir_removes_stale_shared_env_when_default_incompatible(
|
|
59
|
-
tmp_path):
|
|
60
|
-
"""When the default venv is incompatible and the suffixed venv already exists
|
|
61
|
-
but is itself stale/broken, the stale suffixed dir must be removed so that
|
|
62
|
-
setup_venv can create a fresh one rather than silently reusing a broken env."""
|
|
63
|
-
setup_snippet = """
|
|
64
|
-
mkdir -p "$DEFAULT_VENV_DIR"
|
|
65
|
-
SHARED_VENV_DIR="${DEFAULT_VENV_DIR}_${PY_ARCH}_py${PY_MAJOR_MINOR}"
|
|
66
|
-
mkdir -p "$SHARED_VENV_DIR"
|
|
67
|
-
"""
|
|
68
|
-
resolved = _resolve_venv_dir(tmp_path, setup_snippet)
|
|
69
|
-
expected_suffix = f"{tmp_path / 'mlcflow'}_{platform.machine()}_py{sys.version_info[0]}.{sys.version_info[1]}"
|
|
70
|
-
assert resolved == expected_suffix
|
|
71
|
-
# The stale shared dir should have been removed so setup_venv creates it
|
|
72
|
-
# fresh
|
|
73
|
-
assert not Path(expected_suffix).exists()
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
def test_resolve_default_venv_dir_reuses_compatible_suffixed_env(tmp_path):
|
|
77
|
-
setup_snippet = """
|
|
78
|
-
SHARED_VENV_DIR="${DEFAULT_VENV_DIR}_${PY_ARCH}_py${PY_MAJOR_MINOR}"
|
|
79
|
-
python3 -m venv "$SHARED_VENV_DIR"
|
|
80
|
-
"""
|
|
81
|
-
resolved = _resolve_venv_dir(tmp_path, setup_snippet)
|
|
82
|
-
expected = f"{tmp_path / 'mlcflow'}_{platform.machine()}_py{sys.version_info[0]}.{sys.version_info[1]}"
|
|
83
|
-
assert resolved == expected
|
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
import unittest
|
|
2
|
-
from unittest.mock import patch
|
|
3
|
-
|
|
4
|
-
from mlc.script_action import ScriptAction
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
class _Parent:
|
|
8
|
-
pass
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
class ScriptActionApptainerTest(unittest.TestCase):
|
|
12
|
-
def test_apptainer_delegates_to_script_module(self):
|
|
13
|
-
action = ScriptAction(_Parent())
|
|
14
|
-
run_args = {"tags": "detect,os"}
|
|
15
|
-
expected = {"return": 0}
|
|
16
|
-
|
|
17
|
-
with patch.object(
|
|
18
|
-
ScriptAction,
|
|
19
|
-
"call_script_module_function",
|
|
20
|
-
return_value=expected) as call_script_module_function:
|
|
21
|
-
result = action.apptainer(run_args)
|
|
22
|
-
|
|
23
|
-
self.assertEqual(result, expected)
|
|
24
|
-
call_script_module_function.assert_called_once_with(
|
|
25
|
-
"apptainer", run_args)
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
if __name__ == "__main__":
|
|
29
|
-
unittest.main()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|