Add ifcapi CLI wrapper for ifcopenshell.api editor functions

Runtime introspection auto-discovers all API functions. Subcommands:
list (modules/functions), docs (parameter documentation), run (execute
with type-coerced CLI arguments). Output is JSON to stdout, supports
--dry-run validation and -o alternate output path.
This commit is contained in:
Bruno Postle
2026-02-09 06:27:00 +00:00
parent 39161e7256
commit 78f26c2432
12 changed files with 1207 additions and 0 deletions
+19
View File
@@ -0,0 +1,19 @@
# IfcApi - CLI wrapper for ifcopenshell.api mutation functions
# Copyright (C) 2025 Bruno Postle <bruno@postle.net>
#
# This file is part of IfcApi.
#
# IfcApi is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# IfcApi is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with IfcApi. If not, see <http://www.gnu.org/licenses/>.
__version__ = version = "0.0.0"
+180
View File
@@ -0,0 +1,180 @@
# IfcApi - CLI wrapper for ifcopenshell.api mutation functions
# Copyright (C) 2025 Bruno Postle <bruno@postle.net>
#
# This file is part of IfcApi.
#
# IfcApi is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# IfcApi is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with IfcApi. If not, see <http://www.gnu.org/licenses/>.
from __future__ import annotations
import argparse
import json
import sys
import ifcopenshell
from ifcapi.discover import function_docs, list_functions, list_modules
from ifcapi.run import run_api
def format_output(data, fmt: str) -> str:
if fmt == "json":
return json.dumps(data, indent=2, ensure_ascii=False)
elif fmt == "text":
return _format_text(data)
return json.dumps(data, indent=2, ensure_ascii=False)
def _format_text(data, indent: int = 0) -> str:
prefix = " " * indent
lines = []
if isinstance(data, dict):
for key, value in data.items():
if isinstance(value, (dict, list)):
lines.append(f"{prefix}{key}:")
lines.append(_format_text(value, indent + 1))
else:
lines.append(f"{prefix}{key}: {value}")
elif isinstance(data, list):
for item in data:
if isinstance(item, dict):
lines.append(_format_text(item, indent))
lines.append("")
else:
lines.append(f"{prefix}- {item}")
else:
lines.append(f"{prefix}{data}")
return "\n".join(lines)
def cmd_list(args):
if args.module:
try:
functions = list_functions(args.module)
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
print(format_output(functions, args.output_format))
else:
modules = list_modules()
print(format_output(modules, args.output_format))
def cmd_docs(args):
parts = args.function_path.split(".")
if len(parts) != 2:
print("Error: function path must be 'module.function' (e.g. root.create_entity)", file=sys.stderr)
sys.exit(1)
module, function = parts
try:
docs = function_docs(module, function)
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
print(format_output(docs, args.output_format))
def cmd_run(args, extra_args):
try:
model = ifcopenshell.open(args.ifc_file)
except Exception as e:
print(f"Error: Could not open IFC file: {e}", file=sys.stderr)
sys.exit(1)
parts = args.function_path.split(".")
if len(parts) != 2:
print("Error: function path must be 'module.function' (e.g. root.create_entity)", file=sys.stderr)
sys.exit(1)
module, function = parts
# Parse extra --key value arguments into a dict
raw_kwargs = _parse_extra_args(extra_args)
if args.dry_run:
result = {"ok": True, "dry_run": True, "module": module, "function": function, "args": raw_kwargs}
else:
result = run_api(model, module, function, raw_kwargs)
if result["ok"]:
output_path = args.output or args.ifc_file
model.write(output_path)
print(format_output(result, args.output_format))
if not result["ok"]:
sys.exit(1)
def _parse_extra_args(extra: list[str]) -> dict[str, str]:
"""Parse a list of ['--key', 'value', ...] into a dict."""
kwargs = {}
i = 0
while i < len(extra):
arg = extra[i]
if arg.startswith("--"):
key = arg[2:]
if i + 1 < len(extra) and not extra[i + 1].startswith("--"):
kwargs[key] = extra[i + 1]
i += 2
else:
# Flag without value — treat as "true"
kwargs[key] = "true"
i += 1
else:
print(f"Error: Unexpected argument: {arg}", file=sys.stderr)
sys.exit(1)
return kwargs
def main():
parser = argparse.ArgumentParser(
prog="ifcapi",
description="CLI wrapper for ifcopenshell.api IFC model mutation functions",
)
parser.add_argument(
"--format",
choices=["json", "text"],
default="json",
dest="output_format",
help="Output format (default: json)",
)
subparsers = parser.add_subparsers(dest="command", required=True)
# list
list_parser = subparsers.add_parser("list", help="List API modules or functions in a module")
list_parser.add_argument("module", nargs="?", help="Module name (omit to list all modules)")
# docs
docs_parser = subparsers.add_parser("docs", help="Show full documentation for an API function")
docs_parser.add_argument("function_path", help="module.function (e.g. root.create_entity)")
# run
run_parser = subparsers.add_parser("run", help="Execute an API function on an IFC file")
run_parser.add_argument("ifc_file", help="Path to the IFC file")
run_parser.add_argument("function_path", help="module.function (e.g. root.create_entity)")
run_parser.add_argument("-o", "--output", help="Output file path (default: overwrite input)")
run_parser.add_argument("--dry-run", action="store_true", help="Validate without executing or saving")
args, extra = parser.parse_known_args()
if args.command == "list":
cmd_list(args)
elif args.command == "docs":
cmd_docs(args)
elif args.command == "run":
cmd_run(args, extra)
if __name__ == "__main__":
main()
+142
View File
@@ -0,0 +1,142 @@
# IfcApi - CLI wrapper for ifcopenshell.api mutation functions
# Copyright (C) 2025 Bruno Postle <bruno@postle.net>
#
# This file is part of IfcApi.
#
# IfcApi is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# IfcApi is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with IfcApi. If not, see <http://www.gnu.org/licenses/>.
from __future__ import annotations
import json
import typing
import ifcopenshell
def coerce_value(value_str: str, type_hint, model: 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.
Returns:
The converted Python value.
Raises:
ValueError: If the value cannot be converted.
TypeError: If the type hint is not supported.
"""
if type_hint is None:
return value_str
origin = typing.get_origin(type_hint)
args = typing.get_args(type_hint)
# Union / Optional
if origin is typing.Union:
non_none_types = [a for a in args if a is not type(None)]
if value_str.lower() == "none":
if type(None) in args:
return None
# Try each non-None type in order
for t in non_none_types:
try:
return coerce_value(value_str, t, model)
except (ValueError, TypeError):
continue
raise ValueError(f"Cannot convert '{value_str}' to any of {non_none_types}")
# Literal
if origin is typing.Literal:
allowed = args
if value_str in [str(a) for a in allowed]:
# return the actual literal value with proper type
for a in allowed:
if str(a) == value_str:
return a
raise ValueError(f"'{value_str}' is not one of: {', '.join(repr(a) for a in allowed)}")
# list types
if origin is list:
if args and _is_entity_type(args[0]):
return _coerce_entity_list(value_str, model)
if args:
items = _split_list(value_str)
return [coerce_value(item.strip(), args[0], model) for item in items]
return _split_list(value_str)
# dict types
if origin is dict:
return json.loads(value_str)
# Simple types
if type_hint is str:
return value_str
if type_hint is int:
return int(value_str.lstrip("#"))
if type_hint is float:
return float(value_str)
if type_hint is bool:
return value_str.lower() in ("true", "1", "yes")
# entity_instance
if _is_entity_type(type_hint):
return _coerce_entity(value_str, model)
# Fallback: try json.loads for complex types, then plain string
try:
return json.loads(value_str)
except (json.JSONDecodeError, TypeError):
return value_str
def _is_entity_type(hint) -> bool:
"""Check if a type hint refers to ifcopenshell.entity_instance."""
if hint is ifcopenshell.entity_instance:
return True
if isinstance(hint, type) and issubclass(hint, ifcopenshell.entity_instance):
return True
return False
def _coerce_entity(value_str: str, model: ifcopenshell.file | None) -> ifcopenshell.entity_instance:
"""Resolve a step ID string like '123' or '#123' to an entity instance."""
if model is None:
raise ValueError("Cannot resolve entity reference without an IFC model")
entity_id = int(value_str.strip().lstrip("#"))
try:
return model.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]:
"""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]
def _split_list(value_str: str) -> list[str]:
"""Split a comma-separated string, handling JSON arrays too."""
value_str = value_str.strip()
if value_str.startswith("["):
try:
parsed = json.loads(value_str)
if isinstance(parsed, list):
return [str(item) for item in parsed]
except json.JSONDecodeError:
pass
return [item.strip() for item in value_str.split(",") if item.strip()]
+269
View File
@@ -0,0 +1,269 @@
# IfcApi - CLI wrapper for ifcopenshell.api mutation functions
# Copyright (C) 2025 Bruno Postle <bruno@postle.net>
#
# This file is part of IfcApi.
#
# IfcApi is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# IfcApi is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with IfcApi. If not, see <http://www.gnu.org/licenses/>.
from __future__ import annotations
import importlib
import inspect
import re
import typing
from pathlib import Path
def _api_package_path() -> Path:
"""Return the filesystem path to the ifcopenshell.api package."""
import ifcopenshell.api
return Path(ifcopenshell.api.__file__).parent
def list_modules() -> list[dict]:
"""List all API modules with their function counts and descriptions.
Returns a list of dicts: [{"module": "root", "description": "...", "functions": [...], "count": 4}, ...]
"""
api_path = _api_package_path()
modules = []
for child in sorted(api_path.iterdir()):
if not child.is_dir() or child.name.startswith("_"):
continue
init_file = child / "__init__.py"
if not init_file.exists():
continue
try:
mod = importlib.import_module(f"ifcopenshell.api.{child.name}")
except Exception:
continue
all_names = getattr(mod, "__all__", [])
if not all_names:
continue
description = ""
if mod.__doc__:
description = mod.__doc__.strip().split("\n")[0]
modules.append({
"module": child.name,
"description": description,
"functions": list(all_names),
"count": len(all_names),
})
return modules
def list_functions(module: str) -> list[dict]:
"""List functions in an API module with one-line descriptions and parameter info.
Returns a list of dicts: [{"name": "create_entity", "description": "...", "params": [...]}]
"""
mod = importlib.import_module(f"ifcopenshell.api.{module}")
all_names = getattr(mod, "__all__", [])
functions = []
for name in all_names:
fn = _get_underlying_function(module, name)
if fn is None:
continue
description = ""
if fn.__doc__:
description = fn.__doc__.strip().split("\n")[0]
params = _extract_params(fn)
functions.append({
"name": name,
"description": description,
"params": params,
})
return functions
def function_docs(module: str, function: str) -> dict:
"""Full documentation for a single API function.
Returns a dict with: module, function, description, params (with types/defaults/descriptions), return_type
"""
fn = _get_underlying_function(module, function)
if fn is None:
raise ValueError(f"Function '{module}.{function}' not found")
description = ""
long_description = ""
if fn.__doc__:
description, long_description = _parse_docstring_body(fn.__doc__)
params = _extract_params(fn)
param_descriptions = _parse_param_docs(fn.__doc__ or "")
for param in params:
if param["name"] in param_descriptions:
param["description"] = param_descriptions[param["name"]]
return_type = _format_type_hint(typing.get_type_hints(fn).get("return"))
return_description = _parse_return_doc(fn.__doc__ or "")
result = {
"module": module,
"function": function,
"description": description,
"long_description": long_description,
"params": params,
}
if return_type:
result["return_type"] = return_type
if return_description:
result["return_description"] = return_description
return result
def _get_underlying_function(module: str, function: str):
"""Get the actual function object (unwrapping the listener wrapper if needed)."""
try:
fn_module = importlib.import_module(f"ifcopenshell.api.{module}.{function}")
fn = getattr(fn_module, function, None)
return fn
except (ImportError, AttributeError):
return None
def _extract_params(fn) -> list[dict]:
"""Extract parameter info from a function's signature and type hints."""
sig = inspect.signature(fn)
try:
hints = typing.get_type_hints(fn)
except Exception:
hints = {}
params = []
for name, param in sig.parameters.items():
if name == "file" or name == "self":
continue
info = {"name": name}
if name in hints:
info["type"] = _format_type_hint(hints[name])
if param.default is not inspect.Parameter.empty:
info["default"] = _serialize_default(param.default)
else:
info["required"] = True
params.append(info)
return params
def _format_type_hint(hint) -> str | None:
"""Format a type hint to a readable string."""
if hint is None:
return None
if hint is type(None):
return "None"
origin = typing.get_origin(hint)
args = typing.get_args(hint)
# Union (including Optional)
if origin is typing.Union:
formatted = [_format_type_hint(a) for a in args]
# Optional[X] is Union[X, None] — render as "Optional[X]"
if len(formatted) == 2 and "None" in formatted:
inner = [f for f in formatted if f != "None"][0]
return f"Optional[{inner}]"
return " | ".join(formatted)
# Literal
if origin is typing.Literal:
values = ", ".join(repr(a) for a in args)
return f"Literal[{values}]"
# Generic types (list, dict, etc.)
if origin is not None:
origin_name = getattr(origin, "__name__", str(origin))
if args:
inner = ", ".join(_format_type_hint(a) for a in args)
return f"{origin_name}[{inner}]"
return origin_name
# Simple types
return getattr(hint, "__name__", str(hint))
def _serialize_default(value):
"""Serialize a default value to something JSON-friendly."""
if value is None:
return None
if isinstance(value, (str, int, float, bool)):
return value
return repr(value)
def _parse_docstring_body(docstring: str) -> tuple[str, str]:
"""Parse the summary and long description from a docstring."""
lines = docstring.strip().split("\n")
summary = lines[0].strip() if lines else ""
body_lines = []
in_body = False
for line in lines[1:]:
stripped = line.strip()
if stripped.startswith(":param") or stripped.startswith(":return"):
break
if stripped.startswith("Example"):
break
if not in_body and not stripped:
in_body = True
continue
if in_body:
body_lines.append(stripped)
long_description = " ".join(body_lines).strip()
# collapse multiple spaces
long_description = re.sub(r"\s+", " ", long_description)
return summary, long_description
def _parse_param_docs(docstring: str) -> dict[str, str]:
"""Extract :param name: description lines from a docstring."""
params = {}
current_param = None
current_lines = []
for line in docstring.split("\n"):
stripped = line.strip()
match = re.match(r":param\s+(\w+):\s*(.*)", stripped)
if match:
if current_param:
params[current_param] = " ".join(current_lines).strip()
current_param = match.group(1)
current_lines = [match.group(2)]
elif current_param and stripped and not stripped.startswith(":"):
current_lines.append(stripped)
elif stripped.startswith(":") or (stripped == "" and current_param):
if current_param:
params[current_param] = " ".join(current_lines).strip()
current_param = None
current_lines = []
if current_param:
params[current_param] = " ".join(current_lines).strip()
# collapse whitespace
return {k: re.sub(r"\s+", " ", v) for k, v in params.items()}
def _parse_return_doc(docstring: str) -> str:
"""Extract :return: description from a docstring."""
lines = []
in_return = False
for line in docstring.split("\n"):
stripped = line.strip()
match = re.match(r":return:\s*(.*)", stripped)
if match:
in_return = True
lines = [match.group(1)]
elif in_return:
if stripped.startswith(":") or stripped == "":
break
lines.append(stripped)
return re.sub(r"\s+", " ", " ".join(lines).strip())
+112
View File
@@ -0,0 +1,112 @@
# IfcApi - CLI wrapper for ifcopenshell.api mutation functions
# Copyright (C) 2025 Bruno Postle <bruno@postle.net>
#
# This file is part of IfcApi.
#
# IfcApi is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# IfcApi is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with IfcApi. If not, see <http://www.gnu.org/licenses/>.
from __future__ import annotations
import importlib
import inspect
import typing
import ifcopenshell
from ifcapi.coerce import coerce_value
def run_api(
model: ifcopenshell.file,
module: str,
function: str,
raw_kwargs: dict[str, str],
) -> dict:
"""Execute an ifcopenshell.api function with CLI-provided string arguments.
Args:
model: The open IFC model.
module: API module name (e.g. "root").
function: Function name (e.g. "create_entity").
raw_kwargs: String keyword arguments from the CLI.
Returns:
A dict with {"ok": True, "result": ...} on success,
or {"ok": False, "error": "..."} on failure.
"""
try:
fn = _import_function(module, function)
except (ImportError, AttributeError) as e:
return {"ok": False, "error": f"Cannot find function '{module}.{function}': {e}"}
try:
hints = typing.get_type_hints(fn)
except Exception:
hints = {}
sig = inspect.signature(fn)
coerced_kwargs = {}
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)
try:
coerced_kwargs[name] = coerce_value(value_str, hint, model)
except (ValueError, TypeError) as e:
return {"ok": False, "error": f"Cannot convert parameter '{name}': {e}"}
# Determine if the function takes 'file' as its first parameter
first_param = next(iter(sig.parameters), None)
try:
if first_param == "file":
result = fn(model, **coerced_kwargs)
else:
result = fn(**coerced_kwargs)
except Exception as e:
return {"ok": False, "error": f"{type(e).__name__}: {e}"}
return {"ok": True, "result": serialize_result(result)}
def _import_function(module: str, function: str):
"""Import and return the underlying function from ifcopenshell.api."""
fn_module = importlib.import_module(f"ifcopenshell.api.{module}.{function}")
fn = getattr(fn_module, function)
return fn
def serialize_result(value) -> object:
"""Serialize an API result to a JSON-friendly structure."""
if value is None:
return None
if isinstance(value, ifcopenshell.entity_instance):
return _serialize_entity(value)
if isinstance(value, (list, tuple, set, frozenset)):
return [serialize_result(item) for item in value]
if isinstance(value, dict):
return {str(k): serialize_result(v) for k, v in value.items()}
if isinstance(value, (str, int, float, bool)):
return value
return str(value)
def _serialize_entity(entity: ifcopenshell.entity_instance) -> dict:
"""Serialize an entity instance to a summary dict."""
result = {
"id": entity.id(),
"type": entity.is_a(),
}
if hasattr(entity, "Name") and entity.Name:
result["name"] = entity.Name
return result
+30
View File
@@ -0,0 +1,30 @@
[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"
[project]
name = "ifcapi"
version = "0.0.0"
authors = [
{ name="Bruno Postle", email="bruno@postle.net" },
]
description = "CLI wrapper for ifcopenshell.api IFC model mutation functions"
readme = "README.md"
keywords = ["IFC", "BIM", "API"]
classifiers = [
"Programming Language :: Python :: 3",
"License :: OSI Approved :: GNU Lesser General Public License v3 or later (LGPLv3+)",
]
dependencies = ["ifcopenshell"]
[project.urls]
Homepage = "http://ifcopenshell.org"
Documentation = "https://docs.ifcopenshell.org"
Issues = "https://github.com/IfcOpenShell/IfcOpenShell/issues"
[tool.setuptools.packages.find]
include = ["ifcapi*"]
exclude = ["test*"]
[tool.ruff]
extend = "../../pyproject.toml"
View File
+42
View File
@@ -0,0 +1,42 @@
import pytest
import ifcopenshell
import ifcopenshell.api.aggregate
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
@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)
+130
View File
@@ -0,0 +1,130 @@
import json
from typing import Literal, Optional, Union
import pytest
import ifcopenshell
from ifcapi.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"}
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 TestFallback:
def test_no_type_hint(self):
assert coerce_value("hello", None) == "hello"
+103
View File
@@ -0,0 +1,103 @@
from ifcapi.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
+101
View File
@@ -0,0 +1,101 @@
import json
import subprocess
import sys
import pytest
def run_ifcapi(*args):
"""Run ifcapi as a subprocess and return (stdout, stderr, returncode)."""
result = subprocess.run(
[sys.executable, "-m", "ifcapi", *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_ifcapi("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_ifcapi("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_ifcapi("--format", "text", "list")
assert rc == 0
assert "root" in stdout
class TestDocsCommand:
def test_docs_create_entity(self):
stdout, stderr, rc = run_ifcapi("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_ifcapi("docs", "invalid_path")
assert rc != 0
assert "module.function" in stderr
def test_docs_unknown_function(self):
stdout, stderr, rc = run_ifcapi("docs", "root.nonexistent")
assert rc != 0
class TestRunCommand:
def test_create_entity(self, model_file):
stdout, stderr, rc = run_ifcapi(
"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_ifcapi(
"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_ifcapi(
"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_ifcapi("run", model_file, "root.nonexistent")
assert rc != 0
def test_run_invalid_function_path(self, model_file):
stdout, stderr, rc = run_ifcapi("run", model_file, "invalid_path")
assert rc != 0
assert "module.function" in stderr
+79
View File
@@ -0,0 +1,79 @@
import ifcopenshell.api.pset
import ifcopenshell.api.root
from ifcapi.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 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"}