mlcflow 1.2.4__tar.gz → 1.2.6__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 (33) hide show
  1. {mlcflow-1.2.4 → mlcflow-1.2.6}/PKG-INFO +1 -1
  2. mlcflow-1.2.6/mlc/error_codes.py +209 -0
  3. {mlcflow-1.2.4 → mlcflow-1.2.6}/mlc/index.py +18 -11
  4. {mlcflow-1.2.4 → mlcflow-1.2.6}/mlc/main.py +18 -5
  5. {mlcflow-1.2.4 → mlcflow-1.2.6}/mlc/repo_action.py +80 -15
  6. {mlcflow-1.2.4 → mlcflow-1.2.6}/mlc/script_action.py +44 -4
  7. {mlcflow-1.2.4 → mlcflow-1.2.6}/mlcflow.egg-info/PKG-INFO +1 -1
  8. {mlcflow-1.2.4 → mlcflow-1.2.6}/mlcflow.egg-info/SOURCES.txt +4 -1
  9. {mlcflow-1.2.4 → mlcflow-1.2.6}/pyproject.toml +1 -1
  10. mlcflow-1.2.6/tests/test_action_invalid_meta_entries.py +165 -0
  11. mlcflow-1.2.6/tests/test_mlcflow_unix_installer.py +83 -0
  12. mlcflow-1.2.6/tests/test_script_action_apptainer.py +29 -0
  13. mlcflow-1.2.4/mlc/error_codes.py +0 -77
  14. {mlcflow-1.2.4 → mlcflow-1.2.6}/LICENSE.md +0 -0
  15. {mlcflow-1.2.4 → mlcflow-1.2.6}/README.md +0 -0
  16. {mlcflow-1.2.4 → mlcflow-1.2.6}/mlc/__init__.py +0 -0
  17. {mlcflow-1.2.4 → mlcflow-1.2.6}/mlc/__main__.py +0 -0
  18. {mlcflow-1.2.4 → mlcflow-1.2.6}/mlc/action.py +0 -0
  19. {mlcflow-1.2.4 → mlcflow-1.2.6}/mlc/action_factory.py +0 -0
  20. {mlcflow-1.2.4 → mlcflow-1.2.6}/mlc/cache_action.py +0 -0
  21. {mlcflow-1.2.4 → mlcflow-1.2.6}/mlc/cfg_action.py +0 -0
  22. {mlcflow-1.2.4 → mlcflow-1.2.6}/mlc/experiment_action.py +0 -0
  23. {mlcflow-1.2.4 → mlcflow-1.2.6}/mlc/item.py +0 -0
  24. {mlcflow-1.2.4 → mlcflow-1.2.6}/mlc/logger.py +0 -0
  25. {mlcflow-1.2.4 → mlcflow-1.2.6}/mlc/meta_schema.py +0 -0
  26. {mlcflow-1.2.4 → mlcflow-1.2.6}/mlc/repo.py +0 -0
  27. {mlcflow-1.2.4 → mlcflow-1.2.6}/mlc/utils.py +0 -0
  28. {mlcflow-1.2.4 → mlcflow-1.2.6}/mlcflow.egg-info/dependency_links.txt +0 -0
  29. {mlcflow-1.2.4 → mlcflow-1.2.6}/mlcflow.egg-info/entry_points.txt +0 -0
  30. {mlcflow-1.2.4 → mlcflow-1.2.6}/mlcflow.egg-info/requires.txt +0 -0
  31. {mlcflow-1.2.4 → mlcflow-1.2.6}/mlcflow.egg-info/top_level.txt +0 -0
  32. {mlcflow-1.2.4 → mlcflow-1.2.6}/setup.cfg +0 -0
  33. {mlcflow-1.2.4 → mlcflow-1.2.6}/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.4
3
+ Version: 1.2.6
4
4
  Summary: An automation interface tailored for CPU/GPU benchmarking
5
5
  Author-email: MLCommons <systems@mlcommons.org>
6
6
  License:
@@ -0,0 +1,209 @@
1
+ import re
2
+ from enum import Enum, auto
3
+
4
+
5
+ class ErrorCode(Enum):
6
+ """Enum class for error codes in MLCFlow"""
7
+ # General errors (2000-2007)
8
+ AUTOMATION_SCRIPT_NOT_FOUND = (
9
+ 2000, "The specified automation script was not found")
10
+ PATH_DOES_NOT_EXIST = (2001, "Provided path does not exists")
11
+ FILE_NOT_FOUND = (2002, "Required file was not found")
12
+ PERMISSION_DENIED = (2003, "Insufficient permission to execute the script")
13
+ IO_Error = (2004, "File I/O operation failed")
14
+ AUTOMATION_CUSTOM_ERROR = (2005, "Custom error triggered by the script")
15
+ UNSUPPORTED_OS = (
16
+ 2006, "The Operating System is not supported by the script")
17
+ MISSING_ENV_VARIABLE = (2007, "Required environment variables are missing")
18
+
19
+ def __init__(self, code, description):
20
+ self.code = code
21
+ self.description = description
22
+
23
+
24
+ ERROR_CODES = {error.code for error in ErrorCode}
25
+ ERROR_CODE_PATTERNS = [
26
+ re.compile(r'(?:error|exit|return)\s+code\s*[:=]?\s*(\d+)', re.IGNORECASE),
27
+ re.compile(r'\[errno\s+(\d+)\]', re.IGNORECASE),
28
+ re.compile(r'signal\s+(\d+)', re.IGNORECASE),
29
+ ]
30
+
31
+
32
+ class WarningCode(Enum):
33
+ """Enum class for warning codes in MLCFlow"""
34
+ # General warnings (1000-1007)
35
+ IO_WARNING = (1000, "File I/O operation warning")
36
+ AUTOMATION_SCRIPT_NOT_TESTED = (
37
+ 1001,
38
+ "the script is not tested on the current operatinig system or is in a development state")
39
+ AUTOMATION_SCRIPT_SKIPPED = (
40
+ 1002, "The script has been skipped during execution")
41
+ AUTOMATION_CUSTOM_ERROR = (1003, "Custom warning triggered by the script")
42
+ NON_INTERACTIVE_ENV = (1004, "Non interactive environment detected")
43
+ ELEVATED_PERMISSION_NEEDED = (1005, "Elevated permission needed")
44
+ EMPTY_TARGET = (1006, "The specified target is empty")
45
+
46
+ def __init__(self, code, description):
47
+ self.code = code
48
+ self.description = description
49
+
50
+
51
+ def get_error_info(error_code):
52
+ """Get the error message for a given error code"""
53
+ try:
54
+ return {"error_code": ErrorCode(
55
+ error_code).code, "error_message": ErrorCode(error_code).description}
56
+ except ValueError:
57
+ return f"Unknown error code: {error_code}"
58
+
59
+
60
+ def get_warning_info(warning_code):
61
+ """Get the warning message for a given warning code"""
62
+ try:
63
+ return {"warning_code": WarningCode(
64
+ warning_code).code, "warning_message": WarningCode(warning_code).description}
65
+ except ValueError:
66
+ return f"Unknown warning code: {warning_code}"
67
+
68
+
69
+ def is_warning_code(code):
70
+ """Check if a given code is a warning code"""
71
+ try:
72
+ # Check if code is in warning range (2000-2399)
73
+ if 2000 <= code <= 2399:
74
+ WarningCode(code)
75
+ return True
76
+ return False
77
+ except ValueError:
78
+ return False
79
+
80
+
81
+ def is_error_code(code):
82
+ """Check if a given code is an error code"""
83
+ try:
84
+ # Check if code is in error range (1000-1399)
85
+ if 1000 <= code <= 1399:
86
+ ErrorCode(code)
87
+ return True
88
+ return False
89
+ except ValueError:
90
+ return False
91
+
92
+
93
+ def get_code_type(code):
94
+ """Get the type of a code (error or warning)"""
95
+ if is_error_code(code):
96
+ return "error"
97
+ elif is_warning_code(code):
98
+ return "warning"
99
+ else:
100
+ return "unknown"
101
+
102
+
103
+ def _normalize_code(code):
104
+ """Convert a code value to int when possible."""
105
+ if code is None:
106
+ return None
107
+ if isinstance(code, int):
108
+ return code
109
+ try:
110
+ return int(str(code).strip())
111
+ except (TypeError, ValueError):
112
+ return None
113
+
114
+
115
+ def detect_error_code(return_code=None, error_message=""):
116
+ """Detect the most useful error code from a return value or error text."""
117
+ normalized_return_code = _normalize_code(return_code)
118
+ message = error_message or ""
119
+
120
+ for pattern in ERROR_CODE_PATTERNS:
121
+ match = pattern.search(message)
122
+ if match:
123
+ detected = _normalize_code(match.group(1))
124
+ # Prefer the code extracted from the error text when the outer
125
+ # result only carries a generic status such as 1.
126
+ if normalized_return_code in (None, 0, 1):
127
+ return detected
128
+
129
+ return normalized_return_code
130
+
131
+
132
+ def get_error_guidance(return_code=None, error_message=""):
133
+ """Return actionable guidance for known error codes and failure patterns."""
134
+ error_code = detect_error_code(return_code, error_message)
135
+ guidance = {
136
+ "error_code": error_code,
137
+ "error_message": None,
138
+ "suggestions": [],
139
+ }
140
+
141
+ if error_code in ERROR_CODES:
142
+ error_info = get_error_info(error_code)
143
+ if isinstance(error_info, dict):
144
+ guidance["error_message"] = error_info["error_message"]
145
+
146
+ message = (error_message or "").lower()
147
+
148
+ if ("no space left on device" in message or
149
+ "disk full" in message or
150
+ "not enough space" in message):
151
+ guidance["error_message"] = guidance["error_message"] or \
152
+ "Likely disk space exhaustion while running the script"
153
+ guidance["suggestions"] = [
154
+ "Free disk space in the work/cache directories and retry the command.",
155
+ "Remove old artifacts or caches if they are no longer needed.",
156
+ ]
157
+ # 139 = 128 + SIGSEGV(11), while some tools report the raw signal number
158
+ # 11 or its negative form -11.
159
+ elif ("segmentation fault" in message or error_code in [139, -11, 11]):
160
+ guidance["error_message"] = guidance["error_message"] or \
161
+ "A native program crashed with a segmentation fault"
162
+ guidance["suggestions"] = [
163
+ "Rerun with verbose logs to identify which native command crashed.",
164
+ "Check native dependencies, compiler/runtime compatibility, and input files.",
165
+ ]
166
+ # Common downloader/network failure codes used by curl and similar tools:
167
+ # 6/7 resolve/connect failures, 28 timeout, 35 TLS/connect issue,
168
+ # 56 connection reset/read failure, 60 certificate validation failure.
169
+ elif ("network" in message or
170
+ "connection" in message or
171
+ "timed out" in message or
172
+ "temporary failure in name resolution" in message or
173
+ "could not resolve host" in message or
174
+ error_code in [6, 7, 28, 35, 56, 60]):
175
+ guidance["error_message"] = guidance["error_message"] or \
176
+ "Likely network or download failure while running the script"
177
+ guidance["suggestions"] = [
178
+ "Check internet connectivity, proxy/firewall settings, and remote endpoint availability.",
179
+ "Retry the command after verifying the network connection.",
180
+ ]
181
+ elif error_code == 126:
182
+ guidance["error_message"] = guidance["error_message"] or \
183
+ "Command found but it could not be executed"
184
+ guidance["suggestions"] = [
185
+ "Check file permissions and whether the target command is executable.",
186
+ ]
187
+ elif error_code == 127:
188
+ guidance["error_message"] = guidance["error_message"] or \
189
+ "Command not found during script execution"
190
+ guidance["suggestions"] = [
191
+ "Verify that the required tool is installed and available on PATH.",
192
+ ]
193
+ elif error_code == 137:
194
+ guidance["error_message"] = guidance["error_message"] or \
195
+ "Process was terminated, often due to out-of-memory or a kill signal"
196
+ guidance["suggestions"] = [
197
+ "Check system memory limits and retry with fewer parallel jobs if possible.",
198
+ ]
199
+ elif error_code == 130:
200
+ guidance["error_message"] = guidance["error_message"] or \
201
+ "The command was interrupted by the user or the environment"
202
+ guidance["suggestions"] = [
203
+ "Retry the command if the interruption was unexpected.",
204
+ ]
205
+
206
+ if not guidance["error_message"] and not guidance["suggestions"]:
207
+ return None
208
+
209
+ return guidance
@@ -232,8 +232,12 @@ class Index:
232
232
 
233
233
  if os.path.isfile(yaml_path):
234
234
  config_path = yaml_path
235
+ with open(config_path, "r") as f:
236
+ data = yaml.safe_load(f) or {}
235
237
  elif os.path.isfile(json_path):
236
238
  config_path = json_path
239
+ with open(config_path, "r") as f:
240
+ data = json.load(f) or {}
237
241
  else:
238
242
  # No config file found, remove from index if exists
239
243
  delete_flag = False
@@ -257,6 +261,7 @@ class Index:
257
261
  if delete_flag:
258
262
  changed = True
259
263
  continue
264
+
260
265
  if current_item_keys is not None:
261
266
  current_item_keys.add(config_path)
262
267
  mtime = self.get_item_mtime(config_path)
@@ -266,6 +271,19 @@ class Index:
266
271
  if old_mtime == mtime and not repos_changed:
267
272
  continue
268
273
 
274
+ # Validate script meta against schema during indexing if meta
275
+ # changed or repos changed
276
+ if folder_type == "script":
277
+ # logger.debug(f"-----Meta changed for {automation_path}, validating against schema... -----")
278
+ errors, warnings = validate_meta(data, config_path)
279
+ for e in errors:
280
+ logger.error(f"Meta validation error: {e}")
281
+ for w in warnings:
282
+ logger.debug(f"Meta validation warning: {w}")
283
+ if errors:
284
+ raise ValueError(
285
+ f"Meta validation failed for {config_path}. Fix the above error(s) and try again.")
286
+
269
287
  self.modified_times[config_path] = {
270
288
  "mtime": mtime,
271
289
  "date_time": datetime.fromtimestamp(mtime).strftime("%Y-%m-%d %H:%M:%S")
@@ -400,17 +418,6 @@ class Index:
400
418
  tags = data.get("tags", [])
401
419
  alias = data.get("alias", None)
402
420
 
403
- # Validate script meta against schema during indexing
404
- if folder_type == "script":
405
- errors, warnings = validate_meta(data, config_file)
406
- for e in errors:
407
- logger.error(f"Meta validation error: {e}")
408
- for w in warnings:
409
- logger.debug(f"Meta validation warning: {w}")
410
- if errors:
411
- raise ValueError(
412
- f"Meta validation failed for {config_file}. Fix the above error(s) and try again.")
413
-
414
421
  # Remove stale entry for the same meta file path if exists
415
422
  self._delete_index_entries(folder_type, "path", folder_path)
416
423
 
@@ -155,6 +155,7 @@ def _report_error(e):
155
155
  script_name = e.script_name
156
156
  repo_alias = e.repo_alias
157
157
  run_args = e.run_args
158
+ error_guidance = e.error_guidance or {}
158
159
 
159
160
  if script_name:
160
161
  # Build rerun command with user-facing inputs only
@@ -181,6 +182,18 @@ def _report_error(e):
181
182
  logger.error(f"Failed script: {script_name}")
182
183
  logger.error(f"To rerun just the failed part: {rerun_cmd}")
183
184
 
185
+ if error_guidance:
186
+ error_code = error_guidance.get("error_code")
187
+ if error_code is not None:
188
+ logger.error(f"Detected error code: {error_code}")
189
+
190
+ error_message = error_guidance.get("error_message")
191
+ if error_message:
192
+ logger.error(f"Likely cause: {error_message}")
193
+
194
+ for suggestion in error_guidance.get("suggestions", []):
195
+ logger.error(f"Suggestion: {suggestion}")
196
+
184
197
  if e.version_info_file:
185
198
  logger.error(f"Dependency versions: {e.version_info_file}")
186
199
 
@@ -377,7 +390,7 @@ def build_parser(pre_args):
377
390
  reindex_parser.add_argument('extra', nargs=argparse.REMAINDER)
378
391
 
379
392
  # Script-only
380
- for action in ['docker', 'docker-run', 'apptainer',
393
+ for action in ['docker', 'docker-run', 'apptainer', 'apptainer-run',
381
394
  'experiment', 'remote-run', 'remote-experiment',
382
395
  'remote-docker', 'doc', 'lint']:
383
396
  p = subparsers.add_parser(action, add_help=False)
@@ -419,8 +432,8 @@ def build_run_args(args):
419
432
  if args.command in ['pull', 'rm', 'add', 'find'] and args.target == "repo":
420
433
  run_args['repo'] = args.details
421
434
 
422
- if args.command in ['docker', 'docker-run', 'apptainer', 'experiment',
423
- 'remote-run', 'remote-experiment',
435
+ if args.command in ['docker', 'docker-run', 'apptainer', 'apptainer-run',
436
+ 'experiment', 'remote-run', 'remote-experiment',
424
437
  'remote-docker', 'doc', 'lint'] and args.target == "run":
425
438
  # run_args['target'] = 'script' #dont modify this as script might have
426
439
  # target as in input
@@ -494,7 +507,7 @@ def main():
494
507
 
495
508
  | Target | Actions |
496
509
  |---------|-----------------------------------------------------------|
497
- | script | run, find/search, rm, mv, cp, add, test, docker-run, show |
510
+ | script | run, find/search, rm, mv, cp, add, test, docker-run, apptainer/apptainer-run, show |
498
511
  | cache | find/search, rm, show, list, prune, mark-tmp |
499
512
  | repo | pull, search, rm, list, find/search |
500
513
 
@@ -547,7 +560,7 @@ def main():
547
560
  help_text = ""
548
561
  if pre_args.target == "run":
549
562
  if pre_args.action.startswith(
550
- "docker") or pre_args.action == "apptainer":
563
+ "docker") or pre_args.action in ("apptainer", "apptainer-run"):
551
564
  pre_args.target = "script"
552
565
  else:
553
566
  logger.error(
@@ -2,6 +2,7 @@ from .action import Action
2
2
  import os
3
3
  import subprocess
4
4
  import re
5
+ import shlex
5
6
  import yaml
6
7
  import json
7
8
  import shutil
@@ -322,8 +323,21 @@ class RepoAction(Action):
322
323
  return {"return": 0, "value": os.path.basename(
323
324
  url).replace(".git", "")}
324
325
 
326
+ def _is_shallow_repo(self, repo_path):
327
+ """Return True if the git repository at *repo_path* is a shallow clone."""
328
+ try:
329
+ result = subprocess.run(
330
+ ['git', '-C', repo_path, 'rev-parse', '--is-shallow-repository'],
331
+ capture_output=True,
332
+ text=True,
333
+ )
334
+ return result.returncode == 0 and result.stdout.strip() == 'true'
335
+ except (subprocess.SubprocessError, FileNotFoundError, OSError):
336
+ return False
337
+
325
338
  def pull_repo(self, repo_url, branch=None, checkout=None, tag=None,
326
- pat=None, ssh=None, ignore_on_conflict=False, repo_path=None, force=False):
339
+ pat=None, ssh=None, ignore_on_conflict=False, repo_path=None, force=False,
340
+ shallow=False, depth=None, extra_git_args=None):
327
341
 
328
342
  # Determine the checkout path from environment or default
329
343
  repo_base_path = self.repos_path # either the value will be from 'MLC_REPOS'
@@ -358,20 +372,41 @@ class RepoAction(Action):
358
372
  repo_path = os.path.join(repo_base_path, repo_download_name)
359
373
 
360
374
  try:
375
+ # Compute depth argument: --shallow implies depth=1; explicit
376
+ # --depth=N takes precedence
377
+ clone_depth = None
378
+ if depth is not None:
379
+ try:
380
+ clone_depth = int(depth)
381
+ except (TypeError, ValueError):
382
+ return {
383
+ "return": 1, "error": f"Invalid value for --depth: {depth!r}. Must be a positive integer."}
384
+ if clone_depth < 1:
385
+ return {
386
+ "return": 1, "error": f"Invalid value for --depth: {clone_depth}. Must be a positive integer."}
387
+ elif shallow:
388
+ clone_depth = 1
389
+
390
+ # Parse extra_git_args into a list
391
+ extra_args = []
392
+ if extra_git_args:
393
+ if isinstance(extra_git_args, list):
394
+ extra_args = extra_git_args
395
+ else:
396
+ extra_args = shlex.split(str(extra_git_args))
397
+
361
398
  # If the directory doesn't exist, clone it
362
399
  if not os.path.exists(repo_path):
363
400
  logger.info(f"Cloning repository {repo_url} to {repo_path}...")
364
401
 
365
- # Build clone command without branch if not provided
366
- clone_command = ['git', 'clone', repo_url, repo_path]
402
+ # Build clone command
403
+ clone_command = ['git', 'clone']
367
404
  if branch:
368
- clone_command = [
369
- 'git',
370
- 'clone',
371
- '--branch',
372
- branch,
373
- repo_url,
374
- repo_path]
405
+ clone_command += ['--branch', branch]
406
+ if clone_depth is not None:
407
+ clone_command += ['--depth', str(clone_depth)]
408
+ clone_command += extra_args
409
+ clone_command += [repo_url, repo_path]
375
410
 
376
411
  subprocess.run(clone_command, check=True)
377
412
 
@@ -433,8 +468,17 @@ class RepoAction(Action):
433
468
  logger.info(
434
469
  "Pulling latest changes...")
435
470
  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
+ )
436
480
  subprocess.run(
437
- ['git', '-C', repo_path, 'pull'],
481
+ pull_command,
438
482
  capture_output=True,
439
483
  text=True,
440
484
  check=True)
@@ -492,8 +536,16 @@ class RepoAction(Action):
492
536
  else:
493
537
  logger.info(
494
538
  "No local changes detected. Pulling latest changes...")
495
- subprocess.run(
496
- ['git', '-C', repo_path, 'pull'], check=True)
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)
497
549
  logger.info("Repository successfully pulled.")
498
550
 
499
551
  if tag:
@@ -562,6 +614,9 @@ class RepoAction(Action):
562
614
  - `--tag <release_tag>`: Checks out a particular release tag.
563
615
  - `--pat <access_token>` or `--ssh`: Clones a private repository using a personal access token or SSH.
564
616
  - `--force`: For existing repositories with local tracked changes, stashes changes before pull and reapplies them after pull.
617
+ - `--shallow`: Perform a shallow clone with `--depth=1` (fastest for a fresh copy without history). For existing repos, only applied if the repo is already shallow; otherwise ignored with a warning.
618
+ - `--depth=N`: Perform a shallow clone/pull with the specified history depth (e.g. `--depth=5`). For existing repos, `--depth` is only applied when the repository is already a shallow clone; passing `--depth` to a full-history clone would corrupt it and is therefore ignored with a warning.
619
+ - `--extra_git_args=<args>`: Pass additional arguments to the `git clone` command (e.g. `--extra_git_args="--filter=blob:none"`). Only applies when cloning a new repository; not used for pull on existing repos. Accepts only trusted input — arguments are passed directly to git without further validation.
565
620
 
566
621
  Example Output:
567
622
 
@@ -592,7 +647,11 @@ class RepoAction(Action):
592
647
  repo_object.path, os.W_OK):
593
648
  repo_folder_name = os.path.basename(repo_object.path)
594
649
  res = self.pull_repo(
595
- repo_folder_name, repo_path=repo_object.path, force=run_args.get('force'))
650
+ repo_folder_name, repo_path=repo_object.path, force=run_args.get(
651
+ 'force'),
652
+ shallow=run_args.get('shallow', False),
653
+ depth=run_args.get('depth'),
654
+ extra_git_args=run_args.get('extra_git_args'))
596
655
  if res['return'] > 0:
597
656
  return res
598
657
  else:
@@ -604,6 +663,9 @@ class RepoAction(Action):
604
663
  ssh = run_args.get('ssh')
605
664
  force = run_args.get('force')
606
665
  ignore_on_conflict = run_args.get('ignore_on_conflict')
666
+ shallow = run_args.get('shallow', False)
667
+ depth = run_args.get('depth')
668
+ extra_git_args = run_args.get('extra_git_args')
607
669
 
608
670
  if sum(bool(var) for var in [branch, checkout, tag]) > 1:
609
671
  return {
@@ -617,7 +679,10 @@ class RepoAction(Action):
617
679
  pat,
618
680
  ssh,
619
681
  ignore_on_conflict=ignore_on_conflict,
620
- force=force)
682
+ force=force,
683
+ shallow=shallow,
684
+ depth=depth,
685
+ extra_git_args=extra_git_args)
621
686
  if res['return'] > 0:
622
687
  return res
623
688
 
@@ -7,6 +7,7 @@ import json
7
7
  import inspect
8
8
  from .index import Index
9
9
  from . import utils
10
+ from .error_codes import get_error_guidance
10
11
  from .logger import logger
11
12
 
12
13
 
@@ -25,8 +26,9 @@ class ScriptAction(Action):
25
26
  6. Copy(cp)
26
27
  7. Run
27
28
  8. Docker
28
- 9. Test
29
- 10. Experiment
29
+ 9. Apptainer
30
+ 10. Test
31
+ 11. Experiment
30
32
 
31
33
  Scripts in MLCFlow can be identified using different methods:
32
34
 
@@ -284,6 +286,9 @@ Main Script Meta:""")
284
286
  elif function_name == "docker":
285
287
  result = automation_instance.docker(
286
288
  run_args) # Pass args to the run method
289
+ elif function_name == "apptainer":
290
+ result = automation_instance.apptainer(
291
+ run_args) # Pass args to the apptainer method
287
292
  elif function_name == "test":
288
293
  result = automation_instance.test(
289
294
  run_args) # Pass args to the run method
@@ -319,6 +324,8 @@ Main Script Meta:""")
319
324
 
320
325
  if result['return'] > 0:
321
326
  error = result.get('error', "")
327
+ error_guidance = get_error_guidance(
328
+ result.get('error_code', result.get('return')), error)
322
329
  _name_match = re.search(r'name\s*=\s*([^,)]+)', error)
323
330
  _script_name = _name_match.group(1).strip() if _name_match else run_args.get(
324
331
  'tags', run_args.get('details'))
@@ -338,7 +345,10 @@ Main Script Meta:""")
338
345
  raise ScriptExecutionError(
339
346
  f"Script {function_name} execution failed in {module_path}. \nError : {error}",
340
347
  script_name=_script_name, repo_alias=_repo_alias, module_path=module_path,
341
- run_args=run_args, version_info_file=_version_info_file)
348
+ run_args=run_args, version_info_file=_version_info_file,
349
+ error_code=error_guidance.get(
350
+ 'error_code') if error_guidance else None,
351
+ error_guidance=error_guidance)
342
352
 
343
353
  if str(run_args.get("mlc_output")).lower() in [
344
354
  "on", "true", "yes", "1"]:
@@ -408,6 +418,33 @@ Main Script Meta:""")
408
418
 
409
419
  docker.__doc__ = docker_run.__doc__
410
420
 
421
+ def apptainer(self, run_args):
422
+ return self.apptainer_run(run_args)
423
+
424
+ def apptainer_run(self, run_args):
425
+ """
426
+ ####################################################################################################################
427
+ Target: Script
428
+ Action: Apptainer
429
+ ####################################################################################################################
430
+
431
+ The `apptainer` action runs scripts inside an Apptainer containerized environment.
432
+
433
+ An MLCFlow script can be executed inside Apptainer using either of the following syntaxes:
434
+
435
+ 1. Apptainer Run: mlc apptainer run --tags=<script tags> <run flags>
436
+ 2. Apptainer Script: mlc apptainer script --tags=<script tags> <run flags>
437
+
438
+ Example Command:
439
+
440
+ mlc apptainer script --tags=detect,os -j
441
+ mlca detect,os -j
442
+
443
+ """
444
+ return self.call_script_module_function("apptainer", run_args)
445
+
446
+ apptainer.__doc__ = apptainer_run.__doc__
447
+
411
448
  def remote_run(self, run_args):
412
449
  """
413
450
  ####################################################################################################################
@@ -680,10 +717,13 @@ Main Script Meta:""")
680
717
 
681
718
  class ScriptExecutionError(Exception):
682
719
  def __init__(self, message, script_name=None, repo_alias=None,
683
- module_path=None, run_args=None, version_info_file=None):
720
+ module_path=None, run_args=None, version_info_file=None,
721
+ error_code=None, error_guidance=None):
684
722
  super().__init__(message)
685
723
  self.script_name = script_name
686
724
  self.repo_alias = repo_alias
687
725
  self.module_path = module_path
688
726
  self.run_args = run_args or {}
689
727
  self.version_info_file = version_info_file
728
+ self.error_code = error_code
729
+ self.error_guidance = error_guidance
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mlcflow
3
- Version: 1.2.4
3
+ Version: 1.2.6
4
4
  Summary: An automation interface tailored for CPU/GPU benchmarking
5
5
  Author-email: MLCommons <systems@mlcommons.org>
6
6
  License:
@@ -24,4 +24,7 @@ mlcflow.egg-info/dependency_links.txt
24
24
  mlcflow.egg-info/entry_points.txt
25
25
  mlcflow.egg-info/requires.txt
26
26
  mlcflow.egg-info/top_level.txt
27
- tests/test_cache_mark_tmp.py
27
+ tests/test_action_invalid_meta_entries.py
28
+ tests/test_cache_mark_tmp.py
29
+ tests/test_mlcflow_unix_installer.py
30
+ tests/test_script_action_apptainer.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "mlcflow"
7
- version = "1.2.4"
7
+ version = "1.2.6"
8
8
 
9
9
  description = "An automation interface tailored for CPU/GPU benchmarking"
10
10
  authors = [
@@ -0,0 +1,165 @@
1
+ import json
2
+ import os
3
+ import tempfile
4
+ import unittest
5
+
6
+ import mlc.action as _mlc_action
7
+ from mlc.action import Action
8
+ from mlc.item import Item
9
+
10
+
11
+ class ActionInvalidMetaSearchTest(unittest.TestCase):
12
+
13
+ def setUp(self):
14
+ self.temp_dir = tempfile.TemporaryDirectory()
15
+ self.addCleanup(self.temp_dir.cleanup)
16
+
17
+ self.previous_cwd = os.getcwd()
18
+ self.addCleanup(os.chdir, self.previous_cwd)
19
+ os.chdir(self.temp_dir.name)
20
+
21
+ self.previous_mlc_repos = os.environ.get("MLC_REPOS")
22
+ self.addCleanup(self._restore_env)
23
+ os.environ["MLC_REPOS"] = os.path.join(self.temp_dir.name, "repos")
24
+
25
+ def _restore_env(self):
26
+ if self.previous_mlc_repos is None:
27
+ os.environ.pop("MLC_REPOS", None)
28
+ else:
29
+ os.environ["MLC_REPOS"] = self.previous_mlc_repos
30
+
31
+ def _new_action(self):
32
+ a = Action()
33
+ a.parent = None
34
+ return a
35
+
36
+ def _seed_stale_index_entry(
37
+ self, action, folder_name, uid="fake-uid-000", tags=None):
38
+ """
39
+ Bypass the filesystem scanner by directly injecting a stale cache index
40
+ entry that points to a directory with no meta.json. This replicates the
41
+ production scenario where the on-disk index JSON already contains the
42
+ entry from a previous run that crashed mid-download.
43
+ """
44
+ stale_dir = os.path.join(
45
+ self.temp_dir.name, "repos", "local", "cache", folder_name
46
+ )
47
+ os.makedirs(stale_dir, exist_ok=True)
48
+ # Leave only the incomplete-download marker — no meta.json.
49
+ with open(os.path.join(stale_dir, "mlc_temp_cache.json"), "w") as f:
50
+ json.dump({"status": "incomplete"}, f)
51
+
52
+ entry = {
53
+ "uid": uid,
54
+ "tags": tags or [],
55
+ "alias": folder_name,
56
+ "path": stale_dir,
57
+ "repo": "local",
58
+ }
59
+ action.get_index().indices["cache"] = [entry]
60
+ return stale_dir
61
+
62
+ def test_search_fetch_all_with_unknown_target_returns_empty_list(self):
63
+ action = self._new_action()
64
+
65
+ res = action.search({"target_name": "unknown", "fetch_all": True})
66
+
67
+ self.assertEqual(res["return"], 0)
68
+ self.assertEqual(res["list"], [])
69
+
70
+ def test_search_fetch_all_skips_item_with_invalid_meta(self):
71
+ action = self._new_action()
72
+ add_res = action.add({
73
+ "target_name": "cache",
74
+ "item": "bad-meta-cache",
75
+ "tags": "a,b",
76
+ })
77
+ self.assertEqual(add_res["return"], 0)
78
+
79
+ # Corrupt meta.json so it is not a dict.
80
+ with open(os.path.join(add_res["path"], "meta.json"), "w") as f:
81
+ json.dump([], f)
82
+
83
+ res = action.search({"target_name": "cache", "fetch_all": True})
84
+
85
+ self.assertEqual(res["return"], 0)
86
+ self.assertEqual(len(res["list"]), 0)
87
+
88
+ def test_search_alias_lookup_skips_item_with_invalid_meta(self):
89
+ action = self._new_action()
90
+ add_res = action.add({
91
+ "target_name": "cache",
92
+ "item": "bad-meta-alias",
93
+ "tags": "x,y",
94
+ })
95
+ self.assertEqual(add_res["return"], 0)
96
+
97
+ # Corrupt meta.json into a non-dict so it fails verification lookup
98
+ with open(os.path.join(add_res["path"], "meta.json"), "w") as f:
99
+ json.dump([], f)
100
+
101
+ res = action.search(
102
+ {"target_name": "cache", "details": "bad-meta-alias"})
103
+
104
+ self.assertEqual(res["return"], 0)
105
+ self.assertEqual(len(res["list"]), 0)
106
+
107
+ def test_search_alias_lookup_skips_corrupt_index_entry_without_meta(self):
108
+ """
109
+ Index entry points to a path that has no meta file at all.
110
+ Injected directly to bypass the filesystem scanner.
111
+ """
112
+ action = self._new_action()
113
+ self._seed_stale_index_entry(
114
+ action, "bad-whisper-cache", uid="not-a-real-uid", tags=["x", "y"]
115
+ )
116
+
117
+ res = action.search(
118
+ {"target_name": "cache", "details": "bad-whisper-cache"})
119
+
120
+ self.assertEqual(res["return"], 0)
121
+ self.assertEqual(len(res["list"]), 0)
122
+
123
+ def test_search_meta_validation_prevents_corrupt_items_in_alias_path(self):
124
+ """
125
+ Regression test for the Whisper inference stale-folder bug.
126
+ Regression test for the Whisper dataset stale-folder bug.
127
+ Simulates a crashed/incomplete download where the index entry persists but meta.json is missing,
128
+ ensuring the search guard safely skips it.
129
+ """
130
+ action = self._new_action()
131
+
132
+ self._seed_stale_index_entry(
133
+ action, "whisper-dataset", uid="fake-whisper-uid-123", tags=["x", "y"]
134
+ )
135
+
136
+ res = action.search(
137
+ {"target_name": "cache", "details": "whisper-dataset"})
138
+
139
+ self.assertEqual(res["return"], 0)
140
+ self.assertEqual(
141
+ len(res["list"]), 0,
142
+ msg="search() must skip cache items whose meta.json is absent or invalid.",
143
+ )
144
+
145
+ def test_search_tags_handles_missing_tags_key_in_index(self):
146
+ action = self._new_action()
147
+ add_res = action.add({
148
+ "target_name": "cache",
149
+ "item": "missing-tags-index",
150
+ "tags": "alpha,beta",
151
+ })
152
+ self.assertEqual(add_res["return"], 0)
153
+
154
+ cache_index = action.get_index().indices["cache"]
155
+ self.assertEqual(len(cache_index), 1)
156
+ cache_index[0].pop("tags", None)
157
+
158
+ res = action.search({"target_name": "cache", "tags": "alpha"})
159
+
160
+ self.assertEqual(res["return"], 0)
161
+ self.assertEqual(len(res["list"]), 0)
162
+
163
+
164
+ if __name__ == "__main__":
165
+ unittest.main()
@@ -0,0 +1,83 @@
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
@@ -0,0 +1,29 @@
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()
@@ -1,77 +0,0 @@
1
- from enum import Enum, auto
2
-
3
- class ErrorCode(Enum):
4
- """Enum class for error codes in MLCFlow"""
5
- # General errors (2000-2007)
6
- AUTOMATION_SCRIPT_NOT_FOUND = (2000, "The specified automation script was not found")
7
- PATH_DOES_NOT_EXIST = (2001, "Provided path does not exists")
8
- FILE_NOT_FOUND = (2002, "Required file was not found")
9
- PERMISSION_DENIED = (2003, "Insufficient permission to execute the script")
10
- IO_Error = (2004, "File I/O operation failed")
11
- AUTOMATION_CUSTOM_ERROR = (2005, "Custom error triggered by the script")
12
- UNSUPPORTED_OS = (2006, "The Operating System is not supported by the script")
13
- MISSING_ENV_VARIABLE = (2007, "Required environment variables are missing")
14
-
15
- def __init__(self, code, description):
16
- self.code = code
17
- self.description = description
18
-
19
- class WarningCode(Enum):
20
- """Enum class for warning codes in MLCFlow"""
21
- # General warnings (1000-1007)
22
- IO_WARNING = (1000, "File I/O operation warning")
23
- AUTOMATION_SCRIPT_NOT_TESTED = (1001, "the script is not tested on the current operatinig system or is in a development state")
24
- AUTOMATION_SCRIPT_SKIPPED = (1002, "The script has been skipped during execution")
25
- AUTOMATION_CUSTOM_ERROR = (1003, "Custom warning triggered by the script")
26
- NON_INTERACTIVE_ENV = (1004, "Non interactive environment detected")
27
- ELEVATED_PERMISSION_NEEDED = (1005, "Elevated permission needed")
28
- EMPTY_TARGET = (1006, "The specified target is empty")
29
-
30
- def __init__(self, code, description):
31
- self.code = code
32
- self.description = description
33
-
34
- def get_error_info(error_code):
35
- """Get the error message for a given error code"""
36
- try:
37
- return {"error_code": ErrorCode(error_code).code, "error_message": ErrorCode(error_code).description}
38
- except ValueError:
39
- return f"Unknown error code: {error_code}"
40
-
41
- def get_warning_info(warning_code):
42
- """Get the warning message for a given warning code"""
43
- try:
44
- return {"warning_code": WarningCode(warning_code).code, "warning_message": WarningCode(warning_code).description}
45
- except ValueError:
46
- return f"Unknown warning code: {warning_code}"
47
-
48
- def is_warning_code(code):
49
- """Check if a given code is a warning code"""
50
- try:
51
- # Check if code is in warning range (2000-2399)
52
- if 2000 <= code <= 2399:
53
- WarningCode(code)
54
- return True
55
- return False
56
- except ValueError:
57
- return False
58
-
59
- def is_error_code(code):
60
- """Check if a given code is an error code"""
61
- try:
62
- # Check if code is in error range (1000-1399)
63
- if 1000 <= code <= 1399:
64
- ErrorCode(code)
65
- return True
66
- return False
67
- except ValueError:
68
- return False
69
-
70
- def get_code_type(code):
71
- """Get the type of a code (error or warning)"""
72
- if is_error_code(code):
73
- return "error"
74
- elif is_warning_code(code):
75
- return "warning"
76
- else:
77
- return "unknown"
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