fractal-server 1.4.9__py3-none-any.whl → 2.0.0a0__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 (132) hide show
  1. fractal_server/__init__.py +1 -1
  2. fractal_server/app/models/__init__.py +4 -7
  3. fractal_server/app/models/linkuserproject.py +9 -0
  4. fractal_server/app/models/security.py +6 -0
  5. fractal_server/app/models/state.py +1 -1
  6. fractal_server/app/models/v1/__init__.py +10 -0
  7. fractal_server/app/models/{dataset.py → v1/dataset.py} +5 -5
  8. fractal_server/app/models/{job.py → v1/job.py} +5 -5
  9. fractal_server/app/models/{project.py → v1/project.py} +5 -5
  10. fractal_server/app/models/{task.py → v1/task.py} +7 -2
  11. fractal_server/app/models/{workflow.py → v1/workflow.py} +5 -5
  12. fractal_server/app/models/v2/__init__.py +20 -0
  13. fractal_server/app/models/v2/dataset.py +55 -0
  14. fractal_server/app/models/v2/job.py +51 -0
  15. fractal_server/app/models/v2/project.py +31 -0
  16. fractal_server/app/models/v2/task.py +93 -0
  17. fractal_server/app/models/v2/workflow.py +43 -0
  18. fractal_server/app/models/v2/workflowtask.py +90 -0
  19. fractal_server/app/routes/{admin.py → admin/v1.py} +42 -42
  20. fractal_server/app/routes/admin/v2.py +275 -0
  21. fractal_server/app/routes/api/v1/__init__.py +7 -7
  22. fractal_server/app/routes/api/v1/_aux_functions.py +2 -2
  23. fractal_server/app/routes/api/v1/dataset.py +44 -37
  24. fractal_server/app/routes/api/v1/job.py +12 -12
  25. fractal_server/app/routes/api/v1/project.py +23 -21
  26. fractal_server/app/routes/api/v1/task.py +24 -14
  27. fractal_server/app/routes/api/v1/task_collection.py +16 -14
  28. fractal_server/app/routes/api/v1/workflow.py +24 -24
  29. fractal_server/app/routes/api/v1/workflowtask.py +10 -10
  30. fractal_server/app/routes/api/v2/__init__.py +28 -0
  31. fractal_server/app/routes/api/v2/_aux_functions.py +497 -0
  32. fractal_server/app/routes/api/v2/apply.py +220 -0
  33. fractal_server/app/routes/api/v2/dataset.py +310 -0
  34. fractal_server/app/routes/api/v2/images.py +212 -0
  35. fractal_server/app/routes/api/v2/job.py +200 -0
  36. fractal_server/app/routes/api/v2/project.py +205 -0
  37. fractal_server/app/routes/api/v2/task.py +222 -0
  38. fractal_server/app/routes/api/v2/task_collection.py +229 -0
  39. fractal_server/app/routes/api/v2/workflow.py +398 -0
  40. fractal_server/app/routes/api/v2/workflowtask.py +269 -0
  41. fractal_server/app/routes/aux/_job.py +1 -1
  42. fractal_server/app/runner/async_wrap.py +27 -0
  43. fractal_server/app/runner/exceptions.py +129 -0
  44. fractal_server/app/runner/executors/local/__init__.py +3 -0
  45. fractal_server/app/runner/{_local → executors/local}/executor.py +2 -2
  46. fractal_server/app/runner/executors/slurm/__init__.py +3 -0
  47. fractal_server/app/runner/{_slurm → executors/slurm}/_batching.py +1 -1
  48. fractal_server/app/runner/executors/slurm/_check_jobs_status.py +72 -0
  49. fractal_server/app/runner/{_slurm → executors/slurm}/_executor_wait_thread.py +3 -4
  50. fractal_server/app/runner/{_slurm → executors/slurm}/_slurm_config.py +3 -152
  51. fractal_server/app/runner/{_slurm → executors/slurm}/_subprocess_run_as_user.py +1 -1
  52. fractal_server/app/runner/{_slurm → executors/slurm}/executor.py +9 -9
  53. fractal_server/app/runner/filenames.py +6 -0
  54. fractal_server/app/runner/set_start_and_last_task_index.py +39 -0
  55. fractal_server/app/runner/task_files.py +105 -0
  56. fractal_server/app/runner/{__init__.py → v1/__init__.py} +36 -49
  57. fractal_server/app/runner/{_common.py → v1/_common.py} +13 -120
  58. fractal_server/app/runner/{_local → v1/_local}/__init__.py +6 -6
  59. fractal_server/app/runner/{_local → v1/_local}/_local_config.py +6 -7
  60. fractal_server/app/runner/{_local → v1/_local}/_submit_setup.py +1 -5
  61. fractal_server/app/runner/v1/_slurm/__init__.py +310 -0
  62. fractal_server/app/runner/{_slurm → v1/_slurm}/_submit_setup.py +3 -9
  63. fractal_server/app/runner/v1/_slurm/get_slurm_config.py +163 -0
  64. fractal_server/app/runner/v1/common.py +117 -0
  65. fractal_server/app/runner/{handle_failed_job.py → v1/handle_failed_job.py} +8 -8
  66. fractal_server/app/runner/v2/__init__.py +337 -0
  67. fractal_server/app/runner/v2/_local/__init__.py +169 -0
  68. fractal_server/app/runner/v2/_local/_local_config.py +118 -0
  69. fractal_server/app/runner/v2/_local/_submit_setup.py +52 -0
  70. fractal_server/app/runner/v2/_slurm/__init__.py +157 -0
  71. fractal_server/app/runner/v2/_slurm/_submit_setup.py +83 -0
  72. fractal_server/app/runner/v2/_slurm/get_slurm_config.py +179 -0
  73. fractal_server/app/runner/v2/components.py +5 -0
  74. fractal_server/app/runner/v2/deduplicate_list.py +24 -0
  75. fractal_server/app/runner/v2/handle_failed_job.py +156 -0
  76. fractal_server/app/runner/v2/merge_outputs.py +41 -0
  77. fractal_server/app/runner/v2/runner.py +264 -0
  78. fractal_server/app/runner/v2/runner_functions.py +339 -0
  79. fractal_server/app/runner/v2/runner_functions_low_level.py +134 -0
  80. fractal_server/app/runner/v2/task_interface.py +43 -0
  81. fractal_server/app/runner/v2/v1_compat.py +21 -0
  82. fractal_server/app/schemas/__init__.py +4 -42
  83. fractal_server/app/schemas/v1/__init__.py +42 -0
  84. fractal_server/app/schemas/{applyworkflow.py → v1/applyworkflow.py} +18 -18
  85. fractal_server/app/schemas/{dataset.py → v1/dataset.py} +30 -30
  86. fractal_server/app/schemas/{dumps.py → v1/dumps.py} +8 -8
  87. fractal_server/app/schemas/{manifest.py → v1/manifest.py} +5 -5
  88. fractal_server/app/schemas/{project.py → v1/project.py} +9 -9
  89. fractal_server/app/schemas/{task.py → v1/task.py} +12 -12
  90. fractal_server/app/schemas/{task_collection.py → v1/task_collection.py} +7 -7
  91. fractal_server/app/schemas/{workflow.py → v1/workflow.py} +38 -38
  92. fractal_server/app/schemas/v2/__init__.py +34 -0
  93. fractal_server/app/schemas/v2/dataset.py +88 -0
  94. fractal_server/app/schemas/v2/dumps.py +87 -0
  95. fractal_server/app/schemas/v2/job.py +113 -0
  96. fractal_server/app/schemas/v2/manifest.py +109 -0
  97. fractal_server/app/schemas/v2/project.py +36 -0
  98. fractal_server/app/schemas/v2/task.py +121 -0
  99. fractal_server/app/schemas/v2/task_collection.py +105 -0
  100. fractal_server/app/schemas/v2/workflow.py +78 -0
  101. fractal_server/app/schemas/v2/workflowtask.py +118 -0
  102. fractal_server/config.py +5 -10
  103. fractal_server/images/__init__.py +50 -0
  104. fractal_server/images/tools.py +86 -0
  105. fractal_server/main.py +11 -3
  106. fractal_server/migrations/versions/4b35c5cefbe3_tmp_is_v2_compatible.py +39 -0
  107. fractal_server/migrations/versions/56af171b0159_v2.py +217 -0
  108. fractal_server/migrations/versions/876f28db9d4e_tmp_split_task_and_wftask_meta.py +68 -0
  109. fractal_server/migrations/versions/974c802f0dd0_tmp_workflowtaskv2_type_in_db.py +37 -0
  110. fractal_server/migrations/versions/9cd305cd6023_tmp_workflowtaskv2.py +40 -0
  111. fractal_server/migrations/versions/a6231ed6273c_tmp_args_schemas_in_taskv2.py +42 -0
  112. fractal_server/migrations/versions/b9e9eed9d442_tmp_taskv2_type.py +37 -0
  113. fractal_server/migrations/versions/e3e639454d4b_tmp_make_task_meta_non_optional.py +50 -0
  114. fractal_server/tasks/__init__.py +0 -5
  115. fractal_server/tasks/endpoint_operations.py +13 -19
  116. fractal_server/tasks/utils.py +35 -0
  117. fractal_server/tasks/{_TaskCollectPip.py → v1/_TaskCollectPip.py} +3 -3
  118. fractal_server/tasks/{background_operations.py → v1/background_operations.py} +18 -50
  119. fractal_server/tasks/v1/get_collection_data.py +14 -0
  120. fractal_server/tasks/v2/_TaskCollectPip.py +103 -0
  121. fractal_server/tasks/v2/background_operations.py +382 -0
  122. fractal_server/tasks/v2/get_collection_data.py +14 -0
  123. {fractal_server-1.4.9.dist-info → fractal_server-2.0.0a0.dist-info}/METADATA +3 -4
  124. fractal_server-2.0.0a0.dist-info/RECORD +166 -0
  125. fractal_server/app/runner/_slurm/.gitignore +0 -2
  126. fractal_server/app/runner/_slurm/__init__.py +0 -150
  127. fractal_server/app/runner/common.py +0 -311
  128. fractal_server-1.4.9.dist-info/RECORD +0 -97
  129. /fractal_server/app/runner/{_slurm → executors/slurm}/remote.py +0 -0
  130. {fractal_server-1.4.9.dist-info → fractal_server-2.0.0a0.dist-info}/LICENSE +0 -0
  131. {fractal_server-1.4.9.dist-info → fractal_server-2.0.0a0.dist-info}/WHEEL +0 -0
  132. {fractal_server-1.4.9.dist-info → fractal_server-2.0.0a0.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,497 @@
1
+ """
2
+ Auxiliary functions to get object from the database or perform simple checks
3
+ """
4
+ from typing import Any
5
+ from typing import Literal
6
+ from typing import Optional
7
+ from typing import Union
8
+
9
+ from fastapi import HTTPException
10
+ from fastapi import status
11
+ from sqlmodel import select
12
+ from sqlmodel.sql.expression import SelectOfScalar
13
+
14
+ from ....db import AsyncSession
15
+ from ....models.v1 import Task
16
+ from ....models.v2 import DatasetV2
17
+ from ....models.v2 import JobV2
18
+ from ....models.v2 import LinkUserProjectV2
19
+ from ....models.v2 import ProjectV2
20
+ from ....models.v2 import TaskV2
21
+ from ....models.v2 import WorkflowTaskV2
22
+ from ....models.v2 import WorkflowV2
23
+ from ....schemas.v1 import JobStatusTypeV1
24
+ from ....security import User
25
+ from fractal_server.images import Filters
26
+
27
+
28
+ async def _get_project_check_owner(
29
+ *,
30
+ project_id: int,
31
+ user_id: int,
32
+ db: AsyncSession,
33
+ ) -> ProjectV2:
34
+ """
35
+ Check that user is a member of project and return the project.
36
+
37
+ Args:
38
+ project_id:
39
+ user_id:
40
+ db:
41
+ version:
42
+
43
+ Returns:
44
+ The project object
45
+
46
+ Raises:
47
+ HTTPException(status_code=403_FORBIDDEN):
48
+ If the user is not a member of the project
49
+ HTTPException(status_code=404_NOT_FOUND):
50
+ If the project does not exist
51
+ """
52
+ project = await db.get(ProjectV2, project_id)
53
+
54
+ link_user_project = await db.get(LinkUserProjectV2, (project_id, user_id))
55
+ if not project:
56
+ raise HTTPException(
57
+ status_code=status.HTTP_404_NOT_FOUND, detail="Project not found"
58
+ )
59
+ if not link_user_project:
60
+ raise HTTPException(
61
+ status_code=status.HTTP_403_FORBIDDEN,
62
+ detail=f"Not allowed on project {project_id}",
63
+ )
64
+
65
+ return project
66
+
67
+
68
+ async def _get_workflow_check_owner(
69
+ *,
70
+ workflow_id: int,
71
+ project_id: int,
72
+ user_id: int,
73
+ db: AsyncSession,
74
+ ) -> WorkflowV2:
75
+ """
76
+ Get a workflow and a project, after access control on the project.
77
+
78
+ Args:
79
+ workflow_id:
80
+ project_id:
81
+ user_id:
82
+ db:
83
+
84
+ Returns:
85
+ The workflow object.
86
+
87
+ Raises:
88
+ HTTPException(status_code=404_NOT_FOUND):
89
+ If the workflow does not exist
90
+ HTTPException(status_code=422_UNPROCESSABLE_ENTITY):
91
+ If the workflow is not associated to the project
92
+ """
93
+
94
+ # Access control for project
95
+ project = await _get_project_check_owner(
96
+ project_id=project_id, user_id=user_id, db=db
97
+ )
98
+ # Get workflow
99
+ workflow = await db.get(WorkflowV2, workflow_id)
100
+ if not workflow:
101
+ raise HTTPException(
102
+ status_code=status.HTTP_404_NOT_FOUND, detail="Workflow not found"
103
+ )
104
+ if workflow.project_id != project.id:
105
+ raise HTTPException(
106
+ status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
107
+ detail=(f"Invalid {project_id=} for {workflow_id=}."),
108
+ )
109
+
110
+ # Refresh so that workflow.project relationship is loaded (see discussion
111
+ # in issue #1063)
112
+ await db.refresh(workflow)
113
+
114
+ return workflow
115
+
116
+
117
+ async def _get_workflow_task_check_owner(
118
+ *,
119
+ project_id: int,
120
+ workflow_id: int,
121
+ workflow_task_id: int,
122
+ user_id: int,
123
+ db: AsyncSession,
124
+ ) -> tuple[WorkflowTaskV2, WorkflowV2]:
125
+ """
126
+ Check that user has access to Workflow and WorkflowTask.
127
+
128
+ Args:
129
+ project_id:
130
+ workflow_id:
131
+ workflow_task_id:
132
+ user_id:
133
+ db:
134
+
135
+ Returns:
136
+ Tuple of WorkflowTask and Workflow objects.
137
+
138
+ Raises:
139
+ HTTPException(status_code=404_NOT_FOUND):
140
+ If the WorkflowTask does not exist
141
+ HTTPException(status_code=422_UNPROCESSABLE_ENTITY):
142
+ If the WorkflowTask is not associated to the Workflow
143
+ """
144
+
145
+ # Access control for workflow
146
+ workflow = await _get_workflow_check_owner(
147
+ workflow_id=workflow_id,
148
+ project_id=project_id,
149
+ user_id=user_id,
150
+ db=db,
151
+ )
152
+
153
+ # If WorkflowTask is not in the db, exit
154
+ workflow_task = await db.get(WorkflowTaskV2, workflow_task_id)
155
+ if not workflow_task:
156
+ raise HTTPException(
157
+ status_code=status.HTTP_404_NOT_FOUND,
158
+ detail="WorkflowTask not found",
159
+ )
160
+
161
+ # If WorkflowTask is not part of the expected Workflow, exit
162
+ if workflow_id != workflow_task.workflow_id:
163
+ raise HTTPException(
164
+ status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
165
+ detail=f"Invalid {workflow_id=} for {workflow_task_id=}",
166
+ )
167
+
168
+ return workflow_task, workflow
169
+
170
+
171
+ async def _check_workflow_exists(
172
+ *,
173
+ name: str,
174
+ project_id: int,
175
+ db: AsyncSession,
176
+ ) -> None:
177
+ """
178
+ Check that no other workflow of this project has the same name.
179
+
180
+ Args:
181
+ name: Workflow name
182
+ project_id: Project ID
183
+ db:
184
+
185
+ Raises:
186
+ HTTPException(status_code=422_UNPROCESSABLE_ENTITY):
187
+ If such a workflow already exists
188
+ """
189
+ stm = (
190
+ select(WorkflowV2)
191
+ .where(WorkflowV2.name == name)
192
+ .where(WorkflowV2.project_id == project_id)
193
+ )
194
+ res = await db.execute(stm)
195
+ if res.scalars().all():
196
+ raise HTTPException(
197
+ status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
198
+ detail=f"Workflow with {name=} and {project_id=} already exists.",
199
+ )
200
+
201
+
202
+ async def _check_project_exists(
203
+ *,
204
+ project_name: str,
205
+ user_id: int,
206
+ db: AsyncSession,
207
+ ) -> None:
208
+ """
209
+ Check that no other project with this name exists for this user.
210
+
211
+ Args:
212
+ project_name: Project name
213
+ user_id: User ID
214
+ db:
215
+
216
+ Raises:
217
+ HTTPException(status_code=422_UNPROCESSABLE_ENTITY):
218
+ If such a project already exists
219
+ """
220
+ stm = (
221
+ select(ProjectV2)
222
+ .join(LinkUserProjectV2)
223
+ .where(ProjectV2.name == project_name)
224
+ .where(LinkUserProjectV2.user_id == user_id)
225
+ )
226
+ res = await db.execute(stm)
227
+ if res.scalars().all():
228
+ raise HTTPException(
229
+ status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
230
+ detail=f"Project name ({project_name}) already in use",
231
+ )
232
+
233
+
234
+ async def _get_dataset_check_owner(
235
+ *,
236
+ project_id: int,
237
+ dataset_id: int,
238
+ user_id: int,
239
+ db: AsyncSession,
240
+ ) -> dict[Literal["dataset", "project"], Union[DatasetV2, ProjectV2]]:
241
+ """
242
+ Get a dataset and a project, after access control on the project
243
+
244
+ Args:
245
+ project_id:
246
+ dataset_id:
247
+ user_id:
248
+ db:
249
+
250
+ Returns:
251
+ Dictionary with the dataset and project objects (keys: `dataset`,
252
+ `project`).
253
+
254
+ Raises:
255
+ HTTPException(status_code=422_UNPROCESSABLE_ENTITY):
256
+ If the dataset is not associated to the project
257
+ """
258
+
259
+ # Access control for project
260
+ project = await _get_project_check_owner(
261
+ project_id=project_id, user_id=user_id, db=db
262
+ )
263
+ # Get dataset
264
+ dataset = await db.get(DatasetV2, dataset_id)
265
+ if not dataset:
266
+ raise HTTPException(
267
+ status_code=status.HTTP_404_NOT_FOUND, detail="Dataset not found"
268
+ )
269
+ if dataset.project_id != project_id:
270
+ raise HTTPException(
271
+ status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
272
+ detail=f"Invalid {project_id=} for {dataset_id=}",
273
+ )
274
+
275
+ # Refresh so that dataset.project relationship is loaded (see discussion
276
+ # in issue #1063)
277
+ await db.refresh(dataset)
278
+
279
+ return dict(dataset=dataset, project=project)
280
+
281
+
282
+ async def _get_job_check_owner(
283
+ *,
284
+ project_id: int,
285
+ job_id: int,
286
+ user_id: int,
287
+ db: AsyncSession,
288
+ ) -> dict[Literal["job", "project"], Union[JobV2, ProjectV2]]:
289
+ """
290
+ Get a job and a project, after access control on the project
291
+
292
+ Args:
293
+ project_id:
294
+ job_id:
295
+ user_id:
296
+ db:
297
+
298
+ Returns:
299
+ Dictionary with the job and project objects (keys: `job`,
300
+ `project`).
301
+
302
+ Raises:
303
+ HTTPException(status_code=422_UNPROCESSABLE_ENTITY):
304
+ If the job is not associated to the project
305
+ """
306
+ # Access control for project
307
+ project = await _get_project_check_owner(
308
+ project_id=project_id,
309
+ user_id=user_id,
310
+ db=db,
311
+ )
312
+ # Get dataset
313
+ job = await db.get(JobV2, job_id)
314
+ if not job:
315
+ raise HTTPException(
316
+ status_code=status.HTTP_404_NOT_FOUND, detail="Job not found"
317
+ )
318
+ if job.project_id != project_id:
319
+ raise HTTPException(
320
+ status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
321
+ detail=f"Invalid {project_id=} for {job_id=}",
322
+ )
323
+ return dict(job=job, project=project)
324
+
325
+
326
+ async def _get_task_check_owner(
327
+ *,
328
+ task_id: int,
329
+ user: User,
330
+ db: AsyncSession,
331
+ ) -> TaskV2:
332
+ """
333
+ Get a task, after access control.
334
+
335
+ This check constitutes a preliminary version of access control:
336
+ if the current user is not a superuser and differs from the task owner
337
+ (including when `owner is None`), we raise an 403 HTTP Exception.
338
+
339
+ Args:
340
+ task_id:
341
+ user:
342
+ db:
343
+
344
+ Returns:
345
+ The task object.
346
+
347
+ Raises:
348
+ HTTPException(status_code=404_NOT_FOUND):
349
+ If the task does not exist
350
+ HTTPException(status_code=403_FORBIDDEN):
351
+ If the user does not have rights to edit this task.
352
+ """
353
+ task = await db.get(TaskV2, task_id)
354
+ if not task:
355
+ raise HTTPException(
356
+ status_code=status.HTTP_404_NOT_FOUND,
357
+ detail=f"TaskV2 {task_id} not found.",
358
+ )
359
+
360
+ if not user.is_superuser:
361
+ if task.owner is None:
362
+ raise HTTPException(
363
+ status_code=status.HTTP_403_FORBIDDEN,
364
+ detail=(
365
+ "Only a superuser can modify a TaskV2 with `owner=None`."
366
+ ),
367
+ )
368
+ else:
369
+ owner = user.username or user.slurm_user
370
+ if owner != task.owner:
371
+ raise HTTPException(
372
+ status_code=status.HTTP_403_FORBIDDEN,
373
+ detail=(
374
+ f"Current user ({owner}) cannot modify TaskV2 "
375
+ f"{task.id} with different owner ({task.owner})."
376
+ ),
377
+ )
378
+ return task
379
+
380
+
381
+ def _get_submitted_jobs_statement() -> SelectOfScalar:
382
+ """
383
+ Returns:
384
+ A sqlmodel statement that selects all `ApplyWorkflow`s with
385
+ `ApplyWorkflow.status` equal to `submitted`.
386
+ """
387
+ stm = select(JobV2).where(JobV2.status == JobStatusTypeV1.SUBMITTED)
388
+ return stm
389
+
390
+
391
+ async def _workflow_insert_task(
392
+ *,
393
+ workflow_id: int,
394
+ task_id: int,
395
+ is_legacy_task: bool = False,
396
+ order: Optional[int] = None,
397
+ meta_parallel: Optional[dict[str, Any]] = None,
398
+ meta_non_parallel: Optional[dict[str, Any]] = None,
399
+ args_non_parallel: Optional[dict[str, Any]] = None,
400
+ args_parallel: Optional[dict[str, Any]] = None,
401
+ input_filters: Optional[Filters] = None,
402
+ db: AsyncSession,
403
+ ) -> WorkflowTaskV2:
404
+ """
405
+ Insert a new WorkflowTask into Workflow.task_list
406
+
407
+ Args:
408
+ task_id: TBD
409
+ args: TBD
410
+ meta: TBD
411
+ order: TBD
412
+ db: TBD
413
+ """
414
+ db_workflow = await db.get(WorkflowV2, workflow_id)
415
+ if db_workflow is None:
416
+ raise ValueError(f"Workflow {workflow_id} does not exist")
417
+
418
+ if order is None:
419
+ order = len(db_workflow.task_list)
420
+
421
+ # Get task from db, and extract default arguments via a Task property
422
+ # method
423
+ if is_legacy_task is True:
424
+ db_task = await db.get(Task, task_id)
425
+ if db_task is None:
426
+ raise ValueError(f"Task {task_id} not found.")
427
+ task_type = "parallel"
428
+
429
+ final_args_parallel = db_task.default_args_from_args_schema.copy()
430
+ final_args_non_parallel = {}
431
+ final_meta_parallel = (db_task.meta or {}).copy()
432
+ final_meta_non_parallel = {}
433
+
434
+ else:
435
+ db_task = await db.get(TaskV2, task_id)
436
+ if db_task is None:
437
+ raise ValueError(f"TaskV2 {task_id} not found.")
438
+ task_type = db_task.type
439
+
440
+ final_args_non_parallel = (
441
+ db_task.default_args_non_parallel_from_args_schema.copy()
442
+ )
443
+ final_args_parallel = (
444
+ db_task.default_args_parallel_from_args_schema.copy()
445
+ )
446
+ final_meta_parallel = (db_task.meta_parallel or {}).copy()
447
+ final_meta_non_parallel = (db_task.meta_non_parallel or {}).copy()
448
+
449
+ # Combine arg_parallel
450
+ if args_parallel is not None:
451
+ for k, v in args_parallel.items():
452
+ final_args_parallel[k] = v
453
+ if final_args_parallel == {}:
454
+ final_args_parallel = None
455
+ # Combine arg_non_parallel
456
+ if args_non_parallel is not None:
457
+ for k, v in args_non_parallel.items():
458
+ final_args_non_parallel[k] = v
459
+ if final_args_non_parallel == {}:
460
+ final_args_non_parallel = None
461
+
462
+ # Combine meta_parallel (higher priority)
463
+ # and db_task.meta_parallel (lower priority)
464
+ final_meta_parallel.update(meta_parallel or {})
465
+ if final_meta_parallel == {}:
466
+ final_meta_parallel = None
467
+ # Combine meta_non_parallel (higher priority)
468
+ # and db_task.meta_non_parallel (lower priority)
469
+ final_meta_non_parallel.update(meta_non_parallel or {})
470
+ if final_meta_non_parallel == {}:
471
+ final_meta_non_parallel = None
472
+
473
+ # Prepare input_filters attribute
474
+ if input_filters is None:
475
+ input_filters_kwarg = {}
476
+ else:
477
+ input_filters_kwarg = dict(input_filters=input_filters)
478
+
479
+ # Create DB entry
480
+ wf_task = WorkflowTaskV2(
481
+ task_type=task_type,
482
+ is_legacy_task=is_legacy_task,
483
+ task_id=(task_id if not is_legacy_task else None),
484
+ task_legacy_id=(task_id if is_legacy_task else None),
485
+ args_non_parallel=final_args_non_parallel,
486
+ args_parallel=final_args_parallel,
487
+ meta_parallel=final_meta_parallel,
488
+ meta_non_parallel=final_meta_non_parallel,
489
+ **input_filters_kwarg,
490
+ )
491
+ db.add(wf_task)
492
+ db_workflow.task_list.insert(order, wf_task)
493
+ db_workflow.task_list.reorder() # type: ignore
494
+ await db.commit()
495
+ await db.refresh(wf_task)
496
+
497
+ return wf_task
@@ -0,0 +1,220 @@
1
+ from datetime import datetime
2
+ from datetime import timedelta
3
+ from datetime import timezone
4
+ from typing import Optional
5
+
6
+ from fastapi import APIRouter
7
+ from fastapi import BackgroundTasks
8
+ from fastapi import Depends
9
+ from fastapi import HTTPException
10
+ from fastapi import status
11
+ from sqlmodel import select
12
+
13
+ from .....config import get_settings
14
+ from .....syringe import Inject
15
+ from ....db import AsyncSession
16
+ from ....db import get_async_db
17
+ from ....models.v2 import JobV2
18
+ from ....runner.set_start_and_last_task_index import (
19
+ set_start_and_last_task_index,
20
+ ) # FIXME V2
21
+ from ....runner.v2 import submit_workflow
22
+ from ....schemas.v2 import JobCreateV2
23
+ from ....schemas.v2 import JobReadV2
24
+ from ....schemas.v2 import JobStatusTypeV2
25
+ from ....security import current_active_verified_user
26
+ from ....security import User
27
+ from ._aux_functions import _get_dataset_check_owner
28
+ from ._aux_functions import _get_workflow_check_owner
29
+
30
+
31
+ def _encode_as_utc(dt: datetime):
32
+ return dt.replace(tzinfo=timezone.utc).isoformat()
33
+
34
+
35
+ router = APIRouter()
36
+
37
+
38
+ @router.post(
39
+ "/project/{project_id}/workflow/{workflow_id}/apply/",
40
+ status_code=status.HTTP_202_ACCEPTED,
41
+ response_model=JobReadV2,
42
+ )
43
+ async def apply_workflow(
44
+ project_id: int,
45
+ workflow_id: int,
46
+ dataset_id: int,
47
+ job_create: JobCreateV2,
48
+ background_tasks: BackgroundTasks,
49
+ user: User = Depends(current_active_verified_user),
50
+ db: AsyncSession = Depends(get_async_db),
51
+ ) -> Optional[JobReadV2]:
52
+
53
+ output = await _get_dataset_check_owner(
54
+ project_id=project_id,
55
+ dataset_id=dataset_id,
56
+ user_id=user.id,
57
+ db=db,
58
+ )
59
+ project = output["project"]
60
+ dataset = output["dataset"]
61
+
62
+ if dataset.read_only:
63
+ raise HTTPException(
64
+ status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
65
+ detail=(
66
+ "Cannot apply workflow because dataset "
67
+ f"({dataset_id=}) is read_only."
68
+ ),
69
+ )
70
+
71
+ workflow = await _get_workflow_check_owner(
72
+ project_id=project_id, workflow_id=workflow_id, user_id=user.id, db=db
73
+ )
74
+
75
+ if not workflow.task_list:
76
+ raise HTTPException(
77
+ status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
78
+ detail=f"Workflow {workflow_id} has empty task list",
79
+ )
80
+
81
+ # Set values of first_task_index and last_task_index
82
+ num_tasks = len(workflow.task_list)
83
+ try:
84
+ first_task_index, last_task_index = set_start_and_last_task_index(
85
+ num_tasks,
86
+ first_task_index=job_create.first_task_index,
87
+ last_task_index=job_create.last_task_index,
88
+ )
89
+ job_create.first_task_index = first_task_index
90
+ job_create.last_task_index = last_task_index
91
+ except ValueError as e:
92
+ raise HTTPException(
93
+ status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
94
+ detail=(
95
+ "Invalid values for first_task_index or last_task_index "
96
+ f"(with {num_tasks=}).\n"
97
+ f"Original error: {str(e)}"
98
+ ),
99
+ )
100
+
101
+ # If backend is SLURM, check that the user has required attributes
102
+ settings = Inject(get_settings)
103
+ backend = settings.FRACTAL_RUNNER_BACKEND
104
+ if backend == "slurm":
105
+ if not user.slurm_user:
106
+ raise HTTPException(
107
+ status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
108
+ detail=(
109
+ f"FRACTAL_RUNNER_BACKEND={backend}, "
110
+ f"but {user.slurm_user=}."
111
+ ),
112
+ )
113
+ if not user.cache_dir:
114
+ raise HTTPException(
115
+ status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
116
+ detail=(
117
+ f"FRACTAL_RUNNER_BACKEND={backend}, "
118
+ f"but {user.cache_dir=}."
119
+ ),
120
+ )
121
+
122
+ # Check that no other job with the same dataset_id is SUBMITTED
123
+ stm = (
124
+ select(JobV2)
125
+ .where(JobV2.dataset_id == dataset_id)
126
+ .where(JobV2.status == JobStatusTypeV2.SUBMITTED)
127
+ )
128
+ res = await db.execute(stm)
129
+ if res.scalars().all():
130
+ raise HTTPException(
131
+ status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
132
+ detail=(
133
+ f"Dataset {dataset_id} is already in use "
134
+ "in submitted job(s)."
135
+ ),
136
+ )
137
+
138
+ if job_create.slurm_account is not None:
139
+ if job_create.slurm_account not in user.slurm_accounts:
140
+ raise HTTPException(
141
+ status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
142
+ detail=(
143
+ f"SLURM account '{job_create.slurm_account}' is not "
144
+ "among those available to the current user"
145
+ ),
146
+ )
147
+ else:
148
+ if len(user.slurm_accounts) > 0:
149
+ job_create.slurm_account = user.slurm_accounts[0]
150
+
151
+ # Add new ApplyWorkflow object to DB
152
+ job = JobV2(
153
+ project_id=project_id,
154
+ dataset_id=dataset_id,
155
+ workflow_id=workflow_id,
156
+ user_email=user.email,
157
+ dataset_dump=dict(
158
+ **dataset.model_dump(exclude={"timestamp_created"}),
159
+ timestamp_created=_encode_as_utc(dataset.timestamp_created),
160
+ ),
161
+ workflow_dump=dict(
162
+ **workflow.model_dump(exclude={"task_list", "timestamp_created"}),
163
+ timestamp_created=_encode_as_utc(workflow.timestamp_created),
164
+ ),
165
+ project_dump=dict(
166
+ **project.model_dump(exclude={"user_list", "timestamp_created"}),
167
+ timestamp_created=_encode_as_utc(project.timestamp_created),
168
+ ),
169
+ **job_create.dict(),
170
+ )
171
+
172
+ # Rate Limiting:
173
+ # raise `429 TOO MANY REQUESTS` if this endpoint has been called with the
174
+ # same database keys (Project, Workflow and Datasets) during the last
175
+ # `settings.FRACTAL_API_SUBMIT_RATE_LIMIT` seconds.
176
+ stm = (
177
+ select(JobV2)
178
+ .where(JobV2.project_id == project_id)
179
+ .where(JobV2.workflow_id == workflow_id)
180
+ .where(JobV2.dataset_id == dataset_id)
181
+ )
182
+ res = await db.execute(stm)
183
+ db_jobs = res.scalars().all()
184
+ if db_jobs and any(
185
+ abs(
186
+ job.start_timestamp
187
+ - db_job.start_timestamp.replace(tzinfo=timezone.utc)
188
+ )
189
+ < timedelta(seconds=settings.FRACTAL_API_SUBMIT_RATE_LIMIT)
190
+ for db_job in db_jobs
191
+ ):
192
+ raise HTTPException(
193
+ status_code=status.HTTP_429_TOO_MANY_REQUESTS,
194
+ detail=(
195
+ f"The endpoint 'POST /api/v2/project/{project_id}/workflow/"
196
+ f"{workflow_id}/apply/' "
197
+ "was called several times within an interval of less "
198
+ f"than {settings.FRACTAL_API_SUBMIT_RATE_LIMIT} seconds, using"
199
+ " the same foreign keys. If it was intentional, please wait "
200
+ "and try again."
201
+ ),
202
+ )
203
+
204
+ db.add(job)
205
+ await db.commit()
206
+ await db.refresh(job)
207
+
208
+ background_tasks.add_task(
209
+ submit_workflow,
210
+ workflow_id=workflow.id,
211
+ dataset_id=dataset.id,
212
+ job_id=job.id,
213
+ worker_init=job.worker_init,
214
+ slurm_user=user.slurm_user,
215
+ user_cache_dir=user.cache_dir,
216
+ )
217
+
218
+ await db.close()
219
+
220
+ return job