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.
- {mlcflow-1.2.4 → mlcflow-1.2.5}/PKG-INFO +1 -1
- mlcflow-1.2.5/mlc/error_codes.py +209 -0
- {mlcflow-1.2.4 → mlcflow-1.2.5}/mlc/index.py +18 -11
- {mlcflow-1.2.4 → mlcflow-1.2.5}/mlc/main.py +18 -5
- {mlcflow-1.2.4 → mlcflow-1.2.5}/mlc/script_action.py +44 -4
- {mlcflow-1.2.4 → mlcflow-1.2.5}/mlcflow.egg-info/PKG-INFO +1 -1
- {mlcflow-1.2.4 → mlcflow-1.2.5}/mlcflow.egg-info/SOURCES.txt +3 -1
- {mlcflow-1.2.4 → mlcflow-1.2.5}/pyproject.toml +1 -1
- mlcflow-1.2.5/tests/test_action_invalid_meta_entries.py +165 -0
- mlcflow-1.2.5/tests/test_script_action_apptainer.py +29 -0
- mlcflow-1.2.4/mlc/error_codes.py +0 -77
- {mlcflow-1.2.4 → mlcflow-1.2.5}/LICENSE.md +0 -0
- {mlcflow-1.2.4 → mlcflow-1.2.5}/README.md +0 -0
- {mlcflow-1.2.4 → mlcflow-1.2.5}/mlc/__init__.py +0 -0
- {mlcflow-1.2.4 → mlcflow-1.2.5}/mlc/__main__.py +0 -0
- {mlcflow-1.2.4 → mlcflow-1.2.5}/mlc/action.py +0 -0
- {mlcflow-1.2.4 → mlcflow-1.2.5}/mlc/action_factory.py +0 -0
- {mlcflow-1.2.4 → mlcflow-1.2.5}/mlc/cache_action.py +0 -0
- {mlcflow-1.2.4 → mlcflow-1.2.5}/mlc/cfg_action.py +0 -0
- {mlcflow-1.2.4 → mlcflow-1.2.5}/mlc/experiment_action.py +0 -0
- {mlcflow-1.2.4 → mlcflow-1.2.5}/mlc/item.py +0 -0
- {mlcflow-1.2.4 → mlcflow-1.2.5}/mlc/logger.py +0 -0
- {mlcflow-1.2.4 → mlcflow-1.2.5}/mlc/meta_schema.py +0 -0
- {mlcflow-1.2.4 → mlcflow-1.2.5}/mlc/repo.py +0 -0
- {mlcflow-1.2.4 → mlcflow-1.2.5}/mlc/repo_action.py +0 -0
- {mlcflow-1.2.4 → mlcflow-1.2.5}/mlc/utils.py +0 -0
- {mlcflow-1.2.4 → mlcflow-1.2.5}/mlcflow.egg-info/dependency_links.txt +0 -0
- {mlcflow-1.2.4 → mlcflow-1.2.5}/mlcflow.egg-info/entry_points.txt +0 -0
- {mlcflow-1.2.4 → mlcflow-1.2.5}/mlcflow.egg-info/requires.txt +0 -0
- {mlcflow-1.2.4 → mlcflow-1.2.5}/mlcflow.egg-info/top_level.txt +0 -0
- {mlcflow-1.2.4 → mlcflow-1.2.5}/setup.cfg +0 -0
- {mlcflow-1.2.4 → mlcflow-1.2.5}/tests/test_cache_mark_tmp.py +0 -0
|
@@ -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', '
|
|
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
|
|
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.
|
|
29
|
-
10.
|
|
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
|
|
@@ -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/
|
|
27
|
+
tests/test_action_invalid_meta_entries.py
|
|
28
|
+
tests/test_cache_mark_tmp.py
|
|
29
|
+
tests/test_script_action_apptainer.py
|
|
@@ -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()
|
mlcflow-1.2.4/mlc/error_codes.py
DELETED
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|