Add ifcedit: CLI wrapper for ifcopenshell.api mutation functions (#7846)

ifcedit is a new command-line tool for executing ifcopenshell.api mutations from the shell. It wraps the entire API surface — any function callable via ifcopenshell.api can be invoked without writing Python.

Subcommands:

    list [module] — list all API modules, or functions within a module
    docs <module.function> — full documentation (params, types, descriptions)
    run <file> <module.function> [--param value ...] — execute a mutation; overwrites input file by default, or use -o <output> to write elsewhere; --dry-run validates without executing
    quantify list — list available QTO rules
    quantify run <file> <rule> — run quantity take-off, writing IfcElementQuantity psets back to the file

Parameter coercion: entity references can be passed as step IDs (strings); lists, dicts, booleans, and None are handled automatically.

Usage:

python3 -m ifcedit run model.ifc root.remove_product --product 42
python3 -m ifcedit docs geometry.edit_object_placement

Generated with the assistance of an AI coding tool.
This commit is contained in:
Bruno Postle
2026-03-23 23:45:42 +00:00
committed by GitHub
parent 7cd40bf8cb
commit 6bf4259298
15 changed files with 1775 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
# This file was generated with the assistance of an AI coding tool.
+63
View File
@@ -0,0 +1,63 @@
# This file was generated with the assistance of an AI coding tool.
import ifcopenshell
import ifcopenshell.api.aggregate
import ifcopenshell.api.material
import ifcopenshell.api.owner.settings
import ifcopenshell.api.project
import ifcopenshell.api.pset
import ifcopenshell.api.root
import ifcopenshell.api.spatial
import ifcopenshell.api.unit
import pytest
@pytest.fixture
def model():
"""Create an IFC4 model with a spatial hierarchy and a wall."""
f = 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]
project = ifcopenshell.api.root.create_entity(f, ifc_class="IfcProject", name="TestProject")
ifcopenshell.api.unit.assign_unit(f)
site = ifcopenshell.api.root.create_entity(f, ifc_class="IfcSite", name="TestSite")
building = ifcopenshell.api.root.create_entity(f, ifc_class="IfcBuilding", name="TestBuilding")
storey = ifcopenshell.api.root.create_entity(f, ifc_class="IfcBuildingStorey", name="Ground Floor")
ifcopenshell.api.aggregate.assign_object(f, products=[site], relating_object=project)
ifcopenshell.api.aggregate.assign_object(f, products=[building], relating_object=site)
ifcopenshell.api.aggregate.assign_object(f, products=[storey], relating_object=building)
wall = ifcopenshell.api.root.create_entity(f, ifc_class="IfcWall", name="Wall001")
ifcopenshell.api.spatial.assign_container(f, products=[wall], relating_structure=storey)
return f
@pytest.fixture
def model_file(model, tmp_path):
"""Write the model fixture to a temp file and return the 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)
+158
View File
@@ -0,0 +1,158 @@
# This file was generated with the assistance of an AI coding tool.
import json
from typing import Literal, Optional, Union
import ifcopenshell
import ifcopenshell.api.project
import pytest
from ifcedit.coerce import coerce_value
class TestStringCoercion:
def test_plain_string(self):
assert coerce_value("hello", str) == "hello"
def test_empty_string(self):
assert coerce_value("", str) == ""
class TestIntCoercion:
def test_plain_int(self):
assert coerce_value("42", int) == 42
def test_hash_prefix(self):
assert coerce_value("#42", int) == 42
def test_negative(self):
assert coerce_value("-5", int) == -5
class TestFloatCoercion:
def test_plain_float(self):
assert coerce_value("3.14", float) == pytest.approx(3.14)
def test_integer_as_float(self):
assert coerce_value("5", float) == 5.0
class TestBoolCoercion:
def test_true_values(self):
for val in ("true", "True", "TRUE", "1", "yes"):
assert coerce_value(val, bool) is True
def test_false_values(self):
for val in ("false", "False", "0", "no"):
assert coerce_value(val, bool) is False
class TestOptionalCoercion:
def test_optional_string(self):
assert coerce_value("hello", Optional[str]) == "hello"
def test_optional_none(self):
assert coerce_value("none", Optional[str]) is None
assert coerce_value("None", Optional[str]) is None
def test_optional_int(self):
assert coerce_value("42", Optional[int]) == 42
class TestUnionCoercion:
def test_union_str_int(self):
# Tries str first (or int first depending on order), both work
result = coerce_value("hello", Union[str, int])
assert result == "hello"
def test_union_int_none(self):
result = coerce_value("42", Union[int, None])
assert result == 42
class TestLiteralCoercion:
def test_valid_literal(self):
assert coerce_value("IFC4", Literal["IFC2X3", "IFC4", "IFC4X3"]) == "IFC4"
def test_invalid_literal(self):
with pytest.raises(ValueError, match="not one of"):
coerce_value("IFC5", Literal["IFC2X3", "IFC4", "IFC4X3"])
class TestDictCoercion:
def test_json_dict(self):
result = coerce_value('{"IsExternal": true, "FireRating": "2HR"}', dict[str, object])
assert result == {"IsExternal": True, "FireRating": "2HR"}
def test_mixed_float_int_list_coerced_to_float(self):
# [0.419, 0, 0.908] — JSON integer 0 mixed with floats must become float
# so ifcopenshell AGGREGATE OF DOUBLE attributes (e.g. DirectionRatios) don't reject the list
result = coerce_value('{"DirectionRatios": [0.419, 0, 0.908]}', dict[str, object])
assert result["DirectionRatios"] == pytest.approx([0.419, 0.0, 0.908])
assert all(isinstance(v, float) for v in result["DirectionRatios"])
def test_pure_int_list_not_coerced(self):
# All-integer lists (e.g. face indices) must stay as ints
result = coerce_value('{"CoordIndex": [0, 1, 2]}', dict[str, object])
assert result["CoordIndex"] == [0, 1, 2]
assert all(isinstance(v, int) for v in result["CoordIndex"])
class TestListCoercion:
def test_comma_separated(self):
result = coerce_value("a,b,c", list[str])
assert result == ["a", "b", "c"]
def test_json_array(self):
result = coerce_value("[1, 2, 3]", list[int])
assert result == [1, 2, 3]
class TestEntityCoercion:
def test_entity_by_id(self, model):
wall = model.by_type("IfcWall")[0]
result = coerce_value(str(wall.id()), ifcopenshell.entity_instance, model)
assert result == wall
def test_entity_with_hash(self, model):
wall = model.by_type("IfcWall")[0]
result = coerce_value(f"#{wall.id()}", ifcopenshell.entity_instance, model)
assert result == wall
def test_entity_not_found(self, model):
with pytest.raises(ValueError, match="not found"):
coerce_value("999999", ifcopenshell.entity_instance, model)
def test_entity_list(self, model):
wall = model.by_type("IfcWall")[0]
result = coerce_value(str(wall.id()), list[ifcopenshell.entity_instance], model)
assert len(result) == 1
assert result[0] == wall
def test_entity_list_multiple(self, model):
wall = model.by_type("IfcWall")[0]
storey = model.by_type("IfcBuildingStorey")[0]
result = coerce_value(f"{wall.id()},{storey.id()}", list[ifcopenshell.entity_instance], model)
assert len(result) == 2
def test_entity_no_model(self):
with pytest.raises(ValueError, match="without an IFC model"):
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"
+104
View File
@@ -0,0 +1,104 @@
# This file was generated with the assistance of an AI coding tool.
from ifcedit.discover import function_docs, list_functions, list_modules
class TestListModules:
def test_returns_list(self):
result = list_modules()
assert isinstance(result, list)
assert len(result) > 0
def test_module_structure(self):
result = list_modules()
for entry in result:
assert "module" in entry
assert "description" in entry
assert "functions" in entry
assert "count" in entry
assert isinstance(entry["functions"], list)
assert entry["count"] == len(entry["functions"])
def test_known_modules_present(self):
result = list_modules()
module_names = [m["module"] for m in result]
for expected in ("root", "spatial", "pset", "aggregate", "unit"):
assert expected in module_names
def test_root_module_has_functions(self):
result = list_modules()
root = next(m for m in result if m["module"] == "root")
assert "create_entity" in root["functions"]
assert root["count"] >= 3
class TestListFunctions:
def test_root_functions(self):
result = list_functions("root")
assert isinstance(result, list)
names = [f["name"] for f in result]
assert "create_entity" in names
def test_function_structure(self):
result = list_functions("root")
for fn in result:
assert "name" in fn
assert "description" in fn
assert "params" in fn
def test_create_entity_params(self):
result = list_functions("root")
create = next(f for f in result if f["name"] == "create_entity")
param_names = [p["name"] for p in create["params"]]
assert "ifc_class" in param_names
assert "name" in param_names
def test_pset_functions(self):
result = list_functions("pset")
names = [f["name"] for f in result]
assert "add_pset" in names
assert "edit_pset" in names
class TestFunctionDocs:
def test_create_entity_docs(self):
result = function_docs("root", "create_entity")
assert result["module"] == "root"
assert result["function"] == "create_entity"
assert result["description"]
assert isinstance(result["params"], list)
assert len(result["params"]) > 0
def test_params_have_types(self):
result = function_docs("root", "create_entity")
for param in result["params"]:
assert "name" in param
assert "type" in param
def test_params_have_descriptions(self):
result = function_docs("root", "create_entity")
ifc_class = next(p for p in result["params"] if p["name"] == "ifc_class")
assert "description" in ifc_class
assert len(ifc_class["description"]) > 0
def test_return_type(self):
result = function_docs("root", "create_entity")
assert "return_type" in result
def test_assign_container_docs(self):
result = function_docs("spatial", "assign_container")
assert result["module"] == "spatial"
param_names = [p["name"] for p in result["params"]]
assert "products" in param_names
assert "relating_structure" in param_names
def test_unknown_function_raises(self):
import pytest
with pytest.raises(ValueError, match="not found"):
function_docs("root", "nonexistent_function")
def test_edit_pset_docs(self):
result = function_docs("pset", "edit_pset")
param_names = [p["name"] for p in result["params"]]
assert "pset" in param_names
assert "properties" in param_names
+100
View File
@@ -0,0 +1,100 @@
# This file was generated with the assistance of an AI coding tool.
import json
import subprocess
import sys
import pytest
def run_ifcedit(*args):
"""Run ifcedit as a subprocess and return (stdout, stderr, returncode)."""
result = subprocess.run(
[sys.executable, "-m", "ifcedit", *args],
capture_output=True,
text=True,
)
return result.stdout, result.stderr, result.returncode
class TestListCommand:
def test_list_all_modules(self):
stdout, stderr, rc = run_ifcedit("list")
assert rc == 0
data = json.loads(stdout)
assert isinstance(data, list)
module_names = [m["module"] for m in data]
assert "root" in module_names
assert "spatial" in module_names
def test_list_module_functions(self):
stdout, stderr, rc = run_ifcedit("list", "root")
assert rc == 0
data = json.loads(stdout)
assert isinstance(data, list)
names = [f["name"] for f in data]
assert "create_entity" in names
def test_list_text_format(self):
stdout, stderr, rc = run_ifcedit("--format", "text", "list")
assert rc == 0
assert "root" in stdout
class TestDocsCommand:
def test_docs_create_entity(self):
stdout, stderr, rc = run_ifcedit("docs", "root.create_entity")
assert rc == 0
data = json.loads(stdout)
assert data["module"] == "root"
assert data["function"] == "create_entity"
assert "params" in data
def test_docs_invalid_path(self):
stdout, stderr, rc = run_ifcedit("docs", "invalid_path")
assert rc != 0
assert "module.function" in stderr
def test_docs_unknown_function(self):
stdout, stderr, rc = run_ifcedit("docs", "root.nonexistent")
assert rc != 0
class TestRunCommand:
def test_create_entity(self, model_file):
stdout, stderr, rc = run_ifcedit(
"run", model_file, "root.create_entity", "--ifc_class", "IfcWall", "--name", "CLIWall"
)
assert rc == 0, f"stderr: {stderr}"
data = json.loads(stdout)
assert data["ok"] is True
assert data["result"]["type"] == "IfcWall"
assert data["result"]["name"] == "CLIWall"
def test_dry_run(self, model_file):
stdout, stderr, rc = run_ifcedit("run", model_file, "root.create_entity", "--dry-run", "--ifc_class", "IfcWall")
assert rc == 0
data = json.loads(stdout)
assert data["ok"] is True
assert data["dry_run"] is True
def test_output_to_different_file(self, model_file, tmp_path):
output = str(tmp_path / "output.ifc")
stdout, stderr, rc = run_ifcedit(
"run", model_file, "root.create_entity", "-o", output, "--ifc_class", "IfcSlab"
)
assert rc == 0, f"stderr: {stderr}"
data = json.loads(stdout)
assert data["ok"] is True
import os
assert os.path.exists(output)
def test_run_error_bad_function(self, model_file):
stdout, stderr, rc = run_ifcedit("run", model_file, "root.nonexistent")
assert rc != 0
def test_run_invalid_function_path(self, model_file):
stdout, stderr, rc = run_ifcedit("run", model_file, "invalid_path")
assert rc != 0
assert "module.function" in stderr
+87
View File
@@ -0,0 +1,87 @@
# This file was generated with the assistance of an AI coding tool.
from __future__ import annotations
import ifcopenshell
import ifcopenshell.api.aggregate
import ifcopenshell.api.owner.settings
import ifcopenshell.api.project
import ifcopenshell.api.root
import ifcopenshell.api.spatial
import ifcopenshell.api.unit
import pytest
from ifcedit.quantify import AVAILABLE_RULES, list_rules, run_quantify
class TestListRules:
def test_returns_list(self):
result = list_rules()
assert isinstance(result, list)
def test_each_entry_has_name(self):
result = list_rules()
for entry in result:
assert "name" in entry
def test_ifc4_rule_present(self):
result = list_rules()
names = [r["name"] for r in result]
assert "IFC4QtoBaseQuantities" in names
def test_ifc4x3_rule_present(self):
result = list_rules()
names = [r["name"] for r in result]
assert "IFC4X3QtoBaseQuantities" in names
@pytest.fixture
def quantify_model():
"""Create an IFC4 model with a wall element."""
f = 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]
project = ifcopenshell.api.root.create_entity(f, ifc_class="IfcProject", name="TestProject")
ifcopenshell.api.unit.assign_unit(f)
site = ifcopenshell.api.root.create_entity(f, ifc_class="IfcSite", name="TestSite")
building = ifcopenshell.api.root.create_entity(f, ifc_class="IfcBuilding", name="TestBuilding")
storey = ifcopenshell.api.root.create_entity(f, ifc_class="IfcBuildingStorey", name="Ground Floor")
ifcopenshell.api.aggregate.assign_object(f, products=[site], relating_object=project)
ifcopenshell.api.aggregate.assign_object(f, products=[building], relating_object=site)
ifcopenshell.api.aggregate.assign_object(f, products=[storey], relating_object=building)
wall = ifcopenshell.api.root.create_entity(f, ifc_class="IfcWall", name="Wall001")
ifcopenshell.api.spatial.assign_container(f, products=[wall], relating_structure=storey)
return f
class TestRunQuantify:
def test_returns_ok_true(self, quantify_model):
result = run_quantify(quantify_model, "IFC4QtoBaseQuantities")
assert result["ok"] is True
def test_returns_rule_name(self, quantify_model):
result = run_quantify(quantify_model, "IFC4QtoBaseQuantities")
assert result["rule"] == "IFC4QtoBaseQuantities"
def test_returns_elements_quantified(self, quantify_model):
result = run_quantify(quantify_model, "IFC4QtoBaseQuantities")
assert "elements_quantified" in result
assert isinstance(result["elements_quantified"], int)
def test_unknown_rule_returns_error(self, quantify_model):
result = run_quantify(quantify_model, "NonExistentRule")
assert result["ok"] is False
assert "error" in result
def test_selector_restricts_elements(self, quantify_model):
result = run_quantify(quantify_model, "IFC4QtoBaseQuantities", selector="IfcWall")
assert result["ok"] is True
assert result["rule"] == "IFC4QtoBaseQuantities"
def test_empty_selector_runs_on_all(self, quantify_model):
result = run_quantify(quantify_model, "IFC4QtoBaseQuantities", selector=None)
assert result["ok"] is True
+97
View File
@@ -0,0 +1,97 @@
# This file was generated with the assistance of an AI coding tool.
import ifcopenshell
import ifcopenshell.api.project
import ifcopenshell.api.pset
import ifcopenshell.api.root
from ifcedit.run import run_api, serialize_result
class TestRunApi:
def test_create_entity(self, model):
result = run_api(model, "root", "create_entity", {"ifc_class": "IfcWall", "name": "NewWall"})
assert result["ok"] is True
assert result["result"]["type"] == "IfcWall"
assert result["result"]["name"] == "NewWall"
assert isinstance(result["result"]["id"], int)
def test_create_entity_default_class(self, model):
result = run_api(model, "root", "create_entity", {})
assert result["ok"] is True
assert result["result"]["type"] == "IfcBuildingElementProxy"
def test_assign_container(self, model):
wall = ifcopenshell.api.root.create_entity(model, ifc_class="IfcWall", name="TestWall2")
storey = model.by_type("IfcBuildingStorey")[0]
result = run_api(
model,
"spatial",
"assign_container",
{"products": str(wall.id()), "relating_structure": str(storey.id())},
)
assert result["ok"] is True
assert result["result"]["type"] == "IfcRelContainedInSpatialStructure"
def test_add_pset(self, model):
wall = model.by_type("IfcWall")[0]
result = run_api(model, "pset", "add_pset", {"product": str(wall.id()), "name": "Pset_WallCommon"})
assert result["ok"] is True
assert result["result"]["type"] == "IfcPropertySet"
def test_unknown_function(self, model):
result = run_api(model, "root", "nonexistent", {})
assert result["ok"] is False
assert "Cannot find" in result["error"]
def test_unknown_parameter(self, model):
result = run_api(model, "root", "create_entity", {"bogus_param": "value"})
assert result["ok"] is False
assert "Unknown parameter" in result["error"]
def test_bad_entity_reference(self, model):
result = run_api(model, "pset", "add_pset", {"product": "999999", "name": "Pset_WallCommon"})
assert result["ok"] is False
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
def test_string(self):
assert serialize_result("hello") == "hello"
def test_int(self):
assert serialize_result(42) == 42
def test_entity(self, model):
wall = model.by_type("IfcWall")[0]
result = serialize_result(wall)
assert result["id"] == wall.id()
assert result["type"] == "IfcWall"
assert result["name"] == "Wall001"
def test_list(self, model):
walls = model.by_type("IfcWall")
result = serialize_result(walls)
assert isinstance(result, list)
assert all(isinstance(r, dict) for r in result)
def test_dict(self):
result = serialize_result({"key": "value"})
assert result == {"key": "value"}