Skip to content

Commit 6d59ea1

Browse files
Merge branch 'release-candidate/25.7' into marc/hotfix-25.7
2 parents 700fd91 + f4c9b96 commit 6d59ea1

File tree

6 files changed

+509
-0
lines changed

6 files changed

+509
-0
lines changed

flow360/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from flow360.accounts_utils import Accounts
66
from flow360.cli.api_set_func import configure_caller as configure
77
from flow360.component.case import Case
8+
from flow360.component.cloud_examples import show_available_examples
89
from flow360.component.geometry import Geometry
910
from flow360.component.project import Project
1011
from flow360.component.simulation import migration, services
@@ -318,4 +319,5 @@
318319
"MovingStatistic",
319320
"ImportedSurface",
320321
"RunControl",
322+
"show_available_examples",
321323
]

flow360/cloud/flow360_requests.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,3 +250,10 @@ class RenameAssetRequestV2(Flow360RequestsV2):
250250
"""
251251

252252
name: str = pd_v2.Field(description="case to rename")
253+
254+
255+
class CopyExampleRequest(Flow360RequestsV2):
256+
"""Data model for copying an example to create a new project"""
257+
258+
source_example_id: str = pd_v2.Field(alias="sourceExampleId", description="example ID to copy")
259+
target_parent_folder_id: str = pd_v2.Field(alias="targetParentFolderId", default="ROOT.FLOW360")

flow360/cloud/responses.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
"""Response models for Flow360 API responses"""
2+
3+
from typing import List, Optional
4+
5+
import pydantic as pd_v2
6+
7+
8+
class CopyExampleResponse(pd_v2.BaseModel):
9+
"""Response model for copying an example"""
10+
11+
id: str = pd_v2.Field(description="Project ID created from the example")
12+
13+
14+
class ExampleItem(pd_v2.BaseModel):
15+
"""Response model for a single example item"""
16+
17+
tags: List[str] = pd_v2.Field(description="Tags associated with the example")
18+
id: str = pd_v2.Field(description="Example ID")
19+
type: str = pd_v2.Field(description="Type of the example resource")
20+
resource_id: str = pd_v2.Field(alias="resourceId", description="Project ID of the example")
21+
s3path: str = pd_v2.Field(description="S3 path to the example image")
22+
title: str = pd_v2.Field(description="Title of the example")
23+
created_at: str = pd_v2.Field(alias="createdAt", description="Creation timestamp")
24+
order: int = pd_v2.Field(description="Display order of the example")
25+
26+
27+
class ExamplesListResponse(pd_v2.BaseModel):
28+
"""Response model for the examples list API"""
29+
30+
data: List[ExampleItem] = pd_v2.Field(description="List of available examples")
31+
warning: Optional[str] = pd_v2.Field(default=None, description="Warning message if any")
Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
1+
"""Cloud examples interface for fetching and copying Flow360 examples"""
2+
3+
from __future__ import annotations
4+
5+
import difflib
6+
import time
7+
from typing import List, Optional, Tuple
8+
9+
import pydantic as pd_v2
10+
11+
from flow360.cloud.flow360_requests import CopyExampleRequest
12+
from flow360.cloud.responses import (
13+
CopyExampleResponse,
14+
ExampleItem,
15+
ExamplesListResponse,
16+
)
17+
from flow360.cloud.rest_api import RestApi
18+
from flow360.component.interfaces import ProjectInterface
19+
from flow360.environment import Env
20+
from flow360.exceptions import Flow360Error, Flow360ValueError, Flow360WebError
21+
from flow360.log import log
22+
23+
24+
def find_example_by_name(query_name: str, examples: List[ExampleItem]) -> Tuple[ExampleItem, float]:
25+
"""
26+
Find the best matching example by name using fuzzy string matching.
27+
28+
Parameters
29+
----------
30+
query_name : str
31+
The name to search for (case-insensitive, handles typos).
32+
examples : List[ExampleItem]
33+
List of available examples to search through.
34+
35+
Returns
36+
-------
37+
Tuple[ExampleItem, float]
38+
The best matching example and its similarity score (0.0 to 1.0).
39+
40+
Raises
41+
------
42+
Flow360ValueError
43+
If no examples are provided or no match is found.
44+
"""
45+
if not examples:
46+
raise Flow360ValueError("No examples available to search.")
47+
48+
query_lower = query_name.lower().strip()
49+
best_match = None
50+
best_score = 0.0
51+
52+
for example in examples:
53+
example_title_lower = example.title.lower()
54+
score = difflib.SequenceMatcher(None, query_lower, example_title_lower).ratio()
55+
56+
if score > best_score:
57+
best_score = score
58+
best_match = example
59+
60+
if best_match is None or best_score < 0.3:
61+
available_names = [ex.title for ex in examples]
62+
raise Flow360ValueError(
63+
f"No matching example found for '{query_name}'. "
64+
f"Available examples: {', '.join(available_names[:5])}"
65+
+ (f" (and {len(available_names) - 5} more)" if len(available_names) > 5 else "")
66+
)
67+
68+
return best_match, best_score
69+
70+
71+
def fetch_examples() -> List[ExampleItem]:
72+
"""
73+
Fetch available examples from the cloud.
74+
75+
Returns
76+
-------
77+
List[ExampleItem]
78+
List of available example items.
79+
"""
80+
api = RestApi("public/v2/examples")
81+
resp = api.get()
82+
if resp is None:
83+
return []
84+
try:
85+
response_model = ExamplesListResponse(**resp if isinstance(resp, dict) else {"data": resp})
86+
return response_model.data
87+
except (pd_v2.ValidationError, TypeError, ValueError) as e:
88+
log.warning(f"Failed to parse examples response: {e}")
89+
return []
90+
91+
92+
def show_available_examples() -> None:
93+
"""
94+
Display available examples in a formatted table.
95+
96+
Shows a list of pre-executed project examples that can be copied and visited
97+
on the Flow360 web interface.
98+
"""
99+
examples = fetch_examples()
100+
if not examples:
101+
log.info("No examples available.")
102+
return
103+
104+
examples_url = Env.current.get_web_real_url("examples")
105+
log.info(f"These examples are pre-executed projects that can be visited on {examples_url}")
106+
log.info("")
107+
108+
title_width = max(len(e.title) for e in examples)
109+
id_width = max(len(e.id) for e in examples)
110+
111+
header = f"{'#':>3} {'Title'.ljust(title_width)} {'Example ID'.ljust(id_width)} Tags"
112+
table_string = ""
113+
table_string += header + "\n"
114+
table_string += "-" * len(header) + "\n"
115+
116+
for idx, ex in enumerate(examples):
117+
title = ex.title
118+
example_id = ex.id
119+
tags = ", ".join(ex.tags)
120+
table_string += (
121+
f"{idx+1:>3} {title.ljust(title_width)} {example_id.ljust(id_width)} {tags}\n"
122+
)
123+
124+
log.info(table_string)
125+
126+
127+
def _get_project_copy_status(project_id: str) -> Optional[str]:
128+
"""
129+
Get the copy status of a project.
130+
131+
Parameters
132+
----------
133+
project_id : str
134+
Project ID to check.
135+
136+
Returns
137+
-------
138+
Optional[str]
139+
Copy status of the project, or None if not available.
140+
"""
141+
try:
142+
project_api = RestApi(ProjectInterface.endpoint, id=project_id)
143+
info = project_api.get()
144+
if isinstance(info, dict):
145+
return info.get("copyStatus")
146+
except Flow360Error:
147+
pass
148+
return None
149+
150+
151+
def _wait_for_copy_completion(project_id: str, timeout_minutes: int = 30) -> None:
152+
"""
153+
Wait for the copy operation to complete.
154+
155+
Parameters
156+
----------
157+
project_id : str
158+
Project ID to monitor.
159+
timeout_minutes : int
160+
Maximum time to wait in minutes.
161+
162+
Raises
163+
------
164+
TimeoutError
165+
If the copy operation doesn't complete within the timeout period.
166+
"""
167+
update_every_seconds = 2
168+
start_time = time.time()
169+
max_dots = 30
170+
171+
with log.status() as status_logger:
172+
while True:
173+
copy_status = _get_project_copy_status(project_id)
174+
if copy_status is not None and copy_status != "copying":
175+
break
176+
177+
elapsed = time.time() - start_time
178+
dot_count = int((elapsed // update_every_seconds) % max_dots)
179+
status_logger.update(f"Copying example{'.' * dot_count}")
180+
181+
if time.time() - start_time > timeout_minutes * 60:
182+
raise TimeoutError(
183+
f"Timeout: Copy operation did not finish within {timeout_minutes} minutes."
184+
)
185+
186+
time.sleep(update_every_seconds)
187+
188+
189+
def copy_example(example_id: str, wait_for_completion: bool = True) -> str:
190+
"""
191+
Copy an example from the cloud and return the created project ID.
192+
193+
Parameters
194+
----------
195+
example_id : str
196+
ID of the example to copy.
197+
wait_for_completion : bool
198+
Whether to wait for the copy operation to complete before returning.
199+
Default is True (blocking).
200+
201+
Returns
202+
-------
203+
str
204+
Project ID of the newly created project.
205+
206+
Raises
207+
------
208+
Flow360WebError
209+
If the example cannot be copied or the response format is unexpected.
210+
TimeoutError
211+
If wait_for_completion is True and the copy doesn't finish within timeout.
212+
"""
213+
request = CopyExampleRequest(source_example_id=example_id)
214+
example_api = RestApi("v2/examples")
215+
resp = example_api.post(request.dict(), method="copy")
216+
if not isinstance(resp, dict):
217+
raise Flow360WebError(f"Unexpected response format when copying example {example_id}")
218+
response_model = CopyExampleResponse(**resp)
219+
project_id = response_model.id
220+
221+
if wait_for_completion:
222+
copy_status = _get_project_copy_status(project_id)
223+
if copy_status is None or copy_status == "copying":
224+
if copy_status == "copying":
225+
log.info(
226+
f"Copy operation started for project {project_id}. Waiting for completion..."
227+
)
228+
else:
229+
log.info(
230+
f"Copy operation initiated for project {project_id}. "
231+
"Waiting for completion (status unknown, assuming in progress)..."
232+
)
233+
_wait_for_copy_completion(project_id)
234+
log.info("Copy operation completed successfully.")
235+
236+
return project_id

flow360/component/project.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,11 @@
1616
from flow360.cloud.flow360_requests import LengthUnitType, RenameAssetRequestV2
1717
from flow360.cloud.rest_api import RestApi
1818
from flow360.component.case import Case
19+
from flow360.component.cloud_examples import (
20+
copy_example,
21+
fetch_examples,
22+
find_example_by_name,
23+
)
1924
from flow360.component.geometry import Geometry
2025
from flow360.component.interfaces import (
2126
GeometryInterface,
@@ -1197,6 +1202,52 @@ def from_cloud(
11971202
project._get_tree_from_cloud()
11981203
return project
11991204

1205+
@classmethod
1206+
@pd.validate_call
1207+
def from_example(cls, example_id: Optional[str] = None, by_name: Optional[str] = None):
1208+
"""
1209+
Creates a project from an existing example in the cloud.
1210+
1211+
Parameters
1212+
----------
1213+
example_id : str, optional
1214+
ID of the example to copy. Mutually exclusive with `by_name`.
1215+
by_name : str, optional
1216+
Name of the example to copy. Uses fuzzy matching to find the best match.
1217+
Mutually exclusive with `example_id`.
1218+
1219+
Returns
1220+
-------
1221+
Project
1222+
An instance of the project created from the example.
1223+
1224+
Raises
1225+
------
1226+
Flow360ValueError
1227+
If neither or both `example_id` and `by_name` are provided, or if no matching
1228+
example is found when using `by_name`.
1229+
Flow360WebError
1230+
If the example cannot be copied or the project cannot be loaded.
1231+
"""
1232+
if example_id is None and by_name is None:
1233+
raise Flow360ValueError("Either 'example_id' or 'by_name' must be provided.")
1234+
if example_id is not None and by_name is not None:
1235+
raise Flow360ValueError("'example_id' and 'by_name' are mutually exclusive.")
1236+
1237+
if by_name is not None:
1238+
examples = fetch_examples()
1239+
matched_example, score = find_example_by_name(by_name, examples)
1240+
if score < 1.0:
1241+
similarity_pct = score * 100
1242+
log.info(
1243+
f"Found closest match for '{by_name}': '{matched_example.title}' "
1244+
f"(similarity: {similarity_pct:.2f} %%)"
1245+
)
1246+
example_id = matched_example.id
1247+
1248+
project_id = copy_example(example_id)
1249+
return cls.from_cloud(project_id)
1250+
12001251
def _check_initialized(self):
12011252
"""
12021253
Checks if the project instance has been initialized correctly.

0 commit comments

Comments
 (0)