From 4dc83044982470d313b8b15017e5f8a426a44b98 Mon Sep 17 00:00:00 2001 From: Bruno Postle Date: Mon, 23 Mar 2026 23:23:16 +0000 Subject: [PATCH] Add ifcedit CLI wrapper for ifcopenshell.api editor functions --- src/ifcedit/README.md | 248 +++++++++++++++++++++++++ src/ifcedit/ifcedit/__init__.py | 20 ++ src/ifcedit/ifcedit/__main__.py | 217 ++++++++++++++++++++++ src/ifcedit/ifcedit/coerce.py | 180 ++++++++++++++++++ src/ifcedit/ifcedit/discover.py | 282 +++++++++++++++++++++++++++++ src/ifcedit/ifcedit/quantify.py | 37 ++++ src/ifcedit/ifcedit/run.py | 148 +++++++++++++++ src/ifcedit/pyproject.toml | 33 ++++ src/ifcedit/tests/__init__.py | 1 + src/ifcedit/tests/conftest.py | 63 +++++++ src/ifcedit/tests/test_coerce.py | 158 ++++++++++++++++ src/ifcedit/tests/test_discover.py | 104 +++++++++++ src/ifcedit/tests/test_main.py | 100 ++++++++++ src/ifcedit/tests/test_quantify.py | 87 +++++++++ src/ifcedit/tests/test_run.py | 97 ++++++++++ 15 files changed, 1775 insertions(+) create mode 100644 src/ifcedit/README.md create mode 100644 src/ifcedit/ifcedit/__init__.py create mode 100644 src/ifcedit/ifcedit/__main__.py create mode 100644 src/ifcedit/ifcedit/coerce.py create mode 100644 src/ifcedit/ifcedit/discover.py create mode 100644 src/ifcedit/ifcedit/quantify.py create mode 100644 src/ifcedit/ifcedit/run.py create mode 100644 src/ifcedit/pyproject.toml create mode 100644 src/ifcedit/tests/__init__.py create mode 100644 src/ifcedit/tests/conftest.py create mode 100644 src/ifcedit/tests/test_coerce.py create mode 100644 src/ifcedit/tests/test_discover.py create mode 100644 src/ifcedit/tests/test_main.py create mode 100644 src/ifcedit/tests/test_quantify.py create mode 100644 src/ifcedit/tests/test_run.py diff --git a/src/ifcedit/README.md b/src/ifcedit/README.md new file mode 100644 index 0000000000..e6db6db1e6 --- /dev/null +++ b/src/ifcedit/README.md @@ -0,0 +1,248 @@ + +# 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 [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 ` -- 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 ` -- ifcopenshell selector to restrict elements (default: all `IfcElement`) +- `-o, --output ` -- 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) +- **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. diff --git a/src/ifcedit/ifcedit/__init__.py b/src/ifcedit/ifcedit/__init__.py new file mode 100644 index 0000000000..eac2eac799 --- /dev/null +++ b/src/ifcedit/ifcedit/__init__.py @@ -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 +# +# 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 . + +__version__ = version = "0.0.0" diff --git a/src/ifcedit/ifcedit/__main__.py b/src/ifcedit/ifcedit/__main__.py new file mode 100644 index 0000000000..7c4e4c642e --- /dev/null +++ b/src/ifcedit/ifcedit/__main__.py @@ -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 +# +# 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 . + +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() diff --git a/src/ifcedit/ifcedit/coerce.py b/src/ifcedit/ifcedit/coerce.py new file mode 100644 index 0000000000..5a410ec186 --- /dev/null +++ b/src/ifcedit/ifcedit/coerce.py @@ -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 +# +# 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 . + +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()] diff --git a/src/ifcedit/ifcedit/discover.py b/src/ifcedit/ifcedit/discover.py new file mode 100644 index 0000000000..b26a93f6c6 --- /dev/null +++ b/src/ifcedit/ifcedit/discover.py @@ -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 +# +# 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 . + +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()) diff --git a/src/ifcedit/ifcedit/quantify.py b/src/ifcedit/ifcedit/quantify.py new file mode 100644 index 0000000000..f85475e22c --- /dev/null +++ b/src/ifcedit/ifcedit/quantify.py @@ -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)} diff --git a/src/ifcedit/ifcedit/run.py b/src/ifcedit/ifcedit/run.py new file mode 100644 index 0000000000..91d8be6774 --- /dev/null +++ b/src/ifcedit/ifcedit/run.py @@ -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 +# +# 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 . + +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 diff --git a/src/ifcedit/pyproject.toml b/src/ifcedit/pyproject.toml new file mode 100644 index 0000000000..f50c0b3cc4 --- /dev/null +++ b/src/ifcedit/pyproject.toml @@ -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" diff --git a/src/ifcedit/tests/__init__.py b/src/ifcedit/tests/__init__.py new file mode 100644 index 0000000000..0a3bc271a0 --- /dev/null +++ b/src/ifcedit/tests/__init__.py @@ -0,0 +1 @@ +# This file was generated with the assistance of an AI coding tool. diff --git a/src/ifcedit/tests/conftest.py b/src/ifcedit/tests/conftest.py new file mode 100644 index 0000000000..241220b145 --- /dev/null +++ b/src/ifcedit/tests/conftest.py @@ -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) diff --git a/src/ifcedit/tests/test_coerce.py b/src/ifcedit/tests/test_coerce.py new file mode 100644 index 0000000000..4ee6895717 --- /dev/null +++ b/src/ifcedit/tests/test_coerce.py @@ -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" diff --git a/src/ifcedit/tests/test_discover.py b/src/ifcedit/tests/test_discover.py new file mode 100644 index 0000000000..7b77cb5b7e --- /dev/null +++ b/src/ifcedit/tests/test_discover.py @@ -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 diff --git a/src/ifcedit/tests/test_main.py b/src/ifcedit/tests/test_main.py new file mode 100644 index 0000000000..626351a5a3 --- /dev/null +++ b/src/ifcedit/tests/test_main.py @@ -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 diff --git a/src/ifcedit/tests/test_quantify.py b/src/ifcedit/tests/test_quantify.py new file mode 100644 index 0000000000..f4335fb2a7 --- /dev/null +++ b/src/ifcedit/tests/test_quantify.py @@ -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 diff --git a/src/ifcedit/tests/test_run.py b/src/ifcedit/tests/test_run.py new file mode 100644 index 0000000000..80881e879f --- /dev/null +++ b/src/ifcedit/tests/test_run.py @@ -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"}