diff --git a/.gitmodules b/.gitmodules
index 28f164726d..e1b0917e9d 100644
--- a/.gitmodules
+++ b/.gitmodules
@@ -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
\ No newline at end of file
diff --git a/igniter/__init__.py b/igniter/__init__.py
index 20bf9be106..defd45e233 100644
--- a/igniter/__init__.py
+++ b/igniter/__init__.py
@@ -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"
]
diff --git a/igniter/bootstrap_repos.py b/igniter/bootstrap_repos.py
index b49a2f6e7f..f7f35824c8 100644
--- a/igniter/bootstrap_repos.py
+++ b/igniter/bootstrap_repos.py
@@ -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
diff --git a/igniter/install_dialog.py b/igniter/install_dialog.py
index 1ec8cc6768..1fe67e3397 100644
--- a/igniter/install_dialog.py
+++ b/igniter/install_dialog.py
@@ -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)
diff --git a/igniter/nice_progress_bar.py b/igniter/nice_progress_bar.py
new file mode 100644
index 0000000000..47d695a101
--- /dev/null
+++ b/igniter/nice_progress_bar.py
@@ -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)
diff --git a/igniter/tools.py b/igniter/tools.py
index 529d535c25..c934289064 100644
--- a/igniter/tools.py
+++ b/igniter/tools.py
@@ -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()
diff --git a/igniter/update_thread.py b/igniter/update_thread.py
new file mode 100644
index 0000000000..f4fc729faf
--- /dev/null
+++ b/igniter/update_thread.py
@@ -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)
diff --git a/igniter/update_window.py b/igniter/update_window.py
new file mode 100644
index 0000000000..d7908c240b
--- /dev/null
+++ b/igniter/update_window.py
@@ -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"OpenPype 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)
diff --git a/openpype/api.py b/openpype/api.py
index ce18097eca..e4bbb104a3 100644
--- a/openpype/api.py
+++ b/openpype/api.py
@@ -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"
+
]
diff --git a/openpype/cli.py b/openpype/cli.py
index c446d5e443..18cc1c63cd 100644
--- a/openpype/cli.py
+++ b/openpype/cli.py
@@ -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.
diff --git a/openpype/hosts/hiero/plugins/publish/precollect_clip_effects.py b/openpype/hosts/hiero/plugins/publish/precollect_clip_effects.py
index b0b171fb61..80c6abbaef 100644
--- a/openpype/hosts/hiero/plugins/publish/precollect_clip_effects.py
+++ b/openpype/hosts/hiero/plugins/publish/precollect_clip_effects.py
@@ -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"]
diff --git a/openpype/hosts/hiero/plugins/publish/precollect_instances.py b/openpype/hosts/hiero/plugins/publish/precollect_instances.py
index 9b529edf88..936ea2be58 100644
--- a/openpype/hosts/hiero/plugins/publish/precollect_instances.py
+++ b/openpype/hosts/hiero/plugins/publish/precollect_instances.py
@@ -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"]
diff --git a/openpype/hosts/hiero/plugins/publish/precollect_workfile.py b/openpype/hosts/hiero/plugins/publish/precollect_workfile.py
index 530a433423..ff5d516065 100644
--- a/openpype/hosts/hiero/plugins/publish/precollect_workfile.py
+++ b/openpype/hosts/hiero/plugins/publish/precollect_workfile.py
@@ -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):
diff --git a/openpype/hosts/nuke/plugins/publish/extract_thumbnail.py b/openpype/hosts/nuke/plugins/publish/extract_thumbnail.py
index da30dcc632..55f7b746fc 100644
--- a/openpype/hosts/nuke/plugins/publish/extract_thumbnail.py
+++ b/openpype/hosts/nuke/plugins/publish/extract_thumbnail.py
@@ -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"]))
diff --git a/openpype/hosts/nuke/plugins/publish/precollect_instances.py b/openpype/hosts/nuke/plugins/publish/precollect_instances.py
index c2c25d0627..75d0b4f9a9 100644
--- a/openpype/hosts/nuke/plugins/publish/precollect_instances.py
+++ b/openpype/hosts/nuke/plugins/publish/precollect_instances.py
@@ -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"]
diff --git a/openpype/hosts/nuke/plugins/publish/precollect_workfile.py b/openpype/hosts/nuke/plugins/publish/precollect_workfile.py
index 5d3eb5f609..8b1ccb8cef 100644
--- a/openpype/hosts/nuke/plugins/publish/precollect_workfile.py
+++ b/openpype/hosts/nuke/plugins/publish/precollect_workfile.py
@@ -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']
diff --git a/openpype/hosts/nuke/plugins/publish/precollect_writes.py b/openpype/hosts/nuke/plugins/publish/precollect_writes.py
index 0b5fbc0479..47189c31fc 100644
--- a/openpype/hosts/nuke/plugins/publish/precollect_writes.py
+++ b/openpype/hosts/nuke/plugins/publish/precollect_writes.py
@@ -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"]
diff --git a/openpype/hosts/resolve/plugins/publish/precollect_instances.py b/openpype/hosts/resolve/plugins/publish/precollect_instances.py
index 95b891d95a..8f1a13a4e5 100644
--- a/openpype/hosts/resolve/plugins/publish/precollect_instances.py
+++ b/openpype/hosts/resolve/plugins/publish/precollect_instances.py
@@ -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"]
diff --git a/openpype/hosts/resolve/plugins/publish/precollect_workfile.py b/openpype/hosts/resolve/plugins/publish/precollect_workfile.py
index ee05fb6f13..1333516177 100644
--- a/openpype/hosts/resolve/plugins/publish/precollect_workfile.py
+++ b/openpype/hosts/resolve/plugins/publish/precollect_workfile.py
@@ -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):
diff --git a/openpype/hosts/standalonepublisher/plugins/publish/extract_harmony_zip.py b/openpype/hosts/standalonepublisher/plugins/publish/extract_harmony_zip.py
index f7f96c7d03..adbac6ef09 100644
--- a/openpype/hosts/standalonepublisher/plugins/publish/extract_harmony_zip.py
+++ b/openpype/hosts/standalonepublisher/plugins/publish/extract_harmony_zip.py
@@ -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),
diff --git a/openpype/hosts/tvpaint/plugins/publish/collect_instances.py b/openpype/hosts/tvpaint/plugins/publish/collect_instances.py
index e496b144cd..dfa8f17ee9 100644
--- a/openpype/hosts/tvpaint/plugins/publish/collect_instances.py
+++ b/openpype/hosts/tvpaint/plugins/publish/collect_instances.py
@@ -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):
diff --git a/openpype/hosts/tvpaint/plugins/publish/collect_workfile.py b/openpype/hosts/tvpaint/plugins/publish/collect_workfile.py
index b61fec895f..65e38ea258 100644
--- a/openpype/hosts/tvpaint/plugins/publish/collect_workfile.py
+++ b/openpype/hosts/tvpaint/plugins/publish/collect_workfile.py
@@ -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):
diff --git a/openpype/hosts/tvpaint/plugins/publish/collect_workfile_data.py b/openpype/hosts/tvpaint/plugins/publish/collect_workfile_data.py
index 79cc01740a..f4259f1b5f 100644
--- a/openpype/hosts/tvpaint/plugins/publish/collect_workfile_data.py
+++ b/openpype/hosts/tvpaint/plugins/publish/collect_workfile_data.py
@@ -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]
diff --git a/openpype/lib/__init__.py b/openpype/lib/__init__.py
index 3d392dc745..9bc68c9558 100644
--- a/openpype/lib/__init__.py
+++ b/openpype/lib/__init__.py
@@ -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"
]
diff --git a/openpype/lib/applications.py b/openpype/lib/applications.py
index fbf991a32e..45b8e6468d 100644
--- a/openpype/lib/applications.py
+++ b/openpype/lib/applications.py
@@ -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(),
diff --git a/openpype/lib/path_tools.py b/openpype/lib/path_tools.py
index e1dd1e7f10..9dc14497a4 100644
--- a/openpype/lib/path_tools.py
+++ b/openpype/lib/path_tools.py
@@ -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)
diff --git a/openpype/lib/profiles_filtering.py b/openpype/lib/profiles_filtering.py
index c4410204dd..992d757059 100644
--- a/openpype/lib/profiles_filtering.py
+++ b/openpype/lib/profiles_filtering.py
@@ -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)
)
diff --git a/openpype/modules/README.md b/openpype/modules/README.md
index a3733518ac..5716324365 100644
--- a/openpype/modules/README.md
+++ b/openpype/modules/README.md
@@ -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
\ No newline at end of file
diff --git a/openpype/modules/__init__.py b/openpype/modules/__init__.py
index 583480b049..68b5f6c247 100644
--- a/openpype/modules/__init__.py
+++ b/openpype/modules/__init__.py
@@ -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"
)
diff --git a/openpype/modules/base.py b/openpype/modules/base.py
index d43d5635d1..2cd11e5b94 100644
--- a/openpype/modules/base.py
+++ b/openpype/modules/base.py
@@ -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
+ │ ┝ # Any schema or template files
+ │ ┕ ...
+ ┕ project_schemas
+ ┝ # 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)
diff --git a/openpype/modules/default_modules/ftrack/event_handlers_user/action_create_project_structure.py b/openpype/modules/default_modules/ftrack/event_handlers_user/action_create_project_structure.py
index 121c9f652b..94f359c317 100644
--- a/openpype/modules/default_modules/ftrack/event_handlers_user/action_create_project_structure.py
+++ b/openpype/modules/default_modules/ftrack/event_handlers_user/action_create_project_structure.py
@@ -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()
diff --git a/openpype/modules/default_modules/ftrack/plugins/publish/collect_ftrack_family.py b/openpype/modules/default_modules/ftrack/plugins/publish/collect_ftrack_family.py
index cc2a5b7d37..70030acad9 100644
--- a/openpype/modules/default_modules/ftrack/plugins/publish/collect_ftrack_family.py
+++ b/openpype/modules/default_modules/ftrack/plugins/publish/collect_ftrack_family.py
@@ -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,
diff --git a/openpype/modules/default_modules/sync_server/providers/abstract_provider.py b/openpype/modules/default_modules/sync_server/providers/abstract_provider.py
index 2e9632134c..7fd25b9852 100644
--- a/openpype/modules/default_modules/sync_server/providers/abstract_provider.py
+++ b/openpype/modules/default_modules/sync_server/providers/abstract_provider.py
@@ -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
diff --git a/openpype/modules/default_modules/sync_server/providers/gdrive.py b/openpype/modules/default_modules/sync_server/providers/gdrive.py
index 18d679b833..f1ec0b6a0d 100644
--- a/openpype/modules/default_modules/sync_server/providers/gdrive.py
+++ b/openpype/modules/default_modules/sync_server/providers/gdrive.py
@@ -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):
diff --git a/openpype/modules/default_modules/sync_server/providers/lib.py b/openpype/modules/default_modules/sync_server/providers/lib.py
index 816ccca981..463e49dd4d 100644
--- a/openpype/modules/default_modules/sync_server/providers/lib.py
+++ b/openpype/modules/default_modules/sync_server/providers/lib.py
@@ -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
diff --git a/openpype/modules/default_modules/sync_server/providers/local_drive.py b/openpype/modules/default_modules/sync_server/providers/local_drive.py
index 4b80ed44f2..8e5f170bc9 100644
--- a/openpype/modules/default_modules/sync_server/providers/local_drive.py
+++ b/openpype/modules/default_modules/sync_server/providers/local_drive.py
@@ -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,
diff --git a/openpype/modules/default_modules/sync_server/sync_server_module.py b/openpype/modules/default_modules/sync_server/sync_server_module.py
index e65a410551..976a349bfa 100644
--- a/openpype/modules/default_modules/sync_server/sync_server_module.py
+++ b/openpype/modules/default_modules/sync_server/sync_server_module.py
@@ -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"):
diff --git a/openpype/modules/example_addons/example_addon/__init__.py b/openpype/modules/example_addons/example_addon/__init__.py
new file mode 100644
index 0000000000..721d924436
--- /dev/null
+++ b/openpype/modules/example_addons/example_addon/__init__.py
@@ -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"
+)
diff --git a/openpype/modules/example_addons/example_addon/addon.py b/openpype/modules/example_addons/example_addon/addon.py
new file mode 100644
index 0000000000..5573e33cc1
--- /dev/null
+++ b/openpype/modules/example_addons/example_addon/addon.py
@@ -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")]
+ }
diff --git a/openpype/modules/example_addons/example_addon/interfaces.py b/openpype/modules/example_addons/example_addon/interfaces.py
new file mode 100644
index 0000000000..371536efc7
--- /dev/null
+++ b/openpype/modules/example_addons/example_addon/interfaces.py
@@ -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
diff --git a/openpype/modules/example_addons/example_addon/plugins/publish/example_plugin.py b/openpype/modules/example_addons/example_addon/plugins/publish/example_plugin.py
new file mode 100644
index 0000000000..695120e93b
--- /dev/null
+++ b/openpype/modules/example_addons/example_addon/plugins/publish/example_plugin.py
@@ -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!")
diff --git a/openpype/modules/example_addons/example_addon/settings/defaults/project_settings.json b/openpype/modules/example_addons/example_addon/settings/defaults/project_settings.json
new file mode 100644
index 0000000000..0a01fa8977
--- /dev/null
+++ b/openpype/modules/example_addons/example_addon/settings/defaults/project_settings.json
@@ -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
+ ]
+ }
+}
\ No newline at end of file
diff --git a/openpype/modules/example_addons/example_addon/settings/defaults/system_settings.json b/openpype/modules/example_addons/example_addon/settings/defaults/system_settings.json
new file mode 100644
index 0000000000..1e77356373
--- /dev/null
+++ b/openpype/modules/example_addons/example_addon/settings/defaults/system_settings.json
@@ -0,0 +1,5 @@
+{
+ "modules/example_addon": {
+ "enabled": true
+ }
+}
\ No newline at end of file
diff --git a/openpype/modules/example_addons/example_addon/settings/dynamic_schemas/project_dynamic_schemas.json b/openpype/modules/example_addons/example_addon/settings/dynamic_schemas/project_dynamic_schemas.json
new file mode 100644
index 0000000000..1f3da7b37f
--- /dev/null
+++ b/openpype/modules/example_addons/example_addon/settings/dynamic_schemas/project_dynamic_schemas.json
@@ -0,0 +1,6 @@
+{
+ "project_settings/global": {
+ "type": "schema",
+ "name": "example_addon/main"
+ }
+}
diff --git a/openpype/modules/example_addons/example_addon/settings/dynamic_schemas/system_dynamic_schemas.json b/openpype/modules/example_addons/example_addon/settings/dynamic_schemas/system_dynamic_schemas.json
new file mode 100644
index 0000000000..6faa48ba74
--- /dev/null
+++ b/openpype/modules/example_addons/example_addon/settings/dynamic_schemas/system_dynamic_schemas.json
@@ -0,0 +1,6 @@
+{
+ "system_settings/modules": {
+ "type": "schema",
+ "name": "example_addon/main"
+ }
+}
diff --git a/openpype/modules/example_addons/example_addon/settings/schemas/project_schemas/main.json b/openpype/modules/example_addons/example_addon/settings/schemas/project_schemas/main.json
new file mode 100644
index 0000000000..ba692d860e
--- /dev/null
+++ b/openpype/modules/example_addons/example_addon/settings/schemas/project_schemas/main.json
@@ -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"
+ }
+ ]
+ }
+ ]
+}
diff --git a/openpype/modules/example_addons/example_addon/settings/schemas/project_schemas/the_template.json b/openpype/modules/example_addons/example_addon/settings/schemas/project_schemas/the_template.json
new file mode 100644
index 0000000000..af8fd9dae4
--- /dev/null
+++ b/openpype/modules/example_addons/example_addon/settings/schemas/project_schemas/the_template.json
@@ -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
+ }
+ ]
+ }
+]
diff --git a/openpype/modules/example_addons/example_addon/settings/schemas/system_schemas/main.json b/openpype/modules/example_addons/example_addon/settings/schemas/system_schemas/main.json
new file mode 100644
index 0000000000..0fb0a7c1be
--- /dev/null
+++ b/openpype/modules/example_addons/example_addon/settings/schemas/system_schemas/main.json
@@ -0,0 +1,14 @@
+{
+ "type": "dict",
+ "key": "example_addon",
+ "label": "Example addon",
+ "collapsible": true,
+ "checkbox_key": "enabled",
+ "children": [
+ {
+ "type": "boolean",
+ "key": "enabled",
+ "label": "Enabled"
+ }
+ ]
+}
diff --git a/openpype/modules/example_addons/example_addon/widgets.py b/openpype/modules/example_addons/example_addon/widgets.py
new file mode 100644
index 0000000000..0acf238409
--- /dev/null
+++ b/openpype/modules/example_addons/example_addon/widgets.py
@@ -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)
diff --git a/openpype/modules/example_addons/tiny_addon.py b/openpype/modules/example_addons/tiny_addon.py
new file mode 100644
index 0000000000..62962954f5
--- /dev/null
+++ b/openpype/modules/example_addons/tiny_addon.py
@@ -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"
diff --git a/openpype/plugins/publish/collect_host_name.py b/openpype/plugins/publish/collect_host_name.py
index 41d9cc3a5a..b731e3ed26 100644
--- a/openpype/plugins/publish/collect_host_name.py
+++ b/openpype/plugins/publish/collect_host_name.py
@@ -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")
diff --git a/openpype/plugins/publish/stop_timer.py b/openpype/plugins/publish/stop_timer.py
index 81afd16378..5c939b5f1b 100644
--- a/openpype/plugins/publish/stop_timer.py
+++ b/openpype/plugins/publish/stop_timer.py
@@ -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):
diff --git a/openpype/settings/__init__.py b/openpype/settings/__init__.py
index b5810deef4..74f2684b2a 100644
--- a/openpype/settings/__init__.py
+++ b/openpype/settings/__init__.py
@@ -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"
)
diff --git a/openpype/settings/constants.py b/openpype/settings/constants.py
index a53e88a91e..2ea19ead4b 100644
--- a/openpype/settings/constants.py
+++ b/openpype/settings/constants.py
@@ -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",
diff --git a/openpype/settings/defaults/system_settings/modules.json b/openpype/settings/defaults/system_settings/modules.json
index a0ba607edc..229b867327 100644
--- a/openpype/settings/defaults/system_settings/modules.json
+++ b/openpype/settings/defaults/system_settings/modules.json
@@ -1,4 +1,9 @@
{
+ "addon_paths": {
+ "windows": [],
+ "darwin": [],
+ "linux": []
+ },
"avalon": {
"AVALON_TIMEOUT": 1000,
"AVALON_THUMBNAIL_ROOT": {
diff --git a/openpype/settings/entities/__init__.py b/openpype/settings/entities/__init__.py
index 8c30d5044c..aae2d1fa89 100644
--- a/openpype/settings/entities/__init__.py
+++ b/openpype/settings/entities/__init__.py
@@ -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"
)
diff --git a/openpype/settings/entities/base_entity.py b/openpype/settings/entities/base_entity.py
index 851684520b..0e8274d374 100644
--- a/openpype/settings/entities/base_entity.py
+++ b/openpype/settings/entities/base_entity.py
@@ -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."
diff --git a/openpype/settings/entities/dict_conditional.py b/openpype/settings/entities/dict_conditional.py
index 988464d059..6f27760570 100644
--- a/openpype/settings/entities/dict_conditional.py
+++ b/openpype/settings/entities/dict_conditional.py
@@ -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
diff --git a/openpype/settings/entities/dict_immutable_keys_entity.py b/openpype/settings/entities/dict_immutable_keys_entity.py
index 73b08f101a..57e21ff5f3 100644
--- a/openpype/settings/entities/dict_immutable_keys_entity.py
+++ b/openpype/settings/entities/dict_immutable_keys_entity.py
@@ -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
diff --git a/openpype/settings/entities/dict_mutable_keys_entity.py b/openpype/settings/entities/dict_mutable_keys_entity.py
index c3df935269..f75fb23d82 100644
--- a/openpype/settings/entities/dict_mutable_keys_entity.py
+++ b/openpype/settings/entities/dict_mutable_keys_entity.py
@@ -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."
diff --git a/openpype/settings/entities/enum_entity.py b/openpype/settings/entities/enum_entity.py
index cb532c5ae0..a5e734f039 100644
--- a/openpype/settings/entities/enum_entity.py
+++ b/openpype/settings/entities/enum_entity.py
@@ -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):
diff --git a/openpype/settings/entities/input_entities.py b/openpype/settings/entities/input_entities.py
index 336d1f5c1e..0ded3ab7e5 100644
--- a/openpype/settings/entities/input_entities.py
+++ b/openpype/settings/entities/input_entities.py
@@ -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
diff --git a/openpype/settings/entities/item_entities.py b/openpype/settings/entities/item_entities.py
index ac6b3e76dd..c7c9c3097e 100644
--- a/openpype/settings/entities/item_entities.py
+++ b/openpype/settings/entities/item_entities.py
@@ -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
diff --git a/openpype/settings/entities/lib.py b/openpype/settings/entities/lib.py
index 01f61d8bdf..bf3868c08d 100644
--- a/openpype/settings/entities/lib.py
+++ b/openpype/settings/entities/lib.py
@@ -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)
diff --git a/openpype/settings/entities/root_entities.py b/openpype/settings/entities/root_entities.py
index 4a06d2d591..05d20ee60b 100644
--- a/openpype/settings/entities/root_entities.py
+++ b/openpype/settings/entities/root_entities.py
@@ -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)
diff --git a/openpype/settings/entities/schemas/README.md b/openpype/settings/entities/schemas/README.md
index 05605f8ce1..c8432f0f2e 100644
--- a/openpype/settings/entities/schemas/README.md
+++ b/openpype/settings/entities/schemas/README.md
@@ -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`)
diff --git a/openpype/settings/entities/schemas/projects_schema/schema_main.json b/openpype/settings/entities/schemas/projects_schema/schema_main.json
index 575cfc9e72..c9eca5dedd 100644
--- a/openpype/settings/entities/schemas/projects_schema/schema_main.json
+++ b/openpype/settings/entities/schemas/projects_schema/schema_main.json
@@ -125,6 +125,10 @@
{
"type": "schema",
"name": "schema_project_unreal"
+ },
+ {
+ "type": "dynamic_schema",
+ "name": "project_settings/global"
}
]
}
diff --git a/openpype/settings/entities/schemas/system_schema/example_schema.json b/openpype/settings/entities/schemas/system_schema/example_schema.json
index f633d5cb1a..af6a2d49f4 100644
--- a/openpype/settings/entities/schemas/system_schema/example_schema.json
+++ b/openpype/settings/entities/schemas/system_schema/example_schema.json
@@ -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",
diff --git a/openpype/settings/entities/schemas/system_schema/schema_modules.json b/openpype/settings/entities/schemas/system_schema/schema_modules.json
index dd85f9351a..a2b31772e9 100644
--- a/openpype/settings/entities/schemas/system_schema/schema_modules.json
+++ b/openpype/settings/entities/schemas/system_schema/schema_modules.json
@@ -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"
}
]
}
diff --git a/openpype/settings/lib.py b/openpype/settings/lib.py
index 4a363910b8..60ed54bd4a 100644
--- a/openpype/settings/lib.py
+++ b/openpype/settings/lib.py
@@ -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"
}
}
}
diff --git a/openpype/tools/settings/settings/item_widgets.py b/openpype/tools/settings/settings/item_widgets.py
index b2b129da86..da74c2adc5 100644
--- a/openpype/tools/settings/settings/item_widgets.py
+++ b/openpype/tools/settings/settings/item_widgets.py
@@ -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):
diff --git a/openpype/tools/settings/settings/style/style.css b/openpype/tools/settings/settings/style/style.css
index 250c15063f..d9d85a481e 100644
--- a/openpype/tools/settings/settings/style/style.css
+++ b/openpype/tools/settings/settings/style/style.css
@@ -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;
}
diff --git a/openpype/tools/settings/settings/widgets.py b/openpype/tools/settings/settings/widgets.py
index b821c3bb2c..2caf8c33ba 100644
--- a/openpype/tools/settings/settings/widgets.py
+++ b/openpype/tools/settings/settings/widgets.py
@@ -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)
diff --git a/openpype/tools/workfiles/app.py b/openpype/tools/workfiles/app.py
index b542e6e718..3d2633f8dc 100644
--- a/openpype/tools/workfiles/app.py
+++ b/openpype/tools/workfiles/app.py
@@ -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
diff --git a/openpype/widgets/sliders.py b/openpype/widgets/sliders.py
new file mode 100644
index 0000000000..32ade58af5
--- /dev/null
+++ b/openpype/widgets/sliders.py
@@ -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()
diff --git a/repos/avalon-core b/repos/avalon-core
index f48fce09c0..b3e4959778 160000
--- a/repos/avalon-core
+++ b/repos/avalon-core
@@ -1 +1 @@
-Subproject commit f48fce09c0986c1fd7f6731de33907be46b436c5
+Subproject commit b3e49597786c931c13bca207769727d5fc56d5f6
diff --git a/start.py b/start.py
index 6473a926d0..00f9a50cbb 100644
--- a/start.py
+++ b/start.py
@@ -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\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)
diff --git a/website/docs/admin_openpype_commands.md b/website/docs/admin_openpype_commands.md
index 1a91e2e7fe..d6ccc883b0 100644
--- a/website/docs/admin_openpype_commands.md
+++ b/website/docs/admin_openpype_commands.md
@@ -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
diff --git a/website/docs/admin_use.md b/website/docs/admin_use.md
index 4ad08a0174..178241ad19 100644
--- a/website/docs/admin_use.md
+++ b/website/docs/admin_use.md
@@ -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: