diff --git a/examples/workflow_management/folder/examples.ipynb b/examples/workflow_management/folder/examples.ipynb new file mode 100644 index 000000000..cc4c25bf2 --- /dev/null +++ b/examples/workflow_management/folder/examples.ipynb @@ -0,0 +1,429 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 4, + "id": "b725cac8", + "metadata": {}, + "outputs": [], + "source": [ + "import flow360 as fl\n", + "from flow360.log import log" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "c41dd82a", + "metadata": {}, + "outputs": [], + "source": [ + "# Run in dev environments\n", + "fl.Env.dev.active()" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "c74f57f1", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
[11:42:09] INFO: Folder successfully created: Your folder name here, folder-2283bcdf-df47-4899-be04-c1a1ecd3b20f \n", + "\n" + ], + "text/plain": [ + "\u001b[2;36m[11:42:09]\u001b[0m\u001b[2;36m \u001b[0m\u001b[36mINFO\u001b[0m: Folder successfully created: Your folder name here, folder-\u001b[93m2283bcdf-df47-4899-be04-c1a1ecd3b20f\u001b[0m \n" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "# Creating a folder\n", + "folder = fl.Folder.create(\"Your folder name here\").submit()" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "05e2a542", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
[15:30:06] INFO: Folder successfully created: Folder Level A, folder-89bf405c-721c-440a-9da7-b945303869f3 \n", + "\n" + ], + "text/plain": [ + "\u001b[2;36m[15:30:06]\u001b[0m\u001b[2;36m \u001b[0m\u001b[36mINFO\u001b[0m: Folder successfully created: Folder Level A, folder-\u001b[93m89bf405c-721c-440a-9da7-b945303869f3\u001b[0m \n" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "
INFO: Folder successfully created: Folder Level B, folder-dfe9179f-82b4-4549-9ce1-dba8bb987cc8 \n", + "\n" + ], + "text/plain": [ + "\u001b[2;36m \u001b[0m\u001b[2;36m \u001b[0m\u001b[36mINFO\u001b[0m: Folder successfully created: Folder Level B, folder-\u001b[93mdfe9179f-82b4-4549-9ce1-dba8bb987cc8\u001b[0m \n" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "
INFO: Folder successfully created: Folder Level C, folder-6f8386fb-a3fc-482c-932b-af8a4c178e4f \n", + "\n" + ], + "text/plain": [ + "\u001b[2;36m \u001b[0m\u001b[2;36m \u001b[0m\u001b[36mINFO\u001b[0m: Folder successfully created: Folder Level C, folder-\u001b[93m6f8386fb-a3fc-482c-932b-af8a4c178e4f\u001b[0m \n" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "
INFO: Found 0 items in folder: \n", + "\n" + ], + "text/plain": [ + "\u001b[2;36m \u001b[0m\u001b[2;36m \u001b[0m\u001b[36mINFO\u001b[0m: Found \u001b[1;36m0\u001b[0m items in folder: \n" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "# Moving a folder + getting items\n", + "folder_A = fl.Folder.create(\"Folder Level A\").submit()\n", + "folder_B = fl.Folder.create(\"Folder Level B\", parent_folder=folder_A).submit()\n", + "folder_C = fl.Folder.create(\"Folder Level C\", parent_folder=folder_B).submit()\n", + "\n", + "folder_C = folder_C.move_to_folder(folder_A)\n", + "\n", + "#\n", + "# Run cases, upload geometry, etc. here\n", + "#\n", + "\n", + "items = folder_A.get_items()\n", + "log.info(f\"Found {len(items)} items in folder:\")\n", + "\n", + "for item in items:\n", + " log.info(f\"Name: {item['name']}, Type: {item['type']}, Size: {item.get('storageSize', 0)}\")" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "5b9c64a3", + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
[10:27:07] INFO: Folder successfully created: batch-folder-0, folder-581d5150-eaa0-4129-b21a-286375cee917 \n", + "\n" + ], + "text/plain": [ + "\u001b[2;36m[10:27:07]\u001b[0m\u001b[2;36m \u001b[0m\u001b[36mINFO\u001b[0m: Folder successfully created: batch-folder-\u001b[1;36m0\u001b[0m, folder-\u001b[93m581d5150-eaa0-4129-b21a-286375cee917\u001b[0m \n" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "
INFO: Created folder 1: folder-581d5150-eaa0-4129-b21a-286375cee917 \n", + "\n" + ], + "text/plain": [ + "\u001b[2;36m \u001b[0m\u001b[2;36m \u001b[0m\u001b[36mINFO\u001b[0m: Created folder \u001b[1;36m1\u001b[0m: folder-\u001b[93m581d5150-eaa0-4129-b21a-286375cee917\u001b[0m \n" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "
INFO: Folder successfully created: batch-folder-1, folder-4a9c0e8a-5be3-4767-878f-8abde4b61693 \n", + "\n" + ], + "text/plain": [ + "\u001b[2;36m \u001b[0m\u001b[2;36m \u001b[0m\u001b[36mINFO\u001b[0m: Folder successfully created: batch-folder-\u001b[1;36m1\u001b[0m, folder-\u001b[93m4a9c0e8a-5be3-4767-878f-8abde4b61693\u001b[0m \n" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "
INFO: Created folder 2: folder-4a9c0e8a-5be3-4767-878f-8abde4b61693 \n", + "\n" + ], + "text/plain": [ + "\u001b[2;36m \u001b[0m\u001b[2;36m \u001b[0m\u001b[36mINFO\u001b[0m: Created folder \u001b[1;36m2\u001b[0m: folder-\u001b[93m4a9c0e8a-5be3-4767-878f-8abde4b61693\u001b[0m \n" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "
INFO: Folder successfully created: batch-folder-2, folder-6723a405-ad77-434b-aa4e-098c3bbd2d31 \n", + "\n" + ], + "text/plain": [ + "\u001b[2;36m \u001b[0m\u001b[2;36m \u001b[0m\u001b[36mINFO\u001b[0m: Folder successfully created: batch-folder-\u001b[1;36m2\u001b[0m, folder-\u001b[93m6723a405-ad77-434b-aa4e-098c3bbd2d31\u001b[0m \n" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "
INFO: Created folder 3: folder-6723a405-ad77-434b-aa4e-098c3bbd2d31 \n", + "\n" + ], + "text/plain": [ + "\u001b[2;36m \u001b[0m\u001b[2;36m \u001b[0m\u001b[36mINFO\u001b[0m: Created folder \u001b[1;36m3\u001b[0m: folder-\u001b[93m6723a405-ad77-434b-aa4e-098c3bbd2d31\u001b[0m \n" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "
INFO: Folder successfully created: batch-folder-3, folder-3af423d3-9240-41b0-8ae2-2c21a6b66ffb \n", + "\n" + ], + "text/plain": [ + "\u001b[2;36m \u001b[0m\u001b[2;36m \u001b[0m\u001b[36mINFO\u001b[0m: Folder successfully created: batch-folder-\u001b[1;36m3\u001b[0m, folder-\u001b[93m3af423d3-9240-41b0-8ae2-2c21a6b66ffb\u001b[0m \n" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "
INFO: Created folder 4: folder-3af423d3-9240-41b0-8ae2-2c21a6b66ffb \n", + "\n" + ], + "text/plain": [ + "\u001b[2;36m \u001b[0m\u001b[2;36m \u001b[0m\u001b[36mINFO\u001b[0m: Created folder \u001b[1;36m4\u001b[0m: folder-\u001b[93m3af423d3-9240-41b0-8ae2-2c21a6b66ffb\u001b[0m \n" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "
INFO: Folder successfully created: batch-folder-4, folder-b99266bf-575a-403b-ab66-49abfd77f3ff \n", + "\n" + ], + "text/plain": [ + "\u001b[2;36m \u001b[0m\u001b[2;36m \u001b[0m\u001b[36mINFO\u001b[0m: Folder successfully created: batch-folder-\u001b[1;36m4\u001b[0m, folder-\u001b[93mb99266bf-575a-403b-ab66-49abfd77f3ff\u001b[0m \n" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "
INFO: Created folder 5: folder-b99266bf-575a-403b-ab66-49abfd77f3ff \n", + "\n" + ], + "text/plain": [ + "\u001b[2;36m \u001b[0m\u001b[2;36m \u001b[0m\u001b[36mINFO\u001b[0m: Created folder \u001b[1;36m5\u001b[0m: folder-\u001b[93mb99266bf-575a-403b-ab66-49abfd77f3ff\u001b[0m \n" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "
INFO: Folder successfully created: target-folder, folder-c0f19990-d077-4877-993b-8b29a6fd53e6 \n", + "\n" + ], + "text/plain": [ + "\u001b[2;36m \u001b[0m\u001b[2;36m \u001b[0m\u001b[36mINFO\u001b[0m: Folder successfully created: target-folder, folder-\u001b[93mc0f19990-d077-4877-993b-8b29a6fd53e6\u001b[0m \n" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "
INFO: Moved folder-581d5150-eaa0-4129-b21a-286375cee917 to folder-c0f19990-d077-4877-993b-8b29a6fd53e6 \n", + "\n" + ], + "text/plain": [ + "\u001b[2;36m \u001b[0m\u001b[2;36m \u001b[0m\u001b[36mINFO\u001b[0m: Moved folder-\u001b[93m581d5150-eaa0-4129-b21a-286375cee917\u001b[0m to folder-\u001b[93mc0f19990-d077-4877-993b-8b29a6fd53e6\u001b[0m \n" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "
INFO: Moved folder-4a9c0e8a-5be3-4767-878f-8abde4b61693 to folder-c0f19990-d077-4877-993b-8b29a6fd53e6 \n", + "\n" + ], + "text/plain": [ + "\u001b[2;36m \u001b[0m\u001b[2;36m \u001b[0m\u001b[36mINFO\u001b[0m: Moved folder-\u001b[93m4a9c0e8a-5be3-4767-878f-8abde4b61693\u001b[0m to folder-\u001b[93mc0f19990-d077-4877-993b-8b29a6fd53e6\u001b[0m \n" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "
INFO: Moved folder-6723a405-ad77-434b-aa4e-098c3bbd2d31 to folder-c0f19990-d077-4877-993b-8b29a6fd53e6 \n", + "\n" + ], + "text/plain": [ + "\u001b[2;36m \u001b[0m\u001b[2;36m \u001b[0m\u001b[36mINFO\u001b[0m: Moved folder-\u001b[93m6723a405-ad77-434b-aa4e-098c3bbd2d31\u001b[0m to folder-\u001b[93mc0f19990-d077-4877-993b-8b29a6fd53e6\u001b[0m \n" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "
INFO: Moved folder-3af423d3-9240-41b0-8ae2-2c21a6b66ffb to folder-c0f19990-d077-4877-993b-8b29a6fd53e6 \n", + "\n" + ], + "text/plain": [ + "\u001b[2;36m \u001b[0m\u001b[2;36m \u001b[0m\u001b[36mINFO\u001b[0m: Moved folder-\u001b[93m3af423d3-9240-41b0-8ae2-2c21a6b66ffb\u001b[0m to folder-\u001b[93mc0f19990-d077-4877-993b-8b29a6fd53e6\u001b[0m \n" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "
INFO: Moved folder-b99266bf-575a-403b-ab66-49abfd77f3ff to folder-c0f19990-d077-4877-993b-8b29a6fd53e6 \n", + "\n" + ], + "text/plain": [ + "\u001b[2;36m \u001b[0m\u001b[2;36m \u001b[0m\u001b[36mINFO\u001b[0m: Moved folder-\u001b[93mb99266bf-575a-403b-ab66-49abfd77f3ff\u001b[0m to folder-\u001b[93mc0f19990-d077-4877-993b-8b29a6fd53e6\u001b[0m \n" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "# Batch operations example\n", + "from flow360.component.folder import Folder\n", + "\n", + "\n", + "folders = []\n", + "for i in range(5):\n", + " folder = fl.Folder.create(f\"batch-folder-{i}\").submit()\n", + " folders.append(folder)\n", + " log.info(f\"Created folder {i+1}: {folder.id}\")\n", + "\n", + "# Test moving multiple folders\n", + "target = fl.Folder.create(\"target-folder\").submit()\n", + "for folder in folders:\n", + " folder.move_to_folder(target)\n", + " log.info(f\"Moved {folder.id} to {target.id}\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ba10937f", + "metadata": {}, + "outputs": [], + "source": [ + "# Create a project inside a folder\n", + "from flow360.examples import Airplane, OM6wing\n", + "\n", + "# Configuration\n", + "solver_version = \"release-25.6\"\n", + "folder_id = \"folder-8e1cdfca-4d59-49a3-805a-308e4cb6b8d1\" # Replace with your folder ID\n", + "folder = fl.Folder(id=folder_id) # Or create a folder\n", + "\n", + "# Example 1: Create geometry project in folder\n", + "geometry_project = fl.Project.from_geometry(\n", + " Airplane.geometry,\n", + " name=\"Geometry Project in Folder\",\n", + " solver_version=solver_version,\n", + " folder=folder,\n", + ")\n", + "log.info(f\"ā Geometry project created: {geometry_project.id}\")\n", + "\n", + "# Example 2: Create surface mesh project in folder\n", + "surface_project = fl.Project.from_surface_mesh(\n", + " \"/path/to/your/surface/mesh.stl\",\n", + " name=\"Surface Mesh Project in Folder\",\n", + " solver_version=solver_version,\n", + " folder=folder,\n", + ")\n", + "log.info(f\"ā Surface mesh project created: {surface_project.id}\")\n", + "\n", + "# Example 3: Create volume mesh project in folder\n", + "OM6wing.get_files() # Download example files\n", + "volume_project = fl.Project.from_volume_mesh(\n", + " OM6wing.mesh_filename,\n", + " name=\"Volume Mesh Project in Folder\",\n", + " solver_version=solver_version,\n", + " folder=folder,\n", + ")\n", + "log.info(f\"ā Volume mesh project created: {volume_project.id}\")\n", + "\n", + "log.info(f\"\\nš All projects created in folder: {folder.name} ({folder.id})\")" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "flow360-E9f6_bLL-py3.12", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.10" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/flow360/__init__.py b/flow360/__init__.py index 8c30d680f..a990768f6 100644 --- a/flow360/__init__.py +++ b/flow360/__init__.py @@ -9,6 +9,7 @@ from flow360.component.project import Project from flow360.component.simulation import migration, services from flow360.component.simulation import units as u +from flow360.component.simulation.folder import Folder from flow360.component.simulation.meshing_param.edge_params import ( AngleBasedRefinement, AspectRatioBasedRefinement, @@ -238,6 +239,7 @@ "DetachedEddySimulation", "KOmegaSSTModelConstants", "LinearSolver", + "Folder", "ForcePerArea", "Air", "Sutherland", diff --git a/flow360/cloud/flow360_requests.py b/flow360/cloud/flow360_requests.py index 83caf7868..056ccddea 100644 --- a/flow360/cloud/flow360_requests.py +++ b/flow360/cloud/flow360_requests.py @@ -232,3 +232,20 @@ def _validate_force_creation_config(self): f"cannot be later than 'up_to' ({self.up_to})." ) return self + + +class MoveToFolderRequestV2(Flow360RequestsV2): + """Data model for moving folder using v2 endpoint""" + + name: Optional[str] = pd_v2.Field(default=None, description="folder to move name") + tags: List[str] = pd_v2.Field(default=[], description="folder tags") + parent_folder_id: str = pd_v2.Field(alias="parentFolderId", default="ROOT.FLOW360") + + +class RenameAssetRequestV2(Flow360RequestsV2): + """ + Data model for renaming an asset (folder, project, surface mesh, volume mesh, + or case (other request fields, like folder to move to, already have implementations) + """ + + name: str = pd_v2.Field(description="case to rename") diff --git a/flow360/component/case.py b/flow360/component/case.py index f467cdc10..1e1bf3704 100644 --- a/flow360/component/case.py +++ b/flow360/component/case.py @@ -14,7 +14,11 @@ import pydantic.v1 as pd_v1 from .. import error_messages -from ..cloud.flow360_requests import MoveCaseItem, MoveToFolderRequest +from ..cloud.flow360_requests import ( + MoveCaseItem, + MoveToFolderRequest, + RenameAssetRequestV2, +) from ..cloud.rest_api import RestApi from ..cloud.s3_utils import CloudFileNotFoundError from ..exceptions import Flow360RuntimeError, Flow360ValidationError, Flow360ValueError @@ -178,7 +182,6 @@ class CaseMetaV2(AssetMetaBaseModelV2): """ id: str = pd.Field(alias="caseId") - case_mesh_id: str = pd.Field(alias="caseMeshId") status: Flow360Status = pd.Field() def to_case(self) -> Case: @@ -542,6 +545,13 @@ def info(self) -> CaseMetaV2: """ return super().info + @property + def info_v2(self) -> CaseMetaV2: + """ + returns metadata v2 info for case + """ + return self._web_api_v2.info + @property def project_id(self) -> Optional[str]: """Returns the project id of the case if case was run with V2 interface.""" @@ -551,6 +561,13 @@ def project_id(self) -> Optional[str]: return self.info.project_id raise ValueError("Case info is not of type CaseMeta or CaseMetaV2") + @property + def tags(self) -> List[str]: + """ + get case tags + """ + return self._web_api_v2.info.tags + @property def volume_mesh(self) -> "VolumeMeshV2": """ @@ -668,6 +685,19 @@ def move_to_folder(self, folder: Folder): ) return self + def rename(self, new_name: str): + """ + Rename the current case. + + Parameters + ---------- + new_name : str + The new name for the case. + """ + RestApi(CaseInterfaceV2.endpoint).patch( + RenameAssetRequestV2(name=new_name).dict(), method=self.id + ) + @classmethod def _interface(cls): return CaseInterface diff --git a/flow360/component/geometry.py b/flow360/component/geometry.py index 3def38a81..a55f2d4f9 100644 --- a/flow360/component/geometry.py +++ b/flow360/component/geometry.py @@ -7,7 +7,7 @@ import os import threading from enum import Enum -from typing import Any, List, Literal, Union +from typing import Any, List, Literal, Optional, Union import pydantic as pd @@ -25,6 +25,7 @@ ResourceDraft, ) from flow360.component.simulation.entity_info import GeometryEntityInfo +from flow360.component.simulation.folder import Folder from flow360.component.simulation.primitives import Edge, GeometryBodyGroup, Surface from flow360.component.simulation.unit_system import LengthType from flow360.component.simulation.utils import model_attribute_unlock @@ -91,12 +92,14 @@ def __init__( solver_version: str = None, length_unit: LengthUnitType = "m", tags: List[str] = None, + folder: Optional[Folder] = None, ): self._file_names = file_names self.project_name = project_name self.tags = tags if tags is not None else [] self.length_unit = length_unit self.solver_version = solver_version + self.folder = folder self._validate() ResourceDraft.__init__(self) @@ -186,9 +189,7 @@ def submit(self, description="", progress_callback=None, run_async=False) -> Geo ) for file_path in self.file_names + mapbc_files ], - # pylint: disable=fixme - # TODO: remove hardcoding - parent_folder_id="ROOT.FLOW360", + parent_folder_id=self.folder.id if self.folder else "ROOT.FLOW360", length_unit=self.length_unit, description=description, ) @@ -296,9 +297,12 @@ def from_file( solver_version: str = None, length_unit: LengthUnitType = "m", tags: List[str] = None, + folder: Optional[Folder] = None, ) -> GeometryDraft: # For type hint only but proper fix is to fully abstract the Draft class too. - return super().from_file(file_names, project_name, solver_version, length_unit, tags) + return super().from_file( + file_names, project_name, solver_version, length_unit, tags, folder=folder + ) def show_available_groupings(self, verbose_mode: bool = False): """Display all the possible groupings for faces and edges""" diff --git a/flow360/component/interfaces.py b/flow360/component/interfaces.py index 2e97ef772..e3805e4ee 100644 --- a/flow360/component/interfaces.py +++ b/flow360/component/interfaces.py @@ -68,6 +68,10 @@ class BaseInterface(BaseModel): FolderInterface = BaseInterface(resource_type="Folder", s3_transfer_method=None, endpoint="folders") +FolderInterfaceV2 = BaseInterface( + resource_type="Folder", s3_transfer_method=None, endpoint="v2/folders" +) + ReportInterface = BaseInterface( resource_type="Report", s3_transfer_method=S3TransferType.REPORT, diff --git a/flow360/component/project.py b/flow360/component/project.py index 6e135b3ad..ebdde9548 100644 --- a/flow360/component/project.py +++ b/flow360/component/project.py @@ -14,7 +14,7 @@ from PrettyPrint import PrettyPrintTree from pydantic import PositiveInt -from flow360.cloud.flow360_requests import LengthUnitType +from flow360.cloud.flow360_requests import LengthUnitType, RenameAssetRequestV2 from flow360.cloud.rest_api import RestApi from flow360.component.case import Case from flow360.component.geometry import Geometry @@ -25,11 +25,13 @@ VolumeMeshInterfaceV2, ) from flow360.component.project_utils import ( + get_project_records, set_up_params_for_uploading, show_projects_with_keyword_filter, validate_params_with_context, ) from flow360.component.resource_base import Flow360Resource +from flow360.component.simulation.folder import Folder from flow360.component.simulation.simulation_params import SimulationParams from flow360.component.simulation.unit_system import LengthType from flow360.component.simulation.web.asset_base import AssetBase @@ -85,6 +87,8 @@ class ProjectMeta(pd.BaseModel, extra="allow"): The project ID. name : str The name of the project. + tags : List[str] + List of tags associated with the project. root_item_id : str ID of the root item in the project. root_item_type : RootType @@ -94,6 +98,7 @@ class ProjectMeta(pd.BaseModel, extra="allow"): user_id: str = pd.Field(alias="userId") id: str = pd.Field() name: str = pd.Field() + tags: List[str] = pd.Field(default_factory=list) root_item_id: str = pd.Field(alias="rootItemId") root_item_type: RootType = pd.Field(alias="rootItemType") @@ -401,6 +406,18 @@ def id(self) -> str: """ return self.metadata.id + @property + def tags(self) -> List[str]: + """ + Returns the tags of the project. + + Returns + ------- + List[str] + List of the project's tags. + """ + return self.metadata.tags + @property def length_unit(self) -> LengthType.Positive: """ @@ -612,17 +629,54 @@ def get_volume_mesh_ids(self): # pylint: disable=protected-access return self.project_tree._get_asset_ids_by_type(asset_type="VolumeMesh") - def get_case_ids(self): + def get_case_ids(self, tags: Optional[List[str]] = None) -> List[str]: """ - Returns the available IDs of cases in the project + Returns the available IDs of cases in the project, optionally filtered by tags. + + Parameters + ---------- + tags : List[str], optional + List of tags to filter cases by. If None or empty tags list, returns all case IDs. Returns ------- Iterable[str] - An iterable of asset IDs. + An iterable of case IDs. If tags are provided, filters to return only + case IDs that have at least one matching tag. """ # pylint: disable=protected-access - return self.project_tree._get_asset_ids_by_type(asset_type="Case") + all_case_ids = self.project_tree._get_asset_ids_by_type(asset_type="Case") + + if not tags: + return all_case_ids + + # Filter cases by tags + filtered_case_ids = [] + for case_id in all_case_ids: + case = self.get_case(asset_id=case_id) + if set(tags) & set(case.info_v2.tags): + filtered_case_ids.append(case_id) + + return filtered_case_ids + + @classmethod + def get_project_ids(cls, tags: Optional[List[str]] = None) -> List[str]: + """ + Returns the available IDs of projects, optionally filtered by tags. + + Parameters + ---------- + tags : List[str], optional + List of tags to filter projects by. If None, returns all project IDs. + + Returns + ------- + List[str] + A list of project IDs. If tags are provided, filters to return only + project IDs that have at least one matching tag. + """ + project_records, _ = get_project_records("", tags=tags) + return [record.project_id for record in project_records.records] # pylint: disable=too-many-arguments @classmethod @@ -635,6 +689,7 @@ def _create_project_from_files( length_unit: LengthUnitType = "m", tags: List[str] = None, run_async: bool = False, + folder: Optional[Folder] = None, ): """ Initializes a project from a file. @@ -653,6 +708,8 @@ def _create_project_from_files( Tags to assign to the project (default is None). run_async : bool, optional Whether to create the project asynchronously (default is False). + folder : Optional[Folder], optional + Parent folder for the project. If None, creates in root. Returns ------- @@ -670,14 +727,16 @@ def _create_project_from_files( files._check_files_existence() if isinstance(files, GeometryFiles): - draft = Geometry.from_file(files.file_names, name, solver_version, length_unit, tags) + draft = Geometry.from_file( + files.file_names, name, solver_version, length_unit, tags, folder=folder + ) elif isinstance(files, SurfaceMeshFile): draft = SurfaceMeshV2.from_file( - files.file_names, name, solver_version, length_unit, tags + files.file_names, name, solver_version, length_unit, tags, folder=folder ) elif isinstance(files, VolumeMeshFile): draft = VolumeMeshV2.from_file( - files.file_names, name, solver_version, length_unit, tags + files.file_names, name, solver_version, length_unit, tags, folder=folder ) else: raise Flow360FileError( @@ -718,7 +777,11 @@ def _create_project_from_files( return project @classmethod - @pd.validate_call + @pd.validate_call( + config={ + "arbitrary_types_allowed": True + } # Folder (v2: component/simulation/folder.py) does not have validate() defined + ) def from_geometry( cls, files: Union[str, list[str]], @@ -728,6 +791,7 @@ def from_geometry( length_unit: LengthUnitType = "m", tags: List[str] = None, run_async: bool = False, + folder: Optional[Folder] = None, ): """ Initializes a project from local geometry files. @@ -746,6 +810,8 @@ def from_geometry( Tags to assign to the project (default is None). run_async : bool, optional Whether to create project asynchronously (default is False). + folder : Optional[Folder], optional + Parent folder for the project. If None, creates in root. Returns ------- @@ -781,10 +847,11 @@ def from_geometry( length_unit=length_unit, tags=tags, run_async=run_async, + folder=folder, ) @classmethod - @pd.validate_call + @pd.validate_call(config={"arbitrary_types_allowed": True}) def from_surface_mesh( cls, file: str, @@ -794,6 +861,7 @@ def from_surface_mesh( length_unit: LengthUnitType = "m", tags: List[str] = None, run_async: bool = False, + folder: Optional[Folder] = None, ): """ Initializes a project from a local surface mesh file. @@ -813,6 +881,8 @@ def from_surface_mesh( Tags to assign to the project (default is None). run_async : bool, optional Whether to create project asynchronously (default is False). + folder : Optional[Folder], optional + Parent folder for the project. If None, creates in root. Returns ------- @@ -849,10 +919,11 @@ def from_surface_mesh( length_unit=length_unit, tags=tags, run_async=run_async, + folder=folder, ) @classmethod - @pd.validate_call + @pd.validate_call(config={"arbitrary_types_allowed": True}) def from_volume_mesh( cls, file: str, @@ -862,6 +933,7 @@ def from_volume_mesh( length_unit: LengthUnitType = "m", tags: List[str] = None, run_async: bool = False, + folder: Optional[Folder] = None, ): """ Initializes a project from a local volume mesh file. @@ -881,6 +953,8 @@ def from_volume_mesh( Tags to assign to the project (default is None). run_async : bool, optional Whether to create project asynchronously (default is False). + folder : Optional[Folder], optional + Parent folder for the project. If None, creates in root. Returns ------- @@ -917,6 +991,7 @@ def from_volume_mesh( length_unit=length_unit, tags=tags, run_async=run_async, + folder=folder, ) @classmethod @@ -1189,6 +1264,19 @@ def refresh_project_tree(self): """Refresh the local project tree by fetching the latest project tree from cloud.""" return self._get_tree_from_cloud() + def rename(self, new_name: str): + """ + Rename the current project. + + Parameters + ---------- + new_name : str + The new name for the project. + """ + RestApi(ProjectInterface.endpoint).patch( + RenameAssetRequestV2(name=new_name).dict(), method=self.id + ) + def print_project_tree(self, line_width: int = 30, is_horizontal: bool = True): """Print the project tree to the terminal. diff --git a/flow360/component/project_utils.py b/flow360/component/project_utils.py index b6d90aea2..5e499d09f 100644 --- a/flow360/component/project_utils.py +++ b/flow360/component/project_utils.py @@ -107,7 +107,9 @@ def __str__(self): return output_str -def get_project_records(search_keyword: str) -> tuple[ProjectRecords, int]: +def get_project_records( + search_keyword: str, tags: Optional[List[str]] = None +) -> tuple[ProjectRecords, int]: """Get all projects with a keyword filter""" # pylint: disable=invalid-name MAX_SEARCHABLE_ITEM_COUNT = 1000 @@ -117,6 +119,7 @@ def get_project_records(search_keyword: str) -> tuple[ProjectRecords, int]: "page": "0", "size": MAX_SEARCHABLE_ITEM_COUNT, "filterKeywords": search_keyword, + "filterTags": tags, "sortFields": ["createdAt"], "sortDirections": ["asc"], } diff --git a/flow360/component/simulation/folder.py b/flow360/component/simulation/folder.py new file mode 100644 index 000000000..474041179 --- /dev/null +++ b/flow360/component/simulation/folder.py @@ -0,0 +1,353 @@ +""" +Folder component +""" + +# pylint: disable=duplicate-code + +from __future__ import annotations + +from typing import List, Optional, Union + +import pydantic as pd + +from ...cloud.flow360_requests import ( + MoveToFolderRequestV2, + NewFolderRequest, + RenameAssetRequestV2, +) +from ...cloud.rest_api import RestApi +from ...exceptions import Flow360ValueError +from ...log import log +from ..interfaces import FolderInterface, FolderInterfaceV2 +from ..resource_base import AssetMetaBaseModel, Flow360Resource, ResourceDraft +from ..utils import ( + shared_account_confirm_proceed, + storage_size_formatter, + validate_type, +) + +ROOT_FOLDER = "ROOT.FLOW360" + + +class FolderMeta(AssetMetaBaseModel, extra="allow"): + """ + FolderMeta component + """ + + parent_folder_id: Union[str, None] = pd.Field(alias="parentFolderId") + status: Optional[str] = pd.Field() + deleted: Optional[bool] + user_id: Optional[str] = pd.Field(alias="userId") + parent_folders: Optional[List[FolderMeta]] = pd.Field(alias="parentFolders") + + +class FolderDraft(ResourceDraft): + """ + Folder Draft component + """ + + # pylint: disable=too-many-arguments + def __init__(self, name: str = None, tags: List[str] = None, parent_folder: Folder = None): + self.name = name + self.tags = tags + self._id = None + self._parent_folder = parent_folder + ResourceDraft.__init__(self) + + # pylint: disable=protected-access + def submit(self) -> Folder: + """create folder in cloud + + Returns + ------- + Folder + Folder object with id + """ + + if not shared_account_confirm_proceed(): + raise Flow360ValueError("User aborted resource submit.") + + req = NewFolderRequest(name=self.name, tags=self.tags) + if self._parent_folder: + req.parent_folder_id = self._parent_folder.id + resp = RestApi(FolderInterface.endpoint).post(req.dict()) + info = FolderMeta(**resp) + # setting _id will disable "WARNING: You have not submitted..." warning message + self._id = info.id + submitted_folder = Folder(self.id) + log.info(f"Folder successfully created: {info.name}, {info.id}") + return submitted_folder + + +class Folder(Flow360Resource): + """ + Folder component + """ + + # pylint: disable=redefined-builtin + def __init__(self, id: str): + super().__init__( + interface=FolderInterface, + meta_class=FolderMeta, + id=id, + ) + + @classmethod + def _from_meta(cls, meta: FolderMeta): + validate_type(meta, "meta", FolderMeta) + folder = cls(id=meta.id) + folder._set_meta(meta) + return folder + + @property + def info(self) -> FolderMeta: + return super().info + + def get_info(self, force=False) -> FolderMeta: + """ + returns metadata info for resource + """ + + if self._info is None or force: + self._info = self.meta_class(**RestApi(f"v2/folders/{self.id}").get()) + return self._info + + def move_to_folder(self, folder: Folder): + """ + Move the current folder to the specified folder. + + Parameters + ---------- + folder : Folder + The destination folder where the item will be moved. + + Returns + ------- + self + Returns the modified item after it has been moved to the new folder. + + Notes + ----- + This method sends a REST API request to move the current item to the specified folder. + The `folder` parameter should be an instance of the `Folder` class with a valid ID. + """ + RestApi(FolderInterfaceV2.endpoint).patch( + MoveToFolderRequestV2(parent_folder_id=folder.id).dict(), + method=f"{self.id}", + ) + return self + + def rename(self, new_name: str): + """ + Rename the current folder. + + Parameters + ---------- + new_name : str + The new name for the folder. + """ + RestApi(FolderInterfaceV2.endpoint).patch( + RenameAssetRequestV2(name=new_name).dict(), method=self.id + ) + + @classmethod + def _interface(cls): + return FolderInterface + + @classmethod + def _meta_class(cls): + """ + returns folder mesh meta info class: FolderMeta + """ + return FolderMeta + + @classmethod + def create(cls, name: str, tags: List[str] = None, parent_folder: Folder = None) -> FolderDraft: + """ "Create a new folder" + + Parameters + ---------- + name : str + name of the folder + tags : List[str], optional + tags for the folder, by default None + parent_folder : Folder, optional + parent folder object, by default folder is created at root level, by default None + + Returns + ------- + FolderDraft + _description_ + """ + new_folder = FolderDraft( + name=name, + tags=tags, + parent_folder=parent_folder, + ) + return new_folder + + def get_items(self): + """ + Fetch all items within the current folder, handling pagination if needed. + + Returns + ------- + list + A list of all items found in the folder, sorted by storage size in descending order. + """ + + all_records = [] + page = 0 + size = 1000 # Page size + total_record_count = size + + # Loop until all pages are fetched + while len(all_records) < total_record_count: + payload = { + "page": page, + "size": size, + "filterFolderIds": self.id, + "filterExcludeSubfolders": True, + "sortFields": ["storageSize"], + "sortDirections": ["desc"], + "expandFields": ["contentInfo"], + } + + data = RestApi("/v2/items").get(params=payload) + records = data.get("records", []) + all_records.extend(records) + total_record_count = data.get("total", 0) + page += 1 + + return all_records + + def _build_folder_tree(self, folders): + """ + Build a hierarchical folder tree starting from the current folder. + + Parameters + ---------- + folders : list + A list of folder records. + + Returns + ------- + dict + A dictionary representing the folder hierarchy with nested subfolders. + """ + + folder_dict = {folder["id"]: folder for folder in folders} + folder_dict[ROOT_FOLDER] = {"id": ROOT_FOLDER, "name": "My workspace"} + + for folder in folder_dict.values(): + folder["subfolders"] = [] + + for folder in folders: + parent_id = folder.get("parentFolderId") + if parent_id is not None: + parent_folder = folder_dict.get(parent_id) + if parent_folder: + parent_folder["subfolders"].append( + {"name": folder["name"], "id": folder["id"], "subfolders": []} + ) + + def build_hierarchy(folder_id): + folder = folder_dict.get(folder_id) + if not folder: + return None + + return { + "name": folder["name"], + "id": folder["id"], + "subfolders": [ + build_hierarchy(subfolder["id"]) for subfolder in folder["subfolders"] + ], + } + + return build_hierarchy(self.id) + + def get_folder_tree(self): + """ + Retrieve the folder tree including subfolders from the API. + + Returns + ------- + dict + A hierarchical representation of the folder tree starting from the current folder. + """ + + payload = { + "includeSubfolders": True, + "page": 0, + "size": 1000, + } # it assumes user will not have more than 1000 folders + data = RestApi("/v2/folders").get(params=payload) + folder_tree = self._build_folder_tree(data["records"]) + return folder_tree + + def _print_storage(self, tree, indent: int, n_display: int): + """ + Recursively print the folder tree along with its contents and total storage usage. + + Parameters + ---------- + tree : dict + The current folder tree to display. + indent : int + The indentation level for pretty-printing. + n_display : int + The number of items to display before summarizing the remaining items. + + Returns + ------- + int + The total storage size of the current folder and its subfolders. + """ + + log.info(" " * indent + f"- [FOLDER] {tree['name']}") + total_storage = 0 + for subfolder in tree["subfolders"]: + # pylint: disable=protected-access + total_storage += Folder(subfolder["id"])._print_storage( + subfolder, indent + 1, n_display + ) + + items = self.get_items() + displayed_items = items[:n_display] + remaining_items = items[n_display:] + + for item in displayed_items: + if item["type"] != "Folder": + storage_size = item.get("storageSize", 0) + total_storage += storage_size + log.info( + " " * (indent + 1) + + f"- [{item['type']}] {item['name']} (Size: {storage_size_formatter(storage_size)})" + ) + + if len(remaining_items) > 0: + total_remaining_size = sum(item.get("storageSize", 0) for item in remaining_items) + log.info( + " " * (indent + 1) + + f"+{len(remaining_items)} more (total {storage_size_formatter(total_remaining_size)})" + ) + total_storage += total_remaining_size + + log.info(" " * (indent + 1) + f"Total Storage: {storage_size_formatter(total_storage)}") + return total_storage + + @classmethod + def print_storage(cls, folder_id: str = "ROOT.FLOW360", n_display: int = 10) -> None: + """ + Display the storage details of a folder, including subfolders and a summary of all items. + + Parameters + ---------- + folder_id : str, optional + The ID of the folder to print storage details for. Defaults to "ROOT.FLOW360". + n_display : int, optional + The number of items to display before summarizing the remaining items. Defaults to 10. + """ + folder = cls(id=folder_id) + tree = folder.get_folder_tree() + folder._print_storage(tree, 0, n_display) diff --git a/flow360/component/simulation/web/asset_base.py b/flow360/component/simulation/web/asset_base.py index 3a3b438b6..ee5d00765 100644 --- a/flow360/component/simulation/web/asset_base.py +++ b/flow360/component/simulation/web/asset_base.py @@ -11,7 +11,7 @@ from pydantic import ValidationError from requests.exceptions import HTTPError -from flow360.cloud.flow360_requests import LengthUnitType +from flow360.cloud.flow360_requests import LengthUnitType, RenameAssetRequestV2 from flow360.cloud.rest_api import RestApi from flow360.component.interfaces import BaseInterface, ProjectInterface from flow360.component.resource_base import ( @@ -24,6 +24,7 @@ EntityInfoModel, parse_entity_info_model, ) +from flow360.component.simulation.folder import Folder from flow360.component.simulation.framework.updater_utils import Flow360Version from flow360.component.simulation.simulation_params import SimulationParams from flow360.component.utils import ( @@ -75,6 +76,13 @@ def project_id(self): """ return self.info.project_id + @property + def tags(self) -> List[str]: + """ + get asset tags + """ + return self.info.tags + @property def solver_version(self): """ @@ -82,6 +90,19 @@ def solver_version(self): """ return self.info.solver_version + def rename(self, new_name: str): + """ + Rename the current asset. + + Parameters + ---------- + new_name : str + The new name for the asset. + """ + RestApi(self._interface_class.endpoint).patch( + RenameAssetRequestV2(name=new_name).dict(), method=self.id + ) + @classmethod # pylint: disable=protected-access def _from_meta(cls, meta: AssetMetaBaseModelV2): @@ -255,12 +276,14 @@ def from_file( solver_version: str = None, length_unit: LengthUnitType = "m", tags: List[str] = None, + folder: Optional[Folder] = None, ): """ Create asset draft from files :param file_names: :param project_name: :param tags: + :param folder: Folder object where the asset will be created (optional; defaults to root if unspecified) :return: """ # pylint: disable=not-callable @@ -270,6 +293,7 @@ def from_file( solver_version=solver_version, tags=tags, length_unit=length_unit, + folder=folder, ) @classmethod diff --git a/flow360/component/surface_mesh_v2.py b/flow360/component/surface_mesh_v2.py index 41a759201..fdebcfb18 100644 --- a/flow360/component/surface_mesh_v2.py +++ b/flow360/component/surface_mesh_v2.py @@ -21,6 +21,7 @@ ResourceDraft, ) from flow360.component.simulation.entity_info import SurfaceMeshEntityInfo +from flow360.component.simulation.folder import Folder from flow360.component.simulation.web.asset_base import AssetBase from flow360.component.utils import ( MeshNameParser, @@ -90,12 +91,14 @@ def __init__( solver_version: str = None, length_unit: LengthUnitType = "m", tags: List[str] = None, + folder: Optional[Folder] = None, ): self._file_name = file_names self.project_name = project_name self.tags = tags if tags is not None else [] self.length_unit = length_unit self.solver_version = solver_version + self.folder = folder self._validate() ResourceDraft.__init__(self) @@ -157,9 +160,7 @@ def submit(self, description="", progress_callback=None, run_async=False) -> Sur solver_version=self.solver_version, tags=self.tags, file_name=self._file_name, - # pylint: disable=fixme - # TODO: remove hardcoding - parent_folder_id="ROOT.FLOW360", + parent_folder_id=self.folder.id if self.folder else "ROOT.FLOW360", length_unit=self.length_unit, description=description, ) @@ -277,6 +278,7 @@ def from_file( solver_version: str = None, length_unit: LengthUnitType = "m", tags: List[str] = None, + folder: Optional[Folder] = None, ) -> SurfaceMeshDraftV2: """ Parameters @@ -291,6 +293,8 @@ def from_file( Length unit to use for the project ("m", "mm", "cm", "inch", "ft") tags: List[str] List of string tags to be added to the project upon creation + folder : Optional[Folder], optional + Parent folder for the project. If None, creates in root. Returns ------- @@ -304,6 +308,7 @@ def from_file( solver_version=solver_version, length_unit=length_unit, tags=tags, + folder=folder, ) # pylint: disable=useless-parent-delegation diff --git a/flow360/component/volume_mesh.py b/flow360/component/volume_mesh.py index d46b2bf16..36c13adbf 100644 --- a/flow360/component/volume_mesh.py +++ b/flow360/component/volume_mesh.py @@ -28,6 +28,7 @@ ) from flow360.cloud.heartbeat import post_upload_heartbeat from flow360.cloud.rest_api import RestApi +from flow360.component.simulation.folder import Folder from flow360.component.utils import VolumeMeshFile from flow360.component.v1.cloud.flow360_requests import NewVolumeMeshRequest from flow360.component.v1.meshing.params import VolumeMeshingParams @@ -926,12 +927,14 @@ def __init__( solver_version: str = None, length_unit: LengthUnitType = "m", tags: List[str] = None, + folder: Optional[Folder] = None, ): self.file_name = file_names self.project_name = project_name self.tags = tags if tags is not None else [] self.length_unit = length_unit self.solver_version = solver_version + self.folder = folder self._validate() ResourceDraft.__init__(self) @@ -1011,6 +1014,7 @@ def submit( solver_version=self.solver_version, tags=self.tags, file_name=original_file_with_compression, + parent_folder_id=self.folder.id if self.folder else "ROOT.FLOW360", length_unit=self.length_unit, format=mesh_format.value, description=description, @@ -1137,6 +1141,7 @@ def from_file( solver_version: str = None, length_unit: LengthUnitType = "m", tags: List[str] = None, + folder: Optional[Folder] = None, ) -> VolumeMeshDraftV2: """ Parameters @@ -1151,6 +1156,8 @@ def from_file( Length unit to use for the project ("m", "mm", "cm", "inch", "ft") tags: List[str] List of string tags to be added to the project upon creation + folder : Optional[Folder], optional + Parent folder for the project. If None, creates in root. Returns ------- @@ -1164,6 +1171,7 @@ def from_file( solver_version=solver_version, length_unit=length_unit, tags=tags, + folder=folder, ) # pylint: disable=useless-parent-delegation