[Automated] Merged develop into main

This commit is contained in:
pypebot 2021-09-18 05:33:40 +02:00 committed by GitHub
commit 203c716db8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
33 changed files with 6432 additions and 28 deletions

View file

@ -20,12 +20,12 @@ jobs:
python-version: 3.7
- name: Install Python requirements
run: pip install gitpython semver
run: pip install gitpython semver PyGithub
- name: 🔎 Determine next version type
id: version_type
run: |
TYPE=$(python ./tools/ci_tools.py --bump)
TYPE=$(python ./tools/ci_tools.py --bump --github_token ${{ secrets.GITHUB_TOKEN }})
echo ::set-output name=type::$TYPE

View file

@ -10,8 +10,10 @@ from .pipeline import (
from avalon.tools import (
creator,
loader,
sceneinventory,
)
from openpype.tools import (
loader,
libraryloader
)

View file

@ -3,7 +3,7 @@ Basic avalon integration
"""
import os
from avalon.tools import workfiles
from openpype.tools import workfiles
from avalon import api as avalon
from pyblish import api as pyblish
from openpype.api import Logger

View file

@ -41,7 +41,8 @@ def menu_install():
apply_colorspace_project, apply_colorspace_clips
)
# here is the best place to add menu
from avalon.tools import cbloader, creator, sceneinventory
from avalon.tools import creator, sceneinventory
from openpype.tools import loader
from avalon.vendor.Qt import QtGui
menu_name = os.environ['AVALON_LABEL']
@ -90,7 +91,7 @@ def menu_install():
loader_action = menu.addAction("Load ...")
loader_action.setIcon(QtGui.QIcon("icons:CopyRectangle.png"))
loader_action.triggered.connect(cbloader.show)
loader_action.triggered.connect(loader.show)
sceneinventory_action = menu.addAction("Manage ...")
sceneinventory_action.setIcon(QtGui.QIcon("icons:CopyRectangle.png"))

View file

@ -4,10 +4,8 @@ Basic avalon integration
import os
import contextlib
from collections import OrderedDict
from avalon.tools import (
workfiles,
publish as _publish
)
from avalon.tools import publish as _publish
from openpype.tools import workfiles
from avalon.pipeline import AVALON_CONTAINER_ID
from avalon import api as avalon
from avalon import schema

View file

@ -15,8 +15,8 @@ creator.show()
<scriptItem id="avalon_load">
<label>Load ...</label>
<scriptCode><![CDATA[
from avalon.tools import cbloader
cbloader.show(use_context=True)
from openpype.tools import loader
loader.show(use_context=True)
]]></scriptCode>
</scriptItem>

View file

@ -8,7 +8,7 @@ from avalon import api as avalon
from avalon import pipeline
from avalon.maya import suspended_refresh
from avalon.maya.pipeline import IS_HEADLESS
from avalon.tools import workfiles
from openpype.tools import workfiles
from pyblish import api as pyblish
from openpype.lib import any_outdated
import openpype.hosts.maya

View file

@ -79,7 +79,7 @@ def override_toolbox_ui():
log.warning("Could not import SceneInventory tool")
try:
import avalon.tools.loader as loader
import openpype.tools.loader as loader
except Exception:
log.warning("Could not import Loader tool")

View file

@ -7,7 +7,7 @@ from collections import OrderedDict
from avalon import api, io, lib
from avalon.tools import workfiles
from openpype.tools import workfiles
import avalon.nuke
from avalon.nuke import lib as anlib
from avalon.nuke import (

View file

@ -10,11 +10,13 @@ from .pipeline import (
from avalon.tools import (
creator,
loader,
sceneinventory,
libraryloader,
subsetmanager
)
from openpype.tools import (
loader,
libraryloader,
)
def load_stylesheet():

View file

@ -4,7 +4,7 @@ Basic avalon integration
import os
import contextlib
from collections import OrderedDict
from avalon.tools import workfiles
from openpype.tools import workfiles
from avalon import api as avalon
from avalon import schema
from avalon.pipeline import AVALON_CONTAINER_ID

View file

@ -55,11 +55,11 @@ class AvalonModule(OpenPypeModule, ITrayModule, IWebServerRoutes):
def tray_init(self):
# Add library tool
try:
from avalon.tools.libraryloader import app
from avalon import style
from Qt import QtGui
from avalon import style
from openpype.tools.libraryloader import LibraryLoaderWindow
self.libraryloader = app.Window(
self.libraryloader = LibraryLoaderWindow(
icon=QtGui.QIcon(resources.get_openpype_icon_filepath()),
show_projects=True,
show_libraries=True

View file

@ -5,7 +5,7 @@ from bson.objectid import ObjectId
from Qt import QtCore
from Qt.QtCore import Qt
from avalon.tools.delegates import pretty_timestamp
from openpype.tools.utils.delegates import pretty_timestamp
from avalon.vendor import qtawesome
from openpype.lib import PypeLogger

View file

@ -14,7 +14,7 @@ from openpype.tools.settings import (
from openpype.api import get_local_site_id
from openpype.lib import PypeLogger
from avalon.tools.delegates import pretty_timestamp
from openpype.tools.utils.delegates import pretty_timestamp
from avalon.vendor import qtawesome
from .models import (

View file

@ -0,0 +1,11 @@
from .app import (
LibraryLoaderWindow,
show,
cli
)
__all__ = [
"LibraryLoaderWindow",
"show",
"cli",
]

View file

@ -0,0 +1,5 @@
from . import cli
if __name__ == '__main__':
import sys
sys.exit(cli(sys.argv[1:]))

View file

@ -0,0 +1,591 @@
import sys
import time
from Qt import QtWidgets, QtCore, QtGui
from avalon import style
from avalon.api import AvalonMongoDB
from openpype.tools.utils import lib as tools_lib
from openpype.tools.loader.widgets import (
ThumbnailWidget,
VersionWidget,
FamilyListWidget,
RepresentationWidget
)
from openpype.tools.utils.widgets import AssetWidget
from openpype.modules import ModulesManager
from . import lib
from .widgets import LibrarySubsetWidget
module = sys.modules[__name__]
module.window = None
class LibraryLoaderWindow(QtWidgets.QDialog):
"""Asset library loader interface"""
tool_title = "Library Loader 0.5"
tool_name = "library_loader"
def __init__(
self, parent=None, icon=None, show_projects=False, show_libraries=True
):
super(LibraryLoaderWindow, self).__init__(parent)
self._initial_refresh = False
self._ignore_project_change = False
# Enable minimize and maximize for app
self.setWindowTitle(self.tool_title)
self.setWindowFlags(QtCore.Qt.Window)
self.setFocusPolicy(QtCore.Qt.StrongFocus)
if icon is not None:
self.setWindowIcon(icon)
# self.setAttribute(QtCore.Qt.WA_DeleteOnClose)
body = QtWidgets.QWidget()
footer = QtWidgets.QWidget()
footer.setFixedHeight(20)
container = QtWidgets.QWidget()
self.dbcon = AvalonMongoDB()
self.dbcon.install()
self.dbcon.Session["AVALON_PROJECT"] = None
self.show_projects = show_projects
self.show_libraries = show_libraries
# Groups config
self.groups_config = tools_lib.GroupsConfig(self.dbcon)
self.family_config_cache = tools_lib.FamilyConfigCache(self.dbcon)
assets = AssetWidget(
self.dbcon, multiselection=True, parent=self
)
families = FamilyListWidget(
self.dbcon, self.family_config_cache, parent=self
)
subsets = LibrarySubsetWidget(
self.dbcon,
self.groups_config,
self.family_config_cache,
tool_name=self.tool_name,
parent=self
)
version = VersionWidget(self.dbcon)
thumbnail = ThumbnailWidget(self.dbcon)
# Project
self.combo_projects = QtWidgets.QComboBox()
# Create splitter to show / hide family filters
asset_filter_splitter = QtWidgets.QSplitter()
asset_filter_splitter.setOrientation(QtCore.Qt.Vertical)
asset_filter_splitter.addWidget(self.combo_projects)
asset_filter_splitter.addWidget(assets)
asset_filter_splitter.addWidget(families)
asset_filter_splitter.setStretchFactor(1, 65)
asset_filter_splitter.setStretchFactor(2, 35)
manager = ModulesManager()
sync_server = manager.modules_by_name["sync_server"]
representations = RepresentationWidget(self.dbcon)
thumb_ver_splitter = QtWidgets.QSplitter()
thumb_ver_splitter.setOrientation(QtCore.Qt.Vertical)
thumb_ver_splitter.addWidget(thumbnail)
thumb_ver_splitter.addWidget(version)
if sync_server.enabled:
thumb_ver_splitter.addWidget(representations)
thumb_ver_splitter.setStretchFactor(0, 30)
thumb_ver_splitter.setStretchFactor(1, 35)
container_layout = QtWidgets.QHBoxLayout(container)
container_layout.setContentsMargins(0, 0, 0, 0)
split = QtWidgets.QSplitter()
split.addWidget(asset_filter_splitter)
split.addWidget(subsets)
split.addWidget(thumb_ver_splitter)
split.setSizes([180, 950, 200])
container_layout.addWidget(split)
body_layout = QtWidgets.QHBoxLayout(body)
body_layout.addWidget(container)
body_layout.setContentsMargins(0, 0, 0, 0)
message = QtWidgets.QLabel()
message.hide()
footer_layout = QtWidgets.QVBoxLayout(footer)
footer_layout.addWidget(message)
footer_layout.setContentsMargins(0, 0, 0, 0)
layout = QtWidgets.QVBoxLayout(self)
layout.addWidget(body)
layout.addWidget(footer)
self.data = {
"widgets": {
"families": families,
"assets": assets,
"subsets": subsets,
"version": version,
"thumbnail": thumbnail,
"representations": representations
},
"label": {
"message": message,
},
"state": {
"assetIds": None
}
}
families.active_changed.connect(subsets.set_family_filters)
assets.selection_changed.connect(self.on_assetschanged)
assets.refresh_triggered.connect(self.on_assetschanged)
assets.view.clicked.connect(self.on_assetview_click)
subsets.active_changed.connect(self.on_subsetschanged)
subsets.version_changed.connect(self.on_versionschanged)
self.combo_projects.currentTextChanged.connect(self.on_project_change)
self.sync_server = sync_server
# Set default thumbnail on start
thumbnail.set_thumbnail(None)
# Defaults
if sync_server.enabled:
split.setSizes([250, 1000, 550])
self.resize(1800, 900)
else:
split.setSizes([250, 850, 200])
self.resize(1300, 700)
def showEvent(self, event):
super(LibraryLoaderWindow, self).showEvent(event)
if not self._initial_refresh:
self.refresh()
def on_assetview_click(self, *args):
subsets_widget = self.data["widgets"]["subsets"]
selection_model = subsets_widget.view.selectionModel()
if selection_model.selectedIndexes():
selection_model.clearSelection()
def _set_projects(self):
# Store current project
old_project_name = self.current_project
self._ignore_project_change = True
# Cleanup
self.combo_projects.clear()
# Fill combobox with projects
select_project_item = QtGui.QStandardItem("< Select project >")
select_project_item.setData(None, QtCore.Qt.UserRole + 1)
combobox_items = [select_project_item]
project_names = self.get_filtered_projects()
for project_name in sorted(project_names):
item = QtGui.QStandardItem(project_name)
item.setData(project_name, QtCore.Qt.UserRole + 1)
combobox_items.append(item)
root_item = self.combo_projects.model().invisibleRootItem()
root_item.appendRows(combobox_items)
index = 0
self._ignore_project_change = False
if old_project_name:
index = self.combo_projects.findText(
old_project_name, QtCore.Qt.MatchFixedString
)
self.combo_projects.setCurrentIndex(index)
def get_filtered_projects(self):
projects = list()
for project in self.dbcon.projects():
is_library = project.get("data", {}).get("library_project", False)
if (
(is_library and self.show_libraries) or
(not is_library and self.show_projects)
):
projects.append(project["name"])
return projects
def on_project_change(self):
if self._ignore_project_change:
return
row = self.combo_projects.currentIndex()
index = self.combo_projects.model().index(row, 0)
project_name = index.data(QtCore.Qt.UserRole + 1)
self.dbcon.Session["AVALON_PROJECT"] = project_name
_config = lib.find_config()
if hasattr(_config, "install"):
_config.install()
else:
print(
"Config `%s` has no function `install`" % _config.__name__
)
self.family_config_cache.refresh()
self.groups_config.refresh()
self._refresh_assets()
self._assetschanged()
project_name = self.dbcon.active_project() or "No project selected"
title = "{} - {}".format(self.tool_title, project_name)
self.setWindowTitle(title)
subsets = self.data["widgets"]["subsets"]
subsets.on_project_change(self.dbcon.Session["AVALON_PROJECT"])
representations = self.data["widgets"]["representations"]
representations.on_project_change(self.dbcon.Session["AVALON_PROJECT"])
@property
def current_project(self):
if (
not self.dbcon.active_project() or
self.dbcon.active_project() == ""
):
return None
return self.dbcon.active_project()
# -------------------------------
# Delay calling blocking methods
# -------------------------------
def refresh(self):
self.echo("Fetching results..")
tools_lib.schedule(self._refresh, 50, channel="mongo")
def on_assetschanged(self, *args):
self.echo("Fetching asset..")
tools_lib.schedule(self._assetschanged, 50, channel="mongo")
def on_subsetschanged(self, *args):
self.echo("Fetching subset..")
tools_lib.schedule(self._subsetschanged, 50, channel="mongo")
def on_versionschanged(self, *args):
self.echo("Fetching version..")
tools_lib.schedule(self._versionschanged, 150, channel="mongo")
def set_context(self, context, refresh=True):
self.echo("Setting context: {}".format(context))
lib.schedule(
lambda: self._set_context(context, refresh=refresh),
50, channel="mongo"
)
# ------------------------------
def _refresh(self):
if not self._initial_refresh:
self._initial_refresh = True
self._set_projects()
def _refresh_assets(self):
"""Load assets from database"""
if self.current_project is not None:
# Ensure a project is loaded
project_doc = self.dbcon.find_one(
{"type": "project"},
{"type": 1}
)
assert project_doc, "This is a bug"
assets_widget = self.data["widgets"]["assets"]
assets_widget.model.stop_fetch_thread()
assets_widget.refresh()
assets_widget.setFocus()
families = self.data["widgets"]["families"]
families.refresh()
def clear_assets_underlines(self):
last_asset_ids = self.data["state"]["assetIds"]
if not last_asset_ids:
return
assets_widget = self.data["widgets"]["assets"]
id_role = assets_widget.model.ObjectIdRole
for index in tools_lib.iter_model_rows(assets_widget.model, 0):
if index.data(id_role) not in last_asset_ids:
continue
assets_widget.model.setData(
index, [], assets_widget.model.subsetColorsRole
)
def _assetschanged(self):
"""Selected assets have changed"""
t1 = time.time()
assets_widget = self.data["widgets"]["assets"]
subsets_widget = self.data["widgets"]["subsets"]
subsets_model = subsets_widget.model
subsets_model.clear()
self.clear_assets_underlines()
if not self.dbcon.Session.get("AVALON_PROJECT"):
subsets_widget.set_loading_state(
loading=False,
empty=True
)
return
# filter None docs they are silo
asset_docs = assets_widget.get_selected_assets()
if len(asset_docs) == 0:
return
asset_ids = [asset_doc["_id"] for asset_doc in asset_docs]
# Start loading
subsets_widget.set_loading_state(
loading=bool(asset_ids),
empty=True
)
def on_refreshed(has_item):
empty = not has_item
subsets_widget.set_loading_state(loading=False, empty=empty)
subsets_model.refreshed.disconnect()
self.echo("Duration: %.3fs" % (time.time() - t1))
subsets_model.refreshed.connect(on_refreshed)
subsets_model.set_assets(asset_ids)
subsets_widget.view.setColumnHidden(
subsets_model.Columns.index("asset"),
len(asset_ids) < 2
)
# Clear the version information on asset change
self.data["widgets"]["version"].set_version(None)
self.data["widgets"]["thumbnail"].set_thumbnail(asset_docs)
self.data["state"]["assetIds"] = asset_ids
representations = self.data["widgets"]["representations"]
representations.set_version_ids([]) # reset repre list
self.echo("Duration: %.3fs" % (time.time() - t1))
def _subsetschanged(self):
asset_ids = self.data["state"]["assetIds"]
# Skip setting colors if not asset multiselection
if not asset_ids or len(asset_ids) < 2:
self._versionschanged()
return
subsets = self.data["widgets"]["subsets"]
selected_subsets = subsets.selected_subsets(_merged=True, _other=False)
asset_models = {}
asset_ids = []
for subset_node in selected_subsets:
asset_ids.extend(subset_node.get("assetIds", []))
asset_ids = set(asset_ids)
for subset_node in selected_subsets:
for asset_id in asset_ids:
if asset_id not in asset_models:
asset_models[asset_id] = []
color = None
if asset_id in subset_node.get("assetIds", []):
color = subset_node["subsetColor"]
asset_models[asset_id].append(color)
self.clear_assets_underlines()
assets_widget = self.data["widgets"]["assets"]
indexes = assets_widget.view.selectionModel().selectedRows()
for index in indexes:
id = index.data(assets_widget.model.ObjectIdRole)
if id not in asset_models:
continue
assets_widget.model.setData(
index, asset_models[id], assets_widget.model.subsetColorsRole
)
# Trigger repaint
assets_widget.view.updateGeometries()
# Set version in Version Widget
self._versionschanged()
def _versionschanged(self):
subsets = self.data["widgets"]["subsets"]
selection = subsets.view.selectionModel()
# Active must be in the selected rows otherwise we
# assume it's not actually an "active" current index.
version_docs = None
version_doc = None
active = selection.currentIndex()
rows = selection.selectedRows(column=active.column())
if active and active in rows:
item = active.data(subsets.model.ItemRole)
if (
item is not None
and not (item.get("isGroup") or item.get("isMerged"))
):
version_doc = item["version_document"]
if rows:
version_docs = []
for index in rows:
if not index or not index.isValid():
continue
item = index.data(subsets.model.ItemRole)
if (
item is None
or item.get("isGroup")
or item.get("isMerged")
):
continue
version_docs.append(item["version_document"])
self.data["widgets"]["version"].set_version(version_doc)
thumbnail_docs = version_docs
if not thumbnail_docs:
assets_widget = self.data["widgets"]["assets"]
asset_docs = assets_widget.get_selected_assets()
if len(asset_docs) > 0:
thumbnail_docs = asset_docs
self.data["widgets"]["thumbnail"].set_thumbnail(thumbnail_docs)
representations = self.data["widgets"]["representations"]
version_ids = [doc["_id"] for doc in version_docs or []]
representations.set_version_ids(version_ids)
def _set_context(self, context, refresh=True):
"""Set the selection in the interface using a context.
The context must contain `asset` data by name.
Note: Prior to setting context ensure `refresh` is triggered so that
the "silos" are listed correctly, aside from that setting the
context will force a refresh further down because it changes
the active silo and asset.
Args:
context (dict): The context to apply.
Returns:
None
"""
asset = context.get("asset", None)
if asset is None:
return
if refresh:
# Workaround:
# Force a direct (non-scheduled) refresh prior to setting the
# asset widget's silo and asset selection to ensure it's correctly
# displaying the silo tabs. Calling `window.refresh()` and directly
# `window.set_context()` the `set_context()` seems to override the
# scheduled refresh and the silo tabs are not shown.
self._refresh_assets()
asset_widget = self.data["widgets"]["assets"]
asset_widget.select_assets(asset)
def echo(self, message):
widget = self.data["label"]["message"]
widget.setText(str(message))
widget.show()
print(message)
tools_lib.schedule(widget.hide, 5000, channel="message")
def closeEvent(self, event):
# Kill on holding SHIFT
modifiers = QtWidgets.QApplication.queryKeyboardModifiers()
shift_pressed = QtCore.Qt.ShiftModifier & modifiers
if shift_pressed:
print("Force quitted..")
self.setAttribute(QtCore.Qt.WA_DeleteOnClose)
print("Good bye")
return super(LibraryLoaderWindow, self).closeEvent(event)
def show(
debug=False, parent=None, icon=None,
show_projects=False, show_libraries=True
):
"""Display Loader GUI
Arguments:
debug (bool, optional): Run loader in debug-mode,
defaults to False
parent (QtCore.QObject, optional): The Qt object to parent to.
use_context (bool): Whether to apply the current context upon launch
"""
# Remember window
if module.window is not None:
try:
module.window.show()
# If the window is minimized then unminimize it.
if module.window.windowState() & QtCore.Qt.WindowMinimized:
module.window.setWindowState(QtCore.Qt.WindowActive)
# Raise and activate the window
module.window.raise_() # for MacOS
module.window.activateWindow() # for Windows
module.window.refresh()
return
except RuntimeError as e:
if not e.message.rstrip().endswith("already deleted."):
raise
# Garbage collected
module.window = None
if debug:
import traceback
sys.excepthook = lambda typ, val, tb: traceback.print_last()
with tools_lib.application():
window = LibraryLoaderWindow(
parent, icon, show_projects, show_libraries
)
window.setStyleSheet(style.load_stylesheet())
window.show()
module.window = window
def cli(args):
import argparse
parser = argparse.ArgumentParser()
parser.add_argument("project")
show(show_projects=True, show_libraries=True)

View file

@ -0,0 +1,33 @@
import os
import importlib
import logging
from openpype.api import Anatomy
log = logging.getLogger(__name__)
# `find_config` from `pipeline`
def find_config():
log.info("Finding configuration for project..")
config = os.environ["AVALON_CONFIG"]
if not config:
raise EnvironmentError(
"No configuration found in "
"the project nor environment"
)
log.info("Found %s, loading.." % config)
return importlib.import_module(config)
class RegisteredRoots:
roots_per_project = {}
@classmethod
def registered_root(cls, project_name):
if project_name not in cls.roots_per_project:
cls.roots_per_project[project_name] = Anatomy(project_name).roots
return cls.roots_per_project[project_name]

View file

@ -0,0 +1,18 @@
from Qt import QtWidgets
from .lib import RegisteredRoots
from openpype.tools.loader.widgets import SubsetWidget
class LibrarySubsetWidget(SubsetWidget):
def on_copy_source(self):
"""Copy formatted source path to clipboard"""
source = self.data.get("source", None)
if not source:
return
project_name = self.dbcon.Session["AVALON_PROJECT"]
root = RegisteredRoots.registered_root(project_name)
path = source.format(root=root)
clipboard = QtWidgets.QApplication.clipboard()
clipboard.setText(path)

View file

@ -0,0 +1,11 @@
from .app import (
LoaderWindow,
show,
cli,
)
__all__ = (
"LoaderWindow",
"show",
"cli",
)

View file

@ -0,0 +1,33 @@
"""Main entrypoint for standalone debugging
Used for running 'avalon.tool.loader.__main__' as a module (-m), useful for
debugging without need to start host.
Modify AVALON_MONGO accordingly
"""
import os
import sys
from . import cli
def my_exception_hook(exctype, value, traceback):
# Print the error and traceback
print(exctype, value, traceback)
# Call the normal Exception hook after
sys._excepthook(exctype, value, traceback)
sys.exit(1)
if __name__ == '__main__':
os.environ["AVALON_MONGO"] = "mongodb://localhost:27017"
os.environ["OPENPYPE_MONGO"] = "mongodb://localhost:27017"
os.environ["AVALON_DB"] = "avalon"
os.environ["AVALON_TIMEOUT"] = "1000"
os.environ["OPENPYPE_DEBUG"] = "1"
os.environ["AVALON_CONFIG"] = "pype"
os.environ["AVALON_ASSET"] = "Jungle"
# Set the exception hook to our wrapping function
sys.excepthook = my_exception_hook
sys.exit(cli(sys.argv[1:]))

View file

@ -0,0 +1,674 @@
import sys
import time
from Qt import QtWidgets, QtCore
from avalon import api, io, style, pipeline
from openpype.tools.utils.widgets import AssetWidget
from openpype.tools.utils import lib
from .widgets import (
SubsetWidget,
VersionWidget,
FamilyListWidget,
ThumbnailWidget,
RepresentationWidget,
OverlayFrame
)
from openpype.modules import ModulesManager
module = sys.modules[__name__]
module.window = None
# Register callback on task change
# - callback can't be defined in Window as it is weak reference callback
# so `WeakSet` will remove it immidiatelly
def on_context_task_change(*args, **kwargs):
if module.window:
module.window.on_context_task_change(*args, **kwargs)
pipeline.on("taskChanged", on_context_task_change)
class LoaderWindow(QtWidgets.QDialog):
"""Asset loader interface"""
tool_name = "loader"
def __init__(self, parent=None):
super(LoaderWindow, self).__init__(parent)
title = "Asset Loader 2.1"
project_name = api.Session.get("AVALON_PROJECT")
if project_name:
title += " - {}".format(project_name)
self.setWindowTitle(title)
# Groups config
self.groups_config = lib.GroupsConfig(io)
self.family_config_cache = lib.FamilyConfigCache(io)
# Enable minimize and maximize for app
self.setWindowFlags(QtCore.Qt.Window)
self.setFocusPolicy(QtCore.Qt.StrongFocus)
body = QtWidgets.QWidget()
footer = QtWidgets.QWidget()
footer.setFixedHeight(20)
container = QtWidgets.QWidget()
assets = AssetWidget(io, multiselection=True, parent=self)
assets.set_current_asset_btn_visibility(True)
families = FamilyListWidget(io, self.family_config_cache, self)
subsets = SubsetWidget(
io,
self.groups_config,
self.family_config_cache,
tool_name=self.tool_name,
parent=self
)
version = VersionWidget(io)
thumbnail = ThumbnailWidget(io)
representations = RepresentationWidget(io, self.tool_name)
manager = ModulesManager()
sync_server = manager.modules_by_name["sync_server"]
thumb_ver_splitter = QtWidgets.QSplitter()
thumb_ver_splitter.setOrientation(QtCore.Qt.Vertical)
thumb_ver_splitter.addWidget(thumbnail)
thumb_ver_splitter.addWidget(version)
if sync_server.enabled:
thumb_ver_splitter.addWidget(representations)
thumb_ver_splitter.setStretchFactor(0, 30)
thumb_ver_splitter.setStretchFactor(1, 35)
# Create splitter to show / hide family filters
asset_filter_splitter = QtWidgets.QSplitter()
asset_filter_splitter.setOrientation(QtCore.Qt.Vertical)
asset_filter_splitter.addWidget(assets)
asset_filter_splitter.addWidget(families)
asset_filter_splitter.setStretchFactor(0, 65)
asset_filter_splitter.setStretchFactor(1, 35)
container_layout = QtWidgets.QHBoxLayout(container)
container_layout.setContentsMargins(0, 0, 0, 0)
split = QtWidgets.QSplitter()
split.addWidget(asset_filter_splitter)
split.addWidget(subsets)
split.addWidget(thumb_ver_splitter)
container_layout.addWidget(split)
body_layout = QtWidgets.QHBoxLayout(body)
body_layout.addWidget(container)
body_layout.setContentsMargins(0, 0, 0, 0)
message = QtWidgets.QLabel()
message.hide()
footer_layout = QtWidgets.QVBoxLayout(footer)
footer_layout.addWidget(message)
footer_layout.setContentsMargins(0, 0, 0, 0)
layout = QtWidgets.QVBoxLayout(self)
layout.addWidget(body)
layout.addWidget(footer)
self.data = {
"widgets": {
"families": families,
"assets": assets,
"subsets": subsets,
"version": version,
"thumbnail": thumbnail,
"representations": representations
},
"label": {
"message": message,
},
"state": {
"assetIds": None
}
}
overlay_frame = OverlayFrame("Loading...", self)
overlay_frame.setVisible(False)
families.active_changed.connect(subsets.set_family_filters)
assets.selection_changed.connect(self.on_assetschanged)
assets.refresh_triggered.connect(self.on_assetschanged)
assets.view.clicked.connect(self.on_assetview_click)
subsets.active_changed.connect(self.on_subsetschanged)
subsets.version_changed.connect(self.on_versionschanged)
subsets.load_started.connect(self._on_load_start)
subsets.load_ended.connect(self._on_load_end)
representations.load_started.connect(self._on_load_start)
representations.load_ended.connect(self._on_load_end)
self._overlay_frame = overlay_frame
self.family_config_cache.refresh()
self.groups_config.refresh()
self._refresh()
self._assetschanged()
# Defaults
if sync_server.enabled:
split.setSizes([250, 1000, 550])
self.resize(1800, 900)
else:
split.setSizes([250, 850, 200])
self.resize(1300, 700)
def resizeEvent(self, event):
super(LoaderWindow, self).resizeEvent(event)
self._overlay_frame.resize(self.size())
def moveEvent(self, event):
super(LoaderWindow, self).moveEvent(event)
self._overlay_frame.move(0, 0)
# -------------------------------
# Delay calling blocking methods
# -------------------------------
def on_assetview_click(self, *args):
subsets_widget = self.data["widgets"]["subsets"]
selection_model = subsets_widget.view.selectionModel()
if selection_model.selectedIndexes():
selection_model.clearSelection()
def refresh(self):
self.echo("Fetching results..")
lib.schedule(self._refresh, 50, channel="mongo")
def on_assetschanged(self, *args):
self.echo("Fetching asset..")
lib.schedule(self._assetschanged, 50, channel="mongo")
def on_subsetschanged(self, *args):
self.echo("Fetching subset..")
lib.schedule(self._subsetschanged, 50, channel="mongo")
def on_versionschanged(self, *args):
self.echo("Fetching version..")
lib.schedule(self._versionschanged, 150, channel="mongo")
def set_context(self, context, refresh=True):
self.echo("Setting context: {}".format(context))
lib.schedule(lambda: self._set_context(context, refresh=refresh),
50, channel="mongo")
def _on_load_start(self):
# Show overlay and process events so it's repainted
self._overlay_frame.setVisible(True)
QtWidgets.QApplication.processEvents()
def _hide_overlay(self):
self._overlay_frame.setVisible(False)
def _on_load_end(self):
# Delay hiding as click events happened during loading should be
# blocked
QtCore.QTimer.singleShot(100, self._hide_overlay)
# ------------------------------
def on_context_task_change(self, *args, **kwargs):
# Change to context asset on context change
assets_widget = self.data["widgets"]["assets"]
assets_widget.select_assets(io.Session["AVALON_ASSET"])
def _refresh(self):
"""Load assets from database"""
# Ensure a project is loaded
project = io.find_one({"type": "project"}, {"type": 1})
assert project, "Project was not found! This is a bug"
assets_widget = self.data["widgets"]["assets"]
assets_widget.refresh()
assets_widget.setFocus()
families = self.data["widgets"]["families"]
families.refresh()
def clear_assets_underlines(self):
"""Clear colors from asset data to remove colored underlines
When multiple assets are selected colored underlines mark which asset
own selected subsets. These colors must be cleared from asset data
on selection change so they match current selection.
"""
last_asset_ids = self.data["state"]["assetIds"]
if not last_asset_ids:
return
assets_widget = self.data["widgets"]["assets"]
id_role = assets_widget.model.ObjectIdRole
for index in lib.iter_model_rows(assets_widget.model, 0):
if index.data(id_role) not in last_asset_ids:
continue
assets_widget.model.setData(
index, [], assets_widget.model.subsetColorsRole
)
def _assetschanged(self):
"""Selected assets have changed"""
t1 = time.time()
assets_widget = self.data["widgets"]["assets"]
subsets_widget = self.data["widgets"]["subsets"]
subsets_model = subsets_widget.model
subsets_model.clear()
self.clear_assets_underlines()
# filter None docs they are silo
asset_docs = assets_widget.get_selected_assets()
asset_ids = [asset_doc["_id"] for asset_doc in asset_docs]
# Start loading
subsets_widget.set_loading_state(
loading=bool(asset_ids),
empty=True
)
def on_refreshed(has_item):
empty = not has_item
subsets_widget.set_loading_state(loading=False, empty=empty)
subsets_model.refreshed.disconnect()
self.echo("Duration: %.3fs" % (time.time() - t1))
subsets_model.refreshed.connect(on_refreshed)
subsets_model.set_assets(asset_ids)
subsets_widget.view.setColumnHidden(
subsets_model.Columns.index("asset"),
len(asset_ids) < 2
)
# Clear the version information on asset change
self.data["widgets"]["version"].set_version(None)
self.data["widgets"]["thumbnail"].set_thumbnail(asset_docs)
self.data["state"]["assetIds"] = asset_ids
representations = self.data["widgets"]["representations"]
representations.set_version_ids([]) # reset repre list
def _subsetschanged(self):
asset_ids = self.data["state"]["assetIds"]
# Skip setting colors if not asset multiselection
if not asset_ids or len(asset_ids) < 2:
self._versionschanged()
return
subsets = self.data["widgets"]["subsets"]
selected_subsets = subsets.selected_subsets(_merged=True, _other=False)
asset_models = {}
asset_ids = []
for subset_node in selected_subsets:
asset_ids.extend(subset_node.get("assetIds", []))
asset_ids = set(asset_ids)
for subset_node in selected_subsets:
for asset_id in asset_ids:
if asset_id not in asset_models:
asset_models[asset_id] = []
color = None
if asset_id in subset_node.get("assetIds", []):
color = subset_node["subsetColor"]
asset_models[asset_id].append(color)
self.clear_assets_underlines()
assets_widget = self.data["widgets"]["assets"]
indexes = assets_widget.view.selectionModel().selectedRows()
for index in indexes:
id = index.data(assets_widget.model.ObjectIdRole)
if id not in asset_models:
continue
assets_widget.model.setData(
index, asset_models[id], assets_widget.model.subsetColorsRole
)
# Trigger repaint
assets_widget.view.updateGeometries()
# Set version in Version Widget
self._versionschanged()
def _versionschanged(self):
subsets = self.data["widgets"]["subsets"]
selection = subsets.view.selectionModel()
# Active must be in the selected rows otherwise we
# assume it's not actually an "active" current index.
version_docs = None
version_doc = None
active = selection.currentIndex()
rows = selection.selectedRows(column=active.column())
if active:
if active in rows:
item = active.data(subsets.model.ItemRole)
if (
item is not None and
not (item.get("isGroup") or item.get("isMerged"))
):
version_doc = item["version_document"]
if rows:
version_docs = []
for index in rows:
if not index or not index.isValid():
continue
item = index.data(subsets.model.ItemRole)
if item is None:
continue
if item.get("isGroup") or item.get("isMerged"):
for child in item.children():
version_docs.append(child["version_document"])
else:
version_docs.append(item["version_document"])
self.data["widgets"]["version"].set_version(version_doc)
thumbnail_docs = version_docs
assets_widget = self.data["widgets"]["assets"]
asset_docs = assets_widget.get_selected_assets()
if not thumbnail_docs:
if len(asset_docs) > 0:
thumbnail_docs = asset_docs
self.data["widgets"]["thumbnail"].set_thumbnail(thumbnail_docs)
representations = self.data["widgets"]["representations"]
version_ids = [doc["_id"] for doc in version_docs or []]
representations.set_version_ids(version_ids)
# representations.change_visibility("subset", len(rows) > 1)
# representations.change_visibility("asset", len(asset_docs) > 1)
def _set_context(self, context, refresh=True):
"""Set the selection in the interface using a context.
The context must contain `asset` data by name.
Note: Prior to setting context ensure `refresh` is triggered so that
the "silos" are listed correctly, aside from that setting the
context will force a refresh further down because it changes
the active silo and asset.
Args:
context (dict): The context to apply.
Returns:
None
"""
asset = context.get("asset", None)
if asset is None:
return
if refresh:
# Workaround:
# Force a direct (non-scheduled) refresh prior to setting the
# asset widget's silo and asset selection to ensure it's correctly
# displaying the silo tabs. Calling `window.refresh()` and directly
# `window.set_context()` the `set_context()` seems to override the
# scheduled refresh and the silo tabs are not shown.
self._refresh()
asset_widget = self.data["widgets"]["assets"]
asset_widget.select_assets(asset)
def echo(self, message):
widget = self.data["label"]["message"]
widget.setText(str(message))
widget.show()
print(message)
lib.schedule(widget.hide, 5000, channel="message")
def closeEvent(self, event):
# Kill on holding SHIFT
modifiers = QtWidgets.QApplication.queryKeyboardModifiers()
shift_pressed = QtCore.Qt.ShiftModifier & modifiers
if shift_pressed:
print("Force quitted..")
self.setAttribute(QtCore.Qt.WA_DeleteOnClose)
print("Good bye")
return super(LoaderWindow, self).closeEvent(event)
def keyPressEvent(self, event):
modifiers = event.modifiers()
ctrl_pressed = QtCore.Qt.ControlModifier & modifiers
# Grouping subsets on pressing Ctrl + G
if (ctrl_pressed and event.key() == QtCore.Qt.Key_G and
not event.isAutoRepeat()):
self.show_grouping_dialog()
return
super(LoaderWindow, self).keyPressEvent(event)
event.setAccepted(True) # Avoid interfering other widgets
def show_grouping_dialog(self):
subsets = self.data["widgets"]["subsets"]
if not subsets.is_groupable():
self.echo("Grouping not enabled.")
return
selected = []
merged_items = []
for item in subsets.selected_subsets(_merged=True):
if item.get("isMerged"):
merged_items.append(item)
else:
selected.append(item)
for merged_item in merged_items:
for child_item in merged_item.children():
selected.append(child_item)
if not selected:
self.echo("No selected subset.")
return
dialog = SubsetGroupingDialog(
items=selected, groups_config=self.groups_config, parent=self
)
dialog.grouped.connect(self._assetschanged)
dialog.show()
class SubsetGroupingDialog(QtWidgets.QDialog):
grouped = QtCore.Signal()
def __init__(self, items, groups_config, parent=None):
super(SubsetGroupingDialog, self).__init__(parent=parent)
self.setWindowTitle("Grouping Subsets")
self.setMinimumWidth(250)
self.setModal(True)
self.items = items
self.groups_config = groups_config
self.subsets = parent.data["widgets"]["subsets"]
self.asset_ids = parent.data["state"]["assetIds"]
name = QtWidgets.QLineEdit()
name.setPlaceholderText("Remain blank to ungroup..")
# Menu for pre-defined subset groups
name_button = QtWidgets.QPushButton()
name_button.setFixedWidth(18)
name_button.setFixedHeight(20)
name_menu = QtWidgets.QMenu(name_button)
name_button.setMenu(name_menu)
name_layout = QtWidgets.QHBoxLayout()
name_layout.addWidget(name)
name_layout.addWidget(name_button)
name_layout.setContentsMargins(0, 0, 0, 0)
group_btn = QtWidgets.QPushButton("Apply")
layout = QtWidgets.QVBoxLayout(self)
layout.addWidget(QtWidgets.QLabel("Group Name"))
layout.addLayout(name_layout)
layout.addWidget(group_btn)
group_btn.clicked.connect(self.on_group)
group_btn.setAutoDefault(True)
group_btn.setDefault(True)
self.name = name
self.name_menu = name_menu
self._build_menu()
def _build_menu(self):
menu = self.name_menu
button = menu.parent()
# Get and destroy the action group
group = button.findChild(QtWidgets.QActionGroup)
if group:
group.deleteLater()
active_groups = self.groups_config.active_groups(self.asset_ids)
# Build new action group
group = QtWidgets.QActionGroup(button)
group_names = list()
for data in sorted(active_groups, key=lambda x: x["order"]):
name = data["name"]
if name in group_names:
continue
group_names.append(name)
icon = data["icon"]
action = group.addAction(name)
action.setIcon(icon)
menu.addAction(action)
group.triggered.connect(self._on_action_clicked)
button.setEnabled(not menu.isEmpty())
def _on_action_clicked(self, action):
self.name.setText(action.text())
def on_group(self):
name = self.name.text().strip()
self.subsets.group_subsets(name, self.asset_ids, self.items)
with lib.preserve_selection(tree_view=self.subsets.view,
current_index=False):
self.grouped.emit()
self.close()
def show(debug=False, parent=None, use_context=False):
"""Display Loader GUI
Arguments:
debug (bool, optional): Run loader in debug-mode,
defaults to False
parent (QtCore.QObject, optional): The Qt object to parent to.
use_context (bool): Whether to apply the current context upon launch
"""
# Remember window
if module.window is not None:
try:
module.window.show()
# If the window is minimized then unminimize it.
if module.window.windowState() & QtCore.Qt.WindowMinimized:
module.window.setWindowState(QtCore.Qt.WindowActive)
# Raise and activate the window
module.window.raise_() # for MacOS
module.window.activateWindow() # for Windows
module.window.refresh()
return
except (AttributeError, RuntimeError):
# Garbage collected
module.window = None
if debug:
import traceback
sys.excepthook = lambda typ, val, tb: traceback.print_last()
io.install()
any_project = next(
project for project in io.projects()
if project.get("active", True) is not False
)
api.Session["AVALON_PROJECT"] = any_project["name"]
module.project = any_project["name"]
with lib.application():
window = LoaderWindow(parent)
window.setStyleSheet(style.load_stylesheet())
window.show()
if use_context:
context = {"asset": api.Session["AVALON_ASSET"]}
window.set_context(context, refresh=True)
else:
window.refresh()
module.window = window
# Pull window to the front.
module.window.raise_()
module.window.activateWindow()
def cli(args):
import argparse
parser = argparse.ArgumentParser()
parser.add_argument("project")
args = parser.parse_args(args)
project = args.project
print("Entering Project: %s" % project)
io.install()
# Store settings
api.Session["AVALON_PROJECT"] = project
from avalon import pipeline
# Find the set config
_config = pipeline.find_config()
if hasattr(_config, "install"):
_config.install()
else:
print("Config `%s` has no function `install`" %
_config.__name__)
show()

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

View file

@ -0,0 +1,190 @@
import inspect
from Qt import QtGui
from avalon.vendor import qtawesome
from openpype.tools.utils.widgets import (
OptionalAction,
OptionDialog
)
def change_visibility(model, view, column_name, visible):
"""
Hides or shows particular 'column_name'.
"asset" and "subset" columns should be visible only in multiselect
"""
index = model.Columns.index(column_name)
view.setColumnHidden(index, not visible)
def get_selected_items(rows, item_role):
items = []
for row_index in rows:
item = row_index.data(item_role)
if item.get("isGroup"):
continue
elif item.get("isMerged"):
for idx in range(row_index.model().rowCount(row_index)):
child_index = row_index.child(idx, 0)
item = child_index.data(item_role)
if item not in items:
items.append(item)
else:
if item not in items:
items.append(item)
return items
def get_options(action, loader, parent, repre_contexts):
"""Provides dialog to select value from loader provided options.
Loader can provide static or dynamically created options based on
qargparse variants.
Args:
action (OptionalAction) - action in menu
loader (cls of api.Loader) - not initilized yet
parent (Qt element to parent dialog to)
repre_contexts (list) of dict with full info about selected repres
Returns:
(dict) - selected value from OptionDialog
None when dialog was closed or cancelled, in all other cases {}
if no options
"""
# Pop option dialog
options = {}
loader_options = loader.get_options(repre_contexts)
if getattr(action, "optioned", False) and loader_options:
dialog = OptionDialog(parent)
dialog.setWindowTitle(action.label + " Options")
dialog.create(loader_options)
if not dialog.exec_():
return None
# Get option
options = dialog.parse()
return options
def add_representation_loaders_to_menu(loaders, menu, repre_contexts):
"""
Loops through provider loaders and adds them to 'menu'.
Expects loaders sorted in requested order.
Expects loaders de-duplicated if wanted.
Args:
loaders(tuple): representation - loader
menu (OptionalMenu):
repre_contexts (dict): full info about representations (contains
their repre_doc, asset_doc, subset_doc, version_doc),
keys are repre_ids
Returns:
menu (OptionalMenu): with new items
"""
# List the available loaders
for representation, loader in loaders:
label = None
repre_context = None
if representation:
label = representation.get("custom_label")
repre_context = repre_contexts[representation["_id"]]
if not label:
label = get_label_from_loader(loader, representation)
icon = get_icon_from_loader(loader)
loader_options = loader.get_options([repre_context])
use_option = bool(loader_options)
action = OptionalAction(label, icon, use_option, menu)
if use_option:
# Add option box tip
action.set_option_tip(loader_options)
action.setData((representation, loader))
# Add tooltip and statustip from Loader docstring
tip = inspect.getdoc(loader)
if tip:
action.setToolTip(tip)
action.setStatusTip(tip)
menu.addAction(action)
return menu
def remove_tool_name_from_loaders(available_loaders, tool_name):
if not tool_name:
return available_loaders
filtered_loaders = []
for loader in available_loaders:
if hasattr(loader, "tool_names"):
if not ("*" in loader.tool_names or
tool_name in loader.tool_names):
continue
filtered_loaders.append(loader)
return filtered_loaders
def get_icon_from_loader(loader):
"""Pull icon info from loader class"""
# Support font-awesome icons using the `.icon` and `.color`
# attributes on plug-ins.
icon = getattr(loader, "icon", None)
if icon is not None:
try:
key = "fa.{0}".format(icon)
color = getattr(loader, "color", "white")
icon = qtawesome.icon(key, color=color)
except Exception as e:
print("Unable to set icon for loader "
"{}: {}".format(loader, e))
icon = None
return icon
def get_label_from_loader(loader, representation=None):
"""Pull label info from loader class"""
label = getattr(loader, "label", None)
if label is None:
label = loader.__name__
if representation:
# Add the representation as suffix
label = "{0} ({1})".format(label, representation['name'])
return label
def get_no_loader_action(menu, one_item_selected=False):
"""Creates dummy no loader option in 'menu'"""
submsg = "your selection."
if one_item_selected:
submsg = "this version."
msg = "No compatible loaders for {}".format(submsg)
print(msg)
icon = qtawesome.icon(
"fa.exclamation",
color=QtGui.QColor(255, 51, 0)
)
action = OptionalAction(("*" + msg), icon, False, menu)
return action
def sort_loaders(loaders, custom_sorter=None):
def sorter(value):
"""Sort the Loaders by their order and then their name"""
Plugin = value[1]
return Plugin.order, Plugin.__name__
if not custom_sorter:
custom_sorter = sorter
return sorted(loaders, key=custom_sorter)

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

View file

@ -0,0 +1,449 @@
import time
from datetime import datetime
import logging
import numbers
import Qt
from Qt import QtWidgets, QtGui, QtCore
from avalon.lib import HeroVersionType
from .models import (
AssetModel,
TreeModel
)
from . import lib
if Qt.__binding__ == "PySide":
from PySide.QtGui import QStyleOptionViewItemV4
elif Qt.__binding__ == "PyQt4":
from PyQt4.QtGui import QStyleOptionViewItemV4
log = logging.getLogger(__name__)
class AssetDelegate(QtWidgets.QItemDelegate):
bar_height = 3
def sizeHint(self, option, index):
result = super(AssetDelegate, self).sizeHint(option, index)
height = result.height()
result.setHeight(height + self.bar_height)
return result
def paint(self, painter, option, index):
# Qt4 compat
if Qt.__binding__ in ("PySide", "PyQt4"):
option = QStyleOptionViewItemV4(option)
painter.save()
item_rect = QtCore.QRect(option.rect)
item_rect.setHeight(option.rect.height() - self.bar_height)
subset_colors = index.data(AssetModel.subsetColorsRole)
subset_colors_width = 0
if subset_colors:
subset_colors_width = option.rect.width() / len(subset_colors)
subset_rects = []
counter = 0
for subset_c in subset_colors:
new_color = None
new_rect = None
if subset_c:
new_color = QtGui.QColor(*subset_c)
new_rect = QtCore.QRect(
option.rect.left() + (counter * subset_colors_width),
option.rect.top() + (
option.rect.height() - self.bar_height
),
subset_colors_width,
self.bar_height
)
subset_rects.append((new_color, new_rect))
counter += 1
# Background
bg_color = QtGui.QColor(60, 60, 60)
if option.state & QtWidgets.QStyle.State_Selected:
if len(subset_colors) == 0:
item_rect.setTop(item_rect.top() + (self.bar_height / 2))
if option.state & QtWidgets.QStyle.State_MouseOver:
bg_color.setRgb(70, 70, 70)
else:
item_rect.setTop(item_rect.top() + (self.bar_height / 2))
if option.state & QtWidgets.QStyle.State_MouseOver:
bg_color.setAlpha(100)
else:
bg_color.setAlpha(0)
# When not needed to do a rounded corners (easier and without
# painter restore):
# painter.fillRect(
# item_rect,
# QtGui.QBrush(bg_color)
# )
pen = painter.pen()
pen.setStyle(QtCore.Qt.NoPen)
pen.setWidth(0)
painter.setPen(pen)
painter.setBrush(QtGui.QBrush(bg_color))
painter.drawRoundedRect(option.rect, 3, 3)
if option.state & QtWidgets.QStyle.State_Selected:
for color, subset_rect in subset_rects:
if not color or not subset_rect:
continue
painter.fillRect(subset_rect, QtGui.QBrush(color))
painter.restore()
painter.save()
# Icon
icon_index = index.model().index(
index.row(), index.column(), index.parent()
)
# - Default icon_rect if not icon
icon_rect = QtCore.QRect(
item_rect.left(),
item_rect.top(),
# To make sure it's same size all the time
option.rect.height() - self.bar_height,
option.rect.height() - self.bar_height
)
icon = index.model().data(icon_index, QtCore.Qt.DecorationRole)
if icon:
mode = QtGui.QIcon.Normal
if not (option.state & QtWidgets.QStyle.State_Enabled):
mode = QtGui.QIcon.Disabled
elif option.state & QtWidgets.QStyle.State_Selected:
mode = QtGui.QIcon.Selected
if isinstance(icon, QtGui.QPixmap):
icon = QtGui.QIcon(icon)
option.decorationSize = icon.size() / icon.devicePixelRatio()
elif isinstance(icon, QtGui.QColor):
pixmap = QtGui.QPixmap(option.decorationSize)
pixmap.fill(icon)
icon = QtGui.QIcon(pixmap)
elif isinstance(icon, QtGui.QImage):
icon = QtGui.QIcon(QtGui.QPixmap.fromImage(icon))
option.decorationSize = icon.size() / icon.devicePixelRatio()
elif isinstance(icon, QtGui.QIcon):
state = QtGui.QIcon.Off
if option.state & QtWidgets.QStyle.State_Open:
state = QtGui.QIcon.On
actualSize = option.icon.actualSize(
option.decorationSize, mode, state
)
option.decorationSize = QtCore.QSize(
min(option.decorationSize.width(), actualSize.width()),
min(option.decorationSize.height(), actualSize.height())
)
state = QtGui.QIcon.Off
if option.state & QtWidgets.QStyle.State_Open:
state = QtGui.QIcon.On
icon.paint(
painter, icon_rect,
QtCore.Qt.AlignLeft, mode, state
)
# Text
text_rect = QtCore.QRect(
icon_rect.left() + icon_rect.width() + 2,
item_rect.top(),
item_rect.width(),
item_rect.height()
)
painter.drawText(
text_rect, QtCore.Qt.AlignVCenter,
index.data(QtCore.Qt.DisplayRole)
)
painter.restore()
class VersionDelegate(QtWidgets.QStyledItemDelegate):
"""A delegate that display version integer formatted as version string."""
version_changed = QtCore.Signal()
first_run = False
lock = False
def __init__(self, dbcon, *args, **kwargs):
self.dbcon = dbcon
super(VersionDelegate, self).__init__(*args, **kwargs)
def displayText(self, value, locale):
if isinstance(value, HeroVersionType):
return lib.format_version(value, True)
assert isinstance(value, numbers.Integral), (
"Version is not integer. \"{}\" {}".format(value, str(type(value)))
)
return lib.format_version(value)
def paint(self, painter, option, index):
fg_color = index.data(QtCore.Qt.ForegroundRole)
if fg_color:
if isinstance(fg_color, QtGui.QBrush):
fg_color = fg_color.color()
elif isinstance(fg_color, QtGui.QColor):
pass
else:
fg_color = None
if not fg_color:
return super(VersionDelegate, self).paint(painter, option, index)
if option.widget:
style = option.widget.style()
else:
style = QtWidgets.QApplication.style()
style.drawControl(
style.CE_ItemViewItem, option, painter, option.widget
)
painter.save()
text = self.displayText(
index.data(QtCore.Qt.DisplayRole), option.locale
)
pen = painter.pen()
pen.setColor(fg_color)
painter.setPen(pen)
text_rect = style.subElementRect(style.SE_ItemViewItemText, option)
text_margin = style.proxy().pixelMetric(
style.PM_FocusFrameHMargin, option, option.widget
) + 1
painter.drawText(
text_rect.adjusted(text_margin, 0, - text_margin, 0),
option.displayAlignment,
text
)
painter.restore()
def createEditor(self, parent, option, index):
item = index.data(TreeModel.ItemRole)
if item.get("isGroup") or item.get("isMerged"):
return
editor = QtWidgets.QComboBox(parent)
def commit_data():
if not self.first_run:
self.commitData.emit(editor) # Update model data
self.version_changed.emit() # Display model data
editor.currentIndexChanged.connect(commit_data)
self.first_run = True
self.lock = False
return editor
def setEditorData(self, editor, index):
if self.lock:
# Only set editor data once per delegation
return
editor.clear()
# Current value of the index
item = index.data(TreeModel.ItemRole)
value = index.data(QtCore.Qt.DisplayRole)
if item["version_document"]["type"] != "hero_version":
assert isinstance(value, numbers.Integral), (
"Version is not integer"
)
# Add all available versions to the editor
parent_id = item["version_document"]["parent"]
version_docs = list(self.dbcon.find(
{
"type": "version",
"parent": parent_id
},
sort=[("name", 1)]
))
hero_version_doc = self.dbcon.find_one(
{
"type": "hero_version",
"parent": parent_id
}, {
"name": 1,
"data.tags": 1,
"version_id": 1
}
)
doc_for_hero_version = None
selected = None
items = []
for version_doc in version_docs:
version_tags = version_doc["data"].get("tags") or []
if "deleted" in version_tags:
continue
if (
hero_version_doc
and doc_for_hero_version is None
and hero_version_doc["version_id"] == version_doc["_id"]
):
doc_for_hero_version = version_doc
label = lib.format_version(version_doc["name"])
item = QtGui.QStandardItem(label)
item.setData(version_doc, QtCore.Qt.UserRole)
items.append(item)
if version_doc["name"] == value:
selected = item
if hero_version_doc and doc_for_hero_version:
version_name = doc_for_hero_version["name"]
label = lib.format_version(version_name, True)
if isinstance(value, HeroVersionType):
index = len(version_docs)
hero_version_doc["name"] = HeroVersionType(version_name)
item = QtGui.QStandardItem(label)
item.setData(hero_version_doc, QtCore.Qt.UserRole)
items.append(item)
# Reverse items so latest versions be upper
items = list(reversed(items))
for item in items:
editor.model().appendRow(item)
index = 0
if selected:
index = selected.row()
# Will trigger index-change signal
editor.setCurrentIndex(index)
self.first_run = False
self.lock = True
def setModelData(self, editor, model, index):
"""Apply the integer version back in the model"""
version = editor.itemData(editor.currentIndex())
model.setData(index, version["name"])
def pretty_date(t, now=None, strftime="%b %d %Y %H:%M"):
"""Parse datetime to readable timestamp
Within first ten seconds:
- "just now",
Within first minute ago:
- "%S seconds ago"
Within one hour ago:
- "%M minutes ago".
Within one day ago:
- "%H:%M hours ago"
Else:
"%Y-%m-%d %H:%M:%S"
"""
assert isinstance(t, datetime)
if now is None:
now = datetime.now()
assert isinstance(now, datetime)
diff = now - t
second_diff = diff.seconds
day_diff = diff.days
# future (consider as just now)
if day_diff < 0:
return "just now"
# history
if day_diff == 0:
if second_diff < 10:
return "just now"
if second_diff < 60:
return str(second_diff) + " seconds ago"
if second_diff < 120:
return "a minute ago"
if second_diff < 3600:
return str(second_diff // 60) + " minutes ago"
if second_diff < 86400:
minutes = (second_diff % 3600) // 60
hours = second_diff // 3600
return "{0}:{1:02d} hours ago".format(hours, minutes)
return t.strftime(strftime)
def pretty_timestamp(t, now=None):
"""Parse timestamp to user readable format
>>> pretty_timestamp("20170614T151122Z", now="20170614T151123Z")
'just now'
>>> pretty_timestamp("20170614T151122Z", now="20170614T171222Z")
'2:01 hours ago'
Args:
t (str): The time string to parse.
now (str, optional)
Returns:
str: human readable "recent" date.
"""
if now is not None:
try:
now = time.strptime(now, "%Y%m%dT%H%M%SZ")
now = datetime.fromtimestamp(time.mktime(now))
except ValueError as e:
log.warning("Can't parse 'now' time format: {0} {1}".format(t, e))
return None
if isinstance(t, float):
dt = datetime.fromtimestamp(t)
else:
# Parse the time format as if it is `str` result from
# `pyblish.lib.time()` which usually is stored in Avalon database.
try:
t = time.strptime(t, "%Y%m%dT%H%M%SZ")
except ValueError as e:
log.warning("Can't parse time format: {0} {1}".format(t, e))
return None
dt = datetime.fromtimestamp(time.mktime(t))
# prettify
return pretty_date(dt, now=now)
class PrettyTimeDelegate(QtWidgets.QStyledItemDelegate):
"""A delegate that displays a timestamp as a pretty date.
This displays dates like `pretty_date`.
"""
def displayText(self, value, locale):
if value is None:
# Ignore None value
return
return pretty_timestamp(value)

622
openpype/tools/utils/lib.py Normal file
View file

@ -0,0 +1,622 @@
import os
import sys
import contextlib
import collections
from Qt import QtWidgets, QtCore, QtGui
from avalon import io, api, style
from avalon.vendor import qtawesome
self = sys.modules[__name__]
self._jobs = dict()
class SharedObjects:
# Variable for family cache in global context
# QUESTION is this safe? More than one tool can refresh at the same time.
family_cache = None
def global_family_cache():
if SharedObjects.family_cache is None:
SharedObjects.family_cache = FamilyConfigCache(io)
return SharedObjects.family_cache
def format_version(value, hero_version=False):
"""Formats integer to displayable version name"""
label = "v{0:03d}".format(value)
if not hero_version:
return label
return "[{}]".format(label)
@contextlib.contextmanager
def application():
app = QtWidgets.QApplication.instance()
if not app:
print("Starting new QApplication..")
app = QtWidgets.QApplication(sys.argv)
yield app
app.exec_()
else:
print("Using existing QApplication..")
yield app
def defer(delay, func):
"""Append artificial delay to `func`
This aids in keeping the GUI responsive, but complicates logic
when producing tests. To combat this, the environment variable ensures
that every operation is synchonous.
Arguments:
delay (float): Delay multiplier; default 1, 0 means no delay
func (callable): Any callable
"""
delay *= float(os.getenv("PYBLISH_DELAY", 1))
if delay > 0:
return QtCore.QTimer.singleShot(delay, func)
else:
return func()
def schedule(func, time, channel="default"):
"""Run `func` at a later `time` in a dedicated `channel`
Given an arbitrary function, call this function after a given
timeout. It will ensure that only one "job" is running within
the given channel at any one time and cancel any currently
running job if a new job is submitted before the timeout.
"""
try:
self._jobs[channel].stop()
except (AttributeError, KeyError, RuntimeError):
pass
timer = QtCore.QTimer()
timer.setSingleShot(True)
timer.timeout.connect(func)
timer.start(time)
self._jobs[channel] = timer
@contextlib.contextmanager
def dummy():
"""Dummy context manager
Usage:
>> with some_context() if False else dummy():
.. pass
"""
yield
def iter_model_rows(model, column, include_root=False):
"""Iterate over all row indices in a model"""
indices = [QtCore.QModelIndex()] # start iteration at root
for index in indices:
# Add children to the iterations
child_rows = model.rowCount(index)
for child_row in range(child_rows):
child_index = model.index(child_row, column, index)
indices.append(child_index)
if not include_root and not index.isValid():
continue
yield index
@contextlib.contextmanager
def preserve_states(tree_view,
column=0,
role=None,
preserve_expanded=True,
preserve_selection=True,
expanded_role=QtCore.Qt.DisplayRole,
selection_role=QtCore.Qt.DisplayRole):
"""Preserves row selection in QTreeView by column's data role.
This function is created to maintain the selection status of
the model items. When refresh is triggered the items which are expanded
will stay expanded and vise versa.
tree_view (QWidgets.QTreeView): the tree view nested in the application
column (int): the column to retrieve the data from
role (int): the role which dictates what will be returned
Returns:
None
"""
# When `role` is set then override both expanded and selection roles
if role:
expanded_role = role
selection_role = role
model = tree_view.model()
selection_model = tree_view.selectionModel()
flags = selection_model.Select | selection_model.Rows
expanded = set()
if preserve_expanded:
for index in iter_model_rows(
model, column=column, include_root=False
):
if tree_view.isExpanded(index):
value = index.data(expanded_role)
expanded.add(value)
selected = None
if preserve_selection:
selected_rows = selection_model.selectedRows()
if selected_rows:
selected = set(row.data(selection_role) for row in selected_rows)
try:
yield
finally:
if expanded:
for index in iter_model_rows(
model, column=0, include_root=False
):
value = index.data(expanded_role)
is_expanded = value in expanded
# skip if new index was created meanwhile
if is_expanded is None:
continue
tree_view.setExpanded(index, is_expanded)
if selected:
# Go through all indices, select the ones with similar data
for index in iter_model_rows(
model, column=column, include_root=False
):
value = index.data(selection_role)
state = value in selected
if state:
tree_view.scrollTo(index) # Ensure item is visible
selection_model.select(index, flags)
@contextlib.contextmanager
def preserve_expanded_rows(tree_view, column=0, role=None):
"""Preserves expanded row in QTreeView by column's data role.
This function is created to maintain the expand vs collapse status of
the model items. When refresh is triggered the items which are expanded
will stay expanded and vise versa.
Arguments:
tree_view (QWidgets.QTreeView): the tree view which is
nested in the application
column (int): the column to retrieve the data from
role (int): the role which dictates what will be returned
Returns:
None
"""
if role is None:
role = QtCore.Qt.DisplayRole
model = tree_view.model()
expanded = set()
for index in iter_model_rows(model, column=column, include_root=False):
if tree_view.isExpanded(index):
value = index.data(role)
expanded.add(value)
try:
yield
finally:
if not expanded:
return
for index in iter_model_rows(model, column=column, include_root=False):
value = index.data(role)
state = value in expanded
if state:
tree_view.expand(index)
else:
tree_view.collapse(index)
@contextlib.contextmanager
def preserve_selection(tree_view, column=0, role=None, current_index=True):
"""Preserves row selection in QTreeView by column's data role.
This function is created to maintain the selection status of
the model items. When refresh is triggered the items which are expanded
will stay expanded and vise versa.
tree_view (QWidgets.QTreeView): the tree view nested in the application
column (int): the column to retrieve the data from
role (int): the role which dictates what will be returned
Returns:
None
"""
if role is None:
role = QtCore.Qt.DisplayRole
model = tree_view.model()
selection_model = tree_view.selectionModel()
flags = selection_model.Select | selection_model.Rows
if current_index:
current_index_value = tree_view.currentIndex().data(role)
else:
current_index_value = None
selected_rows = selection_model.selectedRows()
if not selected_rows:
yield
return
selected = set(row.data(role) for row in selected_rows)
try:
yield
finally:
if not selected:
return
# Go through all indices, select the ones with similar data
for index in iter_model_rows(model, column=column, include_root=False):
value = index.data(role)
state = value in selected
if state:
tree_view.scrollTo(index) # Ensure item is visible
selection_model.select(index, flags)
if current_index_value and value == current_index_value:
selection_model.setCurrentIndex(
index, selection_model.NoUpdate
)
class FamilyConfigCache:
default_color = "#0091B2"
_default_icon = None
_default_item = None
def __init__(self, dbcon):
self.dbcon = dbcon
self.family_configs = {}
@classmethod
def default_icon(cls):
if cls._default_icon is None:
cls._default_icon = qtawesome.icon(
"fa.folder", color=cls.default_color
)
return cls._default_icon
@classmethod
def default_item(cls):
if cls._default_item is None:
cls._default_item = {"icon": cls.default_icon()}
return cls._default_item
def family_config(self, family_name):
"""Get value from config with fallback to default"""
return self.family_configs.get(family_name, self.default_item())
def refresh(self):
"""Get the family configurations from the database
The configuration must be stored on the project under `config`.
For example:
{"config": {
"families": [
{"name": "avalon.camera", label: "Camera", "icon": "photo"},
{"name": "avalon.anim", label: "Animation", "icon": "male"},
]
}}
It is possible to override the default behavior and set specific
families checked. For example we only want the families imagesequence
and camera to be visible in the Loader.
# This will turn every item off
api.data["familyStateDefault"] = False
# Only allow the imagesequence and camera
api.data["familyStateToggled"] = ["imagesequence", "camera"]
"""
self.family_configs.clear()
families = []
# Update the icons from the project configuration
project_name = self.dbcon.Session.get("AVALON_PROJECT")
if project_name:
project_doc = self.dbcon.find_one(
{"type": "project"},
projection={"config.families": True}
)
if not project_doc:
print((
"Project \"{}\" not found!"
" Can't refresh family icons cache."
).format(project_name))
else:
families = project_doc["config"].get("families") or []
# Check if any family state are being overwritten by the configuration
default_state = api.data.get("familiesStateDefault", True)
toggled = set(api.data.get("familiesStateToggled") or [])
# Replace icons with a Qt icon we can use in the user interfaces
for family in families:
name = family["name"]
# Set family icon
icon = family.get("icon", None)
if icon:
family["icon"] = qtawesome.icon(
"fa.{}".format(icon),
color=self.default_color
)
else:
family["icon"] = self.default_icon()
# Update state
if name in toggled:
state = True
else:
state = default_state
family["state"] = state
self.family_configs[name] = family
return self.family_configs
class GroupsConfig:
# Subset group item's default icon and order
_default_group_config = None
def __init__(self, dbcon):
self.dbcon = dbcon
self.groups = {}
@classmethod
def default_group_config(cls):
if cls._default_group_config is None:
cls._default_group_config = {
"icon": qtawesome.icon(
"fa.object-group",
color=style.colors.default
),
"order": 0
}
return cls._default_group_config
def refresh(self):
"""Get subset group configurations from the database
The 'group' configuration must be stored in the project `config` field.
See schema `config-1.0.json`
"""
# Clear cached groups
self.groups.clear()
group_configs = []
project_name = self.dbcon.Session.get("AVALON_PROJECT")
if project_name:
# Get pre-defined group name and apperance from project config
project_doc = self.dbcon.find_one(
{"type": "project"},
projection={"config.groups": True}
)
if project_doc:
group_configs = project_doc["config"].get("groups") or []
else:
print("Project not found! \"{}\"".format(project_name))
# Build pre-defined group configs
for config in group_configs:
name = config["name"]
icon = "fa." + config.get("icon", "object-group")
color = config.get("color", style.colors.default)
order = float(config.get("order", 0))
self.groups[name] = {
"icon": qtawesome.icon(icon, color=color),
"order": order
}
return self.groups
def ordered_groups(self, group_names):
# default order zero included
_orders = set([0])
for config in self.groups.values():
_orders.add(config["order"])
# Remap order to list index
orders = sorted(_orders)
_groups = list()
for name in group_names:
# Get group config
config = self.groups.get(name) or self.default_group_config()
# Base order
remapped_order = orders.index(config["order"])
data = {
"name": name,
"icon": config["icon"],
"_order": remapped_order,
}
_groups.append(data)
# Sort by tuple (base_order, name)
# If there are multiple groups in same order, will sorted by name.
ordered_groups = sorted(
_groups, key=lambda _group: (_group.pop("_order"), _group["name"])
)
total = len(ordered_groups)
order_temp = "%0{}d".format(len(str(total)))
# Update sorted order to config
for index, group_data in enumerate(ordered_groups):
order = index
inverse_order = total - index
# Format orders into fixed length string for groups sorting
group_data["order"] = order_temp % order
group_data["inverseOrder"] = order_temp % inverse_order
return ordered_groups
def active_groups(self, asset_ids, include_predefined=True):
"""Collect all active groups from each subset"""
# Collect groups from subsets
group_names = set(
self.dbcon.distinct(
"data.subsetGroup",
{"type": "subset", "parent": {"$in": asset_ids}}
)
)
if include_predefined:
# Ensure all predefined group configs will be included
group_names.update(self.groups.keys())
return self.ordered_groups(group_names)
def split_subsets_for_groups(self, subset_docs, grouping):
"""Collect all active groups from each subset"""
subset_docs_without_group = collections.defaultdict(list)
subset_docs_by_group = collections.defaultdict(dict)
for subset_doc in subset_docs:
subset_name = subset_doc["name"]
if grouping:
group_name = subset_doc["data"].get("subsetGroup")
if group_name:
if subset_name not in subset_docs_by_group[group_name]:
subset_docs_by_group[group_name][subset_name] = []
subset_docs_by_group[group_name][subset_name].append(
subset_doc
)
continue
subset_docs_without_group[subset_name].append(subset_doc)
ordered_groups = self.ordered_groups(subset_docs_by_group.keys())
return ordered_groups, subset_docs_without_group, subset_docs_by_group
def create_qthread(func, *args, **kwargs):
class Thread(QtCore.QThread):
def run(self):
func(*args, **kwargs)
return Thread()
def get_repre_icons():
try:
from openpype_modules import sync_server
except Exception:
# Backwards compatibility
from openpype.modules import sync_server
resource_path = os.path.join(
os.path.dirname(sync_server.sync_server_module.__file__),
"providers", "resources"
)
icons = {}
# TODO get from sync module
for provider in ['studio', 'local_drive', 'gdrive']:
pix_url = "{}/{}.png".format(resource_path, provider)
icons[provider] = QtGui.QIcon(pix_url)
return icons
def get_progress_for_repre(doc, active_site, remote_site):
"""
Calculates average progress for representation.
If site has created_dt >> fully available >> progress == 1
Could be calculated in aggregate if it would be too slow
Args:
doc(dict): representation dict
Returns:
(dict) with active and remote sites progress
{'studio': 1.0, 'gdrive': -1} - gdrive site is not present
-1 is used to highlight the site should be added
{'studio': 1.0, 'gdrive': 0.0} - gdrive site is present, not
uploaded yet
"""
progress = {active_site: -1,
remote_site: -1}
if not doc:
return progress
files = {active_site: 0, remote_site: 0}
doc_files = doc.get("files") or []
for doc_file in doc_files:
if not isinstance(doc_file, dict):
continue
sites = doc_file.get("sites") or []
for site in sites:
if (
# Pype 2 compatibility
not isinstance(site, dict)
# Check if site name is one of progress sites
or site["name"] not in progress
):
continue
files[site["name"]] += 1
norm_progress = max(progress[site["name"]], 0)
if site.get("created_dt"):
progress[site["name"]] = norm_progress + 1
elif site.get("progress"):
progress[site["name"]] = norm_progress + site["progress"]
else: # site exists, might be failed, do not add again
progress[site["name"]] = 0
# for example 13 fully avail. files out of 26 >> 13/26 = 0.5
avg_progress = {}
avg_progress[active_site] = \
progress[active_site] / max(files[active_site], 1)
avg_progress[remote_site] = \
progress[remote_site] / max(files[remote_site], 1)
return avg_progress
def is_sync_loader(loader):
return is_remove_site_loader(loader) or is_add_site_loader(loader)
def is_remove_site_loader(loader):
return hasattr(loader, "remove_site_on_representation")
def is_add_site_loader(loader):
return hasattr(loader, "add_site_to_representation")

View file

@ -0,0 +1,500 @@
import re
import time
import logging
import collections
import Qt
from Qt import QtCore, QtGui
from avalon.vendor import qtawesome
from avalon import style, io
from . import lib
log = logging.getLogger(__name__)
class TreeModel(QtCore.QAbstractItemModel):
Columns = list()
ItemRole = QtCore.Qt.UserRole + 1
item_class = None
def __init__(self, parent=None):
super(TreeModel, self).__init__(parent)
self._root_item = self.ItemClass()
@property
def ItemClass(self):
if self.item_class is not None:
return self.item_class
return Item
def rowCount(self, parent=None):
if parent is None or not parent.isValid():
parent_item = self._root_item
else:
parent_item = parent.internalPointer()
return parent_item.childCount()
def columnCount(self, parent):
return len(self.Columns)
def data(self, index, role):
if not index.isValid():
return None
if role == QtCore.Qt.DisplayRole or role == QtCore.Qt.EditRole:
item = index.internalPointer()
column = index.column()
key = self.Columns[column]
return item.get(key, None)
if role == self.ItemRole:
return index.internalPointer()
def setData(self, index, value, role=QtCore.Qt.EditRole):
"""Change the data on the items.
Returns:
bool: Whether the edit was successful
"""
if index.isValid():
if role == QtCore.Qt.EditRole:
item = index.internalPointer()
column = index.column()
key = self.Columns[column]
item[key] = value
# passing `list()` for PyQt5 (see PYSIDE-462)
if Qt.__binding__ in ("PyQt4", "PySide"):
self.dataChanged.emit(index, index)
else:
self.dataChanged.emit(index, index, [role])
# must return true if successful
return True
return False
def setColumns(self, keys):
assert isinstance(keys, (list, tuple))
self.Columns = keys
def headerData(self, section, orientation, role):
if role == QtCore.Qt.DisplayRole:
if section < len(self.Columns):
return self.Columns[section]
super(TreeModel, self).headerData(section, orientation, role)
def flags(self, index):
flags = QtCore.Qt.ItemIsEnabled
item = index.internalPointer()
if item.get("enabled", True):
flags |= QtCore.Qt.ItemIsSelectable
return flags
def parent(self, index):
item = index.internalPointer()
parent_item = item.parent()
# If it has no parents we return invalid
if parent_item == self._root_item or not parent_item:
return QtCore.QModelIndex()
return self.createIndex(parent_item.row(), 0, parent_item)
def index(self, row, column, parent=None):
"""Return index for row/column under parent"""
if parent is None or not parent.isValid():
parent_item = self._root_item
else:
parent_item = parent.internalPointer()
child_item = parent_item.child(row)
if child_item:
return self.createIndex(row, column, child_item)
else:
return QtCore.QModelIndex()
def add_child(self, item, parent=None):
if parent is None:
parent = self._root_item
parent.add_child(item)
def column_name(self, column):
"""Return column key by index"""
if column < len(self.Columns):
return self.Columns[column]
def clear(self):
self.beginResetModel()
self._root_item = self.ItemClass()
self.endResetModel()
class Item(dict):
"""An item that can be represented in a tree view using `TreeModel`.
The item can store data just like a regular dictionary.
>>> data = {"name": "John", "score": 10}
>>> item = Item(data)
>>> assert item["name"] == "John"
"""
def __init__(self, data=None):
super(Item, self).__init__()
self._children = list()
self._parent = None
if data is not None:
assert isinstance(data, dict)
self.update(data)
def childCount(self):
return len(self._children)
def child(self, row):
if row >= len(self._children):
log.warning("Invalid row as child: {0}".format(row))
return
return self._children[row]
def children(self):
return self._children
def parent(self):
return self._parent
def row(self):
"""
Returns:
int: Index of this item under parent"""
if self._parent is not None:
siblings = self.parent().children()
return siblings.index(self)
return -1
def add_child(self, child):
"""Add a child to this item"""
child._parent = self
self._children.append(child)
class AssetModel(TreeModel):
"""A model listing assets in the silo in the active project.
The assets are displayed in a treeview, they are visually parented by
a `visualParent` field in the database containing an `_id` to a parent
asset.
"""
Columns = ["label"]
Name = 0
Deprecated = 2
ObjectId = 3
DocumentRole = QtCore.Qt.UserRole + 2
ObjectIdRole = QtCore.Qt.UserRole + 3
subsetColorsRole = QtCore.Qt.UserRole + 4
doc_fetched = QtCore.Signal(bool)
refreshed = QtCore.Signal(bool)
# Asset document projection
asset_projection = {
"type": 1,
"schema": 1,
"name": 1,
"silo": 1,
"data.visualParent": 1,
"data.label": 1,
"data.tags": 1,
"data.icon": 1,
"data.color": 1,
"data.deprecated": 1
}
def __init__(self, dbcon=None, parent=None, asset_projection=None):
super(AssetModel, self).__init__(parent=parent)
if dbcon is None:
dbcon = io
self.dbcon = dbcon
self.asset_colors = {}
# Projections for Mongo queries
# - let ability to modify them if used in tools that require more than
# defaults
if asset_projection:
self.asset_projection = asset_projection
self.asset_projection = asset_projection
self._doc_fetching_thread = None
self._doc_fetching_stop = False
self._doc_payload = {}
self.doc_fetched.connect(self.on_doc_fetched)
self.refresh()
def _add_hierarchy(self, assets, parent=None, silos=None):
"""Add the assets that are related to the parent as children items.
This method does *not* query the database. These instead are queried
in a single batch upfront as an optimization to reduce database
queries. Resulting in up to 10x speed increase.
Args:
assets (dict): All assets in the currently active silo stored
by key/value
Returns:
None
"""
# Reset colors
self.asset_colors = {}
if silos:
# WARNING: Silo item "_id" is set to silo value
# mainly because GUI issue with perserve selection and expanded row
# and because of easier hierarchy parenting (in "assets")
for silo in silos:
item = Item({
"_id": silo,
"name": silo,
"label": silo,
"type": "silo"
})
self.add_child(item, parent=parent)
self._add_hierarchy(assets, parent=item)
parent_id = parent["_id"] if parent else None
current_assets = assets.get(parent_id, list())
for asset in current_assets:
# get label from data, otherwise use name
data = asset.get("data", {})
label = data.get("label", asset["name"])
tags = data.get("tags", [])
# store for the asset for optimization
deprecated = "deprecated" in tags
item = Item({
"_id": asset["_id"],
"name": asset["name"],
"label": label,
"type": asset["type"],
"tags": ", ".join(tags),
"deprecated": deprecated,
"_document": asset
})
self.add_child(item, parent=parent)
# Add asset's children recursively if it has children
if asset["_id"] in assets:
self._add_hierarchy(assets, parent=item)
self.asset_colors[asset["_id"]] = []
def on_doc_fetched(self, was_stopped):
if was_stopped:
self.stop_fetch_thread()
return
self.beginResetModel()
assets_by_parent = self._doc_payload.get("assets_by_parent")
silos = self._doc_payload.get("silos")
if assets_by_parent is not None:
# Build the hierarchical tree items recursively
self._add_hierarchy(
assets_by_parent,
parent=None,
silos=silos
)
self.endResetModel()
has_content = bool(assets_by_parent) or bool(silos)
self.refreshed.emit(has_content)
self.stop_fetch_thread()
def fetch(self):
self._doc_payload = self._fetch() or {}
# Emit doc fetched only if was not stopped
self.doc_fetched.emit(self._doc_fetching_stop)
def _fetch(self):
if not self.dbcon.Session.get("AVALON_PROJECT"):
return
project_doc = self.dbcon.find_one(
{"type": "project"},
{"_id": True}
)
if not project_doc:
return
# Get all assets sorted by name
db_assets = self.dbcon.find(
{"type": "asset"},
self.asset_projection
).sort("name", 1)
# Group the assets by their visual parent's id
assets_by_parent = collections.defaultdict(list)
for asset in db_assets:
if self._doc_fetching_stop:
return
parent_id = asset.get("data", {}).get("visualParent")
assets_by_parent[parent_id].append(asset)
return {
"assets_by_parent": assets_by_parent,
"silos": None
}
def stop_fetch_thread(self):
if self._doc_fetching_thread is not None:
self._doc_fetching_stop = True
while self._doc_fetching_thread.isRunning():
time.sleep(0.001)
self._doc_fetching_thread = None
def refresh(self, force=False):
"""Refresh the data for the model."""
# Skip fetch if there is already other thread fetching documents
if self._doc_fetching_thread is not None:
if not force:
return
self.stop_fetch_thread()
# Clear model items
self.clear()
# Fetch documents from mongo
# Restart payload
self._doc_payload = {}
self._doc_fetching_stop = False
self._doc_fetching_thread = lib.create_qthread(self.fetch)
self._doc_fetching_thread.start()
def flags(self, index):
return QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable
def setData(self, index, value, role=QtCore.Qt.EditRole):
if not index.isValid():
return False
if role == self.subsetColorsRole:
asset_id = index.data(self.ObjectIdRole)
self.asset_colors[asset_id] = value
if Qt.__binding__ in ("PyQt4", "PySide"):
self.dataChanged.emit(index, index)
else:
self.dataChanged.emit(index, index, [role])
return True
return super(AssetModel, self).setData(index, value, role)
def data(self, index, role):
if not index.isValid():
return
item = index.internalPointer()
if role == QtCore.Qt.DecorationRole:
column = index.column()
if column == self.Name:
# Allow a custom icon and custom icon color to be defined
data = item.get("_document", {}).get("data", {})
icon = data.get("icon", None)
if icon is None and item.get("type") == "silo":
icon = "database"
color = data.get("color", style.colors.default)
if icon is None:
# Use default icons if no custom one is specified.
# If it has children show a full folder, otherwise
# show an open folder
has_children = self.rowCount(index) > 0
icon = "folder" if has_children else "folder-o"
# Make the color darker when the asset is deprecated
if item.get("deprecated", False):
color = QtGui.QColor(color).darker(250)
try:
key = "fa.{0}".format(icon) # font-awesome key
icon = qtawesome.icon(key, color=color)
return icon
except Exception as exception:
# Log an error message instead of erroring out completely
# when the icon couldn't be created (e.g. invalid name)
log.error(exception)
return
if role == QtCore.Qt.ForegroundRole: # font color
if "deprecated" in item.get("tags", []):
return QtGui.QColor(style.colors.light).darker(250)
if role == self.ObjectIdRole:
return item.get("_id", None)
if role == self.DocumentRole:
return item.get("_document", None)
if role == self.subsetColorsRole:
asset_id = item.get("_id", None)
return self.asset_colors.get(asset_id) or []
return super(AssetModel, self).data(index, role)
class RecursiveSortFilterProxyModel(QtCore.QSortFilterProxyModel):
"""Filters to the regex if any of the children matches allow parent"""
def filterAcceptsRow(self, row, parent):
regex = self.filterRegExp()
if not regex.isEmpty():
pattern = regex.pattern()
model = self.sourceModel()
source_index = model.index(row, self.filterKeyColumn(), parent)
if source_index.isValid():
# Check current index itself
key = model.data(source_index, self.filterRole())
if re.search(pattern, key, re.IGNORECASE):
return True
# Check children
rows = model.rowCount(source_index)
for i in range(rows):
if self.filterAcceptsRow(i, source_index):
return True
# Otherwise filter it
return False
return super(
RecursiveSortFilterProxyModel, self
).filterAcceptsRow(row, parent)

View file

@ -0,0 +1,86 @@
import os
from avalon import style
from Qt import QtWidgets, QtCore, QtGui, QtSvg
class DeselectableTreeView(QtWidgets.QTreeView):
"""A tree view that deselects on clicking on an empty area in the view"""
def mousePressEvent(self, event):
index = self.indexAt(event.pos())
if not index.isValid():
# clear the selection
self.clearSelection()
# clear the current index
self.setCurrentIndex(QtCore.QModelIndex())
QtWidgets.QTreeView.mousePressEvent(self, event)
class TreeViewSpinner(QtWidgets.QTreeView):
size = 160
def __init__(self, parent=None):
super(TreeViewSpinner, self).__init__(parent=parent)
loading_image_path = os.path.join(
os.path.dirname(os.path.abspath(style.__file__)),
"svg",
"spinner-200.svg"
)
self.spinner = QtSvg.QSvgRenderer(loading_image_path)
self.is_loading = False
self.is_empty = True
def paint_loading(self, event):
rect = event.rect()
rect = QtCore.QRectF(rect.topLeft(), rect.bottomRight())
rect.moveTo(
rect.x() + rect.width() / 2 - self.size / 2,
rect.y() + rect.height() / 2 - self.size / 2
)
rect.setSize(QtCore.QSizeF(self.size, self.size))
painter = QtGui.QPainter(self.viewport())
self.spinner.render(painter, rect)
def paint_empty(self, event):
painter = QtGui.QPainter(self.viewport())
rect = event.rect()
rect = QtCore.QRectF(rect.topLeft(), rect.bottomRight())
qtext_opt = QtGui.QTextOption(
QtCore.Qt.AlignHCenter | QtCore.Qt.AlignVCenter
)
painter.drawText(rect, "No Data", qtext_opt)
def paintEvent(self, event):
if self.is_loading:
self.paint_loading(event)
elif self.is_empty:
self.paint_empty(event)
else:
super(TreeViewSpinner, self).paintEvent(event)
class AssetsView(TreeViewSpinner, DeselectableTreeView):
"""Item view.
This implements a context menu.
"""
def __init__(self):
super(AssetsView, self).__init__()
self.setIndentation(15)
self.setContextMenuPolicy(QtCore.Qt.CustomContextMenu)
self.setHeaderHidden(True)
def mousePressEvent(self, event):
index = self.indexAt(event.pos())
if not index.isValid():
modifiers = QtWidgets.QApplication.keyboardModifiers()
if modifiers == QtCore.Qt.ShiftModifier:
return
elif modifiers == QtCore.Qt.ControlModifier:
return
super(AssetsView, self).mousePressEvent(event)

View file

@ -0,0 +1,499 @@
import logging
import time
from . import lib
from Qt import QtWidgets, QtCore, QtGui
from avalon.vendor import qtawesome, qargparse
from avalon import style
from .models import AssetModel, RecursiveSortFilterProxyModel
from .views import AssetsView
from .delegates import AssetDelegate
log = logging.getLogger(__name__)
class AssetWidget(QtWidgets.QWidget):
"""A Widget to display a tree of assets with filter
To list the assets of the active project:
>>> # widget = AssetWidget()
>>> # widget.refresh()
>>> # widget.show()
"""
refresh_triggered = QtCore.Signal() # on model refresh
refreshed = QtCore.Signal()
selection_changed = QtCore.Signal() # on view selection change
current_changed = QtCore.Signal() # on view current index change
def __init__(self, dbcon, multiselection=False, parent=None):
super(AssetWidget, self).__init__(parent=parent)
self.dbcon = dbcon
self.setContentsMargins(0, 0, 0, 0)
layout = QtWidgets.QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(4)
# Tree View
model = AssetModel(dbcon=self.dbcon, parent=self)
proxy = RecursiveSortFilterProxyModel()
proxy.setSourceModel(model)
proxy.setFilterCaseSensitivity(QtCore.Qt.CaseInsensitive)
view = AssetsView()
view.setModel(proxy)
if multiselection:
asset_delegate = AssetDelegate()
view.setSelectionMode(view.ExtendedSelection)
view.setItemDelegate(asset_delegate)
# Header
header = QtWidgets.QHBoxLayout()
icon = qtawesome.icon("fa.arrow-down", color=style.colors.light)
set_current_asset_btn = QtWidgets.QPushButton(icon, "")
set_current_asset_btn.setToolTip("Go to Asset from current Session")
# Hide by default
set_current_asset_btn.setVisible(False)
icon = qtawesome.icon("fa.refresh", color=style.colors.light)
refresh = QtWidgets.QPushButton(icon, "")
refresh.setToolTip("Refresh items")
filter = QtWidgets.QLineEdit()
filter.textChanged.connect(proxy.setFilterFixedString)
filter.setPlaceholderText("Filter assets..")
header.addWidget(filter)
header.addWidget(set_current_asset_btn)
header.addWidget(refresh)
# Layout
layout.addLayout(header)
layout.addWidget(view)
# Signals/Slots
selection = view.selectionModel()
selection.selectionChanged.connect(self.selection_changed)
selection.currentChanged.connect(self.current_changed)
refresh.clicked.connect(self.refresh)
set_current_asset_btn.clicked.connect(self.set_current_session_asset)
self.set_current_asset_btn = set_current_asset_btn
self.model = model
self.proxy = proxy
self.view = view
self.model_selection = {}
def set_current_asset_btn_visibility(self, visible=None):
"""Hide set current asset button.
Not all tools support using of current context asset.
"""
if visible is None:
visible = not self.set_current_asset_btn.isVisible()
self.set_current_asset_btn.setVisible(visible)
def _refresh_model(self):
# Store selection
self._store_model_selection()
time_start = time.time()
self.set_loading_state(
loading=True,
empty=True
)
def on_refreshed(has_item):
self.set_loading_state(loading=False, empty=not has_item)
self._restore_model_selection()
self.model.refreshed.disconnect()
self.refreshed.emit()
print("Duration: %.3fs" % (time.time() - time_start))
# Connect to signal
self.model.refreshed.connect(on_refreshed)
# Trigger signal before refresh is called
self.refresh_triggered.emit()
# Refresh model
self.model.refresh()
def refresh(self):
self._refresh_model()
def get_active_asset(self):
"""Return the asset item of the current selection."""
current = self.view.currentIndex()
return current.data(self.model.ItemRole)
def get_active_asset_document(self):
"""Return the asset document of the current selection."""
current = self.view.currentIndex()
return current.data(self.model.DocumentRole)
def get_active_index(self):
return self.view.currentIndex()
def get_selected_assets(self):
"""Return the documents of selected assets."""
selection = self.view.selectionModel()
rows = selection.selectedRows()
assets = [row.data(self.model.DocumentRole) for row in rows]
# NOTE: skip None object assumed they are silo (backwards comp.)
return [asset for asset in assets if asset]
def select_assets(self, assets, expand=True, key="name"):
"""Select assets by item key.
Args:
assets (list): List of asset values that can be found under
specified `key`
expand (bool): Whether to also expand to the asset in the view
key (string): Key that specifies where to look for `assets` values
Returns:
None
Default `key` is "name" in that case `assets` should contain single
asset name or list of asset names. (It is good idea to use "_id" key
instead of name in that case `assets` must contain `ObjectId` object/s)
It is expected that each value in `assets` will be found only once.
If the filters according to the `key` and `assets` correspond to
the more asset, only the first found will be selected.
"""
if not isinstance(assets, (tuple, list)):
assets = [assets]
# convert to list - tuple cant be modified
assets = set(assets)
# Clear selection
selection_model = self.view.selectionModel()
selection_model.clearSelection()
# Select
mode = selection_model.Select | selection_model.Rows
for index in lib.iter_model_rows(
self.proxy, column=0, include_root=False
):
# stop iteration if there are no assets to process
if not assets:
break
value = index.data(self.model.ItemRole).get(key)
if value not in assets:
continue
# Remove processed asset
assets.discard(value)
selection_model.select(index, mode)
if expand:
# Expand parent index
self.view.expand(self.proxy.parent(index))
# Set the currently active index
self.view.setCurrentIndex(index)
def set_loading_state(self, loading, empty):
if self.view.is_loading != loading:
if loading:
self.view.spinner.repaintNeeded.connect(
self.view.viewport().update
)
else:
self.view.spinner.repaintNeeded.disconnect()
self.view.is_loading = loading
self.view.is_empty = empty
def _store_model_selection(self):
index = self.view.currentIndex()
current = None
if index and index.isValid():
current = index.data(self.model.ObjectIdRole)
expanded = set()
model = self.view.model()
for index in lib.iter_model_rows(
model, column=0, include_root=False
):
if self.view.isExpanded(index):
value = index.data(self.model.ObjectIdRole)
expanded.add(value)
selection_model = self.view.selectionModel()
selected = None
selected_rows = selection_model.selectedRows()
if selected_rows:
selected = set(
row.data(self.model.ObjectIdRole)
for row in selected_rows
)
self.model_selection = {
"expanded": expanded,
"selected": selected,
"current": current
}
def _restore_model_selection(self):
model = self.view.model()
not_set = object()
expanded = self.model_selection.pop("expanded", not_set)
selected = self.model_selection.pop("selected", not_set)
current = self.model_selection.pop("current", not_set)
if (
expanded is not_set
or selected is not_set
or current is not_set
):
return
if expanded:
for index in lib.iter_model_rows(
model, column=0, include_root=False
):
is_expanded = index.data(self.model.ObjectIdRole) in expanded
self.view.setExpanded(index, is_expanded)
if not selected and not current:
self.set_current_session_asset()
return
current_index = None
selected_indexes = []
# Go through all indices, select the ones with similar data
for index in lib.iter_model_rows(
model, column=0, include_root=False
):
object_id = index.data(self.model.ObjectIdRole)
if object_id in selected:
selected_indexes.append(index)
if not current_index and object_id == current:
current_index = index
if current_index:
self.view.setCurrentIndex(current_index)
if not selected_indexes:
return
selection_model = self.view.selectionModel()
flags = selection_model.Select | selection_model.Rows
for index in selected_indexes:
# Ensure item is visible
self.view.scrollTo(index)
selection_model.select(index, flags)
def set_current_session_asset(self):
asset_name = self.dbcon.Session.get("AVALON_ASSET")
if asset_name:
self.select_assets([asset_name])
class OptionalMenu(QtWidgets.QMenu):
"""A subclass of `QtWidgets.QMenu` to work with `OptionalAction`
This menu has reimplemented `mouseReleaseEvent`, `mouseMoveEvent` and
`leaveEvent` to provide better action hightlighting and triggering for
actions that were instances of `QtWidgets.QWidgetAction`.
"""
def mouseReleaseEvent(self, event):
"""Emit option clicked signal if mouse released on it"""
active = self.actionAt(event.pos())
if active and active.use_option:
option = active.widget.option
if option.is_hovered(event.globalPos()):
option.clicked.emit()
super(OptionalMenu, self).mouseReleaseEvent(event)
def mouseMoveEvent(self, event):
"""Add highlight to active action"""
active = self.actionAt(event.pos())
for action in self.actions():
action.set_highlight(action is active, event.globalPos())
super(OptionalMenu, self).mouseMoveEvent(event)
def leaveEvent(self, event):
"""Remove highlight from all actions"""
for action in self.actions():
action.set_highlight(False)
super(OptionalMenu, self).leaveEvent(event)
class OptionalAction(QtWidgets.QWidgetAction):
"""Menu action with option box
A menu action like Maya's menu item with option box, implemented by
subclassing `QtWidgets.QWidgetAction`.
"""
def __init__(self, label, icon, use_option, parent):
super(OptionalAction, self).__init__(parent)
self.label = label
self.icon = icon
self.use_option = use_option
self.option_tip = ""
self.optioned = False
def createWidget(self, parent):
widget = OptionalActionWidget(self.label, parent)
self.widget = widget
if self.icon:
widget.setIcon(self.icon)
if self.use_option:
widget.option.clicked.connect(self.on_option)
widget.option.setToolTip(self.option_tip)
else:
widget.option.setVisible(False)
return widget
def set_option_tip(self, options):
sep = "\n\n"
mak = (lambda opt: opt["name"] + " :\n " + opt["help"])
self.option_tip = sep.join(mak(opt) for opt in options)
def on_option(self):
self.optioned = True
def set_highlight(self, state, global_pos=None):
body = self.widget.body
option = self.widget.option
role = QtGui.QPalette.Highlight if state else QtGui.QPalette.Window
body.setBackgroundRole(role)
body.setAutoFillBackground(state)
if not self.use_option:
return
state = option.is_hovered(global_pos)
role = QtGui.QPalette.Highlight if state else QtGui.QPalette.Window
option.setBackgroundRole(role)
option.setAutoFillBackground(state)
class OptionalActionWidget(QtWidgets.QWidget):
"""Main widget class for `OptionalAction`"""
def __init__(self, label, parent=None):
super(OptionalActionWidget, self).__init__(parent)
body = QtWidgets.QWidget()
body.setStyleSheet("background: transparent;")
icon = QtWidgets.QLabel()
label = QtWidgets.QLabel(label)
option = OptionBox(body)
icon.setFixedSize(24, 16)
option.setFixedSize(30, 30)
layout = QtWidgets.QHBoxLayout(body)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(2)
layout.addWidget(icon)
layout.addWidget(label)
layout.addSpacing(6)
layout = QtWidgets.QHBoxLayout(self)
layout.setContentsMargins(6, 1, 2, 1)
layout.setSpacing(0)
layout.addWidget(body)
layout.addWidget(option)
body.setMouseTracking(True)
label.setMouseTracking(True)
option.setMouseTracking(True)
self.setMouseTracking(True)
self.setFixedHeight(32)
self.icon = icon
self.label = label
self.option = option
self.body = body
# (NOTE) For removing ugly QLable shadow FX when highlighted in Nuke.
# See https://stackoverflow.com/q/52838690/4145300
label.setStyle(QtWidgets.QStyleFactory.create("Plastique"))
def setIcon(self, icon):
pixmap = icon.pixmap(16, 16)
self.icon.setPixmap(pixmap)
class OptionBox(QtWidgets.QLabel):
"""Option box widget class for `OptionalActionWidget`"""
clicked = QtCore.Signal()
def __init__(self, parent):
super(OptionBox, self).__init__(parent)
self.setAlignment(QtCore.Qt.AlignCenter)
icon = qtawesome.icon("fa.sticky-note-o", color="#c6c6c6")
pixmap = icon.pixmap(18, 18)
self.setPixmap(pixmap)
self.setStyleSheet("background: transparent;")
def is_hovered(self, global_pos):
if global_pos is None:
return False
pos = self.mapFromGlobal(global_pos)
return self.rect().contains(pos)
class OptionDialog(QtWidgets.QDialog):
"""Option dialog shown by option box"""
def __init__(self, parent=None):
super(OptionDialog, self).__init__(parent)
self.setModal(True)
self._options = dict()
def create(self, options):
parser = qargparse.QArgumentParser(arguments=options)
decision = QtWidgets.QWidget()
accept = QtWidgets.QPushButton("Accept")
cancel = QtWidgets.QPushButton("Cancel")
layout = QtWidgets.QHBoxLayout(decision)
layout.addWidget(accept)
layout.addWidget(cancel)
layout = QtWidgets.QVBoxLayout(self)
layout.addWidget(parser)
layout.addWidget(decision)
accept.clicked.connect(self.accept)
cancel.clicked.connect(self.reject)
parser.changed.connect(self.on_changed)
def on_changed(self, argument):
self._options[argument["name"]] = argument.read()
def parse(self):
return self._options.copy()

View file

@ -3,7 +3,31 @@ import sys
from semver import VersionInfo
from git import Repo
from optparse import OptionParser
from github import Github
import os
def get_release_type_github(Log, github_token):
# print(Log)
minor_labels = ["type: feature", "type: deprecated"]
patch_labels = ["type: enhancement", "type: bug"]
g = Github(github_token)
repo = g.get_repo("pypeclub/OpenPype")
for line in Log.splitlines():
print(line)
match = re.search("pull request #(\d+)", line)
if match:
pr_number = match.group(1)
pr = repo.get_pull(int(pr_number))
for label in pr.labels:
print(label.name)
if label.name in minor_labels:
return ("minor")
elif label.name in patch_labels:
return("patch")
return None
def remove_prefix(text, prefix):
return text[text.startswith(prefix) and len(prefix):]
@ -36,7 +60,7 @@ def get_log_since_tag(version):
def release_type(log):
regex_minor = ["feature/", "(feat)"]
regex_patch = ["bugfix/", "fix/", "(fix)", "enhancement/"]
regex_patch = ["bugfix/", "fix/", "(fix)", "enhancement/", "update"]
for reg in regex_minor:
if re.search(reg, log):
return "minor"
@ -135,17 +159,23 @@ def main():
parser.add_option("-l", "--lastversion",
dest="lastversion", action="store",
help="work with explicit version")
parser.add_option("-g", "--github_token",
dest="github_token", action="store",
help="github token")
(options, args) = parser.parse_args()
if options.bump:
last_CI, last_CI_tag = get_last_version("CI")
last_release, last_release_tag = get_last_version("release")
bump_type_CI = release_type(get_log_since_tag(last_CI_tag))
bump_type_release = release_type(get_log_since_tag(last_release_tag))
if bump_type_CI is None or bump_type_release is None:
bump_type_release = get_release_type_github(
get_log_since_tag(last_release_tag),
options.github_token
)
if bump_type_release is None:
print("skip")
else:
print(bump_type_release)
if options.nightly:
next_tag_v = calculate_next_nightly()