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.
- {mlcflow-1.2.4 → mlcflow-1.2.6}/PKG-INFO +1 -1
- mlcflow-1.2.6/mlc/error_codes.py +209 -0
- {mlcflow-1.2.4 → mlcflow-1.2.6}/mlc/index.py +18 -11
- {mlcflow-1.2.4 → mlcflow-1.2.6}/mlc/main.py +18 -5
- {mlcflow-1.2.4 → mlcflow-1.2.6}/mlc/repo_action.py +80 -15
- {mlcflow-1.2.4 → mlcflow-1.2.6}/mlc/script_action.py +44 -4
- {mlcflow-1.2.4 → mlcflow-1.2.6}/mlcflow.egg-info/PKG-INFO +1 -1
- {mlcflow-1.2.4 → mlcflow-1.2.6}/mlcflow.egg-info/SOURCES.txt +4 -1
- {mlcflow-1.2.4 → mlcflow-1.2.6}/pyproject.toml +1 -1
- mlcflow-1.2.6/tests/test_action_invalid_meta_entries.py +165 -0
- mlcflow-1.2.6/tests/test_mlcflow_unix_installer.py +83 -0
- mlcflow-1.2.6/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.6}/LICENSE.md +0 -0
- {mlcflow-1.2.4 → mlcflow-1.2.6}/README.md +0 -0
- {mlcflow-1.2.4 → mlcflow-1.2.6}/mlc/__init__.py +0 -0
- {mlcflow-1.2.4 → mlcflow-1.2.6}/mlc/__main__.py +0 -0
- {mlcflow-1.2.4 → mlcflow-1.2.6}/mlc/action.py +0 -0
- {mlcflow-1.2.4 → mlcflow-1.2.6}/mlc/action_factory.py +0 -0
- {mlcflow-1.2.4 → mlcflow-1.2.6}/mlc/cache_action.py +0 -0
- {mlcflow-1.2.4 → mlcflow-1.2.6}/mlc/cfg_action.py +0 -0
- {mlcflow-1.2.4 → mlcflow-1.2.6}/mlc/experiment_action.py +0 -0
- {mlcflow-1.2.4 → mlcflow-1.2.6}/mlc/item.py +0 -0
- {mlcflow-1.2.4 → mlcflow-1.2.6}/mlc/logger.py +0 -0
- {mlcflow-1.2.4 → mlcflow-1.2.6}/mlc/meta_schema.py +0 -0
- {mlcflow-1.2.4 → mlcflow-1.2.6}/mlc/repo.py +0 -0
- {mlcflow-1.2.4 → mlcflow-1.2.6}/mlc/utils.py +0 -0
- {mlcflow-1.2.4 → mlcflow-1.2.6}/mlcflow.egg-info/dependency_links.txt +0 -0
- {mlcflow-1.2.4 → mlcflow-1.2.6}/mlcflow.egg-info/entry_points.txt +0 -0
- {mlcflow-1.2.4 → mlcflow-1.2.6}/mlcflow.egg-info/requires.txt +0 -0
- {mlcflow-1.2.4 → mlcflow-1.2.6}/mlcflow.egg-info/top_level.txt +0 -0
- {mlcflow-1.2.4 → mlcflow-1.2.6}/setup.cfg +0 -0
- {mlcflow-1.2.4 → mlcflow-1.2.6}/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(
|
|
@@ -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
|
|
366
|
-
clone_command = ['git', 'clone'
|
|
402
|
+
# Build clone command
|
|
403
|
+
clone_command = ['git', 'clone']
|
|
367
404
|
if branch:
|
|
368
|
-
clone_command
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
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
|
-
|
|
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
|
-
|
|
496
|
-
|
|
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(
|
|
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.
|
|
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,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/
|
|
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
|
|
@@ -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()
|
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
|