mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-09-22 16:21:01 +00:00
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:
@@ -25,13 +25,21 @@ import typing
|
|||||||
import ifcopenshell
|
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.
|
"""Convert a CLI string argument to the proper Python type based on a type hint.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
value_str: The raw string from the CLI.
|
value_str: The raw string from the CLI.
|
||||||
type_hint: The type annotation from the function signature.
|
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:
|
Returns:
|
||||||
The converted Python value.
|
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.
|
ValueError: If the value cannot be converted.
|
||||||
TypeError: If the type hint is not supported.
|
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:
|
if type_hint is None:
|
||||||
return value_str
|
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
|
# Try each non-None type in order
|
||||||
for t in non_none_types:
|
for t in non_none_types:
|
||||||
try:
|
try:
|
||||||
return coerce_value(value_str, t, model)
|
return coerce_value(value_str, t, model, lookup_file)
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
continue
|
continue
|
||||||
raise ValueError(f"Cannot convert '{value_str}' to any of {non_none_types}")
|
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
|
# list types
|
||||||
if origin is list:
|
if origin is list:
|
||||||
if args and _is_entity_type(args[0]):
|
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:
|
if args:
|
||||||
items = _split_list(value_str)
|
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)
|
return _split_list(value_str)
|
||||||
|
|
||||||
# dict types
|
# dict types
|
||||||
@@ -93,9 +104,13 @@ def coerce_value(value_str: str, type_hint, model: ifcopenshell.file | None = No
|
|||||||
if type_hint is bool:
|
if type_hint is bool:
|
||||||
return value_str.lower() in ("true", "1", "yes")
|
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
|
# entity_instance
|
||||||
if _is_entity_type(type_hint):
|
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
|
# Fallback: try json.loads for complex types, then plain string
|
||||||
try:
|
try:
|
||||||
@@ -113,24 +128,24 @@ def _is_entity_type(hint) -> bool:
|
|||||||
return False
|
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."""
|
"""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")
|
raise ValueError("Cannot resolve entity reference without an IFC model")
|
||||||
if isinstance(value_str, int):
|
if isinstance(value_str, int):
|
||||||
entity_id = value_str
|
entity_id = value_str
|
||||||
else:
|
else:
|
||||||
entity_id = int(value_str.strip().lstrip("#"))
|
entity_id = int(value_str.strip().lstrip("#"))
|
||||||
try:
|
try:
|
||||||
return model.by_id(entity_id)
|
return lookup_file.by_id(entity_id)
|
||||||
except RuntimeError:
|
except RuntimeError:
|
||||||
raise ValueError(f"Entity #{entity_id} not found in model")
|
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."""
|
"""Resolve a comma-separated list of step IDs to entity instances."""
|
||||||
items = _split_list(value_str)
|
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]:
|
def _split_list(value_str: str) -> list[str]:
|
||||||
|
|||||||
@@ -165,10 +165,15 @@ def _extract_params(fn) -> list[dict]:
|
|||||||
|
|
||||||
def _format_type_hint(hint) -> str | None:
|
def _format_type_hint(hint) -> str | None:
|
||||||
"""Format a type hint to a readable string."""
|
"""Format a type hint to a readable string."""
|
||||||
|
import ifcopenshell
|
||||||
|
|
||||||
if hint is None:
|
if hint is None:
|
||||||
return None
|
return None
|
||||||
if hint is type(None):
|
if hint is type(None):
|
||||||
return "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)
|
origin = typing.get_origin(hint)
|
||||||
args = typing.get_args(hint)
|
args = typing.get_args(hint)
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,17 @@ import ifcopenshell
|
|||||||
from ifcedit.coerce import coerce_value
|
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(
|
def run_api(
|
||||||
model: ifcopenshell.file,
|
model: ifcopenshell.file,
|
||||||
module: str,
|
module: str,
|
||||||
@@ -58,12 +69,36 @@ def run_api(
|
|||||||
|
|
||||||
sig = inspect.signature(fn)
|
sig = inspect.signature(fn)
|
||||||
coerced_kwargs = {}
|
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():
|
for name, value_str in raw_kwargs.items():
|
||||||
if name not in sig.parameters:
|
if name not in sig.parameters:
|
||||||
return {"ok": False, "error": f"Unknown parameter '{name}' for {module}.{function}"}
|
return {"ok": False, "error": f"Unknown parameter '{name}' for {module}.{function}"}
|
||||||
hint = hints.get(name)
|
hint = hints.get(name)
|
||||||
|
if not _is_file_type(hint):
|
||||||
|
continue
|
||||||
try:
|
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:
|
except (ValueError, TypeError) as e:
|
||||||
return {"ok": False, "error": f"Cannot convert parameter '{name}': {e}"}
|
return {"ok": False, "error": f"Cannot convert parameter '{name}': {e}"}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import ifcopenshell.api.root
|
|||||||
import ifcopenshell.api.spatial
|
import ifcopenshell.api.spatial
|
||||||
import ifcopenshell.api.unit
|
import ifcopenshell.api.unit
|
||||||
import pytest
|
import pytest
|
||||||
|
import ifcopenshell.api.material
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@@ -40,3 +41,23 @@ def model_file(model, tmp_path):
|
|||||||
path = tmp_path / "test.ifc"
|
path = tmp_path / "test.ifc"
|
||||||
model.write(str(path))
|
model.write(str(path))
|
||||||
return 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)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import json
|
|||||||
from typing import Literal, Optional, Union
|
from typing import Literal, Optional, Union
|
||||||
|
|
||||||
import ifcopenshell
|
import ifcopenshell
|
||||||
|
import ifcopenshell.api.project
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from ifcedit.coerce import coerce_value
|
from ifcedit.coerce import coerce_value
|
||||||
@@ -125,6 +126,20 @@ class TestEntityCoercion:
|
|||||||
coerce_value("42", ifcopenshell.entity_instance, None)
|
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:
|
class TestFallback:
|
||||||
def test_no_type_hint(self):
|
def test_no_type_hint(self):
|
||||||
assert coerce_value("hello", None) == "hello"
|
assert coerce_value("hello", None) == "hello"
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
# This file was generated with the assistance of an AI coding tool.
|
# This file was generated with the assistance of an AI coding tool.
|
||||||
|
import ifcopenshell
|
||||||
import ifcopenshell.api.pset
|
import ifcopenshell.api.pset
|
||||||
|
import ifcopenshell.api.project
|
||||||
import ifcopenshell.api.root
|
import ifcopenshell.api.root
|
||||||
|
|
||||||
from ifcedit.run import run_api, serialize_result
|
from ifcedit.run import run_api, serialize_result
|
||||||
@@ -52,6 +54,21 @@ class TestRunApi:
|
|||||||
assert "not found" in result["error"]
|
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:
|
class TestSerializeResult:
|
||||||
def test_none(self):
|
def test_none(self):
|
||||||
assert serialize_result(None) is None
|
assert serialize_result(None) is None
|
||||||
|
|||||||
Reference in New Issue
Block a user