fractal-server 2.7.1__py3-none-any.whl → 2.8.1__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.
Files changed (36) hide show
  1. fractal_server/__init__.py +1 -1
  2. fractal_server/app/models/user_settings.py +1 -0
  3. fractal_server/app/models/v2/task.py +15 -0
  4. fractal_server/app/routes/api/v2/dataset.py +39 -6
  5. fractal_server/app/routes/api/v2/task.py +2 -5
  6. fractal_server/app/routes/api/v2/task_collection.py +14 -42
  7. fractal_server/app/routes/api/v2/task_collection_custom.py +3 -3
  8. fractal_server/app/schemas/_validators.py +1 -1
  9. fractal_server/app/schemas/user_settings.py +18 -0
  10. fractal_server/app/schemas/v2/dataset.py +6 -4
  11. fractal_server/app/schemas/v2/task_collection.py +31 -12
  12. fractal_server/migrations/versions/19eca0dd47a9_user_settings_project_dir.py +39 -0
  13. fractal_server/string_tools.py +10 -3
  14. fractal_server/tasks/utils.py +0 -31
  15. fractal_server/tasks/v1/background_operations.py +11 -11
  16. fractal_server/tasks/v1/endpoint_operations.py +5 -5
  17. fractal_server/tasks/v1/utils.py +2 -2
  18. fractal_server/tasks/v2/collection_local.py +357 -0
  19. fractal_server/tasks/v2/{background_operations_ssh.py → collection_ssh.py} +108 -102
  20. fractal_server/tasks/v2/templates/_1_create_venv.sh +0 -8
  21. fractal_server/tasks/v2/templates/_2_preliminary_pip_operations.sh +2 -2
  22. fractal_server/tasks/v2/templates/_3_pip_install.sh +22 -1
  23. fractal_server/tasks/v2/templates/_5_pip_show.sh +5 -5
  24. fractal_server/tasks/v2/utils_background.py +209 -0
  25. fractal_server/tasks/v2/utils_package_names.py +77 -0
  26. fractal_server/tasks/v2/{utils.py → utils_python_interpreter.py} +0 -26
  27. fractal_server/tasks/v2/utils_templates.py +59 -0
  28. fractal_server/utils.py +48 -3
  29. {fractal_server-2.7.1.dist-info → fractal_server-2.8.1.dist-info}/METADATA +11 -8
  30. {fractal_server-2.7.1.dist-info → fractal_server-2.8.1.dist-info}/RECORD +34 -31
  31. fractal_server/tasks/v2/_venv_pip.py +0 -198
  32. fractal_server/tasks/v2/background_operations.py +0 -456
  33. /fractal_server/{tasks/v2/endpoint_operations.py → app/routes/api/v2/_aux_functions_task_collection.py} +0 -0
  34. {fractal_server-2.7.1.dist-info → fractal_server-2.8.1.dist-info}/LICENSE +0 -0
  35. {fractal_server-2.7.1.dist-info → fractal_server-2.8.1.dist-info}/WHEEL +0 -0
  36. {fractal_server-2.7.1.dist-info → fractal_server-2.8.1.dist-info}/entry_points.txt +0 -0
@@ -2,7 +2,7 @@ from pathlib import Path
2
2
  from typing import Optional
3
3
 
4
4
  from fractal_server.logger import get_logger
5
- from fractal_server.utils import execute_command
5
+ from fractal_server.utils import execute_command_async
6
6
 
7
7
 
8
8
  def get_python_interpreter_v1(version: Optional[str] = None) -> str:
@@ -57,7 +57,7 @@ async def _init_venv_v1(
57
57
  logger.debug(f"[_init_venv] {path=}")
58
58
  interpreter = get_python_interpreter_v1(version=python_version)
59
59
  logger.debug(f"[_init_venv] {interpreter=}")
60
- await execute_command(
60
+ await execute_command_async(
61
61
  cwd=path,
62
62
  command=f"{interpreter} -m venv venv",
63
63
  logger_name=logger_name,
@@ -0,0 +1,357 @@
1
+ import json
2
+ import shutil
3
+ from pathlib import Path
4
+ from tempfile import TemporaryDirectory
5
+
6
+ from sqlalchemy.orm.attributes import flag_modified
7
+
8
+ from .database_operations import create_db_tasks_and_update_task_group
9
+ from fractal_server.app.db import get_sync_db
10
+ from fractal_server.app.models.v2 import CollectionStateV2
11
+ from fractal_server.app.models.v2 import TaskGroupV2
12
+ from fractal_server.app.schemas.v2 import CollectionStatusV2
13
+ from fractal_server.app.schemas.v2 import TaskReadV2
14
+ from fractal_server.app.schemas.v2.manifest import ManifestV2
15
+ from fractal_server.config import get_settings
16
+ from fractal_server.logger import get_logger
17
+ from fractal_server.logger import set_logger
18
+ from fractal_server.syringe import Inject
19
+ from fractal_server.tasks.utils import get_log_path
20
+ from fractal_server.tasks.v2.utils_background import _handle_failure
21
+ from fractal_server.tasks.v2.utils_background import _prepare_tasks_metadata
22
+ from fractal_server.tasks.v2.utils_background import _refresh_logs
23
+ from fractal_server.tasks.v2.utils_background import (
24
+ _set_collection_state_data_status,
25
+ )
26
+ from fractal_server.tasks.v2.utils_background import check_task_files_exist
27
+ from fractal_server.tasks.v2.utils_package_names import compare_package_names
28
+ from fractal_server.tasks.v2.utils_python_interpreter import (
29
+ get_python_interpreter_v2,
30
+ )
31
+ from fractal_server.tasks.v2.utils_templates import customize_template
32
+ from fractal_server.tasks.v2.utils_templates import parse_script_5_stdout
33
+ from fractal_server.utils import execute_command_sync
34
+
35
+
36
+ def _customize_and_run_template(
37
+ script_filename: str,
38
+ replacements: list[tuple[str, str]],
39
+ script_dir: str,
40
+ logger_name: str,
41
+ ) -> str:
42
+ """
43
+ Customize one of the template bash scripts.
44
+
45
+ Args:
46
+ script_filename:
47
+ replacements:
48
+ script_dir:
49
+ logger_name:
50
+ """
51
+ logger = get_logger(logger_name)
52
+ logger.debug(f"_customize_and_run_template {script_filename} - START")
53
+
54
+ script_path_local = Path(script_dir) / script_filename
55
+ # Read template
56
+ customize_template(
57
+ template_name=script_filename,
58
+ replacements=replacements,
59
+ script_path=script_path_local,
60
+ )
61
+
62
+ cmd = f"bash {script_path_local}"
63
+ logger.debug(f"Now run '{cmd}' ")
64
+
65
+ stdout = execute_command_sync(command=cmd)
66
+
67
+ logger.debug(f"Standard output of '{cmd}':\n{stdout}")
68
+ logger.debug(f"_customize_and_run_template {script_filename} - END")
69
+
70
+ return stdout
71
+
72
+
73
+ def collect_package_local(
74
+ *,
75
+ state_id: int,
76
+ task_group: TaskGroupV2,
77
+ ) -> None:
78
+ """
79
+ Collect a task package.
80
+
81
+ This function is run as a background task, therefore exceptions must be
82
+ handled.
83
+
84
+ NOTE: by making this function sync, it will run within a thread - due to
85
+ starlette/fastapi handling of background tasks (see
86
+ https://github.com/encode/starlette/blob/master/starlette/background.py).
87
+
88
+
89
+ Arguments:
90
+ state_id:
91
+ task_group:
92
+ """
93
+
94
+ # Create the task_group path
95
+ with TemporaryDirectory() as tmpdir:
96
+
97
+ # Setup logger in tmpdir
98
+ LOGGER_NAME = "task_collection_local"
99
+ log_file_path = get_log_path(Path(tmpdir))
100
+ logger = set_logger(
101
+ logger_name=LOGGER_NAME,
102
+ log_file_path=log_file_path,
103
+ )
104
+
105
+ # Log some info
106
+ logger.debug("START")
107
+ for key, value in task_group.model_dump().items():
108
+ logger.debug(f"task_group.{key}: {value}")
109
+
110
+ # Open a DB session
111
+ with next(get_sync_db()) as db:
112
+
113
+ # Check that the task_group path does not exist
114
+ if Path(task_group.path).exists():
115
+ error_msg = f"{task_group.path} already exists."
116
+ logger.error(error_msg)
117
+ _handle_failure(
118
+ state_id=state_id,
119
+ logger_name=LOGGER_NAME,
120
+ log_file_path=log_file_path,
121
+ exception=FileExistsError(error_msg),
122
+ db=db,
123
+ task_group_id=task_group.id,
124
+ )
125
+ return
126
+
127
+ try:
128
+ # Prepare replacements for task-collection scripts
129
+ python_bin = get_python_interpreter_v2(
130
+ python_version=task_group.python_version
131
+ )
132
+ install_string = task_group.pip_install_string
133
+ settings = Inject(get_settings)
134
+ replacements = [
135
+ ("__PACKAGE_NAME__", task_group.pkg_name),
136
+ ("__TASK_GROUP_DIR__", task_group.path),
137
+ ("__PACKAGE_ENV_DIR__", task_group.venv_path),
138
+ ("__PYTHON__", python_bin),
139
+ ("__INSTALL_STRING__", install_string),
140
+ (
141
+ "__FRACTAL_MAX_PIP_VERSION__",
142
+ settings.FRACTAL_MAX_PIP_VERSION,
143
+ ),
144
+ (
145
+ "__PINNED_PACKAGE_LIST__",
146
+ task_group.pinned_package_versions_string,
147
+ ),
148
+ ]
149
+
150
+ common_args = dict(
151
+ replacements=replacements,
152
+ script_dir=task_group.path,
153
+ logger_name=LOGGER_NAME,
154
+ )
155
+
156
+ logger.debug("installing - START")
157
+ _set_collection_state_data_status(
158
+ state_id=state_id,
159
+ new_status=CollectionStatusV2.INSTALLING,
160
+ logger_name=LOGGER_NAME,
161
+ db=db,
162
+ )
163
+
164
+ # Create main path for task group
165
+ Path(task_group.path).mkdir(parents=True)
166
+ logger.debug(f"Created {task_group.path}")
167
+
168
+ # Create venv
169
+ logger.debug(
170
+ (f"START - Create python venv {task_group.venv_path}")
171
+ )
172
+ cmd = (
173
+ f"python{task_group.python_version} -m venv "
174
+ f"{task_group.venv_path} --copies"
175
+ )
176
+ stdout = execute_command_sync(command=cmd)
177
+ logger.debug(
178
+ (f"END - Create python venv folder {task_group.venv_path}")
179
+ )
180
+ _refresh_logs(
181
+ state_id=state_id,
182
+ log_file_path=log_file_path,
183
+ db=db,
184
+ )
185
+ # Close db connections before long pip related operations
186
+ # Warning this expunge all ORM objects.
187
+ # https://docs.sqlalchemy.org/en/20/orm/session_api.html#sqlalchemy.orm.Session.close
188
+ db.close()
189
+
190
+ stdout = _customize_and_run_template(
191
+ script_filename="_2_preliminary_pip_operations.sh",
192
+ **common_args,
193
+ )
194
+ _refresh_logs(
195
+ state_id=state_id,
196
+ log_file_path=log_file_path,
197
+ db=db,
198
+ )
199
+ stdout = _customize_and_run_template(
200
+ script_filename="_3_pip_install.sh",
201
+ **common_args,
202
+ )
203
+ _refresh_logs(
204
+ state_id=state_id,
205
+ log_file_path=log_file_path,
206
+ db=db,
207
+ )
208
+ stdout_pip_freeze = _customize_and_run_template(
209
+ script_filename="_4_pip_freeze.sh",
210
+ **common_args,
211
+ )
212
+ logger.debug("installing - END")
213
+ _refresh_logs(
214
+ state_id=state_id,
215
+ log_file_path=log_file_path,
216
+ db=db,
217
+ )
218
+
219
+ logger.debug("collecting - START")
220
+ _set_collection_state_data_status(
221
+ state_id=state_id,
222
+ new_status=CollectionStatusV2.COLLECTING,
223
+ logger_name=LOGGER_NAME,
224
+ db=db,
225
+ )
226
+ _refresh_logs(
227
+ state_id=state_id,
228
+ log_file_path=log_file_path,
229
+ db=db,
230
+ )
231
+
232
+ stdout = _customize_and_run_template(
233
+ script_filename="_5_pip_show.sh",
234
+ **common_args,
235
+ )
236
+ _refresh_logs(
237
+ state_id=state_id,
238
+ log_file_path=log_file_path,
239
+ db=db,
240
+ )
241
+
242
+ pkg_attrs = parse_script_5_stdout(stdout)
243
+ for key, value in pkg_attrs.items():
244
+ logger.debug(
245
+ f"collecting - parsed from pip-show: {key}={value}"
246
+ )
247
+ # Check package_name match between pip show and task-group
248
+ package_name_pip_show = pkg_attrs.get("package_name")
249
+ package_name_task_group = task_group.pkg_name
250
+ compare_package_names(
251
+ pkg_name_pip_show=package_name_pip_show,
252
+ pkg_name_task_group=package_name_task_group,
253
+ logger_name=LOGGER_NAME,
254
+ )
255
+ # Extract/drop parsed attributes
256
+ package_name = package_name_task_group
257
+ python_bin = pkg_attrs.pop("python_bin")
258
+ package_root_parent = pkg_attrs.pop("package_root_parent")
259
+
260
+ # TODO : Use more robust logic to determine `package_root`.
261
+ # Examples: use `importlib.util.find_spec`, or parse the output
262
+ # of `pip show --files {package_name}`.
263
+ package_name_underscore = package_name.replace("-", "_")
264
+ package_root = (
265
+ Path(package_root_parent) / package_name_underscore
266
+ ).as_posix()
267
+
268
+ # Read and validate manifest file
269
+ manifest_path = pkg_attrs.pop("manifest_path")
270
+ logger.info(f"collecting - now loading {manifest_path=}")
271
+ with open(manifest_path) as json_data:
272
+ pkg_manifest_dict = json.load(json_data)
273
+ logger.info(f"collecting - loaded {manifest_path=}")
274
+ logger.info("collecting - now validating manifest content")
275
+ pkg_manifest = ManifestV2(**pkg_manifest_dict)
276
+ logger.info("collecting - validated manifest content")
277
+ _refresh_logs(
278
+ state_id=state_id,
279
+ log_file_path=log_file_path,
280
+ db=db,
281
+ )
282
+
283
+ logger.info("collecting - _prepare_tasks_metadata - start")
284
+ task_list = _prepare_tasks_metadata(
285
+ package_manifest=pkg_manifest,
286
+ package_version=task_group.version,
287
+ package_root=Path(package_root),
288
+ python_bin=Path(python_bin),
289
+ )
290
+ check_task_files_exist(task_list=task_list)
291
+ logger.info("collecting - _prepare_tasks_metadata - end")
292
+ _refresh_logs(
293
+ state_id=state_id,
294
+ log_file_path=log_file_path,
295
+ db=db,
296
+ )
297
+
298
+ logger.info(
299
+ "collecting - create_db_tasks_and_update_task_group - "
300
+ "start"
301
+ )
302
+ task_group = create_db_tasks_and_update_task_group(
303
+ task_list=task_list,
304
+ task_group_id=task_group.id,
305
+ db=db,
306
+ )
307
+ logger.info(
308
+ "collecting - create_db_tasks_and_update_task_group - end"
309
+ )
310
+
311
+ logger.debug("collecting - END")
312
+
313
+ # Finalize (write metadata to DB)
314
+ logger.debug("finalising - START")
315
+
316
+ _refresh_logs(
317
+ state_id=state_id,
318
+ log_file_path=log_file_path,
319
+ db=db,
320
+ )
321
+ collection_state = db.get(CollectionStateV2, state_id)
322
+ collection_state.data["freeze"] = stdout_pip_freeze
323
+ collection_state.data["status"] = CollectionStatusV2.OK
324
+ # FIXME: The `task_list` key is likely not used by any client,
325
+ # we should consider dropping it
326
+ task_read_list = [
327
+ TaskReadV2(**task.model_dump()).dict()
328
+ for task in task_group.task_list
329
+ ]
330
+ collection_state.data["task_list"] = task_read_list
331
+ flag_modified(collection_state, "data")
332
+ db.commit()
333
+ logger.debug("finalising - END")
334
+ logger.debug("END")
335
+
336
+ except Exception as collection_e:
337
+ # Delete corrupted package dir
338
+ try:
339
+ logger.info(f"Now delete folder {task_group.path}")
340
+
341
+ shutil.rmtree(task_group.path)
342
+ logger.info(f"Deleted folder {task_group.path}")
343
+ except Exception as rm_e:
344
+ logger.error(
345
+ "Removing folder failed.\n"
346
+ f"Original error:\n{str(rm_e)}"
347
+ )
348
+
349
+ _handle_failure(
350
+ state_id=state_id,
351
+ logger_name=LOGGER_NAME,
352
+ log_file_path=log_file_path,
353
+ exception=collection_e,
354
+ db=db,
355
+ task_group_id=task_group.id,
356
+ )
357
+ return