fractal-server 2.2.0a1__py3-none-any.whl → 2.3.0__py3-none-any.whl
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.
- fractal_server/__init__.py +1 -1
- fractal_server/app/models/v1/state.py +1 -2
- fractal_server/app/routes/admin/v1.py +2 -2
- fractal_server/app/routes/admin/v2.py +2 -2
- fractal_server/app/routes/api/v1/job.py +2 -2
- fractal_server/app/routes/api/v1/task_collection.py +4 -4
- fractal_server/app/routes/api/v2/__init__.py +23 -3
- fractal_server/app/routes/api/v2/job.py +2 -2
- fractal_server/app/routes/api/v2/submit.py +6 -0
- fractal_server/app/routes/api/v2/task_collection.py +74 -34
- fractal_server/app/routes/api/v2/task_collection_custom.py +170 -0
- fractal_server/app/routes/api/v2/task_collection_ssh.py +125 -0
- fractal_server/app/routes/aux/_runner.py +10 -2
- fractal_server/app/runner/compress_folder.py +120 -0
- fractal_server/app/runner/executors/slurm/__init__.py +0 -3
- fractal_server/app/runner/executors/slurm/_batching.py +0 -1
- fractal_server/app/runner/executors/slurm/_slurm_config.py +9 -9
- fractal_server/app/runner/executors/slurm/ssh/__init__.py +3 -0
- fractal_server/app/runner/executors/slurm/ssh/_executor_wait_thread.py +112 -0
- fractal_server/app/runner/executors/slurm/ssh/_slurm_job.py +120 -0
- fractal_server/app/runner/executors/slurm/ssh/executor.py +1488 -0
- fractal_server/app/runner/executors/slurm/sudo/__init__.py +3 -0
- fractal_server/app/runner/executors/slurm/{_check_jobs_status.py → sudo/_check_jobs_status.py} +1 -1
- fractal_server/app/runner/executors/slurm/{_executor_wait_thread.py → sudo/_executor_wait_thread.py} +1 -1
- fractal_server/app/runner/executors/slurm/{_subprocess_run_as_user.py → sudo/_subprocess_run_as_user.py} +1 -1
- fractal_server/app/runner/executors/slurm/{executor.py → sudo/executor.py} +12 -12
- fractal_server/app/runner/extract_archive.py +38 -0
- fractal_server/app/runner/v1/__init__.py +78 -40
- fractal_server/app/runner/v1/_slurm/__init__.py +1 -1
- fractal_server/app/runner/v2/__init__.py +147 -62
- fractal_server/app/runner/v2/_local_experimental/__init__.py +22 -12
- fractal_server/app/runner/v2/_local_experimental/executor.py +12 -8
- fractal_server/app/runner/v2/_slurm/__init__.py +1 -6
- fractal_server/app/runner/v2/_slurm_ssh/__init__.py +125 -0
- fractal_server/app/runner/v2/_slurm_ssh/_submit_setup.py +83 -0
- fractal_server/app/runner/v2/_slurm_ssh/get_slurm_config.py +182 -0
- fractal_server/app/runner/v2/runner_functions_low_level.py +9 -11
- fractal_server/app/runner/versions.py +30 -0
- fractal_server/app/schemas/v1/__init__.py +1 -0
- fractal_server/app/schemas/{state.py → v1/state.py} +4 -21
- fractal_server/app/schemas/v2/__init__.py +4 -1
- fractal_server/app/schemas/v2/task_collection.py +101 -30
- fractal_server/config.py +184 -3
- fractal_server/main.py +27 -1
- fractal_server/ssh/__init__.py +4 -0
- fractal_server/ssh/_fabric.py +245 -0
- fractal_server/tasks/utils.py +12 -64
- fractal_server/tasks/v1/background_operations.py +2 -2
- fractal_server/tasks/{endpoint_operations.py → v1/endpoint_operations.py} +7 -12
- fractal_server/tasks/v1/utils.py +67 -0
- fractal_server/tasks/v2/_TaskCollectPip.py +61 -32
- fractal_server/tasks/v2/_venv_pip.py +195 -0
- fractal_server/tasks/v2/background_operations.py +257 -295
- fractal_server/tasks/v2/background_operations_ssh.py +317 -0
- fractal_server/tasks/v2/endpoint_operations.py +136 -0
- fractal_server/tasks/v2/templates/_1_create_venv.sh +46 -0
- fractal_server/tasks/v2/templates/_2_upgrade_pip.sh +30 -0
- fractal_server/tasks/v2/templates/_3_pip_install.sh +32 -0
- fractal_server/tasks/v2/templates/_4_pip_freeze.sh +21 -0
- fractal_server/tasks/v2/templates/_5_pip_show.sh +59 -0
- fractal_server/tasks/v2/utils.py +54 -0
- {fractal_server-2.2.0a1.dist-info → fractal_server-2.3.0.dist-info}/METADATA +4 -2
- {fractal_server-2.2.0a1.dist-info → fractal_server-2.3.0.dist-info}/RECORD +66 -42
- fractal_server/tasks/v2/get_collection_data.py +0 -14
- {fractal_server-2.2.0a1.dist-info → fractal_server-2.3.0.dist-info}/LICENSE +0 -0
- {fractal_server-2.2.0a1.dist-info → fractal_server-2.3.0.dist-info}/WHEEL +0 -0
- {fractal_server-2.2.0a1.dist-info → fractal_server-2.3.0.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,245 @@
|
|
1
|
+
import time
|
2
|
+
from contextlib import contextmanager
|
3
|
+
from threading import Lock
|
4
|
+
from typing import Any
|
5
|
+
from typing import Optional
|
6
|
+
|
7
|
+
from fabric import Connection
|
8
|
+
from fabric import Result
|
9
|
+
from invoke import UnexpectedExit
|
10
|
+
from paramiko.ssh_exception import NoValidConnectionsError
|
11
|
+
|
12
|
+
from ..logger import get_logger
|
13
|
+
from ..logger import set_logger
|
14
|
+
from fractal_server.config import get_settings
|
15
|
+
from fractal_server.syringe import Inject
|
16
|
+
|
17
|
+
logger = set_logger(__name__)
|
18
|
+
|
19
|
+
MAX_ATTEMPTS = 5
|
20
|
+
|
21
|
+
|
22
|
+
class TimeoutException(Exception):
|
23
|
+
pass
|
24
|
+
|
25
|
+
|
26
|
+
@contextmanager
|
27
|
+
def acquire_timeout(lock: Lock, timeout: int) -> Any:
|
28
|
+
logger.debug(f"Trying to acquire lock, with {timeout=}")
|
29
|
+
result = lock.acquire(timeout=timeout)
|
30
|
+
try:
|
31
|
+
if not result:
|
32
|
+
raise TimeoutException(
|
33
|
+
f"Failed to acquire lock within {timeout} seconds"
|
34
|
+
)
|
35
|
+
logger.debug("Lock was acquired.")
|
36
|
+
yield result
|
37
|
+
finally:
|
38
|
+
if result:
|
39
|
+
lock.release()
|
40
|
+
logger.debug("Lock was released")
|
41
|
+
|
42
|
+
|
43
|
+
class FractalSSH(object):
|
44
|
+
lock: Lock
|
45
|
+
connection: Connection
|
46
|
+
default_timeout: int
|
47
|
+
|
48
|
+
# FIXME SSH: maybe extend the actual_timeout logic to other methods
|
49
|
+
|
50
|
+
def __init__(self, connection: Connection, default_timeout: int = 250):
|
51
|
+
self.lock = Lock()
|
52
|
+
self.conn = connection
|
53
|
+
self.default_timeout = default_timeout
|
54
|
+
|
55
|
+
@property
|
56
|
+
def is_connected(self) -> bool:
|
57
|
+
return self.conn.is_connected
|
58
|
+
|
59
|
+
def put(self, *args, timeout: Optional[int] = None, **kwargs) -> Result:
|
60
|
+
actual_timeout = timeout or self.default_timeout
|
61
|
+
with acquire_timeout(self.lock, timeout=actual_timeout):
|
62
|
+
return self.conn.put(*args, **kwargs)
|
63
|
+
|
64
|
+
def get(self, *args, **kwargs) -> Result:
|
65
|
+
with acquire_timeout(self.lock, timeout=self.default_timeout):
|
66
|
+
return self.conn.get(*args, **kwargs)
|
67
|
+
|
68
|
+
def run(self, *args, **kwargs) -> Any:
|
69
|
+
with acquire_timeout(self.lock, timeout=self.default_timeout):
|
70
|
+
return self.conn.run(*args, **kwargs)
|
71
|
+
|
72
|
+
def close(self):
|
73
|
+
return self.conn.close()
|
74
|
+
|
75
|
+
def sftp(self):
|
76
|
+
return self.conn.sftp()
|
77
|
+
|
78
|
+
def check_connection(self) -> None:
|
79
|
+
"""
|
80
|
+
Open the SSH connection and handle exceptions.
|
81
|
+
|
82
|
+
This function can be called from within other functions that use
|
83
|
+
`connection`, so that we can provide a meaningful error in case the
|
84
|
+
SSH connection cannot be opened.
|
85
|
+
"""
|
86
|
+
if not self.conn.is_connected:
|
87
|
+
try:
|
88
|
+
self.conn.open()
|
89
|
+
except Exception as e:
|
90
|
+
raise RuntimeError(
|
91
|
+
f"Cannot open SSH connection (original error: '{str(e)}')."
|
92
|
+
)
|
93
|
+
|
94
|
+
|
95
|
+
def get_ssh_connection(
|
96
|
+
*,
|
97
|
+
host: Optional[str] = None,
|
98
|
+
user: Optional[str] = None,
|
99
|
+
key_filename: Optional[str] = None,
|
100
|
+
) -> Connection:
|
101
|
+
"""
|
102
|
+
Create a `fabric.Connection` object based on fractal-server settings
|
103
|
+
or explicit arguments.
|
104
|
+
|
105
|
+
Args:
|
106
|
+
host:
|
107
|
+
user:
|
108
|
+
key_filename:
|
109
|
+
|
110
|
+
Returns:
|
111
|
+
Fabric connection object
|
112
|
+
"""
|
113
|
+
settings = Inject(get_settings)
|
114
|
+
if host is None:
|
115
|
+
host = settings.FRACTAL_SLURM_SSH_HOST
|
116
|
+
if user is None:
|
117
|
+
user = settings.FRACTAL_SLURM_SSH_USER
|
118
|
+
if key_filename is None:
|
119
|
+
key_filename = settings.FRACTAL_SLURM_SSH_PRIVATE_KEY_PATH
|
120
|
+
|
121
|
+
connection = Connection(
|
122
|
+
host=host,
|
123
|
+
user=user,
|
124
|
+
connect_kwargs={"key_filename": key_filename},
|
125
|
+
)
|
126
|
+
logger.debug(f"Now created {connection=}.")
|
127
|
+
return connection
|
128
|
+
|
129
|
+
|
130
|
+
def run_command_over_ssh(
|
131
|
+
*,
|
132
|
+
cmd: str,
|
133
|
+
fractal_ssh: FractalSSH,
|
134
|
+
max_attempts: int = MAX_ATTEMPTS,
|
135
|
+
base_interval: float = 3.0,
|
136
|
+
) -> str:
|
137
|
+
"""
|
138
|
+
Run a command within an open SSH connection.
|
139
|
+
|
140
|
+
Args:
|
141
|
+
cmd: Command to be run
|
142
|
+
fractal_ssh: FractalSSH connection object with custom lock
|
143
|
+
|
144
|
+
Returns:
|
145
|
+
Standard output of the command, if successful.
|
146
|
+
"""
|
147
|
+
t_0 = time.perf_counter()
|
148
|
+
ind_attempt = 0
|
149
|
+
while ind_attempt <= max_attempts:
|
150
|
+
ind_attempt += 1
|
151
|
+
prefix = f"[attempt {ind_attempt}/{max_attempts}]"
|
152
|
+
logger.info(f"{prefix} START running '{cmd}' over SSH.")
|
153
|
+
try:
|
154
|
+
# Case 1: Command runs successfully
|
155
|
+
res = fractal_ssh.run(cmd, hide=True)
|
156
|
+
t_1 = time.perf_counter()
|
157
|
+
logger.info(
|
158
|
+
f"{prefix} END running '{cmd}' over SSH, "
|
159
|
+
f"elapsed {t_1-t_0:.3f}"
|
160
|
+
)
|
161
|
+
logger.debug(f"STDOUT: {res.stdout}")
|
162
|
+
logger.debug(f"STDERR: {res.stderr}")
|
163
|
+
return res.stdout
|
164
|
+
except NoValidConnectionsError as e:
|
165
|
+
# Case 2: Command fails with a connection error
|
166
|
+
logger.warning(
|
167
|
+
f"{prefix} Running command `{cmd}` over SSH failed.\n"
|
168
|
+
f"Original NoValidConnectionError:\n{str(e)}.\n"
|
169
|
+
f"{e.errors=}\n"
|
170
|
+
)
|
171
|
+
if ind_attempt < max_attempts:
|
172
|
+
sleeptime = base_interval**ind_attempt
|
173
|
+
logger.warning(
|
174
|
+
f"{prefix} Now sleep {sleeptime:.3f} seconds and continue."
|
175
|
+
)
|
176
|
+
time.sleep(sleeptime)
|
177
|
+
continue
|
178
|
+
else:
|
179
|
+
logger.error(f"{prefix} Reached last attempt")
|
180
|
+
break
|
181
|
+
except UnexpectedExit as e:
|
182
|
+
# Case 3: Command fails with an actual error
|
183
|
+
error_msg = (
|
184
|
+
f"{prefix} Running command `{cmd}` over SSH failed.\n"
|
185
|
+
f"Original error:\n{str(e)}."
|
186
|
+
)
|
187
|
+
logger.error(error_msg)
|
188
|
+
raise ValueError(error_msg)
|
189
|
+
except Exception as e:
|
190
|
+
logger.error(
|
191
|
+
f"Running command `{cmd}` over SSH failed.\n"
|
192
|
+
f"Original Error:\n{str(e)}."
|
193
|
+
)
|
194
|
+
raise e
|
195
|
+
|
196
|
+
raise ValueError(
|
197
|
+
f"Reached last attempt ({max_attempts=}) for running '{cmd}' over SSH"
|
198
|
+
)
|
199
|
+
|
200
|
+
|
201
|
+
def put_over_ssh(
|
202
|
+
*,
|
203
|
+
local: str,
|
204
|
+
remote: str,
|
205
|
+
fractal_ssh: FractalSSH,
|
206
|
+
logger_name: Optional[str] = None,
|
207
|
+
) -> None:
|
208
|
+
"""
|
209
|
+
Transfer a file via SSH
|
210
|
+
|
211
|
+
Args:
|
212
|
+
local: Local path to file
|
213
|
+
remote: Target path on remote host
|
214
|
+
fractal_ssh: FractalSSH connection object with custom lock
|
215
|
+
logger_name: Name of the logger
|
216
|
+
|
217
|
+
"""
|
218
|
+
try:
|
219
|
+
fractal_ssh.put(local=local, remote=remote)
|
220
|
+
except Exception as e:
|
221
|
+
logger = get_logger(logger_name=logger_name)
|
222
|
+
logger.error(
|
223
|
+
f"Transferring {local=} to {remote=} over SSH failed.\n"
|
224
|
+
f"Original Error:\n{str(e)}."
|
225
|
+
)
|
226
|
+
raise e
|
227
|
+
|
228
|
+
|
229
|
+
def _mkdir_over_ssh(
|
230
|
+
*, folder: str, fractal_ssh: FractalSSH, parents: bool = True
|
231
|
+
) -> None:
|
232
|
+
"""
|
233
|
+
Create a folder remotely via SSH.
|
234
|
+
|
235
|
+
Args:
|
236
|
+
folder:
|
237
|
+
fractal_ssh:
|
238
|
+
parents:
|
239
|
+
"""
|
240
|
+
# FIXME SSH: try using `mkdir` method of `paramiko.SFTPClient`
|
241
|
+
if parents:
|
242
|
+
cmd = f"mkdir -p {folder}"
|
243
|
+
else:
|
244
|
+
cmd = f"mkdir {folder}"
|
245
|
+
run_command_over_ssh(cmd=cmd, fractal_ssh=fractal_ssh)
|
fractal_server/tasks/utils.py
CHANGED
@@ -1,42 +1,12 @@
|
|
1
1
|
import re
|
2
|
-
import shutil
|
3
|
-
import sys
|
4
2
|
from pathlib import Path
|
5
|
-
from typing import Optional
|
6
3
|
|
7
4
|
from fractal_server.config import get_settings
|
8
|
-
from fractal_server.logger import get_logger
|
9
5
|
from fractal_server.syringe import Inject
|
10
|
-
from fractal_server.utils import execute_command
|
11
6
|
|
12
7
|
COLLECTION_FILENAME = "collection.json"
|
13
8
|
COLLECTION_LOG_FILENAME = "collection.log"
|
14
|
-
|
15
|
-
|
16
|
-
def get_python_interpreter(version: Optional[str] = None) -> str:
|
17
|
-
"""
|
18
|
-
Return the path to the python interpreter
|
19
|
-
|
20
|
-
Args:
|
21
|
-
version: Python version
|
22
|
-
|
23
|
-
Raises:
|
24
|
-
ValueError: If the python version requested is not available on the
|
25
|
-
host.
|
26
|
-
|
27
|
-
Returns:
|
28
|
-
interpreter: string representing the python executable or its path
|
29
|
-
"""
|
30
|
-
if version:
|
31
|
-
interpreter = shutil.which(f"python{version}")
|
32
|
-
if not interpreter:
|
33
|
-
raise ValueError(
|
34
|
-
f"Python version {version} not available on host."
|
35
|
-
)
|
36
|
-
else:
|
37
|
-
interpreter = sys.executable
|
38
|
-
|
39
|
-
return interpreter
|
9
|
+
COLLECTION_FREEZE_FILENAME = "collection_freeze.txt"
|
40
10
|
|
41
11
|
|
42
12
|
def slugify_task_name(task_name: str) -> str:
|
@@ -63,6 +33,10 @@ def get_log_path(base: Path) -> Path:
|
|
63
33
|
return base / COLLECTION_LOG_FILENAME
|
64
34
|
|
65
35
|
|
36
|
+
def get_freeze_path(base: Path) -> Path:
|
37
|
+
return base / COLLECTION_FREEZE_FILENAME
|
38
|
+
|
39
|
+
|
66
40
|
def get_collection_log(venv_path: Path) -> str:
|
67
41
|
package_path = get_absolute_venv_path(venv_path)
|
68
42
|
log_path = get_log_path(package_path)
|
@@ -70,6 +44,13 @@ def get_collection_log(venv_path: Path) -> str:
|
|
70
44
|
return log
|
71
45
|
|
72
46
|
|
47
|
+
def get_collection_freeze(venv_path: Path) -> str:
|
48
|
+
package_path = get_absolute_venv_path(venv_path)
|
49
|
+
freeze_path = get_freeze_path(package_path)
|
50
|
+
freeze = freeze_path.open().read()
|
51
|
+
return freeze
|
52
|
+
|
53
|
+
|
73
54
|
def _normalize_package_name(name: str) -> str:
|
74
55
|
"""
|
75
56
|
Implement PyPa specifications for package-name normalization
|
@@ -86,36 +67,3 @@ def _normalize_package_name(name: str) -> str:
|
|
86
67
|
The normalized package name.
|
87
68
|
"""
|
88
69
|
return re.sub(r"[-_.]+", "-", name).lower()
|
89
|
-
|
90
|
-
|
91
|
-
async def _init_venv(
|
92
|
-
*,
|
93
|
-
path: Path,
|
94
|
-
python_version: Optional[str] = None,
|
95
|
-
logger_name: str,
|
96
|
-
) -> Path:
|
97
|
-
"""
|
98
|
-
Set a virtual environment at `path/venv`
|
99
|
-
|
100
|
-
Args:
|
101
|
-
path : Path
|
102
|
-
path to directory in which to set up the virtual environment
|
103
|
-
python_version : default=None
|
104
|
-
Python version the virtual environment will be based upon
|
105
|
-
|
106
|
-
Returns:
|
107
|
-
python_bin : Path
|
108
|
-
path to python interpreter
|
109
|
-
"""
|
110
|
-
logger = get_logger(logger_name)
|
111
|
-
logger.debug(f"[_init_venv] {path=}")
|
112
|
-
interpreter = get_python_interpreter(version=python_version)
|
113
|
-
logger.debug(f"[_init_venv] {interpreter=}")
|
114
|
-
await execute_command(
|
115
|
-
cwd=path,
|
116
|
-
command=f"{interpreter} -m venv venv",
|
117
|
-
logger_name=logger_name,
|
118
|
-
)
|
119
|
-
python_bin = path / "venv/bin/python"
|
120
|
-
logger.debug(f"[_init_venv] {python_bin=}")
|
121
|
-
return python_bin
|
@@ -6,13 +6,13 @@ import json
|
|
6
6
|
from pathlib import Path
|
7
7
|
from shutil import rmtree as shell_rmtree
|
8
8
|
|
9
|
-
from ..utils import _init_venv
|
10
9
|
from ..utils import _normalize_package_name
|
11
10
|
from ..utils import get_collection_log
|
12
11
|
from ..utils import get_collection_path
|
13
12
|
from ..utils import get_log_path
|
14
13
|
from ..utils import slugify_task_name
|
15
14
|
from ._TaskCollectPip import _TaskCollectPip
|
15
|
+
from .utils import _init_venv_v1
|
16
16
|
from fractal_server.app.db import DBSyncSession
|
17
17
|
from fractal_server.app.db import get_sync_db
|
18
18
|
from fractal_server.app.models.v1 import State
|
@@ -168,7 +168,7 @@ async def _create_venv_install_package(
|
|
168
168
|
task_pkg.package_name = _normalize_package_name(task_pkg.package_name)
|
169
169
|
task_pkg.package = _normalize_package_name(task_pkg.package)
|
170
170
|
|
171
|
-
python_bin = await
|
171
|
+
python_bin = await _init_venv_v1(
|
172
172
|
path=path,
|
173
173
|
python_version=task_pkg.python_version,
|
174
174
|
logger_name=logger_name,
|
@@ -4,12 +4,10 @@ from typing import Optional
|
|
4
4
|
from typing import Union
|
5
5
|
from zipfile import ZipFile
|
6
6
|
|
7
|
-
from
|
8
|
-
from .
|
9
|
-
from .
|
10
|
-
from .v2._TaskCollectPip import _TaskCollectPip as _TaskCollectPipV2
|
7
|
+
from ..utils import _normalize_package_name
|
8
|
+
from ._TaskCollectPip import _TaskCollectPip as _TaskCollectPipV1
|
9
|
+
from .utils import get_python_interpreter_v1
|
11
10
|
from fractal_server.app.schemas.v1 import ManifestV1
|
12
|
-
from fractal_server.app.schemas.v2 import ManifestV2
|
13
11
|
from fractal_server.config import get_settings
|
14
12
|
from fractal_server.logger import get_logger
|
15
13
|
from fractal_server.syringe import Inject
|
@@ -21,13 +19,13 @@ FRACTAL_PUBLIC_TASK_SUBDIR = ".fractal"
|
|
21
19
|
|
22
20
|
async def download_package(
|
23
21
|
*,
|
24
|
-
task_pkg:
|
22
|
+
task_pkg: _TaskCollectPipV1,
|
25
23
|
dest: Union[str, Path],
|
26
24
|
) -> Path:
|
27
25
|
"""
|
28
26
|
Download package to destination
|
29
27
|
"""
|
30
|
-
interpreter =
|
28
|
+
interpreter = get_python_interpreter_v1(version=task_pkg.python_version)
|
31
29
|
pip = f"{interpreter} -m pip"
|
32
30
|
version = (
|
33
31
|
f"=={task_pkg.package_version}" if task_pkg.package_version else ""
|
@@ -43,7 +41,7 @@ async def download_package(
|
|
43
41
|
|
44
42
|
def _load_manifest_from_wheel(
|
45
43
|
path: Path, wheel: ZipFile, logger_name: Optional[str] = None
|
46
|
-
) ->
|
44
|
+
) -> ManifestV1:
|
47
45
|
logger = get_logger(logger_name)
|
48
46
|
namelist = wheel.namelist()
|
49
47
|
try:
|
@@ -60,9 +58,6 @@ def _load_manifest_from_wheel(
|
|
60
58
|
if manifest_version == "1":
|
61
59
|
pkg_manifest = ManifestV1(**manifest_dict)
|
62
60
|
return pkg_manifest
|
63
|
-
elif manifest_version == "2":
|
64
|
-
pkg_manifest = ManifestV2(**manifest_dict)
|
65
|
-
return pkg_manifest
|
66
61
|
else:
|
67
62
|
msg = f"Manifest version {manifest_version=} not supported"
|
68
63
|
logger.error(msg)
|
@@ -140,7 +135,7 @@ def inspect_package(path: Path, logger_name: Optional[str] = None) -> dict:
|
|
140
135
|
|
141
136
|
def create_package_dir_pip(
|
142
137
|
*,
|
143
|
-
task_pkg:
|
138
|
+
task_pkg: _TaskCollectPipV1,
|
144
139
|
create: bool = True,
|
145
140
|
) -> Path:
|
146
141
|
"""
|
@@ -0,0 +1,67 @@
|
|
1
|
+
from pathlib import Path
|
2
|
+
from typing import Optional
|
3
|
+
|
4
|
+
from fractal_server.logger import get_logger
|
5
|
+
from fractal_server.utils import execute_command
|
6
|
+
|
7
|
+
|
8
|
+
def get_python_interpreter_v1(version: Optional[str] = None) -> str:
|
9
|
+
"""
|
10
|
+
Return the path to the python interpreter
|
11
|
+
|
12
|
+
Args:
|
13
|
+
version: Python version
|
14
|
+
|
15
|
+
Raises:
|
16
|
+
ValueError: If the python version requested is not available on the
|
17
|
+
host.
|
18
|
+
|
19
|
+
Returns:
|
20
|
+
interpreter: string representing the python executable or its path
|
21
|
+
"""
|
22
|
+
import shutil
|
23
|
+
import sys
|
24
|
+
|
25
|
+
if version:
|
26
|
+
interpreter = shutil.which(f"python{version}")
|
27
|
+
if not interpreter:
|
28
|
+
raise ValueError(
|
29
|
+
f"Python version {version} not available on host."
|
30
|
+
)
|
31
|
+
else:
|
32
|
+
interpreter = sys.executable
|
33
|
+
|
34
|
+
return interpreter
|
35
|
+
|
36
|
+
|
37
|
+
async def _init_venv_v1(
|
38
|
+
*,
|
39
|
+
path: Path,
|
40
|
+
python_version: Optional[str] = None,
|
41
|
+
logger_name: str,
|
42
|
+
) -> Path:
|
43
|
+
"""
|
44
|
+
Set a virtual environment at `path/venv`
|
45
|
+
|
46
|
+
Args:
|
47
|
+
path : Path
|
48
|
+
path to directory in which to set up the virtual environment
|
49
|
+
python_version : default=None
|
50
|
+
Python version the virtual environment will be based upon
|
51
|
+
|
52
|
+
Returns:
|
53
|
+
python_bin : Path
|
54
|
+
path to python interpreter
|
55
|
+
"""
|
56
|
+
logger = get_logger(logger_name)
|
57
|
+
logger.debug(f"[_init_venv] {path=}")
|
58
|
+
interpreter = get_python_interpreter_v1(version=python_version)
|
59
|
+
logger.debug(f"[_init_venv] {interpreter=}")
|
60
|
+
await execute_command(
|
61
|
+
cwd=path,
|
62
|
+
command=f"{interpreter} -m venv venv",
|
63
|
+
logger_name=logger_name,
|
64
|
+
)
|
65
|
+
python_bin = path / "venv/bin/python"
|
66
|
+
logger.debug(f"[_init_venv] {python_bin=}")
|
67
|
+
return python_bin
|
@@ -1,56 +1,90 @@
|
|
1
|
+
import logging
|
1
2
|
from pathlib import Path
|
2
3
|
from typing import Optional
|
3
4
|
|
5
|
+
from pydantic import BaseModel
|
6
|
+
from pydantic import Extra
|
7
|
+
from pydantic import Field
|
4
8
|
from pydantic import root_validator
|
9
|
+
from pydantic import validator
|
5
10
|
|
11
|
+
from fractal_server.app.schemas._validators import valdictkeys
|
12
|
+
from fractal_server.app.schemas._validators import valstr
|
6
13
|
from fractal_server.app.schemas.v2 import ManifestV2
|
7
|
-
from fractal_server.
|
14
|
+
from fractal_server.tasks.utils import _normalize_package_name
|
15
|
+
from fractal_server.tasks.v2.utils import _parse_wheel_filename
|
8
16
|
|
9
17
|
|
10
|
-
class _TaskCollectPip(
|
18
|
+
class _TaskCollectPip(BaseModel, extra=Extra.forbid):
|
11
19
|
"""
|
12
|
-
Internal
|
20
|
+
Internal task-collection model.
|
13
21
|
|
14
|
-
|
22
|
+
This model is similar to the API request-body model (`TaskCollectPip`), but
|
23
|
+
with enough differences that we keep them separated (and they do not have a
|
24
|
+
common base).
|
15
25
|
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
26
|
+
Attributes:
|
27
|
+
package: Either a PyPI package name or the absolute path to a wheel
|
28
|
+
file.
|
29
|
+
package_name: The actual normalized name of the package, which is set
|
30
|
+
internally through a validator.
|
31
|
+
package_version: Package version. For local packages, it is set
|
32
|
+
internally through a validator.
|
22
33
|
"""
|
23
34
|
|
24
|
-
|
35
|
+
package: str
|
36
|
+
package_name: str
|
37
|
+
python_version: str
|
38
|
+
package_extras: Optional[str] = None
|
39
|
+
pinned_package_versions: dict[str, str] = Field(default_factory=dict)
|
40
|
+
package_version: Optional[str] = None
|
25
41
|
package_path: Optional[Path] = None
|
26
42
|
package_manifest: Optional[ManifestV2] = None
|
27
43
|
|
44
|
+
_pinned_package_versions = validator(
|
45
|
+
"pinned_package_versions", allow_reuse=True
|
46
|
+
)(valdictkeys("pinned_package_versions"))
|
47
|
+
_package_extras = validator("package_extras", allow_reuse=True)(
|
48
|
+
valstr("package_extras")
|
49
|
+
)
|
50
|
+
_python_version = validator("python_version", allow_reuse=True)(
|
51
|
+
valstr("python_version")
|
52
|
+
)
|
53
|
+
|
28
54
|
@property
|
29
55
|
def is_local_package(self) -> bool:
|
30
56
|
return bool(self.package_path)
|
31
57
|
|
32
58
|
@root_validator(pre=True)
|
33
|
-
def
|
59
|
+
def set_package_info(cls, values):
|
34
60
|
"""
|
35
|
-
|
36
|
-
|
37
|
-
In this case, the user is providing directly a package file, rather
|
38
|
-
than a remote one from PyPI. We set the `package_path` attribute and
|
39
|
-
get the actual package name and version from the package file name.
|
61
|
+
Depending on whether the package is a local wheel file or a PyPI
|
62
|
+
package, set some of its metadata.
|
40
63
|
"""
|
41
64
|
if "/" in values["package"]:
|
65
|
+
# Local package: parse wheel filename
|
42
66
|
package_path = Path(values["package"])
|
43
67
|
if not package_path.is_absolute():
|
44
68
|
raise ValueError("Package path must be absolute")
|
45
|
-
if package_path.exists():
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
69
|
+
if not package_path.exists():
|
70
|
+
logging.warning(
|
71
|
+
f"Package {package_path} does not exist locally."
|
72
|
+
)
|
73
|
+
values["package_path"] = package_path
|
74
|
+
wheel_metadata = _parse_wheel_filename(package_path.name)
|
75
|
+
values["package_name"] = _normalize_package_name(
|
76
|
+
wheel_metadata["distribution"]
|
77
|
+
)
|
78
|
+
values["package_version"] = wheel_metadata["version"]
|
79
|
+
else:
|
80
|
+
# Remote package: use `package` as `package_name`
|
81
|
+
_package = values["package"]
|
82
|
+
if _package.endswith(".whl"):
|
83
|
+
raise ValueError(
|
84
|
+
f"ERROR: package={_package} ends with '.whl' "
|
85
|
+
"but it is not the absolute path to a wheel file."
|
86
|
+
)
|
87
|
+
values["package_name"] = _normalize_package_name(values["package"])
|
54
88
|
return values
|
55
89
|
|
56
90
|
@property
|
@@ -74,10 +108,7 @@ class _TaskCollectPip(TaskCollectPipV2):
|
|
74
108
|
collection_type = "pip_remote"
|
75
109
|
|
76
110
|
package_extras = self.package_extras or ""
|
77
|
-
|
78
|
-
python_version = f"py{self.python_version}"
|
79
|
-
else:
|
80
|
-
python_version = "" # FIXME: can we allow this?
|
111
|
+
python_version = f"py{self.python_version}"
|
81
112
|
|
82
113
|
source = ":".join(
|
83
114
|
(
|
@@ -95,8 +126,6 @@ class _TaskCollectPip(TaskCollectPipV2):
|
|
95
126
|
Verify that the package has all attributes that are needed to continue
|
96
127
|
with task collection
|
97
128
|
"""
|
98
|
-
if not self.package_name:
|
99
|
-
raise ValueError("`package_name` attribute is not set")
|
100
129
|
if not self.package_version:
|
101
130
|
raise ValueError("`package_version` attribute is not set")
|
102
131
|
if not self.package_manifest:
|