diff --git a/.editorconfig b/.editorconfig index 2f1d858..fe4eb9e 100644 --- a/.editorconfig +++ b/.editorconfig @@ -29,3 +29,6 @@ indent_style = tab [{*.yml,*.yaml}] indent_size = 2 + +[*.json] +indent_size = 2 \ No newline at end of file diff --git a/__init__.py b/__init__.py index d061182..43745b6 100644 --- a/__init__.py +++ b/__init__.py @@ -2,7 +2,16 @@ # Copyright (c) 2025 The Foundry Visionmongers Ltd # SPDX-License-Identifier: Apache-2.0 -"""Top-level package for openassetio-comfyui.""" +""" +Top-level package for openassetio-comfyui. + +This __init__.py will not be packaged, but is useful if the repository +is checked out under ComfyUI's custom_nodes directory, as it allows +ComfyUI to discover the nodes contained within. +""" + +import sys +import pathlib __all__ = [ "NODE_CLASS_MAPPINGS", @@ -13,5 +22,8 @@ __author__ = """Contributors to the OpenAssetIO project""" __email__ = "openassetio-discussion@lists.aswf.io" __version__ = "1.0.0" -from .src.openassetio_comfyui.nodes import NODE_CLASS_MAPPINGS -from .src.openassetio_comfyui.nodes import NODE_DISPLAY_NAME_MAPPINGS +# Ensure src/ is on the path so we can import from there. +sys.path.append(str(pathlib.Path(__file__).parent / "src")) + +from openassetio_comfyui.nodes import NODE_CLASS_MAPPINGS +from openassetio_comfyui.nodes import NODE_DISPLAY_NAME_MAPPINGS diff --git a/pyproject.toml b/pyproject.toml index a827261..bb82c15 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,8 +17,10 @@ readme = "README.md" license = { text = "Apache Software License 2.0" } requires-python = ">=3.10,<3.12" classifiers = [] -dependencies = [] - +dependencies = [ + "openassetio>=1.0.0", + "openassetio-mediacreation>=1.0.0a12" +] [project.optional-dependencies] dev = [ @@ -28,6 +30,7 @@ dev = [ "pre-commit", # runs linting on commit "pytest", # testing "ruff", # linting + "openassetio-manager-bal" # mock manager for testing ] [project.urls] @@ -77,12 +80,37 @@ target-version = "py311" # Add rules to ban exec/eval [tool.ruff.lint] select = [ + # See all rules here: https://docs.astral.sh/ruff/rules "S102", # exec-builtin "S307", # eval-used "W293", - "F", # The "F" series in Ruff stands for "Pyflakes" rules, which catch various Python syntax errors and undefined names. - # See all rules here: https://docs.astral.sh/ruff/rules/#pyflakes-f + # The "F" series in Ruff stands for "Pyflakes" rules, which catch + # various Python syntax errors and undefined names. + "F", + # pydocstyle + "D", + # pycodestyle + "E", "W", + # pylint + "PL" +] + +ignore = [ + # "One-line docstring should fit on one line" - i.e. expects + # """summary""", not """\nsummary\n""", which is inconsistent with + # other OpenAssetIO projects. + "D200", + # "1 blank line required between summary line and description" - + # triggers if the summary spans more than one line. + "D205" ] [tool.ruff.lint.flake8-quotes] inline-quotes = "double" + +[tool.ruff.lint.pydocstyle] +convention = "pep257" + +[tool.ruff.lint.pycodestyle] +max-doc-length = 72 +max-line-length = 99 diff --git a/src/openassetio_comfyui/nodes.py b/src/openassetio_comfyui/nodes.py index 6720fe1..9ab92f7 100644 --- a/src/openassetio_comfyui/nodes.py +++ b/src/openassetio_comfyui/nodes.py @@ -1,131 +1,265 @@ # openassetio-comfyui # Copyright (c) 2025 The Foundry Visionmongers Ltd # SPDX-License-Identifier: Apache-2.0 +""" +OpenAssetIO nodes for ComfyUI. -from inspect import cleandoc +Also contains a singleton class representing the OpenAssetIO host +application. +""" + +import hashlib +import logging + +import torch +import numpy as np + +from PIL import Image, ImageOps, ImageSequence + +from openassetio import EntityReference +from openassetio.utils import FileUrlPathConverter +from openassetio.log import LoggerInterface +from openassetio.access import ResolveAccess +from openassetio.hostApi import ManagerFactory, HostInterface +from openassetio.pluginSystem import ( + HybridPluginSystemManagerImplementationFactory, + PythonPluginSystemManagerImplementationFactory, + CppPluginSystemManagerImplementationFactory, +) +from openassetio_mediacreation.traits.content import LocatableContentTrait + +import node_helpers -class Example: +class _OpenAssetIOHost: """ - A example node + Singleton class representing the OpenAssetIO host. - Class methods - ------------- - INPUT_TYPES (dict): - Tell the main program input parameters of nodes. - IS_CHANGED: - optional method to control when the node is re executed. + Instantiates and exposes a manager and context, and provides common + utility methods. - Attributes - ---------- - RETURN_TYPES (`tuple`): - The type of each element in the output tulple. - RETURN_NAMES (`tuple`): - Optional: The name of each output in the output tulple. - FUNCTION (`str`): - The name of the entry-point method. For example, if `FUNCTION = "execute"` then it will run Example().execute() - OUTPUT_NODE ([`bool`]): - If this node is an output node that outputs a result/image from the graph. The SaveImage node is an example. - The backend iterates on these output nodes and tries to execute all their parents if their parent graph is properly connected. - Assumed to be False if not present. - CATEGORY (`str`): - The category the node should appear in the UI. - execute(s) -> tuple || None: - The entry point method. The name of this method must be the same as the value of property `FUNCTION`. - For example, if `FUNCTION = "execute"` then this method's name must be `execute`, if `FUNCTION = "foo"` then it must be `foo`. + The OpenAssetIO manager must be configured via a config file, + located via the OPENASSETIO_DEFAULT_CONFIG environment variable. See + the OpenAssetIO documentation for more details. """ - def __init__(self): - pass + _instance = None @classmethod - def INPUT_TYPES(s): + def instance(cls) -> "_OpenAssetIOHost": """ - Return a dictionary which contains config for all input fields. - Some types (string): "MODEL", "VAE", "CLIP", "CONDITIONING", "LATENT", "IMAGE", "INT", "STRING", "FLOAT". - Input types "INT", "STRING" or "FLOAT" are special values for fields on the node. - The type can be a list for selection. + Get or create the singleton instance of the OpenAssetIO host. + """ + if cls._instance is None: + cls._instance = cls() + return cls._instance - Returns: `dict`: - - Key input_fields_group (`string`): Can be either required, hidden or optional. A node class must have property `required` - - Value input_fields (`dict`): Contains input fields config: - * Key field_name (`string`): Name of a entry-point method's argument - * Value field_config (`tuple`): - + First value is a string indicate the type of field or a list for selection. - + Secound value is a config for type "INT", "STRING" or "FLOAT". + class _HostInterface(HostInterface): + def displayName(self) -> str: + return "ComfyUI" + + def identifier(self) -> str: + return "org.foundry.comfyui" + + def info(self) -> dict: + return super().info() + + class _LoggerInterface(LoggerInterface): + def __init__(self): + super().__init__() + self.__logger = logging.getLogger("openassetio-comfyui") + + def log(self, severity, message) -> None: + if severity == LoggerInterface.Severity.kDebug: + self.__logger.debug(message) + elif severity == LoggerInterface.Severity.kInfo: + self.__logger.info(message) + elif severity == LoggerInterface.Severity.kWarning: + self.__logger.warning(message) + elif severity == LoggerInterface.Severity.kError: + self.__logger.error(message) + elif severity == LoggerInterface.Severity.kCritical: + self.__logger.critical(message) + else: + self.__logger.log(logging.NOTSET, message) + + def __init__(self): + """ + Load and initialise an OpenAssetIO manager plugin. + """ + self.__logger = self._LoggerInterface() + # Initialise plugin system, then find and load a manager. + self.manager = ManagerFactory.defaultManagerForInterface( + self._HostInterface(), + HybridPluginSystemManagerImplementationFactory( + # Prefer C++ over Python plugins/methods. + [ + CppPluginSystemManagerImplementationFactory(self.__logger), + PythonPluginSystemManagerImplementationFactory(self.__logger), + ], + self.__logger, + ), + self.__logger, + ) + if self.manager is None: + raise RuntimeError( + "Could not create an OpenAssetIO manager instance. Ensure that your OpenAssetIO" + " configuration is correct and that the environment variable" + " OPENASSETIO_DEFAULT_CONFIG is set to a valid configuration file." + ) + self.__context = self.manager.createContext() + self.__file_url_path_converter = FileUrlPathConverter() + + def resolve_to_path(self, entity_reference: str) -> str: + """ + Resolve an OpenAssetIO entity reference to a local file path. + + OpenAssetIO will raise exceptions if the entity reference is + invalid, if there is a problem resolving the entity, or if the + given entity location is not a `file://` URL. + + @throw RuntimeError if the entity reference resolves + successfully, but the entity has no location. + """ + entity_reference = self.manager.createEntityReference(entity_reference) + + traits_data = self.manager.resolve( + entity_reference, {LocatableContentTrait.kId}, ResolveAccess.kRead, self.__context + ) + locatable_content_trait = LocatableContentTrait(traits_data) + url = locatable_content_trait.getLocation() + + if url is None: + raise RuntimeError( + f"Failed to resolve entity reference '{entity_reference}' to a location" + ) + + return self.__file_url_path_converter.pathFromUrl(url) + + +class ResolveImage: + """ + Node to resolve an image from an OpenAssetIO entity reference. + + The non-OpenAssetIO logic is largely duplicated from the built-in + ComfyUI LoadImage node (at least as of 4f5812b9). + """ + + # Tooltip to display when hovering over the node. + DESCRIPTION = "Resolve images from an asset manager." + # Menu category. + CATEGORY = "image" + # Function to call when node is executed. + FUNCTION = "resolve_image" + # Node outputs. + RETURN_TYPES = ("IMAGE", "MASK") + + @classmethod + def INPUT_TYPES(cls) -> dict: + """ + Input sockets and widgets. """ return { "required": { - "image": ("Image", {"tooltip": "This is an image"}), - "int_field": ( - "INT", - { - "default": 0, - "min": 0, # Minimum value - "max": 4096, # Maximum value - "step": 64, # Slider's step - "display": "number", # Cosmetic only: display as "number" or "slider" - }, - ), - "float_field": ( - "FLOAT", - { - "default": 1.0, - "min": 0.0, - "max": 10.0, - "step": 0.01, - "round": 0.001, # The value represeting the precision to round to, will be set to the step value by default. Can be set to False to disable rounding. - "display": "number", - }, - ), - "print_to_screen": (["enable", "disable"],), - "string_field": ( + "entity_reference": ( "STRING", { - "multiline": False, # True if you want the field to look like the one on the ClipTextEncode node - "default": "Hello World!", + "multiline": False, + "default": "", + "placeholder": "Entity reference...", + "tooltip": "The entity to resolve", }, - ), - }, + ) + } } - RETURN_TYPES = ("IMAGE",) - # RETURN_NAMES = ("image_output_name",) - DESCRIPTION = cleandoc(__doc__) - FUNCTION = "test" + @classmethod + def IS_CHANGED(cls, entity_reference: str) -> str: + """ + Resolve the entity reference to a file path, then return a hash + of the file contents. - # OUTPUT_NODE = False - # OUTPUT_TOOLTIPS = ("",) # Tooltips for the output node + This allows ComfyUI to determine if the node needs to be + re-executed. - CATEGORY = "Example" + Non-OpenAssetIO logic largely duplicated from the built-in + ComfyUI LoadImage node (at least as of 4f5812b9). + """ + image_path = _OpenAssetIOHost.instance().resolve_to_path(entity_reference) + m = hashlib.sha256() + with open(image_path, "rb") as f: + m.update(f.read()) + return m.digest().hex() - def test(self, image, string_field, int_field, float_field, print_to_screen): - if print_to_screen == "enable": - print(f"""Your input contains: - string_field aka input text: {string_field} - int_field: {int_field} - float_field: {float_field} - """) - # do some processing on the image, in this example I just invert it - image = 1.0 - image - return (image,) + @classmethod + def VALIDATE_INPUTS(cls, entity_reference: str) -> bool: + """ + Validate that the input entity reference is valid syntax for the + current manager. + """ + return _OpenAssetIOHost.instance().manager.isEntityReferenceString(entity_reference) - """ - The node will always be re executed if any of the inputs change but - this method can be used to force the node to execute again even when the inputs don't change. - You can make this node return a number or a string. This value will be compared to the one returned the last time the node was - executed, if it is different the node will be executed again. - This method is used in the core repo for the LoadImage node where they return the image hash as a string, if the image hash - changes between executions the LoadImage node is executed again. - """ - # @classmethod - # def IS_CHANGED(s, image, string_field, int_field, float_field, print_to_screen): - # return "" + def resolve_image(self, entity_reference: str) -> tuple[torch.Tensor, torch.Tensor]: + """ + Resolve an OpenAssetIO entity reference to a file path, then + load the image(s) at that path and return as tensors. + + Non-OpenAssetIO logic largely duplicated from the built-in + ComfyUI LoadImage node (at least as of 4f5812b9). + """ + image_path = _OpenAssetIOHost.instance().resolve_to_path(entity_reference) + + sequence = node_helpers.pillow(Image.open, image_path) + + output_images = [] + output_masks = [] + w, h = None, None + + excluded_formats = ["MPO"] + + for frame in ImageSequence.Iterator(sequence): + frame = node_helpers.pillow(ImageOps.exif_transpose, frame) # noqa: PLW2901 - frame overwrite + + if frame.mode == "I": + frame = frame.point(lambda px: px * (1 / 255)) # noqa: PLW2901 - frame overwrite + image = frame.convert("RGB") + + if len(output_images) == 0: + w = image.size[0] + h = image.size[1] + + if image.size[0] != w or image.size[1] != h: + continue + + image = np.array(image).astype(np.float32) / 255.0 + image = torch.from_numpy(image)[None,] + if "A" in frame.getbands(): + mask = np.array(frame.getchannel("A")).astype(np.float32) / 255.0 + mask = 1.0 - torch.from_numpy(mask) + elif frame.mode == "P" and "transparency" in frame.info: + mask = np.array(frame.convert("RGBA").getchannel("A")).astype(np.float32) / 255.0 + mask = 1.0 - torch.from_numpy(mask) + else: + mask = torch.zeros((64, 64), dtype=torch.float32, device="cpu") + output_images.append(image) + output_masks.append(mask.unsqueeze(0)) + + if len(output_images) > 1 and sequence.format not in excluded_formats: + output_image = torch.cat(output_images, dim=0) + output_mask = torch.cat(output_masks, dim=0) + else: + output_image = output_images[0] + output_mask = output_masks[0] + + return output_image, output_mask -# A dictionary that contains all nodes you want to export with their names -# NOTE: names should be globally unique -NODE_CLASS_MAPPINGS = {"Example": Example} +# Plugin registration: node classes. +NODE_CLASS_MAPPINGS = { + "OpenAssetIOResolveImage": ResolveImage, +} -# A dictionary that contains the friendly/humanly readable titles for the nodes -NODE_DISPLAY_NAME_MAPPINGS = {"Example": "Example Node"} +# Plugin registration: node names. +NODE_DISPLAY_NAME_MAPPINGS = { + "OpenAssetIOResolveImage": "OpenAssetIO Resolve Image", +} diff --git a/tests/__init__.py b/tests/__init__.py index 80e43d3..5b381ee 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1,5 +1,6 @@ # openassetio-comfyui # Copyright (c) 2025 The Foundry Visionmongers Ltd # SPDX-License-Identifier: Apache-2.0 - -"""Unit test package for openassetio-comfyui""" +""" +Unit test package for openassetio-comfyui. +""" diff --git a/tests/conftest.py b/tests/conftest.py deleted file mode 100644 index ed9c8f5..0000000 --- a/tests/conftest.py +++ /dev/null @@ -1,10 +0,0 @@ -# openassetio-comfyui -# Copyright (c) 2025 The Foundry Visionmongers Ltd -# SPDX-License-Identifier: Apache-2.0 - -import os -import sys - -# Add the project root directory to Python path -# This allows the tests to import the project -sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) diff --git a/tests/pytest.ini b/tests/pytest.ini deleted file mode 100644 index 706fd53..0000000 --- a/tests/pytest.ini +++ /dev/null @@ -1,8 +0,0 @@ -# openassetio-comfyui -# Copyright (c) 2025 The Foundry Visionmongers Ltd -# SPDX-License-Identifier: Apache-2.0 - -[pytest] -testpaths = . # Run tests in the current directory -python_files = test_*.py # Run tests in files that start with "test_" -norecursedirs = .. # Don't run tests in the parent directory diff --git a/tests/resources/bal_db.json b/tests/resources/bal_db.json new file mode 100644 index 0000000..eaa6fb8 --- /dev/null +++ b/tests/resources/bal_db.json @@ -0,0 +1,27 @@ +{ + "entities": { + "image_file": { + "versions": [ + { + "traits": { + "openassetio-mediacreation:usage.Entity": {}, + "openassetio-mediacreation:twoDimensional.Image": {}, + "openassetio-mediacreation:content.LocatableContent": { + "location": "file://${test_tmp_dir}/cat.png" + } + } + } + ] + }, + "no_file": { + "versions": [ + { + "traits": { + "openassetio-mediacreation:usage.Entity": {}, + "openassetio-mediacreation:twoDimensional.Image": {} + } + } + ] + } + } +} diff --git a/tests/resources/openassetio_config.toml b/tests/resources/openassetio_config.toml new file mode 100644 index 0000000..dc9d08c --- /dev/null +++ b/tests/resources/openassetio_config.toml @@ -0,0 +1,10 @@ +# openassetio-comfyui +# SPDX-License-Identifier: Apache-2.0 +# Copyright 2025 The Foundry Visionmongers Ltd + +[manager] +identifier = "org.openassetio.examples.manager.bal" + +[manager.settings] +library_path = "${config_dir}/bal_db.json" +simulated_query_latency_ms = 0 diff --git a/tests/test_openassetio_comfyui.py b/tests/test_openassetio_comfyui.py index d89e69d..7cf0bf6 100644 --- a/tests/test_openassetio_comfyui.py +++ b/tests/test_openassetio_comfyui.py @@ -1,27 +1,218 @@ -#!/usr/bin/env python # openassetio-comfyui # Copyright (c) 2025 The Foundry Visionmongers Ltd # SPDX-License-Identifier: Apache-2.0 +""" +Tests for `openassetio-comfyui` package. +""" -"""Tests for `openassetio-comfyui` package.""" +import contextlib +import hashlib +# D101: "Missing docstring in public class" +# D102: "Missing docstring in public method" +# ruff: noqa: D101,D102 +import inspect +import pathlib + +import numpy as np import pytest -from src.openassetio_comfyui.nodes import Example +import torch +from PIL import Image + +from openassetio_comfyui.nodes import ResolveImage, _OpenAssetIOHost + + +class Test_ResolveImage_init: + """ + Test that the node can be instantiated. + """ + + def test_has_correct_type(self, resolve_image_node): + assert isinstance(resolve_image_node, ResolveImage) + + +class Test_ResolveImage_constants: + """ + Test the node's metadata. + """ + + def test_has_expected_values(self): + assert ResolveImage.DESCRIPTION == "Resolve images from an asset manager." + assert ResolveImage.CATEGORY == "image" + assert ResolveImage.FUNCTION == "resolve_image" + assert ResolveImage.RETURN_TYPES == ("IMAGE", "MASK") + + def test_function_matches(self): + assert inspect.isfunction(getattr(ResolveImage, ResolveImage.FUNCTION)) + + +class Test_ResolveImage_INPUT_TYPES: + def test_has_expected_structure(self): + input_types = ResolveImage.INPUT_TYPES() + + assert input_types == { + "required": { + "entity_reference": ( + "STRING", + { + "multiline": False, + "placeholder": "Entity reference...", + "default": "", + "tooltip": "The entity to resolve", + }, + ) + } + } + + +class Test_ResolveImage_VALIDATE_INPUTS: + def test_when_is_a_reference_then_returns_true(self): + valid = ResolveImage.VALIDATE_INPUTS("bal:///") + assert valid + + def test_when_not_a_reference_then_returns_false(self): + valid = ResolveImage.VALIDATE_INPUTS("notbal:///") + assert not valid + + def test_when_no_manager_found_then_raises(self, assert_raises_missing_manager): + with assert_raises_missing_manager(): + ResolveImage.VALIDATE_INPUTS("bal:///") + + +class Test_ResolveImage_IS_CHANGED: + def test_when_reference_resolves_to_file_then_hash_matches_file(self, image_file_hash): + actual_file_hash = ResolveImage.IS_CHANGED("bal:///image_file") + + assert actual_file_hash == image_file_hash + + def test_when_reference_has_no_path_then_raises(self, image_file_hash): + expected_error_message = ( + "Failed to resolve entity reference 'bal:///no_file' to a location" + ) + + with pytest.raises(RuntimeError, match=expected_error_message): + ResolveImage.IS_CHANGED("bal:///no_file") + + def test_when_no_manager_found_then_raises(self, assert_raises_missing_manager): + with assert_raises_missing_manager(): + ResolveImage.IS_CHANGED("bal:///image_file") + + +class Test_ResolveImage_resolve_image: + def test_when_reference_resolves_to_file_then_returns_image( + self, resolve_image_node, image_file_path + ): + images, masks = resolve_image_node.resolve_image("bal:///image_file") + + assert isinstance(images, torch.Tensor) + assert images.shape == (1, 2, 2, 3) + assert isinstance(masks, torch.Tensor) + assert masks.shape == (1, 2, 2) + + image = images[0] + mask = masks[0] + + # Assert channels at each pixel. + assert torch.equal(image[0, 0], torch.tensor([0, 0, 0])) + assert torch.equal(image[0, 1], torch.tensor([1, 1, 1])) + assert torch.equal(image[1, 0], torch.tensor([0, 0, 0])) + assert torch.equal(image[1, 1], torch.tensor([1, 1, 1])) + + # Note: mask is `1 - alpha`. + assert mask[0, 0].item() == 0 + assert mask[0, 1].item() == 1 + assert mask[1, 0].item() == 1 + assert mask[1, 1].item() == 0 + + def test_when_no_manager_found_then_raises( + self, resolve_image_node, assert_raises_missing_manager + ): + with assert_raises_missing_manager(): + resolve_image_node.resolve_image("bal:///image_file") @pytest.fixture -def example_node(): - """Fixture to create an Example node instance.""" - return Example() +def assert_raises_missing_manager(monkeypatch): + """ + Fixture to assert that a RuntimeError is raised when no manager can + be created. + + Since a lazily-created singleton is used to manage the OpenAssetIO + host, this assertion must be tested for every function that uses + that singleton. + """ + + @contextlib.contextmanager + def assert_ctx(): + monkeypatch.delenv("OPENASSETIO_DEFAULT_CONFIG", raising=False) + expected_error_msg = ( + "Could not create an OpenAssetIO manager instance. Ensure that your OpenAssetIO" + " configuration is correct and that the environment variable" + " OPENASSETIO_DEFAULT_CONFIG is set to a valid configuration file." + ) + with pytest.raises(RuntimeError, match=expected_error_msg): + yield + + return assert_ctx -def test_example_node_initialization(example_node): - """Test that the node can be instantiated.""" - assert isinstance(example_node, Example) +@pytest.fixture +def image_file_hash(image_file_path): + """ + Fixture to provide the hash of the temporary image file. + """ + hasher = hashlib.sha256() + hasher.update(image_file_path.read_bytes()) + return hasher.hexdigest() -def test_return_types(): - """Test the node's metadata.""" - assert Example.RETURN_TYPES == ("IMAGE",) - assert Example.FUNCTION == "test" - assert Example.CATEGORY == "Example" +@pytest.fixture +def image_file_path(monkeypatch, tmp_path): + """ + Fixture to provide a temporary image file. + """ + # See bal_db.json - the resolved path will interpolate this env var. + monkeypatch.setenv("test_tmp_dir", str(tmp_path)) + # See bal_db.json - the resolved path for bal:///cat + file_path = pathlib.Path(tmp_path) / "cat.png" + + content = np.array( + [[[0, 0, 0, 255], [255, 255, 255, 0]], [[0, 0, 0, 0], [255, 255, 255, 255]]] + ).astype(np.uint8) + img = Image.fromarray(content) + img.save(file_path) + return file_path + + +@pytest.fixture +def resolve_image_node(): + """ + Fixture to create an ResolveImage node instance. + """ + return ResolveImage() + + +@pytest.fixture(autouse=True) +def openassetio_config_env_var(monkeypatch, resources_dir): + """ + Fixture to set the OPENASSETIO_DEFAULT_CONFIG environment variable + to point to a test config file. + """ + test_config_path = resources_dir / "openassetio_config.toml" + monkeypatch.setenv("OPENASSETIO_DEFAULT_CONFIG", str(test_config_path)) + + +@pytest.fixture(autouse=True) +def reset_singleton_host(monkeypatch): + """ + Fixture to reset the singleton instance before each test. + """ + _OpenAssetIOHost._instance = None + + +@pytest.fixture(scope="module") +def resources_dir(): + """ + Fixture to provide the path to the resources directory. + """ + return pathlib.Path(__file__).parent / "resources"