diff --git a/src/ifcopenshell-python/docs/ifcpatch.rst b/src/ifcopenshell-python/docs/ifcpatch.rst index 8e995ac348..a49d71b82a 100644 --- a/src/ifcopenshell-python/docs/ifcpatch.rst +++ b/src/ifcopenshell-python/docs/ifcpatch.rst @@ -57,6 +57,19 @@ example, we'll extract out all `IfcWall` elements. ifcpatch -i input.ifc -o output.ifc -r ExtractElements -a "IfcWall" cat output.ifc +You can also alias it to a command: + +.. code-block:: bash + + alias ifcpatch='python -m ifcpatch' + +Alternatively, you can package it as an executable. + +.. code-block:: bash + + python make.py + ./dist/ifcpatch + Here is a minimal example of how to use IfcPatch as a library: .. code-block:: python @@ -71,18 +84,22 @@ Here is a minimal example of how to use IfcPatch as a library: }) ifcpatch.write(output, "output.ifc") -You can also alias it to a command: -.. code-block:: bash +Alternatively, there is a less dynamic way to use IfcPatch +that allows seeing available arguments, their descriptions, types, default values, etc. - alias ifcpatch='python -m ifcpatch' +..code-block:: python -Alternatively, you can package it as an executable. + import ifcopenshell + import ifcpatch + from ifcpatch.recipes import ExtractElements -.. code-block:: bash - - python make.py - ./dist/ifcpatch + patcher = ExtractElements.Patcher( + ifcopenshell.open("input.ifc"), + query="IfcWall", + ) + patcher.patch() + ifcpatch.write(patcher.get_output(), "output.ifc") Patch recipes ------------- diff --git a/src/ifcpatch/ifcpatch/__init__.py b/src/ifcpatch/ifcpatch/__init__.py index 3b102af0b2..211041cd87 100644 --- a/src/ifcpatch/ifcpatch/__init__.py +++ b/src/ifcpatch/ifcpatch/__init__.py @@ -45,7 +45,27 @@ class ArgumentsDict(TypedDict): arguments: NotRequired[Sequence[Any]] -def execute(args: ArgumentsDict) -> Union[ifcopenshell.file, str]: +class BasePatcher: + def __init__(self, file: ifcopenshell.file, logger: Union[logging.Logger, None]): + self.file = file + self.logger = ensure_logger(logger) + + def patch(self) -> None: + raise NotImplementedError + + def get_output(self) -> Union[ifcopenshell.file, str, None]: + if hasattr(self, "file_patched"): + return self.file_patched # pyright: ignore[reportAttributeAccessIssue] + return self.file + + +def ensure_logger(logger: Union[logging.Logger, None] = None) -> logging.Logger: + if logger is not None: + return logger + return logging.getLogger("IFCPatch") + + +def execute(args: ArgumentsDict) -> Union[ifcopenshell.file, str, None]: """Execute a patch recipe The details of how the patch recipe is executed depends on the definition of @@ -88,7 +108,7 @@ def execute(args: ArgumentsDict) -> Union[ifcopenshell.file, str]: """ if "log" in args: logging.basicConfig(filename=args["log"], filemode="a", level=logging.DEBUG) - logger = logging.getLogger("IFCPatch") + logger = ensure_logger() if recipe_dir := os.environ.get("IFCPATCH_RECIPE_DIR"): spec = importlib.util.spec_from_file_location(args["recipe"], os.path.join(recipe_dir, args["recipe"] + ".py")) recipe = importlib.util.module_from_spec(spec) @@ -103,17 +123,17 @@ def execute(args: ArgumentsDict) -> Union[ifcopenshell.file, str]: else: patcher = recipe.Patcher(args.get("file"), logger, arguments) patcher.patch() - output = getattr(patcher, "file_patched", patcher.file) + output = BasePatcher.get_output(patcher) return output -def write(output: Union[ifcopenshell.file, str], filepath: Union[Path, str]) -> None: +def write(output: Union[ifcopenshell.file, str, None], filepath: Union[Path, str]) -> None: """Write the output of an IFC patch to a file Typically a patch output would be a patched IFC model file object, or as a string. This function lets you agnostically write that output to a filepath. - :param output: The results from ifcpatch.execute() + :param output: The results from ``ifcpatch.execute()`` / ``Patcher.get_output()`` :param filepath: A filepath to where the results of the patched model should be written to. :return: None diff --git a/src/ifcpatch/ifcpatch/recipes/ConvertLengthUnit.py b/src/ifcpatch/ifcpatch/recipes/ConvertLengthUnit.py index 7560d7abe5..e82f12c281 100644 --- a/src/ifcpatch/ifcpatch/recipes/ConvertLengthUnit.py +++ b/src/ifcpatch/ifcpatch/recipes/ConvertLengthUnit.py @@ -22,9 +22,11 @@ import ifcopenshell.api.owner.settings import ifcopenshell.util.pset import ifcopenshell.util.element import ifcopenshell.util.unit +import ifcpatch from logging import Logger import typing +from typing import Union LengthUnit = typing.Literal[ "ATTOMETER", @@ -50,11 +52,11 @@ LengthUnit = typing.Literal[ ] -class Patcher: +class Patcher(ifcpatch.BasePatcher): def __init__( self, file: ifcopenshell.file, - logger: Logger, + logger: Union[Logger, None] = None, unit: LengthUnit = "METER", ): """Converts the length unit of a model to the specified unit @@ -74,8 +76,7 @@ class Patcher: # Convert to feet model = ifcpatch.execute({"input": "input.ifc", "file": model, "recipe": "ConvertLengthUnit", "arguments": ["FOOT"]}) """ - self.file = file - self.logger = logger + super().__init__(file, logger) self.unit = unit self.file_patched: ifcopenshell.file diff --git a/src/ifcpatch/ifcpatch/recipes/ExtractElements.py b/src/ifcpatch/ifcpatch/recipes/ExtractElements.py index b1fee6db2f..6f70a410b3 100644 --- a/src/ifcpatch/ifcpatch/recipes/ExtractElements.py +++ b/src/ifcpatch/ifcpatch/recipes/ExtractElements.py @@ -21,15 +21,16 @@ import ifcopenshell.api import ifcopenshell.api.project import ifcopenshell.guid import ifcopenshell.util.selector +import ifcpatch from typing import Union from logging import Logger -class Patcher: +class Patcher(ifcpatch.BasePatcher): def __init__( self, file: ifcopenshell.file, - logger: Logger, + logger: Union[Logger, None] = None, query: str = "IfcWall", assume_asset_uniqueness_by_name: bool = True, ): @@ -62,8 +63,7 @@ class Patcher: # Extract all walls and slabs ifcpatch.execute({"input": "input.ifc", "file": model, "recipe": "ExtractElements", "arguments": ["IfcWall, IfcSlab"]}) """ - self.file = file - self.logger = logger + super().__init__(file, logger) self.query = query self.assume_asset_uniqueness_by_name = assume_asset_uniqueness_by_name diff --git a/src/ifcpatch/ifcpatch/recipes/Ifc2Sql.py b/src/ifcpatch/ifcpatch/recipes/Ifc2Sql.py index 966986841e..413af06416 100644 --- a/src/ifcpatch/ifcpatch/recipes/Ifc2Sql.py +++ b/src/ifcpatch/ifcpatch/recipes/Ifc2Sql.py @@ -36,6 +36,7 @@ import ifcopenshell.util.placement import ifcopenshell.util.schema import ifcopenshell.util.shape import ifcopenshell.util.unit +import ifcpatch from pathlib import Path from typing import Any, TYPE_CHECKING, Literal, Union from typing_extensions import assert_never @@ -61,11 +62,11 @@ else: DEFAULT_DATABASE_NAME = "database" -class Patcher: +class Patcher(ifcpatch.BasePatcher): def __init__( self, file: ifcopenshell.file, - logger: logging.Logger, + logger: Union[logging.Logger, None] = None, sql_type: SQLTypes = "SQLite", host: str = "localhost", username: str = "root", @@ -116,7 +117,7 @@ class Patcher: {"input": "input.ifc", "file": model, "recipe": "Ifc2Sql", "arguments": ["sqlite"]} ) """ - self.file = file + super().__init__(file, logger) self.logger = logger self.sql_type: Literal["sqlite", "mysql"] = sql_type.lower() self.host = host diff --git a/src/ifcpatch/ifcpatch/recipes/Migrate.py b/src/ifcpatch/ifcpatch/recipes/Migrate.py index 80ea9e4020..9f330e9016 100644 --- a/src/ifcpatch/ifcpatch/recipes/Migrate.py +++ b/src/ifcpatch/ifcpatch/recipes/Migrate.py @@ -18,15 +18,17 @@ import ifcopenshell import ifcopenshell.util.schema +import ifcpatch import typing +from typing import Union from logging import Logger -class Patcher: +class Patcher(ifcpatch.BasePatcher): def __init__( self, file: ifcopenshell.file, - logger: Logger, + logger: Union[Logger, None] = None, schema: ifcopenshell.util.schema.IFC_SCHEMA = "IFC4", ): """Migrate from one IFC version to another @@ -43,8 +45,7 @@ class Patcher: # Upgrade an IFC2X3 model to IFC4 ifcpatch.execute({"input": "input.ifc", "file": model, "recipe": "Migrate", "arguments": ["IFC4"]}) """ - self.file = file - self.logger = logger + super().__init__(file, logger) self.schema = schema def patch(self): diff --git a/src/ifcpatch/test/test_ifcpatch.py b/src/ifcpatch/test/test_ifcpatch.py index 53013cffe5..90ff3937fa 100644 --- a/src/ifcpatch/test/test_ifcpatch.py +++ b/src/ifcpatch/test/test_ifcpatch.py @@ -16,6 +16,8 @@ # You should have received a copy of the GNU Lesser General Public License # along with IfcOpenShell. If not, see . +import tempfile +import ifcopenshell.api.root import ifcpatch from pathlib import Path @@ -32,3 +34,26 @@ class Test: expected_keys = ("class_", "description", "output", "inputs") for key in expected_keys: assert key in docs + + def test_static_ifcpatch_execution(self): + from ifcpatch.recipes import ExtractElements + + ifc_file = ifcopenshell.file() + project = ifcopenshell.api.root.create_entity(ifc_file, ifc_class="IfcProject") + wall = ifcopenshell.api.root.create_entity(ifc_file, ifc_class="IfcWall") + + patcher = ExtractElements.Patcher(ifc_file, query="IfcWall") + patcher.patch() + + output = patcher.get_output() + assert isinstance(output, ifcopenshell.file) + assert output.by_type("IfcProject")[0].GlobalId == project.GlobalId + assert output.by_type("IfcWall")[0].GlobalId == wall.GlobalId + + output_path = Path(tempfile.mktemp()) + try: + assert not output_path.exists() + ifcpatch.write(patcher.get_output(), output_path) + assert output_path.exists() + finally: + output_path.unlink()