diff --git a/.github/workflows/prerelease.yml b/.github/workflows/prerelease.yml index bf39f8f956..81d5f05b17 100644 --- a/.github/workflows/prerelease.yml +++ b/.github/workflows/prerelease.yml @@ -40,13 +40,13 @@ jobs: - name: "✏️ Generate full changelog" if: steps.version_type.outputs.type != 'skip' id: generate-full-changelog - uses: heinrichreimer/github-changelog-generator-action@v2.2 + uses: heinrichreimer/github-changelog-generator-action@v2.3 with: token: ${{ secrets.ADMIN_TOKEN }} addSections: '{"documentation":{"prefix":"### 📖 Documentation","labels":["type: documentation"]},"tests":{"prefix":"### ✅ Testing","labels":["tests"]},"feature":{"prefix":"**🆕 New features**", "labels":["type: feature"]},"breaking":{"prefix":"**💥 Breaking**", "labels":["breaking"]},"enhancements":{"prefix":"**🚀 Enhancements**", "labels":["type: enhancement"]},"bugs":{"prefix":"**🐛 Bug fixes**", "labels":["type: bug"]},"deprecated":{"prefix":"**⚠️ Deprecations**", "labels":["depreciated"]}, "refactor":{"prefix":"**🔀 Refactored code**", "labels":["refactor"]}}' issues: false issuesWoLabels: false - sinceTag: "3.0.0" + sinceTag: "3.12.0" maxIssues: 100 pullRequests: true prWoLabels: false @@ -92,4 +92,4 @@ jobs: github_token: ${{ secrets.ADMIN_TOKEN }} source_ref: 'main' target_branch: 'develop' - commit_message_template: '[Automated] Merged {source_ref} into {target_branch}' \ No newline at end of file + commit_message_template: '[Automated] Merged {source_ref} into {target_branch}' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 85864b4442..cc69e1643a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -36,13 +36,13 @@ jobs: - name: "✏️ Generate full changelog" if: steps.version.outputs.release_tag != 'skip' id: generate-full-changelog - uses: heinrichreimer/github-changelog-generator-action@v2.2 + uses: heinrichreimer/github-changelog-generator-action@v2.3 with: token: ${{ secrets.ADMIN_TOKEN }} addSections: '{"documentation":{"prefix":"### 📖 Documentation","labels":["type: documentation"]},"tests":{"prefix":"### ✅ Testing","labels":["tests"]},"feature":{"prefix":"**🆕 New features**", "labels":["type: feature"]},"breaking":{"prefix":"**💥 Breaking**", "labels":["breaking"]},"enhancements":{"prefix":"**🚀 Enhancements**", "labels":["type: enhancement"]},"bugs":{"prefix":"**🐛 Bug fixes**", "labels":["type: bug"]},"deprecated":{"prefix":"**⚠️ Deprecations**", "labels":["depreciated"]}, "refactor":{"prefix":"**🔀 Refactored code**", "labels":["refactor"]}}' issues: false issuesWoLabels: false - sinceTag: "3.0.0" + sinceTag: "3.12.0" maxIssues: 100 pullRequests: true prWoLabels: false @@ -121,4 +121,4 @@ jobs: github_token: ${{ secrets.ADMIN_TOKEN }} source_ref: 'main' target_branch: 'develop' - commit_message_template: '[Automated] Merged release {source_ref} into {target_branch}' \ No newline at end of file + commit_message_template: '[Automated] Merged release {source_ref} into {target_branch}' diff --git a/openpype/hosts/flame/api/workio.py b/openpype/hosts/flame/api/workio.py index 0c96c0752a..e49321c75a 100644 --- a/openpype/hosts/flame/api/workio.py +++ b/openpype/hosts/flame/api/workio.py @@ -1,7 +1,7 @@ """Host API required Work Files tool""" import os -from openpype.api import Logger +from openpype.lib import Logger # from .. import ( # get_project_manager, # get_current_project diff --git a/openpype/hosts/flame/hooks/pre_flame_setup.py b/openpype/hosts/flame/hooks/pre_flame_setup.py index f0fdaa86ba..713daf1031 100644 --- a/openpype/hosts/flame/hooks/pre_flame_setup.py +++ b/openpype/hosts/flame/hooks/pre_flame_setup.py @@ -3,16 +3,17 @@ import json import tempfile import contextlib import socket +from pprint import pformat + from openpype.lib import ( PreLaunchHook, - get_openpype_username + get_openpype_username, + run_subprocess, ) from openpype.lib.applications import ( ApplicationLaunchFailed ) from openpype.hosts import flame as opflame -import openpype -from pprint import pformat class FlamePrelaunch(PreLaunchHook): @@ -127,7 +128,6 @@ class FlamePrelaunch(PreLaunchHook): except OSError as exc: self.log.warning("Not able to open files: {}".format(exc)) - def _get_flame_fps(self, fps_num): fps_table = { float(23.976): "23.976 fps", @@ -179,7 +179,7 @@ class FlamePrelaunch(PreLaunchHook): "env": self.launch_context.env } - openpype.api.run_subprocess(args, **process_kwargs) + run_subprocess(args, **process_kwargs) # process returned json file to pass launch args return_json_data = open(tmp_json_path).read() diff --git a/openpype/hosts/maya/api/lib_renderproducts.py b/openpype/hosts/maya/api/lib_renderproducts.py index 1ab771cfe6..cd204445b7 100644 --- a/openpype/hosts/maya/api/lib_renderproducts.py +++ b/openpype/hosts/maya/api/lib_renderproducts.py @@ -260,20 +260,20 @@ class ARenderProducts: """ try: - file_prefix_attr = IMAGE_PREFIXES[self.renderer] + prefix_attr = IMAGE_PREFIXES[self.renderer] except KeyError: raise UnsupportedRendererException( "Unsupported renderer {}".format(self.renderer) ) - file_prefix = self._get_attr(file_prefix_attr) + prefix = self._get_attr(prefix_attr) - if not file_prefix: + if not prefix: # Fall back to scene name by default log.debug("Image prefix not set, using ") file_prefix = "" - return file_prefix + return prefix def get_render_attribute(self, attribute): """Get attribute from render options. @@ -730,13 +730,16 @@ class RenderProductsVray(ARenderProducts): """Get image prefix for V-Ray. This overrides :func:`ARenderProducts.get_renderer_prefix()` as - we must add `` token manually. + we must add `` token manually. This is done only for + non-multipart outputs, where `` token doesn't make sense. See also: :func:`ARenderProducts.get_renderer_prefix()` """ prefix = super(RenderProductsVray, self).get_renderer_prefix() + if self.multipart: + return prefix aov_separator = self._get_aov_separator() prefix = "{}{}".format(prefix, aov_separator) return prefix @@ -974,15 +977,18 @@ class RenderProductsRedshift(ARenderProducts): """Get image prefix for Redshift. This overrides :func:`ARenderProducts.get_renderer_prefix()` as - we must add `` token manually. + we must add `` token manually. This is done only for + non-multipart outputs, where `` token doesn't make sense. See also: :func:`ARenderProducts.get_renderer_prefix()` """ - file_prefix = super(RenderProductsRedshift, self).get_renderer_prefix() - separator = self.extract_separator(file_prefix) - prefix = "{}{}".format(file_prefix, separator or "_") + prefix = super(RenderProductsRedshift, self).get_renderer_prefix() + if self.multipart: + return prefix + separator = self.extract_separator(prefix) + prefix = "{}{}".format(prefix, separator or "_") return prefix def get_render_products(self): diff --git a/openpype/hosts/traypublisher/plugins/create/create_movie_batch.py b/openpype/hosts/traypublisher/plugins/create/create_movie_batch.py index abe29d7473..cf25a37918 100644 --- a/openpype/hosts/traypublisher/plugins/create/create_movie_batch.py +++ b/openpype/hosts/traypublisher/plugins/create/create_movie_batch.py @@ -188,6 +188,7 @@ class BatchMovieCreator(TrayPublishCreator): folders=False, single_item=False, extensions=self.extensions, + allow_sequences=False, label="Filepath" ), BoolDef( diff --git a/openpype/lib/attribute_definitions.py b/openpype/lib/attribute_definitions.py index 37446f01f8..bb0b07948f 100644 --- a/openpype/lib/attribute_definitions.py +++ b/openpype/lib/attribute_definitions.py @@ -3,11 +3,33 @@ import re import collections import uuid import json -from abc import ABCMeta, abstractmethod +from abc import ABCMeta, abstractmethod, abstractproperty import six import clique +# Global variable which store attribude definitions by type +# - default types are registered on import +_attr_defs_by_type = {} + + +def register_attr_def_class(cls): + """Register attribute definition. + + Currently are registered definitions used to deserialize data to objects. + + Attrs: + cls (AbtractAttrDef): Non-abstract class to be registered with unique + 'type' attribute. + + Raises: + KeyError: When type was already registered. + """ + + if cls.type in _attr_defs_by_type: + raise KeyError("Type \"{}\" was already registered".format(cls.type)) + _attr_defs_by_type[cls.type] = cls + def get_attributes_keys(attribute_definitions): """Collect keys from list of attribute definitions. @@ -90,6 +112,8 @@ class AbtractAttrDef: next to value input or ahead. """ + type_attributes = [] + is_value_def = True def __init__( @@ -115,6 +139,16 @@ class AbtractAttrDef: return False return self.key == other.key + @abstractproperty + def type(self): + """Attribute definition type also used as identifier of class. + + Returns: + str: Type of attribute definition. + """ + + pass + @abstractmethod def convert_value(self, value): """Convert value to a valid one. @@ -125,6 +159,35 @@ class AbtractAttrDef: pass + def serialize(self): + """Serialize object to data so it's possible to recreate it. + + Returns: + Dict[str, Any]: Serialized object that can be passed to + 'deserialize' method. + """ + + data = { + "type": self.type, + "key": self.key, + "label": self.label, + "tooltip": self.tooltip, + "default": self.default, + "is_label_horizontal": self.is_label_horizontal + } + for attr in self.type_attributes: + data[attr] = getattr(self, attr) + return data + + @classmethod + def deserialize(cls, data): + """Recreate object from data. + + Data can be received using 'serialize' method. + """ + + return cls(**data) + # ----------------------------------------- # UI attribute definitoins won't hold value @@ -141,10 +204,12 @@ class UIDef(AbtractAttrDef): class UISeparatorDef(UIDef): - pass + type = "separator" class UILabelDef(UIDef): + type = "label" + def __init__(self, label): super(UILabelDef, self).__init__(label=label) @@ -160,6 +225,8 @@ class UnknownDef(AbtractAttrDef): have known definition of type. """ + type = "unknown" + def __init__(self, key, default=None, **kwargs): kwargs["default"] = default super(UnknownDef, self).__init__(key, **kwargs) @@ -181,6 +248,13 @@ class NumberDef(AbtractAttrDef): default(int, float): Default value for conversion. """ + type = "number" + type_attributes = [ + "minimum", + "maximum", + "decimals" + ] + def __init__( self, key, minimum=None, maximum=None, decimals=None, default=None, **kwargs @@ -252,6 +326,12 @@ class TextDef(AbtractAttrDef): default(str, None): Default value. Empty string used when not defined. """ + type = "text" + type_attributes = [ + "multiline", + "placeholder", + ] + def __init__( self, key, multiline=None, regex=None, placeholder=None, default=None, **kwargs @@ -290,6 +370,11 @@ class TextDef(AbtractAttrDef): return value return self.default + def serialize(self): + data = super(TextDef, self).serialize() + data["regex"] = self.regex.pattern + return data + class EnumDef(AbtractAttrDef): """Enumeration of single item from items. @@ -301,6 +386,8 @@ class EnumDef(AbtractAttrDef): default: Default value. Must be one key(value) from passed items. """ + type = "enum" + def __init__(self, key, items, default=None, **kwargs): if not items: raise ValueError(( @@ -335,6 +422,11 @@ class EnumDef(AbtractAttrDef): return value return self.default + def serialize(self): + data = super(TextDef, self).serialize() + data["items"] = list(self.items) + return data + class BoolDef(AbtractAttrDef): """Boolean representation. @@ -343,6 +435,8 @@ class BoolDef(AbtractAttrDef): default(bool): Default value. Set to `False` if not defined. """ + type = "bool" + def __init__(self, key, default=None, **kwargs): if default is None: default = False @@ -585,6 +679,15 @@ class FileDef(AbtractAttrDef): default(str, List[str]): Default value. """ + type = "path" + type_attributes = [ + "single_item", + "folders", + "extensions", + "allow_sequences", + "extensions_label", + ] + def __init__( self, key, single_item=True, folders=None, extensions=None, allow_sequences=True, extensions_label=None, default=None, **kwargs @@ -675,3 +778,71 @@ class FileDef(AbtractAttrDef): if self.single_item: return FileDefItem.create_empty_item().to_dict() return [] + + +def serialize_attr_def(attr_def): + """Serialize attribute definition to data. + + Args: + attr_def (AbtractAttrDef): Attribute definition to serialize. + + Returns: + Dict[str, Any]: Serialized data. + """ + + return attr_def.serialize() + + +def serialize_attr_defs(attr_defs): + """Serialize attribute definitions to data. + + Args: + attr_defs (List[AbtractAttrDef]): Attribute definitions to serialize. + + Returns: + List[Dict[str, Any]]: Serialized data. + """ + + return [ + serialize_attr_def(attr_def) + for attr_def in attr_defs + ] + + +def deserialize_attr_def(attr_def_data): + """Deserialize attribute definition from data. + + Args: + attr_def (Dict[str, Any]): Attribute definition data to deserialize. + """ + + attr_type = attr_def_data.pop("type") + cls = _attr_defs_by_type[attr_type] + return cls.deserialize(attr_def_data) + + +def deserialize_attr_defs(attr_defs_data): + """Deserialize attribute definitions. + + Args: + List[Dict[str, Any]]: List of attribute definitions. + """ + + return [ + deserialize_attr_def(attr_def_data) + for attr_def_data in attr_defs_data + ] + + +# Register attribute definitions +for _attr_class in ( + UISeparatorDef, + UILabelDef, + UnknownDef, + NumberDef, + TextDef, + EnumDef, + BoolDef, + FileDef +): + register_attr_def_class(_attr_class) diff --git a/openpype/lib/events.py b/openpype/lib/events.py index 301d62e2a6..747761fb3e 100644 --- a/openpype/lib/events.py +++ b/openpype/lib/events.py @@ -1,6 +1,7 @@ """Events holding data about specific event.""" import os import re +import copy import inspect import logging import weakref @@ -207,6 +208,12 @@ class Event(object): @property def source(self): + """Event's source used for triggering callbacks. + + Returns: + Union[str, None]: Source string or None. Source is optional. + """ + return self._source @property @@ -215,6 +222,12 @@ class Event(object): @property def topic(self): + """Event's topic used for triggering callbacks. + + Returns: + str: Topic string. + """ + return self._topic def emit(self): @@ -227,6 +240,42 @@ class Event(object): ) self._event_system.emit_event(self) + def to_data(self): + """Convert Event object to data. + + Returns: + Dict[str, Any]: Event data. + """ + + return { + "id": self.id, + "topic": self.topic, + "source": self.source, + "data": copy.deepcopy(self.data) + } + + @classmethod + def from_data(cls, event_data, event_system=None): + """Create event from data. + + Args: + event_data (Dict[str, Any]): Event data with defined keys. Can be + created using 'to_data' method. + event_system (EventSystem): System to which the event belongs. + + Returns: + Event: Event with attributes from passed data. + """ + + obj = cls( + event_data["topic"], + event_data["data"], + event_data["source"], + event_system + ) + obj._id = event_data["id"] + return obj + class EventSystem(object): """Encapsulate event handling into an object. diff --git a/openpype/lib/vendor_bin_utils.py b/openpype/lib/vendor_bin_utils.py index e5ab2872a0..eb7987c8a1 100644 --- a/openpype/lib/vendor_bin_utils.py +++ b/openpype/lib/vendor_bin_utils.py @@ -1,10 +1,33 @@ import os import logging import platform +import subprocess log = logging.getLogger("Vendor utils") +class CachedToolPaths: + """Cache already used and discovered tools and their executables. + + Discovering path can take some time and can trigger subprocesses so it's + better to cache the paths on first get. + """ + + _cached_paths = {} + + @classmethod + def is_tool_cached(cls, tool): + return tool in cls._cached_paths + + @classmethod + def get_executable_path(cls, tool): + return cls._cached_paths.get(tool) + + @classmethod + def cache_executable_path(cls, tool, path): + cls._cached_paths[tool] = path + + def is_file_executable(filepath): """Filepath lead to executable file. @@ -98,6 +121,7 @@ def get_vendor_bin_path(bin_app): Returns: str: Path to vendorized binaries folder. """ + return os.path.join( os.environ["OPENPYPE_ROOT"], "vendor", @@ -107,6 +131,112 @@ def get_vendor_bin_path(bin_app): ) +def find_tool_in_custom_paths(paths, tool, validation_func=None): + """Find a tool executable in custom paths. + + Args: + paths (Iterable[str]): Iterable of paths where to look for tool. + tool (str): Name of tool (binary file) to find in passed paths. + validation_func (Function): Custom validation function of path. + Function must expect one argument which is path to executable. + If not passed only 'find_executable' is used to be able identify + if path is valid. + + Reuturns: + Union[str, None]: Path to validated executable or None if was not + found. + """ + + for path in paths: + # Skip empty strings + if not path: + continue + + # Handle cases when path is just an executable + # - it allows to use executable from PATH + # - basename must match 'tool' value (without extension) + extless_path, ext = os.path.splitext(path) + if extless_path == tool: + executable_path = find_executable(tool) + if executable_path and ( + validation_func is None + or validation_func(executable_path) + ): + return executable_path + continue + + # Normalize path because it should be a path and check if exists + normalized = os.path.normpath(path) + if not os.path.exists(normalized): + continue + + # Note: Path can be both file and directory + + # If path is a file validate it + if os.path.isfile(normalized): + basename, ext = os.path.splitext(os.path.basename(path)) + # Check if the filename has actually the sane bane as 'tool' + if basename == tool: + executable_path = find_executable(normalized) + if executable_path and ( + validation_func is None + or validation_func(executable_path) + ): + return executable_path + + # Check if path is a directory and look for tool inside the dir + if os.path.isdir(normalized): + executable_path = find_executable(os.path.join(normalized, tool)) + if executable_path and ( + validation_func is None + or validation_func(executable_path) + ): + return executable_path + return None + + +def _oiio_executable_validation(filepath): + """Validate oiio tool executable if can be executed. + + Validation has 2 steps. First is using 'find_executable' to fill possible + missing extension or fill directory then launch executable and validate + that it can be executed. For that is used '--help' argument which is fast + and does not need any other inputs. + + Any possible crash of missing libraries or invalid build should be catched. + + Main reason is to validate if executable can be executed on OS just running + which can be issue ob linux machines. + + Note: + It does not validate if the executable is really a oiio tool which + should be used. + + Args: + filepath (str): Path to executable. + + Returns: + bool: Filepath is valid executable. + """ + + filepath = find_executable(filepath) + if not filepath: + return False + + try: + proc = subprocess.Popen( + [filepath, "--help"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + proc.wait() + return proc.returncode == 0 + + except Exception: + pass + return False + + def get_oiio_tools_path(tool="oiiotool"): """Path to vendorized OpenImageIO tool executables. @@ -117,10 +247,73 @@ def get_oiio_tools_path(tool="oiiotool"): Default is "oiiotool". """ - oiio_dir = get_vendor_bin_path("oiio") - if platform.system().lower() == "linux": - oiio_dir = os.path.join(oiio_dir, "bin") - return find_executable(os.path.join(oiio_dir, tool)) + if CachedToolPaths.is_tool_cached(tool): + return CachedToolPaths.get_executable_path(tool) + + custom_paths_str = os.environ.get("OPENPYPE_OIIO_PATHS") or "" + tool_executable_path = find_tool_in_custom_paths( + custom_paths_str.split(os.pathsep), + tool, + _oiio_executable_validation + ) + + if not tool_executable_path: + oiio_dir = get_vendor_bin_path("oiio") + if platform.system().lower() == "linux": + oiio_dir = os.path.join(oiio_dir, "bin") + default_path = os.path.join(oiio_dir, tool) + if _oiio_executable_validation(default_path): + tool_executable_path = default_path + + # Look to PATH for the tool + if not tool_executable_path: + from_path = find_executable(tool) + if from_path and _oiio_executable_validation(from_path): + tool_executable_path = from_path + + CachedToolPaths.cache_executable_path(tool, tool_executable_path) + return tool_executable_path + + +def _ffmpeg_executable_validation(filepath): + """Validate ffmpeg tool executable if can be executed. + + Validation has 2 steps. First is using 'find_executable' to fill possible + missing extension or fill directory then launch executable and validate + that it can be executed. For that is used '-version' argument which is fast + and does not need any other inputs. + + Any possible crash of missing libraries or invalid build should be catched. + + Main reason is to validate if executable can be executed on OS just running + which can be issue ob linux machines. + + Note: + It does not validate if the executable is really a ffmpeg tool. + + Args: + filepath (str): Path to executable. + + Returns: + bool: Filepath is valid executable. + """ + + filepath = find_executable(filepath) + if not filepath: + return False + + try: + proc = subprocess.Popen( + [filepath, "-version"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + proc.wait() + return proc.returncode == 0 + + except Exception: + pass + return False def get_ffmpeg_tool_path(tool="ffmpeg"): @@ -133,10 +326,33 @@ def get_ffmpeg_tool_path(tool="ffmpeg"): Returns: str: Full path to ffmpeg executable. """ - ffmpeg_dir = get_vendor_bin_path("ffmpeg") - if platform.system().lower() == "windows": - ffmpeg_dir = os.path.join(ffmpeg_dir, "bin") - return find_executable(os.path.join(ffmpeg_dir, tool)) + + if CachedToolPaths.is_tool_cached(tool): + return CachedToolPaths.get_executable_path(tool) + + custom_paths_str = os.environ.get("OPENPYPE_FFMPEG_PATHS") or "" + tool_executable_path = find_tool_in_custom_paths( + custom_paths_str.split(os.pathsep), + tool, + _ffmpeg_executable_validation + ) + + if not tool_executable_path: + ffmpeg_dir = get_vendor_bin_path("ffmpeg") + if platform.system().lower() == "windows": + ffmpeg_dir = os.path.join(ffmpeg_dir, "bin") + tool_path = find_executable(os.path.join(ffmpeg_dir, tool)) + if tool_path and _ffmpeg_executable_validation(tool_path): + tool_executable_path = tool_path + + # Look to PATH for the tool + if not tool_executable_path: + from_path = find_executable(tool) + if from_path and _oiio_executable_validation(from_path): + tool_executable_path = from_path + + CachedToolPaths.cache_executable_path(tool, tool_executable_path) + return tool_executable_path def is_oiio_supported(): diff --git a/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py b/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py index c669f9a814..3398e1725e 100644 --- a/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py @@ -32,6 +32,9 @@ from maya import cmds from openpype.pipeline import legacy_io +from openpype.hosts.maya.api.lib_rendersettings import RenderSettings +from openpype.hosts.maya.api.lib import get_attr_in_layer + from openpype_modules.deadline import abstract_submit_deadline from openpype_modules.deadline.abstract_submit_deadline import DeadlineJobInfo @@ -498,9 +501,10 @@ class MayaSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline): job_info.AssetDependency += self.scene_path # Get layer prefix - render_products = self._instance.data["renderProducts"] - layer_metadata = render_products.layer_data - layer_prefix = layer_metadata.filePrefix + renderlayer = self._instance.data["setMembers"] + renderer = self._instance.data["renderer"] + layer_prefix_attr = RenderSettings.get_image_prefix_attr(renderer) + layer_prefix = get_attr_in_layer(layer_prefix_attr, layer=renderlayer) plugin_info = copy.deepcopy(self.plugin_info) plugin_info.update({ diff --git a/openpype/modules/kitsu/actions/launcher_show_in_kitsu.py b/openpype/modules/kitsu/actions/launcher_show_in_kitsu.py new file mode 100644 index 0000000000..c95079e042 --- /dev/null +++ b/openpype/modules/kitsu/actions/launcher_show_in_kitsu.py @@ -0,0 +1,125 @@ +import webbrowser + +from openpype.pipeline import LauncherAction +from openpype.modules import ModulesManager +from openpype.client import get_project, get_asset_by_name + + +class ShowInKitsu(LauncherAction): + name = "showinkitsu" + label = "Show in Kitsu" + icon = "external-link-square" + color = "#e0e1e1" + order = 10 + + @staticmethod + def get_kitsu_module(): + return ModulesManager().modules_by_name.get("kitsu") + + def is_compatible(self, session): + if not session.get("AVALON_PROJECT"): + return False + + return True + + def process(self, session, **kwargs): + + # Context inputs + project_name = session["AVALON_PROJECT"] + asset_name = session.get("AVALON_ASSET", None) + task_name = session.get("AVALON_TASK", None) + + project = get_project(project_name=project_name, + fields=["data.zou_id"]) + if not project: + raise RuntimeError(f"Project {project_name} not found.") + + project_zou_id = project["data"].get("zou_id") + if not project_zou_id: + raise RuntimeError(f"Project {project_name} has no " + f"connected kitsu id.") + + asset_zou_name = None + asset_zou_id = None + asset_zou_type = 'Assets' + task_zou_id = None + zou_sub_type = ['AssetType', 'Sequence'] + if asset_name: + asset_zou_name = asset_name + asset_fields = ["data.zou.id", "data.zou.type"] + if task_name: + asset_fields.append(f"data.tasks.{task_name}.zou.id") + + asset = get_asset_by_name(project_name, + asset_name=asset_name, + fields=asset_fields) + + asset_zou_data = asset["data"].get("zou") + + if asset_zou_data: + asset_zou_type = asset_zou_data["type"] + if asset_zou_type not in zou_sub_type: + asset_zou_id = asset_zou_data["id"] + else: + asset_zou_type = asset_name + + if task_name: + task_data = asset["data"]["tasks"][task_name] + task_zou_data = task_data.get("zou", {}) + if not task_zou_data: + self.log.debug(f"No zou task data for task: {task_name}") + task_zou_id = task_zou_data["id"] + + # Define URL + url = self.get_url(project_id=project_zou_id, + asset_name=asset_zou_name, + asset_id=asset_zou_id, + asset_type=asset_zou_type, + task_id=task_zou_id) + + # Open URL in webbrowser + self.log.info(f"Opening URL: {url}") + webbrowser.open(url, + # Try in new tab + new=2) + + def get_url(self, + project_id, + asset_name=None, + asset_id=None, + asset_type=None, + task_id=None): + + shots_url = {'Shots', 'Sequence', 'Shot'} + sub_type = {'AssetType', 'Sequence'} + kitsu_module = self.get_kitsu_module() + + # Get kitsu url with /api stripped + kitsu_url = kitsu_module.server_url + if kitsu_url.endswith("/api"): + kitsu_url = kitsu_url[:-len("/api")] + + sub_url = f"/productions/{project_id}" + asset_type_url = "Shots" if asset_type in shots_url else "Assets" + + if task_id: + # Go to task page + # /productions/{project-id}/{asset_type}/tasks/{task_id} + sub_url += f"/{asset_type_url}/tasks/{task_id}" + + elif asset_id: + # Go to asset or shot page + # /productions/{project-id}/assets/{entity_id} + # /productions/{project-id}/shots/{entity_id} + sub_url += f"/{asset_type_url}/{asset_id}" + + else: + # Go to project page + # Project page must end with a view + # /productions/{project-id}/assets/ + # Add search method if is a sub_type + sub_url += f"/{asset_type_url}" + if asset_type in sub_type: + sub_url += f'?search={asset_name}' + + return f"{kitsu_url}{sub_url}" diff --git a/openpype/modules/kitsu/kitsu_module.py b/openpype/modules/kitsu/kitsu_module.py index d19d14dda7..23c032715b 100644 --- a/openpype/modules/kitsu/kitsu_module.py +++ b/openpype/modules/kitsu/kitsu_module.py @@ -89,7 +89,10 @@ class KitsuModule(OpenPypeModule, IPluginPaths, ITrayAction): """Implementation of abstract method for `IPluginPaths`.""" current_dir = os.path.dirname(os.path.abspath(__file__)) - return {"publish": [os.path.join(current_dir, "plugins", "publish")]} + return { + "publish": [os.path.join(current_dir, "plugins", "publish")], + "actions": [os.path.join(current_dir, "actions")] + } def cli(self, click_group): click_group.add_command(cli_main) diff --git a/openpype/modules/kitsu/utils/update_op_with_zou.py b/openpype/modules/kitsu/utils/update_op_with_zou.py index 10e80b3c89..2d14b38bc4 100644 --- a/openpype/modules/kitsu/utils/update_op_with_zou.py +++ b/openpype/modules/kitsu/utils/update_op_with_zou.py @@ -115,7 +115,9 @@ def update_op_assets( item_data["frameStart"] = frame_in # Frames duration, fallback on 0 try: - frames_duration = int(item_data.pop("nb_frames", 0)) + # NOTE nb_frames is stored directly in item + # because of zou's legacy design + frames_duration = int(item.get("nb_frames", 0)) except (TypeError, ValueError): frames_duration = 0 # Frame out, fallback on frame_in + duration or project's value or 1001 @@ -170,7 +172,7 @@ def update_op_assets( # Substitute item type for general classification (assets or shots) if item_type in ["Asset", "AssetType"]: entity_root_asset_name = "Assets" - elif item_type in ["Episode", "Sequence"]: + elif item_type in ["Episode", "Sequence", "Shot"]: entity_root_asset_name = "Shots" # Root parent folder if exist @@ -276,11 +278,13 @@ def write_project_to_op(project: dict, dbcon: AvalonMongoDB) -> UpdateOne: match_res = re.match(r"(\d+)x(\d+)", project["resolution"]) if match_res: - project_data['resolutionWidth'] = int(match_res.group(1)) - project_data['resolutionHeight'] = int(match_res.group(2)) + project_data["resolutionWidth"] = int(match_res.group(1)) + project_data["resolutionHeight"] = int(match_res.group(2)) else: - log.warning(f"\'{project['resolution']}\' does not match the expected" - " format for the resolution, for example: 1920x1080") + log.warning( + f"'{project['resolution']}' does not match the expected" + " format for the resolution, for example: 1920x1080" + ) return UpdateOne( {"_id": project_doc["_id"]}, diff --git a/openpype/pipeline/create/context.py b/openpype/pipeline/create/context.py index c5c9a14f33..3e09ff287d 100644 --- a/openpype/pipeline/create/context.py +++ b/openpype/pipeline/create/context.py @@ -205,6 +205,16 @@ class AttributeValues: def changes(self): return self.calculate_changes(self._data, self._origin_data) + def apply_changes(self, changes): + for key, item in changes.items(): + old_value, new_value = item + if new_value is None: + if key in self: + self.pop(key) + + elif self.get(key) != new_value: + self[key] = new_value + class CreatorAttributeValues(AttributeValues): """Creator specific attribute values of an instance. @@ -338,6 +348,21 @@ class PublishAttributes: changes[key] = (value, None) return changes + def apply_changes(self, changes): + for key, item in changes.items(): + if isinstance(item, dict): + self._data[key].apply_changes(item) + continue + + old_value, new_value = item + if new_value is not None: + raise ValueError( + "Unexpected type \"{}\" expected None".format( + str(type(new_value)) + ) + ) + self.pop(key) + def set_publish_plugins(self, attr_plugins): """Set publish plugins attribute definitions.""" @@ -735,6 +760,97 @@ class CreatedInstance: if member not in self._members: self._members.append(member) + def serialize_for_remote(self): + return { + "data": self.data_to_store(), + "orig_data": copy.deepcopy(self._orig_data) + } + + @classmethod + def deserialize_on_remote(cls, serialized_data, creator_items): + """Convert instance data to CreatedInstance. + + This is fake instance in remote process e.g. in UI process. The creator + is not a full creator and should not be used for calling methods when + instance is created from this method (matters on implementation). + + Args: + serialized_data (Dict[str, Any]): Serialized data for remote + recreating. Should contain 'data' and 'orig_data'. + creator_items (Dict[str, Any]): Mapping of creator identifier and + objects that behave like a creator for most of attribute + access. + """ + + instance_data = copy.deepcopy(serialized_data["data"]) + creator_identifier = instance_data["creator_identifier"] + creator_item = creator_items[creator_identifier] + + family = instance_data.get("family", None) + if family is None: + family = creator_item.family + subset_name = instance_data.get("subset", None) + + obj = cls( + family, subset_name, instance_data, creator_item, new=False + ) + obj._orig_data = serialized_data["orig_data"] + + return obj + + def remote_changes(self): + """Prepare serializable changes on remote side. + + Returns: + Dict[str, Any]: Prepared changes that can be send to client side. + """ + + return { + "changes": self.changes(), + "asset_is_valid": self._asset_is_valid, + "task_is_valid": self._task_is_valid, + } + + def update_from_remote(self, remote_changes): + """Apply changes from remote side on client side. + + Args: + remote_changes (Dict[str, Any]): Changes created on remote side. + """ + + self._asset_is_valid = remote_changes["asset_is_valid"] + self._task_is_valid = remote_changes["task_is_valid"] + + changes = remote_changes["changes"] + creator_attributes = changes.pop("creator_attributes", None) or {} + publish_attributes = changes.pop("publish_attributes", None) or {} + if changes: + self.apply_changes(changes) + + if creator_attributes: + self.creator_attributes.apply_changes(creator_attributes) + + if publish_attributes: + self.publish_attributes.apply_changes(publish_attributes) + + def apply_changes(self, changes): + """Apply changes created via 'changes'. + + Args: + Dict[str, Tuple[Any, Any]]: Instance changes to apply. Same values + are kept untouched. + """ + + for key, item in changes.items(): + old_value, new_value = item + if new_value is None: + if key in self: + self.pop(key) + else: + current_value = self.get(key) + if current_value != new_value: + self[key] = new_value + class CreateContext: """Context of instance creation. @@ -825,6 +941,10 @@ class CreateContext: def instances(self): return self._instances_by_id.values() + @property + def instances_by_id(self): + return self._instances_by_id + @property def publish_attributes(self): """Access to global publish attributes.""" diff --git a/openpype/style/style.css b/openpype/style/style.css index 4d13dc7c89..a6818a5792 100644 --- a/openpype/style/style.css +++ b/openpype/style/style.css @@ -973,23 +973,22 @@ VariantInputsWidget QToolButton { background: {color:bg}; border-radius: 0.3em; } - -#PublishInfoFrame[state="-1"] { - background: rgb(194, 226, 236); -} - #PublishInfoFrame[state="0"] { - background: {color:publisher:crash}; + background: {color:publisher:success}; } #PublishInfoFrame[state="1"] { - background: {color:publisher:success}; + background: {color:publisher:crash}; } #PublishInfoFrame[state="2"] { background: {color:publisher:warning}; } +#PublishInfoFrame[state="3"], #PublishInfoFrame[state="4"] { + background: rgb(194, 226, 236); +} + #PublishInfoFrame QLabel { color: black; font-style: bold; @@ -1086,7 +1085,7 @@ ValidationArtistMessage QLabel { border-color: {color:publisher:error}; } -#PublishProgressBar[state="0"]::chunk { +#PublishProgressBar[state="1"]::chunk, #PublishProgressBar[state="4"]::chunk { background: {color:bg-buttons}; } diff --git a/openpype/tools/publisher/__init__.py b/openpype/tools/publisher/__init__.py index a7b597eece..e69de29bb2 100644 --- a/openpype/tools/publisher/__init__.py +++ b/openpype/tools/publisher/__init__.py @@ -1,7 +0,0 @@ -from .app import show -from .window import PublisherWindow - -__all__ = ( - "show", - "PublisherWindow" -) diff --git a/openpype/tools/publisher/control.py b/openpype/tools/publisher/control.py index 19e28cca4b..bf1564597f 100644 --- a/openpype/tools/publisher/control.py +++ b/openpype/tools/publisher/control.py @@ -3,19 +3,34 @@ import copy import logging import traceback import collections +import uuid +from abc import ABCMeta, abstractmethod, abstractproperty +import six import pyblish.api -from openpype.client import get_assets +from openpype.client import ( + get_assets, + get_asset_by_id, + get_subsets, +) from openpype.lib.events import EventSystem +from openpype.lib.attribute_definitions import ( + serialize_attr_defs, + deserialize_attr_defs, +) from openpype.pipeline import ( PublishValidationError, + KnownPublishError, registered_host, legacy_io, ) -from openpype.pipeline.create import CreateContext - -from Qt import QtCore +from openpype.pipeline.create import ( + CreateContext, + AutoCreator, + HiddenCreator, + Creator, +) # Define constant for plugin orders offset PLUGIN_ORDER_OFFSET = 0.5 @@ -23,6 +38,7 @@ PLUGIN_ORDER_OFFSET = 0.5 class MainThreadItem: """Callback with args and kwargs.""" + def __init__(self, callback, *args, **kwargs): self.callback = callback self.args = args @@ -32,64 +48,9 @@ class MainThreadItem: self.callback(*self.args, **self.kwargs) -class MainThreadProcess(QtCore.QObject): - """Qt based main thread process executor. - - Has timer which controls each 50ms if there is new item to process. - - This approach gives ability to update UI meanwhile plugin is in progress. - """ - - count_timeout = 2 - - def __init__(self): - super(MainThreadProcess, self).__init__() - self._items_to_process = collections.deque() - - timer = QtCore.QTimer() - timer.setInterval(0) - - timer.timeout.connect(self._execute) - - self._timer = timer - self._switch_counter = self.count_timeout - - def process(self, func, *args, **kwargs): - item = MainThreadItem(func, *args, **kwargs) - self.add_item(item) - - def add_item(self, item): - self._items_to_process.append(item) - - def _execute(self): - if not self._items_to_process: - return - - if self._switch_counter > 0: - self._switch_counter -= 1 - return - - self._switch_counter = self.count_timeout - - item = self._items_to_process.popleft() - item.process() - - def start(self): - if not self._timer.isActive(): - self._timer.start() - - def stop(self): - if self._timer.isActive(): - self._timer.stop() - - def clear(self): - if self._timer.isActive(): - self._timer.stop() - self._items_to_process = collections.deque() - - class AssetDocsCache: """Cache asset documents for creation part.""" + projection = { "_id": True, "name": True, @@ -100,40 +61,100 @@ class AssetDocsCache: def __init__(self, controller): self._controller = controller self._asset_docs = None + self._asset_docs_hierarchy = None self._task_names_by_asset_name = {} + self._asset_docs_by_name = {} + self._full_asset_docs_by_name = {} def reset(self): self._asset_docs = None + self._asset_docs_hierarchy = None self._task_names_by_asset_name = {} + self._asset_docs_by_name = {} + self._full_asset_docs_by_name = {} def _query(self): - if self._asset_docs is None: - project_name = self._controller.project_name - asset_docs = get_assets( - project_name, fields=self.projection.keys() - ) - task_names_by_asset_name = {} - for asset_doc in asset_docs: - asset_name = asset_doc["name"] - asset_tasks = asset_doc.get("data", {}).get("tasks") or {} - task_names_by_asset_name[asset_name] = list(asset_tasks.keys()) - self._asset_docs = asset_docs - self._task_names_by_asset_name = task_names_by_asset_name + if self._asset_docs is not None: + return + + project_name = self._controller.project_name + asset_docs = get_assets( + project_name, fields=self.projection.keys() + ) + asset_docs_by_name = {} + task_names_by_asset_name = {} + for asset_doc in asset_docs: + if "data" not in asset_doc: + asset_doc["data"] = {"tasks": {}, "visualParent": None} + elif "tasks" not in asset_doc["data"]: + asset_doc["data"]["tasks"] = {} + + asset_name = asset_doc["name"] + asset_tasks = asset_doc["data"]["tasks"] + task_names_by_asset_name[asset_name] = list(asset_tasks.keys()) + asset_docs_by_name[asset_name] = asset_doc + + self._asset_docs = asset_docs + self._asset_docs_by_name = asset_docs_by_name + self._task_names_by_asset_name = task_names_by_asset_name def get_asset_docs(self): self._query() return copy.deepcopy(self._asset_docs) + def get_asset_hierarchy(self): + """Prepare asset documents into hierarchy. + + Convert ObjectId to string. Asset id is not used during whole + process of publisher but asset name is used rather. + + Returns: + Dict[Union[str, None]: Any]: Mapping of parent id to it's children. + Top level assets have parent id 'None'. + """ + + if self._asset_docs_hierarchy is None: + _queue = collections.deque(self.get_asset_docs()) + + output = collections.defaultdict(list) + while _queue: + asset_doc = _queue.popleft() + asset_doc["_id"] = str(asset_doc["_id"]) + parent_id = asset_doc["data"]["visualParent"] + if parent_id is not None: + parent_id = str(parent_id) + asset_doc["data"]["visualParent"] = parent_id + output[parent_id].append(asset_doc) + self._asset_docs_hierarchy = output + return copy.deepcopy(self._asset_docs_hierarchy) + def get_task_names_by_asset_name(self): self._query() return copy.deepcopy(self._task_names_by_asset_name) + def get_asset_by_name(self, asset_name): + self._query() + asset_doc = self._asset_docs_by_name.get(asset_name) + if asset_doc is None: + return None + return copy.deepcopy(asset_doc) + + def get_full_asset_by_name(self, asset_name): + self._query() + if asset_name not in self._full_asset_docs_by_name: + asset_doc = self._asset_docs_by_name.get(asset_name) + project_name = self._controller.project_name + full_asset_doc = get_asset_by_id(project_name, asset_doc["_id"]) + self._full_asset_docs_by_name[asset_name] = full_asset_doc + return copy.deepcopy(self._full_asset_docs_by_name[asset_name]) + class PublishReport: """Report for single publishing process. Report keeps current state of publishing and currently processed plugin. """ + def __init__(self, controller): self.controller = controller self._publish_discover_result = None @@ -214,13 +235,15 @@ class PublishReport: def add_result(self, result): """Handle result of one plugin and it's instance.""" + instance = result["instance"] instance_id = None if instance is not None: instance_id = instance.id self._current_plugin_data["instances_data"].append({ "id": instance_id, - "logs": self._extract_instance_log_items(result) + "logs": self._extract_instance_log_items(result), + "process_time": result["duration"] }) def add_action_result(self, action, result): @@ -270,7 +293,9 @@ class PublishReport: "plugins_data": plugins_data, "instances": instances_details, "context": self._extract_context_data(self._current_context), - "crashed_file_paths": crashed_file_paths + "crashed_file_paths": crashed_file_paths, + "id": str(uuid.uuid4()), + "report_version": "1.0.0" } def _extract_context_data(self, context): @@ -342,7 +367,1140 @@ class PublishReport: return output -class PublisherController: +class PublishPluginsProxy: + """Wrapper around publish plugin. + + Prepare mapping for publish plugins and actions. Also can create + serializable data for plugin actions so UI don't have to have access to + them. + + This object is created in process where publishing is actually running. + + Notes: + Actions have id but single action can be used on multiple plugins so + to run an action is needed combination of plugin and action. + + Args: + plugins [List[pyblish.api.Plugin]]: Discovered plugins that will be + processed. + """ + + def __init__(self, plugins): + plugins_by_id = {} + actions_by_id = {} + action_ids_by_plugin_id = {} + for plugin in plugins: + plugin_id = plugin.id + plugins_by_id[plugin_id] = plugin + + action_ids = set() + action_ids_by_plugin_id[plugin_id] = action_ids + + actions = getattr(plugin, "actions", None) or [] + for action in actions: + action_id = action.id + action_ids.add(action_id) + actions_by_id[action_id] = action + + self._plugins_by_id = plugins_by_id + self._actions_by_id = actions_by_id + self._action_ids_by_plugin_id = action_ids_by_plugin_id + + def get_action(self, action_id): + return self._actions_by_id[action_id] + + def get_plugin(self, plugin_id): + return self._plugins_by_id[plugin_id] + + def get_plugin_id(self, plugin): + """Get id of plugin based on plugin object. + + It's used for validation errors report. + + Args: + plugin (pyblish.api.Plugin): Publish plugin for which id should be + returned. + + Returns: + str: Plugin id. + """ + + return plugin.id + + def get_plugin_action_items(self, plugin_id): + """Get plugin action items for plugin by it's id. + + Args: + plugin_id (str): Publish plugin id. + + Returns: + List[PublishPluginActionItem]: Items with information about publish + plugin actions. + """ + + return [ + self._create_action_item(self._actions_by_id[action_id], plugin_id) + for action_id in self._action_ids_by_plugin_id[plugin_id] + ] + + def _create_action_item(self, action, plugin_id): + label = action.label or action.__name__ + icon = getattr(action, "icon", None) + return PublishPluginActionItem( + action.id, + plugin_id, + action.active, + action.on, + label, + icon + ) + + +class PublishPluginActionItem: + """Representation of publish plugin action. + + Data driven object which is used as proxy for controller and UI. + + Args: + action_id (str): Action id. + plugin_id (str): Plugin id. + active (bool): Action is active. + on_filter (str): Actions have 'on' attribte which define when can be + action triggered (e.g. 'all', 'failed', ...). + label (str): Action's label. + icon (Union[str, None]) Action's icon. + """ + + def __init__(self, action_id, plugin_id, active, on_filter, label, icon): + self.action_id = action_id + self.plugin_id = plugin_id + self.active = active + self.on_filter = on_filter + self.label = label + self.icon = icon + + def to_data(self): + """Serialize object to dictionary. + + Returns: + Dict[str, Union[str,bool,None]]: Serialized object. + """ + + return { + "action_id": self.action_id, + "plugin_id": self.plugin_id, + "active": self.active, + "on_filter": self.on_filter, + "label": self.label, + "icon": self.icon + } + + @classmethod + def from_data(cls, data): + """Create object from data. + + Args: + data (Dict[str, Union[str,bool,None]]): Data used to recreate + object. + + Returns: + PublishPluginActionItem: Object created using data. + """ + + return cls(**data) + + +class ValidationErrorItem: + """Data driven validation error item. + + Prepared data container with information about validation error and it's + source plugin. + + Can be converted to raw data and recreated should be used for controller + and UI connection. + + Args: + instance_id (str): Id of pyblish instance to which is validation error + connected. + instance_label (str): Prepared instance label. + plugin_id (str): Id of pyblish Plugin which triggered the validation + error. Id is generated using 'PublishPluginsProxy'. + """ + + def __init__( + self, + instance_id, + instance_label, + plugin_id, + context_validation, + title, + description, + detail, + ): + self.instance_id = instance_id + self.instance_label = instance_label + self.plugin_id = plugin_id + self.context_validation = context_validation + self.title = title + self.description = description + self.detail = detail + + def to_data(self): + """Serialize object to dictionary. + + Returns: + Dict[str, Union[str, bool, None]]: Serialized object data. + """ + + return { + "instance_id": self.instance_id, + "instance_label": self.instance_label, + "plugin_id": self.plugin_id, + "context_validation": self.context_validation, + "title": self.title, + "description": self.description, + "detail": self.detail, + } + + @classmethod + def from_result(cls, plugin_id, error, instance): + """Create new object based on resukt from controller. + + Returns: + ValidationErrorItem: New object with filled data. + """ + + instance_label = None + instance_id = None + if instance is not None: + instance_label = ( + instance.data.get("label") or instance.data.get("name") + ) + instance_id = instance.id + + return cls( + instance_id, + instance_label, + plugin_id, + instance is None, + error.title, + error.description, + error.detail, + ) + + @classmethod + def from_data(cls, data): + return cls(**data) + + +class PublishValidationErrorsReport: + """Publish validation errors report that can be parsed to raw data. + + Args: + error_items (List[ValidationErrorItem]): List of validation errors. + plugin_action_items (Dict[str, PublishPluginActionItem]): Action items + by plugin id. + """ + + def __init__(self, error_items, plugin_action_items): + self._error_items = error_items + self._plugin_action_items = plugin_action_items + + def __iter__(self): + for item in self._error_items: + yield item + + def group_items_by_title(self): + """Group errors by plugin and their titles. + + Items are grouped by plugin and title -> same title from different + plugin is different item. Items are ordered by plugin order. + + Returns: + List[Dict[str, Any]]: List where each item title, instance + information related to title and possible plugin actions. + """ + + ordered_plugin_ids = [] + error_items_by_plugin_id = collections.defaultdict(list) + for error_item in self._error_items: + plugin_id = error_item.plugin_id + if plugin_id not in ordered_plugin_ids: + ordered_plugin_ids.append(plugin_id) + error_items_by_plugin_id[plugin_id].append(error_item) + + grouped_error_items = [] + for plugin_id in ordered_plugin_ids: + plugin_action_items = self._plugin_action_items[plugin_id] + error_items = error_items_by_plugin_id[plugin_id] + + titles = [] + error_items_by_title = collections.defaultdict(list) + for error_item in error_items: + title = error_item.title + if title not in titles: + titles.append(error_item.title) + error_items_by_title[title].append(error_item) + + for title in titles: + grouped_error_items.append({ + "plugin_action_items": list(plugin_action_items), + "error_items": error_items_by_title[title], + "title": title + }) + return grouped_error_items + + def to_data(self): + """Serialize object to dictionary. + + Returns: + Dict[str, Any]: Serialized data. + """ + + error_items = [ + item.to_data() + for item in self._error_items + ] + + plugin_action_items = { + plugin_id: [ + action_item.to_data() + for action_item in action_items + ] + for plugin_id, action_items in self._plugin_action_items.items() + } + + return { + "error_items": error_items, + "plugin_action_items": plugin_action_items + } + + @classmethod + def from_data(cls, data): + """Recreate object from data. + + Args: + data (dict[str, Any]): Data to recreate object. Can be created + using 'to_data' method. + + Returns: + PublishValidationErrorsReport: New object based on data. + """ + + error_items = [ + ValidationErrorItem.from_data(error_item) + for error_item in data["error_items"] + ] + plugin_action_items = [ + PublishPluginActionItem.from_data(action_item) + for action_item in data["plugin_action_items"] + ] + return cls(error_items, plugin_action_items) + + +class PublishValidationErrors: + """Object to keep track about validation errors by plugin.""" + + def __init__(self): + self._plugins_proxy = None + self._error_items = [] + self._plugin_action_items = {} + + def __bool__(self): + return self.has_errors + + @property + def has_errors(self): + """At least one error was added.""" + + return bool(self._error_items) + + def reset(self, plugins_proxy): + """Reset object to default state. + + Args: + plugins_proxy (PublishPluginsProxy): Proxy which store plugins, + actions by ids and create mapping of action ids by plugin ids. + """ + + self._plugins_proxy = plugins_proxy + self._error_items = [] + self._plugin_action_items = {} + + def create_report(self): + """Create report based on currently existing errors. + + Returns: + PublishValidationErrorsReport: Validation error report with all + error information and publish plugin action items. + """ + + return PublishValidationErrorsReport( + self._error_items, self._plugin_action_items + ) + + def add_error(self, plugin, error, instance): + """Add error from pyblish result. + + Args: + plugin (pyblish.api.Plugin): Plugin which triggered error. + error (ValidationException): Validation error. + instance (Union[pyblish.api.Instance, None]): Instance on which was + error raised or None if was raised on context. + """ + + # Make sure the cached report is cleared + plugin_id = self._plugins_proxy.get_plugin_id(plugin) + self._error_items.append( + ValidationErrorItem.from_result(plugin_id, error, instance) + ) + if plugin_id in self._plugin_action_items: + return + + plugin_actions = self._plugins_proxy.get_plugin_action_items( + plugin_id + ) + self._plugin_action_items[plugin_id] = plugin_actions + + +class CreatorType: + def __init__(self, name): + self.name = name + + def __str__(self): + return self.name + + def __eq__(self, other): + return self.name == str(other) + + +class CreatorTypes: + base = CreatorType("base") + auto = CreatorType("auto") + hidden = CreatorType("hidden") + artist = CreatorType("artist") + + @classmethod + def from_str(cls, value): + for creator_type in ( + cls.base, + cls.auto, + cls.hidden, + cls.artist + ): + if value == creator_type: + return creator_type + raise ValueError("Unknown type \"{}\"".format(str(value))) + + +class CreatorItem: + """Wrapper around Creator plugin. + + Object can be serialized and recreated. + """ + + def __init__( + self, + identifier, + creator_type, + family, + label, + group_label, + icon, + instance_attributes_defs, + description, + detailed_description, + default_variant, + default_variants, + create_allow_context_change, + pre_create_attributes_defs + ): + self.identifier = identifier + self.creator_type = creator_type + self.family = family + self.label = label + self.group_label = group_label + self.icon = icon + self.description = description + self.detailed_description = detailed_description + self.default_variant = default_variant + self.default_variants = default_variants + self.create_allow_context_change = create_allow_context_change + self.instance_attributes_defs = instance_attributes_defs + self.pre_create_attributes_defs = pre_create_attributes_defs + + def get_instance_attr_defs(self): + return self.instance_attributes_defs + + def get_group_label(self): + return self.group_label + + @classmethod + def from_creator(cls, creator): + if isinstance(creator, AutoCreator): + creator_type = CreatorTypes.auto + elif isinstance(creator, HiddenCreator): + creator_type = CreatorTypes.hidden + elif isinstance(creator, Creator): + creator_type = CreatorTypes.artist + else: + creator_type = CreatorTypes.base + + description = None + detail_description = None + default_variant = None + default_variants = None + pre_create_attr_defs = None + create_allow_context_change = None + if creator_type is CreatorTypes.artist: + description = creator.get_description() + detail_description = creator.get_detail_description() + default_variant = creator.get_default_variant() + default_variants = creator.get_default_variants() + pre_create_attr_defs = creator.get_pre_create_attr_defs() + create_allow_context_change = creator.create_allow_context_change + + identifier = creator.identifier + return cls( + identifier, + creator_type, + creator.family, + creator.label or identifier, + creator.get_group_label(), + creator.get_icon(), + creator.get_instance_attr_defs(), + description, + detail_description, + default_variant, + default_variants, + create_allow_context_change, + pre_create_attr_defs + ) + + def to_data(self): + instance_attributes_defs = None + if self.instance_attributes_defs is not None: + instance_attributes_defs = serialize_attr_defs( + self.instance_attributes_defs + ) + + pre_create_attributes_defs = None + if self.pre_create_attributes_defs is not None: + instance_attributes_defs = serialize_attr_defs( + self.pre_create_attributes_defs + ) + + return { + "identifier": self.identifier, + "creator_type": str(self.creator_type), + "family": self.family, + "label": self.label, + "group_label": self.group_label, + "icon": self.icon, + "description": self.description, + "detailed_description": self.detailed_description, + "default_variant": self.default_variant, + "default_variants": self.default_variants, + "create_allow_context_change": self.create_allow_context_change, + "instance_attributes_defs": instance_attributes_defs, + "pre_create_attributes_defs": pre_create_attributes_defs, + } + + @classmethod + def from_data(cls, data): + instance_attributes_defs = data["instance_attributes_defs"] + if instance_attributes_defs is not None: + data["instance_attributes_defs"] = deserialize_attr_defs( + instance_attributes_defs + ) + + pre_create_attributes_defs = data["pre_create_attributes_defs"] + if pre_create_attributes_defs is not None: + data["pre_create_attributes_defs"] = deserialize_attr_defs( + pre_create_attributes_defs + ) + + data["creator_type"] = CreatorTypes.from_str(data["creator_type"]) + return cls(**data) + + +@six.add_metaclass(ABCMeta) +class AbstractPublisherController(object): + """Publisher tool controller. + + Define what must be implemented to be able use Publisher functionality. + + Goal is to have "data driven" controller that can be used to control UI + running in different process. That lead to some disadvantages like UI can't + access objects directly but by using wrappers that can be serialized. + """ + + @abstractproperty + def log(self): + """Controller's logger object. + + Returns: + logging.Logger: Logger object that can be used for logging. + """ + + pass + + @abstractproperty + def event_system(self): + """Inner event system for publisher controller.""" + + pass + + @abstractproperty + def project_name(self): + """Current context project name. + + Returns: + str: Name of project. + """ + + pass + + @abstractproperty + def current_asset_name(self): + """Current context asset name. + + Returns: + Union[str, None]: Name of asset. + """ + + pass + + @abstractproperty + def current_task_name(self): + """Current context task name. + + Returns: + Union[str, None]: Name of task. + """ + + pass + + @abstractproperty + def host_is_valid(self): + """Host is valid for creation part. + + Host must have implemented certain functionality to be able create + in Publisher tool. + + Returns: + bool: Host can handle creation of instances. + """ + + pass + + @abstractproperty + def instances(self): + """Collected/created instances. + + Returns: + List[CreatedInstance]: List of created instances. + """ + + pass + + @abstractmethod + def get_context_title(self): + """Get context title for artist shown at the top of main window. + + Returns: + Union[str, None]: Context title for window or None. In case of None + a warning is displayed (not nice for artists). + """ + + pass + + @abstractmethod + def get_asset_docs(self): + pass + + @abstractmethod + def get_asset_hierarchy(self): + pass + + @abstractmethod + def get_task_names_by_asset_names(self, asset_names): + pass + + @abstractmethod + def get_existing_subset_names(self, asset_name): + pass + + @abstractmethod + def reset(self): + """Reset whole controller. + + This should reset create context, publish context and all variables + that are related to it. + """ + + pass + + @abstractmethod + def get_creator_attribute_definitions(self, instances): + pass + + @abstractmethod + def get_publish_attribute_definitions(self, instances, include_context): + pass + + @abstractmethod + def get_creator_icon(self, identifier): + """Receive creator's icon by identifier. + + Args: + identifier (str): Creator's identifier. + + Returns: + Union[str, None]: Creator's icon string. + """ + + pass + + @abstractmethod + def get_subset_name( + self, + creator_identifier, + variant, + task_name, + asset_name, + instance_id=None + ): + """Get subset name based on passed data. + + Args: + creator_identifier (str): Identifier of creator which should be + responsible for subset name creation. + variant (str): Variant value from user's input. + task_name (str): Name of task for which is instance created. + asset_name (str): Name of asset for which is instance created. + instance_id (Union[str, None]): Existing instance id when subset + name is updated. + """ + + pass + + @abstractmethod + def create( + self, creator_identifier, subset_name, instance_data, options + ): + """Trigger creation by creator identifier. + + Should also trigger refresh of instanes. + + Args: + creator_identifier (str): Identifier of Creator plugin. + subset_name (str): Calculated subset name. + instance_data (Dict[str, Any]): Base instance data with variant, + asset name and task name. + options (Dict[str, Any]): Data from pre-create attributes. + """ + + def save_changes(self): + """Save changes in create context.""" + + pass + + def remove_instances(self, instance_ids): + """Remove list of instances from create context.""" + # TODO expect instance ids + + pass + + @abstractproperty + def publish_has_finished(self): + """Has publishing finished. + + Returns: + bool: If publishing finished and all plugins were iterated. + """ + + pass + + @abstractproperty + def publish_is_running(self): + """Publishing is running right now. + + Returns: + bool: If publishing is in progress. + """ + + pass + + @abstractproperty + def publish_has_validated(self): + """Publish validation passed. + + Returns: + bool: If publishing passed last possible validation order. + """ + + pass + + @abstractproperty + def publish_has_crashed(self): + """Publishing crashed for any reason. + + Returns: + bool: Publishing crashed. + """ + + pass + + @abstractproperty + def publish_has_validation_errors(self): + """During validation happened at least one validation error. + + Returns: + bool: Validation error was raised during validation. + """ + + pass + + @abstractproperty + def publish_max_progress(self): + """Get maximum possible progress number. + + Returns: + int: Number that can be used as 100% of publish progress bar. + """ + + pass + + @abstractproperty + def publish_progress(self): + """Current progress number. + + Returns: + int: Current progress value from 0 to 'publish_max_progress'. + """ + + pass + + @abstractproperty + def publish_error_msg(self): + """Current error message which cause fail of publishing. + + Returns: + Union[str, None]: Message which will be showed to artist or + None. + """ + + pass + + @abstractmethod + def get_publish_report(self): + pass + + @abstractmethod + def get_validation_errors(self): + pass + + @abstractmethod + def publish(self): + """Trigger publishing without any order limitations.""" + + pass + + @abstractmethod + def validate(self): + """Trigger publishing which will stop after validation order.""" + + pass + + @abstractmethod + def stop_publish(self): + """Stop publishing can be also used to pause publishing. + + Pause of publishing is possible only if all plugins successfully + finished. + """ + + pass + + @abstractmethod + def run_action(self, plugin_id, action_id): + """Trigger pyblish action on a plugin. + + Args: + plugin_id (str): Id of publish plugin. + action_id (str): Id of publish action. + """ + + pass + + @abstractmethod + def set_comment(self, comment): + """Set comment on pyblish context. + + Set "comment" key on current pyblish.api.Context data. + + Args: + comment (str): Artist's comment. + """ + + pass + + @abstractmethod + def emit_card_message(self, message): + """Emit a card message which can have a lifetime. + + This is for UI purposes. Method can be extended to more arguments + in future e.g. different message timeout or type (color). + + Args: + message (str): Message that will be showed. + """ + + pass + + +class BasePublisherController(AbstractPublisherController): + """Implement common logic for controllers. + + Implement event system, logger and common attributes. Attributes are + triggering value changes so anyone can listen to their topics. + + Prepare implementation for creator items. Controller must implement just + their filling by '_collect_creator_items'. + + All prepared implementation is based on calling super '__init__'. + """ + + def __init__(self): + self._log = None + self._event_system = None + + # Host is valid for creation + self._host_is_valid = False + + # Any other exception that happened during publishing + self._publish_error_msg = None + # Publishing is in progress + self._publish_is_running = False + # Publishing is over validation order + self._publish_has_validated = False + + self._publish_has_validation_errors = False + self._publish_has_crashed = False + # All publish plugins are processed + self._publish_has_finished = False + self._publish_max_progress = 0 + self._publish_progress = 0 + + # Controller must '_collect_creator_items' to fill the value + self._creator_items = None + + @property + def log(self): + """Controller's logger object. + + Returns: + logging.Logger: Logger object that can be used for logging. + """ + + if self._log is None: + self._log = logging.getLogget(self.__class__.__name__) + return self._log + + @property + def event_system(self): + """Inner event system for publisher controller. + + Is used for communication with UI. Event system is autocreated. + + Known topics: + "show.detailed.help" - Detailed help requested (UI related). + "show.card.message" - Show card message request (UI related). + "instances.refresh.finished" - Instances are refreshed. + "plugins.refresh.finished" - Plugins refreshed. + "publish.reset.finished" - Publish context reset finished. + "controller.reset.finished" - Controller reset finished. + "publish.process.started" - Publishing started. Can be started from + paused state. + "publish.process.stopped" - Publishing stopped/paused process. + "publish.process.plugin.changed" - Plugin state has changed. + "publish.process.instance.changed" - Instance state has changed. + "publish.has_validated.changed" - Attr 'publish_has_validated' + changed. + "publish.is_running.changed" - Attr 'publish_is_running' changed. + "publish.has_crashed.changed" - Attr 'publish_has_crashed' changed. + "publish.publish_error.changed" - Attr 'publish_error' + "publish.has_validation_errors.changed" - Attr + 'has_validation_errors' changed. + "publish.max_progress.changed" - Attr 'publish_max_progress' + changed. + "publish.progress.changed" - Attr 'publish_progress' changed. + "publish.host_is_valid.changed" - Attr 'host_is_valid' changed. + "publish.finished.changed" - Attr 'publish_has_finished' changed. + + Returns: + EventSystem: Event system which can trigger callbacks for topics. + """ + + if self._event_system is None: + self._event_system = EventSystem() + return self._event_system + + def _emit_event(self, topic, data=None): + if data is None: + data = {} + self.event_system.emit(topic, data, "controller") + + def _get_host_is_valid(self): + return self._host_is_valid + + def _set_host_is_valid(self, value): + if self._host_is_valid != value: + self._host_is_valid = value + self._emit_event("publish.host_is_valid.changed", {"value": value}) + + def _get_publish_has_finished(self): + return self._publish_has_finished + + def _set_publish_has_finished(self, value): + if self._publish_has_finished != value: + self._publish_has_finished = value + self._emit_event("publish.finished.changed", {"value": value}) + + def _get_publish_is_running(self): + return self._publish_is_running + + def _set_publish_is_running(self, value): + if self._publish_is_running != value: + self._publish_is_running = value + self._emit_event("publish.is_running.changed", {"value": value}) + + def _get_publish_has_validated(self): + return self._publish_has_validated + + def _set_publish_has_validated(self, value): + if self._publish_has_validated != value: + self._publish_has_validated = value + self._emit_event("publish.has_validated.changed", {"value": value}) + + def _get_publish_has_crashed(self): + return self._publish_has_crashed + + def _set_publish_has_crashed(self, value): + if self._publish_has_crashed != value: + self._publish_has_crashed = value + self._emit_event("publish.has_crashed.changed", {"value": value}) + + def _get_publish_has_validation_errors(self): + return self._publish_has_validation_errors + + def _set_publish_has_validation_errors(self, value): + if self._publish_has_validation_errors != value: + self._publish_has_validation_errors = value + self._emit_event( + "publish.has_validation_errors.changed", + {"value": value} + ) + + def _get_publish_max_progress(self): + return self._publish_max_progress + + def _set_publish_max_progress(self, value): + if self._publish_max_progress != value: + self._publish_max_progress = value + self._emit_event("publish.max_progress.changed", {"value": value}) + + def _get_publish_progress(self): + return self._publish_progress + + def _set_publish_progress(self, value): + if self._publish_progress != value: + self._publish_progress = value + self._emit_event("publish.progress.changed", {"value": value}) + + def _get_publish_error_msg(self): + return self._publish_error_msg + + def _set_publish_error_msg(self, value): + if self._publish_error_msg != value: + self._publish_error_msg = value + self._emit_event("publish.publish_error.changed", {"value": value}) + + host_is_valid = property( + _get_host_is_valid, _set_host_is_valid + ) + publish_has_finished = property( + _get_publish_has_finished, _set_publish_has_finished + ) + publish_is_running = property( + _get_publish_is_running, _set_publish_is_running + ) + publish_has_validated = property( + _get_publish_has_validated, _set_publish_has_validated + ) + publish_has_crashed = property( + _get_publish_has_crashed, _set_publish_has_crashed + ) + publish_has_validation_errors = property( + _get_publish_has_validation_errors, _set_publish_has_validation_errors + ) + publish_max_progress = property( + _get_publish_max_progress, _set_publish_max_progress + ) + publish_progress = property( + _get_publish_progress, _set_publish_progress + ) + publish_error_msg = property( + _get_publish_error_msg, _set_publish_error_msg + ) + + def _reset_attributes(self): + """Reset most of attributes that can be reset.""" + + # Reset creator items + self._creator_items = None + + self.publish_is_running = False + self.publish_has_validated = False + self.publish_has_crashed = False + self.publish_has_validation_errors = False + self.publish_has_finished = False + + self.publish_error_msg = None + self.publish_progress = 0 + + @property + def creator_items(self): + """Creators that can be shown in create dialog.""" + if self._creator_items is None: + self._creator_items = self._collect_creator_items() + return self._creator_items + + @abstractmethod + def _collect_creator_items(self): + """Receive CreatorItems to work with. + + Returns: + Dict[str, CreatorItem]: Creator items by their identifier. + """ + + pass + + def get_creator_icon(self, identifier): + """Function to receive icon for creator identifier. + + Args: + str: Creator's identifier for which should be icon returned. + """ + + creator_item = self.creator_items.get(identifier) + if creator_item is not None: + return creator_item.icon + return None + + +class PublisherController(BasePublisherController): """Middleware between UI, CreateContext and publish Context. Handle both creation and publishing parts. @@ -352,38 +1510,29 @@ class PublisherController: headless (bool): Headless publishing. ATM not implemented or used. """ + _log = None + def __init__(self, dbcon=None, headless=False): - self.log = logging.getLogger("PublisherController") - self.host = registered_host() - self.headless = headless + super(PublisherController, self).__init__() - # Inner event system of controller - self._event_system = EventSystem() + self._host = registered_host() + self._headless = headless - self.create_context = CreateContext( - self.host, dbcon, headless=headless, reset=False + self._create_context = CreateContext( + self._host, dbcon, headless=headless, reset=False ) + self._publish_plugins_proxy = None + # pyblish.api.Context self._publish_context = None # Pyblish report self._publish_report = PublishReport(self) # Store exceptions of validation error - self._publish_validation_errors = [] - # Currently processing plugin errors - self._publish_current_plugin_validation_errors = None - # Any other exception that happened during publishing - self._publish_error = None - # Publishing is in progress - self._publish_is_running = False - # Publishing is over validation order - self._publish_validated = False + self._publish_validation_errors = PublishValidationErrors() + # Publishing should stop at validation stage self._publish_up_validation = False - # All publish plugins are processed - self._publish_finished = False - self._publish_max_progress = 0 - self._publish_progress = 0 # This information is not much important for controller but for widget # which can change (and set) the comment. self._publish_comment_is_set = False @@ -395,8 +1544,6 @@ class PublisherController: pyblish.api.ValidatorOrder + PLUGIN_ORDER_OFFSET ) - # Qt based main thread processor - self._main_thread_processor = MainThreadProcess() # Plugin iterator self._main_thread_iter = None @@ -415,10 +1562,10 @@ class PublisherController: str: Project name. """ - if not hasattr(self.host, "get_current_context"): + if not hasattr(self._host, "get_current_context"): return legacy_io.active_project() - return self.host.get_current_context()["project_name"] + return self._host.get_current_context()["project_name"] @property def current_asset_name(self): @@ -428,10 +1575,10 @@ class PublisherController: Union[str, None]: Asset name or None if asset is not set. """ - if not hasattr(self.host, "get_current_context"): + if not hasattr(self._host, "get_current_context"): return legacy_io.Session["AVALON_ASSET"] - return self.host.get_current_context()["asset_name"] + return self._host.get_current_context()["asset_name"] @property def current_task_name(self): @@ -441,68 +1588,26 @@ class PublisherController: Union[str, None]: Task name or None if task is not set. """ - if not hasattr(self.host, "get_current_context"): + if not hasattr(self._host, "get_current_context"): return legacy_io.Session["AVALON_TASK"] - return self.host.get_current_context()["task_name"] + return self._host.get_current_context()["task_name"] @property def instances(self): """Current instances in create context.""" - return self.create_context.instances + return self._create_context.instances_by_id @property - def creators(self): + def _creators(self): """All creators loaded in create context.""" - return self.create_context.creators + + return self._create_context.creators @property - def manual_creators(self): - """Creators that can be shown in create dialog.""" - return self.create_context.manual_creators - - @property - def host_is_valid(self): - """Host is valid for creation.""" - return self.create_context.host_is_valid - - @property - def publish_plugins(self): + def _publish_plugins(self): """Publish plugins.""" - return self.create_context.publish_plugins - - @property - def plugins_with_defs(self): - """Publish plugins with possible attribute definitions.""" - return self.create_context.plugins_with_defs - - @property - def event_system(self): - """Inner event system for publisher controller. - - Known topics: - "show.detailed.help" - Detailed help requested (UI related). - "show.card.message" - Show card message request (UI related). - "instances.refresh.finished" - Instances are refreshed. - "plugins.refresh.finished" - Plugins refreshed. - "publish.reset.finished" - Controller reset finished. - "publish.process.started" - Publishing started. Can be started from - paused state. - "publish.process.validated" - Publishing passed validation. - "publish.process.stopped" - Publishing stopped/paused process. - "publish.process.plugin.changed" - Plugin state has changed. - "publish.process.instance.changed" - Instance state has changed. - - Returns: - EventSystem: Event system which can trigger callbacks for topics. - """ - - return self._event_system - - def _emit_event(self, topic, data=None): - if data is None: - data = {} - self._event_system.emit(topic, data, "controller") + return self._create_context.publish_plugins # --- Publish specific callbacks --- def get_asset_docs(self): @@ -511,9 +1616,10 @@ class PublisherController: def get_context_title(self): """Get context title for artist shown at the top of main window.""" + context_title = None - if hasattr(self.host, "get_context_title"): - context_title = self.host.get_context_title() + if hasattr(self._host, "get_context_title"): + context_title = self._host.get_context_title() if context_title is None: context_title = os.environ.get("AVALON_APP_NAME") @@ -524,14 +1630,8 @@ class PublisherController: def get_asset_hierarchy(self): """Prepare asset documents into hierarchy.""" - _queue = collections.deque(self.get_asset_docs()) - output = collections.defaultdict(list) - while _queue: - asset_doc = _queue.popleft() - parent_id = asset_doc["data"]["visualParent"] - output[parent_id].append(asset_doc) - return output + return self._asset_docs_cache.get_asset_hierarchy() def get_task_names_by_asset_names(self, asset_names): """Prepare task names by asset name.""" @@ -545,6 +1645,21 @@ class PublisherController: ) return result + def get_existing_subset_names(self, asset_name): + project_name = self.project_name + asset_doc = self._asset_docs_cache.get_asset_by_name(asset_name) + if not asset_doc: + return None + + asset_id = asset_doc["_id"] + subset_docs = get_subsets( + project_name, asset_ids=[asset_id], fields=["name"] + ) + return { + subset_doc["name"] + for subset_doc in subset_docs + } + def reset(self): """Reset everything related to creation and publishing.""" # Stop publishing @@ -552,10 +1667,14 @@ class PublisherController: self.save_changes() + self.host_is_valid = self._create_context.host_is_valid + self.create_context.reset_preparation() # Reset avalon context - self.create_context.reset_avalon_context() + self._create_context.reset_avalon_context() + + self._asset_docs_cache.reset() self._reset_plugins() # Publish part must be reset after plugins @@ -564,6 +1683,8 @@ class PublisherController: self.create_context.reset_finalization() + self._emit_event("controller.reset.finished") + self.emit_card_message("Refreshed..") def _reset_plugins(self): @@ -573,12 +1694,18 @@ class PublisherController: self._resetting_plugins = True - self.create_context.reset_plugins() + self._create_context.reset_plugins() self._resetting_plugins = False self._emit_event("plugins.refresh.finished") + def _collect_creator_items(self): + return { + identifier: CreatorItem.from_creator(creator) + for identifier, creator in self._create_context.creators.items() + } + def _reset_instances(self): """Reset create instances.""" if self._resetting_instances: @@ -586,14 +1713,14 @@ class PublisherController: self._resetting_instances = True - self.create_context.reset_context_data() - with self.create_context.bulk_instances_collection(): - self.create_context.reset_instances() - self.create_context.execute_autocreators() + self._create_context.reset_context_data() + with self._create_context.bulk_instances_collection(): + self._create_context.reset_instances() + self._create_context.execute_autocreators() self._resetting_instances = False - self._emit_event("instances.refresh.finished") + self._on_create_instance_change() def emit_card_message(self, message): self._emit_event("show.card.message", {"message": message}) @@ -602,13 +1729,16 @@ class PublisherController: """Collect creator attribute definitions for multuple instances. Args: - instances(list): List of created instances for + instances(List[CreatedInstance]): List of created instances for which should be attribute definitions returned. """ + output = [] _attr_defs = {} for instance in instances: - for attr_def in instance.creator_attribute_defs: + creator_identifier = instance.creator_identifier + creator_item = self.creator_items[creator_identifier] + for attr_def in creator_item.instance_attributes_defs: found_idx = None for idx, _attr_def in _attr_defs.items(): if attr_def == _attr_def: @@ -636,9 +1766,10 @@ class PublisherController: which should be attribute definitions returned. include_context(bool): Add context specific attribute definitions. """ + _tmp_items = [] if include_context: - _tmp_items.append(self.create_context) + _tmp_items.append(self._create_context) for instance in instances: _tmp_items.append(instance) @@ -668,7 +1799,7 @@ class PublisherController: attr_values.append((item, value)) output = [] - for plugin in self.plugins_with_defs: + for plugin in self._create_context.plugins_with_defs: plugin_name = plugin.__name__ if plugin_name not in all_defs_by_plugin_name: continue @@ -679,86 +1810,89 @@ class PublisherController: )) return output - def get_icon_for_family(self, family): - """TODO rename to get creator icon.""" - creator = self.creators.get(family) - if creator is not None: - return creator.get_icon() - return None + def get_subset_name( + self, + creator_identifier, + variant, + task_name, + asset_name, + instance_id=None + ): + """Get subset name based on passed data. + + Args: + creator_identifier (str): Identifier of creator which should be + responsible for subset name creation. + variant (str): Variant value from user's input. + task_name (str): Name of task for which is instance created. + asset_name (str): Name of asset for which is instance created. + instance_id (Union[str, None]): Existing instance id when subset + name is updated. + """ + + creator = self._creators[creator_identifier] + project_name = self.project_name + asset_doc = self._asset_docs_cache.get_full_asset_by_name(asset_name) + instance = None + if instance_id: + instance = self.instances[instance_id] + + return creator.get_subset_name( + variant, task_name, asset_doc, project_name, instance=instance + ) def create( self, creator_identifier, subset_name, instance_data, options ): """Trigger creation and refresh of instances in UI.""" - creator = self.creators[creator_identifier] + creator = self._creators[creator_identifier] creator.create(subset_name, instance_data, options) - self._emit_event("instances.refresh.finished") + self._on_create_instance_change() def save_changes(self): """Save changes happened during creation.""" - if self.create_context.host_is_valid: - self.create_context.save_changes() + if self._create_context.host_is_valid: + self._create_context.save_changes() - def remove_instances(self, instances): - """""" + def remove_instances(self, instance_ids): + """Remove instances based on instance ids. + + Args: + instance_ids (List[str]): List of instance ids to remove. + """ + # TODO expect instance ids instead of instances # QUESTION Expect that instances are really removed? In that case save # reset is not required and save changes too. self.save_changes() - self.create_context.remove_instances(instances) + self._remove_instances_from_context(instance_ids) + self._on_create_instance_change() + + def _remove_instances_from_context(self, instance_ids): + instances_by_id = self._create_context.instances_by_id + instances = [ + instances_by_id[instance_id] + for instance_id in instance_ids + ] + self._create_context.remove_instances(instances) + + def _on_create_instance_change(self): self._emit_event("instances.refresh.finished") - # --- Publish specific implementations --- - @property - def publish_has_finished(self): - return self._publish_finished - - @property - def publish_is_running(self): - return self._publish_is_running - - @property - def publish_has_validated(self): - return self._publish_validated - - @property - def publish_has_crashed(self): - return bool(self._publish_error) - - @property - def publish_has_validation_errors(self): - return bool(self._publish_validation_errors) - - @property - def publish_max_progress(self): - return self._publish_max_progress - - @property - def publish_progress(self): - return self._publish_progress - - @property - def publish_comment_is_set(self): - return self._publish_comment_is_set - - def get_publish_crash_error(self): - return self._publish_error - def get_publish_report(self): - return self._publish_report.get_report(self.publish_plugins) + return self._publish_report.get_report(self._publish_plugins) def get_validation_errors(self): - return self._publish_validation_errors + return self._publish_validation_errors.create_report() def _reset_publish(self): - self._publish_is_running = False - self._publish_validated = False + self._reset_attributes() + self._publish_up_validation = False - self._publish_finished = False self._publish_comment_is_set = False - self._main_thread_processor.clear() + self._main_thread_iter = self._publish_iterator() self._publish_context = pyblish.api.Context() # Make sure "comment" is set on publish context @@ -767,21 +1901,30 @@ class PublisherController: # - must not be used for changing CreatedInstances during publishing! # QUESTION # - pop the key after first collector using it would be safest option? - self._publish_context.data["create_context"] = self.create_context + self._publish_context.data["create_context"] = self._create_context - self._publish_report.reset(self._publish_context, self.create_context) - self._publish_validation_errors = [] - self._publish_current_plugin_validation_errors = None - self._publish_error = None + self._publish_plugins_proxy = PublishPluginsProxy( + self._publish_plugins + ) - self._publish_max_progress = len(self.publish_plugins) - self._publish_progress = 0 + self._publish_report.reset(self._publish_context, self._create_context) + self._publish_validation_errors.reset(self._publish_plugins_proxy) + + self.publish_max_progress = len(self._publish_plugins) self._emit_event("publish.reset.finished") def set_comment(self, comment): - self._publish_context.data["comment"] = comment - self._publish_comment_is_set = True + """Set comment from ui to pyblish context. + + This should be called always before publishing is started but should + happen only once on first publish start thus variable + '_publish_comment_is_set' is used to keep track about the information. + """ + + if not self._publish_comment_is_set: + self._publish_context.data["comment"] = comment + self._publish_comment_is_set = True def publish(self): """Run publishing.""" @@ -790,40 +1933,42 @@ class PublisherController: def validate(self): """Run publishing and stop after Validation.""" - if self._publish_validated: + if self.publish_has_validated: return self._publish_up_validation = True self._start_publish() def _start_publish(self): """Start or continue in publishing.""" - if self._publish_is_running: + if self.publish_is_running: return # Make sure changes are saved self.save_changes() - self._publish_is_running = True + self.publish_is_running = True self._emit_event("publish.process.started") - self._main_thread_processor.start() + self._publish_next_process() def _stop_publish(self): """Stop or pause publishing.""" - self._publish_is_running = False - self._main_thread_processor.stop() + self.publish_is_running = False self._emit_event("publish.process.stopped") def stop_publish(self): """Stop publishing process (any reason).""" - if self._publish_is_running: + if self.publish_is_running: self._stop_publish() - def run_action(self, plugin, action): + def run_action(self, plugin_id, action_id): # TODO handle result in UI + plugin = self._publish_plugins_proxy.get_plugin(plugin_id) + action = self._publish_plugins_proxy.get_action(action_id) + result = pyblish.plugin.process( plugin, self._publish_context, None, action.id ) @@ -837,21 +1982,24 @@ class PublisherController: # There are validation errors and validation is passed # - can't do any progree if ( - self._publish_validated - and self._publish_validation_errors + self.publish_has_validated + and self.publish_has_validation_errors ): item = MainThreadItem(self.stop_publish) # Any unexpected error happened # - everything should stop - elif self._publish_error: + elif self.publish_has_crashed: item = MainThreadItem(self.stop_publish) # Everything is ok so try to get new processing item else: item = next(self._main_thread_iter) - self._main_thread_processor.add_item(item) + self._process_main_thread_item(item) + + def _process_main_thread_item(self, item): + item() def _publish_iterator(self): """Main logic center of publishing. @@ -866,30 +2014,24 @@ class PublisherController: QUESTION: Does validate button still make sense? """ - for idx, plugin in enumerate(self.publish_plugins): + for idx, plugin in enumerate(self._publish_plugins): self._publish_progress = idx - # Reset current plugin validations error - self._publish_current_plugin_validation_errors = None - # Check if plugin is over validation order - if not self._publish_validated: - self._publish_validated = ( + if not self.publish_has_validated: + self.publish_has_validated = ( plugin.order >= self._validation_order ) - # Trigger callbacks when validation stage is passed - if self._publish_validated: - self._emit_event("publish.process.validated") # Stop if plugin is over validation order and process # should process up to validation. - if self._publish_up_validation and self._publish_validated: + if self._publish_up_validation and self.publish_has_validated: yield MainThreadItem(self.stop_publish) # Stop if validation is over and validation errors happened if ( - self._publish_validated - and self._publish_validation_errors + self.publish_has_validated + and self.publish_has_validation_errors ): yield MainThreadItem(self.stop_publish) @@ -954,24 +2096,17 @@ class PublisherController: self._publish_report.set_plugin_skipped() # Cleanup of publishing process - self._publish_finished = True - self._publish_progress = self._publish_max_progress + self.publish_has_finished = True + self.publish_progress = self.publish_max_progress yield MainThreadItem(self.stop_publish) def _add_validation_error(self, result): - if self._publish_current_plugin_validation_errors is None: - self._publish_current_plugin_validation_errors = { - "plugin": result["plugin"], - "errors": [] - } - self._publish_validation_errors.append( - self._publish_current_plugin_validation_errors - ) - - self._publish_current_plugin_validation_errors["errors"].append({ - "exception": result["error"], - "instance": result["instance"] - }) + self.publish_has_validation_errors = True + self._publish_validation_errors.add_error( + result["plugin"], + result["error"], + result["instance"] + ) def _process_and_continue(self, plugin, instance): result = pyblish.plugin.process( @@ -984,18 +2119,23 @@ class PublisherController: if exception: if ( isinstance(exception, PublishValidationError) - and not self._publish_validated + and not self.publish_has_validated ): self._add_validation_error(result) else: - self._publish_error = exception + if isinstance(exception, KnownPublishError): + msg = str(exception) + else: + msg = ( + "Something went wrong. Send report" + " to your supervisor or OpenPype." + ) + self.publish_error_msg = msg + self.publish_has_crashed = True self._publish_next_process() - def reset_project_data_cache(self): - self._asset_docs_cache.reset() - def collect_families_from_instances(instances, only_active=False): """Collect all families for passed publish instances. diff --git a/openpype/tools/publisher/control_qt.py b/openpype/tools/publisher/control_qt.py new file mode 100644 index 0000000000..56132a4046 --- /dev/null +++ b/openpype/tools/publisher/control_qt.py @@ -0,0 +1,405 @@ +import collections +from abc import abstractmethod, abstractproperty + +from Qt import QtCore + +from openpype.lib.events import Event +from openpype.pipeline.create import CreatedInstance + +from .control import ( + MainThreadItem, + PublisherController, + BasePublisherController, +) + + +class MainThreadProcess(QtCore.QObject): + """Qt based main thread process executor. + + Has timer which controls each 50ms if there is new item to process. + + This approach gives ability to update UI meanwhile plugin is in progress. + """ + + count_timeout = 2 + + def __init__(self): + super(MainThreadProcess, self).__init__() + self._items_to_process = collections.deque() + + timer = QtCore.QTimer() + timer.setInterval(0) + + timer.timeout.connect(self._execute) + + self._timer = timer + self._switch_counter = self.count_timeout + + def process(self, func, *args, **kwargs): + item = MainThreadItem(func, *args, **kwargs) + self.add_item(item) + + def add_item(self, item): + self._items_to_process.append(item) + + def _execute(self): + if not self._items_to_process: + return + + if self._switch_counter > 0: + self._switch_counter -= 1 + return + + self._switch_counter = self.count_timeout + + item = self._items_to_process.popleft() + item.process() + + def start(self): + if not self._timer.isActive(): + self._timer.start() + + def stop(self): + if self._timer.isActive(): + self._timer.stop() + + def clear(self): + if self._timer.isActive(): + self._timer.stop() + self._items_to_process = collections.deque() + + +class QtPublisherController(PublisherController): + def __init__(self, *args, **kwargs): + self._main_thread_processor = MainThreadProcess() + + super(QtPublisherController, self).__init__(*args, **kwargs) + + self.event_system.add_callback( + "publish.process.started", self._qt_on_publish_start + ) + self.event_system.add_callback( + "publish.process.stopped", self._qt_on_publish_stop + ) + + def _reset_publish(self): + super(QtPublisherController, self)._reset_publish() + self._main_thread_processor.clear() + + def _process_main_thread_item(self, item): + self._main_thread_processor.add_item(item) + + def _qt_on_publish_start(self): + self._main_thread_processor.start() + + def _qt_on_publish_stop(self): + self._main_thread_processor.stop() + + +class QtRemotePublishController(BasePublisherController): + """Abstract Remote controller for Qt UI. + + This controller should be used in process where UI is running and should + listen and ask for data on a client side. + + All objects that are used during UI processing should be able to convert + on client side to json serializable data and then recreated here. Keep in + mind that all changes made here should be send back to client controller + before critical actions. + + ATM Was not tested and will require some changes. All code written here is + based on theoretical idea how it could work. + """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self._created_instances = {} + + @abstractmethod + def _get_serialized_instances(self): + """Receive serialized instances from client process. + + Returns: + List[Dict[str, Any]]: Serialized instances. + """ + + pass + + def _on_create_instance_change(self): + serialized_instances = self._get_serialized_instances() + + created_instances = {} + for serialized_data in serialized_instances: + item = CreatedInstance.deserialize_on_remote( + serialized_data, + self._creator_items + ) + created_instances[item.id] = item + + self._created_instances = created_instances + self._emit_event("instances.refresh.finished") + + def remote_events_handler(self, event_data): + event = Event.from_data(event_data) + + # Topics that cause "replication" of controller changes + if event.topic == "publish.max_progress.changed": + self.publish_max_progress = event["value"] + return + + if event.topic == "publish.progress.changed": + self.publish_progress = event["value"] + return + + if event.topic == "publish.has_validated.changed": + self.publish_has_validated = event["value"] + return + + if event.topic == "publish.is_running.changed": + self.publish_is_running = event["value"] + return + + if event.topic == "publish.publish_error.changed": + self.publish_error_msg = event["value"] + return + + if event.topic == "publish.has_crashed.changed": + self.publish_has_crashed = event["value"] + return + + if event.topic == "publish.has_validation_errors.changed": + self.publish_has_validation_errors = event["value"] + return + + if event.topic == "publish.finished.changed": + self.publish_has_finished = event["value"] + return + + if event.topic == "publish.host_is_valid.changed": + self.host_is_valid = event["value"] + return + + # Topics that can be just passed by because are not affecting + # controller itself + # - "show.card.message" + # - "show.detailed.help" + # - "publish.reset.finished" + # - "instances.refresh.finished" + # - "plugins.refresh.finished" + # - "controller.reset.finished" + # - "publish.process.started" + # - "publish.process.stopped" + # - "publish.process.plugin.changed" + # - "publish.process.instance.changed" + self.event_system.emit_event(event) + + @abstractproperty + def project_name(self): + """Current context project name from client. + + Returns: + str: Name of project. + """ + + pass + + @abstractproperty + def current_asset_name(self): + """Current context asset name from client. + + Returns: + Union[str, None]: Name of asset. + """ + + pass + + @abstractproperty + def current_task_name(self): + """Current context task name from client. + + Returns: + Union[str, None]: Name of task. + """ + + pass + + @property + def instances(self): + """Collected/created instances. + + Returns: + List[CreatedInstance]: List of created instances. + """ + + return self._created_instances + + def get_context_title(self): + """Get context title for artist shown at the top of main window. + + Returns: + Union[str, None]: Context title for window or None. In case of None + a warning is displayed (not nice for artists). + """ + + pass + + def get_asset_docs(self): + pass + + def get_asset_hierarchy(self): + pass + + def get_task_names_by_asset_names(self, asset_names): + pass + + def get_existing_subset_names(self, asset_name): + pass + + @abstractmethod + def get_subset_name( + self, + creator_identifier, + variant, + task_name, + asset_name, + instance_id=None + ): + """Get subset name based on passed data. + + Args: + creator_identifier (str): Identifier of creator which should be + responsible for subset name creation. + variant (str): Variant value from user's input. + task_name (str): Name of task for which is instance created. + asset_name (str): Name of asset for which is instance created. + instance_id (Union[str, None]): Existing instance id when subset + name is updated. + """ + + pass + + @abstractmethod + def create( + self, creator_identifier, subset_name, instance_data, options + ): + """Trigger creation by creator identifier. + + Should also trigger refresh of instanes. + + Args: + creator_identifier (str): Identifier of Creator plugin. + subset_name (str): Calculated subset name. + instance_data (Dict[str, Any]): Base instance data with variant, + asset name and task name. + options (Dict[str, Any]): Data from pre-create attributes. + """ + + pass + + def _get_instance_changes_for_client(self): + """Preimplemented method to receive instance changes for client.""" + + created_instance_changes = {} + for instance_id, instance in self._created_instances.items(): + created_instance_changes[instance_id] = ( + instance.remote_changes() + ) + return created_instance_changes + + @abstractmethod + def _send_instance_changes_to_client(self): + instance_changes = self._get_instance_changes_for_client() + # Implement to send 'instance_changes' value to client + + @abstractmethod + def save_changes(self): + """Save changes happened during creation.""" + + self._send_instance_changes_to_client() + + @abstractmethod + def remove_instances(self, instance_ids): + """Remove list of instances from create context.""" + # TODO add Args: + + pass + + @abstractmethod + def get_publish_report(self): + pass + + @abstractmethod + def get_validation_errors(self): + pass + + @abstractmethod + def reset(self): + """Reset whole controller. + + This should reset create context, publish context and all variables + that are related to it. + """ + + self._send_instance_changes_to_client() + pass + + @abstractmethod + def publish(self): + """Trigger publishing without any order limitations.""" + + self._send_instance_changes_to_client() + pass + + @abstractmethod + def validate(self): + """Trigger publishing which will stop after validation order.""" + + self._send_instance_changes_to_client() + pass + + @abstractmethod + def stop_publish(self): + """Stop publishing can be also used to pause publishing. + + Pause of publishing is possible only if all plugins successfully + finished. + """ + + pass + + @abstractmethod + def run_action(self, plugin_id, action_id): + """Trigger pyblish action on a plugin. + + Args: + plugin_id (str): Id of publish plugin. + action_id (str): Id of publish action. + """ + + pass + + @abstractmethod + def set_comment(self, comment): + """Set comment on pyblish context. + + Set "comment" key on current pyblish.api.Context data. + + Args: + comment (str): Artist's comment. + """ + + pass + + @abstractmethod + def emit_card_message(self, message): + """Emit a card message which can have a lifetime. + + This is for UI purposes. Method can be extended to more arguments + in future e.g. different message timeout or type (color). + + Args: + message (str): Message that will be showed. + """ + + pass diff --git a/openpype/tools/publisher/publish_report_viewer/widgets.py b/openpype/tools/publisher/publish_report_viewer/widgets.py index dc82448495..ff388fb277 100644 --- a/openpype/tools/publisher/publish_report_viewer/widgets.py +++ b/openpype/tools/publisher/publish_report_viewer/widgets.py @@ -139,6 +139,9 @@ class PluginLoadReportWidget(QtWidgets.QWidget): class ZoomPlainText(QtWidgets.QPlainTextEdit): + min_point_size = 1.0 + max_point_size = 200.0 + def __init__(self, *args, **kwargs): super(ZoomPlainText, self).__init__(*args, **kwargs) @@ -148,12 +151,12 @@ class ZoomPlainText(QtWidgets.QPlainTextEdit): anim_timer.timeout.connect(self._scaling_callback) self._anim_timer = anim_timer - self._zoom_enabled = False self._scheduled_scalings = 0 self._point_size = None def wheelEvent(self, event): - if not self._zoom_enabled: + modifiers = QtWidgets.QApplication.keyboardModifiers() + if modifiers != QtCore.Qt.ControlModifier: super(ZoomPlainText, self).wheelEvent(event) return @@ -172,33 +175,40 @@ class ZoomPlainText(QtWidgets.QPlainTextEdit): factor = 1.0 + (self._scheduled_scalings / 300) font = self.font() + if self._point_size is None: - self._point_size = font.pointSizeF() + point_size = font.pointSizeF() + else: + point_size = self._point_size - self._point_size *= factor - if self._point_size < 1: - self._point_size = 1.0 + point_size *= factor + min_hit = False + max_hit = False + if point_size < self.min_point_size: + point_size = self.min_point_size + min_hit = True + elif point_size > self.max_point_size: + point_size = self.max_point_size + max_hit = True - font.setPointSizeF(self._point_size) + self._point_size = point_size + + font.setPointSizeF(point_size) # Using 'self.setFont(font)' would not be propagated when stylesheets # are applied on this widget self.setStyleSheet("font-size: {}pt".format(font.pointSize())) - if self._scheduled_scalings > 0: + if ( + (max_hit and self._scheduled_scalings > 0) + or (min_hit and self._scheduled_scalings < 0) + ): + self._scheduled_scalings = 0 + + elif self._scheduled_scalings > 0: self._scheduled_scalings -= 1 else: self._scheduled_scalings += 1 - def keyPressEvent(self, event): - if event.key() == QtCore.Qt.Key_Control: - self._zoom_enabled = True - super(ZoomPlainText, self).keyPressEvent(event) - - def keyReleaseEvent(self, event): - if event.key() == QtCore.Qt.Key_Control: - self._zoom_enabled = False - super(ZoomPlainText, self).keyReleaseEvent(event) - class DetailsWidget(QtWidgets.QWidget): def __init__(self, parent): diff --git a/openpype/tools/publisher/widgets/assets_widget.py b/openpype/tools/publisher/widgets/assets_widget.py index 39bf3886ea..996c9029d4 100644 --- a/openpype/tools/publisher/widgets/assets_widget.py +++ b/openpype/tools/publisher/widgets/assets_widget.py @@ -1,6 +1,7 @@ import collections from Qt import QtWidgets, QtCore, QtGui + from openpype.tools.utils import ( PlaceholderLineEdit, RecursiveSortFilterProxyModel, @@ -163,6 +164,16 @@ class AssetsHierarchyModel(QtGui.QStandardItemModel): return item_name in self._items_by_name +class AssetDialogView(QtWidgets.QTreeView): + double_clicked = QtCore.Signal(QtCore.QModelIndex) + + def mouseDoubleClickEvent(self, event): + index = self.indexAt(event.pos()) + if index.isValid(): + self.double_clicked.emit(index) + event.accept() + + class AssetsDialog(QtWidgets.QDialog): """Dialog to select asset for a context of instance.""" @@ -178,7 +189,7 @@ class AssetsDialog(QtWidgets.QDialog): filter_input = PlaceholderLineEdit(self) filter_input.setPlaceholderText("Filter assets..") - asset_view = QtWidgets.QTreeView(self) + asset_view = AssetDialogView(self) asset_view.setModel(proxy_model) asset_view.setHeaderHidden(True) asset_view.setFrameShape(QtWidgets.QFrame.NoFrame) @@ -200,6 +211,7 @@ class AssetsDialog(QtWidgets.QDialog): layout.addWidget(asset_view, 1) layout.addLayout(btns_layout, 0) + asset_view.double_clicked.connect(self._on_ok_clicked) filter_input.textChanged.connect(self._on_filter_change) ok_btn.clicked.connect(self._on_ok_clicked) cancel_btn.clicked.connect(self._on_cancel_clicked) @@ -274,7 +286,7 @@ class AssetsDialog(QtWidgets.QDialog): index = self._asset_view.currentIndex() asset_name = None if index.isValid(): - asset_name = index.data(QtCore.Qt.DisplayRole) + asset_name = index.data(ASSET_NAME_ROLE) self._selected_asset = asset_name self.done(1) diff --git a/openpype/tools/publisher/widgets/card_view_widgets.py b/openpype/tools/publisher/widgets/card_view_widgets.py index fa391f4ba0..5daf8059b0 100644 --- a/openpype/tools/publisher/widgets/card_view_widgets.py +++ b/openpype/tools/publisher/widgets/card_view_widgets.py @@ -41,9 +41,26 @@ from ..constants import ( ) +class SelectionType: + def __init__(self, name): + self.name = name + + def __eq__(self, other): + if isinstance(other, SelectionType): + other = other.name + return self.name == other + + +class SelectionTypes: + clear = SelectionType("clear") + extend = SelectionType("extend") + extend_to = SelectionType("extend_to") + + class GroupWidget(QtWidgets.QWidget): """Widget wrapping instances under group.""" - selected = QtCore.Signal(str, str) + + selected = QtCore.Signal(str, str, SelectionType) active_changed = QtCore.Signal() removed_selected = QtCore.Signal() @@ -72,21 +89,73 @@ class GroupWidget(QtWidgets.QWidget): self._group_icons = group_icons self._widgets_by_id = {} + self._ordered_instance_ids = [] self._label_widget = label_widget self._content_layout = layout + @property + def group_name(self): + """Group which widget represent. + + Returns: + str: Name of group. + """ + + return self._group + + def get_selected_instance_ids(self): + """Selected instance ids. + + Returns: + Set[str]: Instance ids that are selected. + """ + + return { + instance_id + for instance_id, widget in self._widgets_by_id.items() + if widget.is_selected + } + + def get_selected_widgets(self): + """Access to widgets marked as selected. + + Returns: + List[InstanceCardWidget]: Instance widgets that are selected. + """ + + return [ + widget + for instance_id, widget in self._widgets_by_id.items() + if widget.is_selected + ] + + def get_ordered_widgets(self): + """Get instance ids in order as are shown in ui. + + Returns: + List[str]: Instance ids. + """ + + return [ + self._widgets_by_id[instance_id] + for instance_id in self._ordered_instance_ids + ] + def get_widget_by_instance_id(self, instance_id): """Get instance widget by it's id.""" + return self._widgets_by_id.get(instance_id) def update_instance_values(self): """Trigger update on instance widgets.""" + for widget in self._widgets_by_id.values(): widget.update_instance_values() def confirm_remove_instance_id(self, instance_id): """Delete widget by instance id.""" + widget = self._widgets_by_id.pop(instance_id) widget.setVisible(False) self._content_layout.removeWidget(widget) @@ -123,6 +192,7 @@ class GroupWidget(QtWidgets.QWidget): # Sort instances by subset name sorted_subset_names = list(sorted(instances_by_subset_name.keys())) + # Add new instances to widget widget_idx = 1 for subset_names in sorted_subset_names: @@ -135,17 +205,30 @@ class GroupWidget(QtWidgets.QWidget): widget = InstanceCardWidget( instance, group_icon, self ) - widget.selected.connect(self.selected) + widget.selected.connect(self._on_widget_selection) widget.active_changed.connect(self.active_changed) self._widgets_by_id[instance.id] = widget self._content_layout.insertWidget(widget_idx, widget) widget_idx += 1 + ordered_instance_ids = [] + for idx in range(self._content_layout.count()): + if idx > 0: + item = self._content_layout.itemAt(idx) + widget = item.widget() + if widget is not None: + ordered_instance_ids.append(widget.id) + + self._ordered_instance_ids = ordered_instance_ids + + def _on_widget_selection(self, instance_id, group_id, selection_type): + self.selected.emit(instance_id, group_id, selection_type) + class CardWidget(BaseClickableFrame): """Clickable card used as bigger button.""" - selected = QtCore.Signal(str, str) + selected = QtCore.Signal(str, str, SelectionType) # Group identifier of card # - this must be set because if send when mouse is released with card id _group_identifier = None @@ -157,6 +240,12 @@ class CardWidget(BaseClickableFrame): self._selected = False self._id = None + @property + def id(self): + """Id of card.""" + + return self._id + @property def is_selected(self): """Is card selected.""" @@ -173,7 +262,16 @@ class CardWidget(BaseClickableFrame): def _mouse_release_callback(self): """Trigger selected signal.""" - self.selected.emit(self._id, self._group_identifier) + + modifiers = QtWidgets.QApplication.keyboardModifiers() + selection_type = SelectionTypes.clear + if bool(modifiers & QtCore.Qt.ShiftModifier): + selection_type = SelectionTypes.extend_to + + elif bool(modifiers & QtCore.Qt.ControlModifier): + selection_type = SelectionTypes.extend + + self.selected.emit(self._id, self._group_identifier, selection_type) class ContextCardWidget(CardWidget): @@ -351,10 +449,11 @@ class InstanceCardView(AbstractInstanceView): Wrapper of all widgets in card view. """ + def __init__(self, controller, parent): super(InstanceCardView, self).__init__(parent) - self.controller = controller + self._controller = controller scroll_area = QtWidgets.QScrollArea(self) scroll_area.setWidgetResizable(True) @@ -381,11 +480,12 @@ class InstanceCardView(AbstractInstanceView): self._content_layout = content_layout self._content_widget = content_widget - self._widgets_by_group = {} self._context_widget = None + self._widgets_by_group = {} + self._ordered_groups = [] - self._selected_group = None - self._selected_instance_id = None + self._explicitly_selected_instance_ids = [] + self._explicitly_selected_groups = [] self.setSizePolicy( QtWidgets.QSizePolicy.Minimum, @@ -405,21 +505,30 @@ class InstanceCardView(AbstractInstanceView): result.setWidth(width) return result - def _get_selected_widget(self): - if self._selected_instance_id == CONTEXT_ID: - return self._context_widget + def _get_selected_widgets(self): + output = [] + if ( + self._context_widget is not None + and self._context_widget.is_selected + ): + output.append(self._context_widget) - group_widget = self._widgets_by_group.get( - self._selected_group - ) - if group_widget is not None: - widget = group_widget.get_widget_by_instance_id( - self._selected_instance_id - ) - if widget is not None: - return widget + for group_widget in self._widgets_by_group.values(): + for widget in group_widget.get_selected_widgets(): + output.append(widget) + return output - return None + def _get_selected_instance_ids(self): + output = [] + if ( + self._context_widget is not None + and self._context_widget.is_selected + ): + output.append(CONTEXT_ID) + + for group_widget in self._widgets_by_group.values(): + output.extend(group_widget.get_selected_instance_ids()) + return output def refresh(self): """Refresh instances in view based on CreatedContext.""" @@ -435,12 +544,10 @@ class InstanceCardView(AbstractInstanceView): self.selection_changed.emit() self._content_layout.insertWidget(0, widget) - self.select_item(CONTEXT_ID, None) - # Prepare instances by group and identifiers by group instances_by_group = collections.defaultdict(list) identifiers_by_group = collections.defaultdict(set) - for instance in self.controller.instances: + for instance in self._controller.instances.values(): group_name = instance.group_label instances_by_group[group_name].append(instance) identifiers_by_group[group_name].add( @@ -452,15 +559,17 @@ class InstanceCardView(AbstractInstanceView): if group_name in instances_by_group: continue - if group_name == self._selected_group: - self._on_remove_selected() widget = self._widgets_by_group.pop(group_name) widget.setVisible(False) self._content_layout.removeWidget(widget) widget.deleteLater() + if group_name in self._explicitly_selected_groups: + self._explicitly_selected_groups.remove(group_name) + # Sort groups sorted_group_names = list(sorted(instances_by_group.keys())) + # Keep track of widget indexes # - we start with 1 because Context item as at the top widget_idx = 1 @@ -469,7 +578,7 @@ class InstanceCardView(AbstractInstanceView): group_widget = self._widgets_by_group[group_name] else: group_icons = { - idenfier: self.controller.get_icon_for_family(idenfier) + idenfier: self._controller.get_creator_icon(idenfier) for idenfier in identifiers_by_group[group_name] } @@ -478,9 +587,6 @@ class InstanceCardView(AbstractInstanceView): ) group_widget.active_changed.connect(self._on_active_changed) group_widget.selected.connect(self._on_widget_selection) - group_widget.removed_selected.connect( - self._on_remove_selected - ) self._content_layout.insertWidget(widget_idx, group_widget) self._widgets_by_group[group_name] = group_widget @@ -489,6 +595,16 @@ class InstanceCardView(AbstractInstanceView): instances_by_group[group_name] ) + ordered_group_names = [""] + for idx in range(self._content_layout.count()): + if idx > 0: + item = self._content_layout.itemAt(idx) + group_widget = item.widget() + if group_widget is not None: + ordered_group_names.append(group_widget.group_name) + + self._ordered_groups = ordered_group_names + def refresh_instance_states(self): """Trigger update of instances on group widgets.""" for widget in self._widgets_by_group.values(): @@ -497,10 +613,7 @@ class InstanceCardView(AbstractInstanceView): def _on_active_changed(self): self.active_changed.emit() - def _on_widget_selection(self, instance_id, group_name): - self.select_item(instance_id, group_name) - - def select_item(self, instance_id, group_name): + def _on_widget_selection(self, instance_id, group_name, selection_type): """Select specific item by instance id. Pass `CONTEXT_ID` as instance id and empty string as group to select @@ -512,34 +625,318 @@ class InstanceCardView(AbstractInstanceView): group_widget = self._widgets_by_group[group_name] new_widget = group_widget.get_widget_by_instance_id(instance_id) - selected_widget = self._get_selected_widget() - if new_widget is selected_widget: - return - - if selected_widget is not None: - selected_widget.set_selected(False) - - self._selected_instance_id = instance_id - self._selected_group = group_name - if new_widget is not None: - new_widget.set_selected(True) + if selection_type is SelectionTypes.clear: + self._select_item_clear(instance_id, group_name, new_widget) + elif selection_type is SelectionTypes.extend: + self._select_item_extend(instance_id, group_name, new_widget) + elif selection_type is SelectionTypes.extend_to: + self._select_item_extend_to(instance_id, group_name, new_widget) self.selection_changed.emit() - def _on_remove_selected(self): - selected_widget = self._get_selected_widget() - if selected_widget is None: - self._on_widget_selection(CONTEXT_ID, None) + def _select_item_clear(self, instance_id, group_name, new_widget): + """Select specific item by instance id and clear previous selection. + + Pass `CONTEXT_ID` as instance id and empty string as group to select + global context item. + """ + + selected_widgets = self._get_selected_widgets() + for widget in selected_widgets: + if widget.id != instance_id: + widget.set_selected(False) + + self._explicitly_selected_groups = [group_name] + self._explicitly_selected_instance_ids = [instance_id] + + if new_widget is not None: + new_widget.set_selected(True) + + def _select_item_extend(self, instance_id, group_name, new_widget): + """Add/Remove single item to/from current selection. + + If item is already selected the selection is removed. + """ + + self._explicitly_selected_instance_ids = ( + self._get_selected_instance_ids() + ) + if new_widget.is_selected: + self._explicitly_selected_instance_ids.remove(instance_id) + new_widget.set_selected(False) + remove_group = False + if instance_id == CONTEXT_ID: + remove_group = True + else: + group_widget = self._widgets_by_group[group_name] + if not group_widget.get_selected_widgets(): + remove_group = True + + if remove_group: + self._explicitly_selected_groups.remove(group_name) + return + + self._explicitly_selected_instance_ids.append(instance_id) + if group_name in self._explicitly_selected_groups: + self._explicitly_selected_groups.remove(group_name) + self._explicitly_selected_groups.append(group_name) + new_widget.set_selected(True) + + def _select_item_extend_to(self, instance_id, group_name, new_widget): + """Extend selected items to specific instance id. + + This method is handling Shift+click selection of widgets. Selection + is not stored to explicit selection items. That's because user can + shift select again and it should use last explicit selected item as + source item for selection. + + Items selected via this function can get to explicit selection only if + selection is extended by one specific item ('_select_item_extend'). + From that moment the selection is locked to new last explicit selected + item. + + It's required to traverse through group widgets in their UI order and + through their instances in UI order. All explicitly selected items + must not change their selection state during this function. Passed + instance id can be above or under last selected item so a start item + and end item must be found to be able know which direction is selection + happening. + """ + + # Start group name (in '_ordered_groups') + start_group = None + # End group name (in '_ordered_groups') + end_group = None + # Instance id of first selected item + start_instance_id = None + # Instance id of last selected item + end_instance_id = None + + # Get previously selected group by explicit selected groups + previous_group = None + if self._explicitly_selected_groups: + previous_group = self._explicitly_selected_groups[-1] + + # Find last explicitly selected instance id + previous_last_selected_id = None + if self._explicitly_selected_instance_ids: + previous_last_selected_id = ( + self._explicitly_selected_instance_ids[-1] + ) + + # If last instance id was not found or available then last selected + # group is also invalid. + # NOTE: This probably never happen? + if previous_last_selected_id is None: + previous_group = None + + # Check if previously selected group is available and find out if + # new instance group is above or under previous selection + # - based on these information are start/end group/instance filled + if previous_group in self._ordered_groups: + new_idx = self._ordered_groups.index(group_name) + prev_idx = self._ordered_groups.index(previous_group) + if new_idx < prev_idx: + start_group = group_name + end_group = previous_group + start_instance_id = instance_id + end_instance_id = previous_last_selected_id + else: + start_group = previous_group + end_group = group_name + start_instance_id = previous_last_selected_id + end_instance_id = instance_id + + # If start group is not set then use context item group name + if start_group is None: + start_group = "" + + # If start instance id is not filled then use context id (similar to + # group) + if start_instance_id is None: + start_instance_id = CONTEXT_ID + + # If end group is not defined then use passed group name + # - this can be happen when previous group was not selected + # - when this happens the selection will probably happen from context + # item to item selected by user + if end_group is None: + end_group = group_name + + # If end instance is not filled then use instance selected by user + if end_instance_id is None: + end_instance_id = instance_id + + # Start and end group are the same + # - a different logic is needed in that case + same_group = start_group == end_group + + # Process known information and change selection of items + passed_start_group = False + passed_end_group = False + # Go through ordered groups (from top to bottom) and change selection + for name in self._ordered_groups: + # Prepare sorted instance widgets + if name == "": + sorted_widgets = [self._context_widget] + else: + group_widget = self._widgets_by_group[name] + sorted_widgets = group_widget.get_ordered_widgets() + + # Change selection based on explicit selection if start group + # was not passed yet + if not passed_start_group: + if name != start_group: + for widget in sorted_widgets: + widget.set_selected( + widget.id in self._explicitly_selected_instance_ids + ) + continue + + # Change selection based on explicit selection if end group + # already passed + if passed_end_group: + for widget in sorted_widgets: + widget.set_selected( + widget.id in self._explicitly_selected_instance_ids + ) + continue + + # Start group is already passed and end group was not yet hit + if same_group: + passed_start_group = True + passed_end_group = True + passed_start_instance = False + passed_end_instance = False + for widget in sorted_widgets: + if not passed_start_instance: + if widget.id in (start_instance_id, end_instance_id): + if widget.id != start_instance_id: + # Swap start/end instance if start instance is + # after end + # - fix 'passed_end_instance' check + start_instance_id, end_instance_id = ( + end_instance_id, start_instance_id + ) + passed_start_instance = True + + # Find out if widget should be selected + select = False + if passed_end_instance: + select = False + + elif passed_start_instance: + select = True + + # Check if instance is in explicitly selected items if + # should ont be selected + if ( + not select + and widget.id in self._explicitly_selected_instance_ids + ): + select = True + + widget.set_selected(select) + + if ( + not passed_end_instance + and widget.id == end_instance_id + ): + passed_end_instance = True + + elif name == start_group: + # First group from which selection should start + # - look for start instance first from which the selection + # should happen + passed_start_group = True + passed_start_instance = False + for widget in sorted_widgets: + if widget.id == start_instance_id: + passed_start_instance = True + + select = False + # Check if passed start instance or instance is + # in explicitly selected items to be selected + if ( + passed_start_instance + or widget.id in self._explicitly_selected_instance_ids + ): + select = True + widget.set_selected(select) + + elif name == end_group: + # Last group where selection should happen + # - look for end instance first after which the selection + # should stop + passed_end_group = True + passed_end_instance = False + for widget in sorted_widgets: + select = False + # Check if not yet passed end instance or if instance is + # in explicitly selected items to be selected + if ( + not passed_end_instance + or widget.id in self._explicitly_selected_instance_ids + ): + select = True + + widget.set_selected(select) + + if widget.id == end_instance_id: + passed_end_instance = True + + else: + # Just select everything between start and end group + for widget in sorted_widgets: + widget.set_selected(True) def get_selected_items(self): """Get selected instance ids and context.""" instances = [] - context_selected = False - selected_widget = self._get_selected_widget() - if selected_widget is self._context_widget: - context_selected = True + selected_widgets = self._get_selected_widgets() - elif selected_widget is not None: - instances.append(selected_widget.instance) + context_selected = False + for widget in selected_widgets: + if widget is self._context_widget: + context_selected = True + else: + instances.append(widget.id) return instances, context_selected + + def set_selected_items(self, instance_ids, context_selected): + s_instance_ids = set(instance_ids) + cur_ids, cur_context = self.get_selected_items() + if ( + set(cur_ids) == s_instance_ids + and cur_context == context_selected + ): + return + + selected_groups = [] + selected_instances = [] + if context_selected: + selected_groups.append("") + selected_instances.append(CONTEXT_ID) + + self._context_widget.set_selected(context_selected) + + for group_name in self._ordered_groups: + if group_name == "": + continue + + group_widget = self._widgets_by_group[group_name] + group_selected = False + for widget in group_widget.get_ordered_widgets(): + select = False + if widget.id in s_instance_ids: + selected_instances.append(widget.id) + group_selected = True + select = True + widget.set_selected(select) + + if group_selected: + selected_groups.append(group_name) + + self._explicitly_selected_groups = selected_groups + self._explicitly_selected_instance_ids = selected_instances diff --git a/openpype/tools/publisher/widgets/create_widget.py b/openpype/tools/publisher/widgets/create_widget.py index 4c9fa63d24..10cf39675e 100644 --- a/openpype/tools/publisher/widgets/create_widget.py +++ b/openpype/tools/publisher/widgets/create_widget.py @@ -1,11 +1,9 @@ import sys import re import traceback -import copy from Qt import QtWidgets, QtCore, QtGui -from openpype.client import get_asset_by_name, get_subsets from openpype.pipeline.create import ( CreatorError, SUBSET_NAME_ALLOWED_SYMBOLS, @@ -150,18 +148,18 @@ class CreatorShortDescWidget(QtWidgets.QWidget): self._family_label = family_label self._description_label = description_label - def set_plugin(self, plugin=None): - if not plugin: + def set_creator_item(self, creator_item=None): + if not creator_item: self._icon_widget.set_icon_def(None) self._family_label.setText("") self._description_label.setText("") return - plugin_icon = plugin.get_icon() - description = plugin.get_description() or "" + plugin_icon = creator_item.icon + description = creator_item.description or "" self._icon_widget.set_icon_def(plugin_icon) - self._family_label.setText("{}".format(plugin.family)) + self._family_label.setText("{}".format(creator_item.family)) self._family_label.setTextInteractionFlags(QtCore.Qt.NoTextInteraction) self._description_label.setText(description) @@ -174,7 +172,7 @@ class CreateWidget(QtWidgets.QWidget): self._controller = controller - self._asset_doc = None + self._asset_name = None self._subset_names = None self._selected_creator = None @@ -380,7 +378,7 @@ class CreateWidget(QtWidgets.QWidget): if asset_name is None: asset_name = self.current_asset_name - return asset_name + return asset_name or None def _get_task_name(self): task_name = None @@ -444,7 +442,7 @@ class CreateWidget(QtWidgets.QWidget): prereq_available = False creator_btn_tooltips.append("Creator is not selected") - if self._context_change_is_enabled() and self._asset_doc is None: + if self._context_change_is_enabled() and self._asset_name is None: # QUESTION how to handle invalid asset? prereq_available = False creator_btn_tooltips.append("Context is not selected") @@ -468,30 +466,19 @@ class CreateWidget(QtWidgets.QWidget): asset_name = self._get_asset_name() # Skip if asset did not change - if self._asset_doc and self._asset_doc["name"] == asset_name: + if self._asset_name and self._asset_name == asset_name: return - # Make sure `_asset_doc` and `_subset_names` variables are reset - self._asset_doc = None + # Make sure `_asset_name` and `_subset_names` variables are reset + self._asset_name = asset_name self._subset_names = None if asset_name is None: return - project_name = self._controller.project_name - asset_doc = get_asset_by_name(project_name, asset_name) - self._asset_doc = asset_doc + subset_names = self._controller.get_existing_subset_names(asset_name) - if asset_doc: - asset_id = asset_doc["_id"] - subset_docs = get_subsets( - project_name, asset_ids=[asset_id], fields=["name"] - ) - self._subset_names = { - subset_doc["name"] - for subset_doc in subset_docs - } - - if not asset_doc: + self._subset_names = subset_names + if subset_names is None: self.subset_name_input.setText("< Asset is not set >") def _refresh_creators(self): @@ -506,7 +493,10 @@ class CreateWidget(QtWidgets.QWidget): # Add new families new_creators = set() - for identifier, creator in self._controller.manual_creators.items(): + for identifier, creator_item in self._controller.creator_items.items(): + if creator_item.creator_type != "artist": + continue + # TODO add details about creator new_creators.add(identifier) if identifier in existing_items: @@ -518,10 +508,9 @@ class CreateWidget(QtWidgets.QWidget): ) self._creators_model.appendRow(item) - label = creator.label or identifier - item.setData(label, QtCore.Qt.DisplayRole) + item.setData(creator_item.label, QtCore.Qt.DisplayRole) item.setData(identifier, CREATOR_IDENTIFIER_ROLE) - item.setData(creator.family, FAMILY_ROLE) + item.setData(creator_item.family, FAMILY_ROLE) # Remove families that are no more available for identifier in (old_creators - new_creators): @@ -572,11 +561,11 @@ class CreateWidget(QtWidgets.QWidget): identifier = new_index.data(CREATOR_IDENTIFIER_ROLE) self._set_creator_by_identifier(identifier) - def _set_creator_detailed_text(self, creator): + def _set_creator_detailed_text(self, creator_item): # TODO implement description = "" - if creator is not None: - description = creator.get_detail_description() or description + if creator_item is not None: + description = creator_item.detailed_description or description self._controller.event_system.emit( "show.detailed.help", { @@ -586,32 +575,39 @@ class CreateWidget(QtWidgets.QWidget): ) def _set_creator_by_identifier(self, identifier): - creator = self._controller.manual_creators.get(identifier) - self._set_creator(creator) + creator_item = self._controller.creator_items.get(identifier) + self._set_creator(creator_item) - def _set_creator(self, creator): - self._creator_short_desc_widget.set_plugin(creator) - self._set_creator_detailed_text(creator) - self._pre_create_widget.set_plugin(creator) + def _set_creator(self, creator_item): + """Set current creator item. - self._selected_creator = creator + Args: + creator_item (CreatorItem): Item representing creator that can be + triggered by artist. + """ - if not creator: + self._creator_short_desc_widget.set_creator_item(creator_item) + self._set_creator_detailed_text(creator_item) + self._pre_create_widget.set_creator_item(creator_item) + + self._selected_creator = creator_item + + if not creator_item: self._set_context_enabled(False) return if ( - creator.create_allow_context_change + creator_item.create_allow_context_change != self._context_change_is_enabled() ): - self._set_context_enabled(creator.create_allow_context_change) + self._set_context_enabled(creator_item.create_allow_context_change) self._refresh_asset() - default_variants = creator.get_default_variants() + default_variants = creator_item.default_variants if not default_variants: default_variants = ["Main"] - default_variant = creator.get_default_variant() + default_variant = creator_item.default_variant if not default_variant: default_variant = default_variants[0] @@ -670,14 +666,13 @@ class CreateWidget(QtWidgets.QWidget): self.subset_name_input.setText("< Valid variant >") return - project_name = self._controller.project_name + asset_name = self._get_asset_name() task_name = self._get_task_name() - - asset_doc = copy.deepcopy(self._asset_doc) + creator_idenfier = self._selected_creator.identifier # Calculate subset name with Creator plugin try: - subset_name = self._selected_creator.get_subset_name( - variant_value, task_name, asset_doc, project_name + subset_name = self._controller.get_subset_name( + creator_idenfier, variant_value, task_name, asset_name ) except TaskNotSetError: self._create_btn.setEnabled(False) diff --git a/openpype/tools/publisher/widgets/help_widget.py b/openpype/tools/publisher/widgets/help_widget.py index 7da07b1e78..0090111889 100644 --- a/openpype/tools/publisher/widgets/help_widget.py +++ b/openpype/tools/publisher/widgets/help_widget.py @@ -44,8 +44,10 @@ class HelpWidget(QtWidgets.QWidget): if commonmark: html = commonmark.commonmark(text) self._detail_description_input.setHtml(html) - else: + elif hasattr(self._detail_description_input, "setMarkdown"): self._detail_description_input.setMarkdown(text) + else: + self._detail_description_input.setText(text) class HelpDialog(QtWidgets.QDialog): diff --git a/openpype/tools/publisher/widgets/list_view_widgets.py b/openpype/tools/publisher/widgets/list_view_widgets.py index a701181e5b..c329ca0e8c 100644 --- a/openpype/tools/publisher/widgets/list_view_widgets.py +++ b/openpype/tools/publisher/widgets/list_view_widgets.py @@ -409,7 +409,7 @@ class InstanceListView(AbstractInstanceView): def __init__(self, controller, parent): super(InstanceListView, self).__init__(parent) - self.controller = controller + self._controller = controller instance_view = InstanceTreeView(self) instance_delegate = ListItemDelegate(instance_view) @@ -520,7 +520,7 @@ class InstanceListView(AbstractInstanceView): # Prepare instances by their groups instances_by_group_name = collections.defaultdict(list) group_names = set() - for instance in self.controller.instances: + for instance in self._controller.instances.values(): group_label = instance.group_label group_names.add(group_label) instances_by_group_name[group_label].append(instance) @@ -723,13 +723,13 @@ class InstanceListView(AbstractInstanceView): widget.update_instance_values() def _on_active_changed(self, changed_instance_id, new_value): - selected_instances, _ = self.get_selected_items() + selected_instance_ids, _ = self.get_selected_items() selected_ids = set() found = False - for instance in selected_instances: - selected_ids.add(instance.id) - if not found and instance.id == changed_instance_id: + for instance_id in selected_instance_ids: + selected_ids.add(instance_id) + if not found and instance_id == changed_instance_id: found = True if not found: @@ -760,32 +760,6 @@ class InstanceListView(AbstractInstanceView): if changed_ids: self.active_changed.emit() - def get_selected_items(self): - """Get selected instance ids and context selection. - - Returns: - tuple: Selected instance ids and boolean if context - is selected. - """ - instances = [] - context_selected = False - instances_by_id = { - instance.id: instance - for instance in self.controller.instances - } - - for index in self._instance_view.selectionModel().selectedIndexes(): - instance_id = index.data(INSTANCE_ID_ROLE) - if not context_selected and instance_id == CONTEXT_ID: - context_selected = True - - elif instance_id is not None: - instance = instances_by_id.get(instance_id) - if instance: - instances.append(instance) - - return instances, context_selected - def _on_selection_change(self, *_args): self.selection_changed.emit() @@ -825,3 +799,102 @@ class InstanceListView(AbstractInstanceView): proxy_index = self._proxy_model.mapFromSource(group_item.index()) if not self._instance_view.isExpanded(proxy_index): self._instance_view.expand(proxy_index) + + def get_selected_items(self): + """Get selected instance ids and context selection. + + Returns: + tuple: Selected instance ids and boolean if context + is selected. + """ + instance_ids = [] + context_selected = False + + for index in self._instance_view.selectionModel().selectedIndexes(): + instance_id = index.data(INSTANCE_ID_ROLE) + if not context_selected and instance_id == CONTEXT_ID: + context_selected = True + + elif instance_id is not None: + instance_ids.append(instance_id) + + return instance_ids, context_selected + + def set_selected_items(self, instance_ids, context_selected): + s_instance_ids = set(instance_ids) + cur_ids, cur_context = self.get_selected_items() + if ( + set(cur_ids) == s_instance_ids + and cur_context == context_selected + ): + return + + view = self._instance_view + src_model = self._instance_model + proxy_model = self._proxy_model + + select_indexes = [] + + select_queue = collections.deque() + select_queue.append( + (src_model.invisibleRootItem(), []) + ) + while select_queue: + queue_item = select_queue.popleft() + item, parent_items = queue_item + + if item.hasChildren(): + new_parent_items = list(parent_items) + new_parent_items.append(item) + for row in range(item.rowCount()): + select_queue.append( + (item.child(row), list(new_parent_items)) + ) + + instance_id = item.data(INSTANCE_ID_ROLE) + if not instance_id: + continue + + if instance_id in s_instance_ids: + select_indexes.append(item.index()) + for parent_item in parent_items: + index = parent_item.index() + proxy_index = proxy_model.mapFromSource(index) + if not view.isExpanded(proxy_index): + view.expand(proxy_index) + + elif context_selected and instance_id == CONTEXT_ID: + select_indexes.append(item.index()) + + selection_model = view.selectionModel() + if not select_indexes: + selection_model.clear() + return + + if len(select_indexes) == 1: + proxy_index = proxy_model.mapFromSource(select_indexes[0]) + selection_model.setCurrentIndex( + proxy_index, + selection_model.ClearAndSelect | selection_model.Rows + ) + return + + first_index = proxy_model.mapFromSource(select_indexes.pop(0)) + last_index = proxy_model.mapFromSource(select_indexes.pop(-1)) + + selection_model.setCurrentIndex( + first_index, + selection_model.ClearAndSelect | selection_model.Rows + ) + + for index in select_indexes: + proxy_index = proxy_model.mapFromSource(index) + selection_model.select( + proxy_index, + selection_model.Select | selection_model.Rows + ) + + selection_model.setCurrentIndex( + last_index, + selection_model.Select | selection_model.Rows + ) diff --git a/openpype/tools/publisher/widgets/overview_widget.py b/openpype/tools/publisher/widgets/overview_widget.py index 08c2ce0513..5bd3017c2a 100644 --- a/openpype/tools/publisher/widgets/overview_widget.py +++ b/openpype/tools/publisher/widgets/overview_widget.py @@ -201,16 +201,16 @@ class OverviewWidget(QtWidgets.QFrame): self.create_requested.emit() def _on_delete_clicked(self): - instances, _ = self.get_selected_items() + instance_ids, _ = self.get_selected_items() # Ask user if he really wants to remove instances dialog = QtWidgets.QMessageBox(self) dialog.setIcon(QtWidgets.QMessageBox.Question) dialog.setWindowTitle("Are you sure?") - if len(instances) > 1: + if len(instance_ids) > 1: msg = ( "Do you really want to remove {} instances?" - ).format(len(instances)) + ).format(len(instance_ids)) else: msg = ( "Do you really want to remove the instance?" @@ -224,7 +224,8 @@ class OverviewWidget(QtWidgets.QFrame): dialog.exec_() # Skip if OK was not clicked if dialog.result() == QtWidgets.QMessageBox.Ok: - self._controller.remove_instances(instances) + instance_ids = set(instance_ids) + self._controller.remove_instances(instance_ids) def _on_change_view_clicked(self): self._change_view_type() @@ -234,11 +235,16 @@ class OverviewWidget(QtWidgets.QFrame): if self._refreshing_instances: return - instances, context_selected = self.get_selected_items() + instance_ids, context_selected = self.get_selected_items() # Disable delete button if nothing is selected - self._delete_btn.setEnabled(len(instances) > 0) + self._delete_btn.setEnabled(len(instance_ids) > 0) + instances_by_id = self._controller.instances + instances = [ + instances_by_id[instance_id] + for instance_id in instance_ids + ] self._subset_attributes_widget.set_current_instances( instances, context_selected ) @@ -315,15 +321,21 @@ class OverviewWidget(QtWidgets.QFrame): def _change_view_type(self): idx = self._subset_views_layout.currentIndex() new_idx = (idx + 1) % self._subset_views_layout.count() - self._subset_views_layout.setCurrentIndex(new_idx) - new_view = self._subset_views_layout.currentWidget() + old_view = self._subset_views_layout.currentWidget() + new_view = self._subset_views_layout.widget(new_idx) + if not new_view.refreshed: new_view.refresh() new_view.set_refreshed(True) else: new_view.refresh_instance_states() + instance_ids, context_selected = old_view.get_selected_items() + new_view.set_selected_items(instance_ids, context_selected) + + self._subset_views_layout.setCurrentIndex(new_idx) + self._on_subset_change() def _refresh_instances(self): diff --git a/openpype/tools/publisher/widgets/precreate_widget.py b/openpype/tools/publisher/widgets/precreate_widget.py index eaadfe890b..ef34c9bcb5 100644 --- a/openpype/tools/publisher/widgets/precreate_widget.py +++ b/openpype/tools/publisher/widgets/precreate_widget.py @@ -58,12 +58,12 @@ class PreCreateWidget(QtWidgets.QWidget): def current_value(self): return self._attributes_widget.current_value() - def set_plugin(self, creator): + def set_creator_item(self, creator_item): attr_defs = [] creator_selected = False - if creator is not None: + if creator_item is not None: creator_selected = True - attr_defs = creator.get_pre_create_attr_defs() + attr_defs = creator_item.pre_create_attributes_defs self._attributes_widget.set_attr_defs(attr_defs) diff --git a/openpype/tools/publisher/widgets/publish_frame.py b/openpype/tools/publisher/widgets/publish_frame.py index ddaac7027d..e6333a104f 100644 --- a/openpype/tools/publisher/widgets/publish_frame.py +++ b/openpype/tools/publisher/widgets/publish_frame.py @@ -4,8 +4,6 @@ import time from Qt import QtWidgets, QtCore -from openpype.pipeline import KnownPublishError - from .widgets import ( StopBtn, ResetBtn, @@ -170,7 +168,7 @@ class PublishFrame(QtWidgets.QWidget): "publish.process.started", self._on_publish_start ) controller.event_system.add_callback( - "publish.process.validated", self._on_publish_validated + "publish.has_validated.changed", self._on_publish_validated_change ) controller.event_system.add_callback( "publish.process.stopped", self._on_publish_stop @@ -185,7 +183,7 @@ class PublishFrame(QtWidgets.QWidget): self._shrunk_anim = shrunk_anim - self.controller = controller + self._controller = controller self._content_frame = content_frame self._content_layout = content_layout @@ -320,8 +318,8 @@ class PublishFrame(QtWidgets.QWidget): self._validate_btn.setEnabled(True) self._publish_btn.setEnabled(True) - self._progress_bar.setValue(self.controller.publish_progress) - self._progress_bar.setMaximum(self.controller.publish_max_progress) + self._progress_bar.setValue(self._controller.publish_progress) + self._progress_bar.setMaximum(self._controller.publish_max_progress) def _on_publish_start(self): if self._last_plugin_label: @@ -330,7 +328,7 @@ class PublishFrame(QtWidgets.QWidget): if self._last_instance_label: self._instance_label.setText(self._last_instance_label) - self._set_success_property(-1) + self._set_success_property(3) self._set_progress_visibility(True) self._set_main_label("Publishing...") @@ -341,8 +339,9 @@ class PublishFrame(QtWidgets.QWidget): self.set_shrunk_state(False) - def _on_publish_validated(self): - self._validate_btn.setEnabled(False) + def _on_publish_validated_change(self, event): + if event["value"]: + self._validate_btn.setEnabled(False) def _on_instance_change(self, event): """Change instance label when instance is going to be processed.""" @@ -355,12 +354,12 @@ class PublishFrame(QtWidgets.QWidget): """Change plugin label when instance is going to be processed.""" self._last_plugin_label = event["plugin_label"] - self._progress_bar.setValue(self.controller.publish_progress) + self._progress_bar.setValue(self._controller.publish_progress) self._plugin_label.setText(event["plugin_label"]) QtWidgets.QApplication.processEvents() def _on_publish_stop(self): - self._progress_bar.setValue(self.controller.publish_progress) + self._progress_bar.setValue(self._controller.publish_progress) self._reset_btn.setEnabled(True) self._stop_btn.setEnabled(False) @@ -368,33 +367,31 @@ class PublishFrame(QtWidgets.QWidget): self._instance_label.setText("") self._plugin_label.setText("") - validate_enabled = not self.controller.publish_has_crashed - publish_enabled = not self.controller.publish_has_crashed + validate_enabled = not self._controller.publish_has_crashed + publish_enabled = not self._controller.publish_has_crashed if validate_enabled: - validate_enabled = not self.controller.publish_has_validated + validate_enabled = not self._controller.publish_has_validated if publish_enabled: if ( - self.controller.publish_has_validated - and self.controller.publish_has_validation_errors + self._controller.publish_has_validated + and self._controller.publish_has_validation_errors ): publish_enabled = False else: - publish_enabled = not self.controller.publish_has_finished + publish_enabled = not self._controller.publish_has_finished self._validate_btn.setEnabled(validate_enabled) self._publish_btn.setEnabled(publish_enabled) - error = self.controller.get_publish_crash_error() - validation_errors = self.controller.get_validation_errors() - if error: - self._set_error(error) + if self._controller.publish_has_crashed: + self._set_error_msg() - elif validation_errors: + elif self._controller.publish_has_validation_errors: self._set_progress_visibility(False) self._set_validation_errors() - elif self.controller.publish_has_finished: + elif self._controller.publish_has_finished: self._set_finished() else: @@ -402,7 +399,7 @@ class PublishFrame(QtWidgets.QWidget): def _set_stopped(self): main_label = "Publish paused" - if self.controller.publish_has_validated: + if self._controller.publish_has_validated: main_label += " - Validation passed" self._set_main_label(main_label) @@ -410,20 +407,16 @@ class PublishFrame(QtWidgets.QWidget): "Hit publish (play button) to continue." ) - self._set_success_property(-1) + self._set_success_property(4) + + def _set_error_msg(self): + """Show error message to artist on publish crash.""" - def _set_error(self, error): self._set_main_label("Error happened") - if isinstance(error, KnownPublishError): - msg = str(error) - else: - msg = ( - "Something went wrong. Send report" - " to your supervisor or OpenPype." - ) - self._message_label_top.setText(msg) - self._set_success_property(0) + self._message_label_top.setText(self._controller.publish_error_msg) + + self._set_success_property(1) def _set_validation_errors(self): self._set_main_label("Your publish didn't pass studio validations") @@ -433,7 +426,7 @@ class PublishFrame(QtWidgets.QWidget): def _set_finished(self): self._set_main_label("Finished") self._message_label_top.setText("") - self._set_success_property(1) + self._set_success_property(0) def _set_progress_visibility(self, visible): window_height = self.height() @@ -454,6 +447,17 @@ class PublishFrame(QtWidgets.QWidget): self.move(window_pos.x(), window_pos_y) def _set_success_property(self, state=None): + """Apply styles by state. + + State enum: + - None - Default state after restart + - 0 - Success finish + - 1 - Error happened + - 2 - Validation error + - 3 - In progress + - 4 - Stopped/Paused + """ + if state is None: state = "" else: @@ -465,7 +469,7 @@ class PublishFrame(QtWidgets.QWidget): widget.style().polish(widget) def _copy_report(self): - logs = self.controller.get_publish_report() + logs = self._controller.get_publish_report() logs_string = json.dumps(logs, indent=4) mime_data = QtCore.QMimeData() @@ -488,7 +492,7 @@ class PublishFrame(QtWidgets.QWidget): if not ext or not new_filepath: return - logs = self.controller.get_publish_report() + logs = self._controller.get_publish_report() full_path = new_filepath + ext dir_path = os.path.dirname(full_path) if not os.path.exists(dir_path): @@ -508,13 +512,13 @@ class PublishFrame(QtWidgets.QWidget): self.details_page_requested.emit() def _on_reset_clicked(self): - self.controller.reset() + self._controller.reset() def _on_stop_clicked(self): - self.controller.stop_publish() + self._controller.stop_publish() def _on_validate_clicked(self): - self.controller.validate() + self._controller.validate() def _on_publish_clicked(self): - self.controller.publish() + self._controller.publish() diff --git a/openpype/tools/publisher/widgets/validations_widget.py b/openpype/tools/publisher/widgets/validations_widget.py index a24797de3e..8c483e8088 100644 --- a/openpype/tools/publisher/widgets/validations_widget.py +++ b/openpype/tools/publisher/widgets/validations_widget.py @@ -50,6 +50,7 @@ class ValidationErrorTitleWidget(QtWidgets.QWidget): Has toggle button to show/hide instances on which validation error happened if there is a list (Valdation error may happen on context). """ + selected = QtCore.Signal(int) instance_changed = QtCore.Signal(int) @@ -75,34 +76,31 @@ class ValidationErrorTitleWidget(QtWidgets.QWidget): title_frame_layout.addWidget(toggle_instance_btn, 0) instances_model = QtGui.QStandardItemModel() - error_info = error_info["error_info"] help_text_by_instance_id = {} - context_validation = False - items = [] - if ( - not error_info - or (len(error_info) == 1 and error_info[0][0] is None) - ): - context_validation = True - toggle_instance_btn.setArrowType(QtCore.Qt.NoArrow) - description = self._prepare_description(error_info[0][1]) - help_text_by_instance_id[None] = description - # Add fake item to have minimum size hint of view widget - items.append(QtGui.QStandardItem("Context")) - else: - for instance, exception in error_info: - label = instance.data.get("label") or instance.data.get("name") - item = QtGui.QStandardItem(label) - item.setFlags( - QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable - ) - item.setData(label, QtCore.Qt.ToolTipRole) - item.setData(instance.id, INSTANCE_ID_ROLE) - items.append(item) - description = self._prepare_description(exception) - help_text_by_instance_id[instance.id] = description + items = [] + context_validation = False + for error_item in error_info["error_items"]: + context_validation = error_item.context_validation + if context_validation: + toggle_instance_btn.setArrowType(QtCore.Qt.NoArrow) + description = self._prepare_description(error_item) + help_text_by_instance_id[None] = description + # Add fake item to have minimum size hint of view widget + items.append(QtGui.QStandardItem("Context")) + continue + + label = error_item.instance_label + item = QtGui.QStandardItem(label) + item.setFlags( + QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable + ) + item.setData(label, QtCore.Qt.ToolTipRole) + item.setData(error_item.instance_id, INSTANCE_ID_ROLE) + items.append(item) + description = self._prepare_description(error_item) + help_text_by_instance_id[error_item.instance_id] = description if items: root_item = instances_model.invisibleRootItem() @@ -167,9 +165,19 @@ class ValidationErrorTitleWidget(QtWidgets.QWidget): def minimumSizeHint(self): return self.sizeHint() - def _prepare_description(self, exception): - dsc = exception.description - detail = exception.detail + def _prepare_description(self, error_item): + """Prepare description text for detail intput. + + Args: + error_item (ValidationErrorItem): Item which hold information about + validation error. + + Returns: + str: Prepared detailed description. + """ + + dsc = error_item.description + detail = error_item.detail if detail: dsc += "

{}".format(detail) @@ -196,32 +204,51 @@ class ValidationErrorTitleWidget(QtWidgets.QWidget): @property def is_selected(self): - """Is widget marked a selected""" + """Is widget marked a selected. + + Returns: + bool: Item is selected or not. + """ + return self._selected @property def index(self): - """Widget's index set by parent.""" + """Widget's index set by parent. + + Returns: + int: Index of widget. + """ + return self._index def set_index(self, index): - """Set index of widget (called by parent).""" + """Set index of widget (called by parent). + + Args: + int: New index of widget. + """ + self._index = index def _change_style_property(self, selected): """Change style of widget based on selection.""" + value = "1" if selected else "" self._title_frame.setProperty("selected", value) self._title_frame.style().polish(self._title_frame) def set_selected(self, selected=None): """Change selected state of widget.""" + if selected is None: selected = not self._selected + # Clear instance view selection on deselect if not selected: self._instances_view.clearSelection() + # Skip if has same value if selected == self._selected: return @@ -263,18 +290,23 @@ class ActionButton(BaseClickableFrame): """Plugin's action callback button. Action may have label or icon or both. - """ - action_clicked = QtCore.Signal(str) - def __init__(self, action, parent): + Args: + plugin_action_item (PublishPluginActionItem): Action item that can be + triggered by it's id. + """ + + action_clicked = QtCore.Signal(str, str) + + def __init__(self, plugin_action_item, parent): super(ActionButton, self).__init__(parent) self.setObjectName("ValidationActionButton") - self.action = action + self.plugin_action_item = plugin_action_item - action_label = action.label or action.__name__ - action_icon = getattr(action, "icon", None) + action_label = plugin_action_item.label + action_icon = plugin_action_item.icon label_widget = QtWidgets.QLabel(action_label, self) icon_label = None if action_icon: @@ -292,7 +324,10 @@ class ActionButton(BaseClickableFrame): ) def _mouse_release_callback(self): - self.action_clicked.emit(self.action.id) + self.action_clicked.emit( + self.plugin_action_item.plugin_id, + self.plugin_action_item.action_id + ) class ValidateActionsWidget(QtWidgets.QFrame): @@ -300,6 +335,7 @@ class ValidateActionsWidget(QtWidgets.QFrame): Change actions based on selected validation error. """ + def __init__(self, controller, parent): super(ValidateActionsWidget, self).__init__(parent) @@ -312,10 +348,9 @@ class ValidateActionsWidget(QtWidgets.QFrame): layout.setContentsMargins(0, 0, 0, 0) layout.addWidget(content_widget) - self.controller = controller + self._controller = controller self._content_widget = content_widget self._content_layout = content_layout - self._plugin = None self._actions_mapping = {} def clear(self): @@ -328,28 +363,34 @@ class ValidateActionsWidget(QtWidgets.QFrame): widget.deleteLater() self._actions_mapping = {} - def set_plugin(self, plugin): + def set_error_item(self, error_item): """Set selected plugin and show it's actions. Clears current actions from widget and recreate them from the plugin. + + Args: + Dict[str, Any]: Object holding error items, title and possible + actions to run. """ + self.clear() - self._plugin = plugin - if not plugin: + + if not error_item: self.setVisible(False) return - actions = getattr(plugin, "actions", []) - for action in actions: - if not action.active: + plugin_action_items = error_item["plugin_action_items"] + for plugin_action_item in plugin_action_items: + if not plugin_action_item.active: continue - if action.on not in ("failed", "all"): + if plugin_action_item.on_filter not in ("failed", "all"): continue - self._actions_mapping[action.id] = action + action_id = plugin_action_item.action_id + self._actions_mapping[action_id] = plugin_action_item - action_btn = ActionButton(action, self._content_widget) + action_btn = ActionButton(plugin_action_item, self._content_widget) action_btn.action_clicked.connect(self._on_action_click) self._content_layout.addWidget(action_btn) @@ -359,9 +400,8 @@ class ValidateActionsWidget(QtWidgets.QFrame): else: self.setVisible(False) - def _on_action_click(self, action_id): - action = self._actions_mapping[action_id] - self.controller.run_action(self._plugin, action) + def _on_action_click(self, plugin_id, action_id): + self._controller.run_action(plugin_id, action_id) class VerticallScrollArea(QtWidgets.QScrollArea): @@ -373,6 +413,7 @@ class VerticallScrollArea(QtWidgets.QScrollArea): Resize if deferred by 100ms because at the moment of resize are not yet propagated sizes and visibility of scroll bars. """ + def __init__(self, *args, **kwargs): super(VerticallScrollArea, self).__init__(*args, **kwargs) @@ -584,45 +625,31 @@ class ValidationsWidget(QtWidgets.QFrame): self._errors_widget.setVisible(False) self._actions_widget.setVisible(False) - def set_errors(self, errors): - """Set errors into context and created titles.""" + def _set_errors(self, validation_error_report): + """Set errors into context and created titles. + + Args: + validation_error_report (PublishValidationErrorsReport): Report + with information about validation errors and publish plugin + actions. + """ + self.clear() - if not errors: + if not validation_error_report: return self._top_label.setVisible(True) self._error_details_frame.setVisible(True) self._errors_widget.setVisible(True) - errors_by_title = [] - for plugin_info in errors: - titles = [] - error_info_by_title = {} - - for error_info in plugin_info["errors"]: - exception = error_info["exception"] - title = exception.title - if title not in titles: - titles.append(title) - error_info_by_title[title] = [] - error_info_by_title[title].append( - (error_info["instance"], exception) - ) - - for title in titles: - errors_by_title.append({ - "plugin": plugin_info["plugin"], - "error_info": error_info_by_title[title], - "title": title - }) - - for idx, item in enumerate(errors_by_title): - widget = ValidationErrorTitleWidget(idx, item, self) + grouped_error_items = validation_error_report.group_items_by_title() + for idx, error_info in enumerate(grouped_error_items): + widget = ValidationErrorTitleWidget(idx, error_info, self) widget.selected.connect(self._on_select) widget.instance_changed.connect(self._on_instance_change) self._errors_layout.addWidget(widget) self._title_widgets[idx] = widget - self._error_info[idx] = item + self._error_info[idx] = error_info self._errors_layout.addStretch(1) @@ -648,7 +675,7 @@ class ValidationsWidget(QtWidgets.QFrame): if self._controller.publish_has_validation_errors: validation_errors = self._controller.get_validation_errors() self._set_current_widget(self._validations_widget) - self.set_errors(validation_errors) + self._set_errors(validation_errors) return if self._controller.publish_has_finished: @@ -667,7 +694,7 @@ class ValidationsWidget(QtWidgets.QFrame): error_item = self._error_info[index] - self._actions_widget.set_plugin(error_item["plugin"]) + self._actions_widget.set_error_item(error_item) self._update_description() @@ -682,5 +709,7 @@ class ValidationsWidget(QtWidgets.QFrame): if commonmark: html = commonmark.commonmark(description) self._error_details_input.setHtml(html) - else: + elif hasattr(self._error_details_input, "setMarkdown"): self._error_details_input.setMarkdown(description) + else: + self._error_details_input.setText(description) diff --git a/openpype/tools/publisher/widgets/widgets.py b/openpype/tools/publisher/widgets/widgets.py index d7b10d24fb..ddbe1eb6b7 100644 --- a/openpype/tools/publisher/widgets/widgets.py +++ b/openpype/tools/publisher/widgets/widgets.py @@ -306,10 +306,25 @@ class AbstractInstanceView(QtWidgets.QWidget): Example: When delete button is clicked to know what should be deleted. """ + raise NotImplementedError(( "{} Method 'get_selected_items' is not implemented." ).format(self.__class__.__name__)) + def set_selected_items(self, instance_ids, context_selected): + """Change selection for instances and context. + + Used to applying selection from one view to other. + + Args: + instance_ids (List[str]): Selected instance ids. + context_selected (bool): Context is selected. + """ + + raise NotImplementedError(( + "{} Method 'set_selected_items' is not implemented." + ).format(self.__class__.__name__)) + class ClickableLineEdit(QtWidgets.QLineEdit): """QLineEdit capturing left mouse click. @@ -994,7 +1009,7 @@ class GlobalAttrsWidget(QtWidgets.QWidget): def __init__(self, controller, parent): super(GlobalAttrsWidget, self).__init__(parent) - self.controller = controller + self._controller = controller self._current_instances = [] variant_input = VariantInputWidget(self) @@ -1060,24 +1075,6 @@ class GlobalAttrsWidget(QtWidgets.QWidget): if self.task_value_widget.has_value_changed(): task_name = self.task_value_widget.get_selected_items()[0] - asset_docs_by_name = {} - asset_names = set() - if asset_name is None: - for instance in self._current_instances: - asset_names.add(instance.get("asset")) - else: - asset_names.add(asset_name) - - for asset_doc in self.controller.get_asset_docs(): - _asset_name = asset_doc["name"] - if _asset_name in asset_names: - asset_names.remove(_asset_name) - asset_docs_by_name[_asset_name] = asset_doc - - if not asset_names: - break - - project_name = self.controller.project_name subset_names = set() invalid_tasks = False for instance in self._current_instances: @@ -1093,16 +1090,15 @@ class GlobalAttrsWidget(QtWidgets.QWidget): if task_name is not None: new_task_name = task_name - asset_doc = asset_docs_by_name[new_asset_name] - try: - new_subset_name = instance.creator.get_subset_name( + new_subset_name = self._controller.get_subset_name( + instance.creator_identifier, new_variant_value, new_task_name, - asset_doc, - project_name, - instance=instance + new_asset_name, + instance.id, ) + except TaskNotSetError: invalid_tasks = True instance.set_task_invalid(True) @@ -1249,7 +1245,7 @@ class CreatorAttrsWidget(QtWidgets.QWidget): self._main_layout = main_layout - self.controller = controller + self._controller = controller self._scroll_area = scroll_area self._attr_def_id_to_instances = {} @@ -1278,7 +1274,7 @@ class CreatorAttrsWidget(QtWidgets.QWidget): self._attr_def_id_to_instances = {} self._attr_def_id_to_attr_def = {} - result = self.controller.get_creator_attribute_definitions( + result = self._controller.get_creator_attribute_definitions( instances ) @@ -1370,7 +1366,7 @@ class PublishPluginAttrsWidget(QtWidgets.QWidget): self._main_layout = main_layout - self.controller = controller + self._controller = controller self._scroll_area = scroll_area self._attr_def_id_to_instances = {} @@ -1402,7 +1398,7 @@ class PublishPluginAttrsWidget(QtWidgets.QWidget): self._attr_def_id_to_attr_def = {} self._attr_def_id_to_plugin_name = {} - result = self.controller.get_publish_attribute_definitions( + result = self._controller.get_publish_attribute_definitions( instances, context_selected ) @@ -1517,7 +1513,7 @@ class SubsetAttributesWidget(QtWidgets.QWidget): self._on_instance_context_changed ) - self.controller = controller + self._controller = controller self.global_attrs_widget = global_attrs_widget diff --git a/openpype/tools/publisher/window.py b/openpype/tools/publisher/window.py index 4b382c0df5..39075d2489 100644 --- a/openpype/tools/publisher/window.py +++ b/openpype/tools/publisher/window.py @@ -11,7 +11,7 @@ from openpype.tools.utils import ( ) from .publish_report_viewer import PublishReportViewerWidget -from .control import PublisherController +from .control_qt import QtPublisherController from .widgets import ( OverviewWidget, ValidationsWidget, @@ -36,7 +36,7 @@ class PublisherWindow(QtWidgets.QDialog): footer_border = 8 publish_footer_spacer = 2 - def __init__(self, parent=None, reset_on_show=None): + def __init__(self, parent=None, controller=None, reset_on_show=None): super(PublisherWindow, self).__init__(parent) self.setWindowTitle("OpenPype publisher") @@ -61,7 +61,8 @@ class PublisherWindow(QtWidgets.QDialog): | on_top_flag ) - controller = PublisherController() + if controller is None: + controller = QtPublisherController() help_dialog = HelpDialog(controller, self) @@ -250,7 +251,7 @@ class PublisherWindow(QtWidgets.QDialog): "publish.process.started", self._on_publish_start ) controller.event_system.add_callback( - "publish.process.validated", self._on_publish_validated + "publish.has_validated.changed", self._on_publish_validated_change ) controller.event_system.add_callback( "publish.process.stopped", self._on_publish_stop @@ -441,11 +442,7 @@ class PublisherWindow(QtWidgets.QDialog): self._controller.stop_publish() def _set_publish_comment(self): - if self._controller.publish_comment_is_set: - return - - comment = self._comment_input.text() - self._controller.set_comment(comment) + self._controller.set_comment(self._comment_input.text()) def _on_validate_clicked(self): self._set_publish_comment() @@ -473,6 +470,11 @@ class PublisherWindow(QtWidgets.QDialog): self._set_publish_visibility(False) self._set_footer_enabled(False) self._update_publish_details_widget() + if ( + not self._tabs_widget.is_current_tab("create") + or not self._tabs_widget.is_current_tab("publish") + ): + self._tabs_widget.set_current_tab("publish") def _on_publish_start(self): self._create_tab.setEnabled(False) @@ -491,15 +493,20 @@ class PublisherWindow(QtWidgets.QDialog): if self._tabs_widget.is_current_tab(self._create_tab): self._tabs_widget.set_current_tab("publish") - def _on_publish_validated(self): - self._validate_btn.setEnabled(False) + def _on_publish_validated_change(self, event): + if event["value"]: + self._validate_btn.setEnabled(False) def _on_publish_stop(self): self._set_publish_overlay_visibility(False) self._reset_btn.setEnabled(True) self._stop_btn.setEnabled(False) - validate_enabled = not self._controller.publish_has_crashed - publish_enabled = not self._controller.publish_has_crashed + publish_has_crashed = self._controller.publish_has_crashed + validate_enabled = not publish_has_crashed + publish_enabled = not publish_has_crashed + if self._tabs_widget.is_current_tab("publish"): + self._go_to_report_tab() + if validate_enabled: validate_enabled = not self._controller.publish_has_validated if publish_enabled: @@ -508,8 +515,6 @@ class PublisherWindow(QtWidgets.QDialog): and self._controller.publish_has_validation_errors ): publish_enabled = False - if self._tabs_widget.is_current_tab("publish"): - self._go_to_report_tab() else: publish_enabled = not self._controller.publish_has_finished @@ -525,7 +530,7 @@ class PublisherWindow(QtWidgets.QDialog): return all_valid = None - for instance in self._controller.instances: + for instance in self._controller.instances.values(): if not instance["active"]: continue diff --git a/openpype/tools/traypublisher/window.py b/openpype/tools/traypublisher/window.py index b1ff3c7383..dfe06d149d 100644 --- a/openpype/tools/traypublisher/window.py +++ b/openpype/tools/traypublisher/window.py @@ -15,6 +15,7 @@ import appdirs from openpype.lib import JSONSettingRegistry from openpype.pipeline import install_host from openpype.hosts.traypublisher.api import TrayPublisherHost +from openpype.tools.publisher.control_qt import QtPublisherController from openpype.tools.publisher.window import PublisherWindow from openpype.tools.utils import PlaceholderLineEdit from openpype.tools.utils.constants import PROJECT_NAME_ROLE @@ -24,6 +25,15 @@ from openpype.tools.utils.models import ( ) +class TrayPublisherController(QtPublisherController): + @property + def host(self): + return self._host + + def reset_project_data_cache(self): + self._asset_docs_cache.reset() + + class TrayPublisherRegistry(JSONSettingRegistry): """Class handling OpenPype general settings registry. @@ -179,7 +189,10 @@ class StandaloneOverlayWidget(QtWidgets.QFrame): class TrayPublishWindow(PublisherWindow): def __init__(self, *args, **kwargs): - super(TrayPublishWindow, self).__init__(reset_on_show=False) + controller = TrayPublisherController() + super(TrayPublishWindow, self).__init__( + controller=controller, reset_on_show=False + ) flags = self.windowFlags() # Disable always on top hint diff --git a/openpype/tools/utils/host_tools.py b/openpype/tools/utils/host_tools.py index 552ce0d432..046dcbdf6a 100644 --- a/openpype/tools/utils/host_tools.py +++ b/openpype/tools/utils/host_tools.py @@ -269,25 +269,25 @@ class HostToolsHelper: dialog.activateWindow() dialog.showNormal() - def get_publisher_tool(self, parent): + def get_publisher_tool(self, parent=None, controller=None): """Create, cache and return publisher window.""" if self._publisher_tool is None: - from openpype.tools.publisher import PublisherWindow + from openpype.tools.publisher.window import PublisherWindow host = registered_host() ILoadHost.validate_load_methods(host) publisher_window = PublisherWindow( - parent=parent or self._parent + controller=controller, parent=parent or self._parent ) self._publisher_tool = publisher_window return self._publisher_tool - def show_publisher_tool(self, parent=None): + def show_publisher_tool(self, parent=None, controller=None): with qt_app_context(): - dialog = self.get_publisher_tool(parent) + dialog = self.get_publisher_tool(parent, controller) dialog.show() dialog.raise_() diff --git a/website/docs/admin_settings_system.md b/website/docs/admin_settings_system.md index 8daba91db1..66715e7288 100644 --- a/website/docs/admin_settings_system.md +++ b/website/docs/admin_settings_system.md @@ -26,6 +26,9 @@ as a naive barier to prevent artists from accidental setting changes. **`Disk mapping`** - Platform dependent configuration for mapping of virtual disk(s) on an artist's OpenPype machines before OP starts up. Uses `subst` command, if configured volume character in `Destination` field already exists, no re-mapping is done for that character(volume). +### FFmpeg and OpenImageIO tools +We bundle FFmpeg tools for all platforms and OpenImageIO tools for Windows and Linux. By default are used bundled tools but it is possible to set environment variables `OPENPYPE_FFMPEG_PATHS` and `OPENPYPE_OIIO_PATHS` in system settings environments to look for them in different directory e.g. for different linux distributions or to add oiio support for MacOs. Values of both environment variables should lead to directory where tool executables are located (multiple paths are supported). + ### OpenPype deployment control **`Versions Repository`** - Location where automatic update mechanism searches for zip files with OpenPype update packages. To read more about preparing OpenPype for automatic updates go to [Admin Distribute docs](admin_distribute.md#2-openpype-codebase)