Compare commits

...

2 Commits

Author SHA1 Message Date
Bruno Postle 2ac4225ac9 README: add missing ifcquery subcommands to relationship section 2026-03-23 23:41:35 +00:00
Bruno Postle 4dc8304498 Add ifcedit CLI wrapper for ifcopenshell.api editor functions 2026-03-23 23:23:16 +00:00
15 changed files with 1775 additions and 0 deletions
+248
View File
@@ -0,0 +1,248 @@
<!-- This file was generated with the assistance of an AI coding tool. -->
# ifcedit
A CLI wrapper that exposes all 350+ `ifcopenshell.api` mutation functions as
shell commands. Functions are auto-discovered at runtime via introspection --
no hardcoded list to maintain.
## Installation
```bash
pip install ifcedit
```
Requires `ifcopenshell`.
## Usage
```
ifcedit <command> [options] [--format json|text]
```
Three subcommands: `list` to discover functions, `docs` to read their
documentation, and `run` to execute them.
## Subcommands
### list
Discover available API modules and their functions.
**List all modules:**
```bash
ifcedit list
```
```json
[
{
"module": "root",
"description": "Functions for creating project-level entities",
"functions": ["create_entity", "remove_product", "copy_class"],
"count": 3
},
{
"module": "spatial",
"description": "Functions for managing spatial relationships",
"functions": ["assign_container", "unassign_container"],
"count": 2
}
]
```
**List functions in a module:**
```bash
ifcedit list root
```
```json
[
{
"name": "create_entity",
"description": "Create an IFC entity with optional initial attributes",
"params": [
{"name": "ifc_class", "type": "str", "required": true},
{"name": "name", "type": "Optional[str]"}
]
}
]
```
### docs
Show full documentation for a specific function, including parameter
descriptions from docstrings and return type.
```bash
ifcedit docs root.create_entity
```
```json
{
"module": "root",
"function": "create_entity",
"description": "Create an IFC entity with optional initial attributes",
"long_description": "This function creates a new entity instance...",
"params": [
{
"name": "ifc_class",
"type": "str",
"required": true,
"description": "The IFC class name (e.g. 'IfcWall', 'IfcProject')"
},
{
"name": "name",
"type": "Optional[str]",
"description": "Optional name attribute"
}
],
"return_type": "ifcopenshell.entity_instance",
"return_description": "The newly created entity instance"
}
```
### run
Execute an API function against an IFC file. Parameters are passed as
`--key value` pairs after the function name.
```bash
ifcedit run model.ifc root.create_entity --ifc_class IfcWall --name "My Wall"
```
```json
{
"ok": true,
"result": {"id": 42, "type": "IfcWall", "name": "My Wall"}
}
```
**Options:**
- `-o, --output <path>` -- write to a different file instead of overwriting the input
- `--dry-run` -- validate parameters without executing or saving
```bash
# Save to a new file
ifcedit run model.ifc root.create_entity -o out.ifc --ifc_class IfcWall
# Validate without executing
ifcedit run model.ifc root.create_entity --dry-run --ifc_class IfcWall
```
Dry-run output shows the resolved parameters:
```json
{
"ok": true,
"dry_run": true,
"module": "root",
"function": "create_entity",
"args": {"ifc_class": "IfcWall", "name": "My Wall"}
}
```
## Parameter type coercion
CLI strings are automatically converted to the types expected by each API
function, using the function's type annotations:
| Type | CLI input | Python value |
|------|-----------|--------------|
| `str` | `"hello"` | `"hello"` |
| `int` | `"42"` or `"#42"` | `42` |
| `float` | `"3.14"` | `3.14` |
| `bool` | `"true"`, `"1"`, `"yes"` | `True` |
| `Optional[X]` | `"none"` | `None` |
| `entity_instance` | `"42"` or `"#42"` | resolved from model by step ID |
| `list[entity_instance]` | `"5,6,7"` or `"[5, 6, 7]"` | list of resolved entities |
| `dict` | `'{"key": "val"}'` | parsed JSON object |
| `Literal["A", "B"]` | `"A"` | validated against allowed values |
## Examples
```bash
# Create a project
ifcedit run model.ifc root.create_entity --ifc_class IfcProject --name "My Project"
# Assign an element to a storey
ifcedit run model.ifc spatial.assign_container --products 10 --relating_structure 4
# Assign multiple elements at once
ifcedit run model.ifc aggregate.assign_object --products "5,6,7" --relating_object 1
# Add a property set
ifcedit run model.ifc pset.add_pset --product 10 --name "Pset_WallCommon"
# Edit properties
ifcedit run model.ifc pset.edit_pset --pset 15 \
--properties '{"IsExternal": true, "FireRating": "2HR"}'
```
### quantify
Run quantity take-off (QTO) on an IFC file, computing physical measurements
(volume, area, length, count, weight) and writing them back as
`IfcElementQuantity` property sets. Uses `ifc5d` rules.
**List available rules:**
```bash
ifcedit quantify list
```
```json
[
{"name": "IFC4QtoBaseQuantities"},
{"name": "IFC4X3QtoBaseQuantities"}
]
```
**Run QTO on a file:**
```bash
ifcedit quantify run model.ifc IFC4QtoBaseQuantities
ifcedit quantify run model.ifc IFC4QtoBaseQuantities --selector IfcWall
ifcedit quantify run model.ifc IFC4QtoBaseQuantities -o model_qto.ifc
```
```json
{"ok": true, "rule": "IFC4QtoBaseQuantities", "elements_quantified": 42}
```
Options:
- `--selector <query>` -- ifcopenshell selector to restrict elements (default: all `IfcElement`)
- `-o, --output <path>` -- write to a different file instead of overwriting the input
Note: `quantify run` writes geometry-based measurements and requires the
IfcOpenShell C++ geometry bindings for elements with computed quantities.
## Error handling
Errors are reported in the JSON response:
```json
{
"ok": false,
"error": "Entity #999 not found in model"
}
```
Exit code is 0 on success, 1 on error.
## Relationship to ifcquery
`ifcedit` and `ifcquery` are complementary tools:
- **ifcquery** reads and inspects IFC models (summary, tree, info, select, relations, clash, validate, schedule, cost, schema, contexts, materials, plot, render)
- **ifcedit** modifies IFC models by wrapping `ifcopenshell.api` functions, and runs QTO via `quantify`
A typical workflow: inspect with `ifcquery`, look up the right API function
with `ifcedit docs`, then apply changes with `ifcedit run`.
## License
LGPLv3+ -- see the IfcOpenShell project license.
+20
View File
@@ -0,0 +1,20 @@
# This file was generated with the assistance of an AI coding tool.
# IfcEdit - CLI wrapper for ifcopenshell.api mutation functions
# Copyright (C) 2026 Bruno Postle <bruno@postle.net>
#
# This file is part of IfcEdit.
#
# IfcEdit 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.
#
# IfcEdit 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 IfcEdit. If not, see <http://www.gnu.org/licenses/>.
__version__ = version = "0.0.0"
+217
View File
@@ -0,0 +1,217 @@
# This file was generated with the assistance of an AI coding tool.
# IfcEdit - CLI wrapper for ifcopenshell.api mutation functions
# Copyright (C) 2026 Bruno Postle <bruno@postle.net>
#
# This file is part of IfcEdit.
#
# IfcEdit 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.
#
# IfcEdit 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 IfcEdit. If not, see <http://www.gnu.org/licenses/>.
from __future__ import annotations
import argparse
import json
import sys
import ifcopenshell
from ifcedit.discover import function_docs, list_functions, list_modules
from ifcedit.quantify import list_rules, run_quantify
from ifcedit.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 cmd_quantify(args, extra_args):
if args.quantify_command == "list":
result = list_rules()
print(format_output(result, args.output_format))
elif args.quantify_command == "run":
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)
selector = args.selector or None
result = run_quantify(model, args.rule_name, selector=selector)
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)
else:
print("Error: quantify requires a subcommand: list or run", file=sys.stderr)
sys.exit(1)
def main():
parser = argparse.ArgumentParser(
prog="ifcedit",
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")
# quantify
quantify_parser = subparsers.add_parser("quantify", help="Quantity take-off (QTO) using ifc5d rules")
quantify_sub = quantify_parser.add_subparsers(dest="quantify_command")
quantify_sub.add_parser("list", help="List available QTO rule names")
qrun_parser = quantify_sub.add_parser("run", help="Run QTO on an IFC file")
qrun_parser.add_argument("ifc_file", help="Path to the IFC file")
qrun_parser.add_argument("rule_name", help="QTO rule name (e.g. IFC4QtoBaseQuantities)")
qrun_parser.add_argument("--selector", help="ifcopenshell selector to restrict elements (default: all IfcElement)")
qrun_parser.add_argument("-o", "--output", help="Output file path (default: overwrite input)")
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)
elif args.command == "quantify":
cmd_quantify(args, extra)
if __name__ == "__main__":
main()
+180
View File
@@ -0,0 +1,180 @@
# This file was generated with the assistance of an AI coding tool.
# IfcEdit - CLI wrapper for ifcopenshell.api mutation functions
# Copyright (C) 2026 Bruno Postle <bruno@postle.net>
#
# This file is part of IfcEdit.
#
# IfcEdit 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.
#
# IfcEdit 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 IfcEdit. 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,
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: 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.
Raises:
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
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, lookup_file)
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, effective_lookup)
if args:
items = _split_list(value_str)
return [coerce_value(item.strip(), args[0], model, lookup_file) for item in items]
return _split_list(value_str)
# dict types
if origin is dict:
return _floatify_numeric_lists(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")
# 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, effective_lookup)
# 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 | int, lookup_file: ifcopenshell.file | None) -> ifcopenshell.entity_instance:
"""Resolve a step ID string like '123' or '#123' to an entity instance."""
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 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, 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(), lookup_file) for item in items]
def _floatify_numeric_lists(obj):
"""Recursively convert lists of numbers to lists of floats.
IFC C++ bindings require Python floats (not ints) for AGGREGATE OF DOUBLE
attributes (e.g. DirectionRatios, Coordinates). JSON parsing produces ints
for whole numbers like 0, which causes a TypeError at the binding level.
"""
if isinstance(obj, dict):
return {k: _floatify_numeric_lists(v) for k, v in obj.items()}
if (
isinstance(obj, list)
and obj
and all(isinstance(v, (int, float)) for v in obj)
and any(isinstance(v, float) for v in obj)
):
return [float(v) for v in obj]
return obj
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 [json.dumps(item) if isinstance(item, (dict, list)) else str(item) for item in parsed]
except json.JSONDecodeError:
pass
return [item.strip() for item in value_str.split(",") if item.strip()]
+282
View File
@@ -0,0 +1,282 @@
# This file was generated with the assistance of an AI coding tool.
# IfcEdit - CLI wrapper for ifcopenshell.api mutation functions
# Copyright (C) 2026 Bruno Postle <bruno@postle.net>
#
# This file is part of IfcEdit.
#
# IfcEdit 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.
#
# IfcEdit 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 IfcEdit. 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."""
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)
# 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 = next(f for f in formatted if f != "None")
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
_FIELD_MARKER = re.compile(r":(?:param|returns?|rtype|type|raises?)\b")
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 _FIELD_MARKER.match(stripped):
current_lines.append(stripped)
elif _FIELD_MARKER.match(stripped) 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 _FIELD_MARKER.match(stripped) or stripped == "":
break
lines.append(stripped)
return re.sub(r"\s+", " ", " ".join(lines).strip())
+37
View File
@@ -0,0 +1,37 @@
# This file was generated with the assistance of an AI coding tool.
from __future__ import annotations
from typing import Any
import ifcopenshell
AVAILABLE_RULES = ["IFC4QtoBaseQuantities", "IFC4X3QtoBaseQuantities"]
def list_rules() -> list[dict[str, str]]:
"""Return a list of available quantification rule names."""
return [{"name": name} for name in AVAILABLE_RULES]
def run_quantify(model: ifcopenshell.file, rule: str, selector: str | None = None) -> dict[str, Any]:
"""Run quantity take-off on the model using the named rule.
Modifies the model in-place by adding/updating IfcElementQuantity psets.
Returns a summary dict with ok, rule, and elements_quantified.
"""
from ifc5d.qto import edit_qtos, quantify
from ifc5d.qto import rules as rule_sets
if rule not in rule_sets:
return {"ok": False, "error": f"Unknown rule: {rule}. Available: {list(rule_sets.keys())}"}
import ifcopenshell.util.selector
if selector:
elements = set(ifcopenshell.util.selector.filter_elements(model, selector))
else:
elements = set(model.by_type("IfcElement"))
results = quantify(model, elements, rule_sets[rule])
edit_qtos(model, results)
return {"ok": True, "rule": rule, "elements_quantified": len(results)}
+148
View File
@@ -0,0 +1,148 @@
# This file was generated with the assistance of an AI coding tool.
# IfcEdit - CLI wrapper for ifcopenshell.api mutation functions
# Copyright (C) 2026 Bruno Postle <bruno@postle.net>
#
# This file is part of IfcEdit.
#
# IfcEdit 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.
#
# IfcEdit 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 IfcEdit. If not, see <http://www.gnu.org/licenses/>.
from __future__ import annotations
import importlib
import inspect
import typing
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,
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 = {}
# 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 = 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}"}
# 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
+33
View File
@@ -0,0 +1,33 @@
[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"
[project]
name = "ifcedit"
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", "ifc5d"]
[project.scripts]
ifcedit = "ifcedit.__main__:main"
[project.urls]
Homepage = "http://ifcopenshell.org"
Documentation = "https://docs.ifcopenshell.org"
Issues = "https://github.com/IfcOpenShell/IfcOpenShell/issues"
[tool.setuptools.packages.find]
include = ["ifcedit*"]
exclude = ["test*"]
[tool.ruff]
extend = "../../pyproject.toml"
+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"}