Support file paths in ifcedit project.append_asset

This now works:

  ifcedit run model.ifc project.append_asset --library library.ifc --element 5711

Generated with the assistance of an AI coding tool.
This commit is contained in:
Bruno Postle
2026-02-28 07:18:28 +00:00
parent c112c115ce
commit f4d7fda255
6 changed files with 120 additions and 12 deletions
+26 -11
View File
@@ -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]:
+5
View File
@@ -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)
+36 -1
View File
@@ -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}"}
+21
View File
@@ -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)
+15
View File
@@ -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"
+17
View File
@@ -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