diff --git a/openpype/hosts/max/api/lib.py b/openpype/hosts/max/api/lib.py index 8531233bb2..e2d8d9c55f 100644 --- a/openpype/hosts/max/api/lib.py +++ b/openpype/hosts/max/api/lib.py @@ -294,6 +294,37 @@ def reset_frame_range(fps: bool = True): frame_range["frameStartHandle"], frame_range["frameEndHandle"]) +def reset_unit_scale(): + """Apply the unit scale setting to 3dsMax + """ + project_name = get_current_project_name() + settings = get_project_settings(project_name).get("max") + scene_scale = settings.get("unit_scale_settings", + {}).get("scene_unit_scale") + if scene_scale: + rt.units.DisplayType = rt.Name("Metric") + rt.units.MetricType = rt.Name(scene_scale) + else: + rt.units.DisplayType = rt.Name("Generic") + + +def convert_unit_scale(): + """Convert system unit scale in 3dsMax + for fbx export + + Returns: + str: unit scale + """ + unit_scale_dict = { + "millimeters": "mm", + "centimeters": "cm", + "meters": "m", + "kilometers": "km" + } + current_unit_scale = rt.Execute("units.MetricType as string") + return unit_scale_dict[current_unit_scale] + + def set_context_setting(): """Apply the project settings from the project definition @@ -310,6 +341,7 @@ def set_context_setting(): reset_scene_resolution() reset_frame_range() reset_colorspace() + reset_unit_scale() def get_max_version(): diff --git a/openpype/hosts/max/api/menu.py b/openpype/hosts/max/api/menu.py index caaa3e3730..9bdb6bd7ce 100644 --- a/openpype/hosts/max/api/menu.py +++ b/openpype/hosts/max/api/menu.py @@ -124,6 +124,10 @@ class OpenPypeMenu(object): colorspace_action.triggered.connect(self.colorspace_callback) openpype_menu.addAction(colorspace_action) + unit_scale_action = QtWidgets.QAction("Set Unit Scale", openpype_menu) + unit_scale_action.triggered.connect(self.unit_scale_callback) + openpype_menu.addAction(unit_scale_action) + return openpype_menu def load_callback(self): @@ -157,3 +161,7 @@ class OpenPypeMenu(object): def colorspace_callback(self): """Callback to reset colorspace""" return lib.reset_colorspace() + + def unit_scale_callback(self): + """Callback to reset unit scale""" + return lib.reset_unit_scale() diff --git a/openpype/hosts/max/plugins/publish/extract_camera_fbx.py b/openpype/hosts/max/plugins/publish/extract_camera_fbx.py deleted file mode 100644 index 4b5631b05f..0000000000 --- a/openpype/hosts/max/plugins/publish/extract_camera_fbx.py +++ /dev/null @@ -1,55 +0,0 @@ -import os - -import pyblish.api -from pymxs import runtime as rt - -from openpype.hosts.max.api import maintained_selection -from openpype.pipeline import OptionalPyblishPluginMixin, publish - - -class ExtractCameraFbx(publish.Extractor, OptionalPyblishPluginMixin): - """Extract Camera with FbxExporter.""" - - order = pyblish.api.ExtractorOrder - 0.2 - label = "Extract Fbx Camera" - hosts = ["max"] - families = ["camera"] - optional = True - - def process(self, instance): - if not self.is_active(instance.data): - return - - stagingdir = self.staging_dir(instance) - filename = "{name}.fbx".format(**instance.data) - - filepath = os.path.join(stagingdir, filename) - rt.FBXExporterSetParam("Animation", True) - rt.FBXExporterSetParam("Cameras", True) - rt.FBXExporterSetParam("AxisConversionMethod", "Animation") - rt.FBXExporterSetParam("UpAxis", "Y") - rt.FBXExporterSetParam("Preserveinstances", True) - - with maintained_selection(): - # select and export - node_list = instance.data["members"] - rt.Select(node_list) - rt.ExportFile( - filepath, - rt.Name("noPrompt"), - selectedOnly=True, - using=rt.FBXEXP, - ) - - self.log.info("Performing Extraction ...") - if "representations" not in instance.data: - instance.data["representations"] = [] - - representation = { - "name": "fbx", - "ext": "fbx", - "files": filename, - "stagingDir": stagingdir, - } - instance.data["representations"].append(representation) - self.log.info(f"Extracted instance '{instance.name}' to: {filepath}") diff --git a/openpype/hosts/max/plugins/publish/extract_model_fbx.py b/openpype/hosts/max/plugins/publish/extract_fbx.py similarity index 67% rename from openpype/hosts/max/plugins/publish/extract_model_fbx.py rename to openpype/hosts/max/plugins/publish/extract_fbx.py index 6c42fd5364..7454cd08d1 100644 --- a/openpype/hosts/max/plugins/publish/extract_model_fbx.py +++ b/openpype/hosts/max/plugins/publish/extract_fbx.py @@ -3,6 +3,7 @@ import pyblish.api from openpype.pipeline import publish, OptionalPyblishPluginMixin from pymxs import runtime as rt from openpype.hosts.max.api import maintained_selection +from openpype.hosts.max.api.lib import convert_unit_scale class ExtractModelFbx(publish.Extractor, OptionalPyblishPluginMixin): @@ -23,14 +24,7 @@ class ExtractModelFbx(publish.Extractor, OptionalPyblishPluginMixin): stagingdir = self.staging_dir(instance) filename = "{name}.fbx".format(**instance.data) filepath = os.path.join(stagingdir, filename) - - rt.FBXExporterSetParam("Animation", False) - rt.FBXExporterSetParam("Cameras", False) - rt.FBXExporterSetParam("Lights", False) - rt.FBXExporterSetParam("PointCache", False) - rt.FBXExporterSetParam("AxisConversionMethod", "Animation") - rt.FBXExporterSetParam("UpAxis", "Y") - rt.FBXExporterSetParam("Preserveinstances", True) + self._set_fbx_attributes() with maintained_selection(): # select and export @@ -56,3 +50,34 @@ class ExtractModelFbx(publish.Extractor, OptionalPyblishPluginMixin): self.log.info( "Extracted instance '%s' to: %s" % (instance.name, filepath) ) + + def _set_fbx_attributes(self): + unit_scale = convert_unit_scale() + rt.FBXExporterSetParam("Animation", False) + rt.FBXExporterSetParam("Cameras", False) + rt.FBXExporterSetParam("Lights", False) + rt.FBXExporterSetParam("PointCache", False) + rt.FBXExporterSetParam("AxisConversionMethod", "Animation") + rt.FBXExporterSetParam("UpAxis", "Y") + rt.FBXExporterSetParam("Preserveinstances", True) + if unit_scale: + rt.FBXExporterSetParam("ConvertUnit", unit_scale) + + +class ExtractCameraFbx(ExtractModelFbx): + """Extract Camera with FbxExporter.""" + + order = pyblish.api.ExtractorOrder - 0.2 + label = "Extract Fbx Camera" + families = ["camera"] + optional = True + + def _set_fbx_attributes(self): + unit_scale = convert_unit_scale() + rt.FBXExporterSetParam("Animation", True) + rt.FBXExporterSetParam("Cameras", True) + rt.FBXExporterSetParam("AxisConversionMethod", "Animation") + rt.FBXExporterSetParam("UpAxis", "Y") + rt.FBXExporterSetParam("Preserveinstances", True) + if unit_scale: + rt.FBXExporterSetParam("ConvertUnit", unit_scale) diff --git a/openpype/lib/events.py b/openpype/lib/events.py index 496b765a05..774790b80a 100644 --- a/openpype/lib/events.py +++ b/openpype/lib/events.py @@ -16,6 +16,113 @@ class MissingEventSystem(Exception): pass +def _get_func_ref(func): + if inspect.ismethod(func): + return WeakMethod(func) + return weakref.ref(func) + + +def _get_func_info(func): + path = "" + if func is None: + return "", path + + if hasattr(func, "__name__"): + name = func.__name__ + else: + name = str(func) + + # Get path to file and fallback to '' if fails + # NOTE This was added because of 'partial' functions which is handled, + # but who knows what else can cause this to fail? + try: + path = os.path.abspath(inspect.getfile(func)) + except TypeError: + pass + + return name, path + + +class weakref_partial: + """Partial function with weak reference to the wrapped function. + + Can be used as 'functools.partial' but it will store weak reference to + function. That means that the function must be reference counted + to avoid garbage collecting the function itself. + + When the referenced functions is garbage collected then calling the + weakref partial (no matter the args/kwargs passed) will do nothing. + It will fail silently, returning `None`. The `is_valid()` method can + be used to detect whether the reference is still valid. + + Is useful for object methods. In that case the callback is + deregistered when object is destroyed. + + Warnings: + Values passed as *args and **kwargs are stored strongly in memory. + That may "keep alive" objects that should be already destroyed. + It is recommended to pass only immutable objects like 'str', + 'bool', 'int' etc. + + Args: + func (Callable): Function to wrap. + *args: Arguments passed to the wrapped function. + **kwargs: Keyword arguments passed to the wrapped function. + """ + + def __init__(self, func, *args, **kwargs): + self._func_ref = _get_func_ref(func) + self._args = args + self._kwargs = kwargs + + def __call__(self, *args, **kwargs): + func = self._func_ref() + if func is None: + return + + new_args = tuple(list(self._args) + list(args)) + new_kwargs = dict(self._kwargs) + new_kwargs.update(kwargs) + return func(*new_args, **new_kwargs) + + def get_func(self): + """Get wrapped function. + + Returns: + Union[Callable, None]: Wrapped function or None if it was + destroyed. + """ + + return self._func_ref() + + def is_valid(self): + """Check if wrapped function is still valid. + + Returns: + bool: Is wrapped function still valid. + """ + + return self._func_ref() is not None + + def validate_signature(self, *args, **kwargs): + """Validate if passed arguments are supported by wrapped function. + + Returns: + bool: Are passed arguments supported by wrapped function. + """ + + func = self._func_ref() + if func is None: + return False + + new_args = tuple(list(self._args) + list(args)) + new_kwargs = dict(self._kwargs) + new_kwargs.update(kwargs) + return is_func_signature_supported( + func, *new_args, **new_kwargs + ) + + class EventCallback(object): """Callback registered to a topic. @@ -34,20 +141,37 @@ class EventCallback(object): or none arguments. When 1 argument is expected then the processed 'Event' object is passed in. - The registered callbacks don't keep function in memory so it is not - possible to store lambda function as callback. + The callbacks are validated against their reference counter, that is + achieved using 'weakref' module. That means that the callback must + be stored in memory somewhere. e.g. lambda functions are not + supported as valid callback. + + You can use 'weakref_partial' functions. In that case is partial object + stored in the callback object and reference counter is checked for + the wrapped function. Args: - topic(str): Topic which will be listened. - func(func): Callback to a topic. + topic (str): Topic which will be listened. + func (Callable): Callback to a topic. + order (Union[int, None]): Order of callback. Lower number means higher + priority. Raises: TypeError: When passed function is not a callable object. """ - def __init__(self, topic, func): + def __init__(self, topic, func, order): + if not callable(func): + raise TypeError(( + "Registered callback is not callable. \"{}\"" + ).format(str(func))) + + self._validate_order(order) + self._log = None self._topic = topic + self._order = order + self._enabled = True # Replace '*' with any character regex and escape rest of text # - when callback is registered for '*' topic it will receive all # events @@ -63,37 +187,38 @@ class EventCallback(object): topic_regex = re.compile(topic_regex_str) self._topic_regex = topic_regex - # Convert callback into references - # - deleted functions won't cause crashes - if inspect.ismethod(func): - func_ref = WeakMethod(func) - elif callable(func): - func_ref = weakref.ref(func) + # Callback function prep + if isinstance(func, weakref_partial): + partial_func = func + (name, path) = _get_func_info(func.get_func()) + func_ref = None + expect_args = partial_func.validate_signature("fake") + expect_kwargs = partial_func.validate_signature(event="fake") + else: - raise TypeError(( - "Registered callback is not callable. \"{}\"" - ).format(str(func))) + partial_func = None + (name, path) = _get_func_info(func) + # Convert callback into references + # - deleted functions won't cause crashes + func_ref = _get_func_ref(func) - # Collect function name and path to file for logging - func_name = func.__name__ - func_path = os.path.abspath(inspect.getfile(func)) - - # Get expected arguments from function spec - # - positional arguments are always preferred - expect_args = is_func_signature_supported(func, "fake") - expect_kwargs = is_func_signature_supported(func, event="fake") + # Get expected arguments from function spec + # - positional arguments are always preferred + expect_args = is_func_signature_supported(func, "fake") + expect_kwargs = is_func_signature_supported(func, event="fake") self._func_ref = func_ref - self._func_name = func_name - self._func_path = func_path + self._partial_func = partial_func + self._ref_is_valid = True self._expect_args = expect_args self._expect_kwargs = expect_kwargs - self._ref_valid = func_ref is not None - self._enabled = True + + self._name = name + self._path = path def __repr__(self): return "< {} - {} > {}".format( - self.__class__.__name__, self._func_name, self._func_path + self.__class__.__name__, self._name, self._path ) @property @@ -104,32 +229,83 @@ class EventCallback(object): @property def is_ref_valid(self): - return self._ref_valid + """ + + Returns: + bool: Is reference to callback valid. + """ + + self._validate_ref() + return self._ref_is_valid def validate_ref(self): - if not self._ref_valid: - return + """Validate if reference to callback is valid. - callback = self._func_ref() - if not callback: - self._ref_valid = False + Deprecated: + Reference is always live checkd with 'is_ref_valid'. + """ + + # Trigger validate by getting 'is_valid' + _ = self.is_ref_valid @property def enabled(self): - """Is callback enabled.""" + """Is callback enabled. + + Returns: + bool: Is callback enabled. + """ + return self._enabled def set_enabled(self, enabled): - """Change if callback is enabled.""" + """Change if callback is enabled. + + Args: + enabled (bool): Change enabled state of the callback. + """ + self._enabled = enabled def deregister(self): """Calling this function will cause that callback will be removed.""" - # Fake reference - self._ref_valid = False + + self._ref_is_valid = False + self._partial_func = None + self._func_ref = None + + def get_order(self): + """Get callback order. + + Returns: + Union[int, None]: Callback order. + """ + + return self._order + + def set_order(self, order): + """Change callback order. + + Args: + order (Union[int, None]): Order of callback. Lower number means + higher priority. + """ + + self._validate_order(order) + self._order = order + + order = property(get_order, set_order) def topic_matches(self, topic): - """Check if event topic matches callback's topic.""" + """Check if event topic matches callback's topic. + + Args: + topic (str): Topic name. + + Returns: + bool: Topic matches callback's topic. + """ + return self._topic_regex.match(topic) def process_event(self, event): @@ -139,36 +315,69 @@ class EventCallback(object): event(Event): Event that was triggered. """ - # Skip if callback is not enabled or has invalid reference - if not self._ref_valid or not self._enabled: + # Skip if callback is not enabled + if not self._enabled: return - # Get reference - callback = self._func_ref() - # Check if reference is valid or callback's topic matches the event - if not callback: - # Change state if is invalid so the callback is removed - self._ref_valid = False + # Get reference and skip if is not available + callback = self._get_callback() + if callback is None: + return - elif self.topic_matches(event.topic): - # Try execute callback - try: - if self._expect_args: - callback(event) + if not self.topic_matches(event.topic): + return - elif self._expect_kwargs: - callback(event=event) + # Try to execute callback + try: + if self._expect_args: + callback(event) - else: - callback() + elif self._expect_kwargs: + callback(event=event) - except Exception: - self.log.warning( - "Failed to execute event callback {}".format( - str(repr(self)) - ), - exc_info=True - ) + else: + callback() + + except Exception: + self.log.warning( + "Failed to execute event callback {}".format( + str(repr(self)) + ), + exc_info=True + ) + + def _validate_order(self, order): + if isinstance(order, int): + return + + raise TypeError( + "Expected type 'int' got '{}'.".format(str(type(order))) + ) + + def _get_callback(self): + if self._partial_func is not None: + return self._partial_func + + if self._func_ref is not None: + return self._func_ref() + return None + + def _validate_ref(self): + if self._ref_is_valid is False: + return + + if self._func_ref is not None: + self._ref_is_valid = self._func_ref() is not None + + elif self._partial_func is not None: + self._ref_is_valid = self._partial_func.is_valid() + + else: + self._ref_is_valid = False + + if not self._ref_is_valid: + self._func_ref = None + self._partial_func = None # Inherit from 'object' for Python 2 hosts @@ -282,30 +491,39 @@ class Event(object): class EventSystem(object): """Encapsulate event handling into an object. - System wraps registered callbacks and triggered events into single object - so it is possible to create mutltiple independent systems that have their + System wraps registered callbacks and triggered events into single object, + so it is possible to create multiple independent systems that have their topics and callbacks. - + Callbacks are stored by order of their registration, but it is possible to + manually define order of callbacks using 'order' argument within + 'add_callback'. """ + default_order = 100 + def __init__(self): self._registered_callbacks = [] - def add_callback(self, topic, callback): + def add_callback(self, topic, callback, order=None): """Register callback in event system. Args: topic (str): Topic for EventCallback. - callback (Callable): Function or method that will be called - when topic is triggered. + callback (Union[Callable, weakref_partial]): Function or method + that will be called when topic is triggered. + order (Optional[int]): Order of callback. Lower number means + higher priority. Returns: EventCallback: Created callback object which can be used to stop listening. """ - callback = EventCallback(topic, callback) + if order is None: + order = self.default_order + + callback = EventCallback(topic, callback, order) self._registered_callbacks.append(callback) return callback @@ -341,22 +559,6 @@ class EventSystem(object): event.emit() return event - def _process_event(self, event): - """Process event topic and trigger callbacks. - - Args: - event (Event): Prepared event with topic and data. - """ - - invalid_callbacks = [] - for callback in self._registered_callbacks: - callback.process_event(event) - if not callback.is_ref_valid: - invalid_callbacks.append(callback) - - for callback in invalid_callbacks: - self._registered_callbacks.remove(callback) - def emit_event(self, event): """Emit event object. @@ -366,6 +568,21 @@ class EventSystem(object): self._process_event(event) + def _process_event(self, event): + """Process event topic and trigger callbacks. + + Args: + event (Event): Prepared event with topic and data. + """ + + callbacks = tuple(sorted( + self._registered_callbacks, key=lambda x: x.order + )) + for callback in callbacks: + callback.process_event(event) + if not callback.is_ref_valid: + self._registered_callbacks.remove(callback) + class QueuedEventSystem(EventSystem): """Events are automatically processed in queue. diff --git a/openpype/lib/python_module_tools.py b/openpype/lib/python_module_tools.py index bedf19562d..4f9eb7f667 100644 --- a/openpype/lib/python_module_tools.py +++ b/openpype/lib/python_module_tools.py @@ -269,7 +269,7 @@ def is_func_signature_supported(func, *args, **kwargs): True Args: - func (function): A function where the signature should be tested. + func (Callable): A function where the signature should be tested. *args (Any): Positional arguments for function signature. **kwargs (Any): Keyword arguments for function signature. diff --git a/openpype/settings/defaults/project_settings/max.json b/openpype/settings/defaults/project_settings/max.json index 19c9d10496..d1610610dc 100644 --- a/openpype/settings/defaults/project_settings/max.json +++ b/openpype/settings/defaults/project_settings/max.json @@ -1,4 +1,8 @@ { + "unit_scale_settings": { + "enabled": true, + "scene_unit_scale": "Meters" + }, "imageio": { "activate_host_color_management": true, "ocio_config": { diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_max.json b/openpype/settings/entities/schemas/projects_schema/schema_project_max.json index 78cca357a3..e4d4d40ce7 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_max.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_max.json @@ -5,6 +5,34 @@ "label": "Max", "is_file": true, "children": [ + { + "key": "unit_scale_settings", + "type": "dict", + "label": "Set Unit Scale", + "collapsible": true, + "is_group": true, + "checkbox_key": "enabled", + "children": [ + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + }, + { + "key": "scene_unit_scale", + "label": "Scene Unit Scale", + "type": "enum", + "multiselection": false, + "defaults": "exr", + "enum_items": [ + {"Millimeters": "mm"}, + {"Centimeters": "cm"}, + {"Meters": "m"}, + {"Kilometers": "km"} + ] + } + ] + }, { "key": "imageio", "type": "dict", diff --git a/openpype/tools/sceneinventory/switch_dialog.py b/openpype/tools/sceneinventory/switch_dialog.py index ce2272df57..150e369678 100644 --- a/openpype/tools/sceneinventory/switch_dialog.py +++ b/openpype/tools/sceneinventory/switch_dialog.py @@ -1230,12 +1230,12 @@ class SwitchAssetDialog(QtWidgets.QDialog): version_ids = list() - version_docs_by_parent_id = {} + version_docs_by_parent_id_and_name = collections.defaultdict(dict) for version_doc in version_docs: parent_id = version_doc["parent"] - if parent_id not in version_docs_by_parent_id: - version_ids.append(version_doc["_id"]) - version_docs_by_parent_id[parent_id] = version_doc + version_ids.append(version_doc["_id"]) + name = version_doc["name"] + version_docs_by_parent_id_and_name[parent_id][name] = version_doc hero_version_docs_by_parent_id = {} for hero_version_doc in hero_version_docs: @@ -1293,13 +1293,32 @@ class SwitchAssetDialog(QtWidgets.QDialog): repre_doc = _repres.get(container_repre_name) if not repre_doc: - version_doc = version_docs_by_parent_id[subset_id] - version_id = version_doc["_id"] - repres_by_name = repre_docs_by_parent_id_by_name[version_id] - if selected_representation: - repre_doc = repres_by_name[selected_representation] + version_docs_by_name = version_docs_by_parent_id_and_name[ + subset_id + ] + + # If asset or subset are selected for switching, we use latest + # version else we try to keep the current container version. + if ( + selected_asset not in (None, container_asset_name) + or selected_subset not in (None, container_subset_name) + ): + version_name = max(version_docs_by_name) else: - repre_doc = repres_by_name[container_repre_name] + version_name = container_version["name"] + + version_doc = version_docs_by_name[version_name] + version_id = version_doc["_id"] + repres_docs_by_name = repre_docs_by_parent_id_by_name[ + version_id + ] + + if selected_representation: + repres_name = selected_representation + else: + repres_name = container_repre_name + + repre_doc = repres_docs_by_name[repres_name] error = None try: diff --git a/server_addon/max/server/settings/main.py b/server_addon/max/server/settings/main.py index ea6a11915a..cad6024cf7 100644 --- a/server_addon/max/server/settings/main.py +++ b/server_addon/max/server/settings/main.py @@ -12,6 +12,25 @@ from .publishers import ( ) +def unit_scale_enum(): + """Return enumerator for scene unit scale.""" + return [ + {"label": "mm", "value": "Millimeters"}, + {"label": "cm", "value": "Centimeters"}, + {"label": "m", "value": "Meters"}, + {"label": "km", "value": "Kilometers"} + ] + + +class UnitScaleSettings(BaseSettingsModel): + enabled: bool = Field(True, title="Enabled") + scene_unit_scale: str = Field( + "Centimeters", + title="Scene Unit Scale", + enum_resolver=unit_scale_enum + ) + + class PRTAttributesModel(BaseSettingsModel): _layout = "compact" name: str = Field(title="Name") @@ -24,6 +43,10 @@ class PointCloudSettings(BaseSettingsModel): class MaxSettings(BaseSettingsModel): + unit_scale_settings: UnitScaleSettings = Field( + default_factory=UnitScaleSettings, + title="Set Unit Scale" + ) imageio: ImageIOSettings = Field( default_factory=ImageIOSettings, title="Color Management (ImageIO)" @@ -46,6 +69,10 @@ class MaxSettings(BaseSettingsModel): DEFAULT_VALUES = { + "unit_scale_settings": { + "enabled": True, + "scene_unit_scale": "Centimeters" + }, "RenderSettings": DEFAULT_RENDER_SETTINGS, "CreateReview": DEFAULT_CREATE_REVIEW_SETTINGS, "PointCloud": { diff --git a/server_addon/max/server/version.py b/server_addon/max/server/version.py index ae7362549b..bbab0242f6 100644 --- a/server_addon/max/server/version.py +++ b/server_addon/max/server/version.py @@ -1 +1 @@ -__version__ = "0.1.3" +__version__ = "0.1.4" diff --git a/server_addon/openpype/client/pyproject.toml b/server_addon/openpype/client/pyproject.toml index 318ceb0185..d8de9d4d96 100644 --- a/server_addon/openpype/client/pyproject.toml +++ b/server_addon/openpype/client/pyproject.toml @@ -16,3 +16,8 @@ pynput = "^1.7.2" # Timers manager - TODO remove "Qt.py" = "^1.3.3" qtawesome = "0.7.3" speedcopy = "^2.1" + +[ayon.runtimeDependencies] +OpenTimelineIO = "0.14.1" +opencolorio = "2.2.1" +Pillow = "9.5.0" diff --git a/tests/unit/openpype/lib/test_event_system.py b/tests/unit/openpype/lib/test_event_system.py index aa3f929065..b0a011d83e 100644 --- a/tests/unit/openpype/lib/test_event_system.py +++ b/tests/unit/openpype/lib/test_event_system.py @@ -1,4 +1,9 @@ -from openpype.lib.events import EventSystem, QueuedEventSystem +from functools import partial +from openpype.lib.events import ( + EventSystem, + QueuedEventSystem, + weakref_partial, +) def test_default_event_system(): @@ -81,3 +86,93 @@ def test_manual_event_system_queue(): assert output == expected_output, ( "Callbacks were not called in correct order") + + +def test_unordered_events(): + """ + Validate if callbacks are triggered in order of their register. + """ + + result = [] + + def function_a(): + result.append("A") + + def function_b(): + result.append("B") + + def function_c(): + result.append("C") + + # Without order + event_system = QueuedEventSystem() + event_system.add_callback("test", function_a) + event_system.add_callback("test", function_b) + event_system.add_callback("test", function_c) + event_system.emit("test", {}, "test") + + assert result == ["A", "B", "C"] + + +def test_ordered_events(): + """ + Validate if callbacks are triggered by their order and order + of their register. + """ + result = [] + + def function_a(): + result.append("A") + + def function_b(): + result.append("B") + + def function_c(): + result.append("C") + + def function_d(): + result.append("D") + + def function_e(): + result.append("E") + + def function_f(): + result.append("F") + + # Without order + event_system = QueuedEventSystem() + event_system.add_callback("test", function_a) + event_system.add_callback("test", function_b, order=-10) + event_system.add_callback("test", function_c, order=200) + event_system.add_callback("test", function_d, order=150) + event_system.add_callback("test", function_e) + event_system.add_callback("test", function_f, order=200) + event_system.emit("test", {}, "test") + + assert result == ["B", "A", "E", "D", "C", "F"] + + +def test_events_partial_callbacks(): + """ + Validate if partial callbacks are triggered. + """ + + result = [] + + def function(name): + result.append(name) + + def function_regular(): + result.append("regular") + + event_system = QueuedEventSystem() + event_system.add_callback("test", function_regular) + event_system.add_callback("test", partial(function, "foo")) + event_system.add_callback("test", weakref_partial(function, "bar")) + event_system.emit("test", {}, "test") + + # Delete function should also make partial callbacks invalid + del function + event_system.emit("test", {}, "test") + + assert result == ["regular", "bar", "regular"]