Merge branch 'develop' into enhancement/houdini_load_filepath_fix

This commit is contained in:
Kayla Man 2024-04-02 21:48:28 +08:00 committed by GitHub
commit a673e62e83
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
115 changed files with 3505 additions and 2631 deletions

View file

@ -16,6 +16,7 @@ import six
import appdirs
import ayon_api
from ayon_core import AYON_CORE_ROOT
from ayon_core.lib import Logger, is_dev_mode_enabled
from ayon_core.settings import get_studio_settings
@ -335,14 +336,70 @@ def _load_ayon_addons(openpype_modules, modules_key, log):
return addons_to_skip_in_core
def _load_ayon_core_addons_dir(
ignore_addon_names, openpype_modules, modules_key, log
):
addons_dir = os.path.join(AYON_CORE_ROOT, "addons")
if not os.path.exists(addons_dir):
return
imported_modules = []
# Make sure that addons which already have client code are not loaded
# from core again, with older code
filtered_paths = []
for name in os.listdir(addons_dir):
if name in ignore_addon_names:
continue
path = os.path.join(addons_dir, name)
if os.path.isdir(path):
filtered_paths.append(path)
for path in filtered_paths:
while path in sys.path:
sys.path.remove(path)
sys.path.insert(0, path)
for name in os.listdir(path):
fullpath = os.path.join(path, name)
if os.path.isfile(fullpath):
basename, ext = os.path.splitext(name)
if ext != ".py":
continue
else:
basename = name
try:
module = __import__(basename, fromlist=("",))
for attr_name in dir(module):
attr = getattr(module, attr_name)
if (
inspect.isclass(attr)
and issubclass(attr, AYONAddon)
):
new_import_str = "{}.{}".format(modules_key, basename)
sys.modules[new_import_str] = module
setattr(openpype_modules, basename, module)
imported_modules.append(module)
break
except Exception:
log.error(
"Failed to import addon '{}'.".format(fullpath),
exc_info=True
)
return imported_modules
def _load_addons_in_core(
ignore_addon_names, openpype_modules, modules_key, log
):
_load_ayon_core_addons_dir(
ignore_addon_names, openpype_modules, modules_key, log
)
# Add current directory at first place
# - has small differences in import logic
current_dir = os.path.abspath(os.path.dirname(__file__))
hosts_dir = os.path.join(os.path.dirname(current_dir), "hosts")
modules_dir = os.path.join(os.path.dirname(current_dir), "modules")
hosts_dir = os.path.join(AYON_CORE_ROOT, "hosts")
modules_dir = os.path.join(AYON_CORE_ROOT, "modules")
ignored_host_names = set(IGNORED_HOSTS_IN_AYON)
ignored_module_dir_filenames = (

View file

@ -0,0 +1,58 @@
from .constants import (
APPLICATIONS_ADDON_ROOT,
DEFAULT_ENV_SUBGROUP,
PLATFORM_NAMES,
)
from .exceptions import (
ApplicationNotFound,
ApplicationExecutableNotFound,
ApplicationLaunchFailed,
MissingRequiredKey,
)
from .defs import (
LaunchTypes,
ApplicationExecutable,
UndefinedApplicationExecutable,
ApplicationGroup,
Application,
EnvironmentToolGroup,
EnvironmentTool,
)
from .hooks import (
LaunchHook,
PreLaunchHook,
PostLaunchHook,
)
from .manager import (
ApplicationManager,
ApplicationLaunchContext,
)
from .addon import ApplicationsAddon
__all__ = (
"DEFAULT_ENV_SUBGROUP",
"PLATFORM_NAMES",
"ApplicationNotFound",
"ApplicationExecutableNotFound",
"ApplicationLaunchFailed",
"MissingRequiredKey",
"LaunchTypes",
"ApplicationExecutable",
"UndefinedApplicationExecutable",
"ApplicationGroup",
"Application",
"EnvironmentToolGroup",
"EnvironmentTool",
"LaunchHook",
"PreLaunchHook",
"PostLaunchHook",
"ApplicationManager",
"ApplicationLaunchContext",
"ApplicationsAddon",
)

View file

@ -0,0 +1,169 @@
import os
import json
from ayon_core.addon import AYONAddon, IPluginPaths, click_wrap
from .constants import APPLICATIONS_ADDON_ROOT
from .defs import LaunchTypes
from .manager import ApplicationManager
class ApplicationsAddon(AYONAddon, IPluginPaths):
name = "applications"
def get_app_environments_for_context(
self,
project_name,
folder_path,
task_name,
full_app_name,
env_group=None,
launch_type=None,
env=None,
):
"""Calculate environment variables for launch context.
Args:
project_name (str): Project name.
folder_path (str): Folder path.
task_name (str): Task name.
full_app_name (str): Full application name.
env_group (Optional[str]): Environment group.
launch_type (Optional[str]): Launch type.
env (Optional[dict[str, str]]): Environment variables to update.
Returns:
dict[str, str]: Environment variables for context.
"""
from ayon_applications.utils import get_app_environments_for_context
if not full_app_name:
return {}
return get_app_environments_for_context(
project_name,
folder_path,
task_name,
full_app_name,
env_group=env_group,
launch_type=launch_type,
env=env,
addons_manager=self.manager
)
def get_farm_publish_environment_variables(
self,
project_name,
folder_path,
task_name,
full_app_name=None,
env_group=None,
):
"""Calculate environment variables for farm publish.
Args:
project_name (str): Project name.
folder_path (str): Folder path.
task_name (str): Task name.
env_group (Optional[str]): Environment group.
full_app_name (Optional[str]): Full application name. Value from
environment variable 'AYON_APP_NAME' is used if 'None' is
passed.
Returns:
dict[str, str]: Environment variables for farm publish.
"""
if full_app_name is None:
full_app_name = os.getenv("AYON_APP_NAME")
return self.get_app_environments_for_context(
project_name,
folder_path,
task_name,
full_app_name,
env_group=env_group,
launch_type=LaunchTypes.farm_publish
)
def get_applications_manager(self, settings=None):
"""Get applications manager.
Args:
settings (Optional[dict]): Studio/project settings.
Returns:
ApplicationManager: Applications manager.
"""
return ApplicationManager(settings)
def get_plugin_paths(self):
return {
"publish": [
os.path.join(APPLICATIONS_ADDON_ROOT, "plugins", "publish")
]
}
# --- CLI ---
def cli(self, addon_click_group):
main_group = click_wrap.group(
self._cli_main, name=self.name, help="Applications addon"
)
(
main_group.command(
self._cli_extract_environments,
name="extractenvironments",
help=(
"Extract environment variables for context into json file"
)
)
.argument("output_json_path")
.option("--project", help="Project name", default=None)
.option("--folder", help="Folder path", default=None)
.option("--task", help="Task name", default=None)
.option("--app", help="Application name", default=None)
.option(
"--envgroup",
help="Environment group (e.g. \"farm\")",
default=None
)
)
# Convert main command to click object and add it to parent group
addon_click_group.add_command(
main_group.to_click_obj()
)
def _cli_main(self):
pass
def _cli_extract_environments(
self, output_json_path, project, folder, task, app, envgroup
):
"""Produces json file with environment based on project and app.
Called by farm integration to propagate environment into farm jobs.
Args:
output_json_path (str): Output json file path.
project (str): Project name.
folder (str): Folder path.
task (str): Task name.
app (str): Full application name e.g. 'maya/2024'.
envgroup (str): Environment group.
"""
if all((project, folder, task, app)):
env = self.get_farm_publish_environment_variables(
project, folder, task, app, env_group=envgroup,
)
else:
env = os.environ.copy()
output_dir = os.path.dirname(output_json_path)
if not os.path.exists(output_dir):
os.makedirs(output_dir)
with open(output_json_path, "w") as file_stream:
json.dump(env, file_stream, indent=4)

View file

@ -0,0 +1,6 @@
import os
APPLICATIONS_ADDON_ROOT = os.path.dirname(os.path.abspath(__file__))
PLATFORM_NAMES = {"windows", "linux", "darwin"}
DEFAULT_ENV_SUBGROUP = "standard"

View file

@ -0,0 +1,404 @@
import os
import platform
import json
import copy
from ayon_core.lib import find_executable
class LaunchTypes:
"""Launch types are filters for pre/post-launch hooks.
Please use these variables in case they'll change values.
"""
# Local launch - application is launched on local machine
local = "local"
# Farm render job - application is on farm
farm_render = "farm-render"
# Farm publish job - integration post-render job
farm_publish = "farm-publish"
# Remote launch - application is launched on remote machine from which
# can be started publishing
remote = "remote"
# Automated launch - application is launched with automated publishing
automated = "automated"
class ApplicationExecutable:
"""Representation of executable loaded from settings."""
def __init__(self, executable):
# Try to format executable with environments
try:
executable = executable.format(**os.environ)
except Exception:
pass
# On MacOS check if exists path to executable when ends with `.app`
# - it is common that path will lead to "/Applications/Blender" but
# real path is "/Applications/Blender.app"
if platform.system().lower() == "darwin":
executable = self.macos_executable_prep(executable)
self.executable_path = executable
def __str__(self):
return self.executable_path
def __repr__(self):
return "<{}> {}".format(self.__class__.__name__, self.executable_path)
@staticmethod
def macos_executable_prep(executable):
"""Try to find full path to executable file.
Real executable is stored in '*.app/Contents/MacOS/<executable>'.
Having path to '*.app' gives ability to read it's plist info and
use "CFBundleExecutable" key from plist to know what is "executable."
Plist is stored in '*.app/Contents/Info.plist'.
This is because some '*.app' directories don't have same permissions
as real executable.
"""
# Try to find if there is `.app` file
if not os.path.exists(executable):
_executable = executable + ".app"
if os.path.exists(_executable):
executable = _executable
# Try to find real executable if executable has `Contents` subfolder
contents_dir = os.path.join(executable, "Contents")
if os.path.exists(contents_dir):
executable_filename = None
# Load plist file and check for bundle executable
plist_filepath = os.path.join(contents_dir, "Info.plist")
if os.path.exists(plist_filepath):
import plistlib
if hasattr(plistlib, "load"):
with open(plist_filepath, "rb") as stream:
parsed_plist = plistlib.load(stream)
else:
parsed_plist = plistlib.readPlist(plist_filepath)
executable_filename = parsed_plist.get("CFBundleExecutable")
if executable_filename:
executable = os.path.join(
contents_dir, "MacOS", executable_filename
)
return executable
def as_args(self):
return [self.executable_path]
def _realpath(self):
"""Check if path is valid executable path."""
# Check for executable in PATH
result = find_executable(self.executable_path)
if result is not None:
return result
# This is not 100% validation but it is better than remove ability to
# launch .bat, .sh or extentionless files
if os.path.exists(self.executable_path):
return self.executable_path
return None
def exists(self):
if not self.executable_path:
return False
return bool(self._realpath())
class UndefinedApplicationExecutable(ApplicationExecutable):
"""Some applications do not require executable path from settings.
In that case this class is used to "fake" existing executable.
"""
def __init__(self):
pass
def __str__(self):
return self.__class__.__name__
def __repr__(self):
return "<{}>".format(self.__class__.__name__)
def as_args(self):
return []
def exists(self):
return True
class ApplicationGroup:
"""Hold information about application group.
Application group wraps different versions(variants) of application.
e.g. "maya" is group and "maya_2020" is variant.
Group hold `host_name` which is implementation name used in AYON. Also
holds `enabled` if whole app group is enabled or `icon` for application
icon path in resources.
Group has also `environment` which hold same environments for all variants.
Args:
name (str): Groups' name.
data (dict): Group defying data loaded from settings.
manager (ApplicationManager): Manager that created the group.
"""
def __init__(self, name, data, manager):
self.name = name
self.manager = manager
self._data = data
self.enabled = data["enabled"]
self.label = data["label"] or None
self.icon = data["icon"] or None
env = {}
try:
env = json.loads(data["environment"])
except Exception:
pass
self._environment = env
host_name = data["host_name"] or None
self.is_host = host_name is not None
self.host_name = host_name
settings_variants = data["variants"]
variants = {}
for variant_data in settings_variants:
app_variant = Application(variant_data, self)
variants[app_variant.name] = app_variant
self.variants = variants
def __repr__(self):
return "<{}> - {}".format(self.__class__.__name__, self.name)
def __iter__(self):
for variant in self.variants.values():
yield variant
@property
def environment(self):
return copy.deepcopy(self._environment)
class Application:
"""Hold information about application.
Object by itself does nothing special.
Args:
data (dict): Data for the version containing information about
executables, variant label or if is enabled.
Only required key is `executables`.
group (ApplicationGroup): App group object that created the application
and under which application belongs.
"""
def __init__(self, data, group):
self._data = data
name = data["name"]
label = data["label"] or name
enabled = False
if group.enabled:
enabled = data.get("enabled", True)
if group.label:
full_label = " ".join((group.label, label))
else:
full_label = label
env = {}
try:
env = json.loads(data["environment"])
except Exception:
pass
arguments = data["arguments"]
if isinstance(arguments, dict):
arguments = arguments.get(platform.system().lower())
if not arguments:
arguments = []
_executables = data["executables"].get(platform.system().lower(), [])
executables = [
ApplicationExecutable(executable)
for executable in _executables
]
self.group = group
self.name = name
self.label = label
self.enabled = enabled
self.use_python_2 = data.get("use_python_2", False)
self.full_name = "/".join((group.name, name))
self.full_label = full_label
self.arguments = arguments
self.executables = executables
self._environment = env
def __repr__(self):
return "<{}> - {}".format(self.__class__.__name__, self.full_name)
@property
def environment(self):
return copy.deepcopy(self._environment)
@property
def manager(self):
return self.group.manager
@property
def host_name(self):
return self.group.host_name
@property
def icon(self):
return self.group.icon
@property
def is_host(self):
return self.group.is_host
def find_executable(self):
"""Try to find existing executable for application.
Returns (str): Path to executable from `executables` or None if any
exists.
"""
for executable in self.executables:
if executable.exists():
return executable
return None
def launch(self, *args, **kwargs):
"""Launch the application.
For this purpose is used manager's launch method to keep logic at one
place.
Arguments must match with manager's launch method. That's why *args
**kwargs are used.
Returns:
subprocess.Popen: Return executed process as Popen object.
"""
return self.manager.launch(self.full_name, *args, **kwargs)
class EnvironmentToolGroup:
"""Hold information about environment tool group.
Environment tool group may hold different variants of same tool and set
environments that are same for all of them.
e.g. "mtoa" may have different versions but all environments except one
are same.
Args:
data (dict): Group information with variants.
manager (ApplicationManager): Manager that creates the group.
"""
def __init__(self, data, manager):
name = data["name"]
label = data["label"]
self.name = name
self.label = label
self._data = data
self.manager = manager
environment = {}
try:
environment = json.loads(data["environment"])
except Exception:
pass
self._environment = environment
variants = data.get("variants") or []
variants_by_name = {}
for variant_data in variants:
tool = EnvironmentTool(variant_data, self)
variants_by_name[tool.name] = tool
self.variants = variants_by_name
def __repr__(self):
return "<{}> - {}".format(self.__class__.__name__, self.name)
def __iter__(self):
for variant in self.variants.values():
yield variant
@property
def environment(self):
return copy.deepcopy(self._environment)
class EnvironmentTool:
"""Hold information about application tool.
Structure of tool information.
Args:
variant_data (dict): Variant data with environments and
host and app variant filters.
group (EnvironmentToolGroup): Name of group which wraps tool.
"""
def __init__(self, variant_data, group):
# Backwards compatibility 3.9.1 - 3.9.2
# - 'variant_data' contained only environments but contain also host
# and application variant filters
name = variant_data["name"]
label = variant_data["label"]
host_names = variant_data["host_names"]
app_variants = variant_data["app_variants"]
environment = {}
try:
environment = json.loads(variant_data["environment"])
except Exception:
pass
self.host_names = host_names
self.app_variants = app_variants
self.name = name
self.variant_label = label
self.label = " ".join((group.label, label))
self.group = group
self._environment = environment
self.full_name = "/".join((group.name, name))
def __repr__(self):
return "<{}> - {}".format(self.__class__.__name__, self.full_name)
@property
def environment(self):
return copy.deepcopy(self._environment)
def is_valid_for_app(self, app):
"""Is tool valid for application.
Args:
app (Application): Application for which are prepared environments.
"""
if self.app_variants and app.full_name not in self.app_variants:
return False
if self.host_names and app.host_name not in self.host_names:
return False
return True

View file

@ -0,0 +1,50 @@
class ApplicationNotFound(Exception):
"""Application was not found in ApplicationManager by name."""
def __init__(self, app_name):
self.app_name = app_name
super(ApplicationNotFound, self).__init__(
"Application \"{}\" was not found.".format(app_name)
)
class ApplicationExecutableNotFound(Exception):
"""Defined executable paths are not available on the machine."""
def __init__(self, application):
self.application = application
details = None
if not application.executables:
msg = (
"Executable paths for application \"{}\"({}) are not set."
)
else:
msg = (
"Defined executable paths for application \"{}\"({})"
" are not available on this machine."
)
details = "Defined paths:"
for executable in application.executables:
details += "\n- " + executable.executable_path
self.msg = msg.format(application.full_label, application.full_name)
self.details = details
exc_mgs = str(self.msg)
if details:
# Is good idea to pass new line symbol to exception message?
exc_mgs += "\n" + details
self.exc_msg = exc_mgs
super(ApplicationExecutableNotFound, self).__init__(exc_mgs)
class ApplicationLaunchFailed(Exception):
"""Application launch failed due to known reason.
Message should be self explanatory as traceback won't be shown.
"""
pass
class MissingRequiredKey(KeyError):
pass

View file

@ -0,0 +1,150 @@
import platform
from abc import ABCMeta, abstractmethod
import six
from ayon_core.lib import Logger
from .defs import LaunchTypes
@six.add_metaclass(ABCMeta)
class LaunchHook:
"""Abstract base class of launch hook."""
# Order of prelaunch hook, will be executed as last if set to None.
order = None
# List of host implementations, skipped if empty.
hosts = set()
# Set of application groups
app_groups = set()
# Set of specific application names
app_names = set()
# Set of platform availability
platforms = set()
# Set of launch types for which is available
# - if empty then is available for all launch types
# - by default has 'local' which is most common reason for launc hooks
launch_types = {LaunchTypes.local}
def __init__(self, launch_context):
"""Constructor of launch hook.
Always should be called
"""
self.log = Logger.get_logger(self.__class__.__name__)
self.launch_context = launch_context
is_valid = self.class_validation(launch_context)
if is_valid:
is_valid = self.validate()
self.is_valid = is_valid
@classmethod
def class_validation(cls, launch_context):
"""Validation of class attributes by launch context.
Args:
launch_context (ApplicationLaunchContext): Context of launching
application.
Returns:
bool: Is launch hook valid for the context by class attributes.
"""
if cls.platforms:
low_platforms = tuple(
_platform.lower()
for _platform in cls.platforms
)
if platform.system().lower() not in low_platforms:
return False
if cls.hosts:
if launch_context.host_name not in cls.hosts:
return False
if cls.app_groups:
if launch_context.app_group.name not in cls.app_groups:
return False
if cls.app_names:
if launch_context.app_name not in cls.app_names:
return False
if cls.launch_types:
if launch_context.launch_type not in cls.launch_types:
return False
return True
@property
def data(self):
return self.launch_context.data
@property
def application(self):
return getattr(self.launch_context, "application", None)
@property
def manager(self):
return getattr(self.application, "manager", None)
@property
def host_name(self):
return getattr(self.application, "host_name", None)
@property
def app_group(self):
return getattr(self.application, "group", None)
@property
def app_name(self):
return getattr(self.application, "full_name", None)
@property
def addons_manager(self):
return getattr(self.launch_context, "addons_manager", None)
@property
def modules_manager(self):
"""
Deprecated:
Use 'addons_wrapper' instead.
"""
return self.addons_manager
def validate(self):
"""Optional validation of launch hook on initialization.
Returns:
bool: Hook is valid (True) or invalid (False).
"""
# QUESTION Not sure if this method has any usable potential.
# - maybe result can be based on settings
return True
@abstractmethod
def execute(self, *args, **kwargs):
"""Abstract execute method where logic of hook is."""
pass
class PreLaunchHook(LaunchHook):
"""Abstract class of prelaunch hook.
This launch hook will be processed before application is launched.
If any exception will happen during processing the application won't be
launched.
"""
class PostLaunchHook(LaunchHook):
"""Abstract class of postlaunch hook.
This launch hook will be processed after application is launched.
Nothing will happen if any exception will happen during processing. And
processing of other postlaunch hooks won't stop either.
"""

View file

@ -0,0 +1,676 @@
import os
import sys
import copy
import json
import tempfile
import platform
import inspect
import subprocess
import six
from ayon_core import AYON_CORE_ROOT
from ayon_core.settings import get_studio_settings
from ayon_core.lib import (
Logger,
modules_from_path,
classes_from_module,
get_linux_launcher_args,
)
from ayon_core.addon import AddonsManager
from .constants import DEFAULT_ENV_SUBGROUP
from .exceptions import (
ApplicationNotFound,
ApplicationExecutableNotFound,
)
from .hooks import PostLaunchHook, PreLaunchHook
from .defs import EnvironmentToolGroup, ApplicationGroup, LaunchTypes
class ApplicationManager:
"""Load applications and tools and store them by their full name.
Args:
studio_settings (dict): Preloaded studio settings. When passed manager
will always use these values. Gives ability to create manager
using different settings.
"""
def __init__(self, studio_settings=None):
self.log = Logger.get_logger(self.__class__.__name__)
self.app_groups = {}
self.applications = {}
self.tool_groups = {}
self.tools = {}
self._studio_settings = studio_settings
self.refresh()
def set_studio_settings(self, studio_settings):
"""Ability to change init system settings.
This will trigger refresh of manager.
"""
self._studio_settings = studio_settings
self.refresh()
def refresh(self):
"""Refresh applications from settings."""
self.app_groups.clear()
self.applications.clear()
self.tool_groups.clear()
self.tools.clear()
if self._studio_settings is not None:
settings = copy.deepcopy(self._studio_settings)
else:
settings = get_studio_settings(
clear_metadata=False, exclude_locals=False
)
applications_addon_settings = settings["applications"]
# Prepare known applications
app_defs = applications_addon_settings["applications"]
additional_apps = app_defs.pop("additional_apps")
for additional_app in additional_apps:
app_name = additional_app.pop("name")
if app_name in app_defs:
self.log.warning((
"Additional application '{}' is already"
" in built-in applications."
).format(app_name))
app_defs[app_name] = additional_app
for group_name, variant_defs in app_defs.items():
group = ApplicationGroup(group_name, variant_defs, self)
self.app_groups[group_name] = group
for app in group:
self.applications[app.full_name] = app
tools_definitions = applications_addon_settings["tool_groups"]
for tool_group_data in tools_definitions:
group = EnvironmentToolGroup(tool_group_data, self)
self.tool_groups[group.name] = group
for tool in group:
self.tools[tool.full_name] = tool
def find_latest_available_variant_for_group(self, group_name):
group = self.app_groups.get(group_name)
if group is None or not group.enabled:
return None
output = None
for _, variant in reversed(sorted(group.variants.items())):
executable = variant.find_executable()
if executable:
output = variant
break
return output
def create_launch_context(self, app_name, **data):
"""Prepare launch context for application.
Args:
app_name (str): Name of application that should be launched.
**data (Any): Any additional data. Data may be used during
Returns:
ApplicationLaunchContext: Launch context for application.
Raises:
ApplicationNotFound: Application was not found by entered name.
"""
app = self.applications.get(app_name)
if not app:
raise ApplicationNotFound(app_name)
executable = app.find_executable()
return ApplicationLaunchContext(
app, executable, **data
)
def launch_with_context(self, launch_context):
"""Launch application using existing launch context.
Args:
launch_context (ApplicationLaunchContext): Prepared launch
context.
"""
if not launch_context.executable:
raise ApplicationExecutableNotFound(launch_context.application)
return launch_context.launch()
def launch(self, app_name, **data):
"""Launch procedure.
For host application it's expected to contain "project_name",
"folder_path" and "task_name".
Args:
app_name (str): Name of application that should be launched.
**data (dict): Any additional data. Data may be used during
preparation to store objects usable in multiple places.
Raises:
ApplicationNotFound: Application was not found by entered
argument `app_name`.
ApplicationExecutableNotFound: Executables in application definition
were not found on this machine.
ApplicationLaunchFailed: Something important for application launch
failed. Exception should contain explanation message,
traceback should not be needed.
"""
context = self.create_launch_context(app_name, **data)
return self.launch_with_context(context)
class ApplicationLaunchContext:
"""Context of launching application.
Main purpose of context is to prepare launch arguments and keyword
arguments for new process. Most important part of keyword arguments
preparations are environment variables.
During the whole process is possible to use `data` attribute to store
object usable in multiple places.
Launch arguments are strings in list. It is possible to "chain" argument
when order of them matters. That is possible to do with adding list where
order is right and should not change.
NOTE: This is recommendation, not requirement.
e.g.: `["nuke.exe", "--NukeX"]` -> In this case any part of process may
insert argument between `nuke.exe` and `--NukeX`. To keep them together
it is better to wrap them in another list: `[["nuke.exe", "--NukeX"]]`.
Notes:
It is possible to use launch context only to prepare environment
variables. In that case `executable` may be None and can be used
'run_prelaunch_hooks' method to run prelaunch hooks which prepare
them.
Args:
application (Application): Application definition.
executable (ApplicationExecutable): Object with path to executable.
env_group (Optional[str]): Environment variable group. If not set
'DEFAULT_ENV_SUBGROUP' is used.
launch_type (Optional[str]): Launch type. If not set 'local' is used.
**data (dict): Any additional data. Data may be used during
preparation to store objects usable in multiple places.
"""
def __init__(
self,
application,
executable,
env_group=None,
launch_type=None,
**data
):
# Application object
self.application = application
self.addons_manager = AddonsManager()
# Logger
logger_name = "{}-{}".format(self.__class__.__name__,
self.application.full_name)
self.log = Logger.get_logger(logger_name)
self.executable = executable
if launch_type is None:
launch_type = LaunchTypes.local
self.launch_type = launch_type
if env_group is None:
env_group = DEFAULT_ENV_SUBGROUP
self.env_group = env_group
self.data = dict(data)
launch_args = []
if executable is not None:
launch_args = executable.as_args()
# subprocess.Popen launch arguments (first argument in constructor)
self.launch_args = launch_args
self.launch_args.extend(application.arguments)
if self.data.get("app_args"):
self.launch_args.extend(self.data.pop("app_args"))
# Handle launch environemtns
src_env = self.data.pop("env", None)
if src_env is not None and not isinstance(src_env, dict):
self.log.warning((
"Passed `env` kwarg has invalid type: {}. Expected: `dict`."
" Using `os.environ` instead."
).format(str(type(src_env))))
src_env = None
if src_env is None:
src_env = os.environ
ignored_env = {"QT_API", }
env = {
key: str(value)
for key, value in src_env.items()
if key not in ignored_env
}
# subprocess.Popen keyword arguments
self.kwargs = {"env": env}
if platform.system().lower() == "windows":
# Detach new process from currently running process on Windows
flags = (
subprocess.CREATE_NEW_PROCESS_GROUP
| subprocess.DETACHED_PROCESS
)
self.kwargs["creationflags"] = flags
if not sys.stdout:
self.kwargs["stdout"] = subprocess.DEVNULL
self.kwargs["stderr"] = subprocess.DEVNULL
self.prelaunch_hooks = None
self.postlaunch_hooks = None
self.process = None
self._prelaunch_hooks_executed = False
@property
def env(self):
if (
"env" not in self.kwargs
or self.kwargs["env"] is None
):
self.kwargs["env"] = {}
return self.kwargs["env"]
@env.setter
def env(self, value):
if not isinstance(value, dict):
raise ValueError(
"'env' attribute expect 'dict' object. Got: {}".format(
str(type(value))
)
)
self.kwargs["env"] = value
@property
def modules_manager(self):
"""
Deprecated:
Use 'addons_manager' instead.
"""
return self.addons_manager
def _collect_addons_launch_hook_paths(self):
"""Helper to collect application launch hooks from addons.
Module have to have implemented 'get_launch_hook_paths' method which
can expect application as argument or nothing.
Returns:
List[str]: Paths to launch hook directories.
"""
expected_types = (list, tuple, set)
output = []
for module in self.addons_manager.get_enabled_addons():
# Skip module if does not have implemented 'get_launch_hook_paths'
func = getattr(module, "get_launch_hook_paths", None)
if func is None:
continue
func = module.get_launch_hook_paths
if hasattr(inspect, "signature"):
sig = inspect.signature(func)
expect_args = len(sig.parameters) > 0
else:
expect_args = len(inspect.getargspec(func)[0]) > 0
# Pass application argument if method expect it.
try:
if expect_args:
hook_paths = func(self.application)
else:
hook_paths = func()
except Exception:
self.log.warning(
"Failed to call 'get_launch_hook_paths'",
exc_info=True
)
continue
if not hook_paths:
continue
# Convert string to list
if isinstance(hook_paths, six.string_types):
hook_paths = [hook_paths]
# Skip invalid types
if not isinstance(hook_paths, expected_types):
self.log.warning((
"Result of `get_launch_hook_paths`"
" has invalid type {}. Expected {}"
).format(type(hook_paths), expected_types))
continue
output.extend(hook_paths)
return output
def paths_to_launch_hooks(self):
"""Directory paths where to look for launch hooks."""
# This method has potential to be part of application manager (maybe).
paths = []
# TODO load additional studio paths from settings
global_hooks_dir = os.path.join(AYON_CORE_ROOT, "hooks")
hooks_dirs = [
global_hooks_dir
]
if self.host_name:
# If host requires launch hooks and is module then launch hooks
# should be collected using 'collect_launch_hook_paths'
# - module have to implement 'get_launch_hook_paths'
host_module = self.addons_manager.get_host_addon(self.host_name)
if not host_module:
hooks_dirs.append(os.path.join(
AYON_CORE_ROOT, "hosts", self.host_name, "hooks"
))
for path in hooks_dirs:
if (
os.path.exists(path)
and os.path.isdir(path)
and path not in paths
):
paths.append(path)
# Load modules paths
paths.extend(self._collect_addons_launch_hook_paths())
return paths
def discover_launch_hooks(self, force=False):
"""Load and prepare launch hooks."""
if (
self.prelaunch_hooks is not None
or self.postlaunch_hooks is not None
):
if not force:
self.log.info("Launch hooks were already discovered.")
return
self.prelaunch_hooks.clear()
self.postlaunch_hooks.clear()
self.log.debug("Discovery of launch hooks started.")
paths = self.paths_to_launch_hooks()
self.log.debug("Paths searched for launch hooks:\n{}".format(
"\n".join("- {}".format(path) for path in paths)
))
all_classes = {
"pre": [],
"post": []
}
for path in paths:
if not os.path.exists(path):
self.log.info(
"Path to launch hooks does not exist: \"{}\"".format(path)
)
continue
modules, _crashed = modules_from_path(path)
for _filepath, module in modules:
all_classes["pre"].extend(
classes_from_module(PreLaunchHook, module)
)
all_classes["post"].extend(
classes_from_module(PostLaunchHook, module)
)
for launch_type, classes in all_classes.items():
hooks_with_order = []
hooks_without_order = []
for klass in classes:
try:
hook = klass(self)
if not hook.is_valid:
self.log.debug(
"Skipped hook invalid for current launch context: "
"{}".format(klass.__name__)
)
continue
if inspect.isabstract(hook):
self.log.debug("Skipped abstract hook: {}".format(
klass.__name__
))
continue
# Separate hooks by pre/post class
if hook.order is None:
hooks_without_order.append(hook)
else:
hooks_with_order.append(hook)
except Exception:
self.log.warning(
"Initialization of hook failed: "
"{}".format(klass.__name__),
exc_info=True
)
# Sort hooks with order by order
ordered_hooks = list(sorted(
hooks_with_order, key=lambda obj: obj.order
))
# Extend ordered hooks with hooks without defined order
ordered_hooks.extend(hooks_without_order)
if launch_type == "pre":
self.prelaunch_hooks = ordered_hooks
else:
self.postlaunch_hooks = ordered_hooks
self.log.debug("Found {} prelaunch and {} postlaunch hooks.".format(
len(self.prelaunch_hooks), len(self.postlaunch_hooks)
))
@property
def app_name(self):
return self.application.name
@property
def host_name(self):
return self.application.host_name
@property
def app_group(self):
return self.application.group
@property
def manager(self):
return self.application.manager
def _run_process(self):
# Windows and MacOS have easier process start
low_platform = platform.system().lower()
if low_platform in ("windows", "darwin"):
return subprocess.Popen(self.launch_args, **self.kwargs)
# Linux uses mid process
# - it is possible that the mid process executable is not
# available for this version of AYON in that case use standard
# launch
launch_args = get_linux_launcher_args()
if launch_args is None:
return subprocess.Popen(self.launch_args, **self.kwargs)
# Prepare data that will be passed to midprocess
# - store arguments to a json and pass path to json as last argument
# - pass environments to set
app_env = self.kwargs.pop("env", {})
json_data = {
"args": self.launch_args,
"env": app_env
}
if app_env:
# Filter environments of subprocess
self.kwargs["env"] = {
key: value
for key, value in os.environ.items()
if key in app_env
}
# Create temp file
json_temp = tempfile.NamedTemporaryFile(
mode="w", prefix="op_app_args", suffix=".json", delete=False
)
json_temp.close()
json_temp_filpath = json_temp.name
with open(json_temp_filpath, "w") as stream:
json.dump(json_data, stream)
launch_args.append(json_temp_filpath)
# Create mid-process which will launch application
process = subprocess.Popen(launch_args, **self.kwargs)
# Wait until the process finishes
# - This is important! The process would stay in "open" state.
process.wait()
# Remove the temp file
os.remove(json_temp_filpath)
# Return process which is already terminated
return process
def run_prelaunch_hooks(self):
"""Run prelaunch hooks.
This method will be executed only once, any future calls will skip
the processing.
"""
if self._prelaunch_hooks_executed:
self.log.warning("Prelaunch hooks were already executed.")
return
# Discover launch hooks
self.discover_launch_hooks()
# Execute prelaunch hooks
for prelaunch_hook in self.prelaunch_hooks:
self.log.debug("Executing prelaunch hook: {}".format(
str(prelaunch_hook.__class__.__name__)
))
prelaunch_hook.execute()
self._prelaunch_hooks_executed = True
def launch(self):
"""Collect data for new process and then create it.
This method must not be executed more than once.
Returns:
subprocess.Popen: Created process as Popen object.
"""
if self.process is not None:
self.log.warning("Application was already launched.")
return
if not self._prelaunch_hooks_executed:
self.run_prelaunch_hooks()
self.log.debug("All prelaunch hook executed. Starting new process.")
# Prepare subprocess args
args_len_str = ""
if isinstance(self.launch_args, str):
args = self.launch_args
else:
args = self.clear_launch_args(self.launch_args)
args_len_str = " ({})".format(len(args))
self.log.info(
"Launching \"{}\" with args{}: {}".format(
self.application.full_name, args_len_str, args
)
)
self.launch_args = args
# Run process
self.process = self._run_process()
# Process post launch hooks
for postlaunch_hook in self.postlaunch_hooks:
self.log.debug("Executing postlaunch hook: {}".format(
str(postlaunch_hook.__class__.__name__)
))
# TODO how to handle errors?
# - store to variable to let them accessible?
try:
postlaunch_hook.execute()
except Exception:
self.log.warning(
"After launch procedures were not successful.",
exc_info=True
)
self.log.debug("Launch of {} finished.".format(
self.application.full_name
))
return self.process
@staticmethod
def clear_launch_args(args):
"""Collect launch arguments to final order.
Launch argument should be list that may contain another lists this
function will upack inner lists and keep ordering.
```
# source
[ [ arg1, [ arg2, arg3 ] ], arg4, [arg5, arg6]]
# result
[ arg1, arg2, arg3, arg4, arg5, arg6]
Args:
args (list): Source arguments in list may contain inner lists.
Return:
list: Unpacked arguments.
"""
if isinstance(args, str):
return args
all_cleared = False
while not all_cleared:
all_cleared = True
new_args = []
for arg in args:
if isinstance(arg, (list, tuple, set)):
all_cleared = False
for _arg in arg:
new_args.append(_arg)
else:
new_args.append(arg)
args = new_args
return args

View file

@ -0,0 +1,48 @@
"""
Run after global plugin 'CollectHostName' in ayon_core.
Requires:
None
Provides:
context -> hostName (str)
context -> appName (str)
context -> appLabel (str)
"""
import os
import pyblish.api
from ayon_applications import ApplicationManager
class CollectAppName(pyblish.api.ContextPlugin):
"""Collect avalon host name to context."""
label = "Collect App Name"
order = pyblish.api.CollectorOrder - 0.499999
def process(self, context):
host_name = context.data.get("hostName")
app_name = context.data.get("appName")
app_label = context.data.get("appLabel")
# Don't override value if is already set
if host_name and app_name and app_label:
return
# Use AYON_APP_NAME to get full app name
if not app_name:
app_name = os.environ.get("AYON_APP_NAME")
# Fill missing values based on app full name
if (not host_name or not app_label) and app_name:
app_manager = ApplicationManager()
app = app_manager.applications.get(app_name)
if app:
if not host_name:
host_name = app.host_name
if not app_label:
app_label = app.full_label
context.data["hostName"] = host_name
context.data["appName"] = app_name
context.data["appLabel"] = app_label

View file

@ -0,0 +1,609 @@
import os
import copy
import json
import platform
import collections
import six
import acre
from ayon_core import AYON_CORE_ROOT
from ayon_core.settings import get_project_settings
from ayon_core.lib import Logger, get_ayon_username
from ayon_core.addon import AddonsManager
from ayon_core.pipeline import HOST_WORKFILE_EXTENSIONS
from ayon_core.pipeline.template_data import get_template_data
from ayon_core.pipeline.workfile import (
get_workfile_template_key,
get_workdir_with_workdir_data,
get_last_workfile,
should_use_last_workfile_on_launch,
should_open_workfiles_tool_on_launch,
)
from .constants import PLATFORM_NAMES, DEFAULT_ENV_SUBGROUP
from .exceptions import MissingRequiredKey, ApplicationLaunchFailed
from .manager import ApplicationManager
def parse_environments(env_data, env_group=None, platform_name=None):
"""Parse environment values from settings byt group and platform.
Data may contain up to 2 hierarchical levels of dictionaries. At the end
of the last level must be string or list. List is joined using platform
specific joiner (';' for windows and ':' for linux and mac).
Hierarchical levels can contain keys for subgroups and platform name.
Platform specific values must be always last level of dictionary. Platform
names are "windows" (MS Windows), "linux" (any linux distribution) and
"darwin" (any MacOS distribution).
Subgroups are helpers added mainly for standard and on farm usage. Farm
may require different environments for e.g. licence related values or
plugins. Default subgroup is "standard".
Examples:
```
{
# Unchanged value
"ENV_KEY1": "value",
# Empty values are kept (unset environment variable)
"ENV_KEY2": "",
# Join list values with ':' or ';'
"ENV_KEY3": ["value1", "value2"],
# Environment groups
"ENV_KEY4": {
"standard": "DEMO_SERVER_URL",
"farm": "LICENCE_SERVER_URL"
},
# Platform specific (and only for windows and mac)
"ENV_KEY5": {
"windows": "windows value",
"darwin": ["value 1", "value 2"]
},
# Environment groups and platform combination
"ENV_KEY6": {
"farm": "FARM_VALUE",
"standard": {
"windows": ["value1", "value2"],
"linux": "value1",
"darwin": ""
}
}
}
```
"""
output = {}
if not env_data:
return output
if not env_group:
env_group = DEFAULT_ENV_SUBGROUP
if not platform_name:
platform_name = platform.system().lower()
for key, value in env_data.items():
if isinstance(value, dict):
# Look if any key is platform key
# - expect that represents environment group if does not contain
# platform keys
if not PLATFORM_NAMES.intersection(set(value.keys())):
# Skip the key if group is not available
if env_group not in value:
continue
value = value[env_group]
# Check again if value is dictionary
# - this time there should be only platform keys
if isinstance(value, dict):
value = value.get(platform_name)
# Check if value is list and join it's values
# QUESTION Should empty values be skipped?
if isinstance(value, (list, tuple)):
value = os.pathsep.join(value)
# Set key to output if value is string
if isinstance(value, six.string_types):
output[key] = value
return output
class EnvironmentPrepData(dict):
"""Helper dictionary for storin temp data during environment prep.
Args:
data (dict): Data must contain required keys.
"""
required_keys = (
"project_entity", "folder_entity", "task_entity", "app", "anatomy"
)
def __init__(self, data):
for key in self.required_keys:
if key not in data:
raise MissingRequiredKey(key)
if not data.get("log"):
data["log"] = Logger.get_logger("EnvironmentPrepData")
if data.get("env") is None:
data["env"] = os.environ.copy()
project_name = data["project_entity"]["name"]
if "project_settings" not in data:
data["project_settings"] = get_project_settings(project_name)
super(EnvironmentPrepData, self).__init__(data)
def get_app_environments_for_context(
project_name,
folder_path,
task_name,
app_name,
env_group=None,
launch_type=None,
env=None,
addons_manager=None
):
"""Prepare environment variables by context.
Args:
project_name (str): Name of project.
folder_path (str): Folder path.
task_name (str): Name of task.
app_name (str): Name of application that is launched and can be found
by ApplicationManager.
env_group (Optional[str]): Name of environment group. If not passed
default group is used.
launch_type (Optional[str]): Type for which prelaunch hooks are
executed.
env (Optional[dict[str, str]]): Initial environment variables.
`os.environ` is used when not passed.
addons_manager (Optional[AddonsManager]): Initialized modules
manager.
Returns:
dict: Environments for passed context and application.
"""
# Prepare app object which can be obtained only from ApplicationManager
app_manager = ApplicationManager()
context = app_manager.create_launch_context(
app_name,
project_name=project_name,
folder_path=folder_path,
task_name=task_name,
env_group=env_group,
launch_type=launch_type,
env=env,
addons_manager=addons_manager,
modules_manager=addons_manager,
)
context.run_prelaunch_hooks()
return context.env
def _merge_env(env, current_env):
"""Modified function(merge) from acre module."""
result = current_env.copy()
for key, value in env.items():
# Keep missing keys by not filling `missing` kwarg
value = acre.lib.partial_format(value, data=current_env)
result[key] = value
return result
def _add_python_version_paths(app, env, logger, addons_manager):
"""Add vendor packages specific for a Python version."""
for addon in addons_manager.get_enabled_addons():
addon.modify_application_launch_arguments(app, env)
# Skip adding if host name is not set
if not app.host_name:
return
# Add Python 2/3 modules
python_vendor_dir = os.path.join(
AYON_CORE_ROOT,
"vendor",
"python"
)
if app.use_python_2:
pythonpath = os.path.join(python_vendor_dir, "python_2")
else:
pythonpath = os.path.join(python_vendor_dir, "python_3")
if not os.path.exists(pythonpath):
return
logger.debug("Adding Python version specific paths to PYTHONPATH")
python_paths = [pythonpath]
# Load PYTHONPATH from current launch context
python_path = env.get("PYTHONPATH")
if python_path:
python_paths.append(python_path)
# Set new PYTHONPATH to launch context environments
env["PYTHONPATH"] = os.pathsep.join(python_paths)
def prepare_app_environments(
data, env_group=None, implementation_envs=True, addons_manager=None
):
"""Modify launch environments based on launched app and context.
Args:
data (EnvironmentPrepData): Dictionary where result and intermediate
result will be stored.
"""
app = data["app"]
log = data["log"]
source_env = data["env"].copy()
if addons_manager is None:
addons_manager = AddonsManager()
_add_python_version_paths(app, source_env, log, addons_manager)
# Use environments from local settings
filtered_local_envs = {}
# NOTE Overrides for environment variables are not implemented in AYON.
# project_settings = data["project_settings"]
# whitelist_envs = project_settings["general"].get("local_env_white_list")
# if whitelist_envs:
# local_settings = get_local_settings()
# local_envs = local_settings.get("environments") or {}
# filtered_local_envs = {
# key: value
# for key, value in local_envs.items()
# if key in whitelist_envs
# }
# Apply local environment variables for already existing values
for key, value in filtered_local_envs.items():
if key in source_env:
source_env[key] = value
# `app_and_tool_labels` has debug purpose
app_and_tool_labels = [app.full_name]
# Environments for application
environments = [
app.group.environment,
app.environment
]
folder_entity = data.get("folder_entity")
# Add tools environments
groups_by_name = {}
tool_by_group_name = collections.defaultdict(dict)
if folder_entity:
# Make sure each tool group can be added only once
for key in folder_entity["attrib"].get("tools") or []:
tool = app.manager.tools.get(key)
if not tool or not tool.is_valid_for_app(app):
continue
groups_by_name[tool.group.name] = tool.group
tool_by_group_name[tool.group.name][tool.name] = tool
for group_name in sorted(groups_by_name.keys()):
group = groups_by_name[group_name]
environments.append(group.environment)
for tool_name in sorted(tool_by_group_name[group_name].keys()):
tool = tool_by_group_name[group_name][tool_name]
environments.append(tool.environment)
app_and_tool_labels.append(tool.full_name)
log.debug(
"Will add environments for apps and tools: {}".format(
", ".join(app_and_tool_labels)
)
)
env_values = {}
for _env_values in environments:
if not _env_values:
continue
# Choose right platform
tool_env = parse_environments(_env_values, env_group)
# Apply local environment variables
# - must happen between all values because they may be used during
# merge
for key, value in filtered_local_envs.items():
if key in tool_env:
tool_env[key] = value
# Merge dictionaries
env_values = _merge_env(tool_env, env_values)
merged_env = _merge_env(env_values, source_env)
loaded_env = acre.compute(merged_env, cleanup=False)
final_env = None
# Add host specific environments
if app.host_name and implementation_envs:
host_addon = addons_manager.get_host_addon(app.host_name)
add_implementation_envs = None
if host_addon:
add_implementation_envs = getattr(
host_addon, "add_implementation_envs", None
)
if add_implementation_envs:
# Function may only modify passed dict without returning value
final_env = add_implementation_envs(loaded_env, app)
if final_env is None:
final_env = loaded_env
keys_to_remove = set(source_env.keys()) - set(final_env.keys())
# Update env
data["env"].update(final_env)
for key in keys_to_remove:
data["env"].pop(key, None)
def apply_project_environments_value(
project_name, env, project_settings=None, env_group=None
):
"""Apply project specific environments on passed environments.
The environments are applied on passed `env` argument value so it is not
required to apply changes back.
Args:
project_name (str): Name of project for which environments should be
received.
env (dict): Environment values on which project specific environments
will be applied.
project_settings (dict): Project settings for passed project name.
Optional if project settings are already prepared.
Returns:
dict: Passed env values with applied project environments.
Raises:
KeyError: If project settings do not contain keys for project specific
environments.
"""
if project_settings is None:
project_settings = get_project_settings(project_name)
env_value = project_settings["core"]["project_environments"]
if env_value:
env_value = json.loads(env_value)
parsed_value = parse_environments(env_value, env_group)
env.update(acre.compute(
_merge_env(parsed_value, env),
cleanup=False
))
return env
def prepare_context_environments(data, env_group=None, addons_manager=None):
"""Modify launch environments with context data for launched host.
Args:
data (EnvironmentPrepData): Dictionary where result and intermediate
result will be stored.
"""
# Context environments
log = data["log"]
project_entity = data["project_entity"]
folder_entity = data["folder_entity"]
task_entity = data["task_entity"]
if not project_entity:
log.info(
"Skipping context environments preparation."
" Launch context does not contain required data."
)
return
# Load project specific environments
project_name = project_entity["name"]
project_settings = get_project_settings(project_name)
data["project_settings"] = project_settings
app = data["app"]
context_env = {
"AYON_PROJECT_NAME": project_entity["name"],
"AYON_APP_NAME": app.full_name
}
if folder_entity:
folder_path = folder_entity["path"]
context_env["AYON_FOLDER_PATH"] = folder_path
if task_entity:
context_env["AYON_TASK_NAME"] = task_entity["name"]
log.debug(
"Context environments set:\n{}".format(
json.dumps(context_env, indent=4)
)
)
data["env"].update(context_env)
# Apply project specific environments on current env value
# - apply them once the context environments are set
apply_project_environments_value(
project_name, data["env"], project_settings, env_group
)
if not app.is_host:
return
data["env"]["AYON_HOST_NAME"] = app.host_name
if not folder_entity or not task_entity:
# QUESTION replace with log.info and skip workfile discovery?
# - technically it should be possible to launch host without context
raise ApplicationLaunchFailed(
"Host launch require folder and task context."
)
workdir_data = get_template_data(
project_entity,
folder_entity,
task_entity,
app.host_name,
project_settings
)
data["workdir_data"] = workdir_data
anatomy = data["anatomy"]
task_type = workdir_data["task"]["type"]
# Temp solution how to pass task type to `_prepare_last_workfile`
data["task_type"] = task_type
try:
workdir = get_workdir_with_workdir_data(
workdir_data,
anatomy.project_name,
anatomy,
project_settings=project_settings
)
except Exception as exc:
raise ApplicationLaunchFailed(
"Error in anatomy.format: {}".format(str(exc))
)
if not os.path.exists(workdir):
log.debug(
"Creating workdir folder: \"{}\"".format(workdir)
)
try:
os.makedirs(workdir)
except Exception as exc:
raise ApplicationLaunchFailed(
"Couldn't create workdir because: {}".format(str(exc))
)
data["env"]["AYON_WORKDIR"] = workdir
_prepare_last_workfile(data, workdir, addons_manager)
def _prepare_last_workfile(data, workdir, addons_manager):
"""last workfile workflow preparation.
Function check if should care about last workfile workflow and tries
to find the last workfile. Both information are stored to `data` and
environments.
Last workfile is filled always (with version 1) even if any workfile
exists yet.
Args:
data (EnvironmentPrepData): Dictionary where result and intermediate
result will be stored.
workdir (str): Path to folder where workfiles should be stored.
"""
if not addons_manager:
addons_manager = AddonsManager()
log = data["log"]
_workdir_data = data.get("workdir_data")
if not _workdir_data:
log.info(
"Skipping last workfile preparation."
" Key `workdir_data` not filled."
)
return
app = data["app"]
workdir_data = copy.deepcopy(_workdir_data)
project_name = data["project_name"]
task_name = data["task_name"]
task_type = data["task_type"]
start_last_workfile = data.get("start_last_workfile")
if start_last_workfile is None:
start_last_workfile = should_use_last_workfile_on_launch(
project_name, app.host_name, task_name, task_type
)
else:
log.info("Opening of last workfile was disabled by user")
data["start_last_workfile"] = start_last_workfile
workfile_startup = should_open_workfiles_tool_on_launch(
project_name, app.host_name, task_name, task_type
)
data["workfile_startup"] = workfile_startup
# Store boolean as "0"(False) or "1"(True)
data["env"]["AVALON_OPEN_LAST_WORKFILE"] = (
str(int(bool(start_last_workfile)))
)
data["env"]["AYON_WORKFILE_TOOL_ON_START"] = (
str(int(bool(workfile_startup)))
)
_sub_msg = "" if start_last_workfile else " not"
log.debug(
"Last workfile should{} be opened on start.".format(_sub_msg)
)
# Last workfile path
last_workfile_path = data.get("last_workfile_path") or ""
if not last_workfile_path:
host_addon = addons_manager.get_host_addon(app.host_name)
if host_addon:
extensions = host_addon.get_workfile_extensions()
else:
extensions = HOST_WORKFILE_EXTENSIONS.get(app.host_name)
if extensions:
anatomy = data["anatomy"]
project_settings = data["project_settings"]
task_type = workdir_data["task"]["type"]
template_key = get_workfile_template_key(
project_name,
task_type,
app.host_name,
project_settings=project_settings
)
# Find last workfile
file_template = anatomy.get_template_item(
"work", template_key, "file"
).template
workdir_data.update({
"version": 1,
"user": get_ayon_username(),
"ext": extensions[0]
})
last_workfile_path = get_last_workfile(
workdir, file_template, workdir_data, extensions, True
)
if os.path.exists(last_workfile_path):
log.debug((
"Workfiles for launch context does not exists"
" yet but path will be set."
))
log.debug(
"Setting last workfile path: {}".format(last_workfile_path)
)
data["env"]["AYON_LAST_WORKFILE"] = last_workfile_path
data["last_workfile_path"] = last_workfile_path

View file

@ -96,6 +96,10 @@ def extractenvironments(output_json_path, project, asset, task, app, envgroup):
environments will be extracted.
Context options are "project", "asset", "task", "app"
Deprecated:
This function is deprecated and will be removed in future. Please use
'addon applications extractenvironments ...' instead.
"""
Commands.extractenvironments(
output_json_path, project, asset, task, app, envgroup

View file

@ -2,7 +2,7 @@
"""Implementation of AYON commands."""
import os
import sys
import json
import warnings
class Commands:
@ -57,10 +57,7 @@ class Commands:
"""
from ayon_core.lib import Logger
from ayon_core.lib.applications import (
get_app_environments_for_context,
LaunchTypes,
)
from ayon_core.addon import AddonsManager
from ayon_core.pipeline import (
install_ayon_plugins,
@ -68,7 +65,6 @@ class Commands:
)
# Register target and host
import pyblish.api
import pyblish.util
if not isinstance(path, str):
@ -99,15 +95,13 @@ class Commands:
for plugin_path in publish_paths:
pyblish.api.register_plugin_path(plugin_path)
app_full_name = os.getenv("AYON_APP_NAME")
if app_full_name:
applications_addon = manager.get_enabled_addon("applications")
if applications_addon is not None:
context = get_global_context()
env = get_app_environments_for_context(
env = applications_addon.get_farm_publish_environment_variables(
context["project_name"],
context["folder_path"],
context["task_name"],
app_full_name,
launch_type=LaunchTypes.farm_publish,
)
os.environ.update(env)
@ -149,36 +143,35 @@ class Commands:
log.info("Publish finished.")
@staticmethod
def extractenvironments(output_json_path, project, asset, task, app,
env_group):
def extractenvironments(
output_json_path, project, asset, task, app, env_group
):
"""Produces json file with environment based on project and app.
Called by Deadline plugin to propagate environment into render jobs.
"""
from ayon_core.lib.applications import (
get_app_environments_for_context,
LaunchTypes,
from ayon_core.addon import AddonsManager
warnings.warn(
(
"Command 'extractenvironments' is deprecated and will be"
" removed in future. Please use "
"'addon applications extractenvironments ...' instead."
),
DeprecationWarning
)
if all((project, asset, task, app)):
env = get_app_environments_for_context(
project,
asset,
task,
app,
env_group=env_group,
launch_type=LaunchTypes.farm_render
addons_manager = AddonsManager()
applications_addon = addons_manager.get_enabled_addon("applications")
if applications_addon is None:
raise RuntimeError(
"Applications addon is not available or enabled."
)
else:
env = os.environ.copy()
output_dir = os.path.dirname(output_json_path)
if not os.path.exists(output_dir):
os.makedirs(output_dir)
with open(output_json_path, "w") as file_stream:
json.dump(env, file_stream, indent=4)
# Please ignore the fact this is using private method
applications_addon._cli_extract_environments(
output_json_path, project, asset, task, app, env_group
)
@staticmethod
def contextselection(output_path, project_name, folder_path, strict):

View file

@ -1,6 +1,6 @@
import os
from ayon_core.lib.applications import PreLaunchHook, LaunchTypes
from ayon_applications import PreLaunchHook, LaunchTypes
class AddLastWorkfileToLaunchArgs(PreLaunchHook):

View file

@ -1,7 +1,7 @@
import os
import shutil
from ayon_core.settings import get_project_settings
from ayon_core.lib.applications import PreLaunchHook, LaunchTypes
from ayon_applications import PreLaunchHook, LaunchTypes
from ayon_core.pipeline.workfile import (
get_custom_workfile_template,
get_custom_workfile_template_by_string_context

View file

@ -1,5 +1,5 @@
import os
from ayon_core.lib.applications import PreLaunchHook, LaunchTypes
from ayon_applications import PreLaunchHook, LaunchTypes
from ayon_core.pipeline.workfile import create_workdir_extra_folders

View file

@ -1,7 +1,7 @@
from ayon_api import get_project, get_folder_by_path, get_task_by_name
from ayon_core.lib.applications import (
PreLaunchHook,
from ayon_applications import PreLaunchHook
from ayon_applications.utils import (
EnvironmentPrepData,
prepare_app_environments,
prepare_context_environments

View file

@ -1,5 +1,5 @@
import os
from ayon_core.lib.applications import PreLaunchHook, LaunchTypes
from ayon_applications import PreLaunchHook, LaunchTypes
class LaunchWithTerminal(PreLaunchHook):

View file

@ -1,5 +1,5 @@
import subprocess
from ayon_core.lib.applications import PreLaunchHook, LaunchTypes
from ayon_applications import PreLaunchHook, LaunchTypes
class LaunchNewConsoleApps(PreLaunchHook):

View file

@ -1,4 +1,4 @@
from ayon_core.lib.applications import PreLaunchHook
from ayon_applications import PreLaunchHook
from ayon_core.pipeline.colorspace import get_imageio_config
from ayon_core.pipeline.template_data import get_template_data_with_names

View file

@ -6,10 +6,7 @@ from ayon_core.lib import (
get_ayon_launcher_args,
is_using_ayon_console,
)
from ayon_core.lib.applications import (
PreLaunchHook,
LaunchTypes,
)
from ayon_applications import PreLaunchHook, LaunchTypes
from ayon_core.hosts.aftereffects import get_launch_script_path

View file

@ -1,6 +1,6 @@
from pathlib import Path
from ayon_core.lib.applications import PreLaunchHook, LaunchTypes
from ayon_applications import PreLaunchHook, LaunchTypes
class AddPythonScriptToLaunchArgs(PreLaunchHook):

View file

@ -2,7 +2,7 @@ import os
import re
import subprocess
from platform import system
from ayon_core.lib.applications import PreLaunchHook, LaunchTypes
from ayon_applications import PreLaunchHook, LaunchTypes
class InstallPySideToBlender(PreLaunchHook):

View file

@ -1,5 +1,5 @@
import subprocess
from ayon_core.lib.applications import PreLaunchHook, LaunchTypes
from ayon_applications import PreLaunchHook, LaunchTypes
class BlenderConsoleWindows(PreLaunchHook):

View file

@ -12,7 +12,7 @@ from ayon_core.pipeline.publish import (
import ayon_core.hosts.blender.api.action
class ValidateMeshNoNegativeScale(pyblish.api.Validator,
class ValidateMeshNoNegativeScale(pyblish.api.InstancePlugin,
OptionalPyblishPluginMixin):
"""Ensure that meshes don't have a negative scale."""

View file

@ -3,7 +3,7 @@ import shutil
import winreg
import subprocess
from ayon_core.lib import get_ayon_launcher_args
from ayon_core.lib.applications import PreLaunchHook, LaunchTypes
from ayon_applications import PreLaunchHook, LaunchTypes
from ayon_core.hosts.celaction import CELACTION_ROOT_DIR

View file

@ -3,11 +3,11 @@ import sys
from pprint import pformat
class CollectCelactionCliKwargs(pyblish.api.Collector):
class CollectCelactionCliKwargs(pyblish.api.ContextPlugin):
""" Collects all keyword arguments passed from the terminal """
label = "Collect Celaction Cli Kwargs"
order = pyblish.api.Collector.order - 0.1
order = pyblish.api.CollectorOrder - 0.1
def process(self, context):
args = list(sys.argv[1:])

View file

@ -9,7 +9,7 @@ from ayon_core.lib import (
get_ayon_username,
run_subprocess,
)
from ayon_core.lib.applications import PreLaunchHook, LaunchTypes
from ayon_applications import PreLaunchHook, LaunchTypes
from ayon_core.hosts import flame as opflame

View file

@ -5,6 +5,8 @@ import contextlib
from ayon_core.lib import Logger
from ayon_core.pipeline import registered_host
from ayon_core.pipeline.create import CreateContext
from ayon_core.pipeline.context_tools import get_current_project_folder
self = sys.modules[__name__]
@ -52,9 +54,15 @@ def update_frame_range(start, end, comp=None, set_render_range=True,
comp.SetAttrs(attrs)
def set_current_context_framerange():
def set_current_context_framerange(folder_entity=None):
"""Set Comp's frame range based on current folder."""
folder_entity = get_current_project_folder()
if folder_entity is None:
folder_entity = get_current_project_folder(
fields={"attrib.frameStart",
"attrib.frameEnd",
"attrib.handleStart",
"attrib.handleEnd"})
folder_attributes = folder_entity["attrib"]
start = folder_attributes["frameStart"]
end = folder_attributes["frameEnd"]
@ -65,9 +73,24 @@ def set_current_context_framerange():
handle_end=handle_end)
def set_current_context_resolution():
def set_current_context_fps(folder_entity=None):
"""Set Comp's frame rate (FPS) to based on current asset"""
if folder_entity is None:
folder_entity = get_current_project_folder(fields={"attrib.fps"})
fps = float(folder_entity["attrib"].get("fps", 24.0))
comp = get_current_comp()
comp.SetPrefs({
"Comp.FrameFormat.Rate": fps,
})
def set_current_context_resolution(folder_entity=None):
"""Set Comp's resolution width x height default based on current folder"""
folder_entity = get_current_project_folder()
if folder_entity is None:
folder_entity = get_current_project_folder(
fields={"attrib.resolutionWidth", "attrib.resolutionHeight"})
folder_attributes = folder_entity["attrib"]
width = folder_attributes["resolutionWidth"]
height = folder_attributes["resolutionHeight"]
@ -285,3 +308,98 @@ def comp_lock_and_undo_chunk(
finally:
comp.Unlock()
comp.EndUndo(keep_undo)
def update_content_on_context_change():
"""Update all Creator instances to current asset"""
host = registered_host()
context = host.get_current_context()
folder_path = context["folder_path"]
task = context["task_name"]
create_context = CreateContext(host, reset=True)
for instance in create_context.instances:
instance_folder_path = instance.get("folderPath")
if instance_folder_path and instance_folder_path != folder_path:
instance["folderPath"] = folder_path
instance_task = instance.get("task")
if instance_task and instance_task != task:
instance["task"] = task
create_context.save_changes()
def prompt_reset_context():
"""Prompt the user what context settings to reset.
This prompt is used on saving to a different task to allow the scene to
get matched to the new context.
"""
# TODO: Cleanup this prototyped mess of imports and odd dialog
from ayon_core.tools.attribute_defs.dialog import (
AttributeDefinitionsDialog
)
from ayon_core.style import load_stylesheet
from ayon_core.lib import BoolDef, UILabelDef
from qtpy import QtWidgets, QtCore
definitions = [
UILabelDef(
label=(
"You are saving your workfile into a different folder or task."
"\n\n"
"Would you like to update some settings to the new context?\n"
)
),
BoolDef(
"fps",
label="FPS",
tooltip="Reset Comp FPS",
default=True
),
BoolDef(
"frame_range",
label="Frame Range",
tooltip="Reset Comp start and end frame ranges",
default=True
),
BoolDef(
"resolution",
label="Comp Resolution",
tooltip="Reset Comp resolution",
default=True
),
BoolDef(
"instances",
label="Publish instances",
tooltip="Update all publish instance's folder and task to match "
"the new folder and task",
default=True
),
]
dialog = AttributeDefinitionsDialog(definitions)
dialog.setWindowFlags(
dialog.windowFlags() | QtCore.Qt.WindowStaysOnTopHint
)
dialog.setWindowTitle("Saving to different context.")
dialog.setStyleSheet(load_stylesheet())
if not dialog.exec_():
return None
options = dialog.get_values()
folder_entity = get_current_project_folder()
if options["frame_range"]:
set_current_context_framerange(folder_entity)
if options["fps"]:
set_current_context_fps(folder_entity)
if options["resolution"]:
set_current_context_resolution(folder_entity)
if options["instances"]:
update_content_on_context_change()
dialog.deleteLater()

View file

@ -5,6 +5,7 @@ import os
import sys
import logging
import contextlib
from pathlib import Path
import pyblish.api
from qtpy import QtCore
@ -28,7 +29,8 @@ from ayon_core.tools.utils import host_tools
from .lib import (
get_current_comp,
validate_comp_prefs
validate_comp_prefs,
prompt_reset_context
)
log = Logger.get_logger(__name__)
@ -40,6 +42,9 @@ LOAD_PATH = os.path.join(PLUGINS_DIR, "load")
CREATE_PATH = os.path.join(PLUGINS_DIR, "create")
INVENTORY_PATH = os.path.join(PLUGINS_DIR, "inventory")
# Track whether the workfile tool is about to save
_about_to_save = False
class FusionLogHandler(logging.Handler):
# Keep a reference to fusion's Print function (Remote Object)
@ -103,8 +108,10 @@ class FusionHost(HostBase, IWorkfileHost, ILoadHost, IPublishHost):
# Register events
register_event_callback("open", on_after_open)
register_event_callback("workfile.save.before", before_workfile_save)
register_event_callback("save", on_save)
register_event_callback("new", on_new)
register_event_callback("taskChanged", on_task_changed)
# region workfile io api
def has_unsaved_changes(self):
@ -168,6 +175,19 @@ def on_save(event):
comp = event["sender"]
validate_comp_prefs(comp)
# We are now starting the actual save directly
global _about_to_save
_about_to_save = False
def on_task_changed():
global _about_to_save
print(f"Task changed: {_about_to_save}")
# TODO: Only do this if not headless
if _about_to_save:
# Let's prompt the user to update the context settings or not
prompt_reset_context()
def on_after_open(event):
comp = event["sender"]
@ -201,6 +221,28 @@ def on_after_open(event):
dialog.setStyleSheet(load_stylesheet())
def before_workfile_save(event):
# Due to Fusion's external python process design we can't really
# detect whether the current Fusion environment matches the one the artists
# expects it to be. For example, our pipeline python process might
# have been shut down, and restarted - which will restart it to the
# environment Fusion started with; not necessarily where the artist
# is currently working.
# The `_about_to_save` var is used to detect context changes when
# saving into another asset. If we keep it False it will be ignored
# as context change. As such, before we change tasks we will only
# consider it the current filepath is within the currently known
# AVALON_WORKDIR. This way we avoid false positives of thinking it's
# saving to another context and instead sometimes just have false negatives
# where we fail to show the "Update on task change" prompt.
comp = get_current_comp()
filepath = comp.GetAttrs()["COMPS_FileName"]
workdir = os.environ.get("AYON_WORKDIR")
if Path(workdir) in Path(filepath).parents:
global _about_to_save
_about_to_save = True
def ls():
"""List containers from active Fusion scene
@ -337,7 +379,6 @@ class FusionEventHandler(QtCore.QObject):
>>> handler = FusionEventHandler(parent=window)
>>> handler.start()
"""
ACTION_IDS = [
"Comp_Save",

View file

@ -7,7 +7,7 @@ from ayon_core.hosts.fusion import (
FUSION_VERSIONS_DICT,
get_fusion_version,
)
from ayon_core.lib.applications import (
from ayon_applications import (
PreLaunchHook,
LaunchTypes,
ApplicationLaunchFailed,

View file

@ -1,5 +1,5 @@
import os
from ayon_core.lib.applications import (
from ayon_applications import (
PreLaunchHook,
LaunchTypes,
ApplicationLaunchFailed,

View file

@ -3,7 +3,7 @@ import subprocess
import platform
import uuid
from ayon_core.lib.applications import PreLaunchHook, LaunchTypes
from ayon_applications import PreLaunchHook, LaunchTypes
class InstallPySideToFusion(PreLaunchHook):

View file

@ -6,10 +6,7 @@ from ayon_core.lib import (
get_ayon_launcher_args,
is_using_ayon_console,
)
from ayon_core.lib.applications import (
PreLaunchHook,
LaunchTypes,
)
from ayon_applications import PreLaunchHook, LaunchTypes
from ayon_core.hosts.harmony import get_launch_script_path

View file

@ -91,7 +91,7 @@ def create_interactive(creator_identifier, **kwargs):
pane = stateutils.activePane(kwargs)
if isinstance(pane, hou.NetworkEditor):
pwd = pane.pwd()
project_name = context.get_current_project_name(),
project_name = context.get_current_project_name()
folder_path = context.get_current_folder_path()
task_name = context.get_current_task_name()
folder_entity = ayon_api.get_folder_by_path(

View file

@ -243,7 +243,10 @@ def render_rop(ropnode):
try:
ropnode.render(verbose=verbose,
# Allow Deadline to capture completion percentage
output_progress=verbose)
output_progress=verbose,
# Render only this node
# (do not render any of its dependencies)
ignore_inputs=True)
except hou.Error as exc:
# The hou.Error is not inherited from a Python Exception class,
# so we explicitly capture the houdini error, otherwise pyblish
@ -948,7 +951,7 @@ def self_publish():
Firstly, it gets the node and its dependencies.
Then, it deactivates all other ROPs
And finaly, it triggers the publishing action.
And finally, it triggers the publishing action.
"""
result, comment = hou.ui.readInput(
@ -1076,4 +1079,4 @@ def prompt_reset_context():
if options["instances"]:
update_content_on_context_change()
dialog.deleteLater()
dialog.deleteLater()

View file

@ -39,7 +39,7 @@ CREATE_PATH = os.path.join(PLUGINS_DIR, "create")
INVENTORY_PATH = os.path.join(PLUGINS_DIR, "inventory")
# Track whether the workfile tool is about to save
ABOUT_TO_SAVE = False
_about_to_save = False
class HoudiniHost(HostBase, IWorkfileHost, ILoadHost, IPublishHost):
@ -292,8 +292,8 @@ def ls():
def before_workfile_save(event):
global ABOUT_TO_SAVE
ABOUT_TO_SAVE = True
global _about_to_save
_about_to_save = True
def before_save():
@ -307,18 +307,14 @@ def on_save():
# update houdini vars
lib.update_houdini_vars_context_dialog()
nodes = lib.get_id_required_nodes()
for node, new_id in lib.generate_ids(nodes):
lib.set_id(node, new_id, overwrite=False)
# We are now starting the actual save directly
global ABOUT_TO_SAVE
ABOUT_TO_SAVE = False
global _about_to_save
_about_to_save = False
def on_task_changed():
global ABOUT_TO_SAVE
if not IS_HEADLESS and ABOUT_TO_SAVE:
global _about_to_save
if not IS_HEADLESS and _about_to_save:
# Let's prompt the user to update the context settings or not
lib.prompt_reset_context()

View file

@ -1,4 +1,4 @@
from ayon_core.lib.applications import PreLaunchHook, LaunchTypes
from ayon_applications import PreLaunchHook, LaunchTypes
class SetPath(PreLaunchHook):

View file

@ -2,7 +2,7 @@
"""Pre-launch to force 3ds max startup script."""
import os
from ayon_core.hosts.max import MAX_HOST_DIR
from ayon_core.lib.applications import PreLaunchHook, LaunchTypes
from ayon_applications import PreLaunchHook, LaunchTypes
class ForceStartupScript(PreLaunchHook):

View file

@ -1,7 +1,7 @@
# -*- coding: utf-8 -*-
"""Pre-launch hook to inject python environment."""
import os
from ayon_core.lib.applications import PreLaunchHook, LaunchTypes
from ayon_applications import PreLaunchHook, LaunchTypes
class InjectPythonPath(PreLaunchHook):

View file

@ -1,4 +1,4 @@
from ayon_core.lib.applications import PreLaunchHook, LaunchTypes
from ayon_applications import PreLaunchHook, LaunchTypes
class SetPath(PreLaunchHook):

View file

@ -4,7 +4,10 @@ from __future__ import absolute_import
import pyblish.api
import ayon_api
from ayon_core.pipeline.publish import get_errored_instances_from_context
from ayon_core.pipeline.publish import (
get_errored_instances_from_context,
get_errored_plugins_from_context
)
class GenerateUUIDsOnInvalidAction(pyblish.api.Action):
@ -112,20 +115,25 @@ class SelectInvalidAction(pyblish.api.Action):
except ImportError:
raise ImportError("Current host is not Maya")
errored_instances = get_errored_instances_from_context(context,
plugin=plugin)
# Get the invalid nodes for the plug-ins
self.log.info("Finding invalid nodes..")
invalid = list()
for instance in errored_instances:
invalid_nodes = plugin.get_invalid(instance)
if invalid_nodes:
if isinstance(invalid_nodes, (list, tuple)):
invalid.extend(invalid_nodes)
else:
self.log.warning("Plug-in returned to be invalid, "
"but has no selectable nodes.")
if issubclass(plugin, pyblish.api.ContextPlugin):
errored_plugins = get_errored_plugins_from_context(context)
if plugin in errored_plugins:
invalid = plugin.get_invalid(context)
else:
errored_instances = get_errored_instances_from_context(
context, plugin=plugin
)
for instance in errored_instances:
invalid_nodes = plugin.get_invalid(instance)
if invalid_nodes:
if isinstance(invalid_nodes, (list, tuple)):
invalid.extend(invalid_nodes)
else:
self.log.warning("Plug-in returned to be invalid, "
"but has no selectable nodes.")
# Ensure unique (process each node only once)
invalid = list(set(invalid))

View file

@ -113,7 +113,9 @@ def override_toolbox_ui():
annotation="Look Manager",
label="Look Manager",
image=os.path.join(icons, "lookmanager.png"),
command=show_look_assigner,
command=lambda: show_look_assigner(
parent=parent_widget
),
width=icon_size,
height=icon_size,
parent=parent

View file

@ -1876,18 +1876,9 @@ def list_looks(project_name, folder_id):
list[dict[str, Any]]: List of look products.
"""
# # get all products with look leading in
# the name associated with the asset
# TODO this should probably look for product type 'look' instead of
# checking product name that can not start with product type
product_entities = ayon_api.get_products(
project_name, folder_ids=[folder_id]
)
return [
product_entity
for product_entity in product_entities
if product_entity["name"].startswith("look")
]
return list(ayon_api.get_products(
project_name, folder_ids=[folder_id], product_types={"look"}
))
def assign_look_by_version(nodes, version_id):
@ -1906,12 +1897,15 @@ def assign_look_by_version(nodes, version_id):
project_name = get_current_project_name()
# Get representations of shader file and relationships
look_representation = ayon_api.get_representation_by_name(
project_name, "ma", version_id
)
json_representation = ayon_api.get_representation_by_name(
project_name, "json", version_id
)
representations = list(ayon_api.get_representations(
project_name=project_name,
representation_names={"ma", "json"},
version_ids=[version_id]
))
look_representation = next(
repre for repre in representations if repre["name"] == "ma")
json_representation = next(
repre for repre in representations if repre["name"] == "json")
# See if representation is already loaded, if so reuse it.
host = registered_host()
@ -1948,7 +1942,7 @@ def assign_look_by_version(nodes, version_id):
apply_shaders(relationships, shader_nodes, nodes)
def assign_look(nodes, product_name="lookDefault"):
def assign_look(nodes, product_name="lookMain"):
"""Assigns a look to a node.
Optimizes the nodes by grouping by folder id and finding
@ -1981,14 +1975,10 @@ def assign_look(nodes, product_name="lookDefault"):
product_entity["id"]
for product_entity in product_entities_by_folder_id.values()
}
last_version_entities = ayon_api.get_last_versions(
last_version_entities_by_product_id = ayon_api.get_last_versions(
project_name,
product_ids
)
last_version_entities_by_product_id = {
last_version_entity["productId"]: last_version_entity
for last_version_entity in last_version_entities
}
for folder_id, asset_nodes in grouped.items():
product_entity = product_entities_by_folder_id.get(folder_id)
@ -2651,31 +2641,114 @@ def reset_scene_resolution():
set_scene_resolution(width, height, pixelAspect)
def set_context_settings():
def set_context_settings(
fps=True,
resolution=True,
frame_range=True,
colorspace=True
):
"""Apply the project settings from the project definition
Settings can be overwritten by an folder if the folder.attrib contains
Settings can be overwritten by an asset if the asset.data contains
any information regarding those settings.
Examples of settings:
fps
resolution
renderer
Args:
fps (bool): Whether to set the scene FPS.
resolution (bool): Whether to set the render resolution.
frame_range (bool): Whether to reset the time slide frame ranges.
colorspace (bool): Whether to reset the colorspace.
Returns:
None
"""
# Set project fps
set_scene_fps(get_fps_for_current_context())
if fps:
# Set project fps
set_scene_fps(get_fps_for_current_context())
reset_scene_resolution()
if resolution:
reset_scene_resolution()
# Set frame range.
reset_frame_range()
if frame_range:
reset_frame_range(fps=False)
# Set colorspace
set_colorspace()
if colorspace:
set_colorspace()
def prompt_reset_context():
"""Prompt the user what context settings to reset.
This prompt is used on saving to a different task to allow the scene to
get matched to the new context.
"""
# TODO: Cleanup this prototyped mess of imports and odd dialog
from ayon_core.tools.attribute_defs.dialog import (
AttributeDefinitionsDialog
)
from ayon_core.style import load_stylesheet
from ayon_core.lib import BoolDef, UILabelDef
definitions = [
UILabelDef(
label=(
"You are saving your workfile into a different folder or task."
"\n\n"
"Would you like to update some settings to the new context?\n"
)
),
BoolDef(
"fps",
label="FPS",
tooltip="Reset workfile FPS",
default=True
),
BoolDef(
"frame_range",
label="Frame Range",
tooltip="Reset workfile start and end frame ranges",
default=True
),
BoolDef(
"resolution",
label="Resolution",
tooltip="Reset workfile resolution",
default=True
),
BoolDef(
"colorspace",
label="Colorspace",
tooltip="Reset workfile resolution",
default=True
),
BoolDef(
"instances",
label="Publish instances",
tooltip="Update all publish instance's folder and task to match "
"the new folder and task",
default=True
),
]
dialog = AttributeDefinitionsDialog(definitions)
dialog.setWindowTitle("Saving to different context.")
dialog.setStyleSheet(load_stylesheet())
if not dialog.exec_():
return None
options = dialog.get_values()
with suspended_refresh():
set_context_settings(
fps=options["fps"],
resolution=options["resolution"],
frame_range=options["frame_range"],
colorspace=options["colorspace"]
)
if options["instances"]:
update_content_on_context_change()
dialog.deleteLater()
# Valid FPS

View file

@ -67,6 +67,9 @@ INVENTORY_PATH = os.path.join(PLUGINS_DIR, "inventory")
AVALON_CONTAINERS = ":AVALON_CONTAINERS"
# Track whether the workfile tool is about to save
_about_to_save = False
class MayaHost(HostBase, IWorkfileHost, ILoadHost, IPublishHost):
name = "maya"
@ -581,6 +584,10 @@ def on_save():
for node, new_id in lib.generate_ids(nodes):
lib.set_id(node, new_id, overwrite=False)
# We are now starting the actual save directly
global _about_to_save
_about_to_save = False
def on_open():
"""On scene open let's assume the containers have changed."""
@ -650,6 +657,11 @@ def on_task_changed():
lib.set_context_settings()
lib.update_content_on_context_change()
global _about_to_save
if not lib.IS_HEADLESS and _about_to_save:
# Let's prompt the user to update the context settings or not
lib.prompt_reset_context()
def before_workfile_open():
if handle_workfile_locks():
@ -664,6 +676,9 @@ def before_workfile_save(event):
if workdir_path:
create_workspace_mel(workdir_path, project_name)
global _about_to_save
_about_to_save = True
def workfile_save_before_xgen(event):
"""Manage Xgen external files when switching context.

View file

@ -1,4 +1,4 @@
from ayon_core.lib.applications import PreLaunchHook, LaunchTypes
from ayon_applications import PreLaunchHook, LaunchTypes
class MayaPreAutoLoadPlugins(PreLaunchHook):

View file

@ -1,4 +1,4 @@
from ayon_core.lib.applications import PreLaunchHook, LaunchTypes
from ayon_applications import PreLaunchHook, LaunchTypes
from ayon_core.hosts.maya.lib import create_workspace_mel

View file

@ -1,4 +1,4 @@
from ayon_core.lib.applications import PreLaunchHook, LaunchTypes
from ayon_applications import PreLaunchHook, LaunchTypes
class MayaPreOpenWorkfilePostInitialization(PreLaunchHook):

View file

@ -142,9 +142,21 @@ class ImagePlaneLoader(load.LoaderPlugin):
with namespaced(namespace):
# Create inside the namespace
image_plane_transform, image_plane_shape = cmds.imagePlane(
fileName=context["representation"]["data"]["path"],
fileName=self.filepath_from_context(context),
camera=camera
)
# Set colorspace
colorspace = self.get_colorspace(context["representation"])
if colorspace:
cmds.setAttr(
"{}.ignoreColorSpaceFileRules".format(image_plane_shape),
True
)
cmds.setAttr("{}.colorSpace".format(image_plane_shape),
colorspace, type="string")
# Set offset frame range
start_frame = cmds.playbackOptions(query=True, min=True)
end_frame = cmds.playbackOptions(query=True, max=True)
@ -216,6 +228,15 @@ class ImagePlaneLoader(load.LoaderPlugin):
repre_entity["id"],
type="string")
colorspace = self.get_colorspace(repre_entity)
if colorspace:
cmds.setAttr(
"{}.ignoreColorSpaceFileRules".format(image_plane_shape),
True
)
cmds.setAttr("{}.colorSpace".format(image_plane_shape),
colorspace, type="string")
# Set frame range.
start_frame = folder_entity["attrib"]["frameStart"]
end_frame = folder_entity["attrib"]["frameEnd"]
@ -243,3 +264,12 @@ class ImagePlaneLoader(load.LoaderPlugin):
deleteNamespaceContent=True)
except RuntimeError:
pass
def get_colorspace(self, representation):
data = representation.get("data", {}).get("colorspaceData", {})
if not data:
return
colorspace = data.get("colorspace")
return colorspace

View file

@ -1,24 +1,19 @@
# -*- coding: utf-8 -*-
"""Collect render data.
This collector will go through render layers in maya and prepare all data
needed to create instances and their representations for submission and
publishing on farm.
This collector will go through renderlayer instances and prepare all data
needed to detect the expected rendered files for a layer, with resolution,
frame ranges and collects the data needed for publishing on the farm.
Requires:
instance -> families
instance -> setMembers
instance -> folderPath
context -> currentFile
context -> workspaceDir
context -> user
Optional:
Provides:
instance -> label
instance -> productName
instance -> subset
instance -> attachTo
instance -> setMembers
instance -> publish
@ -26,6 +21,8 @@ Provides:
instance -> frameEnd
instance -> byFrameStep
instance -> renderer
instance -> family
instance -> asset
instance -> time
instance -> author
instance -> source
@ -71,8 +68,6 @@ class CollectMayaRender(pyblish.api.InstancePlugin):
# TODO: Re-add force enable of workfile instance?
# TODO: Re-add legacy layer support with LAYER_ prefix but in Creator
# TODO: Set and collect active state of RenderLayer in Creator using
# renderlayer.isRenderable()
context = instance.context
layer = instance.data["transientData"]["layer"]
@ -112,7 +107,13 @@ class CollectMayaRender(pyblish.api.InstancePlugin):
except UnsupportedRendererException as exc:
raise KnownPublishError(exc)
render_products = layer_render_products.layer_data.products
assert render_products, "no render products generated"
if not render_products:
self.log.error(
"No render products generated for '%s'. You might not have "
"any render camera in the renderlayer or render end frame is "
"lower than start frame.",
instance.name
)
expected_files = []
multipart = False
for product in render_products:
@ -130,16 +131,21 @@ class CollectMayaRender(pyblish.api.InstancePlugin):
})
has_cameras = any(product.camera for product in render_products)
assert has_cameras, "No render cameras found."
self.log.debug("multipart: {}".format(
multipart))
assert expected_files, "no file names were generated, this is a bug"
self.log.debug(
"expected files: {}".format(
json.dumps(expected_files, indent=4, sort_keys=True)
if render_products and not has_cameras:
self.log.error(
"No render cameras found for: %s",
instance
)
)
if not expected_files:
self.log.warning(
"No file names were generated, this is a bug.")
for render_product in render_products:
self.log.debug(render_product)
self.log.debug("multipart: {}".format(multipart))
self.log.debug("expected files: {}".format(
json.dumps(expected_files, indent=4, sort_keys=True)
))
# if we want to attach render to product, check if we have AOV's
# in expectedFiles. If so, raise error as we cannot attach AOV
@ -151,14 +157,14 @@ class CollectMayaRender(pyblish.api.InstancePlugin):
)
# append full path
aov_dict = {}
image_directory = os.path.join(
cmds.workspace(query=True, rootDirectory=True),
cmds.workspace(fileRuleEntry="images")
)
# replace relative paths with absolute. Render products are
# returned as list of dictionaries.
publish_meta_path = None
publish_meta_path = "NOT-SET"
aov_dict = {}
for aov in expected_files:
full_paths = []
aov_first_key = list(aov.keys())[0]
@ -169,14 +175,6 @@ class CollectMayaRender(pyblish.api.InstancePlugin):
publish_meta_path = os.path.dirname(full_path)
aov_dict[aov_first_key] = full_paths
full_exp_files = [aov_dict]
self.log.debug(full_exp_files)
if publish_meta_path is None:
raise KnownPublishError("Unable to detect any expected output "
"images for: {}. Make sure you have a "
"renderable camera and a valid frame "
"range set for your renderlayer."
"".format(instance.name))
frame_start_render = int(self.get_render_attribute(
"startFrame", layer=layer_name))
@ -222,7 +220,8 @@ class CollectMayaRender(pyblish.api.InstancePlugin):
common_publish_meta_path = "/" + common_publish_meta_path
self.log.debug(
"Publish meta path: {}".format(common_publish_meta_path))
"Publish meta path: {}".format(common_publish_meta_path)
)
# Get layer specific settings, might be overrides
colorspace_data = lib.get_color_management_preferences()

View file

@ -5,7 +5,8 @@ from maya import cmds
from ayon_core.pipeline import publish
class ExtractGPUCache(publish.Extractor):
class ExtractGPUCache(publish.Extractor,
publish.OptionalPyblishPluginMixin):
"""Extract the content of the instance to a GPU cache file."""
label = "GPU Cache"
@ -20,6 +21,9 @@ class ExtractGPUCache(publish.Extractor):
useBaseTessellation = True
def process(self, instance):
if not self.is_active(instance.data):
return
cmds.loadPlugin("gpuCache", quiet=True)
staging_dir = self.staging_dir(instance)

View file

@ -0,0 +1,29 @@
<?xml version="1.0" encoding="UTF-8"?>
<root>
<error id="main">
<title>Shape IDs mismatch original shape</title>
<description>## Shapes mismatch IDs with original shape
Meshes are detected where the (deformed) mesh has a different `cbId` than
the same mesh in its deformation history.
Theses should normally be the same.
### How to repair?
By using the repair action the IDs from the shape in history will be
copied to the deformed shape. For **animation** instances using the
repair action usually is usually the correct fix.
</description>
<detail>
### How does this happen?
When a deformer is applied in the scene on a referenced mesh that had no
deformers then Maya will create a new shape node for the mesh that
does not have the original id. Then on scene save new ids get created for the
meshes lacking a `cbId` and thus the mesh then has a different `cbId` than
the mesh in the deformation history.
</detail>
</error>
</root>

View file

@ -0,0 +1,33 @@
<?xml version="1.0" encoding="UTF-8"?>
<root>
<error id="main">
<title>Non-Manifold Edges/Vertices</title>
<description>## Non-Manifold Edges/Vertices
Meshes found with non-manifold edges or vertices.
### How to repair?
Run select invalid to select the invalid components.
You can also try the _cleanup matching polygons_ action which will perform a
cleanup like Maya's `Mesh > Cleanup...` modeling tool.
It is recommended to always select the invalid to see where the issue is
because if you run any repair on it you will need to double check the topology
is still like you wanted.
</description>
<detail>
### What is non-manifold topology?
_Non-manifold topology_ polygons have a configuration that cannot be unfolded
into a continuous flat piece, for example:
- Three or more faces share an edge
- Two or more faces share a single vertex but no edge.
- Adjacent faces have opposite normals
</detail>
</error>
</root>

View file

@ -6,7 +6,7 @@ from ayon_core.hosts.maya.api import lib
from ayon_core.pipeline.publish import (
RepairAction,
ValidateContentsOrder,
PublishValidationError,
PublishXmlValidationError,
OptionalPyblishPluginMixin,
get_plugin_settings,
apply_plugin_settings_automatically
@ -56,40 +56,39 @@ class ValidateOutRelatedNodeIds(pyblish.api.InstancePlugin,
# if a deformer has been created on the shape
invalid = self.get_invalid(instance)
if invalid:
# TODO: Message formatting can be improved
raise PublishValidationError("Nodes found with mismatching "
"IDs: {0}".format(invalid),
title="Invalid node ids")
# Use the short names
invalid = cmds.ls(invalid)
invalid.sort()
# Construct a human-readable list
invalid = "\n".join("- {}".format(node) for node in invalid)
raise PublishXmlValidationError(
plugin=self,
message=(
"Nodes have different IDs than their input "
"history: \n{0}".format(invalid)
)
)
@classmethod
def get_invalid(cls, instance):
"""Get all nodes which do not match the criteria"""
invalid = []
types_to_skip = ["locator"]
types = ["mesh", "nurbsCurve", "nurbsSurface"]
# get asset id
nodes = instance.data.get("out_hierarchy", instance[:])
for node in nodes:
for node in cmds.ls(nodes, type=types, long=True):
# We only check when the node is *not* referenced
if cmds.referenceQuery(node, isNodeReferenced=True):
continue
# Check if node is a shape as deformers only work on shapes
obj_type = cmds.objectType(node, isAType="shape")
if not obj_type:
continue
# Skip specific types
if cmds.objectType(node) in types_to_skip:
continue
# Get the current id of the node
node_id = lib.get_id(node)
if not node_id:
invalid.append(node)
continue
history_id = lib.get_id_from_sibling(node)
if history_id is not None and node_id != history_id:

View file

@ -10,7 +10,7 @@ from ayon_core.pipeline.publish import (
)
class ValidateColorSets(pyblish.api.Validator,
class ValidateColorSets(pyblish.api.InstancePlugin,
OptionalPyblishPluginMixin):
"""Validate all meshes in the instance have unlocked normals

View file

@ -47,10 +47,18 @@ class ValidateShadingEngine(pyblish.api.InstancePlugin,
shape, destination=True, type="shadingEngine"
) or []
for shading_engine in shading_engines:
name = (
cmds.listConnections(shading_engine + ".surfaceShader")[0]
+ "SG"
materials = cmds.listConnections(
shading_engine + ".surfaceShader",
source=True, destination=False
)
if not materials:
cls.log.warning(
"Shading engine '{}' has no material connected to its "
".surfaceShader attribute.".format(shading_engine))
continue
material = materials[0] # there should only ever be one input
name = material + "SG"
if shading_engine != name:
invalid.append(shading_engine)

View file

@ -10,7 +10,7 @@ from ayon_core.pipeline.publish import (
)
class ValidateMeshNgons(pyblish.api.Validator,
class ValidateMeshNgons(pyblish.api.InstancePlugin,
OptionalPyblishPluginMixin):
"""Ensure that meshes don't have ngons

View file

@ -16,7 +16,7 @@ def _as_report_list(values, prefix="- ", suffix="\n"):
return prefix + (suffix + prefix).join(values)
class ValidateMeshNoNegativeScale(pyblish.api.Validator,
class ValidateMeshNoNegativeScale(pyblish.api.InstancePlugin,
OptionalPyblishPluginMixin):
"""Ensure that meshes don't have a negative scale.

View file

@ -1,14 +1,99 @@
from maya import cmds
from maya import cmds, mel
import pyblish.api
import ayon_core.hosts.maya.api.action
from ayon_core.pipeline.publish import (
ValidateMeshOrder,
PublishValidationError,
PublishXmlValidationError,
RepairAction,
OptionalPyblishPluginMixin
)
def poly_cleanup(version=4,
meshes=None,
# Version 1
all_meshes=False,
select_only=False,
history_on=True,
quads=False,
nsided=False,
concave=False,
holed=False,
nonplanar=False,
zeroGeom=False,
zeroGeomTolerance=1e-05,
zeroEdge=False,
zeroEdgeTolerance=1e-05,
zeroMap=False,
zeroMapTolerance=1e-05,
# Version 2
shared_uvs=False,
non_manifold=False,
# Version 3
lamina=False,
# Version 4
invalid_components=False):
"""Wrapper around `polyCleanupArgList` mel command"""
# Get all inputs named as `dict` to easily do conversions and formatting
values = locals()
# Convert booleans to 1 or 0
for key in [
"all_meshes",
"select_only",
"history_on",
"quads",
"nsided",
"concave",
"holed",
"nonplanar",
"zeroGeom",
"zeroEdge",
"zeroMap",
"shared_uvs",
"non_manifold",
"lamina",
"invalid_components",
]:
values[key] = 1 if values[key] else 0
cmd = (
'polyCleanupArgList {version} {{ '
'"{all_meshes}",' # 0: All selectable meshes
'"{select_only}",' # 1: Only perform a selection
'"{history_on}",' # 2: Keep construction history
'"{quads}",' # 3: Check for quads polys
'"{nsided}",' # 4: Check for n-sides polys
'"{concave}",' # 5: Check for concave polys
'"{holed}",' # 6: Check for holed polys
'"{nonplanar}",' # 7: Check for non-planar polys
'"{zeroGeom}",' # 8: Check for 0 area faces
'"{zeroGeomTolerance}",' # 9: Tolerance for face areas
'"{zeroEdge}",' # 10: Check for 0 length edges
'"{zeroEdgeTolerance}",' # 11: Tolerance for edge length
'"{zeroMap}",' # 12: Check for 0 uv face area
'"{zeroMapTolerance}",' # 13: Tolerance for uv face areas
'"{shared_uvs}",' # 14: Unshare uvs that are shared
# across vertices
'"{non_manifold}",' # 15: Check for nonmanifold polys
'"{lamina}",' # 16: Check for lamina polys
'"{invalid_components}"' # 17: Remove invalid components
' }};'.format(**values)
)
mel.eval("source polyCleanupArgList")
if not all_meshes and meshes:
# Allow to specify meshes to run over by selecting them
cmds.select(meshes, replace=True)
mel.eval(cmd)
class CleanupMatchingPolygons(RepairAction):
label = "Cleanup matching polygons"
def _as_report_list(values, prefix="- ", suffix="\n"):
"""Return list as bullet point list for a report"""
if not values:
@ -16,7 +101,7 @@ def _as_report_list(values, prefix="- ", suffix="\n"):
return prefix + (suffix + prefix).join(values)
class ValidateMeshNonManifold(pyblish.api.Validator,
class ValidateMeshNonManifold(pyblish.api.InstancePlugin,
OptionalPyblishPluginMixin):
"""Ensure that meshes don't have non-manifold edges or vertices
@ -29,7 +114,8 @@ class ValidateMeshNonManifold(pyblish.api.Validator,
hosts = ['maya']
families = ['model']
label = 'Mesh Non-Manifold Edges/Vertices'
actions = [ayon_core.hosts.maya.api.action.SelectInvalidAction]
actions = [ayon_core.hosts.maya.api.action.SelectInvalidAction,
CleanupMatchingPolygons]
optional = True
@staticmethod
@ -39,9 +125,11 @@ class ValidateMeshNonManifold(pyblish.api.Validator,
invalid = []
for mesh in meshes:
if (cmds.polyInfo(mesh, nonManifoldVertices=True) or
cmds.polyInfo(mesh, nonManifoldEdges=True)):
invalid.append(mesh)
components = cmds.polyInfo(mesh,
nonManifoldVertices=True,
nonManifoldEdges=True)
if components:
invalid.extend(components)
return invalid
@ -49,12 +137,34 @@ class ValidateMeshNonManifold(pyblish.api.Validator,
"""Process all the nodes in the instance 'objectSet'"""
if not self.is_active(instance.data):
return
invalid = self.get_invalid(instance)
if invalid:
raise PublishValidationError(
"Meshes found with non-manifold edges/vertices:\n\n{0}".format(
_as_report_list(sorted(invalid))
),
title="Non-Manifold Edges/Vertices"
# Report only the meshes instead of all component indices
invalid_meshes = {
component.split(".", 1)[0] for component in invalid
}
invalid_meshes = _as_report_list(sorted(invalid_meshes))
raise PublishXmlValidationError(
plugin=self,
message=(
"Meshes found with non-manifold "
"edges/vertices:\n\n{0}".format(invalid_meshes)
)
)
@classmethod
def repair(cls, instance):
invalid_components = cls.get_invalid(instance)
if not invalid_components:
cls.log.info("No invalid components found to cleanup.")
return
invalid_meshes = {
component.split(".", 1)[0] for component in invalid_components
}
poly_cleanup(meshes=list(invalid_meshes),
select_only=True,
non_manifold=True)

View file

@ -18,7 +18,7 @@ def _as_report_list(values, prefix="- ", suffix="\n"):
return prefix + (suffix + prefix).join(values)
class ValidateMeshNormalsUnlocked(pyblish.api.Validator,
class ValidateMeshNormalsUnlocked(pyblish.api.InstancePlugin,
OptionalPyblishPluginMixin):
"""Validate all meshes in the instance have unlocked normals

View file

@ -16,7 +16,7 @@ def _as_report_list(values, prefix="- ", suffix="\n"):
return prefix + (suffix + prefix).join(values)
class ValidateNoAnimation(pyblish.api.Validator,
class ValidateNoAnimation(pyblish.api.InstancePlugin,
OptionalPyblishPluginMixin):
"""Ensure no keyframes on nodes in the Instance.

View file

@ -19,22 +19,17 @@ def _as_report_list(values, prefix="- ", suffix="\n"):
def has_shape_children(node):
# Check if any descendants
allDescendents = cmds.listRelatives(node,
allDescendents=True,
fullPath=True)
if not allDescendents:
all_descendents = cmds.listRelatives(node,
allDescendents=True,
fullPath=True)
if not all_descendents:
return False
# Check if there are any shapes at all
shapes = cmds.ls(allDescendents, shapes=True)
shapes = cmds.ls(all_descendents, shapes=True, noIntermediate=True)
if not shapes:
return False
# Check if all descendent shapes are intermediateObjects;
# if so we consider this node a null node and return False.
if all(cmds.getAttr('{0}.intermediateObject'.format(x)) for x in shapes):
return False
return True

View file

@ -1,4 +1,5 @@
import re
import inspect
import pyblish.api
from maya import cmds
@ -36,7 +37,10 @@ class ValidateRenderSingleCamera(pyblish.api.InstancePlugin,
return
invalid = self.get_invalid(instance)
if invalid:
raise PublishValidationError("Invalid cameras for render.")
raise PublishValidationError(
"Invalid render cameras.",
description=self.get_description()
)
@classmethod
def get_invalid(cls, instance):
@ -51,17 +55,30 @@ class ValidateRenderSingleCamera(pyblish.api.InstancePlugin,
RenderSettings.get_image_prefix_attr(renderer)
)
renderlayer = instance.data["renderlayer"]
if len(cameras) > 1:
if re.search(cls.R_CAMERA_TOKEN, file_prefix):
# if there is <Camera> token in prefix and we have more then
# 1 camera, all is ok.
return
cls.log.error("Multiple renderable cameras found for %s: %s " %
(instance.data["setMembers"], cameras))
return [instance.data["setMembers"]] + cameras
cls.log.error(
"Multiple renderable cameras found for %s: %s ",
renderlayer, ", ".join(cameras))
return [renderlayer] + cameras
elif len(cameras) < 1:
cls.log.error("No renderable cameras found for %s " %
instance.data["setMembers"])
return [instance.data["setMembers"]]
cls.log.error("No renderable cameras found for %s ", renderlayer)
return [renderlayer]
def get_description(self):
return inspect.cleandoc(
"""### Render Cameras Invalid
Your render cameras are misconfigured. You may have no render
camera set or have multiple cameras with a render filename
prefix that does not include the `<Camera>` token.
See the logs for more details about the cameras.
"""
)

View file

@ -6,11 +6,12 @@ import ayon_core.hosts.maya.api.action
from ayon_core.pipeline.publish import (
RepairAction,
ValidateMeshOrder,
PublishValidationError,
OptionalPyblishPluginMixin
)
class ValidateShapeRenderStats(pyblish.api.Validator,
class ValidateShapeRenderStats(pyblish.api.InstancePlugin,
OptionalPyblishPluginMixin):
"""Ensure all render stats are set to the default values."""
@ -20,7 +21,6 @@ class ValidateShapeRenderStats(pyblish.api.Validator,
label = 'Shape Default Render Stats'
actions = [ayon_core.hosts.maya.api.action.SelectInvalidAction,
RepairAction]
optional = True
defaults = {'castsShadows': 1,
'receiveShadows': 1,
@ -37,14 +37,13 @@ class ValidateShapeRenderStats(pyblish.api.Validator,
# It seems the "surfaceShape" and those derived from it have
# `renderStat` attributes.
shapes = cmds.ls(instance, long=True, type='surfaceShape')
invalid = []
invalid = set()
for shape in shapes:
_iteritems = getattr(cls.defaults, "iteritems", cls.defaults.items)
for attr, default_value in _iteritems():
for attr, default_value in cls.defaults.items():
if cmds.attributeQuery(attr, node=shape, exists=True):
value = cmds.getAttr('{}.{}'.format(shape, attr))
if value != default_value:
invalid.append(shape)
invalid.add(shape)
return invalid
@ -52,17 +51,36 @@ class ValidateShapeRenderStats(pyblish.api.Validator,
if not self.is_active(instance.data):
return
invalid = self.get_invalid(instance)
if not invalid:
return
if invalid:
raise ValueError("Shapes with non-default renderStats "
"found: {0}".format(invalid))
defaults_str = "\n".join(
"- {}: {}\n".format(key, value)
for key, value in self.defaults.items()
)
description = (
"## Shape Default Render Stats\n"
"Shapes are detected with non-default render stats.\n\n"
"To ensure a model's shapes behave like a shape would by default "
"we require the render stats to have not been altered in "
"the published models.\n\n"
"### How to repair?\n"
"You can reset the default values on the shapes by using the "
"repair action."
)
raise PublishValidationError(
"Shapes with non-default renderStats "
"found: {0}".format(", ".join(sorted(invalid))),
description=description,
detail="The expected default values "
"are:\n\n{}".format(defaults_str)
)
@classmethod
def repair(cls, instance):
for shape in cls.get_invalid(instance):
_iteritems = getattr(cls.defaults, "iteritems", cls.defaults.items)
for attr, default_value in _iteritems():
for attr, default_value in cls.defaults.items():
if cmds.attributeQuery(attr, node=shape, exists=True):
plug = '{0}.{1}'.format(shape, attr)
value = cmds.getAttr(plug)

View file

@ -12,7 +12,7 @@ from ayon_core.pipeline.publish import (
)
class ValidateShapeZero(pyblish.api.Validator,
class ValidateShapeZero(pyblish.api.InstancePlugin,
OptionalPyblishPluginMixin):
"""Shape components may not have any "tweak" values

View file

@ -1,5 +1,6 @@
from maya import cmds
import inspect
from maya import cmds
import pyblish.api
import ayon_core.hosts.maya.api.action
@ -10,7 +11,7 @@ from ayon_core.pipeline.publish import (
)
class ValidateTransformZero(pyblish.api.Validator,
class ValidateTransformZero(pyblish.api.InstancePlugin,
OptionalPyblishPluginMixin):
"""Transforms can't have any values
@ -57,7 +58,7 @@ class ValidateTransformZero(pyblish.api.Validator,
if ('_LOC' in transform) or ('_loc' in transform):
continue
mat = cmds.xform(transform, q=1, matrix=True, objectSpace=True)
if not all(abs(x-y) < cls._tolerance
if not all(abs(x - y) < cls._tolerance
for x, y in zip(cls._identity, mat)):
invalid.append(transform)
@ -69,14 +70,24 @@ class ValidateTransformZero(pyblish.api.Validator,
return
invalid = self.get_invalid(instance)
if invalid:
names = "<br>".join(
" - {}".format(node) for node in invalid
)
raise PublishValidationError(
title="Transform Zero",
description=self.get_description(),
message="The model publish allows no transformations. You must"
" <b>freeze transformations</b> to continue.<br><br>"
"Nodes found with transform values: "
"Nodes found with transform values:<br>"
"{0}".format(names))
@staticmethod
def get_description():
return inspect.cleandoc("""### Transform can't have any values
The model publish allows no transformations.
You must **freeze transformations** to continue.
""")

View file

@ -9,7 +9,7 @@ from ayon_core.pipeline.publish import (
)
class ValidateUniqueNames(pyblish.api.Validator,
class ValidateUniqueNames(pyblish.api.InstancePlugin,
OptionalPyblishPluginMixin):
"""transform names should be unique

View file

@ -10,7 +10,7 @@ from ayon_core.pipeline.publish import (
)
class ValidateYetiRigInputShapesInInstance(pyblish.api.Validator,
class ValidateYetiRigInputShapesInInstance(pyblish.api.InstancePlugin,
OptionalPyblishPluginMixin):
"""Validate if all input nodes are part of the instance's hierarchy"""

View file

@ -51,7 +51,7 @@ def assign_vrayproxy_shaders(vrayproxy, assignments):
index += 1
def vrayproxy_assign_look(vrayproxy, product_name="lookDefault"):
def vrayproxy_assign_look(vrayproxy, product_name="lookMain"):
# type: (str, str) -> None
"""Assign look to vray proxy.

View file

@ -1,4 +1,4 @@
from ayon_core.lib.applications import PreLaunchHook
from ayon_applications import PreLaunchHook
class PrelaunchNukeAssistHook(PreLaunchHook):

View file

@ -2,10 +2,10 @@ import nuke
import pyblish.api
class ExtractScriptSave(pyblish.api.Extractor):
class ExtractScriptSave(pyblish.api.InstancePlugin):
"""Save current Nuke workfile script"""
label = 'Script Save'
order = pyblish.api.Extractor.order - 0.1
order = pyblish.api.ExtractorOrder - 0.1
hosts = ['nuke']
def process(self, instance):

View file

@ -6,10 +6,7 @@ from ayon_core.lib import (
get_ayon_launcher_args,
is_using_ayon_console,
)
from ayon_core.lib.applications import (
PreLaunchHook,
LaunchTypes,
)
from ayon_applications import PreLaunchHook, LaunchTypes
from ayon_core.hosts.photoshop import get_launch_script_path

View file

@ -1,5 +1,5 @@
import os
from ayon_core.lib.applications import PreLaunchHook, LaunchTypes
from ayon_applications import PreLaunchHook, LaunchTypes
class PreLaunchResolveLastWorkfile(PreLaunchHook):

View file

@ -1,7 +1,7 @@
import os
from pathlib import Path
import platform
from ayon_core.lib.applications import PreLaunchHook, LaunchTypes
from ayon_applications import PreLaunchHook, LaunchTypes
from ayon_core.hosts.resolve.utils import setup

View file

@ -1,6 +1,6 @@
import os
from ayon_core.lib.applications import PreLaunchHook, LaunchTypes
from ayon_applications import PreLaunchHook, LaunchTypes
import ayon_core.hosts.resolve

View file

@ -18,7 +18,7 @@ class SubstanceLoadProjectMesh(load.LoaderPlugin):
"""Load mesh for project"""
product_types = {"*"}
representations = ["abc", "fbx", "obj", "gltf"]
representations = ["abc", "fbx", "obj", "gltf", "usd", "usda", "usdc"]
label = "Load mesh"
order = -10

View file

@ -1,5 +1,5 @@
from ayon_core.lib import get_ayon_launcher_args
from ayon_core.lib.applications import PreLaunchHook, LaunchTypes
from ayon_applications import PreLaunchHook, LaunchTypes
class TvpaintPrelaunchHook(PreLaunchHook):

View file

@ -25,8 +25,9 @@ from ayon_core.hosts.tvpaint.lib import (
)
class ExtractSequence(pyblish.api.Extractor):
class ExtractSequence(pyblish.api.InstancePlugin):
label = "Extract Sequence"
order = pyblish.api.ExtractorOrder
hosts = ["tvpaint"]
families = ["review", "render"]

View file

@ -9,7 +9,7 @@ from pathlib import Path
from qtpy import QtCore
from ayon_core import resources
from ayon_core.lib.applications import (
from ayon_applications import (
PreLaunchHook,
ApplicationLaunchFailed,
LaunchTypes,

View file

@ -120,22 +120,6 @@ from .transcoding import (
get_rescaled_command_arguments,
)
from .applications import (
ApplicationLaunchFailed,
ApplictionExecutableNotFound,
ApplicationNotFound,
ApplicationManager,
PreLaunchHook,
PostLaunchHook,
EnvironmentPrepData,
prepare_app_environments,
prepare_context_environments,
get_app_environments_for_context,
apply_project_environments_value
)
from .plugin_tools import (
prepare_template_data,
source_hash,
@ -231,18 +215,6 @@ __all__ = [
"convert_ffprobe_fps_to_float",
"get_rescaled_command_arguments",
"ApplicationLaunchFailed",
"ApplictionExecutableNotFound",
"ApplicationNotFound",
"ApplicationManager",
"PreLaunchHook",
"PostLaunchHook",
"EnvironmentPrepData",
"prepare_app_environments",
"prepare_context_environments",
"get_app_environments_for_context",
"apply_project_environments_value",
"compile_list_of_regexes",
"filter_profiles",

File diff suppressed because it is too large Load diff

View file

@ -1,15 +1,5 @@
# -*- coding: utf-8 -*-
"""Package helping with colorizing and formatting terminal output."""
# ::
# //. ... .. ///. //.
# ///\\\ \\\ \\ ///\\\ ///
# /// \\ \\\ \\ /// \\ /// //
# \\\ // \\\ // \\\ // \\\// ./
# \\\// \\\// \\\// \\\' //
# \\\ \\\ \\\ \\\//
# ''' ''' ''' '''
# ..---===[[ PyP3 Setup ]]===---...
#
import re
import time
import threading

View file

@ -45,7 +45,7 @@ ARRAY_TYPE_REGEX = re.compile(r"^(int|float|string)\[\d+\]$")
IMAGE_EXTENSIONS = {
".ani", ".anim", ".apng", ".art", ".bmp", ".bpg", ".bsave",
".cal", ".cin", ".cpc", ".cpt", ".dds", ".dpx", ".ecw", ".exr",
".cal", ".cin", ".cpc", ".cpt", ".dds", ".dng", ".dpx", ".ecw", ".exr",
".fits", ".flic", ".flif", ".fpx", ".gif", ".hdri", ".hevc",
".icer", ".icns", ".ico", ".cur", ".ics", ".ilbm", ".jbig", ".jbig2",
".jng", ".jpeg", ".jpeg-ls", ".jpeg-hdr", ".2000", ".jpg",

View file

@ -11,19 +11,17 @@ class ClockifyStart(LauncherAction):
order = 500
clockify_api = ClockifyAPI()
def is_compatible(self, session):
def is_compatible(self, selection):
"""Return whether the action is compatible with the session"""
if "AYON_TASK_NAME" in session:
return True
return False
return selection.is_task_selected
def process(self, session, **kwargs):
def process(self, selection, **kwargs):
self.clockify_api.set_api()
user_id = self.clockify_api.user_id
workspace_id = self.clockify_api.workspace_id
project_name = session["AYON_PROJECT_NAME"]
folder_path = session["AYON_FOLDER_PATH"]
task_name = session["AYON_TASK_NAME"]
project_name = selection.project_name
folder_path = selection.folder_path
task_name = selection.task_name
description = "/".join([folder_path.lstrip("/"), task_name])
# fetch folder entity

View file

@ -19,15 +19,18 @@ class ClockifySync(LauncherAction):
order = 500
clockify_api = ClockifyAPI()
def is_compatible(self, session):
def is_compatible(self, selection):
"""Check if there's some projects to sync"""
if selection.is_project_selected:
return True
try:
next(ayon_api.get_projects())
return True
except StopIteration:
return False
def process(self, session, **kwargs):
def process(self, selection, **kwargs):
self.clockify_api.set_api()
workspace_id = self.clockify_api.workspace_id
user_id = self.clockify_api.user_id
@ -37,10 +40,9 @@ class ClockifySync(LauncherAction):
raise ClockifyPermissionsCheckFailed(
"Current CLockify user is missing permissions for this action!"
)
project_name = session.get("AYON_PROJECT_NAME") or ""
if project_name.strip():
projects_to_sync = [ayon_api.get_project(project_name)]
if selection.is_project_selected:
projects_to_sync = [selection.project_entity]
else:
projects_to_sync = ayon_api.get_projects()

View file

@ -80,6 +80,8 @@ class AfterEffectsSubmitDeadline(
"FTRACK_API_KEY",
"FTRACK_API_USER",
"FTRACK_SERVER",
"AYON_BUNDLE_NAME",
"AYON_DEFAULT_SETTINGS_VARIANT",
"AYON_PROJECT_NAME",
"AYON_FOLDER_PATH",
"AYON_TASK_NAME",

View file

@ -102,6 +102,8 @@ class BlenderSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline,
"FTRACK_API_USER",
"FTRACK_SERVER",
"OPENPYPE_SG_USER",
"AYON_BUNDLE_NAME",
"AYON_DEFAULT_SETTINGS_VARIANT",
"AYON_PROJECT_NAME",
"AYON_FOLDER_PATH",
"AYON_TASK_NAME",

View file

@ -225,6 +225,8 @@ class FusionSubmitDeadline(
"FTRACK_API_KEY",
"FTRACK_API_USER",
"FTRACK_SERVER",
"AYON_BUNDLE_NAME",
"AYON_DEFAULT_SETTINGS_VARIANT",
"AYON_PROJECT_NAME",
"AYON_FOLDER_PATH",
"AYON_TASK_NAME",

View file

@ -273,6 +273,8 @@ class HarmonySubmitDeadline(
"FTRACK_API_KEY",
"FTRACK_API_USER",
"FTRACK_SERVER",
"AYON_BUNDLE_NAME",
"AYON_DEFAULT_SETTINGS_VARIANT",
"AYON_PROJECT_NAME",
"AYON_FOLDER_PATH",
"AYON_TASK_NAME",

View file

@ -106,12 +106,14 @@ class MaxSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline,
"FTRACK_API_USER",
"FTRACK_SERVER",
"OPENPYPE_SG_USER",
"AYON_BUNDLE_NAME",
"AYON_DEFAULT_SETTINGS_VARIANT",
"AYON_PROJECT_NAME",
"AYON_FOLDER_PATH",
"AYON_TASK_NAME",
"AYON_WORKDIR",
"AYON_APP_NAME",
"IS_TEST"
"IS_TEST",
]
environment = {

View file

@ -207,6 +207,8 @@ class MayaSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline,
"FTRACK_API_USER",
"FTRACK_SERVER",
"OPENPYPE_SG_USER",
"AYON_BUNDLE_NAME",
"AYON_DEFAULT_SETTINGS_VARIANT",
"AYON_PROJECT_NAME",
"AYON_FOLDER_PATH",
"AYON_TASK_NAME",

View file

@ -376,6 +376,8 @@ class NukeSubmitDeadline(pyblish.api.InstancePlugin,
keys = [
"PYTHONPATH",
"PATH",
"AYON_BUNDLE_NAME",
"AYON_DEFAULT_SETTINGS_VARIANT",
"AYON_PROJECT_NAME",
"AYON_FOLDER_PATH",
"AYON_TASK_NAME",
@ -388,7 +390,6 @@ class NukeSubmitDeadline(pyblish.api.InstancePlugin,
"TOOL_ENV",
"FOUNDRY_LICENSE",
"OPENPYPE_SG_USER",
"AYON_BUNDLE_NAME",
]
# add allowed keys from preset if any

View file

@ -133,6 +133,9 @@ class ProcessSubmittedCacheJobOnFarm(pyblish.api.InstancePlugin,
"AYON_RENDER_JOB": "0",
"AYON_REMOTE_PUBLISH": "0",
"AYON_BUNDLE_NAME": os.environ["AYON_BUNDLE_NAME"],
"AYON_DEFAULT_SETTINGS_VARIANT": (
os.environ["AYON_DEFAULT_SETTINGS_VARIANT"]
),
}
# add environments from self.environ_keys

View file

@ -210,6 +210,9 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin,
"AYON_RENDER_JOB": "0",
"AYON_REMOTE_PUBLISH": "0",
"AYON_BUNDLE_NAME": os.environ["AYON_BUNDLE_NAME"],
"AYON_DEFAULT_SETTINGS_VARIANT": (
os.environ["AYON_DEFAULT_SETTINGS_VARIANT"]
),
}
# add environments from self.environ_keys

View file

@ -13,7 +13,7 @@ from Deadline.Scripting import (
FileUtils,
DirectoryUtils,
)
__version__ = "1.0.1"
__version__ = "1.1.0"
VERSION_REGEX = re.compile(
r"(?P<major>0|[1-9]\d*)"
r"\.(?P<minor>0|[1-9]\d*)"
@ -463,19 +463,13 @@ def inject_ayon_environment(deadlinePlugin):
export_url = os.path.join(tempfile.gettempdir(), temp_file_name)
print(">>> Temporary path: {}".format(export_url))
args = [
"--headless",
"extractenvironments",
export_url
]
add_kwargs = {
"envgroup": "farm",
}
# Support backwards compatible keys
for key, env_keys in (
("project", ["AYON_PROJECT_NAME", "AVALON_PROJECT"]),
("asset", ["AYON_FOLDER_PATH", "AVALON_ASSET"]),
("folder", ["AYON_FOLDER_PATH", "AVALON_ASSET"]),
("task", ["AYON_TASK_NAME", "AVALON_TASK"]),
("app", ["AYON_APP_NAME", "AVALON_APP_NAME"]),
):
@ -486,18 +480,37 @@ def inject_ayon_environment(deadlinePlugin):
break
add_kwargs[key] = value
if job.GetJobEnvironmentKeyValue("IS_TEST"):
args.append("--automatic-tests")
if all(add_kwargs.values()):
for key, value in add_kwargs.items():
args.extend(["--{}".format(key), value])
else:
if not all(add_kwargs.values()):
raise RuntimeError((
"Missing required env vars: AYON_PROJECT_NAME,"
" AYON_FOLDER_PATH, AYON_TASK_NAME, AYON_APP_NAME"
))
# Use applications addon arguments
# TODO validate if applications addon should be used
args = [
"--headless",
"addon",
"applications",
"extractenvironments",
export_url
]
# Backwards compatibility for older versions
legacy_args = [
"--headless",
"extractenvironments",
export_url
]
if job.GetJobEnvironmentKeyValue("IS_TEST"):
args.append("--automatic-tests")
for key, value in add_kwargs.items():
args.extend(["--{}".format(key), value])
# Legacy arguments expect '--asset' instead of '--folder'
if key == "folder":
key = "asset"
legacy_args.extend(["--{}".format(key), value])
environment = {
"AYON_SERVER_URL": ayon_server_url,
"AYON_API_KEY": ayon_api_key,
@ -516,9 +529,18 @@ def inject_ayon_environment(deadlinePlugin):
)
if process_exitcode != 0:
raise RuntimeError(
"Failed to run Ayon process to extract environments."
print(
"Failed to run AYON process to extract environments. Trying"
" to use legacy arguments."
)
legacy_args_str = subprocess.list2cmdline(legacy_args)
process_exitcode = deadlinePlugin.RunProcess(
exe, legacy_args_str, os.path.dirname(exe), -1
)
if process_exitcode != 0:
raise RuntimeError(
"Failed to run AYON process to extract environments."
)
print(">>> Loading file ...")
with open(export_url) as fp:

View file

@ -168,7 +168,7 @@ class JobQueueAddon(AYONAddon):
@classmethod
def start_worker(cls, app_name, server_url=None):
import requests
from ayon_core.lib import ApplicationManager
from ayon_applications import ApplicationManager
if not server_url:
server_url = cls.get_server_url_from_settings()

View file

@ -308,31 +308,45 @@ class BaseCreateRoyalRenderJob(pyblish.api.InstancePlugin,
export_url = os.path.join(tempfile.gettempdir(), temp_file_name)
print(">>> Temporary path: {}".format(export_url))
args = [
"--headless",
"extractenvironments",
export_url
]
anatomy_data = instance.context.data["anatomyData"]
addons_manager = instance.context.data["ayonAddonsManager"]
applications_addon = addons_manager.get_enabled_addon("applications")
folder_key = "folder"
if applications_addon is None:
# Use 'asset' when applications addon command is not used
folder_key = "asset"
add_kwargs = {
"project": anatomy_data["project"]["name"],
"asset": instance.context.data["folderPath"],
folder_key: instance.context.data["folderPath"],
"task": anatomy_data["task"]["name"],
"app": instance.context.data.get("appName"),
"envgroup": "farm"
}
if os.getenv('IS_TEST'):
args.append("--automatic-tests")
if not all(add_kwargs.values()):
raise RuntimeError((
"Missing required env vars: AYON_PROJECT_NAME, AYON_FOLDER_PATH,"
" AYON_TASK_NAME, AYON_APP_NAME"
))
args = ["--headless"]
# Use applications addon to extract environments
# NOTE this is for backwards compatibility, the global command
# will be removed in future and only applications addon command
# should be used.
if applications_addon is not None:
args.extend(["addon", "applications"])
args.extend([
"extractenvironments",
export_url
])
if os.getenv('IS_TEST'):
args.append("--automatic-tests")
for key, value in add_kwargs.items():
args.extend([f"--{key}", value])
self.log.debug("Executing: {}".format(" ".join(args)))

View file

@ -1,4 +1,4 @@
from ayon_core.lib.applications import PostLaunchHook, LaunchTypes
from ayon_applications import PostLaunchHook, LaunchTypes
class PostStartTimerHook(PostLaunchHook):

View file

@ -1,4 +1,8 @@
import logging
import warnings
import ayon_api
from ayon_core.pipeline.plugin_discover import (
discover,
register_plugin,
@ -10,6 +14,288 @@ from ayon_core.pipeline.plugin_discover import (
from .load.utils import get_representation_path_from_context
class LauncherActionSelection:
"""Object helper to pass selection to actions.
Object support backwards compatibility for 'session' from OpenPype where
environment variable keys were used to define selection.
Args:
project_name (str): Selected project name.
folder_id (str): Selected folder id.
task_id (str): Selected task id.
folder_path (Optional[str]): Selected folder path.
task_name (Optional[str]): Selected task name.
project_entity (Optional[dict[str, Any]]): Project entity.
folder_entity (Optional[dict[str, Any]]): Folder entity.
task_entity (Optional[dict[str, Any]]): Task entity.
"""
def __init__(
self,
project_name,
folder_id,
task_id,
folder_path=None,
task_name=None,
project_entity=None,
folder_entity=None,
task_entity=None
):
self._project_name = project_name
self._folder_id = folder_id
self._task_id = task_id
self._folder_path = folder_path
self._task_name = task_name
self._project_entity = project_entity
self._folder_entity = folder_entity
self._task_entity = task_entity
def __getitem__(self, key):
warnings.warn(
(
"Using deprecated access to selection data. Please use"
" attributes and methods"
" defined by 'LauncherActionSelection'."
),
category=DeprecationWarning
)
if key in {"AYON_PROJECT_NAME", "AVALON_PROJECT"}:
return self.project_name
if key in {"AYON_FOLDER_PATH", "AVALON_ASSET"}:
return self.folder_path
if key in {"AYON_TASK_NAME", "AVALON_TASK"}:
return self.task_name
raise KeyError(f"Key: {key} not found")
def __iter__(self):
for key in self.keys():
yield key
def __contains__(self, key):
warnings.warn(
(
"Using deprecated access to selection data. Please use"
" attributes and methods"
" defined by 'LauncherActionSelection'."
),
category=DeprecationWarning
)
# Fake missing keys check for backwards compatibility
if key in {
"AYON_PROJECT_NAME",
"AVALON_PROJECT",
}:
return self._project_name is not None
if key in {
"AYON_FOLDER_PATH",
"AVALON_ASSET",
}:
return self._folder_id is not None
if key in {
"AYON_TASK_NAME",
"AVALON_TASK",
}:
return self._task_id is not None
return False
def get(self, key, default=None):
"""
Deprecated:
Added for backwards compatibility with older actions.
"""
warnings.warn(
(
"Using deprecated access to selection data. Please use"
" attributes and methods"
" defined by 'LauncherActionSelection'."
),
category=DeprecationWarning
)
try:
return self[key]
except KeyError:
return default
def items(self):
"""
Deprecated:
Added for backwards compatibility with older actions.
"""
for key, value in (
("AYON_PROJECT_NAME", self.project_name),
("AYON_FOLDER_PATH", self.folder_path),
("AYON_TASK_NAME", self.task_name),
):
if value is not None:
yield (key, value)
def keys(self):
"""
Deprecated:
Added for backwards compatibility with older actions.
"""
for key, _ in self.items():
yield key
def values(self):
"""
Deprecated:
Added for backwards compatibility with older actions.
"""
for _, value in self.items():
yield value
def get_project_name(self):
"""Selected project name.
Returns:
Union[str, None]: Selected project name.
"""
return self._project_name
def get_folder_id(self):
"""Selected folder id.
Returns:
Union[str, None]: Selected folder id.
"""
return self._folder_id
def get_folder_path(self):
"""Selected folder path.
Returns:
Union[str, None]: Selected folder path.
"""
if self._folder_id is None:
return None
if self._folder_path is None:
self._folder_path = self.folder_entity["path"]
return self._folder_path
def get_task_id(self):
"""Selected task id.
Returns:
Union[str, None]: Selected task id.
"""
return self._task_id
def get_task_name(self):
"""Selected task name.
Returns:
Union[str, None]: Selected task name.
"""
if self._task_id is None:
return None
if self._task_name is None:
self._task_name = self.task_entity["name"]
return self._task_name
def get_project_entity(self):
"""Project entity for the selection.
Returns:
Union[dict[str, Any], None]: Project entity.
"""
if self._project_name is None:
return None
if self._project_entity is None:
self._project_entity = ayon_api.get_project(self._project_name)
return self._project_entity
def get_folder_entity(self):
"""Folder entity for the selection.
Returns:
Union[dict[str, Any], None]: Folder entity.
"""
if self._project_name is None or self._folder_id is None:
return None
if self._folder_entity is None:
self._folder_entity = ayon_api.get_folder_by_id(
self._project_name, self._folder_id
)
return self._folder_entity
def get_task_entity(self):
"""Task entity for the selection.
Returns:
Union[dict[str, Any], None]: Task entity.
"""
if (
self._project_name is None
or self._task_id is None
):
return None
if self._task_entity is None:
self._task_entity = ayon_api.get_task_by_id(
self._project_name, self._task_id
)
return self._task_entity
@property
def is_project_selected(self):
"""Return whether a project is selected.
Returns:
bool: Whether a project is selected.
"""
return self._project_name is not None
@property
def is_folder_selected(self):
"""Return whether a folder is selected.
Returns:
bool: Whether a folder is selected.
"""
return self._folder_id is not None
@property
def is_task_selected(self):
"""Return whether a task is selected.
Returns:
bool: Whether a task is selected.
"""
return self._task_id is not None
project_name = property(get_project_name)
folder_id = property(get_folder_id)
task_id = property(get_task_id)
folder_path = property(get_folder_path)
task_name = property(get_task_name)
project_entity = property(get_project_entity)
folder_entity = property(get_folder_entity)
task_entity = property(get_task_entity)
class LauncherAction(object):
"""A custom action available"""
name = None
@ -21,17 +307,23 @@ class LauncherAction(object):
log = logging.getLogger("LauncherAction")
log.propagate = True
def is_compatible(self, session):
def is_compatible(self, selection):
"""Return whether the class is compatible with the Session.
Args:
session (dict[str, Union[str, None]]): Session data with
AYON_PROJECT_NAME, AYON_FOLDER_PATH and AYON_TASK_NAME.
"""
selection (LauncherActionSelection): Data with selection.
"""
return True
def process(self, session, **kwargs):
def process(self, selection, **kwargs):
"""Process the action.
Args:
selection (LauncherActionSelection): Data with selection.
**kwargs: Additional arguments.
"""
pass

View file

@ -97,8 +97,8 @@ def install_host(host):
"""Install `host` into the running Python session.
Args:
host (module): A Python module containing the Avalon
avalon host-interface.
host (HostBase): A host interface object.
"""
global _is_installed
@ -154,6 +154,13 @@ def install_host(host):
def install_ayon_plugins(project_name=None, host_name=None):
"""Install AYON core plugins and make sure the core is initialized.
Args:
project_name (Optional[str]): Name of project to install plugins for.
host_name (Optional[str]): Name of host to install plugins for.
"""
# Make sure global AYON connection has set site id and version
# - this is necessary if 'install_host' is not called
initialize_ayon_connection()
@ -223,6 +230,12 @@ def install_ayon_plugins(project_name=None, host_name=None):
def install_openpype_plugins(project_name=None, host_name=None):
"""Install AYON core plugins and make sure the core is initialized.
Deprecated:
Use `install_ayon_plugins` instead.
"""
install_ayon_plugins(project_name, host_name)
@ -281,47 +294,6 @@ def deregister_host():
_registered_host["_"] = None
def debug_host():
"""A debug host, useful to debugging features that depend on a host"""
host = types.ModuleType("debugHost")
def ls():
containers = [
{
"representation": "ee-ft-a-uuid1",
"schema": "openpype:container-1.0",
"name": "Bruce01",
"objectName": "Bruce01_node",
"namespace": "_bruce01_",
"version": 3,
},
{
"representation": "aa-bc-s-uuid2",
"schema": "openpype:container-1.0",
"name": "Bruce02",
"objectName": "Bruce01_node",
"namespace": "_bruce02_",
"version": 2,
}
]
for container in containers:
yield container
host.__dict__.update({
"ls": ls,
"open_file": lambda fname: None,
"save_file": lambda fname: None,
"current_file": lambda: os.path.expanduser("~/temp.txt"),
"has_unsaved_changes": lambda: False,
"work_root": lambda: os.path.expanduser("~/temp"),
"file_extensions": lambda: ["txt"],
})
return host
def get_current_host_name():
"""Current host name.
@ -347,7 +319,8 @@ def get_global_context():
Use 'get_current_context' to make sure you'll get current host integration
context info.
Example:
Example::
{
"project_name": "Commercial",
"folder_path": "Bunny",
@ -515,88 +488,13 @@ def get_current_context_template_data(settings=None):
)
def get_workdir_from_session(session=None, template_key=None):
"""Template data for template fill from session keys.
Args:
session (Union[Dict[str, str], None]): The Session to use. If not
provided use the currently active global Session.
template_key (str): Prepared template key from which workdir is
calculated.
Returns:
str: Workdir path.
"""
if session is not None:
project_name = session["AYON_PROJECT_NAME"]
host_name = session["AYON_HOST_NAME"]
else:
project_name = get_current_project_name()
host_name = get_current_host_name()
template_data = get_template_data_from_session(session)
if not template_key:
task_type = template_data["task"]["type"]
template_key = get_workfile_template_key(
project_name,
task_type,
host_name,
)
anatomy = Anatomy(project_name)
template_obj = anatomy.get_template_item("work", template_key, "directory")
path = template_obj.format_strict(template_data)
if path:
path = os.path.normpath(path)
return path
def get_custom_workfile_template_from_session(
session=None, project_settings=None
):
"""Filter and fill workfile template profiles by current context.
This function cab be used only inside host where context is set.
Args:
session (Optional[Dict[str, str]]): Session from which are taken
data.
project_settings(Optional[Dict[str, Any]]): Project settings.
Returns:
str: Path to template or None if none of profiles match current
context. (Existence of formatted path is not validated.)
"""
if session is not None:
project_name = session["AYON_PROJECT_NAME"]
folder_path = session["AYON_FOLDER_PATH"]
task_name = session["AYON_TASK_NAME"]
host_name = session["AYON_HOST_NAME"]
else:
context = get_current_context()
project_name = context["project_name"]
folder_path = context["folder_path"]
task_name = context["task_name"]
host_name = get_current_host_name()
return get_custom_workfile_template_by_string_context(
project_name,
folder_path,
task_name,
host_name,
project_settings=project_settings
)
def get_current_context_custom_workfile_template(project_settings=None):
"""Filter and fill workfile template profiles by current context.
This function can be used only inside host where context is set.
This function can be used only inside host where current context is set.
Args:
project_settings(Optional[Dict[str, Any]]): Project settings.
project_settings (Optional[dict[str, Any]]): Project settings
Returns:
str: Path to template or None if none of profiles match current

View file

@ -8,7 +8,7 @@ Discovers Creator plugins to be able create new instances and convert existing i
Publish plugins are loaded because they can also define attributes definitions. These are less product type specific To be able define attributes Publish plugin must inherit from `AYONPyblishPluginMixin` and must override `get_attribute_defs` class method which must return list of attribute definitions. Values of publish plugin definitions are stored per plugin name under `publish_attributes`. Also can override `convert_attribute_values` class method which gives ability to modify values on instance before are used in CreatedInstance. Method `convert_attribute_values` can be also used without `get_attribute_defs` to modify values when changing compatibility (remove metadata from instance because are irrelevant).
Possible attribute definitions can be found in `openpype/pipeline/lib/attribute_definitions.py`.
Possible attribute definitions can be found in `ayon_core/lib/attribute_definitions.py`.
Except creating and removing instances are all changes not automatically propagated to host context (scene/workfile/...) to propagate changes call `save_changes` which trigger update of all instances in context using Creators implementation.

Some files were not shown because too many files have changed in this diff Show more