diff --git a/CHANGELOG.md b/CHANGELOG.md index b70f3f98f4..09882896f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,37 @@ # Changelog +## [2.18.0](https://github.com/pypeclub/openpype/tree/2.18.0) (2021-05-18) + +[Full Changelog](https://github.com/pypeclub/openpype/compare/2.17.3...2.18.0) + +**Enhancements:** + +- Use SubsetLoader and multiple contexts for delete_old_versions [\#1484](ttps://github.com/pypeclub/OpenPype/pull/1484)) +- TVPaint: Increment workfile version on successfull publish. [\#1489](https://github.com/pypeclub/OpenPype/pull/1489) +- Maya: Use of multiple deadline servers [\#1483](https://github.com/pypeclub/OpenPype/pull/1483) + +**Fixed bugs:** + +- Use instance frame start instead of timeline. [\#1486](https://github.com/pypeclub/OpenPype/pull/1486) +- Maya: Redshift - set proper start frame on proxy [\#1480](https://github.com/pypeclub/OpenPype/pull/1480) +- Maya: wrong collection of playblasted frames [\#1517](https://github.com/pypeclub/OpenPype/pull/1517) +- Existing subsets hints in creator [\#1502](https://github.com/pypeclub/OpenPype/pull/1502) + +## [2.17.3](https://github.com/pypeclub/openpype/tree/2.17.3) (2021-05-06) + +[Full Changelog](https://github.com/pypeclub/openpype/compare/CI/3.0.0-rc.3...2.17.3) + +**Fixed bugs:** + +- Nuke: workfile version synced to db version always [\#1479](https://github.com/pypeclub/OpenPype/pull/1479) + +## [2.17.2](https://github.com/pypeclub/openpype/tree/2.17.2) (2021-05-04) + +[Full Changelog](https://github.com/pypeclub/openpype/compare/CI/3.0.0-rc.1...2.17.2) + +**Enhancements:** + +- Forward/Backward compatible apps and tools with OpenPype 3 [\#1463](https://github.com/pypeclub/OpenPype/pull/1463) ## [2.17.1](https://github.com/pypeclub/openpype/tree/2.17.1) (2021-04-30) @@ -7,28 +39,30 @@ **Enhancements:** +- Nuke: deadline submission with gpu [\#1414](https://github.com/pypeclub/OpenPype/pull/1414) - TVPaint frame range definition [\#1424](https://github.com/pypeclub/OpenPype/pull/1424) - PS - group all published instances [\#1415](https://github.com/pypeclub/OpenPype/pull/1415) -- Nuke: deadline submission with gpu [\#1414](https://github.com/pypeclub/OpenPype/pull/1414) - Add task name to context pop up. [\#1383](https://github.com/pypeclub/OpenPype/pull/1383) -- AE add duration validation [\#1363](https://github.com/pypeclub/OpenPype/pull/1363) -- Maya: Support for Redshift proxies [\#1360](https://github.com/pypeclub/OpenPype/pull/1360) +- Enhance review letterbox feature. [\#1371](https://github.com/pypeclub/OpenPype/pull/1371) **Fixed bugs:** -- Nuke: fixing undo for loaded mov and sequence [\#1433](https://github.com/pypeclub/OpenPype/pull/1433) -- AE - validation for duration was 1 frame shorter [\#1426](https://github.com/pypeclub/OpenPype/pull/1426) - Houdini menu filename [\#1417](https://github.com/pypeclub/OpenPype/pull/1417) -- Maya: Vray - problem getting all file nodes for look publishing [\#1399](https://github.com/pypeclub/OpenPype/pull/1399) +- AE - validation for duration was 1 frame shorter [\#1426](https://github.com/pypeclub/OpenPype/pull/1426) +**Merged pull requests:** + +- Maya: Vray - problem getting all file nodes for look publishing [\#1399](https://github.com/pypeclub/OpenPype/pull/1399) +- Maya: Support for Redshift proxies [\#1360](https://github.com/pypeclub/OpenPype/pull/1360) ## [2.17.0](https://github.com/pypeclub/openpype/tree/2.17.0) (2021-04-20) -[Full Changelog](https://github.com/pypeclub/openpype/compare/3.0.0-beta2...2.17.0) +[Full Changelog](https://github.com/pypeclub/openpype/compare/CI/3.0.0-beta.2...2.17.0) **Enhancements:** - Forward compatible ftrack group [\#1243](https://github.com/pypeclub/OpenPype/pull/1243) +- Settings in mongo as dict [\#1221](https://github.com/pypeclub/OpenPype/pull/1221) - Maya: Make tx option configurable with presets [\#1328](https://github.com/pypeclub/OpenPype/pull/1328) - TVPaint asset name validation [\#1302](https://github.com/pypeclub/OpenPype/pull/1302) - TV Paint: Set initial project settings. [\#1299](https://github.com/pypeclub/OpenPype/pull/1299) @@ -56,35 +90,6 @@ - Nuke: reverse search to make it more versatile [\#1178](https://github.com/pypeclub/OpenPype/pull/1178) -## [2.16.1](https://github.com/pypeclub/pype/tree/2.16.1) (2021-04-13) - -[Full Changelog](https://github.com/pypeclub/pype/compare/2.16.0...2.16.1) - -**Enhancements:** - -- Nuke: comp renders mix up [\#1301](https://github.com/pypeclub/pype/pull/1301) -- Validate project settings [\#1297](https://github.com/pypeclub/pype/pull/1297) -- After Effects: added SubsetManager [\#1234](https://github.com/pypeclub/pype/pull/1234) - -**Fixed bugs:** - -- Ftrack custom attributes in bulks [\#1312](https://github.com/pypeclub/pype/pull/1312) -- Ftrack optional pypclub role [\#1303](https://github.com/pypeclub/pype/pull/1303) -- AE remove orphaned instance from workfile - fix self.stub [\#1282](https://github.com/pypeclub/pype/pull/1282) -- Avalon schema names [\#1242](https://github.com/pypeclub/pype/pull/1242) -- Handle duplication of Task name [\#1226](https://github.com/pypeclub/pype/pull/1226) -- Modified path of plugin loads for Harmony and TVPaint [\#1217](https://github.com/pypeclub/pype/pull/1217) -- Regex checks in profiles filtering [\#1214](https://github.com/pypeclub/pype/pull/1214) -- Bulk mov strict task [\#1204](https://github.com/pypeclub/pype/pull/1204) -- Update custom ftrack session attributes [\#1202](https://github.com/pypeclub/pype/pull/1202) -- Nuke: write node colorspace ignore `default\(\)` label [\#1199](https://github.com/pypeclub/pype/pull/1199) -- Nuke: reverse search to make it more versatile [\#1178](https://github.com/pypeclub/pype/pull/1178) - -**Merged pull requests:** - -- Forward compatible ftrack group [\#1243](https://github.com/pypeclub/pype/pull/1243) -- Error message in pyblish UI [\#1206](https://github.com/pypeclub/pype/pull/1206) -- Nuke: deadline submission with search replaced env values from preset [\#1194](https://github.com/pypeclub/pype/pull/1194) ## [2.16.0](https://github.com/pypeclub/pype/tree/2.16.0) (2021-03-22) @@ -1145,4 +1150,7 @@ A large cleanup release. Most of the change are under the hood. \* *This Changelog was automatically generated by [github_changelog_generator](https://github.com/github-changelog-generator/github-changelog-generator)* +\* *This Changelog was automatically generated by [github_changelog_generator](https://github.com/github-changelog-generator/github-changelog-generator)* + + \* *This Changelog was automatically generated by [github_changelog_generator](https://github.com/github-changelog-generator/github-changelog-generator)* diff --git a/igniter/bootstrap_repos.py b/igniter/bootstrap_repos.py index 8fbb580e8f..b44689ba89 100644 --- a/igniter/bootstrap_repos.py +++ b/igniter/bootstrap_repos.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- """Bootstrap OpenPype repositories.""" -import functools +from __future__ import annotations import logging as log import os import re @@ -9,10 +9,12 @@ import sys import tempfile from pathlib import Path from typing import Union, Callable, List, Tuple + from zipfile import ZipFile, BadZipFile from appdirs import user_data_dir from speedcopy import copyfile +import semver from .user_settings import ( OpenPypeSecureRegistry, @@ -26,159 +28,138 @@ LOG_WARNING = 1 LOG_ERROR = 3 -@functools.total_ordering -class OpenPypeVersion: +class OpenPypeVersion(semver.VersionInfo): """Class for storing information about OpenPype version. Attributes: - major (int): [1].2.3-client-variant - minor (int): 1.[2].3-client-variant - subversion (int): 1.2.[3]-client-variant - client (str): 1.2.3-[client]-variant - variant (str): 1.2.3-client-[variant] + staging (bool): True if it is staging version path (str): path to OpenPype """ - major = 0 - minor = 0 - subversion = 0 - variant = "" - client = None + staging = False path = None + _VERSION_REGEX = re.compile(r"(?P0|[1-9]\d*)\.(?P0|[1-9]\d*)\.(?P0|[1-9]\d*)(?:-(?P(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+(?P[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$") # noqa: E501 - _version_regex = re.compile( - r"(?P\d+)\.(?P\d+)\.(?P\d+)(-(?Pstaging)|-(?P.+)(-(?Pstaging)))?") # noqa: E501 + def __init__(self, *args, **kwargs): + """Create OpenPype version. - @property - def version(self): - """return formatted version string.""" - return self._compose_version() + .. deprecated:: 3.0.0-rc.2 + `client` and `variant` are removed. - @version.setter - def version(self, val): - decomposed = self._decompose_version(val) - self.major = decomposed[0] - self.minor = decomposed[1] - self.subversion = decomposed[2] - self.variant = decomposed[3] - self.client = decomposed[4] - def __init__(self, major: int = None, minor: int = None, - subversion: int = None, version: str = None, - variant: str = "", client: str = None, - path: Path = None): - self.path = path + Args: + major (int): version when you make incompatible API changes. + minor (int): version when you add functionality in a + backwards-compatible manner. + patch (int): version when you make backwards-compatible bug fixes. + prerelease (str): an optional prerelease string + build (str): an optional build string + version (str): if set, it will be parsed and will override + parameters like `major`, `minor` and so on. + staging (bool): set to True if version is staging. + path (Path): path to version location. - if ( - major is None or minor is None or subversion is None - ) and version is None: - raise ValueError("Need version specified in some way.") - if version: - values = self._decompose_version(version) - self.major = values[0] - self.minor = values[1] - self.subversion = values[2] - self.variant = values[3] - self.client = values[4] - else: - self.major = major - self.minor = minor - self.subversion = subversion - # variant is set only if it is "staging", otherwise "production" is - # implied and no need to mention it in version string. - if variant == "staging": - self.variant = variant - self.client = client + """ + self.path = None + self.staging = False - def _compose_version(self): - version = "{}.{}.{}".format(self.major, self.minor, self.subversion) + if "version" in kwargs.keys(): + if not kwargs.get("version"): + raise ValueError("Invalid version specified") + v = OpenPypeVersion.parse(kwargs.get("version")) + kwargs["major"] = v.major + kwargs["minor"] = v.minor + kwargs["patch"] = v.patch + kwargs["prerelease"] = v.prerelease + kwargs["build"] = v.build + kwargs.pop("version") - if self.client: - version = "{}-{}".format(version, self.client) + if kwargs.get("path"): + if isinstance(kwargs.get("path"), str): + self.path = Path(kwargs.get("path")) + elif isinstance(kwargs.get("path"), Path): + self.path = kwargs.get("path") + else: + raise TypeError("Path must be str or Path") + kwargs.pop("path") - if self.variant == "staging": - version = "{}-{}".format(version, self.variant) + if "path" in kwargs.keys(): + kwargs.pop("path") - return version + if kwargs.get("staging"): + self.staging = kwargs.get("staging", False) + kwargs.pop("staging") - @classmethod - def _decompose_version(cls, version_string: str) -> tuple: - m = re.search(cls._version_regex, version_string) - if not m: - raise ValueError( - "Cannot parse version string: {}".format(version_string)) + if "staging" in kwargs.keys(): + kwargs.pop("staging") - variant = None - if m.group("var1") == "staging" or m.group("var2") == "staging": - variant = "staging" + if self.staging: + if kwargs.get("build"): + if "staging" not in kwargs.get("build"): + kwargs["build"] = "{}-staging".format(kwargs.get("build")) + else: + kwargs["build"] = "staging" - client = m.group("client") + if kwargs.get("build") and "staging" in kwargs.get("build", ""): + self.staging = True - return (int(m.group("major")), int(m.group("minor")), - int(m.group("sub")), variant, client) + super().__init__(*args, **kwargs) def __eq__(self, other): - if not isinstance(other, self.__class__): - return False - return self.version == other.version - - def __str__(self): - return self.version + result = super().__eq__(other) + return bool(result and self.staging == other.staging) def __repr__(self): - return "{}, {}: {}".format( - self.__class__.__name__, self.version, self.path) - - def __hash__(self): - return hash(self.version) - - def __lt__(self, other): - if (self.major, self.minor, self.subversion) < \ - (other.major, other.minor, other.subversion): - return True - - # 1.2.3-staging < 1.2.3-client-staging - if self.get_main_version() == other.get_main_version() and \ - not self.client and self.variant and \ - other.client and other.variant: - return True - - # 1.2.3 < 1.2.3-staging - if self.get_main_version() == other.get_main_version() and \ - not self.client and self.variant and \ - not other.client and not other.variant: - return True - - # 1.2.3 < 1.2.3-client - if self.get_main_version() == other.get_main_version() and \ - not self.client and not self.variant and \ - other.client and not other.variant: - return True - - # 1.2.3 < 1.2.3-client-staging - if self.get_main_version() == other.get_main_version() and \ - not self.client and not self.variant and other.client: - return True - - # 1.2.3-client-staging < 1.2.3-client - if self.get_main_version() == other.get_main_version() and \ - self.client and self.variant and \ - other.client and not other.variant: - return True + return "<{}: {} - path={}>".format( + self.__class__.__name__, str(self), self.path) + def __lt__(self, other: OpenPypeVersion): + result = super().__lt__(other) # prefer path over no path - if self.version == other.version and \ - not self.path and other.path: + if self == other and not self.path and other.path: return True - # prefer path with dir over path with file - return self.version == other.version and self.path and \ - other.path and self.path.is_file() and \ - other.path.is_dir() + if self == other and self.path and other.path and \ + other.path.is_dir() and self.path.is_file(): + return True + + if self.finalize_version() == other.finalize_version() and \ + self.prerelease == other.prerelease and \ + self.is_staging() and not other.is_staging(): + return True + + return result + + def set_staging(self) -> OpenPypeVersion: + """Set version as staging and return it. + + This will preserve current one. + + Returns: + OpenPypeVersion: Set as staging. + + """ + if self.staging: + return self + return self.replace(parts={"build": f"{self.build}-staging"}) + + def set_production(self) -> OpenPypeVersion: + """Set version as production and return it. + + This will preserve current one. + + Returns: + OpenPypeVersion: Set as production. + + """ + if not self.staging: + return self + return self.replace( + parts={"build": self.build.replace("-staging", "")}) def is_staging(self) -> bool: """Test if current version is staging one.""" - return self.variant == "staging" + return self.staging def get_main_version(self) -> str: """Return main version component. @@ -186,11 +167,13 @@ class OpenPypeVersion: This returns x.x.x part of version from possibly more complex one like x.x.x-foo-bar. + .. deprecated:: 3.0.0-rc.2 + use `finalize_version()` instead. Returns: str: main version component """ - return "{}.{}.{}".format(self.major, self.minor, self.subversion) + return str(self.finalize_version()) @staticmethod def version_in_str(string: str) -> Tuple: @@ -203,15 +186,22 @@ class OpenPypeVersion: tuple: True/False and OpenPypeVersion if found. """ - try: - result = OpenPypeVersion._decompose_version(string) - except ValueError: + m = re.search(OpenPypeVersion._VERSION_REGEX, string) + if not m: return False, None - return True, OpenPypeVersion(major=result[0], - minor=result[1], - subversion=result[2], - variant=result[3], - client=result[4]) + version = OpenPypeVersion.parse(string[m.start():m.end()]) + return True, version + + @classmethod + def parse(cls, version): + """Extends parse to handle ta handle staging variant.""" + v = super().parse(version) + openpype_version = cls(major=v.major, minor=v.minor, + patch=v.patch, prerelease=v.prerelease, + build=v.build) + if v.build and "staging" in v.build: + openpype_version.staging = True + return openpype_version class BootstrapRepos: @@ -269,7 +259,7 @@ class BootstrapRepos: """Get path for specific version in list of OpenPype versions. Args: - version (str): Version string to look for (1.2.4-staging) + version (str): Version string to look for (1.2.4+staging) version_list (list of OpenPypeVersion): list of version to search. Returns: @@ -632,7 +622,7 @@ class BootstrapRepos: " not implemented yet.")) dir_to_search = self.data_dir - + user_versions = self.get_openpype_versions(self.data_dir, staging) # if we have openpype_path specified, search only there. if openpype_path: dir_to_search = openpype_path @@ -652,6 +642,7 @@ class BootstrapRepos: pass openpype_versions = self.get_openpype_versions(dir_to_search, staging) + openpype_versions += user_versions # remove zip file version if needed. if not include_zips: @@ -764,12 +755,13 @@ class BootstrapRepos: destination = self.data_dir / version.path.stem if destination.exists(): + assert destination.is_dir() try: - destination.unlink() - except OSError: + shutil.rmtree(destination) + except OSError as e: msg = f"!!! Cannot remove already existing {destination}" self._print(msg, LOG_ERROR, exc_info=True) - return None + raise e destination.mkdir(parents=True) @@ -821,7 +813,6 @@ class BootstrapRepos: OpenPypeVersionIOError: If copying or zipping fail. """ - if self.is_inside_user_data(openpype_version.path) and not openpype_version.path.is_file(): # noqa raise OpenPypeVersionExists( "OpenPype already inside user data dir") @@ -868,26 +859,20 @@ class BootstrapRepos: # set zip as version source openpype_version.path = temp_zip + if self.is_inside_user_data(openpype_version.path): + raise OpenPypeVersionInvalid( + "Version is in user data dir.") + openpype_version.path = self._copy_zip( + openpype_version.path, destination) + elif openpype_version.path.is_file(): # check if file is zip (by extension) if openpype_version.path.suffix.lower() != ".zip": raise OpenPypeVersionInvalid("Invalid file format") - if not self.is_inside_user_data(openpype_version.path): - try: - # copy file to destination - self._print("Copying zip to destination ...") - _destination_zip = destination.parent / openpype_version.path.name # noqa: E501 - copyfile( - openpype_version.path.as_posix(), - _destination_zip.as_posix()) - except OSError as e: - self._print( - "cannot copy version to user data directory", LOG_ERROR, - exc_info=True) - raise OpenPypeVersionIOError(( - f"can't copy version {openpype_version.path.as_posix()} " - f"to destination {destination.parent.as_posix()}")) from e + if not self.is_inside_user_data(openpype_version.path): + openpype_version.path = self._copy_zip( + openpype_version.path, destination) # extract zip there self._print("extracting zip to destination ...") @@ -896,6 +881,23 @@ class BootstrapRepos: return destination + def _copy_zip(self, source: Path, destination: Path) -> Path: + try: + # copy file to destination + self._print("Copying zip to destination ...") + _destination_zip = destination.parent / source.name # noqa: E501 + copyfile( + source.as_posix(), + _destination_zip.as_posix()) + except OSError as e: + self._print( + "cannot copy version to user data directory", LOG_ERROR, + exc_info=True) + raise OpenPypeVersionIOError(( + f"can't copy version {source.as_posix()} " + f"to destination {destination.parent.as_posix()}")) from e + return _destination_zip + def _is_openpype_in_dir(self, dir_item: Path, detected_version: OpenPypeVersion) -> bool: diff --git a/openpype/__init__.py b/openpype/__init__.py index f63d534e08..a86d2bc2be 100644 --- a/openpype/__init__.py +++ b/openpype/__init__.py @@ -67,6 +67,15 @@ def patched_discover(superclass): @import_wrapper def install(): """Install Pype to Avalon.""" + from pyblish.lib import MessageHandler + + def modified_emit(obj, record): + """Method replacing `emit` in Pyblish's MessageHandler.""" + record.msg = record.getMessage() + obj.records.append(record) + + MessageHandler.emit = modified_emit + log.info("Registering global plug-ins..") pyblish.register_plugin_path(PUBLISH_PATH) pyblish.register_discovery_filter(filter_pyblish_plugins) diff --git a/openpype/cli.py b/openpype/cli.py index 9c49825721..df38c74a21 100644 --- a/openpype/cli.py +++ b/openpype/cli.py @@ -224,6 +224,11 @@ def launch(app, project, asset, task, PypeCommands().run_application(app, project, asset, task, tools, arguments) +@main.command(context_settings={"ignore_unknown_options": True}) +def projectmanager(): + PypeCommands().launch_project_manager() + + @main.command( context_settings=dict( ignore_unknown_options=True, diff --git a/openpype/hosts/aftereffects/__init__.py b/openpype/hosts/aftereffects/__init__.py index e69de29bb2..deae48d122 100644 --- a/openpype/hosts/aftereffects/__init__.py +++ b/openpype/hosts/aftereffects/__init__.py @@ -0,0 +1,9 @@ +def add_implementation_envs(env, _app): + """Modify environments to contain all required for implementation.""" + defaults = { + "OPENPYPE_LOG_NO_COLORS": "True", + "WEBSOCKET_URL": "ws://localhost:8097/ws/" + } + for key, value in defaults.items(): + if not env.get(key): + env[key] = value diff --git a/openpype/hosts/aftereffects/api/__init__.py b/openpype/hosts/aftereffects/api/__init__.py index e914c26435..5f6a64a6d0 100644 --- a/openpype/hosts/aftereffects/api/__init__.py +++ b/openpype/hosts/aftereffects/api/__init__.py @@ -112,38 +112,4 @@ def get_asset_settings(): "duration": duration } - try: - # temporary, in pype3 replace with api.get_current_project_settings - skip_resolution_check = ( - api.get_current_project_settings() - ["plugins"] - ["aftereffects"] - ["publish"] - ["ValidateSceneSettings"] - ["skip_resolution_check"] - ) - skip_timelines_check = ( - api.get_current_project_settings() - ["plugins"] - ["aftereffects"] - ["publish"] - ["ValidateSceneSettings"] - ["skip_timelines_check"] - ) - except KeyError: - skip_resolution_check = ['*'] - skip_timelines_check = ['*'] - - if os.getenv('AVALON_TASK') in skip_resolution_check or \ - '*' in skip_timelines_check: - scene_data.pop("resolutionWidth") - scene_data.pop("resolutionHeight") - - if entity_type in skip_timelines_check or '*' in skip_timelines_check: - scene_data.pop('fps', None) - scene_data.pop('frameStart', None) - scene_data.pop('frameEnd', None) - scene_data.pop('handleStart', None) - scene_data.pop('handleEnd', None) - return scene_data diff --git a/openpype/hosts/aftereffects/plugins/publish/validate_scene_settings.py b/openpype/hosts/aftereffects/plugins/publish/validate_scene_settings.py index cc7db3141f..5301a2f3ea 100644 --- a/openpype/hosts/aftereffects/plugins/publish/validate_scene_settings.py +++ b/openpype/hosts/aftereffects/plugins/publish/validate_scene_settings.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- """Validate scene settings.""" import os +import re import pyblish.api @@ -56,13 +57,26 @@ class ValidateSceneSettings(pyblish.api.InstancePlugin): hosts = ["aftereffects"] optional = True - skip_timelines_check = ["*"] # * >> skip for all - skip_resolution_check = ["*"] + skip_timelines_check = [".*"] # * >> skip for all + skip_resolution_check = [".*"] def process(self, instance): """Plugin entry point.""" expected_settings = api.get_asset_settings() - self.log.info("expected_settings::{}".format(expected_settings)) + self.log.info("config from DB::{}".format(expected_settings)) + + if any(re.search(pattern, os.getenv('AVALON_TASK')) + for pattern in self.skip_resolution_check): + expected_settings.pop("resolutionWidth") + expected_settings.pop("resolutionHeight") + + if any(re.search(pattern, os.getenv('AVALON_TASK')) + for pattern in self.skip_timelines_check): + expected_settings.pop('fps', None) + expected_settings.pop('frameStart', None) + expected_settings.pop('frameEnd', None) + expected_settings.pop('handleStart', None) + expected_settings.pop('handleEnd', None) # handle case where ftrack uses only two decimal places # 23.976023976023978 vs. 23.98 @@ -76,6 +90,8 @@ class ValidateSceneSettings(pyblish.api.InstancePlugin): duration = instance.data.get("frameEndHandle") - \ instance.data.get("frameStartHandle") + 1 + self.log.debug("filtered config::{}".format(expected_settings)) + current_settings = { "fps": fps, "frameStartHandle": instance.data.get("frameStartHandle"), diff --git a/openpype/hosts/blender/__init__.py b/openpype/hosts/blender/__init__.py index e69de29bb2..4d93233449 100644 --- a/openpype/hosts/blender/__init__.py +++ b/openpype/hosts/blender/__init__.py @@ -0,0 +1,41 @@ +import os + + +def add_implementation_envs(env, _app): + """Modify environments to contain all required for implementation.""" + # Prepare path to implementation script + implementation_user_script_path = os.path.join( + os.environ["OPENPYPE_REPOS_ROOT"], + "repos", + "avalon-core", + "setup", + "blender" + ) + + # Add blender implementation script path to PYTHONPATH + python_path = env.get("PYTHONPATH") or "" + python_path_parts = [ + path + for path in python_path.split(os.pathsep) + if path + ] + python_path_parts.insert(0, implementation_user_script_path) + env["PYTHONPATH"] = os.pathsep.join(python_path_parts) + + # Modify Blender user scripts path + blender_user_scripts = env.get("BLENDER_USER_SCRIPTS") or "" + previous_user_scripts = [] + for path in blender_user_scripts.split(os.pathsep): + if path and os.path.exists(path): + path = os.path.normpath(path) + if path != implementation_user_script_path: + previous_user_scripts.append(path) + + env["OPENPYPE_BLENDER_USER_SCRIPTS"] = os.pathsep.join( + previous_user_scripts + ) + env["BLENDER_USER_SCRIPTS"] = implementation_user_script_path + + # Define Qt binding if not defined + if not env.get("QT_PREFERRED_BINDING"): + env["QT_PREFERRED_BINDING"] = "PySide2" diff --git a/openpype/hosts/blender/plugins/create/create_layout.py b/openpype/hosts/blender/plugins/create/create_layout.py index f72e364f50..5404cec587 100644 --- a/openpype/hosts/blender/plugins/create/create_layout.py +++ b/openpype/hosts/blender/plugins/create/create_layout.py @@ -20,21 +20,9 @@ class CreateLayout(openpype.hosts.blender.api.plugin.Creator): asset = self.data["asset"] subset = self.data["subset"] name = openpype.hosts.blender.api.plugin.asset_name(asset, subset) - collection = bpy.data.collections.new(name=name) - bpy.context.scene.collection.children.link(collection) + collection = bpy.context.collection + collection.name = name self.data['task'] = api.Session.get('AVALON_TASK') lib.imprint(collection, self.data) - # Add the rig object and all the children meshes to - # a set and link them all at the end to avoid duplicates. - # Blender crashes if trying to link an object that is already linked. - # This links automatically the children meshes if they were not - # selected, and doesn't link them twice if they, insted, - # were manually selected by the user. - objects_to_link = set() - - if (self.options or {}).get("useSelection"): - for obj in lib.get_selection(): - collection.children.link(obj.users_collection[0]) - return collection diff --git a/openpype/hosts/blender/plugins/load/load_layout.py b/openpype/hosts/blender/plugins/load/load_layout.py index f1f2fdcddd..87ef9670a6 100644 --- a/openpype/hosts/blender/plugins/load/load_layout.py +++ b/openpype/hosts/blender/plugins/load/load_layout.py @@ -367,13 +367,13 @@ class UnrealLayoutLoader(plugin.AssetLoader): # Y axis mirrored obj.location = ( location.get('x'), - -location.get('y'), + location.get('y'), location.get('z') ) obj.rotation_euler = ( - rotation.get('x') + math.pi / 2, - -rotation.get('y'), - -rotation.get('z') + rotation.get('x'), + rotation.get('y'), + rotation.get('z') ) obj.scale = ( scale.get('x'), diff --git a/openpype/hosts/blender/plugins/load/load_model.py b/openpype/hosts/blender/plugins/load/load_model.py index d645bedfcc..35a241b98e 100644 --- a/openpype/hosts/blender/plugins/load/load_model.py +++ b/openpype/hosts/blender/plugins/load/load_model.py @@ -108,19 +108,21 @@ class BlendModelLoader(plugin.AssetLoader): self.__class__.__name__, ) - container_metadata = container.get( - blender.pipeline.AVALON_PROPERTY) + metadata = container.get(blender.pipeline.AVALON_PROPERTY) - container_metadata["libpath"] = libpath - container_metadata["lib_container"] = lib_container + metadata["libpath"] = libpath + metadata["lib_container"] = lib_container obj_container = self._process( libpath, lib_container, container_name, None) - container_metadata["obj_container"] = obj_container + metadata["obj_container"] = obj_container # Save the list of objects in the metadata container - container_metadata["objects"] = obj_container.all_objects + metadata["objects"] = obj_container.all_objects + + metadata["parent"] = str(context["representation"]["parent"]) + metadata["family"] = context["representation"]["context"]["family"] nodes = list(container.objects) nodes.append(container) diff --git a/openpype/hosts/blender/plugins/load/load_rig.py b/openpype/hosts/blender/plugins/load/load_rig.py index c5690a6ab8..9035458c12 100644 --- a/openpype/hosts/blender/plugins/load/load_rig.py +++ b/openpype/hosts/blender/plugins/load/load_rig.py @@ -155,18 +155,20 @@ class BlendRigLoader(plugin.AssetLoader): self.__class__.__name__, ) - container_metadata = container.get( - blender.pipeline.AVALON_PROPERTY) + metadata = container.get(blender.pipeline.AVALON_PROPERTY) - container_metadata["libpath"] = libpath - container_metadata["lib_container"] = lib_container + metadata["libpath"] = libpath + metadata["lib_container"] = lib_container obj_container = self._process( libpath, lib_container, collection_name, None, None) - container_metadata["obj_container"] = obj_container + metadata["obj_container"] = obj_container # Save the list of objects in the metadata container - container_metadata["objects"] = obj_container.all_objects + metadata["objects"] = obj_container.all_objects + + metadata["parent"] = str(context["representation"]["parent"]) + metadata["family"] = context["representation"]["context"]["family"] nodes = list(container.objects) nodes.append(container) diff --git a/openpype/hosts/blender/plugins/publish/extract_layout.py b/openpype/hosts/blender/plugins/publish/extract_layout.py new file mode 100644 index 0000000000..c6c9bf67f5 --- /dev/null +++ b/openpype/hosts/blender/plugins/publish/extract_layout.py @@ -0,0 +1,92 @@ +import os +import json + +import bpy + +from avalon import blender, io +import openpype.api + + +class ExtractLayout(openpype.api.Extractor): + """Extract a layout.""" + + label = "Extract Layout" + hosts = ["blender"] + families = ["layout"] + optional = True + + def process(self, instance): + # Define extract output file path + stagingdir = self.staging_dir(instance) + + # Perform extraction + self.log.info("Performing extraction..") + + json_data = [] + + for collection in instance: + for asset in collection.children: + collection = bpy.data.collections[asset.name] + container = bpy.data.collections[asset.name + '_CON'] + metadata = container.get(blender.pipeline.AVALON_PROPERTY) + + parent = metadata["parent"] + family = metadata["family"] + + self.log.debug("Parent: {}".format(parent)) + blend = io.find_one( + { + "type": "representation", + "parent": io.ObjectId(parent), + "name": "blend" + }, + projection={"_id": True}) + blend_id = blend["_id"] + + json_element = {} + json_element["reference"] = str(blend_id) + json_element["family"] = family + json_element["instance_name"] = asset.name + json_element["asset_name"] = metadata["lib_container"] + json_element["file_path"] = metadata["libpath"] + + obj = collection.objects[0] + + json_element["transform"] = { + "translation": { + "x": obj.location.x, + "y": obj.location.y, + "z": obj.location.z + }, + "rotation": { + "x": obj.rotation_euler.x, + "y": obj.rotation_euler.y, + "z": obj.rotation_euler.z, + }, + "scale": { + "x": obj.scale.x, + "y": obj.scale.y, + "z": obj.scale.z + } + } + json_data.append(json_element) + + json_filename = "{}.json".format(instance.name) + json_path = os.path.join(stagingdir, json_filename) + + with open(json_path, "w+") as file: + json.dump(json_data, fp=file, indent=2) + + if "representations" not in instance.data: + instance.data["representations"] = [] + + representation = { + 'name': 'json', + 'ext': 'json', + 'files': json_filename, + "stagingDir": stagingdir, + } + instance.data["representations"].append(representation) + + self.log.info("Extracted instance '%s' to: %s", + instance.name, representation) diff --git a/openpype/hosts/harmony/__init__.py b/openpype/hosts/harmony/__init__.py index e69de29bb2..8560fbaf4b 100644 --- a/openpype/hosts/harmony/__init__.py +++ b/openpype/hosts/harmony/__init__.py @@ -0,0 +1,10 @@ +import os + + +def add_implementation_envs(env, _app): + """Modify environments to contain all required for implementation.""" + openharmony_path = os.path.join( + os.environ["OPENPYPE_REPOS_ROOT"], "pype", "vendor", "OpenHarmony" + ) + # TODO check if is already set? What to do if is already set? + env["LIB_OPENHARMONY_PATH"] = openharmony_path diff --git a/openpype/hosts/harmony/api/__init__.py b/openpype/hosts/harmony/api/__init__.py index 705ccef892..fd21725bd5 100644 --- a/openpype/hosts/harmony/api/__init__.py +++ b/openpype/hosts/harmony/api/__init__.py @@ -3,6 +3,7 @@ import os from pathlib import Path import logging +import re from openpype import lib from openpype.api import (get_current_project_settings) @@ -63,26 +64,9 @@ def get_asset_settings(): "handleStart": handle_start, "handleEnd": handle_end, "resolutionWidth": resolution_width, - "resolutionHeight": resolution_height + "resolutionHeight": resolution_height, + "entityType": entity_type } - settings = get_current_project_settings() - - try: - skip_resolution_check = \ - settings["harmony"]["general"]["skip_resolution_check"] - skip_timelines_check = \ - settings["harmony"]["general"]["skip_timelines_check"] - except KeyError: - skip_resolution_check = [] - skip_timelines_check = [] - - if os.getenv('AVALON_TASK') in skip_resolution_check: - scene_data.pop("resolutionWidth") - scene_data.pop("resolutionHeight") - - if entity_type in skip_timelines_check: - scene_data.pop('frameStart', None) - scene_data.pop('frameEnd', None) return scene_data diff --git a/openpype/hosts/harmony/plugins/publish/validate_scene_settings.py b/openpype/hosts/harmony/plugins/publish/validate_scene_settings.py index b3e7f49268..0371e80095 100644 --- a/openpype/hosts/harmony/plugins/publish/validate_scene_settings.py +++ b/openpype/hosts/harmony/plugins/publish/validate_scene_settings.py @@ -2,6 +2,7 @@ """Validate scene settings.""" import os import json +import re import pyblish.api @@ -41,22 +42,42 @@ class ValidateSceneSettings(pyblish.api.InstancePlugin): families = ["workfile"] hosts = ["harmony"] actions = [ValidateSceneSettingsRepair] + optional = True - frame_check_filter = ["_ch_", "_pr_", "_intd_", "_extd_"] - # used for skipping resolution validation for render tasks - render_check_filter = ["render", "Render"] + # skip frameEnd check if asset contains any of: + frame_check_filter = ["_ch_", "_pr_", "_intd_", "_extd_"] # regex + + # skip resolution check if Task name matches any of regex patterns + skip_resolution_check = ["render", "Render"] # regex + + # skip frameStart, frameEnd check if Task name matches any of regex patt. + skip_timelines_check = [] # regex def process(self, instance): """Plugin entry point.""" expected_settings = openpype.hosts.harmony.api.get_asset_settings() - self.log.info(expected_settings) + self.log.info("scene settings from DB:".format(expected_settings)) expected_settings = _update_frames(dict.copy(expected_settings)) expected_settings["frameEndHandle"] = expected_settings["frameEnd"] +\ expected_settings["handleEnd"] - if any(string in instance.context.data['anatomyData']['asset'] - for string in self.frame_check_filter): + if (any(re.search(pattern, os.getenv('AVALON_TASK')) + for pattern in self.skip_resolution_check)): + expected_settings.pop("resolutionWidth") + expected_settings.pop("resolutionHeight") + + entity_type = expected_settings.get("entityType") + if (any(re.search(pattern, entity_type) + for pattern in self.skip_timelines_check)): + expected_settings.pop('frameStart', None) + expected_settings.pop('frameEnd', None) + + expected_settings.pop("entityType") # not useful after the check + + asset_name = instance.context.data['anatomyData']['asset'] + if any(re.search(pattern, asset_name) + for pattern in self.frame_check_filter): expected_settings.pop("frameEnd") # handle case where ftrack uses only two decimal places @@ -66,13 +87,7 @@ class ValidateSceneSettings(pyblish.api.InstancePlugin): fps = float( "{:.2f}".format(instance.context.data.get("frameRate"))) - if any(string in instance.context.data['anatomyData']['task'] - for string in self.render_check_filter): - self.log.debug("Render task detected, resolution check skipped") - expected_settings.pop("resolutionWidth") - expected_settings.pop("resolutionHeight") - - self.log.debug(expected_settings) + self.log.debug("filtered settings: {}".format(expected_settings)) current_settings = { "fps": fps, @@ -84,7 +99,7 @@ class ValidateSceneSettings(pyblish.api.InstancePlugin): "resolutionWidth": instance.context.data.get("resolutionWidth"), "resolutionHeight": instance.context.data.get("resolutionHeight"), } - self.log.debug("curr:: {}".format(current_settings)) + self.log.debug("current scene settings {}".format(current_settings)) invalid_settings = [] for key, value in expected_settings.items(): diff --git a/openpype/hosts/hiero/__init__.py b/openpype/hosts/hiero/__init__.py index e69de29bb2..1781f808e2 100644 --- a/openpype/hosts/hiero/__init__.py +++ b/openpype/hosts/hiero/__init__.py @@ -0,0 +1,40 @@ +import os +import platform + + +def add_implementation_envs(env, _app): + # Add requirements to HIERO_PLUGIN_PATH + pype_root = os.environ["OPENPYPE_REPOS_ROOT"] + new_hiero_paths = [ + os.path.join(pype_root, "openpype", "hosts", "hiero", "startup") + ] + old_hiero_path = env.get("HIERO_PLUGIN_PATH") or "" + for path in old_hiero_path.split(os.pathsep): + if not path or not os.path.exists(path): + continue + + norm_path = os.path.normpath(path) + if norm_path not in new_hiero_paths: + new_hiero_paths.append(norm_path) + + env["HIERO_PLUGIN_PATH"] = os.pathsep.join(new_hiero_paths) + + # Try to add QuickTime to PATH + quick_time_path = "C:/Program Files (x86)/QuickTime/QTSystem" + if platform.system() == "windows" and os.path.exists(quick_time_path): + path_value = env.get("PATH") or "" + path_paths = [ + path + for path in path_value.split(os.pathsep) + if path + ] + path_paths.append(quick_time_path) + env["PATH"] = os.pathsep.join(path_paths) + + # Set default values if are not already set via settings + defaults = { + "LOGLEVEL": "DEBUG" + } + for key, value in defaults.items(): + if not env.get(key): + env[key] = value diff --git a/openpype/hosts/houdini/__init__.py b/openpype/hosts/houdini/__init__.py index e69de29bb2..8c12d13c81 100644 --- a/openpype/hosts/houdini/__init__.py +++ b/openpype/hosts/houdini/__init__.py @@ -0,0 +1,38 @@ +import os + + +def add_implementation_envs(env, _app): + # Add requirements to HOUDINI_PATH and HOUDINI_MENU_PATH + pype_root = os.environ["OPENPYPE_REPOS_ROOT"] + + startup_path = os.path.join( + pype_root, "openpype", "hosts", "houdini", "startup" + ) + new_houdini_path = [startup_path] + new_houdini_menu_path = [startup_path] + + old_houdini_path = env.get("HOUDINI_PATH") or "" + old_houdini_menu_path = env.get("HOUDINI_MENU_PATH") or "" + + for path in old_houdini_path.split(os.pathsep): + if not path or not os.path.exists(path): + continue + + norm_path = os.path.normpath(path) + if norm_path not in new_houdini_path: + new_houdini_path.append(norm_path) + + for path in old_houdini_menu_path.split(os.pathsep): + if not path or not os.path.exists(path): + continue + + norm_path = os.path.normpath(path) + if norm_path not in new_houdini_menu_path: + new_houdini_menu_path.append(norm_path) + + # Add ampersand for unknown reason (Maybe is needed in Houdini?) + new_houdini_path.append("&") + new_houdini_menu_path.append("&") + + env["HOUDINI_PATH"] = os.pathsep.join(new_houdini_path) + env["HOUDINI_MENU_PATH"] = os.pathsep.join(new_houdini_menu_path) diff --git a/openpype/hosts/maya/__init__.py b/openpype/hosts/maya/__init__.py index e69de29bb2..549f100007 100644 --- a/openpype/hosts/maya/__init__.py +++ b/openpype/hosts/maya/__init__.py @@ -0,0 +1,29 @@ +import os + + +def add_implementation_envs(env, _app): + # Add requirements to PYTHONPATH + pype_root = os.environ["OPENPYPE_REPOS_ROOT"] + new_python_paths = [ + os.path.join(pype_root, "openpype", "hosts", "maya", "startup"), + os.path.join(pype_root, "repos", "avalon-core", "setup", "maya"), + os.path.join(pype_root, "tools", "mayalookassigner") + ] + old_python_path = env.get("PYTHONPATH") or "" + for path in old_python_path.split(os.pathsep): + if not path or not os.path.exists(path): + continue + + norm_path = os.path.normpath(path) + if norm_path not in new_python_paths: + new_python_paths.append(norm_path) + + env["PYTHONPATH"] = os.pathsep.join(new_python_paths) + + # Set default values if are not already set via settings + defaults = { + "OPENPYPE_LOG_NO_COLORS": "Yes" + } + for key, value in defaults.items(): + if not env.get(key): + env[key] = value diff --git a/openpype/hosts/maya/api/expected_files.py b/openpype/hosts/maya/api/expected_files.py index 186b199796..c6232f6ca4 100644 --- a/openpype/hosts/maya/api/expected_files.py +++ b/openpype/hosts/maya/api/expected_files.py @@ -184,7 +184,7 @@ class AExpectedFiles: (str): sanitized camera name Example: - >>> sanizite_camera_name('test:camera_01') + >>> AExpectedFiles.sanizite_camera_name('test:camera_01') test_camera_01 """ @@ -230,7 +230,7 @@ class AExpectedFiles: if self.layer.startswith("rs_"): layer_name = self.layer[3:] - scene_data = { + return { "frameStart": int(self.get_render_attribute("startFrame")), "frameEnd": int(self.get_render_attribute("endFrame")), "frameStep": int(self.get_render_attribute("byFrameStep")), @@ -245,7 +245,6 @@ class AExpectedFiles: "filePrefix": file_prefix, "enabledAOVs": self.get_aovs(), } - return scene_data def _generate_single_file_sequence( self, layer_data, force_aov_name=None): @@ -685,8 +684,6 @@ class ExpectedFilesRedshift(AExpectedFiles): """Expected files for Redshift renderer. Attributes: - ext_mapping (list): Mapping redshift extension dropdown values - to strings. unmerged_aovs (list): Name of aovs that are not merged into resulting exr and we need them specified in expectedFiles output. @@ -695,8 +692,6 @@ class ExpectedFilesRedshift(AExpectedFiles): unmerged_aovs = ["Cryptomatte"] - ext_mapping = ["iff", "exr", "tif", "png", "tga", "jpg"] - def __init__(self, layer, render_instance): """Construtor.""" super(ExpectedFilesRedshift, self).__init__(layer, render_instance) @@ -785,12 +780,10 @@ class ExpectedFilesRedshift(AExpectedFiles): # anyway. return enabled_aovs - default_ext = self.ext_mapping[ - cmds.getAttr("redshiftOptions.imageFormat") - ] + default_ext = cmds.getAttr( + "redshiftOptions.imageFormat", asString=True) rs_aovs = cmds.ls(type="RedshiftAOV", referencedNodes=False) - # todo: find out how to detect multichannel exr for redshift for aov in rs_aovs: enabled = self.maya_is_true(cmds.getAttr("{}.enabled".format(aov))) for override in self.get_layer_overrides( diff --git a/openpype/hosts/maya/plugins/create/create_render.py b/openpype/hosts/maya/plugins/create/create_render.py index 907f9cf781..cbca091365 100644 --- a/openpype/hosts/maya/plugins/create/create_render.py +++ b/openpype/hosts/maya/plugins/create/create_render.py @@ -12,7 +12,7 @@ from openpype.hosts.maya.api import ( lib, plugin ) -from openpype.api import get_system_settings +from openpype.api import (get_system_settings, get_asset) class CreateRender(plugin.Creator): @@ -104,7 +104,7 @@ class CreateRender(plugin.Creator): # namespace is not empty, so we leave it untouched pass - while(cmds.namespace(exists=namespace_name)): + while cmds.namespace(exists=namespace_name): namespace_name = "_{}{}".format(str(instance), index) index += 1 @@ -125,7 +125,7 @@ class CreateRender(plugin.Creator): cmds.sets(sets, forceElement=instance) # if no render layers are present, create default one with - # asterix selector + # asterisk selector if not layers: render_layer = self._rs.createRenderLayer('Main') collection = render_layer.createCollection("defaultCollection") @@ -137,9 +137,7 @@ class CreateRender(plugin.Creator): if renderer.startswith('renderman'): renderer = 'renderman' - cmds.setAttr(self._image_prefix_nodes[renderer], - self._image_prefixes[renderer], - type="string") + self._set_default_renderer_settings(renderer) def _create_render_settings(self): # get pools @@ -318,3 +316,86 @@ class CreateRender(plugin.Creator): False if os.getenv("OPENPYPE_DONT_VERIFY_SSL", True) else True ) # noqa return requests.get(*args, **kwargs) + + def _set_default_renderer_settings(self, renderer): + """Set basic settings based on renderer. + + Args: + renderer (str): Renderer name. + + """ + cmds.setAttr(self._image_prefix_nodes[renderer], + self._image_prefixes[renderer], + type="string") + + asset = get_asset() + + if renderer == "arnold": + # set format to exr + cmds.setAttr( + "defaultArnoldDriver.ai_translator", "exr", type="string") + # enable animation + cmds.setAttr("defaultRenderGlobals.outFormatControl", 0) + cmds.setAttr("defaultRenderGlobals.animation", 1) + cmds.setAttr("defaultRenderGlobals.putFrameBeforeExt", 1) + cmds.setAttr("defaultRenderGlobals.extensionPadding", 4) + + # resolution + cmds.setAttr( + "defaultResolution.width", + asset["data"].get("resolutionWidth")) + cmds.setAttr( + "defaultResolution.height", + asset["data"].get("resolutionHeight")) + + if renderer == "vray": + vray_settings = cmds.ls(type="VRaySettingsNode") + if not vray_settings: + node = cmds.createNode("VRaySettingsNode") + else: + node = vray_settings[0] + + # set underscore as element separator instead of default `.` + cmds.setAttr( + "{}.fileNameRenderElementSeparator".format( + node), + "_" + ) + # set format to exr + cmds.setAttr( + "{}.imageFormatStr".format(node), 5) + + # animType + cmds.setAttr( + "{}.animType".format(node), 1) + + # resolution + cmds.setAttr( + "{}.width".format(node), + asset["data"].get("resolutionWidth")) + cmds.setAttr( + "{}.height".format(node), + asset["data"].get("resolutionHeight")) + + if renderer == "redshift": + redshift_settings = cmds.ls(type="RedshiftOptions") + if not redshift_settings: + node = cmds.createNode("RedshiftOptions") + else: + node = redshift_settings[0] + + # set exr + cmds.setAttr("{}.imageFormat".format(node), 1) + # resolution + cmds.setAttr( + "defaultResolution.width", + asset["data"].get("resolutionWidth")) + cmds.setAttr( + "defaultResolution.height", + asset["data"].get("resolutionHeight")) + + # enable animation + cmds.setAttr("defaultRenderGlobals.outFormatControl", 0) + cmds.setAttr("defaultRenderGlobals.animation", 1) + cmds.setAttr("defaultRenderGlobals.putFrameBeforeExt", 1) + cmds.setAttr("defaultRenderGlobals.extensionPadding", 4) diff --git a/openpype/hosts/maya/plugins/publish/extract_playblast.py b/openpype/hosts/maya/plugins/publish/extract_playblast.py index 358fca6c2a..0dc91d67a9 100644 --- a/openpype/hosts/maya/plugins/publish/extract_playblast.py +++ b/openpype/hosts/maya/plugins/publish/extract_playblast.py @@ -96,19 +96,25 @@ class ExtractPlayblast(openpype.api.Extractor): # Remove panel key since it's internal value to capture_gui preset.pop("panel", None) - self.log.info('using viewport preset: {}'.format(preset)) path = capture.capture(**preset) - playblast = self._fix_playblast_output_path(path) - self.log.info("file list {}".format(playblast)) + self.log.debug("playblast path {}".format(path)) - collected_frames = os.listdir(stagingdir) - collections, remainder = clique.assemble(collected_frames) - input_path = os.path.join( - stagingdir, collections[0].format('{head}{padding}{tail}')) - self.log.info("input {}".format(input_path)) + collected_files = os.listdir(stagingdir) + collections, remainder = clique.assemble(collected_files) + + self.log.debug("filename {}".format(filename)) + frame_collection = None + for collection in collections: + filebase = collection.format('{head}').rstrip(".") + self.log.debug("collection head {}".format(filebase)) + if filebase in filename: + frame_collection = collection + self.log.info( + "we found collection of interest {}".format( + str(frame_collection))) if "representations" not in instance.data: instance.data["representations"] = [] @@ -119,12 +125,11 @@ class ExtractPlayblast(openpype.api.Extractor): # Add camera node name to representation data camera_node_name = pm.ls(camera)[0].getTransform().name() - representation = { 'name': 'png', 'ext': 'png', - 'files': collected_frames, + 'files': list(frame_collection), "stagingDir": stagingdir, "frameStart": start, "frameEnd": end, @@ -135,44 +140,6 @@ class ExtractPlayblast(openpype.api.Extractor): } instance.data["representations"].append(representation) - def _fix_playblast_output_path(self, filepath): - """Workaround a bug in maya.cmds.playblast to return correct filepath. - - When the `viewer` argument is set to False and maya.cmds.playblast - does not automatically open the playblasted file the returned - filepath does not have the file's extension added correctly. - - To workaround this we just glob.glob() for any file extensions and - assume the latest modified file is the correct file and return it. - """ - # Catch cancelled playblast - if filepath is None: - self.log.warning("Playblast did not result in output path. " - "Playblast is probably interrupted.") - return None - - # Fix: playblast not returning correct filename (with extension) - # Lets assume the most recently modified file is the correct one. - if not os.path.exists(filepath): - directory = os.path.dirname(filepath) - filename = os.path.basename(filepath) - # check if the filepath is has frame based filename - # example : capture.####.png - parts = filename.split(".") - if len(parts) == 3: - query = os.path.join(directory, "{}.*.{}".format(parts[0], - parts[-1])) - files = glob.glob(query) - else: - files = glob.glob("{}.*".format(filepath)) - - if not files: - raise RuntimeError("Couldn't find playblast from: " - "{0}".format(filepath)) - filepath = max(files, key=os.path.getmtime) - - return filepath - @contextlib.contextmanager def maintained_time(): diff --git a/openpype/hosts/maya/plugins/publish/validate_rendersettings.py b/openpype/hosts/maya/plugins/publish/validate_rendersettings.py index ba676bee83..9aeaad7ff1 100644 --- a/openpype/hosts/maya/plugins/publish/validate_rendersettings.py +++ b/openpype/hosts/maya/plugins/publish/validate_rendersettings.py @@ -1,8 +1,9 @@ -import os +# -*- coding: utf-8 -*- +"""Maya validator for render settings.""" import re +from collections import OrderedDict from maya import cmds, mel -import pymel.core as pm import pyblish.api import openpype.api @@ -60,6 +61,8 @@ class ValidateRenderSettings(pyblish.api.InstancePlugin): 'renderman': '_..' } + redshift_AOV_prefix = "/_" + # WARNING: There is bug? in renderman, translating token # to something left behind mayas default image prefix. So instead # `SceneName_v01` it translates to: @@ -120,25 +123,59 @@ class ValidateRenderSettings(pyblish.api.InstancePlugin): "doesn't have: '' or " "'' token".format(prefix)) - if len(cameras) > 1: - if not re.search(cls.R_CAMERA_TOKEN, prefix): - invalid = True - cls.log.error("Wrong image prefix [ {} ] - " - "doesn't have: '' token".format(prefix)) + if len(cameras) > 1 and not re.search(cls.R_CAMERA_TOKEN, prefix): + invalid = True + cls.log.error("Wrong image prefix [ {} ] - " + "doesn't have: '' token".format(prefix)) # renderer specific checks if renderer == "vray": - # no vray checks implemented yet - pass - elif renderer == "redshift": + vray_settings = cmds.ls(type="VRaySettingsNode") + if not vray_settings: + node = cmds.createNode("VRaySettingsNode") + else: + node = vray_settings[0] + + if cmds.getAttr( + "{}.fileNameRenderElementSeparator".format(node)) != "_": + invalid = False + cls.log.error("AOV separator is not set correctly.") + + if renderer == "redshift": if re.search(cls.R_AOV_TOKEN, prefix): invalid = True - cls.log.error("Do not use AOV token [ {} ] - " - "Redshift automatically append AOV name and " - "it doesn't make much sense with " - "Multipart EXR".format(prefix)) + cls.log.error(("Do not use AOV token [ {} ] - " + "Redshift is using image prefixes per AOV so " + "it doesn't make much sense using it in global" + "image prefix").format(prefix)) + # get redshift AOVs + rs_aovs = cmds.ls(type="RedshiftAOV", referencedNodes=False) + for aov in rs_aovs: + aov_prefix = cmds.getAttr("{}.filePrefix".format(aov)) + # check their image prefix + if aov_prefix != cls.redshift_AOV_prefix: + cls.log.error(("AOV ({}) image prefix is not set " + "correctly {} != {}").format( + cmds.getAttr("{}.name".format(aov)), + cmds.getAttr("{}.filePrefix".format(aov)), + aov_prefix + )) + invalid = True + # get aov format + aov_ext = cmds.getAttr( + "{}.fileFormat".format(aov), asString=True) - elif renderer == "renderman": + default_ext = cmds.getAttr( + "redshiftOptions.imageFormat", asString=True) + + if default_ext != aov_ext: + cls.log.error(("AOV file format is not the same " + "as the one set globally " + "{} != {}").format(default_ext, + aov_ext)) + invalid = True + + if renderer == "renderman": file_prefix = cmds.getAttr("rmanGlobals.imageFileFormat") dir_prefix = cmds.getAttr("rmanGlobals.imageOutputDir") @@ -151,7 +188,7 @@ class ValidateRenderSettings(pyblish.api.InstancePlugin): cls.log.error("Wrong directory prefix [ {} ]".format( dir_prefix)) - else: + if renderer == "arnold": multipart = cmds.getAttr("defaultArnoldDriver.mergeAOVs") if multipart: if re.search(cls.R_AOV_TOKEN, prefix): @@ -177,6 +214,43 @@ class ValidateRenderSettings(pyblish.api.InstancePlugin): cls.log.error("Expecting padding of {} ( {} )".format( cls.DEFAULT_PADDING, "0" * cls.DEFAULT_PADDING)) + # load validation definitions from settings + validation_settings = ( + instance.context.data["project_settings"]["maya"]["publish"]["ValidateRenderSettings"].get( # noqa: E501 + "{}_render_attributes".format(renderer)) + ) + + # go through definitions and test if such node.attribute exists. + # if so, compare its value from the one required. + for attr, value in OrderedDict(validation_settings).items(): + # first get node of that type + cls.log.debug("{}: {}".format(attr, value)) + node_type = attr.split(".")[0] + attribute_name = ".".join(attr.split(".")[1:]) + nodes = cmds.ls(type=node_type) + + if not isinstance(nodes, list): + cls.log.warning("No nodes of '{}' found.".format(node_type)) + continue + + for node in nodes: + try: + render_value = cmds.getAttr( + "{}.{}".format(node, attribute_name)) + except RuntimeError: + invalid = True + cls.log.error( + "Cannot get value of {}.{}".format( + node, attribute_name)) + else: + if value != render_value: + invalid = True + cls.log.error( + ("Invalid value {} set on {}.{}. " + "Expecting {}").format( + render_value, node, attribute_name, value) + ) + return invalid @classmethod @@ -210,3 +284,29 @@ class ValidateRenderSettings(pyblish.api.InstancePlugin): cmds.setAttr("rmanGlobals.imageOutputDir", cls.RendermanDirPrefix, type="string") + + if renderer == "vray": + vray_settings = cmds.ls(type="VRaySettingsNode") + if not vray_settings: + node = cmds.createNode("VRaySettingsNode") + else: + node = vray_settings[0] + + cmds.setAttr( + "{}.fileNameRenderElementSeparator".format( + node), + "_" + ) + + if renderer == "redshift": + # get redshift AOVs + rs_aovs = cmds.ls(type="RedshiftAOV", referencedNodes=False) + for aov in rs_aovs: + # fix AOV prefixes + cmds.setAttr( + "{}.filePrefix".format(aov), cls.redshift_AOV_prefix) + # fix AOV file format + default_ext = cmds.getAttr( + "redshiftOptions.imageFormat", asString=True) + cmds.setAttr( + "{}.fileFormat".format(aov), default_ext) diff --git a/openpype/hosts/nuke/__init__.py b/openpype/hosts/nuke/__init__.py index e69de29bb2..f1e81617e0 100644 --- a/openpype/hosts/nuke/__init__.py +++ b/openpype/hosts/nuke/__init__.py @@ -0,0 +1,43 @@ +import os +import platform + + +def add_implementation_envs(env, _app): + # Add requirements to NUKE_PATH + pype_root = os.environ["OPENPYPE_REPOS_ROOT"] + new_nuke_paths = [ + os.path.join(pype_root, "openpype", "hosts", "nuke", "startup"), + os.path.join( + pype_root, "repos", "avalon-core", "setup", "nuke", "nuke_path" + ) + ] + old_nuke_path = env.get("NUKE_PATH") or "" + for path in old_nuke_path.split(os.pathsep): + if not path or not os.path.exists(path): + continue + + norm_path = os.path.normpath(path) + if norm_path not in new_nuke_paths: + new_nuke_paths.append(norm_path) + + env["NUKE_PATH"] = os.pathsep.join(new_nuke_paths) + + # Try to add QuickTime to PATH + quick_time_path = "C:/Program Files (x86)/QuickTime/QTSystem" + if platform.system() == "windows" and os.path.exists(quick_time_path): + path_value = env.get("PATH") or "" + path_paths = [ + path + for path in path_value.split(os.pathsep) + if path + ] + path_paths.append(quick_time_path) + env["PATH"] = os.pathsep.join(path_paths) + + # Set default values if are not already set via settings + defaults = { + "LOGLEVEL": "DEBUG" + } + for key, value in defaults.items(): + if not env.get(key): + env[key] = value diff --git a/openpype/hosts/nuke/plugins/load/load_mov.py b/openpype/hosts/nuke/plugins/load/load_mov.py index 8b8c5d0c10..e2c9acaa9c 100644 --- a/openpype/hosts/nuke/plugins/load/load_mov.py +++ b/openpype/hosts/nuke/plugins/load/load_mov.py @@ -1,6 +1,5 @@ import nuke -import contextlib - +from avalon.vendor import qargparse from avalon import api, io from openpype.api import get_current_project_settings from openpype.hosts.nuke.api.lib import ( @@ -8,41 +7,6 @@ from openpype.hosts.nuke.api.lib import ( ) -@contextlib.contextmanager -def preserve_trim(node): - """Preserve the relative trim of the Loader tool. - - This tries to preserve the loader's trim (trim in and trim out) after - the context by reapplying the "amount" it trims on the clip's length at - start and end. - - """ - # working script frame range - script_start = nuke.root()["first_frame"].value() - - start_at_frame = None - offset_frame = None - if node['frame_mode'].value() == "start at": - start_at_frame = node['frame'].value() - if node['frame_mode'].value() == "offset": - offset_frame = node['frame'].value() - - try: - yield - finally: - if start_at_frame: - node['frame_mode'].setValue("start at") - node['frame'].setValue(str(script_start)) - print("start frame of Read was set to" - "{}".format(script_start)) - - if offset_frame: - node['frame_mode'].setValue("offset") - node['frame'].setValue(str((script_start + offset_frame))) - print("start frame of Read was set to" - "{}".format(script_start)) - - def add_review_presets_config(): returning = { "families": list(), @@ -79,14 +43,30 @@ class LoadMov(api.Loader): script_start = nuke.root()["first_frame"].value() + # options gui + defaults = { + "start_at_workfile": True + } + + options = [ + qargparse.Boolean( + "start_at_workfile", + help="Load at workfile start frame", + default=True + ) + ] + node_name_template = "{class_name}_{ext}" - def load(self, context, name, namespace, data): + def load(self, context, name, namespace, options): from avalon.nuke import ( containerise, viewer_update_and_undo_stop ) + start_at_workfile = options.get( + "start_at_workfile", self.defaults["start_at_workfile"]) + version = context['version'] version_data = version.get("data", {}) repr_id = context["representation"]["_id"] @@ -149,10 +129,14 @@ class LoadMov(api.Loader): read_node["first"].setValue(first) read_node["origlast"].setValue(last) read_node["last"].setValue(last) - - # start at script start read_node['frame_mode'].setValue("start at") - read_node['frame'].setValue(str(self.script_start)) + + if start_at_workfile: + # start at workfile start + read_node['frame'].setValue(str(self.script_start)) + else: + # start at version frame start + read_node['frame'].setValue(str(orig_first - handle_start)) if colorspace: read_node["colorspace"].setValue(str(colorspace)) @@ -266,29 +250,29 @@ class LoadMov(api.Loader): # create handles offset (only to last, because of mov) last += handle_start + handle_end - # Update the loader's path whilst preserving some values - with preserve_trim(read_node): - read_node["file"].setValue(file) - self.log.info("__ node['file']: {}".format( - read_node["file"].value())) + read_node["file"].setValue(file) - # Set the global in to the start frame of the sequence - read_node["origfirst"].setValue(first) - read_node["first"].setValue(first) - read_node["origlast"].setValue(last) - read_node["last"].setValue(last) + # Set the global in to the start frame of the sequence + read_node["origfirst"].setValue(first) + read_node["first"].setValue(first) + read_node["origlast"].setValue(last) + read_node["last"].setValue(last) + read_node['frame_mode'].setValue("start at") - # start at script start - read_node['frame_mode'].setValue("start at") + if int(self.script_start) == int(read_node['frame'].value()): + # start at workfile start read_node['frame'].setValue(str(self.script_start)) + else: + # start at version frame start + read_node['frame'].setValue(str(orig_first - handle_start)) - if colorspace: - read_node["colorspace"].setValue(str(colorspace)) + if colorspace: + read_node["colorspace"].setValue(str(colorspace)) - preset_clrsp = get_imageio_input_colorspace(file) + preset_clrsp = get_imageio_input_colorspace(file) - if preset_clrsp is not None: - read_node["colorspace"].setValue(preset_clrsp) + if preset_clrsp is not None: + read_node["colorspace"].setValue(preset_clrsp) updated_dict = {} updated_dict.update({ @@ -321,8 +305,8 @@ class LoadMov(api.Loader): from avalon.nuke import viewer_update_and_undo_stop - node = nuke.toNode(container['objectName']) - assert node.Class() == "Read", "Must be Read" + read_node = nuke.toNode(container['objectName']) + assert read_node.Class() == "Read", "Must be Read" with viewer_update_and_undo_stop(): - nuke.delete(node) + nuke.delete(read_node) diff --git a/openpype/hosts/nuke/plugins/load/load_sequence.py b/openpype/hosts/nuke/plugins/load/load_sequence.py index 71f0b8c298..9cbd1d4466 100644 --- a/openpype/hosts/nuke/plugins/load/load_sequence.py +++ b/openpype/hosts/nuke/plugins/load/load_sequence.py @@ -1,74 +1,11 @@ import nuke -import contextlib - +from avalon.vendor import qargparse from avalon import api, io from openpype.hosts.nuke.api.lib import ( get_imageio_input_colorspace ) -@contextlib.contextmanager -def preserve_trim(node): - """Preserve the relative trim of the Loader tool. - - This tries to preserve the loader's trim (trim in and trim out) after - the context by reapplying the "amount" it trims on the clip's length at - start and end. - - """ - # working script frame range - script_start = nuke.root()["first_frame"].value() - - start_at_frame = None - offset_frame = None - if node['frame_mode'].value() == "start at": - start_at_frame = node['frame'].value() - if node['frame_mode'].value() == "offset": - offset_frame = node['frame'].value() - - try: - yield - finally: - if start_at_frame: - node['frame_mode'].setValue("start at") - node['frame'].setValue(str(script_start)) - print("start frame of Read was set to" - "{}".format(script_start)) - - if offset_frame: - node['frame_mode'].setValue("offset") - node['frame'].setValue(str((script_start + offset_frame))) - print("start frame of Read was set to" - "{}".format(script_start)) - - -def loader_shift(node, frame, relative=False): - """Shift global in time by i preserving duration - - This moves the loader by i frames preserving global duration. When relative - is False it will shift the global in to the start frame. - - Args: - loader (tool): The fusion loader tool. - frame (int): The amount of frames to move. - relative (bool): When True the shift is relative, else the shift will - change the global in to frame. - - Returns: - int: The resulting relative frame change (how much it moved) - - """ - # working script frame range - script_start = nuke.root()["first_frame"].value() - - if relative: - node['frame_mode'].setValue("start at") - node['frame'].setValue(str(script_start)) - else: - node['frame_mode'].setValue("start at") - node['frame'].setValue(str(frame)) - - class LoadSequence(api.Loader): """Load image sequence into Nuke""" @@ -80,14 +17,32 @@ class LoadSequence(api.Loader): icon = "file-video-o" color = "white" + script_start = nuke.root()["first_frame"].value() + + # option gui + defaults = { + "start_at_workfile": True + } + + options = [ + qargparse.Boolean( + "start_at_workfile", + help="Load at workfile start frame", + default=True + ) + ] + node_name_template = "{class_name}_{ext}" - def load(self, context, name, namespace, data): + def load(self, context, name, namespace, options): from avalon.nuke import ( containerise, viewer_update_and_undo_stop ) + start_at_workfile = options.get( + "start_at_workfile", self.defaults["start_at_workfile"]) + version = context['version'] version_data = version.get("data", {}) repr_id = context["representation"]["_id"] @@ -139,32 +94,31 @@ class LoadSequence(api.Loader): read_name = self.node_name_template.format(**name_data) # Create the Loader with the filename path set - - # TODO: it might be universal read to img/geo/camera - r = nuke.createNode( + read_node = nuke.createNode( "Read", "name {}".format(read_name)) # to avoid multiple undo steps for rest of process # we will switch off undo-ing with viewer_update_and_undo_stop(): - r["file"].setValue(file) + read_node["file"].setValue(file) # Set colorspace defined in version data colorspace = context["version"]["data"].get("colorspace") if colorspace: - r["colorspace"].setValue(str(colorspace)) + read_node["colorspace"].setValue(str(colorspace)) preset_clrsp = get_imageio_input_colorspace(file) if preset_clrsp is not None: - r["colorspace"].setValue(preset_clrsp) + read_node["colorspace"].setValue(preset_clrsp) - loader_shift(r, first, relative=True) - r["origfirst"].setValue(int(first)) - r["first"].setValue(int(first)) - r["origlast"].setValue(int(last)) - r["last"].setValue(int(last)) + # set start frame depending on workfile or version + self.loader_shift(read_node, start_at_workfile) + read_node["origfirst"].setValue(int(first)) + read_node["first"].setValue(int(first)) + read_node["origlast"].setValue(int(last)) + read_node["last"].setValue(int(last)) # add additional metadata from the version to imprint Avalon knob add_keys = ["frameStart", "frameEnd", @@ -181,48 +135,20 @@ class LoadSequence(api.Loader): data_imprint.update({"objectName": read_name}) - r["tile_color"].setValue(int("0x4ecd25ff", 16)) + read_node["tile_color"].setValue(int("0x4ecd25ff", 16)) if version_data.get("retime", None): speed = version_data.get("speed", 1) time_warp_nodes = version_data.get("timewarps", []) - self.make_retimes(r, speed, time_warp_nodes) + self.make_retimes(read_node, speed, time_warp_nodes) - return containerise(r, + return containerise(read_node, name=name, namespace=namespace, context=context, loader=self.__class__.__name__, data=data_imprint) - def make_retimes(self, node, speed, time_warp_nodes): - ''' Create all retime and timewarping nodes with coppied animation ''' - if speed != 1: - rtn = nuke.createNode( - "Retime", - "speed {}".format(speed)) - rtn["before"].setValue("continue") - rtn["after"].setValue("continue") - rtn["input.first_lock"].setValue(True) - rtn["input.first"].setValue( - self.handle_start + self.first_frame - ) - - if time_warp_nodes != []: - for timewarp in time_warp_nodes: - twn = nuke.createNode(timewarp["Class"], - "name {}".format(timewarp["name"])) - if isinstance(timewarp["lookup"], list): - # if array for animation - twn["lookup"].setAnimated() - for i, value in enumerate(timewarp["lookup"]): - twn["lookup"].setValueAt( - (self.first_frame + i) + value, - (self.first_frame + i)) - else: - # if static value `int` - twn["lookup"].setValue(timewarp["lookup"]) - def switch(self, container, representation): self.update(container, representation) @@ -239,9 +165,9 @@ class LoadSequence(api.Loader): update_container ) - node = nuke.toNode(container['objectName']) + read_node = nuke.toNode(container['objectName']) - assert node.Class() == "Read", "Must be Read" + assert read_node.Class() == "Read", "Must be Read" repr_cont = representation["context"] @@ -288,23 +214,23 @@ class LoadSequence(api.Loader): self.log.warning( "Missing start frame for updated version" "assuming starts at frame 0 for: " - "{} ({})".format(node['name'].value(), representation)) + "{} ({})".format(read_node['name'].value(), representation)) first = 0 first -= self.handle_start last += self.handle_end - # Update the loader's path whilst preserving some values - with preserve_trim(node): - node["file"].setValue(file) - self.log.info("__ node['file']: {}".format(node["file"].value())) + read_node["file"].setValue(file) - # Set the global in to the start frame of the sequence - loader_shift(node, first, relative=True) - node["origfirst"].setValue(int(first)) - node["first"].setValue(int(first)) - node["origlast"].setValue(int(last)) - node["last"].setValue(int(last)) + # set start frame depending on workfile or version + self.loader_shift( + read_node, + bool("start at" in read_node['frame_mode'].value())) + + read_node["origfirst"].setValue(int(first)) + read_node["first"].setValue(int(first)) + read_node["origlast"].setValue(int(last)) + read_node["last"].setValue(int(last)) updated_dict = {} updated_dict.update({ @@ -321,20 +247,20 @@ class LoadSequence(api.Loader): "outputDir": version_data.get("outputDir"), }) - # change color of node + # change color of read_node if version.get("name") not in [max_version]: - node["tile_color"].setValue(int("0xd84f20ff", 16)) + read_node["tile_color"].setValue(int("0xd84f20ff", 16)) else: - node["tile_color"].setValue(int("0x4ecd25ff", 16)) + read_node["tile_color"].setValue(int("0x4ecd25ff", 16)) if version_data.get("retime", None): speed = version_data.get("speed", 1) time_warp_nodes = version_data.get("timewarps", []) - self.make_retimes(node, speed, time_warp_nodes) + self.make_retimes(read_node, speed, time_warp_nodes) # Update the imprinted representation update_container( - node, + read_node, updated_dict ) self.log.info("udated to version: {}".format(version.get("name"))) @@ -343,8 +269,48 @@ class LoadSequence(api.Loader): from avalon.nuke import viewer_update_and_undo_stop - node = nuke.toNode(container['objectName']) - assert node.Class() == "Read", "Must be Read" + read_node = nuke.toNode(container['objectName']) + assert read_node.Class() == "Read", "Must be Read" with viewer_update_and_undo_stop(): - nuke.delete(node) + nuke.delete(read_node) + + def make_retimes(self, speed, time_warp_nodes): + ''' Create all retime and timewarping nodes with coppied animation ''' + if speed != 1: + rtn = nuke.createNode( + "Retime", + "speed {}".format(speed)) + rtn["before"].setValue("continue") + rtn["after"].setValue("continue") + rtn["input.first_lock"].setValue(True) + rtn["input.first"].setValue( + self.handle_start + self.first_frame + ) + + if time_warp_nodes != []: + for timewarp in time_warp_nodes: + twn = nuke.createNode(timewarp["Class"], + "name {}".format(timewarp["name"])) + if isinstance(timewarp["lookup"], list): + # if array for animation + twn["lookup"].setAnimated() + for i, value in enumerate(timewarp["lookup"]): + twn["lookup"].setValueAt( + (self.first_frame + i) + value, + (self.first_frame + i)) + else: + # if static value `int` + twn["lookup"].setValue(timewarp["lookup"]) + + def loader_shift(self, read_node, workfile_start=False): + """ Set start frame of read node to a workfile start + + Args: + read_node (nuke.Node): The nuke's read node + workfile_start (bool): set workfile start frame if true + + """ + if workfile_start: + read_node['frame_mode'].setValue("start at") + read_node['frame'].setValue(str(self.script_start)) diff --git a/openpype/hosts/photoshop/__init__.py b/openpype/hosts/photoshop/__init__.py index e69de29bb2..a91e0a65ff 100644 --- a/openpype/hosts/photoshop/__init__.py +++ b/openpype/hosts/photoshop/__init__.py @@ -0,0 +1,9 @@ +def add_implementation_envs(env, _app): + """Modify environments to contain all required for implementation.""" + defaults = { + "OPENPYPE_LOG_NO_COLORS": "True", + "WEBSOCKET_URL": "ws://localhost:8099/ws/" + } + for key, value in defaults.items(): + if not env.get(key): + env[key] = value diff --git a/openpype/hosts/tvpaint/__init__.py b/openpype/hosts/tvpaint/__init__.py index e69de29bb2..0e793fcf9f 100644 --- a/openpype/hosts/tvpaint/__init__.py +++ b/openpype/hosts/tvpaint/__init__.py @@ -0,0 +1,8 @@ +def add_implementation_envs(env, _app): + """Modify environments to contain all required for implementation.""" + defaults = { + "OPENPYPE_LOG_NO_COLORS": "True" + } + for key, value in defaults.items(): + if not env.get(key): + env[key] = value diff --git a/openpype/hosts/unreal/__init__.py b/openpype/hosts/unreal/__init__.py index e69de29bb2..1280442916 100644 --- a/openpype/hosts/unreal/__init__.py +++ b/openpype/hosts/unreal/__init__.py @@ -0,0 +1,18 @@ +import os + + +def add_implementation_envs(env, _app): + """Modify environments to contain all required for implementation.""" + # Set AVALON_UNREAL_PLUGIN required for Unreal implementation + unreal_plugin_path = os.path.join( + os.environ["OPENPYPE_REPOS_ROOT"], "repos", "avalon-unreal-integration" + ) + env["AVALON_UNREAL_PLUGIN"] = unreal_plugin_path + + # Set default environments if are not set via settings + defaults = { + "OPENPYPE_LOG_NO_COLORS": "True" + } + for key, value in defaults.items(): + if not env.get(key): + env[key] = value diff --git a/openpype/hosts/unreal/plugins/publish/extract_layout.py b/openpype/hosts/unreal/plugins/publish/extract_layout.py index 5924221f51..2d9f6eb3d1 100644 --- a/openpype/hosts/unreal/plugins/publish/extract_layout.py +++ b/openpype/hosts/unreal/plugins/publish/extract_layout.py @@ -78,14 +78,14 @@ class ExtractLayout(openpype.api.Extractor): json_element["transform"] = { "translation": { - "x": transform.translation.x, + "x": -transform.translation.x, "y": transform.translation.y, "z": transform.translation.z }, "rotation": { - "x": math.radians(transform.rotation.euler().x), + "x": math.radians(transform.rotation.euler().x + 90.0), "y": math.radians(transform.rotation.euler().y), - "z": math.radians(transform.rotation.euler().z), + "z": math.radians(180.0 - transform.rotation.euler().z) }, "scale": { "x": transform.scale3d.x, diff --git a/openpype/lib/__init__.py b/openpype/lib/__init__.py index 838c5aa7a1..c97545fdf4 100644 --- a/openpype/lib/__init__.py +++ b/openpype/lib/__init__.py @@ -58,6 +58,10 @@ from .python_module_tools import ( ) from .avalon_context import ( + CURRENT_DOC_SCHEMAS, + PROJECT_NAME_ALLOWED_SYMBOLS, + PROJECT_NAME_REGEX, + create_project, is_latest, any_outdated, get_asset, @@ -163,6 +167,10 @@ __all__ = [ "recursive_bases_from_class", "classes_from_module", + "CURRENT_DOC_SCHEMAS", + "PROJECT_NAME_ALLOWED_SYMBOLS", + "PROJECT_NAME_REGEX", + "create_project", "is_latest", "any_outdated", "get_asset", diff --git a/openpype/lib/applications.py b/openpype/lib/applications.py index c5c192f51b..a44c43102f 100644 --- a/openpype/lib/applications.py +++ b/openpype/lib/applications.py @@ -3,7 +3,6 @@ import re import copy import json import platform -import getpass import collections import inspect import subprocess @@ -362,7 +361,6 @@ class ApplicationManager: context = ApplicationLaunchContext( app, executable, **data ) - # TODO pass context through launch hooks return context.launch() @@ -626,7 +624,7 @@ class ApplicationLaunchContext: # Logger logger_name = "{}-{}".format(self.__class__.__name__, self.app_name) - self.log = PypeLogger().get_logger(logger_name) + self.log = PypeLogger.get_logger(logger_name) self.executable = executable @@ -1033,7 +1031,7 @@ def _merge_env(env, current_env): return result -def prepare_host_environments(data): +def prepare_host_environments(data, implementation_envs=True): """Modify launch environments based on launched app and context. Args: @@ -1089,7 +1087,24 @@ def prepare_host_environments(data): # Merge dictionaries env_values = _merge_env(tool_env, env_values) - final_env = _merge_env(acre.compute(env_values), data["env"]) + loaded_env = _merge_env(acre.compute(env_values), data["env"]) + + final_env = None + # Add host specific environments + if app.host_name and implementation_envs: + module = __import__("openpype.hosts", fromlist=[app.host_name]) + host_module = getattr(module, app.host_name, None) + add_implementation_envs = None + if host_module: + add_implementation_envs = getattr( + host_module, "add_implementation_envs", None + ) + if add_implementation_envs: + # Function may only modify passed dict without returning value + final_env = add_implementation_envs(loaded_env, app) + + if final_env is None: + final_env = loaded_env # Update env data["env"].update(final_env) diff --git a/openpype/lib/avalon_context.py b/openpype/lib/avalon_context.py index 2d8726352a..2a7c58c4ee 100644 --- a/openpype/lib/avalon_context.py +++ b/openpype/lib/avalon_context.py @@ -17,6 +17,99 @@ avalon = None log = logging.getLogger("AvalonContext") +CURRENT_DOC_SCHEMAS = { + "project": "openpype:project-3.0", + "asset": "openpype:asset-3.0", + "config": "openpype:config-2.0" +} +PROJECT_NAME_ALLOWED_SYMBOLS = "a-zA-Z0-9_" +PROJECT_NAME_REGEX = re.compile( + "^[{}]+$".format(PROJECT_NAME_ALLOWED_SYMBOLS) +) + + +def create_project( + project_name, project_code, library_project=False, dbcon=None +): + """Create project using OpenPype settings. + + This project creation function is not validating project document on + creation. It is because project document is created blindly with only + minimum required information about project which is it's name, code, type + and schema. + + Entered project name must be unique and project must not exist yet. + + Args: + project_name(str): New project name. Should be unique. + project_code(str): Project's code should be unique too. + library_project(bool): Project is library project. + dbcon(AvalonMongoDB): Object of connection to MongoDB. + + Raises: + ValueError: When project name already exists in MongoDB. + + Returns: + dict: Created project document. + """ + + from openpype.settings import ProjectSettings, SaveWarningExc + from avalon.api import AvalonMongoDB + from avalon.schema import validate + + if dbcon is None: + dbcon = AvalonMongoDB() + + if not PROJECT_NAME_REGEX.match(project_name): + raise ValueError(( + "Project name \"{}\" contain invalid characters" + ).format(project_name)) + + database = dbcon.database + project_doc = database[project_name].find_one( + {"type": "project"}, + {"name": 1} + ) + if project_doc: + raise ValueError("Project with name \"{}\" already exists".format( + project_name + )) + + project_doc = { + "type": "project", + "name": project_name, + "data": { + "code": project_code, + "library_project": library_project + }, + "schema": CURRENT_DOC_SCHEMAS["project"] + } + # Insert document with basic data + database[project_name].insert_one(project_doc) + # Load ProjectSettings for the project and save it to store all attributes + # and Anatomy + try: + project_settings_entity = ProjectSettings(project_name) + project_settings_entity.save() + except SaveWarningExc as exc: + print(str(exc)) + except Exception: + database[project_name].delete_one({"type": "project"}) + raise + + project_doc = database[project_name].find_one({"type": "project"}) + + try: + # Validate created project document + validate(project_doc) + except Exception: + # Remove project if is not valid + database[project_name].delete_one({"type": "project"}) + raise + + return project_doc + + def with_avalon(func): @functools.wraps(func) def wrap_avalon(*args, **kwargs): diff --git a/openpype/modules/ftrack/event_handlers_server/event_sync_to_avalon.py b/openpype/modules/ftrack/event_handlers_server/event_sync_to_avalon.py index 3bb01798e4..410e51e2a4 100644 --- a/openpype/modules/ftrack/event_handlers_server/event_sync_to_avalon.py +++ b/openpype/modules/ftrack/event_handlers_server/event_sync_to_avalon.py @@ -26,9 +26,7 @@ from openpype.modules.ftrack.lib import ( BaseEvent ) -from openpype.modules.ftrack.lib.avalon_sync import ( - EntitySchemas -) +from openpype.lib import CURRENT_DOC_SCHEMAS class SyncToAvalonEvent(BaseEvent): @@ -1128,7 +1126,7 @@ class SyncToAvalonEvent(BaseEvent): "_id": mongo_id, "name": name, "type": "asset", - "schema": EntitySchemas["asset"], + "schema": CURRENT_DOC_SCHEMAS["asset"], "parent": proj["_id"], "data": { "ftrackId": ftrack_ent["id"], diff --git a/openpype/modules/ftrack/lib/avalon_sync.py b/openpype/modules/ftrack/lib/avalon_sync.py index f58e858a5a..a3b926464e 100644 --- a/openpype/modules/ftrack/lib/avalon_sync.py +++ b/openpype/modules/ftrack/lib/avalon_sync.py @@ -34,7 +34,7 @@ log = Logger.get_logger(__name__) # Current schemas for avalon types -EntitySchemas = { +CURRENT_DOC_SCHEMAS = { "project": "openpype:project-3.0", "asset": "openpype:asset-3.0", "config": "openpype:config-2.0" @@ -1862,7 +1862,7 @@ class SyncEntitiesFactory: item["_id"] = new_id item["parent"] = self.avalon_project_id - item["schema"] = EntitySchemas["asset"] + item["schema"] = CURRENT_DOC_SCHEMAS["asset"] item["data"]["visualParent"] = avalon_parent new_id_str = str(new_id) @@ -2003,8 +2003,8 @@ class SyncEntitiesFactory: project_item["_id"] = new_id project_item["parent"] = None - project_item["schema"] = EntitySchemas["project"] - project_item["config"]["schema"] = EntitySchemas["config"] + project_item["schema"] = CURRENT_DOC_SCHEMAS["project"] + project_item["config"]["schema"] = CURRENT_DOC_SCHEMAS["config"] self.ftrack_avalon_mapper[self.ft_project_id] = new_id self.avalon_ftrack_mapper[new_id] = self.ft_project_id diff --git a/openpype/modules/sync_server/providers/abstract_provider.py b/openpype/modules/sync_server/providers/abstract_provider.py index a60595ba93..2e9632134c 100644 --- a/openpype/modules/sync_server/providers/abstract_provider.py +++ b/openpype/modules/sync_server/providers/abstract_provider.py @@ -7,6 +7,8 @@ log = Logger().get_logger("SyncServer") @six.add_metaclass(abc.ABCMeta) class AbstractProvider: + CODE = '' + LABEL = '' def __init__(self, project_name, site_name, tree=None, presets=None): self.presets = None @@ -25,6 +27,17 @@ class AbstractProvider: (boolean) """ + @classmethod + @abc.abstractmethod + def get_configurable_items(cls): + """ + Returns filtered dict of editable properties + + + Returns: + (dict) + """ + @abc.abstractmethod def upload_file(self, source_path, path, server, collection, file, representation, site, diff --git a/openpype/modules/sync_server/providers/gdrive.py b/openpype/modules/sync_server/providers/gdrive.py index b67e5a6cfa..18d679b833 100644 --- a/openpype/modules/sync_server/providers/gdrive.py +++ b/openpype/modules/sync_server/providers/gdrive.py @@ -1,22 +1,33 @@ from __future__ import print_function import os.path -from googleapiclient.discovery import build -import google.oauth2.service_account as service_account -from googleapiclient import errors -from .abstract_provider import AbstractProvider -from googleapiclient.http import MediaFileUpload, MediaIoBaseDownload +import time +import sys +import six +import platform + from openpype.api import Logger from openpype.api import get_system_settings -from ..utils import time_function, ResumableError -import time +from .abstract_provider import AbstractProvider +from ..utils import time_function, ResumableError, EditableScopes +log = Logger().get_logger("SyncServer") + +try: + from googleapiclient.discovery import build + import google.oauth2.service_account as service_account + from googleapiclient import errors + from googleapiclient.http import MediaFileUpload, MediaIoBaseDownload +except (ImportError, SyntaxError): + if six.PY3: + six.reraise(*sys.exc_info()) + + # handle imports from Python 2 hosts - in those only basic methods are used + log.warning("Import failed, imported from Python 2, operations will fail.") SCOPES = ['https://www.googleapis.com/auth/drive.metadata.readonly', 'https://www.googleapis.com/auth/drive.file', 'https://www.googleapis.com/auth/drive.readonly'] # for write|delete -log = Logger().get_logger("SyncServer") - class GDriveHandler(AbstractProvider): """ @@ -42,15 +53,20 @@ class GDriveHandler(AbstractProvider): } } """ + CODE = 'gdrive' + LABEL = 'Google Drive' + FOLDER_STR = 'application/vnd.google-apps.folder' MY_DRIVE_STR = 'My Drive' # name of root folder of regular Google drive - CHUNK_SIZE = 2097152 # must be divisible by 256! + CHUNK_SIZE = 2097152 # must be divisible by 256! used for upload chunks def __init__(self, project_name, site_name, tree=None, presets=None): self.presets = None self.active = False self.project_name = project_name self.site_name = site_name + self.service = None + self.root = None self.presets = presets if not self.presets: @@ -58,18 +74,15 @@ class GDriveHandler(AbstractProvider): format(site_name)) return - if not os.path.exists(self.presets["credentials_url"]): - log.info("Sync Server: No credentials for Gdrive provider! ") + cred_path = self.presets.get("credentials_url", {}).\ + get(platform.system().lower()) or '' + if not os.path.exists(cred_path): + msg = "Sync Server: No credentials for gdrive provider " + \ + "for '{}' on path '{}'!".format(site_name, cred_path) + log.info(msg) return - self.service = self._get_gd_service() - try: - self.root = self._prepare_root_info() - except errors.HttpError: - log.warning("HttpError in sync loop, " - "trying next loop", - exc_info=True) - raise ResumableError + self.service = self._get_gd_service(cred_path) self._tree = tree self.active = True @@ -80,7 +93,34 @@ class GDriveHandler(AbstractProvider): Returns: (boolean) """ - return self.active + return self.service is not None + + @classmethod + def get_configurable_items(cls): + """ + Returns filtered dict of editable properties. + + + Returns: + (dict) + """ + # {platform} tells that value is multiplatform and only specific OS + # should be returned + editable = { + # credentials could be override on Project or User level + 'credentials_url': { + 'scope': [EditableScopes.PROJECT, + EditableScopes.LOCAL], + 'label': "Credentials url", + 'type': 'text', + 'namespace': '{project_settings}/global/sync_server/sites/{site}/credentials_url/{platform}' # noqa: E501 + }, + # roots could be override only on Project leve, User cannot + 'root': {'scope': [EditableScopes.PROJECT], + 'label': "Roots", + 'type': 'dict'} + } + return editable def get_roots_config(self, anatomy=None): """ @@ -537,7 +577,7 @@ class GDriveHandler(AbstractProvider): return return provider_presets - def _get_gd_service(self): + def _get_gd_service(self, credentials_path): """ Authorize client with 'credentials.json', uses service account. Service account needs to have target folder shared with. @@ -546,11 +586,18 @@ class GDriveHandler(AbstractProvider): Returns: None """ - creds = service_account.Credentials.from_service_account_file( - self.presets["credentials_url"], - scopes=SCOPES) - service = build('drive', 'v3', - credentials=creds, cache_discovery=False) + service = None + try: + creds = service_account.Credentials.from_service_account_file( + credentials_path, + scopes=SCOPES) + service = build('drive', 'v3', + credentials=creds, cache_discovery=False) + except Exception: + log.error("Connection failed, " + + "check '{}' credentials file".format(credentials_path), + exc_info=True) + return service def _prepare_root_info(self): @@ -562,39 +609,47 @@ class GDriveHandler(AbstractProvider): Returns: (dicts) of dicts where root folders are keys + throws ResumableError in case of errors.HttpError """ roots = {} config_roots = self.get_roots_config() - for path in config_roots.values(): - if self.MY_DRIVE_STR in path: - roots[self.MY_DRIVE_STR] = self.service.files()\ - .get(fileId='root').execute() - else: - shared_drives = [] - page_token = None + try: + for path in config_roots.values(): + if self.MY_DRIVE_STR in path: + roots[self.MY_DRIVE_STR] = self.service.files()\ + .get(fileId='root')\ + .execute() + else: + shared_drives = [] + page_token = None - while True: - response = self.service.drives().list( - pageSize=100, - pageToken=page_token).execute() - shared_drives.extend(response.get('drives', [])) - page_token = response.get('nextPageToken', None) - if page_token is None: - break + while True: + response = self.service.drives().list( + pageSize=100, + pageToken=page_token).execute() + shared_drives.extend(response.get('drives', [])) + page_token = response.get('nextPageToken', None) + if page_token is None: + break - folders = path.split('/') - if len(folders) < 2: - raise ValueError("Wrong root folder definition {}". - format(path)) + folders = path.split('/') + if len(folders) < 2: + raise ValueError("Wrong root folder definition {}". + format(path)) - for shared_drive in shared_drives: - if folders[1] in shared_drive["name"]: - roots[shared_drive["name"]] = { - "name": shared_drive["name"], - "id": shared_drive["id"]} - if self.MY_DRIVE_STR not in roots: # add My Drive always - roots[self.MY_DRIVE_STR] = self.service.files() \ - .get(fileId='root').execute() + for shared_drive in shared_drives: + if folders[1] in shared_drive["name"]: + roots[shared_drive["name"]] = { + "name": shared_drive["name"], + "id": shared_drive["id"]} + if self.MY_DRIVE_STR not in roots: # add My Drive always + roots[self.MY_DRIVE_STR] = self.service.files() \ + .get(fileId='root').execute() + except errors.HttpError: + log.warning("HttpError in sync loop, " + "trying next loop", + exc_info=True) + raise ResumableError return roots @@ -615,6 +670,9 @@ class GDriveHandler(AbstractProvider): (dictionary) path as a key, folder id as a value """ log.debug("build_tree len {}".format(len(folders))) + if not self.root: # build only when necessary, could be expensive + self.root = self._prepare_root_info() + root_ids = [] default_root_id = None tree = {} diff --git a/openpype/modules/sync_server/providers/lib.py b/openpype/modules/sync_server/providers/lib.py index 01a5d50ba5..816ccca981 100644 --- a/openpype/modules/sync_server/providers/lib.py +++ b/openpype/modules/sync_server/providers/lib.py @@ -65,6 +65,17 @@ class ProviderFactory: info = self._get_creator_info(provider) return info[1] + def get_provider_configurable_items(self, provider): + """ + Returns dict of modifiable properties for 'provider'. + + Provider contains information which its properties and on what + level could be override + """ + provider_info = self._get_creator_info(provider) + + return provider_info[0].get_configurable_items() + def _get_creator_info(self, provider): """ Collect all necessary info for provider. Currently only creator @@ -91,5 +102,5 @@ factory = ProviderFactory() # there is implementing 'GDriveHandler' class # 7 denotes number of files that could be synced in single loop - learned by # trial and error -factory.register_provider('gdrive', GDriveHandler, 7) -factory.register_provider('local_drive', LocalDriveHandler, 50) +factory.register_provider(GDriveHandler.CODE, GDriveHandler, 7) +factory.register_provider(LocalDriveHandler.CODE, LocalDriveHandler, 50) diff --git a/openpype/modules/sync_server/providers/local_drive.py b/openpype/modules/sync_server/providers/local_drive.py index 1f4fca80eb..4b80ed44f2 100644 --- a/openpype/modules/sync_server/providers/local_drive.py +++ b/openpype/modules/sync_server/providers/local_drive.py @@ -7,22 +7,43 @@ import time from openpype.api import Logger, Anatomy from .abstract_provider import AbstractProvider +from ..utils import EditableScopes + log = Logger().get_logger("SyncServer") class LocalDriveHandler(AbstractProvider): + CODE = 'local_drive' + LABEL = 'Local drive' + """ Handles required operations on mounted disks with OS """ def __init__(self, project_name, site_name, tree=None, presets=None): self.presets = None self.active = False self.project_name = project_name self.site_name = site_name + self._editable_properties = {} self.active = self.is_active() def is_active(self): return True + @classmethod + def get_configurable_items(cls): + """ + Returns filtered dict of editable properties + + Returns: + (dict) + """ + editable = { + 'root': {'scope': [EditableScopes.LOCAL], + 'label': "Roots", + 'type': 'dict'} + } + return editable + def upload_file(self, source_path, target_path, server, collection, file, representation, site, overwrite=False, direction="Upload"): @@ -149,7 +170,10 @@ class LocalDriveHandler(AbstractProvider): site=site, progress=status_val ) - target_file_size = os.path.getsize(target_path) + try: + target_file_size = os.path.getsize(target_path) + except FileNotFoundError: + pass time.sleep(0.5) def _normalize_site_name(self, site_name): diff --git a/openpype/modules/sync_server/sync_server.py b/openpype/modules/sync_server/sync_server.py index 9b305a1b2e..638a4a367f 100644 --- a/openpype/modules/sync_server/sync_server.py +++ b/openpype/modules/sync_server/sync_server.py @@ -206,14 +206,14 @@ def _get_configured_sites_from_setting(module, project_name, project_setting): all_sites = module._get_default_site_configs() all_sites.update(project_setting.get("sites")) for site_name, config in all_sites.items(): - handler = initiated_handlers. \ - get((config["provider"], site_name)) + provider = module.get_provider_for_site(site=site_name) + handler = initiated_handlers.get((provider, site_name)) if not handler: - handler = lib.factory.get_provider(config["provider"], + handler = lib.factory.get_provider(provider, project_name, site_name, presets=config) - initiated_handlers[(config["provider"], site_name)] = \ + initiated_handlers[(provider, site_name)] = \ handler if handler.is_active(): @@ -274,6 +274,9 @@ class SyncServerThread(threading.Thread): self.module.set_sync_project_settings() # clean cache for collection, preset in self.module.sync_project_settings.\ items(): + if collection not in self.module.get_enabled_projects(): + continue + start_time = time.time() local_site, remote_site = self._working_sites(collection) if not all([local_site, remote_site]): @@ -295,13 +298,14 @@ class SyncServerThread(threading.Thread): processed_file_path = set() site_preset = preset.get('sites')[remote_site] - remote_provider = site_preset['provider'] + remote_provider = \ + self.module.get_provider_for_site(site=remote_site) handler = lib.factory.get_provider(remote_provider, collection, remote_site, presets=site_preset) limit = lib.factory.get_provider_batch_limit( - site_preset['provider']) + remote_provider) # first call to get_provider could be expensive, its # building folder tree structure in memory # call only if needed, eg. DO_UPLOAD or DO_DOWNLOAD @@ -451,8 +455,9 @@ class SyncServerThread(threading.Thread): remote_site)) return None, None - if not all([site_is_working(self.module, collection, local_site), - site_is_working(self.module, collection, remote_site)]): + configured_sites = _get_configured_sites(self.module, collection) + if not all([local_site in configured_sites, + remote_site in configured_sites]): log.debug("Some of the sites {} - {} is not ".format(local_site, remote_site) + "working properly") diff --git a/openpype/modules/sync_server/sync_server_module.py b/openpype/modules/sync_server/sync_server_module.py index b50bf19dca..15de4b12e9 100644 --- a/openpype/modules/sync_server/sync_server_module.py +++ b/openpype/modules/sync_server/sync_server_module.py @@ -2,6 +2,8 @@ import os from bson.objectid import ObjectId from datetime import datetime import threading +import platform +import copy from avalon.api import AvalonMongoDB @@ -9,12 +11,18 @@ from .. import PypeModule, ITrayModule from openpype.api import ( Anatomy, get_project_settings, + get_system_settings, get_local_site_id) from openpype.lib import PypeLogger +from openpype.settings.lib import ( + get_default_project_settings, + get_default_anatomy_settings, + get_anatomy_settings) from .providers.local_drive import LocalDriveHandler +from .providers import lib -from .utils import time_function, SyncStatus +from .utils import time_function, SyncStatus, EditableScopes log = PypeLogger().get_logger("SyncServer") @@ -340,18 +348,6 @@ class SyncServerModule(PypeModule, ITrayModule): return self._get_enabled_sites_from_settings(sync_settings) - def get_configurable_items_for_site(self, project_name, site_name): - """ - Returns list of items that should be configurable by User - - Returns: - (list of dict) - [{key:"root", label:"root", value:"valueFromSettings"}] - """ - # if project_name is None: ..for get_default_project_settings - # return handler.get_configurable_items() - pass - def get_active_site(self, project_name): """ Returns active (mine) site for 'project_name' from settings @@ -402,6 +398,205 @@ class SyncServerModule(PypeModule, ITrayModule): return remote_site + def get_local_settings_schema(self): + """Wrapper for Local settings - all projects incl. Default""" + return self.get_configurable_items(EditableScopes.LOCAL) + + def get_configurable_items(self, scope=None): + """ + Returns list of sites that could be configurable for all projects. + + Could be filtered by 'scope' argument (list) + + Args: + scope (list of utils.EditableScope) + + Returns: + (dict of list of dict) + { + siteA : [ + { + key:"root", label:"root", + "value":"{'work': 'c:/projects'}", + "type": "dict", + "children":[ + { "key": "work", + "type": "text", + "value": "c:/projects"} + ] + }, + { + key:"credentials_url", label:"Credentials url", + "value":"'c:/projects/cred.json'", "type": "text", + "namespace": "{project_setting}/global/sync_server/ + sites" + } + ] + } + """ + editable = {} + applicable_projects = list(self.connection.projects()) + applicable_projects.append(None) + for project in applicable_projects: + project_name = None + if project: + project_name = project["name"] + + items = self.get_configurable_items_for_project(project_name, + scope) + editable.update(items) + + return editable + + def get_local_settings_schema_for_project(self, project_name): + """Wrapper for Local settings - for specific 'project_name'""" + return self.get_configurable_items_for_project(project_name, + EditableScopes.LOCAL) + + def get_configurable_items_for_project(self, project_name=None, + scope=None): + """ + Returns list of items that could be configurable for specific + 'project_name' + + Args: + project_name (str) - None > default project, + scope (list of utils.EditableScope) + (optional, None is all scopes, default is LOCAL) + + Returns: + (dict of list of dict) + { + siteA : [ + { + key:"root", label:"root", + "type": "dict", + "children":[ + { "key": "work", + "type": "text", + "value": "c:/projects"} + ] + }, + { + key:"credentials_url", label:"Credentials url", + "value":"'c:/projects/cred.json'", "type": "text", + "namespace": "{project_setting}/global/sync_server/ + sites" + } + ] + } + """ + allowed_sites = set() + sites = self.get_all_site_configs(project_name) + if project_name: + # Local Settings can select only from allowed sites for project + allowed_sites.update(set(self.get_active_sites(project_name))) + allowed_sites.update(set(self.get_remote_sites(project_name))) + + editable = {} + for site_name in sites.keys(): + if allowed_sites and site_name not in allowed_sites: + continue + + items = self.get_configurable_items_for_site(project_name, + site_name, + scope) + # Local Settings need 'local' instead of real value + site_name = site_name.replace(get_local_site_id(), 'local') + editable[site_name] = items + + return editable + + def get_local_settings_schema_for_site(self, project_name, site_name): + """Wrapper for Local settings - for particular 'site_name and proj.""" + return self.get_configurable_items_for_site(project_name, + site_name, + EditableScopes.LOCAL) + + def get_configurable_items_for_site(self, project_name=None, + site_name=None, + scope=None): + """ + Returns list of items that could be configurable. + + Args: + project_name (str) - None > default project + site_name (str) + scope (list of utils.EditableScope) + (optional, None is all scopes) + + Returns: + (list) + [ + { + key:"root", label:"root", type:"dict", + "children":[ + { "key": "work", + "type": "text", + "value": "c:/projects"} + ] + }, ... + ] + """ + provider_name = self.get_provider_for_site(site=site_name) + items = lib.factory.get_provider_configurable_items(provider_name) + + if project_name: + sync_s = self.get_sync_project_setting(project_name, + exclude_locals=True, + cached=False) + else: + sync_s = get_default_project_settings(exclude_locals=True) + sync_s = sync_s["global"]["sync_server"] + sync_s["sites"].update( + self._get_default_site_configs(self.enabled)) + + editable = [] + if type(scope) is not list: + scope = [scope] + scope = set(scope) + for key, properties in items.items(): + if scope is None or scope.intersection(set(properties["scope"])): + val = sync_s.get("sites", {}).get(site_name, {}).get(key) + + item = { + "key": key, + "label": properties["label"], + "type": properties["type"] + } + + if properties.get("namespace"): + item["namespace"] = properties.get("namespace") + if "platform" in item["namespace"]: + try: + if val: + val = val[platform.system().lower()] + except KeyError: + st = "{}'s field value {} should be".format(key, val) # noqa: E501 + log.error(st + " multiplatform dict") + + item["namespace"] = item["namespace"].replace('{site}', + site_name) + children = [] + if properties["type"] == "dict": + if val: + for val_key, val_val in val.items(): + child = { + "type": "text", + "key": val_key, + "value": val_val + } + children.append(child) + + if properties["type"] == "dict": + item["children"] = children + else: + item["value"] = val + + editable.append(item) + + return editable + def reset_timer(self): """ Called when waiting for next loop should be skipped. @@ -418,7 +613,7 @@ class SyncServerModule(PypeModule, ITrayModule): for project in self.connection.projects(): project_name = project["name"] project_settings = self.get_sync_project_setting(project_name) - if project_settings: + if project_settings and project_settings.get("enabled"): enabled_projects.append(project_name) return enabled_projects @@ -570,75 +765,145 @@ class SyncServerModule(PypeModule, ITrayModule): return self._sync_project_settings - def set_sync_project_settings(self): + def set_sync_project_settings(self, exclude_locals=False): """ Set sync_project_settings for all projects (caching) - + Args: + exclude_locals (bool): ignore overrides from Local Settings For performance """ - sync_project_settings = {} - - for collection in self.connection.database.collection_names(False): - sync_settings = self._parse_sync_settings_from_settings( - get_project_settings(collection)) - if sync_settings: - default_sites = self._get_default_site_configs() - sync_settings['sites'].update(default_sites) - sync_project_settings[collection] = sync_settings - - if not sync_project_settings: - log.info("No enabled and configured projects for sync.") + sync_project_settings = self._prepare_sync_project_settings( + exclude_locals) self._sync_project_settings = sync_project_settings - def get_sync_project_setting(self, project_name): + def _prepare_sync_project_settings(self, exclude_locals): + sync_project_settings = {} + system_sites = self.get_all_site_configs() + for collection in self.connection.database.collection_names(False): + sites = copy.deepcopy(system_sites) # get all configured sites + proj_settings = self._parse_sync_settings_from_settings( + get_project_settings(collection, + exclude_locals=exclude_locals)) + sites.update(self._get_default_site_configs( + proj_settings["enabled"], collection)) + sites.update(proj_settings['sites']) + proj_settings["sites"] = sites + + sync_project_settings[collection] = proj_settings + if not sync_project_settings: + log.info("No enabled and configured projects for sync.") + return sync_project_settings + + def get_sync_project_setting(self, project_name, exclude_locals=False, + cached=True): """ Handles pulling sync_server's settings for enabled 'project_name' Args: project_name (str): used in project settings + exclude_locals (bool): ignore overrides from Local Settings + cached (bool): use pre-cached values, or return fresh ones + cached values needed for single loop (with all overrides) + fresh values needed for Local settings (without overrides) Returns: (dict): settings dictionary for the enabled project, empty if no settings or sync is disabled """ # presets set already, do not call again and again # self.log.debug("project preset {}".format(self.presets)) - if self.sync_project_settings and \ - self.sync_project_settings.get(project_name): - return self.sync_project_settings.get(project_name) + if not cached: + return self._prepare_sync_project_settings(exclude_locals)\ + [project_name] - settings = get_project_settings(project_name) - return self._parse_sync_settings_from_settings(settings) + if not self.sync_project_settings or \ + not self.sync_project_settings.get(project_name): + self.set_sync_project_settings(exclude_locals) + + return self.sync_project_settings.get(project_name) def _parse_sync_settings_from_settings(self, settings): """ settings from api.get_project_settings, TOOD rename """ sync_settings = settings.get("global").get("sync_server") - if not sync_settings: - log.info("No project setting not syncing.") - return {} - if sync_settings.get("enabled"): - return sync_settings - return {} + return sync_settings - def _get_default_site_configs(self): + def get_all_site_configs(self, project_name=None): """ - Returns skeleton settings for 'studio' and user's local site + Returns (dict) with all sites configured system wide. + + Args: + project_name (str)(optional): if present, check if not disabled + + Returns: + (dict): {'studio': {'provider':'local_drive'...}, + 'MY_LOCAL': {'provider':....}} """ - default_config = {'provider': 'local_drive'} - all_sites = {self.DEFAULT_SITE: default_config, - get_local_site_id(): default_config} + sys_sett = get_system_settings() + sync_sett = sys_sett["modules"].get("sync_server") + + project_enabled = True + if project_name: + project_enabled = project_name in self.get_enabled_projects() + sync_enabled = sync_sett["enabled"] and project_enabled + + system_sites = {} + if sync_enabled: + for site, detail in sync_sett.get("sites", {}).items(): + system_sites[site] = detail + + system_sites.update(self._get_default_site_configs(sync_enabled, + project_name)) + + return system_sites + + def _get_default_site_configs(self, sync_enabled=True, project_name=None): + """ + Returns settings for 'studio' and user's local site + + Returns base values from setting, not overriden by Local Settings, + eg. value used to push TO LS not to get actual value for syncing. + """ + if not project_name: + anatomy_sett = get_default_anatomy_settings(exclude_locals=True) + else: + anatomy_sett = get_anatomy_settings(project_name, + exclude_locals=True) + roots = {} + for root, config in anatomy_sett["roots"].items(): + roots[root] = config[platform.system().lower()] + studio_config = { + 'provider': 'local_drive', + "root": roots + } + all_sites = {self.DEFAULT_SITE: studio_config} + if sync_enabled: + all_sites[get_local_site_id()] = {'provider': 'local_drive'} return all_sites - def get_provider_for_site(self, project_name, site): + def get_provider_for_site(self, project_name=None, site=None): """ - Return provider name for site. + Return provider name for site (unique name across all projects. """ - site_preset = self.get_sync_project_setting(project_name)["sites"].\ - get(site) - if site_preset: - return site_preset["provider"] + sites = {self.DEFAULT_SITE: "local_drive", + self.LOCAL_SITE: "local_drive", + get_local_site_id(): "local_drive"} - return "NA" + if site in sites.keys(): + return sites[site] + + if project_name: # backward compatibility + proj_settings = self.get_sync_project_setting(project_name) + provider = proj_settings.get("sites", {}).get(site, {}).\ + get("provider") + if provider: + return provider + + sys_sett = get_system_settings() + sync_sett = sys_sett["modules"].get("sync_server") + for site, detail in sync_sett.get("sites", {}).items(): + sites[site] = detail.get("provider") + + return sites.get(site, 'N/A') @time_function def get_sync_representations(self, collection, active_site, remote_site): @@ -757,6 +1022,15 @@ class SyncServerModule(PypeModule, ITrayModule): Always is comparing local record, eg. site with 'name' == self.presets[PROJECT_NAME]['config']["active_site"] + This leads to trigger actual upload or download, there is + a use case 'studio' <> 'remote' where user should publish + to 'studio', see progress in Tray GUI, but do not do + physical upload/download + (as multiple user would be doing that). + + Do physical U/D only when any of the sites is user's local, in that + case only user has the data and must U/D. + Args: file (dictionary): of file from representation in Mongo local_site (string): - local side of compare (usually 'studio') @@ -766,8 +1040,12 @@ class SyncServerModule(PypeModule, ITrayModule): (string) - one of SyncStatus """ sites = file.get("sites") or [] - # if isinstance(sites, list): # temporary, old format of 'sites' - # return SyncStatus.DO_NOTHING + + if get_local_site_id() not in (local_site, remote_site): + # don't do upload/download for studio sites + log.debug("No local site {} - {}".format(local_site, remote_site)) + return SyncStatus.DO_NOTHING + _, remote_rec = self._get_site_rec(sites, remote_site) or {} if remote_rec: # sync remote target created_dt = remote_rec.get("created_dt") @@ -1116,7 +1394,7 @@ class SyncServerModule(PypeModule, ITrayModule): format(site_name)) return - provider_name = self.get_provider_for_site(collection, site_name) + provider_name = self.get_provider_for_site(site=site_name) if provider_name == 'local_drive': query = { diff --git a/openpype/modules/sync_server/tray/lib.py b/openpype/modules/sync_server/tray/lib.py index c1f8eaf629..25c600abd2 100644 --- a/openpype/modules/sync_server/tray/lib.py +++ b/openpype/modules/sync_server/tray/lib.py @@ -158,7 +158,7 @@ def translate_provider_for_icon(sync_server, project, site): """ if site == sync_server.DEFAULT_SITE: return sync_server.DEFAULT_SITE - return sync_server.get_provider_for_site(project, site) + return sync_server.get_provider_for_site(site=site) def get_item_by_id(model, object_id): diff --git a/openpype/modules/sync_server/tray/widgets.py b/openpype/modules/sync_server/tray/widgets.py index e80f91e09f..eae912206e 100644 --- a/openpype/modules/sync_server/tray/widgets.py +++ b/openpype/modules/sync_server/tray/widgets.py @@ -236,7 +236,7 @@ class _SyncRepresentationWidget(QtWidgets.QWidget): for site, progress in {active_site: local_progress, remote_site: remote_progress}.items(): - provider = self.sync_server.get_provider_for_site(project, site) + provider = self.sync_server.get_provider_for_site(site=site) if provider == 'local_drive': if 'studio' in site: txt = " studio version" diff --git a/openpype/modules/sync_server/utils.py b/openpype/modules/sync_server/utils.py index fa6e63b029..d4fc29ff8a 100644 --- a/openpype/modules/sync_server/utils.py +++ b/openpype/modules/sync_server/utils.py @@ -33,3 +33,9 @@ def time_function(method): return result return timed + + +class EditableScopes: + SYSTEM = 0 + PROJECT = 1 + LOCAL = 2 diff --git a/openpype/plugins/publish/extract_review.py b/openpype/plugins/publish/extract_review.py index f341ba197f..048d16fabb 100644 --- a/openpype/plugins/publish/extract_review.py +++ b/openpype/plugins/publish/extract_review.py @@ -55,7 +55,7 @@ class ExtractReview(pyblish.api.InstancePlugin): profiles = None def process(self, instance): - self.log.debug(instance.data["representations"]) + self.log.debug(str(instance.data["representations"])) # Skip review when requested. if not instance.data.get("review", True): return diff --git a/openpype/pype_commands.py b/openpype/pype_commands.py index 981cca82dc..326ca8349a 100644 --- a/openpype/pype_commands.py +++ b/openpype/pype_commands.py @@ -110,6 +110,12 @@ class PypeCommands: with open(output_json_path, "w") as file_stream: json.dump(env, file_stream, indent=4) + @staticmethod + def launch_project_manager(): + from openpype.tools import project_manager + + project_manager.main() + def texture_copy(self, project, asset, path): pass diff --git a/openpype/settings/defaults/project_settings/aftereffects.json b/openpype/settings/defaults/project_settings/aftereffects.json new file mode 100644 index 0000000000..f54dbb9612 --- /dev/null +++ b/openpype/settings/defaults/project_settings/aftereffects.json @@ -0,0 +1,18 @@ +{ + "publish": { + "ValidateSceneSettings": { + "enabled": true, + "optional": true, + "active": true, + "skip_resolution_check": [".*"], + "skip_timelines_check": [".*"] + }, + "AfterEffectsSubmitDeadline": { + "use_published": true, + "priority": 50, + "primary_pool": "", + "secondary_pool": "", + "chunk_size": 1000000 + } + } +} \ No newline at end of file diff --git a/openpype/settings/defaults/project_settings/ftrack.json b/openpype/settings/defaults/project_settings/ftrack.json index 8970aa8ac8..b964ce07c3 100644 --- a/openpype/settings/defaults/project_settings/ftrack.json +++ b/openpype/settings/defaults/project_settings/ftrack.json @@ -1,7 +1,6 @@ { "events": { "sync_to_avalon": { - "enabled": true, "statuses_name_change": [ "ready", "not ready" diff --git a/openpype/settings/defaults/project_settings/global.json b/openpype/settings/defaults/project_settings/global.json index 48b7a24b0d..1f54bed03c 100644 --- a/openpype/settings/defaults/project_settings/global.json +++ b/openpype/settings/defaults/project_settings/global.json @@ -267,13 +267,6 @@ "remote_site": "studio" }, "sites": { - "gdrive": { - "provider": "gdrive", - "credentials_url": "", - "root": { - "work": "" - } - } } }, "project_plugins": { diff --git a/openpype/settings/defaults/project_settings/harmony.json b/openpype/settings/defaults/project_settings/harmony.json index f5f084dd44..0c7a35c058 100644 --- a/openpype/settings/defaults/project_settings/harmony.json +++ b/openpype/settings/defaults/project_settings/harmony.json @@ -1,14 +1,18 @@ { - "general": { - "skip_resolution_check": [], - "skip_timelines_check": [] - }, "publish": { "CollectPalettes": { "allowed_tasks": [ - "." + ".*" ] }, + "ValidateSceneSettings": { + "enabled": true, + "optional": true, + "active": true, + "frame_check_filter": [], + "skip_resolution_check": [], + "skip_timelines_check": [] + }, "HarmonySubmitDeadline": { "use_published": false, "priority": 50, diff --git a/openpype/settings/defaults/project_settings/maya.json b/openpype/settings/defaults/project_settings/maya.json index 8600e49518..779b8bb3f3 100644 --- a/openpype/settings/defaults/project_settings/maya.json +++ b/openpype/settings/defaults/project_settings/maya.json @@ -135,6 +135,12 @@ "enabled": false, "attributes": {} }, + "ValidateRenderSettings": { + "arnold_render_attributes": [], + "vray_render_attributes": [], + "redshift_render_attributes": [], + "renderman_render_attributes": [] + }, "ValidateModelName": { "enabled": false, "material_file": { diff --git a/openpype/settings/defaults/system_settings/applications.json b/openpype/settings/defaults/system_settings/applications.json index 63d6da4633..020924db67 100644 --- a/openpype/settings/defaults/system_settings/applications.json +++ b/openpype/settings/defaults/system_settings/applications.json @@ -5,18 +5,11 @@ "icon": "{}/app_icons/maya.png", "host_name": "maya", "environment": { - "PYTHONPATH": [ - "{OPENPYPE_REPOS_ROOT}/openpype/hosts/maya/startup", - "{OPENPYPE_REPOS_ROOT}/repos/avalon-core/setup/maya", - "{OPENPYPE_REPOS_ROOT}/repos/maya-look-assigner", - "{PYTHONPATH}" - ], "MAYA_DISABLE_CLIC_IPM": "Yes", "MAYA_DISABLE_CIP": "Yes", "MAYA_DISABLE_CER": "Yes", "PYMEL_SKIP_MEL_INIT": "Yes", - "LC_ALL": "C", - "OPENPYPE_LOG_NO_COLORS": "Yes" + "LC_ALL": "C" }, "variants": { "2022": { @@ -110,15 +103,7 @@ "icon": "{}/app_icons/nuke.png", "host_name": "nuke", "environment": { - "NUKE_PATH": [ - "{OPENPYPE_REPOS_ROOT}/repos/avalon-core/setup/nuke/nuke_path", - "{OPENPYPE_REPOS_ROOT}/openpype/hosts/nuke/startup", - "{OPENPYPE_STUDIO_PLUGINS}/nuke" - ], - "PATH": { - "windows": "C:/Program Files (x86)/QuickTime/QTSystem/;{PATH}" - }, - "LOGLEVEL": "DEBUG" + "NUKE_PATH": "{OPENPYPE_STUDIO_PLUGINS}/nuke" }, "variants": { "13-0": { @@ -224,15 +209,7 @@ "icon": "{}/app_icons/nuke.png", "host_name": "nuke", "environment": { - "NUKE_PATH": [ - "{OPENPYPE_REPOS_ROOT}/repos/avalon-core/setup/nuke/nuke_path", - "{OPENPYPE_REPOS_ROOT}/openpype/hosts/nuke/startup", - "{OPENPYPE_STUDIO_PLUGINS}/nuke" - ], - "PATH": { - "windows": "C:/Program Files (x86)/QuickTime/QTSystem/;{PATH}" - }, - "LOGLEVEL": "DEBUG" + "NUKE_PATH": "{OPENPYPE_STUDIO_PLUGINS}/nuke" }, "variants": { "13-0": { @@ -368,15 +345,8 @@ "icon": "{}/app_icons/nuke.png", "host_name": "hiero", "environment": { - "HIERO_PLUGIN_PATH": [ - "{OPENPYPE_REPOS_ROOT}/openpype/hosts/hiero/startup" - ], - "PATH": { - "windows": "C:/Program Files (x86)/QuickTime/QTSystem/;{PATH}" - }, "WORKFILES_STARTUP": "0", - "TAG_ASSETBUILD_STARTUP": "0", - "LOGLEVEL": "DEBUG" + "TAG_ASSETBUILD_STARTUP": "0" }, "variants": { "13-0": { @@ -510,15 +480,8 @@ "icon": "{}/app_icons/hiero.png", "host_name": "hiero", "environment": { - "HIERO_PLUGIN_PATH": [ - "{OPENPYPE_REPOS_ROOT}/openpype/hosts/hiero/startup" - ], - "PATH": { - "windows": "C:/Program Files (x86)/QuickTime/QTSystem/;{PATH}" - }, "WORKFILES_STARTUP": "0", - "TAG_ASSETBUILD_STARTUP": "0", - "LOGLEVEL": "DEBUG" + "TAG_ASSETBUILD_STARTUP": "0" }, "variants": { "13-0": { @@ -783,18 +746,7 @@ "label": "Houdini", "icon": "{}/app_icons/houdini.png", "host_name": "houdini", - "environment": { - "HOUDINI_PATH": { - "darwin": "{OPENPYPE_REPOS_ROOT}/openpype/hosts/houdini/startup:&", - "linux": "{OPENPYPE_REPOS_ROOT}/openpype/hosts/houdini/startup:&", - "windows": "{OPENPYPE_REPOS_ROOT}/openpype/hosts/houdini/startup;&" - }, - "HOUDINI_MENU_PATH": { - "darwin": "{OPENPYPE_REPOS_ROOT}/openpype/hosts/houdini/startup:&", - "linux": "{OPENPYPE_REPOS_ROOT}/openpype/hosts/houdini/startup:&", - "windows": "{OPENPYPE_REPOS_ROOT}/openpype/hosts/houdini/startup;&" - } - }, + "environment": {}, "variants": { "18-5": { "use_python_2": true, @@ -852,14 +804,7 @@ "label": "Blender", "icon": "{}/app_icons/blender.png", "host_name": "blender", - "environment": { - "BLENDER_USER_SCRIPTS": "{OPENPYPE_REPOS_ROOT}/repos/avalon-core/setup/blender", - "PYTHONPATH": [ - "{OPENPYPE_REPOS_ROOT}/repos/avalon-core/setup/blender", - "{PYTHONPATH}" - ], - "QT_PREFERRED_BINDING": "PySide2" - }, + "environment": {}, "variants": { "2-83": { "use_python_2": false, @@ -940,8 +885,7 @@ "icon": "{}/app_icons/harmony.png", "host_name": "harmony", "environment": { - "AVALON_HARMONY_WORKFILES_ON_LAUNCH": "1", - "LIB_OPENHARMONY_PATH": "{OPENPYPE_REPOS_ROOT}/pype/vendor/OpenHarmony" + "AVALON_HARMONY_WORKFILES_ON_LAUNCH": "1" }, "variants": { "20": { @@ -985,9 +929,7 @@ "label": "TVPaint", "icon": "{}/app_icons/tvpaint.png", "host_name": "tvpaint", - "environment": { - "OPENPYPE_LOG_NO_COLORS": "True" - }, + "environment": {}, "variants": { "animation_11-64bits": { "use_python_2": false, @@ -1034,8 +976,6 @@ "host_name": "photoshop", "environment": { "AVALON_PHOTOSHOP_WORKFILES_ON_LAUNCH": "1", - "OPENPYPE_LOG_NO_COLORS": "Yes", - "WEBSOCKET_URL": "ws://localhost:8099/ws/", "WORKFILES_SAVE_AS": "Yes" }, "variants": { @@ -1084,8 +1024,6 @@ "host_name": "aftereffects", "environment": { "AVALON_AFTEREFFECTS_WORKFILES_ON_LAUNCH": "1", - "OPENPYPE_LOG_NO_COLORS": "Yes", - "WEBSOCKET_URL": "ws://localhost:8097/ws/", "WORKFILES_SAVE_AS": "Yes" }, "variants": { @@ -1159,10 +1097,7 @@ "label": "Unreal Editor", "icon": "{}/app_icons/ue4.png'", "host_name": "unreal", - "environment": { - "AVALON_UNREAL_PLUGIN": "{OPENPYPE_REPOS_ROOT}/repos/avalon-unreal-integration", - "OPENPYPE_LOG_NO_COLORS": "True" - }, + "environment": {}, "variants": { "4-26": { "use_python_2": false, diff --git a/openpype/settings/defaults/system_settings/modules.json b/openpype/settings/defaults/system_settings/modules.json index 6e4b493116..5c4aa6c485 100644 --- a/openpype/settings/defaults/system_settings/modules.json +++ b/openpype/settings/defaults/system_settings/modules.json @@ -135,7 +135,8 @@ "workspace_name": "" }, "sync_server": { - "enabled": false + "enabled": false, + "sites": {} }, "deadline": { "enabled": true, diff --git a/openpype/settings/entities/__init__.py b/openpype/settings/entities/__init__.py index f76a915225..2c71b622ee 100644 --- a/openpype/settings/entities/__init__.py +++ b/openpype/settings/entities/__init__.py @@ -101,7 +101,8 @@ from .enum_entity import ( BaseEnumEntity, EnumEntity, AppsEnumEntity, - ToolsEnumEntity + ToolsEnumEntity, + ProvidersEnum ) from .list_entity import ListEntity @@ -149,6 +150,7 @@ __all__ = ( "EnumEntity", "AppsEnumEntity", "ToolsEnumEntity", + "ProvidersEnum", "ListEntity", diff --git a/openpype/settings/entities/dict_mutable_keys_entity.py b/openpype/settings/entities/dict_mutable_keys_entity.py index 4839dbcdc2..907bf98784 100644 --- a/openpype/settings/entities/dict_mutable_keys_entity.py +++ b/openpype/settings/entities/dict_mutable_keys_entity.py @@ -434,8 +434,19 @@ class DictMutableKeysEntity(EndpointEntity): if using_values_from_state: if _settings_value is NOT_SET: initial_value = NOT_SET + + elif self.store_as_list: + new_initial_value = [] + for key, value in _settings_value: + if key in initial_value: + new_initial_value.append(key, initial_value.pop(key)) + + for key, value in initial_value.items(): + new_initial_value.append(key, value) + initial_value = new_initial_value else: initial_value = _settings_value + self.initial_value = initial_value def _convert_to_regex_valid_key(self, key): diff --git a/openpype/settings/entities/enum_entity.py b/openpype/settings/entities/enum_entity.py index 693305cb1e..c6021b68de 100644 --- a/openpype/settings/entities/enum_entity.py +++ b/openpype/settings/entities/enum_entity.py @@ -217,3 +217,41 @@ class ToolsEnumEntity(BaseEnumEntity): if key in self.valid_keys: new_value.append(key) self._current_value = new_value + + +class ProvidersEnum(BaseEnumEntity): + schema_types = ["providers-enum"] + + def _item_initalization(self): + self.multiselection = False + self.value_on_not_set = "" + self.enum_items = [] + self.valid_keys = set() + self.valid_value_types = (str, ) + self.placeholder = None + + def _get_enum_values(self): + from openpype.modules.sync_server.providers import lib as lib_providers + + providers = lib_providers.factory.providers + + valid_keys = set() + valid_keys.add('') + enum_items = [{'': 'Choose Provider'}] + for provider_code, provider_info in providers.items(): + provider, _ = provider_info + enum_items.append({provider_code: provider.LABEL}) + valid_keys.add(provider_code) + + return enum_items, valid_keys + + def set_override_state(self, *args, **kwargs): + super(ProvidersEnum, self).set_override_state(*args, **kwargs) + + self.enum_items, self.valid_keys = self._get_enum_values() + + value_on_not_set = list(self.valid_keys)[0] + if self._current_value is NOT_SET: + self._current_value = value_on_not_set + + self.value_on_not_set = value_on_not_set diff --git a/openpype/settings/entities/schemas/projects_schema/schema_main.json b/openpype/settings/entities/schemas/projects_schema/schema_main.json index 6bc158aa60..b4666b302a 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_main.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_main.json @@ -78,6 +78,10 @@ "type": "schema", "name": "schema_project_hiero" }, + { + "type": "schema", + "name": "schema_project_aftereffects" + }, { "type": "schema", "name": "schema_project_harmony" diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_aftereffects.json b/openpype/settings/entities/schemas/projects_schema/schema_project_aftereffects.json new file mode 100644 index 0000000000..63bf9274a3 --- /dev/null +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_aftereffects.json @@ -0,0 +1,90 @@ +{ + "type": "dict", + "collapsible": true, + "key": "aftereffects", + "label": "AfterEffects", + "is_file": true, + "children": [ + { + "type": "dict", + "collapsible": true, + "key": "publish", + "label": "Publish plugins", + "children": [ + { + "type": "dict", + "collapsible": true, + "key": "ValidateSceneSettings", + "label": "Validate Scene Settings", + "checkbox_key": "enabled", + "children": [ + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + }, + { + "type": "boolean", + "key": "optional", + "label": "Optional" + }, + { + "type": "boolean", + "key": "active", + "label": "Active" + }, + { + "type": "label", + "label": "Validate if FPS and Resolution match shot data" + }, + { + "type": "list", + "key": "skip_resolution_check", + "object_type": "text", + "label": "Skip Resolution Check for Tasks" + }, + { + "type": "list", + "key": "skip_timelines_check", + "object_type": "text", + "label": "Skip Timeline Check for Tasks" + } + ] + }, + { + "type": "dict", + "collapsible": true, + "key": "AfterEffectsSubmitDeadline", + "label": "AfterEffects Submit to Deadline", + "children": [ + { + "type": "boolean", + "key": "use_published", + "label": "Use Published scene" + }, + { + "type": "number", + "key": "priority", + "label": "Priority" + }, + { + "type": "text", + "key": "primary_pool", + "label": "Primary Pool" + }, + { + "type": "text", + "key": "secondary_pool", + "label": "Secondary Pool" + }, + { + "type": "number", + "key": "chunk_size", + "label": "Frames Per Task" + } + ] + } + ] + } + ] +} diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_ftrack.json b/openpype/settings/entities/schemas/projects_schema/schema_project_ftrack.json index a801175031..b1bb207578 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_ftrack.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_ftrack.json @@ -14,13 +14,7 @@ "type": "dict", "key": "sync_to_avalon", "label": "Sync to avalon", - "checkbox_key": "enabled", "children": [ - { - "type": "boolean", - "key": "enabled", - "label": "Enabled" - }, { "type": "label", "label": "Allow name and hierarchy change only if following statuses are on all children tasks" diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_harmony.json b/openpype/settings/entities/schemas/projects_schema/schema_project_harmony.json index c4cdccff42..8b5d638cd8 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_harmony.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_harmony.json @@ -5,26 +5,6 @@ "label": "Harmony", "is_file": true, "children": [ - { - "type": "dict", - "collapsible": true, - "key": "general", - "label": "General", - "children": [ - { - "type": "list", - "key": "skip_resolution_check", - "object_type": "text", - "label": "Skip Resolution Check for Tasks" - }, - { - "type": "list", - "key": "skip_timelines_check", - "object_type": "text", - "label": "Skip Timeliene Check for Tasks" - } - ] - }, { "type": "dict", "collapsible": true, @@ -45,6 +25,52 @@ } ] }, + { + "type": "dict", + "collapsible": true, + "key": "ValidateSceneSettings", + "label": "Validate Scene Settings", + "checkbox_key": "enabled", + "children": [ + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + }, + { + "type": "boolean", + "key": "optional", + "label": "Optional" + }, + { + "type": "boolean", + "key": "active", + "label": "Active" + }, + { + "type": "label", + "label": "Validate if FrameStart, FrameEnd and Resolution match shot data" + }, + { + "type": "list", + "key": "frame_check_filter", + "label": "Skip Frame check for Assets with", + "object_type": "text" + }, + { + "type": "list", + "key": "skip_resolution_check", + "object_type": "text", + "label": "Skip Resolution Check for Tasks" + }, + { + "type": "list", + "key": "skip_timelines_check", + "object_type": "text", + "label": "Skip Timeline Check for Tasks" + } + ] + }, { "type": "dict", "collapsible": true, @@ -59,7 +85,7 @@ { "type": "number", "key": "priority", - "label": "priority" + "label": "Priority" }, { "type": "text", @@ -74,7 +100,7 @@ { "type": "number", "key": "chunk_size", - "label": "Chunk Size" + "label": "Frames Per Task" } ] } diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_syncserver.json b/openpype/settings/entities/schemas/projects_schema/schema_project_syncserver.json index ea1b8fc9da..9428ce2db0 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_syncserver.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_syncserver.json @@ -50,14 +50,10 @@ "type": "dict", "children": [ { - "type": "text", - "key": "provider", - "label": "Provider" - }, - { - "type": "text", + "type": "path", "key": "credentials_url", - "label": "Credentials url" + "label": "Credentials url", + "multiplatform": true }, { "type": "dict-modifiable", diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_publish.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_publish.json index 95b02a7936..4cabf5bb74 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_publish.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_publish.json @@ -72,6 +72,56 @@ } ] }, + + { + "type": "dict", + "collapsible": true, + "key": "ValidateRenderSettings", + "label": "ValidateRenderSettings", + "children": [ + { + "type": "dict-modifiable", + "store_as_list": true, + "key": "arnold_render_attributes", + "label": "Arnold Render Attributes", + "use_label_wrap": true, + "object_type": { + "type": "text" + } + }, + { + "type": "dict-modifiable", + "store_as_list": true, + "key": "vray_render_attributes", + "label": "Vray Render Attributes", + "use_label_wrap": true, + "object_type": { + "type": "text" + } + }, + { + "type": "dict-modifiable", + "store_as_list": true, + "key": "redshift_render_attributes", + "label": "Redshift Render Attributes", + "use_label_wrap": true, + "object_type": { + "type": "text" + } + }, + { + "type": "dict-modifiable", + "store_as_list": true, + "key": "renderman_render_attributes", + "label": "Renderman Render Attributes", + "use_label_wrap": true, + "object_type": { + "type": "text" + } + } + ] + }, + { "type": "collapsible-wrap", "label": "Model", diff --git a/openpype/settings/entities/schemas/system_schema/schema_modules.json b/openpype/settings/entities/schemas/system_schema/schema_modules.json index 878958b12d..d1b498bb86 100644 --- a/openpype/settings/entities/schemas/system_schema/schema_modules.json +++ b/openpype/settings/entities/schemas/system_schema/schema_modules.json @@ -85,11 +85,32 @@ "label": "Site Sync", "collapsible": true, "checkbox_key": "enabled", - "children": [{ - "type": "boolean", - "key": "enabled", - "label": "Enabled" - }] + "children": [ + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + }, + { + "type": "dict-modifiable", + "collapsible": true, + "key": "sites", + "label": "Sites", + "collapsible_key": false, + "is_file": true, + "object_type": + { + "type": "dict", + "children": [ + { + "type": "providers-enum", + "key": "provider", + "label": "Provider" + } + ] + } + } + ] },{ "type": "dict", "key": "deadline", diff --git a/openpype/tools/mayalookassigner/app.py b/openpype/tools/mayalookassigner/app.py index 81aa841eb7..1fa3a3868a 100644 --- a/openpype/tools/mayalookassigner/app.py +++ b/openpype/tools/mayalookassigner/app.py @@ -192,7 +192,7 @@ class App(QtWidgets.QWidget): for i, (asset, item) in enumerate(asset_nodes.items()): # Label prefix - prefix = "({}/{})".format(i+1, len(asset_nodes)) + prefix = "({}/{})".format(i + 1, len(asset_nodes)) # Assign the first matching look relevant for this asset # (since assigning multiple to the same nodes makes no sense) @@ -212,18 +212,19 @@ class App(QtWidgets.QWidget): self.echo("{} Assigning {} to {}\t".format(prefix, subset_name, asset)) + nodes = item["nodes"] - self.echo("Getting vray proxy nodes ...") - vray_proxies = set(cmds.ls(type="VRayProxy")) - nodes = set(item["nodes"]).difference(vray_proxies) + if cmds.pluginInfo('vrayformaya', query=True, loaded=True): + self.echo("Getting vray proxy nodes ...") + vray_proxies = set(cmds.ls(type="VRayProxy")) + nodes = list(set(item["nodes"]).difference(vray_proxies)) + if vray_proxies: + for vp in vray_proxies: + vrayproxy_assign_look(vp, subset_name) - # Assign look + # Assign look if nodes: - assign_look_by_version([nodes], version_id=version["_id"]) - - if vray_proxies: - for vp in vray_proxies: - vrayproxy_assign_look(vp, subset_name) + assign_look_by_version(nodes, version_id=version["_id"]) end = time.time() diff --git a/openpype/tools/project_manager/__init__.py b/openpype/tools/project_manager/__init__.py new file mode 100644 index 0000000000..62fa8af8aa --- /dev/null +++ b/openpype/tools/project_manager/__init__.py @@ -0,0 +1,10 @@ +from .project_manager import ( + ProjectManagerWindow, + main +) + + +__all__ = ( + "ProjectManagerWindow", + "main" +) diff --git a/openpype/tools/project_manager/__main__.py b/openpype/tools/project_manager/__main__.py new file mode 100644 index 0000000000..2e57af5f11 --- /dev/null +++ b/openpype/tools/project_manager/__main__.py @@ -0,0 +1,5 @@ +from project_manager import main + + +if __name__ == "__main__": + main() diff --git a/openpype/tools/project_manager/project_manager/__init__.py b/openpype/tools/project_manager/project_manager/__init__.py new file mode 100644 index 0000000000..49ade4a989 --- /dev/null +++ b/openpype/tools/project_manager/project_manager/__init__.py @@ -0,0 +1,50 @@ +__all__ = ( + "IDENTIFIER_ROLE", + + "HierarchyView", + + "ProjectModel", + "CreateProjectDialog", + + "HierarchyModel", + "HierarchySelectionModel", + "BaseItem", + "RootItem", + "ProjectItem", + "AssetItem", + "TaskItem", + + "ProjectManagerWindow", + "main" +) + + +from .constants import ( + IDENTIFIER_ROLE +) +from .widgets import CreateProjectDialog +from .view import HierarchyView +from .model import ( + ProjectModel, + + HierarchyModel, + HierarchySelectionModel, + BaseItem, + RootItem, + ProjectItem, + AssetItem, + TaskItem +) +from .window import ProjectManagerWindow + + +def main(): + import sys + from Qt import QtWidgets + + app = QtWidgets.QApplication([]) + + window = ProjectManagerWindow() + window.show() + + sys.exit(app.exec_()) diff --git a/openpype/tools/project_manager/project_manager/constants.py b/openpype/tools/project_manager/project_manager/constants.py new file mode 100644 index 0000000000..6fb4b991ed --- /dev/null +++ b/openpype/tools/project_manager/project_manager/constants.py @@ -0,0 +1,13 @@ +import re +from Qt import QtCore + + +IDENTIFIER_ROLE = QtCore.Qt.UserRole + 1 +DUPLICATED_ROLE = QtCore.Qt.UserRole + 2 +HIERARCHY_CHANGE_ABLE_ROLE = QtCore.Qt.UserRole + 3 +REMOVED_ROLE = QtCore.Qt.UserRole + 4 +ITEM_TYPE_ROLE = QtCore.Qt.UserRole + 5 +EDITOR_OPENED_ROLE = QtCore.Qt.UserRole + 6 + +NAME_ALLOWED_SYMBOLS = "a-zA-Z0-9_" +NAME_REGEX = re.compile("^[" + NAME_ALLOWED_SYMBOLS + "]*$") diff --git a/openpype/tools/project_manager/project_manager/delegates.py b/openpype/tools/project_manager/project_manager/delegates.py new file mode 100644 index 0000000000..51edff028f --- /dev/null +++ b/openpype/tools/project_manager/project_manager/delegates.py @@ -0,0 +1,159 @@ +from Qt import QtWidgets, QtCore + +from .widgets import ( + NameTextEdit, + FilterComboBox +) +from .multiselection_combobox import MultiSelectionComboBox + + +class ResizeEditorDelegate(QtWidgets.QStyledItemDelegate): + @staticmethod + def _q_smart_min_size(editor): + min_size_hint = editor.minimumSizeHint() + size_policy = editor.sizePolicy() + width = 0 + height = 0 + if size_policy.horizontalPolicy() != QtWidgets.QSizePolicy.Ignored: + if ( + size_policy.horizontalPolicy() + & QtWidgets.QSizePolicy.ShrinkFlag + ): + width = min_size_hint.width() + else: + width = max( + editor.sizeHint().width(), + min_size_hint.width() + ) + + if size_policy.verticalPolicy() != QtWidgets.QSizePolicy.Ignored: + if size_policy.verticalPolicy() & QtWidgets.QSizePolicy.ShrinkFlag: + height = min_size_hint.height() + else: + height = max( + editor.sizeHint().height(), + min_size_hint.height() + ) + + output = QtCore.QSize(width, height).boundedTo(editor.maximumSize()) + min_size = editor.minimumSize() + if min_size.width() > 0: + output.setWidth(min_size.width()) + if min_size.height() > 0: + output.setHeight(min_size.height()) + + return output.expandedTo(QtCore.QSize(0, 0)) + + def updateEditorGeometry(self, editor, option, index): + self.initStyleOption(option, index) + + option.showDecorationSelected = editor.style().styleHint( + QtWidgets.QStyle.SH_ItemView_ShowDecorationSelected, None, editor + ) + + widget = option.widget + + style = widget.style() if widget else QtWidgets.QApplication.style() + geo = style.subElementRect( + QtWidgets.QStyle.SE_ItemViewItemText, option, widget + ) + delta = self._q_smart_min_size(editor).width() - geo.width() + if delta > 0: + if editor.layoutDirection() == QtCore.Qt.RightToLeft: + geo.adjust(-delta, 0, 0, 0) + else: + geo.adjust(0, 0, delta, 0) + editor.setGeometry(geo) + + +class NumberDelegate(QtWidgets.QStyledItemDelegate): + def __init__(self, minimum, maximum, decimals, *args, **kwargs): + super(NumberDelegate, self).__init__(*args, **kwargs) + self.minimum = minimum + self.maximum = maximum + self.decimals = decimals + + def createEditor(self, parent, option, index): + if self.decimals > 0: + editor = QtWidgets.QDoubleSpinBox(parent) + else: + editor = QtWidgets.QSpinBox(parent) + + editor.setObjectName("NumberEditor") + editor.setMinimum(self.minimum) + editor.setMaximum(self.maximum) + editor.setButtonSymbols(QtWidgets.QSpinBox.NoButtons) + + value = index.data(QtCore.Qt.EditRole) + if value is not None: + try: + if isinstance(value, str): + value = float(value) + editor.setValue(value) + + except Exception: + print("Couldn't set invalid value \"{}\"".format(str(value))) + + return editor + + +class NameDelegate(QtWidgets.QStyledItemDelegate): + def createEditor(self, parent, option, index): + editor = NameTextEdit(parent) + editor.setObjectName("NameEditor") + value = index.data(QtCore.Qt.EditRole) + if value is not None: + editor.setText(str(value)) + return editor + + +class TypeDelegate(QtWidgets.QStyledItemDelegate): + def __init__(self, project_doc_cache, *args, **kwargs): + self._project_doc_cache = project_doc_cache + super(TypeDelegate, self).__init__(*args, **kwargs) + + def createEditor(self, parent, option, index): + editor = FilterComboBox(parent) + editor.setObjectName("TypeEditor") + editor.style().polish(editor) + if not self._project_doc_cache.project_doc: + return editor + + task_type_defs = self._project_doc_cache.project_doc["config"]["tasks"] + editor.addItems(list(task_type_defs.keys())) + + return editor + + def setEditorData(self, editor, index): + value = index.data(QtCore.Qt.EditRole) + index = editor.findText(value) + if index >= 0: + editor.setCurrentIndex(index) + + def setModelData(self, editor, model, index): + editor.value_cleanup() + super(TypeDelegate, self).setModelData(editor, model, index) + + +class ToolsDelegate(QtWidgets.QStyledItemDelegate): + def __init__(self, tools_cache, *args, **kwargs): + self._tools_cache = tools_cache + super(ToolsDelegate, self).__init__(*args, **kwargs) + + def createEditor(self, parent, option, index): + editor = MultiSelectionComboBox(parent) + editor.setObjectName("ToolEditor") + if not self._tools_cache.tools_data: + return editor + + for key, label in self._tools_cache.tools_data: + editor.addItem(label, key) + + return editor + + def setEditorData(self, editor, index): + value = index.data(QtCore.Qt.EditRole) + editor.set_value(value) + + def setModelData(self, editor, model, index): + model.setData(index, editor.value(), QtCore.Qt.EditRole) diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py new file mode 100644 index 0000000000..6e20dd368f --- /dev/null +++ b/openpype/tools/project_manager/project_manager/model.py @@ -0,0 +1,2004 @@ +import collections +import copy +import json +from queue import Queue +from uuid import uuid4 + +from .constants import ( + IDENTIFIER_ROLE, + ITEM_TYPE_ROLE, + DUPLICATED_ROLE, + HIERARCHY_CHANGE_ABLE_ROLE, + REMOVED_ROLE, + EDITOR_OPENED_ROLE +) +from .style import ResourceCache + +from openpype.lib import CURRENT_DOC_SCHEMAS +from pymongo import UpdateOne, DeleteOne +from avalon.vendor import qtawesome +from Qt import QtCore, QtGui + + +class ProjectModel(QtGui.QStandardItemModel): + project_changed = QtCore.Signal() + + def __init__(self, dbcon, *args, **kwargs): + self.dbcon = dbcon + + self._project_names = set() + + super(ProjectModel, self).__init__(*args, **kwargs) + + def refresh(self): + self.dbcon.Session["AVALON_PROJECT"] = None + + project_items = [] + + none_project = QtGui.QStandardItem("< Select Project >") + none_project.setData(None) + project_items.append(none_project) + + database = self.dbcon.database + project_names = set() + for project_name in database.collection_names(): + # Each collection will have exactly one project document + project_doc = database[project_name].find_one( + {"type": "project"}, + {"name": 1} + ) + if not project_doc: + continue + + project_name = project_doc.get("name") + if project_name: + project_names.add(project_name) + project_items.append(QtGui.QStandardItem(project_name)) + + self.clear() + + self._project_names = project_names + + self.invisibleRootItem().appendRows(project_items) + + +class HierarchySelectionModel(QtCore.QItemSelectionModel): + def __init__(self, multiselection_columns, *args, **kwargs): + super(HierarchySelectionModel, self).__init__(*args, **kwargs) + self.multiselection_columns = multiselection_columns + + def setCurrentIndex(self, index, command): + if index.column() in self.multiselection_columns: + if ( + command & QtCore.QItemSelectionModel.Clear + and command & QtCore.QItemSelectionModel.Select + ): + command = QtCore.QItemSelectionModel.NoUpdate + super(HierarchySelectionModel, self).setCurrentIndex(index, command) + + +class HierarchyModel(QtCore.QAbstractItemModel): + _columns_def = [ + ("name", "Name"), + ("type", "Type"), + ("fps", "FPS"), + ("frameStart", "Frame start"), + ("frameEnd", "Frame end"), + ("handleStart", "Handle start"), + ("handleEnd", "Handle end"), + ("resolutionWidth", "Width"), + ("resolutionHeight", "Height"), + ("clipIn", "Clip in"), + ("clipOut", "Clip out"), + ("pixelAspect", "Pixel aspect"), + ("tools_env", "Tools") + ] + multiselection_columns = { + "frameStart", + "frameEnd", + "fps", + "resolutionWidth", + "resolutionHeight", + "handleStart", + "handleEnd", + "clipIn", + "clipOut", + "pixelAspect", + "tools_env" + } + columns = [ + item[0] + for item in _columns_def + ] + columns_len = len(columns) + column_labels = { + idx: item[1] + for idx, item in enumerate(_columns_def) + } + + index_moved = QtCore.Signal(QtCore.QModelIndex) + project_changed = QtCore.Signal() + + def __init__(self, dbcon, parent=None): + super(HierarchyModel, self).__init__(parent) + + self.multiselection_column_indexes = { + self.columns.index(key) + for key in self.multiselection_columns + } + + # TODO Reset them on project change + self._current_project = None + self._root_item = None + self._items_by_id = {} + self._asset_items_by_name = collections.defaultdict(set) + self.dbcon = dbcon + + self._reset_root_item() + + @property + def items_by_id(self): + return self._items_by_id + + def _reset_root_item(self): + self._root_item = RootItem(self) + + def refresh_project(self): + self.set_project(self._current_project, True) + + @property + def project_item(self): + output = None + for row in range(self._root_item.rowCount()): + item = self._root_item.child(row) + if isinstance(item, ProjectItem): + output = item + break + return output + + def set_project(self, project_name, force=False): + if self._current_project == project_name and not force: + return + + self.clear() + + self._current_project = project_name + if not project_name: + return + + project_doc = self.dbcon.database[project_name].find_one( + {"type": "project"}, + ProjectItem.query_projection + ) + if not project_doc: + return + + project_item = ProjectItem(project_doc) + self.add_item(project_item) + + asset_docs = self.dbcon.database[project_name].find( + {"type": "asset"}, + AssetItem.query_projection + ) + asset_docs_by_id = { + asset_doc["_id"]: asset_doc + for asset_doc in asset_docs + } + + # Prepare booleans if asset item can be modified (name or hierarchy) + # - the same must be applied to all it's parents + asset_ids = list(asset_docs_by_id.keys()) + result = [] + if asset_ids: + result = self.dbcon.database[project_name].aggregate([ + { + "$match": { + "type": "subset", + "parent": {"$in": asset_ids} + } + }, + { + "$group": { + "_id": "$parent", + "count": {"$sum": 1} + } + } + ]) + + asset_modifiable = { + asset_id: True + for asset_id in asset_docs_by_id.keys() + } + for item in result: + asset_id = item["_id"] + count = item["count"] + asset_modifiable[asset_id] = count < 1 + + asset_docs_by_parent_id = collections.defaultdict(list) + for asset_doc in asset_docs_by_id.values(): + parent_id = asset_doc["data"].get("visualParent") + asset_docs_by_parent_id[parent_id].append(asset_doc) + + appending_queue = Queue() + appending_queue.put((None, project_item)) + + asset_items_by_id = {} + non_modifiable_items = set() + while not appending_queue.empty(): + parent_id, parent_item = appending_queue.get() + asset_docs = asset_docs_by_parent_id.get(parent_id) or [] + + new_items = [] + for asset_doc in sorted(asset_docs, key=lambda item: item["name"]): + # Create new Item + new_item = AssetItem(asset_doc) + # Store item to be added under parent in bulk + new_items.append(new_item) + + # Store item by id for task processing + asset_id = asset_doc["_id"] + if not asset_modifiable[asset_id]: + non_modifiable_items.add(new_item.id) + + asset_items_by_id[asset_id] = new_item + # Add item to appending queue + appending_queue.put((asset_id, new_item)) + + if new_items: + self.add_items(new_items, parent_item) + + # Handle Asset's that are not modifiable + # - pass the information to all it's parents + non_modifiable_queue = Queue() + for item_id in non_modifiable_items: + non_modifiable_queue.put(item_id) + + while not non_modifiable_queue.empty(): + item_id = non_modifiable_queue.get() + item = self._items_by_id[item_id] + item.setData(False, HIERARCHY_CHANGE_ABLE_ROLE) + + parent = item.parent() + if ( + isinstance(parent, AssetItem) + and parent.id not in non_modifiable_items + ): + non_modifiable_items.add(parent.id) + non_modifiable_queue.put(parent.id) + + # Add task items + for asset_id, asset_item in asset_items_by_id.items(): + asset_doc = asset_docs_by_id[asset_id] + asset_tasks = asset_doc["data"]["tasks"] + if not asset_tasks: + continue + + task_items = [] + for task_name in sorted(asset_tasks.keys()): + _task_data = copy.deepcopy(asset_tasks[task_name]) + _task_data["name"] = task_name + task_item = TaskItem(_task_data) + task_items.append(task_item) + + self.add_items(task_items, asset_item) + + self.project_changed.emit() + + 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.rowCount() + + def columnCount(self, *args, **kwargs): + return self.columns_len + + def data(self, index, role): + if not index.isValid(): + return None + + column = index.column() + key = self.columns[column] + + item = index.internalPointer() + return item.data(role, key) + + def setData(self, index, value, role=QtCore.Qt.EditRole): + if not index.isValid(): + return False + + item = index.internalPointer() + column = index.column() + key = self.columns[column] + if ( + key == "name" + and role in (QtCore.Qt.EditRole, QtCore.Qt.DisplayRole) + ): + self._rename_asset(item, value) + + result = item.setData(value, role, key) + if result: + self.dataChanged.emit(index, index, [role]) + + return result + + def headerData(self, section, orientation, role): + if role == QtCore.Qt.DisplayRole: + if section < self.columnCount(): + return self.column_labels[section] + + return super(HierarchyModel, self).headerData( + section, orientation, role + ) + + def flags(self, index): + item = index.internalPointer() + if item is None: + return QtCore.Qt.NoItemFlags + column = index.column() + key = self.columns[column] + return item.flags(key) + + def parent(self, index=None): + if not index.isValid(): + return QtCore.QModelIndex() + + item = index.internalPointer() + parent_item = item.parent() + + # If it has no parents we return invalid + if not parent_item or parent_item is self._root_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""" + parent_item = None + if parent is not None and parent.isValid(): + parent_item = parent.internalPointer() + + return self.index_from_item(row, column, parent_item) + + def index_for_item(self, item, column=0): + return self.index_from_item( + item.row(), column, item.parent() + ) + + def index_from_item(self, row, column, parent=None): + if parent is None: + parent = self._root_item + + child_item = parent.child(row) + if child_item: + return self.createIndex(row, column, child_item) + + return QtCore.QModelIndex() + + def add_new_asset(self, source_index): + item_id = source_index.data(IDENTIFIER_ROLE) + item = self.items_by_id[item_id] + + if isinstance(item, (RootItem, ProjectItem)): + name = "ep" + new_row = None + else: + name = None + new_row = item.rowCount() + + asset_data = {} + if name: + asset_data["name"] = name + + new_child = AssetItem(asset_data) + + result = self.add_item(new_child, item, new_row) + if result is not None: + # WARNING Expecting result is index for column 0 ("name") + new_name = result.data(QtCore.Qt.EditRole) + self._validate_asset_duplicity(new_name) + + return result + + def add_new_task(self, parent_index): + item_id = parent_index.data(IDENTIFIER_ROLE) + item = self.items_by_id[item_id] + + if isinstance(item, TaskItem): + parent = item.parent() + else: + parent = item + + if not isinstance(parent, AssetItem): + return None + + new_child = TaskItem() + return self.add_item(new_child, parent) + + def add_items(self, items, parent=None, start_row=None): + if parent is None: + parent = self._root_item + + if parent.data(REMOVED_ROLE): + return [] + + if start_row is None: + start_row = parent.rowCount() + + end_row = start_row + len(items) - 1 + + parent_index = self.index_from_item(parent.row(), 0, parent.parent()) + + self.beginInsertRows(parent_index, start_row, end_row) + + for idx, item in enumerate(items): + row = start_row + idx + if item.parent() is not parent: + item.set_parent(parent) + + parent.add_child(item, row) + + if isinstance(item, AssetItem): + name = item.data(QtCore.Qt.EditRole, "name") + self._asset_items_by_name[name].add(item.id) + + if item.id not in self._items_by_id: + self._items_by_id[item.id] = item + + self.endInsertRows() + + indexes = [] + for row in range(start_row, end_row + 1): + indexes.append( + self.index_from_item(row, 0, parent) + ) + return indexes + + def add_item(self, item, parent=None, row=None): + result = self.add_items([item], parent, row) + if result: + return result[0] + return None + + def remove_delete_flag(self, item_ids, with_children=True): + items_by_id = {} + for item_id in item_ids: + if item_id in items_by_id: + continue + + item = self.items_by_id[item_id] + if isinstance(item, (AssetItem, TaskItem)): + items_by_id[item_id] = item + + for item in tuple(items_by_id.values()): + parent = item.parent() + while True: + if not isinstance(parent, (AssetItem, TaskItem)): + break + + if parent.id not in items_by_id: + items_by_id[parent.id] = parent + + parent = parent.parent() + + if not with_children: + continue + + def _children_recursion(_item): + if not isinstance(_item, AssetItem): + return + + for row in range(_item.rowCount()): + _child_item = _item.child(row) + if _child_item.id in items_by_id: + continue + + items_by_id[_child_item.id] = _child_item + _children_recursion(_child_item) + + _children_recursion(item) + + for item in items_by_id.values(): + if item.data(REMOVED_ROLE): + item.setData(False, REMOVED_ROLE) + if isinstance(item, AssetItem): + name = item.data(QtCore.Qt.EditRole, "name") + self._asset_items_by_name[name].add(item.id) + self._validate_asset_duplicity(name) + + def delete_index(self, index): + return self.delete_indexes([index]) + + def delete_indexes(self, indexes): + items_by_id = {} + processed_ids = set() + for index in indexes: + if not index.isValid(): + continue + + item_id = index.data(IDENTIFIER_ROLE) + # There may be indexes for multiple columns + if item_id not in processed_ids: + processed_ids.add(item_id) + + item = self._items_by_id[item_id] + if isinstance(item, (TaskItem, AssetItem)): + items_by_id[item_id] = item + + if not items_by_id: + return + + for item in items_by_id.values(): + self._remove_item(item) + + def _remove_item(self, item): + is_removed = item.data(REMOVED_ROLE) + if is_removed: + return + + parent = item.parent() + + all_descendants = collections.defaultdict(dict) + all_descendants[parent.id][item.id] = item + + def _fill_children(_all_descendants, cur_item, parent_item=None): + if parent_item is not None: + _all_descendants[parent_item.id][cur_item.id] = cur_item + + if isinstance(cur_item, TaskItem): + was_removed = cur_item.data(REMOVED_ROLE) + task_removed = True + if not was_removed and parent_item is not None: + task_removed = parent_item.data(REMOVED_ROLE) + if not was_removed: + cur_item.setData(task_removed, REMOVED_ROLE) + return task_removed + + remove_item = True + task_children = [] + for row in range(cur_item.rowCount()): + child_item = cur_item.child(row) + if isinstance(child_item, TaskItem): + task_children.append(child_item) + continue + + if not _fill_children(_all_descendants, child_item, cur_item): + remove_item = False + + if remove_item: + cur_item.setData(True, REMOVED_ROLE) + if isinstance(cur_item, AssetItem): + self._rename_asset(cur_item, None) + + for task_item in task_children: + _fill_children(_all_descendants, task_item, cur_item) + return remove_item + + _fill_children(all_descendants, item) + + modified_children = [] + while all_descendants: + for parent_id in tuple(all_descendants.keys()): + children = all_descendants[parent_id] + if not children: + all_descendants.pop(parent_id) + continue + + parent_children = {} + all_without_children = True + for child_id in tuple(children.keys()): + if child_id in all_descendants: + all_without_children = False + break + parent_children[child_id] = children[child_id] + + if not all_without_children: + continue + + parent_item = self._items_by_id[parent_id] + row_ranges = [] + start_row = end_row = None + chilren_by_row = {} + for row in range(parent_item.rowCount()): + child_item = parent_item.child(row) + child_id = child_item.id + if child_id not in children: + continue + + chilren_by_row[row] = child_item + children.pop(child_item.id) + + remove_item = child_item.data(REMOVED_ROLE) + if not remove_item or not child_item.is_new: + modified_children.append(child_item) + if end_row is not None: + row_ranges.append((start_row, end_row)) + start_row = end_row = None + continue + + end_row = row + if start_row is None: + start_row = row + + if end_row is not None: + row_ranges.append((start_row, end_row)) + + parent_index = None + for start, end in row_ranges: + if parent_index is None: + parent_index = self.index_for_item(parent_item) + + self.beginRemoveRows(parent_index, start, end) + + for idx in range(start, end + 1): + child_item = chilren_by_row[idx] + # Force name validation + if isinstance(child_item, AssetItem): + self._rename_asset(child_item, None) + child_item.set_parent(None) + self._items_by_id.pop(child_item.id) + + self.endRemoveRows() + + for item in modified_children: + s_index = self.index_for_item(item) + e_index = self.index_for_item(item, column=self.columns_len - 1) + self.dataChanged.emit(s_index, e_index, [QtCore.Qt.BackgroundRole]) + + def _rename_asset(self, asset_item, new_name): + if not isinstance(asset_item, AssetItem): + return + + prev_name = asset_item.data(QtCore.Qt.EditRole, "name") + if prev_name == new_name: + return + + if asset_item.id in self._asset_items_by_name[prev_name]: + self._asset_items_by_name[prev_name].remove(asset_item.id) + + self._validate_asset_duplicity(prev_name) + + if new_name is None: + return + self._asset_items_by_name[new_name].add(asset_item.id) + + self._validate_asset_duplicity(new_name) + + def _validate_asset_duplicity(self, name): + if name not in self._asset_items_by_name: + return + + item_ids = self._asset_items_by_name[name] + if not item_ids: + self._asset_items_by_name.pop(name) + + elif len(item_ids) == 1: + for item_id in item_ids: + item = self._items_by_id[item_id] + index = self.index_for_item(item) + self.setData(index, False, DUPLICATED_ROLE) + + else: + for item_id in item_ids: + item = self._items_by_id[item_id] + index = self.index_for_item(item) + self.setData(index, True, DUPLICATED_ROLE) + + def _move_horizontal_single(self, index, direction): + if not index.isValid(): + return + + item_id = index.data(IDENTIFIER_ROLE) + if item_id is None: + return + + item = self._items_by_id[item_id] + if isinstance(item, (RootItem, ProjectItem)): + return + + if item.data(REMOVED_ROLE): + return + + if ( + isinstance(item, AssetItem) + and not item.data(HIERARCHY_CHANGE_ABLE_ROLE) + ): + return + + if abs(direction) != 1: + return + + # Move under parent of parent + src_row = item.row() + src_parent = item.parent() + src_parent_index = self.index_from_item( + src_parent.row(), 0, src_parent.parent() + ) + + dst_row = None + dst_parent = None + + if direction == -1: + if isinstance(src_parent, (RootItem, ProjectItem)): + return + dst_parent = src_parent.parent() + dst_row = src_parent.row() + 1 + + # Move under parent before or after if before is None + elif direction == 1: + src_row_count = src_parent.rowCount() + if src_row_count == 1: + return + + item_row = item.row() + dst_parent = None + for row in reversed(range(item_row)): + _item = src_parent.child(row) + if not isinstance(_item, AssetItem): + continue + + if _item.data(REMOVED_ROLE): + continue + + dst_parent = _item + break + + _next_row = item_row + 1 + if dst_parent is None and _next_row < src_row_count: + for row in range(_next_row, src_row_count): + _item = src_parent.child(row) + if not isinstance(_item, AssetItem): + continue + + if _item.data(REMOVED_ROLE): + continue + + dst_parent = _item + break + + if dst_parent is None: + return + + dst_row = dst_parent.rowCount() + + if src_parent is dst_parent: + return + + if ( + isinstance(item, TaskItem) + and not isinstance(dst_parent, AssetItem) + ): + return + + dst_parent_index = self.index_from_item( + dst_parent.row(), 0, dst_parent.parent() + ) + + self.beginMoveRows( + src_parent_index, + src_row, + src_row, + dst_parent_index, + dst_row + ) + src_parent.remove_child(item) + dst_parent.add_child(item) + item.set_parent(dst_parent) + dst_parent.move_to(item, dst_row) + + self.endMoveRows() + + new_index = self.index(dst_row, index.column(), dst_parent_index) + self.index_moved.emit(new_index) + + def move_horizontal(self, indexes, direction): + if not indexes: + return + + if isinstance(indexes, QtCore.QModelIndex): + indexes = [indexes] + + if len(indexes) == 1: + self._move_horizontal_single(indexes[0], direction) + return + + items_by_id = {} + for index in indexes: + item_id = index.data(IDENTIFIER_ROLE) + item = self._items_by_id[item_id] + if isinstance(item, (RootItem, ProjectItem)): + continue + + if ( + direction == -1 + and isinstance(item.parent(), (RootItem, ProjectItem)) + ): + continue + + items_by_id[item_id] = item + + if not items_by_id: + return + + parents_by_id = {} + items_ids_by_parent_id = collections.defaultdict(set) + skip_ids = set(items_by_id.keys()) + for item_id, item in tuple(items_by_id.items()): + item_parent = item.parent() + + parent_ids = set() + skip_item = False + parent = item_parent + while parent is not None: + if parent.id in skip_ids: + skip_item = True + skip_ids |= parent_ids + break + parent_ids.add(parent.id) + parent = parent.parent() + + if skip_item: + items_by_id.pop(item_id) + else: + parents_by_id[item_parent.id] = item_parent + items_ids_by_parent_id[item_parent.id].add(item_id) + + if direction == 1: + for parent_id, parent in parents_by_id.items(): + items_ids = items_ids_by_parent_id[parent_id] + if len(items_ids) == parent.rowCount(): + for item_id in items_ids: + items_by_id.pop(item_id) + + items = tuple(items_by_id.values()) + if direction == -1: + items = reversed(items) + + for item in items: + index = self.index_for_item(item) + self._move_horizontal_single(index, direction) + + def _move_vertical_single(self, index, direction): + if not index.isValid(): + return + + item_id = index.data(IDENTIFIER_ROLE) + item = self._items_by_id[item_id] + if isinstance(item, (RootItem, ProjectItem)): + return + + if item.data(REMOVED_ROLE): + return + + if ( + isinstance(item, AssetItem) + and not item.data(HIERARCHY_CHANGE_ABLE_ROLE) + ): + return + + if abs(direction) != 1: + return + + src_parent = item.parent() + if not isinstance(src_parent, AssetItem): + return + + src_parent_index = self.index_from_item( + src_parent.row(), 0, src_parent.parent() + ) + source_row = item.row() + + parent_items = [] + parent = src_parent + while True: + parent = parent.parent() + parent_items.insert(0, parent) + if isinstance(parent, ProjectItem): + break + + dst_parent = None + # Down + if direction == 1: + current_idxs = [] + current_max_idxs = [] + for parent_item in parent_items: + current_max_idxs.append(parent_item.rowCount()) + if not isinstance(parent_item, ProjectItem): + current_idxs.append(parent_item.row()) + current_idxs.append(src_parent.row()) + indexes_len = len(current_idxs) + + while True: + def _update_parents(idx, top=True): + if idx < 0: + return False + + if current_max_idxs[idx] == current_idxs[idx]: + if not _update_parents(idx - 1, False): + return False + + parent = parent_items[idx] + row_count = 0 + if parent is not None: + row_count = parent.rowCount() + current_max_idxs[idx] = row_count + current_idxs[idx] = 0 + return True + + if top: + return True + + current_idxs[idx] += 1 + parent_item = parent_items[idx] + new_item = parent_item.child(current_idxs[idx]) + parent_items[idx + 1] = new_item + + return True + + updated = _update_parents(indexes_len - 1) + if not updated: + return + + start = current_idxs[-1] + end = current_max_idxs[-1] + current_idxs[-1] = current_max_idxs[-1] + parent = parent_items[-1] + for row in range(start, end): + child_item = parent.child(row) + if ( + child_item is src_parent + or child_item.data(REMOVED_ROLE) + or not isinstance(child_item, AssetItem) + ): + continue + + dst_parent = child_item + destination_row = 0 + break + + if dst_parent is not None: + break + + # Up + elif direction == -1: + current_idxs = [] + for parent_item in parent_items: + if not isinstance(parent_item, ProjectItem): + current_idxs.append(parent_item.row()) + current_idxs.append(src_parent.row()) + + max_idxs = [0 for _ in current_idxs] + indexes_len = len(current_idxs) + + while True: + if current_idxs == max_idxs: + return + + def _update_parents(_current_idx, top=True): + if _current_idx < 0: + return False + + if current_idxs[_current_idx] == 0: + if not _update_parents(_current_idx - 1, False): + return False + + parent = parent_items[_current_idx] + row_count = 0 + if parent is not None: + row_count = parent.rowCount() + current_idxs[_current_idx] = row_count + return True + if top: + return True + + current_idxs[_current_idx] -= 1 + parent_item = parent_items[_current_idx] + new_item = parent_item.child(current_idxs[_current_idx]) + parent_items[_current_idx + 1] = new_item + + return True + + updated = _update_parents(indexes_len - 1) + if not updated: + return + + parent_item = parent_items[-1] + row_count = current_idxs[-1] + current_idxs[-1] = 0 + for row in reversed(range(row_count)): + child_item = parent_item.child(row) + if ( + child_item is src_parent + or child_item.data(REMOVED_ROLE) + or not isinstance(child_item, AssetItem) + ): + continue + + dst_parent = child_item + destination_row = dst_parent.rowCount() + break + + if dst_parent is not None: + break + + if dst_parent is None: + return + + dst_parent_index = self.index_from_item( + dst_parent.row(), 0, dst_parent.parent() + ) + + self.beginMoveRows( + src_parent_index, + source_row, + source_row, + dst_parent_index, + destination_row + ) + + if src_parent is dst_parent: + dst_parent.move_to(item, destination_row) + + else: + src_parent.remove_child(item) + dst_parent.add_child(item) + item.set_parent(dst_parent) + dst_parent.move_to(item, destination_row) + + self.endMoveRows() + + new_index = self.index( + destination_row, index.column(), dst_parent_index + ) + self.index_moved.emit(new_index) + + def move_vertical(self, indexes, direction): + if not indexes: + return + + if isinstance(indexes, QtCore.QModelIndex): + indexes = [indexes] + + if len(indexes) == 1: + self._move_vertical_single(indexes[0], direction) + return + + items_by_id = {} + for index in indexes: + item_id = index.data(IDENTIFIER_ROLE) + items_by_id[item_id] = self._items_by_id[item_id] + + skip_ids = set(items_by_id.keys()) + for item_id, item in tuple(items_by_id.items()): + parent = item.parent() + parent_ids = set() + skip_item = False + while parent is not None: + if parent.id in skip_ids: + skip_item = True + skip_ids |= parent_ids + break + parent_ids.add(parent.id) + parent = parent.parent() + + if skip_item: + items_by_id.pop(item_id) + + items = tuple(items_by_id.values()) + if direction == 1: + items = reversed(items) + + for item in items: + index = self.index_for_item(item) + self._move_vertical_single(index, direction) + + def child_removed(self, child): + self._items_by_id.pop(child.id, None) + + def column_name(self, column): + """Return column key by index""" + if column < len(self.columns): + return self.columns[column] + return None + + def clear(self): + self.beginResetModel() + self._reset_root_item() + self.endResetModel() + + def save(self): + all_valid = True + for item in self._items_by_id.values(): + if not item.is_valid: + all_valid = False + break + + if not all_valid: + return + + project_item = None + for _project_item in self._root_item.children(): + project_item = _project_item + + if not project_item: + return + + project_name = project_item.name + project_col = self.dbcon.database[project_name] + + to_process = Queue() + to_process.put(project_item) + + bulk_writes = [] + while not to_process.empty(): + parent = to_process.get() + insert_list = [] + for item in parent.children(): + if not isinstance(item, AssetItem): + continue + + to_process.put(item) + + if item.is_new: + insert_list.append(item) + + elif item.data(REMOVED_ROLE): + if item.data(HIERARCHY_CHANGE_ABLE_ROLE): + bulk_writes.append(DeleteOne( + {"_id": item.asset_id} + )) + else: + bulk_writes.append(UpdateOne( + {"_id": item.asset_id}, + {"$set": {"type": "archived_asset"}} + )) + + else: + update_data = item.update_data() + if update_data: + bulk_writes.append(UpdateOne( + {"_id": item.asset_id}, + update_data + )) + + if insert_list: + new_docs = [] + for item in insert_list: + new_docs.append(item.to_doc()) + + result = project_col.insert_many(new_docs) + for idx, mongo_id in enumerate(result.inserted_ids): + insert_list[idx].mongo_id = mongo_id + + if bulk_writes: + project_col.bulk_write(bulk_writes) + + self.refresh_project() + + def copy_mime_data(self, indexes): + items = [] + processed_ids = set() + for index in indexes: + if not index.isValid(): + continue + item_id = index.data(IDENTIFIER_ROLE) + if item_id in processed_ids: + continue + processed_ids.add(item_id) + item = self._items_by_id[item_id] + items.append(item) + + parent_item = None + for item in items: + if not isinstance(item, TaskItem): + raise ValueError("Can copy only tasks") + + if parent_item is None: + parent_item = item.parent() + elif item.parent() is not parent_item: + raise ValueError("Can copy only tasks from same parent") + + data = [] + for task_item in items: + data.append(task_item.to_json_data()) + + encoded_data = QtCore.QByteArray() + stream = QtCore.QDataStream(encoded_data, QtCore.QIODevice.WriteOnly) + stream.writeQString(json.dumps(data)) + mimedata = QtCore.QMimeData() + mimedata.setData("application/copy_task", encoded_data) + return mimedata + + def paste_mime_data(self, index, mime_data): + if not index.isValid(): + return + + item_id = index.data(IDENTIFIER_ROLE) + item = self._items_by_id[item_id] + if not isinstance(item, (AssetItem, TaskItem)): + return + + raw_data = mime_data.data("application/copy_task") + encoded_data = QtCore.QByteArray.fromRawData(raw_data) + stream = QtCore.QDataStream(encoded_data, QtCore.QIODevice.ReadOnly) + text = stream.readQString() + try: + data = json.loads(text) + except Exception: + data = [] + + if not data: + return + + if isinstance(item, TaskItem): + parent = item.parent() + else: + parent = item + + for task_item_data in data: + task_data = {} + for name, data in task_item_data.items(): + task_data = data + task_data["name"] = name + + task_item = TaskItem(task_data, True) + self.add_item(task_item, parent) + + +class BaseItem: + columns = [] + # Use `set` for faster result + editable_columns = set() + + _name_icons = None + _is_duplicated = False + item_type = "base" + + _None = object() + + def __init__(self, data=None): + self._id = uuid4() + self._children = list() + self._parent = None + + self._data = { + key: None + for key in self.columns + } + self._global_data = {} + self._source_data = data + if data: + for key, value in data.items(): + if key in self.columns: + self._data[key] = value + + def name_icon(self): + return None + + @property + def is_valid(self): + return not self._is_duplicated + + def model(self): + return self._parent.model() + + def move_to(self, item, row): + idx = self._children.index(item) + if idx == row: + return + + self._children.pop(idx) + self._children.insert(row, item) + + def _get_global_data(self, role): + if role == ITEM_TYPE_ROLE: + return self.item_type + + if role == IDENTIFIER_ROLE: + return self._id + + if role == DUPLICATED_ROLE: + return self._is_duplicated + + if role == REMOVED_ROLE: + return False + + return self._global_data.get(role, self._None) + + def _set_global_data(self, value, role): + self._global_data[role] = value + return True + + def data(self, role, key=None): + value = self._get_global_data(role) + if value is not self._None: + return value + + if key not in self.columns: + return None + + if role == QtCore.Qt.ForegroundRole: + if key == "name" and not self.is_valid: + return ResourceCache.colors["warning"] + return None + + if role in (QtCore.Qt.DisplayRole, QtCore.Qt.EditRole): + value = self._data[key] + if value is None: + value = self.parent().data(role, key) + return value + + if role == QtCore.Qt.DecorationRole and key == "name": + return self.name_icon() + return None + + def setData(self, value, role, key=None): + if role == DUPLICATED_ROLE: + if value == self._is_duplicated: + return False + + self._is_duplicated = value + return True + + if role == QtCore.Qt.EditRole: + if key in self.editable_columns: + self._data[key] = value + # must return true if successful + return True + + return self._set_global_data(value, role) + + @property + def id(self): + return self._id + + @property + def is_new(self): + return False + + def rowCount(self): + return len(self._children) + + def child(self, row): + if -1 < row < self.rowCount(): + return self._children[row] + return None + + def children(self): + return self._children + + def child_row(self, child): + if child not in self._children: + return -1 + return self._children.index(child) + + def parent(self): + return self._parent + + def set_parent(self, parent): + if parent is self._parent: + return + + if self._parent: + self._parent.remove_child(self) + self._parent = parent + + def row(self): + if self._parent is not None: + return self._parent.child_row(self) + return -1 + + def add_child(self, item, row=None): + if item in self._children: + return + + row_count = self.rowCount() + if row is None or row == row_count: + self._children.append(item) + return + + if row > row_count or row < 0: + raise ValueError( + "Invalid row number {} expected range 0 - {}".format( + row, row_count + ) + ) + + self._children.insert(row, item) + + def remove_child(self, item): + if item in self._children: + self._children.remove(item) + + def flags(self, key): + flags = QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable + if key in self.editable_columns: + flags |= QtCore.Qt.ItemIsEditable + return flags + + +class RootItem(BaseItem): + item_type = "root" + + def __init__(self, model): + super(RootItem, self).__init__() + self._model = model + + def model(self): + return self._model + + def flags(self, *args, **kwargs): + return QtCore.Qt.NoItemFlags + + +class ProjectItem(BaseItem): + item_type = "project" + + columns = { + "name", + "type", + "frameStart", + "frameEnd", + "fps", + "resolutionWidth", + "resolutionHeight", + "handleStart", + "handleEnd", + "clipIn", + "clipOut", + "pixelAspect", + "tools_env", + } + query_projection = { + "_id": 1, + "name": 1, + "type": 1, + + "data.frameStart": 1, + "data.frameEnd": 1, + "data.fps": 1, + "data.resolutionWidth": 1, + "data.resolutionHeight": 1, + "data.handleStart": 1, + "data.handleEnd": 1, + "data.clipIn": 1, + "data.clipOut": 1, + "data.pixelAspect": 1, + "data.tools_env": 1 + } + + def __init__(self, project_doc): + self._mongo_id = project_doc["_id"] + + data = self.data_from_doc(project_doc) + super(ProjectItem, self).__init__(data) + + @property + def project_id(self): + return self._mongo_id + + @property + def asset_id(self): + return None + + @property + def name(self): + return self._data["name"] + + def child_parents(self): + return [] + + @classmethod + def data_from_doc(cls, project_doc): + data = { + "name": project_doc["name"], + "type": project_doc["type"] + } + doc_data = project_doc.get("data") or {} + for key in cls.columns: + if key in data: + continue + + data[key] = doc_data.get(key) + + return data + + def flags(self, *args, **kwargs): + return QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable + + +class AssetItem(BaseItem): + item_type = "asset" + + columns = { + "name", + "type", + "fps", + "frameStart", + "frameEnd", + "resolutionWidth", + "resolutionHeight", + "handleStart", + "handleEnd", + "clipIn", + "clipOut", + "pixelAspect", + "tools_env" + } + editable_columns = { + "name", + "frameStart", + "frameEnd", + "fps", + "resolutionWidth", + "resolutionHeight", + "handleStart", + "handleEnd", + "clipIn", + "clipOut", + "pixelAspect", + "tools_env" + } + query_projection = { + "_id": 1, + "data.tasks": 1, + "data.visualParent": 1, + "schema": 1, + + "name": 1, + "type": 1, + "data.frameStart": 1, + "data.frameEnd": 1, + "data.fps": 1, + "data.resolutionWidth": 1, + "data.resolutionHeight": 1, + "data.handleStart": 1, + "data.handleEnd": 1, + "data.clipIn": 1, + "data.clipOut": 1, + "data.pixelAspect": 1, + "data.tools_env": 1 + } + + def __init__(self, asset_doc): + if not asset_doc: + asset_doc = {} + self.mongo_id = asset_doc.get("_id") + self._project_id = None + self._edited_columns = { + column_name: False + for column_name in self.editable_columns + } + + # Item data + self._hierarchy_changes_enabled = True + self._removed = False + + # Task children duplication variables + self._task_items_by_name = collections.defaultdict(list) + self._task_name_by_item_id = {} + self._duplicated_task_names = set() + + # Copy of original document + self._origin_asset_doc = copy.deepcopy(asset_doc) + + data = self.data_from_doc(asset_doc) + + self._origin_data = copy.deepcopy(data) + + super(AssetItem, self).__init__(data) + + @property + def project_id(self): + if self._project_id is None: + self._project_id = self.parent().project_id + return self._project_id + + @property + def asset_id(self): + return self.mongo_id + + @property + def is_new(self): + return self.asset_id is None + + @property + def is_valid(self): + if self._is_duplicated or not self._data["name"]: + return False + return True + + @property + def name(self): + return self._data["name"] + + def child_parents(self): + parents = self.parent().child_parents() + parents.append(self.name) + return parents + + def to_doc(self): + tasks = {} + for item in self.children(): + if isinstance(item, TaskItem): + tasks.update(item.to_doc_data()) + + doc_data = { + "parents": self.parent().child_parents(), + "visualParent": self.parent().asset_id, + "tasks": tasks + } + schema_name = ( + self._origin_asset_doc.get("schema") + or CURRENT_DOC_SCHEMAS["asset"] + ) + + doc = { + "name": self.data(QtCore.Qt.EditRole, "name"), + "type": self.data(QtCore.Qt.EditRole, "type"), + "schema": schema_name, + "data": doc_data, + "parent": self.project_id + } + if self.mongo_id: + doc["_id"] = self.mongo_id + + for key in self._data.keys(): + if key in doc: + continue + # Use `data` method to get inherited values + doc_data[key] = self.data(QtCore.Qt.EditRole, key) + + return doc + + def update_data(self): + if not self.mongo_id: + return {} + + document = self.to_doc() + + changes = {} + + for key, value in document.items(): + if key in ("data", "_id"): + continue + + if ( + key in self._origin_asset_doc + and self._origin_asset_doc[key] == value + ): + continue + + changes[key] = value + + if "data" not in self._origin_asset_doc: + changes["data"] = document["data"] + else: + origin_data = self._origin_asset_doc["data"] + + for key, value in document["data"].items(): + if key in origin_data and origin_data[key] == value: + continue + _key = "data.{}".format(key) + changes[_key] = value + + if changes: + return {"$set": changes} + return {} + + @classmethod + def data_from_doc(cls, asset_doc): + data = { + "name": None, + "type": "asset" + } + if asset_doc: + for key in data.keys(): + if key in asset_doc: + data[key] = asset_doc[key] + + doc_data = asset_doc.get("data") or {} + for key in cls.columns: + if key in data: + continue + + data[key] = doc_data.get(key) + + return data + + def name_icon(self): + if self.__class__._name_icons is None: + self.__class__._name_icons = ResourceCache.get_icons()["asset"] + + if self._removed: + icon_type = "removed" + elif not self.is_valid: + icon_type = "invalid" + elif self.is_new: + icon_type = "new" + else: + icon_type = "default" + return self.__class__._name_icons[icon_type] + + def _get_global_data(self, role): + if role == HIERARCHY_CHANGE_ABLE_ROLE: + return self._hierarchy_changes_enabled + + if role == REMOVED_ROLE: + return self._removed + + if role == QtCore.Qt.ToolTipRole: + name = self.data(QtCore.Qt.EditRole, "name") + if not name: + return "Name is not set" + + elif self._is_duplicated: + return "Duplicated asset name \"{}\"".format(name) + return None + + return super(AssetItem, self)._get_global_data(role) + + def data(self, role, key=None): + if role == EDITOR_OPENED_ROLE: + if key not in self._edited_columns: + return False + return self._edited_columns[key] + + if role == QtCore.Qt.DisplayRole and self._edited_columns.get(key): + return "" + + return super(AssetItem, self).data(role, key) + + def setData(self, value, role, key=None): + if role == EDITOR_OPENED_ROLE: + if key not in self._edited_columns: + return False + self._edited_columns[key] = value + return True + + if role == REMOVED_ROLE: + self._removed = value + return True + + if role == HIERARCHY_CHANGE_ABLE_ROLE: + if self._hierarchy_changes_enabled == value: + return False + self._hierarchy_changes_enabled = value + return True + + if ( + role == QtCore.Qt.EditRole + and key == "name" + and not self._hierarchy_changes_enabled + ): + return False + return super(AssetItem, self).setData(value, role, key) + + def flags(self, key): + if key == "name": + flags = QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable + if self._hierarchy_changes_enabled: + flags |= QtCore.Qt.ItemIsEditable + return flags + return super(AssetItem, self).flags(key) + + def _add_task(self, item): + name = item.data(QtCore.Qt.EditRole, "name").lower() + item_id = item.data(IDENTIFIER_ROLE) + + self._task_name_by_item_id[item_id] = name + self._task_items_by_name[name].append(item) + if len(self._task_items_by_name[name]) > 1: + self._duplicated_task_names.add(name) + for _item in self._task_items_by_name[name]: + _item.setData(True, DUPLICATED_ROLE) + elif item.data(DUPLICATED_ROLE): + item.setData(False, DUPLICATED_ROLE) + + def _remove_task(self, item): + item_id = item.data(IDENTIFIER_ROLE) + + name = self._task_name_by_item_id.pop(item_id) + self._task_items_by_name[name].remove(item) + if not self._task_items_by_name[name]: + self._task_items_by_name.pop(name) + + elif len(self._task_items_by_name[name]) == 1: + self._duplicated_task_names.remove(name) + for _item in self._task_items_by_name[name]: + _item.setData(False, DUPLICATED_ROLE) + + def _rename_task(self, item): + new_name = item.data(QtCore.Qt.EditRole, "name").lower() + item_id = item.data(IDENTIFIER_ROLE) + prev_name = self._task_name_by_item_id[item_id] + if new_name == prev_name: + return + + # Remove from previous name mapping + self._task_items_by_name[prev_name].remove(item) + if not self._task_items_by_name[prev_name]: + self._task_items_by_name.pop(prev_name) + + elif len(self._task_items_by_name[prev_name]) == 1: + self._duplicated_task_names.remove(prev_name) + for _item in self._task_items_by_name[prev_name]: + _item.setData(False, DUPLICATED_ROLE) + + # Add to new name mapping + self._task_items_by_name[new_name].append(item) + if len(self._task_items_by_name[new_name]) > 1: + self._duplicated_task_names.add(new_name) + for _item in self._task_items_by_name[new_name]: + _item.setData(True, DUPLICATED_ROLE) + else: + item.setData(False, DUPLICATED_ROLE) + + self._task_name_by_item_id[item_id] = new_name + + def on_task_name_change(self, task_item): + self._rename_task(task_item) + + def add_child(self, item, row=None): + if item in self._children: + return + + super(AssetItem, self).add_child(item, row) + + if isinstance(item, TaskItem): + self._add_task(item) + + def remove_child(self, item): + if item not in self._children: + return + + if isinstance(item, TaskItem): + self._remove_task(item) + + super(AssetItem, self).remove_child(item) + + +class TaskItem(BaseItem): + item_type = "task" + + columns = { + "name", + "type" + } + editable_columns = { + "name", + "type" + } + + def __init__(self, data=None, is_new=None): + self._removed = False + if is_new is None: + is_new = data is None + self._is_new = is_new + if data is None: + data = {} + + self._edited_columns = { + column_name: False + for column_name in self.editable_columns + } + self._origin_data = copy.deepcopy(data) + super(TaskItem, self).__init__(data) + + @property + def is_new(self): + return self._is_new + + @property + def is_valid(self): + if self._is_duplicated or not self._data["type"]: + return False + if not self.data(QtCore.Qt.EditRole, "name"): + return False + return True + + def name_icon(self): + if self.__class__._name_icons is None: + self.__class__._name_icons = ResourceCache.get_icons()["task"] + + if self._removed: + icon_type = "removed" + elif not self.is_valid: + icon_type = "invalid" + elif self.is_new: + icon_type = "new" + else: + icon_type = "default" + return self.__class__._name_icons[icon_type] + + def add_child(self, item, row=None): + raise AssertionError("BUG: Can't add children to Task") + + def _get_global_data(self, role): + if role == REMOVED_ROLE: + return self._removed + + if role == QtCore.Qt.ToolTipRole: + if not self._data["type"]: + return "Type is not set" + + name = self.data(QtCore.Qt.EditRole, "name") + if not name: + return "Name is not set" + + elif self._is_duplicated: + return "Duplicated task name \"{}".format(name) + return None + + return super(TaskItem, self)._get_global_data(role) + + def to_doc_data(self): + if self._removed: + return {} + data = copy.deepcopy(self._data) + data.pop("name") + name = self.data(QtCore.Qt.EditRole, "name") + return { + name: data + } + + def data(self, role, key=None): + if role == EDITOR_OPENED_ROLE: + if key not in self._edited_columns: + return False + return self._edited_columns[key] + + if role == QtCore.Qt.DisplayRole and self._edited_columns.get(key): + return "" + + if role in (QtCore.Qt.DisplayRole, QtCore.Qt.EditRole): + if key == "type": + return self._data["type"] + + if key == "name": + if not self._data["type"]: + if role == QtCore.Qt.DisplayRole: + return "< Select Type >" + if role == QtCore.Qt.EditRole: + return "" + else: + return self._data[key] or self._data["type"] + + return super(TaskItem, self).data(role, key) + + def setData(self, value, role, key=None): + if role == EDITOR_OPENED_ROLE: + if key not in self._edited_columns: + return False + self._edited_columns[key] = value + return True + + if role == REMOVED_ROLE: + self._removed = value + return True + + if ( + role == QtCore.Qt.EditRole + and key == "name" + and not value + ): + value = None + + result = super(TaskItem, self).setData(value, role, key) + + if role == QtCore.Qt.EditRole: + if ( + key == "name" + or (key == "type" and not self._data["name"]) + ): + self.parent().on_task_name_change(self) + + return result + + def to_json_data(self): + """Convert json data without parent reference. + + Method used for mime data on copy/paste + """ + return self.to_doc_data() diff --git a/openpype/tools/project_manager/project_manager/multiselection_combobox.py b/openpype/tools/project_manager/project_manager/multiselection_combobox.py new file mode 100644 index 0000000000..b26976d3c6 --- /dev/null +++ b/openpype/tools/project_manager/project_manager/multiselection_combobox.py @@ -0,0 +1,215 @@ +from Qt import QtCore, QtGui, QtWidgets + + +class ComboItemDelegate(QtWidgets.QStyledItemDelegate): + """ + Helper styled delegate (mostly based on existing private Qt's + delegate used by the QtWidgets.QComboBox). Used to style the popup like a + list view (e.g windows style). + """ + + def paint(self, painter, option, index): + option = QtWidgets.QStyleOptionViewItem(option) + option.showDecorationSelected = True + + # option.state &= ( + # ~QtWidgets.QStyle.State_HasFocus + # & ~QtWidgets.QStyle.State_MouseOver + # ) + super(ComboItemDelegate, self).paint(painter, option, index) + + +class MultiSelectionComboBox(QtWidgets.QComboBox): + value_changed = QtCore.Signal() + ignored_keys = { + QtCore.Qt.Key_Up, + QtCore.Qt.Key_Down, + QtCore.Qt.Key_PageDown, + QtCore.Qt.Key_PageUp, + QtCore.Qt.Key_Home, + QtCore.Qt.Key_End + } + + def __init__(self, parent=None, **kwargs): + super(MultiSelectionComboBox, self).__init__(parent=parent, **kwargs) + self.setObjectName("MultiSelectionComboBox") + self.setFocusPolicy(QtCore.Qt.StrongFocus) + + self._popup_is_shown = False + self._block_mouse_release_timer = QtCore.QTimer(self, singleShot=True) + self._initial_mouse_pos = None + self._delegate = ComboItemDelegate(self) + self.setItemDelegate(self._delegate) + + def mousePressEvent(self, event): + """Reimplemented.""" + self._popup_is_shown = False + super(MultiSelectionComboBox, self).mousePressEvent(event) + if self._popup_is_shown: + self._initial_mouse_pos = self.mapToGlobal(event.pos()) + self._block_mouse_release_timer.start( + QtWidgets.QApplication.doubleClickInterval() + ) + + def showPopup(self): + """Reimplemented.""" + super(MultiSelectionComboBox, self).showPopup() + view = self.view() + view.installEventFilter(self) + view.viewport().installEventFilter(self) + self._popup_is_shown = True + + def hidePopup(self): + """Reimplemented.""" + self.view().removeEventFilter(self) + self.view().viewport().removeEventFilter(self) + self._popup_is_shown = False + self._initial_mouse_pos = None + super(MultiSelectionComboBox, self).hidePopup() + self.view().clearFocus() + + def _event_popup_shown(self, obj, event): + if not self._popup_is_shown: + return + + current_index = self.view().currentIndex() + model = self.model() + + if event.type() == QtCore.QEvent.MouseMove: + if ( + self.view().isVisible() + and self._initial_mouse_pos is not None + and self._block_mouse_release_timer.isActive() + ): + diff = obj.mapToGlobal(event.pos()) - self._initial_mouse_pos + if diff.manhattanLength() > 9: + self._block_mouse_release_timer.stop() + return + + index_flags = current_index.flags() + state = current_index.data(QtCore.Qt.CheckStateRole) + new_state = None + + if event.type() == QtCore.QEvent.MouseButtonRelease: + if ( + self._block_mouse_release_timer.isActive() + or not current_index.isValid() + or not self.view().isVisible() + or not self.view().rect().contains(event.pos()) + or not index_flags & QtCore.Qt.ItemIsSelectable + or not index_flags & QtCore.Qt.ItemIsEnabled + or not index_flags & QtCore.Qt.ItemIsUserCheckable + ): + return + + if state == QtCore.Qt.Unchecked: + new_state = QtCore.Qt.Checked + else: + new_state = QtCore.Qt.Unchecked + + elif event.type() == QtCore.QEvent.KeyPress: + # TODO: handle QtCore.Qt.Key_Enter, Key_Return? + if event.key() == QtCore.Qt.Key_Space: + # toogle the current items check state + if ( + index_flags & QtCore.Qt.ItemIsUserCheckable + and index_flags & QtCore.Qt.ItemIsTristate + ): + new_state = QtCore.Qt.CheckState((int(state) + 1) % 3) + + elif index_flags & QtCore.Qt.ItemIsUserCheckable: + if state != QtCore.Qt.Checked: + new_state = QtCore.Qt.Checked + else: + new_state = QtCore.Qt.Unchecked + + if new_state is not None: + model.setData(current_index, new_state, QtCore.Qt.CheckStateRole) + self.view().update(current_index) + self.value_changed.emit() + return True + + def eventFilter(self, obj, event): + """Reimplemented.""" + result = self._event_popup_shown(obj, event) + if result is not None: + return result + + return super(MultiSelectionComboBox, self).eventFilter(obj, event) + + def addItem(self, *args, **kwargs): + idx = self.count() + super(MultiSelectionComboBox, self).addItem(*args, **kwargs) + self.model().item(idx).setCheckable(True) + + def paintEvent(self, event): + """Reimplemented.""" + painter = QtWidgets.QStylePainter(self) + option = QtWidgets.QStyleOptionComboBox() + self.initStyleOption(option) + painter.drawComplexControl(QtWidgets.QStyle.CC_ComboBox, option) + + # draw the icon and text + items = self.checked_items_text() + if not items: + return + + text_rect = self.style().subControlRect( + QtWidgets.QStyle.CC_ComboBox, + option, + QtWidgets.QStyle.SC_ComboBoxEditField + ) + text = ", ".join(items) + new_text = self.fontMetrics().elidedText( + text, QtCore.Qt.ElideRight, text_rect.width() + ) + painter.drawText( + text_rect, + QtCore.Qt.AlignLeft | QtCore.Qt.AlignVCenter, + new_text + ) + + def setItemCheckState(self, index, state): + self.setItemData(index, state, QtCore.Qt.CheckStateRole) + + def set_value(self, values): + for idx in range(self.count()): + value = self.itemData(idx, role=QtCore.Qt.UserRole) + if value in values: + check_state = QtCore.Qt.Checked + else: + check_state = QtCore.Qt.Unchecked + self.setItemData(idx, check_state, QtCore.Qt.CheckStateRole) + + def value(self): + items = list() + for idx in range(self.count()): + state = self.itemData(idx, role=QtCore.Qt.CheckStateRole) + if state == QtCore.Qt.Checked: + items.append( + self.itemData(idx, role=QtCore.Qt.UserRole) + ) + return items + + def checked_items_text(self): + items = list() + for idx in range(self.count()): + state = self.itemData(idx, role=QtCore.Qt.CheckStateRole) + if state == QtCore.Qt.Checked: + items.append(self.itemText(idx)) + return items + + def wheelEvent(self, event): + event.ignore() + + def keyPressEvent(self, event): + if ( + event.key() == QtCore.Qt.Key_Down + and event.modifiers() & QtCore.Qt.AltModifier + ): + return self.showPopup() + + if event.key() in self.ignored_keys: + return event.ignore() + + return super(MultiSelectionComboBox, self).keyPressEvent(event) diff --git a/openpype/tools/project_manager/project_manager/style/__init__.py b/openpype/tools/project_manager/project_manager/style/__init__.py new file mode 100644 index 0000000000..b686967ddd --- /dev/null +++ b/openpype/tools/project_manager/project_manager/style/__init__.py @@ -0,0 +1,98 @@ +import os +from openpype import resources +from avalon.vendor import qtawesome + + +class ResourceCache: + colors = { + "standard": "#333333", + "new": "#2d9a4c", + "warning": "#c83232" + } + icons = None + + @classmethod + def get_icon(cls, *keys): + output = cls.get_icons() + for key in keys: + output = output[key] + return output + + @classmethod + def get_icons(cls): + if cls.icons is None: + cls.icons = { + "asset": { + "default": qtawesome.icon( + "fa.folder", + color=cls.colors["standard"] + ), + "new": qtawesome.icon( + "fa.folder", + color=cls.colors["new"] + ), + "invalid": qtawesome.icon( + "fa.exclamation-triangle", + color=cls.colors["warning"] + ), + "removed": qtawesome.icon( + "fa.trash", + color=cls.colors["warning"] + ) + }, + "task": { + "default": qtawesome.icon( + "fa.check-circle-o", + color=cls.colors["standard"] + ), + "new": qtawesome.icon( + "fa.check-circle", + color=cls.colors["new"] + ), + "invalid": qtawesome.icon( + "fa.exclamation-circle", + color=cls.colors["warning"] + ), + "removed": qtawesome.icon( + "fa.trash", + color=cls.colors["warning"] + ) + }, + "refresh": qtawesome.icon( + "fa.refresh", + color=cls.colors["standard"] + ) + } + return cls.icons + + @classmethod + def get_color(cls, color_name): + return cls.colors[color_name] + + @classmethod + def style_fill_data(cls): + output = {} + for color_name, color_value in cls.colors.items(): + key = "color:{}".format(color_name) + output[key] = color_value + return output + + +def load_stylesheet(): + from . import qrc_resources + + qrc_resources.qInitResources() + + current_dir = os.path.dirname(os.path.abspath(__file__)) + style_path = os.path.join(current_dir, "style.css") + with open(style_path, "r") as style_file: + stylesheet = style_file.read() + + for key, value in ResourceCache.style_fill_data().items(): + replacement_key = "{" + key + "}" + stylesheet = stylesheet.replace(replacement_key, value) + return stylesheet + + +def app_icon_path(): + return resources.pype_icon_filepath() diff --git a/openpype/tools/project_manager/project_manager/style/images/combobox_arrow.png b/openpype/tools/project_manager/project_manager/style/images/combobox_arrow.png new file mode 100644 index 0000000000..5805d9842b Binary files /dev/null and b/openpype/tools/project_manager/project_manager/style/images/combobox_arrow.png differ diff --git a/openpype/tools/project_manager/project_manager/style/images/combobox_arrow_disabled.png b/openpype/tools/project_manager/project_manager/style/images/combobox_arrow_disabled.png new file mode 100644 index 0000000000..e271f7f90b Binary files /dev/null and b/openpype/tools/project_manager/project_manager/style/images/combobox_arrow_disabled.png differ diff --git a/openpype/tools/project_manager/project_manager/style/pyqt5_resources.py b/openpype/tools/project_manager/project_manager/style/pyqt5_resources.py new file mode 100644 index 0000000000..836934019d --- /dev/null +++ b/openpype/tools/project_manager/project_manager/style/pyqt5_resources.py @@ -0,0 +1,105 @@ +# -*- coding: utf-8 -*- + +# Resource object code +# +# Created by: The Resource Compiler for PyQt5 (Qt v5.15.2) +# +# WARNING! All changes made in this file will be lost! + +from PyQt5 import QtCore + + +qt_resource_data = b"\ +\x00\x00\x00\xa5\ +\x89\ +\x50\x4e\x47\x0d\x0a\x1a\x0a\x00\x00\x00\x0d\x49\x48\x44\x52\x00\ +\x00\x00\x09\x00\x00\x00\x06\x08\x04\x00\x00\x00\xbb\xce\x7c\x4e\ +\x00\x00\x00\x01\x73\x52\x47\x42\x00\xae\xce\x1c\xe9\x00\x00\x00\ +\x02\x62\x4b\x47\x44\x00\x9c\x53\x34\xfc\x5d\x00\x00\x00\x09\x70\ +\x48\x59\x73\x00\x00\x0b\x13\x00\x00\x0b\x13\x01\x00\x9a\x9c\x18\ +\x00\x00\x00\x07\x74\x49\x4d\x45\x07\xdc\x08\x17\x0b\x02\x04\x6d\ +\x98\x1b\x69\x00\x00\x00\x29\x49\x44\x41\x54\x08\xd7\x63\x60\xc0\ +\x00\x8c\x0c\x0c\xff\xcf\xa3\x08\x18\x32\x32\x30\x20\x0b\x32\x1a\ +\x32\x30\x30\x42\x98\x10\x41\x46\x43\x14\x13\x50\xb5\xa3\x01\x00\ +\xd6\x10\x07\xd2\x2f\x48\xdf\x4a\x00\x00\x00\x00\x49\x45\x4e\x44\ +\xae\x42\x60\x82\ +\x00\x00\x00\xa6\ +\x89\ +\x50\x4e\x47\x0d\x0a\x1a\x0a\x00\x00\x00\x0d\x49\x48\x44\x52\x00\ +\x00\x00\x09\x00\x00\x00\x06\x08\x04\x00\x00\x00\xbb\xce\x7c\x4e\ +\x00\x00\x00\x01\x73\x52\x47\x42\x00\xae\xce\x1c\xe9\x00\x00\x00\ +\x02\x62\x4b\x47\x44\x00\xff\x87\x8f\xcc\xbf\x00\x00\x00\x09\x70\ +\x48\x59\x73\x00\x00\x0b\x13\x00\x00\x0b\x13\x01\x00\x9a\x9c\x18\ +\x00\x00\x00\x07\x74\x49\x4d\x45\x07\xdc\x08\x17\x08\x15\x3b\xdc\ +\x3b\x0c\x9b\x00\x00\x00\x2a\x49\x44\x41\x54\x08\xd7\x63\x60\xc0\ +\x00\x8c\x0c\x0c\x73\x3e\x20\x0b\xa4\x08\x30\x32\x30\x20\x0b\xa6\ +\x08\x30\x30\x30\x42\x98\x10\xc1\x14\x01\x14\x13\x50\xb5\xa3\x01\ +\x00\xc6\xb9\x07\x90\x5d\x66\x1f\x83\x00\x00\x00\x00\x49\x45\x4e\ +\x44\xae\x42\x60\x82\ +" + + +qt_resource_name = b"\ +\x00\x08\ +\x06\xc5\x8e\xa5\ +\x00\x6f\ +\x00\x70\x00\x65\x00\x6e\x00\x70\x00\x79\x00\x70\x00\x65\ +\x00\x06\ +\x07\x03\x7d\xc3\ +\x00\x69\ +\x00\x6d\x00\x61\x00\x67\x00\x65\x00\x73\ +\x00\x12\ +\x01\x2e\x03\x27\ +\x00\x63\ +\x00\x6f\x00\x6d\x00\x62\x00\x6f\x00\x62\x00\x6f\x00\x78\x00\x5f\x00\x61\x00\x72\x00\x72\x00\x6f\x00\x77\x00\x2e\x00\x70\x00\x6e\ +\x00\x67\ +\x00\x1b\ +\x03\x5a\x32\x27\ +\x00\x63\ +\x00\x6f\x00\x6d\x00\x62\x00\x6f\x00\x62\x00\x6f\x00\x78\x00\x5f\x00\x61\x00\x72\x00\x72\x00\x6f\x00\x77\x00\x5f\x00\x64\x00\x69\ +\x00\x73\x00\x61\x00\x62\x00\x6c\x00\x65\x00\x64\x00\x2e\x00\x70\x00\x6e\x00\x67\ +" + + +qt_resource_struct_v1 = b"\ +\x00\x00\x00\x00\x00\x02\x00\x00\x00\x01\x00\x00\x00\x01\ +\x00\x00\x00\x00\x00\x02\x00\x00\x00\x01\x00\x00\x00\x02\ +\x00\x00\x00\x16\x00\x02\x00\x00\x00\x02\x00\x00\x00\x03\ +\x00\x00\x00\x28\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\ +\x00\x00\x00\x52\x00\x00\x00\x00\x00\x01\x00\x00\x00\xa9\ +" + + +qt_resource_struct_v2 = b"\ +\x00\x00\x00\x00\x00\x02\x00\x00\x00\x01\x00\x00\x00\x01\ +\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x02\x00\x00\x00\x01\x00\x00\x00\x02\ +\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x16\x00\x02\x00\x00\x00\x02\x00\x00\x00\x03\ +\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x28\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\ +\x00\x00\x01\x76\x41\x9d\xa2\x35\ +\x00\x00\x00\x52\x00\x00\x00\x00\x00\x01\x00\x00\x00\xa9\ +\x00\x00\x01\x76\x41\x9d\xa2\x35\ +" + + +qt_version = [int(v) for v in QtCore.qVersion().split('.')] +if qt_version < [5, 8, 0]: + rcc_version = 1 + qt_resource_struct = qt_resource_struct_v1 +else: + rcc_version = 2 + qt_resource_struct = qt_resource_struct_v2 + + +def qInitResources(): + QtCore.qRegisterResourceData( + rcc_version, qt_resource_struct, qt_resource_name, qt_resource_data + ) + + +def qCleanupResources(): + QtCore.qUnregisterResourceData( + rcc_version, qt_resource_struct, qt_resource_name, qt_resource_data + ) diff --git a/openpype/tools/project_manager/project_manager/style/pyside2_resources.py b/openpype/tools/project_manager/project_manager/style/pyside2_resources.py new file mode 100644 index 0000000000..b73d5e334a --- /dev/null +++ b/openpype/tools/project_manager/project_manager/style/pyside2_resources.py @@ -0,0 +1,84 @@ +# Resource object code (Python 3) +# Created by: object code +# Created by: The Resource Compiler for Qt version 5.15.2 +# WARNING! All changes made in this file will be lost! + +from PySide2 import QtCore + + +qt_resource_data = b"\ +\x00\x00\x00\xa5\ +\x89\ +PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0dIHDR\x00\ +\x00\x00\x09\x00\x00\x00\x06\x08\x04\x00\x00\x00\xbb\xce|N\ +\x00\x00\x00\x01sRGB\x00\xae\xce\x1c\xe9\x00\x00\x00\ +\x02bKGD\x00\x9cS4\xfc]\x00\x00\x00\x09p\ +HYs\x00\x00\x0b\x13\x00\x00\x0b\x13\x01\x00\x9a\x9c\x18\ +\x00\x00\x00\x07tIME\x07\xdc\x08\x17\x0b\x02\x04m\ +\x98\x1bi\x00\x00\x00)IDAT\x08\xd7c`\xc0\ +\x00\x8c\x0c\x0c\xff\xcf\xa3\x08\x18220 \x0b2\x1a\ +200B\x98\x10AFC\x14\x13P\xb5\xa3\x01\x00\ +\xd6\x10\x07\xd2/H\xdfJ\x00\x00\x00\x00IEND\ +\xaeB`\x82\ +\x00\x00\x00\xa6\ +\x89\ +PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0dIHDR\x00\ +\x00\x00\x09\x00\x00\x00\x06\x08\x04\x00\x00\x00\xbb\xce|N\ +\x00\x00\x00\x01sRGB\x00\xae\xce\x1c\xe9\x00\x00\x00\ +\x02bKGD\x00\xff\x87\x8f\xcc\xbf\x00\x00\x00\x09p\ +HYs\x00\x00\x0b\x13\x00\x00\x0b\x13\x01\x00\x9a\x9c\x18\ +\x00\x00\x00\x07tIME\x07\xdc\x08\x17\x08\x15;\xdc\ +;\x0c\x9b\x00\x00\x00*IDAT\x08\xd7c`\xc0\ +\x00\x8c\x0c\x0cs> \x0b\xa4\x08020 \x0b\xa6\ +\x08000B\x98\x10\xc1\x14\x01\x14\x13P\xb5\xa3\x01\ +\x00\xc6\xb9\x07\x90]f\x1f\x83\x00\x00\x00\x00IEN\ +D\xaeB`\x82\ +" + + +qt_resource_name = b"\ +\x00\x08\ +\x06\xc5\x8e\xa5\ +\x00o\ +\x00p\x00e\x00n\x00p\x00y\x00p\x00e\ +\x00\x06\ +\x07\x03}\xc3\ +\x00i\ +\x00m\x00a\x00g\x00e\x00s\ +\x00\x12\ +\x01.\x03'\ +\x00c\ +\x00o\x00m\x00b\x00o\x00b\x00o\x00x\x00_\x00a\x00r\x00r\x00o\x00w\x00.\x00p\x00n\ +\x00g\ +\x00\x1b\ +\x03Z2'\ +\x00c\ +\x00o\x00m\x00b\x00o\x00b\x00o\x00x\x00_\x00a\x00r\x00r\x00o\x00w\x00_\x00d\x00i\ +\x00s\x00a\x00b\x00l\x00e\x00d\x00.\x00p\x00n\x00g\ +" + + +qt_resource_struct = b"\ +\x00\x00\x00\x00\x00\x02\x00\x00\x00\x01\x00\x00\x00\x01\ +\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x02\x00\x00\x00\x01\x00\x00\x00\x02\ +\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x16\x00\x02\x00\x00\x00\x02\x00\x00\x00\x03\ +\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00(\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\ +\x00\x00\x01vA\x9d\xa25\ +\x00\x00\x00R\x00\x00\x00\x00\x00\x01\x00\x00\x00\xa9\ +\x00\x00\x01vA\x9d\xa25\ +" + + +def qInitResources(): + QtCore.qRegisterResourceData( + 0x03, qt_resource_struct, qt_resource_name, qt_resource_data + ) + + +def qCleanupResources(): + QtCore.qUnregisterResourceData( + 0x03, qt_resource_struct, qt_resource_name, qt_resource_data + ) diff --git a/openpype/tools/project_manager/project_manager/style/qrc_resources.py b/openpype/tools/project_manager/project_manager/style/qrc_resources.py new file mode 100644 index 0000000000..a9e219c9ad --- /dev/null +++ b/openpype/tools/project_manager/project_manager/style/qrc_resources.py @@ -0,0 +1,32 @@ +import Qt + + +initialized = False +resources = None +if Qt.__binding__ == "PySide2": + from . import pyside2_resources as resources +elif Qt.__binding__ == "PyQt5": + from . import pyqt5_resources as resources + + +def qInitResources(): + global resources + global initialized + if resources is not None and not initialized: + initialized = True + resources.qInitResources() + + +def qCleanupResources(): + global resources + global initialized + if resources is not None: + initialized = False + resources.qCleanupResources() + + +__all__ = ( + "resources", + "qInitResources", + "qCleanupResources" +) diff --git a/openpype/tools/project_manager/project_manager/style/resources.qrc b/openpype/tools/project_manager/project_manager/style/resources.qrc new file mode 100644 index 0000000000..9281c69479 --- /dev/null +++ b/openpype/tools/project_manager/project_manager/style/resources.qrc @@ -0,0 +1,6 @@ + + + images/combobox_arrow.png + images/combobox_arrow_disabled.png + + diff --git a/openpype/tools/project_manager/project_manager/style/style.css b/openpype/tools/project_manager/project_manager/style/style.css new file mode 100644 index 0000000000..31196b7cc6 --- /dev/null +++ b/openpype/tools/project_manager/project_manager/style/style.css @@ -0,0 +1,21 @@ +QTreeView::item { + padding-top: 3px; + padding-bottom: 3px; + padding-right: 3px; +} + + +QTreeView::item:selected, QTreeView::item:selected:!active { + background: rgba(0, 122, 204, 127); + color: black; +} + +#RefreshBtn { + padding: 2px; +} + +#TypeEditor, #ToolEditor, #NameEditor, #NumberEditor { + background: transparent; + border: 1px solid #005c99; + border-radius: 0.3em; +} diff --git a/openpype/tools/project_manager/project_manager/view.py b/openpype/tools/project_manager/project_manager/view.py new file mode 100644 index 0000000000..70af11e68d --- /dev/null +++ b/openpype/tools/project_manager/project_manager/view.py @@ -0,0 +1,643 @@ +import collections +from queue import Queue + +from Qt import QtWidgets, QtCore, QtGui + +from .delegates import ( + NumberDelegate, + NameDelegate, + TypeDelegate, + ToolsDelegate +) + +from openpype.lib import ApplicationManager +from .constants import ( + REMOVED_ROLE, + IDENTIFIER_ROLE, + ITEM_TYPE_ROLE, + HIERARCHY_CHANGE_ABLE_ROLE, + EDITOR_OPENED_ROLE +) + + +class NameDef: + pass + + +class NumberDef: + def __init__(self, minimum=None, maximum=None, decimals=None): + self.minimum = 0 if minimum is None else minimum + self.maximum = 999999 if maximum is None else maximum + self.decimals = 0 if decimals is None else decimals + + +class TypeDef: + pass + + +class ToolsDef: + pass + + +class ProjectDocCache: + def __init__(self, dbcon): + self.dbcon = dbcon + self.project_doc = None + + def set_project(self, project_name): + self.project_doc = None + + if not project_name: + return + + self.project_doc = self.dbcon.database[project_name].find_one( + {"type": "project"} + ) + + +class ToolsCache: + def __init__(self): + self.tools_data = [] + + def refresh(self): + app_manager = ApplicationManager() + tools_data = [] + for tool_name, tool in app_manager.tools.items(): + tools_data.append( + (tool_name, tool.label) + ) + self.tools_data = tools_data + + +class HierarchyView(QtWidgets.QTreeView): + """A tree view that deselects on clicking on an empty area in the view""" + column_delegate_defs = { + "name": NameDef(), + "type": TypeDef(), + "frameStart": NumberDef(1), + "frameEnd": NumberDef(1), + "fps": NumberDef(1, decimals=2), + "resolutionWidth": NumberDef(0), + "resolutionHeight": NumberDef(0), + "handleStart": NumberDef(0), + "handleEnd": NumberDef(0), + "clipIn": NumberDef(1), + "clipOut": NumberDef(1), + "pixelAspect": NumberDef(0, decimals=2), + "tools_env": ToolsDef() + } + + columns_sizes = { + "default": { + "stretch": QtWidgets.QHeaderView.ResizeToContents + }, + "name": { + "stretch": QtWidgets.QHeaderView.Stretch + }, + "type": { + "stretch": QtWidgets.QHeaderView.Interactive, + "width": 100 + }, + "tools_env": { + "stretch": QtWidgets.QHeaderView.Interactive, + "width": 140 + }, + "pixelAspect": { + "stretch": QtWidgets.QHeaderView.Interactive, + "width": 80 + } + } + persistent_columns = { + "type", + "frameStart", + "frameEnd", + "fps", + "resolutionWidth", + "resolutionHeight", + "handleStart", + "handleEnd", + "clipIn", + "clipOut", + "pixelAspect", + "tools_env" + } + + def __init__(self, dbcon, source_model, parent): + super(HierarchyView, self).__init__(parent) + # Direct access to model + self._source_model = source_model + self._editors_mapping = {} + self._persisten_editors = set() + # Access to parent because of `show_message` method + self._parent = parent + + project_doc_cache = ProjectDocCache(dbcon) + tools_cache = ToolsCache() + + main_delegate = QtWidgets.QStyledItemDelegate() + self.setItemDelegate(main_delegate) + self.setAlternatingRowColors(True) + self.setSelectionMode(HierarchyView.ExtendedSelection) + self.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) + + column_delegates = {} + column_key_to_index = {} + for key, item_type in self.column_delegate_defs.items(): + if isinstance(item_type, NameDef): + delegate = NameDelegate() + + elif isinstance(item_type, NumberDef): + delegate = NumberDelegate( + item_type.minimum, + item_type.maximum, + item_type.decimals + ) + + elif isinstance(item_type, TypeDef): + delegate = TypeDelegate(project_doc_cache) + + elif isinstance(item_type, ToolsDef): + delegate = ToolsDelegate(tools_cache) + + column = self._source_model.columns.index(key) + self.setItemDelegateForColumn(column, delegate) + column_delegates[key] = delegate + column_key_to_index[key] = column + + source_model.index_moved.connect(self._on_rows_moved) + self.customContextMenuRequested.connect(self._on_context_menu) + self._source_model.project_changed.connect(self._on_project_reset) + + self._project_doc_cache = project_doc_cache + self._tools_cache = tools_cache + + self._delegate = main_delegate + self._column_delegates = column_delegates + self._column_key_to_index = column_key_to_index + + def header_init(self): + header = self.header() + header.setStretchLastSection(False) + + default_behavior = self.columns_sizes["default"] + widths_by_idx = {} + for idx in range(header.count()): + key = self._source_model.columns[idx] + behavior = self.columns_sizes.get(key, default_behavior) + logical_index = header.logicalIndex(idx) + stretch = behavior["stretch"] + header.setSectionResizeMode(logical_index, stretch) + width = behavior.get("width") + if width is not None: + widths_by_idx[idx] = width + + for idx, width in widths_by_idx.items(): + self.setColumnWidth(idx, width) + + def set_project(self, project_name): + # Trigger helpers first + self._project_doc_cache.set_project(project_name) + self._tools_cache.refresh() + + # Trigger update of model after all data for delegates are filled + self._source_model.set_project(project_name) + + def _on_project_reset(self): + self.header_init() + + self.collapseAll() + + project_item = self._source_model.project_item + if project_item: + index = self._source_model.index_for_item(project_item) + self.expand(index) + + def _on_rows_moved(self, index): + parent_index = index.parent() + if not self.isExpanded(parent_index): + self.expand(parent_index) + + def commitData(self, editor): + super(HierarchyView, self).commitData(editor) + current_index = self.currentIndex() + column = current_index.column() + row = current_index.row() + skipped_index = None + # Change column from "type" to "name" + if column == 1: + new_index = self._source_model.index( + current_index.row(), + 0, + current_index.parent() + ) + self.setCurrentIndex(new_index) + elif column > 0: + indexes = [] + for index in self.selectedIndexes(): + if index.column() == column: + if index.row() == row: + skipped_index = index + else: + indexes.append(index) + + if skipped_index is not None: + value = current_index.data(QtCore.Qt.EditRole) + for index in indexes: + index.model().setData(index, value, QtCore.Qt.EditRole) + + # Update children data + self.updateEditorData() + + def _deselect_editor(self, editor): + if editor: + if isinstance( + editor, (QtWidgets.QSpinBox, QtWidgets.QDoubleSpinBox) + ): + line_edit = editor.findChild(QtWidgets.QLineEdit) + line_edit.deselect() + + elif isinstance(editor, QtWidgets.QLineEdit): + editor.deselect() + + def edit(self, index, *args, **kwargs): + result = super(HierarchyView, self).edit(index, *args, **kwargs) + if result: + # Mark index to not return text for DisplayRole + editor = self.indexWidget(index) + if ( + editor not in self._persisten_editors + and editor not in self._editors_mapping + ): + self._editors_mapping[editor] = index + self._source_model.setData(index, True, EDITOR_OPENED_ROLE) + # Deselect content of editor + # QUESTION not sure if we want do this all the time + self._deselect_editor(editor) + return result + + def closeEditor(self, editor, hint): + if ( + editor not in self._persisten_editors + and editor in self._editors_mapping + ): + index = self._editors_mapping.pop(editor) + self._source_model.setData(index, False, EDITOR_OPENED_ROLE) + super(HierarchyView, self).closeEditor(editor, hint) + + def openPersistentEditor(self, index): + self._source_model.setData(index, True, EDITOR_OPENED_ROLE) + super(HierarchyView, self).openPersistentEditor(index) + editor = self.indexWidget(index) + self._persisten_editors.add(editor) + self._deselect_editor(editor) + + def closePersistentEditor(self, index): + self._source_model.setData(index, False, EDITOR_OPENED_ROLE) + editor = self.indexWidget(index) + self._persisten_editors.remove(editor) + super(HierarchyView, self).closePersistentEditor(index) + + def rowsInserted(self, parent_index, start, end): + super(HierarchyView, self).rowsInserted(parent_index, start, end) + + for row in range(start, end + 1): + for key, column in self._column_key_to_index.items(): + if key not in self.persistent_columns: + continue + col_index = self._source_model.index(row, column, parent_index) + if bool( + self._source_model.flags(col_index) + & QtCore.Qt.ItemIsEditable + ): + self.openPersistentEditor(col_index) + + # Expand parent on insert + if not self.isExpanded(parent_index): + self.expand(parent_index) + + 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()) + + super(HierarchyView, self).mousePressEvent(event) + + def keyPressEvent(self, event): + call_super = False + if event.key() == QtCore.Qt.Key_Delete: + self._delete_items() + + elif event.matches(QtGui.QKeySequence.Copy): + self._copy_items() + + elif event.matches(QtGui.QKeySequence.Paste): + self._paste_items() + + elif event.key() in (QtCore.Qt.Key_Return, QtCore.Qt.Key_Enter): + mdfs = event.modifiers() + if mdfs == (QtCore.Qt.ShiftModifier | QtCore.Qt.ControlModifier): + self._on_ctrl_shift_enter_pressed() + elif mdfs == QtCore.Qt.ShiftModifier: + self._on_shift_enter_pressed() + else: + if self.state() == HierarchyView.NoState: + self._on_enter_pressed() + + elif event.modifiers() == QtCore.Qt.ControlModifier: + if event.key() == QtCore.Qt.Key_Left: + self._on_left_ctrl_pressed() + elif event.key() == QtCore.Qt.Key_Right: + self._on_right_ctrl_pressed() + elif event.key() == QtCore.Qt.Key_Up: + self._on_up_ctrl_pressed() + elif event.key() == QtCore.Qt.Key_Down: + self._on_down_ctrl_pressed() + else: + call_super = True + + if call_super: + super(HierarchyView, self).keyPressEvent(event) + else: + event.accept() + + def _copy_items(self, indexes=None): + try: + if indexes is None: + indexes = self.selectedIndexes() + mime_data = self._source_model.copy_mime_data(indexes) + + QtWidgets.QApplication.clipboard().setMimeData(mime_data) + self._show_message("Tasks copied") + except ValueError as exc: + self._show_message(str(exc)) + + def _paste_items(self): + index = self.currentIndex() + mime_data = QtWidgets.QApplication.clipboard().mimeData() + self._source_model.paste_mime_data(index, mime_data) + + def _delete_items(self, indexes=None): + if indexes is None: + indexes = self.selectedIndexes() + self._source_model.delete_indexes(indexes) + + def _on_ctrl_shift_enter_pressed(self): + self._add_task_and_edit() + + def add_asset(self, parent_index=None): + if parent_index is None: + parent_index = self.currentIndex() + + if not parent_index.isValid(): + return + + # Stop editing + self.setState(HierarchyView.NoState) + QtWidgets.QApplication.processEvents() + + return self._source_model.add_new_asset(parent_index) + + def add_task(self, parent_index=None): + if parent_index is None: + parent_index = self.currentIndex() + + if not parent_index.isValid(): + return + + return self._source_model.add_new_task(parent_index) + + def _add_asset_and_edit(self, parent_index=None): + new_index = self.add_asset(parent_index) + if new_index is None: + return + + # Change current index + self.selectionModel().setCurrentIndex( + new_index, + QtCore.QItemSelectionModel.Clear + | QtCore.QItemSelectionModel.Select + ) + # Start editing + self.edit(new_index) + + def _add_task_and_edit(self): + new_index = self.add_task() + if new_index is None: + return + + # Stop editing + self.setState(HierarchyView.NoState) + QtWidgets.QApplication.processEvents() + + # TODO change hardcoded column index to coded + task_type_index = self._source_model.index( + new_index.row(), 1, new_index.parent() + ) + # Change current index + self.selectionModel().setCurrentIndex( + task_type_index, + QtCore.QItemSelectionModel.Clear + | QtCore.QItemSelectionModel.Select + ) + # Start editing + self.edit(task_type_index) + + def _on_shift_enter_pressed(self): + parent_index = self.currentIndex() + if not parent_index.isValid(): + return + + if parent_index.data(ITEM_TYPE_ROLE) == "asset": + parent_index = parent_index.parent() + self._add_asset_and_edit(parent_index) + + def _on_up_ctrl_pressed(self): + indexes = self.selectedIndexes() + self._source_model.move_vertical(indexes, -1) + + def _on_down_ctrl_pressed(self): + indexes = self.selectedIndexes() + self._source_model.move_vertical(indexes, 1) + + def _on_left_ctrl_pressed(self): + indexes = self.selectedIndexes() + self._source_model.move_horizontal(indexes, -1) + + def _on_right_ctrl_pressed(self): + indexes = self.selectedIndexes() + self._source_model.move_horizontal(indexes, 1) + + def _on_enter_pressed(self): + index = self.currentIndex() + if ( + index.isValid() + and index.flags() & QtCore.Qt.ItemIsEditable + ): + self.edit(index) + + def _remove_delete_flag(self, item_ids): + """Remove deletion flag on items marked for deletion.""" + self._source_model.remove_delete_flag(item_ids) + + def _expand_items(self, indexes): + """Expand multiple items with all it's children. + + Args: + indexes (list): List of QModelIndex that should be expanded. + """ + process_queue = Queue() + for index in indexes: + if index.column() == 0: + process_queue.put(index) + + item_ids = set() + # Use deque as expanding not visible items as first is faster + indexes_deque = collections.deque() + while not process_queue.empty(): + index = process_queue.get() + item_id = index.data(IDENTIFIER_ROLE) + if item_id in item_ids: + continue + item_ids.add(item_id) + + indexes_deque.append(index) + + for row in range(self._source_model.rowCount(index)): + process_queue.put(self._source_model.index( + row, 0, index + )) + + while indexes_deque: + self.expand(indexes_deque.pop()) + + def _collapse_items(self, indexes): + """Collapse multiple items with all it's children. + + Args: + indexes (list): List of QModelIndex that should be collapsed. + """ + item_ids = set() + process_queue = Queue() + for index in indexes: + if index.column() == 0: + process_queue.put(index) + + while not process_queue.empty(): + index = process_queue.get() + item_id = index.data(IDENTIFIER_ROLE) + if item_id in item_ids: + continue + item_ids.add(item_id) + + self.collapse(index) + + for row in range(self._source_model.rowCount(index)): + process_queue.put(self._source_model.index( + row, 0, index + )) + + def _show_message(self, message): + """Show message to user.""" + self._parent.show_message(message) + + def _on_context_menu(self, point): + """Context menu on right click. + + Currently is menu shown only on "name" column. + """ + index = self.indexAt(point) + column = index.column() + if column != 0: + return + + actions = [] + + context_menu = QtWidgets.QMenu(self) + + indexes = self.selectedIndexes() + + items_by_id = {} + for index in indexes: + if index.column() != column: + continue + + item_id = index.data(IDENTIFIER_ROLE) + items_by_id[item_id] = self._source_model.items_by_id[item_id] + + item_ids = tuple(items_by_id.keys()) + if len(item_ids) == 1: + item = items_by_id[item_ids[0]] + item_type = item.data(ITEM_TYPE_ROLE) + if item_type in ("asset", "project"): + add_asset_action = QtWidgets.QAction("Add Asset", context_menu) + add_asset_action.triggered.connect( + self._add_asset_and_edit + ) + actions.append(add_asset_action) + + if item_type in ("asset", "task"): + add_task_action = QtWidgets.QAction("Add Task", context_menu) + add_task_action.triggered.connect( + self._add_task_and_edit + ) + actions.append(add_task_action) + + # Remove delete tag on items + removed_item_ids = [] + show_delete_items = False + for item_id, item in items_by_id.items(): + if item.data(REMOVED_ROLE): + removed_item_ids.append(item_id) + elif ( + not show_delete_items + and item.data(ITEM_TYPE_ROLE) != "project" + and item.data(HIERARCHY_CHANGE_ABLE_ROLE) + ): + show_delete_items = True + + if show_delete_items: + action = QtWidgets.QAction("Delete items", context_menu) + action.triggered.connect( + lambda: self._delete_items() + ) + actions.append(action) + + if removed_item_ids: + action = QtWidgets.QAction("Keep items", context_menu) + action.triggered.connect( + lambda: self._remove_delete_flag(removed_item_ids) + ) + actions.append(action) + + # Collapse/Expand action + show_collapse_expand_action = False + for item_id in item_ids: + item = items_by_id[item_id] + item_type = item.data(ITEM_TYPE_ROLE) + if item_type != "task": + show_collapse_expand_action = True + break + + if show_collapse_expand_action: + expand_action = QtWidgets.QAction("Expand all", context_menu) + collapse_action = QtWidgets.QAction("Collapse all", context_menu) + expand_action.triggered.connect( + lambda: self._expand_items(indexes) + ) + collapse_action.triggered.connect( + lambda: self._collapse_items(indexes) + ) + actions.append(expand_action) + actions.append(collapse_action) + + if not actions: + return + + for action in actions: + context_menu.addAction(action) + + global_point = self.viewport().mapToGlobal(point) + context_menu.exec_(global_point) diff --git a/openpype/tools/project_manager/project_manager/widgets.py b/openpype/tools/project_manager/project_manager/widgets.py new file mode 100644 index 0000000000..9c57febcf6 --- /dev/null +++ b/openpype/tools/project_manager/project_manager/widgets.py @@ -0,0 +1,281 @@ +import re + +from .constants import ( + NAME_ALLOWED_SYMBOLS, + NAME_REGEX +) +from openpype.lib import ( + create_project, + PROJECT_NAME_ALLOWED_SYMBOLS, + PROJECT_NAME_REGEX +) +from avalon.api import AvalonMongoDB + +from Qt import QtWidgets, QtCore + + +class NameTextEdit(QtWidgets.QLineEdit): + def __init__(self, *args, **kwargs): + super(NameTextEdit, self).__init__(*args, **kwargs) + + self.textChanged.connect(self._on_text_change) + + def _on_text_change(self, text): + if NAME_REGEX.match(text): + return + + idx = self.cursorPosition() + before_text = text[0:idx] + after_text = text[idx:len(text)] + sub_regex = "[^{}]+".format(NAME_ALLOWED_SYMBOLS) + new_before_text = re.sub(sub_regex, "", before_text) + new_after_text = re.sub(sub_regex, "", after_text) + idx -= (len(before_text) - len(new_before_text)) + + self.setText(new_before_text + new_after_text) + self.setCursorPosition(idx) + + +class FilterComboBox(QtWidgets.QComboBox): + def __init__(self, parent=None): + super(FilterComboBox, self).__init__(parent) + + self._last_value = None + + self.setFocusPolicy(QtCore.Qt.StrongFocus) + self.setEditable(True) + + filter_proxy_model = QtCore.QSortFilterProxyModel(self) + filter_proxy_model.setFilterCaseSensitivity(QtCore.Qt.CaseInsensitive) + filter_proxy_model.setSourceModel(self.model()) + + completer = QtWidgets.QCompleter(filter_proxy_model, self) + completer.setCompletionMode( + QtWidgets.QCompleter.UnfilteredPopupCompletion + ) + self.setCompleter(completer) + + self.lineEdit().textEdited.connect( + filter_proxy_model.setFilterFixedString + ) + completer.activated.connect(self.on_completer_activated) + + self._completer = completer + self._filter_proxy_model = filter_proxy_model + + def focusInEvent(self, event): + super(FilterComboBox, self).focusInEvent(event) + self._last_value = self.lineEdit().text() + self.lineEdit().selectAll() + + def value_cleanup(self): + text = self.lineEdit().text() + idx = self.findText(text) + if idx < 0: + count = self._completer.completionModel().rowCount() + if count > 0: + index = self._completer.completionModel().index(0, 0) + text = index.data(QtCore.Qt.DisplayRole) + idx = self.findText(text) + elif self._last_value is not None: + idx = self.findText(self._last_value) + + if idx < 0: + idx = 0 + self.setCurrentIndex(idx) + + def on_completer_activated(self, text): + if text: + index = self.findText(text) + self.setCurrentIndex(index) + + def keyPressEvent(self, event): + if event.key() in (QtCore.Qt.Key_Return, QtCore.Qt.Key_Enter): + self.value_cleanup() + + super(FilterComboBox, self).keyPressEvent(event) + + def setModel(self, model): + super(FilterComboBox, self).setModel(model) + self._filter_proxy_model.setSourceModel(model) + self._completer.setModel(self._filter_proxy_model) + + def setModelColumn(self, column): + self._completer.setCompletionColumn(column) + self._filter_proxy_model.setFilterKeyColumn(column) + super(FilterComboBox, self).setModelColumn(column) + + +class CreateProjectDialog(QtWidgets.QDialog): + def __init__(self, parent=None, dbcon=None): + super(CreateProjectDialog, self).__init__(parent) + + self.setWindowTitle("Create Project") + + self.allowed_regex = "[^{}]+".format(PROJECT_NAME_ALLOWED_SYMBOLS) + + if dbcon is None: + dbcon = AvalonMongoDB() + + self.dbcon = dbcon + self._ignore_code_change = False + self._project_name_is_valid = False + self._project_code_is_valid = False + self._project_code_value = None + + project_names, project_codes = self._get_existing_projects() + + inputs_widget = QtWidgets.QWidget(self) + project_name_input = QtWidgets.QLineEdit(inputs_widget) + project_code_input = QtWidgets.QLineEdit(inputs_widget) + library_project_input = QtWidgets.QCheckBox(inputs_widget) + + inputs_layout = QtWidgets.QFormLayout(inputs_widget) + inputs_layout.setContentsMargins(0, 0, 0, 0) + inputs_layout.addRow("Project name:", project_name_input) + inputs_layout.addRow("Project code:", project_code_input) + inputs_layout.addRow("Library project:", library_project_input) + + project_name_label = QtWidgets.QLabel(self) + project_code_label = QtWidgets.QLabel(self) + + btns_widget = QtWidgets.QWidget(self) + ok_btn = QtWidgets.QPushButton("Ok", btns_widget) + ok_btn.setEnabled(False) + cancel_btn = QtWidgets.QPushButton("Cancel", btns_widget) + btns_layout = QtWidgets.QHBoxLayout(btns_widget) + btns_layout.setContentsMargins(0, 0, 0, 0) + btns_layout.addStretch(1) + btns_layout.addWidget(ok_btn) + btns_layout.addWidget(cancel_btn) + + main_layout = QtWidgets.QVBoxLayout(self) + main_layout.addWidget(inputs_widget, 0) + main_layout.addWidget(project_name_label, 1) + main_layout.addWidget(project_code_label, 1) + main_layout.addStretch(1) + main_layout.addWidget(btns_widget, 0) + + project_name_input.textChanged.connect(self._on_project_name_change) + project_code_input.textChanged.connect(self._on_project_code_change) + ok_btn.clicked.connect(self._on_ok_clicked) + cancel_btn.clicked.connect(self._on_cancel_clicked) + + self.invalid_project_names = project_names + self.invalid_project_codes = project_codes + + self.project_name_label = project_name_label + self.project_code_label = project_code_label + + self.project_name_input = project_name_input + self.project_code_input = project_code_input + self.library_project_input = library_project_input + + self.ok_btn = ok_btn + + @property + def project_name(self): + return self.project_name_input.text() + + def _on_project_name_change(self, value): + if self._project_code_value is None: + self._ignore_code_change = True + self.project_code_input.setText(value.lower()) + self._ignore_code_change = False + + self._update_valid_project_name(value) + + def _on_project_code_change(self, value): + if not value: + value = None + + self._update_valid_project_code(value) + + if not self._ignore_code_change: + self._project_code_value = value + + def _update_valid_project_name(self, value): + message = "" + is_valid = True + if not value: + message = "Project name is empty" + is_valid = False + + elif value in self.invalid_project_names: + message = "Project name \"{}\" already exist".format(value) + is_valid = False + + elif not PROJECT_NAME_REGEX.match(value): + message = ( + "Project name \"{}\" contain not supported symbols" + ).format(value) + is_valid = False + + self._project_name_is_valid = is_valid + self.project_name_label.setText(message) + self.project_name_label.setVisible(bool(message)) + self._enable_button() + + def _update_valid_project_code(self, value): + message = "" + is_valid = True + if not value: + message = "Project code is empty" + is_valid = False + + elif value in self.invalid_project_names: + message = "Project code \"{}\" already exist".format(value) + is_valid = False + + elif not PROJECT_NAME_REGEX.match(value): + message = ( + "Project code \"{}\" contain not supported symbols" + ).format(value) + is_valid = False + + self._project_code_is_valid = is_valid + self.project_code_label.setText(message) + self._enable_button() + + def _enable_button(self): + self.ok_btn.setEnabled( + self._project_name_is_valid and self._project_code_is_valid + ) + + def _on_cancel_clicked(self): + self.done(0) + + def _on_ok_clicked(self): + if not self._project_name_is_valid or not self._project_code_is_valid: + return + + project_name = self.project_name_input.text() + project_code = self.project_code_input.text() + library_project = self.library_project_input.isChecked() + create_project(project_name, project_code, library_project, self.dbcon) + + self.done(1) + + def _get_existing_projects(self): + project_names = set() + project_codes = set() + for project_name in self.dbcon.database.collection_names(): + # Each collection will have exactly one project document + project_doc = self.dbcon.database[project_name].find_one( + {"type": "project"}, + {"name": 1, "data.code": 1} + ) + if not project_doc: + continue + + project_name = project_doc.get("name") + if not project_name: + continue + + project_names.add(project_name) + project_code = project_doc.get("data", {}).get("code") + if not project_code: + project_code = project_name + + project_codes.add(project_code) + return project_names, project_codes diff --git a/openpype/tools/project_manager/project_manager/window.py b/openpype/tools/project_manager/project_manager/window.py new file mode 100644 index 0000000000..a800214517 --- /dev/null +++ b/openpype/tools/project_manager/project_manager/window.py @@ -0,0 +1,176 @@ +from Qt import QtWidgets, QtCore, QtGui + +from . import ( + ProjectModel, + + HierarchyModel, + HierarchySelectionModel, + HierarchyView, + + CreateProjectDialog +) +from .style import load_stylesheet, ResourceCache + +from openpype import resources +from avalon.api import AvalonMongoDB + + +class ProjectManagerWindow(QtWidgets.QWidget): + def __init__(self, parent=None): + super(ProjectManagerWindow, self).__init__(parent) + + self.setWindowTitle("OpenPype Project Manager") + self.setWindowIcon(QtGui.QIcon(resources.pype_icon_filepath())) + + # Top part of window + top_part_widget = QtWidgets.QWidget(self) + + # Project selection + project_widget = QtWidgets.QWidget(top_part_widget) + + dbcon = AvalonMongoDB() + + project_model = ProjectModel(dbcon) + project_combobox = QtWidgets.QComboBox(project_widget) + project_combobox.setModel(project_model) + project_combobox.setRootModelIndex(QtCore.QModelIndex()) + + refresh_projects_btn = QtWidgets.QPushButton(project_widget) + refresh_projects_btn.setIcon(ResourceCache.get_icon("refresh")) + refresh_projects_btn.setToolTip("Refresh projects") + refresh_projects_btn.setObjectName("RefreshBtn") + + create_project_btn = QtWidgets.QPushButton( + "Create project...", project_widget + ) + + project_layout = QtWidgets.QHBoxLayout(project_widget) + project_layout.setContentsMargins(0, 0, 0, 0) + project_layout.addWidget(project_combobox, 0) + project_layout.addWidget(refresh_projects_btn, 0) + project_layout.addWidget(create_project_btn, 0) + project_layout.addStretch(1) + + # Helper buttons + helper_btns_widget = QtWidgets.QWidget(top_part_widget) + + helper_label = QtWidgets.QLabel("Add:", helper_btns_widget) + add_asset_btn = QtWidgets.QPushButton( + ResourceCache.get_icon("asset", "default"), + "Asset", + helper_btns_widget + ) + add_task_btn = QtWidgets.QPushButton( + ResourceCache.get_icon("task", "default"), + "Task", + helper_btns_widget + ) + + helper_btns_layout = QtWidgets.QHBoxLayout(helper_btns_widget) + helper_btns_layout.setContentsMargins(0, 0, 0, 0) + helper_btns_layout.addWidget(helper_label) + helper_btns_layout.addWidget(add_asset_btn) + helper_btns_layout.addWidget(add_task_btn) + helper_btns_layout.addStretch(1) + + # Add widgets to top widget layout + top_part_layout = QtWidgets.QVBoxLayout(top_part_widget) + top_part_layout.setContentsMargins(0, 0, 0, 0) + top_part_layout.addWidget(project_widget) + top_part_layout.addWidget(helper_btns_widget) + + hierarchy_model = HierarchyModel(dbcon) + + hierarchy_view = HierarchyView(dbcon, hierarchy_model, self) + hierarchy_view.setModel(hierarchy_model) + + _selection_model = HierarchySelectionModel( + hierarchy_model.multiselection_column_indexes + ) + _selection_model.setModel(hierarchy_view.model()) + hierarchy_view.setSelectionModel(_selection_model) + + buttons_widget = QtWidgets.QWidget(self) + + message_label = QtWidgets.QLabel(buttons_widget) + save_btn = QtWidgets.QPushButton("Save", buttons_widget) + + buttons_layout = QtWidgets.QHBoxLayout(buttons_widget) + buttons_layout.setContentsMargins(0, 0, 0, 0) + buttons_layout.addWidget(message_label) + buttons_layout.addStretch(1) + buttons_layout.addWidget(save_btn) + + main_layout = QtWidgets.QVBoxLayout(self) + main_layout.addWidget(top_part_widget) + main_layout.addWidget(hierarchy_view) + main_layout.addWidget(buttons_widget) + + refresh_projects_btn.clicked.connect(self._on_project_refresh) + create_project_btn.clicked.connect(self._on_project_create) + project_combobox.currentIndexChanged.connect(self._on_project_change) + save_btn.clicked.connect(self._on_save_click) + add_asset_btn.clicked.connect(self._on_add_asset) + add_task_btn.clicked.connect(self._on_add_task) + + self.project_model = project_model + self.project_combobox = project_combobox + + self.hierarchy_view = hierarchy_view + self.hierarchy_model = hierarchy_model + + self.message_label = message_label + + self.resize(1200, 600) + self.setStyleSheet(load_stylesheet()) + + self.refresh_projects() + + def _set_project(self, project_name=None): + self.hierarchy_view.set_project(project_name) + + def refresh_projects(self, project_name=None): + if project_name is None: + if self.project_combobox.count() > 0: + project_name = self.project_combobox.currentText() + + self.project_model.refresh() + + if self.project_combobox.count() == 0: + return self._set_project() + + if project_name: + row = self.project_combobox.findText(project_name) + if row >= 0: + self.project_combobox.setCurrentIndex(row) + + self._set_project(self.project_combobox.currentText()) + + def _on_project_change(self): + self._set_project(self.project_combobox.currentText()) + + def _on_project_refresh(self): + self.refresh_projects() + + def _on_save_click(self): + self.hierarchy_model.save() + + def _on_add_asset(self): + self.hierarchy_view.add_asset() + + def _on_add_task(self): + self.hierarchy_view.add_task() + + def show_message(self, message): + # TODO add nicer message pop + self.message_label.setText(message) + + def _on_project_create(self): + dialog = CreateProjectDialog(self) + dialog.exec_() + if dialog.result() != 1: + return + + project_name = dialog.project_name + self.show_message("Created project \"{}\"".format(project_name)) + self.refresh_projects(project_name) diff --git a/openpype/version.py b/openpype/version.py index 27186ad2bb..a88ae329d0 100644 --- a/openpype/version.py +++ b/openpype/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring Pype version.""" -__version__ = "3.0.0-rc4" +__version__ = "3.0.0-rc.5" diff --git a/poetry.lock b/poetry.lock index 41a1f636ec..09e2d133e8 100644 --- a/poetry.lock +++ b/poetry.lock @@ -80,11 +80,11 @@ python-dateutil = ">=2.7.0" [[package]] name = "astroid" -version = "2.5.3" +version = "2.5.6" description = "An abstract syntax tree for Python with inference support." category = "dev" optional = false -python-versions = ">=3.6" +python-versions = "~=3.6" [package.dependencies] lazy-object-proxy = ">=1.4.0" @@ -109,21 +109,21 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" [[package]] name = "attrs" -version = "20.3.0" +version = "21.2.0" description = "Classes Without Boilerplate" category = "main" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" [package.extras] -dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface", "furo", "sphinx", "pre-commit"] -docs = ["furo", "sphinx", "zope.interface"] -tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface"] -tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six"] +dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "furo", "sphinx", "sphinx-notfound-page", "pre-commit"] +docs = ["furo", "sphinx", "zope.interface", "sphinx-notfound-page"] +tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface"] +tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins"] [[package]] name = "autopep8" -version = "1.5.6" +version = "1.5.7" description = "A tool that automatically formats Python code to conform to the PEP 8 style guide" category = "dev" optional = false @@ -135,7 +135,7 @@ toml = "*" [[package]] name = "babel" -version = "2.9.0" +version = "2.9.1" description = "Internationalization utilities" category = "dev" optional = false @@ -159,7 +159,7 @@ wcwidth = ">=0.1.4" [[package]] name = "cachetools" -version = "4.2.1" +version = "4.2.2" description = "Extensible memoizing collections and decorators" category = "main" optional = false @@ -335,7 +335,7 @@ python-versions = "*" [[package]] name = "flake8" -version = "3.9.1" +version = "3.9.2" description = "the modular source code checker: pep8 pyflakes and co" category = "dev" optional = false @@ -413,7 +413,7 @@ uritemplate = ">=3.0.0,<4dev" [[package]] name = "google-auth" -version = "1.29.0" +version = "1.30.0" description = "Google Authentication Library" category = "main" optional = false @@ -486,7 +486,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" [[package]] name = "importlib-metadata" -version = "4.0.0" +version = "4.0.1" description = "Read metadata from Python packages" category = "main" optional = false @@ -736,7 +736,7 @@ python-versions = "*" [[package]] name = "protobuf" -version = "3.15.8" +version = "3.17.0" description = "Protocol Buffers" category = "main" optional = false @@ -826,7 +826,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" [[package]] name = "pygments" -version = "2.8.1" +version = "2.9.0" description = "Pygments is a syntax highlighting package written in Python." category = "dev" optional = false @@ -834,25 +834,22 @@ python-versions = ">=3.5" [[package]] name = "pylint" -version = "2.7.4" +version = "2.8.2" description = "python code static checker" category = "dev" optional = false python-versions = "~=3.6" [package.dependencies] -astroid = ">=2.5.2,<2.7" +astroid = ">=2.5.6,<2.7" colorama = {version = "*", markers = "sys_platform == \"win32\""} isort = ">=4.2.5,<6" mccabe = ">=0.6,<0.7" toml = ">=0.7.1" -[package.extras] -docs = ["sphinx (==3.5.1)", "python-docs-theme (==2020.12)"] - [[package]] name = "pymongo" -version = "3.11.3" +version = "3.11.4" description = "Python driver for MongoDB " category = "main" optional = false @@ -884,7 +881,7 @@ six = "*" [[package]] name = "pyobjc-core" -version = "7.1" +version = "7.2" description = "Python<->ObjC Interoperability Module" category = "main" optional = false @@ -892,26 +889,26 @@ python-versions = ">=3.6" [[package]] name = "pyobjc-framework-cocoa" -version = "7.1" +version = "7.2" description = "Wrappers for the Cocoa frameworks on macOS" category = "main" optional = false python-versions = ">=3.6" [package.dependencies] -pyobjc-core = ">=7.1" +pyobjc-core = ">=7.2" [[package]] name = "pyobjc-framework-quartz" -version = "7.1" +version = "7.2" description = "Wrappers for the Quartz frameworks on macOS" category = "main" optional = false python-versions = ">=3.6" [package.dependencies] -pyobjc-core = ">=7.1" -pyobjc-framework-Cocoa = ">=7.1" +pyobjc-core = ">=7.2" +pyobjc-framework-Cocoa = ">=7.2" [[package]] name = "pyparsing" @@ -943,7 +940,7 @@ python-versions = "*" [[package]] name = "pyqt5-sip" -version = "12.8.1" +version = "12.9.0" description = "The sip module support for PyQt5" category = "main" optional = false @@ -959,7 +956,7 @@ python-versions = ">=3.5" [[package]] name = "pytest" -version = "6.2.3" +version = "6.2.4" description = "pytest: simple powerful testing with Python" category = "dev" optional = false @@ -1124,9 +1121,17 @@ python-versions = ">=3.6" cryptography = ">=2.0" jeepney = ">=0.6" +[[package]] +name = "semver" +version = "2.13.0" +description = "Python helper for Semantic Versioning (http://semver.org/)" +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + [[package]] name = "six" -version = "1.15.0" +version = "1.16.0" description = "Python 2 and 3 compatibility utilities" category = "main" optional = false @@ -1150,19 +1155,20 @@ python-versions = "*" [[package]] name = "sphinx" -version = "3.5.4" +version = "4.0.1" description = "Python documentation generator" category = "dev" optional = false -python-versions = ">=3.5" +python-versions = ">=3.6" [package.dependencies] alabaster = ">=0.7,<0.8" babel = ">=1.3" colorama = {version = ">=0.3.5", markers = "sys_platform == \"win32\""} -docutils = ">=0.12,<0.17" +docutils = ">=0.14,<0.18" imagesize = "*" -Jinja2 = ">=2.3" +Jinja2 = ">=2.3,<3.0" +MarkupSafe = "<2.0" packaging = "*" Pygments = ">=2.0" requests = ">=2.5.0" @@ -1318,7 +1324,7 @@ python-versions = "*" [[package]] name = "typing-extensions" -version = "3.7.4.3" +version = "3.10.0.0" description = "Backported and Experimental Type Hints for Python 3.5+" category = "main" optional = false @@ -1355,7 +1361,7 @@ python-versions = "*" [[package]] name = "websocket-client" -version = "0.58.0" +version = "0.59.0" description = "WebSocket client for Python with low level API options" category = "main" optional = false @@ -1417,7 +1423,7 @@ testing = ["pytest (>=4.6)", "pytest-checkdocs (>=1.2.3)", "pytest-flake8", "pyt [metadata] lock-version = "1.1" python-versions = "3.7.*" -content-hash = "80fde42aade7fc90bb68d85f0d9b3feb27fc3744d72eb5af6a11b6c9d9836aca" +content-hash = "9e067714903bf7e438bc11556b58b6b96be6b079e9a245690c84de8493fa516e" [metadata.files] acre = [] @@ -1481,8 +1487,8 @@ arrow = [ {file = "arrow-0.17.0.tar.gz", hash = "sha256:ff08d10cda1d36c68657d6ad20d74fbea493d980f8b2d45344e00d6ed2bf6ed4"}, ] astroid = [ - {file = "astroid-2.5.3-py3-none-any.whl", hash = "sha256:bea3f32799fbb8581f58431c12591bc20ce11cbc90ad82e2ea5717d94f2080d5"}, - {file = "astroid-2.5.3.tar.gz", hash = "sha256:ad63b8552c70939568966811a088ef0bc880f99a24a00834abd0e3681b514f91"}, + {file = "astroid-2.5.6-py3-none-any.whl", hash = "sha256:4db03ab5fc3340cf619dbc25e42c2cc3755154ce6009469766d7143d1fc2ee4e"}, + {file = "astroid-2.5.6.tar.gz", hash = "sha256:8a398dfce302c13f14bab13e2b14fe385d32b73f4e4853b9bdfb64598baa1975"}, ] async-timeout = [ {file = "async-timeout-3.0.1.tar.gz", hash = "sha256:0c3c816a028d47f659d6ff5c745cb2acf1f966da1fe5c19c77a70282b25f4c5f"}, @@ -1493,24 +1499,24 @@ atomicwrites = [ {file = "atomicwrites-1.4.0.tar.gz", hash = "sha256:ae70396ad1a434f9c7046fd2dd196fc04b12f9e91ffb859164193be8b6168a7a"}, ] attrs = [ - {file = "attrs-20.3.0-py2.py3-none-any.whl", hash = "sha256:31b2eced602aa8423c2aea9c76a724617ed67cf9513173fd3a4f03e3a929c7e6"}, - {file = "attrs-20.3.0.tar.gz", hash = "sha256:832aa3cde19744e49938b91fea06d69ecb9e649c93ba974535d08ad92164f700"}, + {file = "attrs-21.2.0-py2.py3-none-any.whl", hash = "sha256:149e90d6d8ac20db7a955ad60cf0e6881a3f20d37096140088356da6c716b0b1"}, + {file = "attrs-21.2.0.tar.gz", hash = "sha256:ef6aaac3ca6cd92904cdd0d83f629a15f18053ec84e6432106f7a4d04ae4f5fb"}, ] autopep8 = [ - {file = "autopep8-1.5.6-py2.py3-none-any.whl", hash = "sha256:f01b06a6808bc31698db907761e5890eb2295e287af53f6693b39ce55454034a"}, - {file = "autopep8-1.5.6.tar.gz", hash = "sha256:5454e6e9a3d02aae38f866eec0d9a7de4ab9f93c10a273fb0340f3d6d09f7514"}, + {file = "autopep8-1.5.7-py2.py3-none-any.whl", hash = "sha256:aa213493c30dcdac99537249ee65b24af0b2c29f2e83cd8b3f68760441ed0db9"}, + {file = "autopep8-1.5.7.tar.gz", hash = "sha256:276ced7e9e3cb22e5d7c14748384a5cf5d9002257c0ed50c0e075b68011bb6d0"}, ] babel = [ - {file = "Babel-2.9.0-py2.py3-none-any.whl", hash = "sha256:9d35c22fcc79893c3ecc85ac4a56cde1ecf3f19c540bba0922308a6c06ca6fa5"}, - {file = "Babel-2.9.0.tar.gz", hash = "sha256:da031ab54472314f210b0adcff1588ee5d1d1d0ba4dbd07b94dba82bde791e05"}, + {file = "Babel-2.9.1-py2.py3-none-any.whl", hash = "sha256:ab49e12b91d937cd11f0b67cb259a57ab4ad2b59ac7a3b41d6c06c0ac5b0def9"}, + {file = "Babel-2.9.1.tar.gz", hash = "sha256:bc0c176f9f6a994582230df350aa6e05ba2ebe4b3ac317eab29d9be5d2768da0"}, ] blessed = [ {file = "blessed-1.18.0-py2.py3-none-any.whl", hash = "sha256:5b5e2f0563d5a668c282f3f5946f7b1abb70c85829461900e607e74d7725106e"}, {file = "blessed-1.18.0.tar.gz", hash = "sha256:1312879f971330a1b7f2c6341f2ae7e2cbac244bfc9d0ecfbbecd4b0293bc755"}, ] cachetools = [ - {file = "cachetools-4.2.1-py3-none-any.whl", hash = "sha256:1d9d5f567be80f7c07d765e21b814326d78c61eb0c3a637dffc0e5d1796cb2e2"}, - {file = "cachetools-4.2.1.tar.gz", hash = "sha256:f469e29e7aa4cff64d8de4aad95ce76de8ea1125a16c68e0d93f65c3c3dc92e9"}, + {file = "cachetools-4.2.2-py3-none-any.whl", hash = "sha256:2cc0b89715337ab6dbba85b5b50effe2b0c74e035d83ee8ed637cf52f12ae001"}, + {file = "cachetools-4.2.2.tar.gz", hash = "sha256:61b5ed1e22a0924aed1d23b478f37e8d52549ff8a961de2909c69bf950020cff"}, ] certifi = [ {file = "certifi-2020.12.5-py2.py3-none-any.whl", hash = "sha256:719a74fb9e33b9bd44cc7f3a8d94bc35e4049deebe19ba7d8e108280cfd59830"}, @@ -1689,8 +1695,8 @@ evdev = [ {file = "evdev-1.4.0.tar.gz", hash = "sha256:8782740eb1a86b187334c07feb5127d3faa0b236e113206dfe3ae8f77fb1aaf1"}, ] flake8 = [ - {file = "flake8-3.9.1-py2.py3-none-any.whl", hash = "sha256:3b9f848952dddccf635be78098ca75010f073bfe14d2c6bda867154bea728d2a"}, - {file = "flake8-3.9.1.tar.gz", hash = "sha256:1aa8990be1e689d96c745c5682b687ea49f2e05a443aff1f8251092b0014e378"}, + {file = "flake8-3.9.2-py2.py3-none-any.whl", hash = "sha256:bf8fd333346d844f616e8d47905ef3a3384edae6b4e9beb0c5101e25e3110907"}, + {file = "flake8-3.9.2.tar.gz", hash = "sha256:07528381786f2a6237b061f6e96610a4167b226cb926e2aa2b6b1d78057c576b"}, ] ftrack-python-api = [ {file = "ftrack-python-api-2.0.0.tar.gz", hash = "sha256:dd6f02c31daf5a10078196dc9eac4671e4297c762fbbf4df98de668ac12281d9"}, @@ -1708,8 +1714,8 @@ google-api-python-client = [ {file = "google_api_python_client-1.12.8-py2.py3-none-any.whl", hash = "sha256:3c4c4ca46b5c21196bec7ee93453443e477d82cbfa79234d1ce0645f81170eaf"}, ] google-auth = [ - {file = "google-auth-1.29.0.tar.gz", hash = "sha256:010f011c4e27d3d5eb01106fba6aac39d164842dfcd8709955c4638f5b11ccf8"}, - {file = "google_auth-1.29.0-py2.py3-none-any.whl", hash = "sha256:f30a672a64d91cc2e3137765d088c5deec26416246f7a9e956eaf69a8d7ed49c"}, + {file = "google-auth-1.30.0.tar.gz", hash = "sha256:9ad25fba07f46a628ad4d0ca09f38dcb262830df2ac95b217f9b0129c9e42206"}, + {file = "google_auth-1.30.0-py2.py3-none-any.whl", hash = "sha256:588bdb03a41ecb4978472b847881e5518b5d9ec6153d3d679aa127a55e13b39f"}, ] google-auth-httplib2 = [ {file = "google-auth-httplib2-0.1.0.tar.gz", hash = "sha256:a07c39fd632becacd3f07718dfd6021bf396978f03ad3ce4321d060015cc30ac"}, @@ -1732,8 +1738,8 @@ imagesize = [ {file = "imagesize-1.2.0.tar.gz", hash = "sha256:b1f6b5a4eab1f73479a50fb79fcf729514a900c341d8503d62a62dbc4127a2b1"}, ] importlib-metadata = [ - {file = "importlib_metadata-4.0.0-py3-none-any.whl", hash = "sha256:19192b88d959336bfa6bdaaaef99aeafec179eca19c47c804e555703ee5f07ef"}, - {file = "importlib_metadata-4.0.0.tar.gz", hash = "sha256:2e881981c9748d7282b374b68e759c87745c25427b67ecf0cc67fb6637a1bff9"}, + {file = "importlib_metadata-4.0.1-py3-none-any.whl", hash = "sha256:d7eb1dea6d6a6086f8be21784cc9e3bcfa55872b52309bc5fad53a8ea444465d"}, + {file = "importlib_metadata-4.0.1.tar.gz", hash = "sha256:8c501196e49fb9df5df43833bdb1e4328f64847763ec8a50703148b73784d581"}, ] iniconfig = [ {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"}, @@ -1948,26 +1954,29 @@ prefixed = [ {file = "prefixed-0.3.2.tar.gz", hash = "sha256:ca48277ba5fa8346dd4b760847da930c7b84416387c39e93affef086add2c029"}, ] protobuf = [ - {file = "protobuf-3.15.8-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:fad4f971ec38d8df7f4b632c819bf9bbf4f57cfd7312cf526c69ce17ef32436a"}, - {file = "protobuf-3.15.8-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:f17b352d7ce33c81773cf81d536ca70849de6f73c96413f17309f4b43ae7040b"}, - {file = "protobuf-3.15.8-cp35-cp35m-macosx_10_9_intel.whl", hash = "sha256:4a054b0b5900b7ea7014099e783fb8c4618e4209fffcd6050857517b3f156e18"}, - {file = "protobuf-3.15.8-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:efa4c4d4fc9ba734e5e85eaced70e1b63fb3c8d08482d839eb838566346f1737"}, - {file = "protobuf-3.15.8-cp35-cp35m-win32.whl", hash = "sha256:07eec4e2ccbc74e95bb9b3afe7da67957947ee95bdac2b2e91b038b832dd71f0"}, - {file = "protobuf-3.15.8-cp35-cp35m-win_amd64.whl", hash = "sha256:f9cadaaa4065d5dd4d15245c3b68b967b3652a3108e77f292b58b8c35114b56c"}, - {file = "protobuf-3.15.8-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:2dc0e8a9e4962207bdc46a365b63a3f1aca6f9681a5082a326c5837ef8f4b745"}, - {file = "protobuf-3.15.8-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:f80afc0a0ba13339bbab25ca0409e9e2836b12bb012364c06e97c2df250c3343"}, - {file = "protobuf-3.15.8-cp36-cp36m-win32.whl", hash = "sha256:c5566f956a26cda3abdfacc0ca2e21db6c9f3d18f47d8d4751f2209d6c1a5297"}, - {file = "protobuf-3.15.8-cp36-cp36m-win_amd64.whl", hash = "sha256:dab75b56a12b1ceb3e40808b5bd9dfdaef3a1330251956e6744e5b6ed8f8830b"}, - {file = "protobuf-3.15.8-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:3053f13207e7f13dc7be5e9071b59b02020172f09f648e85dc77e3fcb50d1044"}, - {file = "protobuf-3.15.8-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:1f0b5d156c3df08cc54bc2c8b8b875648ea4cd7ebb2a9a130669f7547ec3488c"}, - {file = "protobuf-3.15.8-cp37-cp37m-win32.whl", hash = "sha256:90270fe5732c1f1ff664a3bd7123a16456d69b4e66a09a139a00443a32f210b8"}, - {file = "protobuf-3.15.8-cp37-cp37m-win_amd64.whl", hash = "sha256:f42c2f5fb67da5905bfc03733a311f72fa309252bcd77c32d1462a1ad519521e"}, - {file = "protobuf-3.15.8-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:f6077db37bfa16494dca58a4a02bfdacd87662247ad6bc1f7f8d13ff3f0013e1"}, - {file = "protobuf-3.15.8-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:510e66491f1a5ac5953c908aa8300ec47f793130097e4557482803b187a8ee05"}, - {file = "protobuf-3.15.8-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5ff9fa0e67fcab442af9bc8d4ec3f82cb2ff3be0af62dba047ed4187f0088b7d"}, - {file = "protobuf-3.15.8-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:1c0e9e56202b9dccbc094353285a252e2b7940b74fdf75f1b4e1b137833fabd7"}, - {file = "protobuf-3.15.8-py2.py3-none-any.whl", hash = "sha256:a0a08c6b2e6d6c74a6eb5bf6184968eefb1569279e78714e239d33126e753403"}, - {file = "protobuf-3.15.8.tar.gz", hash = "sha256:0277f62b1e42210cafe79a71628c1d553348da81cbd553402a7f7549c50b11d0"}, + {file = "protobuf-3.17.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:15351df904347da2081a2eebc42b192c29724eb57dbe56dae440be843f1e4779"}, + {file = "protobuf-3.17.0-cp27-cp27mu-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:5356981c1919782b8c2e3ea5c5d85ad5937b8178a025ac9edc2f2ca5b4a717ae"}, + {file = "protobuf-3.17.0-cp35-cp35m-macosx_10_9_intel.whl", hash = "sha256:eac0a2a7ea99e17175f6e7b53cdc9004ed786c072fbdf933def0e454e14fd323"}, + {file = "protobuf-3.17.0-cp35-cp35m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:4c8d0997fdc0a4cf9de7950d598ce6974b22e8618bbcf1d15e9842010cf8420a"}, + {file = "protobuf-3.17.0-cp35-cp35m-win32.whl", hash = "sha256:9ae321459d4890c3939c536382f75e232c9e91ce506310353c8a15ad5c379e0d"}, + {file = "protobuf-3.17.0-cp35-cp35m-win_amd64.whl", hash = "sha256:295944ef0772498d7bf75f6aa5d4dfcfd02f5ce70f735b406e52e43ac3914d38"}, + {file = "protobuf-3.17.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:850f429bd2399525d339d05bc809f090f16d3d88737bed637d355a5ee8d3b81a"}, + {file = "protobuf-3.17.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:809a96d5a1a74538728710f9104f43ae77f5e48bde274ee321b10a324ba52e4f"}, + {file = "protobuf-3.17.0-cp36-cp36m-win32.whl", hash = "sha256:8a3ac375539055164f31a330770f137875307e6f04c21e2647f2e7139c501295"}, + {file = "protobuf-3.17.0-cp36-cp36m-win_amd64.whl", hash = "sha256:3d338910b10b88b18581cf6877b3938b2e262e8fdc2c1057f5a291787de63183"}, + {file = "protobuf-3.17.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:1488f786bd1912f97796cf5def8cacf433735616896cf7ed9dc786cee693dfc8"}, + {file = "protobuf-3.17.0-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:bcaff977db178f0bfde10bab0d23a5f5adf5964adba70c315e45922a1c55eb90"}, + {file = "protobuf-3.17.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:939ce06846ddfec99c0bff510510b3ee45778e7a3aec6544d1f36526e5fecb67"}, + {file = "protobuf-3.17.0-cp37-cp37m-win32.whl", hash = "sha256:3237acce5b666c7b0f45785cc2d0809796d4df3593bd68338aebf25408139188"}, + {file = "protobuf-3.17.0-cp37-cp37m-win_amd64.whl", hash = "sha256:2f77afe33bb86c7d34221a86193256d69aa10818620fe4a7513d98211d67d672"}, + {file = "protobuf-3.17.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:acc9f2091ace3de429eee424ab7ba0bc52a6aa9ffc9909e5c4de259a3f71db46"}, + {file = "protobuf-3.17.0-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:a29631f4f8bcf79b12a59e83d238d888de5034871461d788c74c68218ad75049"}, + {file = "protobuf-3.17.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:05c304396e309661c45e3a97bd2d8da1fc2bab743ed2ca880bcb757271c40c0e"}, + {file = "protobuf-3.17.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:baea44967071e6a51e705e4e88aebf35f530a14004cc69f60a185e5d7e13de7e"}, + {file = "protobuf-3.17.0-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:3b5c461af5a3cebd796c73370db929b7e24cbaba655eefdc044226bc8a843d6b"}, + {file = "protobuf-3.17.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:44399393c3a8cc04a4cfbdc721dd7f2114497efda582e946a91b8c4290ae5ff5"}, + {file = "protobuf-3.17.0-py2.py3-none-any.whl", hash = "sha256:e32ef0c9f4b548c80d94dfff8b4130ca2ff3d50caaf2455889e3f5b8a01e8038"}, + {file = "protobuf-3.17.0.tar.gz", hash = "sha256:05dfe9319939a8473c21b469f34f6486646e54fb8542637cf7ed8e2fbfe21538"}, ] py = [ {file = "py-1.10.0-py2.py3-none-any.whl", hash = "sha256:3b80836aa6d1feeaa108e046da6423ab8f6ceda6468545ae8d02d9d58d18818a"}, @@ -2028,78 +2037,78 @@ pyflakes = [ {file = "pyflakes-2.3.1.tar.gz", hash = "sha256:f5bc8ecabc05bb9d291eb5203d6810b49040f6ff446a756326104746cc00c1db"}, ] pygments = [ - {file = "Pygments-2.8.1-py3-none-any.whl", hash = "sha256:534ef71d539ae97d4c3a4cf7d6f110f214b0e687e92f9cb9d2a3b0d3101289c8"}, - {file = "Pygments-2.8.1.tar.gz", hash = "sha256:2656e1a6edcdabf4275f9a3640db59fd5de107d88e8663c5d4e9a0fa62f77f94"}, + {file = "Pygments-2.9.0-py3-none-any.whl", hash = "sha256:d66e804411278594d764fc69ec36ec13d9ae9147193a1740cd34d272ca383b8e"}, + {file = "Pygments-2.9.0.tar.gz", hash = "sha256:a18f47b506a429f6f4b9df81bb02beab9ca21d0a5fee38ed15aef65f0545519f"}, ] pylint = [ - {file = "pylint-2.7.4-py3-none-any.whl", hash = "sha256:209d712ec870a0182df034ae19f347e725c1e615b2269519ab58a35b3fcbbe7a"}, - {file = "pylint-2.7.4.tar.gz", hash = "sha256:bd38914c7731cdc518634a8d3c5585951302b6e2b6de60fbb3f7a0220e21eeee"}, + {file = "pylint-2.8.2-py3-none-any.whl", hash = "sha256:f7e2072654a6b6afdf5e2fb38147d3e2d2d43c89f648637baab63e026481279b"}, + {file = "pylint-2.8.2.tar.gz", hash = "sha256:586d8fa9b1891f4b725f587ef267abe2a1bad89d6b184520c7f07a253dd6e217"}, ] pymongo = [ - {file = "pymongo-3.11.3-cp27-cp27m-macosx_10_14_intel.whl", hash = "sha256:4d959e929cec805c2bf391418b1121590b4e7d5cb00af7b1ba521443d45a0918"}, - {file = "pymongo-3.11.3-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:9fbffc5bad4df99a509783cbd449ed0d24fcd5a450c28e7756c8f20eda3d2aa5"}, - {file = "pymongo-3.11.3-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:bd351ceb2decd23d523fc50bad631ee9ae6e97e7cdc355ce5600fe310484f96e"}, - {file = "pymongo-3.11.3-cp27-cp27m-win32.whl", hash = "sha256:7d2ae2f7c50adec20fde46a73465de31a6a6fbb4903240f8b7304549752ca7a1"}, - {file = "pymongo-3.11.3-cp27-cp27m-win_amd64.whl", hash = "sha256:b1aa62903a2c5768b0001632efdea2e8da6c80abdd520c2e8a16001cc9affb23"}, - {file = "pymongo-3.11.3-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:180511abfef70feb022360b35f4863dd68e08334197089201d5c52208de9ca2e"}, - {file = "pymongo-3.11.3-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:42f9ec9d77358f557fe17cc15e796c4d4d492ede1a30cba3664822cae66e97c5"}, - {file = "pymongo-3.11.3-cp34-cp34m-macosx_10_6_intel.whl", hash = "sha256:3dbc67754882d740f17809342892f0b24398770bd99d48c5cb5ba89f5f5dee4e"}, - {file = "pymongo-3.11.3-cp34-cp34m-manylinux1_i686.whl", hash = "sha256:733e1cfffc4cd99848230e2999c8a86e284c6af6746482f8ad2ad554dce14e39"}, - {file = "pymongo-3.11.3-cp34-cp34m-manylinux1_x86_64.whl", hash = "sha256:622a5157ffcd793d305387c1c9fb94185f496c8c9fd66dafb59de0807bc14ad7"}, - {file = "pymongo-3.11.3-cp34-cp34m-win32.whl", hash = "sha256:2aeb108da1ed8e066800fb447ba5ae89d560e6773d228398a87825ac3630452d"}, - {file = "pymongo-3.11.3-cp34-cp34m-win_amd64.whl", hash = "sha256:7c77801620e5e75fb9c7abae235d3cc45d212a67efa98f4972eef63e736a8daa"}, - {file = "pymongo-3.11.3-cp35-cp35m-macosx_10_6_intel.whl", hash = "sha256:29390c39ca873737689a0749c9c3257aad96b323439b11279fbc0ba8626ec9c5"}, - {file = "pymongo-3.11.3-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:a8b02e0119d6ee381a265d8d2450a38096f82916d895fed2dfd81d4c7a54d6e4"}, - {file = "pymongo-3.11.3-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:28633868be21a187702a8613913e13d1987d831529358c29fc6f6670413df040"}, - {file = "pymongo-3.11.3-cp35-cp35m-manylinux2014_aarch64.whl", hash = "sha256:685b884fa41bd2913fd20af85866c4ff886b7cbb7e4833b918996aa5d45a04be"}, - {file = "pymongo-3.11.3-cp35-cp35m-manylinux2014_i686.whl", hash = "sha256:7cd42c66d49ffb68dea065e1c8a4323e7ceab386e660fee9863d4fa227302ba9"}, - {file = "pymongo-3.11.3-cp35-cp35m-manylinux2014_ppc64le.whl", hash = "sha256:950710f7370613a6bfa2ccd842b488c5b8072e83fb6b7d45d99110bf44651d06"}, - {file = "pymongo-3.11.3-cp35-cp35m-manylinux2014_s390x.whl", hash = "sha256:c7fd18d4b7939408df9315fedbdb05e179760960a92b3752498e2fcd03f24c3d"}, - {file = "pymongo-3.11.3-cp35-cp35m-manylinux2014_x86_64.whl", hash = "sha256:cc359e408712faf9ea775f4c0ec8f2bfc843afe47747a657808d9595edd34d71"}, - {file = "pymongo-3.11.3-cp35-cp35m-win32.whl", hash = "sha256:7814b2cf23aad23464859973c5cd2066ca2fd99e0b934acefbb0b728ac2525bf"}, - {file = "pymongo-3.11.3-cp35-cp35m-win_amd64.whl", hash = "sha256:e1414599a97554d451e441afb362dbee1505e4550852c0068370d843757a3fe2"}, - {file = "pymongo-3.11.3-cp36-cp36m-macosx_10_6_intel.whl", hash = "sha256:0384d76b409278ddb34ac19cdc4664511685959bf719adbdc051875ded4689aa"}, - {file = "pymongo-3.11.3-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:22ee2c94fee1e391735be63aa1c9af4c69fdcb325ae9e5e4ddff770248ef60a6"}, - {file = "pymongo-3.11.3-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:db6fd53ef5f1914ad801830406440c3bfb701e38a607eda47c38adba267ba300"}, - {file = "pymongo-3.11.3-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:66b688fc139c6742057795510e3b12c4acbf90d11af1eff9689a41d9c84478d6"}, - {file = "pymongo-3.11.3-cp36-cp36m-manylinux2014_i686.whl", hash = "sha256:6a5834e392c97f19f36670e34bf9d346d733ad89ee0689a6419dd737dfa4308a"}, - {file = "pymongo-3.11.3-cp36-cp36m-manylinux2014_ppc64le.whl", hash = "sha256:87981008d565f647142869d99915cc4760b7725858da3d39ecb2a606e23f36fd"}, - {file = "pymongo-3.11.3-cp36-cp36m-manylinux2014_s390x.whl", hash = "sha256:413b18ac2222f5d961eb8d1c8dcca6c6ca176c8613636d8c13aa23abae7f7a21"}, - {file = "pymongo-3.11.3-cp36-cp36m-manylinux2014_x86_64.whl", hash = "sha256:610d5cbbfd026e2f6d15665af51e048e49b68363fedece2ed318cc8fe080dd94"}, - {file = "pymongo-3.11.3-cp36-cp36m-win32.whl", hash = "sha256:3873866534b6527e6863e742eb23ea2a539e3c7ee00ad3f9bec9da27dbaaff6f"}, - {file = "pymongo-3.11.3-cp36-cp36m-win_amd64.whl", hash = "sha256:b17e627844d86031c77147c40bf992a6e1114025a460874deeda6500d0f34862"}, - {file = "pymongo-3.11.3-cp37-cp37m-macosx_10_6_intel.whl", hash = "sha256:05e2bda928a3a6bc6ddff9e5a8579d41928b75d7417b18f9a67c82bb52150ac6"}, - {file = "pymongo-3.11.3-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:19d52c60dc37520385f538d6d1a4c40bc398e0885f4ed6a36ce10b631dab2852"}, - {file = "pymongo-3.11.3-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:2163d736d6f62b20753be5da3dc07a188420b355f057fcbb3075b05ee6227b2f"}, - {file = "pymongo-3.11.3-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:b4535d98df83abebb572035754fb3d4ad09ce7449375fa09fa9ede2dbc87b62b"}, - {file = "pymongo-3.11.3-cp37-cp37m-manylinux2014_i686.whl", hash = "sha256:cd8fc35d4c0c717cc29b0cb894871555cb7137a081e179877ecc537e2607f0b9"}, - {file = "pymongo-3.11.3-cp37-cp37m-manylinux2014_ppc64le.whl", hash = "sha256:92e2376ce3ca0e3e443b3c5c2bb5d584c7e59221edfb0035313c6306049ba55a"}, - {file = "pymongo-3.11.3-cp37-cp37m-manylinux2014_s390x.whl", hash = "sha256:4ca92e15fcf02e02e7c24b448a16599b98c9d0e6a46cd85cc50804450ebf7245"}, - {file = "pymongo-3.11.3-cp37-cp37m-manylinux2014_x86_64.whl", hash = "sha256:5a03ae5ac85b04b2034a0689add9ff597b16d5e24066a87f6ab0e9fa67049156"}, - {file = "pymongo-3.11.3-cp37-cp37m-win32.whl", hash = "sha256:bc2eb67387b8376120a2be6cba9d23f9d6a6c3828e00fb0a64c55ad7b54116d1"}, - {file = "pymongo-3.11.3-cp37-cp37m-win_amd64.whl", hash = "sha256:5e1341276ce8b7752db9aeac6bbb0cbe82a3f6a6186866bf6b4906d8d328d50b"}, - {file = "pymongo-3.11.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:4ac387ac1be71b798d1c372a924f9c30352f30e684e06f086091297352698ac0"}, - {file = "pymongo-3.11.3-cp38-cp38-manylinux1_i686.whl", hash = "sha256:728313cc0d59d1a1a004f675607dcf5c711ced3f55e75d82b3f264fd758869f3"}, - {file = "pymongo-3.11.3-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:daa44cefde19978af57ac1d50413cd86ebf2b497328e7a27832f5824bda47439"}, - {file = "pymongo-3.11.3-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:322f6cc7bf23a264151ebc5229a92600c4b55ac83c83c91c9bab1ec92c888a8d"}, - {file = "pymongo-3.11.3-cp38-cp38-manylinux2014_i686.whl", hash = "sha256:6043d251fac27ca04ff22ed8deb5ff7a43dc18e8a4a15b4c442d2a20fa313162"}, - {file = "pymongo-3.11.3-cp38-cp38-manylinux2014_ppc64le.whl", hash = "sha256:66573c8c7808cce4f3b56c23cb7cad6c3d7f4c464b9016d35f5344ad743896d7"}, - {file = "pymongo-3.11.3-cp38-cp38-manylinux2014_s390x.whl", hash = "sha256:bf70097bd497089f1baabf9cbb3ec4f69c022dc7a70c41ba9c238fa4d0fff7ab"}, - {file = "pymongo-3.11.3-cp38-cp38-manylinux2014_x86_64.whl", hash = "sha256:f23abcf6eca5859a2982beadfb5111f8c5e76e30ff99aaee3c1c327f814f9f10"}, - {file = "pymongo-3.11.3-cp38-cp38-win32.whl", hash = "sha256:1d559a76ae87143ad96c2ecd6fdd38e691721e175df7ced3fcdc681b4638bca1"}, - {file = "pymongo-3.11.3-cp38-cp38-win_amd64.whl", hash = "sha256:152e4ac3158b776135d8fce28d2ac06e682b885fcbe86690d66465f262ab244e"}, - {file = "pymongo-3.11.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:34c15f5798f23488e509eae82fbf749c3d17db74379a88c07c869ece1aa806b9"}, - {file = "pymongo-3.11.3-cp39-cp39-manylinux1_i686.whl", hash = "sha256:210ec4a058480b9c3869082e52b66d80c4a48eda9682d7a569a1a5a48100ea54"}, - {file = "pymongo-3.11.3-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:b44fa04720bbfd617b6aef036989c8c30435f11450c0a59136291d7b41ed647f"}, - {file = "pymongo-3.11.3-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:b32e4eed2ef19a20dfb57698497a9bc54e74efb2e260c003e9056c145f130dc7"}, - {file = "pymongo-3.11.3-cp39-cp39-manylinux2014_i686.whl", hash = "sha256:5091aacbdb667b418b751157f48f6daa17142c4f9063d58e5a64c90b2afbdf9a"}, - {file = "pymongo-3.11.3-cp39-cp39-manylinux2014_ppc64le.whl", hash = "sha256:bb6a5777bf558f444cd4883d617546182cfeff8f2d4acd885253f11a16740534"}, - {file = "pymongo-3.11.3-cp39-cp39-manylinux2014_s390x.whl", hash = "sha256:980527f4ccc6644855bb68056fe7835da6d06d37776a52df5bcc1882df57c3db"}, - {file = "pymongo-3.11.3-cp39-cp39-manylinux2014_x86_64.whl", hash = "sha256:65b67637f0a25ac9d25efb13c1578eb065870220ffa82f132c5b2d8e43ac39c3"}, - {file = "pymongo-3.11.3-cp39-cp39-win32.whl", hash = "sha256:f6748c447feeadda059719ef5ab1fb9d84bd370e205b20049a0e8b45ef4ad593"}, - {file = "pymongo-3.11.3-cp39-cp39-win_amd64.whl", hash = "sha256:ee42a8f850143ae7c67ea09a183a6a4ad8d053e1dbd9a1134e21a7b5c1bc6c73"}, - {file = "pymongo-3.11.3-py2.7-macosx-10.14-intel.egg", hash = "sha256:7edff02e44dd0badd749d7342e40705a398d98c5d8f7570f57cff9568c2351fa"}, - {file = "pymongo-3.11.3.tar.gz", hash = "sha256:db5098587f58fbf8582d9bda2462762b367207246d3e19623782fb449c3c5fcc"}, + {file = "pymongo-3.11.4-cp27-cp27m-macosx_10_14_intel.whl", hash = "sha256:b7efc7e7049ef366777cfd35437c18a4166bb50a5606a1c840ee3b9624b54fc9"}, + {file = "pymongo-3.11.4-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:517ba47ca04a55b1f50ee8df9fd97f6c37df5537d118fb2718952b8623860466"}, + {file = "pymongo-3.11.4-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:225c61e08fe517aede7912937939e09adf086c8e6f7e40d4c85ad678c2c2aea3"}, + {file = "pymongo-3.11.4-cp27-cp27m-win32.whl", hash = "sha256:e4e9db78b71db2b1684ee4ecc3e32c4600f18cdf76e6b9ae03e338e52ee4b168"}, + {file = "pymongo-3.11.4-cp27-cp27m-win_amd64.whl", hash = "sha256:8e0004b0393d72d76de94b4792a006cb960c1c65c7659930fbf9a81ce4341982"}, + {file = "pymongo-3.11.4-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:fedf0dee7a412ca6d1d6d92c158fe9cbaa8ea0cae90d268f9ccc0744de7a97d0"}, + {file = "pymongo-3.11.4-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:f947b359cc4769af8b49be7e37af01f05fcf15b401da2528021148e4a54426d1"}, + {file = "pymongo-3.11.4-cp34-cp34m-macosx_10_6_intel.whl", hash = "sha256:3a3498a8326111221560e930f198b495ea6926937e249f475052ffc6893a6680"}, + {file = "pymongo-3.11.4-cp34-cp34m-manylinux1_i686.whl", hash = "sha256:9a4f6e0b01df820ba9ed0b4e618ca83a1c089e48d4f268d0e00dcd49893d4549"}, + {file = "pymongo-3.11.4-cp34-cp34m-manylinux1_x86_64.whl", hash = "sha256:d65bac5f6724d9ea6f0b5a0f0e4952fbbf209adcf6b5583b54c54bd2fcd74dc0"}, + {file = "pymongo-3.11.4-cp34-cp34m-win32.whl", hash = "sha256:15b083d1b789b230e5ac284442d9ecb113c93f3785a6824f748befaab803b812"}, + {file = "pymongo-3.11.4-cp34-cp34m-win_amd64.whl", hash = "sha256:f08665d3cc5abc2f770f472a9b5f720a9b3ab0b8b3bb97c7c1487515e5653d39"}, + {file = "pymongo-3.11.4-cp35-cp35m-macosx_10_6_intel.whl", hash = "sha256:977b1d4f868986b4ba5d03c317fde4d3b66e687d74473130cd598e3103db34fa"}, + {file = "pymongo-3.11.4-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:510cd3bfabb63a07405b7b79fae63127e34c118b7531a2cbbafc7a24fd878594"}, + {file = "pymongo-3.11.4-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:071552b065e809d24c5653fcc14968cfd6fde4e279408640d5ac58e3353a3c5f"}, + {file = "pymongo-3.11.4-cp35-cp35m-manylinux2014_aarch64.whl", hash = "sha256:f4ba58157e8ae33ee86fadf9062c506e535afd904f07f9be32731f4410a23b7f"}, + {file = "pymongo-3.11.4-cp35-cp35m-manylinux2014_i686.whl", hash = "sha256:b413117210fa6d92664c3d860571e8e8727c3e8f2ff197276c5d0cb365abd3ad"}, + {file = "pymongo-3.11.4-cp35-cp35m-manylinux2014_ppc64le.whl", hash = "sha256:08b8723248730599c9803ae4c97b8f3f76c55219104303c88cb962a31e3bb5ee"}, + {file = "pymongo-3.11.4-cp35-cp35m-manylinux2014_s390x.whl", hash = "sha256:8a41fdc751dc4707a4fafb111c442411816a7c225ebb5cadb57599534b5d5372"}, + {file = "pymongo-3.11.4-cp35-cp35m-manylinux2014_x86_64.whl", hash = "sha256:f664ed7613b8b18f0ce5696b146776266a038c19c5cd6efffa08ecc189b01b73"}, + {file = "pymongo-3.11.4-cp35-cp35m-win32.whl", hash = "sha256:5c36428cc4f7fae56354db7f46677fd21222fc3cb1e8829549b851172033e043"}, + {file = "pymongo-3.11.4-cp35-cp35m-win_amd64.whl", hash = "sha256:d0a70151d7de8a3194cdc906bcc1a42e14594787c64b0c1c9c975e5a2af3e251"}, + {file = "pymongo-3.11.4-cp36-cp36m-macosx_10_6_intel.whl", hash = "sha256:9b9298964389c180a063a9e8bac8a80ed42de11d04166b20249bfa0a489e0e0f"}, + {file = "pymongo-3.11.4-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:b2f41261b648cf5dee425f37ff14f4ad151c2f24b827052b402637158fd056ef"}, + {file = "pymongo-3.11.4-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:e02beaab433fd1104b2804f909e694cfbdb6578020740a9051597adc1cd4e19f"}, + {file = "pymongo-3.11.4-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:8898f6699f740ca93a0879ed07d8e6db02d68af889d0ebb3d13ab017e6b1af1e"}, + {file = "pymongo-3.11.4-cp36-cp36m-manylinux2014_i686.whl", hash = "sha256:62c29bc36a6d9be68fe7b5aaf1e120b4aa66a958d1e146601fcd583eb12cae7b"}, + {file = "pymongo-3.11.4-cp36-cp36m-manylinux2014_ppc64le.whl", hash = "sha256:424799c71ff435094e5fb823c40eebb4500f0e048133311e9c026467e8ccebac"}, + {file = "pymongo-3.11.4-cp36-cp36m-manylinux2014_s390x.whl", hash = "sha256:3551912f5c34d8dd7c32c6bb00ae04192af47f7b9f653608f107d19c1a21a194"}, + {file = "pymongo-3.11.4-cp36-cp36m-manylinux2014_x86_64.whl", hash = "sha256:5db59223ed1e634d842a053325f85f908359c6dac9c8ddce8ef145061fae7df8"}, + {file = "pymongo-3.11.4-cp36-cp36m-win32.whl", hash = "sha256:fea5cb1c63efe1399f0812532c7cf65458d38fd011be350bc5021dfcac39fba8"}, + {file = "pymongo-3.11.4-cp36-cp36m-win_amd64.whl", hash = "sha256:d4e62417e89b717a7bcd8576ac3108cd063225942cc91c5b37ff5465fdccd386"}, + {file = "pymongo-3.11.4-cp37-cp37m-macosx_10_6_intel.whl", hash = "sha256:4c7e8c8e1e1918dcf6a652ac4b9d87164587c26fd2ce5dd81e73a5ab3b3d492f"}, + {file = "pymongo-3.11.4-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:38a7b5140a48fc91681cdb5cb95b7cd64640b43d19259fdd707fa9d5a715f2b2"}, + {file = "pymongo-3.11.4-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:aff3656af2add93f290731a6b8930b23b35c0c09569150130a58192b3ec6fc61"}, + {file = "pymongo-3.11.4-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:03be7ad107d252bb7325d4af6309fdd2c025d08854d35f0e7abc8bf048f4245e"}, + {file = "pymongo-3.11.4-cp37-cp37m-manylinux2014_i686.whl", hash = "sha256:6060794aac9f7b0644b299f46a9c6cbc0bc470bd01572f4134df140afd41ded6"}, + {file = "pymongo-3.11.4-cp37-cp37m-manylinux2014_ppc64le.whl", hash = "sha256:73326b211e7410c8bd6a74500b1e3f392f39cf10862e243d00937e924f112c01"}, + {file = "pymongo-3.11.4-cp37-cp37m-manylinux2014_s390x.whl", hash = "sha256:20d75ea11527331a2980ab04762a9d960bcfea9475c54bbeab777af880de61cd"}, + {file = "pymongo-3.11.4-cp37-cp37m-manylinux2014_x86_64.whl", hash = "sha256:3135dd574ef1286189f3f04a36c8b7a256376914f8cbbce66b94f13125ded858"}, + {file = "pymongo-3.11.4-cp37-cp37m-win32.whl", hash = "sha256:7c97554ea521f898753d9773891d0347ebfaddcc1dee2ad94850b163171bf1f1"}, + {file = "pymongo-3.11.4-cp37-cp37m-win_amd64.whl", hash = "sha256:a08c8b322b671857c81f4c30cd3c8df2895fd3c0e9358714f39e0ef8fb327702"}, + {file = "pymongo-3.11.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:f3d851af3852f16ad4adc7ee054fd9c90a7a5063de94d815b7f6a88477b9f4c6"}, + {file = "pymongo-3.11.4-cp38-cp38-manylinux1_i686.whl", hash = "sha256:3bfc7689a1bacb9bcd2f2d5185d99507aa29f667a58dd8adaa43b5a348139e46"}, + {file = "pymongo-3.11.4-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:b8f94acd52e530a38f25e4d5bf7ddfdd4bea9193e718f58419def0d4406b58d3"}, + {file = "pymongo-3.11.4-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:e4b631688dfbdd61b5610e20b64b99d25771c6d52d9da73349342d2a0f11c46a"}, + {file = "pymongo-3.11.4-cp38-cp38-manylinux2014_i686.whl", hash = "sha256:474e21d0e07cd09679e357d1dac76e570dab86665e79a9d3354b10a279ac6fb3"}, + {file = "pymongo-3.11.4-cp38-cp38-manylinux2014_ppc64le.whl", hash = "sha256:421d13523d11c57f57f257152bc4a6bb463aadf7a3918e9c96fefdd6be8dbfb8"}, + {file = "pymongo-3.11.4-cp38-cp38-manylinux2014_s390x.whl", hash = "sha256:0cabfc297f4cf921f15bc789a8fbfd7115eb9f813d3f47a74b609894bc66ab0d"}, + {file = "pymongo-3.11.4-cp38-cp38-manylinux2014_x86_64.whl", hash = "sha256:fe4189846448df013cd9df11bba38ddf78043f8c290a9f06430732a7a8601cce"}, + {file = "pymongo-3.11.4-cp38-cp38-win32.whl", hash = "sha256:eb4d176394c37a76e8b0afe54b12d58614a67a60a7f8c0dd3a5afbb013c01092"}, + {file = "pymongo-3.11.4-cp38-cp38-win_amd64.whl", hash = "sha256:fffff7bfb6799a763d3742c59c6ee7ffadda21abed557637bc44ed1080876484"}, + {file = "pymongo-3.11.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:13acf6164ead81c9fc2afa0e1ea6d6134352973ce2bb35496834fee057063c04"}, + {file = "pymongo-3.11.4-cp39-cp39-manylinux1_i686.whl", hash = "sha256:d360e5d5dd3d55bf5d1776964625018d85b937d1032bae1926dd52253decd0db"}, + {file = "pymongo-3.11.4-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:0aaf4d44f1f819360f9432df538d54bbf850f18152f34e20337c01b828479171"}, + {file = "pymongo-3.11.4-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:08bda7b2c522ff9f1e554570da16298271ebb0c56ab9699446aacba249008988"}, + {file = "pymongo-3.11.4-cp39-cp39-manylinux2014_i686.whl", hash = "sha256:1a994a42f49dab5b6287e499be7d3d2751776486229980d8857ad53b8333d469"}, + {file = "pymongo-3.11.4-cp39-cp39-manylinux2014_ppc64le.whl", hash = "sha256:161fcd3281c42f644aa8dec7753cca2af03ce654e17d76da4f0dab34a12480ca"}, + {file = "pymongo-3.11.4-cp39-cp39-manylinux2014_s390x.whl", hash = "sha256:78f07961f4f214ea8e80be63cffd5cc158eb06cd922ffbf6c7155b11728f28f9"}, + {file = "pymongo-3.11.4-cp39-cp39-manylinux2014_x86_64.whl", hash = "sha256:ad31f184dcd3271de26ab1f9c51574afb99e1b0e484ab1da3641256b723e4994"}, + {file = "pymongo-3.11.4-cp39-cp39-win32.whl", hash = "sha256:5e606846c049ed40940524057bfdf1105af6066688c0e6a1a3ce2038589bae70"}, + {file = "pymongo-3.11.4-cp39-cp39-win_amd64.whl", hash = "sha256:3491c7de09e44eded16824cb58cf9b5cc1dc6f066a0bb7aa69929d02aa53b828"}, + {file = "pymongo-3.11.4-py2.7-macosx-10.14-intel.egg", hash = "sha256:506a6dab4c7ffdcacdf0b8e70bd20eb2e77fa994519547c9d88d676400fcad58"}, + {file = "pymongo-3.11.4.tar.gz", hash = "sha256:539d4cb1b16b57026999c53e5aab857fe706e70ae5310cc8c232479923f932e6"}, ] pynput = [ {file = "pynput-1.7.3-py2.py3-none-any.whl", hash = "sha256:fea5777454f896bd79d35393088cd29a089f3b2da166f0848a922b1d5a807d4f"}, @@ -2107,28 +2116,28 @@ pynput = [ {file = "pynput-1.7.3.tar.gz", hash = "sha256:4e50b1a0ab86847e87e58f6d1993688b9a44f9f4c88d4712315ea8eb552ef828"}, ] pyobjc-core = [ - {file = "pyobjc-core-7.1.tar.gz", hash = "sha256:a0616d5d816b4471f8f782c3a9a8923d2cc85014d88ad4f7fec694be9e6ea349"}, - {file = "pyobjc_core-7.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:f9fb45c9916f2a03ecd6b9ecde4c35d1d0f1a590ae2ea2372f9d9a360226ac1d"}, - {file = "pyobjc_core-7.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:fff8e87358c6195a2937004f279050cce3d4c02cd77acd73c5ad367307def855"}, - {file = "pyobjc_core-7.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:afb38efd3f2960eb49eb78552d465cfd025a9d6efa06cd4cd8694dafbe7c6e06"}, - {file = "pyobjc_core-7.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:7cb329c4119044fe83bcb3c5d4794d636c706ff0cb7c1c77d36ef5c373100082"}, - {file = "pyobjc_core-7.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7913d7b20217c294900537faf58e5cc15942ed7af277bf05db25667d18255114"}, + {file = "pyobjc-core-7.2.tar.gz", hash = "sha256:9e9ec482d80ea030cdb1613d05a247f31eedabe6666d884d42dd890cc5fb0e05"}, + {file = "pyobjc_core-7.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:94b4d9de9d228db52dd35012096d63bdf8c1ace58ea3be1d5f6f39313cd502f2"}, + {file = "pyobjc_core-7.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:971cbd7189ae1aa03ef0d16124aa5bcd053779e0e6b6011a41c3dbd5b4ea7e88"}, + {file = "pyobjc_core-7.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:9d93b20394008373d6d2856d49aaff26f4b97ff42d924a14516c8a82313ec8c0"}, + {file = "pyobjc_core-7.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:860183540d1be792c26426018139ac8ba75e85f675c59ba080ccdc52d8e74c7a"}, + {file = "pyobjc_core-7.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:ffe61d3c2a404354daf2d895e34e38c5044453353581b3c396bf5365de26250c"}, ] pyobjc-framework-cocoa = [ - {file = "pyobjc-framework-Cocoa-7.1.tar.gz", hash = "sha256:67966152b3d38a0225176fceca2e9f56d849c8e7445548da09a00cb13155ec3e"}, - {file = "pyobjc_framework_Cocoa-7.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:bef77eafaac5eaf1d91d479d5483fd02216caa3edc27e8f5adc9af0b3fecdac3"}, - {file = "pyobjc_framework_Cocoa-7.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:b2ea3582c456827dc20e648c905fdbcf8d3dfae89434f981e9b761cd07262049"}, - {file = "pyobjc_framework_Cocoa-7.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1a4050f2d776f40c2409a151c6f7896420e936934b3bdbfabedf91509637ed9b"}, - {file = "pyobjc_framework_Cocoa-7.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:3f68f022f1f6d5985c418e10c6608c562fcf4bfe3714ec64fd10ce3dc6221bd4"}, - {file = "pyobjc_framework_Cocoa-7.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:ecfefd4c48dae42275c18679c69f6f2fff970e711097515a0a8732fc10194018"}, + {file = "pyobjc-framework-Cocoa-7.2.tar.gz", hash = "sha256:c8b23f03dc3f4436d36c0fd006a8a084835c4f6015187df7c3aa5de8ecd5c653"}, + {file = "pyobjc_framework_Cocoa-7.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:8e5dd5daa0096755937ec24c345a4b07c3fa131a457f99e0fdeeb01979178ec7"}, + {file = "pyobjc_framework_Cocoa-7.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:828d183947fc7746953fd0c9b1092cc423745ba0b49719e7b7d1e1614aaa20ec"}, + {file = "pyobjc_framework_Cocoa-7.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:7e4c6d7baa0c2ab5ea5efb8836ad0b3b3976cffcfc6195c1f195e826c6eb5744"}, + {file = "pyobjc_framework_Cocoa-7.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:c9a9d1d49cc5a810773c88d6de821e60c8cc41d01113cf1b9e7662938f5f7d66"}, + {file = "pyobjc_framework_Cocoa-7.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:506c2cd09f421eac92b9008a0142174c3d1d70ecd4b0e3fa2b924767995fd14e"}, ] pyobjc-framework-quartz = [ - {file = "pyobjc-framework-Quartz-7.1.tar.gz", hash = "sha256:73102c9f4dbfa13275621014785ab3b684cf03ce93a4b0b270500c795349bea9"}, - {file = "pyobjc_framework_Quartz-7.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:7207a26244f02d4534ebb007fa55a9dc7c1b7fbb490d1e89e0d62cfd175e20f3"}, - {file = "pyobjc_framework_Quartz-7.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:5bc7a4fb3ea80b5af6910cc27729a0774a96327a69583fcf28057cb2ffce33ac"}, - {file = "pyobjc_framework_Quartz-7.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:c0469d60d4a79fc252f74adaa8177d2c680621d858c1b8ef19c411e903e2c892"}, - {file = "pyobjc_framework_Quartz-7.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:04953c031fc35020682bd4613b9b5a9688bdb9eab7ed76fd8dcf028783568b4f"}, - {file = "pyobjc_framework_Quartz-7.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:d8e0c086faf649f86386d0ed99194c6d0704b602576e2b258532b635b510b790"}, + {file = "pyobjc-framework-Quartz-7.2.tar.gz", hash = "sha256:ea554e5697bc6747a4ce793c0b0036da16622b44ff75196d6124603008922afa"}, + {file = "pyobjc_framework_Quartz-7.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:dc61fe61d26f797e4335f3ffc891bcef64624c728c2603e3307b3910580b2cb8"}, + {file = "pyobjc_framework_Quartz-7.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:ad8103cc38923f2708904db11a0992ea960125ce6adf7b4c7a77d8fdafd412c4"}, + {file = "pyobjc_framework_Quartz-7.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:4549d17ca41f0bf62792d5bc4b4293ba9a6cc560014b3e18ba22c65e4a5030d2"}, + {file = "pyobjc_framework_Quartz-7.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:da16e4f1e13cb7b02e30fa538cbb3a356e4a694bbc2bb26d2bd100ca12a54ff6"}, + {file = "pyobjc_framework_Quartz-7.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:c1f6471177a39535cd0358ae29b8f3d31fe778a21deb74105c448c4e726619d7"}, ] pyparsing = [ {file = "pyparsing-2.4.7-py2.py3-none-any.whl", hash = "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b"}, @@ -2148,34 +2157,26 @@ pyqt5-qt5 = [ {file = "PyQt5_Qt5-5.15.2-py3-none-win_amd64.whl", hash = "sha256:750b78e4dba6bdf1607febedc08738e318ea09e9b10aea9ff0d73073f11f6962"}, ] pyqt5-sip = [ - {file = "PyQt5_sip-12.8.1-cp35-cp35m-macosx_10_6_intel.whl", hash = "sha256:bb5a87b66fc1445915104ee97f7a20a69decb42f52803e3b0795fa17ff88226c"}, - {file = "PyQt5_sip-12.8.1-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:a29e2ac399429d3b7738f73e9081e50783e61ac5d29344e0802d0dcd6056c5a2"}, - {file = "PyQt5_sip-12.8.1-cp35-cp35m-win32.whl", hash = "sha256:0304ca9114b9817a270f67f421355075b78ff9fc25ac58ffd72c2601109d2194"}, - {file = "PyQt5_sip-12.8.1-cp35-cp35m-win_amd64.whl", hash = "sha256:84ba7746762bd223bed22428e8561aa267a229c28344c2d28c5d5d3f8970cffb"}, - {file = "PyQt5_sip-12.8.1-cp36-cp36m-macosx_10_6_intel.whl", hash = "sha256:7b81382ce188d63890a0e35abe0f9bb946cabc873a31873b73583b0fc84ac115"}, - {file = "PyQt5_sip-12.8.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:b6d42250baec52a5f77de64e2951d001c5501c3a2df2179f625b241cbaec3369"}, - {file = "PyQt5_sip-12.8.1-cp36-cp36m-win32.whl", hash = "sha256:6c1ebee60f1d2b3c70aff866b7933d8d8d7646011f7c32f9321ee88c290aa4f9"}, - {file = "PyQt5_sip-12.8.1-cp36-cp36m-win_amd64.whl", hash = "sha256:34dcd29be47553d5f016ff86e89e24cbc5eebae92eb2f96fb32d2d7ba028c43c"}, - {file = "PyQt5_sip-12.8.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:ed897c58acf4a3cdca61469daa31fe6e44c33c6c06a37c3f21fab31780b3b86a"}, - {file = "PyQt5_sip-12.8.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:a1b8ef013086e224b8e86c93f880f776d01b59195bdfa2a8e0b23f0480678fec"}, - {file = "PyQt5_sip-12.8.1-cp37-cp37m-win32.whl", hash = "sha256:0cd969be528c27bbd4755bd323dff4a79a8fdda28215364e6ce3e069cb56c2a9"}, - {file = "PyQt5_sip-12.8.1-cp37-cp37m-win_amd64.whl", hash = "sha256:c9800729badcb247765e4ffe2241549d02da1fa435b9db224845bc37c3e99cb0"}, - {file = "PyQt5_sip-12.8.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:9312ec47cac4e33c11503bc1cbeeb0bdae619620472f38e2078c5a51020a930f"}, - {file = "PyQt5_sip-12.8.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:2f35e82fd7ec1e1f6716e9154721c7594956a4f5bd4f826d8c6a6453833cc2f0"}, - {file = "PyQt5_sip-12.8.1-cp38-cp38-win32.whl", hash = "sha256:da9c9f1e65b9d09e73bd75befc82961b6b61b5a3b9d0a7c832168e1415f163c6"}, - {file = "PyQt5_sip-12.8.1-cp38-cp38-win_amd64.whl", hash = "sha256:832fd60a264de4134c2824d393320838f3ab648180c9c357ec58a74524d24507"}, - {file = "PyQt5_sip-12.8.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:c317ab1263e6417c498b81f5c970a9b1af7acefab1f80b4cc0f2f8e661f29fc5"}, - {file = "PyQt5_sip-12.8.1-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:c9d6d448c29dc6606bb7974696608f81f4316c8234f7c7216396ed110075e777"}, - {file = "PyQt5_sip-12.8.1-cp39-cp39-win32.whl", hash = "sha256:5a011aeff89660622a6d5c3388d55a9d76932f3b82c95e82fc31abd8b1d2990d"}, - {file = "PyQt5_sip-12.8.1-cp39-cp39-win_amd64.whl", hash = "sha256:f168f0a7f32b81bfeffdf003c36f25d81c97dee5eb67072a5183e761fe250f13"}, - {file = "PyQt5_sip-12.8.1.tar.gz", hash = "sha256:30e944db9abee9cc757aea16906d4198129558533eb7fadbe48c5da2bd18e0bd"}, + {file = "PyQt5_sip-12.9.0-cp36-cp36m-macosx_10_6_intel.whl", hash = "sha256:d85002238b5180bce4b245c13d6face848faa1a7a9e5c6e292025004f2fd619a"}, + {file = "PyQt5_sip-12.9.0-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:83c3220b1ca36eb8623ba2eb3766637b19eb0ce9f42336ad8253656d32750c0a"}, + {file = "PyQt5_sip-12.9.0-cp36-cp36m-win_amd64.whl", hash = "sha256:69a3ad4259172e2b1aa9060de211efac39ddd734a517b1924d9c6c0cc4f55f96"}, + {file = "PyQt5_sip-12.9.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:42274a501ab4806d2c31659170db14c282b8313d2255458064666d9e70d96206"}, + {file = "PyQt5_sip-12.9.0-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:6a8701892a01a5a2a4720872361197cc80fdd5f49c8482d488ddf38c9c84f055"}, + {file = "PyQt5_sip-12.9.0-cp37-cp37m-win_amd64.whl", hash = "sha256:4347bd81d30c8e3181e553b3734f91658cfbdd8f1a19f254777f906870974e6d"}, + {file = "PyQt5_sip-12.9.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:c446971c360a0a1030282a69375a08c78e8a61d568bfd6dab3dcc5cf8817f644"}, + {file = "PyQt5_sip-12.9.0-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:fc43f2d7c438517ee33e929e8ae77132749c15909afab6aeece5fcf4147ffdb5"}, + {file = "PyQt5_sip-12.9.0-cp38-cp38-win_amd64.whl", hash = "sha256:c5216403d4d8d857ec4a61f631d3945e44fa248aa2415e9ee9369ab7c8a4d0c7"}, + {file = "PyQt5_sip-12.9.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a25b9843c7da6a1608f310879c38e6434331aab1dc2fe6cb65c14f1ecf33780e"}, + {file = "PyQt5_sip-12.9.0-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:dd05c768c2b55ffe56a9d49ce6cc77cdf3d53dbfad935258a9e347cbfd9a5850"}, + {file = "PyQt5_sip-12.9.0-cp39-cp39-win_amd64.whl", hash = "sha256:b09f4cd36a4831229fb77c424d89635fa937d97765ec90685e2f257e56a2685a"}, + {file = "PyQt5_sip-12.9.0.tar.gz", hash = "sha256:d3e4489d7c2b0ece9d203ae66e573939f7f60d4d29e089c9f11daa17cfeaae32"}, ] pyrsistent = [ {file = "pyrsistent-0.17.3.tar.gz", hash = "sha256:2e636185d9eb976a18a8a8e96efce62f2905fea90041958d8cc2a189756ebf3e"}, ] pytest = [ - {file = "pytest-6.2.3-py3-none-any.whl", hash = "sha256:6ad9c7bdf517a808242b998ac20063c41532a570d088d77eec1ee12b0b5574bc"}, - {file = "pytest-6.2.3.tar.gz", hash = "sha256:671238a46e4df0f3498d1c3270e5deb9b32d25134c99b7d75370a68cfbe9b634"}, + {file = "pytest-6.2.4-py3-none-any.whl", hash = "sha256:91ef2131a9bd6be8f76f1f08eac5c5317221d6ad1e143ae03894b862e8976890"}, + {file = "pytest-6.2.4.tar.gz", hash = "sha256:50bcad0a0b9c5a72c8e4e7c9855a3ad496ca6a881a3641b4260605450772c54b"}, ] pytest-cov = [ {file = "pytest-cov-2.11.1.tar.gz", hash = "sha256:359952d9d39b9f822d9d29324483e7ba04a3a17dd7d05aa6beb7ea01e359e5f7"}, @@ -2236,9 +2237,13 @@ secretstorage = [ {file = "SecretStorage-3.3.1-py3-none-any.whl", hash = "sha256:422d82c36172d88d6a0ed5afdec956514b189ddbfb72fefab0c8a1cee4eaf71f"}, {file = "SecretStorage-3.3.1.tar.gz", hash = "sha256:fd666c51a6bf200643495a04abb261f83229dcb6fd8472ec393df7ffc8b6f195"}, ] +semver = [ + {file = "semver-2.13.0-py2.py3-none-any.whl", hash = "sha256:ced8b23dceb22134307c1b8abfa523da14198793d9787ac838e70e29e77458d4"}, + {file = "semver-2.13.0.tar.gz", hash = "sha256:fa0fe2722ee1c3f57eac478820c3a5ae2f624af8264cbdf9000c980ff7f75e3f"}, +] six = [ - {file = "six-1.15.0-py2.py3-none-any.whl", hash = "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced"}, - {file = "six-1.15.0.tar.gz", hash = "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259"}, + {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, + {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, ] snowballstemmer = [ {file = "snowballstemmer-2.1.0-py2.py3-none-any.whl", hash = "sha256:b51b447bea85f9968c13b650126a888aabd4cb4463fca868ec596826325dedc2"}, @@ -2249,8 +2254,8 @@ speedcopy = [ {file = "speedcopy-2.1.0.tar.gz", hash = "sha256:8bb1a6c735900b83901a7be84ba2175ed3887c13c6786f97dea48f2ea7d504c2"}, ] sphinx = [ - {file = "Sphinx-3.5.4-py3-none-any.whl", hash = "sha256:2320d4e994a191f4b4be27da514e46b3d6b420f2ff895d064f52415d342461e8"}, - {file = "Sphinx-3.5.4.tar.gz", hash = "sha256:19010b7b9fa0dc7756a6e105b2aacd3a80f798af3c25c273be64d7beeb482cb1"}, + {file = "Sphinx-4.0.1-py3-none-any.whl", hash = "sha256:b2566f5f339737a6ef37198c47d56de1f4a746c722bebdb2fe045c34bfd8b9d0"}, + {file = "Sphinx-4.0.1.tar.gz", hash = "sha256:cf5104777571b2b7f06fa88ee08fade24563f4a0594cf4bd17d31c47b8740b4c"}, ] sphinx-qt-documentation = [ {file = "sphinx_qt_documentation-0.3-py3-none-any.whl", hash = "sha256:bee247cb9e4fc03fc496d07adfdb943100e1103320c3e5e820e0cfa7c790d9b6"}, @@ -2328,9 +2333,9 @@ typed-ast = [ {file = "typed_ast-1.4.3.tar.gz", hash = "sha256:fb1bbeac803adea29cedd70781399c99138358c26d05fcbd23c13016b7f5ec65"}, ] typing-extensions = [ - {file = "typing_extensions-3.7.4.3-py2-none-any.whl", hash = "sha256:dafc7639cde7f1b6e1acc0f457842a83e722ccca8eef5270af2d74792619a89f"}, - {file = "typing_extensions-3.7.4.3-py3-none-any.whl", hash = "sha256:7cb407020f00f7bfc3cb3e7881628838e69d8f3fcab2f64742a5e76b2f841918"}, - {file = "typing_extensions-3.7.4.3.tar.gz", hash = "sha256:99d4073b617d30288f569d3f13d2bd7548c3a7e4c8de87db09a9d29bb3a4a60c"}, + {file = "typing_extensions-3.10.0.0-py2-none-any.whl", hash = "sha256:0ac0f89795dd19de6b97debb0c6af1c70987fd80a2d62d1958f7e56fcc31b497"}, + {file = "typing_extensions-3.10.0.0-py3-none-any.whl", hash = "sha256:779383f6086d90c99ae41cf0ff39aac8a7937a9283ce0a414e5dd782f4c94a84"}, + {file = "typing_extensions-3.10.0.0.tar.gz", hash = "sha256:50b6f157849174217d0656f99dc82fe932884fb250826c18350e159ec6cdf342"}, ] uritemplate = [ {file = "uritemplate-3.0.1-py2.py3-none-any.whl", hash = "sha256:07620c3f3f8eed1f12600845892b0e036a2420acf513c53f7de0abd911a5894f"}, @@ -2345,8 +2350,8 @@ wcwidth = [ {file = "wcwidth-0.2.5.tar.gz", hash = "sha256:c4d647b99872929fdb7bdcaa4fbe7f01413ed3d98077df798530e5b04f116c83"}, ] websocket-client = [ - {file = "websocket_client-0.58.0-py2.py3-none-any.whl", hash = "sha256:44b5df8f08c74c3d82d28100fdc81f4536809ce98a17f0757557813275fbb663"}, - {file = "websocket_client-0.58.0.tar.gz", hash = "sha256:63509b41d158ae5b7f67eb4ad20fecbb4eee99434e73e140354dc3ff8e09716f"}, + {file = "websocket-client-0.59.0.tar.gz", hash = "sha256:d376bd60eace9d437ab6d7ee16f4ab4e821c9dae591e1b783c58ebd8aaf80c5c"}, + {file = "websocket_client-0.59.0-py2.py3-none-any.whl", hash = "sha256:2e50d26ca593f70aba7b13a489435ef88b8fc3b5c5643c1ce8808ff9b40f0b32"}, ] wrapt = [ {file = "wrapt-1.12.1.tar.gz", hash = "sha256:b62ffa81fb85f4332a4f609cab4ac40709470da05643a082ec1eb88e6d9b97d7"}, diff --git a/pyproject.toml b/pyproject.toml index 1c3c5ad44e..f7eeafd04f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "OpenPype" -version = "3.0.0-rc4" +version = "3.0.0-rc.5" description = "Open VFX and Animation pipeline with support." authors = ["OpenPype Team "] license = "MIT License" @@ -36,6 +36,7 @@ pyqt5 = "^5.12.2" # ideally should be replaced with PySide2 "Qt.py" = "^1.3.3" speedcopy = "^2.1" six = "^1.15" +semver = "^2.13.0" # for version resolution wsrpc_aiohttp = "^3.1.1" # websocket server pywin32 = { version = "300", markers = "sys_platform == 'win32'" } jinxed = [ diff --git a/setup.py b/setup.py index c096befa34..5fb0b33f2a 100644 --- a/setup.py +++ b/setup.py @@ -98,7 +98,7 @@ executables = [ setup( name="OpenPype", version=__version__, - description="Ultimate pipeline", + description="OpenPype", cmdclass={"build_sphinx": BuildDoc}, options={ "build_exe": build_exe_options, diff --git a/start.py b/start.py index baa75aef21..660d0c9006 100644 --- a/start.py +++ b/start.py @@ -360,7 +360,7 @@ def _determine_mongodb() -> str: def _initialize_environment(openpype_version: OpenPypeVersion) -> None: version_path = openpype_version.path - os.environ["OPENPYPE_VERSION"] = openpype_version.version + os.environ["OPENPYPE_VERSION"] = str(openpype_version) # set OPENPYPE_REPOS_ROOT to point to currently used OpenPype version. os.environ["OPENPYPE_REPOS_ROOT"] = os.path.normpath( version_path.as_posix() @@ -417,6 +417,26 @@ def _find_frozen_openpype(use_version: str = None, openpype_version = None openpype_versions = bootstrap.find_openpype(include_zips=True, staging=use_staging) + # get local frozen version and add it to detected version so if it is + # newer it will be used instead. + local_version_str = bootstrap.get_version( + Path(os.environ["OPENPYPE_ROOT"])) + if local_version_str: + local_version = OpenPypeVersion( + version=local_version_str, + path=Path(os.environ["OPENPYPE_ROOT"])) + if local_version not in openpype_versions: + openpype_versions.append(local_version) + openpype_versions.sort() + # if latest is currently running, ditch whole list + # and run from current without installing it. + if local_version == openpype_versions[-1]: + os.environ["OPENPYPE_TRYOUT"] = "1" + openpype_versions = [] + + else: + print("!!! Warning: cannot determine current running version.") + if not os.getenv("OPENPYPE_TRYOUT"): try: # use latest one found (last in the list is latest) @@ -464,12 +484,9 @@ def _find_frozen_openpype(use_version: str = None, use_version, openpype_versions) if not version_path: - if use_version is not None: - if not openpype_version: - ... - else: - print(("!!! Specified version was not found, using " - "latest available")) + if use_version is not None and openpype_version: + print(("!!! Specified version was not found, using " + "latest available")) # specified version was not found so use latest detected. version_path = openpype_version.path print(f">>> Using version [ {openpype_version} ]") @@ -492,7 +509,15 @@ def _find_frozen_openpype(use_version: str = None, if openpype_version.path.is_file(): print(">>> Extracting zip file ...") - version_path = bootstrap.extract_openpype(openpype_version) + try: + version_path = bootstrap.extract_openpype(openpype_version) + except OSError as e: + print("!!! failed: {}".format(str(e))) + sys.exit(1) + else: + # cleanup zip after extraction + os.unlink(openpype_version.path) + openpype_version.path = version_path _initialize_environment(openpype_version) diff --git a/tests/igniter/test_bootstrap_repos.py b/tests/igniter/test_bootstrap_repos.py index 6c70380ab6..743131acfa 100644 --- a/tests/igniter/test_bootstrap_repos.py +++ b/tests/igniter/test_bootstrap_repos.py @@ -5,72 +5,76 @@ import sys from collections import namedtuple from pathlib import Path from zipfile import ZipFile +from uuid import uuid4 import appdirs import pytest from igniter.bootstrap_repos import BootstrapRepos -from igniter.bootstrap_repos import PypeVersion -from pype.lib import OpenPypeSettingsRegistry +from igniter.bootstrap_repos import OpenPypeVersion +from igniter.user_settings import OpenPypeSettingsRegistry @pytest.fixture def fix_bootstrap(tmp_path, pytestconfig): + """This will fix BoostrapRepos with temp paths.""" bs = BootstrapRepos() bs.live_repo_dir = pytestconfig.rootpath / 'repos' bs.data_dir = tmp_path return bs -def test_pype_version(): - v1 = PypeVersion(1, 2, 3) +def test_openpype_version(printer): + """Test determination of OpenPype versions.""" + v1 = OpenPypeVersion(1, 2, 3) assert str(v1) == "1.2.3" - v2 = PypeVersion(1, 2, 3, client="x") + v2 = OpenPypeVersion(1, 2, 3, prerelease="x") assert str(v2) == "1.2.3-x" - assert v1 < v2 + assert v1 > v2 - v3 = PypeVersion(1, 2, 3, variant="staging") - assert str(v3) == "1.2.3-staging" + v3 = OpenPypeVersion(1, 2, 3, staging=True) + assert str(v3) == "1.2.3+staging" - v4 = PypeVersion(1, 2, 3, variant="staging", client="client") - assert str(v4) == "1.2.3-client-staging" - assert v3 < v4 - assert v1 < v4 + v4 = OpenPypeVersion(1, 2, 3, staging="True", prerelease="rc.1") + assert str(v4) == "1.2.3-rc.1+staging" + assert v3 > v4 + assert v1 > v4 + assert v4 < OpenPypeVersion(1, 2, 3, prerelease="rc.1") - v5 = PypeVersion(1, 2, 3, variant="foo", client="x") - assert str(v5) == "1.2.3-x" + v5 = OpenPypeVersion(1, 2, 3, build="foo", prerelease="x") + assert str(v5) == "1.2.3-x+foo" assert v4 < v5 - v6 = PypeVersion(1, 2, 3, variant="foo") - assert str(v6) == "1.2.3" + v6 = OpenPypeVersion(1, 2, 3, prerelease="foo") + assert str(v6) == "1.2.3-foo" - v7 = PypeVersion(2, 0, 0) + v7 = OpenPypeVersion(2, 0, 0) assert v1 < v7 - v8 = PypeVersion(0, 1, 5) + v8 = OpenPypeVersion(0, 1, 5) assert v8 < v7 - v9 = PypeVersion(1, 2, 4) + v9 = OpenPypeVersion(1, 2, 4) assert v9 > v1 - v10 = PypeVersion(1, 2, 2) + v10 = OpenPypeVersion(1, 2, 2) assert v10 < v1 - v11 = PypeVersion(1, 2, 3, path=Path("/foo/bar")) + v11 = OpenPypeVersion(1, 2, 3, path=Path("/foo/bar")) assert v10 < v11 assert v5 == v2 sort_versions = [ - PypeVersion(3, 2, 1), - PypeVersion(1, 2, 3), - PypeVersion(0, 0, 1), - PypeVersion(4, 8, 10), - PypeVersion(4, 8, 20), - PypeVersion(4, 8, 9), - PypeVersion(1, 2, 3, variant="staging"), - PypeVersion(1, 2, 3, client="client") + OpenPypeVersion(3, 2, 1), + OpenPypeVersion(1, 2, 3), + OpenPypeVersion(0, 0, 1), + OpenPypeVersion(4, 8, 10), + OpenPypeVersion(4, 8, 20), + OpenPypeVersion(4, 8, 9), + OpenPypeVersion(1, 2, 3, staging=True), + OpenPypeVersion(1, 2, 3, build="foo") ] res = sorted(sort_versions) @@ -81,57 +85,51 @@ def test_pype_version(): str_versions = [ "5.5.1", - "5.5.2-client", - "5.5.3-client-strange", - "5.5.4-staging", - "5.5.5-staging-client", + "5.5.2-foo", + "5.5.3-foo+strange", + "5.5.4+staging", + "5.5.5+staging-client", "5.6.3", - "5.6.3-staging" + "5.6.3+staging" ] - res_versions = [] - for v in str_versions: - res_versions.append(PypeVersion(version=v)) - + res_versions = [OpenPypeVersion(version=v) for v in str_versions] sorted_res_versions = sorted(res_versions) assert str(sorted_res_versions[0]) == str_versions[0] assert str(sorted_res_versions[-1]) == str_versions[5] - with pytest.raises(ValueError): - _ = PypeVersion() + with pytest.raises(TypeError): + _ = OpenPypeVersion() with pytest.raises(ValueError): - _ = PypeVersion(major=1) + _ = OpenPypeVersion(version="booobaa") - with pytest.raises(ValueError): - _ = PypeVersion(version="booobaa") - - v11 = PypeVersion(version="4.6.7-client-staging") + v11 = OpenPypeVersion(version="4.6.7-foo+staging") assert v11.major == 4 assert v11.minor == 6 - assert v11.subversion == 7 - assert v11.variant == "staging" - assert v11.client == "client" + assert v11.patch == 7 + assert v11.staging is True + assert v11.prerelease == "foo" def test_get_main_version(): - ver = PypeVersion(1, 2, 3, variant="staging", client="foo") + ver = OpenPypeVersion(1, 2, 3, staging=True, prerelease="foo") assert ver.get_main_version() == "1.2.3" def test_get_version_path_from_list(): versions = [ - PypeVersion(1, 2, 3, path=Path('/foo/bar')), - PypeVersion(3, 4, 5, variant="staging", path=Path("/bar/baz")), - PypeVersion(6, 7, 8, client="x", path=Path("boo/goo")) + OpenPypeVersion(1, 2, 3, path=Path('/foo/bar')), + OpenPypeVersion(3, 4, 5, staging=True, path=Path("/bar/baz")), + OpenPypeVersion(6, 7, 8, prerelease="x", path=Path("boo/goo")) ] path = BootstrapRepos.get_version_path_from_list( - "3.4.5-staging", versions) + "3.4.5+staging", versions) assert path == Path("/bar/baz") -def test_search_string_for_pype_version(printer): +def test_search_string_for_openpype_version(printer): strings = [ ("3.0.1", True), ("foo-3.0", False), @@ -142,106 +140,112 @@ def test_search_string_for_pype_version(printer): ] for ver_string in strings: printer(f"testing {ver_string[0]} should be {ver_string[1]}") - assert PypeVersion.version_in_str(ver_string[0])[0] == ver_string[1] + assert OpenPypeVersion.version_in_str(ver_string[0])[0] == \ + ver_string[1] @pytest.mark.slow -def test_install_live_repos(fix_bootstrap, printer): - pype_version = fix_bootstrap.create_version_from_live_code() +def test_install_live_repos(fix_bootstrap, printer, monkeypatch, pytestconfig): + monkeypatch.setenv("OPENPYPE_ROOT", pytestconfig.rootpath.as_posix()) + monkeypatch.setenv("OPENPYPE_DATABASE_NAME", str(uuid4())) + openpype_version = fix_bootstrap.create_version_from_live_code() sep = os.path.sep expected_paths = [ - f"{pype_version.path}{sep}repos{sep}avalon-core", - f"{pype_version.path}{sep}repos{sep}avalon-unreal-integration", - f"{pype_version.path}" + f"{openpype_version.path}{sep}repos{sep}avalon-core", + f"{openpype_version.path}{sep}repos{sep}avalon-unreal-integration", + f"{openpype_version.path}" ] printer("testing zip creation") - assert os.path.exists(pype_version.path), "zip archive was not created" - fix_bootstrap.add_paths_from_archive(pype_version.path) + assert os.path.exists(openpype_version.path), "zip archive was not created" + fix_bootstrap.add_paths_from_archive(openpype_version.path) for ep in expected_paths: assert ep in sys.path, f"{ep} not set correctly" - printer("testing pype imported") - del sys.modules["pype"] - import pype # noqa: F401 + printer("testing openpype imported") + try: + del sys.modules["openpype"] + except KeyError: + # wasn't imported before + pass + import openpype # noqa: F401 - # test if pype is imported from specific location in zip - assert "pype" in sys.modules.keys(), "Pype not imported" - assert sys.modules["pype"].__file__ == \ - f"{pype_version.path}{sep}pype{sep}__init__.py" + # test if openpype is imported from specific location in zip + assert "openpype" in sys.modules.keys(), "OpenPype not imported" + assert sys.modules["openpype"].__file__ == \ + f"{openpype_version.path}{sep}openpype{sep}__init__.py" -def test_find_pype(fix_bootstrap, tmp_path_factory, monkeypatch, printer): - - test_pype = namedtuple("Pype", "prefix version suffix type valid") +def test_find_openpype(fix_bootstrap, tmp_path_factory, monkeypatch, printer): + test_openpype = namedtuple("OpenPype", "prefix version suffix type valid") test_versions_1 = [ - test_pype(prefix="foo-v", version="5.5.1", - suffix=".zip", type="zip", valid=False), - test_pype(prefix="bar-v", version="5.5.2-client", - suffix=".zip", type="zip", valid=True), - test_pype(prefix="baz-v", version="5.5.3-client-strange", - suffix=".zip", type="zip", valid=True), - test_pype(prefix="bum-v", version="5.5.4-staging", - suffix=".zip", type="zip", valid=True), - test_pype(prefix="zum-v", version="5.5.5-client-staging", - suffix=".zip", type="zip", valid=True), - test_pype(prefix="fam-v", version="5.6.3", - suffix=".zip", type="zip", valid=True), - test_pype(prefix="foo-v", version="5.6.3-staging", - suffix=".zip", type="zip", valid=True), - test_pype(prefix="fim-v", version="5.6.3", - suffix=".zip", type="zip", valid=False), - test_pype(prefix="foo-v", version="5.6.4", - suffix=".txt", type="txt", valid=False), - test_pype(prefix="foo-v", version="5.7.1", - suffix="", type="dir", valid=False), + test_openpype(prefix="foo-v", version="5.5.1", + suffix=".zip", type="zip", valid=False), + test_openpype(prefix="bar-v", version="5.5.2-rc.1", + suffix=".zip", type="zip", valid=True), + test_openpype(prefix="baz-v", version="5.5.3-foo-strange", + suffix=".zip", type="zip", valid=True), + test_openpype(prefix="bum-v", version="5.5.4+staging", + suffix=".zip", type="zip", valid=True), + test_openpype(prefix="zum-v", version="5.5.5-foo+staging", + suffix=".zip", type="zip", valid=True), + test_openpype(prefix="fam-v", version="5.6.3", + suffix=".zip", type="zip", valid=True), + test_openpype(prefix="foo-v", version="5.6.3+staging", + suffix=".zip", type="zip", valid=True), + test_openpype(prefix="fim-v", version="5.6.3", + suffix=".zip", type="zip", valid=False), + test_openpype(prefix="foo-v", version="5.6.4", + suffix=".txt", type="txt", valid=False), + test_openpype(prefix="foo-v", version="5.7.1", + suffix="", type="dir", valid=False), ] test_versions_2 = [ - test_pype(prefix="foo-v", version="10.0.0", - suffix=".txt", type="txt", valid=False), - test_pype(prefix="lom-v", version="7.2.6", - suffix=".zip", type="zip", valid=True), - test_pype(prefix="bom-v", version="7.2.7-client", - suffix=".zip", type="zip", valid=True), - test_pype(prefix="woo-v", version="7.2.8-client-strange", - suffix=".zip", type="zip", valid=True), - test_pype(prefix="loo-v", version="7.2.10-client-staging", - suffix=".zip", type="zip", valid=True), - test_pype(prefix="kok-v", version="7.0.1", - suffix=".zip", type="zip", valid=True) + test_openpype(prefix="foo-v", version="10.0.0", + suffix=".txt", type="txt", valid=False), + test_openpype(prefix="lom-v", version="7.2.6", + suffix=".zip", type="zip", valid=True), + test_openpype(prefix="bom-v", version="7.2.7-rc.3", + suffix=".zip", type="zip", valid=True), + test_openpype(prefix="woo-v", version="7.2.8-foo-strange", + suffix=".zip", type="zip", valid=True), + test_openpype(prefix="loo-v", version="7.2.10-foo+staging", + suffix=".zip", type="zip", valid=True), + test_openpype(prefix="kok-v", version="7.0.1", + suffix=".zip", type="zip", valid=True) ] test_versions_3 = [ - test_pype(prefix="foo-v", version="3.0.0", - suffix=".zip", type="zip", valid=True), - test_pype(prefix="goo-v", version="3.0.1", - suffix=".zip", type="zip", valid=True), - test_pype(prefix="hoo-v", version="4.1.0", - suffix=".zip", type="zip", valid=True), - test_pype(prefix="foo-v", version="4.1.2", - suffix=".zip", type="zip", valid=True), - test_pype(prefix="foo-v", version="3.0.1-client", - suffix=".zip", type="zip", valid=True), - test_pype(prefix="foo-v", version="3.0.1-client-strange", - suffix=".zip", type="zip", valid=True), - test_pype(prefix="foo-v", version="3.0.1-staging", - suffix=".zip", type="zip", valid=True), - test_pype(prefix="foo-v", version="3.0.1-client-staging", - suffix=".zip", type="zip", valid=True), - test_pype(prefix="foo-v", version="3.2.0", - suffix=".zip", type="zip", valid=True) + test_openpype(prefix="foo-v", version="3.0.0", + suffix=".zip", type="zip", valid=True), + test_openpype(prefix="goo-v", version="3.0.1", + suffix=".zip", type="zip", valid=True), + test_openpype(prefix="hoo-v", version="4.1.0", + suffix=".zip", type="zip", valid=True), + test_openpype(prefix="foo-v", version="4.1.2", + suffix=".zip", type="zip", valid=True), + test_openpype(prefix="foo-v", version="3.0.1-foo", + suffix=".zip", type="zip", valid=True), + test_openpype(prefix="foo-v", version="3.0.1-foo-strange", + suffix=".zip", type="zip", valid=True), + test_openpype(prefix="foo-v", version="3.0.1+staging", + suffix=".zip", type="zip", valid=True), + test_openpype(prefix="foo-v", version="3.0.1-foo+staging", + suffix=".zip", type="zip", valid=True), + test_openpype(prefix="foo-v", version="3.2.0", + suffix=".zip", type="zip", valid=True) ] test_versions_4 = [ - test_pype(prefix="foo-v", version="10.0.0", - suffix="", type="dir", valid=True), - test_pype(prefix="lom-v", version="11.2.6", - suffix=".zip", type="dir", valid=False), - test_pype(prefix="bom-v", version="7.2.7-client", - suffix=".zip", type="zip", valid=True), - test_pype(prefix="woo-v", version="7.2.8-client-strange", - suffix=".zip", type="txt", valid=False) + test_openpype(prefix="foo-v", version="10.0.0", + suffix="", type="dir", valid=True), + test_openpype(prefix="lom-v", version="11.2.6", + suffix=".zip", type="dir", valid=False), + test_openpype(prefix="bom-v", version="7.2.7-foo", + suffix=".zip", type="zip", valid=True), + test_openpype(prefix="woo-v", version="7.2.8-foo-strange", + suffix=".zip", type="txt", valid=False) ] def _create_invalid_zip(path: Path): @@ -251,7 +255,7 @@ def test_find_pype(fix_bootstrap, tmp_path_factory, monkeypatch, printer): def _create_valid_zip(path: Path, version: str): with ZipFile(path, "w") as zf: zf.writestr( - "pype/version.py", f"__version__ = '{version}'\n\n") + "openpype/version.py", f"__version__ = '{version}'\n\n") def _create_invalid_dir(path: Path): path.mkdir(parents=True, exist_ok=True) @@ -259,9 +263,9 @@ def test_find_pype(fix_bootstrap, tmp_path_factory, monkeypatch, printer): fp.write("invalid") def _create_valid_dir(path: Path, version: str): - pype_path = path / "pype" - version_path = pype_path / "version.py" - pype_path.mkdir(parents=True, exist_ok=True) + openpype_path = path / "openpype" + version_path = openpype_path / "version.py" + openpype_path.mkdir(parents=True, exist_ok=True) with open(version_path, "w") as fp: fp.write(f"__version__ = '{version}'\n\n") @@ -283,15 +287,15 @@ def test_find_pype(fix_bootstrap, tmp_path_factory, monkeypatch, printer): with open(test_path, "w") as fp: fp.write("foo") - # in PYPE_PATH + # in OPENPYPE_PATH e_path = tmp_path_factory.mktemp("environ") # create files and directories for test for test_file in test_versions_1: _build_test_item(e_path, test_file) - # in pypePath registry - p_path = tmp_path_factory.mktemp("pypePath") + # in openPypePath registry + p_path = tmp_path_factory.mktemp("openPypePath") for test_file in test_versions_2: _build_test_item(p_path, test_file) @@ -310,10 +314,10 @@ def test_find_pype(fix_bootstrap, tmp_path_factory, monkeypatch, printer): for test_file in test_versions_4: _build_test_item(dir_path, test_file) - printer("testing finding Pype in given path ...") - result = fix_bootstrap.find_pype(g_path, include_zips=True) + printer("testing finding OpenPype in given path ...") + result = fix_bootstrap.find_openpype(g_path, include_zips=True) # we should have results as file were created - assert result is not None, "no Pype version found" + assert result is not None, "no OpenPype version found" # latest item in `result` should be latest version found. expected_path = Path( g_path / "{}{}{}".format( @@ -323,13 +327,14 @@ def test_find_pype(fix_bootstrap, tmp_path_factory, monkeypatch, printer): ) ) assert result, "nothing found" - assert result[-1].path == expected_path, "not a latest version of Pype 3" + assert result[-1].path == expected_path, ("not a latest version of " + "OpenPype 3") - monkeypatch.setenv("PYPE_PATH", e_path.as_posix()) + monkeypatch.setenv("OPENPYPE_PATH", e_path.as_posix()) - result = fix_bootstrap.find_pype(include_zips=True) + result = fix_bootstrap.find_openpype(include_zips=True) # we should have results as file were created - assert result is not None, "no Pype version found" + assert result is not None, "no OpenPype version found" # latest item in `result` should be latest version found. expected_path = Path( e_path / "{}{}{}".format( @@ -339,21 +344,23 @@ def test_find_pype(fix_bootstrap, tmp_path_factory, monkeypatch, printer): ) ) assert result, "nothing found" - assert result[-1].path == expected_path, "not a latest version of Pype 1" + assert result[-1].path == expected_path, ("not a latest version of " + "OpenPype 1") - monkeypatch.delenv("PYPE_PATH", raising=False) + monkeypatch.delenv("OPENPYPE_PATH", raising=False) # mock appdirs user_data_dir def mock_user_data_dir(*args, **kwargs): + """Mock local app data dir.""" return d_path.as_posix() monkeypatch.setattr(appdirs, "user_data_dir", mock_user_data_dir) fix_bootstrap.registry = OpenPypeSettingsRegistry() - fix_bootstrap.registry.set_item("pypePath", d_path.as_posix()) + fix_bootstrap.registry.set_item("openPypePath", d_path.as_posix()) - result = fix_bootstrap.find_pype(include_zips=True) + result = fix_bootstrap.find_openpype(include_zips=True) # we should have results as file were created - assert result is not None, "no Pype version found" + assert result is not None, "no OpenPype version found" # latest item in `result` should be latest version found. expected_path = Path( d_path / "{}{}{}".format( @@ -363,10 +370,11 @@ def test_find_pype(fix_bootstrap, tmp_path_factory, monkeypatch, printer): ) ) assert result, "nothing found" - assert result[-1].path == expected_path, "not a latest version of Pype 2" + assert result[-1].path == expected_path, ("not a latest version of " + "OpenPype 2") - result = fix_bootstrap.find_pype(e_path, include_zips=True) - assert result is not None, "no Pype version found" + result = fix_bootstrap.find_openpype(e_path, include_zips=True) + assert result is not None, "no OpenPype version found" expected_path = Path( e_path / "{}{}{}".format( test_versions_1[5].prefix, @@ -374,10 +382,11 @@ def test_find_pype(fix_bootstrap, tmp_path_factory, monkeypatch, printer): test_versions_1[5].suffix ) ) - assert result[-1].path == expected_path, "not a latest version of Pype 1" + assert result[-1].path == expected_path, ("not a latest version of " + "OpenPype 1") - result = fix_bootstrap.find_pype(dir_path, include_zips=True) - assert result is not None, "no Pype versions found" + result = fix_bootstrap.find_openpype(dir_path, include_zips=True) + assert result is not None, "no OpenPype versions found" expected_path = Path( dir_path / "{}{}{}".format( test_versions_4[0].prefix, @@ -385,4 +394,5 @@ def test_find_pype(fix_bootstrap, tmp_path_factory, monkeypatch, printer): test_versions_4[0].suffix ) ) - assert result[-1].path == expected_path, "not a latest version of Pype 4" + assert result[-1].path == expected_path, ("not a latest version of " + "OpenPype 4") diff --git a/tests/pype/lib/test_user_settings.py b/tests/openpype/lib/test_user_settings.py similarity index 95% rename from tests/pype/lib/test_user_settings.py rename to tests/openpype/lib/test_user_settings.py index 02342abbc9..2c58e1f35a 100644 --- a/tests/pype/lib/test_user_settings.py +++ b/tests/openpype/lib/test_user_settings.py @@ -1,5 +1,7 @@ +# -*- coding: utf-8 -*- +"""Test suite for User Settings.""" import pytest -from pype.lib import ( +from igniter.user_settings import ( IniSettingRegistry, JSONSettingRegistry, OpenPypeSecureRegistry @@ -9,9 +11,9 @@ import configparser @pytest.fixture -def secure_registry(tmpdir): +def secure_registry(): name = "pypetest_{}".format(str(uuid4())) - r = OpenPypeSecureRegistry(name, tmpdir) + r = OpenPypeSecureRegistry(name) yield r diff --git a/tools/build.ps1 b/tools/build.ps1 index 566e40cb55..611d8af668 100644 --- a/tools/build.ps1 +++ b/tools/build.ps1 @@ -14,6 +14,12 @@ PS> .\build.ps1 #> +$arguments=$ARGS +$disable_submodule_update="" +if($arguments -eq "--no-submodule-update") { + $disable_submodule_update=$true +} + function Start-Progress { param([ScriptBlock]$code) $scroll = "/-\|/-\|" @@ -134,10 +140,14 @@ catch { Write-Host $_.Exception.Message Exit-WithCode 1 } - -Write-Host ">>> " -NoNewLine -ForegroundColor green -Write-Host "Making sure submodules are up-to-date ..." -git submodule update --init --recursive +if (-not $disable_submodule_update) { + Write-Host ">>> " -NoNewLine -ForegroundColor green + Write-Host "Making sure submodules are up-to-date ..." + git submodule update --init --recursive +} else { + Write-Host "*** " -NoNewLine -ForegroundColor yellow + Write-Host "Not updating submodules ..." +} Write-Host ">>> " -NoNewline -ForegroundColor green Write-Host "OpenPype [ " -NoNewline -ForegroundColor white @@ -164,6 +174,7 @@ Write-Host "OK" -ForegroundColor green Write-Host ">>> " -NoNewline -ForegroundColor green Write-Host "Building OpenPype ..." +$startTime = [int][double]::Parse((Get-Date -UFormat %s)) $out = & poetry run python setup.py build 2>&1 if ($LASTEXITCODE -ne 0) @@ -182,7 +193,8 @@ Write-Host ">>> " -NoNewline -ForegroundColor green Write-Host "restoring current directory" Set-Location -Path $current_dir +$endTime = [int][double]::Parse((Get-Date -UFormat %s)) Write-Host "*** " -NoNewline -ForegroundColor Cyan -Write-Host "All done. You will find OpenPype and build log in " -NoNewLine +Write-Host "All done in $($endTime - $startTime) secs. You will find OpenPype and build log in " -NoNewLine Write-Host "'.\build'" -NoNewline -ForegroundColor Green Write-Host " directory." diff --git a/tools/build.sh b/tools/build.sh index 953d51bd81..ccd97ea4c1 100755 --- a/tools/build.sh +++ b/tools/build.sh @@ -57,6 +57,26 @@ BIPurple='\033[1;95m' # Purple BICyan='\033[1;96m' # Cyan BIWhite='\033[1;97m' # White +args=$@ +disable_submodule_update = 0 +while :; do + case $1 in + --no-submodule-update) + disable_submodule_update=1 + ;; + --) + shift + break + ;; + *) + break + esac + + shift +done + + + ############################################################################## # Detect required version of python @@ -172,9 +192,12 @@ main () { . "$openpype_root/tools/create_env.sh" || { echo -e "${BIRed}!!!${RST} Poetry installation failed"; return; } fi +if [ "$disable_submodule_update" == 1 ]; then echo -e "${BIGreen}>>>${RST} Making sure submodules are up-to-date ..." git submodule update --init --recursive - + else + echo -e "${BIYellow}***${RST} Not updating submodules ..." + fi echo -e "${BIGreen}>>>${RST} Building ..." if [[ "$OSTYPE" == "linux-gnu"* ]]; then poetry run python "$openpype_root/setup.py" build > "$openpype_root/build/build.log" || { echo -e "${BIRed}!!!${RST} Build failed, see the build log."; return; } diff --git a/tools/build_dependencies.py b/tools/build_dependencies.py index fb52e2b5fd..de3b6da021 100644 --- a/tools/build_dependencies.py +++ b/tools/build_dependencies.py @@ -26,10 +26,12 @@ import platform from pathlib import Path import shutil import blessed +import enlighten import time term = blessed.Terminal() +manager = enlighten.get_manager() def _print(msg: str, type: int = 0) -> None: @@ -52,6 +54,24 @@ def _print(msg: str, type: int = 0) -> None: print("{}{}".format(header, msg)) +def count_folders(path: Path) -> int: + """Recursively count items inside given Path. + + Args: + path (Path): Path to count. + + Returns: + int: number of items. + + """ + cnt = 0 + for child in path.iterdir(): + if child.is_dir(): + cnt += 1 + cnt += count_folders(child) + return cnt + + _print("Starting dependency cleanup ...") start_time = time.time_ns() @@ -96,30 +116,55 @@ deps_dir = build_dir / "dependencies" # copy all files _print("Copying dependencies ...") -shutil.copytree(site_pkg.as_posix(), deps_dir.as_posix()) +total_files = count_folders(site_pkg) +progress_bar = enlighten.Counter( + total=total_files, desc="Processing Dependencies", + units="%", color="green") + + +def _progress(_base, _names): + progress_bar.update() + return [] + + +shutil.copytree(site_pkg.as_posix(), + deps_dir.as_posix(), + ignore=_progress) +progress_bar.close() # iterate over frozen libs and create list to delete libs_dir = build_dir / "lib" to_delete = [] -_print("Finding duplicates ...") +# _print("Finding duplicates ...") deps_items = list(deps_dir.iterdir()) +item_count = len(list(libs_dir.iterdir())) +find_progress_bar = enlighten.Counter( + total=item_count, desc="Finding duplicates", units="%", color="yellow") + for d in libs_dir.iterdir(): if (deps_dir / d.name) in deps_items: to_delete.append(d) - _print(f"found {d}", 3) + # _print(f"found {d}", 3) + find_progress_bar.update() +find_progress_bar.close() # add openpype and igniter in libs too to_delete.append(libs_dir / "openpype") to_delete.append(libs_dir / "igniter") # delete duplicates -_print(f"Deleting {len(to_delete)} duplicates ...") +# _print(f"Deleting {len(to_delete)} duplicates ...") +delete_progress_bar = enlighten.Counter( + total=len(to_delete), desc="Deleting duplicates", units="%", color="red") for d in to_delete: if d.is_dir(): shutil.rmtree(d) else: d.unlink() + delete_progress_bar.update() + +delete_progress_bar.close() end_time = time.time_ns() total_time = (end_time - start_time) / 1000000000 diff --git a/tools/run_project_manager.ps1 b/tools/run_project_manager.ps1 new file mode 100644 index 0000000000..78dce19df1 --- /dev/null +++ b/tools/run_project_manager.ps1 @@ -0,0 +1,18 @@ +<# +.SYNOPSIS + Helper script OpenPype Tray. + +.DESCRIPTION + + +.EXAMPLE + +PS> .\run_tray.ps1 + +#> +$current_dir = Get-Location +$script_dir = Split-Path -Path $MyInvocation.MyCommand.Definition -Parent +$openpype_root = (Get-Item $script_dir).parent.FullName +Set-Location -Path $openpype_root +& poetry run python "$($openpype_root)\start.py" projectmanager +Set-Location -Path $current_dir diff --git a/tools/run_tests.sh b/tools/run_tests.sh index 5488be9430..3620ebc0e5 100755 --- a/tools/run_tests.sh +++ b/tools/run_tests.sh @@ -122,5 +122,4 @@ main () { PYTHONPATH=$original_pythonpath } - - +main diff --git a/website/README.md b/website/README.md new file mode 100644 index 0000000000..c6f13273e8 --- /dev/null +++ b/website/README.md @@ -0,0 +1,28 @@ +When developing on Windows make sure `start.sh` has the correct line endings (`LF`). + +Start via yarn: +--------------- +Clone repository + +Install yarn if not already installed (https://classic.yarnpkg.com/en/docs/install) +For example via npm (but could be installed differently too) + + ```npm install --global yarn``` + +Then go to `website` folder + + ```yarn install``` (takes a while) + +To start local test server: + + ```yarn start``` + +Server is accessible by default on http://localhost:3000 + +Start via docker: +----------------- +Setting for docker container: +```bash +docker build . -t pype-docs +docker run --rm -p 3000:3000 -v /c/Users/admin/openpype.io:/app pype-docs +``` diff --git a/website/docs/admin_hosts_aftereffects.md b/website/docs/admin_hosts_aftereffects.md new file mode 100644 index 0000000000..dc43820465 --- /dev/null +++ b/website/docs/admin_hosts_aftereffects.md @@ -0,0 +1,39 @@ +--- +id: admin_hosts_aftereffects +title: AfterEffects Settings +sidebar_label: AfterEffects +--- + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +## AfterEffects settings + +There is a couple of settings that could configure publishing process for **AfterEffects**. +All of them are Project based, eg. each project could have different configuration. + +Location: Settings > Project > AfterEffects + +![Harmony Project Settings](assets/admin_hosts_aftereffects_settings.png) + +## Publish plugins + +### Validate Scene Settings + +#### Skip Resolution Check for Tasks + +Set regex pattern(s) to look for in a Task name to skip resolution check against values from DB. + +#### Skip Timeline Check for Tasks + +Set regex pattern(s) to look for in a Task name to skip `frameStart`, `frameEnd` check against values from DB. + +### AfterEffects Submit to Deadline + +* `Use Published scene` - Set to True (green) when Deadline should take published scene as a source instead of uploaded local one. +* `Priority` - priority of job on farm +* `Primary Pool` - here is list of pool fetched from server you can select from. +* `Secondary Pool` +* `Frames Per Task` - number of sequence division between individual tasks (chunks) +making one job on farm. + diff --git a/website/docs/admin_hosts_harmony.md b/website/docs/admin_hosts_harmony.md new file mode 100644 index 0000000000..2c49d8ba73 --- /dev/null +++ b/website/docs/admin_hosts_harmony.md @@ -0,0 +1,51 @@ +--- +id: admin_hosts_harmony +title: ToonBoom Harmony Settings +sidebar_label: ToonBoom Harmony +--- + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +## ToonBoom Harmony settings + +There is a couple of settings that could configure publishing process for **ToonBoom Harmony**. +All of them are Project based, eg. each project could have different configuration. + +Location: Settings > Project > Harmony + +![Harmony Project Settings](assets/admin_hosts_harmony_settings.png) + +## Publish plugins + +### Collect Palettes + +#### Allowed tasks + +Set regex pattern(s) only for task names when publishing of Palettes should occur. + +Use ".*" to publish Palettes for ALL tasks. + +### Validate Scene Settings + +#### Skip Frame check for Assets with + +Set regex pattern(s) for filtering Asset name that should skip validation of `frameEnd` value from DB. + +#### Skip Resolution Check for Tasks + +Set regex pattern(s) for filtering Asset name that should skip validation or `Resolution` value from DB. + +#### Skip Timeline Check for Tasks + +Set regex pattern(s) for filtering Task name that should skip validation `frameStart`, `frameEnd` check against values from DB. + +### Harmony Submit to Deadline + +* `Use Published scene` - Set to True (green) when Deadline should take published scene as a source instead of uploaded local one. +* `Priority` - priority of job on farm +* `Primary Pool` - here is list of pool fetched from server you can select from. +* `Secondary Pool` +* `Frames Per Task` - number of sequence division between individual tasks (chunks) +making one job on farm. + diff --git a/website/docs/admin_hosts_maya.md b/website/docs/admin_hosts_maya.md new file mode 100644 index 0000000000..83c4121be9 --- /dev/null +++ b/website/docs/admin_hosts_maya.md @@ -0,0 +1,52 @@ +--- +id: admin_hosts_maya +title: Maya +sidebar_label: Maya +--- + +## Maya + +### Publish Plugins + +#### Render Settings Validator (`ValidateRenderSettings`) + +Render Settings Validator is here to make sure artists will submit renders +we correct settings. Some of these settings are needed by OpenPype but some +can be defined by TD using [OpenPype Settings UI](admin_settings). + +OpenPype enforced settings include: + +- animation must be enabled in output +- render prefix must start with `maya/` to make sure renders are in +correct directory +- there must be `` or its equivalent in different renderers in +file prefix +- if multiple cameras are to be rendered, `` token must be in file prefix + +For **Vray**: +- AOV separator must be set to `_` (underscore) + +For **Redshift**: +- all AOVs must follow `/_` image file prefix +- AOV image format must be same as the one set in Output settings + +For **Renderman**: +- both image and directory prefixes must comply to `_..` and `/renders/maya//` respectively + +For **Arnold**: +- there shouldn't be `` token when merge AOVs option is turned on + + +Additional check can be added via Settings - **Project Settings > Maya > Publish plugin > ValidateRenderSettings**. +You can add as many options as you want for every supported renderer. In first field put node type and attribute +and in the second required value. + +![Settings example](assets/maya-admin_render_settings_validator.png) + +In this example we've put `aiOptions.AA_samples` in first one and `6` to second to enforce +Arnolds Camera (AA) samples to 6. + +Note that `aiOptions` is not the name of node but rather its type. For renderers there is usually +just one instance of this node type but if that is not so, validator will go through all its +instances and check the value there. Node type for **VRay** settings is `VRaySettingsNode`, for **Renderman** +it is `rmanGlobals`, for **Redshift** it is `RedshiftOptions`. \ No newline at end of file diff --git a/website/docs/artist_tools.md b/website/docs/artist_tools.md index f03ea8e249..5bc3f4c1fd 100644 --- a/website/docs/artist_tools.md +++ b/website/docs/artist_tools.md @@ -142,6 +142,22 @@ You can set group of selected subsets with shortcut `Ctrl + G`. You'll set the group in Avalon database so your changes will take effect for all users. ::: +### Site Sync support + +If **Site Sync** is enabled additional widget is shown in right bottom corner. +It contains list of all representations of selected version(s). It also shows availability of representation files +on particular site (*active* - mine, *remote* - theirs). + +![site_sync_support](assets/site_sync_loader.png) + +On this picture you see that representation files are available only on remote site (could be GDrive or other). +If artist wants to work with the file(s) they need to be downloaded first. That could be done by right mouse click on +particular representation (or multiselect all) and select *Download*. + +This will mark representation to be download which will happen in the background if OpenPype Tray is running. + +For more details of progress, state or possible error details artist should open **[Sync Queue](#Sync-Queue)** item in Tray app. + Work in progress... ## Library Loader @@ -412,3 +428,35 @@ It might also happen that user deletes underlying host item(for example layer in This could result in phantom issues during publishing. Use Subset Manager to purge workfile from abandoned items. Please check behaviour in host of your choice. + +## Sync Queue + +### Details + +If **Site Sync** is configured for a project, each asset is marked to be synchronized to a remote site during publishing. +Each artist's OpenPype Tray application handles synchronization in background, it looks for all representation which +are marked with the site of the user (unique site name per artist) and remote site. + +Artists then can see progress of synchronization via **Sync Queue** link in the Tray application. + +Artists can see all synced representation in this dialog with helpful information such as when representation was created, when it was synched, +status of synchronization (OK or Fail) etc. + +### Usage + +With this app artists can modify synchronized representation, for example mark failed representation for re-sync etc. + +![Sync Queue](assets/site_sync_sync_queue.png) + +Actions accessible by context menu on single (or multiple representations): +- *Open in Explorer* - if site is locally accessible, open folder with it with OS based explorer +- *Re-sync Active Site* - mark artist own side for re-download (repre must be accessible on remote side) +- *Re-sync Remote Site* - mark representation for re-upload +- *Completely remove from local* - removes tag of synchronization to artist's local site, removes files from disk (available only for personal sites) +- *Change priority* - mark representations with higher priority for faster synchronization run + +Double click on any of the representation open Detail dialog with information about all files for particular representation. +In this dialog error details could be accessed in the context menu. + +Artists can also Pause whole server or specific project for synchronization. In that state no download/upload is being run. +This might be helpful if the artist is not interested in a particular project for a while or wants to save bandwidth data limit for a bit. \ No newline at end of file diff --git a/website/docs/assets/admin_hosts_aftereffects_settings.png b/website/docs/assets/admin_hosts_aftereffects_settings.png new file mode 100644 index 0000000000..9b879585f8 Binary files /dev/null and b/website/docs/assets/admin_hosts_aftereffects_settings.png differ diff --git a/website/docs/assets/admin_hosts_harmony_settings.png b/website/docs/assets/admin_hosts_harmony_settings.png new file mode 100644 index 0000000000..800a64e986 Binary files /dev/null and b/website/docs/assets/admin_hosts_harmony_settings.png differ diff --git a/website/docs/assets/maya-admin_render_settings_validator.png b/website/docs/assets/maya-admin_render_settings_validator.png new file mode 100644 index 0000000000..8687b538b1 Binary files /dev/null and b/website/docs/assets/maya-admin_render_settings_validator.png differ diff --git a/website/docs/assets/site_sync_gdrive_user.png b/website/docs/assets/site_sync_gdrive_user.png new file mode 100644 index 0000000000..cfffcc644c Binary files /dev/null and b/website/docs/assets/site_sync_gdrive_user.png differ diff --git a/website/docs/assets/site_sync_loader.png b/website/docs/assets/site_sync_loader.png new file mode 100644 index 0000000000..8792f6c9d9 Binary files /dev/null and b/website/docs/assets/site_sync_loader.png differ diff --git a/website/docs/assets/site_sync_local_setting.png b/website/docs/assets/site_sync_local_setting.png new file mode 100644 index 0000000000..6af1b8aaca Binary files /dev/null and b/website/docs/assets/site_sync_local_setting.png differ diff --git a/website/docs/assets/site_sync_project_settings.png b/website/docs/assets/site_sync_project_settings.png new file mode 100644 index 0000000000..3b31566616 Binary files /dev/null and b/website/docs/assets/site_sync_project_settings.png differ diff --git a/website/docs/assets/site_sync_sync_queue.png b/website/docs/assets/site_sync_sync_queue.png new file mode 100644 index 0000000000..0fc7d0491d Binary files /dev/null and b/website/docs/assets/site_sync_sync_queue.png differ diff --git a/website/docs/assets/site_sync_system.png b/website/docs/assets/site_sync_system.png new file mode 100644 index 0000000000..0c455eca37 Binary files /dev/null and b/website/docs/assets/site_sync_system.png differ diff --git a/website/docs/changelog.md b/website/docs/changelog.md index bec4a02173..57048b9398 100644 --- a/website/docs/changelog.md +++ b/website/docs/changelog.md @@ -4,6 +4,101 @@ title: Changelog sidebar_label: Changelog --- +## [2.18.0](https://github.com/pypeclub/openpype/tree/2.18.0) +_**release date:** (2021-05-18)_ + +[Full Changelog](https://github.com/pypeclub/openpype/compare/2.17.3...2.18.0) + +**Enhancements:** + +- Use SubsetLoader and multiple contexts for delete_old_versions [\#1484](ttps://github.com/pypeclub/OpenPype/pull/1484)) +- TVPaint: Increment workfile version on successfull publish. [\#1489](https://github.com/pypeclub/OpenPype/pull/1489) +- Maya: Use of multiple deadline servers [\#1483](https://github.com/pypeclub/OpenPype/pull/1483) + +**Fixed bugs:** + +- Use instance frame start instead of timeline. [\#1486](https://github.com/pypeclub/OpenPype/pull/1486) +- Maya: Redshift - set proper start frame on proxy [\#1480](https://github.com/pypeclub/OpenPype/pull/1480) +- Maya: wrong collection of playblasted frames [\#1517](https://github.com/pypeclub/OpenPype/pull/1517) +- Existing subsets hints in creator [\#1502](https://github.com/pypeclub/OpenPype/pull/1502) + + +### [2.17.3](https://github.com/pypeclub/openpype/tree/2.17.3) +_**release date:** (2021-05-06)_ + +[Full Changelog](https://github.com/pypeclub/openpype/compare/CI/3.0.0-rc.3...2.17.3) + +**Fixed bugs:** + +- Nuke: workfile version synced to db version always [\#1479](https://github.com/pypeclub/OpenPype/pull/1479) + +### [2.17.2](https://github.com/pypeclub/openpype/tree/2.17.2) +_**release date:** (2021-05-04)_ + +[Full Changelog](https://github.com/pypeclub/openpype/compare/CI/3.0.0-rc.1...2.17.2) + +**Enhancements:** + +- Forward/Backward compatible apps and tools with OpenPype 3 [\#1463](https://github.com/pypeclub/OpenPype/pull/1463) + +### [2.17.1](https://github.com/pypeclub/openpype/tree/2.17.1) +_**release date:** (2021-04-30)_ + +[Full Changelog](https://github.com/pypeclub/openpype/compare/2.17.0...2.17.1) + +**Enhancements:** + +- Faster settings UI loading [\#1442](https://github.com/pypeclub/OpenPype/pull/1442) +- Nuke: deadline submission with gpu [\#1414](https://github.com/pypeclub/OpenPype/pull/1414) +- TVPaint frame range definition [\#1424](https://github.com/pypeclub/OpenPype/pull/1424) +- PS - group all published instances [\#1415](https://github.com/pypeclub/OpenPype/pull/1415) +- Add task name to context pop up. [\#1383](https://github.com/pypeclub/OpenPype/pull/1383) +- Enhance review letterbox feature. [\#1371](https://github.com/pypeclub/OpenPype/pull/1371) +- AE add duration validation [\#1363](https://github.com/pypeclub/OpenPype/pull/1363) + +**Fixed bugs:** + +- Houdini menu filename [\#1417](https://github.com/pypeclub/OpenPype/pull/1417) +- Nuke: fixing undo for loaded mov and sequence [\#1433](https://github.com/pypeclub/OpenPype/pull/1433) +- AE - validation for duration was 1 frame shorter [\#1426](https://github.com/pypeclub/OpenPype/pull/1426) + +**Merged pull requests:** + +- Maya: Vray - problem getting all file nodes for look publishing [\#1399](https://github.com/pypeclub/OpenPype/pull/1399) +- Maya: Support for Redshift proxies [\#1360](https://github.com/pypeclub/OpenPype/pull/1360) + +## [2.17.0](https://github.com/pypeclub/openpype/tree/2.17.0) +_**release date:** (2021-04-20)_ + +[Full Changelog](https://github.com/pypeclub/openpype/compare/CI/3.0.0-beta.2...2.17.0) + +**Enhancements:** + +- Forward compatible ftrack group [\#1243](https://github.com/pypeclub/OpenPype/pull/1243) +- Maya: Make tx option configurable with presets [\#1328](https://github.com/pypeclub/OpenPype/pull/1328) +- TVPaint asset name validation [\#1302](https://github.com/pypeclub/OpenPype/pull/1302) +- TV Paint: Set initial project settings. [\#1299](https://github.com/pypeclub/OpenPype/pull/1299) +- TV Paint: Validate mark in and out. [\#1298](https://github.com/pypeclub/OpenPype/pull/1298) +- Validate project settings [\#1297](https://github.com/pypeclub/OpenPype/pull/1297) +- After Effects: added SubsetManager [\#1234](https://github.com/pypeclub/OpenPype/pull/1234) +- Show error message in pyblish UI [\#1206](https://github.com/pypeclub/OpenPype/pull/1206) + +**Fixed bugs:** + +- Hiero: fixing source frame from correct object [\#1362](https://github.com/pypeclub/OpenPype/pull/1362) +- Nuke: fix colourspace, prerenders and nuke panes opening [\#1308](https://github.com/pypeclub/OpenPype/pull/1308) +- AE remove orphaned instance from workfile - fix self.stub [\#1282](https://github.com/pypeclub/OpenPype/pull/1282) +- Nuke: deadline submission with search replaced env values from preset [\#1194](https://github.com/pypeclub/OpenPype/pull/1194) +- Ftrack custom attributes in bulks [\#1312](https://github.com/pypeclub/OpenPype/pull/1312) +- Ftrack optional pypclub role [\#1303](https://github.com/pypeclub/OpenPype/pull/1303) +- After Effects: remove orphaned instances [\#1275](https://github.com/pypeclub/OpenPype/pull/1275) +- Avalon schema names [\#1242](https://github.com/pypeclub/OpenPype/pull/1242) +- Handle duplication of Task name [\#1226](https://github.com/pypeclub/OpenPype/pull/1226) +- Modified path of plugin loads for Harmony and TVPaint [\#1217](https://github.com/pypeclub/OpenPype/pull/1217) +- Regex checks in profiles filtering [\#1214](https://github.com/pypeclub/OpenPype/pull/1214) +- Update custom ftrack session attributes [\#1202](https://github.com/pypeclub/OpenPype/pull/1202) +- Nuke: write node colorspace ignore `default\(\)` label [\#1199](https://github.com/pypeclub/OpenPype/pull/1199) + ## [2.16.0](https://github.com/pypeclub/pype/tree/2.16.0) _**release date:** 2021-03-22_ diff --git a/website/docs/module_site_sync.md b/website/docs/module_site_sync.md index 1b728e151a..6ee6660048 100644 --- a/website/docs/module_site_sync.md +++ b/website/docs/module_site_sync.md @@ -8,3 +8,105 @@ import Tabs from '@theme/Tabs'; import TabItem from '@theme/TabItem'; +:::warning +**This feature is** currently **in a beta stage** and it is not recommended to rely on it fully for production. +::: + +Site Sync allows users and studios to synchronize published assets between multiple 'sites'. Site denotes a storage location, +which could be a physical disk, server, cloud storage. To be able to use site sync, it first needs to be configured. + +The general idea is that each user acts as an individual site and can download and upload any published project files when they are needed. that way, artist can have access to the whole project, but only every store files that are relevant to them on their home workstation. + +:::note +At the moment site sync is only able to deal with publishes files. No workfiles will be synchronized unless they are published. We are working on making workfile synchronization possible as well. +::: + +## System Settings + +To use synchronization, *Site Sync* needs to be enabled globally in **OpenPype Settings/System/Modules/Site Sync**. + +![Configure module](assets/site_sync_system.png) + + +## Project Settings + +Sites need to be made available for each project. Of course this is possible to do on the default project as well, in which case all other projects will inherit these settings until overriden explicitly. + +You'll find the setting in **Settings/Project/Global/Site Sync** + +The attributes that can be configured will vary between sites and their providers. + +## Local settings + +Each user should configure root folder for their 'local' site via **Local Settings** in OpenPype Tray. This folder will be used for all files that the user publishes or downloads while working on a project. Artist has the option to set the folder as "default"in which case it is used for all the projects, or it can be set on a project level individually. + +Artists can also override which site they use as active and remote if need be. + +![Local overrides](assets/site_sync_local_setting.png) + + +## Sites + +By default there are two sites created for each OpenPype installation: +- **studio** - default site - usually a centralized mounted disk accessible to all artists. Studio site is used if Site Sync is disabled. +- **local** - each workstation or server running OpenPype Tray receives its own with unique site name. Workstation refers to itself as "local"however all other sites will see it under it's unique ID. + +Artists can explore their site ID by opening OpenPype Info tool by clicking on a version number in the tray app. + +Many different sites can be created and configured on the system level, and some or all can be assigned to each project. + +Each OpenPype Tray app works with two sites at one time. (Sites can be the same, and no synching is done in this setup). + +Sites could be configured differently per project basis. + + +## Providers + +Each site implements a so called `provider` which handles most common operations (list files, copy files etc.) and provides interface with a particular type of storage. (disk, gdrive, aws, etc.) +Multiple configured sites could share the same provider with different settings (multiple mounted disks - each disk can be a separate site, while +all share the same provider). + +**Currently implemented providers:** + +### Local Drive + +Handles files stored on disk storage. + +Local drive provider is the most basic one that is used for accessing all standard hard disk storage scenarios. It will work with any storage that can be mounted on your system in a standard way. This could correspond to a physical external hard drive, network mounted storage, internal drive or even VPN connected network drive. It doesn't care about how te drive is mounted, but you must be able to point to it with a simple directory path. + +Default sites `local` and `studio` both use local drive provider. + + +### Google Drive + +Handles files on Google Drive (this). GDrive is provided as a production example for implementing other cloud providers + +Let's imagine a small globally distributed studio which wants all published work for all their freelancers uploaded to Google Drive folder. + +For this use case admin needs to configure: +- how many times it tries to synchronize file in case of some issue (network, permissions) +- how often should synchronization check for new assets +- sites for synchronization - 'local' and 'gdrive' (this can be overriden in local settings) +- user credentials +- root folder location on Google Drive side + +Configuration would look like this: + +![Configure project](assets/site_sync_project_settings.png) + +*Site Sync* for Google Drive works using its API: https://developers.google.com/drive/api/v3/about-sdk + +To configure Google Drive side you would need to have access to Google Cloud Platform project: https://console.cloud.google.com/ + +To get working connection to Google Drive there are some necessary steps: +- first you need to enable GDrive API: https://developers.google.com/drive/api/v3/enable-drive-api +- next you need to create user, choose **Service Account** (for basic configuration no roles for account are necessary) +- add new key for created account and download .json file with credentials +- share destination folder on the Google Drive with created account (directly in GDrive web application) +- add new site back in OpenPype Settings, name as you want, provider needs to be 'gdrive' +- distribute credentials file via shared mounted disk location + +### Custom providers + +If a studio needs to use other services for cloud storage, or want to implement totally different storage providers, they can do so by writing their own provider plugin. We're working on a developer documentation, however, for now we recommend looking at `abstract_provider.py`and `gdrive.py` inside `openpype/modules/sync_server/providers` and using it as a template. + diff --git a/website/sidebars.js b/website/sidebars.js index 6eec02b2b5..c9edf5e3b7 100644 --- a/website/sidebars.js +++ b/website/sidebars.js @@ -84,7 +84,10 @@ module.exports = { label: "Integrations", items: [ "admin_hosts_blender", - "admin_hosts_resolve" + "admin_hosts_maya", + "admin_hosts_resolve", + "admin_hosts_harmony", + "admin_hosts_aftereffects" ], }, { diff --git a/website/src/pages/features.js b/website/src/pages/features.js index 35e156a230..d5c036eb89 100644 --- a/website/src/pages/features.js +++ b/website/src/pages/features.js @@ -37,15 +37,21 @@ const key_features = [ docs: "/docs/artist_tools#publisher", }, { - label: "Inventory", + label: "Scene manager", description: "Universal GUI for managing versions of assets loaded into your working scene.", docs: "docs/artist_tools#inventory", - }, - { + }, + { + label: "Project manager", + docs: "", + description: + "Tools for creating shots, assets and task within your project if you don't use third party project management", + }, + { label: "Library Loader", description: - "A loader GUI that allows yo to load content from dedicated cross project asset library", + "A loader GUI that allows yo to load content from dedicated cross project asset library", docs: "docs/artist_tools#library-loader", image: "", }, @@ -56,12 +62,6 @@ const key_features = [ "A standalone GUI for publishing data into pipeline without going though DCC app.", image: "", }, - { - label: "Pype Tray", - link: "", - description: - "Cross platform wrapper app, which is the central point of pype. All other tools are ran from here.", - }, { label: "App Launcher", link: "", @@ -69,14 +69,26 @@ const key_features = [ "Standalone GUI for launching application in the chosen context directly from tray", }, { - label: "Timer Manager", + label: "Configuration GUI", link: "", + description: + "All settings and configuration are done via openPype Settings tool. No need to dig around .json and .yaml", + }, + { + label: "Site Sync", + docs: "docs/module_site_sync", + description: + "Built in file synchronization between your central storage (cloud or physical) and all your freelancers", + }, + { + label: "Timers Manager", + link: "docs/admin_settings_system#timers-manager", description: "Service for monitoring the user activity to start, stop and synchronise time tracking.", }, { label: "Farm rendering", - link: "", + docs: "docs/module_deadline", description: "Integrations with Deadline and Muster render managers. Render, publish and generate reviews on the farm.", }, @@ -93,10 +105,10 @@ const key_features = [ "System for simple scene building. Loads pre-defined publishes to scene with single click, speeding up scene preparation.", }, { - label: "Configuration GUI", - link: "", + label: "Reviewables", + docs: "docs/project_settings/settings_project_global#extract-review", description: - "All settings and configuration are done via openPype Settings tool. No need to dig around .json and .yaml", + "Generate automated reviewable quicktimes and sequences in any format, with metadata burnins.", }, ]; @@ -109,6 +121,10 @@ const ftrack = [ docs: "docs/manager_ftrack#project-management", label: "Project Setup", description: "Quickly sets up project with customisable pre-defined structure and attributes." + }, { + docs: "docs/module_ftrack#update-status-on-task-action", + label: "Automate statuses", + description: "Quickly sets up project with customisable pre-defined structure and attributes." }, { docs: "docs/admin_ftrack#event-server", label: "Event Server", @@ -118,7 +134,7 @@ const ftrack = [ label: "Review publishing", description: "All reviewables from all DCC aps, including farm renders are pushed to ftrack online review." }, { - docs: "", + docs: "docs/admin_settings_system#timers-manager", label: "Auto Time Tracker", description: "Automatically starts and stops ftrack time tracker, base on artist activity." } @@ -185,8 +201,8 @@ const maya_features = [ description:"Makes all your playblasts consistent, with burnins and correct viewport settings" }, { - label: "Model > Render", - description:"We cover full project data flow from model through animation, till final render.", + label: "Renderlayers and AOVs", + description:"Full support of rendersetup layers and AOVs in all major renderers.", docs: "docs/artist_hosts_maya#working-with-pype-in-maya" }, { @@ -211,6 +227,7 @@ const maya_families = [ {label:"VDB Cache"}, {label:"Assembly"}, {label:"Camera"}, + {label:"CameraRig"}, {label:"RenderSetup"}, {label:"Render"}, {label:"Plate"}, @@ -231,7 +248,7 @@ const nuke_features = [ docs: "docs/artist_hosts_nuke#set-colorspace" }, { label: "Script Building", - description:"Automatically build first workfiles from published plates or renders", + description:"Automatically build initial workfiles from published plates or renders", docs: "docs/artist_hosts_nuke#build-first-work-file" }, { @@ -254,10 +271,8 @@ const nuke_families = [ {label: "Render"}, {label: "Plate"}, {label: "Review"}, - {label: "Group"}, {label: "Workfile"}, {label: "LUT"}, - {label: "Cache"}, {label: "Gizmo"}, {label: "Prerender"}, ] @@ -294,6 +309,26 @@ const deadline_families = [ ] const hiero_features = [ + { + label: "Project setup", + description:"Automatic colour, timeline and fps setup of you hiero project." + }, + { + label: "Create shots", + description:"Populate project with shots based on your conformed edit." + }, + { + label: "Publish plates", + description:"Publish multiple tracks with plates to you shots from a single timeline." + }, + { + label: "Retimes", + description:"Publish retime information for individual plates." + }, + { + label: "LUTS and fx", + description:"Publish soft effects from your timeline to be used on shots." + }, ] const hiero_families = [ @@ -330,7 +365,6 @@ const houdini_families = [ {label:"Point Cache"}, {label:"VDB Cache"}, {label:"Camera"}, - {label:"Review"}, {label:"Workfile"}, ] @@ -355,11 +389,29 @@ const harmony_families = [ {label: "Workfile"} ] +const tvpaint_families = [ + {label: "Render"}, + {label: "Review"}, + {label: "Image"}, + {label: "Audio"}, + {label: "Workfile"} +] + const photoshop_families = [ {label: "Render"}, {label: "Plate"}, {label: "Image"}, {label: "LayeredImage"}, + {label: "Background"}, + {label: "Workfile"} +] + +const aftereffects_families = [ + {label: "Render"}, + {label: "Plate"}, + {label: "Image"}, + {label: "Audio"}, + {label: "Background"}, {label: "Workfile"} ] @@ -512,9 +564,15 @@ function Home() {
-

Autodesk Maya

+

Autodesk Maya

versions 2017 and higher

+

+ OpenPype includes very robust Maya implementation that can handle full CG workflow from model, + through animation till final renders. Scene settings, Your artists won't need to touch file browser at all and OpenPype will + take care of all the file management. Most of maya workflows are supported including gpucaches, referencing, nested references and render proxies. +

+ {maya_features && maya_features.length && (
{maya_features.map((props, idx) => ( @@ -537,7 +595,7 @@ function Home() {
-

Foundry Nuke | NukeX

+

Foundry Nuke | NukeX

versions 11.0 and higher

@@ -563,10 +621,18 @@ function Home() {
-

Foundry Hiero | Nuke Studio

+

Foundry Hiero | Nuke Studio

versions 11.0 and higher

+ {hiero_features && hiero_features.length && ( +
+ {hiero_features.map((props, idx) => ( + + ))} +
+ )} +

Supported Families

{hiero_families && hiero_families.length && ( @@ -579,6 +645,78 @@ function Home() {
+
+
+

After Effects

+ +

versions 2020 and higher

+ +

Supported Families

+ + {aftereffects_families && aftereffects_families.length && ( +
+ {aftereffects_families.map((props, idx) => ( + + ))} +
+ )} +
+
+ +
+
+

Photoshop

+ +

versions 2020 and higher

+ +

Supported Families

+ + {photoshop_families && photoshop_families.length && ( +
+ {photoshop_families.map((props, idx) => ( + + ))} +
+ )} +
+
+ +
+
+

Harmony

+ +

versions 17 and higher

+ +

Supported Families

+ + {harmony_families && harmony_families.length && ( +
+ {harmony_families.map((props, idx) => ( + + ))} +
+ )} +
+
+ +
+
+

TV Paint

+ +

versions 11

+ +

Supported Families

+ + {tvpaint_families && tvpaint_families.length && ( +
+ {tvpaint_families.map((props, idx) => ( + + ))} +
+ )} +
+
+

Houdini

@@ -600,9 +738,9 @@ function Home() {
-

Blender

+

Blender

-

versions 2.80 and higher

+

versions 2.83 and higher

Supported Families

@@ -636,44 +774,6 @@ function Home() {
-
-
-

Harmony

- -

versions 17 and higher

- -

Supported Families

- - {harmony_families && harmony_families.length && ( -
- {harmony_families.map((props, idx) => ( - - ))} -
- )} -
-
- - -
-
-

Photoshop

- -

versions 2020 and higher

- -

Supported Families

- - {photoshop_families && photoshop_families.length && ( -
- {photoshop_families.map((props, idx) => ( - - ))} -
- )} -
-
- - ); }