From b3b587fa8637169308ef246ee563263dc2e19ec2 Mon Sep 17 00:00:00 2001 From: Maciej Skarysz Date: Mon, 13 Apr 2026 16:39:41 +0200 Subject: [PATCH 01/15] fix(cli): avoid lazy import cycle on startup --- flow360/cli/auth.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/flow360/cli/auth.py b/flow360/cli/auth.py index f60995168..436088888 100644 --- a/flow360/cli/auth.py +++ b/flow360/cli/auth.py @@ -13,6 +13,8 @@ from typing import Callable, Dict, Optional from urllib.parse import parse_qs, urlencode, urlparse +<<<<<<< HEAD +import flow360.user_config as user_config from cryptography.exceptions import InvalidTag from cryptography.hazmat.primitives import hashes, serialization from cryptography.hazmat.primitives.asymmetric import ec From a6b0421c75a62d7b4251f0cc414de4a561122493 Mon Sep 17 00:00:00 2001 From: Maciej Skarysz Date: Mon, 13 Apr 2026 17:13:48 +0200 Subject: [PATCH 02/15] fix(lint): suppress invalid import recommendation --- flow360/cli/auth.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/flow360/cli/auth.py b/flow360/cli/auth.py index 436088888..4dae00178 100644 --- a/flow360/cli/auth.py +++ b/flow360/cli/auth.py @@ -13,15 +13,12 @@ from typing import Callable, Dict, Optional from urllib.parse import parse_qs, urlencode, urlparse -<<<<<<< HEAD -import flow360.user_config as user_config +import flow360.user_config as user_config # pylint: disable=consider-using-from-import from cryptography.exceptions import InvalidTag from cryptography.hazmat.primitives import hashes, serialization from cryptography.hazmat.primitives.asymmetric import ec from cryptography.hazmat.primitives.ciphers.aead import AESGCM from cryptography.hazmat.primitives.kdf.hkdf import HKDF - -import flow360.user_config as user_config # pylint: disable=consider-using-from-import from flow360.environment import Env from flow360.user_config import store_apikey From dbf82acf624228fe7aee1b8917e1811645aafb8f Mon Sep 17 00:00:00 2001 From: Maciej Skarysz Date: Tue, 21 Apr 2026 15:47:21 +0200 Subject: [PATCH 03/15] Save Flow360 CLI implementation progress --- flow360/cli/api_set_func.py | 28 +- flow360/cli/app.py | 35 + flow360/cli/assets.py | 424 ++++++++ flow360/cli/auth.py | 26 +- flow360/cli/auth_guidance.py | 15 +- flow360/cli/browser_links.py | 129 +++ flow360/cli/draft.py | 476 +++++++++ flow360/cli/open_resource.py | 31 + flow360/cli/resource_refs.py | 67 ++ flow360/cli/resource_state.py | 89 ++ flow360/cli/wait.py | 41 + flow360/cloud/http_util.py | 20 +- flow360/component/interfaces.py | 6 + flow360/component/project.py | 194 +++- .../component/simulation/web/asset_webapi.py | 92 ++ .../component/simulation/web/draft_webapi.py | 120 +++ .../component/simulation/web/project_tree.py | 201 ++++ .../simulation/web/workspace_webapi.py | 25 + flow360/exceptions.py | 3 + flow360/user_config.py | 65 +- .../mock_webapi/case_files_mock_response.json | 18 +- tests/mock_server.py | 219 +++- tests/simulation/test_project_create.py | 26 + tests/simulation/test_project_lazy.py | 86 ++ tests/v1/test_cli_assets.py | 738 +++++++++++++ tests/v1/test_cli_auth_guidance.py | 29 + tests/v1/test_cli_draft.py | 817 +++++++++++++++ tests/v1/test_cli_folder.py | 169 +++ tests/v1/test_cli_open.py | 162 +++ tests/v1/test_cli_project.py | 574 +++++++++++ tests/v1/test_cli_resource_refs.py | 50 + tests/v1/test_cli_wait.py | 109 ++ tests/v1/test_cli_webapi_integration.py | 970 ++++++++++++++++++ tests/v1/test_workspace_webapi.py | 41 + tools/cli_live_benchmark.py | 387 +++++++ 35 files changed, 6321 insertions(+), 161 deletions(-) create mode 100644 flow360/cli/assets.py create mode 100644 flow360/cli/browser_links.py create mode 100644 flow360/cli/draft.py create mode 100644 flow360/cli/open_resource.py create mode 100644 flow360/cli/resource_refs.py create mode 100644 flow360/cli/resource_state.py create mode 100644 flow360/cli/wait.py create mode 100644 flow360/component/simulation/web/asset_webapi.py create mode 100644 flow360/component/simulation/web/draft_webapi.py create mode 100644 flow360/component/simulation/web/project_tree.py create mode 100644 flow360/component/simulation/web/workspace_webapi.py create mode 100644 tests/simulation/test_project_create.py create mode 100644 tests/simulation/test_project_lazy.py create mode 100644 tests/v1/test_cli_assets.py create mode 100644 tests/v1/test_cli_auth_guidance.py create mode 100644 tests/v1/test_cli_draft.py create mode 100644 tests/v1/test_cli_folder.py create mode 100644 tests/v1/test_cli_open.py create mode 100644 tests/v1/test_cli_project.py create mode 100644 tests/v1/test_cli_resource_refs.py create mode 100644 tests/v1/test_cli_wait.py create mode 100644 tests/v1/test_cli_webapi_integration.py create mode 100644 tests/v1/test_workspace_webapi.py create mode 100644 tools/cli_live_benchmark.py 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..97de9308e 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 and manage Flow360 geometries.", + }, + "surface-mesh": { + "module": "flow360.cli.assets", + "attr": "surface_mesh", + "help": "Inspect and manage Flow360 surface meshes.", + }, + "volume-mesh": { + "module": "flow360.cli.assets", + "attr": "volume_mesh", + "help": "Inspect and manage Flow360 volume meshes.", + }, + "case": { + "module": "flow360.cli.assets", + "attr": "case", + "help": "Inspect and manage 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.", + }, } diff --git a/flow360/cli/assets.py b/flow360/cli/assets.py new file mode 100644 index 000000000..5e95bfdba --- /dev/null +++ b/flow360/cli/assets.py @@ -0,0 +1,424 @@ +""" +Asset CLI commands. +""" + +from __future__ import annotations + +import json +import os + +import click + +from flow360.cli.output import emit_json +from flow360.cli.resource_state import get_resource_state_for_type + + +def _rename_asset(webapi_cls, asset_id, new_name): + # pylint: disable=import-outside-toplevel + from flow360.cloud.flow360_requests import RenameAssetRequestV2 + + webapi_cls(asset_id).patch(RenameAssetRequestV2(name=new_name).dict()) + + +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 _serialize_case_result(record): + path = _get_case_result_path(record) + return { + "name": os.path.basename(path) if path else None, + "path": path, + "file_type": record.get("fileType"), + "size_bytes": record.get("length"), + "updated_at": record.get("updatedAt"), + } + + +def _get_case_result_path(record): + for value in (record.get("fileName"), record.get("filePath")): + if not value: + continue + if "results/" in value: + return value[value.index("results/") :] + return record.get("fileName") or record.get("filePath") + + +def _list_case_results(case_id): + # pylint: disable=import-outside-toplevel + from flow360.component.simulation.web.asset_webapi import CaseWebApi + + files = CaseWebApi(case_id).list_files() + result_files = [record for record in files if (_get_case_result_path(record) or "").startswith("results/")] + result_files.sort(key=lambda record: _get_case_result_path(record) or "") + return result_files + + +def _resolve_case_result(case_id, result_ref): + results = _list_case_results(case_id) + if not results: + raise click.ClickException(f"No result files are available for case {case_id}.") + + exact_matches = [ + record + for record in results + if result_ref in {record.get("filePath"), record.get("fileName"), _get_case_result_path(record)} + ] + if len(exact_matches) == 1: + return exact_matches[0] + if len(exact_matches) > 1: + raise click.ClickException(f"Multiple results matched '{result_ref}'. Use the full path.") + + basename_matches = [ + record + for record in results + if os.path.basename(_get_case_result_path(record) or "") == result_ref + ] + if len(basename_matches) == 1: + return basename_matches[0] + if len(basename_matches) > 1: + matches = ", ".join( + sorted(_get_case_result_path(record) or "" for record in basename_matches) + ) + raise click.ClickException( + f"Multiple results matched '{result_ref}'. Use one of: {matches}" + ) + + raise click.ClickException(f"Result '{result_ref}' was not found for case {case_id}.") + + +def _download_case_result(case_id, result_path, *, to_path=None, overwrite=False): + # pylint: disable=import-outside-toplevel + from flow360.component.simulation.web.asset_webapi import CaseWebApi + + if to_path is None: + return CaseWebApi(case_id).download_file(result_path, overwrite=overwrite) + + return CaseWebApi(case_id).download_file( + result_path, + to_file=to_path, + overwrite=overwrite, + ) + + +@click.group("geometry") +def geometry(): + """Inspect and manage 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("rename") +@click.argument("geometry_id") +@click.option("--name", required=True, help="New geometry name.") +def rename_geometry(geometry_id, name): + """Rename a geometry.""" + # pylint: disable=import-outside-toplevel + from flow360.component.simulation.web.asset_webapi import GeometryWebApi + + _rename_asset(GeometryWebApi, geometry_id, name) + emit_json({"id": geometry_id, "name": name}) + + +@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.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 and manage 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("rename") +@click.argument("surface_mesh_id") +@click.option("--name", required=True, help="New surface mesh name.") +def rename_surface_mesh(surface_mesh_id, name): + """Rename a surface mesh.""" + # pylint: disable=import-outside-toplevel + from flow360.component.simulation.web.asset_webapi import SurfaceMeshWebApi + + _rename_asset(SurfaceMeshWebApi, surface_mesh_id, name) + emit_json({"id": surface_mesh_id, "name": name}) + + +@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.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 and manage 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("rename") +@click.argument("volume_mesh_id") +@click.option("--name", required=True, help="New volume mesh name.") +def rename_volume_mesh(volume_mesh_id, name): + """Rename a volume mesh.""" + # pylint: disable=import-outside-toplevel + from flow360.component.simulation.web.asset_webapi import VolumeMeshWebApi + + _rename_asset(VolumeMeshWebApi, volume_mesh_id, name) + emit_json({"id": volume_mesh_id, "name": name}) + + +@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.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 and manage 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("rename") +@click.argument("case_id") +@click.option("--name", required=True, help="New case name.") +def rename_case(case_id, name): + """Rename a case.""" + # pylint: disable=import-outside-toplevel + from flow360.component.simulation.web.asset_webapi import CaseWebApi + + _rename_asset(CaseWebApi, case_id, name) + emit_json({"id": case_id, "name": name}) + + +@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)}) + + +@case.group("results") +def case_results(): + """Namespace for case result artifacts.""" + + +def _emit_case_results_list(case_id): + emit_json({"records": [_serialize_case_result(record) for record in _list_case_results(case_id)]}) + + +@case_results.command("list") +@click.argument("case_id") +def list_case_results(case_id): + """List case result artifacts.""" + _emit_case_results_list(case_id) + + +@case_results.command("ls", hidden=True) +@click.argument("case_id") +def list_case_results_alias(case_id): + """Backward-compatible alias for case results list.""" + _emit_case_results_list(case_id) + + +@case_results.command("get") +@click.argument("case_id") +@click.argument("result_ref") +@click.option( + "--to", + "to_path", + default=None, + type=click.Path(dir_okay=True, file_okay=True, resolve_path=True), + help="Optional destination file or folder path.", +) +@click.option("--overwrite", is_flag=True, help="Overwrite an existing destination file.") +def get_case_result(case_id, result_ref, to_path, overwrite): + """Download one case result artifact.""" + result_record = _resolve_case_result(case_id, result_ref) + result_path = _get_case_result_path(result_record) + saved_to = _download_case_result(case_id, result_path, to_path=to_path, overwrite=overwrite) + emit_json( + { + "case_id": case_id, + "result": _serialize_case_result(result_record), + "saved_to": saved_to, + } + ) diff --git a/flow360/cli/auth.py b/flow360/cli/auth.py index 4dae00178..a8ca9cbfa 100644 --- a/flow360/cli/auth.py +++ b/flow360/cli/auth.py @@ -24,6 +24,7 @@ LOGIN_PATH = "account/cli-login" CALLBACK_PATH = "/callback" +LOCAL_DEV_WEB_URL = "http://local.dev-simulation.cloud:3000" DEV_WEB_URL = "https://flow360.dev-simulation.cloud" CALLBACK_HOST = "127.0.0.1" CALLBACK_ENCRYPTION_ALGORITHM = "P-256-ECDH-AES-GCM-256" @@ -38,16 +39,19 @@ def resolve_target_environment( dev: bool = False, uat: bool = False, env: Optional[str] = None, + local: bool = False, ): """Resolve the selected environment and validate conflicting CLI flags.""" - selected = [flag for flag, enabled in (("dev", dev), ("uat", uat)) if enabled] + selected = [flag for flag, enabled in (("dev", dev), ("uat", uat), ("local", local)) if enabled] if env is not None: selected.append(env) if len(selected) > 1: - raise ValueError("Use only one of --dev, --uat, or --env.") + raise ValueError("Use only one of --dev, --uat, --local, or --env.") - if dev: + if local: + target = Env.dev + elif dev: target = Env.dev elif uat: target = Env.uat @@ -60,13 +64,14 @@ def resolve_target_environment( return target, storage_environment -def build_login_url( # pylint: disable=too-many-arguments +def build_login_url( environment, callback_url: str, state: str, profile: str, callback_public_key: Optional[str] = None, callback_encryption_algorithm: Optional[str] = None, + use_local_ui: bool = False, ) -> str: """Build the browser login URL for the selected environment.""" query_params = { @@ -82,7 +87,9 @@ def build_login_url( # pylint: disable=too-many-arguments query_params["callback_encryption_algorithm"] = callback_encryption_algorithm query = urlencode(query_params) - if environment.name == Env.dev.name: + if use_local_ui: + base_url = LOCAL_DEV_WEB_URL + elif environment.name == Env.dev.name: base_url = DEV_WEB_URL else: base_url = environment.web_url @@ -141,9 +148,7 @@ def _decrypt_callback_payload( raise LoginError("Encrypted login callback payload is invalid.") return { - key: value - for key, value in payload.items() - if isinstance(key, str) and isinstance(value, str) + key: value for key, value in payload.items() if isinstance(key, str) and isinstance(value, str) } @@ -156,7 +161,6 @@ def __init__(self, server_address, *, expected_state: str, callback_private_key) self.callback_private_key = callback_private_key def process_callback_params(self, params: Dict[str, str]) -> Dict[str, str]: - """Validate and normalize callback parameters before storing the API key.""" if params.get("state") != self.expected_state: raise LoginError("Login callback state mismatch.") if "error" in params: @@ -408,6 +412,7 @@ def wait_for_login( profile: str, port: Optional[int] = None, timeout: int = 120, + use_local_ui: bool = False, announce_login: Optional[Callable[[Dict[str, str]], None]] = None, ): # pylint: disable=too-many-arguments,too-many-locals """Run the browser-based login flow and persist the resulting API key.""" @@ -431,6 +436,7 @@ def wait_for_login( profile, callback_public_key=callback_public_key, callback_encryption_algorithm=CALLBACK_ENCRYPTION_ALGORITHM, + use_local_ui=use_local_ui, ) try: @@ -466,7 +472,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..815a8c39e 100644 --- a/flow360/cli/auth_guidance.py +++ b/flow360/cli/auth_guidance.py @@ -6,7 +6,6 @@ def _env_flag(environment_name: str) -> str: - """Return the CLI flag segment for the selected environment.""" if environment_name == "dev": return "--dev" if environment_name == "uat": @@ -17,7 +16,6 @@ def _env_flag(environment_name: str) -> str: def build_login_command(environment_name: str, profile: str) -> str: - """Build the recommended interactive login command for a given context.""" env_flag = _env_flag(environment_name) parts = ["flow360", "login"] if env_flag: @@ -28,7 +26,6 @@ def build_login_command(environment_name: str, profile: str) -> str: def build_configure_command(environment_name: str, profile: str) -> str: - """Build the recommended manual API key configuration command.""" env_flag = _env_flag(environment_name) parts = ["flow360", "configure"] if env_flag: @@ -37,3 +34,15 @@ 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: + 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..b6aa852f6 --- /dev/null +++ b/flow360/cli/browser_links.py @@ -0,0 +1,129 @@ +"""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, + GeometryWebApi, + SurfaceMeshWebApi, + VolumeMeshWebApi, + ) + from flow360.component.simulation.web.draft_webapi import DraftWebApi + + 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..6ab7e17e4 --- /dev/null +++ b/flow360/cli/draft.py @@ -0,0 +1,476 @@ +""" +Draft CLI commands. +""" + +from __future__ import annotations + +import copy +import json + +import click +from flow360.cli.dict_utils import merge_overwrite +from flow360.cli.output import emit_json +from flow360.cli.resource_refs import ResourceRefError, parse_resource_ref, require_resource_type +from flow360.cli.resource_state import ( + WaitTimeoutError, + get_resource_state as _get_resource_state, + get_resource_state_for_type, + wait_for_resource_state as _wait_for_resource_state, +) + + +RUN_TARGETS = { + "surface-mesh": "SurfaceMesh", + "volume-mesh": "VolumeMesh", + "case": "Case", +} + + +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 _parse_resource_id(resource_id): + try: + return parse_resource_ref(resource_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.draft_webapi import DraftWebApi + + return DraftWebApi(draft_id).get_info() + + +def _list_drafts(project_id): + # pylint: disable=import-outside-toplevel + from flow360.component.simulation.web.draft_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.draft_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 _set_draft_simulation_json(draft_id, simulation_json): + # pylint: disable=import-outside-toplevel + from flow360.component.simulation.web.draft_webapi import DraftWebApi + + DraftWebApi(draft_id).set_simulation_json(simulation_json) + + +def _run_draft(draft_id, up_to): + # pylint: disable=import-outside-toplevel + from flow360.component.simulation.web.draft_webapi import DraftWebApi + + return DraftWebApi(draft_id).run(up_to=up_to) + + +def _rename_draft(draft_id, new_name): + # pylint: disable=import-outside-toplevel + from flow360.cloud.flow360_requests import RenameAssetRequestV2 + from flow360.component.simulation.web.draft_webapi import DraftWebApi + + DraftWebApi(draft_id).patch(RenameAssetRequestV2(name=new_name).dict()) + + +def _load_simulation_json(simulation_path): + try: + with open(simulation_path, encoding="utf-8") as handle: + return json.load(handle) + except json.JSONDecodeError as error: + raise click.ClickException(f"Invalid JSON in {simulation_path}: {error}") from error + + +def _load_patch_json(patch_path): + patch_json = _load_simulation_json(patch_path) + if not isinstance(patch_json, dict): + raise click.ClickException(f"Patch JSON in {patch_path} must be a JSON object.") + return patch_json + + +def _get_asset_info_for_type(resource_type, resource_id): + # pylint: disable=import-outside-toplevel + from flow360.component.simulation.web.asset_webapi import ( + CaseWebApi, + GeometryWebApi, + SurfaceMeshWebApi, + VolumeMeshWebApi, + ) + + webapi_by_type = { + "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"Unsupported draft source type: {resource_type}.") + + return webapi_cls(resource_id).get_info() + + +def _get_asset_simulation_json_for_type(resource_type, resource_id): + # pylint: disable=import-outside-toplevel + from flow360.component.simulation.web.asset_webapi import ( + CaseWebApi, + GeometryWebApi, + SurfaceMeshWebApi, + VolumeMeshWebApi, + ) + + webapi_by_type = { + "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"Unsupported draft source type: {resource_type}.") + + return webapi_cls(resource_id).get_simulation_json() + + +def _resolve_draft_source(ref_id): + resource_ref = _parse_resource_id(ref_id) + + if resource_ref.resource_type == "Draft": + raise click.ClickException( + "Draft creation requires a project or asset ref, not a draft ID." + ) + + if resource_ref.resource_type == "Project": + # pylint: disable=import-outside-toplevel + from flow360.component.simulation.web.project_webapi import ProjectWebApi + + project_info = ProjectWebApi(resource_ref.id).get_info() + source_item_id = project_info.get("rootItemId") + source_item_type = project_info.get("rootItemType") + if not source_item_id or not source_item_type: + raise click.ClickException( + f"Project {resource_ref.id} does not expose a root item for draft creation." + ) + source_info = _get_asset_info_for_type(source_item_type, source_item_id) + return { + "project_id": project_info.get("id") or resource_ref.id, + "source_item_id": source_item_id, + "source_item_type": source_item_type, + "solver_version": source_info.get("solverVersion"), + "fork_case": source_item_type == "Case", + } + + if resource_ref.resource_type in {"Geometry", "SurfaceMesh", "VolumeMesh", "Case"}: + source_info = _get_asset_info_for_type(resource_ref.resource_type, resource_ref.id) + return { + "project_id": source_info.get("projectId"), + "source_item_id": resource_ref.id, + "source_item_type": resource_ref.resource_type, + "solver_version": source_info.get("solverVersion"), + "fork_case": resource_ref.resource_type == "Case", + } + + raise click.ClickException( + "Draft creation is only supported from prj-, geo-, sm-, vm-, or case- refs." + ) + + +def _create_draft_from_ref(ref_id, *, name=None): + source = _resolve_draft_source(ref_id) + return _create_draft_from_source(source, name=name) + + +def _create_draft_from_source(source, *, name=None): + # pylint: disable=import-outside-toplevel + from flow360.component.simulation.web.draft_webapi import DraftWebApi + + created = DraftWebApi.create( + name=name, + project_id=source["project_id"], + source_item_id=source["source_item_id"], + source_item_type=source["source_item_type"], + solver_version=source["solver_version"], + fork_case=source["fork_case"], + ) + return { + "id": created.get("id"), + "name": created.get("name"), + "projectId": created.get("projectId") or source["project_id"], + "solverVersion": created.get("solverVersion") or source["solver_version"], + "sourceItemId": created.get("sourceItemId") or source["source_item_id"], + "sourceItemType": created.get("sourceItemType") or source["source_item_type"], + "forkCase": created.get("forkCase", source["fork_case"]), + "type": created.get("type") or "Draft", + } + + +def _apply_patch_to_source_simulation(source, patch_json): + source_simulation = _get_asset_simulation_json_for_type( + source["source_item_type"], source["source_item_id"] + ) + if not isinstance(source_simulation, dict): + raise click.ClickException("Source simulation JSON must be a JSON object to apply a patch.") + return merge_overwrite(copy.deepcopy(source_simulation), patch_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"), + } + + +def _serialize_run_result(info): + payload = { + "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"), + } + if payload["type"] == "Case": + payload["mesh_id"] = ( + info.get("caseMeshId") or info.get("meshId") or info.get("volumeMeshId") + ) + return payload + + +def _emit_run_payload(*, draft_info=None, result, state=None, timed_out=False): + result_payload = _serialize_run_result(result) + if draft_info is None and state is None and not timed_out: + emit_json(result_payload) + return + + payload = {"result": result_payload} + if draft_info is not None: + payload["draft"] = _serialize_draft_info(draft_info) + if state is not None: + payload["state"] = state + if timed_out: + payload["timed_out"] = True + emit_json(payload) + + +@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) + + +@draft.command("create") +@click.argument("ref_id") +@click.option("--name", default=None, help="Optional draft name.") +def create_draft(ref_id, name): + """Create a draft from a project or asset ref.""" + emit_json(_serialize_draft_info(_create_draft_from_ref(ref_id, name=name))) + + +@draft.command("run") +@click.argument("ref_id") +@click.argument( + "simulation_path", + required=False, + type=click.Path(exists=True, dir_okay=False, resolve_path=True), +) +@click.option( + "--patch", + "patch_path", + default=None, + type=click.Path(exists=True, dir_okay=False, resolve_path=True), + help="JSON patch object merged locally into the source simulation before draft run.", +) +@click.option("--name", default=None, help="Optional name for the created draft in one-shot mode.") +@click.option( + "--up-to", + "up_to_name", + required=True, + type=click.Choice(list(RUN_TARGETS.keys()), case_sensitive=False), + help="Run the draft up to the selected resource type.", +) +@click.option("--wait", "wait_for_result", is_flag=True, help="Wait for the result to reach a terminal state.") +@click.option( + "--timeout", + default=3600, + show_default=True, + type=click.FloatRange(min=0.1, min_open=False), + help="Maximum wait time in seconds when --wait is used.", +) +@click.option( + "--poll-interval", + default=2.0, + show_default=True, + type=click.FloatRange(min=0.1, min_open=False), + help="Polling interval in seconds when --wait is used.", +) +def run_draft(ref_id, simulation_path, patch_path, name, up_to_name, wait_for_result, timeout, poll_interval): + """Run a draft workflow.""" + resource_ref = _parse_resource_id(ref_id) + up_to = RUN_TARGETS[up_to_name.lower()] + + if resource_ref.resource_type == "Draft": + if simulation_path is not None or patch_path is not None or name is not None: + raise click.ClickException( + "Simulation JSON, patch, or name cannot be passed when running an existing draft. " + "Use 'flow360 draft simulation set ' first." + ) + result = _run_draft(resource_ref.id, up_to) + if not wait_for_result: + _emit_run_payload(result=result) + return + + try: + state = _wait_for_resource_state( + result["id"], timeout=timeout, poll_interval=poll_interval + ) + except WaitTimeoutError as error: + _emit_run_payload(result=result, state=error.state, timed_out=True) + raise click.exceptions.Exit(124) from error + + _emit_run_payload(result=result, state=state) + if not state["is_success"]: + raise click.exceptions.Exit(1) + return + + if simulation_path is not None and patch_path is not None: + raise click.ClickException( + "Provide either a full simulation JSON path or --patch, not both." + ) + + if simulation_path is None and patch_path is None: + raise click.ClickException( + "Simulation JSON path or --patch is required when running from a non-draft ref." + ) + + source = _resolve_draft_source(resource_ref.id) + if simulation_path is not None: + simulation_json = _load_simulation_json(simulation_path) + else: + simulation_json = _apply_patch_to_source_simulation(source, _load_patch_json(patch_path)) + + draft_info = _create_draft_from_source(source, name=name) + _set_draft_simulation_json(draft_info["id"], simulation_json) + result = _run_draft(draft_info["id"], up_to) + if not wait_for_result: + _emit_run_payload(draft_info=draft_info, result=result) + return + + try: + state = _wait_for_resource_state(result["id"], timeout=timeout, poll_interval=poll_interval) + except WaitTimeoutError as error: + _emit_run_payload(draft_info=draft_info, result=result, state=error.state, timed_out=True) + raise click.exceptions.Exit(124) from error + + _emit_run_payload(draft_info=draft_info, result=result, state=state) + if not state["is_success"]: + raise click.exceptions.Exit(1) + + +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("rename") +@click.argument("draft_id") +@click.option("--name", required=True, help="New draft name.") +def rename_draft(draft_id, name): + """Rename a draft.""" + draft_id = _require_typed_id(draft_id, "Draft") + _rename_draft(draft_id, name) + emit_json({"id": draft_id, "name": name}) + + +@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)}) + + +@draft_simulation.command("set") +@click.argument("draft_id") +@click.argument( + "simulation_path", + type=click.Path(exists=True, dir_okay=False, resolve_path=True), +) +def set_draft_simulation(draft_id, simulation_path): + """Replace draft simulation JSON.""" + draft_id = _require_typed_id(draft_id, "Draft") + simulation_json = _load_simulation_json(simulation_path) + _set_draft_simulation_json(draft_id, simulation_json) + emit_json({"id": draft_id, "updated": True}) diff --git a/flow360/cli/open_resource.py b/flow360/cli/open_resource.py new file mode 100644 index 000000000..0d9d6d6ea --- /dev/null +++ b/flow360/cli/open_resource.py @@ -0,0 +1,31 @@ +"""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/resource_refs.py b/flow360/cli/resource_refs.py new file mode 100644 index 000000000..4d1cbbb6b --- /dev/null +++ b/flow360/cli/resource_refs.py @@ -0,0 +1,67 @@ +""" +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..2a7373a4d --- /dev/null +++ b/flow360/cli/resource_state.py @@ -0,0 +1,89 @@ +""" +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, + GeometryWebApi, + SurfaceMeshWebApi, + VolumeMeshWebApi, + ) + from flow360.component.simulation.web.draft_webapi import DraftWebApi + + 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/wait.py b/flow360/cli/wait.py new file mode 100644 index 000000000..c349a58b7 --- /dev/null +++ b/flow360/cli/wait.py @@ -0,0 +1,41 @@ +""" +Generic resource wait command. +""" + +from __future__ import annotations + +import click + +from flow360.cli.output import emit_json +from flow360.cli.resource_state import WaitTimeoutError, 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..858c4226e 100644 --- a/flow360/component/project.py +++ b/flow360/component/project.py @@ -7,14 +7,13 @@ import json from enum import Enum -from typing import Dict, Iterable, List, Literal, Optional, Union +from typing import TYPE_CHECKING, Any, Dict, Iterable, List, Literal, Optional, Union import pydantic as pd import typing_extensions from flow360_schema.framework.physical_dimensions import Length from flow360_schema.models.asset_cache import CoordinateSystemStatus, MirrorStatus from pydantic import PositiveInt - from flow360.cloud.file_cache import get_shared_cloud_file_cache from flow360.cloud.flow360_requests import ( CloneVolumeMeshRequest, @@ -23,7 +22,6 @@ ) from flow360.cloud.http_util import http from flow360.cloud.rest_api import RestApi -from flow360.component.case import Case from flow360.component.cloud_examples import ( copy_example, fetch_examples, @@ -65,6 +63,8 @@ get_project_records, show_projects_with_keyword_filter, ) +from flow360.component.simulation.web.project_webapi import ProjectWebApi +from flow360.component.simulation.web.project_tree import ProjectTree from flow360.component.simulation.web.utils import ( get_project_dependency_resource_metadata, ) @@ -93,6 +93,18 @@ AssetOrResource = Union[type[AssetBase], type[Flow360Resource]] +if TYPE_CHECKING: + from flow360.component.case import Case +else: + Case = Any + + +def _case_class(): + # pylint: disable=import-outside-toplevel + from flow360.component.case import Case + + return Case + class RootType(Enum): """ @@ -248,7 +260,7 @@ def _merge_geometry_entity_info( if isinstance(new_run_from, Geometry) and new_run_from.info.dependency: raise Flow360ValueError("Draft creation from an imported Geometry is not supported.") - if isinstance(new_run_from, Case): + if isinstance(new_run_from, _case_class()): raise Flow360ValueError("Draft creation from a Case is not supported.") if not isinstance(new_run_from.entity_info, GeometryEntityInfo) and ( @@ -611,15 +623,21 @@ class Project(pd.BaseModel): Project class containing the interface for creating and running simulations. """ - metadata: ProjectMeta = pd.Field(description="Metadata of the project.") - project_tree: ProjectTree = pd.Field() - solver_version: str = pd.Field(frozen=True, description="Version of the solver being used.") + metadata: Optional[ProjectMeta] = pd.Field( + default=None, description="Metadata of the project." + ) + project_tree: ProjectTree = pd.Field(default_factory=ProjectTree) + solver_version: Optional[str] = pd.Field( + default=None, description="Version of the solver being used." + ) _root_asset: Union[Geometry, SurfaceMeshV2, VolumeMeshV2] = pd.PrivateAttr(None) _root_webapi: Optional[RestApi] = pd.PrivateAttr(None) - _project_webapi: Optional[RestApi] = pd.PrivateAttr(None) + _project_webapi: Optional[ProjectWebApi] = pd.PrivateAttr(None) _root_simulation_json: Optional[dict] = pd.PrivateAttr(None) + _project_id: Optional[str] = pd.PrivateAttr(None) + _lazy_load: bool = pd.PrivateAttr(False) @classmethod def show_remote(cls, search_keyword: Union[None, str] = None): @@ -643,7 +661,9 @@ def id(self) -> str: str The project ID. """ - return self.metadata.id + if self.metadata is not None: + return self.metadata.id + return self._project_id @property def tags(self) -> List[str]: @@ -655,7 +675,19 @@ def tags(self) -> List[str]: List[str] List of the project's tags. """ - return self.metadata.tags + return self.get_metadata().tags + + def get_metadata(self) -> ProjectMeta: + """Return project metadata, loading it on first access.""" + if self.metadata is None: + self._load_metadata() + return self.metadata + + def get_project_tree(self) -> ProjectTree: + """Return the project tree, loading it on first access.""" + if not self.project_tree.nodes: + self._load_tree() + return self.project_tree @property def length_unit(self) -> Length.PositiveFloat64: @@ -825,7 +857,7 @@ def get_case(self, asset_id: str = None) -> Case: asset_id = self.project_tree.get_full_asset_id( query_asset=AssetShortID(asset_id=asset_id, asset_type="Case") ) - return Case.from_cloud(case_id=asset_id) + return _case_class().from_cloud(case_id=asset_id) @property def case(self): @@ -927,6 +959,7 @@ def _create_project_from_files( solver_version: str = __solver_version__, length_unit: LengthUnitType = "m", tags: List[str] = None, + description: str = "", run_async: bool = False, folder: Optional[Folder] = None, workflow: GeometryWorkflow = "standard", @@ -946,6 +979,8 @@ def _create_project_from_files( Unit of length (default is "m"). tags : list of str, optional Tags to assign to the project (default is None). + description : str, optional + Description to assign to the project (default is ""). run_async : bool, optional Whether to create the project asynchronously (default is False). folder : Optional[Folder], optional @@ -989,7 +1024,7 @@ def _create_project_from_files( "Cannot detect the intended project root with the given file(s)." ) - root_asset = draft.submit(run_async=run_async) + root_asset = draft.submit(description=description, run_async=run_async) if run_async: log.info( f"The input file(s) has been successfully uploaded to project: {root_asset.project_id} " @@ -1032,6 +1067,7 @@ def _create_project_from_volume_mesh_clone( solver_version: str, length_unit: LengthUnitType, tags: Optional[List[str]], + description: str, run_async: bool, folder: Optional[Folder], ): @@ -1050,6 +1086,8 @@ def _create_project_from_volume_mesh_clone( Unit of length. tags : list of str, optional Tags to assign to the project. + description : str + Description to assign to the project. run_async : bool Whether to create the project asynchronously. folder : Optional[Folder], optional @@ -1067,6 +1105,7 @@ def _create_project_from_volume_mesh_clone( tags=tags, parent_folder_id=folder.id if folder else "ROOT.FLOW360", length_unit=length_unit, + description=description, original_volume_mesh_id=volume_mesh.id, ) @@ -1153,6 +1192,7 @@ def from_geometry( solver_version: str = __solver_version__, length_unit: LengthUnitType = "m", tags: List[str] = None, + description: str = "", run_async: bool = False, folder: Optional[Folder] = None, workflow: GeometryWorkflow = "standard", @@ -1172,6 +1212,8 @@ def from_geometry( Unit of length (default is "m"). tags : list of str, optional Tags to assign to the project (default is None). + description : str, optional + Description to assign to the project (default is ""). run_async : bool, optional Whether to create project asynchronously (default is False). folder : Optional[Folder], optional @@ -1213,6 +1255,7 @@ def from_geometry( solver_version=solver_version, length_unit=length_unit, tags=tags, + description=description, run_async=run_async, folder=folder, workflow=workflow, @@ -1228,6 +1271,7 @@ def from_surface_mesh( solver_version: str = __solver_version__, length_unit: LengthUnitType = "m", tags: List[str] = None, + description: str = "", run_async: bool = False, folder: Optional[Folder] = None, ): @@ -1247,6 +1291,8 @@ def from_surface_mesh( Unit of length (default is "m"). tags : list of str, optional Tags to assign to the project (default is None). + description : str, optional + Description to assign to the project (default is ""). run_async : bool, optional Whether to create project asynchronously (default is False). folder : Optional[Folder], optional @@ -1285,6 +1331,7 @@ def from_surface_mesh( solver_version=solver_version, length_unit=length_unit, tags=tags, + description=description, run_async=run_async, folder=folder, ) @@ -1300,6 +1347,7 @@ def from_volume_mesh( solver_version: Optional[str] = None, length_unit: Optional[LengthUnitType] = None, tags: Optional[List[str]] = None, + description: str = "", run_async: bool = False, folder: Optional[Folder] = None, ): @@ -1327,6 +1375,8 @@ def from_volume_mesh( tags : list of str, optional Tags to assign to the project (default is None for file input, or the original volume mesh's tags for VolumeMeshV2 input). + description : str, optional + Description to assign to the project (default is ""). run_async : bool Whether to create project asynchronously (default is False). folder : Optional[Folder], optional @@ -1375,6 +1425,7 @@ def from_volume_mesh( solver_version=resolved_solver_version, length_unit=resolved_length_unit, tags=resolved_tags, + description=description, run_async=run_async, folder=folder, ) @@ -1391,6 +1442,7 @@ def from_volume_mesh( solver_version=resolved_solver_version, length_unit=resolved_length_unit, tags=resolved_tags, + description=description, run_async=run_async, folder=folder, ) @@ -1409,6 +1461,7 @@ def from_file( solver_version: str = __solver_version__, length_unit: LengthUnitType = "m", tags: List[str] = None, + description: str = "", run_async: bool = False, ): """ @@ -1427,6 +1480,8 @@ def from_file( Unit of length (default is "m"). tags : list of str, optional Tags to assign to the project (default is None). + description : str, optional + Description to assign to the project (default is ""). run_async : bool, optional Whether to create project asynchronously (default is False). @@ -1462,6 +1517,7 @@ def _detect_input_file_type(file: Union[str, list[str]]): solver_version=solver_version, length_unit=length_unit, tags=tags, + description=description, run_async=run_async, ) @@ -1717,7 +1773,7 @@ def _get_user_requested_entity_info( "The supplied cloud resource for `new_run_from` does not belong to the project." ) - if isinstance(new_run_from, Case): + if isinstance(new_run_from, _case_class()): user_requested_entity_info = new_run_from.get_simulation_params() if isinstance(new_run_from, (Geometry, SurfaceMeshV2, VolumeMeshV2)): user_requested_entity_info = new_run_from.params @@ -1733,6 +1789,7 @@ def from_cloud( project_id: str, *, new_run_from: Optional[Union[Geometry, SurfaceMeshV2, VolumeMeshV2, Case]] = None, + lazy_load: bool = False, ): """ Loads a project from the cloud. @@ -1764,29 +1821,70 @@ def from_cloud( """ project_info = AssetShortID(asset_id=project_id, asset_type="Project") - project_api = RestApi(ProjectInterface.endpoint, id=project_info.asset_id) - info = project_api.get() - if not isinstance(info, dict): - raise Flow360WebError( - f"Cannot load project {project_info.asset_id}, missing project data." - ) - if not info: - raise Flow360WebError(f"Couldn't retrieve project info for {project_info.asset_id}") - meta = ProjectMeta(**info) - root_asset = None - root_type = meta.root_item_type + + if lazy_load and new_run_from is not None: + raise ValueError("lazy project loading does not support `new_run_from`.") if ( new_run_from is not None - and isinstance(new_run_from, (Geometry, SurfaceMeshV2, VolumeMeshV2, Case)) is False + and isinstance( + new_run_from, (Geometry, SurfaceMeshV2, VolumeMeshV2, _case_class()) + ) + is False ): # Should have been caught by the validate_call? raise ValueError( "The supplied `new_run_from` is not valid. Please check the function description for more details." ) - entity_info_param = cls._get_user_requested_entity_info( - current_project_id=project_info.asset_id, new_run_from=new_run_from + project = Project() + project._project_id = project_info.asset_id + project._project_webapi = ProjectWebApi(project_info.asset_id) + project._lazy_load = lazy_load + + if lazy_load: + return project + + project._hydrate_full_project(new_run_from=new_run_from) + return project + + def _load_metadata(self): + """Load project metadata from cloud if it has not been loaded yet.""" + if self.metadata is not None: + return + info = self._project_webapi.get_info() + if not isinstance(info, dict): + raise Flow360WebError(f"Cannot load project {self.id}, missing project data.") + if not info: + raise Flow360WebError(f"Couldn't retrieve project info for {self.id}") + self.metadata = ProjectMeta(**info) + self._project_id = self.metadata.id + + def _load_tree(self): + """Load project tree from cloud if it has not been loaded yet.""" + if self.project_tree.nodes: + return + resp = self._project_webapi.get_tree() + asset_records = sorted( + resp["records"], + key=lambda d: parse_datetime(d["updatedAt"]), + ) + self.project_tree = ProjectTree() + self.project_tree.construct_tree(asset_records=asset_records) + + def _hydrate_full_project( + self, + *, + new_run_from: Optional[Union[Geometry, SurfaceMeshV2, VolumeMeshV2, Case]] = None, + ): + """Hydrate the full project state used by heavier SDK workflows.""" + self._load_metadata() + meta = self.metadata + root_asset = None + root_type = meta.root_item_type + + entity_info_param = self._get_user_requested_entity_info( + current_project_id=self.id, new_run_from=new_run_from ) if root_type == RootType.GEOMETRY: @@ -1800,23 +1898,19 @@ def from_cloud( meta.root_item_id, entity_info_param=entity_info_param ) if not root_asset: - raise Flow360ValueError(f"Couldn't retrieve root asset for {project_info.asset_id}") - project = Project( - metadata=meta, project_tree=ProjectTree(), solver_version=root_asset.solver_version - ) - project._project_webapi = project_api + raise Flow360ValueError(f"Couldn't retrieve root asset for {self.id}") + self.solver_version = root_asset.solver_version if root_type == RootType.GEOMETRY: - project._root_asset = root_asset - project._root_webapi = RestApi(GeometryInterface.endpoint, id=root_asset.id) + self._root_asset = root_asset + self._root_webapi = RestApi(GeometryInterface.endpoint, id=root_asset.id) elif root_type == RootType.SURFACE_MESH: - project._root_asset = root_asset - project._root_webapi = RestApi(SurfaceMeshInterfaceV2.endpoint, id=root_asset.id) + self._root_asset = root_asset + self._root_webapi = RestApi(SurfaceMeshInterfaceV2.endpoint, id=root_asset.id) elif root_type == RootType.VOLUME_MESH: - project._root_asset = root_asset - project._root_webapi = RestApi(VolumeMeshInterfaceV2.endpoint, id=root_asset.id) - project._get_root_simulation_json() - project._get_tree_from_cloud() - return project + self._root_asset = root_asset + self._root_webapi = RestApi(VolumeMeshInterfaceV2.endpoint, id=root_asset.id) + self._get_root_simulation_json() + self._get_tree_from_cloud() @classmethod @pd.validate_call @@ -1873,6 +1967,9 @@ def _check_initialized(self): Flow360ValueError If the project is not initialized. """ + if self._lazy_load and (not self.metadata or not self.solver_version or not self._root_asset): + self._hydrate_full_project() + if not self.metadata or not self.solver_version or not self._root_asset: raise Flow360ValueError( "Project not initialized - use Project.from_file or Project.from_cloud" @@ -1895,12 +1992,9 @@ def _get_tree_from_cloud(self, destination_obj: AssetOrResource = None): asset_records = [] if destination_obj: method = "path" - resp = self._project_webapi.get( - method=method, - params={ - "itemId": destination_obj.id, - "itemType": destination_obj._cloud_resource_type_name, - }, + resp = self._project_webapi.get_path( + item_id=destination_obj.id, + item_type=destination_obj._cloud_resource_type_name, ) for key, val in resp.items(): if not val: @@ -1911,7 +2005,7 @@ def _get_tree_from_cloud(self, destination_obj: AssetOrResource = None): asset_records.append(val) else: method = "tree" - resp = self._project_webapi.get(method=method) + resp = self._project_webapi.get_tree() asset_records = resp["records"] self.project_tree = ProjectTree() @@ -2179,7 +2273,7 @@ def _run( # Remove when converting Case to V2 kwargs = {} - if isinstance(destination_obj, Case): + if isinstance(destination_obj, _case_class()): kwargs = {"project_id": destination_obj.project_id} log.info(f"Successfully submitted: {destination_obj.short_description(**kwargs)}") @@ -2443,7 +2537,7 @@ def run_case( self._check_initialized() case_or_draft = self._run( params=params, - target=Case, + target=_case_class(), draft_name=name, run_async=run_async, fork_from=fork_from, diff --git a/flow360/component/simulation/web/asset_webapi.py b/flow360/component/simulation/web/asset_webapi.py new file mode 100644 index 000000000..054e21f28 --- /dev/null +++ b/flow360/component/simulation/web/asset_webapi.py @@ -0,0 +1,92 @@ +""" +Thin asset web API wrappers. +""" + +from __future__ import annotations + +import json + +from flow360.cloud.rest_api import RestApi +from flow360.component.interfaces import ( + CaseInterface, + CaseInterfaceV2, + GeometryInterface, + SurfaceMeshInterfaceV2, + VolumeMeshInterfaceV2, +) + + +class AssetWebApi: + """Thin wrapper around a single asset endpoint.""" + + def __init__(self, interface, asset_id: str): + self.asset_id = asset_id + self._api = RestApi(interface.endpoint, id=asset_id) + + @staticmethod + def _unwrap_data(response): + if isinstance(response, dict) and "data" in response: + return response["data"] + return response + + def get_info(self): + return self._unwrap_data(self._api.get()) + + def get_simulation_json(self): + 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): + return self._api.get(path=path, method=method, json=json, params=params) + + def post(self, json, path=None, method=None): + return self._api.post(json=json, path=path, method=method) + + def put(self, json, path=None, method=None): + return self._api.put(json=json, path=path, method=method) + + def patch(self, json, path=None, method=None): + return self._api.patch(json=json, path=path, method=method) + + def delete(self, path=None, method=None): + return self._api.delete(path=path, method=method) + + +class GeometryWebApi(AssetWebApi): + def __init__(self, asset_id: str): + super().__init__(GeometryInterface, asset_id) + + +class SurfaceMeshWebApi(AssetWebApi): + def __init__(self, asset_id: str): + super().__init__(SurfaceMeshInterfaceV2, asset_id) + + +class VolumeMeshWebApi(AssetWebApi): + def __init__(self, asset_id: str): + super().__init__(VolumeMeshInterfaceV2, asset_id) + + +class CaseWebApi(AssetWebApi): + def __init__(self, asset_id: str): + super().__init__(CaseInterfaceV2, asset_id) + self._files_api = RestApi(CaseInterface.endpoint, id=asset_id) + + def list_files(self): + return self._unwrap_data(self._files_api.get(method="files")) + + def download_file(self, remote_file_name, *, to_file=None, to_folder=".", overwrite=False): + return CaseInterface.s3_transfer_method.download_file( + self.asset_id, + remote_file_name, + to_file=to_file, + to_folder=to_folder, + overwrite=overwrite, + verbose=False, + ) diff --git a/flow360/component/simulation/web/draft_webapi.py b/flow360/component/simulation/web/draft_webapi.py new file mode 100644 index 000000000..4225dae75 --- /dev/null +++ b/flow360/component/simulation/web/draft_webapi.py @@ -0,0 +1,120 @@ +""" +Thin draft web API wrapper. +""" + +from __future__ import annotations + +import json + +from flow360.cloud.rest_api import RestApi +from flow360.cloud.flow360_requests import DraftCreateRequest +from flow360.component.interfaces import DraftInterface + + +class DraftWebApi: + """Thin wrapper around draft endpoints.""" + + def __init__(self, draft_id: str): + self.draft_id = draft_id + self._api = RestApi(DraftInterface.endpoint, id=draft_id) + + @staticmethod + def _unwrap_data(response): + if isinstance(response, dict) and "data" in response: + return response["data"] + return response + + def get_info(self): + return self._unwrap_data(self._api.get()) + + @classmethod + def create( + cls, + *, + project_id: str, + source_item_id: str, + source_item_type: str, + solver_version: str, + fork_case: bool, + name: str | None = None, + interpolation_volume_mesh_id: str | None = None, + interpolation_case_id: str | None = None, + tags: list[str] | None = None, + ): + api = RestApi(DraftInterface.endpoint) + payload = DraftCreateRequest( + name=name, + project_id=project_id, + source_item_id=source_item_id, + source_item_type=source_item_type, + solver_version=solver_version, + fork_case=fork_case, + interpolation_volume_mesh_id=interpolation_volume_mesh_id, + interpolation_case_id=interpolation_case_id, + tags=tags, + ).model_dump(by_alias=True) + return cls._unwrap_data(api.post(json=payload)) + + @classmethod + def list_records(cls, project_id: str): + api = RestApi(DraftInterface.endpoint) + response = api.get(params={"projectId": project_id}) + return response.get("records", []) + + def get_simulation_json(self): + response = self._api.get(method="simulation/file", params={"type": "simulation"}) + if isinstance(response, dict) and "simulationJson" in response: + return response["simulationJson"] + return response + + def set_simulation_json(self, simulation_json): + if not isinstance(simulation_json, str): + simulation_json = json.dumps(simulation_json) + + return self._api.post( + method="simulation/file", + json={ + "data": simulation_json, + "type": "simulation", + "version": "", + }, + ) + + def run( + self, + up_to: str, + *, + use_in_house: bool = False, + use_gai: bool = False, + start_from: str | None = None, + job_type: str | None = None, + priority: int | None = None, + ): + payload = { + "upTo": up_to, + "useInHouse": use_in_house, + "useGai": use_gai, + } + if start_from is not None: + payload["forceCreationConfig"] = {"startFrom": start_from} + if job_type is not None: + payload["jobType"] = job_type + if priority is not None: + payload["priority"] = priority + + return self._unwrap_data(self._api.post(method="run", json=payload)) + + def get(self, path=None, method=None, json=None, params=None): + return self._api.get(path=path, method=method, json=json, params=params) + + def post(self, json, path=None, method=None): + return self._api.post(json=json, path=path, method=method) + + def put(self, json, path=None, method=None): + return self._api.put(json=json, path=path, method=method) + + def patch(self, json, path=None, method=None): + return self._api.patch(json=json, path=path, method=method) + + def delete(self, path=None, method=None): + return self._api.delete(path=path, method=method) diff --git a/flow360/component/simulation/web/project_tree.py b/flow360/component/simulation/web/project_tree.py new file mode 100644 index 000000000..f2cd859d2 --- /dev/null +++ b/flow360/component/simulation/web/project_tree.py @@ -0,0 +1,201 @@ +""" +Project tree models and construction helpers. +""" + +from __future__ import annotations + +from typing import List, Literal, Optional + +import pydantic as pd +from pydantic import PositiveInt + +from flow360.component.utils import AssetShortID, get_short_asset_id, wrapstring + + +class ProjectTreeNode(pd.BaseModel): + """ + ProjectTreeNode class containing the info of an asset item in a project tree. + """ + + asset_id: str = pd.Field() + asset_name: str = pd.Field() + asset_type: str = pd.Field() + parent_id: Optional[str] = pd.Field(None) + case_mesh_id: Optional[str] = pd.Field(None) + case_mesh_label: Optional[str] = pd.Field(None) + children: List = pd.Field([]) + min_length_short_id: PositiveInt = pd.Field(7) + + def construct_string(self, line_width): + title_line = "<<" + self.asset_type + ">>" + name_line = f"name: {self.asset_name}" + id_line = f"id: {self.short_id}" + + max_line_width = min(line_width, max(len(name_line), len(id_line))) + block_line_width = max(len(title_line), max_line_width) + + name_line = wrapstring(long_str=f"name: {self.asset_name}", str_length=block_line_width) + id_line = wrapstring(long_str=f"id: {self.short_id}", str_length=block_line_width) + return f"{title_line.center(block_line_width)}\n{name_line}\n{id_line}" + + def add_child(self, child: "ProjectTreeNode"): + self.children.append(child) + + def remove_child(self, child_to_remove: "ProjectTreeNode"): + self.children = [child for child in self.children if child is not child_to_remove] + + @property + def short_id(self) -> str: + return get_short_asset_id( + full_asset_id=self.asset_id, num_character=self.min_length_short_id + ) + + @property + def edge_label(self) -> str: + if self.case_mesh_label: + prefix = "Using VolumeMesh:\n" + mesh_short_id = get_short_asset_id( + full_asset_id=self.case_mesh_label, + num_character=self.min_length_short_id, + ) + return prefix + mesh_short_id.center(len(prefix)) + return None + + +class ProjectTree(pd.BaseModel): + """ + ProjectTree class containing the project tree. + """ + + root: ProjectTreeNode = pd.Field(None) + nodes: dict[str, ProjectTreeNode] = pd.Field({}) + short_id_map: dict[str, List[str]] = pd.Field({}) + + def _update_case_mesh_label(self): + for node_id in self._get_asset_ids_by_type(asset_type="Case"): + node = self.nodes.get(node_id) + parent_node = self._get_parent_node(node=node) + if not parent_node: + continue + if parent_node.asset_type != "Case" or node.case_mesh_id == parent_node.case_mesh_id: + node.case_mesh_label = None + + def _update_node_short_id(self): + if len(self.nodes) == len(self.short_id_map): + pass + full_id_to_update = [] + short_id_duplicate = [] + for short_id, full_ids in self.short_id_map.items(): + if len(full_ids) > 1: + short_id_duplicate.append(short_id) + common_prefix = full_ids[0] + for full_id in full_ids[1:]: + while not full_id.startswith(common_prefix): + common_prefix = common_prefix[:-1] + common_prefix_processed = "".join(common_prefix.split("-")[1:]) + for full_id in full_ids: + self.nodes[full_id].min_length_short_id = len(common_prefix_processed) + 1 + full_id_to_update.append(full_id) + for full_id in full_id_to_update: + self.short_id_map.update({self.nodes[full_id].short_id: [full_id]}) + for short_id in short_id_duplicate: + self.short_id_map.pop(short_id, None) + + def _get_parent_node(self, node: ProjectTreeNode): + if not node.parent_id: + return None + return self.nodes.get(node.parent_id, None) + + def _has_node(self, asset_id: str) -> bool: + return asset_id in self.nodes.keys() + + def _get_asset_ids_by_type( + self, asset_type: str = Literal["Geometry", "SurfaceMesh", "VolumeMesh", "Case"] + ): + return [node.asset_id for node in self.nodes.values() if node.asset_type == asset_type] + + @classmethod + def _create_new_node(cls, asset_record: dict): + parent_id = ( + asset_record["parentCaseId"] + if asset_record["parentCaseId"] + else asset_record["parentId"] + ) + case_mesh_id = asset_record["parentId"] if asset_record["type"] == "Case" else None + + return ProjectTreeNode( + asset_id=asset_record["id"], + asset_name=asset_record["name"], + asset_type=asset_record["type"], + parent_id=parent_id, + case_mesh_id=case_mesh_id, + case_mesh_label=case_mesh_id, + ) + + def _update_short_id_map(self, new_node: ProjectTreeNode): + if new_node.short_id not in self.short_id_map.keys(): + self.short_id_map[new_node.short_id] = [] + self.short_id_map[new_node.short_id].append(new_node.asset_id) + + def add(self, asset_record: dict): + if self._has_node(asset_id=asset_record["id"]): + return False + + 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 + for node in self.nodes.values(): + if node.parent_id == new_node.asset_id: + new_node.add_child(child=node) + if node.asset_id == new_node.parent_id: + node.add_child(child=new_node) + self.nodes.update({new_node.asset_id: new_node}) + self._update_node_short_id() + self._update_case_mesh_label() + return True + + def remove_node(self, node_id: str): + node = self.nodes.get(node_id) + if not node: + return + if node.parent_id and self._has_node(node.parent_id): + self.nodes[node.parent_id].remove_child(node) + self.nodes.pop(node.asset_id) + + def construct_tree(self, asset_records: List[dict]): + for asset_record in asset_records: + 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}) + + for node in self.nodes.values(): + if node.parent_id and self._has_node(node.parent_id): + self.nodes[node.parent_id].add_child(node) + self._update_node_short_id() + self._update_case_mesh_label() + + @pd.validate_call + def get_full_asset_id(self, query_asset: AssetShortID) -> str: + if query_asset.asset_id is None: + asset_ids = self._get_asset_ids_by_type(asset_type=query_asset.asset_type) + if not asset_ids: + raise ValueError(f"No {query_asset.asset_type} is available in this project.") + return asset_ids[-1] + + if query_asset.asset_id in self.nodes: + return query_asset.asset_id + + full_ids = self.short_id_map.get(query_asset.asset_id, None) + if full_ids is None: + raise ValueError( + f"This asset does not exist in this project. Please check the input asset ID ({query_asset.asset_id})" + ) + if len(full_ids) > 1: + raise ValueError( + f"The input asset ID ({query_asset.asset_id}) is too short to retrieve the correct asset." + ) + return full_ids[0] + diff --git a/flow360/component/simulation/web/workspace_webapi.py b/flow360/component/simulation/web/workspace_webapi.py new file mode 100644 index 000000000..1dc8b7422 --- /dev/null +++ b/flow360/component/simulation/web/workspace_webapi.py @@ -0,0 +1,25 @@ +"""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): + 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: + for record in cls.list_records(): + if record.get("rootFolderId") == root_folder_id: + return record.get("id") + return None diff --git a/flow360/exceptions.py b/flow360/exceptions.py index 7bd02af79..ea6054ba2 100644 --- a/flow360/exceptions.py +++ b/flow360/exceptions.py @@ -80,6 +80,9 @@ class Flow360AuthenticationError(Flow360Error): class Flow360AuthorisationError(Flow360Error): """Error authenticating a user through webapi webAPI.""" + def __init__(self, message: str = None): + Exception.__init__(self, message) + class Flow360DataError(Flow360Error): """Error accessing data.""" diff --git a/flow360/user_config.py b/flow360/user_config.py index 134a06f51..2aeafc0f4 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=_normalize_storage_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,16 @@ def enable_validation(self): self._do_validation = True +def reload_user_config(): + """Reload the shared user-config object in place when possible.""" + + global UserConfig # pylint: disable=global-statement + + if isinstance(UserConfig, BasicUserConfig): + BasicUserConfig.__init__(UserConfig) + else: + UserConfig = BasicUserConfig() + return UserConfig + + UserConfig = BasicUserConfig() diff --git a/tests/data/mock_webapi/case_files_mock_response.json b/tests/data/mock_webapi/case_files_mock_response.json index 9886ffbb9..7e3a0f82b 100644 --- a/tests/data/mock_webapi/case_files_mock_response.json +++ b/tests/data/mock_webapi/case_files_mock_response.json @@ -2,7 +2,7 @@ "data": [ { "fileName": "results/monitor_massFluxExhaust_v2.csv", - "filePath": "results/monitor_massFluxExhaust_v2.csv", + "filePath": "users/AIDAXWCOWJGJINZTEEEIP/case-69b8c249-fce5-412a-9927-6a79049deebb/results/monitor_massFluxExhaust_v2.csv", "fileType": ".csv", "length": 2479, "storageClass": null, @@ -10,7 +10,7 @@ }, { "fileName": "results/monitor_massFluxIntake_v2.csv", - "filePath": "results/monitor_massFluxIntake_v2.csv", + "filePath": "users/AIDAXWCOWJGJINZTEEEIP/case-69b8c249-fce5-412a-9927-6a79049deebb/results/monitor_massFluxIntake_v2.csv", "fileType": ".csv", "length": 2479, "storageClass": null, @@ -18,7 +18,7 @@ }, { "fileName": "results/udd_massInflowController_Exhaust_v2.csv", - "filePath": "results/udd_massInflowController_Exhaust_v2.csv", + "filePath": "users/AIDAXWCOWJGJINZTEEEIP/case-69b8c249-fce5-412a-9927-6a79049deebb/results/udd_massInflowController_Exhaust_v2.csv", "fileType": ".csv", "length": 2479, "storageClass": null, @@ -26,7 +26,7 @@ }, { "fileName": "results/udd_massInflowController_Intake_v2.csv", - "filePath": "results/udd_massInflowController_Intake_v2.csv", + "filePath": "users/AIDAXWCOWJGJINZTEEEIP/case-69b8c249-fce5-412a-9927-6a79049deebb/results/udd_massInflowController_Intake_v2.csv", "fileType": ".csv", "length": 2479, "storageClass": null, @@ -34,7 +34,7 @@ }, { "fileName": "results/force_output_wing_all_planes_forces_v2.csv", - "filePath": "results/force_output_wing_all_planes_forces_v2.csv", + "filePath": "users/AIDAXWCOWJGJINZTEEEIP/case-69b8c249-fce5-412a-9927-6a79049deebb/results/force_output_wing_all_planes_forces_v2.csv", "fileType": ".csv", "length": 1524, "storageClass": null, @@ -42,7 +42,7 @@ }, { "fileName": "results/force_output_wing_all_planes_forces_moving_statistic_v2.csv", - "filePath": "results/force_output_wing_all_planes_forces_moving_statistic_v2.csv", + "filePath": "users/AIDAXWCOWJGJINZTEEEIP/case-69b8c249-fce5-412a-9927-6a79049deebb/results/force_output_wing_all_planes_forces_moving_statistic_v2.csv", "fileType": ".csv", "length": 1648, "storageClass": null, @@ -50,7 +50,7 @@ }, { "fileName": "results/force_distro_cumul_forceDistribution.csv", - "filePath": "results/force_distro_cumul_forceDistribution.csv", + "filePath": "users/AIDAXWCOWJGJINZTEEEIP/case-69b8c249-fce5-412a-9927-6a79049deebb/results/force_distro_cumul_forceDistribution.csv", "fileType": ".csv", "length": 50000, "storageClass": null, @@ -58,7 +58,7 @@ }, { "fileName": "results/ta_distro_forceDistribution.csv", - "filePath": "results/ta_distro_forceDistribution.csv", + "filePath": "users/AIDAXWCOWJGJINZTEEEIP/case-69b8c249-fce5-412a-9927-6a79049deebb/results/ta_distro_forceDistribution.csv", "fileType": ".csv", "length": 50000, "storageClass": null, @@ -66,7 +66,7 @@ }, { "fileName": "simulation.json", - "filePath": "simulation.json", + "filePath": "users/AIDAXWCOWJGJINZTEEEIP/case-69b8c249-fce5-412a-9927-6a79049deebb/simulation.json", "fileType": ".json", "length": 36806, "storageClass": null, diff --git a/tests/mock_server.py b/tests/mock_server.py index d0a6f401d..4114f80e1 100644 --- a/tests/mock_server.py +++ b/tests/mock_server.py @@ -216,25 +216,6 @@ def json(): return res -class MockResponseFolderListV2(MockResponse): - """response for GET /v2/folders""" - - @staticmethod - def json(): - with open(os.path.join(here, "data/mock_webapi/folder_at_root_meta_resp.json")) as fh: - root_folder = json.load(fh)["data"] - with open(os.path.join(here, "data/mock_webapi/folder_nested_meta_resp.json")) as fh: - nested_folder = json.load(fh)["data"] - return { - "data": { - "page": 0, - "size": 1000, - "total": 2, - "records": [root_folder, nested_folder], - } - } - - class MockResponseFolderMove(MockResponse): """response if moving to folder""" @@ -461,8 +442,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): @@ -473,10 +454,103 @@ def json(): with open( os.path.join(here, "data/mock_webapi/project_case_fork_simulation_json_resp.json") ) as fh: + simulation_json = json.load(fh) + return {"data": {"simulationJson": json.dumps(simulation_json)}} + + +class MockResponseDraftInfo(MockResponse): + """response for Draft.from_cloud(id="dft-84b20880-937d-4ef2-983b-7f75089f6dd6")'s meta json""" + + @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 Draft(id="dft-84b20880-937d-4ef2-983b-7f75089f6dd6").simulation""" + + @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 MockResponseFolderInfoAtRootV2(MockResponse): + """response for GET /v2/folders/folder-3834758b-3d39-4a4a-ad85-710b7652267c""" + + @staticmethod + def json(): + with open(os.path.join(here, "data/mock_webapi/folder_at_root_meta_resp.json")) as fh: + res = json.load(fh) + return res + + +class MockResponseFolderInfoNestedV2(MockResponse): + """response for GET /v2/folders/folder-4da3cdd0-c5b6-4130-9ca1-196237322ab9""" + + @staticmethod + def json(): + with open(os.path.join(here, "data/mock_webapi/folder_nested_meta_resp.json")) as fh: res = json.load(fh) return res +class MockResponseFolderListV2(MockResponse): + """response for GET /v2/folders""" + + @staticmethod + def json(): + with open(os.path.join(here, "data/mock_webapi/folder_at_root_meta_resp.json")) as fh: + root_folder = json.load(fh)["data"] + with open(os.path.join(here, "data/mock_webapi/folder_nested_meta_resp.json")) as fh: + nested_folder = json.load(fh)["data"] + return { + "data": { + "page": 0, + "size": 1000, + "total": 2, + "records": [root_folder, nested_folder], + } + } + + class MockResponseProjectRunCase(MockResponse): """response for project.run_case(params = params)'s meta json""" @@ -510,26 +584,25 @@ def json(self): class MockResponseDraftSubmit(MockResponse): - """response for Project(id="prj-41d2333b-85fd-4bed-ae13-15dcb6da519e")'s path to Fork Case json""" + """response for POST /v2/drafts""" def __init__(self, *args, params=None, **kwargs) -> None: super().__init__(*args, **kwargs) - self._params = params + self._params = params or {} def json(self): - res = None - if self._params["name"] == "VolumeMesh": - with open( - os.path.join(here, "data/mock_webapi/project_draft_volume_mesh_submit_resp.json") - ) as fh: - res = json.load(fh) - - if self._params["name"] == "Case": - with open( - os.path.join(here, "data/mock_webapi/project_draft_case_fork_submit_resp.json") - ) as fh: - res = json.load(fh) - return res + return { + "data": { + "id": "dft-84b20880-937d-4ef2-983b-7f75089f6dd6", + "name": self._params.get("name", "Draft 1"), + "projectId": self._params.get("projectId"), + "solverVersion": self._params.get("solverVersion"), + "sourceItemId": self._params.get("sourceItemId"), + "sourceItemType": self._params.get("sourceItemType"), + "forkCase": self._params.get("forkCase"), + "type": "Draft", + } + } class MockResponseDraftVolumeMeshRun(MockResponse): @@ -544,6 +617,24 @@ def json(): return res +class MockResponseDraftRunV2(MockResponse): + """response for POST /v2/drafts/{id}/run""" + + def __init__(self, *args, params=None, **kwargs) -> None: + super().__init__(*args, **kwargs) + self._params = params or {} + + def json(self): + up_to = self._params.get("upTo") + if up_to == "SurfaceMesh": + return MockResponseProjectSurfaceMesh.json() + if up_to == "VolumeMesh": + return MockResponseProjectVolumeMesh.json() + if up_to == "Case": + return MockResponseProjectCase.json() + return MockResponseInfoNotFound.json() + + class MockResponseProjectPatchDraftSubmit(MockResponse): def __init__(self, *args, params=None, **kwargs) -> None: @@ -553,11 +644,26 @@ def __init__(self, *args, params=None, **kwargs) -> None: def json(self): with open(os.path.join(here, "data/mock_webapi/project_meta_resp.json")) as fh: res = json.load(fh) - res["data"]["lastOpenItemId"] = self._params["lastOpenItemId"] - res["data"]["lastOpenItemType"] = self._params["lastOpenItemType"] + if "lastOpenItemId" in self._params: + res["data"]["lastOpenItemId"] = self._params["lastOpenItemId"] + if "lastOpenItemType" in self._params: + res["data"]["lastOpenItemType"] = self._params["lastOpenItemType"] + if "name" in self._params: + res["data"]["name"] = self._params["name"] return res +class MockResponseFolderPatchV2(MockResponse): + """response for PATCH /v2/folders/{id}""" + + def __init__(self, *args, params=None, **kwargs) -> None: + super().__init__(*args, **kwargs) + self._params = params or {} + + def json(self): + return {"data": dict(self._params)} + + class MockResponseReportSubmit(MockResponse): """response for report_template.create_in_cloud's meta json""" @@ -595,8 +701,6 @@ def json(): "/account": MockResponseOrganizationAccounts, "/folders/items/folder-3834758b-3d39-4a4a-ad85-710b7652267c/metadata": MockResponseFolderRootMetadata, "/folders/items/folder-4da3cdd0-c5b6-4130-9ca1-196237322ab9/metadata": MockResponseFolderNestedMetadata, - "/v2/folders/folder-3834758b-3d39-4a4a-ad85-710b7652267c": MockResponseFolderRootMetadata, - "/v2/folders/folder-4da3cdd0-c5b6-4130-9ca1-196237322ab9": MockResponseFolderNestedMetadata, "/v2/projects/prj-41d2333b-85fd-4bed-ae13-15dcb6da519e": MockResponseProject, "/v2/projects/prj-41d2333b-85fd-4bed-ae13-15dcb6da519e/dependency": MockResponseProjectEmptyDependency, "/v2/projects/prj-99cc6f96-15d3-4170-973c-a0cced6bf36b": MockResponseProjectFromVM, @@ -613,12 +717,18 @@ 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/folders/folder-3834758b-3d39-4a4a-ad85-710b7652267c": MockResponseFolderInfoAtRootV2, + "/v2/folders/folder-4da3cdd0-c5b6-4130-9ca1-196237322ab9": MockResponseFolderInfoNestedV2, "/v2/projects": MockResponseAllProjects, "/cases/case-666666666-66666666-666-6666666666666/files": MockResponseCaseFiles, + "/cases/case-69b8c249-fce5-412a-9927-6a79049deebb/files": MockResponseCaseFiles, } PUT_RESPONSE_MAP = { @@ -629,6 +739,7 @@ def json(): "/volumemeshes/00112233-4455-6677-8899-aabbccddeeff/case": MockResponseCaseSubmit, "/volumemeshes/00000000-0000-0000-0000-000000000000/case": MockResponseCaseSubmit, "/folders": MockResponseFolderSubmit, + "/v2/drafts/dft-84b20880-937d-4ef2-983b-7f75089f6dd6/simulation/file": MockResponse, "/v2/drafts/vm-7c3681cd-8c6c-4db7-a62c-1742d825e9d3/simulation/file": MockResponseProjectVolumeMeshSimConfig, "/v2/drafts/vm-7c3681cd-8c6c-4db7-a62c-1742d825e9d3/run": MockResponseProjectVolumeMesh, "/v2/drafts/case-84d4604e-f3cd-4c6b-8517-92a80a3346d3/simulation/file": MockResponseProjectCaseForkSimConfig, @@ -666,6 +777,9 @@ def mock_webapi(type, url, params): if method.endswith("/path"): return MockResponseProjectPath(params=params) + if method == "/v2/drafts": + return MockResponseDraftList(params=params) + if method == "/v2/folders": return MockResponseFolderListV2() @@ -683,12 +797,34 @@ def mock_webapi(type, url, params): if method == "/v2/drafts": return MockResponseDraftSubmit(params=params) + if method.startswith("/v2/drafts/") and method.endswith("/simulation/file"): + return MockResponse() + + if method.startswith("/v2/drafts/") and method.endswith("/run"): + return MockResponseDraftRunV2(params=params) + if method in POST_RESPONSE_MAP.keys(): return POST_RESPONSE_MAP[method]() elif type == "patch": if method.startswith("/v2/projects"): return MockResponseProjectPatchDraftSubmit(params=params) + if method.startswith("/v2/geometries/"): + return MockResponse() + if method.startswith("/v2/surface-meshes/"): + return MockResponse() + if method.startswith("/v2/volume-meshes/"): + return MockResponse() + if method.startswith("/v2/cases/"): + return MockResponse() + if method.startswith("/v2/drafts/"): + return MockResponse() + if method.startswith("/v2/folders/"): + return MockResponseFolderPatchV2(params=params) + + elif type == "delete": + if method.startswith("/v2/projects/"): + return MockResponse() return MockResponseInfoNotFound() @@ -722,6 +858,9 @@ def put(self, url, json=None, **kwargs): def post(self, url, json=None, **kwargs): return get_response(url, type="post", params=json, **kwargs) + def delete(self, url, **kwargs): + return get_response(url, type="delete", **kwargs) + monkeypatch.setattr( http_util, "api_key_auth", lambda: {"Authorization": None, "Application": "FLOW360"} ) diff --git a/tests/simulation/test_project_create.py b/tests/simulation/test_project_create.py new file mode 100644 index 000000000..4cfd70e84 --- /dev/null +++ b/tests/simulation/test_project_create.py @@ -0,0 +1,26 @@ +import flow360.component.project as project_module + +from flow360.component.project import Project + + +def test_project_from_geometry_passes_description_to_draft_submit(monkeypatch, tmp_path): + geometry_file = tmp_path / "wing.csm" + geometry_file.write_text("solid") + calls = {} + + class FakeDraft: + def submit(self, description="", run_async=False): + calls["description"] = description + calls["run_async"] = run_async + return type("FakeRootAsset", (), {"project_id": "prj-123"})() + + monkeypatch.setattr(project_module.Geometry, "from_file", lambda *args, **kwargs: FakeDraft()) + + project_id = Project.from_geometry( + str(geometry_file), + description="demo project", + run_async=True, + ) + + assert project_id == "prj-123" + assert calls == {"description": "demo project", "run_async": True} diff --git a/tests/simulation/test_project_lazy.py b/tests/simulation/test_project_lazy.py new file mode 100644 index 000000000..35939a56f --- /dev/null +++ b/tests/simulation/test_project_lazy.py @@ -0,0 +1,86 @@ +from flow360.component.project import Project, ProjectMeta, RootType + + +def test_from_cloud_lazy_does_not_fetch_until_requested(monkeypatch): + calls = [] + project_id = "prj-12345678-1234-1234-1234-123456789abc" + geometry_id = "geo-12345678-1234-1234-1234-123456789abc" + + def fake_get(self, path=None, method=None, json=None, params=None): + calls.append(method) + if method == "tree": + return { + "records": [ + { + "createdAt": "2025-01-01T00:00:00Z", + "displayStatus": "completed", + "id": geometry_id, + "name": "Wing", + "parentCaseId": None, + "parentFolderId": "ROOT.FLOW360", + "parentId": None, + "parentItemName": None, + "parentItemProjectId": None, + "parentItemTye": None, + "postProcessStatus": None, + "postProcessedAt": None, + "projectId": project_id, + "requestStopAt": None, + "runSequence": "run-1", + "solverFinishAt": None, + "solverStartAt": None, + "solverVersion": "release-25.2", + "status": "processed", + "tags": None, + "type": "Geometry", + "updatedAt": "2025-01-01T00:00:01Z", + "userId": "user-1", + "viewed": False, + "visualizationStatus": None, + "visualizedAt": None, + } + ] + } + return { + "userId": "user-1", + "id": project_id, + "name": "Wing Study", + "tags": ["demo"], + "rootItemId": geometry_id, + "rootItemType": "Geometry", + } + + monkeypatch.setattr("flow360.component.project.RestApi.get", fake_get) + + project = Project.from_cloud(project_id, lazy_load=True) + + assert calls == [] + tree = project.get_project_tree() + assert tree.root.asset_id == geometry_id + assert calls == ["tree"] + + +def test_lazy_project_metadata_fetches_only_metadata(monkeypatch): + calls = [] + project_id = "prj-12345678-1234-1234-1234-123456789abc" + geometry_id = "geo-12345678-1234-1234-1234-123456789abc" + + def fake_get(self, path=None, method=None, json=None, params=None): + calls.append(method) + return { + "userId": "user-1", + "id": project_id, + "name": "Wing Study", + "tags": ["demo"], + "rootItemId": geometry_id, + "rootItemType": "Geometry", + } + + monkeypatch.setattr("flow360.component.project.RestApi.get", fake_get) + + project = Project.from_cloud(project_id, lazy_load=True) + meta = project.get_metadata() + + assert isinstance(meta, ProjectMeta) + assert meta.root_item_type is RootType.GEOMETRY + assert calls == [None] diff --git a/tests/v1/test_cli_assets.py b/tests/v1/test_cli_assets.py new file mode 100644 index 000000000..f8b94ab54 --- /dev/null +++ b/tests/v1/test_cli_assets.py @@ -0,0 +1,738 @@ +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 "simulation" in result.output + assert "results" 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 "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 "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 "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_rename_outputs_metadata(monkeypatch): + from flow360.cli import assets as assets_cli + + runner = CliRunner() + calls = {} + monkeypatch.setattr( + assets_cli, + "_rename_asset", + lambda webapi_cls, asset_id, new_name: calls.update( + {"webapi_cls": webapi_cls, "asset_id": asset_id, "new_name": new_name} + ), + ) + + result = runner.invoke(flow360, ["geometry", "rename", "geo-123", "--name", "Wing Renamed"]) + + assert result.exit_code == 0 + assert json.loads(result.output) == {"id": "geo-123", "name": "Wing Renamed"} + assert calls["asset_id"] == "geo-123" + assert calls["new_name"] == "Wing Renamed" + + +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_rename_outputs_metadata(monkeypatch): + from flow360.cli import assets as assets_cli + + runner = CliRunner() + calls = {} + monkeypatch.setattr( + assets_cli, + "_rename_asset", + lambda webapi_cls, asset_id, new_name: calls.update( + {"webapi_cls": webapi_cls, "asset_id": asset_id, "new_name": new_name} + ), + ) + + result = runner.invoke( + flow360, + ["surface-mesh", "rename", "sm-123", "--name", "Surface Mesh Renamed"], + ) + + assert result.exit_code == 0 + assert json.loads(result.output) == {"id": "sm-123", "name": "Surface Mesh Renamed"} + assert calls["asset_id"] == "sm-123" + assert calls["new_name"] == "Surface Mesh Renamed" + + +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_rename_outputs_metadata(monkeypatch): + from flow360.cli import assets as assets_cli + + runner = CliRunner() + calls = {} + monkeypatch.setattr( + assets_cli, + "_rename_asset", + lambda webapi_cls, asset_id, new_name: calls.update( + {"webapi_cls": webapi_cls, "asset_id": asset_id, "new_name": new_name} + ), + ) + + result = runner.invoke( + flow360, + ["volume-mesh", "rename", "vm-123", "--name", "Volume Mesh Renamed"], + ) + + assert result.exit_code == 0 + assert json.loads(result.output) == {"id": "vm-123", "name": "Volume Mesh Renamed"} + assert calls["asset_id"] == "vm-123" + assert calls["new_name"] == "Volume Mesh Renamed" + + +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_rename_outputs_metadata(monkeypatch): + from flow360.cli import assets as assets_cli + + runner = CliRunner() + calls = {} + monkeypatch.setattr( + assets_cli, + "_rename_asset", + lambda webapi_cls, asset_id, new_name: calls.update( + {"webapi_cls": webapi_cls, "asset_id": asset_id, "new_name": new_name} + ), + ) + + result = runner.invoke(flow360, ["case", "rename", "case-123", "--name", "Alpha -18"]) + + assert result.exit_code == 0 + assert json.loads(result.output) == {"id": "case-123", "name": "Alpha -18"} + assert calls["asset_id"] == "case-123" + assert calls["new_name"] == "Alpha -18" + + +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" + + +def test_case_results_list_outputs_only_result_artifacts(monkeypatch): + from flow360.cli import assets as assets_cli + + runner = CliRunner() + monkeypatch.setattr( + assets_cli, + "_list_case_results", + lambda case_id: [ + { + "fileName": "results/total_forces_v2.csv", + "filePath": "results/total_forces_v2.csv", + "fileType": ".csv", + "length": 1024, + "updatedAt": "2025-01-01T01:00:00Z", + }, + { + "fileName": "results/nonlinear_residual_v2.csv", + "filePath": "results/nonlinear_residual_v2.csv", + "fileType": ".csv", + "length": 2048, + "updatedAt": None, + }, + ], + ) + + result = runner.invoke(flow360, ["case", "results", "list", "case-123"]) + + assert result.exit_code == 0 + assert json.loads(result.output) == { + "records": [ + { + "name": "total_forces_v2.csv", + "path": "results/total_forces_v2.csv", + "file_type": ".csv", + "size_bytes": 1024, + "updated_at": "2025-01-01T01:00:00Z", + }, + { + "name": "nonlinear_residual_v2.csv", + "path": "results/nonlinear_residual_v2.csv", + "file_type": ".csv", + "size_bytes": 2048, + "updated_at": None, + }, + ] + } + + +def test_case_results_ls_alias_outputs_only_result_artifacts(monkeypatch): + from flow360.cli import assets as assets_cli + + runner = CliRunner() + monkeypatch.setattr( + assets_cli, + "_list_case_results", + lambda case_id: [ + { + "fileName": "results/total_forces_v2.csv", + "filePath": "results/total_forces_v2.csv", + "fileType": ".csv", + "length": 1024, + "updatedAt": "2025-01-01T01:00:00Z", + } + ], + ) + + result = runner.invoke(flow360, ["case", "results", "ls", "case-123"]) + + assert result.exit_code == 0 + assert json.loads(result.output)["records"][0]["path"] == "results/total_forces_v2.csv" + + +def test_case_result_serialization_normalizes_full_storage_path(): + from flow360.cli import assets as assets_cli + + record = { + "fileName": "results/total_forces_v2.csv", + "filePath": "users/AID/case-123/results/total_forces_v2.csv", + "fileType": ".csv", + "length": 1024, + "updatedAt": "2025-01-01T01:00:00Z", + } + + assert assets_cli._serialize_case_result(record) == { + "name": "total_forces_v2.csv", + "path": "results/total_forces_v2.csv", + "file_type": ".csv", + "size_bytes": 1024, + "updated_at": "2025-01-01T01:00:00Z", + } + + +def test_case_results_get_downloads_selected_artifact(monkeypatch): + from flow360.cli import assets as assets_cli + + runner = CliRunner() + monkeypatch.setattr( + assets_cli, + "_resolve_case_result", + lambda case_id, result_ref: { + "fileName": "results/total_forces_v2.csv", + "filePath": "results/total_forces_v2.csv", + "fileType": ".csv", + "length": 1024, + "updatedAt": "2025-01-01T01:00:00Z", + }, + ) + monkeypatch.setattr( + assets_cli, + "_download_case_result", + lambda case_id, result_path, to_path=None, overwrite=False: "/tmp/total_forces_v2.csv", + ) + + result = runner.invoke(flow360, ["case", "results", "get", "case-123", "total_forces_v2.csv"]) + + assert result.exit_code == 0 + assert json.loads(result.output) == { + "case_id": "case-123", + "result": { + "name": "total_forces_v2.csv", + "path": "results/total_forces_v2.csv", + "file_type": ".csv", + "size_bytes": 1024, + "updated_at": "2025-01-01T01:00:00Z", + }, + "saved_to": "/tmp/total_forces_v2.csv", + } + + +def test_case_results_get_normalizes_storage_path_before_download(monkeypatch): + from flow360.cli import assets as assets_cli + + runner = CliRunner() + captured = {} + monkeypatch.setattr( + assets_cli, + "_resolve_case_result", + lambda case_id, result_ref: { + "fileName": "results/total_forces_v2.csv", + "filePath": "users/AID/case-123/results/total_forces_v2.csv", + "fileType": ".csv", + "length": 1024, + "updatedAt": "2025-01-01T01:00:00Z", + }, + ) + + def _fake_download(case_id, result_path, to_path=None, overwrite=False): + captured["case_id"] = case_id + captured["result_path"] = result_path + return "/tmp/total_forces_v2.csv" + + monkeypatch.setattr(assets_cli, "_download_case_result", _fake_download) + + result = runner.invoke(flow360, ["case", "results", "get", "case-123", "total_forces_v2.csv"]) + + assert result.exit_code == 0 + assert captured == { + "case_id": "case-123", + "result_path": "results/total_forces_v2.csv", + } + assert json.loads(result.output) == { + "case_id": "case-123", + "result": { + "name": "total_forces_v2.csv", + "path": "results/total_forces_v2.csv", + "file_type": ".csv", + "size_bytes": 1024, + "updated_at": "2025-01-01T01:00:00Z", + }, + "saved_to": "/tmp/total_forces_v2.csv", + } diff --git a/tests/v1/test_cli_auth_guidance.py b/tests/v1/test_cli_auth_guidance.py new file mode 100644 index 000000000..a4244b83e --- /dev/null +++ b/tests/v1/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/v1/test_cli_draft.py b/tests/v1/test_cli_draft.py new file mode 100644 index 000000000..aea49ec32 --- /dev/null +++ b/tests/v1/test_cli_draft.py @@ -0,0 +1,817 @@ +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 "create" in result.output + assert "info" in result.output + assert "state" in result.output + assert "run" 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_rename_outputs_metadata(monkeypatch): + from flow360.cli import draft as draft_cli + + runner = CliRunner() + calls = {} + monkeypatch.setattr( + draft_cli, + "_rename_draft", + lambda draft_id, new_name: calls.update({"draft_id": draft_id, "new_name": new_name}), + ) + + result = runner.invoke(flow360, ["draft", "rename", "dft-123", "--name", "Alpha Sweep 1"]) + + assert result.exit_code == 0 + assert json.loads(result.output) == {"id": "dft-123", "name": "Alpha Sweep 1"} + assert calls == {"draft_id": "dft-123", "new_name": "Alpha Sweep 1"} + + +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" + + +def test_draft_simulation_set_reads_json_and_updates(monkeypatch, tmp_path): + from flow360.cli import draft as draft_cli + + runner = CliRunner() + calls = {} + file_path = tmp_path / "params.json" + file_path.write_text('{"version":"24.11.0","unit_system":{"name":"SI"}}') + + monkeypatch.setattr( + draft_cli, + "_set_draft_simulation_json", + lambda draft_id, simulation_json: calls.update( + {"draft_id": draft_id, "simulation_json": simulation_json} + ), + ) + + result = runner.invoke( + flow360, + ["draft", "simulation", "set", "dft-123", str(file_path)], + ) + + assert result.exit_code == 0 + assert json.loads(result.output) == {"id": "dft-123", "updated": True} + assert calls == { + "draft_id": "dft-123", + "simulation_json": {"version": "24.11.0", "unit_system": {"name": "SI"}}, + } + + +def test_draft_simulation_set_rejects_invalid_json(tmp_path): + runner = CliRunner() + file_path = tmp_path / "params.json" + file_path.write_text("{not-json}") + + result = runner.invoke( + flow360, + ["draft", "simulation", "set", "dft-123", str(file_path)], + ) + + assert result.exit_code != 0 + assert f"Invalid JSON in {file_path}" in result.output + + +def test_draft_run_outputs_result_metadata(monkeypatch): + from flow360.cli import draft as draft_cli + + runner = CliRunner() + monkeypatch.setattr( + draft_cli, + "_run_draft", + lambda draft_id, up_to: { + "id": "vm-123", + "name": "Volume Mesh", + "projectId": "prj-123", + "parentId": "sm-123", + "solverVersion": "release-25.2", + "status": "queued", + "tags": ["demo"], + "type": "VolumeMesh", + "createdAt": "2025-01-01T00:00:00Z", + "updatedAt": "2025-01-01T01:00:00Z", + }, + ) + + result = runner.invoke(flow360, ["draft", "run", "dft-123", "--up-to", "volume-mesh"]) + + assert result.exit_code == 0 + payload = json.loads(result.output) + assert payload == { + "created_at": "2025-01-01T00:00:00Z", + "id": "vm-123", + "name": "Volume Mesh", + "parent_id": "sm-123", + "project_id": "prj-123", + "solver_version": "release-25.2", + "status": "queued", + "tags": ["demo"], + "type": "VolumeMesh", + "updated_at": "2025-01-01T01:00:00Z", + } + + +def test_wait_for_resource_state_polls_until_terminal(monkeypatch): + from flow360.cli import resource_state + + states = iter( + [ + { + "id": "vm-123", + "type": "VolumeMesh", + "status": "queued", + "is_terminal": False, + "is_success": False, + "updated_at": "2025-01-01T00:00:00Z", + }, + { + "id": "vm-123", + "type": "VolumeMesh", + "status": "completed", + "is_terminal": True, + "is_success": True, + "updated_at": "2025-01-01T00:00:02Z", + }, + ] + ) + sleeps = [] + monotonic_values = iter([0.0, 0.5]) + + monkeypatch.setattr(resource_state, "get_resource_state", lambda resource_id: next(states)) + monkeypatch.setattr(resource_state.time, "sleep", lambda interval: sleeps.append(interval)) + monkeypatch.setattr(resource_state.time, "monotonic", lambda: next(monotonic_values)) + + state = resource_state.wait_for_resource_state("vm-123", timeout=10.0, poll_interval=2.0) + + assert state["status"] == "completed" + assert sleeps == [2.0] + + +def test_draft_run_wait_outputs_result_and_state(monkeypatch): + from flow360.cli import draft as draft_cli + + runner = CliRunner() + monkeypatch.setattr( + draft_cli, + "_run_draft", + lambda draft_id, up_to: { + "id": "vm-123", + "name": "Volume Mesh", + "projectId": "prj-123", + "parentId": "sm-123", + "solverVersion": "release-25.2", + "status": "queued", + "tags": ["demo"], + "type": "VolumeMesh", + "createdAt": "2025-01-01T00:00:00Z", + "updatedAt": "2025-01-01T01:00:00Z", + }, + ) + monkeypatch.setattr( + draft_cli, + "_wait_for_resource_state", + lambda resource_id, timeout, poll_interval: { + "id": resource_id, + "type": "VolumeMesh", + "status": "completed", + "is_terminal": True, + "is_success": True, + "updated_at": "2025-01-01T01:00:02Z", + }, + ) + + result = runner.invoke( + flow360, + ["draft", "run", "dft-123", "--up-to", "volume-mesh", "--wait"], + ) + + assert result.exit_code == 0 + assert json.loads(result.output) == { + "result": { + "created_at": "2025-01-01T00:00:00Z", + "id": "vm-123", + "name": "Volume Mesh", + "parent_id": "sm-123", + "project_id": "prj-123", + "solver_version": "release-25.2", + "status": "queued", + "tags": ["demo"], + "type": "VolumeMesh", + "updated_at": "2025-01-01T01:00:00Z", + }, + "state": { + "id": "vm-123", + "type": "VolumeMesh", + "status": "completed", + "is_terminal": True, + "is_success": True, + "updated_at": "2025-01-01T01:00:02Z", + }, + } + + +def test_draft_run_wait_failed_state_exits_nonzero(monkeypatch): + from flow360.cli import draft as draft_cli + + runner = CliRunner() + monkeypatch.setattr( + draft_cli, + "_run_draft", + lambda draft_id, up_to: { + "id": "vm-123", + "name": "Volume Mesh", + "projectId": "prj-123", + "parentId": "sm-123", + "solverVersion": "release-25.2", + "status": "queued", + "tags": [], + "type": "VolumeMesh", + "createdAt": "2025-01-01T00:00:00Z", + "updatedAt": "2025-01-01T01:00:00Z", + }, + ) + monkeypatch.setattr( + draft_cli, + "_wait_for_resource_state", + lambda resource_id, timeout, poll_interval: { + "id": resource_id, + "type": "VolumeMesh", + "status": "failed", + "is_terminal": True, + "is_success": False, + "updated_at": "2025-01-01T01:00:02Z", + }, + ) + + result = runner.invoke( + flow360, + ["draft", "run", "dft-123", "--up-to", "volume-mesh", "--wait"], + ) + + assert result.exit_code == 1 + payload = json.loads(result.output) + assert payload["state"]["status"] == "failed" + assert payload["state"]["is_success"] is False + + +def test_draft_run_wait_timeout_exits_124(monkeypatch): + from flow360.cli import draft as draft_cli + + runner = CliRunner() + monkeypatch.setattr( + draft_cli, + "_run_draft", + lambda draft_id, up_to: { + "id": "vm-123", + "name": "Volume Mesh", + "projectId": "prj-123", + "parentId": "sm-123", + "solverVersion": "release-25.2", + "status": "queued", + "tags": [], + "type": "VolumeMesh", + "createdAt": "2025-01-01T00:00:00Z", + "updatedAt": "2025-01-01T01:00:00Z", + }, + ) + monkeypatch.setattr( + draft_cli, + "_wait_for_resource_state", + lambda resource_id, timeout, poll_interval: (_ for _ in ()).throw( + draft_cli.WaitTimeoutError( + { + "id": resource_id, + "type": "VolumeMesh", + "status": "running", + "is_terminal": False, + "is_success": False, + "updated_at": "2025-01-01T01:00:02Z", + } + ) + ), + ) + + result = runner.invoke( + flow360, + ["draft", "run", "dft-123", "--up-to", "volume-mesh", "--wait"], + ) + + assert result.exit_code == 124 + payload = json.loads(result.output) + assert payload["timed_out"] is True + assert payload["state"]["status"] == "running" + + +def test_draft_run_rejects_non_draft_id(): + runner = CliRunner() + + result = runner.invoke(flow360, ["draft", "run", "prj-123", "--up-to", "volume-mesh"]) + + assert result.exit_code != 0 + assert "Simulation JSON path or --patch is required when running from a non-draft ref." in result.output + + +def test_draft_create_outputs_metadata(monkeypatch): + from flow360.cli import draft as draft_cli + + runner = CliRunner() + monkeypatch.setattr( + draft_cli, + "_create_draft_from_ref", + lambda ref_id, name=None: { + "id": "dft-123", + "name": name, + "projectId": "prj-123", + "solverVersion": "release-25.2", + "sourceItemId": "geo-123", + "sourceItemType": "Geometry", + "forkCase": False, + "type": "Draft", + }, + ) + + result = runner.invoke(flow360, ["draft", "create", "prj-123", "--name", "Draft 1"]) + + assert result.exit_code == 0 + assert json.loads(result.output) == { + "fork_case": False, + "id": "dft-123", + "name": "Draft 1", + "project_id": "prj-123", + "solver_version": "release-25.2", + "source_item_id": "geo-123", + "source_item_type": "Geometry", + "type": "Draft", + } + + +def test_draft_run_from_project_creates_sets_and_runs(monkeypatch, tmp_path): + from flow360.cli import draft as draft_cli + + runner = CliRunner() + simulation_path = tmp_path / "simulation.json" + simulation_path.write_text('{"version":"24.11.0","unit_system":{"name":"SI"}}') + calls = {} + + monkeypatch.setattr( + draft_cli, + "_resolve_draft_source", + lambda ref_id: { + "project_id": "prj-123", + "source_item_id": "geo-123", + "source_item_type": "Geometry", + "solver_version": "release-25.2", + "fork_case": False, + }, + ) + monkeypatch.setattr( + draft_cli, + "_create_draft_from_source", + lambda source, name=None: { + "id": "dft-123", + "name": name or "Draft 1", + "projectId": source["project_id"], + "solverVersion": source["solver_version"], + "sourceItemId": source["source_item_id"], + "sourceItemType": source["source_item_type"], + "forkCase": source["fork_case"], + "type": "Draft", + }, + ) + monkeypatch.setattr( + draft_cli, + "_set_draft_simulation_json", + lambda draft_id, simulation_json: calls.update( + {"draft_id": draft_id, "simulation_json": simulation_json} + ), + ) + monkeypatch.setattr( + draft_cli, + "_run_draft", + lambda draft_id, up_to: { + "id": "vm-123", + "name": "Volume Mesh", + "projectId": "prj-123", + "parentId": "sm-123", + "solverVersion": "release-25.2", + "status": "queued", + "tags": ["demo"], + "type": "VolumeMesh", + "createdAt": "2025-01-01T00:00:00Z", + "updatedAt": "2025-01-01T01:00:00Z", + }, + ) + + result = runner.invoke( + flow360, + ["draft", "run", "prj-123", str(simulation_path), "--name", "Alpha -18", "--up-to", "volume-mesh"], + ) + + assert result.exit_code == 0 + assert calls == { + "draft_id": "dft-123", + "simulation_json": {"version": "24.11.0", "unit_system": {"name": "SI"}}, + } + assert json.loads(result.output) == { + "draft": { + "fork_case": False, + "id": "dft-123", + "name": "Alpha -18", + "project_id": "prj-123", + "solver_version": "release-25.2", + "source_item_id": "geo-123", + "source_item_type": "Geometry", + "type": "Draft", + }, + "result": { + "created_at": "2025-01-01T00:00:00Z", + "id": "vm-123", + "name": "Volume Mesh", + "parent_id": "sm-123", + "project_id": "prj-123", + "solver_version": "release-25.2", + "status": "queued", + "tags": ["demo"], + "type": "VolumeMesh", + "updated_at": "2025-01-01T01:00:00Z", + }, + } + + +def test_draft_run_from_project_with_wait_outputs_draft_result_and_state(monkeypatch, tmp_path): + from flow360.cli import draft as draft_cli + + runner = CliRunner() + simulation_path = tmp_path / "simulation.json" + simulation_path.write_text('{"version":"24.11.0","unit_system":{"name":"SI"}}') + + monkeypatch.setattr( + draft_cli, + "_resolve_draft_source", + lambda ref_id: { + "project_id": "prj-123", + "source_item_id": "geo-123", + "source_item_type": "Geometry", + "solver_version": "release-25.2", + "fork_case": False, + }, + ) + monkeypatch.setattr( + draft_cli, + "_create_draft_from_source", + lambda source, name=None: { + "id": "dft-123", + "name": "Draft 1", + "projectId": source["project_id"], + "solverVersion": source["solver_version"], + "sourceItemId": source["source_item_id"], + "sourceItemType": source["source_item_type"], + "forkCase": source["fork_case"], + "type": "Draft", + }, + ) + monkeypatch.setattr(draft_cli, "_set_draft_simulation_json", lambda draft_id, simulation_json: None) + monkeypatch.setattr( + draft_cli, + "_run_draft", + lambda draft_id, up_to: { + "id": "vm-123", + "name": "Volume Mesh", + "projectId": "prj-123", + "parentId": "sm-123", + "solverVersion": "release-25.2", + "status": "queued", + "tags": ["demo"], + "type": "VolumeMesh", + "createdAt": "2025-01-01T00:00:00Z", + "updatedAt": "2025-01-01T01:00:00Z", + }, + ) + monkeypatch.setattr( + draft_cli, + "_wait_for_resource_state", + lambda resource_id, timeout, poll_interval: { + "id": resource_id, + "type": "VolumeMesh", + "status": "completed", + "is_terminal": True, + "is_success": True, + "updated_at": "2025-01-01T01:00:02Z", + }, + ) + + result = runner.invoke( + flow360, + ["draft", "run", "prj-123", str(simulation_path), "--up-to", "volume-mesh", "--wait"], + ) + + assert result.exit_code == 0 + payload = json.loads(result.output) + assert payload["draft"]["id"] == "dft-123" + assert payload["result"]["id"] == "vm-123" + assert payload["state"]["status"] == "completed" + + +def test_draft_run_existing_draft_rejects_simulation_path(tmp_path): + runner = CliRunner() + simulation_path = tmp_path / "simulation.json" + simulation_path.write_text('{"version":"24.11.0"}') + + result = runner.invoke( + flow360, + ["draft", "run", "dft-123", str(simulation_path), "--up-to", "volume-mesh"], + ) + + assert result.exit_code != 0 + assert ( + "Simulation JSON, patch, or name cannot be passed when running an existing draft." + in result.output + ) + + +def test_draft_run_existing_draft_rejects_name(): + runner = CliRunner() + + result = runner.invoke( + flow360, + ["draft", "run", "dft-123", "--name", "Alpha -18", "--up-to", "volume-mesh"], + ) + + assert result.exit_code != 0 + assert ( + "Simulation JSON, patch, or name cannot be passed when running an existing draft." + in result.output + ) + + +def test_draft_run_from_project_patch_creates_sets_and_runs(monkeypatch, tmp_path): + from flow360.cli import draft as draft_cli + + runner = CliRunner() + patch_path = tmp_path / "patch.json" + patch_path.write_text('{"meshing":{"refinement_factor":2.5}}') + calls = {} + + monkeypatch.setattr( + draft_cli, + "_resolve_draft_source", + lambda ref_id: { + "project_id": "prj-123", + "source_item_id": "geo-123", + "source_item_type": "Geometry", + "solver_version": "release-25.2", + "fork_case": False, + }, + ) + monkeypatch.setattr( + draft_cli, + "_apply_patch_to_source_simulation", + lambda source, patch_json: { + "version": "24.11.0", + "meshing": { + "defaults": {"first_layer_thickness": 0.1}, + "refinement_factor": patch_json["meshing"]["refinement_factor"], + }, + }, + ) + monkeypatch.setattr( + draft_cli, + "_create_draft_from_source", + lambda source, name=None: { + "id": "dft-123", + "name": "Draft 1", + "projectId": source["project_id"], + "solverVersion": source["solver_version"], + "sourceItemId": source["source_item_id"], + "sourceItemType": source["source_item_type"], + "forkCase": source["fork_case"], + "type": "Draft", + }, + ) + monkeypatch.setattr( + draft_cli, + "_set_draft_simulation_json", + lambda draft_id, simulation_json: calls.update( + {"draft_id": draft_id, "simulation_json": simulation_json} + ), + ) + monkeypatch.setattr( + draft_cli, + "_run_draft", + lambda draft_id, up_to: { + "id": "vm-123", + "name": "Volume Mesh", + "projectId": "prj-123", + "parentId": "sm-123", + "solverVersion": "release-25.2", + "status": "queued", + "tags": ["demo"], + "type": "VolumeMesh", + "createdAt": "2025-01-01T00:00:00Z", + "updatedAt": "2025-01-01T01:00:00Z", + }, + ) + + result = runner.invoke( + flow360, + ["draft", "run", "prj-123", "--patch", str(patch_path), "--up-to", "volume-mesh"], + ) + + assert result.exit_code == 0 + assert calls == { + "draft_id": "dft-123", + "simulation_json": { + "version": "24.11.0", + "meshing": { + "defaults": {"first_layer_thickness": 0.1}, + "refinement_factor": 2.5, + }, + }, + } + + +def test_draft_run_rejects_simulation_and_patch_together(tmp_path): + runner = CliRunner() + simulation_path = tmp_path / "simulation.json" + patch_path = tmp_path / "patch.json" + simulation_path.write_text('{"version":"24.11.0"}') + patch_path.write_text('{"meshing":{"refinement_factor":2.5}}') + + result = runner.invoke( + flow360, + [ + "draft", + "run", + "prj-123", + str(simulation_path), + "--patch", + str(patch_path), + "--up-to", + "volume-mesh", + ], + ) + + assert result.exit_code != 0 + assert "Provide either a full simulation JSON path or --patch, not both." in result.output diff --git a/tests/v1/test_cli_folder.py b/tests/v1/test_cli_folder.py new file mode 100644 index 000000000..70db96972 --- /dev/null +++ b/tests/v1/test_cli_folder.py @@ -0,0 +1,169 @@ +import json + +from click.testing import CliRunner + +from flow360.cli import flow360 + + +def test_folder_group_help_shows_read_commands(): + runner = CliRunner() + + result = runner.invoke(flow360, ["folder", "--help"]) + + assert result.exit_code == 0 + assert "get" in result.output + assert "tree" in result.output + assert "create" in result.output + assert "rename" in result.output + assert "move" in result.output + + +def test_folder_get_outputs_metadata(monkeypatch): + from flow360.cli import folder as folder_cli + + runner = CliRunner() + monkeypatch.setattr( + folder_cli, + "_get_folder_info", + lambda folder_id: { + "id": folder_id, + "name": "Folder A", + "parentFolderId": "ROOT.FLOW360", + "type": "folder", + "tags": ["demo"], + "createdAt": "2025-01-01T00:00:00Z", + "updatedAt": "2025-01-01T01:00:00Z", + "parentFolders": [{"id": "ROOT.FLOW360", "name": "My workspace"}], + }, + ) + + result = runner.invoke(flow360, ["folder", "get", "folder-123"]) + + assert result.exit_code == 0 + payload = json.loads(result.output) + assert payload["id"] == "folder-123" + assert payload["parent_id"] == "ROOT.FLOW360" + assert payload["type"] == "folder" + + +def test_folder_tree_outputs_nested_tree(monkeypatch): + from flow360.cli import folder as folder_cli + + runner = CliRunner() + monkeypatch.setattr( + folder_cli, + "_get_folder_tree", + lambda folder_id: { + "id": folder_id, + "name": "My workspace", + "subfolders": [ + { + "id": "folder-123", + "name": "Folder A", + "subfolders": [], + } + ], + }, + ) + + result = runner.invoke(flow360, ["folder", "tree"]) + + assert result.exit_code == 0 + payload = json.loads(result.output) + assert payload["root"]["id"] == "ROOT.FLOW360" + assert payload["root"]["subfolders"][0]["id"] == "folder-123" + + +def test_folder_create_outputs_metadata(monkeypatch): + from flow360.cli import folder as folder_cli + + runner = CliRunner() + calls = {} + monkeypatch.setattr( + folder_cli, + "_create_folder", + lambda name, parent_folder_id="ROOT.FLOW360", tags=None: calls.update( + { + "name": name, + "parent_folder_id": parent_folder_id, + "tags": tags, + } + ) + or { + "id": "folder-123", + "name": name, + "parentFolderId": parent_folder_id, + "type": "folder", + "tags": list(tags or []), + "createdAt": "2025-01-01T00:00:00Z", + "updatedAt": "2025-01-01T01:00:00Z", + "parentFolders": [], + }, + ) + + result = runner.invoke( + flow360, + [ + "folder", + "create", + "--name", + "Folder A", + "--parent-folder-id", + "ROOT.FLOW360", + "--tag", + "demo", + ], + ) + + assert result.exit_code == 0 + payload = json.loads(result.output) + assert payload["id"] == "folder-123" + assert payload["name"] == "Folder A" + assert calls == { + "name": "Folder A", + "parent_folder_id": "ROOT.FLOW360", + "tags": ("demo",), + } + + +def test_folder_rename_outputs_metadata(monkeypatch): + from flow360.cli import folder as folder_cli + + runner = CliRunner() + calls = {} + monkeypatch.setattr( + folder_cli, + "_rename_folder", + lambda folder_id, new_name: calls.update({"folder_id": folder_id, "new_name": new_name}), + ) + + result = runner.invoke(flow360, ["folder", "rename", "folder-123", "--name", "Renamed"]) + + assert result.exit_code == 0 + payload = json.loads(result.output) + assert payload == {"id": "folder-123", "name": "Renamed"} + assert calls == {"folder_id": "folder-123", "new_name": "Renamed"} + + +def test_folder_move_outputs_metadata(monkeypatch): + from flow360.cli import folder as folder_cli + + runner = CliRunner() + calls = {} + monkeypatch.setattr( + folder_cli, + "_move_folder", + lambda folder_id, parent_folder_id: calls.update( + {"folder_id": folder_id, "parent_folder_id": parent_folder_id} + ), + ) + + result = runner.invoke( + flow360, + ["folder", "move", "folder-123", "--parent-folder-id", "folder-456"], + ) + + assert result.exit_code == 0 + payload = json.loads(result.output) + assert payload == {"id": "folder-123", "parent_id": "folder-456"} + assert calls == {"folder_id": "folder-123", "parent_folder_id": "folder-456"} diff --git a/tests/v1/test_cli_open.py b/tests/v1/test_cli_open.py new file mode 100644 index 000000000..f13745b4c --- /dev/null +++ b/tests/v1/test_cli_open.py @@ -0,0 +1,162 @@ +import json + +from click.testing import CliRunner +import pytest + +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 open_resource as open_cli + from flow360.cli import browser_links + + 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 open_resource as open_cli + from flow360.cli import browser_links + + 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 open_resource as open_cli + from flow360.cli import browser_links + + 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 open_resource as open_cli + from flow360.cli import browser_links + + 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/v1/test_cli_project.py b/tests/v1/test_cli_project.py new file mode 100644 index 000000000..b15da66f8 --- /dev/null +++ b/tests/v1/test_cli_project.py @@ -0,0 +1,574 @@ +import json +from types import SimpleNamespace + +from click.testing import CliRunner + +from flow360.cli import flow360 + + +def test_project_group_help_shows_read_commands(): + runner = CliRunner() + + result = runner.invoke(flow360, ["project", "--help"]) + + assert result.exit_code == 0 + assert "list" in result.output + assert "create" in result.output + assert "info" in result.output + assert "tree" in result.output + assert "path" in result.output + + +def test_project_ls_supports_search_limit_and_folder_filters(monkeypatch): + from flow360.cli import project as project_cli + + runner = CliRunner() + calls = {} + record = SimpleNamespace( + name="Wing Study", + project_id="prj-123", + tags=["demo"], + description="test project", + solver_version="release-25.2", + created_at="2025-01-01T00:00:00Z", + root_item_type="Geometry", + ) + + monkeypatch.setattr( + project_cli, + "_get_project_records", + lambda search=None, limit=25, folder_ids=None, exclude_subfolders=False: ( + calls.update( + { + "search": search, + "limit": limit, + "folder_ids": folder_ids, + "exclude_subfolders": exclude_subfolders, + } + ) + or ([record], 1) + ), + ) + + result = runner.invoke( + flow360, + [ + "project", + "list", + "--search", + "wing", + "--limit", + "10", + "--folder-id", + "folder-123", + "--exclude-subfolders", + ], + ) + + assert result.exit_code == 0 + payload = json.loads(result.output) + assert payload["records"][0]["id"] == "prj-123" + assert payload["returned"] == 1 + assert calls == { + "search": "wing", + "limit": 10, + "folder_ids": ("folder-123",), + "exclude_subfolders": True, + } + + +def test_global_dev_and_profile_apply_to_project_commands(monkeypatch): + from flow360.cli import project as project_cli + from flow360.environment import Env + from flow360.user_config import UserConfig + + runner = CliRunner() + seen = {} + + monkeypatch.setattr( + project_cli, + "_get_project_info", + lambda project_id: seen.update( + {"env": Env.current.name, "profile": UserConfig.profile} + ) + or { + "id": project_id, + "name": "Wing Study", + "solverVersion": "release-25.2", + "tags": [], + "rootItemId": "geo-123", + "rootItemType": "Geometry", + }, + ) + + result = runner.invoke( + flow360, + ["--dev", "--profile", "alt", "project", "get", "prj-12345678-1234-1234-1234-123456789abc"], + ) + + assert result.exit_code == 0 + assert seen["env"] == "dev" + assert seen["profile"] == "alt" + + +def test_project_create_from_geometry_calls_sdk(monkeypatch, tmp_path): + from flow360.cli import project as project_cli + + runner = CliRunner() + calls = {} + file_a = tmp_path / "wing.csm" + file_b = tmp_path / "wing.step" + file_a.write_text("solid") + file_b.write_text("solid") + + class FakeProject: + id = "prj-123" + + @staticmethod + def get_metadata(): + return SimpleNamespace( + name="Wing Project", + tags=["demo"], + root_item_id="geo-123", + root_item_type="Geometry", + ) + + monkeypatch.setattr( + project_cli, + "_create_project", + lambda **kwargs: calls.update(kwargs) or FakeProject(), + ) + + result = runner.invoke( + flow360, + [ + "project", + "create", + "--from", + "geometry", + "--file", + str(file_a), + "--file", + str(file_b), + "--name", + "Wing Project", + "--solver-version", + "release-25.9", + "--length-unit", + "cm", + "--description", + "demo project", + "--tag", + "demo", + "--folder-id", + "folder-123", + ], + ) + + assert result.exit_code == 0 + payload = json.loads(result.output) + assert payload["id"] == "prj-123" + assert payload["root_item"]["id"] == "geo-123" + assert calls == { + "source": "geometry", + "files": (str(file_a), str(file_b)), + "name": "Wing Project", + "solver_version": "release-25.9", + "length_unit": "cm", + "description": "demo project", + "tags": ("demo",), + "folder_id": "folder-123", + "run_async": False, + } + + +def test_project_create_async_outputs_project_id(monkeypatch, tmp_path): + from flow360.cli import project as project_cli + + runner = CliRunner() + mesh_file = tmp_path / "mesh.cgns" + mesh_file.write_text("mesh") + + monkeypatch.setattr(project_cli, "_create_project", lambda **kwargs: "prj-async") + + result = runner.invoke( + flow360, + [ + "project", + "create", + "--from", + "surface-mesh", + "--file", + str(mesh_file), + "--async", + ], + ) + + assert result.exit_code == 0 + assert json.loads(result.output) == {"async": True, "id": "prj-async"} + + +def test_project_create_surface_mesh_requires_single_file(tmp_path): + runner = CliRunner() + file_a = tmp_path / "mesh-a.cgns" + file_b = tmp_path / "mesh-b.cgns" + file_a.write_text("mesh") + file_b.write_text("mesh") + + result = runner.invoke( + flow360, + [ + "project", + "create", + "--from", + "surface-mesh", + "--file", + str(file_a), + "--file", + str(file_b), + ], + ) + + assert result.exit_code != 0 + assert "surface-mesh projects require exactly one --file." in result.output + + +def test_project_create_volume_mesh_requires_single_file(tmp_path): + runner = CliRunner() + file_a = tmp_path / "mesh-a.cgns" + file_b = tmp_path / "mesh-b.cgns" + file_a.write_text("mesh") + file_b.write_text("mesh") + + result = runner.invoke( + flow360, + [ + "project", + "create", + "--from", + "volume-mesh", + "--file", + str(file_a), + "--file", + str(file_b), + ], + ) + + assert result.exit_code != 0 + assert "volume-mesh projects require exactly one --file." in result.output + + +def test_project_list_outputs_records(monkeypatch): + from flow360.cli import project as project_cli + + runner = CliRunner() + record = SimpleNamespace( + name="Wing Study", + project_id="prj-123", + tags=["demo"], + description="test project", + solver_version="release-25.2", + created_at="2025-01-01T00:00:00Z", + root_item_type="Geometry", + ) + + monkeypatch.setattr( + project_cli, + "_get_project_records", + lambda search=None, limit=25, folder_ids=None, exclude_subfolders=False: ([record], 1), + ) + + result = runner.invoke(flow360, ["project", "list", "--keyword", "wing"]) + + assert result.exit_code == 0 + payload = json.loads(result.output) + assert payload["records"][0]["id"] == "prj-123" + assert payload["records"][0]["name"] == "Wing Study" + assert payload["returned"] == 1 + assert payload["total"] == 1 + + +def test_project_ls_alias_outputs_records(monkeypatch): + from flow360.cli import project as project_cli + + runner = CliRunner() + record = SimpleNamespace( + name="Wing Study", + project_id="prj-123", + tags=["demo"], + description="test project", + solver_version="release-25.2", + created_at="2025-01-01T00:00:00Z", + root_item_type="Geometry", + ) + + monkeypatch.setattr( + project_cli, + "_get_project_records", + lambda search=None, limit=25, folder_ids=None, exclude_subfolders=False: ([record], 1), + ) + + result = runner.invoke(flow360, ["project", "ls", "--keyword", "wing"]) + + assert result.exit_code == 0 + payload = json.loads(result.output) + assert payload["records"][0]["id"] == "prj-123" + assert payload["records"][0]["name"] == "Wing Study" + assert payload["returned"] == 1 + assert payload["total"] == 1 + + +def test_project_info_outputs_metadata(monkeypatch): + from flow360.cli import project as project_cli + + runner = CliRunner() + info = { + "id": "prj-123", + "name": "Wing Study", + "solverVersion": "release-25.2", + "tags": ["demo"], + "rootItemId": "geo-123", + "rootItemType": "Geometry", + } + + monkeypatch.setattr( + project_cli, + "_get_project_info", + lambda project_id: info, + ) + + result = runner.invoke(flow360, ["project", "info", "prj-123"]) + + assert result.exit_code == 0 + payload = json.loads(result.output) + assert payload["id"] == "prj-123" + assert payload["name"] == "Wing Study" + assert payload["root_item"]["id"] == "geo-123" + assert payload["root_item"]["type"] == "Geometry" + + +def test_project_get_alias_outputs_metadata(monkeypatch): + from flow360.cli import project as project_cli + + runner = CliRunner() + info = { + "id": "prj-123", + "name": "Wing Study", + "solverVersion": "release-25.2", + "tags": ["demo"], + "rootItemId": "geo-123", + "rootItemType": "Geometry", + } + + monkeypatch.setattr( + project_cli, + "_get_project_info", + lambda project_id: info, + ) + + result = runner.invoke(flow360, ["project", "get", "prj-123"]) + + assert result.exit_code == 0 + payload = json.loads(result.output) + assert payload["id"] == "prj-123" + assert payload["name"] == "Wing Study" + assert payload["root_item"]["id"] == "geo-123" + assert payload["root_item"]["type"] == "Geometry" + + +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], + ) + monkeypatch.setattr( + project_cli, + "_get_project_tree", + lambda project_id: SimpleNamespace(root=root), + ) + + result = runner.invoke(flow360, ["project", "tree", "prj-123"]) + + assert result.exit_code == 0 + payload = json.loads(result.output) + assert payload["root"]["id"] == "geo-123" + assert payload["root"]["children"][0]["id"] == "case-123" + + +def test_project_items_outputs_flat_items(monkeypatch): + from flow360.cli import project as project_cli + + runner = CliRunner() + monkeypatch.setattr( + project_cli, + "_get_project_tree_records", + lambda project_id: [ + { + "id": "geo-123", + "name": "Wing", + "type": "Geometry", + "parentId": None, + "parentCaseId": None, + }, + { + "id": "case-123", + "name": "Case 1", + "type": "Case", + "parentId": None, + "parentCaseId": "geo-123", + }, + ], + ) + + result = runner.invoke(flow360, ["project", "items", "prj-123"]) + + assert result.exit_code == 0 + payload = json.loads(result.output) + assert payload["items"] == [ + { + "id": "geo-123", + "name": "Wing", + "parent_id": None, + "type": "Geometry", + }, + { + "id": "case-123", + "name": "Case 1", + "parent_id": "geo-123", + "type": "Case", + }, + ] + + +def test_project_path_outputs_flat_branch(monkeypatch): + from flow360.cli import project as project_cli + + runner = CliRunner() + monkeypatch.setattr( + project_cli, + "_get_project_path", + lambda project_id, item_id, item_type: [ + { + "id": "geo-123", + "name": "Wing", + "type": "Geometry", + "parentId": None, + "status": "processed", + "updatedAt": "2025-01-01T00:00:00Z", + }, + { + "id": "case-123", + "name": "Case 1", + "type": "Case", + "parentId": "geo-123", + "status": "completed", + "updatedAt": "2025-01-01T01:00:00Z", + }, + ], + ) + + result = runner.invoke( + flow360, + [ + "project", + "path", + "prj-123", + "--item-id", + "case-123", + "--item-type", + "Case", + ], + ) + + assert result.exit_code == 0 + payload = json.loads(result.output) + assert payload["items"] == [ + { + "id": "geo-123", + "name": "Wing", + "parent_id": None, + "status": "processed", + "type": "Geometry", + "updated_at": "2025-01-01T00:00:00Z", + }, + { + "id": "case-123", + "name": "Case 1", + "parent_id": "geo-123", + "status": "completed", + "type": "Case", + "updated_at": "2025-01-01T01:00:00Z", + }, + ] + + +def test_project_rename_calls_webapi(monkeypatch): + from flow360.cli import project as project_cli + + runner = CliRunner() + calls = {} + + class FakeWebApi: + def __init__(self, project_id): + calls["project_id"] = project_id + + def patch(self, payload): + calls["payload"] = payload + + monkeypatch.setattr(project_cli, "_rename_project", lambda project_id, new_name: None) + monkeypatch.setattr( + project_cli, + "_rename_project", + lambda project_id, new_name: calls.update( + { + "project_id": project_id, + "new_name": new_name, + } + ), + ) + + result = runner.invoke(flow360, ["project", "rename", "prj-123", "--name", "New Name"]) + + assert result.exit_code == 0 + assert calls["project_id"] == "prj-123" + assert calls["new_name"] == "New Name" + assert "Renamed project prj-123 to New Name." in result.output + + +def test_project_delete_requires_yes(): + runner = CliRunner() + + result = runner.invoke(flow360, ["project", "delete", "prj-123"]) + + assert result.exit_code != 0 + assert "Pass --yes to confirm project deletion." in result.output + + +def test_project_delete_calls_webapi(monkeypatch): + from flow360.cli import project as project_cli + + runner = CliRunner() + calls = {} + + monkeypatch.setattr( + project_cli, + "_delete_project", + lambda project_id: calls.update({"project_id": project_id}), + ) + + result = runner.invoke(flow360, ["project", "delete", "prj-123", "--yes"]) + + assert result.exit_code == 0 + assert calls["project_id"] == "prj-123" + assert "Deleted project prj-123." in result.output diff --git a/tests/v1/test_cli_resource_refs.py b/tests/v1/test_cli_resource_refs.py new file mode 100644 index 000000000..51a372931 --- /dev/null +++ b/tests/v1/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/v1/test_cli_wait.py b/tests/v1/test_cli_wait.py new file mode 100644 index 000000000..c9182f976 --- /dev/null +++ b/tests/v1/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/v1/test_cli_webapi_integration.py b/tests/v1/test_cli_webapi_integration.py new file mode 100644 index 000000000..f09920602 --- /dev/null +++ b/tests/v1/test_cli_webapi_integration.py @@ -0,0 +1,970 @@ +import importlib +import json + +import pytest +from click.testing import CliRunner + +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" + + +@pytest.fixture +def recorded_webapi_calls(monkeypatch, mock_response): + mock_server = importlib.import_module("tests.mock_server") + original_mock_webapi = mock_server.mock_webapi + calls = [] + + def recording_mock_webapi(request_type, url, params): + calls.append({"type": request_type, "url": url, "params": params}) + return original_mock_webapi(request_type, url, params) + + monkeypatch.setattr(mock_server, "mock_webapi", recording_mock_webapi) + return calls + + +def _load_json_output(output): + json_start = output.rfind("\n{") + if json_start == -1: + json_start = output.find("{") + else: + json_start += 1 + return json.loads(output[json_start:]) + + +def test_project_info_uses_project_info_endpoint(recorded_webapi_calls): + runner = CliRunner() + + result = runner.invoke(flow360, ["project", "info", PROJECT_ID]) + + assert result.exit_code == 0 + payload = _load_json_output(result.output) + assert payload["id"] == PROJECT_ID + assert recorded_webapi_calls[-1] == { + "type": "get", + "url": f"/v2/projects/{PROJECT_ID}", + "params": None, + } + + +def test_project_get_alias_uses_project_info_endpoint(recorded_webapi_calls): + runner = CliRunner() + + result = runner.invoke(flow360, ["project", "get", PROJECT_ID]) + + assert result.exit_code == 0 + payload = _load_json_output(result.output) + assert payload["id"] == PROJECT_ID + assert recorded_webapi_calls[-1] == { + "type": "get", + "url": f"/v2/projects/{PROJECT_ID}", + "params": None, + } + + +def test_project_tree_uses_tree_endpoint(recorded_webapi_calls): + runner = CliRunner() + + result = runner.invoke(flow360, ["project", "tree", PROJECT_ID]) + + assert result.exit_code == 0 + payload = _load_json_output(result.output) + assert payload["root"]["id"] == "geo-2877e124-96ff-473d-864b-11eec8648d42" + assert recorded_webapi_calls[-1] == { + "type": "get", + "url": f"/v2/projects/{PROJECT_ID}/tree", + "params": None, + } + + +def test_project_ls_uses_limit_search_and_folder_filters(recorded_webapi_calls): + runner = CliRunner() + + result = runner.invoke( + flow360, + [ + "project", + "ls", + "--search", + "wing", + "--limit", + "10", + "--folder-id", + FOLDER_ID, + "--exclude-subfolders", + ], + ) + + assert result.exit_code == 0 + payload = _load_json_output(result.output) + assert payload["returned"] >= 1 + assert recorded_webapi_calls[-1] == { + "type": "get", + "url": "/v2/projects", + "params": { + "page": "0", + "size": 10, + "filterKeywords": "wing", + "filterTags": None, + "sortFields": ["createdAt"], + "sortDirections": ["desc"], + "filterFolderIds": [FOLDER_ID], + "filterExcludeSubfolders": True, + }, + } + + +def test_project_path_uses_path_endpoint(recorded_webapi_calls): + runner = CliRunner() + + result = runner.invoke( + flow360, + [ + "project", + "path", + PROJECT_ID, + "--item-id", + CASE_ID, + "--item-type", + "Case", + ], + ) + + assert result.exit_code == 0 + payload = _load_json_output(result.output) + assert payload["items"][0]["type"] == "Geometry" + assert recorded_webapi_calls[-1] == { + "type": "get", + "url": f"/v2/projects/{PROJECT_ID}/path", + "params": {"itemId": CASE_ID, "itemType": "Case"}, + } + + +def test_geometry_info_uses_geometry_v2_endpoint(recorded_webapi_calls): + runner = CliRunner() + + result = runner.invoke(flow360, ["geometry", "info", GEOMETRY_ID]) + + assert result.exit_code == 0 + payload = _load_json_output(result.output) + assert payload["id"] == GEOMETRY_ID + assert payload["type"] == "Geometry" + assert recorded_webapi_calls[-1] == { + "type": "get", + "url": f"/v2/geometries/{GEOMETRY_ID}", + "params": None, + } + + +def test_geometry_get_alias_uses_geometry_v2_endpoint(recorded_webapi_calls): + runner = CliRunner() + + result = runner.invoke(flow360, ["geometry", "get", GEOMETRY_ID]) + + assert result.exit_code == 0 + payload = _load_json_output(result.output) + assert payload["id"] == GEOMETRY_ID + assert payload["type"] == "Geometry" + assert recorded_webapi_calls[-1] == { + "type": "get", + "url": f"/v2/geometries/{GEOMETRY_ID}", + "params": None, + } + + +def test_geometry_state_uses_geometry_v2_endpoint(recorded_webapi_calls): + runner = CliRunner() + + result = runner.invoke(flow360, ["geometry", "state", GEOMETRY_ID]) + + assert result.exit_code == 0 + payload = _load_json_output(result.output) + assert payload["id"] == GEOMETRY_ID + assert payload["status"] == "processed" + assert recorded_webapi_calls[-1] == { + "type": "get", + "url": f"/v2/geometries/{GEOMETRY_ID}", + "params": None, + } + + +def test_geometry_simulation_get_uses_geometry_simulation_endpoint(recorded_webapi_calls): + runner = CliRunner() + + result = runner.invoke(flow360, ["geometry", "simulation", "get", GEOMETRY_ID]) + + assert result.exit_code == 0 + payload = _load_json_output(result.output) + assert "simulation" in payload + assert isinstance(payload["simulation"], dict) + assert "models" in payload["simulation"] + assert recorded_webapi_calls[-1] == { + "type": "get", + "url": f"/v2/geometries/{GEOMETRY_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"] == "vm-7c3681cd-8c6c-4db7-a62c-1742d825e9d3" + 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"] == "vm-7c3681cd-8c6c-4db7-a62c-1742d825e9d3" + assert recorded_webapi_calls[-1] == { + "type": "get", + "url": f"/v2/cases/{CASE_ID}", + "params": None, + } + + +def test_surface_mesh_info_uses_surface_mesh_v2_endpoint(recorded_webapi_calls): + runner = CliRunner() + + result = runner.invoke(flow360, ["surface-mesh", "info", SURFACE_MESH_ID]) + + assert result.exit_code == 0 + payload = _load_json_output(result.output) + assert payload["id"] == SURFACE_MESH_ID + assert payload["type"] == "SurfaceMesh" + assert recorded_webapi_calls[-1] == { + "type": "get", + "url": f"/v2/surface-meshes/{SURFACE_MESH_ID}", + "params": None, + } + + +def test_surface_mesh_get_alias_uses_surface_mesh_v2_endpoint(recorded_webapi_calls): + runner = CliRunner() + + result = runner.invoke(flow360, ["surface-mesh", "get", SURFACE_MESH_ID]) + + assert result.exit_code == 0 + payload = _load_json_output(result.output) + assert payload["id"] == SURFACE_MESH_ID + assert payload["type"] == "SurfaceMesh" + assert recorded_webapi_calls[-1] == { + "type": "get", + "url": f"/v2/surface-meshes/{SURFACE_MESH_ID}", + "params": None, + } + + +def test_surface_mesh_state_uses_surface_mesh_v2_endpoint(recorded_webapi_calls): + runner = CliRunner() + + result = runner.invoke(flow360, ["surface-mesh", "state", SURFACE_MESH_ID]) + + assert result.exit_code == 0 + payload = _load_json_output(result.output) + assert payload["id"] == SURFACE_MESH_ID + assert payload["status"] == "processed" + assert recorded_webapi_calls[-1] == { + "type": "get", + "url": f"/v2/surface-meshes/{SURFACE_MESH_ID}", + "params": None, + } + + +def test_surface_mesh_simulation_get_uses_surface_mesh_simulation_endpoint(recorded_webapi_calls): + runner = CliRunner() + + result = runner.invoke(flow360, ["surface-mesh", "simulation", "get", SURFACE_MESH_ID]) + + assert result.exit_code == 0 + payload = _load_json_output(result.output) + assert "simulation" in payload + assert isinstance(payload["simulation"], dict) + assert "models" in payload["simulation"] + assert recorded_webapi_calls[-1] == { + "type": "get", + "url": f"/v2/surface-meshes/{SURFACE_MESH_ID}/simulation/file", + "params": {"type": "simulation"}, + } + + +def test_volume_mesh_info_uses_volume_mesh_v2_endpoint(recorded_webapi_calls): + runner = CliRunner() + + result = runner.invoke(flow360, ["volume-mesh", "info", VOLUME_MESH_ID]) + + assert result.exit_code == 0 + payload = _load_json_output(result.output) + assert payload["id"] == VOLUME_MESH_ID + assert payload["type"] == "VolumeMesh" + assert recorded_webapi_calls[-1] == { + "type": "get", + "url": f"/v2/volume-meshes/{VOLUME_MESH_ID}", + "params": None, + } + + +def test_volume_mesh_get_alias_uses_volume_mesh_v2_endpoint(recorded_webapi_calls): + runner = CliRunner() + + result = runner.invoke(flow360, ["volume-mesh", "get", VOLUME_MESH_ID]) + + assert result.exit_code == 0 + payload = _load_json_output(result.output) + assert payload["id"] == VOLUME_MESH_ID + assert payload["type"] == "VolumeMesh" + assert recorded_webapi_calls[-1] == { + "type": "get", + "url": f"/v2/volume-meshes/{VOLUME_MESH_ID}", + "params": None, + } + + +def test_volume_mesh_state_uses_volume_mesh_v2_endpoint(recorded_webapi_calls): + runner = CliRunner() + + result = runner.invoke(flow360, ["volume-mesh", "state", VOLUME_MESH_ID]) + + 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_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_volume_mesh_simulation_get_uses_volume_mesh_simulation_endpoint(recorded_webapi_calls): + runner = CliRunner() + + result = runner.invoke(flow360, ["volume-mesh", "simulation", "get", VOLUME_MESH_ID]) + + assert result.exit_code == 0 + payload = _load_json_output(result.output) + assert "simulation" in payload + assert isinstance(payload["simulation"], dict) + assert "models" in payload["simulation"] + assert recorded_webapi_calls[-1] == { + "type": "get", + "url": f"/v2/volume-meshes/{VOLUME_MESH_ID}/simulation/file", + "params": {"type": "simulation"}, + } + + +def test_case_get_alias_uses_case_v2_endpoint(recorded_webapi_calls): + runner = CliRunner() + + result = runner.invoke(flow360, ["case", "get", CASE_ID]) + + assert result.exit_code == 0 + payload = _load_json_output(result.output) + assert payload["id"] == CASE_ID + assert payload["mesh_id"] == "vm-7c3681cd-8c6c-4db7-a62c-1742d825e9d3" + assert recorded_webapi_calls[-1] == { + "type": "get", + "url": f"/v2/cases/{CASE_ID}", + "params": None, + } + + +def test_case_simulation_get_uses_case_simulation_endpoint(recorded_webapi_calls): + runner = CliRunner() + + result = runner.invoke(flow360, ["case", "simulation", "get", CASE_ID]) + + assert result.exit_code == 0 + payload = _load_json_output(result.output) + assert "simulation" in payload + assert isinstance(payload["simulation"], dict) + assert "models" in payload["simulation"] + assert recorded_webapi_calls[-1] == { + "type": "get", + "url": f"/v2/cases/{CASE_ID}/simulation/file", + "params": {"type": "simulation"}, + } + + +def test_case_results_ls_uses_legacy_case_files_endpoint(recorded_webapi_calls): + runner = CliRunner() + + result = runner.invoke(flow360, ["case", "results", "list", CASE_ID]) + + assert result.exit_code == 0 + payload = _load_json_output(result.output) + assert payload["records"] + assert all(record["path"].startswith("results/") for record in payload["records"]) + assert recorded_webapi_calls[-1] == { + "type": "get", + "url": f"/cases/{CASE_ID}/files", + "params": None, + } + + +def test_case_results_get_uses_legacy_case_files_endpoint(monkeypatch, recorded_webapi_calls): + from flow360.cli import assets as assets_cli + + runner = CliRunner() + monkeypatch.setattr( + assets_cli, + "_download_case_result", + lambda case_id, result_path, to_path=None, overwrite=False: "/tmp/total_forces_v2.csv", + ) + + result = runner.invoke( + flow360, + ["case", "results", "get", CASE_ID, "force_output_wing_all_planes_forces_v2.csv"], + ) + + assert result.exit_code == 0 + payload = _load_json_output(result.output) + assert payload["saved_to"] == "/tmp/total_forces_v2.csv" + assert payload["result"]["path"] == "results/force_output_wing_all_planes_forces_v2.csv" + assert recorded_webapi_calls[-1] == { + "type": "get", + "url": f"/cases/{CASE_ID}/files", + "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_get_alias_uses_draft_info_endpoint(recorded_webapi_calls): + runner = CliRunner() + + result = runner.invoke(flow360, ["draft", "get", 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_draft_simulation_set_uses_simulation_endpoint(recorded_webapi_calls, tmp_path): + runner = CliRunner() + file_path = tmp_path / "params.json" + file_path.write_text('{"version":"24.11.0","unit_system":{"name":"SI"}}') + + result = runner.invoke( + flow360, + ["draft", "simulation", "set", DRAFT_ID, str(file_path)], + ) + + assert result.exit_code == 0 + payload = _load_json_output(result.output) + assert payload == {"id": DRAFT_ID, "updated": True} + assert recorded_webapi_calls[-1] == { + "type": "post", + "url": f"/v2/drafts/{DRAFT_ID}/simulation/file", + "params": { + "data": '{"version": "24.11.0", "unit_system": {"name": "SI"}}', + "type": "simulation", + "version": "", + }, + } + + +def test_draft_create_from_project_resolves_root_and_posts_to_drafts(recorded_webapi_calls): + runner = CliRunner() + + result = runner.invoke(flow360, ["draft", "create", PROJECT_ID, "--name", "Draft A"]) + + assert result.exit_code == 0 + payload = _load_json_output(result.output) + assert payload["id"] == DRAFT_ID + assert payload["source_item_id"] == GEOMETRY_ID + calls = recorded_webapi_calls[-3:] + assert calls[0] == { + "type": "get", + "url": f"/v2/projects/{PROJECT_ID}", + "params": None, + } + assert calls[1] == { + "type": "get", + "url": f"/v2/geometries/{GEOMETRY_ID}", + "params": None, + } + assert calls[2]["type"] == "post" + assert calls[2]["url"] == "/v2/drafts" + assert calls[2]["params"]["name"] == "Draft A" + assert calls[2]["params"]["projectId"] == PROJECT_ID + assert calls[2]["params"]["sourceItemId"] == GEOMETRY_ID + assert calls[2]["params"]["sourceItemType"] == "Geometry" + assert calls[2]["params"]["solverVersion"] == "release-24.11" + assert calls[2]["params"]["forkCase"] is False + + +def test_draft_run_uses_run_endpoint(recorded_webapi_calls): + runner = CliRunner() + + result = runner.invoke(flow360, ["draft", "run", DRAFT_ID, "--up-to", "volume-mesh"]) + + assert result.exit_code == 0 + payload = _load_json_output(result.output) + assert payload["id"] == VOLUME_MESH_ID + assert payload["type"] == "VolumeMesh" + assert recorded_webapi_calls[-1] == { + "type": "post", + "url": f"/v2/drafts/{DRAFT_ID}/run", + "params": { + "upTo": "VolumeMesh", + "useInHouse": False, + "useGai": False, + }, + } + + +def test_draft_run_wait_polls_result_state_endpoint(recorded_webapi_calls): + runner = CliRunner() + + result = runner.invoke( + flow360, ["draft", "run", DRAFT_ID, "--up-to", "volume-mesh", "--wait"] + ) + + assert result.exit_code == 0 + payload = _load_json_output(result.output) + assert payload["result"]["id"] == VOLUME_MESH_ID + assert payload["state"]["id"] == VOLUME_MESH_ID + calls = recorded_webapi_calls[-2:] + assert calls[0] == { + "type": "post", + "url": f"/v2/drafts/{DRAFT_ID}/run", + "params": { + "upTo": "VolumeMesh", + "useInHouse": False, + "useGai": False, + }, + } + assert calls[1] == { + "type": "get", + "url": f"/v2/volume-meshes/{VOLUME_MESH_ID}", + "params": None, + } + + +def test_draft_run_from_project_creates_sets_and_runs(recorded_webapi_calls, tmp_path): + runner = CliRunner() + simulation_path = tmp_path / "simulation.json" + simulation_path.write_text('{"version":"24.11.0","unit_system":{"name":"SI"}}') + + result = runner.invoke( + flow360, + ["draft", "run", PROJECT_ID, str(simulation_path), "--name", "Alpha -18", "--up-to", "volume-mesh"], + ) + + assert result.exit_code == 0 + payload = _load_json_output(result.output) + assert payload["draft"]["id"] == DRAFT_ID + assert payload["result"]["id"] == VOLUME_MESH_ID + calls = recorded_webapi_calls[-5:] + assert calls[0] == { + "type": "get", + "url": f"/v2/projects/{PROJECT_ID}", + "params": None, + } + assert calls[1] == { + "type": "get", + "url": f"/v2/geometries/{GEOMETRY_ID}", + "params": None, + } + assert calls[2]["type"] == "post" + assert calls[2]["url"] == "/v2/drafts" + assert calls[2]["params"]["projectId"] == PROJECT_ID + assert calls[2]["params"]["sourceItemId"] == GEOMETRY_ID + assert calls[2]["params"]["sourceItemType"] == "Geometry" + assert calls[2]["params"]["solverVersion"] == "release-24.11" + assert calls[2]["params"]["forkCase"] is False + assert calls[2]["params"]["name"] == "Alpha -18" + assert calls[3] == { + "type": "post", + "url": f"/v2/drafts/{DRAFT_ID}/simulation/file", + "params": { + "data": '{"version": "24.11.0", "unit_system": {"name": "SI"}}', + "type": "simulation", + "version": "", + }, + } + assert calls[4] == { + "type": "post", + "url": f"/v2/drafts/{DRAFT_ID}/run", + "params": { + "upTo": "VolumeMesh", + "useInHouse": False, + "useGai": False, + }, + } + + +def test_draft_run_from_project_patch_fetches_merges_sets_and_runs( + recorded_webapi_calls, tmp_path +): + runner = CliRunner() + patch_path = tmp_path / "patch.json" + patch_path.write_text('{"meshing":{"refinement_factor":2.5}}') + + result = runner.invoke( + flow360, + ["draft", "run", PROJECT_ID, "--patch", str(patch_path), "--up-to", "volume-mesh"], + ) + + assert result.exit_code == 0 + payload = _load_json_output(result.output) + assert payload["draft"]["id"] == DRAFT_ID + assert payload["result"]["id"] == VOLUME_MESH_ID + calls = recorded_webapi_calls[-6:] + assert calls[0] == { + "type": "get", + "url": f"/v2/projects/{PROJECT_ID}", + "params": None, + } + assert calls[1] == { + "type": "get", + "url": f"/v2/geometries/{GEOMETRY_ID}", + "params": None, + } + assert calls[2] == { + "type": "get", + "url": f"/v2/geometries/{GEOMETRY_ID}/simulation/file", + "params": {"type": "simulation"}, + } + assert calls[3] == { + "type": "post", + "url": "/v2/drafts", + "params": { + **calls[3]["params"], + }, + } + assert calls[3]["params"]["projectId"] == PROJECT_ID + assert calls[3]["params"]["sourceItemId"] == GEOMETRY_ID + assert calls[3]["params"]["sourceItemType"] == "Geometry" + assert calls[3]["params"]["solverVersion"] == "release-24.11" + assert calls[3]["params"]["forkCase"] is False + assert isinstance(calls[3]["params"]["name"], str) + assert calls[3]["params"]["name"] + assert calls[4]["type"] == "post" + assert calls[4]["url"] == f"/v2/drafts/{DRAFT_ID}/simulation/file" + assert calls[4]["params"]["type"] == "simulation" + assert calls[4]["params"]["version"] == "" + assert calls[5] == { + "type": "post", + "url": f"/v2/drafts/{DRAFT_ID}/run", + "params": { + "upTo": "VolumeMesh", + "useInHouse": False, + "useGai": False, + }, + } + + merged_payload = json.loads(calls[4]["params"]["data"]) + assert merged_payload["meshing"]["refinement_factor"] == 2.5 + assert "defaults" in merged_payload["meshing"] + + +def test_project_rename_uses_project_patch_endpoint(recorded_webapi_calls): + runner = CliRunner() + + result = runner.invoke(flow360, ["project", "rename", PROJECT_ID, "--name", "Renamed Project"]) + + assert result.exit_code == 0 + assert f"Renamed project {PROJECT_ID} to Renamed Project." in result.output + assert recorded_webapi_calls[-1]["type"] == "patch" + assert recorded_webapi_calls[-1]["url"] == f"/v2/projects/{PROJECT_ID}" + assert recorded_webapi_calls[-1]["params"]["name"] == "Renamed Project" + + +def test_case_rename_uses_case_patch_endpoint(recorded_webapi_calls): + runner = CliRunner() + + result = runner.invoke(flow360, ["case", "rename", CASE_ID, "--name", "Alpha -18"]) + + assert result.exit_code == 0 + payload = _load_json_output(result.output) + assert payload == {"id": CASE_ID, "name": "Alpha -18"} + assert recorded_webapi_calls[-1] == { + "type": "patch", + "url": f"/v2/cases/{CASE_ID}", + "params": {"name": "Alpha -18"}, + } + + +def test_geometry_rename_uses_geometry_patch_endpoint(recorded_webapi_calls): + runner = CliRunner() + + result = runner.invoke(flow360, ["geometry", "rename", GEOMETRY_ID, "--name", "Renamed Geometry"]) + + assert result.exit_code == 0 + payload = _load_json_output(result.output) + assert payload == {"id": GEOMETRY_ID, "name": "Renamed Geometry"} + assert recorded_webapi_calls[-1] == { + "type": "patch", + "url": f"/v2/geometries/{GEOMETRY_ID}", + "params": {"name": "Renamed Geometry"}, + } + + +def test_surface_mesh_rename_uses_surface_mesh_patch_endpoint(recorded_webapi_calls): + runner = CliRunner() + + result = runner.invoke( + flow360, + ["surface-mesh", "rename", SURFACE_MESH_ID, "--name", "Renamed Surface Mesh"], + ) + + assert result.exit_code == 0 + payload = _load_json_output(result.output) + assert payload == {"id": SURFACE_MESH_ID, "name": "Renamed Surface Mesh"} + assert recorded_webapi_calls[-1] == { + "type": "patch", + "url": f"/v2/surface-meshes/{SURFACE_MESH_ID}", + "params": {"name": "Renamed Surface Mesh"}, + } + + +def test_volume_mesh_rename_uses_volume_mesh_patch_endpoint(recorded_webapi_calls): + runner = CliRunner() + + result = runner.invoke( + flow360, + ["volume-mesh", "rename", VOLUME_MESH_ID, "--name", "Renamed Volume Mesh"], + ) + + assert result.exit_code == 0 + payload = _load_json_output(result.output) + assert payload == {"id": VOLUME_MESH_ID, "name": "Renamed Volume Mesh"} + assert recorded_webapi_calls[-1] == { + "type": "patch", + "url": f"/v2/volume-meshes/{VOLUME_MESH_ID}", + "params": {"name": "Renamed Volume Mesh"}, + } + + +def test_draft_rename_uses_draft_patch_endpoint(recorded_webapi_calls): + runner = CliRunner() + + result = runner.invoke(flow360, ["draft", "rename", DRAFT_ID, "--name", "Renamed Draft"]) + + assert result.exit_code == 0 + payload = _load_json_output(result.output) + assert payload == {"id": DRAFT_ID, "name": "Renamed Draft"} + assert recorded_webapi_calls[-1] == { + "type": "patch", + "url": f"/v2/drafts/{DRAFT_ID}", + "params": {"name": "Renamed Draft"}, + } + + +def test_project_delete_uses_project_delete_endpoint(recorded_webapi_calls): + runner = CliRunner() + + result = runner.invoke(flow360, ["project", "delete", PROJECT_ID, "--yes"]) + + assert result.exit_code == 0 + assert f"Deleted project {PROJECT_ID}." in result.output + assert recorded_webapi_calls[-1] == { + "type": "delete", + "url": f"/v2/projects/{PROJECT_ID}", + "params": None, + } + + +def test_folder_get_uses_folder_info_endpoint(recorded_webapi_calls): + runner = CliRunner() + + result = runner.invoke(flow360, ["folder", "get", FOLDER_ID]) + + assert result.exit_code == 0 + payload = _load_json_output(result.output) + assert payload["id"] == FOLDER_ID + assert recorded_webapi_calls[-1] == { + "type": "get", + "url": f"/v2/folders/{FOLDER_ID}", + "params": None, + } + + +def test_folder_tree_uses_folder_list_endpoint(recorded_webapi_calls): + runner = CliRunner() + + result = runner.invoke(flow360, ["folder", "tree"]) + + assert result.exit_code == 0 + payload = _load_json_output(result.output) + assert payload["root"]["id"] == "ROOT.FLOW360" + assert recorded_webapi_calls[-1] == { + "type": "get", + "url": "/v2/folders", + "params": { + "includeSubfolders": True, + "page": 0, + "size": 1000, + }, + } + + +def test_folder_create_uses_folder_create_endpoint(recorded_webapi_calls): + runner = CliRunner() + + result = runner.invoke( + flow360, + [ + "folder", + "create", + "--name", + "Folder A", + "--parent-folder-id", + "ROOT.FLOW360", + "--tag", + "demo", + ], + ) + + assert result.exit_code == 0 + payload = _load_json_output(result.output) + assert payload["id"] == "folder-3834758b-3d39-4a4a-ad85-710b7652267c" + assert recorded_webapi_calls[-1] == { + "type": "post", + "url": "/folders", + "params": { + "name": "Folder A", + "tags": ["demo"], + "parentFolderId": "ROOT.FLOW360", + "type": "folder", + }, + } + + +def test_folder_rename_uses_folder_patch_endpoint(recorded_webapi_calls): + runner = CliRunner() + + result = runner.invoke(flow360, ["folder", "rename", FOLDER_ID, "--name", "Renamed Folder"]) + + assert result.exit_code == 0 + payload = _load_json_output(result.output) + assert payload == {"id": FOLDER_ID, "name": "Renamed Folder"} + assert recorded_webapi_calls[-1] == { + "type": "patch", + "url": f"/v2/folders/{FOLDER_ID}", + "params": {"name": "Renamed Folder"}, + } + + +def test_folder_move_uses_folder_patch_endpoint(recorded_webapi_calls): + runner = CliRunner() + + result = runner.invoke( + flow360, + ["folder", "move", FOLDER_ID, "--parent-folder-id", "folder-4da3cdd0-c5b6-4130-9ca1-196237322ab9"], + ) + + assert result.exit_code == 0 + payload = _load_json_output(result.output) + assert payload == { + "id": FOLDER_ID, + "parent_id": "folder-4da3cdd0-c5b6-4130-9ca1-196237322ab9", + } + assert recorded_webapi_calls[-1] == { + "type": "patch", + "url": f"/v2/folders/{FOLDER_ID}", + "params": {"parentFolderId": "folder-4da3cdd0-c5b6-4130-9ca1-196237322ab9"}, + } diff --git a/tests/v1/test_workspace_webapi.py b/tests/v1/test_workspace_webapi.py new file mode 100644 index 000000000..515bbe655 --- /dev/null +++ b/tests/v1/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/tools/cli_live_benchmark.py b/tools/cli_live_benchmark.py new file mode 100644 index 000000000..5a1d4bad2 --- /dev/null +++ b/tools/cli_live_benchmark.py @@ -0,0 +1,387 @@ +"""Live Flow360 CLI benchmark and review helper. + +Runs the local CLI as a fresh subprocess for each command, measures wall time, +and emits a markdown report to stdout. +""" + +from __future__ import annotations + +import argparse +import json +import os +import subprocess +import sys +import time +from dataclasses import dataclass +from datetime import date +from pathlib import Path + + +CLI_BOOTSTRAP = "from flow360.cli import flow360; flow360()" +REPO_ROOT = Path(__file__).resolve().parents[1] + + +@dataclass +class CommandResult: + args: list[str] + duration_s: float + exit_code: int + stdout: str + stderr: str + + @property + def command(self) -> str: + return "flow360 " + " ".join(self.args) + + +def run_cli(args: list[str]) -> CommandResult: + command = [sys.executable, "-c", CLI_BOOTSTRAP, *args] + started = time.perf_counter() + completed = subprocess.run( + command, + cwd=REPO_ROOT, + capture_output=True, + text=True, + check=False, + ) + duration_s = time.perf_counter() - started + return CommandResult( + args=args, + duration_s=duration_s, + exit_code=completed.returncode, + stdout=completed.stdout, + stderr=completed.stderr, + ) + + +def run_python(code: str) -> CommandResult: + command = [sys.executable, "-c", code] + started = time.perf_counter() + completed = subprocess.run( + command, + cwd=REPO_ROOT, + capture_output=True, + text=True, + check=False, + ) + duration_s = time.perf_counter() - started + return CommandResult( + args=["python", "-c", code], + duration_s=duration_s, + exit_code=completed.returncode, + stdout=completed.stdout, + stderr=completed.stderr, + ) + + +def run_pytest(args: list[str]) -> CommandResult: + command = [sys.executable, "-m", "pytest", *args] + started = time.perf_counter() + completed = subprocess.run( + command, + cwd=REPO_ROOT, + capture_output=True, + text=True, + check=False, + ) + duration_s = time.perf_counter() - started + return CommandResult( + args=["pytest", *args], + duration_s=duration_s, + exit_code=completed.returncode, + stdout=completed.stdout, + stderr=completed.stderr, + ) + + +def parse_json_output(result: CommandResult): + return json.loads(result.stdout) + + +def choose_project(project_records: list[dict]) -> tuple[dict, CommandResult, list[dict]]: + for record in project_records[:25]: + items_result = run_cli(["project", "items", record["id"]]) + if items_result.exit_code != 0: + continue + items = parse_json_output(items_result)["items"] + types = {item["type"] for item in items} + if {"Geometry", "SurfaceMesh", "VolumeMesh", "Case"}.issubset(types): + return record, items_result, items + + for record in project_records[:25]: + items_result = run_cli(["project", "items", record["id"]]) + if items_result.exit_code != 0: + continue + items = parse_json_output(items_result)["items"] + if items: + return record, items_result, items + + raise RuntimeError("Could not find a benchmarkable project from project ls output.") + + +def pick_item(items: list[dict], item_type: str) -> dict | None: + for item in items: + if item["type"] == item_type: + return item + return None + + +def find_first_folder_node(tree: dict | None) -> dict | None: + if not tree: + return None + for child in tree.get("subfolders", []): + return child + return None + + +def render_bool(value: bool) -> str: + return "yes" if value else "no" + + +def format_seconds(value: float) -> str: + return f"{value:.3f}s" + + +def md_escape(value: str) -> str: + return value.replace("|", "\\|") + + +def build_report() -> str: + sections: list[str] = [] + + import_result = run_python( + "import time; s=time.perf_counter(); import flow360.cli.app; print(time.perf_counter()-s)" + ) + lazy_check = run_python( + "import sys; import flow360.cli.app; " + "mods=('flow360.cli.project','flow360.cli.assets','flow360.cli.draft','flow360.cli.folder','flow360.cloud.flow360_requests'); " + "print({m:(m in sys.modules) for m in mods})" + ) + + project_ls = run_cli(["project", "ls"]) + if project_ls.exit_code != 0: + raise RuntimeError(f"project ls failed:\n{project_ls.stderr or project_ls.stdout}") + + project_records = parse_json_output(project_ls)["records"] + selected_project, selected_items_result, selected_items = choose_project(project_records) + + geometry = pick_item(selected_items, "Geometry") + surface_mesh = pick_item(selected_items, "SurfaceMesh") + volume_mesh = pick_item(selected_items, "VolumeMesh") + case = pick_item(selected_items, "Case") + path_target = case or volume_mesh or surface_mesh or geometry + if path_target is None: + raise RuntimeError("Selected project has no items.") + + project_get = run_cli(["project", "get", selected_project["id"]]) + project_tree = run_cli(["project", "tree", selected_project["id"]]) + project_path = run_cli( + [ + "project", + "path", + selected_project["id"], + "--item-id", + path_target["id"], + "--item-type", + path_target["type"], + ] + ) + + help_results = [ + run_cli(["--help"]), + run_cli(["project", "--help"]), + run_cli(["draft", "--help"]), + run_cli(["folder", "--help"]), + run_cli(["geometry", "--help"]), + run_cli(["surface-mesh", "--help"]), + run_cli(["volume-mesh", "--help"]), + run_cli(["case", "--help"]), + ] + + folder_tree = run_cli(["folder", "tree"]) + folder_get = None + folder_node = None + if folder_tree.exit_code == 0: + folder_root = parse_json_output(folder_tree)["root"] + folder_node = find_first_folder_node(folder_root) + if folder_node is not None: + folder_get = run_cli(["folder", "get", folder_node["id"]]) + + asset_results: list[CommandResult] = [] + geometry_simulation_get = None + if geometry: + asset_results.append(run_cli(["geometry", "info", geometry["id"]])) + geometry_simulation_get = run_cli(["geometry", "simulation", "get", geometry["id"]]) + surface_mesh_simulation_get = None + volume_mesh_simulation_get = None + if surface_mesh: + asset_results.append(run_cli(["surface-mesh", "info", surface_mesh["id"]])) + surface_mesh_simulation_get = run_cli(["surface-mesh", "simulation", "get", surface_mesh["id"]]) + if volume_mesh: + asset_results.append(run_cli(["volume-mesh", "info", volume_mesh["id"]])) + volume_mesh_simulation_get = run_cli(["volume-mesh", "simulation", "get", volume_mesh["id"]]) + case_simulation_get = None + if case: + asset_results.append(run_cli(["case", "info", case["id"]])) + case_simulation_get = run_cli(["case", "simulation", "get", case["id"]]) + + draft_ls = run_cli(["draft", "ls", "--project-id", selected_project["id"]]) + draft_info = None + draft_simulation_get = None + draft_record = None + if draft_ls.exit_code == 0: + draft_records = parse_json_output(draft_ls).get("records", []) + if draft_records: + draft_record = draft_records[0] + draft_info = run_cli(["draft", "info", draft_record["id"]]) + draft_simulation_get = run_cli(["draft", "simulation", "get", draft_record["id"]]) + + pytest_result = run_pytest( + [ + "tests/test_lazy_imports.py", + "tests/v1/test_cli_project.py", + "tests/v1/test_cli_folder.py", + "tests/v1/test_cli_assets.py", + "tests/v1/test_cli_draft.py", + "tests/v1/test_cli_webapi_integration.py", + "tests/v1/test_cli_login.py", + "tests/v1/_test_cli.py", + "tests/simulation/test_project_create.py", + "-q", + ] + ) + + sections.append(f"# Flow360 CLI Live Review\n") + sections.append(f"Date: {date.today().isoformat()}\n") + sections.append("## Scope\n") + sections.append( + "This report measures the current local CLI implementation in `Flow360/` using fresh subprocesses " + "for each command, so each timing includes Python startup, Click dispatch, local imports, and live network calls.\n" + ) + sections.append( + "No mutation commands were executed. `project create`, `project rename`, `project delete`, " + "`folder create`, `folder rename`, and `folder move` were intentionally skipped.\n" + ) + sections.append("The benchmark used the currently configured credentials with no explicit `--dev`, `--uat`, `--env`, or `--profile` overrides.\n") + + sections.append("## Selected Live Resources\n") + sections.append(f"- project: `{selected_project['id']}` `{selected_project['name']}`\n") + sections.append(f"- geometry present: {render_bool(geometry is not None)}\n") + sections.append(f"- surface mesh present: {render_bool(surface_mesh is not None)}\n") + sections.append(f"- volume mesh present: {render_bool(volume_mesh is not None)}\n") + sections.append(f"- case present: {render_bool(case is not None)}\n") + sections.append(f"- draft present: {render_bool(draft_record is not None)}\n") + if draft_record is not None: + sections.append(f"- draft: `{draft_record['id']}` `{draft_record['name']}`\n") + sections.append(f"- folder present: {render_bool(folder_node is not None)}\n") + if folder_node is not None: + sections.append(f"- folder: `{folder_node['id']}` `{folder_node['name']}`\n") + + sections.append("\n## Import Diagnostics\n") + sections.append("| Check | Exit | Time | Result |\n") + sections.append("| --- | ---: | ---: | --- |\n") + sections.append( + f"| `import flow360.cli.app` | {import_result.exit_code} | {format_seconds(import_result.duration_s)} | `{md_escape(import_result.stdout.strip())}` |\n" + ) + sections.append( + f"| lazy module check | {lazy_check.exit_code} | {format_seconds(lazy_check.duration_s)} | `{md_escape(lazy_check.stdout.strip())}` |\n" + ) + + sections.append("\n## End-to-End CLI Timings\n") + sections.append("| Command | Exit | Time |\n") + sections.append("| --- | ---: | ---: |\n") + for result in help_results: + sections.append( + f"| `{md_escape(result.command)}` | {result.exit_code} | {format_seconds(result.duration_s)} |\n" + ) + for result in (project_ls, project_get, project_tree, selected_items_result, project_path, folder_tree): + sections.append( + f"| `{md_escape(result.command)}` | {result.exit_code} | {format_seconds(result.duration_s)} |\n" + ) + if folder_get is not None: + sections.append( + f"| `{md_escape(folder_get.command)}` | {folder_get.exit_code} | {format_seconds(folder_get.duration_s)} |\n" + ) + for result in asset_results: + sections.append( + f"| `{md_escape(result.command)}` | {result.exit_code} | {format_seconds(result.duration_s)} |\n" + ) + if geometry_simulation_get is not None: + sections.append( + f"| `{md_escape(geometry_simulation_get.command)}` | {geometry_simulation_get.exit_code} | {format_seconds(geometry_simulation_get.duration_s)} |\n" + ) + if surface_mesh_simulation_get is not None: + sections.append( + f"| `{md_escape(surface_mesh_simulation_get.command)}` | {surface_mesh_simulation_get.exit_code} | {format_seconds(surface_mesh_simulation_get.duration_s)} |\n" + ) + if volume_mesh_simulation_get is not None: + sections.append( + f"| `{md_escape(volume_mesh_simulation_get.command)}` | {volume_mesh_simulation_get.exit_code} | {format_seconds(volume_mesh_simulation_get.duration_s)} |\n" + ) + if case_simulation_get is not None: + sections.append( + f"| `{md_escape(case_simulation_get.command)}` | {case_simulation_get.exit_code} | {format_seconds(case_simulation_get.duration_s)} |\n" + ) + sections.append( + f"| `{md_escape(draft_ls.command)}` | {draft_ls.exit_code} | {format_seconds(draft_ls.duration_s)} |\n" + ) + if draft_info is not None: + sections.append( + f"| `{md_escape(draft_info.command)}` | {draft_info.exit_code} | {format_seconds(draft_info.duration_s)} |\n" + ) + if draft_simulation_get is not None: + sections.append( + f"| `{md_escape(draft_simulation_get.command)}` | {draft_simulation_get.exit_code} | {format_seconds(draft_simulation_get.duration_s)} |\n" + ) + + sections.append("\n## Verification\n") + sections.append("| Command | Exit | Time | Result |\n") + sections.append("| --- | ---: | ---: | --- |\n") + sections.append( + f"| `{md_escape('python -m ' + ' '.join(pytest_result.args))}` | {pytest_result.exit_code} | {format_seconds(pytest_result.duration_s)} | `{md_escape(pytest_result.stdout.strip().splitlines()[-1] if pytest_result.stdout.strip() else '')}` |\n" + ) + + sections.append("\n## Review Findings\n") + sections.append( + "1. Root startup is in the right shape. The root import path stays thin, and root help remains around the low hundreds of milliseconds from a fresh subprocess.\n" + ) + sections.append( + "2. The bounded `project ls` default changed the performance profile materially. It is no longer the clear outlier because the CLI now asks the API for 25 projects by default instead of a much larger page.\n" + ) + sections.append( + "3. The read-only surface still clusters near the network floor. `project get/tree/items/path`, asset gets, and draft reads are all dominated by backend latency rather than local import or serialization overhead.\n" + ) + sections.append( + "4. The new folder commands fit the current architecture well. They reuse a thin shared web API wrapper and do not add noticeable startup cost to the root CLI path.\n" + ) + sections.append( + "5. The largest remaining unknown is write-path cold-start cost, especially for `project create`, because that command now intentionally goes through the richer SDK upload flow rather than a CLI-specific transport shim.\n" + ) + + sections.append("\n## Recommended Next Steps\n") + sections.append("1. Benchmark the new `project create` path separately and record its cold-start, upload-start, and async-return timings.\n") + sections.append("2. Benchmark `draft run` separately and record its cold-start and time-to-first-request behavior.\n") + sections.append("3. Keep `show_projects` unchanged until deprecation, but do not extend it or use it as the base for new behavior.\n") + sections.append("4. Extend project/draft-oriented workflow options only if needed, for example `draft run --start-from ...`, while keeping case branching out of the new CLI surface.\n") + + return "".join(sections) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--output", default=None, help="Write markdown report to this path.") + args = parser.parse_args() + + report = build_report() + + if args.output: + output_path = Path(args.output) + output_path.write_text(report, encoding="utf-8") + else: + sys.stdout.write(report) + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 12eb828f8c0306a641c831adfe5f536361f499b1 Mon Sep 17 00:00:00 2001 From: Maciej Skarysz Date: Mon, 27 Apr 2026 19:01:05 +0200 Subject: [PATCH 04/15] Save Flow360 CLI work before PR split --- flow360/cli/assets.py | 67 +++- flow360/cli/draft.py | 6 +- flow360/cli/simulation_summary.py | 432 ++++++++++++++++++++++++ tests/test_lazy_imports.py | 21 ++ tests/v1/test_cli_assets.py | 4 + tests/v1/test_cli_project.py | 93 +++++ tests/v1/test_cli_simulation_summary.py | 166 +++++++++ tests/v1/test_cli_webapi_integration.py | 127 ++++++- 8 files changed, 903 insertions(+), 13 deletions(-) create mode 100644 flow360/cli/simulation_summary.py create mode 100644 tests/v1/test_cli_simulation_summary.py diff --git a/flow360/cli/assets.py b/flow360/cli/assets.py index 5e95bfdba..6e1d628c6 100644 --- a/flow360/cli/assets.py +++ b/flow360/cli/assets.py @@ -48,6 +48,22 @@ def _get_asset_simulation_json(webapi_cls, asset_id): 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)), + } + ) + + def _serialize_case_result(record): path = _get_case_result_path(record) return { @@ -73,7 +89,9 @@ def _list_case_results(case_id): from flow360.component.simulation.web.asset_webapi import CaseWebApi files = CaseWebApi(case_id).list_files() - result_files = [record for record in files if (_get_case_result_path(record) or "").startswith("results/")] + result_files = [ + record for record in files if (_get_case_result_path(record) or "").startswith("results/") + ] result_files.sort(key=lambda record: _get_case_result_path(record) or "") return result_files @@ -86,7 +104,8 @@ def _resolve_case_result(case_id, result_ref): exact_matches = [ record for record in results - if result_ref in {record.get("filePath"), record.get("fileName"), _get_case_result_path(record)} + if result_ref + in {record.get("filePath"), record.get("fileName"), _get_case_result_path(record)} ] if len(exact_matches) == 1: return exact_matches[0] @@ -171,6 +190,16 @@ def state_geometry(geometry_id): 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.""" @@ -232,6 +261,16 @@ def state_surface_mesh(surface_mesh_id): 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.""" @@ -293,6 +332,16 @@ def state_volume_mesh(volume_mesh_id): 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.""" @@ -361,6 +410,16 @@ def rename_case(case_id, name): emit_json({"id": case_id, "name": name}) +@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.""" @@ -382,7 +441,9 @@ def case_results(): def _emit_case_results_list(case_id): - emit_json({"records": [_serialize_case_result(record) for record in _list_case_results(case_id)]}) + emit_json( + {"records": [_serialize_case_result(record) for record in _list_case_results(case_id)]} + ) @case_results.command("list") diff --git a/flow360/cli/draft.py b/flow360/cli/draft.py index 6ab7e17e4..4d8e7c892 100644 --- a/flow360/cli/draft.py +++ b/flow360/cli/draft.py @@ -322,7 +322,11 @@ def create_draft(ref_id, name): "patch_path", default=None, type=click.Path(exists=True, dir_okay=False, resolve_path=True), - help="JSON patch object merged locally into the source simulation before draft run.", + help=( + "JSON patch object merged locally into the source simulation before draft run. " + "Recommended only for small edits such as angle of attack or velocity. " + "For larger or structurally risky changes, use Python with Pydantic models instead." + ), ) @click.option("--name", default=None, help="Optional name for the created draft in one-shot mode.") @click.option( diff --git a/flow360/cli/simulation_summary.py b/flow360/cli/simulation_summary.py new file mode 100644 index 000000000..5d3be7d50 --- /dev/null +++ b/flow360/cli/simulation_summary.py @@ -0,0 +1,432 @@ +"""Generic simulation JSON compaction for CLI inspection.""" + +from __future__ import annotations + +from collections import OrderedDict +import copy +import json +import logging + + +_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(copy.deepcopy(simulation_json)) + params_dict, _ = SimulationParams._update_param_dict(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/tests/test_lazy_imports.py b/tests/test_lazy_imports.py index 1ca673929..af9d84755 100644 --- a/tests/test_lazy_imports.py +++ b/tests/test_lazy_imports.py @@ -92,6 +92,27 @@ 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.component.simulation.simulation_params", + ) + + 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.component.simulation.simulation_params" 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( diff --git a/tests/v1/test_cli_assets.py b/tests/v1/test_cli_assets.py index f8b94ab54..5276fcb03 100644 --- a/tests/v1/test_cli_assets.py +++ b/tests/v1/test_cli_assets.py @@ -26,6 +26,7 @@ def test_case_group_help_shows_info_and_simulation(): 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 "results" in result.output assert "get" not in result.output @@ -39,6 +40,7 @@ def test_geometry_group_help_shows_info_and_simulation(): 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 @@ -51,6 +53,7 @@ def test_surface_mesh_group_help_shows_info_and_simulation(): 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 @@ -63,6 +66,7 @@ def test_volume_mesh_group_help_shows_info_and_simulation(): 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 diff --git a/tests/v1/test_cli_project.py b/tests/v1/test_cli_project.py index b15da66f8..7b05fc4ae 100644 --- a/tests/v1/test_cli_project.py +++ b/tests/v1/test_cli_project.py @@ -288,6 +288,99 @@ def test_project_list_outputs_records(monkeypatch): assert payload["total"] == 1 +def test_project_list_can_output_legacy_style_text(monkeypatch): + from flow360.cli import project as project_cli + + runner = CliRunner() + record = SimpleNamespace( + name="Wing Study", + project_id="prj-123", + tags=["demo"], + description="test project", + solver_version="release-25.2", + created_at="2025-01-01T00:00:00Z", + root_item_type="Geometry", + statistics=SimpleNamespace( + geometry=SimpleNamespace( + count=1, + successCount=1, + runningCount=0, + divergedCount=0, + errorCount=0, + ), + surface_mesh=None, + volume_mesh=None, + case=SimpleNamespace( + count=3, + successCount=2, + runningCount=0, + divergedCount=1, + errorCount=0, + ), + ), + ) + + monkeypatch.setattr( + project_cli, + "_get_project_records", + lambda search=None, limit=25, folder_ids=None, exclude_subfolders=False: ([record], 7), + ) + monkeypatch.setattr( + project_cli, + "_project_browser_url", + lambda project_id: f"https://example.test/workbench/{project_id}", + ) + + result = runner.invoke(flow360, ["project", "list", "--keyword", "wing", "--format", "text"]) + + assert result.exit_code == 0 + assert ">>> Projects sorted by creation time:" in result.output + assert "Name: Wing Study" in result.output + assert "Created with: Geometry" in result.output + assert "Solver: release-25.2" in result.output + assert "Link: https://example.test/workbench/prj-123" in result.output + assert "Geometry count: 1" in result.output + assert "Case count: 3" in result.output + assert "Showing 1 of 7 matching projects." in result.output + + +def test_show_projects_uses_project_list_formatter(monkeypatch): + from flow360.cli import project as project_cli + + runner = CliRunner() + calls = {} + record = SimpleNamespace( + name="Wing Study", + project_id="prj-123", + tags=[], + description=None, + solver_version="release-25.2", + created_at="2025-01-01T00:00:00Z", + root_item_type="Geometry", + ) + + monkeypatch.setattr( + project_cli, + "_get_project_records", + lambda search=None, limit=25, folder_ids=None, exclude_subfolders=False: ( + calls.update({"search": search, "limit": limit}) + or ([record], 1) + ), + ) + monkeypatch.setattr( + project_cli, + "_project_browser_url", + lambda project_id: f"https://example.test/workbench/{project_id}", + ) + + result = runner.invoke(flow360, ["show_projects", "-k", "wing"]) + + assert result.exit_code == 0 + assert calls == {"search": "wing", "limit": 200} + assert "Name: Wing Study" in result.output + assert "Link: https://example.test/workbench/prj-123" in result.output + + def test_project_ls_alias_outputs_records(monkeypatch): from flow360.cli import project as project_cli diff --git a/tests/v1/test_cli_simulation_summary.py b/tests/v1/test_cli_simulation_summary.py new file mode 100644 index 000000000..ea1e30022 --- /dev/null +++ b/tests/v1/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/v1/test_cli_webapi_integration.py b/tests/v1/test_cli_webapi_integration.py index f09920602..f53b6f61c 100644 --- a/tests/v1/test_cli_webapi_integration.py +++ b/tests/v1/test_cli_webapi_integration.py @@ -211,6 +211,29 @@ def test_geometry_simulation_get_uses_geometry_simulation_endpoint(recorded_weba } +def test_geometry_summary_uses_geometry_simulation_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, ["geometry", "summary", GEOMETRY_ID]) + + assert result.exit_code == 0 + payload = _load_json_output(result.output) + assert payload["id"] == GEOMETRY_ID + assert "summary" in payload + assert recorded_webapi_calls[-1] == { + "type": "get", + "url": f"/v2/geometries/{GEOMETRY_ID}/simulation/file", + "params": {"type": "simulation"}, + } + + def test_case_info_uses_case_v2_endpoint(recorded_webapi_calls): runner = CliRunner() @@ -309,6 +332,31 @@ def test_surface_mesh_simulation_get_uses_surface_mesh_simulation_endpoint(recor } +def test_surface_mesh_summary_uses_surface_mesh_simulation_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, ["surface-mesh", "summary", SURFACE_MESH_ID]) + + assert result.exit_code == 0 + payload = _load_json_output(result.output) + assert payload["id"] == SURFACE_MESH_ID + assert "summary" in payload + assert recorded_webapi_calls[-1] == { + "type": "get", + "url": f"/v2/surface-meshes/{SURFACE_MESH_ID}/simulation/file", + "params": {"type": "simulation"}, + } + + def test_volume_mesh_info_uses_volume_mesh_v2_endpoint(recorded_webapi_calls): runner = CliRunner() @@ -390,6 +438,31 @@ def test_volume_mesh_simulation_get_uses_volume_mesh_simulation_endpoint(recorde } +def test_volume_mesh_summary_uses_volume_mesh_simulation_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, ["volume-mesh", "summary", VOLUME_MESH_ID]) + + assert result.exit_code == 0 + payload = _load_json_output(result.output) + assert payload["id"] == VOLUME_MESH_ID + assert "summary" in payload + assert recorded_webapi_calls[-1] == { + "type": "get", + "url": f"/v2/volume-meshes/{VOLUME_MESH_ID}/simulation/file", + "params": {"type": "simulation"}, + } + + def test_case_get_alias_uses_case_v2_endpoint(recorded_webapi_calls): runner = CliRunner() @@ -423,6 +496,29 @@ def test_case_simulation_get_uses_case_simulation_endpoint(recorded_webapi_calls } +def test_case_summary_uses_case_simulation_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": {"fluid": []}}, + ) + + result = runner.invoke(flow360, ["case", "summary", CASE_ID]) + + assert result.exit_code == 0 + payload = _load_json_output(result.output) + assert payload["id"] == CASE_ID + assert "summary" in payload + assert recorded_webapi_calls[-1] == { + "type": "get", + "url": f"/v2/cases/{CASE_ID}/simulation/file", + "params": {"type": "simulation"}, + } + + def test_case_results_ls_uses_legacy_case_files_endpoint(recorded_webapi_calls): runner = CliRunner() @@ -618,9 +714,7 @@ def test_draft_run_uses_run_endpoint(recorded_webapi_calls): def test_draft_run_wait_polls_result_state_endpoint(recorded_webapi_calls): runner = CliRunner() - result = runner.invoke( - flow360, ["draft", "run", DRAFT_ID, "--up-to", "volume-mesh", "--wait"] - ) + result = runner.invoke(flow360, ["draft", "run", DRAFT_ID, "--up-to", "volume-mesh", "--wait"]) assert result.exit_code == 0 payload = _load_json_output(result.output) @@ -650,7 +744,16 @@ def test_draft_run_from_project_creates_sets_and_runs(recorded_webapi_calls, tmp result = runner.invoke( flow360, - ["draft", "run", PROJECT_ID, str(simulation_path), "--name", "Alpha -18", "--up-to", "volume-mesh"], + [ + "draft", + "run", + PROJECT_ID, + str(simulation_path), + "--name", + "Alpha -18", + "--up-to", + "volume-mesh", + ], ) assert result.exit_code == 0 @@ -696,9 +799,7 @@ def test_draft_run_from_project_creates_sets_and_runs(recorded_webapi_calls, tmp } -def test_draft_run_from_project_patch_fetches_merges_sets_and_runs( - recorded_webapi_calls, tmp_path -): +def test_draft_run_from_project_patch_fetches_merges_sets_and_runs(recorded_webapi_calls, tmp_path): runner = CliRunner() patch_path = tmp_path / "patch.json" patch_path.write_text('{"meshing":{"refinement_factor":2.5}}') @@ -791,7 +892,9 @@ def test_case_rename_uses_case_patch_endpoint(recorded_webapi_calls): def test_geometry_rename_uses_geometry_patch_endpoint(recorded_webapi_calls): runner = CliRunner() - result = runner.invoke(flow360, ["geometry", "rename", GEOMETRY_ID, "--name", "Renamed Geometry"]) + result = runner.invoke( + flow360, ["geometry", "rename", GEOMETRY_ID, "--name", "Renamed Geometry"] + ) assert result.exit_code == 0 payload = _load_json_output(result.output) @@ -954,7 +1057,13 @@ def test_folder_move_uses_folder_patch_endpoint(recorded_webapi_calls): result = runner.invoke( flow360, - ["folder", "move", FOLDER_ID, "--parent-folder-id", "folder-4da3cdd0-c5b6-4130-9ca1-196237322ab9"], + [ + "folder", + "move", + FOLDER_ID, + "--parent-folder-id", + "folder-4da3cdd0-c5b6-4130-9ca1-196237322ab9", + ], ) assert result.exit_code == 0 From bd56713d7e7b30d8ee3a09c1763dda51c80edf66 Mon Sep 17 00:00:00 2001 From: Maciej Skarysz Date: Tue, 28 Apr 2026 14:04:06 +0200 Subject: [PATCH 05/15] Move CLI tests out of v1 package --- tests/{v1 => cli}/test_cli_assets.py | 0 tests/{v1 => cli}/test_cli_auth_guidance.py | 0 tests/{v1 => cli}/test_cli_draft.py | 0 tests/cli/test_cli_folder.py | 98 ++ tests/{v1 => cli}/test_cli_open.py | 0 tests/cli/test_cli_project.py | 303 ++++- tests/{v1 => cli}/test_cli_resource_refs.py | 0 .../test_cli_simulation_summary.py | 0 tests/{v1 => cli}/test_cli_wait.py | 0 tests/cli/test_cli_webapi_integration.py | 944 +++++++++++++- .../{v1 => cli}/test_remote_resource_logs.py | 0 tests/{v1 => cli}/test_workspace_webapi.py | 0 tests/v1/test_cli_folder.py | 169 --- tests/v1/test_cli_project.py | 667 ---------- tests/v1/test_cli_webapi_integration.py | 1079 ----------------- tools/cli_live_benchmark.py | 13 +- 16 files changed, 1311 insertions(+), 1962 deletions(-) rename tests/{v1 => cli}/test_cli_assets.py (100%) rename tests/{v1 => cli}/test_cli_auth_guidance.py (100%) rename tests/{v1 => cli}/test_cli_draft.py (100%) rename tests/{v1 => cli}/test_cli_open.py (100%) rename tests/{v1 => cli}/test_cli_resource_refs.py (100%) rename tests/{v1 => cli}/test_cli_simulation_summary.py (100%) rename tests/{v1 => cli}/test_cli_wait.py (100%) rename tests/{v1 => cli}/test_remote_resource_logs.py (100%) rename tests/{v1 => cli}/test_workspace_webapi.py (100%) delete mode 100644 tests/v1/test_cli_folder.py delete mode 100644 tests/v1/test_cli_project.py delete mode 100644 tests/v1/test_cli_webapi_integration.py diff --git a/tests/v1/test_cli_assets.py b/tests/cli/test_cli_assets.py similarity index 100% rename from tests/v1/test_cli_assets.py rename to tests/cli/test_cli_assets.py diff --git a/tests/v1/test_cli_auth_guidance.py b/tests/cli/test_cli_auth_guidance.py similarity index 100% rename from tests/v1/test_cli_auth_guidance.py rename to tests/cli/test_cli_auth_guidance.py diff --git a/tests/v1/test_cli_draft.py b/tests/cli/test_cli_draft.py similarity index 100% rename from tests/v1/test_cli_draft.py rename to tests/cli/test_cli_draft.py diff --git a/tests/cli/test_cli_folder.py b/tests/cli/test_cli_folder.py index bbb785c6f..70db96972 100644 --- a/tests/cli/test_cli_folder.py +++ b/tests/cli/test_cli_folder.py @@ -13,6 +13,9 @@ def test_folder_group_help_shows_read_commands(): assert result.exit_code == 0 assert "get" in result.output assert "tree" in result.output + assert "create" in result.output + assert "rename" in result.output + assert "move" in result.output def test_folder_get_outputs_metadata(monkeypatch): @@ -69,3 +72,98 @@ def test_folder_tree_outputs_nested_tree(monkeypatch): payload = json.loads(result.output) assert payload["root"]["id"] == "ROOT.FLOW360" assert payload["root"]["subfolders"][0]["id"] == "folder-123" + + +def test_folder_create_outputs_metadata(monkeypatch): + from flow360.cli import folder as folder_cli + + runner = CliRunner() + calls = {} + monkeypatch.setattr( + folder_cli, + "_create_folder", + lambda name, parent_folder_id="ROOT.FLOW360", tags=None: calls.update( + { + "name": name, + "parent_folder_id": parent_folder_id, + "tags": tags, + } + ) + or { + "id": "folder-123", + "name": name, + "parentFolderId": parent_folder_id, + "type": "folder", + "tags": list(tags or []), + "createdAt": "2025-01-01T00:00:00Z", + "updatedAt": "2025-01-01T01:00:00Z", + "parentFolders": [], + }, + ) + + result = runner.invoke( + flow360, + [ + "folder", + "create", + "--name", + "Folder A", + "--parent-folder-id", + "ROOT.FLOW360", + "--tag", + "demo", + ], + ) + + assert result.exit_code == 0 + payload = json.loads(result.output) + assert payload["id"] == "folder-123" + assert payload["name"] == "Folder A" + assert calls == { + "name": "Folder A", + "parent_folder_id": "ROOT.FLOW360", + "tags": ("demo",), + } + + +def test_folder_rename_outputs_metadata(monkeypatch): + from flow360.cli import folder as folder_cli + + runner = CliRunner() + calls = {} + monkeypatch.setattr( + folder_cli, + "_rename_folder", + lambda folder_id, new_name: calls.update({"folder_id": folder_id, "new_name": new_name}), + ) + + result = runner.invoke(flow360, ["folder", "rename", "folder-123", "--name", "Renamed"]) + + assert result.exit_code == 0 + payload = json.loads(result.output) + assert payload == {"id": "folder-123", "name": "Renamed"} + assert calls == {"folder_id": "folder-123", "new_name": "Renamed"} + + +def test_folder_move_outputs_metadata(monkeypatch): + from flow360.cli import folder as folder_cli + + runner = CliRunner() + calls = {} + monkeypatch.setattr( + folder_cli, + "_move_folder", + lambda folder_id, parent_folder_id: calls.update( + {"folder_id": folder_id, "parent_folder_id": parent_folder_id} + ), + ) + + result = runner.invoke( + flow360, + ["folder", "move", "folder-123", "--parent-folder-id", "folder-456"], + ) + + assert result.exit_code == 0 + payload = json.loads(result.output) + assert payload == {"id": "folder-123", "parent_id": "folder-456"} + assert calls == {"folder_id": "folder-123", "parent_folder_id": "folder-456"} diff --git a/tests/v1/test_cli_open.py b/tests/cli/test_cli_open.py similarity index 100% rename from tests/v1/test_cli_open.py rename to tests/cli/test_cli_open.py diff --git a/tests/cli/test_cli_project.py b/tests/cli/test_cli_project.py index aa19784ce..7b05fc4ae 100644 --- a/tests/cli/test_cli_project.py +++ b/tests/cli/test_cli_project.py @@ -13,12 +13,13 @@ def test_project_group_help_shows_read_commands(): assert result.exit_code == 0 assert "list" in result.output + assert "create" in result.output assert "info" in result.output assert "tree" in result.output assert "path" in result.output -def test_project_list_supports_search_limit_and_folder_filters(monkeypatch): +def test_project_ls_supports_search_limit_and_folder_filters(monkeypatch): from flow360.cli import project as project_cli runner = CliRunner() @@ -76,25 +77,6 @@ def test_project_list_supports_search_limit_and_folder_filters(monkeypatch): } -def test_get_project_records_accepts_none_folder_ids(monkeypatch): - from flow360.cli import project as project_cli - from flow360.component.simulation.web import project_records - - calls = {} - - def fake_get_project_records(**kwargs): - calls.update(kwargs) - return [], 0 - - monkeypatch.setattr(project_records, "get_project_records", fake_get_project_records) - - records, total = project_cli._get_project_records(folder_ids=None) - - assert records == [] - assert total == 0 - assert calls["folder_ids"] is None - - def test_global_dev_and_profile_apply_to_project_commands(monkeypatch): from flow360.cli import project as project_cli from flow360.environment import Env @@ -106,7 +88,9 @@ def test_global_dev_and_profile_apply_to_project_commands(monkeypatch): monkeypatch.setattr( project_cli, "_get_project_info", - lambda project_id: seen.update({"env": Env.current.name, "profile": UserConfig.profile}) + lambda project_id: seen.update( + {"env": Env.current.name, "profile": UserConfig.profile} + ) or { "id": project_id, "name": "Wing Study", @@ -117,21 +101,161 @@ def test_global_dev_and_profile_apply_to_project_commands(monkeypatch): }, ) + result = runner.invoke( + flow360, + ["--dev", "--profile", "alt", "project", "get", "prj-12345678-1234-1234-1234-123456789abc"], + ) + + assert result.exit_code == 0 + assert seen["env"] == "dev" + assert seen["profile"] == "alt" + + +def test_project_create_from_geometry_calls_sdk(monkeypatch, tmp_path): + from flow360.cli import project as project_cli + + runner = CliRunner() + calls = {} + file_a = tmp_path / "wing.csm" + file_b = tmp_path / "wing.step" + file_a.write_text("solid") + file_b.write_text("solid") + + class FakeProject: + id = "prj-123" + + @staticmethod + def get_metadata(): + return SimpleNamespace( + name="Wing Project", + tags=["demo"], + root_item_id="geo-123", + root_item_type="Geometry", + ) + + monkeypatch.setattr( + project_cli, + "_create_project", + lambda **kwargs: calls.update(kwargs) or FakeProject(), + ) + result = runner.invoke( flow360, [ - "--dev", - "--profile", - "alt", "project", - "info", - "prj-12345678-1234-1234-1234-123456789abc", + "create", + "--from", + "geometry", + "--file", + str(file_a), + "--file", + str(file_b), + "--name", + "Wing Project", + "--solver-version", + "release-25.9", + "--length-unit", + "cm", + "--description", + "demo project", + "--tag", + "demo", + "--folder-id", + "folder-123", ], ) assert result.exit_code == 0 - assert seen["env"] == "dev" - assert seen["profile"] == "alt" + payload = json.loads(result.output) + assert payload["id"] == "prj-123" + assert payload["root_item"]["id"] == "geo-123" + assert calls == { + "source": "geometry", + "files": (str(file_a), str(file_b)), + "name": "Wing Project", + "solver_version": "release-25.9", + "length_unit": "cm", + "description": "demo project", + "tags": ("demo",), + "folder_id": "folder-123", + "run_async": False, + } + + +def test_project_create_async_outputs_project_id(monkeypatch, tmp_path): + from flow360.cli import project as project_cli + + runner = CliRunner() + mesh_file = tmp_path / "mesh.cgns" + mesh_file.write_text("mesh") + + monkeypatch.setattr(project_cli, "_create_project", lambda **kwargs: "prj-async") + + result = runner.invoke( + flow360, + [ + "project", + "create", + "--from", + "surface-mesh", + "--file", + str(mesh_file), + "--async", + ], + ) + + assert result.exit_code == 0 + assert json.loads(result.output) == {"async": True, "id": "prj-async"} + + +def test_project_create_surface_mesh_requires_single_file(tmp_path): + runner = CliRunner() + file_a = tmp_path / "mesh-a.cgns" + file_b = tmp_path / "mesh-b.cgns" + file_a.write_text("mesh") + file_b.write_text("mesh") + + result = runner.invoke( + flow360, + [ + "project", + "create", + "--from", + "surface-mesh", + "--file", + str(file_a), + "--file", + str(file_b), + ], + ) + + assert result.exit_code != 0 + assert "surface-mesh projects require exactly one --file." in result.output + + +def test_project_create_volume_mesh_requires_single_file(tmp_path): + runner = CliRunner() + file_a = tmp_path / "mesh-a.cgns" + file_b = tmp_path / "mesh-b.cgns" + file_a.write_text("mesh") + file_b.write_text("mesh") + + result = runner.invoke( + flow360, + [ + "project", + "create", + "--from", + "volume-mesh", + "--file", + str(file_a), + "--file", + str(file_b), + ], + ) + + assert result.exit_code != 0 + assert "volume-mesh projects require exactly one --file." in result.output def test_project_list_outputs_records(monkeypatch): @@ -239,7 +363,8 @@ def test_show_projects_uses_project_list_formatter(monkeypatch): project_cli, "_get_project_records", lambda search=None, limit=25, folder_ids=None, exclude_subfolders=False: ( - calls.update({"search": search, "limit": limit}) or ([record], 1) + calls.update({"search": search, "limit": limit}) + or ([record], 1) ), ) monkeypatch.setattr( @@ -256,6 +381,36 @@ def test_show_projects_uses_project_list_formatter(monkeypatch): assert "Link: https://example.test/workbench/prj-123" in result.output +def test_project_ls_alias_outputs_records(monkeypatch): + from flow360.cli import project as project_cli + + runner = CliRunner() + record = SimpleNamespace( + name="Wing Study", + project_id="prj-123", + tags=["demo"], + description="test project", + solver_version="release-25.2", + created_at="2025-01-01T00:00:00Z", + root_item_type="Geometry", + ) + + monkeypatch.setattr( + project_cli, + "_get_project_records", + lambda search=None, limit=25, folder_ids=None, exclude_subfolders=False: ([record], 1), + ) + + result = runner.invoke(flow360, ["project", "ls", "--keyword", "wing"]) + + assert result.exit_code == 0 + payload = json.loads(result.output) + assert payload["records"][0]["id"] == "prj-123" + assert payload["records"][0]["name"] == "Wing Study" + assert payload["returned"] == 1 + assert payload["total"] == 1 + + def test_project_info_outputs_metadata(monkeypatch): from flow360.cli import project as project_cli @@ -285,6 +440,35 @@ def test_project_info_outputs_metadata(monkeypatch): assert payload["root_item"]["type"] == "Geometry" +def test_project_get_alias_outputs_metadata(monkeypatch): + from flow360.cli import project as project_cli + + runner = CliRunner() + info = { + "id": "prj-123", + "name": "Wing Study", + "solverVersion": "release-25.2", + "tags": ["demo"], + "rootItemId": "geo-123", + "rootItemType": "Geometry", + } + + monkeypatch.setattr( + project_cli, + "_get_project_info", + lambda project_id: info, + ) + + result = runner.invoke(flow360, ["project", "get", "prj-123"]) + + assert result.exit_code == 0 + payload = json.loads(result.output) + assert payload["id"] == "prj-123" + assert payload["name"] == "Wing Study" + assert payload["root_item"]["id"] == "geo-123" + assert payload["root_item"]["type"] == "Geometry" + + def test_project_tree_outputs_nested_tree(monkeypatch): from flow360.cli import project as project_cli @@ -420,3 +604,64 @@ def test_project_path_outputs_flat_branch(monkeypatch): "updated_at": "2025-01-01T01:00:00Z", }, ] + + +def test_project_rename_calls_webapi(monkeypatch): + from flow360.cli import project as project_cli + + runner = CliRunner() + calls = {} + + class FakeWebApi: + def __init__(self, project_id): + calls["project_id"] = project_id + + def patch(self, payload): + calls["payload"] = payload + + monkeypatch.setattr(project_cli, "_rename_project", lambda project_id, new_name: None) + monkeypatch.setattr( + project_cli, + "_rename_project", + lambda project_id, new_name: calls.update( + { + "project_id": project_id, + "new_name": new_name, + } + ), + ) + + result = runner.invoke(flow360, ["project", "rename", "prj-123", "--name", "New Name"]) + + assert result.exit_code == 0 + assert calls["project_id"] == "prj-123" + assert calls["new_name"] == "New Name" + assert "Renamed project prj-123 to New Name." in result.output + + +def test_project_delete_requires_yes(): + runner = CliRunner() + + result = runner.invoke(flow360, ["project", "delete", "prj-123"]) + + assert result.exit_code != 0 + assert "Pass --yes to confirm project deletion." in result.output + + +def test_project_delete_calls_webapi(monkeypatch): + from flow360.cli import project as project_cli + + runner = CliRunner() + calls = {} + + monkeypatch.setattr( + project_cli, + "_delete_project", + lambda project_id: calls.update({"project_id": project_id}), + ) + + result = runner.invoke(flow360, ["project", "delete", "prj-123", "--yes"]) + + assert result.exit_code == 0 + assert calls["project_id"] == "prj-123" + assert "Deleted project prj-123." in result.output diff --git a/tests/v1/test_cli_resource_refs.py b/tests/cli/test_cli_resource_refs.py similarity index 100% rename from tests/v1/test_cli_resource_refs.py rename to tests/cli/test_cli_resource_refs.py diff --git a/tests/v1/test_cli_simulation_summary.py b/tests/cli/test_cli_simulation_summary.py similarity index 100% rename from tests/v1/test_cli_simulation_summary.py rename to tests/cli/test_cli_simulation_summary.py diff --git a/tests/v1/test_cli_wait.py b/tests/cli/test_cli_wait.py similarity index 100% rename from tests/v1/test_cli_wait.py rename to tests/cli/test_cli_wait.py diff --git a/tests/cli/test_cli_webapi_integration.py b/tests/cli/test_cli_webapi_integration.py index ecfaedab6..f53b6f61c 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" @@ -49,6 +53,21 @@ def test_project_info_uses_project_info_endpoint(recorded_webapi_calls): } +def test_project_get_alias_uses_project_info_endpoint(recorded_webapi_calls): + runner = CliRunner() + + result = runner.invoke(flow360, ["project", "get", PROJECT_ID]) + + assert result.exit_code == 0 + payload = _load_json_output(result.output) + assert payload["id"] == PROJECT_ID + assert recorded_webapi_calls[-1] == { + "type": "get", + "url": f"/v2/projects/{PROJECT_ID}", + "params": None, + } + + def test_project_tree_uses_tree_endpoint(recorded_webapi_calls): runner = CliRunner() @@ -64,14 +83,14 @@ def test_project_tree_uses_tree_endpoint(recorded_webapi_calls): } -def test_project_list_uses_limit_search_and_folder_filters(recorded_webapi_calls): +def test_project_ls_uses_limit_search_and_folder_filters(recorded_webapi_calls): runner = CliRunner() result = runner.invoke( flow360, [ "project", - "list", + "ls", "--search", "wing", "--limit", @@ -127,31 +146,934 @@ def test_project_path_uses_path_endpoint(recorded_webapi_calls): } -def test_folder_get_uses_folder_v2_endpoint(recorded_webapi_calls): +def test_geometry_info_uses_geometry_v2_endpoint(recorded_webapi_calls): runner = CliRunner() - result = runner.invoke(flow360, ["folder", "get", FOLDER_ID]) + result = runner.invoke(flow360, ["geometry", "info", GEOMETRY_ID]) assert result.exit_code == 0 payload = _load_json_output(result.output) - assert payload["id"] == FOLDER_ID + assert payload["id"] == GEOMETRY_ID + assert payload["type"] == "Geometry" assert recorded_webapi_calls[-1] == { "type": "get", - "url": f"/v2/folders/{FOLDER_ID}", + "url": f"/v2/geometries/{GEOMETRY_ID}", "params": None, } -def test_folder_tree_uses_folder_list_endpoint(recorded_webapi_calls): +def test_geometry_get_alias_uses_geometry_v2_endpoint(recorded_webapi_calls): runner = CliRunner() - result = runner.invoke(flow360, ["folder", "tree"]) + result = runner.invoke(flow360, ["geometry", "get", GEOMETRY_ID]) assert result.exit_code == 0 payload = _load_json_output(result.output) - assert payload["root"]["id"] == "ROOT.FLOW360" + assert payload["id"] == GEOMETRY_ID + assert payload["type"] == "Geometry" assert recorded_webapi_calls[-1] == { "type": "get", - "url": "/v2/folders", - "params": {"includeSubfolders": True, "page": 0, "size": 1000}, + "url": f"/v2/geometries/{GEOMETRY_ID}", + "params": None, + } + + +def test_geometry_state_uses_geometry_v2_endpoint(recorded_webapi_calls): + runner = CliRunner() + + result = runner.invoke(flow360, ["geometry", "state", GEOMETRY_ID]) + + assert result.exit_code == 0 + payload = _load_json_output(result.output) + assert payload["id"] == GEOMETRY_ID + assert payload["status"] == "processed" + assert recorded_webapi_calls[-1] == { + "type": "get", + "url": f"/v2/geometries/{GEOMETRY_ID}", + "params": None, + } + + +def test_geometry_simulation_get_uses_geometry_simulation_endpoint(recorded_webapi_calls): + runner = CliRunner() + + result = runner.invoke(flow360, ["geometry", "simulation", "get", GEOMETRY_ID]) + + assert result.exit_code == 0 + payload = _load_json_output(result.output) + assert "simulation" in payload + assert isinstance(payload["simulation"], dict) + assert "models" in payload["simulation"] + assert recorded_webapi_calls[-1] == { + "type": "get", + "url": f"/v2/geometries/{GEOMETRY_ID}/simulation/file", + "params": {"type": "simulation"}, + } + + +def test_geometry_summary_uses_geometry_simulation_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, ["geometry", "summary", GEOMETRY_ID]) + + assert result.exit_code == 0 + payload = _load_json_output(result.output) + assert payload["id"] == GEOMETRY_ID + assert "summary" in payload + assert recorded_webapi_calls[-1] == { + "type": "get", + "url": f"/v2/geometries/{GEOMETRY_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"] == "vm-7c3681cd-8c6c-4db7-a62c-1742d825e9d3" + 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"] == "vm-7c3681cd-8c6c-4db7-a62c-1742d825e9d3" + assert recorded_webapi_calls[-1] == { + "type": "get", + "url": f"/v2/cases/{CASE_ID}", + "params": None, + } + + +def test_surface_mesh_info_uses_surface_mesh_v2_endpoint(recorded_webapi_calls): + runner = CliRunner() + + result = runner.invoke(flow360, ["surface-mesh", "info", SURFACE_MESH_ID]) + + assert result.exit_code == 0 + payload = _load_json_output(result.output) + assert payload["id"] == SURFACE_MESH_ID + assert payload["type"] == "SurfaceMesh" + assert recorded_webapi_calls[-1] == { + "type": "get", + "url": f"/v2/surface-meshes/{SURFACE_MESH_ID}", + "params": None, + } + + +def test_surface_mesh_get_alias_uses_surface_mesh_v2_endpoint(recorded_webapi_calls): + runner = CliRunner() + + result = runner.invoke(flow360, ["surface-mesh", "get", SURFACE_MESH_ID]) + + assert result.exit_code == 0 + payload = _load_json_output(result.output) + assert payload["id"] == SURFACE_MESH_ID + assert payload["type"] == "SurfaceMesh" + assert recorded_webapi_calls[-1] == { + "type": "get", + "url": f"/v2/surface-meshes/{SURFACE_MESH_ID}", + "params": None, + } + + +def test_surface_mesh_state_uses_surface_mesh_v2_endpoint(recorded_webapi_calls): + runner = CliRunner() + + result = runner.invoke(flow360, ["surface-mesh", "state", SURFACE_MESH_ID]) + + assert result.exit_code == 0 + payload = _load_json_output(result.output) + assert payload["id"] == SURFACE_MESH_ID + assert payload["status"] == "processed" + assert recorded_webapi_calls[-1] == { + "type": "get", + "url": f"/v2/surface-meshes/{SURFACE_MESH_ID}", + "params": None, + } + + +def test_surface_mesh_simulation_get_uses_surface_mesh_simulation_endpoint(recorded_webapi_calls): + runner = CliRunner() + + result = runner.invoke(flow360, ["surface-mesh", "simulation", "get", SURFACE_MESH_ID]) + + assert result.exit_code == 0 + payload = _load_json_output(result.output) + assert "simulation" in payload + assert isinstance(payload["simulation"], dict) + assert "models" in payload["simulation"] + assert recorded_webapi_calls[-1] == { + "type": "get", + "url": f"/v2/surface-meshes/{SURFACE_MESH_ID}/simulation/file", + "params": {"type": "simulation"}, + } + + +def test_surface_mesh_summary_uses_surface_mesh_simulation_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, ["surface-mesh", "summary", SURFACE_MESH_ID]) + + assert result.exit_code == 0 + payload = _load_json_output(result.output) + assert payload["id"] == SURFACE_MESH_ID + assert "summary" in payload + assert recorded_webapi_calls[-1] == { + "type": "get", + "url": f"/v2/surface-meshes/{SURFACE_MESH_ID}/simulation/file", + "params": {"type": "simulation"}, + } + + +def test_volume_mesh_info_uses_volume_mesh_v2_endpoint(recorded_webapi_calls): + runner = CliRunner() + + result = runner.invoke(flow360, ["volume-mesh", "info", VOLUME_MESH_ID]) + + assert result.exit_code == 0 + payload = _load_json_output(result.output) + assert payload["id"] == VOLUME_MESH_ID + assert payload["type"] == "VolumeMesh" + assert recorded_webapi_calls[-1] == { + "type": "get", + "url": f"/v2/volume-meshes/{VOLUME_MESH_ID}", + "params": None, + } + + +def test_volume_mesh_get_alias_uses_volume_mesh_v2_endpoint(recorded_webapi_calls): + runner = CliRunner() + + result = runner.invoke(flow360, ["volume-mesh", "get", VOLUME_MESH_ID]) + + assert result.exit_code == 0 + payload = _load_json_output(result.output) + assert payload["id"] == VOLUME_MESH_ID + assert payload["type"] == "VolumeMesh" + assert recorded_webapi_calls[-1] == { + "type": "get", + "url": f"/v2/volume-meshes/{VOLUME_MESH_ID}", + "params": None, + } + + +def test_volume_mesh_state_uses_volume_mesh_v2_endpoint(recorded_webapi_calls): + runner = CliRunner() + + result = runner.invoke(flow360, ["volume-mesh", "state", VOLUME_MESH_ID]) + + 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_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_volume_mesh_simulation_get_uses_volume_mesh_simulation_endpoint(recorded_webapi_calls): + runner = CliRunner() + + result = runner.invoke(flow360, ["volume-mesh", "simulation", "get", VOLUME_MESH_ID]) + + assert result.exit_code == 0 + payload = _load_json_output(result.output) + assert "simulation" in payload + assert isinstance(payload["simulation"], dict) + assert "models" in payload["simulation"] + assert recorded_webapi_calls[-1] == { + "type": "get", + "url": f"/v2/volume-meshes/{VOLUME_MESH_ID}/simulation/file", + "params": {"type": "simulation"}, + } + + +def test_volume_mesh_summary_uses_volume_mesh_simulation_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, ["volume-mesh", "summary", VOLUME_MESH_ID]) + + assert result.exit_code == 0 + payload = _load_json_output(result.output) + assert payload["id"] == VOLUME_MESH_ID + assert "summary" in payload + assert recorded_webapi_calls[-1] == { + "type": "get", + "url": f"/v2/volume-meshes/{VOLUME_MESH_ID}/simulation/file", + "params": {"type": "simulation"}, + } + + +def test_case_get_alias_uses_case_v2_endpoint(recorded_webapi_calls): + runner = CliRunner() + + result = runner.invoke(flow360, ["case", "get", CASE_ID]) + + assert result.exit_code == 0 + payload = _load_json_output(result.output) + assert payload["id"] == CASE_ID + assert payload["mesh_id"] == "vm-7c3681cd-8c6c-4db7-a62c-1742d825e9d3" + assert recorded_webapi_calls[-1] == { + "type": "get", + "url": f"/v2/cases/{CASE_ID}", + "params": None, + } + + +def test_case_simulation_get_uses_case_simulation_endpoint(recorded_webapi_calls): + runner = CliRunner() + + result = runner.invoke(flow360, ["case", "simulation", "get", CASE_ID]) + + assert result.exit_code == 0 + payload = _load_json_output(result.output) + assert "simulation" in payload + assert isinstance(payload["simulation"], dict) + assert "models" in payload["simulation"] + assert recorded_webapi_calls[-1] == { + "type": "get", + "url": f"/v2/cases/{CASE_ID}/simulation/file", + "params": {"type": "simulation"}, + } + + +def test_case_summary_uses_case_simulation_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": {"fluid": []}}, + ) + + result = runner.invoke(flow360, ["case", "summary", CASE_ID]) + + assert result.exit_code == 0 + payload = _load_json_output(result.output) + assert payload["id"] == CASE_ID + assert "summary" in payload + assert recorded_webapi_calls[-1] == { + "type": "get", + "url": f"/v2/cases/{CASE_ID}/simulation/file", + "params": {"type": "simulation"}, + } + + +def test_case_results_ls_uses_legacy_case_files_endpoint(recorded_webapi_calls): + runner = CliRunner() + + result = runner.invoke(flow360, ["case", "results", "list", CASE_ID]) + + assert result.exit_code == 0 + payload = _load_json_output(result.output) + assert payload["records"] + assert all(record["path"].startswith("results/") for record in payload["records"]) + assert recorded_webapi_calls[-1] == { + "type": "get", + "url": f"/cases/{CASE_ID}/files", + "params": None, + } + + +def test_case_results_get_uses_legacy_case_files_endpoint(monkeypatch, recorded_webapi_calls): + from flow360.cli import assets as assets_cli + + runner = CliRunner() + monkeypatch.setattr( + assets_cli, + "_download_case_result", + lambda case_id, result_path, to_path=None, overwrite=False: "/tmp/total_forces_v2.csv", + ) + + result = runner.invoke( + flow360, + ["case", "results", "get", CASE_ID, "force_output_wing_all_planes_forces_v2.csv"], + ) + + assert result.exit_code == 0 + payload = _load_json_output(result.output) + assert payload["saved_to"] == "/tmp/total_forces_v2.csv" + assert payload["result"]["path"] == "results/force_output_wing_all_planes_forces_v2.csv" + assert recorded_webapi_calls[-1] == { + "type": "get", + "url": f"/cases/{CASE_ID}/files", + "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_get_alias_uses_draft_info_endpoint(recorded_webapi_calls): + runner = CliRunner() + + result = runner.invoke(flow360, ["draft", "get", 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_draft_simulation_set_uses_simulation_endpoint(recorded_webapi_calls, tmp_path): + runner = CliRunner() + file_path = tmp_path / "params.json" + file_path.write_text('{"version":"24.11.0","unit_system":{"name":"SI"}}') + + result = runner.invoke( + flow360, + ["draft", "simulation", "set", DRAFT_ID, str(file_path)], + ) + + assert result.exit_code == 0 + payload = _load_json_output(result.output) + assert payload == {"id": DRAFT_ID, "updated": True} + assert recorded_webapi_calls[-1] == { + "type": "post", + "url": f"/v2/drafts/{DRAFT_ID}/simulation/file", + "params": { + "data": '{"version": "24.11.0", "unit_system": {"name": "SI"}}', + "type": "simulation", + "version": "", + }, + } + + +def test_draft_create_from_project_resolves_root_and_posts_to_drafts(recorded_webapi_calls): + runner = CliRunner() + + result = runner.invoke(flow360, ["draft", "create", PROJECT_ID, "--name", "Draft A"]) + + assert result.exit_code == 0 + payload = _load_json_output(result.output) + assert payload["id"] == DRAFT_ID + assert payload["source_item_id"] == GEOMETRY_ID + calls = recorded_webapi_calls[-3:] + assert calls[0] == { + "type": "get", + "url": f"/v2/projects/{PROJECT_ID}", + "params": None, + } + assert calls[1] == { + "type": "get", + "url": f"/v2/geometries/{GEOMETRY_ID}", + "params": None, + } + assert calls[2]["type"] == "post" + assert calls[2]["url"] == "/v2/drafts" + assert calls[2]["params"]["name"] == "Draft A" + assert calls[2]["params"]["projectId"] == PROJECT_ID + assert calls[2]["params"]["sourceItemId"] == GEOMETRY_ID + assert calls[2]["params"]["sourceItemType"] == "Geometry" + assert calls[2]["params"]["solverVersion"] == "release-24.11" + assert calls[2]["params"]["forkCase"] is False + + +def test_draft_run_uses_run_endpoint(recorded_webapi_calls): + runner = CliRunner() + + result = runner.invoke(flow360, ["draft", "run", DRAFT_ID, "--up-to", "volume-mesh"]) + + assert result.exit_code == 0 + payload = _load_json_output(result.output) + assert payload["id"] == VOLUME_MESH_ID + assert payload["type"] == "VolumeMesh" + assert recorded_webapi_calls[-1] == { + "type": "post", + "url": f"/v2/drafts/{DRAFT_ID}/run", + "params": { + "upTo": "VolumeMesh", + "useInHouse": False, + "useGai": False, + }, + } + + +def test_draft_run_wait_polls_result_state_endpoint(recorded_webapi_calls): + runner = CliRunner() + + result = runner.invoke(flow360, ["draft", "run", DRAFT_ID, "--up-to", "volume-mesh", "--wait"]) + + assert result.exit_code == 0 + payload = _load_json_output(result.output) + assert payload["result"]["id"] == VOLUME_MESH_ID + assert payload["state"]["id"] == VOLUME_MESH_ID + calls = recorded_webapi_calls[-2:] + assert calls[0] == { + "type": "post", + "url": f"/v2/drafts/{DRAFT_ID}/run", + "params": { + "upTo": "VolumeMesh", + "useInHouse": False, + "useGai": False, + }, + } + assert calls[1] == { + "type": "get", + "url": f"/v2/volume-meshes/{VOLUME_MESH_ID}", + "params": None, + } + + +def test_draft_run_from_project_creates_sets_and_runs(recorded_webapi_calls, tmp_path): + runner = CliRunner() + simulation_path = tmp_path / "simulation.json" + simulation_path.write_text('{"version":"24.11.0","unit_system":{"name":"SI"}}') + + result = runner.invoke( + flow360, + [ + "draft", + "run", + PROJECT_ID, + str(simulation_path), + "--name", + "Alpha -18", + "--up-to", + "volume-mesh", + ], + ) + + assert result.exit_code == 0 + payload = _load_json_output(result.output) + assert payload["draft"]["id"] == DRAFT_ID + assert payload["result"]["id"] == VOLUME_MESH_ID + calls = recorded_webapi_calls[-5:] + assert calls[0] == { + "type": "get", + "url": f"/v2/projects/{PROJECT_ID}", + "params": None, + } + assert calls[1] == { + "type": "get", + "url": f"/v2/geometries/{GEOMETRY_ID}", + "params": None, + } + assert calls[2]["type"] == "post" + assert calls[2]["url"] == "/v2/drafts" + assert calls[2]["params"]["projectId"] == PROJECT_ID + assert calls[2]["params"]["sourceItemId"] == GEOMETRY_ID + assert calls[2]["params"]["sourceItemType"] == "Geometry" + assert calls[2]["params"]["solverVersion"] == "release-24.11" + assert calls[2]["params"]["forkCase"] is False + assert calls[2]["params"]["name"] == "Alpha -18" + assert calls[3] == { + "type": "post", + "url": f"/v2/drafts/{DRAFT_ID}/simulation/file", + "params": { + "data": '{"version": "24.11.0", "unit_system": {"name": "SI"}}', + "type": "simulation", + "version": "", + }, + } + assert calls[4] == { + "type": "post", + "url": f"/v2/drafts/{DRAFT_ID}/run", + "params": { + "upTo": "VolumeMesh", + "useInHouse": False, + "useGai": False, + }, + } + + +def test_draft_run_from_project_patch_fetches_merges_sets_and_runs(recorded_webapi_calls, tmp_path): + runner = CliRunner() + patch_path = tmp_path / "patch.json" + patch_path.write_text('{"meshing":{"refinement_factor":2.5}}') + + result = runner.invoke( + flow360, + ["draft", "run", PROJECT_ID, "--patch", str(patch_path), "--up-to", "volume-mesh"], + ) + + assert result.exit_code == 0 + payload = _load_json_output(result.output) + assert payload["draft"]["id"] == DRAFT_ID + assert payload["result"]["id"] == VOLUME_MESH_ID + calls = recorded_webapi_calls[-6:] + assert calls[0] == { + "type": "get", + "url": f"/v2/projects/{PROJECT_ID}", + "params": None, + } + assert calls[1] == { + "type": "get", + "url": f"/v2/geometries/{GEOMETRY_ID}", + "params": None, + } + assert calls[2] == { + "type": "get", + "url": f"/v2/geometries/{GEOMETRY_ID}/simulation/file", + "params": {"type": "simulation"}, + } + assert calls[3] == { + "type": "post", + "url": "/v2/drafts", + "params": { + **calls[3]["params"], + }, + } + assert calls[3]["params"]["projectId"] == PROJECT_ID + assert calls[3]["params"]["sourceItemId"] == GEOMETRY_ID + assert calls[3]["params"]["sourceItemType"] == "Geometry" + assert calls[3]["params"]["solverVersion"] == "release-24.11" + assert calls[3]["params"]["forkCase"] is False + assert isinstance(calls[3]["params"]["name"], str) + assert calls[3]["params"]["name"] + assert calls[4]["type"] == "post" + assert calls[4]["url"] == f"/v2/drafts/{DRAFT_ID}/simulation/file" + assert calls[4]["params"]["type"] == "simulation" + assert calls[4]["params"]["version"] == "" + assert calls[5] == { + "type": "post", + "url": f"/v2/drafts/{DRAFT_ID}/run", + "params": { + "upTo": "VolumeMesh", + "useInHouse": False, + "useGai": False, + }, + } + + merged_payload = json.loads(calls[4]["params"]["data"]) + assert merged_payload["meshing"]["refinement_factor"] == 2.5 + assert "defaults" in merged_payload["meshing"] + + +def test_project_rename_uses_project_patch_endpoint(recorded_webapi_calls): + runner = CliRunner() + + result = runner.invoke(flow360, ["project", "rename", PROJECT_ID, "--name", "Renamed Project"]) + + assert result.exit_code == 0 + assert f"Renamed project {PROJECT_ID} to Renamed Project." in result.output + assert recorded_webapi_calls[-1]["type"] == "patch" + assert recorded_webapi_calls[-1]["url"] == f"/v2/projects/{PROJECT_ID}" + assert recorded_webapi_calls[-1]["params"]["name"] == "Renamed Project" + + +def test_case_rename_uses_case_patch_endpoint(recorded_webapi_calls): + runner = CliRunner() + + result = runner.invoke(flow360, ["case", "rename", CASE_ID, "--name", "Alpha -18"]) + + assert result.exit_code == 0 + payload = _load_json_output(result.output) + assert payload == {"id": CASE_ID, "name": "Alpha -18"} + assert recorded_webapi_calls[-1] == { + "type": "patch", + "url": f"/v2/cases/{CASE_ID}", + "params": {"name": "Alpha -18"}, + } + + +def test_geometry_rename_uses_geometry_patch_endpoint(recorded_webapi_calls): + runner = CliRunner() + + result = runner.invoke( + flow360, ["geometry", "rename", GEOMETRY_ID, "--name", "Renamed Geometry"] + ) + + assert result.exit_code == 0 + payload = _load_json_output(result.output) + assert payload == {"id": GEOMETRY_ID, "name": "Renamed Geometry"} + assert recorded_webapi_calls[-1] == { + "type": "patch", + "url": f"/v2/geometries/{GEOMETRY_ID}", + "params": {"name": "Renamed Geometry"}, + } + + +def test_surface_mesh_rename_uses_surface_mesh_patch_endpoint(recorded_webapi_calls): + runner = CliRunner() + + result = runner.invoke( + flow360, + ["surface-mesh", "rename", SURFACE_MESH_ID, "--name", "Renamed Surface Mesh"], + ) + + assert result.exit_code == 0 + payload = _load_json_output(result.output) + assert payload == {"id": SURFACE_MESH_ID, "name": "Renamed Surface Mesh"} + assert recorded_webapi_calls[-1] == { + "type": "patch", + "url": f"/v2/surface-meshes/{SURFACE_MESH_ID}", + "params": {"name": "Renamed Surface Mesh"}, + } + + +def test_volume_mesh_rename_uses_volume_mesh_patch_endpoint(recorded_webapi_calls): + runner = CliRunner() + + result = runner.invoke( + flow360, + ["volume-mesh", "rename", VOLUME_MESH_ID, "--name", "Renamed Volume Mesh"], + ) + + assert result.exit_code == 0 + payload = _load_json_output(result.output) + assert payload == {"id": VOLUME_MESH_ID, "name": "Renamed Volume Mesh"} + assert recorded_webapi_calls[-1] == { + "type": "patch", + "url": f"/v2/volume-meshes/{VOLUME_MESH_ID}", + "params": {"name": "Renamed Volume Mesh"}, + } + + +def test_draft_rename_uses_draft_patch_endpoint(recorded_webapi_calls): + runner = CliRunner() + + result = runner.invoke(flow360, ["draft", "rename", DRAFT_ID, "--name", "Renamed Draft"]) + + assert result.exit_code == 0 + payload = _load_json_output(result.output) + assert payload == {"id": DRAFT_ID, "name": "Renamed Draft"} + assert recorded_webapi_calls[-1] == { + "type": "patch", + "url": f"/v2/drafts/{DRAFT_ID}", + "params": {"name": "Renamed Draft"}, + } + + +def test_project_delete_uses_project_delete_endpoint(recorded_webapi_calls): + runner = CliRunner() + + result = runner.invoke(flow360, ["project", "delete", PROJECT_ID, "--yes"]) + + assert result.exit_code == 0 + assert f"Deleted project {PROJECT_ID}." in result.output + assert recorded_webapi_calls[-1] == { + "type": "delete", + "url": f"/v2/projects/{PROJECT_ID}", + "params": None, + } + + +def test_folder_get_uses_folder_info_endpoint(recorded_webapi_calls): + runner = CliRunner() + + result = runner.invoke(flow360, ["folder", "get", FOLDER_ID]) + + assert result.exit_code == 0 + payload = _load_json_output(result.output) + assert payload["id"] == FOLDER_ID + assert recorded_webapi_calls[-1] == { + "type": "get", + "url": f"/v2/folders/{FOLDER_ID}", + "params": None, + } + + +def test_folder_tree_uses_folder_list_endpoint(recorded_webapi_calls): + runner = CliRunner() + + result = runner.invoke(flow360, ["folder", "tree"]) + + assert result.exit_code == 0 + payload = _load_json_output(result.output) + assert payload["root"]["id"] == "ROOT.FLOW360" + assert recorded_webapi_calls[-1] == { + "type": "get", + "url": "/v2/folders", + "params": { + "includeSubfolders": True, + "page": 0, + "size": 1000, + }, + } + + +def test_folder_create_uses_folder_create_endpoint(recorded_webapi_calls): + runner = CliRunner() + + result = runner.invoke( + flow360, + [ + "folder", + "create", + "--name", + "Folder A", + "--parent-folder-id", + "ROOT.FLOW360", + "--tag", + "demo", + ], + ) + + assert result.exit_code == 0 + payload = _load_json_output(result.output) + assert payload["id"] == "folder-3834758b-3d39-4a4a-ad85-710b7652267c" + assert recorded_webapi_calls[-1] == { + "type": "post", + "url": "/folders", + "params": { + "name": "Folder A", + "tags": ["demo"], + "parentFolderId": "ROOT.FLOW360", + "type": "folder", + }, + } + + +def test_folder_rename_uses_folder_patch_endpoint(recorded_webapi_calls): + runner = CliRunner() + + result = runner.invoke(flow360, ["folder", "rename", FOLDER_ID, "--name", "Renamed Folder"]) + + assert result.exit_code == 0 + payload = _load_json_output(result.output) + assert payload == {"id": FOLDER_ID, "name": "Renamed Folder"} + assert recorded_webapi_calls[-1] == { + "type": "patch", + "url": f"/v2/folders/{FOLDER_ID}", + "params": {"name": "Renamed Folder"}, + } + + +def test_folder_move_uses_folder_patch_endpoint(recorded_webapi_calls): + runner = CliRunner() + + result = runner.invoke( + flow360, + [ + "folder", + "move", + FOLDER_ID, + "--parent-folder-id", + "folder-4da3cdd0-c5b6-4130-9ca1-196237322ab9", + ], + ) + + assert result.exit_code == 0 + payload = _load_json_output(result.output) + assert payload == { + "id": FOLDER_ID, + "parent_id": "folder-4da3cdd0-c5b6-4130-9ca1-196237322ab9", + } + assert recorded_webapi_calls[-1] == { + "type": "patch", + "url": f"/v2/folders/{FOLDER_ID}", + "params": {"parentFolderId": "folder-4da3cdd0-c5b6-4130-9ca1-196237322ab9"}, } diff --git a/tests/v1/test_remote_resource_logs.py b/tests/cli/test_remote_resource_logs.py similarity index 100% rename from tests/v1/test_remote_resource_logs.py rename to tests/cli/test_remote_resource_logs.py diff --git a/tests/v1/test_workspace_webapi.py b/tests/cli/test_workspace_webapi.py similarity index 100% rename from tests/v1/test_workspace_webapi.py rename to tests/cli/test_workspace_webapi.py diff --git a/tests/v1/test_cli_folder.py b/tests/v1/test_cli_folder.py deleted file mode 100644 index 70db96972..000000000 --- a/tests/v1/test_cli_folder.py +++ /dev/null @@ -1,169 +0,0 @@ -import json - -from click.testing import CliRunner - -from flow360.cli import flow360 - - -def test_folder_group_help_shows_read_commands(): - runner = CliRunner() - - result = runner.invoke(flow360, ["folder", "--help"]) - - assert result.exit_code == 0 - assert "get" in result.output - assert "tree" in result.output - assert "create" in result.output - assert "rename" in result.output - assert "move" in result.output - - -def test_folder_get_outputs_metadata(monkeypatch): - from flow360.cli import folder as folder_cli - - runner = CliRunner() - monkeypatch.setattr( - folder_cli, - "_get_folder_info", - lambda folder_id: { - "id": folder_id, - "name": "Folder A", - "parentFolderId": "ROOT.FLOW360", - "type": "folder", - "tags": ["demo"], - "createdAt": "2025-01-01T00:00:00Z", - "updatedAt": "2025-01-01T01:00:00Z", - "parentFolders": [{"id": "ROOT.FLOW360", "name": "My workspace"}], - }, - ) - - result = runner.invoke(flow360, ["folder", "get", "folder-123"]) - - assert result.exit_code == 0 - payload = json.loads(result.output) - assert payload["id"] == "folder-123" - assert payload["parent_id"] == "ROOT.FLOW360" - assert payload["type"] == "folder" - - -def test_folder_tree_outputs_nested_tree(monkeypatch): - from flow360.cli import folder as folder_cli - - runner = CliRunner() - monkeypatch.setattr( - folder_cli, - "_get_folder_tree", - lambda folder_id: { - "id": folder_id, - "name": "My workspace", - "subfolders": [ - { - "id": "folder-123", - "name": "Folder A", - "subfolders": [], - } - ], - }, - ) - - result = runner.invoke(flow360, ["folder", "tree"]) - - assert result.exit_code == 0 - payload = json.loads(result.output) - assert payload["root"]["id"] == "ROOT.FLOW360" - assert payload["root"]["subfolders"][0]["id"] == "folder-123" - - -def test_folder_create_outputs_metadata(monkeypatch): - from flow360.cli import folder as folder_cli - - runner = CliRunner() - calls = {} - monkeypatch.setattr( - folder_cli, - "_create_folder", - lambda name, parent_folder_id="ROOT.FLOW360", tags=None: calls.update( - { - "name": name, - "parent_folder_id": parent_folder_id, - "tags": tags, - } - ) - or { - "id": "folder-123", - "name": name, - "parentFolderId": parent_folder_id, - "type": "folder", - "tags": list(tags or []), - "createdAt": "2025-01-01T00:00:00Z", - "updatedAt": "2025-01-01T01:00:00Z", - "parentFolders": [], - }, - ) - - result = runner.invoke( - flow360, - [ - "folder", - "create", - "--name", - "Folder A", - "--parent-folder-id", - "ROOT.FLOW360", - "--tag", - "demo", - ], - ) - - assert result.exit_code == 0 - payload = json.loads(result.output) - assert payload["id"] == "folder-123" - assert payload["name"] == "Folder A" - assert calls == { - "name": "Folder A", - "parent_folder_id": "ROOT.FLOW360", - "tags": ("demo",), - } - - -def test_folder_rename_outputs_metadata(monkeypatch): - from flow360.cli import folder as folder_cli - - runner = CliRunner() - calls = {} - monkeypatch.setattr( - folder_cli, - "_rename_folder", - lambda folder_id, new_name: calls.update({"folder_id": folder_id, "new_name": new_name}), - ) - - result = runner.invoke(flow360, ["folder", "rename", "folder-123", "--name", "Renamed"]) - - assert result.exit_code == 0 - payload = json.loads(result.output) - assert payload == {"id": "folder-123", "name": "Renamed"} - assert calls == {"folder_id": "folder-123", "new_name": "Renamed"} - - -def test_folder_move_outputs_metadata(monkeypatch): - from flow360.cli import folder as folder_cli - - runner = CliRunner() - calls = {} - monkeypatch.setattr( - folder_cli, - "_move_folder", - lambda folder_id, parent_folder_id: calls.update( - {"folder_id": folder_id, "parent_folder_id": parent_folder_id} - ), - ) - - result = runner.invoke( - flow360, - ["folder", "move", "folder-123", "--parent-folder-id", "folder-456"], - ) - - assert result.exit_code == 0 - payload = json.loads(result.output) - assert payload == {"id": "folder-123", "parent_id": "folder-456"} - assert calls == {"folder_id": "folder-123", "parent_folder_id": "folder-456"} diff --git a/tests/v1/test_cli_project.py b/tests/v1/test_cli_project.py deleted file mode 100644 index 7b05fc4ae..000000000 --- a/tests/v1/test_cli_project.py +++ /dev/null @@ -1,667 +0,0 @@ -import json -from types import SimpleNamespace - -from click.testing import CliRunner - -from flow360.cli import flow360 - - -def test_project_group_help_shows_read_commands(): - runner = CliRunner() - - result = runner.invoke(flow360, ["project", "--help"]) - - assert result.exit_code == 0 - assert "list" in result.output - assert "create" in result.output - assert "info" in result.output - assert "tree" in result.output - assert "path" in result.output - - -def test_project_ls_supports_search_limit_and_folder_filters(monkeypatch): - from flow360.cli import project as project_cli - - runner = CliRunner() - calls = {} - record = SimpleNamespace( - name="Wing Study", - project_id="prj-123", - tags=["demo"], - description="test project", - solver_version="release-25.2", - created_at="2025-01-01T00:00:00Z", - root_item_type="Geometry", - ) - - monkeypatch.setattr( - project_cli, - "_get_project_records", - lambda search=None, limit=25, folder_ids=None, exclude_subfolders=False: ( - calls.update( - { - "search": search, - "limit": limit, - "folder_ids": folder_ids, - "exclude_subfolders": exclude_subfolders, - } - ) - or ([record], 1) - ), - ) - - result = runner.invoke( - flow360, - [ - "project", - "list", - "--search", - "wing", - "--limit", - "10", - "--folder-id", - "folder-123", - "--exclude-subfolders", - ], - ) - - assert result.exit_code == 0 - payload = json.loads(result.output) - assert payload["records"][0]["id"] == "prj-123" - assert payload["returned"] == 1 - assert calls == { - "search": "wing", - "limit": 10, - "folder_ids": ("folder-123",), - "exclude_subfolders": True, - } - - -def test_global_dev_and_profile_apply_to_project_commands(monkeypatch): - from flow360.cli import project as project_cli - from flow360.environment import Env - from flow360.user_config import UserConfig - - runner = CliRunner() - seen = {} - - monkeypatch.setattr( - project_cli, - "_get_project_info", - lambda project_id: seen.update( - {"env": Env.current.name, "profile": UserConfig.profile} - ) - or { - "id": project_id, - "name": "Wing Study", - "solverVersion": "release-25.2", - "tags": [], - "rootItemId": "geo-123", - "rootItemType": "Geometry", - }, - ) - - result = runner.invoke( - flow360, - ["--dev", "--profile", "alt", "project", "get", "prj-12345678-1234-1234-1234-123456789abc"], - ) - - assert result.exit_code == 0 - assert seen["env"] == "dev" - assert seen["profile"] == "alt" - - -def test_project_create_from_geometry_calls_sdk(monkeypatch, tmp_path): - from flow360.cli import project as project_cli - - runner = CliRunner() - calls = {} - file_a = tmp_path / "wing.csm" - file_b = tmp_path / "wing.step" - file_a.write_text("solid") - file_b.write_text("solid") - - class FakeProject: - id = "prj-123" - - @staticmethod - def get_metadata(): - return SimpleNamespace( - name="Wing Project", - tags=["demo"], - root_item_id="geo-123", - root_item_type="Geometry", - ) - - monkeypatch.setattr( - project_cli, - "_create_project", - lambda **kwargs: calls.update(kwargs) or FakeProject(), - ) - - result = runner.invoke( - flow360, - [ - "project", - "create", - "--from", - "geometry", - "--file", - str(file_a), - "--file", - str(file_b), - "--name", - "Wing Project", - "--solver-version", - "release-25.9", - "--length-unit", - "cm", - "--description", - "demo project", - "--tag", - "demo", - "--folder-id", - "folder-123", - ], - ) - - assert result.exit_code == 0 - payload = json.loads(result.output) - assert payload["id"] == "prj-123" - assert payload["root_item"]["id"] == "geo-123" - assert calls == { - "source": "geometry", - "files": (str(file_a), str(file_b)), - "name": "Wing Project", - "solver_version": "release-25.9", - "length_unit": "cm", - "description": "demo project", - "tags": ("demo",), - "folder_id": "folder-123", - "run_async": False, - } - - -def test_project_create_async_outputs_project_id(monkeypatch, tmp_path): - from flow360.cli import project as project_cli - - runner = CliRunner() - mesh_file = tmp_path / "mesh.cgns" - mesh_file.write_text("mesh") - - monkeypatch.setattr(project_cli, "_create_project", lambda **kwargs: "prj-async") - - result = runner.invoke( - flow360, - [ - "project", - "create", - "--from", - "surface-mesh", - "--file", - str(mesh_file), - "--async", - ], - ) - - assert result.exit_code == 0 - assert json.loads(result.output) == {"async": True, "id": "prj-async"} - - -def test_project_create_surface_mesh_requires_single_file(tmp_path): - runner = CliRunner() - file_a = tmp_path / "mesh-a.cgns" - file_b = tmp_path / "mesh-b.cgns" - file_a.write_text("mesh") - file_b.write_text("mesh") - - result = runner.invoke( - flow360, - [ - "project", - "create", - "--from", - "surface-mesh", - "--file", - str(file_a), - "--file", - str(file_b), - ], - ) - - assert result.exit_code != 0 - assert "surface-mesh projects require exactly one --file." in result.output - - -def test_project_create_volume_mesh_requires_single_file(tmp_path): - runner = CliRunner() - file_a = tmp_path / "mesh-a.cgns" - file_b = tmp_path / "mesh-b.cgns" - file_a.write_text("mesh") - file_b.write_text("mesh") - - result = runner.invoke( - flow360, - [ - "project", - "create", - "--from", - "volume-mesh", - "--file", - str(file_a), - "--file", - str(file_b), - ], - ) - - assert result.exit_code != 0 - assert "volume-mesh projects require exactly one --file." in result.output - - -def test_project_list_outputs_records(monkeypatch): - from flow360.cli import project as project_cli - - runner = CliRunner() - record = SimpleNamespace( - name="Wing Study", - project_id="prj-123", - tags=["demo"], - description="test project", - solver_version="release-25.2", - created_at="2025-01-01T00:00:00Z", - root_item_type="Geometry", - ) - - monkeypatch.setattr( - project_cli, - "_get_project_records", - lambda search=None, limit=25, folder_ids=None, exclude_subfolders=False: ([record], 1), - ) - - result = runner.invoke(flow360, ["project", "list", "--keyword", "wing"]) - - assert result.exit_code == 0 - payload = json.loads(result.output) - assert payload["records"][0]["id"] == "prj-123" - assert payload["records"][0]["name"] == "Wing Study" - assert payload["returned"] == 1 - assert payload["total"] == 1 - - -def test_project_list_can_output_legacy_style_text(monkeypatch): - from flow360.cli import project as project_cli - - runner = CliRunner() - record = SimpleNamespace( - name="Wing Study", - project_id="prj-123", - tags=["demo"], - description="test project", - solver_version="release-25.2", - created_at="2025-01-01T00:00:00Z", - root_item_type="Geometry", - statistics=SimpleNamespace( - geometry=SimpleNamespace( - count=1, - successCount=1, - runningCount=0, - divergedCount=0, - errorCount=0, - ), - surface_mesh=None, - volume_mesh=None, - case=SimpleNamespace( - count=3, - successCount=2, - runningCount=0, - divergedCount=1, - errorCount=0, - ), - ), - ) - - monkeypatch.setattr( - project_cli, - "_get_project_records", - lambda search=None, limit=25, folder_ids=None, exclude_subfolders=False: ([record], 7), - ) - monkeypatch.setattr( - project_cli, - "_project_browser_url", - lambda project_id: f"https://example.test/workbench/{project_id}", - ) - - result = runner.invoke(flow360, ["project", "list", "--keyword", "wing", "--format", "text"]) - - assert result.exit_code == 0 - assert ">>> Projects sorted by creation time:" in result.output - assert "Name: Wing Study" in result.output - assert "Created with: Geometry" in result.output - assert "Solver: release-25.2" in result.output - assert "Link: https://example.test/workbench/prj-123" in result.output - assert "Geometry count: 1" in result.output - assert "Case count: 3" in result.output - assert "Showing 1 of 7 matching projects." in result.output - - -def test_show_projects_uses_project_list_formatter(monkeypatch): - from flow360.cli import project as project_cli - - runner = CliRunner() - calls = {} - record = SimpleNamespace( - name="Wing Study", - project_id="prj-123", - tags=[], - description=None, - solver_version="release-25.2", - created_at="2025-01-01T00:00:00Z", - root_item_type="Geometry", - ) - - monkeypatch.setattr( - project_cli, - "_get_project_records", - lambda search=None, limit=25, folder_ids=None, exclude_subfolders=False: ( - calls.update({"search": search, "limit": limit}) - or ([record], 1) - ), - ) - monkeypatch.setattr( - project_cli, - "_project_browser_url", - lambda project_id: f"https://example.test/workbench/{project_id}", - ) - - result = runner.invoke(flow360, ["show_projects", "-k", "wing"]) - - assert result.exit_code == 0 - assert calls == {"search": "wing", "limit": 200} - assert "Name: Wing Study" in result.output - assert "Link: https://example.test/workbench/prj-123" in result.output - - -def test_project_ls_alias_outputs_records(monkeypatch): - from flow360.cli import project as project_cli - - runner = CliRunner() - record = SimpleNamespace( - name="Wing Study", - project_id="prj-123", - tags=["demo"], - description="test project", - solver_version="release-25.2", - created_at="2025-01-01T00:00:00Z", - root_item_type="Geometry", - ) - - monkeypatch.setattr( - project_cli, - "_get_project_records", - lambda search=None, limit=25, folder_ids=None, exclude_subfolders=False: ([record], 1), - ) - - result = runner.invoke(flow360, ["project", "ls", "--keyword", "wing"]) - - assert result.exit_code == 0 - payload = json.loads(result.output) - assert payload["records"][0]["id"] == "prj-123" - assert payload["records"][0]["name"] == "Wing Study" - assert payload["returned"] == 1 - assert payload["total"] == 1 - - -def test_project_info_outputs_metadata(monkeypatch): - from flow360.cli import project as project_cli - - runner = CliRunner() - info = { - "id": "prj-123", - "name": "Wing Study", - "solverVersion": "release-25.2", - "tags": ["demo"], - "rootItemId": "geo-123", - "rootItemType": "Geometry", - } - - monkeypatch.setattr( - project_cli, - "_get_project_info", - lambda project_id: info, - ) - - result = runner.invoke(flow360, ["project", "info", "prj-123"]) - - assert result.exit_code == 0 - payload = json.loads(result.output) - assert payload["id"] == "prj-123" - assert payload["name"] == "Wing Study" - assert payload["root_item"]["id"] == "geo-123" - assert payload["root_item"]["type"] == "Geometry" - - -def test_project_get_alias_outputs_metadata(monkeypatch): - from flow360.cli import project as project_cli - - runner = CliRunner() - info = { - "id": "prj-123", - "name": "Wing Study", - "solverVersion": "release-25.2", - "tags": ["demo"], - "rootItemId": "geo-123", - "rootItemType": "Geometry", - } - - monkeypatch.setattr( - project_cli, - "_get_project_info", - lambda project_id: info, - ) - - result = runner.invoke(flow360, ["project", "get", "prj-123"]) - - assert result.exit_code == 0 - payload = json.loads(result.output) - assert payload["id"] == "prj-123" - assert payload["name"] == "Wing Study" - assert payload["root_item"]["id"] == "geo-123" - assert payload["root_item"]["type"] == "Geometry" - - -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], - ) - monkeypatch.setattr( - project_cli, - "_get_project_tree", - lambda project_id: SimpleNamespace(root=root), - ) - - result = runner.invoke(flow360, ["project", "tree", "prj-123"]) - - assert result.exit_code == 0 - payload = json.loads(result.output) - assert payload["root"]["id"] == "geo-123" - assert payload["root"]["children"][0]["id"] == "case-123" - - -def test_project_items_outputs_flat_items(monkeypatch): - from flow360.cli import project as project_cli - - runner = CliRunner() - monkeypatch.setattr( - project_cli, - "_get_project_tree_records", - lambda project_id: [ - { - "id": "geo-123", - "name": "Wing", - "type": "Geometry", - "parentId": None, - "parentCaseId": None, - }, - { - "id": "case-123", - "name": "Case 1", - "type": "Case", - "parentId": None, - "parentCaseId": "geo-123", - }, - ], - ) - - result = runner.invoke(flow360, ["project", "items", "prj-123"]) - - assert result.exit_code == 0 - payload = json.loads(result.output) - assert payload["items"] == [ - { - "id": "geo-123", - "name": "Wing", - "parent_id": None, - "type": "Geometry", - }, - { - "id": "case-123", - "name": "Case 1", - "parent_id": "geo-123", - "type": "Case", - }, - ] - - -def test_project_path_outputs_flat_branch(monkeypatch): - from flow360.cli import project as project_cli - - runner = CliRunner() - monkeypatch.setattr( - project_cli, - "_get_project_path", - lambda project_id, item_id, item_type: [ - { - "id": "geo-123", - "name": "Wing", - "type": "Geometry", - "parentId": None, - "status": "processed", - "updatedAt": "2025-01-01T00:00:00Z", - }, - { - "id": "case-123", - "name": "Case 1", - "type": "Case", - "parentId": "geo-123", - "status": "completed", - "updatedAt": "2025-01-01T01:00:00Z", - }, - ], - ) - - result = runner.invoke( - flow360, - [ - "project", - "path", - "prj-123", - "--item-id", - "case-123", - "--item-type", - "Case", - ], - ) - - assert result.exit_code == 0 - payload = json.loads(result.output) - assert payload["items"] == [ - { - "id": "geo-123", - "name": "Wing", - "parent_id": None, - "status": "processed", - "type": "Geometry", - "updated_at": "2025-01-01T00:00:00Z", - }, - { - "id": "case-123", - "name": "Case 1", - "parent_id": "geo-123", - "status": "completed", - "type": "Case", - "updated_at": "2025-01-01T01:00:00Z", - }, - ] - - -def test_project_rename_calls_webapi(monkeypatch): - from flow360.cli import project as project_cli - - runner = CliRunner() - calls = {} - - class FakeWebApi: - def __init__(self, project_id): - calls["project_id"] = project_id - - def patch(self, payload): - calls["payload"] = payload - - monkeypatch.setattr(project_cli, "_rename_project", lambda project_id, new_name: None) - monkeypatch.setattr( - project_cli, - "_rename_project", - lambda project_id, new_name: calls.update( - { - "project_id": project_id, - "new_name": new_name, - } - ), - ) - - result = runner.invoke(flow360, ["project", "rename", "prj-123", "--name", "New Name"]) - - assert result.exit_code == 0 - assert calls["project_id"] == "prj-123" - assert calls["new_name"] == "New Name" - assert "Renamed project prj-123 to New Name." in result.output - - -def test_project_delete_requires_yes(): - runner = CliRunner() - - result = runner.invoke(flow360, ["project", "delete", "prj-123"]) - - assert result.exit_code != 0 - assert "Pass --yes to confirm project deletion." in result.output - - -def test_project_delete_calls_webapi(monkeypatch): - from flow360.cli import project as project_cli - - runner = CliRunner() - calls = {} - - monkeypatch.setattr( - project_cli, - "_delete_project", - lambda project_id: calls.update({"project_id": project_id}), - ) - - result = runner.invoke(flow360, ["project", "delete", "prj-123", "--yes"]) - - assert result.exit_code == 0 - assert calls["project_id"] == "prj-123" - assert "Deleted project prj-123." in result.output diff --git a/tests/v1/test_cli_webapi_integration.py b/tests/v1/test_cli_webapi_integration.py deleted file mode 100644 index f53b6f61c..000000000 --- a/tests/v1/test_cli_webapi_integration.py +++ /dev/null @@ -1,1079 +0,0 @@ -import importlib -import json - -import pytest -from click.testing import CliRunner - -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" - - -@pytest.fixture -def recorded_webapi_calls(monkeypatch, mock_response): - mock_server = importlib.import_module("tests.mock_server") - original_mock_webapi = mock_server.mock_webapi - calls = [] - - def recording_mock_webapi(request_type, url, params): - calls.append({"type": request_type, "url": url, "params": params}) - return original_mock_webapi(request_type, url, params) - - monkeypatch.setattr(mock_server, "mock_webapi", recording_mock_webapi) - return calls - - -def _load_json_output(output): - json_start = output.rfind("\n{") - if json_start == -1: - json_start = output.find("{") - else: - json_start += 1 - return json.loads(output[json_start:]) - - -def test_project_info_uses_project_info_endpoint(recorded_webapi_calls): - runner = CliRunner() - - result = runner.invoke(flow360, ["project", "info", PROJECT_ID]) - - assert result.exit_code == 0 - payload = _load_json_output(result.output) - assert payload["id"] == PROJECT_ID - assert recorded_webapi_calls[-1] == { - "type": "get", - "url": f"/v2/projects/{PROJECT_ID}", - "params": None, - } - - -def test_project_get_alias_uses_project_info_endpoint(recorded_webapi_calls): - runner = CliRunner() - - result = runner.invoke(flow360, ["project", "get", PROJECT_ID]) - - assert result.exit_code == 0 - payload = _load_json_output(result.output) - assert payload["id"] == PROJECT_ID - assert recorded_webapi_calls[-1] == { - "type": "get", - "url": f"/v2/projects/{PROJECT_ID}", - "params": None, - } - - -def test_project_tree_uses_tree_endpoint(recorded_webapi_calls): - runner = CliRunner() - - result = runner.invoke(flow360, ["project", "tree", PROJECT_ID]) - - assert result.exit_code == 0 - payload = _load_json_output(result.output) - assert payload["root"]["id"] == "geo-2877e124-96ff-473d-864b-11eec8648d42" - assert recorded_webapi_calls[-1] == { - "type": "get", - "url": f"/v2/projects/{PROJECT_ID}/tree", - "params": None, - } - - -def test_project_ls_uses_limit_search_and_folder_filters(recorded_webapi_calls): - runner = CliRunner() - - result = runner.invoke( - flow360, - [ - "project", - "ls", - "--search", - "wing", - "--limit", - "10", - "--folder-id", - FOLDER_ID, - "--exclude-subfolders", - ], - ) - - assert result.exit_code == 0 - payload = _load_json_output(result.output) - assert payload["returned"] >= 1 - assert recorded_webapi_calls[-1] == { - "type": "get", - "url": "/v2/projects", - "params": { - "page": "0", - "size": 10, - "filterKeywords": "wing", - "filterTags": None, - "sortFields": ["createdAt"], - "sortDirections": ["desc"], - "filterFolderIds": [FOLDER_ID], - "filterExcludeSubfolders": True, - }, - } - - -def test_project_path_uses_path_endpoint(recorded_webapi_calls): - runner = CliRunner() - - result = runner.invoke( - flow360, - [ - "project", - "path", - PROJECT_ID, - "--item-id", - CASE_ID, - "--item-type", - "Case", - ], - ) - - assert result.exit_code == 0 - payload = _load_json_output(result.output) - assert payload["items"][0]["type"] == "Geometry" - assert recorded_webapi_calls[-1] == { - "type": "get", - "url": f"/v2/projects/{PROJECT_ID}/path", - "params": {"itemId": CASE_ID, "itemType": "Case"}, - } - - -def test_geometry_info_uses_geometry_v2_endpoint(recorded_webapi_calls): - runner = CliRunner() - - result = runner.invoke(flow360, ["geometry", "info", GEOMETRY_ID]) - - assert result.exit_code == 0 - payload = _load_json_output(result.output) - assert payload["id"] == GEOMETRY_ID - assert payload["type"] == "Geometry" - assert recorded_webapi_calls[-1] == { - "type": "get", - "url": f"/v2/geometries/{GEOMETRY_ID}", - "params": None, - } - - -def test_geometry_get_alias_uses_geometry_v2_endpoint(recorded_webapi_calls): - runner = CliRunner() - - result = runner.invoke(flow360, ["geometry", "get", GEOMETRY_ID]) - - assert result.exit_code == 0 - payload = _load_json_output(result.output) - assert payload["id"] == GEOMETRY_ID - assert payload["type"] == "Geometry" - assert recorded_webapi_calls[-1] == { - "type": "get", - "url": f"/v2/geometries/{GEOMETRY_ID}", - "params": None, - } - - -def test_geometry_state_uses_geometry_v2_endpoint(recorded_webapi_calls): - runner = CliRunner() - - result = runner.invoke(flow360, ["geometry", "state", GEOMETRY_ID]) - - assert result.exit_code == 0 - payload = _load_json_output(result.output) - assert payload["id"] == GEOMETRY_ID - assert payload["status"] == "processed" - assert recorded_webapi_calls[-1] == { - "type": "get", - "url": f"/v2/geometries/{GEOMETRY_ID}", - "params": None, - } - - -def test_geometry_simulation_get_uses_geometry_simulation_endpoint(recorded_webapi_calls): - runner = CliRunner() - - result = runner.invoke(flow360, ["geometry", "simulation", "get", GEOMETRY_ID]) - - assert result.exit_code == 0 - payload = _load_json_output(result.output) - assert "simulation" in payload - assert isinstance(payload["simulation"], dict) - assert "models" in payload["simulation"] - assert recorded_webapi_calls[-1] == { - "type": "get", - "url": f"/v2/geometries/{GEOMETRY_ID}/simulation/file", - "params": {"type": "simulation"}, - } - - -def test_geometry_summary_uses_geometry_simulation_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, ["geometry", "summary", GEOMETRY_ID]) - - assert result.exit_code == 0 - payload = _load_json_output(result.output) - assert payload["id"] == GEOMETRY_ID - assert "summary" in payload - assert recorded_webapi_calls[-1] == { - "type": "get", - "url": f"/v2/geometries/{GEOMETRY_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"] == "vm-7c3681cd-8c6c-4db7-a62c-1742d825e9d3" - 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"] == "vm-7c3681cd-8c6c-4db7-a62c-1742d825e9d3" - assert recorded_webapi_calls[-1] == { - "type": "get", - "url": f"/v2/cases/{CASE_ID}", - "params": None, - } - - -def test_surface_mesh_info_uses_surface_mesh_v2_endpoint(recorded_webapi_calls): - runner = CliRunner() - - result = runner.invoke(flow360, ["surface-mesh", "info", SURFACE_MESH_ID]) - - assert result.exit_code == 0 - payload = _load_json_output(result.output) - assert payload["id"] == SURFACE_MESH_ID - assert payload["type"] == "SurfaceMesh" - assert recorded_webapi_calls[-1] == { - "type": "get", - "url": f"/v2/surface-meshes/{SURFACE_MESH_ID}", - "params": None, - } - - -def test_surface_mesh_get_alias_uses_surface_mesh_v2_endpoint(recorded_webapi_calls): - runner = CliRunner() - - result = runner.invoke(flow360, ["surface-mesh", "get", SURFACE_MESH_ID]) - - assert result.exit_code == 0 - payload = _load_json_output(result.output) - assert payload["id"] == SURFACE_MESH_ID - assert payload["type"] == "SurfaceMesh" - assert recorded_webapi_calls[-1] == { - "type": "get", - "url": f"/v2/surface-meshes/{SURFACE_MESH_ID}", - "params": None, - } - - -def test_surface_mesh_state_uses_surface_mesh_v2_endpoint(recorded_webapi_calls): - runner = CliRunner() - - result = runner.invoke(flow360, ["surface-mesh", "state", SURFACE_MESH_ID]) - - assert result.exit_code == 0 - payload = _load_json_output(result.output) - assert payload["id"] == SURFACE_MESH_ID - assert payload["status"] == "processed" - assert recorded_webapi_calls[-1] == { - "type": "get", - "url": f"/v2/surface-meshes/{SURFACE_MESH_ID}", - "params": None, - } - - -def test_surface_mesh_simulation_get_uses_surface_mesh_simulation_endpoint(recorded_webapi_calls): - runner = CliRunner() - - result = runner.invoke(flow360, ["surface-mesh", "simulation", "get", SURFACE_MESH_ID]) - - assert result.exit_code == 0 - payload = _load_json_output(result.output) - assert "simulation" in payload - assert isinstance(payload["simulation"], dict) - assert "models" in payload["simulation"] - assert recorded_webapi_calls[-1] == { - "type": "get", - "url": f"/v2/surface-meshes/{SURFACE_MESH_ID}/simulation/file", - "params": {"type": "simulation"}, - } - - -def test_surface_mesh_summary_uses_surface_mesh_simulation_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, ["surface-mesh", "summary", SURFACE_MESH_ID]) - - assert result.exit_code == 0 - payload = _load_json_output(result.output) - assert payload["id"] == SURFACE_MESH_ID - assert "summary" in payload - assert recorded_webapi_calls[-1] == { - "type": "get", - "url": f"/v2/surface-meshes/{SURFACE_MESH_ID}/simulation/file", - "params": {"type": "simulation"}, - } - - -def test_volume_mesh_info_uses_volume_mesh_v2_endpoint(recorded_webapi_calls): - runner = CliRunner() - - result = runner.invoke(flow360, ["volume-mesh", "info", VOLUME_MESH_ID]) - - assert result.exit_code == 0 - payload = _load_json_output(result.output) - assert payload["id"] == VOLUME_MESH_ID - assert payload["type"] == "VolumeMesh" - assert recorded_webapi_calls[-1] == { - "type": "get", - "url": f"/v2/volume-meshes/{VOLUME_MESH_ID}", - "params": None, - } - - -def test_volume_mesh_get_alias_uses_volume_mesh_v2_endpoint(recorded_webapi_calls): - runner = CliRunner() - - result = runner.invoke(flow360, ["volume-mesh", "get", VOLUME_MESH_ID]) - - assert result.exit_code == 0 - payload = _load_json_output(result.output) - assert payload["id"] == VOLUME_MESH_ID - assert payload["type"] == "VolumeMesh" - assert recorded_webapi_calls[-1] == { - "type": "get", - "url": f"/v2/volume-meshes/{VOLUME_MESH_ID}", - "params": None, - } - - -def test_volume_mesh_state_uses_volume_mesh_v2_endpoint(recorded_webapi_calls): - runner = CliRunner() - - result = runner.invoke(flow360, ["volume-mesh", "state", VOLUME_MESH_ID]) - - 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_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_volume_mesh_simulation_get_uses_volume_mesh_simulation_endpoint(recorded_webapi_calls): - runner = CliRunner() - - result = runner.invoke(flow360, ["volume-mesh", "simulation", "get", VOLUME_MESH_ID]) - - assert result.exit_code == 0 - payload = _load_json_output(result.output) - assert "simulation" in payload - assert isinstance(payload["simulation"], dict) - assert "models" in payload["simulation"] - assert recorded_webapi_calls[-1] == { - "type": "get", - "url": f"/v2/volume-meshes/{VOLUME_MESH_ID}/simulation/file", - "params": {"type": "simulation"}, - } - - -def test_volume_mesh_summary_uses_volume_mesh_simulation_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, ["volume-mesh", "summary", VOLUME_MESH_ID]) - - assert result.exit_code == 0 - payload = _load_json_output(result.output) - assert payload["id"] == VOLUME_MESH_ID - assert "summary" in payload - assert recorded_webapi_calls[-1] == { - "type": "get", - "url": f"/v2/volume-meshes/{VOLUME_MESH_ID}/simulation/file", - "params": {"type": "simulation"}, - } - - -def test_case_get_alias_uses_case_v2_endpoint(recorded_webapi_calls): - runner = CliRunner() - - result = runner.invoke(flow360, ["case", "get", CASE_ID]) - - assert result.exit_code == 0 - payload = _load_json_output(result.output) - assert payload["id"] == CASE_ID - assert payload["mesh_id"] == "vm-7c3681cd-8c6c-4db7-a62c-1742d825e9d3" - assert recorded_webapi_calls[-1] == { - "type": "get", - "url": f"/v2/cases/{CASE_ID}", - "params": None, - } - - -def test_case_simulation_get_uses_case_simulation_endpoint(recorded_webapi_calls): - runner = CliRunner() - - result = runner.invoke(flow360, ["case", "simulation", "get", CASE_ID]) - - assert result.exit_code == 0 - payload = _load_json_output(result.output) - assert "simulation" in payload - assert isinstance(payload["simulation"], dict) - assert "models" in payload["simulation"] - assert recorded_webapi_calls[-1] == { - "type": "get", - "url": f"/v2/cases/{CASE_ID}/simulation/file", - "params": {"type": "simulation"}, - } - - -def test_case_summary_uses_case_simulation_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": {"fluid": []}}, - ) - - result = runner.invoke(flow360, ["case", "summary", CASE_ID]) - - assert result.exit_code == 0 - payload = _load_json_output(result.output) - assert payload["id"] == CASE_ID - assert "summary" in payload - assert recorded_webapi_calls[-1] == { - "type": "get", - "url": f"/v2/cases/{CASE_ID}/simulation/file", - "params": {"type": "simulation"}, - } - - -def test_case_results_ls_uses_legacy_case_files_endpoint(recorded_webapi_calls): - runner = CliRunner() - - result = runner.invoke(flow360, ["case", "results", "list", CASE_ID]) - - assert result.exit_code == 0 - payload = _load_json_output(result.output) - assert payload["records"] - assert all(record["path"].startswith("results/") for record in payload["records"]) - assert recorded_webapi_calls[-1] == { - "type": "get", - "url": f"/cases/{CASE_ID}/files", - "params": None, - } - - -def test_case_results_get_uses_legacy_case_files_endpoint(monkeypatch, recorded_webapi_calls): - from flow360.cli import assets as assets_cli - - runner = CliRunner() - monkeypatch.setattr( - assets_cli, - "_download_case_result", - lambda case_id, result_path, to_path=None, overwrite=False: "/tmp/total_forces_v2.csv", - ) - - result = runner.invoke( - flow360, - ["case", "results", "get", CASE_ID, "force_output_wing_all_planes_forces_v2.csv"], - ) - - assert result.exit_code == 0 - payload = _load_json_output(result.output) - assert payload["saved_to"] == "/tmp/total_forces_v2.csv" - assert payload["result"]["path"] == "results/force_output_wing_all_planes_forces_v2.csv" - assert recorded_webapi_calls[-1] == { - "type": "get", - "url": f"/cases/{CASE_ID}/files", - "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_get_alias_uses_draft_info_endpoint(recorded_webapi_calls): - runner = CliRunner() - - result = runner.invoke(flow360, ["draft", "get", 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_draft_simulation_set_uses_simulation_endpoint(recorded_webapi_calls, tmp_path): - runner = CliRunner() - file_path = tmp_path / "params.json" - file_path.write_text('{"version":"24.11.0","unit_system":{"name":"SI"}}') - - result = runner.invoke( - flow360, - ["draft", "simulation", "set", DRAFT_ID, str(file_path)], - ) - - assert result.exit_code == 0 - payload = _load_json_output(result.output) - assert payload == {"id": DRAFT_ID, "updated": True} - assert recorded_webapi_calls[-1] == { - "type": "post", - "url": f"/v2/drafts/{DRAFT_ID}/simulation/file", - "params": { - "data": '{"version": "24.11.0", "unit_system": {"name": "SI"}}', - "type": "simulation", - "version": "", - }, - } - - -def test_draft_create_from_project_resolves_root_and_posts_to_drafts(recorded_webapi_calls): - runner = CliRunner() - - result = runner.invoke(flow360, ["draft", "create", PROJECT_ID, "--name", "Draft A"]) - - assert result.exit_code == 0 - payload = _load_json_output(result.output) - assert payload["id"] == DRAFT_ID - assert payload["source_item_id"] == GEOMETRY_ID - calls = recorded_webapi_calls[-3:] - assert calls[0] == { - "type": "get", - "url": f"/v2/projects/{PROJECT_ID}", - "params": None, - } - assert calls[1] == { - "type": "get", - "url": f"/v2/geometries/{GEOMETRY_ID}", - "params": None, - } - assert calls[2]["type"] == "post" - assert calls[2]["url"] == "/v2/drafts" - assert calls[2]["params"]["name"] == "Draft A" - assert calls[2]["params"]["projectId"] == PROJECT_ID - assert calls[2]["params"]["sourceItemId"] == GEOMETRY_ID - assert calls[2]["params"]["sourceItemType"] == "Geometry" - assert calls[2]["params"]["solverVersion"] == "release-24.11" - assert calls[2]["params"]["forkCase"] is False - - -def test_draft_run_uses_run_endpoint(recorded_webapi_calls): - runner = CliRunner() - - result = runner.invoke(flow360, ["draft", "run", DRAFT_ID, "--up-to", "volume-mesh"]) - - assert result.exit_code == 0 - payload = _load_json_output(result.output) - assert payload["id"] == VOLUME_MESH_ID - assert payload["type"] == "VolumeMesh" - assert recorded_webapi_calls[-1] == { - "type": "post", - "url": f"/v2/drafts/{DRAFT_ID}/run", - "params": { - "upTo": "VolumeMesh", - "useInHouse": False, - "useGai": False, - }, - } - - -def test_draft_run_wait_polls_result_state_endpoint(recorded_webapi_calls): - runner = CliRunner() - - result = runner.invoke(flow360, ["draft", "run", DRAFT_ID, "--up-to", "volume-mesh", "--wait"]) - - assert result.exit_code == 0 - payload = _load_json_output(result.output) - assert payload["result"]["id"] == VOLUME_MESH_ID - assert payload["state"]["id"] == VOLUME_MESH_ID - calls = recorded_webapi_calls[-2:] - assert calls[0] == { - "type": "post", - "url": f"/v2/drafts/{DRAFT_ID}/run", - "params": { - "upTo": "VolumeMesh", - "useInHouse": False, - "useGai": False, - }, - } - assert calls[1] == { - "type": "get", - "url": f"/v2/volume-meshes/{VOLUME_MESH_ID}", - "params": None, - } - - -def test_draft_run_from_project_creates_sets_and_runs(recorded_webapi_calls, tmp_path): - runner = CliRunner() - simulation_path = tmp_path / "simulation.json" - simulation_path.write_text('{"version":"24.11.0","unit_system":{"name":"SI"}}') - - result = runner.invoke( - flow360, - [ - "draft", - "run", - PROJECT_ID, - str(simulation_path), - "--name", - "Alpha -18", - "--up-to", - "volume-mesh", - ], - ) - - assert result.exit_code == 0 - payload = _load_json_output(result.output) - assert payload["draft"]["id"] == DRAFT_ID - assert payload["result"]["id"] == VOLUME_MESH_ID - calls = recorded_webapi_calls[-5:] - assert calls[0] == { - "type": "get", - "url": f"/v2/projects/{PROJECT_ID}", - "params": None, - } - assert calls[1] == { - "type": "get", - "url": f"/v2/geometries/{GEOMETRY_ID}", - "params": None, - } - assert calls[2]["type"] == "post" - assert calls[2]["url"] == "/v2/drafts" - assert calls[2]["params"]["projectId"] == PROJECT_ID - assert calls[2]["params"]["sourceItemId"] == GEOMETRY_ID - assert calls[2]["params"]["sourceItemType"] == "Geometry" - assert calls[2]["params"]["solverVersion"] == "release-24.11" - assert calls[2]["params"]["forkCase"] is False - assert calls[2]["params"]["name"] == "Alpha -18" - assert calls[3] == { - "type": "post", - "url": f"/v2/drafts/{DRAFT_ID}/simulation/file", - "params": { - "data": '{"version": "24.11.0", "unit_system": {"name": "SI"}}', - "type": "simulation", - "version": "", - }, - } - assert calls[4] == { - "type": "post", - "url": f"/v2/drafts/{DRAFT_ID}/run", - "params": { - "upTo": "VolumeMesh", - "useInHouse": False, - "useGai": False, - }, - } - - -def test_draft_run_from_project_patch_fetches_merges_sets_and_runs(recorded_webapi_calls, tmp_path): - runner = CliRunner() - patch_path = tmp_path / "patch.json" - patch_path.write_text('{"meshing":{"refinement_factor":2.5}}') - - result = runner.invoke( - flow360, - ["draft", "run", PROJECT_ID, "--patch", str(patch_path), "--up-to", "volume-mesh"], - ) - - assert result.exit_code == 0 - payload = _load_json_output(result.output) - assert payload["draft"]["id"] == DRAFT_ID - assert payload["result"]["id"] == VOLUME_MESH_ID - calls = recorded_webapi_calls[-6:] - assert calls[0] == { - "type": "get", - "url": f"/v2/projects/{PROJECT_ID}", - "params": None, - } - assert calls[1] == { - "type": "get", - "url": f"/v2/geometries/{GEOMETRY_ID}", - "params": None, - } - assert calls[2] == { - "type": "get", - "url": f"/v2/geometries/{GEOMETRY_ID}/simulation/file", - "params": {"type": "simulation"}, - } - assert calls[3] == { - "type": "post", - "url": "/v2/drafts", - "params": { - **calls[3]["params"], - }, - } - assert calls[3]["params"]["projectId"] == PROJECT_ID - assert calls[3]["params"]["sourceItemId"] == GEOMETRY_ID - assert calls[3]["params"]["sourceItemType"] == "Geometry" - assert calls[3]["params"]["solverVersion"] == "release-24.11" - assert calls[3]["params"]["forkCase"] is False - assert isinstance(calls[3]["params"]["name"], str) - assert calls[3]["params"]["name"] - assert calls[4]["type"] == "post" - assert calls[4]["url"] == f"/v2/drafts/{DRAFT_ID}/simulation/file" - assert calls[4]["params"]["type"] == "simulation" - assert calls[4]["params"]["version"] == "" - assert calls[5] == { - "type": "post", - "url": f"/v2/drafts/{DRAFT_ID}/run", - "params": { - "upTo": "VolumeMesh", - "useInHouse": False, - "useGai": False, - }, - } - - merged_payload = json.loads(calls[4]["params"]["data"]) - assert merged_payload["meshing"]["refinement_factor"] == 2.5 - assert "defaults" in merged_payload["meshing"] - - -def test_project_rename_uses_project_patch_endpoint(recorded_webapi_calls): - runner = CliRunner() - - result = runner.invoke(flow360, ["project", "rename", PROJECT_ID, "--name", "Renamed Project"]) - - assert result.exit_code == 0 - assert f"Renamed project {PROJECT_ID} to Renamed Project." in result.output - assert recorded_webapi_calls[-1]["type"] == "patch" - assert recorded_webapi_calls[-1]["url"] == f"/v2/projects/{PROJECT_ID}" - assert recorded_webapi_calls[-1]["params"]["name"] == "Renamed Project" - - -def test_case_rename_uses_case_patch_endpoint(recorded_webapi_calls): - runner = CliRunner() - - result = runner.invoke(flow360, ["case", "rename", CASE_ID, "--name", "Alpha -18"]) - - assert result.exit_code == 0 - payload = _load_json_output(result.output) - assert payload == {"id": CASE_ID, "name": "Alpha -18"} - assert recorded_webapi_calls[-1] == { - "type": "patch", - "url": f"/v2/cases/{CASE_ID}", - "params": {"name": "Alpha -18"}, - } - - -def test_geometry_rename_uses_geometry_patch_endpoint(recorded_webapi_calls): - runner = CliRunner() - - result = runner.invoke( - flow360, ["geometry", "rename", GEOMETRY_ID, "--name", "Renamed Geometry"] - ) - - assert result.exit_code == 0 - payload = _load_json_output(result.output) - assert payload == {"id": GEOMETRY_ID, "name": "Renamed Geometry"} - assert recorded_webapi_calls[-1] == { - "type": "patch", - "url": f"/v2/geometries/{GEOMETRY_ID}", - "params": {"name": "Renamed Geometry"}, - } - - -def test_surface_mesh_rename_uses_surface_mesh_patch_endpoint(recorded_webapi_calls): - runner = CliRunner() - - result = runner.invoke( - flow360, - ["surface-mesh", "rename", SURFACE_MESH_ID, "--name", "Renamed Surface Mesh"], - ) - - assert result.exit_code == 0 - payload = _load_json_output(result.output) - assert payload == {"id": SURFACE_MESH_ID, "name": "Renamed Surface Mesh"} - assert recorded_webapi_calls[-1] == { - "type": "patch", - "url": f"/v2/surface-meshes/{SURFACE_MESH_ID}", - "params": {"name": "Renamed Surface Mesh"}, - } - - -def test_volume_mesh_rename_uses_volume_mesh_patch_endpoint(recorded_webapi_calls): - runner = CliRunner() - - result = runner.invoke( - flow360, - ["volume-mesh", "rename", VOLUME_MESH_ID, "--name", "Renamed Volume Mesh"], - ) - - assert result.exit_code == 0 - payload = _load_json_output(result.output) - assert payload == {"id": VOLUME_MESH_ID, "name": "Renamed Volume Mesh"} - assert recorded_webapi_calls[-1] == { - "type": "patch", - "url": f"/v2/volume-meshes/{VOLUME_MESH_ID}", - "params": {"name": "Renamed Volume Mesh"}, - } - - -def test_draft_rename_uses_draft_patch_endpoint(recorded_webapi_calls): - runner = CliRunner() - - result = runner.invoke(flow360, ["draft", "rename", DRAFT_ID, "--name", "Renamed Draft"]) - - assert result.exit_code == 0 - payload = _load_json_output(result.output) - assert payload == {"id": DRAFT_ID, "name": "Renamed Draft"} - assert recorded_webapi_calls[-1] == { - "type": "patch", - "url": f"/v2/drafts/{DRAFT_ID}", - "params": {"name": "Renamed Draft"}, - } - - -def test_project_delete_uses_project_delete_endpoint(recorded_webapi_calls): - runner = CliRunner() - - result = runner.invoke(flow360, ["project", "delete", PROJECT_ID, "--yes"]) - - assert result.exit_code == 0 - assert f"Deleted project {PROJECT_ID}." in result.output - assert recorded_webapi_calls[-1] == { - "type": "delete", - "url": f"/v2/projects/{PROJECT_ID}", - "params": None, - } - - -def test_folder_get_uses_folder_info_endpoint(recorded_webapi_calls): - runner = CliRunner() - - result = runner.invoke(flow360, ["folder", "get", FOLDER_ID]) - - assert result.exit_code == 0 - payload = _load_json_output(result.output) - assert payload["id"] == FOLDER_ID - assert recorded_webapi_calls[-1] == { - "type": "get", - "url": f"/v2/folders/{FOLDER_ID}", - "params": None, - } - - -def test_folder_tree_uses_folder_list_endpoint(recorded_webapi_calls): - runner = CliRunner() - - result = runner.invoke(flow360, ["folder", "tree"]) - - assert result.exit_code == 0 - payload = _load_json_output(result.output) - assert payload["root"]["id"] == "ROOT.FLOW360" - assert recorded_webapi_calls[-1] == { - "type": "get", - "url": "/v2/folders", - "params": { - "includeSubfolders": True, - "page": 0, - "size": 1000, - }, - } - - -def test_folder_create_uses_folder_create_endpoint(recorded_webapi_calls): - runner = CliRunner() - - result = runner.invoke( - flow360, - [ - "folder", - "create", - "--name", - "Folder A", - "--parent-folder-id", - "ROOT.FLOW360", - "--tag", - "demo", - ], - ) - - assert result.exit_code == 0 - payload = _load_json_output(result.output) - assert payload["id"] == "folder-3834758b-3d39-4a4a-ad85-710b7652267c" - assert recorded_webapi_calls[-1] == { - "type": "post", - "url": "/folders", - "params": { - "name": "Folder A", - "tags": ["demo"], - "parentFolderId": "ROOT.FLOW360", - "type": "folder", - }, - } - - -def test_folder_rename_uses_folder_patch_endpoint(recorded_webapi_calls): - runner = CliRunner() - - result = runner.invoke(flow360, ["folder", "rename", FOLDER_ID, "--name", "Renamed Folder"]) - - assert result.exit_code == 0 - payload = _load_json_output(result.output) - assert payload == {"id": FOLDER_ID, "name": "Renamed Folder"} - assert recorded_webapi_calls[-1] == { - "type": "patch", - "url": f"/v2/folders/{FOLDER_ID}", - "params": {"name": "Renamed Folder"}, - } - - -def test_folder_move_uses_folder_patch_endpoint(recorded_webapi_calls): - runner = CliRunner() - - result = runner.invoke( - flow360, - [ - "folder", - "move", - FOLDER_ID, - "--parent-folder-id", - "folder-4da3cdd0-c5b6-4130-9ca1-196237322ab9", - ], - ) - - assert result.exit_code == 0 - payload = _load_json_output(result.output) - assert payload == { - "id": FOLDER_ID, - "parent_id": "folder-4da3cdd0-c5b6-4130-9ca1-196237322ab9", - } - assert recorded_webapi_calls[-1] == { - "type": "patch", - "url": f"/v2/folders/{FOLDER_ID}", - "params": {"parentFolderId": "folder-4da3cdd0-c5b6-4130-9ca1-196237322ab9"}, - } diff --git a/tools/cli_live_benchmark.py b/tools/cli_live_benchmark.py index 5a1d4bad2..fec64750c 100644 --- a/tools/cli_live_benchmark.py +++ b/tools/cli_live_benchmark.py @@ -239,13 +239,12 @@ def build_report() -> str: pytest_result = run_pytest( [ "tests/test_lazy_imports.py", - "tests/v1/test_cli_project.py", - "tests/v1/test_cli_folder.py", - "tests/v1/test_cli_assets.py", - "tests/v1/test_cli_draft.py", - "tests/v1/test_cli_webapi_integration.py", - "tests/v1/test_cli_login.py", - "tests/v1/_test_cli.py", + "tests/cli/test_cli_project.py", + "tests/cli/test_cli_folder.py", + "tests/cli/test_cli_assets.py", + "tests/cli/test_cli_draft.py", + "tests/cli/test_cli_webapi_integration.py", + "tests/test_cli_login.py", "tests/simulation/test_project_create.py", "-q", ] From 34058af320c782a9397d43774e3b6855f77cf77d Mon Sep 17 00:00:00 2001 From: Maciej Skarysz Date: Tue, 28 Apr 2026 14:23:29 +0200 Subject: [PATCH 06/15] Remove project ls alias --- tests/cli/test_cli_project.py | 32 +----------------------- tests/cli/test_cli_webapi_integration.py | 4 +-- tools/cli_live_benchmark.py | 21 ++++++++++------ 3 files changed, 17 insertions(+), 40 deletions(-) diff --git a/tests/cli/test_cli_project.py b/tests/cli/test_cli_project.py index 7b05fc4ae..5010a8778 100644 --- a/tests/cli/test_cli_project.py +++ b/tests/cli/test_cli_project.py @@ -19,7 +19,7 @@ def test_project_group_help_shows_read_commands(): assert "path" in result.output -def test_project_ls_supports_search_limit_and_folder_filters(monkeypatch): +def test_project_list_supports_search_limit_and_folder_filters(monkeypatch): from flow360.cli import project as project_cli runner = CliRunner() @@ -381,36 +381,6 @@ def test_show_projects_uses_project_list_formatter(monkeypatch): assert "Link: https://example.test/workbench/prj-123" in result.output -def test_project_ls_alias_outputs_records(monkeypatch): - from flow360.cli import project as project_cli - - runner = CliRunner() - record = SimpleNamespace( - name="Wing Study", - project_id="prj-123", - tags=["demo"], - description="test project", - solver_version="release-25.2", - created_at="2025-01-01T00:00:00Z", - root_item_type="Geometry", - ) - - monkeypatch.setattr( - project_cli, - "_get_project_records", - lambda search=None, limit=25, folder_ids=None, exclude_subfolders=False: ([record], 1), - ) - - result = runner.invoke(flow360, ["project", "ls", "--keyword", "wing"]) - - assert result.exit_code == 0 - payload = json.loads(result.output) - assert payload["records"][0]["id"] == "prj-123" - assert payload["records"][0]["name"] == "Wing Study" - assert payload["returned"] == 1 - assert payload["total"] == 1 - - def test_project_info_outputs_metadata(monkeypatch): from flow360.cli import project as project_cli diff --git a/tests/cli/test_cli_webapi_integration.py b/tests/cli/test_cli_webapi_integration.py index f53b6f61c..13b630b31 100644 --- a/tests/cli/test_cli_webapi_integration.py +++ b/tests/cli/test_cli_webapi_integration.py @@ -83,14 +83,14 @@ def test_project_tree_uses_tree_endpoint(recorded_webapi_calls): } -def test_project_ls_uses_limit_search_and_folder_filters(recorded_webapi_calls): +def test_project_list_uses_limit_search_and_folder_filters(recorded_webapi_calls): runner = CliRunner() result = runner.invoke( flow360, [ "project", - "ls", + "list", "--search", "wing", "--limit", diff --git a/tools/cli_live_benchmark.py b/tools/cli_live_benchmark.py index fec64750c..598ed608c 100644 --- a/tools/cli_live_benchmark.py +++ b/tools/cli_live_benchmark.py @@ -116,7 +116,7 @@ def choose_project(project_records: list[dict]) -> tuple[dict, CommandResult, li if items: return record, items_result, items - raise RuntimeError("Could not find a benchmarkable project from project ls output.") + raise RuntimeError("Could not find a benchmarkable project from project list output.") def pick_item(items: list[dict], item_type: str) -> dict | None: @@ -158,11 +158,11 @@ def build_report() -> str: "print({m:(m in sys.modules) for m in mods})" ) - project_ls = run_cli(["project", "ls"]) - if project_ls.exit_code != 0: - raise RuntimeError(f"project ls failed:\n{project_ls.stderr or project_ls.stdout}") + project_list = run_cli(["project", "list"]) + if project_list.exit_code != 0: + raise RuntimeError(f"project list failed:\n{project_list.stderr or project_list.stdout}") - project_records = parse_json_output(project_ls)["records"] + project_records = parse_json_output(project_list)["records"] selected_project, selected_items_result, selected_items = choose_project(project_records) geometry = pick_item(selected_items, "Geometry") @@ -293,7 +293,14 @@ def build_report() -> str: sections.append( f"| `{md_escape(result.command)}` | {result.exit_code} | {format_seconds(result.duration_s)} |\n" ) - for result in (project_ls, project_get, project_tree, selected_items_result, project_path, folder_tree): + for result in ( + project_list, + project_get, + project_tree, + selected_items_result, + project_path, + folder_tree, + ): sections.append( f"| `{md_escape(result.command)}` | {result.exit_code} | {format_seconds(result.duration_s)} |\n" ) @@ -345,7 +352,7 @@ def build_report() -> str: "1. Root startup is in the right shape. The root import path stays thin, and root help remains around the low hundreds of milliseconds from a fresh subprocess.\n" ) sections.append( - "2. The bounded `project ls` default changed the performance profile materially. It is no longer the clear outlier because the CLI now asks the API for 25 projects by default instead of a much larger page.\n" + "2. The bounded `project list` default changed the performance profile materially. It is no longer the clear outlier because the CLI now asks the API for 25 projects by default instead of a much larger page.\n" ) sections.append( "3. The read-only surface still clusters near the network floor. `project get/tree/items/path`, asset gets, and draft reads are all dominated by backend latency rather than local import or serialization overhead.\n" From eee9602ce03c4bed5f5a245fbf6190fc75a193e3 Mon Sep 17 00:00:00 2001 From: Maciej Skarysz Date: Fri, 1 May 2026 15:09:10 +0200 Subject: [PATCH 07/15] cli: add resource inspection commands --- flow360/cli/app.py | 8 +- flow360/cli/assets.py | 194 +---- flow360/cli/auth.py | 25 +- flow360/cli/auth_guidance.py | 4 + flow360/cli/browser_links.py | 4 +- flow360/cli/draft.py | 362 +------- flow360/cli/open_resource.py | 5 +- flow360/cli/resource_refs.py | 1 - flow360/cli/resource_state.py | 1 - flow360/cli/simulation_summary.py | 7 +- flow360/cli/wait.py | 5 +- flow360/component/project.py | 194 ++--- .../component/simulation/web/asset_webapi.py | 27 - .../component/simulation/web/draft_webapi.py | 80 -- .../component/simulation/web/project_tree.py | 201 ----- flow360/exceptions.py | 3 - flow360/user_config.py | 2 +- tests/cli/test_cli_assets.py | 264 ------ tests/cli/test_cli_draft.py | 648 +------------- tests/cli/test_cli_folder.py | 98 --- tests/cli/test_cli_open.py | 26 +- tests/cli/test_cli_project.py | 271 +----- tests/cli/test_cli_webapi_integration.py | 815 ++---------------- .../mock_webapi/case_files_mock_response.json | 18 +- tests/mock_server.py | 169 +--- tests/simulation/test_project_create.py | 26 - tests/simulation/test_project_lazy.py | 86 -- tests/test_lazy_imports.py | 4 +- .../{cli => v1}/test_remote_resource_logs.py | 0 tools/cli_live_benchmark.py | 393 --------- 30 files changed, 254 insertions(+), 3687 deletions(-) delete mode 100644 flow360/component/simulation/web/project_tree.py delete mode 100644 tests/simulation/test_project_create.py delete mode 100644 tests/simulation/test_project_lazy.py rename tests/{cli => v1}/test_remote_resource_logs.py (100%) delete mode 100644 tools/cli_live_benchmark.py diff --git a/flow360/cli/app.py b/flow360/cli/app.py index 97de9308e..115617602 100644 --- a/flow360/cli/app.py +++ b/flow360/cli/app.py @@ -41,22 +41,22 @@ "geometry": { "module": "flow360.cli.assets", "attr": "geometry", - "help": "Inspect and manage Flow360 geometries.", + "help": "Inspect Flow360 geometries.", }, "surface-mesh": { "module": "flow360.cli.assets", "attr": "surface_mesh", - "help": "Inspect and manage Flow360 surface meshes.", + "help": "Inspect Flow360 surface meshes.", }, "volume-mesh": { "module": "flow360.cli.assets", "attr": "volume_mesh", - "help": "Inspect and manage Flow360 volume meshes.", + "help": "Inspect Flow360 volume meshes.", }, "case": { "module": "flow360.cli.assets", "attr": "case", - "help": "Inspect and manage Flow360 cases.", + "help": "Inspect Flow360 cases.", }, "folder": { "module": "flow360.cli.folder", diff --git a/flow360/cli/assets.py b/flow360/cli/assets.py index 6e1d628c6..69867e137 100644 --- a/flow360/cli/assets.py +++ b/flow360/cli/assets.py @@ -5,7 +5,6 @@ from __future__ import annotations import json -import os import click @@ -13,13 +12,6 @@ from flow360.cli.resource_state import get_resource_state_for_type -def _rename_asset(webapi_cls, asset_id, new_name): - # pylint: disable=import-outside-toplevel - from flow360.cloud.flow360_requests import RenameAssetRequestV2 - - webapi_cls(asset_id).patch(RenameAssetRequestV2(name=new_name).dict()) - - def _serialize_asset_info(info): return { "id": info.get("id"), @@ -64,89 +56,9 @@ def _emit_asset_summary(webapi_cls, asset_id): ) -def _serialize_case_result(record): - path = _get_case_result_path(record) - return { - "name": os.path.basename(path) if path else None, - "path": path, - "file_type": record.get("fileType"), - "size_bytes": record.get("length"), - "updated_at": record.get("updatedAt"), - } - - -def _get_case_result_path(record): - for value in (record.get("fileName"), record.get("filePath")): - if not value: - continue - if "results/" in value: - return value[value.index("results/") :] - return record.get("fileName") or record.get("filePath") - - -def _list_case_results(case_id): - # pylint: disable=import-outside-toplevel - from flow360.component.simulation.web.asset_webapi import CaseWebApi - - files = CaseWebApi(case_id).list_files() - result_files = [ - record for record in files if (_get_case_result_path(record) or "").startswith("results/") - ] - result_files.sort(key=lambda record: _get_case_result_path(record) or "") - return result_files - - -def _resolve_case_result(case_id, result_ref): - results = _list_case_results(case_id) - if not results: - raise click.ClickException(f"No result files are available for case {case_id}.") - - exact_matches = [ - record - for record in results - if result_ref - in {record.get("filePath"), record.get("fileName"), _get_case_result_path(record)} - ] - if len(exact_matches) == 1: - return exact_matches[0] - if len(exact_matches) > 1: - raise click.ClickException(f"Multiple results matched '{result_ref}'. Use the full path.") - - basename_matches = [ - record - for record in results - if os.path.basename(_get_case_result_path(record) or "") == result_ref - ] - if len(basename_matches) == 1: - return basename_matches[0] - if len(basename_matches) > 1: - matches = ", ".join( - sorted(_get_case_result_path(record) or "" for record in basename_matches) - ) - raise click.ClickException( - f"Multiple results matched '{result_ref}'. Use one of: {matches}" - ) - - raise click.ClickException(f"Result '{result_ref}' was not found for case {case_id}.") - - -def _download_case_result(case_id, result_path, *, to_path=None, overwrite=False): - # pylint: disable=import-outside-toplevel - from flow360.component.simulation.web.asset_webapi import CaseWebApi - - if to_path is None: - return CaseWebApi(case_id).download_file(result_path, overwrite=overwrite) - - return CaseWebApi(case_id).download_file( - result_path, - to_file=to_path, - overwrite=overwrite, - ) - - @click.group("geometry") def geometry(): - """Inspect and manage Flow360 geometries.""" + """Inspect Flow360 geometries.""" def _emit_geometry_info(geometry_id): @@ -171,18 +83,6 @@ def get_geometry_alias(geometry_id): _emit_geometry_info(geometry_id) -@geometry.command("rename") -@click.argument("geometry_id") -@click.option("--name", required=True, help="New geometry name.") -def rename_geometry(geometry_id, name): - """Rename a geometry.""" - # pylint: disable=import-outside-toplevel - from flow360.component.simulation.web.asset_webapi import GeometryWebApi - - _rename_asset(GeometryWebApi, geometry_id, name) - emit_json({"id": geometry_id, "name": name}) - - @geometry.command("state") @click.argument("geometry_id") def state_geometry(geometry_id): @@ -217,7 +117,7 @@ def get_geometry_simulation(geometry_id): @click.group("surface-mesh") def surface_mesh(): - """Inspect and manage Flow360 surface meshes.""" + """Inspect Flow360 surface meshes.""" def _emit_surface_mesh_info(surface_mesh_id): @@ -242,18 +142,6 @@ def get_surface_mesh_alias(surface_mesh_id): _emit_surface_mesh_info(surface_mesh_id) -@surface_mesh.command("rename") -@click.argument("surface_mesh_id") -@click.option("--name", required=True, help="New surface mesh name.") -def rename_surface_mesh(surface_mesh_id, name): - """Rename a surface mesh.""" - # pylint: disable=import-outside-toplevel - from flow360.component.simulation.web.asset_webapi import SurfaceMeshWebApi - - _rename_asset(SurfaceMeshWebApi, surface_mesh_id, name) - emit_json({"id": surface_mesh_id, "name": name}) - - @surface_mesh.command("state") @click.argument("surface_mesh_id") def state_surface_mesh(surface_mesh_id): @@ -288,7 +176,7 @@ def get_surface_mesh_simulation(surface_mesh_id): @click.group("volume-mesh") def volume_mesh(): - """Inspect and manage Flow360 volume meshes.""" + """Inspect Flow360 volume meshes.""" def _emit_volume_mesh_info(volume_mesh_id): @@ -313,18 +201,6 @@ def get_volume_mesh_alias(volume_mesh_id): _emit_volume_mesh_info(volume_mesh_id) -@volume_mesh.command("rename") -@click.argument("volume_mesh_id") -@click.option("--name", required=True, help="New volume mesh name.") -def rename_volume_mesh(volume_mesh_id, name): - """Rename a volume mesh.""" - # pylint: disable=import-outside-toplevel - from flow360.component.simulation.web.asset_webapi import VolumeMeshWebApi - - _rename_asset(VolumeMeshWebApi, volume_mesh_id, name) - emit_json({"id": volume_mesh_id, "name": name}) - - @volume_mesh.command("state") @click.argument("volume_mesh_id") def state_volume_mesh(volume_mesh_id): @@ -359,7 +235,7 @@ def get_volume_mesh_simulation(volume_mesh_id): @click.group("case") def case(): - """Inspect and manage Flow360 cases.""" + """Inspect Flow360 cases.""" def _serialize_case_info(info): @@ -398,18 +274,6 @@ def state_case(case_id): emit_json(get_resource_state_for_type("Case", case_id)) -@case.command("rename") -@click.argument("case_id") -@click.option("--name", required=True, help="New case name.") -def rename_case(case_id, name): - """Rename a case.""" - # pylint: disable=import-outside-toplevel - from flow360.component.simulation.web.asset_webapi import CaseWebApi - - _rename_asset(CaseWebApi, case_id, name) - emit_json({"id": case_id, "name": name}) - - @case.command("summary") @click.argument("case_id") def summary_case(case_id): @@ -433,53 +297,3 @@ def get_case_simulation(case_id): from flow360.component.simulation.web.asset_webapi import CaseWebApi emit_json({"simulation": _get_asset_simulation_json(CaseWebApi, case_id)}) - - -@case.group("results") -def case_results(): - """Namespace for case result artifacts.""" - - -def _emit_case_results_list(case_id): - emit_json( - {"records": [_serialize_case_result(record) for record in _list_case_results(case_id)]} - ) - - -@case_results.command("list") -@click.argument("case_id") -def list_case_results(case_id): - """List case result artifacts.""" - _emit_case_results_list(case_id) - - -@case_results.command("ls", hidden=True) -@click.argument("case_id") -def list_case_results_alias(case_id): - """Backward-compatible alias for case results list.""" - _emit_case_results_list(case_id) - - -@case_results.command("get") -@click.argument("case_id") -@click.argument("result_ref") -@click.option( - "--to", - "to_path", - default=None, - type=click.Path(dir_okay=True, file_okay=True, resolve_path=True), - help="Optional destination file or folder path.", -) -@click.option("--overwrite", is_flag=True, help="Overwrite an existing destination file.") -def get_case_result(case_id, result_ref, to_path, overwrite): - """Download one case result artifact.""" - result_record = _resolve_case_result(case_id, result_ref) - result_path = _get_case_result_path(result_record) - saved_to = _download_case_result(case_id, result_path, to_path=to_path, overwrite=overwrite) - emit_json( - { - "case_id": case_id, - "result": _serialize_case_result(result_record), - "saved_to": saved_to, - } - ) diff --git a/flow360/cli/auth.py b/flow360/cli/auth.py index a8ca9cbfa..8e7b21024 100644 --- a/flow360/cli/auth.py +++ b/flow360/cli/auth.py @@ -13,18 +13,18 @@ from typing import Callable, Dict, Optional from urllib.parse import parse_qs, urlencode, urlparse -import flow360.user_config as user_config # pylint: disable=consider-using-from-import from cryptography.exceptions import InvalidTag from cryptography.hazmat.primitives import hashes, serialization from cryptography.hazmat.primitives.asymmetric import ec from cryptography.hazmat.primitives.ciphers.aead import AESGCM from cryptography.hazmat.primitives.kdf.hkdf import HKDF + +import flow360.user_config as user_config # pylint: disable=consider-using-from-import from flow360.environment import Env from flow360.user_config import store_apikey LOGIN_PATH = "account/cli-login" CALLBACK_PATH = "/callback" -LOCAL_DEV_WEB_URL = "http://local.dev-simulation.cloud:3000" DEV_WEB_URL = "https://flow360.dev-simulation.cloud" CALLBACK_HOST = "127.0.0.1" CALLBACK_ENCRYPTION_ALGORITHM = "P-256-ECDH-AES-GCM-256" @@ -39,19 +39,16 @@ def resolve_target_environment( dev: bool = False, uat: bool = False, env: Optional[str] = None, - local: bool = False, ): """Resolve the selected environment and validate conflicting CLI flags.""" - selected = [flag for flag, enabled in (("dev", dev), ("uat", uat), ("local", local)) if enabled] + selected = [flag for flag, enabled in (("dev", dev), ("uat", uat)) if enabled] if env is not None: selected.append(env) if len(selected) > 1: - raise ValueError("Use only one of --dev, --uat, --local, or --env.") + raise ValueError("Use only one of --dev, --uat, or --env.") - if local: - target = Env.dev - elif dev: + if dev: target = Env.dev elif uat: target = Env.uat @@ -71,7 +68,6 @@ def build_login_url( profile: str, callback_public_key: Optional[str] = None, callback_encryption_algorithm: Optional[str] = None, - use_local_ui: bool = False, ) -> str: """Build the browser login URL for the selected environment.""" query_params = { @@ -87,9 +83,7 @@ def build_login_url( query_params["callback_encryption_algorithm"] = callback_encryption_algorithm query = urlencode(query_params) - if use_local_ui: - base_url = LOCAL_DEV_WEB_URL - elif environment.name == Env.dev.name: + if environment.name == Env.dev.name: base_url = DEV_WEB_URL else: base_url = environment.web_url @@ -148,7 +142,9 @@ def _decrypt_callback_payload( raise LoginError("Encrypted login callback payload is invalid.") return { - key: value for key, value in payload.items() if isinstance(key, str) and isinstance(value, str) + key: value + for key, value in payload.items() + if isinstance(key, str) and isinstance(value, str) } @@ -161,6 +157,7 @@ def __init__(self, server_address, *, expected_state: str, callback_private_key) self.callback_private_key = callback_private_key def process_callback_params(self, params: Dict[str, str]) -> Dict[str, str]: + """Validate and normalize callback parameters before storing the API key.""" if params.get("state") != self.expected_state: raise LoginError("Login callback state mismatch.") if "error" in params: @@ -412,7 +409,6 @@ def wait_for_login( profile: str, port: Optional[int] = None, timeout: int = 120, - use_local_ui: bool = False, announce_login: Optional[Callable[[Dict[str, str]], None]] = None, ): # pylint: disable=too-many-arguments,too-many-locals """Run the browser-based login flow and persist the resulting API key.""" @@ -436,7 +432,6 @@ def wait_for_login( profile, callback_public_key=callback_public_key, callback_encryption_algorithm=CALLBACK_ENCRYPTION_ALGORITHM, - use_local_ui=use_local_ui, ) try: diff --git a/flow360/cli/auth_guidance.py b/flow360/cli/auth_guidance.py index 815a8c39e..d168d98f7 100644 --- a/flow360/cli/auth_guidance.py +++ b/flow360/cli/auth_guidance.py @@ -6,6 +6,7 @@ def _env_flag(environment_name: str) -> str: + """Return the CLI flag segment for the selected environment.""" if environment_name == "dev": return "--dev" if environment_name == "uat": @@ -16,6 +17,7 @@ def _env_flag(environment_name: str) -> str: def build_login_command(environment_name: str, profile: str) -> str: + """Build the recommended interactive login command for a given context.""" env_flag = _env_flag(environment_name) parts = ["flow360", "login"] if env_flag: @@ -26,6 +28,7 @@ def build_login_command(environment_name: str, profile: str) -> str: def build_configure_command(environment_name: str, profile: str) -> str: + """Build the recommended manual API key configuration command.""" env_flag = _env_flag(environment_name) parts = ["flow360", "configure"] if env_flag: @@ -37,6 +40,7 @@ def build_configure_command(environment_name: str, profile: str) -> str: 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}.", diff --git a/flow360/cli/browser_links.py b/flow360/cli/browser_links.py index b6aa852f6..99bfd2992 100644 --- a/flow360/cli/browser_links.py +++ b/flow360/cli/browser_links.py @@ -33,7 +33,9 @@ def _get_project_scoped_resource_info(resource_type: str, resource_id: str) -> d 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.") + raise ResourceRefError( + f"Opening {resource_type} resources in the browser is not supported." + ) return webapi_cls(resource_id).get_info() diff --git a/flow360/cli/draft.py b/flow360/cli/draft.py index 4d8e7c892..42931f8dc 100644 --- a/flow360/cli/draft.py +++ b/flow360/cli/draft.py @@ -4,26 +4,13 @@ from __future__ import annotations -import copy import json import click -from flow360.cli.dict_utils import merge_overwrite -from flow360.cli.output import emit_json -from flow360.cli.resource_refs import ResourceRefError, parse_resource_ref, require_resource_type -from flow360.cli.resource_state import ( - WaitTimeoutError, - get_resource_state as _get_resource_state, - get_resource_state_for_type, - wait_for_resource_state as _wait_for_resource_state, -) - -RUN_TARGETS = { - "surface-mesh": "SurfaceMesh", - "volume-mesh": "VolumeMesh", - "case": "Case", -} +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): @@ -33,13 +20,6 @@ def _require_typed_id(resource_id, expected_type): raise click.ClickException(str(error)) from error -def _parse_resource_id(resource_id): - try: - return parse_resource_ref(resource_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.draft_webapi import DraftWebApi @@ -64,170 +44,6 @@ def _get_draft_simulation_json(draft_id): return simulation_json -def _set_draft_simulation_json(draft_id, simulation_json): - # pylint: disable=import-outside-toplevel - from flow360.component.simulation.web.draft_webapi import DraftWebApi - - DraftWebApi(draft_id).set_simulation_json(simulation_json) - - -def _run_draft(draft_id, up_to): - # pylint: disable=import-outside-toplevel - from flow360.component.simulation.web.draft_webapi import DraftWebApi - - return DraftWebApi(draft_id).run(up_to=up_to) - - -def _rename_draft(draft_id, new_name): - # pylint: disable=import-outside-toplevel - from flow360.cloud.flow360_requests import RenameAssetRequestV2 - from flow360.component.simulation.web.draft_webapi import DraftWebApi - - DraftWebApi(draft_id).patch(RenameAssetRequestV2(name=new_name).dict()) - - -def _load_simulation_json(simulation_path): - try: - with open(simulation_path, encoding="utf-8") as handle: - return json.load(handle) - except json.JSONDecodeError as error: - raise click.ClickException(f"Invalid JSON in {simulation_path}: {error}") from error - - -def _load_patch_json(patch_path): - patch_json = _load_simulation_json(patch_path) - if not isinstance(patch_json, dict): - raise click.ClickException(f"Patch JSON in {patch_path} must be a JSON object.") - return patch_json - - -def _get_asset_info_for_type(resource_type, resource_id): - # pylint: disable=import-outside-toplevel - from flow360.component.simulation.web.asset_webapi import ( - CaseWebApi, - GeometryWebApi, - SurfaceMeshWebApi, - VolumeMeshWebApi, - ) - - webapi_by_type = { - "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"Unsupported draft source type: {resource_type}.") - - return webapi_cls(resource_id).get_info() - - -def _get_asset_simulation_json_for_type(resource_type, resource_id): - # pylint: disable=import-outside-toplevel - from flow360.component.simulation.web.asset_webapi import ( - CaseWebApi, - GeometryWebApi, - SurfaceMeshWebApi, - VolumeMeshWebApi, - ) - - webapi_by_type = { - "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"Unsupported draft source type: {resource_type}.") - - return webapi_cls(resource_id).get_simulation_json() - - -def _resolve_draft_source(ref_id): - resource_ref = _parse_resource_id(ref_id) - - if resource_ref.resource_type == "Draft": - raise click.ClickException( - "Draft creation requires a project or asset ref, not a draft ID." - ) - - if resource_ref.resource_type == "Project": - # pylint: disable=import-outside-toplevel - from flow360.component.simulation.web.project_webapi import ProjectWebApi - - project_info = ProjectWebApi(resource_ref.id).get_info() - source_item_id = project_info.get("rootItemId") - source_item_type = project_info.get("rootItemType") - if not source_item_id or not source_item_type: - raise click.ClickException( - f"Project {resource_ref.id} does not expose a root item for draft creation." - ) - source_info = _get_asset_info_for_type(source_item_type, source_item_id) - return { - "project_id": project_info.get("id") or resource_ref.id, - "source_item_id": source_item_id, - "source_item_type": source_item_type, - "solver_version": source_info.get("solverVersion"), - "fork_case": source_item_type == "Case", - } - - if resource_ref.resource_type in {"Geometry", "SurfaceMesh", "VolumeMesh", "Case"}: - source_info = _get_asset_info_for_type(resource_ref.resource_type, resource_ref.id) - return { - "project_id": source_info.get("projectId"), - "source_item_id": resource_ref.id, - "source_item_type": resource_ref.resource_type, - "solver_version": source_info.get("solverVersion"), - "fork_case": resource_ref.resource_type == "Case", - } - - raise click.ClickException( - "Draft creation is only supported from prj-, geo-, sm-, vm-, or case- refs." - ) - - -def _create_draft_from_ref(ref_id, *, name=None): - source = _resolve_draft_source(ref_id) - return _create_draft_from_source(source, name=name) - - -def _create_draft_from_source(source, *, name=None): - # pylint: disable=import-outside-toplevel - from flow360.component.simulation.web.draft_webapi import DraftWebApi - - created = DraftWebApi.create( - name=name, - project_id=source["project_id"], - source_item_id=source["source_item_id"], - source_item_type=source["source_item_type"], - solver_version=source["solver_version"], - fork_case=source["fork_case"], - ) - return { - "id": created.get("id"), - "name": created.get("name"), - "projectId": created.get("projectId") or source["project_id"], - "solverVersion": created.get("solverVersion") or source["solver_version"], - "sourceItemId": created.get("sourceItemId") or source["source_item_id"], - "sourceItemType": created.get("sourceItemType") or source["source_item_type"], - "forkCase": created.get("forkCase", source["fork_case"]), - "type": created.get("type") or "Draft", - } - - -def _apply_patch_to_source_simulation(source, patch_json): - source_simulation = _get_asset_simulation_json_for_type( - source["source_item_type"], source["source_item_id"] - ) - if not isinstance(source_simulation, dict): - raise click.ClickException("Source simulation JSON must be a JSON object to apply a patch.") - return merge_overwrite(copy.deepcopy(source_simulation), patch_json) - - def _serialize_draft_info(info): return { "id": info.get("id"), @@ -241,42 +57,6 @@ def _serialize_draft_info(info): } -def _serialize_run_result(info): - payload = { - "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"), - } - if payload["type"] == "Case": - payload["mesh_id"] = ( - info.get("caseMeshId") or info.get("meshId") or info.get("volumeMeshId") - ) - return payload - - -def _emit_run_payload(*, draft_info=None, result, state=None, timed_out=False): - result_payload = _serialize_run_result(result) - if draft_info is None and state is None and not timed_out: - emit_json(result_payload) - return - - payload = {"result": result_payload} - if draft_info is not None: - payload["draft"] = _serialize_draft_info(draft_info) - if state is not None: - payload["state"] = state - if timed_out: - payload["timed_out"] = True - emit_json(payload) - - @click.group("draft") def draft(): """Inspect draft resources.""" @@ -302,118 +82,6 @@ def list_drafts_alias(project_id): _emit_draft_list(project_id) -@draft.command("create") -@click.argument("ref_id") -@click.option("--name", default=None, help="Optional draft name.") -def create_draft(ref_id, name): - """Create a draft from a project or asset ref.""" - emit_json(_serialize_draft_info(_create_draft_from_ref(ref_id, name=name))) - - -@draft.command("run") -@click.argument("ref_id") -@click.argument( - "simulation_path", - required=False, - type=click.Path(exists=True, dir_okay=False, resolve_path=True), -) -@click.option( - "--patch", - "patch_path", - default=None, - type=click.Path(exists=True, dir_okay=False, resolve_path=True), - help=( - "JSON patch object merged locally into the source simulation before draft run. " - "Recommended only for small edits such as angle of attack or velocity. " - "For larger or structurally risky changes, use Python with Pydantic models instead." - ), -) -@click.option("--name", default=None, help="Optional name for the created draft in one-shot mode.") -@click.option( - "--up-to", - "up_to_name", - required=True, - type=click.Choice(list(RUN_TARGETS.keys()), case_sensitive=False), - help="Run the draft up to the selected resource type.", -) -@click.option("--wait", "wait_for_result", is_flag=True, help="Wait for the result to reach a terminal state.") -@click.option( - "--timeout", - default=3600, - show_default=True, - type=click.FloatRange(min=0.1, min_open=False), - help="Maximum wait time in seconds when --wait is used.", -) -@click.option( - "--poll-interval", - default=2.0, - show_default=True, - type=click.FloatRange(min=0.1, min_open=False), - help="Polling interval in seconds when --wait is used.", -) -def run_draft(ref_id, simulation_path, patch_path, name, up_to_name, wait_for_result, timeout, poll_interval): - """Run a draft workflow.""" - resource_ref = _parse_resource_id(ref_id) - up_to = RUN_TARGETS[up_to_name.lower()] - - if resource_ref.resource_type == "Draft": - if simulation_path is not None or patch_path is not None or name is not None: - raise click.ClickException( - "Simulation JSON, patch, or name cannot be passed when running an existing draft. " - "Use 'flow360 draft simulation set ' first." - ) - result = _run_draft(resource_ref.id, up_to) - if not wait_for_result: - _emit_run_payload(result=result) - return - - try: - state = _wait_for_resource_state( - result["id"], timeout=timeout, poll_interval=poll_interval - ) - except WaitTimeoutError as error: - _emit_run_payload(result=result, state=error.state, timed_out=True) - raise click.exceptions.Exit(124) from error - - _emit_run_payload(result=result, state=state) - if not state["is_success"]: - raise click.exceptions.Exit(1) - return - - if simulation_path is not None and patch_path is not None: - raise click.ClickException( - "Provide either a full simulation JSON path or --patch, not both." - ) - - if simulation_path is None and patch_path is None: - raise click.ClickException( - "Simulation JSON path or --patch is required when running from a non-draft ref." - ) - - source = _resolve_draft_source(resource_ref.id) - if simulation_path is not None: - simulation_json = _load_simulation_json(simulation_path) - else: - simulation_json = _apply_patch_to_source_simulation(source, _load_patch_json(patch_path)) - - draft_info = _create_draft_from_source(source, name=name) - _set_draft_simulation_json(draft_info["id"], simulation_json) - result = _run_draft(draft_info["id"], up_to) - if not wait_for_result: - _emit_run_payload(draft_info=draft_info, result=result) - return - - try: - state = _wait_for_resource_state(result["id"], timeout=timeout, poll_interval=poll_interval) - except WaitTimeoutError as error: - _emit_run_payload(draft_info=draft_info, result=result, state=error.state, timed_out=True) - raise click.exceptions.Exit(124) from error - - _emit_run_payload(draft_info=draft_info, result=result, state=state) - if not state["is_success"]: - raise click.exceptions.Exit(1) - - def _emit_draft_info(draft_id): info = _get_draft_info(draft_id) emit_json(_serialize_draft_info(info)) @@ -435,16 +103,6 @@ def get_draft_alias(draft_id): _emit_draft_info(draft_id) -@draft.command("rename") -@click.argument("draft_id") -@click.option("--name", required=True, help="New draft name.") -def rename_draft(draft_id, name): - """Rename a draft.""" - draft_id = _require_typed_id(draft_id, "Draft") - _rename_draft(draft_id, name) - emit_json({"id": draft_id, "name": name}) - - @draft.command("state") @click.argument("draft_id") def show_draft_state(draft_id): @@ -464,17 +122,3 @@ 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)}) - - -@draft_simulation.command("set") -@click.argument("draft_id") -@click.argument( - "simulation_path", - type=click.Path(exists=True, dir_okay=False, resolve_path=True), -) -def set_draft_simulation(draft_id, simulation_path): - """Replace draft simulation JSON.""" - draft_id = _require_typed_id(draft_id, "Draft") - simulation_json = _load_simulation_json(simulation_path) - _set_draft_simulation_json(draft_id, simulation_json) - emit_json({"id": draft_id, "updated": True}) diff --git a/flow360/cli/open_resource.py b/flow360/cli/open_resource.py index 0d9d6d6ea..953160420 100644 --- a/flow360/cli/open_resource.py +++ b/flow360/cli/open_resource.py @@ -4,10 +4,7 @@ import click -from flow360.cli.browser_links import ( - get_resource_browser_payload, - open_browser_url, -) +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 diff --git a/flow360/cli/resource_refs.py b/flow360/cli/resource_refs.py index 4d1cbbb6b..149004aba 100644 --- a/flow360/cli/resource_refs.py +++ b/flow360/cli/resource_refs.py @@ -6,7 +6,6 @@ from dataclasses import dataclass - RESOURCE_PREFIX_MAP = { "prj": "Project", "geo": "Geometry", diff --git a/flow360/cli/resource_state.py b/flow360/cli/resource_state.py index 2a7373a4d..4c3bfebc3 100644 --- a/flow360/cli/resource_state.py +++ b/flow360/cli/resource_state.py @@ -10,7 +10,6 @@ from flow360.cli.resource_refs import ResourceRefError, parse_resource_ref - SUCCESS_STATES = {"completed", "processed"} TERMINAL_STATES = SUCCESS_STATES | {"failed", "error", "deleted"} diff --git a/flow360/cli/simulation_summary.py b/flow360/cli/simulation_summary.py index 5d3be7d50..ea00dc584 100644 --- a/flow360/cli/simulation_summary.py +++ b/flow360/cli/simulation_summary.py @@ -2,11 +2,10 @@ from __future__ import annotations -from collections import OrderedDict import copy import json import logging - +from collections import OrderedDict _PRIVATE_PREFIX = "private_attribute_" _ENTITY_COLLECTION_KEYS = ("stored_entities", "selectors") @@ -51,7 +50,9 @@ def _load_summary_dicts(simulation_json: dict) -> tuple[dict, dict, dict | None] 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 + 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) diff --git a/flow360/cli/wait.py b/flow360/cli/wait.py index c349a58b7..75b933329 100644 --- a/flow360/cli/wait.py +++ b/flow360/cli/wait.py @@ -7,7 +7,10 @@ import click from flow360.cli.output import emit_json -from flow360.cli.resource_state import WaitTimeoutError, wait_for_resource_state as _wait_for_resource_state +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") diff --git a/flow360/component/project.py b/flow360/component/project.py index 858c4226e..217776b7d 100644 --- a/flow360/component/project.py +++ b/flow360/component/project.py @@ -7,13 +7,14 @@ import json from enum import Enum -from typing import TYPE_CHECKING, Any, Dict, Iterable, List, Literal, Optional, Union +from typing import Dict, Iterable, List, Literal, Optional, Union import pydantic as pd import typing_extensions from flow360_schema.framework.physical_dimensions import Length from flow360_schema.models.asset_cache import CoordinateSystemStatus, MirrorStatus from pydantic import PositiveInt + from flow360.cloud.file_cache import get_shared_cloud_file_cache from flow360.cloud.flow360_requests import ( CloneVolumeMeshRequest, @@ -22,6 +23,7 @@ ) from flow360.cloud.http_util import http from flow360.cloud.rest_api import RestApi +from flow360.component.case import Case from flow360.component.cloud_examples import ( copy_example, fetch_examples, @@ -63,8 +65,6 @@ get_project_records, show_projects_with_keyword_filter, ) -from flow360.component.simulation.web.project_webapi import ProjectWebApi -from flow360.component.simulation.web.project_tree import ProjectTree from flow360.component.simulation.web.utils import ( get_project_dependency_resource_metadata, ) @@ -93,18 +93,6 @@ AssetOrResource = Union[type[AssetBase], type[Flow360Resource]] -if TYPE_CHECKING: - from flow360.component.case import Case -else: - Case = Any - - -def _case_class(): - # pylint: disable=import-outside-toplevel - from flow360.component.case import Case - - return Case - class RootType(Enum): """ @@ -260,7 +248,7 @@ def _merge_geometry_entity_info( if isinstance(new_run_from, Geometry) and new_run_from.info.dependency: raise Flow360ValueError("Draft creation from an imported Geometry is not supported.") - if isinstance(new_run_from, _case_class()): + if isinstance(new_run_from, Case): raise Flow360ValueError("Draft creation from a Case is not supported.") if not isinstance(new_run_from.entity_info, GeometryEntityInfo) and ( @@ -623,21 +611,15 @@ class Project(pd.BaseModel): Project class containing the interface for creating and running simulations. """ - metadata: Optional[ProjectMeta] = pd.Field( - default=None, description="Metadata of the project." - ) - project_tree: ProjectTree = pd.Field(default_factory=ProjectTree) - solver_version: Optional[str] = pd.Field( - default=None, description="Version of the solver being used." - ) + metadata: ProjectMeta = pd.Field(description="Metadata of the project.") + project_tree: ProjectTree = pd.Field() + solver_version: str = pd.Field(frozen=True, description="Version of the solver being used.") _root_asset: Union[Geometry, SurfaceMeshV2, VolumeMeshV2] = pd.PrivateAttr(None) _root_webapi: Optional[RestApi] = pd.PrivateAttr(None) - _project_webapi: Optional[ProjectWebApi] = pd.PrivateAttr(None) + _project_webapi: Optional[RestApi] = pd.PrivateAttr(None) _root_simulation_json: Optional[dict] = pd.PrivateAttr(None) - _project_id: Optional[str] = pd.PrivateAttr(None) - _lazy_load: bool = pd.PrivateAttr(False) @classmethod def show_remote(cls, search_keyword: Union[None, str] = None): @@ -661,9 +643,7 @@ def id(self) -> str: str The project ID. """ - if self.metadata is not None: - return self.metadata.id - return self._project_id + return self.metadata.id @property def tags(self) -> List[str]: @@ -675,19 +655,7 @@ def tags(self) -> List[str]: List[str] List of the project's tags. """ - return self.get_metadata().tags - - def get_metadata(self) -> ProjectMeta: - """Return project metadata, loading it on first access.""" - if self.metadata is None: - self._load_metadata() - return self.metadata - - def get_project_tree(self) -> ProjectTree: - """Return the project tree, loading it on first access.""" - if not self.project_tree.nodes: - self._load_tree() - return self.project_tree + return self.metadata.tags @property def length_unit(self) -> Length.PositiveFloat64: @@ -857,7 +825,7 @@ def get_case(self, asset_id: str = None) -> Case: asset_id = self.project_tree.get_full_asset_id( query_asset=AssetShortID(asset_id=asset_id, asset_type="Case") ) - return _case_class().from_cloud(case_id=asset_id) + return Case.from_cloud(case_id=asset_id) @property def case(self): @@ -959,7 +927,6 @@ def _create_project_from_files( solver_version: str = __solver_version__, length_unit: LengthUnitType = "m", tags: List[str] = None, - description: str = "", run_async: bool = False, folder: Optional[Folder] = None, workflow: GeometryWorkflow = "standard", @@ -979,8 +946,6 @@ def _create_project_from_files( Unit of length (default is "m"). tags : list of str, optional Tags to assign to the project (default is None). - description : str, optional - Description to assign to the project (default is ""). run_async : bool, optional Whether to create the project asynchronously (default is False). folder : Optional[Folder], optional @@ -1024,7 +989,7 @@ def _create_project_from_files( "Cannot detect the intended project root with the given file(s)." ) - root_asset = draft.submit(description=description, run_async=run_async) + root_asset = draft.submit(run_async=run_async) if run_async: log.info( f"The input file(s) has been successfully uploaded to project: {root_asset.project_id} " @@ -1067,7 +1032,6 @@ def _create_project_from_volume_mesh_clone( solver_version: str, length_unit: LengthUnitType, tags: Optional[List[str]], - description: str, run_async: bool, folder: Optional[Folder], ): @@ -1086,8 +1050,6 @@ def _create_project_from_volume_mesh_clone( Unit of length. tags : list of str, optional Tags to assign to the project. - description : str - Description to assign to the project. run_async : bool Whether to create the project asynchronously. folder : Optional[Folder], optional @@ -1105,7 +1067,6 @@ def _create_project_from_volume_mesh_clone( tags=tags, parent_folder_id=folder.id if folder else "ROOT.FLOW360", length_unit=length_unit, - description=description, original_volume_mesh_id=volume_mesh.id, ) @@ -1192,7 +1153,6 @@ def from_geometry( solver_version: str = __solver_version__, length_unit: LengthUnitType = "m", tags: List[str] = None, - description: str = "", run_async: bool = False, folder: Optional[Folder] = None, workflow: GeometryWorkflow = "standard", @@ -1212,8 +1172,6 @@ def from_geometry( Unit of length (default is "m"). tags : list of str, optional Tags to assign to the project (default is None). - description : str, optional - Description to assign to the project (default is ""). run_async : bool, optional Whether to create project asynchronously (default is False). folder : Optional[Folder], optional @@ -1255,7 +1213,6 @@ def from_geometry( solver_version=solver_version, length_unit=length_unit, tags=tags, - description=description, run_async=run_async, folder=folder, workflow=workflow, @@ -1271,7 +1228,6 @@ def from_surface_mesh( solver_version: str = __solver_version__, length_unit: LengthUnitType = "m", tags: List[str] = None, - description: str = "", run_async: bool = False, folder: Optional[Folder] = None, ): @@ -1291,8 +1247,6 @@ def from_surface_mesh( Unit of length (default is "m"). tags : list of str, optional Tags to assign to the project (default is None). - description : str, optional - Description to assign to the project (default is ""). run_async : bool, optional Whether to create project asynchronously (default is False). folder : Optional[Folder], optional @@ -1331,7 +1285,6 @@ def from_surface_mesh( solver_version=solver_version, length_unit=length_unit, tags=tags, - description=description, run_async=run_async, folder=folder, ) @@ -1347,7 +1300,6 @@ def from_volume_mesh( solver_version: Optional[str] = None, length_unit: Optional[LengthUnitType] = None, tags: Optional[List[str]] = None, - description: str = "", run_async: bool = False, folder: Optional[Folder] = None, ): @@ -1375,8 +1327,6 @@ def from_volume_mesh( tags : list of str, optional Tags to assign to the project (default is None for file input, or the original volume mesh's tags for VolumeMeshV2 input). - description : str, optional - Description to assign to the project (default is ""). run_async : bool Whether to create project asynchronously (default is False). folder : Optional[Folder], optional @@ -1425,7 +1375,6 @@ def from_volume_mesh( solver_version=resolved_solver_version, length_unit=resolved_length_unit, tags=resolved_tags, - description=description, run_async=run_async, folder=folder, ) @@ -1442,7 +1391,6 @@ def from_volume_mesh( solver_version=resolved_solver_version, length_unit=resolved_length_unit, tags=resolved_tags, - description=description, run_async=run_async, folder=folder, ) @@ -1461,7 +1409,6 @@ def from_file( solver_version: str = __solver_version__, length_unit: LengthUnitType = "m", tags: List[str] = None, - description: str = "", run_async: bool = False, ): """ @@ -1480,8 +1427,6 @@ def from_file( Unit of length (default is "m"). tags : list of str, optional Tags to assign to the project (default is None). - description : str, optional - Description to assign to the project (default is ""). run_async : bool, optional Whether to create project asynchronously (default is False). @@ -1517,7 +1462,6 @@ def _detect_input_file_type(file: Union[str, list[str]]): solver_version=solver_version, length_unit=length_unit, tags=tags, - description=description, run_async=run_async, ) @@ -1773,7 +1717,7 @@ def _get_user_requested_entity_info( "The supplied cloud resource for `new_run_from` does not belong to the project." ) - if isinstance(new_run_from, _case_class()): + if isinstance(new_run_from, Case): user_requested_entity_info = new_run_from.get_simulation_params() if isinstance(new_run_from, (Geometry, SurfaceMeshV2, VolumeMeshV2)): user_requested_entity_info = new_run_from.params @@ -1789,7 +1733,6 @@ def from_cloud( project_id: str, *, new_run_from: Optional[Union[Geometry, SurfaceMeshV2, VolumeMeshV2, Case]] = None, - lazy_load: bool = False, ): """ Loads a project from the cloud. @@ -1821,70 +1764,29 @@ def from_cloud( """ project_info = AssetShortID(asset_id=project_id, asset_type="Project") - - if lazy_load and new_run_from is not None: - raise ValueError("lazy project loading does not support `new_run_from`.") + project_api = RestApi(ProjectInterface.endpoint, id=project_info.asset_id) + info = project_api.get() + if not isinstance(info, dict): + raise Flow360WebError( + f"Cannot load project {project_info.asset_id}, missing project data." + ) + if not info: + raise Flow360WebError(f"Couldn't retrieve project info for {project_info.asset_id}") + meta = ProjectMeta(**info) + root_asset = None + root_type = meta.root_item_type if ( new_run_from is not None - and isinstance( - new_run_from, (Geometry, SurfaceMeshV2, VolumeMeshV2, _case_class()) - ) - is False + and isinstance(new_run_from, (Geometry, SurfaceMeshV2, VolumeMeshV2, Case)) is False ): # Should have been caught by the validate_call? raise ValueError( "The supplied `new_run_from` is not valid. Please check the function description for more details." ) - project = Project() - project._project_id = project_info.asset_id - project._project_webapi = ProjectWebApi(project_info.asset_id) - project._lazy_load = lazy_load - - if lazy_load: - return project - - project._hydrate_full_project(new_run_from=new_run_from) - return project - - def _load_metadata(self): - """Load project metadata from cloud if it has not been loaded yet.""" - if self.metadata is not None: - return - info = self._project_webapi.get_info() - if not isinstance(info, dict): - raise Flow360WebError(f"Cannot load project {self.id}, missing project data.") - if not info: - raise Flow360WebError(f"Couldn't retrieve project info for {self.id}") - self.metadata = ProjectMeta(**info) - self._project_id = self.metadata.id - - def _load_tree(self): - """Load project tree from cloud if it has not been loaded yet.""" - if self.project_tree.nodes: - return - resp = self._project_webapi.get_tree() - asset_records = sorted( - resp["records"], - key=lambda d: parse_datetime(d["updatedAt"]), - ) - self.project_tree = ProjectTree() - self.project_tree.construct_tree(asset_records=asset_records) - - def _hydrate_full_project( - self, - *, - new_run_from: Optional[Union[Geometry, SurfaceMeshV2, VolumeMeshV2, Case]] = None, - ): - """Hydrate the full project state used by heavier SDK workflows.""" - self._load_metadata() - meta = self.metadata - root_asset = None - root_type = meta.root_item_type - - entity_info_param = self._get_user_requested_entity_info( - current_project_id=self.id, new_run_from=new_run_from + entity_info_param = cls._get_user_requested_entity_info( + current_project_id=project_info.asset_id, new_run_from=new_run_from ) if root_type == RootType.GEOMETRY: @@ -1898,19 +1800,23 @@ def _hydrate_full_project( meta.root_item_id, entity_info_param=entity_info_param ) if not root_asset: - raise Flow360ValueError(f"Couldn't retrieve root asset for {self.id}") - self.solver_version = root_asset.solver_version + raise Flow360ValueError(f"Couldn't retrieve root asset for {project_info.asset_id}") + project = Project( + metadata=meta, project_tree=ProjectTree(), solver_version=root_asset.solver_version + ) + project._project_webapi = project_api if root_type == RootType.GEOMETRY: - self._root_asset = root_asset - self._root_webapi = RestApi(GeometryInterface.endpoint, id=root_asset.id) + project._root_asset = root_asset + project._root_webapi = RestApi(GeometryInterface.endpoint, id=root_asset.id) elif root_type == RootType.SURFACE_MESH: - self._root_asset = root_asset - self._root_webapi = RestApi(SurfaceMeshInterfaceV2.endpoint, id=root_asset.id) + project._root_asset = root_asset + project._root_webapi = RestApi(SurfaceMeshInterfaceV2.endpoint, id=root_asset.id) elif root_type == RootType.VOLUME_MESH: - self._root_asset = root_asset - self._root_webapi = RestApi(VolumeMeshInterfaceV2.endpoint, id=root_asset.id) - self._get_root_simulation_json() - self._get_tree_from_cloud() + project._root_asset = root_asset + project._root_webapi = RestApi(VolumeMeshInterfaceV2.endpoint, id=root_asset.id) + project._get_root_simulation_json() + project._get_tree_from_cloud() + return project @classmethod @pd.validate_call @@ -1967,9 +1873,6 @@ def _check_initialized(self): Flow360ValueError If the project is not initialized. """ - if self._lazy_load and (not self.metadata or not self.solver_version or not self._root_asset): - self._hydrate_full_project() - if not self.metadata or not self.solver_version or not self._root_asset: raise Flow360ValueError( "Project not initialized - use Project.from_file or Project.from_cloud" @@ -1992,9 +1895,12 @@ def _get_tree_from_cloud(self, destination_obj: AssetOrResource = None): asset_records = [] if destination_obj: method = "path" - resp = self._project_webapi.get_path( - item_id=destination_obj.id, - item_type=destination_obj._cloud_resource_type_name, + resp = self._project_webapi.get( + method=method, + params={ + "itemId": destination_obj.id, + "itemType": destination_obj._cloud_resource_type_name, + }, ) for key, val in resp.items(): if not val: @@ -2005,7 +1911,7 @@ def _get_tree_from_cloud(self, destination_obj: AssetOrResource = None): asset_records.append(val) else: method = "tree" - resp = self._project_webapi.get_tree() + resp = self._project_webapi.get(method=method) asset_records = resp["records"] self.project_tree = ProjectTree() @@ -2273,7 +2179,7 @@ def _run( # Remove when converting Case to V2 kwargs = {} - if isinstance(destination_obj, _case_class()): + if isinstance(destination_obj, Case): kwargs = {"project_id": destination_obj.project_id} log.info(f"Successfully submitted: {destination_obj.short_description(**kwargs)}") @@ -2537,7 +2443,7 @@ def run_case( self._check_initialized() case_or_draft = self._run( params=params, - target=_case_class(), + target=Case, draft_name=name, run_async=run_async, fork_from=fork_from, diff --git a/flow360/component/simulation/web/asset_webapi.py b/flow360/component/simulation/web/asset_webapi.py index 054e21f28..ba5ff2a41 100644 --- a/flow360/component/simulation/web/asset_webapi.py +++ b/flow360/component/simulation/web/asset_webapi.py @@ -8,7 +8,6 @@ from flow360.cloud.rest_api import RestApi from flow360.component.interfaces import ( - CaseInterface, CaseInterfaceV2, GeometryInterface, SurfaceMeshInterfaceV2, @@ -45,18 +44,6 @@ def get_simulation_json(self): def get(self, path=None, method=None, json=None, params=None): return self._api.get(path=path, method=method, json=json, params=params) - def post(self, json, path=None, method=None): - return self._api.post(json=json, path=path, method=method) - - def put(self, json, path=None, method=None): - return self._api.put(json=json, path=path, method=method) - - def patch(self, json, path=None, method=None): - return self._api.patch(json=json, path=path, method=method) - - def delete(self, path=None, method=None): - return self._api.delete(path=path, method=method) - class GeometryWebApi(AssetWebApi): def __init__(self, asset_id: str): @@ -76,17 +63,3 @@ def __init__(self, asset_id: str): class CaseWebApi(AssetWebApi): def __init__(self, asset_id: str): super().__init__(CaseInterfaceV2, asset_id) - self._files_api = RestApi(CaseInterface.endpoint, id=asset_id) - - def list_files(self): - return self._unwrap_data(self._files_api.get(method="files")) - - def download_file(self, remote_file_name, *, to_file=None, to_folder=".", overwrite=False): - return CaseInterface.s3_transfer_method.download_file( - self.asset_id, - remote_file_name, - to_file=to_file, - to_folder=to_folder, - overwrite=overwrite, - verbose=False, - ) diff --git a/flow360/component/simulation/web/draft_webapi.py b/flow360/component/simulation/web/draft_webapi.py index 4225dae75..71637d132 100644 --- a/flow360/component/simulation/web/draft_webapi.py +++ b/flow360/component/simulation/web/draft_webapi.py @@ -4,10 +4,7 @@ from __future__ import annotations -import json - from flow360.cloud.rest_api import RestApi -from flow360.cloud.flow360_requests import DraftCreateRequest from flow360.component.interfaces import DraftInterface @@ -27,34 +24,6 @@ def _unwrap_data(response): def get_info(self): return self._unwrap_data(self._api.get()) - @classmethod - def create( - cls, - *, - project_id: str, - source_item_id: str, - source_item_type: str, - solver_version: str, - fork_case: bool, - name: str | None = None, - interpolation_volume_mesh_id: str | None = None, - interpolation_case_id: str | None = None, - tags: list[str] | None = None, - ): - api = RestApi(DraftInterface.endpoint) - payload = DraftCreateRequest( - name=name, - project_id=project_id, - source_item_id=source_item_id, - source_item_type=source_item_type, - solver_version=solver_version, - fork_case=fork_case, - interpolation_volume_mesh_id=interpolation_volume_mesh_id, - interpolation_case_id=interpolation_case_id, - tags=tags, - ).model_dump(by_alias=True) - return cls._unwrap_data(api.post(json=payload)) - @classmethod def list_records(cls, project_id: str): api = RestApi(DraftInterface.endpoint) @@ -67,54 +36,5 @@ def get_simulation_json(self): return response["simulationJson"] return response - def set_simulation_json(self, simulation_json): - if not isinstance(simulation_json, str): - simulation_json = json.dumps(simulation_json) - - return self._api.post( - method="simulation/file", - json={ - "data": simulation_json, - "type": "simulation", - "version": "", - }, - ) - - def run( - self, - up_to: str, - *, - use_in_house: bool = False, - use_gai: bool = False, - start_from: str | None = None, - job_type: str | None = None, - priority: int | None = None, - ): - payload = { - "upTo": up_to, - "useInHouse": use_in_house, - "useGai": use_gai, - } - if start_from is not None: - payload["forceCreationConfig"] = {"startFrom": start_from} - if job_type is not None: - payload["jobType"] = job_type - if priority is not None: - payload["priority"] = priority - - return self._unwrap_data(self._api.post(method="run", json=payload)) - def get(self, path=None, method=None, json=None, params=None): return self._api.get(path=path, method=method, json=json, params=params) - - def post(self, json, path=None, method=None): - return self._api.post(json=json, path=path, method=method) - - def put(self, json, path=None, method=None): - return self._api.put(json=json, path=path, method=method) - - def patch(self, json, path=None, method=None): - return self._api.patch(json=json, path=path, method=method) - - def delete(self, path=None, method=None): - return self._api.delete(path=path, method=method) diff --git a/flow360/component/simulation/web/project_tree.py b/flow360/component/simulation/web/project_tree.py deleted file mode 100644 index f2cd859d2..000000000 --- a/flow360/component/simulation/web/project_tree.py +++ /dev/null @@ -1,201 +0,0 @@ -""" -Project tree models and construction helpers. -""" - -from __future__ import annotations - -from typing import List, Literal, Optional - -import pydantic as pd -from pydantic import PositiveInt - -from flow360.component.utils import AssetShortID, get_short_asset_id, wrapstring - - -class ProjectTreeNode(pd.BaseModel): - """ - ProjectTreeNode class containing the info of an asset item in a project tree. - """ - - asset_id: str = pd.Field() - asset_name: str = pd.Field() - asset_type: str = pd.Field() - parent_id: Optional[str] = pd.Field(None) - case_mesh_id: Optional[str] = pd.Field(None) - case_mesh_label: Optional[str] = pd.Field(None) - children: List = pd.Field([]) - min_length_short_id: PositiveInt = pd.Field(7) - - def construct_string(self, line_width): - title_line = "<<" + self.asset_type + ">>" - name_line = f"name: {self.asset_name}" - id_line = f"id: {self.short_id}" - - max_line_width = min(line_width, max(len(name_line), len(id_line))) - block_line_width = max(len(title_line), max_line_width) - - name_line = wrapstring(long_str=f"name: {self.asset_name}", str_length=block_line_width) - id_line = wrapstring(long_str=f"id: {self.short_id}", str_length=block_line_width) - return f"{title_line.center(block_line_width)}\n{name_line}\n{id_line}" - - def add_child(self, child: "ProjectTreeNode"): - self.children.append(child) - - def remove_child(self, child_to_remove: "ProjectTreeNode"): - self.children = [child for child in self.children if child is not child_to_remove] - - @property - def short_id(self) -> str: - return get_short_asset_id( - full_asset_id=self.asset_id, num_character=self.min_length_short_id - ) - - @property - def edge_label(self) -> str: - if self.case_mesh_label: - prefix = "Using VolumeMesh:\n" - mesh_short_id = get_short_asset_id( - full_asset_id=self.case_mesh_label, - num_character=self.min_length_short_id, - ) - return prefix + mesh_short_id.center(len(prefix)) - return None - - -class ProjectTree(pd.BaseModel): - """ - ProjectTree class containing the project tree. - """ - - root: ProjectTreeNode = pd.Field(None) - nodes: dict[str, ProjectTreeNode] = pd.Field({}) - short_id_map: dict[str, List[str]] = pd.Field({}) - - def _update_case_mesh_label(self): - for node_id in self._get_asset_ids_by_type(asset_type="Case"): - node = self.nodes.get(node_id) - parent_node = self._get_parent_node(node=node) - if not parent_node: - continue - if parent_node.asset_type != "Case" or node.case_mesh_id == parent_node.case_mesh_id: - node.case_mesh_label = None - - def _update_node_short_id(self): - if len(self.nodes) == len(self.short_id_map): - pass - full_id_to_update = [] - short_id_duplicate = [] - for short_id, full_ids in self.short_id_map.items(): - if len(full_ids) > 1: - short_id_duplicate.append(short_id) - common_prefix = full_ids[0] - for full_id in full_ids[1:]: - while not full_id.startswith(common_prefix): - common_prefix = common_prefix[:-1] - common_prefix_processed = "".join(common_prefix.split("-")[1:]) - for full_id in full_ids: - self.nodes[full_id].min_length_short_id = len(common_prefix_processed) + 1 - full_id_to_update.append(full_id) - for full_id in full_id_to_update: - self.short_id_map.update({self.nodes[full_id].short_id: [full_id]}) - for short_id in short_id_duplicate: - self.short_id_map.pop(short_id, None) - - def _get_parent_node(self, node: ProjectTreeNode): - if not node.parent_id: - return None - return self.nodes.get(node.parent_id, None) - - def _has_node(self, asset_id: str) -> bool: - return asset_id in self.nodes.keys() - - def _get_asset_ids_by_type( - self, asset_type: str = Literal["Geometry", "SurfaceMesh", "VolumeMesh", "Case"] - ): - return [node.asset_id for node in self.nodes.values() if node.asset_type == asset_type] - - @classmethod - def _create_new_node(cls, asset_record: dict): - parent_id = ( - asset_record["parentCaseId"] - if asset_record["parentCaseId"] - else asset_record["parentId"] - ) - case_mesh_id = asset_record["parentId"] if asset_record["type"] == "Case" else None - - return ProjectTreeNode( - asset_id=asset_record["id"], - asset_name=asset_record["name"], - asset_type=asset_record["type"], - parent_id=parent_id, - case_mesh_id=case_mesh_id, - case_mesh_label=case_mesh_id, - ) - - def _update_short_id_map(self, new_node: ProjectTreeNode): - if new_node.short_id not in self.short_id_map.keys(): - self.short_id_map[new_node.short_id] = [] - self.short_id_map[new_node.short_id].append(new_node.asset_id) - - def add(self, asset_record: dict): - if self._has_node(asset_id=asset_record["id"]): - return False - - 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 - for node in self.nodes.values(): - if node.parent_id == new_node.asset_id: - new_node.add_child(child=node) - if node.asset_id == new_node.parent_id: - node.add_child(child=new_node) - self.nodes.update({new_node.asset_id: new_node}) - self._update_node_short_id() - self._update_case_mesh_label() - return True - - def remove_node(self, node_id: str): - node = self.nodes.get(node_id) - if not node: - return - if node.parent_id and self._has_node(node.parent_id): - self.nodes[node.parent_id].remove_child(node) - self.nodes.pop(node.asset_id) - - def construct_tree(self, asset_records: List[dict]): - for asset_record in asset_records: - 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}) - - for node in self.nodes.values(): - if node.parent_id and self._has_node(node.parent_id): - self.nodes[node.parent_id].add_child(node) - self._update_node_short_id() - self._update_case_mesh_label() - - @pd.validate_call - def get_full_asset_id(self, query_asset: AssetShortID) -> str: - if query_asset.asset_id is None: - asset_ids = self._get_asset_ids_by_type(asset_type=query_asset.asset_type) - if not asset_ids: - raise ValueError(f"No {query_asset.asset_type} is available in this project.") - return asset_ids[-1] - - if query_asset.asset_id in self.nodes: - return query_asset.asset_id - - full_ids = self.short_id_map.get(query_asset.asset_id, None) - if full_ids is None: - raise ValueError( - f"This asset does not exist in this project. Please check the input asset ID ({query_asset.asset_id})" - ) - if len(full_ids) > 1: - raise ValueError( - f"The input asset ID ({query_asset.asset_id}) is too short to retrieve the correct asset." - ) - return full_ids[0] - diff --git a/flow360/exceptions.py b/flow360/exceptions.py index ea6054ba2..7bd02af79 100644 --- a/flow360/exceptions.py +++ b/flow360/exceptions.py @@ -80,9 +80,6 @@ class Flow360AuthenticationError(Flow360Error): class Flow360AuthorisationError(Flow360Error): """Error authenticating a user through webapi webAPI.""" - def __init__(self, message: str = None): - Exception.__init__(self, message) - class Flow360DataError(Flow360Error): """Error accessing data.""" diff --git a/flow360/user_config.py b/flow360/user_config.py index 2aeafc0f4..50785f86b 100644 --- a/flow360/user_config.py +++ b/flow360/user_config.py @@ -106,7 +106,7 @@ def configure_apikey( store_apikey( apikey, profile=profile, - environment_name=_normalize_storage_environment_name(environment), + environment_name=environment, ) reload_user_config() log.info("Configuration successful.") diff --git a/tests/cli/test_cli_assets.py b/tests/cli/test_cli_assets.py index 5276fcb03..377c702ab 100644 --- a/tests/cli/test_cli_assets.py +++ b/tests/cli/test_cli_assets.py @@ -28,7 +28,6 @@ def test_case_group_help_shows_info_and_simulation(): assert "state" in result.output assert "summary" in result.output assert "simulation" in result.output - assert "results" in result.output assert "get" not in result.output @@ -125,27 +124,6 @@ def test_geometry_get_alias_outputs_metadata(monkeypatch): assert payload["type"] == "Geometry" -def test_geometry_rename_outputs_metadata(monkeypatch): - from flow360.cli import assets as assets_cli - - runner = CliRunner() - calls = {} - monkeypatch.setattr( - assets_cli, - "_rename_asset", - lambda webapi_cls, asset_id, new_name: calls.update( - {"webapi_cls": webapi_cls, "asset_id": asset_id, "new_name": new_name} - ), - ) - - result = runner.invoke(flow360, ["geometry", "rename", "geo-123", "--name", "Wing Renamed"]) - - assert result.exit_code == 0 - assert json.loads(result.output) == {"id": "geo-123", "name": "Wing Renamed"} - assert calls["asset_id"] == "geo-123" - assert calls["new_name"] == "Wing Renamed" - - def test_geometry_state_outputs_lifecycle_projection(monkeypatch): from flow360.cli import assets as assets_cli @@ -248,30 +226,6 @@ def test_surface_mesh_get_alias_outputs_metadata(monkeypatch): assert payload["type"] == "SurfaceMesh" -def test_surface_mesh_rename_outputs_metadata(monkeypatch): - from flow360.cli import assets as assets_cli - - runner = CliRunner() - calls = {} - monkeypatch.setattr( - assets_cli, - "_rename_asset", - lambda webapi_cls, asset_id, new_name: calls.update( - {"webapi_cls": webapi_cls, "asset_id": asset_id, "new_name": new_name} - ), - ) - - result = runner.invoke( - flow360, - ["surface-mesh", "rename", "sm-123", "--name", "Surface Mesh Renamed"], - ) - - assert result.exit_code == 0 - assert json.loads(result.output) == {"id": "sm-123", "name": "Surface Mesh Renamed"} - assert calls["asset_id"] == "sm-123" - assert calls["new_name"] == "Surface Mesh Renamed" - - def test_surface_mesh_state_outputs_lifecycle_projection(monkeypatch): from flow360.cli import assets as assets_cli @@ -372,30 +326,6 @@ def test_volume_mesh_get_alias_outputs_metadata(monkeypatch): assert payload["type"] == "VolumeMesh" -def test_volume_mesh_rename_outputs_metadata(monkeypatch): - from flow360.cli import assets as assets_cli - - runner = CliRunner() - calls = {} - monkeypatch.setattr( - assets_cli, - "_rename_asset", - lambda webapi_cls, asset_id, new_name: calls.update( - {"webapi_cls": webapi_cls, "asset_id": asset_id, "new_name": new_name} - ), - ) - - result = runner.invoke( - flow360, - ["volume-mesh", "rename", "vm-123", "--name", "Volume Mesh Renamed"], - ) - - assert result.exit_code == 0 - assert json.loads(result.output) == {"id": "vm-123", "name": "Volume Mesh Renamed"} - assert calls["asset_id"] == "vm-123" - assert calls["new_name"] == "Volume Mesh Renamed" - - def test_volume_mesh_state_outputs_lifecycle_projection(monkeypatch): from flow360.cli import assets as assets_cli @@ -498,27 +428,6 @@ def test_case_get_alias_outputs_metadata(monkeypatch): assert payload["type"] == "Case" -def test_case_rename_outputs_metadata(monkeypatch): - from flow360.cli import assets as assets_cli - - runner = CliRunner() - calls = {} - monkeypatch.setattr( - assets_cli, - "_rename_asset", - lambda webapi_cls, asset_id, new_name: calls.update( - {"webapi_cls": webapi_cls, "asset_id": asset_id, "new_name": new_name} - ), - ) - - result = runner.invoke(flow360, ["case", "rename", "case-123", "--name", "Alpha -18"]) - - assert result.exit_code == 0 - assert json.loads(result.output) == {"id": "case-123", "name": "Alpha -18"} - assert calls["asset_id"] == "case-123" - assert calls["new_name"] == "Alpha -18" - - def test_case_state_outputs_lifecycle_projection(monkeypatch): from flow360.cli import assets as assets_cli @@ -567,176 +476,3 @@ def test_case_simulation_get_outputs_json(monkeypatch): payload = json.loads(result.output) assert payload["simulation"]["version"] == "24.11.0" assert payload["simulation"]["unit_system"]["name"] == "SI" - - -def test_case_results_list_outputs_only_result_artifacts(monkeypatch): - from flow360.cli import assets as assets_cli - - runner = CliRunner() - monkeypatch.setattr( - assets_cli, - "_list_case_results", - lambda case_id: [ - { - "fileName": "results/total_forces_v2.csv", - "filePath": "results/total_forces_v2.csv", - "fileType": ".csv", - "length": 1024, - "updatedAt": "2025-01-01T01:00:00Z", - }, - { - "fileName": "results/nonlinear_residual_v2.csv", - "filePath": "results/nonlinear_residual_v2.csv", - "fileType": ".csv", - "length": 2048, - "updatedAt": None, - }, - ], - ) - - result = runner.invoke(flow360, ["case", "results", "list", "case-123"]) - - assert result.exit_code == 0 - assert json.loads(result.output) == { - "records": [ - { - "name": "total_forces_v2.csv", - "path": "results/total_forces_v2.csv", - "file_type": ".csv", - "size_bytes": 1024, - "updated_at": "2025-01-01T01:00:00Z", - }, - { - "name": "nonlinear_residual_v2.csv", - "path": "results/nonlinear_residual_v2.csv", - "file_type": ".csv", - "size_bytes": 2048, - "updated_at": None, - }, - ] - } - - -def test_case_results_ls_alias_outputs_only_result_artifacts(monkeypatch): - from flow360.cli import assets as assets_cli - - runner = CliRunner() - monkeypatch.setattr( - assets_cli, - "_list_case_results", - lambda case_id: [ - { - "fileName": "results/total_forces_v2.csv", - "filePath": "results/total_forces_v2.csv", - "fileType": ".csv", - "length": 1024, - "updatedAt": "2025-01-01T01:00:00Z", - } - ], - ) - - result = runner.invoke(flow360, ["case", "results", "ls", "case-123"]) - - assert result.exit_code == 0 - assert json.loads(result.output)["records"][0]["path"] == "results/total_forces_v2.csv" - - -def test_case_result_serialization_normalizes_full_storage_path(): - from flow360.cli import assets as assets_cli - - record = { - "fileName": "results/total_forces_v2.csv", - "filePath": "users/AID/case-123/results/total_forces_v2.csv", - "fileType": ".csv", - "length": 1024, - "updatedAt": "2025-01-01T01:00:00Z", - } - - assert assets_cli._serialize_case_result(record) == { - "name": "total_forces_v2.csv", - "path": "results/total_forces_v2.csv", - "file_type": ".csv", - "size_bytes": 1024, - "updated_at": "2025-01-01T01:00:00Z", - } - - -def test_case_results_get_downloads_selected_artifact(monkeypatch): - from flow360.cli import assets as assets_cli - - runner = CliRunner() - monkeypatch.setattr( - assets_cli, - "_resolve_case_result", - lambda case_id, result_ref: { - "fileName": "results/total_forces_v2.csv", - "filePath": "results/total_forces_v2.csv", - "fileType": ".csv", - "length": 1024, - "updatedAt": "2025-01-01T01:00:00Z", - }, - ) - monkeypatch.setattr( - assets_cli, - "_download_case_result", - lambda case_id, result_path, to_path=None, overwrite=False: "/tmp/total_forces_v2.csv", - ) - - result = runner.invoke(flow360, ["case", "results", "get", "case-123", "total_forces_v2.csv"]) - - assert result.exit_code == 0 - assert json.loads(result.output) == { - "case_id": "case-123", - "result": { - "name": "total_forces_v2.csv", - "path": "results/total_forces_v2.csv", - "file_type": ".csv", - "size_bytes": 1024, - "updated_at": "2025-01-01T01:00:00Z", - }, - "saved_to": "/tmp/total_forces_v2.csv", - } - - -def test_case_results_get_normalizes_storage_path_before_download(monkeypatch): - from flow360.cli import assets as assets_cli - - runner = CliRunner() - captured = {} - monkeypatch.setattr( - assets_cli, - "_resolve_case_result", - lambda case_id, result_ref: { - "fileName": "results/total_forces_v2.csv", - "filePath": "users/AID/case-123/results/total_forces_v2.csv", - "fileType": ".csv", - "length": 1024, - "updatedAt": "2025-01-01T01:00:00Z", - }, - ) - - def _fake_download(case_id, result_path, to_path=None, overwrite=False): - captured["case_id"] = case_id - captured["result_path"] = result_path - return "/tmp/total_forces_v2.csv" - - monkeypatch.setattr(assets_cli, "_download_case_result", _fake_download) - - result = runner.invoke(flow360, ["case", "results", "get", "case-123", "total_forces_v2.csv"]) - - assert result.exit_code == 0 - assert captured == { - "case_id": "case-123", - "result_path": "results/total_forces_v2.csv", - } - assert json.loads(result.output) == { - "case_id": "case-123", - "result": { - "name": "total_forces_v2.csv", - "path": "results/total_forces_v2.csv", - "file_type": ".csv", - "size_bytes": 1024, - "updated_at": "2025-01-01T01:00:00Z", - }, - "saved_to": "/tmp/total_forces_v2.csv", - } diff --git a/tests/cli/test_cli_draft.py b/tests/cli/test_cli_draft.py index aea49ec32..ba3cbdf6e 100644 --- a/tests/cli/test_cli_draft.py +++ b/tests/cli/test_cli_draft.py @@ -12,10 +12,10 @@ def test_draft_group_help_shows_read_commands(): assert result.exit_code == 0 assert "list" in result.output - assert "create" in result.output assert "info" in result.output assert "state" in result.output - assert "run" 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 @@ -125,24 +125,6 @@ def test_draft_get_alias_outputs_metadata(monkeypatch): assert payload["type"] == "Draft" -def test_draft_rename_outputs_metadata(monkeypatch): - from flow360.cli import draft as draft_cli - - runner = CliRunner() - calls = {} - monkeypatch.setattr( - draft_cli, - "_rename_draft", - lambda draft_id, new_name: calls.update({"draft_id": draft_id, "new_name": new_name}), - ) - - result = runner.invoke(flow360, ["draft", "rename", "dft-123", "--name", "Alpha Sweep 1"]) - - assert result.exit_code == 0 - assert json.loads(result.output) == {"id": "dft-123", "name": "Alpha Sweep 1"} - assert calls == {"draft_id": "dft-123", "new_name": "Alpha Sweep 1"} - - def test_draft_state_outputs_lifecycle_projection(monkeypatch): from flow360.cli import draft as draft_cli @@ -189,629 +171,3 @@ def test_draft_simulation_get_outputs_json(monkeypatch): payload = json.loads(result.output) assert payload["simulation"]["version"] == "24.11.0" assert payload["simulation"]["unit_system"]["name"] == "SI" - - -def test_draft_simulation_set_reads_json_and_updates(monkeypatch, tmp_path): - from flow360.cli import draft as draft_cli - - runner = CliRunner() - calls = {} - file_path = tmp_path / "params.json" - file_path.write_text('{"version":"24.11.0","unit_system":{"name":"SI"}}') - - monkeypatch.setattr( - draft_cli, - "_set_draft_simulation_json", - lambda draft_id, simulation_json: calls.update( - {"draft_id": draft_id, "simulation_json": simulation_json} - ), - ) - - result = runner.invoke( - flow360, - ["draft", "simulation", "set", "dft-123", str(file_path)], - ) - - assert result.exit_code == 0 - assert json.loads(result.output) == {"id": "dft-123", "updated": True} - assert calls == { - "draft_id": "dft-123", - "simulation_json": {"version": "24.11.0", "unit_system": {"name": "SI"}}, - } - - -def test_draft_simulation_set_rejects_invalid_json(tmp_path): - runner = CliRunner() - file_path = tmp_path / "params.json" - file_path.write_text("{not-json}") - - result = runner.invoke( - flow360, - ["draft", "simulation", "set", "dft-123", str(file_path)], - ) - - assert result.exit_code != 0 - assert f"Invalid JSON in {file_path}" in result.output - - -def test_draft_run_outputs_result_metadata(monkeypatch): - from flow360.cli import draft as draft_cli - - runner = CliRunner() - monkeypatch.setattr( - draft_cli, - "_run_draft", - lambda draft_id, up_to: { - "id": "vm-123", - "name": "Volume Mesh", - "projectId": "prj-123", - "parentId": "sm-123", - "solverVersion": "release-25.2", - "status": "queued", - "tags": ["demo"], - "type": "VolumeMesh", - "createdAt": "2025-01-01T00:00:00Z", - "updatedAt": "2025-01-01T01:00:00Z", - }, - ) - - result = runner.invoke(flow360, ["draft", "run", "dft-123", "--up-to", "volume-mesh"]) - - assert result.exit_code == 0 - payload = json.loads(result.output) - assert payload == { - "created_at": "2025-01-01T00:00:00Z", - "id": "vm-123", - "name": "Volume Mesh", - "parent_id": "sm-123", - "project_id": "prj-123", - "solver_version": "release-25.2", - "status": "queued", - "tags": ["demo"], - "type": "VolumeMesh", - "updated_at": "2025-01-01T01:00:00Z", - } - - -def test_wait_for_resource_state_polls_until_terminal(monkeypatch): - from flow360.cli import resource_state - - states = iter( - [ - { - "id": "vm-123", - "type": "VolumeMesh", - "status": "queued", - "is_terminal": False, - "is_success": False, - "updated_at": "2025-01-01T00:00:00Z", - }, - { - "id": "vm-123", - "type": "VolumeMesh", - "status": "completed", - "is_terminal": True, - "is_success": True, - "updated_at": "2025-01-01T00:00:02Z", - }, - ] - ) - sleeps = [] - monotonic_values = iter([0.0, 0.5]) - - monkeypatch.setattr(resource_state, "get_resource_state", lambda resource_id: next(states)) - monkeypatch.setattr(resource_state.time, "sleep", lambda interval: sleeps.append(interval)) - monkeypatch.setattr(resource_state.time, "monotonic", lambda: next(monotonic_values)) - - state = resource_state.wait_for_resource_state("vm-123", timeout=10.0, poll_interval=2.0) - - assert state["status"] == "completed" - assert sleeps == [2.0] - - -def test_draft_run_wait_outputs_result_and_state(monkeypatch): - from flow360.cli import draft as draft_cli - - runner = CliRunner() - monkeypatch.setattr( - draft_cli, - "_run_draft", - lambda draft_id, up_to: { - "id": "vm-123", - "name": "Volume Mesh", - "projectId": "prj-123", - "parentId": "sm-123", - "solverVersion": "release-25.2", - "status": "queued", - "tags": ["demo"], - "type": "VolumeMesh", - "createdAt": "2025-01-01T00:00:00Z", - "updatedAt": "2025-01-01T01:00:00Z", - }, - ) - monkeypatch.setattr( - draft_cli, - "_wait_for_resource_state", - lambda resource_id, timeout, poll_interval: { - "id": resource_id, - "type": "VolumeMesh", - "status": "completed", - "is_terminal": True, - "is_success": True, - "updated_at": "2025-01-01T01:00:02Z", - }, - ) - - result = runner.invoke( - flow360, - ["draft", "run", "dft-123", "--up-to", "volume-mesh", "--wait"], - ) - - assert result.exit_code == 0 - assert json.loads(result.output) == { - "result": { - "created_at": "2025-01-01T00:00:00Z", - "id": "vm-123", - "name": "Volume Mesh", - "parent_id": "sm-123", - "project_id": "prj-123", - "solver_version": "release-25.2", - "status": "queued", - "tags": ["demo"], - "type": "VolumeMesh", - "updated_at": "2025-01-01T01:00:00Z", - }, - "state": { - "id": "vm-123", - "type": "VolumeMesh", - "status": "completed", - "is_terminal": True, - "is_success": True, - "updated_at": "2025-01-01T01:00:02Z", - }, - } - - -def test_draft_run_wait_failed_state_exits_nonzero(monkeypatch): - from flow360.cli import draft as draft_cli - - runner = CliRunner() - monkeypatch.setattr( - draft_cli, - "_run_draft", - lambda draft_id, up_to: { - "id": "vm-123", - "name": "Volume Mesh", - "projectId": "prj-123", - "parentId": "sm-123", - "solverVersion": "release-25.2", - "status": "queued", - "tags": [], - "type": "VolumeMesh", - "createdAt": "2025-01-01T00:00:00Z", - "updatedAt": "2025-01-01T01:00:00Z", - }, - ) - monkeypatch.setattr( - draft_cli, - "_wait_for_resource_state", - lambda resource_id, timeout, poll_interval: { - "id": resource_id, - "type": "VolumeMesh", - "status": "failed", - "is_terminal": True, - "is_success": False, - "updated_at": "2025-01-01T01:00:02Z", - }, - ) - - result = runner.invoke( - flow360, - ["draft", "run", "dft-123", "--up-to", "volume-mesh", "--wait"], - ) - - assert result.exit_code == 1 - payload = json.loads(result.output) - assert payload["state"]["status"] == "failed" - assert payload["state"]["is_success"] is False - - -def test_draft_run_wait_timeout_exits_124(monkeypatch): - from flow360.cli import draft as draft_cli - - runner = CliRunner() - monkeypatch.setattr( - draft_cli, - "_run_draft", - lambda draft_id, up_to: { - "id": "vm-123", - "name": "Volume Mesh", - "projectId": "prj-123", - "parentId": "sm-123", - "solverVersion": "release-25.2", - "status": "queued", - "tags": [], - "type": "VolumeMesh", - "createdAt": "2025-01-01T00:00:00Z", - "updatedAt": "2025-01-01T01:00:00Z", - }, - ) - monkeypatch.setattr( - draft_cli, - "_wait_for_resource_state", - lambda resource_id, timeout, poll_interval: (_ for _ in ()).throw( - draft_cli.WaitTimeoutError( - { - "id": resource_id, - "type": "VolumeMesh", - "status": "running", - "is_terminal": False, - "is_success": False, - "updated_at": "2025-01-01T01:00:02Z", - } - ) - ), - ) - - result = runner.invoke( - flow360, - ["draft", "run", "dft-123", "--up-to", "volume-mesh", "--wait"], - ) - - assert result.exit_code == 124 - payload = json.loads(result.output) - assert payload["timed_out"] is True - assert payload["state"]["status"] == "running" - - -def test_draft_run_rejects_non_draft_id(): - runner = CliRunner() - - result = runner.invoke(flow360, ["draft", "run", "prj-123", "--up-to", "volume-mesh"]) - - assert result.exit_code != 0 - assert "Simulation JSON path or --patch is required when running from a non-draft ref." in result.output - - -def test_draft_create_outputs_metadata(monkeypatch): - from flow360.cli import draft as draft_cli - - runner = CliRunner() - monkeypatch.setattr( - draft_cli, - "_create_draft_from_ref", - lambda ref_id, name=None: { - "id": "dft-123", - "name": name, - "projectId": "prj-123", - "solverVersion": "release-25.2", - "sourceItemId": "geo-123", - "sourceItemType": "Geometry", - "forkCase": False, - "type": "Draft", - }, - ) - - result = runner.invoke(flow360, ["draft", "create", "prj-123", "--name", "Draft 1"]) - - assert result.exit_code == 0 - assert json.loads(result.output) == { - "fork_case": False, - "id": "dft-123", - "name": "Draft 1", - "project_id": "prj-123", - "solver_version": "release-25.2", - "source_item_id": "geo-123", - "source_item_type": "Geometry", - "type": "Draft", - } - - -def test_draft_run_from_project_creates_sets_and_runs(monkeypatch, tmp_path): - from flow360.cli import draft as draft_cli - - runner = CliRunner() - simulation_path = tmp_path / "simulation.json" - simulation_path.write_text('{"version":"24.11.0","unit_system":{"name":"SI"}}') - calls = {} - - monkeypatch.setattr( - draft_cli, - "_resolve_draft_source", - lambda ref_id: { - "project_id": "prj-123", - "source_item_id": "geo-123", - "source_item_type": "Geometry", - "solver_version": "release-25.2", - "fork_case": False, - }, - ) - monkeypatch.setattr( - draft_cli, - "_create_draft_from_source", - lambda source, name=None: { - "id": "dft-123", - "name": name or "Draft 1", - "projectId": source["project_id"], - "solverVersion": source["solver_version"], - "sourceItemId": source["source_item_id"], - "sourceItemType": source["source_item_type"], - "forkCase": source["fork_case"], - "type": "Draft", - }, - ) - monkeypatch.setattr( - draft_cli, - "_set_draft_simulation_json", - lambda draft_id, simulation_json: calls.update( - {"draft_id": draft_id, "simulation_json": simulation_json} - ), - ) - monkeypatch.setattr( - draft_cli, - "_run_draft", - lambda draft_id, up_to: { - "id": "vm-123", - "name": "Volume Mesh", - "projectId": "prj-123", - "parentId": "sm-123", - "solverVersion": "release-25.2", - "status": "queued", - "tags": ["demo"], - "type": "VolumeMesh", - "createdAt": "2025-01-01T00:00:00Z", - "updatedAt": "2025-01-01T01:00:00Z", - }, - ) - - result = runner.invoke( - flow360, - ["draft", "run", "prj-123", str(simulation_path), "--name", "Alpha -18", "--up-to", "volume-mesh"], - ) - - assert result.exit_code == 0 - assert calls == { - "draft_id": "dft-123", - "simulation_json": {"version": "24.11.0", "unit_system": {"name": "SI"}}, - } - assert json.loads(result.output) == { - "draft": { - "fork_case": False, - "id": "dft-123", - "name": "Alpha -18", - "project_id": "prj-123", - "solver_version": "release-25.2", - "source_item_id": "geo-123", - "source_item_type": "Geometry", - "type": "Draft", - }, - "result": { - "created_at": "2025-01-01T00:00:00Z", - "id": "vm-123", - "name": "Volume Mesh", - "parent_id": "sm-123", - "project_id": "prj-123", - "solver_version": "release-25.2", - "status": "queued", - "tags": ["demo"], - "type": "VolumeMesh", - "updated_at": "2025-01-01T01:00:00Z", - }, - } - - -def test_draft_run_from_project_with_wait_outputs_draft_result_and_state(monkeypatch, tmp_path): - from flow360.cli import draft as draft_cli - - runner = CliRunner() - simulation_path = tmp_path / "simulation.json" - simulation_path.write_text('{"version":"24.11.0","unit_system":{"name":"SI"}}') - - monkeypatch.setattr( - draft_cli, - "_resolve_draft_source", - lambda ref_id: { - "project_id": "prj-123", - "source_item_id": "geo-123", - "source_item_type": "Geometry", - "solver_version": "release-25.2", - "fork_case": False, - }, - ) - monkeypatch.setattr( - draft_cli, - "_create_draft_from_source", - lambda source, name=None: { - "id": "dft-123", - "name": "Draft 1", - "projectId": source["project_id"], - "solverVersion": source["solver_version"], - "sourceItemId": source["source_item_id"], - "sourceItemType": source["source_item_type"], - "forkCase": source["fork_case"], - "type": "Draft", - }, - ) - monkeypatch.setattr(draft_cli, "_set_draft_simulation_json", lambda draft_id, simulation_json: None) - monkeypatch.setattr( - draft_cli, - "_run_draft", - lambda draft_id, up_to: { - "id": "vm-123", - "name": "Volume Mesh", - "projectId": "prj-123", - "parentId": "sm-123", - "solverVersion": "release-25.2", - "status": "queued", - "tags": ["demo"], - "type": "VolumeMesh", - "createdAt": "2025-01-01T00:00:00Z", - "updatedAt": "2025-01-01T01:00:00Z", - }, - ) - monkeypatch.setattr( - draft_cli, - "_wait_for_resource_state", - lambda resource_id, timeout, poll_interval: { - "id": resource_id, - "type": "VolumeMesh", - "status": "completed", - "is_terminal": True, - "is_success": True, - "updated_at": "2025-01-01T01:00:02Z", - }, - ) - - result = runner.invoke( - flow360, - ["draft", "run", "prj-123", str(simulation_path), "--up-to", "volume-mesh", "--wait"], - ) - - assert result.exit_code == 0 - payload = json.loads(result.output) - assert payload["draft"]["id"] == "dft-123" - assert payload["result"]["id"] == "vm-123" - assert payload["state"]["status"] == "completed" - - -def test_draft_run_existing_draft_rejects_simulation_path(tmp_path): - runner = CliRunner() - simulation_path = tmp_path / "simulation.json" - simulation_path.write_text('{"version":"24.11.0"}') - - result = runner.invoke( - flow360, - ["draft", "run", "dft-123", str(simulation_path), "--up-to", "volume-mesh"], - ) - - assert result.exit_code != 0 - assert ( - "Simulation JSON, patch, or name cannot be passed when running an existing draft." - in result.output - ) - - -def test_draft_run_existing_draft_rejects_name(): - runner = CliRunner() - - result = runner.invoke( - flow360, - ["draft", "run", "dft-123", "--name", "Alpha -18", "--up-to", "volume-mesh"], - ) - - assert result.exit_code != 0 - assert ( - "Simulation JSON, patch, or name cannot be passed when running an existing draft." - in result.output - ) - - -def test_draft_run_from_project_patch_creates_sets_and_runs(monkeypatch, tmp_path): - from flow360.cli import draft as draft_cli - - runner = CliRunner() - patch_path = tmp_path / "patch.json" - patch_path.write_text('{"meshing":{"refinement_factor":2.5}}') - calls = {} - - monkeypatch.setattr( - draft_cli, - "_resolve_draft_source", - lambda ref_id: { - "project_id": "prj-123", - "source_item_id": "geo-123", - "source_item_type": "Geometry", - "solver_version": "release-25.2", - "fork_case": False, - }, - ) - monkeypatch.setattr( - draft_cli, - "_apply_patch_to_source_simulation", - lambda source, patch_json: { - "version": "24.11.0", - "meshing": { - "defaults": {"first_layer_thickness": 0.1}, - "refinement_factor": patch_json["meshing"]["refinement_factor"], - }, - }, - ) - monkeypatch.setattr( - draft_cli, - "_create_draft_from_source", - lambda source, name=None: { - "id": "dft-123", - "name": "Draft 1", - "projectId": source["project_id"], - "solverVersion": source["solver_version"], - "sourceItemId": source["source_item_id"], - "sourceItemType": source["source_item_type"], - "forkCase": source["fork_case"], - "type": "Draft", - }, - ) - monkeypatch.setattr( - draft_cli, - "_set_draft_simulation_json", - lambda draft_id, simulation_json: calls.update( - {"draft_id": draft_id, "simulation_json": simulation_json} - ), - ) - monkeypatch.setattr( - draft_cli, - "_run_draft", - lambda draft_id, up_to: { - "id": "vm-123", - "name": "Volume Mesh", - "projectId": "prj-123", - "parentId": "sm-123", - "solverVersion": "release-25.2", - "status": "queued", - "tags": ["demo"], - "type": "VolumeMesh", - "createdAt": "2025-01-01T00:00:00Z", - "updatedAt": "2025-01-01T01:00:00Z", - }, - ) - - result = runner.invoke( - flow360, - ["draft", "run", "prj-123", "--patch", str(patch_path), "--up-to", "volume-mesh"], - ) - - assert result.exit_code == 0 - assert calls == { - "draft_id": "dft-123", - "simulation_json": { - "version": "24.11.0", - "meshing": { - "defaults": {"first_layer_thickness": 0.1}, - "refinement_factor": 2.5, - }, - }, - } - - -def test_draft_run_rejects_simulation_and_patch_together(tmp_path): - runner = CliRunner() - simulation_path = tmp_path / "simulation.json" - patch_path = tmp_path / "patch.json" - simulation_path.write_text('{"version":"24.11.0"}') - patch_path.write_text('{"meshing":{"refinement_factor":2.5}}') - - result = runner.invoke( - flow360, - [ - "draft", - "run", - "prj-123", - str(simulation_path), - "--patch", - str(patch_path), - "--up-to", - "volume-mesh", - ], - ) - - assert result.exit_code != 0 - assert "Provide either a full simulation JSON path or --patch, not both." in result.output diff --git a/tests/cli/test_cli_folder.py b/tests/cli/test_cli_folder.py index 70db96972..bbb785c6f 100644 --- a/tests/cli/test_cli_folder.py +++ b/tests/cli/test_cli_folder.py @@ -13,9 +13,6 @@ def test_folder_group_help_shows_read_commands(): assert result.exit_code == 0 assert "get" in result.output assert "tree" in result.output - assert "create" in result.output - assert "rename" in result.output - assert "move" in result.output def test_folder_get_outputs_metadata(monkeypatch): @@ -72,98 +69,3 @@ def test_folder_tree_outputs_nested_tree(monkeypatch): payload = json.loads(result.output) assert payload["root"]["id"] == "ROOT.FLOW360" assert payload["root"]["subfolders"][0]["id"] == "folder-123" - - -def test_folder_create_outputs_metadata(monkeypatch): - from flow360.cli import folder as folder_cli - - runner = CliRunner() - calls = {} - monkeypatch.setattr( - folder_cli, - "_create_folder", - lambda name, parent_folder_id="ROOT.FLOW360", tags=None: calls.update( - { - "name": name, - "parent_folder_id": parent_folder_id, - "tags": tags, - } - ) - or { - "id": "folder-123", - "name": name, - "parentFolderId": parent_folder_id, - "type": "folder", - "tags": list(tags or []), - "createdAt": "2025-01-01T00:00:00Z", - "updatedAt": "2025-01-01T01:00:00Z", - "parentFolders": [], - }, - ) - - result = runner.invoke( - flow360, - [ - "folder", - "create", - "--name", - "Folder A", - "--parent-folder-id", - "ROOT.FLOW360", - "--tag", - "demo", - ], - ) - - assert result.exit_code == 0 - payload = json.loads(result.output) - assert payload["id"] == "folder-123" - assert payload["name"] == "Folder A" - assert calls == { - "name": "Folder A", - "parent_folder_id": "ROOT.FLOW360", - "tags": ("demo",), - } - - -def test_folder_rename_outputs_metadata(monkeypatch): - from flow360.cli import folder as folder_cli - - runner = CliRunner() - calls = {} - monkeypatch.setattr( - folder_cli, - "_rename_folder", - lambda folder_id, new_name: calls.update({"folder_id": folder_id, "new_name": new_name}), - ) - - result = runner.invoke(flow360, ["folder", "rename", "folder-123", "--name", "Renamed"]) - - assert result.exit_code == 0 - payload = json.loads(result.output) - assert payload == {"id": "folder-123", "name": "Renamed"} - assert calls == {"folder_id": "folder-123", "new_name": "Renamed"} - - -def test_folder_move_outputs_metadata(monkeypatch): - from flow360.cli import folder as folder_cli - - runner = CliRunner() - calls = {} - monkeypatch.setattr( - folder_cli, - "_move_folder", - lambda folder_id, parent_folder_id: calls.update( - {"folder_id": folder_id, "parent_folder_id": parent_folder_id} - ), - ) - - result = runner.invoke( - flow360, - ["folder", "move", "folder-123", "--parent-folder-id", "folder-456"], - ) - - assert result.exit_code == 0 - payload = json.loads(result.output) - assert payload == {"id": "folder-123", "parent_id": "folder-456"} - assert calls == {"folder_id": "folder-123", "parent_folder_id": "folder-456"} diff --git a/tests/cli/test_cli_open.py b/tests/cli/test_cli_open.py index f13745b4c..55268ce77 100644 --- a/tests/cli/test_cli_open.py +++ b/tests/cli/test_cli_open.py @@ -1,7 +1,7 @@ import json -from click.testing import CliRunner import pytest +from click.testing import CliRunner from flow360.cli import flow360 from flow360.cli.resource_refs import ResourceRefError @@ -49,8 +49,8 @@ def test_open_project_prints_url_and_opens_browser(monkeypatch): def test_open_case_prints_url_when_browser_does_not_open(monkeypatch): - from flow360.cli import open_resource as open_cli 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) @@ -72,8 +72,8 @@ def test_open_case_prints_url_when_browser_does_not_open(monkeypatch): def test_open_respects_root_environment_selection(monkeypatch): - from flow360.cli import open_resource as open_cli 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) @@ -93,12 +93,14 @@ def test_open_respects_root_environment_selection(monkeypatch): def test_open_folder_infers_workspace_route(monkeypatch): - from flow360.cli import open_resource as open_cli 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") + monkeypatch.setattr( + browser_links, "_resolve_folder_workspace_id", lambda resource_id: "private-abc" + ) result = runner.invoke(flow360, ["open", "folder-123"]) @@ -112,8 +114,8 @@ def test_open_folder_infers_workspace_route(monkeypatch): def test_open_shared_root_folder_uses_inferred_workspace_route(monkeypatch): - from flow360.cli import open_resource as open_cli 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) @@ -137,7 +139,9 @@ def test_open_shared_root_folder_uses_inferred_workspace_route(monkeypatch): 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_root_folder_id", lambda resource_id: "ROOT.FLOW360.123" + ) monkeypatch.setattr( browser_links, "_get_workspace_id_for_root_folder", @@ -150,8 +154,12 @@ def test_resolve_folder_workspace_id_uses_root_folder_workspace_mapping(monkeypa 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) + 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") diff --git a/tests/cli/test_cli_project.py b/tests/cli/test_cli_project.py index 5010a8778..aa19784ce 100644 --- a/tests/cli/test_cli_project.py +++ b/tests/cli/test_cli_project.py @@ -13,7 +13,6 @@ def test_project_group_help_shows_read_commands(): assert result.exit_code == 0 assert "list" in result.output - assert "create" in result.output assert "info" in result.output assert "tree" in result.output assert "path" in result.output @@ -77,6 +76,25 @@ def test_project_list_supports_search_limit_and_folder_filters(monkeypatch): } +def test_get_project_records_accepts_none_folder_ids(monkeypatch): + from flow360.cli import project as project_cli + from flow360.component.simulation.web import project_records + + calls = {} + + def fake_get_project_records(**kwargs): + calls.update(kwargs) + return [], 0 + + monkeypatch.setattr(project_records, "get_project_records", fake_get_project_records) + + records, total = project_cli._get_project_records(folder_ids=None) + + assert records == [] + assert total == 0 + assert calls["folder_ids"] is None + + def test_global_dev_and_profile_apply_to_project_commands(monkeypatch): from flow360.cli import project as project_cli from flow360.environment import Env @@ -88,9 +106,7 @@ def test_global_dev_and_profile_apply_to_project_commands(monkeypatch): monkeypatch.setattr( project_cli, "_get_project_info", - lambda project_id: seen.update( - {"env": Env.current.name, "profile": UserConfig.profile} - ) + lambda project_id: seen.update({"env": Env.current.name, "profile": UserConfig.profile}) or { "id": project_id, "name": "Wing Study", @@ -101,161 +117,21 @@ def test_global_dev_and_profile_apply_to_project_commands(monkeypatch): }, ) - result = runner.invoke( - flow360, - ["--dev", "--profile", "alt", "project", "get", "prj-12345678-1234-1234-1234-123456789abc"], - ) - - assert result.exit_code == 0 - assert seen["env"] == "dev" - assert seen["profile"] == "alt" - - -def test_project_create_from_geometry_calls_sdk(monkeypatch, tmp_path): - from flow360.cli import project as project_cli - - runner = CliRunner() - calls = {} - file_a = tmp_path / "wing.csm" - file_b = tmp_path / "wing.step" - file_a.write_text("solid") - file_b.write_text("solid") - - class FakeProject: - id = "prj-123" - - @staticmethod - def get_metadata(): - return SimpleNamespace( - name="Wing Project", - tags=["demo"], - root_item_id="geo-123", - root_item_type="Geometry", - ) - - monkeypatch.setattr( - project_cli, - "_create_project", - lambda **kwargs: calls.update(kwargs) or FakeProject(), - ) - result = runner.invoke( flow360, [ + "--dev", + "--profile", + "alt", "project", - "create", - "--from", - "geometry", - "--file", - str(file_a), - "--file", - str(file_b), - "--name", - "Wing Project", - "--solver-version", - "release-25.9", - "--length-unit", - "cm", - "--description", - "demo project", - "--tag", - "demo", - "--folder-id", - "folder-123", + "info", + "prj-12345678-1234-1234-1234-123456789abc", ], ) assert result.exit_code == 0 - payload = json.loads(result.output) - assert payload["id"] == "prj-123" - assert payload["root_item"]["id"] == "geo-123" - assert calls == { - "source": "geometry", - "files": (str(file_a), str(file_b)), - "name": "Wing Project", - "solver_version": "release-25.9", - "length_unit": "cm", - "description": "demo project", - "tags": ("demo",), - "folder_id": "folder-123", - "run_async": False, - } - - -def test_project_create_async_outputs_project_id(monkeypatch, tmp_path): - from flow360.cli import project as project_cli - - runner = CliRunner() - mesh_file = tmp_path / "mesh.cgns" - mesh_file.write_text("mesh") - - monkeypatch.setattr(project_cli, "_create_project", lambda **kwargs: "prj-async") - - result = runner.invoke( - flow360, - [ - "project", - "create", - "--from", - "surface-mesh", - "--file", - str(mesh_file), - "--async", - ], - ) - - assert result.exit_code == 0 - assert json.loads(result.output) == {"async": True, "id": "prj-async"} - - -def test_project_create_surface_mesh_requires_single_file(tmp_path): - runner = CliRunner() - file_a = tmp_path / "mesh-a.cgns" - file_b = tmp_path / "mesh-b.cgns" - file_a.write_text("mesh") - file_b.write_text("mesh") - - result = runner.invoke( - flow360, - [ - "project", - "create", - "--from", - "surface-mesh", - "--file", - str(file_a), - "--file", - str(file_b), - ], - ) - - assert result.exit_code != 0 - assert "surface-mesh projects require exactly one --file." in result.output - - -def test_project_create_volume_mesh_requires_single_file(tmp_path): - runner = CliRunner() - file_a = tmp_path / "mesh-a.cgns" - file_b = tmp_path / "mesh-b.cgns" - file_a.write_text("mesh") - file_b.write_text("mesh") - - result = runner.invoke( - flow360, - [ - "project", - "create", - "--from", - "volume-mesh", - "--file", - str(file_a), - "--file", - str(file_b), - ], - ) - - assert result.exit_code != 0 - assert "volume-mesh projects require exactly one --file." in result.output + assert seen["env"] == "dev" + assert seen["profile"] == "alt" def test_project_list_outputs_records(monkeypatch): @@ -363,8 +239,7 @@ def test_show_projects_uses_project_list_formatter(monkeypatch): project_cli, "_get_project_records", lambda search=None, limit=25, folder_ids=None, exclude_subfolders=False: ( - calls.update({"search": search, "limit": limit}) - or ([record], 1) + calls.update({"search": search, "limit": limit}) or ([record], 1) ), ) monkeypatch.setattr( @@ -410,35 +285,6 @@ def test_project_info_outputs_metadata(monkeypatch): assert payload["root_item"]["type"] == "Geometry" -def test_project_get_alias_outputs_metadata(monkeypatch): - from flow360.cli import project as project_cli - - runner = CliRunner() - info = { - "id": "prj-123", - "name": "Wing Study", - "solverVersion": "release-25.2", - "tags": ["demo"], - "rootItemId": "geo-123", - "rootItemType": "Geometry", - } - - monkeypatch.setattr( - project_cli, - "_get_project_info", - lambda project_id: info, - ) - - result = runner.invoke(flow360, ["project", "get", "prj-123"]) - - assert result.exit_code == 0 - payload = json.loads(result.output) - assert payload["id"] == "prj-123" - assert payload["name"] == "Wing Study" - assert payload["root_item"]["id"] == "geo-123" - assert payload["root_item"]["type"] == "Geometry" - - def test_project_tree_outputs_nested_tree(monkeypatch): from flow360.cli import project as project_cli @@ -574,64 +420,3 @@ def test_project_path_outputs_flat_branch(monkeypatch): "updated_at": "2025-01-01T01:00:00Z", }, ] - - -def test_project_rename_calls_webapi(monkeypatch): - from flow360.cli import project as project_cli - - runner = CliRunner() - calls = {} - - class FakeWebApi: - def __init__(self, project_id): - calls["project_id"] = project_id - - def patch(self, payload): - calls["payload"] = payload - - monkeypatch.setattr(project_cli, "_rename_project", lambda project_id, new_name: None) - monkeypatch.setattr( - project_cli, - "_rename_project", - lambda project_id, new_name: calls.update( - { - "project_id": project_id, - "new_name": new_name, - } - ), - ) - - result = runner.invoke(flow360, ["project", "rename", "prj-123", "--name", "New Name"]) - - assert result.exit_code == 0 - assert calls["project_id"] == "prj-123" - assert calls["new_name"] == "New Name" - assert "Renamed project prj-123 to New Name." in result.output - - -def test_project_delete_requires_yes(): - runner = CliRunner() - - result = runner.invoke(flow360, ["project", "delete", "prj-123"]) - - assert result.exit_code != 0 - assert "Pass --yes to confirm project deletion." in result.output - - -def test_project_delete_calls_webapi(monkeypatch): - from flow360.cli import project as project_cli - - runner = CliRunner() - calls = {} - - monkeypatch.setattr( - project_cli, - "_delete_project", - lambda project_id: calls.update({"project_id": project_id}), - ) - - result = runner.invoke(flow360, ["project", "delete", "prj-123", "--yes"]) - - assert result.exit_code == 0 - assert calls["project_id"] == "prj-123" - assert "Deleted project prj-123." in result.output diff --git a/tests/cli/test_cli_webapi_integration.py b/tests/cli/test_cli_webapi_integration.py index 13b630b31..ff41edd96 100644 --- a/tests/cli/test_cli_webapi_integration.py +++ b/tests/cli/test_cli_webapi_integration.py @@ -53,21 +53,6 @@ def test_project_info_uses_project_info_endpoint(recorded_webapi_calls): } -def test_project_get_alias_uses_project_info_endpoint(recorded_webapi_calls): - runner = CliRunner() - - result = runner.invoke(flow360, ["project", "get", PROJECT_ID]) - - assert result.exit_code == 0 - payload = _load_json_output(result.output) - assert payload["id"] == PROJECT_ID - assert recorded_webapi_calls[-1] == { - "type": "get", - "url": f"/v2/projects/{PROJECT_ID}", - "params": None, - } - - def test_project_tree_uses_tree_endpoint(recorded_webapi_calls): runner = CliRunner() @@ -75,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", @@ -146,72 +131,97 @@ def test_project_path_uses_path_endpoint(recorded_webapi_calls): } -def test_geometry_info_uses_geometry_v2_endpoint(recorded_webapi_calls): - runner = CliRunner() - - result = runner.invoke(flow360, ["geometry", "info", GEOMETRY_ID]) - - assert result.exit_code == 0 - payload = _load_json_output(result.output) - assert payload["id"] == GEOMETRY_ID - assert payload["type"] == "Geometry" - assert recorded_webapi_calls[-1] == { - "type": "get", - "url": f"/v2/geometries/{GEOMETRY_ID}", - "params": None, - } - - -def test_geometry_get_alias_uses_geometry_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, ["geometry", "get", GEOMETRY_ID]) + result = runner.invoke(flow360, [command, "info", resource_id]) assert result.exit_code == 0 payload = _load_json_output(result.output) - assert payload["id"] == GEOMETRY_ID - assert payload["type"] == "Geometry" + assert payload["id"] == resource_id + assert payload["type"] == resource_type assert recorded_webapi_calls[-1] == { "type": "get", - "url": f"/v2/geometries/{GEOMETRY_ID}", + "url": f"/v2/{endpoint}/{resource_id}", "params": None, } -def test_geometry_state_uses_geometry_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_state_uses_v2_endpoint( + command, resource_id, resource_type, endpoint, recorded_webapi_calls +): runner = CliRunner() - result = runner.invoke(flow360, ["geometry", "state", GEOMETRY_ID]) + result = runner.invoke(flow360, [command, "state", resource_id]) assert result.exit_code == 0 payload = _load_json_output(result.output) - assert payload["id"] == GEOMETRY_ID - assert payload["status"] == "processed" + assert payload["id"] == resource_id + assert payload["type"] == resource_type assert recorded_webapi_calls[-1] == { "type": "get", - "url": f"/v2/geometries/{GEOMETRY_ID}", + "url": f"/v2/{endpoint}/{resource_id}", "params": None, } -def test_geometry_simulation_get_uses_geometry_simulation_endpoint(recorded_webapi_calls): +@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, ["geometry", "simulation", "get", GEOMETRY_ID]) + 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 "models" in payload["simulation"] assert recorded_webapi_calls[-1] == { "type": "get", - "url": f"/v2/geometries/{GEOMETRY_ID}/simulation/file", + "url": f"/v2/{endpoint}/{resource_id}/simulation/file", "params": {"type": "simulation"}, } -def test_geometry_summary_uses_geometry_simulation_endpoint(monkeypatch, recorded_webapi_calls): +@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() @@ -221,15 +231,15 @@ def test_geometry_summary_uses_geometry_simulation_endpoint(monkeypatch, recorde lambda simulation_json: {"models": {"surface": []}}, ) - result = runner.invoke(flow360, ["geometry", "summary", GEOMETRY_ID]) + result = runner.invoke(flow360, [command, "summary", resource_id]) assert result.exit_code == 0 payload = _load_json_output(result.output) - assert payload["id"] == GEOMETRY_ID + assert payload["id"] == resource_id assert "summary" in payload assert recorded_webapi_calls[-1] == { "type": "get", - "url": f"/v2/geometries/{GEOMETRY_ID}/simulation/file", + "url": f"/v2/{endpoint}/{resource_id}/simulation/file", "params": {"type": "simulation"}, } @@ -242,7 +252,7 @@ def test_case_info_uses_case_v2_endpoint(recorded_webapi_calls): assert result.exit_code == 0 payload = _load_json_output(result.output) assert payload["id"] == CASE_ID - assert payload["mesh_id"] == "vm-7c3681cd-8c6c-4db7-a62c-1742d825e9d3" + assert payload["mesh_id"] == VOLUME_MESH_ID assert recorded_webapi_calls[-1] == { "type": "get", "url": f"/v2/cases/{CASE_ID}", @@ -259,7 +269,7 @@ def test_case_state_uses_case_v2_endpoint(recorded_webapi_calls): payload = _load_json_output(result.output) assert payload["id"] == CASE_ID assert payload["status"] == "completed" - assert payload["mesh_id"] == "vm-7c3681cd-8c6c-4db7-a62c-1742d825e9d3" + assert payload["mesh_id"] == VOLUME_MESH_ID assert recorded_webapi_calls[-1] == { "type": "get", "url": f"/v2/cases/{CASE_ID}", @@ -267,144 +277,6 @@ def test_case_state_uses_case_v2_endpoint(recorded_webapi_calls): } -def test_surface_mesh_info_uses_surface_mesh_v2_endpoint(recorded_webapi_calls): - runner = CliRunner() - - result = runner.invoke(flow360, ["surface-mesh", "info", SURFACE_MESH_ID]) - - assert result.exit_code == 0 - payload = _load_json_output(result.output) - assert payload["id"] == SURFACE_MESH_ID - assert payload["type"] == "SurfaceMesh" - assert recorded_webapi_calls[-1] == { - "type": "get", - "url": f"/v2/surface-meshes/{SURFACE_MESH_ID}", - "params": None, - } - - -def test_surface_mesh_get_alias_uses_surface_mesh_v2_endpoint(recorded_webapi_calls): - runner = CliRunner() - - result = runner.invoke(flow360, ["surface-mesh", "get", SURFACE_MESH_ID]) - - assert result.exit_code == 0 - payload = _load_json_output(result.output) - assert payload["id"] == SURFACE_MESH_ID - assert payload["type"] == "SurfaceMesh" - assert recorded_webapi_calls[-1] == { - "type": "get", - "url": f"/v2/surface-meshes/{SURFACE_MESH_ID}", - "params": None, - } - - -def test_surface_mesh_state_uses_surface_mesh_v2_endpoint(recorded_webapi_calls): - runner = CliRunner() - - result = runner.invoke(flow360, ["surface-mesh", "state", SURFACE_MESH_ID]) - - assert result.exit_code == 0 - payload = _load_json_output(result.output) - assert payload["id"] == SURFACE_MESH_ID - assert payload["status"] == "processed" - assert recorded_webapi_calls[-1] == { - "type": "get", - "url": f"/v2/surface-meshes/{SURFACE_MESH_ID}", - "params": None, - } - - -def test_surface_mesh_simulation_get_uses_surface_mesh_simulation_endpoint(recorded_webapi_calls): - runner = CliRunner() - - result = runner.invoke(flow360, ["surface-mesh", "simulation", "get", SURFACE_MESH_ID]) - - assert result.exit_code == 0 - payload = _load_json_output(result.output) - assert "simulation" in payload - assert isinstance(payload["simulation"], dict) - assert "models" in payload["simulation"] - assert recorded_webapi_calls[-1] == { - "type": "get", - "url": f"/v2/surface-meshes/{SURFACE_MESH_ID}/simulation/file", - "params": {"type": "simulation"}, - } - - -def test_surface_mesh_summary_uses_surface_mesh_simulation_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, ["surface-mesh", "summary", SURFACE_MESH_ID]) - - assert result.exit_code == 0 - payload = _load_json_output(result.output) - assert payload["id"] == SURFACE_MESH_ID - assert "summary" in payload - assert recorded_webapi_calls[-1] == { - "type": "get", - "url": f"/v2/surface-meshes/{SURFACE_MESH_ID}/simulation/file", - "params": {"type": "simulation"}, - } - - -def test_volume_mesh_info_uses_volume_mesh_v2_endpoint(recorded_webapi_calls): - runner = CliRunner() - - result = runner.invoke(flow360, ["volume-mesh", "info", VOLUME_MESH_ID]) - - assert result.exit_code == 0 - payload = _load_json_output(result.output) - assert payload["id"] == VOLUME_MESH_ID - assert payload["type"] == "VolumeMesh" - assert recorded_webapi_calls[-1] == { - "type": "get", - "url": f"/v2/volume-meshes/{VOLUME_MESH_ID}", - "params": None, - } - - -def test_volume_mesh_get_alias_uses_volume_mesh_v2_endpoint(recorded_webapi_calls): - runner = CliRunner() - - result = runner.invoke(flow360, ["volume-mesh", "get", VOLUME_MESH_ID]) - - assert result.exit_code == 0 - payload = _load_json_output(result.output) - assert payload["id"] == VOLUME_MESH_ID - assert payload["type"] == "VolumeMesh" - assert recorded_webapi_calls[-1] == { - "type": "get", - "url": f"/v2/volume-meshes/{VOLUME_MESH_ID}", - "params": None, - } - - -def test_volume_mesh_state_uses_volume_mesh_v2_endpoint(recorded_webapi_calls): - runner = CliRunner() - - result = runner.invoke(flow360, ["volume-mesh", "state", VOLUME_MESH_ID]) - - 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_wait_uses_resource_state_endpoint(recorded_webapi_calls): runner = CliRunner() @@ -421,146 +293,6 @@ def test_wait_uses_resource_state_endpoint(recorded_webapi_calls): } -def test_volume_mesh_simulation_get_uses_volume_mesh_simulation_endpoint(recorded_webapi_calls): - runner = CliRunner() - - result = runner.invoke(flow360, ["volume-mesh", "simulation", "get", VOLUME_MESH_ID]) - - assert result.exit_code == 0 - payload = _load_json_output(result.output) - assert "simulation" in payload - assert isinstance(payload["simulation"], dict) - assert "models" in payload["simulation"] - assert recorded_webapi_calls[-1] == { - "type": "get", - "url": f"/v2/volume-meshes/{VOLUME_MESH_ID}/simulation/file", - "params": {"type": "simulation"}, - } - - -def test_volume_mesh_summary_uses_volume_mesh_simulation_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, ["volume-mesh", "summary", VOLUME_MESH_ID]) - - assert result.exit_code == 0 - payload = _load_json_output(result.output) - assert payload["id"] == VOLUME_MESH_ID - assert "summary" in payload - assert recorded_webapi_calls[-1] == { - "type": "get", - "url": f"/v2/volume-meshes/{VOLUME_MESH_ID}/simulation/file", - "params": {"type": "simulation"}, - } - - -def test_case_get_alias_uses_case_v2_endpoint(recorded_webapi_calls): - runner = CliRunner() - - result = runner.invoke(flow360, ["case", "get", CASE_ID]) - - assert result.exit_code == 0 - payload = _load_json_output(result.output) - assert payload["id"] == CASE_ID - assert payload["mesh_id"] == "vm-7c3681cd-8c6c-4db7-a62c-1742d825e9d3" - assert recorded_webapi_calls[-1] == { - "type": "get", - "url": f"/v2/cases/{CASE_ID}", - "params": None, - } - - -def test_case_simulation_get_uses_case_simulation_endpoint(recorded_webapi_calls): - runner = CliRunner() - - result = runner.invoke(flow360, ["case", "simulation", "get", CASE_ID]) - - assert result.exit_code == 0 - payload = _load_json_output(result.output) - assert "simulation" in payload - assert isinstance(payload["simulation"], dict) - assert "models" in payload["simulation"] - assert recorded_webapi_calls[-1] == { - "type": "get", - "url": f"/v2/cases/{CASE_ID}/simulation/file", - "params": {"type": "simulation"}, - } - - -def test_case_summary_uses_case_simulation_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": {"fluid": []}}, - ) - - result = runner.invoke(flow360, ["case", "summary", CASE_ID]) - - assert result.exit_code == 0 - payload = _load_json_output(result.output) - assert payload["id"] == CASE_ID - assert "summary" in payload - assert recorded_webapi_calls[-1] == { - "type": "get", - "url": f"/v2/cases/{CASE_ID}/simulation/file", - "params": {"type": "simulation"}, - } - - -def test_case_results_ls_uses_legacy_case_files_endpoint(recorded_webapi_calls): - runner = CliRunner() - - result = runner.invoke(flow360, ["case", "results", "list", CASE_ID]) - - assert result.exit_code == 0 - payload = _load_json_output(result.output) - assert payload["records"] - assert all(record["path"].startswith("results/") for record in payload["records"]) - assert recorded_webapi_calls[-1] == { - "type": "get", - "url": f"/cases/{CASE_ID}/files", - "params": None, - } - - -def test_case_results_get_uses_legacy_case_files_endpoint(monkeypatch, recorded_webapi_calls): - from flow360.cli import assets as assets_cli - - runner = CliRunner() - monkeypatch.setattr( - assets_cli, - "_download_case_result", - lambda case_id, result_path, to_path=None, overwrite=False: "/tmp/total_forces_v2.csv", - ) - - result = runner.invoke( - flow360, - ["case", "results", "get", CASE_ID, "force_output_wing_all_planes_forces_v2.csv"], - ) - - assert result.exit_code == 0 - payload = _load_json_output(result.output) - assert payload["saved_to"] == "/tmp/total_forces_v2.csv" - assert payload["result"]["path"] == "results/force_output_wing_all_planes_forces_v2.csv" - assert recorded_webapi_calls[-1] == { - "type": "get", - "url": f"/cases/{CASE_ID}/files", - "params": None, - } - - def test_draft_list_uses_project_scoped_list_endpoint(recorded_webapi_calls): runner = CliRunner() @@ -591,21 +323,6 @@ def test_draft_info_uses_draft_info_endpoint(recorded_webapi_calls): } -def test_draft_get_alias_uses_draft_info_endpoint(recorded_webapi_calls): - runner = CliRunner() - - result = runner.invoke(flow360, ["draft", "get", 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() @@ -637,340 +354,6 @@ def test_draft_simulation_get_uses_simulation_endpoint(recorded_webapi_calls): } -def test_draft_simulation_set_uses_simulation_endpoint(recorded_webapi_calls, tmp_path): - runner = CliRunner() - file_path = tmp_path / "params.json" - file_path.write_text('{"version":"24.11.0","unit_system":{"name":"SI"}}') - - result = runner.invoke( - flow360, - ["draft", "simulation", "set", DRAFT_ID, str(file_path)], - ) - - assert result.exit_code == 0 - payload = _load_json_output(result.output) - assert payload == {"id": DRAFT_ID, "updated": True} - assert recorded_webapi_calls[-1] == { - "type": "post", - "url": f"/v2/drafts/{DRAFT_ID}/simulation/file", - "params": { - "data": '{"version": "24.11.0", "unit_system": {"name": "SI"}}', - "type": "simulation", - "version": "", - }, - } - - -def test_draft_create_from_project_resolves_root_and_posts_to_drafts(recorded_webapi_calls): - runner = CliRunner() - - result = runner.invoke(flow360, ["draft", "create", PROJECT_ID, "--name", "Draft A"]) - - assert result.exit_code == 0 - payload = _load_json_output(result.output) - assert payload["id"] == DRAFT_ID - assert payload["source_item_id"] == GEOMETRY_ID - calls = recorded_webapi_calls[-3:] - assert calls[0] == { - "type": "get", - "url": f"/v2/projects/{PROJECT_ID}", - "params": None, - } - assert calls[1] == { - "type": "get", - "url": f"/v2/geometries/{GEOMETRY_ID}", - "params": None, - } - assert calls[2]["type"] == "post" - assert calls[2]["url"] == "/v2/drafts" - assert calls[2]["params"]["name"] == "Draft A" - assert calls[2]["params"]["projectId"] == PROJECT_ID - assert calls[2]["params"]["sourceItemId"] == GEOMETRY_ID - assert calls[2]["params"]["sourceItemType"] == "Geometry" - assert calls[2]["params"]["solverVersion"] == "release-24.11" - assert calls[2]["params"]["forkCase"] is False - - -def test_draft_run_uses_run_endpoint(recorded_webapi_calls): - runner = CliRunner() - - result = runner.invoke(flow360, ["draft", "run", DRAFT_ID, "--up-to", "volume-mesh"]) - - assert result.exit_code == 0 - payload = _load_json_output(result.output) - assert payload["id"] == VOLUME_MESH_ID - assert payload["type"] == "VolumeMesh" - assert recorded_webapi_calls[-1] == { - "type": "post", - "url": f"/v2/drafts/{DRAFT_ID}/run", - "params": { - "upTo": "VolumeMesh", - "useInHouse": False, - "useGai": False, - }, - } - - -def test_draft_run_wait_polls_result_state_endpoint(recorded_webapi_calls): - runner = CliRunner() - - result = runner.invoke(flow360, ["draft", "run", DRAFT_ID, "--up-to", "volume-mesh", "--wait"]) - - assert result.exit_code == 0 - payload = _load_json_output(result.output) - assert payload["result"]["id"] == VOLUME_MESH_ID - assert payload["state"]["id"] == VOLUME_MESH_ID - calls = recorded_webapi_calls[-2:] - assert calls[0] == { - "type": "post", - "url": f"/v2/drafts/{DRAFT_ID}/run", - "params": { - "upTo": "VolumeMesh", - "useInHouse": False, - "useGai": False, - }, - } - assert calls[1] == { - "type": "get", - "url": f"/v2/volume-meshes/{VOLUME_MESH_ID}", - "params": None, - } - - -def test_draft_run_from_project_creates_sets_and_runs(recorded_webapi_calls, tmp_path): - runner = CliRunner() - simulation_path = tmp_path / "simulation.json" - simulation_path.write_text('{"version":"24.11.0","unit_system":{"name":"SI"}}') - - result = runner.invoke( - flow360, - [ - "draft", - "run", - PROJECT_ID, - str(simulation_path), - "--name", - "Alpha -18", - "--up-to", - "volume-mesh", - ], - ) - - assert result.exit_code == 0 - payload = _load_json_output(result.output) - assert payload["draft"]["id"] == DRAFT_ID - assert payload["result"]["id"] == VOLUME_MESH_ID - calls = recorded_webapi_calls[-5:] - assert calls[0] == { - "type": "get", - "url": f"/v2/projects/{PROJECT_ID}", - "params": None, - } - assert calls[1] == { - "type": "get", - "url": f"/v2/geometries/{GEOMETRY_ID}", - "params": None, - } - assert calls[2]["type"] == "post" - assert calls[2]["url"] == "/v2/drafts" - assert calls[2]["params"]["projectId"] == PROJECT_ID - assert calls[2]["params"]["sourceItemId"] == GEOMETRY_ID - assert calls[2]["params"]["sourceItemType"] == "Geometry" - assert calls[2]["params"]["solverVersion"] == "release-24.11" - assert calls[2]["params"]["forkCase"] is False - assert calls[2]["params"]["name"] == "Alpha -18" - assert calls[3] == { - "type": "post", - "url": f"/v2/drafts/{DRAFT_ID}/simulation/file", - "params": { - "data": '{"version": "24.11.0", "unit_system": {"name": "SI"}}', - "type": "simulation", - "version": "", - }, - } - assert calls[4] == { - "type": "post", - "url": f"/v2/drafts/{DRAFT_ID}/run", - "params": { - "upTo": "VolumeMesh", - "useInHouse": False, - "useGai": False, - }, - } - - -def test_draft_run_from_project_patch_fetches_merges_sets_and_runs(recorded_webapi_calls, tmp_path): - runner = CliRunner() - patch_path = tmp_path / "patch.json" - patch_path.write_text('{"meshing":{"refinement_factor":2.5}}') - - result = runner.invoke( - flow360, - ["draft", "run", PROJECT_ID, "--patch", str(patch_path), "--up-to", "volume-mesh"], - ) - - assert result.exit_code == 0 - payload = _load_json_output(result.output) - assert payload["draft"]["id"] == DRAFT_ID - assert payload["result"]["id"] == VOLUME_MESH_ID - calls = recorded_webapi_calls[-6:] - assert calls[0] == { - "type": "get", - "url": f"/v2/projects/{PROJECT_ID}", - "params": None, - } - assert calls[1] == { - "type": "get", - "url": f"/v2/geometries/{GEOMETRY_ID}", - "params": None, - } - assert calls[2] == { - "type": "get", - "url": f"/v2/geometries/{GEOMETRY_ID}/simulation/file", - "params": {"type": "simulation"}, - } - assert calls[3] == { - "type": "post", - "url": "/v2/drafts", - "params": { - **calls[3]["params"], - }, - } - assert calls[3]["params"]["projectId"] == PROJECT_ID - assert calls[3]["params"]["sourceItemId"] == GEOMETRY_ID - assert calls[3]["params"]["sourceItemType"] == "Geometry" - assert calls[3]["params"]["solverVersion"] == "release-24.11" - assert calls[3]["params"]["forkCase"] is False - assert isinstance(calls[3]["params"]["name"], str) - assert calls[3]["params"]["name"] - assert calls[4]["type"] == "post" - assert calls[4]["url"] == f"/v2/drafts/{DRAFT_ID}/simulation/file" - assert calls[4]["params"]["type"] == "simulation" - assert calls[4]["params"]["version"] == "" - assert calls[5] == { - "type": "post", - "url": f"/v2/drafts/{DRAFT_ID}/run", - "params": { - "upTo": "VolumeMesh", - "useInHouse": False, - "useGai": False, - }, - } - - merged_payload = json.loads(calls[4]["params"]["data"]) - assert merged_payload["meshing"]["refinement_factor"] == 2.5 - assert "defaults" in merged_payload["meshing"] - - -def test_project_rename_uses_project_patch_endpoint(recorded_webapi_calls): - runner = CliRunner() - - result = runner.invoke(flow360, ["project", "rename", PROJECT_ID, "--name", "Renamed Project"]) - - assert result.exit_code == 0 - assert f"Renamed project {PROJECT_ID} to Renamed Project." in result.output - assert recorded_webapi_calls[-1]["type"] == "patch" - assert recorded_webapi_calls[-1]["url"] == f"/v2/projects/{PROJECT_ID}" - assert recorded_webapi_calls[-1]["params"]["name"] == "Renamed Project" - - -def test_case_rename_uses_case_patch_endpoint(recorded_webapi_calls): - runner = CliRunner() - - result = runner.invoke(flow360, ["case", "rename", CASE_ID, "--name", "Alpha -18"]) - - assert result.exit_code == 0 - payload = _load_json_output(result.output) - assert payload == {"id": CASE_ID, "name": "Alpha -18"} - assert recorded_webapi_calls[-1] == { - "type": "patch", - "url": f"/v2/cases/{CASE_ID}", - "params": {"name": "Alpha -18"}, - } - - -def test_geometry_rename_uses_geometry_patch_endpoint(recorded_webapi_calls): - runner = CliRunner() - - result = runner.invoke( - flow360, ["geometry", "rename", GEOMETRY_ID, "--name", "Renamed Geometry"] - ) - - assert result.exit_code == 0 - payload = _load_json_output(result.output) - assert payload == {"id": GEOMETRY_ID, "name": "Renamed Geometry"} - assert recorded_webapi_calls[-1] == { - "type": "patch", - "url": f"/v2/geometries/{GEOMETRY_ID}", - "params": {"name": "Renamed Geometry"}, - } - - -def test_surface_mesh_rename_uses_surface_mesh_patch_endpoint(recorded_webapi_calls): - runner = CliRunner() - - result = runner.invoke( - flow360, - ["surface-mesh", "rename", SURFACE_MESH_ID, "--name", "Renamed Surface Mesh"], - ) - - assert result.exit_code == 0 - payload = _load_json_output(result.output) - assert payload == {"id": SURFACE_MESH_ID, "name": "Renamed Surface Mesh"} - assert recorded_webapi_calls[-1] == { - "type": "patch", - "url": f"/v2/surface-meshes/{SURFACE_MESH_ID}", - "params": {"name": "Renamed Surface Mesh"}, - } - - -def test_volume_mesh_rename_uses_volume_mesh_patch_endpoint(recorded_webapi_calls): - runner = CliRunner() - - result = runner.invoke( - flow360, - ["volume-mesh", "rename", VOLUME_MESH_ID, "--name", "Renamed Volume Mesh"], - ) - - assert result.exit_code == 0 - payload = _load_json_output(result.output) - assert payload == {"id": VOLUME_MESH_ID, "name": "Renamed Volume Mesh"} - assert recorded_webapi_calls[-1] == { - "type": "patch", - "url": f"/v2/volume-meshes/{VOLUME_MESH_ID}", - "params": {"name": "Renamed Volume Mesh"}, - } - - -def test_draft_rename_uses_draft_patch_endpoint(recorded_webapi_calls): - runner = CliRunner() - - result = runner.invoke(flow360, ["draft", "rename", DRAFT_ID, "--name", "Renamed Draft"]) - - assert result.exit_code == 0 - payload = _load_json_output(result.output) - assert payload == {"id": DRAFT_ID, "name": "Renamed Draft"} - assert recorded_webapi_calls[-1] == { - "type": "patch", - "url": f"/v2/drafts/{DRAFT_ID}", - "params": {"name": "Renamed Draft"}, - } - - -def test_project_delete_uses_project_delete_endpoint(recorded_webapi_calls): - runner = CliRunner() - - result = runner.invoke(flow360, ["project", "delete", PROJECT_ID, "--yes"]) - - assert result.exit_code == 0 - assert f"Deleted project {PROJECT_ID}." in result.output - assert recorded_webapi_calls[-1] == { - "type": "delete", - "url": f"/v2/projects/{PROJECT_ID}", - "params": None, - } - - def test_folder_get_uses_folder_info_endpoint(recorded_webapi_calls): runner = CliRunner() @@ -1003,77 +386,3 @@ def test_folder_tree_uses_folder_list_endpoint(recorded_webapi_calls): "size": 1000, }, } - - -def test_folder_create_uses_folder_create_endpoint(recorded_webapi_calls): - runner = CliRunner() - - result = runner.invoke( - flow360, - [ - "folder", - "create", - "--name", - "Folder A", - "--parent-folder-id", - "ROOT.FLOW360", - "--tag", - "demo", - ], - ) - - assert result.exit_code == 0 - payload = _load_json_output(result.output) - assert payload["id"] == "folder-3834758b-3d39-4a4a-ad85-710b7652267c" - assert recorded_webapi_calls[-1] == { - "type": "post", - "url": "/folders", - "params": { - "name": "Folder A", - "tags": ["demo"], - "parentFolderId": "ROOT.FLOW360", - "type": "folder", - }, - } - - -def test_folder_rename_uses_folder_patch_endpoint(recorded_webapi_calls): - runner = CliRunner() - - result = runner.invoke(flow360, ["folder", "rename", FOLDER_ID, "--name", "Renamed Folder"]) - - assert result.exit_code == 0 - payload = _load_json_output(result.output) - assert payload == {"id": FOLDER_ID, "name": "Renamed Folder"} - assert recorded_webapi_calls[-1] == { - "type": "patch", - "url": f"/v2/folders/{FOLDER_ID}", - "params": {"name": "Renamed Folder"}, - } - - -def test_folder_move_uses_folder_patch_endpoint(recorded_webapi_calls): - runner = CliRunner() - - result = runner.invoke( - flow360, - [ - "folder", - "move", - FOLDER_ID, - "--parent-folder-id", - "folder-4da3cdd0-c5b6-4130-9ca1-196237322ab9", - ], - ) - - assert result.exit_code == 0 - payload = _load_json_output(result.output) - assert payload == { - "id": FOLDER_ID, - "parent_id": "folder-4da3cdd0-c5b6-4130-9ca1-196237322ab9", - } - assert recorded_webapi_calls[-1] == { - "type": "patch", - "url": f"/v2/folders/{FOLDER_ID}", - "params": {"parentFolderId": "folder-4da3cdd0-c5b6-4130-9ca1-196237322ab9"}, - } diff --git a/tests/data/mock_webapi/case_files_mock_response.json b/tests/data/mock_webapi/case_files_mock_response.json index 7e3a0f82b..9886ffbb9 100644 --- a/tests/data/mock_webapi/case_files_mock_response.json +++ b/tests/data/mock_webapi/case_files_mock_response.json @@ -2,7 +2,7 @@ "data": [ { "fileName": "results/monitor_massFluxExhaust_v2.csv", - "filePath": "users/AIDAXWCOWJGJINZTEEEIP/case-69b8c249-fce5-412a-9927-6a79049deebb/results/monitor_massFluxExhaust_v2.csv", + "filePath": "results/monitor_massFluxExhaust_v2.csv", "fileType": ".csv", "length": 2479, "storageClass": null, @@ -10,7 +10,7 @@ }, { "fileName": "results/monitor_massFluxIntake_v2.csv", - "filePath": "users/AIDAXWCOWJGJINZTEEEIP/case-69b8c249-fce5-412a-9927-6a79049deebb/results/monitor_massFluxIntake_v2.csv", + "filePath": "results/monitor_massFluxIntake_v2.csv", "fileType": ".csv", "length": 2479, "storageClass": null, @@ -18,7 +18,7 @@ }, { "fileName": "results/udd_massInflowController_Exhaust_v2.csv", - "filePath": "users/AIDAXWCOWJGJINZTEEEIP/case-69b8c249-fce5-412a-9927-6a79049deebb/results/udd_massInflowController_Exhaust_v2.csv", + "filePath": "results/udd_massInflowController_Exhaust_v2.csv", "fileType": ".csv", "length": 2479, "storageClass": null, @@ -26,7 +26,7 @@ }, { "fileName": "results/udd_massInflowController_Intake_v2.csv", - "filePath": "users/AIDAXWCOWJGJINZTEEEIP/case-69b8c249-fce5-412a-9927-6a79049deebb/results/udd_massInflowController_Intake_v2.csv", + "filePath": "results/udd_massInflowController_Intake_v2.csv", "fileType": ".csv", "length": 2479, "storageClass": null, @@ -34,7 +34,7 @@ }, { "fileName": "results/force_output_wing_all_planes_forces_v2.csv", - "filePath": "users/AIDAXWCOWJGJINZTEEEIP/case-69b8c249-fce5-412a-9927-6a79049deebb/results/force_output_wing_all_planes_forces_v2.csv", + "filePath": "results/force_output_wing_all_planes_forces_v2.csv", "fileType": ".csv", "length": 1524, "storageClass": null, @@ -42,7 +42,7 @@ }, { "fileName": "results/force_output_wing_all_planes_forces_moving_statistic_v2.csv", - "filePath": "users/AIDAXWCOWJGJINZTEEEIP/case-69b8c249-fce5-412a-9927-6a79049deebb/results/force_output_wing_all_planes_forces_moving_statistic_v2.csv", + "filePath": "results/force_output_wing_all_planes_forces_moving_statistic_v2.csv", "fileType": ".csv", "length": 1648, "storageClass": null, @@ -50,7 +50,7 @@ }, { "fileName": "results/force_distro_cumul_forceDistribution.csv", - "filePath": "users/AIDAXWCOWJGJINZTEEEIP/case-69b8c249-fce5-412a-9927-6a79049deebb/results/force_distro_cumul_forceDistribution.csv", + "filePath": "results/force_distro_cumul_forceDistribution.csv", "fileType": ".csv", "length": 50000, "storageClass": null, @@ -58,7 +58,7 @@ }, { "fileName": "results/ta_distro_forceDistribution.csv", - "filePath": "users/AIDAXWCOWJGJINZTEEEIP/case-69b8c249-fce5-412a-9927-6a79049deebb/results/ta_distro_forceDistribution.csv", + "filePath": "results/ta_distro_forceDistribution.csv", "fileType": ".csv", "length": 50000, "storageClass": null, @@ -66,7 +66,7 @@ }, { "fileName": "simulation.json", - "filePath": "users/AIDAXWCOWJGJINZTEEEIP/case-69b8c249-fce5-412a-9927-6a79049deebb/simulation.json", + "filePath": "simulation.json", "fileType": ".json", "length": 36806, "storageClass": null, diff --git a/tests/mock_server.py b/tests/mock_server.py index 4114f80e1..60d2a9c19 100644 --- a/tests/mock_server.py +++ b/tests/mock_server.py @@ -216,6 +216,25 @@ def json(): return res +class MockResponseFolderListV2(MockResponse): + """response for GET /v2/folders""" + + @staticmethod + def json(): + with open(os.path.join(here, "data/mock_webapi/folder_at_root_meta_resp.json")) as fh: + root_folder = json.load(fh)["data"] + with open(os.path.join(here, "data/mock_webapi/folder_nested_meta_resp.json")) as fh: + nested_folder = json.load(fh)["data"] + return { + "data": { + "page": 0, + "size": 1000, + "total": 2, + "records": [root_folder, nested_folder], + } + } + + class MockResponseFolderMove(MockResponse): """response if moving to folder""" @@ -454,12 +473,12 @@ def json(): with open( os.path.join(here, "data/mock_webapi/project_case_fork_simulation_json_resp.json") ) as fh: - simulation_json = json.load(fh) - return {"data": {"simulationJson": json.dumps(simulation_json)}} + res = json.load(fh) + return res class MockResponseDraftInfo(MockResponse): - """response for Draft.from_cloud(id="dft-84b20880-937d-4ef2-983b-7f75089f6dd6")'s meta json""" + """response for GET /v2/drafts/dft-84b20880-937d-4ef2-983b-7f75089f6dd6""" @staticmethod def json(): @@ -501,7 +520,7 @@ def json(self): class MockResponseDraftSimulation(MockResponse): - """response for Draft(id="dft-84b20880-937d-4ef2-983b-7f75089f6dd6").simulation""" + """response for GET /v2/drafts/{id}/simulation/file""" @staticmethod def json(): @@ -512,45 +531,6 @@ def json(): return {"data": {"simulationJson": json.dumps(simulation_json)}} -class MockResponseFolderInfoAtRootV2(MockResponse): - """response for GET /v2/folders/folder-3834758b-3d39-4a4a-ad85-710b7652267c""" - - @staticmethod - def json(): - with open(os.path.join(here, "data/mock_webapi/folder_at_root_meta_resp.json")) as fh: - res = json.load(fh) - return res - - -class MockResponseFolderInfoNestedV2(MockResponse): - """response for GET /v2/folders/folder-4da3cdd0-c5b6-4130-9ca1-196237322ab9""" - - @staticmethod - def json(): - with open(os.path.join(here, "data/mock_webapi/folder_nested_meta_resp.json")) as fh: - res = json.load(fh) - return res - - -class MockResponseFolderListV2(MockResponse): - """response for GET /v2/folders""" - - @staticmethod - def json(): - with open(os.path.join(here, "data/mock_webapi/folder_at_root_meta_resp.json")) as fh: - root_folder = json.load(fh)["data"] - with open(os.path.join(here, "data/mock_webapi/folder_nested_meta_resp.json")) as fh: - nested_folder = json.load(fh)["data"] - return { - "data": { - "page": 0, - "size": 1000, - "total": 2, - "records": [root_folder, nested_folder], - } - } - - class MockResponseProjectRunCase(MockResponse): """response for project.run_case(params = params)'s meta json""" @@ -584,25 +564,26 @@ def json(self): class MockResponseDraftSubmit(MockResponse): - """response for POST /v2/drafts""" + """response for Project(id="prj-41d2333b-85fd-4bed-ae13-15dcb6da519e")'s path to Fork Case json""" def __init__(self, *args, params=None, **kwargs) -> None: super().__init__(*args, **kwargs) - self._params = params or {} + self._params = params def json(self): - return { - "data": { - "id": "dft-84b20880-937d-4ef2-983b-7f75089f6dd6", - "name": self._params.get("name", "Draft 1"), - "projectId": self._params.get("projectId"), - "solverVersion": self._params.get("solverVersion"), - "sourceItemId": self._params.get("sourceItemId"), - "sourceItemType": self._params.get("sourceItemType"), - "forkCase": self._params.get("forkCase"), - "type": "Draft", - } - } + res = None + if self._params["name"] == "VolumeMesh": + with open( + os.path.join(here, "data/mock_webapi/project_draft_volume_mesh_submit_resp.json") + ) as fh: + res = json.load(fh) + + if self._params["name"] == "Case": + with open( + os.path.join(here, "data/mock_webapi/project_draft_case_fork_submit_resp.json") + ) as fh: + res = json.load(fh) + return res class MockResponseDraftVolumeMeshRun(MockResponse): @@ -617,24 +598,6 @@ def json(): return res -class MockResponseDraftRunV2(MockResponse): - """response for POST /v2/drafts/{id}/run""" - - def __init__(self, *args, params=None, **kwargs) -> None: - super().__init__(*args, **kwargs) - self._params = params or {} - - def json(self): - up_to = self._params.get("upTo") - if up_to == "SurfaceMesh": - return MockResponseProjectSurfaceMesh.json() - if up_to == "VolumeMesh": - return MockResponseProjectVolumeMesh.json() - if up_to == "Case": - return MockResponseProjectCase.json() - return MockResponseInfoNotFound.json() - - class MockResponseProjectPatchDraftSubmit(MockResponse): def __init__(self, *args, params=None, **kwargs) -> None: @@ -644,26 +607,11 @@ def __init__(self, *args, params=None, **kwargs) -> None: def json(self): with open(os.path.join(here, "data/mock_webapi/project_meta_resp.json")) as fh: res = json.load(fh) - if "lastOpenItemId" in self._params: - res["data"]["lastOpenItemId"] = self._params["lastOpenItemId"] - if "lastOpenItemType" in self._params: - res["data"]["lastOpenItemType"] = self._params["lastOpenItemType"] - if "name" in self._params: - res["data"]["name"] = self._params["name"] + res["data"]["lastOpenItemId"] = self._params["lastOpenItemId"] + res["data"]["lastOpenItemType"] = self._params["lastOpenItemType"] return res -class MockResponseFolderPatchV2(MockResponse): - """response for PATCH /v2/folders/{id}""" - - def __init__(self, *args, params=None, **kwargs) -> None: - super().__init__(*args, **kwargs) - self._params = params or {} - - def json(self): - return {"data": dict(self._params)} - - class MockResponseReportSubmit(MockResponse): """response for report_template.create_in_cloud's meta json""" @@ -701,6 +649,8 @@ def json(): "/account": MockResponseOrganizationAccounts, "/folders/items/folder-3834758b-3d39-4a4a-ad85-710b7652267c/metadata": MockResponseFolderRootMetadata, "/folders/items/folder-4da3cdd0-c5b6-4130-9ca1-196237322ab9/metadata": MockResponseFolderNestedMetadata, + "/v2/folders/folder-3834758b-3d39-4a4a-ad85-710b7652267c": MockResponseFolderRootMetadata, + "/v2/folders/folder-4da3cdd0-c5b6-4130-9ca1-196237322ab9": MockResponseFolderNestedMetadata, "/v2/projects/prj-41d2333b-85fd-4bed-ae13-15dcb6da519e": MockResponseProject, "/v2/projects/prj-41d2333b-85fd-4bed-ae13-15dcb6da519e/dependency": MockResponseProjectEmptyDependency, "/v2/projects/prj-99cc6f96-15d3-4170-973c-a0cced6bf36b": MockResponseProjectFromVM, @@ -724,11 +674,8 @@ def json(): "/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/folders/folder-3834758b-3d39-4a4a-ad85-710b7652267c": MockResponseFolderInfoAtRootV2, - "/v2/folders/folder-4da3cdd0-c5b6-4130-9ca1-196237322ab9": MockResponseFolderInfoNestedV2, "/v2/projects": MockResponseAllProjects, "/cases/case-666666666-66666666-666-6666666666666/files": MockResponseCaseFiles, - "/cases/case-69b8c249-fce5-412a-9927-6a79049deebb/files": MockResponseCaseFiles, } PUT_RESPONSE_MAP = { @@ -739,7 +686,6 @@ def json(): "/volumemeshes/00112233-4455-6677-8899-aabbccddeeff/case": MockResponseCaseSubmit, "/volumemeshes/00000000-0000-0000-0000-000000000000/case": MockResponseCaseSubmit, "/folders": MockResponseFolderSubmit, - "/v2/drafts/dft-84b20880-937d-4ef2-983b-7f75089f6dd6/simulation/file": MockResponse, "/v2/drafts/vm-7c3681cd-8c6c-4db7-a62c-1742d825e9d3/simulation/file": MockResponseProjectVolumeMeshSimConfig, "/v2/drafts/vm-7c3681cd-8c6c-4db7-a62c-1742d825e9d3/run": MockResponseProjectVolumeMesh, "/v2/drafts/case-84d4604e-f3cd-4c6b-8517-92a80a3346d3/simulation/file": MockResponseProjectCaseForkSimConfig, @@ -777,12 +723,12 @@ def mock_webapi(type, url, params): if method.endswith("/path"): return MockResponseProjectPath(params=params) - if method == "/v2/drafts": - return MockResponseDraftList(params=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) @@ -797,34 +743,12 @@ def mock_webapi(type, url, params): if method == "/v2/drafts": return MockResponseDraftSubmit(params=params) - if method.startswith("/v2/drafts/") and method.endswith("/simulation/file"): - return MockResponse() - - if method.startswith("/v2/drafts/") and method.endswith("/run"): - return MockResponseDraftRunV2(params=params) - if method in POST_RESPONSE_MAP.keys(): return POST_RESPONSE_MAP[method]() elif type == "patch": if method.startswith("/v2/projects"): return MockResponseProjectPatchDraftSubmit(params=params) - if method.startswith("/v2/geometries/"): - return MockResponse() - if method.startswith("/v2/surface-meshes/"): - return MockResponse() - if method.startswith("/v2/volume-meshes/"): - return MockResponse() - if method.startswith("/v2/cases/"): - return MockResponse() - if method.startswith("/v2/drafts/"): - return MockResponse() - if method.startswith("/v2/folders/"): - return MockResponseFolderPatchV2(params=params) - - elif type == "delete": - if method.startswith("/v2/projects/"): - return MockResponse() return MockResponseInfoNotFound() @@ -858,9 +782,6 @@ def put(self, url, json=None, **kwargs): def post(self, url, json=None, **kwargs): return get_response(url, type="post", params=json, **kwargs) - def delete(self, url, **kwargs): - return get_response(url, type="delete", **kwargs) - monkeypatch.setattr( http_util, "api_key_auth", lambda: {"Authorization": None, "Application": "FLOW360"} ) diff --git a/tests/simulation/test_project_create.py b/tests/simulation/test_project_create.py deleted file mode 100644 index 4cfd70e84..000000000 --- a/tests/simulation/test_project_create.py +++ /dev/null @@ -1,26 +0,0 @@ -import flow360.component.project as project_module - -from flow360.component.project import Project - - -def test_project_from_geometry_passes_description_to_draft_submit(monkeypatch, tmp_path): - geometry_file = tmp_path / "wing.csm" - geometry_file.write_text("solid") - calls = {} - - class FakeDraft: - def submit(self, description="", run_async=False): - calls["description"] = description - calls["run_async"] = run_async - return type("FakeRootAsset", (), {"project_id": "prj-123"})() - - monkeypatch.setattr(project_module.Geometry, "from_file", lambda *args, **kwargs: FakeDraft()) - - project_id = Project.from_geometry( - str(geometry_file), - description="demo project", - run_async=True, - ) - - assert project_id == "prj-123" - assert calls == {"description": "demo project", "run_async": True} diff --git a/tests/simulation/test_project_lazy.py b/tests/simulation/test_project_lazy.py deleted file mode 100644 index 35939a56f..000000000 --- a/tests/simulation/test_project_lazy.py +++ /dev/null @@ -1,86 +0,0 @@ -from flow360.component.project import Project, ProjectMeta, RootType - - -def test_from_cloud_lazy_does_not_fetch_until_requested(monkeypatch): - calls = [] - project_id = "prj-12345678-1234-1234-1234-123456789abc" - geometry_id = "geo-12345678-1234-1234-1234-123456789abc" - - def fake_get(self, path=None, method=None, json=None, params=None): - calls.append(method) - if method == "tree": - return { - "records": [ - { - "createdAt": "2025-01-01T00:00:00Z", - "displayStatus": "completed", - "id": geometry_id, - "name": "Wing", - "parentCaseId": None, - "parentFolderId": "ROOT.FLOW360", - "parentId": None, - "parentItemName": None, - "parentItemProjectId": None, - "parentItemTye": None, - "postProcessStatus": None, - "postProcessedAt": None, - "projectId": project_id, - "requestStopAt": None, - "runSequence": "run-1", - "solverFinishAt": None, - "solverStartAt": None, - "solverVersion": "release-25.2", - "status": "processed", - "tags": None, - "type": "Geometry", - "updatedAt": "2025-01-01T00:00:01Z", - "userId": "user-1", - "viewed": False, - "visualizationStatus": None, - "visualizedAt": None, - } - ] - } - return { - "userId": "user-1", - "id": project_id, - "name": "Wing Study", - "tags": ["demo"], - "rootItemId": geometry_id, - "rootItemType": "Geometry", - } - - monkeypatch.setattr("flow360.component.project.RestApi.get", fake_get) - - project = Project.from_cloud(project_id, lazy_load=True) - - assert calls == [] - tree = project.get_project_tree() - assert tree.root.asset_id == geometry_id - assert calls == ["tree"] - - -def test_lazy_project_metadata_fetches_only_metadata(monkeypatch): - calls = [] - project_id = "prj-12345678-1234-1234-1234-123456789abc" - geometry_id = "geo-12345678-1234-1234-1234-123456789abc" - - def fake_get(self, path=None, method=None, json=None, params=None): - calls.append(method) - return { - "userId": "user-1", - "id": project_id, - "name": "Wing Study", - "tags": ["demo"], - "rootItemId": geometry_id, - "rootItemType": "Geometry", - } - - monkeypatch.setattr("flow360.component.project.RestApi.get", fake_get) - - project = Project.from_cloud(project_id, lazy_load=True) - meta = project.get_metadata() - - assert isinstance(meta, ProjectMeta) - assert meta.root_item_type is RootType.GEOMETRY - assert calls == [None] diff --git a/tests/test_lazy_imports.py b/tests/test_lazy_imports.py index af9d84755..2745c6ab4 100644 --- a/tests/test_lazy_imports.py +++ b/tests/test_lazy_imports.py @@ -103,7 +103,9 @@ def test_asset_group_help_does_not_import_simulation_summary(monkeypatch): "flow360.component.simulation.simulation_params", ) - from flow360.cli import flow360 # pylint: disable=import-outside-toplevel,import-error + from flow360.cli import ( + flow360, # pylint: disable=import-outside-toplevel,import-error + ) result = CliRunner().invoke(flow360, ["case", "--help"]) diff --git a/tests/cli/test_remote_resource_logs.py b/tests/v1/test_remote_resource_logs.py similarity index 100% rename from tests/cli/test_remote_resource_logs.py rename to tests/v1/test_remote_resource_logs.py diff --git a/tools/cli_live_benchmark.py b/tools/cli_live_benchmark.py deleted file mode 100644 index 598ed608c..000000000 --- a/tools/cli_live_benchmark.py +++ /dev/null @@ -1,393 +0,0 @@ -"""Live Flow360 CLI benchmark and review helper. - -Runs the local CLI as a fresh subprocess for each command, measures wall time, -and emits a markdown report to stdout. -""" - -from __future__ import annotations - -import argparse -import json -import os -import subprocess -import sys -import time -from dataclasses import dataclass -from datetime import date -from pathlib import Path - - -CLI_BOOTSTRAP = "from flow360.cli import flow360; flow360()" -REPO_ROOT = Path(__file__).resolve().parents[1] - - -@dataclass -class CommandResult: - args: list[str] - duration_s: float - exit_code: int - stdout: str - stderr: str - - @property - def command(self) -> str: - return "flow360 " + " ".join(self.args) - - -def run_cli(args: list[str]) -> CommandResult: - command = [sys.executable, "-c", CLI_BOOTSTRAP, *args] - started = time.perf_counter() - completed = subprocess.run( - command, - cwd=REPO_ROOT, - capture_output=True, - text=True, - check=False, - ) - duration_s = time.perf_counter() - started - return CommandResult( - args=args, - duration_s=duration_s, - exit_code=completed.returncode, - stdout=completed.stdout, - stderr=completed.stderr, - ) - - -def run_python(code: str) -> CommandResult: - command = [sys.executable, "-c", code] - started = time.perf_counter() - completed = subprocess.run( - command, - cwd=REPO_ROOT, - capture_output=True, - text=True, - check=False, - ) - duration_s = time.perf_counter() - started - return CommandResult( - args=["python", "-c", code], - duration_s=duration_s, - exit_code=completed.returncode, - stdout=completed.stdout, - stderr=completed.stderr, - ) - - -def run_pytest(args: list[str]) -> CommandResult: - command = [sys.executable, "-m", "pytest", *args] - started = time.perf_counter() - completed = subprocess.run( - command, - cwd=REPO_ROOT, - capture_output=True, - text=True, - check=False, - ) - duration_s = time.perf_counter() - started - return CommandResult( - args=["pytest", *args], - duration_s=duration_s, - exit_code=completed.returncode, - stdout=completed.stdout, - stderr=completed.stderr, - ) - - -def parse_json_output(result: CommandResult): - return json.loads(result.stdout) - - -def choose_project(project_records: list[dict]) -> tuple[dict, CommandResult, list[dict]]: - for record in project_records[:25]: - items_result = run_cli(["project", "items", record["id"]]) - if items_result.exit_code != 0: - continue - items = parse_json_output(items_result)["items"] - types = {item["type"] for item in items} - if {"Geometry", "SurfaceMesh", "VolumeMesh", "Case"}.issubset(types): - return record, items_result, items - - for record in project_records[:25]: - items_result = run_cli(["project", "items", record["id"]]) - if items_result.exit_code != 0: - continue - items = parse_json_output(items_result)["items"] - if items: - return record, items_result, items - - raise RuntimeError("Could not find a benchmarkable project from project list output.") - - -def pick_item(items: list[dict], item_type: str) -> dict | None: - for item in items: - if item["type"] == item_type: - return item - return None - - -def find_first_folder_node(tree: dict | None) -> dict | None: - if not tree: - return None - for child in tree.get("subfolders", []): - return child - return None - - -def render_bool(value: bool) -> str: - return "yes" if value else "no" - - -def format_seconds(value: float) -> str: - return f"{value:.3f}s" - - -def md_escape(value: str) -> str: - return value.replace("|", "\\|") - - -def build_report() -> str: - sections: list[str] = [] - - import_result = run_python( - "import time; s=time.perf_counter(); import flow360.cli.app; print(time.perf_counter()-s)" - ) - lazy_check = run_python( - "import sys; import flow360.cli.app; " - "mods=('flow360.cli.project','flow360.cli.assets','flow360.cli.draft','flow360.cli.folder','flow360.cloud.flow360_requests'); " - "print({m:(m in sys.modules) for m in mods})" - ) - - project_list = run_cli(["project", "list"]) - if project_list.exit_code != 0: - raise RuntimeError(f"project list failed:\n{project_list.stderr or project_list.stdout}") - - project_records = parse_json_output(project_list)["records"] - selected_project, selected_items_result, selected_items = choose_project(project_records) - - geometry = pick_item(selected_items, "Geometry") - surface_mesh = pick_item(selected_items, "SurfaceMesh") - volume_mesh = pick_item(selected_items, "VolumeMesh") - case = pick_item(selected_items, "Case") - path_target = case or volume_mesh or surface_mesh or geometry - if path_target is None: - raise RuntimeError("Selected project has no items.") - - project_get = run_cli(["project", "get", selected_project["id"]]) - project_tree = run_cli(["project", "tree", selected_project["id"]]) - project_path = run_cli( - [ - "project", - "path", - selected_project["id"], - "--item-id", - path_target["id"], - "--item-type", - path_target["type"], - ] - ) - - help_results = [ - run_cli(["--help"]), - run_cli(["project", "--help"]), - run_cli(["draft", "--help"]), - run_cli(["folder", "--help"]), - run_cli(["geometry", "--help"]), - run_cli(["surface-mesh", "--help"]), - run_cli(["volume-mesh", "--help"]), - run_cli(["case", "--help"]), - ] - - folder_tree = run_cli(["folder", "tree"]) - folder_get = None - folder_node = None - if folder_tree.exit_code == 0: - folder_root = parse_json_output(folder_tree)["root"] - folder_node = find_first_folder_node(folder_root) - if folder_node is not None: - folder_get = run_cli(["folder", "get", folder_node["id"]]) - - asset_results: list[CommandResult] = [] - geometry_simulation_get = None - if geometry: - asset_results.append(run_cli(["geometry", "info", geometry["id"]])) - geometry_simulation_get = run_cli(["geometry", "simulation", "get", geometry["id"]]) - surface_mesh_simulation_get = None - volume_mesh_simulation_get = None - if surface_mesh: - asset_results.append(run_cli(["surface-mesh", "info", surface_mesh["id"]])) - surface_mesh_simulation_get = run_cli(["surface-mesh", "simulation", "get", surface_mesh["id"]]) - if volume_mesh: - asset_results.append(run_cli(["volume-mesh", "info", volume_mesh["id"]])) - volume_mesh_simulation_get = run_cli(["volume-mesh", "simulation", "get", volume_mesh["id"]]) - case_simulation_get = None - if case: - asset_results.append(run_cli(["case", "info", case["id"]])) - case_simulation_get = run_cli(["case", "simulation", "get", case["id"]]) - - draft_ls = run_cli(["draft", "ls", "--project-id", selected_project["id"]]) - draft_info = None - draft_simulation_get = None - draft_record = None - if draft_ls.exit_code == 0: - draft_records = parse_json_output(draft_ls).get("records", []) - if draft_records: - draft_record = draft_records[0] - draft_info = run_cli(["draft", "info", draft_record["id"]]) - draft_simulation_get = run_cli(["draft", "simulation", "get", draft_record["id"]]) - - pytest_result = run_pytest( - [ - "tests/test_lazy_imports.py", - "tests/cli/test_cli_project.py", - "tests/cli/test_cli_folder.py", - "tests/cli/test_cli_assets.py", - "tests/cli/test_cli_draft.py", - "tests/cli/test_cli_webapi_integration.py", - "tests/test_cli_login.py", - "tests/simulation/test_project_create.py", - "-q", - ] - ) - - sections.append(f"# Flow360 CLI Live Review\n") - sections.append(f"Date: {date.today().isoformat()}\n") - sections.append("## Scope\n") - sections.append( - "This report measures the current local CLI implementation in `Flow360/` using fresh subprocesses " - "for each command, so each timing includes Python startup, Click dispatch, local imports, and live network calls.\n" - ) - sections.append( - "No mutation commands were executed. `project create`, `project rename`, `project delete`, " - "`folder create`, `folder rename`, and `folder move` were intentionally skipped.\n" - ) - sections.append("The benchmark used the currently configured credentials with no explicit `--dev`, `--uat`, `--env`, or `--profile` overrides.\n") - - sections.append("## Selected Live Resources\n") - sections.append(f"- project: `{selected_project['id']}` `{selected_project['name']}`\n") - sections.append(f"- geometry present: {render_bool(geometry is not None)}\n") - sections.append(f"- surface mesh present: {render_bool(surface_mesh is not None)}\n") - sections.append(f"- volume mesh present: {render_bool(volume_mesh is not None)}\n") - sections.append(f"- case present: {render_bool(case is not None)}\n") - sections.append(f"- draft present: {render_bool(draft_record is not None)}\n") - if draft_record is not None: - sections.append(f"- draft: `{draft_record['id']}` `{draft_record['name']}`\n") - sections.append(f"- folder present: {render_bool(folder_node is not None)}\n") - if folder_node is not None: - sections.append(f"- folder: `{folder_node['id']}` `{folder_node['name']}`\n") - - sections.append("\n## Import Diagnostics\n") - sections.append("| Check | Exit | Time | Result |\n") - sections.append("| --- | ---: | ---: | --- |\n") - sections.append( - f"| `import flow360.cli.app` | {import_result.exit_code} | {format_seconds(import_result.duration_s)} | `{md_escape(import_result.stdout.strip())}` |\n" - ) - sections.append( - f"| lazy module check | {lazy_check.exit_code} | {format_seconds(lazy_check.duration_s)} | `{md_escape(lazy_check.stdout.strip())}` |\n" - ) - - sections.append("\n## End-to-End CLI Timings\n") - sections.append("| Command | Exit | Time |\n") - sections.append("| --- | ---: | ---: |\n") - for result in help_results: - sections.append( - f"| `{md_escape(result.command)}` | {result.exit_code} | {format_seconds(result.duration_s)} |\n" - ) - for result in ( - project_list, - project_get, - project_tree, - selected_items_result, - project_path, - folder_tree, - ): - sections.append( - f"| `{md_escape(result.command)}` | {result.exit_code} | {format_seconds(result.duration_s)} |\n" - ) - if folder_get is not None: - sections.append( - f"| `{md_escape(folder_get.command)}` | {folder_get.exit_code} | {format_seconds(folder_get.duration_s)} |\n" - ) - for result in asset_results: - sections.append( - f"| `{md_escape(result.command)}` | {result.exit_code} | {format_seconds(result.duration_s)} |\n" - ) - if geometry_simulation_get is not None: - sections.append( - f"| `{md_escape(geometry_simulation_get.command)}` | {geometry_simulation_get.exit_code} | {format_seconds(geometry_simulation_get.duration_s)} |\n" - ) - if surface_mesh_simulation_get is not None: - sections.append( - f"| `{md_escape(surface_mesh_simulation_get.command)}` | {surface_mesh_simulation_get.exit_code} | {format_seconds(surface_mesh_simulation_get.duration_s)} |\n" - ) - if volume_mesh_simulation_get is not None: - sections.append( - f"| `{md_escape(volume_mesh_simulation_get.command)}` | {volume_mesh_simulation_get.exit_code} | {format_seconds(volume_mesh_simulation_get.duration_s)} |\n" - ) - if case_simulation_get is not None: - sections.append( - f"| `{md_escape(case_simulation_get.command)}` | {case_simulation_get.exit_code} | {format_seconds(case_simulation_get.duration_s)} |\n" - ) - sections.append( - f"| `{md_escape(draft_ls.command)}` | {draft_ls.exit_code} | {format_seconds(draft_ls.duration_s)} |\n" - ) - if draft_info is not None: - sections.append( - f"| `{md_escape(draft_info.command)}` | {draft_info.exit_code} | {format_seconds(draft_info.duration_s)} |\n" - ) - if draft_simulation_get is not None: - sections.append( - f"| `{md_escape(draft_simulation_get.command)}` | {draft_simulation_get.exit_code} | {format_seconds(draft_simulation_get.duration_s)} |\n" - ) - - sections.append("\n## Verification\n") - sections.append("| Command | Exit | Time | Result |\n") - sections.append("| --- | ---: | ---: | --- |\n") - sections.append( - f"| `{md_escape('python -m ' + ' '.join(pytest_result.args))}` | {pytest_result.exit_code} | {format_seconds(pytest_result.duration_s)} | `{md_escape(pytest_result.stdout.strip().splitlines()[-1] if pytest_result.stdout.strip() else '')}` |\n" - ) - - sections.append("\n## Review Findings\n") - sections.append( - "1. Root startup is in the right shape. The root import path stays thin, and root help remains around the low hundreds of milliseconds from a fresh subprocess.\n" - ) - sections.append( - "2. The bounded `project list` default changed the performance profile materially. It is no longer the clear outlier because the CLI now asks the API for 25 projects by default instead of a much larger page.\n" - ) - sections.append( - "3. The read-only surface still clusters near the network floor. `project get/tree/items/path`, asset gets, and draft reads are all dominated by backend latency rather than local import or serialization overhead.\n" - ) - sections.append( - "4. The new folder commands fit the current architecture well. They reuse a thin shared web API wrapper and do not add noticeable startup cost to the root CLI path.\n" - ) - sections.append( - "5. The largest remaining unknown is write-path cold-start cost, especially for `project create`, because that command now intentionally goes through the richer SDK upload flow rather than a CLI-specific transport shim.\n" - ) - - sections.append("\n## Recommended Next Steps\n") - sections.append("1. Benchmark the new `project create` path separately and record its cold-start, upload-start, and async-return timings.\n") - sections.append("2. Benchmark `draft run` separately and record its cold-start and time-to-first-request behavior.\n") - sections.append("3. Keep `show_projects` unchanged until deprecation, but do not extend it or use it as the base for new behavior.\n") - sections.append("4. Extend project/draft-oriented workflow options only if needed, for example `draft run --start-from ...`, while keeping case branching out of the new CLI surface.\n") - - return "".join(sections) - - -def main() -> int: - parser = argparse.ArgumentParser() - parser.add_argument("--output", default=None, help="Write markdown report to this path.") - args = parser.parse_args() - - report = build_report() - - if args.output: - output_path = Path(args.output) - output_path.write_text(report, encoding="utf-8") - else: - sys.stdout.write(report) - - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) From 7d32b6861ace7fd8beebdad96c48697b85334f63 Mon Sep 17 00:00:00 2001 From: Maciej Skarysz Date: Fri, 1 May 2026 18:11:19 +0200 Subject: [PATCH 08/15] cli: keep help paths lightweight --- flow360/cli/app.py | 2 +- tests/test_lazy_imports.py | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/flow360/cli/app.py b/flow360/cli/app.py index 115617602..942ad7281 100644 --- a/flow360/cli/app.py +++ b/flow360/cli/app.py @@ -82,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/tests/test_lazy_imports.py b/tests/test_lazy_imports.py index 2745c6ab4..f7c9e3005 100644 --- a/tests/test_lazy_imports.py +++ b/tests/test_lazy_imports.py @@ -100,7 +100,10 @@ def test_asset_group_help_does_not_import_simulation_summary(monkeypatch): "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 ( @@ -112,7 +115,10 @@ def test_asset_group_help_does_not_import_simulation_summary(monkeypatch): 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): From ab7ff50f8f45668820712993570a0ab6bad37f7e Mon Sep 17 00:00:00 2001 From: Maciej Skarysz Date: Mon, 4 May 2026 12:26:06 +0200 Subject: [PATCH 09/15] cli: address lint after main rebase --- flow360/cli/auth.py | 2 +- flow360/cli/simulation_summary.py | 8 ++++++-- flow360/component/simulation/web/asset_webapi.py | 16 +++++++++++++++- flow360/component/simulation/web/draft_webapi.py | 5 +++++ .../component/simulation/web/workspace_webapi.py | 2 ++ flow360/user_config.py | 2 +- 6 files changed, 30 insertions(+), 5 deletions(-) diff --git a/flow360/cli/auth.py b/flow360/cli/auth.py index 8e7b21024..d7f79bc17 100644 --- a/flow360/cli/auth.py +++ b/flow360/cli/auth.py @@ -61,7 +61,7 @@ def resolve_target_environment( return target, storage_environment -def build_login_url( +def build_login_url( # pylint: disable=too-many-arguments environment, callback_url: str, state: str, diff --git a/flow360/cli/simulation_summary.py b/flow360/cli/simulation_summary.py index ea00dc584..28b152a4c 100644 --- a/flow360/cli/simulation_summary.py +++ b/flow360/cli/simulation_summary.py @@ -34,8 +34,12 @@ def _load_summary_dicts(simulation_json: dict) -> tuple[dict, dict, dict | None] previous_disable_level = logging.root.manager.disable logging.disable(logging.WARNING) try: - params_dict = SimulationParams._sanitize_params_dict(copy.deepcopy(simulation_json)) - params_dict, _ = SimulationParams._update_param_dict(params_dict) + 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) diff --git a/flow360/component/simulation/web/asset_webapi.py b/flow360/component/simulation/web/asset_webapi.py index ba5ff2a41..26da0f7fb 100644 --- a/flow360/component/simulation/web/asset_webapi.py +++ b/flow360/component/simulation/web/asset_webapi.py @@ -24,14 +24,17 @@ def __init__(self, interface, asset_id: str): @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 asset metadata.""" return self._unwrap_data(self._api.get()) def get_simulation_json(self): + """Fetch the asset simulation JSON payload.""" response = self._unwrap_data( self._api.get(method="simulation/file", params={"type": "simulation"}) ) @@ -41,25 +44,36 @@ def get_simulation_json(self): return json.loads(response) return response - def get(self, path=None, method=None, json=None, params=None): + 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(AssetWebApi): + """Thin geometry web API wrapper.""" + def __init__(self, asset_id: str): super().__init__(GeometryInterface, asset_id) class SurfaceMeshWebApi(AssetWebApi): + """Thin surface mesh web API wrapper.""" + def __init__(self, asset_id: str): super().__init__(SurfaceMeshInterfaceV2, asset_id) class VolumeMeshWebApi(AssetWebApi): + """Thin volume mesh web API wrapper.""" + def __init__(self, asset_id: str): super().__init__(VolumeMeshInterfaceV2, asset_id) class CaseWebApi(AssetWebApi): + """Thin case web API wrapper.""" + def __init__(self, asset_id: str): super().__init__(CaseInterfaceV2, asset_id) diff --git a/flow360/component/simulation/web/draft_webapi.py b/flow360/component/simulation/web/draft_webapi.py index 71637d132..f84ac193b 100644 --- a/flow360/component/simulation/web/draft_webapi.py +++ b/flow360/component/simulation/web/draft_webapi.py @@ -17,24 +17,29 @@ def __init__(self, draft_id: str): @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 draft metadata.""" return self._unwrap_data(self._api.get()) @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", []) def get_simulation_json(self): + """Fetch the draft simulation JSON payload.""" response = self._api.get(method="simulation/file", params={"type": "simulation"}) if isinstance(response, dict) and "simulationJson" in response: return response["simulationJson"] return response def get(self, path=None, method=None, json=None, params=None): + """Delegate specialized GET calls to the underlying REST API.""" return self._api.get(path=path, method=method, json=json, params=params) diff --git a/flow360/component/simulation/web/workspace_webapi.py b/flow360/component/simulation/web/workspace_webapi.py index 1dc8b7422..1546eb542 100644 --- a/flow360/component/simulation/web/workspace_webapi.py +++ b/flow360/component/simulation/web/workspace_webapi.py @@ -11,6 +11,7 @@ class WorkspaceWebApi: @classmethod def list_records(cls): + """List available workspace records.""" api = RestApi(WorkspaceInterface.endpoint) response = api.get() if isinstance(response, list): @@ -19,6 +20,7 @@ def list_records(cls): @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") diff --git a/flow360/user_config.py b/flow360/user_config.py index 50785f86b..381da9815 100644 --- a/flow360/user_config.py +++ b/flow360/user_config.py @@ -257,7 +257,7 @@ def reload_user_config(): global UserConfig # pylint: disable=global-statement - if isinstance(UserConfig, BasicUserConfig): + if isinstance(UserConfig, BasicUserConfig): # pylint: disable=used-before-assignment BasicUserConfig.__init__(UserConfig) else: UserConfig = BasicUserConfig() From 038ee16997f49ca46dd81c85d201d673bab77b9e Mon Sep 17 00:00:00 2001 From: Maciej Skarysz Date: Mon, 4 May 2026 12:44:07 +0200 Subject: [PATCH 10/15] config: preserve runtime flags on reload --- flow360/user_config.py | 5 +++++ tests/test_cli_login.py | 17 +++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/flow360/user_config.py b/flow360/user_config.py index 381da9815..28c61f86f 100644 --- a/flow360/user_config.py +++ b/flow360/user_config.py @@ -254,11 +254,16 @@ def enable_validation(self): 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 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) From d2761413cd0a1fa97a5af227eca02b978651acfc Mon Sep 17 00:00:00 2001 From: Maciej Skarysz Date: Mon, 4 May 2026 13:26:02 +0200 Subject: [PATCH 11/15] cli: share resource web api wrapper --- .../component/simulation/web/asset_webapi.py | 36 ++------------- .../component/simulation/web/draft_webapi.py | 27 ++--------- .../simulation/web/resource_webapi.py | 45 +++++++++++++++++++ 3 files changed, 51 insertions(+), 57 deletions(-) create mode 100644 flow360/component/simulation/web/resource_webapi.py diff --git a/flow360/component/simulation/web/asset_webapi.py b/flow360/component/simulation/web/asset_webapi.py index 26da0f7fb..cd7849ab2 100644 --- a/flow360/component/simulation/web/asset_webapi.py +++ b/flow360/component/simulation/web/asset_webapi.py @@ -4,51 +4,21 @@ from __future__ import annotations -import json - -from flow360.cloud.rest_api import RestApi from flow360.component.interfaces import ( CaseInterfaceV2, GeometryInterface, SurfaceMeshInterfaceV2, VolumeMeshInterfaceV2, ) +from flow360.component.simulation.web.resource_webapi import ResourceWebApi -class AssetWebApi: +class AssetWebApi(ResourceWebApi): """Thin wrapper around a single asset endpoint.""" def __init__(self, interface, asset_id: str): self.asset_id = asset_id - self._api = RestApi(interface.endpoint, id=asset_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 asset metadata.""" - return self._unwrap_data(self._api.get()) - - def get_simulation_json(self): - """Fetch the asset 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) + super().__init__(interface, asset_id) class GeometryWebApi(AssetWebApi): diff --git a/flow360/component/simulation/web/draft_webapi.py b/flow360/component/simulation/web/draft_webapi.py index f84ac193b..206a11367 100644 --- a/flow360/component/simulation/web/draft_webapi.py +++ b/flow360/component/simulation/web/draft_webapi.py @@ -6,25 +6,15 @@ from flow360.cloud.rest_api import RestApi from flow360.component.interfaces import DraftInterface +from flow360.component.simulation.web.resource_webapi import ResourceWebApi -class DraftWebApi: +class DraftWebApi(ResourceWebApi): """Thin wrapper around draft endpoints.""" def __init__(self, draft_id: str): self.draft_id = draft_id - self._api = RestApi(DraftInterface.endpoint, id=draft_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 draft metadata.""" - return self._unwrap_data(self._api.get()) + super().__init__(DraftInterface, draft_id) @classmethod def list_records(cls, project_id: str): @@ -32,14 +22,3 @@ def list_records(cls, project_id: str): api = RestApi(DraftInterface.endpoint) response = api.get(params={"projectId": project_id}) return response.get("records", []) - - def get_simulation_json(self): - """Fetch the draft simulation JSON payload.""" - response = self._api.get(method="simulation/file", params={"type": "simulation"}) - if isinstance(response, dict) and "simulationJson" in response: - return response["simulationJson"] - return response - - def get(self, path=None, method=None, json=None, params=None): - """Delegate specialized GET calls to the underlying REST API.""" - return self._api.get(path=path, method=method, json=json, params=params) diff --git a/flow360/component/simulation/web/resource_webapi.py b/flow360/component/simulation/web/resource_webapi.py new file mode 100644 index 000000000..ebbf62133 --- /dev/null +++ b/flow360/component/simulation/web/resource_webapi.py @@ -0,0 +1,45 @@ +""" +Shared thin resource web API wrapper. +""" + +from __future__ import annotations + +import json + +from flow360.cloud.rest_api import RestApi + + +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) From fcf8a85ca0d34e44b49cfcd267a869944d96502a Mon Sep 17 00:00:00 2001 From: Maciej Skarysz Date: Mon, 4 May 2026 14:19:14 +0200 Subject: [PATCH 12/15] cli: keep resource webapi wrappers together --- flow360/cli/browser_links.py | 2 +- flow360/cli/draft.py | 6 +- flow360/cli/resource_state.py | 2 +- .../component/simulation/web/asset_webapi.py | 67 ++++++++++++++++--- .../component/simulation/web/draft_webapi.py | 24 ------- .../simulation/web/resource_webapi.py | 45 ------------- 6 files changed, 61 insertions(+), 85 deletions(-) delete mode 100644 flow360/component/simulation/web/draft_webapi.py delete mode 100644 flow360/component/simulation/web/resource_webapi.py diff --git a/flow360/cli/browser_links.py b/flow360/cli/browser_links.py index 99bfd2992..97a295123 100644 --- a/flow360/cli/browser_links.py +++ b/flow360/cli/browser_links.py @@ -17,11 +17,11 @@ def _get_project_scoped_resource_info(resource_type: str, resource_id: str) -> d # pylint: disable=import-outside-toplevel from flow360.component.simulation.web.asset_webapi import ( CaseWebApi, + DraftWebApi, GeometryWebApi, SurfaceMeshWebApi, VolumeMeshWebApi, ) - from flow360.component.simulation.web.draft_webapi import DraftWebApi webapi_by_type = { "Geometry": GeometryWebApi, diff --git a/flow360/cli/draft.py b/flow360/cli/draft.py index 42931f8dc..90489528a 100644 --- a/flow360/cli/draft.py +++ b/flow360/cli/draft.py @@ -22,21 +22,21 @@ def _require_typed_id(resource_id, expected_type): def _get_draft_info(draft_id): # pylint: disable=import-outside-toplevel - from flow360.component.simulation.web.draft_webapi import DraftWebApi + 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.draft_webapi import DraftWebApi + 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.draft_webapi import DraftWebApi + from flow360.component.simulation.web.asset_webapi import DraftWebApi simulation_json = DraftWebApi(draft_id).get_simulation_json() if isinstance(simulation_json, str): diff --git a/flow360/cli/resource_state.py b/flow360/cli/resource_state.py index 4c3bfebc3..66a88fab2 100644 --- a/flow360/cli/resource_state.py +++ b/flow360/cli/resource_state.py @@ -40,11 +40,11 @@ def get_resource_state_for_type(resource_type, resource_id): # pylint: disable=import-outside-toplevel from flow360.component.simulation.web.asset_webapi import ( CaseWebApi, + DraftWebApi, GeometryWebApi, SurfaceMeshWebApi, VolumeMeshWebApi, ) - from flow360.component.simulation.web.draft_webapi import DraftWebApi webapi_by_type = { "Draft": DraftWebApi, diff --git a/flow360/component/simulation/web/asset_webapi.py b/flow360/component/simulation/web/asset_webapi.py index cd7849ab2..26ff47db6 100644 --- a/flow360/component/simulation/web/asset_webapi.py +++ b/flow360/component/simulation/web/asset_webapi.py @@ -1,49 +1,94 @@ """ -Thin asset web API wrappers. +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, ) -from flow360.component.simulation.web.resource_webapi import ResourceWebApi -class AssetWebApi(ResourceWebApi): - """Thin wrapper around a single asset endpoint.""" +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 __init__(self, interface, asset_id: str): - self.asset_id = asset_id - super().__init__(interface, asset_id) + 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(AssetWebApi): +class GeometryWebApi(ResourceWebApi): """Thin geometry web API wrapper.""" def __init__(self, asset_id: str): super().__init__(GeometryInterface, asset_id) -class SurfaceMeshWebApi(AssetWebApi): +class SurfaceMeshWebApi(ResourceWebApi): """Thin surface mesh web API wrapper.""" def __init__(self, asset_id: str): super().__init__(SurfaceMeshInterfaceV2, asset_id) -class VolumeMeshWebApi(AssetWebApi): +class VolumeMeshWebApi(ResourceWebApi): """Thin volume mesh web API wrapper.""" def __init__(self, asset_id: str): super().__init__(VolumeMeshInterfaceV2, asset_id) -class CaseWebApi(AssetWebApi): +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/draft_webapi.py b/flow360/component/simulation/web/draft_webapi.py deleted file mode 100644 index 206a11367..000000000 --- a/flow360/component/simulation/web/draft_webapi.py +++ /dev/null @@ -1,24 +0,0 @@ -""" -Thin draft web API wrapper. -""" - -from __future__ import annotations - -from flow360.cloud.rest_api import RestApi -from flow360.component.interfaces import DraftInterface -from flow360.component.simulation.web.resource_webapi import ResourceWebApi - - -class DraftWebApi(ResourceWebApi): - """Thin wrapper around draft endpoints.""" - - def __init__(self, draft_id: str): - self.draft_id = draft_id - 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/resource_webapi.py b/flow360/component/simulation/web/resource_webapi.py deleted file mode 100644 index ebbf62133..000000000 --- a/flow360/component/simulation/web/resource_webapi.py +++ /dev/null @@ -1,45 +0,0 @@ -""" -Shared thin resource web API wrapper. -""" - -from __future__ import annotations - -import json - -from flow360.cloud.rest_api import RestApi - - -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) From c0ff6c29488d550b78bff30afd6f0c8fb4abedec Mon Sep 17 00:00:00 2001 From: Maciej Skarysz Date: Mon, 4 May 2026 14:43:52 +0200 Subject: [PATCH 13/15] cli: keep project tree lightweight --- flow360/cli/project.py | 53 ++++++++++++++++++--------- tests/cli/test_cli_project.py | 69 +++++++++++++++++++++++++++-------- 2 files changed, 90 insertions(+), 32 deletions(-) diff --git a/flow360/cli/project.py b/flow360/cli/project.py index aebd96c62..7e5c679e9 100644 --- a/flow360/cli/project.py +++ b/flow360/cli/project.py @@ -31,13 +31,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,13 +92,39 @@ def _serialize_project_statistics(statistics): } -def _serialize_tree_node(node): - return { - "id": node.asset_id, - "name": node.asset_name, - "type": node.asset_type, - "children": [_serialize_tree_node(child) for child in node.children], - } +def _project_tree_parent_id(item): + return item.get("parentCaseId") or item.get("parentId") + + +def _project_tree_from_records(records): + nodes = {} + root_ids = [] + for item in records: + node_id = item["id"] + if node_id in nodes: + raise click.ClickException(f"Project tree response contains duplicate item: {node_id}") + nodes[node_id] = { + "id": node_id, + "name": item["name"], + "type": item["type"], + "children": [], + } + + for item in records: + node_id = item["id"] + parent_id = _project_tree_parent_id(item) + if parent_id is None: + root_ids.append(node_id) + continue + if parent_id not in nodes: + raise click.ClickException( + f"Project tree response references missing parent {parent_id} for {node_id}" + ) + nodes[parent_id]["children"].append(nodes[node_id]) + + if len(root_ids) != 1: + raise click.ClickException(f"Project tree response contains {len(root_ids)} root items") + return nodes[root_ids[0]] def _project_items_from_records(records): @@ -113,7 +133,7 @@ def _project_items_from_records(records): "id": item["id"], "name": item["name"], "type": item["type"], - "parent_id": item.get("parentCaseId") or item.get("parentId"), + "parent_id": _project_tree_parent_id(item), } for item in records ] @@ -243,8 +263,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/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): From cf730efcee95eafd54e148099d31e033fb6e94e5 Mon Sep 17 00:00:00 2001 From: Maciej Skarysz Date: Mon, 4 May 2026 14:50:54 +0200 Subject: [PATCH 14/15] cli: share project tree assembly --- flow360/cli/project.py | 59 +++++++----------- flow360/component/project.py | 29 +++++---- .../component/simulation/web/project_tree.py | 52 ++++++++++++++++ tests/simulation/test_project_tree.py | 62 +++++++++++++++++++ 4 files changed, 154 insertions(+), 48 deletions(-) create mode 100644 flow360/component/simulation/web/project_tree.py create mode 100644 tests/simulation/test_project_tree.py diff --git a/flow360/cli/project.py b/flow360/cli/project.py index 7e5c679e9..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): @@ -92,51 +96,36 @@ def _serialize_project_statistics(statistics): } -def _project_tree_parent_id(item): - return item.get("parentCaseId") or item.get("parentId") - - def _project_tree_from_records(records): - nodes = {} - root_ids = [] - for item in records: - node_id = item["id"] - if node_id in nodes: - raise click.ClickException(f"Project tree response contains duplicate item: {node_id}") - nodes[node_id] = { - "id": node_id, + def create_node(item): + return { + "id": item["id"], "name": item["name"], "type": item["type"], "children": [], } - for item in records: - node_id = item["id"] - parent_id = _project_tree_parent_id(item) - if parent_id is None: - root_ids.append(node_id) - continue - if parent_id not in nodes: - raise click.ClickException( - f"Project tree response references missing parent {parent_id} for {node_id}" - ) - nodes[parent_id]["children"].append(nodes[node_id]) + def add_child(parent, child): + parent["children"].append(child) - if len(root_ids) != 1: - raise click.ClickException(f"Project tree response contains {len(root_ids)} root items") - return nodes[root_ids[0]] + 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": 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": _project_tree_parent_id(item), - } - for item in records - ] + return [_project_item_from_record(item) for item in records] def _serialize_project_item(item): diff --git a/flow360/component/project.py b/flow360/component/project.py index 217776b7d..0ac742338 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, ) @@ -503,11 +507,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 +558,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/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/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}, + ] + ) From 62f51597be01c3f5c0e938ecac28adb0d699b4bd Mon Sep 17 00:00:00 2001 From: Maciej Skarysz Date: Mon, 4 May 2026 15:46:48 +0200 Subject: [PATCH 15/15] cli: satisfy project tree lint --- flow360/component/project.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/flow360/component/project.py b/flow360/component/project.py index 0ac742338..872ff4141 100644 --- a/flow360/component/project.py +++ b/flow360/component/project.py @@ -494,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"]