fractal-server 1.4.10__py3-none-any.whl → 2.0.0a1__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 (126) hide show
  1. fractal_server/__init__.py +1 -1
  2. fractal_server/app/models/__init__.py +3 -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 +11 -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 +274 -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 +37 -37
  24. fractal_server/app/routes/api/v1/job.py +14 -14
  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/dataset.py +309 -0
  33. fractal_server/app/routes/api/v2/images.py +207 -0
  34. fractal_server/app/routes/api/v2/job.py +200 -0
  35. fractal_server/app/routes/api/v2/project.py +202 -0
  36. fractal_server/app/routes/api/v2/submit.py +220 -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 +397 -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/components.py +5 -0
  44. fractal_server/app/runner/exceptions.py +129 -0
  45. fractal_server/app/runner/executors/slurm/__init__.py +3 -0
  46. fractal_server/app/runner/{_slurm → executors/slurm}/_batching.py +1 -1
  47. fractal_server/app/runner/{_slurm → executors/slurm}/_check_jobs_status.py +1 -1
  48. fractal_server/app/runner/{_slurm → executors/slurm}/_executor_wait_thread.py +1 -1
  49. fractal_server/app/runner/{_slurm → executors/slurm}/_slurm_config.py +3 -152
  50. fractal_server/app/runner/{_slurm → executors/slurm}/_subprocess_run_as_user.py +1 -1
  51. fractal_server/app/runner/{_slurm → executors/slurm}/executor.py +32 -19
  52. fractal_server/app/runner/filenames.py +6 -0
  53. fractal_server/app/runner/set_start_and_last_task_index.py +39 -0
  54. fractal_server/app/runner/task_files.py +103 -0
  55. fractal_server/app/runner/{__init__.py → v1/__init__.py} +22 -20
  56. fractal_server/app/runner/{_common.py → v1/_common.py} +13 -120
  57. fractal_server/app/runner/{_local → v1/_local}/__init__.py +5 -5
  58. fractal_server/app/runner/{_local → v1/_local}/_local_config.py +6 -7
  59. fractal_server/app/runner/{_local → v1/_local}/_submit_setup.py +1 -5
  60. fractal_server/app/runner/v1/_slurm/__init__.py +310 -0
  61. fractal_server/app/runner/{_slurm → v1/_slurm}/_submit_setup.py +3 -9
  62. fractal_server/app/runner/v1/_slurm/get_slurm_config.py +163 -0
  63. fractal_server/app/runner/v1/common.py +117 -0
  64. fractal_server/app/runner/{handle_failed_job.py → v1/handle_failed_job.py} +8 -8
  65. fractal_server/app/runner/v2/__init__.py +336 -0
  66. fractal_server/app/runner/v2/_local/__init__.py +167 -0
  67. fractal_server/app/runner/v2/_local/_local_config.py +118 -0
  68. fractal_server/app/runner/v2/_local/_submit_setup.py +52 -0
  69. fractal_server/app/runner/v2/_local/executor.py +100 -0
  70. fractal_server/app/runner/{_slurm → v2/_slurm}/__init__.py +34 -45
  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/deduplicate_list.py +22 -0
  74. fractal_server/app/runner/v2/handle_failed_job.py +156 -0
  75. fractal_server/app/runner/v2/merge_outputs.py +38 -0
  76. fractal_server/app/runner/v2/runner.py +267 -0
  77. fractal_server/app/runner/v2/runner_functions.py +341 -0
  78. fractal_server/app/runner/v2/runner_functions_low_level.py +134 -0
  79. fractal_server/app/runner/v2/task_interface.py +43 -0
  80. fractal_server/app/runner/v2/v1_compat.py +21 -0
  81. fractal_server/app/schemas/__init__.py +4 -42
  82. fractal_server/app/schemas/v1/__init__.py +42 -0
  83. fractal_server/app/schemas/{applyworkflow.py → v1/applyworkflow.py} +18 -18
  84. fractal_server/app/schemas/{dataset.py → v1/dataset.py} +30 -30
  85. fractal_server/app/schemas/{dumps.py → v1/dumps.py} +8 -8
  86. fractal_server/app/schemas/{manifest.py → v1/manifest.py} +5 -5
  87. fractal_server/app/schemas/{project.py → v1/project.py} +9 -9
  88. fractal_server/app/schemas/{task.py → v1/task.py} +12 -12
  89. fractal_server/app/schemas/{task_collection.py → v1/task_collection.py} +7 -7
  90. fractal_server/app/schemas/{workflow.py → v1/workflow.py} +38 -38
  91. fractal_server/app/schemas/v2/__init__.py +34 -0
  92. fractal_server/app/schemas/v2/dataset.py +89 -0
  93. fractal_server/app/schemas/v2/dumps.py +87 -0
  94. fractal_server/app/schemas/v2/job.py +114 -0
  95. fractal_server/app/schemas/v2/manifest.py +159 -0
  96. fractal_server/app/schemas/v2/project.py +37 -0
  97. fractal_server/app/schemas/v2/task.py +120 -0
  98. fractal_server/app/schemas/v2/task_collection.py +105 -0
  99. fractal_server/app/schemas/v2/workflow.py +79 -0
  100. fractal_server/app/schemas/v2/workflowtask.py +119 -0
  101. fractal_server/config.py +5 -4
  102. fractal_server/images/__init__.py +2 -0
  103. fractal_server/images/models.py +50 -0
  104. fractal_server/images/tools.py +85 -0
  105. fractal_server/main.py +11 -3
  106. fractal_server/migrations/env.py +0 -2
  107. fractal_server/migrations/versions/d71e732236cd_v2.py +239 -0
  108. fractal_server/tasks/__init__.py +0 -5
  109. fractal_server/tasks/endpoint_operations.py +13 -19
  110. fractal_server/tasks/utils.py +35 -0
  111. fractal_server/tasks/{_TaskCollectPip.py → v1/_TaskCollectPip.py} +3 -3
  112. fractal_server/tasks/{background_operations.py → v1/background_operations.py} +18 -50
  113. fractal_server/tasks/v1/get_collection_data.py +14 -0
  114. fractal_server/tasks/v2/_TaskCollectPip.py +103 -0
  115. fractal_server/tasks/v2/background_operations.py +381 -0
  116. fractal_server/tasks/v2/get_collection_data.py +14 -0
  117. {fractal_server-1.4.10.dist-info → fractal_server-2.0.0a1.dist-info}/METADATA +1 -1
  118. fractal_server-2.0.0a1.dist-info/RECORD +160 -0
  119. fractal_server/app/runner/_slurm/.gitignore +0 -2
  120. fractal_server/app/runner/common.py +0 -311
  121. fractal_server-1.4.10.dist-info/RECORD +0 -98
  122. /fractal_server/app/runner/{_slurm → executors/slurm}/remote.py +0 -0
  123. /fractal_server/app/runner/{_local → v1/_local}/executor.py +0 -0
  124. {fractal_server-1.4.10.dist-info → fractal_server-2.0.0a1.dist-info}/LICENSE +0 -0
  125. {fractal_server-1.4.10.dist-info → fractal_server-2.0.0a1.dist-info}/WHEEL +0 -0
  126. {fractal_server-1.4.10.dist-info → fractal_server-2.0.0a1.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,309 @@
1
+ import json
2
+ from pathlib import Path
3
+ from typing import Optional
4
+
5
+ from fastapi import APIRouter
6
+ from fastapi import Depends
7
+ from fastapi import HTTPException
8
+ from fastapi import Response
9
+ from fastapi import status
10
+ from sqlmodel import select
11
+
12
+ from ....db import AsyncSession
13
+ from ....db import get_async_db
14
+ from ....models.v2 import DatasetV2
15
+ from ....models.v2 import JobV2
16
+ from ....models.v2 import ProjectV2
17
+ from ....schemas.v2 import DatasetCreateV2
18
+ from ....schemas.v2 import DatasetReadV2
19
+ from ....schemas.v2 import DatasetUpdateV2
20
+ from ....schemas.v2.dataset import DatasetStatusReadV2
21
+ from ....schemas.v2.dataset import WorkflowTaskStatusTypeV2
22
+ from ....security import current_active_user
23
+ from ....security import User
24
+ from ._aux_functions import _get_dataset_check_owner
25
+ from ._aux_functions import _get_project_check_owner
26
+ from ._aux_functions import _get_submitted_jobs_statement
27
+ from ._aux_functions import _get_workflow_check_owner
28
+ from fractal_server.app.runner.filenames import HISTORY_FILENAME
29
+
30
+ router = APIRouter()
31
+
32
+
33
+ @router.post(
34
+ "/project/{project_id}/dataset/",
35
+ response_model=DatasetReadV2,
36
+ status_code=status.HTTP_201_CREATED,
37
+ )
38
+ async def create_dataset(
39
+ project_id: int,
40
+ dataset: DatasetCreateV2,
41
+ user: User = Depends(current_active_user),
42
+ db: AsyncSession = Depends(get_async_db),
43
+ ) -> Optional[DatasetReadV2]:
44
+ """
45
+ Add new dataset to current project
46
+ """
47
+ await _get_project_check_owner(
48
+ project_id=project_id, user_id=user.id, db=db
49
+ )
50
+ db_dataset = DatasetV2(project_id=project_id, **dataset.dict())
51
+ db.add(db_dataset)
52
+ await db.commit()
53
+ await db.refresh(db_dataset)
54
+ await db.close()
55
+
56
+ return db_dataset
57
+
58
+
59
+ @router.get(
60
+ "/project/{project_id}/dataset/",
61
+ response_model=list[DatasetReadV2],
62
+ )
63
+ async def read_dataset_list(
64
+ project_id: int,
65
+ history: bool = True,
66
+ user: User = Depends(current_active_user),
67
+ db: AsyncSession = Depends(get_async_db),
68
+ ) -> Optional[list[DatasetReadV2]]:
69
+ """
70
+ Get dataset list for given project
71
+ """
72
+ # Access control
73
+ project = await _get_project_check_owner(
74
+ project_id=project_id, user_id=user.id, db=db
75
+ )
76
+ # Find datasets of the current project. Note: this select/where approach
77
+ # has much better scaling than refreshing all elements of
78
+ # `project.dataset_list` - ref
79
+ # https://github.com/fractal-analytics-platform/fractal-server/pull/1082#issuecomment-1856676097.
80
+ stm = select(DatasetV2).where(DatasetV2.project_id == project.id)
81
+ res = await db.execute(stm)
82
+ dataset_list = res.scalars().all()
83
+ await db.close()
84
+ if not history:
85
+ for ds in dataset_list:
86
+ setattr(ds, "history", [])
87
+ return dataset_list
88
+
89
+
90
+ @router.get(
91
+ "/project/{project_id}/dataset/{dataset_id}/",
92
+ response_model=DatasetReadV2,
93
+ )
94
+ async def read_dataset(
95
+ project_id: int,
96
+ dataset_id: int,
97
+ user: User = Depends(current_active_user),
98
+ db: AsyncSession = Depends(get_async_db),
99
+ ) -> Optional[DatasetReadV2]:
100
+ """
101
+ Get info on a dataset associated to the current project
102
+ """
103
+ output = await _get_dataset_check_owner(
104
+ project_id=project_id,
105
+ dataset_id=dataset_id,
106
+ user_id=user.id,
107
+ db=db,
108
+ )
109
+ dataset = output["dataset"]
110
+ await db.close()
111
+ return dataset
112
+
113
+
114
+ @router.patch(
115
+ "/project/{project_id}/dataset/{dataset_id}/",
116
+ response_model=DatasetReadV2,
117
+ )
118
+ async def update_dataset(
119
+ project_id: int,
120
+ dataset_id: int,
121
+ dataset_update: DatasetUpdateV2,
122
+ user: User = Depends(current_active_user),
123
+ db: AsyncSession = Depends(get_async_db),
124
+ ) -> Optional[DatasetReadV2]:
125
+ """
126
+ Edit a dataset associated to the current project
127
+ """
128
+
129
+ output = await _get_dataset_check_owner(
130
+ project_id=project_id,
131
+ dataset_id=dataset_id,
132
+ user_id=user.id,
133
+ db=db,
134
+ )
135
+ db_dataset = output["dataset"]
136
+
137
+ for key, value in dataset_update.dict(exclude_unset=True).items():
138
+ setattr(db_dataset, key, value)
139
+
140
+ await db.commit()
141
+ await db.refresh(db_dataset)
142
+ await db.close()
143
+ return db_dataset
144
+
145
+
146
+ @router.delete(
147
+ "/project/{project_id}/dataset/{dataset_id}/",
148
+ status_code=204,
149
+ )
150
+ async def delete_dataset(
151
+ project_id: int,
152
+ dataset_id: int,
153
+ user: User = Depends(current_active_user),
154
+ db: AsyncSession = Depends(get_async_db),
155
+ ) -> Response:
156
+ """
157
+ Delete a dataset associated to the current project
158
+ """
159
+ output = await _get_dataset_check_owner(
160
+ project_id=project_id,
161
+ dataset_id=dataset_id,
162
+ user_id=user.id,
163
+ db=db,
164
+ )
165
+ dataset = output["dataset"]
166
+
167
+ # Fail if there exist jobs that are submitted and in relation with the
168
+ # current dataset.
169
+ stm = _get_submitted_jobs_statement().where(JobV2.dataset_id == dataset_id)
170
+ res = await db.execute(stm)
171
+ jobs = res.scalars().all()
172
+ if jobs:
173
+ string_ids = str([job.id for job in jobs])[1:-1]
174
+ raise HTTPException(
175
+ status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
176
+ detail=(
177
+ f"Cannot delete dataset {dataset.id} because it "
178
+ f"is linked to active job(s) {string_ids}."
179
+ ),
180
+ )
181
+
182
+ # Cascade operations: set foreign-keys to null for jobs which are in
183
+ # relationship with the current dataset
184
+ stm = select(JobV2).where(JobV2.dataset_id == dataset_id)
185
+ res = await db.execute(stm)
186
+ jobs = res.scalars().all()
187
+ for job in jobs:
188
+ job.dataset_id = None
189
+ await db.commit()
190
+
191
+ # Delete dataset
192
+ await db.delete(dataset)
193
+ await db.commit()
194
+
195
+ return Response(status_code=status.HTTP_204_NO_CONTENT)
196
+
197
+
198
+ @router.get("/dataset/", response_model=list[DatasetReadV2])
199
+ async def get_user_datasets(
200
+ history: bool = True,
201
+ user: User = Depends(current_active_user),
202
+ db: AsyncSession = Depends(get_async_db),
203
+ ) -> list[DatasetReadV2]:
204
+ """
205
+ Returns all the datasets of the current user
206
+ """
207
+ stm = select(DatasetV2)
208
+ stm = stm.join(ProjectV2).where(
209
+ ProjectV2.user_list.any(User.id == user.id)
210
+ )
211
+
212
+ res = await db.execute(stm)
213
+ dataset_list = res.scalars().all()
214
+ await db.close()
215
+ if not history:
216
+ for ds in dataset_list:
217
+ setattr(ds, "history", [])
218
+ return dataset_list
219
+
220
+
221
+ @router.get(
222
+ "/project/{project_id}/dataset/{dataset_id}/status/",
223
+ response_model=DatasetStatusReadV2,
224
+ )
225
+ async def get_workflowtask_status(
226
+ project_id: int,
227
+ dataset_id: int,
228
+ user: User = Depends(current_active_user),
229
+ db: AsyncSession = Depends(get_async_db),
230
+ ) -> Optional[DatasetStatusReadV2]:
231
+ """
232
+ Extract the status of all `WorkflowTask`s that ran on a given `DatasetV2`.
233
+ """
234
+ # Get the dataset DB entry
235
+ output = await _get_dataset_check_owner(
236
+ project_id=project_id,
237
+ dataset_id=dataset_id,
238
+ user_id=user.id,
239
+ db=db,
240
+ )
241
+ dataset = output["dataset"]
242
+
243
+ # Check whether there exists a job such that
244
+ # 1. `job.dataset_id == dataset_id`, and
245
+ # 2. `job.status` is submitted
246
+ # If one such job exists, it will be used later. If there are multiple
247
+ # jobs, raise an error.
248
+ stm = _get_submitted_jobs_statement().where(JobV2.dataset_id == dataset_id)
249
+ res = await db.execute(stm)
250
+ running_jobs = res.scalars().all()
251
+ if len(running_jobs) == 0:
252
+ running_job = None
253
+ elif len(running_jobs) == 1:
254
+ running_job = running_jobs[0]
255
+ else:
256
+ string_ids = str([job.id for job in running_jobs])[1:-1]
257
+ raise HTTPException(
258
+ status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
259
+ detail=(
260
+ f"Cannot get WorkflowTaskV2 statuses as DatasetV2 {dataset.id}"
261
+ f" is linked to multiple active jobs: {string_ids}."
262
+ ),
263
+ )
264
+
265
+ # Initialize empty dictionary for WorkflowTaskV2 status
266
+ workflow_tasks_status_dict: dict = {}
267
+
268
+ # Lowest priority: read status from DB, which corresponds to jobs that are
269
+ # not running
270
+ history = dataset.history
271
+ for history_item in history:
272
+ wftask_id = history_item["workflowtask"]["id"]
273
+ wftask_status = history_item["status"]
274
+ workflow_tasks_status_dict[wftask_id] = wftask_status
275
+
276
+ # If a job is running, then gather more up-to-date information
277
+ if running_job is not None:
278
+ # Get the workflow DB entry
279
+ running_workflow = await _get_workflow_check_owner(
280
+ project_id=project_id,
281
+ workflow_id=running_job.workflow_id,
282
+ user_id=user.id,
283
+ db=db,
284
+ )
285
+ # Mid priority: Set all WorkflowTask's that are part of the running job
286
+ # as "submitted"
287
+ start = running_job.first_task_index
288
+ end = running_job.last_task_index + 1
289
+ for wftask in running_workflow.task_list[start:end]:
290
+ workflow_tasks_status_dict[
291
+ wftask.id
292
+ ] = WorkflowTaskStatusTypeV2.SUBMITTED
293
+
294
+ # Highest priority: Read status updates coming from the running-job
295
+ # temporary file. Note: this file only contains information on
296
+ # # WorkflowTask's that ran through successfully.
297
+ tmp_file = Path(running_job.working_dir) / HISTORY_FILENAME
298
+ try:
299
+ with tmp_file.open("r") as f:
300
+ history = json.load(f)
301
+ except FileNotFoundError:
302
+ history = []
303
+ for history_item in history:
304
+ wftask_id = history_item["workflowtask"]["id"]
305
+ wftask_status = history_item["status"]
306
+ workflow_tasks_status_dict[wftask_id] = wftask_status
307
+
308
+ response_body = DatasetStatusReadV2(status=workflow_tasks_status_dict)
309
+ return response_body
@@ -0,0 +1,207 @@
1
+ from typing import Any
2
+ from typing import Optional
3
+
4
+ from fastapi import APIRouter
5
+ from fastapi import Depends
6
+ from fastapi import HTTPException
7
+ from fastapi import Response
8
+ from fastapi import status
9
+ from pydantic import BaseModel
10
+ from pydantic import Field
11
+ from sqlalchemy.orm.attributes import flag_modified
12
+
13
+ from ._aux_functions import _get_dataset_check_owner
14
+ from fractal_server.app.db import AsyncSession
15
+ from fractal_server.app.db import get_async_db
16
+ from fractal_server.app.security import current_active_user
17
+ from fractal_server.app.security import User
18
+ from fractal_server.images import Filters
19
+ from fractal_server.images import SingleImage
20
+ from fractal_server.images.tools import match_filter
21
+
22
+ router = APIRouter()
23
+
24
+
25
+ class ImagePage(BaseModel):
26
+
27
+ total_count: int
28
+ page_size: int
29
+ current_page: int
30
+
31
+ attributes: dict[str, list[Any]]
32
+ types: list[str]
33
+
34
+ images: list[SingleImage]
35
+
36
+
37
+ class ImageQuery(BaseModel):
38
+ path: Optional[str]
39
+ filters: Filters = Field(default_factory=Filters)
40
+
41
+
42
+ @router.post(
43
+ "/project/{project_id}/dataset/{dataset_id}/images/",
44
+ status_code=status.HTTP_201_CREATED,
45
+ )
46
+ async def post_new_image(
47
+ project_id: int,
48
+ dataset_id: int,
49
+ new_image: SingleImage,
50
+ user: User = Depends(current_active_user),
51
+ db: AsyncSession = Depends(get_async_db),
52
+ ) -> Response:
53
+
54
+ output = await _get_dataset_check_owner(
55
+ project_id=project_id, dataset_id=dataset_id, user_id=user.id, db=db
56
+ )
57
+ dataset = output["dataset"]
58
+
59
+ if new_image.path in dataset.image_paths:
60
+ raise HTTPException(
61
+ status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
62
+ detail=(
63
+ f"Image with path '{new_image.path}' "
64
+ f"already in DatasetV2 {dataset_id}",
65
+ ),
66
+ )
67
+
68
+ dataset.images.append(new_image.dict())
69
+ flag_modified(dataset, "images")
70
+
71
+ await db.commit()
72
+
73
+ return Response(status_code=status.HTTP_201_CREATED)
74
+
75
+
76
+ @router.post(
77
+ "/project/{project_id}/dataset/{dataset_id}/images/query/",
78
+ response_model=ImagePage,
79
+ status_code=status.HTTP_200_OK,
80
+ )
81
+ async def query_dataset_images(
82
+ project_id: int,
83
+ dataset_id: int,
84
+ use_dataset_filters: bool = False, # query param
85
+ page: int = 1, # query param
86
+ page_size: Optional[int] = None, # query param
87
+ query: Optional[ImageQuery] = None, # body
88
+ user: User = Depends(current_active_user),
89
+ db: AsyncSession = Depends(get_async_db),
90
+ ) -> ImagePage:
91
+
92
+ if page < 1:
93
+ raise HTTPException(
94
+ status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
95
+ detail=f"Invalid pagination parameter: page={page} < 1",
96
+ )
97
+
98
+ output = await _get_dataset_check_owner(
99
+ project_id=project_id, dataset_id=dataset_id, user_id=user.id, db=db
100
+ )
101
+ dataset = output["dataset"]
102
+ images = dataset.images
103
+
104
+ if use_dataset_filters is True:
105
+ images = [
106
+ image
107
+ for image in images
108
+ if match_filter(image, Filters(**dataset.filters))
109
+ ]
110
+
111
+ attributes = {}
112
+ for image in images:
113
+ for k, v in image["attributes"].items():
114
+ attributes.setdefault(k, []).append(v)
115
+ for k, v in attributes.items():
116
+ attributes[k] = list(set(v))
117
+
118
+ types = list(
119
+ set(type for image in images for type in image["types"].keys())
120
+ )
121
+
122
+ if query is not None:
123
+
124
+ if query.path is not None:
125
+ image = next(
126
+ (image for image in images if image["path"] == query.path),
127
+ None,
128
+ )
129
+ if image is None:
130
+ images = []
131
+ else:
132
+ images = [image]
133
+
134
+ if query.filters.attributes or query.filters.types:
135
+ images = [
136
+ image
137
+ for image in images
138
+ if match_filter(
139
+ image,
140
+ Filters(**query.filters.dict()),
141
+ )
142
+ ]
143
+
144
+ total_count = len(images)
145
+
146
+ if page_size is not None:
147
+ if page_size <= 0:
148
+ raise HTTPException(
149
+ status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
150
+ detail=(
151
+ f"Invalid pagination parameter: page_size={page_size} <= 0"
152
+ ),
153
+ )
154
+ else:
155
+ page_size = total_count
156
+
157
+ if total_count == 0:
158
+ page = 1
159
+ else:
160
+ last_page = (total_count // page_size) + (total_count % page_size > 0)
161
+ if page > last_page:
162
+ page = last_page
163
+ offset = (page - 1) * page_size
164
+ images = images[offset : offset + page_size] # noqa E203
165
+
166
+ return ImagePage(
167
+ total_count=total_count,
168
+ current_page=page,
169
+ page_size=page_size,
170
+ attributes=attributes,
171
+ types=types,
172
+ images=images,
173
+ )
174
+
175
+
176
+ @router.delete(
177
+ "/project/{project_id}/dataset/{dataset_id}/images/",
178
+ status_code=status.HTTP_204_NO_CONTENT,
179
+ )
180
+ async def delete_dataset_images(
181
+ project_id: int,
182
+ dataset_id: int,
183
+ path: str,
184
+ user: User = Depends(current_active_user),
185
+ db: AsyncSession = Depends(get_async_db),
186
+ ) -> Response:
187
+
188
+ output = await _get_dataset_check_owner(
189
+ project_id=project_id, dataset_id=dataset_id, user_id=user.id, db=db
190
+ )
191
+ dataset = output["dataset"]
192
+
193
+ image_to_remove = next(
194
+ (image for image in dataset.images if image["path"] == path), None
195
+ )
196
+ if image_to_remove is None:
197
+ raise HTTPException(
198
+ status_code=status.HTTP_404_NOT_FOUND,
199
+ detail=f"No image with path '{path}' in DatasetV2 {dataset_id}.",
200
+ )
201
+
202
+ dataset.images.remove(image_to_remove)
203
+ flag_modified(dataset, "images")
204
+
205
+ await db.commit()
206
+
207
+ return Response(status_code=status.HTTP_204_NO_CONTENT)
@@ -0,0 +1,200 @@
1
+ from pathlib import Path
2
+ from typing import Optional
3
+
4
+ from fastapi import APIRouter
5
+ from fastapi import Depends
6
+ from fastapi import Response
7
+ from fastapi import status
8
+ from fastapi.responses import StreamingResponse
9
+ from sqlmodel import select
10
+
11
+ from ....db import AsyncSession
12
+ from ....db import get_async_db
13
+ from ....models.v2 import JobV2
14
+ from ....models.v2 import ProjectV2
15
+ from ....runner.filenames import WORKFLOW_LOG_FILENAME # FIXME
16
+ from ....schemas.v2 import JobReadV2
17
+ from ....schemas.v2 import JobStatusTypeV2
18
+ from ....security import current_active_user
19
+ from ....security import User
20
+ from ...aux._job import _write_shutdown_file
21
+ from ...aux._job import _zip_folder_to_byte_stream
22
+ from ...aux._runner import _check_backend_is_slurm
23
+ from ._aux_functions import _get_job_check_owner
24
+ from ._aux_functions import _get_project_check_owner
25
+ from ._aux_functions import _get_workflow_check_owner
26
+
27
+ router = APIRouter()
28
+
29
+
30
+ @router.get("/job/", response_model=list[JobReadV2])
31
+ async def get_user_jobs(
32
+ user: User = Depends(current_active_user),
33
+ log: bool = True,
34
+ db: AsyncSession = Depends(get_async_db),
35
+ ) -> list[JobReadV2]:
36
+ """
37
+ Returns all the jobs of the current user
38
+ """
39
+ stm = (
40
+ select(JobV2)
41
+ .join(ProjectV2)
42
+ .where(ProjectV2.user_list.any(User.id == user.id))
43
+ )
44
+ res = await db.execute(stm)
45
+ job_list = res.scalars().all()
46
+ await db.close()
47
+ if not log:
48
+ for job in job_list:
49
+ setattr(job, "log", None)
50
+
51
+ return job_list
52
+
53
+
54
+ @router.get(
55
+ "/project/{project_id}/workflow/{workflow_id}/job/",
56
+ response_model=list[JobReadV2],
57
+ )
58
+ async def get_workflow_jobs(
59
+ project_id: int,
60
+ workflow_id: int,
61
+ user: User = Depends(current_active_user),
62
+ db: AsyncSession = Depends(get_async_db),
63
+ ) -> Optional[list[JobReadV2]]:
64
+ """
65
+ Returns all the jobs related to a specific workflow
66
+ """
67
+ await _get_workflow_check_owner(
68
+ project_id=project_id, workflow_id=workflow_id, user_id=user.id, db=db
69
+ )
70
+ stm = select(JobV2).where(JobV2.workflow_id == workflow_id)
71
+ res = await db.execute(stm)
72
+ job_list = res.scalars().all()
73
+ return job_list
74
+
75
+
76
+ @router.get(
77
+ "/project/{project_id}/job/{job_id}/",
78
+ response_model=JobReadV2,
79
+ )
80
+ async def read_job(
81
+ project_id: int,
82
+ job_id: int,
83
+ show_tmp_logs: bool = False,
84
+ user: User = Depends(current_active_user),
85
+ db: AsyncSession = Depends(get_async_db),
86
+ ) -> Optional[JobReadV2]:
87
+ """
88
+ Return info on an existing job
89
+ """
90
+
91
+ output = await _get_job_check_owner(
92
+ project_id=project_id,
93
+ job_id=job_id,
94
+ user_id=user.id,
95
+ db=db,
96
+ )
97
+ job = output["job"]
98
+ await db.close()
99
+
100
+ if show_tmp_logs and (job.status == JobStatusTypeV2.SUBMITTED):
101
+ try:
102
+ with open(f"{job.working_dir}/{WORKFLOW_LOG_FILENAME}", "r") as f:
103
+ job.log = f.read()
104
+ except FileNotFoundError:
105
+ pass
106
+
107
+ return job
108
+
109
+
110
+ @router.get(
111
+ "/project/{project_id}/job/{job_id}/download/",
112
+ response_class=StreamingResponse,
113
+ )
114
+ async def download_job_logs(
115
+ project_id: int,
116
+ job_id: int,
117
+ user: User = Depends(current_active_user),
118
+ db: AsyncSession = Depends(get_async_db),
119
+ ) -> StreamingResponse:
120
+ """
121
+ Download job folder
122
+ """
123
+ output = await _get_job_check_owner(
124
+ project_id=project_id,
125
+ job_id=job_id,
126
+ user_id=user.id,
127
+ db=db,
128
+ )
129
+ job = output["job"]
130
+
131
+ # Create and return byte stream for zipped log folder
132
+ PREFIX_ZIP = Path(job.working_dir).name
133
+ zip_filename = f"{PREFIX_ZIP}_archive.zip"
134
+ byte_stream = _zip_folder_to_byte_stream(
135
+ folder=job.working_dir, zip_filename=zip_filename
136
+ )
137
+ return StreamingResponse(
138
+ iter([byte_stream.getvalue()]),
139
+ media_type="application/x-zip-compressed",
140
+ headers={"Content-Disposition": f"attachment;filename={zip_filename}"},
141
+ )
142
+
143
+
144
+ @router.get(
145
+ "/project/{project_id}/job/",
146
+ response_model=list[JobReadV2],
147
+ )
148
+ async def get_job_list(
149
+ project_id: int,
150
+ user: User = Depends(current_active_user),
151
+ log: bool = True,
152
+ db: AsyncSession = Depends(get_async_db),
153
+ ) -> Optional[list[JobReadV2]]:
154
+ """
155
+ Get job list for given project
156
+ """
157
+ project = await _get_project_check_owner(
158
+ project_id=project_id, user_id=user.id, db=db
159
+ )
160
+
161
+ stm = select(JobV2).where(JobV2.project_id == project.id)
162
+ res = await db.execute(stm)
163
+ job_list = res.scalars().all()
164
+ await db.close()
165
+ if not log:
166
+ for job in job_list:
167
+ setattr(job, "log", None)
168
+
169
+ return job_list
170
+
171
+
172
+ @router.get(
173
+ "/project/{project_id}/job/{job_id}/stop/",
174
+ status_code=202,
175
+ )
176
+ async def stop_job(
177
+ project_id: int,
178
+ job_id: int,
179
+ user: User = Depends(current_active_user),
180
+ db: AsyncSession = Depends(get_async_db),
181
+ ) -> Response:
182
+ """
183
+ Stop execution of a workflow job (only available for slurm backend)
184
+ """
185
+
186
+ # This endpoint is only implemented for SLURM backend
187
+ _check_backend_is_slurm()
188
+
189
+ # Get job from DB
190
+ output = await _get_job_check_owner(
191
+ project_id=project_id,
192
+ job_id=job_id,
193
+ user_id=user.id,
194
+ db=db,
195
+ )
196
+ job = output["job"]
197
+
198
+ _write_shutdown_file(job=job)
199
+
200
+ return Response(status_code=status.HTTP_202_ACCEPTED)