mlcflow 1.2.4__tar.gz → 1.2.5__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 (32) hide show
  1. {mlcflow-1.2.4 → mlcflow-1.2.5}/PKG-INFO +1 -1
  2. mlcflow-1.2.5/mlc/error_codes.py +209 -0
  3. {mlcflow-1.2.4 → mlcflow-1.2.5}/mlc/index.py +18 -11
  4. {mlcflow-1.2.4 → mlcflow-1.2.5}/mlc/main.py +18 -5
  5. {mlcflow-1.2.4 → mlcflow-1.2.5}/mlc/script_action.py +44 -4
  6. {mlcflow-1.2.4 → mlcflow-1.2.5}/mlcflow.egg-info/PKG-INFO +1 -1
  7. {mlcflow-1.2.4 → mlcflow-1.2.5}/mlcflow.egg-info/SOURCES.txt +3 -1
  8. {mlcflow-1.2.4 → mlcflow-1.2.5}/pyproject.toml +1 -1
  9. mlcflow-1.2.5/tests/test_action_invalid_meta_entries.py +165 -0
  10. mlcflow-1.2.5/tests/test_script_action_apptainer.py +29 -0
  11. mlcflow-1.2.4/mlc/error_codes.py +0 -77
  12. {mlcflow-1.2.4 → mlcflow-1.2.5}/LICENSE.md +0 -0
  13. {mlcflow-1.2.4 → mlcflow-1.2.5}/README.md +0 -0
  14. {mlcflow-1.2.4 → mlcflow-1.2.5}/mlc/__init__.py +0 -0
  15. {mlcflow-1.2.4 → mlcflow-1.2.5}/mlc/__main__.py +0 -0
  16. {mlcflow-1.2.4 → mlcflow-1.2.5}/mlc/action.py +0 -0
  17. {mlcflow-1.2.4 → mlcflow-1.2.5}/mlc/action_factory.py +0 -0
  18. {mlcflow-1.2.4 → mlcflow-1.2.5}/mlc/cache_action.py +0 -0
  19. {mlcflow-1.2.4 → mlcflow-1.2.5}/mlc/cfg_action.py +0 -0
  20. {mlcflow-1.2.4 → mlcflow-1.2.5}/mlc/experiment_action.py +0 -0
  21. {mlcflow-1.2.4 → mlcflow-1.2.5}/mlc/item.py +0 -0
  22. {mlcflow-1.2.4 → mlcflow-1.2.5}/mlc/logger.py +0 -0
  23. {mlcflow-1.2.4 → mlcflow-1.2.5}/mlc/meta_schema.py +0 -0
  24. {mlcflow-1.2.4 → mlcflow-1.2.5}/mlc/repo.py +0 -0
  25. {mlcflow-1.2.4 → mlcflow-1.2.5}/mlc/repo_action.py +0 -0
  26. {mlcflow-1.2.4 → mlcflow-1.2.5}/mlc/utils.py +0 -0
  27. {mlcflow-1.2.4 → mlcflow-1.2.5}/mlcflow.egg-info/dependency_links.txt +0 -0
  28. {mlcflow-1.2.4 → mlcflow-1.2.5}/mlcflow.egg-info/entry_points.txt +0 -0
  29. {mlcflow-1.2.4 → mlcflow-1.2.5}/mlcflow.egg-info/requires.txt +0 -0
  30. {mlcflow-1.2.4 → mlcflow-1.2.5}/mlcflow.egg-info/top_level.txt +0 -0
  31. {mlcflow-1.2.4 → mlcflow-1.2.5}/setup.cfg +0 -0
  32. {mlcflow-1.2.4 → mlcflow-1.2.5}/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.5
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(
@@ -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.5
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,6 @@ 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_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.5"
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,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
File without changes