mirror of
https://github.com/ynput/ayon-core.git
synced 2025-12-25 05:14:40 +01:00
Merge branch 'develop' into enhancement/launcher_action_to_create_folder_structure
This commit is contained in:
commit
f0cdeca61d
171 changed files with 13314 additions and 4977 deletions
14
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
14
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
|
|
@ -35,6 +35,20 @@ body:
|
|||
label: Version
|
||||
description: What version are you running? Look to AYON Tray
|
||||
options:
|
||||
- 1.0.14
|
||||
- 1.0.13
|
||||
- 1.0.12
|
||||
- 1.0.11
|
||||
- 1.0.10
|
||||
- 1.0.9
|
||||
- 1.0.8
|
||||
- 1.0.7
|
||||
- 1.0.6
|
||||
- 1.0.5
|
||||
- 1.0.4
|
||||
- 1.0.3
|
||||
- 1.0.2
|
||||
- 1.0.1
|
||||
- 1.0.0
|
||||
- 0.4.4
|
||||
- 0.4.3
|
||||
|
|
|
|||
48
.github/workflows/assign_pr_to_project.yml
vendored
Normal file
48
.github/workflows/assign_pr_to_project.yml
vendored
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
name: 🔸Auto assign pr
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
pr_number:
|
||||
type: string
|
||||
description: "Run workflow for this PR number"
|
||||
required: true
|
||||
project_id:
|
||||
type: string
|
||||
description: "Github Project Number"
|
||||
required: true
|
||||
default: "16"
|
||||
pull_request:
|
||||
types:
|
||||
- opened
|
||||
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
|
||||
jobs:
|
||||
get-pr-repo:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
pr_repo_name: ${{ steps.get-repo-name.outputs.repo_name || github.event.pull_request.head.repo.full_name }}
|
||||
|
||||
# INFO `github.event.pull_request.head.repo.full_name` is not available on manual triggered (dispatched) runs
|
||||
steps:
|
||||
- name: Get PR repo name
|
||||
if: ${{ github.event_name == 'workflow_dispatch' }}
|
||||
id: get-repo-name
|
||||
run: |
|
||||
repo_name=$(gh pr view ${{ inputs.pr_number }} --json headRepository,headRepositoryOwner --repo ${{ github.repository }} | jq -r '.headRepositoryOwner.login + "/" + .headRepository.name')
|
||||
echo "repo_name=$repo_name" >> $GITHUB_OUTPUT
|
||||
|
||||
auto-assign-pr:
|
||||
needs:
|
||||
- get-pr-repo
|
||||
if: ${{ needs.get-pr-repo.outputs.pr_repo_name == github.repository }}
|
||||
uses: ynput/ops-repo-automation/.github/workflows/pr_to_project.yml@main
|
||||
with:
|
||||
repo: "${{ github.repository }}"
|
||||
project_id: ${{ inputs.project_id != '' && fromJSON(inputs.project_id) || 16 }}
|
||||
pull_request_number: ${{ github.event.pull_request.number || fromJSON(inputs.pr_number) }}
|
||||
secrets:
|
||||
# INFO fallback to default `github.token` is required for PRs from forks
|
||||
# INFO organization secrets won't be available to forks
|
||||
token: ${{ secrets.YNPUT_BOT_TOKEN || github.token}}
|
||||
4
.github/workflows/pr_linting.yml
vendored
4
.github/workflows/pr_linting.yml
vendored
|
|
@ -21,4 +21,6 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: chartboost/ruff-action@v1
|
||||
- uses: astral-sh/ruff-action@v1
|
||||
with:
|
||||
changed-files: "true"
|
||||
|
|
|
|||
13
.github/workflows/release_trigger.yml
vendored
13
.github/workflows/release_trigger.yml
vendored
|
|
@ -2,10 +2,23 @@ name: 🚀 Release Trigger
|
|||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
draft:
|
||||
type: boolean
|
||||
description: "Create Release Draft"
|
||||
required: false
|
||||
default: false
|
||||
release_overwrite:
|
||||
type: string
|
||||
description: "Set Version Release Tag"
|
||||
required: false
|
||||
|
||||
jobs:
|
||||
call-release-trigger:
|
||||
uses: ynput/ops-repo-automation/.github/workflows/release_trigger.yml@main
|
||||
with:
|
||||
draft: ${{ inputs.draft }}
|
||||
release_overwrite: ${{ inputs.release_overwrite }}
|
||||
secrets:
|
||||
token: ${{ secrets.YNPUT_BOT_TOKEN }}
|
||||
email: ${{ secrets.CI_EMAIL }}
|
||||
|
|
|
|||
16
.github/workflows/upload_to_ynput_cloud.yml
vendored
Normal file
16
.github/workflows/upload_to_ynput_cloud.yml
vendored
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
name: 📤 Upload to Ynput Cloud
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
jobs:
|
||||
call-upload-to-ynput-cloud:
|
||||
uses: ynput/ops-repo-automation/.github/workflows/upload_to_ynput_cloud.yml@main
|
||||
secrets:
|
||||
CI_EMAIL: ${{ secrets.CI_EMAIL }}
|
||||
CI_USER: ${{ secrets.CI_USER }}
|
||||
YNPUT_BOT_TOKEN: ${{ secrets.YNPUT_BOT_TOKEN }}
|
||||
YNPUT_CLOUD_URL: ${{ secrets.YNPUT_CLOUD_URL }}
|
||||
YNPUT_CLOUD_TOKEN: ${{ secrets.YNPUT_CLOUD_TOKEN }}
|
||||
18
.github/workflows/validate_pr_labels.yml
vendored
Normal file
18
.github/workflows/validate_pr_labels.yml
vendored
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
name: 🔎 Validate PR Labels
|
||||
on:
|
||||
pull_request:
|
||||
types:
|
||||
- opened
|
||||
- edited
|
||||
- labeled
|
||||
- unlabeled
|
||||
|
||||
jobs:
|
||||
validate-type-label:
|
||||
uses: ynput/ops-repo-automation/.github/workflows/validate_pr_labels.yml@main
|
||||
with:
|
||||
repo: "${{ github.repository }}"
|
||||
pull_request_number: ${{ github.event.pull_request.number }}
|
||||
query_prefix: "type: "
|
||||
secrets:
|
||||
token: ${{ secrets.YNPUT_BOT_TOKEN }}
|
||||
|
|
@ -370,67 +370,11 @@ def _load_ayon_addons(log):
|
|||
return all_addon_modules
|
||||
|
||||
|
||||
def _load_addons_in_core(log):
|
||||
# Add current directory at first place
|
||||
# - has small differences in import logic
|
||||
addon_modules = []
|
||||
modules_dir = os.path.join(AYON_CORE_ROOT, "modules")
|
||||
if not os.path.exists(modules_dir):
|
||||
log.warning(
|
||||
f"Could not find path when loading AYON addons \"{modules_dir}\""
|
||||
)
|
||||
return addon_modules
|
||||
|
||||
ignored_filenames = IGNORED_FILENAMES | IGNORED_DEFAULT_FILENAMES
|
||||
for filename in os.listdir(modules_dir):
|
||||
# Ignore filenames
|
||||
if filename in ignored_filenames:
|
||||
continue
|
||||
|
||||
fullpath = os.path.join(modules_dir, filename)
|
||||
basename, ext = os.path.splitext(filename)
|
||||
|
||||
# Validations
|
||||
if os.path.isdir(fullpath):
|
||||
# Check existence of init file
|
||||
init_path = os.path.join(fullpath, "__init__.py")
|
||||
if not os.path.exists(init_path):
|
||||
log.debug((
|
||||
"Addon directory does not contain __init__.py"
|
||||
f" file {fullpath}"
|
||||
))
|
||||
continue
|
||||
|
||||
elif ext != ".py":
|
||||
continue
|
||||
|
||||
# TODO add more logic how to define if folder is addon or not
|
||||
# - check manifest and content of manifest
|
||||
try:
|
||||
# Don't import dynamically current directory modules
|
||||
import_str = f"ayon_core.modules.{basename}"
|
||||
default_module = __import__(import_str, fromlist=("", ))
|
||||
addon_modules.append(default_module)
|
||||
|
||||
except Exception:
|
||||
log.error(
|
||||
f"Failed to import in-core addon '{basename}'.",
|
||||
exc_info=True
|
||||
)
|
||||
return addon_modules
|
||||
|
||||
|
||||
def _load_addons():
|
||||
log = Logger.get_logger("AddonsLoader")
|
||||
|
||||
addon_modules = _load_ayon_addons(log)
|
||||
# All addon in 'modules' folder are tray actions and should be moved
|
||||
# to tray tool.
|
||||
# TODO remove
|
||||
addon_modules.extend(_load_addons_in_core(log))
|
||||
|
||||
# Store modules to local cache
|
||||
_LoadCache.addon_modules = addon_modules
|
||||
_LoadCache.addon_modules = _load_ayon_addons(log)
|
||||
|
||||
|
||||
class AYONAddon(ABC):
|
||||
|
|
@ -535,8 +479,8 @@ class AYONAddon(ABC):
|
|||
Implementation of this method is optional.
|
||||
|
||||
Note:
|
||||
The logic can be similar to logic in tray, but tray does not require
|
||||
to be logged in.
|
||||
The logic can be similar to logic in tray, but tray does not
|
||||
require to be logged in.
|
||||
|
||||
Args:
|
||||
process_context (ProcessContext): Context of child
|
||||
|
|
@ -950,6 +894,21 @@ class AddonsManager:
|
|||
output.extend(paths)
|
||||
return output
|
||||
|
||||
def collect_launcher_action_paths(self):
|
||||
"""Helper to collect launcher action paths from addons.
|
||||
|
||||
Returns:
|
||||
list: List of paths to launcher actions.
|
||||
|
||||
"""
|
||||
output = self._collect_plugin_paths(
|
||||
"get_launcher_action_paths"
|
||||
)
|
||||
# Add default core actions
|
||||
actions_dir = os.path.join(AYON_CORE_ROOT, "plugins", "actions")
|
||||
output.insert(0, actions_dir)
|
||||
return output
|
||||
|
||||
def collect_create_plugin_paths(self, host_name):
|
||||
"""Helper to collect creator plugin paths from addons.
|
||||
|
||||
|
|
|
|||
|
|
@ -54,6 +54,13 @@ class IPluginPaths(AYONInterface):
|
|||
paths = [paths]
|
||||
return paths
|
||||
|
||||
def get_launcher_action_paths(self):
|
||||
"""Receive launcher actions paths.
|
||||
|
||||
Give addons ability to add launcher actions paths.
|
||||
"""
|
||||
return self._get_plugin_paths_by_type("actions")
|
||||
|
||||
def get_create_plugin_paths(self, host_name):
|
||||
"""Receive create plugin paths.
|
||||
|
||||
|
|
@ -125,6 +132,7 @@ class ITrayAddon(AYONInterface):
|
|||
|
||||
tray_initialized = False
|
||||
_tray_manager = None
|
||||
_admin_submenu = None
|
||||
|
||||
@abstractmethod
|
||||
def tray_init(self):
|
||||
|
|
@ -198,6 +206,27 @@ class ITrayAddon(AYONInterface):
|
|||
if hasattr(self.manager, "add_doubleclick_callback"):
|
||||
self.manager.add_doubleclick_callback(self, callback)
|
||||
|
||||
@staticmethod
|
||||
def admin_submenu(tray_menu):
|
||||
if ITrayAddon._admin_submenu is None:
|
||||
from qtpy import QtWidgets
|
||||
|
||||
admin_submenu = QtWidgets.QMenu("Admin", tray_menu)
|
||||
admin_submenu.menuAction().setVisible(False)
|
||||
ITrayAddon._admin_submenu = admin_submenu
|
||||
return ITrayAddon._admin_submenu
|
||||
|
||||
@staticmethod
|
||||
def add_action_to_admin_submenu(label, tray_menu):
|
||||
from qtpy import QtWidgets
|
||||
|
||||
menu = ITrayAddon.admin_submenu(tray_menu)
|
||||
action = QtWidgets.QAction(label, menu)
|
||||
menu.addAction(action)
|
||||
if not menu.menuAction().isVisible():
|
||||
menu.menuAction().setVisible(True)
|
||||
return action
|
||||
|
||||
|
||||
class ITrayAction(ITrayAddon):
|
||||
"""Implementation of Tray action.
|
||||
|
|
@ -211,7 +240,6 @@ class ITrayAction(ITrayAddon):
|
|||
"""
|
||||
|
||||
admin_action = False
|
||||
_admin_submenu = None
|
||||
_action_item = None
|
||||
|
||||
@property
|
||||
|
|
@ -229,12 +257,7 @@ class ITrayAction(ITrayAddon):
|
|||
from qtpy import QtWidgets
|
||||
|
||||
if self.admin_action:
|
||||
menu = self.admin_submenu(tray_menu)
|
||||
action = QtWidgets.QAction(self.label, menu)
|
||||
menu.addAction(action)
|
||||
if not menu.menuAction().isVisible():
|
||||
menu.menuAction().setVisible(True)
|
||||
|
||||
action = self.add_action_to_admin_submenu(self.label, tray_menu)
|
||||
else:
|
||||
action = QtWidgets.QAction(self.label, tray_menu)
|
||||
tray_menu.addAction(action)
|
||||
|
|
@ -248,16 +271,6 @@ class ITrayAction(ITrayAddon):
|
|||
def tray_exit(self):
|
||||
return
|
||||
|
||||
@staticmethod
|
||||
def admin_submenu(tray_menu):
|
||||
if ITrayAction._admin_submenu is None:
|
||||
from qtpy import QtWidgets
|
||||
|
||||
admin_submenu = QtWidgets.QMenu("Admin", tray_menu)
|
||||
admin_submenu.menuAction().setVisible(False)
|
||||
ITrayAction._admin_submenu = admin_submenu
|
||||
return ITrayAction._admin_submenu
|
||||
|
||||
|
||||
class ITrayService(ITrayAddon):
|
||||
# Module's property
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ from pathlib import Path
|
|||
import warnings
|
||||
|
||||
import click
|
||||
import acre
|
||||
|
||||
from ayon_core import AYON_CORE_ROOT
|
||||
from ayon_core.addon import AddonsManager
|
||||
|
|
@ -18,6 +17,11 @@ from ayon_core.lib import (
|
|||
is_running_from_build,
|
||||
Logger,
|
||||
)
|
||||
from ayon_core.lib.env_tools import (
|
||||
parse_env_variables_structure,
|
||||
compute_env_variables_structure,
|
||||
merge_env_variables,
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
|
@ -146,7 +150,8 @@ def publish_report_viewer():
|
|||
@main_cli.command()
|
||||
@click.argument("output_path")
|
||||
@click.option("--project", help="Define project context")
|
||||
@click.option("--folder", help="Define folder in project (project must be set)")
|
||||
@click.option(
|
||||
"--folder", help="Define folder in project (project must be set)")
|
||||
@click.option(
|
||||
"--strict",
|
||||
is_flag=True,
|
||||
|
|
@ -234,19 +239,15 @@ def version(build):
|
|||
|
||||
def _set_global_environments() -> None:
|
||||
"""Set global AYON environments."""
|
||||
general_env = get_general_environments()
|
||||
# First resolve general environment
|
||||
general_env = parse_env_variables_structure(get_general_environments())
|
||||
|
||||
# first resolve general environment because merge doesn't expect
|
||||
# values to be list.
|
||||
# TODO: switch to AYON environment functions
|
||||
merged_env = acre.merge(
|
||||
acre.compute(acre.parse(general_env), cleanup=False),
|
||||
# Merge environments with current environments and update values
|
||||
merged_env = merge_env_variables(
|
||||
compute_env_variables_structure(general_env),
|
||||
dict(os.environ)
|
||||
)
|
||||
env = acre.compute(
|
||||
merged_env,
|
||||
cleanup=False
|
||||
)
|
||||
env = compute_env_variables_structure(merged_env)
|
||||
os.environ.clear()
|
||||
os.environ.update(env)
|
||||
|
||||
|
|
@ -262,8 +263,8 @@ def _set_addons_environments(addons_manager):
|
|||
|
||||
# Merge environments with current environments and update values
|
||||
if module_envs := addons_manager.collect_global_environments():
|
||||
parsed_envs = acre.parse(module_envs)
|
||||
env = acre.merge(parsed_envs, dict(os.environ))
|
||||
parsed_envs = parse_env_variables_structure(module_envs)
|
||||
env = merge_env_variables(parsed_envs, dict(os.environ))
|
||||
os.environ.clear()
|
||||
os.environ.update(env)
|
||||
|
||||
|
|
|
|||
|
|
@ -26,10 +26,12 @@ class AddLastWorkfileToLaunchArgs(PreLaunchHook):
|
|||
"photoshop",
|
||||
"tvpaint",
|
||||
"substancepainter",
|
||||
"substancedesigner",
|
||||
"aftereffects",
|
||||
"wrap",
|
||||
"openrv",
|
||||
"cinema4d"
|
||||
"cinema4d",
|
||||
"silhouette",
|
||||
}
|
||||
launch_types = {LaunchTypes.local}
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ class OCIOEnvHook(PreLaunchHook):
|
|||
order = 0
|
||||
hosts = {
|
||||
"substancepainter",
|
||||
"substancedesigner",
|
||||
"fusion",
|
||||
"blender",
|
||||
"aftereffects",
|
||||
|
|
@ -20,7 +21,8 @@ class OCIOEnvHook(PreLaunchHook):
|
|||
"hiero",
|
||||
"resolve",
|
||||
"openrv",
|
||||
"cinema4d"
|
||||
"cinema4d",
|
||||
"silhouette",
|
||||
}
|
||||
launch_types = set()
|
||||
|
||||
|
|
|
|||
|
|
@ -117,10 +117,7 @@ class HostDirmap(ABC):
|
|||
It checks if Site Sync is enabled and user chose to use local
|
||||
site, in that case configuration in Local Settings takes precedence
|
||||
"""
|
||||
|
||||
dirmap_label = "{}-dirmap".format(self.host_name)
|
||||
mapping_sett = self.project_settings[self.host_name].get(dirmap_label,
|
||||
{})
|
||||
mapping_sett = self.project_settings[self.host_name].get("dirmap", {})
|
||||
local_mapping = self._get_local_sync_dirmap()
|
||||
mapping_enabled = mapping_sett.get("enabled") or bool(local_mapping)
|
||||
if not mapping_enabled:
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,7 +1,34 @@
|
|||
from __future__ import annotations
|
||||
import os
|
||||
import re
|
||||
import platform
|
||||
import typing
|
||||
import collections
|
||||
from string import Formatter
|
||||
from typing import Optional
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from typing import Union, Literal
|
||||
|
||||
PlatformName = Literal["windows", "linux", "darwin"]
|
||||
EnvValue = Union[str, list[str], dict[str, str], dict[str, list[str]]]
|
||||
|
||||
|
||||
def env_value_to_bool(env_key=None, value=None, default=False):
|
||||
class CycleError(ValueError):
|
||||
"""Raised when a cycle is detected in dynamic env variables compute."""
|
||||
pass
|
||||
|
||||
|
||||
class DynamicKeyClashError(Exception):
|
||||
"""Raised when dynamic key clashes with an existing key."""
|
||||
pass
|
||||
|
||||
|
||||
def env_value_to_bool(
|
||||
env_key: Optional[str] = None,
|
||||
value: Optional[str] = None,
|
||||
default: bool = False,
|
||||
) -> bool:
|
||||
"""Convert environment variable value to boolean.
|
||||
|
||||
Function is based on value of the environemt variable. Value is lowered
|
||||
|
|
@ -11,6 +38,7 @@ def env_value_to_bool(env_key=None, value=None, default=False):
|
|||
bool: If value match to one of ["true", "yes", "1"] result if True
|
||||
but if value match to ["false", "no", "0"] result is False else
|
||||
default value is returned.
|
||||
|
||||
"""
|
||||
if value is None and env_key is None:
|
||||
return default
|
||||
|
|
@ -27,18 +55,23 @@ def env_value_to_bool(env_key=None, value=None, default=False):
|
|||
return default
|
||||
|
||||
|
||||
def get_paths_from_environ(env_key=None, env_value=None, return_first=False):
|
||||
def get_paths_from_environ(
|
||||
env_key: Optional[str] = None,
|
||||
env_value: Optional[str] = None,
|
||||
return_first: bool = False,
|
||||
) -> Optional[Union[str, list[str]]]:
|
||||
"""Return existing paths from specific environment variable.
|
||||
|
||||
Args:
|
||||
env_key (str): Environment key where should look for paths.
|
||||
env_value (str): Value of environment variable. Argument `env_key` is
|
||||
skipped if this argument is entered.
|
||||
env_key (Optional[str]): Environment key where should look for paths.
|
||||
env_value (Optional[str]): Value of environment variable.
|
||||
Argument `env_key` is skipped if this argument is entered.
|
||||
return_first (bool): Return first found value or return list of found
|
||||
paths. `None` or empty list returned if nothing found.
|
||||
|
||||
Returns:
|
||||
str, list, None: Result of found path/s.
|
||||
Optional[Union[str, list[str]]]: Result of found path/s.
|
||||
|
||||
"""
|
||||
existing_paths = []
|
||||
if not env_key and not env_value:
|
||||
|
|
@ -69,3 +102,225 @@ def get_paths_from_environ(env_key=None, env_value=None, return_first=False):
|
|||
return None
|
||||
# Return all existing paths from environment variable
|
||||
return existing_paths
|
||||
|
||||
|
||||
def parse_env_variables_structure(
|
||||
env: dict[str, EnvValue],
|
||||
platform_name: Optional[PlatformName] = None
|
||||
) -> dict[str, str]:
|
||||
"""Parse environment for platform-specific values and paths as lists.
|
||||
|
||||
Args:
|
||||
env (dict): The source environment to read.
|
||||
platform_name (Optional[PlatformName]): Name of platform to parse for.
|
||||
Defaults to current platform.
|
||||
|
||||
Returns:
|
||||
dict: The flattened environment for a platform.
|
||||
|
||||
"""
|
||||
if platform_name is None:
|
||||
platform_name = platform.system().lower()
|
||||
|
||||
# Separator based on OS 'os.pathsep' is ';' on Windows and ':' on Unix
|
||||
sep = ";" if platform_name == "windows" else ":"
|
||||
|
||||
result = {}
|
||||
for variable, value in env.items():
|
||||
# Platform specific values
|
||||
if isinstance(value, dict):
|
||||
value = value.get(platform_name)
|
||||
|
||||
# Allow to have lists as values in the tool data
|
||||
if isinstance(value, (list, tuple)):
|
||||
value = sep.join(value)
|
||||
|
||||
if not value:
|
||||
continue
|
||||
|
||||
if not isinstance(value, str):
|
||||
raise TypeError(f"Expected 'str' got '{type(value)}'")
|
||||
|
||||
result[variable] = value
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _topological_sort(
|
||||
dependencies: dict[str, set[str]]
|
||||
) -> tuple[list[str], list[str]]:
|
||||
"""Sort values subject to dependency constraints.
|
||||
|
||||
Args:
|
||||
dependencies (dict[str, set[str]): Mapping of environment variable
|
||||
keys to a set of keys they depend on.
|
||||
|
||||
Returns:
|
||||
tuple[list[str], list[str]]: A tuple of two lists. The first list
|
||||
contains the ordered keys in which order should be environment
|
||||
keys filled, the second list contains the keys that would cause
|
||||
cyclic fill of values.
|
||||
|
||||
"""
|
||||
num_heads = collections.defaultdict(int) # num arrows pointing in
|
||||
tails = collections.defaultdict(list) # list of arrows going out
|
||||
heads = [] # unique list of heads in order first seen
|
||||
for head, tail_values in dependencies.items():
|
||||
for tail_value in tail_values:
|
||||
num_heads[tail_value] += 1
|
||||
if head not in tails:
|
||||
heads.append(head)
|
||||
tails[head].append(tail_value)
|
||||
|
||||
ordered = [head for head in heads if head not in num_heads]
|
||||
for head in ordered:
|
||||
for tail in tails[head]:
|
||||
num_heads[tail] -= 1
|
||||
if not num_heads[tail]:
|
||||
ordered.append(tail)
|
||||
cyclic = [tail for tail, heads in num_heads.items() if heads]
|
||||
return ordered, cyclic
|
||||
|
||||
|
||||
class _PartialFormatDict(dict):
|
||||
"""This supports partial formatting.
|
||||
|
||||
Missing keys are replaced with the return value of __missing__.
|
||||
|
||||
"""
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self._missing_template: str = "{{{key}}}"
|
||||
|
||||
def set_missing_template(self, template: str):
|
||||
self._missing_template = template
|
||||
|
||||
def __missing__(self, key: str) -> str:
|
||||
return self._missing_template.format(key=key)
|
||||
|
||||
|
||||
def _partial_format(
|
||||
value: str,
|
||||
data: dict[str, str],
|
||||
missing_template: Optional[str] = None,
|
||||
) -> str:
|
||||
"""Return string `s` formatted by `data` allowing a partial format
|
||||
|
||||
Arguments:
|
||||
value (str): The string that will be formatted
|
||||
data (dict): The dictionary used to format with.
|
||||
missing_template (Optional[str]): The template to use when a key is
|
||||
missing from the data. If `None`, the key will remain unformatted.
|
||||
|
||||
Example:
|
||||
>>> _partial_format("{d} {a} {b} {c} {d}", {'b': "and", 'd': "left"})
|
||||
'left {a} and {c} left'
|
||||
|
||||
"""
|
||||
|
||||
mapping = _PartialFormatDict(**data)
|
||||
if missing_template is not None:
|
||||
mapping.set_missing_template(missing_template)
|
||||
|
||||
formatter = Formatter()
|
||||
try:
|
||||
output = formatter.vformat(value, (), mapping)
|
||||
except Exception:
|
||||
r_token = re.compile(r"({.*?})")
|
||||
output = value
|
||||
for match in re.findall(r_token, value):
|
||||
try:
|
||||
output = re.sub(match, match.format(**data), output)
|
||||
except (KeyError, ValueError, IndexError):
|
||||
continue
|
||||
return output
|
||||
|
||||
|
||||
def compute_env_variables_structure(
|
||||
env: dict[str, str],
|
||||
fill_dynamic_keys: bool = True,
|
||||
) -> dict[str, str]:
|
||||
"""Compute the result from recursive dynamic environment.
|
||||
|
||||
Note: Keys that are not present in the data will remain unformatted as the
|
||||
original keys. So they can be formatted against the current user
|
||||
environment when merging. So {"A": "{key}"} will remain {key} if not
|
||||
present in the dynamic environment.
|
||||
|
||||
"""
|
||||
env = env.copy()
|
||||
|
||||
# Collect dependencies
|
||||
dependencies = collections.defaultdict(set)
|
||||
for key, value in env.items():
|
||||
dependent_keys = re.findall("{(.+?)}", value)
|
||||
for dependent_key in dependent_keys:
|
||||
# Ignore reference to itself or key is not in env
|
||||
if dependent_key != key and dependent_key in env:
|
||||
dependencies[key].add(dependent_key)
|
||||
|
||||
ordered, cyclic = _topological_sort(dependencies)
|
||||
|
||||
# Check cycle
|
||||
if cyclic:
|
||||
raise CycleError(f"A cycle is detected on: {cyclic}")
|
||||
|
||||
# Format dynamic values
|
||||
for key in reversed(ordered):
|
||||
if key in env:
|
||||
if not isinstance(env[key], str):
|
||||
continue
|
||||
data = env.copy()
|
||||
data.pop(key) # format without itself
|
||||
env[key] = _partial_format(env[key], data=data)
|
||||
|
||||
# Format dynamic keys
|
||||
if fill_dynamic_keys:
|
||||
formatted = {}
|
||||
for key, value in env.items():
|
||||
if not isinstance(value, str):
|
||||
formatted[key] = value
|
||||
continue
|
||||
|
||||
new_key = _partial_format(key, data=env)
|
||||
if new_key in formatted:
|
||||
raise DynamicKeyClashError(
|
||||
f"Key clashes on: {new_key} (source: {key})"
|
||||
)
|
||||
|
||||
formatted[new_key] = value
|
||||
env = formatted
|
||||
|
||||
return env
|
||||
|
||||
|
||||
def merge_env_variables(
|
||||
src_env: dict[str, str],
|
||||
dst_env: dict[str, str],
|
||||
missing_template: Optional[str] = None,
|
||||
) -> dict[str, str]:
|
||||
"""Merge the tools environment with the 'current_env'.
|
||||
|
||||
This finalizes the join with a current environment by formatting the
|
||||
remainder of dynamic variables with that from the current environment.
|
||||
|
||||
Remaining missing variables result in an empty value.
|
||||
|
||||
Args:
|
||||
src_env (dict): The dynamic environment
|
||||
dst_env (dict): The target environment variables mapping to merge
|
||||
the dynamic environment into.
|
||||
missing_template (str): Argument passed to '_partial_format' during
|
||||
merging. `None` should keep missing keys unchanged.
|
||||
|
||||
Returns:
|
||||
dict[str, str]: The resulting environment after the merge.
|
||||
|
||||
"""
|
||||
result = dst_env.copy()
|
||||
for key, value in src_env.items():
|
||||
result[key] = _partial_format(
|
||||
str(value), dst_env, missing_template
|
||||
)
|
||||
|
||||
return result
|
||||
|
|
|
|||
|
|
@ -566,6 +566,10 @@ class EventSystem:
|
|||
|
||||
self._process_event(event)
|
||||
|
||||
def clear_callbacks(self):
|
||||
"""Clear all registered callbacks."""
|
||||
self._registered_callbacks = []
|
||||
|
||||
def _process_event(self, event):
|
||||
"""Process event topic and trigger callbacks.
|
||||
|
||||
|
|
|
|||
|
|
@ -108,21 +108,29 @@ def run_subprocess(*args, **kwargs):
|
|||
| getattr(subprocess, "CREATE_NO_WINDOW", 0)
|
||||
)
|
||||
|
||||
# Escape parentheses for bash
|
||||
# Escape special characters in certain shells
|
||||
if (
|
||||
kwargs.get("shell") is True
|
||||
and len(args) == 1
|
||||
and isinstance(args[0], str)
|
||||
and os.getenv("SHELL") in ("/bin/bash", "/bin/sh")
|
||||
):
|
||||
new_arg = (
|
||||
args[0]
|
||||
.replace("(", "\\(")
|
||||
.replace(")", "\\)")
|
||||
)
|
||||
args = (new_arg, )
|
||||
# Escape parentheses for bash
|
||||
if os.getenv("SHELL") in ("/bin/bash", "/bin/sh"):
|
||||
new_arg = (
|
||||
args[0]
|
||||
.replace("(", "\\(")
|
||||
.replace(")", "\\)")
|
||||
)
|
||||
args = (new_arg,)
|
||||
# Escape & on Windows in shell with `cmd.exe` using ^&
|
||||
elif (
|
||||
platform.system().lower() == "windows"
|
||||
and os.getenv("COMSPEC").endswith("cmd.exe")
|
||||
):
|
||||
new_arg = args[0].replace("&", "^&")
|
||||
args = (new_arg, )
|
||||
|
||||
# Get environents from kwarg or use current process environments if were
|
||||
# Get environments from kwarg or use current process environments if were
|
||||
# not passed.
|
||||
env = kwargs.get("env") or os.environ
|
||||
# Make sure environment contains only strings
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ from datetime import datetime
|
|||
from abc import ABC, abstractmethod
|
||||
from functools import lru_cache
|
||||
|
||||
import appdirs
|
||||
import platformdirs
|
||||
import ayon_api
|
||||
|
||||
_PLACEHOLDER = object()
|
||||
|
|
@ -17,7 +17,7 @@ _PLACEHOLDER = object()
|
|||
|
||||
def _get_ayon_appdirs(*args):
|
||||
return os.path.join(
|
||||
appdirs.user_data_dir("AYON", "Ynput"),
|
||||
platformdirs.user_data_dir("AYON", "Ynput"),
|
||||
*args
|
||||
)
|
||||
|
||||
|
|
@ -276,12 +276,7 @@ class ASettingRegistry(ABC):
|
|||
@abstractmethod
|
||||
def _delete_item(self, name):
|
||||
# type: (str) -> None
|
||||
"""Delete item from settings.
|
||||
|
||||
Note:
|
||||
see :meth:`ayon_core.lib.user_settings.ARegistrySettings.delete_item`
|
||||
|
||||
"""
|
||||
"""Delete item from settings."""
|
||||
pass
|
||||
|
||||
def __delitem__(self, name):
|
||||
|
|
@ -433,12 +428,7 @@ class IniSettingRegistry(ASettingRegistry):
|
|||
config.write(cfg)
|
||||
|
||||
def _delete_item(self, name):
|
||||
"""Delete item from default section.
|
||||
|
||||
Note:
|
||||
See :meth:`~ayon_core.lib.IniSettingsRegistry.delete_item_from_section`
|
||||
|
||||
"""
|
||||
"""Delete item from default section."""
|
||||
self.delete_item_from_section("MAIN", name)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import os
|
||||
import sys
|
||||
import uuid
|
||||
import getpass
|
||||
import logging
|
||||
import platform
|
||||
|
|
@ -11,12 +10,12 @@ import copy
|
|||
|
||||
from . import Terminal
|
||||
|
||||
# Check for `unicode` in builtins
|
||||
USE_UNICODE = hasattr(__builtins__, "unicode")
|
||||
|
||||
|
||||
class LogStreamHandler(logging.StreamHandler):
|
||||
""" StreamHandler class designed to handle utf errors in python 2.x hosts.
|
||||
"""StreamHandler class.
|
||||
|
||||
This was originally designed to handle UTF errors in python 2.x hosts,
|
||||
however currently solely remains for backwards compatibility.
|
||||
|
||||
"""
|
||||
|
||||
|
|
@ -25,49 +24,27 @@ class LogStreamHandler(logging.StreamHandler):
|
|||
self.enabled = True
|
||||
|
||||
def enable(self):
|
||||
""" Enable StreamHandler
|
||||
"""Enable StreamHandler
|
||||
|
||||
Used to silence output
|
||||
Make StreamHandler output again
|
||||
"""
|
||||
self.enabled = True
|
||||
|
||||
def disable(self):
|
||||
""" Disable StreamHandler
|
||||
"""Disable StreamHandler
|
||||
|
||||
Make StreamHandler output again
|
||||
Used to silence output
|
||||
"""
|
||||
self.enabled = False
|
||||
|
||||
def emit(self, record):
|
||||
if not self.enable:
|
||||
if not self.enabled or self.stream is None:
|
||||
return
|
||||
try:
|
||||
msg = self.format(record)
|
||||
msg = Terminal.log(msg)
|
||||
stream = self.stream
|
||||
if stream is None:
|
||||
return
|
||||
fs = "%s\n"
|
||||
# if no unicode support...
|
||||
if not USE_UNICODE:
|
||||
stream.write(fs % msg)
|
||||
else:
|
||||
try:
|
||||
if (isinstance(msg, unicode) and # noqa: F821
|
||||
getattr(stream, 'encoding', None)):
|
||||
ufs = u'%s\n'
|
||||
try:
|
||||
stream.write(ufs % msg)
|
||||
except UnicodeEncodeError:
|
||||
stream.write((ufs % msg).encode(stream.encoding))
|
||||
else:
|
||||
if (getattr(stream, 'encoding', 'utf-8')):
|
||||
ufs = u'%s\n'
|
||||
stream.write(ufs % unicode(msg)) # noqa: F821
|
||||
else:
|
||||
stream.write(fs % msg)
|
||||
except UnicodeError:
|
||||
stream.write(fs % msg.encode("UTF-8"))
|
||||
stream.write(f"{msg}\n")
|
||||
self.flush()
|
||||
except (KeyboardInterrupt, SystemExit):
|
||||
raise
|
||||
|
|
@ -141,8 +118,6 @@ class Logger:
|
|||
process_data = None
|
||||
# Cached process name or ability to set different process name
|
||||
_process_name = None
|
||||
# TODO Remove 'mongo_process_id' in 1.x.x
|
||||
mongo_process_id = uuid.uuid4().hex
|
||||
|
||||
@classmethod
|
||||
def get_logger(cls, name=None):
|
||||
|
|
|
|||
|
|
@ -1,9 +1,15 @@
|
|||
import os
|
||||
import re
|
||||
import copy
|
||||
import numbers
|
||||
import warnings
|
||||
from string import Formatter
|
||||
import typing
|
||||
from typing import List, Dict, Any, Set
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from typing import Union
|
||||
|
||||
KEY_PATTERN = re.compile(r"(\{.*?[^{0]*\})")
|
||||
KEY_PADDING_PATTERN = re.compile(r"([^:]+)\S+[><]\S+")
|
||||
SUB_DICT_PATTERN = re.compile(r"([^\[\]]+)")
|
||||
OPTIONAL_PATTERN = re.compile(r"(<.*?[^{0]*>)[^0-9]*?")
|
||||
|
||||
|
|
@ -18,9 +24,7 @@ class TemplateUnsolved(Exception):
|
|||
def __init__(self, template, missing_keys, invalid_types):
|
||||
invalid_type_items = []
|
||||
for _key, _type in invalid_types.items():
|
||||
invalid_type_items.append(
|
||||
"\"{0}\" {1}".format(_key, str(_type))
|
||||
)
|
||||
invalid_type_items.append(f"\"{_key}\" {str(_type)}")
|
||||
|
||||
invalid_types_msg = ""
|
||||
if invalid_type_items:
|
||||
|
|
@ -33,31 +37,32 @@ class TemplateUnsolved(Exception):
|
|||
missing_keys_msg = self.missing_keys_msg.format(
|
||||
", ".join(missing_keys)
|
||||
)
|
||||
super(TemplateUnsolved, self).__init__(
|
||||
super().__init__(
|
||||
self.msg.format(template, missing_keys_msg, invalid_types_msg)
|
||||
)
|
||||
|
||||
|
||||
class StringTemplate:
|
||||
"""String that can be formatted."""
|
||||
def __init__(self, template):
|
||||
def __init__(self, template: str):
|
||||
if not isinstance(template, str):
|
||||
raise TypeError("<{}> argument must be a string, not {}.".format(
|
||||
self.__class__.__name__, str(type(template))
|
||||
))
|
||||
raise TypeError(
|
||||
f"<{self.__class__.__name__}> argument must be a string,"
|
||||
f" not {str(type(template))}."
|
||||
)
|
||||
|
||||
self._template = template
|
||||
self._template: str = template
|
||||
parts = []
|
||||
last_end_idx = 0
|
||||
for item in KEY_PATTERN.finditer(template):
|
||||
start, end = item.span()
|
||||
if start > last_end_idx:
|
||||
parts.append(template[last_end_idx:start])
|
||||
parts.append(FormattingPart(template[start:end]))
|
||||
last_end_idx = end
|
||||
formatter = Formatter()
|
||||
|
||||
if last_end_idx < len(template):
|
||||
parts.append(template[last_end_idx:len(template)])
|
||||
for item in formatter.parse(template):
|
||||
literal_text, field_name, format_spec, conversion = item
|
||||
if literal_text:
|
||||
parts.append(literal_text)
|
||||
if field_name:
|
||||
parts.append(
|
||||
FormattingPart(field_name, format_spec, conversion)
|
||||
)
|
||||
|
||||
new_parts = []
|
||||
for part in parts:
|
||||
|
|
@ -77,15 +82,17 @@ class StringTemplate:
|
|||
if substr:
|
||||
new_parts.append(substr)
|
||||
|
||||
self._parts = self.find_optional_parts(new_parts)
|
||||
self._parts: List["Union[str, OptionalPart, FormattingPart]"] = (
|
||||
self.find_optional_parts(new_parts)
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
def __str__(self) -> str:
|
||||
return self.template
|
||||
|
||||
def __repr__(self):
|
||||
return "<{}> {}".format(self.__class__.__name__, self.template)
|
||||
def __repr__(self) -> str:
|
||||
return f"<{self.__class__.__name__}> {self.template}"
|
||||
|
||||
def __contains__(self, other):
|
||||
def __contains__(self, other: str) -> bool:
|
||||
return other in self.template
|
||||
|
||||
def replace(self, *args, **kwargs):
|
||||
|
|
@ -93,10 +100,10 @@ class StringTemplate:
|
|||
return self
|
||||
|
||||
@property
|
||||
def template(self):
|
||||
def template(self) -> str:
|
||||
return self._template
|
||||
|
||||
def format(self, data):
|
||||
def format(self, data: Dict[str, Any]) -> "TemplateResult":
|
||||
""" Figure out with whole formatting.
|
||||
|
||||
Separate advanced keys (*Like '{project[name]}') from string which must
|
||||
|
|
@ -108,6 +115,7 @@ class StringTemplate:
|
|||
Returns:
|
||||
TemplateResult: Filled or partially filled template containing all
|
||||
data needed or missing for filling template.
|
||||
|
||||
"""
|
||||
result = TemplatePartResult()
|
||||
for part in self._parts:
|
||||
|
|
@ -135,23 +143,29 @@ class StringTemplate:
|
|||
invalid_types
|
||||
)
|
||||
|
||||
def format_strict(self, *args, **kwargs):
|
||||
result = self.format(*args, **kwargs)
|
||||
def format_strict(self, data: Dict[str, Any]) -> "TemplateResult":
|
||||
result = self.format(data)
|
||||
result.validate()
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def format_template(cls, template, data):
|
||||
def format_template(
|
||||
cls, template: str, data: Dict[str, Any]
|
||||
) -> "TemplateResult":
|
||||
objected_template = cls(template)
|
||||
return objected_template.format(data)
|
||||
|
||||
@classmethod
|
||||
def format_strict_template(cls, template, data):
|
||||
def format_strict_template(
|
||||
cls, template: str, data: Dict[str, Any]
|
||||
) -> "TemplateResult":
|
||||
objected_template = cls(template)
|
||||
return objected_template.format_strict(data)
|
||||
|
||||
@staticmethod
|
||||
def find_optional_parts(parts):
|
||||
def find_optional_parts(
|
||||
parts: List["Union[str, FormattingPart]"]
|
||||
) -> List["Union[str, OptionalPart, FormattingPart]"]:
|
||||
new_parts = []
|
||||
tmp_parts = {}
|
||||
counted_symb = -1
|
||||
|
|
@ -216,11 +230,11 @@ class TemplateResult(str):
|
|||
of number.
|
||||
"""
|
||||
|
||||
used_values = None
|
||||
solved = None
|
||||
template = None
|
||||
missing_keys = None
|
||||
invalid_types = None
|
||||
used_values: Dict[str, Any] = None
|
||||
solved: bool = None
|
||||
template: str = None
|
||||
missing_keys: List[str] = None
|
||||
invalid_types: Dict[str, Any] = None
|
||||
|
||||
def __new__(
|
||||
cls, filled_template, template, solved,
|
||||
|
|
@ -248,7 +262,7 @@ class TemplateResult(str):
|
|||
self.invalid_types
|
||||
)
|
||||
|
||||
def copy(self):
|
||||
def copy(self) -> "TemplateResult":
|
||||
cls = self.__class__
|
||||
return cls(
|
||||
str(self),
|
||||
|
|
@ -259,7 +273,7 @@ class TemplateResult(str):
|
|||
self.invalid_types
|
||||
)
|
||||
|
||||
def normalized(self):
|
||||
def normalized(self) -> "TemplateResult":
|
||||
"""Convert to normalized path."""
|
||||
|
||||
cls = self.__class__
|
||||
|
|
@ -275,27 +289,28 @@ class TemplateResult(str):
|
|||
|
||||
class TemplatePartResult:
|
||||
"""Result to store result of template parts."""
|
||||
def __init__(self, optional=False):
|
||||
def __init__(self, optional: bool = False):
|
||||
# Missing keys or invalid value types of required keys
|
||||
self._missing_keys = set()
|
||||
self._invalid_types = {}
|
||||
self._missing_keys: Set[str] = set()
|
||||
self._invalid_types: Dict[str, Any] = {}
|
||||
# Missing keys or invalid value types of optional keys
|
||||
self._missing_optional_keys = set()
|
||||
self._invalid_optional_types = {}
|
||||
self._missing_optional_keys: Set[str] = set()
|
||||
self._invalid_optional_types: Dict[str, Any] = {}
|
||||
|
||||
# Used values stored by key with origin type
|
||||
# - key without any padding or key modifiers
|
||||
# - value from filling data
|
||||
# Example: {"version": 1}
|
||||
self._used_values = {}
|
||||
self._used_values: Dict[str, Any] = {}
|
||||
# Used values stored by key with all modifirs
|
||||
# - value is already formatted string
|
||||
# Example: {"version:0>3": "001"}
|
||||
self._realy_used_values = {}
|
||||
self._really_used_values: Dict[str, Any] = {}
|
||||
# Concatenated string output after formatting
|
||||
self._output = ""
|
||||
self._output: str = ""
|
||||
# Is this result from optional part
|
||||
self._optional = True
|
||||
# TODO find out why we don't use 'optional' from args
|
||||
self._optional: bool = True
|
||||
|
||||
def add_output(self, other):
|
||||
if isinstance(other, str):
|
||||
|
|
@ -313,7 +328,7 @@ class TemplatePartResult:
|
|||
if other.optional and not other.solved:
|
||||
return
|
||||
self._used_values.update(other.used_values)
|
||||
self._realy_used_values.update(other.realy_used_values)
|
||||
self._really_used_values.update(other.really_used_values)
|
||||
|
||||
else:
|
||||
raise TypeError("Cannot add data from \"{}\" to \"{}\"".format(
|
||||
|
|
@ -321,7 +336,7 @@ class TemplatePartResult:
|
|||
)
|
||||
|
||||
@property
|
||||
def solved(self):
|
||||
def solved(self) -> bool:
|
||||
if self.optional:
|
||||
if (
|
||||
len(self.missing_optional_keys) > 0
|
||||
|
|
@ -334,45 +349,53 @@ class TemplatePartResult:
|
|||
)
|
||||
|
||||
@property
|
||||
def optional(self):
|
||||
def optional(self) -> bool:
|
||||
return self._optional
|
||||
|
||||
@property
|
||||
def output(self):
|
||||
def output(self) -> str:
|
||||
return self._output
|
||||
|
||||
@property
|
||||
def missing_keys(self):
|
||||
def missing_keys(self) -> Set[str]:
|
||||
return self._missing_keys
|
||||
|
||||
@property
|
||||
def missing_optional_keys(self):
|
||||
def missing_optional_keys(self) -> Set[str]:
|
||||
return self._missing_optional_keys
|
||||
|
||||
@property
|
||||
def invalid_types(self):
|
||||
def invalid_types(self) -> Dict[str, Any]:
|
||||
return self._invalid_types
|
||||
|
||||
@property
|
||||
def invalid_optional_types(self):
|
||||
def invalid_optional_types(self) -> Dict[str, Any]:
|
||||
return self._invalid_optional_types
|
||||
|
||||
@property
|
||||
def realy_used_values(self):
|
||||
return self._realy_used_values
|
||||
def really_used_values(self) -> Dict[str, Any]:
|
||||
return self._really_used_values
|
||||
|
||||
@property
|
||||
def used_values(self):
|
||||
def realy_used_values(self) -> Dict[str, Any]:
|
||||
warnings.warn(
|
||||
"Property 'realy_used_values' is deprecated."
|
||||
" Use 'really_used_values' instead.",
|
||||
DeprecationWarning
|
||||
)
|
||||
return self._really_used_values
|
||||
|
||||
@property
|
||||
def used_values(self) -> Dict[str, Any]:
|
||||
return self._used_values
|
||||
|
||||
@staticmethod
|
||||
def split_keys_to_subdicts(values):
|
||||
def split_keys_to_subdicts(values: Dict[str, Any]) -> Dict[str, Any]:
|
||||
output = {}
|
||||
formatter = Formatter()
|
||||
for key, value in values.items():
|
||||
key_padding = list(KEY_PADDING_PATTERN.findall(key))
|
||||
if key_padding:
|
||||
key = key_padding[0]
|
||||
key_subdict = list(SUB_DICT_PATTERN.findall(key))
|
||||
_, field_name, _, _ = next(formatter.parse(f"{{{key}}}"))
|
||||
key_subdict = list(SUB_DICT_PATTERN.findall(field_name))
|
||||
data = output
|
||||
last_key = key_subdict.pop(-1)
|
||||
for subkey in key_subdict:
|
||||
|
|
@ -382,7 +405,7 @@ class TemplatePartResult:
|
|||
data[last_key] = value
|
||||
return output
|
||||
|
||||
def get_clean_used_values(self):
|
||||
def get_clean_used_values(self) -> Dict[str, Any]:
|
||||
new_used_values = {}
|
||||
for key, value in self.used_values.items():
|
||||
if isinstance(value, FormatObject):
|
||||
|
|
@ -391,19 +414,27 @@ class TemplatePartResult:
|
|||
|
||||
return self.split_keys_to_subdicts(new_used_values)
|
||||
|
||||
def add_realy_used_value(self, key, value):
|
||||
self._realy_used_values[key] = value
|
||||
def add_really_used_value(self, key: str, value: Any):
|
||||
self._really_used_values[key] = value
|
||||
|
||||
def add_used_value(self, key, value):
|
||||
def add_realy_used_value(self, key: str, value: Any):
|
||||
warnings.warn(
|
||||
"Method 'add_realy_used_value' is deprecated."
|
||||
" Use 'add_really_used_value' instead.",
|
||||
DeprecationWarning
|
||||
)
|
||||
self.add_really_used_value(key, value)
|
||||
|
||||
def add_used_value(self, key: str, value: Any):
|
||||
self._used_values[key] = value
|
||||
|
||||
def add_missing_key(self, key):
|
||||
def add_missing_key(self, key: str):
|
||||
if self._optional:
|
||||
self._missing_optional_keys.add(key)
|
||||
else:
|
||||
self._missing_keys.add(key)
|
||||
|
||||
def add_invalid_type(self, key, value):
|
||||
def add_invalid_type(self, key: str, value: Any):
|
||||
if self._optional:
|
||||
self._invalid_optional_types[key] = type(value)
|
||||
else:
|
||||
|
|
@ -421,10 +452,10 @@ class FormatObject:
|
|||
def __format__(self, *args, **kwargs):
|
||||
return self.value.__format__(*args, **kwargs)
|
||||
|
||||
def __str__(self):
|
||||
def __str__(self) -> str:
|
||||
return str(self.value)
|
||||
|
||||
def __repr__(self):
|
||||
def __repr__(self) -> str:
|
||||
return self.__str__()
|
||||
|
||||
|
||||
|
|
@ -434,23 +465,44 @@ class FormattingPart:
|
|||
Containt only single key to format e.g. "{project[name]}".
|
||||
|
||||
Args:
|
||||
template(str): String containing the formatting key.
|
||||
field_name (str): Name of key.
|
||||
format_spec (str): Format specification.
|
||||
conversion (Union[str, None]): Conversion type.
|
||||
|
||||
"""
|
||||
def __init__(self, template):
|
||||
self._template = template
|
||||
def __init__(
|
||||
self,
|
||||
field_name: str,
|
||||
format_spec: str,
|
||||
conversion: "Union[str, None]",
|
||||
):
|
||||
format_spec_v = ""
|
||||
if format_spec:
|
||||
format_spec_v = f":{format_spec}"
|
||||
conversion_v = ""
|
||||
if conversion:
|
||||
conversion_v = f"!{conversion}"
|
||||
|
||||
self._field_name: str = field_name
|
||||
self._format_spec: str = format_spec_v
|
||||
self._conversion: str = conversion_v
|
||||
|
||||
template_base = f"{field_name}{format_spec_v}{conversion_v}"
|
||||
self._template_base: str = template_base
|
||||
self._template: str = f"{{{template_base}}}"
|
||||
|
||||
@property
|
||||
def template(self):
|
||||
def template(self) -> str:
|
||||
return self._template
|
||||
|
||||
def __repr__(self):
|
||||
def __repr__(self) -> str:
|
||||
return "<Format:{}>".format(self._template)
|
||||
|
||||
def __str__(self):
|
||||
def __str__(self) -> str:
|
||||
return self._template
|
||||
|
||||
@staticmethod
|
||||
def validate_value_type(value):
|
||||
def validate_value_type(value: Any) -> bool:
|
||||
"""Check if value can be used for formatting of single key."""
|
||||
if isinstance(value, (numbers.Number, FormatObject)):
|
||||
return True
|
||||
|
|
@ -461,7 +513,7 @@ class FormattingPart:
|
|||
return False
|
||||
|
||||
@staticmethod
|
||||
def validate_key_is_matched(key):
|
||||
def validate_key_is_matched(key: str) -> bool:
|
||||
"""Validate that opening has closing at correct place.
|
||||
Future-proof, only square brackets are currently used in keys.
|
||||
|
||||
|
|
@ -488,17 +540,27 @@ class FormattingPart:
|
|||
return False
|
||||
return not queue
|
||||
|
||||
def format(self, data, result):
|
||||
@staticmethod
|
||||
def keys_to_template_base(keys: List[str]):
|
||||
if not keys:
|
||||
return None
|
||||
# Create copy of keys
|
||||
keys = list(keys)
|
||||
template_base = keys.pop(0)
|
||||
joined_keys = "".join([f"[{key}]" for key in keys])
|
||||
return f"{template_base}{joined_keys}"
|
||||
|
||||
def format(
|
||||
self, data: Dict[str, Any], result: TemplatePartResult
|
||||
) -> TemplatePartResult:
|
||||
"""Format the formattings string.
|
||||
|
||||
Args:
|
||||
data(dict): Data that should be used for formatting.
|
||||
result(TemplatePartResult): Object where result is stored.
|
||||
|
||||
"""
|
||||
key = self.template[1:-1]
|
||||
if key in result.realy_used_values:
|
||||
result.add_output(result.realy_used_values[key])
|
||||
return result
|
||||
key = self._template_base
|
||||
|
||||
# ensure key is properly formed [({})] properly closed.
|
||||
if not self.validate_key_is_matched(key):
|
||||
|
|
@ -507,17 +569,38 @@ class FormattingPart:
|
|||
return result
|
||||
|
||||
# check if key expects subdictionary keys (e.g. project[name])
|
||||
existence_check = key
|
||||
key_padding = list(KEY_PADDING_PATTERN.findall(existence_check))
|
||||
if key_padding:
|
||||
existence_check = key_padding[0]
|
||||
key_subdict = list(SUB_DICT_PATTERN.findall(existence_check))
|
||||
key_subdict = list(SUB_DICT_PATTERN.findall(self._field_name))
|
||||
|
||||
value = data
|
||||
missing_key = False
|
||||
invalid_type = False
|
||||
used_keys = []
|
||||
keys_to_value = None
|
||||
used_value = None
|
||||
|
||||
for sub_key in key_subdict:
|
||||
if isinstance(value, list):
|
||||
if not sub_key.lstrip("-").isdigit():
|
||||
invalid_type = True
|
||||
break
|
||||
sub_key = int(sub_key)
|
||||
if sub_key < 0:
|
||||
sub_key = len(value) + sub_key
|
||||
|
||||
valid = 0 <= sub_key < len(value)
|
||||
if not valid:
|
||||
used_keys.append(sub_key)
|
||||
missing_key = True
|
||||
break
|
||||
|
||||
used_keys.append(sub_key)
|
||||
if keys_to_value is None:
|
||||
keys_to_value = list(used_keys)
|
||||
keys_to_value.pop(-1)
|
||||
used_value = copy.deepcopy(value)
|
||||
value = value[sub_key]
|
||||
continue
|
||||
|
||||
if (
|
||||
value is None
|
||||
or (hasattr(value, "items") and sub_key not in value)
|
||||
|
|
@ -533,45 +616,57 @@ class FormattingPart:
|
|||
used_keys.append(sub_key)
|
||||
value = value.get(sub_key)
|
||||
|
||||
if missing_key or invalid_type:
|
||||
if len(used_keys) == 0:
|
||||
invalid_key = key_subdict[0]
|
||||
else:
|
||||
invalid_key = used_keys[0]
|
||||
for idx, sub_key in enumerate(used_keys):
|
||||
if idx == 0:
|
||||
continue
|
||||
invalid_key += "[{0}]".format(sub_key)
|
||||
field_name = key_subdict[0]
|
||||
if used_keys:
|
||||
field_name = self.keys_to_template_base(used_keys)
|
||||
|
||||
if missing_key or invalid_type:
|
||||
if missing_key:
|
||||
result.add_missing_key(invalid_key)
|
||||
result.add_missing_key(field_name)
|
||||
|
||||
elif invalid_type:
|
||||
result.add_invalid_type(invalid_key, value)
|
||||
result.add_invalid_type(field_name, value)
|
||||
|
||||
result.add_output(self.template)
|
||||
return result
|
||||
|
||||
if self.validate_value_type(value):
|
||||
fill_data = {}
|
||||
first_value = True
|
||||
for used_key in reversed(used_keys):
|
||||
if first_value:
|
||||
first_value = False
|
||||
fill_data[used_key] = value
|
||||
else:
|
||||
_fill_data = {used_key: fill_data}
|
||||
fill_data = _fill_data
|
||||
|
||||
formatted_value = self.template.format(**fill_data)
|
||||
result.add_realy_used_value(key, formatted_value)
|
||||
result.add_used_value(existence_check, formatted_value)
|
||||
result.add_output(formatted_value)
|
||||
if not self.validate_value_type(value):
|
||||
result.add_invalid_type(key, value)
|
||||
result.add_output(self.template)
|
||||
return result
|
||||
|
||||
result.add_invalid_type(key, value)
|
||||
result.add_output(self.template)
|
||||
fill_data = root_fill_data = {}
|
||||
parent_fill_data = None
|
||||
parent_key = None
|
||||
fill_value = data
|
||||
value_filled = False
|
||||
for used_key in used_keys:
|
||||
if isinstance(fill_value, list):
|
||||
parent_fill_data[parent_key] = fill_value
|
||||
value_filled = True
|
||||
break
|
||||
fill_value = fill_value[used_key]
|
||||
parent_fill_data = fill_data
|
||||
fill_data = parent_fill_data.setdefault(used_key, {})
|
||||
parent_key = used_key
|
||||
|
||||
if not value_filled:
|
||||
parent_fill_data[used_keys[-1]] = value
|
||||
|
||||
template = f"{{{field_name}{self._format_spec}{self._conversion}}}"
|
||||
formatted_value = template.format(**root_fill_data)
|
||||
used_key = key
|
||||
if keys_to_value is not None:
|
||||
used_key = self.keys_to_template_base(keys_to_value)
|
||||
|
||||
if used_value is None:
|
||||
if isinstance(value, numbers.Number):
|
||||
used_value = value
|
||||
else:
|
||||
used_value = formatted_value
|
||||
result.add_really_used_value(self._field_name, used_value)
|
||||
result.add_used_value(used_key, used_value)
|
||||
result.add_output(formatted_value)
|
||||
return result
|
||||
|
||||
|
||||
|
|
@ -585,20 +680,27 @@ class OptionalPart:
|
|||
'FormattingPart'.
|
||||
"""
|
||||
|
||||
def __init__(self, parts):
|
||||
self._parts = parts
|
||||
def __init__(
|
||||
self,
|
||||
parts: List["Union[str, OptionalPart, FormattingPart]"]
|
||||
):
|
||||
self._parts: List["Union[str, OptionalPart, FormattingPart]"] = parts
|
||||
|
||||
@property
|
||||
def parts(self):
|
||||
def parts(self) -> List["Union[str, OptionalPart, FormattingPart]"]:
|
||||
return self._parts
|
||||
|
||||
def __str__(self):
|
||||
def __str__(self) -> str:
|
||||
return "<{}>".format("".join([str(p) for p in self._parts]))
|
||||
|
||||
def __repr__(self):
|
||||
def __repr__(self) -> str:
|
||||
return "<Optional:{}>".format("".join([str(p) for p in self._parts]))
|
||||
|
||||
def format(self, data, result):
|
||||
def format(
|
||||
self,
|
||||
data: Dict[str, Any],
|
||||
result: TemplatePartResult,
|
||||
) -> TemplatePartResult:
|
||||
new_result = TemplatePartResult(True)
|
||||
for part in self._parts:
|
||||
if isinstance(part, str):
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import os
|
||||
import re
|
||||
import logging
|
||||
import platform
|
||||
|
||||
import clique
|
||||
|
||||
|
|
@ -38,31 +37,7 @@ def create_hard_link(src_path, dst_path):
|
|||
dst_path(str): Full path to a file where a link of source will be
|
||||
added.
|
||||
"""
|
||||
# Use `os.link` if is available
|
||||
# - should be for all platforms with newer python versions
|
||||
if hasattr(os, "link"):
|
||||
os.link(src_path, dst_path)
|
||||
return
|
||||
|
||||
# Windows implementation of hardlinks
|
||||
# - used in Python 2
|
||||
if platform.system().lower() == "windows":
|
||||
import ctypes
|
||||
from ctypes.wintypes import BOOL
|
||||
CreateHardLink = ctypes.windll.kernel32.CreateHardLinkW
|
||||
CreateHardLink.argtypes = [
|
||||
ctypes.c_wchar_p, ctypes.c_wchar_p, ctypes.c_void_p
|
||||
]
|
||||
CreateHardLink.restype = BOOL
|
||||
|
||||
res = CreateHardLink(dst_path, src_path, None)
|
||||
if res == 0:
|
||||
raise ctypes.WinError()
|
||||
return
|
||||
# Raises not implemented error if gets here
|
||||
raise NotImplementedError(
|
||||
"Implementation of hardlink for current environment is missing."
|
||||
)
|
||||
os.link(src_path, dst_path)
|
||||
|
||||
|
||||
def collect_frames(files):
|
||||
|
|
@ -210,7 +185,7 @@ def get_last_version_from_path(path_dir, filter):
|
|||
assert isinstance(filter, list) and (
|
||||
len(filter) != 0), "`filter` argument needs to be list and not empty"
|
||||
|
||||
filtred_files = list()
|
||||
filtered_files = list()
|
||||
|
||||
# form regex for filtering
|
||||
pattern = r".*".join(filter)
|
||||
|
|
@ -218,10 +193,10 @@ def get_last_version_from_path(path_dir, filter):
|
|||
for file in os.listdir(path_dir):
|
||||
if not re.findall(pattern, file):
|
||||
continue
|
||||
filtred_files.append(file)
|
||||
filtered_files.append(file)
|
||||
|
||||
if filtred_files:
|
||||
sorted(filtred_files)
|
||||
return filtred_files[-1]
|
||||
if filtered_files:
|
||||
filtered_files.sort()
|
||||
return filtered_files[-1]
|
||||
|
||||
return None
|
||||
|
|
|
|||
|
|
@ -1,60 +0,0 @@
|
|||
import os
|
||||
|
||||
from ayon_core import AYON_CORE_ROOT
|
||||
from ayon_core.addon import AYONAddon, ITrayAction
|
||||
|
||||
|
||||
class LauncherAction(AYONAddon, ITrayAction):
|
||||
label = "Launcher"
|
||||
name = "launcher_tool"
|
||||
version = "1.0.0"
|
||||
|
||||
def initialize(self, settings):
|
||||
|
||||
# Tray attributes
|
||||
self._window = None
|
||||
|
||||
def tray_init(self):
|
||||
self._create_window()
|
||||
|
||||
self.add_doubleclick_callback(self._show_launcher)
|
||||
|
||||
def tray_start(self):
|
||||
return
|
||||
|
||||
def connect_with_addons(self, enabled_modules):
|
||||
# Register actions
|
||||
if not self.tray_initialized:
|
||||
return
|
||||
|
||||
from ayon_core.pipeline.actions import register_launcher_action_path
|
||||
|
||||
actions_dir = os.path.join(AYON_CORE_ROOT, "plugins", "actions")
|
||||
if os.path.exists(actions_dir):
|
||||
register_launcher_action_path(actions_dir)
|
||||
|
||||
actions_paths = self.manager.collect_plugin_paths()["actions"]
|
||||
for path in actions_paths:
|
||||
if path and os.path.exists(path):
|
||||
register_launcher_action_path(path)
|
||||
|
||||
def on_action_trigger(self):
|
||||
"""Implementation for ITrayAction interface.
|
||||
|
||||
Show launcher tool on action trigger.
|
||||
"""
|
||||
|
||||
self._show_launcher()
|
||||
|
||||
def _create_window(self):
|
||||
if self._window:
|
||||
return
|
||||
from ayon_core.tools.launcher.ui import LauncherWindow
|
||||
self._window = LauncherWindow()
|
||||
|
||||
def _show_launcher(self):
|
||||
if self._window is None:
|
||||
return
|
||||
self._window.show()
|
||||
self._window.raise_()
|
||||
self._window.activateWindow()
|
||||
|
|
@ -1,68 +0,0 @@
|
|||
from ayon_core.addon import AYONAddon, ITrayAddon
|
||||
|
||||
|
||||
class LoaderAddon(AYONAddon, ITrayAddon):
|
||||
name = "loader_tool"
|
||||
version = "1.0.0"
|
||||
|
||||
def initialize(self, settings):
|
||||
# Tray attributes
|
||||
self._loader_imported = None
|
||||
self._loader_window = None
|
||||
|
||||
def tray_init(self):
|
||||
# Add library tool
|
||||
self._loader_imported = False
|
||||
try:
|
||||
from ayon_core.tools.loader.ui import LoaderWindow # noqa F401
|
||||
|
||||
self._loader_imported = True
|
||||
except Exception:
|
||||
self.log.warning(
|
||||
"Couldn't load Loader tool for tray.",
|
||||
exc_info=True
|
||||
)
|
||||
|
||||
# Definition of Tray menu
|
||||
def tray_menu(self, tray_menu):
|
||||
if not self._loader_imported:
|
||||
return
|
||||
|
||||
from qtpy import QtWidgets
|
||||
# Actions
|
||||
action_loader = QtWidgets.QAction(
|
||||
"Loader", tray_menu
|
||||
)
|
||||
|
||||
action_loader.triggered.connect(self.show_loader)
|
||||
|
||||
tray_menu.addAction(action_loader)
|
||||
|
||||
def tray_start(self, *_a, **_kw):
|
||||
return
|
||||
|
||||
def tray_exit(self, *_a, **_kw):
|
||||
return
|
||||
|
||||
def show_loader(self):
|
||||
if self._loader_window is None:
|
||||
from ayon_core.pipeline import install_ayon_plugins
|
||||
|
||||
self._init_loader()
|
||||
|
||||
install_ayon_plugins()
|
||||
|
||||
self._loader_window.show()
|
||||
|
||||
# Raise and activate the window
|
||||
# for MacOS
|
||||
self._loader_window.raise_()
|
||||
# for Windows
|
||||
self._loader_window.activateWindow()
|
||||
|
||||
def _init_loader(self):
|
||||
from ayon_core.tools.loader.ui import LoaderWindow
|
||||
|
||||
libraryloader = LoaderWindow()
|
||||
|
||||
self._loader_window = libraryloader
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
from .addon import (
|
||||
PythonInterpreterAction
|
||||
)
|
||||
|
||||
|
||||
__all__ = (
|
||||
"PythonInterpreterAction",
|
||||
)
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
from ayon_core.addon import AYONAddon, ITrayAction
|
||||
|
||||
|
||||
class PythonInterpreterAction(AYONAddon, ITrayAction):
|
||||
label = "Console"
|
||||
name = "python_interpreter"
|
||||
version = "1.0.0"
|
||||
admin_action = True
|
||||
|
||||
def initialize(self, settings):
|
||||
self._interpreter_window = None
|
||||
|
||||
def tray_init(self):
|
||||
self.create_interpreter_window()
|
||||
|
||||
def tray_exit(self):
|
||||
if self._interpreter_window is not None:
|
||||
self._interpreter_window.save_registry()
|
||||
|
||||
def create_interpreter_window(self):
|
||||
"""Initializa Settings Qt window."""
|
||||
if self._interpreter_window:
|
||||
return
|
||||
|
||||
from ayon_core.modules.python_console_interpreter.window import (
|
||||
PythonInterpreterWidget
|
||||
)
|
||||
|
||||
self._interpreter_window = PythonInterpreterWidget()
|
||||
|
||||
def on_action_trigger(self):
|
||||
self.show_interpreter_window()
|
||||
|
||||
def show_interpreter_window(self):
|
||||
self.create_interpreter_window()
|
||||
|
||||
if self._interpreter_window.isVisible():
|
||||
self._interpreter_window.activateWindow()
|
||||
self._interpreter_window.raise_()
|
||||
return
|
||||
|
||||
self._interpreter_window.show()
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
from .widgets import (
|
||||
PythonInterpreterWidget
|
||||
)
|
||||
|
||||
|
||||
__all__ = (
|
||||
"PythonInterpreterWidget",
|
||||
)
|
||||
|
|
@ -1,660 +0,0 @@
|
|||
import os
|
||||
import re
|
||||
import sys
|
||||
import collections
|
||||
from code import InteractiveInterpreter
|
||||
|
||||
import appdirs
|
||||
from qtpy import QtCore, QtWidgets, QtGui
|
||||
|
||||
from ayon_core import resources
|
||||
from ayon_core.style import load_stylesheet
|
||||
from ayon_core.lib import JSONSettingRegistry
|
||||
|
||||
|
||||
ayon_art = r"""
|
||||
|
||||
▄██▄
|
||||
▄███▄ ▀██▄ ▀██▀ ▄██▀ ▄██▀▀▀██▄ ▀███▄ █▄
|
||||
▄▄ ▀██▄ ▀██▄ ▄██▀ ██▀ ▀██▄ ▄ ▀██▄ ███
|
||||
▄██▀ ██▄ ▀ ▄▄ ▀ ██ ▄██ ███ ▀██▄ ███
|
||||
▄██▀ ▀██▄ ██ ▀██▄ ▄██▀ ███ ▀██ ▀█▀
|
||||
▄██▀ ▀██▄ ▀█ ▀██▄▄▄▄██▀ █▀ ▀██▄
|
||||
|
||||
· · - =[ by YNPUT ]:[ http://ayon.ynput.io ]= - · ·
|
||||
|
||||
"""
|
||||
|
||||
|
||||
class PythonInterpreterRegistry(JSONSettingRegistry):
|
||||
"""Class handling OpenPype general settings registry.
|
||||
|
||||
Attributes:
|
||||
vendor (str): Name used for path construction.
|
||||
product (str): Additional name used for path construction.
|
||||
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.vendor = "Ynput"
|
||||
self.product = "AYON"
|
||||
name = "python_interpreter_tool"
|
||||
path = appdirs.user_data_dir(self.product, self.vendor)
|
||||
super(PythonInterpreterRegistry, self).__init__(name, path)
|
||||
|
||||
|
||||
class StdOEWrap:
|
||||
def __init__(self):
|
||||
self._origin_stdout_write = None
|
||||
self._origin_stderr_write = None
|
||||
self._listening = False
|
||||
self.lines = collections.deque()
|
||||
|
||||
if not sys.stdout:
|
||||
sys.stdout = open(os.devnull, "w")
|
||||
|
||||
if not sys.stderr:
|
||||
sys.stderr = open(os.devnull, "w")
|
||||
|
||||
if self._origin_stdout_write is None:
|
||||
self._origin_stdout_write = sys.stdout.write
|
||||
|
||||
if self._origin_stderr_write is None:
|
||||
self._origin_stderr_write = sys.stderr.write
|
||||
|
||||
self._listening = True
|
||||
sys.stdout.write = self._stdout_listener
|
||||
sys.stderr.write = self._stderr_listener
|
||||
|
||||
def stop_listen(self):
|
||||
self._listening = False
|
||||
|
||||
def _stdout_listener(self, text):
|
||||
if self._listening:
|
||||
self.lines.append(text)
|
||||
if self._origin_stdout_write is not None:
|
||||
self._origin_stdout_write(text)
|
||||
|
||||
def _stderr_listener(self, text):
|
||||
if self._listening:
|
||||
self.lines.append(text)
|
||||
if self._origin_stderr_write is not None:
|
||||
self._origin_stderr_write(text)
|
||||
|
||||
|
||||
class PythonCodeEditor(QtWidgets.QPlainTextEdit):
|
||||
execute_requested = QtCore.Signal()
|
||||
|
||||
def __init__(self, parent):
|
||||
super(PythonCodeEditor, self).__init__(parent)
|
||||
|
||||
self.setObjectName("PythonCodeEditor")
|
||||
|
||||
self._indent = 4
|
||||
|
||||
def _tab_shift_right(self):
|
||||
cursor = self.textCursor()
|
||||
selected_text = cursor.selectedText()
|
||||
if not selected_text:
|
||||
cursor.insertText(" " * self._indent)
|
||||
return
|
||||
|
||||
sel_start = cursor.selectionStart()
|
||||
sel_end = cursor.selectionEnd()
|
||||
cursor.setPosition(sel_end)
|
||||
end_line = cursor.blockNumber()
|
||||
cursor.setPosition(sel_start)
|
||||
while True:
|
||||
cursor.movePosition(QtGui.QTextCursor.StartOfLine)
|
||||
text = cursor.block().text()
|
||||
spaces = len(text) - len(text.lstrip(" "))
|
||||
new_spaces = spaces % self._indent
|
||||
if not new_spaces:
|
||||
new_spaces = self._indent
|
||||
|
||||
cursor.insertText(" " * new_spaces)
|
||||
if cursor.blockNumber() == end_line:
|
||||
break
|
||||
|
||||
cursor.movePosition(QtGui.QTextCursor.NextBlock)
|
||||
|
||||
def _tab_shift_left(self):
|
||||
tmp_cursor = self.textCursor()
|
||||
sel_start = tmp_cursor.selectionStart()
|
||||
sel_end = tmp_cursor.selectionEnd()
|
||||
|
||||
cursor = QtGui.QTextCursor(self.document())
|
||||
cursor.setPosition(sel_end)
|
||||
end_line = cursor.blockNumber()
|
||||
cursor.setPosition(sel_start)
|
||||
while True:
|
||||
cursor.movePosition(QtGui.QTextCursor.StartOfLine)
|
||||
text = cursor.block().text()
|
||||
spaces = len(text) - len(text.lstrip(" "))
|
||||
if spaces:
|
||||
spaces_to_remove = (spaces % self._indent) or self._indent
|
||||
if spaces_to_remove > spaces:
|
||||
spaces_to_remove = spaces
|
||||
|
||||
cursor.setPosition(
|
||||
cursor.position() + spaces_to_remove,
|
||||
QtGui.QTextCursor.KeepAnchor
|
||||
)
|
||||
cursor.removeSelectedText()
|
||||
|
||||
if cursor.blockNumber() == end_line:
|
||||
break
|
||||
|
||||
cursor.movePosition(QtGui.QTextCursor.NextBlock)
|
||||
|
||||
def keyPressEvent(self, event):
|
||||
if event.key() == QtCore.Qt.Key_Backtab:
|
||||
self._tab_shift_left()
|
||||
event.accept()
|
||||
return
|
||||
|
||||
if event.key() == QtCore.Qt.Key_Tab:
|
||||
if event.modifiers() == QtCore.Qt.NoModifier:
|
||||
self._tab_shift_right()
|
||||
event.accept()
|
||||
return
|
||||
|
||||
if (
|
||||
event.key() == QtCore.Qt.Key_Return
|
||||
and event.modifiers() == QtCore.Qt.ControlModifier
|
||||
):
|
||||
self.execute_requested.emit()
|
||||
event.accept()
|
||||
return
|
||||
|
||||
super(PythonCodeEditor, self).keyPressEvent(event)
|
||||
|
||||
|
||||
class PythonTabWidget(QtWidgets.QWidget):
|
||||
add_tab_requested = QtCore.Signal()
|
||||
before_execute = QtCore.Signal(str)
|
||||
|
||||
def __init__(self, parent):
|
||||
super(PythonTabWidget, self).__init__(parent)
|
||||
|
||||
code_input = PythonCodeEditor(self)
|
||||
|
||||
self.setFocusProxy(code_input)
|
||||
|
||||
add_tab_btn = QtWidgets.QPushButton("Add tab...", self)
|
||||
add_tab_btn.setToolTip("Add new tab")
|
||||
|
||||
execute_btn = QtWidgets.QPushButton("Execute", self)
|
||||
execute_btn.setToolTip("Execute command (Ctrl + Enter)")
|
||||
|
||||
btns_layout = QtWidgets.QHBoxLayout()
|
||||
btns_layout.setContentsMargins(0, 0, 0, 0)
|
||||
btns_layout.addWidget(add_tab_btn)
|
||||
btns_layout.addStretch(1)
|
||||
btns_layout.addWidget(execute_btn)
|
||||
|
||||
layout = QtWidgets.QVBoxLayout(self)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.addWidget(code_input, 1)
|
||||
layout.addLayout(btns_layout, 0)
|
||||
|
||||
add_tab_btn.clicked.connect(self._on_add_tab_clicked)
|
||||
execute_btn.clicked.connect(self._on_execute_clicked)
|
||||
code_input.execute_requested.connect(self.execute)
|
||||
|
||||
self._code_input = code_input
|
||||
self._interpreter = InteractiveInterpreter()
|
||||
|
||||
def _on_add_tab_clicked(self):
|
||||
self.add_tab_requested.emit()
|
||||
|
||||
def _on_execute_clicked(self):
|
||||
self.execute()
|
||||
|
||||
def get_code(self):
|
||||
return self._code_input.toPlainText()
|
||||
|
||||
def set_code(self, code_text):
|
||||
self._code_input.setPlainText(code_text)
|
||||
|
||||
def execute(self):
|
||||
code_text = self._code_input.toPlainText()
|
||||
self.before_execute.emit(code_text)
|
||||
self._interpreter.runcode(code_text)
|
||||
|
||||
|
||||
class TabNameDialog(QtWidgets.QDialog):
|
||||
default_width = 330
|
||||
default_height = 85
|
||||
|
||||
def __init__(self, parent):
|
||||
super(TabNameDialog, self).__init__(parent)
|
||||
|
||||
self.setWindowTitle("Enter tab name")
|
||||
|
||||
name_label = QtWidgets.QLabel("Tab name:", self)
|
||||
name_input = QtWidgets.QLineEdit(self)
|
||||
|
||||
inputs_layout = QtWidgets.QHBoxLayout()
|
||||
inputs_layout.addWidget(name_label)
|
||||
inputs_layout.addWidget(name_input)
|
||||
|
||||
ok_btn = QtWidgets.QPushButton("Ok", self)
|
||||
cancel_btn = QtWidgets.QPushButton("Cancel", self)
|
||||
btns_layout = QtWidgets.QHBoxLayout()
|
||||
btns_layout.addStretch(1)
|
||||
btns_layout.addWidget(ok_btn)
|
||||
btns_layout.addWidget(cancel_btn)
|
||||
|
||||
layout = QtWidgets.QVBoxLayout(self)
|
||||
layout.addLayout(inputs_layout)
|
||||
layout.addStretch(1)
|
||||
layout.addLayout(btns_layout)
|
||||
|
||||
ok_btn.clicked.connect(self._on_ok_clicked)
|
||||
cancel_btn.clicked.connect(self._on_cancel_clicked)
|
||||
|
||||
self._name_input = name_input
|
||||
self._ok_btn = ok_btn
|
||||
self._cancel_btn = cancel_btn
|
||||
|
||||
self._result = None
|
||||
|
||||
self.resize(self.default_width, self.default_height)
|
||||
|
||||
def set_tab_name(self, name):
|
||||
self._name_input.setText(name)
|
||||
|
||||
def result(self):
|
||||
return self._result
|
||||
|
||||
def showEvent(self, event):
|
||||
super(TabNameDialog, self).showEvent(event)
|
||||
btns_width = max(
|
||||
self._ok_btn.width(),
|
||||
self._cancel_btn.width()
|
||||
)
|
||||
|
||||
self._ok_btn.setMinimumWidth(btns_width)
|
||||
self._cancel_btn.setMinimumWidth(btns_width)
|
||||
|
||||
def _on_ok_clicked(self):
|
||||
self._result = self._name_input.text()
|
||||
self.accept()
|
||||
|
||||
def _on_cancel_clicked(self):
|
||||
self._result = None
|
||||
self.reject()
|
||||
|
||||
|
||||
class OutputTextWidget(QtWidgets.QTextEdit):
|
||||
v_max_offset = 4
|
||||
|
||||
def vertical_scroll_at_max(self):
|
||||
v_scroll = self.verticalScrollBar()
|
||||
return v_scroll.value() > v_scroll.maximum() - self.v_max_offset
|
||||
|
||||
def scroll_to_bottom(self):
|
||||
v_scroll = self.verticalScrollBar()
|
||||
return v_scroll.setValue(v_scroll.maximum())
|
||||
|
||||
|
||||
class EnhancedTabBar(QtWidgets.QTabBar):
|
||||
double_clicked = QtCore.Signal(QtCore.QPoint)
|
||||
right_clicked = QtCore.Signal(QtCore.QPoint)
|
||||
mid_clicked = QtCore.Signal(QtCore.QPoint)
|
||||
|
||||
def __init__(self, parent):
|
||||
super(EnhancedTabBar, self).__init__(parent)
|
||||
|
||||
self.setDrawBase(False)
|
||||
|
||||
def mouseDoubleClickEvent(self, event):
|
||||
self.double_clicked.emit(event.globalPos())
|
||||
event.accept()
|
||||
|
||||
def mouseReleaseEvent(self, event):
|
||||
if event.button() == QtCore.Qt.RightButton:
|
||||
self.right_clicked.emit(event.globalPos())
|
||||
event.accept()
|
||||
return
|
||||
|
||||
elif event.button() == QtCore.Qt.MidButton:
|
||||
self.mid_clicked.emit(event.globalPos())
|
||||
event.accept()
|
||||
|
||||
else:
|
||||
super(EnhancedTabBar, self).mouseReleaseEvent(event)
|
||||
|
||||
|
||||
class PythonInterpreterWidget(QtWidgets.QWidget):
|
||||
default_width = 1000
|
||||
default_height = 600
|
||||
|
||||
def __init__(self, allow_save_registry=True, parent=None):
|
||||
super(PythonInterpreterWidget, self).__init__(parent)
|
||||
|
||||
self.setWindowTitle("AYON Console")
|
||||
self.setWindowIcon(QtGui.QIcon(resources.get_ayon_icon_filepath()))
|
||||
|
||||
self.ansi_escape = re.compile(
|
||||
r"(?:\x1B[@-_]|[\x80-\x9F])[0-?]*[ -/]*[@-~]"
|
||||
)
|
||||
|
||||
self._tabs = []
|
||||
|
||||
self._stdout_err_wrapper = StdOEWrap()
|
||||
|
||||
output_widget = OutputTextWidget(self)
|
||||
output_widget.setObjectName("PythonInterpreterOutput")
|
||||
output_widget.setLineWrapMode(QtWidgets.QTextEdit.NoWrap)
|
||||
output_widget.setTextInteractionFlags(QtCore.Qt.TextBrowserInteraction)
|
||||
|
||||
tab_widget = QtWidgets.QTabWidget(self)
|
||||
tab_bar = EnhancedTabBar(tab_widget)
|
||||
tab_widget.setTabBar(tab_bar)
|
||||
tab_widget.setTabsClosable(False)
|
||||
tab_widget.setContextMenuPolicy(QtCore.Qt.CustomContextMenu)
|
||||
|
||||
widgets_splitter = QtWidgets.QSplitter(self)
|
||||
widgets_splitter.setOrientation(QtCore.Qt.Vertical)
|
||||
widgets_splitter.addWidget(output_widget)
|
||||
widgets_splitter.addWidget(tab_widget)
|
||||
widgets_splitter.setStretchFactor(0, 1)
|
||||
widgets_splitter.setStretchFactor(1, 1)
|
||||
height = int(self.default_height / 2)
|
||||
widgets_splitter.setSizes([height, self.default_height - height])
|
||||
|
||||
layout = QtWidgets.QVBoxLayout(self)
|
||||
layout.addWidget(widgets_splitter)
|
||||
|
||||
line_check_timer = QtCore.QTimer()
|
||||
line_check_timer.setInterval(200)
|
||||
|
||||
line_check_timer.timeout.connect(self._on_timer_timeout)
|
||||
tab_bar.right_clicked.connect(self._on_tab_right_click)
|
||||
tab_bar.double_clicked.connect(self._on_tab_double_click)
|
||||
tab_bar.mid_clicked.connect(self._on_tab_mid_click)
|
||||
tab_widget.tabCloseRequested.connect(self._on_tab_close_req)
|
||||
|
||||
self._widgets_splitter = widgets_splitter
|
||||
self._output_widget = output_widget
|
||||
self._tab_widget = tab_widget
|
||||
self._line_check_timer = line_check_timer
|
||||
|
||||
self._append_lines([ayon_art])
|
||||
|
||||
self._first_show = True
|
||||
self._splitter_size_ratio = None
|
||||
self._allow_save_registry = allow_save_registry
|
||||
self._registry_saved = True
|
||||
|
||||
self._init_from_registry()
|
||||
|
||||
if self._tab_widget.count() < 1:
|
||||
self.add_tab("Python")
|
||||
|
||||
def _init_from_registry(self):
|
||||
setting_registry = PythonInterpreterRegistry()
|
||||
width = None
|
||||
height = None
|
||||
try:
|
||||
width = setting_registry.get_item("width")
|
||||
height = setting_registry.get_item("height")
|
||||
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
if width is None or width < 200:
|
||||
width = self.default_width
|
||||
|
||||
if height is None or height < 200:
|
||||
height = self.default_height
|
||||
|
||||
self.resize(width, height)
|
||||
|
||||
try:
|
||||
self._splitter_size_ratio = (
|
||||
setting_registry.get_item("splitter_sizes")
|
||||
)
|
||||
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
try:
|
||||
tab_defs = setting_registry.get_item("tabs") or []
|
||||
for tab_def in tab_defs:
|
||||
widget = self.add_tab(tab_def["name"])
|
||||
widget.set_code(tab_def["code"])
|
||||
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
def save_registry(self):
|
||||
# Window was not showed
|
||||
if not self._allow_save_registry or self._registry_saved:
|
||||
return
|
||||
|
||||
self._registry_saved = True
|
||||
setting_registry = PythonInterpreterRegistry()
|
||||
|
||||
setting_registry.set_item("width", self.width())
|
||||
setting_registry.set_item("height", self.height())
|
||||
|
||||
setting_registry.set_item(
|
||||
"splitter_sizes", self._widgets_splitter.sizes()
|
||||
)
|
||||
|
||||
tabs = []
|
||||
for tab_idx in range(self._tab_widget.count()):
|
||||
widget = self._tab_widget.widget(tab_idx)
|
||||
tab_code = widget.get_code()
|
||||
tab_name = self._tab_widget.tabText(tab_idx)
|
||||
tabs.append({
|
||||
"name": tab_name,
|
||||
"code": tab_code
|
||||
})
|
||||
|
||||
setting_registry.set_item("tabs", tabs)
|
||||
|
||||
def _on_tab_right_click(self, global_point):
|
||||
point = self._tab_widget.mapFromGlobal(global_point)
|
||||
tab_bar = self._tab_widget.tabBar()
|
||||
tab_idx = tab_bar.tabAt(point)
|
||||
last_index = tab_bar.count() - 1
|
||||
if tab_idx < 0 or tab_idx > last_index:
|
||||
return
|
||||
|
||||
menu = QtWidgets.QMenu(self._tab_widget)
|
||||
|
||||
add_tab_action = QtWidgets.QAction("Add tab...", menu)
|
||||
add_tab_action.setToolTip("Add new tab")
|
||||
|
||||
rename_tab_action = QtWidgets.QAction("Rename...", menu)
|
||||
rename_tab_action.setToolTip("Rename tab")
|
||||
|
||||
duplicate_tab_action = QtWidgets.QAction("Duplicate...", menu)
|
||||
duplicate_tab_action.setToolTip("Duplicate code to new tab")
|
||||
|
||||
close_tab_action = QtWidgets.QAction("Close", menu)
|
||||
close_tab_action.setToolTip("Close tab and lose content")
|
||||
close_tab_action.setEnabled(self._tab_widget.tabsClosable())
|
||||
|
||||
menu.addAction(add_tab_action)
|
||||
menu.addAction(rename_tab_action)
|
||||
menu.addAction(duplicate_tab_action)
|
||||
menu.addAction(close_tab_action)
|
||||
|
||||
result = menu.exec_(global_point)
|
||||
if result is None:
|
||||
return
|
||||
|
||||
if result is rename_tab_action:
|
||||
self._rename_tab_req(tab_idx)
|
||||
|
||||
elif result is add_tab_action:
|
||||
self._on_add_requested()
|
||||
|
||||
elif result is duplicate_tab_action:
|
||||
self._duplicate_requested(tab_idx)
|
||||
|
||||
elif result is close_tab_action:
|
||||
self._on_tab_close_req(tab_idx)
|
||||
|
||||
def _rename_tab_req(self, tab_idx):
|
||||
dialog = TabNameDialog(self)
|
||||
dialog.set_tab_name(self._tab_widget.tabText(tab_idx))
|
||||
dialog.exec_()
|
||||
tab_name = dialog.result()
|
||||
if tab_name:
|
||||
self._tab_widget.setTabText(tab_idx, tab_name)
|
||||
|
||||
def _duplicate_requested(self, tab_idx=None):
|
||||
if tab_idx is None:
|
||||
tab_idx = self._tab_widget.currentIndex()
|
||||
|
||||
src_widget = self._tab_widget.widget(tab_idx)
|
||||
dst_widget = self._add_tab()
|
||||
if dst_widget is None:
|
||||
return
|
||||
dst_widget.set_code(src_widget.get_code())
|
||||
|
||||
def _on_tab_mid_click(self, global_point):
|
||||
point = self._tab_widget.mapFromGlobal(global_point)
|
||||
tab_bar = self._tab_widget.tabBar()
|
||||
tab_idx = tab_bar.tabAt(point)
|
||||
last_index = tab_bar.count() - 1
|
||||
if tab_idx < 0 or tab_idx > last_index:
|
||||
return
|
||||
|
||||
self._on_tab_close_req(tab_idx)
|
||||
|
||||
def _on_tab_double_click(self, global_point):
|
||||
point = self._tab_widget.mapFromGlobal(global_point)
|
||||
tab_bar = self._tab_widget.tabBar()
|
||||
tab_idx = tab_bar.tabAt(point)
|
||||
last_index = tab_bar.count() - 1
|
||||
if tab_idx < 0 or tab_idx > last_index:
|
||||
return
|
||||
|
||||
self._rename_tab_req(tab_idx)
|
||||
|
||||
def _on_tab_close_req(self, tab_index):
|
||||
if self._tab_widget.count() == 1:
|
||||
return
|
||||
|
||||
widget = self._tab_widget.widget(tab_index)
|
||||
if widget in self._tabs:
|
||||
self._tabs.remove(widget)
|
||||
self._tab_widget.removeTab(tab_index)
|
||||
|
||||
if self._tab_widget.count() == 1:
|
||||
self._tab_widget.setTabsClosable(False)
|
||||
|
||||
def _append_lines(self, lines):
|
||||
at_max = self._output_widget.vertical_scroll_at_max()
|
||||
tmp_cursor = QtGui.QTextCursor(self._output_widget.document())
|
||||
tmp_cursor.movePosition(QtGui.QTextCursor.End)
|
||||
for line in lines:
|
||||
tmp_cursor.insertText(line)
|
||||
|
||||
if at_max:
|
||||
self._output_widget.scroll_to_bottom()
|
||||
|
||||
def _on_timer_timeout(self):
|
||||
if self._stdout_err_wrapper.lines:
|
||||
lines = []
|
||||
while self._stdout_err_wrapper.lines:
|
||||
line = self._stdout_err_wrapper.lines.popleft()
|
||||
lines.append(self.ansi_escape.sub("", line))
|
||||
self._append_lines(lines)
|
||||
|
||||
def _on_add_requested(self):
|
||||
self._add_tab()
|
||||
|
||||
def _add_tab(self):
|
||||
dialog = TabNameDialog(self)
|
||||
dialog.exec_()
|
||||
tab_name = dialog.result()
|
||||
if tab_name:
|
||||
return self.add_tab(tab_name)
|
||||
|
||||
return None
|
||||
|
||||
def _on_before_execute(self, code_text):
|
||||
at_max = self._output_widget.vertical_scroll_at_max()
|
||||
document = self._output_widget.document()
|
||||
tmp_cursor = QtGui.QTextCursor(document)
|
||||
tmp_cursor.movePosition(QtGui.QTextCursor.End)
|
||||
tmp_cursor.insertText("{}\nExecuting command:\n".format(20 * "-"))
|
||||
|
||||
code_block_format = QtGui.QTextFrameFormat()
|
||||
code_block_format.setBackground(QtGui.QColor(27, 27, 27))
|
||||
code_block_format.setPadding(4)
|
||||
|
||||
tmp_cursor.insertFrame(code_block_format)
|
||||
char_format = tmp_cursor.charFormat()
|
||||
char_format.setForeground(
|
||||
QtGui.QBrush(QtGui.QColor(114, 224, 198))
|
||||
)
|
||||
tmp_cursor.setCharFormat(char_format)
|
||||
tmp_cursor.insertText(code_text)
|
||||
|
||||
# Create new cursor
|
||||
tmp_cursor = QtGui.QTextCursor(document)
|
||||
tmp_cursor.movePosition(QtGui.QTextCursor.End)
|
||||
tmp_cursor.insertText("{}\n".format(20 * "-"))
|
||||
|
||||
if at_max:
|
||||
self._output_widget.scroll_to_bottom()
|
||||
|
||||
def add_tab(self, tab_name, index=None):
|
||||
widget = PythonTabWidget(self)
|
||||
widget.before_execute.connect(self._on_before_execute)
|
||||
widget.add_tab_requested.connect(self._on_add_requested)
|
||||
if index is None:
|
||||
if self._tab_widget.count() > 0:
|
||||
index = self._tab_widget.currentIndex() + 1
|
||||
else:
|
||||
index = 0
|
||||
|
||||
self._tabs.append(widget)
|
||||
self._tab_widget.insertTab(index, widget, tab_name)
|
||||
self._tab_widget.setCurrentIndex(index)
|
||||
|
||||
if self._tab_widget.count() > 1:
|
||||
self._tab_widget.setTabsClosable(True)
|
||||
widget.setFocus()
|
||||
return widget
|
||||
|
||||
def showEvent(self, event):
|
||||
self._line_check_timer.start()
|
||||
self._registry_saved = False
|
||||
super(PythonInterpreterWidget, self).showEvent(event)
|
||||
# First show setup
|
||||
if self._first_show:
|
||||
self._first_show = False
|
||||
self._on_first_show()
|
||||
|
||||
self._output_widget.scroll_to_bottom()
|
||||
|
||||
def _on_first_show(self):
|
||||
# Change stylesheet
|
||||
self.setStyleSheet(load_stylesheet())
|
||||
# Check if splitter size ratio is set
|
||||
# - first store value to local variable and then unset it
|
||||
splitter_size_ratio = self._splitter_size_ratio
|
||||
self._splitter_size_ratio = None
|
||||
# Skip if is not set
|
||||
if not splitter_size_ratio:
|
||||
return
|
||||
|
||||
# Skip if number of size items does not match to splitter
|
||||
splitters_count = len(self._widgets_splitter.sizes())
|
||||
if len(splitter_size_ratio) == splitters_count:
|
||||
self._widgets_splitter.setSizes(splitter_size_ratio)
|
||||
|
||||
def closeEvent(self, event):
|
||||
self.save_registry()
|
||||
super(PythonInterpreterWidget, self).closeEvent(event)
|
||||
self._line_check_timer.stop()
|
||||
|
|
@ -7,6 +7,10 @@ from .constants import (
|
|||
|
||||
from .anatomy import Anatomy
|
||||
|
||||
from .tempdir import get_temp_dir
|
||||
|
||||
from .staging_dir import get_staging_dir_info
|
||||
|
||||
from .create import (
|
||||
BaseCreator,
|
||||
Creator,
|
||||
|
|
@ -117,6 +121,12 @@ __all__ = (
|
|||
# --- Anatomy ---
|
||||
"Anatomy",
|
||||
|
||||
# --- Temp dir ---
|
||||
"get_temp_dir",
|
||||
|
||||
# --- Staging dir ---
|
||||
"get_staging_dir_info",
|
||||
|
||||
# --- Create ---
|
||||
"BaseCreator",
|
||||
"Creator",
|
||||
|
|
|
|||
|
|
@ -585,9 +585,6 @@ def version_up_current_workfile():
|
|||
"""Function to increment and save workfile
|
||||
"""
|
||||
host = registered_host()
|
||||
if not host.has_unsaved_changes():
|
||||
print("No unsaved changes, skipping file save..")
|
||||
return
|
||||
|
||||
project_name = get_current_project_name()
|
||||
folder_path = get_current_folder_path()
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,12 +1,13 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
import os
|
||||
import copy
|
||||
import collections
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
from typing import TYPE_CHECKING, Optional, Dict, Any
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
from ayon_core.settings import get_project_settings
|
||||
from ayon_core.lib import Logger
|
||||
from ayon_core.lib import Logger, get_version_from_path
|
||||
from ayon_core.pipeline.plugin_discover import (
|
||||
discover,
|
||||
register_plugin,
|
||||
|
|
@ -14,16 +15,18 @@ from ayon_core.pipeline.plugin_discover import (
|
|||
deregister_plugin,
|
||||
deregister_plugin_path
|
||||
)
|
||||
from ayon_core.pipeline.staging_dir import get_staging_dir_info, StagingDir
|
||||
|
||||
from .constants import DEFAULT_VARIANT_VALUE
|
||||
from .product_name import get_product_name
|
||||
from .utils import get_next_versions_for_instances
|
||||
from .legacy_create import LegacyCreator
|
||||
from .structures import CreatedInstance
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ayon_core.lib import AbstractAttrDef
|
||||
# Avoid cyclic imports
|
||||
from .context import CreateContext, CreatedInstance, UpdateData # noqa: F401
|
||||
from .context import CreateContext, UpdateData # noqa: F401
|
||||
|
||||
|
||||
class ProductConvertorPlugin(ABC):
|
||||
|
|
@ -204,6 +207,7 @@ class BaseCreator(ABC):
|
|||
self.headless = headless
|
||||
|
||||
self.apply_settings(project_settings)
|
||||
self.register_callbacks()
|
||||
|
||||
@staticmethod
|
||||
def _get_settings_values(project_settings, category_name, plugin_name):
|
||||
|
|
@ -289,6 +293,14 @@ class BaseCreator(ABC):
|
|||
))
|
||||
setattr(self, key, value)
|
||||
|
||||
def register_callbacks(self):
|
||||
"""Register callbacks for creator.
|
||||
|
||||
Default implementation does nothing. It can be overridden to register
|
||||
callbacks for creator.
|
||||
"""
|
||||
pass
|
||||
|
||||
@property
|
||||
def identifier(self):
|
||||
"""Identifier of creator (must be unique).
|
||||
|
|
@ -362,6 +374,35 @@ class BaseCreator(ABC):
|
|||
self._log = Logger.get_logger(self.__class__.__name__)
|
||||
return self._log
|
||||
|
||||
def _create_instance(
|
||||
self,
|
||||
product_name: str,
|
||||
data: Dict[str, Any],
|
||||
product_type: Optional[str] = None
|
||||
) -> CreatedInstance:
|
||||
"""Create instance and add instance to context.
|
||||
|
||||
Args:
|
||||
product_name (str): Product name.
|
||||
data (Dict[str, Any]): Instance data.
|
||||
product_type (Optional[str]): Product type, object attribute
|
||||
'product_type' is used if not passed.
|
||||
|
||||
Returns:
|
||||
CreatedInstance: Created instance.
|
||||
|
||||
"""
|
||||
if product_type is None:
|
||||
product_type = self.product_type
|
||||
instance = CreatedInstance(
|
||||
product_type,
|
||||
product_name,
|
||||
data,
|
||||
creator=self,
|
||||
)
|
||||
self._add_instance_to_context(instance)
|
||||
return instance
|
||||
|
||||
def _add_instance_to_context(self, instance):
|
||||
"""Helper method to add instance to create context.
|
||||
|
||||
|
|
@ -521,6 +562,10 @@ class BaseCreator(ABC):
|
|||
instance
|
||||
)
|
||||
|
||||
cur_project_name = self.create_context.get_current_project_name()
|
||||
if not project_entity and project_name == cur_project_name:
|
||||
project_entity = self.create_context.get_current_project_entity()
|
||||
|
||||
return get_product_name(
|
||||
project_name,
|
||||
task_name,
|
||||
|
|
@ -551,6 +596,16 @@ class BaseCreator(ABC):
|
|||
|
||||
return self.instance_attr_defs
|
||||
|
||||
def get_attr_defs_for_instance(self, instance):
|
||||
"""Get attribute definitions for an instance.
|
||||
|
||||
Args:
|
||||
instance (CreatedInstance): Instance for which to get
|
||||
attribute definitions.
|
||||
|
||||
"""
|
||||
return self.get_instance_attr_defs()
|
||||
|
||||
@property
|
||||
def collection_shared_data(self):
|
||||
"""Access to shared data that can be used during creator's collection.
|
||||
|
|
@ -782,6 +837,118 @@ class Creator(BaseCreator):
|
|||
"""
|
||||
return self.pre_create_attr_defs
|
||||
|
||||
def get_staging_dir(self, instance) -> Optional[StagingDir]:
|
||||
"""Return the staging dir and persistence from instance.
|
||||
|
||||
Args:
|
||||
instance (CreatedInstance): Instance for which should be staging
|
||||
dir gathered.
|
||||
|
||||
Returns:
|
||||
Optional[namedtuple]: Staging dir path and persistence or None
|
||||
"""
|
||||
create_ctx = self.create_context
|
||||
product_name = instance.get("productName")
|
||||
product_type = instance.get("productType")
|
||||
folder_path = instance.get("folderPath")
|
||||
|
||||
# this can only work if product name and folder path are available
|
||||
if not product_name or not folder_path:
|
||||
return None
|
||||
|
||||
publish_settings = self.project_settings["core"]["publish"]
|
||||
follow_workfile_version = (
|
||||
publish_settings
|
||||
["CollectAnatomyInstanceData"]
|
||||
["follow_workfile_version"]
|
||||
)
|
||||
follow_version_hosts = (
|
||||
publish_settings
|
||||
["CollectSceneVersion"]
|
||||
["hosts"]
|
||||
)
|
||||
|
||||
current_host = create_ctx.host.name
|
||||
follow_workfile_version = (
|
||||
follow_workfile_version and
|
||||
current_host in follow_version_hosts
|
||||
)
|
||||
|
||||
# Gather version number provided from the instance.
|
||||
current_workfile = create_ctx.get_current_workfile_path()
|
||||
version = instance.get("version")
|
||||
|
||||
# If follow workfile, gather version from workfile path.
|
||||
if version is None and follow_workfile_version and current_workfile:
|
||||
workfile_version = get_version_from_path(current_workfile)
|
||||
if workfile_version is not None:
|
||||
version = int(workfile_version)
|
||||
|
||||
# Fill-up version with next version available.
|
||||
if version is None:
|
||||
versions = self.get_next_versions_for_instances(
|
||||
[instance]
|
||||
)
|
||||
version, = tuple(versions.values())
|
||||
|
||||
template_data = {"version": version}
|
||||
|
||||
staging_dir_info = get_staging_dir_info(
|
||||
create_ctx.get_current_project_entity(),
|
||||
create_ctx.get_folder_entity(folder_path),
|
||||
create_ctx.get_task_entity(folder_path, instance.get("task")),
|
||||
product_type,
|
||||
product_name,
|
||||
create_ctx.host_name,
|
||||
anatomy=create_ctx.get_current_project_anatomy(),
|
||||
project_settings=create_ctx.get_current_project_settings(),
|
||||
always_return_path=False,
|
||||
logger=self.log,
|
||||
template_data=template_data,
|
||||
)
|
||||
|
||||
return staging_dir_info or None
|
||||
|
||||
def apply_staging_dir(self, instance):
|
||||
"""Apply staging dir with persistence to instance's transient data.
|
||||
|
||||
Method is called on instance creation and on instance update.
|
||||
|
||||
Args:
|
||||
instance (CreatedInstance): Instance for which should be staging
|
||||
dir applied.
|
||||
|
||||
Returns:
|
||||
Optional[str]: Staging dir path or None if not applied.
|
||||
"""
|
||||
staging_dir_info = self.get_staging_dir(instance)
|
||||
if staging_dir_info is None:
|
||||
return None
|
||||
|
||||
# path might be already created by get_staging_dir_info
|
||||
staging_dir_path = staging_dir_info.directory
|
||||
os.makedirs(staging_dir_path, exist_ok=True)
|
||||
|
||||
instance.transient_data.update({
|
||||
"stagingDir": staging_dir_path,
|
||||
"stagingDir_persistent": staging_dir_info.is_persistent,
|
||||
"stagingDir_is_custom": staging_dir_info.is_custom,
|
||||
})
|
||||
|
||||
self.log.info(f"Applied staging dir to instance: {staging_dir_path}")
|
||||
|
||||
return staging_dir_path
|
||||
|
||||
def _pre_create_attr_defs_changed(self):
|
||||
"""Called when pre-create attribute definitions change.
|
||||
|
||||
Create plugin can call this method when knows that
|
||||
'get_pre_create_attr_defs' should be called again.
|
||||
"""
|
||||
self.create_context.create_plugin_pre_create_attr_defs_changed(
|
||||
self.identifier
|
||||
)
|
||||
|
||||
|
||||
class HiddenCreator(BaseCreator):
|
||||
@abstractmethod
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import os
|
|||
import logging
|
||||
import collections
|
||||
|
||||
from ayon_core.pipeline.constants import AVALON_INSTANCE_ID
|
||||
from ayon_core.pipeline.constants import AYON_INSTANCE_ID
|
||||
|
||||
from .product_name import get_product_name
|
||||
|
||||
|
|
@ -34,7 +34,7 @@ class LegacyCreator:
|
|||
# Default data
|
||||
self.data = collections.OrderedDict()
|
||||
# TODO use 'AYON_INSTANCE_ID' when all hosts support it
|
||||
self.data["id"] = AVALON_INSTANCE_ID
|
||||
self.data["id"] = AYON_INSTANCE_ID
|
||||
self.data["productType"] = self.product_type
|
||||
self.data["folderPath"] = folder_path
|
||||
self.data["productName"] = name
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
import ayon_api
|
||||
from ayon_core.lib import StringTemplate, filter_profiles, prepare_template_data
|
||||
from ayon_core.lib import (
|
||||
StringTemplate,
|
||||
filter_profiles,
|
||||
prepare_template_data,
|
||||
)
|
||||
from ayon_core.settings import get_project_settings
|
||||
|
||||
from .constants import DEFAULT_PRODUCT_TEMPLATE
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
import copy
|
||||
import collections
|
||||
from uuid import uuid4
|
||||
from typing import Optional
|
||||
import typing
|
||||
from typing import Optional, Dict, List, Any
|
||||
|
||||
from ayon_core.lib.attribute_definitions import (
|
||||
AbstractAttrDef,
|
||||
UnknownDef,
|
||||
serialize_attr_defs,
|
||||
deserialize_attr_defs,
|
||||
|
|
@ -16,6 +18,9 @@ from ayon_core.pipeline import (
|
|||
from .exceptions import ImmutableKeyError
|
||||
from .changes import TrackChangesItem
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from .creator_plugins import BaseCreator
|
||||
|
||||
|
||||
class ConvertorItem:
|
||||
"""Item representing convertor plugin.
|
||||
|
|
@ -79,12 +84,17 @@ class AttributeValues:
|
|||
Has dictionary like methods. Not all of them are allowed all the time.
|
||||
|
||||
Args:
|
||||
attr_defs(AbstractAttrDef): Definitions of value type and properties.
|
||||
values(dict): Values after possible conversion.
|
||||
origin_data(dict): Values loaded from host before conversion.
|
||||
"""
|
||||
parent (Union[CreatedInstance, PublishAttributes]): Parent object.
|
||||
key (str): Key of attribute values.
|
||||
attr_defs (List[AbstractAttrDef]): Definitions of value type
|
||||
and properties.
|
||||
values (dict): Values after possible conversion.
|
||||
origin_data (dict): Values loaded from host before conversion.
|
||||
|
||||
def __init__(self, attr_defs, values, origin_data=None):
|
||||
"""
|
||||
def __init__(self, parent, key, attr_defs, values, origin_data=None):
|
||||
self._parent = parent
|
||||
self._key = key
|
||||
if origin_data is None:
|
||||
origin_data = copy.deepcopy(values)
|
||||
self._origin_data = origin_data
|
||||
|
|
@ -106,7 +116,10 @@ class AttributeValues:
|
|||
self._data = {}
|
||||
for attr_def in attr_defs:
|
||||
value = values.get(attr_def.key)
|
||||
if value is not None:
|
||||
if value is None:
|
||||
continue
|
||||
converted_value = attr_def.convert_value(value)
|
||||
if converted_value == value:
|
||||
self._data[attr_def.key] = value
|
||||
|
||||
def __setitem__(self, key, value):
|
||||
|
|
@ -123,6 +136,10 @@ class AttributeValues:
|
|||
def __contains__(self, key):
|
||||
return key in self._attr_defs_by_key
|
||||
|
||||
def __iter__(self):
|
||||
for key in self._attr_defs_by_key:
|
||||
yield key
|
||||
|
||||
def get(self, key, default=None):
|
||||
if key in self._attr_defs_by_key:
|
||||
return self[key]
|
||||
|
|
@ -139,6 +156,9 @@ class AttributeValues:
|
|||
for key in self._attr_defs_by_key.keys():
|
||||
yield key, self._data.get(key)
|
||||
|
||||
def get_attr_def(self, key, default=None):
|
||||
return self._attr_defs_by_key.get(key, default)
|
||||
|
||||
def update(self, value):
|
||||
changes = {}
|
||||
for _key, _value in dict(value).items():
|
||||
|
|
@ -147,7 +167,11 @@ class AttributeValues:
|
|||
self._data[_key] = _value
|
||||
changes[_key] = _value
|
||||
|
||||
if changes:
|
||||
self._parent.attribute_value_changed(self._key, changes)
|
||||
|
||||
def pop(self, key, default=None):
|
||||
has_key = key in self._data
|
||||
value = self._data.pop(key, default)
|
||||
# Remove attribute definition if is 'UnknownDef'
|
||||
# - gives option to get rid of unknown values
|
||||
|
|
@ -155,6 +179,8 @@ class AttributeValues:
|
|||
if isinstance(attr_def, UnknownDef):
|
||||
self._attr_defs_by_key.pop(key)
|
||||
self._attr_defs.remove(attr_def)
|
||||
elif has_key:
|
||||
self._parent.attribute_value_changed(self._key, {key: None})
|
||||
return value
|
||||
|
||||
def reset_values(self):
|
||||
|
|
@ -204,15 +230,11 @@ class AttributeValues:
|
|||
|
||||
|
||||
class CreatorAttributeValues(AttributeValues):
|
||||
"""Creator specific attribute values of an instance.
|
||||
"""Creator specific attribute values of an instance."""
|
||||
|
||||
Args:
|
||||
instance (CreatedInstance): Instance for which are values hold.
|
||||
"""
|
||||
|
||||
def __init__(self, instance, *args, **kwargs):
|
||||
self.instance = instance
|
||||
super().__init__(*args, **kwargs)
|
||||
@property
|
||||
def instance(self):
|
||||
return self._parent
|
||||
|
||||
|
||||
class PublishAttributeValues(AttributeValues):
|
||||
|
|
@ -220,19 +242,11 @@ class PublishAttributeValues(AttributeValues):
|
|||
|
||||
Values are for single plugin which can be on `CreatedInstance`
|
||||
or context values stored on `CreateContext`.
|
||||
|
||||
Args:
|
||||
publish_attributes(PublishAttributes): Wrapper for multiple publish
|
||||
attributes is used as parent object.
|
||||
"""
|
||||
|
||||
def __init__(self, publish_attributes, *args, **kwargs):
|
||||
self.publish_attributes = publish_attributes
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
@property
|
||||
def parent(self):
|
||||
return self.publish_attributes.parent
|
||||
def publish_attributes(self):
|
||||
return self._parent
|
||||
|
||||
|
||||
class PublishAttributes:
|
||||
|
|
@ -245,22 +259,13 @@ class PublishAttributes:
|
|||
parent(CreatedInstance, CreateContext): Parent for which will be
|
||||
data stored and from which are data loaded.
|
||||
origin_data(dict): Loaded data by plugin class name.
|
||||
attr_plugins(Union[List[pyblish.api.Plugin], None]): List of publish
|
||||
plugins that may have defined attribute definitions.
|
||||
"""
|
||||
|
||||
def __init__(self, parent, origin_data, attr_plugins=None):
|
||||
self.parent = parent
|
||||
"""
|
||||
def __init__(self, parent, origin_data):
|
||||
self._parent = parent
|
||||
self._origin_data = copy.deepcopy(origin_data)
|
||||
|
||||
attr_plugins = attr_plugins or []
|
||||
self.attr_plugins = attr_plugins
|
||||
|
||||
self._data = copy.deepcopy(origin_data)
|
||||
self._plugin_names_order = []
|
||||
self._missing_plugins = []
|
||||
|
||||
self.set_publish_plugins(attr_plugins)
|
||||
|
||||
def __getitem__(self, key):
|
||||
return self._data[key]
|
||||
|
|
@ -277,6 +282,9 @@ class PublishAttributes:
|
|||
def items(self):
|
||||
return self._data.items()
|
||||
|
||||
def get(self, key, default=None):
|
||||
return self._data.get(key, default)
|
||||
|
||||
def pop(self, key, default=None):
|
||||
"""Remove or reset value for plugin.
|
||||
|
||||
|
|
@ -291,74 +299,65 @@ class PublishAttributes:
|
|||
if key not in self._data:
|
||||
return default
|
||||
|
||||
if key in self._missing_plugins:
|
||||
self._missing_plugins.remove(key)
|
||||
removed_item = self._data.pop(key)
|
||||
return removed_item.data_to_store()
|
||||
value = self._data[key]
|
||||
if not isinstance(value, AttributeValues):
|
||||
self.attribute_value_changed(key, None)
|
||||
return self._data.pop(key)
|
||||
|
||||
value_item = self._data[key]
|
||||
# Prepare value to return
|
||||
output = value_item.data_to_store()
|
||||
# Reset values
|
||||
value_item.reset_values()
|
||||
self.attribute_value_changed(
|
||||
key, value_item.data_to_store()
|
||||
)
|
||||
return output
|
||||
|
||||
def plugin_names_order(self):
|
||||
"""Plugin names order by their 'order' attribute."""
|
||||
|
||||
for name in self._plugin_names_order:
|
||||
yield name
|
||||
|
||||
def mark_as_stored(self):
|
||||
self._origin_data = copy.deepcopy(self.data_to_store())
|
||||
|
||||
def data_to_store(self):
|
||||
"""Convert attribute values to "data to store"."""
|
||||
|
||||
output = {}
|
||||
for key, attr_value in self._data.items():
|
||||
output[key] = attr_value.data_to_store()
|
||||
if isinstance(attr_value, AttributeValues):
|
||||
output[key] = attr_value.data_to_store()
|
||||
else:
|
||||
output[key] = attr_value
|
||||
return output
|
||||
|
||||
@property
|
||||
def origin_data(self):
|
||||
return copy.deepcopy(self._origin_data)
|
||||
|
||||
def set_publish_plugins(self, attr_plugins):
|
||||
"""Set publish plugins attribute definitions."""
|
||||
def attribute_value_changed(self, key, changes):
|
||||
self._parent.publish_attribute_value_changed(key, changes)
|
||||
|
||||
self._plugin_names_order = []
|
||||
self._missing_plugins = []
|
||||
self.attr_plugins = attr_plugins or []
|
||||
def set_publish_plugin_attr_defs(
|
||||
self,
|
||||
plugin_name: str,
|
||||
attr_defs: List[AbstractAttrDef],
|
||||
value: Optional[Dict[str, Any]] = None
|
||||
):
|
||||
"""Set attribute definitions for plugin.
|
||||
|
||||
origin_data = self._origin_data
|
||||
data = self._data
|
||||
self._data = {}
|
||||
added_keys = set()
|
||||
for plugin in attr_plugins:
|
||||
output = plugin.convert_attribute_values(data)
|
||||
if output is not None:
|
||||
data = output
|
||||
attr_defs = plugin.get_attribute_defs()
|
||||
if not attr_defs:
|
||||
continue
|
||||
Args:
|
||||
plugin_name (str): Name of plugin.
|
||||
attr_defs (List[AbstractAttrDef]): Attribute definitions.
|
||||
value (Optional[Dict[str, Any]]): Attribute values.
|
||||
|
||||
key = plugin.__name__
|
||||
added_keys.add(key)
|
||||
self._plugin_names_order.append(key)
|
||||
"""
|
||||
# TODO what if 'attr_defs' is 'None'?
|
||||
if value is None:
|
||||
value = self._data.get(plugin_name)
|
||||
|
||||
value = data.get(key) or {}
|
||||
orig_value = copy.deepcopy(origin_data.get(key) or {})
|
||||
self._data[key] = PublishAttributeValues(
|
||||
self, attr_defs, value, orig_value
|
||||
)
|
||||
if value is None:
|
||||
value = {}
|
||||
|
||||
for key, value in data.items():
|
||||
if key not in added_keys:
|
||||
self._missing_plugins.append(key)
|
||||
self._data[key] = PublishAttributeValues(
|
||||
self, [], value, value
|
||||
)
|
||||
self._data[plugin_name] = PublishAttributeValues(
|
||||
self, plugin_name, attr_defs, value, value
|
||||
)
|
||||
|
||||
def serialize_attributes(self):
|
||||
return {
|
||||
|
|
@ -366,14 +365,9 @@ class PublishAttributes:
|
|||
plugin_name: attrs_value.get_serialized_attr_defs()
|
||||
for plugin_name, attrs_value in self._data.items()
|
||||
},
|
||||
"plugin_names_order": self._plugin_names_order,
|
||||
"missing_plugins": self._missing_plugins
|
||||
}
|
||||
|
||||
def deserialize_attributes(self, data):
|
||||
self._plugin_names_order = data["plugin_names_order"]
|
||||
self._missing_plugins = data["missing_plugins"]
|
||||
|
||||
attr_defs = deserialize_attr_defs(data["attr_defs"])
|
||||
|
||||
origin_data = self._origin_data
|
||||
|
|
@ -386,15 +380,12 @@ class PublishAttributes:
|
|||
value = data.get(plugin_name) or {}
|
||||
orig_value = copy.deepcopy(origin_data.get(plugin_name) or {})
|
||||
self._data[plugin_name] = PublishAttributeValues(
|
||||
self, attr_defs, value, orig_value
|
||||
self, plugin_name, attr_defs, value, orig_value
|
||||
)
|
||||
|
||||
for key, value in data.items():
|
||||
if key not in added_keys:
|
||||
self._missing_plugins.append(key)
|
||||
self._data[key] = PublishAttributeValues(
|
||||
self, [], value, value
|
||||
)
|
||||
self._data[key] = value
|
||||
|
||||
|
||||
class InstanceContextInfo:
|
||||
|
|
@ -432,12 +423,7 @@ class CreatedInstance:
|
|||
product_name (str): Name of product that will be created.
|
||||
data (Dict[str, Any]): Data used for filling product name or override
|
||||
data from already existing instance.
|
||||
creator (Union[BaseCreator, None]): Creator responsible for instance.
|
||||
creator_identifier (str): Identifier of creator plugin.
|
||||
creator_label (str): Creator plugin label.
|
||||
group_label (str): Default group label from creator plugin.
|
||||
creator_attr_defs (List[AbstractAttrDef]): Attribute definitions from
|
||||
creator.
|
||||
creator (BaseCreator): Creator responsible for instance.
|
||||
"""
|
||||
|
||||
# Keys that can't be changed or removed from data after loading using
|
||||
|
|
@ -447,28 +433,31 @@ class CreatedInstance:
|
|||
__immutable_keys = (
|
||||
"id",
|
||||
"instance_id",
|
||||
"product_type",
|
||||
"productType",
|
||||
"creator_identifier",
|
||||
"creator_attributes",
|
||||
"publish_attributes"
|
||||
)
|
||||
# Keys that can be changed, but should not be removed from instance
|
||||
__required_keys = {
|
||||
"folderPath": None,
|
||||
"task": None,
|
||||
"productName": None,
|
||||
"active": True,
|
||||
}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
product_type,
|
||||
product_name,
|
||||
data,
|
||||
creator=None,
|
||||
creator_identifier=None,
|
||||
creator_label=None,
|
||||
group_label=None,
|
||||
creator_attr_defs=None,
|
||||
product_type: str,
|
||||
product_name: str,
|
||||
data: Dict[str, Any],
|
||||
creator: "BaseCreator",
|
||||
transient_data: Optional[Dict[str, Any]] = None,
|
||||
):
|
||||
if creator is not None:
|
||||
creator_identifier = creator.identifier
|
||||
group_label = creator.get_group_label()
|
||||
creator_label = creator.label
|
||||
creator_attr_defs = creator.get_instance_attr_defs()
|
||||
self._creator = creator
|
||||
creator_identifier = creator.identifier
|
||||
group_label = creator.get_group_label()
|
||||
creator_label = creator.label
|
||||
|
||||
self._creator_label = creator_label
|
||||
self._group_label = group_label or creator_identifier
|
||||
|
|
@ -478,7 +467,9 @@ class CreatedInstance:
|
|||
self._members = []
|
||||
|
||||
# Data that can be used for lifetime of object
|
||||
self._transient_data = {}
|
||||
if transient_data is None:
|
||||
transient_data = {}
|
||||
self._transient_data = transient_data
|
||||
|
||||
# Create a copy of passed data to avoid changing them on the fly
|
||||
data = copy.deepcopy(data or {})
|
||||
|
|
@ -508,7 +499,7 @@ class CreatedInstance:
|
|||
item_id = data.get("id")
|
||||
# TODO use only 'AYON_INSTANCE_ID' when all hosts support it
|
||||
if item_id not in {AYON_INSTANCE_ID, AVALON_INSTANCE_ID}:
|
||||
item_id = AVALON_INSTANCE_ID
|
||||
item_id = AYON_INSTANCE_ID
|
||||
self._data["id"] = item_id
|
||||
self._data["productType"] = product_type
|
||||
self._data["productName"] = product_name
|
||||
|
|
@ -528,25 +519,27 @@ class CreatedInstance:
|
|||
# {key: value}
|
||||
creator_values = copy.deepcopy(orig_creator_attributes)
|
||||
|
||||
self._data["creator_attributes"] = CreatorAttributeValues(
|
||||
self,
|
||||
list(creator_attr_defs),
|
||||
creator_values,
|
||||
orig_creator_attributes
|
||||
)
|
||||
self._data["creator_attributes"] = creator_values
|
||||
|
||||
# Stored publish specific attribute values
|
||||
# {<plugin name>: {key: value}}
|
||||
# - must be set using 'set_publish_plugins'
|
||||
self._data["publish_attributes"] = PublishAttributes(
|
||||
self, orig_publish_attributes, None
|
||||
self, orig_publish_attributes
|
||||
)
|
||||
if data:
|
||||
self._data.update(data)
|
||||
|
||||
for key, default in self.__required_keys.items():
|
||||
self._data.setdefault(key, default)
|
||||
|
||||
if not self._data.get("instance_id"):
|
||||
self._data["instance_id"] = str(uuid4())
|
||||
|
||||
creator_attr_defs = creator.get_attr_defs_for_instance(self)
|
||||
self.set_create_attr_defs(
|
||||
creator_attr_defs, creator_values
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return (
|
||||
"<CreatedInstance {product[name]}"
|
||||
|
|
@ -566,13 +559,20 @@ class CreatedInstance:
|
|||
|
||||
def __setitem__(self, key, value):
|
||||
# Validate immutable keys
|
||||
if key not in self.__immutable_keys:
|
||||
self._data[key] = value
|
||||
|
||||
elif value != self._data.get(key):
|
||||
if key in self.__immutable_keys:
|
||||
if value == self._data.get(key):
|
||||
return
|
||||
# Raise exception if key is immutable and value has changed
|
||||
raise ImmutableKeyError(key)
|
||||
|
||||
if key in self._data and self._data[key] == value:
|
||||
return
|
||||
|
||||
self._data[key] = value
|
||||
self._create_context.instance_values_changed(
|
||||
self.id, {key: value}
|
||||
)
|
||||
|
||||
def get(self, key, default=None):
|
||||
return self._data.get(key, default)
|
||||
|
||||
|
|
@ -581,7 +581,15 @@ class CreatedInstance:
|
|||
if key in self.__immutable_keys:
|
||||
raise ImmutableKeyError(key)
|
||||
|
||||
self._data.pop(key, *args, **kwargs)
|
||||
has_key = key in self._data
|
||||
output = self._data.pop(key, *args, **kwargs)
|
||||
if has_key:
|
||||
if key in self.__required_keys:
|
||||
self._data[key] = self.__required_keys[key]
|
||||
self._create_context.instance_values_changed(
|
||||
self.id, {key: None}
|
||||
)
|
||||
return output
|
||||
|
||||
def keys(self):
|
||||
return self._data.keys()
|
||||
|
|
@ -628,7 +636,7 @@ class CreatedInstance:
|
|||
|
||||
@property
|
||||
def creator_label(self):
|
||||
return self._creator_label or self.creator_identifier
|
||||
return self._creator.label or self.creator_identifier
|
||||
|
||||
@property
|
||||
def id(self):
|
||||
|
|
@ -745,22 +753,67 @@ class CreatedInstance:
|
|||
continue
|
||||
output[key] = value
|
||||
|
||||
output["creator_attributes"] = self.creator_attributes.data_to_store()
|
||||
if isinstance(self.creator_attributes, AttributeValues):
|
||||
creator_attributes = self.creator_attributes.data_to_store()
|
||||
else:
|
||||
creator_attributes = copy.deepcopy(self.creator_attributes)
|
||||
output["creator_attributes"] = creator_attributes
|
||||
output["publish_attributes"] = self.publish_attributes.data_to_store()
|
||||
|
||||
return output
|
||||
|
||||
def set_create_attr_defs(self, attr_defs, value=None):
|
||||
"""Create plugin updates create attribute definitions.
|
||||
|
||||
Method called by create plugin when attribute definitions should
|
||||
be changed.
|
||||
|
||||
Args:
|
||||
attr_defs (List[AbstractAttrDef]): Attribute definitions.
|
||||
value (Optional[Dict[str, Any]]): Values of attribute definitions.
|
||||
Current values are used if not passed in.
|
||||
|
||||
"""
|
||||
if value is None:
|
||||
value = self._data["creator_attributes"]
|
||||
|
||||
if isinstance(value, AttributeValues):
|
||||
value = value.data_to_store()
|
||||
|
||||
if isinstance(self._data["creator_attributes"], AttributeValues):
|
||||
origin_data = self._data["creator_attributes"].origin_data
|
||||
else:
|
||||
origin_data = copy.deepcopy(self._data["creator_attributes"])
|
||||
self._data["creator_attributes"] = CreatorAttributeValues(
|
||||
self,
|
||||
"creator_attributes",
|
||||
attr_defs,
|
||||
value,
|
||||
origin_data
|
||||
)
|
||||
self._create_context.instance_create_attr_defs_changed(self.id)
|
||||
|
||||
@classmethod
|
||||
def from_existing(cls, instance_data, creator):
|
||||
def from_existing(
|
||||
cls,
|
||||
instance_data: Dict[str, Any],
|
||||
creator: "BaseCreator",
|
||||
transient_data: Optional[Dict[str, Any]] = None,
|
||||
) -> "CreatedInstance":
|
||||
"""Convert instance data from workfile to CreatedInstance.
|
||||
|
||||
Args:
|
||||
instance_data (Dict[str, Any]): Data in a structure ready for
|
||||
'CreatedInstance' object.
|
||||
creator (BaseCreator): Creator plugin which is creating the
|
||||
instance of for which the instance belong.
|
||||
"""
|
||||
instance of for which the instance belongs.
|
||||
transient_data (Optional[dict[str, Any]]): Instance transient
|
||||
data.
|
||||
|
||||
Returns:
|
||||
CreatedInstance: Instance object.
|
||||
|
||||
"""
|
||||
instance_data = copy.deepcopy(instance_data)
|
||||
|
||||
product_type = instance_data.get("productType")
|
||||
|
|
@ -773,21 +826,54 @@ class CreatedInstance:
|
|||
product_name = instance_data.get("subset")
|
||||
|
||||
return cls(
|
||||
product_type, product_name, instance_data, creator
|
||||
product_type,
|
||||
product_name,
|
||||
instance_data,
|
||||
creator,
|
||||
transient_data=transient_data,
|
||||
)
|
||||
|
||||
def set_publish_plugins(self, attr_plugins):
|
||||
"""Set publish plugins with attribute definitions.
|
||||
|
||||
This method should be called only from 'CreateContext'.
|
||||
def attribute_value_changed(self, key, changes):
|
||||
"""A value changed.
|
||||
|
||||
Args:
|
||||
attr_plugins (List[pyblish.api.Plugin]): Pyblish plugins which
|
||||
inherit from 'AYONPyblishPluginMixin' and may contain
|
||||
attribute definitions.
|
||||
"""
|
||||
key (str): Key of attribute values.
|
||||
changes (Dict[str, Any]): Changes in values.
|
||||
|
||||
self.publish_attributes.set_publish_plugins(attr_plugins)
|
||||
"""
|
||||
self._create_context.instance_values_changed(self.id, {key: changes})
|
||||
|
||||
def set_publish_plugin_attr_defs(self, plugin_name, attr_defs):
|
||||
"""Set attribute definitions for publish plugin.
|
||||
|
||||
Args:
|
||||
plugin_name(str): Name of publish plugin.
|
||||
attr_defs(List[AbstractAttrDef]): Attribute definitions.
|
||||
|
||||
"""
|
||||
self.publish_attributes.set_publish_plugin_attr_defs(
|
||||
plugin_name, attr_defs
|
||||
)
|
||||
self._create_context.instance_publish_attr_defs_changed(
|
||||
self.id, plugin_name
|
||||
)
|
||||
|
||||
def publish_attribute_value_changed(self, plugin_name, value):
|
||||
"""Method called from PublishAttributes.
|
||||
|
||||
Args:
|
||||
plugin_name (str): Plugin name.
|
||||
value (Dict[str, Any]): Changes in values for the plugin.
|
||||
|
||||
"""
|
||||
self._create_context.instance_values_changed(
|
||||
self.id,
|
||||
{
|
||||
"publish_attributes": {
|
||||
plugin_name: value,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
def add_members(self, members):
|
||||
"""Currently unused method."""
|
||||
|
|
@ -796,60 +882,12 @@ class CreatedInstance:
|
|||
if member not in self._members:
|
||||
self._members.append(member)
|
||||
|
||||
def serialize_for_remote(self):
|
||||
"""Serialize object into data to be possible recreated object.
|
||||
@property
|
||||
def _create_context(self):
|
||||
"""Get create context.
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: Serialized data.
|
||||
CreateContext: Context object which wraps object.
|
||||
|
||||
"""
|
||||
|
||||
creator_attr_defs = self.creator_attributes.get_serialized_attr_defs()
|
||||
publish_attributes = self.publish_attributes.serialize_attributes()
|
||||
return {
|
||||
"data": self.data_to_store(),
|
||||
"orig_data": self.origin_data,
|
||||
"creator_attr_defs": creator_attr_defs,
|
||||
"publish_attributes": publish_attributes,
|
||||
"creator_label": self._creator_label,
|
||||
"group_label": self._group_label,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def deserialize_on_remote(cls, serialized_data):
|
||||
"""Convert instance data to CreatedInstance.
|
||||
|
||||
This is fake instance in remote process e.g. in UI process. The creator
|
||||
is not a full creator and should not be used for calling methods when
|
||||
instance is created from this method (matters on implementation).
|
||||
|
||||
Args:
|
||||
serialized_data (Dict[str, Any]): Serialized data for remote
|
||||
recreating. Should contain 'data' and 'orig_data'.
|
||||
"""
|
||||
|
||||
instance_data = copy.deepcopy(serialized_data["data"])
|
||||
creator_identifier = instance_data["creator_identifier"]
|
||||
|
||||
product_type = instance_data["productType"]
|
||||
product_name = instance_data.get("productName", None)
|
||||
|
||||
creator_label = serialized_data["creator_label"]
|
||||
group_label = serialized_data["group_label"]
|
||||
creator_attr_defs = deserialize_attr_defs(
|
||||
serialized_data["creator_attr_defs"]
|
||||
)
|
||||
publish_attributes = serialized_data["publish_attributes"]
|
||||
|
||||
obj = cls(
|
||||
product_type,
|
||||
product_name,
|
||||
instance_data,
|
||||
creator_identifier=creator_identifier,
|
||||
creator_label=creator_label,
|
||||
group_label=group_label,
|
||||
creator_attr_defs=creator_attr_defs
|
||||
)
|
||||
obj._orig_data = serialized_data["orig_data"]
|
||||
obj.publish_attributes.deserialize_attributes(publish_attributes)
|
||||
|
||||
return obj
|
||||
return self._creator.create_context
|
||||
|
|
|
|||
|
|
@ -3,11 +3,20 @@ import os
|
|||
import copy
|
||||
import shutil
|
||||
import glob
|
||||
import clique
|
||||
import collections
|
||||
from typing import Dict, Any, Iterable
|
||||
|
||||
import clique
|
||||
import ayon_api
|
||||
|
||||
from ayon_core.lib import create_hard_link
|
||||
|
||||
from .template_data import (
|
||||
get_general_template_data,
|
||||
get_folder_template_data,
|
||||
get_task_template_data,
|
||||
)
|
||||
|
||||
|
||||
def _copy_file(src_path, dst_path):
|
||||
"""Hardlink file if possible(to save space), copy if not.
|
||||
|
|
@ -327,3 +336,82 @@ def deliver_sequence(
|
|||
uploaded += 1
|
||||
|
||||
return report_items, uploaded
|
||||
|
||||
|
||||
def _merge_data(data, new_data):
|
||||
queue = collections.deque()
|
||||
queue.append((data, new_data))
|
||||
while queue:
|
||||
q_data, q_new_data = queue.popleft()
|
||||
for key, value in q_new_data.items():
|
||||
if key in q_data and isinstance(value, dict):
|
||||
queue.append((q_data[key], value))
|
||||
continue
|
||||
q_data[key] = value
|
||||
|
||||
|
||||
def get_representations_delivery_template_data(
|
||||
project_name: str,
|
||||
representation_ids: Iterable[str],
|
||||
) -> Dict[str, Dict[str, Any]]:
|
||||
representation_ids = set(representation_ids)
|
||||
|
||||
output = {
|
||||
repre_id: {}
|
||||
for repre_id in representation_ids
|
||||
}
|
||||
if not representation_ids:
|
||||
return output
|
||||
|
||||
project_entity = ayon_api.get_project(project_name)
|
||||
|
||||
general_template_data = get_general_template_data()
|
||||
|
||||
repres_hierarchy = ayon_api.get_representations_hierarchy(
|
||||
project_name,
|
||||
representation_ids,
|
||||
project_fields=set(),
|
||||
folder_fields={"path", "folderType"},
|
||||
task_fields={"name", "taskType"},
|
||||
product_fields={"name", "productType"},
|
||||
version_fields={"version", "productId"},
|
||||
representation_fields=None,
|
||||
)
|
||||
for repre_id, repre_hierarchy in repres_hierarchy.items():
|
||||
repre_entity = repre_hierarchy.representation
|
||||
if repre_entity is None:
|
||||
continue
|
||||
|
||||
template_data = repre_entity["context"]
|
||||
# Bug in 'ayon_api', 'get_representations_hierarchy' did not fully
|
||||
# convert representation entity. Fixed in 'ayon_api' 1.0.10.
|
||||
if isinstance(template_data, str):
|
||||
con = ayon_api.get_server_api_connection()
|
||||
con._representation_conversion(repre_entity)
|
||||
template_data = repre_entity["context"]
|
||||
|
||||
template_data.update(copy.deepcopy(general_template_data))
|
||||
template_data.update(get_folder_template_data(
|
||||
repre_hierarchy.folder, project_name
|
||||
))
|
||||
if repre_hierarchy.task:
|
||||
template_data.update(get_task_template_data(
|
||||
project_entity, repre_hierarchy.task
|
||||
))
|
||||
|
||||
product_entity = repre_hierarchy.product
|
||||
version_entity = repre_hierarchy.version
|
||||
template_data.update({
|
||||
"product": {
|
||||
"name": product_entity["name"],
|
||||
"type": product_entity["productType"],
|
||||
},
|
||||
"version": version_entity["version"],
|
||||
})
|
||||
_merge_data(template_data, repre_entity["context"])
|
||||
|
||||
# Remove roots from template data to auto-fill them with anatomy data
|
||||
template_data.pop("root", None)
|
||||
|
||||
output[repre_id] = template_data
|
||||
return output
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import os
|
||||
import re
|
||||
import clique
|
||||
import math
|
||||
|
||||
import opentimelineio as otio
|
||||
from opentimelineio import opentime as _ot
|
||||
|
|
@ -196,11 +197,11 @@ def is_clip_from_media_sequence(otio_clip):
|
|||
return is_input_sequence or is_input_sequence_legacy
|
||||
|
||||
|
||||
def remap_range_on_file_sequence(otio_clip, in_out_range):
|
||||
def remap_range_on_file_sequence(otio_clip, otio_range):
|
||||
"""
|
||||
Args:
|
||||
otio_clip (otio.schema.Clip): The OTIO clip to check.
|
||||
in_out_range (tuple[float, float]): The in-out range to remap.
|
||||
otio_range (otio.schema.TimeRange): The trim range to apply.
|
||||
|
||||
Returns:
|
||||
tuple(int, int): The remapped range as discrete frame number.
|
||||
|
|
@ -211,36 +212,59 @@ def remap_range_on_file_sequence(otio_clip, in_out_range):
|
|||
if not is_clip_from_media_sequence(otio_clip):
|
||||
raise ValueError(f"Cannot map on non-file sequence clip {otio_clip}.")
|
||||
|
||||
try:
|
||||
media_in_trimmed, media_out_trimmed = in_out_range
|
||||
|
||||
except ValueError as error:
|
||||
raise ValueError("Invalid in_out_range provided.") from error
|
||||
|
||||
media_ref = otio_clip.media_reference
|
||||
available_range = otio_clip.available_range()
|
||||
source_range = otio_clip.source_range
|
||||
available_range_rate = available_range.start_time.rate
|
||||
media_in = available_range.start_time.value
|
||||
|
||||
# Backward-compatibility for Hiero OTIO exporter.
|
||||
# NTSC compatibility might introduce floating rates, when these are
|
||||
# not exactly the same (23.976 vs 23.976024627685547)
|
||||
# this will cause precision issue in computation.
|
||||
# Currently round to 2 decimals for comparison,
|
||||
# but this should always rescale after that.
|
||||
rounded_av_rate = round(available_range_rate, 2)
|
||||
rounded_range_rate = round(otio_range.start_time.rate, 2)
|
||||
|
||||
if rounded_av_rate != rounded_range_rate:
|
||||
raise ValueError("Inconsistent range between clip and provided clip")
|
||||
|
||||
source_range = otio_clip.source_range
|
||||
media_in = available_range.start_time
|
||||
available_range_start_frame = (
|
||||
available_range.start_time.to_frames()
|
||||
)
|
||||
|
||||
# Temporary.
|
||||
# Some AYON custom OTIO exporter were implemented with relative
|
||||
# source range for image sequence. Following code maintain
|
||||
# backward-compatibility by adjusting media_in
|
||||
# while we are updating those.
|
||||
conformed_src_in = source_range.start_time.rescaled_to(
|
||||
available_range_rate
|
||||
)
|
||||
if (
|
||||
is_clip_from_media_sequence(otio_clip)
|
||||
and otio_clip.available_range().start_time.to_frames() == media_ref.start_frame
|
||||
and source_range.start_time.to_frames() < media_ref.start_frame
|
||||
and available_range_start_frame == media_ref.start_frame
|
||||
and conformed_src_in.to_frames() < media_ref.start_frame
|
||||
):
|
||||
media_in = 0
|
||||
media_in = otio.opentime.RationalTime(
|
||||
0, rate=available_range_rate
|
||||
)
|
||||
|
||||
src_offset_in = otio_range.start_time - media_in
|
||||
frame_in = otio.opentime.RationalTime.from_frames(
|
||||
media_in_trimmed - media_in + media_ref.start_frame,
|
||||
media_ref.start_frame + src_offset_in.to_frames(),
|
||||
rate=available_range_rate,
|
||||
).to_frames()
|
||||
|
||||
# e.g.:
|
||||
# duration = 10 frames at 24fps
|
||||
# if frame_in = 1001 then
|
||||
# frame_out = 1010
|
||||
offset_duration = max(0, otio_range.duration.to_frames() - 1)
|
||||
|
||||
frame_out = otio.opentime.RationalTime.from_frames(
|
||||
media_out_trimmed - media_in + media_ref.start_frame,
|
||||
frame_in + offset_duration,
|
||||
rate=available_range_rate,
|
||||
).to_frames()
|
||||
|
||||
|
|
@ -258,21 +282,6 @@ def get_media_range_with_retimes(otio_clip, handle_start, handle_end):
|
|||
media_ref = otio_clip.media_reference
|
||||
is_input_sequence = is_clip_from_media_sequence(otio_clip)
|
||||
|
||||
# Temporary.
|
||||
# Some AYON custom OTIO exporter were implemented with relative
|
||||
# source range for image sequence. Following code maintain
|
||||
# backward-compatibility by adjusting available range
|
||||
# while we are updating those.
|
||||
if (
|
||||
is_input_sequence
|
||||
and available_range.start_time.to_frames() == media_ref.start_frame
|
||||
and source_range.start_time.to_frames() < media_ref.start_frame
|
||||
):
|
||||
available_range = _ot.TimeRange(
|
||||
_ot.RationalTime(0, rate=available_range_rate),
|
||||
available_range.duration,
|
||||
)
|
||||
|
||||
# Conform source range bounds to available range rate
|
||||
# .e.g. embedded TC of (3600 sec/ 1h), duration 100 frames
|
||||
#
|
||||
|
|
@ -303,8 +312,12 @@ def get_media_range_with_retimes(otio_clip, handle_start, handle_end):
|
|||
rounded_av_rate = round(available_range_rate, 2)
|
||||
rounded_src_rate = round(source_range.start_time.rate, 2)
|
||||
if rounded_av_rate != rounded_src_rate:
|
||||
conformed_src_in = source_range.start_time.rescaled_to(available_range_rate)
|
||||
conformed_src_duration = source_range.duration.rescaled_to(available_range_rate)
|
||||
conformed_src_in = source_range.start_time.rescaled_to(
|
||||
available_range_rate
|
||||
)
|
||||
conformed_src_duration = source_range.duration.rescaled_to(
|
||||
available_range_rate
|
||||
)
|
||||
conformed_source_range = otio.opentime.TimeRange(
|
||||
start_time=conformed_src_in,
|
||||
duration=conformed_src_duration
|
||||
|
|
@ -313,10 +326,24 @@ def get_media_range_with_retimes(otio_clip, handle_start, handle_end):
|
|||
else:
|
||||
conformed_source_range = source_range
|
||||
|
||||
# Temporary.
|
||||
# Some AYON custom OTIO exporter were implemented with relative
|
||||
# source range for image sequence. Following code maintain
|
||||
# backward-compatibility by adjusting available range
|
||||
# while we are updating those.
|
||||
if (
|
||||
is_input_sequence
|
||||
and available_range.start_time.to_frames() == media_ref.start_frame
|
||||
and conformed_source_range.start_time.to_frames() <
|
||||
media_ref.start_frame
|
||||
):
|
||||
available_range = _ot.TimeRange(
|
||||
_ot.RationalTime(0, rate=available_range_rate),
|
||||
available_range.duration,
|
||||
)
|
||||
|
||||
# modifiers
|
||||
time_scalar = 1.
|
||||
offset_in = 0
|
||||
offset_out = 0
|
||||
time_warp_nodes = []
|
||||
|
||||
# Check for speed effects and adjust playback speed accordingly
|
||||
|
|
@ -347,51 +374,134 @@ def get_media_range_with_retimes(otio_clip, handle_start, handle_end):
|
|||
tw_node.update(metadata)
|
||||
tw_node["lookup"] = list(lookup)
|
||||
|
||||
# get first and last frame offsets
|
||||
offset_in += lookup[0]
|
||||
offset_out += lookup[-1]
|
||||
|
||||
# add to timewarp nodes
|
||||
time_warp_nodes.append(tw_node)
|
||||
|
||||
# multiply by time scalar
|
||||
offset_in *= time_scalar
|
||||
offset_out *= time_scalar
|
||||
|
||||
# scale handles
|
||||
handle_start *= abs(time_scalar)
|
||||
handle_end *= abs(time_scalar)
|
||||
|
||||
# flip offset and handles if reversed speed
|
||||
if time_scalar < 0:
|
||||
offset_in, offset_out = offset_out, offset_in
|
||||
handle_start, handle_end = handle_end, handle_start
|
||||
|
||||
# compute retimed range
|
||||
media_in_trimmed = conformed_source_range.start_time.value + offset_in
|
||||
media_out_trimmed = media_in_trimmed + (
|
||||
(
|
||||
conformed_source_range.duration.value
|
||||
* abs(time_scalar)
|
||||
+ offset_out
|
||||
) - 1
|
||||
)
|
||||
|
||||
media_in = available_range.start_time.value
|
||||
media_out = available_range.end_time_inclusive().value
|
||||
|
||||
# If media source is an image sequence, returned
|
||||
# mediaIn/mediaOut have to correspond
|
||||
# to frame numbers from source sequence.
|
||||
if is_input_sequence:
|
||||
|
||||
src_in = conformed_source_range.start_time
|
||||
src_duration = math.ceil(
|
||||
otio_clip.source_range.duration.value
|
||||
* abs(time_scalar)
|
||||
)
|
||||
retimed_duration = otio.opentime.RationalTime(
|
||||
src_duration,
|
||||
otio_clip.source_range.duration.rate
|
||||
)
|
||||
retimed_duration = retimed_duration.rescaled_to(src_in.rate)
|
||||
|
||||
trim_range = otio.opentime.TimeRange(
|
||||
start_time=src_in,
|
||||
duration=retimed_duration,
|
||||
)
|
||||
|
||||
# preserve discrete frame numbers
|
||||
media_in_trimmed, media_out_trimmed = remap_range_on_file_sequence(
|
||||
otio_clip,
|
||||
(media_in_trimmed, media_out_trimmed)
|
||||
trim_range,
|
||||
)
|
||||
media_in = media_ref.start_frame
|
||||
media_out = media_in + available_range.duration.to_frames() - 1
|
||||
|
||||
else:
|
||||
# compute retimed range
|
||||
media_in_trimmed = conformed_source_range.start_time.value
|
||||
|
||||
offset_duration = (
|
||||
conformed_source_range.duration.value
|
||||
* abs(time_scalar)
|
||||
)
|
||||
|
||||
# Offset duration by 1 for media out frame
|
||||
# - only if duration is not single frame (start frame != end frame)
|
||||
if offset_duration > 0:
|
||||
offset_duration -= 1
|
||||
media_out_trimmed = media_in_trimmed + offset_duration
|
||||
|
||||
media_in = available_range.start_time.value
|
||||
media_out = available_range.end_time_inclusive().value
|
||||
|
||||
if time_warp_nodes:
|
||||
# Naive approach: Resolve consecutive timewarp(s) on range,
|
||||
# then check if plate range has to be extended beyond source range.
|
||||
in_frame = media_in_trimmed
|
||||
frame_range = [in_frame]
|
||||
for _ in range(otio_clip.source_range.duration.to_frames() - 1):
|
||||
in_frame += time_scalar
|
||||
frame_range.append(in_frame)
|
||||
|
||||
# Different editorial DCC might have different TimeWarp logic.
|
||||
# The following logic assumes that the "lookup" list values are
|
||||
# frame offsets relative to the current source frame number.
|
||||
#
|
||||
# media_source_range |______1_____|______2______|______3______|
|
||||
#
|
||||
# media_retimed_range |______2_____|______2______|______3______|
|
||||
#
|
||||
# TimeWarp lookup +1 0 0
|
||||
for tw_idx, tw in enumerate(time_warp_nodes):
|
||||
for idx, frame_number in enumerate(frame_range):
|
||||
# First timewarp, apply on media range
|
||||
if tw_idx == 0:
|
||||
frame_range[idx] = round(
|
||||
frame_number +
|
||||
(tw["lookup"][idx] * time_scalar)
|
||||
)
|
||||
# Consecutive timewarp, apply on the previous result
|
||||
else:
|
||||
new_idx = round(idx + tw["lookup"][idx])
|
||||
|
||||
if 0 <= new_idx < len(frame_range):
|
||||
frame_range[idx] = frame_range[new_idx]
|
||||
continue
|
||||
|
||||
# TODO: implementing this would need to actually have
|
||||
# retiming engine resolve process within AYON,
|
||||
# resolving wraps as curves, then projecting
|
||||
# those into the previous media_range.
|
||||
raise NotImplementedError(
|
||||
"Unsupported consecutive timewarps "
|
||||
"(out of computed range)"
|
||||
)
|
||||
|
||||
# adjust range if needed
|
||||
media_in_trimmed_before_tw = media_in_trimmed
|
||||
media_in_trimmed = max(min(frame_range), media_in)
|
||||
media_out_trimmed = min(max(frame_range), media_out)
|
||||
|
||||
# If TimeWarp changes the first frame of the soure range,
|
||||
# we need to offset the first TimeWarp values accordingly.
|
||||
#
|
||||
# expected_range |______2_____|______2______|______3______|
|
||||
#
|
||||
# EDITORIAL
|
||||
# media_source_range |______1_____|______2______|______3______|
|
||||
#
|
||||
# TimeWarp lookup +1 0 0
|
||||
#
|
||||
# EXTRACTED PLATE
|
||||
# plate_range |______2_____|______3______|_ _ _ _ _ _ _|
|
||||
#
|
||||
# expected TimeWarp 0 -1 -1
|
||||
if media_in_trimmed != media_in_trimmed_before_tw:
|
||||
offset = media_in_trimmed_before_tw - media_in_trimmed
|
||||
offset *= 1.0 / time_scalar
|
||||
time_warp_nodes[0]["lookup"] = [
|
||||
value + offset
|
||||
for value in time_warp_nodes[0]["lookup"]
|
||||
]
|
||||
|
||||
# adjust available handles if needed
|
||||
if (media_in_trimmed - media_in) < handle_start:
|
||||
handle_start = max(0, media_in_trimmed - media_in)
|
||||
|
|
@ -410,16 +520,16 @@ def get_media_range_with_retimes(otio_clip, handle_start, handle_end):
|
|||
"retime": True,
|
||||
"speed": time_scalar,
|
||||
"timewarps": time_warp_nodes,
|
||||
"handleStart": int(handle_start),
|
||||
"handleEnd": int(handle_end)
|
||||
"handleStart": math.ceil(handle_start),
|
||||
"handleEnd": math.ceil(handle_end)
|
||||
}
|
||||
}
|
||||
|
||||
returning_dict = {
|
||||
"mediaIn": media_in_trimmed,
|
||||
"mediaOut": media_out_trimmed,
|
||||
"handleStart": int(handle_start),
|
||||
"handleEnd": int(handle_end),
|
||||
"handleStart": math.ceil(handle_start),
|
||||
"handleEnd": math.ceil(handle_end),
|
||||
"speed": time_scalar
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -18,13 +18,13 @@ def parse_ayon_entity_uri(uri: str) -> Optional[dict]:
|
|||
|
||||
Example:
|
||||
>>> parse_ayon_entity_uri(
|
||||
>>> "ayon://test/char/villain?product=modelMain&version=2&representation=usd" # noqa: E501
|
||||
>>> "ayon://test/char/villain?product=modelMain&version=2&representation=usd"
|
||||
>>> )
|
||||
{'project': 'test', 'folderPath': '/char/villain',
|
||||
'product': 'modelMain', 'version': 1,
|
||||
'representation': 'usd'}
|
||||
>>> parse_ayon_entity_uri(
|
||||
>>> "ayon+entity://project/folder?product=renderMain&version=3&representation=exr" # noqa: E501
|
||||
>>> "ayon+entity://project/folder?product=renderMain&version=3&representation=exr"
|
||||
>>> )
|
||||
{'project': 'project', 'folderPath': '/folder',
|
||||
'product': 'renderMain', 'version': 3,
|
||||
|
|
@ -34,7 +34,7 @@ def parse_ayon_entity_uri(uri: str) -> Optional[dict]:
|
|||
dict[str, Union[str, int]]: The individual key with their values as
|
||||
found in the ayon entity URI.
|
||||
|
||||
"""
|
||||
""" # noqa: E501
|
||||
|
||||
if not (uri.startswith("ayon+entity://") or uri.startswith("ayon://")):
|
||||
return {}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,10 @@ import attr
|
|||
import ayon_api
|
||||
import clique
|
||||
from ayon_core.lib import Logger
|
||||
from ayon_core.pipeline import get_current_project_name, get_representation_path
|
||||
from ayon_core.pipeline import (
|
||||
get_current_project_name,
|
||||
get_representation_path,
|
||||
)
|
||||
from ayon_core.pipeline.create import get_product_name
|
||||
from ayon_core.pipeline.farm.patterning import match_aov_pattern
|
||||
from ayon_core.pipeline.publish import KnownPublishError
|
||||
|
|
@ -295,11 +298,17 @@ def _add_review_families(families):
|
|||
return families
|
||||
|
||||
|
||||
def prepare_representations(skeleton_data, exp_files, anatomy, aov_filter,
|
||||
skip_integration_repre_list,
|
||||
do_not_add_review,
|
||||
context,
|
||||
color_managed_plugin):
|
||||
def prepare_representations(
|
||||
skeleton_data,
|
||||
exp_files,
|
||||
anatomy,
|
||||
aov_filter,
|
||||
skip_integration_repre_list,
|
||||
do_not_add_review,
|
||||
context,
|
||||
color_managed_plugin,
|
||||
frames_to_render=None
|
||||
):
|
||||
"""Create representations for file sequences.
|
||||
|
||||
This will return representations of expected files if they are not
|
||||
|
|
@ -315,6 +324,8 @@ def prepare_representations(skeleton_data, exp_files, anatomy, aov_filter,
|
|||
skip_integration_repre_list (list): exclude specific extensions,
|
||||
do_not_add_review (bool): explicitly skip review
|
||||
color_managed_plugin (publish.ColormanagedPyblishPluginMixin)
|
||||
frames_to_render (str): implicit or explicit range of frames to render
|
||||
this value is sent to Deadline in JobInfo.Frames
|
||||
Returns:
|
||||
list of representations
|
||||
|
||||
|
|
@ -325,6 +336,14 @@ def prepare_representations(skeleton_data, exp_files, anatomy, aov_filter,
|
|||
|
||||
log = Logger.get_logger("farm_publishing")
|
||||
|
||||
if frames_to_render is not None:
|
||||
frames_to_render = _get_real_frames_to_render(frames_to_render)
|
||||
else:
|
||||
# Backwards compatibility for older logic
|
||||
frame_start = int(skeleton_data.get("frameStartHandle"))
|
||||
frame_end = int(skeleton_data.get("frameEndHandle"))
|
||||
frames_to_render = list(range(frame_start, frame_end + 1))
|
||||
|
||||
# create representation for every collected sequence
|
||||
for collection in collections:
|
||||
ext = collection.tail.lstrip(".")
|
||||
|
|
@ -361,18 +380,21 @@ def prepare_representations(skeleton_data, exp_files, anatomy, aov_filter,
|
|||
" This may cause issues on farm."
|
||||
).format(staging))
|
||||
|
||||
frame_start = int(skeleton_data.get("frameStartHandle"))
|
||||
frame_start = frames_to_render[0]
|
||||
frame_end = frames_to_render[-1]
|
||||
if skeleton_data.get("slate"):
|
||||
frame_start -= 1
|
||||
frames_to_render.insert(0, frame_start)
|
||||
|
||||
files = _get_real_files_to_render(collection, frames_to_render)
|
||||
# explicitly disable review by user
|
||||
preview = preview and not do_not_add_review
|
||||
rep = {
|
||||
"name": ext,
|
||||
"ext": ext,
|
||||
"files": [os.path.basename(f) for f in list(collection)],
|
||||
"files": files,
|
||||
"frameStart": frame_start,
|
||||
"frameEnd": int(skeleton_data.get("frameEndHandle")),
|
||||
"frameEnd": frame_end,
|
||||
# If expectedFile are absolute, we need only filenames
|
||||
"stagingDir": staging,
|
||||
"fps": skeleton_data.get("fps"),
|
||||
|
|
@ -453,6 +475,61 @@ def prepare_representations(skeleton_data, exp_files, anatomy, aov_filter,
|
|||
return representations
|
||||
|
||||
|
||||
def _get_real_frames_to_render(frames):
|
||||
"""Returns list of frames that should be rendered.
|
||||
|
||||
Artists could want to selectively render only particular frames
|
||||
"""
|
||||
frames_to_render = []
|
||||
for frame in frames.split(","):
|
||||
if "-" in frame:
|
||||
splitted = frame.split("-")
|
||||
frames_to_render.extend(
|
||||
range(int(splitted[0]), int(splitted[1])+1))
|
||||
else:
|
||||
frames_to_render.append(int(frame))
|
||||
frames_to_render.sort()
|
||||
return frames_to_render
|
||||
|
||||
|
||||
def _get_real_files_to_render(collection, frames_to_render):
|
||||
"""Filter files with frames that should be really rendered.
|
||||
|
||||
'expected_files' are collected from DCC based on timeline setting. This is
|
||||
being calculated differently in each DCC. Filtering here is on single place
|
||||
|
||||
But artists might explicitly set frames they want to render in Publisher UI
|
||||
This range would override and filter previously prepared expected files
|
||||
from DCC.
|
||||
|
||||
Args:
|
||||
collection (clique.Collection): absolute paths
|
||||
frames_to_render (list[int]): of int 1001
|
||||
Returns:
|
||||
(list[str])
|
||||
|
||||
Example:
|
||||
--------
|
||||
|
||||
expectedFiles = [
|
||||
"foo_v01.0001.exr",
|
||||
"foo_v01.0002.exr",
|
||||
]
|
||||
frames_to_render = 1
|
||||
>>
|
||||
["foo_v01.0001.exr"] - only explicitly requested frame returned
|
||||
"""
|
||||
included_frames = set(collection.indexes).intersection(frames_to_render)
|
||||
real_collection = clique.Collection(
|
||||
collection.head,
|
||||
collection.tail,
|
||||
collection.padding,
|
||||
indexes=included_frames
|
||||
)
|
||||
real_full_paths = list(real_collection)
|
||||
return [os.path.basename(file_url) for file_url in real_full_paths]
|
||||
|
||||
|
||||
def create_instances_for_aov(instance, skeleton, aov_filter,
|
||||
skip_integration_repre_list,
|
||||
do_not_add_review):
|
||||
|
|
@ -702,9 +779,14 @@ def _create_instances_for_aov(instance, skeleton, aov_filter, additional_data,
|
|||
|
||||
project_settings = instance.context.data.get("project_settings")
|
||||
|
||||
use_legacy_product_name = True
|
||||
try:
|
||||
use_legacy_product_name = project_settings["core"]["tools"]["creator"]["use_legacy_product_names_for_renders"] # noqa: E501
|
||||
use_legacy_product_name = (
|
||||
project_settings
|
||||
["core"]
|
||||
["tools"]
|
||||
["creator"]
|
||||
["use_legacy_product_names_for_renders"]
|
||||
)
|
||||
except KeyError:
|
||||
warnings.warn(
|
||||
("use_legacy_for_renders not found in project settings. "
|
||||
|
|
@ -720,7 +802,9 @@ def _create_instances_for_aov(instance, skeleton, aov_filter, additional_data,
|
|||
dynamic_data=dynamic_data)
|
||||
|
||||
else:
|
||||
product_name, group_name = get_product_name_and_group_from_template(
|
||||
(
|
||||
product_name, group_name
|
||||
) = get_product_name_and_group_from_template(
|
||||
task_entity=instance.data["taskEntity"],
|
||||
project_name=instance.context.data["projectName"],
|
||||
host_name=instance.context.data["hostName"],
|
||||
|
|
@ -788,15 +872,15 @@ def _create_instances_for_aov(instance, skeleton, aov_filter, additional_data,
|
|||
colorspace = product.colorspace
|
||||
break
|
||||
|
||||
if isinstance(files, (list, tuple)):
|
||||
files = [os.path.basename(f) for f in files]
|
||||
if isinstance(collected_files, (list, tuple)):
|
||||
collected_files = [os.path.basename(f) for f in collected_files]
|
||||
else:
|
||||
files = os.path.basename(files)
|
||||
collected_files = os.path.basename(collected_files)
|
||||
|
||||
rep = {
|
||||
"name": ext,
|
||||
"ext": ext,
|
||||
"files": files,
|
||||
"files": collected_files,
|
||||
"frameStart": int(skeleton["frameStartHandle"]),
|
||||
"frameEnd": int(skeleton["frameEndHandle"]),
|
||||
# If expectedFile are absolute, we need only filenames
|
||||
|
|
@ -863,7 +947,7 @@ def _collect_expected_files_for_aov(files):
|
|||
# but we really expect only one collection.
|
||||
# Nothing else make sense.
|
||||
if len(cols) != 1:
|
||||
raise ValueError("Only one image sequence type is expected.") # noqa: E501
|
||||
raise ValueError("Only one image sequence type is expected.")
|
||||
return list(cols[0])
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -242,6 +242,26 @@ class LoaderPlugin(list):
|
|||
if hasattr(self, "_fname"):
|
||||
return self._fname
|
||||
|
||||
@classmethod
|
||||
def get_representation_name_aliases(cls, representation_name: str):
|
||||
"""Return representation names to which switching is allowed from
|
||||
the input representation name, like an alias replacement of the input
|
||||
`representation_name`.
|
||||
|
||||
For example, to allow an automated switch on update from representation
|
||||
`ma` to `mb` or `abc`, then when `representation_name` is `ma` return:
|
||||
["mb", "abc"]
|
||||
|
||||
The order of the names in the returned representation names is
|
||||
important, because the first one existing under the new version will
|
||||
be chosen.
|
||||
|
||||
Returns:
|
||||
List[str]: Representation names switching to is allowed on update
|
||||
if the input representation name is not found on the new version.
|
||||
"""
|
||||
return []
|
||||
|
||||
|
||||
class ProductLoaderPlugin(LoaderPlugin):
|
||||
"""Load product into host application
|
||||
|
|
|
|||
|
|
@ -465,7 +465,9 @@ def update_container(container, version=-1):
|
|||
from ayon_core.pipeline import get_current_project_name
|
||||
|
||||
# Compute the different version from 'representation'
|
||||
project_name = get_current_project_name()
|
||||
project_name = container.get("project_name")
|
||||
if project_name is None:
|
||||
project_name = get_current_project_name()
|
||||
repre_id = container["representation"]
|
||||
if not _is_valid_representation_id(repre_id):
|
||||
raise ValueError(
|
||||
|
|
@ -505,21 +507,6 @@ def update_container(container, version=-1):
|
|||
project_name, product_entity["folderId"]
|
||||
)
|
||||
|
||||
repre_name = current_representation["name"]
|
||||
new_representation = ayon_api.get_representation_by_name(
|
||||
project_name, repre_name, new_version["id"]
|
||||
)
|
||||
if new_representation is None:
|
||||
raise ValueError(
|
||||
"Representation '{}' wasn't found on requested version".format(
|
||||
repre_name
|
||||
)
|
||||
)
|
||||
|
||||
path = get_representation_path(new_representation)
|
||||
if not path or not os.path.exists(path):
|
||||
raise ValueError("Path {} doesn't exist".format(path))
|
||||
|
||||
# Run update on the Loader for this container
|
||||
Loader = _get_container_loader(container)
|
||||
if not Loader:
|
||||
|
|
@ -527,6 +514,36 @@ def update_container(container, version=-1):
|
|||
"Can't update container because loader '{}' was not found."
|
||||
.format(container.get("loader"))
|
||||
)
|
||||
|
||||
repre_name = current_representation["name"]
|
||||
new_representation = ayon_api.get_representation_by_name(
|
||||
project_name, repre_name, new_version["id"]
|
||||
)
|
||||
if new_representation is None:
|
||||
# The representation name is not found in the new version.
|
||||
# Allow updating to a 'matching' representation if the loader
|
||||
# has defined compatible update conversions
|
||||
repre_name_aliases = Loader.get_representation_name_aliases(repre_name)
|
||||
if repre_name_aliases:
|
||||
representations = ayon_api.get_representations(
|
||||
project_name,
|
||||
representation_names=repre_name_aliases,
|
||||
version_ids=[new_version["id"]])
|
||||
representations_by_name = {
|
||||
repre["name"]: repre for repre in representations
|
||||
}
|
||||
for name in repre_name_aliases:
|
||||
if name in representations_by_name:
|
||||
new_representation = representations_by_name[name]
|
||||
break
|
||||
|
||||
if new_representation is None:
|
||||
raise ValueError(
|
||||
"Representation '{}' wasn't found on requested version".format(
|
||||
repre_name
|
||||
)
|
||||
)
|
||||
|
||||
project_entity = ayon_api.get_project(project_name)
|
||||
context = {
|
||||
"project": project_entity,
|
||||
|
|
@ -535,6 +552,9 @@ def update_container(container, version=-1):
|
|||
"version": new_version,
|
||||
"representation": new_representation,
|
||||
}
|
||||
path = get_representation_path_from_context(context)
|
||||
if not path or not os.path.exists(path):
|
||||
raise ValueError("Path {} doesn't exist".format(path))
|
||||
|
||||
return Loader().update(container, context)
|
||||
|
||||
|
|
@ -570,7 +590,9 @@ def switch_container(container, representation, loader_plugin=None):
|
|||
)
|
||||
|
||||
# Get the new representation to switch to
|
||||
project_name = get_current_project_name()
|
||||
project_name = container.get("project_name")
|
||||
if project_name is None:
|
||||
project_name = get_current_project_name()
|
||||
|
||||
context = get_representation_context(
|
||||
project_name, representation["id"]
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ from .constants import (
|
|||
ValidateContentsOrder,
|
||||
ValidateSceneOrder,
|
||||
ValidateMeshOrder,
|
||||
FARM_JOB_ENV_DATA_KEY,
|
||||
)
|
||||
|
||||
from .publish_plugins import (
|
||||
|
|
@ -59,6 +60,7 @@ __all__ = (
|
|||
"ValidateContentsOrder",
|
||||
"ValidateSceneOrder",
|
||||
"ValidateMeshOrder",
|
||||
"FARM_JOB_ENV_DATA_KEY",
|
||||
|
||||
"AbstractMetaInstancePlugin",
|
||||
"AbstractMetaContextPlugin",
|
||||
|
|
|
|||
|
|
@ -8,4 +8,5 @@ ValidateMeshOrder = pyblish.api.ValidatorOrder + 0.3
|
|||
|
||||
DEFAULT_PUBLISH_TEMPLATE = "default"
|
||||
DEFAULT_HERO_PUBLISH_TEMPLATE = "default"
|
||||
TRANSIENT_DIR_TEMPLATE = "default"
|
||||
|
||||
FARM_JOB_ENV_DATA_KEY: str = "farmJobEnv"
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import os
|
|||
import sys
|
||||
import inspect
|
||||
import copy
|
||||
import tempfile
|
||||
import warnings
|
||||
import xml.etree.ElementTree
|
||||
from typing import Optional, Union, List
|
||||
|
||||
|
|
@ -18,15 +18,11 @@ from ayon_core.lib import (
|
|||
)
|
||||
from ayon_core.settings import get_project_settings
|
||||
from ayon_core.addon import AddonsManager
|
||||
from ayon_core.pipeline import (
|
||||
tempdir,
|
||||
Anatomy
|
||||
)
|
||||
from ayon_core.pipeline import get_staging_dir_info
|
||||
from ayon_core.pipeline.plugin_discover import DiscoverResult
|
||||
from .constants import (
|
||||
DEFAULT_PUBLISH_TEMPLATE,
|
||||
DEFAULT_HERO_PUBLISH_TEMPLATE,
|
||||
TRANSIENT_DIR_TEMPLATE
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -468,6 +464,12 @@ def filter_pyblish_plugins(plugins):
|
|||
if getattr(plugin, "enabled", True) is False:
|
||||
plugins.remove(plugin)
|
||||
|
||||
# Pyblish already operated a filter based on host.
|
||||
# But applying settings might have changed "hosts"
|
||||
# value in plugin so re-filter.
|
||||
elif not pyblish.plugin.host_is_compatible(plugin):
|
||||
plugins.remove(plugin)
|
||||
|
||||
|
||||
def get_errored_instances_from_context(context, plugin=None):
|
||||
"""Collect failed instances from pyblish context.
|
||||
|
|
@ -581,58 +583,6 @@ def context_plugin_should_run(plugin, context):
|
|||
return False
|
||||
|
||||
|
||||
def get_instance_staging_dir(instance):
|
||||
"""Unified way how staging dir is stored and created on instances.
|
||||
|
||||
First check if 'stagingDir' is already set in instance data.
|
||||
In case there already is new tempdir will not be created.
|
||||
|
||||
It also supports `AYON_TMPDIR`, so studio can define own temp
|
||||
shared repository per project or even per more granular context.
|
||||
Template formatting is supported also with optional keys. Folder is
|
||||
created in case it doesn't exists.
|
||||
|
||||
Available anatomy formatting keys:
|
||||
- root[work | <root name key>]
|
||||
- project[name | code]
|
||||
|
||||
Note:
|
||||
Staging dir does not have to be necessarily in tempdir so be careful
|
||||
about its usage.
|
||||
|
||||
Args:
|
||||
instance (pyblish.lib.Instance): Instance for which we want to get
|
||||
staging dir.
|
||||
|
||||
Returns:
|
||||
str: Path to staging dir of instance.
|
||||
"""
|
||||
staging_dir = instance.data.get('stagingDir')
|
||||
if staging_dir:
|
||||
return staging_dir
|
||||
|
||||
anatomy = instance.context.data.get("anatomy")
|
||||
|
||||
# get customized tempdir path from `AYON_TMPDIR` env var
|
||||
custom_temp_dir = tempdir.create_custom_tempdir(
|
||||
anatomy.project_name, anatomy)
|
||||
|
||||
if custom_temp_dir:
|
||||
staging_dir = os.path.normpath(
|
||||
tempfile.mkdtemp(
|
||||
prefix="pyblish_tmp_",
|
||||
dir=custom_temp_dir
|
||||
)
|
||||
)
|
||||
else:
|
||||
staging_dir = os.path.normpath(
|
||||
tempfile.mkdtemp(prefix="pyblish_tmp_")
|
||||
)
|
||||
instance.data['stagingDir'] = staging_dir
|
||||
|
||||
return staging_dir
|
||||
|
||||
|
||||
def get_publish_repre_path(instance, repre, only_published=False):
|
||||
"""Get representation path that can be used for integration.
|
||||
|
||||
|
|
@ -685,6 +635,8 @@ def get_publish_repre_path(instance, repre, only_published=False):
|
|||
return None
|
||||
|
||||
|
||||
# deprecated: backward compatibility only (2024-09-12)
|
||||
# TODO: remove in the future
|
||||
def get_custom_staging_dir_info(
|
||||
project_name,
|
||||
host_name,
|
||||
|
|
@ -694,67 +646,88 @@ def get_custom_staging_dir_info(
|
|||
product_name,
|
||||
project_settings=None,
|
||||
anatomy=None,
|
||||
log=None
|
||||
log=None,
|
||||
):
|
||||
"""Checks profiles if context should use special custom dir as staging.
|
||||
from ayon_core.pipeline.staging_dir import get_staging_dir_config
|
||||
warnings.warn(
|
||||
(
|
||||
"Function 'get_custom_staging_dir_info' in"
|
||||
" 'ayon_core.pipeline.publish' is deprecated. Please use"
|
||||
" 'get_custom_staging_dir_info'"
|
||||
" in 'ayon_core.pipeline.stagingdir'."
|
||||
),
|
||||
DeprecationWarning,
|
||||
)
|
||||
tr_data = get_staging_dir_config(
|
||||
project_name,
|
||||
task_type,
|
||||
task_name,
|
||||
product_type,
|
||||
product_name,
|
||||
host_name,
|
||||
project_settings=project_settings,
|
||||
anatomy=anatomy,
|
||||
log=log,
|
||||
)
|
||||
|
||||
Args:
|
||||
project_name (str)
|
||||
host_name (str)
|
||||
product_type (str)
|
||||
task_name (str)
|
||||
task_type (str)
|
||||
product_name (str)
|
||||
project_settings(Dict[str, Any]): Prepared project settings.
|
||||
anatomy (Dict[str, Any])
|
||||
log (Logger) (optional)
|
||||
if not tr_data:
|
||||
return None, None
|
||||
|
||||
return tr_data["template"], tr_data["persistence"]
|
||||
|
||||
|
||||
def get_instance_staging_dir(instance):
|
||||
"""Unified way how staging dir is stored and created on instances.
|
||||
|
||||
First check if 'stagingDir' is already set in instance data.
|
||||
In case there already is new tempdir will not be created.
|
||||
|
||||
Returns:
|
||||
(tuple)
|
||||
Raises:
|
||||
ValueError - if misconfigured template should be used
|
||||
str: Path to staging dir
|
||||
"""
|
||||
settings = project_settings or get_project_settings(project_name)
|
||||
custom_staging_dir_profiles = (settings["core"]
|
||||
["tools"]
|
||||
["publish"]
|
||||
["custom_staging_dir_profiles"])
|
||||
if not custom_staging_dir_profiles:
|
||||
return None, None
|
||||
staging_dir = instance.data.get("stagingDir")
|
||||
|
||||
if not log:
|
||||
log = Logger.get_logger("get_custom_staging_dir_info")
|
||||
if staging_dir:
|
||||
return staging_dir
|
||||
|
||||
filtering_criteria = {
|
||||
"hosts": host_name,
|
||||
"families": product_type,
|
||||
"task_names": task_name,
|
||||
"task_types": task_type,
|
||||
"subsets": product_name
|
||||
}
|
||||
profile = filter_profiles(custom_staging_dir_profiles,
|
||||
filtering_criteria,
|
||||
logger=log)
|
||||
anatomy_data = instance.data["anatomyData"]
|
||||
template_data = copy.deepcopy(anatomy_data)
|
||||
|
||||
if not profile or not profile["active"]:
|
||||
return None, None
|
||||
# context data based variables
|
||||
context = instance.context
|
||||
|
||||
if not anatomy:
|
||||
anatomy = Anatomy(project_name)
|
||||
# add current file as workfile name into formatting data
|
||||
current_file = context.data.get("currentFile")
|
||||
if current_file:
|
||||
workfile = os.path.basename(current_file)
|
||||
workfile_name, _ = os.path.splitext(workfile)
|
||||
template_data["workfile_name"] = workfile_name
|
||||
|
||||
template_name = profile["template_name"] or TRANSIENT_DIR_TEMPLATE
|
||||
|
||||
custom_staging_dir = anatomy.get_template_item(
|
||||
"staging", template_name, "directory", default=None
|
||||
staging_dir_info = get_staging_dir_info(
|
||||
context.data["projectEntity"],
|
||||
instance.data.get("folderEntity"),
|
||||
instance.data.get("taskEntity"),
|
||||
instance.data["productType"],
|
||||
instance.data["productName"],
|
||||
context.data["hostName"],
|
||||
anatomy=context.data["anatomy"],
|
||||
project_settings=context.data["project_settings"],
|
||||
template_data=template_data,
|
||||
always_return_path=True,
|
||||
username=context.data["user"],
|
||||
)
|
||||
if custom_staging_dir is None:
|
||||
raise ValueError((
|
||||
"Anatomy of project \"{}\" does not have set"
|
||||
" \"{}\" template key!"
|
||||
).format(project_name, template_name))
|
||||
is_persistent = profile["custom_staging_dir_persistent"]
|
||||
|
||||
return custom_staging_dir.template, is_persistent
|
||||
staging_dir_path = staging_dir_info.directory
|
||||
|
||||
# path might be already created by get_staging_dir_info
|
||||
os.makedirs(staging_dir_path, exist_ok=True)
|
||||
instance.data.update({
|
||||
"stagingDir": staging_dir_path,
|
||||
"stagingDir_persistent": staging_dir_info.is_persistent,
|
||||
"stagingDir_is_custom": staging_dir_info.is_custom
|
||||
})
|
||||
|
||||
return staging_dir_path
|
||||
|
||||
|
||||
def get_published_workfile_instance(context):
|
||||
|
|
@ -799,7 +772,7 @@ def replace_with_published_scene_path(instance, replace_in_path=True):
|
|||
return
|
||||
|
||||
# determine published path from Anatomy.
|
||||
template_data = workfile_instance.data.get("anatomyData")
|
||||
template_data = copy.deepcopy(workfile_instance.data["anatomyData"])
|
||||
rep = workfile_instance.data["representations"][0]
|
||||
template_data["representation"] = rep.get("name")
|
||||
template_data["ext"] = rep.get("ext")
|
||||
|
|
|
|||
|
|
@ -1,9 +1,19 @@
|
|||
import inspect
|
||||
from abc import ABCMeta
|
||||
import typing
|
||||
from typing import Optional
|
||||
|
||||
import pyblish.api
|
||||
import pyblish.logic
|
||||
from pyblish.plugin import MetaPlugin, ExplicitMetaPlugin
|
||||
|
||||
from ayon_core.lib import BoolDef
|
||||
|
||||
from ayon_core.pipeline.colorspace import (
|
||||
get_colorspace_settings_from_publish_context,
|
||||
set_colorspace_data_to_representation
|
||||
)
|
||||
|
||||
from .lib import (
|
||||
load_help_content_from_plugin,
|
||||
get_errored_instances_from_context,
|
||||
|
|
@ -11,10 +21,8 @@ from .lib import (
|
|||
get_instance_staging_dir,
|
||||
)
|
||||
|
||||
from ayon_core.pipeline.colorspace import (
|
||||
get_colorspace_settings_from_publish_context,
|
||||
set_colorspace_data_to_representation
|
||||
)
|
||||
if typing.TYPE_CHECKING:
|
||||
from ayon_core.pipeline.create import CreateContext, CreatedInstance
|
||||
|
||||
|
||||
class AbstractMetaInstancePlugin(ABCMeta, MetaPlugin):
|
||||
|
|
@ -125,32 +133,118 @@ class AYONPyblishPluginMixin:
|
|||
# for callback in self._state_change_callbacks:
|
||||
# callback(self)
|
||||
|
||||
@classmethod
|
||||
def register_create_context_callbacks(
|
||||
cls, create_context: "CreateContext"
|
||||
):
|
||||
"""Register callbacks for create context.
|
||||
|
||||
It is possible to register callbacks listening to changes happened
|
||||
in create context.
|
||||
|
||||
Methods available on create context:
|
||||
- add_instances_added_callback
|
||||
- add_instances_removed_callback
|
||||
- add_value_changed_callback
|
||||
- add_pre_create_attr_defs_change_callback
|
||||
- add_create_attr_defs_change_callback
|
||||
- add_publish_attr_defs_change_callback
|
||||
|
||||
Args:
|
||||
create_context (CreateContext): Create context.
|
||||
|
||||
"""
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
def get_attribute_defs(cls):
|
||||
"""Publish attribute definitions.
|
||||
|
||||
Attributes available for all families in plugin's `families` attribute.
|
||||
Returns:
|
||||
list<AbstractAttrDef>: Attribute definitions for plugin.
|
||||
"""
|
||||
|
||||
Returns:
|
||||
list[AbstractAttrDef]: Attribute definitions for plugin.
|
||||
|
||||
"""
|
||||
return []
|
||||
|
||||
@classmethod
|
||||
def convert_attribute_values(cls, attribute_values):
|
||||
if cls.__name__ not in attribute_values:
|
||||
return attribute_values
|
||||
def get_attr_defs_for_context(cls, create_context: "CreateContext"):
|
||||
"""Publish attribute definitions for context.
|
||||
|
||||
plugin_values = attribute_values[cls.__name__]
|
||||
Attributes available for all families in plugin's `families` attribute.
|
||||
|
||||
attr_defs = cls.get_attribute_defs()
|
||||
for attr_def in attr_defs:
|
||||
key = attr_def.key
|
||||
if key in plugin_values:
|
||||
plugin_values[key] = attr_def.convert_value(
|
||||
plugin_values[key]
|
||||
)
|
||||
return attribute_values
|
||||
Args:
|
||||
create_context (CreateContext): Create context.
|
||||
|
||||
Returns:
|
||||
list[AbstractAttrDef]: Attribute definitions for plugin.
|
||||
|
||||
"""
|
||||
if cls.__instanceEnabled__:
|
||||
return []
|
||||
return cls.get_attribute_defs()
|
||||
|
||||
@classmethod
|
||||
def instance_matches_plugin_families(
|
||||
cls, instance: Optional["CreatedInstance"]
|
||||
):
|
||||
"""Check if instance matches families.
|
||||
|
||||
Args:
|
||||
instance (Optional[CreatedInstance]): Instance to check. Or None
|
||||
for context.
|
||||
|
||||
Returns:
|
||||
bool: True if instance matches plugin families.
|
||||
|
||||
"""
|
||||
if instance is None:
|
||||
return not cls.__instanceEnabled__
|
||||
|
||||
if not cls.__instanceEnabled__:
|
||||
return False
|
||||
|
||||
families = [instance.product_type]
|
||||
families.extend(instance.get("families", []))
|
||||
for _ in pyblish.logic.plugins_by_families([cls], families):
|
||||
return True
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def get_attr_defs_for_instance(
|
||||
cls, create_context: "CreateContext", instance: "CreatedInstance"
|
||||
):
|
||||
"""Publish attribute definitions for an instance.
|
||||
|
||||
Attributes available for all families in plugin's `families` attribute.
|
||||
|
||||
Args:
|
||||
create_context (CreateContext): Create context.
|
||||
instance (CreatedInstance): Instance for which attributes are
|
||||
collected.
|
||||
|
||||
Returns:
|
||||
list[AbstractAttrDef]: Attribute definitions for plugin.
|
||||
|
||||
"""
|
||||
if not cls.instance_matches_plugin_families(instance):
|
||||
return []
|
||||
return cls.get_attribute_defs()
|
||||
|
||||
@classmethod
|
||||
def convert_attribute_values(
|
||||
cls, create_context: "CreateContext", instance: "CreatedInstance"
|
||||
):
|
||||
"""Convert attribute values for instance.
|
||||
|
||||
Args:
|
||||
create_context (CreateContext): Create context.
|
||||
instance (CreatedInstance): Instance for which attributes are
|
||||
converted.
|
||||
|
||||
"""
|
||||
return
|
||||
|
||||
@staticmethod
|
||||
def get_attr_values_from_data_for_plugin(plugin, data):
|
||||
|
|
@ -198,6 +292,9 @@ class OptionalPyblishPluginMixin(AYONPyblishPluginMixin):
|
|||
```
|
||||
"""
|
||||
|
||||
# Allow exposing tooltip from class with `optional_tooltip` attribute
|
||||
optional_tooltip: Optional[str] = None
|
||||
|
||||
@classmethod
|
||||
def get_attribute_defs(cls):
|
||||
"""Attribute definitions based on plugin's optional attribute."""
|
||||
|
|
@ -210,8 +307,14 @@ class OptionalPyblishPluginMixin(AYONPyblishPluginMixin):
|
|||
active = getattr(cls, "active", True)
|
||||
# Return boolean stored under 'active' key with label of the class name
|
||||
label = cls.label or cls.__name__
|
||||
|
||||
return [
|
||||
BoolDef("active", default=active, label=label)
|
||||
BoolDef(
|
||||
"active",
|
||||
default=active,
|
||||
label=label,
|
||||
tooltip=cls.optional_tooltip,
|
||||
)
|
||||
]
|
||||
|
||||
def is_active(self, data):
|
||||
|
|
|
|||
242
client/ayon_core/pipeline/staging_dir.py
Normal file
242
client/ayon_core/pipeline/staging_dir.py
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
import logging
|
||||
import warnings
|
||||
from typing import Optional, Dict, Any
|
||||
from dataclasses import dataclass
|
||||
|
||||
from ayon_core.lib import Logger, filter_profiles
|
||||
from ayon_core.settings import get_project_settings
|
||||
|
||||
from .template_data import get_template_data
|
||||
from .anatomy import Anatomy
|
||||
from .tempdir import get_temp_dir
|
||||
|
||||
|
||||
@dataclass
|
||||
class StagingDir:
|
||||
directory: str
|
||||
is_persistent: bool
|
||||
# Whether the staging dir is a custom staging dir
|
||||
is_custom: bool
|
||||
|
||||
def __setattr__(self, key, value):
|
||||
if key == "persistent":
|
||||
warnings.warn(
|
||||
"'StagingDir.persistent' is deprecated."
|
||||
" Use 'StagingDir.is_persistent' instead.",
|
||||
DeprecationWarning
|
||||
)
|
||||
key = "is_persistent"
|
||||
super().__setattr__(key, value)
|
||||
|
||||
@property
|
||||
def persistent(self):
|
||||
warnings.warn(
|
||||
"'StagingDir.persistent' is deprecated."
|
||||
" Use 'StagingDir.is_persistent' instead.",
|
||||
DeprecationWarning
|
||||
)
|
||||
return self.is_persistent
|
||||
|
||||
|
||||
def get_staging_dir_config(
|
||||
project_name: str,
|
||||
task_type: Optional[str],
|
||||
task_name: Optional[str],
|
||||
product_type: str,
|
||||
product_name: str,
|
||||
host_name: str,
|
||||
project_settings: Optional[Dict[str, Any]] = None,
|
||||
anatomy: Optional[Anatomy] = None,
|
||||
log: Optional[logging.Logger] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Get matching staging dir profile.
|
||||
|
||||
Args:
|
||||
host_name (str): Name of host.
|
||||
project_name (str): Name of project.
|
||||
task_type (Optional[str]): Type of task.
|
||||
task_name (Optional[str]): Name of task.
|
||||
product_type (str): Type of product.
|
||||
product_name (str): Name of product.
|
||||
project_settings(Dict[str, Any]): Prepared project settings.
|
||||
anatomy (Dict[str, Any])
|
||||
log (Optional[logging.Logger])
|
||||
|
||||
Returns:
|
||||
Dict or None: Data with directory template and is_persistent or None
|
||||
|
||||
Raises:
|
||||
KeyError - if misconfigured template should be used
|
||||
|
||||
"""
|
||||
settings = project_settings or get_project_settings(project_name)
|
||||
|
||||
staging_dir_profiles = settings["core"]["tools"]["publish"][
|
||||
"custom_staging_dir_profiles"
|
||||
]
|
||||
|
||||
if not staging_dir_profiles:
|
||||
return None
|
||||
|
||||
if not log:
|
||||
log = Logger.get_logger("get_staging_dir_config")
|
||||
|
||||
filtering_criteria = {
|
||||
"hosts": host_name,
|
||||
"task_types": task_type,
|
||||
"task_names": task_name,
|
||||
"product_types": product_type,
|
||||
"product_names": product_name,
|
||||
}
|
||||
profile = filter_profiles(
|
||||
staging_dir_profiles, filtering_criteria, logger=log)
|
||||
|
||||
if not profile or not profile["active"]:
|
||||
return None
|
||||
|
||||
if not anatomy:
|
||||
anatomy = Anatomy(project_name)
|
||||
|
||||
# get template from template name
|
||||
template_name = profile["template_name"]
|
||||
|
||||
template = anatomy.get_template_item("staging", template_name)
|
||||
|
||||
if not template:
|
||||
# template should always be found either from anatomy or from profile
|
||||
raise KeyError(
|
||||
f"Staging template '{template_name}' was not found."
|
||||
"Check project anatomy or settings at: "
|
||||
"'ayon+settings://core/tools/publish/custom_staging_dir_profiles'"
|
||||
)
|
||||
|
||||
data_persistence = profile["custom_staging_dir_persistent"]
|
||||
|
||||
return {"template": template, "persistence": data_persistence}
|
||||
|
||||
|
||||
def get_staging_dir_info(
|
||||
project_entity: Dict[str, Any],
|
||||
folder_entity: Optional[Dict[str, Any]],
|
||||
task_entity: Optional[Dict[str, Any]],
|
||||
product_type: str,
|
||||
product_name: str,
|
||||
host_name: str,
|
||||
anatomy: Optional[Anatomy] = None,
|
||||
project_settings: Optional[Dict[str, Any]] = None,
|
||||
template_data: Optional[Dict[str, Any]] = None,
|
||||
always_return_path: bool = True,
|
||||
force_tmp_dir: bool = False,
|
||||
logger: Optional[logging.Logger] = None,
|
||||
prefix: Optional[str] = None,
|
||||
suffix: Optional[str] = None,
|
||||
username: Optional[str] = None,
|
||||
) -> Optional[StagingDir]:
|
||||
"""Get staging dir info data.
|
||||
|
||||
If `force_temp` is set, staging dir will be created as tempdir.
|
||||
If `always_get_some_dir` is set, staging dir will be created as tempdir if
|
||||
no staging dir profile is found.
|
||||
If `prefix` or `suffix` is not set, default values will be used.
|
||||
|
||||
Arguments:
|
||||
project_entity (Dict[str, Any]): Project entity.
|
||||
folder_entity (Optional[Dict[str, Any]]): Folder entity.
|
||||
task_entity (Optional[Dict[str, Any]]): Task entity.
|
||||
product_type (str): Type of product.
|
||||
product_name (str): Name of product.
|
||||
host_name (str): Name of host.
|
||||
anatomy (Optional[Anatomy]): Anatomy object.
|
||||
project_settings (Optional[Dict[str, Any]]): Prepared project settings.
|
||||
template_data (Optional[Dict[str, Any]]): Additional data for
|
||||
formatting staging dir template.
|
||||
always_return_path (Optional[bool]): If True, staging dir will be
|
||||
created as tempdir if no staging dir profile is found. Input value
|
||||
False will return None if no staging dir profile is found.
|
||||
force_tmp_dir (Optional[bool]): If True, staging dir will be created as
|
||||
tempdir.
|
||||
logger (Optional[logging.Logger]): Logger instance.
|
||||
prefix (Optional[str]) Optional prefix for staging dir name.
|
||||
suffix (Optional[str]): Optional suffix for staging dir name.
|
||||
username (Optional[str]): AYON Username.
|
||||
|
||||
Returns:
|
||||
Optional[StagingDir]: Staging dir info data
|
||||
|
||||
"""
|
||||
log = logger or Logger.get_logger("get_staging_dir_info")
|
||||
|
||||
if anatomy is None:
|
||||
anatomy = Anatomy(
|
||||
project_entity["name"], project_entity=project_entity
|
||||
)
|
||||
|
||||
if force_tmp_dir:
|
||||
return StagingDir(
|
||||
get_temp_dir(
|
||||
project_name=project_entity["name"],
|
||||
anatomy=anatomy,
|
||||
prefix=prefix,
|
||||
suffix=suffix,
|
||||
),
|
||||
is_persistent=False,
|
||||
is_custom=False
|
||||
)
|
||||
|
||||
# making few queries to database
|
||||
ctx_data = get_template_data(
|
||||
project_entity, folder_entity, task_entity, host_name,
|
||||
settings=project_settings,
|
||||
username=username
|
||||
)
|
||||
|
||||
# add additional data
|
||||
ctx_data["product"] = {
|
||||
"type": product_type,
|
||||
"name": product_name
|
||||
}
|
||||
|
||||
# add additional template formatting data
|
||||
if template_data:
|
||||
ctx_data.update(template_data)
|
||||
|
||||
task_name = task_type = None
|
||||
if task_entity:
|
||||
task_name = task_entity["name"]
|
||||
task_type = task_entity["taskType"]
|
||||
|
||||
# get staging dir config
|
||||
staging_dir_config = get_staging_dir_config(
|
||||
project_entity["name"],
|
||||
task_type,
|
||||
task_name ,
|
||||
product_type,
|
||||
product_name,
|
||||
host_name,
|
||||
project_settings=project_settings,
|
||||
anatomy=anatomy,
|
||||
log=log,
|
||||
)
|
||||
|
||||
if staging_dir_config:
|
||||
dir_template = staging_dir_config["template"]["directory"]
|
||||
return StagingDir(
|
||||
dir_template.format_strict(ctx_data),
|
||||
is_persistent=staging_dir_config["persistence"],
|
||||
is_custom=True
|
||||
)
|
||||
|
||||
# no config found but force an output
|
||||
if always_return_path:
|
||||
return StagingDir(
|
||||
get_temp_dir(
|
||||
project_name=project_entity["name"],
|
||||
anatomy=anatomy,
|
||||
prefix=prefix,
|
||||
suffix=suffix,
|
||||
),
|
||||
is_persistent=False,
|
||||
is_custom=False
|
||||
)
|
||||
|
||||
return None
|
||||
|
|
@ -3,11 +3,90 @@ Temporary folder operations
|
|||
"""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
import warnings
|
||||
|
||||
from ayon_core.lib import StringTemplate
|
||||
from ayon_core.pipeline import Anatomy
|
||||
|
||||
|
||||
def get_temp_dir(
|
||||
project_name, anatomy=None, prefix=None, suffix=None, use_local_temp=False
|
||||
):
|
||||
"""Get temporary dir path.
|
||||
|
||||
If `use_local_temp` is set, tempdir will be created in local tempdir.
|
||||
If `anatomy` is not set, default anatomy will be used.
|
||||
If `prefix` or `suffix` is not set, default values will be used.
|
||||
|
||||
It also supports `AYON_TMPDIR`, so studio can define own temp
|
||||
shared repository per project or even per more granular context.
|
||||
Template formatting is supported also with optional keys. Folder is
|
||||
created in case it doesn't exists.
|
||||
|
||||
Args:
|
||||
project_name (str): Name of project.
|
||||
anatomy (Optional[Anatomy]): Project Anatomy object.
|
||||
suffix (Optional[str]): Suffix for tempdir.
|
||||
prefix (Optional[str]): Prefix for tempdir.
|
||||
use_local_temp (Optional[bool]): If True, temp dir will be created in
|
||||
local tempdir.
|
||||
|
||||
Returns:
|
||||
str: Path to staging dir of instance.
|
||||
|
||||
"""
|
||||
if prefix is None:
|
||||
prefix = "ay_tmp_"
|
||||
suffix = suffix or ""
|
||||
|
||||
if use_local_temp:
|
||||
return _create_local_staging_dir(prefix, suffix)
|
||||
|
||||
# make sure anatomy is set
|
||||
if not anatomy:
|
||||
anatomy = Anatomy(project_name)
|
||||
|
||||
# get customized tempdir path from `OPENPYPE_TMPDIR` env var
|
||||
custom_temp_dir = _create_custom_tempdir(anatomy.project_name, anatomy)
|
||||
|
||||
return _create_local_staging_dir(prefix, suffix, dirpath=custom_temp_dir)
|
||||
|
||||
|
||||
def _create_local_staging_dir(prefix, suffix, dirpath=None):
|
||||
"""Create local staging dir
|
||||
|
||||
Args:
|
||||
prefix (str): prefix for tempdir
|
||||
suffix (str): suffix for tempdir
|
||||
dirpath (Optional[str]): path to tempdir
|
||||
|
||||
Returns:
|
||||
str: path to tempdir
|
||||
"""
|
||||
# use pathlib for creating tempdir
|
||||
return tempfile.mkdtemp(
|
||||
prefix=prefix, suffix=suffix, dir=dirpath
|
||||
)
|
||||
|
||||
|
||||
def create_custom_tempdir(project_name, anatomy=None):
|
||||
"""Backward compatibility deprecated since 2024/12/09.
|
||||
"""
|
||||
warnings.warn(
|
||||
"Used deprecated 'create_custom_tempdir' "
|
||||
"use 'ayon_core.pipeline.tempdir.get_temp_dir' instead.",
|
||||
DeprecationWarning,
|
||||
)
|
||||
|
||||
if anatomy is None:
|
||||
anatomy = Anatomy(project_name)
|
||||
|
||||
return _create_custom_tempdir(project_name, anatomy)
|
||||
|
||||
|
||||
def _create_custom_tempdir(project_name, anatomy):
|
||||
""" Create custom tempdir
|
||||
|
||||
Template path formatting is supporting:
|
||||
|
|
@ -18,42 +97,35 @@ def create_custom_tempdir(project_name, anatomy=None):
|
|||
|
||||
Args:
|
||||
project_name (str): project name
|
||||
anatomy (ayon_core.pipeline.Anatomy)[optional]: Anatomy object
|
||||
anatomy (ayon_core.pipeline.Anatomy): Anatomy object
|
||||
|
||||
Returns:
|
||||
str | None: formatted path or None
|
||||
"""
|
||||
env_tmpdir = os.getenv("AYON_TMPDIR")
|
||||
if not env_tmpdir:
|
||||
return
|
||||
return None
|
||||
|
||||
custom_tempdir = None
|
||||
if "{" in env_tmpdir:
|
||||
if anatomy is None:
|
||||
anatomy = Anatomy(project_name)
|
||||
# create base formate data
|
||||
data = {
|
||||
template_data = {
|
||||
"root": anatomy.roots,
|
||||
"project": {
|
||||
"name": anatomy.project_name,
|
||||
"code": anatomy.project_code,
|
||||
}
|
||||
},
|
||||
}
|
||||
# path is anatomy template
|
||||
custom_tempdir = StringTemplate.format_template(
|
||||
env_tmpdir, data).normalized()
|
||||
env_tmpdir, template_data)
|
||||
|
||||
custom_tempdir_path = Path(custom_tempdir)
|
||||
|
||||
else:
|
||||
# path is absolute
|
||||
custom_tempdir = env_tmpdir
|
||||
custom_tempdir_path = Path(env_tmpdir)
|
||||
|
||||
# create the dir path if it doesn't exists
|
||||
if not os.path.exists(custom_tempdir):
|
||||
try:
|
||||
# create it if it doesn't exists
|
||||
os.makedirs(custom_tempdir)
|
||||
except IOError as error:
|
||||
raise IOError(
|
||||
"Path couldn't be created: {}".format(error))
|
||||
custom_tempdir_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
return custom_tempdir
|
||||
return custom_tempdir_path.as_posix()
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ from ayon_core.settings import get_studio_settings
|
|||
from ayon_core.lib.local_settings import get_ayon_username
|
||||
|
||||
|
||||
def get_general_template_data(settings=None):
|
||||
def get_general_template_data(settings=None, username=None):
|
||||
"""General template data based on system settings or machine.
|
||||
|
||||
Output contains formatting keys:
|
||||
|
|
@ -14,17 +14,22 @@ def get_general_template_data(settings=None):
|
|||
|
||||
Args:
|
||||
settings (Dict[str, Any]): Studio or project settings.
|
||||
username (Optional[str]): AYON Username.
|
||||
"""
|
||||
|
||||
if not settings:
|
||||
settings = get_studio_settings()
|
||||
|
||||
if username is None:
|
||||
username = get_ayon_username()
|
||||
|
||||
core_settings = settings["core"]
|
||||
return {
|
||||
"studio": {
|
||||
"name": core_settings["studio_name"],
|
||||
"code": core_settings["studio_code"]
|
||||
},
|
||||
"user": get_ayon_username()
|
||||
"user": username
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -87,14 +92,13 @@ def get_folder_template_data(folder_entity, project_name):
|
|||
"""
|
||||
|
||||
path = folder_entity["path"]
|
||||
hierarchy_parts = path.split("/")
|
||||
# Remove empty string from the beginning
|
||||
hierarchy_parts.pop(0)
|
||||
# Remove empty string from the beginning and split by '/'
|
||||
parents = path.lstrip("/").split("/")
|
||||
# Remove last part which is folder name
|
||||
folder_name = hierarchy_parts.pop(-1)
|
||||
hierarchy = "/".join(hierarchy_parts)
|
||||
if hierarchy_parts:
|
||||
parent_name = hierarchy_parts[-1]
|
||||
folder_name = parents.pop(-1)
|
||||
hierarchy = "/".join(parents)
|
||||
if parents:
|
||||
parent_name = parents[-1]
|
||||
else:
|
||||
parent_name = project_name
|
||||
|
||||
|
|
@ -103,6 +107,7 @@ def get_folder_template_data(folder_entity, project_name):
|
|||
"name": folder_name,
|
||||
"type": folder_entity["folderType"],
|
||||
"path": path,
|
||||
"parents": parents,
|
||||
},
|
||||
"asset": folder_name,
|
||||
"hierarchy": hierarchy,
|
||||
|
|
@ -145,6 +150,7 @@ def get_template_data(
|
|||
task_entity=None,
|
||||
host_name=None,
|
||||
settings=None,
|
||||
username=None
|
||||
):
|
||||
"""Prepare data for templates filling from entered documents and info.
|
||||
|
||||
|
|
@ -167,12 +173,13 @@ def get_template_data(
|
|||
host_name (Optional[str]): Used to fill '{app}' key.
|
||||
settings (Union[Dict, None]): Prepared studio or project settings.
|
||||
They're queried if not passed (may be slower).
|
||||
username (Optional[str]): AYON Username.
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: Data prepared for filling workdir template.
|
||||
"""
|
||||
|
||||
template_data = get_general_template_data(settings)
|
||||
template_data = get_general_template_data(settings, username=username)
|
||||
template_data.update(get_project_template_data(project_entity))
|
||||
if folder_entity:
|
||||
template_data.update(get_folder_template_data(
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import os
|
|||
import re
|
||||
import copy
|
||||
import platform
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
import ayon_api
|
||||
|
||||
|
|
@ -16,12 +17,12 @@ from ayon_core.pipeline.template_data import get_template_data
|
|||
|
||||
|
||||
def get_workfile_template_key_from_context(
|
||||
project_name,
|
||||
folder_path,
|
||||
task_name,
|
||||
host_name,
|
||||
project_settings=None
|
||||
):
|
||||
project_name: str,
|
||||
folder_path: str,
|
||||
task_name: str,
|
||||
host_name: str,
|
||||
project_settings: Optional[Dict[str, Any]] = None,
|
||||
) -> str:
|
||||
"""Helper function to get template key for workfile template.
|
||||
|
||||
Do the same as `get_workfile_template_key` but returns value for "session
|
||||
|
|
@ -34,15 +35,23 @@ def get_workfile_template_key_from_context(
|
|||
host_name (str): Host name.
|
||||
project_settings (Dict[str, Any]): Project settings for passed
|
||||
'project_name'. Not required at all but makes function faster.
|
||||
"""
|
||||
|
||||
Returns:
|
||||
str: Workfile template name.
|
||||
|
||||
"""
|
||||
folder_entity = ayon_api.get_folder_by_path(
|
||||
project_name, folder_path, fields={"id"}
|
||||
project_name,
|
||||
folder_path,
|
||||
fields={"id"},
|
||||
)
|
||||
task_entity = ayon_api.get_task_by_name(
|
||||
project_name, folder_entity["id"], task_name
|
||||
project_name,
|
||||
folder_entity["id"],
|
||||
task_name,
|
||||
fields={"taskType"},
|
||||
)
|
||||
task_type = task_entity.get("type")
|
||||
task_type = task_entity.get("taskType")
|
||||
|
||||
return get_workfile_template_key(
|
||||
project_name, task_type, host_name, project_settings
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ from ayon_core.pipeline.plugin_discover import (
|
|||
from ayon_core.pipeline.create import (
|
||||
discover_legacy_creator_plugins,
|
||||
CreateContext,
|
||||
HiddenCreator,
|
||||
)
|
||||
|
||||
_NOT_SET = object()
|
||||
|
|
@ -309,7 +310,13 @@ class AbstractTemplateBuilder(ABC):
|
|||
self._creators_by_name = creators_by_name
|
||||
|
||||
def _collect_creators(self):
|
||||
self._creators_by_name = dict(self.create_context.creators)
|
||||
self._creators_by_name = {
|
||||
identifier: creator
|
||||
for identifier, creator
|
||||
in self.create_context.manual_creators.items()
|
||||
# Do not list HiddenCreator even though it is a 'manual creator'
|
||||
if not isinstance(creator, HiddenCreator)
|
||||
}
|
||||
|
||||
def get_creators_by_name(self):
|
||||
if self._creators_by_name is None:
|
||||
|
|
|
|||
|
|
@ -99,7 +99,7 @@ class OpenTaskPath(LauncherAction):
|
|||
if platform_name == "windows":
|
||||
args = ["start", path]
|
||||
elif platform_name == "darwin":
|
||||
args = ["open", "-na", path]
|
||||
args = ["open", "-R", path]
|
||||
elif platform_name == "linux":
|
||||
args = ["xdg-open", path]
|
||||
else:
|
||||
|
|
|
|||
|
|
@ -1,23 +1,22 @@
|
|||
import copy
|
||||
import platform
|
||||
from collections import defaultdict
|
||||
|
||||
import ayon_api
|
||||
from qtpy import QtWidgets, QtCore, QtGui
|
||||
|
||||
from ayon_core.pipeline import load, Anatomy
|
||||
from ayon_core import resources, style
|
||||
|
||||
from ayon_core.lib import (
|
||||
format_file_size,
|
||||
collect_frames,
|
||||
get_datetime_data,
|
||||
)
|
||||
from ayon_core.pipeline import load, Anatomy
|
||||
from ayon_core.pipeline.load import get_representation_path_with_anatomy
|
||||
from ayon_core.pipeline.delivery import (
|
||||
get_format_dict,
|
||||
check_destination_path,
|
||||
deliver_single_file
|
||||
deliver_single_file,
|
||||
get_representations_delivery_template_data,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -200,20 +199,31 @@ class DeliveryOptionsDialog(QtWidgets.QDialog):
|
|||
format_dict = get_format_dict(self.anatomy, self.root_line_edit.text())
|
||||
renumber_frame = self.renumber_frame.isChecked()
|
||||
frame_offset = self.first_frame_start.value()
|
||||
filtered_repres = []
|
||||
repre_ids = set()
|
||||
for repre in self._representations:
|
||||
if repre["name"] not in selected_repres:
|
||||
continue
|
||||
if repre["name"] in selected_repres:
|
||||
filtered_repres.append(repre)
|
||||
repre_ids.add(repre["id"])
|
||||
|
||||
template_data_by_repre_id = (
|
||||
get_representations_delivery_template_data(
|
||||
self.anatomy.project_name, repre_ids
|
||||
)
|
||||
)
|
||||
for repre in filtered_repres:
|
||||
repre_path = get_representation_path_with_anatomy(
|
||||
repre, self.anatomy
|
||||
)
|
||||
|
||||
anatomy_data = copy.deepcopy(repre["context"])
|
||||
new_report_items = check_destination_path(repre["id"],
|
||||
self.anatomy,
|
||||
anatomy_data,
|
||||
datetime_data,
|
||||
template_name)
|
||||
template_data = template_data_by_repre_id[repre["id"]]
|
||||
new_report_items = check_destination_path(
|
||||
repre["id"],
|
||||
self.anatomy,
|
||||
template_data,
|
||||
datetime_data,
|
||||
template_name
|
||||
)
|
||||
|
||||
report_items.update(new_report_items)
|
||||
if new_report_items:
|
||||
|
|
@ -224,7 +234,7 @@ class DeliveryOptionsDialog(QtWidgets.QDialog):
|
|||
repre,
|
||||
self.anatomy,
|
||||
template_name,
|
||||
anatomy_data,
|
||||
template_data,
|
||||
format_dict,
|
||||
report_items,
|
||||
self.log
|
||||
|
|
@ -267,9 +277,9 @@ class DeliveryOptionsDialog(QtWidgets.QDialog):
|
|||
|
||||
if frame is not None:
|
||||
if repre["context"].get("frame"):
|
||||
anatomy_data["frame"] = frame
|
||||
template_data["frame"] = frame
|
||||
elif repre["context"].get("udim"):
|
||||
anatomy_data["udim"] = frame
|
||||
template_data["udim"] = frame
|
||||
else:
|
||||
# Fallback
|
||||
self.log.warning(
|
||||
|
|
@ -277,7 +287,7 @@ class DeliveryOptionsDialog(QtWidgets.QDialog):
|
|||
" data. Supplying sequence frame to '{frame}'"
|
||||
" formatting data."
|
||||
)
|
||||
anatomy_data["frame"] = frame
|
||||
template_data["frame"] = frame
|
||||
new_report_items, uploaded = deliver_single_file(*args)
|
||||
report_items.update(new_report_items)
|
||||
self._update_progress(uploaded)
|
||||
|
|
@ -342,8 +352,8 @@ class DeliveryOptionsDialog(QtWidgets.QDialog):
|
|||
def _get_selected_repres(self):
|
||||
"""Returns list of representation names filtered from checkboxes."""
|
||||
selected_repres = []
|
||||
for repre_name, chckbox in self._representation_checkboxes.items():
|
||||
if chckbox.isChecked():
|
||||
for repre_name, checkbox in self._representation_checkboxes.items():
|
||||
if checkbox.isChecked():
|
||||
selected_repres.append(repre_name)
|
||||
|
||||
return selected_repres
|
||||
|
|
|
|||
|
|
@ -116,11 +116,11 @@ class CollectAnatomyInstanceData(pyblish.api.ContextPlugin):
|
|||
|
||||
if not_found_folder_paths:
|
||||
joined_folder_paths = ", ".join(
|
||||
["\"{}\"".format(path) for path in not_found_folder_paths]
|
||||
[f"\"{path}\"" for path in not_found_folder_paths]
|
||||
)
|
||||
self.log.warning(
|
||||
f"Not found folder entities with paths {joined_folder_paths}."
|
||||
)
|
||||
self.log.warning((
|
||||
"Not found folder entities with paths \"{}\"."
|
||||
).format(joined_folder_paths))
|
||||
|
||||
def fill_missing_task_entities(self, context, project_name):
|
||||
self.log.debug("Querying task entities for instances.")
|
||||
|
|
@ -413,14 +413,16 @@ class CollectAnatomyInstanceData(pyblish.api.ContextPlugin):
|
|||
# Backwards compatible (Deprecated since 24/06/06)
|
||||
or instance.data.get("newAssetPublishing")
|
||||
):
|
||||
hierarchy = instance.data["hierarchy"]
|
||||
anatomy_data["hierarchy"] = hierarchy
|
||||
folder_path = instance.data["folderPath"]
|
||||
parents = folder_path.lstrip("/").split("/")
|
||||
folder_name = parents.pop(-1)
|
||||
|
||||
parent_name = project_entity["name"]
|
||||
if hierarchy:
|
||||
parent_name = hierarchy.split("/")[-1]
|
||||
hierarchy = ""
|
||||
if parents:
|
||||
parent_name = parents[-1]
|
||||
hierarchy = "/".join(parents)
|
||||
|
||||
folder_name = instance.data["folderPath"].split("/")[-1]
|
||||
anatomy_data.update({
|
||||
"asset": folder_name,
|
||||
"hierarchy": hierarchy,
|
||||
|
|
@ -432,6 +434,7 @@ class CollectAnatomyInstanceData(pyblish.api.ContextPlugin):
|
|||
# Using 'Shot' is current default behavior of editorial
|
||||
# (or 'newHierarchyIntegration') publishing.
|
||||
"type": "Shot",
|
||||
"parents": parents,
|
||||
},
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -1,76 +0,0 @@
|
|||
"""
|
||||
Requires:
|
||||
anatomy
|
||||
|
||||
|
||||
Provides:
|
||||
instance.data -> stagingDir (folder path)
|
||||
-> stagingDir_persistent (bool)
|
||||
"""
|
||||
import copy
|
||||
import os.path
|
||||
|
||||
import pyblish.api
|
||||
|
||||
from ayon_core.pipeline.publish.lib import get_custom_staging_dir_info
|
||||
|
||||
|
||||
class CollectCustomStagingDir(pyblish.api.InstancePlugin):
|
||||
"""Looks through profiles if stagingDir should be persistent and in special
|
||||
location.
|
||||
|
||||
Transient staging dir could be useful in specific use cases where is
|
||||
desirable to have temporary renders in specific, persistent folders, could
|
||||
be on disks optimized for speed for example.
|
||||
|
||||
It is studio responsibility to clean up obsolete folders with data.
|
||||
|
||||
Location of the folder is configured in `project_anatomy/templates/others`.
|
||||
('transient' key is expected, with 'folder' key)
|
||||
|
||||
Which family/task type/product is applicable is configured in:
|
||||
`project_settings/global/tools/publish/custom_staging_dir_profiles`
|
||||
|
||||
"""
|
||||
label = "Collect Custom Staging Directory"
|
||||
order = pyblish.api.CollectorOrder + 0.4990
|
||||
|
||||
template_key = "transient"
|
||||
|
||||
def process(self, instance):
|
||||
product_type = instance.data["productType"]
|
||||
product_name = instance.data["productName"]
|
||||
host_name = instance.context.data["hostName"]
|
||||
project_name = instance.context.data["projectName"]
|
||||
project_settings = instance.context.data["project_settings"]
|
||||
anatomy = instance.context.data["anatomy"]
|
||||
task = instance.data["anatomyData"].get("task", {})
|
||||
|
||||
transient_tml, is_persistent = get_custom_staging_dir_info(
|
||||
project_name,
|
||||
host_name,
|
||||
product_type,
|
||||
product_name,
|
||||
task.get("name"),
|
||||
task.get("type"),
|
||||
project_settings=project_settings,
|
||||
anatomy=anatomy,
|
||||
log=self.log)
|
||||
|
||||
if transient_tml:
|
||||
anatomy_data = copy.deepcopy(instance.data["anatomyData"])
|
||||
anatomy_data["root"] = anatomy.roots
|
||||
scene_name = instance.context.data.get("currentFile")
|
||||
if scene_name:
|
||||
anatomy_data["scene_name"] = os.path.basename(scene_name)
|
||||
transient_dir = transient_tml.format(**anatomy_data)
|
||||
instance.data["stagingDir"] = transient_dir
|
||||
|
||||
instance.data["stagingDir_persistent"] = is_persistent
|
||||
result_str = "Adding '{}' as".format(transient_dir)
|
||||
else:
|
||||
result_str = "Not adding"
|
||||
|
||||
self.log.debug("{} custom staging dir for instance with '{}'".format(
|
||||
result_str, product_type
|
||||
))
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
import os
|
||||
|
||||
import pyblish.api
|
||||
|
||||
from ayon_core.lib import get_ayon_username
|
||||
from ayon_core.pipeline.publish import FARM_JOB_ENV_DATA_KEY
|
||||
|
||||
|
||||
class CollectCoreJobEnvVars(pyblish.api.ContextPlugin):
|
||||
"""Collect set of environment variables to submit with deadline jobs"""
|
||||
order = pyblish.api.CollectorOrder - 0.45
|
||||
label = "AYON core Farm Environment Variables"
|
||||
targets = ["local"]
|
||||
|
||||
def process(self, context):
|
||||
env = context.data.setdefault(FARM_JOB_ENV_DATA_KEY, {})
|
||||
|
||||
# Disable colored logs on farm
|
||||
for key, value in (
|
||||
("AYON_LOG_NO_COLORS", "1"),
|
||||
("AYON_PROJECT_NAME", context.data["projectName"]),
|
||||
("AYON_FOLDER_PATH", context.data.get("folderPath")),
|
||||
("AYON_TASK_NAME", context.data.get("task")),
|
||||
# NOTE we should use 'context.data["user"]' but that has higher
|
||||
# order.
|
||||
("AYON_USERNAME", get_ayon_username()),
|
||||
("AYON_HOST_NAME", context.data["hostName"]),
|
||||
):
|
||||
if value:
|
||||
self.log.debug(f"Setting job env: {key}: {value}")
|
||||
env[key] = value
|
||||
|
||||
for key in [
|
||||
"AYON_BUNDLE_NAME",
|
||||
"AYON_USE_STAGING",
|
||||
"AYON_IN_TESTS",
|
||||
# NOTE Not sure why workdir is needed?
|
||||
"AYON_WORKDIR",
|
||||
# DEPRECATED remove when deadline stops using it (added in 1.1.2)
|
||||
"AYON_DEFAULT_SETTINGS_VARIANT",
|
||||
]:
|
||||
value = os.getenv(key)
|
||||
if value:
|
||||
self.log.debug(f"Setting job env: {key}: {value}")
|
||||
env[key] = value
|
||||
|
||||
|
|
@ -13,8 +13,7 @@ class CollectHierarchy(pyblish.api.ContextPlugin):
|
|||
|
||||
label = "Collect Hierarchy"
|
||||
order = pyblish.api.CollectorOrder - 0.076
|
||||
families = ["shot"]
|
||||
hosts = ["resolve", "hiero", "flame"]
|
||||
hosts = ["resolve", "hiero", "flame", "traypublisher"]
|
||||
|
||||
def process(self, context):
|
||||
project_name = context.data["projectName"]
|
||||
|
|
@ -32,36 +31,50 @@ class CollectHierarchy(pyblish.api.ContextPlugin):
|
|||
product_type = instance.data["productType"]
|
||||
families = instance.data["families"]
|
||||
|
||||
# exclude other families then self.families with intersection
|
||||
if not set(self.families).intersection(
|
||||
set(families + [product_type])
|
||||
):
|
||||
# exclude other families then "shot" with intersection
|
||||
if "shot" not in (families + [product_type]):
|
||||
self.log.debug("Skipping not a shot: {}".format(families))
|
||||
continue
|
||||
|
||||
# exclude if not masterLayer True
|
||||
# Skip if is not a hero track
|
||||
if not instance.data.get("heroTrack"):
|
||||
self.log.debug("Skipping not a shot from hero track")
|
||||
continue
|
||||
|
||||
shot_data = {
|
||||
"entity_type": "folder",
|
||||
# WARNING Default folder type is hardcoded
|
||||
# suppose that all instances are Shots
|
||||
"folder_type": "Shot",
|
||||
# WARNING unless overwritten, default folder type is hardcoded
|
||||
# to shot
|
||||
"folder_type": instance.data.get("folder_type") or "Shot",
|
||||
"tasks": instance.data.get("tasks") or {},
|
||||
"comments": instance.data.get("comments", []),
|
||||
"attributes": {
|
||||
"handleStart": instance.data["handleStart"],
|
||||
"handleEnd": instance.data["handleEnd"],
|
||||
"frameStart": instance.data["frameStart"],
|
||||
"frameEnd": instance.data["frameEnd"],
|
||||
"clipIn": instance.data["clipIn"],
|
||||
"clipOut": instance.data["clipOut"],
|
||||
"fps": instance.data["fps"],
|
||||
"resolutionWidth": instance.data["resolutionWidth"],
|
||||
"resolutionHeight": instance.data["resolutionHeight"],
|
||||
"pixelAspect": instance.data["pixelAspect"],
|
||||
},
|
||||
}
|
||||
|
||||
shot_data["attributes"] = {}
|
||||
SHOT_ATTRS = (
|
||||
"handleStart",
|
||||
"handleEnd",
|
||||
"frameStart",
|
||||
"frameEnd",
|
||||
"clipIn",
|
||||
"clipOut",
|
||||
"fps",
|
||||
"resolutionWidth",
|
||||
"resolutionHeight",
|
||||
"pixelAspect",
|
||||
)
|
||||
for shot_attr in SHOT_ATTRS:
|
||||
attr_value = instance.data.get(shot_attr)
|
||||
if attr_value is None:
|
||||
# Shot attribute might not be defined (e.g. CSV ingest)
|
||||
self.log.debug(
|
||||
"%s shot attribute is not defined for instance.",
|
||||
shot_attr
|
||||
)
|
||||
continue
|
||||
|
||||
shot_data["attributes"][shot_attr] = attr_value
|
||||
|
||||
# Split by '/' for AYON where asset is a path
|
||||
name = instance.data["folderPath"].split("/")[-1]
|
||||
actual = {name: shot_data}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,47 @@
|
|||
"""
|
||||
Requires:
|
||||
anatomy
|
||||
|
||||
|
||||
Provides:
|
||||
instance.data -> stagingDir (folder path)
|
||||
-> stagingDir_persistent (bool)
|
||||
"""
|
||||
|
||||
import pyblish.api
|
||||
|
||||
from ayon_core.pipeline.publish import get_instance_staging_dir
|
||||
|
||||
|
||||
class CollectManagedStagingDir(pyblish.api.InstancePlugin):
|
||||
"""Apply matching Staging Dir profile to a instance.
|
||||
|
||||
Apply Staging dir via profiles could be useful in specific use cases
|
||||
where is desirable to have temporary renders in specific,
|
||||
persistent folders, could be on disks optimized for speed for example.
|
||||
|
||||
It is studio's responsibility to clean up obsolete folders with data.
|
||||
|
||||
Location of the folder is configured in:
|
||||
`ayon+anatomy://_/templates/staging`.
|
||||
|
||||
Which family/task type/subset is applicable is configured in:
|
||||
`ayon+settings://core/tools/publish/custom_staging_dir_profiles`
|
||||
"""
|
||||
|
||||
label = "Collect Managed Staging Directory"
|
||||
order = pyblish.api.CollectorOrder + 0.4990
|
||||
|
||||
def process(self, instance):
|
||||
""" Collect the staging data and stores it to the instance.
|
||||
|
||||
Args:
|
||||
instance (object): The instance to inspect.
|
||||
"""
|
||||
staging_dir_path = get_instance_staging_dir(instance)
|
||||
persistance = instance.data.get("stagingDir_persistent", False)
|
||||
|
||||
self.log.info((
|
||||
f"Instance staging dir was set to `{staging_dir_path}` "
|
||||
f"and persistence is set to `{persistance}`"
|
||||
))
|
||||
|
|
@ -29,6 +29,10 @@ class CollectOtioFrameRanges(pyblish.api.InstancePlugin):
|
|||
otio_range_with_handles
|
||||
)
|
||||
|
||||
if not instance.data.get("otioClip"):
|
||||
self.log.debug("Skipping collect OTIO frame range.")
|
||||
return
|
||||
|
||||
# get basic variables
|
||||
otio_clip = instance.data["otioClip"]
|
||||
workfile_start = instance.data["workfileFrameStart"]
|
||||
|
|
|
|||
|
|
@ -36,6 +36,16 @@ class CollectOtioReview(pyblish.api.InstancePlugin):
|
|||
# optionally get `reviewTrack`
|
||||
review_track_name = instance.data.get("reviewTrack")
|
||||
|
||||
# [clip_media] setting:
|
||||
# Extract current clip source range as reviewable.
|
||||
# Flag review content from otio_clip.
|
||||
if not review_track_name and "review" in instance.data["families"]:
|
||||
otio_review_clips = [otio_clip]
|
||||
|
||||
# skip if no review track available
|
||||
elif not review_track_name:
|
||||
return
|
||||
|
||||
# generate range in parent
|
||||
otio_tl_range = otio_clip.range_in_parent()
|
||||
|
||||
|
|
@ -43,12 +53,14 @@ class CollectOtioReview(pyblish.api.InstancePlugin):
|
|||
clip_frame_end = int(
|
||||
otio_tl_range.start_time.value + otio_tl_range.duration.value)
|
||||
|
||||
# skip if no review track available
|
||||
if not review_track_name:
|
||||
return
|
||||
|
||||
# loop all tracks and match with name in `reviewTrack`
|
||||
for track in otio_timeline.tracks:
|
||||
|
||||
# No review track defined, skip the loop
|
||||
if review_track_name is None:
|
||||
break
|
||||
|
||||
# Not current review track, skip it.
|
||||
if review_track_name != track.name:
|
||||
continue
|
||||
|
||||
|
|
@ -95,9 +107,42 @@ class CollectOtioReview(pyblish.api.InstancePlugin):
|
|||
instance.data["label"] = label + " (review)"
|
||||
instance.data["families"] += ["review", "ftrack"]
|
||||
instance.data["otioReviewClips"] = otio_review_clips
|
||||
|
||||
self.log.info(
|
||||
"Creating review track: {}".format(otio_review_clips))
|
||||
|
||||
# get colorspace from metadata if available
|
||||
# get metadata from first clip with media reference
|
||||
r_otio_cl = next(
|
||||
(
|
||||
clip
|
||||
for clip in otio_review_clips
|
||||
if (
|
||||
isinstance(clip, otio.schema.Clip)
|
||||
and clip.media_reference
|
||||
)
|
||||
),
|
||||
None
|
||||
)
|
||||
if r_otio_cl is not None:
|
||||
media_ref = r_otio_cl.media_reference
|
||||
media_metadata = media_ref.metadata
|
||||
|
||||
# TODO: we might need some alternative method since
|
||||
# native OTIO exports do not support ayon metadata
|
||||
review_colorspace = media_metadata.get(
|
||||
"ayon.source.colorspace"
|
||||
)
|
||||
if review_colorspace is None:
|
||||
# Backwards compatibility for older scenes
|
||||
review_colorspace = media_metadata.get(
|
||||
"openpype.source.colourtransform"
|
||||
)
|
||||
if review_colorspace:
|
||||
instance.data["reviewColorspace"] = review_colorspace
|
||||
self.log.info(
|
||||
"Review colorspace: {}".format(review_colorspace))
|
||||
|
||||
self.log.debug(
|
||||
"_ instance.data: {}".format(pformat(instance.data)))
|
||||
self.log.debug(
|
||||
|
|
|
|||
|
|
@ -6,16 +6,21 @@ Provides:
|
|||
instance -> otioReviewClips
|
||||
"""
|
||||
import os
|
||||
import math
|
||||
|
||||
import clique
|
||||
import pyblish.api
|
||||
|
||||
from ayon_core.pipeline import publish
|
||||
from ayon_core.pipeline.publish import (
|
||||
get_publish_template_name
|
||||
)
|
||||
|
||||
|
||||
class CollectOtioSubsetResources(pyblish.api.InstancePlugin):
|
||||
class CollectOtioSubsetResources(
|
||||
pyblish.api.InstancePlugin,
|
||||
publish.ColormanagedPyblishPluginMixin
|
||||
):
|
||||
"""Get Resources for a product version"""
|
||||
|
||||
label = "Collect OTIO Subset Resources"
|
||||
|
|
@ -65,9 +70,17 @@ class CollectOtioSubsetResources(pyblish.api.InstancePlugin):
|
|||
self.log.debug(
|
||||
">> retimed_attributes: {}".format(retimed_attributes))
|
||||
|
||||
# break down into variables
|
||||
media_in = int(retimed_attributes["mediaIn"])
|
||||
media_out = int(retimed_attributes["mediaOut"])
|
||||
# break down into variables as rounded frame numbers
|
||||
#
|
||||
# 0 1 2 3 4
|
||||
# |-------------|---------------|--------------|-------------|
|
||||
# |_______________media range_______________|
|
||||
# 0.6 3.2
|
||||
#
|
||||
# As rounded frames, media_in = 0 and media_out = 4
|
||||
media_in = math.floor(retimed_attributes["mediaIn"])
|
||||
media_out = math.ceil(retimed_attributes["mediaOut"])
|
||||
|
||||
handle_start = int(retimed_attributes["handleStart"])
|
||||
handle_end = int(retimed_attributes["handleEnd"])
|
||||
|
||||
|
|
@ -169,9 +182,18 @@ class CollectOtioSubsetResources(pyblish.api.InstancePlugin):
|
|||
path, trimmed_media_range_h, metadata)
|
||||
self.staging_dir, collection = collection_data
|
||||
|
||||
self.log.debug(collection)
|
||||
repre = self._create_representation(
|
||||
frame_start, frame_end, collection=collection)
|
||||
if len(collection.indexes) > 1:
|
||||
self.log.debug(collection)
|
||||
repre = self._create_representation(
|
||||
frame_start, frame_end, collection=collection)
|
||||
else:
|
||||
filename = tuple(collection)[0]
|
||||
self.log.debug(filename)
|
||||
|
||||
# TODO: discuss this, it erases frame number.
|
||||
repre = self._create_representation(
|
||||
frame_start, frame_end, file=filename)
|
||||
|
||||
|
||||
else:
|
||||
_trim = False
|
||||
|
|
@ -187,12 +209,18 @@ class CollectOtioSubsetResources(pyblish.api.InstancePlugin):
|
|||
repre = self._create_representation(
|
||||
frame_start, frame_end, file=filename, trim=_trim)
|
||||
|
||||
|
||||
instance.data["originalDirname"] = self.staging_dir
|
||||
|
||||
# add representation to instance data
|
||||
if repre:
|
||||
# add representation to instance data
|
||||
colorspace = instance.data.get("colorspace")
|
||||
# add colorspace data to representation
|
||||
self.set_representation_colorspace(
|
||||
repre, instance.context, colorspace)
|
||||
|
||||
instance.data["representations"].append(repre)
|
||||
self.log.debug(">>>>>>>> {}".format(repre))
|
||||
|
||||
|
||||
self.log.debug(instance.data)
|
||||
|
||||
|
|
@ -213,7 +241,8 @@ class CollectOtioSubsetResources(pyblish.api.InstancePlugin):
|
|||
representation_data = {
|
||||
"frameStart": start,
|
||||
"frameEnd": end,
|
||||
"stagingDir": self.staging_dir
|
||||
"stagingDir": self.staging_dir,
|
||||
"tags": [],
|
||||
}
|
||||
|
||||
if kwargs.get("collection"):
|
||||
|
|
@ -239,8 +268,10 @@ class CollectOtioSubsetResources(pyblish.api.InstancePlugin):
|
|||
"frameEnd": end,
|
||||
})
|
||||
|
||||
if kwargs.get("trim") is True:
|
||||
representation_data["tags"] = ["trim"]
|
||||
for tag_name in ("trim", "delete", "review"):
|
||||
if kwargs.get(tag_name) is True:
|
||||
representation_data["tags"].append(tag_name)
|
||||
|
||||
return representation_data
|
||||
|
||||
def get_template_name(self, instance):
|
||||
|
|
|
|||
|
|
@ -93,8 +93,7 @@ class CollectRenderedFiles(pyblish.api.ContextPlugin):
|
|||
|
||||
# now we can just add instances from json file and we are done
|
||||
any_staging_dir_persistent = False
|
||||
for instance_data in data.get("instances"):
|
||||
|
||||
for instance_data in data["instances"]:
|
||||
self.log.debug(" - processing instance for {}".format(
|
||||
instance_data.get("productName")))
|
||||
instance = self._context.create_instance(
|
||||
|
|
@ -105,7 +104,11 @@ class CollectRenderedFiles(pyblish.api.ContextPlugin):
|
|||
instance.data.update(instance_data)
|
||||
|
||||
# stash render job id for later validation
|
||||
instance.data["render_job_id"] = data.get("job").get("_id")
|
||||
instance.data["publishJobMetadata"] = data
|
||||
# TODO remove 'render_job_id' here and rather use
|
||||
# 'publishJobMetadata' where is needed.
|
||||
# - this is deadline specific
|
||||
instance.data["render_job_id"] = data.get("job", {}).get("_id")
|
||||
staging_dir_persistent = instance.data.get(
|
||||
"stagingDir_persistent", False
|
||||
)
|
||||
|
|
|
|||
|
|
@ -66,7 +66,8 @@ class CollectResourcesPath(pyblish.api.InstancePlugin):
|
|||
"yeticacheUE",
|
||||
"tycache",
|
||||
"usd",
|
||||
"oxrig"
|
||||
"oxrig",
|
||||
"sbsar",
|
||||
]
|
||||
|
||||
def process(self, instance):
|
||||
|
|
|
|||
|
|
@ -14,23 +14,7 @@ class CollectSceneVersion(pyblish.api.ContextPlugin):
|
|||
order = pyblish.api.CollectorOrder
|
||||
label = 'Collect Scene Version'
|
||||
# configurable in Settings
|
||||
hosts = [
|
||||
"aftereffects",
|
||||
"blender",
|
||||
"celaction",
|
||||
"fusion",
|
||||
"harmony",
|
||||
"hiero",
|
||||
"houdini",
|
||||
"maya",
|
||||
"max",
|
||||
"nuke",
|
||||
"photoshop",
|
||||
"resolve",
|
||||
"tvpaint",
|
||||
"motionbuilder",
|
||||
"substancepainter"
|
||||
]
|
||||
hosts = ["*"]
|
||||
|
||||
# in some cases of headless publishing (for example webpublisher using PS)
|
||||
# you want to ignore version from name and let integrate use next version
|
||||
|
|
|
|||
|
|
@ -9,11 +9,13 @@ import clique
|
|||
import pyblish.api
|
||||
|
||||
from ayon_core import resources, AYON_CORE_ROOT
|
||||
from ayon_core.pipeline import publish
|
||||
from ayon_core.pipeline import (
|
||||
publish,
|
||||
get_temp_dir
|
||||
)
|
||||
from ayon_core.lib import (
|
||||
run_ayon_launcher_process,
|
||||
|
||||
get_transcode_temp_directory,
|
||||
convert_input_paths_for_ffmpeg,
|
||||
should_convert_for_ffmpeg
|
||||
)
|
||||
|
|
@ -250,7 +252,10 @@ class ExtractBurnin(publish.Extractor):
|
|||
# - change staging dir of source representation
|
||||
# - must be set back after output definitions processing
|
||||
if do_convert:
|
||||
new_staging_dir = get_transcode_temp_directory()
|
||||
new_staging_dir = get_temp_dir(
|
||||
project_name=instance.context.data["projectName"],
|
||||
use_local_temp=True,
|
||||
)
|
||||
repre["stagingDir"] = new_staging_dir
|
||||
|
||||
convert_input_paths_for_ffmpeg(
|
||||
|
|
|
|||
|
|
@ -3,15 +3,15 @@ import copy
|
|||
import clique
|
||||
import pyblish.api
|
||||
|
||||
from ayon_core.pipeline import publish
|
||||
from ayon_core.pipeline import (
|
||||
publish,
|
||||
get_temp_dir
|
||||
)
|
||||
from ayon_core.lib import (
|
||||
|
||||
is_oiio_supported,
|
||||
)
|
||||
|
||||
from ayon_core.lib.transcoding import (
|
||||
convert_colorspace,
|
||||
get_transcode_temp_directory,
|
||||
)
|
||||
|
||||
from ayon_core.lib.profiles_filtering import filter_profiles
|
||||
|
|
@ -104,7 +104,10 @@ class ExtractOIIOTranscode(publish.Extractor):
|
|||
new_repre = copy.deepcopy(repre)
|
||||
|
||||
original_staging_dir = new_repre["stagingDir"]
|
||||
new_staging_dir = get_transcode_temp_directory()
|
||||
new_staging_dir = get_temp_dir(
|
||||
project_name=instance.context.data["projectName"],
|
||||
use_local_temp=True,
|
||||
)
|
||||
new_repre["stagingDir"] = new_staging_dir
|
||||
|
||||
if isinstance(new_repre["files"], list):
|
||||
|
|
@ -154,12 +157,15 @@ class ExtractOIIOTranscode(publish.Extractor):
|
|||
|
||||
files_to_convert = self._translate_to_sequence(
|
||||
files_to_convert)
|
||||
self.log.debug("Files to convert: {}".format(files_to_convert))
|
||||
for file_name in files_to_convert:
|
||||
self.log.debug("Transcoding file: `{}`".format(file_name))
|
||||
input_path = os.path.join(original_staging_dir,
|
||||
file_name)
|
||||
output_path = self._get_output_file_path(input_path,
|
||||
new_staging_dir,
|
||||
output_extension)
|
||||
|
||||
convert_colorspace(
|
||||
input_path,
|
||||
output_path,
|
||||
|
|
@ -263,7 +269,7 @@ class ExtractOIIOTranscode(publish.Extractor):
|
|||
(list) of [file.1001-1010#.exr] or [fileA.exr, fileB.exr]
|
||||
"""
|
||||
pattern = [clique.PATTERNS["frames"]]
|
||||
collections, remainder = clique.assemble(
|
||||
collections, _ = clique.assemble(
|
||||
files_to_convert, patterns=pattern,
|
||||
assume_padded_when_ambiguous=True)
|
||||
|
||||
|
|
|
|||
|
|
@ -37,6 +37,9 @@ class ExtractColorspaceData(publish.Extractor,
|
|||
# get colorspace settings
|
||||
context = instance.context
|
||||
|
||||
# colorspace name could be kept in instance.data
|
||||
colorspace = instance.data.get("colorspace")
|
||||
|
||||
# loop representations
|
||||
for representation in representations:
|
||||
# skip if colorspaceData is already at representation
|
||||
|
|
@ -44,5 +47,4 @@ class ExtractColorspaceData(publish.Extractor,
|
|||
continue
|
||||
|
||||
self.set_representation_colorspace(
|
||||
representation, context
|
||||
)
|
||||
representation, context, colorspace)
|
||||
|
|
|
|||
|
|
@ -22,7 +22,6 @@ class ExtractHierarchyToAYON(pyblish.api.ContextPlugin):
|
|||
|
||||
order = pyblish.api.ExtractorOrder - 0.01
|
||||
label = "Extract Hierarchy To AYON"
|
||||
families = ["clip", "shot"]
|
||||
|
||||
def process(self, context):
|
||||
if not context.data.get("hierarchyContext"):
|
||||
|
|
@ -154,7 +153,9 @@ class ExtractHierarchyToAYON(pyblish.api.ContextPlugin):
|
|||
# TODO check if existing entity have 'task' type
|
||||
if task_entity is None:
|
||||
task_entity = entity_hub.add_new_task(
|
||||
task_info["type"],
|
||||
task_type=task_info["type"],
|
||||
# TODO change 'parent_id' to 'folder_id' when ayon api
|
||||
# is updated
|
||||
parent_id=entity.id,
|
||||
name=task_name
|
||||
)
|
||||
|
|
@ -182,7 +183,7 @@ class ExtractHierarchyToAYON(pyblish.api.ContextPlugin):
|
|||
folder_type = "Folder"
|
||||
|
||||
child_entity = entity_hub.add_new_folder(
|
||||
folder_type,
|
||||
folder_type=folder_type,
|
||||
parent_id=entity.id,
|
||||
name=child_name
|
||||
)
|
||||
|
|
|
|||
|
|
@ -71,20 +71,18 @@ class ExtractOtioAudioTracks(pyblish.api.ContextPlugin):
|
|||
name = inst.data["folderPath"]
|
||||
|
||||
recycling_file = [f for f in created_files if name in f]
|
||||
|
||||
# frameranges
|
||||
timeline_in_h = inst.data["clipInH"]
|
||||
timeline_out_h = inst.data["clipOutH"]
|
||||
fps = inst.data["fps"]
|
||||
|
||||
# create duration
|
||||
duration = (timeline_out_h - timeline_in_h) + 1
|
||||
audio_clip = inst.data["otioClip"]
|
||||
audio_range = audio_clip.range_in_parent()
|
||||
duration = audio_range.duration.to_frames()
|
||||
|
||||
# ffmpeg generate new file only if doesn't exists already
|
||||
if not recycling_file:
|
||||
# convert to seconds
|
||||
start_sec = float(timeline_in_h / fps)
|
||||
duration_sec = float(duration / fps)
|
||||
parent_track = audio_clip.parent()
|
||||
parent_track_start = parent_track.range_in_parent().start_time
|
||||
relative_start_time = (
|
||||
audio_range.start_time - parent_track_start)
|
||||
start_sec = relative_start_time.to_seconds()
|
||||
duration_sec = audio_range.duration.to_seconds()
|
||||
|
||||
# temp audio file
|
||||
audio_fpath = self.create_temp_file(name)
|
||||
|
|
@ -163,34 +161,36 @@ class ExtractOtioAudioTracks(pyblish.api.ContextPlugin):
|
|||
|
||||
output = []
|
||||
# go trough all audio tracks
|
||||
for otio_track in otio_timeline.tracks:
|
||||
if "Audio" not in otio_track.kind:
|
||||
continue
|
||||
for otio_track in otio_timeline.audio_tracks():
|
||||
self.log.debug("_" * 50)
|
||||
playhead = 0
|
||||
for otio_clip in otio_track:
|
||||
self.log.debug(otio_clip)
|
||||
if isinstance(otio_clip, otio.schema.Gap):
|
||||
playhead += otio_clip.source_range.duration.value
|
||||
elif isinstance(otio_clip, otio.schema.Clip):
|
||||
start = otio_clip.source_range.start_time.value
|
||||
duration = otio_clip.source_range.duration.value
|
||||
fps = otio_clip.source_range.start_time.rate
|
||||
if (isinstance(otio_clip, otio.schema.Clip) and
|
||||
not otio_clip.media_reference.is_missing_reference):
|
||||
media_av_start = otio_clip.available_range().start_time
|
||||
clip_start = otio_clip.source_range.start_time
|
||||
fps = clip_start.rate
|
||||
conformed_av_start = media_av_start.rescaled_to(fps)
|
||||
# ffmpeg ignores embedded tc
|
||||
start = clip_start - conformed_av_start
|
||||
duration = otio_clip.source_range.duration
|
||||
media_path = otio_clip.media_reference.target_url
|
||||
input = {
|
||||
"mediaPath": media_path,
|
||||
"delayFrame": playhead,
|
||||
"startFrame": start,
|
||||
"durationFrame": duration,
|
||||
"startFrame": start.to_frames(),
|
||||
"durationFrame": duration.to_frames(),
|
||||
"delayMilSec": int(float(playhead / fps) * 1000),
|
||||
"startSec": float(start / fps),
|
||||
"durationSec": float(duration / fps),
|
||||
"fps": fps
|
||||
"startSec": start.to_seconds(),
|
||||
"durationSec": duration.to_seconds(),
|
||||
"fps": float(fps)
|
||||
}
|
||||
if input not in output:
|
||||
output.append(input)
|
||||
self.log.debug("__ input: {}".format(input))
|
||||
playhead += otio_clip.source_range.duration.value
|
||||
|
||||
playhead += otio_clip.source_range.duration.value
|
||||
|
||||
return output
|
||||
|
||||
|
|
|
|||
|
|
@ -26,7 +26,10 @@ from ayon_core.lib import (
|
|||
from ayon_core.pipeline import publish
|
||||
|
||||
|
||||
class ExtractOTIOReview(publish.Extractor):
|
||||
class ExtractOTIOReview(
|
||||
publish.Extractor,
|
||||
publish.ColormanagedPyblishPluginMixin
|
||||
):
|
||||
"""
|
||||
Extract OTIO timeline into one concuted image sequence file.
|
||||
|
||||
|
|
@ -68,17 +71,24 @@ class ExtractOTIOReview(publish.Extractor):
|
|||
# TODO: convert resulting image sequence to mp4
|
||||
|
||||
# get otio clip and other time info from instance clip
|
||||
otio_review_clips = instance.data.get("otioReviewClips")
|
||||
|
||||
if otio_review_clips is None:
|
||||
self.log.info(f"Instance `{instance}` has no otioReviewClips")
|
||||
return
|
||||
|
||||
# TODO: what if handles are different in `versionData`?
|
||||
handle_start = instance.data["handleStart"]
|
||||
handle_end = instance.data["handleEnd"]
|
||||
otio_review_clips = instance.data["otioReviewClips"]
|
||||
|
||||
# add plugin wide attributes
|
||||
self.representation_files = []
|
||||
self.used_frames = []
|
||||
self.workfile_start = int(instance.data.get(
|
||||
"workfileFrameStart", 1001)) - handle_start
|
||||
self.padding = len(str(self.workfile_start))
|
||||
# NOTE: padding has to be converted from
|
||||
# end frame since start could be lower then 1000
|
||||
self.padding = len(str(instance.data.get("frameEnd", 1001)))
|
||||
self.used_frames.append(self.workfile_start)
|
||||
self.to_width = instance.data.get(
|
||||
"resolutionWidth") or self.to_width
|
||||
|
|
@ -86,8 +96,10 @@ class ExtractOTIOReview(publish.Extractor):
|
|||
"resolutionHeight") or self.to_height
|
||||
|
||||
# skip instance if no reviewable data available
|
||||
if (not isinstance(otio_review_clips[0], otio.schema.Clip)) \
|
||||
and (len(otio_review_clips) == 1):
|
||||
if (
|
||||
not isinstance(otio_review_clips[0], otio.schema.Clip)
|
||||
and len(otio_review_clips) == 1
|
||||
):
|
||||
self.log.warning(
|
||||
"Instance `{}` has nothing to process".format(instance))
|
||||
return
|
||||
|
|
@ -119,26 +131,32 @@ class ExtractOTIOReview(publish.Extractor):
|
|||
res_data[key] = value
|
||||
break
|
||||
|
||||
self.to_width, self.to_height = res_data["width"], res_data["height"]
|
||||
self.log.debug("> self.to_width x self.to_height: {} x {}".format(
|
||||
self.to_width, self.to_height
|
||||
))
|
||||
self.to_width, self.to_height = (
|
||||
res_data["width"], res_data["height"]
|
||||
)
|
||||
self.log.debug(
|
||||
"> self.to_width x self.to_height:"
|
||||
f" {self.to_width} x {self.to_height}"
|
||||
)
|
||||
|
||||
available_range = r_otio_cl.available_range()
|
||||
available_range_start_frame = (
|
||||
available_range.start_time.to_frames()
|
||||
)
|
||||
processing_range = None
|
||||
self.actual_fps = available_range.duration.rate
|
||||
start = src_range.start_time.rescaled_to(self.actual_fps)
|
||||
duration = src_range.duration.rescaled_to(self.actual_fps)
|
||||
|
||||
# Temporary.
|
||||
# Some AYON custom OTIO exporter were implemented with relative
|
||||
# source range for image sequence. Following code maintain
|
||||
# backward-compatibility by adjusting available range
|
||||
# Some AYON custom OTIO exporter were implemented with
|
||||
# relative source range for image sequence. Following code
|
||||
# maintain backward-compatibility by adjusting available range
|
||||
# while we are updating those.
|
||||
if (
|
||||
is_clip_from_media_sequence(r_otio_cl)
|
||||
and available_range.start_time.to_frames() == media_ref.start_frame
|
||||
and src_range.start_time.to_frames() < media_ref.start_frame
|
||||
and available_range_start_frame == media_ref.start_frame
|
||||
and start.to_frames() < media_ref.start_frame
|
||||
):
|
||||
available_range = otio.opentime.TimeRange(
|
||||
otio.opentime.RationalTime(0, rate=self.actual_fps),
|
||||
|
|
@ -168,7 +186,7 @@ class ExtractOTIOReview(publish.Extractor):
|
|||
start -= clip_handle_start
|
||||
duration += clip_handle_start
|
||||
elif len(otio_review_clips) > 1 \
|
||||
and (index == len(otio_review_clips) - 1):
|
||||
and (index == len(otio_review_clips) - 1):
|
||||
# more clips | last clip reframing with handle
|
||||
duration += clip_handle_end
|
||||
elif len(otio_review_clips) == 1:
|
||||
|
|
@ -190,13 +208,9 @@ class ExtractOTIOReview(publish.Extractor):
|
|||
# File sequence way
|
||||
if is_sequence:
|
||||
# Remap processing range to input file sequence.
|
||||
processing_range_as_frames = (
|
||||
processing_range.start_time.to_frames(),
|
||||
processing_range.end_time_inclusive().to_frames()
|
||||
)
|
||||
first, last = remap_range_on_file_sequence(
|
||||
r_otio_cl,
|
||||
processing_range_as_frames,
|
||||
processing_range,
|
||||
)
|
||||
input_fps = processing_range.start_time.rate
|
||||
|
||||
|
|
@ -236,7 +250,8 @@ class ExtractOTIOReview(publish.Extractor):
|
|||
# Extraction via FFmpeg.
|
||||
else:
|
||||
path = media_ref.target_url
|
||||
# Set extract range from 0 (FFmpeg ignores embedded timecode).
|
||||
# Set extract range from 0 (FFmpeg ignores
|
||||
# embedded timecode).
|
||||
extract_range = otio.opentime.TimeRange(
|
||||
otio.opentime.RationalTime(
|
||||
(
|
||||
|
|
@ -263,8 +278,15 @@ class ExtractOTIOReview(publish.Extractor):
|
|||
|
||||
# creating and registering representation
|
||||
representation = self._create_representation(start, duration)
|
||||
|
||||
# add colorspace data to representation
|
||||
if colorspace := instance.data.get("reviewColorspace"):
|
||||
self.set_representation_colorspace(
|
||||
representation, instance.context, colorspace
|
||||
)
|
||||
|
||||
instance.data["representations"].append(representation)
|
||||
self.log.info("Adding representation: {}".format(representation))
|
||||
self.log.debug("Adding representation: {}".format(representation))
|
||||
|
||||
def _create_representation(self, start, duration):
|
||||
"""
|
||||
|
|
@ -298,6 +320,9 @@ class ExtractOTIOReview(publish.Extractor):
|
|||
end = max(collection.indexes)
|
||||
|
||||
files = [f for f in collection]
|
||||
# single frame sequence
|
||||
if len(files) == 1:
|
||||
files = files[0]
|
||||
ext = collection.format("{tail}")
|
||||
representation_data.update({
|
||||
"name": ext[1:],
|
||||
|
|
@ -397,7 +422,8 @@ class ExtractOTIOReview(publish.Extractor):
|
|||
to defined image sequence format.
|
||||
|
||||
Args:
|
||||
sequence (list): input dir path string, collection object, fps in list
|
||||
sequence (list): input dir path string, collection object,
|
||||
fps in list.
|
||||
video (list)[optional]: video_path string, otio_range in list
|
||||
gap (int)[optional]: gap duration
|
||||
end_offset (int)[optional]: offset gap frame start in frames
|
||||
|
|
|
|||
|
|
@ -22,8 +22,8 @@ from ayon_core.lib.transcoding import (
|
|||
should_convert_for_ffmpeg,
|
||||
get_review_layer_name,
|
||||
convert_input_paths_for_ffmpeg,
|
||||
get_transcode_temp_directory,
|
||||
)
|
||||
from ayon_core.pipeline import get_temp_dir
|
||||
from ayon_core.pipeline.publish import (
|
||||
KnownPublishError,
|
||||
get_publish_instance_label,
|
||||
|
|
@ -310,7 +310,10 @@ class ExtractReview(pyblish.api.InstancePlugin):
|
|||
# - change staging dir of source representation
|
||||
# - must be set back after output definitions processing
|
||||
if do_convert:
|
||||
new_staging_dir = get_transcode_temp_directory()
|
||||
new_staging_dir = get_temp_dir(
|
||||
project_name=instance.context.data["projectName"],
|
||||
use_local_temp=True,
|
||||
)
|
||||
repre["stagingDir"] = new_staging_dir
|
||||
|
||||
convert_input_paths_for_ffmpeg(
|
||||
|
|
|
|||
|
|
@ -35,8 +35,11 @@ class ExtractThumbnail(pyblish.api.InstancePlugin):
|
|||
"resolve",
|
||||
"traypublisher",
|
||||
"substancepainter",
|
||||
"substancedesigner",
|
||||
"nuke",
|
||||
"aftereffects"
|
||||
"aftereffects",
|
||||
"unreal",
|
||||
"houdini"
|
||||
]
|
||||
enabled = False
|
||||
|
||||
|
|
|
|||
|
|
@ -458,7 +458,18 @@ class CollectUSDLayerContributions(pyblish.api.InstancePlugin,
|
|||
return new_instance
|
||||
|
||||
@classmethod
|
||||
def get_attribute_defs(cls):
|
||||
def get_attr_defs_for_instance(cls, create_context, instance):
|
||||
# Filtering of instance, if needed, can be customized
|
||||
if not cls.instance_matches_plugin_families(instance):
|
||||
return []
|
||||
|
||||
# Attributes logic
|
||||
publish_attributes = instance["publish_attributes"].get(
|
||||
cls.__name__, {})
|
||||
|
||||
visible = publish_attributes.get("contribution_enabled", True)
|
||||
variant_visible = visible and publish_attributes.get(
|
||||
"contribution_apply_as_variant", True)
|
||||
|
||||
return [
|
||||
UISeparatorDef("usd_container_settings1"),
|
||||
|
|
@ -484,7 +495,8 @@ class CollectUSDLayerContributions(pyblish.api.InstancePlugin,
|
|||
"the contribution itself will be added to the "
|
||||
"department layer."
|
||||
),
|
||||
default="usdAsset"),
|
||||
default="usdAsset",
|
||||
visible=visible),
|
||||
EnumDef("contribution_target_product_init",
|
||||
label="Initialize as",
|
||||
tooltip=(
|
||||
|
|
@ -495,7 +507,8 @@ class CollectUSDLayerContributions(pyblish.api.InstancePlugin,
|
|||
"setting will do nothing."
|
||||
),
|
||||
items=["asset", "shot"],
|
||||
default="asset"),
|
||||
default="asset",
|
||||
visible=visible),
|
||||
|
||||
# Asset layer, e.g. model.usd, look.usd, rig.usd
|
||||
EnumDef("contribution_layer",
|
||||
|
|
@ -507,7 +520,8 @@ class CollectUSDLayerContributions(pyblish.api.InstancePlugin,
|
|||
"the list) will contribute as a stronger opinion."
|
||||
),
|
||||
items=list(cls.contribution_layers.keys()),
|
||||
default="model"),
|
||||
default="model",
|
||||
visible=visible),
|
||||
BoolDef("contribution_apply_as_variant",
|
||||
label="Add as variant",
|
||||
tooltip=(
|
||||
|
|
@ -518,13 +532,16 @@ class CollectUSDLayerContributions(pyblish.api.InstancePlugin,
|
|||
"appended to as a sublayer to the department layer "
|
||||
"instead."
|
||||
),
|
||||
default=True),
|
||||
default=True,
|
||||
visible=visible),
|
||||
TextDef("contribution_variant_set_name",
|
||||
label="Variant Set Name",
|
||||
default="{layer}"),
|
||||
default="{layer}",
|
||||
visible=variant_visible),
|
||||
TextDef("contribution_variant",
|
||||
label="Variant Name",
|
||||
default="{variant}"),
|
||||
default="{variant}",
|
||||
visible=variant_visible),
|
||||
BoolDef("contribution_variant_is_default",
|
||||
label="Set as default variant selection",
|
||||
tooltip=(
|
||||
|
|
@ -535,10 +552,41 @@ class CollectUSDLayerContributions(pyblish.api.InstancePlugin,
|
|||
"The behavior is unpredictable if multiple instances "
|
||||
"for the same variant set have this enabled."
|
||||
),
|
||||
default=False),
|
||||
default=False,
|
||||
visible=variant_visible),
|
||||
UISeparatorDef("usd_container_settings3"),
|
||||
]
|
||||
|
||||
@classmethod
|
||||
def register_create_context_callbacks(cls, create_context):
|
||||
create_context.add_value_changed_callback(cls.on_values_changed)
|
||||
|
||||
@classmethod
|
||||
def on_values_changed(cls, event):
|
||||
"""Update instance attribute definitions on attribute changes."""
|
||||
|
||||
# Update attributes if any of the following plug-in attributes
|
||||
# change:
|
||||
keys = ["contribution_enabled", "contribution_apply_as_variant"]
|
||||
|
||||
for instance_change in event["changes"]:
|
||||
instance = instance_change["instance"]
|
||||
if not cls.instance_matches_plugin_families(instance):
|
||||
continue
|
||||
value_changes = instance_change["changes"]
|
||||
plugin_attribute_changes = (
|
||||
value_changes.get("publish_attributes", {})
|
||||
.get(cls.__name__, {}))
|
||||
|
||||
if not any(key in plugin_attribute_changes for key in keys):
|
||||
continue
|
||||
|
||||
# Update the attribute definitions
|
||||
new_attrs = cls.get_attr_defs_for_instance(
|
||||
event["create_context"], instance
|
||||
)
|
||||
instance.set_publish_plugin_attr_defs(cls.__name__, new_attrs)
|
||||
|
||||
|
||||
class CollectUSDLayerContributionsHoudiniLook(CollectUSDLayerContributions):
|
||||
"""
|
||||
|
|
@ -551,9 +599,12 @@ class CollectUSDLayerContributionsHoudiniLook(CollectUSDLayerContributions):
|
|||
label = CollectUSDLayerContributions.label + " (Look)"
|
||||
|
||||
@classmethod
|
||||
def get_attribute_defs(cls):
|
||||
defs = super(CollectUSDLayerContributionsHoudiniLook,
|
||||
cls).get_attribute_defs()
|
||||
def get_attr_defs_for_instance(cls, create_context, instance):
|
||||
# Filtering of instance, if needed, can be customized
|
||||
if not cls.instance_matches_plugin_families(instance):
|
||||
return []
|
||||
|
||||
defs = super().get_attr_defs_for_instance(create_context, instance)
|
||||
|
||||
# Update default for department layer to look
|
||||
layer_def = next(d for d in defs if d.key == "contribution_layer")
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ class ValidateCurrentSaveFile(pyblish.api.ContextPlugin):
|
|||
label = "Validate File Saved"
|
||||
order = pyblish.api.ValidatorOrder - 0.1
|
||||
hosts = ["fusion", "houdini", "max", "maya", "nuke", "substancepainter",
|
||||
"cinema4d"]
|
||||
"cinema4d", "silhouette"]
|
||||
actions = [SaveByVersionUpAction, ShowWorkfilesAction]
|
||||
|
||||
def process(self, context):
|
||||
|
|
|
|||
|
|
@ -11,8 +11,8 @@ class ValidateProductUniqueness(pyblish.api.ContextPlugin):
|
|||
"""Validate all product names are unique.
|
||||
|
||||
This only validates whether the instances currently set to publish from
|
||||
the workfile overlap one another for the folder + product they are publishing
|
||||
to.
|
||||
the workfile overlap one another for the folder + product they are
|
||||
publishing to.
|
||||
|
||||
This does not perform any check against existing publishes in the database
|
||||
since it is allowed to publish into existing products resulting in
|
||||
|
|
@ -72,8 +72,10 @@ class ValidateProductUniqueness(pyblish.api.ContextPlugin):
|
|||
# All is ok
|
||||
return
|
||||
|
||||
msg = ("Instance product names {} are not unique. ".format(non_unique) +
|
||||
"Please remove or rename duplicates.")
|
||||
msg = (
|
||||
f"Instance product names {non_unique} are not unique."
|
||||
" Please remove or rename duplicates."
|
||||
)
|
||||
formatting_data = {
|
||||
"non_unique": ",".join(non_unique)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -79,7 +79,8 @@ class ModifiedBurnins(ffmpeg_burnins.Burnins):
|
|||
- Datatypes explanation:
|
||||
<color> string format must be supported by FFmpeg.
|
||||
Examples: "#000000", "0x000000", "black"
|
||||
<font> must be accesible by ffmpeg = name of registered Font in system or path to font file.
|
||||
<font> must be accesible by ffmpeg = name of registered Font in system
|
||||
or path to font file.
|
||||
Examples: "Arial", "C:/Windows/Fonts/arial.ttf"
|
||||
|
||||
- Possible keys:
|
||||
|
|
@ -87,17 +88,21 @@ class ModifiedBurnins(ffmpeg_burnins.Burnins):
|
|||
"bg_opacity" - Opacity of background (box around text) - <float, Range:0-1>
|
||||
"bg_color" - Background color - <color>
|
||||
"bg_padding" - Background padding in pixels - <int>
|
||||
"x_offset" - offsets burnin vertically by entered pixels from border - <int>
|
||||
"y_offset" - offsets burnin horizontally by entered pixels from border - <int>
|
||||
"x_offset" - offsets burnin vertically by entered pixels
|
||||
from border - <int>
|
||||
"y_offset" - offsets burnin horizontally by entered pixels
|
||||
from border - <int>
|
||||
- x_offset & y_offset should be set at least to same value as bg_padding!!
|
||||
"font" - Font Family for text - <font>
|
||||
"font_size" - Font size in pixels - <int>
|
||||
"font_color" - Color of text - <color>
|
||||
"frame_offset" - Default start frame - <int>
|
||||
- required IF start frame is not set when using frames or timecode burnins
|
||||
- required IF start frame is not set when using frames
|
||||
or timecode burnins
|
||||
|
||||
On initializing class can be set General options through "options_init" arg.
|
||||
General can be overridden when adding burnin
|
||||
On initializing class can be set General options through
|
||||
"options_init" arg.
|
||||
General options can be overridden when adding burnin.
|
||||
|
||||
'''
|
||||
TOP_CENTERED = ffmpeg_burnins.TOP_CENTERED
|
||||
|
|
|
|||
|
|
@ -190,6 +190,7 @@ def get_current_project_settings():
|
|||
project_name = os.environ.get("AYON_PROJECT_NAME")
|
||||
if not project_name:
|
||||
raise ValueError(
|
||||
"Missing context project in environemt variable `AYON_PROJECT_NAME`."
|
||||
"Missing context project in environment"
|
||||
" variable `AYON_PROJECT_NAME`."
|
||||
)
|
||||
return get_project_settings(project_name)
|
||||
|
|
|
|||
|
|
@ -60,7 +60,11 @@
|
|||
"icon-alert-tools": "#AA5050",
|
||||
"icon-entity-default": "#bfccd6",
|
||||
"icon-entity-disabled": "#808080",
|
||||
|
||||
"font-entity-deprecated": "#666666",
|
||||
|
||||
"font-overridden": "#91CDFC",
|
||||
|
||||
"overlay-messages": {
|
||||
"close-btn": "#D3D8DE",
|
||||
"bg-success": "#458056",
|
||||
|
|
|
|||
|
|
@ -23,6 +23,9 @@ Enabled vs Disabled logic in most of stylesheets
|
|||
font-family: "Noto Sans";
|
||||
font-weight: 450;
|
||||
outline: none;
|
||||
|
||||
/* Define icon size to fix size issues for most of DCCs */
|
||||
icon-size: 16px;
|
||||
}
|
||||
|
||||
QWidget {
|
||||
|
|
@ -1168,6 +1171,8 @@ ValidationArtistMessage QLabel {
|
|||
|
||||
#PublishLogMessage {
|
||||
font-family: "Noto Sans Mono";
|
||||
border: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
#PublishInstanceLogsLabel {
|
||||
|
|
@ -1585,6 +1590,10 @@ CreateNextPageOverlay {
|
|||
}
|
||||
|
||||
/* Attribute Definition widgets */
|
||||
AttributeDefinitionsLabel[overridden="1"] {
|
||||
color: {color:font-overridden};
|
||||
}
|
||||
|
||||
AttributeDefinitionsWidget QAbstractSpinBox, QLineEdit, QPlainTextEdit, QTextEdit {
|
||||
padding: 1px;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
from .widgets import (
|
||||
create_widget_for_attr_def,
|
||||
AttributeDefinitionsWidget,
|
||||
AttributeDefinitionsLabel,
|
||||
)
|
||||
|
||||
from .dialog import (
|
||||
|
|
@ -11,6 +12,7 @@ from .dialog import (
|
|||
__all__ = (
|
||||
"create_widget_for_attr_def",
|
||||
"AttributeDefinitionsWidget",
|
||||
"AttributeDefinitionsLabel",
|
||||
|
||||
"AttributeDefinitionsDialog",
|
||||
)
|
||||
|
|
|
|||
1
client/ayon_core/tools/attribute_defs/_constants.py
Normal file
1
client/ayon_core/tools/attribute_defs/_constants.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
REVERT_TO_DEFAULT_LABEL = "Revert to default"
|
||||
|
|
@ -17,6 +17,8 @@ from ayon_core.tools.utils import (
|
|||
PixmapLabel
|
||||
)
|
||||
|
||||
from ._constants import REVERT_TO_DEFAULT_LABEL
|
||||
|
||||
ITEM_ID_ROLE = QtCore.Qt.UserRole + 1
|
||||
ITEM_LABEL_ROLE = QtCore.Qt.UserRole + 2
|
||||
ITEM_ICON_ROLE = QtCore.Qt.UserRole + 3
|
||||
|
|
@ -252,7 +254,7 @@ class FilesModel(QtGui.QStandardItemModel):
|
|||
"""Make sure that removed items are removed from items mapping.
|
||||
|
||||
Connected with '_on_insert'. When user drag item and drop it to same
|
||||
view the item is actually removed and creted again but it happens in
|
||||
view the item is actually removed and created again but it happens in
|
||||
inner calls of Qt.
|
||||
"""
|
||||
|
||||
|
|
@ -598,7 +600,7 @@ class FilesView(QtWidgets.QListView):
|
|||
"""View showing instances and their groups."""
|
||||
|
||||
remove_requested = QtCore.Signal()
|
||||
context_menu_requested = QtCore.Signal(QtCore.QPoint)
|
||||
context_menu_requested = QtCore.Signal(QtCore.QPoint, bool)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(FilesView, self).__init__(*args, **kwargs)
|
||||
|
|
@ -690,9 +692,8 @@ class FilesView(QtWidgets.QListView):
|
|||
|
||||
def _on_context_menu_request(self, pos):
|
||||
index = self.indexAt(pos)
|
||||
if index.isValid():
|
||||
point = self.viewport().mapToGlobal(pos)
|
||||
self.context_menu_requested.emit(point)
|
||||
point = self.viewport().mapToGlobal(pos)
|
||||
self.context_menu_requested.emit(point, index.isValid())
|
||||
|
||||
def _on_selection_change(self):
|
||||
self._remove_btn.setEnabled(self.has_selected_item_ids())
|
||||
|
|
@ -721,27 +722,34 @@ class FilesView(QtWidgets.QListView):
|
|||
|
||||
class FilesWidget(QtWidgets.QFrame):
|
||||
value_changed = QtCore.Signal()
|
||||
revert_requested = QtCore.Signal()
|
||||
|
||||
def __init__(self, single_item, allow_sequences, extensions_label, parent):
|
||||
super(FilesWidget, self).__init__(parent)
|
||||
super().__init__(parent)
|
||||
self.setAcceptDrops(True)
|
||||
|
||||
wrapper_widget = QtWidgets.QWidget(self)
|
||||
|
||||
empty_widget = DropEmpty(
|
||||
single_item, allow_sequences, extensions_label, self
|
||||
single_item, allow_sequences, extensions_label, wrapper_widget
|
||||
)
|
||||
|
||||
files_model = FilesModel(single_item, allow_sequences)
|
||||
files_proxy_model = FilesProxyModel()
|
||||
files_proxy_model.setSourceModel(files_model)
|
||||
files_view = FilesView(self)
|
||||
files_view = FilesView(wrapper_widget)
|
||||
files_view.setModel(files_proxy_model)
|
||||
|
||||
layout = QtWidgets.QStackedLayout(self)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setStackingMode(QtWidgets.QStackedLayout.StackAll)
|
||||
layout.addWidget(empty_widget)
|
||||
layout.addWidget(files_view)
|
||||
layout.setCurrentWidget(empty_widget)
|
||||
wrapper_layout = QtWidgets.QStackedLayout(wrapper_widget)
|
||||
wrapper_layout.setContentsMargins(0, 0, 0, 0)
|
||||
wrapper_layout.setStackingMode(QtWidgets.QStackedLayout.StackAll)
|
||||
wrapper_layout.addWidget(empty_widget)
|
||||
wrapper_layout.addWidget(files_view)
|
||||
wrapper_layout.setCurrentWidget(empty_widget)
|
||||
|
||||
main_layout = QtWidgets.QHBoxLayout(self)
|
||||
main_layout.setContentsMargins(0, 0, 0, 0)
|
||||
main_layout.addWidget(wrapper_widget, 1)
|
||||
|
||||
files_proxy_model.rowsInserted.connect(self._on_rows_inserted)
|
||||
files_proxy_model.rowsRemoved.connect(self._on_rows_removed)
|
||||
|
|
@ -761,7 +769,11 @@ class FilesWidget(QtWidgets.QFrame):
|
|||
|
||||
self._widgets_by_id = {}
|
||||
|
||||
self._layout = layout
|
||||
self._wrapper_widget = wrapper_widget
|
||||
self._wrapper_layout = wrapper_layout
|
||||
|
||||
self.setContextMenuPolicy(QtCore.Qt.CustomContextMenu)
|
||||
self.customContextMenuRequested.connect(self._on_context_menu)
|
||||
|
||||
def _set_multivalue(self, multivalue):
|
||||
if self._multivalue is multivalue:
|
||||
|
|
@ -770,7 +782,7 @@ class FilesWidget(QtWidgets.QFrame):
|
|||
self._files_view.set_multivalue(multivalue)
|
||||
self._files_model.set_multivalue(multivalue)
|
||||
self._files_proxy_model.set_multivalue(multivalue)
|
||||
self.setEnabled(not multivalue)
|
||||
self._wrapper_widget.setEnabled(not multivalue)
|
||||
|
||||
def set_value(self, value, multivalue):
|
||||
self._in_set_value = True
|
||||
|
|
@ -829,7 +841,7 @@ class FilesWidget(QtWidgets.QFrame):
|
|||
self._multivalue
|
||||
)
|
||||
widget.context_menu_requested.connect(
|
||||
self._on_context_menu_requested
|
||||
self._on_item_context_menu_request
|
||||
)
|
||||
self._files_view.setIndexWidget(index, widget)
|
||||
self._files_proxy_model.setData(
|
||||
|
|
@ -847,7 +859,7 @@ class FilesWidget(QtWidgets.QFrame):
|
|||
for row in range(self._files_proxy_model.rowCount()):
|
||||
index = self._files_proxy_model.index(row, 0)
|
||||
item_id = index.data(ITEM_ID_ROLE)
|
||||
available_item_ids.add(index.data(ITEM_ID_ROLE))
|
||||
available_item_ids.add(item_id)
|
||||
|
||||
widget_ids = set(self._widgets_by_id.keys())
|
||||
for item_id in available_item_ids:
|
||||
|
|
@ -888,22 +900,31 @@ class FilesWidget(QtWidgets.QFrame):
|
|||
if items_to_delete:
|
||||
self._remove_item_by_ids(items_to_delete)
|
||||
|
||||
def _on_context_menu_requested(self, pos):
|
||||
if self._multivalue:
|
||||
return
|
||||
def _on_context_menu(self, pos):
|
||||
self._on_context_menu_requested(pos, False)
|
||||
|
||||
def _on_context_menu_requested(self, pos, valid_index):
|
||||
menu = QtWidgets.QMenu(self._files_view)
|
||||
if valid_index and not self._multivalue:
|
||||
if self._files_view.has_selected_sequence():
|
||||
split_action = QtWidgets.QAction("Split sequence", menu)
|
||||
split_action.triggered.connect(self._on_split_request)
|
||||
menu.addAction(split_action)
|
||||
|
||||
if self._files_view.has_selected_sequence():
|
||||
split_action = QtWidgets.QAction("Split sequence", menu)
|
||||
split_action.triggered.connect(self._on_split_request)
|
||||
menu.addAction(split_action)
|
||||
remove_action = QtWidgets.QAction("Remove", menu)
|
||||
remove_action.triggered.connect(self._on_remove_requested)
|
||||
menu.addAction(remove_action)
|
||||
|
||||
remove_action = QtWidgets.QAction("Remove", menu)
|
||||
remove_action.triggered.connect(self._on_remove_requested)
|
||||
menu.addAction(remove_action)
|
||||
if not valid_index:
|
||||
revert_action = QtWidgets.QAction(REVERT_TO_DEFAULT_LABEL, menu)
|
||||
revert_action.triggered.connect(self.revert_requested)
|
||||
menu.addAction(revert_action)
|
||||
|
||||
menu.popup(pos)
|
||||
if menu.actions():
|
||||
menu.popup(pos)
|
||||
|
||||
def _on_item_context_menu_request(self, pos):
|
||||
self._on_context_menu_requested(pos, True)
|
||||
|
||||
def dragEnterEvent(self, event):
|
||||
if self._multivalue:
|
||||
|
|
@ -1011,5 +1032,5 @@ class FilesWidget(QtWidgets.QFrame):
|
|||
current_widget = self._files_view
|
||||
else:
|
||||
current_widget = self._empty_widget
|
||||
self._layout.setCurrentWidget(current_widget)
|
||||
self._wrapper_layout.setCurrentWidget(current_widget)
|
||||
self._files_view.update_remove_btn_visibility()
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import copy
|
||||
import typing
|
||||
from typing import Optional
|
||||
|
||||
from qtpy import QtWidgets, QtCore
|
||||
from qtpy import QtWidgets, QtCore, QtGui
|
||||
|
||||
from ayon_core.lib.attribute_definitions import (
|
||||
AbstractAttrDef,
|
||||
|
|
@ -20,58 +22,125 @@ from ayon_core.tools.utils import (
|
|||
FocusSpinBox,
|
||||
FocusDoubleSpinBox,
|
||||
MultiSelectionComboBox,
|
||||
PlaceholderLineEdit,
|
||||
PlaceholderPlainTextEdit,
|
||||
set_style_property,
|
||||
)
|
||||
from ayon_core.tools.utils import NiceCheckbox
|
||||
|
||||
from ._constants import REVERT_TO_DEFAULT_LABEL
|
||||
from .files_widget import FilesWidget
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from typing import Union
|
||||
|
||||
def create_widget_for_attr_def(attr_def, parent=None):
|
||||
widget = _create_widget_for_attr_def(attr_def, parent)
|
||||
if attr_def.hidden:
|
||||
|
||||
def create_widget_for_attr_def(
|
||||
attr_def: AbstractAttrDef,
|
||||
parent: Optional[QtWidgets.QWidget] = None,
|
||||
handle_revert_to_default: Optional[bool] = True,
|
||||
):
|
||||
widget = _create_widget_for_attr_def(
|
||||
attr_def, parent, handle_revert_to_default
|
||||
)
|
||||
if not attr_def.visible:
|
||||
widget.setVisible(False)
|
||||
|
||||
if attr_def.disabled:
|
||||
if not attr_def.enabled:
|
||||
widget.setEnabled(False)
|
||||
return widget
|
||||
|
||||
|
||||
def _create_widget_for_attr_def(attr_def, parent=None):
|
||||
def _create_widget_for_attr_def(
|
||||
attr_def: AbstractAttrDef,
|
||||
parent: "Union[QtWidgets.QWidget, None]",
|
||||
handle_revert_to_default: bool,
|
||||
):
|
||||
if not isinstance(attr_def, AbstractAttrDef):
|
||||
raise TypeError("Unexpected type \"{}\" expected \"{}\"".format(
|
||||
str(type(attr_def)), AbstractAttrDef
|
||||
))
|
||||
|
||||
cls = None
|
||||
if isinstance(attr_def, NumberDef):
|
||||
return NumberAttrWidget(attr_def, parent)
|
||||
cls = NumberAttrWidget
|
||||
|
||||
if isinstance(attr_def, TextDef):
|
||||
return TextAttrWidget(attr_def, parent)
|
||||
elif isinstance(attr_def, TextDef):
|
||||
cls = TextAttrWidget
|
||||
|
||||
if isinstance(attr_def, EnumDef):
|
||||
return EnumAttrWidget(attr_def, parent)
|
||||
elif isinstance(attr_def, EnumDef):
|
||||
cls = EnumAttrWidget
|
||||
|
||||
if isinstance(attr_def, BoolDef):
|
||||
return BoolAttrWidget(attr_def, parent)
|
||||
elif isinstance(attr_def, BoolDef):
|
||||
cls = BoolAttrWidget
|
||||
|
||||
if isinstance(attr_def, UnknownDef):
|
||||
return UnknownAttrWidget(attr_def, parent)
|
||||
elif isinstance(attr_def, UnknownDef):
|
||||
cls = UnknownAttrWidget
|
||||
|
||||
if isinstance(attr_def, HiddenDef):
|
||||
return HiddenAttrWidget(attr_def, parent)
|
||||
elif isinstance(attr_def, HiddenDef):
|
||||
cls = HiddenAttrWidget
|
||||
|
||||
if isinstance(attr_def, FileDef):
|
||||
return FileAttrWidget(attr_def, parent)
|
||||
elif isinstance(attr_def, FileDef):
|
||||
cls = FileAttrWidget
|
||||
|
||||
if isinstance(attr_def, UISeparatorDef):
|
||||
return SeparatorAttrWidget(attr_def, parent)
|
||||
elif isinstance(attr_def, UISeparatorDef):
|
||||
cls = SeparatorAttrWidget
|
||||
|
||||
if isinstance(attr_def, UILabelDef):
|
||||
return LabelAttrWidget(attr_def, parent)
|
||||
elif isinstance(attr_def, UILabelDef):
|
||||
cls = LabelAttrWidget
|
||||
|
||||
raise ValueError("Unknown attribute definition \"{}\"".format(
|
||||
str(type(attr_def))
|
||||
))
|
||||
if cls is None:
|
||||
raise ValueError("Unknown attribute definition \"{}\"".format(
|
||||
str(type(attr_def))
|
||||
))
|
||||
|
||||
return cls(attr_def, parent, handle_revert_to_default)
|
||||
|
||||
|
||||
class AttributeDefinitionsLabel(QtWidgets.QLabel):
|
||||
"""Label related to value attribute definition.
|
||||
|
||||
Label is used to show attribute definition label and to show if value
|
||||
is overridden.
|
||||
|
||||
Label can be right-clicked to revert value to default.
|
||||
"""
|
||||
revert_to_default_requested = QtCore.Signal(str)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
attr_id: str,
|
||||
label: str,
|
||||
parent: QtWidgets.QWidget,
|
||||
):
|
||||
super().__init__(label, parent)
|
||||
|
||||
self._attr_id = attr_id
|
||||
self._overridden = False
|
||||
self.setContextMenuPolicy(QtCore.Qt.CustomContextMenu)
|
||||
|
||||
self.customContextMenuRequested.connect(self._on_context_menu)
|
||||
|
||||
def set_overridden(self, overridden: bool):
|
||||
if self._overridden == overridden:
|
||||
return
|
||||
self._overridden = overridden
|
||||
set_style_property(
|
||||
self,
|
||||
"overridden",
|
||||
"1" if overridden else ""
|
||||
)
|
||||
|
||||
def _on_context_menu(self, point: QtCore.QPoint):
|
||||
menu = QtWidgets.QMenu(self)
|
||||
action = QtWidgets.QAction(menu)
|
||||
action.setText(REVERT_TO_DEFAULT_LABEL)
|
||||
action.triggered.connect(self._request_revert_to_default)
|
||||
menu.addAction(action)
|
||||
menu.exec_(self.mapToGlobal(point))
|
||||
|
||||
def _request_revert_to_default(self):
|
||||
self.revert_to_default_requested.emit(self._attr_id)
|
||||
|
||||
|
||||
class AttributeDefinitionsWidget(QtWidgets.QWidget):
|
||||
|
|
@ -83,16 +152,18 @@ class AttributeDefinitionsWidget(QtWidgets.QWidget):
|
|||
"""
|
||||
|
||||
def __init__(self, attr_defs=None, parent=None):
|
||||
super(AttributeDefinitionsWidget, self).__init__(parent)
|
||||
super().__init__(parent)
|
||||
|
||||
self._widgets = []
|
||||
self._widgets_by_id = {}
|
||||
self._labels_by_id = {}
|
||||
self._current_keys = set()
|
||||
|
||||
self.set_attr_defs(attr_defs)
|
||||
|
||||
def clear_attr_defs(self):
|
||||
"""Remove all existing widgets and reset layout if needed."""
|
||||
self._widgets = []
|
||||
self._widgets_by_id = {}
|
||||
self._labels_by_id = {}
|
||||
self._current_keys = set()
|
||||
|
||||
layout = self.layout()
|
||||
|
|
@ -133,9 +204,9 @@ class AttributeDefinitionsWidget(QtWidgets.QWidget):
|
|||
|
||||
self._current_keys.add(attr_def.key)
|
||||
widget = create_widget_for_attr_def(attr_def, self)
|
||||
self._widgets.append(widget)
|
||||
self._widgets_by_id[attr_def.id] = widget
|
||||
|
||||
if attr_def.hidden:
|
||||
if not attr_def.visible:
|
||||
continue
|
||||
|
||||
expand_cols = 2
|
||||
|
|
@ -145,7 +216,13 @@ class AttributeDefinitionsWidget(QtWidgets.QWidget):
|
|||
col_num = 2 - expand_cols
|
||||
|
||||
if attr_def.is_value_def and attr_def.label:
|
||||
label_widget = QtWidgets.QLabel(attr_def.label, self)
|
||||
label_widget = AttributeDefinitionsLabel(
|
||||
attr_def.id, attr_def.label, self
|
||||
)
|
||||
label_widget.revert_to_default_requested.connect(
|
||||
self._on_revert_request
|
||||
)
|
||||
self._labels_by_id[attr_def.id] = label_widget
|
||||
tooltip = attr_def.tooltip
|
||||
if tooltip:
|
||||
label_widget.setToolTip(tooltip)
|
||||
|
|
@ -160,6 +237,9 @@ class AttributeDefinitionsWidget(QtWidgets.QWidget):
|
|||
if not attr_def.is_label_horizontal:
|
||||
row += 1
|
||||
|
||||
if attr_def.is_value_def:
|
||||
widget.value_changed.connect(self._on_value_change)
|
||||
|
||||
layout.addWidget(
|
||||
widget, row, col_num, 1, expand_cols
|
||||
)
|
||||
|
|
@ -168,7 +248,7 @@ class AttributeDefinitionsWidget(QtWidgets.QWidget):
|
|||
def set_value(self, value):
|
||||
new_value = copy.deepcopy(value)
|
||||
unused_keys = set(new_value.keys())
|
||||
for widget in self._widgets:
|
||||
for widget in self._widgets_by_id.values():
|
||||
attr_def = widget.attr_def
|
||||
if attr_def.key not in new_value:
|
||||
continue
|
||||
|
|
@ -181,22 +261,42 @@ class AttributeDefinitionsWidget(QtWidgets.QWidget):
|
|||
|
||||
def current_value(self):
|
||||
output = {}
|
||||
for widget in self._widgets:
|
||||
for widget in self._widgets_by_id.values():
|
||||
attr_def = widget.attr_def
|
||||
if not isinstance(attr_def, UIDef):
|
||||
output[attr_def.key] = widget.current_value()
|
||||
|
||||
return output
|
||||
|
||||
def _on_revert_request(self, attr_id):
|
||||
widget = self._widgets_by_id.get(attr_id)
|
||||
if widget is not None:
|
||||
widget.set_value(widget.attr_def.default)
|
||||
|
||||
def _on_value_change(self, value, attr_id):
|
||||
widget = self._widgets_by_id.get(attr_id)
|
||||
if widget is None:
|
||||
return
|
||||
label = self._labels_by_id.get(attr_id)
|
||||
if label is not None:
|
||||
label.set_overridden(value != widget.attr_def.default)
|
||||
|
||||
|
||||
class _BaseAttrDefWidget(QtWidgets.QWidget):
|
||||
# Type 'object' may not work with older PySide versions
|
||||
value_changed = QtCore.Signal(object, str)
|
||||
revert_to_default_requested = QtCore.Signal(str)
|
||||
|
||||
def __init__(self, attr_def, parent):
|
||||
super(_BaseAttrDefWidget, self).__init__(parent)
|
||||
def __init__(
|
||||
self,
|
||||
attr_def: AbstractAttrDef,
|
||||
parent: "Union[QtWidgets.QWidget, None]",
|
||||
handle_revert_to_default: Optional[bool] = True,
|
||||
):
|
||||
super().__init__(parent)
|
||||
|
||||
self.attr_def = attr_def
|
||||
self.attr_def: AbstractAttrDef = attr_def
|
||||
self._handle_revert_to_default: bool = handle_revert_to_default
|
||||
|
||||
main_layout = QtWidgets.QHBoxLayout(self)
|
||||
main_layout.setContentsMargins(0, 0, 0, 0)
|
||||
|
|
@ -205,6 +305,15 @@ class _BaseAttrDefWidget(QtWidgets.QWidget):
|
|||
|
||||
self._ui_init()
|
||||
|
||||
def revert_to_default_value(self):
|
||||
if not self.attr_def.is_value_def:
|
||||
return
|
||||
|
||||
if self._handle_revert_to_default:
|
||||
self.set_value(self.attr_def.default)
|
||||
else:
|
||||
self.revert_to_default_requested.emit(self.attr_def.id)
|
||||
|
||||
def _ui_init(self):
|
||||
raise NotImplementedError(
|
||||
"Method '_ui_init' is not implemented. {}".format(
|
||||
|
|
@ -255,7 +364,7 @@ class ClickableLineEdit(QtWidgets.QLineEdit):
|
|||
clicked = QtCore.Signal()
|
||||
|
||||
def __init__(self, text, parent):
|
||||
super(ClickableLineEdit, self).__init__(parent)
|
||||
super().__init__(parent)
|
||||
self.setText(text)
|
||||
self.setReadOnly(True)
|
||||
|
||||
|
|
@ -264,7 +373,7 @@ class ClickableLineEdit(QtWidgets.QLineEdit):
|
|||
def mousePressEvent(self, event):
|
||||
if event.button() == QtCore.Qt.LeftButton:
|
||||
self._mouse_pressed = True
|
||||
super(ClickableLineEdit, self).mousePressEvent(event)
|
||||
super().mousePressEvent(event)
|
||||
|
||||
def mouseReleaseEvent(self, event):
|
||||
if self._mouse_pressed:
|
||||
|
|
@ -272,7 +381,7 @@ class ClickableLineEdit(QtWidgets.QLineEdit):
|
|||
if self.rect().contains(event.pos()):
|
||||
self.clicked.emit()
|
||||
|
||||
super(ClickableLineEdit, self).mouseReleaseEvent(event)
|
||||
super().mouseReleaseEvent(event)
|
||||
|
||||
|
||||
class NumberAttrWidget(_BaseAttrDefWidget):
|
||||
|
|
@ -284,6 +393,9 @@ class NumberAttrWidget(_BaseAttrDefWidget):
|
|||
else:
|
||||
input_widget = FocusSpinBox(self)
|
||||
|
||||
# Override context menu event to add revert to default action
|
||||
input_widget.contextMenuEvent = self._input_widget_context_event
|
||||
|
||||
if self.attr_def.tooltip:
|
||||
input_widget.setToolTip(self.attr_def.tooltip)
|
||||
|
||||
|
|
@ -321,6 +433,16 @@ class NumberAttrWidget(_BaseAttrDefWidget):
|
|||
self._set_multiselection_visible(True)
|
||||
return False
|
||||
|
||||
def _input_widget_context_event(self, event):
|
||||
line_edit = self._input_widget.lineEdit()
|
||||
menu = line_edit.createStandardContextMenu()
|
||||
menu.setAttribute(QtCore.Qt.WA_DeleteOnClose)
|
||||
action = QtWidgets.QAction(menu)
|
||||
action.setText(REVERT_TO_DEFAULT_LABEL)
|
||||
action.triggered.connect(self.revert_to_default_value)
|
||||
menu.addAction(action)
|
||||
menu.popup(event.globalPos())
|
||||
|
||||
def current_value(self):
|
||||
return self._input_widget.value()
|
||||
|
||||
|
|
@ -382,9 +504,12 @@ class TextAttrWidget(_BaseAttrDefWidget):
|
|||
|
||||
self.multiline = self.attr_def.multiline
|
||||
if self.multiline:
|
||||
input_widget = QtWidgets.QPlainTextEdit(self)
|
||||
input_widget = PlaceholderPlainTextEdit(self)
|
||||
else:
|
||||
input_widget = QtWidgets.QLineEdit(self)
|
||||
input_widget = PlaceholderLineEdit(self)
|
||||
|
||||
# Override context menu event to add revert to default action
|
||||
input_widget.contextMenuEvent = self._input_widget_context_event
|
||||
|
||||
if (
|
||||
self.attr_def.placeholder
|
||||
|
|
@ -407,6 +532,15 @@ class TextAttrWidget(_BaseAttrDefWidget):
|
|||
|
||||
self.main_layout.addWidget(input_widget, 0)
|
||||
|
||||
def _input_widget_context_event(self, event):
|
||||
menu = self._input_widget.createStandardContextMenu()
|
||||
menu.setAttribute(QtCore.Qt.WA_DeleteOnClose)
|
||||
action = QtWidgets.QAction(menu)
|
||||
action.setText(REVERT_TO_DEFAULT_LABEL)
|
||||
action.triggered.connect(self.revert_to_default_value)
|
||||
menu.addAction(action)
|
||||
menu.popup(event.globalPos())
|
||||
|
||||
def _on_value_change(self):
|
||||
if self.multiline:
|
||||
new_value = self._input_widget.toPlainText()
|
||||
|
|
@ -459,6 +593,20 @@ class BoolAttrWidget(_BaseAttrDefWidget):
|
|||
self.main_layout.addWidget(input_widget, 0)
|
||||
self.main_layout.addStretch(1)
|
||||
|
||||
self.setContextMenuPolicy(QtCore.Qt.CustomContextMenu)
|
||||
self.customContextMenuRequested.connect(self._on_context_menu)
|
||||
|
||||
def _on_context_menu(self, pos):
|
||||
self._menu = QtWidgets.QMenu(self)
|
||||
|
||||
action = QtWidgets.QAction(self._menu)
|
||||
action.setText(REVERT_TO_DEFAULT_LABEL)
|
||||
action.triggered.connect(self.revert_to_default_value)
|
||||
self._menu.addAction(action)
|
||||
|
||||
global_pos = self.mapToGlobal(pos)
|
||||
self._menu.exec_(global_pos)
|
||||
|
||||
def _on_value_change(self):
|
||||
new_value = self._input_widget.isChecked()
|
||||
self.value_changed.emit(new_value, self.attr_def.id)
|
||||
|
|
@ -487,7 +635,7 @@ class BoolAttrWidget(_BaseAttrDefWidget):
|
|||
class EnumAttrWidget(_BaseAttrDefWidget):
|
||||
def __init__(self, *args, **kwargs):
|
||||
self._multivalue = False
|
||||
super(EnumAttrWidget, self).__init__(*args, **kwargs)
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
@property
|
||||
def multiselection(self):
|
||||
|
|
@ -495,7 +643,9 @@ class EnumAttrWidget(_BaseAttrDefWidget):
|
|||
|
||||
def _ui_init(self):
|
||||
if self.multiselection:
|
||||
input_widget = MultiSelectionComboBox(self)
|
||||
input_widget = MultiSelectionComboBox(
|
||||
self, placeholder=self.attr_def.placeholder
|
||||
)
|
||||
|
||||
else:
|
||||
input_widget = CustomTextComboBox(self)
|
||||
|
|
@ -509,6 +659,9 @@ class EnumAttrWidget(_BaseAttrDefWidget):
|
|||
for item in self.attr_def.items:
|
||||
input_widget.addItem(item["label"], item["value"])
|
||||
|
||||
if not self.attr_def.items:
|
||||
self._add_empty_item(input_widget)
|
||||
|
||||
idx = input_widget.findData(self.attr_def.default)
|
||||
if idx >= 0:
|
||||
input_widget.setCurrentIndex(idx)
|
||||
|
|
@ -522,6 +675,34 @@ class EnumAttrWidget(_BaseAttrDefWidget):
|
|||
|
||||
self.main_layout.addWidget(input_widget, 0)
|
||||
|
||||
input_widget.setContextMenuPolicy(QtCore.Qt.CustomContextMenu)
|
||||
input_widget.customContextMenuRequested.connect(self._on_context_menu)
|
||||
|
||||
def _add_empty_item(self, input_widget):
|
||||
model = input_widget.model()
|
||||
if not isinstance(model, QtGui.QStandardItemModel):
|
||||
return
|
||||
|
||||
root_item = model.invisibleRootItem()
|
||||
|
||||
empty_item = QtGui.QStandardItem()
|
||||
empty_item.setData("< No items to select >", QtCore.Qt.DisplayRole)
|
||||
empty_item.setData("", QtCore.Qt.UserRole)
|
||||
empty_item.setFlags(QtCore.Qt.NoItemFlags)
|
||||
|
||||
root_item.appendRow(empty_item)
|
||||
|
||||
def _on_context_menu(self, pos):
|
||||
menu = QtWidgets.QMenu(self)
|
||||
|
||||
action = QtWidgets.QAction(menu)
|
||||
action.setText(REVERT_TO_DEFAULT_LABEL)
|
||||
action.triggered.connect(self.revert_to_default_value)
|
||||
menu.addAction(action)
|
||||
|
||||
global_pos = self.mapToGlobal(pos)
|
||||
menu.exec_(global_pos)
|
||||
|
||||
def _on_value_change(self):
|
||||
new_value = self.current_value()
|
||||
if self._multivalue:
|
||||
|
|
@ -614,7 +795,7 @@ class HiddenAttrWidget(_BaseAttrDefWidget):
|
|||
def setVisible(self, visible):
|
||||
if visible:
|
||||
visible = False
|
||||
super(HiddenAttrWidget, self).setVisible(visible)
|
||||
super().setVisible(visible)
|
||||
|
||||
def current_value(self):
|
||||
if self._multivalue:
|
||||
|
|
@ -650,10 +831,25 @@ class FileAttrWidget(_BaseAttrDefWidget):
|
|||
|
||||
self.main_layout.addWidget(input_widget, 0)
|
||||
|
||||
input_widget.setContextMenuPolicy(QtCore.Qt.CustomContextMenu)
|
||||
input_widget.customContextMenuRequested.connect(self._on_context_menu)
|
||||
input_widget.revert_requested.connect(self.revert_to_default_value)
|
||||
|
||||
def _on_value_change(self):
|
||||
new_value = self.current_value()
|
||||
self.value_changed.emit(new_value, self.attr_def.id)
|
||||
|
||||
def _on_context_menu(self, pos):
|
||||
menu = QtWidgets.QMenu(self)
|
||||
|
||||
action = QtWidgets.QAction(menu)
|
||||
action.setText(REVERT_TO_DEFAULT_LABEL)
|
||||
action.triggered.connect(self.revert_to_default_value)
|
||||
menu.addAction(action)
|
||||
|
||||
global_pos = self.mapToGlobal(pos)
|
||||
menu.exec_(global_pos)
|
||||
|
||||
def current_value(self):
|
||||
return self._input_widget.current_value()
|
||||
|
||||
|
|
|
|||
|
|
@ -1,12 +1,18 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
import collections
|
||||
import contextlib
|
||||
import typing
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
import ayon_api
|
||||
|
||||
from ayon_core.lib import NestedCacheItem
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from typing import Union
|
||||
|
||||
HIERARCHY_MODEL_SENDER = "hierarchy.model"
|
||||
|
||||
|
||||
|
|
@ -82,19 +88,26 @@ class TaskItem:
|
|||
Args:
|
||||
task_id (str): Task id.
|
||||
name (str): Name of task.
|
||||
name (Union[str, None]): Task label.
|
||||
task_type (str): Type of task.
|
||||
parent_id (str): Parent folder id.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self, task_id, name, task_type, parent_id
|
||||
self,
|
||||
task_id: str,
|
||||
name: str,
|
||||
label: Union[str, None],
|
||||
task_type: str,
|
||||
parent_id: str,
|
||||
):
|
||||
self.task_id = task_id
|
||||
self.name = name
|
||||
self.label = label
|
||||
self.task_type = task_type
|
||||
self.parent_id = parent_id
|
||||
|
||||
self._label = None
|
||||
self._full_label = None
|
||||
|
||||
@property
|
||||
def id(self):
|
||||
|
|
@ -107,16 +120,17 @@ class TaskItem:
|
|||
return self.task_id
|
||||
|
||||
@property
|
||||
def label(self):
|
||||
def full_label(self):
|
||||
"""Label of task item for UI.
|
||||
|
||||
Returns:
|
||||
str: Label of task item.
|
||||
"""
|
||||
|
||||
if self._label is None:
|
||||
self._label = "{} ({})".format(self.name, self.task_type)
|
||||
return self._label
|
||||
if self._full_label is None:
|
||||
label = self.label or self.name
|
||||
self._full_label = f"{label} ({self.task_type})"
|
||||
return self._full_label
|
||||
|
||||
def to_data(self):
|
||||
"""Converts task item to data.
|
||||
|
|
@ -128,6 +142,7 @@ class TaskItem:
|
|||
return {
|
||||
"task_id": self.task_id,
|
||||
"name": self.name,
|
||||
"label": self.label,
|
||||
"parent_id": self.parent_id,
|
||||
"task_type": self.task_type,
|
||||
}
|
||||
|
|
@ -159,6 +174,7 @@ def _get_task_items_from_tasks(tasks):
|
|||
output.append(TaskItem(
|
||||
task["id"],
|
||||
task["name"],
|
||||
task["label"],
|
||||
task["type"],
|
||||
folder_id
|
||||
))
|
||||
|
|
@ -368,7 +384,7 @@ class HierarchyModel(object):
|
|||
sender (Union[str, None]): Who requested the task item.
|
||||
|
||||
Returns:
|
||||
Union[TaskItem, None]: Task item found by name and folder id.
|
||||
Optional[TaskItem]: Task item found by name and folder id.
|
||||
|
||||
"""
|
||||
for task_item in self.get_task_items(project_name, folder_id, sender):
|
||||
|
|
|
|||
8
client/ayon_core/tools/console_interpreter/__init__.py
Normal file
8
client/ayon_core/tools/console_interpreter/__init__.py
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
from .abstract import AbstractInterpreterController
|
||||
from .control import InterpreterController
|
||||
|
||||
|
||||
__all__ = (
|
||||
"AbstractInterpreterController",
|
||||
"InterpreterController",
|
||||
)
|
||||
33
client/ayon_core/tools/console_interpreter/abstract.py
Normal file
33
client/ayon_core/tools/console_interpreter/abstract.py
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
from typing import List, Dict, Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class TabItem:
|
||||
name: str
|
||||
code: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class InterpreterConfig:
|
||||
width: Optional[int]
|
||||
height: Optional[int]
|
||||
splitter_sizes: List[int] = field(default_factory=list)
|
||||
tabs: List[TabItem] = field(default_factory=list)
|
||||
|
||||
|
||||
class AbstractInterpreterController(ABC):
|
||||
@abstractmethod
|
||||
def get_config(self) -> InterpreterConfig:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def save_config(
|
||||
self,
|
||||
width: int,
|
||||
height: int,
|
||||
splitter_sizes: List[int],
|
||||
tabs: List[Dict[str, str]],
|
||||
):
|
||||
pass
|
||||
63
client/ayon_core/tools/console_interpreter/control.py
Normal file
63
client/ayon_core/tools/console_interpreter/control.py
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
from typing import List, Dict
|
||||
|
||||
from ayon_core.lib import JSONSettingRegistry
|
||||
from ayon_core.lib.local_settings import get_launcher_local_dir
|
||||
|
||||
from .abstract import (
|
||||
AbstractInterpreterController,
|
||||
TabItem,
|
||||
InterpreterConfig,
|
||||
)
|
||||
|
||||
|
||||
class InterpreterController(AbstractInterpreterController):
|
||||
def __init__(self):
|
||||
self._registry = JSONSettingRegistry(
|
||||
"python_interpreter_tool",
|
||||
get_launcher_local_dir(),
|
||||
)
|
||||
|
||||
def get_config(self):
|
||||
width = None
|
||||
height = None
|
||||
splitter_sizes = []
|
||||
tabs = []
|
||||
try:
|
||||
width = self._registry.get_item("width")
|
||||
height = self._registry.get_item("height")
|
||||
|
||||
except (ValueError, KeyError):
|
||||
pass
|
||||
|
||||
try:
|
||||
splitter_sizes = self._registry.get_item("splitter_sizes")
|
||||
except (ValueError, KeyError):
|
||||
pass
|
||||
|
||||
try:
|
||||
tab_defs = self._registry.get_item("tabs") or []
|
||||
for tab_def in tab_defs:
|
||||
tab_name = tab_def.get("name")
|
||||
if not tab_name:
|
||||
continue
|
||||
code = tab_def.get("code") or ""
|
||||
tabs.append(TabItem(tab_name, code))
|
||||
|
||||
except (ValueError, KeyError):
|
||||
pass
|
||||
|
||||
return InterpreterConfig(
|
||||
width, height, splitter_sizes, tabs
|
||||
)
|
||||
|
||||
def save_config(
|
||||
self,
|
||||
width: int,
|
||||
height: int,
|
||||
splitter_sizes: List[int],
|
||||
tabs: List[Dict[str, str]],
|
||||
):
|
||||
self._registry.set_item("width", width)
|
||||
self._registry.set_item("height", height)
|
||||
self._registry.set_item("splitter_sizes", splitter_sizes)
|
||||
self._registry.set_item("tabs", tabs)
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
from .window import (
|
||||
ConsoleInterpreterWindow
|
||||
)
|
||||
|
||||
|
||||
__all__ = (
|
||||
"ConsoleInterpreterWindow",
|
||||
)
|
||||
42
client/ayon_core/tools/console_interpreter/ui/utils.py
Normal file
42
client/ayon_core/tools/console_interpreter/ui/utils.py
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import os
|
||||
import sys
|
||||
import collections
|
||||
|
||||
|
||||
class StdOEWrap:
|
||||
def __init__(self):
|
||||
self._origin_stdout_write = None
|
||||
self._origin_stderr_write = None
|
||||
self._listening = False
|
||||
self.lines = collections.deque()
|
||||
|
||||
if not sys.stdout:
|
||||
sys.stdout = open(os.devnull, "w")
|
||||
|
||||
if not sys.stderr:
|
||||
sys.stderr = open(os.devnull, "w")
|
||||
|
||||
if self._origin_stdout_write is None:
|
||||
self._origin_stdout_write = sys.stdout.write
|
||||
|
||||
if self._origin_stderr_write is None:
|
||||
self._origin_stderr_write = sys.stderr.write
|
||||
|
||||
self._listening = True
|
||||
sys.stdout.write = self._stdout_listener
|
||||
sys.stderr.write = self._stderr_listener
|
||||
|
||||
def stop_listen(self):
|
||||
self._listening = False
|
||||
|
||||
def _stdout_listener(self, text):
|
||||
if self._listening:
|
||||
self.lines.append(text)
|
||||
if self._origin_stdout_write is not None:
|
||||
self._origin_stdout_write(text)
|
||||
|
||||
def _stderr_listener(self, text):
|
||||
if self._listening:
|
||||
self.lines.append(text)
|
||||
if self._origin_stderr_write is not None:
|
||||
self._origin_stderr_write(text)
|
||||
251
client/ayon_core/tools/console_interpreter/ui/widgets.py
Normal file
251
client/ayon_core/tools/console_interpreter/ui/widgets.py
Normal file
|
|
@ -0,0 +1,251 @@
|
|||
from code import InteractiveInterpreter
|
||||
|
||||
from qtpy import QtCore, QtWidgets, QtGui
|
||||
|
||||
|
||||
class PythonCodeEditor(QtWidgets.QPlainTextEdit):
|
||||
execute_requested = QtCore.Signal()
|
||||
|
||||
def __init__(self, parent):
|
||||
super().__init__(parent)
|
||||
|
||||
self.setObjectName("PythonCodeEditor")
|
||||
|
||||
self._indent = 4
|
||||
|
||||
def _tab_shift_right(self):
|
||||
cursor = self.textCursor()
|
||||
selected_text = cursor.selectedText()
|
||||
if not selected_text:
|
||||
cursor.insertText(" " * self._indent)
|
||||
return
|
||||
|
||||
sel_start = cursor.selectionStart()
|
||||
sel_end = cursor.selectionEnd()
|
||||
cursor.setPosition(sel_end)
|
||||
end_line = cursor.blockNumber()
|
||||
cursor.setPosition(sel_start)
|
||||
while True:
|
||||
cursor.movePosition(QtGui.QTextCursor.StartOfLine)
|
||||
text = cursor.block().text()
|
||||
spaces = len(text) - len(text.lstrip(" "))
|
||||
new_spaces = spaces % self._indent
|
||||
if not new_spaces:
|
||||
new_spaces = self._indent
|
||||
|
||||
cursor.insertText(" " * new_spaces)
|
||||
if cursor.blockNumber() == end_line:
|
||||
break
|
||||
|
||||
cursor.movePosition(QtGui.QTextCursor.NextBlock)
|
||||
|
||||
def _tab_shift_left(self):
|
||||
tmp_cursor = self.textCursor()
|
||||
sel_start = tmp_cursor.selectionStart()
|
||||
sel_end = tmp_cursor.selectionEnd()
|
||||
|
||||
cursor = QtGui.QTextCursor(self.document())
|
||||
cursor.setPosition(sel_end)
|
||||
end_line = cursor.blockNumber()
|
||||
cursor.setPosition(sel_start)
|
||||
while True:
|
||||
cursor.movePosition(QtGui.QTextCursor.StartOfLine)
|
||||
text = cursor.block().text()
|
||||
spaces = len(text) - len(text.lstrip(" "))
|
||||
if spaces:
|
||||
spaces_to_remove = (spaces % self._indent) or self._indent
|
||||
if spaces_to_remove > spaces:
|
||||
spaces_to_remove = spaces
|
||||
|
||||
cursor.setPosition(
|
||||
cursor.position() + spaces_to_remove,
|
||||
QtGui.QTextCursor.KeepAnchor
|
||||
)
|
||||
cursor.removeSelectedText()
|
||||
|
||||
if cursor.blockNumber() == end_line:
|
||||
break
|
||||
|
||||
cursor.movePosition(QtGui.QTextCursor.NextBlock)
|
||||
|
||||
def keyPressEvent(self, event):
|
||||
if event.key() == QtCore.Qt.Key_Backtab:
|
||||
self._tab_shift_left()
|
||||
event.accept()
|
||||
return
|
||||
|
||||
if event.key() == QtCore.Qt.Key_Tab:
|
||||
if event.modifiers() == QtCore.Qt.NoModifier:
|
||||
self._tab_shift_right()
|
||||
event.accept()
|
||||
return
|
||||
|
||||
if (
|
||||
event.key() == QtCore.Qt.Key_Return
|
||||
and event.modifiers() == QtCore.Qt.ControlModifier
|
||||
):
|
||||
self.execute_requested.emit()
|
||||
event.accept()
|
||||
return
|
||||
|
||||
super().keyPressEvent(event)
|
||||
|
||||
|
||||
class PythonTabWidget(QtWidgets.QWidget):
|
||||
add_tab_requested = QtCore.Signal()
|
||||
before_execute = QtCore.Signal(str)
|
||||
|
||||
def __init__(self, parent):
|
||||
super().__init__(parent)
|
||||
|
||||
code_input = PythonCodeEditor(self)
|
||||
|
||||
self.setFocusProxy(code_input)
|
||||
|
||||
add_tab_btn = QtWidgets.QPushButton("Add tab...", self)
|
||||
add_tab_btn.setDefault(False)
|
||||
add_tab_btn.setToolTip("Add new tab")
|
||||
|
||||
execute_btn = QtWidgets.QPushButton("Execute", self)
|
||||
execute_btn.setDefault(False)
|
||||
execute_btn.setToolTip("Execute command (Ctrl + Enter)")
|
||||
|
||||
btns_layout = QtWidgets.QHBoxLayout()
|
||||
btns_layout.setContentsMargins(0, 0, 0, 0)
|
||||
btns_layout.addWidget(add_tab_btn)
|
||||
btns_layout.addStretch(1)
|
||||
btns_layout.addWidget(execute_btn)
|
||||
|
||||
layout = QtWidgets.QVBoxLayout(self)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.addWidget(code_input, 1)
|
||||
layout.addLayout(btns_layout, 0)
|
||||
|
||||
add_tab_btn.clicked.connect(self._on_add_tab_clicked)
|
||||
execute_btn.clicked.connect(self._on_execute_clicked)
|
||||
code_input.execute_requested.connect(self.execute)
|
||||
|
||||
self._code_input = code_input
|
||||
self._interpreter = InteractiveInterpreter()
|
||||
|
||||
def _on_add_tab_clicked(self):
|
||||
self.add_tab_requested.emit()
|
||||
|
||||
def _on_execute_clicked(self):
|
||||
self.execute()
|
||||
|
||||
def get_code(self):
|
||||
return self._code_input.toPlainText()
|
||||
|
||||
def set_code(self, code_text):
|
||||
self._code_input.setPlainText(code_text)
|
||||
|
||||
def execute(self):
|
||||
code_text = self._code_input.toPlainText()
|
||||
self.before_execute.emit(code_text)
|
||||
self._interpreter.runcode(code_text)
|
||||
|
||||
|
||||
class TabNameDialog(QtWidgets.QDialog):
|
||||
default_width = 330
|
||||
default_height = 85
|
||||
|
||||
def __init__(self, parent):
|
||||
super().__init__(parent)
|
||||
|
||||
self.setWindowTitle("Enter tab name")
|
||||
|
||||
name_label = QtWidgets.QLabel("Tab name:", self)
|
||||
name_input = QtWidgets.QLineEdit(self)
|
||||
|
||||
inputs_layout = QtWidgets.QHBoxLayout()
|
||||
inputs_layout.addWidget(name_label)
|
||||
inputs_layout.addWidget(name_input)
|
||||
|
||||
ok_btn = QtWidgets.QPushButton("Ok", self)
|
||||
cancel_btn = QtWidgets.QPushButton("Cancel", self)
|
||||
btns_layout = QtWidgets.QHBoxLayout()
|
||||
btns_layout.addStretch(1)
|
||||
btns_layout.addWidget(ok_btn)
|
||||
btns_layout.addWidget(cancel_btn)
|
||||
|
||||
layout = QtWidgets.QVBoxLayout(self)
|
||||
layout.addLayout(inputs_layout)
|
||||
layout.addStretch(1)
|
||||
layout.addLayout(btns_layout)
|
||||
|
||||
ok_btn.clicked.connect(self._on_ok_clicked)
|
||||
cancel_btn.clicked.connect(self._on_cancel_clicked)
|
||||
|
||||
self._name_input = name_input
|
||||
self._ok_btn = ok_btn
|
||||
self._cancel_btn = cancel_btn
|
||||
|
||||
self._result = None
|
||||
|
||||
self.resize(self.default_width, self.default_height)
|
||||
|
||||
def set_tab_name(self, name):
|
||||
self._name_input.setText(name)
|
||||
|
||||
def result(self):
|
||||
return self._result
|
||||
|
||||
def showEvent(self, event):
|
||||
super().showEvent(event)
|
||||
btns_width = max(
|
||||
self._ok_btn.width(),
|
||||
self._cancel_btn.width()
|
||||
)
|
||||
|
||||
self._ok_btn.setMinimumWidth(btns_width)
|
||||
self._cancel_btn.setMinimumWidth(btns_width)
|
||||
|
||||
def _on_ok_clicked(self):
|
||||
self._result = self._name_input.text()
|
||||
self.accept()
|
||||
|
||||
def _on_cancel_clicked(self):
|
||||
self._result = None
|
||||
self.reject()
|
||||
|
||||
|
||||
class OutputTextWidget(QtWidgets.QTextEdit):
|
||||
v_max_offset = 4
|
||||
|
||||
def vertical_scroll_at_max(self):
|
||||
v_scroll = self.verticalScrollBar()
|
||||
return v_scroll.value() > v_scroll.maximum() - self.v_max_offset
|
||||
|
||||
def scroll_to_bottom(self):
|
||||
v_scroll = self.verticalScrollBar()
|
||||
return v_scroll.setValue(v_scroll.maximum())
|
||||
|
||||
|
||||
class EnhancedTabBar(QtWidgets.QTabBar):
|
||||
double_clicked = QtCore.Signal(QtCore.QPoint)
|
||||
right_clicked = QtCore.Signal(QtCore.QPoint)
|
||||
mid_clicked = QtCore.Signal(QtCore.QPoint)
|
||||
|
||||
def __init__(self, parent):
|
||||
super().__init__(parent)
|
||||
|
||||
self.setDrawBase(False)
|
||||
|
||||
def mouseDoubleClickEvent(self, event):
|
||||
self.double_clicked.emit(event.globalPos())
|
||||
event.accept()
|
||||
|
||||
def mouseReleaseEvent(self, event):
|
||||
if event.button() == QtCore.Qt.RightButton:
|
||||
self.right_clicked.emit(event.globalPos())
|
||||
event.accept()
|
||||
return
|
||||
|
||||
elif event.button() == QtCore.Qt.MidButton:
|
||||
self.mid_clicked.emit(event.globalPos())
|
||||
event.accept()
|
||||
|
||||
else:
|
||||
super().mouseReleaseEvent(event)
|
||||
|
||||
324
client/ayon_core/tools/console_interpreter/ui/window.py
Normal file
324
client/ayon_core/tools/console_interpreter/ui/window.py
Normal file
|
|
@ -0,0 +1,324 @@
|
|||
import re
|
||||
from typing import Optional
|
||||
|
||||
from qtpy import QtWidgets, QtGui, QtCore
|
||||
|
||||
from ayon_core import resources
|
||||
from ayon_core.style import load_stylesheet
|
||||
from ayon_core.tools.console_interpreter import (
|
||||
AbstractInterpreterController,
|
||||
InterpreterController,
|
||||
)
|
||||
|
||||
from .utils import StdOEWrap
|
||||
from .widgets import (
|
||||
PythonTabWidget,
|
||||
OutputTextWidget,
|
||||
EnhancedTabBar,
|
||||
TabNameDialog,
|
||||
)
|
||||
|
||||
ANSI_ESCAPE = re.compile(
|
||||
r"(?:\x1B[@-_]|[\x80-\x9F])[0-?]*[ -/]*[@-~]"
|
||||
)
|
||||
AYON_ART = r"""
|
||||
|
||||
▄██▄
|
||||
▄███▄ ▀██▄ ▀██▀ ▄██▀ ▄██▀▀▀██▄ ▀███▄ █▄
|
||||
▄▄ ▀██▄ ▀██▄ ▄██▀ ██▀ ▀██▄ ▄ ▀██▄ ███
|
||||
▄██▀ ██▄ ▀ ▄▄ ▀ ██ ▄██ ███ ▀██▄ ███
|
||||
▄██▀ ▀██▄ ██ ▀██▄ ▄██▀ ███ ▀██ ▀█▀
|
||||
▄██▀ ▀██▄ ▀█ ▀██▄▄▄▄██▀ █▀ ▀██▄
|
||||
|
||||
· · - =[ by YNPUT ]:[ http://ayon.ynput.io ]= - · ·
|
||||
|
||||
"""
|
||||
|
||||
|
||||
class ConsoleInterpreterWindow(QtWidgets.QWidget):
|
||||
default_width = 1000
|
||||
default_height = 600
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
controller: Optional[AbstractInterpreterController] = None,
|
||||
parent: Optional[QtWidgets.QWidget] = None,
|
||||
):
|
||||
super().__init__(parent)
|
||||
|
||||
self.setWindowTitle("AYON Console")
|
||||
self.setWindowIcon(QtGui.QIcon(resources.get_ayon_icon_filepath()))
|
||||
|
||||
if controller is None:
|
||||
controller = InterpreterController()
|
||||
|
||||
output_widget = OutputTextWidget(self)
|
||||
output_widget.setObjectName("PythonInterpreterOutput")
|
||||
output_widget.setLineWrapMode(QtWidgets.QTextEdit.NoWrap)
|
||||
output_widget.setTextInteractionFlags(QtCore.Qt.TextBrowserInteraction)
|
||||
|
||||
tab_widget = QtWidgets.QTabWidget(self)
|
||||
tab_bar = EnhancedTabBar(tab_widget)
|
||||
tab_widget.setTabBar(tab_bar)
|
||||
tab_widget.setTabsClosable(False)
|
||||
tab_widget.setContextMenuPolicy(QtCore.Qt.CustomContextMenu)
|
||||
|
||||
widgets_splitter = QtWidgets.QSplitter(self)
|
||||
widgets_splitter.setOrientation(QtCore.Qt.Vertical)
|
||||
widgets_splitter.addWidget(output_widget)
|
||||
widgets_splitter.addWidget(tab_widget)
|
||||
widgets_splitter.setStretchFactor(0, 1)
|
||||
widgets_splitter.setStretchFactor(1, 1)
|
||||
height = int(self.default_height / 2)
|
||||
widgets_splitter.setSizes([height, self.default_height - height])
|
||||
|
||||
layout = QtWidgets.QVBoxLayout(self)
|
||||
layout.addWidget(widgets_splitter)
|
||||
|
||||
line_check_timer = QtCore.QTimer()
|
||||
line_check_timer.setInterval(200)
|
||||
|
||||
line_check_timer.timeout.connect(self._on_timer_timeout)
|
||||
tab_bar.right_clicked.connect(self._on_tab_right_click)
|
||||
tab_bar.double_clicked.connect(self._on_tab_double_click)
|
||||
tab_bar.mid_clicked.connect(self._on_tab_mid_click)
|
||||
tab_widget.tabCloseRequested.connect(self._on_tab_close_req)
|
||||
|
||||
self._tabs = []
|
||||
|
||||
self._stdout_err_wrapper = StdOEWrap()
|
||||
|
||||
self._widgets_splitter = widgets_splitter
|
||||
self._output_widget = output_widget
|
||||
self._tab_widget = tab_widget
|
||||
self._line_check_timer = line_check_timer
|
||||
|
||||
self._append_lines([AYON_ART])
|
||||
|
||||
self._first_show = True
|
||||
self._controller = controller
|
||||
|
||||
def showEvent(self, event):
|
||||
self._line_check_timer.start()
|
||||
super().showEvent(event)
|
||||
# First show setup
|
||||
if self._first_show:
|
||||
self._first_show = False
|
||||
self._on_first_show()
|
||||
|
||||
if self._tab_widget.count() < 1:
|
||||
self.add_tab("Python")
|
||||
|
||||
self._output_widget.scroll_to_bottom()
|
||||
|
||||
def closeEvent(self, event):
|
||||
self._save_registry()
|
||||
super().closeEvent(event)
|
||||
self._line_check_timer.stop()
|
||||
|
||||
def add_tab(self, tab_name, index=None):
|
||||
widget = PythonTabWidget(self)
|
||||
widget.before_execute.connect(self._on_before_execute)
|
||||
widget.add_tab_requested.connect(self._on_add_requested)
|
||||
if index is None:
|
||||
if self._tab_widget.count() > 0:
|
||||
index = self._tab_widget.currentIndex() + 1
|
||||
else:
|
||||
index = 0
|
||||
|
||||
self._tabs.append(widget)
|
||||
self._tab_widget.insertTab(index, widget, tab_name)
|
||||
self._tab_widget.setCurrentIndex(index)
|
||||
|
||||
if self._tab_widget.count() > 1:
|
||||
self._tab_widget.setTabsClosable(True)
|
||||
widget.setFocus()
|
||||
return widget
|
||||
|
||||
def _on_first_show(self):
|
||||
config = self._controller.get_config()
|
||||
width = config.width
|
||||
height = config.height
|
||||
if width is None or width < 200:
|
||||
width = self.default_width
|
||||
if height is None or height < 200:
|
||||
height = self.default_height
|
||||
|
||||
for tab_item in config.tabs:
|
||||
widget = self.add_tab(tab_item.name)
|
||||
widget.set_code(tab_item.code)
|
||||
|
||||
self.resize(width, height)
|
||||
# Change stylesheet
|
||||
self.setStyleSheet(load_stylesheet())
|
||||
# Check if splitter sizes are set
|
||||
splitters_count = len(self._widgets_splitter.sizes())
|
||||
if len(config.splitter_sizes) == splitters_count:
|
||||
self._widgets_splitter.setSizes(config.splitter_sizes)
|
||||
|
||||
def _save_registry(self):
|
||||
tabs = []
|
||||
for tab_idx in range(self._tab_widget.count()):
|
||||
widget = self._tab_widget.widget(tab_idx)
|
||||
tabs.append({
|
||||
"name": self._tab_widget.tabText(tab_idx),
|
||||
"code": widget.get_code()
|
||||
})
|
||||
|
||||
self._controller.save_config(
|
||||
self.width(),
|
||||
self.height(),
|
||||
self._widgets_splitter.sizes(),
|
||||
tabs
|
||||
)
|
||||
|
||||
def _on_tab_right_click(self, global_point):
|
||||
point = self._tab_widget.mapFromGlobal(global_point)
|
||||
tab_bar = self._tab_widget.tabBar()
|
||||
tab_idx = tab_bar.tabAt(point)
|
||||
last_index = tab_bar.count() - 1
|
||||
if tab_idx < 0 or tab_idx > last_index:
|
||||
return
|
||||
|
||||
menu = QtWidgets.QMenu(self._tab_widget)
|
||||
|
||||
add_tab_action = QtWidgets.QAction("Add tab...", menu)
|
||||
add_tab_action.setToolTip("Add new tab")
|
||||
|
||||
rename_tab_action = QtWidgets.QAction("Rename...", menu)
|
||||
rename_tab_action.setToolTip("Rename tab")
|
||||
|
||||
duplicate_tab_action = QtWidgets.QAction("Duplicate...", menu)
|
||||
duplicate_tab_action.setToolTip("Duplicate code to new tab")
|
||||
|
||||
close_tab_action = QtWidgets.QAction("Close", menu)
|
||||
close_tab_action.setToolTip("Close tab and lose content")
|
||||
close_tab_action.setEnabled(self._tab_widget.tabsClosable())
|
||||
|
||||
menu.addAction(add_tab_action)
|
||||
menu.addAction(rename_tab_action)
|
||||
menu.addAction(duplicate_tab_action)
|
||||
menu.addAction(close_tab_action)
|
||||
|
||||
result = menu.exec_(global_point)
|
||||
if result is None:
|
||||
return
|
||||
|
||||
if result is rename_tab_action:
|
||||
self._rename_tab_req(tab_idx)
|
||||
|
||||
elif result is add_tab_action:
|
||||
self._on_add_requested()
|
||||
|
||||
elif result is duplicate_tab_action:
|
||||
self._duplicate_requested(tab_idx)
|
||||
|
||||
elif result is close_tab_action:
|
||||
self._on_tab_close_req(tab_idx)
|
||||
|
||||
def _rename_tab_req(self, tab_idx):
|
||||
dialog = TabNameDialog(self)
|
||||
dialog.set_tab_name(self._tab_widget.tabText(tab_idx))
|
||||
dialog.exec_()
|
||||
tab_name = dialog.result()
|
||||
if tab_name:
|
||||
self._tab_widget.setTabText(tab_idx, tab_name)
|
||||
|
||||
def _duplicate_requested(self, tab_idx=None):
|
||||
if tab_idx is None:
|
||||
tab_idx = self._tab_widget.currentIndex()
|
||||
|
||||
src_widget = self._tab_widget.widget(tab_idx)
|
||||
dst_widget = self._add_tab()
|
||||
if dst_widget is None:
|
||||
return
|
||||
dst_widget.set_code(src_widget.get_code())
|
||||
|
||||
def _on_tab_mid_click(self, global_point):
|
||||
point = self._tab_widget.mapFromGlobal(global_point)
|
||||
tab_bar = self._tab_widget.tabBar()
|
||||
tab_idx = tab_bar.tabAt(point)
|
||||
last_index = tab_bar.count() - 1
|
||||
if tab_idx < 0 or tab_idx > last_index:
|
||||
return
|
||||
|
||||
self._on_tab_close_req(tab_idx)
|
||||
|
||||
def _on_tab_double_click(self, global_point):
|
||||
point = self._tab_widget.mapFromGlobal(global_point)
|
||||
tab_bar = self._tab_widget.tabBar()
|
||||
tab_idx = tab_bar.tabAt(point)
|
||||
last_index = tab_bar.count() - 1
|
||||
if tab_idx < 0 or tab_idx > last_index:
|
||||
return
|
||||
|
||||
self._rename_tab_req(tab_idx)
|
||||
|
||||
def _on_tab_close_req(self, tab_index):
|
||||
if self._tab_widget.count() == 1:
|
||||
return
|
||||
|
||||
widget = self._tab_widget.widget(tab_index)
|
||||
if widget in self._tabs:
|
||||
self._tabs.remove(widget)
|
||||
self._tab_widget.removeTab(tab_index)
|
||||
|
||||
if self._tab_widget.count() == 1:
|
||||
self._tab_widget.setTabsClosable(False)
|
||||
|
||||
def _append_lines(self, lines):
|
||||
at_max = self._output_widget.vertical_scroll_at_max()
|
||||
tmp_cursor = QtGui.QTextCursor(self._output_widget.document())
|
||||
tmp_cursor.movePosition(QtGui.QTextCursor.End)
|
||||
for line in lines:
|
||||
tmp_cursor.insertText(line)
|
||||
|
||||
if at_max:
|
||||
self._output_widget.scroll_to_bottom()
|
||||
|
||||
def _on_timer_timeout(self):
|
||||
if self._stdout_err_wrapper.lines:
|
||||
lines = []
|
||||
while self._stdout_err_wrapper.lines:
|
||||
line = self._stdout_err_wrapper.lines.popleft()
|
||||
lines.append(ANSI_ESCAPE.sub("", line))
|
||||
self._append_lines(lines)
|
||||
|
||||
def _on_add_requested(self):
|
||||
self._add_tab()
|
||||
|
||||
def _add_tab(self):
|
||||
dialog = TabNameDialog(self)
|
||||
dialog.exec_()
|
||||
tab_name = dialog.result()
|
||||
if tab_name:
|
||||
return self.add_tab(tab_name)
|
||||
|
||||
return None
|
||||
|
||||
def _on_before_execute(self, code_text):
|
||||
at_max = self._output_widget.vertical_scroll_at_max()
|
||||
document = self._output_widget.document()
|
||||
tmp_cursor = QtGui.QTextCursor(document)
|
||||
tmp_cursor.movePosition(QtGui.QTextCursor.End)
|
||||
tmp_cursor.insertText("{}\nExecuting command:\n".format(20 * "-"))
|
||||
|
||||
code_block_format = QtGui.QTextFrameFormat()
|
||||
code_block_format.setBackground(QtGui.QColor(27, 27, 27))
|
||||
code_block_format.setPadding(4)
|
||||
|
||||
tmp_cursor.insertFrame(code_block_format)
|
||||
char_format = tmp_cursor.charFormat()
|
||||
char_format.setForeground(
|
||||
QtGui.QBrush(QtGui.QColor(114, 224, 198))
|
||||
)
|
||||
tmp_cursor.setCharFormat(char_format)
|
||||
tmp_cursor.insertText(code_text)
|
||||
|
||||
# Create new cursor
|
||||
tmp_cursor = QtGui.QTextCursor(document)
|
||||
tmp_cursor.movePosition(QtGui.QTextCursor.End)
|
||||
tmp_cursor.insertText("{}\n".format(20 * "-"))
|
||||
|
||||
if at_max:
|
||||
self._output_widget.scroll_to_bottom()
|
||||
|
|
@ -104,7 +104,7 @@ class ProductNameValidator(RegularExpressionValidatorClass):
|
|||
|
||||
def validate(self, text, pos):
|
||||
results = super(ProductNameValidator, self).validate(text, pos)
|
||||
if results[0] == self.Invalid:
|
||||
if results[0] == RegularExpressionValidatorClass.Invalid:
|
||||
self.invalid.emit(self.invalid_chars(text))
|
||||
return results
|
||||
|
||||
|
|
@ -217,7 +217,9 @@ class ProductTypeDescriptionWidget(QtWidgets.QWidget):
|
|||
|
||||
product_type_label = QtWidgets.QLabel(self)
|
||||
product_type_label.setObjectName("CreatorProductTypeLabel")
|
||||
product_type_label.setAlignment(QtCore.Qt.AlignBottom | QtCore.Qt.AlignLeft)
|
||||
product_type_label.setAlignment(
|
||||
QtCore.Qt.AlignBottom | QtCore.Qt.AlignLeft
|
||||
)
|
||||
|
||||
help_label = QtWidgets.QLabel(self)
|
||||
help_label.setAlignment(QtCore.Qt.AlignTop | QtCore.Qt.AlignLeft)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,273 @@
|
|||
"""
|
||||
Brought from https://gist.github.com/BigRoy/1972822065e38f8fae7521078e44eca2
|
||||
Code Credits: [BigRoy](https://github.com/BigRoy)
|
||||
|
||||
Requirement:
|
||||
It requires pyblish version >= 1.8.12
|
||||
|
||||
How it works:
|
||||
This tool makes use of pyblish event `pluginProcessed` to:
|
||||
1. Pause the publishing.
|
||||
2. Collect some info about the plugin.
|
||||
3. Show that info to the tool's window.
|
||||
4. Continue publishing on clicking `step` button.
|
||||
|
||||
How to use it:
|
||||
1. Launch the tool from AYON experimental tools window.
|
||||
2. Launch the publisher tool and click validate.
|
||||
3. Click Step to run plugins one by one.
|
||||
|
||||
Note :
|
||||
Pyblish debugger also works when triggering the validation or
|
||||
publishing from code.
|
||||
Here's an example about validating from code:
|
||||
https://github.com/MustafaJafar/ayon-recipes/blob/main/validate_from_code.py
|
||||
|
||||
"""
|
||||
|
||||
import copy
|
||||
import json
|
||||
from qtpy import QtWidgets, QtCore, QtGui
|
||||
|
||||
import pyblish.api
|
||||
from ayon_core import style
|
||||
|
||||
TAB = 4* " "
|
||||
HEADER_SIZE = "15px"
|
||||
|
||||
KEY_COLOR = QtGui.QColor("#ffffff")
|
||||
NEW_KEY_COLOR = QtGui.QColor("#00ff00")
|
||||
VALUE_TYPE_COLOR = QtGui.QColor("#ffbbbb")
|
||||
NEW_VALUE_TYPE_COLOR = QtGui.QColor("#ff4444")
|
||||
VALUE_COLOR = QtGui.QColor("#777799")
|
||||
NEW_VALUE_COLOR = QtGui.QColor("#DDDDCC")
|
||||
CHANGED_VALUE_COLOR = QtGui.QColor("#CCFFCC")
|
||||
|
||||
MAX_VALUE_STR_LEN = 100
|
||||
|
||||
|
||||
def failsafe_deepcopy(data):
|
||||
"""Allow skipping the deepcopy for unsupported types"""
|
||||
try:
|
||||
return copy.deepcopy(data)
|
||||
except TypeError:
|
||||
if isinstance(data, dict):
|
||||
return {
|
||||
key: failsafe_deepcopy(value)
|
||||
for key, value in data.items()
|
||||
}
|
||||
elif isinstance(data, list):
|
||||
return data.copy()
|
||||
return data
|
||||
|
||||
|
||||
class DictChangesModel(QtGui.QStandardItemModel):
|
||||
# TODO: Replace this with a QAbstractItemModel
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(DictChangesModel, self).__init__(*args, **kwargs)
|
||||
self._data = {}
|
||||
|
||||
columns = ["Key", "Type", "Value"]
|
||||
self.setColumnCount(len(columns))
|
||||
for i, label in enumerate(columns):
|
||||
self.setHeaderData(i, QtCore.Qt.Horizontal, label)
|
||||
|
||||
def _update_recursive(self, data, parent, previous_data):
|
||||
for key, value in data.items():
|
||||
|
||||
# Find existing item or add new row
|
||||
parent_index = parent.index()
|
||||
for row in range(self.rowCount(parent_index)):
|
||||
# Update existing item if it exists
|
||||
index = self.index(row, 0, parent_index)
|
||||
if index.data() == key:
|
||||
item = self.itemFromIndex(index)
|
||||
type_item = self.itemFromIndex(self.index(row, 1, parent_index)) # noqa
|
||||
value_item = self.itemFromIndex(self.index(row, 2, parent_index)) # noqa
|
||||
break
|
||||
else:
|
||||
item = QtGui.QStandardItem(key)
|
||||
type_item = QtGui.QStandardItem()
|
||||
value_item = QtGui.QStandardItem()
|
||||
parent.appendRow([item, type_item, value_item])
|
||||
|
||||
# Key
|
||||
key_color = NEW_KEY_COLOR if key not in previous_data else KEY_COLOR # noqa
|
||||
item.setData(key_color, QtCore.Qt.ForegroundRole)
|
||||
|
||||
# Type
|
||||
type_str = type(value).__name__
|
||||
type_color = VALUE_TYPE_COLOR
|
||||
if (
|
||||
key in previous_data
|
||||
and type(previous_data[key]).__name__ != type_str
|
||||
):
|
||||
type_color = NEW_VALUE_TYPE_COLOR
|
||||
|
||||
type_item.setText(type_str)
|
||||
type_item.setData(type_color, QtCore.Qt.ForegroundRole)
|
||||
|
||||
# Value
|
||||
value_changed = False
|
||||
if key not in previous_data or previous_data[key] != value:
|
||||
value_changed = True
|
||||
value_color = NEW_VALUE_COLOR if value_changed else VALUE_COLOR
|
||||
|
||||
value_item.setData(value_color, QtCore.Qt.ForegroundRole)
|
||||
if value_changed:
|
||||
value_str = str(value)
|
||||
if len(value_str) > MAX_VALUE_STR_LEN:
|
||||
value_str = value_str[:MAX_VALUE_STR_LEN] + "..."
|
||||
value_item.setText(value_str)
|
||||
|
||||
# Preferably this is deferred to only when the data gets
|
||||
# requested since this formatting can be slow for very large
|
||||
# data sets like project settings and system settings
|
||||
# This will also be MUCH faster if we don't clear the
|
||||
# items on each update but only updated/add/remove changed
|
||||
# items so that this also runs much less often
|
||||
value_item.setData(
|
||||
json.dumps(value, default=str, indent=4),
|
||||
QtCore.Qt.ToolTipRole
|
||||
)
|
||||
|
||||
if isinstance(value, dict):
|
||||
previous_value = previous_data.get(key, {})
|
||||
if previous_data.get(key) != value:
|
||||
# Update children if the value is not the same as before
|
||||
self._update_recursive(value,
|
||||
parent=item,
|
||||
previous_data=previous_value)
|
||||
else:
|
||||
# TODO: Ensure all children are updated to be not marked
|
||||
# as 'changed' in the most optimal way possible
|
||||
self._update_recursive(value,
|
||||
parent=item,
|
||||
previous_data=previous_value)
|
||||
|
||||
self._data = data
|
||||
|
||||
def update(self, data):
|
||||
parent = self.invisibleRootItem()
|
||||
|
||||
data = failsafe_deepcopy(data)
|
||||
previous_data = self._data
|
||||
self._update_recursive(data, parent, previous_data)
|
||||
self._data = data # store previous data for next update
|
||||
|
||||
|
||||
class DebugUI(QtWidgets.QDialog):
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super(DebugUI, self).__init__(parent=parent)
|
||||
self.setStyleSheet(style.load_stylesheet())
|
||||
|
||||
self._set_window_title()
|
||||
self.setWindowFlags(
|
||||
QtCore.Qt.Window
|
||||
| QtCore.Qt.CustomizeWindowHint
|
||||
| QtCore.Qt.WindowTitleHint
|
||||
| QtCore.Qt.WindowMinimizeButtonHint
|
||||
| QtCore.Qt.WindowCloseButtonHint
|
||||
| QtCore.Qt.WindowStaysOnTopHint
|
||||
)
|
||||
|
||||
layout = QtWidgets.QVBoxLayout(self)
|
||||
text_edit = QtWidgets.QTextEdit()
|
||||
text_edit.setFixedHeight(65)
|
||||
font = QtGui.QFont("NONEXISTENTFONT")
|
||||
font.setStyleHint(QtGui.QFont.TypeWriter)
|
||||
text_edit.setFont(font)
|
||||
text_edit.setLineWrapMode(QtWidgets.QTextEdit.NoWrap)
|
||||
|
||||
step = QtWidgets.QPushButton("Step")
|
||||
step.setEnabled(False)
|
||||
|
||||
model = DictChangesModel()
|
||||
proxy = QtCore.QSortFilterProxyModel()
|
||||
proxy.setRecursiveFilteringEnabled(True)
|
||||
proxy.setSourceModel(model)
|
||||
view = QtWidgets.QTreeView()
|
||||
view.setModel(proxy)
|
||||
view.setSortingEnabled(True)
|
||||
|
||||
filter_field = QtWidgets.QLineEdit()
|
||||
filter_field.setPlaceholderText("Filter keys...")
|
||||
filter_field.textChanged.connect(proxy.setFilterFixedString)
|
||||
|
||||
layout.addWidget(text_edit)
|
||||
layout.addWidget(filter_field)
|
||||
layout.addWidget(view)
|
||||
layout.addWidget(step)
|
||||
|
||||
step.clicked.connect(self.on_step)
|
||||
|
||||
self._pause = False
|
||||
self.model = model
|
||||
self.filter = filter_field
|
||||
self.proxy = proxy
|
||||
self.view = view
|
||||
self.text = text_edit
|
||||
self.step = step
|
||||
self.resize(700, 500)
|
||||
|
||||
self._previous_data = {}
|
||||
|
||||
def _set_window_title(self, plugin=None):
|
||||
title = "Pyblish Debug Stepper"
|
||||
if plugin is not None:
|
||||
plugin_label = plugin.label or plugin.__name__
|
||||
title += f" | {plugin_label}"
|
||||
self.setWindowTitle(title)
|
||||
|
||||
def pause(self, state):
|
||||
self._pause = state
|
||||
self.step.setEnabled(state)
|
||||
|
||||
def on_step(self):
|
||||
self.pause(False)
|
||||
|
||||
def showEvent(self, event):
|
||||
print("Registering callback..")
|
||||
pyblish.api.register_callback("pluginProcessed",
|
||||
self.on_plugin_processed)
|
||||
|
||||
def hideEvent(self, event):
|
||||
self.pause(False)
|
||||
print("Deregistering callback..")
|
||||
pyblish.api.deregister_callback("pluginProcessed",
|
||||
self.on_plugin_processed)
|
||||
|
||||
def on_plugin_processed(self, result):
|
||||
self.pause(True)
|
||||
|
||||
self._set_window_title(plugin=result["plugin"])
|
||||
|
||||
print(10*"<", result["plugin"].__name__, 10*">")
|
||||
|
||||
plugin_order = result["plugin"].order
|
||||
plugin_name = result["plugin"].__name__
|
||||
duration = result['duration']
|
||||
plugin_instance = result["instance"]
|
||||
context = result["context"]
|
||||
|
||||
msg = ""
|
||||
msg += f"Order: {plugin_order}<br>"
|
||||
msg += f"Plugin: {plugin_name}"
|
||||
if plugin_instance is not None:
|
||||
msg += f" -> instance: {plugin_instance}"
|
||||
msg += "<br>"
|
||||
msg += f"Duration: {duration} ms<br>"
|
||||
self.text.setHtml(msg)
|
||||
|
||||
data = {
|
||||
"context": context.data
|
||||
}
|
||||
for instance in context:
|
||||
data[instance.name] = instance.data
|
||||
self.model.update(data)
|
||||
|
||||
app = QtWidgets.QApplication.instance()
|
||||
while self._pause:
|
||||
# Allow user interaction with the UI
|
||||
app.processEvents()
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import os
|
||||
from .pyblish_debug_stepper import DebugUI
|
||||
|
||||
# Constant key under which local settings are stored
|
||||
LOCAL_EXPERIMENTAL_KEY = "experimental_tools"
|
||||
|
|
@ -95,6 +96,12 @@ class ExperimentalTools:
|
|||
"hiero",
|
||||
"resolve",
|
||||
]
|
||||
),
|
||||
ExperimentalHostTool(
|
||||
"pyblish_debug_stepper",
|
||||
"Pyblish Debug Stepper",
|
||||
"Debug Pyblish plugins step by step.",
|
||||
self._show_pyblish_debugger,
|
||||
)
|
||||
]
|
||||
|
||||
|
|
@ -162,9 +169,16 @@ class ExperimentalTools:
|
|||
local_settings.get(LOCAL_EXPERIMENTAL_KEY)
|
||||
) or {}
|
||||
|
||||
for identifier, eperimental_tool in self.tools_by_identifier.items():
|
||||
# Enable the following tools by default.
|
||||
# Because they will always be disabled due
|
||||
# to the fact their settings don't exist.
|
||||
experimental_settings.update({
|
||||
"pyblish_debug_stepper": True,
|
||||
})
|
||||
|
||||
for identifier, experimental_tool in self.tools_by_identifier.items():
|
||||
enabled = experimental_settings.get(identifier, False)
|
||||
eperimental_tool.set_enabled(enabled)
|
||||
experimental_tool.set_enabled(enabled)
|
||||
|
||||
def _show_publisher(self):
|
||||
if self._publisher_tool is None:
|
||||
|
|
@ -175,3 +189,7 @@ class ExperimentalTools:
|
|||
)
|
||||
|
||||
self._publisher_tool.show()
|
||||
|
||||
def _show_pyblish_debugger(self):
|
||||
window = DebugUI(parent=self._parent_widget)
|
||||
window.show()
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ from ayon_core.pipeline.actions import (
|
|||
discover_launcher_actions,
|
||||
LauncherAction,
|
||||
LauncherActionSelection,
|
||||
register_launcher_action_path,
|
||||
)
|
||||
from ayon_core.pipeline.workfile import should_use_last_workfile_on_launch
|
||||
|
||||
|
|
@ -21,9 +22,9 @@ except ImportError:
|
|||
|
||||
Application action based on 'ApplicationManager' system.
|
||||
|
||||
Handling of applications in launcher is not ideal and should be completely
|
||||
redone from scratch. This is just a temporary solution to keep backwards
|
||||
compatibility with AYON launcher.
|
||||
Handling of applications in launcher is not ideal and should be
|
||||
completely redone from scratch. This is just a temporary solution
|
||||
to keep backwards compatibility with AYON launcher.
|
||||
|
||||
Todos:
|
||||
Move handling of errors to frontend.
|
||||
|
|
@ -459,6 +460,14 @@ class ActionsModel:
|
|||
|
||||
def _get_discovered_action_classes(self):
|
||||
if self._discovered_actions is None:
|
||||
# NOTE We don't need to register the paths, but that would
|
||||
# require to change discovery logic and deprecate all functions
|
||||
# related to registering and discovering launcher actions.
|
||||
addons_manager = self._get_addons_manager()
|
||||
actions_paths = addons_manager.collect_launcher_action_paths()
|
||||
for path in actions_paths:
|
||||
if path and os.path.exists(path):
|
||||
register_launcher_action_path(path)
|
||||
self._discovered_actions = (
|
||||
discover_launcher_actions()
|
||||
+ self._get_applications_action_classes()
|
||||
|
|
|
|||
|
|
@ -265,7 +265,7 @@ class ActionDelegate(QtWidgets.QStyledItemDelegate):
|
|||
|
||||
if index.data(FORCE_NOT_OPEN_WORKFILE_ROLE):
|
||||
rect = QtCore.QRectF(
|
||||
option.rect.x(), option.rect.height(), 5, 5)
|
||||
option.rect.x(), option.rect.y() + option.rect.height(), 5, 5)
|
||||
painter.setPen(QtCore.Qt.NoPen)
|
||||
painter.setBrush(QtGui.QColor(200, 0, 0))
|
||||
painter.drawEllipse(rect)
|
||||
|
|
|
|||
|
|
@ -202,8 +202,9 @@ class LauncherWindow(QtWidgets.QWidget):
|
|||
self._go_to_hierarchy_page(project_name)
|
||||
|
||||
def _on_projects_refresh(self):
|
||||
# There is nothing to do, we're on projects page
|
||||
# Refresh only actions on projects page
|
||||
if self._is_on_projects_page:
|
||||
self._actions_widget.refresh()
|
||||
return
|
||||
|
||||
# No projects were found -> go back to projects page
|
||||
|
|
|
|||
|
|
@ -372,17 +372,17 @@ class LoaderController(BackendLoaderController, FrontendLoaderController):
|
|||
|
||||
repre_ids = set()
|
||||
for container in containers:
|
||||
repre_id = container.get("representation")
|
||||
# Ignore invalid representation ids.
|
||||
# - invalid representation ids may be available if e.g. is
|
||||
# opened scene from OpenPype whe 'ObjectId' was used instead
|
||||
# of 'uuid'.
|
||||
# NOTE: Server call would crash if there is any invalid id.
|
||||
# That would cause crash we won't get any information.
|
||||
try:
|
||||
repre_id = container.get("representation")
|
||||
# Ignore invalid representation ids.
|
||||
# - invalid representation ids may be available if e.g. is
|
||||
# opened scene from OpenPype whe 'ObjectId' was used
|
||||
# instead of 'uuid'.
|
||||
# NOTE: Server call would crash if there is any invalid id.
|
||||
# That would cause crash we won't get any information.
|
||||
uuid.UUID(repre_id)
|
||||
repre_ids.add(repre_id)
|
||||
except ValueError:
|
||||
except (ValueError, TypeError, AttributeError):
|
||||
pass
|
||||
|
||||
product_ids = self._products_model.get_product_ids_by_repre_ids(
|
||||
|
|
|
|||
|
|
@ -517,7 +517,11 @@ class CustomPaintMultiselectComboBox(QtWidgets.QComboBox):
|
|||
def setItemCheckState(self, index, state):
|
||||
self.setItemData(index, state, QtCore.Qt.CheckStateRole)
|
||||
|
||||
def set_value(self, values: Optional[Iterable[Any]], role: Optional[int] = None):
|
||||
def set_value(
|
||||
self,
|
||||
values: Optional[Iterable[Any]],
|
||||
role: Optional[int] = None,
|
||||
):
|
||||
if role is None:
|
||||
role = self._value_role
|
||||
|
||||
|
|
|
|||
|
|
@ -222,6 +222,7 @@ class VersionDelegate(QtWidgets.QStyledItemDelegate):
|
|||
|
||||
editor = VersionComboBox(product_id, parent)
|
||||
editor.setProperty("itemId", item_id)
|
||||
editor.setFocusPolicy(QtCore.Qt.NoFocus)
|
||||
|
||||
editor.value_changed.connect(self._on_editor_change)
|
||||
editor.destroyed.connect(self._on_destroy)
|
||||
|
|
|
|||
|
|
@ -499,8 +499,10 @@ class ProductsModel(QtGui.QStandardItemModel):
|
|||
version_item.version_id
|
||||
for version_item in last_version_by_product_id.values()
|
||||
}
|
||||
repre_count_by_version_id = self._controller.get_versions_representation_count(
|
||||
project_name, version_ids
|
||||
repre_count_by_version_id = (
|
||||
self._controller.get_versions_representation_count(
|
||||
project_name, version_ids
|
||||
)
|
||||
)
|
||||
sync_availability_by_version_id = (
|
||||
self._controller.get_version_sync_availability(
|
||||
|
|
|
|||
|
|
@ -15,7 +15,6 @@ from ayon_core.lib import AbstractAttrDef
|
|||
from ayon_core.host import HostBase
|
||||
from ayon_core.pipeline.create import (
|
||||
CreateContext,
|
||||
CreatedInstance,
|
||||
ConvertorItem,
|
||||
)
|
||||
from ayon_core.tools.common_models import (
|
||||
|
|
@ -26,7 +25,7 @@ from ayon_core.tools.common_models import (
|
|||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .models import CreatorItem, PublishErrorInfo
|
||||
from .models import CreatorItem, PublishErrorInfo, InstanceItem
|
||||
|
||||
|
||||
class CardMessageTypes:
|
||||
|
|
@ -78,7 +77,7 @@ class AbstractPublisherCommon(ABC):
|
|||
in future e.g. different message timeout or type (color).
|
||||
|
||||
Args:
|
||||
message (str): Message that will be showed.
|
||||
message (str): Message that will be shown.
|
||||
message_type (Optional[str]): Message type.
|
||||
"""
|
||||
|
||||
|
|
@ -203,7 +202,7 @@ class AbstractPublisherFrontend(AbstractPublisherCommon):
|
|||
def is_host_valid(self) -> bool:
|
||||
"""Host is valid for creation part.
|
||||
|
||||
Host must have implemented certain functionality to be able create
|
||||
Host must have implemented certain functionality to be able to create
|
||||
in Publisher tool.
|
||||
|
||||
Returns:
|
||||
|
|
@ -266,6 +265,11 @@ class AbstractPublisherFrontend(AbstractPublisherCommon):
|
|||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_folder_id_from_path(self, folder_path: str) -> Optional[str]:
|
||||
"""Get folder id from folder path."""
|
||||
pass
|
||||
|
||||
# --- Create ---
|
||||
@abstractmethod
|
||||
def get_creator_items(self) -> Dict[str, "CreatorItem"]:
|
||||
|
|
@ -277,6 +281,21 @@ class AbstractPublisherFrontend(AbstractPublisherCommon):
|
|||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_creator_item_by_id(
|
||||
self, identifier: str
|
||||
) -> Optional["CreatorItem"]:
|
||||
"""Get creator item by identifier.
|
||||
|
||||
Args:
|
||||
identifier (str): Create plugin identifier.
|
||||
|
||||
Returns:
|
||||
Optional[CreatorItem]: Creator item or None.
|
||||
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_creator_icon(
|
||||
self, identifier: str
|
||||
|
|
@ -307,19 +326,19 @@ class AbstractPublisherFrontend(AbstractPublisherCommon):
|
|||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_instances(self) -> List[CreatedInstance]:
|
||||
def get_instance_items(self) -> List["InstanceItem"]:
|
||||
"""Collected/created instances.
|
||||
|
||||
Returns:
|
||||
List[CreatedInstance]: List of created instances.
|
||||
List[InstanceItem]: List of created instances.
|
||||
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_instances_by_id(
|
||||
def get_instance_items_by_id(
|
||||
self, instance_ids: Optional[Iterable[str]] = None
|
||||
) -> Dict[str, Union[CreatedInstance, None]]:
|
||||
) -> Dict[str, Union["InstanceItem", None]]:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
|
|
@ -328,28 +347,73 @@ class AbstractPublisherFrontend(AbstractPublisherCommon):
|
|||
):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def set_instances_context_info(
|
||||
self, changes_by_instance_id: Dict[str, Dict[str, Any]]
|
||||
):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def set_instances_active_state(
|
||||
self, active_state_by_id: Dict[str, bool]
|
||||
):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_existing_product_names(self, folder_path: str) -> List[str]:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_creator_attribute_definitions(
|
||||
self, instances: List[CreatedInstance]
|
||||
) -> List[Tuple[AbstractAttrDef, List[CreatedInstance], List[Any]]]:
|
||||
self, instance_ids: Iterable[str]
|
||||
) -> List[Tuple[AbstractAttrDef, Dict[str, Dict[str, Any]]]]:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def set_instances_create_attr_values(
|
||||
self, instance_ids: Iterable[str], key: str, value: Any
|
||||
):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def revert_instances_create_attr_values(
|
||||
self,
|
||||
instance_ids: List["Union[str, None]"],
|
||||
key: str,
|
||||
):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_publish_attribute_definitions(
|
||||
self,
|
||||
instances: List[CreatedInstance],
|
||||
instance_ids: Iterable[str],
|
||||
include_context: bool
|
||||
) -> List[Tuple[
|
||||
str,
|
||||
List[AbstractAttrDef],
|
||||
Dict[str, List[Tuple[CreatedInstance, Any]]]
|
||||
Dict[str, List[Tuple[str, Any, Any]]]
|
||||
]]:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def set_instances_publish_attr_values(
|
||||
self,
|
||||
instance_ids: Iterable[str],
|
||||
plugin_name: str,
|
||||
key: str,
|
||||
value: Any
|
||||
):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def revert_instances_publish_attr_values(
|
||||
self,
|
||||
instance_ids: List["Union[str, None]"],
|
||||
plugin_name: str,
|
||||
key: str,
|
||||
):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_product_name(
|
||||
self,
|
||||
|
|
@ -383,7 +447,7 @@ class AbstractPublisherFrontend(AbstractPublisherCommon):
|
|||
):
|
||||
"""Trigger creation by creator identifier.
|
||||
|
||||
Should also trigger refresh of instanes.
|
||||
Should also trigger refresh of instances.
|
||||
|
||||
Args:
|
||||
creator_identifier (str): Identifier of Creator plugin.
|
||||
|
|
@ -446,8 +510,8 @@ class AbstractPublisherFrontend(AbstractPublisherCommon):
|
|||
"""Trigger pyblish action on a plugin.
|
||||
|
||||
Args:
|
||||
plugin_id (str): Id of publish plugin.
|
||||
action_id (str): Id of publish action.
|
||||
plugin_id (str): Publish plugin id.
|
||||
action_id (str): Publish action id.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
|
@ -586,7 +650,7 @@ class AbstractPublisherFrontend(AbstractPublisherCommon):
|
|||
|
||||
@abstractmethod
|
||||
def get_thumbnail_temp_dir_path(self) -> str:
|
||||
"""Return path to directory where thumbnails can be temporary stored.
|
||||
"""Path to directory where thumbnails can be temporarily stored.
|
||||
|
||||
Returns:
|
||||
str: Path to a directory.
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue