Merge branch 'develop' into enhancement/copy_multiple_library_project

This commit is contained in:
Petr Kalis 2025-08-21 18:08:55 +02:00 committed by GitHub
commit 5db9efb95c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
36 changed files with 1697 additions and 2568 deletions

View file

@ -1,6 +1,8 @@
from .constants import ContextChangeReason
from .abstract import AbstractHost
from .host import (
HostBase,
ContextChangeData,
)
from .interfaces import (
@ -18,7 +20,10 @@ from .dirmap import HostDirmap
__all__ = (
"ContextChangeReason",
"AbstractHost",
"HostBase",
"ContextChangeData",
"IWorkfileHost",
"WorkfileInfo",

View file

@ -0,0 +1,96 @@
from __future__ import annotations
import logging
from abc import ABC, abstractmethod
import typing
from typing import Optional, Any
from .constants import ContextChangeReason
if typing.TYPE_CHECKING:
from ayon_core.pipeline import Anatomy
from .typing import HostContextData
class AbstractHost(ABC):
"""Abstract definition of host implementation."""
@property
@abstractmethod
def log(self) -> logging.Logger:
pass
@property
@abstractmethod
def name(self) -> str:
"""Host name."""
pass
@abstractmethod
def get_current_context(self) -> HostContextData:
"""Get the current context of the host.
Current context is defined by project name, folder path and task name.
Returns:
HostContextData: The current context of the host.
"""
pass
@abstractmethod
def set_current_context(
self,
folder_entity: dict[str, Any],
task_entity: dict[str, Any],
*,
reason: ContextChangeReason = ContextChangeReason.undefined,
project_entity: Optional[dict[str, Any]] = None,
anatomy: Optional[Anatomy] = None,
) -> HostContextData:
"""Change context of the host.
Args:
folder_entity (dict[str, Any]): Folder entity.
task_entity (dict[str, Any]): Task entity.
reason (ContextChangeReason): Reason for change.
project_entity (dict[str, Any]): Project entity.
anatomy (Anatomy): Anatomy entity.
"""
pass
@abstractmethod
def get_current_project_name(self) -> str:
"""Get the current project name.
Returns:
Optional[str]: The current project name.
"""
pass
@abstractmethod
def get_current_folder_path(self) -> Optional[str]:
"""Get the current folder path.
Returns:
Optional[str]: The current folder path.
"""
pass
@abstractmethod
def get_current_task_name(self) -> Optional[str]:
"""Get the current task name.
Returns:
Optional[str]: The current task name.
"""
pass
@abstractmethod
def get_context_title(self) -> str:
"""Get the context title used in UIs."""
pass

View file

@ -3,26 +3,21 @@ from __future__ import annotations
import os
import logging
import contextlib
from abc import ABC, abstractmethod
from dataclasses import dataclass
import typing
from typing import Optional, Any
from dataclasses import dataclass
import ayon_api
from ayon_core.lib import emit_event
from .constants import ContextChangeReason
from .abstract import AbstractHost
if typing.TYPE_CHECKING:
from ayon_core.pipeline import Anatomy
from typing import TypedDict
class HostContextData(TypedDict):
project_name: str
folder_path: Optional[str]
task_name: Optional[str]
from .typing import HostContextData
@dataclass
@ -34,7 +29,7 @@ class ContextChangeData:
anatomy: Anatomy
class HostBase(ABC):
class HostBase(AbstractHost):
"""Base of host implementation class.
Host is pipeline implementation of DCC application. This class should help
@ -109,48 +104,41 @@ class HostBase(ABC):
It is called automatically when 'ayon_core.pipeline.install_host' is
triggered.
"""
"""
pass
@property
def log(self):
def log(self) -> logging.Logger:
if self._log is None:
self._log = logging.getLogger(self.__class__.__name__)
return self._log
@property
@abstractmethod
def name(self) -> str:
"""Host name."""
pass
def get_current_project_name(self):
def get_current_project_name(self) -> str:
"""
Returns:
Union[str, None]: Current project name.
"""
str: Current project name.
return os.environ.get("AYON_PROJECT_NAME")
"""
return os.environ["AYON_PROJECT_NAME"]
def get_current_folder_path(self) -> Optional[str]:
"""
Returns:
Union[str, None]: Current asset name.
"""
Optional[str]: Current asset name.
"""
return os.environ.get("AYON_FOLDER_PATH")
def get_current_task_name(self) -> Optional[str]:
"""
Returns:
Union[str, None]: Current task name.
"""
Optional[str]: Current task name.
"""
return os.environ.get("AYON_TASK_NAME")
def get_current_context(self) -> "HostContextData":
def get_current_context(self) -> HostContextData:
"""Get current context information.
This method should be used to get current context of host. Usage of
@ -159,10 +147,10 @@ class HostBase(ABC):
can't be caught properly.
Returns:
Dict[str, Union[str, None]]: Context with 3 keys 'project_name',
'folder_path' and 'task_name'. All of them can be 'None'.
"""
HostContextData: Current context with 'project_name',
'folder_path' and 'task_name'.
"""
return {
"project_name": self.get_current_project_name(),
"folder_path": self.get_current_folder_path(),
@ -177,7 +165,7 @@ class HostBase(ABC):
reason: ContextChangeReason = ContextChangeReason.undefined,
project_entity: Optional[dict[str, Any]] = None,
anatomy: Optional[Anatomy] = None,
) -> "HostContextData":
) -> HostContextData:
"""Set current context information.
This method should be used to set current context of host. Usage of
@ -290,7 +278,7 @@ class HostBase(ABC):
project_name: str,
folder_path: Optional[str],
task_name: Optional[str],
) -> "HostContextData":
) -> HostContextData:
"""Emit context change event.
Args:
@ -302,7 +290,7 @@ class HostBase(ABC):
HostContextData: Data send to context change event.
"""
data = {
data: HostContextData = {
"project_name": project_name,
"folder_path": folder_path,
"task_name": task_name,

View file

@ -1,9 +1,11 @@
from abc import abstractmethod
from ayon_core.host.abstract import AbstractHost
from .exceptions import MissingMethodsError
class ILoadHost:
class ILoadHost(AbstractHost):
"""Implementation requirements to be able use reference of representations.
The load plugins can do referencing even without implementation of methods
@ -24,7 +26,7 @@ class ILoadHost:
loading. Checks only existence of methods.
Args:
Union[ModuleType, HostBase]: Object of host where to look for
Union[ModuleType, AbstractHost]: Object of host where to look for
required methods.
Returns:
@ -46,7 +48,7 @@ class ILoadHost:
"""Validate implemented methods of "old type" host for load workflow.
Args:
Union[ModuleType, HostBase]: Object of host to validate.
Union[ModuleType, AbstractHost]: Object of host to validate.
Raises:
MissingMethodsError: If there are missing methods on host
@ -83,7 +85,7 @@ class ILoadHost:
return self.get_containers()
class IPublishHost:
class IPublishHost(AbstractHost):
"""Functions related to new creation system in new publisher.
New publisher is not storing information only about each created instance
@ -99,7 +101,7 @@ class IPublishHost:
new publish creation. Checks only existence of methods.
Args:
Union[ModuleType, HostBase]: Host module where to look for
Union[ModuleType, AbstractHost]: Host module where to look for
required methods.
Returns:
@ -127,7 +129,7 @@ class IPublishHost:
"""Validate implemented methods of "old type" host.
Args:
Union[ModuleType, HostBase]: Host module to validate.
Union[ModuleType, AbstractHost]: Host module to validate.
Raises:
MissingMethodsError: If there are missing methods on host

View file

@ -15,6 +15,7 @@ import arrow
from ayon_core.lib import emit_event
from ayon_core.settings import get_project_settings
from ayon_core.host.abstract import AbstractHost
from ayon_core.host.constants import ContextChangeReason
if typing.TYPE_CHECKING:
@ -821,7 +822,7 @@ class PublishedWorkfileInfo:
return PublishedWorkfileInfo(**data)
class IWorkfileHost:
class IWorkfileHost(AbstractHost):
"""Implementation requirements to be able to use workfiles utils and tool.
Some of the methods are pre-implemented as they generally do the same in

View file

@ -0,0 +1,7 @@
from typing import Optional, TypedDict
class HostContextData(TypedDict):
project_name: str
folder_path: Optional[str]
task_name: Optional[str]

View file

@ -19,11 +19,7 @@ from .create import (
CreatedInstance,
CreatorError,
LegacyCreator,
legacy_create,
discover_creator_plugins,
discover_legacy_creator_plugins,
register_creator_plugin,
deregister_creator_plugin,
register_creator_plugin_path,
@ -141,12 +137,7 @@ __all__ = (
"CreatorError",
# - legacy creation
"LegacyCreator",
"legacy_create",
"discover_creator_plugins",
"discover_legacy_creator_plugins",
"register_creator_plugin",
"deregister_creator_plugin",
"register_creator_plugin_path",

View file

@ -13,7 +13,7 @@ import pyblish.api
from pyblish.lib import MessageHandler
from ayon_core import AYON_CORE_ROOT
from ayon_core.host import HostBase
from ayon_core.host import AbstractHost
from ayon_core.lib import (
is_in_tests,
initialize_ayon_connection,
@ -100,16 +100,16 @@ def registered_root():
return _registered_root["_"]
def install_host(host: HostBase) -> None:
def install_host(host: AbstractHost) -> None:
"""Install `host` into the running Python session.
Args:
host (HostBase): A host interface object.
host (AbstractHost): A host interface object.
"""
if not isinstance(host, HostBase):
if not isinstance(host, AbstractHost):
log.error(
f"Host must be a subclass of 'HostBase', got '{type(host)}'."
f"Host must be a subclass of 'AbstractHost', got '{type(host)}'."
)
global _is_installed
@ -310,7 +310,7 @@ def get_current_host_name():
"""
host = registered_host()
if isinstance(host, HostBase):
if isinstance(host, AbstractHost):
return host.name
return os.environ.get("AYON_HOST_NAME")
@ -346,28 +346,28 @@ def get_global_context():
def get_current_context():
host = registered_host()
if isinstance(host, HostBase):
if isinstance(host, AbstractHost):
return host.get_current_context()
return get_global_context()
def get_current_project_name():
host = registered_host()
if isinstance(host, HostBase):
if isinstance(host, AbstractHost):
return host.get_current_project_name()
return get_global_context()["project_name"]
def get_current_folder_path():
host = registered_host()
if isinstance(host, HostBase):
if isinstance(host, AbstractHost):
return host.get_current_folder_path()
return get_global_context()["folder_path"]
def get_current_task_name():
host = registered_host()
if isinstance(host, HostBase):
if isinstance(host, AbstractHost):
return host.get_current_task_name()
return get_global_context()["task_name"]

View file

@ -21,12 +21,14 @@ from .exceptions import (
TemplateFillError,
)
from .structures import (
ParentFlags,
CreatedInstance,
ConvertorItem,
AttributeValues,
CreatorAttributeValues,
PublishAttributeValues,
PublishAttributes,
InstanceContextInfo,
)
from .utils import (
get_last_versions_for_instances,
@ -44,9 +46,6 @@ from .creator_plugins import (
AutoCreator,
HiddenCreator,
discover_legacy_creator_plugins,
get_legacy_creator_by_name,
discover_creator_plugins,
register_creator_plugin,
deregister_creator_plugin,
@ -58,11 +57,6 @@ from .creator_plugins import (
from .context import CreateContext
from .legacy_create import (
LegacyCreator,
legacy_create,
)
__all__ = (
"PRODUCT_NAME_ALLOWED_SYMBOLS",
@ -85,12 +79,14 @@ __all__ = (
"TaskNotSetError",
"TemplateFillError",
"ParentFlags",
"CreatedInstance",
"ConvertorItem",
"AttributeValues",
"CreatorAttributeValues",
"PublishAttributeValues",
"PublishAttributes",
"InstanceContextInfo",
"get_last_versions_for_instances",
"get_next_versions_for_instances",
@ -105,9 +101,6 @@ __all__ = (
"AutoCreator",
"HiddenCreator",
"discover_legacy_creator_plugins",
"get_legacy_creator_by_name",
"discover_creator_plugins",
"register_creator_plugin",
"deregister_creator_plugin",
@ -117,7 +110,4 @@ __all__ = (
"cache_and_get_instances",
"CreateContext",
"LegacyCreator",
"legacy_create",
)

View file

@ -41,7 +41,12 @@ from .exceptions import (
HostMissRequiredMethod,
)
from .changes import TrackChangesItem
from .structures import PublishAttributes, ConvertorItem, InstanceContextInfo
from .structures import (
PublishAttributes,
ConvertorItem,
InstanceContextInfo,
ParentFlags,
)
from .creator_plugins import (
Creator,
AutoCreator,
@ -49,15 +54,12 @@ from .creator_plugins import (
discover_convertor_plugins,
)
if typing.TYPE_CHECKING:
from ayon_core.host import HostBase
from ayon_core.lib import AbstractAttrDef
from ayon_core.lib.events import EventCallback, Event
from .structures import CreatedInstance
from .creator_plugins import BaseCreator
class PublishHost(HostBase, IPublishHost):
pass
# Import of functions and classes that were moved to different file
# TODO Should be removed in future release - Added 24/08/28, 0.4.3-dev.1
@ -80,6 +82,7 @@ INSTANCE_ADDED_TOPIC = "instances.added"
INSTANCE_REMOVED_TOPIC = "instances.removed"
VALUE_CHANGED_TOPIC = "values.changed"
INSTANCE_REQUIREMENT_CHANGED_TOPIC = "instance.requirement.changed"
INSTANCE_PARENT_CHANGED_TOPIC = "instance.parent.changed"
PRE_CREATE_ATTR_DEFS_CHANGED_TOPIC = "pre.create.attr.defs.changed"
CREATE_ATTR_DEFS_CHANGED_TOPIC = "create.attr.defs.changed"
PUBLISH_ATTR_DEFS_CHANGED_TOPIC = "publish.attr.defs.changed"
@ -163,7 +166,7 @@ class CreateContext:
context which should be handled by host.
Args:
host (PublishHost): Host implementation which handles implementation
host (IPublishHost): Host implementation which handles implementation
and global metadata.
headless (bool): Context is created out of UI (Current not used).
reset (bool): Reset context on initialization.
@ -173,7 +176,7 @@ class CreateContext:
def __init__(
self,
host: "PublishHost",
host: IPublishHost,
headless: bool = False,
reset: bool = True,
discover_publish_plugins: bool = True,
@ -262,6 +265,8 @@ class CreateContext:
# - right now used only for 'mandatory' but can be extended
# in future
"requirement_change": BulkInfo(),
# Instance parent changed
"parent_change": BulkInfo(),
}
self._bulk_order = []
@ -1083,6 +1088,35 @@ class CreateContext:
INSTANCE_REQUIREMENT_CHANGED_TOPIC, callback
)
def add_instance_parent_change_callback(
self, callback: Callable
) -> "EventCallback":
"""Register callback to listen to instance parent changes.
Instance changed parent or parent flags.
Data structure of event:
```python
{
"instances": [CreatedInstance, ...],
"create_context": CreateContext
}
```
Args:
callback (Callable): Callback function that will be called when
instance requirement changed.
Returns:
EventCallback: Created callback object which can be used to
stop listening.
"""
return self._event_hub.add_callback(
INSTANCE_PARENT_CHANGED_TOPIC, callback
)
def context_data_to_store(self) -> dict[str, Any]:
"""Data that should be stored by host function.
@ -1364,6 +1398,13 @@ class CreateContext:
) as bulk_info:
yield bulk_info
@contextmanager
def bulk_instance_parent_change(self, sender: Optional[str] = None):
with self._bulk_context(
"parent_change", sender
) as bulk_info:
yield bulk_info
@contextmanager
def bulk_publish_attr_defs_change(self, sender: Optional[str] = None):
with self._bulk_context("publish_attrs_change", sender) as bulk_info:
@ -1444,6 +1485,19 @@ class CreateContext:
with self.bulk_instance_requirement_change() as bulk_item:
bulk_item.append(instance_id)
def instance_parent_changed(self, instance_id: str) -> None:
"""Instance parent changed.
Triggered by `CreatedInstance`.
Args:
instance_id (Optional[str]): Instance id.
"""
if self._is_instance_events_ready(instance_id):
with self.bulk_instance_parent_change() as bulk_item:
bulk_item.append(instance_id)
# --- context change callbacks ---
def publish_attribute_value_changed(
self, plugin_name: str, value: dict[str, Any]
@ -2046,63 +2100,97 @@ class CreateContext:
sender (Optional[str]): Sender of the event.
"""
instance_ids_by_parent_id = collections.defaultdict(set)
for instance in self.instances:
instance_ids_by_parent_id[instance.parent_instance_id].add(
instance.id
)
instances_to_remove = list(instances)
ids_to_remove = {
instance.id
for instance in instances_to_remove
}
_queue = collections.deque()
_queue.extend(instances_to_remove)
# Add children with parent lifetime flag
while _queue:
instance = _queue.popleft()
ids_to_remove.add(instance.id)
children_ids = instance_ids_by_parent_id[instance.id]
for children_id in children_ids:
if children_id in ids_to_remove:
continue
instance = self._instances_by_id[children_id]
if instance.parent_flags & ParentFlags.parent_lifetime:
instances_to_remove.append(instance)
ids_to_remove.add(instance.id)
_queue.append(instance)
instances_by_identifier = collections.defaultdict(list)
for instance in instances:
for instance in instances_to_remove:
identifier = instance.creator_identifier
instances_by_identifier[identifier].append(instance)
# Just remove instances from context if creator is not available
missing_creators = set(instances_by_identifier) - set(self.creators)
instances = []
miss_creator_instances = []
for identifier in missing_creators:
instances.extend(
instance
for instance in instances_by_identifier[identifier]
)
miss_creator_instances.extend(instances_by_identifier[identifier])
self._remove_instances(instances, sender)
with self.bulk_remove_instances(sender):
self._remove_instances(miss_creator_instances, sender)
error_message = "Instances removement of creator \"{}\" failed. {}"
failed_info = []
# Remove instances by creator plugin order
for creator in self.get_sorted_creators(
instances_by_identifier.keys()
):
identifier = creator.identifier
creator_instances = instances_by_identifier[identifier]
error_message = "Instances removement of creator \"{}\" failed. {}"
failed_info = []
# Remove instances by creator plugin order
for creator in self.get_sorted_creators(
instances_by_identifier.keys()
):
identifier = creator.identifier
# Filter instances by current state of 'CreateContext'
# - in case instances were already removed as subroutine of
# previous create plugin.
creator_instances = [
instance
for instance in instances_by_identifier[identifier]
if instance.id in self._instances_by_id
]
if not creator_instances:
continue
label = creator.label
failed = False
add_traceback = False
exc_info = None
try:
creator.remove_instances(creator_instances)
label = creator.label
failed = False
add_traceback = False
exc_info = None
try:
creator.remove_instances(creator_instances)
except CreatorError:
failed = True
exc_info = sys.exc_info()
self.log.warning(
error_message.format(identifier, exc_info[1])
)
except (KeyboardInterrupt, SystemExit):
raise
except: # noqa: E722
failed = True
add_traceback = True
exc_info = sys.exc_info()
self.log.warning(
error_message.format(identifier, ""),
exc_info=True
)
if failed:
failed_info.append(
prepare_failed_creator_operation_info(
identifier, label, exc_info, add_traceback
except CreatorError:
failed = True
exc_info = sys.exc_info()
self.log.warning(
error_message.format(identifier, exc_info[1])
)
except (KeyboardInterrupt, SystemExit):
raise
except: # noqa: E722
failed = True
add_traceback = True
exc_info = sys.exc_info()
self.log.warning(
error_message.format(identifier, ""),
exc_info=True
)
if failed:
failed_info.append(
prepare_failed_creator_operation_info(
identifier, label, exc_info, add_traceback
)
)
)
if failed_info:
raise CreatorsRemoveFailed(failed_info)
@ -2305,6 +2393,8 @@ class CreateContext:
self._bulk_publish_attrs_change_finished(data, sender)
elif key == "requirement_change":
self._bulk_instance_requirement_change_finished(data, sender)
elif key == "parent_change":
self._bulk_instance_parent_change_finished(data, sender)
def _bulk_add_instances_finished(
self,
@ -2518,3 +2608,22 @@ class CreateContext:
{"instances": instances},
sender,
)
def _bulk_instance_parent_change_finished(
self,
instance_ids: list[str],
sender: Optional[str],
):
if not instance_ids:
return
instances = [
self.get_instance_by_id(instance_id)
for instance_id in set(instance_ids)
]
self._emit_event(
INSTANCE_PARENT_CHANGED_TOPIC,
{"instances": instances},
sender,
)

View file

@ -6,7 +6,6 @@ from typing import TYPE_CHECKING, Optional, Dict, Any
from abc import ABC, abstractmethod
from ayon_core.settings import get_project_settings
from ayon_core.lib import Logger, get_version_from_path
from ayon_core.pipeline.plugin_discover import (
discover,
@ -20,7 +19,6 @@ from ayon_core.pipeline.staging_dir import get_staging_dir_info, StagingDir
from .constants import DEFAULT_VARIANT_VALUE
from .product_name import get_product_name
from .utils import get_next_versions_for_instances
from .legacy_create import LegacyCreator
from .structures import CreatedInstance
if TYPE_CHECKING:
@ -975,62 +973,10 @@ def discover_convertor_plugins(*args, **kwargs):
return discover(ProductConvertorPlugin, *args, **kwargs)
def discover_legacy_creator_plugins():
from ayon_core.pipeline import get_current_project_name
log = Logger.get_logger("CreatorDiscover")
plugins = discover(LegacyCreator)
project_name = get_current_project_name()
project_settings = get_project_settings(project_name)
for plugin in plugins:
try:
plugin.apply_settings(project_settings)
except Exception:
log.warning(
"Failed to apply settings to creator {}".format(
plugin.__name__
),
exc_info=True
)
return plugins
def get_legacy_creator_by_name(creator_name, case_sensitive=False):
"""Find creator plugin by name.
Args:
creator_name (str): Name of creator class that should be returned.
case_sensitive (bool): Match of creator plugin name is case sensitive.
Set to `False` by default.
Returns:
Creator: Return first matching plugin or `None`.
"""
# Lower input creator name if is not case sensitive
if not case_sensitive:
creator_name = creator_name.lower()
for creator_plugin in discover_legacy_creator_plugins():
_creator_name = creator_plugin.__name__
# Lower creator plugin name if is not case sensitive
if not case_sensitive:
_creator_name = _creator_name.lower()
if _creator_name == creator_name:
return creator_plugin
return None
def register_creator_plugin(plugin):
if issubclass(plugin, BaseCreator):
register_plugin(BaseCreator, plugin)
elif issubclass(plugin, LegacyCreator):
register_plugin(LegacyCreator, plugin)
elif issubclass(plugin, ProductConvertorPlugin):
register_plugin(ProductConvertorPlugin, plugin)
@ -1039,22 +985,17 @@ def deregister_creator_plugin(plugin):
if issubclass(plugin, BaseCreator):
deregister_plugin(BaseCreator, plugin)
elif issubclass(plugin, LegacyCreator):
deregister_plugin(LegacyCreator, plugin)
elif issubclass(plugin, ProductConvertorPlugin):
deregister_plugin(ProductConvertorPlugin, plugin)
def register_creator_plugin_path(path):
register_plugin_path(BaseCreator, path)
register_plugin_path(LegacyCreator, path)
register_plugin_path(ProductConvertorPlugin, path)
def deregister_creator_plugin_path(path):
deregister_plugin_path(BaseCreator, path)
deregister_plugin_path(LegacyCreator, path)
deregister_plugin_path(ProductConvertorPlugin, path)

View file

@ -1,216 +0,0 @@
"""Create workflow moved from avalon-core repository.
Renamed classes and functions
- 'Creator' -> 'LegacyCreator'
- 'create' -> 'legacy_create'
"""
import os
import logging
import collections
from ayon_core.pipeline.constants import AYON_INSTANCE_ID
from .product_name import get_product_name
class LegacyCreator:
"""Determine how assets are created"""
label = None
product_type = None
defaults = None
maintain_selection = True
enabled = True
dynamic_product_name_keys = []
log = logging.getLogger("LegacyCreator")
log.propagate = True
def __init__(self, name, folder_path, options=None, data=None):
self.name = name # For backwards compatibility
self.options = options
# Default data
self.data = collections.OrderedDict()
# TODO use 'AYON_INSTANCE_ID' when all hosts support it
self.data["id"] = AYON_INSTANCE_ID
self.data["productType"] = self.product_type
self.data["folderPath"] = folder_path
self.data["productName"] = name
self.data["active"] = True
self.data.update(data or {})
@classmethod
def apply_settings(cls, project_settings):
"""Apply AYON settings to a plugin class."""
host_name = os.environ.get("AYON_HOST_NAME")
plugin_type = "create"
plugin_type_settings = (
project_settings
.get(host_name, {})
.get(plugin_type, {})
)
global_type_settings = (
project_settings
.get("core", {})
.get(plugin_type, {})
)
if not global_type_settings and not plugin_type_settings:
return
plugin_name = cls.__name__
plugin_settings = None
# Look for plugin settings in host specific settings
if plugin_name in plugin_type_settings:
plugin_settings = plugin_type_settings[plugin_name]
# Look for plugin settings in global settings
elif plugin_name in global_type_settings:
plugin_settings = global_type_settings[plugin_name]
if not plugin_settings:
return
cls.log.debug(">>> We have preset for {}".format(plugin_name))
for option, value in plugin_settings.items():
if option == "enabled" and value is False:
cls.log.debug(" - is disabled by preset")
else:
cls.log.debug(" - setting `{}`: `{}`".format(option, value))
setattr(cls, option, value)
def process(self):
pass
@classmethod
def get_dynamic_data(
cls, project_name, folder_entity, task_entity, variant, host_name
):
"""Return dynamic data for current Creator plugin.
By default return keys from `dynamic_product_name_keys` attribute
as mapping to keep formatted template unchanged.
```
dynamic_product_name_keys = ["my_key"]
---
output = {
"my_key": "{my_key}"
}
```
Dynamic keys may override default Creator keys (productType, task,
folderPath, ...) but do it wisely if you need.
All of keys will be converted into 3 variants unchanged, capitalized
and all upper letters. Because of that are all keys lowered.
This method can be modified to prefill some values just keep in mind it
is class method.
Args:
project_name (str): Context's project name.
folder_entity (dict[str, Any]): Folder entity.
task_entity (dict[str, Any]): Task entity.
variant (str): What is entered by user in creator tool.
host_name (str): Name of host.
Returns:
dict: Fill data for product name template.
"""
dynamic_data = {}
for key in cls.dynamic_product_name_keys:
key = key.lower()
dynamic_data[key] = "{" + key + "}"
return dynamic_data
@classmethod
def get_product_name(
cls, project_name, folder_entity, task_entity, variant, host_name=None
):
"""Return product name created with entered arguments.
Logic extracted from Creator tool. This method should give ability
to get product name without the tool.
TODO: Maybe change `variant` variable.
By default is output concatenated product type with variant.
Args:
project_name (str): Context's project name.
folder_entity (dict[str, Any]): Folder entity.
task_entity (dict[str, Any]): Task entity.
variant (str): What is entered by user in creator tool.
host_name (str): Name of host.
Returns:
str: Formatted product name with entered arguments. Should match
config's logic.
"""
dynamic_data = cls.get_dynamic_data(
project_name, folder_entity, task_entity, variant, host_name
)
task_name = task_type = None
if task_entity:
task_name = task_entity["name"]
task_type = task_entity["taskType"]
return get_product_name(
project_name,
task_name,
task_type,
host_name,
cls.product_type,
variant,
dynamic_data=dynamic_data
)
def legacy_create(
Creator, product_name, folder_path, options=None, data=None
):
"""Create a new instance
Associate nodes with a product name and type. These nodes are later
validated, according to their `product type`, and integrated into the
shared environment, relative their `productName`.
Data relative each product type, along with default data, are imprinted
into the resulting objectSet. This data is later used by extractors
and finally asset browsers to help identify the origin of the asset.
Arguments:
Creator (Creator): Class of creator.
product_name (str): Name of product.
folder_path (str): Folder path.
options (dict, optional): Additional options from GUI.
data (dict, optional): Additional data from GUI.
Raises:
NameError on `productName` already exists
KeyError on invalid dynamic property
RuntimeError on host error
Returns:
Name of instance
"""
from ayon_core.pipeline import registered_host
host = registered_host()
plugin = Creator(product_name, folder_path, options, data)
if plugin.maintain_selection is True:
with host.maintained_selection():
print("Running %s with maintained selection" % plugin)
instance = plugin.process()
return instance
print("Running %s" % plugin)
instance = plugin.process()
return instance

View file

@ -1,6 +1,7 @@
import copy
import collections
from uuid import uuid4
from enum import Enum
import typing
from typing import Optional, Dict, List, Any
@ -22,6 +23,23 @@ if typing.TYPE_CHECKING:
from .creator_plugins import BaseCreator
class IntEnum(int, Enum):
"""An int-based Enum class that allows for int comparison."""
def __int__(self) -> int:
return self.value
class ParentFlags(IntEnum):
# Delete instance if parent is deleted
parent_lifetime = 1
# Active state is propagated from parent to children
# - the active state is propagated in collection phase
# NOTE It might be helpful to have a function that would return "real"
# active state for instances
share_active = 1 << 1
class ConvertorItem:
"""Item representing convertor plugin.
@ -507,7 +525,9 @@ class CreatedInstance:
if transient_data is None:
transient_data = {}
self._transient_data = transient_data
self._is_mandatory = False
self._is_mandatory: bool = False
self._parent_instance_id: Optional[str] = None
self._parent_flags: int = 0
# Create a copy of passed data to avoid changing them on the fly
data = copy.deepcopy(data or {})
@ -752,6 +772,39 @@ class CreatedInstance:
self["active"] = True
self._create_context.instance_requirement_changed(self.id)
@property
def parent_instance_id(self) -> Optional[str]:
return self._parent_instance_id
@property
def parent_flags(self) -> int:
return self._parent_flags
def set_parent(
self, instance_id: Optional[str], flags: int
) -> None:
"""Set parent instance id and parenting flags.
Args:
instance_id (Optional[str]): Parent instance id.
flags (int): Parenting flags.
"""
changed = False
if instance_id != self._parent_instance_id:
changed = True
self._parent_instance_id = instance_id
if flags is None:
flags = 0
if self._parent_flags != flags:
self._parent_flags = flags
changed = True
if changed:
self._create_context.instance_parent_changed(self.id)
def changes(self):
"""Calculate and return changes."""

View file

@ -30,7 +30,7 @@ from ayon_api import (
)
from ayon_core.settings import get_project_settings
from ayon_core.host import IWorkfileHost, HostBase
from ayon_core.host import IWorkfileHost, AbstractHost
from ayon_core.lib import (
Logger,
StringTemplate,
@ -54,7 +54,6 @@ from ayon_core.pipeline.plugin_discover import (
)
from ayon_core.pipeline.create import (
discover_legacy_creator_plugins,
CreateContext,
HiddenCreator,
)
@ -127,15 +126,14 @@ class AbstractTemplateBuilder(ABC):
placeholder population.
Args:
host (Union[HostBase, ModuleType]): Implementation of host.
host (Union[AbstractHost, ModuleType]): Implementation of host.
"""
_log = None
use_legacy_creators = False
def __init__(self, host):
# Get host name
if isinstance(host, HostBase):
if isinstance(host, AbstractHost):
host_name = host.name
else:
host_name = os.environ.get("AYON_HOST_NAME")
@ -163,24 +161,24 @@ class AbstractTemplateBuilder(ABC):
@property
def project_name(self):
if isinstance(self._host, HostBase):
if isinstance(self._host, AbstractHost):
return self._host.get_current_project_name()
return os.getenv("AYON_PROJECT_NAME")
@property
def current_folder_path(self):
if isinstance(self._host, HostBase):
if isinstance(self._host, AbstractHost):
return self._host.get_current_folder_path()
return os.getenv("AYON_FOLDER_PATH")
@property
def current_task_name(self):
if isinstance(self._host, HostBase):
if isinstance(self._host, AbstractHost):
return self._host.get_current_task_name()
return os.getenv("AYON_TASK_NAME")
def get_current_context(self):
if isinstance(self._host, HostBase):
if isinstance(self._host, AbstractHost):
return self._host.get_current_context()
return {
"project_name": self.project_name,
@ -256,7 +254,7 @@ class AbstractTemplateBuilder(ABC):
"""Access to host implementation.
Returns:
Union[HostBase, ModuleType]: Implementation of host.
Union[AbstractHost, ModuleType]: Implementation of host.
"""
return self._host
@ -321,19 +319,6 @@ class AbstractTemplateBuilder(ABC):
return list(get_folders(project_name, folder_ids=linked_folder_ids))
def _collect_legacy_creators(self):
creators_by_name = {}
for creator in discover_legacy_creator_plugins():
if not creator.enabled:
continue
creator_name = creator.__name__
if creator_name in creators_by_name:
raise KeyError(
"Duplicated creator name {} !".format(creator_name)
)
creators_by_name[creator_name] = creator
self._creators_by_name = creators_by_name
def _collect_creators(self):
self._creators_by_name = {
identifier: creator
@ -345,10 +330,7 @@ class AbstractTemplateBuilder(ABC):
def get_creators_by_name(self):
if self._creators_by_name is None:
if self.use_legacy_creators:
self._collect_legacy_creators()
else:
self._collect_creators()
self._collect_creators()
return self._creators_by_name
@ -1938,8 +1920,6 @@ class PlaceholderCreateMixin(object):
pre_create_data (dict): dictionary of configuration from Creator
configuration in UI
"""
legacy_create = self.builder.use_legacy_creators
creator_name = placeholder.data["creator"]
create_variant = placeholder.data["create_variant"]
active = placeholder.data.get("active")
@ -1979,20 +1959,14 @@ class PlaceholderCreateMixin(object):
# compile product name from variant
try:
if legacy_create:
creator_instance = creator_plugin(
product_name,
folder_path
).process()
else:
creator_instance = self.builder.create_context.create(
creator_plugin.identifier,
create_variant,
folder_entity,
task_entity,
pre_create_data=pre_create_data,
active=active
)
creator_instance = self.builder.create_context.create(
creator_plugin.identifier,
create_variant,
folder_entity,
task_entity,
pre_create_data=pre_create_data,
active=active
)
except: # noqa: E722
failed = True

View file

@ -2,11 +2,13 @@
"""
import os
import collections
import pyblish.api
from ayon_core.host import IPublishHost
from ayon_core.pipeline import registered_host
from ayon_core.pipeline.create import CreateContext
from ayon_core.pipeline.create import CreateContext, ParentFlags
class CollectFromCreateContext(pyblish.api.ContextPlugin):
@ -36,18 +38,51 @@ class CollectFromCreateContext(pyblish.api.ContextPlugin):
if project_name:
context.data["projectName"] = project_name
# Separate root instances and parented instances
instances_by_parent_id = collections.defaultdict(list)
root_instances = []
for created_instance in create_context.instances:
parent_id = created_instance.parent_instance_id
if parent_id is None:
root_instances.append(created_instance)
else:
instances_by_parent_id[parent_id].append(created_instance)
# Traverse instances from top to bottom
# - All instances without an existing parent are automatically
# eliminated
filtered_instances = []
_queue = collections.deque()
_queue.append((root_instances, True))
while _queue:
created_instances, parent_is_active = _queue.popleft()
for created_instance in created_instances:
is_active = created_instance["active"]
# Use a parent's active state if parent flags defines that
if (
created_instance.parent_flags & ParentFlags.share_active
and is_active
):
is_active = parent_is_active
if is_active:
filtered_instances.append(created_instance)
children = instances_by_parent_id[created_instance.id]
if children:
_queue.append((children, is_active))
for created_instance in filtered_instances:
instance_data = created_instance.data_to_store()
if instance_data["active"]:
thumbnail_path = thumbnail_paths_by_instance_id.get(
created_instance.id
)
self.create_instance(
context,
instance_data,
created_instance.transient_data,
thumbnail_path
)
thumbnail_path = thumbnail_paths_by_instance_id.get(
created_instance.id
)
self.create_instance(
context,
instance_data,
created_instance.transient_data,
thumbnail_path
)
# Update global data to context
context.data.update(create_context.context_data_to_store())

View file

@ -97,6 +97,7 @@
},
"publisher": {
"error": "#AA5050",
"disabled": "#5b6779",
"crash": "#FF6432",
"success": "#458056",
"warning": "#ffc671",

View file

@ -1153,6 +1153,10 @@ PixmapButton:disabled {
color: {color:publisher:error};
}
#ListViewProductName[state="disabled"] {
color: {color:publisher:disabled};
}
#PublishInfoFrame {
background: {color:bg};
border-radius: 0.3em;

View file

@ -1,9 +0,0 @@
from .window import (
show,
CreatorWindow
)
__all__ = (
"show",
"CreatorWindow"
)

View file

@ -1,8 +0,0 @@
from qtpy import QtCore
PRODUCT_TYPE_ROLE = QtCore.Qt.UserRole + 1
ITEM_ID_ROLE = QtCore.Qt.UserRole + 2
SEPARATOR = "---"
SEPARATORS = {"---", "---separator---"}

View file

@ -1,61 +0,0 @@
import uuid
from qtpy import QtGui, QtCore
from ayon_core.pipeline import discover_legacy_creator_plugins
from . constants import (
PRODUCT_TYPE_ROLE,
ITEM_ID_ROLE
)
class CreatorsModel(QtGui.QStandardItemModel):
def __init__(self, *args, **kwargs):
super(CreatorsModel, self).__init__(*args, **kwargs)
self._creators_by_id = {}
def reset(self):
# TODO change to refresh when clearing is not needed
self.clear()
self._creators_by_id = {}
items = []
creators = discover_legacy_creator_plugins()
for creator in creators:
if not creator.enabled:
continue
item_id = str(uuid.uuid4())
self._creators_by_id[item_id] = creator
label = creator.label or creator.product_type
item = QtGui.QStandardItem(label)
item.setEditable(False)
item.setData(item_id, ITEM_ID_ROLE)
item.setData(creator.product_type, PRODUCT_TYPE_ROLE)
items.append(item)
if not items:
item = QtGui.QStandardItem("No registered create plugins")
item.setEnabled(False)
item.setData(False, QtCore.Qt.ItemIsEnabled)
items.append(item)
items.sort(key=lambda item: item.text())
self.invisibleRootItem().appendRows(items)
def get_creator_by_id(self, item_id):
return self._creators_by_id.get(item_id)
def get_indexes_by_product_type(self, product_type):
indexes = []
for row in range(self.rowCount()):
index = self.index(row, 0)
item_id = index.data(ITEM_ID_ROLE)
creator_plugin = self._creators_by_id.get(item_id)
if creator_plugin and (
creator_plugin.label.lower() == product_type.lower()
or creator_plugin.product_type.lower() == product_type.lower()
):
indexes.append(index)
return indexes

View file

@ -1,275 +0,0 @@
import re
import inspect
from qtpy import QtWidgets, QtCore, QtGui
import qtawesome
from ayon_core.pipeline.create import PRODUCT_NAME_ALLOWED_SYMBOLS
from ayon_core.tools.utils import ErrorMessageBox
if hasattr(QtGui, "QRegularExpressionValidator"):
RegularExpressionValidatorClass = QtGui.QRegularExpressionValidator
RegularExpressionClass = QtCore.QRegularExpression
else:
RegularExpressionValidatorClass = QtGui.QRegExpValidator
RegularExpressionClass = QtCore.QRegExp
class CreateErrorMessageBox(ErrorMessageBox):
def __init__(
self,
product_type,
product_name,
folder_path,
exc_msg,
formatted_traceback,
parent
):
self._product_type = product_type
self._product_name = product_name
self._folder_path = folder_path
self._exc_msg = exc_msg
self._formatted_traceback = formatted_traceback
super(CreateErrorMessageBox, self).__init__("Creation failed", parent)
def _create_top_widget(self, parent_widget):
label_widget = QtWidgets.QLabel(parent_widget)
label_widget.setText(
"<span style='font-size:18pt;'>Failed to create</span>"
)
return label_widget
def _get_report_data(self):
report_message = (
"Failed to create Product: \"{product_name}\""
" Type: \"{product_type}\""
" in Folder: \"{folder_path}\""
"\n\nError: {message}"
).format(
product_name=self._product_name,
product_type=self._product_type,
folder_path=self._folder_path,
message=self._exc_msg
)
if self._formatted_traceback:
report_message += "\n\n{}".format(self._formatted_traceback)
return [report_message]
def _create_content(self, content_layout):
item_name_template = (
"<span style='font-weight:bold;'>{}:</span> {{}}<br>"
"<span style='font-weight:bold;'>{}:</span> {{}}<br>"
"<span style='font-weight:bold;'>{}:</span> {{}}<br>"
).format(
"Product type",
"Product name",
"Folder"
)
exc_msg_template = "<span style='font-weight:bold'>{}</span>"
line = self._create_line()
content_layout.addWidget(line)
item_name_widget = QtWidgets.QLabel(self)
item_name_widget.setText(
item_name_template.format(
self._product_type, self._product_name, self._folder_path
)
)
content_layout.addWidget(item_name_widget)
message_label_widget = QtWidgets.QLabel(self)
message_label_widget.setText(
exc_msg_template.format(self.convert_text_for_html(self._exc_msg))
)
content_layout.addWidget(message_label_widget)
if self._formatted_traceback:
line_widget = self._create_line()
tb_widget = self._create_traceback_widget(
self._formatted_traceback
)
content_layout.addWidget(line_widget)
content_layout.addWidget(tb_widget)
class ProductNameValidator(RegularExpressionValidatorClass):
invalid = QtCore.Signal(set)
pattern = "^[{}]*$".format(PRODUCT_NAME_ALLOWED_SYMBOLS)
def __init__(self):
reg = RegularExpressionClass(self.pattern)
super(ProductNameValidator, self).__init__(reg)
def validate(self, text, pos):
results = super(ProductNameValidator, self).validate(text, pos)
if results[0] == RegularExpressionValidatorClass.Invalid:
self.invalid.emit(self.invalid_chars(text))
return results
def invalid_chars(self, text):
invalid = set()
re_valid = re.compile(self.pattern)
for char in text:
if char == " ":
invalid.add("' '")
continue
if not re_valid.match(char):
invalid.add(char)
return invalid
class VariantLineEdit(QtWidgets.QLineEdit):
report = QtCore.Signal(str)
colors = {
"empty": (QtGui.QColor("#78879b"), ""),
"exists": (QtGui.QColor("#4E76BB"), "border-color: #4E76BB;"),
"new": (QtGui.QColor("#7AAB8F"), "border-color: #7AAB8F;"),
}
def __init__(self, *args, **kwargs):
super(VariantLineEdit, self).__init__(*args, **kwargs)
validator = ProductNameValidator()
self.setValidator(validator)
self.setToolTip("Only alphanumeric characters (A-Z a-z 0-9), "
"'_' and '.' are allowed.")
self._status_color = self.colors["empty"][0]
anim = QtCore.QPropertyAnimation()
anim.setTargetObject(self)
anim.setPropertyName(b"status_color")
anim.setEasingCurve(QtCore.QEasingCurve.InCubic)
anim.setDuration(300)
anim.setStartValue(QtGui.QColor("#C84747")) # `Invalid` status color
self.animation = anim
validator.invalid.connect(self.on_invalid)
def on_invalid(self, invalid):
message = "Invalid character: %s" % ", ".join(invalid)
self.report.emit(message)
self.animation.stop()
self.animation.start()
def as_empty(self):
self._set_border("empty")
self.report.emit("Empty product name ..")
def as_exists(self):
self._set_border("exists")
self.report.emit("Existing product, appending next version.")
def as_new(self):
self._set_border("new")
self.report.emit("New product, creating first version.")
def _set_border(self, status):
qcolor, style = self.colors[status]
self.animation.setEndValue(qcolor)
self.setStyleSheet(style)
def _get_status_color(self):
return self._status_color
def _set_status_color(self, color):
self._status_color = color
self.setStyleSheet("border-color: %s;" % color.name())
status_color = QtCore.Property(
QtGui.QColor, _get_status_color, _set_status_color
)
class ProductTypeDescriptionWidget(QtWidgets.QWidget):
"""A product type description widget.
Shows a product type icon, name and a help description.
Used in creator header.
_______________________
| ____ |
| |icon| PRODUCT TYPE |
| |____| help |
|_______________________|
"""
SIZE = 35
def __init__(self, parent=None):
super(ProductTypeDescriptionWidget, self).__init__(parent=parent)
icon_label = QtWidgets.QLabel(self)
icon_label.setSizePolicy(
QtWidgets.QSizePolicy.Maximum,
QtWidgets.QSizePolicy.Maximum
)
# Add 4 pixel padding to avoid icon being cut off
icon_label.setFixedWidth(self.SIZE + 4)
icon_label.setFixedHeight(self.SIZE + 4)
label_layout = QtWidgets.QVBoxLayout()
label_layout.setSpacing(0)
product_type_label = QtWidgets.QLabel(self)
product_type_label.setObjectName("CreatorProductTypeLabel")
product_type_label.setAlignment(
QtCore.Qt.AlignBottom | QtCore.Qt.AlignLeft
)
help_label = QtWidgets.QLabel(self)
help_label.setAlignment(QtCore.Qt.AlignTop | QtCore.Qt.AlignLeft)
label_layout.addWidget(product_type_label)
label_layout.addWidget(help_label)
layout = QtWidgets.QHBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(5)
layout.addWidget(icon_label)
layout.addLayout(label_layout)
self._help_label = help_label
self._product_type_label = product_type_label
self._icon_label = icon_label
def set_item(self, creator_plugin):
"""Update elements to display information of a product type item.
Args:
creator_plugin (dict): A product type item as registered with
name, help and icon.
Returns:
None
"""
if not creator_plugin:
self._icon_label.setPixmap(None)
self._product_type_label.setText("")
self._help_label.setText("")
return
# Support a font-awesome icon
icon_name = getattr(creator_plugin, "icon", None) or "info-circle"
try:
icon = qtawesome.icon("fa.{}".format(icon_name), color="white")
pixmap = icon.pixmap(self.SIZE, self.SIZE)
except Exception:
print("BUG: Couldn't load icon \"fa.{}\"".format(str(icon_name)))
# Create transparent pixmap
pixmap = QtGui.QPixmap()
pixmap.fill(QtCore.Qt.transparent)
pixmap = pixmap.scaled(self.SIZE, self.SIZE)
# Parse a clean line from the Creator's docstring
docstring = inspect.getdoc(creator_plugin)
creator_help = docstring.splitlines()[0] if docstring else ""
self._icon_label.setPixmap(pixmap)
self._product_type_label.setText(creator_plugin.product_type)
self._help_label.setText(creator_help)

View file

@ -1,508 +0,0 @@
import sys
import traceback
import re
import ayon_api
from qtpy import QtWidgets, QtCore
from ayon_core import style
from ayon_core.settings import get_current_project_settings
from ayon_core.tools.utils.lib import qt_app_context
from ayon_core.pipeline import (
get_current_project_name,
get_current_folder_path,
get_current_task_name,
)
from ayon_core.pipeline.create import (
PRODUCT_NAME_ALLOWED_SYMBOLS,
legacy_create,
CreatorError,
)
from .model import CreatorsModel
from .widgets import (
CreateErrorMessageBox,
VariantLineEdit,
ProductTypeDescriptionWidget
)
from .constants import (
ITEM_ID_ROLE,
SEPARATOR,
SEPARATORS
)
module = sys.modules[__name__]
module.window = None
class CreatorWindow(QtWidgets.QDialog):
def __init__(self, parent=None):
super(CreatorWindow, self).__init__(parent)
self.setWindowTitle("Instance Creator")
self.setFocusPolicy(QtCore.Qt.StrongFocus)
if not parent:
self.setWindowFlags(
self.windowFlags() | QtCore.Qt.WindowStaysOnTopHint
)
creator_info = ProductTypeDescriptionWidget(self)
creators_model = CreatorsModel()
creators_proxy = QtCore.QSortFilterProxyModel()
creators_proxy.setSourceModel(creators_model)
creators_view = QtWidgets.QListView(self)
creators_view.setObjectName("CreatorsView")
creators_view.setModel(creators_proxy)
folder_path_input = QtWidgets.QLineEdit(self)
variant_input = VariantLineEdit(self)
product_name_input = QtWidgets.QLineEdit(self)
product_name_input.setEnabled(False)
variants_btn = QtWidgets.QPushButton()
variants_btn.setFixedWidth(18)
variants_menu = QtWidgets.QMenu(variants_btn)
variants_btn.setMenu(variants_menu)
name_layout = QtWidgets.QHBoxLayout()
name_layout.addWidget(variant_input)
name_layout.addWidget(variants_btn)
name_layout.setSpacing(3)
name_layout.setContentsMargins(0, 0, 0, 0)
body_layout = QtWidgets.QVBoxLayout()
body_layout.setContentsMargins(0, 0, 0, 0)
body_layout.addWidget(creator_info, 0)
body_layout.addWidget(QtWidgets.QLabel("Product type", self), 0)
body_layout.addWidget(creators_view, 1)
body_layout.addWidget(QtWidgets.QLabel("Folder path", self), 0)
body_layout.addWidget(folder_path_input, 0)
body_layout.addWidget(QtWidgets.QLabel("Product name", self), 0)
body_layout.addLayout(name_layout, 0)
body_layout.addWidget(product_name_input, 0)
useselection_chk = QtWidgets.QCheckBox("Use selection", self)
useselection_chk.setCheckState(QtCore.Qt.Checked)
create_btn = QtWidgets.QPushButton("Create", self)
# Need to store error_msg to prevent garbage collection
msg_label = QtWidgets.QLabel(self)
footer_layout = QtWidgets.QVBoxLayout()
footer_layout.addWidget(create_btn, 0)
footer_layout.addWidget(msg_label, 0)
footer_layout.setContentsMargins(0, 0, 0, 0)
layout = QtWidgets.QVBoxLayout(self)
layout.addLayout(body_layout, 1)
layout.addWidget(useselection_chk, 0, QtCore.Qt.AlignLeft)
layout.addLayout(footer_layout, 0)
msg_timer = QtCore.QTimer()
msg_timer.setSingleShot(True)
msg_timer.setInterval(5000)
validation_timer = QtCore.QTimer()
validation_timer.setSingleShot(True)
validation_timer.setInterval(300)
msg_timer.timeout.connect(self._on_msg_timer)
validation_timer.timeout.connect(self._on_validation_timer)
create_btn.clicked.connect(self._on_create)
variant_input.returnPressed.connect(self._on_create)
variant_input.textChanged.connect(self._on_data_changed)
variant_input.report.connect(self.echo)
folder_path_input.textChanged.connect(self._on_data_changed)
creators_view.selectionModel().currentChanged.connect(
self._on_selection_changed
)
# Store valid states and
self._is_valid = False
create_btn.setEnabled(self._is_valid)
self._first_show = True
# Message dialog when something goes wrong during creation
self._message_dialog = None
self._creator_info = creator_info
self._create_btn = create_btn
self._useselection_chk = useselection_chk
self._variant_input = variant_input
self._product_name_input = product_name_input
self._folder_path_input = folder_path_input
self._creators_model = creators_model
self._creators_proxy = creators_proxy
self._creators_view = creators_view
self._variants_btn = variants_btn
self._variants_menu = variants_menu
self._msg_label = msg_label
self._validation_timer = validation_timer
self._msg_timer = msg_timer
# Defaults
self.resize(300, 500)
variant_input.setFocus()
def _set_valid_state(self, valid):
if self._is_valid == valid:
return
self._is_valid = valid
self._create_btn.setEnabled(valid)
def _build_menu(self, default_names=None):
"""Create optional predefined variants.
Args:
default_names(list): all predefined names
Returns:
None
"""
if not default_names:
default_names = []
menu = self._variants_menu
button = self._variants_btn
# Get and destroy the action group
group = button.findChild(QtWidgets.QActionGroup)
if group:
group.deleteLater()
state = any(default_names)
button.setEnabled(state)
if state is False:
return
# Build new action group
group = QtWidgets.QActionGroup(button)
for name in default_names:
if name in SEPARATORS:
menu.addSeparator()
continue
action = group.addAction(name)
menu.addAction(action)
group.triggered.connect(self._on_action_clicked)
def _on_action_clicked(self, action):
self._variant_input.setText(action.text())
def _on_data_changed(self, *args):
# Set invalid state until it's reconfirmed to be valid by the
# scheduled callback so any form of creation is held back until
# valid again
self._set_valid_state(False)
self._validation_timer.start()
def _on_validation_timer(self):
index = self._creators_view.currentIndex()
item_id = index.data(ITEM_ID_ROLE)
creator_plugin = self._creators_model.get_creator_by_id(item_id)
user_input_text = self._variant_input.text()
folder_path = self._folder_path_input.text()
# Early exit if no folder path
if not folder_path:
self._build_menu()
self.echo("Folder is required ..")
self._set_valid_state(False)
return
project_name = get_current_project_name()
folder_entity = None
if creator_plugin:
# Get the folder from the database which match with the name
folder_entity = ayon_api.get_folder_by_path(
project_name, folder_path, fields={"id"}
)
# Get plugin
if not folder_entity or not creator_plugin:
self._build_menu()
if not creator_plugin:
self.echo("No registered product types ..")
else:
self.echo("Folder '{}' not found ..".format(folder_path))
self._set_valid_state(False)
return
folder_id = folder_entity["id"]
task_name = get_current_task_name()
task_entity = ayon_api.get_task_by_name(
project_name, folder_id, task_name
)
# Calculate product name with Creator plugin
product_name = creator_plugin.get_product_name(
project_name, folder_entity, task_entity, user_input_text
)
# Force replacement of prohibited symbols
# QUESTION should Creator care about this and here should be only
# validated with schema regex?
# Allow curly brackets in product name for dynamic keys
curly_left = "__cbl__"
curly_right = "__cbr__"
tmp_product_name = (
product_name
.replace("{", curly_left)
.replace("}", curly_right)
)
# Replace prohibited symbols
tmp_product_name = re.sub(
"[^{}]+".format(PRODUCT_NAME_ALLOWED_SYMBOLS),
"",
tmp_product_name
)
product_name = (
tmp_product_name
.replace(curly_left, "{")
.replace(curly_right, "}")
)
self._product_name_input.setText(product_name)
# Get all products of the current folder
product_entities = ayon_api.get_products(
project_name, folder_ids={folder_id}, fields={"name"}
)
existing_product_names = {
product_entity["name"]
for product_entity in product_entities
}
existing_product_names_low = set(
_name.lower()
for _name in existing_product_names
)
# Defaults to dropdown
defaults = []
# Check if Creator plugin has set defaults
if (
creator_plugin.defaults
and isinstance(creator_plugin.defaults, (list, tuple, set))
):
defaults = list(creator_plugin.defaults)
# Replace
compare_regex = re.compile(re.sub(
user_input_text, "(.+)", product_name, flags=re.IGNORECASE
))
variant_hints = set()
if user_input_text:
for _name in existing_product_names:
_result = compare_regex.search(_name)
if _result:
variant_hints |= set(_result.groups())
if variant_hints:
if defaults:
defaults.append(SEPARATOR)
defaults.extend(variant_hints)
self._build_menu(defaults)
# Indicate product existence
if not user_input_text:
self._variant_input.as_empty()
elif product_name.lower() in existing_product_names_low:
# validate existence of product name with lowered text
# - "renderMain" vs. "rensermain" mean same path item for
# windows
self._variant_input.as_exists()
else:
self._variant_input.as_new()
# Update the valid state
valid = product_name.strip() != ""
self._set_valid_state(valid)
def _on_selection_changed(self, old_idx, new_idx):
index = self._creators_view.currentIndex()
item_id = index.data(ITEM_ID_ROLE)
creator_plugin = self._creators_model.get_creator_by_id(item_id)
self._creator_info.set_item(creator_plugin)
if creator_plugin is None:
return
default = None
if hasattr(creator_plugin, "get_default_variant"):
default = creator_plugin.get_default_variant()
if not default:
if (
creator_plugin.defaults
and isinstance(creator_plugin.defaults, list)
):
default = creator_plugin.defaults[0]
else:
default = "Default"
self._variant_input.setText(default)
self._on_data_changed()
def keyPressEvent(self, event):
"""Custom keyPressEvent.
Override keyPressEvent to do nothing so that Maya's panels won't
take focus when pressing "SHIFT" whilst mouse is over viewport or
outliner. This way users don't accidentally perform Maya commands
whilst trying to name an instance.
"""
pass
def showEvent(self, event):
super(CreatorWindow, self).showEvent(event)
if self._first_show:
self._first_show = False
self.setStyleSheet(style.load_stylesheet())
def refresh(self):
self._folder_path_input.setText(get_current_folder_path())
self._creators_model.reset()
product_types_smart_select = (
get_current_project_settings()
["core"]
["tools"]
["creator"]
["product_types_smart_select"]
)
current_index = None
product_type = None
task_name = get_current_task_name() or None
lowered_task_name = task_name.lower()
if task_name:
for smart_item in product_types_smart_select:
_low_task_names = {
name.lower() for name in smart_item["task_names"]
}
for _task_name in _low_task_names:
if _task_name in lowered_task_name:
product_type = smart_item["name"]
break
if product_type:
break
if product_type:
indexes = self._creators_model.get_indexes_by_product_type(
product_type
)
if indexes:
index = indexes[0]
current_index = self._creators_proxy.mapFromSource(index)
if current_index is None or not current_index.isValid():
current_index = self._creators_proxy.index(0, 0)
self._creators_view.setCurrentIndex(current_index)
def _on_create(self):
# Do not allow creation in an invalid state
if not self._is_valid:
return
index = self._creators_view.currentIndex()
item_id = index.data(ITEM_ID_ROLE)
creator_plugin = self._creators_model.get_creator_by_id(item_id)
if creator_plugin is None:
return
product_name = self._product_name_input.text()
folder_path = self._folder_path_input.text()
use_selection = self._useselection_chk.isChecked()
variant = self._variant_input.text()
error_info = None
try:
legacy_create(
creator_plugin,
product_name,
folder_path,
options={"useSelection": use_selection},
data={"variant": variant}
)
except CreatorError as exc:
self.echo("Creator error: {}".format(str(exc)))
error_info = (str(exc), None)
except Exception as exc:
self.echo("Program error: %s" % str(exc))
exc_type, exc_value, exc_traceback = sys.exc_info()
formatted_traceback = "".join(traceback.format_exception(
exc_type, exc_value, exc_traceback
))
error_info = (str(exc), formatted_traceback)
if error_info:
box = CreateErrorMessageBox(
creator_plugin.product_type,
product_name,
folder_path,
*error_info,
parent=self
)
box.show()
# Store dialog so is not garbage collected before is shown
self._message_dialog = box
else:
self.echo("Created %s .." % product_name)
def _on_msg_timer(self):
self._msg_label.setText("")
def echo(self, message):
self._msg_label.setText(str(message))
self._msg_timer.start()
def show(parent=None):
"""Display product creator GUI
Arguments:
debug (bool, optional): Run loader in debug-mode,
defaults to False
parent (QtCore.QObject, optional): When provided parent the interface
to this QObject.
"""
try:
module.window.close()
del module.window
except (AttributeError, RuntimeError):
pass
with qt_app_context():
window = CreatorWindow(parent)
window.refresh()
window.show()
module.window = window
# Pull window to the front.
module.window.raise_()
module.window.activateWindow()

View file

@ -13,7 +13,7 @@ from typing import (
)
from ayon_core.lib import AbstractAttrDef
from ayon_core.host import HostBase
from ayon_core.host import AbstractHost
from ayon_core.pipeline.create import (
CreateContext,
ConvertorItem,
@ -176,7 +176,7 @@ class AbstractPublisherBackend(AbstractPublisherCommon):
pass
@abstractmethod
def get_host(self) -> HostBase:
def get_host(self) -> AbstractHost:
pass
@abstractmethod

View file

@ -219,6 +219,8 @@ class InstanceItem:
is_active: bool,
is_mandatory: bool,
has_promised_context: bool,
parent_instance_id: Optional[str],
parent_flags: int,
):
self._instance_id: str = instance_id
self._creator_identifier: str = creator_identifier
@ -232,6 +234,8 @@ class InstanceItem:
self._is_active: bool = is_active
self._is_mandatory: bool = is_mandatory
self._has_promised_context: bool = has_promised_context
self._parent_instance_id: Optional[str] = parent_instance_id
self._parent_flags: int = parent_flags
@property
def id(self):
@ -261,6 +265,14 @@ class InstanceItem:
def has_promised_context(self):
return self._has_promised_context
@property
def parent_instance_id(self):
return self._parent_instance_id
@property
def parent_flags(self) -> int:
return self._parent_flags
def get_variant(self):
return self._variant
@ -312,6 +324,8 @@ class InstanceItem:
instance["active"],
instance.is_mandatory,
instance.has_promised_context,
instance.parent_instance_id,
instance.parent_flags,
)
@ -486,6 +500,9 @@ class CreateModel:
self._create_context.add_instance_requirement_change_callback(
self._cc_instance_requirement_changed
)
self._create_context.add_instance_parent_change_callback(
self._cc_instance_parent_changed
)
self._create_context.reset_finalization()
@ -566,15 +583,21 @@ class CreateModel:
def set_instances_active_state(
self, active_state_by_id: Dict[str, bool]
):
changed_ids = set()
with self._create_context.bulk_value_changes(CREATE_EVENT_SOURCE):
for instance_id, active in active_state_by_id.items():
instance = self._create_context.get_instance_by_id(instance_id)
instance["active"] = active
if instance["active"] is not active:
instance["active"] = active
changed_ids.add(instance_id)
if not changed_ids:
return
self._emit_event(
"create.model.instances.context.changed",
{
"instance_ids": set(active_state_by_id.keys())
"instance_ids": changed_ids
}
)
@ -1191,6 +1214,16 @@ class CreateModel:
{"instance_ids": instance_ids},
)
def _cc_instance_parent_changed(self, event):
instance_ids = {
instance.id
for instance in event.data["instances"]
}
self._emit_event(
"create.model.instance.parent.changed",
{"instance_ids": instance_ids},
)
def _get_allowed_creators_pattern(self) -> Union[Pattern, None]:
"""Provide regex pattern for configured creator labels in this context

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,3 +1,7 @@
from __future__ import annotations
from typing import Generator
from qtpy import QtWidgets, QtCore
from ayon_core.tools.publisher.abstract import AbstractPublisherFrontend
@ -6,6 +10,7 @@ from .border_label_widget import BorderedLabelWidget
from .card_view_widgets import InstanceCardView
from .list_view_widgets import InstanceListView
from .widgets import (
AbstractInstanceView,
CreateInstanceBtn,
RemoveInstanceBtn,
ChangeViewBtn,
@ -43,10 +48,16 @@ class OverviewWidget(QtWidgets.QFrame):
product_view_cards = InstanceCardView(controller, product_views_widget)
product_list_view = InstanceListView(controller, product_views_widget)
product_list_view.set_parent_grouping(False)
product_list_view_grouped = InstanceListView(
controller, product_views_widget
)
product_list_view_grouped.set_parent_grouping(True)
product_views_layout = QtWidgets.QStackedLayout()
product_views_layout.addWidget(product_view_cards)
product_views_layout.addWidget(product_list_view)
product_views_layout.addWidget(product_list_view_grouped)
product_views_layout.setCurrentWidget(product_view_cards)
# Buttons at the bottom of product view
@ -118,6 +129,12 @@ class OverviewWidget(QtWidgets.QFrame):
product_list_view.double_clicked.connect(
self.publish_tab_requested
)
product_list_view_grouped.selection_changed.connect(
self._on_product_change
)
product_list_view_grouped.double_clicked.connect(
self.publish_tab_requested
)
product_view_cards.selection_changed.connect(
self._on_product_change
)
@ -159,16 +176,22 @@ class OverviewWidget(QtWidgets.QFrame):
"create.model.instance.requirement.changed",
self._on_instance_requirement_changed
)
controller.register_event_callback(
"create.model.instance.parent.changed",
self._on_instance_parent_changed
)
self._product_content_widget = product_content_widget
self._product_content_layout = product_content_layout
self._product_view_cards = product_view_cards
self._product_list_view = product_list_view
self._product_list_view_grouped = product_list_view_grouped
self._product_views_layout = product_views_layout
self._create_btn = create_btn
self._delete_btn = delete_btn
self._change_view_btn = change_view_btn
self._product_attributes_widget = product_attributes_widget
self._create_widget = create_widget
@ -246,7 +269,7 @@ class OverviewWidget(QtWidgets.QFrame):
)
def has_items(self):
view = self._product_views_layout.currentWidget()
view = self._get_current_view()
return view.has_items()
def _on_create_clicked(self):
@ -361,17 +384,18 @@ class OverviewWidget(QtWidgets.QFrame):
def _on_instance_requirement_changed(self, event):
self._refresh_instance_states(event["instance_ids"])
def _refresh_instance_states(self, instance_ids):
current_idx = self._product_views_layout.currentIndex()
for idx in range(self._product_views_layout.count()):
if idx == current_idx:
continue
widget = self._product_views_layout.widget(idx)
if widget.refreshed:
widget.set_refreshed(False)
def _on_instance_parent_changed(self, event):
self._refresh_instance_states(event["instance_ids"])
current_widget = self._product_views_layout.widget(current_idx)
current_widget.refresh_instance_states(instance_ids)
def _refresh_instance_states(self, instance_ids):
current_view = self._get_current_view()
for view in self._iter_views():
if view is current_view:
current_view = view
elif view.refreshed:
view.set_refreshed(False)
current_view.refresh_instance_states(instance_ids)
def _on_convert_requested(self):
self.convert_requested.emit()
@ -385,7 +409,7 @@ class OverviewWidget(QtWidgets.QFrame):
convertor plugins.
"""
view = self._product_views_layout.currentWidget()
view = self._get_current_view()
return view.get_selected_items()
def get_selected_legacy_convertors(self):
@ -400,12 +424,12 @@ class OverviewWidget(QtWidgets.QFrame):
return convertor_identifiers
def _change_view_type(self):
old_view = self._get_current_view()
idx = self._product_views_layout.currentIndex()
new_idx = (idx + 1) % self._product_views_layout.count()
old_view = self._product_views_layout.currentWidget()
new_view = self._product_views_layout.widget(new_idx)
new_view = self._get_view_by_idx(new_idx)
if not new_view.refreshed:
new_view.refresh()
new_view.set_refreshed(True)
@ -418,22 +442,52 @@ class OverviewWidget(QtWidgets.QFrame):
new_view.set_selected_items(
instance_ids, context_selected, convertor_identifiers
)
view_type = "list"
if new_view is self._product_list_view_grouped:
view_type = "card"
elif new_view is self._product_list_view:
view_type = "list-parent-grouping"
self._change_view_btn.set_view_type(view_type)
self._product_views_layout.setCurrentIndex(new_idx)
self._on_product_change()
def _iter_views(self) -> Generator[AbstractInstanceView, None, None]:
for idx in range(self._product_views_layout.count()):
widget = self._product_views_layout.widget(idx)
if not isinstance(widget, AbstractInstanceView):
raise TypeError(
"Current widget is not instance of 'AbstractInstanceView'"
)
yield widget
def _get_current_view(self) -> AbstractInstanceView:
widget = self._product_views_layout.currentWidget()
if isinstance(widget, AbstractInstanceView):
return widget
raise TypeError(
"Current widget is not instance of 'AbstractInstanceView'"
)
def _get_view_by_idx(self, idx: int) -> AbstractInstanceView:
widget = self._product_views_layout.widget(idx)
if isinstance(widget, AbstractInstanceView):
return widget
raise TypeError(
"Current widget is not instance of 'AbstractInstanceView'"
)
def _refresh_instances(self):
if self._refreshing_instances:
return
self._refreshing_instances = True
for idx in range(self._product_views_layout.count()):
widget = self._product_views_layout.widget(idx)
widget.set_refreshed(False)
for view in self._iter_views():
view.set_refreshed(False)
view = self._product_views_layout.currentWidget()
view = self._get_current_view()
view.refresh()
view.set_refreshed(True)
@ -444,25 +498,22 @@ class OverviewWidget(QtWidgets.QFrame):
# Give a change to process Resize Request
QtWidgets.QApplication.processEvents()
# Trigger update geometry of
widget = self._product_views_layout.currentWidget()
widget.updateGeometry()
# Trigger update geometry
view.updateGeometry()
def _on_publish_start(self):
"""Publish started."""
self._create_btn.setEnabled(False)
self._product_attributes_wrap.setEnabled(False)
for idx in range(self._product_views_layout.count()):
widget = self._product_views_layout.widget(idx)
widget.set_active_toggle_enabled(False)
for view in self._iter_views():
view.set_active_toggle_enabled(False)
def _on_controller_reset_start(self):
"""Controller reset started."""
for idx in range(self._product_views_layout.count()):
widget = self._product_views_layout.widget(idx)
widget.set_active_toggle_enabled(True)
for view in self._iter_views():
view.set_active_toggle_enabled(True)
def _on_publish_reset(self):
"""Context in controller has been reseted."""
@ -477,7 +528,19 @@ class OverviewWidget(QtWidgets.QFrame):
self._refresh_instances()
def _on_instances_added(self):
view = self._get_current_view()
is_card_view = False
count = 0
if isinstance(view, InstanceCardView):
is_card_view = True
count = view.get_current_instance_count()
self._refresh_instances()
if is_card_view and count < 10:
new_count = view.get_current_instance_count()
if new_count > count and new_count >= 10:
self._change_view_type()
def _on_instances_removed(self):
self._refresh_instances()

View file

@ -10,6 +10,7 @@ from ayon_core.tools.flickcharm import FlickCharm
from ayon_core.tools.utils import (
IconButton,
PixmapLabel,
get_qt_icon,
)
from ayon_core.tools.publisher.constants import ResetKeySequence
@ -287,12 +288,32 @@ class RemoveInstanceBtn(PublishIconBtn):
self.setToolTip("Remove selected instances")
class ChangeViewBtn(PublishIconBtn):
"""Create toggle view button."""
class ChangeViewBtn(IconButton):
"""Toggle views button."""
def __init__(self, parent=None):
icon_path = get_icon_path("change_view")
super().__init__(icon_path, parent)
self.setToolTip("Swap between views")
super().__init__(parent)
self.set_view_type("list")
def set_view_type(self, view_type):
if view_type == "list":
# icon_name = "data_table"
icon_name = "dehaze"
tooltip = "Change to list view"
elif view_type == "card":
icon_name = "view_agenda"
tooltip = "Change to card view"
else:
icon_name = "segment"
tooltip = "Change to parent grouping view"
# "format_align_right"
# "segment"
icon = get_qt_icon({
"type": "material-symbols",
"name": icon_name,
})
self.setIcon(icon)
self.setToolTip(tooltip)
class AbstractInstanceView(QtWidgets.QWidget):
@ -370,6 +391,20 @@ class AbstractInstanceView(QtWidgets.QWidget):
"{} Method 'set_active_toggle_enabled' is not implemented."
).format(self.__class__.__name__))
def refresh_instance_states(self, instance_ids=None):
"""Refresh instance states.
Args:
instance_ids: Optional[Iterable[str]]: Instance ids to refresh.
If not passed then all instances are refreshed.
"""
raise NotImplementedError(
f"{self.__class__.__name__} Method 'refresh_instance_states'"
" is not implemented."
)
class ClickableLineEdit(QtWidgets.QLineEdit):
"""QLineEdit capturing left mouse click.

View file

@ -1,7 +1,7 @@
import ayon_api
from ayon_core.lib.events import QueuedEventSystem
from ayon_core.host import HostBase
from ayon_core.host import ILoadHost
from ayon_core.pipeline import (
registered_host,
get_current_context,
@ -35,7 +35,7 @@ class SceneInventoryController:
self._projects_model = ProjectsModel(self)
self._event_system = self._create_event_system()
def get_host(self) -> HostBase:
def get_host(self) -> ILoadHost:
return self._host
def emit_event(self, topic, data=None, source=None):

View file

@ -1,19 +0,0 @@
Subset manager
--------------
Simple UI showing list of created subset that will be published via Pyblish.
Useful for applications (Photoshop, AfterEffects, TVPaint, Harmony) which are
storing metadata about instance hidden from user.
This UI allows listing all created subset and removal of them if needed (
in case use doesn't want to publish anymore, its using workfile as a starting
file for different task and instances should be completely different etc.
)
Host is expected to implemented:
- `list_instances` - returning list of dictionaries (instances), must contain
unique uuid field
example:
```[{"uuid":"15","active":true,"subset":"imageBG","family":"image","id":"ayon.create.instance","asset":"Town"}]```
- `remove_instance(instance)` - removes instance from file's metadata
instance is a dictionary, with uuid field

View file

@ -1,9 +0,0 @@
from .window import (
show,
SubsetManagerWindow
)
__all__ = (
"show",
"SubsetManagerWindow"
)

View file

@ -1,56 +0,0 @@
import uuid
from qtpy import QtCore, QtGui
from ayon_core.pipeline import registered_host
ITEM_ID_ROLE = QtCore.Qt.UserRole + 1
class InstanceModel(QtGui.QStandardItemModel):
def __init__(self, *args, **kwargs):
super(InstanceModel, self).__init__(*args, **kwargs)
self._instances_by_item_id = {}
def get_instance_by_id(self, item_id):
return self._instances_by_item_id.get(item_id)
def refresh(self):
self.clear()
self._instances_by_item_id = {}
instances = None
host = registered_host()
list_instances = getattr(host, "list_instances", None)
if list_instances:
instances = list_instances()
if not instances:
return
items = []
for instance_data in instances:
item_id = str(uuid.uuid4())
product_name = (
instance_data.get("productName")
or instance_data.get("subset")
)
label = instance_data.get("label") or product_name
item = QtGui.QStandardItem(label)
item.setEnabled(True)
item.setEditable(False)
item.setData(item_id, ITEM_ID_ROLE)
items.append(item)
self._instances_by_item_id[item_id] = instance_data
if items:
self.invisibleRootItem().appendRows(items)
def headerData(self, section, orientation, role):
if role == QtCore.Qt.DisplayRole and section == 0:
return "Instance"
return super(InstanceModel, self).headerData(
section, orientation, role
)

View file

@ -1,110 +0,0 @@
import json
from qtpy import QtWidgets, QtCore
class InstanceDetail(QtWidgets.QWidget):
save_triggered = QtCore.Signal()
def __init__(self, parent=None):
super(InstanceDetail, self).__init__(parent)
details_widget = QtWidgets.QPlainTextEdit(self)
details_widget.setObjectName("SubsetManagerDetailsText")
save_btn = QtWidgets.QPushButton("Save", self)
self._block_changes = False
self._editable = False
self._item_id = None
layout = QtWidgets.QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.addWidget(details_widget, 1)
layout.addWidget(save_btn, 0, QtCore.Qt.AlignRight)
save_btn.clicked.connect(self._on_save_clicked)
details_widget.textChanged.connect(self._on_text_change)
self._details_widget = details_widget
self._save_btn = save_btn
self.set_editable(False)
def _on_save_clicked(self):
if self.is_valid():
self.save_triggered.emit()
def set_editable(self, enabled=True):
self._editable = enabled
self.update_state()
def update_state(self, valid=None):
editable = self._editable
if not self._item_id:
editable = False
self._save_btn.setVisible(editable)
self._details_widget.setReadOnly(not editable)
if valid is None:
valid = self.is_valid()
self._save_btn.setEnabled(valid)
self._set_invalid_detail(valid)
def _set_invalid_detail(self, valid):
state = ""
if not valid:
state = "invalid"
current_state = self._details_widget.property("state")
if current_state != state:
self._details_widget.setProperty("state", state)
self._details_widget.style().polish(self._details_widget)
def set_details(self, container, item_id):
self._item_id = item_id
text = "Nothing selected"
if item_id:
try:
text = json.dumps(container, indent=4)
except Exception:
text = str(container)
self._block_changes = True
self._details_widget.setPlainText(text)
self._block_changes = False
self.update_state()
def instance_data_from_text(self):
try:
jsoned = json.loads(self._details_widget.toPlainText())
except Exception:
jsoned = None
return jsoned
def item_id(self):
return self._item_id
def is_valid(self):
if not self._item_id:
return True
value = self._details_widget.toPlainText()
valid = False
try:
jsoned = json.loads(value)
if jsoned and isinstance(jsoned, dict):
valid = True
except Exception:
pass
return valid
def _on_text_change(self):
if self._block_changes or not self._item_id:
return
valid = self.is_valid()
self.update_state(valid)

View file

@ -1,218 +0,0 @@
import os
import sys
from qtpy import QtWidgets, QtCore
import qtawesome
from ayon_core import style
from ayon_core.pipeline import registered_host
from ayon_core.tools.utils import PlaceholderLineEdit
from ayon_core.tools.utils.lib import (
iter_model_rows,
qt_app_context
)
from ayon_core.tools.utils.models import RecursiveSortFilterProxyModel
from .model import (
InstanceModel,
ITEM_ID_ROLE
)
from .widgets import InstanceDetail
module = sys.modules[__name__]
module.window = None
class SubsetManagerWindow(QtWidgets.QDialog):
def __init__(self, parent=None):
super(SubsetManagerWindow, self).__init__(parent=parent)
self.setWindowTitle("Subset Manager 0.1")
self.setObjectName("SubsetManager")
if not parent:
self.setWindowFlags(
self.windowFlags() | QtCore.Qt.WindowStaysOnTopHint
)
self.resize(780, 430)
# Trigger refresh on first called show
self._first_show = True
left_side_widget = QtWidgets.QWidget(self)
# Header part
header_widget = QtWidgets.QWidget(left_side_widget)
# Filter input
filter_input = PlaceholderLineEdit(header_widget)
filter_input.setPlaceholderText("Filter products..")
# Refresh button
icon = qtawesome.icon("fa.refresh", color="white")
refresh_btn = QtWidgets.QPushButton(header_widget)
refresh_btn.setIcon(icon)
header_layout = QtWidgets.QHBoxLayout(header_widget)
header_layout.setContentsMargins(0, 0, 0, 0)
header_layout.addWidget(filter_input)
header_layout.addWidget(refresh_btn)
# Instances view
view = QtWidgets.QTreeView(left_side_widget)
view.setIndentation(0)
view.setContextMenuPolicy(QtCore.Qt.CustomContextMenu)
model = InstanceModel(view)
proxy = RecursiveSortFilterProxyModel()
proxy.setSourceModel(model)
proxy.setFilterCaseSensitivity(QtCore.Qt.CaseInsensitive)
view.setModel(proxy)
left_side_layout = QtWidgets.QVBoxLayout(left_side_widget)
left_side_layout.setContentsMargins(0, 0, 0, 0)
left_side_layout.addWidget(header_widget)
left_side_layout.addWidget(view)
details_widget = InstanceDetail(self)
layout = QtWidgets.QHBoxLayout(self)
layout.addWidget(left_side_widget, 0)
layout.addWidget(details_widget, 1)
filter_input.textChanged.connect(proxy.setFilterFixedString)
refresh_btn.clicked.connect(self._on_refresh_clicked)
view.clicked.connect(self._on_activated)
view.customContextMenuRequested.connect(self.on_context_menu)
details_widget.save_triggered.connect(self._on_save)
self._model = model
self._proxy = proxy
self._view = view
self._details_widget = details_widget
self._refresh_btn = refresh_btn
def _on_refresh_clicked(self):
self.refresh()
def _on_activated(self, index):
container = None
item_id = None
if index.isValid():
item_id = index.data(ITEM_ID_ROLE)
container = self._model.get_instance_by_id(item_id)
self._details_widget.set_details(container, item_id)
def _on_save(self):
host = registered_host()
if not hasattr(host, "save_instances"):
print("BUG: Host does not have \"save_instances\" method")
return
current_index = self._view.selectionModel().currentIndex()
if not current_index.isValid():
return
item_id = current_index.data(ITEM_ID_ROLE)
if item_id != self._details_widget.item_id():
return
item_data = self._details_widget.instance_data_from_text()
new_instances = []
for index in iter_model_rows(self._model, 0):
_item_id = index.data(ITEM_ID_ROLE)
if _item_id == item_id:
instance_data = item_data
else:
instance_data = self._model.get_instance_by_id(item_id)
new_instances.append(instance_data)
host.save_instances(new_instances)
def on_context_menu(self, point):
point_index = self._view.indexAt(point)
item_id = point_index.data(ITEM_ID_ROLE)
instance_data = self._model.get_instance_by_id(item_id)
if instance_data is None:
return
# Prepare menu
menu = QtWidgets.QMenu(self)
actions = []
host = registered_host()
if hasattr(host, "remove_instance"):
action = QtWidgets.QAction("Remove instance", menu)
action.setData(host.remove_instance)
actions.append(action)
if hasattr(host, "select_instance"):
action = QtWidgets.QAction("Select instance", menu)
action.setData(host.select_instance)
actions.append(action)
if not actions:
actions.append(QtWidgets.QAction("* Nothing to do", menu))
for action in actions:
menu.addAction(action)
# Show menu under mouse
global_point = self._view.mapToGlobal(point)
action = menu.exec_(global_point)
if not action or not action.data():
return
# Process action
# TODO catch exceptions
function = action.data()
function(instance_data)
# Reset modified data
self.refresh()
def refresh(self):
self._details_widget.set_details(None, None)
self._model.refresh()
host = registered_host()
dev_mode = os.environ.get("AVALON_DEVELOP_MODE") or ""
editable = False
if dev_mode.lower() in ("1", "yes", "true", "on"):
editable = hasattr(host, "save_instances")
self._details_widget.set_editable(editable)
def showEvent(self, *args, **kwargs):
super(SubsetManagerWindow, self).showEvent(*args, **kwargs)
if self._first_show:
self._first_show = False
self.setStyleSheet(style.load_stylesheet())
self.refresh()
def show(root=None, debug=False, parent=None):
"""Display Scene Inventory GUI
Arguments:
debug (bool, optional): Run in debug-mode,
defaults to False
parent (QtCore.QObject, optional): When provided parent the interface
to this QObject.
"""
try:
module.window.close()
del module.window
except (RuntimeError, AttributeError):
pass
with qt_app_context():
window = SubsetManagerWindow(parent)
window.show()
module.window = window
# Pull window to the front.
module.window.raise_()
module.window.activateWindow()

View file

@ -31,9 +31,7 @@ class HostToolsHelper:
# Prepare attributes for all tools
self._workfiles_tool = None
self._loader_tool = None
self._creator_tool = None
self._publisher_tool = None
self._subset_manager_tool = None
self._scene_inventory_tool = None
self._experimental_tools_dialog = None
@ -96,49 +94,6 @@ class HostToolsHelper:
loader_tool.refresh()
def get_creator_tool(self, parent):
"""Create, cache and return creator tool window."""
if self._creator_tool is None:
from ayon_core.tools.creator import CreatorWindow
creator_window = CreatorWindow(parent=parent or self._parent)
self._creator_tool = creator_window
return self._creator_tool
def show_creator(self, parent=None):
"""Show tool to create new instantes for publishing."""
with qt_app_context():
creator_tool = self.get_creator_tool(parent)
creator_tool.refresh()
creator_tool.show()
# Pull window to the front.
creator_tool.raise_()
creator_tool.activateWindow()
def get_subset_manager_tool(self, parent):
"""Create, cache and return subset manager tool window."""
if self._subset_manager_tool is None:
from ayon_core.tools.subsetmanager import SubsetManagerWindow
subset_manager_window = SubsetManagerWindow(
parent=parent or self._parent
)
self._subset_manager_tool = subset_manager_window
return self._subset_manager_tool
def show_subset_manager(self, parent=None):
"""Show tool display/remove existing created instances."""
with qt_app_context():
subset_manager_tool = self.get_subset_manager_tool(parent)
subset_manager_tool.show()
# Pull window to the front.
subset_manager_tool.raise_()
subset_manager_tool.activateWindow()
def get_scene_inventory_tool(self, parent):
"""Create, cache and return scene inventory tool window."""
if self._scene_inventory_tool is None:
@ -261,35 +216,29 @@ class HostToolsHelper:
if tool_name == "workfiles":
return self.get_workfiles_tool(parent, *args, **kwargs)
elif tool_name == "loader":
if tool_name == "loader":
return self.get_loader_tool(parent, *args, **kwargs)
elif tool_name == "libraryloader":
if tool_name == "libraryloader":
return self.get_library_loader_tool(parent, *args, **kwargs)
elif tool_name == "creator":
return self.get_creator_tool(parent, *args, **kwargs)
elif tool_name == "subsetmanager":
return self.get_subset_manager_tool(parent, *args, **kwargs)
elif tool_name == "sceneinventory":
if tool_name == "sceneinventory":
return self.get_scene_inventory_tool(parent, *args, **kwargs)
elif tool_name == "publish":
self.log.info("Can't return publish tool window.")
# "new" publisher
elif tool_name == "publisher":
if tool_name == "publisher":
return self.get_publisher_tool(parent, *args, **kwargs)
elif tool_name == "experimental_tools":
if tool_name == "experimental_tools":
return self.get_experimental_tools_dialog(parent, *args, **kwargs)
else:
self.log.warning(
"Can't show unknown tool name: \"{}\"".format(tool_name)
)
if tool_name == "publish":
self.log.info("Can't return publish tool window.")
return None
self.log.warning(
"Can't show unknown tool name: \"{}\"".format(tool_name)
)
return None
def show_tool_by_name(self, tool_name, parent=None, *args, **kwargs):
"""Show tool by it's name.
@ -305,12 +254,6 @@ class HostToolsHelper:
elif tool_name == "libraryloader":
self.show_library_loader(parent, *args, **kwargs)
elif tool_name == "creator":
self.show_creator(parent, *args, **kwargs)
elif tool_name == "subsetmanager":
self.show_subset_manager(parent, *args, **kwargs)
elif tool_name == "sceneinventory":
self.show_scene_inventory(parent, *args, **kwargs)
@ -379,14 +322,6 @@ def show_library_loader(parent=None):
_SingletonPoint.show_tool_by_name("libraryloader", parent)
def show_creator(parent=None):
_SingletonPoint.show_tool_by_name("creator", parent)
def show_subset_manager(parent=None):
_SingletonPoint.show_tool_by_name("subsetmanager", parent)
def show_scene_inventory(parent=None):
_SingletonPoint.show_tool_by_name("sceneinventory", parent)

View file

@ -14,7 +14,6 @@ from ayon_core.lib import (
Logger,
)
from ayon_core.host import (
HostBase,
IWorkfileHost,
WorkfileInfo,
PublishedWorkfileInfo,
@ -49,19 +48,15 @@ if typing.TYPE_CHECKING:
_NOT_SET = object()
class HostType(HostBase, IWorkfileHost):
pass
class WorkfilesModel:
"""Workfiles model."""
def __init__(
self,
host: HostType,
host: IWorkfileHost,
controller: AbstractWorkfilesBackend
):
self._host: HostType = host
self._host: IWorkfileHost = host
self._controller: AbstractWorkfilesBackend = controller
self._log = Logger.get_logger("WorkfilesModel")