Merge branch 'develop' into feature/new_publisher_core

This commit is contained in:
iLLiCiTiT 2021-09-14 09:20:06 +02:00
commit acae4f94d8
79 changed files with 2682 additions and 558 deletions

2
.gitmodules vendored
View file

@ -9,4 +9,4 @@
url = https://github.com/arrow-py/arrow.git
[submodule "openpype/modules/default_modules/ftrack/python2_vendor/ftrack-python-api"]
path = openpype/modules/default_modules/ftrack/python2_vendor/ftrack-python-api
url = https://bitbucket.org/ftrack/ftrack-python-api.git
url = https://bitbucket.org/ftrack/ftrack-python-api.git

View file

@ -12,6 +12,9 @@ from .version import __version__ as version
def open_dialog():
"""Show Igniter dialog."""
if os.getenv("OPENPYPE_HEADLESS_MODE"):
print("!!! Can't open dialog in headless mode. Exiting.")
sys.exit(1)
from Qt import QtWidgets, QtCore
from .install_dialog import InstallDialog
@ -28,8 +31,31 @@ def open_dialog():
return d.result()
def open_update_window(openpype_version):
"""Open update window."""
if os.getenv("OPENPYPE_HEADLESS_MODE"):
print("!!! Can't open dialog in headless mode. Exiting.")
sys.exit(1)
from Qt import QtWidgets, QtCore
from .update_window import UpdateWindow
scale_attr = getattr(QtCore.Qt, "AA_EnableHighDpiScaling", None)
if scale_attr is not None:
QtWidgets.QApplication.setAttribute(scale_attr)
app = QtWidgets.QApplication(sys.argv)
d = UpdateWindow(version=openpype_version)
d.open()
app.exec_()
version_path = d.get_version_path()
return version_path
__all__ = [
"BootstrapRepos",
"open_dialog",
"open_update_window",
"version"
]

View file

@ -9,6 +9,7 @@ import sys
import tempfile
from pathlib import Path
from typing import Union, Callable, List, Tuple
import hashlib
from zipfile import ZipFile, BadZipFile
@ -28,6 +29,25 @@ LOG_WARNING = 1
LOG_ERROR = 3
def sha256sum(filename):
"""Calculate sha256 for content of the file.
Args:
filename (str): Path to file.
Returns:
str: hex encoded sha256
"""
h = hashlib.sha256()
b = bytearray(128 * 1024)
mv = memoryview(b)
with open(filename, 'rb', buffering=0) as f:
for n in iter(lambda: f.readinto(mv), 0):
h.update(mv[:n])
return h.hexdigest()
class OpenPypeVersion(semver.VersionInfo):
"""Class for storing information about OpenPype version.
@ -261,7 +281,8 @@ class BootstrapRepos:
self.live_repo_dir = Path(Path(__file__).parent / ".." / "repos")
@staticmethod
def get_version_path_from_list(version: str, version_list: list) -> Path:
def get_version_path_from_list(
version: str, version_list: list) -> Union[Path, None]:
"""Get path for specific version in list of OpenPype versions.
Args:
@ -275,6 +296,7 @@ class BootstrapRepos:
for v in version_list:
if str(v) == version:
return v.path
return None
@staticmethod
def get_local_live_version() -> str:
@ -487,6 +509,7 @@ class BootstrapRepos:
openpype_root = openpype_path.resolve()
# generate list of filtered paths
dir_filter = [openpype_root / f for f in self.openpype_filter]
checksums = []
file: Path
for file in openpype_list:
@ -508,12 +531,119 @@ class BootstrapRepos:
processed_path = file
self._print(f"- processing {processed_path}")
zip_file.write(file, file.resolve().relative_to(openpype_root))
checksums.append(
(
sha256sum(file.as_posix()),
file.resolve().relative_to(openpype_root)
)
)
zip_file.write(
file, file.resolve().relative_to(openpype_root))
checksums_str = ""
for c in checksums:
checksums_str += "{}:{}\n".format(c[0], c[1])
zip_file.writestr("checksums", checksums_str)
# test if zip is ok
zip_file.testzip()
self._progress_callback(100)
def validate_openpype_version(self, path: Path) -> tuple:
"""Validate version directory or zip file.
This will load `checksums` file if present, calculate checksums
of existing files in given path and compare. It will also compare
lists of files together for missing files.
Args:
path (Path): Path to OpenPype version to validate.
Returns:
tuple(bool, str): with version validity as first item
and string with reason as second.
"""
if not path.exists():
return False, "Path doesn't exist"
if path.is_file():
return self._validate_zip(path)
return self._validate_dir(path)
@staticmethod
def _validate_zip(path: Path) -> tuple:
"""Validate content of zip file."""
with ZipFile(path, "r") as zip_file:
# read checksums
try:
checksums_data = str(zip_file.read("checksums"))
except IOError:
# FIXME: This should be set to False sometimes in the future
return True, "Cannot read checksums for archive."
# split it to the list of tuples
checksums = [
tuple(line.split(":"))
for line in checksums_data.split("\n") if line
]
# calculate and compare checksums in the zip file
for file in checksums:
h = hashlib.sha256()
try:
h.update(zip_file.read(file[1]))
except FileNotFoundError:
return False, f"Missing file [ {file[1]} ]"
if h.hexdigest() != file[0]:
return False, f"Invalid checksum on {file[1]}"
# get list of files in zip minus `checksums` file itself
# and turn in to set to compare against list of files
# from checksum file. If difference exists, something is
# wrong
files_in_zip = zip_file.namelist()
files_in_zip.remove("checksums")
files_in_zip = set(files_in_zip)
files_in_checksum = set([file[1] for file in checksums])
diff = files_in_zip.difference(files_in_checksum)
if diff:
return False, f"Missing files {diff}"
return True, "All ok"
@staticmethod
def _validate_dir(path: Path) -> tuple:
checksums_file = Path(path / "checksums")
if not checksums_file.exists():
# FIXME: This should be set to False sometimes in the future
return True, "Cannot read checksums for archive."
checksums_data = checksums_file.read_text()
checksums = [
tuple(line.split(":"))
for line in checksums_data.split("\n") if line
]
files_in_dir = [
file.relative_to(path).as_posix()
for file in path.iterdir() if file.is_file()
]
files_in_dir.remove("checksums")
files_in_dir = set(files_in_dir)
files_in_checksum = set([file[1] for file in checksums])
for file in checksums:
try:
current = sha256sum((path / file[1]).as_posix())
except FileNotFoundError:
return False, f"Missing file [ {file[1]} ]"
if file[0] != current:
return False, f"Invalid checksum on {file[1]}"
diff = files_in_dir.difference(files_in_checksum)
if diff:
return False, f"Missing files {diff}"
return True, "All ok"
@staticmethod
def add_paths_from_archive(archive: Path) -> None:
"""Add first-level directory and 'repos' as paths to :mod:`sys.path`.
@ -837,6 +967,7 @@ class BootstrapRepos:
# test if destination directory already exist, if so lets delete it.
if destination.exists() and force:
self._print("removing existing directory")
try:
shutil.rmtree(destination)
except OSError as e:
@ -846,6 +977,7 @@ class BootstrapRepos:
raise OpenPypeVersionIOError(
f"cannot remove existing {destination}") from e
elif destination.exists() and not force:
self._print("destination directory already exists")
raise OpenPypeVersionExists(f"{destination} already exist.")
else:
# create destination parent directories even if they don't exist.
@ -855,6 +987,7 @@ class BootstrapRepos:
if openpype_version.path.is_dir():
# create zip inside temporary directory.
self._print("Creating zip from directory ...")
self._progress_callback(0)
with tempfile.TemporaryDirectory() as temp_dir:
temp_zip = \
Path(temp_dir) / f"openpype-v{openpype_version}.zip"
@ -880,13 +1013,16 @@ class BootstrapRepos:
raise OpenPypeVersionInvalid("Invalid file format")
if not self.is_inside_user_data(openpype_version.path):
self._progress_callback(35)
openpype_version.path = self._copy_zip(
openpype_version.path, destination)
# extract zip there
self._print("extracting zip to destination ...")
with ZipFile(openpype_version.path, "r") as zip_ref:
self._progress_callback(75)
zip_ref.extractall(destination)
self._progress_callback(100)
return destination

View file

@ -14,21 +14,13 @@ from .tools import (
validate_mongo_connection,
get_openpype_path_from_db
)
from .nice_progress_bar import NiceProgressBar
from .user_settings import OpenPypeSecureRegistry
from .tools import load_stylesheet
from .version import __version__
def load_stylesheet():
stylesheet_path = os.path.join(
os.path.dirname(__file__),
"stylesheet.css"
)
with open(stylesheet_path, "r") as file_stream:
stylesheet = file_stream.read()
return stylesheet
class ButtonWithOptions(QtWidgets.QFrame):
option_clicked = QtCore.Signal(str)
@ -91,25 +83,6 @@ class ButtonWithOptions(QtWidgets.QFrame):
self.option_clicked.emit(self._default_value)
class NiceProgressBar(QtWidgets.QProgressBar):
def __init__(self, parent=None):
super(NiceProgressBar, self).__init__(parent)
self._real_value = 0
def setValue(self, value):
self._real_value = value
if value != 0 and value < 11:
value = 11
super(NiceProgressBar, self).setValue(value)
def value(self):
return self._real_value
def text(self):
return "{} %".format(self._real_value)
class ConsoleWidget(QtWidgets.QWidget):
def __init__(self, parent=None):
super(ConsoleWidget, self).__init__(parent)

View file

@ -0,0 +1,20 @@
from Qt import QtCore, QtGui, QtWidgets # noqa
class NiceProgressBar(QtWidgets.QProgressBar):
def __init__(self, parent=None):
super(NiceProgressBar, self).__init__(parent)
self._real_value = 0
def setValue(self, value):
self._real_value = value
if value != 0 and value < 11:
value = 11
super(NiceProgressBar, self).setValue(value)
def value(self):
return self._real_value
def text(self):
return "{} %".format(self._real_value)

View file

@ -248,3 +248,15 @@ def get_openpype_path_from_db(url: str) -> Union[str, None]:
if os.path.exists(path):
return path
return None
def load_stylesheet() -> str:
"""Load css style sheet.
Returns:
str: content of the stylesheet
"""
stylesheet_path = Path(__file__).parent.resolve() / "stylesheet.css"
return stylesheet_path.read_text()

61
igniter/update_thread.py Normal file
View file

@ -0,0 +1,61 @@
# -*- coding: utf-8 -*-
"""Working thread for update."""
from Qt.QtCore import QThread, Signal, QObject # noqa
from .bootstrap_repos import (
BootstrapRepos,
OpenPypeVersion
)
class UpdateThread(QThread):
"""Install Worker thread.
This class takes care of finding OpenPype version on user entered path
(or loading this path from database). If nothing is entered by user,
OpenPype will create its zip files from repositories that comes with it.
If path contains plain repositories, they are zipped and installed to
user data dir.
"""
progress = Signal(int)
message = Signal((str, bool))
def __init__(self, parent=None):
self._result = None
self._openpype_version = None
QThread.__init__(self, parent)
def set_version(self, openpype_version: OpenPypeVersion):
self._openpype_version = openpype_version
def result(self):
"""Result of finished installation."""
return self._result
def _set_result(self, value):
if self._result is not None:
raise AssertionError("BUG: Result was set more than once!")
self._result = value
def run(self):
"""Thread entry point.
Using :class:`BootstrapRepos` to either install OpenPype as zip files
or copy them from location specified by user or retrieved from
database.
"""
bs = BootstrapRepos(
progress_callback=self.set_progress, message=self.message)
version_path = bs.install_version(self._openpype_version)
self._set_result(version_path)
def set_progress(self, progress: int) -> None:
"""Helper to set progress bar.
Args:
progress (int): Progress in percents.
"""
self.progress.emit(progress)

136
igniter/update_window.py Normal file
View file

@ -0,0 +1,136 @@
# -*- coding: utf-8 -*-
"""Progress window to show when OpenPype is updating/installing locally."""
import os
from .update_thread import UpdateThread
from Qt import QtCore, QtGui, QtWidgets # noqa
from .bootstrap_repos import OpenPypeVersion
from .nice_progress_bar import NiceProgressBar
from .tools import load_stylesheet
class UpdateWindow(QtWidgets.QDialog):
"""OpenPype update window."""
_width = 500
_height = 100
def __init__(self, version: OpenPypeVersion, parent=None):
super(UpdateWindow, self).__init__(parent)
self._openpype_version = version
self._result_version_path = None
self.setWindowTitle(
f"OpenPype is updating ..."
)
self.setModal(True)
self.setWindowFlags(
QtCore.Qt.WindowMinimizeButtonHint
)
current_dir = os.path.dirname(os.path.abspath(__file__))
roboto_font_path = os.path.join(current_dir, "RobotoMono-Regular.ttf")
poppins_font_path = os.path.join(current_dir, "Poppins")
icon_path = os.path.join(current_dir, "openpype_icon.png")
# Install roboto font
QtGui.QFontDatabase.addApplicationFont(roboto_font_path)
for filename in os.listdir(poppins_font_path):
if os.path.splitext(filename)[1] == ".ttf":
QtGui.QFontDatabase.addApplicationFont(filename)
# Load logo
pixmap_openpype_logo = QtGui.QPixmap(icon_path)
# Set logo as icon of window
self.setWindowIcon(QtGui.QIcon(pixmap_openpype_logo))
self._pixmap_openpype_logo = pixmap_openpype_logo
self._update_thread = None
self.resize(QtCore.QSize(self._width, self._height))
self._init_ui()
# Set stylesheet
self.setStyleSheet(load_stylesheet())
self._run_update()
def _init_ui(self):
# Main info
# --------------------------------------------------------------------
main_label = QtWidgets.QLabel(
f"<b>OpenPype</b> is updating to {self._openpype_version}", self)
main_label.setWordWrap(True)
main_label.setObjectName("MainLabel")
# Progress bar
# --------------------------------------------------------------------
progress_bar = NiceProgressBar(self)
progress_bar.setAlignment(QtCore.Qt.AlignCenter)
progress_bar.setTextVisible(False)
# add all to main
main = QtWidgets.QVBoxLayout(self)
main.addSpacing(15)
main.addWidget(main_label, 0)
main.addSpacing(15)
main.addWidget(progress_bar, 0)
main.addSpacing(15)
self._progress_bar = progress_bar
def _run_update(self):
"""Start install process.
This will once again validate entered path and mongo if ok, start
working thread that will do actual job.
"""
# Check if install thread is not already running
if self._update_thread and self._update_thread.isRunning():
return
self._progress_bar.setRange(0, 0)
update_thread = UpdateThread(self)
update_thread.set_version(self._openpype_version)
update_thread.message.connect(self.update_console)
update_thread.progress.connect(self._update_progress)
update_thread.finished.connect(self._installation_finished)
self._update_thread = update_thread
update_thread.start()
def get_version_path(self):
return self._result_version_path
def _installation_finished(self):
status = self._update_thread.result()
self._result_version_path = status
self._progress_bar.setRange(0, 1)
self._update_progress(100)
QtWidgets.QApplication.processEvents()
self.done(0)
def _update_progress(self, progress: int):
# not updating progress as we are not able to determine it
# correctly now. Progress bar is set to un-deterministic mode
# until we are able to get progress in better way.
"""
self._progress_bar.setRange(0, 0)
self._progress_bar.setValue(progress)
text_visible = self._progress_bar.isTextVisible()
if progress == 0:
if text_visible:
self._progress_bar.setTextVisible(False)
elif not text_visible:
self._progress_bar.setTextVisible(True)
"""
return
def update_console(self, msg: str, error: bool = False) -> None:
"""Display message in console.
Args:
msg (str): message.
error (bool): if True, print it red.
"""
print(msg)

View file

@ -24,7 +24,9 @@ from .lib import (
get_latest_version,
get_global_environments,
get_local_site_id,
change_openpype_mongo_url
change_openpype_mongo_url,
create_project_folders,
get_project_basic_paths
)
from .lib.mongo import (
@ -72,6 +74,7 @@ __all__ = [
"get_current_project_settings",
"get_anatomy_settings",
"get_environments",
"get_project_basic_paths",
"SystemSettings",
@ -120,5 +123,9 @@ __all__ = [
"get_global_environments",
"get_local_site_id",
"change_openpype_mongo_url"
"change_openpype_mongo_url",
"get_project_basic_paths",
"create_project_folders"
]

View file

@ -18,6 +18,8 @@ from .pype_commands import PypeCommands
@click.option("--list-versions", is_flag=True, expose_value=False,
help=("list all detected versions. Use With `--use-staging "
"to list staging versions."))
@click.option("--validate-version", expose_value=False,
help="validate given version integrity")
def main(ctx):
"""Pype is main command serving as entry point to pipeline system.

View file

@ -5,7 +5,7 @@ import pyblish.api
class PreCollectClipEffects(pyblish.api.InstancePlugin):
"""Collect soft effects instances."""
order = pyblish.api.CollectorOrder - 0.579
order = pyblish.api.CollectorOrder - 0.479
label = "Precollect Clip Effects Instances"
families = ["clip"]

View file

@ -13,7 +13,7 @@ from pprint import pformat
class PrecollectInstances(pyblish.api.ContextPlugin):
"""Collect all Track items selection."""
order = pyblish.api.CollectorOrder - 0.59
order = pyblish.api.CollectorOrder - 0.49
label = "Precollect Instances"
hosts = ["hiero"]

View file

@ -12,7 +12,7 @@ class PrecollectWorkfile(pyblish.api.ContextPlugin):
"""Inject the current working file into context"""
label = "Precollect Workfile"
order = pyblish.api.CollectorOrder - 0.6
order = pyblish.api.CollectorOrder - 0.5
def process(self, context):

View file

@ -112,24 +112,26 @@ class ExtractThumbnail(openpype.api.Extractor):
# create write node
write_node = nuke.createNode("Write")
file = fhead + "jpeg"
file = fhead + "jpg"
name = "thumbnail"
path = os.path.join(staging_dir, file).replace("\\", "/")
instance.data["thumbnail"] = path
write_node["file"].setValue(path)
write_node["file_type"].setValue("jpeg")
write_node["file_type"].setValue("jpg")
write_node["raw"].setValue(1)
write_node.setInput(0, previous_node)
temporary_nodes.append(write_node)
tags = ["thumbnail", "publish_on_farm"]
# retime for
mid_frame = int((int(last_frame) - int(first_frame)) / 2) \
+ int(first_frame)
first_frame = int(last_frame) / 2
last_frame = int(last_frame) / 2
repre = {
'name': name,
'ext': "jpeg",
'ext': "jpg",
"outputName": "thumb",
'files': file,
"stagingDir": staging_dir,
@ -140,7 +142,7 @@ class ExtractThumbnail(openpype.api.Extractor):
instance.data["representations"].append(repre)
# Render frames
nuke.execute(write_node.name(), int(first_frame), int(last_frame))
nuke.execute(write_node.name(), int(mid_frame), int(mid_frame))
self.log.debug(
"representations: {}".format(instance.data["representations"]))

View file

@ -8,7 +8,7 @@ from avalon.nuke import lib as anlib
class PreCollectNukeInstances(pyblish.api.ContextPlugin):
"""Collect all nodes with Avalon knob."""
order = pyblish.api.CollectorOrder - 0.59
order = pyblish.api.CollectorOrder - 0.49
label = "Pre-collect Instances"
hosts = ["nuke", "nukeassist"]

View file

@ -9,7 +9,7 @@ reload(anlib)
class CollectWorkfile(pyblish.api.ContextPlugin):
"""Collect current script for publish."""
order = pyblish.api.CollectorOrder - 0.60
order = pyblish.api.CollectorOrder - 0.50
label = "Pre-collect Workfile"
hosts = ['nuke']

View file

@ -11,7 +11,7 @@ from avalon import io, api
class CollectNukeWrites(pyblish.api.InstancePlugin):
"""Collect all write nodes."""
order = pyblish.api.CollectorOrder - 0.58
order = pyblish.api.CollectorOrder - 0.48
label = "Pre-collect Writes"
hosts = ["nuke", "nukeassist"]
families = ["write"]

View file

@ -8,7 +8,7 @@ from pprint import pformat
class PrecollectInstances(pyblish.api.ContextPlugin):
"""Collect all Track items selection."""
order = pyblish.api.CollectorOrder - 0.59
order = pyblish.api.CollectorOrder - 0.49
label = "Precollect Instances"
hosts = ["resolve"]

View file

@ -13,7 +13,7 @@ class PrecollectWorkfile(pyblish.api.ContextPlugin):
"""Precollect the current working file into context"""
label = "Precollect Workfile"
order = pyblish.api.CollectorOrder - 0.6
order = pyblish.api.CollectorOrder - 0.5
def process(self, context):

View file

@ -11,6 +11,7 @@ import zipfile
import pyblish.api
from avalon import api, io
import openpype.api
from openpype.lib import get_workfile_template_key_from_context
class ExtractHarmonyZip(openpype.api.Extractor):
@ -65,10 +66,10 @@ class ExtractHarmonyZip(openpype.api.Extractor):
# Get Task types and Statuses for creation if needed
self.task_types = self._get_all_task_types(project_entity)
self.task_statuses = self.get_all_task_statuses(project_entity)
self.task_statuses = self._get_all_task_statuses(project_entity)
# Get Statuses of AssetVersions
self.assetversion_statuses = self.get_all_assetversion_statuses(
self.assetversion_statuses = self._get_all_assetversion_statuses(
project_entity
)
@ -233,18 +234,28 @@ class ExtractHarmonyZip(openpype.api.Extractor):
"version": 1,
"ext": "zip",
}
host_name = "harmony"
template_name = get_workfile_template_key_from_context(
instance.data["asset"],
instance.data.get("task"),
host_name,
project_name=project_entity["name"],
dbcon=io
)
# Get a valid work filename first with version 1
file_template = anatomy.templates["work"]["file"]
file_template = anatomy.templates[template_name]["file"]
anatomy_filled = anatomy.format(data)
work_path = anatomy_filled["work"]["path"]
work_path = anatomy_filled[template_name]["path"]
# Get the final work filename with the proper version
data["version"] = api.last_workfile_with_version(
os.path.dirname(work_path), file_template, data, [".zip"]
os.path.dirname(work_path),
file_template,
data,
api.HOST_WORKFILE_EXTENSIONS[host_name]
)[1]
work_path = anatomy_filled["work"]["path"]
base_name = os.path.splitext(os.path.basename(work_path))[0]
staging_work_path = os.path.join(os.path.dirname(staging_scene),

View file

@ -9,7 +9,7 @@ from openpype.lib import get_subset_name
class CollectInstances(pyblish.api.ContextPlugin):
label = "Collect Instances"
order = pyblish.api.CollectorOrder - 1
order = pyblish.api.CollectorOrder - 0.4
hosts = ["tvpaint"]
def process(self, context):

View file

@ -8,7 +8,7 @@ from openpype.lib import get_subset_name
class CollectWorkfile(pyblish.api.ContextPlugin):
label = "Collect Workfile"
order = pyblish.api.CollectorOrder - 1
order = pyblish.api.CollectorOrder - 0.4
hosts = ["tvpaint"]
def process(self, context):

View file

@ -39,7 +39,7 @@ class ResetTVPaintWorkfileMetadata(pyblish.api.Action):
class CollectWorkfileData(pyblish.api.ContextPlugin):
label = "Collect Workfile Data"
order = pyblish.api.CollectorOrder - 1.01
order = pyblish.api.CollectorOrder - 0.45
hosts = ["tvpaint"]
actions = [ResetTVPaintWorkfileMetadata]

View file

@ -143,7 +143,9 @@ from .plugin_tools import (
from .path_tools import (
version_up,
get_version_from_path,
get_last_version_from_path
get_last_version_from_path,
create_project_folders,
get_project_basic_paths
)
from .editorial import (
@ -276,5 +278,7 @@ __all__ = [
"range_from_frames",
"frames_to_secons",
"frames_to_timecode",
"make_sequence_collection"
"make_sequence_collection",
"create_project_folders",
"get_project_basic_paths"
]

View file

@ -29,7 +29,7 @@ from .local_settings import get_openpype_username
from .avalon_context import (
get_workdir_data,
get_workdir_with_workdir_data,
get_workfile_template_key_from_context
get_workfile_template_key
)
from .python_module_tools import (
@ -1226,8 +1226,12 @@ def prepare_context_environments(data):
# Load project specific environments
project_name = project_doc["name"]
project_settings = get_project_settings(project_name)
data["project_settings"] = project_settings
# Apply project specific environments on current env value
apply_project_environments_value(project_name, data["env"])
apply_project_environments_value(
project_name, data["env"], project_settings
)
app = data["app"]
workdir_data = get_workdir_data(
@ -1237,17 +1241,19 @@ def prepare_context_environments(data):
anatomy = data["anatomy"]
template_key = get_workfile_template_key_from_context(
asset_doc["name"],
task_name,
asset_tasks = asset_doc.get("data", {}).get("tasks") or {}
task_info = asset_tasks.get(task_name) or {}
task_type = task_info.get("type")
workfile_template_key = get_workfile_template_key(
task_type,
app.host_name,
project_name=project_name,
dbcon=data["dbcon"]
project_settings=project_settings
)
try:
workdir = get_workdir_with_workdir_data(
workdir_data, anatomy, template_key=template_key
workdir_data, anatomy, template_key=workfile_template_key
)
except Exception as exc:
@ -1281,10 +1287,10 @@ def prepare_context_environments(data):
)
data["env"].update(context_env)
_prepare_last_workfile(data, workdir)
_prepare_last_workfile(data, workdir, workfile_template_key)
def _prepare_last_workfile(data, workdir):
def _prepare_last_workfile(data, workdir, workfile_template_key):
"""last workfile workflow preparation.
Function check if should care about last workfile workflow and tries
@ -1345,7 +1351,7 @@ def _prepare_last_workfile(data, workdir):
if extensions:
anatomy = data["anatomy"]
# Find last workfile
file_template = anatomy.templates["work"]["file"]
file_template = anatomy.templates[workfile_template_key]["file"]
workdir_data.update({
"version": 1,
"user": get_openpype_username(),

View file

@ -1,6 +1,11 @@
import json
import logging
import os
import re
import logging
from .anatomy import Anatomy
from openpype.settings import get_project_settings
log = logging.getLogger(__name__)
@ -77,7 +82,7 @@ def get_version_from_path(file):
"""
pattern = re.compile(r"[\._]v([0-9]+)", re.IGNORECASE)
try:
return pattern.findall(file)[0]
return pattern.findall(file)[-1]
except IndexError:
log.error(
"templates:get_version_from_workfile:"
@ -119,3 +124,73 @@ def get_last_version_from_path(path_dir, filter):
return filtred_files[-1]
return None
def compute_paths(basic_paths_items, project_root):
pattern_array = re.compile(r"\[.*\]")
project_root_key = "__project_root__"
output = []
for path_items in basic_paths_items:
clean_items = []
for path_item in path_items:
matches = re.findall(pattern_array, path_item)
if len(matches) > 0:
path_item = path_item.replace(matches[0], "")
if path_item == project_root_key:
path_item = project_root
clean_items.append(path_item)
output.append(os.path.normpath(os.path.sep.join(clean_items)))
return output
def create_project_folders(basic_paths, project_name):
anatomy = Anatomy(project_name)
roots_paths = []
if isinstance(anatomy.roots, dict):
for root in anatomy.roots.values():
roots_paths.append(root.value)
else:
roots_paths.append(anatomy.roots.value)
for root_path in roots_paths:
project_root = os.path.join(root_path, project_name)
full_paths = compute_paths(basic_paths, project_root)
# Create folders
for path in full_paths:
full_path = path.format(project_root=project_root)
if os.path.exists(full_path):
log.debug(
"Folder already exists: {}".format(full_path)
)
else:
log.debug("Creating folder: {}".format(full_path))
os.makedirs(full_path)
def _list_path_items(folder_structure):
output = []
for key, value in folder_structure.items():
if not value:
output.append(key)
else:
paths = _list_path_items(value)
for path in paths:
if not isinstance(path, (list, tuple)):
path = [path]
output.append([key, *path])
return output
def get_project_basic_paths(project_name):
project_settings = get_project_settings(project_name)
folder_structure = (
project_settings["global"]["project_folder_structure"]
)
if not folder_structure:
return []
if isinstance(folder_structure, str):
folder_structure = json.loads(folder_structure)
return _list_path_items(folder_structure)

View file

@ -165,7 +165,8 @@ def filter_profiles(profiles_data, key_values, keys_order=None, logger=None):
if match == -1:
profile_value = profile.get(key) or []
logger.debug(
"\"{}\" not found in {}".format(key, profile_value)
"\"{}\" not found in \"{}\": {}".format(value, key,
profile_value)
)
profile_points = -1
break
@ -192,13 +193,13 @@ def filter_profiles(profiles_data, key_values, keys_order=None, logger=None):
])
if not matching_profiles:
logger.warning(
logger.info(
"None of profiles match your setup. {}".format(log_parts)
)
return None
if len(matching_profiles) > 1:
logger.warning(
logger.info(
"More than one profile match your setup. {}".format(log_parts)
)

View file

@ -1,125 +1,143 @@
# OpenPype modules/addons
OpenPype modules should contain separated logic of specific kind of implementation, like Ftrack connection and usage code or Deadline farm rendering or may contain only special plugins. Addons work the same way currently there is no difference in module and addon.
OpenPype modules should contain separated logic of specific kind of implementation, such as Ftrack connection and its usage code, Deadline farm rendering or may contain only special plugins. Addons work the same way currently, there is no difference between module and addon functionality.
## Modules concept
- modules and addons are dynamically imported to virtual python module `openpype_modules` from which it is possible to import them no matter where is the modulo located
- modules or addons should never be imported directly even if you know possible full import path
- it is because all of their content must be imported in specific order and should not be imported without defined functions as it may also break few implementation parts
- modules and addons are dynamically imported to virtual python module `openpype_modules` from which it is possible to import them no matter where is the module located
- modules or addons should never be imported directly, even if you know possible full import path
- it is because all of their content must be imported in specific order and should not be imported without defined functions as it may also break few implementation parts
### TODOs
- add module/addon manifest
- definition of module (not 100% defined content e.g. minimum require OpenPype version etc.)
- defying that folder is content of a module or an addon
- module/addon have it's settings schemas and default values outside OpenPype
- add general setting of paths to modules
- definition of module (not 100% defined content e.g. minimum required OpenPype version etc.)
- defining a folder as a content of a module or an addon
## Base class `OpenPypeModule`
- abstract class as base for each module
- implementation should be module's api withou GUI parts
- may implement `get_global_environments` method which should return dictionary of environments that are globally appliable and value is the same for whole studio if launched at any workstation (except os specific paths)
- implementation should contain module's api without GUI parts
- may implement `get_global_environments` method which should return dictionary of environments that are globally applicable and value is the same for whole studio if launched at any workstation (except os specific paths)
- abstract parts:
- `name` attribute - name of a module
- `initialize` method - method for own initialization of a module (should not override `__init__`)
- `connect_with_modules` method - where module may look for it's interfaces implementations or check for other modules
- `__init__` should not be overriden and `initialize` should not do time consuming part but only prepare base data about module
- also keep in mind that they may be initialized in headless mode
- `name` attribute - name of a module
- `initialize` method - method for own initialization of a module (should not override `__init__`)
- `connect_with_modules` method - where module may look for it's interfaces implementations or check for other modules
- `__init__` should not be overridden and `initialize` should not do time consuming part but only prepare base data about module
- also keep in mind that they may be initialized in headless mode
- connection with other modules is made with help of interfaces
## Addon class `OpenPypeAddOn`
- inherits from `OpenPypeModule` but is enabled by default and doesn't have to implement `initialize` and `connect_with_modules` methods
- that is because it is expected that addons don't need to have system settings and `enabled` value on it (but it is possible...)
## How to add addons/modules
- in System settings go to `modules/addon_paths` (`Modules/OpenPype AddOn Paths`) where you have to add path to addon root folder
- for openpype example addons use `{OPENPYPE_REPOS_ROOT}/openpype/modules/example_addons`
## Addon/module settings
- addons/modules may have defined custom settings definitions with default values
- it is based on settings type `dynamic_schema` which has `name`
- that item defines that it can be replaced dynamically with any schemas from module or module which won't be saved to openpype core defaults
- they can't be added to any schema hierarchy
- item must not be in settings group (under overrides) or in dynamic item (e.g. `list` of `dict-modifiable`)
- addons may define it's dynamic schema items
- they can be defined with class which inherits from `BaseModuleSettingsDef`
- it is recommended to use pre implemented `JsonFilesSettingsDef` which defined structure and use json files to define dynamic schemas, schemas and default values
- check it's docstring and check for `example_addon` in example addons
- settings definition returns schemas by dynamic schemas names
# Interfaces
- interface is class that has defined abstract methods to implement and may contain preimplemented helper methods
- interface is class that has defined abstract methods to implement and may contain pre implemented helper methods
- module that inherit from an interface must implement those abstract methods otherwise won't be initialized
- it is easy to find which module object inherited from which interfaces withh 100% chance they have implemented required methods
- it is easy to find which module object inherited from which interfaces with 100% chance they have implemented required methods
- interfaces can be defined in `interfaces.py` inside module directory
- the file can't use relative imports or import anything from other parts
of module itself at the header of file
- this is one of reasons why modules/addons can't be imported directly without using defined functions in OpenPype modules implementation
- the file can't use relative imports or import anything from other parts
of module itself at the header of file
- this is one of reasons why modules/addons can't be imported directly without using defined functions in OpenPype modules implementation
## Base class `OpenPypeInterface`
- has nothing implemented
- has ABCMeta as metaclass
- is defined to be able find out classes which inherit from this base to be
able tell this is an Interface
able tell this is an Interface
## Global interfaces
- few interfaces are implemented for global usage
### IPluginPaths
- module want to add directory path/s to avalon or publish plugins
- module wants to add directory path/s to avalon or publish plugins
- module must implement `get_plugin_paths` which must return dictionary with possible keys `"publish"`, `"load"`, `"create"` or `"actions"`
- each key may contain list or string with path to directory with plugins
- each key may contain list or string with a path to directory with plugins
### ITrayModule
- module has more logic when used in tray
- it is possible that module can be used only in tray
- module has more logic when used in a tray
- it is possible that module can be used only in the tray
- abstract methods
- `tray_init` - initialization triggered after `initialize` when used in `TrayModulesManager` and before `connect_with_modules`
- `tray_menu` - add actions to tray widget's menu that represent the module
- `tray_start` - start of module's login in tray
- module is initialized and connected with other modules
- `tray_exit` - module's cleanup like stop and join threads etc.
- order of calling is based on implementation this order is how it works with `TrayModulesManager`
- it is recommended to import and use GUI implementaion only in these methods
- `tray_init` - initialization triggered after `initialize` when used in `TrayModulesManager` and before `connect_with_modules`
- `tray_menu` - add actions to tray widget's menu that represent the module
- `tray_start` - start of module's login in tray
- module is initialized and connected with other modules
- `tray_exit` - module's cleanup like stop and join threads etc.
- order of calling is based on implementation this order is how it works with `TrayModulesManager`
- it is recommended to import and use GUI implementation only in these methods
- has attribute `tray_initialized` (bool) which is set to False by default and is set by `TrayModulesManager` to True after `tray_init`
- if module has logic only in tray or for both then should be checking for `tray_initialized` attribute to decide how should handle situations
- if module has logic only in tray or for both then should be checking for `tray_initialized` attribute to decide how should handle situations
### ITrayService
- inherit from `ITrayModule` and implement `tray_menu` method for you
- add action to submenu "Services" in tray widget menu with icon and label
- abstract atttribute `label`
- label shown in menu
- interface has preimplemented methods to change icon color
- `set_service_running` - green icon
- `set_service_failed` - red icon
- `set_service_idle` - orange icon
- these states must be set by module itself `set_service_running` is default state on initialization
- inherits from `ITrayModule` and implements `tray_menu` method for you
- adds action to submenu "Services" in tray widget menu with icon and label
- abstract attribute `label`
- label shown in menu
- interface has pre implemented methods to change icon color
- `set_service_running` - green icon
- `set_service_failed` - red icon
- `set_service_idle` - orange icon
- these states must be set by module itself `set_service_running` is default state on initialization
### ITrayAction
- inherit from `ITrayModule` and implement `tray_menu` method for you
- add action to tray widget menu with label
- abstract atttribute `label`
- label shown in menu
- inherits from `ITrayModule` and implements `tray_menu` method for you
- adds action to tray widget menu with label
- abstract attribute `label`
- label shown in menu
- abstract method `on_action_trigger`
- what should happen when action is triggered
- NOTE: It is good idea to implement logic in `on_action_trigger` to api method and trigger that methods on callbacks this gives ability to trigger that method outside tray
- what should happen when an action is triggered
- NOTE: It is a good idea to implement logic in `on_action_trigger` to the api method and trigger that method on callbacks. This gives ability to trigger that method outside tray
## Modules interfaces
- modules may have defined their interfaces to be able recognize other modules that would want to use their features
-
### Example:
- Ftrack module has `IFtrackEventHandlerPaths` which helps to tell Ftrack module which of other modules want to add paths to server/user event handlers
- Clockify module use `IFtrackEventHandlerPaths` and return paths to clockify ftrack synchronizers
- modules may have defined their own interfaces to be able to recognize other modules that would want to use their features
- Clockify has more inharitance it's class definition looks like
### Example:
- Ftrack module has `IFtrackEventHandlerPaths` which helps to tell Ftrack module which other modules want to add paths to server/user event handlers
- Clockify module use `IFtrackEventHandlerPaths` and returns paths to clockify ftrack synchronizers
- Clockify inherits from more interfaces. It's class definition looks like:
```
class ClockifyModule(
OpenPypeModule, # Says it's Pype module so ModulesManager will try to initialize.
ITrayModule, # Says has special implementation when used in tray.
IPluginPaths, # Says has plugin paths that want to register (paths to clockify actions for launcher).
IFtrackEventHandlerPaths, # Says has Ftrack actions/events for user/server.
ITimersManager # Listen to other modules with timer and can trigger changes in other module timers through `TimerManager` module.
OpenPypeModule, # Says it's Pype module so ModulesManager will try to initialize.
ITrayModule, # Says has special implementation when used in tray.
IPluginPaths, # Says has plugin paths that want to register (paths to clockify actions for launcher).
IFtrackEventHandlerPaths, # Says has Ftrack actions/events for user/server.
ITimersManager # Listen to other modules with timer and can trigger changes in other module timers through `TimerManager` module.
):
```
### ModulesManager
- collect module classes and tries to initialize them
- collects module classes and tries to initialize them
- important attributes
- `modules` - list of available attributes
- `modules_by_id` - dictionary of modules mapped by their ids
- `modules_by_name` - dictionary of modules mapped by their names
- all these attributes contain all found modules even if are not enabled
- `modules` - list of available attributes
- `modules_by_id` - dictionary of modules mapped by their ids
- `modules_by_name` - dictionary of modules mapped by their names
- all these attributes contain all found modules even if are not enabled
- helper methods
- `collect_global_environments` to collect all global environments from enabled modules with calling `get_global_environments` on each of them
- `collect_plugin_paths` collect plugin paths from all enabled modules
- output is always dictionary with all keys and values as list
```
{
"publish": [],
"create": [],
"load": [],
"actions": []
}
```
- `collect_global_environments` to collect all global environments from enabled modules with calling `get_global_environments` on each of them
- `collect_plugin_paths` collects plugin paths from all enabled modules
- output is always dictionary with all keys and values as an list
```
{
"publish": [],
"create": [],
"load": [],
"actions": []
}
```
### TrayModulesManager
- inherit from `ModulesManager`
- has specific implementations for Pype Tray tool and handle `ITrayModule` methods
- inherits from `ModulesManager`
- has specific implementation for Pype Tray tool and handle `ITrayModule` methods

View file

@ -1,21 +1,35 @@
# -*- coding: utf-8 -*-
from .base import (
OpenPypeModule,
OpenPypeAddOn,
OpenPypeInterface,
load_modules,
ModulesManager,
TrayModulesManager
TrayModulesManager,
BaseModuleSettingsDef,
ModuleSettingsDef,
JsonFilesSettingsDef,
get_module_settings_defs
)
__all__ = (
"OpenPypeModule",
"OpenPypeAddOn",
"OpenPypeInterface",
"load_modules",
"ModulesManager",
"TrayModulesManager"
"TrayModulesManager",
"BaseModuleSettingsDef",
"ModuleSettingsDef",
"JsonFilesSettingsDef",
"get_module_settings_defs"
)

View file

@ -2,9 +2,11 @@
"""Base class for Pype Modules."""
import os
import sys
import json
import time
import inspect
import logging
import platform
import threading
import collections
from uuid import uuid4
@ -12,7 +14,18 @@ from abc import ABCMeta, abstractmethod
import six
import openpype
from openpype.settings import get_system_settings
from openpype.settings import (
get_system_settings,
SYSTEM_SETTINGS_KEY,
PROJECT_SETTINGS_KEY,
SCHEMA_KEY_SYSTEM_SETTINGS,
SCHEMA_KEY_PROJECT_SETTINGS
)
from openpype.settings.lib import (
get_studio_system_settings_overrides,
load_json_file
)
from openpype.lib import PypeLogger
@ -115,11 +128,51 @@ def get_default_modules_dir():
return os.path.join(current_dir, "default_modules")
def get_dynamic_modules_dirs():
"""Possible paths to OpenPype Addons of Modules.
Paths are loaded from studio settings under:
`modules -> addon_paths -> {platform name}`
Path may contain environment variable as a formatting string.
They are not validated or checked their existence.
Returns:
list: Paths loaded from studio overrides.
"""
output = []
value = get_studio_system_settings_overrides()
for key in ("modules", "addon_paths", platform.system().lower()):
if key not in value:
return output
value = value[key]
for path in value:
if not path:
continue
try:
path = path.format(**os.environ)
except Exception:
pass
output.append(path)
return output
def get_module_dirs():
"""List of paths where OpenPype modules can be found."""
dirpaths = [
get_default_modules_dir()
]
_dirpaths = []
_dirpaths.append(get_default_modules_dir())
_dirpaths.extend(get_dynamic_modules_dirs())
dirpaths = []
for path in _dirpaths:
if not path:
continue
normalized = os.path.normpath(path)
if normalized not in dirpaths:
dirpaths.append(normalized)
return dirpaths
@ -165,6 +218,9 @@ def _load_interfaces():
os.path.join(get_default_modules_dir(), "interfaces.py")
)
for dirpath in dirpaths:
if not os.path.exists(dirpath):
continue
for filename in os.listdir(dirpath):
if filename in ("__pycache__", ):
continue
@ -272,12 +328,19 @@ def _load_modules():
# TODO add more logic how to define if folder is module or not
# - check manifest and content of manifest
if os.path.isdir(fullpath):
import_module_from_dirpath(dirpath, filename, modules_key)
try:
if os.path.isdir(fullpath):
import_module_from_dirpath(dirpath, filename, modules_key)
elif ext in (".py", ):
module = import_filepath(fullpath)
setattr(openpype_modules, basename, module)
elif ext in (".py", ):
module = import_filepath(fullpath)
setattr(openpype_modules, basename, module)
except Exception:
log.error(
"Failed to import '{}'.".format(fullpath),
exc_info=True
)
class _OpenPypeInterfaceMeta(ABCMeta):
@ -368,7 +431,16 @@ class OpenPypeModule:
class OpenPypeAddOn(OpenPypeModule):
pass
# Enable Addon by default
enabled = True
def initialize(self, module_settings):
"""Initialization is not be required for most of addons."""
pass
def connect_with_modules(self, enabled_modules):
"""Do not require to implement connection with modules for addon."""
pass
class ModulesManager:
@ -423,6 +495,7 @@ class ModulesManager:
if (
not inspect.isclass(modules_item)
or modules_item is OpenPypeModule
or modules_item is OpenPypeAddOn
or not issubclass(modules_item, OpenPypeModule)
):
continue
@ -920,3 +993,424 @@ class TrayModulesManager(ModulesManager):
),
exc_info=True
)
def get_module_settings_defs():
"""Check loaded addons/modules for existence of thei settings definition.
Check if OpenPype addon/module as python module has class that inherit
from `ModuleSettingsDef` in python module variables (imported
in `__init__py`).
Returns:
list: All valid and not abstract settings definitions from imported
openpype addons and modules.
"""
# Make sure modules are loaded
load_modules()
import openpype_modules
settings_defs = []
log = PypeLogger.get_logger("ModuleSettingsLoad")
for raw_module in openpype_modules:
for attr_name in dir(raw_module):
attr = getattr(raw_module, attr_name)
if (
not inspect.isclass(attr)
or attr is ModuleSettingsDef
or not issubclass(attr, ModuleSettingsDef)
):
continue
if inspect.isabstract(attr):
# Find missing implementations by convetion on `abc` module
not_implemented = []
for attr_name in dir(attr):
attr = getattr(attr, attr_name, None)
abs_method = getattr(
attr, "__isabstractmethod__", None
)
if attr and abs_method:
not_implemented.append(attr_name)
# Log missing implementations
log.warning((
"Skipping abstract Class: {} in module {}."
" Missing implementations: {}"
).format(
attr_name, raw_module.__name__, ", ".join(not_implemented)
))
continue
settings_defs.append(attr)
return settings_defs
@six.add_metaclass(ABCMeta)
class BaseModuleSettingsDef:
"""Definition of settings for OpenPype module or AddOn."""
_id = None
@property
def id(self):
"""ID created on initialization.
ID should be per created object. Helps to store objects.
"""
if self._id is None:
self._id = uuid4()
return self._id
@abstractmethod
def get_settings_schemas(self, schema_type):
"""Setting schemas for passed schema type.
These are main schemas by dynamic schema keys. If they're using
sub schemas or templates they should be loaded with
`get_dynamic_schemas`.
Returns:
dict: Schema by `dynamic_schema` keys.
"""
pass
@abstractmethod
def get_dynamic_schemas(self, schema_type):
"""Settings schemas and templates that can be used anywhere.
It is recommended to add prefix specific for addon/module to keys
(e.g. "my_addon/real_schema_name").
Returns:
dict: Schemas and templates by their keys.
"""
pass
@abstractmethod
def get_defaults(self, top_key):
"""Default values for passed top key.
Top keys are (currently) "system_settings" or "project_settings".
Should return exactly what was passed with `save_defaults`.
Returns:
dict: Default values by path to first key in OpenPype defaults.
"""
pass
@abstractmethod
def save_defaults(self, top_key, data):
"""Save default values for passed top key.
Top keys are (currently) "system_settings" or "project_settings".
Passed data are by path to first key defined in main schemas.
"""
pass
class ModuleSettingsDef(BaseModuleSettingsDef):
"""Settings definiton with separated system and procect settings parts.
Reduce conditions that must be checked and adds predefined methods for
each case.
"""
def get_defaults(self, top_key):
"""Split method into 2 methods by top key."""
if top_key == SYSTEM_SETTINGS_KEY:
return self.get_default_system_settings() or {}
elif top_key == PROJECT_SETTINGS_KEY:
return self.get_default_project_settings() or {}
return {}
def save_defaults(self, top_key, data):
"""Split method into 2 methods by top key."""
if top_key == SYSTEM_SETTINGS_KEY:
self.save_system_defaults(data)
elif top_key == PROJECT_SETTINGS_KEY:
self.save_project_defaults(data)
def get_settings_schemas(self, schema_type):
"""Split method into 2 methods by schema type."""
if schema_type == SCHEMA_KEY_SYSTEM_SETTINGS:
return self.get_system_settings_schemas() or {}
elif schema_type == SCHEMA_KEY_PROJECT_SETTINGS:
return self.get_project_settings_schemas() or {}
return {}
def get_dynamic_schemas(self, schema_type):
"""Split method into 2 methods by schema type."""
if schema_type == SCHEMA_KEY_SYSTEM_SETTINGS:
return self.get_system_dynamic_schemas() or {}
elif schema_type == SCHEMA_KEY_PROJECT_SETTINGS:
return self.get_project_dynamic_schemas() or {}
return {}
@abstractmethod
def get_system_settings_schemas(self):
"""Schemas and templates usable in system settings schemas.
Returns:
dict: Schemas and templates by it's names. Names must be unique
across whole OpenPype.
"""
pass
@abstractmethod
def get_project_settings_schemas(self):
"""Schemas and templates usable in project settings schemas.
Returns:
dict: Schemas and templates by it's names. Names must be unique
across whole OpenPype.
"""
pass
@abstractmethod
def get_system_dynamic_schemas(self):
"""System schemas by dynamic schema name.
If dynamic schema name is not available in then schema will not used.
Returns:
dict: Schemas or list of schemas by dynamic schema name.
"""
pass
@abstractmethod
def get_project_dynamic_schemas(self):
"""Project schemas by dynamic schema name.
If dynamic schema name is not available in then schema will not used.
Returns:
dict: Schemas or list of schemas by dynamic schema name.
"""
pass
@abstractmethod
def get_default_system_settings(self):
"""Default system settings values.
Returns:
dict: Default values by path to first key.
"""
pass
@abstractmethod
def get_default_project_settings(self):
"""Default project settings values.
Returns:
dict: Default values by path to first key.
"""
pass
@abstractmethod
def save_system_defaults(self, data):
"""Save default system settings values.
Passed data are by path to first key defined in main schemas.
"""
pass
@abstractmethod
def save_project_defaults(self, data):
"""Save default project settings values.
Passed data are by path to first key defined in main schemas.
"""
pass
class JsonFilesSettingsDef(ModuleSettingsDef):
"""Preimplemented settings definition using json files and file structure.
Expected file structure:
root
# Default values
defaults
system_settings.json
project_settings.json
# Schemas for `dynamic_template` type
dynamic_schemas
system_dynamic_schemas.json
project_dynamic_schemas.json
# Schemas that can be used anywhere (enhancement for `dynamic_schemas`)
schemas
system_schemas
<system schema.json> # Any schema or template files
...
project_schemas
<system schema.json> # Any schema or template files
...
Schemas can be loaded with prefix to avoid duplicated schema/template names
across all OpenPype addons/modules. Prefix can be defined with class
attribute `schema_prefix`.
Only think which must be implemented in `get_settings_root_path` which
should return directory path to `root` (in structure graph above).
"""
# Possible way how to define `schemas` prefix
schema_prefix = ""
@abstractmethod
def get_settings_root_path(self):
"""Directory path where settings and it's schemas are located."""
pass
def __init__(self):
settings_root_dir = self.get_settings_root_path()
defaults_dir = os.path.join(
settings_root_dir, "defaults"
)
dynamic_schemas_dir = os.path.join(
settings_root_dir, "dynamic_schemas"
)
schemas_dir = os.path.join(
settings_root_dir, "schemas"
)
self.system_defaults_filepath = os.path.join(
defaults_dir, "system_settings.json"
)
self.project_defaults_filepath = os.path.join(
defaults_dir, "project_settings.json"
)
self.system_dynamic_schemas_filepath = os.path.join(
dynamic_schemas_dir, "system_dynamic_schemas.json"
)
self.project_dynamic_schemas_filepath = os.path.join(
dynamic_schemas_dir, "project_dynamic_schemas.json"
)
self.system_schemas_dir = os.path.join(
schemas_dir, "system_schemas"
)
self.project_schemas_dir = os.path.join(
schemas_dir, "project_schemas"
)
def _load_json_file_data(self, path):
if os.path.exists(path):
return load_json_file(path)
return {}
def get_default_system_settings(self):
"""Default system settings values.
Returns:
dict: Default values by path to first key.
"""
return self._load_json_file_data(self.system_defaults_filepath)
def get_default_project_settings(self):
"""Default project settings values.
Returns:
dict: Default values by path to first key.
"""
return self._load_json_file_data(self.project_defaults_filepath)
def _save_data_to_filepath(self, path, data):
dirpath = os.path.dirname(path)
if not os.path.exists(dirpath):
os.makedirs(dirpath)
with open(path, "w") as file_stream:
json.dump(data, file_stream, indent=4)
def save_system_defaults(self, data):
"""Save default system settings values.
Passed data are by path to first key defined in main schemas.
"""
self._save_data_to_filepath(self.system_defaults_filepath, data)
def save_project_defaults(self, data):
"""Save default project settings values.
Passed data are by path to first key defined in main schemas.
"""
self._save_data_to_filepath(self.project_defaults_filepath, data)
def get_system_dynamic_schemas(self):
"""System schemas by dynamic schema name.
If dynamic schema name is not available in then schema will not used.
Returns:
dict: Schemas or list of schemas by dynamic schema name.
"""
return self._load_json_file_data(self.system_dynamic_schemas_filepath)
def get_project_dynamic_schemas(self):
"""Project schemas by dynamic schema name.
If dynamic schema name is not available in then schema will not used.
Returns:
dict: Schemas or list of schemas by dynamic schema name.
"""
return self._load_json_file_data(self.project_dynamic_schemas_filepath)
def _load_files_from_path(self, path):
output = {}
if not path or not os.path.exists(path):
return output
if os.path.isfile(path):
filename = os.path.basename(path)
basename, ext = os.path.splitext(filename)
if ext == ".json":
if self.schema_prefix:
key = "{}/{}".format(self.schema_prefix, basename)
else:
key = basename
output[key] = self._load_json_file_data(path)
return output
path = os.path.normpath(path)
for root, _, files in os.walk(path, topdown=False):
for filename in files:
basename, ext = os.path.splitext(filename)
if ext != ".json":
continue
json_path = os.path.join(root, filename)
store_key = os.path.join(
root.replace(path, ""), basename
).replace("\\", "/")
if self.schema_prefix:
store_key = "{}/{}".format(self.schema_prefix, store_key)
output[store_key] = self._load_json_file_data(json_path)
return output
def get_system_settings_schemas(self):
"""Schemas and templates usable in system settings schemas.
Returns:
dict: Schemas and templates by it's names. Names must be unique
across whole OpenPype.
"""
return self._load_files_from_path(self.system_schemas_dir)
def get_project_settings_schemas(self):
"""Schemas and templates usable in project settings schemas.
Returns:
dict: Schemas and templates by it's names. Names must be unique
across whole OpenPype.
"""
return self._load_files_from_path(self.project_schemas_dir)

View file

@ -3,7 +3,7 @@ import re
import json
from openpype_modules.ftrack.lib import BaseAction, statics_icon
from openpype.api import Anatomy, get_project_settings
from openpype.api import get_project_basic_paths, create_project_folders
class CreateProjectFolders(BaseAction):
@ -72,25 +72,18 @@ class CreateProjectFolders(BaseAction):
def launch(self, session, entities, event):
# Get project entity
project_entity = self.get_project_from_entity(entities[0])
# Load settings for project
project_name = project_entity["full_name"]
project_settings = get_project_settings(project_name)
project_folder_structure = (
project_settings["global"]["project_folder_structure"]
)
if not project_folder_structure:
return {
"success": False,
"message": "Project structure is not set."
}
try:
if isinstance(project_folder_structure, str):
project_folder_structure = json.loads(project_folder_structure)
# Get paths based on presets
basic_paths = self.get_path_items(project_folder_structure)
self.create_folders(basic_paths, project_entity)
basic_paths = get_project_basic_paths(project_name)
if not basic_paths:
return {
"success": False,
"message": "Project structure is not set."
}
# Invoking OpenPype API to create the project folders
create_project_folders(basic_paths, project_name)
self.create_ftrack_entities(basic_paths, project_entity)
except Exception as exc:
@ -195,58 +188,6 @@ class CreateProjectFolders(BaseAction):
self.session.commit()
return new_ent
def get_path_items(self, in_dict):
output = []
for key, value in in_dict.items():
if not value:
output.append(key)
else:
paths = self.get_path_items(value)
for path in paths:
if not isinstance(path, (list, tuple)):
path = [path]
output.append([key, *path])
return output
def compute_paths(self, basic_paths_items, project_root):
output = []
for path_items in basic_paths_items:
clean_items = []
for path_item in path_items:
matches = re.findall(self.pattern_array, path_item)
if len(matches) > 0:
path_item = path_item.replace(matches[0], "")
if path_item == self.project_root_key:
path_item = project_root
clean_items.append(path_item)
output.append(os.path.normpath(os.path.sep.join(clean_items)))
return output
def create_folders(self, basic_paths, project):
anatomy = Anatomy(project["full_name"])
roots_paths = []
if isinstance(anatomy.roots, dict):
for root in anatomy.roots.values():
roots_paths.append(root.value)
else:
roots_paths.append(anatomy.roots.value)
for root_path in roots_paths:
project_root = os.path.join(root_path, project["full_name"])
full_paths = self.compute_paths(basic_paths, project_root)
# Create folders
for path in full_paths:
full_path = path.format(project_root=project_root)
if os.path.exists(full_path):
self.log.debug(
"Folder already exists: {}".format(full_path)
)
else:
self.log.debug("Creating folder: {}".format(full_path))
os.makedirs(full_path)
def register(session):
CreateProjectFolders(session).register()

View file

@ -68,9 +68,6 @@ class CollectFtrackFamily(pyblish.api.InstancePlugin):
instance.data["families"].append("ftrack")
else:
instance.data["families"] = ["ftrack"]
else:
self.log.debug("Instance '{}' doesn't match any profile".format(
instance.data.get("family")))
def _get_add_ftrack_f_from_addit_filters(self,
additional_filters,

View file

@ -29,13 +29,35 @@ class AbstractProvider:
@classmethod
@abc.abstractmethod
def get_configurable_items(cls):
def get_system_settings_schema(cls):
"""
Returns filtered dict of editable properties
Returns dict for editable properties on system settings level
Returns:
(dict)
(list) of dict
"""
@classmethod
@abc.abstractmethod
def get_project_settings_schema(cls):
"""
Returns dict for editable properties on project settings level
Returns:
(list) of dict
"""
@classmethod
@abc.abstractmethod
def get_local_settings_schema(cls):
"""
Returns dict for editable properties on local settings level
Returns:
(list) of dict
"""
@abc.abstractmethod

View file

@ -8,7 +8,7 @@ import platform
from openpype.api import Logger
from openpype.api import get_system_settings
from .abstract_provider import AbstractProvider
from ..utils import time_function, ResumableError, EditableScopes
from ..utils import time_function, ResumableError
log = Logger().get_logger("SyncServer")
@ -96,30 +96,61 @@ class GDriveHandler(AbstractProvider):
return self.service is not None
@classmethod
def get_configurable_items(cls):
def get_system_settings_schema(cls):
"""
Returns filtered dict of editable properties.
Returns dict for editable properties on system settings level
Returns:
(list) of dict
"""
return []
@classmethod
def get_project_settings_schema(cls):
"""
Returns dict for editable properties on project settings level
Returns:
(list) of dict
"""
# {platform} tells that value is multiplatform and only specific OS
# should be returned
editable = [
# credentials could be overriden on Project or User level
{
'key': "credentials_url",
'label': "Credentials url",
'type': 'text'
},
# roots could be overriden only on Project leve, User cannot
{
'key': "roots",
'label': "Roots",
'type': 'dict'
}
]
return editable
@classmethod
def get_local_settings_schema(cls):
"""
Returns dict for editable properties on local settings level
Returns:
(dict)
"""
# {platform} tells that value is multiplatform and only specific OS
# should be returned
editable = {
editable = [
# credentials could be override on Project or User level
'credentials_url': {
'scope': [EditableScopes.PROJECT,
EditableScopes.LOCAL],
{
'key': "credentials_url",
'label': "Credentials url",
'type': 'text',
'namespace': '{project_settings}/global/sync_server/sites/{site}/credentials_url/{platform}' # noqa: E501
},
# roots could be override only on Project leve, User cannot
'root': {'scope': [EditableScopes.PROJECT],
'label': "Roots",
'type': 'dict'}
}
}
]
return editable
def get_roots_config(self, anatomy=None):

View file

@ -76,6 +76,14 @@ class ProviderFactory:
return provider_info[0].get_configurable_items()
def get_provider_cls(self, provider_code):
"""
Returns class object for 'provider_code' to run class methods on.
"""
provider_info = self._get_creator_info(provider_code)
return provider_info[0]
def _get_creator_info(self, provider):
"""
Collect all necessary info for provider. Currently only creator

View file

@ -7,8 +7,6 @@ import time
from openpype.api import Logger, Anatomy
from .abstract_provider import AbstractProvider
from ..utils import EditableScopes
log = Logger().get_logger("SyncServer")
@ -30,18 +28,51 @@ class LocalDriveHandler(AbstractProvider):
return True
@classmethod
def get_configurable_items(cls):
def get_system_settings_schema(cls):
"""
Returns filtered dict of editable properties
Returns dict for editable properties on system settings level
Returns:
(list) of dict
"""
return []
@classmethod
def get_project_settings_schema(cls):
"""
Returns dict for editable properties on project settings level
Returns:
(list) of dict
"""
# for non 'studio' sites, 'studio' is configured in Anatomy
editable = [
{
'key': "roots",
'label': "Roots",
'type': 'dict'
}
]
return editable
@classmethod
def get_local_settings_schema(cls):
"""
Returns dict for editable properties on local settings level
Returns:
(dict)
"""
editable = {
'root': {'scope': [EditableScopes.LOCAL],
'label': "Roots",
'type': 'dict'}
}
editable = [
{
'key': "roots",
'label': "Roots",
'type': 'dict'
}
]
return editable
def upload_file(self, source_path, target_path,

View file

@ -16,14 +16,13 @@ from openpype.api import (
get_local_site_id)
from openpype.lib import PypeLogger
from openpype.settings.lib import (
get_default_project_settings,
get_default_anatomy_settings,
get_anatomy_settings)
from .providers.local_drive import LocalDriveHandler
from .providers import lib
from .utils import time_function, SyncStatus, EditableScopes
from .utils import time_function, SyncStatus
log = PypeLogger().get_logger("SyncServer")
@ -399,204 +398,239 @@ class SyncServerModule(OpenPypeModule, ITrayModule):
return remote_site
def get_local_settings_schema(self):
"""Wrapper for Local settings - all projects incl. Default"""
return self.get_configurable_items(EditableScopes.LOCAL)
# Methods for Settings UI to draw appropriate forms
@classmethod
def get_system_settings_schema(cls):
""" Gets system level schema of configurable items
def get_configurable_items(self, scope=None):
Used for Setting UI to provide forms.
"""
Returns list of sites that could be configurable for all projects.
ret_dict = {}
for provider_code in lib.factory.providers:
ret_dict[provider_code] = \
lib.factory.get_provider_cls(provider_code). \
get_system_settings_schema()
Could be filtered by 'scope' argument (list)
return ret_dict
Args:
scope (list of utils.EditableScope)
@classmethod
def get_project_settings_schema(cls):
""" Gets project level schema of configurable items.
Returns:
(dict of list of dict)
{
siteA : [
{
key:"root", label:"root",
"value":"{'work': 'c:/projects'}",
"type": "dict",
"children":[
{ "key": "work",
"type": "text",
"value": "c:/projects"}
]
},
{
key:"credentials_url", label:"Credentials url",
"value":"'c:/projects/cred.json'", "type": "text",
"namespace": "{project_setting}/global/sync_server/
sites"
}
]
}
It is not using Setting! Used for Setting UI to provide forms.
"""
editable = {}
applicable_projects = list(self.connection.projects())
applicable_projects.append(None)
for project in applicable_projects:
project_name = None
if project:
project_name = project["name"]
ret_dict = {}
for provider_code in lib.factory.providers:
ret_dict[provider_code] = \
lib.factory.get_provider_cls(provider_code). \
get_project_settings_schema()
items = self.get_configurable_items_for_project(project_name,
scope)
editable.update(items)
return ret_dict
return editable
@classmethod
def get_local_settings_schema(cls):
""" Gets local level schema of configurable items.
def get_local_settings_schema_for_project(self, project_name):
"""Wrapper for Local settings - for specific 'project_name'"""
return self.get_configurable_items_for_project(project_name,
EditableScopes.LOCAL)
def get_configurable_items_for_project(self, project_name=None,
scope=None):
It is not using Setting! Used for Setting UI to provide forms.
"""
Returns list of items that could be configurable for specific
'project_name'
ret_dict = {}
for provider_code in lib.factory.providers:
ret_dict[provider_code] = \
lib.factory.get_provider_cls(provider_code). \
get_local_settings_schema()
Args:
project_name (str) - None > default project,
scope (list of utils.EditableScope)
(optional, None is all scopes, default is LOCAL)
return ret_dict
Returns:
(dict of list of dict)
{
siteA : [
{
key:"root", label:"root",
"type": "dict",
"children":[
{ "key": "work",
"type": "text",
"value": "c:/projects"}
]
},
{
key:"credentials_url", label:"Credentials url",
"value":"'c:/projects/cred.json'", "type": "text",
"namespace": "{project_setting}/global/sync_server/
sites"
}
]
}
"""
allowed_sites = set()
sites = self.get_all_site_configs(project_name)
if project_name:
# Local Settings can select only from allowed sites for project
allowed_sites.update(set(self.get_active_sites(project_name)))
allowed_sites.update(set(self.get_remote_sites(project_name)))
editable = {}
for site_name in sites.keys():
if allowed_sites and site_name not in allowed_sites:
continue
items = self.get_configurable_items_for_site(project_name,
site_name,
scope)
# Local Settings need 'local' instead of real value
site_name = site_name.replace(get_local_site_id(), 'local')
editable[site_name] = items
return editable
def get_local_settings_schema_for_site(self, project_name, site_name):
"""Wrapper for Local settings - for particular 'site_name and proj."""
return self.get_configurable_items_for_site(project_name,
site_name,
EditableScopes.LOCAL)
def get_configurable_items_for_site(self, project_name=None,
site_name=None,
scope=None):
"""
Returns list of items that could be configurable.
Args:
project_name (str) - None > default project
site_name (str)
scope (list of utils.EditableScope)
(optional, None is all scopes)
Returns:
(list)
[
{
key:"root", label:"root", type:"dict",
"children":[
{ "key": "work",
"type": "text",
"value": "c:/projects"}
]
}, ...
]
"""
provider_name = self.get_provider_for_site(site=site_name)
items = lib.factory.get_provider_configurable_items(provider_name)
if project_name:
sync_s = self.get_sync_project_setting(project_name,
exclude_locals=True,
cached=False)
else:
sync_s = get_default_project_settings(exclude_locals=True)
sync_s = sync_s["global"]["sync_server"]
sync_s["sites"].update(
self._get_default_site_configs(self.enabled))
editable = []
if type(scope) is not list:
scope = [scope]
scope = set(scope)
for key, properties in items.items():
if scope is None or scope.intersection(set(properties["scope"])):
val = sync_s.get("sites", {}).get(site_name, {}).get(key)
item = {
"key": key,
"label": properties["label"],
"type": properties["type"]
}
if properties.get("namespace"):
item["namespace"] = properties.get("namespace")
if "platform" in item["namespace"]:
try:
if val:
val = val[platform.system().lower()]
except KeyError:
st = "{}'s field value {} should be".format(key, val) # noqa: E501
log.error(st + " multiplatform dict")
item["namespace"] = item["namespace"].replace('{site}',
site_name)
children = []
if properties["type"] == "dict":
if val:
for val_key, val_val in val.items():
child = {
"type": "text",
"key": val_key,
"value": val_val
}
children.append(child)
if properties["type"] == "dict":
item["children"] = children
else:
item["value"] = val
editable.append(item)
return editable
# Needs to be refactored after Settings are updated
# # Methods for Settings to get appriate values to fill forms
# def get_configurable_items(self, scope=None):
# """
# Returns list of sites that could be configurable for all projects
#
# Could be filtered by 'scope' argument (list)
#
# Args:
# scope (list of utils.EditableScope)
#
# Returns:
# (dict of list of dict)
# {
# siteA : [
# {
# key:"root", label:"root",
# "value":"{'work': 'c:/projects'}",
# "type": "dict",
# "children":[
# { "key": "work",
# "type": "text",
# "value": "c:/projects"}
# ]
# },
# {
# key:"credentials_url", label:"Credentials url",
# "value":"'c:/projects/cred.json'", "type": "text", # noqa: E501
# "namespace": "{project_setting}/global/sync_server/ # noqa: E501
# sites"
# }
# ]
# }
# """
# editable = {}
# applicable_projects = list(self.connection.projects())
# applicable_projects.append(None)
# for project in applicable_projects:
# project_name = None
# if project:
# project_name = project["name"]
#
# items = self.get_configurable_items_for_project(project_name,
# scope)
# editable.update(items)
#
# return editable
#
# def get_local_settings_schema_for_project(self, project_name):
# """Wrapper for Local settings - for specific 'project_name'"""
# return self.get_configurable_items_for_project(project_name,
# EditableScopes.LOCAL)
#
# def get_configurable_items_for_project(self, project_name=None,
# scope=None):
# """
# Returns list of items that could be configurable for specific
# 'project_name'
#
# Args:
# project_name (str) - None > default project,
# scope (list of utils.EditableScope)
# (optional, None is all scopes, default is LOCAL)
#
# Returns:
# (dict of list of dict)
# {
# siteA : [
# {
# key:"root", label:"root",
# "type": "dict",
# "children":[
# { "key": "work",
# "type": "text",
# "value": "c:/projects"}
# ]
# },
# {
# key:"credentials_url", label:"Credentials url",
# "value":"'c:/projects/cred.json'", "type": "text",
# "namespace": "{project_setting}/global/sync_server/
# sites"
# }
# ]
# }
# """
# allowed_sites = set()
# sites = self.get_all_site_configs(project_name)
# if project_name:
# # Local Settings can select only from allowed sites for project
# allowed_sites.update(set(self.get_active_sites(project_name)))
# allowed_sites.update(set(self.get_remote_sites(project_name)))
#
# editable = {}
# for site_name in sites.keys():
# if allowed_sites and site_name not in allowed_sites:
# continue
#
# items = self.get_configurable_items_for_site(project_name,
# site_name,
# scope)
# # Local Settings need 'local' instead of real value
# site_name = site_name.replace(get_local_site_id(), 'local')
# editable[site_name] = items
#
# return editable
#
# def get_configurable_items_for_site(self, project_name=None,
# site_name=None,
# scope=None):
# """
# Returns list of items that could be configurable.
#
# Args:
# project_name (str) - None > default project
# site_name (str)
# scope (list of utils.EditableScope)
# (optional, None is all scopes)
#
# Returns:
# (list)
# [
# {
# key:"root", label:"root", type:"dict",
# "children":[
# { "key": "work",
# "type": "text",
# "value": "c:/projects"}
# ]
# }, ...
# ]
# """
# provider_name = self.get_provider_for_site(site=site_name)
# items = lib.factory.get_provider_configurable_items(provider_name)
#
# if project_name:
# sync_s = self.get_sync_project_setting(project_name,
# exclude_locals=True,
# cached=False)
# else:
# sync_s = get_default_project_settings(exclude_locals=True)
# sync_s = sync_s["global"]["sync_server"]
# sync_s["sites"].update(
# self._get_default_site_configs(self.enabled))
#
# editable = []
# if type(scope) is not list:
# scope = [scope]
# scope = set(scope)
# for key, properties in items.items():
# if scope is None or scope.intersection(set(properties["scope"])):
# val = sync_s.get("sites", {}).get(site_name, {}).get(key)
#
# item = {
# "key": key,
# "label": properties["label"],
# "type": properties["type"]
# }
#
# if properties.get("namespace"):
# item["namespace"] = properties.get("namespace")
# if "platform" in item["namespace"]:
# try:
# if val:
# val = val[platform.system().lower()]
# except KeyError:
# st = "{}'s field value {} should be".format(key, val) # noqa: E501
# log.error(st + " multiplatform dict")
#
# item["namespace"] = item["namespace"].replace('{site}',
# site_name)
# children = []
# if properties["type"] == "dict":
# if val:
# for val_key, val_val in val.items():
# child = {
# "type": "text",
# "key": val_key,
# "value": val_val
# }
# children.append(child)
#
# if properties["type"] == "dict":
# item["children"] = children
# else:
# item["value"] = val
#
# editable.append(item)
#
# return editable
def reset_timer(self):
"""
@ -611,7 +645,7 @@ class SyncServerModule(OpenPypeModule, ITrayModule):
enabled_projects = []
if self.enabled:
for project in self.connection.projects():
for project in self.connection.projects(projection={"name": 1}):
project_name = project["name"]
project_settings = self.get_sync_project_setting(project_name)
if project_settings and project_settings.get("enabled"):

View file

@ -0,0 +1,15 @@
""" Addon class definition and Settings definition must be imported here.
If addon class or settings definition won't be here their definition won't
be found by OpenPype discovery.
"""
from .addon import (
AddonSettingsDef,
ExampleAddon
)
__all__ = (
"AddonSettingsDef",
"ExampleAddon"
)

View file

@ -0,0 +1,132 @@
"""Addon definition is located here.
Import of python packages that may not be available should not be imported
in global space here until are required or used.
- Qt related imports
- imports of Python 3 packages
- we still support Python 2 hosts where addon definition should available
"""
import os
from openpype.modules import (
JsonFilesSettingsDef,
OpenPypeAddOn
)
# Import interface defined by this addon to be able find other addons using it
from openpype_interfaces import (
IExampleInterface,
IPluginPaths,
ITrayAction
)
# Settings definition of this addon using `JsonFilesSettingsDef`
# - JsonFilesSettingsDef is prepared settings definition using json files
# to define settings and store default values
class AddonSettingsDef(JsonFilesSettingsDef):
# This will add prefixes to every schema and template from `schemas`
# subfolder.
# - it is not required to fill the prefix but it is highly
# recommended as schemas and templates may have name clashes across
# multiple addons
# - it is also recommended that prefix has addon name in it
schema_prefix = "example_addon"
def get_settings_root_path(self):
"""Implemented abstract class of JsonFilesSettingsDef.
Return directory path where json files defying addon settings are
located.
"""
return os.path.join(
os.path.dirname(os.path.abspath(__file__)),
"settings"
)
class ExampleAddon(OpenPypeAddOn, IPluginPaths, ITrayAction):
"""This Addon has defined it's settings and interface.
This example has system settings with an enabled option. And use
few other interfaces:
- `IPluginPaths` to define custom plugin paths
- `ITrayAction` to be shown in tray tool
"""
label = "Example Addon"
name = "example_addon"
def initialize(self, settings):
"""Initialization of addon."""
module_settings = settings[self.name]
# Enabled by settings
self.enabled = module_settings.get("enabled", False)
# Prepare variables that can be used or set afterwards
self._connected_modules = None
# UI which must not be created at this time
self._dialog = None
def tray_init(self):
"""Implementation of abstract method for `ITrayAction`.
We're definitely in tray tool so we can pre create dialog.
"""
self._create_dialog()
def connect_with_modules(self, enabled_modules):
"""Method where you should find connected modules.
It is triggered by OpenPype modules manager at the best possible time.
Some addons and modules may required to connect with other modules
before their main logic is executed so changes would require to restart
whole process.
"""
self._connected_modules = []
for module in enabled_modules:
if isinstance(module, IExampleInterface):
self._connected_modules.append(module)
def _create_dialog(self):
# Don't recreate dialog if already exists
if self._dialog is not None:
return
from .widgets import MyExampleDialog
self._dialog = MyExampleDialog()
def show_dialog(self):
"""Show dialog with connected modules.
This can be called from anywhere but can also crash in headless mode.
There is no way to prevent addon to do invalid operations if he's
not handling them.
"""
# Make sure dialog is created
self._create_dialog()
# Change value of dialog by current state
self._dialog.set_connected_modules(self.get_connected_modules())
# Show dialog
self._dialog.open()
def get_connected_modules(self):
"""Custom implementation of addon."""
names = set()
if self._connected_modules is not None:
for module in self._connected_modules:
names.add(module.name)
return names
def on_action_trigger(self):
"""Implementation of abstract method for `ITrayAction`."""
self.show_dialog()
def get_plugin_paths(self):
"""Implementation of abstract method for `IPluginPaths`."""
current_dir = os.path.dirname(os.path.abspath(__file__))
return {
"publish": [os.path.join(current_dir, "plugins", "publish")]
}

View file

@ -0,0 +1,28 @@
""" Using interfaces is one way of connecting multiple OpenPype Addons/Modules.
Interfaces must be in `interfaces.py` file (or folder). Interfaces should not
import module logic or other module in global namespace. That is because
all of them must be imported before all OpenPype AddOns and Modules.
Ideally they should just define abstract and helper methods. If interface
require any logic or connection it should be defined in module.
Keep in mind that attributes and methods will be added to other addon
attributes and methods so they should be unique and ideally contain
addon name in it's name.
"""
from abc import abstractmethod
from openpype.modules import OpenPypeInterface
class IExampleInterface(OpenPypeInterface):
"""Example interface of addon."""
_example_module = None
def get_example_module(self):
return self._example_module
@abstractmethod
def example_method_of_example_interface(self):
pass

View file

@ -0,0 +1,9 @@
import pyblish.api
class CollectExampleAddon(pyblish.api.ContextPlugin):
order = pyblish.api.CollectorOrder + 0.4
label = "Collect Example Addon"
def process(self, context):
self.log.info("I'm in example addon's plugin!")

View file

@ -0,0 +1,15 @@
{
"project_settings/example_addon": {
"number": 0,
"color_1": [
0.0,
0.0,
0.0
],
"color_2": [
0.0,
0.0,
0.0
]
}
}

View file

@ -0,0 +1,5 @@
{
"modules/example_addon": {
"enabled": true
}
}

View file

@ -0,0 +1,6 @@
{
"project_settings/global": {
"type": "schema",
"name": "example_addon/main"
}
}

View file

@ -0,0 +1,6 @@
{
"system_settings/modules": {
"type": "schema",
"name": "example_addon/main"
}
}

View file

@ -0,0 +1,30 @@
{
"type": "dict",
"key": "example_addon",
"label": "Example addon",
"collapsible": true,
"children": [
{
"type": "number",
"key": "number",
"label": "This is your lucky number:",
"minimum": 7,
"maximum": 7,
"decimals": 0
},
{
"type": "template",
"name": "example_addon/the_template",
"template_data": [
{
"name": "color_1",
"label": "Color 1"
},
{
"name": "color_2",
"label": "Color 2"
}
]
}
]
}

View file

@ -0,0 +1,30 @@
[
{
"type": "list-strict",
"key": "{name}",
"label": "{label}",
"object_types": [
{
"label": "Red",
"type": "number",
"minimum": 0,
"maximum": 1,
"decimal": 3
},
{
"label": "Green",
"type": "number",
"minimum": 0,
"maximum": 1,
"decimal": 3
},
{
"label": "Blue",
"type": "number",
"minimum": 0,
"maximum": 1,
"decimal": 3
}
]
}
]

View file

@ -0,0 +1,14 @@
{
"type": "dict",
"key": "example_addon",
"label": "Example addon",
"collapsible": true,
"checkbox_key": "enabled",
"children": [
{
"type": "boolean",
"key": "enabled",
"label": "Enabled"
}
]
}

View file

@ -0,0 +1,39 @@
from Qt import QtWidgets
from openpype.style import load_stylesheet
class MyExampleDialog(QtWidgets.QDialog):
def __init__(self, parent=None):
super(MyExampleDialog, self).__init__(parent)
self.setWindowTitle("Connected modules")
label_widget = QtWidgets.QLabel(self)
ok_btn = QtWidgets.QPushButton("OK", self)
btns_layout = QtWidgets.QHBoxLayout()
btns_layout.addStretch(1)
btns_layout.addWidget(ok_btn)
layout = QtWidgets.QVBoxLayout(self)
layout.addWidget(label_widget)
layout.addLayout(btns_layout)
ok_btn.clicked.connect(self._on_ok_clicked)
self._label_widget = label_widget
self.setStyleSheet(load_stylesheet())
def _on_ok_clicked(self):
self.done(1)
def set_connected_modules(self, connected_modules):
if connected_modules:
message = "\n".join(connected_modules)
else:
message = (
"Other enabled modules/addons are not using my interface."
)
self._label_widget.setText(message)

View file

@ -0,0 +1,9 @@
from openpype.modules import OpenPypeAddOn
class TinyAddon(OpenPypeAddOn):
"""This is tiniest possible addon.
This addon won't do much but will exist in OpenPype modules environment.
"""
name = "tiniest_addon_ever"

View file

@ -14,7 +14,7 @@ class CollectHostName(pyblish.api.ContextPlugin):
"""Collect avalon host name to context."""
label = "Collect Host Name"
order = pyblish.api.CollectorOrder - 1
order = pyblish.api.CollectorOrder - 0.5
def process(self, context):
host_name = context.data.get("hostName")

View file

@ -8,7 +8,7 @@ from openpype.api import get_system_settings
class StopTimer(pyblish.api.ContextPlugin):
label = "Stop Timer"
order = pyblish.api.ExtractorOrder - 0.5
order = pyblish.api.ExtractorOrder - 0.49
hosts = ["*"]
def process(self, context):

View file

@ -1,7 +1,21 @@
from .constants import (
GLOBAL_SETTINGS_KEY,
SYSTEM_SETTINGS_KEY,
PROJECT_SETTINGS_KEY,
PROJECT_ANATOMY_KEY,
LOCAL_SETTING_KEY,
SCHEMA_KEY_SYSTEM_SETTINGS,
SCHEMA_KEY_PROJECT_SETTINGS,
KEY_ALLOWED_SYMBOLS,
KEY_REGEX
)
from .exceptions import (
SaveWarningExc
)
from .lib import (
get_general_environments,
get_system_settings,
get_project_settings,
get_current_project_settings,
@ -16,15 +30,27 @@ from .entities import (
__all__ = (
"GLOBAL_SETTINGS_KEY",
"SYSTEM_SETTINGS_KEY",
"PROJECT_SETTINGS_KEY",
"PROJECT_ANATOMY_KEY",
"LOCAL_SETTING_KEY",
"SCHEMA_KEY_SYSTEM_SETTINGS",
"SCHEMA_KEY_PROJECT_SETTINGS",
"KEY_ALLOWED_SYMBOLS",
"KEY_REGEX",
"SaveWarningExc",
"get_general_environments",
"get_system_settings",
"get_project_settings",
"get_current_project_settings",
"get_anatomy_settings",
"get_environments",
"get_local_settings",
"SystemSettings",
"ProjectSettings"
)

View file

@ -14,13 +14,17 @@ METADATA_KEYS = (
M_DYNAMIC_KEY_LABEL
)
# File where studio's system overrides are stored
# Keys where studio's system overrides are stored
GLOBAL_SETTINGS_KEY = "global_settings"
SYSTEM_SETTINGS_KEY = "system_settings"
PROJECT_SETTINGS_KEY = "project_settings"
PROJECT_ANATOMY_KEY = "project_anatomy"
LOCAL_SETTING_KEY = "local_settings"
# Schema hub names
SCHEMA_KEY_SYSTEM_SETTINGS = "system_schema"
SCHEMA_KEY_PROJECT_SETTINGS = "projects_schema"
DEFAULT_PROJECT_KEY = "__default_project__"
KEY_ALLOWED_SYMBOLS = "a-zA-Z0-9-_ "
@ -39,6 +43,9 @@ __all__ = (
"PROJECT_ANATOMY_KEY",
"LOCAL_SETTING_KEY",
"SCHEMA_KEY_SYSTEM_SETTINGS",
"SCHEMA_KEY_PROJECT_SETTINGS",
"DEFAULT_PROJECT_KEY",
"KEY_ALLOWED_SYMBOLS",

View file

@ -1,4 +1,9 @@
{
"addon_paths": {
"windows": [],
"darwin": [],
"linux": []
},
"avalon": {
"AVALON_TIMEOUT": 1000,
"AVALON_THUMBNAIL_ROOT": {

View file

@ -105,7 +105,6 @@ from .enum_entity import (
AppsEnumEntity,
ToolsEnumEntity,
TaskTypeEnumEntity,
ProvidersEnum,
DeadlineUrlEnumEntity,
AnatomyTemplatesEnumEntity
)
@ -113,7 +112,10 @@ from .enum_entity import (
from .list_entity import ListEntity
from .dict_immutable_keys_entity import DictImmutableKeysEntity
from .dict_mutable_keys_entity import DictMutableKeysEntity
from .dict_conditional import DictConditionalEntity
from .dict_conditional import (
DictConditionalEntity,
SyncServerProviders
)
from .anatomy_entities import AnatomyEntity
@ -161,7 +163,6 @@ __all__ = (
"AppsEnumEntity",
"ToolsEnumEntity",
"TaskTypeEnumEntity",
"ProvidersEnum",
"DeadlineUrlEnumEntity",
"AnatomyTemplatesEnumEntity",
@ -172,6 +173,7 @@ __all__ = (
"DictMutableKeysEntity",
"DictConditionalEntity",
"SyncServerProviders",
"AnatomyEntity"
)

View file

@ -104,6 +104,12 @@ class BaseItemEntity(BaseEntity):
self.is_group = False
# Entity's value will be stored into file with name of it's key
self.is_file = False
# Default values are not stored to an openpype file
# - these must not be set through schemas directly
self.dynamic_schema_id = None
self.is_dynamic_schema_node = False
self.is_in_dynamic_schema_node = False
# Reference to parent entity which has `is_group` == True
# - stays as None if none of parents is group
self.group_item = None
@ -255,13 +261,22 @@ class BaseItemEntity(BaseEntity):
)
# Group item can be only once in on hierarchy branch.
if self.is_group and self.group_item:
if self.is_group and self.group_item is not None:
raise SchemeGroupHierarchyBug(self)
# Group item can be only once in on hierarchy branch.
if self.group_item is not None and self.is_dynamic_schema_node:
reason = (
"Dynamic schema is inside grouped item {}."
" Change group hierarchy or remove dynamic"
" schema to be able work properly."
).format(self.group_item.path)
raise EntitySchemaError(self, reason)
# Validate that env group entities will be stored into file.
# - env group entities must store metadata which is not possible if
# metadata would be outside of file
if not self.file_item and self.is_env_group:
if self.file_item is None and self.is_env_group:
reason = (
"Environment item is not inside file"
" item so can't store metadata for defaults."
@ -478,7 +493,15 @@ class BaseItemEntity(BaseEntity):
@abstractmethod
def settings_value(self):
"""Value of an item without key."""
"""Value of an item without key without dynamic items."""
pass
@abstractmethod
def collect_dynamic_schema_entities(self):
"""Collect entities that are on top of dynamically added schemas.
This method make sence only when defaults are saved.
"""
pass
@abstractmethod
@ -808,6 +831,12 @@ class ItemEntity(BaseItemEntity):
self.is_dynamic_item = is_dynamic_item
self.is_file = self.schema_data.get("is_file", False)
# These keys have underscore as they must not be set in schemas
self.dynamic_schema_id = self.schema_data.get(
"_dynamic_schema_id", None
)
self.is_dynamic_schema_node = self.dynamic_schema_id is not None
self.is_group = self.schema_data.get("is_group", False)
self.is_in_dynamic_item = bool(
not self.is_dynamic_item
@ -837,10 +866,20 @@ class ItemEntity(BaseItemEntity):
self._require_restart_on_change = require_restart_on_change
# File item reference
if self.parent.is_file:
self.file_item = self.parent
elif self.parent.file_item:
self.file_item = self.parent.file_item
if not self.is_dynamic_schema_node:
self.is_in_dynamic_schema_node = (
self.parent.is_dynamic_schema_node
or self.parent.is_in_dynamic_schema_node
)
if (
not self.is_dynamic_schema_node
and not self.is_in_dynamic_schema_node
):
if self.parent.is_file:
self.file_item = self.parent
elif self.parent.file_item:
self.file_item = self.parent.file_item
# Group item reference
if self.parent.is_group:
@ -891,6 +930,18 @@ class ItemEntity(BaseItemEntity):
def root_key(self):
return self.root_item.root_key
@abstractmethod
def collect_dynamic_schema_entities(self, collector):
"""Collect entities that are on top of dynamically added schemas.
This method make sence only when defaults are saved.
Args:
collector(DynamicSchemaValueCollector): Object where dynamic
entities are stored.
"""
pass
def schema_validations(self):
if not self.label and self.use_label_wrap:
reason = (
@ -899,7 +950,12 @@ class ItemEntity(BaseItemEntity):
)
raise EntitySchemaError(self, reason)
if self.is_file and self.file_item is not None:
if (
not self.is_dynamic_schema_node
and not self.is_in_dynamic_schema_node
and self.is_file
and self.file_item is not None
):
reason = (
"Entity has set `is_file` to true but"
" it's parent is already marked as file item."

View file

@ -469,6 +469,10 @@ class DictConditionalEntity(ItemEntity):
return True
return False
def collect_dynamic_schema_entities(self, collector):
if self.is_dynamic_schema_node:
collector.add_entity(self)
def settings_value(self):
if self._override_state is OverrideState.NOT_DEFINED:
return NOT_SET
@ -482,13 +486,7 @@ class DictConditionalEntity(ItemEntity):
output = {}
for key, child_obj in children_items:
child_value = child_obj.settings_value()
if not child_obj.is_file and not child_obj.file_item:
for _key, _value in child_value.items():
new_key = "/".join([key, _key])
output[new_key] = _value
else:
output[key] = child_value
output[key] = child_obj.settings_value()
return output
if self.is_group:
@ -726,3 +724,49 @@ class DictConditionalEntity(ItemEntity):
for children in self.children.values():
for child_entity in children:
child_entity.reset_callbacks()
class SyncServerProviders(DictConditionalEntity):
schema_types = ["sync-server-providers"]
def _add_children(self):
self.enum_key = "provider"
self.enum_label = "Provider"
enum_children = self._get_enum_children()
if not enum_children:
enum_children.append({
"key": None,
"label": "< Nothing >"
})
self.enum_children = enum_children
super(SyncServerProviders, self)._add_children()
def _get_enum_children(self):
from openpype_modules import sync_server
from openpype_modules.sync_server.providers import lib as lib_providers
provider_code_to_label = {}
providers = lib_providers.factory.providers
for provider_code, provider_info in providers.items():
provider, _ = provider_info
provider_code_to_label[provider_code] = provider.LABEL
system_settings_schema = (
sync_server
.SyncServerModule
.get_system_settings_schema()
)
enum_children = []
for provider_code, configurables in system_settings_schema.items():
label = provider_code_to_label.get(provider_code) or provider_code
enum_children.append({
"key": provider_code,
"label": label,
"children": configurables
})
return enum_children

View file

@ -330,15 +330,32 @@ class DictImmutableKeysEntity(ItemEntity):
return True
return False
def collect_dynamic_schema_entities(self, collector):
for child_obj in self.non_gui_children.values():
child_obj.collect_dynamic_schema_entities(collector)
if self.is_dynamic_schema_node:
collector.add_entity(self)
def settings_value(self):
if self._override_state is OverrideState.NOT_DEFINED:
return NOT_SET
if self._override_state is OverrideState.DEFAULTS:
is_dynamic_schema_node = (
self.is_dynamic_schema_node or self.is_in_dynamic_schema_node
)
output = {}
for key, child_obj in self.non_gui_children.items():
if child_obj.is_dynamic_schema_node:
continue
child_value = child_obj.settings_value()
if not child_obj.is_file and not child_obj.file_item:
if (
not is_dynamic_schema_node
and not child_obj.is_file
and not child_obj.file_item
):
for _key, _value in child_value.items():
new_key = "/".join([key, _key])
output[new_key] = _value

View file

@ -261,7 +261,7 @@ class DictMutableKeysEntity(EndpointEntity):
raise EntitySchemaError(self, reason)
# TODO Ability to store labels should be defined with different key
if self.collapsible_key and not self.file_item:
if self.collapsible_key and self.file_item is None:
reason = (
"Modifiable dictionary with collapsible keys is not under"
" file item so can't store metadata."

View file

@ -376,11 +376,16 @@ class TaskTypeEnumEntity(BaseEnumEntity):
schema_types = ["task-types-enum"]
def _item_initalization(self):
self.multiselection = True
self.value_on_not_set = []
self.multiselection = self.schema_data.get("multiselection", True)
if self.multiselection:
self.valid_value_types = (list, )
self.value_on_not_set = []
else:
self.valid_value_types = (STRING_TYPE, )
self.value_on_not_set = ""
self.enum_items = []
self.valid_keys = set()
self.valid_value_types = (list, )
self.placeholder = None
def _get_enum_values(self):
@ -396,53 +401,51 @@ class TaskTypeEnumEntity(BaseEnumEntity):
return enum_items, valid_keys
def _convert_value_for_current_state(self, source_value):
if self.multiselection:
output = []
for key in source_value:
if key in self.valid_keys:
output.append(key)
return output
if source_value not in self.valid_keys:
# Take first item from enum items
for item in self.enum_items:
for key in item.keys():
source_value = key
break
return source_value
def set_override_state(self, *args, **kwargs):
super(TaskTypeEnumEntity, self).set_override_state(*args, **kwargs)
self.enum_items, self.valid_keys = self._get_enum_values()
new_value = []
for key in self._current_value:
if key in self.valid_keys:
new_value.append(key)
self._current_value = new_value
if self.multiselection:
new_value = []
for key in self._current_value:
if key in self.valid_keys:
new_value.append(key)
class ProvidersEnum(BaseEnumEntity):
schema_types = ["providers-enum"]
if self._current_value != new_value:
self.set(new_value)
else:
if not self.enum_items:
self.valid_keys.add("")
self.enum_items.append({"": "< Empty >"})
def _item_initalization(self):
self.multiselection = False
self.value_on_not_set = ""
self.enum_items = []
self.valid_keys = set()
self.valid_value_types = (str, )
self.placeholder = None
for item in self.enum_items:
for key in item.keys():
value_on_not_set = key
break
def _get_enum_values(self):
from openpype_modules.sync_server.providers import lib as lib_providers
providers = lib_providers.factory.providers
valid_keys = set()
valid_keys.add('')
enum_items = [{'': 'Choose Provider'}]
for provider_code, provider_info in providers.items():
provider, _ = provider_info
enum_items.append({provider_code: provider.LABEL})
valid_keys.add(provider_code)
return enum_items, valid_keys
def set_override_state(self, *args, **kwargs):
super(ProvidersEnum, self).set_override_state(*args, **kwargs)
self.enum_items, self.valid_keys = self._get_enum_values()
value_on_not_set = list(self.valid_keys)[0]
if self._current_value is NOT_SET:
self._current_value = value_on_not_set
self.value_on_not_set = value_on_not_set
self.value_on_not_set = value_on_not_set
if (
self._current_value is NOT_SET
or self._current_value not in self.valid_keys
):
self.set(value_on_not_set)
class DeadlineUrlEnumEntity(BaseEnumEntity):

View file

@ -49,6 +49,10 @@ class EndpointEntity(ItemEntity):
super(EndpointEntity, self).schema_validations()
def collect_dynamic_schema_entities(self, collector):
if self.is_dynamic_schema_node:
collector.add_entity(self)
@abstractmethod
def _settings_value(self):
pass
@ -121,7 +125,11 @@ class InputEntity(EndpointEntity):
def schema_validations(self):
# Input entity must have file parent.
if not self.file_item:
if (
not self.is_dynamic_schema_node
and not self.is_in_dynamic_schema_node
and self.file_item is None
):
raise EntitySchemaError(self, "Missing parent file entity.")
super(InputEntity, self).schema_validations()
@ -369,6 +377,14 @@ class NumberEntity(InputEntity):
self.valid_value_types = valid_value_types
self.value_on_not_set = value_on_not_set
# UI specific attributes
self.show_slider = self.schema_data.get("show_slider", False)
steps = self.schema_data.get("steps", None)
# Make sure that steps are not set to `0`
if steps == 0:
steps = None
self.steps = steps
def _convert_to_valid_type(self, value):
if isinstance(value, str):
new_value = None

View file

@ -115,6 +115,9 @@ class PathEntity(ItemEntity):
def set(self, value):
self.child_obj.set(value)
def collect_dynamic_schema_entities(self, *args, **kwargs):
self.child_obj.collect_dynamic_schema_entities(*args, **kwargs)
def settings_value(self):
if self._override_state is OverrideState.NOT_DEFINED:
return NOT_SET
@ -236,7 +239,12 @@ class ListStrictEntity(ItemEntity):
def schema_validations(self):
# List entity must have file parent.
if not self.file_item and not self.is_file:
if (
not self.is_dynamic_schema_node
and not self.is_in_dynamic_schema_node
and not self.is_file
and self.file_item is None
):
raise EntitySchemaError(
self, "Missing file entity in hierarchy."
)
@ -279,6 +287,10 @@ class ListStrictEntity(ItemEntity):
for idx, item in enumerate(new_value):
self.children[idx].set(item)
def collect_dynamic_schema_entities(self, collector):
if self.is_dynamic_schema_node:
collector.add_entity(self)
def settings_value(self):
if self._override_state is OverrideState.NOT_DEFINED:
return NOT_SET

View file

@ -3,6 +3,7 @@ import re
import json
import copy
import inspect
import collections
import contextlib
from .exceptions import (
@ -10,6 +11,12 @@ from .exceptions import (
SchemaDuplicatedEnvGroupKeys
)
from openpype.settings.constants import (
SYSTEM_SETTINGS_KEY,
PROJECT_SETTINGS_KEY,
SCHEMA_KEY_SYSTEM_SETTINGS,
SCHEMA_KEY_PROJECT_SETTINGS
)
try:
STRING_TYPE = basestring
except Exception:
@ -24,6 +31,10 @@ TEMPLATE_METADATA_KEYS = (
DEFAULT_VALUES_KEY,
)
SCHEMA_EXTEND_TYPES = (
"schema", "template", "schema_template", "dynamic_schema"
)
template_key_pattern = re.compile(r"(\{.*?[^{0]*\})")
@ -102,8 +113,8 @@ class OverrideState:
class SchemasHub:
def __init__(self, schema_subfolder, reset=True):
self._schema_subfolder = schema_subfolder
def __init__(self, schema_type, reset=True):
self._schema_type = schema_type
self._loaded_types = {}
self._gui_types = tuple()
@ -112,25 +123,60 @@ class SchemasHub:
self._loaded_templates = {}
self._loaded_schemas = {}
# Attributes for modules settings
self._dynamic_schemas_defs_by_id = {}
self._dynamic_schemas_by_id = {}
# Store validating and validated dynamic template or schemas
self._validating_dynamic = set()
self._validated_dynamic = set()
# It doesn't make sence to reload types on each reset as they can't be
# changed
self._load_types()
# Trigger reset
if reset:
self.reset()
@property
def schema_type(self):
return self._schema_type
def reset(self):
self._load_modules_settings_defs()
self._load_types()
self._load_schemas()
def _load_modules_settings_defs(self):
from openpype.modules import get_module_settings_defs
module_settings_defs = get_module_settings_defs()
for module_settings_def_cls in module_settings_defs:
module_settings_def = module_settings_def_cls()
def_id = module_settings_def.id
self._dynamic_schemas_defs_by_id[def_id] = module_settings_def
@property
def gui_types(self):
return self._gui_types
def resolve_dynamic_schema(self, dynamic_key):
output = []
for def_id, def_keys in self._dynamic_schemas_by_id.items():
if dynamic_key in def_keys:
def_schema = def_keys[dynamic_key]
if not def_schema:
continue
if isinstance(def_schema, dict):
def_schema = [def_schema]
all_def_schema = []
for item in def_schema:
items = self.resolve_schema_data(item)
for _item in items:
_item["_dynamic_schema_id"] = def_id
all_def_schema.extend(items)
output.extend(all_def_schema)
return output
def get_template_name(self, item_def, default=None):
"""Get template name from passed item definition.
@ -260,7 +306,7 @@ class SchemasHub:
list: Resolved schema data.
"""
schema_type = schema_data["type"]
if schema_type not in ("schema", "template", "schema_template"):
if schema_type not in SCHEMA_EXTEND_TYPES:
return [schema_data]
if schema_type == "schema":
@ -268,6 +314,9 @@ class SchemasHub:
self.get_schema(schema_data["name"])
)
if schema_type == "dynamic_schema":
return self.resolve_dynamic_schema(schema_data["name"])
template_name = schema_data["name"]
template_def = self.get_template(template_name)
@ -368,14 +417,16 @@ class SchemasHub:
self._crashed_on_load = {}
self._loaded_templates = {}
self._loaded_schemas = {}
self._dynamic_schemas_by_id = {}
dirpath = os.path.join(
os.path.dirname(os.path.abspath(__file__)),
"schemas",
self._schema_subfolder
self.schema_type
)
loaded_schemas = {}
loaded_templates = {}
dynamic_schemas_by_id = {}
for root, _, filenames in os.walk(dirpath):
for filename in filenames:
basename, ext = os.path.splitext(filename)
@ -425,8 +476,34 @@ class SchemasHub:
)
loaded_schemas[basename] = schema_data
defs_iter = self._dynamic_schemas_defs_by_id.items()
for def_id, module_settings_def in defs_iter:
dynamic_schemas_by_id[def_id] = (
module_settings_def.get_dynamic_schemas(self.schema_type)
)
module_schemas = module_settings_def.get_settings_schemas(
self.schema_type
)
for key, schema_data in module_schemas.items():
if isinstance(schema_data, list):
if key in loaded_templates:
raise KeyError(
"Duplicated template key \"{}\"".format(key)
)
loaded_templates[key] = schema_data
else:
if key in loaded_schemas:
raise KeyError(
"Duplicated schema key \"{}\"".format(key)
)
loaded_schemas[key] = schema_data
self._loaded_templates = loaded_templates
self._loaded_schemas = loaded_schemas
self._dynamic_schemas_by_id = dynamic_schemas_by_id
def get_dynamic_modules_settings_defs(self, schema_def_id):
return self._dynamic_schemas_defs_by_id.get(schema_def_id)
def _fill_template(self, child_data, template_def):
"""Fill template based on schema definition and template definition.
@ -660,3 +737,38 @@ class SchemasHub:
if found_idx is not None:
metadata_item = template_def.pop(found_idx)
return metadata_item
class DynamicSchemaValueCollector:
# Map schema hub type to store keys
schema_hub_type_map = {
SCHEMA_KEY_SYSTEM_SETTINGS: SYSTEM_SETTINGS_KEY,
SCHEMA_KEY_PROJECT_SETTINGS: PROJECT_SETTINGS_KEY
}
def __init__(self, schema_hub):
self._schema_hub = schema_hub
self._dynamic_entities = []
def add_entity(self, entity):
self._dynamic_entities.append(entity)
def create_hierarchy(self):
output = collections.defaultdict(dict)
for entity in self._dynamic_entities:
output[entity.dynamic_schema_id][entity.path] = (
entity.settings_value()
)
return output
def save_values(self):
hierarchy = self.create_hierarchy()
for schema_def_id, schema_def_value in hierarchy.items():
schema_def = self._schema_hub.get_dynamic_modules_settings_defs(
schema_def_id
)
top_key = self.schema_hub_type_map.get(
self._schema_hub.schema_type
)
schema_def.save_defaults(top_key, schema_def_value)

View file

@ -9,8 +9,11 @@ from .base_entity import BaseItemEntity
from .lib import (
NOT_SET,
WRAPPER_TYPES,
SCHEMA_KEY_SYSTEM_SETTINGS,
SCHEMA_KEY_PROJECT_SETTINGS,
OverrideState,
SchemasHub
SchemasHub,
DynamicSchemaValueCollector
)
from .exceptions import (
SchemaError,
@ -28,6 +31,7 @@ from openpype.settings.lib import (
DEFAULTS_DIR,
get_default_settings,
reset_default_settings,
get_studio_system_settings_overrides,
save_studio_settings,
@ -265,6 +269,16 @@ class RootEntity(BaseItemEntity):
output[key] = child_obj.value
return output
def collect_dynamic_schema_entities(self):
output = DynamicSchemaValueCollector(self.schema_hub)
if self._override_state is not OverrideState.DEFAULTS:
return output
for child_obj in self.non_gui_children.values():
child_obj.collect_dynamic_schema_entities(output)
return output
def settings_value(self):
"""Value for current override state with metadata.
@ -276,6 +290,8 @@ class RootEntity(BaseItemEntity):
if self._override_state is not OverrideState.DEFAULTS:
output = {}
for key, child_obj in self.non_gui_children.items():
if child_obj.is_dynamic_schema_node:
continue
value = child_obj.settings_value()
if value is not NOT_SET:
output[key] = value
@ -374,6 +390,7 @@ class RootEntity(BaseItemEntity):
if self._override_state is OverrideState.DEFAULTS:
self._save_default_values()
reset_default_settings()
elif self._override_state is OverrideState.STUDIO:
self._save_studio_values()
@ -421,6 +438,9 @@ class RootEntity(BaseItemEntity):
with open(output_path, "w") as file_stream:
json.dump(value, file_stream, indent=4)
dynamic_values_item = self.collect_dynamic_schema_entities()
dynamic_values_item.save_values()
@abstractmethod
def _save_studio_values(self):
"""Save studio override values."""
@ -476,7 +496,7 @@ class SystemSettings(RootEntity):
):
if schema_hub is None:
# Load system schemas
schema_hub = SchemasHub("system_schema")
schema_hub = SchemasHub(SCHEMA_KEY_SYSTEM_SETTINGS)
super(SystemSettings, self).__init__(schema_hub, reset)
@ -607,7 +627,7 @@ class ProjectSettings(RootEntity):
if schema_hub is None:
# Load system schemas
schema_hub = SchemasHub("projects_schema")
schema_hub = SchemasHub(SCHEMA_KEY_PROJECT_SETTINGS)
super(ProjectSettings, self).__init__(schema_hub, reset)

View file

@ -112,6 +112,22 @@
```
- It is possible to define default values for unfilled fields to do so one of items in list must be dictionary with key `"__default_values__"` and value as dictionary with default key: values (as in example above).
### dynamic_schema
- dynamic templates that can be defined by class of `ModuleSettingsDef`
- example:
```
{
"type": "dynamic_schema",
"name": "project_settings/global"
}
```
- all valid `ModuleSettingsDef` classes where calling of `get_settings_schemas`
will return dictionary where is key "project_settings/global" with schemas
will extend and replace this item
- works almost the same way as templates
- one item can be replaced by multiple items (or by 0 items)
- goal is to dynamically loaded settings of OpenPype addons without having
their schemas or default values in main repository
## Basic Dictionary inputs
- these inputs wraps another inputs into {key: value} relation
@ -300,6 +316,8 @@ How output of the schema could look like on save:
- key `"decimal"` defines how many decimal places will be used, 0 is for integer input (Default: `0`)
- key `"minimum"` as minimum allowed number to enter (Default: `-99999`)
- key `"maxium"` as maximum allowed number to enter (Default: `99999`)
- key `"steps"` will change single step value of UI inputs (using arrows and wheel scroll)
- for UI it is possible to show slider to enable this option set `show_slider` to `true`
```
{
"type": "number",
@ -311,6 +329,18 @@ How output of the schema could look like on save:
}
```
```
{
"type": "number",
"key": "ratio",
"label": "Ratio"
"decimal": 3,
"minimum": 0,
"maximum": 1,
"show_slider": true
}
```
### text
- simple text input
- key `"multiline"` allows to enter multiple lines of text (Default: `False`)

View file

@ -125,6 +125,10 @@
{
"type": "schema",
"name": "schema_project_unreal"
},
{
"type": "dynamic_schema",
"name": "project_settings/global"
}
]
}

View file

@ -183,6 +183,15 @@
"minimum": -10,
"maximum": -5
},
{
"type": "number",
"key": "number_with_slider",
"label": "Number with slider",
"decimal": 2,
"minimum": 0.0,
"maximum": 1.0,
"show_slider": true
},
{
"type": "text",
"key": "singleline_text",

View file

@ -5,6 +5,18 @@
"collapsible": true,
"is_file": true,
"children": [
{
"type": "path",
"key": "addon_paths",
"label": "OpenPype AddOn Paths",
"use_label_wrap": true,
"multiplatform": true,
"multipath": true,
"require_restart": true
},
{
"type": "separator"
},
{
"type": "dict",
"key": "avalon",
@ -16,7 +28,8 @@
"type": "number",
"key": "AVALON_TIMEOUT",
"minimum": 0,
"label": "Avalon Mongo Timeout (ms)"
"label": "Avalon Mongo Timeout (ms)",
"steps": 100
},
{
"type": "path",
@ -109,14 +122,7 @@
"collapsible_key": false,
"object_type":
{
"type": "dict",
"children": [
{
"type": "providers-enum",
"key": "provider",
"label": "Provider"
}
]
"type": "sync-server-providers"
}
}
]
@ -230,6 +236,10 @@
"label": "Enabled"
}
]
},
{
"type": "dynamic_schema",
"name": "system_settings/modules"
}
]
}

View file

@ -329,6 +329,45 @@ def reset_default_settings():
_DEFAULT_SETTINGS = None
def _get_default_settings():
from openpype.modules import get_module_settings_defs
defaults = load_openpype_default_settings()
module_settings_defs = get_module_settings_defs()
for module_settings_def_cls in module_settings_defs:
module_settings_def = module_settings_def_cls()
system_defaults = module_settings_def.get_defaults(
SYSTEM_SETTINGS_KEY
) or {}
for path, value in system_defaults.items():
if not path:
continue
subdict = defaults["system_settings"]
path_items = list(path.split("/"))
last_key = path_items.pop(-1)
for key in path_items:
subdict = subdict[key]
subdict[last_key] = value
project_defaults = module_settings_def.get_defaults(
PROJECT_SETTINGS_KEY
) or {}
for path, value in project_defaults.items():
if not path:
continue
subdict = defaults
path_items = list(path.split("/"))
last_key = path_items.pop(-1)
for key in path_items:
subdict = subdict[key]
subdict[last_key] = value
return defaults
def get_default_settings():
"""Get default settings.
@ -338,12 +377,10 @@ def get_default_settings():
Returns:
dict: Loaded default settings.
"""
# TODO add cacher
return load_openpype_default_settings()
# global _DEFAULT_SETTINGS
# if _DEFAULT_SETTINGS is None:
# _DEFAULT_SETTINGS = load_jsons_from_dir(DEFAULTS_DIR)
# return copy.deepcopy(_DEFAULT_SETTINGS)
global _DEFAULT_SETTINGS
if _DEFAULT_SETTINGS is None:
_DEFAULT_SETTINGS = _get_default_settings()
return copy.deepcopy(_DEFAULT_SETTINGS)
def load_json_file(fpath):
@ -380,8 +417,8 @@ def load_jsons_from_dir(path, *args, **kwargs):
"data1": "CONTENT OF FILE"
},
"folder2": {
"data1": {
"subfolder1": "CONTENT OF FILE"
"subfolder1": {
"data2": "CONTENT OF FILE"
}
}
}

View file

@ -21,6 +21,7 @@ from .base import (
BaseWidget,
InputWidget
)
from openpype.widgets.sliders import NiceSlider
from openpype.tools.settings import CHILD_OFFSET
@ -404,21 +405,53 @@ class TextWidget(InputWidget):
class NumberWidget(InputWidget):
_slider_widget = None
def _add_inputs_to_layout(self):
kwargs = {
"minimum": self.entity.minimum,
"maximum": self.entity.maximum,
"decimal": self.entity.decimal
"decimal": self.entity.decimal,
"steps": self.entity.steps
}
self.input_field = NumberSpinBox(self.content_widget, **kwargs)
input_field_stretch = 1
slider_multiplier = 1
if self.entity.show_slider:
# Slider can't handle float numbers so all decimals are converted
# to integer range.
slider_multiplier = 10 ** self.entity.decimal
slider_widget = NiceSlider(QtCore.Qt.Horizontal, self)
slider_widget.setRange(
int(self.entity.minimum * slider_multiplier),
int(self.entity.maximum * slider_multiplier)
)
if self.entity.steps is not None:
slider_widget.setSingleStep(
self.entity.steps * slider_multiplier
)
self.content_layout.addWidget(slider_widget, 1)
slider_widget.valueChanged.connect(self._on_slider_change)
self._slider_widget = slider_widget
input_field_stretch = 0
self._slider_multiplier = slider_multiplier
self.setFocusProxy(self.input_field)
self.content_layout.addWidget(self.input_field, 1)
self.content_layout.addWidget(self.input_field, input_field_stretch)
self.input_field.valueChanged.connect(self._on_value_change)
self.input_field.focused_in.connect(self._on_input_focus)
self._ignore_slider_change = False
self._ignore_input_change = False
def _on_input_focus(self):
self.focused_in()
@ -429,10 +462,25 @@ class NumberWidget(InputWidget):
def set_entity_value(self):
self.input_field.setValue(self.entity.value)
def _on_slider_change(self, new_value):
if self._ignore_slider_change:
return
self._ignore_input_change = True
self.input_field.setValue(new_value / self._slider_multiplier)
self._ignore_input_change = False
def _on_value_change(self):
if self.ignore_input_changes:
return
self.entity.set(self.input_field.value())
value = self.input_field.value()
if self._slider_widget is not None and not self._ignore_input_change:
self._ignore_slider_change = True
self._slider_widget.setValue(value * self._slider_multiplier)
self._ignore_slider_change = False
self.entity.set(value)
class RawJsonInput(SettingsPlainTextEdit):

View file

@ -114,6 +114,30 @@ QPushButton[btn-type="expand-toggle"] {
background: #21252B;
}
/* SLider */
QSlider::groove {
border: 1px solid #464b54;
border-radius: 0.3em;
}
QSlider::groove:horizontal {
height: 8px;
}
QSlider::groove:vertical {
width: 8px;
}
QSlider::handle {
width: 10px;
height: 10px;
border-radius: 5px;
}
QSlider::handle:horizontal {
margin: -2px 0;
}
QSlider::handle:vertical {
margin: 0 -2px;
}
#GroupWidget {
border-bottom: 1px solid #21252B;
}

View file

@ -92,11 +92,15 @@ class NumberSpinBox(QtWidgets.QDoubleSpinBox):
min_value = kwargs.pop("minimum", -99999)
max_value = kwargs.pop("maximum", 99999)
decimals = kwargs.pop("decimal", 0)
steps = kwargs.pop("steps", None)
super(NumberSpinBox, self).__init__(*args, **kwargs)
self.setFocusPolicy(QtCore.Qt.StrongFocus)
self.setDecimals(decimals)
self.setMinimum(min_value)
self.setMaximum(max_value)
if steps is not None:
self.setSingleStep(steps)
def focusInEvent(self, event):
super(NumberSpinBox, self).focusInEvent(event)

View file

@ -430,7 +430,6 @@ class FilesWidget(QtWidgets.QWidget):
# Pype's anatomy object for current project
self.anatomy = Anatomy(io.Session["AVALON_PROJECT"])
# Template key used to get work template from anatomy templates
# TODO change template key based on task
self.template_key = "work"
# This is not root but workfile directory

139
openpype/widgets/sliders.py Normal file
View file

@ -0,0 +1,139 @@
from Qt import QtWidgets, QtCore, QtGui
class NiceSlider(QtWidgets.QSlider):
def __init__(self, *args, **kwargs):
super(NiceSlider, self).__init__(*args, **kwargs)
self._mouse_clicked = False
self._handle_size = 0
self._bg_brush = QtGui.QBrush(QtGui.QColor("#21252B"))
self._fill_brush = QtGui.QBrush(QtGui.QColor("#5cadd6"))
def mousePressEvent(self, event):
self._mouse_clicked = True
if event.button() == QtCore.Qt.LeftButton:
self._set_value_to_pos(event.pos())
return event.accept()
return super(NiceSlider, self).mousePressEvent(event)
def mouseMoveEvent(self, event):
if self._mouse_clicked:
self._set_value_to_pos(event.pos())
super(NiceSlider, self).mouseMoveEvent(event)
def mouseReleaseEvent(self, event):
self._mouse_clicked = True
super(NiceSlider, self).mouseReleaseEvent(event)
def _set_value_to_pos(self, pos):
if self.orientation() == QtCore.Qt.Horizontal:
self._set_value_to_pos_x(pos.x())
else:
self._set_value_to_pos_y(pos.y())
def _set_value_to_pos_x(self, pos_x):
_range = self.maximum() - self.minimum()
handle_size = self._handle_size
half_handle = handle_size / 2
pos_x -= half_handle
width = self.width() - handle_size
value = ((_range * pos_x) / width) + self.minimum()
self.setValue(value)
def _set_value_to_pos_y(self, pos_y):
_range = self.maximum() - self.minimum()
handle_size = self._handle_size
half_handle = handle_size / 2
pos_y = self.height() - pos_y - half_handle
height = self.height() - handle_size
value = (_range * pos_y / height) + self.minimum()
self.setValue(value)
def paintEvent(self, event):
painter = QtGui.QPainter(self)
opt = QtWidgets.QStyleOptionSlider()
self.initStyleOption(opt)
painter.fillRect(event.rect(), QtCore.Qt.transparent)
painter.setRenderHint(QtGui.QPainter.HighQualityAntialiasing)
horizontal = self.orientation() == QtCore.Qt.Horizontal
rect = self.style().subControlRect(
QtWidgets.QStyle.CC_Slider,
opt,
QtWidgets.QStyle.SC_SliderGroove,
self
)
_range = self.maximum() - self.minimum()
_offset = self.value() - self.minimum()
if horizontal:
_handle_half = rect.height() / 2
_handle_size = _handle_half * 2
width = rect.width() - _handle_size
pos_x = ((width / _range) * _offset)
pos_y = rect.center().y() - _handle_half + 1
else:
_handle_half = rect.width() / 2
_handle_size = _handle_half * 2
height = rect.height() - _handle_size
pos_x = rect.center().x() - _handle_half + 1
pos_y = height - ((height / _range) * _offset)
handle_rect = QtCore.QRect(
pos_x, pos_y, _handle_size, _handle_size
)
self._handle_size = _handle_size
_offset = 2
_size = _handle_size - _offset
if horizontal:
if rect.height() > _size:
new_rect = QtCore.QRect(0, 0, rect.width(), _size)
center_point = QtCore.QPoint(
rect.center().x(), handle_rect.center().y()
)
new_rect.moveCenter(center_point)
rect = new_rect
ratio = rect.height() / 2
fill_rect = QtCore.QRect(
rect.x(),
rect.y(),
handle_rect.right() - rect.x(),
rect.height()
)
else:
if rect.width() > _size:
new_rect = QtCore.QRect(0, 0, _size, rect.height())
center_point = QtCore.QPoint(
handle_rect.center().x(), rect.center().y()
)
new_rect.moveCenter(center_point)
rect = new_rect
ratio = rect.width() / 2
fill_rect = QtCore.QRect(
rect.x(),
handle_rect.y(),
rect.width(),
rect.height() - handle_rect.y(),
)
painter.save()
painter.setPen(QtCore.Qt.NoPen)
painter.setBrush(self._bg_brush)
painter.drawRoundedRect(rect, ratio, ratio)
painter.setBrush(self._fill_brush)
painter.drawRoundedRect(fill_rect, ratio, ratio)
painter.setPen(QtCore.Qt.NoPen)
painter.setBrush(self._fill_brush)
painter.drawEllipse(handle_rect)
painter.restore()

@ -1 +1 @@
Subproject commit f48fce09c0986c1fd7f6731de33907be46b436c5
Subproject commit b3e49597786c931c13bca207769727d5fc56d5f6

View file

@ -179,6 +179,12 @@ else:
ssl_cert_file = certifi.where()
os.environ["SSL_CERT_FILE"] = ssl_cert_file
if "--headless" in sys.argv:
os.environ["OPENPYPE_HEADLESS_MODE"] = "1"
sys.argv.remove("--headless")
else:
if os.getenv("OPENPYPE_HEADLESS_MODE") != "1":
os.environ.pop("OPENPYPE_HEADLESS_MODE", None)
import igniter # noqa: E402
from igniter import BootstrapRepos # noqa: E402
@ -343,7 +349,7 @@ def _process_arguments() -> tuple:
# check for `--use-version=3.0.0` argument and `--use-staging`
use_version = None
use_staging = False
print_versions = False
commands = []
for arg in sys.argv:
if arg == "--use-version":
_print("!!! Please use option --use-version like:")
@ -366,17 +372,38 @@ def _process_arguments() -> tuple:
" proper version string."))
sys.exit(1)
if arg == "--validate-version":
_print("!!! Please use option --validate-version like:")
_print(" --validate-version=3.0.0")
sys.exit(1)
if arg.startswith("--validate-version="):
m = re.search(
r"--validate-version=(?P<version>\d+\.\d+\.\d+(?:\S*)?)", arg)
if m and m.group('version'):
use_version = m.group('version')
sys.argv.remove(arg)
commands.append("validate")
else:
_print("!!! Requested version isn't in correct format.")
_print((" Use --list-versions to find out"
" proper version string."))
sys.exit(1)
if "--use-staging" in sys.argv:
use_staging = True
sys.argv.remove("--use-staging")
if "--list-versions" in sys.argv:
print_versions = True
commands.append("print_versions")
sys.argv.remove("--list-versions")
# handle igniter
# this is helper to run igniter before anything else
if "igniter" in sys.argv:
if os.getenv("OPENPYPE_HEADLESS_MODE") == "1":
_print("!!! Cannot open Igniter dialog in headless mode.")
sys.exit(1)
import igniter
return_code = igniter.open_dialog()
@ -389,7 +416,7 @@ def _process_arguments() -> tuple:
sys.argv.pop(idx)
sys.argv.insert(idx, "tray")
return use_version, use_staging, print_versions
return use_version, use_staging, commands
def _determine_mongodb() -> str:
@ -424,6 +451,11 @@ def _determine_mongodb() -> str:
if not openpype_mongo:
_print("*** No DB connection string specified.")
if os.getenv("OPENPYPE_HEADLESS_MODE") == "1":
_print("!!! Cannot open Igniter dialog in headless mode.")
_print(
"!!! Please use `OPENPYPE_MONGO` to specify server address.")
sys.exit(1)
_print("--- launching setup UI ...")
result = igniter.open_dialog()
@ -527,6 +559,9 @@ def _find_frozen_openpype(use_version: str = None,
except IndexError:
# no OpenPype version found, run Igniter and ask for them.
_print('*** No OpenPype versions found.')
if os.getenv("OPENPYPE_HEADLESS_MODE") == "1":
_print("!!! Cannot open Igniter dialog in headless mode.")
sys.exit(1)
_print("--- launching setup UI ...")
import igniter
return_code = igniter.open_dialog()
@ -590,8 +625,16 @@ def _find_frozen_openpype(use_version: str = None,
if not is_inside:
# install latest version to user data dir
version_path = bootstrap.install_version(
openpype_version, force=True)
if os.getenv("OPENPYPE_HEADLESS_MODE", "0") != "1":
import igniter
version_path = igniter.open_update_window(openpype_version)
else:
version_path = bootstrap.install_version(
openpype_version, force=True)
openpype_version.path = version_path
_initialize_environment(openpype_version)
return openpype_version.path
if openpype_version.path.is_file():
_print(">>> Extracting zip file ...")
@ -738,7 +781,7 @@ def boot():
# Process arguments
# ------------------------------------------------------------------------
use_version, use_staging, print_versions = _process_arguments()
use_version, use_staging, commands = _process_arguments()
if os.getenv("OPENPYPE_VERSION"):
if use_version:
@ -766,13 +809,47 @@ def boot():
# Get openpype path from database and set it to environment so openpype can
# find its versions there and bootstrap them.
openpype_path = get_openpype_path_from_db(openpype_mongo)
if getattr(sys, 'frozen', False):
local_version = bootstrap.get_version(Path(OPENPYPE_ROOT))
else:
local_version = bootstrap.get_local_live_version()
if "validate" in commands:
_print(f">>> Validating version [ {use_version} ]")
openpype_versions = bootstrap.find_openpype(include_zips=True,
staging=True)
openpype_versions += bootstrap.find_openpype(include_zips=True,
staging=False)
v: OpenPypeVersion
found = [v for v in openpype_versions if str(v) == use_version]
if not found:
_print(f"!!! Version [ {use_version} ] not found.")
list_versions(openpype_versions, local_version)
sys.exit(1)
# print result
result = bootstrap.validate_openpype_version(
bootstrap.get_version_path_from_list(
use_version, openpype_versions))
_print("{}{}".format(
">>> " if result[0] else "!!! ",
bootstrap.validate_openpype_version(
bootstrap.get_version_path_from_list(
use_version, openpype_versions)
)[1])
)
sys.exit(1)
if not openpype_path:
_print("*** Cannot get OpenPype path from database.")
if not os.getenv("OPENPYPE_PATH") and openpype_path:
os.environ["OPENPYPE_PATH"] = openpype_path
if print_versions:
if "print_versions" in commands:
if not use_staging:
_print("--- This will list only non-staging versions detected.")
_print(" To see staging versions, use --use-staging argument.")
@ -803,6 +880,13 @@ def boot():
# no version to run
_print(f"!!! {e}")
sys.exit(1)
# validate version
_print(f">>> Validating version [ {str(version_path)} ]")
result = bootstrap.validate_openpype_version(version_path)
if not result[0]:
_print(f"!!! Invalid version: {result[1]}")
sys.exit(1)
_print(f"--- version is valid")
else:
version_path = _bootstrap_from_code(use_version, use_staging)

View file

@ -18,11 +18,14 @@ Running OpenPype without any commands will default to `tray`.
```shell
openpype_console --use-version=3.0.0-foo+bar
```
`--headless` - to run OpenPype in headless mode (without using graphical UI)
`--use-staging` - to use staging versions of OpenPype.
`--list-versions [--use-staging]` - to list available versions.
`--validate-version` to validate integrity of given version
For more information [see here](admin_use#run-openpype).
## Commands

View file

@ -56,6 +56,19 @@ openpype_console --list-versions
You can add `--use-staging` to list staging versions.
:::
If you want to validate integrity of some available version, you can use:
```shell
openpype_console --validate-version=3.3.0
```
This will go through the version and validate file content against sha 256 hashes
stored in `checksums` file.
:::tip Headless mode
Add `--headless` to run OpenPype without graphical UI (useful on server or on automated tasks, etc.)
:::
### Details
When you run OpenPype from executable, few check are made: