diff --git a/src/ifcedit/ifcedit/coerce.py b/src/ifcedit/ifcedit/coerce.py index 0e92bded6d..4a48925293 100644 --- a/src/ifcedit/ifcedit/coerce.py +++ b/src/ifcedit/ifcedit/coerce.py @@ -25,13 +25,21 @@ import typing import ifcopenshell -def coerce_value(value_str: str, type_hint, model: ifcopenshell.file | None = None): +def coerce_value( + value_str: str, + type_hint, + model: ifcopenshell.file | None = None, + lookup_file: ifcopenshell.file | None = None, +): """Convert a CLI string argument to the proper Python type based on a type hint. Args: value_str: The raw string from the CLI. type_hint: The type annotation from the function signature. - model: An open IFC model, needed to resolve entity instance references by ID. + model: The main open IFC model, needed to resolve entity instance references by ID. + lookup_file: Override file for entity resolution (e.g. a library file for + project.append_asset). When provided, entity IDs are looked up here instead + of in model. Returns: The converted Python value. @@ -40,6 +48,9 @@ def coerce_value(value_str: str, type_hint, model: ifcopenshell.file | None = No ValueError: If the value cannot be converted. TypeError: If the type hint is not supported. """ + # When a library file has been opened, entity IDs are resolved from it, not the main model. + effective_lookup = lookup_file if lookup_file is not None else model + if type_hint is None: return value_str @@ -55,7 +66,7 @@ def coerce_value(value_str: str, type_hint, model: ifcopenshell.file | None = No # Try each non-None type in order for t in non_none_types: try: - return coerce_value(value_str, t, model) + return coerce_value(value_str, t, model, lookup_file) except (ValueError, TypeError): continue raise ValueError(f"Cannot convert '{value_str}' to any of {non_none_types}") @@ -73,10 +84,10 @@ def coerce_value(value_str: str, type_hint, model: ifcopenshell.file | None = No # list types if origin is list: if args and _is_entity_type(args[0]): - return _coerce_entity_list(value_str, model) + return _coerce_entity_list(value_str, effective_lookup) if args: items = _split_list(value_str) - return [coerce_value(item.strip(), args[0], model) for item in items] + return [coerce_value(item.strip(), args[0], model, lookup_file) for item in items] return _split_list(value_str) # dict types @@ -93,9 +104,13 @@ def coerce_value(value_str: str, type_hint, model: ifcopenshell.file | None = No if type_hint is bool: return value_str.lower() in ("true", "1", "yes") + # ifcopenshell.file — open from path string + if type_hint is ifcopenshell.file: + return ifcopenshell.open(value_str) + # entity_instance if _is_entity_type(type_hint): - return _coerce_entity(value_str, model) + return _coerce_entity(value_str, effective_lookup) # Fallback: try json.loads for complex types, then plain string try: @@ -113,24 +128,24 @@ def _is_entity_type(hint) -> bool: return False -def _coerce_entity(value_str: str | int, model: ifcopenshell.file | None) -> ifcopenshell.entity_instance: +def _coerce_entity(value_str: str | int, lookup_file: ifcopenshell.file | None) -> ifcopenshell.entity_instance: """Resolve a step ID string like '123' or '#123' to an entity instance.""" - if model is None: + if lookup_file is None: raise ValueError("Cannot resolve entity reference without an IFC model") if isinstance(value_str, int): entity_id = value_str else: entity_id = int(value_str.strip().lstrip("#")) try: - return model.by_id(entity_id) + return lookup_file.by_id(entity_id) except RuntimeError: raise ValueError(f"Entity #{entity_id} not found in model") -def _coerce_entity_list(value_str: str, model: ifcopenshell.file | None) -> list[ifcopenshell.entity_instance]: +def _coerce_entity_list(value_str: str, lookup_file: ifcopenshell.file | None) -> list[ifcopenshell.entity_instance]: """Resolve a comma-separated list of step IDs to entity instances.""" items = _split_list(value_str) - return [_coerce_entity(item.strip(), model) for item in items] + return [_coerce_entity(item.strip(), lookup_file) for item in items] def _split_list(value_str: str) -> list[str]: diff --git a/src/ifcedit/ifcedit/discover.py b/src/ifcedit/ifcedit/discover.py index 3059b3b363..3828cf989b 100644 --- a/src/ifcedit/ifcedit/discover.py +++ b/src/ifcedit/ifcedit/discover.py @@ -165,10 +165,15 @@ def _extract_params(fn) -> list[dict]: def _format_type_hint(hint) -> str | None: """Format a type hint to a readable string.""" + import ifcopenshell + if hint is None: return None if hint is type(None): return "None" + # ifcopenshell.file params are passed as a file path string + if hint is ifcopenshell.file: + return "file_path" origin = typing.get_origin(hint) args = typing.get_args(hint) diff --git a/src/ifcedit/ifcedit/run.py b/src/ifcedit/ifcedit/run.py index c361715c06..91d8be6774 100644 --- a/src/ifcedit/ifcedit/run.py +++ b/src/ifcedit/ifcedit/run.py @@ -28,6 +28,17 @@ import ifcopenshell from ifcedit.coerce import coerce_value +def _is_file_type(hint) -> bool: + """Check if a type hint refers to ifcopenshell.file (or Optional[ifcopenshell.file]).""" + if hint is ifcopenshell.file: + return True + origin = typing.get_origin(hint) + args = typing.get_args(hint) + if origin is typing.Union and ifcopenshell.file in args: + return True + return False + + def run_api( model: ifcopenshell.file, module: str, @@ -58,12 +69,36 @@ def run_api( sig = inspect.signature(fn) coerced_kwargs = {} + + # Pass 1: coerce ifcopenshell.file-typed params first (e.g. library= in append_asset). + # The opened file is then used as the lookup file for entity resolution in pass 2. + opened_files: list[ifcopenshell.file] = [] for name, value_str in raw_kwargs.items(): if name not in sig.parameters: return {"ok": False, "error": f"Unknown parameter '{name}' for {module}.{function}"} hint = hints.get(name) + if not _is_file_type(hint): + continue try: - coerced_kwargs[name] = coerce_value(value_str, hint, model) + coerced = coerce_value(value_str, hint, model) + coerced_kwargs[name] = coerced + if isinstance(coerced, ifcopenshell.file): + opened_files.append(coerced) + except (ValueError, TypeError) as e: + return {"ok": False, "error": f"Cannot convert parameter '{name}': {e}"} + + # Pass 2: coerce remaining params. Entity instance IDs are resolved from the opened + # library file (if any), since you are always appending from another file, never + # from the current model. + lookup_file = opened_files[0] if opened_files else None + for name, value_str in raw_kwargs.items(): + if name in coerced_kwargs: + continue + if name not in sig.parameters: + return {"ok": False, "error": f"Unknown parameter '{name}' for {module}.{function}"} + hint = hints.get(name) + try: + coerced_kwargs[name] = coerce_value(value_str, hint, model, lookup_file=lookup_file) except (ValueError, TypeError) as e: return {"ok": False, "error": f"Cannot convert parameter '{name}': {e}"} diff --git a/src/ifcedit/tests/conftest.py b/src/ifcedit/tests/conftest.py index 941bb90426..017578a343 100644 --- a/src/ifcedit/tests/conftest.py +++ b/src/ifcedit/tests/conftest.py @@ -8,6 +8,7 @@ import ifcopenshell.api.root import ifcopenshell.api.spatial import ifcopenshell.api.unit import pytest +import ifcopenshell.api.material @pytest.fixture @@ -40,3 +41,23 @@ def model_file(model, tmp_path): path = tmp_path / "test.ifc" model.write(str(path)) return str(path) + + +@pytest.fixture +def library(): + """Create an IFC4 library with a single IfcWallType asset.""" + lib = ifcopenshell.api.project.create_file() + ifcopenshell.api.owner.settings.get_user = lambda ifc: (ifc.by_type("IfcPersonAndOrganization") or [None])[0] + ifcopenshell.api.owner.settings.get_application = lambda ifc: (ifc.by_type("IfcApplication") or [None])[0] + ifcopenshell.api.root.create_entity(lib, ifc_class="IfcProject", name="TestLibrary") + ifcopenshell.api.unit.assign_unit(lib) + ifcopenshell.api.root.create_entity(lib, ifc_class="IfcWallType", name="WAL01") + return lib + + +@pytest.fixture +def library_file(library, tmp_path): + """Write the library fixture to a temp file and return the path.""" + path = tmp_path / "library.ifc" + library.write(str(path)) + return str(path) diff --git a/src/ifcedit/tests/test_coerce.py b/src/ifcedit/tests/test_coerce.py index 6028d910cb..620ab139b7 100644 --- a/src/ifcedit/tests/test_coerce.py +++ b/src/ifcedit/tests/test_coerce.py @@ -3,6 +3,7 @@ import json from typing import Literal, Optional, Union import ifcopenshell +import ifcopenshell.api.project import pytest from ifcedit.coerce import coerce_value @@ -125,6 +126,20 @@ class TestEntityCoercion: coerce_value("42", ifcopenshell.entity_instance, None) +class TestFileCoercion: + def test_opens_file_from_path(self, model_file): + result = coerce_value(model_file, ifcopenshell.file) + assert isinstance(result, ifcopenshell.file) + + def test_entity_from_lookup_file(self, model_file): + lib = ifcopenshell.open(model_file) + wall = lib.by_type("IfcWall")[0] + empty_model = ifcopenshell.api.project.create_file() + result = coerce_value(str(wall.id()), ifcopenshell.entity_instance, empty_model, lookup_file=lib) + assert result.id() == wall.id() + assert result.is_a("IfcWall") + + class TestFallback: def test_no_type_hint(self): assert coerce_value("hello", None) == "hello" diff --git a/src/ifcedit/tests/test_run.py b/src/ifcedit/tests/test_run.py index f2d940ee98..620614ff08 100644 --- a/src/ifcedit/tests/test_run.py +++ b/src/ifcedit/tests/test_run.py @@ -1,5 +1,7 @@ # This file was generated with the assistance of an AI coding tool. +import ifcopenshell import ifcopenshell.api.pset +import ifcopenshell.api.project import ifcopenshell.api.root from ifcedit.run import run_api, serialize_result @@ -52,6 +54,21 @@ class TestRunApi: assert "not found" in result["error"] +class TestAppendAsset: + def test_append_asset_from_library(self, model, library_file): + lib = ifcopenshell.open(library_file) + wall_type = lib.by_type("IfcWallType")[0] + result = run_api( + model, + "project", + "append_asset", + {"library": library_file, "element": str(wall_type.id())}, + ) + assert result["ok"] is True + assert result["result"]["type"] == "IfcWallType" + assert model.by_type("IfcWallType"), "wall type should have been appended to the model" + + class TestSerializeResult: def test_none(self): assert serialize_result(None) is None