fractal-server 1.4.3a0__py3-none-any.whl → 1.4.3a1__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/db/__init__.py +36 -25
- fractal_server/app/routes/admin.py +8 -8
- fractal_server/app/routes/api/v1/_aux_functions.py +3 -5
- fractal_server/app/routes/api/v1/dataset.py +24 -23
- fractal_server/app/routes/api/v1/job.py +7 -7
- fractal_server/app/routes/api/v1/project.py +14 -19
- fractal_server/app/routes/api/v1/task.py +6 -6
- fractal_server/app/routes/api/v1/task_collection.py +12 -126
- fractal_server/app/routes/api/v1/workflow.py +13 -13
- fractal_server/app/routes/api/v1/workflowtask.py +5 -5
- fractal_server/app/routes/auth.py +2 -2
- fractal_server/app/runner/__init__.py +0 -1
- fractal_server/app/schemas/__init__.py +1 -0
- fractal_server/app/schemas/applyworkflow.py +5 -9
- fractal_server/app/schemas/task_collection.py +2 -10
- fractal_server/app/security/__init__.py +3 -3
- fractal_server/config.py +14 -0
- fractal_server/tasks/_TaskCollectPip.py +103 -0
- fractal_server/tasks/__init__.py +3 -1
- fractal_server/tasks/background_operations.py +384 -0
- fractal_server/tasks/endpoint_operations.py +167 -0
- fractal_server/tasks/utils.py +86 -0
- {fractal_server-1.4.3a0.dist-info → fractal_server-1.4.3a1.dist-info}/METADATA +1 -1
- {fractal_server-1.4.3a0.dist-info → fractal_server-1.4.3a1.dist-info}/RECORD +28 -25
- fractal_server/tasks/collection.py +0 -556
- {fractal_server-1.4.3a0.dist-info → fractal_server-1.4.3a1.dist-info}/LICENSE +0 -0
- {fractal_server-1.4.3a0.dist-info → fractal_server-1.4.3a1.dist-info}/WHEEL +0 -0
- {fractal_server-1.4.3a0.dist-info → fractal_server-1.4.3a1.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,384 @@
|
|
1
|
+
"""
|
2
|
+
The main function exported from this module is `background_collect_pip`, which
|
3
|
+
is used as a background task for the task-collection endpoint.
|
4
|
+
"""
|
5
|
+
import json
|
6
|
+
from pathlib import Path
|
7
|
+
from shutil import rmtree as shell_rmtree
|
8
|
+
from typing import Optional
|
9
|
+
|
10
|
+
from fractal_server.app.db import DBSyncSession
|
11
|
+
from fractal_server.app.db import get_sync_db
|
12
|
+
from fractal_server.app.models import State
|
13
|
+
from fractal_server.app.models import Task
|
14
|
+
from fractal_server.app.schemas import TaskCollectStatus
|
15
|
+
from fractal_server.app.schemas import TaskCreate
|
16
|
+
from fractal_server.app.schemas import TaskRead
|
17
|
+
from fractal_server.logger import close_logger
|
18
|
+
from fractal_server.logger import get_logger
|
19
|
+
from fractal_server.logger import set_logger
|
20
|
+
from fractal_server.tasks._TaskCollectPip import _TaskCollectPip
|
21
|
+
from fractal_server.tasks.utils import _normalize_package_name
|
22
|
+
from fractal_server.tasks.utils import get_collection_log
|
23
|
+
from fractal_server.tasks.utils import get_collection_path
|
24
|
+
from fractal_server.tasks.utils import get_log_path
|
25
|
+
from fractal_server.tasks.utils import get_python_interpreter
|
26
|
+
from fractal_server.tasks.utils import slugify_task_name
|
27
|
+
from fractal_server.utils import execute_command
|
28
|
+
|
29
|
+
|
30
|
+
async def _init_venv(
|
31
|
+
*,
|
32
|
+
path: Path,
|
33
|
+
python_version: Optional[str] = None,
|
34
|
+
logger_name: str,
|
35
|
+
) -> Path:
|
36
|
+
"""
|
37
|
+
Set a virtual environment at `path/venv`
|
38
|
+
|
39
|
+
Args:
|
40
|
+
path : Path
|
41
|
+
path to directory in which to set up the virtual environment
|
42
|
+
python_version : default=None
|
43
|
+
Python version the virtual environment will be based upon
|
44
|
+
|
45
|
+
Returns:
|
46
|
+
python_bin : Path
|
47
|
+
path to python interpreter
|
48
|
+
"""
|
49
|
+
logger = get_logger(logger_name)
|
50
|
+
logger.debug(f"[_init_venv] {path=}")
|
51
|
+
interpreter = get_python_interpreter(version=python_version)
|
52
|
+
logger.debug(f"[_init_venv] {interpreter=}")
|
53
|
+
await execute_command(
|
54
|
+
cwd=path,
|
55
|
+
command=f"{interpreter} -m venv venv",
|
56
|
+
logger_name=logger_name,
|
57
|
+
)
|
58
|
+
python_bin = path / "venv/bin/python"
|
59
|
+
logger.debug(f"[_init_venv] {python_bin=}")
|
60
|
+
return python_bin
|
61
|
+
|
62
|
+
|
63
|
+
async def _pip_install(
|
64
|
+
venv_path: Path,
|
65
|
+
task_pkg: _TaskCollectPip,
|
66
|
+
logger_name: str,
|
67
|
+
) -> Path:
|
68
|
+
"""
|
69
|
+
Install package in venv
|
70
|
+
|
71
|
+
Args:
|
72
|
+
venv_path:
|
73
|
+
task_pkg:
|
74
|
+
logger_name:
|
75
|
+
|
76
|
+
Returns:
|
77
|
+
The location of the package.
|
78
|
+
"""
|
79
|
+
|
80
|
+
logger = get_logger(logger_name)
|
81
|
+
|
82
|
+
pip = venv_path / "venv/bin/pip"
|
83
|
+
|
84
|
+
extras = f"[{task_pkg.package_extras}]" if task_pkg.package_extras else ""
|
85
|
+
|
86
|
+
if task_pkg.is_local_package:
|
87
|
+
pip_install_str = f"{task_pkg.package_path.as_posix()}{extras}"
|
88
|
+
else:
|
89
|
+
version_string = (
|
90
|
+
f"=={task_pkg.package_version}" if task_pkg.package_version else ""
|
91
|
+
)
|
92
|
+
pip_install_str = f"{task_pkg.package}{extras}{version_string}"
|
93
|
+
|
94
|
+
cmd_install = f"{pip} install {pip_install_str}"
|
95
|
+
cmd_inspect = f"{pip} show {task_pkg.package}"
|
96
|
+
|
97
|
+
await execute_command(
|
98
|
+
cwd=venv_path,
|
99
|
+
command=f"{pip} install --upgrade pip",
|
100
|
+
logger_name=logger_name,
|
101
|
+
)
|
102
|
+
await execute_command(
|
103
|
+
cwd=venv_path, command=cmd_install, logger_name=logger_name
|
104
|
+
)
|
105
|
+
if task_pkg.pinned_package_versions:
|
106
|
+
for (
|
107
|
+
pinned_pkg_name,
|
108
|
+
pinned_pkg_version,
|
109
|
+
) in task_pkg.pinned_package_versions.items():
|
110
|
+
|
111
|
+
logger.debug(
|
112
|
+
"Specific version required: "
|
113
|
+
f"{pinned_pkg_name}=={pinned_pkg_version}"
|
114
|
+
)
|
115
|
+
logger.debug(
|
116
|
+
"Preliminary check: verify that "
|
117
|
+
f"{pinned_pkg_version} is already installed"
|
118
|
+
)
|
119
|
+
stdout_inspect = await execute_command(
|
120
|
+
cwd=venv_path,
|
121
|
+
command=f"{pip} show {pinned_pkg_name}",
|
122
|
+
logger_name=logger_name,
|
123
|
+
)
|
124
|
+
current_version = next(
|
125
|
+
line.split()[-1]
|
126
|
+
for line in stdout_inspect.split("\n")
|
127
|
+
if line.startswith("Version:")
|
128
|
+
)
|
129
|
+
if current_version != pinned_pkg_version:
|
130
|
+
logger.debug(
|
131
|
+
f"Currently installed version of {pinned_pkg_name} "
|
132
|
+
f"({current_version}) differs from pinned version "
|
133
|
+
f"({pinned_pkg_version}); "
|
134
|
+
f"install version {pinned_pkg_version}."
|
135
|
+
)
|
136
|
+
await execute_command(
|
137
|
+
cwd=venv_path,
|
138
|
+
command=(
|
139
|
+
f"{pip} install "
|
140
|
+
f"{pinned_pkg_name}=={pinned_pkg_version}"
|
141
|
+
),
|
142
|
+
logger_name=logger_name,
|
143
|
+
)
|
144
|
+
else:
|
145
|
+
logger.debug(
|
146
|
+
f"Currently installed version of {pinned_pkg_name} "
|
147
|
+
f"({current_version}) already matches the pinned version."
|
148
|
+
)
|
149
|
+
|
150
|
+
# Extract package installation path from `pip show`
|
151
|
+
stdout_inspect = await execute_command(
|
152
|
+
cwd=venv_path, command=cmd_inspect, logger_name=logger_name
|
153
|
+
)
|
154
|
+
|
155
|
+
location = Path(
|
156
|
+
next(
|
157
|
+
line.split()[-1]
|
158
|
+
for line in stdout_inspect.split("\n")
|
159
|
+
if line.startswith("Location:")
|
160
|
+
)
|
161
|
+
)
|
162
|
+
|
163
|
+
# NOTE
|
164
|
+
# https://packaging.python.org/en/latest/specifications/recording-installed-packages/
|
165
|
+
# This directory is named as {name}-{version}.dist-info, with name and
|
166
|
+
# version fields corresponding to Core metadata specifications. Both
|
167
|
+
# fields must be normalized (see the name normalization specification and
|
168
|
+
# the version normalization specification), and replace dash (-)
|
169
|
+
# characters with underscore (_) characters, so the .dist-info directory
|
170
|
+
# always has exactly one dash (-) character in its stem, separating the
|
171
|
+
# name and version fields.
|
172
|
+
package_root = location / (task_pkg.package.replace("-", "_"))
|
173
|
+
logger.debug(f"[_pip install] {location=}")
|
174
|
+
logger.debug(f"[_pip install] {task_pkg.package=}")
|
175
|
+
logger.debug(f"[_pip install] {package_root=}")
|
176
|
+
if not package_root.exists():
|
177
|
+
raise RuntimeError(
|
178
|
+
"Could not determine package installation location."
|
179
|
+
)
|
180
|
+
return package_root
|
181
|
+
|
182
|
+
|
183
|
+
async def _create_venv_install_package(
|
184
|
+
*,
|
185
|
+
task_pkg: _TaskCollectPip,
|
186
|
+
path: Path,
|
187
|
+
logger_name: str,
|
188
|
+
) -> tuple[Path, Path]:
|
189
|
+
"""Create venv and install package
|
190
|
+
|
191
|
+
Args:
|
192
|
+
path: the directory in which to create the environment
|
193
|
+
task_pkg: object containing the different metadata required to install
|
194
|
+
the package
|
195
|
+
|
196
|
+
Returns:
|
197
|
+
python_bin: path to venv's python interpreter
|
198
|
+
package_root: the location of the package manifest
|
199
|
+
"""
|
200
|
+
|
201
|
+
# Normalize package name
|
202
|
+
task_pkg.package_name = _normalize_package_name(task_pkg.package_name)
|
203
|
+
task_pkg.package = _normalize_package_name(task_pkg.package)
|
204
|
+
|
205
|
+
python_bin = await _init_venv(
|
206
|
+
path=path,
|
207
|
+
python_version=task_pkg.python_version,
|
208
|
+
logger_name=logger_name,
|
209
|
+
)
|
210
|
+
package_root = await _pip_install(
|
211
|
+
venv_path=path, task_pkg=task_pkg, logger_name=logger_name
|
212
|
+
)
|
213
|
+
return python_bin, package_root
|
214
|
+
|
215
|
+
|
216
|
+
async def create_package_environment_pip(
|
217
|
+
*,
|
218
|
+
task_pkg: _TaskCollectPip,
|
219
|
+
venv_path: Path,
|
220
|
+
logger_name: str,
|
221
|
+
) -> list[TaskCreate]:
|
222
|
+
"""
|
223
|
+
Create environment, install package, and prepare task list
|
224
|
+
"""
|
225
|
+
|
226
|
+
logger = get_logger(logger_name)
|
227
|
+
|
228
|
+
# Normalize package name
|
229
|
+
task_pkg.package_name = _normalize_package_name(task_pkg.package_name)
|
230
|
+
task_pkg.package = _normalize_package_name(task_pkg.package)
|
231
|
+
|
232
|
+
# Only proceed if package, version and manifest attributes are set
|
233
|
+
task_pkg.check()
|
234
|
+
|
235
|
+
try:
|
236
|
+
|
237
|
+
logger.debug("Creating venv and installing package")
|
238
|
+
python_bin, package_root = await _create_venv_install_package(
|
239
|
+
path=venv_path,
|
240
|
+
task_pkg=task_pkg,
|
241
|
+
logger_name=logger_name,
|
242
|
+
)
|
243
|
+
logger.debug("Venv creation and package installation ended correctly.")
|
244
|
+
|
245
|
+
# Prepare task_list with appropriate metadata
|
246
|
+
logger.debug("Creating task list from manifest")
|
247
|
+
task_list = []
|
248
|
+
for t in task_pkg.package_manifest.task_list:
|
249
|
+
# Fill in attributes for TaskCreate
|
250
|
+
task_executable = package_root / t.executable
|
251
|
+
cmd = f"{python_bin.as_posix()} {task_executable.as_posix()}"
|
252
|
+
task_name_slug = slugify_task_name(t.name)
|
253
|
+
task_source = f"{task_pkg.package_source}:{task_name_slug}"
|
254
|
+
if not task_executable.exists():
|
255
|
+
raise FileNotFoundError(
|
256
|
+
f"Cannot find executable `{task_executable}` "
|
257
|
+
f"for task `{t.name}`"
|
258
|
+
)
|
259
|
+
manifest = task_pkg.package_manifest
|
260
|
+
if manifest.has_args_schemas:
|
261
|
+
additional_attrs = dict(
|
262
|
+
args_schema_version=manifest.args_schema_version
|
263
|
+
)
|
264
|
+
else:
|
265
|
+
additional_attrs = {}
|
266
|
+
this_task = TaskCreate(
|
267
|
+
**t.dict(),
|
268
|
+
command=cmd,
|
269
|
+
version=task_pkg.package_version,
|
270
|
+
**additional_attrs,
|
271
|
+
source=task_source,
|
272
|
+
)
|
273
|
+
task_list.append(this_task)
|
274
|
+
logger.debug("Task list created correctly")
|
275
|
+
except Exception as e:
|
276
|
+
logger.error("Task manifest loading failed")
|
277
|
+
raise e
|
278
|
+
return task_list
|
279
|
+
|
280
|
+
|
281
|
+
async def _insert_tasks(
|
282
|
+
task_list: list[TaskCreate],
|
283
|
+
db: DBSyncSession,
|
284
|
+
) -> list[Task]:
|
285
|
+
"""
|
286
|
+
Insert tasks into database
|
287
|
+
"""
|
288
|
+
task_db_list = [Task(**t.dict()) for t in task_list]
|
289
|
+
db.add_all(task_db_list)
|
290
|
+
db.commit()
|
291
|
+
for t in task_db_list:
|
292
|
+
db.refresh(t)
|
293
|
+
db.close()
|
294
|
+
return task_db_list
|
295
|
+
|
296
|
+
|
297
|
+
async def background_collect_pip(
|
298
|
+
state_id: int,
|
299
|
+
venv_path: Path,
|
300
|
+
task_pkg: _TaskCollectPip,
|
301
|
+
) -> None:
|
302
|
+
"""
|
303
|
+
Install package and collect tasks
|
304
|
+
|
305
|
+
Install a python package and collect the tasks it provides according to
|
306
|
+
the manifest.
|
307
|
+
|
308
|
+
In case of error, copy the log into the state and delete the package
|
309
|
+
directory.
|
310
|
+
"""
|
311
|
+
logger_name = task_pkg.package.replace("/", "_")
|
312
|
+
logger = set_logger(
|
313
|
+
logger_name=logger_name,
|
314
|
+
log_file_path=get_log_path(venv_path),
|
315
|
+
)
|
316
|
+
logger.debug("Start background task collection")
|
317
|
+
for key, value in task_pkg.dict(exclude={"package_manifest"}).items():
|
318
|
+
logger.debug(f"{key}: {value}")
|
319
|
+
|
320
|
+
with next(get_sync_db()) as db:
|
321
|
+
state: State = db.get(State, state_id)
|
322
|
+
data = TaskCollectStatus(**state.data)
|
323
|
+
data.info = None
|
324
|
+
|
325
|
+
try:
|
326
|
+
# install
|
327
|
+
logger.debug("Task-collection status: installing")
|
328
|
+
data.status = "installing"
|
329
|
+
|
330
|
+
state.data = data.sanitised_dict()
|
331
|
+
db.merge(state)
|
332
|
+
db.commit()
|
333
|
+
task_list = await create_package_environment_pip(
|
334
|
+
venv_path=venv_path,
|
335
|
+
task_pkg=task_pkg,
|
336
|
+
logger_name=logger_name,
|
337
|
+
)
|
338
|
+
|
339
|
+
# collect
|
340
|
+
logger.debug("Task-collection status: collecting")
|
341
|
+
data.status = "collecting"
|
342
|
+
state.data = data.sanitised_dict()
|
343
|
+
db.merge(state)
|
344
|
+
db.commit()
|
345
|
+
tasks = await _insert_tasks(task_list=task_list, db=db)
|
346
|
+
|
347
|
+
# finalise
|
348
|
+
logger.debug("Task-collection status: finalising")
|
349
|
+
collection_path = get_collection_path(venv_path)
|
350
|
+
data.task_list = [TaskRead(**task.model_dump()) for task in tasks]
|
351
|
+
with collection_path.open("w") as f:
|
352
|
+
json.dump(data.sanitised_dict(), f)
|
353
|
+
|
354
|
+
# Update DB
|
355
|
+
data.status = "OK"
|
356
|
+
data.log = get_collection_log(venv_path)
|
357
|
+
state.data = data.sanitised_dict()
|
358
|
+
db.add(state)
|
359
|
+
db.merge(state)
|
360
|
+
db.commit()
|
361
|
+
|
362
|
+
# Write last logs to file
|
363
|
+
logger.debug("Task-collection status: OK")
|
364
|
+
logger.info("Background task collection completed successfully")
|
365
|
+
close_logger(logger)
|
366
|
+
db.close()
|
367
|
+
|
368
|
+
except Exception as e:
|
369
|
+
# Write last logs to file
|
370
|
+
logger.debug("Task-collection status: fail")
|
371
|
+
logger.info(f"Background collection failed. Original error: {e}")
|
372
|
+
close_logger(logger)
|
373
|
+
|
374
|
+
# Update db
|
375
|
+
data.status = "fail"
|
376
|
+
data.info = f"Original error: {e}"
|
377
|
+
data.log = get_collection_log(venv_path)
|
378
|
+
state.data = data.sanitised_dict()
|
379
|
+
db.merge(state)
|
380
|
+
db.commit()
|
381
|
+
db.close()
|
382
|
+
|
383
|
+
# Delete corrupted package dir
|
384
|
+
shell_rmtree(venv_path)
|
@@ -0,0 +1,167 @@
|
|
1
|
+
import json
|
2
|
+
from pathlib import Path
|
3
|
+
from typing import Optional
|
4
|
+
from typing import Union
|
5
|
+
from zipfile import ZipFile
|
6
|
+
|
7
|
+
from fractal_server.app.schemas import ManifestV1
|
8
|
+
from fractal_server.app.schemas import TaskCollectStatus
|
9
|
+
from fractal_server.config import get_settings
|
10
|
+
from fractal_server.logger import get_logger
|
11
|
+
from fractal_server.syringe import Inject
|
12
|
+
from fractal_server.tasks._TaskCollectPip import _TaskCollectPip
|
13
|
+
from fractal_server.tasks.utils import _normalize_package_name
|
14
|
+
from fractal_server.tasks.utils import get_absolute_venv_path
|
15
|
+
from fractal_server.tasks.utils import get_collection_path
|
16
|
+
from fractal_server.tasks.utils import get_python_interpreter
|
17
|
+
from fractal_server.utils import execute_command
|
18
|
+
|
19
|
+
|
20
|
+
FRACTAL_PUBLIC_TASK_SUBDIR = ".fractal"
|
21
|
+
|
22
|
+
|
23
|
+
def get_collection_data(venv_path: Path) -> TaskCollectStatus:
|
24
|
+
package_path = get_absolute_venv_path(venv_path)
|
25
|
+
collection_path = get_collection_path(package_path)
|
26
|
+
with collection_path.open() as f:
|
27
|
+
data = json.load(f)
|
28
|
+
return TaskCollectStatus(**data)
|
29
|
+
|
30
|
+
|
31
|
+
async def download_package(
|
32
|
+
*,
|
33
|
+
task_pkg: _TaskCollectPip,
|
34
|
+
dest: Union[str, Path],
|
35
|
+
):
|
36
|
+
"""
|
37
|
+
Download package to destination
|
38
|
+
"""
|
39
|
+
interpreter = get_python_interpreter(version=task_pkg.python_version)
|
40
|
+
pip = f"{interpreter} -m pip"
|
41
|
+
version = (
|
42
|
+
f"=={task_pkg.package_version}" if task_pkg.package_version else ""
|
43
|
+
)
|
44
|
+
package_and_version = f"{task_pkg.package}{version}"
|
45
|
+
cmd = f"{pip} download --no-deps {package_and_version} -d {dest}"
|
46
|
+
stdout = await execute_command(command=cmd, cwd=Path("."))
|
47
|
+
pkg_file = next(
|
48
|
+
line.split()[-1] for line in stdout.split("\n") if "Saved" in line
|
49
|
+
)
|
50
|
+
return Path(pkg_file)
|
51
|
+
|
52
|
+
|
53
|
+
def _load_manifest_from_wheel(
|
54
|
+
path: Path, wheel: ZipFile, logger_name: Optional[str] = None
|
55
|
+
) -> ManifestV1:
|
56
|
+
logger = get_logger(logger_name)
|
57
|
+
namelist = wheel.namelist()
|
58
|
+
try:
|
59
|
+
manifest = next(
|
60
|
+
name for name in namelist if "__FRACTAL_MANIFEST__.json" in name
|
61
|
+
)
|
62
|
+
except StopIteration:
|
63
|
+
msg = f"{path.as_posix()} does not include __FRACTAL_MANIFEST__.json"
|
64
|
+
logger.error(msg)
|
65
|
+
raise ValueError(msg)
|
66
|
+
with wheel.open(manifest) as manifest_fd:
|
67
|
+
manifest_dict = json.load(manifest_fd)
|
68
|
+
manifest_version = str(manifest_dict["manifest_version"])
|
69
|
+
if manifest_version == "1":
|
70
|
+
pkg_manifest = ManifestV1(**manifest_dict)
|
71
|
+
return pkg_manifest
|
72
|
+
else:
|
73
|
+
msg = f"Manifest version {manifest_version=} not supported"
|
74
|
+
logger.error(msg)
|
75
|
+
raise ValueError(msg)
|
76
|
+
|
77
|
+
|
78
|
+
def inspect_package(path: Path, logger_name: Optional[str] = None) -> dict:
|
79
|
+
"""
|
80
|
+
Inspect task package to extract version, name and manifest
|
81
|
+
|
82
|
+
Note that this only works with wheel files, which have a well-defined
|
83
|
+
dist-info section. If we need to generalize to to tar.gz archives, we would
|
84
|
+
need to go and look for `PKG-INFO`.
|
85
|
+
|
86
|
+
Note: package name is normalized via `_normalize_package_name`.
|
87
|
+
|
88
|
+
Args:
|
89
|
+
path: Path
|
90
|
+
the path in which the package is saved
|
91
|
+
|
92
|
+
Returns:
|
93
|
+
version_manifest: A dictionary containing `version`, the version of the
|
94
|
+
pacakge, and `manifest`, the Fractal manifest object relative to the
|
95
|
+
tasks.
|
96
|
+
"""
|
97
|
+
|
98
|
+
logger = get_logger(logger_name)
|
99
|
+
|
100
|
+
if not path.as_posix().endswith(".whl"):
|
101
|
+
raise ValueError(
|
102
|
+
f"Only wheel packages are supported, given {path.as_posix()}."
|
103
|
+
)
|
104
|
+
|
105
|
+
with ZipFile(path) as wheel:
|
106
|
+
namelist = wheel.namelist()
|
107
|
+
|
108
|
+
# Read and validate task manifest
|
109
|
+
logger.debug(f"Now reading manifest for {path.as_posix()}")
|
110
|
+
pkg_manifest = _load_manifest_from_wheel(
|
111
|
+
path, wheel, logger_name=logger_name
|
112
|
+
)
|
113
|
+
logger.debug("Manifest read correctly.")
|
114
|
+
|
115
|
+
# Read package name and version from *.dist-info/METADATA
|
116
|
+
logger.debug(
|
117
|
+
f"Now reading package name and version for {path.as_posix()}"
|
118
|
+
)
|
119
|
+
metadata = next(
|
120
|
+
name for name in namelist if "dist-info/METADATA" in name
|
121
|
+
)
|
122
|
+
with wheel.open(metadata) as metadata_fd:
|
123
|
+
meta = metadata_fd.read().decode("utf-8")
|
124
|
+
pkg_name = next(
|
125
|
+
line.split()[-1]
|
126
|
+
for line in meta.splitlines()
|
127
|
+
if line.startswith("Name")
|
128
|
+
)
|
129
|
+
pkg_version = next(
|
130
|
+
line.split()[-1]
|
131
|
+
for line in meta.splitlines()
|
132
|
+
if line.startswith("Version")
|
133
|
+
)
|
134
|
+
logger.debug("Package name and version read correctly.")
|
135
|
+
|
136
|
+
# Normalize package name:
|
137
|
+
pkg_name = _normalize_package_name(pkg_name)
|
138
|
+
|
139
|
+
info = dict(
|
140
|
+
pkg_name=pkg_name,
|
141
|
+
pkg_version=pkg_version,
|
142
|
+
pkg_manifest=pkg_manifest,
|
143
|
+
)
|
144
|
+
return info
|
145
|
+
|
146
|
+
|
147
|
+
def create_package_dir_pip(
|
148
|
+
*,
|
149
|
+
task_pkg: _TaskCollectPip,
|
150
|
+
create: bool = True,
|
151
|
+
) -> Path:
|
152
|
+
"""
|
153
|
+
Create venv folder for a task package and return corresponding Path object
|
154
|
+
"""
|
155
|
+
settings = Inject(get_settings)
|
156
|
+
user = FRACTAL_PUBLIC_TASK_SUBDIR
|
157
|
+
if task_pkg.package_version is None:
|
158
|
+
raise ValueError(
|
159
|
+
f"Cannot create venv folder for package `{task_pkg.package}` "
|
160
|
+
"with `version=None`."
|
161
|
+
)
|
162
|
+
normalized_package = _normalize_package_name(task_pkg.package)
|
163
|
+
package_dir = f"{normalized_package}{task_pkg.package_version}"
|
164
|
+
venv_path = settings.FRACTAL_TASKS_DIR / user / package_dir
|
165
|
+
if create:
|
166
|
+
venv_path.mkdir(exist_ok=False, parents=True)
|
167
|
+
return venv_path
|
@@ -0,0 +1,86 @@
|
|
1
|
+
import re
|
2
|
+
import shutil
|
3
|
+
import sys
|
4
|
+
from pathlib import Path
|
5
|
+
from typing import Optional
|
6
|
+
|
7
|
+
from fractal_server.config import get_settings
|
8
|
+
from fractal_server.syringe import Inject
|
9
|
+
|
10
|
+
COLLECTION_FILENAME = "collection.json"
|
11
|
+
COLLECTION_LOG_FILENAME = "collection.log"
|
12
|
+
|
13
|
+
|
14
|
+
def get_python_interpreter(version: Optional[str] = None) -> str:
|
15
|
+
"""
|
16
|
+
Return the path to the python interpreter
|
17
|
+
|
18
|
+
Args:
|
19
|
+
version: Python version
|
20
|
+
|
21
|
+
Raises:
|
22
|
+
ValueError: If the python version requested is not available on the
|
23
|
+
host.
|
24
|
+
|
25
|
+
Returns:
|
26
|
+
interpreter: string representing the python executable or its path
|
27
|
+
"""
|
28
|
+
if version:
|
29
|
+
interpreter = shutil.which(f"python{version}")
|
30
|
+
if not interpreter:
|
31
|
+
raise ValueError(
|
32
|
+
f"Python version {version} not available on host."
|
33
|
+
)
|
34
|
+
else:
|
35
|
+
interpreter = sys.executable
|
36
|
+
|
37
|
+
return interpreter
|
38
|
+
|
39
|
+
|
40
|
+
def slugify_task_name(task_name: str) -> str:
|
41
|
+
return task_name.replace(" ", "_").lower()
|
42
|
+
|
43
|
+
|
44
|
+
def get_absolute_venv_path(venv_path: Path) -> Path:
|
45
|
+
"""
|
46
|
+
If a path is not absolute, make it a relative path of FRACTAL_TASKS_DIR.
|
47
|
+
"""
|
48
|
+
if venv_path.is_absolute():
|
49
|
+
package_path = venv_path
|
50
|
+
else:
|
51
|
+
settings = Inject(get_settings)
|
52
|
+
package_path = settings.FRACTAL_TASKS_DIR / venv_path
|
53
|
+
return package_path
|
54
|
+
|
55
|
+
|
56
|
+
def get_collection_path(base: Path) -> Path:
|
57
|
+
return base / COLLECTION_FILENAME
|
58
|
+
|
59
|
+
|
60
|
+
def get_log_path(base: Path) -> Path:
|
61
|
+
return base / COLLECTION_LOG_FILENAME
|
62
|
+
|
63
|
+
|
64
|
+
def get_collection_log(venv_path: Path) -> str:
|
65
|
+
package_path = get_absolute_venv_path(venv_path)
|
66
|
+
log_path = get_log_path(package_path)
|
67
|
+
log = log_path.open().read()
|
68
|
+
return log
|
69
|
+
|
70
|
+
|
71
|
+
def _normalize_package_name(name: str) -> str:
|
72
|
+
"""
|
73
|
+
Implement PyPa specifications for package-name normalization
|
74
|
+
|
75
|
+
The name should be lowercased with all runs of the characters `.`, `-`,
|
76
|
+
or `_` replaced with a single `-` character. This can be implemented in
|
77
|
+
Python with the re module.
|
78
|
+
(https://packaging.python.org/en/latest/specifications/name-normalization)
|
79
|
+
|
80
|
+
Args:
|
81
|
+
name: The non-normalized package name.
|
82
|
+
|
83
|
+
Returns:
|
84
|
+
The normalized package name.
|
85
|
+
"""
|
86
|
+
return re.sub(r"[-_.]+", "-", name).lower()
|