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.
Files changed (36) hide show
  1. {mlcflow-1.2.6 → mlcflow-1.2.8}/PKG-INFO +1 -1
  2. {mlcflow-1.2.6 → mlcflow-1.2.8}/mlc/action.py +8 -3
  3. {mlcflow-1.2.6 → mlcflow-1.2.8}/mlc/main.py +2 -2
  4. {mlcflow-1.2.6 → mlcflow-1.2.8}/mlc/repo_action.py +220 -33
  5. {mlcflow-1.2.6 → mlcflow-1.2.8}/mlc/script_action.py +2 -1
  6. {mlcflow-1.2.6 → mlcflow-1.2.8}/mlcflow.egg-info/PKG-INFO +1 -1
  7. {mlcflow-1.2.6 → mlcflow-1.2.8}/mlcflow.egg-info/SOURCES.txt +3 -1
  8. {mlcflow-1.2.6 → mlcflow-1.2.8}/pyproject.toml +1 -1
  9. mlcflow-1.2.8/tests/test_mlcflow_unix_installer.py +137 -0
  10. mlcflow-1.2.8/tests/test_repo_action_extra_git_args.py +20 -0
  11. mlcflow-1.2.8/tests/test_script_action_apptainer.py +54 -0
  12. mlcflow-1.2.8/tests/test_unix_installer_venv.py +224 -0
  13. mlcflow-1.2.6/tests/test_mlcflow_unix_installer.py +0 -83
  14. mlcflow-1.2.6/tests/test_script_action_apptainer.py +0 -29
  15. {mlcflow-1.2.6 → mlcflow-1.2.8}/LICENSE.md +0 -0
  16. {mlcflow-1.2.6 → mlcflow-1.2.8}/README.md +0 -0
  17. {mlcflow-1.2.6 → mlcflow-1.2.8}/mlc/__init__.py +0 -0
  18. {mlcflow-1.2.6 → mlcflow-1.2.8}/mlc/__main__.py +0 -0
  19. {mlcflow-1.2.6 → mlcflow-1.2.8}/mlc/action_factory.py +0 -0
  20. {mlcflow-1.2.6 → mlcflow-1.2.8}/mlc/cache_action.py +0 -0
  21. {mlcflow-1.2.6 → mlcflow-1.2.8}/mlc/cfg_action.py +0 -0
  22. {mlcflow-1.2.6 → mlcflow-1.2.8}/mlc/error_codes.py +0 -0
  23. {mlcflow-1.2.6 → mlcflow-1.2.8}/mlc/experiment_action.py +0 -0
  24. {mlcflow-1.2.6 → mlcflow-1.2.8}/mlc/index.py +0 -0
  25. {mlcflow-1.2.6 → mlcflow-1.2.8}/mlc/item.py +0 -0
  26. {mlcflow-1.2.6 → mlcflow-1.2.8}/mlc/logger.py +0 -0
  27. {mlcflow-1.2.6 → mlcflow-1.2.8}/mlc/meta_schema.py +0 -0
  28. {mlcflow-1.2.6 → mlcflow-1.2.8}/mlc/repo.py +0 -0
  29. {mlcflow-1.2.6 → mlcflow-1.2.8}/mlc/utils.py +0 -0
  30. {mlcflow-1.2.6 → mlcflow-1.2.8}/mlcflow.egg-info/dependency_links.txt +0 -0
  31. {mlcflow-1.2.6 → mlcflow-1.2.8}/mlcflow.egg-info/entry_points.txt +0 -0
  32. {mlcflow-1.2.6 → mlcflow-1.2.8}/mlcflow.egg-info/requires.txt +0 -0
  33. {mlcflow-1.2.6 → mlcflow-1.2.8}/mlcflow.egg-info/top_level.txt +0 -0
  34. {mlcflow-1.2.6 → mlcflow-1.2.8}/setup.cfg +0 -0
  35. {mlcflow-1.2.6 → mlcflow-1.2.8}/tests/test_action_invalid_meta_entries.py +0 -0
  36. {mlcflow-1.2.6 → mlcflow-1.2.8}/tests/test_cache_mark_tmp.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mlcflow
3
- Version: 1.2.6
3
+ Version: 1.2.8
4
4
  Summary: An automation interface tailored for CPU/GPU benchmarking
5
5
  Author-email: MLCommons <systems@mlcommons.org>
6
6
  License:
@@ -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
- # Step 3: Ask user for confirmation if multiple items are found
508
- user_input = input(
509
- f"{len(found_items)} items found. Do you want to update all? (yes/no): ").strip().lower()
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!r}. Must be a positive integer."}
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
- extra_args = shlex.split(str(extra_git_args))
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
- pull_command = ['git', '-C', repo_path, 'pull']
472
- if clone_depth is not None:
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
- pull_command,
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 = (e.stderr or e.stdout or str(e)).strip()
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": f"Force pull failed during git pull for {repo_path}: {pull_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
- pull_command = ['git', '-C', repo_path, 'pull']
540
- if clone_depth is not None:
541
- if self._is_shallow_repo(repo_path):
542
- pull_command += ['--depth', str(clone_depth)]
543
- else:
544
- logger.warning(
545
- f"--depth/--shallow ignored for pull on non-shallow repo {repo_path}. "
546
- "Re-clone with --shallow to create a shallow copy."
547
- )
548
- subprocess.run(pull_command, check=True)
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 **while cloning** a new repository.
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
 
@@ -244,7 +244,8 @@ Main Script Meta:""")
244
244
  "automation": "repo",
245
245
  "action": "pull",
246
246
  "repo": "mlcommons@mlperf-automations",
247
- "branch": "dev"
247
+ "branch": "dev",
248
+ "fast_forward_only": True
248
249
  })
249
250
 
250
251
  if result['return'] == 0:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mlcflow
3
- Version: 1.2.6
3
+ Version: 1.2.8
4
4
  Summary: An automation interface tailored for CPU/GPU benchmarking
5
5
  Author-email: MLCommons <systems@mlcommons.org>
6
6
  License:
@@ -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/test_script_action_apptainer.py
30
+ tests/test_repo_action_extra_git_args.py
31
+ tests/test_script_action_apptainer.py
32
+ tests/test_unix_installer_venv.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "mlcflow"
7
- version = "1.2.6"
7
+ version = "1.2.8"
8
8
 
9
9
  description = "An automation interface tailored for CPU/GPU benchmarking"
10
10
  authors = [
@@ -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