Skip to content

Commit eb22047

Browse files
authored
Merge branch 'master' into feature/wallets-direct-link
2 parents 12f54aa + e1c7636 commit eb22047

File tree

18 files changed

+582
-440
lines changed

18 files changed

+582
-440
lines changed

api/specs/web-server/_projects_crud.py

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -12,23 +12,23 @@
1212
from typing import Annotated
1313

1414
from fastapi import APIRouter, Depends, status
15+
from models_library.api_schemas_long_running_tasks.tasks import TaskGet
1516
from models_library.api_schemas_webserver.projects import (
1617
ProjectCopyOverride,
1718
ProjectCreateNew,
1819
ProjectGet,
1920
ProjectListItem,
2021
ProjectReplace,
2122
ProjectUpdate,
22-
TaskGet,
2323
)
2424
from models_library.generics import Envelope
2525
from models_library.projects import ProjectID
2626
from models_library.rest_pagination import Page
27-
from servicelib.aiohttp.long_running_tasks.server import TaskGet
2827
from simcore_service_webserver._meta import API_VTAG
28+
from simcore_service_webserver.projects._common_models import ProjectPathParams
2929
from simcore_service_webserver.projects._crud_handlers import (
30-
_ProjectCreateParams,
31-
_ProjectListParams,
30+
ProjectCreateParams,
31+
ProjectListParams,
3232
)
3333

3434
router = APIRouter(
@@ -39,19 +39,14 @@
3939
)
4040

4141

42-
#
43-
# API entrypoints
44-
#
45-
46-
4742
@router.post(
4843
"/projects",
4944
response_model=Envelope[TaskGet],
5045
summary="Creates a new project or copies an existing one",
5146
status_code=status.HTTP_201_CREATED,
5247
)
5348
async def create_project(
54-
_params: Annotated[_ProjectCreateParams, Depends()],
49+
_params: Annotated[ProjectCreateParams, Depends()],
5550
_create: ProjectCreateNew | ProjectCopyOverride,
5651
):
5752
...
@@ -61,7 +56,7 @@ async def create_project(
6156
"/projects",
6257
response_model=Page[ProjectListItem],
6358
)
64-
async def list_projects(_params: Annotated[_ProjectListParams, Depends()]):
59+
async def list_projects(_params: Annotated[ProjectListParams, Depends()]):
6560
...
6661

6762

@@ -103,3 +98,14 @@ async def update_project(project_id: ProjectID, update: ProjectUpdate):
10398
)
10499
async def delete_project(project_id: ProjectID):
105100
...
101+
102+
103+
@router.post(
104+
"/projects/{project_id}:clone",
105+
response_model=Envelope[TaskGet],
106+
status_code=status.HTTP_201_CREATED,
107+
)
108+
async def clone_project(
109+
_params: Annotated[ProjectPathParams, Depends()],
110+
):
111+
...

packages/service-library/src/servicelib/aiohttp/long_running_tasks/client.py

Lines changed: 23 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import asyncio
2+
from collections.abc import AsyncGenerator, Coroutine
23
from dataclasses import dataclass
3-
from typing import Any, AsyncGenerator, Coroutine, Final, Optional
4+
from typing import Any, Final, TypeAlias
45

56
from aiohttp import ClientConnectionError, ClientSession, web
6-
from pydantic import Json
77
from tenacity import TryAgain, retry
88
from tenacity._asyncio import AsyncRetrying
99
from tenacity.retry import retry_if_exception_type
@@ -14,30 +14,27 @@
1414
from ..rest_responses import unwrap_envelope
1515
from .server import TaskGet, TaskId, TaskProgress, TaskStatus
1616

17-
RequestBody = Json
17+
RequestBody: TypeAlias = Any
1818

19-
_MINUTE: Final[int] = 60
20-
_HOUR: Final[int] = 60 * _MINUTE
19+
_MINUTE: Final[int] = 60 # in secs
20+
_HOUR: Final[int] = 60 * _MINUTE # in secs
2121
_DEFAULT_POLL_INTERVAL_S: Final[float] = 1
22-
_DEFAULT_AIOHTTP_RETRY_POLICY = dict(
23-
retry=retry_if_exception_type(ClientConnectionError),
24-
wait=wait_random_exponential(max=20),
25-
stop=stop_after_delay(60),
26-
reraise=True,
27-
)
22+
_DEFAULT_AIOHTTP_RETRY_POLICY = {
23+
"retry": retry_if_exception_type(ClientConnectionError),
24+
"wait": wait_random_exponential(max=20),
25+
"stop": stop_after_delay(60),
26+
"reraise": True,
27+
}
2828

2929

3030
@retry(**_DEFAULT_AIOHTTP_RETRY_POLICY)
31-
async def _start(
32-
session: ClientSession, url: URL, json: Optional[RequestBody]
33-
) -> TaskGet:
31+
async def _start(session: ClientSession, url: URL, json: RequestBody | None) -> TaskGet:
3432
async with session.post(url, json=json) as response:
3533
response.raise_for_status()
3634
data, error = unwrap_envelope(await response.json())
3735
assert not error # nosec
3836
assert data is not None # nosec
39-
task = TaskGet.parse_obj(data)
40-
return task
37+
return TaskGet.parse_obj(data)
4138

4239

4340
@retry(**_DEFAULT_AIOHTTP_RETRY_POLICY)
@@ -48,7 +45,6 @@ async def _wait_for_completion(
4845
client_timeout: int,
4946
) -> AsyncGenerator[TaskProgress, None]:
5047
try:
51-
5248
async for attempt in AsyncRetrying(
5349
stop=stop_after_delay(client_timeout),
5450
reraise=True,
@@ -70,16 +66,13 @@ async def _wait_for_completion(
7066
)
7167
)
7268
)
73-
raise TryAgain(
74-
f"{task_id=}, {task_status.started=} has "
75-
f"status: '{task_status.task_progress.message}'"
76-
f" {task_status.task_progress.percent}%"
77-
)
69+
msg = f"{task_id=}, {task_status.started=} has status: '{task_status.task_progress.message}' {task_status.task_progress.percent}%"
70+
raise TryAgain(msg) # noqa: TRY301
71+
7872
except TryAgain as exc:
7973
# this is a timeout
80-
raise asyncio.TimeoutError(
81-
f"Long running task {task_id}, calling to {status_url} timed-out after {client_timeout} seconds"
82-
) from exc
74+
msg = f"Long running task {task_id}, calling to {status_url} timed-out after {client_timeout} seconds"
75+
raise asyncio.TimeoutError(msg) from exc
8376

8477

8578
@retry(**_DEFAULT_AIOHTTP_RETRY_POLICY)
@@ -91,6 +84,7 @@ async def _task_result(session: ClientSession, result_url: URL) -> Any:
9184
assert not error # nosec
9285
assert data # nosec
9386
return data
87+
return None
9488

9589

9690
@retry(**_DEFAULT_AIOHTTP_RETRY_POLICY)
@@ -105,21 +99,22 @@ async def _abort_task(session: ClientSession, abort_url: URL) -> None:
10599
@dataclass(frozen=True)
106100
class LRTask:
107101
progress: TaskProgress
108-
_result: Optional[Coroutine[Any, Any, Any]] = None
102+
_result: Coroutine[Any, Any, Any] | None = None
109103

110104
def done(self) -> bool:
111105
return self._result is not None
112106

113107
async def result(self) -> Any:
114108
if not self._result:
115-
raise ValueError("No result ready!")
109+
msg = "No result ready!"
110+
raise ValueError(msg)
116111
return await self._result
117112

118113

119114
async def long_running_task_request(
120115
session: ClientSession,
121116
url: URL,
122-
json: Optional[RequestBody] = None,
117+
json: RequestBody | None = None,
123118
client_timeout: int = 1 * _HOUR,
124119
) -> AsyncGenerator[LRTask, None]:
125120
"""Will use the passed `ClientSession` to call an oSparc long

services/api-server/src/simcore_service_api_server/api/routes/solvers_jobs.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,9 @@ async def list_jobs(
9999
)
100100
_logger.debug("Listing Jobs in Solver '%s'", solver.name)
101101

102-
projects_page = await webserver_api.list_projects(solver.name, limit=20, offset=0)
102+
projects_page = await webserver_api.get_projects_w_solver_page(
103+
solver.name, limit=20, offset=0
104+
)
103105

104106
jobs: deque[Job] = deque()
105107
for prj in projects_page.data:
@@ -140,7 +142,7 @@ async def get_jobs_page(
140142
)
141143
_logger.debug("Listing Jobs in Solver '%s'", solver.name)
142144

143-
projects_page = await webserver_api.list_projects(
145+
projects_page = await webserver_api.get_projects_w_solver_page(
144146
solver.name, limit=page_params.limit, offset=page_params.offset
145147
)
146148

services/api-server/src/simcore_service_api_server/api/routes/studies.py

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ async def list_studies(
4747
4848
New in *version 0.5.0* (only with API_SERVER_DEV_FEATURES_ENABLED=1)
4949
"""
50-
projects_page = await webserver_api.list_user_projects(
50+
projects_page = await webserver_api.get_projects_page(
5151
limit=page_params.limit, offset=page_params.offset
5252
)
5353

@@ -63,7 +63,7 @@ async def list_studies(
6363

6464

6565
@router.get(
66-
"/{study_id}",
66+
"/{study_id:uuid}",
6767
response_model=Study,
6868
responses={**_COMMON_ERROR_RESPONSES},
6969
include_in_schema=API_SERVER_DEV_FEATURES_ENABLED,
@@ -87,8 +87,19 @@ async def get_study(
8787
)
8888

8989

90+
@router.post(
91+
"/{study_id:uuid}",
92+
response_model=Study,
93+
responses={**_COMMON_ERROR_RESPONSES},
94+
include_in_schema=API_SERVER_DEV_FEATURES_ENABLED,
95+
)
96+
async def clone_study(study_id: StudyID):
97+
msg = f"cloning study with study_id={study_id!r}. SEE https://github.com/ITISFoundation/osparc-simcore/issues/4651"
98+
raise NotImplementedError(msg)
99+
100+
90101
@router.get(
91-
"/{study_id}/ports",
102+
"/{study_id:uuid}/ports",
92103
response_model=OnePage[StudyPort],
93104
responses={**_COMMON_ERROR_RESPONSES},
94105
include_in_schema=API_SERVER_DEV_FEATURES_ENABLED,

services/api-server/src/simcore_service_api_server/services/webserver.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -248,7 +248,7 @@ async def get_project(self, project_id: UUID) -> ProjectGet:
248248
data: JSON | None = self._get_data_or_raise_http_exception(response)
249249
return ProjectGet.parse_obj(data)
250250

251-
async def list_projects(
251+
async def get_projects_w_solver_page(
252252
self, solver_name: str, limit: int, offset: int
253253
) -> Page[ProjectGet]:
254254
return await self._page_projects(
@@ -260,7 +260,7 @@ async def list_projects(
260260
search=urllib.parse.quote(solver_name, safe=""),
261261
)
262262

263-
async def list_user_projects(self, limit: int, offset: int):
263+
async def get_projects_page(self, limit: int, offset: int):
264264
return await self._page_projects(
265265
limit=limit,
266266
offset=offset,

services/api-server/tests/unit/api_studies/test_api_routes_studies.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,3 +128,15 @@ async def test_list_study_ports(
128128
resp = await client.get(f"/v0/studies/{study_id}/ports", auth=auth)
129129
assert resp.status_code == status.HTTP_200_OK
130130
assert resp.json() == {"items": fake_study_ports, "total": len(fake_study_ports)}
131+
132+
133+
@pytest.mark.xfail(
134+
reason="Under dev: https://github.com/ITISFoundation/osparc-simcore/issues/4651"
135+
)
136+
async def test_clone_study(
137+
client: httpx.AsyncClient,
138+
auth: httpx.BasicAuth,
139+
study_id: StudyID,
140+
):
141+
resp = await client.post(f"/v0/studies/{study_id}:clone", auth=auth)
142+
assert resp.status_code == status.HTTP_201_CREATED

services/web/server/VERSION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
0.25.0
1+
0.26.0

services/web/server/setup.cfg

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
[bumpversion]
2-
current_version = 0.25.0
2+
current_version = 0.26.0
33
commit = True
44
message = services/webserver api version: {current_version} → {new_version}
55
tag = False
@@ -12,7 +12,7 @@ commit_args = --no-verify
1212
[tool:pytest]
1313
addopts = --strict-markers
1414
asyncio_mode = auto
15-
markers =
15+
markers =
1616
slow: marks tests as slow (deselect with '-m "not slow"')
1717
acceptance_test: "marks tests as 'acceptance tests' i.e. does the system do what the user expects? Typically those are workflows."
1818
testit: "marks test to run during development"

services/web/server/src/simcore_service_webserver/api/v0/openapi.yaml

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ info:
33
title: simcore-service-webserver
44
description: ' Main service with an interface (http-API & websockets) to the web
55
front-end'
6-
version: 0.25.0
6+
version: 0.26.0
77
servers:
88
- url: ''
99
description: webserver
@@ -2136,6 +2136,27 @@ paths:
21362136
application/json:
21372137
schema:
21382138
$ref: '#/components/schemas/Envelope_ProjectGet_'
2139+
/v0/projects/{project_id}:clone:
2140+
post:
2141+
tags:
2142+
- projects
2143+
summary: Clone Project
2144+
operationId: clone_project
2145+
parameters:
2146+
- required: true
2147+
schema:
2148+
title: Project Id
2149+
type: string
2150+
format: uuid
2151+
name: project_id
2152+
in: path
2153+
responses:
2154+
'201':
2155+
description: Successful Response
2156+
content:
2157+
application/json:
2158+
schema:
2159+
$ref: '#/components/schemas/Envelope_TaskGet_'
21392160
/v0/projects/{project_id}/metadata:
21402161
get:
21412162
tags:
@@ -6968,13 +6989,11 @@ components:
69686989
exclusiveMinimum: true
69696990
type: integer
69706991
minimum: 0
6971-
default: []
69726992
classifiers:
69736993
title: Classifiers
69746994
type: array
69756995
items:
69766996
type: string
6977-
default: []
69786997
additionalProperties: false
69796998
description: 'inspired by Docker API https://docs.docker.com/engine/api/v1.43/#tag/Container/operation/ContainerList.
69806999

0 commit comments

Comments
 (0)