diff --git a/flow360/cli/api_set_func.py b/flow360/cli/api_set_func.py index b36345a16..6b6b32378 100644 --- a/flow360/cli/api_set_func.py +++ b/flow360/cli/api_set_func.py @@ -1,10 +1,6 @@ """Helper function to set up the API key for the user.""" -from click.testing import CliRunner - -import flow360.user_config as user_config # pylint: disable=consider-using-from-import -from flow360.cli.app import configure -from flow360.log import log +from flow360.user_config import configure_apikey def configure_caller(apikey: str, environment: str = None, profile: str = "default") -> None: @@ -19,24 +15,4 @@ def configure_caller(apikey: str, environment: str = None, profile: str = "defau Returns: None """ - runner = CliRunner() - - # Construct CLI arguments as a list - args = ["--apikey", apikey, "--profile", profile] - - if environment: - if environment.lower() in ("dev", "uat"): - args += ["--" + environment.lower()] - elif environment.lower() == "prod": - args += [] - else: - args += ["--env", environment] - - # Invoke the `configure` command - result = runner.invoke(configure, args) - - if result.exit_code != 0: - log.error(result.output if result.output else str(result.exception)) - else: - log.info("Configuration successful.") - user_config.UserConfig = user_config.BasicUserConfig() # Reload + configure_apikey(apikey=apikey, environment=environment, profile=profile) diff --git a/flow360/cli/app.py b/flow360/cli/app.py index a6e251a63..942ad7281 100644 --- a/flow360/cli/app.py +++ b/flow360/cli/app.py @@ -33,11 +33,46 @@ "attr": "project", "help": "Inspect and manage Flow360 projects.", }, + "draft": { + "module": "flow360.cli.draft", + "attr": "draft", + "help": "Inspect draft resources.", + }, + "geometry": { + "module": "flow360.cli.assets", + "attr": "geometry", + "help": "Inspect Flow360 geometries.", + }, + "surface-mesh": { + "module": "flow360.cli.assets", + "attr": "surface_mesh", + "help": "Inspect Flow360 surface meshes.", + }, + "volume-mesh": { + "module": "flow360.cli.assets", + "attr": "volume_mesh", + "help": "Inspect Flow360 volume meshes.", + }, + "case": { + "module": "flow360.cli.assets", + "attr": "case", + "help": "Inspect Flow360 cases.", + }, "folder": { "module": "flow360.cli.folder", "attr": "folder", "help": "Inspect Flow360 folders.", }, + "open": { + "module": "flow360.cli.open_resource", + "attr": "open_resource", + "help": "Open a Flow360 resource in the browser.", + }, + "wait": { + "module": "flow360.cli.wait", + "attr": "wait", + "help": "Wait for a Flow360 resource to reach a terminal state.", + }, } @@ -47,7 +82,7 @@ class LazyFlow360Group(click.Group): def invoke(self, ctx): try: return super().invoke(ctx) - except click.ClickException: + except (click.ClickException, click.exceptions.Exit, click.Abort): raise except Exception as error: # pylint: disable=broad-except # Convert uncaught SDK auth failures into normal CLI errors. diff --git a/flow360/cli/assets.py b/flow360/cli/assets.py new file mode 100644 index 000000000..69867e137 --- /dev/null +++ b/flow360/cli/assets.py @@ -0,0 +1,299 @@ +""" +Asset CLI commands. +""" + +from __future__ import annotations + +import json + +import click + +from flow360.cli.output import emit_json +from flow360.cli.resource_state import get_resource_state_for_type + + +def _serialize_asset_info(info): + return { + "id": info.get("id"), + "name": info.get("name"), + "project_id": info.get("projectId"), + "parent_id": info.get("parentId"), + "solver_version": info.get("solverVersion"), + "status": info.get("status"), + "tags": list(info.get("tags") or []), + "type": info.get("type"), + "created_at": info.get("createdAt"), + "updated_at": info.get("updatedAt"), + } + + +def _get_asset_info(webapi_cls, asset_id): + # pylint: disable=import-outside-toplevel + return webapi_cls(asset_id).get_info() + + +def _get_asset_simulation_json(webapi_cls, asset_id): + # pylint: disable=import-outside-toplevel + simulation_json = webapi_cls(asset_id).get_simulation_json() + if isinstance(simulation_json, str): + return json.loads(simulation_json) + return simulation_json + + +def _summarize_simulation_json(simulation_json): + # pylint: disable=import-outside-toplevel + from flow360.cli.simulation_summary import summarize_simulation + + return summarize_simulation(simulation_json) + + +def _emit_asset_summary(webapi_cls, asset_id): + emit_json( + { + "id": asset_id, + "summary": _summarize_simulation_json(_get_asset_simulation_json(webapi_cls, asset_id)), + } + ) + + +@click.group("geometry") +def geometry(): + """Inspect Flow360 geometries.""" + + +def _emit_geometry_info(geometry_id): + # pylint: disable=import-outside-toplevel + from flow360.component.simulation.web.asset_webapi import GeometryWebApi + + info = _get_asset_info(GeometryWebApi, geometry_id) + emit_json(_serialize_asset_info(info)) + + +@geometry.command("info") +@click.argument("geometry_id") +def info_geometry(geometry_id): + """Get geometry metadata.""" + _emit_geometry_info(geometry_id) + + +@geometry.command("get", hidden=True) +@click.argument("geometry_id") +def get_geometry_alias(geometry_id): + """Backward-compatible alias for geometry info.""" + _emit_geometry_info(geometry_id) + + +@geometry.command("state") +@click.argument("geometry_id") +def state_geometry(geometry_id): + """Get geometry lifecycle state.""" + emit_json(get_resource_state_for_type("Geometry", geometry_id)) + + +@geometry.command("summary") +@click.argument("geometry_id") +def summary_geometry(geometry_id): + """Summarize geometry simulation settings.""" + # pylint: disable=import-outside-toplevel + from flow360.component.simulation.web.asset_webapi import GeometryWebApi + + _emit_asset_summary(GeometryWebApi, geometry_id) + + +@geometry.group("simulation") +def geometry_simulation(): + """Namespace for geometry simulation commands.""" + + +@geometry_simulation.command("get") +@click.argument("geometry_id") +def get_geometry_simulation(geometry_id): + """Get geometry simulation JSON.""" + # pylint: disable=import-outside-toplevel + from flow360.component.simulation.web.asset_webapi import GeometryWebApi + + emit_json({"simulation": _get_asset_simulation_json(GeometryWebApi, geometry_id)}) + + +@click.group("surface-mesh") +def surface_mesh(): + """Inspect Flow360 surface meshes.""" + + +def _emit_surface_mesh_info(surface_mesh_id): + # pylint: disable=import-outside-toplevel + from flow360.component.simulation.web.asset_webapi import SurfaceMeshWebApi + + info = _get_asset_info(SurfaceMeshWebApi, surface_mesh_id) + emit_json(_serialize_asset_info(info)) + + +@surface_mesh.command("info") +@click.argument("surface_mesh_id") +def info_surface_mesh(surface_mesh_id): + """Get surface mesh metadata.""" + _emit_surface_mesh_info(surface_mesh_id) + + +@surface_mesh.command("get", hidden=True) +@click.argument("surface_mesh_id") +def get_surface_mesh_alias(surface_mesh_id): + """Backward-compatible alias for surface mesh info.""" + _emit_surface_mesh_info(surface_mesh_id) + + +@surface_mesh.command("state") +@click.argument("surface_mesh_id") +def state_surface_mesh(surface_mesh_id): + """Get surface mesh lifecycle state.""" + emit_json(get_resource_state_for_type("SurfaceMesh", surface_mesh_id)) + + +@surface_mesh.command("summary") +@click.argument("surface_mesh_id") +def summary_surface_mesh(surface_mesh_id): + """Summarize surface mesh simulation settings.""" + # pylint: disable=import-outside-toplevel + from flow360.component.simulation.web.asset_webapi import SurfaceMeshWebApi + + _emit_asset_summary(SurfaceMeshWebApi, surface_mesh_id) + + +@surface_mesh.group("simulation") +def surface_mesh_simulation(): + """Namespace for surface mesh simulation commands.""" + + +@surface_mesh_simulation.command("get") +@click.argument("surface_mesh_id") +def get_surface_mesh_simulation(surface_mesh_id): + """Get surface mesh simulation JSON.""" + # pylint: disable=import-outside-toplevel + from flow360.component.simulation.web.asset_webapi import SurfaceMeshWebApi + + emit_json({"simulation": _get_asset_simulation_json(SurfaceMeshWebApi, surface_mesh_id)}) + + +@click.group("volume-mesh") +def volume_mesh(): + """Inspect Flow360 volume meshes.""" + + +def _emit_volume_mesh_info(volume_mesh_id): + # pylint: disable=import-outside-toplevel + from flow360.component.simulation.web.asset_webapi import VolumeMeshWebApi + + info = _get_asset_info(VolumeMeshWebApi, volume_mesh_id) + emit_json(_serialize_asset_info(info)) + + +@volume_mesh.command("info") +@click.argument("volume_mesh_id") +def info_volume_mesh(volume_mesh_id): + """Get volume mesh metadata.""" + _emit_volume_mesh_info(volume_mesh_id) + + +@volume_mesh.command("get", hidden=True) +@click.argument("volume_mesh_id") +def get_volume_mesh_alias(volume_mesh_id): + """Backward-compatible alias for volume mesh info.""" + _emit_volume_mesh_info(volume_mesh_id) + + +@volume_mesh.command("state") +@click.argument("volume_mesh_id") +def state_volume_mesh(volume_mesh_id): + """Get volume mesh lifecycle state.""" + emit_json(get_resource_state_for_type("VolumeMesh", volume_mesh_id)) + + +@volume_mesh.command("summary") +@click.argument("volume_mesh_id") +def summary_volume_mesh(volume_mesh_id): + """Summarize volume mesh simulation settings.""" + # pylint: disable=import-outside-toplevel + from flow360.component.simulation.web.asset_webapi import VolumeMeshWebApi + + _emit_asset_summary(VolumeMeshWebApi, volume_mesh_id) + + +@volume_mesh.group("simulation") +def volume_mesh_simulation(): + """Namespace for volume mesh simulation commands.""" + + +@volume_mesh_simulation.command("get") +@click.argument("volume_mesh_id") +def get_volume_mesh_simulation(volume_mesh_id): + """Get volume mesh simulation JSON.""" + # pylint: disable=import-outside-toplevel + from flow360.component.simulation.web.asset_webapi import VolumeMeshWebApi + + emit_json({"simulation": _get_asset_simulation_json(VolumeMeshWebApi, volume_mesh_id)}) + + +@click.group("case") +def case(): + """Inspect Flow360 cases.""" + + +def _serialize_case_info(info): + payload = _serialize_asset_info(info) + payload["type"] = payload["type"] or "Case" + payload["mesh_id"] = info.get("caseMeshId") or info.get("meshId") + return payload + + +def _emit_case_info(case_id): + # pylint: disable=import-outside-toplevel + from flow360.component.simulation.web.asset_webapi import CaseWebApi + + info = _get_asset_info(CaseWebApi, case_id) + emit_json(_serialize_case_info(info)) + + +@case.command("info") +@click.argument("case_id") +def info_case(case_id): + """Get case metadata.""" + _emit_case_info(case_id) + + +@case.command("get", hidden=True) +@click.argument("case_id") +def get_case_alias(case_id): + """Backward-compatible alias for case info.""" + _emit_case_info(case_id) + + +@case.command("state") +@click.argument("case_id") +def state_case(case_id): + """Get case lifecycle state.""" + emit_json(get_resource_state_for_type("Case", case_id)) + + +@case.command("summary") +@click.argument("case_id") +def summary_case(case_id): + """Summarize case simulation settings.""" + # pylint: disable=import-outside-toplevel + from flow360.component.simulation.web.asset_webapi import CaseWebApi + + _emit_asset_summary(CaseWebApi, case_id) + + +@case.group("simulation") +def case_simulation(): + """Namespace for case simulation commands.""" + + +@case_simulation.command("get") +@click.argument("case_id") +def get_case_simulation(case_id): + """Get case simulation JSON.""" + # pylint: disable=import-outside-toplevel + from flow360.component.simulation.web.asset_webapi import CaseWebApi + + emit_json({"simulation": _get_asset_simulation_json(CaseWebApi, case_id)}) diff --git a/flow360/cli/auth.py b/flow360/cli/auth.py index f60995168..d7f79bc17 100644 --- a/flow360/cli/auth.py +++ b/flow360/cli/auth.py @@ -467,7 +467,7 @@ def wait_for_login( storage_environment = None if environment.name == Env.prod.name else environment.name store_apikey(apikey, profile=profile, environment_name=storage_environment) - user_config.UserConfig = user_config.BasicUserConfig() + user_config.reload_user_config() return { "status": "success", "login_url": login_url, diff --git a/flow360/cli/auth_guidance.py b/flow360/cli/auth_guidance.py index a0f7b7a54..d168d98f7 100644 --- a/flow360/cli/auth_guidance.py +++ b/flow360/cli/auth_guidance.py @@ -37,3 +37,16 @@ def build_configure_command(environment_name: str, profile: str) -> str: parts.extend(["--profile", profile]) parts.extend(["--apikey", ""]) return " ".join(parts) + + +def build_missing_api_key_message(environment_name: str, profile: str) -> str: + """Build the auth guidance shown when no API key is configured.""" + return "\n".join( + [ + f"No API key configured for env={environment_name}, profile={profile}.", + "Authenticate with:", + f" {build_login_command(environment_name, profile)}", + "For headless or manual setup:", + f" {build_configure_command(environment_name, profile)}", + ] + ) diff --git a/flow360/cli/browser_links.py b/flow360/cli/browser_links.py new file mode 100644 index 000000000..97a295123 --- /dev/null +++ b/flow360/cli/browser_links.py @@ -0,0 +1,131 @@ +"""Shared browser-link helpers for Flow360 CLI resources.""" + +from __future__ import annotations + +import webbrowser +from urllib.parse import urlencode + +from flow360.cli.resource_refs import ResourceRefError, parse_resource_ref +from flow360.environment import Env + + +def _is_root_folder_id(resource_id: str) -> bool: + return resource_id == "ROOT.FLOW360" or resource_id.startswith("ROOT.FLOW360.") + + +def _get_project_scoped_resource_info(resource_type: str, resource_id: str) -> dict: + # pylint: disable=import-outside-toplevel + from flow360.component.simulation.web.asset_webapi import ( + CaseWebApi, + DraftWebApi, + GeometryWebApi, + SurfaceMeshWebApi, + VolumeMeshWebApi, + ) + + webapi_by_type = { + "Geometry": GeometryWebApi, + "SurfaceMesh": SurfaceMeshWebApi, + "VolumeMesh": VolumeMeshWebApi, + "Case": CaseWebApi, + "Draft": DraftWebApi, + } + + webapi_cls = webapi_by_type.get(resource_type) + if webapi_cls is None: + raise ResourceRefError( + f"Opening {resource_type} resources in the browser is not supported." + ) + + return webapi_cls(resource_id).get_info() + + +def _get_workspace_id_for_root_folder(root_folder_id: str) -> str | None: + # pylint: disable=import-outside-toplevel + from flow360.component.simulation.web.workspace_webapi import WorkspaceWebApi + + return WorkspaceWebApi.get_workspace_id_for_root_folder(root_folder_id) + + +def _get_root_folder_id(resource_id: str) -> str: + if _is_root_folder_id(resource_id): + return resource_id + + # pylint: disable=import-outside-toplevel + from flow360.component.simulation.web.folder_webapi import FolderWebApi + + current_id = resource_id + while True: + info = FolderWebApi(current_id).get_info() + parent_folders = info.get("parentFolders") or [] + for ancestor in parent_folders: + ancestor_id = ancestor.get("id") + if ancestor_id and _is_root_folder_id(ancestor_id): + return ancestor_id + + parent_id = info.get("parentFolderId") + if not parent_id: + return current_id + if _is_root_folder_id(parent_id): + return parent_id + current_id = parent_id + + +def _resolve_folder_workspace_id(resource_id: str) -> str: + root_folder_id = _get_root_folder_id(resource_id) + root_workspace_id = _get_workspace_id_for_root_folder(root_folder_id) + if root_workspace_id: + return root_workspace_id + + raise ResourceRefError( + f"Could not infer a workspace for folder {resource_id}. " + f"No workspace matched rootFolderId {root_folder_id}." + ) + + +def _get_folder_browser_path(resource_id: str, workspace_id: str | None) -> str: + resolved_workspace_id = workspace_id or _resolve_folder_workspace_id(resource_id) + query = urlencode( + { + "workspaceId": resolved_workspace_id, + "folderId": resource_id, + "activeTabIndex": 0, + } + ) + return f"workspaces?{query}" + + +def _get_workbench_path(project_id: str, resource_id: str, resource_type: str) -> str: + query = urlencode({"id": resource_id, "type": resource_type}) + return f"workbench/{project_id}?{query}" + + +def get_resource_browser_payload(ref_id: str, *, workspace_id: str | None = None) -> dict: + """Resolve a typed Flow360 ref to a browser-openable URL payload.""" + resource_ref = parse_resource_ref(ref_id) + if resource_ref.resource_type == "Project": + path = f"workbench/{resource_ref.id}" + elif resource_ref.resource_type == "Folder": + path = _get_folder_browser_path(resource_ref.id, workspace_id) + else: + info = _get_project_scoped_resource_info(resource_ref.resource_type, resource_ref.id) + project_id = info.get("projectId") + if not project_id: + raise ResourceRefError( + f"{resource_ref.resource_type} {resource_ref.id} does not expose a projectId." + ) + path = _get_workbench_path(project_id, resource_ref.id, resource_ref.resource_type) + url = Env.current.get_web_real_url(path) + return { + "id": resource_ref.id, + "type": resource_ref.resource_type, + "url": url, + } + + +def open_browser_url(url: str) -> bool: + """Best-effort browser open that never raises CLI-visible browser errors.""" + try: + return bool(webbrowser.open(url)) + except webbrowser.Error: + return False diff --git a/flow360/cli/draft.py b/flow360/cli/draft.py new file mode 100644 index 000000000..90489528a --- /dev/null +++ b/flow360/cli/draft.py @@ -0,0 +1,124 @@ +""" +Draft CLI commands. +""" + +from __future__ import annotations + +import json + +import click + +from flow360.cli.output import emit_json +from flow360.cli.resource_refs import ResourceRefError, require_resource_type +from flow360.cli.resource_state import get_resource_state_for_type + + +def _require_typed_id(resource_id, expected_type): + try: + return require_resource_type(resource_id, expected_type).id + except ResourceRefError as error: + raise click.ClickException(str(error)) from error + + +def _get_draft_info(draft_id): + # pylint: disable=import-outside-toplevel + from flow360.component.simulation.web.asset_webapi import DraftWebApi + + return DraftWebApi(draft_id).get_info() + + +def _list_drafts(project_id): + # pylint: disable=import-outside-toplevel + from flow360.component.simulation.web.asset_webapi import DraftWebApi + + return DraftWebApi.list_records(project_id) + + +def _get_draft_simulation_json(draft_id): + # pylint: disable=import-outside-toplevel + from flow360.component.simulation.web.asset_webapi import DraftWebApi + + simulation_json = DraftWebApi(draft_id).get_simulation_json() + if isinstance(simulation_json, str): + return json.loads(simulation_json) + return simulation_json + + +def _serialize_draft_info(info): + return { + "id": info.get("id"), + "name": info.get("name"), + "project_id": info.get("projectId"), + "solver_version": info.get("solverVersion"), + "source_item_id": info.get("sourceItemId"), + "source_item_type": info.get("sourceItemType"), + "fork_case": info.get("forkCase"), + "type": info.get("type"), + } + + +@click.group("draft") +def draft(): + """Inspect draft resources.""" + + +def _emit_draft_list(project_id): + emit_json({"records": [_serialize_draft_info(info) for info in _list_drafts(project_id)]}) + + +@draft.command("list") +@click.option("--project-id", required=True, help="Project ID.") +def list_drafts(project_id): + """List drafts for a project.""" + project_id = _require_typed_id(project_id, "Project") + _emit_draft_list(project_id) + + +@draft.command("ls", hidden=True) +@click.option("--project-id", required=True, help="Project ID.") +def list_drafts_alias(project_id): + """Backward-compatible alias for draft list.""" + project_id = _require_typed_id(project_id, "Project") + _emit_draft_list(project_id) + + +def _emit_draft_info(draft_id): + info = _get_draft_info(draft_id) + emit_json(_serialize_draft_info(info)) + + +@draft.command("info") +@click.argument("draft_id") +def show_draft_info(draft_id): + """Get draft metadata.""" + draft_id = _require_typed_id(draft_id, "Draft") + _emit_draft_info(draft_id) + + +@draft.command("get", hidden=True) +@click.argument("draft_id") +def get_draft_alias(draft_id): + """Backward-compatible alias for draft info.""" + draft_id = _require_typed_id(draft_id, "Draft") + _emit_draft_info(draft_id) + + +@draft.command("state") +@click.argument("draft_id") +def show_draft_state(draft_id): + """Get draft lifecycle state.""" + draft_id = _require_typed_id(draft_id, "Draft") + emit_json(get_resource_state_for_type("Draft", draft_id)) + + +@draft.group("simulation") +def draft_simulation(): + """Namespace for draft simulation commands.""" + + +@draft_simulation.command("get") +@click.argument("draft_id") +def get_draft_simulation(draft_id): + """Get draft simulation JSON.""" + draft_id = _require_typed_id(draft_id, "Draft") + emit_json({"simulation": _get_draft_simulation_json(draft_id)}) diff --git a/flow360/cli/open_resource.py b/flow360/cli/open_resource.py new file mode 100644 index 000000000..953160420 --- /dev/null +++ b/flow360/cli/open_resource.py @@ -0,0 +1,28 @@ +"""Open Flow360 resources in the browser.""" + +from __future__ import annotations + +import click + +from flow360.cli.browser_links import get_resource_browser_payload, open_browser_url +from flow360.cli.output import emit_json +from flow360.cli.resource_refs import ResourceRefError + + +@click.command("open") +@click.argument("ref_id") +@click.option( + "--workspace-id", + default=None, + hidden=True, + help="Internal override for folder workspace resolution.", +) +def open_resource(ref_id, workspace_id): + """Open a Flow360 resource in the browser.""" + try: + payload = get_resource_browser_payload(ref_id, workspace_id=workspace_id) + except ResourceRefError as error: + raise click.ClickException(str(error)) from error + + payload["opened"] = open_browser_url(payload["url"]) + emit_json(payload) diff --git a/flow360/cli/project.py b/flow360/cli/project.py index aebd96c62..bfd52a082 100644 --- a/flow360/cli/project.py +++ b/flow360/cli/project.py @@ -8,6 +8,10 @@ from flow360.cli.output import emit_json, emit_payload from flow360.cli.project_formatters import format_project_list +from flow360.component.simulation.web.project_tree import ( + build_project_tree, + get_project_tree_parent_id, +) def _get_project_records(search=None, limit=25, folder_ids=None, exclude_subfolders=False): @@ -31,13 +35,7 @@ def _get_project_info(project_id): def _get_project_tree(project_id): - # pylint: disable=import-outside-toplevel - from flow360.component.project import ProjectTree - - records = _get_project_tree_records(project_id) - tree = ProjectTree() - tree.construct_tree(asset_records=records) - return tree + return _project_tree_from_records(_get_project_tree_records(project_id)) def _get_project_tree_records(project_id): @@ -98,25 +96,36 @@ def _serialize_project_statistics(statistics): } -def _serialize_tree_node(node): +def _project_tree_from_records(records): + def create_node(item): + return { + "id": item["id"], + "name": item["name"], + "type": item["type"], + "children": [], + } + + def add_child(parent, child): + parent["children"].append(child) + + try: + root, _nodes = build_project_tree(records, create_node=create_node, add_child=add_child) + except ValueError as err: + raise click.ClickException(str(err)) from err + return root + + +def _project_item_from_record(item): return { - "id": node.asset_id, - "name": node.asset_name, - "type": node.asset_type, - "children": [_serialize_tree_node(child) for child in node.children], + "id": item["id"], + "name": item["name"], + "type": item["type"], + "parent_id": get_project_tree_parent_id(item), } def _project_items_from_records(records): - return [ - { - "id": item["id"], - "name": item["name"], - "type": item["type"], - "parent_id": item.get("parentCaseId") or item.get("parentId"), - } - for item in records - ] + return [_project_item_from_record(item) for item in records] def _serialize_project_item(item): @@ -243,8 +252,7 @@ def project_tree(project_id): """ Get the project tree. """ - tree = _get_project_tree(project_id) - emit_json({"root": _serialize_tree_node(tree.root)}) + emit_json({"root": _get_project_tree(project_id)}) @project.command("items") diff --git a/flow360/cli/resource_refs.py b/flow360/cli/resource_refs.py new file mode 100644 index 000000000..149004aba --- /dev/null +++ b/flow360/cli/resource_refs.py @@ -0,0 +1,66 @@ +""" +Shared CLI parsing for typed Flow360 resource references. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +RESOURCE_PREFIX_MAP = { + "prj": "Project", + "geo": "Geometry", + "sm": "SurfaceMesh", + "vm": "VolumeMesh", + "case": "Case", + "dft": "Draft", + "folder": "Folder", +} +ROOT_FOLDER_PREFIX = "ROOT.FLOW360" + + +class ResourceRefError(ValueError): + """Raised when a CLI resource reference is malformed or unsupported.""" + + +@dataclass(frozen=True) +class ResourceRef: + """Normalized typed resource reference parsed from a Flow360 id.""" + + id: str + resource_type: str + + +def parse_resource_ref(resource_id: str) -> ResourceRef: + """Parse a Flow360 resource id by its stable type prefix.""" + normalized_id = resource_id.strip() + if not normalized_id: + raise ResourceRefError("Resource ID cannot be empty.") + + if normalized_id == ROOT_FOLDER_PREFIX or normalized_id.startswith(f"{ROOT_FOLDER_PREFIX}."): + return ResourceRef(id=normalized_id, resource_type="Folder") + + prefix, separator, suffix = normalized_id.partition("-") + if not separator or not suffix: + raise ResourceRefError( + f"Resource ID '{resource_id}' does not have the expected '-...' shape." + ) + + resource_type = RESOURCE_PREFIX_MAP.get(prefix) + if resource_type is None: + expected_prefixes = ", ".join(f"{value}-" for value in sorted(RESOURCE_PREFIX_MAP)) + raise ResourceRefError( + f"Unsupported resource ID prefix in '{normalized_id}'. " + f"Expected one of: {expected_prefixes}." + ) + + return ResourceRef(id=normalized_id, resource_type=resource_type) + + +def require_resource_type(resource_id: str, expected_type: str) -> ResourceRef: + """Parse and validate that a resource id matches the expected Flow360 type.""" + resource_ref = parse_resource_ref(resource_id) + if resource_ref.resource_type != expected_type: + raise ResourceRefError( + f"Expected a {expected_type} ID, got {resource_ref.id} ({resource_ref.resource_type})." + ) + return resource_ref diff --git a/flow360/cli/resource_state.py b/flow360/cli/resource_state.py new file mode 100644 index 000000000..66a88fab2 --- /dev/null +++ b/flow360/cli/resource_state.py @@ -0,0 +1,88 @@ +""" +Shared resource state helpers for the Flow360 CLI. +""" + +from __future__ import annotations + +import time + +import click + +from flow360.cli.resource_refs import ResourceRefError, parse_resource_ref + +SUCCESS_STATES = {"completed", "processed"} +TERMINAL_STATES = SUCCESS_STATES | {"failed", "error", "deleted"} + + +class WaitTimeoutError(RuntimeError): + """Raised when a wait loop exceeds the requested timeout.""" + + def __init__(self, state): + super().__init__("Timed out while waiting for terminal resource state.") + self.state = state + + +def serialize_resource_state(info, *, default_type=None): + """Project a resource info payload into the CLI lifecycle-state contract.""" + status = info.get("status") + return { + "id": info.get("id"), + "type": info.get("type") or default_type, + "status": status, + "is_terminal": status in TERMINAL_STATES, + "is_success": status in SUCCESS_STATES, + "updated_at": info.get("updatedAt"), + } + + +def get_resource_state_for_type(resource_type, resource_id): + """Fetch and serialize lifecycle state for a known resource type.""" + # pylint: disable=import-outside-toplevel + from flow360.component.simulation.web.asset_webapi import ( + CaseWebApi, + DraftWebApi, + GeometryWebApi, + SurfaceMeshWebApi, + VolumeMeshWebApi, + ) + + webapi_by_type = { + "Draft": DraftWebApi, + "Geometry": GeometryWebApi, + "SurfaceMesh": SurfaceMeshWebApi, + "VolumeMesh": VolumeMeshWebApi, + "Case": CaseWebApi, + } + webapi_cls = webapi_by_type.get(resource_type) + if webapi_cls is None: + raise click.ClickException(f"Waiting for {resource_type} resources is not supported.") + + info = webapi_cls(resource_id).get_info() + payload = serialize_resource_state(info, default_type=resource_type) + if resource_type == "Case": + payload["mesh_id"] = info.get("caseMeshId") or info.get("meshId") + return payload + + +def get_resource_state(resource_id): + """Fetch lifecycle state for a typed Flow360 resource id.""" + try: + resource_ref = parse_resource_ref(resource_id) + except ResourceRefError as error: + raise click.ClickException(str(error)) from error + + return get_resource_state_for_type(resource_ref.resource_type, resource_ref.id) + + +def wait_for_resource_state(resource_id, *, timeout, poll_interval): + """Poll a resource until it reaches a terminal state or times out.""" + deadline = time.monotonic() + timeout + last_state = None + + while True: + last_state = get_resource_state(resource_id) + if last_state["is_terminal"]: + return last_state + if time.monotonic() >= deadline: + raise WaitTimeoutError(last_state) + time.sleep(poll_interval) diff --git a/flow360/cli/simulation_summary.py b/flow360/cli/simulation_summary.py new file mode 100644 index 000000000..28b152a4c --- /dev/null +++ b/flow360/cli/simulation_summary.py @@ -0,0 +1,437 @@ +"""Generic simulation JSON compaction for CLI inspection.""" + +from __future__ import annotations + +import copy +import json +import logging +from collections import OrderedDict + +_PRIVATE_PREFIX = "private_attribute_" +_ENTITY_COLLECTION_KEYS = ("stored_entities", "selectors") +_GROUP_LABEL_KEYS = {"name"} +_SAMPLE_LIMIT = 10 + + +def summarize_simulation(simulation_json: dict) -> dict: + """Validate simulation JSON and return a compact JSON projection.""" + + display_dict, normalized_dict, default_dict = _load_summary_dicts(simulation_json) + compact_display = _compact_value(display_dict) + if default_dict is None: + return compact_display + return _prune_defaults( + compact_display, + _compact_value(normalized_dict), + _compact_value(default_dict), + ) + + +def _load_summary_dicts(simulation_json: dict) -> tuple[dict, dict, dict | None]: + # pylint: disable=import-outside-toplevel + from flow360.component.simulation.simulation_params import SimulationParams + + previous_disable_level = logging.root.manager.disable + logging.disable(logging.WARNING) + try: + params_dict = SimulationParams._sanitize_params_dict( # pylint: disable=protected-access + copy.deepcopy(simulation_json) + ) + params_dict, _ = SimulationParams._update_param_dict( # pylint: disable=protected-access + params_dict + ) + root_item_type = _infer_root_item_type(params_dict) + unit_system_name = _unit_system_name(params_dict) + length_unit = _project_length_unit(params_dict) + params_dict = _strip_private_cache(params_dict) + params = SimulationParams(file_content=copy.deepcopy(params_dict)) + normalized_dict = _strip_private_cache(params.model_dump(mode="json", exclude_none=True)) + default_dict = _default_params_dict(unit_system_name, length_unit, root_item_type) + return params_dict, normalized_dict, default_dict + finally: + logging.disable(previous_disable_level) + + +def _infer_root_item_type(params_dict): + # pylint: disable=import-outside-toplevel + from flow360.component.simulation.services import ( + _parse_root_item_type_from_simulation_json, + ) + + try: + return _parse_root_item_type_from_simulation_json(param_as_dict=params_dict) + except ValueError: + return "VolumeMesh" if params_dict.get("meshing") is None else "Geometry" + + +def _unit_system_name(params_dict): + unit_system = params_dict.get("unit_system") + if isinstance(unit_system, dict): + return unit_system.get("name") or "SI" + return unit_system or "SI" + + +def _project_length_unit(params_dict): + project_length_unit = params_dict.get("private_attribute_asset_cache", {}).get( + "project_length_unit" + ) + if isinstance(project_length_unit, dict): + return project_length_unit.get("units") or "m" + return "m" + + +def _default_params_dict(unit_system_name, length_unit, root_item_type): + # pylint: disable=import-outside-toplevel + from flow360.component.simulation.services import get_default_params + + try: + return _strip_private_cache( + get_default_params(unit_system_name, length_unit, root_item_type) + ) + except (RuntimeError, ValueError, TypeError): + return None + + +def _compact_value(value): + if isinstance(value, dict): + if _is_entity_collection(value): + return _compact_entity_collection(value) + if set(value) == {"items"}: + return _compact_value(value["items"]) + return _compact_mapping(value) + + if isinstance(value, list): + return _compact_sequence(value) + + return value + + +def _compact_mapping(value): + compacted = OrderedDict() + for key, child in value.items(): + if _should_drop_key(key): + continue + compacted[key] = _compact_value(child) + return _clean_empty(compacted) + + +def _compact_sequence(value): + compacted = [_compact_value(item) for item in value] + compacted = [item for item in compacted if item not in ({}, [], None)] + + if not compacted: + return [] + + if all(isinstance(item, dict) for item in compacted): + return _group_compacted_mappings(compacted) + + if len(compacted) > _SAMPLE_LIMIT: + return {"_count": len(compacted), "_sample": compacted[:_SAMPLE_LIMIT]} + + return compacted + + +def _group_compacted_mappings(items): + groups = OrderedDict() + for item in items: + signature = _group_signature(item) + bucket = groups.setdefault( + signature, + { + "count": 0, + "representative": item, + "labels": [], + "entity_summaries": OrderedDict(), + }, + ) + bucket["count"] += 1 + bucket["labels"].extend(_extract_labels(item)) + _collect_entity_summaries(item, bucket["entity_summaries"]) + + if len(groups) == len(items) and len(items) <= _SAMPLE_LIMIT: + return items + + grouped_items = [_serialize_group(bucket) for bucket in groups.values()] + if len(grouped_items) > _SAMPLE_LIMIT: + return {"_count": len(grouped_items), "_sample": grouped_items[:_SAMPLE_LIMIT]} + return grouped_items + + +def _serialize_group(bucket): + if bucket["count"] == 1: + return bucket["representative"] + + representative = copy.deepcopy(bucket["representative"]) + _drop_group_variable_fields(representative) + representative["_count"] = bucket["count"] + + labels = _unique(bucket["labels"]) + if labels: + representative["_names"] = _sample(labels) + + for path, names in bucket["entity_summaries"].items(): + _set_path(representative, path, _entity_summary(names)) + + return _clean_empty(representative) + + +def _compact_entity_collection(value): + names = [] + for key in _ENTITY_COLLECTION_KEYS: + for entity in value.get(key) or []: + names.append(_entity_label(entity)) + return _entity_summary(names) + + +def _entity_summary(names): + unique_names = _unique([name for name in names if name]) + if not unique_names: + return {"_count": 0} + return {"_count": len(unique_names), "_sample": _sample(unique_names)} + + +def _entity_label(entity): + if isinstance(entity, dict): + return ( + entity.get("name") or entity.get("id") or entity.get("type") or entity.get("type_name") + ) + return str(entity) + + +def _group_signature(value): + signature_value = _strip_group_variable_fields(value) + return json.dumps(signature_value, sort_keys=True, separators=(",", ":")) + + +def _strip_group_variable_fields(value): + if isinstance(value, dict): + return { + key: _strip_group_variable_fields(child) + for key, child in value.items() + if key not in _GROUP_LABEL_KEYS and not _is_entity_summary(child) + } + if isinstance(value, list): + return [_strip_group_variable_fields(item) for item in value] + return value + + +def _drop_group_variable_fields(value): + if not isinstance(value, dict): + return + for key in list(value): + if key in _GROUP_LABEL_KEYS or _is_entity_summary(value[key]): + value.pop(key) + continue + _drop_group_variable_fields(value[key]) + + +def _extract_labels(value): + labels = [] + if isinstance(value, dict): + for key, child in value.items(): + if key in _GROUP_LABEL_KEYS and child: + labels.append(child) + elif isinstance(child, (dict, list)): + labels.extend(_extract_labels(child)) + elif isinstance(value, list): + for child in value: + labels.extend(_extract_labels(child)) + return labels + + +def _collect_entity_summaries(value, summaries, path=()): + if isinstance(value, dict): + if _is_entity_summary(value): + summaries.setdefault(path, []).extend(value.get("_sample") or []) + return + for key, child in value.items(): + _collect_entity_summaries(child, summaries, (*path, key)) + elif isinstance(value, list): + for index, child in enumerate(value): + _collect_entity_summaries(child, summaries, (*path, index)) + + +def _set_path(value, path, replacement): + target = value + for key in path[:-1]: + if isinstance(target, dict): + target = target.setdefault(key, OrderedDict()) + elif isinstance(target, list) and isinstance(key, int) and key < len(target): + target = target[key] + else: + return + if not path: + return + final_key = path[-1] + if isinstance(target, dict): + target[final_key] = replacement + elif isinstance(target, list) and isinstance(final_key, int) and final_key < len(target): + target[final_key] = replacement + + +def _prune_defaults( + display_value, + normalized_value, + default_value, + *, + keep_type_marker=False, + depth=0, +): + if default_value is None: + return display_value + if normalized_value == default_value: + if keep_type_marker: + return _type_marker(display_value) + return None + + if ( + isinstance(display_value, dict) + and isinstance(normalized_value, dict) + and isinstance(default_value, dict) + ): + pruned = OrderedDict() + for key, child in display_value.items(): + child_keep_type_marker = _is_type_marker_key(key) or ( + depth == 0 and bool(_type_marker(child)) + ) + if key in default_value: + child = _prune_defaults( + child, + normalized_value.get(key), + default_value.get(key), + keep_type_marker=child_keep_type_marker, + depth=depth + 1, + ) + elif _is_absent_default_like(child): + child = None + if child not in ({}, [], None): + pruned[key] = child + + marker = _type_marker(display_value) + if ( + marker + and (pruned or keep_type_marker) + and not any(_is_type_marker_key(key) for key in pruned) + ): + pruned = OrderedDict([*marker.items(), *pruned.items()]) + return _clean_empty(pruned) + + if ( + isinstance(display_value, list) + and isinstance(normalized_value, list) + and isinstance(default_value, list) + ): + return _prune_default_sequence(display_value, normalized_value, default_value, depth=depth) + + return display_value + + +def _prune_default_sequence(display_items, normalized_items, default_items, *, depth): + matched_default_indices = set() + pruned_items = [] + for index, display_item in enumerate(display_items): + normalized_item = normalized_items[index] if index < len(normalized_items) else None + default_index = _find_default_match(normalized_item, default_items, matched_default_indices) + if default_index is None: + pruned_items.append(display_item) + continue + matched_default_indices.add(default_index) + pruned_item = _prune_defaults( + display_item, + normalized_item, + default_items[default_index], + keep_type_marker=True, + depth=depth + 1, + ) + if pruned_item not in ({}, [], None): + pruned_items.append(pruned_item) + return pruned_items + + +def _find_default_match(normalized_item, default_items, matched_indices): + normalized_marker = _type_marker(normalized_item) + normalized_name = normalized_item.get("name") if isinstance(normalized_item, dict) else None + for index, default_item in enumerate(default_items): + if index in matched_indices: + continue + if normalized_item == default_item: + return index + if not normalized_marker or normalized_marker != _type_marker(default_item): + continue + default_name = default_item.get("name") if isinstance(default_item, dict) else None + if normalized_name is None or default_name is None or normalized_name == default_name: + return index + return None + + +def _type_marker(value): + if not isinstance(value, dict): + return {} + return {key: value[key] for key in value if _is_type_marker_key(key)} + + +def _is_type_marker_key(key): + return key in {"type", "type_name", "output_type", "refinement_type"} + + +def _is_absent_default_like(value): + return value in (None, False, 0, 0.0, [], {}) + + +def _is_entity_collection(value): + if not any(key in value for key in _ENTITY_COLLECTION_KEYS): + return False + entities = [] + for key in _ENTITY_COLLECTION_KEYS: + entities.extend(value.get(key) or []) + return all(isinstance(entity, dict) for entity in entities) + + +def _is_entity_summary(value): + return isinstance(value, dict) and set(value) <= {"_count", "_sample"} and "_count" in value + + +def _should_drop_key(key): + return isinstance(key, str) and key.startswith(_PRIVATE_PREFIX) + + +def _strip_private_cache(value): + if isinstance(value, dict): + return { + key: _strip_private_cache(child) + for key, child in value.items() + if key not in {"private_attribute_asset_cache", "private_attribute_dict"} + } + if isinstance(value, list): + return [_strip_private_cache(item) for item in value] + return value + + +def _sample(items): + return items[:_SAMPLE_LIMIT] + + +def _unique(items): + seen = set() + unique = [] + for item in items: + marker = json.dumps(item, sort_keys=True, default=str) + if marker in seen: + continue + seen.add(marker) + unique.append(item) + return unique + + +def _clean_empty(value): + if isinstance(value, dict): + cleaned = OrderedDict() + for key, child in value.items(): + cleaned_child = _clean_empty(child) + if cleaned_child in ({}, [], None): + continue + cleaned[key] = cleaned_child + return dict(cleaned) + if isinstance(value, list): + return [_clean_empty(item) for item in value if item not in ({}, [], None)] + return value diff --git a/flow360/cli/wait.py b/flow360/cli/wait.py new file mode 100644 index 000000000..75b933329 --- /dev/null +++ b/flow360/cli/wait.py @@ -0,0 +1,44 @@ +""" +Generic resource wait command. +""" + +from __future__ import annotations + +import click + +from flow360.cli.output import emit_json +from flow360.cli.resource_state import WaitTimeoutError +from flow360.cli.resource_state import ( + wait_for_resource_state as _wait_for_resource_state, +) + + +@click.command("wait") +@click.argument("ref_id") +@click.option( + "--timeout", + default=3600, + show_default=True, + type=click.FloatRange(min=0.1, min_open=False), + help="Maximum wait time in seconds.", +) +@click.option( + "--poll-interval", + default=2.0, + show_default=True, + type=click.FloatRange(min=0.1, min_open=False), + help="Polling interval in seconds.", +) +def wait(ref_id, timeout, poll_interval): + """Wait for a resource to reach a terminal state.""" + try: + state = _wait_for_resource_state(ref_id, timeout=timeout, poll_interval=poll_interval) + except WaitTimeoutError as error: + payload = dict(error.state or {}) + payload["timed_out"] = True + emit_json(payload) + raise click.exceptions.Exit(124) from error + + emit_json(state) + if not state["is_success"]: + raise click.exceptions.Exit(1) diff --git a/flow360/cloud/http_util.py b/flow360/cloud/http_util.py index 1cb39a714..a6979ae01 100644 --- a/flow360/cloud/http_util.py +++ b/flow360/cloud/http_util.py @@ -9,6 +9,7 @@ import requests +from ..cli.auth_guidance import build_missing_api_key_message from ..environment import Env from ..exceptions import ( Flow360AuthorisationError, @@ -38,25 +39,8 @@ def api_key_auth(request): """ key = api_key() if not key: - if Env.current.name == "dev": - raise Flow360AuthorisationError( - "API key not found for env=dev, please set it by commandline: " - f"flow360 configure --dev --profile {UserConfig.profile} --apikey " - ) - if Env.current.name == "uat": - raise Flow360AuthorisationError( - "API key not found for env=uat, please set it by commandline: " - f"flow360 configure --uat --profile {UserConfig.profile} --apikey " - ) - if Env.current.name == "prod": - raise Flow360AuthorisationError( - "API key not found for env=prod, please set it by commandline: " - f"flow360 configure --profile {UserConfig.profile} --apikey " - ) raise Flow360AuthorisationError( - f"API key not found for profile={UserConfig.profile} in env={Env.current.name}, " - "please set it by commandline: " - f"flow360 configure --profile {UserConfig.profile} --env {Env.current.name} --apikey " + build_missing_api_key_message(Env.current.name, UserConfig.profile) ) request.headers["simcloud-api-key"] = key request.headers["flow360-python-version"] = __version__ diff --git a/flow360/component/interfaces.py b/flow360/component/interfaces.py index cce15edd4..3561a8095 100644 --- a/flow360/component/interfaces.py +++ b/flow360/component/interfaces.py @@ -76,3 +76,9 @@ class BaseInterface(BaseModel): s3_transfer_method=S3TransferType.REPORT, endpoint="v2/report", ) + +WorkspaceInterface = BaseInterface( + resource_type="Workspace", + s3_transfer_method=None, + endpoint="v2/workspaces", +) diff --git a/flow360/component/project.py b/flow360/component/project.py index 217776b7d..872ff4141 100644 --- a/flow360/component/project.py +++ b/flow360/component/project.py @@ -65,6 +65,10 @@ get_project_records, show_projects_with_keyword_filter, ) +from flow360.component.simulation.web.project_tree import ( + build_project_tree, + get_project_tree_parent_id, +) from flow360.component.simulation.web.utils import ( get_project_dependency_resource_metadata, ) @@ -490,9 +494,7 @@ def _get_parent_node(self, node: ProjectTreeNode): def _has_node(self, asset_id: str) -> bool: """Use asset_id to check if the asset already exists in the project tree""" - if asset_id in self.nodes.keys(): - return True - return False + return asset_id in self.nodes def _get_asset_ids_by_type( self, asset_type: str = Literal["Geometry", "SurfaceMesh", "VolumeMesh", "Case"] @@ -503,11 +505,7 @@ def _get_asset_ids_by_type( @classmethod def _create_new_node(cls, asset_record: dict): """Create a new node based on the asset record from API call""" - parent_id = ( - asset_record["parentCaseId"] - if asset_record["parentCaseId"] - else asset_record["parentId"] - ) + parent_id = get_project_tree_parent_id(asset_record) case_mesh_id = asset_record["parentId"] if asset_record["type"] == "Case" else None new_node = ProjectTreeNode( @@ -558,17 +556,20 @@ def remove_node(self, node_id: str): def construct_tree(self, asset_records: List[dict]): """Construct the entire project tree""" - for asset_record in asset_records: + + def create_node(asset_record): new_node = ProjectTree._create_new_node(asset_record) self._update_short_id_map(new_node) - if new_node.parent_id is None: - self.root = new_node - self.nodes.update({new_node.asset_id: new_node}) + return new_node - for node in self.nodes.values(): - if node.parent_id and self._has_node(node.parent_id): - # pylint: disable=unsubscriptable-object - self.nodes[node.parent_id].add_child(node) + def add_child(parent, child): + parent.add_child(child) + + self.root, self.nodes = build_project_tree( + asset_records, + create_node=create_node, + add_child=add_child, + ) self._update_node_short_id() self._update_case_mesh_label() diff --git a/flow360/component/simulation/web/asset_webapi.py b/flow360/component/simulation/web/asset_webapi.py new file mode 100644 index 000000000..26ff47db6 --- /dev/null +++ b/flow360/component/simulation/web/asset_webapi.py @@ -0,0 +1,94 @@ +""" +Thin V2 resource web API wrappers. +""" + +from __future__ import annotations + +import json + +from flow360.cloud.rest_api import RestApi +from flow360.component.interfaces import ( + CaseInterfaceV2, + DraftInterface, + GeometryInterface, + SurfaceMeshInterfaceV2, + VolumeMeshInterfaceV2, +) + + +class ResourceWebApi: + """Thin wrapper around a single Flow360 resource endpoint.""" + + def __init__(self, interface, resource_id: str): + self.resource_id = resource_id + self._api = RestApi(interface.endpoint, id=resource_id) + + @staticmethod + def _unwrap_data(response): + """Return response data when REST responses use a top-level data envelope.""" + if isinstance(response, dict) and "data" in response: + return response["data"] + return response + + def get_info(self): + """Fetch resource metadata.""" + return self._unwrap_data(self._api.get()) + + def get_simulation_json(self): + """Fetch the resource simulation JSON payload.""" + response = self._unwrap_data( + self._api.get(method="simulation/file", params={"type": "simulation"}) + ) + if isinstance(response, dict) and "simulationJson" in response: + response = response["simulationJson"] + if isinstance(response, str): + return json.loads(response) + return response + + def get( + self, path=None, method=None, json=None, params=None + ): # pylint: disable=redefined-outer-name + """Delegate specialized GET calls to the underlying REST API.""" + return self._api.get(path=path, method=method, json=json, params=params) + + +class GeometryWebApi(ResourceWebApi): + """Thin geometry web API wrapper.""" + + def __init__(self, asset_id: str): + super().__init__(GeometryInterface, asset_id) + + +class SurfaceMeshWebApi(ResourceWebApi): + """Thin surface mesh web API wrapper.""" + + def __init__(self, asset_id: str): + super().__init__(SurfaceMeshInterfaceV2, asset_id) + + +class VolumeMeshWebApi(ResourceWebApi): + """Thin volume mesh web API wrapper.""" + + def __init__(self, asset_id: str): + super().__init__(VolumeMeshInterfaceV2, asset_id) + + +class CaseWebApi(ResourceWebApi): + """Thin case web API wrapper.""" + + def __init__(self, asset_id: str): + super().__init__(CaseInterfaceV2, asset_id) + + +class DraftWebApi(ResourceWebApi): + """Thin draft web API wrapper.""" + + def __init__(self, draft_id: str): + super().__init__(DraftInterface, draft_id) + + @classmethod + def list_records(cls, project_id: str): + """List draft records for a project.""" + api = RestApi(DraftInterface.endpoint) + response = api.get(params={"projectId": project_id}) + return response.get("records", []) diff --git a/flow360/component/simulation/web/project_tree.py b/flow360/component/simulation/web/project_tree.py new file mode 100644 index 000000000..a6ab2eca5 --- /dev/null +++ b/flow360/component/simulation/web/project_tree.py @@ -0,0 +1,52 @@ +""" +Lightweight project tree assembly helpers. +""" + +from __future__ import annotations + +from collections.abc import Callable, Iterable, Mapping +from typing import Any, TypeVar + +NodeT = TypeVar("NodeT") + + +def get_project_tree_parent_id(record: Mapping[str, Any]) -> str | None: + """Return the effective parent ID for a project tree record.""" + + return record.get("parentCaseId") or record.get("parentId") + + +def build_project_tree( + records: Iterable[Mapping[str, Any]], + *, + create_node: Callable[[Mapping[str, Any]], NodeT], + add_child: Callable[[NodeT, NodeT], None], +) -> tuple[NodeT, dict[str, NodeT]]: + """Build a project tree from flat API records without SDK dependencies.""" + + record_list = list(records) + nodes: dict[str, NodeT] = {} + root_ids: list[str] = [] + + for record in record_list: + node_id = record["id"] + if node_id in nodes: + raise ValueError(f"Project tree response contains duplicate item: {node_id}") + nodes[node_id] = create_node(record) + + for record in record_list: + node_id = record["id"] + parent_id = get_project_tree_parent_id(record) + if parent_id is None: + root_ids.append(node_id) + continue + if parent_id not in nodes: + raise ValueError( + f"Project tree response references missing parent {parent_id} for {node_id}" + ) + add_child(nodes[parent_id], nodes[node_id]) + + if len(root_ids) != 1: + raise ValueError(f"Project tree response contains {len(root_ids)} root items") + + return nodes[root_ids[0]], nodes diff --git a/flow360/component/simulation/web/workspace_webapi.py b/flow360/component/simulation/web/workspace_webapi.py new file mode 100644 index 000000000..1546eb542 --- /dev/null +++ b/flow360/component/simulation/web/workspace_webapi.py @@ -0,0 +1,27 @@ +"""Thin workspace web API wrapper.""" + +from __future__ import annotations + +from flow360.cloud.rest_api import RestApi +from flow360.component.interfaces import WorkspaceInterface + + +class WorkspaceWebApi: + """Thin wrapper around workspace endpoints.""" + + @classmethod + def list_records(cls): + """List available workspace records.""" + api = RestApi(WorkspaceInterface.endpoint) + response = api.get() + if isinstance(response, list): + return response + return response.get("data", []) + + @classmethod + def get_workspace_id_for_root_folder(cls, root_folder_id: str) -> str | None: + """Return the workspace ID that owns a root folder, if available.""" + for record in cls.list_records(): + if record.get("rootFolderId") == root_folder_id: + return record.get("id") + return None diff --git a/flow360/user_config.py b/flow360/user_config.py index 134a06f51..28c61f86f 100644 --- a/flow360/user_config.py +++ b/flow360/user_config.py @@ -17,6 +17,35 @@ CONFIG_FILE_MODE = 0o600 +def _merge_overwrite(old: dict, new: dict): + """Deep-merge dictionaries while overwriting conflicts from `new`.""" + + for key, value in new.items(): + if key in old and isinstance(old[key], dict) and isinstance(value, dict): + _merge_overwrite(old[key], value) + else: + old[key] = value + return old + + +def _normalize_storage_environment_name(environment: Optional[str]) -> Optional[str]: + """Normalize environment names used for config storage.""" + + if environment is None: + return None + + normalized = environment.strip() + if not normalized: + return None + + lowered = normalized.lower() + if lowered == prod.name: + return None + if lowered in ("dev", "uat"): + return lowered + return normalized + + def _ensure_permissions(path: str, mode: int): """Best-effort permission hardening for local config paths.""" try: @@ -55,20 +84,34 @@ def store_apikey( ): """Store an API key using the same config layout consumed by UserConfig.""" config = read_user_config() + environment_name = _normalize_storage_environment_name(environment_name) if environment_name in (None, "", prod.name): entry = {profile: {"apikey": apikey}} else: entry = {profile: {environment_name: {"apikey": apikey}}} - # Avoid importing CLI modules at import time because the wider package has lazy-import paths. - from flow360.cli import dict_utils # pylint: disable=import-outside-toplevel - - dict_utils.merge_overwrite(config, entry) + _merge_overwrite(config, entry) write_user_config(config) return config +def configure_apikey( + apikey: str, + environment: Optional[str] = None, + profile: str = DEFAULT_PROFILE, +) -> None: + """SDK-facing helper for storing an API key without going through the CLI app.""" + + store_apikey( + apikey, + profile=profile, + environment_name=environment, + ) + reload_user_config() + log.info("Configuration successful.") + + def delete_apikey(profile: str = DEFAULT_PROFILE, environment_name: Optional[str] = None): """Delete a stored API key for the selected profile/environment if present.""" config = read_user_config() @@ -162,7 +205,7 @@ def apikey(self, env): # If other environment is used, check if the key exists key = key.get(env.name, None) if key is None: - log.warning(f"Cannot find api key associated with environment '{env.name}'.") + log.debug(f"No api key configured for environment '{env.name}'.") return None if key is None else key.get("apikey", "") def suppress_submit_warning(self): @@ -209,4 +252,21 @@ def enable_validation(self): self._do_validation = True +def reload_user_config(): + """Reload the shared user-config object in place when possible.""" + # pylint: disable=protected-access + + global UserConfig # pylint: disable=global-statement + + if isinstance(UserConfig, BasicUserConfig): # pylint: disable=used-before-assignment + do_validation = UserConfig.do_validation + suppress_submit_warning = UserConfig._suppress_submit_warning + BasicUserConfig.__init__(UserConfig) + UserConfig._do_validation = do_validation + UserConfig._suppress_submit_warning = suppress_submit_warning + else: + UserConfig = BasicUserConfig() + return UserConfig + + UserConfig = BasicUserConfig() diff --git a/tests/cli/test_cli_assets.py b/tests/cli/test_cli_assets.py new file mode 100644 index 000000000..377c702ab --- /dev/null +++ b/tests/cli/test_cli_assets.py @@ -0,0 +1,478 @@ +import json + +from click.testing import CliRunner + +from flow360.cli import flow360 + + +def test_flow360_help_shows_asset_groups(): + runner = CliRunner() + + result = runner.invoke(flow360, ["--help"]) + + assert result.exit_code == 0 + assert "geometry" in result.output + assert "surface-mesh" in result.output + assert "volume-mesh" in result.output + assert "case" in result.output + assert "folder" in result.output + + +def test_case_group_help_shows_info_and_simulation(): + runner = CliRunner() + + result = runner.invoke(flow360, ["case", "--help"]) + + assert result.exit_code == 0 + assert "info" in result.output + assert "state" in result.output + assert "summary" in result.output + assert "simulation" in result.output + assert "get" not in result.output + + +def test_geometry_group_help_shows_info_and_simulation(): + runner = CliRunner() + + result = runner.invoke(flow360, ["geometry", "--help"]) + + assert result.exit_code == 0 + assert "info" in result.output + assert "state" in result.output + assert "summary" in result.output + assert "simulation" in result.output + assert "get" not in result.output + + +def test_surface_mesh_group_help_shows_info_and_simulation(): + runner = CliRunner() + + result = runner.invoke(flow360, ["surface-mesh", "--help"]) + + assert result.exit_code == 0 + assert "info" in result.output + assert "state" in result.output + assert "summary" in result.output + assert "simulation" in result.output + assert "get" not in result.output + + +def test_volume_mesh_group_help_shows_info_and_simulation(): + runner = CliRunner() + + result = runner.invoke(flow360, ["volume-mesh", "--help"]) + + assert result.exit_code == 0 + assert "info" in result.output + assert "state" in result.output + assert "summary" in result.output + assert "simulation" in result.output + assert "get" not in result.output + + +def test_geometry_info_outputs_metadata(monkeypatch): + from flow360.cli import assets as assets_cli + + runner = CliRunner() + info = { + "id": "geo-123", + "name": "Wing", + "projectId": "prj-123", + "parentId": None, + "solverVersion": "release-25.2", + "status": "processed", + "tags": ["demo"], + "type": "Geometry", + "createdAt": "2025-01-01T00:00:00Z", + "updatedAt": "2025-01-01T01:00:00Z", + } + monkeypatch.setattr(assets_cli, "_get_asset_info", lambda webapi_cls, asset_id: info) + + result = runner.invoke(flow360, ["geometry", "info", "geo-123"]) + + assert result.exit_code == 0 + payload = json.loads(result.output) + assert payload["id"] == "geo-123" + assert payload["project_id"] == "prj-123" + assert payload["type"] == "Geometry" + + +def test_geometry_get_alias_outputs_metadata(monkeypatch): + from flow360.cli import assets as assets_cli + + runner = CliRunner() + info = { + "id": "geo-123", + "name": "Wing", + "projectId": "prj-123", + "parentId": None, + "solverVersion": "release-25.2", + "status": "processed", + "tags": ["demo"], + "type": "Geometry", + "createdAt": "2025-01-01T00:00:00Z", + "updatedAt": "2025-01-01T01:00:00Z", + } + monkeypatch.setattr(assets_cli, "_get_asset_info", lambda webapi_cls, asset_id: info) + + result = runner.invoke(flow360, ["geometry", "get", "geo-123"]) + + assert result.exit_code == 0 + payload = json.loads(result.output) + assert payload["id"] == "geo-123" + assert payload["project_id"] == "prj-123" + assert payload["type"] == "Geometry" + + +def test_geometry_state_outputs_lifecycle_projection(monkeypatch): + from flow360.cli import assets as assets_cli + + runner = CliRunner() + monkeypatch.setattr( + assets_cli, + "get_resource_state_for_type", + lambda resource_type, resource_id: { + "id": resource_id, + "type": "Geometry", + "status": "processed", + "is_terminal": True, + "is_success": True, + "updated_at": "2025-01-01T01:00:00Z", + }, + ) + + result = runner.invoke(flow360, ["geometry", "state", "geo-123"]) + + assert result.exit_code == 0 + assert json.loads(result.output) == { + "id": "geo-123", + "type": "Geometry", + "status": "processed", + "is_terminal": True, + "is_success": True, + "updated_at": "2025-01-01T01:00:00Z", + } + + +def test_geometry_simulation_get_outputs_json(monkeypatch): + from flow360.cli import assets as assets_cli + + runner = CliRunner() + monkeypatch.setattr( + assets_cli, + "_get_asset_simulation_json", + lambda webapi_cls, asset_id: {"version": "24.11.0", "unit_system": {"name": "SI"}}, + ) + + result = runner.invoke(flow360, ["geometry", "simulation", "get", "geo-123"]) + + assert result.exit_code == 0 + payload = json.loads(result.output) + assert payload["simulation"]["version"] == "24.11.0" + assert payload["simulation"]["unit_system"]["name"] == "SI" + + +def test_surface_mesh_info_outputs_metadata(monkeypatch): + from flow360.cli import assets as assets_cli + + runner = CliRunner() + info = { + "id": "sm-123", + "name": "Surface Mesh", + "projectId": "prj-123", + "parentId": "geo-123", + "solverVersion": "release-25.2", + "status": "processed", + "tags": [], + "type": "SurfaceMesh", + "createdAt": "2025-01-01T00:00:00Z", + "updatedAt": "2025-01-01T01:00:00Z", + } + monkeypatch.setattr(assets_cli, "_get_asset_info", lambda webapi_cls, asset_id: info) + + result = runner.invoke(flow360, ["surface-mesh", "info", "sm-123"]) + + assert result.exit_code == 0 + payload = json.loads(result.output) + assert payload["id"] == "sm-123" + assert payload["parent_id"] == "geo-123" + assert payload["type"] == "SurfaceMesh" + + +def test_surface_mesh_get_alias_outputs_metadata(monkeypatch): + from flow360.cli import assets as assets_cli + + runner = CliRunner() + info = { + "id": "sm-123", + "name": "Surface Mesh", + "projectId": "prj-123", + "parentId": "geo-123", + "solverVersion": "release-25.2", + "status": "processed", + "tags": [], + "type": "SurfaceMesh", + "createdAt": "2025-01-01T00:00:00Z", + "updatedAt": "2025-01-01T01:00:00Z", + } + monkeypatch.setattr(assets_cli, "_get_asset_info", lambda webapi_cls, asset_id: info) + + result = runner.invoke(flow360, ["surface-mesh", "get", "sm-123"]) + + assert result.exit_code == 0 + payload = json.loads(result.output) + assert payload["id"] == "sm-123" + assert payload["parent_id"] == "geo-123" + assert payload["type"] == "SurfaceMesh" + + +def test_surface_mesh_state_outputs_lifecycle_projection(monkeypatch): + from flow360.cli import assets as assets_cli + + runner = CliRunner() + monkeypatch.setattr( + assets_cli, + "get_resource_state_for_type", + lambda resource_type, resource_id: { + "id": resource_id, + "type": "SurfaceMesh", + "status": "queued", + "is_terminal": False, + "is_success": False, + "updated_at": "2025-01-01T01:00:00Z", + }, + ) + + result = runner.invoke(flow360, ["surface-mesh", "state", "sm-123"]) + + assert result.exit_code == 0 + assert json.loads(result.output) == { + "id": "sm-123", + "type": "SurfaceMesh", + "status": "queued", + "is_terminal": False, + "is_success": False, + "updated_at": "2025-01-01T01:00:00Z", + } + + +def test_surface_mesh_simulation_get_outputs_json(monkeypatch): + from flow360.cli import assets as assets_cli + + runner = CliRunner() + monkeypatch.setattr( + assets_cli, + "_get_asset_simulation_json", + lambda webapi_cls, asset_id: {"version": "24.11.0", "unit_system": {"name": "SI"}}, + ) + + result = runner.invoke(flow360, ["surface-mesh", "simulation", "get", "sm-123"]) + + assert result.exit_code == 0 + payload = json.loads(result.output) + assert payload["simulation"]["version"] == "24.11.0" + assert payload["simulation"]["unit_system"]["name"] == "SI" + + +def test_volume_mesh_info_outputs_metadata(monkeypatch): + from flow360.cli import assets as assets_cli + + runner = CliRunner() + info = { + "id": "vm-123", + "name": "Volume Mesh", + "projectId": "prj-123", + "parentId": "sm-123", + "solverVersion": "release-25.2", + "status": "completed", + "tags": ["demo"], + "type": "VolumeMesh", + "createdAt": "2025-01-01T00:00:00Z", + "updatedAt": "2025-01-01T01:00:00Z", + } + monkeypatch.setattr(assets_cli, "_get_asset_info", lambda webapi_cls, asset_id: info) + + result = runner.invoke(flow360, ["volume-mesh", "info", "vm-123"]) + + assert result.exit_code == 0 + payload = json.loads(result.output) + assert payload["id"] == "vm-123" + assert payload["type"] == "VolumeMesh" + + +def test_volume_mesh_get_alias_outputs_metadata(monkeypatch): + from flow360.cli import assets as assets_cli + + runner = CliRunner() + info = { + "id": "vm-123", + "name": "Volume Mesh", + "projectId": "prj-123", + "parentId": "sm-123", + "solverVersion": "release-25.2", + "status": "completed", + "tags": ["demo"], + "type": "VolumeMesh", + "createdAt": "2025-01-01T00:00:00Z", + "updatedAt": "2025-01-01T01:00:00Z", + } + monkeypatch.setattr(assets_cli, "_get_asset_info", lambda webapi_cls, asset_id: info) + + result = runner.invoke(flow360, ["volume-mesh", "get", "vm-123"]) + + assert result.exit_code == 0 + payload = json.loads(result.output) + assert payload["id"] == "vm-123" + assert payload["type"] == "VolumeMesh" + + +def test_volume_mesh_state_outputs_lifecycle_projection(monkeypatch): + from flow360.cli import assets as assets_cli + + runner = CliRunner() + monkeypatch.setattr( + assets_cli, + "get_resource_state_for_type", + lambda resource_type, resource_id: { + "id": resource_id, + "type": "VolumeMesh", + "status": "failed", + "is_terminal": True, + "is_success": False, + "updated_at": "2025-01-01T01:00:00Z", + }, + ) + + result = runner.invoke(flow360, ["volume-mesh", "state", "vm-123"]) + + assert result.exit_code == 0 + assert json.loads(result.output) == { + "id": "vm-123", + "type": "VolumeMesh", + "status": "failed", + "is_terminal": True, + "is_success": False, + "updated_at": "2025-01-01T01:00:00Z", + } + + +def test_volume_mesh_simulation_get_outputs_json(monkeypatch): + from flow360.cli import assets as assets_cli + + runner = CliRunner() + monkeypatch.setattr( + assets_cli, + "_get_asset_simulation_json", + lambda webapi_cls, asset_id: {"version": "24.11.0", "unit_system": {"name": "SI"}}, + ) + + result = runner.invoke(flow360, ["volume-mesh", "simulation", "get", "vm-123"]) + + assert result.exit_code == 0 + payload = json.loads(result.output) + assert payload["simulation"]["version"] == "24.11.0" + assert payload["simulation"]["unit_system"]["name"] == "SI" + + +def test_case_info_outputs_metadata(monkeypatch): + from flow360.cli import assets as assets_cli + + runner = CliRunner() + info = { + "id": "case-123", + "name": "Case 1", + "projectId": "prj-123", + "caseMeshId": "vm-123", + "solverVersion": "release-25.2", + "status": "completed", + "tags": ["demo"], + "type": "Case", + "createdAt": "2025-01-01T00:00:00Z", + "updatedAt": "2025-01-01T01:00:00Z", + } + monkeypatch.setattr(assets_cli, "_get_asset_info", lambda webapi_cls, asset_id: info) + + result = runner.invoke(flow360, ["case", "info", "case-123"]) + + assert result.exit_code == 0 + payload = json.loads(result.output) + assert payload["id"] == "case-123" + assert payload["mesh_id"] == "vm-123" + assert payload["type"] == "Case" + + +def test_case_get_alias_outputs_metadata(monkeypatch): + from flow360.cli import assets as assets_cli + + runner = CliRunner() + info = { + "id": "case-123", + "name": "Case 1", + "projectId": "prj-123", + "caseMeshId": "vm-123", + "solverVersion": "release-25.2", + "status": "completed", + "tags": ["demo"], + "type": "Case", + "createdAt": "2025-01-01T00:00:00Z", + "updatedAt": "2025-01-01T01:00:00Z", + } + monkeypatch.setattr(assets_cli, "_get_asset_info", lambda webapi_cls, asset_id: info) + + result = runner.invoke(flow360, ["case", "get", "case-123"]) + + assert result.exit_code == 0 + payload = json.loads(result.output) + assert payload["id"] == "case-123" + assert payload["mesh_id"] == "vm-123" + assert payload["type"] == "Case" + + +def test_case_state_outputs_lifecycle_projection(monkeypatch): + from flow360.cli import assets as assets_cli + + runner = CliRunner() + monkeypatch.setattr( + assets_cli, + "get_resource_state_for_type", + lambda resource_type, resource_id: { + "id": resource_id, + "type": "Case", + "status": "completed", + "is_terminal": True, + "is_success": True, + "mesh_id": "vm-123", + "updated_at": "2025-01-01T01:00:00Z", + }, + ) + + result = runner.invoke(flow360, ["case", "state", "case-123"]) + + assert result.exit_code == 0 + assert json.loads(result.output) == { + "id": "case-123", + "type": "Case", + "status": "completed", + "is_terminal": True, + "is_success": True, + "mesh_id": "vm-123", + "updated_at": "2025-01-01T01:00:00Z", + } + + +def test_case_simulation_get_outputs_json(monkeypatch): + from flow360.cli import assets as assets_cli + + runner = CliRunner() + monkeypatch.setattr( + assets_cli, + "_get_asset_simulation_json", + lambda webapi_cls, asset_id: {"version": "24.11.0", "unit_system": {"name": "SI"}}, + ) + + result = runner.invoke(flow360, ["case", "simulation", "get", "case-123"]) + + assert result.exit_code == 0 + payload = json.loads(result.output) + assert payload["simulation"]["version"] == "24.11.0" + assert payload["simulation"]["unit_system"]["name"] == "SI" diff --git a/tests/cli/test_cli_auth_guidance.py b/tests/cli/test_cli_auth_guidance.py new file mode 100644 index 000000000..a4244b83e --- /dev/null +++ b/tests/cli/test_cli_auth_guidance.py @@ -0,0 +1,29 @@ +from click.testing import CliRunner + +from flow360.cli import flow360 +from flow360.exceptions import Flow360AuthorisationError + + +def test_project_list_missing_apikey_shows_clean_auth_guidance(monkeypatch): + def raise_auth_error(*args, **kwargs): + raise Flow360AuthorisationError( + "\n".join( + [ + "No API key configured for env=dev, profile=default.", + "Authenticate with:", + " flow360 login --dev", + "For headless or manual setup:", + " flow360 configure --dev --apikey ", + ] + ) + ) + + project_group = flow360.get_command(None, "project") + monkeypatch.setattr(project_group.commands["list"], "callback", raise_auth_error) + + result = CliRunner().invoke(flow360, ["--dev", "project", "list"]) + + assert result.exit_code == 1 + assert "Traceback" not in result.output + assert "flow360 login --dev" in result.output + assert "flow360 configure --dev --apikey " in result.output diff --git a/tests/cli/test_cli_draft.py b/tests/cli/test_cli_draft.py new file mode 100644 index 000000000..ba3cbdf6e --- /dev/null +++ b/tests/cli/test_cli_draft.py @@ -0,0 +1,173 @@ +import json + +from click.testing import CliRunner + +from flow360.cli import flow360 + + +def test_draft_group_help_shows_read_commands(): + runner = CliRunner() + + result = runner.invoke(flow360, ["draft", "--help"]) + + assert result.exit_code == 0 + assert "list" in result.output + assert "info" in result.output + assert "state" in result.output + assert "create" not in result.output + assert "run" not in result.output + assert "get" not in result.output + assert "simulation" in result.output + + +def test_draft_list_outputs_records(monkeypatch): + from flow360.cli import draft as draft_cli + + runner = CliRunner() + monkeypatch.setattr( + draft_cli, + "_list_drafts", + lambda project_id: [ + { + "id": "dft-123", + "name": "Draft 1", + "projectId": project_id, + "solverVersion": "release-25.2", + "type": "Draft", + } + ], + ) + + result = runner.invoke(flow360, ["draft", "list", "--project-id", "prj-123"]) + + assert result.exit_code == 0 + payload = json.loads(result.output) + assert payload["records"] == [ + { + "fork_case": None, + "id": "dft-123", + "name": "Draft 1", + "project_id": "prj-123", + "solver_version": "release-25.2", + "source_item_id": None, + "source_item_type": None, + "type": "Draft", + } + ] + + +def test_draft_ls_alias_outputs_records(monkeypatch): + from flow360.cli import draft as draft_cli + + runner = CliRunner() + monkeypatch.setattr( + draft_cli, + "_list_drafts", + lambda project_id: [ + { + "id": "dft-123", + "name": "Draft 1", + "projectId": project_id, + "solverVersion": "release-25.2", + "type": "Draft", + } + ], + ) + + result = runner.invoke(flow360, ["draft", "ls", "--project-id", "prj-123"]) + + assert result.exit_code == 0 + payload = json.loads(result.output) + assert payload["records"][0]["id"] == "dft-123" + + +def test_draft_info_outputs_metadata(monkeypatch): + from flow360.cli import draft as draft_cli + + runner = CliRunner() + info = { + "id": "dft-123", + "name": "Draft 1", + "projectId": "prj-123", + "solverVersion": "release-25.2", + "type": "Draft", + } + monkeypatch.setattr(draft_cli, "_get_draft_info", lambda draft_id: info) + + result = runner.invoke(flow360, ["draft", "info", "dft-123"]) + + assert result.exit_code == 0 + payload = json.loads(result.output) + assert payload["id"] == "dft-123" + assert payload["project_id"] == "prj-123" + assert payload["type"] == "Draft" + + +def test_draft_get_alias_outputs_metadata(monkeypatch): + from flow360.cli import draft as draft_cli + + runner = CliRunner() + info = { + "id": "dft-123", + "name": "Draft 1", + "projectId": "prj-123", + "solverVersion": "release-25.2", + "type": "Draft", + } + monkeypatch.setattr(draft_cli, "_get_draft_info", lambda draft_id: info) + + result = runner.invoke(flow360, ["draft", "get", "dft-123"]) + + assert result.exit_code == 0 + payload = json.loads(result.output) + assert payload["id"] == "dft-123" + assert payload["project_id"] == "prj-123" + assert payload["type"] == "Draft" + + +def test_draft_state_outputs_lifecycle_projection(monkeypatch): + from flow360.cli import draft as draft_cli + + runner = CliRunner() + monkeypatch.setattr( + draft_cli, + "get_resource_state_for_type", + lambda resource_type, resource_id: { + "id": resource_id, + "type": "Draft", + "status": "queued", + "is_terminal": False, + "is_success": False, + "updated_at": "2025-01-01T01:00:00Z", + }, + ) + + result = runner.invoke(flow360, ["draft", "state", "dft-123"]) + + assert result.exit_code == 0 + assert json.loads(result.output) == { + "id": "dft-123", + "type": "Draft", + "status": "queued", + "is_terminal": False, + "is_success": False, + "updated_at": "2025-01-01T01:00:00Z", + } + + +def test_draft_simulation_get_outputs_json(monkeypatch): + from flow360.cli import draft as draft_cli + + runner = CliRunner() + monkeypatch.setattr( + draft_cli, + "_get_draft_simulation_json", + lambda draft_id: {"version": "24.11.0", "unit_system": {"name": "SI"}}, + ) + + result = runner.invoke(flow360, ["draft", "simulation", "get", "dft-123"]) + + assert result.exit_code == 0 + payload = json.loads(result.output) + assert payload["simulation"]["version"] == "24.11.0" + assert payload["simulation"]["unit_system"]["name"] == "SI" diff --git a/tests/cli/test_cli_open.py b/tests/cli/test_cli_open.py new file mode 100644 index 000000000..55268ce77 --- /dev/null +++ b/tests/cli/test_cli_open.py @@ -0,0 +1,170 @@ +import json + +import pytest +from click.testing import CliRunner + +from flow360.cli import flow360 +from flow360.cli.resource_refs import ResourceRefError + + +def test_root_help_shows_open(): + runner = CliRunner() + + result = runner.invoke(flow360, ["--help"]) + + assert result.exit_code == 0 + assert "open" in result.output + + +def test_open_help_shows_usage(): + runner = CliRunner() + + result = runner.invoke(flow360, ["open", "--help"]) + + assert result.exit_code == 0 + assert "Open a Flow360 resource in the browser." in result.output + + +def test_open_project_prints_url_and_opens_browser(monkeypatch): + from flow360.cli import open_resource as open_cli + + runner = CliRunner() + opened_urls = [] + monkeypatch.setattr( + open_cli, + "open_browser_url", + lambda url: opened_urls.append(url) or True, + ) + + result = runner.invoke(flow360, ["open", "prj-123"]) + + assert result.exit_code == 0 + assert json.loads(result.output) == { + "id": "prj-123", + "opened": True, + "type": "Project", + "url": "https://flow360.simulation.cloud/workbench/prj-123", + } + assert opened_urls == ["https://flow360.simulation.cloud/workbench/prj-123"] + + +def test_open_case_prints_url_when_browser_does_not_open(monkeypatch): + from flow360.cli import browser_links + from flow360.cli import open_resource as open_cli + + runner = CliRunner() + monkeypatch.setattr(open_cli, "open_browser_url", lambda url: False) + monkeypatch.setattr( + browser_links, + "_get_project_scoped_resource_info", + lambda resource_type, resource_id: {"projectId": "prj-123"}, + ) + + result = runner.invoke(flow360, ["open", "case-123"]) + + assert result.exit_code == 0 + assert json.loads(result.output) == { + "id": "case-123", + "opened": False, + "type": "Case", + "url": "https://flow360.simulation.cloud/workbench/prj-123?id=case-123&type=Case", + } + + +def test_open_respects_root_environment_selection(monkeypatch): + from flow360.cli import browser_links + from flow360.cli import open_resource as open_cli + + runner = CliRunner() + monkeypatch.setattr(open_cli, "open_browser_url", lambda url: True) + monkeypatch.setattr( + browser_links, + "_get_project_scoped_resource_info", + lambda resource_type, resource_id: {"projectId": "prj-123"}, + ) + + result = runner.invoke(flow360, ["--dev", "open", "dft-123"]) + + assert result.exit_code == 0 + assert ( + json.loads(result.output)["url"] + == "https://flow360.dev-simulation.cloud/workbench/prj-123?id=dft-123&type=Draft" + ) + + +def test_open_folder_infers_workspace_route(monkeypatch): + from flow360.cli import browser_links + from flow360.cli import open_resource as open_cli + + runner = CliRunner() + monkeypatch.setattr(open_cli, "open_browser_url", lambda url: True) + monkeypatch.setattr( + browser_links, "_resolve_folder_workspace_id", lambda resource_id: "private-abc" + ) + + result = runner.invoke(flow360, ["open", "folder-123"]) + + assert result.exit_code == 0 + assert json.loads(result.output) == { + "id": "folder-123", + "opened": True, + "type": "Folder", + "url": "https://flow360.simulation.cloud/workspaces?workspaceId=private-abc&folderId=folder-123&activeTabIndex=0", + } + + +def test_open_shared_root_folder_uses_inferred_workspace_route(monkeypatch): + from flow360.cli import browser_links + from flow360.cli import open_resource as open_cli + + runner = CliRunner() + monkeypatch.setattr(open_cli, "open_browser_url", lambda url: False) + monkeypatch.setattr( + browser_links, + "_resolve_folder_workspace_id", + lambda resource_id: "shared-abc", + ) + + result = runner.invoke(flow360, ["open", "ROOT.FLOW360.123"]) + + assert result.exit_code == 0 + assert json.loads(result.output) == { + "id": "ROOT.FLOW360.123", + "opened": False, + "type": "Folder", + "url": "https://flow360.simulation.cloud/workspaces?workspaceId=shared-abc&folderId=ROOT.FLOW360.123&activeTabIndex=0", + } + + +def test_resolve_folder_workspace_id_uses_root_folder_workspace_mapping(monkeypatch): + from flow360.cli import browser_links + + monkeypatch.setattr( + browser_links, "_get_root_folder_id", lambda resource_id: "ROOT.FLOW360.123" + ) + monkeypatch.setattr( + browser_links, + "_get_workspace_id_for_root_folder", + lambda root_folder_id: "shared-abc" if root_folder_id == "ROOT.FLOW360.123" else None, + ) + + assert browser_links._resolve_folder_workspace_id("folder-123") == "shared-abc" + + +def test_resolve_folder_workspace_id_errors_when_workspace_is_missing(monkeypatch): + from flow360.cli import browser_links + + monkeypatch.setattr( + browser_links, "_get_root_folder_id", lambda resource_id: "ROOT.FLOW360.123" + ) + monkeypatch.setattr( + browser_links, "_get_workspace_id_for_root_folder", lambda root_folder_id: None + ) + + with pytest.raises(ResourceRefError) as error: + browser_links._resolve_folder_workspace_id("folder-123") + + assert str(error.value) == ( + "Could not infer a workspace for folder folder-123. " + "No workspace matched rootFolderId ROOT.FLOW360.123." + ) diff --git a/tests/cli/test_cli_project.py b/tests/cli/test_cli_project.py index aa19784ce..5de6ca07f 100644 --- a/tests/cli/test_cli_project.py +++ b/tests/cli/test_cli_project.py @@ -1,3 +1,4 @@ +import builtins import json from types import SimpleNamespace @@ -289,22 +290,54 @@ def test_project_tree_outputs_nested_tree(monkeypatch): from flow360.cli import project as project_cli runner = CliRunner() - leaf = SimpleNamespace( - asset_id="case-123", - asset_name="Case 1", - asset_type="Case", - children=[], - ) - root = SimpleNamespace( - asset_id="geo-123", - asset_name="Wing", - asset_type="Geometry", - children=[leaf], - ) + original_import = builtins.__import__ + + def guard_project_sdk_import(name, *args, **kwargs): + if name == "flow360.component.project": + raise AssertionError("project tree must not import the full Project SDK") + return original_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", guard_project_sdk_import) monkeypatch.setattr( project_cli, - "_get_project_tree", - lambda project_id: SimpleNamespace(root=root), + "_get_project_tree_records", + lambda project_id: [ + { + "id": "geo-123", + "name": "Wing", + "type": "Geometry", + "parentId": None, + "parentCaseId": None, + }, + { + "id": "sm-123", + "name": "Wing surface mesh", + "type": "SurfaceMesh", + "parentId": "geo-123", + "parentCaseId": None, + }, + { + "id": "vm-123", + "name": "Wing volume mesh", + "type": "VolumeMesh", + "parentId": "sm-123", + "parentCaseId": None, + }, + { + "id": "case-123", + "name": "Case 1", + "type": "Case", + "parentId": "vm-123", + "parentCaseId": None, + }, + { + "id": "case-456", + "name": "Case 2", + "type": "Case", + "parentId": "vm-123", + "parentCaseId": "case-123", + }, + ], ) result = runner.invoke(flow360, ["project", "tree", "prj-123"]) @@ -312,7 +345,13 @@ def test_project_tree_outputs_nested_tree(monkeypatch): assert result.exit_code == 0 payload = json.loads(result.output) assert payload["root"]["id"] == "geo-123" - assert payload["root"]["children"][0]["id"] == "case-123" + surface_mesh = payload["root"]["children"][0] + volume_mesh = surface_mesh["children"][0] + case = volume_mesh["children"][0] + assert surface_mesh["id"] == "sm-123" + assert volume_mesh["id"] == "vm-123" + assert case["id"] == "case-123" + assert case["children"][0]["id"] == "case-456" def test_project_items_outputs_flat_items(monkeypatch): diff --git a/tests/cli/test_cli_resource_refs.py b/tests/cli/test_cli_resource_refs.py new file mode 100644 index 000000000..51a372931 --- /dev/null +++ b/tests/cli/test_cli_resource_refs.py @@ -0,0 +1,50 @@ +import pytest + +from flow360.cli.resource_refs import ( + ResourceRefError, + parse_resource_ref, + require_resource_type, +) + + +@pytest.mark.parametrize( + ("resource_id", "resource_type"), + [ + ("prj-123", "Project"), + ("geo-123", "Geometry"), + ("sm-123", "SurfaceMesh"), + ("vm-123", "VolumeMesh"), + ("case-123", "Case"), + ("dft-123", "Draft"), + ("folder-123", "Folder"), + ("ROOT.FLOW360", "Folder"), + ("ROOT.FLOW360.123", "Folder"), + ], +) +def test_parse_resource_ref_detects_type_from_prefix(resource_id, resource_type): + resource_ref = parse_resource_ref(resource_id) + + assert resource_ref.id == resource_id + assert resource_ref.resource_type == resource_type + + +def test_parse_resource_ref_trims_outer_whitespace(): + resource_ref = parse_resource_ref(" dft-123 ") + + assert resource_ref.id == "dft-123" + assert resource_ref.resource_type == "Draft" + + +def test_parse_resource_ref_rejects_unknown_prefix(): + with pytest.raises(ResourceRefError, match="Unsupported resource ID prefix"): + parse_resource_ref("foo-123") + + +def test_parse_resource_ref_rejects_malformed_id(): + with pytest.raises(ResourceRefError, match="expected '-...' shape"): + parse_resource_ref("foo") + + +def test_require_resource_type_rejects_wrong_kind(): + with pytest.raises(ResourceRefError, match="Expected a Draft ID"): + require_resource_type("prj-123", "Draft") diff --git a/tests/cli/test_cli_simulation_summary.py b/tests/cli/test_cli_simulation_summary.py new file mode 100644 index 000000000..ea1e30022 --- /dev/null +++ b/tests/cli/test_cli_simulation_summary.py @@ -0,0 +1,166 @@ +import json + +from click.testing import CliRunner + +from flow360.cli import flow360 + + +def _surface_entity(name): + return { + "name": name, + "private_attribute_entity_type_name": "Surface", + "private_attribute_sub_components": [], + } + + +def _minimal_simulation(models): + return { + "version": "25.10.3b1", + "unit_system": {"name": "SI"}, + "operating_condition": { + "type_name": "AerospaceCondition", + "alpha": {"value": 5.0, "units": "degree"}, + "beta": {"value": 0.0, "units": "degree"}, + "velocity_magnitude": {"value": 50.0, "units": "m/s"}, + "thermal_state": { + "type_name": "ThermalState", + "temperature": {"value": 288.15, "units": "K"}, + "density": {"value": 1.225, "units": "kg/m**3"}, + }, + }, + "models": models, + "time_stepping": {"type_name": "Steady", "max_steps": 1000}, + } + + +def test_simulation_summary_extracts_solver_and_operating_condition(): + from flow360.cli.simulation_summary import summarize_simulation + + summary = summarize_simulation( + _minimal_simulation( + [ + { + "type": "Fluid", + "navier_stokes_solver": {"type_name": "Compressible"}, + "turbulence_model_solver": {"type_name": "SpalartAllmaras"}, + } + ] + ) + ) + + assert summary["operating_condition"]["alpha"] == {"units": "degree", "value": 5.0} + assert "beta" not in summary["operating_condition"] + assert summary["time_stepping"]["type_name"] == "Steady" + assert summary["models"] == [{"type": "Fluid"}] + + +def test_simulation_summary_groups_identical_surface_models_by_settings(): + from flow360.cli.simulation_summary import summarize_simulation + + summary = summarize_simulation( + _minimal_simulation( + [ + { + "type": "Wall", + "name": "Wall", + "use_wall_function": False, + "entities": { + "stored_entities": [ + _surface_entity("wing"), + _surface_entity("fuselage"), + ] + }, + }, + { + "type": "Wall", + "name": "Wall", + "use_wall_function": False, + "entities": {"stored_entities": [_surface_entity("tail")]}, + }, + ] + ) + ) + + assert summary["models"] == [ + { + "_count": 2, + "_names": ["Wall"], + "entities": {"_count": 3, "_sample": ["wing", "fuselage", "tail"]}, + "type": "Wall", + } + ] + + +def test_simulation_summary_ignores_invalid_private_cache(): + from flow360.cli.simulation_summary import summarize_simulation + + simulation = _minimal_simulation([]) + simulation["private_attribute_asset_cache"] = { + "variable_context": [{"name": "bad", "value": {"expression": "rho + missing_symbol"}}] + } + + summary = summarize_simulation(simulation) + + assert "private_attribute_asset_cache" not in summary + assert "models" not in summary + + +def test_simulation_summary_prunes_absent_zero_defaults(): + from flow360.cli.simulation_summary import summarize_simulation + + simulation = _minimal_simulation([]) + simulation["meshing"] = { + "type_name": "MeshingParams", + "gap_treatment_strength": 0, + } + + summary = summarize_simulation(simulation) + + assert summary["meshing"] == {"type_name": "MeshingParams"} + + +def test_case_summary_outputs_compact_json(monkeypatch): + from flow360.cli import assets as assets_cli + + runner = CliRunner() + monkeypatch.setattr( + assets_cli, + "_get_asset_simulation_json", + lambda webapi_cls, asset_id: _minimal_simulation( + [ + { + "type": "Fluid", + "navier_stokes_solver": {"type_name": "Compressible"}, + "turbulence_model_solver": {"type_name": "SpalartAllmaras"}, + } + ] + ), + ) + + result = runner.invoke(flow360, ["case", "summary", "case-123"]) + + assert result.exit_code == 0 + payload = json.loads(result.output) + assert payload["id"] == "case-123" + assert payload["summary"]["models"] == [{"type": "Fluid"}] + + +def test_mesh_summary_commands_are_available(monkeypatch): + from flow360.cli import assets as assets_cli + + runner = CliRunner() + monkeypatch.setattr( + assets_cli, + "_get_asset_simulation_json", + lambda webapi_cls, asset_id: _minimal_simulation([]), + ) + + for command, resource_id in [ + ("geometry", "geo-123"), + ("surface-mesh", "sm-123"), + ("volume-mesh", "vm-123"), + ]: + result = runner.invoke(flow360, [command, "summary", resource_id]) + + assert result.exit_code == 0 + assert json.loads(result.output)["id"] == resource_id diff --git a/tests/cli/test_cli_wait.py b/tests/cli/test_cli_wait.py new file mode 100644 index 000000000..c9182f976 --- /dev/null +++ b/tests/cli/test_cli_wait.py @@ -0,0 +1,109 @@ +import json + +from click.testing import CliRunner + +from flow360.cli import flow360 + + +def test_root_help_shows_wait(): + runner = CliRunner() + + result = runner.invoke(flow360, ["--help"]) + + assert result.exit_code == 0 + assert "wait" in result.output + + +def test_wait_help_shows_polling_options(): + runner = CliRunner() + + result = runner.invoke(flow360, ["wait", "--help"]) + + assert result.exit_code == 0 + assert "--timeout" in result.output + assert "--poll-interval" in result.output + + +def test_wait_outputs_terminal_success_state(monkeypatch): + from flow360.cli import wait as wait_cli + + runner = CliRunner() + monkeypatch.setattr( + wait_cli, + "_wait_for_resource_state", + lambda ref_id, timeout, poll_interval: { + "id": ref_id, + "type": "VolumeMesh", + "status": "completed", + "is_terminal": True, + "is_success": True, + "updated_at": "2025-01-01T01:00:00Z", + }, + ) + + result = runner.invoke(flow360, ["wait", "vm-123"]) + + assert result.exit_code == 0 + assert json.loads(result.output) == { + "id": "vm-123", + "type": "VolumeMesh", + "status": "completed", + "is_terminal": True, + "is_success": True, + "updated_at": "2025-01-01T01:00:00Z", + } + + +def test_wait_failed_terminal_state_exits_nonzero(monkeypatch): + from flow360.cli import wait as wait_cli + + runner = CliRunner() + monkeypatch.setattr( + wait_cli, + "_wait_for_resource_state", + lambda ref_id, timeout, poll_interval: { + "id": ref_id, + "type": "Case", + "status": "failed", + "is_terminal": True, + "is_success": False, + "updated_at": "2025-01-01T01:00:00Z", + "mesh_id": "vm-123", + }, + ) + + result = runner.invoke(flow360, ["wait", "case-123"]) + + assert result.exit_code == 1 + payload = json.loads(result.output) + assert payload["status"] == "failed" + assert payload["mesh_id"] == "vm-123" + + +def test_wait_timeout_exits_124(monkeypatch): + from flow360.cli import wait as wait_cli + + runner = CliRunner() + monkeypatch.setattr( + wait_cli, + "_wait_for_resource_state", + lambda ref_id, timeout, poll_interval: (_ for _ in ()).throw( + wait_cli.WaitTimeoutError( + { + "id": ref_id, + "type": "Draft", + "status": "queued", + "is_terminal": False, + "is_success": False, + "updated_at": "2025-01-01T01:00:00Z", + } + ) + ), + ) + + result = runner.invoke(flow360, ["wait", "dft-123"]) + + assert result.exit_code == 124 + payload = json.loads(result.output) + assert payload["timed_out"] is True + assert payload["status"] == "queued" diff --git a/tests/cli/test_cli_webapi_integration.py b/tests/cli/test_cli_webapi_integration.py index ecfaedab6..ff41edd96 100644 --- a/tests/cli/test_cli_webapi_integration.py +++ b/tests/cli/test_cli_webapi_integration.py @@ -7,7 +7,11 @@ from flow360.cli import flow360 PROJECT_ID = "prj-41d2333b-85fd-4bed-ae13-15dcb6da519e" +GEOMETRY_ID = "geo-2877e124-96ff-473d-864b-11eec8648d42" +SURFACE_MESH_ID = "sm-1f1f2753-fe31-47ea-b3ab-efb2313ab65a" +VOLUME_MESH_ID = "vm-7c3681cd-8c6c-4db7-a62c-1742d825e9d3" CASE_ID = "case-69b8c249-fce5-412a-9927-6a79049deebb" +DRAFT_ID = "dft-84b20880-937d-4ef2-983b-7f75089f6dd6" FOLDER_ID = "folder-3834758b-3d39-4a4a-ad85-710b7652267c" @@ -56,7 +60,7 @@ def test_project_tree_uses_tree_endpoint(recorded_webapi_calls): assert result.exit_code == 0 payload = _load_json_output(result.output) - assert payload["root"]["id"] == "geo-2877e124-96ff-473d-864b-11eec8648d42" + assert payload["root"]["id"] == GEOMETRY_ID assert recorded_webapi_calls[-1] == { "type": "get", "url": f"/v2/projects/{PROJECT_ID}/tree", @@ -127,7 +131,230 @@ def test_project_path_uses_path_endpoint(recorded_webapi_calls): } -def test_folder_get_uses_folder_v2_endpoint(recorded_webapi_calls): +@pytest.mark.parametrize( + ("command", "resource_id", "resource_type", "endpoint"), + [ + ("geometry", GEOMETRY_ID, "Geometry", "geometries"), + ("surface-mesh", SURFACE_MESH_ID, "SurfaceMesh", "surface-meshes"), + ("volume-mesh", VOLUME_MESH_ID, "VolumeMesh", "volume-meshes"), + ], +) +def test_asset_info_uses_v2_endpoint( + command, resource_id, resource_type, endpoint, recorded_webapi_calls +): + runner = CliRunner() + + result = runner.invoke(flow360, [command, "info", resource_id]) + + assert result.exit_code == 0 + payload = _load_json_output(result.output) + assert payload["id"] == resource_id + assert payload["type"] == resource_type + assert recorded_webapi_calls[-1] == { + "type": "get", + "url": f"/v2/{endpoint}/{resource_id}", + "params": None, + } + + +@pytest.mark.parametrize( + ("command", "resource_id", "resource_type", "endpoint"), + [ + ("geometry", GEOMETRY_ID, "Geometry", "geometries"), + ("surface-mesh", SURFACE_MESH_ID, "SurfaceMesh", "surface-meshes"), + ("volume-mesh", VOLUME_MESH_ID, "VolumeMesh", "volume-meshes"), + ], +) +def test_asset_state_uses_v2_endpoint( + command, resource_id, resource_type, endpoint, recorded_webapi_calls +): + runner = CliRunner() + + result = runner.invoke(flow360, [command, "state", resource_id]) + + assert result.exit_code == 0 + payload = _load_json_output(result.output) + assert payload["id"] == resource_id + assert payload["type"] == resource_type + assert recorded_webapi_calls[-1] == { + "type": "get", + "url": f"/v2/{endpoint}/{resource_id}", + "params": None, + } + + +@pytest.mark.parametrize( + ("command", "resource_id", "endpoint"), + [ + ("geometry", GEOMETRY_ID, "geometries"), + ("surface-mesh", SURFACE_MESH_ID, "surface-meshes"), + ("volume-mesh", VOLUME_MESH_ID, "volume-meshes"), + ("case", CASE_ID, "cases"), + ], +) +def test_asset_simulation_get_uses_simulation_endpoint( + command, resource_id, endpoint, recorded_webapi_calls +): + runner = CliRunner() + + result = runner.invoke(flow360, [command, "simulation", "get", resource_id]) + + assert result.exit_code == 0 + payload = _load_json_output(result.output) + assert "simulation" in payload + assert isinstance(payload["simulation"], dict) + assert recorded_webapi_calls[-1] == { + "type": "get", + "url": f"/v2/{endpoint}/{resource_id}/simulation/file", + "params": {"type": "simulation"}, + } + + +@pytest.mark.parametrize( + ("command", "resource_id", "endpoint"), + [ + ("geometry", GEOMETRY_ID, "geometries"), + ("surface-mesh", SURFACE_MESH_ID, "surface-meshes"), + ("volume-mesh", VOLUME_MESH_ID, "volume-meshes"), + ("case", CASE_ID, "cases"), + ], +) +def test_asset_summary_uses_simulation_endpoint( + command, resource_id, endpoint, monkeypatch, recorded_webapi_calls +): + from flow360.cli import assets as assets_cli + + runner = CliRunner() + monkeypatch.setattr( + assets_cli, + "_summarize_simulation_json", + lambda simulation_json: {"models": {"surface": []}}, + ) + + result = runner.invoke(flow360, [command, "summary", resource_id]) + + assert result.exit_code == 0 + payload = _load_json_output(result.output) + assert payload["id"] == resource_id + assert "summary" in payload + assert recorded_webapi_calls[-1] == { + "type": "get", + "url": f"/v2/{endpoint}/{resource_id}/simulation/file", + "params": {"type": "simulation"}, + } + + +def test_case_info_uses_case_v2_endpoint(recorded_webapi_calls): + runner = CliRunner() + + result = runner.invoke(flow360, ["case", "info", CASE_ID]) + + assert result.exit_code == 0 + payload = _load_json_output(result.output) + assert payload["id"] == CASE_ID + assert payload["mesh_id"] == VOLUME_MESH_ID + assert recorded_webapi_calls[-1] == { + "type": "get", + "url": f"/v2/cases/{CASE_ID}", + "params": None, + } + + +def test_case_state_uses_case_v2_endpoint(recorded_webapi_calls): + runner = CliRunner() + + result = runner.invoke(flow360, ["case", "state", CASE_ID]) + + assert result.exit_code == 0 + payload = _load_json_output(result.output) + assert payload["id"] == CASE_ID + assert payload["status"] == "completed" + assert payload["mesh_id"] == VOLUME_MESH_ID + assert recorded_webapi_calls[-1] == { + "type": "get", + "url": f"/v2/cases/{CASE_ID}", + "params": None, + } + + +def test_wait_uses_resource_state_endpoint(recorded_webapi_calls): + runner = CliRunner() + + result = runner.invoke(flow360, ["wait", VOLUME_MESH_ID, "--timeout", "0.1"]) + + assert result.exit_code == 0 + payload = _load_json_output(result.output) + assert payload["id"] == VOLUME_MESH_ID + assert payload["status"] == "completed" + assert recorded_webapi_calls[-1] == { + "type": "get", + "url": f"/v2/volume-meshes/{VOLUME_MESH_ID}", + "params": None, + } + + +def test_draft_list_uses_project_scoped_list_endpoint(recorded_webapi_calls): + runner = CliRunner() + + result = runner.invoke(flow360, ["draft", "list", "--project-id", PROJECT_ID]) + + assert result.exit_code == 0 + payload = _load_json_output(result.output) + assert payload["records"][0]["id"] == DRAFT_ID + assert recorded_webapi_calls[-1] == { + "type": "get", + "url": "/v2/drafts", + "params": {"projectId": PROJECT_ID}, + } + + +def test_draft_info_uses_draft_info_endpoint(recorded_webapi_calls): + runner = CliRunner() + + result = runner.invoke(flow360, ["draft", "info", DRAFT_ID]) + + assert result.exit_code == 0 + payload = _load_json_output(result.output) + assert payload["id"] == DRAFT_ID + assert recorded_webapi_calls[-1] == { + "type": "get", + "url": f"/v2/drafts/{DRAFT_ID}", + "params": None, + } + + +def test_draft_state_uses_draft_info_endpoint(recorded_webapi_calls): + runner = CliRunner() + + result = runner.invoke(flow360, ["draft", "state", DRAFT_ID]) + + assert result.exit_code == 0 + payload = _load_json_output(result.output) + assert payload["id"] == DRAFT_ID + assert payload["status"] == "queued" + assert recorded_webapi_calls[-1] == { + "type": "get", + "url": f"/v2/drafts/{DRAFT_ID}", + "params": None, + } + + +def test_draft_simulation_get_uses_simulation_endpoint(recorded_webapi_calls): + runner = CliRunner() + + result = runner.invoke(flow360, ["draft", "simulation", "get", DRAFT_ID]) + + assert result.exit_code == 0 + payload = _load_json_output(result.output) + assert "simulation" in payload + assert recorded_webapi_calls[-1] == { + "type": "get", + "url": f"/v2/drafts/{DRAFT_ID}/simulation/file", + "params": {"type": "simulation"}, + } + + +def test_folder_get_uses_folder_info_endpoint(recorded_webapi_calls): runner = CliRunner() result = runner.invoke(flow360, ["folder", "get", FOLDER_ID]) @@ -153,5 +380,9 @@ def test_folder_tree_uses_folder_list_endpoint(recorded_webapi_calls): assert recorded_webapi_calls[-1] == { "type": "get", "url": "/v2/folders", - "params": {"includeSubfolders": True, "page": 0, "size": 1000}, + "params": { + "includeSubfolders": True, + "page": 0, + "size": 1000, + }, } diff --git a/tests/cli/test_workspace_webapi.py b/tests/cli/test_workspace_webapi.py new file mode 100644 index 000000000..515bbe655 --- /dev/null +++ b/tests/cli/test_workspace_webapi.py @@ -0,0 +1,41 @@ +from flow360.component.simulation.web import workspace_webapi +from flow360.component.simulation.web.workspace_webapi import WorkspaceWebApi + + +def test_workspace_list_records_accepts_bare_list(monkeypatch): + monkeypatch.setattr( + workspace_webapi.RestApi, + "get", + lambda self: [{"id": "private-abc", "rootFolderId": "ROOT.FLOW360"}], + ) + + assert WorkspaceWebApi.list_records() == [{"id": "private-abc", "rootFolderId": "ROOT.FLOW360"}] + + +def test_workspace_list_records_accepts_enveloped_data(monkeypatch): + monkeypatch.setattr( + workspace_webapi.RestApi, + "get", + lambda self: {"data": [{"id": "shared-abc", "rootFolderId": "ROOT.FLOW360.123"}]}, + ) + + assert WorkspaceWebApi.list_records() == [ + {"id": "shared-abc", "rootFolderId": "ROOT.FLOW360.123"} + ] + + +def test_workspace_get_workspace_id_for_root_folder(monkeypatch): + monkeypatch.setattr( + WorkspaceWebApi, + "list_records", + classmethod( + lambda cls: [ + {"id": "shared-abc", "rootFolderId": "ROOT.FLOW360.123"}, + {"id": "private-abc", "rootFolderId": "ROOT.FLOW360"}, + ] + ), + ) + + assert WorkspaceWebApi.get_workspace_id_for_root_folder("ROOT.FLOW360.123") == "shared-abc" + assert WorkspaceWebApi.get_workspace_id_for_root_folder("ROOT.FLOW360") == "private-abc" + assert WorkspaceWebApi.get_workspace_id_for_root_folder("ROOT.FLOW360.missing") is None diff --git a/tests/mock_server.py b/tests/mock_server.py index d0a6f401d..60d2a9c19 100644 --- a/tests/mock_server.py +++ b/tests/mock_server.py @@ -461,8 +461,8 @@ def json(): with open( os.path.join(here, "data/case-69b8c249-fce5-412a-9927-6a79049deebb/simulation.json") ) as fh: - res = json.load(fh) - return res + simulation_json = json.load(fh) + return {"data": {"simulationJson": json.dumps(simulation_json)}} class MockResponseProjectCaseForkSimConfig(MockResponse): @@ -477,6 +477,60 @@ def json(): return res +class MockResponseDraftInfo(MockResponse): + """response for GET /v2/drafts/dft-84b20880-937d-4ef2-983b-7f75089f6dd6""" + + @staticmethod + def json(): + return { + "data": { + "id": "dft-84b20880-937d-4ef2-983b-7f75089f6dd6", + "name": "Draft 1", + "projectId": "prj-41d2333b-85fd-4bed-ae13-15dcb6da519e", + "solverVersion": "release-24.11", + "status": "queued", + "type": "Draft", + "updatedAt": "2025-01-01T01:00:00Z", + } + } + + +class MockResponseDraftList(MockResponse): + """response for GET /v2/drafts?projectId=...""" + + def __init__(self, *args, params=None, **kwargs) -> None: + super().__init__(*args, **kwargs) + self._params = params + + def json(self): + project_id = None if self._params is None else self._params.get("projectId") + return { + "data": { + "records": [ + { + "id": "dft-84b20880-937d-4ef2-983b-7f75089f6dd6", + "name": "Draft 1", + "projectId": project_id, + "solverVersion": "release-24.11", + "type": "Draft", + } + ] + } + } + + +class MockResponseDraftSimulation(MockResponse): + """response for GET /v2/drafts/{id}/simulation/file""" + + @staticmethod + def json(): + with open( + os.path.join(here, "data/case-69b8c249-fce5-412a-9927-6a79049deebb/simulation.json") + ) as fh: + simulation_json = json.load(fh) + return {"data": {"simulationJson": json.dumps(simulation_json)}} + + class MockResponseProjectRunCase(MockResponse): """response for project.run_case(params = params)'s meta json""" @@ -613,10 +667,13 @@ def json(): "/v2/volume-meshes/vm-bff35714-41b1-4251-ac74-46a40b95a330": MockResponseProjectFromVMVolumeMeshMeta, "/v2/volume-meshes/vm-bff35714-41b1-4251-ac74-46a40b95a330/simulation/file": MockResponseProjectFromVMVolumeMeshSimConfig, "/cases/case-69b8c249-fce5-412a-9927-6a79049deebb": MockResponseProjectCase, + "/v2/cases/case-69b8c249-fce5-412a-9927-6a79049deebb": MockResponseProjectCase, "/v2/cases/case-69b8c249-fce5-412a-9927-6a79049deebb/simulation/file": MockResponseProjectCaseSimConfig, "/cases/case-f7480884-4493-4453-9a27-dd5f8498c608": MockResponseProjectFromVMCase, "/cases/case-84d4604e-f3cd-4c6b-8517-92a80a3346d3": MockResponseProjectCaseFork, "/v2/cases/case-84d4604e-f3cd-4c6b-8517-92a80a3346d3/simulation/file": MockResponseProjectCaseForkSimConfig, + "/v2/drafts/dft-84b20880-937d-4ef2-983b-7f75089f6dd6": MockResponseDraftInfo, + "/v2/drafts/dft-84b20880-937d-4ef2-983b-7f75089f6dd6/simulation/file": MockResponseDraftSimulation, "/v2/projects": MockResponseAllProjects, "/cases/case-666666666-66666666-666-6666666666666/files": MockResponseCaseFiles, } @@ -669,6 +726,9 @@ def mock_webapi(type, url, params): if method == "/v2/folders": return MockResponseFolderListV2() + if method == "/v2/drafts": + return MockResponseDraftList(params=params) + elif type == "put": if method == "/folders/move": return MockResponseFolderMove(params=params) diff --git a/tests/simulation/test_project_tree.py b/tests/simulation/test_project_tree.py new file mode 100644 index 000000000..b2a1c65d0 --- /dev/null +++ b/tests/simulation/test_project_tree.py @@ -0,0 +1,62 @@ +import pytest + +from flow360.component.simulation.web.project_tree import ( + build_project_tree, + get_project_tree_parent_id, +) + + +def _build_dict_tree(records): + def create_node(record): + return {"id": record["id"], "children": []} + + def add_child(parent, child): + parent["children"].append(child) + + return build_project_tree(records, create_node=create_node, add_child=add_child) + + +def test_get_project_tree_parent_id_prefers_parent_case_id(): + assert ( + get_project_tree_parent_id({"parentCaseId": "case-parent", "parentId": "vm-parent"}) + == "case-parent" + ) + assert ( + get_project_tree_parent_id({"parentCaseId": None, "parentId": "vm-parent"}) == "vm-parent" + ) + + +def test_build_project_tree_uses_case_parent_edges(): + root, nodes = _build_dict_tree( + [ + {"id": "geo-1", "parentId": None, "parentCaseId": None}, + {"id": "vm-1", "parentId": "geo-1", "parentCaseId": None}, + {"id": "case-1", "parentId": "vm-1", "parentCaseId": None}, + {"id": "case-2", "parentId": "vm-1", "parentCaseId": "case-1"}, + ] + ) + + assert root["id"] == "geo-1" + assert nodes["vm-1"]["children"][0]["id"] == "case-1" + assert nodes["case-1"]["children"][0]["id"] == "case-2" + + +def test_build_project_tree_rejects_invalid_records(): + with pytest.raises(ValueError, match="duplicate item"): + _build_dict_tree( + [ + {"id": "geo-1", "parentId": None, "parentCaseId": None}, + {"id": "geo-1", "parentId": None, "parentCaseId": None}, + ] + ) + + with pytest.raises(ValueError, match="missing parent"): + _build_dict_tree([{"id": "case-1", "parentId": "vm-1", "parentCaseId": None}]) + + with pytest.raises(ValueError, match="2 root items"): + _build_dict_tree( + [ + {"id": "geo-1", "parentId": None, "parentCaseId": None}, + {"id": "geo-2", "parentId": None, "parentCaseId": None}, + ] + ) diff --git a/tests/test_cli_login.py b/tests/test_cli_login.py index 29867f802..1d4a84720 100644 --- a/tests/test_cli_login.py +++ b/tests/test_cli_login.py @@ -112,6 +112,23 @@ def test_configure_stores_dev_apikey(monkeypatch, tmp_path): assert config["default"]["dev"]["apikey"] == "dev-key" +def test_reload_user_config_preserves_runtime_validation_toggle(monkeypatch, tmp_path): + _patch_config_file(monkeypatch, tmp_path) + + previous_do_validation = user_config.UserConfig.do_validation + user_config.UserConfig.disable_validation() + try: + reloaded_config = user_config.reload_user_config() + + assert reloaded_config is user_config.UserConfig + assert not user_config.UserConfig.do_validation + finally: + if previous_do_validation: + user_config.UserConfig.enable_validation() + else: + user_config.UserConfig.disable_validation() + + def test_login_uses_dev_web_url_with_manual_fallback(monkeypatch, tmp_path): _patch_config_file(monkeypatch, tmp_path) monkeypatch.setattr(auth, "_find_available_port", lambda host: 8765) diff --git a/tests/test_lazy_imports.py b/tests/test_lazy_imports.py index 1ca673929..f7c9e3005 100644 --- a/tests/test_lazy_imports.py +++ b/tests/test_lazy_imports.py @@ -92,6 +92,35 @@ def test_flow360_root_help_does_not_eagerly_import_sdk_command_modules(monkeypat assert "flow360.cloud.flow360_requests" not in sys.modules +def test_asset_group_help_does_not_import_simulation_summary(monkeypatch): + monkeypatch.delenv("FLOW360_SUPPRESS_BETA_WARNING", raising=False) + _unload_modules( + monkeypatch, + "flow360.cli", + "flow360.cli.app", + "flow360.cli.assets", + "flow360.cli.simulation_summary", + "flow360.exceptions", + "flow360.component.simulation.simulation_params", + "flow360_schema.exceptions", + "flow360_schema.unit_system", + ) + + from flow360.cli import ( + flow360, # pylint: disable=import-outside-toplevel,import-error + ) + + result = CliRunner().invoke(flow360, ["case", "--help"]) + + assert result.exit_code == 0 + assert "flow360.cli.assets" in sys.modules + assert "flow360.cli.simulation_summary" not in sys.modules + assert "flow360.exceptions" not in sys.modules + assert "flow360.component.simulation.simulation_params" not in sys.modules + assert "flow360_schema.exceptions" not in sys.modules + assert "flow360_schema.unit_system" not in sys.modules + + def test_public_namespace_configure_does_not_eagerly_import_cli_modules(monkeypatch): monkeypatch.delenv("FLOW360_SUPPRESS_BETA_WARNING", raising=False) _unload_modules(